diff options
| author | Adam Soltys <[email protected]> | 2026-03-06 10:06:53 -0800 |
|---|---|---|
| committer | Adam Soltys <[email protected]> | 2026-03-06 10:06:53 -0800 |
| commit | 47316e28150953909c71adfd1e9100c4e08024b1 (patch) | |
| tree | 5f383db13a8145fac312b53199d01174386aed61 /src | |
| parent | 7e45e1b48c382f29c2d3bd114a4751a0f6b89d51 (diff) | |
| download | coinos-ui-47316e28150953909c71adfd1e9100c4e08024b1.tar.gz coinos-ui-47316e28150953909c71adfd1e9100c4e08024b1.zip | |
chat fixes
Diffstat (limited to 'src')
| -rw-r--r-- | src/lib/marmot.ts | 37 | ||||
| -rw-r--r-- | src/lib/messaging.ts | 7 | ||||
| -rw-r--r-- | src/lib/mls-relay.ts | 66 | ||||
| -rw-r--r-- | src/routes/(app)/messages/+page.svelte | 55 | ||||
| -rw-r--r-- | src/routes/(app)/messages/[id]/+page.svelte | 155 |
5 files changed, 221 insertions, 99 deletions
diff --git a/src/lib/marmot.ts b/src/lib/marmot.ts index ed2143c3..2eecbdb1 100644 --- a/src/lib/marmot.ts +++ b/src/lib/marmot.ts @@ -164,31 +164,30 @@ class CoinosNostrNetwork implements NostrNetworkInterface { // IndexedDB-backed KeyValueStoreBackend // --------------------------------------------------------------------------- -const IDB_NAME = "coinos-marmot"; const IDB_VERSION = 1; +const STORE_NAMES = ["group-state", "key-packages", "invites-received", "invites-unread", "invites-seen"]; + +let _idbPubkey: string | null = null; +let _idbCache: IDBDatabase | null = null; + +function openMarmotDb(pubkey?: string): Promise<IDBDatabase> { + const pk = pubkey || _idbPubkey || "default"; + if (_idbCache && _idbPubkey === pk) return Promise.resolve(_idbCache); -function openMarmotDb(): Promise<IDBDatabase> { return new Promise((resolve, reject) => { - const req = indexedDB.open(IDB_NAME, IDB_VERSION); + const dbName = `coinos-marmot-${pk.slice(0, 16)}`; + const req = indexedDB.open(dbName, 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"); + for (const name of STORE_NAMES) { + if (!db.objectStoreNames.contains(name)) db.createObjectStore(name); } }; - req.onsuccess = () => resolve(req.result); + req.onsuccess = () => { + _idbPubkey = pk; + _idbCache = req.result; + resolve(req.result); + }; req.onerror = () => reject(req.error); }); } @@ -316,6 +315,8 @@ export function getMarmotClient(pubkey: string): MarmotClient { _client = null; _inviteReader = null; _currentPubkey = pubkey; + _idbPubkey = pubkey; + _idbCache = null; const signer = new CoinosEventSigner(pubkey); const groupStateBackend = new IDBGroupStateBackend(); diff --git a/src/lib/messaging.ts b/src/lib/messaging.ts index 37a4c22e..ae91709c 100644 --- a/src/lib/messaging.ts +++ b/src/lib/messaging.ts @@ -68,10 +68,5 @@ export async function resolveUser(pubkey: string): Promise<any> { /** Get a display name for a user info object. */ export function displayName(info: any): string { - return info?.coinosUsername || info?.nostrName || shortPubkey(info?.pubkey || ""); -} - -function shortPubkey(pk: string): string { - if (pk.length <= 16) return pk; - return pk.slice(0, 8) + "…" + pk.slice(-4); + return info?.coinosUsername || info?.nostrName || "Anonymous"; } diff --git a/src/lib/mls-relay.ts b/src/lib/mls-relay.ts index 6a280a3e..c8ac2241 100644 --- a/src/lib/mls-relay.ts +++ b/src/lib/mls-relay.ts @@ -11,22 +11,19 @@ import { getMarmotClient, getInviteReader, DM_RELAYS } from "$lib/marmot"; const pool = new SimplePool(); -/** Extract member pubkeys from an MLS group's ratchet tree. */ -function getGroupMembers(group: any): string[] { - const state = group.state; +/** Extract member pubkeys from an MLS group's ratchet tree (pass group.state). */ +function getGroupMembers(state: any): string[] { 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); - } - } + if (!node?.leaf?.credential?.identity) continue; + const identity = node.leaf.credential.identity; + const hex = identity instanceof Uint8Array + ? bytesToHex(identity) + : typeof identity === "string" ? identity + : bytesToHex(new Uint8Array(identity)); + // identity may be 32 raw bytes (-> 64 hex) or already 64 hex chars + if (hex.length === 64) pubkeys.add(hex); } return Array.from(pubkeys); } @@ -307,6 +304,30 @@ export async function subscribeToMessages( } }); + // Fetch historical welcomes so we join groups created while we were offline + try { + const historicalWraps = await pool.querySync(relays, { + kinds: [1059], + "#p": [user.pubkey], + limit: 100, + }); + for (const event of historicalWraps) { + try { + const ingested = await inviteReader.ingestEvent(event as NostrEvent); + if (ingested) await inviteReader.decryptGiftWraps(); + } catch {} + } + // Re-load groups after processing welcomes + await client.loadAllGroups(); + for (const g of client.groups) { + registerGroupIds(g); + const r = g.relays; + if (r) r.forEach((url: string) => allRelayUrls.add(url)); + } + } catch (e) { + console.error("[mls] Failed to fetch historical welcomes:", e); + } + for (const url of allRelayUrls) { try { const relay = await Relay.connect(url); @@ -444,6 +465,25 @@ async function handleGroupMessageEvent( export async function fetchGroupHistory(user: any, relays: string[] = DM_RELAYS): Promise<void> { const client = getMarmotClient(user.pubkey); + const inviteReader = getInviteReader(user.pubkey); + + // Process any pending welcomes first so we know about all our groups + try { + const wraps = await pool.querySync(relays, { + kinds: [1059], + "#p": [user.pubkey], + limit: 100, + }); + for (const event of wraps) { + try { + const ingested = await inviteReader.ingestEvent(event as NostrEvent); + if (ingested) await inviteReader.decryptGiftWraps(); + } catch {} + } + } catch (e) { + console.error("[mls] Failed to fetch welcomes in history:", e); + } + await client.loadAllGroups(); const groups = client.groups; diff --git a/src/routes/(app)/messages/+page.svelte b/src/routes/(app)/messages/+page.svelte index c24a20c8..f9f59bb1 100644 --- a/src/routes/(app)/messages/+page.svelte +++ b/src/routes/(app)/messages/+page.svelte @@ -7,6 +7,7 @@ } from "$lib/messaging"; import type { GroupInfo } from "$lib/messaging"; import { t } from "$lib/translations"; + import Avatar from "$comp/Avatar.svelte"; let { data } = $props(); const user = $derived(data.user); @@ -19,40 +20,41 @@ let groups: GroupInfo[] = $state([]); let loaded = $state(false); - let memberNames = $state(new Map<string, Map<string, string>>()); + let memberInfos = $state(new Map<string, any>()); + + const otherMembers = (g: GroupInfo) => g.members.filter((pk) => pk !== user.pubkey); const groupDisplayName = (g: GroupInfo): string => { if (g.name) return g.name; - const names = memberNames.get(g.groupId); - if (!names) return "…"; - const otherNames = g.members - .filter((pk) => pk !== user.pubkey) - .map((pk) => names.get(pk) || shortPk(pk)); - return otherNames.join(", ") || "Empty group"; + const names = otherMembers(g) + .map((pk) => { const i = memberInfos.get(pk); return i ? displayName(i) : "…"; }); + return names.join(", ") || "…"; }; - const shortPk = (pk: string) => pk.slice(0, 8) + "…"; + const peerUser = (g: GroupInfo): any | null => { + if (g.members.length !== 2) return null; + const peer = otherMembers(g)[0]; + if (!peer) return null; + const info = memberInfos.get(peer); + return { pubkey: peer, username: info?.coinosUsername, picture: info?.picture }; + }; - const resolveMemberNames = async (groups: GroupInfo[]) => { - const updated = new Map(memberNames); + const resolveMembers = async (groups: GroupInfo[]) => { for (const g of groups) { - if (!updated.has(g.groupId)) updated.set(g.groupId, new Map()); - const gMap = updated.get(g.groupId)!; for (const pk of g.members) { - if (pk === user.pubkey || gMap.has(pk)) continue; + if (pk === user.pubkey || memberInfos.has(pk)) continue; resolveUser(pk).then((info) => { - gMap.set(pk, displayName(info)); - memberNames = new Map(updated); + memberInfos = new Map(memberInfos).set(pk, info); }); } } - memberNames = updated; }; const loadGroups = async () => { await fetchGroupHistory(user); - groups = await getGroups(user); - resolveMemberNames(groups); + const allGroups = await getGroups(user); + groups = allGroups.filter((g) => g.members.includes(user.pubkey)); + resolveMembers(groups); loaded = true; }; @@ -61,8 +63,9 @@ onMount(() => { loadGroups(); subscribeToMessages(user, async () => { - groups = await getGroups(user); - resolveMemberNames(groups); + const allGroups = await getGroups(user); + groups = allGroups.filter((g) => g.members.includes(user.pubkey)); + resolveMembers(groups); }).then((close) => (closeSub = close)); }); @@ -77,9 +80,15 @@ {:else} {#each groups as g} <a href="/messages/{g.groupId}" class="flex items-center gap-3 p-3 rounded-2xl hover:bg-base-200"> - <div class="w-12 h-12 rounded-full bg-base-300 shrink-0 flex items-center justify-center text-lg font-bold text-base-content/40"> - {g.members.length} - </div> + {#if peerUser(g)} + <div class="shrink-0"> + <Avatar user={peerUser(g)} size={12} disabled /> + </div> + {:else} + <div class="w-12 h-12 rounded-full bg-base-300 shrink-0 flex items-center justify-center text-lg font-bold text-base-content/40"> + {g.members.length} + </div> + {/if} <div class="min-w-0 flex-1"> <div class="font-semibold text-lg truncate">{groupDisplayName(g)}</div> {#if g.lastMessage} diff --git a/src/routes/(app)/messages/[id]/+page.svelte b/src/routes/(app)/messages/[id]/+page.svelte index d7520c5d..c8ba8051 100644 --- a/src/routes/(app)/messages/[id]/+page.svelte +++ b/src/routes/(app)/messages/[id]/+page.svelte @@ -14,6 +14,7 @@ import { t } from "$lib/translations"; import { theme } from "$lib/store"; import ChatMessage from "$comp/ChatMessage.svelte"; + import Avatar from "$comp/Avatar.svelte"; const focus = (el: HTMLElement) => el.focus(); @@ -48,12 +49,22 @@ const groupDisplayName = (g: GroupInfo | undefined): string => { if (!g) return "…"; if (g.name) return g.name; - const otherMembers = g.members.filter((pk) => pk !== user.pubkey); - const names = otherMembers.map((pk) => { + // Use group members, or fall back to unique sender pubkeys from messages + let otherPks = g.members.filter((pk) => pk !== user.pubkey); + if (otherPks.length === 0) { + const senderPks = new Set<string>(); + for (const [, dayRumours] of messageRumours) { + for (const r of dayRumours) { + if (r.pubkey && r.pubkey !== user.pubkey) senderPks.add(r.pubkey); + } + } + otherPks = Array.from(senderPks); + } + const names = otherPks.map((pk) => { const info = senderInfos.get(pk); - return info ? displayName(info) : pk.slice(0, 8) + "…"; + return info ? displayName(info) : "…"; }); - return names.join(", ") || "Group"; + return names.join(", ") || "…"; }; const dateMap = (rumours: any[]): Map<number, any[]> => { @@ -69,6 +80,22 @@ return m; }; + type SenderRun = { pubkey: string; messages: any[] }; + + /** Group a flat list of rumours into consecutive sender runs. */ + const senderRuns = (rumours: any[]): SenderRun[] => { + const runs: SenderRun[] = []; + for (const r of rumours) { + const last = runs[runs.length - 1]; + if (last && last.pubkey === r.pubkey) { + last.messages.push(r); + } else { + runs.push({ pubkey: r.pubkey, messages: [r] }); + } + } + return runs; + }; + const updateMessages = () => { const rumours = getGroupRumours(groupId); messageRumours = dateMap(rumours); @@ -101,15 +128,22 @@ groupInfo = found; updateMessages(); + // Resolve names for group members AND rumour senders + const pubkeysToResolve = new Set<string>(); if (groupInfo) { - for (const pk of groupInfo.members) { - if (!senderInfos.has(pk)) { - resolveUser(pk).then((info) => { - senderInfos = new Map(senderInfos).set(pk, info); - }); - } + for (const pk of groupInfo.members) pubkeysToResolve.add(pk); + } + for (const [, dayRumours] of messageRumours) { + for (const r of dayRumours) { + if (r.pubkey) pubkeysToResolve.add(r.pubkey); } } + for (const pk of pubkeysToResolve) { + if (pk === user.pubkey || senderInfos.has(pk)) continue; + resolveUser(pk).then((info) => { + senderInfos = new Map(senderInfos).set(pk, info); + }); + } await tick(); scrollChat(); @@ -118,7 +152,13 @@ const senderName = (pubkey: string): string => { if (pubkey === user.pubkey) return user.username || "You"; const info = senderInfos.get(pubkey); - return info ? displayName(info) : pubkey.slice(0, 8) + "…"; + return info ? displayName(info) : "…"; + }; + + const senderUser = (pubkey: string): any => { + if (pubkey === user.pubkey) return user; + const info = senderInfos.get(pubkey); + return { pubkey, username: info?.coinosUsername, picture: info?.picture }; }; const TIME = new Intl.DateTimeFormat(undefined, { @@ -229,21 +269,25 @@ <p class="date-header secondary">{formatDate(new Date(day * 86400 * 1000))}</p> {@const dayMessages = messageRumours.get(day) ?? []} <ul> - {#each dayMessages as rumour, i (rumour.id)} - {@const showName = i === 0 || dayMessages[i - 1]?.pubkey !== rumour.pubkey} - <li class="message-row"> - <div class="message-content"> - {#if showName} - <div class="sender-name">{senderName(rumour.pubkey)}</div> - {/if} - <div - class={($theme === "light" ? "light-message " : "dark-message ") + "message"} - > - <ChatMessage content={rumour.content} tags={rumour.tags} /> - <span class="timestamp secondary text-xs"> - {TIME.format(new Date(rumour.created_at * 1000))} - </span> - </div> + {#each senderRuns(dayMessages) as run} + <li class="sender-run"> + <div class="run-avatar"> + <Avatar user={senderUser(run.pubkey)} size={16} disabled /> + </div> + <div class="run-messages"> + {#each run.messages as rumour, j (rumour.id)} + <div + class={(j === run.messages.length - 1 ? "bubble-tail " : "") + (j === 0 ? "bubble-first " : "") + ($theme === "light" ? "light-message " : "dark-message ") + "message"} + > + {#if j === 0} + <div class="sender-name">{senderName(run.pubkey)}</div> + {/if} + <ChatMessage content={rumour.content} tags={rumour.tags} /> + <span class="timestamp secondary text-xs"> + {TIME.format(new Date(rumour.created_at * 1000))} + </span> + </div> + {/each} </div> </li> {/each} @@ -314,24 +358,34 @@ color: #7f7f7f; } - .message-row { + .sender-run { display: flex; align-items: flex-end; gap: 0.5rem; - margin: 1px 0; + margin-top: 0.5rem; } - .message-content { - max-width: 85%; + .run-avatar { + flex-shrink: 0; + display: flex; + flex-direction: column; + align-items: center; + gap: 2px; + margin-bottom: -6px; + } + + .run-messages { + max-width: calc(85% - 3rem); + display: flex; + flex-direction: column; + gap: 5px; } .sender-name { font-size: 0.75rem; - font-weight: 600; - color: #7f7f7f; - margin-top: 0.5rem; + font-weight: 700; + color: var(--accent, #6366f1); margin-bottom: 1px; - margin-left: 0.75rem; } .date-header { @@ -379,20 +433,43 @@ } .message { - padding: 0.25em 0.75em; + padding: 0.35em 0.75em; width: fit-content; max-width: 100%; - border-radius: 10px; - overflow: hidden; + border-radius: 12px; + overflow: visible; word-break: break-word; + position: relative; + } + + .bubble-tail { + border-bottom-left-radius: 4px; + } + + .bubble-tail::before { + content: ""; + position: absolute; + bottom: 0; + left: -8px; + width: 10px; + height: 16px; + clip-path: path('M10 0 C10 7 2 13 0 16 L10 16 Z'); } .light-message { - background-color: #ddd; + background-color: #e4e4e4; + } + + .light-message.bubble-tail::before { + background: #e4e4e4; } .dark-message { - background-color: #222; + background-color: #2a2a2a; + } + + .dark-message.bubble-tail::before { + background: #2a2a2a; } .input-bar { |
