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:
2026-07-19 00:08:34 +02:00
parent 6c32e5c8d6
commit a303f307cd
7 changed files with 199 additions and 134 deletions
+4 -77
View File
@@ -32,13 +32,11 @@ import {
LS,
readAccount,
setAccountRaw,
useAccountPersistedState,
writeAccount,
} from "./lib/storage";
import type { PrefsController } from "./components/SettingsPanel";
import { useNavigation, useNavigationActions } from "./components/NavigationProvider";
import { useFeedFilters, useFeedFiltersActions } from "./components/FeedFiltersProvider";
import type { ChannelStatusFilter, ChannelsView } from "./components/Channels";
// Persistent shell — always mounted, so eagerly imported.
import Welcome from "./components/Welcome";
import SetupWizard from "./components/SetupWizard";
@@ -95,7 +93,7 @@ type EditablePrefs = {
export default function App() {
const { t, i18n } = useTranslation();
const { page, channelView, ytSearch } = useNavigation();
const { setPage, openChannel, enterYtSearch, exitYtSearch } = useNavigationActions();
const { setPage, enterYtSearch, exitYtSearch } = useNavigationActions();
const [theme, setThemeState] = useState<ThemePrefs>(() => loadLocalTheme());
// The global feed filters live in FeedFiltersProvider (per-account persistence + the account-load
// effect + share-link capture all moved there). App reads them to hand to <Feed> and to the
@@ -129,49 +127,10 @@ export default function App() {
plex: loadLayout("plex"),
playlists: loadLayout("playlists"),
}));
// Client-side name filters for the Channels + Playlists lists — lifted here so the shared top
// SearchBar can drive them (the modules read/seed them via props).
const [channelsSearch, setChannelsSearch] = useState("");
const [wizardOpen, setWizardOpen] = useState(false);
// Channel status chip, persisted so a reload (F5) keeps it; clamp a stale value at render.
const [channelFilterRaw, setChannelFilter] = useAccountPersistedState(LS.channelFilter, "all");
const channelFilter: ChannelStatusFilter = (
["needs_full", "fully_synced", "hidden", "all"] as const
).includes(channelFilterRaw as ChannelStatusFilter)
? (channelFilterRaw as ChannelStatusFilter)
: "all";
const [aboutOpen, setAboutOpen] = useState(false);
const [notesOpen, setNotesOpen] = useState(false);
const [notesHighlight, setNotesHighlight] = useState<string | undefined>(undefined);
// The Channel manager's active tab (subscriptions vs playlist discovery). Lifted here and
// persisted so navigation intents that target the subscriptions table — the header's
// "without full history" link, focus-channel — can force it back to "subscribed" instead
// of dumping the user on whatever tab they last left open.
const [channelsViewRaw, setChannelsView] = useAccountPersistedState(LS.channelsView, "subscribed");
const channelsView: ChannelsView =
channelsViewRaw === "discovery"
? "discovery"
: channelsViewRaw === "blocked"
? "blocked"
: "subscribed";
// Bumped to tell the channel manager to drop a stale column filter when we send the user
// there to see a specific set (the header's "without full history" link).
const [channelsFilterReset, setChannelsFilterReset] = useState(0);
// "Focus this channel in the manager": jump to the Channels page and seed its name filter
// so the channel is isolated. Cleared when leaving the page so it doesn't re-apply later.
const [focusChannelName, setFocusChannelName] = useState<string | null>(null);
const [focusChannelToken, setFocusChannelToken] = useState(0);
const focusChannel = (name: string) => {
setFocusChannelName(name);
setFocusChannelToken((n) => n + 1); // re-seed even when the same channel is re-focused
setChannelsView("subscribed"); // the name filter applies to the subscriptions table
setPage("channels");
};
useEffect(() => {
// Clear the channel-manager focus when leaving the Channels page so it doesn't re-apply later.
// (Leaving the feed clearing the live search is now the NavigationProvider's job.)
if (page !== "channels") setFocusChannelName(null);
}, [page]);
function openReleaseNotes(highlight?: string) {
setNotesHighlight(highlight);
@@ -546,7 +505,6 @@ export default function App() {
<Sidebar
layout={panelLayouts.feed}
setLayout={(l) => setPanelLayout("feed", l)}
onFocusChannel={focusChannel}
isDemo={meQuery.data!.is_demo}
collapsed={filterCollapsed}
onToggleCollapse={() => setFilterCollapsed(!filterCollapsed)}
@@ -607,18 +565,7 @@ export default function App() {
// fade would sit on it and flip on with the first scroll, so turn it off there.
fadeTop={page !== "channels" && page !== "audit"}
header={
<Header
me={meQuery.data!}
channelsSearch={channelsSearch}
setChannelsSearch={setChannelsSearch}
onYtSearch={enterYtSearch}
onGoToFullHistory={() => {
setChannelFilter("needs_full");
setChannelsView("subscribed"); // the status filter applies to the subscriptions tab
setChannelsFilterReset((n) => n + 1); // drop any stale column filter hiding the rows
setPage("channels");
}}
/>
<Header me={meQuery.data!} onYtSearch={enterYtSearch} />
}
banners={
<>
@@ -629,27 +576,7 @@ export default function App() {
>
<Suspense fallback={pageFallback}>
{page === "channels" ? (
<Channels
canWrite={meQuery.data!.can_write}
isAdmin={meQuery.data!.role === "admin"}
search={channelsSearch}
setSearch={setChannelsSearch}
focusChannelName={focusChannelName}
focusChannelToken={focusChannelToken}
onFocusChannel={focusChannel}
statusFilter={channelFilter}
setStatusFilter={setChannelFilter}
view={channelsView}
setView={setChannelsView}
filtersResetToken={channelsFilterReset}
onOpenWizard={() => setWizardOpen(true)}
onViewChannel={(id, name) => openChannel(id, name)}
onFilterByTag={(tagId, name) => {
setFilters({ ...filters, tags: [tagId], channelIds: [] });
setPage("feed");
notify({ level: "info", message: t("channels.notify.filteredByTag", { name }) });
}}
/>
<Channels me={meQuery.data!} onOpenWizard={() => setWizardOpen(true)} />
) : page === "stats" ? (
<Stats me={meQuery.data!} />
) : page === "scheduler" && meQuery.data!.role === "admin" ? (
@@ -663,7 +590,7 @@ export default function App() {
) : page === "playlists" ? (
<Playlists me={meQuery.data!} />
) : page === "notifications" ? (
<NotificationsPanel onFocusChannel={focusChannel} />
<NotificationsPanel />
) : page === "messages" && !meQuery.data!.is_demo ? (
<div className="p-4 max-w-3xl w-full mx-auto">
<Messages meId={meQuery.data!.id} />
+30 -40
View File
@@ -15,7 +15,7 @@ import {
UserMinus,
X,
} from "lucide-react";
import { api, type ManagedChannel, type Tag } from "../lib/api";
import { api, type ManagedChannel, type Me, type Tag } from "../lib/api";
import { notifyYouTubeActionError } from "../lib/youtubeErrors";
import { accountKey, LS } from "../lib/storage";
import { useDismiss } from "../lib/useDismiss";
@@ -30,6 +30,9 @@ import ChannelLink from "./ChannelLink";
import ChannelDiscovery from "./ChannelDiscovery";
import TagManager from "./TagManager";
import { useConfirm } from "./ConfirmProvider";
import { useChannels, useChannelsActions } from "./ChannelsProvider";
import { useNavigationActions } from "./NavigationProvider";
import { useFeedFilters, useFeedFiltersActions } from "./FeedFiltersProvider";
export type ChannelsView = "subscribed" | "discovery" | "blocked";
@@ -42,49 +45,36 @@ const STATUS_FILTERS: { id: ChannelStatusFilter; labelKey: string }[] = [
{ id: "hidden", labelKey: "channels.filters.hidden" },
];
export default function Channels({
canWrite,
isAdmin,
search,
setSearch,
onViewChannel,
onFilterByTag,
onFocusChannel,
focusChannelName,
focusChannelToken,
statusFilter,
setStatusFilter,
view,
setView,
filtersResetToken,
onOpenWizard,
}: {
canWrite: boolean;
isAdmin: boolean;
// The channel-name filter, lifted to App so the shared top SearchBar drives it.
search: string;
setSearch: (q: string) => void;
onViewChannel: (id: string, name: string) => void;
onFilterByTag: (tagId: number, name: string) => void;
onFocusChannel: (name: string) => void;
focusChannelName: string | null;
// Bumped every time a focus-channel intent is (re-)issued, so re-clicking the same channel
// re-seeds the search box even when the name is unchanged.
focusChannelToken: number;
statusFilter: ChannelStatusFilter;
setStatusFilter: (f: ChannelStatusFilter) => void;
// The active tab lives in App so navigation intents that target the subscriptions table
// (the header's "without full history" link, focus-channel) can force it back here.
view: ChannelsView;
setView: (v: ChannelsView) => void;
// Bumped by the header's "without full history" intent to clear a stale column filter.
filtersResetToken: number;
onOpenWizard: () => void;
}) {
export default function Channels({ me, onOpenWizard }: { me: Me; onOpenWizard: () => void }) {
const canWrite = me.can_write;
const isAdmin = me.role === "admin";
// The manager's name filter, status chip, active tab, reset token, and focus-channel intent live
// in ChannelsProvider (shared with the header search box + the cross-module "focus this channel"
// callers); this page is their primary editor.
const {
search,
statusFilter,
view,
filtersResetToken,
focusName: focusChannelName,
focusToken: focusChannelToken,
} = useChannels();
const { setSearch, setStatusFilter, setView, focusChannel: onFocusChannel } = useChannelsActions();
const { openChannel: onViewChannel, setPage } = useNavigationActions();
const feedFilters = useFeedFilters();
const { setFilters: setFeedFilters } = useFeedFiltersActions();
const { t } = useTranslation();
const qc = useQueryClient();
const confirm = useConfirm();
// Clicking a tag chip filters the feed by that tag and jumps there. Internalised (was an App
// callback) now that Channels reads the feed filters + navigation straight from their providers.
const onFilterByTag = (tagId: number, name: string) => {
setFeedFilters({ ...feedFilters, tags: [tagId], channelIds: [] });
setPage("feed");
notify({ level: "info", message: t("channels.notify.filteredByTag", { name }) });
};
const channelsQuery = useQuery({ queryKey: ["channels"], queryFn: api.channels });
const tagsQuery = useQuery({ queryKey: ["tags"], queryFn: api.tags });
const statusQuery = useQuery({ queryKey: ["my-status"], queryFn: api.myStatus });
@@ -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>
);
}
+6 -9
View File
@@ -10,37 +10,34 @@ import { useNavigation, useNavigationActions } from "./NavigationProvider";
import { useFeedFilters, useFeedFiltersActions } from "./FeedFiltersProvider";
import { usePlaylists, usePlaylistsActions } from "./PlaylistsProvider";
import { usePlex, usePlexActions } from "./PlexProvider";
import { useChannels, useChannelsActions } from "./ChannelsProvider";
// The floating top header: a row of glass pills over the content — a ModuleName pill (label +
// accent dot + back/forward arrows), the module's SearchBar where it has one, and the per-user
// sync chip at the right end.
export default function Header({
me,
channelsSearch,
setChannelsSearch,
onYtSearch,
onGoToFullHistory,
}: {
me: Me;
channelsSearch: string;
setChannelsSearch: (q: string) => void;
// Trigger a live YouTube search for the current term (feed only; hidden for the demo account,
// which can't spend the shared quota).
onYtSearch: (q: string) => void;
// Jump to the channel manager filtered to channels still missing full history.
onGoToFullHistory: () => void;
}) {
const { t } = useTranslation();
const { page } = useNavigation();
const { setPage } = useNavigationActions();
const filters = useFeedFilters();
const { setFilters } = useFeedFiltersActions();
// The playlists name filter lives in PlaylistsProvider (shared with its rail + detail pane); the
// Plex search term lives in PlexProvider (shared with its rail + browse grid).
// Each module's search term lives in its own provider (shared with that module's rail/content):
// playlists name filter, Plex search term, channels name filter. The channel sync chip's
// "go to full history" jump is a ChannelsProvider action.
const { search: playlistsSearch } = usePlaylists();
const { setSearch: setPlaylistsSearch } = usePlaylistsActions();
const { q: plexQ } = usePlex();
const { setQ: setPlexQ } = usePlexActions();
const { search: channelsSearch } = useChannels();
const { setSearch: setChannelsSearch, goToFullHistory: onGoToFullHistory } = useChannelsActions();
// The ◀/▶ arrows step cyclically through the user's available modules (derived from `me`, so
// it stays correct as modules are added/gated). Labels of every active module feed ModuleName's
// dynamic width so the pill is sized to the widest reachable title in the current language.
@@ -23,6 +23,7 @@ import { channelYouTubeUrl, relativeFromMs, relativeTime } from "../lib/format";
import { LEVEL_STYLE } from "./Toaster";
import { PageToolbar } from "./PageShell";
import { useNavigationActions } from "./NavigationProvider";
import { useChannelsActions } from "./ChannelsProvider";
import { useFeedFilters, useFeedFiltersActions } from "./FeedFiltersProvider";
// The single notification center. "System" = durable, server-backed notifications (inbox
@@ -31,15 +32,14 @@ import { useFeedFilters, useFeedFiltersActions } from "./FeedFiltersProvider";
// there's one indicator and one place to look. Transient toasts still pop via the Toaster.
const POLL_MS = 8000;
export default function NotificationsPanel({
onFocusChannel,
}: {
onFocusChannel: (name: string) => void;
}) {
export default function NotificationsPanel() {
const { t } = useTranslation();
const { setPage } = useNavigationActions();
const filters = useFeedFilters();
const { setFilters } = useFeedFiltersActions();
// "Focus this channel in the manager" (from a new-subscription notification) lives in
// ChannelsProvider — read it here and pass it down to the notification rows.
const { focusChannel: onFocusChannel } = useChannelsActions();
const qc = useQueryClient();
const q = useLiveQuery<{ items: AppNotification[]; total: number }>(
["notifications"],
+3 -2
View File
@@ -14,6 +14,7 @@ import SidePanel from "./SidePanel";
import PanelGroups, { type PanelItem } from "./PanelGroups";
import ChannelPicker from "./ChannelPicker";
import { useFeedFilters, useFeedFiltersActions } from "./FeedFiltersProvider";
import { useChannelsActions } from "./ChannelsProvider";
// Filter ids; display labels resolved at render time via t("sidebar.sort.<id>") etc.
const DATE_PRESETS: { days: number; key: string }[] = [
@@ -63,14 +64,12 @@ function TagChip({
export default function Sidebar({
layout,
setLayout,
onFocusChannel,
isDemo = false,
collapsed,
onToggleCollapse,
}: {
layout: PanelLayout;
setLayout: (l: PanelLayout) => void;
onFocusChannel: (name: string) => void;
isDemo?: boolean;
collapsed: boolean;
onToggleCollapse: () => void;
@@ -78,6 +77,8 @@ export default function Sidebar({
const { t } = useTranslation();
const filters = useFeedFilters();
const { setFilters } = useFeedFiltersActions();
// "Focus this channel in the manager" (from the tag manager here) lives in ChannelsProvider.
const { focusChannel: onFocusChannel } = useChannelsActions();
const tagsQuery = useQuery({ queryKey: ["tags"], queryFn: api.tags });
const tags = tagsQuery.data ?? [];
+4 -1
View File
@@ -7,6 +7,7 @@ import { NavigationProvider } from "./components/NavigationProvider";
import { FeedFiltersProvider } from "./components/FeedFiltersProvider";
import { PlaylistsProvider } from "./components/PlaylistsProvider";
import { PlexProvider } from "./components/PlexProvider";
import { ChannelsProvider } from "./components/ChannelsProvider";
import "./i18n";
import "./index.css";
@@ -43,7 +44,9 @@ const root =
<FeedFiltersProvider>
<PlaylistsProvider>
<PlexProvider>
<App />
<ChannelsProvider>
<App />
</ChannelsProvider>
</PlexProvider>
</PlaylistsProvider>
</FeedFiltersProvider>