eleven / messenger Public

sw+server: pushed messages file straight into the local store (#457 phase 2) (#779)

The web-push payload gains id/at (additive — old SWs ignore them; both are
values the recipient reads from /api/messages anyway), and sw.js files the
row into lchat-store before deciding whether to show the notification — the
same ciphertext the page's write-through stores, gated identically (chat
threads whose key the page synced). Opening from a notification then paints
with the message already in place; the put is idempotent, so it also covers
a page whose socket silently dropped.

The writer is a classic-worker copy of store.js putMessages for one row
(store.js's header already binds the two files' schema).
Co-authored-by: Claude Fable 5 <noreply@anthropic.com>
Paul Campbell c2236e86615079b06faf9395183cf3e948d61950 parent c362491
3 files changed, +61 −1
  • changelog/2026-07-12-notification-instant.md +3 −0
  • push.go +6 −1
  • web/static/sw.js +52 −0
diff --git a/changelog/2026-07-12-notification-instant.md b/changelog/2026-07-12-notification-instant.md
new file mode 100644
index 0000000..8343eb8
--- /dev/null
+++ b/changelog/2026-07-12-notification-instant.md
@@ -0,0 +1,3 @@
+🤖 Tapping a notification now opens the chat with that message already there —
+the notification itself quietly files the message on your device, so there's
+nothing to wait for, even on a flaky connection.
diff --git a/push.go b/push.go
index 3febe86..ae02f55 100644
--- a/push.go
+++ b/push.go
@@ -227,7 +227,12 @@ func (s *Server) pushNewMessage(blob, threadID, senderToken string, id, at int64
for _, u := range members {
set[u] = true
}
- payload, _ := json.Marshal(map[string]string{"blob": blob, "thread": threadID})
+ // id/at ride along so the service worker can file the row straight into
+ // the page's local message store (#457 phase 2) — opening from a
+ // notification then paints with the message already in place. Additive:
+ // an older SW just ignores them. Both are values the recipient could read
+ // from /api/messages anyway; nothing new is disclosed.
+ payload, _ := json.Marshal(map[string]any{"blob": blob, "thread": threadID, "id": id, "at": at})
// skipActive=true: don't notify people currently reading on another device.
// recipients=set: only this thread's members, never non-members.
s.sendToRecipients(payload, "msg", senderToken, true, set)
diff --git a/web/static/sw.js b/web/static/sw.js
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 {