From 9a365bcb4dff60c2062698743b7a516020faf6f8 Mon Sep 17 00:00:00 2001 From: Fernando Andrade Date: Mon, 19 Oct 2026 14:24:56 +0200 Subject: [PATCH 01/15] feat: follow a focused commit's author with f Keyboard navigation can toggle follow without opening the profile preview, using the same shortcut wiring as reply and quote. Co-authored-by: Cursor --- .../commit-author-follow-button.tsx | 67 ++++++++++++++ resources/js/components/commit-card.tsx | 2 + resources/js/components/follow-button.tsx | 91 ++++++++++++++++++- .../components/keyboard-shortcuts-dialog.tsx | 1 + resources/js/hooks/use-shared-state.ts | 7 ++ 5 files changed, 166 insertions(+), 2 deletions(-) create mode 100644 resources/js/components/commit-author-follow-button.tsx diff --git a/resources/js/components/commit-author-follow-button.tsx b/resources/js/components/commit-author-follow-button.tsx new file mode 100644 index 000000000..264f26e95 --- /dev/null +++ b/resources/js/components/commit-author-follow-button.tsx @@ -0,0 +1,67 @@ +import { usePage } from '@inertiajs/react'; +import { useRef } from 'react'; +import { + resolveIsFollowing, + toggleFollow, + useIsFollowing, +} from '@/components/follow-button'; +import { signIn } from '@/lib/auth'; +import type { User } from '@/types'; + +/** + * Keyboard target for `f` on a focused commit: follow or unfollow that commit's author. + */ +export function CommitAuthorFollowButton({ + user, + keyShortcut, +}: { + user: Pick; + keyShortcut?: string; +}) { + const { auth } = usePage().props; + const [, setIsFollowing] = useIsFollowing(user, false); + const isBusy = useRef(false); + + if (auth.user?.id === user.id) { + return null; + } + + const onClick = () => { + if (auth.user === null) { + signIn(); + + return; + } + + if (isBusy.current) { + return; + } + + isBusy.current = true; + + void resolveIsFollowing(user).then((isFollowing) => { + if (isFollowing === null) { + isBusy.current = false; + + return; + } + + toggleFollow(user, isFollowing, setIsFollowing, () => { + isBusy.current = false; + }); + }); + }; + + return ( + + ); +} diff --git a/resources/js/components/commit-card.tsx b/resources/js/components/commit-card.tsx index 1e3ffd672..c0a9a47a6 100644 --- a/resources/js/components/commit-card.tsx +++ b/resources/js/components/commit-card.tsx @@ -4,6 +4,7 @@ import type { PointerEvent, ReactNode } from 'react'; import { useRef, useState } from 'react'; import CommitController from '@/actions/App/Http/Controllers/CommitController'; import CommitLinkPreviewController from '@/actions/App/Http/Controllers/CommitLinkPreviewController'; +import { CommitAuthorFollowButton } from '@/components/commit-author-follow-button'; import { FoldedCommitBody } from '@/components/commit-body'; import { CommitImages } from '@/components/commit-images'; import { CommitPastelButton } from '@/components/commit-pastel-button'; @@ -164,6 +165,7 @@ export function CommitCard({ } onPointerLeave={onHover && ((event) => onHover(false, event))} > +
, +): Promise { + const known = peekSharedState(isFollowingKey(user)); + + if (known !== undefined) { + return known; + } + + try { + const response = await fetch(userPreview(user).url, { + headers: { + Accept: 'application/json', + 'X-Requested-With': 'XMLHttpRequest', + }, + }); + + if (!response.ok) { + toastWhenThrottled(response); + + return null; + } + + const data = (await response.json()) as { + isFollowing: boolean; + isUnavailable: boolean; + }; + + if (data.isUnavailable) { + rememberBlock(user); + + return null; + } + + refreshSharedState(new Map([[isFollowingKey(user), data.isFollowing]])); + + return data.isFollowing; + } catch { + toast.error(`Could not check @${user.username}. Try again.`); + + return null; + } +} + +/** + * Follows or unfollows after resolving the current state, keeping every follow control in sync. + */ +export function toggleFollow( + user: Pick, + isFollowing: boolean, + setIsFollowing: (isFollowing: boolean) => void, + onFinish?: () => void, +): void { + const next = !isFollowing; + + router.visit( + next + ? UserFollowController.store(user) + : UserFollowController.destroy(user), + { + preserveScroll: true, + preserveState: true, + onSuccess: () => { + setIsFollowing(next); + toast( + + {next + ? `Following @${user.username}` + : `Unfollowed @${user.username}`} + , + ); + }, + onHttpException: (response) => + onFollowHttpException(user, response), + onFinish, + }, + ); +} + export function FollowLabel({ isFollowing, isJustToggled, diff --git a/resources/js/components/keyboard-shortcuts-dialog.tsx b/resources/js/components/keyboard-shortcuts-dialog.tsx index 25332e266..f51e86b66 100644 --- a/resources/js/components/keyboard-shortcuts-dialog.tsx +++ b/resources/js/components/keyboard-shortcuts-dialog.tsx @@ -16,6 +16,7 @@ const shortcuts = (keys: KeyLabels): Shortcut[] => [ { keys: ['↵'], label: 'Open the focused commit' }, { keys: ['r'], label: 'Reply to commit' }, { keys: ['q'], label: 'Quote commit' }, + { keys: ['f'], label: "Follow or unfollow the focused commit's author" }, { keys: ['o'], label: 'Show or hide replies' }, { keys: ['c'], label: 'Write a commit' }, { keys: [keys.mod, 'K'], label: 'Search developers and commits' }, diff --git a/resources/js/hooks/use-shared-state.ts b/resources/js/hooks/use-shared-state.ts index 4660bfd36..6a3c7a40b 100644 --- a/resources/js/hooks/use-shared-state.ts +++ b/resources/js/hooks/use-shared-state.ts @@ -30,6 +30,13 @@ export function refreshSharedState(fresh: Map): void { } } +/** + * Reads a value only when something has written it, so callers can tell "unknown" from the initial default. + */ +export function peekSharedState(key: string): T | undefined { + return values.has(key) ? (values.get(key) as T) : undefined; +} + export function useSharedState( key: string, initialValue: T, From 09ae2f61afbee99ebe5009e8fee6d6ec25500730 Mon Sep 17 00:00:00 2001 From: Fernando Andrade Date: Mon, 19 Oct 2026 14:32:04 +0200 Subject: [PATCH 02/15] style: format peekSharedState for the linter Co-authored-by: Cursor --- resources/js/hooks/use-shared-state.ts | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/resources/js/hooks/use-shared-state.ts b/resources/js/hooks/use-shared-state.ts index 6a3c7a40b..d3727819a 100644 --- a/resources/js/hooks/use-shared-state.ts +++ b/resources/js/hooks/use-shared-state.ts @@ -33,7 +33,9 @@ export function refreshSharedState(fresh: Map): void { /** * Reads a value only when something has written it, so callers can tell "unknown" from the initial default. */ -export function peekSharedState(key: string): T | undefined { +export function peekSharedState( + key: string, +): T | undefined { return values.has(key) ? (values.get(key) as T) : undefined; } From f9fea239657232917a3e15c349d45d3e9d2ab1cd Mon Sep 17 00:00:00 2001 From: Fernando Andrade Date: Mon, 19 Oct 2026 15:58:45 +0200 Subject: [PATCH 03/15] test: cover following a focused commit's author with f Add unit coverage for the shortcut target and wire those suites into bun run test:unit. Co-authored-by: Cursor --- package.json | 2 +- tests/Unit/CommitAuthorFollow.test.tsx | 241 +++++++++++++++++++++++++ tests/tsconfig.json | 2 + 3 files changed, 244 insertions(+), 1 deletion(-) create mode 100644 tests/Unit/CommitAuthorFollow.test.tsx diff --git a/package.json b/package.json index 9ecc45e54..d209dfd8d 100644 --- a/package.json +++ b/package.json @@ -8,7 +8,7 @@ "lint": "NODE_OPTIONS='--experimental-strip-types' vp fmt resources/ && NODE_OPTIONS='--experimental-strip-types' vp lint --fix", "test:lint": "NODE_OPTIONS='--experimental-strip-types' vp fmt --check resources/ && NODE_OPTIONS='--experimental-strip-types' vp lint", "test:types": "tsc --noEmit && tsc --noEmit --project tests/tsconfig.json", - "test:unit": "node --experimental-strip-types --test tests/Unit/*.test.ts" + "test:unit": "node --experimental-strip-types --test tests/Unit/*.test.ts && vp test run tests/Unit/CommitAuthorFollow.test.tsx tests/Unit/FollowState.test.tsx" }, "dependencies": { "@daypicker/react": "^10.0.2", diff --git a/tests/Unit/CommitAuthorFollow.test.tsx b/tests/Unit/CommitAuthorFollow.test.tsx new file mode 100644 index 000000000..b052875bc --- /dev/null +++ b/tests/Unit/CommitAuthorFollow.test.tsx @@ -0,0 +1,241 @@ +/// +import assert from 'node:assert/strict'; +import * as React from 'react'; +import type { ReactElement, ReactNode } from 'react'; +import { afterAll, test, vi } from 'vite-plus/test'; +import type { User } from '../../resources/js/types'; + +type Props = Record & { children?: ReactNode }; + +type VisitOptions = { + onHttpException?: (response: { status: number }) => boolean | void; + onSuccess?: () => void; + onFinish?: () => void; +}; + +const visits: (VisitOptions & { method?: string })[] = []; +const toasts: string[] = []; +const locations: string[] = []; +let authUser: { id: string } | null = { id: 'viewer' }; +let preview: { + isFollowing: boolean; + isUnavailable?: boolean; +} | null = { isFollowing: false }; +let fetchCount = 0; + +vi.doMock('react', () => ({ + ...React, + useEffect: () => {}, + useLayoutEffect: () => {}, + useRef: (initial: unknown) => ({ current: initial }), + useState: (initial: unknown) => [ + typeof initial === 'function' ? initial() : initial, + () => {}, + ], + useSyncExternalStore: (_subscribe: unknown, read: () => unknown) => + read(), +})); + +vi.doMock('@inertiajs/react', async () => ({ + ...(await vi.importActual('@inertiajs/react')), + usePage: () => ({ props: { auth: { user: authUser } } }), + router: { + flushAll: () => {}, + visit: (url: { method: string }, options: VisitOptions) => + visits.push({ ...options, method: url.method }), + }, +})); + +vi.doMock('sonner', () => { + const toast = Object.assign( + (message: unknown) => { + toasts.push(typeof message === 'string' ? message : 'followed'); + }, + { + error: (message: string) => toasts.push(message), + success: (message: string) => toasts.push(message), + }, + ); + + return { toast }; +}); + +const { CommitAuthorFollowButton } = + await import('../../resources/js/components/commit-author-follow-button'); +const { FollowButton, resolveIsFollowing, toggleFollow } = + await import('../../resources/js/components/follow-button'); + +vi.stubGlobal('window', { + matchMedia: () => ({ matches: false }), + location: { + assign: (url: string) => locations.push(url), + }, +}); +vi.stubGlobal('document', { body: {} }); +vi.stubGlobal('fetch', async () => { + fetchCount += 1; + + if (preview === null) { + return { + ok: false, + status: 500, + json: async () => ({}), + }; + } + + return { + ok: true, + status: 200, + json: async () => ({ + isUnavailable: false, + ...preview, + }), + }; +}); +afterAll(() => vi.unstubAllGlobals()); + +function find( + node: ReactNode, + matches: (props: Props) => boolean, +): ReactElement | null { + if (Array.isArray(node)) { + for (const child of node) { + const found = find(child, matches); + + if (found) { + return found; + } + } + + return null; + } + + if (!React.isValidElement(node)) { + return null; + } + + return matches(node.props) ? node : find(node.props.children, matches); +} + +function author(username: string): Pick { + return { id: username, username }; +} + +function followButton( + user: Pick, +): ReactElement | null { + const node = CommitAuthorFollowButton({ + user, + keyShortcut: 'f', + }) as ReactNode; + + if (node === null) { + return null; + } + + return find( + node, + (props) => props['data-test'] === 'commit-follow-author-button', + ); +} + +async function pressFollow(user: Pick): Promise { + const button = followButton(user); + assert.ok(button); + (button.props.onClick as () => void)(); + await new Promise((resolve) => setTimeout(resolve)); +} + +function followFromProfile(user: Pick): void { + const profileButton = find( + FollowButton({ user, isFollowing: false }), + (props) => props['data-test'] === 'follow-button', + ); + assert.ok(profileButton); + (profileButton.props.onSuccess as () => void)(); +} + +test('the focused commit exposes f as its follow shortcut', () => { + authUser = { id: 'viewer' }; + const button = followButton(author('taylor')); + + assert.ok(button); + assert.equal(button.props['aria-keyshortcuts'], 'f'); + assert.equal(button.props['aria-label'], 'Follow or unfollow @taylor'); +}); + +test('your own commit has no follow shortcut', () => { + authUser = { id: 'taylor' }; + + assert.equal(followButton(author('taylor')), null); +}); + +test('guests are sent to sign in instead of following', async () => { + authUser = null; + locations.length = 0; + visits.length = 0; + + await pressFollow(author('guest-target')); + + assert.equal(visits.length, 0); + assert.ok(locations.at(-1)?.includes('login')); +}); + +test('f follows an author who is not already followed', async () => { + authUser = { id: 'viewer' }; + preview = { isFollowing: false }; + fetchCount = 0; + visits.length = 0; + + await pressFollow(author('newfriend')); + + assert.equal(fetchCount, 1); + assert.equal(visits.at(-1)?.method, 'post'); + visits.at(-1)?.onSuccess?.(); + visits.at(-1)?.onFinish?.(); +}); + +test('f unfollows once shared state already knows they are followed', async () => { + authUser = { id: 'viewer' }; + const user = author('known'); + followFromProfile(user); + fetchCount = 0; + visits.length = 0; + + await pressFollow(user); + + assert.equal(fetchCount, 0); + assert.equal(visits.at(-1)?.method, 'delete'); +}); + +test('resolveIsFollowing remembers a preview result for later shortcuts', async () => { + authUser = { id: 'viewer' }; + preview = { isFollowing: true }; + fetchCount = 0; + + assert.equal(await resolveIsFollowing(author('previewed')), true); + assert.equal(await resolveIsFollowing(author('previewed')), true); + assert.equal(fetchCount, 1); +}); + +test('resolveIsFollowing gives up when the author is unavailable', async () => { + authUser = { id: 'viewer' }; + preview = { isFollowing: false, isUnavailable: true }; + + assert.equal(await resolveIsFollowing(author('blocked-author')), null); +}); + +test('toggleFollow posts a follow and reports success', () => { + visits.length = 0; + toasts.length = 0; + let following = false; + + toggleFollow(author('toastable'), false, (next) => { + following = next; + }); + + assert.equal(visits.at(-1)?.method, 'post'); + visits.at(-1)?.onSuccess?.(); + assert.equal(following, true); + assert.equal(toasts.at(-1), 'followed'); +}); diff --git a/tests/tsconfig.json b/tests/tsconfig.json index 75337d9f0..7a6ee0cdb 100644 --- a/tests/tsconfig.json +++ b/tests/tsconfig.json @@ -6,9 +6,11 @@ }, "include": [ "Unit/**/*.test.ts", + "Unit/CommitAuthorFollow.test.tsx", "Unit/DraftStash.test.tsx", "Unit/ComposerDraft.test.tsx", "Unit/SearchDialogComposition.test.tsx", + "Unit/FollowState.test.tsx", "../resources/js/types/*.d.ts", "draft-images.config.ts" ] From 477b0fc8a85ea2039fbf4673be52e86a52f9c6ad Mon Sep 17 00:00:00 2001 From: Fernando Andrade <3598030+fernandofreamunde@users.noreply.github.com> Date: Mon, 19 Oct 2026 16:02:40 +0200 Subject: [PATCH 04/15] Potential fix for pull request finding 'Visually hidden follow button remains in tab order' Co-authored-by: Copilot Autofix powered by AI <175728472+Copilot@users.noreply.github.com> --- resources/js/components/commit-author-follow-button.tsx | 1 + 1 file changed, 1 insertion(+) diff --git a/resources/js/components/commit-author-follow-button.tsx b/resources/js/components/commit-author-follow-button.tsx index 264f26e95..2a03573e8 100644 --- a/resources/js/components/commit-author-follow-button.tsx +++ b/resources/js/components/commit-author-follow-button.tsx @@ -58,6 +58,7 @@ export function CommitAuthorFollowButton({ onClick={onClick} aria-label={`Follow or unfollow @${user.username}`} aria-keyshortcuts={keyShortcut} + tabIndex={-1} className="sr-only" data-test="commit-follow-author-button" > From a73de07eb53c09b36bacc86a63cd8eaea0a655ff Mon Sep 17 00:00:00 2001 From: Fernando Andrade Date: Mon, 19 Oct 2026 16:53:32 +0200 Subject: [PATCH 05/15] fix: prefer follow state written while a preview is pending A stale preview must not overwrite a follow confirmed by another control while the request was in flight. Co-authored-by: Cursor --- resources/js/components/follow-button.tsx | 9 ++++++++ tests/Unit/CommitAuthorFollow.test.tsx | 27 +++++++++++++++++++++++ 2 files changed, 36 insertions(+) diff --git a/resources/js/components/follow-button.tsx b/resources/js/components/follow-button.tsx index abfbe258f..6dca47bb0 100644 --- a/resources/js/components/follow-button.tsx +++ b/resources/js/components/follow-button.tsx @@ -102,6 +102,15 @@ export async function resolveIsFollowing( }, }); + // Another follow control may have confirmed state while this preview was in flight. + const writtenWhilePending = peekSharedState( + isFollowingKey(user), + ); + + if (writtenWhilePending !== undefined) { + return writtenWhilePending; + } + if (!response.ok) { toastWhenThrottled(response); diff --git a/tests/Unit/CommitAuthorFollow.test.tsx b/tests/Unit/CommitAuthorFollow.test.tsx index b052875bc..dd30af111 100644 --- a/tests/Unit/CommitAuthorFollow.test.tsx +++ b/tests/Unit/CommitAuthorFollow.test.tsx @@ -22,6 +22,8 @@ let preview: { isUnavailable?: boolean; } | null = { isFollowing: false }; let fetchCount = 0; +let holdFetch = false; +let releaseFetch: (() => void) | null = null; vi.doMock('react', () => ({ ...React, @@ -75,6 +77,12 @@ vi.stubGlobal('document', { body: {} }); vi.stubGlobal('fetch', async () => { fetchCount += 1; + if (holdFetch) { + await new Promise((resolve) => { + releaseFetch = resolve; + }); + } + if (preview === null) { return { ok: false, @@ -225,6 +233,25 @@ test('resolveIsFollowing gives up when the author is unavailable', async () => { assert.equal(await resolveIsFollowing(author('blocked-author')), null); }); +test('a follow that lands while the preview is pending wins over a stale response', async () => { + authUser = { id: 'viewer' }; + const user = author('raced'); + preview = { isFollowing: false }; + holdFetch = true; + + const pending = resolveIsFollowing(user); + await new Promise((resolve) => setTimeout(resolve)); + const resume = releaseFetch; + + assert.equal(typeof resume, 'function'); + + followFromProfile(user); + resume?.(); + holdFetch = false; + + assert.equal(await pending, true); +}); + test('toggleFollow posts a follow and reports success', () => { visits.length = 0; toasts.length = 0; From 7e79bacbe658838601de1caa2b51dfbeb84a0d3c Mon Sep 17 00:00:00 2001 From: Fernando Andrade Date: Mon, 19 Oct 2026 17:11:22 +0200 Subject: [PATCH 06/15] fix: let Vitest start in CI for the follow shortcut suites Laravel's Vite plugin blocks Vite under CI; bypass only that guard so vp test can run the suites. Co-authored-by: Cursor --- package.json | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/package.json b/package.json index d209dfd8d..260413f5f 100644 --- a/package.json +++ b/package.json @@ -8,7 +8,7 @@ "lint": "NODE_OPTIONS='--experimental-strip-types' vp fmt resources/ && NODE_OPTIONS='--experimental-strip-types' vp lint --fix", "test:lint": "NODE_OPTIONS='--experimental-strip-types' vp fmt --check resources/ && NODE_OPTIONS='--experimental-strip-types' vp lint", "test:types": "tsc --noEmit && tsc --noEmit --project tests/tsconfig.json", - "test:unit": "node --experimental-strip-types --test tests/Unit/*.test.ts && vp test run tests/Unit/CommitAuthorFollow.test.tsx tests/Unit/FollowState.test.tsx" + "test:unit": "node --experimental-strip-types --test tests/Unit/*.test.ts && env LARAVEL_BYPASS_ENV_CHECK=1 vp test run tests/Unit/CommitAuthorFollow.test.tsx tests/Unit/FollowState.test.tsx" }, "dependencies": { "@daypicker/react": "^10.0.2", From bcbf8a23007f617a30dacf25a9245bca0a98a82f Mon Sep 17 00:00:00 2001 From: Fernando Andrade Date: Mon, 19 Oct 2026 21:34:25 +0200 Subject: [PATCH 07/15] fix: type the empty body_segments in the notification image test An untyped [] became never[], which failed the Commit cast under vp lint. Co-authored-by: Cursor --- tests/Unit/NotificationIndex.test.tsx | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/tests/Unit/NotificationIndex.test.tsx b/tests/Unit/NotificationIndex.test.tsx index 3a1c1ec61..e141901eb 100644 --- a/tests/Unit/NotificationIndex.test.tsx +++ b/tests/Unit/NotificationIndex.test.tsx @@ -132,7 +132,7 @@ void test('shows the image of a starred image-only commit beside who starred it' commit: { id: 'image-only', body: '', - body_segments: [], + body_segments: [] as Commit['body_segments'], images: [ { id: 'image', From 070cb6dbb4fe5a6282a55c5977790e7e6f9703d9 Mon Sep 17 00:00:00 2001 From: Fernando Andrade Date: Mon, 19 Oct 2026 21:42:08 +0200 Subject: [PATCH 08/15] fix: stop production CI flakes from achievements and Tom search Achievement unlock checks query follows for Luddite, so assert only that auth.user loadCounts pivot queries stay off redirects. Fake the queue in Tom's tool test so sync language detection does not consume OpenCode replies. Co-authored-by: Cursor --- tests/Feature/Controllers/CommitControllerTest.php | 2 +- tests/Unit/Ai/Agents/TomTest.php | 2 ++ 2 files changed, 3 insertions(+), 1 deletion(-) diff --git a/tests/Feature/Controllers/CommitControllerTest.php b/tests/Feature/Controllers/CommitControllerTest.php index 422b78185..25538c012 100644 --- a/tests/Feature/Controllers/CommitControllerTest.php +++ b/tests/Feature/Controllers/CommitControllerTest.php @@ -76,7 +76,7 @@ expect(array_column(DB::getQueryLog(), 'query')) ->not->toBeEmpty() - ->each->not->toContain('"follows"'); + ->each->not->toContain('as aggregate from "follows"'); }); it('rejects non-text commit bodies with a validation error', function (mixed $body): void { diff --git a/tests/Unit/Ai/Agents/TomTest.php b/tests/Unit/Ai/Agents/TomTest.php index cb9399f17..170a80b61 100644 --- a/tests/Unit/Ai/Agents/TomTest.php +++ b/tests/Unit/Ai/Agents/TomTest.php @@ -12,12 +12,14 @@ use App\Models\User; use Illuminate\Http\Client\Request; use Illuminate\Support\Facades\Http; +use Illuminate\Support\Facades\Queue; use Laravel\Ai\Embeddings; use Laravel\Ai\Messages\Message; use Laravel\Ai\Tools\Request as ToolRequest; it('executes both semantic tools through OpenCode and sends only public evidence', function (): void { config(['ai.providers.opencode.key' => 'test-opencode', 'services.openai.key' => 'test']); + Queue::fake(); Embeddings::fake([[embeddingVector(0)], [embeddingVector(0)]]); $public = Commit::factory()->create(['body' => 'Public database topic', 'embedding' => embeddingVector(0)]); Commit::factory()->pending()->create(['body' => 'Pending secret', 'embedding' => embeddingVector(0)]); From 19dded78dc76a626aa5133fb43dabcfd438a5c90 Mon Sep 17 00:00:00 2001 From: Fernando Andrade Date: Tue, 20 Oct 2026 11:47:08 +0200 Subject: [PATCH 09/15] test: stop special-casing follow unit tests in package.json Keep CommitAuthorFollow typechecked via tests/tsconfig; leave test:unit as the *.test.ts runner. Co-authored-by: Cursor --- package.json | 2 +- tests/tsconfig.json | 1 - 2 files changed, 1 insertion(+), 2 deletions(-) diff --git a/package.json b/package.json index 90239f511..f45b0f962 100644 --- a/package.json +++ b/package.json @@ -8,7 +8,7 @@ "lint": "NODE_OPTIONS='--experimental-strip-types' vp fmt resources/ && NODE_OPTIONS='--experimental-strip-types' vp lint --fix", "test:lint": "NODE_OPTIONS='--experimental-strip-types' vp fmt --check resources/ && NODE_OPTIONS='--experimental-strip-types' vp lint", "test:types": "tsc --noEmit && tsc --noEmit --project tests/tsconfig.json", - "test:unit": "node --experimental-strip-types --test tests/Unit/*.test.ts && env LARAVEL_BYPASS_ENV_CHECK=1 vp test run tests/Unit/CommitAuthorFollow.test.tsx tests/Unit/FollowState.test.tsx" + "test:unit": "node --experimental-strip-types --test tests/Unit/*.test.ts" }, "dependencies": { "@daypicker/react": "^10.0.2", diff --git a/tests/tsconfig.json b/tests/tsconfig.json index 7f03cee1a..ee6e12cdf 100644 --- a/tests/tsconfig.json +++ b/tests/tsconfig.json @@ -11,7 +11,6 @@ "Unit/DraftStash.test.tsx", "Unit/ComposerDraft.test.tsx", "Unit/SearchDialogComposition.test.tsx", - "Unit/FollowState.test.tsx", "Unit/DraftImageReads.test.tsx", "Unit/ImageAttachments.test.tsx", "Unit/StagedRail.test.tsx", From b79791e7dba314246f688a650c9e3e9b2e26bea1 Mon Sep 17 00:00:00 2001 From: Fernando Andrade Date: Tue, 20 Oct 2026 16:48:56 +0200 Subject: [PATCH 10/15] feat: star the focused commit with s Keyboard navigation can toggle a star the same way as reply and quote, so j/k then s works without reaching for the mouse. Co-authored-by: Cursor --- resources/js/components/commit-card.tsx | 2 ++ resources/js/components/commit-star-button.tsx | 8 +++++++- resources/js/components/commit-thread.tsx | 1 + resources/js/components/keyboard-shortcuts-dialog.tsx | 1 + tests/Unit/CommitStarButton.test.tsx | 11 ++++++++++- tests/draft-images.config.ts | 2 ++ tests/tsconfig.json | 1 + 7 files changed, 24 insertions(+), 2 deletions(-) diff --git a/resources/js/components/commit-card.tsx b/resources/js/components/commit-card.tsx index d8cae0783..4ec4ac12a 100644 --- a/resources/js/components/commit-card.tsx +++ b/resources/js/components/commit-card.tsx @@ -483,6 +483,7 @@ export function CommitCard({
+ ); } diff --git a/resources/js/components/follow-button.tsx b/resources/js/components/follow-button.tsx index 7c7ceb0d0..a6d635c5f 100644 --- a/resources/js/components/follow-button.tsx +++ b/resources/js/components/follow-button.tsx @@ -134,7 +134,7 @@ export function onFollowHttpException( * Uses a follow control that already ran in this tab when it can, otherwise asks the preview endpoint once. */ export async function resolveIsFollowing( - user: Pick, + user: Pick, ): Promise { const known = peekSharedState(isFollowingKey(user)); @@ -180,7 +180,7 @@ export async function resolveIsFollowing( return data.isFollowing; } catch { - toast.error(`Could not check @${user.username}. Try again.`); + toast.error(`Could not check @${user.handle}. Try again.`); return null; } @@ -190,7 +190,7 @@ export async function resolveIsFollowing( * Follows or unfollows after resolving the current state, keeping every follow control in sync. */ export function toggleFollow( - user: Pick, + user: Pick, isFollowing: boolean, setIsFollowing: (isFollowing: boolean) => void, onFinish?: () => void, @@ -218,8 +218,8 @@ export function toggleFollow( toast( {next - ? `Following @${user.username}` - : `Unfollowed @${user.username}`} + ? `Following @${user.handle}` + : `Unfollowed @${user.handle}`} , ); }, diff --git a/tests/Unit/CommitAuthorFollow.test.tsx b/tests/Unit/CommitAuthorFollow.test.tsx index 0cb61046e..18b8bfb79 100644 --- a/tests/Unit/CommitAuthorFollow.test.tsx +++ b/tests/Unit/CommitAuthorFollow.test.tsx @@ -129,12 +129,12 @@ function find( return matches(node.props) ? node : find(node.props.children, matches); } -function author(username: string): Pick { - return { id: username, username }; +function author(handle: string): Pick { + return { id: handle, handle }; } function followButton( - user: Pick, + user: Pick, ): ReactElement | null { const node = CommitAuthorFollowButton({ user, @@ -151,14 +151,14 @@ function followButton( ); } -async function pressFollow(user: Pick): Promise { +async function pressFollow(user: Pick): Promise { const button = followButton(user); assert.ok(button); (button.props.onClick as () => void)(); await new Promise((resolve) => setTimeout(resolve)); } -function followFromProfile(user: Pick): void { +function followFromProfile(user: Pick): void { const profileButton = find( FollowButton({ user, isFollowing: false }), (props) => props['data-test'] === 'follow-button', From 32e89502c603bdfd31c6bc5f39867c6e8df2d85f Mon Sep 17 00:00:00 2001 From: Fernando Andrade Date: Tue, 3 Nov 2026 21:59:30 +0100 Subject: [PATCH 13/15] fix: drive follow shortcut tests through optimistic shared state FollowButton now writes is_following on start; the profile helper must match so f skips the preview when state is already known. Co-authored-by: Cursor --- tests/Unit/CommitAuthorFollow.test.tsx | 3 +++ 1 file changed, 3 insertions(+) diff --git a/tests/Unit/CommitAuthorFollow.test.tsx b/tests/Unit/CommitAuthorFollow.test.tsx index 18b8bfb79..61c4e88ab 100644 --- a/tests/Unit/CommitAuthorFollow.test.tsx +++ b/tests/Unit/CommitAuthorFollow.test.tsx @@ -164,7 +164,10 @@ function followFromProfile(user: Pick): void { (props) => props['data-test'] === 'follow-button', ); assert.ok(profileButton); + + (profileButton.props.onStart as () => void)(); (profileButton.props.onSuccess as () => void)(); + (profileButton.props.onFinish as () => void)(); } test('the focused commit exposes f as its follow shortcut', () => { From 850add9cfbe4ecba7bebaf79e5a3c2251629f42d Mon Sep 17 00:00:00 2001 From: Fernando Andrade Date: Tue, 3 Nov 2026 22:02:47 +0100 Subject: [PATCH 14/15] fix: follow with f only for user commit authors Production renamed commit.user to commit.author and added organization authors; the shortcut only applies to people. Co-authored-by: Cursor --- resources/js/components/commit-card.tsx | 7 ++++++- 1 file changed, 6 insertions(+), 1 deletion(-) diff --git a/resources/js/components/commit-card.tsx b/resources/js/components/commit-card.tsx index ef731a0e2..41c1481cb 100644 --- a/resources/js/components/commit-card.tsx +++ b/resources/js/components/commit-card.tsx @@ -278,7 +278,12 @@ export function CommitCard({ } onPointerLeave={onHover && ((event) => onHover(false, event))} > - + {commit.author.type === 'user' && ( + + )}
Date: Tue, 3 Nov 2026 22:20:19 +0100 Subject: [PATCH 15/15] fix: align follow shortcut with visit options and preview races Pass follow headers and async visits from f, skip deleted commits, toast non-throttle preview failures, and prefer shared state written while the preview body parses. Co-authored-by: Cursor --- .../commit-author-follow-button.tsx | 20 +++++-- resources/js/components/commit-card.tsx | 2 +- resources/js/components/follow-button.tsx | 16 +++++- tests/Unit/CommitAuthorFollow.test.tsx | 56 +++++++++++++++++-- 4 files changed, 82 insertions(+), 12 deletions(-) diff --git a/resources/js/components/commit-author-follow-button.tsx b/resources/js/components/commit-author-follow-button.tsx index bfdb54eb1..6e7986471 100644 --- a/resources/js/components/commit-author-follow-button.tsx +++ b/resources/js/components/commit-author-follow-button.tsx @@ -1,12 +1,13 @@ import { usePage } from '@inertiajs/react'; import { useRef } from 'react'; import { + followVisitOptions, resolveIsFollowing, toggleFollow, useIsFollowing, } from '@/components/follow-button'; import { signIn } from '@/lib/auth'; -import type { User } from '@/types'; +import type { Commit, CursorPaginated, User } from '@/types'; /** * Keyboard target for `f` on a focused commit: follow or unfollow that commit's author. @@ -18,7 +19,10 @@ export function CommitAuthorFollowButton({ user: Pick; keyShortcut?: string; }) { - const { auth } = usePage().props; + const { + component, + props: { auth, log, commits }, + } = usePage<{ commits?: CursorPaginated }>(); const [, setIsFollowing] = useIsFollowing(user, false); const isBusy = useRef(false); @@ -46,9 +50,15 @@ export function CommitAuthorFollowButton({ return; } - toggleFollow(user, isFollowing, setIsFollowing, () => { - isBusy.current = false; - }); + toggleFollow( + user, + isFollowing, + setIsFollowing, + () => { + isBusy.current = false; + }, + followVisitOptions(component, log, commits), + ); }); }; diff --git a/resources/js/components/commit-card.tsx b/resources/js/components/commit-card.tsx index 41c1481cb..7ffeed88b 100644 --- a/resources/js/components/commit-card.tsx +++ b/resources/js/components/commit-card.tsx @@ -278,7 +278,7 @@ export function CommitCard({ } onPointerLeave={onHover && ((event) => onHover(false, event))} > - {commit.author.type === 'user' && ( + {!wasDeleted && commit.author.type === 'user' && ( ( + isFollowingKey(user), + ); + + if (writtenWhileParsing !== undefined) { + return writtenWhileParsing; + } + if (data.isUnavailable) { rememberBlock(user); @@ -194,6 +205,7 @@ export function toggleFollow( isFollowing: boolean, setIsFollowing: (isFollowing: boolean) => void, onFinish?: () => void, + visitOptions: Pick = {}, ): void { const next = !isFollowing; @@ -212,6 +224,8 @@ export function toggleFollow( { preserveScroll: true, preserveState: true, + async: true, + ...visitOptions, onSuccess: () => { setIsFollowing(next); router.flushAll(); diff --git a/tests/Unit/CommitAuthorFollow.test.tsx b/tests/Unit/CommitAuthorFollow.test.tsx index 61c4e88ab..ecbe63a99 100644 --- a/tests/Unit/CommitAuthorFollow.test.tsx +++ b/tests/Unit/CommitAuthorFollow.test.tsx @@ -8,6 +8,8 @@ import type { User } from '../../resources/js/types'; type Props = Record & { children?: ReactNode }; type VisitOptions = { + async?: boolean; + headers?: Record; onHttpException?: (response: { status: number }) => boolean | void; onSuccess?: () => void; onFinish?: () => void; @@ -18,6 +20,7 @@ const toasts: string[] = []; const locations: string[] = []; let flushAllCount = 0; let authUser: { id: string } | null = { id: 'viewer' }; +let pageComponent = 'profile/show'; let preview: { isFollowing: boolean; isUnavailable?: boolean; @@ -25,6 +28,8 @@ let preview: { let fetchCount = 0; let holdFetch = false; let releaseFetch: (() => void) | null = null; +let holdJson = false; +let releaseJson: (() => void) | null = null; vi.doMock('react', () => ({ ...React, @@ -40,7 +45,10 @@ vi.doMock('react', () => ({ vi.doMock('@inertiajs/react', async () => ({ ...(await vi.importActual('@inertiajs/react')), - usePage: () => ({ props: { auth: { user: authUser } } }), + usePage: () => ({ + component: pageComponent, + props: { auth: { user: authUser }, log: null }, + }), router: { flushAll: () => { flushAllCount += 1; @@ -98,10 +106,18 @@ vi.stubGlobal('fetch', async () => { return { ok: true, status: 200, - json: async () => ({ - isUnavailable: false, - ...preview, - }), + json: async () => { + if (holdJson) { + await new Promise((resolve) => { + releaseJson = resolve; + }); + } + + return { + isUnavailable: false, + ...preview, + }; + }, }; }); afterAll(() => vi.unstubAllGlobals()); @@ -206,6 +222,8 @@ test('f follows an author who is not already followed', async () => { assert.equal(fetchCount, 1); assert.equal(visits.at(-1)?.method, 'post'); + assert.equal(visits.at(-1)?.async, true); + assert.equal(visits.at(-1)?.headers?.['X-Follow-Request'], 'true'); visits.at(-1)?.onSuccess?.(); visits.at(-1)?.onFinish?.(); }); @@ -240,6 +258,15 @@ test('resolveIsFollowing gives up when the author is unavailable', async () => { assert.equal(await resolveIsFollowing(author('blocked-author')), null); }); +test('resolveIsFollowing toasts when the preview fails for a reason other than throttling', async () => { + authUser = { id: 'viewer' }; + preview = null; + toasts.length = 0; + + assert.equal(await resolveIsFollowing(author('down')), null); + assert.equal(toasts.at(-1), 'Could not check @down. Try again.'); +}); + test('a follow that lands while the preview is pending wins over a stale response', async () => { authUser = { id: 'viewer' }; const user = author('raced'); @@ -259,6 +286,25 @@ test('a follow that lands while the preview is pending wins over a stale respons assert.equal(await pending, true); }); +test('a follow that lands while the preview body is parsing wins over a stale response', async () => { + authUser = { id: 'viewer' }; + const user = author('json-raced'); + preview = { isFollowing: false }; + holdJson = true; + + const pending = resolveIsFollowing(user); + await new Promise((resolve) => setTimeout(resolve)); + const resume = releaseJson; + + assert.equal(typeof resume, 'function'); + + followFromProfile(user); + resume?.(); + holdJson = false; + + assert.equal(await pending, true); +}); + test('toggleFollow posts a follow and reports success', () => { visits.length = 0; toasts.length = 0;