aboutsummaryrefslogtreecommitdiffstats
path: root/src
diff options
context:
space:
mode:
authorAdam Soltys <[email protected]>2026-03-06 10:06:53 -0800
committerAdam Soltys <[email protected]>2026-03-06 10:06:53 -0800
commit47316e28150953909c71adfd1e9100c4e08024b1 (patch)
tree5f383db13a8145fac312b53199d01174386aed61 /src
parent7e45e1b48c382f29c2d3bd114a4751a0f6b89d51 (diff)
downloadcoinos-ui-47316e28150953909c71adfd1e9100c4e08024b1.tar.gz
coinos-ui-47316e28150953909c71adfd1e9100c4e08024b1.zip
chat fixes
Diffstat (limited to 'src')
-rw-r--r--src/lib/marmot.ts37
-rw-r--r--src/lib/messaging.ts7
-rw-r--r--src/lib/mls-relay.ts66
-rw-r--r--src/routes/(app)/messages/+page.svelte55
-rw-r--r--src/routes/(app)/messages/[id]/+page.svelte155
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 {