commit 06a1219a5aa1a5d7eb93e22ce7cee912d4d826ed
parent ca410b8d73d84d0ae275117fa864efe11333b4ac
Author: I Mean I'm Just Saying <imeanimjustsaying@kiwifarms.st>
Date: Tue, 19 May 2026 02:02:52 -0400
New filter url handling
Diffstat:
3 files changed, 244 insertions(+), 0 deletions(-)
diff --git a/common/components/TranscriptSearch.tsx b/common/components/TranscriptSearch.tsx
Binary files differ.
diff --git a/common/components/shareUrl.ts b/common/components/shareUrl.ts
@@ -0,0 +1,125 @@
+// Share-link URL schema v1. Positive (selection-based) encoding of the
+// export filter state so links don't drift when the channel manifest grows.
+//
+// Keep this file the *only* home for v1 parsing/building. The legacy schema
+// lives in `urlState.ts` (the `ch/nov/nol/.../tk` keys on `UrlParams`) plus
+// the `snapshotToExcluded` helper in `TranscriptSearch.tsx`. Retiring the
+// legacy schema later means deleting those legacy bits — this file stays.
+
+import type { SearchMode } from "./urlState";
+
+export type ShareSelection = {
+ selectedChannels: Set<string>;
+ videos: boolean;
+ livestreams: boolean;
+ allAges: boolean;
+ restricted: boolean;
+ available: boolean;
+ deleted: boolean;
+ tracks: Set<string>;
+};
+
+// Legacy filter keys auto-written by older versions of this app. Listed here
+// so the strip-on-commit helper can clear them alongside v1 keys without
+// `urlState.ts` having to know about share-link mechanics.
+export const FILTER_URL_KEYS_LEGACY = [
+ "ch",
+ "nov",
+ "nol",
+ "naa",
+ "nar",
+ "nav",
+ "nd",
+ "tk",
+] as const;
+
+// v1 filter keys. `fv` is the sentinel; the rest are positive selections.
+export const FILTER_URL_KEYS_V1 = [
+ "fv",
+ "fc",
+ "ft",
+ "fa",
+ "fav",
+ "fk",
+] as const;
+
+export function hasShareV1(search: string): boolean {
+ return new URLSearchParams(search).get("fv") === "1";
+}
+
+// Channels not listed in `fc` are *not selected*. Unknown channel names in
+// `fc` (e.g. a renamed/removed channel) are silently dropped against the
+// current manifest so the resolved set always reflects what actually exists.
+export function parseShareV1(
+ search: string,
+ allChannelNames: ReadonlyArray<string>,
+): ShareSelection {
+ const p = new URLSearchParams(search);
+ const requestedChannels = new Set(p.getAll("fc"));
+ const selectedChannels = new Set<string>();
+ for (const name of allChannelNames) {
+ if (requestedChannels.has(name)) selectedChannels.add(name);
+ }
+ const types = new Set(p.getAll("ft"));
+ const audience = new Set(p.getAll("fa"));
+ const availability = new Set(p.getAll("fav"));
+ const tracks = new Set(p.getAll("fk"));
+ return {
+ selectedChannels,
+ videos: types.has("v"),
+ livestreams: types.has("l"),
+ allAges: audience.has("a"),
+ restricted: audience.has("r"),
+ available: availability.has("a"),
+ deleted: availability.has("d"),
+ tracks,
+ };
+}
+
+export function buildShareSearchParams(
+ committed: ShareSelection,
+ opts: { q: string; mode: SearchMode; regex: boolean },
+): URLSearchParams {
+ const p = new URLSearchParams();
+ p.set("fv", "1");
+ const channelNames = Array.from(committed.selectedChannels).sort();
+ for (const name of channelNames) p.append("fc", name);
+ if (committed.videos) p.append("ft", "v");
+ if (committed.livestreams) p.append("ft", "l");
+ if (committed.allAges) p.append("fa", "a");
+ if (committed.restricted) p.append("fa", "r");
+ if (committed.available) p.append("fav", "a");
+ if (committed.deleted) p.append("fav", "d");
+ for (const tk of Array.from(committed.tracks).sort()) p.append("fk", tk);
+ if (opts.q) p.set("q", opts.q);
+ if (opts.mode === "subs") p.set("m", "subs");
+ if (opts.regex) p.set("re", "1");
+ return p;
+}
+
+// Remove every filter-related param (legacy + v1) from the URL via
+// history.replaceState. Leaves `q`, `m`, `re`, `v`, `t`, `vm` untouched.
+// Mirrors the popstate-notify dance in `writeUrlParams` so listeners
+// re-render when keys disappear.
+export function stripAllFilterParamsFromUrl(): void {
+ if (typeof window === "undefined") return;
+ const params = new URLSearchParams(window.location.search);
+ let changed = false;
+ for (const k of FILTER_URL_KEYS_LEGACY) {
+ if (params.has(k)) {
+ params.delete(k);
+ changed = true;
+ }
+ }
+ for (const k of FILTER_URL_KEYS_V1) {
+ if (params.has(k)) {
+ params.delete(k);
+ changed = true;
+ }
+ }
+ if (!changed) return;
+ const qs = params.toString();
+ const next = `${window.location.pathname}${qs ? `?${qs}` : ""}`;
+ window.history.replaceState(window.history.state, "", next);
+ window.dispatchEvent(new PopStateEvent("popstate"));
+}
diff --git a/export/e2e/share-current-search.spec.ts b/export/e2e/share-current-search.spec.ts
@@ -0,0 +1,119 @@
+import { expect, test } from "@playwright/test";
+import { CHANNEL } from "./fixtures/data";
+import { installRoutes, urlParams } from "./helpers";
+
+test.use({
+ permissions: ["clipboard-read", "clipboard-write"],
+});
+
+test.describe("search page — share current search", () => {
+ test.beforeEach(async ({ page, context }) => {
+ await context.grantPermissions(["clipboard-read", "clipboard-write"]);
+ await installRoutes(page);
+ });
+
+ test("builds positive-encoded URL and strips filter params on commit", async ({
+ page,
+ }) => {
+ await page.goto("/");
+ // Wait for filter UI to render (gated on manifest load).
+ const livestreamsBox = page.getByRole("checkbox", { name: "Livestreams" });
+ await expect(livestreamsBox).toBeVisible();
+
+ // Uncheck Livestreams + Deleted so the share link distinguishes selections.
+ await livestreamsBox.uncheck();
+ await page.getByRole("checkbox", { name: "Deleted" }).uncheck();
+
+ // Type a query and run the search.
+ const search = page.getByPlaceholder("Search transcripts...");
+ await search.fill("alpha");
+ await search.press("Enter");
+
+ // After commit the URL only carries q (filters stripped).
+ await expect(page).toHaveURL(/\?q=alpha$/);
+
+ // Click Share and inspect the clipboard.
+ await page.getByRole("button", { name: "Share current search" }).click();
+ await expect(
+ page.getByRole("button", { name: "Link copied!" }),
+ ).toBeVisible();
+ const clip = await page.evaluate(() => navigator.clipboard.readText());
+ const url = new URL(clip);
+ expect(url.searchParams.get("fv")).toBe("1");
+ expect(url.searchParams.get("q")).toBe("alpha");
+ // The only known channel is still selected (we only edited type/avail).
+ expect(url.searchParams.getAll("fc")).toEqual([CHANNEL]);
+ // Videos remains selected, Livestreams was unchecked.
+ expect(url.searchParams.getAll("ft").sort()).toEqual(["v"]);
+ // Audience: both selected by default.
+ expect(url.searchParams.getAll("fa").sort()).toEqual(["a", "r"]);
+ // Availability: Deleted unchecked → only `a` remains.
+ expect(url.searchParams.getAll("fav")).toEqual(["a"]);
+ // Legacy filter params must NOT leak into the new link.
+ expect(url.searchParams.get("ch")).toBeNull();
+ expect(url.searchParams.get("nol")).toBeNull();
+ expect(url.searchParams.get("nd")).toBeNull();
+ });
+
+ test("round-trips a v1 share link, then strips filter params on next commit", async ({
+ page,
+ }) => {
+ // Open a synthesized share link directly.
+ const params = new URLSearchParams();
+ params.set("fv", "1");
+ params.append("fc", CHANNEL);
+ params.append("ft", "v");
+ params.append("fa", "a");
+ params.append("fa", "r");
+ params.append("fav", "a");
+ params.set("q", "alpha");
+ await page.goto(`/?${params.toString()}`);
+
+ // Wait for filter UI then verify hydration applied the v1 params.
+ await expect(
+ page.getByRole("checkbox", { name: "Livestreams" }),
+ ).not.toBeChecked();
+ await expect(page.getByRole("checkbox", { name: "Videos" })).toBeChecked();
+ await expect(page.getByRole("checkbox", { name: "Deleted" })).not.toBeChecked();
+ await expect(
+ page.getByRole("checkbox", { name: "Available" }),
+ ).toBeChecked();
+ await expect(page.getByPlaceholder("Search transcripts...")).toHaveValue(
+ "alpha",
+ );
+
+ // Trigger another commit to confirm strip-on-commit behavior.
+ await page.getByPlaceholder("Search transcripts...").press("Enter");
+ await expect(page).toHaveURL(/\?q=alpha$/);
+ const after = await urlParams(page);
+ expect(after.get("fv")).toBeNull();
+ expect(after.get("fc")).toBeNull();
+ expect(after.get("ft")).toBeNull();
+ expect(after.get("fav")).toBeNull();
+ });
+
+ test("legacy link still applies filters and is stripped on next commit", async ({
+ page,
+ }) => {
+ // Legacy disables videos via nov=1. After hydration the Videos checkbox
+ // should be unchecked.
+ const params = new URLSearchParams();
+ params.set("nov", "1");
+ params.set("nd", "1");
+ params.set("q", "hello");
+ await page.goto(`/?${params.toString()}`);
+
+ await expect(page.getByRole("checkbox", { name: "Videos" })).not.toBeChecked();
+ await expect(page.getByRole("checkbox", { name: "Deleted" })).not.toBeChecked();
+ await expect(
+ page.getByRole("checkbox", { name: "Livestreams" }),
+ ).toBeChecked();
+
+ // Re-commit; legacy params are stripped.
+ await page.getByPlaceholder("Search transcripts...").press("Enter");
+ await expect(page).toHaveURL(/\?q=hello$/);
+ const after = await urlParams(page);
+ expect(after.get("nov")).toBeNull();
+ expect(after.get("nd")).toBeNull();
+ });
+});