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,
replaceView,
} 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()),
};
}
/** The address of the page under a live search, the search ended: no search, player or channel. */
const searchEndParams = (): Record => ({
...closedChannelParams(),
...nulls(PLAYER_KEYS),
...ytSearchParams(null, DEFAULT_YT_COUNT),
});
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" 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).
// So must a live search on it, though that press ends only the search: the page's view stays.
const samePage = next === pageRef.current && !channelViewRef.current;
if (samePage && ytSearchRef.current == null) {
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.
// The press on a search over its own page ends only the search: the page's view is the one
// on screen and stays named — its store, unchanged, writes nothing back in.
rememberNavPosition();
window.history.pushState(
{ sfPage: next, sfScroll: nextNavId() },
"",
urlWithParams(samePage ? searchEndParams() : pageRootParams(next)),
);
notifyNav();
then?.();
}),
[pageRef, channelViewRef, ytSearchRef, 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;
replaceView({ ...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 ?? {};
replaceView(
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).
replaceView({ ...st, _yt: q }, urlWithParams(ytSearchParams(q, ytCountFrom(here))));
} else {
// New sub-view entry, at the default count — ours (`_ytOwn`): leaving it steps back off it.
setYtCountState(DEFAULT_YT_COUNT);
const left = rememberNavPosition();
window.history.pushState(
{ ...left, sfPage: "feed", _yt: q, _ytOwn: 1 },
"",
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);
replaceView(
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 — a search a link or a reload opened is marked (`_yt`) but was pushed by nobody: below it is
// another site, or nothing — or there is no entry of this document below it (a full tab, see
// backStaysInDocument).
if (window.history.state?._ytOwn && backStaysInDocument()) window.history.back();
else {
endYtSearch();
const { _yt: _q, _ytOwn: _own, ...st } = window.history.state ?? {};
replaceView(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") endYtSearch();
}
// The browser tab names the place and the object in it (DESIGN § Composition, "Where am I"):
// "