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:
parent
4eeedb38fb
commit
4b01c2296d
@ -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,
|
||||
});
|
||||
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<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 };
|
||||
}
|
||||
}
|
||||
|
||||
@ -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">
|
||||
✓ Notifications enabled. New messages will pop up when this tab
|
||||
isn't visible.
|
||||
</p>
|
||||
<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
|
||||
|
||||
Loading…
x
Reference in New Issue
Block a user