Files
siftlode/frontend/src/components/useChannelActions.tsx
T
peter aac79ffa53 fix(i18n): numbers, units, dates and order in the UI language, and after a switch (E30 S3)
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
2026-09-29 22:39:57 +02:00

253 lines
9.6 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
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 };
}