aboutsummaryrefslogtreecommitdiffstats
path: root/src/lib
diff options
context:
space:
mode:
authorAdam Soltys <[email protected]>2026-03-06 08:39:18 -0800
committerAdam Soltys <[email protected]>2026-03-06 08:39:18 -0800
commit7e45e1b48c382f29c2d3bd114a4751a0f6b89d51 (patch)
tree7cbfc937afdcccab812d7f97c8f85b681c0d4687 /src/lib
parent3b18232d52957dbd1a5a0c12891020d2a6e3295e (diff)
downloadcoinos-ui-7e45e1b48c382f29c2d3bd114a4751a0f6b89d51.tar.gz
coinos-ui-7e45e1b48c382f29c2d3bd114a4751a0f6b89d51.zip
group chats
Diffstat (limited to 'src/lib')
-rw-r--r--src/lib/messaging.ts153
-rw-r--r--src/lib/mls-relay.ts303
-rw-r--r--src/lib/nip17.ts11
-rw-r--r--src/lib/store.ts1
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);