mirror of
https://github.com/Sendouc/sendou.ink.git
synced 2026-10-01 23:58:36 -05:00
200 lines
5.6 KiB
TypeScript
200 lines
5.6 KiB
TypeScript
import * as React from "react";
|
|
import { useFetchers, useLocation, useNavigation } from "react-router";
|
|
import { useRefreshOnReconnect } from "~/features/chat/chat-hooks";
|
|
import { useChatContext } from "~/features/chat/useChatContext";
|
|
import { useBackgroundResource } from "~/hooks/useBackgroundResource";
|
|
import type { SerializeFrom } from "~/utils/remix";
|
|
import { NOTIFICATIONS_DATA_ROUTE } from "~/utils/urls";
|
|
import { resyncPushSubscription } from "./core/pushSubscription";
|
|
import type { loader } from "./routes/api.notifications";
|
|
|
|
/** Spreads out the refetches when a notification fans out to many users at once. */
|
|
const PING_REFRESH_JITTER_MS = 3_000;
|
|
|
|
const WS_DOWN_POLL_MS = 2 * 60 * 1000;
|
|
|
|
export type NotificationsData = SerializeFrom<typeof loader>["notifications"];
|
|
|
|
interface NotificationsContextValue {
|
|
notifications?: NotificationsData;
|
|
/** Refetches the notification peek, without touching the page's own loaders. */
|
|
refresh: () => void;
|
|
}
|
|
|
|
const NotificationsContext = React.createContext<NotificationsContextValue>({
|
|
refresh: () => {},
|
|
});
|
|
|
|
/**
|
|
* Serves the notification peek (bell popover + unseen dot) and keeps it fresh
|
|
* push-first: an initial fetch after mount (deliberately not part of any
|
|
* loader, so notifications never delay a page), then a refetch whenever skalop
|
|
* pings over the chat websocket that the user's notifications changed
|
|
* server-side (new notification, marked seen, resolved by an action
|
|
* elsewhere). Polling and refetch-on-activity heuristics only kick in as a
|
|
* fallback while the websocket is down.
|
|
*/
|
|
export function NotificationsProvider({
|
|
user,
|
|
children,
|
|
}: {
|
|
user?: { id: number } | null;
|
|
children: React.ReactNode;
|
|
}) {
|
|
const { data, refresh } = useBackgroundResource<SerializeFrom<typeof loader>>(
|
|
NOTIFICATIONS_DATA_ROUTE,
|
|
);
|
|
const chat = useChatContext();
|
|
|
|
const loggedIn = Boolean(user);
|
|
const readyState = chat?.readyState ?? "CLOSED";
|
|
const wsDown = loggedIn && readyState !== "CONNECTED";
|
|
|
|
React.useEffect(() => {
|
|
if (!loggedIn) return;
|
|
|
|
refresh();
|
|
void resyncPushSubscription();
|
|
}, [loggedIn, refresh]);
|
|
|
|
useRefreshOnPing({ version: chat?.notificationsVersion ?? 0, refresh });
|
|
useRefreshOnReconnect(readyState, refresh);
|
|
useRefreshOnVisible({ enabled: loggedIn, refresh });
|
|
useFallbackPoll({ enabled: wsDown, refresh });
|
|
|
|
const notifications = data?.notifications;
|
|
|
|
useFallbackRefreshOnPotentialResolution({
|
|
enabled: wsDown,
|
|
notifications,
|
|
refresh,
|
|
});
|
|
|
|
const value: NotificationsContextValue = {
|
|
notifications,
|
|
refresh,
|
|
};
|
|
|
|
return (
|
|
<NotificationsContext.Provider value={value}>
|
|
{children}
|
|
</NotificationsContext.Provider>
|
|
);
|
|
}
|
|
|
|
/** The user's notification peek; `notifications` is `undefined` until the first fetch lands. */
|
|
export function useNotificationsData() {
|
|
return React.useContext(NotificationsContext);
|
|
}
|
|
|
|
function useRefreshOnPing({
|
|
version,
|
|
refresh,
|
|
}: {
|
|
version: number;
|
|
refresh: () => void;
|
|
}) {
|
|
React.useEffect(() => {
|
|
if (version === 0) return;
|
|
|
|
// jittered so a notification sent to a whole tournament's worth of users
|
|
// does not make every connected client refetch in the same instant; a
|
|
// follow-up ping inside the window replaces the pending refetch
|
|
const timeout = setTimeout(refresh, Math.random() * PING_REFRESH_JITTER_MS);
|
|
return () => clearTimeout(timeout);
|
|
}, [version, refresh]);
|
|
}
|
|
|
|
/** Refetches when the tab becomes visible again (e.g. waking from sleep). */
|
|
function useRefreshOnVisible({
|
|
enabled,
|
|
refresh,
|
|
}: {
|
|
enabled: boolean;
|
|
refresh: () => void;
|
|
}) {
|
|
React.useEffect(() => {
|
|
if (!enabled) return;
|
|
|
|
const handleVisibilityChange = () => {
|
|
if (document.visibilityState === "visible") {
|
|
refresh();
|
|
}
|
|
};
|
|
|
|
document.addEventListener("visibilitychange", handleVisibilityChange);
|
|
return () =>
|
|
document.removeEventListener("visibilitychange", handleVisibilityChange);
|
|
}, [enabled, refresh]);
|
|
}
|
|
|
|
function useFallbackPoll({
|
|
enabled,
|
|
refresh,
|
|
}: {
|
|
enabled: boolean;
|
|
refresh: () => void;
|
|
}) {
|
|
React.useEffect(() => {
|
|
if (!enabled) return;
|
|
|
|
const interval = setInterval(refresh, WS_DOWN_POLL_MS);
|
|
return () => clearInterval(interval);
|
|
}, [enabled, refresh]);
|
|
}
|
|
|
|
/**
|
|
* Without the websocket there is no ping when something the user did resolves
|
|
* an unseen notification, so fall back to refetching after anything that may
|
|
* have: a navigation (loaders mark notifications seen when the user views the
|
|
* page a notification points at) or a settled action submission (actions mark
|
|
* them seen when the user addresses the thing itself). Only fires while an
|
|
* unseen notification exists, so it usually adds no server load even then.
|
|
*/
|
|
function useFallbackRefreshOnPotentialResolution({
|
|
enabled,
|
|
notifications,
|
|
refresh,
|
|
}: {
|
|
enabled: boolean;
|
|
notifications: NotificationsData;
|
|
refresh: () => void;
|
|
}) {
|
|
const location = useLocation();
|
|
const navigation = useNavigation();
|
|
const fetchers = useFetchers();
|
|
|
|
const hasUnseen =
|
|
(enabled && notifications?.some((notification) => !notification.seen)) ??
|
|
false;
|
|
|
|
const submitting =
|
|
navigation.state === "submitting" ||
|
|
fetchers.some((fetcher) => fetcher.state === "submitting");
|
|
const allIdle =
|
|
navigation.state === "idle" &&
|
|
fetchers.every((fetcher) => fetcher.state === "idle");
|
|
|
|
const refreshOnIdleRef = React.useRef(false);
|
|
if (submitting && hasUnseen) {
|
|
refreshOnIdleRef.current = true;
|
|
}
|
|
|
|
const prevLocationKeyRef = React.useRef(location.key);
|
|
|
|
React.useEffect(() => {
|
|
if (prevLocationKeyRef.current !== location.key) {
|
|
prevLocationKeyRef.current = location.key;
|
|
if (hasUnseen) {
|
|
refresh();
|
|
}
|
|
return;
|
|
}
|
|
|
|
if (allIdle && refreshOnIdleRef.current) {
|
|
refreshOnIdleRef.current = false;
|
|
refresh();
|
|
}
|
|
}, [location.key, allIdle, hasUnseen, refresh]);
|
|
}
|