siftlode/frontend/src/components/NotificationsPanel.tsx
npeter83 7f358f63e3 feat(notifications): coalesce identical notifications with a repeat count
Fold a burst of identical notifications (same level+title+message within a
short window) into one entry with a ×N count instead of stacking copies — tames
a crash-retry loop or a repeatedly-failing poll. Shown in the toast and centre.
2026-07-13 05:13:18 +02:00

426 lines
15 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 } from "react";
import { useTranslation } from "react-i18next";
import { useMutation, useQueryClient } from "@tanstack/react-query";
import { Activity, Bell, Check, CheckCheck, ExternalLink, Eye, RotateCcw, Search, Trash2, Tv, UserPlus, X } from "lucide-react";
import { api, type AppNotification, type FeedFilters } from "../lib/api";
import { useLiveQuery } from "../lib/useLiveQuery";
import { focusAccessRequestsTab } from "../lib/adminUsersTab";
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, relativeFromMs, relativeTime } from "../lib/format";
import type { Page } from "../lib/urlState";
import { LEVEL_STYLE } from "./Toaster";
// 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;
export default function NotificationsPanel({
filters,
setFilters,
setPage,
onFocusChannel,
}: {
filters: FeedFilters;
setFilters: (f: FeedFilters) => void;
setPage: (p: Page) => void;
onFocusChannel: (name: string) => void;
}) {
const { t } = useTranslation();
const qc = useQueryClient();
const q = useLiveQuery<{ items: AppNotification[]; total: number }>(
["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: ["notifications"] });
qc.invalidateQueries({ queryKey: ["notif-unread"] });
};
const readMut = useMutation({ mutationFn: api.markNotificationRead, onSuccess: refresh });
const readAllMut = useMutation({ mutationFn: api.markAllNotificationsRead, onSuccess: refresh });
const dismissMut = useMutation({ mutationFn: api.dismissNotification, onSuccess: refresh });
const clearMut = useMutation({ mutationFn: api.clearNotifications, onSuccess: refresh });
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();
}
function clearEverything() {
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.
function locate(meta: VideoHiddenMeta | VideoWatchedMeta) {
setFilters({
...filters,
show: meta.kind === "video-hidden" ? "hidden" : "watched",
channelId: meta.channelId || undefined,
channelName: meta.channelName || undefined,
});
setPage("feed");
}
function revertState(meta: VideoHiddenMeta | VideoWatchedMeta) {
api
.setState(meta.videoId, "new")
.then(() => {
qc.invalidateQueries({ queryKey: ["feed"] });
qc.invalidateQueries({ queryKey: ["feed-count"] });
// Quietly resolve the now-stale notice (no confirmation toast — the change is visible).
resolveVideo(meta.videoId);
})
.catch(() => {});
}
return (
<div className="p-4 max-w-3xl w-full mx-auto space-y-4">
<div className="glass rounded-2xl p-4 flex items-center gap-3">
<Bell className="w-5 h-5 text-accent shrink-0" />
<div className="flex-1 min-w-0">
<div className="font-semibold">{t("inbox.title")}</div>
<div className="text-xs text-muted">{t("inbox.subtitle")}</div>
</div>
{hasAny && (
<div className="flex items-center gap-2">
<button
onClick={markAll}
disabled={!hasUnread || readAllMut.isPending}
className="inline-flex items-center gap-1.5 text-xs px-2.5 py-1.5 rounded-lg text-muted hover:text-fg hover:bg-card transition disabled:opacity-40"
>
<CheckCheck className="w-4 h-4" />
{t("inbox.markAllRead")}
</button>
<button
onClick={clearEverything}
disabled={clearMut.isPending}
className="inline-flex items-center gap-1.5 text-xs px-2.5 py-1.5 rounded-lg text-muted hover:text-fg hover:bg-card transition disabled:opacity-40"
>
<Trash2 className="w-4 h-4" />
{t("inbox.clearAll")}
</button>
</div>
)}
</div>
{q.isLoading && !q.data && clientItems.length === 0 ? (
<div className="p-8 text-muted text-center">{t("common.loading")}</div>
) : !hasAny ? (
<div className="glass rounded-2xl 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>
<div className="text-xs uppercase tracking-wide text-muted mb-2">
{t("inbox.sectionSystem")}
</div>
<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")}
/>
))}
</div>
</section>
)}
{clientItems.length > 0 && (
<section>
<div className="text-xs uppercase tracking-wide text-muted mb-2">
{t("inbox.sectionActivity")}
</div>
<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>
</section>
)}
</div>
)}
</div>
);
}
function NotificationRow({
n,
t,
onRead,
onDismiss,
onOpenScheduler,
}: {
n: AppNotification;
t: (k: string, o?: any) => string;
onRead: () => void;
onDismiss: () => void;
onOpenScheduler: () => void;
}) {
// 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 = n.type === "scheduler" && typeof n.data?.job_id === "string";
let title = n.title;
let body = n.body;
if (isMaintenance) {
title = t("inbox.maintenance.title");
body = t("inbox.maintenance.body", { count: n.data!.count });
} else if (isScheduler) {
// "<Job label> finished/failed", with the raw result summary as the (technical) body.
const job = t(`scheduler.jobs.${n.data!.job_id}`, n.data!.job_id);
title = t(
n.data!.status === "error" ? "inbox.jobDone.titleError" : "inbox.jobDone.titleOk",
{ job }
);
body = n.data!.summary || n.body;
}
return (
<div
className={`glass rounded-xl 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>
<span className="text-[11px] text-muted shrink-0">
{relativeTime(n.created_at)}
</span>
</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
onClick={onOpenScheduler}
className="flex items-center gap-1 text-accent text-sm font-semibold hover:underline mt-1.5"
>
<Activity className="w-3.5 h-3.5" />
{t("inbox.openScheduler")}
</button>
)}
</div>
<div className="flex items-center gap-1 shrink-0">
{!n.read && (
<button
onClick={onRead}
title={t("inbox.markRead")}
aria-label={t("inbox.markRead")}
className="p-1.5 rounded-lg text-muted hover:text-fg hover:bg-card transition"
>
<Check className="w-4 h-4" />
</button>
)}
<button
onClick={onDismiss}
title={t("inbox.dismiss")}
aria-label={t("inbox.dismiss")}
className="p-1.5 rounded-lg text-muted hover:text-fg hover:bg-card transition"
>
<X className="w-4 h-4" />
</button>
</div>
</div>
);
}
function ClientActivityRow({
n,
t,
onFind,
onRevert,
onFocusChannel,
onReviewAccess,
onRemove,
}: {
n: ClientNotif;
t: (k: string, o?: any) => string;
onFind: (meta: VideoHiddenMeta | VideoWatchedMeta) => void;
onRevert: (meta: VideoHiddenMeta | VideoWatchedMeta) => void;
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;
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={`glass rounded-xl 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 break-words">
{n.message}
{n.repeat > 1 && (
<span className="ml-1.5 align-middle text-[11px] font-semibold text-muted tabular-nums">
×{n.repeat}
</span>
)}
</div>
<div className="flex items-center gap-2 mt-0.5">
<span className="text-[11px] text-muted">{relativeFromMs(n.ts)}</span>
{needsAction && (
<span className="text-[10px] uppercase tracking-wide font-semibold text-accent">
{t("notifications.actionNeeded")}
</span>
)}
</div>
{hidden ? (
<div className="flex items-center gap-3 mt-1">
<button
onClick={() => onFind(hidden)}
className="flex items-center gap-1 text-accent text-sm font-semibold hover:underline"
>
<Search className="w-3.5 h-3.5" />
{t("notifications.findInFeed")}
</button>
<button
onClick={() => onRevert(hidden)}
className="flex items-center gap-1 text-accent text-sm font-semibold hover:underline"
>
<Eye className="w-3.5 h-3.5" />
{t("notifications.unhide")}
</button>
</div>
) : watched ? (
<div className="flex items-center gap-3 mt-1">
<button
onClick={() => onFind(watched)}
className="flex items-center gap-1 text-accent text-sm font-semibold hover:underline"
>
<Search className="w-3.5 h-3.5" />
{t("notifications.findInFeed")}
</button>
<button
onClick={() => onRevert(watched)}
className="flex items-center gap-1 text-accent text-sm font-semibold hover:underline"
>
<RotateCcw className="w-3.5 h-3.5" />
{t("notifications.unwatch")}
</button>
</div>
) : subscribed ? (
<div className="flex items-center gap-3 mt-1">
<button
onClick={() => onFocusChannel(subscribed.channelName)}
className="flex items-center gap-1 text-accent text-sm font-semibold hover:underline"
>
<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"
>
<ExternalLink className="w-3.5 h-3.5" />
{t("notifications.openOnYouTube")}
</a>
</div>
) : access ? (
<button
onClick={() => onReviewAccess()}
className="flex items-center gap-1 text-accent text-sm font-semibold hover:underline mt-1"
>
<UserPlus className="w-3.5 h-3.5" />
{t("common.accessRequestsReview")}
</button>
) : (
n.action &&
!n.dismissed && (
<button
onClick={() => {
n.action!.onClick();
dismissClient(n.id);
}}
className="mt-1 text-accent text-sm font-semibold hover:underline"
>
{n.action.label}
</button>
)
)}
</div>
<button
onClick={onRemove}
title={t("inbox.dismiss")}
aria-label={t("inbox.dismiss")}
className="p-1.5 rounded-lg text-muted hover:text-fg hover:bg-card transition shrink-0"
>
<X className="w-4 h-4" />
</button>
</div>
);
}