feat(FN-4661): complete Step 1 — add useChatUnread hook

Fusion-Task-Id: FN-4661
Fusion-Task-Lineage: 39553dc3-4e7d-4398-b03f-24fa2edb9e20
This commit is contained in:
Fusion
2026-05-15 15:02:06 -07:00
committed by gsxdsm
parent df28dcf982
commit 9b51f01f90
2 changed files with 253 additions and 0 deletions

View File

@@ -0,0 +1,81 @@
import { act, renderHook } from "@testing-library/react";
import { beforeEach, describe, expect, it } from "vitest";
import { useChatUnread } from "../useChatUnread";
describe("useChatUnread", () => {
beforeEach(() => {
localStorage.clear();
});
it("treats unknown conversation as unread when activity exists", () => {
const { result } = renderHook(() => useChatUnread("p1"));
expect(result.current.isUnread("direct", "s1", "2026-05-15T00:00:00.000Z")).toBe(true);
});
it("markRead clears unread when activity is not newer", () => {
const { result } = renderHook(() => useChatUnread("p1"));
act(() => {
result.current.markRead("direct", "s1", "2026-05-15T00:00:00.000Z");
});
expect(result.current.isUnread("direct", "s1", "2026-05-15T00:00:00.000Z")).toBe(false);
expect(result.current.isUnread("direct", "s1", "2026-05-14T23:59:00.000Z")).toBe(false);
});
it("returns false for missing or invalid activity timestamps", () => {
const { result } = renderHook(() => useChatUnread("p1"));
expect(result.current.isUnread("direct", "s1", undefined)).toBe(false);
expect(result.current.isUnread("room", "r1", "invalid-date")).toBe(false);
});
it("persists and reloads from project-scoped storage", () => {
const { result, unmount } = renderHook(() => useChatUnread("p1"));
act(() => {
result.current.markRead("room", "r1", "2026-05-15T01:00:00.000Z");
});
unmount();
const { result: reloaded } = renderHook(() => useChatUnread("p1"));
expect(reloaded.current.isUnread("room", "r1", "2026-05-15T00:59:00.000Z")).toBe(false);
expect(reloaded.current.isUnread("room", "r1", "2026-05-15T01:01:00.000Z")).toBe(true);
});
it("isolates unread maps by project scope", () => {
const { result, rerender } = renderHook(({ projectId }: { projectId: string }) => useChatUnread(projectId), {
initialProps: { projectId: "p1" },
});
act(() => {
result.current.markRead("direct", "s1", "2026-05-15T02:00:00.000Z");
});
rerender({ projectId: "p2" });
expect(result.current.isUnread("direct", "s1", "2026-05-15T02:00:00.000Z")).toBe(true);
rerender({ projectId: "p1" });
expect(result.current.isUnread("direct", "s1", "2026-05-15T02:00:00.000Z")).toBe(false);
});
it("evicts oldest entries when map exceeds cap", () => {
const { result } = renderHook(() => useChatUnread("p1"));
act(() => {
const base = new Date("2026-05-15T00:00:00.000Z").getTime();
for (let index = 0; index < 205; index += 1) {
result.current.markRead("direct", `s-${index}`, new Date(base + index * 60_000).toISOString());
}
});
const storedRaw = localStorage.getItem("kb:p1:fusion:chat-unread:direct");
expect(storedRaw).toBeTruthy();
const stored = JSON.parse(storedRaw ?? "{}");
expect(Object.keys(stored)).toHaveLength(200);
expect(stored["s-204"]).toBeDefined();
expect(stored["s-0"]).toBeUndefined();
});
});

View File

@@ -0,0 +1,172 @@
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,
};
}