feat(filters): scope global feed filters in FeedFiltersProvider
Lift the feed's filter god-state (per-account persistence, the account-load effect, share-link capture) out of App into a split-context provider mirroring NavigationProvider. Sidebar/Header/NotificationsPanel read it via hooks instead of prop-drilling; <Feed> keeps its filters prop since ChannelPage renders it with its own channel-scoped state.
This commit is contained in:
+8
-91
@@ -7,7 +7,6 @@ import {
|
||||
getActiveAccount,
|
||||
setActiveAccount,
|
||||
setUnauthorizedHandler,
|
||||
type FeedFilters,
|
||||
type PlexFilters,
|
||||
} from "./lib/api";
|
||||
import { setLanguage, isSupported, type LangCode } from "./i18n";
|
||||
@@ -18,7 +17,7 @@ import {
|
||||
saveLocalTheme,
|
||||
type ThemePrefs,
|
||||
} from "./lib/theme";
|
||||
import { hasFilterParams, paramsToFilters, stripUrlParams } from "./lib/urlState";
|
||||
import { hasFilterParams, stripUrlParams } from "./lib/urlState";
|
||||
import {
|
||||
loadLayout,
|
||||
normalizeLayout,
|
||||
@@ -31,19 +30,16 @@ import { configureNotifications, getNotifSettings, notify, removeByMetaKind, typ
|
||||
import { hintsEnabled, setHintsEnabled } from "./lib/hints";
|
||||
import { parseFeedView, type FeedView } from "./lib/feedView";
|
||||
import {
|
||||
accountKey,
|
||||
getAccountRaw,
|
||||
LS,
|
||||
readAccount,
|
||||
readJSON,
|
||||
readMerged,
|
||||
setAccountRaw,
|
||||
useAccountPersistedState,
|
||||
writeAccount,
|
||||
writeJSON,
|
||||
} 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";
|
||||
@@ -86,20 +82,6 @@ const OnboardingWizard = lazy(() => import("./components/OnboardingWizard"));
|
||||
const About = lazy(() => import("./components/About"));
|
||||
const ReleaseNotes = lazy(() => import("./components/ReleaseNotes"));
|
||||
|
||||
const DEFAULT_FILTERS: FeedFilters = {
|
||||
tags: [],
|
||||
tagMode: "or",
|
||||
channelIds: [],
|
||||
q: "",
|
||||
sort: "newest",
|
||||
scope: "my",
|
||||
includeNormal: true,
|
||||
includeShorts: false,
|
||||
includeLive: false,
|
||||
show: "unwatched",
|
||||
};
|
||||
|
||||
const FILTERS_KEY = LS.filters;
|
||||
const PERF_KEY = LS.perfMode;
|
||||
|
||||
// The preferences edited on the Settings page. They apply locally for instant preview but
|
||||
@@ -112,50 +94,16 @@ type EditablePrefs = {
|
||||
notifications: NotifSettings;
|
||||
};
|
||||
|
||||
// Page is navigation state, not a filter, but it's also kept out of the address bar — so
|
||||
// persist it to localStorage to survive a reload. A share link's ?page= still wins.
|
||||
// This account's own persisted filters (per-account keys — never another account's). A chosen
|
||||
// default saved view wins over the last-session filters (SavedViewsWidget mirrors it here). When
|
||||
// the account isn't known yet (first load, before the tab is pinned), start from defaults; the
|
||||
// post-login effect loads the real ones once the id arrives.
|
||||
function loadAccountFilters(id: number | null): FeedFilters {
|
||||
// Your last-applied filters win, so a reload keeps whatever view you're on (a saved view you
|
||||
// picked, or manual tweaks) — setFilters persists them under LS.filters on every change. The
|
||||
// starred default view only SEEDS a fresh account that has never stored filters: it drives the
|
||||
// very first load, after which that state persists like any other. (Re-apply the default view
|
||||
// from the sidebar to return to it.)
|
||||
const fKey = accountKey(LS.filters, id);
|
||||
let hasStored = false;
|
||||
try {
|
||||
hasStored = !!fKey && localStorage.getItem(fKey) != null;
|
||||
} catch {
|
||||
/* localStorage may be unavailable */
|
||||
}
|
||||
if (hasStored && fKey) return readMerged(fKey, DEFAULT_FILTERS);
|
||||
const dvKey = accountKey(LS.defaultViewFilters, id);
|
||||
const def = dvKey ? readJSON<FeedFilters | null>(dvKey, null) : null;
|
||||
if (def) return { ...DEFAULT_FILTERS, ...def };
|
||||
return { ...DEFAULT_FILTERS };
|
||||
}
|
||||
|
||||
// URL wins over localStorage so a pasted link reproduces the exact view.
|
||||
function loadInitialFilters(): FeedFilters {
|
||||
const params = new URLSearchParams(window.location.search);
|
||||
if (hasFilterParams(params)) return paramsToFilters(params, DEFAULT_FILTERS);
|
||||
return loadAccountFilters(getActiveAccount());
|
||||
}
|
||||
|
||||
export default function App() {
|
||||
const { t, i18n } = useTranslation();
|
||||
const { page, channelView, ytSearch } = useNavigation();
|
||||
const { setPage, openChannel, enterYtSearch, exitYtSearch } = useNavigationActions();
|
||||
const [theme, setThemeState] = useState<ThemePrefs>(() => loadLocalTheme());
|
||||
const [filters, setFiltersState] = useState<FeedFilters>(loadInitialFilters);
|
||||
// Whether this load arrived via a "Share view" link (captured once, before the URL is stripped)
|
||||
// — such filters win over the account's stored view and are persisted to the account instead.
|
||||
const [initialHadUrlFilters] = useState(() =>
|
||||
hasFilterParams(new URLSearchParams(window.location.search))
|
||||
);
|
||||
// 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
|
||||
// handlers that mutate the feed view (onShowInFeed, tag-click, the feed scrollKey).
|
||||
const filters = useFeedFilters();
|
||||
const { setFilters } = useFeedFiltersActions();
|
||||
// The feed's view mode is picked from the feed's own toolbar; like every Settings pref it
|
||||
// persists the moment you choose it.
|
||||
const [view, setViewState] = useState<FeedView>(() => parseFeedView(readAccount(LS.feedView, null)));
|
||||
@@ -257,13 +205,6 @@ export default function App() {
|
||||
setNotesOpen(true);
|
||||
}
|
||||
|
||||
function setFilters(next: FeedFilters) {
|
||||
setFiltersState(next);
|
||||
// Persist under THIS tab's active account so filters don't bleed between accounts.
|
||||
const key = accountKey(FILTERS_KEY, getActiveAccount());
|
||||
if (key) writeJSON(key, next);
|
||||
}
|
||||
|
||||
function setPanelLayout(panel: PanelId, next: PanelLayout) {
|
||||
setPanelLayouts((prev) => ({ ...prev, [panel]: next }));
|
||||
saveLayoutLocal(panel, next);
|
||||
@@ -423,26 +364,6 @@ export default function App() {
|
||||
if (meQuery.data && getActiveAccount() == null) setActiveAccount(meQuery.data.id);
|
||||
}, [meQuery.data?.id]);
|
||||
|
||||
// Load THIS account's own filters when the account first resolves or changes (login / switch /
|
||||
// add). Filters are per-account localStorage state, so another account's last view (incl. its
|
||||
// default saved view) must never carry over. A share link's filters (captured at mount) win and
|
||||
// are persisted to the account instead. Runs on id change only, so a 60s background refetch of
|
||||
// the same account never clobbers the filters you're editing.
|
||||
useEffect(() => {
|
||||
const id = meQuery.data?.id;
|
||||
if (id == null) return;
|
||||
if (initialHadUrlFilters) {
|
||||
const key = accountKey(FILTERS_KEY, id);
|
||||
if (key) writeJSON(key, filters);
|
||||
return;
|
||||
}
|
||||
let f = loadAccountFilters(id);
|
||||
// The shared demo account has no subscriptions, so default it to the whole library.
|
||||
if (meQuery.data?.is_demo) f = { ...f, scope: "all" };
|
||||
setFiltersState(f);
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
}, [meQuery.data?.id]);
|
||||
|
||||
// Once signed in, drop any leftover query string from the pre-login flow (e.g.
|
||||
// ?access=requested, ?verify, ?reset). The logged-in app reads nothing from the URL, so the
|
||||
// address bar stays clean. Gated on auth so the logged-out Welcome page still reads its params.
|
||||
@@ -661,8 +582,6 @@ export default function App() {
|
||||
content column. Sits beside the nav rail as its own collapsible column. */}
|
||||
{page === "feed" && !channelView && (
|
||||
<Sidebar
|
||||
filters={filters}
|
||||
setFilters={setFilters}
|
||||
layout={panelLayouts.feed}
|
||||
setLayout={(l) => setPanelLayout("feed", l)}
|
||||
onFocusChannel={focusChannel}
|
||||
@@ -740,8 +659,6 @@ export default function App() {
|
||||
header={
|
||||
<Header
|
||||
me={meQuery.data!}
|
||||
filters={filters}
|
||||
setFilters={setFilters}
|
||||
plexQ={plexQ}
|
||||
setPlexQ={setPlexQ}
|
||||
channelsSearch={channelsSearch}
|
||||
@@ -804,7 +721,7 @@ export default function App() {
|
||||
setSelectedId={setSelectedPlaylistId}
|
||||
/>
|
||||
) : page === "notifications" ? (
|
||||
<NotificationsPanel filters={filters} setFilters={setFilters} onFocusChannel={focusChannel} />
|
||||
<NotificationsPanel onFocusChannel={focusChannel} />
|
||||
) : page === "messages" && !meQuery.data!.is_demo ? (
|
||||
<div className="p-4 max-w-3xl w-full mx-auto">
|
||||
<Messages meId={meQuery.data!.id} />
|
||||
|
||||
Reference in New Issue
Block a user