- A URL mirror names the view again on every entry Back or Forward lands on, so a change made under a dialog, a drawer or a sub-view is not undone by the next reload. - A view pushed from the entry a reload under a dialog started from no longer inherits its overlay mark; a reload under two overlays closes onto the page in one step. - The linked player reopens over a live search, gives itself up when its list moves first, and plays alone over an emptied playlist; a capped Files list keeps the link it cannot resolve. - The Feed press over a channel ends the search; leaving a search ends its asked mark; Files and Playlists searches end with their page; the feed always names its view and debounces its search. - Search-box keys are declared per module and kept out of the client log below TRACE. - Writers outside lib/ go through codecs; the URL contract scans computed keys, overlay writers and two-line reads, and holds every page to a bookmark round trip (Config, Activity, Playlists and the Files sort and viewer added).
396 lines
18 KiB
TypeScript
396 lines
18 KiB
TypeScript
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 <App/>.
|
|
//
|
|
// 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<string, string | null> {
|
|
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<string, string | null> => ({
|
|
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<Page>(() => 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<string | null>(() =>
|
|
page === "feed" ? ytSearchFrom(new URLSearchParams(window.location.search)) : null,
|
|
);
|
|
const [ytCount, setYtCountState] = useState(() =>
|
|
ytCountFrom(new URLSearchParams(window.location.search)),
|
|
);
|
|
const [ytAsked, setYtAsked] = useState<string | null>(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=<id>, 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<ChannelView | null>(() =>
|
|
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"):
|
|
// "<object> · <module> · 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<NavigationState>(
|
|
() => ({
|
|
page,
|
|
channelView,
|
|
ytSearch,
|
|
ytCount,
|
|
ytSearchAsked: ytSearch != null && ytAsked === ytSearch,
|
|
}),
|
|
[page, channelView, ytSearch, ytCount, ytAsked],
|
|
);
|
|
const actions = useMemo<NavigationActions>(
|
|
() => ({
|
|
setPage,
|
|
openChannel,
|
|
closeChannel,
|
|
enterYtSearch,
|
|
exitYtSearch,
|
|
askYtSearch,
|
|
setYtCount,
|
|
}),
|
|
[setPage, openChannel, closeChannel, enterYtSearch, exitYtSearch, askYtSearch, setYtCount],
|
|
);
|
|
|
|
return (
|
|
<NavigationActionsContext.Provider value={actions}>
|
|
<NavigationStateContext.Provider value={state}>{children}</NavigationStateContext.Provider>
|
|
</NavigationActionsContext.Provider>
|
|
);
|
|
}
|