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 ( -
    +