eleven / ocho Public

Clone
git clone https://amadan.net/eleven/ocho

Plain git — no account needed to clone.

Download

Download this file

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
8self.addEventListener("install", () => self.skipWaiting());
9self.addEventListener("activate", (e) => e.waitUntil(self.clients.claim()));
10
11function 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.
23function 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.
51function 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
62async 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.
80function 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.
124async 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
136self.addEventListener("push", (event) => {
137 event.waitUntil(handlePush(event));
138});
139
140async 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
285self.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