import { Deferred, Link, router, usePage } from '@inertiajs/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, FollowIconButton, useHasBlockWith, } from '@/components/follow-button'; import { Button } from '@/components/ui/button'; import { DropdownMenu, DropdownMenuContent, DropdownMenuItem, DropdownMenuSeparator, DropdownMenuTrigger, } from '@/components/ui/dropdown-menu'; import { Skeleton } from '@/components/ui/skeleton'; import { Tooltip, TooltipContent, TooltipTrigger, } from '@/components/ui/tooltip'; import { UserAvatar } from '@/components/user-avatar'; import { UserPreview } from '@/components/user-preview'; import { useSharedState } from '@/hooks/use-shared-state'; import { currentRound, useWhoToFollow } from '@/hooks/use-who-to-follow'; import { formatCount, formatExactCount } from '@/lib/numbers'; import { toastWhenThrottled } from '@/lib/throttle'; import { cn, laneColor } from '@/lib/utils'; import { edit } from '@/routes/appearance'; import { show } from '@/routes/user'; import type { SuggestedUser } from '@/types'; function reasonFor(user: SuggestedUser, format = formatCount): string | null { if (user.followed_by_following_count > 0) { return `Followed by ${format(user.followed_by_following_count)} you follow`; } if (user.followers_count > 0) { return user.followers_count === 1 ? '1 follower' : `${format(user.followers_count)} followers`; } return null; } function BioComment({ bio }: { bio: string }) { return ( #{' '} {bio} ); } function Reason({ reason, title }: { reason: string; title: string }) { return ( {reason} ); } function DismissButton({ user, onDismiss, }: { user: SuggestedUser; onDismiss: () => void; }) { const label = `Not interested. @${user.handle} won't be suggested again.`; return ( Not interested @{user.handle} won't be suggested again. ); } /** * The aside is too narrow to share a line with the button, so there the bio and reason go below on their own lines. * * With onDismiss, the row gets small follow and dismiss icons instead of the full follow button. onDismiss returns * whether someone takes the row's place; otherwise the row folds away. A failed dismissal opens it back up. These rows * keep room for both lines below the name, so someone without a bio taking a slot doesn't move the rows under it. */ export function SuggestedUserRow({ user, isStacked = false, className, buttonClassName, onDismiss, }: { user: SuggestedUser; isStacked?: boolean; className?: string; buttonClassName?: string; onDismiss?: (onFailed: () => void) => boolean; }) { const reason = reasonFor(user); const exactReason = reasonFor(user, formatExactCount); const bio = user.bio?.trim() || null; const details = (bio || reason) && ( <> {bio && } {reason && } ); const avatarRef = useRef(null); const hasBlock = useHasBlockWith(user); const [isDismissed, setIsDismissed] = useState(false); const [isGone, setIsGone] = useState(false); const isLeaving = hasBlock || isDismissed; if (isGone) { return null; } return (
  • { if ( isLeaving && event.target === event.currentTarget && event.propertyName === 'grid-template-rows' ) { setIsGone(true); } }} >
    {user.name} @{user.handle} {!isStacked && ( {details} )} {onDismiss ? (
    onDismiss(() => setIsDismissed(false)) || setIsDismissed(true) } />
    ) : ( router.flushAll()} data-test="suggested-follow-button" /> )}
    {isStacked && (
    {details}
    )}
  • ); } function SuggestedUsersSkeleton({ rowClassName, isStacked = false, }: { rowClassName?: string; isStacked?: boolean; }) { const { whoToFollowCount } = usePage().props; const details = ( <> ); return ( <> {Array.from({ length: whoToFollowCount }, (_, index) => (
  • {!isStacked && details}
    {isStacked && (
    {details}
    )}
  • ))} ); } function WhoToFollowRefreshButton({ disabled }: { disabled: boolean }) { const { isRefreshing, refreshSuggestedUsers } = useWhoToFollow(); return ( ); } function WhoToFollowActions({ hasSuggestions, onHide, }: { hasSuggestions: boolean; onHide: () => void; }) { return (
    ); } function WhoToFollowMenu({ onHide }: { onHide: () => void }) { return ( Don't show again > /dev/null ); } /** * 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 { 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, isHidden, isEmptied, 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(); /** * The server sends a few spare suggestions after the shown ones, so a dismissed person's slot is taken by the next * spare right away while everyone else stays put. Once every dismissal is through, the spares are topped up from the * server, which only adds people after the ones already here. A dismissal that fails puts the person back in their * slot, unless a refresh has moved on to other people since. * * A top-up can be answered before someone else's dismissal is saved, so everyone dismissed since the page loaded is * taken out again when it lands, and the next top-up follows. Visiting another page cancels a top-up, so it is sent * again once that page is shown. * * The aside and the log share this state, so both say nobody is left once a top-up finds nobody else, until the next * page load leaves the block out like any other time there's nobody to suggest. */ function useDismissSuggestion() { const { suggestedUsers = [], whoToFollowCount: shownSuggestionsCount } = usePage().props; const [dismissingCount, setDismissingCount] = useSharedState( 'who-to-follow-dismissing', 0, ); const [topUp, setTopUp] = useSharedState( 'who-to-follow-top-up', 'idle', ); const [hasDismissed, setHasDismissed] = useSharedState( 'who-to-follow-dismissed', false, ); const remove = (user: Pick): void => { router.replaceProp('suggestedUsers', (current: SuggestedUser[]) => { const slot = current.findIndex( (suggested) => suggested.id === user.id, ); if (slot === -1) { return current; } const remaining = [...current]; const spare = slot < shownSuggestionsCount ? remaining.splice(shownSuggestionsCount, 1) : []; remaining.splice(slot, 1, ...spare); return remaining; }); }; /** * Whoever holds the slot now came from the spares, unless they were already shown, so they go back to the front. */ const restore = ( user: SuggestedUser, slot: number, shownIds: SuggestedUser['id'][], ): void => { router.replaceProp('suggestedUsers', (current: SuggestedUser[]) => { if (current.some((suggested) => suggested.id === user.id)) { return current; } const restored = [...current]; const holder = restored.at(slot); if (holder && !shownIds.includes(holder.id)) { restored.splice(slot, 1, user); restored.splice(shownSuggestionsCount, 0, holder); } else { restored.splice(slot, 0, user); } return restored; }); }; const sendTopUp = (): void => { let isApplied = false; setTopUp('running'); router.reload({ only: ['suggestedUsers'], async: true, onBeforeUpdate: () => { isApplied = true; }, onSuccess: () => dismissedIds.forEach((id) => remove({ id })), onFinish: () => { if (!isApplied) { const stop = router.on('navigate', () => { stop(); sendTopUp(); }); return; } // The shared setter runs its update at once, so what it saw can be read right after. let wasQueued = false; setTopUp((current) => { wasQueued = current === 'queued'; return 'idle'; }); if (wasQueued) { sendTopUp(); } }, }); }; const settle = (): void => { let left = 0; setDismissingCount((count) => (left = count - 1)); if (left > 0) { return; } let isRunning = false; setTopUp((current) => { isRunning = current !== 'idle'; return isRunning ? 'queued' : current; }); if (!isRunning) { sendTopUp(); } }; const dismiss = (user: SuggestedUser, onFailed: () => void): boolean => { const shownIds = suggestedUsers .slice(0, shownSuggestionsCount) .map((suggested) => suggested.id); const slot = shownIds.indexOf(user.id); const round = currentRound(); const hasSpare = suggestedUsers.length > shownSuggestionsCount; let isSaved = false; dismissedIds.add(user.id); setHasDismissed(true); setDismissingCount((count) => count + 1); router.visit(SuggestionDismissalController.store(user), { only: ['auth'], preserveState: true, preserveScroll: true, async: true, onHttpException: (response) => !toastWhenThrottled(response), onSuccess: () => { isSaved = true; router.flushAll(); }, onFinish: () => { if (!isSaved) { dismissedIds.delete(user.id); onFailed(); if (currentRound() === round) { restore(user, slot, shownIds); } } settle(); }, }); if (hasSpare) { remove(user); } else { // Taken out after the row has folded away, even if the block it was dismissed from is no longer shown. window.setTimeout(() => { if (dismissedIds.has(user.id)) { remove(user); } }, 300); } return hasSpare; }; return { hasDismissed, isSettled: dismissingCount === 0 && topUp === 'idle', isEmptied: hasDismissed && dismissingCount === 0 && topUp === 'idle' && suggestedUsers.length === 0, dismiss, }; } function WhoToFollowEmptied() { return (

    fatal: no one left to suggest

    You've followed or dismissed everyone we'd suggest. New people show up here as they start committing.

    ); } function WhoToFollowHidden({ onUndo }: { onUndo: () => void }) { return (

    $ who --should-i-follow{' '} > /dev/null

    Bring it back in{' '} Settings .

    ); } export function WhoToFollow() { const { isVisible, isHidden, isEmptied, hide, undo, suggestedUsers, dismiss, block, } = useHideWhoToFollow(); if (!isVisible) { return null; } return (

    Who to follow

    {!isHidden && !isEmptied && ( )}
    {isHidden ? ( ) : isEmptied ? ( ) : (
      } > {suggestedUsers?.map((user) => ( dismiss(user, onFailed) } /> ))}
    )}
    ); } /** * The aside is hidden below the xl breakpoint, so smaller screens get the same suggestions inside the log. */ export function WhoToFollowInLog() { const { isVisible, isHidden, isEmptied, hide, undo, suggestedUsers, dismiss, block, } = useHideWhoToFollow(); if (!isVisible) { return null; } return (

    $ who --should-i-follow

    {!isHidden && !isEmptied && ( )}
    {isHidden ? ( ) : isEmptied ? ( ) : (
      } > {suggestedUsers?.map((user) => ( dismiss(user, onFailed) } /> ))}
    )}
    ); }