/** * Keeps the community feed for the components that show it, loaded from * `GET /api/community/feed` and again whenever the socket tells of something * the feed may hold. `App` mounts it around every page, so that the feed * outlives the header it is shown in, which every page renders anew. * * @module */ import { type ReactNode, useCallback, useEffect, useMemo, useRef, useState, } from "react"; import type { Wire } from "../../../shared/api.ts"; import type { CommunityFeed } from "../../../shared/community.ts"; import { COMMUNITY_FEED_URL } from "../../config/api.ts"; import { useAuth } from "../../hooks/auth/useAuth.ts"; import { useFetchState } from "../../hooks/shared/useFetchState.ts"; import { useOnReconnect, useWS, useWSEvent } from "../../hooks/ws/useWS.ts"; import { expectOk } from "../../utils/shared/api.ts"; import { deserializeCommunityFeed } from "../../utils/shared/deserialize.ts"; import { CommunityFeedContext, type CommunityFeedContextValue, } from "./CommunityFeedContext.ts"; /** Passed to {@link CommunityFeedProvider} by `App`. */ export interface CommunityFeedProviderProps { children: ReactNode; } // The feed is fetched again this long after the last change the socket told // of, so that a run of upvotes costs one request. const RELOAD_MS = 2000; /** * Provides {@link CommunityFeedContext}. It fetches the feed once a component * watches it, for the signed-in user alone. The socket's messages don't carry * a feed's lines, an upvote's naming neither its dump nor its voter, so each * one that may change the feed has it fetched again, and the server's query * stays the one place that decides what the feed holds. A change that comes * while nothing watches is caught up with on the next watch. */ export function CommunityFeedProvider( { children }: CommunityFeedProviderProps, ): React.JSX.Element { const { user, authFetch } = useAuth(); const [watched, setWatched] = useState(false); const load = useCallback( async (signal: AbortSignal) => deserializeCommunityFeed( await expectOk>( await authFetch(COMMUNITY_FEED_URL, { signal }), ), ), [authFetch], ); const { state, reload } = useFetchState(user?.id, load, { enabled: watched, }); const watchers = useRef(0); // A change came that the feed hasn't been fetched again for. const due = useRef(false); const timer = useRef>(undefined); const refresh = useCallback(() => { if (!due.current || watchers.current === 0) return; due.current = false; reload(); }, [reload]); const changed = useCallback(() => { due.current = true; clearTimeout(timer.current); timer.current = setTimeout(refresh, RELOAD_MS); }, [refresh]); useEffect(() => () => clearTimeout(timer.current), []); useWSEvent("dump", changed); useWSEvent("vote", changed); useWSEvent("comment", (e) => e.type !== "updated" && changed()); useWSEvent("playlist", (e) => e.type !== "dumps_updated" && changed()); // A rename changes the names the feed writes. useWSEvent("user", changed); useOnReconnect(changed); // A deleted dump comes as no event, only as a change of this set. const { deletedDumpIds } = useWS(); const seenDeleted = useRef(deletedDumpIds); useEffect(() => { if (seenDeleted.current === deletedDumpIds) return; seenDeleted.current = deletedDumpIds; changed(); }, [deletedDumpIds, changed]); const watch = useCallback(() => { watchers.current++; setWatched(true); refresh(); return () => { watchers.current--; }; }, [refresh]); const feed = state.status === "loaded" ? state.data : null; const value: CommunityFeedContextValue = useMemo( () => ({ feed, watch }), [feed, watch], ); return ( {children} ); }