fix(kez-chat/web): notify on poll-delivered messages too + add test button

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 <noreply@anthropic.com>
This commit is contained in:
Jason Tudisco 2026-05-27 00:33:15 -06:00
parent 4eeedb38fb
commit 4b01c2296d
2 changed files with 90 additions and 11 deletions

View File

@ -54,20 +54,28 @@ class InboxService {
#pollTimer: ReturnType<typeof setInterval> | 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,
});
// 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();
if (viaPush) this.#fireSystemNotification(displayName || pt.from, pt.body);
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<NotificationPerm
if (!notificationsSupported()) return "unsupported";
return await Notification.requestPermission();
}
/**
* Fire a one-shot notification right now so the user can verify their
* OS + browser settings actually deliver. Useful when debugging
* "I clicked Allow but I see nothing" fails loud with a return value
* the caller can show.
*
* Notably this fires REGARDLESS of document.visibilityState the user
* is testing while looking at the page, so suppressing would defeat
* the purpose.
*/
export function fireTestNotification(): { ok: boolean; reason?: string } {
if (!notificationsSupported()) {
return { ok: false, reason: "Notification API not available" };
}
if (Notification.permission !== "granted") {
return {
ok: false,
reason: `permission is "${Notification.permission}" — click Enable first`,
};
}
try {
new Notification("kez-chat · Test", {
body: "If you see this, notifications are working. New chat messages will look the same.",
icon: "/pwa-192x192.png",
badge: "/pwa-64x64.png",
tag: "kez-chat-test",
});
return { ok: true };
} catch (e) {
return { ok: false, reason: (e as Error).message };
}
}

View File

@ -21,6 +21,7 @@
notificationsSupported,
notificationsPermission,
requestNotificationsPermission,
fireTestNotification,
} from "../lib/inbox-service.svelte.js";
let registryRecord = $state<any | null>(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.
</p>
{:else if notifPerm === "granted"}
<p class="mt-3 text-sm text-green-800 bg-green-50 border border-green-200 rounded p-3">
<div class="mt-3 space-y-3">
<p class="text-sm text-green-800 bg-green-50 border border-green-200 rounded p-3">
✓ Notifications enabled. New messages will pop up when this tab
isn't visible.
</p>
<button
class="px-3 py-1.5 text-sm border border-gray-300 rounded-md text-gray-700 hover:bg-gray-50"
onclick={sendTestNotification}
>
Send test notification
</button>
{#if testNotifResult?.ok === true}
<p class="text-xs text-green-700">
✓ Sent. If you didn't see anything, check your OS notification
settings (System Settings → Notifications → Chrome on macOS,
Settings → Apps → Notifications on Windows/Android).
</p>
{:else if testNotifResult?.ok === false}
<p class="text-xs text-red-700">
{testNotifResult.reason}
</p>
{/if}
</div>
{:else if notifPerm === "denied"}
<p class="mt-3 text-sm text-amber-800 bg-amber-50 border border-amber-200 rounded p-3">
You blocked notifications for this site. Re-enable them in your