feat(design): the download, notification and Scheduler action lists (C1) — the rows' "…", the band's and the strip's "…" (E28 S3e3)
A download row keeps play (pause / resume / retry in the queue) and the save link; the editors, sharing, re-download, the copies and cancel / delete / remove moved into its "…" (useDownloadActions). A link inside the row keeps the browser's own right-click menu. A notification row keeps its link and its unread mark and shows its dismiss on hover; an activity notice's Find in feed and Undo are in its "…". The inbox band keeps Mark all read and puts Clear everything, now behind a confirm, in its "…". The Scheduler strip keeps Run all and Pause and puts Purge discovery in its "…". Polled lists key their menus by id (keyOf). ui/menuGroups draws a rule only between two non-empty groups. Find in feed also drops the tag, search and age filters that could hide the video it looks for.
This commit is contained in:
@@ -1,4 +1,4 @@
|
||||
import { useEffect, useSyncExternalStore } from "react";
|
||||
import { useEffect, useSyncExternalStore, type ReactNode } from "react";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import type { TFunction } from "i18next";
|
||||
import { useMutation, useQueryClient } from "@tanstack/react-query";
|
||||
@@ -47,6 +47,15 @@ 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";
|
||||
|
||||
// The single notification center. "System" = durable, server-backed notifications (inbox
|
||||
// phase 1). "Activity" = the client-side transient events (action confirmations, errors, with
|
||||
@@ -54,6 +63,8 @@ import { SectionLabel } from "./ui/Headings";
|
||||
// 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();
|
||||
@@ -63,6 +74,7 @@ export default function NotificationsPanel() {
|
||||
// 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(),
|
||||
@@ -112,16 +124,31 @@ export default function NotificationsPanel() {
|
||||
if (serverUnread) readAllMut.mutate();
|
||||
else refresh();
|
||||
}
|
||||
function clearEverything() {
|
||||
// It empties both lists for good (the server's are deleted), so it asks first — it moved from the
|
||||
// band into the header's "…" (E28 C1), 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.
|
||||
// The narrowing filters that could leave the video out (a tag, "no tag", a search, an age cap) are
|
||||
// dropped: kept, a channel outside the reader's tag filter found an empty feed.
|
||||
function locate(meta: VideoHiddenMeta | VideoWatchedMeta) {
|
||||
setFilters({
|
||||
...filters,
|
||||
tags: [],
|
||||
untagged: false,
|
||||
q: "",
|
||||
maxAgeDays: undefined,
|
||||
show: meta.kind === "video-hidden" ? "hidden" : "watched",
|
||||
channelIds: meta.channelId ? [meta.channelId] : [],
|
||||
});
|
||||
@@ -139,13 +166,165 @@ export default function NotificationsPanel() {
|
||||
.catch((e) => notifyActionError(e, t("inbox.revertFailed")));
|
||||
}
|
||||
|
||||
// --- The rows' ONE menu (E28 C1) -------------------------------------------------------------
|
||||
// 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 openDownloads = () => setPage("downloads");
|
||||
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.openDownloads"),
|
||||
icon: icon(Download),
|
||||
onSelect: openDownloads,
|
||||
},
|
||||
]
|
||||
: []),
|
||||
...(!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.action && !n.dismissed
|
||||
? [
|
||||
{
|
||||
id: "action",
|
||||
label: n.action.label,
|
||||
onSelect: () => {
|
||||
n.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): its two actions, 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 inbox band (B4): Mark all read in sight, Clear everything in its "…" (E28 C1), 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. */}
|
||||
{hasAny && (
|
||||
<PageToolbar>
|
||||
<div className="px-4 pt-2 pb-1 max-w-3xl w-full mx-auto flex justify-end gap-2">
|
||||
<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"
|
||||
@@ -155,15 +334,11 @@ export default function NotificationsPanel() {
|
||||
>
|
||||
{t("inbox.markAllRead")}
|
||||
</Button>
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
onClick={clearEverything}
|
||||
disabled={clearMut.isPending}
|
||||
icon={<Trash2 />}
|
||||
>
|
||||
{t("inbox.clearAll")}
|
||||
</Button>
|
||||
<MoreButton
|
||||
label={t("inbox.menu")}
|
||||
expanded={headerMenu.open}
|
||||
menuProps={headerMenu.buttonProps("inbox")}
|
||||
/>
|
||||
</div>
|
||||
</PageToolbar>
|
||||
)}
|
||||
@@ -185,53 +360,112 @@ export default function NotificationsPanel() {
|
||||
{serverItems.length > 0 && (
|
||||
<section>
|
||||
<SectionLabel className="mb-2">{t("inbox.sectionSystem")}</SectionLabel>
|
||||
<div className="flex flex-col gap-2">
|
||||
{serverItems.map((n) => (
|
||||
<NotificationRow
|
||||
key={n.id}
|
||||
n={n}
|
||||
t={t}
|
||||
onRead={() => readMut.mutate(n.id)}
|
||||
onDismiss={() => dismissMut.mutate(n.id)}
|
||||
onOpenScheduler={() => setPage("scheduler")}
|
||||
onOpenDownloads={() => setPage("downloads")}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
<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}
|
||||
onOpenDownloads={openDownloads}
|
||||
/>
|
||||
);
|
||||
})}
|
||||
</NavGrid>
|
||||
</section>
|
||||
)}
|
||||
{clientItems.length > 0 && (
|
||||
<section>
|
||||
<SectionLabel className="mb-2">{t("inbox.sectionActivity")}</SectionLabel>
|
||||
<div className="flex flex-col gap-2">
|
||||
{clientItems.map((n) => (
|
||||
<ClientActivityRow
|
||||
key={n.id}
|
||||
n={n}
|
||||
t={t}
|
||||
onFind={locate}
|
||||
onRevert={revertState}
|
||||
onFocusChannel={onFocusChannel}
|
||||
onReviewAccess={() => {
|
||||
focusAccessRequestsTab();
|
||||
setPage("users");
|
||||
}}
|
||||
onRemove={() => removeClient(n.id)}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
<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 server notification's link, from its type and typed payload (the row's button and its menu). */
|
||||
function serverKind(n: AppNotification): "scheduler" | "download" | null {
|
||||
if (n.type === "scheduler" && typeof n.data?.job_id === "string") return "scheduler";
|
||||
if (n.type === "download_done") return "download";
|
||||
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,
|
||||
@@ -239,6 +473,9 @@ function NotificationRow({
|
||||
}: {
|
||||
n: AppNotification;
|
||||
t: TFunction;
|
||||
navIndex: number;
|
||||
rowProps: Record<string, unknown>;
|
||||
more: ReactNode;
|
||||
onRead: () => void;
|
||||
onDismiss: () => void;
|
||||
onOpenScheduler: () => void;
|
||||
@@ -251,8 +488,8 @@ function NotificationRow({
|
||||
// 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 = n.type === "scheduler" && typeof n.data?.job_id === "string";
|
||||
const isDownloadDone = n.type === "download_done";
|
||||
const isScheduler = serverKind(n) === "scheduler";
|
||||
const isDownloadDone = serverKind(n) === "download";
|
||||
const isEmailUndelivered = n.type === "email_undelivered" && typeof n.data?.reason === "string";
|
||||
let title = n.title;
|
||||
let body = n.body;
|
||||
@@ -289,7 +526,9 @@ function NotificationRow({
|
||||
}
|
||||
return (
|
||||
<div
|
||||
className={`${SURFACE.card} p-3.5 flex items-start gap-3 transition ${
|
||||
{...navItemAttrs(navIndex, title)}
|
||||
{...rowProps}
|
||||
className={`${SURFACE.card} group p-3.5 flex items-start gap-3 transition ${
|
||||
n.read ? "opacity-70" : ""
|
||||
}`}
|
||||
>
|
||||
@@ -318,6 +557,7 @@ function NotificationRow({
|
||||
<Button
|
||||
variant="link"
|
||||
className="text-sm mt-1.5"
|
||||
data-nav-open=""
|
||||
onClick={onOpenScheduler}
|
||||
icon={<Activity className="w-3.5 h-3.5" />}
|
||||
>
|
||||
@@ -328,6 +568,7 @@ function NotificationRow({
|
||||
<Button
|
||||
variant="link"
|
||||
className="text-sm mt-1.5"
|
||||
data-nav-open=""
|
||||
onClick={onOpenDownloads}
|
||||
icon={<Download className="w-3.5 h-3.5" />}
|
||||
>
|
||||
@@ -335,16 +576,7 @@ function NotificationRow({
|
||||
</Button>
|
||||
)}
|
||||
</div>
|
||||
<div className="flex items-center gap-1 shrink-0">
|
||||
{!n.read && (
|
||||
<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}>
|
||||
<X className="w-4 h-4" />
|
||||
</IconButton>
|
||||
</div>
|
||||
<RowEnd onRead={n.read ? undefined : onRead} onDismiss={onDismiss} more={more} />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -352,30 +584,34 @@ function NotificationRow({
|
||||
function ClientActivityRow({
|
||||
n,
|
||||
t,
|
||||
onFind,
|
||||
onRevert,
|
||||
navIndex,
|
||||
rowProps,
|
||||
more,
|
||||
onFocusChannel,
|
||||
onReviewAccess,
|
||||
onRemove,
|
||||
}: {
|
||||
n: ClientNotif;
|
||||
t: TFunction;
|
||||
onFind: (meta: VideoHiddenMeta | VideoWatchedMeta) => void;
|
||||
onRevert: (meta: VideoHiddenMeta | VideoWatchedMeta) => void;
|
||||
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;
|
||||
const hidden = n.meta?.kind === "video-hidden" ? n.meta : null;
|
||||
const watched = n.meta?.kind === "video-watched" ? n.meta : null;
|
||||
// A hidden / watched notice's Find in feed and Undo are in its menu (E28 C1): 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
|
||||
className={`${SURFACE.card} p-3.5 flex items-start gap-3 ${needsAction ? "ring-1 ring-accent/30" : ""}`}
|
||||
{...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">
|
||||
@@ -396,49 +632,12 @@ function ClientActivityRow({
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
{hidden ? (
|
||||
<div className="flex items-center gap-3 mt-1">
|
||||
<Button
|
||||
variant="link"
|
||||
className="text-sm"
|
||||
onClick={() => onFind(hidden)}
|
||||
icon={<Search className="w-3.5 h-3.5" />}
|
||||
>
|
||||
{t("notifications.findInFeed")}
|
||||
</Button>
|
||||
<Button
|
||||
variant="link"
|
||||
className="text-sm"
|
||||
onClick={() => onRevert(hidden)}
|
||||
icon={<Eye className="w-3.5 h-3.5" />}
|
||||
>
|
||||
{t("notifications.unhide")}
|
||||
</Button>
|
||||
</div>
|
||||
) : watched ? (
|
||||
<div className="flex items-center gap-3 mt-1">
|
||||
<Button
|
||||
variant="link"
|
||||
className="text-sm"
|
||||
onClick={() => onFind(watched)}
|
||||
icon={<Search className="w-3.5 h-3.5" />}
|
||||
>
|
||||
{t("notifications.findInFeed")}
|
||||
</Button>
|
||||
<Button
|
||||
variant="link"
|
||||
className="text-sm"
|
||||
onClick={() => onRevert(watched)}
|
||||
icon={<RotateCcw className="w-3.5 h-3.5" />}
|
||||
>
|
||||
{t("notifications.unwatch")}
|
||||
</Button>
|
||||
</div>
|
||||
) : subscribed ? (
|
||||
{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" />}
|
||||
>
|
||||
@@ -449,6 +648,8 @@ function ClientActivityRow({
|
||||
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")}
|
||||
@@ -458,6 +659,7 @@ function ClientActivityRow({
|
||||
<Button
|
||||
variant="link"
|
||||
className="text-sm mt-1"
|
||||
data-nav-open=""
|
||||
onClick={() => onReviewAccess()}
|
||||
icon={<UserPlus className="w-3.5 h-3.5" />}
|
||||
>
|
||||
@@ -469,6 +671,7 @@ function ClientActivityRow({
|
||||
<Button
|
||||
variant="link"
|
||||
className="mt-1 text-sm"
|
||||
data-nav-open=""
|
||||
onClick={() => {
|
||||
n.action!.onClick();
|
||||
dismissClient(n.id);
|
||||
@@ -479,9 +682,7 @@ function ClientActivityRow({
|
||||
)
|
||||
)}
|
||||
</div>
|
||||
<IconButton label={t("inbox.dismiss")} size="sm" variant="ghost" onClick={onRemove}>
|
||||
<X className="w-4 h-4" />
|
||||
</IconButton>
|
||||
<RowEnd onDismiss={onRemove} more={more} />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user