Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
31 commits
Select commit Hold shift + click to select a range
9a365bc
feat: follow a focused commit's author with f
fernandofreamunde Oct 19, 2026
09ae2f6
style: format peekSharedState for the linter
fernandofreamunde Oct 19, 2026
f9fea23
test: cover following a focused commit's author with f
fernandofreamunde Oct 19, 2026
477b0fc
Potential fix for pull request finding 'Visually hidden follow button…
fernandofreamunde Oct 19, 2026
a73de07
fix: prefer follow state written while a preview is pending
fernandofreamunde Oct 19, 2026
7e79bac
fix: let Vitest start in CI for the follow shortcut suites
fernandofreamunde Oct 19, 2026
0734255
Merge branch 'production' into feat/follow-author-shortcut
fernandofreamunde Oct 19, 2026
bcbf8a2
fix: type the empty body_segments in the notification image test
fernandofreamunde Oct 19, 2026
ab67de3
Merge remote-tracking branch 'origin/production' into feat/follow-aut…
fernandofreamunde Oct 19, 2026
070cb6d
fix: stop production CI flakes from achievements and Tom search
fernandofreamunde Oct 19, 2026
79dbb82
Merge remote-tracking branch 'origin/production' into feat/follow-aut…
fernandofreamunde Oct 19, 2026
6a6210f
Merge remote-tracking branch 'delme/production' into feat/follow-auth…
fernandofreamunde Oct 20, 2026
19dded7
test: stop special-casing follow unit tests in package.json
fernandofreamunde Oct 20, 2026
b79791e
feat: star the focused commit with s
fernandofreamunde Oct 20, 2026
2f83cbc
Merge branch 'production' into feat/follow-author-shortcut
fernandofreamunde Oct 20, 2026
5cc2d8a
Merge remote-tracking branch 'origin/production' into feat/follow-aut…
fernandofreamunde Oct 20, 2026
130666f
Merge branch 'production' into feat/follow-author-shortcut
fernandofreamunde Oct 21, 2026
e7e976b
Merge remote-tracking branch 'fork/feat/follow-author-shortcut' into …
fernandofreamunde Oct 21, 2026
d59d7eb
Merge remote-tracking branch 'origin/production' into feat/follow-aut…
fernandofreamunde Oct 21, 2026
22f5026
Merge branch 'production' into feat/follow-author-shortcut
fernandofreamunde Oct 22, 2026
b49bf9b
Merge branch 'production' into feat/follow-author-shortcut
fernandofreamunde Oct 27, 2026
b83517a
fix: keep follow shortcut and reply stars from racing shared state
fernandofreamunde Oct 27, 2026
8561f7f
Merge branch 'production' into feat/follow-author-shortcut
fernandofreamunde Oct 29, 2026
65f6e10
fix: use handle after production renamed username on users
fernandofreamunde Oct 29, 2026
6be40da
Merge remote-tracking branch 'origin/production' into feat/follow-aut…
fernandofreamunde Oct 29, 2026
fda2a54
Merge remote-tracking branch 'origin/production' into feat/follow-aut…
fernandofreamunde Nov 3, 2026
32e8950
fix: drive follow shortcut tests through optimistic shared state
fernandofreamunde Nov 3, 2026
850add9
fix: follow with f only for user commit authors
fernandofreamunde Nov 3, 2026
8751fd4
fix: align follow shortcut with visit options and preview races
fernandofreamunde Nov 3, 2026
4649f14
Merge remote-tracking branch 'origin/production' into feat/follow-aut…
fernandofreamunde Nov 3, 2026
8b648e8
Merge remote-tracking branch 'origin/production' into feat/follow-aut…
fernandofreamunde Nov 3, 2026
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions KNOWLEDGE_BASE.md
Original file line number Diff line number Diff line change
Expand Up @@ -523,6 +523,7 @@ Press **?** to open the shortcut reference. Navigation shortcuts work outside te
| `y` | Copy the focused commit's link. |
| `r` / `q` / `o` | Reply, quote, or show/hide replies. |
| `s` | Star or unstar the focused commit. |
| `f` | Follow or unfollow the focused commit's author. |
| `c` | Write a commit. |
| `⌘/Ctrl+K` or `/` | Search developers and commits. |
| `⌘/Ctrl+Enter` | Submit from the composer. |
Expand Down
78 changes: 78 additions & 0 deletions resources/js/components/commit-author-follow-button.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,78 @@
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 { Commit, CursorPaginated, 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<User, 'id' | 'handle'>;
keyShortcut?: string;
}) {
const {
component,
props: { auth, log, commits },
} = usePage<{ commits?: CursorPaginated<Commit> }>();
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;
},
followVisitOptions(component, log, commits),
);
});
};

return (
<button
type="button"
onClick={onClick}
aria-label={`Follow or unfollow @${user.handle}`}
aria-keyshortcuts={keyShortcut}
tabIndex={-1}
className="sr-only"
data-test="commit-follow-author-button"
>
Follow or unfollow @{user.handle}
</button>
);
}
7 changes: 7 additions & 0 deletions resources/js/components/commit-card.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,7 @@ import type { MouseEvent, PointerEvent, ReactNode } from 'react';
import { useCallback, useContext, 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';
Expand Down Expand Up @@ -277,6 +278,12 @@ export function CommitCard({
}
onPointerLeave={onHover && ((event) => onHover(false, event))}
>
{!wasDeleted && commit.author.type === 'user' && (
<CommitAuthorFollowButton
user={commit.author}
keyShortcut="f"
/>
)}
<div
className="grid min-h-0 grid-cols-[4rem_minmax(0,1fr)] overflow-hidden sm:grid-cols-[4.5rem_minmax(0,1fr)]"
data-graph-leaving={deletion === 'deleting' || undefined}
Expand Down
2 changes: 1 addition & 1 deletion resources/js/components/commit-star-button.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -169,8 +169,8 @@ export function CommitStarButton({
type="button"
onClick={toggle}
aria-pressed={starred}
aria-keyshortcuts={keyShortcut}
aria-label={starLabel(starred, count)}
aria-keyshortcuts={keyShortcut}
className={cn(
'group/star inline-flex items-center gap-1 rounded-md px-1.5 py-1 font-mono text-[13px] text-muted-foreground transition-colors hover:bg-accent hover:text-foreground',
starred && 'text-primary hover:text-primary',
Expand Down
124 changes: 123 additions & 1 deletion resources/js/components/follow-button.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -12,10 +12,15 @@ import {
TooltipContent,
TooltipTrigger,
} from '@/components/ui/tooltip';
import { refreshSharedState, useSharedState } from '@/hooks/use-shared-state';
import {
peekSharedState,
refreshSharedState,
useSharedState,
} from '@/hooks/use-shared-state';
import { signIn } from '@/lib/auth';
import { toastWhenThrottled } from '@/lib/throttle';
import { cn } from '@/lib/utils';
import { show as userPreview } from '@/routes/user-preview';
import type { Commit, CursorPaginated, User } from '@/types';

/**
Expand Down Expand Up @@ -125,6 +130,123 @@ export function onFollowHttpException(
return false;
}

/**
* 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, 'id' | 'handle'>,
): Promise<boolean | null> {
const known = peekSharedState<boolean>(isFollowingKey(user));

if (known !== undefined) {
return known;
}

try {
const response = await fetch(userPreview(user).url, {
headers: {
Accept: 'application/json',
'X-Requested-With': 'XMLHttpRequest',
},
});

// Another follow control may have confirmed state while this preview was in flight.
const writtenWhilePending = peekSharedState<boolean>(
isFollowingKey(user),
);

if (writtenWhilePending !== undefined) {
return writtenWhilePending;
}

if (!response.ok) {
if (!toastWhenThrottled(response)) {
toast.error(`Could not check @${user.handle}. Try again.`);
}

return null;
Comment thread
fernandofreamunde marked this conversation as resolved.
Resolved
}

const data = (await response.json()) as {
isFollowing: boolean;
isUnavailable: boolean;
};

// A follow may have landed while response.json() was still pending.
const writtenWhileParsing = peekSharedState<boolean>(
isFollowingKey(user),
);

if (writtenWhileParsing !== undefined) {
return writtenWhileParsing;
}

if (data.isUnavailable) {
rememberBlock(user);

return null;
}

refreshSharedState(new Map([[isFollowingKey(user), data.isFollowing]]));
Comment thread
fernandofreamunde marked this conversation as resolved.
Resolved

return data.isFollowing;
} catch {
toast.error(`Could not check @${user.handle}. Try again.`);

return null;
}
}

/**
* Follows or unfollows after resolving the current state, keeping every follow control in sync.
*/
export function toggleFollow(
user: Pick<User, 'id' | 'handle'>,
isFollowing: boolean,
setIsFollowing: (isFollowing: boolean) => void,
onFinish?: () => void,
visitOptions: Pick<VisitOptions, 'only' | 'headers'> = {},
): void {
const next = !isFollowing;

if (peekSharedState<boolean>(isPendingKey(user)) === true) {
onFinish?.();

return;
}

refreshSharedState(new Map([[isPendingKey(user), true]]));

router.visit(
next
? UserFollowController.store(user)
: UserFollowController.destroy(user),
{
preserveScroll: true,
preserveState: true,
Comment thread
coderabbitai[bot] marked this conversation as resolved.
Resolved
async: true,
...visitOptions,
onSuccess: () => {
setIsFollowing(next);
router.flushAll();
toast(
<span className="font-mono text-[13px]">
{next
? `Following @${user.handle}`
: `Unfollowed @${user.handle}`}
</span>,
);
},
onHttpException: (response) =>
onFollowHttpException(user, response),
onFinish: () => {
refreshSharedState(new Map([[isPendingKey(user), false]]));
onFinish?.();
},
},
);
}
Comment thread
fernandofreamunde marked this conversation as resolved.
Resolved

export function useFollow(
user: Pick<User, 'id' | 'handle'>,
initialIsFollowing: boolean,
Expand Down
4 changes: 4 additions & 0 deletions resources/js/components/keyboard-shortcuts-dialog.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -27,6 +27,10 @@ const groups = (
{ keys: [['r']], label: 'Reply' },
{ keys: [['q']], label: 'Quote' },
{ keys: [['s']], label: 'Star or unstar' },
{
keys: [['f']],
label: "Follow or unfollow the focused commit's author",
},
{ keys: [['o']], label: 'Show or hide replies' },
{ keys: [['y']], label: 'Copy link' },
],
Expand Down
9 changes: 9 additions & 0 deletions resources/js/hooks/use-shared-state.ts
Original file line number Diff line number Diff line change
Expand Up @@ -30,6 +30,15 @@ export function refreshSharedState(fresh: Map<string, SharedValue>): void {
}
}

/**
* Reads a value only when something has written it, so callers can tell "unknown" from the initial default.
*/
export function peekSharedState<T extends SharedValue>(
key: string,
): T | undefined {
return values.has(key) ? (values.get(key) as T) : undefined;
}

export function useSharedState<T extends SharedValue>(
key: string,
initialValue: T,
Expand Down
Loading