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