aboutsummaryrefslogtreecommitdiffstats
path: root/src/lib
diff options
context:
space:
mode:
authorAdam Soltys <[email protected]>2026-03-05 22:10:50 -0800
committerAdam Soltys <[email protected]>2026-03-05 22:32:43 -0800
commit653224387d8c09e461aabe632dbad6d59caf8f05 (patch)
tree4bc940709f10550a86fe172579b21c0ab4a2c240 /src/lib
parent796119569ae96ff0afd5cc4d5472175e271e9c8d (diff)
downloadcoinos-ui-653224387d8c09e461aabe632dbad6d59caf8f05.tar.gz
coinos-ui-653224387d8c09e461aabe632dbad6d59caf8f05.zip
Add MLS messaging with marmot-ts, NIP-17 fallback
Integrate MLS (Messaging Layer Security) via marmot-ts for encrypted group messaging. Falls back to NIP-17 when the recipient doesn't have an MLS key package. Includes optimistic UI for sent messages, local caching of decrypted rumours, dynamic group subscriptions, and self-echo filtering. Fix SSR crash in nostr settings page. Co-Authored-By: Claude Opus 4.6 <[email protected]>
Diffstat (limited to 'src/lib')
-rw-r--r--src/lib/marmot.ts356
-rw-r--r--src/lib/messaging.ts109
-rw-r--r--src/lib/mls-relay.ts481
3 files changed, 946 insertions, 0 deletions
diff --git a/src/lib/marmot.ts b/src/lib/marmot.ts
new file mode 100644
index 00000000..ed2143c3
--- /dev/null
+++ b/src/lib/marmot.ts
@@ -0,0 +1,356 @@
+/**
+ * Marmot protocol integration: MarmotClient setup, storage backends,
+ * signer adapter, and network adapter.
+ *
+ * Uses @internet-privacy/marmot-ts which wraps ts-mls with Nostr-specific
+ * MIP-00/01/02/03 protocol handling.
+ */
+
+import type { EventTemplate, NostrEvent, UnsignedEvent } from "nostr-tools/pure";
+import { finalizeEvent } from "nostr-tools/pure";
+import { bytesToHex, hexToBytes } from "@noble/hashes/utils.js";
+import { SimplePool } from "nostr-tools/pool";
+import { Relay } from "nostr-tools/relay";
+import {
+ MarmotClient,
+ KeyPackageStore,
+ InviteReader,
+} from "@internet-privacy/marmot-ts";
+import type {
+ NostrNetworkInterface,
+ PublishResponse,
+ Subscribable,
+ GroupStateStoreBackend,
+ SerializedClientState,
+ StoredKeyPackage,
+} from "@internet-privacy/marmot-ts";
+import { ensureSigner } from "$lib/nip07";
+import { encrypt as nip44Encrypt, decrypt as nip44Decrypt, u as nip44u } from "$lib/nip44";
+import { PUBLIC_DM_RELAYS } from "$env/static/public";
+
+/** Key-value store interface matching marmot-ts's KeyValueStoreBackend */
+interface KVStore<T> {
+ getItem(key: string): Promise<T | null>;
+ setItem(key: string, value: T): Promise<T>;
+ removeItem(key: string): Promise<void>;
+ clear(): Promise<void>;
+ keys(): Promise<string[]>;
+}
+
+const DM_RELAYS = PUBLIC_DM_RELAYS.split(",");
+const pool = new SimplePool();
+
+// ---------------------------------------------------------------------------
+// EventSigner adapter — bridges our ensureSigner/NIP-07 to applesauce interface
+// ---------------------------------------------------------------------------
+
+class CoinosEventSigner {
+ private pubkey: string;
+
+ constructor(pubkey: string) {
+ this.pubkey = pubkey;
+ }
+
+ getPublicKey(): string {
+ return this.pubkey;
+ }
+
+ async signEvent(draft: EventTemplate | UnsignedEvent): Promise<NostrEvent> {
+ const sk = await ensureSigner(this.pubkey);
+ if (sk === null) {
+ return (window as any).nostr.signEvent(draft);
+ }
+ return finalizeEvent(draft as any, sk);
+ }
+
+ nip44 = {
+ encrypt: async (pubkey: string, plaintext: string): Promise<string> => {
+ const sk = await ensureSigner(this.pubkey);
+ if (sk === null) {
+ return (window as any).nostr.nip44.encrypt(pubkey, plaintext);
+ }
+ const skHex = bytesToHex(sk);
+ const ckey = nip44u.getConversationKey(skHex, pubkey);
+ return nip44Encrypt(plaintext, ckey);
+ },
+ decrypt: async (pubkey: string, ciphertext: string): Promise<string> => {
+ const sk = await ensureSigner(this.pubkey);
+ if (sk === null) {
+ return (window as any).nostr.nip44.decrypt(pubkey, ciphertext);
+ }
+ const skHex = bytesToHex(sk);
+ const ckey = nip44u.getConversationKey(skHex, pubkey);
+ return nip44Decrypt(ciphertext, ckey);
+ },
+ };
+}
+
+// ---------------------------------------------------------------------------
+// NostrNetworkInterface adapter — bridges SimplePool to marmot-ts
+// ---------------------------------------------------------------------------
+
+class CoinosNostrNetwork implements NostrNetworkInterface {
+ async publish(relays: string[], event: NostrEvent): Promise<Record<string, PublishResponse>> {
+ const results: Record<string, PublishResponse> = {};
+ await Promise.allSettled(
+ relays.map(async (url) => {
+ try {
+ const relay = await Relay.connect(url);
+ await relay.publish(event);
+ results[url] = { from: url, ok: true };
+ } catch (e: any) {
+ results[url] = { from: url, ok: false, message: e?.message };
+ }
+ }),
+ );
+ return results;
+ }
+
+ async request(relays: string[], filters: any): Promise<NostrEvent[]> {
+ if (Array.isArray(filters)) {
+ return pool.querySync(relays, filters[0], ...filters.slice(1)) as any;
+ }
+ return pool.querySync(relays, filters) as any;
+ }
+
+ subscription(relays: string[], filters: any): Subscribable<NostrEvent> {
+ const filterArr = Array.isArray(filters) ? filters : [filters];
+
+ return {
+ subscribe(observer) {
+ const subs: (() => void)[] = [];
+
+ for (const url of relays) {
+ Relay.connect(url)
+ .then((relay) => {
+ const sub = relay.subscribe(filterArr, {
+ onevent: (event) => observer.next?.(event as NostrEvent),
+ oneose: () => {},
+ });
+ subs.push(() => sub.close());
+ })
+ .catch((e) => observer.error?.(e));
+ }
+
+ return {
+ unsubscribe() {
+ subs.forEach((close) => close());
+ },
+ };
+ },
+ };
+ }
+
+ async getUserInboxRelays(pubkey: string): Promise<string[]> {
+ // Fetch kind 10050 (NIP-17 inbox relays) or fall back to DM_RELAYS
+ try {
+ const events = await pool.querySync(DM_RELAYS, {
+ kinds: [10050],
+ authors: [pubkey],
+ limit: 1,
+ });
+ if (events.length > 0) {
+ const relays = events[0].tags
+ .filter((t: string[]) => t[0] === "relay")
+ .map((t: string[]) => t[1]);
+ if (relays.length > 0) return relays;
+ }
+ } catch {}
+ return DM_RELAYS;
+ }
+}
+
+// ---------------------------------------------------------------------------
+// IndexedDB-backed KeyValueStoreBackend
+// ---------------------------------------------------------------------------
+
+const IDB_NAME = "coinos-marmot";
+const IDB_VERSION = 1;
+
+function openMarmotDb(): Promise<IDBDatabase> {
+ return new Promise((resolve, reject) => {
+ const req = indexedDB.open(IDB_NAME, IDB_VERSION);
+ req.onupgradeneeded = () => {
+ const db = req.result;
+ if (!db.objectStoreNames.contains("group-state")) {
+ db.createObjectStore("group-state");
+ }
+ if (!db.objectStoreNames.contains("key-packages")) {
+ db.createObjectStore("key-packages");
+ }
+ if (!db.objectStoreNames.contains("invites-received")) {
+ db.createObjectStore("invites-received");
+ }
+ if (!db.objectStoreNames.contains("invites-unread")) {
+ db.createObjectStore("invites-unread");
+ }
+ if (!db.objectStoreNames.contains("invites-seen")) {
+ db.createObjectStore("invites-seen");
+ }
+ };
+ req.onsuccess = () => resolve(req.result);
+ req.onerror = () => reject(req.error);
+ });
+}
+
+/** Generic IndexedDB key-value backend for marmot-ts stores. */
+function createIDBKeyValueStore<T>(storeName: string): KVStore<T> {
+ return {
+ async getItem(key: string): Promise<T | null> {
+ const db = await openMarmotDb();
+ return new Promise((resolve, reject) => {
+ const tx = db.transaction(storeName, "readonly");
+ const req = tx.objectStore(storeName).get(key);
+ req.onsuccess = () => resolve(req.result ?? null);
+ req.onerror = () => reject(req.error);
+ });
+ },
+ async setItem(key: string, value: T): Promise<T> {
+ const db = await openMarmotDb();
+ return new Promise((resolve, reject) => {
+ const tx = db.transaction(storeName, "readwrite");
+ tx.objectStore(storeName).put(value, key);
+ tx.oncomplete = () => resolve(value);
+ tx.onerror = () => reject(tx.error);
+ });
+ },
+ async removeItem(key: string): Promise<void> {
+ const db = await openMarmotDb();
+ return new Promise((resolve, reject) => {
+ const tx = db.transaction(storeName, "readwrite");
+ tx.objectStore(storeName).delete(key);
+ tx.oncomplete = () => resolve();
+ tx.onerror = () => reject(tx.error);
+ });
+ },
+ async clear(): Promise<void> {
+ const db = await openMarmotDb();
+ return new Promise((resolve, reject) => {
+ const tx = db.transaction(storeName, "readwrite");
+ tx.objectStore(storeName).clear();
+ tx.oncomplete = () => resolve();
+ tx.onerror = () => reject(tx.error);
+ });
+ },
+ async keys(): Promise<string[]> {
+ const db = await openMarmotDb();
+ return new Promise((resolve, reject) => {
+ const tx = db.transaction(storeName, "readonly");
+ const req = tx.objectStore(storeName).getAllKeys();
+ req.onsuccess = () => resolve(req.result.map(String));
+ req.onerror = () => reject(req.error);
+ });
+ },
+ };
+}
+
+// ---------------------------------------------------------------------------
+// GroupStateStoreBackend (uses Uint8Array keys, needs special handling)
+// ---------------------------------------------------------------------------
+
+class IDBGroupStateBackend implements GroupStateStoreBackend {
+ private storeName = "group-state";
+
+ private keyToStr(groupId: Uint8Array): string {
+ return bytesToHex(groupId);
+ }
+
+ async get(groupId: Uint8Array): Promise<SerializedClientState | null> {
+ const db = await openMarmotDb();
+ return new Promise((resolve, reject) => {
+ const tx = db.transaction(this.storeName, "readonly");
+ const req = tx.objectStore(this.storeName).get(this.keyToStr(groupId));
+ req.onsuccess = () => {
+ const result = req.result;
+ if (!result) return resolve(null);
+ resolve(result instanceof Uint8Array ? result : new Uint8Array(result));
+ };
+ req.onerror = () => reject(req.error);
+ });
+ }
+
+ async set(groupId: Uint8Array, stateBytes: SerializedClientState): Promise<void> {
+ const db = await openMarmotDb();
+ return new Promise((resolve, reject) => {
+ const tx = db.transaction(this.storeName, "readwrite");
+ tx.objectStore(this.storeName).put(stateBytes, this.keyToStr(groupId));
+ tx.oncomplete = () => resolve();
+ tx.onerror = () => reject(tx.error);
+ });
+ }
+
+ async remove(groupId: Uint8Array): Promise<void> {
+ const db = await openMarmotDb();
+ return new Promise((resolve, reject) => {
+ const tx = db.transaction(this.storeName, "readwrite");
+ tx.objectStore(this.storeName).delete(this.keyToStr(groupId));
+ tx.oncomplete = () => resolve();
+ tx.onerror = () => reject(tx.error);
+ });
+ }
+
+ async list(): Promise<Uint8Array[]> {
+ const db = await openMarmotDb();
+ return new Promise((resolve, reject) => {
+ const tx = db.transaction(this.storeName, "readonly");
+ const req = tx.objectStore(this.storeName).getAllKeys();
+ req.onsuccess = () => resolve(req.result.map((k) => hexToBytes(String(k))));
+ req.onerror = () => reject(req.error);
+ });
+ }
+}
+
+// ---------------------------------------------------------------------------
+// Singleton MarmotClient + InviteReader
+// ---------------------------------------------------------------------------
+
+let _client: MarmotClient | null = null;
+let _inviteReader: InviteReader | null = null;
+let _currentPubkey: string | null = null;
+const _network = new CoinosNostrNetwork();
+
+export function getMarmotClient(pubkey: string): MarmotClient {
+ if (_client && _currentPubkey === pubkey) return _client;
+
+ // Tear down old client if user changed
+ _client = null;
+ _inviteReader = null;
+ _currentPubkey = pubkey;
+
+ const signer = new CoinosEventSigner(pubkey);
+ const groupStateBackend = new IDBGroupStateBackend();
+ const keyPackageBackend = createIDBKeyValueStore<StoredKeyPackage>("key-packages") as any;
+ const keyPackageStore = new KeyPackageStore(keyPackageBackend);
+
+ _client = new MarmotClient({
+ signer,
+ groupStateBackend,
+ keyPackageStore,
+ network: _network,
+ });
+
+ return _client;
+}
+
+export function getInviteReader(pubkey: string): InviteReader {
+ if (_inviteReader && _currentPubkey === pubkey) return _inviteReader;
+
+ // Ensure client is initialized
+ getMarmotClient(pubkey);
+
+ const signer = new CoinosEventSigner(pubkey);
+ const store = {
+ received: createIDBKeyValueStore("invites-received"),
+ unread: createIDBKeyValueStore("invites-unread"),
+ seen: createIDBKeyValueStore("invites-seen"),
+ } as any;
+
+ _inviteReader = new InviteReader({ signer, store });
+ return _inviteReader;
+}
+
+export function getNetwork(): CoinosNostrNetwork {
+ return _network;
+}
+
+export { DM_RELAYS };
diff --git a/src/lib/messaging.ts b/src/lib/messaging.ts
new file mode 100644
index 00000000..fdb03f90
--- /dev/null
+++ b/src/lib/messaging.ts
@@ -0,0 +1,109 @@
+/**
+ * Unified messaging facade: MLS (marmot) with NIP-17 fallback.
+ *
+ * Drop-in replacement for the nip17 send/subscribe/getMessageRumours API.
+ * Checks if the recipient supports MLS (has kind 443 key package) and
+ * uses MLS when available, otherwise falls back to NIP-17.
+ */
+
+import * as nip17 from "$lib/nip17";
+import {
+ recipientSupportsMls,
+ sendMlsMessage,
+ subscribeToMlsMessages,
+ getMlsMessageRumours,
+ publishKeyPackage,
+} from "$lib/mls-relay";
+
+// Re-export unchanged functions from nip17
+export {
+ decrypt,
+ getPreferredRelays,
+ cacheRumour,
+ getCachedChatList,
+ setCachedChatList,
+ getCachedUserInfo,
+ setCachedUserInfo,
+} from "$lib/nip17";
+
+// Cache for MLS support checks
+const mlsSupportCache = new Map<string, boolean>();
+
+async function checkMlsSupport(pubkey: string): Promise<boolean> {
+ if (mlsSupportCache.has(pubkey)) return mlsSupportCache.get(pubkey)!;
+ try {
+ const supported = await recipientSupportsMls(pubkey);
+ mlsSupportCache.set(pubkey, supported);
+ return supported;
+ } catch {
+ return false;
+ }
+}
+
+/** Send a message — MLS if both parties support it, NIP-17 otherwise. */
+export const send = async (message: string, user: any, recipient: any, expiryDays?: number) => {
+ if (user.pubkey !== recipient.pubkey && !expiryDays) {
+ try {
+ const peerSupports = await checkMlsSupport(recipient.pubkey);
+ if (peerSupports) {
+ await sendMlsMessage(user, recipient.pubkey, message);
+ return;
+ }
+ } catch (e) {
+ console.warn("[messaging] MLS send failed, falling back to NIP-17:", e);
+ }
+ }
+
+ return nip17.send(message, user, recipient, expiryDays);
+};
+
+/** Get all message rumours — combines NIP-17 and MLS sources. */
+export const getMessageRumours = async (user: any): Promise<any[]> => {
+ const [nip17Rumours, mlsRumours] = await Promise.allSettled([
+ nip17.getMessageRumours(user),
+ getMlsMessageRumours(user),
+ ]);
+
+ const rumours: any[] = [];
+ if (nip17Rumours.status === "fulfilled") rumours.push(...nip17Rumours.value);
+ if (mlsRumours.status === "fulfilled") rumours.push(...mlsRumours.value);
+
+ // Dedupe by id
+ const seen = new Set<string>();
+ return rumours.filter((r) => {
+ if (seen.has(r.id)) return false;
+ seen.add(r.id);
+ return true;
+ });
+};
+
+/** Subscribe to both NIP-17 and MLS incoming messages. Returns a close function. */
+export const subscribeToMessages = async (
+ user: any,
+ onNewRumour: (rumour: any) => void,
+): Promise<() => void> => {
+ const closers: (() => void)[] = [];
+
+ // NIP-17 subscription
+ try {
+ const closeNip17 = await nip17.subscribeToMessages(user, onNewRumour);
+ closers.push(closeNip17);
+ } catch (e) {
+ console.error("[messaging] NIP-17 subscription failed:", e);
+ }
+
+ // MLS subscription
+ try {
+ const closeMls = await subscribeToMlsMessages(user, onNewRumour);
+ closers.push(closeMls);
+ } catch (e) {
+ console.error("[messaging] MLS subscription failed:", e);
+ }
+
+ // Publish our key package so others can start MLS conversations with us
+ publishKeyPackage(user).catch((e) =>
+ console.warn("[messaging] Failed to publish MLS key package:", e),
+ );
+
+ return () => closers.forEach((c) => c());
+};
diff --git a/src/lib/mls-relay.ts b/src/lib/mls-relay.ts
new file mode 100644
index 00000000..bc24b953
--- /dev/null
+++ b/src/lib/mls-relay.ts
@@ -0,0 +1,481 @@
+/**
+ * MLS relay layer using marmot-ts: subscribes to kinds 443/1059/445,
+ * manages key packages, and bridges MLS messages into the existing chat UI.
+ *
+ * Produces "rumour-like" objects so the UI can consume them alongside NIP-17.
+ */
+
+import type { NostrEvent } from "nostr-tools/pure";
+import { bytesToHex } from "@noble/hashes/utils.js";
+import { SimplePool } from "nostr-tools/pool";
+import { Relay } from "nostr-tools/relay";
+
+import { getMarmotClient, getInviteReader, getNetwork, DM_RELAYS } from "$lib/marmot";
+
+const pool = new SimplePool();
+
+// In-memory caches
+const peerGroupMap = new Map<string, string>(); // pubkey -> mlsGroupIdHex
+const groupPeerMap = new Map<string, string>(); // mlsGroupIdHex -> pubkey
+const nostrIdToMlsId = new Map<string, string>(); // nostrGroupIdHex -> mlsGroupIdHex
+let mlsRumourCache: any[] = [];
+const processedEventIds = new Set<string>();
+
+/** Load cached rumours and processed event IDs from localStorage */
+function loadLocalCache(): void {
+ try {
+ const cached = localStorage.getItem("mls:rumours");
+ if (cached) {
+ const arr = JSON.parse(cached);
+ if (Array.isArray(arr)) mlsRumourCache = arr;
+ }
+ const ids = localStorage.getItem("mls:processedEvents");
+ if (ids) {
+ for (const id of JSON.parse(ids)) processedEventIds.add(id);
+ }
+ } catch {}
+}
+
+/** Save rumour cache and processed event IDs to localStorage */
+function saveLocalCache(): void {
+ try {
+ localStorage.setItem("mls:rumours", JSON.stringify(mlsRumourCache));
+ localStorage.setItem("mls:processedEvents", JSON.stringify([...processedEventIds]));
+ } catch {}
+}
+
+/** Get the nostr group ID (used in h tags) from a MarmotGroup */
+function getNostrGroupId(group: any): string {
+ const data = group.groupData;
+ if (data?.nostrGroupId) return bytesToHex(data.nostrGroupId);
+ // Fallback to MLS group ID if no marmot data
+ return bytesToHex(group.id);
+}
+
+/** Register the mapping between nostr group ID and MLS group ID */
+function registerGroupIds(group: any): void {
+ const nostrId = getNostrGroupId(group);
+ const mlsId = bytesToHex(group.id);
+ nostrIdToMlsId.set(nostrId, mlsId);
+}
+
+// Active subscription state — allows adding group subscriptions dynamically
+let _activeSub: {
+ user: any;
+ relays: string[];
+ seen: Set<string>;
+ onNewRumour: (rumour: any) => void;
+ subscribedGroups: Set<string>;
+ closers: (() => void)[];
+ connectedRelays: any[];
+} | null = null;
+
+// ---------------------------------------------------------------------------
+// Key package management (kind 443)
+// ---------------------------------------------------------------------------
+
+/** Publish our MLS key package to relays so others can invite us to groups. */
+export async function publishKeyPackage(user: any, relays: string[] = DM_RELAYS): Promise<void> {
+ const client = getMarmotClient(user.pubkey);
+ await client.keyPackages.create({ relays, client: "coinos/marmot-ts" });
+}
+
+/** Fetch a recipient's key package event from relays (kind 443). */
+export async function fetchKeyPackage(pubkey: string, relays: string[] = DM_RELAYS): Promise<NostrEvent | null> {
+ const events = await pool.querySync(relays, {
+ kinds: [443],
+ authors: [pubkey],
+ limit: 1,
+ });
+ if (events.length === 0) return null;
+ return events.sort((a, b) => b.created_at - a.created_at)[0] as NostrEvent;
+}
+
+/** Check if a recipient supports MLS (has a kind 443 key package). */
+export async function recipientSupportsMls(pubkey: string, relays: string[] = DM_RELAYS): Promise<boolean> {
+ const kp = await fetchKeyPackage(pubkey, relays);
+ return kp !== null;
+}
+
+// ---------------------------------------------------------------------------
+// Starting an MLS conversation (1:1 = 2-person group)
+// ---------------------------------------------------------------------------
+
+/** Start an MLS DM with a recipient. Creates group and sends welcome via NIP-59. */
+export async function startMlsConversation(
+ user: any,
+ recipientPubkey: string,
+ relays: string[] = DM_RELAYS,
+): Promise<any> {
+ // Check if we already have a group with this user
+ const existingGroupId = peerGroupMap.get(recipientPubkey);
+ if (existingGroupId) {
+ const client = getMarmotClient(user.pubkey);
+ try {
+ return await client.getGroup(existingGroupId);
+ } catch {}
+ }
+
+ // Fetch recipient's key package
+ const kpEvent = await fetchKeyPackage(recipientPubkey, relays);
+ if (!kpEvent) {
+ throw new Error("Recipient has no MLS key package published");
+ }
+
+ // Use relays from the key package event so the recipient can see our commits/welcomes
+ const kpRelaysTag = kpEvent.tags.find((t: string[]) => t[0] === "relays");
+ const groupRelays = kpRelaysTag ? kpRelaysTag.slice(1) : relays;
+
+ // Create group and invite recipient
+ const client = getMarmotClient(user.pubkey);
+ const group = await client.createGroup("", { relays: groupRelays });
+
+ // Invite by key package event — handles Add proposal, Commit, Welcome delivery via NIP-59
+ await group.inviteByKeyPackageEvent(kpEvent);
+
+ // Register IDs and store peer mapping
+ registerGroupIds(group);
+ const mlsGroupIdHex = bytesToHex(group.id);
+ storePeerGroupMapping(recipientPubkey, mlsGroupIdHex);
+
+ // Subscribe to this new group's messages using nostr group ID
+ subscribeToGroup(getNostrGroupId(group));
+
+ return group;
+}
+
+// ---------------------------------------------------------------------------
+// Sending MLS messages
+// ---------------------------------------------------------------------------
+
+/** Send a message via MLS. */
+export async function sendMlsMessage(
+ user: any,
+ recipientPubkey: string,
+ message: string,
+ relays: string[] = DM_RELAYS,
+): Promise<void> {
+ let mlsGroupIdHex = peerGroupMap.get(recipientPubkey);
+
+ if (!mlsGroupIdHex) {
+ const group = await startMlsConversation(user, recipientPubkey, relays);
+ mlsGroupIdHex = bytesToHex(group.id);
+ }
+
+ const client = getMarmotClient(user.pubkey);
+ const group = await client.getGroup(mlsGroupIdHex!);
+ await group.sendChatMessage(message);
+
+ // Add to local cache as a rumour-like object
+ const rumour = makeRumour(message, user.pubkey, recipientPubkey);
+ mlsRumourCache.push(rumour);
+ saveLocalCache();
+}
+
+// ---------------------------------------------------------------------------
+// Dynamic group subscription — subscribe to a single group on existing relays
+// ---------------------------------------------------------------------------
+
+/** Subscribe to a group's messages. Uses the nostr group ID (h tag value). */
+function subscribeToGroup(nostrGroupIdHex: string): void {
+ if (!_activeSub) return;
+ if (_activeSub.subscribedGroups.has(nostrGroupIdHex)) return;
+
+ _activeSub.subscribedGroups.add(nostrGroupIdHex);
+ const { seen, onNewRumour, closers, connectedRelays, user } = _activeSub;
+ const client = getMarmotClient(user.pubkey);
+
+ for (const relay of connectedRelays) {
+ try {
+ const sub = relay.subscribe(
+ [{ kinds: [445], "#h": [nostrGroupIdHex] }],
+ {
+ onevent: async (event: any) => {
+ if (seen.has(event.id)) return;
+ seen.add(event.id);
+ const rumour = await handleGroupMessageEvent(event as NostrEvent, user, client);
+ if (rumour) onNewRumour(rumour);
+ },
+ },
+ );
+ closers.push(() => sub.close());
+ } catch {}
+ }
+
+}
+
+// ---------------------------------------------------------------------------
+// Subscription: listen for welcomes (kind 1059) and messages (kind 445)
+// ---------------------------------------------------------------------------
+
+/** Subscribe to MLS events for the user. Returns a close function. */
+export async function subscribeToMlsMessages(
+ user: any,
+ onNewRumour: (rumour: any) => void,
+ relays: string[] = DM_RELAYS,
+): Promise<() => void> {
+ const client = getMarmotClient(user.pubkey);
+ const inviteReader = getInviteReader(user.pubkey);
+ const closers: (() => void)[] = [];
+ const seen = new Set<string>();
+ const subscribedGroups = new Set<string>();
+ const connectedRelays: any[] = [];
+
+ // Set up active subscription state for dynamic group adds
+ _activeSub = { user, relays, seen, onNewRumour, subscribedGroups, closers, connectedRelays };
+
+ // Load all known groups and register their ID mappings
+ await client.loadAllGroups();
+ const allRelayUrls = new Set(relays);
+ for (const g of client.groups) {
+ registerGroupIds(g);
+ const r = g.relays;
+ if (r) r.forEach((url: string) => allRelayUrls.add(url));
+ }
+
+ // Listen for new invites (welcomes via NIP-59)
+ inviteReader.on("newInvite", async (invite) => {
+ try {
+ const { group } = await client.joinGroupFromWelcome({ welcomeRumor: invite });
+ registerGroupIds(group);
+ const mlsGroupIdHex = bytesToHex(group.id);
+ const senderPubkey = invite.pubkey;
+ storePeerGroupMapping(senderPubkey, mlsGroupIdHex);
+ // Subscribe to this newly joined group's messages
+ subscribeToGroup(getNostrGroupId(group));
+ } catch (e) {
+ console.error("[mls-relay] Failed to join from welcome:", e);
+ }
+ });
+
+ for (const url of allRelayUrls) {
+ try {
+ const relay = await Relay.connect(url);
+ connectedRelays.push(relay);
+
+ // Subscribe to gift wraps (kind 1059) addressed to us — welcomes arrive via NIP-59
+ const welcomeSub = relay.subscribe(
+ [{ kinds: [1059], "#p": [user.pubkey] }],
+ {
+ onevent: async (event) => {
+ if (seen.has(event.id)) return;
+ seen.add(event.id);
+ try {
+ const ingested = await inviteReader.ingestEvent(event as NostrEvent);
+ if (ingested) {
+ await inviteReader.decryptGiftWraps();
+ }
+ } catch {}
+ },
+ },
+ );
+ closers.push(() => welcomeSub.close());
+
+ // Subscribe to messages for all groups we're already in (kind 445)
+ 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);
+ }
+ const msgSub = relay.subscribe(
+ [{ kinds: [445], "#h": hTags }],
+ {
+ onevent: async (event) => {
+ if (seen.has(event.id)) return;
+ seen.add(event.id);
+ const rumour = await handleGroupMessageEvent(event as NostrEvent, user, client);
+ if (rumour) onNewRumour(rumour);
+ },
+ },
+ );
+ closers.push(() => msgSub.close());
+ }
+ } catch (e) {
+ console.error("[mls-relay] Failed to connect:", url, e);
+ }
+ }
+
+ return () => {
+ _activeSub = null;
+ inviteReader.removeAllListeners("newInvite");
+ closers.forEach((c) => c());
+ };
+}
+
+// ---------------------------------------------------------------------------
+// Handle incoming kind 445 group messages
+// ---------------------------------------------------------------------------
+
+async function handleGroupMessageEvent(
+ event: NostrEvent,
+ user: any,
+ client: any,
+): Promise<any | null> {
+ try {
+ const hTag = event.tags.find((t: string[]) => t[0] === "h");
+ if (!hTag) return null;
+ const nostrGroupIdHex = hTag[1];
+
+ // Skip already-processed events
+ if (processedEventIds.has(event.id)) return null;
+
+ // Resolve nostr group ID to MLS group ID for client.getGroup()
+ const mlsGroupIdHex = nostrIdToMlsId.get(nostrGroupIdHex);
+ if (!mlsGroupIdHex) return null;
+
+ let group;
+ try {
+ group = await client.getGroup(mlsGroupIdHex);
+ } catch {
+ return null;
+ }
+
+ // Use MarmotGroup.ingest to process the event
+ for await (const result of group.ingest([event])) {
+ if (result.kind === "processed" && result.result.kind === "applicationMessage") {
+ const raw = new TextDecoder().decode(result.result.message);
+ let content: string;
+ let senderPubkey: string;
+ try {
+ const rumor = JSON.parse(raw);
+ content = rumor.content ?? raw;
+ senderPubkey = rumor.pubkey ?? (groupPeerMap.get(mlsGroupIdHex) || event.pubkey);
+ } catch {
+ content = raw;
+ senderPubkey = groupPeerMap.get(mlsGroupIdHex) || event.pubkey;
+ }
+
+ // Skip self-echo — we already cached our own messages locally at send time
+ if (senderPubkey === user.pubkey) {
+ processedEventIds.add(event.id);
+ saveLocalCache();
+ await group.save();
+ return null;
+ }
+
+ const recipientPubkey = user.pubkey;
+
+ const rumour = makeRumour(content, senderPubkey, recipientPubkey);
+ rumour._mls = true;
+ rumour._groupId = mlsGroupIdHex;
+ mlsRumourCache.push(rumour);
+ processedEventIds.add(event.id);
+ saveLocalCache();
+
+ await group.save();
+ return rumour;
+ }
+ }
+
+ processedEventIds.add(event.id);
+ saveLocalCache();
+ await group.save();
+ return null;
+ } catch (e) {
+ console.error("[mls-relay] Failed to process group message:", event.id, e);
+ return null;
+ }
+}
+
+// ---------------------------------------------------------------------------
+// Fetch historical MLS messages
+// ---------------------------------------------------------------------------
+
+/** Fetch MLS messages the user has received. Returns rumour-like objects. */
+export async function getMlsMessageRumours(user: any, relays: string[] = DM_RELAYS): Promise<any[]> {
+ // Load from localStorage on first call
+ if (mlsRumourCache.length === 0) loadLocalCache();
+ if (mlsRumourCache.length > 0) return mlsRumourCache;
+
+ const client = getMarmotClient(user.pubkey);
+ await client.loadAllGroups();
+
+ const groups = client.groups;
+ if (groups.length === 0) return [];
+
+ // Register ID mappings and collect nostr group IDs for h tag queries
+ const hTags: string[] = [];
+ for (const g of groups) {
+ registerGroupIds(g);
+ hTags.push(getNostrGroupId(g));
+ }
+
+ // Also query the group's own relays, not just DM_RELAYS
+ const groupRelays = new Set(relays);
+ for (const g of groups) {
+ const r = g.relays;
+ if (r) r.forEach((url: string) => groupRelays.add(url));
+ }
+
+ // Fetch kind 445 events
+ const events = await pool.querySync([...groupRelays], {
+ kinds: [445],
+ "#h": hTags,
+ limit: 1000,
+ });
+
+ // Sort chronologically and process
+ events.sort((a, b) => a.created_at - b.created_at);
+
+ for (const event of events) {
+ const rumour = await handleGroupMessageEvent(event as NostrEvent, user, client);
+ if (rumour) mlsRumourCache.push(rumour);
+ }
+ return mlsRumourCache;
+}
+
+// ---------------------------------------------------------------------------
+// Peer <-> Group mapping (localStorage)
+// ---------------------------------------------------------------------------
+
+function storePeerGroupMapping(peerPubkey: string, mlsGroupId: string): void {
+ peerGroupMap.set(peerPubkey, mlsGroupId);
+ groupPeerMap.set(mlsGroupId, peerPubkey);
+ try {
+ const key = "mls:peer2group";
+ const map = JSON.parse(localStorage.getItem(key) || "{}");
+ map[peerPubkey] = mlsGroupId;
+ localStorage.setItem(key, JSON.stringify(map));
+
+ const rkey = "mls:group2peer";
+ const rmap = JSON.parse(localStorage.getItem(rkey) || "{}");
+ rmap[mlsGroupId] = peerPubkey;
+ localStorage.setItem(rkey, JSON.stringify(rmap));
+ } catch {}
+}
+
+// Restore peer mappings from localStorage on load
+function restorePeerMappings(): void {
+ try {
+ const map = JSON.parse(localStorage.getItem("mls:peer2group") || "{}");
+ for (const [k, v] of Object.entries(map)) {
+ peerGroupMap.set(k, v as string);
+ }
+ const rmap = JSON.parse(localStorage.getItem("mls:group2peer") || "{}");
+ for (const [k, v] of Object.entries(rmap)) {
+ groupPeerMap.set(k, v as string);
+ }
+ } catch {}
+}
+restorePeerMappings();
+loadLocalCache();
+
+// ---------------------------------------------------------------------------
+// Helpers
+// ---------------------------------------------------------------------------
+
+/** Create a rumour-like object compatible with the existing chat UI. */
+function makeRumour(content: string, senderPubkey: string, recipientPubkey: string): any {
+ return {
+ id: bytesToHex(crypto.getRandomValues(new Uint8Array(32))),
+ kind: 14,
+ created_at: Math.floor(Date.now() / 1000),
+ pubkey: senderPubkey,
+ content,
+ tags: [["p", recipientPubkey]],
+ _mls: true,
+ };
+}