);
}
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.
)}
);
}
/**
* 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 (