Files
fusion/packages/dashboard/app/hooks/useChatUnread.ts
Fusion 9b51f01f90 feat(FN-4661): complete Step 1 — add useChatUnread hook
Fusion-Task-Id: FN-4661
Fusion-Task-Lineage: 39553dc3-4e7d-4398-b03f-24fa2edb9e20
2026-05-15 15:19:56 -07:00

173 lines
4.9 KiB
TypeScript

import { useCallback, useEffect, useMemo, useState } from "react";
import { getScopedItem, setScopedItem } from "../utils/projectStorage";
type ChatUnreadKind = "direct" | "room";
type ChatUnreadMap = Record<string, string>;
const STORAGE_KEYS: Record<ChatUnreadKind, string> = {
direct: "fusion:chat-unread:direct",
room: "fusion:chat-unread:rooms",
};
const MAX_ENTRIES_PER_KIND = 200;
function toTimestamp(value: string | undefined): number | null {
if (!value) {
return null;
}
const timestamp = new Date(value).getTime();
return Number.isFinite(timestamp) ? timestamp : null;
}
function pruneUnreadMap(map: ChatUnreadMap): ChatUnreadMap {
const entries = Object.entries(map);
if (entries.length <= MAX_ENTRIES_PER_KIND) {
return map;
}
const sorted = entries
.map(([id, timestamp]) => ({ id, timestamp, value: toTimestamp(timestamp) ?? 0 }))
.sort((a, b) => b.value - a.value)
.slice(0, MAX_ENTRIES_PER_KIND);
return Object.fromEntries(sorted.map(({ id, timestamp }) => [id, timestamp]));
}
function parseStoredMap(raw: string | null): ChatUnreadMap {
if (!raw) {
return {};
}
try {
const parsed = JSON.parse(raw);
if (!parsed || typeof parsed !== "object") {
return {};
}
const next: ChatUnreadMap = {};
for (const [id, value] of Object.entries(parsed as Record<string, unknown>)) {
if (typeof id === "string" && typeof value === "string") {
next[id] = value;
}
}
return pruneUnreadMap(next);
} catch {
return {};
}
}
/**
* Tracks per-conversation read state in project-scoped localStorage maps.
* Storage keys: `fusion:chat-unread:direct` and `fusion:chat-unread:rooms`, each storing
* a JSON object `{ [conversationId]: lastReadAtIso }` scoped via `projectId`.
* To bound storage growth, each map is capped to the 200 newest timestamps.
*/
export function useChatUnread(projectId: string | undefined): {
isUnread: (kind: ChatUnreadKind, id: string, lastActivityAt: string | undefined) => boolean;
markRead: (kind: ChatUnreadKind, id: string, asOf?: string) => void;
markAllRead: (kind: ChatUnreadKind, entries: Array<{ id: string; lastActivityAt?: string }>) => void;
} {
const [directReadMap, setDirectReadMap] = useState<ChatUnreadMap>(() => {
try {
return parseStoredMap(getScopedItem(STORAGE_KEYS.direct, projectId));
} catch {
return {};
}
});
const [roomReadMap, setRoomReadMap] = useState<ChatUnreadMap>(() => {
try {
return parseStoredMap(getScopedItem(STORAGE_KEYS.room, projectId));
} catch {
return {};
}
});
useEffect(() => {
try {
setDirectReadMap(parseStoredMap(getScopedItem(STORAGE_KEYS.direct, projectId)));
} catch {
setDirectReadMap({});
}
try {
setRoomReadMap(parseStoredMap(getScopedItem(STORAGE_KEYS.room, projectId)));
} catch {
setRoomReadMap({});
}
}, [projectId]);
const maps = useMemo(() => ({ direct: directReadMap, room: roomReadMap }), [directReadMap, roomReadMap]);
const persistMap = useCallback((kind: ChatUnreadKind, map: ChatUnreadMap) => {
const nextMap = pruneUnreadMap(map);
try {
setScopedItem(STORAGE_KEYS[kind], JSON.stringify(nextMap), projectId);
} catch {
// Ignore storage write failures.
}
return nextMap;
}, [projectId]);
const updateMap = useCallback((kind: ChatUnreadKind, updater: (previous: ChatUnreadMap) => ChatUnreadMap) => {
if (kind === "direct") {
setDirectReadMap((previous) => persistMap(kind, updater(previous)));
return;
}
setRoomReadMap((previous) => persistMap(kind, updater(previous)));
}, [persistMap]);
const isUnread = useCallback((kind: ChatUnreadKind, id: string, lastActivityAt: string | undefined): boolean => {
const activityTimestamp = toTimestamp(lastActivityAt);
if (activityTimestamp === null) {
return false;
}
const lastRead = maps[kind][id];
const readTimestamp = toTimestamp(lastRead);
if (readTimestamp === null) {
return true;
}
return activityTimestamp > readTimestamp;
}, [maps]);
const markRead = useCallback((kind: ChatUnreadKind, id: string, asOf?: string) => {
if (!id) {
return;
}
const timestamp = asOf ?? new Date().toISOString();
updateMap(kind, (previous) => ({
...previous,
[id]: timestamp,
}));
}, [updateMap]);
const markAllRead = useCallback((kind: ChatUnreadKind, entries: Array<{ id: string; lastActivityAt?: string }>) => {
if (entries.length === 0) {
return;
}
updateMap(kind, (previous) => {
const nextMap = { ...previous };
for (const entry of entries) {
if (!entry.id) {
continue;
}
nextMap[entry.id] = entry.lastActivityAt ?? new Date().toISOString();
}
return nextMap;
});
}, [updateMap]);
return {
isUnread,
markRead,
markAllRead,
};
}