import { createContext, useCallback, useContext, useEffect, useMemo, useRef, useState, type ReactNode, } from "react"; import { getAccountRaw, LS, readAccount, setAccountRaw, writeAccount } from "../lib/storage"; import { parseChannelLayout, type ChannelLayout } from "../lib/channelLayout"; import { parseSortState, type SortState } from "../lib/columnSort"; import { useNavigation, useNavigationActions } from "./NavigationProvider"; import type { ChannelStatusFilter, ChannelsView } from "./Channels"; // Owns the Channels module's shared local state — the rail-less manager's name filter, status chip, // 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). status / 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. const STATUS_FILTERS: readonly ChannelStatusFilter[] = [ "needs_full", "fully_synced", "hidden", "all", ]; function clampStatus(v: string | null): ChannelStatusFilter { return STATUS_FILTERS.includes(v as ChannelStatusFilter) ? (v as ChannelStatusFilter) : "all"; } function clampView(v: string | null): ChannelsView { return v === "discovery" ? "discovery" : v === "blocked" ? "blocked" : "subscribed"; } type ChannelsState = { search: string; statusFilter: ChannelStatusFilter; view: ChannelsView; // The subscribed table's sort, lifted out of DataTable so the card layout shares it. sort: SortState; // Bumped to tell the manager to drop a stale column filter when an intent sends the user to a // specific set (the header's "without full history" link). filtersResetToken: number; focusName: string | null; // Bumped every time a focus intent is (re-)issued, so re-focusing the same channel re-seeds the // manager's name filter even when the name is unchanged. focusToken: number; }; interface ChannelsActions { setSearch: (q: string) => void; setStatusFilter: (f: ChannelStatusFilter) => void; setView: (v: ChannelsView) => void; setLayout: (l: ChannelLayout) => void; setSort: (s: SortState) => void; // "Focus this channel in the manager": jump to Channels (subscriptions tab) and seed its name // filter so the channel is isolated. Called from the feed sidebar and the notifications inbox. focusChannel: (name: string) => void; // Jump to the manager filtered to channels still missing full history (the header sync chip). goToFullHistory: () => void; } const StateContext = createContext({ search: "", statusFilter: "all", view: "subscribed", sort: null, filtersResetToken: 0, focusName: null, focusToken: 0, }); // The table/cards density gets its OWN context, deliberately narrow: App reads it (to decide the // shell's top fade) and would otherwise re-render the whole app on every keystroke in the manager's // search box, since the state context above changes with it. const LayoutContext = createContext("table"); const ActionsContext = createContext({ setSearch: () => {}, setStatusFilter: () => {}, setView: () => {}, setLayout: () => {}, setSort: () => {}, focusChannel: () => {}, goToFullHistory: () => {}, }); export function useChannels(): ChannelsState { return useContext(StateContext); } /** The manager's table/cards density on its own — subscribe to this instead of the whole state when * the layout is all you need (App does, for the shell's top fade). */ export function useChannelLayout(): ChannelLayout { return useContext(LayoutContext); } export function useChannelsActions(): ChannelsActions { return useContext(ActionsContext); } export function ChannelsProvider({ children }: { children: ReactNode }) { const { page } = useNavigation(); const { setPage } = useNavigationActions(); const [search, setSearchState] = useState(""); const [statusFilter, setStatusFilterState] = useState(() => clampStatus(getAccountRaw(LS.channelFilter)), ); const [view, setViewState] = useState(() => clampView(getAccountRaw(LS.channelsView)), ); const [layout, setLayoutState] = useState(() => parseChannelLayout(getAccountRaw(LS.channelLayout)), ); const [sort, setSortState] = useState(() => parseSortState(readAccount(LS.channelSort, null)), ); const [filtersResetToken, setFiltersResetToken] = useState(0); const [focusName, setFocusName] = useState(null); const [focusToken, setFocusToken] = useState(0); // 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). // Written during render — the sanctioned pattern NavigationProvider uses for `pageRef`. const viewRef = useRef(view); viewRef.current = view; const setSearch = useCallback((q: string) => setSearchState(q), []); const setStatusFilter = useCallback((f: ChannelStatusFilter) => { setStatusFilterState(f); setAccountRaw(LS.channelFilter, f); }, []); // 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; setViewState(v); setAccountRaw(LS.channelsView, v); // 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. window.history.pushState({ sfPage: "channels", _chTab: v }, ""); } }, []); const setLayout = useCallback((l: ChannelLayout) => { setLayoutState(l); setAccountRaw(LS.channelLayout, l); }, []); const setSort = useCallback((s: SortState) => { setSortState(s); writeAccount(LS.channelSort, s); }, []); 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(() => { setStatusFilter("needs_full"); setView("subscribed"); // the status filter applies to the subscriptions tab setFiltersResetToken((n) => n + 1); // drop any stale column filter hiding the rows setPage("channels"); }, [setStatusFilter, setView, setPage]); // Clear the manager focus when leaving the Channels page so it doesn't re-apply on a later visit. useEffect(() => { if (page !== "channels") setFocusName(null); }, [page]); // 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 const next = clampView(tab as string); setViewState(next); setAccountRaw(LS.channelsView, next); } window.addEventListener("popstate", onPop); return () => window.removeEventListener("popstate", onPop); }, []); const state = useMemo( () => ({ search, statusFilter, view, sort, filtersResetToken, focusName, focusToken }), [search, statusFilter, view, sort, filtersResetToken, focusName, focusToken], ); const actions = useMemo( () => ({ setSearch, setStatusFilter, setView, setLayout, setSort, focusChannel, goToFullHistory }), [setSearch, setStatusFilter, setView, setLayout, setSort, focusChannel, goToFullHistory], ); return ( {children} ); }