Files
siftlode/frontend/src/modules/channels/useChannelActions.tsx
T
peter b515c16117 fix(notifications): a notice's button is said when shown, like its text
An action label was translated when the notice was raised, so after a language switch a live
toast or center row read in one language with its button in the other. The access-requests nudge
no longer re-issues on a switch for it. New tests: the button and a server refusal follow a
switch.
2026-10-30 00:10:38 +01:00

257 lines
9.9 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 { msg, notify, raw } from "../../lib/notifications";
import { notifyYouTubeActionError } from "../../lib/youtubeErrors";
import { useMe } from "../../lib/useMe";
import { useConfirm } from "../../components/ui/confirmContext";
import { useCopyItems } from "../../components/ui/copyItems";
import { type ActionMenuItem } from "../../components/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();
// The notice says it when shown: a channel with no title yet is "this channel" in the reader's language.
const nameOf = (c: ManagedChannel) =>
c.title == null ? msg("channels.row.thisChannel") : raw(c.title);
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: msg("channels.notify.hidden", { name: nameOf(c) }),
action: {
label: msg("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: msg("channels.notify.unsubscribed", { name: nameOf(c) }),
});
},
onError: (e) =>
notifyYouTubeActionError(e, msg("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: msg("channels.notify.resetDone", { name: nameOf(c) }) });
},
onError: (e) => notifyYouTubeActionError(e, msg("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 };
}