push: suppress notifications for your own messages and while the app is open
- server skips the sender's own subscriptions (matched by invite token) so you never get a push for a message you just sent. - the page writes a 'lastActiveAt' heartbeat to IndexedDB; the service worker checks it (in addition to clients.matchAll focus, which is unreliable in the iOS PWA push handler) and skips notifications while the app is open.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
5 files changed,
+46
−12
hub.go+2 −2push.go+12 −6web/static/chat.js+13 −0web/static/push.js+1 −1web/static/sw.js+18 −3
diff --git a/hub.go b/hub.go| index f9f90ba..9f4f5fa 100644 |
| --- a/hub.go |
| +++ b/hub.go |
| @@ -154,8 +154,8 @@ func (s *Server) handleWS(w http.ResponseWriter, r *http.Request) { |
| } |
| out, _ := json.Marshal(wsOut{Type: "msg", ID: m.ID, CreatedAt: m.CreatedAt, Blob: m.Blob}) |
| s.hub.broadcast <- out |
| - // Notify subscribers who don't have the app open (best-effort, async). |
| - go s.pushNewMessage(m.Blob) |
| + // Notify others (not the sender), best-effort, async. |
| + go s.pushNewMessage(m.Blob, token) |
| case "react", "unreact": |
| if in.MessageID <= 0 || !allowedReactions[in.Emoji] { |
diff --git a/push.go b/push.go| index 62b84ca..27c8446 100644 |
| --- a/push.go |
| +++ b/push.go |
| @@ -82,21 +82,24 @@ func (db *DB) ensureVAPID() (pub, priv string, err error) { |
| // never sees plaintext: the payload IS the same ciphertext stored in the DB, and |
| // the service worker decrypts it locally with the group key. Expired subscriptions |
| // (404/410) are pruned. Best-effort; runs in its own goroutine. |
| -func (s *Server) pushNewMessage(blob string) { |
| +// pushNewMessage notifies everyone except the sender — you don't want a push on |
| +// your own devices for a message you just sent. The sender is identified by the |
| +// invite token from their WebSocket (their subscriptions carry the same token). |
| +func (s *Server) pushNewMessage(blob, senderToken string) { |
| payload, _ := json.Marshal(map[string]string{"blob": blob}) |
| - s.sendToAll(payload, "msg") |
| + s.sendToAll(payload, "msg", senderToken) |
| } |
| // sendTestPush delivers a generic "it works" notification to every subscriber, |
| // bypassing the message flow. Returns the number of successful sends. |
| func (s *Server) sendTestPush() int { |
| payload, _ := json.Marshal(map[string]bool{"test": true}) |
| - return s.sendToAll(payload, "test") |
| + return s.sendToAll(payload, "test", "") |
| } |
| -// sendToAll delivers a payload to every subscription, pruning dead ones (404/410). |
| -// Returns the count of successful sends and logs per-send results for debugging. |
| -func (s *Server) sendToAll(payload []byte, kind string) int { |
| +// sendToAll delivers a payload to every subscription (skipping any whose token |
| +// equals exceptToken), pruning dead ones (404/410). Returns successful sends. |
| +func (s *Server) sendToAll(payload []byte, kind, exceptToken string) int { |
| subs, err := s.db.listSubscriptions() |
| if err != nil { |
| log.Println("push: list subs:", err) |
| @@ -105,6 +108,9 @@ func (s *Server) sendToAll(payload []byte, kind string) int { |
| log.Printf("push[%s]: %d subscription(s)", kind, len(subs)) |
| sent := 0 |
| for _, sub := range subs { |
| + if exceptToken != "" && sub.Token == exceptToken { |
| + continue // don't notify the sender's own devices |
| + } |
| resp, err := webpush.SendNotification(payload, &webpush.Subscription{ |
| Endpoint: sub.Endpoint, |
| Keys: webpush.Keys{P256dh: sub.P256dh, Auth: sub.Auth}, |
diff --git a/web/static/chat.js b/web/static/chat.js| index bae10bf..16046f7 100644 |
| --- a/web/static/chat.js |
| +++ b/web/static/chat.js |
| @@ -14,6 +14,7 @@ import { |
| registerSW, |
| subscribePush, |
| unsubscribePush, |
| + idbSet, |
| } from "/static/push.js"; |
| const SESSION_KEY = "lchat_session"; |
| @@ -366,6 +367,18 @@ async function start(session) { |
| const isCoarse = |
| window.matchMedia && window.matchMedia("(pointer: coarse)").matches; |
| + // Heartbeat: stamp "active" while the app is visible so the service worker can |
| + // suppress push notifications for messages you're already watching live. |
| + // Stamps 0 when hidden so notifications resume the moment you leave. |
| + const markActive = () => |
| + idbSet("lastActiveAt", document.visibilityState === "visible" ? Date.now() : 0).catch( |
| + () => {} |
| + ); |
| + markActive(); |
| + setInterval(markActive, 3000); |
| + document.addEventListener("visibilitychange", markActive); |
| + window.addEventListener("focus", markActive); |
| + |
| setupMenu(session); |
| const seen = new Set(); |
diff --git a/web/static/push.js b/web/static/push.js| index 5fe06a1..92b2df7 100644 |
| --- a/web/static/push.js |
| +++ b/web/static/push.js |
| @@ -1,6 +1,6 @@ |
| // Web Push helpers, driven by the chat page's onboarding flow (see chat.js). |
| -function idbSet(key, value) { |
| +export function idbSet(key, value) { |
| return new Promise((resolve, reject) => { |
| const req = indexedDB.open("lchat", 1); |
| req.onupgradeneeded = () => req.result.createObjectStore("kv"); |
diff --git a/web/static/sw.js b/web/static/sw.js| index 3909b8e..b738925 100644 |
| --- a/web/static/sw.js |
| +++ b/web/static/sw.js |
| @@ -43,6 +43,22 @@ async function decryptBlob(blob, keyB64) { |
| return JSON.parse(new TextDecoder().decode(pt)); |
| } |
| +// 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)); |
| }); |
| @@ -61,9 +77,8 @@ async function handlePush(event) { |
| title = "✅ Test notification"; |
| body = "Push notifications are working!"; |
| } else { |
| - // Real message: skip if the chat is already open and focused (seen live). |
| - const wins = await self.clients.matchAll({ type: "window", includeUncontrolled: true }); |
| - if (wins.some((c) => c.visibilityState === "visible" && c.focused)) return; |
| + // Real message: skip if the app is open/active (you're already seeing it). |
| + if (await appIsActive()) return; |
| try { |
| const keyB64 = await idbGet("groupKey"); |
| if (data.blob && keyB64) { |