| 1 | /** |
| 2 | * Conversations already seen, kept in the browser so switching back to one |
| 3 | * draws it at once (routes/workspace/chat/channel.tsx `clientLoader`), |
| 4 | * while the server is asked again behind it. The latest ~20, newest kept; |
| 5 | * in memory, and in IndexedDB when the browser allows it so a reload is |
| 6 | * instant too. Every IndexedDB step may fail (private windows, full |
| 7 | * disks); then only memory is used. |
| 8 | */ |
| 9 | |
| 10 | /** How many conversations are kept. */ |
| 11 | export const KEEP = 20; |
| 12 | |
| 13 | /** A conversation as it was last read, and when. */ |
| 14 | export type Kept<T> = { value: T; at: number }; |
| 15 | |
| 16 | /** A small LRU: the most recently used last. Pure, so it is tested on its own. */ |
| 17 | export class Recent<T> { |
| 18 | private items = new Map<string, Kept<T>>(); |
| 19 | private readonly keep: number; |
| 20 | constructor(keep = KEEP) { |
| 21 | this.keep = keep; |
| 22 | } |
| 23 | get(key: string): Kept<T> | undefined { |
| 24 | const found = this.items.get(key); |
| 25 | if (found) { |
| 26 | this.items.delete(key); |
| 27 | this.items.set(key, found); |
| 28 | } |
| 29 | return found; |
| 30 | } |
| 31 | has(key: string): boolean { |
| 32 | return this.items.has(key); |
| 33 | } |
| 34 | set(key: string, value: T, at = Date.now()): void { |
| 35 | this.items.delete(key); |
| 36 | this.items.set(key, { value, at }); |
| 37 | while (this.items.size > this.keep) { |
| 38 | const oldest = this.items.keys().next().value; |
| 39 | if (oldest === undefined) break; |
| 40 | this.items.delete(oldest); |
| 41 | } |
| 42 | } |
| 43 | keys(): string[] { |
| 44 | return [...this.items.keys()]; |
| 45 | } |
| 46 | } |
| 47 | |
| 48 | const DB = "g1t-chat"; |
| 49 | const STORE = "conversations"; |
| 50 | |
| 51 | function openDb(): Promise<IDBDatabase | null> { |
| 52 | return new Promise((resolve) => { |
| 53 | try { |
| 54 | if (typeof indexedDB === "undefined") return resolve(null); |
| 55 | const request = indexedDB.open(DB, 1); |
| 56 | request.onupgradeneeded = () => { |
| 57 | try { |
| 58 | request.result.createObjectStore(STORE); |
| 59 | } catch { |
| 60 | // Already there. |
| 61 | } |
| 62 | }; |
| 63 | request.onsuccess = () => resolve(request.result); |
| 64 | request.onerror = () => resolve(null); |
| 65 | request.onblocked = () => resolve(null); |
| 66 | } catch { |
| 67 | resolve(null); |
| 68 | } |
| 69 | }); |
| 70 | } |
| 71 | |
| 72 | let db: Promise<IDBDatabase | null> | null = null; |
| 73 | const database = () => (db ??= openDb()); |
| 74 | |
| 75 | /** The conversations kept, shared by every page in this tab. */ |
| 76 | export function createConversationCache<T>() { |
| 77 | const memory = new Recent<T>(); |
| 78 | return { |
| 79 | peek: (key: string) => memory.get(key)?.value, |
| 80 | has: (key: string) => memory.has(key), |
| 81 | set(key: string, value: T) { |
| 82 | memory.set(key, value); |
| 83 | void database().then((store) => { |
| 84 | if (!store) return; |
| 85 | try { |
| 86 | const tx = store.transaction(STORE, "readwrite"); |
| 87 | tx.objectStore(STORE).put({ value, at: Date.now() }, key); |
| 88 | // Keep the store as small as memory: drop what memory let go. |
| 89 | const kept = new Set(memory.keys()); |
| 90 | const keys = tx.objectStore(STORE).getAllKeys(); |
| 91 | keys.onsuccess = () => { |
| 92 | for (const stored of keys.result) if (!kept.has(String(stored))) tx.objectStore(STORE).delete(stored); |
| 93 | }; |
| 94 | } catch { |
| 95 | // Memory still has it. |
| 96 | } |
| 97 | }); |
| 98 | }, |
| 99 | /** From IndexedDB after a reload, when memory has nothing yet. */ |
| 100 | async load(key: string): Promise<T | undefined> { |
| 101 | const found = memory.get(key)?.value; |
| 102 | if (found !== undefined) return found; |
| 103 | const store = await database(); |
| 104 | if (!store) return undefined; |
| 105 | return new Promise((resolve) => { |
| 106 | try { |
| 107 | const request = store.transaction(STORE, "readonly").objectStore(STORE).get(key); |
| 108 | request.onsuccess = () => { |
| 109 | const kept = request.result as Kept<T> | undefined; |
| 110 | if (kept) memory.set(key, kept.value, kept.at); |
| 111 | resolve(kept?.value); |
| 112 | }; |
| 113 | request.onerror = () => resolve(undefined); |
| 114 | } catch { |
| 115 | resolve(undefined); |
| 116 | } |
| 117 | }); |
| 118 | }, |
| 119 | }; |
| 120 | } |