aboutsummaryrefslogtreecommitdiffstats
path: root/src
diff options
context:
space:
mode:
authorAdam Soltys <[email protected]>2026-03-06 17:14:31 -0800
committerAdam Soltys <[email protected]>2026-03-06 17:14:31 -0800
commit07c2d1afc6d8512fda97ce5c74c44dade1ddb218 (patch)
treef1c0d52c809896decad58960195374e64050676e /src
parentf84e49bdfefa674b6b6a56ea5ba2bd467326e87e (diff)
downloadcoinos-ui-07c2d1afc6d8512fda97ce5c74c44dade1ddb218.tar.gz
coinos-ui-07c2d1afc6d8512fda97ce5c74c44dade1ddb218.zip
MLS user search on /messages
Co-Authored-By: Claude Opus 4.6 <[email protected]>
Diffstat (limited to 'src')
-rw-r--r--src/lib/messaging.ts2
-rw-r--r--src/lib/mls-relay.ts222
-rw-r--r--src/locales/en.json2
-rw-r--r--src/routes/(app)/messages/+page.svelte57
4 files changed, 272 insertions, 11 deletions
diff --git a/src/lib/messaging.ts b/src/lib/messaging.ts
index 1eb59b9c..becc1714 100644
--- a/src/lib/messaging.ts
+++ b/src/lib/messaging.ts
@@ -7,6 +7,8 @@
export {
publishKeyPackage,
fetchKeyPackage,
+ searchMlsUsers,
+ preloadMlsUsers,
getGroups,
getGroupRumours,
sendGroupMessage,
diff --git a/src/lib/mls-relay.ts b/src/lib/mls-relay.ts
index 82091ce6..4a3c06f3 100644
--- a/src/lib/mls-relay.ts
+++ b/src/lib/mls-relay.ts
@@ -5,6 +5,7 @@
import type { NostrEvent } from "nostr-tools/pure";
import { bytesToHex } from "@noble/hashes/utils.js";
+import { nip19 } from "nostr-tools";
import { SimplePool } from "nostr-tools/pool";
import { Relay } from "nostr-tools/relay";
import { getMarmotClient, getInviteReader, DM_RELAYS } from "$lib/marmot";
@@ -144,6 +145,227 @@ async function getInboxRelays(pubkey: string): Promise<string[]> {
return [];
}
+// ---------------------------------------------------------------------------
+// MLS user directory — IndexedDB-cached profiles of kind 443 authors
+// ---------------------------------------------------------------------------
+
+interface MlsUserProfile {
+ pubkey: string;
+ name?: string;
+ picture?: string;
+ nip05?: string;
+}
+
+const MLS_DB_NAME = "coinos-mls-directory";
+const MLS_DB_VERSION = 1;
+const MLS_STORE = "profiles";
+let _mlsDb: IDBDatabase | null = null;
+let _mlsLoading: Promise<void> | null = null;
+
+function openMlsDb(): Promise<IDBDatabase> {
+ if (_mlsDb) return Promise.resolve(_mlsDb);
+ return new Promise((resolve, reject) => {
+ const req = indexedDB.open(MLS_DB_NAME, MLS_DB_VERSION);
+ req.onupgradeneeded = () => {
+ const db = req.result;
+ if (!db.objectStoreNames.contains(MLS_STORE)) {
+ const store = db.createObjectStore(MLS_STORE, { keyPath: "pubkey" });
+ store.createIndex("name", "name");
+ }
+ };
+ req.onsuccess = () => { _mlsDb = req.result; resolve(_mlsDb); };
+ req.onerror = () => reject(req.error);
+ });
+}
+
+const MAX_DB_BYTES = 1_000_000; // 1 MB cap
+
+async function getMeta(db: IDBDatabase): Promise<any> {
+ return new Promise((resolve) => {
+ const tx = db.transaction(MLS_STORE, "readonly");
+ const req = tx.objectStore(MLS_STORE).get("__meta__");
+ req.onsuccess = () => resolve(req.result || {});
+ req.onerror = () => resolve({});
+ });
+}
+
+async function putProfiles(db: IDBDatabase, profiles: MlsUserProfile[], meta: any): Promise<void> {
+ const tx = db.transaction(MLS_STORE, "readwrite");
+ const store = tx.objectStore(MLS_STORE);
+ for (const p of profiles) store.put(p);
+ store.put({ pubkey: "__meta__", ...meta });
+ await new Promise<void>((resolve) => { tx.oncomplete = () => resolve(); });
+}
+
+async function enforceSize(db: IDBDatabase): Promise<void> {
+ const all: MlsUserProfile[] = await new Promise((resolve) => {
+ const tx = db.transaction(MLS_STORE, "readonly");
+ const req = tx.objectStore(MLS_STORE).getAll();
+ req.onsuccess = () => resolve(req.result);
+ req.onerror = () => resolve([]);
+ });
+
+ const size = new Blob([JSON.stringify(all)]).size;
+ if (size <= MAX_DB_BYTES) return;
+
+ const entries = all.filter((u) => u.pubkey !== "__meta__");
+ entries.sort((a, b) => (a.name || "").length - (b.name || "").length);
+
+ const tx = db.transaction(MLS_STORE, "readwrite");
+ const store = tx.objectStore(MLS_STORE);
+ let currentSize = size;
+ for (const entry of entries) {
+ if (currentSize <= MAX_DB_BYTES) break;
+ currentSize -= new Blob([JSON.stringify(entry)]).size;
+ store.delete(entry.pubkey);
+ }
+ await new Promise<void>((resolve) => { tx.oncomplete = () => resolve(); });
+}
+
+async function resolveProfiles(authors: string[], knownPubkeys: Set<string>): Promise<MlsUserProfile[]> {
+ const newAuthors = authors.filter((pk) => !knownPubkeys.has(pk));
+ if (newAuthors.length === 0) return [];
+
+ const profiles: MlsUserProfile[] = [];
+ for (let i = 0; i < newAuthors.length; i += 50) {
+ const chunk = newAuthors.slice(i, i + 50);
+ const events = await pool.querySync(DISCOVERY_RELAYS, { kinds: [0], authors: chunk, limit: 50 });
+ for (const e of events) {
+ try {
+ const p = JSON.parse(e.content);
+ profiles.push({
+ pubkey: e.pubkey,
+ name: p.display_name || p.name || undefined,
+ picture: p.picture || undefined,
+ nip05: p.nip05 || undefined,
+ });
+ } catch {}
+ }
+ }
+ return profiles;
+}
+
+/** Preload MLS user profiles into IndexedDB. Background refresh every hour. */
+export function preloadMlsUsers(): Promise<void> {
+ if (_mlsLoading) return _mlsLoading;
+ _mlsLoading = (async () => {
+ const db = await openMlsDb();
+ const meta = await getMeta(db);
+
+ // Skip if refreshed within the last hour
+ if (meta.lastFetch && Date.now() - meta.lastFetch < 60 * 60 * 1000) return;
+
+ // Get existing pubkeys so we can merge
+ const existing: MlsUserProfile[] = await new Promise((resolve) => {
+ const tx = db.transaction(MLS_STORE, "readonly");
+ const req = tx.objectStore(MLS_STORE).getAll();
+ req.onsuccess = () => resolve(req.result);
+ req.onerror = () => resolve([]);
+ });
+ const knownPubkeys = new Set(existing.filter((u) => u.pubkey !== "__meta__").map((u) => u.pubkey));
+
+ // First pass — get immediate results for search
+ const events = await pool.querySync(DISCOVERY_RELAYS, { kinds: [443], limit: 500 });
+ for (const e of events) knownPubkeys.add(e.pubkey); // track for dedup
+ const firstAuthors = [...new Set(events.map((e) => e.pubkey))];
+ const profiles = await resolveProfiles(firstAuthors, new Set(existing.filter((u) => u.pubkey !== "__meta__").map((u) => u.pubkey)));
+ const oldest = events.length ? Math.min(...events.map((e) => e.created_at)) : 0;
+ await putProfiles(db, profiles, { pubkey: "__meta__", lastFetch: Date.now() });
+
+ // Background: paginate back 3 weeks to find all MLS users
+ const threeWeeksAgo = Math.floor(Date.now() / 1000) - 21 * 24 * 60 * 60;
+ if (oldest > threeWeeksAgo) {
+ backgroundPaginate(db, oldest, threeWeeksAgo, knownPubkeys);
+ }
+ })();
+ return _mlsLoading;
+}
+
+async function backgroundPaginate(db: IDBDatabase, until: number, earliest: number, knownPubkeys: Set<string>): Promise<void> {
+ let cursor = until;
+ while (cursor > earliest) {
+ try {
+ const events = await pool.querySync(DISCOVERY_RELAYS, { kinds: [443], limit: 500, until: cursor });
+ if (events.length === 0) break;
+ const newAuthors = [...new Set(events.map((e) => e.pubkey))].filter((pk) => !knownPubkeys.has(pk));
+ if (newAuthors.length > 0) {
+ const profiles = await resolveProfiles(newAuthors, knownPubkeys);
+ for (const pk of newAuthors) knownPubkeys.add(pk);
+ if (profiles.length) {
+ await putProfiles(db, profiles, { pubkey: "__meta__", lastFetch: Date.now() });
+ await enforceSize(db);
+ }
+ }
+ cursor = Math.min(...events.map((e) => e.created_at));
+ } catch {
+ break;
+ }
+ }
+}
+
+/** Resolve a hex pubkey to an MLS profile if they have a key package. */
+async function lookupMlsUser(pubkey: string): Promise<MlsUserProfile | null> {
+ const kp = await fetchKeyPackage(pubkey);
+ if (!kp) return null;
+ const profiles = await pool.querySync(DISCOVERY_RELAYS, { kinds: [0], authors: [pubkey], limit: 1 });
+ let name: string | undefined, picture: string | undefined, nip05Val: string | undefined;
+ if (profiles.length) {
+ try {
+ const p = JSON.parse(profiles[0].content);
+ name = p.display_name || p.name;
+ picture = p.picture;
+ nip05Val = p.nip05;
+ } catch {}
+ }
+ const profile: MlsUserProfile = { pubkey, name, picture, nip05: nip05Val };
+
+ // Cache in IndexedDB for future searches
+ try {
+ const db = await openMlsDb();
+ const tx = db.transaction(MLS_STORE, "readwrite");
+ tx.objectStore(MLS_STORE).put(profile);
+ } catch {}
+
+ return profile;
+}
+
+/** Search MLS users from local IndexedDB. Falls back to relay lookup for npub/pubkey. */
+export async function searchMlsUsers(query: string): Promise<MlsUserProfile[]> {
+ if (!query.trim()) return [];
+ const q = query.trim().toLowerCase();
+
+ // If query looks like an npub or hex pubkey, do a direct lookup
+ let directPubkey: string | null = null;
+ if (q.startsWith("npub1")) {
+ try { directPubkey = nip19.decode(q).data as string; } catch {}
+ } else if (/^[0-9a-f]{64}$/.test(q)) {
+ directPubkey = q;
+ }
+
+ const db = await openMlsDb();
+ const all: MlsUserProfile[] = await new Promise((resolve, reject) => {
+ const tx = db.transaction(MLS_STORE, "readonly");
+ const req = tx.objectStore(MLS_STORE).getAll();
+ req.onsuccess = () => resolve(req.result);
+ req.onerror = () => reject(req.error);
+ });
+
+ const results = all.filter((u) => {
+ if (u.pubkey === "__meta__") return false;
+ const name = (u.name || "").toLowerCase();
+ const nip05 = (u.nip05 || "").toLowerCase();
+ return name.includes(q) || nip05.includes(q) || u.pubkey.startsWith(q);
+ }).slice(0, 20);
+
+ // If no local results and we have a direct pubkey, check relays
+ if (results.length === 0 && directPubkey) {
+ const profile = await lookupMlsUser(directPubkey);
+ if (profile) return [profile];
+ }
+
+ return results;
+}
+
export async function fetchKeyPackage(pubkey: string, relays: string[] = DM_RELAYS): Promise<NostrEvent | null> {
const inboxRelays = await getInboxRelays(pubkey);
const allRelays = [...new Set([...inboxRelays, ...relays, ...DISCOVERY_RELAYS])];
diff --git a/src/locales/en.json b/src/locales/en.json
index c6e89bfd..c5cfe594 100644
--- a/src/locales/en.json
+++ b/src/locales/en.json
@@ -196,6 +196,8 @@
"findByNIP05": "Find by NIP-05",
"findByPubkey": "Find by pubkey",
"noChats": "You don't have any chats yet.",
+ "searchMls": "Search MLS users...",
+ "noResults": "No MLS users found.",
"selectChat": "Select a chat from the list to the left.",
"recipientMsg": "You are sending nostr messages to [R].",
"sendMessage": "Send Message",
diff --git a/src/routes/(app)/messages/+page.svelte b/src/routes/(app)/messages/+page.svelte
index f9f59bb1..1981b5f1 100644
--- a/src/routes/(app)/messages/+page.svelte
+++ b/src/routes/(app)/messages/+page.svelte
@@ -3,12 +3,14 @@
import {
getGroups, subscribeToMessages, fetchGroupHistory,
- resolveUser, displayName,
+ searchMlsUsers, preloadMlsUsers, resolveUser, displayName,
} from "$lib/messaging";
import type { GroupInfo } from "$lib/messaging";
import { t } from "$lib/translations";
import Avatar from "$comp/Avatar.svelte";
+ const focus = (el: HTMLElement) => el.focus();
+
let { data } = $props();
const user = $derived(data.user);
@@ -22,6 +24,15 @@
let loaded = $state(false);
let memberInfos = $state(new Map<string, any>());
+ // Search state
+ let query = $state("");
+ let searchResults: any[] = $state([]);
+
+ const onSearchInput = async () => {
+ if (!query.trim()) { searchResults = []; return; }
+ searchResults = (await searchMlsUsers(query)).filter((u) => u.pubkey !== user.pubkey);
+ };
+
const otherMembers = (g: GroupInfo) => g.members.filter((pk) => pk !== user.pubkey);
const groupDisplayName = (g: GroupInfo): string => {
@@ -50,30 +61,54 @@
}
};
- const loadGroups = async () => {
- await fetchGroupHistory(user);
+ const refreshGroups = async () => {
const allGroups = await getGroups(user);
groups = allGroups.filter((g) => g.members.includes(user.pubkey));
resolveMembers(groups);
- loaded = true;
};
let closeSub: (() => void) | undefined;
onMount(() => {
- loadGroups();
- subscribeToMessages(user, async () => {
- const allGroups = await getGroups(user);
- groups = allGroups.filter((g) => g.members.includes(user.pubkey));
- resolveMembers(groups);
- }).then((close) => (closeSub = close));
+ // Show cached groups immediately, then fetch history in background
+ refreshGroups().then(() => { loaded = true; });
+ fetchGroupHistory(user).then(refreshGroups);
+ preloadMlsUsers();
+ subscribeToMessages(user, refreshGroups).then((close) => (closeSub = close));
});
onDestroy(() => closeSub?.());
</script>
<div class="container mx-auto max-w-xl px-4 space-y-1">
- {#if !loaded}
+ <input
+ use:focus
+ type="text"
+ bind:value={query}
+ oninput={onSearchInput}
+ placeholder={$t("dm.searchMls")}
+ class="w-full px-4 py-2 rounded-full border border-base-300 bg-base-100 text-base-content focus:outline-none focus:border-base-content/30 mb-2"
+ />
+
+ {#if query.trim()}
+ {#if searchResults.length === 0}
+ <p class="text-center text-secondary py-4">{$t("dm.noResults")}</p>
+ {:else}
+ {#each searchResults as u}
+ <a href="/messages/{u.pubkey}" class="flex items-center gap-3 p-3 rounded-2xl hover:bg-base-200">
+ <div class="shrink-0">
+ <Avatar user={{ pubkey: u.pubkey, username: u.name, picture: u.picture }} size={12} disabled />
+ </div>
+ <div class="min-w-0 flex-1">
+ <div class="font-semibold text-lg truncate">{u.name || u.pubkey.slice(0, 12) + "…"}</div>
+ {#if u.nip05}
+ <div class="text-secondary text-sm truncate">{u.nip05}</div>
+ {/if}
+ </div>
+ </a>
+ {/each}
+ {/if}
+ {:else if !loaded}
<div class="flex justify-center py-8"><div class="loading loading-spinner"></div></div>
{:else if groups.length === 0}
<p class="text-center text-secondary">{$t("dm.noChats")}</p>