feat(kez-chat/web): always-on inbox stream + unread badge + browser notifications

Previously the SSE stream only ran while the Messages component was
mounted. Navigate to Dashboard or Claims and new messages just piled
up server-side until you came back. Now the stream runs for the whole
session, drives an unread badge in the nav, and (with permission)
fires a system notification when a message lands while you're in
another tab.

inboxService (lib/inbox-service.svelte.ts):
  • Singleton Svelte 5 $state class. session.setUnlocked() starts it,
    session.lock() stops it. Holds the SSE stream + the 30s heartbeat
    poll for the entire session lifetime.
  • Reactive state read by anyone: status (off/connecting/live/
    reconnecting), unreadCount (since last visit to /messages), and
    lastError (surfaced in the Messages footer).
  • onMessage(fn) lets components subscribe to repaint when ingest
    succeeds — Messages page uses this instead of owning its own
    stream.
  • #fireSystemNotification fires Notification API on inbound when
    Notification.permission === "granted" AND document.visibilityState
    !== "visible". Silent while you're actively looking at the tab.
    Uses tag="kez-chat-inbox" so multiple notifications collapse.

Messages.svelte:
  • Stripped its own stream/poll. Now just subscribes to inboxService.
    onMount also calls markAllRead() — landing on /messages = you've
    seen the new stuff.
  • Footer status indicator reads from inboxService instead of local
    state.

App.svelte nav:
  • Messages link grows a red unread-count badge (1, 2, …, 9+) when
    inboxService.unreadCount > 0 and the user isn't already on the
    Messages route.

Dashboard:
  • New "Notifications" section between Quick unlock and Backup with
    the standard 3-state UX: granted (green confirm), denied (amber
    "fix in site settings"), default (button to request).
  • Helpers in inbox-service.ts wrap the Notification API so non-
    supporting browsers (older Safari, Firefox in some configs) get
    graceful "not supported" copy.

Caveat (for v0.3): notifications only fire while the tab is open in
SOME state (background-but-not-closed). Closing the tab kills the
SSE stream so nothing arrives at the page to notify about. True
background push (Web Push API + VAPID + server-side push) is a
separate piece of work.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
This commit is contained in:
Jason Tudisco 2026-05-27 00:10:29 -06:00
parent ca5290dc0f
commit 76fcaa1d3c
5 changed files with 291 additions and 100 deletions

View File

@ -3,6 +3,7 @@
import { onMount } from "svelte"; import { onMount } from "svelte";
import { hasStoredIdentity } from "./lib/identity-store.js"; import { hasStoredIdentity } from "./lib/identity-store.js";
import { session } from "./lib/store.svelte.js"; import { session } from "./lib/store.svelte.js";
import { inboxService } from "./lib/inbox-service.svelte.js";
import Landing from "./routes/Landing.svelte"; import Landing from "./routes/Landing.svelte";
import CreateAccount from "./routes/CreateAccount.svelte"; import CreateAccount from "./routes/CreateAccount.svelte";
@ -46,7 +47,17 @@
{#if session.unlocked} {#if session.unlocked}
<nav class="flex items-center gap-4 text-sm"> <nav class="flex items-center gap-4 text-sm">
<a href="#/dashboard" class="text-gray-700 hover:text-gray-900">Dashboard</a> <a href="#/dashboard" class="text-gray-700 hover:text-gray-900">Dashboard</a>
<a href="#/messages" class="text-gray-700 hover:text-gray-900">Messages</a> <a href="#/messages" class="text-gray-700 hover:text-gray-900 inline-flex items-center gap-1.5">
Messages
{#if inboxService.unreadCount > 0 && $location !== "/messages"}
<span
class="inline-flex items-center justify-center min-w-5 h-5 px-1.5 text-xs font-semibold bg-red-600 text-white rounded-full"
aria-label="{inboxService.unreadCount} unread"
>
{inboxService.unreadCount > 9 ? "9+" : inboxService.unreadCount}
</span>
{/if}
</a>
<a href="#/claims" class="text-gray-700 hover:text-gray-900">Claims</a> <a href="#/claims" class="text-gray-700 hover:text-gray-900">Claims</a>
<span class="text-gray-400">|</span> <span class="text-gray-400">|</span>
<span class="text-gray-500">{session.unlocked.handle}@{session.unlocked.server}</span> <span class="text-gray-500">{session.unlocked.handle}@{session.unlocked.server}</span>

View File

@ -0,0 +1,203 @@
// Global inbox service — keeps the SSE stream + heartbeat poll alive
// for as long as the session is unlocked, regardless of which route
// the user is on.
//
// Why a singleton: previously the SSE connection was scoped to the
// Messages component's lifecycle. Navigating to Dashboard / Claims /
// etc. closed the stream — new messages just piled up server-side
// until the user came back. With this service, the stream stays open
// the whole session and the Messages page just reads from the same
// store everyone else does.
//
// Exposes reactive state (Svelte 5 `$state` on a class instance):
// • streamStatus — "connecting" / "live" / "reconnecting" / "off"
// • unreadCount — how many new messages have arrived since the user
// last visited the Messages page. Drives the nav
// badge.
//
// Notification policy:
// • If Notification.permission === "granted" AND the document isn't
// visible (tab in background, or user on a different app), fire a
// desktop/system notification with the sender's display name.
// • In-page UX (toast / banner) lives in the component layer.
import {
decrypt,
pollInbox,
streamInbox,
type InboxMessage,
type StreamHandle,
} from "./messages.js";
import { lookupByPrimary } from "./api.js";
import {
appendInbound,
getConversation,
getGlobalCursor,
} from "./conversations-store.js";
import type { Identity } from "./kez.js";
const POLL_INTERVAL_MS = 30_000;
export type StreamStatus = "off" | "connecting" | "live" | "reconnecting";
class InboxService {
status = $state<StreamStatus>("off");
unreadCount = $state(0);
/** Last decode/poll error, surfaced to the Messages footer. */
lastError = $state<string | null>(null);
/** Wall-clock of the last heartbeat poll — debug aid in Messages footer. */
lastPolledAt = $state<string | null>(null);
#handle: string | null = null;
#seed: Uint8Array | null = null;
#stream: StreamHandle | null = null;
#pollTimer: ReturnType<typeof setInterval> | null = null;
/** Callbacks for "new message arrived" — Messages page subscribes to repaint. */
#listeners = new Set<() => void>();
/** Start streaming for this session. Called from App.svelte on unlock. */
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";
this.#stream = streamInbox({
handle,
seed,
onMessage: (m) => void this.#ingest(m, /*viaPush=*/ true),
onStatus: (s) => (this.status = s),
});
this.#pollTimer = setInterval(() => void this.#heartbeat(), POLL_INTERVAL_MS);
// Eager first poll so we catch up anything queued before this session.
void this.#heartbeat();
}
/** Stop everything. Called on lock + on tab close. */
stop() {
this.#stream?.close();
this.#stream = null;
if (this.#pollTimer) clearInterval(this.#pollTimer);
this.#pollTimer = null;
this.#handle = null;
this.#seed = null;
this.status = "off";
}
/** Messages page calls this when the user lands on /messages. */
markAllRead() {
this.unreadCount = 0;
}
/** Components can subscribe to be notified when ingest succeeds. */
onMessage(fn: () => void): () => void {
this.#listeners.add(fn);
return () => this.#listeners.delete(fn);
}
async #heartbeat() {
if (!this.#handle || !this.#seed) return;
try {
const since = await getGlobalCursor();
const { messages } = await pollInbox({
handle: this.#handle,
seed: this.#seed,
since,
});
for (const m of messages) await this.#ingest(m, /*viaPush=*/ false);
this.lastError = null;
this.lastPolledAt = new Date().toISOString();
} catch (e) {
this.lastError = (e as Error).message;
}
}
async #ingest(m: InboxMessage, viaPush: boolean) {
if (!this.#handle || !this.#seed) return;
try {
const pt = await decrypt(m.envelope, this.#handle, this.#seed);
// Resolve sender's display handle (cache on conversation row).
let displayName = "";
const existing = await getConversation(pt.from);
if (existing?.peer_handle) {
displayName = existing.peer_handle;
} else {
try {
const record = await lookupByPrimary(pt.from);
displayName = record.fqhn;
} catch {
// Unknown to this server (cross-server v0.2). Show truncated key later.
}
}
await appendInbound({
peer_primary: pt.from as Identity,
peer_handle: displayName,
seq: m.seq,
body: pt.body,
ts: pt.sent_at,
});
this.unreadCount += 1;
this.#notifyListeners();
if (viaPush) this.#fireSystemNotification(displayName || pt.from, pt.body);
} catch (e) {
console.error(`inbox-service: seq ${m.seq} decrypt failed`, e);
}
}
#notifyListeners() {
for (const fn of this.#listeners) {
try {
fn();
} catch (e) {
console.error("inbox-service: listener threw", e);
}
}
}
/**
* Fire a system notification if the page isn't visible. We deliberately
* skip notifications while the user is actively looking at the tab
* the in-app toast / live-updating chat is the better signal there.
*/
#fireSystemNotification(sender: string, body: string) {
if (typeof Notification === "undefined") return;
if (Notification.permission !== "granted") return;
if (typeof document !== "undefined" && document.visibilityState === "visible") return;
try {
const preview = body.length > 80 ? body.slice(0, 80) + "…" : body;
new Notification(`kez-chat · ${sender}`, {
body: preview,
// Use the maskable icon — looks right on Android lock screens.
icon: "/pwa-192x192.png",
badge: "/pwa-64x64.png",
tag: "kez-chat-inbox", // collapse multiple notifications into one
});
} catch (e) {
console.error("inbox-service: notification failed", e);
}
}
}
/** Singleton — App.svelte starts/stops, Messages page reads from it. */
export const inboxService = new InboxService();
// ─────────────────────────────────────────────────────────────────────────────
// Browser-notification permission helpers — called from a user-gesture
// handler in the Dashboard "Enable notifications" button.
// ─────────────────────────────────────────────────────────────────────────────
export function notificationsSupported(): boolean {
return typeof Notification !== "undefined";
}
export function notificationsPermission(): NotificationPermission | "unsupported" {
if (!notificationsSupported()) return "unsupported";
return Notification.permission;
}
export async function requestNotificationsPermission(): Promise<NotificationPermission | "unsupported"> {
if (!notificationsSupported()) return "unsupported";
return await Notification.requestPermission();
}

View File

@ -1,16 +1,23 @@
// Global session state — Svelte 5 runes. Tracks the unlocked identity // Global session state — Svelte 5 runes. Tracks the unlocked identity
// (if any) for the current browser session. // (if any) for the current browser session.
//
// Also owns the inbox-service lifecycle: starting the SSE stream on
// unlock means new messages arrive regardless of which route the user
// is on (Dashboard, Claims, etc.), driving the unread badge in the nav.
import type { UnlockedIdentity } from "./identity-store.js"; import type { UnlockedIdentity } from "./identity-store.js";
import { inboxService } from "./inbox-service.svelte.js";
class Session { class Session {
unlocked = $state<UnlockedIdentity | null>(null); unlocked = $state<UnlockedIdentity | null>(null);
setUnlocked(id: UnlockedIdentity) { setUnlocked(id: UnlockedIdentity) {
this.unlocked = id; this.unlocked = id;
inboxService.start(id.handle, id.seed);
} }
lock() { lock() {
inboxService.stop();
this.unlocked = null; this.unlocked = null;
} }
} }

View File

@ -17,6 +17,11 @@
removeStoredBiometric, removeStoredBiometric,
isPlatformAuthenticatorAvailable, isPlatformAuthenticatorAvailable,
} from "../lib/webauthn.js"; } from "../lib/webauthn.js";
import {
notificationsSupported,
notificationsPermission,
requestNotificationsPermission,
} from "../lib/inbox-service.svelte.js";
let registryRecord = $state<any | null>(null); let registryRecord = $state<any | null>(null);
let loading = $state(true); let loading = $state(true);
@ -33,6 +38,10 @@
let biometricBusy = $state(false); let biometricBusy = $state(false);
let biometricError = $state<string | null>(null); let biometricError = $state<string | null>(null);
// Browser notification permission state
let notifSupported = $state(false);
let notifPerm = $state<NotificationPermission | "unsupported">("default");
// Derived buckets for the verified-claims section. // Derived buckets for the verified-claims section.
const verifiedClaims = $derived( const verifiedClaims = $derived(
claims.filter((c) => c.last_verify?.status === "ok"), claims.filter((c) => c.last_verify?.status === "ok"),
@ -51,6 +60,8 @@
} }
claims = await listClaims(); claims = await listClaims();
await refreshBiometricStatus(); await refreshBiometricStatus();
notifSupported = notificationsSupported();
notifPerm = notificationsPermission();
try { try {
registryRecord = await lookup(session.unlocked.handle); registryRecord = await lookup(session.unlocked.handle);
} catch (e) { } catch (e) {
@ -90,6 +101,10 @@
} }
} }
async function enableNotifications() {
notifPerm = await requestNotificationsPermission();
}
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();
@ -301,6 +316,38 @@
{/if} {/if}
</section> </section>
<section class="border border-gray-200 rounded-lg p-6 bg-white">
<p class="text-xs text-gray-500 uppercase tracking-wide mb-2">Notifications</p>
<p class="text-sm text-gray-700">
Get a system notification when a new message arrives while you're
in another tab or app. Your browser will ask once — pick "Allow".
Notifications are silent while you're actively looking at this tab.
</p>
{#if !notifSupported}
<p class="mt-3 text-sm text-gray-500 italic">
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>
{: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
browser's site settings if you change your mind.
</p>
{:else}
<button
class="mt-3 px-3 py-2 text-sm bg-gray-900 text-white rounded-md hover:bg-gray-700 flex items-center gap-2"
onclick={enableNotifications}
>
<span>🔔</span> Enable notifications
</button>
{/if}
</section>
<section class="border border-gray-200 rounded-lg p-6 bg-white"> <section class="border border-gray-200 rounded-lg p-6 bg-white">
<p class="text-xs text-gray-500 uppercase tracking-wide mb-2">Backup</p> <p class="text-xs text-gray-500 uppercase tracking-wide mb-2">Backup</p>
<p class="text-sm text-gray-700"> <p class="text-sm text-gray-700">

View File

@ -2,22 +2,13 @@
import { onMount, onDestroy } from "svelte"; import { onMount, onDestroy } from "svelte";
import { push } from "svelte-spa-router"; import { push } from "svelte-spa-router";
import { session } from "../lib/store.svelte.js"; import { session } from "../lib/store.svelte.js";
import { import { sendMessage } from "../lib/messages.js";
decrypt, import { lookup, ApiError } from "../lib/api.js";
pollInbox, import { inboxService } from "../lib/inbox-service.svelte.js";
sendMessage,
streamInbox,
type InboxMessage,
type StreamHandle,
} from "../lib/messages.js";
import { lookup, lookupByPrimary, ApiError } from "../lib/api.js";
import EmojiButton from "../lib/EmojiButton.svelte"; import EmojiButton from "../lib/EmojiButton.svelte";
import { import {
appendInbound,
appendOutbound, appendOutbound,
ensureConversation, ensureConversation,
getConversation,
getGlobalCursor,
listConversations, listConversations,
type Conversation, type Conversation,
} from "../lib/conversations-store.js"; } from "../lib/conversations-store.js";
@ -83,11 +74,8 @@
if (count <= 6) return "lg"; if (count <= 6) return "lg";
return null; return null;
} }
let pollError = $state<string | null>(null); /** Unsubscribe handle for the inbox-service "new message" listener. */
let lastPolledAt = $state<string | null>(null); let unsubscribe: (() => void) | null = null;
let pollTimer: ReturnType<typeof setInterval> | null = null;
let streamHandle: StreamHandle | null = null;
let streamStatus = $state<"connecting" | "live" | "reconnecting">("connecting");
// "Start chat with" lookup state. // "Start chat with" lookup state.
let newPeerInput = $state(""); let newPeerInput = $state("");
@ -97,97 +85,29 @@
// Toast for the share-link copy action. // Toast for the share-link copy action.
let copied = $state(false); let copied = $state(false);
// Background heartbeat — SSE handles real-time delivery; this just
// catches anything missed during a reconnect window.
const POLL_INTERVAL_MS = 30_000;
onMount(async () => { onMount(async () => {
if (!session.unlocked) { if (!session.unlocked) {
push("/unlock"); push("/unlock");
return; return;
} }
await refresh(); await refresh();
await pollOnce(); // catch-up before SSE goes live // Subscribe to the always-on inbox service — re-render whenever a
// new message lands. The service is already running (it started on
// Real-time push via SSE — server fires per-message as soon as // session unlock in store.svelte.ts) regardless of which route the
// POST /v1/messages hands off the row to the broker. Sub-second // user was on.
// latency in the common case. unsubscribe = inboxService.onMessage(() => void refresh());
streamHandle = streamInbox({ // Landing here = the user has seen new messages; reset the badge.
handle: session.unlocked.handle, inboxService.markAllRead();
seed: session.unlocked.seed,
onMessage: handlePushedMessage,
onStatus: (s) => (streamStatus = s),
});
// Background heartbeat in case SSE drops + reconnects between events.
pollTimer = setInterval(pollOnce, POLL_INTERVAL_MS);
}); });
onDestroy(() => { onDestroy(() => {
if (pollTimer) clearInterval(pollTimer); unsubscribe?.();
streamHandle?.close();
}); });
/** Decrypt + cache one incoming envelope from SSE or polling. */
async function ingest(m: InboxMessage) {
if (!session.unlocked) return;
try {
const pt = await decrypt(
m.envelope,
session.unlocked.handle,
session.unlocked.seed,
);
let handle = "";
const existing = await getConversation(pt.from);
if (existing?.peer_handle) {
handle = existing.peer_handle;
} else {
try {
const record = await lookupByPrimary(pt.from);
handle = record.fqhn;
} catch {
// unknown to this server — leave blank, UI will show short key
}
}
await appendInbound({
peer_primary: pt.from,
peer_handle: handle,
seq: m.seq,
body: pt.body,
ts: pt.sent_at,
});
} catch (e) {
console.error(`seq ${m.seq}: decrypt failed`, e);
}
}
async function handlePushedMessage(m: InboxMessage) {
await ingest(m);
await refresh();
}
async function refresh() { async function refresh() {
conversations = await listConversations(); conversations = await listConversations();
} }
async function pollOnce() {
if (!session.unlocked) return;
try {
const since = await getGlobalCursor();
const { messages } = await pollInbox({
handle: session.unlocked.handle,
seed: session.unlocked.seed,
since,
});
for (const m of messages) await ingest(m);
if (messages.length > 0) await refresh();
pollError = null;
lastPolledAt = new Date().toISOString();
} catch (e) {
pollError = (e as Error).message;
}
}
/** "Start chat with handle" — resolve, ensure conversation, open it. */ /** "Start chat with handle" — resolve, ensure conversation, open it. */
async function startConversation() { async function startConversation() {
if (!session.unlocked || !newPeerInput.trim()) return; if (!session.unlocked || !newPeerInput.trim()) return;
@ -374,19 +294,22 @@
{/if} {/if}
</div> </div>
<!-- Footer status --> <!-- Footer status — reads from the global inbox service so it
reflects the SAME connection that's running everywhere else. -->
<div class="p-2 border-t border-gray-200 text-xs text-gray-500 space-y-0.5"> <div class="p-2 border-t border-gray-200 text-xs text-gray-500 space-y-0.5">
<p> <p>
{#if streamStatus === "live"} {#if inboxService.status === "live"}
<span class="text-green-700">● live</span> <span class="text-green-700">● live</span>
{:else if streamStatus === "reconnecting"} {:else if inboxService.status === "reconnecting"}
<span class="text-amber-700">● reconnecting…</span> <span class="text-amber-700">● reconnecting…</span>
{:else} {:else if inboxService.status === "connecting"}
<span class="text-gray-500">○ connecting…</span> <span class="text-gray-500">○ connecting…</span>
{:else}
<span class="text-gray-400">○ off</span>
{/if} {/if}
</p> </p>
{#if pollError} {#if inboxService.lastError}
<p class="text-red-700">{pollError}</p> <p class="text-red-700">{inboxService.lastError}</p>
{/if} {/if}
</div> </div>
</aside> </aside>