From d67b0923b5f4f3f39271b1d0c5842465b45c681e Mon Sep 17 00:00:00 2001 From: snapsun Date: Wed, 28 Oct 2026 16:05:41 +0100 Subject: [PATCH 1/4] feat: choose how many people who to follow suggests MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Who to follow always showed three people. Settings → Appearance now offers Hide, 1, 3 or 5, kept in a who_to_follow_count cookie next to who_to_follow, so each browser keeps its own choice and nothing new is stored. With a single slot, suggestions no longer go through every popular person before anyone your follows follow. --- KNOWLEDGE_BASE.md | 4 +- app/Enums/WhoToFollowCount.php | 22 ++++++++++ app/Http/Middleware/HandleInertiaRequests.php | 3 ++ app/Queries/SuggestedUsersQuery.php | 3 +- bootstrap/app.php | 2 +- .../js/components/who-to-follow-tabs.tsx | 31 +++++++++---- resources/js/components/who-to-follow.tsx | 13 +++--- resources/js/hooks/use-who-to-follow.ts | 17 +++++++- resources/js/pages/appearance/update.tsx | 2 +- resources/js/types/global.d.ts | 1 + tests/Unit/FollowState.test.tsx | 43 ++++++++++++++++++- .../Middleware/HandleInertiaRequestsTest.php | 27 ++++++++++++ .../Unit/Queries/SuggestedUsersQueryTest.php | 29 ++++++++++++- 13 files changed, 175 insertions(+), 22 deletions(-) create mode 100644 app/Enums/WhoToFollowCount.php diff --git a/KNOWLEDGE_BASE.md b/KNOWLEDGE_BASE.md index 236f592e6..2fc4a2eed 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, or 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..bbfb30c3c 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,7 @@ 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, '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..987a39c16 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', '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..4f776e422 100644 --- a/resources/js/components/who-to-follow-tabs.tsx +++ b/resources/js/components/who-to-follow-tabs.tsx @@ -1,26 +1,40 @@ 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. + */ export default function WhoToFollowTabs() { - const { showsWhoToFollow, setShowsWhoToFollow } = useWhoToFollow(); - const [selected, setSelected] = useState(showsWhoToFollow); + const { + showsWhoToFollow, + setShowsWhoToFollow, + whoToFollowCount, + setWhoToFollowCount, + } = useWhoToFollow(); + const [selected, setSelected] = useState( + showsWhoToFollow ? whoToFollowCount : 0, + ); - 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 +45,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..fffd9e35e 100644 --- a/resources/js/components/who-to-follow.tsx +++ b/resources/js/components/who-to-follow.tsx @@ -70,8 +70,6 @@ function Reason({ reason, title }: { reason: string; title: string }) { ); } -const shownSuggestionsCount = 3; - function DismissButton({ user, onDismiss, @@ -257,6 +255,7 @@ function SuggestedUsersSkeleton({ rowClassName?: string; isStacked?: boolean; }) { + const { whoToFollowCount } = usePage().props; const details = ( <> @@ -266,7 +265,7 @@ function SuggestedUsersSkeleton({ return ( <> - {[0, 1, 2].map((index) => ( + {Array.from({ length: whoToFollowCount }, (_, index) => (
  • @@ -358,7 +357,8 @@ 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. */ function useHideWhoToFollow() { - const { showsWhoToFollow, suggestedUsers } = usePage().props; + const { showsWhoToFollow, suggestedUsers, whoToFollowCount } = + usePage().props; const { rememberShowsWhoToFollow } = useWhoToFollow(); const [isHidden, setIsHidden] = useState(false); const { hasDismissed, isEmptied, dismiss } = useDismissSuggestion(); @@ -375,7 +375,7 @@ function useHideWhoToFollow() { isEmptied, hide: () => toggle(true), undo: () => toggle(false), - suggestedUsers: suggestedUsers?.slice(0, shownSuggestionsCount), + suggestedUsers: suggestedUsers?.slice(0, whoToFollowCount), dismiss, }; } @@ -398,7 +398,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, diff --git a/resources/js/hooks/use-who-to-follow.ts b/resources/js/hooks/use-who-to-follow.ts index 220ee1c55..3e5863334 100644 --- a/resources/js/hooks/use-who-to-follow.ts +++ b/resources/js/hooks/use-who-to-follow.ts @@ -12,7 +12,7 @@ export function currentRound(): number { * Whether to suggest people to follow is kept in a cookie, like the theme, so the server can skip the suggestions too. */ export function useWhoToFollow() { - const { showsWhoToFollow } = usePage().props; + const { showsWhoToFollow, whoToFollowCount } = usePage().props; const [isRefreshing, setIsRefreshing] = useState(false); const rememberShowsWhoToFollow = (shows: boolean): void => { @@ -29,6 +29,19 @@ export function useWhoToFollow() { }); }; + /** + * Choosing how many to suggest also brings the suggestions back if they were hidden. + */ + const setWhoToFollowCount = (count: number): void => { + setCookie('who_to_follow_count', String(count)); + rememberShowsWhoToFollow(true); + router.reload({ + only: ['showsWhoToFollow', 'whoToFollowCount', '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. */ @@ -50,6 +63,8 @@ export function useWhoToFollow() { showsWhoToFollow, rememberShowsWhoToFollow, setShowsWhoToFollow, + whoToFollowCount, + setWhoToFollowCount, 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..8d18db6d9 100644 --- a/resources/js/types/global.d.ts +++ b/resources/js/types/global.d.ts @@ -31,6 +31,7 @@ declare module '@inertiajs/core' { hasUnmoderatedCommits: boolean; sidebarOpen: boolean; showsWhoToFollow: boolean; + whoToFollowCount: number; dimsLanes: boolean; repliesPerPage: number; clock: SharedClock; diff --git a/tests/Unit/FollowState.test.tsx b/tests/Unit/FollowState.test.tsx index 68026c200..ac32aa96d 100644 --- a/tests/Unit/FollowState.test.tsx +++ b/tests/Unit/FollowState.test.tsx @@ -12,6 +12,7 @@ let component = 'user/show'; let log: string | undefined; let loadedCommits: { data: { id: string }[] } | undefined; let suggestedUsers: SuggestedUser[] | undefined; +let whoToFollowCount = 3; type VisitOptions = { only?: string[]; headers?: Record; @@ -61,6 +62,7 @@ vi.doMock('@inertiajs/react', async () => ({ log, commits: loadedCommits, showsWhoToFollow: true, + whoToFollowCount, suggestedUsers, }, }), @@ -120,6 +122,7 @@ afterEach(() => { log = undefined; loadedCommits = undefined; suggestedUsers = undefined; + whoToFollowCount = 3; refreshSharedState( new Map([ ['who-to-follow-dismissing', 0], @@ -528,7 +531,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 { @@ -599,6 +604,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]; diff --git a/tests/Unit/Middleware/HandleInertiaRequestsTest.php b/tests/Unit/Middleware/HandleInertiaRequestsTest.php index e7065f79b..417d64c50 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; @@ -386,6 +387,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 { From 06ca80da42f73f0c0027089005c69a6581d165be Mon Sep 17 00:00:00 2001 From: snapsun Date: Wed, 28 Oct 2026 18:24:44 +0100 Subject: [PATCH 2/4] feat: suggest other people to follow every minute MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Who to follow now moves on to the next people every minute, the way the refresh button does, but not while the tab is in the background, the pointer or keyboard focus is on the block, or a menu or preview is open. A switch under … → Refresh automatically turns it off, kept in a who_to_follow_auto_refresh cookie next to the other who to follow choices. Hiding the block from its own menu now shows as Hide in Settings → Appearance at once, and choosing a count there after visiting another page brings the suggestions back instead of the hidden notice. --- KNOWLEDGE_BASE.md | 2 +- app/Http/Middleware/HandleInertiaRequests.php | 1 + bootstrap/app.php | 2 +- .../js/components/who-to-follow-tabs.tsx | 14 +- resources/js/components/who-to-follow.tsx | 139 +++++++++++-- resources/js/hooks/use-who-to-follow.ts | 83 +++++++- resources/js/types/global.d.ts | 1 + tests/Unit/FollowState.test.tsx | 184 +++++++++++++++++- .../Middleware/HandleInertiaRequestsTest.php | 25 +++ tests/Unit/WhoToFollowTabs.test.tsx | 145 ++++++++++++++ 10 files changed, 557 insertions(+), 39 deletions(-) create mode 100644 tests/Unit/WhoToFollowTabs.test.tsx diff --git a/KNOWLEDGE_BASE.md b/KNOWLEDGE_BASE.md index 2fc4a2eed..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. 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. +**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). diff --git a/app/Http/Middleware/HandleInertiaRequests.php b/app/Http/Middleware/HandleInertiaRequests.php index bbfb30c3c..14a326bbb 100644 --- a/app/Http/Middleware/HandleInertiaRequests.php +++ b/app/Http/Middleware/HandleInertiaRequests.php @@ -116,6 +116,7 @@ public function share(Request $request): array '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/bootstrap/app.php b/bootstrap/app.php index 987a39c16..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', 'who_to_follow_count', '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 4f776e422..93d340c30 100644 --- a/resources/js/components/who-to-follow-tabs.tsx +++ b/resources/js/components/who-to-follow-tabs.tsx @@ -8,7 +8,8 @@ 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. + * 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 { @@ -17,9 +18,14 @@ export default function WhoToFollowTabs() { whoToFollowCount, setWhoToFollowCount, } = useWhoToFollow(); - const [selected, setSelected] = useState( - showsWhoToFollow ? whoToFollowCount : 0, - ); + 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: number; icon?: LucideIcon; label: string }[] = [ { value: 0, icon: EyeOff, label: 'Hide' }, diff --git a/resources/js/components/who-to-follow.tsx b/resources/js/components/who-to-follow.tsx index fffd9e35e..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'; @@ -293,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" @@ -338,7 +340,9 @@ function WhoToFollowMenu({ onHide }: { onHide: () => void }) { - + + + @@ -354,32 +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, whoToFollowCount } = usePage().props; - const { rememberShowsWhoToFollow } = useWhoToFollow(); - const [isHidden, setIsHidden] = useState(false); - const { hasDismissed, isEmptied, dismiss } = useDismissSuggestion(); - - const toggle = (hides: boolean) => { - rememberShowsWhoToFollow(!hides); - setIsHidden(hides); - }; + 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), + 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(); @@ -569,6 +664,7 @@ function useDismissSuggestion() { return { hasDismissed, + isSettled: dismissingCount === 0 && topUp === 'idle', isEmptied: hasDismissed && dismissingCount === 0 && @@ -632,6 +728,7 @@ export function WhoToFollow() { undo, suggestedUsers, dismiss, + block, } = useHideWhoToFollow(); if (!isVisible) { @@ -640,6 +737,7 @@ export function WhoToFollow() { return (
    @@ -693,6 +791,7 @@ export function WhoToFollowInLog() { undo, suggestedUsers, dismiss, + block, } = useHideWhoToFollow(); if (!isVisible) { @@ -700,7 +799,11 @@ export function WhoToFollowInLog() { } return ( -
    +