sueta / client / src / app / history.ts
 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
/**
 * Device-local room history (IndexedDB). Each device remembers the rooms it
 * was in — messages plus recent image blobs — so rejoining alone restores
 * your view, and you become a history source for everyone who joins later.
 * Nothing here ever leaves the device except through the normal encrypted
 * peer-to-peer history sync.
 *
 * Note: like the room list, this is stored readable-on-device — the room
 * links in localStorage already carry the secrets, so at-rest encryption here
 * would add nothing against someone who has the browser profile.
 */

export interface PersistMsg {
  id: string;
  from: string;
  name: string;
  ts: number;
  seq: number;
  text?: string;
  thread?: string;
  img?: { w: number; h: number; bytes: number };
  rx?: [string, string[]][];
  blob?: Blob; // present for the most recent images
}

export interface StoredRoom {
  msgs: PersistMsg[];
  mineIds: string[]; // messages authored on this device (for "mine" styling)
  savedAt: number;
}

const DB_NAME = "sueta-history";
const STORE = "rooms";
const MAX_ROOMS = 50;

function openDB(): Promise<IDBDatabase> {
  return new Promise((resolve, reject) => {
    const req = indexedDB.open(DB_NAME, 1);
    req.onupgradeneeded = () => req.result.createObjectStore(STORE);
    req.onsuccess = () => resolve(req.result);
    req.onerror = () => reject(req.error);
  });
}

export async function loadHistory(roomId: string): Promise<StoredRoom | null> {
  try {
    const db = await openDB();
    return await new Promise((resolve) => {
      const req = db.transaction(STORE).objectStore(STORE).get(roomId);
      req.onsuccess = () => resolve((req.result as StoredRoom) ?? null);
      req.onerror = () => resolve(null);
    });
  } catch {
    return null; // no IDB (private mode) — history is session-only, app still works
  }
}

export async function saveHistory(roomId: string, rec: StoredRoom): Promise<void> {
  try {
    const db = await openDB();
    await new Promise<void>((resolve) => {
      const tx = db.transaction(STORE, "readwrite");
      tx.objectStore(STORE).put(rec, roomId);
      tx.oncomplete = () => resolve();
      tx.onerror = () => resolve();
    });
    void pruneOldRooms(db);
  } catch {}
}

/** Keep the store bounded: drop the oldest rooms beyond MAX_ROOMS. */
async function pruneOldRooms(db: IDBDatabase): Promise<void> {
  try {
    const keys: string[] = [];
    const stamps = new Map<string, number>();
    await new Promise<void>((resolve) => {
      const cur = db.transaction(STORE).objectStore(STORE).openCursor();
      cur.onsuccess = () => {
        const c = cur.result;
        if (!c) return resolve();
        keys.push(String(c.key));
        stamps.set(String(c.key), (c.value as StoredRoom)?.savedAt ?? 0);
        c.continue();
      };
      cur.onerror = () => resolve();
    });
    if (keys.length <= MAX_ROOMS) return;
    keys.sort((a, b) => (stamps.get(a) ?? 0) - (stamps.get(b) ?? 0));
    const tx = db.transaction(STORE, "readwrite");
    for (const k of keys.slice(0, keys.length - MAX_ROOMS)) tx.objectStore(STORE).delete(k);
  } catch {}
}