diff options
| author | Adam Soltys <[email protected]> | 2026-03-06 08:39:18 -0800 |
|---|---|---|
| committer | Adam Soltys <[email protected]> | 2026-03-06 08:39:18 -0800 |
| commit | 7e45e1b48c382f29c2d3bd114a4751a0f6b89d51 (patch) | |
| tree | 7cbfc937afdcccab812d7f97c8f85b681c0d4687 /src/routes | |
| parent | 3b18232d52957dbd1a5a0c12891020d2a6e3295e (diff) | |
| download | coinos-ui-7e45e1b48c382f29c2d3bd114a4751a0f6b89d51.tar.gz coinos-ui-7e45e1b48c382f29c2d3bd114a4751a0f6b89d51.zip | |
group chats
Diffstat (limited to 'src/routes')
| -rw-r--r-- | src/routes/(app)/+layout.svelte | 2 | ||||
| -rw-r--r-- | src/routes/(app)/messages/+layout.svelte | 2 | ||||
| -rw-r--r-- | src/routes/(app)/messages/+page.svelte | 172 | ||||
| -rw-r--r-- | src/routes/(app)/messages/[id]/+page.svelte | 446 | ||||
| -rw-r--r-- | src/routes/(app)/messages/[pubkey]/+page.svelte | 520 |
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> |
