import { useCallback, useEffect, useMemo, useState, type ReactNode } from "react"; import { useLatest } from "../../lib/useLatest"; import { useTranslation } from "react-i18next"; import { useMe } from "../../lib/useMe"; import { channelUrl, resolvePage, retiredParams, urlWithParams, type Page, } from "../../lib/urlState"; import { useDocumentObjectTitle } from "../../lib/documentTitle"; import { getAccountRaw, setAccountRaw } from "../../lib/storage"; import { backStaysInDocument, closeOverlaysThenNavigate, nextNavId, notifyNav, rememberNavPosition, } from "../../lib/history"; import { foreignUrlKeys, moduleUrlKeys, pageTitleKey, reachablePage } from "./moduleRegistry"; import { CHANNEL_PAGE_KEYS } from "../../lib/channelPageUrlState"; import { PLAYER_KEYS } from "../../lib/playerUrlState"; import { DEFAULT_YT_COUNT, ytCountFrom, ytSearchFrom, ytSearchParams, } from "../../lib/ytSearchUrlState"; import { channelViewFrom, loadInitialPage, PAGE_KEY, START_ON_HOME_OPTIONS, START_ON_HOME_PREF, type ChannelView, } from "./navigationInit"; import { usePref } from "../../lib/usePref"; import { NavigationActionsContext, NavigationStateContext, type NavigationActions, type NavigationState, } from "./navigationContext"; // Owns the app's navigation + browser-history state, lifted out of the App god component so pages // consume it via a hook instead of prop-drilling `page`/`setPage`/`onOpenChannel` through the tree. // Mirrors ConfirmProvider (context + default value + one-line hooks); composed in main.tsx inside // ConfirmProvider, around . // // The contexts, their value types and the hooks live in navigationContext.ts. /** The address bar of `page`'s root: the page — the feed too: a bare address is the landing (Home, or * the remembered page), so a feed left unnamed reloaded or bookmarked as another page — no channel or * player over it, and every module's own keys cleared — a page switch resets to that module's root, * and its store mirrors what it holds back in. */ function pageRootParams(page: Page): Record { return { page, ...closedChannelParams(), ...nulls(PLAYER_KEYS), ...nulls(moduleUrlKeys()), }; } const nulls = (keys: readonly string[]) => Object.fromEntries(keys.map((k) => [k, null])); /** No channel page: neither `chan` nor its view's keys, which the page itself writes (ChannelPage) — * a closed channel leaves none behind. (An opened one gets its address from `channelUrl`.) */ const closedChannelParams = (): Record => ({ chan: null, ...nulls(CHANNEL_PAGE_KEYS), }); export function NavigationProvider({ children }: { children: ReactNode }) { const { t, i18n } = useTranslation(); // Held here as well as in Settings: this is the instance mounted on every boot, so a value only // this browser held reaches the server at once. const [startOnHome] = usePref(START_ON_HOME_PREF, true, START_ON_HOME_OPTIONS); const [chosenPage, setPageState] = useState(() => loadInitialPage(startOnHome)); // The page on screen: the chosen one when this account may open it, else the feed — derived, so a // link, a Back and a role taken away mid-session all land the same way, and the rail, the content // and the address bar (stamped from it below) agree. const me = useMe(); const page = reachablePage(chosenPage, me); // The live YouTube search: named in the address (`yt`, and `ytn` for its count, lib/ytSearchUrlState) // so a reload or a link opens it again; its own history entry, so Back steps player → search → feed. // Whether it RUNS by itself is not the address's to say: `ytAsked` is the term this document was // asked to search for (sent from the search box, or "Search YouTube again" pressed), on the entries // it has stayed on since — leaving the search ends it. Anything else — a reload, a link, a Back or // a Forward onto a search — runs only when it costs no quota (Feed decides). const [ytSearch, setYtSearch] = useState(() => page === "feed" ? ytSearchFrom(new URLSearchParams(window.location.search)) : null, ); const [ytCount, setYtCountState] = useState(() => ytCountFrom(new URLSearchParams(window.location.search)), ); const [ytAsked, setYtAsked] = useState(null); const ytSearchRef = useLatest(ytSearch); const ytCountRef = useLatest(ytCount); // The search ends: off the screen, and no longer asked for. const endYtSearch = useCallback(() => { setYtSearch(null); setYtAsked(null); }, []); // The open channel page rides in history.state (_chan = id, _chanName = a name to show before the // detail loads) AND is mirrored to the address bar as ?chan=, so Back closes it, a reload restores // it, and a bookmarked ?chan link opens it. Reload of an in-app entry reads history.state._chan; a // fresh bookmark has only the ?chan param. const [channelView, setChannelView] = useState(() => channelViewFrom(window.history.state, window.location.search), ); // A page this account can no longer open (a role taken away, a module switched off mid-session) // is given up, not kept behind the feed: kept, the address bar would go on naming it, a click on // Feed would be a no-op (setPage's same-page guard sees the feed already on screen), and a later // grant would switch the page by itself. A channel page open over it closes with it, as on any // page switch. Adjusted during render; the history entry and the remembered page follow in the // effect below. const [givenUp, setGivenUp] = useState(0); if (page !== chosenPage) { setPageState(page); setChannelView(null); setGivenUp((n) => n + 1); } // Latest-value refs so the STABLE action callbacks can read the latest state without depending on // it — a dep would recreate them on every navigation and re-render every actions consumer (e.g. // VideoCard). Only READ in event handlers, after commit. const pageRef = useLatest(page); const channelViewRef = useLatest(channelView); const setPage = useCallback( (next: Page, then?: () => void) => // Never under an open overlay — see the interface doc. The refs are read when the push // actually happens, which over an open player is a moment after this call. closeOverlaysThenNavigate(() => { // A channel page overlays the content column, so a nav click must close it even when the // underlying page is unchanged (e.g. "Feed" while a channel opened from the feed). if (next === pageRef.current && !channelViewRef.current) { then?.(); return; } setChannelView(null); // leave any open channel page when navigating via the rail // A live search under that channel ends too: the page opens at its root, which the address // below names — the Feed press over a channel opened from a search included. endYtSearch(); setPageState(next); setAccountRaw(PAGE_KEY, next); // Push an in-app history entry so browser/mouse Back steps through pages instead of leaving // the app. A fresh { sfPage } (no carried-over _yt/_chan markers) so each page starts at its // root, and the address bar reflects the page (the feed too) while clearing `chan` and // every module's own keys (pageRootParams) — a page switch resets to that module's root, and // each module's store mirrors its state back in on its page. The item it was opened from is // still focused: the state setters above have not re-rendered anything yet. rememberNavPosition(); window.history.pushState( { sfPage: next, sfScroll: nextNavId() }, "", urlWithParams(pageRootParams(next)), ); notifyNav(); then?.(); }), [pageRef, channelViewRef, endYtSearch], ); // A sub-view (channel / YT search) is an OVERLAY, not a page change. Like setPage it never lands // under an open overlay: the player's channel link and the palette's channel rows call it from // inside one. const openChannel = useCallback((id: string, name?: string) => { closeOverlaysThenNavigate(() => { // The channel's address names the channel alone: the page under it keeps its own keys on its // own entry, where Back finds them, and writes none while the channel stands over it (urlPage). const url = channelUrl(id); if (window.history.state?._chan) { const st = window.history.state; window.history.replaceState({ ...st, _chan: id, _chanName: name ?? null }, "", url); } else { const st = rememberNavPosition(); window.history.pushState({ ...st, _chan: id, _chanName: name ?? null }, "", url); } setChannelView({ id, name }); }); }, []); const closeChannel = useCallback(() => { if (window.history.state?._chan && backStaysInDocument()) window.history.back(); else { // No owned history entry (e.g. opened straight from a ?chan bookmark), or none of this document // below it to step back to (a full tab, see backStaysInDocument): clear it in place — the // entry's channel marks and the ?chan param. // A live search under it (the channel opened from its results) is on screen again, and named // again: the channel's address carried none of it. setChannelView(null); const { _chan: _id, _chanName: _name, ...st } = window.history.state ?? {}; window.history.replaceState( st, "", urlWithParams({ ...closedChannelParams(), ...ytSearchParams(ytSearchRef.current, ytCountRef.current), }), ); } }, [ytSearchRef, ytCountRef]); const enterYtSearch = useCallback((q: string) => { setYtSearch(q); setYtAsked(q); const st = window.history.state ?? {}; const here = new URLSearchParams(window.location.search); if (st._yt || ytSearchFrom(here) != null) { // Refine the search on screen, in place, at the count it shows (one a link opened too: it // is the first entry of this document, so its new `_yt` mark has nothing of ours below it). window.history.replaceState( { ...st, _yt: q }, "", urlWithParams(ytSearchParams(q, ytCountFrom(here))), ); } else { // New sub-view entry, at the default count. setYtCountState(DEFAULT_YT_COUNT); const left = rememberNavPosition(); window.history.pushState( { ...left, sfPage: "feed", _yt: q }, "", urlWithParams(ytSearchParams(q, DEFAULT_YT_COUNT)), ); } }, []); // The press that runs the search on screen, where it did not run by itself. const askYtSearch = useCallback(() => setYtAsked(ytSearchRef.current), [ytSearchRef]); const setYtCount = useCallback( (n: number) => { setYtCountState(n); window.history.replaceState( window.history.state, "", urlWithParams(ytSearchParams(ytSearchRef.current, n)), ); }, [ytSearchRef], ); const exitYtSearch = useCallback(() => { // Pop our search entry (so Forward still works); fall back to a clear in place if we don't own // one, or there is no entry of this document below it (a full tab, see backStaysInDocument). if (window.history.state?._yt && backStaysInDocument()) window.history.back(); else { endYtSearch(); const { _yt: _q, ...st } = window.history.state ?? {}; window.history.replaceState(st, "", urlWithParams(ytSearchParams(null, DEFAULT_YT_COUNT))); } }, [endYtSearch]); // Leaving the feed ends any live YouTube search, so coming back shows the normal feed. Adjusted // DURING the render that changes the page, against the page we were last on: as an effect it ran // after the new page had already committed, so for one frame a module rendered with a live search // still in the nav state. Keyed on the CHANGE rather than derived from `page`, because "ended" has // to be permanent — a derived null would quietly come back when you returned to the feed. const [pageSearchWasOn, setPageSearchWasOn] = useState(page); if (pageSearchWasOn !== page) { setPageSearchWasOn(page); if (page !== "feed") { setYtSearch(null); setYtAsked(null); } } // The browser tab names the place and the object in it (DESIGN § Composition, "Where am I"): // " · · Siftlode". The object is the open view's own heading (a channel, a Plex // title, a playlist — `PageHeading` reports it), so a channel opened from a link is named the same // as one opened in the app. Signed out, the page on screen is the public landing whatever `page` // says — the tab read "What should I watch?" over the sign-in form (prod UAT 2026-09-19). const signedIn = me != null; const objectTitle = useDocumentObjectTitle(); useEffect(() => { const parts = signedIn ? [objectTitle, t(pageTitleKey(page))] : []; document.title = [...parts.filter(Boolean), "Siftlode"].join(" · "); }, [signedIn, page, objectTitle, i18n.language, t]); // The page given up above is the feed, and the current entry says so (replaced, not pushed: no // step was taken). Its own keys go with it, and the channel page closed with it, as on any page // switch — left in the entry, a reload would reopen what the screen no longer shows. useEffect(() => { if (!givenUp) return; setAccountRaw(PAGE_KEY, "feed"); const { _chan: _closedChan, _chanName: _closedName, ...rest } = window.history.state ?? {}; window.history.replaceState( { ...rest, sfPage: "feed" }, "", urlWithParams(pageRootParams("feed")), ); }, [givenUp]); // The page this tab LOADED on. Frozen, because the stamp below belongs to the initial history // entry: every later page change writes its own entry through setPage. const [mountPage] = useState(page); // In-app Back/Forward: stamp the initial entry with the current page (mirrored to the address bar), // then sync state from history.state on popstate — the browser restores each entry's URL for us, so // the popstate handler only needs to read history.state. useEffect(() => { // The entry's `_yt` mark stays while the address still names its search (a reload of it); a mark // left without one (an older build's entry) goes. _chan/_chanName are always kept so a channel // page survives F5. const st = window.history.state || {}; // Mirror the resolved initial page to the address bar (the feed too) so a page reached from the // stored landing pref — not just from the URL — is itself bookmarkable. urlWithParams keeps the // shell's keys (?chan, a player's) and the page's own; other pages' go (below). // A retired page's link — or the remembered last page, when that is what opened — also opens its // part of the page that replaced it (an `at` tab). const linked = new URLSearchParams(window.location.search).get("page"); const remembered = getAccountRaw(PAGE_KEY); // A link can carry another page's keys (hand-edited, or written by an older build): the page // opens without them, so its address names only what it shows — and a channel page open over it // shows none of the page's own (an older build's channel links carried the feed's filters). const shown = channelViewFrom(st, window.location.search); const url = urlWithParams({ ...nulls(shown ? moduleUrlKeys() : foreignUrlKeys(mountPage)), ...retiredParams(linked ?? (resolvePage(remembered) === mountPage ? remembered : null)), page: mountPage, }); if (st._yt && ytSearchFrom(new URLSearchParams(window.location.search)) != null) { window.history.replaceState({ ...st, sfPage: mountPage }, "", url); } else { const { _yt: _staleYt, _ytScrape: _staleScrape, ...rest } = st; window.history.replaceState({ ...rest, sfPage: mountPage }, "", url); } function onPop(e: PopStateEvent) { // An entry can name a page this build no longer has (one pushed before a module was removed, // reached by Back after a reload onto the new build); that lands where the page's content went // (`resolvePage`), else on the feed, not on a crash. The entry is rewritten to say so: left // alone, the address bar would keep naming the dead page and a click on the page it landed on // would be a no-op (setPage's same-page guard). It spreads the LIVE entry, not `e.state`: // lib/history's listener ran first and already took the spent `sfNav` off it. const sf: unknown = e.state?.sfPage; const p: Page = (typeof sf === "string" && resolvePage(sf)) || "feed"; if (typeof sf === "string" && sf !== p) window.history.replaceState( { ...window.history.state, sfPage: p }, "", urlWithParams({ ...retiredParams(sf), page: p }), ); setPageState(p); setAccountRaw(PAGE_KEY, p); // The YouTube search is read from the address the browser restored, so Back/Forward opens or // clears it to match the entry we landed on. One landed on without it has ended: a Forward back // onto it is not asked for, and an API search waits for a press. const here = new URLSearchParams(window.location.search); const landed = p === "feed" ? ytSearchFrom(here) : null; setYtSearch(landed); if (landed == null) setYtAsked(null); setYtCountState(ytCountFrom(here)); // The channel page rides in history.state._chan, with a ?chan URL fallback for a fresh bookmark // entry (URL-only, no _chan) — reading e.state alone would wrongly close the channel when a // popstate lands here (e.g. after an overlay's programmatic history.back()). channelViewFrom // shares this resolution with the seed initializer above. setChannelView(channelViewFrom(e.state, window.location.search)); } window.addEventListener("popstate", onPop); return () => window.removeEventListener("popstate", onPop); }, [mountPage]); const state = useMemo( () => ({ page, channelView, ytSearch, ytCount, ytSearchAsked: ytSearch != null && ytAsked === ytSearch, }), [page, channelView, ytSearch, ytCount, ytAsked], ); const actions = useMemo( () => ({ setPage, openChannel, closeChannel, enterYtSearch, exitYtSearch, askYtSearch, setYtCount, }), [setPage, openChannel, closeChannel, enterYtSearch, exitYtSearch, askYtSearch, setYtCount], ); return ( {children} ); }