| index 2bd5770..ad5381c 100644 |
| --- a/web/static/sw.js |
| +++ b/web/static/sw.js |
| @@ -59,6 +59,44 @@ async function decryptBlob(blob, keyB64) { |
| return JSON.parse(new TextDecoder().decode(pt)); |
| } |
| |
| +// File a pushed message row into the page's local message store (#457 phase |
| +// 2), so opening the app from this notification paints with the message |
| +// already in place — no fetch. The row is the SAME ciphertext the page's own |
| +// write-through stores; nothing decrypted is written. A classic-worker copy |
| +// of store.js's putMessages for one row — keep DB name/version/store names in |
| +// sync with store.js (its header notes this file). Best-effort: a failure |
| +// must never break the notification. |
| +function storePushedRow(threadID, id, at, blob) { |
| + return new Promise((resolve) => { |
| + let req; |
| + try { |
| + req = indexedDB.open("lchat-store", 1); |
| + } catch { |
| + resolve(); |
| + return; |
| + } |
| + req.onupgradeneeded = () => { |
| + // Mirror store.js exactly — whichever side opens first defines the DB. |
| + const db = req.result; |
| + if (!db.objectStoreNames.contains("kv")) db.createObjectStore("kv"); |
| + if (!db.objectStoreNames.contains("msgs")) db.createObjectStore("msgs", { keyPath: ["t", "id"] }); |
| + }; |
| + req.onsuccess = () => { |
| + try { |
| + const t = req.result.transaction("msgs", "readwrite"); |
| + t.objectStore("msgs").put({ id, created_at: at, blob, t: threadID }); |
| + t.oncomplete = () => resolve(); |
| + t.onabort = () => resolve(); |
| + t.onerror = () => resolve(); |
| + } catch { |
| + resolve(); |
| + } |
| + }; |
| + req.onerror = () => resolve(); |
| + req.onblocked = () => resolve(); |
| + }); |
| +} |
| + |
| // Is the app currently open and active? Prefer the window focus check, but fall |
| // back to the page's IndexedDB heartbeat — clients.matchAll()'s focus state is |
| // unreliable in the iOS PWA push handler, which let notifications through while |
| @@ -109,6 +147,20 @@ async function handlePush(event) { |
| }); |
| return; |
| } else { |
| + // File the row into the local message store first (#457 phase 2) — even |
| + // when the notification is suppressed below, an idempotent put costs |
| + // nothing and covers a page whose socket silently dropped. Gated exactly |
| + // like the page's own write-through: only chat threads (group/dm) whose |
| + // key the page synced; app-threads (kind "self") hydrate differently. |
| + if (data.blob && data.id && data.thread) { |
| + try { |
| + const known = (await idbGet("threadKeys")) || {}; |
| + const kt = known[data.thread]; |
| + if (kt && (kt.kind === "group" || kt.kind === "dm")) { |
| + await storePushedRow(data.thread, data.id, data.at || 0, data.blob); |
| + } |
| + } catch {} |
| + } |
| // Real message: skip if the app is open/active (you're already seeing it). |
| if (await appIsActive()) return; |
| try { |