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:
parent
ca5290dc0f
commit
76fcaa1d3c
@ -3,6 +3,7 @@
|
||||
import { onMount } from "svelte";
|
||||
import { hasStoredIdentity } from "./lib/identity-store.js";
|
||||
import { session } from "./lib/store.svelte.js";
|
||||
import { inboxService } from "./lib/inbox-service.svelte.js";
|
||||
|
||||
import Landing from "./routes/Landing.svelte";
|
||||
import CreateAccount from "./routes/CreateAccount.svelte";
|
||||
@ -46,7 +47,17 @@
|
||||
{#if session.unlocked}
|
||||
<nav class="flex items-center gap-4 text-sm">
|
||||
<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>
|
||||
<span class="text-gray-400">|</span>
|
||||
<span class="text-gray-500">{session.unlocked.handle}@{session.unlocked.server}</span>
|
||||
|
||||
203
kez-chat/web/src/lib/inbox-service.svelte.ts
Normal file
203
kez-chat/web/src/lib/inbox-service.svelte.ts
Normal 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();
|
||||
}
|
||||
@ -1,16 +1,23 @@
|
||||
// Global session state — Svelte 5 runes. Tracks the unlocked identity
|
||||
// (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 { inboxService } from "./inbox-service.svelte.js";
|
||||
|
||||
class Session {
|
||||
unlocked = $state<UnlockedIdentity | null>(null);
|
||||
|
||||
setUnlocked(id: UnlockedIdentity) {
|
||||
this.unlocked = id;
|
||||
inboxService.start(id.handle, id.seed);
|
||||
}
|
||||
|
||||
lock() {
|
||||
inboxService.stop();
|
||||
this.unlocked = null;
|
||||
}
|
||||
}
|
||||
|
||||
@ -17,6 +17,11 @@
|
||||
removeStoredBiometric,
|
||||
isPlatformAuthenticatorAvailable,
|
||||
} from "../lib/webauthn.js";
|
||||
import {
|
||||
notificationsSupported,
|
||||
notificationsPermission,
|
||||
requestNotificationsPermission,
|
||||
} from "../lib/inbox-service.svelte.js";
|
||||
|
||||
let registryRecord = $state<any | null>(null);
|
||||
let loading = $state(true);
|
||||
@ -33,6 +38,10 @@
|
||||
let biometricBusy = $state(false);
|
||||
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.
|
||||
const verifiedClaims = $derived(
|
||||
claims.filter((c) => c.last_verify?.status === "ok"),
|
||||
@ -51,6 +60,8 @@
|
||||
}
|
||||
claims = await listClaims();
|
||||
await refreshBiometricStatus();
|
||||
notifSupported = notificationsSupported();
|
||||
notifPerm = notificationsPermission();
|
||||
try {
|
||||
registryRecord = await lookup(session.unlocked.handle);
|
||||
} catch (e) {
|
||||
@ -90,6 +101,10 @@
|
||||
}
|
||||
}
|
||||
|
||||
async function enableNotifications() {
|
||||
notifPerm = await requestNotificationsPermission();
|
||||
}
|
||||
|
||||
async function disableBiometric() {
|
||||
if (!confirm("Disable biometric unlock for this device? You'll need your passphrase to unlock next time.")) return;
|
||||
await removeStoredBiometric();
|
||||
@ -301,6 +316,38 @@
|
||||
{/if}
|
||||
</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">
|
||||
<p class="text-xs text-gray-500 uppercase tracking-wide mb-2">Backup</p>
|
||||
<p class="text-sm text-gray-700">
|
||||
|
||||
@ -2,22 +2,13 @@
|
||||
import { onMount, onDestroy } from "svelte";
|
||||
import { push } from "svelte-spa-router";
|
||||
import { session } from "../lib/store.svelte.js";
|
||||
import {
|
||||
decrypt,
|
||||
pollInbox,
|
||||
sendMessage,
|
||||
streamInbox,
|
||||
type InboxMessage,
|
||||
type StreamHandle,
|
||||
} from "../lib/messages.js";
|
||||
import { lookup, lookupByPrimary, ApiError } from "../lib/api.js";
|
||||
import { sendMessage } from "../lib/messages.js";
|
||||
import { lookup, ApiError } from "../lib/api.js";
|
||||
import { inboxService } from "../lib/inbox-service.svelte.js";
|
||||
import EmojiButton from "../lib/EmojiButton.svelte";
|
||||
import {
|
||||
appendInbound,
|
||||
appendOutbound,
|
||||
ensureConversation,
|
||||
getConversation,
|
||||
getGlobalCursor,
|
||||
listConversations,
|
||||
type Conversation,
|
||||
} from "../lib/conversations-store.js";
|
||||
@ -83,11 +74,8 @@
|
||||
if (count <= 6) return "lg";
|
||||
return null;
|
||||
}
|
||||
let pollError = $state<string | null>(null);
|
||||
let lastPolledAt = $state<string | null>(null);
|
||||
let pollTimer: ReturnType<typeof setInterval> | null = null;
|
||||
let streamHandle: StreamHandle | null = null;
|
||||
let streamStatus = $state<"connecting" | "live" | "reconnecting">("connecting");
|
||||
/** Unsubscribe handle for the inbox-service "new message" listener. */
|
||||
let unsubscribe: (() => void) | null = null;
|
||||
|
||||
// "Start chat with" lookup state.
|
||||
let newPeerInput = $state("");
|
||||
@ -97,97 +85,29 @@
|
||||
// Toast for the share-link copy action.
|
||||
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 () => {
|
||||
if (!session.unlocked) {
|
||||
push("/unlock");
|
||||
return;
|
||||
}
|
||||
await refresh();
|
||||
await pollOnce(); // catch-up before SSE goes live
|
||||
|
||||
// Real-time push via SSE — server fires per-message as soon as
|
||||
// POST /v1/messages hands off the row to the broker. Sub-second
|
||||
// latency in the common case.
|
||||
streamHandle = streamInbox({
|
||||
handle: session.unlocked.handle,
|
||||
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);
|
||||
// Subscribe to the always-on inbox service — re-render whenever a
|
||||
// new message lands. The service is already running (it started on
|
||||
// session unlock in store.svelte.ts) regardless of which route the
|
||||
// user was on.
|
||||
unsubscribe = inboxService.onMessage(() => void refresh());
|
||||
// Landing here = the user has seen new messages; reset the badge.
|
||||
inboxService.markAllRead();
|
||||
});
|
||||
|
||||
onDestroy(() => {
|
||||
if (pollTimer) clearInterval(pollTimer);
|
||||
streamHandle?.close();
|
||||
unsubscribe?.();
|
||||
});
|
||||
|
||||
/** 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() {
|
||||
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. */
|
||||
async function startConversation() {
|
||||
if (!session.unlocked || !newPeerInput.trim()) return;
|
||||
@ -374,19 +294,22 @@
|
||||
{/if}
|
||||
</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">
|
||||
<p>
|
||||
{#if streamStatus === "live"}
|
||||
{#if inboxService.status === "live"}
|
||||
<span class="text-green-700">● live</span>
|
||||
{:else if streamStatus === "reconnecting"}
|
||||
{:else if inboxService.status === "reconnecting"}
|
||||
<span class="text-amber-700">● reconnecting…</span>
|
||||
{:else}
|
||||
{:else if inboxService.status === "connecting"}
|
||||
<span class="text-gray-500">○ connecting…</span>
|
||||
{:else}
|
||||
<span class="text-gray-400">○ off</span>
|
||||
{/if}
|
||||
</p>
|
||||
{#if pollError}
|
||||
<p class="text-red-700">⚠ {pollError}</p>
|
||||
{#if inboxService.lastError}
|
||||
<p class="text-red-700">⚠ {inboxService.lastError}</p>
|
||||
{/if}
|
||||
</div>
|
||||
</aside>
|
||||
|
||||
Loading…
x
Reference in New Issue
Block a user