aboutsummaryrefslogtreecommitdiffstats
path: root/src/routes
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/routes
parent3b18232d52957dbd1a5a0c12891020d2a6e3295e (diff)
downloadcoinos-ui-7e45e1b48c382f29c2d3bd114a4751a0f6b89d51.tar.gz
coinos-ui-7e45e1b48c382f29c2d3bd114a4751a0f6b89d51.zip
group chats
Diffstat (limited to 'src/routes')
-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
5 files changed, 499 insertions, 643 deletions
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>