- Remove clearRoomMessages on room switch: IDB cache now persists across visits, enabling instant re-entry without API round-trip - Increase initial API limit from 50 → 200: more messages cached upfront - Add loadOlderMessagesFromIdb: uses 'by_room_seq' compound index to serve scroll-back history from IDB without API call - loadMore now tries IDB first before falling back to API
233 lines
7.1 KiB
TypeScript
233 lines
7.1 KiB
TypeScript
/**
|
|
* 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<IDBDatabase> | null = null;
|
|
|
|
function openDB(): Promise<IDBDatabase> {
|
|
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<void> {
|
|
try {
|
|
const db = await openDB();
|
|
const tx = db.transaction(STORE_MESSAGES, 'readwrite');
|
|
tx.objectStore(STORE_MESSAGES).put(msgToStored(msg));
|
|
await new Promise<void>((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<void> {
|
|
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<void>((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<MessageWithMeta[]> {
|
|
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<void> {
|
|
try {
|
|
const db = await openDB();
|
|
const tx = db.transaction(STORE_MESSAGES, 'readwrite');
|
|
tx.objectStore(STORE_MESSAGES).delete(messageId);
|
|
await new Promise<void>((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<void> {
|
|
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<void>((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<MessageWithMeta[]> {
|
|
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<number> {
|
|
try {
|
|
const msgs = await loadMessages(roomId);
|
|
return msgs.length > 0 ? msgs[msgs.length - 1].seq : -1;
|
|
} catch {
|
|
return -1;
|
|
}
|
|
}
|