From aa620a93cd0f61851bc07ea6c079126e6a25c7ad Mon Sep 17 00:00:00 2001 From: Punyapal Shah <53343069+MrPunyapal@users.noreply.github.com> Date: Thu, 29 Oct 2026 00:36:49 +0530 Subject: [PATCH 1/3] feat: share commits in remotes --- KNOWLEDGE_BASE.md | 2 + .../Controllers/RemoteCandidateController.php | 2 +- .../Requests/ListRemoteCandidatesRequest.php | 7 +- app/Queries/RemoteCandidatesQuery.php | 8 +- .../components/commit-secondary-actions.tsx | 207 +++++++++--- resources/js/components/quoted-commit.tsx | 4 +- resources/js/components/search-dialog.tsx | 18 +- resources/js/lib/chat.ts | 2 +- .../RemoteCandidateControllerTest.php | 31 ++ tests/Unit/CommitShareActions.test.tsx | 294 ++++++++++++++++++ 10 files changed, 528 insertions(+), 47 deletions(-) create mode 100644 tests/Unit/CommitShareActions.test.tsx diff --git a/KNOWLEDGE_BASE.md b/KNOWLEDGE_BASE.md index 26b649c54..7d7396357 100644 --- a/KNOWLEDGE_BASE.md +++ b/KNOWLEDGE_BASE.md @@ -369,6 +369,8 @@ Every email has an **Unsubscribe** link at the bottom, and mail apps such as Gma ### Remotes: direct messages +Open a commit's **Share** menu to copy its link or choose **Send in Remotes** on an approved commit. Pick a developer and confirm sending its link. The confirmation shows the commit before sending; cancelling sends nothing. Sharing follows the usual Remotes contact and request rules and opens the recipient's conversation, where delivery failures can be retried. + **Remotes** is /dev/null's private messaging inbox at `/remotes`. Open it from navigation or use a developer's **Remote** control. Remotes can be disabled in Settings → Profile; disabling keeps existing conversations but stops new messaging. By default, you can contact someone who follows you, provided both accounts have Remotes enabled and neither has blocked the other. The follow is only needed for the first message: once a conversation exists, both people can keep writing. Administrators have a contact-policy exception. Tom has Remotes turned off, so he cannot be messaged. Messages support up to **2,000 characters**, replies to messages, link cards, and up to three images with the same file limits and descriptions as commit attachments. An image makes message text optional. **Voice notes** of up to a minute can be recorded from the microphone button in the composer; a voice note is sent on its own (it can still reply to a message), and you can listen to it before pushing it or discard it. Voice notes are compressed with Opus in your browser, about 180 KB a minute (browsers that can't record Opus send plain 16 kHz mono audio instead). The server rebuilds each recording from its audio alone, so no metadata or tags are kept, and stores it encrypted with the app key like message bodies, only ever served to the people in the conversation, and erased with your account. On a computer, hovering the speaker next to a voice note turns its waveform into a volume ramp to drag; the volume you set applies to every voice note, from anyone, on that device, and is saved in your browser, never on the server. Phones and tablets use their volume buttons instead. The speed button after it plays a voice note at 1×, 1.5× or 2×, with the clock showing how long it takes at that speed; the speed applies only to that voice note and resets when the page reloads. Messages cannot be edited or deleted, and mentions in them do not notify anyone. React to a message with any emoji, up to 12 different ones per person per message: three quick reactions sit next to each message, a picker searches the rest, and a double-tap or double-click toggles ❤️. On touch screens, press and hold a reaction beneath a message to see who left it; a short tap still adds or removes your own reaction. Typing indicators show recent activity. **Ping** with the button next to the microphone, a tribute to MSN Messenger: the remote shakes and plays MSN's sound for you and everyone in it (unless their sound is off or the remote is muted), the history logs it, and it alerts like a message, browser and app notifications included. Ping one person in a direct remote that has already started, or everyone in an org at once, up to three pings a minute. Read receipts, called **pull receipts**, are on by default and shared only when both relevant people enable them. Stashing a conversation archives it for you without deleting it; it pops back when anyone writes in it. On desktop, **Dock** keeps the inbox at the bottom of every page. **Back to present** returns to the newest message after following an old reply or scrolling until at least 25 newer messages are below the conversation viewport. This works in direct remotes, orgs, and the dock. Select a person’s name in the chat header or above a group message to open their profile. New messages arrive by polling, not push: every few seconds while a conversation is open. Message bodies, images, and voice notes are encrypted at rest with keys derived from the app key, not end to end. Block someone from inside a conversation with its menu. diff --git a/app/Http/Controllers/RemoteCandidateController.php b/app/Http/Controllers/RemoteCandidateController.php index 968fba434..b2d4eebd1 100644 --- a/app/Http/Controllers/RemoteCandidateController.php +++ b/app/Http/Controllers/RemoteCandidateController.php @@ -15,7 +15,7 @@ public function index(ListRemoteCandidatesRequest $request, #[CurrentUser] User $user, RemoteCandidatesQuery $candidates): JsonResponse { return response()->json([ - 'candidates' => $candidates->get($user, $request->string('q')->value(), $request->org()), + 'candidates' => $candidates->get($user, $request->string('q')->value(), $request->org(), forSharing: $request->isSharing()), ])->header('Cache-Control', 'private, no-store'); } } diff --git a/app/Http/Requests/ListRemoteCandidatesRequest.php b/app/Http/Requests/ListRemoteCandidatesRequest.php index d839e0419..a3ea2d62f 100644 --- a/app/Http/Requests/ListRemoteCandidatesRequest.php +++ b/app/Http/Requests/ListRemoteCandidatesRequest.php @@ -33,10 +33,15 @@ public function rules(): array return [ 'q' => ['nullable', 'string', 'max:100'], 'org' => ['nullable', 'uuid'], - 'for' => ['nullable', 'in:remote,org'], + 'for' => ['nullable', 'in:remote,org,share'], ]; } + public function isSharing(): bool + { + return $this->input('for') === 'share'; + } + /** * The org people are being picked for: an existing one, a new one not created yet, or none when picking someone * to message. diff --git a/app/Queries/RemoteCandidatesQuery.php b/app/Queries/RemoteCandidatesQuery.php index 442b3b31a..9a7158a2e 100644 --- a/app/Queries/RemoteCandidatesQuery.php +++ b/app/Queries/RemoteCandidatesQuery.php @@ -24,10 +24,10 @@ public function __construct(private UserSearchQuery $search, private PushAccessQ * * @return Collection */ - public function get(User $user, string $term, ?Conversation $org = null): Collection + public function get(User $user, string $term, ?Conversation $org = null, bool $forSharing = false): Collection { $candidates = $this->search->get($term, $user)->reject(fn (User $candidate): bool => $candidate->is($user))->values(); - $reasons = $this->reasons($user, $candidates, $org); + $reasons = $this->reasons($user, $candidates, $org, forSharing: $forSharing); return $candidates->map(fn (User $candidate): array => [ 'user' => $candidate, @@ -44,12 +44,14 @@ public function get(User $user, string $term, ?Conversation $org = null): Collec * @param Collection|null $joining * @return array */ - public function reasons(User $user, Collection $candidates, ?Conversation $org = null, ?Collection $joining = null): array + public function reasons(User $user, Collection $candidates, ?Conversation $org = null, ?Collection $joining = null, bool $forSharing = false): array { if (! $org instanceof Conversation) { return $candidates->mapWithKeys(fn (User $candidate): array => [$candidate->id => match ($this->pushAccess->get($user, $candidate)) { PushAccess::Closed => 'has remotes off', PushAccess::Restricted => "doesn't follow you", + PushAccess::Request => $forSharing ? 'merge their request first' : null, + PushAccess::Waiting => $forSharing ? 'your request is waiting to be merged' : null, default => null, }])->all(); } diff --git a/resources/js/components/commit-secondary-actions.tsx b/resources/js/components/commit-secondary-actions.tsx index a10bad955..6e7118ad0 100644 --- a/resources/js/components/commit-secondary-actions.tsx +++ b/resources/js/components/commit-secondary-actions.tsx @@ -1,19 +1,36 @@ -import { Ellipsis, Flag, LinkIcon } from 'lucide-react'; +import { router, usePage } from '@inertiajs/react'; +import { LinkIcon, Send, Share2 } from 'lucide-react'; import { useState } from 'react'; +import CommitController from '@/actions/App/Http/Controllers/CommitController'; +import ConversationController from '@/actions/App/Http/Controllers/ConversationController'; +import { CommitReportButton } from '@/components/commit-report-button'; +import { useCopyLink } from '@/components/copy-link-button'; +import { QuotedCommit } from '@/components/quoted-commit'; +import { SearchDialog } from '@/components/search-dialog'; +import { Button } from '@/components/ui/button'; import { - CommitReportButton, - CommitReportDialog, - useCanReport, -} from '@/components/commit-report-button'; -import { CopyLinkButton, useCopyLink } from '@/components/copy-link-button'; + Dialog, + DialogContent, + DialogDescription, + DialogFooter, + DialogHeader, + DialogTitle, +} from '@/components/ui/dialog'; import { DropdownMenu, DropdownMenuContent, DropdownMenuItem, + DropdownMenuShortcut, DropdownMenuTrigger, } from '@/components/ui/dropdown-menu'; +import { + Tooltip, + TooltipContent, + TooltipTrigger, +} from '@/components/ui/tooltip'; +import { sendMessage } from '@/lib/chat'; import { cn } from '@/lib/utils'; -import type { Commit } from '@/types'; +import type { Commit, User } from '@/types'; export function CommitSecondaryActions({ commit, @@ -24,62 +41,178 @@ export function CommitSecondaryActions({ keyShortcut?: string; isReply?: boolean; }) { + const { auth } = usePage().props; + const [isPicking, setIsPicking] = useState(false); + const [recipient, setRecipient] = useState(null); + const [isConfirming, setIsConfirming] = useState(false); + const canSend = + auth.user !== null && + auth.remotes !== null && + commit.status === 'approved'; + + const pickRecipient = () => { + setRecipient(null); + setIsPicking(true); + }; + + const send = () => { + if (!canSend || recipient === null || !isConfirming) { + return; + } + + const target = { kind: 'direct' as const, partner: recipient }; + const permalink = CommitController.show({ + user: commit.user, + commit, + }).url; + + sendMessage(target, { + body: new URL(permalink, window.location.origin).href, + replyTo: null, + images: [], + descriptions: [], + linkPreviewUrl: undefined, + }); + setIsConfirming(false); + router.visit(ConversationController.show(recipient)); + }; + return (
- +
- + {canSend && (isPicking || recipient !== null) && ( + { + if (recipient !== null) setIsConfirming(true); + }} + /> + )} + {canSend && recipient !== null && ( + + + + + Send commit to @{recipient.handle}? + + + Send a link to this commit in Remotes. + + +
+ +
+ + + + +
+
+ )}
); } -function CommitActionsMenu({ commit }: { commit: Commit }) { - const [isReporting, setIsReporting] = useState(false); +function CommitShareMenu({ + commit, + keyShortcut, + canSend, + onSend, +}: { + commit: Commit; + keyShortcut?: string; + canSend: boolean; + onSend: () => void; +}) { const { copyLink } = useCopyLink(commit); - const canReport = useCanReport(commit); return ( -
+ <> + {keyShortcut && ( + - + + + + + + + Share + - + Copy link + {keyShortcut && ( + + {keyShortcut} + + )} - {canReport && ( - setIsReporting(true)}> - - Report commit + {canSend && ( + + + Send in Remotes )} - {canReport && ( - - )} -
+ ); } diff --git a/resources/js/components/quoted-commit.tsx b/resources/js/components/quoted-commit.tsx index 230ab215d..e59fe21eb 100644 --- a/resources/js/components/quoted-commit.tsx +++ b/resources/js/components/quoted-commit.tsx @@ -19,9 +19,11 @@ import type { DeletedCommit, QuotedCommit as QuotedCommitData } from '@/types'; export function QuotedCommit({ commit, isPreview = false, + label = 'Quoting', }: { commit: QuotedCommitData | DeletedCommit | null; isPreview?: boolean; + label?: string; }) { const now = useNow(); if (commit === null || isDeleted(commit)) { @@ -45,7 +47,7 @@ export function QuotedCommit({ const content = ( <> - Quoting + {label} {commit.user.name} diff --git a/resources/js/components/search-dialog.tsx b/resources/js/components/search-dialog.tsx index 4f8ef10ab..287e23dda 100644 --- a/resources/js/components/search-dialog.tsx +++ b/resources/js/components/search-dialog.tsx @@ -81,6 +81,10 @@ export function SearchDialog({ excludeId, onAction, isLive = false, + pickerTitle = 'New remote', + pickerPlaceholder = 'Message a developer', + pickerScope = 'remote', + onCloseAutoFocus, }: { open: boolean; onOpenChange: (open: boolean) => void; @@ -88,6 +92,10 @@ export function SearchDialog({ excludeId?: string; onAction?: (action: SearchAction) => void; isLive?: boolean; + pickerTitle?: string; + pickerPlaceholder?: string; + pickerScope?: 'remote' | 'share'; + onCloseAutoFocus?: (event: Event) => void; }) { const userId = usePage().props.auth.user?.id ?? null; const recentSearches = useRecentSearches(userId); @@ -105,7 +113,10 @@ export function SearchDialog({ const developers = useDebouncedSearch( developerTerm, developerDebounce, - isPicking ? searchRemoteCandidates : searchDevelopers, + isPicking + ? (value, signal) => + searchCandidates(value, { for: pickerScope }, signal) + : searchDevelopers, ); const commits = useDebouncedSearch( commitTerm, @@ -239,11 +250,12 @@ export function SearchDialog({ - {isPicking ? 'New remote' : 'Search'} + {isPicking ? pickerTitle : 'Search'} {isPicking @@ -262,7 +274,7 @@ export function SearchDialog({ onKeyDown={onKeyDown} placeholder={ isPicking - ? 'Message a developer' + ? pickerPlaceholder : 'Search developers or commits' } role="combobox" diff --git a/resources/js/lib/chat.ts b/resources/js/lib/chat.ts index f9fc9a3fd..7f5f72c5a 100644 --- a/resources/js/lib/chat.ts +++ b/resources/js/lib/chat.ts @@ -1938,7 +1938,7 @@ export async function searchConversations( */ export async function searchCandidates( term: string, - scope: { for: 'remote' | 'org' } | { org: string }, + scope: { for: 'remote' | 'org' | 'share' } | { org: string }, signal: AbortSignal, ): Promise { const response = await fetch( diff --git a/tests/Feature/Controllers/RemoteCandidateControllerTest.php b/tests/Feature/Controllers/RemoteCandidateControllerTest.php index 266b0416a..bad5fa112 100644 --- a/tests/Feature/Controllers/RemoteCandidateControllerTest.php +++ b/tests/Feature/Controllers/RemoteCandidateControllerTest.php @@ -2,8 +2,39 @@ declare(strict_types=1); +use App\Actions\CreateConversation; +use App\Enums\RemoteStatus; use App\Models\User; +it('only shares into conversations that can accept a message', function (bool $outgoing, bool $dropped, string $reason): void { + $viewer = User::factory()->create(); + $partner = User::factory()->create(['handle' => 'share_partner']); + $conversation = resolve(CreateConversation::class)->handle($outgoing ? $viewer : $partner, $outgoing ? $partner : $viewer); + + if ($dropped) { + $conversation->participants()->where('user_id', $partner->id)->update(['status' => RemoteStatus::Dropped]); + } + + $this->actingAs($viewer)->getJson(route('remote-candidate.index', ['q' => 'share_partner', 'for' => 'share'])) + ->assertOk()->assertJsonPath('candidates.0.reason', $reason); + + $this->getJson(route('remote-candidate.index', ['q' => 'share_partner', 'for' => 'remote'])) + ->assertOk()->assertJsonPath('candidates.0.reason', null); +})->with([ + 'incoming request' => [false, false, 'merge their request first'], + 'outgoing request' => [true, false, 'your request is waiting to be merged'], + 'dropped request stays private' => [true, true, 'your request is waiting to be merged'], +]); + +it('can share a first message when requests are enabled', function (): void { + config(['remotes.requests' => true]); + $viewer = User::factory()->create(); + User::factory()->create(['handle' => 'share_partner']); + + $this->actingAs($viewer)->getJson(route('remote-candidate.index', ['q' => 'share_partner', 'for' => 'share'])) + ->assertOk()->assertJsonPath('candidates.0.reason', null); +}); + it('says who can be messaged and why not', function (): void { $mark = User::factory()->create(); $follower = User::factory()->create(['handle' => 'ana_follows']); diff --git a/tests/Unit/CommitShareActions.test.tsx b/tests/Unit/CommitShareActions.test.tsx new file mode 100644 index 000000000..8d98c35bf --- /dev/null +++ b/tests/Unit/CommitShareActions.test.tsx @@ -0,0 +1,294 @@ +/// +import assert from 'node:assert/strict'; +import * as React from 'react'; +import type { ReactElement, ReactNode } from 'react'; +import { afterEach, test, vi } from 'vite-plus/test'; +import type { Commit, User } from '../../resources/js/types'; + +let state: unknown[] = []; +let cursor = 0; +let signedIn = true; +let remotesEnabled = true; +const sendMessage = vi.fn(); +const visit = vi.fn(); +const copyLink = vi.fn(); + +vi.doMock('react', () => ({ + ...React, + useState: (initial: unknown) => { + const index = cursor++; + if (!(index in state)) state[index] = initial; + return [state[index], (next: unknown) => (state[index] = next)]; + }, +})); +vi.doMock('@inertiajs/react', () => ({ + router: { visit }, + usePage: () => ({ + props: { + auth: { + user: signedIn ? { id: 'viewer' } : null, + remotes: remotesEnabled ? {} : null, + }, + }, + }), +})); +vi.doMock('../../resources/js/lib/chat', () => ({ sendMessage })); +vi.doMock('../../resources/js/components/search-dialog', () => ({ + SearchDialog: () => null, +})); +vi.doMock('../../resources/js/components/quoted-commit', () => ({ + QuotedCommit: () => null, +})); +vi.doMock('../../resources/js/components/copy-link-button', () => ({ + useCopyLink: () => ({ copyLink }), +})); +vi.doMock('../../resources/js/components/commit-report-button', () => ({ + CommitReportButton: () => null, +})); + +const { CommitSecondaryActions } = + await import('../../resources/js/components/commit-secondary-actions'); +const { Dialog } = await import('../../resources/js/components/ui/dialog'); +const { default: CommitController } = + await import('../../resources/js/actions/App/Http/Controllers/CommitController'); +const commit = { + id: '123e4567-e89b-12d3-a456-426614174000', + user_id: 'author', + user: { handle: 'author' }, + status: 'approved', + body: 'Shipped it.', +} as Commit; +const recipient = { id: 'recipient', handle: 'friend' } as User; +type Element = ReactElement>; + +afterEach(() => { + state = []; + signedIn = true; + remotesEnabled = true; + sendMessage.mockClear(); + visit.mockClear(); + copyLink.mockClear(); + vi.unstubAllGlobals(); + vi.restoreAllMocks(); +}); + +function elements(node: ReactNode): Element[] { + if (!React.isValidElement(node)) return []; + const element = node as Element; + return [ + element, + ...React.Children.toArray(element.props.children as ReactNode).flatMap( + elements, + ), + ]; +} + +function render(value: Commit = commit, keyShortcut?: string): Element[] { + cursor = 0; + return elements( + CommitSecondaryActions({ commit: value, keyShortcut }), + ).flatMap((element) => + typeof element.type === 'function' && + element.type.name === 'CommitShareMenu' + ? [ + element, + ...elements( + ( + element.type as ( + props: Record, + ) => ReactNode + )(element.props), + ), + ] + : [element], + ); +} + +function pick(tree: Element[]): void { + const picker = tree.find( + (element) => element.props.pickerScope === 'share', + )!; + (picker.props.onOpenChange as (open: boolean) => void)(false); + (picker.props.onPickDeveloper as (user: User) => void)(recipient); +} + +function confirm(value: Commit = commit): Element[] { + ( + render(value).find( + (element) => element.props['data-test'] === 'share-in-remotes', + )!.props.onSelect as () => void + )(); + pick(render(value)); + const selected = render(value); + const picker = selected.find( + (element) => element.props.pickerScope === 'share', + )!; + (picker.props.onCloseAutoFocus as () => void)(); + return render(value); +} + +test('only offers sharing for signed-in developers with Remotes and approved commits', () => { + for (const [user, enabled, status] of [ + [false, true, 'approved'], + [true, false, 'approved'], + [true, true, 'pending'], + [true, true, 'rejected'], + ] as const) { + signedIn = user; + remotesEnabled = enabled; + const tree = render({ ...commit, status }); + assert.equal( + tree.some( + (element) => element.props['data-test'] === 'share-in-remotes', + ), + false, + ); + assert.equal( + tree.find((element) => typeof element.props.onSend === 'function')! + .props.canSend, + false, + ); + } +}); + +test('picking a recipient waits for the picker to close and never sends by itself', () => { + ( + render().find( + (element) => element.props['data-test'] === 'share-in-remotes', + )!.props.onSelect as () => void + )(); + pick(render()); + const selected = render(); + + assert.equal( + selected.find((element) => element.type === Dialog)!.props.open, + false, + ); + assert.equal(sendMessage.mock.calls.length, 0); + ( + selected.find((element) => element.props.pickerScope === 'share')!.props + .onCloseAutoFocus as () => void + )(); + assert.equal( + render().find((element) => element.type === Dialog)!.props.open, + true, + ); +}); + +test('confirmation sends the absolute commit link through chat and opens that recipient', () => { + vi.stubGlobal('window', { location: { origin: 'https://dev-null.sh' } }); + const tree = confirm(); + ( + tree.find( + (element) => element.props['data-test'] === 'confirm-share-commit', + )!.props.onClick as () => void + )(); + + assert.equal(sendMessage.mock.calls.length, 1); + assert.deepEqual(sendMessage.mock.calls[0], [ + { kind: 'direct', partner: recipient }, + { + body: 'https://dev-null.sh/@author/commits/123e4567-e89b-12d3-a456-426614174000', + replyTo: null, + images: [], + descriptions: [], + linkPreviewUrl: undefined, + }, + ]); + assert.deepEqual(visit.mock.calls[0][0], { + url: '/remotes/@friend', + method: 'get', + }); + assert.equal( + render().find((element) => element.type === Dialog)!.props.open, + false, + ); +}); + +test('cancelling preserves the closing preview and sends nothing', () => { + const value = { ...commit, body: '', images: [{ id: 'photo' }] } as Commit; + const tree = confirm(value); + assert.equal( + tree.find((element) => element.props.label === 'Sharing')!.props.commit, + value, + ); + ( + tree.find((element) => element.type === Dialog)!.props.onOpenChange as ( + open: boolean, + ) => void + )(false); + + const closed = render(value); + assert.equal( + closed.find((element) => element.type === Dialog)!.props.open, + false, + ); + assert.equal( + closed.find((element) => element.props.label === 'Sharing')!.props + .commit, + value, + ); + assert.equal(sendMessage.mock.calls.length, 0); +}); + +test('the share menu opens the recipient picker', () => { + const item = render().find( + (element) => element.props['data-test'] === 'share-in-remotes', + )!; + (item.props.onSelect as () => void)(); + assert.ok( + render().find((element) => element.props.pickerScope === 'share'), + ); +}); + +test('guests can copy a link without opening Remotes', () => { + signedIn = false; + const tree = render(); + assert.ok( + tree.find( + (element) => element.props['data-test'] === 'share-commit-button', + ), + ); + ( + tree.find( + (element) => element.props['data-test'] === 'copy-commit-link', + )!.props.onSelect as () => void + )(); + + assert.equal(copyLink.mock.calls.length, 1); + assert.equal(sendMessage.mock.calls.length, 0); + assert.equal( + render().some((element) => element.props.pickerScope === 'share'), + false, + ); +}); + +test('the copy shortcut still copies directly without adding a tab stop', () => { + const shortcut = render(commit, 'y').find( + (element) => element.props['aria-keyshortcuts'] === 'y', + )!; + assert.equal(shortcut.props.tabIndex, -1); + (shortcut.props.onClick as () => void)(); + + assert.equal(copyLink.mock.calls.length, 1); + assert.equal(sendMessage.mock.calls.length, 0); +}); + +test('sharing an absolute permalink preserves its host', () => { + vi.stubGlobal('window', { location: { origin: 'http://localhost:8000' } }); + vi.spyOn(CommitController, 'show').mockReturnValue({ + url: 'https://dev-null.sh/@author/commits/123e4567-e89b-12d3-a456-426614174000', + method: 'get', + }); + const tree = confirm(); + ( + tree.find( + (element) => element.props['data-test'] === 'confirm-share-commit', + )!.props.onClick as () => void + )(); + + assert.equal( + sendMessage.mock.calls[0][1].body, + 'https://dev-null.sh/@author/commits/123e4567-e89b-12d3-a456-426614174000', + ); +}); From 9d1be73f94cfa73f169eb870c2a1256960a49cc4 Mon Sep 17 00:00:00 2001 From: Punyapal Shah <53343069+MrPunyapal@users.noreply.github.com> Date: Fri, 30 Oct 2026 01:49:48 +0530 Subject: [PATCH 2/3] fix: preserve focus when sharing commits in remotes --- .../components/commit-secondary-actions.tsx | 42 ++++++- tests/Unit/CommitSecondaryActions.test.tsx | 8 +- tests/Unit/CommitShareActions.test.tsx | 103 +++++++++++++++++- 3 files changed, 142 insertions(+), 11 deletions(-) diff --git a/resources/js/components/commit-secondary-actions.tsx b/resources/js/components/commit-secondary-actions.tsx index 6e7118ad0..a21255e87 100644 --- a/resources/js/components/commit-secondary-actions.tsx +++ b/resources/js/components/commit-secondary-actions.tsx @@ -1,6 +1,7 @@ import { router, usePage } from '@inertiajs/react'; import { LinkIcon, Send, Share2 } from 'lucide-react'; -import { useState } from 'react'; +import { useRef, useState } from 'react'; +import type { Ref } from 'react'; import CommitController from '@/actions/App/Http/Controllers/CommitController'; import ConversationController from '@/actions/App/Http/Controllers/ConversationController'; import { CommitReportButton } from '@/components/commit-report-button'; @@ -42,7 +43,9 @@ export function CommitSecondaryActions({ isReply?: boolean; }) { const { auth } = usePage().props; + const shareTriggerRef = useRef(null); const [isPicking, setIsPicking] = useState(false); + const [isPickerMounted, setIsPickerMounted] = useState(false); const [recipient, setRecipient] = useState(null); const [isConfirming, setIsConfirming] = useState(false); const canSend = @@ -52,6 +55,7 @@ export function CommitSecondaryActions({ const pickRecipient = () => { setRecipient(null); + setIsPickerMounted(true); setIsPicking(true); }; @@ -91,11 +95,13 @@ export function CommitSecondaryActions({ commit={commit} keyShortcut={keyShortcut} canSend={canSend} + isPickerMounted={isPickerMounted} + triggerRef={shareTriggerRef} onSend={pickRecipient} /> - {canSend && (isPicking || recipient !== null) && ( + {canSend && isPickerMounted && ( { - if (recipient !== null) setIsConfirming(true); + onCloseAutoFocus={(event) => { + event.preventDefault(); + setIsPickerMounted(false); + + if (recipient !== null) { + setIsConfirming(true); + } else { + shareTriggerRef.current?.focus(); + } }} /> )} {canSend && recipient !== null && ( - + { + event.preventDefault(); + shareTriggerRef.current?.focus(); + }} + > Send commit to @{recipient.handle}? @@ -152,11 +171,15 @@ function CommitShareMenu({ commit, keyShortcut, canSend, + isPickerMounted, + triggerRef, onSend, }: { commit: Commit; keyShortcut?: string; canSend: boolean; + isPickerMounted: boolean; + triggerRef: Ref; onSend: () => void; }) { const { copyLink } = useCopyLink(commit); @@ -178,6 +201,7 @@ function CommitShareMenu({