diff options
| author | Adam Soltys <[email protected]> | 2026-03-06 08:39:18 -0800 |
|---|---|---|
| committer | Adam Soltys <[email protected]> | 2026-03-06 08:39:18 -0800 |
| commit | 7e45e1b48c382f29c2d3bd114a4751a0f6b89d51 (patch) | |
| tree | 7cbfc937afdcccab812d7f97c8f85b681c0d4687 /src/lib | |
| parent | 3b18232d52957dbd1a5a0c12891020d2a6e3295e (diff) | |
| download | coinos-ui-7e45e1b48c382f29c2d3bd114a4751a0f6b89d51.tar.gz coinos-ui-7e45e1b48c382f29c2d3bd114a4751a0f6b89d51.zip | |
group chats
Diffstat (limited to 'src/lib')
| -rw-r--r-- | src/lib/messaging.ts | 153 | ||||
| -rw-r--r-- | src/lib/mls-relay.ts | 303 | ||||
| -rw-r--r-- | src/lib/nip17.ts | 11 | ||||
| -rw-r--r-- | src/lib/store.ts | 1 |
4 files changed, 218 insertions, 250 deletions
diff --git a/src/lib/messaging.ts b/src/lib/messaging.ts index c4d4b211..37a4c22e 100644 --- a/src/lib/messaging.ts +++ b/src/lib/messaging.ts @@ -1,114 +1,77 @@ /** - * Unified messaging facade: MLS (marmot) with NIP-17 fallback. + * Messaging facade — MLS groups only. * - * 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. + * Every conversation is an MLS group (1:1 DMs are 2-person groups). */ -import * as nip17 from "$lib/nip17"; -import { - recipientSupportsMls, - sendMlsMessage, - subscribeToMlsMessages, - getMlsMessageRumours, +export { publishKeyPackage, + getGroups, + getGroupRumours, + sendGroupMessage, + findOrCreateDmGroup, + subscribeToMessages, + fetchGroupHistory, } 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>(); +export type { GroupInfo } from "$lib/mls-relay"; -async function checkMlsSupport(pubkey: string): Promise<boolean> { - if (mlsSupportCache.has(pubkey)) return mlsSupportCache.get(pubkey)!; - try { - const supported = await recipientSupportsMls(pubkey); - console.log("[messaging] MLS support for", pubkey.slice(0, 12) + "…:", supported); - mlsSupportCache.set(pubkey, supported); - return supported; - } catch (e) { - console.warn("[messaging] MLS support check failed:", e); - return false; - } -} +import { getNostrUserInfo } from "$lib/nip01"; +import { isValid } from "nostr-tools/nip05"; -/** Send a message — MLS if both parties support it, NIP-17 otherwise. */ -export const send = async (message: string, user: any, recipient: any, expiryDays?: number) => { - console.log("[messaging] send to", recipient.pubkey?.slice(0, 12) + "…", { self: user.pubkey === recipient.pubkey, expiry: !!expiryDays }); - if (user.pubkey !== recipient.pubkey && !expiryDays) { - try { - const peerSupports = await checkMlsSupport(recipient.pubkey); - if (peerSupports) { - console.log("[messaging] → MLS path"); - await sendMlsMessage(user, recipient.pubkey, message); - return; - } - console.log("[messaging] → NIP-17 fallback (no MLS support)"); - } catch (e) { - console.warn("[messaging] MLS send failed, falling back to NIP-17:", e); - } - } +// --------------------------------------------------------------------------- +// User info cache (shared across pages) +// --------------------------------------------------------------------------- - return nip17.send(message, user, recipient, expiryDays); -}; +const userInfoCache = new Map<string, any>(); -/** 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), - ]); +export function getCachedUserInfo(pubkey: string): any | null { + return userInfoCache.get(pubkey) ?? null; +} - const rumours: any[] = []; - if (nip17Rumours.status === "fulfilled") rumours.push(...nip17Rumours.value); - if (mlsRumours.status === "fulfilled") rumours.push(...mlsRumours.value); +export function setCachedUserInfo(pubkey: string, info: any): void { + userInfoCache.set(pubkey, info); +} - // 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; - }); -}; +/** Resolve a pubkey to user info (coinos user, nostr profile, or anonymous). */ +export async function resolveUser(pubkey: string): Promise<any> { + const cached = userInfoCache.get(pubkey); + if (cached) return cached; -/** 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)[] = []; + const nostrUserInfo = (await getNostrUserInfo(pubkey)) || {}; + const valid = "nip05" in nostrUserInfo && (await isValid(pubkey, nostrUserInfo.nip05)); - // NIP-17 subscription + let coinosUsername = null; + let picture = nostrUserInfo?.picture || null; try { - const closeNip17 = await nip17.subscribeToMessages(user, onNewRumour); - closers.push(closeNip17); - } catch (e) { - console.error("[messaging] NIP-17 subscription failed:", e); - } + const response = await fetch(`/api/users/${pubkey}`); + if (response.ok) { + const info = await response.json(); + if (!info.anon) { + coinosUsername = info.username || null; + picture = info.picture || picture; + } + } + } catch {} - // MLS subscription - try { - const closeMls = await subscribeToMlsMessages(user, onNewRumour); - closers.push(closeMls); - } catch (e) { - console.error("[messaging] MLS subscription failed:", e); - } + const info = { + coinosUsername, + picture, + nostrName: nostrUserInfo?.name || null, + nip05: nostrUserInfo?.nip05 || null, + nip05Valid: valid, + pubkey, + }; + userInfoCache.set(pubkey, info); + return info; +} - // 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), - ); +/** Get a display name for a user info object. */ +export function displayName(info: any): string { + return info?.coinosUsername || info?.nostrName || shortPubkey(info?.pubkey || ""); +} - return () => closers.forEach((c) => c()); -}; +function shortPubkey(pk: string): string { + if (pk.length <= 16) return pk; + return pk.slice(0, 8) + "…" + pk.slice(-4); +} diff --git a/src/lib/mls-relay.ts b/src/lib/mls-relay.ts index 4b0001e3..6a280a3e 100644 --- a/src/lib/mls-relay.ts +++ b/src/lib/mls-relay.ts @@ -1,37 +1,70 @@ /** * 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. + * manages key packages, and provides group-based messaging. */ 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"; +import { getMarmotClient, getInviteReader, 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[] = []; +/** Extract member pubkeys from an MLS group's ratchet tree. */ +function getGroupMembers(group: any): string[] { + const state = group.state; + if (!state?.ratchetTree) return []; + const pubkeys = new Set<string>(); + for (const node of state.ratchetTree) { + if (node?.nodeType === 1 && node.leaf?.credential?.credentialType === 0) { + // Basic credential: identity is the pubkey bytes + const identity = node.leaf.credential.identity; + if (identity) { + const hex = typeof identity === "string" + ? identity + : bytesToHex(new Uint8Array(identity)); + if (hex.length === 64) pubkeys.add(hex); + } + } + } + return Array.from(pubkeys); +} + +// ID mappings +const nostrIdToMlsId = new Map<string, string>(); + +// Rumour cache keyed by group +const groupRumourCache = new Map<string, any[]>(); const processedEventIds = new Set<string>(); let _cacheUserPubkey: string | null = null; -/** Load cached rumours and processed event IDs from localStorage (per-user) */ +/** Group metadata exposed to the UI */ +export interface GroupInfo { + groupId: string; + name: string; + members: string[]; // member pubkeys + adminPubkeys: string[]; + relays: string[]; + lastMessage?: string; + lastMessageAt?: number; +} + +// --------------------------------------------------------------------------- +// LocalStorage cache (per-user) +// --------------------------------------------------------------------------- + function loadLocalCache(userPubkey?: string): void { if (userPubkey) _cacheUserPubkey = userPubkey; const pk = _cacheUserPubkey; if (!pk) return; try { - const cached = localStorage.getItem(`mls:rumours:${pk}`); + const cached = localStorage.getItem(`mls:groupRumours:${pk}`); if (cached) { - const arr = JSON.parse(cached); - if (Array.isArray(arr)) mlsRumourCache = arr; + const obj = JSON.parse(cached); + for (const [gid, rumours] of Object.entries(obj)) { + if (Array.isArray(rumours)) groupRumourCache.set(gid, rumours as any[]); + } } const ids = localStorage.getItem(`mls:processedEvents:${pk}`); if (ids) { @@ -40,32 +73,37 @@ function loadLocalCache(userPubkey?: string): void { } catch {} } -/** Save rumour cache and processed event IDs to localStorage (per-user) */ function saveLocalCache(): void { const pk = _cacheUserPubkey; if (!pk) return; try { - localStorage.setItem(`mls:rumours:${pk}`, JSON.stringify(mlsRumourCache)); + const obj: Record<string, any[]> = {}; + for (const [gid, rumours] of groupRumourCache) obj[gid] = rumours; + localStorage.setItem(`mls:groupRumours:${pk}`, JSON.stringify(obj)); localStorage.setItem(`mls:processedEvents:${pk}`, JSON.stringify([...processedEventIds])); } catch {} } -/** Get the nostr group ID (used in h tags) from a MarmotGroup */ +// --------------------------------------------------------------------------- +// Group ID helpers +// --------------------------------------------------------------------------- + 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 +// --------------------------------------------------------------------------- +// Active subscription state +// --------------------------------------------------------------------------- + let _activeSub: { user: any; relays: string[]; @@ -80,14 +118,12 @@ let _activeSub: { // 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> { console.log("[mls] publishKeyPackage to", relays); 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], @@ -98,96 +134,110 @@ export async function fetchKeyPackage(pubkey: string, relays: string[] = DM_RELA 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; +// --------------------------------------------------------------------------- +// Group listing +// --------------------------------------------------------------------------- + +/** Load all groups and return metadata for the chat list. */ +export async function getGroups(user: any): Promise<GroupInfo[]> { + const client = getMarmotClient(user.pubkey); + await client.loadAllGroups(); + + // Ensure caches are loaded + if (!_cacheUserPubkey) loadLocalCache(user.pubkey); + + const infos: GroupInfo[] = []; + for (const g of client.groups) { + registerGroupIds(g); + const groupId = bytesToHex(g.id); + const data = g.groupData; + const members = getGroupMembers(g.state); + const rumours = groupRumourCache.get(groupId) || []; + const last = rumours.length > 0 ? rumours[rumours.length - 1] : null; + + infos.push({ + groupId, + name: data?.name || "", + members, + adminPubkeys: data?.adminPubkeys || [], + relays: data?.relays || g.relays || [], + lastMessage: last?.content, + lastMessageAt: last?.created_at, + }); + } + + // Sort by most recent message + infos.sort((a, b) => (b.lastMessageAt ?? 0) - (a.lastMessageAt ?? 0)); + return infos; +} + +/** Get rumours for a specific group. */ +export function getGroupRumours(groupId: string): any[] { + return groupRumourCache.get(groupId) || []; } // --------------------------------------------------------------------------- -// Starting an MLS conversation (1:1 = 2-person group) +// Starting a conversation (creates a 2-person group) // --------------------------------------------------------------------------- -/** Start an MLS DM with a recipient. Creates group and sends welcome via NIP-59. */ -export async function startMlsConversation( +/** Find an existing DM group with a peer, or create one. Returns the group ID. */ +export async function findOrCreateDmGroup( 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 {} +): Promise<string> { + const client = getMarmotClient(user.pubkey); + await client.loadAllGroups(); + + // Check existing groups for a 2-person group with this peer + for (const g of client.groups) { + const members = getGroupMembers(g.state); + if (members.length === 2 && members.includes(recipientPubkey) && members.includes(user.pubkey)) { + return bytesToHex(g.id); + } } - // Fetch recipient's key package + // Create a new group 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; - console.log("[mls] startConversation with", recipientPubkey.slice(0, 12) + "…", "relays:", groupRelays); - // 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); - const nostrGroupIdHex = getNostrGroupId(group); - console.log("[mls] created group mls:", mlsGroupIdHex.slice(0, 16), "nostr:", nostrGroupIdHex.slice(0, 16)); - storePeerGroupMapping(recipientPubkey, mlsGroupIdHex); - - // Subscribe to this new group's messages using nostr group ID + const groupId = bytesToHex(group.id); subscribeToGroup(getNostrGroupId(group)); - return group; + return groupId; } // --------------------------------------------------------------------------- -// Sending MLS messages +// Sending messages // --------------------------------------------------------------------------- -/** Send a message via MLS. */ -export async function sendMlsMessage( +/** Send a message to a group by MLS group ID. */ +export async function sendGroupMessage( user: any, - recipientPubkey: string, + groupId: 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!); - console.log("[mls] sendChatMessage to group", mlsGroupIdHex!.slice(0, 16)); + const group = await client.getGroup(groupId); await group.sendChatMessage(message); - // Add to local cache as a rumour-like object - const rumour = makeRumour(message, user.pubkey, recipientPubkey); - mlsRumourCache.push(rumour); - saveLocalCache(); + const rumour = makeRumour(message, user.pubkey, groupId); + cacheGroupRumour(groupId, rumour); } // --------------------------------------------------------------------------- -// Dynamic group subscription — subscribe to a single group on existing relays +// Dynamic group subscription // --------------------------------------------------------------------------- -/** 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; @@ -195,7 +245,6 @@ function subscribeToGroup(nostrGroupIdHex: string): void { _activeSub.subscribedGroups.add(nostrGroupIdHex); const { seen, onNewRumour, closers, connectedRelays, user } = _activeSub; const client = getMarmotClient(user.pubkey); - console.log("[mls] subscribeToGroup", nostrGroupIdHex.slice(0, 16), "on", connectedRelays.length, "relays"); for (const relay of connectedRelays) { try { @@ -213,15 +262,13 @@ function subscribeToGroup(nostrGroupIdHex: string): void { 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( +export async function subscribeToMessages( user: any, onNewRumour: (rumour: any) => void, relays: string[] = DM_RELAYS, @@ -233,17 +280,12 @@ export async function subscribeToMlsMessages( 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 }; - // Initialize per-user caches _cacheUserPubkey = user.pubkey; - restorePeerMappings(user.pubkey); loadLocalCache(user.pubkey); - // Load all known groups and register their ID mappings await client.loadAllGroups(); - console.log("[mls] subscribe: loaded", client.groups.length, "groups, relays:", relays); const allRelayUrls = new Set(relays); for (const g of client.groups) { registerGroupIds(g); @@ -257,11 +299,8 @@ export async function subscribeToMlsMessages( try { const { group } = await client.joinGroupFromWelcome({ welcomeRumor: invite }); registerGroupIds(group); - const mlsGroupIdHex = bytesToHex(group.id); - const senderPubkey = invite.pubkey; - console.log("[mls] joined group", mlsGroupIdHex.slice(0, 16), "from", senderPubkey.slice(0, 12)); - storePeerGroupMapping(senderPubkey, mlsGroupIdHex); - // Subscribe to this newly joined group's messages + const groupId = bytesToHex(group.id); + console.log("[mls] joined group", groupId.slice(0, 16), "from", invite.pubkey?.slice(0, 12)); subscribeToGroup(getNostrGroupId(group)); } catch (e) { console.error("[mls] Failed to join from welcome:", e); @@ -273,7 +312,6 @@ export async function subscribeToMlsMessages( 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] }], { @@ -291,7 +329,6 @@ export async function subscribeToMlsMessages( ); 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[] = []; @@ -318,6 +355,11 @@ export async function subscribeToMlsMessages( } } + // Publish key package + publishKeyPackage(user).catch((e) => + console.warn("[mls] Failed to publish key package:", e), + ); + return () => { _activeSub = null; inviteReader.removeAllListeners("newInvite"); @@ -339,10 +381,8 @@ async function handleGroupMessageEvent( 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) { console.warn("[mls] unknown nostr group ID:", nostrGroupIdHex.slice(0, 16)); @@ -356,9 +396,7 @@ async function handleGroupMessageEvent( return null; } - // Use MarmotGroup.ingest to process the event for await (const result of group.ingest([event])) { - console.log("[mls] ingest result:", result.kind, result.result?.kind ?? ""); if (result.kind === "processed" && result.result.kind === "applicationMessage") { const raw = new TextDecoder().decode(result.result.message); let content: string; @@ -366,13 +404,13 @@ async function handleGroupMessageEvent( try { const rumor = JSON.parse(raw); content = rumor.content ?? raw; - senderPubkey = rumor.pubkey ?? (groupPeerMap.get(mlsGroupIdHex) || event.pubkey); + senderPubkey = rumor.pubkey ?? event.pubkey; } catch { content = raw; - senderPubkey = groupPeerMap.get(mlsGroupIdHex) || event.pubkey; + senderPubkey = event.pubkey; } - // Skip self-echo — we already cached our own messages locally at send time + // Skip self-echo if (senderPubkey === user.pubkey) { processedEventIds.add(event.id); saveLocalCache(); @@ -380,12 +418,8 @@ async function handleGroupMessageEvent( return null; } - const recipientPubkey = user.pubkey; - - const rumour = makeRumour(content, senderPubkey, recipientPubkey); - rumour._mls = true; - rumour._groupId = mlsGroupIdHex; - mlsRumourCache.push(rumour); + const rumour = makeRumour(content, senderPubkey, mlsGroupIdHex); + cacheGroupRumour(mlsGroupIdHex, rumour); processedEventIds.add(event.id); saveLocalCache(); @@ -408,98 +442,57 @@ async function handleGroupMessageEvent( // 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 (per-user) - if (mlsRumourCache.length === 0) loadLocalCache(user.pubkey); - if (mlsRumourCache.length > 0) return mlsRumourCache; - +export async function fetchGroupHistory(user: any, relays: string[] = DM_RELAYS): Promise<void> { const client = getMarmotClient(user.pubkey); await client.loadAllGroups(); const groups = client.groups; - if (groups.length === 0) return []; + 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); + await handleGroupMessageEvent(event as NostrEvent, user, client); } - return mlsRumourCache; -} - -// --------------------------------------------------------------------------- -// Peer <-> Group mapping (localStorage) -// --------------------------------------------------------------------------- - -function storePeerGroupMapping(peerPubkey: string, mlsGroupId: string): void { - peerGroupMap.set(peerPubkey, mlsGroupId); - groupPeerMap.set(mlsGroupId, peerPubkey); - const pk = _cacheUserPubkey; - if (!pk) return; - try { - const key = `mls:peer2group:${pk}`; - const map = JSON.parse(localStorage.getItem(key) || "{}"); - map[peerPubkey] = mlsGroupId; - localStorage.setItem(key, JSON.stringify(map)); - - const rkey = `mls:group2peer:${pk}`; - const rmap = JSON.parse(localStorage.getItem(rkey) || "{}"); - rmap[mlsGroupId] = peerPubkey; - localStorage.setItem(rkey, JSON.stringify(rmap)); - } catch {} -} - -// Restore peer mappings from localStorage (per-user, called when user is known) -function restorePeerMappings(userPubkey: string): void { - try { - const map = JSON.parse(localStorage.getItem(`mls:peer2group:${userPubkey}`) || "{}"); - for (const [k, v] of Object.entries(map)) { - peerGroupMap.set(k, v as string); - } - const rmap = JSON.parse(localStorage.getItem(`mls:group2peer:${userPubkey}`) || "{}"); - for (const [k, v] of Object.entries(rmap)) { - groupPeerMap.set(k, v as string); - } - } catch {} } // --------------------------------------------------------------------------- // Helpers // --------------------------------------------------------------------------- -/** Create a rumour-like object compatible with the existing chat UI. */ -function makeRumour(content: string, senderPubkey: string, recipientPubkey: string): any { +function makeRumour(content: string, senderPubkey: string, groupId: 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, + tags: [], + _groupId: groupId, }; } + +function cacheGroupRumour(groupId: string, rumour: any): void { + const existing = groupRumourCache.get(groupId) || []; + existing.push(rumour); + groupRumourCache.set(groupId, existing); + saveLocalCache(); +} diff --git a/src/lib/nip17.ts b/src/lib/nip17.ts index 62e1a8b3..c6021339 100644 --- a/src/lib/nip17.ts +++ b/src/lib/nip17.ts @@ -402,6 +402,17 @@ export const subscribeToMessages = async ( return () => closers.forEach((close) => close()); }; +// Lightweight count of new gift wraps since a timestamp (no decryption needed). +export const countNewGiftWraps = async (pubkey: string, since: number): Promise<number> => { + const events = await pool.querySync(DM_RELAYS_LIST, { + kinds: [1059], + "#p": [pubkey], + since, + limit: 100, + }); + return events.length; +}; + // Sends a NIP-17 message from `user` to `recipient`. // If expiryDays is set, messages will expire after that many days. export const send = async (message: string, user: any, recipient: any, expiryDays?: number) => { diff --git a/src/lib/store.ts b/src/lib/store.ts index 13aa8bd8..dcde172e 100644 --- a/src/lib/store.ts +++ b/src/lib/store.ts @@ -106,3 +106,4 @@ export const cachedAccounts = persistLocal("cachedAccounts", []); export const cachedPayments = persistLocal("cachedPayments", []); export const cachedRates = persistLocal("cachedRates"); export const offline = writable(false); +export const unreadDMs = writable(0); |
