agent-ecosystem/src/renderer/services/commentReadStorage.ts
2026-05-07 17:16:06 +03:00

392 lines
11 KiB
TypeScript

import { get, set } from 'idb-keyval';
const IDB_KEY = 'comment-read-state-v2';
const LS_KEY = 'comment-read-state-v2';
const LEGACY_IDB_KEY = 'comment-read-state';
const LEGACY_LS_KEY = 'comment-read-state';
const SAVE_DEBOUNCE_MS = 300;
const STALE_THRESHOLD_MS = 30 * 24 * 60 * 60 * 1000; // 30 days
/**
* Per-task read state: tracks individual comment IDs that have been seen.
* `lastUpdated` is used for stale cleanup (prune entries older than 30 days).
*/
interface TaskReadEntry {
readIds: string[];
lastUpdated: number;
manualUnread?: boolean;
}
type ReadState = Record<string, TaskReadEntry>; // key = "teamName/taskId"
// Legacy format for migration (v1 stored a single timestamp per task)
type LegacyReadState = Record<string, number>;
// --- localStorage helpers ---
function lsLoad(): ReadState | null {
try {
const raw = localStorage.getItem(LS_KEY);
if (!raw) return null;
const parsed: unknown = JSON.parse(raw);
if (!parsed || typeof parsed !== 'object' || Array.isArray(parsed)) return null;
return parsed as ReadState;
} catch {
return null;
}
}
function lsSave(state: ReadState): void {
try {
localStorage.setItem(LS_KEY, JSON.stringify(state));
} catch {
// localStorage full or unavailable — silently ignore
}
}
function lsLoadLegacy(): LegacyReadState | null {
try {
const raw = localStorage.getItem(LEGACY_LS_KEY);
if (!raw) return null;
const parsed: unknown = JSON.parse(raw);
if (!parsed || typeof parsed !== 'object' || Array.isArray(parsed)) return null;
// Verify it's the old format (values are numbers, not objects)
const entries = Object.entries(parsed as Record<string, unknown>);
if (entries.length > 0 && typeof entries[0][1] === 'number') {
return parsed as LegacyReadState;
}
return null;
} catch {
return null;
}
}
/**
* Migrate legacy per-task timestamp to per-comment ID format.
* Since we don't have comment IDs from the old format, we treat all
* comments with timestamps <= the old lastRead as "read" by storing
* a sentinel marker. The actual per-comment tracking starts fresh.
*/
function migrateLegacy(legacy: LegacyReadState): ReadState {
const migrated: ReadState = {};
for (const [key, timestamp] of Object.entries(legacy)) {
if (typeof timestamp === 'number' && timestamp > 0) {
// Store legacy timestamp as a sentinel — getUnreadCount will use it
// for comments older than migration, and per-ID for newer ones.
migrated[key] = {
readIds: [],
lastUpdated: timestamp,
};
}
}
return migrated;
}
// Synchronous init from localStorage — guarantees first render sees read state
let cache: ReadState = {};
const v2Data = lsLoad();
if (v2Data && Object.keys(v2Data).length > 0) {
cache = v2Data;
} else {
const legacyData = lsLoadLegacy();
if (legacyData && Object.keys(legacyData).length > 0) {
cache = migrateLegacy(legacyData);
}
}
let loaded = Object.keys(cache).length > 0;
let idbAvailable = true; // flips to false on first IndexedDB failure
let saveTimer: ReturnType<typeof setTimeout> | null = null;
const listeners = new Set<() => void>();
// --- useSyncExternalStore API ---
export function subscribe(listener: () => void): () => void {
listeners.add(listener);
if (!loaded) void load();
return () => {
listeners.delete(listener);
};
}
export function getSnapshot(): ReadState {
return cache;
}
// --- Mutations ---
/**
* Mark specific comment IDs as read for a given team/task.
*/
export function markCommentsRead(teamName: string, taskId: string, commentIds: string[]): void {
const key = `${teamName}/${taskId}`;
const prev = cache[key];
if (commentIds.length === 0) {
if (prev?.manualUnread) clearTaskManualUnread(teamName, taskId);
return;
}
const prevSet = new Set(prev?.readIds ?? []);
let changed = false;
for (const id of commentIds) {
if (!prevSet.has(id)) {
prevSet.add(id);
changed = true;
}
}
if (!changed && !prev?.manualUnread) return;
cache = {
...cache,
[key]: {
readIds: Array.from(prevSet),
lastUpdated: Date.now(),
},
};
notify();
scheduleSave();
}
/**
* @deprecated Use markCommentsRead() instead. Kept for backward compatibility
* with code that hasn't migrated yet (e.g. flush fallback).
*/
export function markAsRead(teamName: string, taskId: string, latestTimestamp: number): void {
const key = `${teamName}/${taskId}`;
const prev = cache[key];
// Update lastUpdated to at least this timestamp (for legacy migration support)
const prevLastUpdated = prev?.lastUpdated ?? 0;
if (latestTimestamp <= prevLastUpdated && prev && !prev.manualUnread) return;
cache = {
...cache,
[key]: {
readIds: prev?.readIds ?? [],
lastUpdated: Math.max(prevLastUpdated, latestTimestamp),
},
};
notify();
scheduleSave();
}
/**
* Manually mark a task as unread even when it has no unread comments.
*/
export function markTaskUnread(teamName: string, taskId: string): void {
const key = `${teamName}/${taskId}`;
const prev = cache[key];
if (prev?.manualUnread) return;
cache = {
...cache,
[key]: {
readIds: prev?.readIds ?? [],
lastUpdated: Date.now(),
manualUnread: true,
},
};
notify();
scheduleSave();
}
/**
* Clear only the manual unread marker. Comment read state is preserved.
*/
export function clearTaskManualUnread(teamName: string, taskId: string): void {
const key = `${teamName}/${taskId}`;
const prev = cache[key];
if (!prev?.manualUnread) return;
cache = {
...cache,
[key]: {
readIds: prev.readIds,
lastUpdated: Date.now(),
},
};
notify();
scheduleSave();
}
/**
* Count unread comments for a task.
* A comment is unread if its ID is NOT in the readIds set.
*
* Legacy migration: when readIds is empty (data migrated from v1 timestamp
* format), comments created at or before the legacy cutoff are treated as read.
* Once any per-ID tracking starts (readIds non-empty), the cutoff is ignored
* — only explicit IDs determine read state. This prevents `lastUpdated`
* (which is refreshed by markCommentsRead on every save for stale-cleanup
* purposes) from accidentally marking ALL comments as read.
*/
export function getUnreadCount(
readState: ReadState,
teamName: string,
taskId: string,
comments: { id?: string; createdAt: string }[]
): number {
const key = `${teamName}/${taskId}`;
const entry = readState[key];
if (!comments || comments.length === 0) return entry?.manualUnread ? 1 : 0;
if (!entry) return comments.length;
const readSet = new Set(entry.readIds);
// Only use the timestamp cutoff for pure-legacy entries (no per-ID tracking yet).
// Once readIds is non-empty, per-ID tracking is authoritative and the timestamp
// must NOT be used — it gets refreshed to Date.now() on every save.
const legacyCutoff = readSet.size === 0 ? entry.lastUpdated : 0;
let count = 0;
for (const c of comments) {
// If comment has an ID and it's in the read set → read
if (c.id && readSet.has(c.id)) continue;
// Legacy-only: comment created before/at the migration cutoff → read
if (legacyCutoff > 0) {
const ts = new Date(c.createdAt).getTime();
if (ts <= legacyCutoff) continue;
}
// Otherwise → unread
count++;
}
return entry.manualUnread && count === 0 ? 1 : count;
}
/**
* Get the set of read comment IDs for a team/task pair.
*/
export function getReadCommentIds(teamName: string, taskId: string): Set<string> {
const key = `${teamName}/${taskId}`;
const entry = cache[key];
return new Set(entry?.readIds ?? []);
}
/**
* Get the legacy migration cutoff timestamp for a team/task pair (0 if none).
* Returns non-zero only for pure-legacy entries where readIds is empty.
* Once per-ID tracking has started (readIds non-empty), the cutoff is 0
* because lastUpdated gets refreshed to Date.now() on every save and
* would incorrectly mark all comments as read.
*/
export function getLegacyCutoff(teamName: string, taskId: string): number {
const key = `${teamName}/${taskId}`;
const entry = cache[key];
if (!entry) return 0;
// Only honour the timestamp when no per-ID tracking exists (pure legacy data).
if (entry.readIds.length > 0) return 0;
return entry.lastUpdated;
}
/** @deprecated Use getReadCommentIds() + getLegacyCutoff() instead. */
export function getLastReadTimestamp(teamName: string, taskId: string): number {
const key = `${teamName}/${taskId}`;
return cache[key]?.lastUpdated ?? 0;
}
// --- Internal ---
function hasIndexedDB(): boolean {
return typeof indexedDB !== 'undefined';
}
function notify(): void {
listeners.forEach((l) => l());
}
function scheduleSave(): void {
if (saveTimer) clearTimeout(saveTimer);
saveTimer = setTimeout(() => {
saveTimer = null;
void save();
}, SAVE_DEBOUNCE_MS);
}
async function load(): Promise<void> {
if (loaded) return;
if (hasIndexedDB() && idbAvailable) {
try {
// Try v2 format first
const stored = await get<ReadState>(IDB_KEY);
if (stored && typeof stored === 'object') {
const merged = { ...cache };
for (const [k, v] of Object.entries(stored)) {
if (!v || typeof v !== 'object') continue;
const entry = v;
const prev = merged[k];
if (!prev) {
merged[k] = entry;
} else {
// Merge: union of readIds, max lastUpdated
const mergedIds = new Set([...prev.readIds, ...entry.readIds]);
merged[k] = {
readIds: Array.from(mergedIds),
lastUpdated: Math.max(prev.lastUpdated, entry.lastUpdated),
...(prev.manualUnread || entry.manualUnread ? { manualUnread: true } : {}),
};
}
}
cache = merged;
notify();
} else {
// Try legacy IDB format
const legacy = await get<LegacyReadState>(LEGACY_IDB_KEY);
if (legacy && typeof legacy === 'object') {
const migrated = migrateLegacy(legacy);
const merged = { ...cache };
for (const [k, v] of Object.entries(migrated)) {
if (!merged[k]) {
merged[k] = v;
} else {
merged[k] = {
readIds: [...new Set([...merged[k].readIds, ...v.readIds])],
lastUpdated: Math.max(merged[k].lastUpdated, v.lastUpdated),
...(merged[k].manualUnread || v.manualUnread ? { manualUnread: true } : {}),
};
}
}
cache = merged;
notify();
}
}
} catch {
idbAvailable = false;
}
}
loaded = true;
}
async function save(): Promise<void> {
// Always write to localStorage (sync, reliable)
lsSave(cache);
// Also write to IndexedDB (async, primary)
if (idbAvailable && hasIndexedDB()) {
try {
await set(IDB_KEY, cache);
} catch {
idbAvailable = false;
}
}
}
export async function cleanupStale(): Promise<void> {
const now = Date.now();
let changed = false;
const result: ReadState = {};
for (const [k, v] of Object.entries(cache)) {
if (now - v.lastUpdated < STALE_THRESHOLD_MS) {
result[k] = v;
} else {
changed = true;
}
}
if (!changed) return;
// Update in-memory cache
cache = result;
notify();
// Persist to both storages
lsSave(result);
if (idbAvailable && hasIndexedDB()) {
try {
await set(IDB_KEY, result);
} catch {
idbAvailable = false;
}
}
}