Files
siftlode/frontend/src/modules/channels/ChannelsProvider.tsx
T
peter 91d36b3357 fix(channel): the channel page's address names the channel and its own view, not the page under it
Opening a channel kept every key of the page under it (`?q=music&sort=oldest&chan=…`, a sort the
channel did not show), and the channel's own tab, sort and content types lived in its state alone, so a
reload or a bookmark lost them. Now the channel's address is `channelUrl`: the shell's keys and `chan`,
shared by `openChannel` and the channel link's href. The page writes its view (`chtab`, `chsort`,
`chtypes`, lib/channelPageUrlState) and reopens it; these are shell keys, cleared by a page switch and
by closing it in place. While a channel stands over a page, no module store writes its keys (`urlPage`);
Back finds them on the page's own entry. A link written by an older build opens without the page's keys.
2026-10-26 00:19:02 +01:00

265 lines
13 KiB
TypeScript

import {
useCallback,
useEffect,
useLayoutEffect,
useMemo,
useRef,
useState,
type ReactNode,
} from "react";
import { getAccountRaw, LS, useAccountPersistedState } from "../../lib/storage";
import { parseChannelLayout } from "../../lib/channelLayout";
import { hydrateSort } from "../../lib/columnSort";
import { useLatest } from "../../lib/useLatest";
import { useSortState } from "../../lib/useSortState";
import { usePref } from "../../lib/usePref";
import { useFilterState } from "../../lib/useFilterState";
import { useUrlMirror } from "../../lib/useUrlMirror";
import { rememberNavPosition } from "../../lib/history";
import {
channelsParamsToState,
channelsParamUpdates,
hasChannelsParams,
type ChannelsUrlState,
} from "../../lib/channelsUrlState";
import type { FilterMap } from "../../lib/tableColumns";
import { useNavigation, useNavigationActions } from "../../features/navigation/navigationContext";
import { urlPage } from "../../features/navigation/urlPage";
import {
ChannelLayoutContext,
ChannelsActionsContext,
ChannelsStateContext,
type ChannelsActions,
type ChannelsState,
type ChannelsView,
} from "./channelsContext";
// Owns the Channels module's shared local state — the rail-less manager's name filter, column-filter
// map, active tab, the "drop a stale column filter" token, and the focus-a-channel intent — lifted out of
// App so the Channels page, the header search box, and the cross-module callers (the feed filter
// sidebar and the notifications inbox both "focus this channel in the manager") read it via hooks
// instead of prop-drilling through App. Mirrors FeedFiltersProvider (split State + Actions contexts,
// stable useCallback setters, one-line hooks; composed in main.tsx around <App/>).
//
// Nested INSIDE NavigationProvider so its actions can drive navigation: focusChannel and
// goToFullHistory both jump to the Channels page (and force the subscriptions tab). filters / view are
// per-account persisted (localStorage); search / focus / the reset token are ephemeral. An account
// switch does a full reload, so init-from-storage is all the hydration the persisted ones need.
//
// The contexts, their value types and the hooks live in channelsContext.ts.
function clampView(v: string | null): ChannelsView {
return v === "discovery" || v === "watched" || v === "blocked" || v === "hidden"
? v
: "subscribed";
}
// One-time carry-forward of the retired single-value sync chip (LS.legacyChannelFilter, vocab
// all/needs_full/fully_synced) into the new filter map's `sync` key (vocab ""/needsFull/fully), so a
// user who had a chip pinned keeps it. Only consulted when channelsFilterMap is still empty.
function migrateSyncChip(): FilterMap | null {
const old = getAccountRaw(LS.legacyChannelFilter);
if (old === "needs_full") return { sync: "needsFull" };
if (old === "fully_synced") return { sync: "fully" };
return null;
}
// The manager's stored column-filter map, healed to a plain string/string[] object (same discipline as
// useFilterState's hydrate). Used only to build the seed's base — the URL keeps whatever the stored map
// has for any dimension a partial shared link omits.
function readStoredFilters(): FilterMap {
const raw = getAccountRaw(LS.channelsFilterMap);
if (raw) {
try {
const v = JSON.parse(raw);
if (v && typeof v === "object" && !Array.isArray(v)) return v as FilterMap;
} catch {
/* fall through to the migrate seed */
}
}
return migrateSyncChip() ?? {};
}
// A shared manager link carries the content state in namespaced URL keys (ctab/csort/cf). When
// present, the URL wins over this account's stored view — read once at mount and applied over the slice
// hooks (see the provider). The keys are channels-unique, so their mere presence identifies a shared
// manager view; no ?page check is needed (the mirror below is page-gated so it never pollutes others).
// The base is this account's stored view (BootGate claims the local state before this provider mounts), so
// a PARTIAL link — one carrying only the tab, say — keeps the stored sort and filter map for the rest.
function readChannelsUrlSeed(): ChannelsUrlState | null {
const params = new URLSearchParams(window.location.search);
if (!hasChannelsParams(params)) return null;
return channelsParamsToState(params, {
view: clampView(getAccountRaw(LS.channelsView)),
sort: hydrateSort(getAccountRaw(LS.channelSort), [], null),
filters: readStoredFilters(),
});
}
export function ChannelsProvider({ children }: { children: ReactNode }) {
const nav = useNavigation();
const { page } = nav;
const { setPage } = useNavigationActions();
const [search, setSearchState] = useState("");
// The subscribed table's column-filter map, owned here (lifted out of DataTable) so the top status
// chips, the Sync column funnel, and the card layout share one state; useFilterState persists it.
const [filters, setFilters] = useFilterState(LS.channelsFilterMap, migrateSyncChip);
// The view is this browser's (useAccountPersistedState), the layout an account preference
// (usePref). Stored un-clamped; clampView/parseChannelLayout heal them for display. setView layers
// the history logic (below) on top of the raw setter.
const [viewRaw, setViewRaw] = useAccountPersistedState(LS.channelsView, "subscribed");
const view = clampView(viewRaw);
const [layoutRaw, setLayout] = usePref<string>("channelLayout", "table", {
localKey: LS.channelLayout,
parse: (raw) => (typeof raw === "string" ? raw : "table"),
});
const layout = parseChannelLayout(layoutRaw);
// Sort is owned here (lifted out of DataTable) so the card layout shares it; ColumnSortControl heals
// it against the live columns for display, so the provider stores it un-healed (no options passed).
const [sort, setSort] = useSortState(LS.channelSort);
const [filtersResetToken, setFiltersResetToken] = useState(0);
const [focusNameRaw, setFocusName] = useState<string | null>(null);
const [focusToken, setFocusToken] = useState(0);
// Shareable manager view. Captured once at mount (or null when no manager URL keys are present).
const [urlSeed] = useState(readChannelsUrlSeed);
const seededRef = useRef(false);
// Apply the shared-link seed once, BEFORE paint, through the slice setters — which persist it, so the
// shared view also survives a later PLAIN reload (no URL). setViewRaw (not setView) restores the tab
// without pushing a phantom history entry. A normal load has urlSeed === null and this is a no-op.
// `view` is frozen at mount for the same reason `urlSeed` is: the comparison is against the tab
// this load started on, and the three setters are stable by construction (useFilterState,
// useSortState and useAccountPersistedState all return a useCallback). So the dep list can name
// every value the body reads without the seed re-arming on a later tab switch.
const [mountView] = useState(view);
useLayoutEffect(() => {
if (!urlSeed || seededRef.current) return;
seededRef.current = true;
if (urlSeed.view !== mountView) setViewRaw(urlSeed.view);
setFilters(urlSeed.filters);
setSort(urlSeed.sort);
}, [urlSeed, mountView, setViewRaw, setFilters, setSort]);
// Latest-value ref so the STABLE setView can compare against the current tab without depending on
// it (a dep would recreate the action on every switch and re-render every actions consumer).
const viewRef = useLatest(view);
const setSearch = useCallback((q: string) => setSearchState(q), []);
// The active tab rides in history.state (`_chTab`), the same way the channel page (`_chan`) and the
// live YouTube search (`_yt`) do — so Back steps back through the tabs you actually visited instead
// of jumping straight out to the previous module.
const setView = useCallback(
(v: ChannelsView) => {
// Re-selecting the tab you are already on is not a navigation — bail before pushing, or clicking
// the active tab (and goToFullHistory forcing "subscribed") stacks Back entries that do nothing.
if (v === viewRef.current) return;
setViewRaw(v); // persists (via useAccountPersistedState)
// Only own an entry while the manager is already on screen. Arriving from elsewhere
// (focusChannel / goToFullHistory) lets setPage push the entry — the stamp effect labels it — so
// one navigation never costs two Back presses.
if (window.history.state?.sfPage === "channels") {
// A FRESH state, like setPage's: carrying the old entry's `_chan`/`_yt` markers over would make
// Back re-open the channel overlay or a spent search on top of the tab.
rememberNavPosition();
window.history.pushState({ sfPage: "channels", _chTab: v }, "");
}
},
[setViewRaw, viewRef],
);
const focusChannel = useCallback(
(name: string) => {
setFocusName(name);
setFocusToken((n) => n + 1); // re-seed even when the same channel is re-focused
setView("subscribed"); // the name filter applies to the subscriptions table
setPage("channels");
},
[setView, setPage],
);
const goToFullHistory = useCallback(() => {
// One controlled map replaces the old status chip: sync=needsFull, and dropping every other
// column filter (the whole map is set fresh) so no stale funnel hides the rows we're sent to see.
setFilters({ sync: "needsFull" });
setView("subscribed"); // the sync filter applies to the subscriptions tab
setFiltersResetToken((n) => n + 1); // also clear the ephemeral name search + tag chips
setPage("channels");
}, [setFilters, setView, setPage]);
// The manager focus only means anything ON the Channels page, so it is DERIVED from the page
// rather than cleared by an effect when you leave. focusChannel sets the name and navigates in the
// same handler, so the value is live by the time channels renders; everywhere else it reads null,
// one render earlier than the effect used to make it.
//
// But an intent has to be CONSUMED, not merely masked, and the consumed marker has to live HERE.
// Channels is a page chunk that unmounts on every navigation (and on opening a channel page, which
// does not even change `page`), so its seeding effect runs again on each remount. A marker inside
// it would reset with it and re-apply an intent from earlier in the session over whatever the user
// has since typed into the search box. Keyed on the token rather than the name, because
// re-focusing the same channel is a new intent.
const [focusApplied, setFocusApplied] = useState(0);
const consumeFocus = useCallback(() => setFocusApplied(focusToken), [focusToken]);
const focusName = page === "channels" && focusApplied !== focusToken ? focusNameRaw : null;
// Label the entry the manager was REACHED on (setPage pushes a bare { sfPage }), so stepping back
// onto it later restores the tab it belonged to rather than leaving whatever tab is current.
useEffect(() => {
if (page !== "channels") return;
const st = window.history.state || {};
if (st._chTab === undefined) window.history.replaceState({ ...st, _chTab: view }, "");
}, [page, view]);
// Back/Forward across tab entries: restore the tab the entry carries.
useEffect(() => {
function onPop(e: PopStateEvent) {
const tab = e.state?._chTab;
if (!tab) return; // an entry outside the manager — leave the tab alone
setViewRaw(clampView(tab as string)); // persists the restored tab (setViewRaw is stable)
}
window.addEventListener("popstate", onPop);
return () => window.removeEventListener("popstate", onPop);
}, [setViewRaw]);
const state = useMemo<ChannelsState>(
() => ({ search, filters, view, sort, filtersResetToken, focusName, focusToken }),
[search, filters, view, sort, filtersResetToken, focusName, focusToken],
);
const actions = useMemo<ChannelsActions>(
() => ({
setSearch,
setFilters,
setView,
setLayout,
setSort,
focusChannel,
consumeFocus,
goToFullHistory,
}),
[
setSearch,
setFilters,
setView,
setLayout,
setSort,
focusChannel,
consumeFocus,
goToFullHistory,
],
);
// Mirror the content state (tab + sort + column filters) into the address bar under the namespaced
// keys so a manager view is shareable. Gated on urlPage===channels: off the manager (or under a channel
// page) the keys are cleared
// (localStorage still holds the state; it re-mirrors on return). The name `search` stays OUT — it's a
// deliberately transient rail-search, like the feed's and plex's.
useUrlMirror(channelsParamUpdates(urlPage(nav) === "channels" ? { view, sort, filters } : null));
return (
<ChannelsActionsContext.Provider value={actions}>
<ChannelLayoutContext.Provider value={layout}>
<ChannelsStateContext.Provider value={state}>{children}</ChannelsStateContext.Provider>
</ChannelLayoutContext.Provider>
</ChannelsActionsContext.Provider>
);
}