fix(feed): a channel filter's chips name their channels from the first paint

After a reload the chips (filter panel and the row above the feed) read as
raw UC… ids until the subscription list landed. The names seen last time
are now kept per account (useChannelNames) and name the chips at once; the
list then replaces the copy, so a renamed or dropped channel heals. A
channel never named on this browser draws a placeholder bone while the
list loads; a failed list still falls back to the id.
This commit is contained in:
2026-10-03 03:29:54 +02:00
parent 6edb22988c
commit e9c15baae0
8 changed files with 227 additions and 32 deletions
+15 -4
View File
@@ -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 ? (
<ChipLabelPending value={f.label} />
) : (
<span className={className}>{f.label}</span>
);
}
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 })}
>
<span className="max-w-56 truncate">{f.label}</span>
<ChipText f={f} className="max-w-56 truncate" />
</Chip>
);
}
@@ -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"
>
<span className="flex-1 truncate">{f.label}</span>
<ChipText f={f} className="flex-1 truncate" />
<X className="w-3.5 h-3.5 shrink-0 opacity-60 group-hover:opacity-100" />
</PopoverItem>
))}
@@ -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"}
>
<span className="max-w-56 truncate">{f.label}</span>
<ChipText f={f} className="max-w-56 truncate" />
<X className="size-3" />
</span>
))}
+6 -11
View File
@@ -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 (
<Combobox
@@ -43,7 +37,8 @@ export default function ChannelPicker({
error={channelsQuery.isError && !channelsQuery.data ? t("common.loadError") : undefined}
onRetry={() => void channelsQuery.refetch()}
emptyText={t("sidebar.noChannelMatch")}
selectedLabels={selectedLabels}
selectedLabels={names}
labelsPending={pending}
/>
);
}
@@ -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<typeof import("react-i18next")>();
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<ManagedChannel[]>((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(
<ActiveFilterChips filters={result.current} onClearAll={() => {}} />,
);
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(<ChannelPicker filters={filters} setFilters={() => {}} />, {
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,
}),
);
});
});
+12 -2
View File
@@ -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<string, string>;
/** 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 ? (
<ChipLabelPending value={v} />
) : (
labelFor(v)
)}
</DismissibleChip>
))}
<InnerInput
+20 -3
View File
@@ -7,15 +7,32 @@ import { SURFACE } from "./surfaces";
// paragraph's, for prose that arrives after its page). The
// shapes are shown through `LoadingState shape=…`, which owns the 300 ms reveal and the status role.
export function Skeleton({ className }: { className?: string }) {
export function Skeleton({ className, inline = false }: { className?: string; inline?: boolean }) {
// Inline: a span, for a bone inside phrasing content (a chip is a button, which may not hold a div).
const Tag = inline ? "span" : "div";
return (
<div
<Tag
aria-hidden="true"
className={clsx("rounded-lg bg-fg/8 animate-pulse motion-reduce:animate-none", className)}
className={clsx(
"rounded-lg bg-fg/8 animate-pulse motion-reduce:animate-none",
inline && "inline-block align-middle",
className,
)}
/>
);
}
/** A chip whose name is still on its way: a name-sized bone, not the raw value (a channel's UC… id).
* A screen reader still gets the value, so two such chips stay told apart. */
export function ChipLabelPending({ value }: { value: string }) {
return (
<>
<Skeleton inline className="h-3 w-20" />
<span className="sr-only">{value}</span>
</>
);
}
export type SkeletonShape = "grid" | "poster" | "list" | "table" | "text";
const COUNT: Record<SkeletonShape, number> = { grid: 8, poster: 12, list: 6, table: 8, text: 5 };
+3
View File
@@ -41,6 +41,9 @@ export const LS = {
channelCardSize: "siftlode.channelCardSize",
channelDiscoveryCardSize: "siftlode.channelDiscoveryCardSize",
channelsTable: "siftlode.channelsTable",
// Channel id → name as last seen (useChannelNames): names a channel filter's chips after a reload,
// before the subscription list has landed. A cache, rewritten from the list on every load.
channelNames: "siftlode.channelNames",
channelDiscoveryTable: "siftlode.channelDiscoveryTable",
// A page's sub-tab: sessionStorage, per browser tab (useTabPersistedState), like adminUsersTab and
// statsTab below.
+10 -12
View File
@@ -1,7 +1,6 @@
import { useTranslation } from "react-i18next";
import { useQuery } from "@tanstack/react-query";
import { qk } from "./queryKeys";
import { api, DEFAULT_TAG_MODES, type FeedFilters } from "./api";
import { DEFAULT_TAG_MODES, type FeedFilters } from "./api";
import { useChannelNames } from "./useChannelNames";
import { parseSort } from "./feedSort";
import { useTags } from "./useTags";
@@ -9,6 +8,8 @@ import { useTags } from "./useTags";
export interface ActiveFilter {
key: string;
label: string;
/** The name is still on its way: the chip draws a placeholder, not `label` (a raw id). */
pending?: boolean;
remove: () => void;
}
@@ -49,12 +50,7 @@ export function useActiveFilters(
): ActiveFilter[] {
const { t } = useTranslation();
const tagsQuery = useTags();
const channelsQuery = useQuery({
queryKey: qk.channels(),
queryFn: api.channels,
enabled: filters.channelIds.length > 0,
staleTime: 5 * 60_000,
});
const channelNames = useChannelNames(filters.channelIds.length > 0);
const out: ActiveFilter[] = [];
@@ -113,9 +109,11 @@ export function useActiveFilters(
for (const id of filters.channelIds) {
out.push({
key: `channel:${id}`,
// Fall back to the id, not a generic label: several chips reading "This channel" while
// the name cache loads would be indistinguishable, and each ✕ drops a different one.
label: channelsQuery.data?.find((c) => c.id === id)?.title ?? id,
// Fall back to the id, not a generic label: several chips reading "This channel" would be
// indistinguishable, and each ✕ drops a different one. While the list loads, a channel this
// browser has never named draws a placeholder instead (the id stays its accessible name).
label: channelNames.names[id] ?? id,
pending: channelNames.names[id] === undefined && channelNames.pending,
remove: () =>
setFilters({ ...filters, channelIds: filters.channelIds.filter((x) => x !== id) }),
});
+44
View File
@@ -0,0 +1,44 @@
import { useEffect, useState } from "react";
import { useQuery } from "@tanstack/react-query";
import { qk } from "./queryKeys";
import { api, type ManagedChannel } from "./api";
import { LS, readAccount, writeAccount } from "./storage";
type Names = Record<string, string>;
const namesOf = (channels: ManagedChannel[]): Names =>
Object.fromEntries(channels.map((c) => [c.id, c.title ?? c.id]));
const readNames = (): Names => {
const raw = readAccount<unknown>(LS.channelNames, {});
return raw && typeof raw === "object" && !Array.isArray(raw) ? (raw as Names) : {};
};
/**
* Channel id → name, for the chips that name a channel filter (the filter panel's picker and the chip
* row above the feed). The names come from the subscription list, which a reload has not fetched yet:
* for a moment the chips read as raw UC… ids (the owner, 2026-10-03). The names seen last time are
* kept per account and name the chips until the list lands; the list then wins and replaces the copy,
* so a renamed or dropped channel heals on the next load.
*
* `pending` is true while the list loads: a channel this browser has never named then has no name to
* show, and the chip draws a placeholder rather than its id. A failed list leaves the id (the chips
* stay told apart, and each ✕ drops a different one).
*/
export function useChannelNames(enabled = true) {
const query = useQuery({
queryKey: qk.channels(),
queryFn: api.channels,
enabled,
staleTime: 5 * 60_000,
});
const [remembered] = useState(readNames);
useEffect(() => {
if (query.data) writeAccount(LS.channelNames, namesOf(query.data));
}, [query.data]);
return {
query,
names: query.data ? namesOf(query.data) : remembered,
pending: enabled && query.isPending,
};
}