Files
sendou.ink/app/features/notifications/notifications-hooks.ts
2026-09-05 15:11:04 +03:00

140 lines
4.3 KiB
TypeScript

import * as React from "react";
import { useFetcher } from "react-router";
import { NOTIFICATIONS_MARK_AS_SEEN_ROUTE } from "~/utils/urls";
import { useNotificationsData } from "./NotificationsProvider";
export function useMarkNotificationsAsSeen(unseenIds: number[]) {
const fetcher = useFetcher();
const { refresh } = useNotificationsData();
const submittedIdsRef = React.useRef(new Set<number>());
const refreshPendingRef = React.useRef(false);
const { submit } = fetcher;
React.useEffect(() => {
// a submit while one is in flight would abort it; ids arriving mid-flight
// get submitted when the fetcher returns to idle
if (fetcher.state !== "idle") return;
// the action's notificationsChanged event also triggers a refetch, but only
// for clients with a live event stream; refetching here keeps the dot
// clearing promptly for the tab that did the marking either way
if (refreshPendingRef.current) {
refreshPendingRef.current = false;
refresh();
}
const idsToSubmit = unseenIds.filter(
(id) => !submittedIdsRef.current.has(id),
);
if (idsToSubmit.length === 0) return;
for (const id of idsToSubmit) {
submittedIdsRef.current.add(id);
}
refreshPendingRef.current = true;
submit(
{ notificationIds: idsToSubmit },
{
method: "post",
encType: "application/json",
action: NOTIFICATIONS_MARK_AS_SEEN_ROUTE,
},
);
}, [submit, unseenIds, fetcher.state, refresh]);
}
const UNSEEN_DOT_GRACE_MS = 10_000;
/**
* Whether the bell shows its unseen dot. A notification born during the session only counts once
* unseen past a short grace period: one about something the user is already headed to (a SendouQ
* match that just started, redirect a second away) resolves itself right after and the dot would
* flash for nothing. Ones predating the session show right away — whatever would resolve them
* already ran before the first fetch.
*/
export function useShowUnseenDot(
notifications: Array<{ createdAt: number; seen: number }> | undefined,
) {
// time lives in state (only advanced by the timer below) because reading
// Date.now() during render would be frozen by the React Compiler's memoization
const [mountedAt] = React.useState(() => Date.now());
const [now, setNow] = React.useState(mountedAt);
const dotShowTimes =
notifications
?.filter((notification) => !notification.seen)
.map((notification) => {
const createdAtMs = notification.createdAt * 1000;
return createdAtMs <= mountedAt
? mountedAt
: createdAtMs + UNSEEN_DOT_GRACE_MS;
}) ?? [];
const showDot = dotShowTimes.some((showTime) => showTime <= now);
const nextShowTime =
!showDot && dotShowTimes.length > 0 ? Math.min(...dotShowTimes) : null;
React.useEffect(() => {
if (nextShowTime === null) return;
const timeout = setTimeout(
() => setNow(Date.now()),
Math.max(0, nextShowTime - Date.now()) + 100,
);
return () => clearTimeout(timeout);
}, [nextShowTime]);
return showDot;
}
/**
* Ids of the notifications to show an unseen dot for, keeping the dot for as
* long as the list stays open. Opening the list marks its notifications as
* seen right away so the bell stops claiming there is something new, and this
* keeps the reader from losing track of which ones those were. The list stays
* mounted while closed, so each opening starts over from what is unseen then.
*/
export function useStickyUnseenIds(
notifications: Array<{ id: number; seen: number }>,
isOpen: boolean,
) {
const [unseenIds, setUnseenIds] = React.useState(
() => new Set(unseenIdsOf(notifications)),
);
const [prevNotifications, setPrevNotifications] =
React.useState(notifications);
const [prevIsOpen, setPrevIsOpen] = React.useState(isOpen);
if (prevIsOpen !== isOpen) {
setPrevIsOpen(isOpen);
if (isOpen) {
setUnseenIds(new Set(unseenIdsOf(notifications)));
}
}
if (prevNotifications !== notifications) {
setPrevNotifications(notifications);
setUnseenIds((prevUnseenIds) => {
const newUnseenIds = new Set(prevUnseenIds);
for (const id of unseenIdsOf(notifications)) {
newUnseenIds.add(id);
}
if (newUnseenIds.size === prevUnseenIds.size) return prevUnseenIds;
return newUnseenIds;
});
}
return unseenIds;
}
function unseenIdsOf(notifications: Array<{ id: number; seen: number }>) {
return notifications
.filter((notification) => !notification.seen)
.map((notification) => notification.id);
}