refactor(channels): scope module state in ChannelsProvider
Lift the Channels manager's shared local state (name filter, status chip,
active tab, column-reset token, focus-a-channel intent) out of App into a
split-context ChannelsProvider, nested inside NavigationProvider so its
focusChannel/goToFullHistory actions can drive navigation. The Channels page,
the header search box + sync chip, the feed filter sidebar, and the
notifications inbox all read it via useChannels()/useChannelsActions() instead
of props drilled through App. Channels also internalises onViewChannel /
onFilterByTag / onFocusChannel (nav + feed-filters straight from providers) and
takes only { me, onOpenWizard } now. App loses five useStates, the focusChannel
helper, the focus-clear effect, and a large block of per-page prop plumbing.
(Platform S6b.2)
This commit is contained in:
@@ -0,0 +1,147 @@
|
||||
import {
|
||||
createContext,
|
||||
useCallback,
|
||||
useContext,
|
||||
useEffect,
|
||||
useMemo,
|
||||
useState,
|
||||
type ReactNode,
|
||||
} from "react";
|
||||
import { getAccountRaw, LS, setAccountRaw } from "../lib/storage";
|
||||
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 <App/>).
|
||||
//
|
||||
// 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;
|
||||
// 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;
|
||||
// "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: "",
|
||||
statusFilter: "all",
|
||||
view: "subscribed",
|
||||
filtersResetToken: 0,
|
||||
focusName: null,
|
||||
focusToken: 0,
|
||||
});
|
||||
const ActionsContext = createContext<ChannelsActions>({
|
||||
setSearch: () => {},
|
||||
setStatusFilter: () => {},
|
||||
setView: () => {},
|
||||
focusChannel: () => {},
|
||||
goToFullHistory: () => {},
|
||||
});
|
||||
|
||||
export function useChannels(): ChannelsState {
|
||||
return useContext(StateContext);
|
||||
}
|
||||
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<ChannelStatusFilter>(() =>
|
||||
clampStatus(getAccountRaw(LS.channelFilter)),
|
||||
);
|
||||
const [view, setViewState] = useState<ChannelsView>(() =>
|
||||
clampView(getAccountRaw(LS.channelsView)),
|
||||
);
|
||||
const [filtersResetToken, setFiltersResetToken] = useState(0);
|
||||
const [focusName, setFocusName] = useState<string | null>(null);
|
||||
const [focusToken, setFocusToken] = useState(0);
|
||||
|
||||
const setSearch = useCallback((q: string) => setSearchState(q), []);
|
||||
const setStatusFilter = useCallback((f: ChannelStatusFilter) => {
|
||||
setStatusFilterState(f);
|
||||
setAccountRaw(LS.channelFilter, f);
|
||||
}, []);
|
||||
const setView = useCallback((v: ChannelsView) => {
|
||||
setViewState(v);
|
||||
setAccountRaw(LS.channelsView, v);
|
||||
}, []);
|
||||
|
||||
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]);
|
||||
|
||||
const state = useMemo<ChannelsState>(
|
||||
() => ({ search, statusFilter, view, filtersResetToken, focusName, focusToken }),
|
||||
[search, statusFilter, view, filtersResetToken, focusName, focusToken],
|
||||
);
|
||||
const actions = useMemo<ChannelsActions>(
|
||||
() => ({ setSearch, setStatusFilter, setView, focusChannel, goToFullHistory }),
|
||||
[setSearch, setStatusFilter, setView, focusChannel, goToFullHistory],
|
||||
);
|
||||
|
||||
return (
|
||||
<ActionsContext.Provider value={actions}>
|
||||
<StateContext.Provider value={state}>{children}</StateContext.Provider>
|
||||
</ActionsContext.Provider>
|
||||
);
|
||||
}
|
||||
Reference in New Issue
Block a user