aboutsummaryrefslogtreecommitdiffstats
diff options
context:
space:
mode:
authorAdam Soltys <[email protected]>2026-03-07 10:39:57 -0800
committerAdam Soltys <[email protected]>2026-03-07 10:39:57 -0800
commit63249aed9dd62e59aa302ddcf7c3a419effc43b3 (patch)
tree48e89bcc0c58a92753a75d5d8d9e9dea4291dc87
parentad0b4009b2b2ae9173fffb41f0cfa7b35edcb303 (diff)
downloadcoinos-ui-63249aed9dd62e59aa302ddcf7c3a419effc43b3.tar.gz
coinos-ui-63249aed9dd62e59aa302ddcf7c3a419effc43b3.zip
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 <[email protected]>
-rw-r--r--src/lib/messaging.ts3
-rw-r--r--src/lib/mls-relay.ts152
-rw-r--r--src/routes/(app)/messages/+page.svelte28
-rw-r--r--src/routes/(app)/messages/[id]/+page.svelte23
-rw-r--r--src/service-worker.js10
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
@@ -129,6 +129,90 @@ let _activeSub: {
} | 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<void> {
+ 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<string, any>());
// 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?.();
+ });
</script>
+{#if !connected}
+ <div class="reconnecting-banner">Reconnecting...</div>
+{/if}
+
<div class="container mx-auto max-w-xl px-4 space-y-1">
<input
use:focus
@@ -134,3 +145,18 @@
{/each}
{/if}
</div>
+
+<style>
+ .reconnecting-banner {
+ text-align: center;
+ font-size: 0.75rem;
+ color: #f59e0b;
+ padding: 0.25rem;
+ animation: pulse-text 1.5s ease-in-out infinite;
+ }
+
+ @keyframes pulse-text {
+ 0%, 100% { opacity: 0.6; }
+ 50% { opacity: 1; }
+ }
+</style>
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<string, any>());
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?.();
+ });
</script>
<div class="chat-container">
@@ -281,6 +288,9 @@
{#if groupInfo && groupInfo.members.length > 2}
<span class="text-secondary text-sm">{groupInfo.members.length} members</span>
{/if}
+ {#if !connected}
+ <span class="connection-status">reconnecting...</span>
+ {/if}
</div>
<div id="messages">
@@ -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),
);