Every locale formatter takes the language as an argument. Under the React
Compiler a formatter that read the i18n singleton was memoized on its other
arguments, so a card kept "2 hr 4 min" after a switch to Hungarian; plexLang()
had the same flaw and left Plex queries in the old language. Components now
take the language from useTranslation, and the signature makes forgetting it
a type error.
- ratings, file sizes, stream stats: the language's decimal mark ("8,3")
- view and subscriber counts compact in the language ("1,2 E")
- seconds as the language's unit ("6 mp") in the sliders and the stats
- a person's birth and death dates as dates, not ISO strings
- A-Z lists by the shown name in the language's alphabet (compareText),
the tag catalog re-sorted by its translated names
- lint local/locale-format (was date-format) also refuses Intl.NumberFormat
outside lib/format.ts, localeCompare, and a component reading the language
off the i18n singleton
253 lines
9.6 KiB
TypeScript
253 lines
9.6 KiB
TypeScript
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 (E28 C1): 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"
|
||
* (E28 review J3). */
|
||
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 = <History className="w-4 h-4" />;
|
||
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: <Tags className="w-4 h-4" />,
|
||
};
|
||
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: <ArrowUpDown className="w-4 h-4" />,
|
||
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: <Tv className="w-4 h-4" />,
|
||
onSelect: () => onView(c),
|
||
},
|
||
...pin,
|
||
{ ...tagsItem(c), separatorBefore: true },
|
||
priorityItem(c),
|
||
...[
|
||
{
|
||
id: "open-youtube",
|
||
label: t("channels.row.openOnYouTube"),
|
||
icon: <ExternalLink className="w-4 h-4" />,
|
||
// 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: <EyeOff className="w-4 h-4" />,
|
||
disabled: hide.isPending,
|
||
onSelect: () => hide.mutate(c),
|
||
},
|
||
...(me.can_write
|
||
? [
|
||
{
|
||
id: "unsubscribe",
|
||
label: t("channels.row.unsubscribeOnYoutube"),
|
||
title: t("channels.row.unsubscribeHint"),
|
||
icon: <UserMinus className="w-4 h-4" />,
|
||
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 };
|
||
}
|