import { createContext, useCallback, useContext, useEffect, useMemo, useRef, useState, type ReactNode, } from "react"; import { useTranslation } from "react-i18next"; import { isPage, readPage, type Page } from "../lib/urlState"; import { pageTitleKey } from "../lib/pageMeta"; import { getAccountRaw, LS, setAccountRaw } from "../lib/storage"; import { setNavigator } from "../lib/nav"; // 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 . // // TWO contexts on purpose: a STABLE actions context (setPage/openChannel/… never change reference) // and a CHANGING state context (page/channelView/ytSearch). Context consumers re-render whenever the // value changes, so deep memo'd leaves (VideoCard, PlayerModal) subscribe to actions ONLY and never // re-render on a page/channel switch — preserving the memo that keeps feed appends from re-rendering // every existing card. const PAGE_KEY = LS.page; export interface ChannelView { id: string; name?: string; } export interface NavigationState { page: Page; /** The open channel page (null = none). Overlays `page`; rides in history.state._chan. */ channelView: ChannelView | null; /** Live YouTube search term (null = normal feed). A feed sub-view; rides in history.state._yt. */ ytSearch: string | null; } export interface NavigationActions { setPage: (next: Page) => void; openChannel: (id: string, name?: string) => void; closeChannel: () => void; enterYtSearch: (q: string) => void; exitYtSearch: () => void; } function loadInitialPage(): Page { const params = new URLSearchParams(window.location.search); if (params.has("page")) return readPage(); const stored = getAccountRaw(PAGE_KEY); return isPage(stored) ? stored : "feed"; } const StateContext = createContext({ page: "feed", channelView: null, ytSearch: null, }); const ActionsContext = createContext({ setPage: () => {}, openChannel: () => {}, closeChannel: () => {}, enterYtSearch: () => {}, exitYtSearch: () => {}, }); export function useNavigation(): NavigationState { return useContext(StateContext); } export function useNavigationActions(): NavigationActions { return useContext(ActionsContext); } export function NavigationProvider({ children }: { children: ReactNode }) { const { t, i18n } = useTranslation(); const [page, setPageState] = useState(loadInitialPage); // Ephemeral: not persisted and not in the URL, so a reload returns to the normal feed (a search // spends quota, so we never auto-replay it) — UNLESS it was zero-quota scrape-sourced, which rides // in history.state and is free to re-fetch. The popstate handler derives it from history.state so // Back steps player → search → feed instead of the search vanishing without a history entry. const [ytSearch, setYtSearch] = useState(() => { const st = window.history.state; return st?._yt && st?._ytScrape ? (st._yt as string) : null; }); // The open channel page rides in history.state (_chan = id, _chanName = a name to show before the // detail loads), so Back closes it and a reload restores it (unlike ytSearch, which is dropped). const [channelView, setChannelView] = useState(() => { const c = window.history.state?._chan as string | undefined; return c ? { id: c, name: (window.history.state?._chanName as string) ?? undefined } : null; }); // 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). // Written during render (the sanctioned "latest value" pattern): the ref is only READ in event // handlers after commit, and this provider never suspends or renders in a transition, so no // discarded render can leave it stale. const pageRef = useRef(page); pageRef.current = page; const channelViewRef = useRef(channelView); channelViewRef.current = channelView; const setPage = useCallback((next: Page) => { // 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) return; setChannelView(null); // leave any open channel page when navigating via the rail setPageState(next); setAccountRaw(PAGE_KEY, next); // Push an in-app history entry so browser/mouse Back steps through pages instead of leaving the // app. The URL stays clean — the page rides in history.state. A fresh { sfPage } (no carried-over // _yt/_chan markers) so each page starts at its root. window.history.pushState({ sfPage: next }, ""); }, []); const openChannel = useCallback((id: string, name?: string) => { setChannelView({ id, name }); const st = window.history.state || {}; if (st._chan) window.history.replaceState({ ...st, _chan: id, _chanName: name ?? null }, ""); else window.history.pushState({ ...st, _chan: id, _chanName: name ?? null }, ""); }, []); const closeChannel = useCallback(() => { if (window.history.state?._chan) window.history.back(); else setChannelView(null); }, []); const enterYtSearch = useCallback((q: string) => { setYtSearch(q); // Drop any prior _ytScrape marker — the new search's source isn't known yet; Feed re-stamps it // once the results resolve, so a reload only restores a confirmed scrape-sourced search. const { _ytScrape: _drop, ...st } = window.history.state || {}; if (st._yt) window.history.replaceState({ ...st, _yt: q }, ""); // refine current search else window.history.pushState({ ...st, sfPage: "feed", _yt: q }, ""); // new sub-view entry }, []); const exitYtSearch = useCallback(() => { // Pop our search entry (so Forward still works); fall back to a plain clear if we don't own one. if (window.history.state?._yt) window.history.back(); else setYtSearch(null); }, []); // Expose setPage to decoupled callers (download toast "View", etc.) via the lib/nav singleton. useEffect(() => setNavigator(setPage), [setPage]); // Leaving the feed ends any live YouTube search, so coming back shows the normal feed. useEffect(() => { if (page !== "feed") setYtSearch(null); }, [page]); // Reflect the current module in the browser tab title. Feed = brand only; an open channel page // shows the channel name. useEffect(() => { const brand = "Siftlode"; const label = channelView ? channelView.name || t("header.channelManager") : page === "feed" ? null : t(pageTitleKey(page)); document.title = label ? `${label} · ${brand}` : brand; }, [page, channelView, i18n.language, t]); // In-app Back/Forward: stamp the initial entry with the current page, then sync state from // history.state on popstate. (stripUrlParams preserves history.state, so this stamp survives a // later query-string strip in App.) useEffect(() => { // A reload drops a stale _yt EXCEPT when scrape-sourced (zero quota): then keep _yt + _ytScrape // so it restores and re-fetches free (ytSearch above already initialised from it). _chan/_chanName // are always kept so a channel page survives F5. const st = window.history.state || {}; if (st._yt && st._ytScrape) { window.history.replaceState({ ...st, sfPage: page }, ""); } else { const { _yt: _staleYt, _ytScrape: _staleScrape, ...rest } = st; window.history.replaceState({ ...rest, sfPage: page }, ""); } function onPop(e: PopStateEvent) { const p = (e.state?.sfPage as Page) ?? "feed"; setPageState(p); setAccountRaw(PAGE_KEY, p); // The YouTube-search sub-view and the channel page ride in history.state, so Back/Forward // restores or clears them to match the entry we landed on. setYtSearch((e.state?._yt as string) ?? null); const chan = e.state?._chan as string | undefined; setChannelView(chan ? { id: chan, name: (e.state?._chanName as string) ?? undefined } : null); } window.addEventListener("popstate", onPop); return () => window.removeEventListener("popstate", onPop); // eslint-disable-next-line react-hooks/exhaustive-deps }, []); const state = useMemo( () => ({ page, channelView, ytSearch }), [page, channelView, ytSearch], ); const actions = useMemo( () => ({ setPage, openChannel, closeChannel, enterYtSearch, exitYtSearch }), [setPage, openChannel, closeChannel, enterYtSearch, exitYtSearch], ); return ( {children} ); }