import { useMutation, useQueryClient } from "@tanstack/react-query"; import { useTranslation } from "react-i18next"; import { ArrowUpDown, EyeOff, ExternalLink, History, Tags, Tv, UserMinus } from "lucide-react"; import { qk } from "../lib/queryKeys"; import { invalidateSubscription } from "../lib/subscriptionCache"; import { api, type ManagedChannel, type Tag } from "../lib/api"; import { priorityChoices } from "../lib/channelOrder"; import { compareText } from "../lib/collate"; import { channelYouTubeUrl } from "../lib/format"; import { notify } from "../lib/notifications"; import { notifyYouTubeActionError } from "../lib/youtubeErrors"; import { useMe } from "../lib/useMe"; import { useConfirm } from "./confirmContext"; import { useCopyItems } from "./copyItems"; import { type ActionMenuItem } from "./ui/ActionMenu"; // A subscribed channel's ONE action list: the manager row's "…", its right-click, long-press // and Shift+F10, in the table and in the card layout alike. The row keeps what the reader looks at // or tunes all the time (priority, tags, the stored count's ✓); everything that was a button on the // row — full history, hide, unsubscribe — is here, beside open, the pin and the copies. Tags and // priority are here TOO, as submenus (owner, 2026-09-26): tagging a run of channels, or bubbling one // up the list, from the menu the hand is already in. export function useChannelActions({ onView, onOpenWizard, tags, tagsState = "ready", onToggleTag, onPickPriority, }: { onView: (c: ManagedChannel) => void; onOpenWizard: () => void; /** The reader's own tags (no system tags) — the Tags submenu lists them A–Z. */ tags: readonly Tag[]; /** Whether `tags` is the answer yet: loading or failed is said as such, never as "no tags yet". */ tagsState?: "loading" | "error" | "ready"; onToggleTag: (c: ManagedChannel, tagId: number) => void; /** A priority step picked: the caller re-sorts its list and follows the row. */ onPickPriority: (c: ManagedChannel, priority: number) => void; }) { const qc = useQueryClient(); const { t, i18n } = useTranslation(); const lang = i18n.language; const confirm = useConfirm(); const me = useMe(); const isAdmin = me.role === "admin"; const copy = useCopyItems(); const nameOf = (c: ManagedChannel) => c.title ?? t("channels.row.thisChannel"); const refresh = () => { qc.invalidateQueries({ queryKey: qk.channels() }); qc.invalidateQueries({ queryKey: qk.myStatus() }); qc.invalidateQueries({ queryKey: qk.feed() }); qc.invalidateQueries({ queryKey: qk.feedSummary() }); }; const patch = useMutation({ mutationFn: (v: { id: string; body: { hidden?: boolean; deep_requested?: boolean } }) => api.updateChannel(v.id, v.body), meta: { failTitle: "errors.writeFailed.channelSettings" }, onSuccess: refresh, }); // Hiding moves the channel to the Hidden tab, out of sight: the toast's Undo is the way back from // where the reader is, so hiding needs no confirm (the same bargain as a playlist row's Remove). const hide = useMutation({ mutationFn: (c: ManagedChannel) => api.updateChannel(c.id, { hidden: true }), meta: { failTitle: "errors.writeFailed.hideChannel" }, onSuccess: (_d, c) => { refresh(); notify({ level: "success", message: t("channels.notify.hidden", { name: nameOf(c) }), action: { label: t("common.undo"), onClick: () => patch.mutate({ id: c.id, body: { hidden: false } }), }, }); }, }); const unsubscribe = useMutation({ mutationFn: (c: ManagedChannel) => api.unsubscribeChannel(c.id), onSuccess: (_d, c) => { invalidateSubscription(qc, c.id); notify({ level: "success", message: t("channels.notify.unsubscribed", { name: nameOf(c) }) }); }, onError: (e) => notifyYouTubeActionError(e, t("channels.notify.unsubscribeFailed"), onOpenWizard), }); const resetBackfill = useMutation({ mutationFn: (c: ManagedChannel) => api.resetChannelBackfill(c.id), onSuccess: (_d, c) => { qc.invalidateQueries({ queryKey: qk.channels() }); qc.invalidateQueries({ queryKey: qk.myStatus() }); notify({ level: "success", message: t("channels.notify.resetDone", { name: nameOf(c) }) }); }, onError: (e) => notifyYouTubeActionError(e, t("channels.notify.resetFailed"), onOpenWizard), }); async function onUnsubscribe(c: ManagedChannel) { const ok = await confirm({ title: t("channels.row.unsubscribeOnYoutube"), message: t("channels.confirmUnsubscribe", { name: c.title ?? c.id }), confirmLabel: t("channels.row.unsubscribeOnYoutube"), danger: true, }); if (ok) unsubscribe.mutate(c); } async function onReset(c: ManagedChannel) { const ok = await confirm({ title: t("channels.row.resetBackfill"), message: t("channels.confirmReset", { name: c.title ?? c.id }), confirmLabel: t("channels.row.resetBackfill"), }); if (ok) resetBackfill.mutate(c); } /** The full-history row. An admin re-fetches any channel from scratch (a confirm: it spends * quota). A reader asks for the back-catalogue, or takes the ask back; once it is in, or someone * else's ask already brings it, the row stays in place, off, and says why. */ function backfillItem(c: ManagedChannel): ActionMenuItem { const icon = ; if (isAdmin) return { id: "backfill", label: t("channels.row.resetBackfill"), title: t("channels.row.resetHint"), icon, disabled: resetBackfill.isPending, onSelect: () => void onReset(c), }; const requested = !c.backfill_done && c.deep_requested; const note = c.backfill_done ? t("channels.row.fullHint") : c.deep_in_queue && !requested ? t("channels.row.queuedByOtherHint") : undefined; return { id: "backfill", label: requested ? t("channels.row.cancelBackfill") : t("channels.row.backfillThis"), title: requested ? t("channels.row.queuedRequestedHint") : t("channels.row.backfillThisHint"), icon, disabled: !!note || patch.isPending, note, onSelect: () => patch.mutate({ id: c.id, body: { deep_requested: !requested } }), }; } /** Tags A–Z, one check each, toggled in place (the menu stays open for the next one). With no tag * yet the row stays, off, and says where tags are made. */ function tagsItem(c: ManagedChannel): ActionMenuItem { const base = { id: "tags", label: t("channels.tags.label"), icon: , }; if (tags.length === 0) { const note = tagsState === "loading" ? t("common.loading") : tagsState === "error" ? t("common.loadError") : t("channels.row.noTagsYet"); return { ...base, disabled: true, note, onSelect: () => {} }; } const on = new Set(c.tag_ids); return { ...base, items: [...tags] .sort((a, b) => compareText(a.name, b.name, lang)) .map((tg) => ({ id: `tag-${tg.id}`, label: tg.name, check: { kind: "many" as const, checked: on.has(tg.id) }, onSelect: () => onToggleTag(c, tg.id), })), }; } /** The fixed priority steps, the current one ticked; a pick re-sorts the list (the caller). */ function priorityItem(c: ManagedChannel): ActionMenuItem { return { id: "priority", label: t("channels.row.priority"), icon: , items: priorityChoices(c.priority).map((p) => ({ id: `priority-${p}`, label: String(p), check: { kind: "one" as const, checked: p === c.priority }, onSelect: () => { if (p !== c.priority) onPickPriority(c, p); }, })), }; } /** The channel's menu rows; `pin` is the caller's comfort-shelf row (its "is it pinned?" read * follows the caller's own menu). */ function items(c: ManagedChannel, { pin }: { pin: ActionMenuItem[] }): ActionMenuItem[] { const url = channelYouTubeUrl(c.id, c.handle); return [ { id: "open", label: t("card.menu.openChannel"), icon: , onSelect: () => onView(c), }, ...pin, { ...tagsItem(c), separatorBefore: true }, priorityItem(c), ...[ { id: "open-youtube", label: t("channels.row.openOnYouTube"), icon: , // A new tab only opens inside the click (a popup blocker refuses one a task later). inGesture: true, onSelect: () => void window.open(url, "_blank", "noopener,noreferrer"), }, ...copy(c.title, url, "name"), ].map((it, i) => ({ ...it, separatorBefore: i === 0 })), { ...backfillItem(c), separatorBefore: true }, { id: "hide", label: t("channels.row.hideFromFeed"), title: t("channels.row.hideHint"), icon: , disabled: hide.isPending, onSelect: () => hide.mutate(c), }, ...(me.can_write ? [ { id: "unsubscribe", label: t("channels.row.unsubscribeOnYoutube"), title: t("channels.row.unsubscribeHint"), icon: , danger: true, separatorBefore: true, onSelect: () => void onUnsubscribe(c), }, ] : []), ]; } /** Unhide from the Hidden tab — the same flag, the same refresh. */ const unhide = (id: string) => patch.mutate({ id, body: { hidden: false } }); return { items, unhide }; }