313 lines
15 KiB
TypeScript
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>
|
|
);
|
|
}
|