// lchat service worker — handles Web Push. // // The push payload is the SAME ciphertext the server stores; it never sees // plaintext. We decrypt here, in the browser, with the group key kept in // IndexedDB (service workers can't read localStorage), so notifications can show // real content while staying end-to-end encrypted. self.addEventListener("install", () => self.skipWaiting()); self.addEventListener("activate", (e) => e.waitUntil(self.clients.claim())); function b64urlToBytes(s) { s = s.replace(/-/g, "+").replace(/_/g, "/"); while (s.length % 4) s += "="; const bin = atob(s); const out = new Uint8Array(bin.length); for (let i = 0; i < bin.length; i++) out[i] = bin.charCodeAt(i); return out; } // Read a value from the "lchat" IndexedDB (shared with the page; see push.js). // Close-after-read + versionchange handler for the same reason as idbSet there: // a held connection would block a future schema upgrade in a page tab forever. function idbGet(key) { return new Promise((resolve, reject) => { const req = indexedDB.open("lchat", 1); req.onupgradeneeded = () => req.result.createObjectStore("kv"); req.onsuccess = () => { const db = req.result; db.onversionchange = () => db.close(); const tx = db.transaction("kv", "readonly"); const g = tx.objectStore("kv").get(key); g.onsuccess = () => { db.close(); resolve(g.result); }; g.onerror = () => { db.close(); reject(g.error); }; }; req.onerror = () => reject(req.error); req.onblocked = () => reject(new Error("blocked")); }); } // Per-viewer names (#577) in notification text. `renames` maps published // label → the name this member calls them. A classic-worker copy of ui.js // translateMentions (unit-tested there; this file can't import modules) — // same boundary rules as the feed's renderer: longest label wins, no trailing // word char, and a preceding word/@/. char means mid-word or an email. function translateMentions(text, renames) { const labels = Object.keys(renames || {}); if (!text || !labels.length || !text.includes("@")) return text; const esc = (s) => s.replace(/[.*+?^${}()|[\]\\]/g, "\\$&"); const re = new RegExp("@(" + labels.sort((a, b) => b.length - a.length).map(esc).join("|") + ")(?![\\w])", "g"); return text.replace(re, (m, label, idx) => { const prev = text[idx - 1]; return prev !== undefined && /[\w@.]/.test(prev) ? m : "@" + renames[label]; }); } async function decryptBlob(blob, keyB64) { const key = await crypto.subtle.importKey( "raw", b64urlToBytes(keyB64), "AES-GCM", false, ["decrypt"] ); const data = b64urlToBytes(blob); const pt = await crypto.subtle.decrypt( { name: "AES-GCM", iv: data.slice(0, 12) }, key, data.slice(12) ); 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 = () => { const db = req.result; // Same door as idbGet above: close when done, step aside for upgrades. db.onversionchange = () => db.close(); const settle = () => { try { db.close(); } catch {} resolve(); }; try { const t = db.transaction("msgs", "readwrite"); t.objectStore("msgs").put({ id, created_at: at, blob, t: threadID }); t.oncomplete = settle; t.onabort = settle; t.onerror = settle; } catch { settle(); } }; 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 // the app was open. async function appIsActive() { try { const wins = await self.clients.matchAll({ type: "window", includeUncontrolled: true }); if (wins.some((c) => c.visibilityState === "visible" && c.focused)) return true; } catch {} try { const last = await idbGet("lastActiveAt"); if (last && Date.now() - last < 8000) return true; } catch {} return false; } self.addEventListener("push", (event) => { event.waitUntil(handlePush(event)); }); async function handlePush(event) { let data = {}; try { data = event.data ? event.data.json() : {}; } catch {} let title = "New message"; let body = "Open the chat to read it"; if (data.test) { // Test notification: always show, even if the app is focused. title = "✅ Test notification"; body = "Push notifications are working!"; } else if (data.changelog) { // Release notes for an admin (announce.go). Uniquely, the words arrive in // clear — the changelog is branding-class plaintext baked into the binary, // never user content — so there's nothing to decrypt. Shown even when the // app is active: the deploy just restarted the server, and this banner is // the one signal the update happened. Its own tag so it never collapses // into (or gets replaced by) a message notification. await self.registration.showNotification(String(data.title || "What's new"), { body: String(data.body || ""), icon: "/static/icon-192.png", badge: "/static/icon-192.png", tag: "lchat-changelog", renotify: true, data: {}, }); return; } else if (data.report) { // A report notification for an admin (issue #578): server-composed // metadata ("N waiting"), never report content — the changelog banner's // class, so like it, shown even while the app is open. Its own tag + // renotify: a fresh burst replaces the old banner instead of stacking. await self.registration.showNotification(String(data.title || "New report"), { body: String(data.body || "Open the Admin panel to review."), icon: "/static/icon-192.png", badge: "/static/icon-192.png", tag: "lchat-report", renotify: true, data: {}, }); return; } else if (data.release) { // A new server release sighted on this space's update channel // (releasealert.go) — the heads-up that makes the supply-chain safety // hold useful. Server-composed public feed data (channel/version/hold), // the changelog banner's class: in the clear, shown even while the app // is open, its own tag so a fresh sighting replaces the old banner. await self.registration.showNotification(String(data.title || "Server update on the way"), { body: String(data.body || ""), icon: "/static/icon-192.png", badge: "/static/icon-192.png", tag: "lchat-release", renotify: true, data: {}, }); 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 { // Mute (issue #578): muted THREADS are already skipped by the server's // fan-out — this local check just covers pushes already in flight when // the mute landed. Muted PEOPLE are checked after decryption below (the // server can't: who's muted is sealed client-side). const mutedPrefs = (await idbGet("muted")) || {}; if (Array.isArray(mutedPrefs.threads) && data.thread && mutedPrefs.threads.includes(data.thread)) return; // Per-thread keys (id -> {key, name, kind}) the page syncs to IndexedDB. const threads = (await idbGet("threadKeys")) || {}; // Names as this member shows them (#577): uid → display name, plus the // published-label → my-name map for rewriting @mentions in body text. const names = (await idbGet("memberNames")) || {}; const nameFor = (uid, fallback) => (uid && names.byUid && names.byUid[uid]) || fallback; const t = data.thread && threads[data.thread]; if (data.blob && t && t.key) { const msg = await decryptBlob(data.blob, t.key); // A muted person never notifies — from any thread, for any event type. if (msg.uid && Array.isArray(mutedPrefs.people) && mutedPrefs.people.includes(msg.uid)) return; if (msg.type === "reaction") { // A reaction event: only the reacted-to message's author is shown a // notification. The author's uid (targetUid) rides inside the // ciphertext — stamped by the reacting client, invisible to the // server — so the filter can only run here, after decryption. // Un-reacts (on=false) and reactions to legacy messages without an // author uid notify no one. const me = await idbGet("myUid"); if (!msg.on || !msg.targetUid || !me || msg.targetUid !== me) return; title = t.name || nameFor(msg.uid, msg.name) || "New message"; body = `${nameFor(msg.uid, msg.name) || "Someone"} reacted ${msg.emoji || ""} to your message`; } else if (msg.notice && msg.notice.title) { // A mini-app event carrying its own (encrypted) notification — e.g. a // reminder the server just delivered. The app wrote the words. title = String(msg.notice.title); body = msg.notice.body ? String(msg.notice.body) : ""; } else if (msg.type && msg.type !== "chat" && !msg.text) { // A mini-app event with nothing to say (a vote, a todo tick): stay // silent rather than showing an empty "New message". return; } else { const sender = nameFor(msg.uid, msg.name) || ""; let text = translateMentions(msg.text || "", names.renames); if (!text && Array.isArray(msg.attachments) && msg.attachments.length) { const a = msg.attachments[0]; text = a.type && a.type.startsWith("image/") ? "📷 Photo" : "📎 " + (a.name || "File"); } title = t.name || sender || "New message"; // In groups, prefix the sender; a DM's title is already the person. // An action line ("Alice waves") already names the actor — skip the // prefix so it doesn't double up ("Alice: Alice waves"). body = t.kind === "group" && sender && !msg.action ? `${sender}: ${text}` : text; } } } catch (e) { // fall back to the generic notification } } await self.registration.showNotification(title, { body, icon: "/static/icon-192.png", badge: "/static/icon-192.png", tag: "lchat-message", renotify: true, // Carry the thread id so a tap can open straight to it. Present even on the // generic fallback (payload had it; we just couldn't decrypt the body); // absent on the test notification, which has no thread. data: { thread: data.thread }, }); } self.addEventListener("notificationclick", (event) => { event.notification.close(); const thread = event.notification.data && event.notification.data.thread; event.waitUntil( (async () => { // Clear any other lchat notifications too, so a single tap dismisses the // whole stack rather than leaving the rest lingering in the shade. try { for (const n of await self.registration.getNotifications()) n.close(); } catch {} // Open straight to the notification's thread. An already-open tab gets a // postMessage (it's a single-page app — no reload); a cold start rides the // URL hash, which the page reads on boot. Fragments stay client-side, so // the thread id never hits the server's access log. const wins = await self.clients.matchAll({ type: "window", includeUncontrolled: true }); for (const c of wins) { if ("focus" in c) { await c.focus(); if (thread) c.postMessage({ type: "open-thread", thread }); return; } } if (self.clients.openWindow) { return self.clients.openWindow(thread ? `/#thread=${encodeURIComponent(thread)}` : "/"); } })() ); });