import { getConversation, listConversations, updateConversationFromMessage, updateConversationOpenState, } from "../storage/dmDb.js"; import { userLogger } from "../utils/logger.js"; function sanitizeTimestamp(value) { const numeric = Number(value); if (!Number.isFinite(numeric) || numeric <= 0) { return 0; } return Math.floor(numeric); } function normalizeConversationState(conversation) { if (!conversation || typeof conversation !== "object") { return { conversation_id: "", opened_until: 0, downloaded_until: 0, unseen_count: 0, }; } return { conversation_id: conversation.conversation_id || "", opened_until: sanitizeTimestamp(conversation.opened_until), downloaded_until: sanitizeTimestamp(conversation.downloaded_until), last_message_at: sanitizeTimestamp(conversation.last_message_at), unseen_count: Number.isFinite(Number(conversation.unseen_count)) ? Number(conversation.unseen_count) : 0, }; } export class DmNotificationManager { constructor({ logger } = {}) { this.logger = logger || userLogger; this.conversationCache = new Map(); this.updateQueue = new Map(); this.focusedConversationId = ""; } getFocusedConversationId() { return this.focusedConversationId; } enqueueUpdate(conversationId, task) { const key = conversationId || "global"; const previous = this.updateQueue.get(key) || Promise.resolve(); const next = previous .then(() => task()) .catch((error) => { this.logger.warn( `[dmNotifications] Update task failed for ${key}:`, error, ); }); this.updateQueue.set(key, next); return next; } reset() { this.conversationCache.clear(); this.updateQueue.clear(); this.focusedConversationId = ""; } setFocusedConversation(conversationId, isFocused = true) { if (typeof conversationId !== "string") { return; } const normalized = conversationId.trim(); if (!normalized) { return; } if (isFocused) { this.focusedConversationId = normalized; return; } if (this.focusedConversationId === normalized) { this.focusedConversationId = ""; } } async ensureConversationState(conversationId) { if (typeof conversationId !== "string" || !conversationId.trim()) { return null; } const normalized = conversationId.trim(); if (this.conversationCache.has(normalized)) { return this.conversationCache.get(normalized); } try { const stored = await getConversation(normalized); const normalizedState = normalizeConversationState({ conversation_id: normalized, ...stored, }); this.conversationCache.set(normalized, normalizedState); return normalizedState; } catch (error) { this.logger.warn("[dmNotifications] Failed to hydrate conversation", error); const fallback = normalizeConversationState({ conversation_id: normalized }); this.conversationCache.set(normalized, fallback); return fallback; } } recordMessage({ record, preview = "", direction = "" } = {}) { if (!record || typeof record !== "object") { return Promise.resolve(null); } const conversationId = typeof record.conversation_id === "string" ? record.conversation_id.trim() : ""; if (!conversationId) { return Promise.resolve(null); } return this.enqueueUpdate(conversationId, async () => { const createdAt = sanitizeTimestamp(record.created_at); const conversationState = await this.ensureConversationState(conversationId); if (!conversationState) { return null; } const isIncoming = direction === "incoming"; const isFocused = this.focusedConversationId === conversationId; const shouldNotify = isIncoming && !isFocused && createdAt > conversationState.opened_until; const unseenDelta = isIncoming && createdAt > conversationState.opened_until ? 1 : 0; let updated = null; try { updated = await updateConversationFromMessage(record, { preview, unseenDelta, downloadedUntil: createdAt, }); } catch (error) { this.logger.warn("[dmNotifications] Failed to update conversation", error); } const normalizedUpdate = normalizeConversationState({ conversation_id: conversationId, ...(updated || conversationState), opened_until: updated?.opened_until ?? conversationState.opened_until, }); this.conversationCache.set(conversationId, normalizedUpdate); return { conversation: normalizedUpdate, shouldNotify, isFocused, }; }); } acknowledgeRenderedMessages({ conversationId, renderedUntil } = {}) { if (typeof conversationId !== "string" || !conversationId.trim()) { return Promise.resolve(null); } const normalized = conversationId.trim(); return this.enqueueUpdate(normalized, async () => { const conversationState = await this.ensureConversationState(normalized); if (!conversationState) { return null; } const nextOpenedUntil = Math.max( conversationState.opened_until || 0, sanitizeTimestamp(renderedUntil), ); let updated = null; try { updated = await updateConversationOpenState(normalized, { openedUntil: nextOpenedUntil, unseenCount: 0, }); } catch (error) { this.logger.warn("[dmNotifications] Failed to update opened_until", error); } const normalizedUpdate = normalizeConversationState({ conversation_id: normalized, ...conversationState, ...updated, opened_until: nextOpenedUntil, unseen_count: 0, }); this.conversationCache.set(normalized, normalizedUpdate); return normalizedUpdate; }); } async listConversationSummaries() { try { const stored = await listConversations(); const summaries = stored.map((conversation) => normalizeConversationState(conversation), ); for (const summary of summaries) { if (summary.conversation_id) { this.conversationCache.set(summary.conversation_id, summary); } } return summaries; } catch (error) { this.logger.warn("[dmNotifications] Failed to list conversations", error); return []; } } getUnseenCount(conversationId) { if (typeof conversationId !== "string" || !conversationId.trim()) { return 0; } const cached = this.conversationCache.get(conversationId.trim()); return cached && Number.isFinite(Number(cached.unseen_count)) ? Number(cached.unseen_count) : 0; } }