refactor(ui): reuse the store, media-query and deleted-tag rules the review found rebuilt, and name a loading pick
E32 review round 1 (F3–F6). - Channels' open-row store and the chat dock use lib/store's createStore instead of a hand-rolled listener set (the chat dock gets a test of its own). - The Plex backdrop reads prefers-reduced-motion through useMediaQuery; the query string is one constant (REDUCED_MOTION) for the hook and the carousel's one-off read. - A link-restored collection filter showed its raw rating_key in the picker while the titles loaded: usePlexCollectionTitles now says it is pending, the picker draws a placeholder (labelsPending, as the channel picker does) and so does the chip row. - The "deleted tag" rule was written twice with two i18n keys and had drifted: the channel manager showed NO chip for a pick while the tag list loaded, or ever after a failed load, while the pick still narrowed the table. One rule now (lib/tagPicks), one key (common.deletedTag); the manager's row shows every pick, as a placeholder while loading and as its id after a failure.
This commit is contained in:
@@ -9,6 +9,7 @@ import {
|
|||||||
} from "react";
|
} from "react";
|
||||||
import { useTranslation } from "react-i18next";
|
import { useTranslation } from "react-i18next";
|
||||||
import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query";
|
import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query";
|
||||||
|
import { createStore, type Store } from "../lib/store";
|
||||||
import { qk } from "../lib/queryKeys";
|
import { qk } from "../lib/queryKeys";
|
||||||
import { Ban, Eye, EyeOff, History, LayoutGrid, Pencil, RefreshCw, Table, X } from "lucide-react";
|
import { Ban, Eye, EyeOff, History, LayoutGrid, Pencil, RefreshCw, Table, X } from "lucide-react";
|
||||||
import ExcludedChannelList from "./ExcludedChannelList";
|
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
|
// 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
|
// 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
|
// ms on 400 rows, each open and close), while a store calls its subscribers and re-renders only the
|
||||||
// two buttons whose answer changed.
|
// two buttons whose answer changed (each reads a boolean, so a change elsewhere renders nothing).
|
||||||
function createOpenRowStore() {
|
type OpenRowStore = Store<string | null>;
|
||||||
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<typeof createOpenRowStore>;
|
|
||||||
|
|
||||||
function RowMoreButton({
|
function RowMoreButton({
|
||||||
id,
|
id,
|
||||||
@@ -349,7 +334,7 @@ export default function Channels() {
|
|||||||
// stays one line and the trailing "…" actions can't wrap.
|
// stays one line and the trailing "…" actions can't wrap.
|
||||||
const tagChips = tagFilterChips({
|
const tagChips = tagFilterChips({
|
||||||
userTags,
|
userTags,
|
||||||
allTags,
|
tags: tagsQuery,
|
||||||
picked: tagFilter,
|
picked: tagFilter,
|
||||||
noTagOn,
|
noTagOn,
|
||||||
toggle: toggleTagFilter,
|
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.
|
// 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 { buttonProps: rowButtonProps, triggerProps: rowTriggerProps } = rowMenu;
|
||||||
const openRowId = rowMenu.open ? (rowMenu.target?.id ?? null) : null;
|
const openRowId = rowMenu.open ? (rowMenu.target?.id ?? null) : null;
|
||||||
const [openRow] = useState(createOpenRowStore);
|
const [openRow] = useState(() => createStore<string | null>(null));
|
||||||
useEffect(() => openRow.set(openRowId), [openRow, openRowId]);
|
useEffect(() => openRow.set(openRowId), [openRow, openRowId]);
|
||||||
const channelActions = useChannelActions({
|
const channelActions = useChannelActions({
|
||||||
onView,
|
onView,
|
||||||
|
|||||||
@@ -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
|
// …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
|
// once the picker is opened — so the token rendered the raw rating_key until then. Resolve just the
|
||||||
// selected ids (see usePlexCollectionTitles).
|
// selected ids (see usePlexCollectionTitles).
|
||||||
const resolved = usePlexCollectionTitles(values, titles);
|
const { titles: resolved, pending } = usePlexCollectionTitles(values, titles);
|
||||||
const options: ComboboxOption[] = (q.data?.collections ?? []).map((c) => ({
|
const options: ComboboxOption[] = (q.data?.collections ?? []).map((c) => ({
|
||||||
value: c.id,
|
value: c.id,
|
||||||
label: c.title,
|
label: c.title,
|
||||||
@@ -459,6 +459,7 @@ function CollectionPicker({
|
|||||||
options={options}
|
options={options}
|
||||||
selected={values}
|
selected={values}
|
||||||
selectedLabels={resolved}
|
selectedLabels={resolved}
|
||||||
|
labelsPending={pending}
|
||||||
query={search}
|
query={search}
|
||||||
onQueryChange={setSearch}
|
onQueryChange={setSearch}
|
||||||
onOpenChange={setOpen}
|
onOpenChange={setOpen}
|
||||||
|
|||||||
@@ -7,6 +7,7 @@ import { chipClass } from "./ui/chipStyles";
|
|||||||
import { PopoverSurface } from "./ui/AnchoredPopover";
|
import { PopoverSurface } from "./ui/AnchoredPopover";
|
||||||
import { useAnchoredPopover } from "./ui/anchoredPopoverHooks";
|
import { useAnchoredPopover } from "./ui/anchoredPopoverHooks";
|
||||||
import { SURFACE } from "./ui/surfaces";
|
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
|
// 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
|
// 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 {
|
export interface FilterChip {
|
||||||
key: string;
|
key: string;
|
||||||
label: string;
|
label: string;
|
||||||
|
/** The name is still on its way: the chip draws a placeholder, not `label` (a raw id). */
|
||||||
|
pending?: boolean;
|
||||||
active: boolean;
|
active: boolean;
|
||||||
onToggle: () => void;
|
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 ? <ChipLabelPending value={chip.label} /> : <>{chip.label}</>;
|
||||||
|
}
|
||||||
|
|
||||||
function TagChip({ chip }: { chip: FilterChip }) {
|
function TagChip({ chip }: { chip: FilterChip }) {
|
||||||
return (
|
return (
|
||||||
<Chip onClick={chip.onToggle} pressed={chip.active}>
|
<Chip onClick={chip.onToggle} pressed={chip.active}>
|
||||||
{chip.label}
|
<ChipText chip={chip} />
|
||||||
</Chip>
|
</Chip>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
@@ -43,7 +52,9 @@ export default function TagFilterChips({
|
|||||||
const pillRef = useRef<HTMLSpanElement | null>(null);
|
const pillRef = useRef<HTMLSpanElement | null>(null);
|
||||||
// Re-fit when the set, the labels, OR the pressed state change (the pressed paint can change a chip's
|
// 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).
|
// 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<number>({
|
const visible = useChipOverflowFit<number>({
|
||||||
rowRef,
|
rowRef,
|
||||||
@@ -101,7 +112,9 @@ export default function TagFilterChips({
|
|||||||
}}
|
}}
|
||||||
className="inline-flex"
|
className="inline-flex"
|
||||||
>
|
>
|
||||||
<span className={chipClass()}>{c.label}</span>
|
<span className={chipClass()}>
|
||||||
|
<ChipText chip={c} />
|
||||||
|
</span>
|
||||||
</span>
|
</span>
|
||||||
))}
|
))}
|
||||||
<span ref={pillRef} className={chipClass()}>
|
<span ref={pillRef} className={chipClass()}>
|
||||||
|
|||||||
@@ -81,10 +81,15 @@ describe("tagsColumnTest (item B — 'No tag')", () => {
|
|||||||
describe("tagFilterChips — a pick whose tag is gone (E32 S2)", () => {
|
describe("tagFilterChips — a pick whose tag is gone (E32 S2)", () => {
|
||||||
const mine = { id: 1, name: "Music", system: false } as Tag;
|
const mine = { id: 1, name: "Music", system: false } as Tag;
|
||||||
const t = ((k: string) => k) as unknown as TFunction;
|
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({
|
tagFilterChips({
|
||||||
userTags: allTags ? [mine] : [],
|
userTags: state === "loaded" ? [mine] : [],
|
||||||
allTags,
|
tags: {
|
||||||
|
data: state === "loaded" ? [mine] : undefined,
|
||||||
|
isSuccess: state === "loaded",
|
||||||
|
isPending: state === "loading",
|
||||||
|
},
|
||||||
picked,
|
picked,
|
||||||
noTagOn: false,
|
noTagOn: false,
|
||||||
toggle: () => {},
|
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", () => {
|
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([
|
expect(chips("loaded", ["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
|
["t77", "common.deletedTag", true], // first: the "+N" overflow must not fold it away
|
||||||
["t1", "Music", true],
|
["t1", "Music", true],
|
||||||
[NO_TAG_SENTINEL, "channels.tags.noTag", false],
|
[NO_TAG_SENTINEL, "channels.tags.noTag", false],
|
||||||
]);
|
]);
|
||||||
});
|
});
|
||||||
|
|
||||||
it("is not called deleted before the tag list has loaded", () => {
|
// E32 review: before, a pick had NO chip at all until the list loaded — or ever, after a failed
|
||||||
expect(chips(undefined, ["77"]).map((c) => c.key)).toEqual([NO_TAG_SENTINEL]);
|
// 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],
|
||||||
|
]);
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|||||||
@@ -1,6 +1,7 @@
|
|||||||
import type { ComponentProps, ReactNode } from "react";
|
import type { ComponentProps, ReactNode } from "react";
|
||||||
import type { TFunction } from "i18next";
|
import type { TFunction } from "i18next";
|
||||||
import { NO_TAG_SENTINEL, type ManagedChannel, type Tag } from "../lib/api";
|
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 { formatCountOrDash, formatTotalHours } from "../lib/format";
|
||||||
import type { Column } from "./ui/DataTable";
|
import type { Column } from "./ui/DataTable";
|
||||||
import { PriorityStepper } from "./ui/PriorityStepper";
|
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)));
|
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
|
/** The manager's tag-filter chip row (the Tags column filter's values): any picked id that is not one
|
||||||
* longer exists, then each user tag, then the "No tag" sentinel. A stored pick can outlive its tag
|
* of the user's tags, then each user tag, then the "No tag" sentinel. A stored pick can outlive its
|
||||||
* (deleted here or on another device) and still narrows the table to nothing, so it stays a visible,
|
* tag (deleted here or on another device) and still narrows the table to nothing, so it stays a
|
||||||
* removable chip — FIRST, so the row's "+N" overflow never folds the reason away (E32 S2). */
|
* 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: {
|
export function tagFilterChips(o: {
|
||||||
userTags: Tag[];
|
userTags: Tag[];
|
||||||
allTags: Tag[] | undefined;
|
tags: TagList;
|
||||||
picked: string[];
|
picked: string[];
|
||||||
noTagOn: boolean;
|
noTagOn: boolean;
|
||||||
toggle: (tagId: number) => void;
|
toggle: (tagId: number) => void;
|
||||||
toggleNoTag: () => void;
|
toggleNoTag: () => void;
|
||||||
t: TFunction;
|
t: TFunction;
|
||||||
}): FilterChip[] {
|
}): FilterChip[] {
|
||||||
const known = new Set((o.allTags ?? []).map((tg) => String(tg.id)));
|
const mine = new Set(o.userTags.map((tg) => String(tg.id)));
|
||||||
const gone = o.allTags ? o.picked.filter((id) => id !== NO_TAG_SENTINEL && !known.has(id)) : [];
|
const others = o.picked.filter((id) => id !== NO_TAG_SENTINEL && !mine.has(id));
|
||||||
return [
|
return [
|
||||||
...gone.map((id) => ({
|
...others.map((id) => ({
|
||||||
key: `t${id}`,
|
key: `t${id}`,
|
||||||
label: o.t("channels.tags.deleted"),
|
label: tagPickLabel(Number(id), o.tags, o.t),
|
||||||
|
pending: o.tags.isPending,
|
||||||
active: true,
|
active: true,
|
||||||
onToggle: () => o.toggle(Number(id)),
|
onToggle: () => o.toggle(Number(id)),
|
||||||
})),
|
})),
|
||||||
|
|||||||
@@ -39,7 +39,7 @@ describe("a tag filter's chip names", () => {
|
|||||||
const chip = (id: number) => result.current.find((f) => f.key === `tag:${id}`)!;
|
const chip = (id: number) => result.current.find((f) => f.key === `tag:${id}`)!;
|
||||||
expect(chip(99).pending).toBe(true);
|
expect(chip(99).pending).toBe(true);
|
||||||
await waitFor(() => expect(chip(1)).toMatchObject({ label: "Music", pending: false }));
|
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 () => {
|
it("fall back to the id when the tag list fails, never claiming the tag was deleted", async () => {
|
||||||
|
|||||||
@@ -6,6 +6,7 @@ import { useScrollEdges } from "../../lib/useScrollEdges";
|
|||||||
import { useListNav } from "../../lib/listNav";
|
import { useListNav } from "../../lib/listNav";
|
||||||
import { Button } from "./Button";
|
import { Button } from "./Button";
|
||||||
import { IconButton } from "./IconButton";
|
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
|
// 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
|
// the hand-rolled `flex gap overflow-x-auto` strips (Cast / Related / collections) and the future
|
||||||
@@ -19,7 +20,7 @@ function scrollBehavior(): ScrollBehavior {
|
|||||||
const reduce =
|
const reduce =
|
||||||
typeof window !== "undefined" &&
|
typeof window !== "undefined" &&
|
||||||
typeof window.matchMedia === "function" &&
|
typeof window.matchMedia === "function" &&
|
||||||
window.matchMedia("(prefers-reduced-motion: reduce)").matches;
|
window.matchMedia(REDUCED_MOTION).matches;
|
||||||
return reduce ? "auto" : "smooth";
|
return reduce ? "auto" : "smooth";
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -85,6 +85,15 @@ describe("usePlexActiveFilters", () => {
|
|||||||
expect(byKey["collection:A"]).toBe("Marvel");
|
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", () => {
|
it("removing the last collection chip prunes its title and collapses collectionTitles to undefined", () => {
|
||||||
setFilters.mockClear();
|
setFilters.mockClear();
|
||||||
const { result } = renderHook(() => usePlexActiveFilters(), { wrapper });
|
const { result } = renderHook(() => usePlexActiveFilters(), { wrapper });
|
||||||
|
|||||||
@@ -23,8 +23,16 @@ export function usePlexActiveFilters(): ActiveFilter[] {
|
|||||||
const patch = (p: Partial<PlexFilters>) => setFilters({ ...filters, ...p });
|
const patch = (p: Partial<PlexFilters>) => setFilters({ ...filters, ...p });
|
||||||
// A link opened cold knows the collection ids but not their names — resolve the missing ones for
|
// 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).
|
// display (see usePlexCollectionTitles for why they are NOT written back into the filter state).
|
||||||
const collectionTitles = usePlexCollectionTitles(filters.collections, filters.collectionTitles);
|
const collections = usePlexCollectionTitles(filters.collections, filters.collectionTitles);
|
||||||
return buildPlexActiveFilters(filters, show, patch, setShow, t, 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
|
// 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
|
/** 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. */
|
* the saved-views manager, which describes a stored view and has only the captured titles. */
|
||||||
collectionTitles?: Record<string, string>,
|
collectionTitles?: Record<string, string>,
|
||||||
|
/** The missing titles are still loading: their chips show a placeholder, not the generic word. */
|
||||||
|
collectionsPending = false,
|
||||||
): ActiveFilter[] {
|
): ActiveFilter[] {
|
||||||
const out: ActiveFilter[] = [];
|
const out: ActiveFilter[] = [];
|
||||||
|
|
||||||
@@ -134,23 +144,7 @@ export function buildPlexActiveFilters(
|
|||||||
remove: () => patch({ keywords: filters.keywords.filter((x) => x !== k) }),
|
remove: () => patch({ keywords: filters.keywords.filter((x) => x !== k) }),
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
for (const c of filters.collections) {
|
out.push(...collectionChips(filters, patch, t, collectionTitles, collectionsPending));
|
||||||
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,
|
|
||||||
});
|
|
||||||
},
|
|
||||||
});
|
|
||||||
}
|
|
||||||
if (show !== "all") {
|
if (show !== "all") {
|
||||||
out.push({
|
out.push({
|
||||||
key: "show",
|
key: "show",
|
||||||
@@ -171,3 +165,30 @@ export function usePlexClearFilters(): () => void {
|
|||||||
setSort(PLEX_SORT_FALLBACK);
|
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<PlexFilters>) => void,
|
||||||
|
t: TFn,
|
||||||
|
collectionTitles: Record<string, string> | 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,
|
||||||
|
});
|
||||||
|
},
|
||||||
|
}));
|
||||||
|
}
|
||||||
|
|||||||
@@ -232,7 +232,7 @@ describe("backend ids have their names in both languages", () => {
|
|||||||
expect(failDetails).toEqual(expect.arrayContaining(["facebook.login_wall"]));
|
expect(failDetails).toEqual(expect.arrayContaining(["facebook.login_wall"]));
|
||||||
expect(goneWhats).toEqual(expect.arrayContaining(["playlist", "download", "job"]));
|
expect(goneWhats).toEqual(expect.arrayContaining(["playlist", "download", "job"]));
|
||||||
expect(opaqueWhats).toEqual([]);
|
expect(opaqueWhats).toEqual([]);
|
||||||
expect(youtubeReasons).toEqual(["quota", "reconnect", "unavailable"]);
|
expect(youtubeReasons).toEqual(["quota", "reconnect", "unavailable", "gone"]);
|
||||||
expect(crewJobs).toEqual(
|
expect(crewJobs).toEqual(
|
||||||
expect.arrayContaining(["Director", "Creator", "Director of Photography"]),
|
expect.arrayContaining(["Director", "Creator", "Director of Photography"]),
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -57,7 +57,6 @@
|
|||||||
"createFailed": "Couldn't create the tag",
|
"createFailed": "Couldn't create the tag",
|
||||||
"updateFailed": "Couldn't update the tag",
|
"updateFailed": "Couldn't update the tag",
|
||||||
"noTag": "No tag",
|
"noTag": "No tag",
|
||||||
"deleted": "Deleted tag",
|
|
||||||
"moreCount_one": "{{count}} more tag",
|
"moreCount_one": "{{count}} more tag",
|
||||||
"moreCount_other": "{{count}} more tags",
|
"moreCount_other": "{{count}} more tags",
|
||||||
"suggestedHint": "Suggested from your tagging pattern ({{pct}}% match) — click to add",
|
"suggestedHint": "Suggested from your tagging pattern ({{pct}}% match) — click to add",
|
||||||
|
|||||||
@@ -26,6 +26,7 @@
|
|||||||
"retry": "Retry",
|
"retry": "Retry",
|
||||||
"retryWithEnter": "Press Enter to try again.",
|
"retryWithEnter": "Press Enter to try again.",
|
||||||
"loadError": "Couldn't load this — check your connection and try again",
|
"loadError": "Couldn't load this — check your connection and try again",
|
||||||
|
"deletedTag": "Deleted tag",
|
||||||
"seeAll": "See all",
|
"seeAll": "See all",
|
||||||
"scrollLeft": "Scroll left",
|
"scrollLeft": "Scroll left",
|
||||||
"scrollRight": "Scroll right",
|
"scrollRight": "Scroll right",
|
||||||
|
|||||||
@@ -4,7 +4,6 @@
|
|||||||
"remove": "Remove filter: {{name}}",
|
"remove": "Remove filter: {{name}}",
|
||||||
"contentNone": "No content types",
|
"contentNone": "No content types",
|
||||||
"untagged": "Untagged",
|
"untagged": "Untagged",
|
||||||
"deletedTag": "Deleted tag",
|
|
||||||
"count_one": "{{count}} filter",
|
"count_one": "{{count}} filter",
|
||||||
"count_other": "{{count}} filters",
|
"count_other": "{{count}} filters",
|
||||||
"moreCount_one": "{{count}} more filter",
|
"moreCount_one": "{{count}} more filter",
|
||||||
|
|||||||
@@ -57,7 +57,6 @@
|
|||||||
"createFailed": "Nem sikerült létrehozni a címkét",
|
"createFailed": "Nem sikerült létrehozni a címkét",
|
||||||
"updateFailed": "Nem sikerült frissíteni a címkét",
|
"updateFailed": "Nem sikerült frissíteni a címkét",
|
||||||
"noTag": "Nincs címke",
|
"noTag": "Nincs címke",
|
||||||
"deleted": "Törölt címke",
|
|
||||||
"moreCount_one": "még {{count}} címke",
|
"moreCount_one": "még {{count}} címke",
|
||||||
"moreCount_other": "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",
|
"suggestedHint": "A címkézési mintád alapján javasolt ({{pct}}% egyezés) — kattints a hozzáadáshoz",
|
||||||
|
|||||||
@@ -26,6 +26,7 @@
|
|||||||
"retry": "Újra",
|
"retry": "Újra",
|
||||||
"retryWithEnter": "Újrapróbáláshoz nyomd meg az Entert.",
|
"retryWithEnter": "Újrapróbáláshoz nyomd meg az Entert.",
|
||||||
"loadError": "Nem sikerült betölteni — ellenőrizd a kapcsolatot, és próbáld újra",
|
"loadError": "Nem sikerült betölteni — ellenőrizd a kapcsolatot, és próbáld újra",
|
||||||
|
"deletedTag": "Törölt címke",
|
||||||
"seeAll": "Összes",
|
"seeAll": "Összes",
|
||||||
"scrollLeft": "Görgetés balra",
|
"scrollLeft": "Görgetés balra",
|
||||||
"scrollRight": "Görgetés jobbra",
|
"scrollRight": "Görgetés jobbra",
|
||||||
|
|||||||
@@ -4,7 +4,6 @@
|
|||||||
"remove": "Szűrő eltávolítása: {{name}}",
|
"remove": "Szűrő eltávolítása: {{name}}",
|
||||||
"contentNone": "Nincs tartalomtípus",
|
"contentNone": "Nincs tartalomtípus",
|
||||||
"untagged": "Címkézetlen",
|
"untagged": "Címkézetlen",
|
||||||
"deletedTag": "Törölt címke",
|
|
||||||
"count_one": "{{count}} szűrő",
|
"count_one": "{{count}} szűrő",
|
||||||
"count_other": "{{count}} szűrő",
|
"count_other": "{{count}} szűrő",
|
||||||
"moreCount_one": "még {{count}} szűrő",
|
"moreCount_one": "még {{count}} szűrő",
|
||||||
|
|||||||
@@ -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 },
|
||||||
|
]);
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -2,8 +2,8 @@
|
|||||||
// A window can be opened from anywhere (a conversation's "pop out" button, or automatically
|
// 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
|
// 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.
|
// are open + their minimised state) is persisted per user so it survives a reload.
|
||||||
import { useSyncExternalStore } from "react";
|
|
||||||
import { LS } from "./storage";
|
import { LS } from "./storage";
|
||||||
|
import { createStore } from "./store";
|
||||||
|
|
||||||
export interface DockChat {
|
export interface DockChat {
|
||||||
partnerId: number;
|
partnerId: number;
|
||||||
@@ -16,9 +16,8 @@ export interface DockChat {
|
|||||||
type PartnerLike = { id: number; name: string; avatar_url: string | null };
|
type PartnerLike = { id: number; name: string; avatar_url: string | null };
|
||||||
|
|
||||||
const MAX_OPEN = 3; // keep the dock from overflowing the corner
|
const MAX_OPEN = 3; // keep the dock from overflowing the corner
|
||||||
let chats: DockChat[] = [];
|
const dock = createStore<DockChat[]>([]);
|
||||||
let userId: number | null = null;
|
let userId: number | null = null;
|
||||||
const subs = new Set<() => void>();
|
|
||||||
|
|
||||||
function storageKey(): string | null {
|
function storageKey(): string | null {
|
||||||
return userId != null ? LS.chatDock(userId) : null;
|
return userId != null ? LS.chatDock(userId) : null;
|
||||||
@@ -29,16 +28,15 @@ function persist(): void {
|
|||||||
if (!k) return;
|
if (!k) return;
|
||||||
try {
|
try {
|
||||||
// Don't persist the transient flash counter.
|
// 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 {
|
} catch {
|
||||||
/* ignore quota/serialization errors */
|
/* ignore quota/serialization errors */
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
function emit(next: DockChat[]): void {
|
function emit(next: DockChat[]): void {
|
||||||
chats = next;
|
dock.set(next);
|
||||||
persist();
|
persist();
|
||||||
for (const s of subs) s();
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// Bind the dock to the signed-in user and restore their persisted windows (once per user).
|
// 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 {
|
} catch {
|
||||||
restored = [];
|
restored = [];
|
||||||
}
|
}
|
||||||
chats = restored.slice(0, MAX_OPEN);
|
dock.set(restored.slice(0, MAX_OPEN));
|
||||||
for (const s of subs) s();
|
|
||||||
}
|
}
|
||||||
|
|
||||||
export function openChat(partner: PartnerLike): void {
|
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) {
|
if (existing) {
|
||||||
// Re-opening focuses it: ensure expanded and move to the front.
|
// 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;
|
return;
|
||||||
}
|
}
|
||||||
emit(
|
emit(
|
||||||
@@ -73,7 +73,7 @@ export function openChat(partner: PartnerLike): void {
|
|||||||
minimized: false,
|
minimized: false,
|
||||||
flash: 0,
|
flash: 0,
|
||||||
},
|
},
|
||||||
...chats,
|
...dock.get(),
|
||||||
].slice(0, MAX_OPEN),
|
].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
|
// 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).
|
// flash it (whether expanded or minimised — don't disturb the minimised state).
|
||||||
export function notifyIncoming(partner: PartnerLike): void {
|
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) {
|
if (!existing) {
|
||||||
emit(
|
emit(
|
||||||
[
|
[
|
||||||
@@ -92,29 +92,22 @@ export function notifyIncoming(partner: PartnerLike): void {
|
|||||||
minimized: false,
|
minimized: false,
|
||||||
flash: 1,
|
flash: 1,
|
||||||
},
|
},
|
||||||
...chats,
|
...dock.get(),
|
||||||
].slice(0, MAX_OPEN),
|
].slice(0, MAX_OPEN),
|
||||||
);
|
);
|
||||||
return;
|
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 {
|
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 {
|
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[] {
|
export function useDockChats(): DockChat[] {
|
||||||
return useSyncExternalStore(
|
return dock.useValue();
|
||||||
(cb) => {
|
|
||||||
subs.add(cb);
|
|
||||||
return () => subs.delete(cb);
|
|
||||||
},
|
|
||||||
() => chats,
|
|
||||||
() => chats,
|
|
||||||
);
|
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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 { usePref, useSavePref } from "./usePref";
|
||||||
import type { TFunction } from "i18next";
|
import type { TFunction } from "i18next";
|
||||||
import type { PlexCard, PlexTmdb } from "./api";
|
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
|
// prefs, the TMDB backdrop URL, the crossfading art backdrop hook and the episode label. Split out so
|
||||||
// plexDetailUi exports only components (Fast Refresh).
|
// 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() {
|
export function useDetailPrefs() {
|
||||||
// Three per-user detail-page toggles, each a server pref adopted on login (usePref). Default ON:
|
// 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
|
// 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
|
// 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
|
// 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.
|
// 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<boolean | null>("plexInfoArtCrossfade", null, {
|
const [crossfadePick, setCrossfade] = usePref<boolean | null>("plexInfoArtCrossfade", null, {
|
||||||
parse: (raw) => (typeof raw === "boolean" ? raw : null),
|
parse: (raw) => (typeof raw === "boolean" ? raw : null),
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -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);
|
||||||
|
}
|
||||||
@@ -1,6 +1,6 @@
|
|||||||
import type { TFunction } from "i18next";
|
|
||||||
import { useTranslation } from "react-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 { useChannelNames } from "./useChannelNames";
|
||||||
import { parseSort } from "./feedSort";
|
import { parseSort } from "./feedSort";
|
||||||
import { useTags } from "./useTags";
|
import { useTags } from "./useTags";
|
||||||
@@ -36,15 +36,6 @@ export function clearedFilters(current: FeedFilters): FeedFilters {
|
|||||||
return { ...DEFAULT_SIDEBAR_FILTERS, q: current.q, scope: "my" };
|
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
|
* 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
|
* 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) {
|
for (const id of filters.tags) {
|
||||||
out.push({
|
out.push({
|
||||||
key: `tag:${id}`,
|
key: `tag:${id}`,
|
||||||
label: tagLabel(id, tagsQuery, t),
|
label: tagPickLabel(id, tagsQuery, t),
|
||||||
pending: tagsQuery.isPending,
|
pending: tagsQuery.isPending,
|
||||||
remove: () => setFilters({ ...filters, tags: filters.tags.filter((x) => x !== id) }),
|
remove: () => setFilters({ ...filters, tags: filters.tags.filter((x) => x !== id) }),
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -1,5 +1,9 @@
|
|||||||
import { useSyncExternalStore } from "react";
|
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
|
/** 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
|
* 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
|
* AND its card list at every width: double the DOM, duplicate ids and keyboard targets). Where
|
||||||
|
|||||||
@@ -44,7 +44,7 @@ describe("usePlexCollectionTitles", () => {
|
|||||||
wrapper: wrap(),
|
wrapper: wrap(),
|
||||||
});
|
});
|
||||||
await waitFor(() =>
|
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.
|
// Only the missing ids are asked for — nothing else.
|
||||||
expect(plexCollections).toHaveBeenCalledWith(undefined, undefined, ["17045"]);
|
expect(plexCollections).toHaveBeenCalledWith(undefined, undefined, ["17045"]);
|
||||||
@@ -55,7 +55,7 @@ describe("usePlexCollectionTitles", () => {
|
|||||||
const { result } = renderHook(() => usePlexCollectionTitles(["17045"], known), {
|
const { result } = renderHook(() => usePlexCollectionTitles(["17045"], known), {
|
||||||
wrapper: wrap(),
|
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.
|
// Give a would-be fetch a chance to happen before asserting it did not.
|
||||||
await new Promise((r) => setTimeout(r, 20));
|
await new Promise((r) => setTimeout(r, 20));
|
||||||
expect(plexCollections).not.toHaveBeenCalled();
|
expect(plexCollections).not.toHaveBeenCalled();
|
||||||
@@ -66,10 +66,10 @@ describe("usePlexCollectionTitles", () => {
|
|||||||
() => usePlexCollectionTitles(["17045", "68231"], { "17045": "Already Known" }),
|
() => usePlexCollectionTitles(["17045", "68231"], { "17045": "Already Known" }),
|
||||||
{ wrapper: wrap() },
|
{ 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.
|
// The known id is not in the request — and its stored title survives the merge.
|
||||||
expect(plexCollections).toHaveBeenCalledWith(undefined, undefined, ["68231"]);
|
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 () => {
|
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), {
|
const a = renderHook(() => usePlexCollectionTitles(["68231", "17045"], undefined), {
|
||||||
wrapper: w,
|
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 calls = plexCollections.mock.calls.length;
|
||||||
const b = renderHook(() => usePlexCollectionTitles(["17045", "68231"], undefined), {
|
const b = renderHook(() => usePlexCollectionTitles(["17045", "68231"], undefined), {
|
||||||
wrapper: w,
|
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);
|
expect(plexCollections.mock.calls.length).toBe(calls);
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -91,7 +91,21 @@ describe("usePlexCollectionTitles", () => {
|
|||||||
const { result } = renderHook(() => usePlexCollectionTitles(["17045"], undefined), {
|
const { result } = renderHook(() => usePlexCollectionTitles(["17045"], undefined), {
|
||||||
wrapper: wrap(),
|
wrapper: wrap(),
|
||||||
});
|
});
|
||||||
// The caller falls back to its own label; it must not be handed a half-built map.
|
// The caller falls back to its own label; it must not be handed a half-built map — and it is
|
||||||
expect(result.current["17045"]).toBeUndefined();
|
// 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");
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -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
|
// 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
|
// 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
|
// 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(
|
export function usePlexCollectionTitles(
|
||||||
ids: string[],
|
ids: string[],
|
||||||
known: Record<string, string> | undefined,
|
known: Record<string, string> | undefined,
|
||||||
): Record<string, string> {
|
): { titles: Record<string, string>; pending: boolean } {
|
||||||
// Sorted so the same set of ids in a different order is the same query.
|
// Sorted so the same set of ids in a different order is the same query.
|
||||||
const missing = ids.filter((id) => !known?.[id]).sort();
|
const missing = ids.filter((id) => !known?.[id]).sort();
|
||||||
const q = useQuery({
|
const q = useQuery({
|
||||||
@@ -25,7 +27,7 @@ export function usePlexCollectionTitles(
|
|||||||
// of the sidebar and the chip row.
|
// of the sidebar and the chip row.
|
||||||
staleTime: 60 * 60_000,
|
staleTime: 60 * 60_000,
|
||||||
});
|
});
|
||||||
const out: Record<string, string> = { ...known };
|
const titles: Record<string, string> = { ...known };
|
||||||
for (const c of q.data?.collections ?? []) out[c.id] = c.title;
|
for (const c of q.data?.collections ?? []) titles[c.id] = c.title;
|
||||||
return out;
|
return { titles, pending: missing.length > 0 && q.isPending };
|
||||||
}
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user