diff --git a/KNOWLEDGE_BASE.md b/KNOWLEDGE_BASE.md
index 236f592e6..a86eb5f7f 100644
--- a/KNOWLEDGE_BASE.md
+++ b/KNOWLEDGE_BASE.md
@@ -148,7 +148,7 @@ Sources: [badge definitions](app/Enums/Achievement.php), [unlock conditions and
Follow or unfollow from a developer's profile or a follow control elsewhere in the app. Following puts their commits in your Following log. Following or unfollowing from a log updates the controls, counts, and live-following sidebar without navigating away: Latest and Trending keep their loaded commits and ranking, while Following updates its commits and empty-state suggestions. You cannot follow yourself or follow across a block. GitHub follows are imported once: connections to developers already on /dev/null are added in both directions, with up to 1,000 GitHub connections checked each way. This is not continuous synchronization, so unfollowing someone on /dev/null sticks.
-**Who to follow** suggests developers in the side column or within the log on smaller screens, each with the first line of their bio and why they are suggested (how many people you follow follow them, or their follower count). Follow from the icon next to each suggestion; for someone who already follows you, it follows back. The X icon marks a suggestion **Not interested**: that developer is never suggested to you again, without muting them, and the next suggestion takes their place. Refresh for different suggestions, or use **… → Don't show again** to hide the block, with an immediate **Undo** option. Show or hide these suggestions in Settings → Appearance. Suggested developers must have a visible top-level commit. On profiles, suggestions prioritize the people that developer follows; on commit pages, they prioritize visible thread participants. Regular suggestions fill any remaining places. You can also follow back from the notifications list.
+**Who to follow** suggests developers in the side column or within the log on smaller screens, each with the first line of their bio and why they are suggested (how many people you follow follow them, or their follower count). Follow from the icon next to each suggestion; for someone who already follows you, it follows back. The X icon marks a suggestion **Not interested**: that developer is never suggested to you again, without muting them, and the next suggestion takes their place. Refresh for different suggestions. They also move on to other people every minute, but not while they're out of view, you're pointing at them or moving through them with the keyboard, or the tab is in the background; turn that off with the **… → Refresh automatically** switch. Use **… → Don't show again** to hide the block, with an immediate **Undo** option. Choose 1, 3 (the default) or 5 suggestions, or hide them, in Settings → Appearance. Suggested developers must have a visible top-level commit. On profiles, suggestions prioritize the people that developer follows; on commit pages, they prioritize visible thread participants. Regular suggestions fill any remaining places. You can also follow back from the notifications list.
Sources: [follow import](app/Actions/CreateUserGitHubFollows.php), [suggestions](resources/js/components/who-to-follow.tsx), [suggestion eligibility](app/Queries/SuggestedUsersQuery.php), [appearance settings](resources/js/pages/appearance/update.tsx), [notifications page](resources/js/pages/notification/index.tsx).
@@ -410,7 +410,7 @@ Settings → Appearance offers:
- **Navigation:** expanded or collapsed, on wide screens. The navigation also has its own collapse button.
- **Lane dimming:** on by default; other commits fade while the pointer rests on a commit or its author.
- **Replies:** how many replies show under a commit before "Show more": 1, 3, 5, 10, 15, 20, or 25 (3 by default).
-- **Who to follow:** show or hide the suggestions.
+- **Who to follow:** show 1, 3, or 5 suggestions, or hide them.
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.
diff --git a/app/Enums/WhoToFollowCount.php b/app/Enums/WhoToFollowCount.php
new file mode 100644
index 000000000..d01f6e0e1
--- /dev/null
+++ b/app/Enums/WhoToFollowCount.php
@@ -0,0 +1,22 @@
+ (string) $count->value === $request->cookie('who_to_follow_count')) ?? self::Three;
+ }
+}
diff --git a/app/Http/Middleware/HandleInertiaRequests.php b/app/Http/Middleware/HandleInertiaRequests.php
index 7aac3eac8..14a326bbb 100644
--- a/app/Http/Middleware/HandleInertiaRequests.php
+++ b/app/Http/Middleware/HandleInertiaRequests.php
@@ -7,6 +7,7 @@
use App\Dto\ReaderClock;
use App\Enums\Log;
use App\Enums\RepliesPerPage;
+use App\Enums\WhoToFollowCount;
use App\Http\Requests\ListNotificationsRequest;
use App\Models\Commit;
use App\Models\User;
@@ -79,6 +80,7 @@ public function share(Request $request): array
$suggestedUsers = $user instanceof User && $showsWhoToFollow && $hasAside
? Inertia::defer(fn (): Collection => resolve(SuggestedUsersQuery::class)->get(
$user,
+ limit: WhoToFollowCount::of($request)->value,
round: max(0, (int) $request->cookie('who_to_follow_round')),
spares: 3,
context: $context,
@@ -113,6 +115,8 @@ public function share(Request $request): array
'hasUnmoderatedCommits' => fn (): bool => $isAdmin && Commit::query()->pending()->exists(),
'sidebarOpen' => ! $request->hasCookie('sidebar_state') || $request->cookie('sidebar_state') === 'true',
'showsWhoToFollow' => $showsWhoToFollow,
+ 'whoToFollowCount' => WhoToFollowCount::of($request)->value,
+ 'refreshesWhoToFollow' => $request->cookie('who_to_follow_auto_refresh') !== 'false',
'liveFollowing' => fn (): Collection => $user instanceof User && $hasAside
? resolve(LiveFollowingQuery::class)->get($user)
: collect(),
diff --git a/app/Queries/SuggestedUsersQuery.php b/app/Queries/SuggestedUsersQuery.php
index 2bbc91484..e2297d1ef 100644
--- a/app/Queries/SuggestedUsersQuery.php
+++ b/app/Queries/SuggestedUsersQuery.php
@@ -186,7 +186,8 @@ private function pages(Randomizer $randomizer, Collection $network, Collection $
$pages = [];
do {
- $networkSlots = $prioritizeNetwork ? $limit : $limit - $randomizer->getInt(1, max(1, $limit - 1));
+ // A single slot goes to either pool, so one suggestion isn't always someone popular.
+ $networkSlots = $prioritizeNetwork ? $limit : $limit - $randomizer->getInt($limit > 1 ? 1 : 0, max(1, $limit - 1));
$popular ??= new Collection($randomizer->shuffleArray($popularPool->all()));
$pageNetwork = $network->take(max($networkSlots, $limit - $popular->count()));
diff --git a/bootstrap/app.php b/bootstrap/app.php
index 121bc7a65..388766f5a 100644
--- a/bootstrap/app.php
+++ b/bootstrap/app.php
@@ -43,7 +43,7 @@
$middleware->prepend(StripNullBytes::class);
$middleware->append(PreventFraming::class);
- $middleware->encryptCookies(except: ['appearance', 'sidebar_state', 'who_to_follow', 'who_to_follow_round', 'lane_dimming', 'replies_per_page', 'time_zone', 'hour_cycle']);
+ $middleware->encryptCookies(except: ['appearance', 'sidebar_state', 'who_to_follow', 'who_to_follow_round', 'who_to_follow_count', 'who_to_follow_auto_refresh', 'lane_dimming', 'replies_per_page', 'time_zone', 'hour_cycle']);
$middleware->redirectGuestsTo(fn (): string => route('home'));
$middleware->redirectUsersTo(fn (Request $request): string => route('log.show', $request->user()->log ?? Log::Following));
diff --git a/resources/js/components/who-to-follow-tabs.tsx b/resources/js/components/who-to-follow-tabs.tsx
index 9b6950485..93d340c30 100644
--- a/resources/js/components/who-to-follow-tabs.tsx
+++ b/resources/js/components/who-to-follow-tabs.tsx
@@ -1,26 +1,46 @@
import type { LucideIcon } from 'lucide-react';
-import { Eye, EyeOff } from 'lucide-react';
+import { EyeOff } from 'lucide-react';
import { useState } from 'react';
import { SegmentedControl } from '@/components/segmented-control';
import { useWhoToFollow } from '@/hooks/use-who-to-follow';
import { withViewTransition } from '@/lib/utils';
+const counts = [1, 3, 5];
+
+/**
+ * Hide stands for 0, so hiding keeps the count chosen before for when the suggestions come back. Hiding or bringing
+ * them back from the block itself moves the selection along.
+ */
export default function WhoToFollowTabs() {
- const { showsWhoToFollow, setShowsWhoToFollow } = useWhoToFollow();
- const [selected, setSelected] = useState(showsWhoToFollow);
+ const {
+ showsWhoToFollow,
+ setShowsWhoToFollow,
+ whoToFollowCount,
+ setWhoToFollowCount,
+ } = useWhoToFollow();
+ const shown = showsWhoToFollow ? whoToFollowCount : 0;
+ const [selected, setSelected] = useState(shown);
+ const [lastShown, setLastShown] = useState(shown);
+
+ if (shown !== lastShown) {
+ setLastShown(shown);
+ setSelected(shown);
+ }
- const tabs: { value: boolean; icon: LucideIcon; label: string }[] = [
- { value: true, icon: Eye, label: 'Show' },
- { value: false, icon: EyeOff, label: 'Hide' },
+ const tabs: { value: number; icon?: LucideIcon; label: string }[] = [
+ { value: 0, icon: EyeOff, label: 'Hide' },
+ ...counts.map((count) => ({ value: count, label: String(count) })),
];
- const select = (value: boolean): void => {
+ const select = (value: number): void => {
if (value === selected) {
return;
}
void withViewTransition(() => setSelected(value)).then(() =>
- setShowsWhoToFollow(value),
+ value === 0
+ ? setShowsWhoToFollow(false)
+ : setWhoToFollowCount(value),
);
};
@@ -31,6 +51,7 @@ export default function WhoToFollowTabs() {
options={tabs}
value={selected}
onChange={select}
+ className="tabular-nums"
/>
);
}
diff --git a/resources/js/components/who-to-follow.tsx b/resources/js/components/who-to-follow.tsx
index a06f04722..cdfe6ceb7 100644
--- a/resources/js/components/who-to-follow.tsx
+++ b/resources/js/components/who-to-follow.tsx
@@ -1,6 +1,7 @@
import { Deferred, Link, router, usePage } from '@inertiajs/react';
-import { Ellipsis, EyeOff, RefreshCw, X } from 'lucide-react';
-import { useRef, useState } from 'react';
+import { Ellipsis, EyeOff, RefreshCw, Timer, X } from 'lucide-react';
+import type { RefObject } from 'react';
+import { useEffect, useEffectEvent, useRef, useState } from 'react';
import SuggestionDismissalController from '@/actions/App/Http/Controllers/SuggestionDismissalController';
import {
FollowButton,
@@ -12,6 +13,7 @@ import {
DropdownMenu,
DropdownMenuContent,
DropdownMenuItem,
+ DropdownMenuSeparator,
DropdownMenuTrigger,
} from '@/components/ui/dropdown-menu';
import { Skeleton } from '@/components/ui/skeleton';
@@ -70,8 +72,6 @@ function Reason({ reason, title }: { reason: string; title: string }) {
);
}
-const shownSuggestionsCount = 3;
-
function DismissButton({
user,
onDismiss,
@@ -257,6 +257,7 @@ function SuggestedUsersSkeleton({
rowClassName?: string;
isStacked?: boolean;
}) {
+ const { whoToFollowCount } = usePage().props;
const details = (
<>
@@ -266,7 +267,7 @@ function SuggestedUsersSkeleton({
return (
<>
- {[0, 1, 2].map((index) => (
+ {Array.from({ length: whoToFollowCount }, (_, index) => (
@@ -294,7 +295,7 @@ function WhoToFollowRefreshButton({ disabled }: { disabled: boolean }) {
variant="ghost"
size="icon"
className="size-7 text-muted-foreground"
- onClick={refreshSuggestedUsers}
+ onClick={() => refreshSuggestedUsers()}
disabled={disabled}
aria-disabled={isRefreshing}
aria-label="Suggest other people"
@@ -339,7 +340,9 @@ function WhoToFollowMenu({ onHide }: { onHide: () => void }) {
-
+
+
+
@@ -355,31 +358,123 @@ function WhoToFollowMenu({ onHide }: { onHide: () => void }) {
}
/**
- * Hiding only writes the cookie, so the block stays put with an undo until the next visit leaves it out.
+ * Kept open on select, so the switch can be seen to flip.
*/
+function AutoRefreshItem() {
+ const { refreshesWhoToFollow, setRefreshesWhoToFollow } = useWhoToFollow();
+
+ return (
+ {
+ event.preventDefault();
+ setRefreshesWhoToFollow(!refreshesWhoToFollow);
+ }}
+ data-test="who-to-follow-auto-refresh"
+ >
+
+
+ Refresh automatically
+
+ watch -n 60
+
+
+
+
+
+
+ );
+}
+
function useHideWhoToFollow() {
- const { showsWhoToFollow, suggestedUsers } = usePage().props;
- const { rememberShowsWhoToFollow } = useWhoToFollow();
- const [isHidden, setIsHidden] = useState(false);
- const { hasDismissed, isEmptied, dismiss } = useDismissSuggestion();
-
- const toggle = (hides: boolean) => {
- rememberShowsWhoToFollow(!hides);
- setIsHidden(hides);
- };
+ const { showsWhoToFollow, suggestedUsers, whoToFollowCount } =
+ usePage().props;
+ const { isHidden, hideWhoToFollow } = useWhoToFollow();
+ const { hasDismissed, isEmptied, isSettled, dismiss } =
+ useDismissSuggestion();
+ const isVisible =
+ showsWhoToFollow && (suggestedUsers?.length !== 0 || hasDismissed);
+ const block = useRef(null);
+ useAutoRefreshSuggestedUsers(
+ block,
+ !isVisible || isHidden || isEmptied || !isSettled,
+ );
return {
- isVisible:
- showsWhoToFollow && (suggestedUsers?.length !== 0 || hasDismissed),
+ isVisible,
isHidden,
isEmptied,
- hide: () => toggle(true),
- undo: () => toggle(false),
- suggestedUsers: suggestedUsers?.slice(0, shownSuggestionsCount),
+ hide: () => hideWhoToFollow(true),
+ undo: () => hideWhoToFollow(false),
+ suggestedUsers: suggestedUsers?.slice(0, whoToFollowCount),
dismiss,
+ block,
};
}
+const autoRefreshInterval = 60_000;
+
+/**
+ * Unless switched off, the suggestions move on to the next people every minute. Only the block in view refreshes, so
+ * the one in the log stays put once scrolled past, and not while the tab is in the background, the pointer or keyboard
+ * focus is in the block, or a menu or preview is open, so nobody is swapped out from under a click. Focus a click
+ * leaves behind, like on the menu button once the menu closes, doesn't count. Any change to the suggestions, like a
+ * refresh or a dismissal, starts the minute over.
+ */
+function useAutoRefreshSuggestedUsers(
+ block: RefObject,
+ isPaused: boolean,
+): void {
+ const { suggestedUsers } = usePage().props;
+ const { refreshesWhoToFollow, refreshSuggestedUsers } = useWhoToFollow();
+ const isActive =
+ refreshesWhoToFollow && !isPaused && suggestedUsers !== undefined;
+
+ const refresh = useEffectEvent(() => {
+ const element = block.current;
+ const bounds = element?.getBoundingClientRect();
+
+ if (
+ document.visibilityState !== 'visible' ||
+ !element?.checkVisibility() ||
+ !bounds ||
+ bounds.bottom <= 0 ||
+ bounds.top >= window.innerHeight ||
+ element.matches(':hover') ||
+ element.querySelector(':focus-visible, [data-state="open"]') ||
+ document.querySelector('[role="dialog"][data-state="open"]')
+ ) {
+ return;
+ }
+
+ refreshSuggestedUsers(true);
+ });
+
+ useEffect(() => {
+ if (!isActive) {
+ return;
+ }
+
+ const interval = window.setInterval(refresh, autoRefreshInterval);
+
+ return () => window.clearInterval(interval);
+ }, [isActive, suggestedUsers]);
+}
+
type TopUp = 'idle' | 'running' | 'queued';
const dismissedIds = new Set();
@@ -398,7 +493,8 @@ const dismissedIds = new Set();
* page load leaves the block out like any other time there's nobody to suggest.
*/
function useDismissSuggestion() {
- const { suggestedUsers = [] } = usePage().props;
+ const { suggestedUsers = [], whoToFollowCount: shownSuggestionsCount } =
+ usePage().props;
const [dismissingCount, setDismissingCount] = useSharedState(
'who-to-follow-dismissing',
0,
@@ -568,6 +664,7 @@ function useDismissSuggestion() {
return {
hasDismissed,
+ isSettled: dismissingCount === 0 && topUp === 'idle',
isEmptied:
hasDismissed &&
dismissingCount === 0 &&
@@ -631,6 +728,7 @@ export function WhoToFollow() {
undo,
suggestedUsers,
dismiss,
+ block,
} = useHideWhoToFollow();
if (!isVisible) {
@@ -639,6 +737,7 @@ export function WhoToFollow() {
return (
@@ -692,6 +791,7 @@ export function WhoToFollowInLog() {
undo,
suggestedUsers,
dismiss,
+ block,
} = useHideWhoToFollow();
if (!isVisible) {
@@ -699,7 +799,11 @@ export function WhoToFollowInLog() {
}
return (
-
+
(
+ 'who-to-follow-hidden',
+ false,
+ );
+ const [isRefreshing, setIsRefreshing] = useSharedState
(
+ 'who-to-follow-refreshing',
+ false,
+ );
+
+ // Once a visit leaves the suggestions out, there's no block left to undo from.
+ useEffect(() => {
+ if (!showsWhoToFollow && isHidden) {
+ setIsHidden(false);
+ }
+ }, [showsWhoToFollow, isHidden, setIsHidden]);
const rememberShowsWhoToFollow = (shows: boolean): void => {
setCookie('who_to_follow', String(shows));
router.flushAll();
};
+ const hideWhoToFollow = (hides: boolean): void => {
+ rememberShowsWhoToFollow(!hides);
+ setIsHidden(hides);
+ };
+
+ /**
+ * Reloads run side by side, so a newer choice cancels the one still waiting, and a refresh of the suggestions
+ * fetched for the count from before: otherwise an older answer that lands last would put the count or the people
+ * from before back on the page. A block hidden from its own menu stays hidden until the latest reload is through,
+ * so neither the suggestions nor the count from before show in between, and then comes back the way the reload's
+ * own changes do.
+ */
+ const reloadSettings = (options: ReloadOptions): void => {
+ const reload = ++latestSettingsReload;
+ const wasHidden = isHidden;
+
+ settingsReloadInFlight?.cancel();
+ refreshInFlight?.cancel();
+ router.reload({
+ ...options,
+ viewTransition: true,
+ onCancelToken: (token) => {
+ settingsReloadInFlight = token;
+ },
+ onFinish: () => {
+ if (reload !== latestSettingsReload) {
+ return;
+ }
+
+ settingsReloadInFlight = null;
+
+ if (wasHidden) {
+ void withViewTransition(() => setIsHidden(false));
+ }
+ },
+ });
+ };
+
const setShowsWhoToFollow = (shows: boolean): void => {
rememberShowsWhoToFollow(shows);
- router.reload({
+ reloadSettings({
only: ['showsWhoToFollow', 'suggestedUsers'],
reset: ['suggestedUsers'],
- viewTransition: true,
});
};
/**
- * Each refresh moves to the next page of today's picks. The old suggestions stay until the next ones arrive.
+ * Choosing how many to suggest also brings the suggestions back if they were hidden.
*/
- const refreshSuggestedUsers = (): void => {
+ const setWhoToFollowCount = (count: number): void => {
+ setCookie('who_to_follow_count', String(count));
+ rememberShowsWhoToFollow(true);
+ reloadSettings({
+ only: ['showsWhoToFollow', 'whoToFollowCount', 'suggestedUsers'],
+ reset: ['suggestedUsers'],
+ });
+ };
+
+ const setRefreshesWhoToFollow = (refreshes: boolean): void => {
+ setCookie('who_to_follow_auto_refresh', String(refreshes));
+ router.flushAll();
+ router.replaceProp('refreshesWhoToFollow', refreshes);
+ };
+
+ /**
+ * Each refresh moves to the next page of today's picks. The old suggestions stay until the next ones arrive. One
+ * made in the background neither holds up nor cancels a visit, and a gateway error doesn't come up for it.
+ */
+ const refreshSuggestedUsers = (inBackground = false): void => {
if (isRefreshing) {
return;
}
setCookie('who_to_follow_round', String(currentRound() + 1), 1);
- router.reload({
+
+ const options: ReloadOptions = {
only: ['suggestedUsers'],
reset: ['suggestedUsers'],
+ async: inBackground,
onStart: () => setIsRefreshing(true),
- onFinish: () => setIsRefreshing(false),
- });
+ onCancelToken: (token) => {
+ refreshInFlight = token;
+ },
+ onFinish: () => {
+ refreshInFlight = null;
+ setIsRefreshing(false);
+ },
+ };
+
+ router.reload(
+ inBackground ? backgroundPollOptions(options)() : options,
+ );
};
return {
- showsWhoToFollow,
- rememberShowsWhoToFollow,
+ showsWhoToFollow: showsWhoToFollow && !isHidden,
+ isHidden,
+ hideWhoToFollow,
setShowsWhoToFollow,
+ whoToFollowCount,
+ setWhoToFollowCount,
+ refreshesWhoToFollow,
+ setRefreshesWhoToFollow,
isRefreshing,
refreshSuggestedUsers,
} as const;
diff --git a/resources/js/pages/appearance/update.tsx b/resources/js/pages/appearance/update.tsx
index d4330ae38..97932cc6a 100644
--- a/resources/js/pages/appearance/update.tsx
+++ b/resources/js/pages/appearance/update.tsx
@@ -48,7 +48,7 @@ export default function Update() {
diff --git a/resources/js/types/global.d.ts b/resources/js/types/global.d.ts
index 07c0226c7..7acd4693a 100644
--- a/resources/js/types/global.d.ts
+++ b/resources/js/types/global.d.ts
@@ -31,6 +31,8 @@ declare module '@inertiajs/core' {
hasUnmoderatedCommits: boolean;
sidebarOpen: boolean;
showsWhoToFollow: boolean;
+ whoToFollowCount: number;
+ refreshesWhoToFollow: boolean;
dimsLanes: boolean;
repliesPerPage: number;
clock: SharedClock;
diff --git a/tests/Unit/FollowState.test.tsx b/tests/Unit/FollowState.test.tsx
index 68026c200..9bb93db4b 100644
--- a/tests/Unit/FollowState.test.tsx
+++ b/tests/Unit/FollowState.test.tsx
@@ -12,6 +12,8 @@ let component = 'user/show';
let log: string | undefined;
let loadedCommits: { data: { id: string }[] } | undefined;
let suggestedUsers: SuggestedUser[] | undefined;
+let whoToFollowCount = 3;
+let refreshesWhoToFollow = true;
type VisitOptions = {
only?: string[];
headers?: Record;
@@ -25,17 +27,22 @@ type VisitOptions = {
onCancel?: () => void;
onFinish?: () => void;
onBeforeUpdate?: () => void;
+ onCancelToken?: (token: { cancel: () => void }) => void;
+ isCancelled?: boolean;
};
const visits: (VisitOptions & { method?: string })[] = [];
const reloads: VisitOptions[] = [];
const navigateListeners: (() => void)[] = [];
const timers: (() => void)[] = [];
+const intervals: { callback: () => void; delay: number }[] = [];
+const effects: (() => void)[] = [];
const toasts: string[] = [];
vi.doMock('react', () => ({
...React,
- useEffect: () => {},
+ useEffect: (effect: () => void) => effects.push(effect),
+ useEffectEvent: (callback: unknown) => callback,
useLayoutEffect: () => {},
useRef: (initial: unknown) => ({ current: initial }),
useState: (initial: unknown) => [
@@ -61,6 +68,8 @@ vi.doMock('@inertiajs/react', async () => ({
log,
commits: loadedCommits,
showsWhoToFollow: true,
+ whoToFollowCount,
+ refreshesWhoToFollow,
suggestedUsers,
},
}),
@@ -68,14 +77,26 @@ vi.doMock('@inertiajs/react', async () => ({
flushAll: () => {},
visit: (url: { method: string }, options: VisitOptions) =>
visits.push({ ...options, method: url.method }),
- reload: (options: VisitOptions) => reloads.push(options),
+ // Cancelling fires the visit's finish, as Inertia does.
+ reload: (options: VisitOptions) => {
+ reloads.push(options);
+ options.onCancelToken?.({
+ cancel: () => {
+ options.isCancelled = true;
+ options.onFinish?.();
+ },
+ });
+ },
on: (event: string, listener: () => void) => {
if (event === 'navigate') {
navigateListeners.push(listener);
}
return () =>
- navigateListeners.splice(navigateListeners.indexOf(listener), 1);
+ navigateListeners.splice(
+ navigateListeners.indexOf(listener),
+ 1,
+ );
},
replaceProp: (
_key: string,
@@ -102,6 +123,8 @@ const { UserPreview } =
await import('../../resources/js/components/user-preview');
const { SuggestedUserRow, WhoToFollow, WhoToFollowInLog } =
await import('../../resources/js/components/who-to-follow');
+const { default: WhoToFollowTabs } =
+ await import('../../resources/js/components/who-to-follow-tabs');
const { refreshSharedState } =
await import('../../resources/js/hooks/use-shared-state');
const { UserSafetyMenu } =
@@ -112,19 +135,34 @@ const { ConversationActions } =
vi.stubGlobal('window', {
matchMedia: () => ({ matches: false }),
setTimeout: (callback: () => void) => timers.push(callback),
+ setInterval: (callback: () => void, delay: number) =>
+ intervals.push({ callback, delay }),
+ clearInterval: () => {},
+ innerHeight: 800,
+});
+vi.stubGlobal('document', {
+ body: {},
+ cookie: '',
+ visibilityState: 'visible',
+ querySelector: () => null,
});
-vi.stubGlobal('document', { body: {}, cookie: '' });
afterAll(() => vi.unstubAllGlobals());
afterEach(() => {
component = 'user/show';
log = undefined;
loadedCommits = undefined;
suggestedUsers = undefined;
+ whoToFollowCount = 3;
+ refreshesWhoToFollow = true;
+ intervals.length = 0;
+ effects.length = 0;
refreshSharedState(
new Map([
['who-to-follow-dismissing', 0],
['who-to-follow-top-up', 'idle'],
['who-to-follow-dismissed', false],
+ ['who-to-follow-hidden', false],
+ ['who-to-follow-refreshing', false],
]),
);
});
@@ -528,7 +566,9 @@ test('both Following controls carry the oldest loaded row through the follow red
});
function shownSuggestions(): (string | number)[] {
- return (suggestedUsers ?? []).slice(0, 3).map((user) => user.id);
+ return (suggestedUsers ?? [])
+ .slice(0, whoToFollowCount)
+ .map((user) => user.id);
}
function suggestionRow(user: SuggestedUser): Props {
@@ -542,9 +582,9 @@ function suggestionRow(user: SuggestedUser): Props {
}
function dismissSuggestion(user: SuggestedUser): boolean {
- return (
- suggestionRow(user).onDismiss as (onFailed: () => void) => boolean
- )(() => {});
+ return (suggestionRow(user).onDismiss as (onFailed: () => void) => boolean)(
+ () => {},
+ );
}
function hasElementNamed(node: ReactNode, name: string): boolean {
@@ -599,6 +639,42 @@ test('a failed dismissal takes its slot back from whoever holds it, without brin
);
});
+test('shows as many suggestions as chosen, and a dismissed one hands its slot to the next spare', () => {
+ const [ada, bob, cleo, dina, eve, finn] = suggestions(
+ 'ada',
+ 'bob',
+ 'cleo',
+ 'dina',
+ 'eve',
+ 'finn',
+ );
+
+ for (const count of [1, 5]) {
+ whoToFollowCount = count;
+ suggestedUsers = [ada, bob, cleo, dina, eve, finn];
+
+ const block = WhoToFollow();
+ const rows = suggestedUsers
+ .filter((user) =>
+ find(
+ block,
+ (props) =>
+ (props.user as SuggestedUser | undefined)?.id ===
+ user.id,
+ ),
+ )
+ .map((user) => user.id);
+ assert.equal(rows.length, count);
+ assert.deepEqual(rows, shownSuggestions());
+
+ assert.equal(dismissSuggestion(ada), true);
+ assert.deepEqual(
+ shownSuggestions(),
+ count === 1 ? ['bob'] : ['finn', 'bob', 'cleo', 'dina', 'eve'],
+ );
+ }
+});
+
test('a dismissal that fails after a refresh leaves the new suggestions alone', () => {
const [gus, hana, ivo, jay] = suggestions('gus', 'hana', 'ivo', 'jay');
suggestedUsers = [gus, hana, ivo, jay];
@@ -687,3 +763,172 @@ test('a top-up that lands after someone else was dismissed takes them back out a
);
assert.notEqual(reloads.at(-1), topUp);
});
+
+function hideFromBlock(): void {
+ const actions = find(WhoToFollow(), (props) => props.onHide !== undefined)!;
+ (actions.props.onHide as () => void)();
+}
+
+test('hiding from one block hides both, and a count chosen in the settings brings them back once it loads', async () => {
+ suggestedUsers = suggestions('ada', 'bob', 'cleo', 'dina');
+
+ hideFromBlock();
+
+ assert.equal(hasElementNamed(WhoToFollow(), 'WhoToFollowHidden'), true);
+ assert.equal(
+ hasElementNamed(WhoToFollowInLog(), 'WhoToFollowHidden'),
+ true,
+ );
+
+ (WhoToFollowTabs().props.onChange as (value: number) => void)(1);
+ await Promise.resolve();
+ const reload = reloads.at(-1)!;
+
+ assert.deepEqual(reload.only, [
+ 'showsWhoToFollow',
+ 'whoToFollowCount',
+ 'suggestedUsers',
+ ]);
+ assert.equal(hasElementNamed(WhoToFollow(), 'WhoToFollowHidden'), true);
+
+ whoToFollowCount = 1;
+ reload.onFinish?.();
+
+ assert.equal(hasElementNamed(WhoToFollow(), 'WhoToFollowHidden'), false);
+ assert.equal(hasElementNamed(WhoToFollow(), 'SuggestedUserRow'), true);
+});
+
+type Focus = 'none' | 'clicked' | 'keyboard';
+
+/**
+ * Runs the effects of the block's latest render only, with the block on screen. A click leaves focus in the block that
+ * matches :focus-within but not :focus-visible, like on the menu button once the menu closes.
+ */
+function showWhoToFollow(
+ isHovered = false,
+ focus: Focus = 'none',
+ top = 100,
+): void {
+ effects.length = 0;
+ const block = WhoToFollow()!;
+ (block.props.ref as { current: unknown }).current = {
+ checkVisibility: () => true,
+ getBoundingClientRect: () => ({ top, bottom: top + 300 }),
+ matches: (selector: string) =>
+ selector
+ .split(', ')
+ .some(
+ (part) =>
+ (part === ':hover' && isHovered) ||
+ (part === ':focus-within' && focus !== 'none'),
+ ),
+ querySelector: (selector: string) =>
+ selector.includes(':focus-visible') && focus === 'keyboard'
+ ? {}
+ : null,
+ };
+ effects.splice(0).forEach((effect) => effect());
+}
+
+test('suggests other people every minute in the background', () => {
+ document.cookie = '';
+ suggestedUsers = suggestions('ada', 'bob', 'cleo', 'dina');
+ showWhoToFollow();
+
+ assert.deepEqual(
+ intervals.map(({ delay }) => delay),
+ [60_000],
+ );
+
+ intervals[0].callback();
+
+ assert.deepEqual(reloads.at(-1)?.only, ['suggestedUsers']);
+ assert.equal(reloads.at(-1)?.async, true);
+ assert.equal(
+ document.cookie,
+ 'who_to_follow_round=1;path=/;max-age=86400;SameSite=Lax',
+ );
+ document.cookie = '';
+});
+
+test('changing the count cancels a refresh still waiting for the people of the count from before', async () => {
+ document.cookie = '';
+ suggestedUsers = suggestions('ada', 'bob', 'cleo', 'dina');
+ showWhoToFollow();
+ intervals[0].callback();
+ const refresh = reloads.at(-1)!;
+
+ (WhoToFollowTabs().props.onChange as (value: number) => void)(5);
+ await Promise.resolve();
+
+ assert.deepEqual(refresh.only, ['suggestedUsers']);
+ assert.equal(refresh.isCancelled, true);
+ assert.deepEqual(reloads.at(-1)?.only, [
+ 'showsWhoToFollow',
+ 'whoToFollowCount',
+ 'suggestedUsers',
+ ]);
+ assert.equal(reloads.at(-1)?.isCancelled, undefined);
+ document.cookie = '';
+});
+
+test('waits while the pointer or keyboard focus is on the suggestions', () => {
+ suggestedUsers = suggestions('ada', 'bob', 'cleo', 'dina');
+
+ for (const [isHovered, focus] of [
+ [true, 'none'],
+ [false, 'keyboard'],
+ ] as const) {
+ intervals.length = 0;
+ showWhoToFollow(isHovered, focus);
+ const sent = reloads.length;
+
+ intervals[0].callback();
+
+ assert.equal(reloads.length, sent);
+ }
+});
+
+test('focus a click leaves on the block does not hold up the next suggestions', () => {
+ suggestedUsers = suggestions('ada', 'bob', 'cleo', 'dina');
+ showWhoToFollow(false, 'clicked');
+ const sent = reloads.length;
+
+ intervals[0].callback();
+
+ assert.equal(reloads.length, sent + 1);
+ document.cookie = '';
+});
+
+test('leaves the suggestions alone once they are scrolled out of view', () => {
+ suggestedUsers = suggestions('ada', 'bob', 'cleo', 'dina');
+
+ for (const top of [-400, 900]) {
+ intervals.length = 0;
+ showWhoToFollow(false, 'none', top);
+ const sent = reloads.length;
+
+ intervals[0].callback();
+
+ assert.equal(reloads.length, sent);
+ }
+});
+
+test('does not suggest other people on its own when switched off, hidden or still dismissing', () => {
+ const [ada, bob, cleo, dina] = suggestions('ada', 'bob', 'cleo', 'dina');
+
+ refreshesWhoToFollow = false;
+ suggestedUsers = [ada, bob, cleo, dina];
+ showWhoToFollow();
+ assert.equal(intervals.length, 0);
+
+ refreshesWhoToFollow = true;
+ dismissSuggestion(ada);
+ showWhoToFollow();
+ assert.equal(intervals.length, 0);
+
+ refreshSharedState(new Map([['who-to-follow-dismissing', 0]]));
+ hideFromBlock();
+ showWhoToFollow();
+ assert.equal(intervals.length, 0);
+});
diff --git a/tests/Unit/Middleware/HandleInertiaRequestsTest.php b/tests/Unit/Middleware/HandleInertiaRequestsTest.php
index e7065f79b..2c98c7060 100644
--- a/tests/Unit/Middleware/HandleInertiaRequestsTest.php
+++ b/tests/Unit/Middleware/HandleInertiaRequestsTest.php
@@ -4,6 +4,7 @@
use App\Enums\CommitStatus;
use App\Enums\LivePlatform;
+use App\Enums\WhoToFollowCount;
use App\Http\Middleware\HandleInertiaRequests;
use App\Models\Commit;
use App\Models\Notification;
@@ -265,6 +266,31 @@
expect($shared['dimsLanes'])->toBeTrue();
})->with(['', 'true']);
+it('refreshes who to follow automatically by default or when enabled', function (string $cookie): void {
+ $middleware = new HandleInertiaRequests();
+
+ $request = Request::create('/', 'GET');
+
+ if ($cookie !== '') {
+ $request->cookies->set('who_to_follow_auto_refresh', $cookie);
+ }
+
+ $shared = $middleware->share($request);
+
+ expect($shared['refreshesWhoToFollow'])->toBeTrue();
+})->with(['', 'true']);
+
+it('does not refresh who to follow automatically when disabled', function (): void {
+ $middleware = new HandleInertiaRequests();
+
+ $request = Request::create('/', 'GET');
+ $request->cookies->set('who_to_follow_auto_refresh', 'false');
+
+ $shared = $middleware->share($request);
+
+ expect($shared['refreshesWhoToFollow'])->toBeFalse();
+});
+
it('does not dim lanes when disabled', function (): void {
$middleware = new HandleInertiaRequests();
@@ -386,6 +412,32 @@
->and($shared['liveFollowing']())->toBeEmpty();
})->with(['/operations', '/operations/events']);
+it('suggests as many people as the user chose', function (?string $cookie, WhoToFollowCount $count): void {
+ $middleware = new HandleInertiaRequests();
+ $user = User::factory()->create();
+ User::factory()->count(12)->has(Commit::factory())->create();
+
+ $request = Request::create('/', 'GET');
+ $request->setUserResolver(fn () => $user);
+
+ if ($cookie !== null) {
+ $request->cookies->set('who_to_follow_count', $cookie);
+ }
+
+ $shared = $middleware->share($request);
+
+ expect($shared['whoToFollowCount'])->toBe($count->value)
+ ->and($shared['suggestedUsers']()->pluck('id')->all())
+ ->toBe(resolve(SuggestedUsersQuery::class)->get($user, limit: $count->value, spares: 3)->pluck('id')->all())
+ ->and($shared['suggestedUsers']())->toHaveCount($count->value + 3);
+})->with([
+ 'default' => [null, WhoToFollowCount::Three],
+ 'one' => ['1', WhoToFollowCount::One],
+ 'five' => ['5', WhoToFollowCount::Five],
+ 'not offered' => ['4', WhoToFollowCount::Three],
+ 'not a number' => ['abc', WhoToFollowCount::Three],
+]);
+
it('suggests the round of people the user refreshed to', function (string $cookie, int $round): void {
$middleware = new HandleInertiaRequests();
$user = User::factory()->create();
diff --git a/tests/Unit/Queries/SuggestedUsersQueryTest.php b/tests/Unit/Queries/SuggestedUsersQueryTest.php
index 4877aa442..f347f49a2 100644
--- a/tests/Unit/Queries/SuggestedUsersQueryTest.php
+++ b/tests/Unit/Queries/SuggestedUsersQueryTest.php
@@ -155,7 +155,34 @@ function popularUsers(int $count): Collection
User::factory()->count(5)->has(Commit::factory())->create();
expect(resolve(SuggestedUsersQuery::class)->get($user))->toHaveCount(3)
- ->and(resolve(SuggestedUsersQuery::class)->get($user, limit: 2))->toHaveCount(2);
+ ->and(resolve(SuggestedUsersQuery::class)->get($user, limit: 2))->toHaveCount(2)
+ ->and(resolve(SuggestedUsersQuery::class)->get($user, limit: 5))->toHaveCount(5);
+});
+
+it('does not keep a single suggestion for the most followed until they run out', function (): void {
+ $user = User::factory()->create();
+ $vouched = vouchedFor($user, 10);
+ popularUsers(10);
+
+ $query = resolve(SuggestedUsersQuery::class);
+ $suggested = collect(range(0, 9))->flatMap(fn (int $round): array => $query->get($user, limit: 1, round: $round)->pluck('id')->all());
+
+ expect($suggested)->toHaveCount(10)
+ ->and($suggested->intersect($vouched->modelKeys()))->not->toBeEmpty();
+});
+
+it('moves on to new people each round when showing five', function (): void {
+ $user = User::factory()->create();
+ vouchedFor($user, 6);
+ popularUsers(6);
+
+ $query = resolve(SuggestedUsersQuery::class);
+ $first = $query->get($user, limit: 5)->pluck('id')->all();
+ $second = $query->get($user, limit: 5, round: 1)->pluck('id')->all();
+
+ expect($first)->toHaveCount(5)
+ ->and($second)->toHaveCount(5)
+ ->and(array_intersect($first, $second))->toBeEmpty();
});
it('does not suggest muted or dismissed people or people across a block', function (): void {
diff --git a/tests/Unit/WhoToFollowTabs.test.tsx b/tests/Unit/WhoToFollowTabs.test.tsx
new file mode 100644
index 000000000..c330466f4
--- /dev/null
+++ b/tests/Unit/WhoToFollowTabs.test.tsx
@@ -0,0 +1,160 @@
+///
+import assert from 'node:assert/strict';
+import * as React from 'react';
+import type { ReactElement } from 'react';
+import { afterAll, afterEach, test, vi } from 'vite-plus/test';
+
+type Props = { value: number; onChange: (value: number) => void };
+
+const initialProps = {
+ showsWhoToFollow: true,
+ whoToFollowCount: 3,
+ refreshesWhoToFollow: true,
+};
+let props = { ...initialProps };
+type Reload = {
+ only?: string[];
+ onFinish?: () => void;
+ onCancelToken?: (token: { cancel: () => void }) => void;
+ isCancelled?: boolean;
+};
+const reloads: Reload[] = [];
+
+// State lives across renders, and setting it while rendering renders again, as React does.
+let slots: unknown[] = [];
+let cursor = 0;
+let isRendering = false;
+let rendersAgain = false;
+
+vi.doMock('react', () => ({
+ ...React,
+ useEffect: (effect: () => void) => effect(),
+ useState: (initial: unknown) => {
+ const slot = cursor++;
+
+ if (!(slot in slots)) {
+ slots[slot] = typeof initial === 'function' ? initial() : initial;
+ }
+
+ return [
+ slots[slot],
+ (update: unknown) => {
+ slots[slot] =
+ typeof update === 'function' ? update(slots[slot]) : update;
+ rendersAgain ||= isRendering;
+ },
+ ];
+ },
+ useSyncExternalStore: (_subscribe: unknown, read: () => unknown) => read(),
+}));
+vi.doMock('@inertiajs/react', async () => ({
+ ...(await vi.importActual('@inertiajs/react')),
+ usePage: () => ({ props }),
+ router: {
+ flushAll: () => {},
+ // Cancelling fires the visit's finish, as Inertia does.
+ reload: (options: Reload) => {
+ reloads.push(options);
+ options.onCancelToken?.({
+ cancel: () => {
+ options.isCancelled = true;
+ options.onFinish?.();
+ },
+ });
+ },
+ replaceProp: () => {},
+ },
+}));
+
+const { default: WhoToFollowTabs } =
+ await import('../../resources/js/components/who-to-follow-tabs');
+const { refreshSharedState } =
+ await import('../../resources/js/hooks/use-shared-state');
+
+vi.stubGlobal('document', { cookie: '' });
+afterAll(() => vi.unstubAllGlobals());
+afterEach(() => {
+ props = { ...initialProps };
+ reloads.length = 0;
+ slots = [];
+ hideFromBlock(false);
+});
+
+function render(): ReactElement {
+ let tabs: ReactElement;
+
+ do {
+ rendersAgain = false;
+ cursor = 0;
+ isRendering = true;
+ tabs = WhoToFollowTabs() as ReactElement;
+ isRendering = false;
+ } while (rendersAgain);
+
+ return tabs;
+}
+
+function hideFromBlock(hides = true): void {
+ refreshSharedState(new Map([['who-to-follow-hidden', hides]]));
+}
+
+test('moves to Hide, and back, when the block on the page is hidden from its own menu', () => {
+ assert.equal(render().props.value, 3);
+
+ hideFromBlock();
+ assert.equal(render().props.value, 0);
+
+ hideFromBlock(false);
+ assert.equal(render().props.value, 3);
+});
+
+test('a count picked after hiding the block never shows the count from before', async () => {
+ hideFromBlock();
+ const shown = [render().props.value];
+
+ render().props.onChange(1);
+ await Promise.resolve();
+ shown.push(render().props.value);
+
+ props.whoToFollowCount = 1;
+ shown.push(render().props.value);
+
+ reloads.at(-1)?.onFinish?.();
+ shown.push(render().props.value);
+
+ assert.deepEqual(shown, [0, 1, 1, 1]);
+});
+
+test('a count picked again before the first one loads cancels it and never shows the count from before', async () => {
+ hideFromBlock();
+ const shown = [render().props.value];
+
+ render().props.onChange(1);
+ await Promise.resolve();
+ const first = reloads.at(-1)!;
+ shown.push(render().props.value);
+
+ render().props.onChange(5);
+ await Promise.resolve();
+ const second = reloads.at(-1)!;
+ shown.push(render().props.value);
+
+ // The first answer can no longer land after the second and put 1 back.
+ assert.equal(first.isCancelled, true);
+ assert.equal(second.isCancelled, undefined);
+
+ props.whoToFollowCount = 5;
+ second.onFinish?.();
+ shown.push(render().props.value);
+
+ assert.deepEqual(shown, [0, 1, 5, 5]);
+});
+
+test('a visit that leaves the suggestions out forgets they were hidden from the block', () => {
+ hideFromBlock();
+ props.showsWhoToFollow = false;
+ assert.equal(render().props.value, 0);
+
+ props.showsWhoToFollow = true;
+ assert.equal(render().props.value, 3);
+});