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