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 { 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>
|
||||||
|
|||||||
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
|
// 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;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@ -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">
|
||||||
|
|||||||
@ -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>
|
||||||
|
|||||||
Loading…
x
Reference in New Issue
Block a user