// 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 { decodeSortParam, encodeSortParam, type SortState } from "./columnSort"; import { type FilterMap } from "./tableColumns"; // 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`) via the shared codec in columnSort. There is no default // sort here (fallback null), so any sort emits — no default short-circuit needed. // 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: encodeSortParam(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 ? (decodeSortParam(csort) ?? base.sort) : base.sort, filters: params.has("cf") ? decodeFilters(params.get("cf") ?? "") : base.filters, }; }