Files
siftlode/frontend/src/components/NotificationsPanel.tsx
T
peter db56f25387 feat(files): API tokens in Settings, the Hub folder in Files, a notice before a token runs out
Settings gains an API tokens tab: a row per token (name and prefix; each
folder with its rights; last use and expiry, "expires soon" marked),
revoked from its menu behind a confirm. A new token is the Hub exchange in
one click, or folders and rights picked per folder (no Write where an
upload cannot land); expiry 30/90 days, 1 year or never (warned). The
token shows once in the same dialog, with Copy and a ready curl line.

The Hub folder closes the Sources in the Files rail, takes uploads and
moved-in files. A token about to run out warns its user once, a week
before (the Files GC's sixth pass); the notice opens the tokens tab.
2026-10-14 23:33:34 +02:00

781 lines
28 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 { useEffect, useSyncExternalStore, type ReactNode } from "react";
import { useTranslation } from "react-i18next";
import type { TFunction } from "i18next";
import { useMutation, useQueryClient } from "@tanstack/react-query";
import { qk } from "../lib/queryKeys";
import {
Activity,
Bell,
Check,
CheckCheck,
ExternalLink,
Eye,
FolderOpen,
KeyRound,
RotateCcw,
Search,
Trash2,
Tv,
UserPlus,
X,
} from "lucide-react";
import { api, type AppNotification } from "../lib/api";
import { jobRunText } from "../lib/jobResult";
import { notifyActionError } from "../lib/actionErrors";
import { useLiveQuery } from "../lib/useLiveQuery";
import { focusAccessRequestsTab } from "../lib/adminUsersTab";
import { focusSettingsTab } from "../lib/settingsTab";
import {
clearAll as clearClient,
dismiss as dismissClient,
getNotifications,
getUnreadCount,
markAllRead as markAllClientRead,
remove as removeClient,
resolveVideo,
subscribe,
type Notification as ClientNotif,
type ChannelSubscribedMeta,
type VideoHiddenMeta,
type VideoWatchedMeta,
} from "../lib/notifications";
import { channelYouTubeUrl, formatDate } from "../lib/format";
import { Button } from "./ui/Button";
import { IconButton } from "./ui/IconButton";
import { LEVEL_STYLE } from "./ui/toastLevels";
import { PageToolbar } from "./ui/PageShell";
import { PageMenu } from "./ui/PageMenu";
import { LoadingState, StateMessage } from "./ui/QueryState";
import { useNavigationActions } from "./navigationContext";
import { useFilesActions } from "./filesContext";
import { useChannelsActions } from "./channelsContext";
import { useFeedFilters, useFeedFiltersActions } from "./feedFiltersContext";
import { SURFACE } from "./ui/surfaces";
import { SectionLabel } from "./ui/Headings";
import NavGrid from "./ui/NavGrid";
import { navItemAttrs } from "../lib/listNav";
import { useContextMenu, type ContextMenuController } from "./ui/useContextMenu";
import { ContextMenu } from "./ui/ContextMenu";
import { MoreButton } from "./ui/MoreButton";
import { type ActionMenuItem } from "./ui/ActionMenu";
import { menuGroups } from "./ui/menuGroups";
import { HOVER_REVEAL } from "./ui/reveal";
import { useConfirm } from "./confirmContext";
import { RelativeTime } from "./ui/RelativeTime";
// The single notification center. "System" = durable, server-backed notifications (inbox
// phase 1). "Activity" = the client-side transient events (action confirmations, errors, with
// their inline undo/find actions) that used to live behind a separate bell — folded in here so
// there's one indicator and one place to look. Transient toasts still pop via the Toaster.
const POLL_MS = 8000;
const byId = (n: { id: number }) => n.id;
export default function NotificationsPanel() {
const { t } = useTranslation();
const { setPage } = useNavigationActions();
const { openFile } = useFilesActions();
const filters = useFeedFilters();
const { setFilters } = useFeedFiltersActions();
// "Focus this channel in the manager" (from a new-subscription notification) lives in
// ChannelsProvider — read it here and pass it down to the notification rows.
const { focusChannel: onFocusChannel } = useChannelsActions();
const qc = useQueryClient();
const confirm = useConfirm();
const q = useLiveQuery<{ items: AppNotification[]; total: number }>(
qk.notifications(),
() => api.notifications(),
{ intervalMs: POLL_MS },
);
const serverItems = q.data?.items ?? [];
const clientItems = useSyncExternalStore(subscribe, getNotifications, getNotifications);
const clientUnread = useSyncExternalStore(subscribe, getUnreadCount, getUnreadCount);
// Opening the center marks the (ephemeral) client items read, matching the old bell. Server
// items stay unread until acted on individually.
useEffect(() => {
markAllClientRead();
}, []);
const refresh = () => {
qc.invalidateQueries({ queryKey: qk.notifications() });
qc.invalidateQueries({ queryKey: qk.notifUnread() });
};
const readMut = useMutation({
mutationFn: api.markNotificationRead,
onSuccess: refresh,
meta: { failTitle: "errors.writeFailed.notifRead" },
});
const readAllMut = useMutation({
mutationFn: api.markAllNotificationsRead,
onSuccess: refresh,
meta: { failTitle: "errors.writeFailed.notifReadAll" },
});
const dismissMut = useMutation({
mutationFn: api.dismissNotification,
onSuccess: refresh,
meta: { failTitle: "errors.writeFailed.notifDismiss" },
});
const clearMut = useMutation({
mutationFn: api.clearNotifications,
onSuccess: refresh,
meta: { failTitle: "errors.writeFailed.notifClear" },
});
const serverUnread = serverItems.some((n) => !n.read);
const hasAny = serverItems.length > 0 || clientItems.length > 0;
const hasUnread = serverUnread || clientUnread > 0;
function markAll() {
markAllClientRead();
if (serverUnread) readAllMut.mutate();
else refresh();
}
// It empties both lists for good (the server's are deleted), so it asks first — it moved from the
// band into the header's "…", where a destructive row keeps its confirm.
async function clearEverything() {
const ok = await confirm({
title: t("inbox.clearConfirmTitle"),
message: t("inbox.clearConfirmBody"),
confirmLabel: t("inbox.clearAll"),
danger: true,
});
if (!ok) return;
clearClient();
clearMut.mutate();
}
// "Find in feed" / undo, ported from the old bell. Works for both hidden and watched
// notices — jump to the feed filtered to that state and the video's channel.
// Built from nothing, not spread over the reader's filters: EVERY narrowing that could leave the
// video out goes — a tag, "no tag", a search, the age cap, the year, the custom range, the length,
// a content type (a hidden Short under "no Shorts"), the scope and the library source (the
// spread kept whatever this list forgot). Only how the reader orders and matches stays.
function locate(meta: VideoHiddenMeta | VideoWatchedMeta) {
setFilters({
tags: [],
tagModes: filters.tagModes,
untagged: false,
q: "",
includeNormal: true,
includeShorts: true,
includeLive: true,
scope: "all",
librarySource: "all",
sort: filters.sort,
show: meta.kind === "video-hidden" ? "hidden" : "watched",
channelIds: meta.channelId ? [meta.channelId] : [],
});
setPage("feed");
}
function revertState(meta: VideoHiddenMeta | VideoWatchedMeta) {
api
.setState(meta.videoId, "new")
.then(() => {
qc.invalidateQueries({ queryKey: qk.feed() });
qc.invalidateQueries({ queryKey: qk.feedSummary() });
// Quietly resolve the now-stale notice (no confirmation toast — the change is visible).
resolveVideo(meta.videoId);
})
.catch((e) => notifyActionError(e, t("inbox.revertFailed")));
}
// --- The rows' ONE menu -------------------------------------------------------------
// A row keeps its link and its unread mark in sight; its dismiss shows on hover. Everything it can
// do — the link again, find / undo on an activity row, mark read, dismiss — is its menu.
// One menu per list, keyed by the notification's id: the server list polls, and the "…" must still
// close the menu a poll replaced its row under.
const serverMenu = useContextMenu<AppNotification>({ keyOf: byId });
const clientMenu = useContextMenu<ClientNotif>({ keyOf: byId });
const openScheduler = () => setPage("scheduler");
const openTokens = () => {
focusSettingsTab("tokens");
setPage("settings");
};
// A finished download opens on its file, in its folder; a notice from before it named one, on Files.
const openDone = (n: AppNotification) => {
const { job_id: id, folder_id: folder } = n.data ?? {};
if (typeof id === "number") openFile(id, typeof folder === "number" ? folder : null);
else setPage("files");
};
const reviewAccess = () => {
focusAccessRequestsTab();
setPage("users");
};
const icon = (I: typeof Check) => <I className="w-4 h-4" />;
const dismissRow = (onSelect: () => void): ActionMenuItem => ({
id: "dismiss",
label: t("inbox.dismiss"),
icon: icon(X),
onSelect,
});
function serverRows(n: AppNotification): ActionMenuItem[] {
const kind = serverKind(n);
return menuGroups(
[
...(kind === "scheduler"
? [
{
id: "open",
label: t("inbox.openScheduler"),
icon: icon(Activity),
onSelect: openScheduler,
},
]
: kind === "download"
? [
{
id: "open",
label: t("inbox.openFile"),
icon: icon(FolderOpen),
onSelect: () => openDone(n),
},
]
: kind === "token"
? [
{
id: "open",
label: t("inbox.openTokens"),
icon: icon(KeyRound),
onSelect: openTokens,
},
]
: []),
...(!n.read
? [
{
id: "read",
label: t("inbox.markRead"),
icon: icon(Check),
onSelect: () => readMut.mutate(n.id),
},
]
: []),
],
[dismissRow(() => dismissMut.mutate(n.id))],
);
}
function clientRows(n: ClientNotif): ActionMenuItem[] {
const m = n.meta;
const own: ActionMenuItem[] =
m?.kind === "video-hidden" || m?.kind === "video-watched"
? [
{
id: "find",
label: t("notifications.findInFeed"),
icon: icon(Search),
onSelect: () => locate(m),
},
{
id: "revert",
label: t(
m.kind === "video-hidden" ? "notifications.unhide" : "notifications.unwatch",
),
icon: icon(m.kind === "video-hidden" ? Eye : RotateCcw),
onSelect: () => revertState(m),
},
]
: m?.kind === "channel-subscribed"
? [
{
id: "manager",
label: t("notifications.openInManager"),
icon: icon(Tv),
onSelect: () => onFocusChannel(m.channelName),
},
{
id: "youtube",
label: t("notifications.openOnYouTube"),
icon: icon(ExternalLink),
// A new tab only opens inside the click (a popup blocker refuses one a task later).
inGesture: true,
onSelect: () =>
void window.open(channelYouTubeUrl(m.channelId), "_blank", "noopener,noreferrer"),
},
]
: m?.kind === "access-requests"
? [
{
id: "review",
label: t("common.accessRequestsReview"),
icon: icon(UserPlus),
onSelect: reviewAccess,
},
]
: !n.dismissed
? liveActions(n).map((action, i) => ({
id: `action${i}`,
label: action.label,
onSelect: () => {
action.onClick();
dismissClient(n.id);
},
}))
: [];
return menuGroups(own, [dismissRow(() => removeClient(n.id))]);
}
const more = <T extends { id: number }>(menu: ContextMenuController<T>, n: T) => {
const mine = menu.open && menu.target?.id === n.id;
return (
<MoreButton
label={t("inbox.rowMenu")}
expanded={mine}
menuProps={menu.buttonProps(n)}
className={mine ? undefined : "reveal"}
/>
);
};
// --- The page's own menu: the header's "…" ------------------------------------------------------
const headerMenu = useContextMenu<"inbox">();
const headerItems: ActionMenuItem[] = [
{
id: "read-all",
label: t("inbox.markAllRead"),
icon: icon(CheckCheck),
disabled: !hasUnread || readAllMut.isPending,
onSelect: markAll,
},
{
id: "clear",
label: t("inbox.clearAll"),
icon: icon(Trash2),
danger: true,
separatorBefore: true,
disabled: clearMut.isPending,
onSelect: () => void clearEverything(),
},
];
return (
<>
{/* The inbox band (B4): Mark all read in sight, Clear everything in its "…", fixed
while the list scrolls. No title card: the nav rail and the tab title name the page, and the
shell's hidden h1 names it to a screen reader. */}
{/* The page's background menu (DESIGN "Background menu"): the band's own non-destructive
action — Clear everything stays behind its "…" and its confirm. */}
{hasAny && (
<PageMenu
ariaLabel={t("common.pageMenu")}
items={headerItems.filter((it) => !("danger" in it && it.danger))}
/>
)}
{hasAny && (
<PageToolbar>
<div className="px-4 pt-2 pb-1 max-w-3xl w-full mx-auto flex justify-end items-center gap-2">
<Button
variant="ghost"
size="sm"
onClick={markAll}
disabled={!hasUnread || readAllMut.isPending}
icon={<CheckCheck />}
>
{t("inbox.markAllRead")}
</Button>
<MoreButton
label={t("inbox.menu")}
expanded={headerMenu.open}
menuProps={headerMenu.buttonProps("inbox")}
/>
</div>
</PageToolbar>
)}
<div className="px-4 pb-4 pt-3 max-w-3xl w-full mx-auto space-y-4">
{q.isError && !hasAny ? (
<StateMessage tone="error" onRetry={() => q.refetch()}>
{t("common.loadError")}
</StateMessage>
) : q.isLoading && !q.data && clientItems.length === 0 ? (
<LoadingState shape="list" />
) : !hasAny ? (
<div className={`${SURFACE.panel} p-10 text-center text-muted`}>
<Bell className="w-8 h-8 mx-auto mb-3 opacity-40" />
{t("inbox.empty")}
</div>
) : (
<div className="space-y-4">
{serverItems.length > 0 && (
<section>
<SectionLabel className="mb-2">{t("inbox.sectionSystem")}</SectionLabel>
<NavGrid className="flex flex-col gap-2">
{serverItems.map((n, i) => {
return (
<NotificationRow
key={n.id}
n={n}
t={t}
navIndex={i}
rowProps={serverMenu.triggerProps(n)}
more={more(serverMenu, n)}
onRead={() => readMut.mutate(n.id)}
onDismiss={() => dismissMut.mutate(n.id)}
onOpenScheduler={openScheduler}
onOpenFile={() => openDone(n)}
onOpenTokens={openTokens}
/>
);
})}
</NavGrid>
</section>
)}
{clientItems.length > 0 && (
<section>
<SectionLabel className="mb-2">{t("inbox.sectionActivity")}</SectionLabel>
<NavGrid className="flex flex-col gap-2">
{clientItems.map((n, i) => {
return (
<ClientActivityRow
key={n.id}
n={n}
t={t}
navIndex={i}
rowProps={clientMenu.triggerProps(n)}
more={more(clientMenu, n)}
onFocusChannel={onFocusChannel}
onReviewAccess={reviewAccess}
onRemove={() => removeClient(n.id)}
/>
);
})}
</NavGrid>
</section>
)}
</div>
)}
</div>
<ContextMenu
menu={serverMenu}
ariaLabel={t("inbox.rowMenu")}
items={serverMenu.target ? serverRows(serverMenu.target) : []}
/>
<ContextMenu
menu={clientMenu}
ariaLabel={t("inbox.rowMenu")}
items={clientMenu.target ? clientRows(clientMenu.target) : []}
/>
<ContextMenu menu={headerMenu} ariaLabel={t("inbox.menu")} items={headerItems} />
</>
);
}
/** A client notice's live actions (they do not survive a reload): its action, then the lesser one. */
function liveActions(n: ClientNotif): { label: string; onClick: () => void }[] {
return [n.action, n.secondaryAction].filter((a) => a !== undefined);
}
/** A server notification's link, from its type and typed payload (the row's button and its menu). */
// The download-lifecycle notification types → their `inbox.<key>` copy.
const DOWNLOAD_LIFECYCLE: Record<string, string | undefined> = {
download_shared: "downloadShared",
download_expiring: "downloadExpiring",
download_expired: "downloadExpired",
download_evicted: "downloadEvicted",
};
// A `download_done` notice's title by the producer's `kind`; any other kind reads as a download.
const DONE_TITLE: Record<string, string | undefined> = {
edit: "inbox.editDone.title",
upload: "inbox.uploadDone.title",
};
function serverKind(n: AppNotification): "scheduler" | "download" | "token" | null {
if (n.type === "scheduler" && typeof n.data?.job_id === "string") return "scheduler";
if (n.type === "download_done") return "download";
if (n.type === "api_token_expiring" && typeof n.data?.name === "string") return "token";
return null;
}
/** A row's end: its unread mark (server rows), its dismiss on hover (mouse; on touch it is in the
* "…"), then its "…". */
function RowEnd({
onRead,
onDismiss,
more,
}: {
onRead?: () => void;
onDismiss: () => void;
more: ReactNode;
}) {
const { t } = useTranslation();
return (
<div className="flex items-center gap-1 shrink-0">
{onRead && (
<IconButton label={t("inbox.markRead")} size="sm" variant="ghost" onClick={onRead}>
<Check className="w-4 h-4" />
</IconButton>
)}
<IconButton
label={t("inbox.dismiss")}
size="sm"
variant="ghost"
onClick={onDismiss}
className={HOVER_REVEAL}
>
<X className="w-4 h-4" />
</IconButton>
{more}
</div>
);
}
function NotificationRow({
n,
t,
navIndex,
rowProps,
more,
onRead,
onDismiss,
onOpenScheduler,
onOpenFile,
onOpenTokens,
}: {
n: AppNotification;
t: TFunction;
navIndex: number;
rowProps: Record<string, unknown>;
more: ReactNode;
onRead: () => void;
onDismiss: () => void;
onOpenScheduler: () => void;
onOpenFile: () => void;
onOpenTokens: () => void;
}) {
const lang = useTranslation().i18n.language;
// The maintenance producer ships the affected videos in `data.videos`; list a few titles.
const videos: { id: string; title: string | null }[] = Array.isArray(n.data?.videos)
? n.data!.videos
: [];
// Known notification types are rendered from i18n (so they're trilingual) using the typed
// payload; the server-stored title/body are an English fallback for any unknown type.
const isMaintenance = n.type === "maintenance" && typeof n.data?.count === "number";
const isScheduler = serverKind(n) === "scheduler";
const isDownloadDone = serverKind(n) === "download";
const isTokenExpiring = serverKind(n) === "token";
const isEmailUndelivered = n.type === "email_undelivered" && typeof n.data?.reason === "string";
// A download's life after it is done: shared with you, about to expire, expired, evicted.
const lifecycle = DOWNLOAD_LIFECYCLE[n.type];
let title = n.title;
let body = n.body;
if (isMaintenance) {
// Guarded above (typeof count === "number"); shape the JSON `data` bag for typed access.
const d = n.data as { count: number };
title = t("inbox.maintenance.title");
body = t("inbox.maintenance.body", { count: d.count });
} else if (isScheduler) {
// "<Job label> finished/failed", with the result as the body — named in the reader's language
// when it rode along as data, else the English summary of an older notice.
const run = jobRunText(t, n.data as { job_id: string }, lang);
title = run.title;
body = run.body || n.body;
} else if (isEmailUndelivered) {
// Email is fail-soft: a drop leaves no error anywhere else, so this notice IS the trace. Name
// the message that was lost, since that is what tells the operator what their users didn't get.
const d = n.data as { reason: string; subject?: string };
title = t("inbox.emailUndelivered.title");
body = t(
d.reason === "not_configured"
? "inbox.emailUndelivered.notConfigured"
: "inbox.emailUndelivered.sendFailed",
{ subject: d.subject || n.body || "" },
);
} else if (lifecycle) {
// The server stores an English title ("… was shared with you", or the bare word "download" for an
// untitled video); the notice is named from `data`, the video's title as the body — none when it
// has no title (or the share notice predates the title riding along).
const d = n.data as { title?: string | null; from?: string };
title = t(`inbox.${lifecycle}.title`, { from: d.from ?? "" });
body = d.title || null;
} else if (isTokenExpiring) {
// A machine's key to the library runs out within a week: which one, and when.
const d = n.data as { name: string; expires_at?: string | null };
title = t("inbox.apiTokenExpiring.title");
body = t("inbox.apiTokenExpiring.body", { name: d.name, date: formatDate(d.expires_at, lang) });
} else if (isDownloadDone) {
// "Download ready" / "Edit ready" / "Upload ready" per the producer's kind, with the video's title
// as the body (shipped in data.title). Same notification type; the copy differs so an edit or an
// upload isn't called a download.
const d = n.data as { title?: string | null; kind?: string };
title = t(DONE_TITLE[d.kind ?? ""] ?? "inbox.downloadDone.title");
body = d.title || n.body;
}
return (
<div
{...navItemAttrs(navIndex, title)}
{...rowProps}
className={`${SURFACE.card} group p-3.5 flex items-start gap-3 transition ${
n.read ? "opacity-70" : ""
}`}
>
{!n.read && <span className="mt-1.5 w-2 h-2 rounded-full bg-accent shrink-0" aria-hidden />}
<div className="flex-1 min-w-0">
<div className="flex items-baseline gap-2">
<span className="font-medium text-sm">{title}</span>
<RelativeTime at={n.created_at} className="text-2xs text-muted shrink-0" />
</div>
{body && <div className="text-sm text-muted mt-0.5">{body}</div>}
{videos.length > 0 && (
<ul className="mt-2 text-xs text-muted list-disc pl-4 space-y-0.5">
{videos.slice(0, 8).map((v) => (
<li key={v.id} className="truncate">
{v.title || v.id}
</li>
))}
{videos.length > 8 && (
<li className="list-none text-muted/70">
{t("inbox.andMore", { count: videos.length - 8 })}
</li>
)}
</ul>
)}
{isScheduler && (
<Button
variant="link"
className="text-sm mt-1.5"
data-nav-open=""
onClick={onOpenScheduler}
icon={<Activity className="w-3.5 h-3.5" />}
>
{t("inbox.openScheduler")}
</Button>
)}
{isTokenExpiring && (
<Button
variant="link"
className="text-sm mt-1.5"
data-nav-open=""
onClick={onOpenTokens}
icon={<KeyRound className="w-3.5 h-3.5" />}
>
{t("inbox.openTokens")}
</Button>
)}
{isDownloadDone && (
<Button
variant="link"
className="text-sm mt-1.5"
data-nav-open=""
onClick={onOpenFile}
icon={<FolderOpen className="w-3.5 h-3.5" />}
>
{t("inbox.openFile")}
</Button>
)}
</div>
<RowEnd onRead={n.read ? undefined : onRead} onDismiss={onDismiss} more={more} />
</div>
);
}
function ClientActivityRow({
n,
t,
navIndex,
rowProps,
more,
onFocusChannel,
onReviewAccess,
onRemove,
}: {
n: ClientNotif;
t: TFunction;
navIndex: number;
rowProps: Record<string, unknown>;
more: ReactNode;
onFocusChannel: (name: string) => void;
onReviewAccess: () => void;
onRemove: () => void;
}) {
const { icon: Icon, color } = LEVEL_STYLE[n.level];
const needsAction = n.requiresInteraction && !n.dismissed;
// A hidden / watched notice's Find in feed and Undo are in its menu: the row reads.
const quiet = n.meta?.kind === "video-hidden" || n.meta?.kind === "video-watched";
const subscribed: ChannelSubscribedMeta | null =
n.meta?.kind === "channel-subscribed" ? n.meta : null;
const access = n.meta?.kind === "access-requests" ? n.meta : null;
return (
<div
{...navItemAttrs(navIndex, n.title || n.message)}
{...rowProps}
className={`${SURFACE.card} group p-3.5 flex items-start gap-3 ${needsAction ? "ring-1 ring-accent/30" : ""}`}
>
<Icon className={`w-4 h-4 shrink-0 mt-0.5 ${color}`} />
<div className="min-w-0 flex-1">
{n.title && <div className="text-sm font-semibold">{n.title}</div>}
<div className="text-sm wrap-break-word">
{n.message}
{n.repeat > 1 && (
<span className="ml-1.5 align-middle text-2xs font-semibold text-muted tabular-nums">
×{n.repeat}
</span>
)}
</div>
<div className="flex items-center gap-2 mt-0.5">
<RelativeTime at={n.ts} className="text-2xs text-muted" />
{needsAction && (
<span className="text-3xs uppercase tracking-wide font-semibold text-accent">
{t("notifications.actionNeeded")}
</span>
)}
</div>
{quiet ? null : subscribed ? (
<div className="flex items-center gap-3 mt-1">
<Button
variant="link"
className="text-sm"
data-nav-open=""
onClick={() => onFocusChannel(subscribed.channelName)}
icon={<Tv className="w-3.5 h-3.5" />}
>
{t("notifications.openInManager")}
</Button>
<a
href={channelYouTubeUrl(subscribed.channelId)}
target="_blank"
rel="noopener noreferrer"
className="flex items-center gap-1 text-accent text-sm font-semibold hover:underline"
// The link keeps the browser's own right-click menu; the row's answers elsewhere.
onContextMenu={(e) => e.stopPropagation()}
>
<ExternalLink className="w-3.5 h-3.5" />
{t("notifications.openOnYouTube")}
</a>
</div>
) : access ? (
<Button
variant="link"
className="text-sm mt-1"
data-nav-open=""
onClick={() => onReviewAccess()}
icon={<UserPlus className="w-3.5 h-3.5" />}
>
{t("common.accessRequestsReview")}
</Button>
) : (
!n.dismissed &&
liveActions(n).length > 0 && (
<div className="mt-1 flex flex-wrap gap-x-4">
{liveActions(n).map((action, i) => (
<Button
key={action.label}
variant="link"
className="text-sm"
// The first is the row's own: Enter on the row runs it.
data-nav-open={i === 0 ? "" : undefined}
onClick={() => {
action.onClick();
dismissClient(n.id);
}}
>
{action.label}
</Button>
))}
</div>
)
)}
</div>
<RowEnd onDismiss={onRemove} more={more} />
</div>
);
}