178 lines
7.7 KiB
TypeScript
178 lines
7.7 KiB
TypeScript
// AK-P3 (AD-28/S5) — shared SSE subscription for in-app notifications.
|
|
// Hosts (routes/NavBar) own this hook and inject the resulting items into the
|
|
// presentational screens as props; screens themselves never subscribe (DoD A.5).
|
|
// Stream records are mapped through the same pure VM mapper as loader data so
|
|
// live updates and initial payloads share one shape (deeplink, bounce keys).
|
|
|
|
import { useEffect, useRef, useState } from "react";
|
|
import {
|
|
mergeNotificationCenterItems,
|
|
toNotificationCenterItemVM,
|
|
NOTIFICATION_CENTER_MAX_ITEMS,
|
|
type NotificationCenterItemVM,
|
|
} from "@hivegrid/view-models";
|
|
|
|
const RECONNECT_DELAY_MS = 1_000;
|
|
const MAX_RECONNECT_DELAY_MS = 30_000;
|
|
/**
|
|
* A connection only counts as healthy once it survived this long. Resetting the
|
|
* backoff on every `open`/message meant a stream that dropped right after the
|
|
* snapshot reconnected once per second forever instead of backing off.
|
|
*/
|
|
const STABLE_CONNECTION_MS = 15_000;
|
|
|
|
type StreamPayload = {
|
|
cursor?: unknown;
|
|
id?: unknown;
|
|
items?: unknown;
|
|
deliveries?: unknown;
|
|
item?: unknown;
|
|
delivery?: unknown;
|
|
notification?: unknown;
|
|
};
|
|
|
|
function serializeDateField(value: unknown): string | null {
|
|
if (value instanceof Date) return value.toISOString();
|
|
return typeof value === "string" && value.trim().length > 0 ? value : null;
|
|
}
|
|
|
|
function normalizeStreamItem(value: unknown): NotificationCenterItemVM | 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 toNotificationCenterItemVM({
|
|
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,
|
|
createdAtIso: serializeDateField(record.createdAt),
|
|
updatedAtIso: serializeDateField(record.updatedAt),
|
|
sentAtIso: serializeDateField(record.sentAt),
|
|
readAtIso: serializeDateField(record.readAt),
|
|
lastError: typeof record.lastError === "string" ? record.lastError : null,
|
|
payloadMetadata: typeof record.payloadMetadata === "object" ? record.payloadMetadata : undefined,
|
|
deeplink: typeof record.deeplink === "string" ? record.deeplink : null,
|
|
});
|
|
}
|
|
|
|
function getPayloadItems(payload: StreamPayload): NotificationCenterItemVM[] {
|
|
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 NotificationCenterItemVM => 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: NotificationCenterItemVM[] } | 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 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 type NotificationsStreamState = {
|
|
items: NotificationCenterItemVM[];
|
|
unreadCount: number;
|
|
connectionStatusKey: string;
|
|
};
|
|
|
|
export function useNotificationsStream(
|
|
streamUrl: string | null | undefined,
|
|
initialItems: readonly NotificationCenterItemVM[]
|
|
): NotificationsStreamState {
|
|
const [items, setItems] = useState<NotificationCenterItemVM[]>(() => initialItems.slice(0, NOTIFICATION_CENTER_MAX_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);
|
|
const connectedAtRef = useRef<number | null>(null);
|
|
|
|
useEffect(() => {
|
|
// Revalidated loader data merges over the live state (never truncates it).
|
|
// merge(current, incoming): incoming wins per id — the fresh loader data
|
|
// must be the incoming side, otherwise stale live state shadows it.
|
|
setItems((currentItems) => mergeNotificationCenterItems(currentItems, initialItems.slice(0, NOTIFICATION_CENTER_MAX_ITEMS)));
|
|
}, [initialItems]);
|
|
|
|
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();
|
|
connectedAtRef.current = null;
|
|
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;
|
|
setItems((currentItems) => mergeNotificationCenterItems(currentItems, parsed.items));
|
|
setConnectionStatusKey("notifications.center.realtime.connected");
|
|
}
|
|
function handleOpen() {
|
|
connectedAtRef.current = Date.now();
|
|
setConnectionStatusKey("notifications.center.realtime.connected");
|
|
}
|
|
function handleError() {
|
|
if (closed) return;
|
|
source?.close();
|
|
setConnectionStatusKey("notifications.center.realtime.reconnecting");
|
|
// Only a connection that actually held counts as healthy. Otherwise a
|
|
// stream that dies right after opening keeps resetting the backoff and
|
|
// reconnects once per second forever.
|
|
const openedAt = connectedAtRef.current;
|
|
if (openedAt !== null && Date.now() - openedAt >= STABLE_CONNECTION_MS) {
|
|
reconnectDelayRef.current = RECONNECT_DELAY_MS;
|
|
}
|
|
connectedAtRef.current = null;
|
|
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 {
|
|
items,
|
|
unreadCount: items.filter((item) => !item.isRead).length,
|
|
connectionStatusKey,
|
|
};
|
|
}
|