diff --git a/frontend/src/components/ChannelsProvider.dom.test.tsx b/frontend/src/components/ChannelsProvider.dom.test.tsx index 5012e794..5ad4a6f0 100644 --- a/frontend/src/components/ChannelsProvider.dom.test.tsx +++ b/frontend/src/components/ChannelsProvider.dom.test.tsx @@ -27,7 +27,10 @@ const VIEW_KEY = "siftlode.channelsView.1"; // account-scoped (accountKey suffix beforeEach(() => { localStorage.clear(); sessionStorage.setItem("siftlode.activeAccount", "1"); // pin the tab's account - window.history.replaceState({ sfPage: "channels" }, ""); // setView only owns an entry on this page + // Reset BOTH the history state and the query string: setView only owns an entry on the channels page, + // and the S8 URL mirror writes ctab/csort/cf — a stale key left by a prior test would be read back by + // this provider's mount-time URL seed and change the starting tab. + window.history.replaceState({ sfPage: "channels" }, "", "/"); }); describe("ChannelsProvider tab history", () => { @@ -71,3 +74,29 @@ describe("ChannelsProvider tab history", () => { expect(result.current.view).toBe("hidden"); // unchanged }); }); + +describe("ChannelsProvider — S8 shareable URL", () => { + it("seeds tab + sort + filters from a shared manager link at mount (URL wins over stored)", () => { + // Stored view says "blocked"; the shared link says "hidden" + a sort + a filter → the URL wins. + localStorage.setItem(VIEW_KEY, "blocked"); + const cf = encodeURIComponent(JSON.stringify({ sync: "needsFull" })); + window.history.replaceState( + { sfPage: "channels" }, + "", + `/?ctab=hidden&csort=priority:desc&cf=${cf}`, + ); + const { result } = render(); + expect(result.current.view).toBe("hidden"); + expect(result.current.sort).toEqual({ key: "priority", dir: "desc" }); + expect(result.current.filters).toEqual({ sync: "needsFull" }); + }); + + it("mirrors the tab into the address bar and clears it back to the default", () => { + const { result } = render(); + act(() => result.current.setView("blocked")); + expect(new URLSearchParams(window.location.search).get("ctab")).toBe("blocked"); + // Back to the default tab clears the key (only non-defaults are emitted). + act(() => result.current.setView("subscribed")); + expect(new URLSearchParams(window.location.search).has("ctab")).toBe(false); + }); +}); diff --git a/frontend/src/components/ChannelsProvider.tsx b/frontend/src/components/ChannelsProvider.tsx index c4d5b26f..35ad9f02 100644 --- a/frontend/src/components/ChannelsProvider.tsx +++ b/frontend/src/components/ChannelsProvider.tsx @@ -3,16 +3,25 @@ import { useCallback, useContext, useEffect, + useLayoutEffect, useMemo, useRef, useState, type ReactNode, } from "react"; -import { getAccountRaw, LS } from "../lib/storage"; +import { getAccountRaw, setAccountRaw, LS } from "../lib/storage"; import { parseChannelLayout, type ChannelLayout } from "../lib/channelLayout"; -import { type SortState } from "../lib/columnSort"; +import { hydrateSort, type SortState } from "../lib/columnSort"; import { useSortState } from "../lib/useSortState"; import { useFilterState } from "../lib/useFilterState"; +import { useActiveAccountId } from "../lib/activeAccount"; +import { useUrlMirror } from "../lib/useUrlMirror"; +import { + channelsParamsToState, + channelsParamUpdates, + hasChannelsParams, + type ChannelsUrlState, +} from "../lib/channelsUrlState"; import { usePersistedTab } from "./Tabs"; import type { FilterMap } from "./tableHeader"; import { useNavigation, useNavigationActions } from "./NavigationProvider"; @@ -44,6 +53,36 @@ function migrateSyncChip(): FilterMap | null { return null; } +// The manager's stored column-filter map, healed to a plain string/string[] object (same discipline as +// useFilterState's hydrate). Used only to build the seed's base — the URL keeps whatever the stored map +// has for any dimension a partial shared link omits. +function readStoredFilters(): FilterMap { + const raw = getAccountRaw(LS.channelsFilterMap); + if (raw) { + try { + const v = JSON.parse(raw); + if (v && typeof v === "object" && !Array.isArray(v)) return v as FilterMap; + } catch { + /* fall through to the migrate seed */ + } + } + return migrateSyncChip() ?? {}; +} + +// S8: a shared manager link carries the content state in namespaced URL keys (ctab/csort/cf). When +// present, the URL wins over this account's stored view — read once at mount and applied over the slice +// hooks (see the provider). The keys are channels-unique, so their mere presence identifies a shared +// manager view; no ?page check is needed (the mirror below is page-gated so it never pollutes others). +function readChannelsUrlSeed(): ChannelsUrlState | null { + const params = new URLSearchParams(window.location.search); + if (!hasChannelsParams(params)) return null; + return channelsParamsToState(params, { + view: clampView(getAccountRaw(LS.channelsView)), + sort: hydrateSort(getAccountRaw(LS.channelSort), [], null), + filters: readStoredFilters(), + }); +} + type ChannelsState = { search: string; // The subscribed table's column filters, lifted out of DataTable so the top status chips, the Sync @@ -132,6 +171,38 @@ export function ChannelsProvider({ children }: { children: ReactNode }) { const [focusName, setFocusName] = useState(null); const [focusToken, setFocusToken] = useState(0); + // S8 — shareable manager view. Captured once at mount (or null when no manager URL keys are present). + const [urlSeed] = useState(readChannelsUrlSeed); + const accountId = useActiveAccountId(); + const seededRef = useRef(false); + const persistedRef = useRef(false); + + // Apply the shared-link seed once, BEFORE paint, through the slice setters. Going through the setters + // (not raw state) is deliberate: each one calls its hook's markDirty, so the account-pin re-hydrate + // (which fires later on a fresh tab) sees the slice as user-edited and won't reload the stored view + // over the shared one. setViewRaw (not setView) restores the tab without pushing a phantom history + // entry. A normal load has urlSeed === null and this is a no-op. + useLayoutEffect(() => { + if (!urlSeed || seededRef.current) return; + seededRef.current = true; + if (urlSeed.view !== view) setViewRaw(urlSeed.view); + setFilters(urlSeed.filters); + setSort(urlSeed.sort); + // eslint-disable-next-line react-hooks/exhaustive-deps + }, []); + + // On a fresh tab the account pins AFTER mount, so the seed's setter writes above no-op'd (no account + // yet). Persist the seed to the account once it's known, so the shared view also survives a later + // PLAIN reload (no URL) — mirrors PlexProvider's rehydrate-persist. The slice hooks' own re-hydrate + // already skips (marked dirty above), so this only touches storage, not state. + useEffect(() => { + if (!urlSeed || accountId == null || persistedRef.current) return; + persistedRef.current = true; + setAccountRaw(LS.channelsView, urlSeed.view); + setAccountRaw(LS.channelsFilterMap, JSON.stringify(urlSeed.filters)); + setAccountRaw(LS.channelSort, JSON.stringify(urlSeed.sort)); + }, [urlSeed, accountId]); + // 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`. @@ -218,6 +289,12 @@ export function ChannelsProvider({ children }: { children: ReactNode }) { [setSearch, setFilters, setView, setLayout, setSort, focusChannel, goToFullHistory], ); + // S8: mirror the content state (tab + sort + column filters) into the address bar under the namespaced + // keys so a manager view is shareable. Gated on page===channels: off the manager the keys are cleared + // (localStorage still holds the state; it re-mirrors on return). The name `search` stays OUT — it's a + // deliberately transient rail-search, like the feed's and plex's. + useUrlMirror(channelsParamUpdates(page === "channels" ? { view, sort, filters } : null)); + return ( diff --git a/frontend/src/components/NavigationProvider.tsx b/frontend/src/components/NavigationProvider.tsx index 64008888..982841a2 100644 --- a/frontend/src/components/NavigationProvider.tsx +++ b/frontend/src/components/NavigationProvider.tsx @@ -127,8 +127,8 @@ export function NavigationProvider({ children }: { children: ReactNode }) { // app. A fresh { sfPage } (no carried-over _yt/_chan markers) so each page starts at its root, and // the address bar reflects the page (feed is implicit) while clearing the view-specific params // (chan/v/pl and the plex content keys pscope/psort/pshow/pf) — a page switch resets to that - // module's root. Feed filters (owned by FeedFiltersProvider) are left intact; the plex keys - // re-mirror from PlexProvider on return to the plex page. + // module's root. Feed filters (owned by FeedFiltersProvider) are left intact; the plex/channels + // keys re-mirror from PlexProvider / ChannelsProvider on return to their page. window.history.pushState( { sfPage: next, sfScroll: nextNavId() }, "", @@ -141,6 +141,9 @@ export function NavigationProvider({ children }: { children: ReactNode }) { psort: null, pshow: null, pf: null, + ctab: null, + csort: null, + cf: null, }), ); notifyNav(); diff --git a/frontend/src/lib/channelsUrlState.dom.test.ts b/frontend/src/lib/channelsUrlState.dom.test.ts new file mode 100644 index 00000000..3018ffdc --- /dev/null +++ b/frontend/src/lib/channelsUrlState.dom.test.ts @@ -0,0 +1,108 @@ +// @vitest-environment jsdom +// +// S8 — the Channels-manager content state (active tab + column sort + the column-filter map) round-trips +// through namespaced URL keys (ctab/csort/cf) so a manager view is shareable, the S4/plexUrlState +// analogue. `cf` carries the mixed scalar/array FilterMap as a JSON blob. +import { describe, expect, it } from "vitest"; +import { + channelsParamsToState, + channelsParamUpdates, + hasChannelsParams, + type ChannelsUrlState, +} from "./channelsUrlState"; +import type { FilterMap } from "../components/tableHeader"; + +// A concrete non-default filter map exercising both wire shapes: scalars (the sync chip, a select) and +// an array (a multi column). +const RICH_FILTERS: FilterMap = { + sync: "needsFull", + priority: "3", + tags: ["12", "34"], +}; + +const BASE: ChannelsUrlState = { view: "subscribed", sort: null, filters: {} }; + +// Apply an updates delta onto a base URLSearchParams the way urlWithParams would (null deletes). +function applyUpdates(updates: Record): URLSearchParams { + const p = new URLSearchParams(); + for (const [k, v] of Object.entries(updates)) { + if (v != null) p.set(k, v); + } + return p; +} + +describe("channelsParamUpdates", () => { + it("emits nothing for the all-default state", () => { + expect(channelsParamUpdates(BASE)).toEqual({ ctab: null, csort: null, cf: null }); + }); + + it("clears every key when passed null (off-manager page)", () => { + expect(channelsParamUpdates(null)).toEqual({ ctab: null, csort: null, cf: null }); + }); + + it("emits only non-default keys", () => { + const u = channelsParamUpdates({ + view: "hidden", + sort: { key: "subscribers", dir: "desc" }, + filters: { sync: "fully" }, + }); + expect(u.ctab).toBe("hidden"); + expect(u.csort).toBe("subscribers:desc"); + expect(u.cf).toBe(JSON.stringify({ sync: "fully" })); + }); + + it("drops empty filter entries so a cleared funnel emits no cf", () => { + expect( + channelsParamUpdates({ view: "subscribed", sort: null, filters: { tags: [], sync: "" } }).cf, + ).toBeNull(); + }); +}); + +describe("round-trip", () => { + it("restores a rich state (scalar + array filters) from the emitted URL", () => { + const state: ChannelsUrlState = { + view: "blocked", + sort: { key: "priority", dir: "asc" }, + filters: RICH_FILTERS, + }; + const params = applyUpdates(channelsParamUpdates(state)); + const back = channelsParamsToState(params, BASE); + expect(back.view).toBe("blocked"); + expect(back.sort).toEqual({ key: "priority", dir: "asc" }); + expect(back.filters).toEqual(RICH_FILTERS); + // the array shape survives the JSON round-trip, not collapsed to a scalar + expect(Array.isArray(back.filters.tags)).toBe(true); + }); + + it("keeps base values for absent keys (partial link)", () => { + const params = new URLSearchParams("csort=title:asc"); + const back = channelsParamsToState(params, { + view: "discovery", + sort: null, + filters: { sync: "needsFull" }, + }); + expect(back.view).toBe("discovery"); // from base, no ctab + expect(back.filters).toEqual({ sync: "needsFull" }); // from base, no cf + expect(back.sort).toEqual({ key: "title", dir: "asc" }); + }); + + it("ignores an out-of-vocabulary tab / bad sort / corrupt cf and falls back to base", () => { + const params = new URLSearchParams("ctab=bogus&csort=key:sideways&cf=notjson"); + const back = channelsParamsToState(params, { view: "subscribed", sort: null, filters: {} }); + expect(back.view).toBe("subscribed"); // bogus tab → base + expect(back.sort).toBeNull(); // bad dir → parseSortState null → base (null) + expect(back.filters).toEqual({}); // corrupt cf → decodeFilters heals to empty + }); +}); + +describe("hasChannelsParams", () => { + it("detects any namespaced key", () => { + expect(hasChannelsParams(new URLSearchParams("ctab=hidden"))).toBe(true); + expect(hasChannelsParams(new URLSearchParams("cf=%7B%22sync%22%3A%22fully%22%7D"))).toBe(true); + }); + it("ignores the feed's and plex's keys", () => { + expect(hasChannelsParams(new URLSearchParams("scope=all&pscope=movie&sort=oldest"))).toBe( + false, + ); + }); +}); diff --git a/frontend/src/lib/channelsUrlState.ts b/frontend/src/lib/channelsUrlState.ts new file mode 100644 index 00000000..b1fa43bf --- /dev/null +++ b/frontend/src/lib/channelsUrlState.ts @@ -0,0 +1,98 @@ +// Serialize the Channels-manager content state (active tab + column sort + the column-filter map) to/ +// from the address bar so a manager view is bookmarkable/shareable — the S8 analogue of the feed's +// urlState and Plex's plexUrlState (S4). Keys are NAMESPACED (`ctab`/`csort`/`cf`) so they're +// channels-unique (like `?v=`/`?pl`/`pscope`): no other provider ever reads or clears them, and the +// feed's URL-stripping can't delete them out from under the manager. Only non-default values are +// emitted, so URLs stay clean. The manager's name search is deliberately EPHEMERAL (like the feed/plex +// rail search) and stays OUT of the shared contract. +import { parseSortState, type SortState } from "./columnSort"; +import { type FilterMap } from "../components/tableHeader"; + +// The content state S8 mirrors. `view` is the raw tab string (the provider clamps it); `sort` is the +// raw provider SortState (healed against live columns only at the point of use), so the user's real +// preference round-trips through the URL. +export interface ChannelsUrlState { + view: string; + sort: SortState; + filters: FilterMap; +} + +const KEYS = ["ctab", "csort", "cf"] as const; +const VIEWS = ["subscribed", "discovery", "hidden", "blocked"]; + +// Sort ⇄ `key:dir` (e.g. `subscribers:desc`). Column sort keys are simple words (no colon), so a plain +// split is safe. There is no default sort (fallback null), so any sort emits. +function encodeSort(s: SortState): string | null { + return s ? `${s.key}:${s.dir}` : null; +} +function decodeSort(raw: string): SortState { + const i = raw.indexOf(":"); + const key = i === -1 ? raw : raw.slice(0, i); + const dir = i === -1 ? "asc" : raw.slice(i + 1); + return parseSortState({ key, dir }); +} + +// The FilterMap mixes scalar column filters (priority=select, name=text, the sync chip) with array ones +// (tags=multi), so it's carried as a compact JSON blob rather than a nested query string — JSON round- +// trips both shapes exactly (and future multi columns for free), and the values are opaque enum/id +// tokens where a readable querystring buys little. Empty entries are dropped so a cleared funnel emits +// no `cf`. +function encodeFilters(map: FilterMap): string | null { + const clean: FilterMap = {}; + for (const [k, v] of Object.entries(map)) { + if (Array.isArray(v) ? v.length : v) clean[k] = v; + } + return Object.keys(clean).length ? JSON.stringify(clean) : null; +} +// A URL blob is untrusted input: accept only a plain object of string / string[] entries, anything else +// heals to empty (same discipline as useFilterState's storage hydrate). +function decodeFilters(raw: string): FilterMap { + try { + const v = JSON.parse(raw); + if (!v || typeof v !== "object" || Array.isArray(v)) return {}; + const out: FilterMap = {}; + for (const [k, val] of Object.entries(v)) { + if (typeof val === "string") out[k] = val; + else if (Array.isArray(val) && val.every((x) => typeof x === "string")) + out[k] = val as string[]; + } + return out; + } catch { + return {}; + } +} + +/** The address-bar delta for the manager content state: each namespaced key mapped to its emitted + * value, or null to delete it (so a control back at its default clears the key). Pass `null` to clear + * ALL manager keys — used when the app is off the Channels page (localStorage still holds the state; it + * re-mirrors on return). Feed the result into urlWithParams(). */ +export function channelsParamUpdates( + state: ChannelsUrlState | null, +): Record { + if (!state) return { ctab: null, csort: null, cf: null }; + return { + ctab: state.view && state.view !== "subscribed" ? state.view : null, + csort: encodeSort(state.sort), + cf: encodeFilters(state.filters), + }; +} + +/** True if the query string carries any manager content key we recognize (a shared manager link). */ +export function hasChannelsParams(params: URLSearchParams): boolean { + return KEYS.some((k) => params.has(k)); +} + +/** Merge the manager content keys from the URL over a base state (the localStorage-seeded values); an + * absent or invalid key keeps the base value, so a partial link (only `?ctab=`) is honoured. */ +export function channelsParamsToState( + params: URLSearchParams, + base: ChannelsUrlState, +): ChannelsUrlState { + const tab = params.get("ctab"); + const csort = params.get("csort"); + return { + view: tab && VIEWS.includes(tab) ? tab : base.view, + sort: csort != null ? (decodeSort(csort) ?? base.sort) : base.sort, + filters: params.has("cf") ? decodeFilters(params.get("cf") ?? "") : base.filters, + }; +}