From 4b01c2296d7e594f85c57e6748aea7bdd91ed509 Mon Sep 17 00:00:00 2001
From: Jason Tudisco
Date: Wed, 27 May 2026 00:33:15 -0600
Subject: [PATCH] fix(kez-chat/web): notify on poll-delivered messages too +
add test button
MIME-Version: 1.0
Content-Type: text/plain; charset=UTF-8
Content-Transfer-Encoding: 8bit
The bug you hit: minimize Chrome → SSE stream gets throttled by the
background-tab policy → eventually disconnects → next message arrives
via the 30s heartbeat poll instead of SSE push → my code skipped the
notification because of a `viaPush` guard.
Removed the guard. Now notifications fire for ANY incoming message
the user hasn't seen yet, regardless of transport (SSE vs poll). To
avoid notification-storm on startup catch-up:
• inbox-service now tracks #notifiedThroughSeq, seeded from the
persisted global cursor at start().
• #ingest only fires badge++ + system notification when m.seq is
strictly greater than the watermark — startup re-reading the
cache doesn't blow up the UI.
Also added a "Send test notification" button on Dashboard (visible
once permission is granted). Lets you sanity-check OS + browser
settings without needing a second device:
• Fires regardless of visibilityState
• Reports failure reason if Notification() throws
• Auto-clears after 5s so the panel doesn't grow stale
If the test fires successfully but real chat notifications still
don't appear when minimized, the fault is probably OS-level
(System Settings → Notifications → Chrome on macOS) — the success
message now tells the user where to look.
Co-Authored-By: Claude Opus 4.7
---
kez-chat/web/src/lib/inbox-service.svelte.ts | 66 +++++++++++++++++---
kez-chat/web/src/routes/Dashboard.svelte | 35 +++++++++--
2 files changed, 90 insertions(+), 11 deletions(-)
diff --git a/kez-chat/web/src/lib/inbox-service.svelte.ts b/kez-chat/web/src/lib/inbox-service.svelte.ts
index ef5412a..e15de70 100644
--- a/kez-chat/web/src/lib/inbox-service.svelte.ts
+++ b/kez-chat/web/src/lib/inbox-service.svelte.ts
@@ -54,20 +54,28 @@ class InboxService {
#pollTimer: ReturnType | null = null;
/** Callbacks for "new message arrived" — Messages page subscribes to repaint. */
#listeners = new Set<() => void>();
+ /** Highest seq we've already shown a notification for. Anything <= this
+ * was either already-known on session start (so the user has seen it
+ * on this or another device) or already notified about. */
+ #notifiedThroughSeq = -1;
/** Start streaming for this session. Called from App.svelte on unlock. */
- start(handle: string, seed: Uint8Array) {
+ async start(handle: string, seed: Uint8Array) {
// No-op if already running for this handle.
if (this.#handle === handle && this.#stream) return;
this.stop();
this.#handle = handle;
this.#seed = seed;
this.status = "connecting";
+ // Seed the notification watermark from the persisted cursor so we
+ // don't notify-storm for messages already in the conversation cache
+ // (which the user has either seen on this device or another).
+ this.#notifiedThroughSeq = await getGlobalCursor();
this.#stream = streamInbox({
handle,
seed,
- onMessage: (m) => void this.#ingest(m, /*viaPush=*/ true),
+ onMessage: (m) => void this.#ingest(m),
onStatus: (s) => (this.status = s),
});
this.#pollTimer = setInterval(() => void this.#heartbeat(), POLL_INTERVAL_MS);
@@ -106,7 +114,7 @@ class InboxService {
seed: this.#seed,
since,
});
- for (const m of messages) await this.#ingest(m, /*viaPush=*/ false);
+ for (const m of messages) await this.#ingest(m);
this.lastError = null;
this.lastPolledAt = new Date().toISOString();
} catch (e) {
@@ -114,7 +122,7 @@ class InboxService {
}
}
- async #ingest(m: InboxMessage, viaPush: boolean) {
+ async #ingest(m: InboxMessage) {
if (!this.#handle || !this.#seed) return;
try {
const pt = await decrypt(m.envelope, this.#handle, this.#seed);
@@ -138,9 +146,20 @@ class InboxService {
body: pt.body,
ts: pt.sent_at,
});
- this.unreadCount += 1;
- this.#notifyListeners();
- if (viaPush) this.#fireSystemNotification(displayName || pt.from, pt.body);
+ // Only fire UI side-effects (badge + system notification) for
+ // messages we haven't already notified about. This guards both:
+ // • SSE+poll race: same seq comes in twice via different paths
+ // • Startup catch-up: don't badge/notify the entire history
+ if (m.seq > this.#notifiedThroughSeq) {
+ this.#notifiedThroughSeq = m.seq;
+ this.unreadCount += 1;
+ this.#notifyListeners();
+ this.#fireSystemNotification(displayName || pt.from, pt.body);
+ } else {
+ // Still tell components so they repaint (avoid stale UI), but
+ // skip the noisy badge++ + system notification.
+ this.#notifyListeners();
+ }
} catch (e) {
console.error(`inbox-service: seq ${m.seq} decrypt failed`, e);
}
@@ -201,3 +220,36 @@ export async function requestNotificationsPermission(): Promise(null);
@@ -105,6 +106,13 @@
notifPerm = await requestNotificationsPermission();
}
+ let testNotifResult = $state<{ ok: boolean; reason?: string } | null>(null);
+ function sendTestNotification() {
+ testNotifResult = fireTestNotification();
+ // Auto-clear after a few seconds so the panel doesn't grow stale.
+ setTimeout(() => (testNotifResult = null), 5_000);
+ }
+
async function disableBiometric() {
if (!confirm("Disable biometric unlock for this device? You'll need your passphrase to unlock next time.")) return;
await removeStoredBiometric();
@@ -329,10 +337,29 @@
Notifications aren't supported in this browser.
{:else if notifPerm === "granted"}
-
- ✓ Notifications enabled. New messages will pop up when this tab
- isn't visible.
-
+
+
+ ✓ Notifications enabled. New messages will pop up when this tab
+ isn't visible.
+
+
+ {#if testNotifResult?.ok === true}
+
+ ✓ Sent. If you didn't see anything, check your OS notification
+ settings (System Settings → Notifications → Chrome on macOS,
+ Settings → Apps → Notifications on Windows/Android).
+
+ {:else if testNotifResult?.ok === false}
+
+ ✗ {testNotifResult.reason}
+
+ {/if}
+
{:else if notifPerm === "denied"}
You blocked notifications for this site. Re-enable them in your