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