/** * IndexedDB storage for room messages. * Provides persistent message storage that survives page reloads and works offline. */ import type { MessageWithMeta } from '@/contexts/room-context'; import type { ReactionGroup } from '@/contexts'; const DB_NAME = 'room-messages'; const DB_VERSION = 1; const STORE_MESSAGES = 'messages'; const STORE_REACTIONS = 'reactions'; let dbPromise: Promise | null = null; function openDB(): Promise { if (dbPromise) return dbPromise; dbPromise = new Promise((resolve, reject) => { const request = indexedDB.open(DB_NAME, DB_VERSION); request.onerror = () => reject(request.error); request.onsuccess = () => resolve(request.result); request.onupgradeneeded = (event) => { const db = (event.target as IDBOpenDBRequest).result; // Messages store: keyed by message_id, indexed by room_id and seq if (!db.objectStoreNames.contains(STORE_MESSAGES)) { const msgStore = db.createObjectStore(STORE_MESSAGES, { keyPath: 'id' }); msgStore.createIndex('by_room', 'room', { unique: false }); msgStore.createIndex('by_room_seq', ['room', 'seq'], { unique: false }); } // Reactions store: keyed by message_id if (!db.objectStoreNames.contains(STORE_REACTIONS)) { db.createObjectStore(STORE_REACTIONS, { keyPath: 'message_id' }); } }; }); return dbPromise; } export interface StoredMessage { id: string; room: string; seq: number; sender_type: string; sender_id?: string; display_name?: string; thread_id?: string; content: string; content_type: string; edited_at?: string; send_at: string; revoked?: string; revoked_by?: string; reactions?: ReactionGroup[]; } function msgToStored(msg: MessageWithMeta): StoredMessage { return { id: msg.id, room: msg.room, seq: msg.seq, sender_type: msg.sender_type, sender_id: msg.sender_id ?? undefined, display_name: msg.display_name ?? undefined, thread_id: msg.thread_id, content: msg.content, content_type: msg.content_type, edited_at: msg.edited_at ?? undefined, send_at: msg.send_at, revoked: msg.revoked ?? undefined, revoked_by: msg.revoked_by ?? undefined, reactions: msg.reactions, }; } function storedToMsg(stored: StoredMessage): MessageWithMeta { return { ...stored, thread: stored.thread_id, display_content: stored.content, is_streaming: false, }; } /** Save a single message to IndexedDB */ export async function saveMessage(msg: MessageWithMeta): Promise { try { const db = await openDB(); const tx = db.transaction(STORE_MESSAGES, 'readwrite'); tx.objectStore(STORE_MESSAGES).put(msgToStored(msg)); await new Promise((resolve, reject) => { tx.oncomplete = () => resolve(); tx.onerror = () => reject(tx.error); }); } catch (err) { console.warn('[IDB] saveMessage failed:', err); } } /** Save multiple messages in a single transaction */ export async function saveMessages(_roomId: string, msgs: MessageWithMeta[]): Promise { if (msgs.length === 0) return; try { const db = await openDB(); const tx = db.transaction(STORE_MESSAGES, 'readwrite'); const store = tx.objectStore(STORE_MESSAGES); for (const msg of msgs) { store.put(msgToStored(msg)); } await new Promise((resolve, reject) => { tx.oncomplete = () => resolve(); tx.onerror = () => reject(tx.error); }); } catch (err) { console.warn('[IDB] saveMessages failed:', err); } } /** Load all messages for a room from IndexedDB, sorted by seq ascending */ export async function loadMessages(roomId: string): Promise { try { const db = await openDB(); const tx = db.transaction(STORE_MESSAGES, 'readonly'); const index = tx.objectStore(STORE_MESSAGES).index('by_room'); const request = index.getAll(IDBKeyRange.only(roomId)); return new Promise((resolve, reject) => { request.onsuccess = () => { const results: StoredMessage[] = request.result; const msgs = results.map(storedToMsg); msgs.sort((a, b) => a.seq - b.seq); resolve(msgs); }; request.onerror = () => reject(request.error); }); } catch (err) { console.warn('[IDB] loadMessages failed:', err); return []; } } /** Delete a message by id */ export async function deleteMessage(messageId: string): Promise { try { const db = await openDB(); const tx = db.transaction(STORE_MESSAGES, 'readwrite'); tx.objectStore(STORE_MESSAGES).delete(messageId); await new Promise((resolve, reject) => { tx.oncomplete = () => resolve(); tx.onerror = () => reject(tx.error); }); } catch (err) { console.warn('[IDB] deleteMessage failed:', err); } } /** Clear all messages for a room */ export async function clearRoomMessages(roomId: string): Promise { try { const db = await openDB(); const tx = db.transaction(STORE_MESSAGES, 'readwrite'); const index = tx.objectStore(STORE_MESSAGES).index('by_room'); const request = index.openCursor(IDBKeyRange.only(roomId)); await new Promise((resolve, reject) => { request.onsuccess = () => { const cursor = request.result; if (cursor) { cursor.delete(); cursor.continue(); } }; tx.oncomplete = () => resolve(); tx.onerror = () => reject(tx.error); }); } catch (err) { console.warn('[IDB] clearRoomMessages failed:', err); } } /** Load older messages from IDB (seq < beforeSeq), sorted ascending, up to `limit` */ export async function loadOlderMessagesFromIdb( roomId: string, beforeSeq: number, limit = 50, ): Promise { try { const db = await openDB(); const tx = db.transaction(STORE_MESSAGES, 'readonly'); const index = tx.objectStore(STORE_MESSAGES).index('by_room_seq'); // Compound key range: roomId + any seq less than beforeSeq const range = IDBKeyRange.bound([roomId, 0], [roomId, beforeSeq - 1]); const request = index.openCursor(range, 'prev'); // 'prev' = descending seq (newest first) // We want oldest before `beforeSeq`, so after getting `limit` items in 'prev' order, // reverse to ascending seq. const collected: StoredMessage[] = []; return new Promise((resolve, reject) => { request.onsuccess = () => { const cursor = request.result; if (cursor && collected.length < limit) { collected.push(cursor.value); cursor.continue(); } else { // Reverse back to ascending order (oldest first) const msgs = collected.reverse().map(storedToMsg); resolve(msgs); } }; request.onerror = () => reject(request.error); }); } catch (err) { console.warn('[IDB] loadOlderMessagesFromIdb failed:', err); return []; } } /** Get the highest seq number for a room (for dedup) */ export async function getMaxSeq(roomId: string): Promise { try { const msgs = await loadMessages(roomId); return msgs.length > 0 ? msgs[msgs.length - 1].seq : -1; } catch { return -1; } }