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} />