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