Files
siftlode/frontend/src/features/navigation/NavigationProvider.tsx
T
peter 0d5c6273f1 fix(nav): the address follows the view through overlays, reloads and Back (review round 1)
- 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).
2026-10-27 13:52:42 +01:00

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>
);
}