eleven / messenger Public

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>
Paul Campbell 979c71c0851d2fbfed90e08b5115e05cbf4229b0 parent 2054f46
5 files changed, +46 −12
  • hub.go +2 −2
  • push.go +12 −6
  • web/static/chat.js +13 −0
  • web/static/push.js +1 −1
  • web/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) {