diff --git a/frontend/src/components/ActiveFilterChips.tsx b/frontend/src/components/ActiveFilterChips.tsx index 68baec50..17b6a3cd 100644 --- a/frontend/src/components/ActiveFilterChips.tsx +++ b/frontend/src/components/ActiveFilterChips.tsx @@ -9,6 +9,7 @@ import { useAnchoredPopover } from "./ui/anchoredPopoverHooks"; import { Button } from "./ui/Button"; import { Chip } from "./ui/Chip"; import { chipClass } from "./ui/chipStyles"; +import { ChipLabelPending } from "./ui/Skeleton"; import { SURFACE } from "./ui/surfaces"; // The applied-filter row above the feed: every narrowing currently in force, each removable on its @@ -26,6 +27,16 @@ import { SURFACE } from "./ui/surfaces"; // the "fit some + N more" treatment used on wider lanes. const NARROW_PX = 420; +// A chip's text: its name, or a placeholder while the name is on its way. The visible chip, the +// overflow popover's row and the hidden measuring mirror all draw it, so the mirror measures what shows. +function ChipText({ f, className }: { f: ActiveFilter; className: string }) { + return f.pending ? ( + + ) : ( + {f.label} + ); +} + function ChipButton({ f, testId }: { f: ActiveFilter; testId?: string }) { const { t } = useTranslation(); return ( @@ -37,7 +48,7 @@ function ChipButton({ f, testId }: { f: ActiveFilter; testId?: string }) { onClick={f.remove} removeLabel={t("feed.chips.remove", { name: f.label })} > - {f.label} + ); } @@ -93,7 +104,7 @@ function OverflowPopover({ label={f.label} className="group flex w-full items-center gap-2 rounded-lg px-2 py-1.5 text-left text-sm text-fg transition" > - {f.label} + ))} @@ -134,7 +145,7 @@ export default function ActiveFilterChips({ const n = filters.length; // Re-measure whenever the chip set changes (labels or count) — captures both in one dep. - const chipsKey = filters.map((f) => f.key + f.label).join("|"); + const chipsKey = filters.map((f) => f.key + f.label + (f.pending ? "…" : "")).join("|"); // The shared measurement core hands over the chip right edges + gap + pill width + lane width; this // policy adds the feed row's own framing (the leading label's right edge, and the trailing "Clear @@ -191,7 +202,7 @@ export default function ActiveFilterChips({ }} className={chipClass("token", "md") + " pr-1.5"} > - {f.label} + ))} diff --git a/frontend/src/components/ChannelPicker.tsx b/frontend/src/components/ChannelPicker.tsx index 495a5161..619fb7fe 100644 --- a/frontend/src/components/ChannelPicker.tsx +++ b/frontend/src/components/ChannelPicker.tsx @@ -1,7 +1,6 @@ import { useTranslation } from "react-i18next"; -import { useQuery } from "@tanstack/react-query"; -import { qk } from "../lib/queryKeys"; -import { api, type FeedFilters } from "../lib/api"; +import type { FeedFilters } from "../lib/api"; +import { useChannelNames } from "../lib/useChannelNames"; import Combobox, { type ComboboxOption } from "./ui/Combobox"; // Pick channels to narrow the feed to — several OR together, so every other filter then applies @@ -19,18 +18,13 @@ export default function ChannelPicker({ setFilters: (f: FeedFilters) => void; }) { const { t } = useTranslation(); - const channelsQuery = useQuery({ - queryKey: qk.channels(), - queryFn: api.channels, - staleTime: 5 * 60_000, - }); + const { query: channelsQuery, names, pending } = useChannelNames(); const all = channelsQuery.data ?? []; // The list order isn't alphabetical — it's the user's channel priority — and Combobox preserves the // options order, so it stays meaningful. selectedLabels keeps a picked channel named even if a later - // narrowing ever drops it from the visible rows. + // narrowing ever drops it from the visible rows, and names a reloaded filter before the list lands. const options: ComboboxOption[] = all.map((c) => ({ value: c.id, label: c.title ?? c.id })); - const selectedLabels = Object.fromEntries(all.map((c) => [c.id, c.title ?? c.id])); return ( void channelsQuery.refetch()} emptyText={t("sidebar.noChannelMatch")} - selectedLabels={selectedLabels} + selectedLabels={names} + labelsPending={pending} /> ); } diff --git a/frontend/src/components/channelChipNames.dom.test.tsx b/frontend/src/components/channelChipNames.dom.test.tsx new file mode 100644 index 00000000..9dccbdaf --- /dev/null +++ b/frontend/src/components/channelChipNames.dom.test.tsx @@ -0,0 +1,117 @@ +// @vitest-environment jsdom +// +// A channel filter's chips are named from the subscription list, which a reload has not fetched yet: +// they used to read as raw UC… ids for a moment (the owner, 2026-10-03). The names seen last time now +// name them until the list lands; a channel never named on this browser draws a placeholder instead. +import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; +import { act, cleanup, render, renderHook, screen, waitFor } from "@testing-library/react"; +import { QueryClient, QueryClientProvider } from "@tanstack/react-query"; +import { createElement, type ReactNode } from "react"; +import { api, type FeedFilters, type ManagedChannel } from "../lib/api"; +import { ACTIVE_ACCOUNT_KEY } from "../lib/activeAccount"; +import { accountKey, LS } from "../lib/storage"; +import { clearedFilters, useActiveFilters } from "../lib/useActiveFilters"; +import ActiveFilterChips from "./ActiveFilterChips"; +import ChannelPicker from "./ChannelPicker"; + +vi.mock("react-i18next", async (importOriginal) => { + const actual = await importOriginal(); + return { + ...actual, + useTranslation: () => ({ t: (key: string) => key, i18n: { language: "en" } }), + }; +}); + +const KNOWN = "UCknown0000000000000000"; +const NEW = "UCnever00000000000000000"; +const filters: FeedFilters = { + ...clearedFilters({ q: "" } as FeedFilters), + channelIds: [KNOWN, NEW], +}; +const namesKey = () => accountKey(LS.channelNames, 5)!; + +let resolveChannels: (c: ManagedChannel[]) => void; +let qc: QueryClient; +const wrapper = ({ children }: { children: ReactNode }) => + createElement(QueryClientProvider, { client: qc }, children); + +beforeEach(() => { + localStorage.clear(); + sessionStorage.setItem(ACTIVE_ACCOUNT_KEY, "5"); + localStorage.setItem(namesKey(), JSON.stringify({ [KNOWN]: "Remembered name" })); + qc = new QueryClient({ defaultOptions: { queries: { retry: false } } }); + vi.spyOn(api, "tags").mockResolvedValue([] as never); + vi.spyOn(api, "channels").mockReturnValue( + new Promise((r) => { + resolveChannels = r; + }), + ); +}); +afterEach(() => { + cleanup(); + vi.restoreAllMocks(); +}); + +const land = async () => { + await act(async () => { + resolveChannels([ + { id: KNOWN, title: "Renamed since" }, + { id: NEW, title: "Brand new" }, + ] as ManagedChannel[]); + }); +}; + +describe("a channel filter's chip names", () => { + it("name a remembered channel before the list lands, and hold the rest as pending", async () => { + const { result } = renderHook(() => useActiveFilters(filters, () => {}), { wrapper }); + const chip = (id: string) => result.current.find((f) => f.key === `channel:${id}`)!; + expect(chip(KNOWN)).toMatchObject({ label: "Remembered name", pending: false }); + expect(chip(NEW)).toMatchObject({ label: NEW, pending: true }); + + await land(); + await waitFor(() => expect(chip(NEW)).toMatchObject({ label: "Brand new", pending: false })); + // The list wins over the remembered copy, and replaces it for the next reload. + expect(chip(KNOWN).label).toBe("Renamed since"); + expect(JSON.parse(localStorage.getItem(namesKey())!)).toEqual({ + [KNOWN]: "Renamed since", + [NEW]: "Brand new", + }); + }); + + it("draw a placeholder above the feed, not the raw id", () => { + const { result } = renderHook(() => useActiveFilters(filters, () => {}), { wrapper }); + const { container } = render( + {}} />, + ); + expect(screen.getAllByText("Remembered name").length).toBeGreaterThan(0); + // The id is only a screen reader's (sr-only), beside a bone; nothing paints it. + for (const el of screen.getAllByText(NEW)) expect(el.className).toBe("sr-only"); + expect(container.querySelectorAll(".animate-pulse").length).toBeGreaterThan(0); + }); + + it("draw a placeholder in the filter panel's picker, then the name", async () => { + const { container } = render( {}} />, { + wrapper, + }); + screen.getByText("Remembered name"); + expect(screen.getByText(NEW).className).toBe("sr-only"); + expect(container.querySelectorAll(".animate-pulse")).toHaveLength(1); + + await land(); + await waitFor(() => screen.getByText("Brand new")); + expect(screen.queryByText(NEW)).toBeNull(); + expect(container.querySelectorAll(".animate-pulse")).toHaveLength(0); + }); + + it("fall back to the id when the list fails, so each chip stays told apart", async () => { + localStorage.removeItem(namesKey()); + vi.spyOn(api, "channels").mockRejectedValue(new Error("down")); + const { result } = renderHook(() => useActiveFilters(filters, () => {}), { wrapper }); + await waitFor(() => + expect(result.current.find((f) => f.key === `channel:${NEW}`)).toMatchObject({ + label: NEW, + pending: false, + }), + ); + }); +}); diff --git a/frontend/src/components/ui/Combobox.tsx b/frontend/src/components/ui/Combobox.tsx index a7a16ef1..8019acbf 100644 --- a/frontend/src/components/ui/Combobox.tsx +++ b/frontend/src/components/ui/Combobox.tsx @@ -11,6 +11,7 @@ import { InnerInput } from "./Input"; import { DismissibleChip } from "./Chip"; import { fieldClass } from "./fieldStyles"; import { SURFACE } from "./surfaces"; +import { ChipLabelPending } from "./Skeleton"; // The universal combobox: a react-select / Overseerr-style "combi input" — selected values are // removable chips that live INSIDE a text field you type into, and a checklist of options opens below. @@ -72,6 +73,9 @@ export interface ComboboxProps { /** Chip/label text for a selected value whose option is not in the current (e.g. server-narrowed) * list — the collection picker passes the stored title here so a reloaded filter still names itself. */ selectedLabels?: Record; + /** The labels are still on their way: a selected value with none yet draws a placeholder chip + * instead of its raw value (a channel filter's UC… id after a reload). */ + labelsPending?: boolean; } export default function Combobox({ @@ -93,6 +97,7 @@ export default function Combobox({ maxVisible = 50, renderOption, selectedLabels, + labelsPending = false, }: ComboboxProps) { const { t } = useTranslation(); const [open, setOpen] = useState(false); @@ -113,7 +118,8 @@ export default function Combobox({ useEffect(() => { for (const o of options) if (o.label != null) labelCache.current[o.value] = o.label; }, [options]); - const labelFor = (v: string) => selectedLabels?.[v] ?? labelCache.current[v] ?? v; + const knownLabel = (v: string) => selectedLabels?.[v] ?? labelCache.current[v]; + const labelFor = (v: string) => knownLabel(v) ?? v; const setOpenAnd = (next: boolean) => { if (next && disabled) return; @@ -210,7 +216,11 @@ export default function Combobox({ onDismiss={() => !disabled && onChange(selected.filter((x) => x !== v))} dismissLabel={t("common.removeNamed", { name: labelFor(v) })} > - {labelFor(v)} + {labelsPending && knownLabel(v) === undefined ? ( + + ) : ( + labelFor(v) + )} ))}