Files
HiveGrid-Wireframes/hivegrid-cloud/apps/web/app/components/notifications/NotificationCenter.tsx
T

313 lines
15 KiB
TypeScript

import { useEffect, useRef, useState, type ReactNode } from "react";
import { useTranslation } from "react-i18next";
import type { LocaleFormatters } from "~/locale/format";
import { useLocaleFormatters } from "~/locale/format";
import { translateLabel } from "~/utils/i18n/translateLabel";
export type NotificationCenterItem = {
id: string;
messageKey: string;
category: string;
channel: "IN_APP" | string;
status: "PENDING" | "SENT" | "READ" | "FAILED" | "SKIPPED" | string;
attemptsMade: number;
createdAt?: string | Date | null;
updatedAt?: string | Date | null;
sentAt?: string | Date | null;
readAt?: string | Date | null;
lastError?: string | null;
};
type NotificationCenterProps = {
csrf?: string | null;
items: NotificationCenterItem[];
streamUrl?: string | null;
title?: ReactNode;
emptyMessage?: ReactNode;
};
type StreamPayload = {
cursor?: unknown;
id?: unknown;
items?: unknown;
deliveries?: unknown;
item?: unknown;
delivery?: unknown;
notification?: unknown;
};
const MAX_NOTIFICATION_ITEMS = 50;
const RECONNECT_DELAY_MS = 1_000;
const MAX_RECONNECT_DELAY_MS = 30_000;
const statusStyles: Record<string, string> = {
FAILED: "border-red-200 bg-red-50 text-red-700",
PENDING: "border-amber-200 bg-amber-50 text-amber-700",
READ: "border-emerald-200 bg-emerald-50 text-emerald-700",
SKIPPED: "border-slate-200 bg-slate-50 text-slate-700",
SENT: "border-blue-200 bg-blue-50 text-blue-700",
UNREAD: "border-blue-200 bg-blue-50 text-blue-700",
};
function formatTimestamp(value: NotificationCenterItem["updatedAt"], t: ReturnType<typeof useTranslation>["t"], formatters: LocaleFormatters): string {
if (!value) return String(t("notifications.center.timestamps.notRecorded"));
const date = value instanceof Date ? value : new Date(value);
return Number.isNaN(date.getTime()) ? String(t("notifications.center.timestamps.invalid")) : formatters.dateTime(date, { dateStyle: "medium", timeStyle: "short" });
}
function formatEnumLabel(t: ReturnType<typeof useTranslation>["t"], i18n: ReturnType<typeof useTranslation>["i18n"], key: string, fallbackKey: string): string {
return i18n.exists(key) ? String(t(key)) : String(t(fallbackKey));
}
function displayStatus(t: ReturnType<typeof useTranslation>["t"], i18n: ReturnType<typeof useTranslation>["i18n"], status: string): string {
return formatEnumLabel(t, i18n, `notifications.center.statuses.${status.toLowerCase()}`, "notifications.center.statuses.unknown");
}
function formatMessageLabel(t: ReturnType<typeof useTranslation>["t"], i18n: ReturnType<typeof useTranslation>["i18n"], messageKey: string): string {
if (!i18n.exists(messageKey)) {
return String(t("notifications.center.unknownMessage"));
}
const translatedLabel = translateLabel(t, i18n, messageKey);
return translatedLabel === messageKey ? String(t("notifications.center.unknownMessage")) : translatedLabel;
}
function formatCategoryLabel(t: ReturnType<typeof useTranslation>["t"], i18n: ReturnType<typeof useTranslation>["i18n"], category: string): string {
return formatEnumLabel(t, i18n, `notifications.center.categories.${category.toLowerCase()}`, "notifications.center.categories.unknown");
}
function formatChannelLabel(t: ReturnType<typeof useTranslation>["t"], i18n: ReturnType<typeof useTranslation>["i18n"], channel: string): string {
return formatEnumLabel(t, i18n, `notifications.center.channels.${channel.toLowerCase()}`, "notifications.center.channels.unknown");
}
function redactLastError(lastError: string): string {
const withoutTokenPairs = lastError.replace(/\b((?:api[_-]?key|secret|token|password|provider[_-]?id))\s*[:=]\s*[^\s,;]+/gi, "$1=[redacted]");
const withoutLikelyBearer = withoutTokenPairs.replace(/\b(?:sk|pk|provider|secret|token)_[A-Za-z0-9_-]{6,}\b/g, "[redacted]");
return withoutLikelyBearer.length > 160 ? `${withoutLikelyBearer.slice(0, 157)}...` : withoutLikelyBearer;
}
function StatusBadge({ label, status }: { label: string; status: string }) {
const style = statusStyles[status] ?? "border-slate-200 bg-slate-50 text-slate-700";
return <span className={`inline-flex items-center rounded-full border px-2.5 py-0.5 text-xs font-medium ${style}`}>{label}</span>;
}
function serializeDateField(value: unknown): string | null | undefined {
if (value === null || value === undefined) return value as null | undefined;
if (value instanceof Date) return value.toISOString();
return typeof value === "string" ? value : undefined;
}
function normalizeStreamItem(value: unknown): NotificationCenterItem | null {
if (!value || typeof value !== "object") return null;
const record = value as Record<string, unknown>;
if (typeof record.id !== "string" || record.id.trim().length === 0) return null;
return {
id: record.id,
messageKey: typeof record.messageKey === "string" ? record.messageKey : "notification.delivery",
category: typeof record.category === "string" ? record.category : "notification",
channel: typeof record.channel === "string" ? record.channel : "IN_APP",
status: typeof record.status === "string" ? record.status : "PENDING",
attemptsMade: typeof record.attemptsMade === "number" && Number.isFinite(record.attemptsMade) ? record.attemptsMade : 0,
createdAt: serializeDateField(record.createdAt),
updatedAt: serializeDateField(record.updatedAt),
sentAt: serializeDateField(record.sentAt),
readAt: serializeDateField(record.readAt),
lastError: typeof record.lastError === "string" ? record.lastError : record.lastError === null ? null : undefined,
};
}
function getPayloadItems(payload: StreamPayload): NotificationCenterItem[] {
const snapshotItems = Array.isArray(payload.items) ? payload.items : Array.isArray(payload.deliveries) ? payload.deliveries : null;
if (snapshotItems) return snapshotItems.map(normalizeStreamItem).filter((item): item is NotificationCenterItem => Boolean(item));
const singleItem = normalizeStreamItem(payload.item ?? payload.delivery ?? payload.notification ?? payload);
return singleItem ? [singleItem] : [];
}
function getEventCursor(event: MessageEvent, payload: StreamPayload): string | null {
if (typeof payload.cursor === "string" && payload.cursor.trim().length > 0) return payload.cursor;
if (typeof payload.id === "string" && payload.id.trim().length > 0) return payload.id;
return event.lastEventId?.trim() ? event.lastEventId : null;
}
function parseStreamEvent(event: MessageEvent): { cursor: string | null; items: NotificationCenterItem[] } | null {
try {
const payload = JSON.parse(event.data || "{}");
if (!payload || typeof payload !== "object") return null;
return { cursor: getEventCursor(event, payload as StreamPayload), items: getPayloadItems(payload as StreamPayload) };
} catch {
return null;
}
}
function itemTimestamp(item: NotificationCenterItem): number {
const timestamp = item.updatedAt ?? item.sentAt ?? item.createdAt;
const date = timestamp instanceof Date ? timestamp : timestamp ? new Date(timestamp) : null;
return date && !Number.isNaN(date.getTime()) ? date.getTime() : 0;
}
function mergeNotificationItems(currentItems: NotificationCenterItem[], incomingItems: NotificationCenterItem[]) {
if (incomingItems.length === 0) return currentItems;
const byId = new Map(currentItems.map((item) => [item.id, item]));
for (const item of incomingItems) byId.set(item.id, { ...byId.get(item.id), ...item });
return Array.from(byId.values())
.sort((left, right) => itemTimestamp(right) - itemTimestamp(left))
.slice(0, MAX_NOTIFICATION_ITEMS);
}
function buildReconnectUrl(streamUrl: string, cursor: string | null) {
if (!cursor) return streamUrl;
const url = new URL(streamUrl, window.location.origin);
url.searchParams.set("afterCursor", cursor);
return url.toString();
}
export function NotificationCenter({ csrf = null, items, streamUrl = null, title, emptyMessage }: NotificationCenterProps) {
const { t, i18n } = useTranslation();
const formatters = useLocaleFormatters();
const resolvedTitle = title ?? t("notifications.center.title");
const resolvedEmptyMessage = emptyMessage ?? t("notifications.center.empty");
const [deliveryItems, setDeliveryItems] = useState(() => items.slice(0, MAX_NOTIFICATION_ITEMS));
const [connectionStatusKey, setConnectionStatusKey] = useState(
streamUrl ? "notifications.center.realtime.connecting" : "notifications.center.realtime.unavailable"
);
const cursorRef = useRef<string | null>(null);
const reconnectTimerRef = useRef<number | null>(null);
const reconnectDelayRef = useRef(RECONNECT_DELAY_MS);
useEffect(() => {
setDeliveryItems((currentItems) => mergeNotificationItems(items.slice(0, MAX_NOTIFICATION_ITEMS), currentItems));
}, [items]);
useEffect(() => {
if (!streamUrl || typeof window === "undefined" || typeof window.EventSource === "undefined") return;
const activeStreamUrl = streamUrl;
let closed = false;
let source: EventSource | null = null;
function clearReconnectTimer() {
if (reconnectTimerRef.current) {
window.clearTimeout(reconnectTimerRef.current);
reconnectTimerRef.current = null;
}
}
function connect() {
clearReconnectTimer();
source = new EventSource(buildReconnectUrl(activeStreamUrl, cursorRef.current));
setConnectionStatusKey("notifications.center.realtime.connecting");
function handleMessage(event: MessageEvent) {
const parsed = parseStreamEvent(event);
if (!parsed) return;
if (parsed.cursor && parsed.items.length > 0) cursorRef.current = parsed.cursor;
setDeliveryItems((currentItems) => mergeNotificationItems(currentItems, parsed.items));
reconnectDelayRef.current = RECONNECT_DELAY_MS;
setConnectionStatusKey("notifications.center.realtime.connected");
}
function handleOpen() {
reconnectDelayRef.current = RECONNECT_DELAY_MS;
setConnectionStatusKey("notifications.center.realtime.connected");
}
function handleError() {
if (closed) return;
source?.close();
setConnectionStatusKey("notifications.center.realtime.reconnecting");
const reconnectDelay = reconnectDelayRef.current;
reconnectDelayRef.current = Math.min(reconnectDelay * 2, MAX_RECONNECT_DELAY_MS);
reconnectTimerRef.current = window.setTimeout(connect, reconnectDelay);
}
source.addEventListener("open", handleOpen);
source.addEventListener("notification.snapshot", handleMessage);
source.addEventListener("notification.update", handleMessage);
source.addEventListener("error", handleError);
}
connect();
return () => {
closed = true;
clearReconnectTimer();
source?.close();
};
}, [streamUrl]);
return (
<section aria-labelledby="notification-center-title" className="space-y-4">
<div>
<h1 id="notification-center-title" className="text-foreground text-2xl font-semibold">
{resolvedTitle}
</h1>
<p className="text-muted-foreground mt-1 text-sm">{t("notifications.center.diagnostics")}</p>
<p className="sr-only" role="status">
{t(connectionStatusKey)}
</p>
</div>
{deliveryItems.length === 0 ? (
<div className="border-border bg-muted/30 rounded-lg border border-dashed p-8 text-center" role="status">
<p className="text-foreground text-sm font-medium">{resolvedEmptyMessage}</p>
<p className="text-muted-foreground mt-1 text-sm">{t("notifications.center.emptyHelper")}</p>
</div>
) : (
<ul aria-label={String(t("notifications.center.listLabel"))} className="space-y-3">
{deliveryItems.map((item) => {
const isRead = item.status === "READ";
const lastError = item.lastError ? redactLastError(item.lastError) : null;
const messageLabel = formatMessageLabel(t, i18n, item.messageKey);
const statusLabel = displayStatus(t, i18n, item.status);
const categoryLabel = formatCategoryLabel(t, i18n, item.category);
const channelLabel = formatChannelLabel(t, i18n, item.channel);
return (
<li key={item.id} className="border-border bg-background rounded-lg border p-4 shadow-xs">
<article aria-labelledby={`notification-${item.id}-title`} className="space-y-3">
<div className="flex flex-col gap-2 sm:flex-row sm:items-start sm:justify-between">
<div>
<h2 id={`notification-${item.id}-title`} className="text-foreground text-base font-semibold">
{messageLabel}
</h2>
<p className="text-muted-foreground mt-1 text-sm">
{categoryLabel} · {channelLabel}
</p>
</div>
<StatusBadge label={statusLabel} status={item.status} />
</div>
<dl className="text-muted-foreground grid gap-2 text-sm sm:grid-cols-2 lg:grid-cols-4">
<div>
<dt className="text-foreground font-medium">{t("notifications.center.fields.attempts")}</dt>
<dd>{formatters.number(item.attemptsMade)}</dd>
</div>
<div>
<dt className="text-foreground font-medium">{t("notifications.center.fields.sent")}</dt>
<dd>{formatTimestamp(item.sentAt, t, formatters)}</dd>
</div>
<div>
<dt className="text-foreground font-medium">{t("notifications.center.fields.read")}</dt>
<dd>{formatTimestamp(item.readAt, t, formatters)}</dd>
</div>
<div>
<dt className="text-foreground font-medium">{t("notifications.center.fields.updated")}</dt>
<dd>{formatTimestamp(item.updatedAt, t, formatters)}</dd>
</div>
</dl>
{lastError ? (
<p className="rounded-md border border-red-200 bg-red-50 p-3 text-sm text-red-700">
<span className="font-medium">{t("notifications.center.lastError")}</span> {lastError}
</p>
) : null}
{!isRead ? (
<form method="post">
{csrf ? <input type="hidden" name="csrf" value={csrf} readOnly /> : null}
<input type="hidden" name="action" value="mark-read" />
<input type="hidden" name="deliveryId" value={item.id} />
<button
type="submit"
className="bg-primary text-primary-foreground hover:bg-primary/90 focus:ring-ring inline-flex items-center rounded-md px-3 py-2 text-sm font-medium focus:ring-2 focus:ring-offset-2 focus:outline-hidden"
aria-label={String(t("notifications.center.markReadLabel", { message: messageLabel }))}
>
{t("notifications.center.markRead")}
</button>
</form>
) : null}
</article>
</li>
);
})}
</ul>
)}
</section>
);
}