From 63249aed9dd62e59aa302ddcf7c3a419effc43b3 Mon Sep 17 00:00:00 2001 From: Adam Soltys Date: Sat, 7 Mar 2026 10:39:57 -0800 Subject: improve message delivery with websocket reconnect and push data transport - Enable auto-reconnect on relay connections so dropped WebSockets recover - Connect relays in parallel with 5s timeout for faster startup - Add visibility change handler to catch up missed messages on tab return - Forward kind 445 events via push notifications as a data channel - Show "reconnecting..." status in chat UI when connections are down Co-Authored-By: Claude Opus 4.6 --- src/lib/messaging.ts | 3 + src/lib/mls-relay.ts | 152 ++++++++++++++++++++++++++-- src/routes/(app)/messages/+page.svelte | 28 ++++- src/routes/(app)/messages/[id]/+page.svelte | 23 ++++- src/service-worker.js | 10 +- 5 files changed, 202 insertions(+), 14 deletions(-) diff --git a/src/lib/messaging.ts b/src/lib/messaging.ts index 4cebd2b0..a647a657 100644 --- a/src/lib/messaging.ts +++ b/src/lib/messaging.ts @@ -17,6 +17,9 @@ export { subscribeToMessages, fetchGroupHistory, decryptMediaUrl, + isConnected, + onConnectionChange, + catchUpMessages, } from "$lib/mls-relay"; export type { GroupInfo } from "$lib/mls-relay"; diff --git a/src/lib/mls-relay.ts b/src/lib/mls-relay.ts index d29cd535..bdd33935 100644 --- a/src/lib/mls-relay.ts +++ b/src/lib/mls-relay.ts @@ -128,6 +128,90 @@ let _activeSub: { connectedRelays: any[]; } | null = null; +// --------------------------------------------------------------------------- +// Connection status tracking +// --------------------------------------------------------------------------- + +let _connectionListeners: ((connected: boolean) => void)[] = []; +let _isConnected = false; + +export function isConnected(): boolean { + return _isConnected; +} + +export function onConnectionChange(cb: (connected: boolean) => void): () => void { + _connectionListeners.push(cb); + return () => { + _connectionListeners = _connectionListeners.filter((l) => l !== cb); + }; +} + +function _updateConnectionStatus(): void { + if (!_activeSub) { + if (_isConnected) { + _isConnected = false; + _connectionListeners.forEach((cb) => cb(false)); + } + return; + } + const connected = _activeSub.connectedRelays.some((r: any) => r.connected); + if (connected !== _isConnected) { + _isConnected = connected; + _connectionListeners.forEach((cb) => cb(connected)); + } +} + +/** Fetch recent messages to catch up after disconnect or background period. */ +export async function catchUpMessages(): Promise { + if (!_activeSub) return; + const { user, seen, onNewRumour } = _activeSub; + const client = getMarmotClient(user.pubkey); + + try { + await client.loadAllGroups(); + const groups = client.groups; + if (groups.length === 0) return; + + const hTags: string[] = []; + const catchUpRelays = new Set(_activeSub.relays); + for (const g of groups) { + registerGroupIds(g); + hTags.push(getNostrGroupId(g)); + const r = g.relays; + if (r) r.forEach((url: string) => catchUpRelays.add(url)); + } + + const since = Math.floor(Date.now() / 1000) - 300; + const events = await pool.querySync([...catchUpRelays], { + kinds: [445], + "#h": hTags, + since, + limit: 200, + }); + + events.sort((a, b) => a.created_at - b.created_at); + + let newCount = 0; + for (const event of events) { + if (seen.has(event.id)) continue; + seen.add(event.id); + const rumour = await handleGroupMessageEvent( + event as NostrEvent, + user, + client, + ); + if (rumour) { + onNewRumour(rumour); + newCount++; + } + } + if (newCount > 0) + console.log(`[mls] caught up ${newCount} missed messages`); + } catch (e) { + console.error("[mls] catch-up failed:", e); + } +} + // --------------------------------------------------------------------------- // Key package management (kind 443) // --------------------------------------------------------------------------- @@ -569,9 +653,22 @@ export async function subscribeToMessages( console.error("[mls] Failed to fetch historical welcomes:", e); } - for (const url of allRelayUrls) { + // Pre-compute group subscription filters + const groups = client.groups; + const hTags: string[] = []; + for (const g of groups) { + const nostrId = getNostrGroupId(g); + subscribedGroups.add(nostrId); + hTags.push(nostrId); + } + + // Connect to relays in parallel with auto-reconnect + const setupRelay = async (url: string) => { try { - const relay = await Relay.connect(url); + const relay = await Relay.connect(url, { + enableReconnect: true, + timeout: 5000, + }); connectedRelays.push(relay); const welcomeSub = relay.subscribe( @@ -591,14 +688,7 @@ export async function subscribeToMessages( ); closers.push(() => welcomeSub.close()); - const groups = client.groups; - if (groups.length > 0) { - const hTags: string[] = []; - for (const g of groups) { - const nostrId = getNostrGroupId(g); - subscribedGroups.add(nostrId); - hTags.push(nostrId); - } + if (hTags.length > 0) { const msgSub = relay.subscribe( [{ kinds: [445], "#h": hTags }], { @@ -613,8 +703,47 @@ export async function subscribeToMessages( closers.push(() => msgSub.close()); } } catch (e) { - console.error("[mls-relay] Failed to connect:", url, e); + console.warn("[mls-relay] Failed to connect:", url); } + }; + + await Promise.allSettled([...allRelayUrls].map(setupRelay)); + _updateConnectionStatus(); + + // Catch up on missed messages when tab becomes visible again + const handleVisibility = () => { + if (document.visibilityState === "visible") { + _updateConnectionStatus(); + catchUpMessages(); + } + }; + document.addEventListener("visibilitychange", handleVisibility); + closers.push(() => + document.removeEventListener("visibilitychange", handleVisibility), + ); + + // Listen for push-delivered events from service worker + if (typeof navigator !== "undefined" && navigator.serviceWorker) { + const handleSWMessage = async (evt: MessageEvent) => { + if (evt.data?.type !== "mls-event") return; + try { + const event = JSON.parse(evt.data.event); + if (seen.has(event.id)) return; + seen.add(event.id); + const rumour = await handleGroupMessageEvent( + event as NostrEvent, + user, + client, + ); + if (rumour) onNewRumour(rumour); + } catch (e) { + console.error("[mls] Failed to process push event:", e); + } + }; + navigator.serviceWorker.addEventListener("message", handleSWMessage); + closers.push(() => + navigator.serviceWorker.removeEventListener("message", handleSWMessage), + ); } // Publish key package @@ -624,6 +753,7 @@ export async function subscribeToMessages( return () => { _activeSub = null; + _updateConnectionStatus(); inviteReader.removeAllListeners("newInvite"); closers.forEach((c) => c()); }; diff --git a/src/routes/(app)/messages/+page.svelte b/src/routes/(app)/messages/+page.svelte index 1981b5f1..215f07b0 100644 --- a/src/routes/(app)/messages/+page.svelte +++ b/src/routes/(app)/messages/+page.svelte @@ -4,6 +4,7 @@ import { getGroups, subscribeToMessages, fetchGroupHistory, searchMlsUsers, preloadMlsUsers, resolveUser, displayName, + isConnected, onConnectionChange, } from "$lib/messaging"; import type { GroupInfo } from "$lib/messaging"; import { t } from "$lib/translations"; @@ -22,6 +23,7 @@ let groups: GroupInfo[] = $state([]); let loaded = $state(false); + let connected = $state(isConnected()); let memberInfos = $state(new Map()); // Search state @@ -68,8 +70,10 @@ }; let closeSub: (() => void) | undefined; + let closeConnectionListener: (() => void) | undefined; onMount(() => { + closeConnectionListener = onConnectionChange((c) => (connected = c)); // Show cached groups immediately, then fetch history in background refreshGroups().then(() => { loaded = true; }); fetchGroupHistory(user).then(refreshGroups); @@ -77,9 +81,16 @@ subscribeToMessages(user, refreshGroups).then((close) => (closeSub = close)); }); - onDestroy(() => closeSub?.()); + onDestroy(() => { + closeSub?.(); + closeConnectionListener?.(); + }); +{#if !connected} +
Reconnecting...
+{/if} +
+ + diff --git a/src/routes/(app)/messages/[id]/+page.svelte b/src/routes/(app)/messages/[id]/+page.svelte index a5768df7..4af0def5 100644 --- a/src/routes/(app)/messages/[id]/+page.svelte +++ b/src/routes/(app)/messages/[id]/+page.svelte @@ -12,6 +12,7 @@ findOrCreateDmGroup, resolveUser, displayName, decryptMediaUrl, + isConnected, onConnectionChange, } from "$lib/messaging"; import type { GroupInfo } from "$lib/messaging"; import { t } from "$lib/translations"; @@ -48,6 +49,7 @@ let dates: number[] = $state([]); let sendError = $state(""); let uploading = $state(false); + let connected = $state(isConnected()); let senderInfos = $state(new Map()); const groupDisplayName = (g: GroupInfo | undefined): string => { @@ -243,8 +245,10 @@ }; let closeSub: (() => void) | undefined; + let closeConnectionListener: (() => void) | undefined; onMount(() => { + closeConnectionListener = onConnectionChange((c) => (connected = c)); loadGroup(); const vv = window.visualViewport; @@ -272,7 +276,10 @@ }).then((close) => (closeSub = close)); }); - onDestroy(() => closeSub?.()); + onDestroy(() => { + closeSub?.(); + closeConnectionListener?.(); + });
@@ -281,6 +288,9 @@ {#if groupInfo && groupInfo.members.length > 2} {groupInfo.members.length} members {/if} + {#if !connected} + reconnecting... + {/if}
@@ -394,6 +404,17 @@ flex-shrink: 0; } + .connection-status { + font-size: 0.75rem; + color: #f59e0b; + animation: pulse-text 1.5s ease-in-out infinite; + } + + @keyframes pulse-text { + 0%, 100% { opacity: 0.6; } + 50% { opacity: 1; } + } + .warning { color: #ff7f00; } diff --git a/src/service-worker.js b/src/service-worker.js index b5c3a7c0..c75de809 100644 --- a/src/service-worker.js +++ b/src/service-worker.js @@ -113,10 +113,18 @@ self.addEventListener("fetch", (event) => { }); self.addEventListener("push", (event) => { - const { title, body, url } = event.data.json(); + const data = event.data.json(); + const { title, body, url } = data; event.waitUntil( clients.matchAll({ type: "window" }).then((clientList) => { + // Forward raw event to all open clients for instant processing + if (data.event) { + for (const client of clientList) { + client.postMessage({ type: "mls-event", event: data.event }); + } + } + const onPage = clientList.some( (c) => c.visibilityState === "visible" && new URL(c.url).pathname.startsWith(url), ); -- cgit v1.2.3