diff options
| author | Adam Soltys <[email protected]> | 2026-03-05 22:10:50 -0800 |
|---|---|---|
| committer | Adam Soltys <[email protected]> | 2026-03-05 22:32:43 -0800 |
| commit | 653224387d8c09e461aabe632dbad6d59caf8f05 (patch) | |
| tree | 4bc940709f10550a86fe172579b21c0ab4a2c240 /src/lib | |
| parent | 796119569ae96ff0afd5cc4d5472175e271e9c8d (diff) | |
| download | coinos-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.ts | 356 | ||||
| -rw-r--r-- | src/lib/messaging.ts | 109 | ||||
| -rw-r--r-- | src/lib/mls-relay.ts | 481 |
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, + }; +} |
