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:
81
packages/dashboard/app/hooks/__tests__/useChatUnread.test.ts
Normal file
81
packages/dashboard/app/hooks/__tests__/useChatUnread.test.ts
Normal 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();
|
||||
});
|
||||
});
|
||||
172
packages/dashboard/app/hooks/useChatUnread.ts
Normal file
172
packages/dashboard/app/hooks/useChatUnread.ts
Normal 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,
|
||||
};
|
||||
}
|
||||
Reference in New Issue
Block a user