import { useCallback, useState } from "react"; import { useTranslation } from "react-i18next"; import { useQuery, useQueryClient } from "@tanstack/react-query"; import { Loader2, Pin, PinOff } from "lucide-react"; import { api, type DiscoverCard, type PinKind } from "../lib/api"; import { notifyActionError } from "../lib/actionErrors"; import { notify } from "../lib/notifications"; import { qk } from "../lib/queryKeys"; import { type ActionMenuItem } from "./ui/ActionMenu"; // E24 S3 — "put this on my comfort shelf", from wherever you happen to be looking at the thing. // // The shelf is the Home surface's object, but the GESTURE belongs wherever a pinnable thing is // shown — a video, a channel, a playlist, a Plex show or movie: you decide something is comfort media // while you are watching it. (Locked decision 3: an episode, a season and a person are not pin kinds, // so their cards and strips carry no pin.) This file is the one place the pin lives; every surface // composes the same `ActionMenuItem` into the menu it already has (`Playlists`, `PlaylistsRail`) or // mounts the `ComfortPinMenu` wrapper when it has none. // // Feature layer, not `components/ui/`: it speaks a domain API slice, which the platform boundary // (eslint `no-restricted-imports`) forbids a `ui/` primitive from doing. The MENU SURFACE that // renders this item lives in `ComfortPinMenu.tsx` — the same hook/component split, for the same // react-refresh reason, as `useContextMenu.ts` beside `ContextMenu.tsx`. /** What the API needs to pin something: the kind, and the id the rest of the API already speaks for * that kind (a video id, a channel id, a Plex `rating_key`, a playlist id). Mirrors * `app/discover/pins.py::PIN_COLUMNS`. */ export interface PinTarget { kind: PinKind; ref: string; } // Constructors rather than five call sites writing the kind strings by hand. The mapping from a // domain object to its pin kind is an invariant, so it lives in a function — a `"plex_show"` typo'd // as `"plex-show"` at one of eight call sites would be a 404 nobody notices until they try it. export const pinVideo = (videoId: string): PinTarget => ({ kind: "video", ref: videoId }); export const pinChannel = (channelId: string): PinTarget => ({ kind: "channel", ref: channelId }); export const pinPlaylist = (playlistId: number): PinTarget => ({ kind: "playlist", ref: String(playlistId), }); /** A Plex tile. A SHOW is pinned as a show ("put that series on" resolves to whichever episode is * next); a movie or a single episode is pinned as the leaf item it is. */ export const pinPlex = (card: { id: string; type: string }): PinTarget => ({ kind: card.type === "show" ? "plex_show" : "plex_item", ref: card.id, }); /** A unified discover card back to the thing it was pinned as — the inverse of the mapping in * `app/discover/cards.py`. Null for a kind that cannot be pinned. */ export function pinTargetForCard( card: Pick, ): PinTarget | null { if (card.source === "plex") return pinPlex({ id: card.id, type: card.kind }); if (card.kind === "video") return pinVideo(card.id); if (card.kind === "channel") return pinChannel(card.id); if (card.kind === "playlist") return { kind: "playlist", ref: card.id }; return null; } /** Where a shelf move lands. The shelf is a HORIZONTAL rail, so the words are left/right rather than * the playlist table's up/down — the same four moves, named after what the user actually sees. */ export type ShelfMove = "first" | "left" | "right" | "last"; /** `from` -> the index the move targets, or -1 when there is nowhere to go. Pure, so the disabled * state of a menu row and the move it would perform are decided by ONE rule instead of two that can * disagree (a row that looks available and then no-ops is the worst of the three states). */ export function shelfMoveTarget(from: number, to: ShelfMove, length: number): number { if (from < 0 || from >= length) return -1; const target = to === "first" ? 0 : to === "last" ? length - 1 : to === "left" ? from - 1 : from + 1; return target === from || target < 0 || target >= length ? -1 : target; } /** The identity a target is looked up by. One function, so "is this pinned?" and "what did the * server give back?" can never key the shelf two different ways. */ const targetKey = (target: PinTarget): string => `${target.kind}:${target.ref}`; export interface ComfortPin { /** `pinIdOf` and `toggle` stay module-local until a second consumer needs them: publishing them * invites the next surface to re-derive the three-state loading dance (pinned / not pinned / * not known yet) that `item` already encapsulates, which is what this file exists to prevent. */ remove: (pinId: number, title: string) => void; /** The pin/unpin row, ready to spread into any `ActionMenuItem[]`. Empty for a null target. */ item: (target: PinTarget | null, title: string) => ActionMenuItem[]; } /** * The shelf's client half. * * `open` is the host menu's own open flag. The shelf is only fetched once a menu has actually been * opened (and stays cached afterwards), because the pinned-ness of a row is needed to LABEL one menu * item and nothing else — firing `/api/discover/pins`, which resolves a `next` per non-leaf pin, on * every feed load would be a real request for a string almost nobody reads that session. */ export function useComfortPin(open: boolean): ComfortPin { const { t } = useTranslation(); const qc = useQueryClient(); // Latch: react-query keeps the data cached when `enabled` goes false again, so this only decides // WHEN the first fetch happens, not whether the answer survives the menu closing. const [everOpen, setEverOpen] = useState(false); // Latched during render: the query below is gated on it, so an effect delayed the first fetch by // a commit for no reason. Convergent — the write falsifies its own condition. if (open && !everOpen) setEverOpen(true); const shelf = useQuery({ queryKey: qk.comfortPins(), queryFn: () => api.comfortPins(), enabled: everOpen, // The shelf is hand-curated and short; re-asking on every menu open would be noise. staleTime: 60_000, }); const pins = shelf.data?.items; const pinIdOf = useCallback( (target: PinTarget): number | null | undefined => { if (!pins) return undefined; const key = targetKey(target); for (const card of pins) { const other = pinTargetForCard(card); if (other && targetKey(other) === key) return card.pin_id ?? null; } return null; }, [pins], ); const invalidate = useCallback(() => { void qc.invalidateQueries({ queryKey: qk.comfortPins() }); // The prefix, not an exact key: the Home payload is per-budget, and pinning something can change // the hero as well as the shelf. void qc.invalidateQueries({ queryKey: qk.discover() }); }, [qc]); const add = useCallback( async (target: PinTarget, title: string) => { try { await api.addComfortPin(target.kind, target.ref); // The shelf lives on another page, so without this the click has no visible consequence at // all from the feed — the one place a toast genuinely carries the whole feedback. notify({ level: "success", message: t("home.pin.added", { title }) }); } catch (e) { notifyActionError(e, t("home.pin.addFailed")); } invalidate(); }, [invalidate, t], ); const remove = useCallback( (pinId: number, title: string) => { void (async () => { try { await api.removeComfortPin(pinId); notify({ level: "success", message: t("home.pin.removed", { title }) }); } catch (e) { notifyActionError(e, t("home.pin.removeFailed")); } invalidate(); })(); }, [invalidate, t], ); const toggle = useCallback( (target: PinTarget, title: string) => { const pinId = pinIdOf(target); if (pinId == null) void add(target, title); else remove(pinId, title); }, [add, pinIdOf, remove], ); const item = useCallback( (target: PinTarget | null, title: string): ActionMenuItem[] => { if (!target) return []; const pinId = pinIdOf(target); // Still loading: an honest "we don't know yet" row rather than a confident "Pin" over // something that is already on the shelf. Only ever seen on the first menu open of a session. if (pinId === undefined) return [ { id: "comfort-pin", label: t("home.pin.add"), icon: , separatorBefore: true, disabled: true, onSelect: () => {}, }, ]; return [ { id: "comfort-pin", label: pinId === null ? t("home.pin.add") : t("home.pin.remove"), icon: pinId === null ? : , separatorBefore: true, // One row, two states, and only ONE of them is destructive — so `danger` flips with the // label and the icon rather than being decided at a call site. The shelf's own hand-rolled // unpin row on Home got the flag first; leaving this one neutral painted the identical // command (same label, same icon, same `removeComfortPin`) two different colours on one // screen — the hero's menu and the Continue-watching rail come through here (round 2). danger: pinId !== null, onSelect: () => toggle(target, title), }, ]; }, [pinIdOf, t, toggle], ); return { remove, item }; }