Files
siftlode/frontend/src/components/ChannelsProvider.tsx
T
peter 5bfcf5d83a refactor(e10): move the platform components into components/ui/ and enforce the boundary
The app HAS a platform layer -- AnchoredPopover (22 importers, with
arrows/typeahead/Escape/anchor-flip), QueryState (21), Modal (17),
PageShell, DataTable, Tabs, Tooltip and ~26 more -- but it sat flat among
~160 feature components with nothing marking it as one. Several of those
files describe themselves as 'the one place' or 'canonical' and were
still bypassed by hand-rolled markup, because a layer nobody can see is a
layer nobody uses; three separate inventory passes of this codebase
misread it the same way.

Moves the 33 domain-free modules into components/ui/, rewrites the
imports in 89 files, and makes the boundary a lint rule rather than a
convention: inside ui/ a one-level-up import (a feature component) and
any lib/api slice are errors. The rule was verified to fire on both and
to leave generic lib/ helpers alone. README.md states which side of the
line a component belongs on and why.

No behaviour change: 3x tsc, eslint, knip, prettier and 687 vitest tests
all green; no backend file touched.
2026-09-05 16:58:29 +02:00

324 lines
16 KiB
TypeScript

import {
createContext,
useCallback,
useContext,
useEffect,
useLayoutEffect,
useMemo,
useRef,
useState,
type ReactNode,
} from "react";
import { getAccountRaw, setAccountRaw, LS } from "../lib/storage";
import { parseChannelLayout, type ChannelLayout } from "../lib/channelLayout";
import { hydrateSort, type SortState } from "../lib/columnSort";
import { useSortState } from "../lib/useSortState";
import { useFilterState } from "../lib/useFilterState";
import { useActiveAccountId } from "../lib/activeAccount";
import { useUrlMirror } from "../lib/useUrlMirror";
import {
channelsParamsToState,
channelsParamUpdates,
hasChannelsParams,
type ChannelsUrlState,
} from "../lib/channelsUrlState";
import { usePersistedTab } from "./ui/Tabs";
import type { FilterMap } from "./ui/tableHeader";
import { useNavigation, useNavigationActions } from "./NavigationProvider";
import type { ChannelsView } from "./Channels";
// 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.
function clampView(v: string | null): ChannelsView {
return v === "discovery" || 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() ?? {};
}
// S8: 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 seed = the URL-merged state PLUS which dimensions the URL actually carried. `present` matters
// because the base below is read at MOUNT, before the account is pinned (getAccountRaw returns null),
// so an OMITTED dimension's base is a default, not this account's stored value. The provider seeds and
// persists only the PRESENT dimensions and lets the absent ones re-hydrate from storage normally —
// otherwise a partial shared link silently resets the omitted stored sort / filter map.
type ChannelsSeedPresent = { view: boolean; sort: boolean; filters: boolean };
type ChannelsUrlSeed = { state: ChannelsUrlState; present: ChannelsSeedPresent };
function readChannelsUrlSeed(): ChannelsUrlSeed | null {
const params = new URLSearchParams(window.location.search);
if (!hasChannelsParams(params)) return null;
const state = channelsParamsToState(params, {
view: clampView(getAccountRaw(LS.channelsView)),
sort: hydrateSort(getAccountRaw(LS.channelSort), [], null),
filters: readStoredFilters(),
});
return {
state,
present: { view: params.has("ctab"), sort: params.has("csort"), filters: params.has("cf") },
};
}
type ChannelsState = {
search: string;
// The subscribed table's column filters, lifted out of DataTable so the top status chips, the Sync
// column funnel, and the card layout share ONE filter state (the sync chip lives at `filters.sync`).
filters: FilterMap;
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;
setFilters: (f: FilterMap) => 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<ChannelsState>({
search: "",
filters: {},
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<ChannelLayout>("table");
const ActionsContext = createContext<ChannelsActions>({
setSearch: () => {},
setFilters: () => {},
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("");
// 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);
// view + layout are persisted tabs (usePersistedTab = the shared account-scoped tab-state hook), so
// they get init-from-storage AND the account-pin re-hydrate for free (this provider mounts above
// <App>, which pins the account only after /api/me). Stored un-clamped; clampView/parseChannelLayout
// heal them for display. setView layers the history logic (below) on top of the raw setter.
const [viewRaw, setViewRaw] = usePersistedTab(LS.channelsView, "subscribed");
const view = clampView(viewRaw);
const [layoutRaw, setLayout] = usePersistedTab(LS.channelLayout, "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 [focusName, setFocusName] = useState<string | null>(null);
const [focusToken, setFocusToken] = useState(0);
// S8 — shareable manager view. Captured once at mount (or null when no manager URL keys are present).
const [urlSeed] = useState(readChannelsUrlSeed);
const accountId = useActiveAccountId();
const seededRef = useRef(false);
const persistedRef = useRef(false);
// Apply the shared-link seed once, BEFORE paint, through the slice setters. Going through the setters
// (not raw state) is deliberate: each one calls its hook's markDirty, so the account-pin re-hydrate
// (which fires later on a fresh tab) sees the slice as user-edited and won't reload the stored view
// over the shared one. setViewRaw (not setView) restores the tab without pushing a phantom history
// entry. A normal load has urlSeed === null and this is a no-op.
useLayoutEffect(() => {
if (!urlSeed || seededRef.current) return;
seededRef.current = true;
// Only apply the dimensions the URL actually carried. An omitted dimension must NOT go through its
// setter (which would markDirty and block the account-pin re-hydrate from loading the stored value)
// — leave it to hydrate from storage normally. Fixes a partial link wiping the stored sort/filters.
const { state, present } = urlSeed;
if (present.view && state.view !== view) setViewRaw(state.view);
if (present.filters) setFilters(state.filters);
if (present.sort) setSort(state.sort);
// eslint-disable-next-line react-hooks/exhaustive-deps
}, []);
// On a fresh tab the account pins AFTER mount, so the seed's setter writes above no-op'd (no account
// yet). Persist the seed to the account once it's known, so the shared view also survives a later
// PLAIN reload (no URL) — mirrors PlexProvider's rehydrate-persist. The slice hooks' own re-hydrate
// already skips (marked dirty above), so this only touches storage, not state.
useEffect(() => {
if (!urlSeed || accountId == null || persistedRef.current) return;
persistedRef.current = true;
// Persist ONLY the URL-present dimensions — the omitted ones were seeded from mount-time defaults
// (no account yet), so writing them here would overwrite the account's real stored sort/filter map.
const { state, present } = urlSeed;
if (present.view) setAccountRaw(LS.channelsView, state.view);
if (present.filters) setAccountRaw(LS.channelsFilterMap, JSON.stringify(state.filters));
if (present.sort) setAccountRaw(LS.channelSort, JSON.stringify(state.sort));
}, [urlSeed, accountId]);
// 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), []);
// 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 + guards the account-pin re-hydrate (via usePersistedTab)
// 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 }, "");
}
},
[setViewRaw],
);
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]);
// 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
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,
goToFullHistory,
}),
[setSearch, setFilters, setView, setLayout, setSort, focusChannel, goToFullHistory],
);
// S8: mirror the content state (tab + sort + column filters) into the address bar under the namespaced
// keys so a manager view is shareable. Gated on page===channels: off the manager 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(page === "channels" ? { view, sort, filters } : null));
return (
<ActionsContext.Provider value={actions}>
<LayoutContext.Provider value={layout}>
<StateContext.Provider value={state}>{children}</StateContext.Provider>
</LayoutContext.Provider>
</ActionsContext.Provider>
);
}