Retire the hand-rolled channel-manager view pills for the shared <Tabs> component (given an optional className so it embeds in the justify-between header without the default mb-4), and back view + layout on usePersistedTab instead of manual getAccountRaw/setAccountRaw useState. That subsumes the account-pin re-hydrate (usePersistedTab now re-reads on the pin), so ChannelsProvider's bespoke markManagerDirty/useAccountRehydrate is gone; setView keeps the history-tab logic (_chTab pushState + popstate) layered on the stable raw setter, and useAccountPersistedState's setter is now memoized so that stable action holds. Per the user's call, unify every channel-manager pill on the rounded-lg idiom: the view tabs via <Tabs>, the sync chips and tag chips + the "manage tags" affordance from rounded-full to rounded-lg — no more odd-one-out rounded-full. gate-green: tsc + vitest 120 + eslint-0 + knip + prettier. Self-E2E'd: tab switch, browser Back across tabs (Blocked->Hidden->Subscriptions with localStorage in sync), and a fresh tab restoring view=hidden on the FIRST load; zero console errors.
229 lines
10 KiB
TypeScript
229 lines
10 KiB
TypeScript
import {
|
|
createContext,
|
|
useCallback,
|
|
useContext,
|
|
useEffect,
|
|
useMemo,
|
|
useRef,
|
|
useState,
|
|
type ReactNode,
|
|
} from "react";
|
|
import { getAccountRaw, LS } from "../lib/storage";
|
|
import { parseChannelLayout, type ChannelLayout } from "../lib/channelLayout";
|
|
import { type SortState } from "../lib/columnSort";
|
|
import { useSortState } from "../lib/useSortState";
|
|
import { useFilterState } from "../lib/useFilterState";
|
|
import { usePersistedTab } from "./Tabs";
|
|
import type { FilterMap } from "./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;
|
|
}
|
|
|
|
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);
|
|
|
|
// 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],
|
|
);
|
|
|
|
return (
|
|
<ActionsContext.Provider value={actions}>
|
|
<LayoutContext.Provider value={layout}>
|
|
<StateContext.Provider value={state}>{children}</StateContext.Provider>
|
|
</LayoutContext.Provider>
|
|
</ActionsContext.Provider>
|
|
);
|
|
}
|