diff --git a/KNOWLEDGE_BASE.md b/KNOWLEDGE_BASE.md index 4f6391d9f..a1ba473aa 100644 --- a/KNOWLEDGE_BASE.md +++ b/KNOWLEDGE_BASE.md @@ -510,7 +510,7 @@ Settings → Appearance offers: These appearance choices are saved in the browser, not on the account, so each browser keeps its own. Settings → Profile lets you choose one of six lane colors (Orange, Green, Violet, Teal, Rose, Ochre), a custom color, or the automatic color used around your identity and activity graph. -Profile settings also let you share a timezone. Timestamps can show the author's local time as well as the viewer's when a timezone is shared. Leaving it unset uses the browser's timezone for your display without publishing your timezone. A separate, off-by-default setting shows your current local time on your profile, next to your location and website; it only applies while a timezone is set, and clearing the timezone turns it off. These /dev/null preferences are separate from your GitHub-managed profile information. +Profile settings also let you share a timezone. Timestamps can show the author's local time as well as the viewer's when a timezone is shared. Leaving it unset uses the browser's timezone for your display without publishing your timezone. A separate, off-by-default setting shows your current local time on your profile, next to your location and website, in direct Remote headers, and beside members who have opted in on group Remotes. It only applies while a timezone is set, and clearing the timezone turns it off. These /dev/null preferences are separate from your GitHub-managed profile information. Sources: [appearance settings](resources/js/pages/appearance/update.tsx), [lane picker](resources/js/components/lane-picker.tsx), [timezone picker](resources/js/components/timezone-picker.tsx), [local time](resources/js/components/local-time.tsx), [timestamp tooltip](resources/js/components/timestamp-tooltip.tsx). diff --git a/app/Http/Controllers/ConversationController.php b/app/Http/Controllers/ConversationController.php index e8f486958..6fd1366a0 100644 --- a/app/Http/Controllers/ConversationController.php +++ b/app/Http/Controllers/ConversationController.php @@ -7,6 +7,7 @@ use App\Actions\UpdateConversationReadThrough; use App\Http\Requests\ListConversationsRequest; use App\Http\Requests\ShowConversationRequest; +use App\Http\Resources\ConversationResource; use App\Models\Conversation; use App\Models\User; use App\Queries\ConversationsQuery; @@ -20,7 +21,12 @@ public function index(ListConversationsRequest $request, #[CurrentUser] User $user, ConversationsQuery $conversations): Response { return Inertia::render('conversation/index', [ - 'inbox' => fn (): array => $conversations->get($user), + 'inbox' => function () use ($request, $user, $conversations): array { + $inbox = $conversations->get($user); + $inbox['conversations'] = ConversationResource::collection($inbox['conversations'])->resolve($request); + + return $inbox; + }, 'partner' => null, 'thread' => null, ]); @@ -44,8 +50,16 @@ public function show(ShowConversationRequest $request, #[CurrentUser] User $user return $thread; }, - 'inbox' => fn (): array => $conversations->get($user), - 'partner' => $partner, + 'inbox' => function () use ($request, $user, $conversations): array { + $inbox = $conversations->get($user); + $inbox['conversations'] = ConversationResource::collection($inbox['conversations'])->resolve($request); + + return $inbox; + }, + 'partner' => [ + ...$partner->toArray(), + 'timezone' => $partner->show_local_time ? $partner->timezone : null, + ], ]); } } diff --git a/app/Http/Controllers/InboxController.php b/app/Http/Controllers/InboxController.php index ce8b3dc5d..70a6f2829 100644 --- a/app/Http/Controllers/InboxController.php +++ b/app/Http/Controllers/InboxController.php @@ -5,6 +5,7 @@ namespace App\Http\Controllers; use App\Http\Requests\ListInboxRequest; +use App\Http\Resources\ConversationResource; use App\Models\User; use App\Queries\ConversationsQuery; use Illuminate\Container\Attributes\CurrentUser; @@ -14,10 +15,15 @@ { public function index(ListInboxRequest $request, #[CurrentUser] User $user, ConversationsQuery $conversations): JsonResponse { + $inbox = $conversations->get($user, $request->tab(), $request->term(), $request->beforeId()); + $inbox['conversations'] = ConversationResource::collection($inbox['conversations'])->resolve($request); + return response()->json([ ...($request->notificationBaseline() ? ['notification_baseline' => $conversations->notificationBaseline($user)] : []), - ...$conversations->get($user, $request->tab(), $request->term(), $request->beforeId()), - ...($request->notificationConversations() ? ['notification_conversations' => $conversations->get($user, pinnedFirst: false)['conversations']] : []), + ...$inbox, + ...($request->notificationConversations() ? [ + 'notification_conversations' => ConversationResource::collection($conversations->get($user, pinnedFirst: false)['conversations'])->resolve($request), + ] : []), 'unread' => $conversations->unreadCount($user), 'requests' => $conversations->requestCount($user), ])->header('Cache-Control', 'private, no-store'); diff --git a/app/Http/Controllers/OrgController.php b/app/Http/Controllers/OrgController.php index 0e779a994..57f54a69d 100644 --- a/app/Http/Controllers/OrgController.php +++ b/app/Http/Controllers/OrgController.php @@ -10,6 +10,7 @@ use App\Http\Requests\CreateOrgRequest; use App\Http\Requests\ShowOrgRequest; use App\Http\Requests\UpdateOrgRequest; +use App\Http\Resources\ConversationResource; use App\Models\Conversation; use App\Models\User; use App\Queries\ConversationsQuery; @@ -32,11 +33,25 @@ public function show(ShowOrgRequest $request, #[CurrentUser] User $user, Convers 'thread' => function () use ($user, $org, $threads, $action): array { $thread = $threads->org($user, $org); + $thread['members'] = $thread['members']->map(function (array $member): array { + $member['user'] = [ + ...$member['user']->toArray(), + 'timezone' => $member['user']->show_local_time ? $member['user']->timezone : null, + ]; + + return $member; + }); + $action->handle($user, $org, $thread['messages']->last()->id ?? 0); return $thread; }, - 'inbox' => fn (): array => $conversations->get($user), + 'inbox' => function () use ($request, $user, $conversations): array { + $inbox = $conversations->get($user); + $inbox['conversations'] = ConversationResource::collection($inbox['conversations'])->resolve($request); + + return $inbox; + }, 'partner' => null, 'org' => ['id' => $org->id, 'name' => $org->name], ]); diff --git a/app/Http/Controllers/OrgMessageController.php b/app/Http/Controllers/OrgMessageController.php index 83bdb1823..426427313 100644 --- a/app/Http/Controllers/OrgMessageController.php +++ b/app/Http/Controllers/OrgMessageController.php @@ -17,9 +17,19 @@ { public function index(ListOrgMessagesRequest $request, #[CurrentUser] User $user, Conversation $org, ConversationThreadQuery $threads): JsonResponse { + $thread = $threads->org($user, $org, $request->afterId(), $request->beforeId(), $request->reactionsAfter()); + $thread['members'] = $thread['members']->map(function (array $member): array { + $member['user'] = [ + ...$member['user']->toArray(), + 'timezone' => $member['user']->show_local_time ? $member['user']->timezone : null, + ]; + + return $member; + }); + return response()->json([ 'org' => ['id' => $org->id, 'name' => $org->name], - ...$threads->org($user, $org, $request->afterId(), $request->beforeId(), $request->reactionsAfter()), + ...$thread, ])->header('Cache-Control', 'private, no-store'); } diff --git a/app/Http/Resources/ConversationResource.php b/app/Http/Resources/ConversationResource.php index a9d601123..a3b89b6ef 100644 --- a/app/Http/Resources/ConversationResource.php +++ b/app/Http/Resources/ConversationResource.php @@ -30,7 +30,9 @@ public function toArray(Request $request): array return [ ...$conversation, - 'partner' => $conversation['partner'] instanceof User ? UserResource::make($conversation['partner']) : null, + 'partner' => $conversation['partner'] instanceof User ? [ + ...UserResource::make($conversation['partner'])->toArray($request), + ] : null, 'members' => $conversation['members']?->map(fn (array $member): array => [ 'user' => UserResource::make($member['user']), 'role' => $member['role'], diff --git a/app/Http/Resources/UserResource.php b/app/Http/Resources/UserResource.php index 1f1d0b638..91aef1caa 100644 --- a/app/Http/Resources/UserResource.php +++ b/app/Http/Resources/UserResource.php @@ -36,6 +36,8 @@ public function toArray(Request $request): array 'bio' => $this->bio, 'company' => $this->company, 'location' => $this->location, + 'timezone' => $this->show_local_time ? $this->timezone : null, + 'show_local_time' => $this->show_local_time, 'website' => $this->website, 'github_url' => "https://github.com/{$this->handle}", 'html_url' => route('user.show', $this->resource), diff --git a/resources/js/components/chat/chat-dock.tsx b/resources/js/components/chat/chat-dock.tsx index 53aef369c..afffd7626 100644 --- a/resources/js/components/chat/chat-dock.tsx +++ b/resources/js/components/chat/chat-dock.tsx @@ -15,6 +15,7 @@ import { ConversationActions } from '@/components/chat/conversation-actions'; import { ConversationThread } from '@/components/chat/conversation-thread'; import { InboxPanel } from '@/components/chat/inbox-panel'; import { MemberStack, OrgHeader, orgTitle } from '@/components/chat/org'; +import { RemoteLocalTime } from '@/components/chat/remote-local-time'; import { SearchDialog } from '@/components/search-dialog'; import { Tooltip, @@ -63,8 +64,9 @@ function DockedChat({ user }: { user: User }) { const target: ChatTarget | null = dock.conversation === null ? null - : (threads[dock.conversation]?.target ?? - (summary === undefined ? null : targetOf(summary))); + : summary === undefined + ? (threads[dock.conversation]?.target ?? null) + : targetOf(summary); const thread = target === null ? undefined : threads[targetKey(target)]; const open = (next: ChatTarget) => { @@ -217,17 +219,22 @@ function DockedChat({ user }: { user: User }) { avatarClassName="size-6" /> )} - - {target.kind === 'direct' - ? target.partner.name - : orgTitle( - target.org, - thread?.members ?? - summary?.members ?? - null, - user, - )} - +
+ + {target.kind === 'direct' + ? target.partner.name + : orgTitle( + target.org, + thread?.members ?? + summary?.members ?? + null, + user, + )} + + {target.kind === 'direct' && ( + + )} +
{unread > 0 && ( @@ -319,6 +326,7 @@ function DirectDockHeader({ @{partner.handle} + @{member.user.handle} + ; +}) { + if (!partner.show_local_time || !partner.timezone) { + return null; + } + + return ( + + ); +} diff --git a/resources/js/components/timezone-picker.tsx b/resources/js/components/timezone-picker.tsx index 5a91e147c..62122ef88 100644 --- a/resources/js/components/timezone-picker.tsx +++ b/resources/js/components/timezone-picker.tsx @@ -71,7 +71,7 @@ export default function TimezonePicker() {
- Show my local time on my profile + Show my local time on my profile and in direct + and group Remotes - Anyone who opens your profile sees the - current time in your timezone. + People viewing your profile or in a direct + or group remote with you can see your + current local time. diff --git a/resources/js/pages/conversation/index.tsx b/resources/js/pages/conversation/index.tsx index a9ccf5ff7..6b8cd5644 100644 --- a/resources/js/pages/conversation/index.tsx +++ b/resources/js/pages/conversation/index.tsx @@ -12,6 +12,7 @@ import { ConversationActions } from '@/components/chat/conversation-actions'; import { ConversationThread } from '@/components/chat/conversation-thread'; import { InboxPanel } from '@/components/chat/inbox-panel'; import { OrgHeader, OrgPeopleDialog } from '@/components/chat/org'; +import { RemoteLocalTime } from '@/components/chat/remote-local-time'; import { RemoteSettingsMenu } from '@/components/chat/remote-settings-menu'; import { SearchDialog } from '@/components/search-dialog'; import { Button } from '@/components/ui/button'; @@ -43,13 +44,22 @@ export default function Index({ thread: Thread | OrgThread | null; }) { const { user } = useUser(); + const conversations = useInbox(); const dock = useChatDock(); const [isPicking, setIsPicking] = useState(false); const [isStartingOrg, setIsStartingOrg] = useState(false); const [seededFrom, setSeededFrom] = useState(null); + const currentPartner = + partner === null + ? null + : (conversations?.find( + (conversation) => + conversation.kind === 'direct' && + conversation.partner?.id === partner.id, + )?.partner ?? partner); const target: ChatTarget | null = - partner !== null - ? { kind: 'direct', partner } + currentPartner !== null + ? { kind: 'direct', partner: currentPartner } : org !== null ? { kind: 'org', org } : null; @@ -63,8 +73,6 @@ export default function Index({ } } - useInbox(); - // Keeps the inbox's tab and search while moving between conversations. const visit = (href: Parameters[0]) => router.visit(href, { preserveScroll: true, preserveState: true }); @@ -90,8 +98,8 @@ export default function Index({ <> - +
@{partner.handle} - + +
assertJsonMissingPath('data.0.email'); }); +it('shares a suggested user timezone only when they opt in', function (): void { + $sharing = User::factory()->create([ + 'handle' => 'sharing', + 'timezone' => 'America/Los_Angeles', + 'show_local_time' => true, + ]); + $private = User::factory()->create([ + 'handle' => 'private', + 'timezone' => 'Asia/Tokyo', + 'show_local_time' => false, + ]); + $this->user->following()->attach([$sharing->id, $private->id]); + + $users = collect(signedApiRequest($this->keyPair, 'GET', route('api-mention-suggestion.index')) + ->assertOk() + ->json('data'))->keyBy('handle'); + + expect($users['sharing'])->toMatchArray([ + 'timezone' => 'America/Los_Angeles', + 'show_local_time' => true, + ])->and($users['private'])->toMatchArray([ + 'timezone' => null, + 'show_local_time' => false, + ]); +}); + it('suggests matches, people the user follows first, leaving out muted people and people across a block', function (): void { User::factory()->create(['handle' => 'taylor', 'name' => 'Taylor']); $followed = User::factory()->create(['handle' => 'taylorotwell', 'name' => 'Taylor Otwell']); diff --git a/tests/Feature/Controllers/Api/OrgMessageControllerTest.php b/tests/Feature/Controllers/Api/OrgMessageControllerTest.php index 9a2a0667a..838d7e9e1 100644 --- a/tests/Feature/Controllers/Api/OrgMessageControllerTest.php +++ b/tests/Feature/Controllers/Api/OrgMessageControllerTest.php @@ -39,6 +39,23 @@ ->assertJsonPath('data.access', 'open'); }); +it('shares a member timezone through the group API only when they opt in', function (): void { + [$sharing, $private] = followersOf($this->user, 2); + $sharing->update(['timezone' => 'America/Los_Angeles', 'show_local_time' => true]); + $private->update(['timezone' => 'Asia/Tokyo', 'show_local_time' => false]); + $org = createOrg($this->user, [$sharing, $private]); + + $members = collect(signedApiRequest($this->keyPair, 'GET', route('api-org-message.index', $org)) + ->assertOk() + ->json('data.members')) + ->keyBy('user.handle'); + + expect($members[$sharing->handle]['user']['timezone'])->toBe('America/Los_Angeles') + ->and($members[$sharing->handle]['user']['show_local_time'])->toBeTrue() + ->and($members[$private->handle]['user']['timezone'])->toBeNull() + ->and($members[$private->handle]['user']['show_local_time'])->toBeFalse(); +}); + it('names who an event is about', function (): void { [$nuno, $taylor] = followersOf($this->user, 2); $org = createOrg($this->user, [$nuno]); diff --git a/tests/Feature/Controllers/ConversationControllerTest.php b/tests/Feature/Controllers/ConversationControllerTest.php index 6a851b6b5..5f4fdfbb9 100644 --- a/tests/Feature/Controllers/ConversationControllerTest.php +++ b/tests/Feature/Controllers/ConversationControllerTest.php @@ -74,6 +74,47 @@ ->where('thread.messages', [])); }); +it('shares a direct partner timezone only when they opt in', function (): void { + $mark = User::factory()->withRemotes()->create(); + $sharing = User::factory()->withRemotes()->create([ + 'timezone' => 'America/Los_Angeles', + 'show_local_time' => true, + ]); + $private = User::factory()->withRemotes()->create([ + 'timezone' => 'Asia/Tokyo', + 'show_local_time' => false, + ]); + sendChatMessage($sharing, $mark, 'local time is visible'); + sendChatMessage($private, $mark, 'local time is private'); + + $this->actingAs($mark)->get(route('conversation.show', $sharing)) + ->assertInertia(fn (AssertableInertia $page): AssertableInertia => $page + ->where('partner.timezone', 'America/Los_Angeles') + ->where('partner.show_local_time', true)); + + $this->get(route('conversation.show', $private)) + ->assertInertia(fn (AssertableInertia $page): AssertableInertia => $page + ->where('partner.timezone', null) + ->where('partner.show_local_time', false) + ->where('inbox.conversations.0.partner.handle', $private->handle) + ->where('inbox.conversations.0.partner.timezone', null)); + + $private->update(['show_local_time' => true]); + $inbox = collect($this->getJson(route('inbox.index'))->assertOk()->json('conversations')) + ->keyBy('partner.handle'); + + expect($inbox[$sharing->handle]['partner']['timezone'])->toBe('America/Los_Angeles') + ->and($inbox[$private->handle]['partner']['timezone'])->toBe('Asia/Tokyo') + ->and($inbox[$private->handle]['partner']['show_local_time'])->toBeTrue(); + + $private->update(['show_local_time' => false]); + $inbox = collect($this->getJson(route('inbox.index'))->assertOk()->json('conversations')) + ->keyBy('partner.handle'); + + expect($inbox[$private->handle]['partner']['timezone'])->toBeNull() + ->and($inbox[$private->handle]['partner']['show_local_time'])->toBeFalse(); +}); + it('hides conversations you cannot have', function (): void { $mark = User::factory()->withRemotes()->create(); $nuno = User::factory()->withRemotes()->create(); diff --git a/tests/Feature/Controllers/OrgControllerTest.php b/tests/Feature/Controllers/OrgControllerTest.php index 39648da8b..5d0c538c6 100644 --- a/tests/Feature/Controllers/OrgControllerTest.php +++ b/tests/Feature/Controllers/OrgControllerTest.php @@ -118,6 +118,22 @@ $this->getJson(route('org-message.index', $org))->assertNotFound(); }); +it('shows opted-in member local times in a group remote without sharing timezones from other members', function (): void { + $mark = User::factory()->create(); + [$sharing, $private] = followersOf($mark, 2); + $sharing->update(['timezone' => 'America/Los_Angeles', 'show_local_time' => true]); + $private->update(['timezone' => 'Asia/Tokyo', 'show_local_time' => false]); + $org = createOrg($mark, [$sharing, $private]); + + $this->actingAs($mark)->get(route('org.show', $org)) + ->assertOk() + ->assertInertia(fn (AssertableInertia $page): AssertableInertia => $page + ->where('thread.members.1.user.timezone', 'America/Los_Angeles') + ->where('thread.members.1.user.show_local_time', true) + ->where('thread.members.2.user.timezone', null) + ->where('thread.members.2.user.show_local_time', false)); +}); + it('polls the badge counts on an open org without marking it read', function (): void { $mark = User::factory()->create(); [$nuno] = followersOf($mark, 1); diff --git a/tests/Feature/Controllers/OrgMessageControllerTest.php b/tests/Feature/Controllers/OrgMessageControllerTest.php index 51a1b8388..11c747c71 100644 --- a/tests/Feature/Controllers/OrgMessageControllerTest.php +++ b/tests/Feature/Controllers/OrgMessageControllerTest.php @@ -37,6 +37,22 @@ $this->actingAs($mark)->getJson(route('org-message.index', $org))->assertJsonPath('members.1.read_through_id', 0); }); +it('shares a member timezone with the group only when they opt in', function (): void { + $mark = User::factory()->create(); + [$sharing, $private] = followersOf($mark, 2); + $sharing->update(['timezone' => 'America/Los_Angeles', 'show_local_time' => true]); + $private->update(['timezone' => 'Asia/Tokyo', 'show_local_time' => false]); + $org = createOrg($mark, [$sharing, $private]); + + $members = collect($this->actingAs($mark)->getJson(route('org-message.index', $org))->assertOk()->json('members')) + ->keyBy('user.handle'); + + expect($members[$sharing->handle]['user']['timezone'])->toBe('America/Los_Angeles') + ->and($members[$sharing->handle]['user']['show_local_time'])->toBeTrue() + ->and($members[$private->handle]['user']['timezone'])->toBeNull() + ->and($members[$private->handle]['user']['show_local_time'])->toBeFalse(); +}); + it('shows who is typing', function (): void { $mark = User::factory()->create(); [$nuno] = followersOf($mark, 1); diff --git a/tests/Unit/RemoteLocalTime.test.tsx b/tests/Unit/RemoteLocalTime.test.tsx new file mode 100644 index 000000000..ef0ffc37d --- /dev/null +++ b/tests/Unit/RemoteLocalTime.test.tsx @@ -0,0 +1,31 @@ +import assert from 'node:assert/strict'; +import { isValidElement } from 'react'; +import type { ReactElement } from 'react'; +import { test } from 'vite-plus/test'; +import { RemoteLocalTime } from '../../resources/js/components/chat/remote-local-time'; + +test('shows time only when the partner has opted in and set a timezone', () => { + assert.equal( + RemoteLocalTime({ + partner: { timezone: 'Asia/Kolkata', show_local_time: false }, + }), + null, + ); + assert.equal( + RemoteLocalTime({ partner: { timezone: null, show_local_time: true } }), + null, + ); + + const result = RemoteLocalTime({ + partner: { timezone: 'Asia/Kolkata', show_local_time: true }, + }); + + assert.ok( + isValidElement<{ + title: string; + children: ReactElement<{ timeZone: string }>; + }>(result), + ); + assert.equal(result.props.title, 'Asia/Kolkata'); + assert.equal(result.props.children.props.timeZone, 'Asia/Kolkata'); +});