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;
|
#pollTimer: ReturnType<typeof setInterval> | null = null;
|
||||||
/** Callbacks for "new message arrived" — Messages page subscribes to repaint. */
|
/** Callbacks for "new message arrived" — Messages page subscribes to repaint. */
|
||||||
#listeners = new Set<() => void>();
|
#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 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.
|
// No-op if already running for this handle.
|
||||||
if (this.#handle === handle && this.#stream) return;
|
if (this.#handle === handle && this.#stream) return;
|
||||||
this.stop();
|
this.stop();
|
||||||
this.#handle = handle;
|
this.#handle = handle;
|
||||||
this.#seed = seed;
|
this.#seed = seed;
|
||||||
this.status = "connecting";
|
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({
|
this.#stream = streamInbox({
|
||||||
handle,
|
handle,
|
||||||
seed,
|
seed,
|
||||||
onMessage: (m) => void this.#ingest(m, /*viaPush=*/ true),
|
onMessage: (m) => void this.#ingest(m),
|
||||||
onStatus: (s) => (this.status = s),
|
onStatus: (s) => (this.status = s),
|
||||||
});
|
});
|
||||||
this.#pollTimer = setInterval(() => void this.#heartbeat(), POLL_INTERVAL_MS);
|
this.#pollTimer = setInterval(() => void this.#heartbeat(), POLL_INTERVAL_MS);
|
||||||
@ -106,7 +114,7 @@ class InboxService {
|
|||||||
seed: this.#seed,
|
seed: this.#seed,
|
||||||
since,
|
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.lastError = null;
|
||||||
this.lastPolledAt = new Date().toISOString();
|
this.lastPolledAt = new Date().toISOString();
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
@ -114,7 +122,7 @@ class InboxService {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
async #ingest(m: InboxMessage, viaPush: boolean) {
|
async #ingest(m: InboxMessage) {
|
||||||
if (!this.#handle || !this.#seed) return;
|
if (!this.#handle || !this.#seed) return;
|
||||||
try {
|
try {
|
||||||
const pt = await decrypt(m.envelope, this.#handle, this.#seed);
|
const pt = await decrypt(m.envelope, this.#handle, this.#seed);
|
||||||
@ -138,9 +146,20 @@ class InboxService {
|
|||||||
body: pt.body,
|
body: pt.body,
|
||||||
ts: pt.sent_at,
|
ts: pt.sent_at,
|
||||||
});
|
});
|
||||||
this.unreadCount += 1;
|
// Only fire UI side-effects (badge + system notification) for
|
||||||
this.#notifyListeners();
|
// messages we haven't already notified about. This guards both:
|
||||||
if (viaPush) this.#fireSystemNotification(displayName || pt.from, pt.body);
|
// • 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) {
|
} catch (e) {
|
||||||
console.error(`inbox-service: seq ${m.seq} decrypt failed`, 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";
|
if (!notificationsSupported()) return "unsupported";
|
||||||
return await Notification.requestPermission();
|
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,
|
notificationsSupported,
|
||||||
notificationsPermission,
|
notificationsPermission,
|
||||||
requestNotificationsPermission,
|
requestNotificationsPermission,
|
||||||
|
fireTestNotification,
|
||||||
} from "../lib/inbox-service.svelte.js";
|
} from "../lib/inbox-service.svelte.js";
|
||||||
|
|
||||||
let registryRecord = $state<any | null>(null);
|
let registryRecord = $state<any | null>(null);
|
||||||
@ -105,6 +106,13 @@
|
|||||||
notifPerm = await requestNotificationsPermission();
|
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() {
|
async function disableBiometric() {
|
||||||
if (!confirm("Disable biometric unlock for this device? You'll need your passphrase to unlock next time.")) return;
|
if (!confirm("Disable biometric unlock for this device? You'll need your passphrase to unlock next time.")) return;
|
||||||
await removeStoredBiometric();
|
await removeStoredBiometric();
|
||||||
@ -329,10 +337,29 @@
|
|||||||
Notifications aren't supported in this browser.
|
Notifications aren't supported in this browser.
|
||||||
</p>
|
</p>
|
||||||
{:else if notifPerm === "granted"}
|
{: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">
|
||||||
✓ Notifications enabled. New messages will pop up when this tab
|
<p class="text-sm text-green-800 bg-green-50 border border-green-200 rounded p-3">
|
||||||
isn't visible.
|
✓ Notifications enabled. New messages will pop up when this tab
|
||||||
</p>
|
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"}
|
{:else if notifPerm === "denied"}
|
||||||
<p class="mt-3 text-sm text-amber-800 bg-amber-50 border border-amber-200 rounded p-3">
|
<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
|
You blocked notifications for this site. Re-enable them in your
|
||||||
|
|||||||
Loading…
x
Reference in New Issue
Block a user