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 | |
| parent | 3b18232d52957dbd1a5a0c12891020d2a6e3295e (diff) | |
| download | coinos-ui-7e45e1b48c382f29c2d3bd114a4751a0f6b89d51.tar.gz coinos-ui-7e45e1b48c382f29c2d3bd114a4751a0f6b89d51.zip | |
group chats
Diffstat (limited to 'src')
| -rw-r--r-- | src/components/Menu.svelte | 30 | ||||
| -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 | ||||
| -rw-r--r-- | src/routes/(app)/+layout.svelte | 2 | ||||
| -rw-r--r-- | src/routes/(app)/messages/+layout.svelte | 2 | ||||
| -rw-r--r-- | src/routes/(app)/messages/+page.svelte | 172 | ||||
| -rw-r--r-- | src/routes/(app)/messages/[id]/+page.svelte | 446 | ||||
| -rw-r--r-- | src/routes/(app)/messages/[pubkey]/+page.svelte | 520 |
10 files changed, 744 insertions, 896 deletions
diff --git a/src/components/Menu.svelte b/src/components/Menu.svelte index 9803e7a2..b1fae7a9 100644 --- a/src/components/Menu.svelte +++ b/src/components/Menu.svelte @@ -8,7 +8,7 @@ import PhMoonStarsBold from "virtual:icons/ph/moon-stars-bold"; import PhSignOutBold from "virtual:icons/ph/sign-out-bold"; import CoinosLogo from "virtual:icons/coinos/logo"; - import { theme } from "$lib/store"; + import { theme, unreadDMs } from "$lib/store"; import DarkToggle from "$comp/DarkToggle.svelte"; let { t, user, w, opacity } = $props(); @@ -36,8 +36,11 @@ </script> <div use:clickOutside={hideMenu}> - <button class="flex justify-center items-center bg-base-100 p-2 rounded-full w-12 h-12 sm:w-16 sm:h-16 drop-shadow-xl {opacity('/support')}" onclick={toggleMenu} aria-label="Open menu"> + <button class="relative flex justify-center items-center bg-base-100 p-2 rounded-full w-12 h-12 sm:w-16 sm:h-16 drop-shadow-xl {opacity('/support')}" onclick={toggleMenu} aria-label="Open menu"> <PhListBold width={w > 640 ? 32 : 24} /> + {#if $unreadDMs > 0} + <span class="badge-dot">{$unreadDMs > 99 ? "99+" : $unreadDMs}</span> + {/if} </button> <div @@ -57,10 +60,13 @@ onclick={hideMenu} > <button - class="flex justify-center items-center font-semibold hover:opacity-80 gap-2" + class="relative flex justify-center items-center font-semibold hover:opacity-80 gap-2" > <Icon width="32" /> {t(key)} + {#if key === "nav.dm" && $unreadDMs > 0} + <span class="badge-dot">{$unreadDMs > 99 ? "99+" : $unreadDMs}</span> + {/if} </button> </a> {/if} @@ -73,3 +79,21 @@ </a> </div> </div> + +<style> + .badge-dot { + position: absolute; + top: -2px; + right: -2px; + min-width: 18px; + height: 18px; + padding: 0 4px; + border-radius: 9px; + background: #f97316; + color: white; + font-size: 11px; + font-weight: 700; + line-height: 18px; + text-align: center; + } +</style> 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); diff --git a/src/routes/(app)/+layout.svelte b/src/routes/(app)/+layout.svelte index 5a9397b5..6b211563 100644 --- a/src/routes/(app)/+layout.svelte +++ b/src/routes/(app)/+layout.svelte @@ -243,6 +243,8 @@ // Auto-unlock nostr key if silent entropy is available import("$lib/seed").then(({ autoUnlockNostr }) => autoUnlockNostr(user.pubkey)); + + // TODO: unread DM badge via MLS } // if (window.NDEFReader) { diff --git a/src/routes/(app)/messages/+layout.svelte b/src/routes/(app)/messages/+layout.svelte index c923768c..18c83d23 100644 --- a/src/routes/(app)/messages/+layout.svelte +++ b/src/routes/(app)/messages/+layout.svelte @@ -10,7 +10,7 @@ let user = $derived(data.user); let w: number | undefined = $state(); - let isDetail = $derived($page.params.pubkey); + let isDetail = $derived($page.params.id); let opacity = $derived((href: string) => $page.url.pathname === href ? "opacity-100" : "opacity-90 hover:opacity-none", diff --git a/src/routes/(app)/messages/+page.svelte b/src/routes/(app)/messages/+page.svelte index 2c3b1eb1..c24a20c8 100644 --- a/src/routes/(app)/messages/+page.svelte +++ b/src/routes/(app)/messages/+page.svelte @@ -1,162 +1,90 @@ <script lang="ts"> import { onMount, onDestroy } from "svelte"; - import { isValid } from "nostr-tools/nip05"; - import { decode } from "nostr-tools/nip19"; - import { getNostrUserInfo } from "$lib/nip01"; import { - getMessageRumours, subscribeToMessages, cacheRumour, - getCachedChatList, setCachedChatList, - getCachedUserInfo, setCachedUserInfo, + getGroups, subscribeToMessages, fetchGroupHistory, + resolveUser, displayName, } from "$lib/messaging"; - import { pTagKeys } from "$lib/nostr"; + import type { GroupInfo } from "$lib/messaging"; import { t } from "$lib/translations"; let { data } = $props(); const user = $derived(data.user); - (() => { + onMount(() => { if (!data.user) { window.location.replace("/login"); } - })(); - - const coinosUserFromPubkey = async (pubkey: string): Promise<any> => { - const response = await fetch(`/api/users/${pubkey}`); - if (response.ok) { - const info = await response.json(); - return info.anon ? null : info; - } - return null; - }; - - const userInfo = async (pubkey: string) => { - if (pubkey.slice(0, 4) === "npub") { - return userInfo(decode(pubkey).data as string); - } - - const cached = getCachedUserInfo(pubkey); - if (cached) return cached; - - const nostrUserInfo = (await getNostrUserInfo(pubkey)) || {}; - const valid = "nip05" in nostrUserInfo && (await isValid(pubkey, nostrUserInfo.nip05)); - const coinosUser = await coinosUserFromPubkey(pubkey); - const info = { - coinosUsername: coinosUser?.username || null, - picture: coinosUser?.picture || nostrUserInfo?.picture || null, - nostrName: nostrUserInfo?.name, - nip05: nostrUserInfo?.nip05, - nip05Valid: valid, - pubkey, - }; - setCachedUserInfo(pubkey, info); - return info; - }; + }); - const name = (info: any): string => { - return info.coinosUsername || info.nostrName || $t("dm.anonymous"); - }; + let groups: GroupInfo[] = $state([]); + let loaded = $state(false); + let memberNames = $state(new Map<string, Map<string, string>>()); - const rumourInvolves = (rumour: any, pubkey: string) => { - if (rumour.pubkey === pubkey) return true; - for (const tagPK of pTagKeys(rumour)) { - if (tagPK === pubkey) return true; - } - return false; + 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 mostRecentCommunication = (rumours: any[], pubkey: string) => { - let mostRecentTime = null; - for (const rumour of rumours.filter((r) => rumourInvolves(r, pubkey))) { - if (!mostRecentTime || rumour.created_at > mostRecentTime) { - mostRecentTime = rumour.created_at; - } - } - return mostRecentTime; - }; + const shortPk = (pk: string) => pk.slice(0, 8) + "…"; - const lastMessage = (rumours: any[], pubkey: string) => { - let latest: any = null; - for (const rumour of rumours.filter((r) => rumourInvolves(r, pubkey))) { - if (!latest || rumour.created_at > latest.created_at) { - latest = rumour; + const resolveMemberNames = async (groups: GroupInfo[]) => { + const updated = new Map(memberNames); + 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; + resolveUser(pk).then((info) => { + gMap.set(pk, displayName(info)); + memberNames = new Map(updated); + }); } } - return latest?.content || ""; + memberNames = updated; }; - // Restore from localStorage on hard refresh, or in-memory cache on SPA nav - // svelte-ignore state_referenced_locally - const userPubkey = data.user?.pubkey; - const storageKey = `chatList:${userPubkey}`; - const cachedList = getCachedChatList(userPubkey); - let stored: any = null; - try { stored = JSON.parse(localStorage.getItem(storageKey) || "null"); } catch {} - let chats: any[] = $state(cachedList?.chats ?? stored?.chats ?? []); - let allRumours: any[] = $state(cachedList?.rumours ?? stored?.rumours ?? []); - // svelte-ignore state_referenced_locally - let loaded = $state(chats.length > 0); - - const updateSendersRecipients = async (rumours: any[]) => { - allRumours = rumours; - const senders = new Set<any>(); - const recipients = new Set<any>(); - for (const rumour of rumours) { - if (rumour.pubkey !== user.pubkey) { - senders.add(await userInfo(rumour.pubkey)); - } else { - for (const pubkey of pTagKeys(rumour)) { - recipients.add(await userInfo(pubkey)); - } - } - } - - const chatMap = new Map<string, any>(); - for (const sender of senders) chatMap.set(sender.pubkey, sender); - for (const recipient of recipients) chatMap.set(recipient.pubkey, recipient); - chats = Array.from(chatMap.values()); - chats.sort( - (a, b) => - (mostRecentCommunication(rumours, b.pubkey) ?? 0) - - (mostRecentCommunication(rumours, a.pubkey) ?? 0), - ); - setCachedChatList(user.pubkey, chats, allRumours); - try { localStorage.setItem(storageKey, JSON.stringify({ chats, rumours })); } catch {} + const loadGroups = async () => { + await fetchGroupHistory(user); + groups = await getGroups(user); + resolveMemberNames(groups); + loaded = true; }; - (() => getMessageRumours(data.user).then(updateSendersRecipients).then(() => (loaded = true)))(); - let closeSub: (() => void) | undefined; onMount(() => { - subscribeToMessages(user, async (rumour) => { - if (!allRumours.find((r) => r.id === rumour.id)) { - cacheRumour(user.pubkey, rumour); - allRumours = [...allRumours, rumour]; - await updateSendersRecipients(allRumours); - } + loadGroups(); + subscribeToMessages(user, async () => { + groups = await getGroups(user); + resolveMemberNames(groups); }).then((close) => (closeSub = close)); }); onDestroy(() => closeSub?.()); </script> -<div class="container mx-auto max-w-xl px-4 space-y-4"> +<div class="container mx-auto max-w-xl px-4 space-y-1"> {#if !loaded} <div class="flex justify-center py-8"><div class="loading loading-spinner"></div></div> - {:else if chats.length === 0} + {:else if groups.length === 0} <p class="text-center text-secondary">{$t("dm.noChats")}</p> {:else} - {#each chats as c} - <a href="/messages/{c.pubkey}" class="flex items-center gap-3 p-3 rounded-2xl hover:bg-base-200"> - {#if c.picture} - <img src={c.picture} alt={name(c)} class="w-12 h-12 rounded-full object-cover shrink-0" /> - {:else} - <div class="w-12 h-12 rounded-full bg-base-300 shrink-0"></div> - {/if} - <div class="min-w-0"> - <div class="font-semibold text-lg">{name(c)}</div> - <div class="text-secondary text-sm truncate">{lastMessage(allRumours, c.pubkey)}</div> + {#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> + <div class="min-w-0 flex-1"> + <div class="font-semibold text-lg truncate">{groupDisplayName(g)}</div> + {#if g.lastMessage} + <div class="text-secondary text-sm truncate">{g.lastMessage}</div> + {/if} </div> </a> {/each} diff --git a/src/routes/(app)/messages/[id]/+page.svelte b/src/routes/(app)/messages/[id]/+page.svelte new file mode 100644 index 00000000..d7520c5d --- /dev/null +++ b/src/routes/(app)/messages/[id]/+page.svelte @@ -0,0 +1,446 @@ +<script lang="ts"> + import PhPaperPlaneRightFill from "virtual:icons/ph/paper-plane-right-fill"; + import { tick, onMount, onDestroy } from "svelte"; + import { page } from "$app/state"; + import { goto } from "$app/navigation"; + + import { + getGroups, getGroupRumours, sendGroupMessage, + subscribeToMessages, fetchGroupHistory, + findOrCreateDmGroup, + resolveUser, displayName, + } from "$lib/messaging"; + import type { GroupInfo } from "$lib/messaging"; + import { t } from "$lib/translations"; + import { theme } from "$lib/store"; + import ChatMessage from "$comp/ChatMessage.svelte"; + + const focus = (el: HTMLElement) => el.focus(); + + const scrollChat = () => { + const doScroll = () => { + const el = document.getElementById("messages"); + if (el) el.scrollTop = el.scrollHeight; + document.getElementById("messages-end")?.scrollIntoView({ block: "nearest" }); + }; + doScroll(); + setTimeout(doScroll, 200); + }; + + let { data } = $props(); + const user = $derived(data.user); + const id: string = $derived(page.params.id!); + + onMount(() => { + if (!data.user) { + window.location.replace("/login"); + } + }); + + let groupId = $state(""); + let groupInfo: GroupInfo | undefined = $state(undefined); + let text = $state(""); + let messageRumours: Map<number, any[]> = $state(new Map()); + let dates: number[] = $state([]); + let sendError = $state(""); + let senderInfos = $state(new Map<string, any>()); + + 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) => { + const info = senderInfos.get(pk); + return info ? displayName(info) : pk.slice(0, 8) + "…"; + }); + return names.join(", ") || "Group"; + }; + + const dateMap = (rumours: any[]): Map<number, any[]> => { + const m = new Map<number, any[]>(); + for (const r of rumours) { + const day = Math.floor(r.created_at / 86400); + if (m.has(day)) m.get(day)!.push(r); + else m.set(day, [r]); + } + for (const [, dayRumours] of m) { + dayRumours.sort((a: any, b: any) => a.created_at - b.created_at); + } + return m; + }; + + const updateMessages = () => { + const rumours = getGroupRumours(groupId); + messageRumours = dateMap(rumours); + dates = Array.from(messageRumours.keys()).sort((a, b) => a - b); + }; + + const loadGroup = async () => { + await fetchGroupHistory(user); + + // If `id` is a 64-char hex string and not a known group, treat as pubkey (profile DM link) + const groups = await getGroups(user); + let found = groups.find((g) => g.groupId === id); + + if (!found && /^[0-9a-f]{64}$/.test(id)) { + try { + const newGroupId = await findOrCreateDmGroup(user, id); + groupId = newGroupId; + const updatedGroups = await getGroups(user); + found = updatedGroups.find((g) => g.groupId === newGroupId); + goto(`/messages/${newGroupId}`, { replaceState: true }); + } catch (e: any) { + console.error("[messages] Failed to create DM group:", e); + sendError = e.message || "Failed to start conversation"; + return; + } + } else { + groupId = id; + } + + groupInfo = found; + updateMessages(); + + if (groupInfo) { + for (const pk of groupInfo.members) { + if (!senderInfos.has(pk)) { + resolveUser(pk).then((info) => { + senderInfos = new Map(senderInfos).set(pk, info); + }); + } + } + } + + await tick(); + scrollChat(); + }; + + 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) + "…"; + }; + + const TIME = new Intl.DateTimeFormat(undefined, { + hour: "numeric", + hour12: false, + minute: "numeric", + }); + const NEAR_DATE = new Intl.DateTimeFormat(undefined, { + month: "short", + day: "numeric", + weekday: "short", + }); + const FAR_DATE = new Intl.DateTimeFormat(undefined, { + year: "numeric", + month: "short", + day: "numeric", + weekday: "short", + }); + const NEAR_DISTANCE = 182 * 24 * 60 * 60 * 1000; + const formatDate = (date: Date): string => { + return date.getTime() >= Date.now() - NEAR_DISTANCE + ? NEAR_DATE.format(date) + : FAR_DATE.format(date); + }; + + const sendMessage = async (message: string) => { + sendError = ""; + text = ""; + const ta = document.getElementById("message-contents") as HTMLTextAreaElement; + if (ta) { + ta.style.height = "auto"; + ta.focus(); + } + + // Optimistic UI + const optimistic = { + id: crypto.randomUUID(), + kind: 14, + created_at: Math.floor(Date.now() / 1000), + pubkey: user.pubkey, + content: message, + tags: [], + _groupId: groupId, + }; + const dayKey = Math.floor(optimistic.created_at / 86400); + const existing = messageRumours.get(dayKey); + if (existing) { + messageRumours = new Map(messageRumours).set(dayKey, [...existing, optimistic]); + } else { + messageRumours = new Map(messageRumours).set(dayKey, [optimistic]); + } + dates = Array.from(messageRumours.keys()).sort((a: number, b: number) => a - b); + await tick(); + scrollChat(); + + try { + await sendGroupMessage(user, groupId, message); + } catch (e: any) { + console.error("Failed to send message:", e); + sendError = $t("dm.sendFailed"); + } + }; + + let closeSub: (() => void) | undefined; + + onMount(() => { + loadGroup(); + + const vv = window.visualViewport; + if (vv) { + const onResize = () => scrollChat(); + vv.addEventListener("resize", onResize); + return () => vv.removeEventListener("resize", onResize); + } + }); + + onMount(() => { + subscribeToMessages(user, async (rumour) => { + if (rumour._groupId !== groupId) return; + if (rumour.pubkey === user.pubkey) return; + + if (!senderInfos.has(rumour.pubkey)) { + resolveUser(rumour.pubkey).then((info) => { + senderInfos = new Map(senderInfos).set(rumour.pubkey, info); + }); + } + + updateMessages(); + await tick(); + scrollChat(); + }).then((close) => (closeSub = close)); + }); + + onDestroy(() => closeSub?.()); +</script> + +<div class="chat-container"> + <div class="chat-header"> + <h2 class="text-xl font-semibold">{groupDisplayName(groupInfo)}</h2> + {#if groupInfo && groupInfo.members.length > 2} + <span class="text-secondary text-sm">{groupInfo.members.length} members</span> + {/if} + </div> + + <div id="messages"> + <div class="spacer"></div> + {#each dates as day} + <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> + </div> + </li> + {/each} + </ul> + {/each} + <div id="messages-end"></div> + </div> + + <div class="input-bar"> + <div class="input-row"> + <textarea + id="message-contents" + bind:value={text} + use:focus + rows="1" + oninput={(e) => { + const el = e.currentTarget; + el.style.height = "auto"; + el.style.height = Math.min(el.scrollHeight, 120) + "px"; + }} + onkeydown={(e) => { + if (e.key === "Enter" && !e.shiftKey) { + e.preventDefault(); + if (text.trim() && groupId) sendMessage(text); + } + }} + ></textarea> + <button + class="send-btn" + disabled={!text.trim() || !groupId} + onmousedown={(e) => e.preventDefault()} + ontouchstart={(e) => e.preventDefault()} + onclick={() => sendMessage(text)} + aria-label="Send" + > + <PhPaperPlaneRightFill width="22" /> + </button> + </div> + {#if sendError} + <p class="warning"><em>{sendError}</em></p> + {/if} + </div> +</div> + +<style> + .chat-container { + display: flex; + flex-direction: column; + height: 100%; + max-width: 36rem; + margin: 0 auto; + padding: 0 1rem; + } + + .chat-header { + display: flex; + align-items: baseline; + gap: 0.75rem; + padding: 0.5rem 0; + flex-shrink: 0; + } + + .warning { + color: #ff7f00; + } + + .secondary { + color: #7f7f7f; + } + + .message-row { + display: flex; + align-items: flex-end; + gap: 0.5rem; + margin: 1px 0; + } + + .message-content { + max-width: 85%; + } + + .sender-name { + font-size: 0.75rem; + font-weight: 600; + color: #7f7f7f; + margin-top: 0.5rem; + margin-bottom: 1px; + margin-left: 0.75rem; + } + + .date-header { + text-align: center; + margin: 1.5em 0em; + } + + .timestamp { + margin-left: 0.5em; + text-align: right; + } + + #messages { + flex: 1; + overflow-y: auto; + scrollbar-width: thin; + margin-right: -1rem; + padding-right: 1.5rem; + display: flex; + flex-direction: column; + } + + .spacer { + flex: 1; + } + + @media (min-width: 36rem) { + #messages { + margin-right: 0; + padding-right: 0.5rem; + } + } + + #messages::-webkit-scrollbar { + width: 6px; + } + + #messages::-webkit-scrollbar-thumb { + background: rgba(128, 128, 128, 0.3); + border-radius: 3px; + } + + #messages::-webkit-scrollbar-button { + display: none; + } + + .message { + padding: 0.25em 0.75em; + width: fit-content; + max-width: 100%; + border-radius: 10px; + overflow: hidden; + word-break: break-word; + } + + .light-message { + background-color: #ddd; + } + + .dark-message { + background-color: #222; + } + + .input-bar { + flex-shrink: 0; + padding: 0.5rem 0; + } + + .input-row { + display: flex; + align-items: flex-end; + gap: 0.5rem; + } + + .input-row textarea { + flex: 1; + resize: none; + overflow-y: auto; + line-height: 1.4; + padding: 0.5rem 0.75rem; + border-radius: 1.25rem; + border: 1px solid rgba(128, 128, 128, 0.3); + background: transparent; + color: inherit; + font: inherit; + min-height: unset; + } + + .input-row textarea:focus { + outline: none; + border-color: rgba(128, 128, 128, 0.6); + } + + .send-btn { + display: flex; + align-items: center; + justify-content: center; + width: 2.5rem; + height: 2.5rem; + border-radius: 9999px; + background: var(--accent, #6366f1); + color: white; + border: none; + cursor: pointer; + flex-shrink: 0; + } + + .send-btn:disabled { + opacity: 0.35; + cursor: default; + } +</style> diff --git a/src/routes/(app)/messages/[pubkey]/+page.svelte b/src/routes/(app)/messages/[pubkey]/+page.svelte deleted file mode 100644 index f5402bb1..00000000 --- a/src/routes/(app)/messages/[pubkey]/+page.svelte +++ /dev/null @@ -1,520 +0,0 @@ -<script lang="ts"> - import PhPaperPlaneRightFill from "virtual:icons/ph/paper-plane-right-fill"; - import { tick, onMount, onDestroy } from "svelte"; - import { page } from "$app/state"; - import { decode } from "nostr-tools/nip19"; - import { isValid } from "nostr-tools/nip05"; - - import { getNostrUserInfo } from "$lib/nip01"; - import * as libnip17 from "$lib/messaging"; - import { pTagKeys } from "$lib/nostr"; - import { t } from "$lib/translations"; - import { theme } from "$lib/store"; - import ChatMessage from "$comp/ChatMessage.svelte"; - - const focus = (el: HTMLElement) => el.focus(); - - const scrollChat = () => { - const doScroll = () => { - const el = document.getElementById("messages"); - if (el) el.scrollTop = el.scrollHeight; - document.getElementById("messages-end")?.scrollIntoView({ block: "nearest" }); - }; - doScroll(); - setTimeout(doScroll, 200); - }; - - let { data } = $props(); - const user = $derived(data.user); - const pubkey: string = $derived(page.params.pubkey!); - - (() => { - if (!data.user) { - window.location.replace("/login"); - } - })(); - - const coinosUserFromPubkey = async (pk: string): Promise<any> => { - const response = await fetch(`/api/users/${pk}`); - if (response.ok) { - const info = await response.json(); - return info.anon ? null : info; - } - return null; - }; - - const userInfo = async (pk: string) => { - if (pk.slice(0, 4) === "npub") { - return userInfo(decode(pk).data as string); - } - - const cached = libnip17.getCachedUserInfo(pk); - if (cached) return cached; - - const nostrUserInfo = (await getNostrUserInfo(pk)) || {}; - const valid = "nip05" in nostrUserInfo && (await isValid(pk, nostrUserInfo.nip05)); - const coinosUser = await coinosUserFromPubkey(pk); - const info = { - coinosUsername: coinosUser?.username || null, - picture: coinosUser?.picture || nostrUserInfo?.picture || null, - nostrName: nostrUserInfo?.name, - nip05: nostrUserInfo?.nip05, - nip05Valid: valid, - pubkey: pk, - }; - libnip17.setCachedUserInfo(pk, info); - return info; - }; - - const name = (info: any): string => { - return info.coinosUsername || info.nostrName || $t("dm.anonymous"); - }; - - // svelte-ignore state_referenced_locally - let selectedChat: any = $state(libnip17.getCachedUserInfo(pubkey) ?? { pubkey }); - let text = $state(""); - let allRumours: any[] = $state([]); - let messageRumours: Map<number, any[]> = $state(new Map()); - let dates: number[] = $state([]); - let relayWarningShown = $state(false); - let canSend = $state(false); - let sendError = $state(""); - - const appendMultimap = (map: Map<any, any>, key: any, value: any) => { - if (map.has(key)) { - map.get(key)!.push(value); - } else { - map.set(key, [value]); - } - }; - - const dateMap = (events: any[]): Map<number, any[]> => { - let eventsMap = new Map<number, any[]>(); - for (const event of events) { - const created = Math.floor(event.created_at / 86400); - appendMultimap(eventsMap, created, event); - } - for (const [date, dateEvents] of eventsMap) { - dateEvents.sort((ev1: any, ev2: any) => ev1.created_at - ev2.created_at); - } - return eventsMap; - }; - - const updateEvents = async () => { - allRumours = await libnip17.getMessageRumours(user); - const rumours = allRumours.filter( - (r) => - (r.pubkey === user.pubkey && pTagKeys(r).includes(pubkey)) || - (r.pubkey === pubkey && pTagKeys(r).includes(user.pubkey)), - ); - messageRumours = dateMap(rumours); - - dates = Array.from(messageRumours.keys()); - dates.sort((d1, d2) => d1 - d2); - - await tick(); - scrollChat(); - }; - - const TIME = new Intl.DateTimeFormat(undefined, { - hour: "numeric", - hour12: false, - minute: "numeric", - }); - const NEAR_DATE = new Intl.DateTimeFormat(undefined, { - month: "short", - day: "numeric", - weekday: "short", - }); - const FAR_DATE = new Intl.DateTimeFormat(undefined, { - year: "numeric", - month: "short", - day: "numeric", - weekday: "short", - }); - const NEAR_DISTANCE = 182 * 24 * 60 * 60 * 1000; - const formatDate = (date: Date): string => { - if (date.getTime() >= Date.now() - NEAR_DISTANCE) { - return NEAR_DATE.format(date); - } else { - return FAR_DATE.format(date); - } - }; - - const sendMessage = async (message: string) => { - sendError = ""; - text = ""; - const ta = document.getElementById("message-contents") as HTMLTextAreaElement; - if (ta) { - ta.style.height = "auto"; - ta.focus(); - } - - // Optimistic UI: show message immediately - const optimistic = { - id: crypto.randomUUID(), - kind: 14, - created_at: Math.floor(Date.now() / 1000), - pubkey: user.pubkey, - content: message, - tags: [["p", pubkey]], - }; - allRumours = [...allRumours, optimistic]; - const dayKey = Math.floor(optimistic.created_at / 86400); - const existing = messageRumours.get(dayKey); - if (existing) { - messageRumours = new Map(messageRumours).set(dayKey, [...existing, optimistic]); - } else { - messageRumours = new Map(messageRumours).set(dayKey, [optimistic]); - } - dates = Array.from(messageRumours.keys()).sort((a: number, b: number) => a - b); - await tick(); - scrollChat(); - - try { - await libnip17.send(message, user, selectedChat); - } catch (e: any) { - console.error("Failed to send message:", e); - sendError = $t("dm.sendFailed"); - } - }; - - onMount(() => { - updateEvents(); - userInfo(pubkey).then((info) => (selectedChat = info)); - libnip17.getPreferredRelays(pubkey).then((relays) => { - canSend = relays && relays.length > 0; - }); - }); - - (() => libnip17.getPreferredRelays(data.user.pubkey).then((relays) => { - if (!relays || relays.length == 0) { - relayWarningShown = true; - return; - } - relayWarningShown = false; - }))(); - - let closeSub: (() => void) | undefined; - - // Scroll messages to bottom when mobile keyboard opens/closes - onMount(() => { - const vv = window.visualViewport; - if (!vv) return; - const onResize = () => scrollChat(); - vv.addEventListener("resize", onResize); - return () => vv.removeEventListener("resize", onResize); - }); - - onMount(() => { - libnip17.subscribeToMessages(user, async (rumour) => { - if (!allRumours.find((r) => r.id === rumour.id)) { - libnip17.cacheRumour(user.pubkey, rumour); - allRumours = [...allRumours, rumour]; - } - - // Skip self-sent messages — already shown via optimistic UI - if (rumour.pubkey === user.pubkey) return; - - if ( - rumour.pubkey === pubkey && pTagKeys(rumour).includes(user.pubkey) - ) { - const dayKey = Math.floor(rumour.created_at / 86400); - const existing = messageRumours.get(dayKey); - if (existing) { - if (!existing.find((r: any) => r.id === rumour.id)) { - const updated = [...existing, rumour].sort((a: any, b: any) => a.created_at - b.created_at); - messageRumours = new Map(messageRumours).set(dayKey, updated); - } - } else { - messageRumours = new Map(messageRumours).set(dayKey, [rumour]); - } - dates = Array.from(messageRumours.keys()).sort((a: number, b: number) => a - b); - - await tick(); - scrollChat(); - } - }).then((close) => (closeSub = close)); - }); - - onDestroy(() => closeSub?.()); -</script> - -<div class="chat-container"> - {#if selectedChat} - {@const chatProfile = selectedChat.coinosUsername ? `/${selectedChat.coinosUsername}` : null} - <a href={chatProfile} class="chat-header"> - {#if selectedChat.picture} - <img src={selectedChat.picture} alt={name(selectedChat)} class="w-10 h-10 rounded-full object-cover" /> - {/if} - <h2 class="text-xl font-semibold">{name(selectedChat)}</h2> - </a> - - <div id="messages"> - <div class="spacer"></div> - {#each dates as day} - <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 isLastInRun = i === dayMessages.length - 1 || dayMessages[i + 1]?.pubkey !== rumour.pubkey} - {#if rumour.pubkey === user.pubkey && pTagKeys(rumour).includes(selectedChat.pubkey)} - <li class="message-row by-user"> - <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> - {#if isLastInRun && user.picture} - <a href="/{user.username}"><img src={user.picture} alt={user.username} class="msg-avatar" /></a> - {:else} - <div class="msg-avatar-placeholder"></div> - {/if} - </li> - {:else if rumour.pubkey === selectedChat.pubkey && pTagKeys(rumour).includes(user.pubkey)} - <li class="message-row by-other"> - {#if isLastInRun && selectedChat.picture} - <a href={chatProfile}><img src={selectedChat.picture} alt={name(selectedChat)} class="msg-avatar" /></a> - {:else} - <div class="msg-avatar-placeholder"></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> - </li> - {/if} - {/each} - </ul> - {/each} - <div id="messages-end"></div> - </div> - - <div class="input-bar"> - <div class="input-row"> - <textarea - id="message-contents" - bind:value={text} - use:focus - rows="1" - placeholder={canSend ? "" : $t("dm.relaysNotFound").replace("[R]", selectedChat ? name(selectedChat) : "")} - disabled={!canSend} - oninput={(e) => { - const el = e.currentTarget; - el.style.height = "auto"; - el.style.height = Math.min(el.scrollHeight, 120) + "px"; - }} - onkeydown={(e) => { - if (e.key === "Enter" && !e.shiftKey) { - e.preventDefault(); - if (canSend && text.trim()) sendMessage(text); - } - }} - ></textarea> - <button - class="send-btn" - disabled={!canSend || !text.trim()} - onmousedown={(e) => e.preventDefault()} - ontouchstart={(e) => e.preventDefault()} - onclick={() => sendMessage(text)} - aria-label="Send" - > - <PhPaperPlaneRightFill width="22" /> - </button> - </div> - {#if sendError} - <p class="warning"><em>{sendError}</em></p> - {/if} - </div> - {/if} - - {#if relayWarningShown} - <p class="warning"> - <em> - {$t("dm.noRelaysSet")} - <a class="link" href="/settings/nostr#dm-relays">{$t("dm.nostrSettingsLink")}</a> - </em> - </p> - {/if} -</div> - -<style> - .chat-container { - display: flex; - flex-direction: column; - height: 100%; - max-width: 36rem; - margin: 0 auto; - padding: 0 1rem; - } - - .chat-header { - display: flex; - align-items: center; - gap: 0.75rem; - padding: 0.5rem 0; - flex-shrink: 0; - } - - .chat-header:hover { - opacity: 0.8; - } - - .warning { - color: #ff7f00; - } - - .secondary { - color: #7f7f7f; - } - - .link { - text-decoration-line: underline; - } - - .message-row { - display: flex; - align-items: flex-end; - gap: 0.5rem; - margin: 3px 0; - } - - .message-row.by-user { - justify-content: flex-end; - } - - .message-row.by-other { - justify-content: flex-start; - } - - .msg-avatar { - width: 28px; - height: 28px; - border-radius: 9999px; - object-fit: cover; - flex-shrink: 0; - } - - .msg-avatar-placeholder { - width: 28px; - height: 28px; - flex-shrink: 0; - } - - .date-header { - text-align: center; - margin: 1.5em 0em; - } - - .timestamp { - margin-left: 0.5em; - text-align: right; - } - - #messages { - flex: 1; - overflow-y: auto; - scrollbar-width: thin; - margin-right: -1rem; - padding-right: 1.5rem; - display: flex; - flex-direction: column; - } - - .spacer { - flex: 1; - } - - @media (min-width: 36rem) { - #messages { - margin-right: 0; - padding-right: 0.5rem; - } - } - - #messages::-webkit-scrollbar { - width: 6px; - } - - #messages::-webkit-scrollbar-thumb { - background: rgba(128, 128, 128, 0.3); - border-radius: 3px; - } - - #messages::-webkit-scrollbar-button { - display: none; - } - - .message { - padding: 0.25em 0.75em; - width: fit-content; - max-width: 75%; - border-radius: 10px; - overflow: hidden; - word-break: break-word; - } - - .light-message { - background-color: #ddd; - } - - .dark-message { - background-color: #222; - } - - .input-bar { - flex-shrink: 0; - padding: 0.5rem 0; - } - - .input-row { - display: flex; - align-items: flex-end; - gap: 0.5rem; - } - - .input-row textarea { - flex: 1; - resize: none; - overflow-y: auto; - line-height: 1.4; - padding: 0.5rem 0.75rem; - border-radius: 1.25rem; - border: 1px solid rgba(128, 128, 128, 0.3); - background: transparent; - color: inherit; - font: inherit; - min-height: unset; - } - - .input-row textarea:focus { - outline: none; - border-color: rgba(128, 128, 128, 0.6); - } - - .send-btn { - display: flex; - align-items: center; - justify-content: center; - width: 2.5rem; - height: 2.5rem; - border-radius: 9999px; - background: var(--accent, #6366f1); - color: white; - border: none; - cursor: pointer; - flex-shrink: 0; - } - - .send-btn:disabled { - opacity: 0.35; - cursor: default; - } -</style> |
