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 ). // // 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("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(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( () => ({ search, filters, view, sort, filtersResetToken, focusName, focusToken }), [search, filters, view, sort, filtersResetToken, focusName, focusToken], ); const actions = useMemo( () => ({ 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 ( {children} ); }