| 1 | // lchat service worker — handles Web Push. |
| 2 | // |
| 3 | // The push payload is the SAME ciphertext the server stores; it never sees |
| 4 | // plaintext. We decrypt here, in the browser, with the group key kept in |
| 5 | // IndexedDB (service workers can't read localStorage), so notifications can show |
| 6 | // real content while staying end-to-end encrypted. |
| 7 | |
| 8 | self.addEventListener("install", () => self.skipWaiting()); |
| 9 | self.addEventListener("activate", (e) => e.waitUntil(self.clients.claim())); |
| 10 | |
| 11 | function b64urlToBytes(s) { |
| 12 | s = s.replace(/-/g, "+").replace(/_/g, "/"); |
| 13 | while (s.length % 4) s += "="; |
| 14 | const bin = atob(s); |
| 15 | const out = new Uint8Array(bin.length); |
| 16 | for (let i = 0; i < bin.length; i++) out[i] = bin.charCodeAt(i); |
| 17 | return out; |
| 18 | } |
| 19 | |
| 20 | // Read a value from the "lchat" IndexedDB (shared with the page; see push.js). |
| 21 | // Close-after-read + versionchange handler for the same reason as idbSet there: |
| 22 | // a held connection would block a future schema upgrade in a page tab forever. |
| 23 | function idbGet(key) { |
| 24 | return new Promise((resolve, reject) => { |
| 25 | const req = indexedDB.open("lchat", 1); |
| 26 | req.onupgradeneeded = () => req.result.createObjectStore("kv"); |
| 27 | req.onsuccess = () => { |
| 28 | const db = req.result; |
| 29 | db.onversionchange = () => db.close(); |
| 30 | const tx = db.transaction("kv", "readonly"); |
| 31 | const g = tx.objectStore("kv").get(key); |
| 32 | g.onsuccess = () => { |
| 33 | db.close(); |
| 34 | resolve(g.result); |
| 35 | }; |
| 36 | g.onerror = () => { |
| 37 | db.close(); |
| 38 | reject(g.error); |
| 39 | }; |
| 40 | }; |
| 41 | req.onerror = () => reject(req.error); |
| 42 | req.onblocked = () => reject(new Error("blocked")); |
| 43 | }); |
| 44 | } |
| 45 | |
| 46 | // Per-viewer names (#577) in notification text. `renames` maps published |
| 47 | // label → the name this member calls them. A classic-worker copy of ui.js |
| 48 | // translateMentions (unit-tested there; this file can't import modules) — |
| 49 | // same boundary rules as the feed's renderer: longest label wins, no trailing |
| 50 | // word char, and a preceding word/@/. char means mid-word or an email. |
| 51 | function translateMentions(text, renames) { |
| 52 | const labels = Object.keys(renames || {}); |
| 53 | if (!text || !labels.length || !text.includes("@")) return text; |
| 54 | const esc = (s) => s.replace(/[.*+?^${}()|[\]\\]/g, "\\$&"); |
| 55 | const re = new RegExp("@(" + labels.sort((a, b) => b.length - a.length).map(esc).join("|") + ")(?![\\w])", "g"); |
| 56 | return text.replace(re, (m, label, idx) => { |
| 57 | const prev = text[idx - 1]; |
| 58 | return prev !== undefined && /[\w@.]/.test(prev) ? m : "@" + renames[label]; |
| 59 | }); |
| 60 | } |
| 61 | |
| 62 | async function decryptBlob(blob, keyB64) { |
| 63 | const key = await crypto.subtle.importKey( |
| 64 | "raw", b64urlToBytes(keyB64), "AES-GCM", false, ["decrypt"] |
| 65 | ); |
| 66 | const data = b64urlToBytes(blob); |
| 67 | const pt = await crypto.subtle.decrypt( |
| 68 | { name: "AES-GCM", iv: data.slice(0, 12) }, key, data.slice(12) |
| 69 | ); |
| 70 | return JSON.parse(new TextDecoder().decode(pt)); |
| 71 | } |
| 72 | |
| 73 | // File a pushed message row into the page's local message store (#457 phase |
| 74 | // 2), so opening the app from this notification paints with the message |
| 75 | // already in place — no fetch. The row is the SAME ciphertext the page's own |
| 76 | // write-through stores; nothing decrypted is written. A classic-worker copy |
| 77 | // of store.js's putMessages for one row — keep DB name/version/store names in |
| 78 | // sync with store.js (its header notes this file). Best-effort: a failure |
| 79 | // must never break the notification. |
| 80 | function storePushedRow(threadID, id, at, blob) { |
| 81 | return new Promise((resolve) => { |
| 82 | let req; |
| 83 | try { |
| 84 | req = indexedDB.open("lchat-store", 1); |
| 85 | } catch { |
| 86 | resolve(); |
| 87 | return; |
| 88 | } |
| 89 | req.onupgradeneeded = () => { |
| 90 | // Mirror store.js exactly — whichever side opens first defines the DB. |
| 91 | const db = req.result; |
| 92 | if (!db.objectStoreNames.contains("kv")) db.createObjectStore("kv"); |
| 93 | if (!db.objectStoreNames.contains("msgs")) db.createObjectStore("msgs", { keyPath: ["t", "id"] }); |
| 94 | }; |
| 95 | req.onsuccess = () => { |
| 96 | const db = req.result; |
| 97 | // Same door as idbGet above: close when done, step aside for upgrades. |
| 98 | db.onversionchange = () => db.close(); |
| 99 | const settle = () => { |
| 100 | try { |
| 101 | db.close(); |
| 102 | } catch {} |
| 103 | resolve(); |
| 104 | }; |
| 105 | try { |
| 106 | const t = db.transaction("msgs", "readwrite"); |
| 107 | t.objectStore("msgs").put({ id, created_at: at, blob, t: threadID }); |
| 108 | t.oncomplete = settle; |
| 109 | t.onabort = settle; |
| 110 | t.onerror = settle; |
| 111 | } catch { |
| 112 | settle(); |
| 113 | } |
| 114 | }; |
| 115 | req.onerror = () => resolve(); |
| 116 | req.onblocked = () => resolve(); |
| 117 | }); |
| 118 | } |
| 119 | |
| 120 | // Is the app currently open and active? Prefer the window focus check, but fall |
| 121 | // back to the page's IndexedDB heartbeat — clients.matchAll()'s focus state is |
| 122 | // unreliable in the iOS PWA push handler, which let notifications through while |
| 123 | // the app was open. |
| 124 | async function appIsActive() { |
| 125 | try { |
| 126 | const wins = await self.clients.matchAll({ type: "window", includeUncontrolled: true }); |
| 127 | if (wins.some((c) => c.visibilityState === "visible" && c.focused)) return true; |
| 128 | } catch {} |
| 129 | try { |
| 130 | const last = await idbGet("lastActiveAt"); |
| 131 | if (last && Date.now() - last < 8000) return true; |
| 132 | } catch {} |
| 133 | return false; |
| 134 | } |
| 135 | |
| 136 | self.addEventListener("push", (event) => { |
| 137 | event.waitUntil(handlePush(event)); |
| 138 | }); |
| 139 | |
| 140 | async function handlePush(event) { |
| 141 | let data = {}; |
| 142 | try { |
| 143 | data = event.data ? event.data.json() : {}; |
| 144 | } catch {} |
| 145 | |
| 146 | let title = "New message"; |
| 147 | let body = "Open the chat to read it"; |
| 148 | |
| 149 | if (data.test) { |
| 150 | // Test notification: always show, even if the app is focused. |
| 151 | title = "✅ Test notification"; |
| 152 | body = "Push notifications are working!"; |
| 153 | } else if (data.changelog) { |
| 154 | // Release notes for an admin (announce.go). Uniquely, the words arrive in |
| 155 | // clear — the changelog is branding-class plaintext baked into the binary, |
| 156 | // never user content — so there's nothing to decrypt. Shown even when the |
| 157 | // app is active: the deploy just restarted the server, and this banner is |
| 158 | // the one signal the update happened. Its own tag so it never collapses |
| 159 | // into (or gets replaced by) a message notification. |
| 160 | await self.registration.showNotification(String(data.title || "What's new"), { |
| 161 | body: String(data.body || ""), |
| 162 | icon: "/static/icon-192.png", |
| 163 | badge: "/static/icon-192.png", |
| 164 | tag: "lchat-changelog", |
| 165 | renotify: true, |
| 166 | data: {}, |
| 167 | }); |
| 168 | return; |
| 169 | } else if (data.report) { |
| 170 | // A report notification for an admin (issue #578): server-composed |
| 171 | // metadata ("N waiting"), never report content — the changelog banner's |
| 172 | // class, so like it, shown even while the app is open. Its own tag + |
| 173 | // renotify: a fresh burst replaces the old banner instead of stacking. |
| 174 | await self.registration.showNotification(String(data.title || "New report"), { |
| 175 | body: String(data.body || "Open the Admin panel to review."), |
| 176 | icon: "/static/icon-192.png", |
| 177 | badge: "/static/icon-192.png", |
| 178 | tag: "lchat-report", |
| 179 | renotify: true, |
| 180 | data: {}, |
| 181 | }); |
| 182 | return; |
| 183 | } else if (data.release) { |
| 184 | // A new server release sighted on this space's update channel |
| 185 | // (releasealert.go) — the heads-up that makes the supply-chain safety |
| 186 | // hold useful. Server-composed public feed data (channel/version/hold), |
| 187 | // the changelog banner's class: in the clear, shown even while the app |
| 188 | // is open, its own tag so a fresh sighting replaces the old banner. |
| 189 | await self.registration.showNotification(String(data.title || "Server update on the way"), { |
| 190 | body: String(data.body || ""), |
| 191 | icon: "/static/icon-192.png", |
| 192 | badge: "/static/icon-192.png", |
| 193 | tag: "lchat-release", |
| 194 | renotify: true, |
| 195 | data: {}, |
| 196 | }); |
| 197 | return; |
| 198 | } else { |
| 199 | // File the row into the local message store first (#457 phase 2) — even |
| 200 | // when the notification is suppressed below, an idempotent put costs |
| 201 | // nothing and covers a page whose socket silently dropped. Gated exactly |
| 202 | // like the page's own write-through: only chat threads (group/dm) whose |
| 203 | // key the page synced; app-threads (kind "self") hydrate differently. |
| 204 | if (data.blob && data.id && data.thread) { |
| 205 | try { |
| 206 | const known = (await idbGet("threadKeys")) || {}; |
| 207 | const kt = known[data.thread]; |
| 208 | if (kt && (kt.kind === "group" || kt.kind === "dm")) { |
| 209 | await storePushedRow(data.thread, data.id, data.at || 0, data.blob); |
| 210 | } |
| 211 | } catch {} |
| 212 | } |
| 213 | // Real message: skip if the app is open/active (you're already seeing it). |
| 214 | if (await appIsActive()) return; |
| 215 | try { |
| 216 | // Mute (issue #578): muted THREADS are already skipped by the server's |
| 217 | // fan-out — this local check just covers pushes already in flight when |
| 218 | // the mute landed. Muted PEOPLE are checked after decryption below (the |
| 219 | // server can't: who's muted is sealed client-side). |
| 220 | const mutedPrefs = (await idbGet("muted")) || {}; |
| 221 | if (Array.isArray(mutedPrefs.threads) && data.thread && mutedPrefs.threads.includes(data.thread)) return; |
| 222 | // Per-thread keys (id -> {key, name, kind}) the page syncs to IndexedDB. |
| 223 | const threads = (await idbGet("threadKeys")) || {}; |
| 224 | // Names as this member shows them (#577): uid → display name, plus the |
| 225 | // published-label → my-name map for rewriting @mentions in body text. |
| 226 | const names = (await idbGet("memberNames")) || {}; |
| 227 | const nameFor = (uid, fallback) => (uid && names.byUid && names.byUid[uid]) || fallback; |
| 228 | const t = data.thread && threads[data.thread]; |
| 229 | if (data.blob && t && t.key) { |
| 230 | const msg = await decryptBlob(data.blob, t.key); |
| 231 | // A muted person never notifies — from any thread, for any event type. |
| 232 | if (msg.uid && Array.isArray(mutedPrefs.people) && mutedPrefs.people.includes(msg.uid)) return; |
| 233 | if (msg.type === "reaction") { |
| 234 | // A reaction event: only the reacted-to message's author is shown a |
| 235 | // notification. The author's uid (targetUid) rides inside the |
| 236 | // ciphertext — stamped by the reacting client, invisible to the |
| 237 | // server — so the filter can only run here, after decryption. |
| 238 | // Un-reacts (on=false) and reactions to legacy messages without an |
| 239 | // author uid notify no one. |
| 240 | const me = await idbGet("myUid"); |
| 241 | if (!msg.on || !msg.targetUid || !me || msg.targetUid !== me) return; |
| 242 | title = t.name || nameFor(msg.uid, msg.name) || "New message"; |
| 243 | body = `${nameFor(msg.uid, msg.name) || "Someone"} reacted ${msg.emoji || ""} to your message`; |
| 244 | } else if (msg.notice && msg.notice.title) { |
| 245 | // A mini-app event carrying its own (encrypted) notification — e.g. a |
| 246 | // reminder the server just delivered. The app wrote the words. |
| 247 | title = String(msg.notice.title); |
| 248 | body = msg.notice.body ? String(msg.notice.body) : ""; |
| 249 | } else if (msg.type && msg.type !== "chat" && !msg.text) { |
| 250 | // A mini-app event with nothing to say (a vote, a todo tick): stay |
| 251 | // silent rather than showing an empty "New message". |
| 252 | return; |
| 253 | } else { |
| 254 | const sender = nameFor(msg.uid, msg.name) || ""; |
| 255 | let text = translateMentions(msg.text || "", names.renames); |
| 256 | if (!text && Array.isArray(msg.attachments) && msg.attachments.length) { |
| 257 | const a = msg.attachments[0]; |
| 258 | text = a.type && a.type.startsWith("image/") ? "📷 Photo" : "📎 " + (a.name || "File"); |
| 259 | } |
| 260 | title = t.name || sender || "New message"; |
| 261 | // In groups, prefix the sender; a DM's title is already the person. |
| 262 | // An action line ("Alice waves") already names the actor — skip the |
| 263 | // prefix so it doesn't double up ("Alice: Alice waves"). |
| 264 | body = t.kind === "group" && sender && !msg.action ? `${sender}: ${text}` : text; |
| 265 | } |
| 266 | } |
| 267 | } catch (e) { |
| 268 | // fall back to the generic notification |
| 269 | } |
| 270 | } |
| 271 | |
| 272 | await self.registration.showNotification(title, { |
| 273 | body, |
| 274 | icon: "/static/icon-192.png", |
| 275 | badge: "/static/icon-192.png", |
| 276 | tag: "lchat-message", |
| 277 | renotify: true, |
| 278 | // Carry the thread id so a tap can open straight to it. Present even on the |
| 279 | // generic fallback (payload had it; we just couldn't decrypt the body); |
| 280 | // absent on the test notification, which has no thread. |
| 281 | data: { thread: data.thread }, |
| 282 | }); |
| 283 | } |
| 284 | |
| 285 | self.addEventListener("notificationclick", (event) => { |
| 286 | event.notification.close(); |
| 287 | const thread = event.notification.data && event.notification.data.thread; |
| 288 | event.waitUntil( |
| 289 | (async () => { |
| 290 | // Clear any other lchat notifications too, so a single tap dismisses the |
| 291 | // whole stack rather than leaving the rest lingering in the shade. |
| 292 | try { |
| 293 | for (const n of await self.registration.getNotifications()) n.close(); |
| 294 | } catch {} |
| 295 | // Open straight to the notification's thread. An already-open tab gets a |
| 296 | // postMessage (it's a single-page app — no reload); a cold start rides the |
| 297 | // URL hash, which the page reads on boot. Fragments stay client-side, so |
| 298 | // the thread id never hits the server's access log. |
| 299 | const wins = await self.clients.matchAll({ type: "window", includeUncontrolled: true }); |
| 300 | for (const c of wins) { |
| 301 | if ("focus" in c) { |
| 302 | await c.focus(); |
| 303 | if (thread) c.postMessage({ type: "open-thread", thread }); |
| 304 | return; |
| 305 | } |
| 306 | } |
| 307 | if (self.clients.openWindow) { |
| 308 | return self.clients.openWindow(thread ? `/#thread=${encodeURIComponent(thread)}` : "/"); |
| 309 | } |
| 310 | })() |
| 311 | ); |
| 312 | }); |
| 313 | |