diff --git a/frontend/src/components/Channels.tsx b/frontend/src/components/Channels.tsx index e520b459..7bdf3749 100644 --- a/frontend/src/components/Channels.tsx +++ b/frontend/src/components/Channels.tsx @@ -9,6 +9,7 @@ import { } from "react"; import { useTranslation } from "react-i18next"; import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query"; +import { createStore, type Store } from "../lib/store"; import { qk } from "../lib/queryKeys"; import { Ban, Eye, EyeOff, History, LayoutGrid, Pencil, RefreshCw, Table, X } from "lucide-react"; import ExcludedChannelList from "./ExcludedChannelList"; @@ -91,24 +92,8 @@ const channelKey = (c: ManagedChannel) => c.id; // Which channel's row menu is open, for its "…" (`aria-expanded`, and staying shown while open). A // tiny store rather than a context: a context change walks the whole table to find its readers (~20 // ms on 400 rows, each open and close), while a store calls its subscribers and re-renders only the -// two buttons whose answer changed. -function createOpenRowStore() { - let open: string | null = null; - const listeners = new Set<() => void>(); - return { - get: () => open, - set: (next: string | null) => { - if (next === open) return; - open = next; - listeners.forEach((l) => l()); - }, - subscribe: (l: () => void) => { - listeners.add(l); - return () => void listeners.delete(l); - }, - }; -} -type OpenRowStore = ReturnType; +// two buttons whose answer changed (each reads a boolean, so a change elsewhere renders nothing). +type OpenRowStore = Store; function RowMoreButton({ id, @@ -349,7 +334,7 @@ export default function Channels() { // stays one line and the trailing "…" actions can't wrap. const tagChips = tagFilterChips({ userTags, - allTags, + tags: tagsQuery, picked: tagFilter, noTagOn, toggle: toggleTagFilter, @@ -397,7 +382,7 @@ export default function Channels() { // reposition, and so re-rendered the whole table — ~0.8 s to open the menu on 400 rows. const { buttonProps: rowButtonProps, triggerProps: rowTriggerProps } = rowMenu; const openRowId = rowMenu.open ? (rowMenu.target?.id ?? null) : null; - const [openRow] = useState(createOpenRowStore); + const [openRow] = useState(() => createStore(null)); useEffect(() => openRow.set(openRowId), [openRow, openRowId]); const channelActions = useChannelActions({ onView, diff --git a/frontend/src/components/PlexSidebar.tsx b/frontend/src/components/PlexSidebar.tsx index a0702107..f42824e1 100644 --- a/frontend/src/components/PlexSidebar.tsx +++ b/frontend/src/components/PlexSidebar.tsx @@ -439,7 +439,7 @@ function CollectionPicker({ // …but a selection restored from a LINK has ids and no titles, and the list above is only fetched // once the picker is opened — so the token rendered the raw rating_key until then. Resolve just the // selected ids (see usePlexCollectionTitles). - const resolved = usePlexCollectionTitles(values, titles); + const { titles: resolved, pending } = usePlexCollectionTitles(values, titles); const options: ComboboxOption[] = (q.data?.collections ?? []).map((c) => ({ value: c.id, label: c.title, @@ -459,6 +459,7 @@ function CollectionPicker({ options={options} selected={values} selectedLabels={resolved} + labelsPending={pending} query={search} onQueryChange={setSearch} onOpenChange={setOpen} diff --git a/frontend/src/components/TagFilterChips.tsx b/frontend/src/components/TagFilterChips.tsx index b3650553..50ed62ff 100644 --- a/frontend/src/components/TagFilterChips.tsx +++ b/frontend/src/components/TagFilterChips.tsx @@ -7,6 +7,7 @@ import { chipClass } from "./ui/chipStyles"; import { PopoverSurface } from "./ui/AnchoredPopover"; import { useAnchoredPopover } from "./ui/anchoredPopoverHooks"; import { SURFACE } from "./ui/surfaces"; +import { ChipLabelPending } from "./ui/Skeleton"; // A single-line row of toggle filter chips that collapses its overflow into a trailing "+N" pill whose // popover holds the rest (still toggleable) — the same compression the feed's applied-filter row uses @@ -18,14 +19,22 @@ import { SURFACE } from "./ui/surfaces"; export interface FilterChip { key: string; label: string; + /** The name is still on its way: the chip draws a placeholder, not `label` (a raw id). */ + pending?: boolean; active: boolean; onToggle: () => void; } +// A chip's text — the visible chip and the hidden measuring mirror both draw it, so the mirror +// measures what shows (ActiveFilterChips' ChipText, for this row's chips). +function ChipText({ chip }: { chip: FilterChip }) { + return chip.pending ? : <>{chip.label}; +} + function TagChip({ chip }: { chip: FilterChip }) { return ( - {chip.label} + ); } @@ -43,7 +52,9 @@ export default function TagFilterChips({ const pillRef = useRef(null); // Re-fit when the set, the labels, OR the pressed state change (the pressed paint can change a chip's // width via the `.pill` recipe, so an aria-pressed flip must re-measure too). - const key = chips.map((c) => `${c.key}:${c.label}:${c.active ? 1 : 0}`).join("|"); + const key = chips + .map((c) => `${c.key}:${c.label}:${c.active ? 1 : 0}${c.pending ? "…" : ""}`) + .join("|"); const visible = useChipOverflowFit({ rowRef, @@ -101,7 +112,9 @@ export default function TagFilterChips({ }} className="inline-flex" > - {c.label} + + + ))} diff --git a/frontend/src/components/channelColumns.dom.test.tsx b/frontend/src/components/channelColumns.dom.test.tsx index 30c1f1ff..794db934 100644 --- a/frontend/src/components/channelColumns.dom.test.tsx +++ b/frontend/src/components/channelColumns.dom.test.tsx @@ -81,10 +81,15 @@ describe("tagsColumnTest (item B — 'No tag')", () => { describe("tagFilterChips — a pick whose tag is gone (E32 S2)", () => { const mine = { id: 1, name: "Music", system: false } as Tag; const t = ((k: string) => k) as unknown as TFunction; - const chips = (allTags: Tag[] | undefined, picked: string[]) => + type State = "loaded" | "loading" | "failed"; + const chips = (state: State, picked: string[]) => tagFilterChips({ - userTags: allTags ? [mine] : [], - allTags, + userTags: state === "loaded" ? [mine] : [], + tags: { + data: state === "loaded" ? [mine] : undefined, + isSuccess: state === "loaded", + isPending: state === "loading", + }, picked, noTagOn: false, toggle: () => {}, @@ -93,15 +98,27 @@ describe("tagFilterChips — a pick whose tag is gone (E32 S2)", () => { }); it("stays a visible, active chip instead of an empty table with no reason", () => { - expect(chips([mine], ["1", "77"]).map((c) => [c.key, c.label, c.active])).toEqual([ - ["t77", "channels.tags.deleted", true], // first: the "+N" overflow must not fold it away + expect(chips("loaded", ["1", "77"]).map((c) => [c.key, c.label, c.active])).toEqual([ + ["t77", "common.deletedTag", true], // first: the "+N" overflow must not fold it away ["t1", "Music", true], [NO_TAG_SENTINEL, "channels.tags.noTag", false], ]); }); - it("is not called deleted before the tag list has loaded", () => { - expect(chips(undefined, ["77"]).map((c) => c.key)).toEqual([NO_TAG_SENTINEL]); + // E32 review: before, a pick had NO chip at all until the list loaded — or ever, after a failed + // load — while it still narrowed the table. + it("is a placeholder while the tag list loads, never called deleted", () => { + expect(chips("loading", ["77"]).map((c) => [c.key, c.label, c.pending])).toEqual([ + ["t77", "77", true], + [NO_TAG_SENTINEL, "channels.tags.noTag", undefined], + ]); + }); + + it("shows the id after a failed load, still a visible, removable chip", () => { + expect(chips("failed", ["77"]).map((c) => [c.key, c.label, c.pending, c.active])).toEqual([ + ["t77", "77", false, true], + [NO_TAG_SENTINEL, "channels.tags.noTag", undefined, false], + ]); }); }); diff --git a/frontend/src/components/channelColumns.tsx b/frontend/src/components/channelColumns.tsx index c8cd4c61..1bbd8808 100644 --- a/frontend/src/components/channelColumns.tsx +++ b/frontend/src/components/channelColumns.tsx @@ -1,6 +1,7 @@ import type { ComponentProps, ReactNode } from "react"; import type { TFunction } from "i18next"; import { NO_TAG_SENTINEL, type ManagedChannel, type Tag } from "../lib/api"; +import { tagPickLabel, type TagList } from "../lib/tagPicks"; import { formatCountOrDash, formatTotalHours } from "../lib/format"; import type { Column } from "./ui/DataTable"; import { PriorityStepper } from "./ui/PriorityStepper"; @@ -50,25 +51,28 @@ export function tagsColumnTest(c: ManagedChannel, values: string[]): boolean { return values.some((v) => c.tag_ids.includes(Number(v))); } -/** The manager's tag-filter chip row (the Tags column filter's values): any picked id whose tag no - * longer exists, then each user tag, then the "No tag" sentinel. A stored pick can outlive its tag - * (deleted here or on another device) and still narrows the table to nothing, so it stays a visible, - * removable chip — FIRST, so the row's "+N" overflow never folds the reason away (E32 S2). */ +/** The manager's tag-filter chip row (the Tags column filter's values): any picked id that is not one + * of the user's tags, then each user tag, then the "No tag" sentinel. A stored pick can outlive its + * tag (deleted here or on another device) and still narrows the table to nothing, so it stays a + * visible, removable chip — FIRST, so the row's "+N" overflow never folds the reason away (E32 S2) — + * and so does every pick while the tag list loads or after it failed, named by lib/tagPicks' rule + * (a placeholder, then the name, "Deleted tag" or the id), not hidden (E32 review). */ export function tagFilterChips(o: { userTags: Tag[]; - allTags: Tag[] | undefined; + tags: TagList; picked: string[]; noTagOn: boolean; toggle: (tagId: number) => void; toggleNoTag: () => void; t: TFunction; }): FilterChip[] { - const known = new Set((o.allTags ?? []).map((tg) => String(tg.id))); - const gone = o.allTags ? o.picked.filter((id) => id !== NO_TAG_SENTINEL && !known.has(id)) : []; + const mine = new Set(o.userTags.map((tg) => String(tg.id))); + const others = o.picked.filter((id) => id !== NO_TAG_SENTINEL && !mine.has(id)); return [ - ...gone.map((id) => ({ + ...others.map((id) => ({ key: `t${id}`, - label: o.t("channels.tags.deleted"), + label: tagPickLabel(Number(id), o.tags, o.t), + pending: o.tags.isPending, active: true, onToggle: () => o.toggle(Number(id)), })), diff --git a/frontend/src/components/tagChipNames.dom.test.tsx b/frontend/src/components/tagChipNames.dom.test.tsx index 75dceb13..b6aa34fa 100644 --- a/frontend/src/components/tagChipNames.dom.test.tsx +++ b/frontend/src/components/tagChipNames.dom.test.tsx @@ -39,7 +39,7 @@ describe("a tag filter's chip names", () => { const chip = (id: number) => result.current.find((f) => f.key === `tag:${id}`)!; expect(chip(99).pending).toBe(true); await waitFor(() => expect(chip(1)).toMatchObject({ label: "Music", pending: false })); - expect(chip(99)).toMatchObject({ label: "feed.chips.deletedTag", pending: false }); + expect(chip(99)).toMatchObject({ label: "common.deletedTag", pending: false }); }); it("fall back to the id when the tag list fails, never claiming the tag was deleted", async () => { diff --git a/frontend/src/components/ui/Carousel.tsx b/frontend/src/components/ui/Carousel.tsx index c8bcd30c..d638ccc2 100644 --- a/frontend/src/components/ui/Carousel.tsx +++ b/frontend/src/components/ui/Carousel.tsx @@ -6,6 +6,7 @@ import { useScrollEdges } from "../../lib/useScrollEdges"; import { useListNav } from "../../lib/listNav"; import { Button } from "./Button"; import { IconButton } from "./IconButton"; +import { REDUCED_MOTION } from "../../lib/useMediaQuery"; // Shared horizontal rail — the one place the app's "row of cards you scroll sideways" lives, replacing // the hand-rolled `flex gap overflow-x-auto` strips (Cast / Related / collections) and the future @@ -19,7 +20,7 @@ function scrollBehavior(): ScrollBehavior { const reduce = typeof window !== "undefined" && typeof window.matchMedia === "function" && - window.matchMedia("(prefers-reduced-motion: reduce)").matches; + window.matchMedia(REDUCED_MOTION).matches; return reduce ? "auto" : "smooth"; } diff --git a/frontend/src/components/usePlexActiveFilters.dom.test.tsx b/frontend/src/components/usePlexActiveFilters.dom.test.tsx index d036e19f..b5ca2b93 100644 --- a/frontend/src/components/usePlexActiveFilters.dom.test.tsx +++ b/frontend/src/components/usePlexActiveFilters.dom.test.tsx @@ -85,6 +85,15 @@ describe("usePlexActiveFilters", () => { expect(byKey["collection:A"]).toBe("Marvel"); }); + it("marks a link-supplied collection chip pending while its title loads, not the known one", () => { + qc.removeQueries({ queryKey: qk.plexCollections("titles", "B") }); + vi.spyOn(api, "plexCollections").mockReturnValue(new Promise(() => {}) as never); + const { result } = renderHook(() => usePlexActiveFilters(), { wrapper }); + const pending = Object.fromEntries(result.current.map((c) => [c.key, c.pending])); + expect(pending["collection:B"]).toBe(true); + expect(pending["collection:A"]).toBe(false); + }); + it("removing the last collection chip prunes its title and collapses collectionTitles to undefined", () => { setFilters.mockClear(); const { result } = renderHook(() => usePlexActiveFilters(), { wrapper }); diff --git a/frontend/src/components/usePlexActiveFilters.ts b/frontend/src/components/usePlexActiveFilters.ts index 9dcc3b78..a9716b9e 100644 --- a/frontend/src/components/usePlexActiveFilters.ts +++ b/frontend/src/components/usePlexActiveFilters.ts @@ -23,8 +23,16 @@ export function usePlexActiveFilters(): ActiveFilter[] { const patch = (p: Partial) => setFilters({ ...filters, ...p }); // A link opened cold knows the collection ids but not their names — resolve the missing ones for // display (see usePlexCollectionTitles for why they are NOT written back into the filter state). - const collectionTitles = usePlexCollectionTitles(filters.collections, filters.collectionTitles); - return buildPlexActiveFilters(filters, show, patch, setShow, t, collectionTitles); + const collections = usePlexCollectionTitles(filters.collections, filters.collectionTitles); + return buildPlexActiveFilters( + filters, + show, + patch, + setShow, + t, + collections.titles, + collections.pending, + ); } // The pure builder behind the hook: turns a Plex filter set + watch-state into the source-agnostic @@ -39,6 +47,8 @@ export function buildPlexActiveFilters( /** Collection titles for the chip labels, resolved beyond what the filter state carries. Omitted by * the saved-views manager, which describes a stored view and has only the captured titles. */ collectionTitles?: Record, + /** The missing titles are still loading: their chips show a placeholder, not the generic word. */ + collectionsPending = false, ): ActiveFilter[] { const out: ActiveFilter[] = []; @@ -134,23 +144,7 @@ export function buildPlexActiveFilters( remove: () => patch({ keywords: filters.keywords.filter((x) => x !== k) }), }); } - for (const c of filters.collections) { - out.push({ - key: `collection:${c}`, - label: collectionTitles?.[c] ?? filters.collectionTitles?.[c] ?? t("plex.filter.collection"), - // Prune the title too — collectionTitles is hashed into the plex query keys, so a stale entry - // splits the cache from the logically-identical no-collection state (mirrors CollectionPicker). - // Collapse an empty map back to undefined so the last-removed state key-matches the pristine - // one (an empty `{}` still hashes differently from an absent key). - remove: () => { - const { [c]: _drop, ...restTitles } = filters.collectionTitles ?? {}; - patch({ - collections: filters.collections.filter((x) => x !== c), - collectionTitles: Object.keys(restTitles).length ? restTitles : undefined, - }); - }, - }); - } + out.push(...collectionChips(filters, patch, t, collectionTitles, collectionsPending)); if (show !== "all") { out.push({ key: "show", @@ -171,3 +165,30 @@ export function usePlexClearFilters(): () => void { setSort(PLEX_SORT_FALLBACK); }; } + +// One chip per selected collection, named by the resolved titles (a placeholder while a link's titles +// load), else by the ones the state carries, else the generic word. +function collectionChips( + filters: PlexFilters, + patch: (p: Partial) => void, + t: TFn, + collectionTitles: Record | undefined, + pending: boolean, +): ActiveFilter[] { + return filters.collections.map((c) => ({ + key: `collection:${c}`, + label: collectionTitles?.[c] ?? filters.collectionTitles?.[c] ?? t("plex.filter.collection"), + pending: pending && collectionTitles?.[c] === undefined, + // Prune the title too — collectionTitles is hashed into the plex query keys, so a stale entry + // splits the cache from the logically-identical no-collection state (mirrors CollectionPicker). + // Collapse an empty map back to undefined so the last-removed state key-matches the pristine + // one (an empty `{}` still hashes differently from an absent key). + remove: () => { + const { [c]: _drop, ...restTitles } = filters.collectionTitles ?? {}; + patch({ + collections: filters.collections.filter((x) => x !== c), + collectionTitles: Object.keys(restTitles).length ? restTitles : undefined, + }); + }, + })); +} diff --git a/frontend/src/i18n/backendIds.contract.test.ts b/frontend/src/i18n/backendIds.contract.test.ts index 96a69fad..f5f173e4 100644 --- a/frontend/src/i18n/backendIds.contract.test.ts +++ b/frontend/src/i18n/backendIds.contract.test.ts @@ -232,7 +232,7 @@ describe("backend ids have their names in both languages", () => { expect(failDetails).toEqual(expect.arrayContaining(["facebook.login_wall"])); expect(goneWhats).toEqual(expect.arrayContaining(["playlist", "download", "job"])); expect(opaqueWhats).toEqual([]); - expect(youtubeReasons).toEqual(["quota", "reconnect", "unavailable"]); + expect(youtubeReasons).toEqual(["quota", "reconnect", "unavailable", "gone"]); expect(crewJobs).toEqual( expect.arrayContaining(["Director", "Creator", "Director of Photography"]), ); diff --git a/frontend/src/i18n/locales/en/channels.json b/frontend/src/i18n/locales/en/channels.json index 13ab3c9c..3d9b956b 100644 --- a/frontend/src/i18n/locales/en/channels.json +++ b/frontend/src/i18n/locales/en/channels.json @@ -57,7 +57,6 @@ "createFailed": "Couldn't create the tag", "updateFailed": "Couldn't update the tag", "noTag": "No tag", - "deleted": "Deleted tag", "moreCount_one": "{{count}} more tag", "moreCount_other": "{{count}} more tags", "suggestedHint": "Suggested from your tagging pattern ({{pct}}% match) — click to add", diff --git a/frontend/src/i18n/locales/en/common.json b/frontend/src/i18n/locales/en/common.json index 50212267..10017eef 100644 --- a/frontend/src/i18n/locales/en/common.json +++ b/frontend/src/i18n/locales/en/common.json @@ -26,6 +26,7 @@ "retry": "Retry", "retryWithEnter": "Press Enter to try again.", "loadError": "Couldn't load this — check your connection and try again", + "deletedTag": "Deleted tag", "seeAll": "See all", "scrollLeft": "Scroll left", "scrollRight": "Scroll right", diff --git a/frontend/src/i18n/locales/en/feed.json b/frontend/src/i18n/locales/en/feed.json index 638037b5..80f14326 100644 --- a/frontend/src/i18n/locales/en/feed.json +++ b/frontend/src/i18n/locales/en/feed.json @@ -4,7 +4,6 @@ "remove": "Remove filter: {{name}}", "contentNone": "No content types", "untagged": "Untagged", - "deletedTag": "Deleted tag", "count_one": "{{count}} filter", "count_other": "{{count}} filters", "moreCount_one": "{{count}} more filter", diff --git a/frontend/src/i18n/locales/hu/channels.json b/frontend/src/i18n/locales/hu/channels.json index db3f6bf8..f73a6dd6 100644 --- a/frontend/src/i18n/locales/hu/channels.json +++ b/frontend/src/i18n/locales/hu/channels.json @@ -57,7 +57,6 @@ "createFailed": "Nem sikerült létrehozni a címkét", "updateFailed": "Nem sikerült frissíteni a címkét", "noTag": "Nincs címke", - "deleted": "Törölt címke", "moreCount_one": "még {{count}} címke", "moreCount_other": "még {{count}} címke", "suggestedHint": "A címkézési mintád alapján javasolt ({{pct}}% egyezés) — kattints a hozzáadáshoz", diff --git a/frontend/src/i18n/locales/hu/common.json b/frontend/src/i18n/locales/hu/common.json index e50e5c1d..0e0c00f3 100644 --- a/frontend/src/i18n/locales/hu/common.json +++ b/frontend/src/i18n/locales/hu/common.json @@ -26,6 +26,7 @@ "retry": "Újra", "retryWithEnter": "Újrapróbáláshoz nyomd meg az Entert.", "loadError": "Nem sikerült betölteni — ellenőrizd a kapcsolatot, és próbáld újra", + "deletedTag": "Törölt címke", "seeAll": "Összes", "scrollLeft": "Görgetés balra", "scrollRight": "Görgetés jobbra", diff --git a/frontend/src/i18n/locales/hu/feed.json b/frontend/src/i18n/locales/hu/feed.json index 7b970023..38a7c63d 100644 --- a/frontend/src/i18n/locales/hu/feed.json +++ b/frontend/src/i18n/locales/hu/feed.json @@ -4,7 +4,6 @@ "remove": "Szűrő eltávolítása: {{name}}", "contentNone": "Nincs tartalomtípus", "untagged": "Címkézetlen", - "deletedTag": "Törölt címke", "count_one": "{{count}} szűrő", "count_other": "{{count}} szűrő", "moreCount_one": "még {{count}} szűrő", diff --git a/frontend/src/lib/chatDock.dom.test.ts b/frontend/src/lib/chatDock.dom.test.ts new file mode 100644 index 00000000..adaf4e7f --- /dev/null +++ b/frontend/src/lib/chatDock.dom.test.ts @@ -0,0 +1,52 @@ +// @vitest-environment jsdom +// +// The floating chat dock's store (E32 review: moved onto lib/store's createStore). It restores the +// signed-in user's windows, keeps at most three, flashes a window a message arrives for, and +// persists everything but the transient flash counter. +import { describe, expect, it } from "vitest"; +import { act, renderHook } from "@testing-library/react"; +import { + closeChat, + initDock, + notifyIncoming, + openChat, + toggleMinimize, + useDockChats, +} from "./chatDock"; +import { LS } from "./storage"; + +const partner = (id: number) => ({ id, name: `P${id}`, avatar_url: null }); + +describe("the chat dock", () => { + it("restores, caps, flashes and persists the user's windows", () => { + localStorage.setItem( + LS.chatDock(7), + JSON.stringify([{ partnerId: 1, name: "P1", avatar: null, minimized: true }]), + ); + const { result } = renderHook(() => useDockChats()); + act(() => initDock(7)); + expect(result.current).toEqual([ + { partnerId: 1, name: "P1", avatar: null, minimized: true, flash: 0 }, + ]); + + act(() => notifyIncoming(partner(1))); // already open: flash it, keep it minimised + expect(result.current[0]).toMatchObject({ partnerId: 1, minimized: true, flash: 1 }); + + act(() => { + for (const id of [2, 3, 4]) openChat(partner(id)); + }); + expect(result.current.map((c) => c.partnerId)).toEqual([4, 3, 2]); // newest first, three at most + + act(() => toggleMinimize(3)); + act(() => closeChat(2)); + expect(result.current.map((c) => [c.partnerId, c.minimized])).toEqual([ + [4, false], + [3, true], + ]); + const stored = JSON.parse(localStorage.getItem(LS.chatDock(7))!) as object[]; + expect(stored).toEqual([ + { partnerId: 4, name: "P4", avatar: null, minimized: false }, + { partnerId: 3, name: "P3", avatar: null, minimized: true }, + ]); + }); +}); diff --git a/frontend/src/lib/chatDock.ts b/frontend/src/lib/chatDock.ts index 43558904..2f8216c6 100644 --- a/frontend/src/lib/chatDock.ts +++ b/frontend/src/lib/chatDock.ts @@ -2,8 +2,8 @@ // A window can be opened from anywhere (a conversation's "pop out" button, or automatically // when a message arrives) and stays open across page navigation. The dock state (which chats // are open + their minimised state) is persisted per user so it survives a reload. -import { useSyncExternalStore } from "react"; import { LS } from "./storage"; +import { createStore } from "./store"; export interface DockChat { partnerId: number; @@ -16,9 +16,8 @@ export interface DockChat { type PartnerLike = { id: number; name: string; avatar_url: string | null }; const MAX_OPEN = 3; // keep the dock from overflowing the corner -let chats: DockChat[] = []; +const dock = createStore([]); let userId: number | null = null; -const subs = new Set<() => void>(); function storageKey(): string | null { return userId != null ? LS.chatDock(userId) : null; @@ -29,16 +28,15 @@ function persist(): void { if (!k) return; try { // Don't persist the transient flash counter. - localStorage.setItem(k, JSON.stringify(chats.map(({ flash, ...c }) => c))); + localStorage.setItem(k, JSON.stringify(dock.get().map(({ flash, ...c }) => c))); } catch { /* ignore quota/serialization errors */ } } function emit(next: DockChat[]): void { - chats = next; + dock.set(next); persist(); - for (const s of subs) s(); } // Bind the dock to the signed-in user and restore their persisted windows (once per user). @@ -53,15 +51,17 @@ export function initDock(uid: number): void { } catch { restored = []; } - chats = restored.slice(0, MAX_OPEN); - for (const s of subs) s(); + dock.set(restored.slice(0, MAX_OPEN)); } export function openChat(partner: PartnerLike): void { - const existing = chats.find((c) => c.partnerId === partner.id); + const existing = dock.get().find((c) => c.partnerId === partner.id); if (existing) { // Re-opening focuses it: ensure expanded and move to the front. - emit([{ ...existing, minimized: false }, ...chats.filter((c) => c.partnerId !== partner.id)]); + emit([ + { ...existing, minimized: false }, + ...dock.get().filter((c) => c.partnerId !== partner.id), + ]); return; } emit( @@ -73,7 +73,7 @@ export function openChat(partner: PartnerLike): void { minimized: false, flash: 0, }, - ...chats, + ...dock.get(), ].slice(0, MAX_OPEN), ); } @@ -81,7 +81,7 @@ export function openChat(partner: PartnerLike): void { // An incoming message arrived from `partner`: open the window if it's closed, otherwise just // flash it (whether expanded or minimised — don't disturb the minimised state). export function notifyIncoming(partner: PartnerLike): void { - const existing = chats.find((c) => c.partnerId === partner.id); + const existing = dock.get().find((c) => c.partnerId === partner.id); if (!existing) { emit( [ @@ -92,29 +92,22 @@ export function notifyIncoming(partner: PartnerLike): void { minimized: false, flash: 1, }, - ...chats, + ...dock.get(), ].slice(0, MAX_OPEN), ); return; } - emit(chats.map((c) => (c.partnerId === partner.id ? { ...c, flash: c.flash + 1 } : c))); + emit(dock.get().map((c) => (c.partnerId === partner.id ? { ...c, flash: c.flash + 1 } : c))); } export function closeChat(partnerId: number): void { - emit(chats.filter((c) => c.partnerId !== partnerId)); + emit(dock.get().filter((c) => c.partnerId !== partnerId)); } export function toggleMinimize(partnerId: number): void { - emit(chats.map((c) => (c.partnerId === partnerId ? { ...c, minimized: !c.minimized } : c))); + emit(dock.get().map((c) => (c.partnerId === partnerId ? { ...c, minimized: !c.minimized } : c))); } export function useDockChats(): DockChat[] { - return useSyncExternalStore( - (cb) => { - subs.add(cb); - return () => subs.delete(cb); - }, - () => chats, - () => chats, - ); + return dock.useValue(); } diff --git a/frontend/src/lib/plexDetail.ts b/frontend/src/lib/plexDetail.ts index ece8475b..f3ef8d31 100644 --- a/frontend/src/lib/plexDetail.ts +++ b/frontend/src/lib/plexDetail.ts @@ -1,4 +1,5 @@ -import { useLayoutEffect, useSyncExternalStore } from "react"; +import { useLayoutEffect } from "react"; +import { REDUCED_MOTION, useMediaQuery } from "./useMediaQuery"; import { usePref, useSavePref } from "./usePref"; import type { TFunction } from "i18next"; import type { PlexCard, PlexTmdb } from "./api"; @@ -7,18 +8,6 @@ import type { PlexCard, PlexTmdb } from "./api"; // prefs, the TMDB backdrop URL, the crossfading art backdrop hook and the episode label. Split out so // plexDetailUi exports only components (Fast Refresh). -const REDUCED_MOTION = "(prefers-reduced-motion: reduce)"; -const reducedMotion = () => - typeof window !== "undefined" && - typeof window.matchMedia === "function" && - window.matchMedia(REDUCED_MOTION).matches; -const onReducedMotionChange = (cb: () => void) => { - if (typeof window === "undefined" || typeof window.matchMedia !== "function") return () => {}; - const m = window.matchMedia(REDUCED_MOTION); - m.addEventListener("change", cb); - return () => m.removeEventListener("change", cb); -}; - export function useDetailPrefs() { // Three per-user detail-page toggles, each a server pref adopted on login (usePref). Default ON: // undefined → true, only an explicit false hides. The detail pages mount after `me` is cached, so @@ -31,7 +20,7 @@ export function useDetailPrefs() { // Windows also reports whenever its animation effects are off). Once the reader turns it on or off // here, that pick wins on every device: the owner never saw the crossfade, because the system signal // used to switch it off whatever the toggle said (2026-10-03). The toggle shows the state in force. - const reduce = useSyncExternalStore(onReducedMotionChange, reducedMotion, () => false); + const reduce = useMediaQuery(REDUCED_MOTION, false); const [crossfadePick, setCrossfade] = usePref("plexInfoArtCrossfade", null, { parse: (raw) => (typeof raw === "boolean" ? raw : null), }); diff --git a/frontend/src/lib/tagPicks.ts b/frontend/src/lib/tagPicks.ts new file mode 100644 index 00000000..106eb143 --- /dev/null +++ b/frontend/src/lib/tagPicks.ts @@ -0,0 +1,22 @@ +import type { TFunction } from "i18next"; +import type { Tag } from "./api"; + +// A saved view or a stored filter can outlive its tag (deleted here or on another device). The filter +// then matches nothing (E32 S2), so its chip must say why instead of reading as a bare id — but only +// once the tag list has loaded: while it loads the chip is a placeholder (`isPending`), and if the load +// failed it shows the id, never a guessed "deleted". One rule for the feed's applied-filter row and the +// channel manager's tag row, which had each written their own and drifted (E32 review). + +/** The tag list as react-query hands it over — only what the rule reads. */ +export interface TagList { + data?: Tag[]; + isSuccess: boolean; + isPending: boolean; +} + +/** A picked tag's chip label: its name, "Deleted tag" once the list says it is gone, else the id. */ +export function tagPickLabel(id: number, tags: TagList, t: TFunction): string { + const tag = tags.data?.find((x) => x.id === id); + if (tag) return tag.name; + return tags.isSuccess ? t("common.deletedTag") : String(id); +} diff --git a/frontend/src/lib/useActiveFilters.ts b/frontend/src/lib/useActiveFilters.ts index 94b59cf6..c37fd6fd 100644 --- a/frontend/src/lib/useActiveFilters.ts +++ b/frontend/src/lib/useActiveFilters.ts @@ -1,6 +1,6 @@ -import type { TFunction } from "i18next"; import { useTranslation } from "react-i18next"; -import { DEFAULT_TAG_MODES, type FeedFilters, type Tag } from "./api"; +import { DEFAULT_TAG_MODES, type FeedFilters } from "./api"; +import { tagPickLabel } from "./tagPicks"; import { useChannelNames } from "./useChannelNames"; import { parseSort } from "./feedSort"; import { useTags } from "./useTags"; @@ -36,15 +36,6 @@ export function clearedFilters(current: FeedFilters): FeedFilters { return { ...DEFAULT_SIDEBAR_FILTERS, q: current.q, scope: "my" }; } -// A tag chip's name. A saved view or the stored filter can outlive its tag: the feed then matches -// nothing (E32 S2), and the chip says why instead of reading as a bare id — but only once the tag -// list has loaded; while it loads (a placeholder) or if it failed, the id, never a guessed "deleted". -function tagLabel(id: number, tags: { data?: Tag[]; isSuccess: boolean }, t: TFunction): string { - const tag = tags.data?.find((x) => x.id === id); - if (tag) return tag.name; - return tags.isSuccess ? t("feed.chips.deletedTag") : String(id); -} - /** * The single source of truth for "what is currently narrowing the feed" — it backs BOTH the side * panel's active-count badge and the removable chip row above the feed, so the two can never @@ -68,7 +59,7 @@ export function useActiveFilters( for (const id of filters.tags) { out.push({ key: `tag:${id}`, - label: tagLabel(id, tagsQuery, t), + label: tagPickLabel(id, tagsQuery, t), pending: tagsQuery.isPending, remove: () => setFilters({ ...filters, tags: filters.tags.filter((x) => x !== id) }), }); diff --git a/frontend/src/lib/useMediaQuery.ts b/frontend/src/lib/useMediaQuery.ts index 0b8929ed..849aeb2e 100644 --- a/frontend/src/lib/useMediaQuery.ts +++ b/frontend/src/lib/useMediaQuery.ts @@ -1,5 +1,9 @@ import { useSyncExternalStore } from "react"; +/** The reader asked the system for less motion (Windows also reports it whenever its animation + * effects are off). One spelling for the hook and for a one-off `matchMedia` read alike. */ +export const REDUCED_MOTION = "(prefers-reduced-motion: reduce)"; + /** Whether a CSS media query matches, live. For a component that must RENDER differently at a * breakpoint rather than merely hide one of two renders with CSS (`ui/DataTable` mounted its table * AND its card list at every width: double the DOM, duplicate ids and keyboard targets). Where diff --git a/frontend/src/lib/usePlexCollectionTitles.dom.test.tsx b/frontend/src/lib/usePlexCollectionTitles.dom.test.tsx index 1064a41a..32223a48 100644 --- a/frontend/src/lib/usePlexCollectionTitles.dom.test.tsx +++ b/frontend/src/lib/usePlexCollectionTitles.dom.test.tsx @@ -44,7 +44,7 @@ describe("usePlexCollectionTitles", () => { wrapper: wrap(), }); await waitFor(() => - expect(result.current["17045"]).toBe("Pirates of the Caribbean Collection"), + expect(result.current.titles["17045"]).toBe("Pirates of the Caribbean Collection"), ); // Only the missing ids are asked for — nothing else. expect(plexCollections).toHaveBeenCalledWith(undefined, undefined, ["17045"]); @@ -55,7 +55,7 @@ describe("usePlexCollectionTitles", () => { const { result } = renderHook(() => usePlexCollectionTitles(["17045"], known), { wrapper: wrap(), }); - expect(result.current["17045"]).toBe("Pirates of the Caribbean Collection"); + expect(result.current.titles["17045"]).toBe("Pirates of the Caribbean Collection"); // Give a would-be fetch a chance to happen before asserting it did not. await new Promise((r) => setTimeout(r, 20)); expect(plexCollections).not.toHaveBeenCalled(); @@ -66,10 +66,10 @@ describe("usePlexCollectionTitles", () => { () => usePlexCollectionTitles(["17045", "68231"], { "17045": "Already Known" }), { wrapper: wrap() }, ); - await waitFor(() => expect(result.current["68231"]).toBe("IMDb Top 250")); + await waitFor(() => expect(result.current.titles["68231"]).toBe("IMDb Top 250")); // The known id is not in the request — and its stored title survives the merge. expect(plexCollections).toHaveBeenCalledWith(undefined, undefined, ["68231"]); - expect(result.current["17045"]).toBe("Already Known"); + expect(result.current.titles["17045"]).toBe("Already Known"); }); it("treats the same ids in a different order as one query", async () => { @@ -77,12 +77,12 @@ describe("usePlexCollectionTitles", () => { const a = renderHook(() => usePlexCollectionTitles(["68231", "17045"], undefined), { wrapper: w, }); - await waitFor(() => expect(a.result.current["17045"]).toBeDefined()); + await waitFor(() => expect(a.result.current.titles["17045"]).toBeDefined()); const calls = plexCollections.mock.calls.length; const b = renderHook(() => usePlexCollectionTitles(["17045", "68231"], undefined), { wrapper: w, }); - await waitFor(() => expect(b.result.current["17045"]).toBeDefined()); + await waitFor(() => expect(b.result.current.titles["17045"]).toBeDefined()); expect(plexCollections.mock.calls.length).toBe(calls); }); @@ -91,7 +91,21 @@ describe("usePlexCollectionTitles", () => { const { result } = renderHook(() => usePlexCollectionTitles(["17045"], undefined), { wrapper: wrap(), }); - // The caller falls back to its own label; it must not be handed a half-built map. - expect(result.current["17045"]).toBeUndefined(); + // The caller falls back to its own label; it must not be handed a half-built map — and it is + // told the names are on their way, so it draws a placeholder rather than the raw id. + expect(result.current.titles["17045"]).toBeUndefined(); + expect(result.current.pending).toBe(true); + }); + + it("is not pending when nothing is missing, nor once the lookup answered", async () => { + const known = renderHook(() => usePlexCollectionTitles(["17045"], { "17045": "Known" }), { + wrapper: wrap(), + }); + expect(known.result.current.pending).toBe(false); + const cold = renderHook(() => usePlexCollectionTitles(["17045"], undefined), { + wrapper: wrap(), + }); + await waitFor(() => expect(cold.result.current.pending).toBe(false)); + expect(cold.result.current.titles["17045"]).toBe("Pirates of the Caribbean Collection"); }); }); diff --git a/frontend/src/lib/usePlexCollectionTitles.ts b/frontend/src/lib/usePlexCollectionTitles.ts index ec2c380c..8467b2dc 100644 --- a/frontend/src/lib/usePlexCollectionTitles.ts +++ b/frontend/src/lib/usePlexCollectionTitles.ts @@ -10,11 +10,13 @@ import { qk } from "./queryKeys"; // This resolves the missing ones from the server and merges them over what the filter state already // knows. It stays OUT of `collectionTitles`: that map is hashed into the Plex query keys, so writing a // resolved title back in would split the cache from the logically identical state and refetch the grid -// for a label. Titles are display-only, so they belong in a display-only read. +// for a label. Titles are display-only, so they belong in a display-only read. `pending` says the +// missing ones are still on their way, so a token or chip can show a placeholder rather than the raw id +// (Combobox `labelsPending`, ActiveFilter `pending` — the feed's channel picker does the same). export function usePlexCollectionTitles( ids: string[], known: Record | undefined, -): Record { +): { titles: Record; pending: boolean } { // Sorted so the same set of ids in a different order is the same query. const missing = ids.filter((id) => !known?.[id]).sort(); const q = useQuery({ @@ -25,7 +27,7 @@ export function usePlexCollectionTitles( // of the sidebar and the chip row. staleTime: 60 * 60_000, }); - const out: Record = { ...known }; - for (const c of q.data?.collections ?? []) out[c.id] = c.title; - return out; + const titles: Record = { ...known }; + for (const c of q.data?.collections ?? []) titles[c.id] = c.title; + return { titles, pending: missing.length > 0 && q.isPending }; }