diff options
| author | Adam Soltys <[email protected]> | 2026-03-01 21:09:04 -0800 |
|---|---|---|
| committer | Adam Soltys <[email protected]> | 2026-03-01 21:09:04 -0800 |
| commit | cbbd98ae5446b11f2a432dee7ffea291cdf2ee21 (patch) | |
| tree | a397fe8afa5527cf9d86e4fd854d69d8f56fabfe /src | |
| parent | c7e2383736d3ef99f069226bbafad9fbddfeb030 (diff) | |
| download | coinos-ui-cbbd98ae5446b11f2a432dee7ffea291cdf2ee21.tar.gz coinos-ui-cbbd98ae5446b11f2a432dee7ffea291cdf2ee21.zip | |
messages
Diffstat (limited to 'src')
| -rw-r--r-- | src/components/Menu.svelte | 2 | ||||
| -rw-r--r-- | src/lib/nip17.ts | 44 | ||||
| -rw-r--r-- | src/routes/(app)/[username]/(profile)/+layout.svelte | 2 | ||||
| -rw-r--r-- | src/routes/(app)/dm/+page.svelte | 694 | ||||
| -rw-r--r-- | src/routes/(app)/messages/+layout.svelte | 56 | ||||
| -rw-r--r-- | src/routes/(app)/messages/+page.server.ts | 43 | ||||
| -rw-r--r-- | src/routes/(app)/messages/+page.svelte | 200 | ||||
| -rw-r--r-- | src/routes/(app)/messages/[pubkey]/+page.svelte | 394 | ||||
| -rw-r--r-- | src/routes/(app)/messages/[recipient]/+page.server.ts | 22 | ||||
| -rw-r--r-- | src/routes/(app)/messages/[recipient]/+page.svelte | 189 | ||||
| -rw-r--r-- | src/routes/(static)/register/auth/+page.svelte | 24 |
11 files changed, 642 insertions, 1028 deletions
diff --git a/src/components/Menu.svelte b/src/components/Menu.svelte index 17c0bf08..6c581ba1 100644 --- a/src/components/Menu.svelte +++ b/src/components/Menu.svelte @@ -16,7 +16,7 @@ // icon: "ph:storefront-bold", // href: `https://coinosmerch.com`, // }, - // { key: "nav.dm", icon: "ph:chat-bold", href: `/dm` }, + { key: "nav.dm", icon: "ph:chat-bold", href: `/messages` }, { key: "nav.dark", icon: "ph:moon-stars-bold", href: `/dark` }, { key: "nav.signOut", icon: "ph:sign-out-bold", href: `/logout` }, ]; diff --git a/src/lib/nip17.ts b/src/lib/nip17.ts index 1400b5a5..69f69464 100644 --- a/src/lib/nip17.ts +++ b/src/lib/nip17.ts @@ -162,10 +162,10 @@ export const decrypt = async (wrapped: any, user: any): Promise<any> => { } }; -// Returns the rumours of the messages the provided user has sent or received. -// If the events have an expiration timestamp, -// that timestamp is added to its rumour object as the property `expiration`. -export const getMessageRumours = async (user: any): Promise<any[]> => { +// In-memory cache for rumours, keyed by pubkey +let rumourCache = new Map<string, any[]>(); + +const fetchMessageRumours = async (user: any): Promise<any[]> => { const preferredRelays = await getPreferredRelays(user.pubkey); // Always include default relays to catch messages that might be there const relays = Array.from(new Set([...preferredRelays, ...DM_RELAYS_LIST])); @@ -197,9 +197,45 @@ export const getMessageRumours = async (user: any): Promise<any[]> => { } } + rumourCache.set(user.pubkey, rumours); return rumours; }; +// Returns the rumours of the messages the provided user has sent or received. +// If the events have an expiration timestamp, +// that timestamp is added to its rumour object as the property `expiration`. +// Returns cached results immediately if available, and refreshes in the background. +export const getMessageRumours = async (user: any): Promise<any[]> => { + const cached = rumourCache.get(user.pubkey); + if (cached) { + fetchMessageRumours(user); + return cached; + } + return fetchMessageRumours(user); +}; + +// Cache for computed chat lists and user info +let chatListCache = new Map<string, { chats: any[]; rumours: any[] }>(); +let userInfoCache = new Map<string, any>(); + +export const getCachedChatList = (userPubkey: string) => chatListCache.get(userPubkey); +export const setCachedChatList = (userPubkey: string, chats: any[], rumours: any[]) => { + chatListCache.set(userPubkey, { chats, rumours }); +}; + +export const getCachedUserInfo = (pubkey: string) => userInfoCache.get(pubkey); +export const setCachedUserInfo = (pubkey: string, info: any) => { + userInfoCache.set(pubkey, info); +}; + +// Add a rumour to the cache (used by subscription handlers) +export const cacheRumour = (userPubkey: string, rumour: any) => { + const cached = rumourCache.get(userPubkey); + if (cached && !cached.find((r: any) => r.id === rumour.id)) { + cached.push(rumour); + } +}; + // Returns the preferred relays of the user with the provided pubkey, // according to their kind-10050 event. export const getPreferredRelays = async (pubkey: string): Promise<string[]> => { diff --git a/src/routes/(app)/[username]/(profile)/+layout.svelte b/src/routes/(app)/[username]/(profile)/+layout.svelte index df63051f..55fffb71 100644 --- a/src/routes/(app)/[username]/(profile)/+layout.svelte +++ b/src/routes/(app)/[username]/(profile)/+layout.svelte @@ -40,7 +40,7 @@ <iconify-icon noobserver icon="ph:faders-bold" width="32"></iconify-icon> </a> {:else} - <a href="/dm?username={subject.username}" aria-label="Direct Message"> + <a href="/messages/{subject.pubkey}" aria-label="Direct Message"> <iconify-icon noobserver icon="ph:chat-bold" width="32"></iconify-icon> </a> {/if} diff --git a/src/routes/(app)/dm/+page.svelte b/src/routes/(app)/dm/+page.svelte deleted file mode 100644 index 1453ccd6..00000000 --- a/src/routes/(app)/dm/+page.svelte +++ /dev/null @@ -1,694 +0,0 @@ -<script lang="ts"> - import { tick, onMount, onDestroy } from "svelte"; - - import { isValid, queryProfile } from "nostr-tools/nip05"; - import * as toolsnip17 from "nostr-tools/nip17"; - - import { getNostrUserInfo } from "$lib/nip01"; - import { relaysSupporting } from "$lib/nip11"; - import { getMessageRumours } from "$lib/nip17"; - import * as libnip17 from "$lib/nip17"; - import { npubEncode, decode } from "nostr-tools/nip19"; - // import { mute, unmute, mutedAccounts } from "$lib/nip51"; - import { sign, getPrivateKey, pTagKeys } from "$lib/nostr"; - import { t } from "$lib/translations"; - import { theme } from "$lib/store"; - - 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); - - (() => { - if (!data.user) { - window.location.replace("/login"); - } - })(); - - /// chat selection - 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 nostrUserInfo = (await getNostrUserInfo(pubkey)) || {}; - const valid = "nip05" in nostrUserInfo && (await isValid(pubkey, nostrUserInfo.nip05)); - const coinosUser = await coinosUserFromPubkey(pubkey); - return { - coinosUsername: coinosUser?.username || null, - picture: coinosUser?.picture || nostrUserInfo?.picture || null, - nostrName: nostrUserInfo?.name, - nip05: nostrUserInfo?.nip05, - nip05Valid: valid, - pubkey, - }; - }; - - const name = (userInfo: any): string => { - return userInfo.coinosUsername || userInfo.nostrName || $t("dm.anonymous"); - }; - - const rumourInvolves = (rumour: any, pubkey: string) => { - if (rumour.pubkey === pubkey) { - return true; - } else { - for (const tagPK of pTagKeys(rumour)) { - if (tagPK === pubkey) { - return true; - } - } - return false; - } - }; - - 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 updateSendersRecipients = async (rumours: any[]) => { - 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)); - } - } - } - messageSenders = Array.from(senders); - messageRecipients = Array.from(recipients); - - 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), - ); - }; - - let messageSenders: any[] = $state([]); - let messageRecipients: any[] = $state([]); - let chats: any[] = $state([]); - (() => getMessageRumours(data.user).then(updateSendersRecipients))(); - - let selectedChat: any = $state(null); - let creatingNewChat = $state(false); - let searchQuery = $state(""); - - /// individual chat - let text = $state(""); - let allRumours: any[] = $state([]); - let messageRumours: any = $state([]); - let dates: any[] = $state([]); - let relayWarningShown = $state(false); - let canSend = $state(false); - let nostrUserInfo: any = $state({}); - - const selectedChatEmpty = () => !messageRumours || messageRumours.size == 0; - - const removeChat = (pubkey: string) => { - for (let i = 0; i < chats.length; i++) { - if (chats[i].pubkey === pubkey) { - chats.splice(i, 1); - } - } - }; - - const includesPubkey = (chats: any[], pubkey: string) => { - for (const c of chats) { - if (c.pubkey === pubkey) { - return true; - } - } - return false; - }; - - const selectChat = (c: any) => { - if (selectedChat && selectedChatEmpty()) { - removeChat(selectedChat.pubkey); - } - - selectedChat = c; - if (!selectedChat) { - return; - } - if (!includesPubkey(chats, c.pubkey)) { - chats.unshift(c); - } - userInfo(selectedChat.pubkey).then((info) => (nostrUserInfo = info)); - libnip17.getPreferredRelays(selectedChat.pubkey).then((relays) => { - canSend = relays && relays.length > 0; - }); - updateEvents(); - }; - - const selectChatUsername = async (username: string) => { - const response = await fetch(`/api/users/${username}`); - if (!response.ok) { - alert($t("dm.usernameNotFound")); - return; - } - const coinosUserInfo = await response.json(); - selectChatPubkey(coinosUserInfo.pubkey); - }; - - const selectChatNip05 = async (nip05: string) => { - const response = await queryProfile(nip05); - if (response == null) { - alert($t("dm.nip05NotFound")); - return; - } - selectChatPubkey(response.pubkey); - }; - - const selectChatPubkey = async (pubkey: string) => { - selectChat(await userInfo(pubkey)); - creatingNewChat = false; - }; - - onMount(() => { - const urlParams = new URLSearchParams(window.location.search); - if (urlParams.get("username")) { - selectChatUsername(urlParams.get("username")!); - } else if (urlParams.get("nip05")) { - selectChatNip05(urlParams.get("nip05")!); - } else if (urlParams.get("pubkey")) { - selectChatPubkey(urlParams.get("pubkey")!); - } - }); - - 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<any, any> => { - let eventsMap = new Map(); - 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, ev2) => 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(selectedChat.pubkey)) || - (r.pubkey === selectedChat.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; // around 6 months - const formatDate = (date: Date): string => { - if (date.getTime() >= Date.now() - NEAR_DISTANCE) { - return NEAR_DATE.format(date); - } else { - return FAR_DATE.format(date); - } - }; - - let sendError = $state(""); - - const sendMessage = async (message: string) => { - sendError = ""; - try { - await libnip17.send(message, user, selectedChat); - text = ""; - updateEvents(); - await tick(); - document.getElementById("message-contents")?.focus(); - } catch (e: any) { - console.error("Failed to send message:", e); - sendError = $t("dm.sendFailed"); - } - }; - - (() => libnip17.getPreferredRelays(data.user.pubkey).then((relays) => { - if (!relays || relays.length == 0) { - relayWarningShown = true; - return; - } - relayWarningShown = false; - }))(); - - let closeSub: (() => void) | undefined; - - onMount(() => { - libnip17.subscribeToMessages(user, async (rumour) => { - // Add rumour to the full list if not already present - if (!allRumours.find((r) => r.id === rumour.id)) { - allRumours = [...allRumours, rumour]; - await updateSendersRecipients(allRumours); - } - - // If this rumour belongs to the current chat, add it to the display - if ( - selectedChat && - ((rumour.pubkey === user.pubkey && pTagKeys(rumour).includes(selectedChat.pubkey)) || - (rumour.pubkey === selectedChat.pubkey && pTagKeys(rumour).includes(user.pubkey))) - ) { - const dayKey = Math.floor(rumour.created_at / 86400); - if (messageRumours.has(dayKey)) { - const dayMsgs = messageRumours.get(dayKey); - if (!dayMsgs.find((r: any) => r.id === rumour.id)) { - dayMsgs.push(rumour); - dayMsgs.sort((a: any, b: any) => a.created_at - b.created_at); - } - } else { - messageRumours.set(dayKey, [rumour]); - } - dates = Array.from(messageRumours.keys()).sort((a, b) => a - b); - // Force reactivity - messageRumours = new Map(messageRumours); - - await tick(); - scrollChat(); - } - }).then((close) => (closeSub = close)); - }); - - onDestroy(() => closeSub?.()); - - // let muted = $state(new Set<string>()); - // (() => mutedAccounts(data.user).then((m) => (muted = m)))(); - - // const toggleMute = async (pubkey: string) => { - // if (muted && muted.has(pubkey)) { - // await unmute(user, pubkey); - // } else { - // await mute(user, pubkey, true); - // } - // muted = await mutedAccounts(user); - // }; -</script> - -<div class="super-container"> - <div class={"sidebar " + ($theme === "light" ? "light-sidebar" : "dark-sidebar")}> - <h2 class="text-xl chat-header"> - {creatingNewChat ? $t("dm.newChatHeader") : $t("dm.chatHeader")} - <button - class={"chat-btn push-right " + - ($theme === "light" ? "light-chat-btn" : "dark-chat-btn") + - (creatingNewChat ? ($theme === "light" ? " light-selected" : " dark-selected") : "")} - onclick={() => { - creatingNewChat = !creatingNewChat; - selectChat(null); - searchQuery = ""; - }} - > - + - </button> - </h2> - {#if creatingNewChat} - <input - type="text" - class="short" - bind:value={searchQuery} - placeholder={$t("dm.searchPrompt")} - /> - {/if} - {#if !creatingNewChat} - {#each chats as c} - <button - class={"chat-btn tall-btn " + - ($theme === "light" ? "light-chat-btn" : "dark-chat-btn") + - (selectedChat && selectedChat.pubkey == c.pubkey - ? $theme === "light" - ? " light-selected" - : " dark-selected" - : "")} - onclick={() => selectChat(c)} - > - {#if c.picture} - <img src={c.picture} alt={name(c)} class="sidebar-avatar" /> - {/if} - <span class="text-xl">{name(c)}</span> - </button> - {/each} - {:else if searchQuery !== ""} - <button - class={"chat-btn tall-btn " + ($theme === "light" ? "light-chat-btn" : "dark-chat-btn")} - onclick={() => selectChatUsername(searchQuery)} - > - <span class="text-xl">{$t("dm.findByUsername")}</span> - </button> - {#if searchQuery.includes(".")} - <button - class={"chat-btn tall-btn " + ($theme === "light" ? "light-chat-btn" : "dark-chat-btn")} - onclick={() => selectChatNip05(searchQuery)} - > - <span class="text-xl">{$t("dm.findByNIP05")}</span> - </button> - {/if} - {#if searchQuery.length === 64 || searchQuery.slice(0, 4) === "npub"} - <button - class={"chat-btn tall-btn " + ($theme === "light" ? "light-chat-btn" : "dark-chat-btn")} - onclick={() => selectChatPubkey(searchQuery)} - > - <span class="text-xl">{$t("dm.findByPubkey")}</span> - </button> - {/if} - {/if} - </div> - <div - class="main container space-y-2 mx-auto space-y-5 lg:max-w-xl xl:max-w-2xl lg:pl-10 mt-5 lg:mt-0" - > - <h1 class="text-5xl font-medium text-left w-full mx-auto lg:mx-0 md:w-[500px]"> - {$t("dm.header")} - </h1> - - {#if selectedChat} - <div id="messages"> - {#each dates as day} - <p class="date-header secondary">{formatDate(new Date(day * 86400 * 1000))}</p> - <ul> - {#each messageRumours.get(day) as rumour, i} - {@const dayMessages = messageRumours.get(day)} - {@const next = dayMessages[i + 1]} - {@const isLastInRun = !next || next.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"} - > - {rumour.content} - <span class="timestamp secondary text-xs"> - {TIME.format(new Date(rumour.created_at * 1000))} - </span> - </div> - {#if isLastInRun && user.picture} - <img src={user.picture} alt={user.username} class="msg-avatar" /> - {: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} - <img src={selectedChat.picture} alt={name(selectedChat)} class="msg-avatar" /> - {:else} - <div class="msg-avatar-placeholder"></div> - {/if} - <div - class={($theme === "light" ? "light-message " : "dark-message ") + "message"} - > - {rumour.content} - <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"> - <textarea - id="message-contents" - bind:value={text} - use:focus - onkeydown={(e) => { - if (e.key === "Enter" && !e.shiftKey) { - e.preventDefault(); - if (canSend && text.trim()) sendMessage(text); - } - }} - ></textarea> - <input - id="send-message" - type="button" - class="btn btn-accent" - disabled={!canSend} - value={canSend - ? $t("dm.sendMessage") - : $t("dm.relaysNotFound").replace("[R]", name(selectedChat))} - onclick={async () => sendMessage(text)} - /> - {#if sendError} - <p class="warning"><em>{sendError}</em></p> - {/if} - </div> - {:else if chats.length == 0} - <p>{$t("dm.noChats")}</p> - {:else} - <p>{$t("dm.selectChat")}</p> - {/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> -</div> - -<style> - .warning { - color: #ff7f00; - } - - .secondary { - color: #7f7f7f; - } - - .link { - text-decoration-line: underline; - } - - .short { - height: 1.5em; - margin-bottom: 10px; - } - - .super-container { - display: flex; - } - - .sidebar { - max-width: 300px; - padding: 0px 10px 5px; - margin: 0px 5px; - height: 100%; - border-radius: 10px; - } - - .light-sidebar { - background-color: #eeeeee; - } - - .dark-sidebar { - background-color: #222222; - } - - .chat-btn { - margin: 2px 0px; - padding: 0 25px; - border-radius: 10px; - min-width: 30px; - text-align: left; - } - - .tall-btn { - min-height: 3em; - width: 100%; - display: flex; - align-items: center; - gap: 0.5rem; - } - - .sidebar-avatar { - width: 32px; - height: 32px; - border-radius: 9999px; - object-fit: cover; - flex-shrink: 0; - } - - .push-right { - margin-left: auto; - } - - .chat-header { - margin: 1em 15px 0.5em; - } - - .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; - } - - .light-chat-btn { - background-color: #eeeeee; - } - - .dark-chat-btn { - background-color: #222222; - } - - .light-chat-btn:hover { - background-color: #dddddd; - } - - .dark-chat-btn:hover { - background-color: #333333; - } - - .light-selected { - background-color: #dddddd; - } - - .dark-selected { - background-color: #333333; - } - - .main { - padding-left: 0px; - margin-left: 30px; - } - - .date-header { - text-align: center; - margin: 1.5em 0em; - } - - .timestamp { - margin-left: 0.5em; - text-align: right; - } - - #messages { - overflow-y: auto; - max-height: 50vh; - scrollbar-width: thin; - } - - #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; - } - - .light-message { - background-color: #ddd; - } - - .dark-message { - background-color: #222; - } - - .input-bar { - padding: 0.5rem 0; - display: flex; - flex-direction: column; - gap: 0.5rem; - } - -</style> diff --git a/src/routes/(app)/messages/+layout.svelte b/src/routes/(app)/messages/+layout.svelte new file mode 100644 index 00000000..7e7e3cd0 --- /dev/null +++ b/src/routes/(app)/messages/+layout.svelte @@ -0,0 +1,56 @@ +<script lang="ts"> + import { onMount, onDestroy } from "svelte"; + import Menu from "$comp/Menu.svelte"; + import { t } from "$lib/translations"; + import { page } from "$app/stores"; + + let { data, children } = $props(); + let user = $derived(data.user); + + let w: number | undefined = $state(); + let isDetail = $derived($page.params.pubkey); + + let opacity = $derived((href: string) => + $page.url.pathname === href ? "opacity-100" : "opacity-90 hover:opacity-none", + ); + + let header: HTMLElement | null = null; + + onMount(() => { + header = document.querySelector("main > header"); + if (header) header.style.display = "none"; + }); + + onDestroy(() => { + if (header) header.style.display = ""; + }); +</script> + +<svelte:window bind:innerWidth={w} /> + +<nav class="flex items-center p-4 gap-4"> + {#if isDetail} + <a href="/messages" class="btn-menu" aria-label="Back"> + <iconify-icon noobserver icon="ph:arrow-left-bold" width={(w ?? 0) > 640 ? 32 : 24}></iconify-icon> + </a> + {/if} + + <div class="ml-auto flex items-center gap-4"> + <a href="/{user?.username}" data-sveltekit-preload-code="eager" aria-label="Home"> + <button class="btn-menu" aria-label="Home"> + <iconify-icon noobserver icon="ph:house-bold" width={(w ?? 0) > 640 ? 32 : 24}></iconify-icon> + </button> + </a> + <Menu {opacity} {user} t={$t} {w} /> + </div> +</nav> + +{@render children?.()} + +<style> + @reference "../../../app.css"; + + .btn-menu { + @apply 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; + } +</style> diff --git a/src/routes/(app)/messages/+page.server.ts b/src/routes/(app)/messages/+page.server.ts deleted file mode 100644 index 80d6f1a9..00000000 --- a/src/routes/(app)/messages/+page.server.ts +++ /dev/null @@ -1,43 +0,0 @@ -import { get } from "$lib/utils"; -import { redirect } from "@sveltejs/kit"; - -interface Message { - created_at: number; -} - -interface Note { - created_at: number; - seen: number; -} - -export async function load({ parent, url }) { - const { user } = await parent(); - const username = url.searchParams.get("username"); - if (user.username !== username) redirect(307, `/${username}`); - const since = url.searchParams.get("since") || 0; - - let messages = []; - let notes = []; - - if (user?.pubkey) { - const { pubkey } = user; - try { - messages = await get(`/${pubkey}/${since}/messages`); - messages = messages.sort((a: Message, b: Message) => b.created_at - a.created_at); - } catch (e) { - console.log("failed to fetch nostr messages", e); - } - - try { - notes = await get(`/${pubkey}/notes`); - } catch (e) { - console.log(`failed to fetch nostr notes for ${pubkey}`, e); - } - - notes.map((e: Note) => { - e.seen = e.created_at; - }); - } - - return { messages, notes }; -} diff --git a/src/routes/(app)/messages/+page.svelte b/src/routes/(app)/messages/+page.svelte index a858df99..6eb5a399 100644 --- a/src/routes/(app)/messages/+page.svelte +++ b/src/routes/(app)/messages/+page.svelte @@ -1,94 +1,154 @@ <script lang="ts"> - import { run } from "svelte/legacy"; + 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, + } from "$lib/nip17"; + import { pTagKeys } from "$lib/nostr"; import { t } from "$lib/translations"; - import Avatar from "$comp/Avatar.svelte"; - import { event as e, password } from "$lib/store"; - import { browser } from "$app/environment"; - import { decrypt } from "$lib/nostr"; - let { data }: any = $props(); + let { data } = $props(); + const user = $derived(data.user); - let messages: any = $state((() => data.messages)()); - let notes: any = $state((() => data.notes)()); - let invoices: any = $state((() => data.invoices)()); - let sent: any = $state((() => data.sent)()); - let received: any = $state((() => data.received)()); - let subject: any = $state((() => data.subject)()); - let user: any = $state((() => data.user)()); - let refresh = (d: any) => ({ messages, notes, invoices, sent, received, subject, user } = d); + (() => { + 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; + }; - let keys = new Set(); - let latest: any[] = $state([]); - let ready: any; + const userInfo = async (pubkey: string) => { + if (pubkey.slice(0, 4) === "npub") { + return userInfo(decode(pubkey).data as string); + } - e.subscribe(async (event: any) => { - if (!(browser && event && ready)) return; - if (event.recipient?.id === user.id && !~latest.findIndex((m: any) => m.id === event.id)) { - event.content = await decrypt({ event, user }); + const cached = getCachedUserInfo(pubkey); + if (cached) return cached; - let i = latest.findIndex((m: any) => m.pubkey === event.pubkey); - let popped; - if (~i) popped = latest.splice(i, 1); - else popped = latest.pop(); + 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; + }; - latest.unshift(event); - latest = latest; + const name = (info: any): string => { + return info.coinosUsername || info.nostrName || $t("dm.anonymous"); + }; + + 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; + }; - let initialize = async (p: any) => { - let i = 0; - ready = false; - if (!(browser && user)) return; - while (i < messages.length) { - let event = messages[i]; - i++; + 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; + }; - if (!(event.author && event.recipient)) continue; + 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; + } + } + return latest?.content || ""; + }; - let k = event.author.id === user.id ? event.recipient?.pubkey : event.author.pubkey; + // Initialize from cache immediately + const cachedList = getCachedChatList(data.user?.pubkey); + let chats: any[] = $state(cachedList?.chats ?? []); + let allRumours: any[] = $state(cachedList?.rumours ?? []); - if (!keys.has(k)) { - keys.add(k); - event.content = await decrypt({ event, user }); - if (event.content) latest.push(event); + 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)); + } } } - latest = latest; - ready = true; + 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); }; - run(() => { - refresh(data); - }); - run(() => { - initialize($password); + + (() => getMessageRumours(data.user).then(updateSendersRecipients))(); + + 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); + } + }).then((close) => (closeSub = close)); }); + + onDestroy(() => closeSub?.()); </script> -<div class="container w-full mx-auto text-lg px-4 max-w-xl space-y-5"> - <h1 class="px-3 md:px-0 text-center text-3xl md:text-4xl font-semibold mb-10"> - {$t("user.messages")} - </h1> - {#if latest.length && user?.id === subject.id} - <div class="relative"> - {#each latest as { content, pubkey, author, recipient }} - {@const u = author.id === user.id ? recipient : author} - <a href={`/messages/${u.username}`}> - <div class="flex hover:bg-gray-100 p-4 rounded-2xl"> - <div class="my-auto"> - <Avatar user={u} size={20} disabled={true} /> - </div> - <div class="my-auto truncate"> - <div class="my-auto ml-1 text-lg font-bold">{u.username}</div> - <div class="my-auto ml-1 text-secondary text-lg truncate">{content}</div> - </div> - </div> - </a> - {/each} - </div> +<div class="container mx-auto max-w-xl px-4 space-y-4"> + {#if chats.length === 0} + <p class="text-center text-secondary">{$t("dm.noChats")}</p> {:else} - <div class="text-center">{$t("user.noMessages")}</div> + {#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> + </div> + </a> + {/each} {/if} </div> diff --git a/src/routes/(app)/messages/[pubkey]/+page.svelte b/src/routes/(app)/messages/[pubkey]/+page.svelte new file mode 100644 index 00000000..0efa6206 --- /dev/null +++ b/src/routes/(app)/messages/[pubkey]/+page.svelte @@ -0,0 +1,394 @@ +<script lang="ts"> + 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/nip17"; + import { pTagKeys } from "$lib/nostr"; + import { t } from "$lib/translations"; + import { theme } from "$lib/store"; + + 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"); + }; + + 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 = ""; + try { + await libnip17.send(message, user, selectedChat); + text = ""; + updateEvents(); + await tick(); + document.getElementById("message-contents")?.focus(); + } catch (e: any) { + console.error("Failed to send message:", e); + sendError = $t("dm.sendFailed"); + } + }; + + updateEvents(); + + onMount(() => { + 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; + + onMount(() => { + libnip17.subscribeToMessages(user, async (rumour) => { + if (!allRumours.find((r) => r.id === rumour.id)) { + libnip17.cacheRumour(user.pubkey, rumour); + allRumours = [...allRumours, rumour]; + } + + if ( + (rumour.pubkey === user.pubkey && pTagKeys(rumour).includes(pubkey)) || + (rumour.pubkey === pubkey && pTagKeys(rumour).includes(user.pubkey)) + ) { + const dayKey = Math.floor(rumour.created_at / 86400); + if (messageRumours.has(dayKey)) { + const dayMsgs = messageRumours.get(dayKey)!; + if (!dayMsgs.find((r: any) => r.id === rumour.id)) { + dayMsgs.push(rumour); + dayMsgs.sort((a: any, b: any) => a.created_at - b.created_at); + } + } else { + messageRumours.set(dayKey, [rumour]); + } + dates = Array.from(messageRumours.keys()).sort((a: number, b: number) => a - b); + messageRumours = new Map(messageRumours); + + await tick(); + scrollChat(); + } + }).then((close) => (closeSub = close)); + }); + + onDestroy(() => closeSub?.()); +</script> + +<div class="container mx-auto max-w-xl px-4 space-y-2"> + {#if selectedChat} + {@const chatProfile = selectedChat.coinosUsername ? `/${selectedChat.coinosUsername}` : null} + <a href={chatProfile} class="flex items-center gap-3 mb-2 hover:opacity-80"> + {#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"> + {#each dates as day} + <p class="date-header secondary">{formatDate(new Date(day * 86400 * 1000))}</p> + <ul> + {#each messageRumours.get(day) ?? [] as rumour, i} + {@const dayMessages = messageRumours.get(day) ?? []} + {@const next = dayMessages[i + 1]} + {@const isLastInRun = !next || next.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"} + > + {rumour.content} + <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"} + > + {rumour.content} + <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"> + <textarea + id="message-contents" + bind:value={text} + use:focus + onkeydown={(e) => { + if (e.key === "Enter" && !e.shiftKey) { + e.preventDefault(); + if (canSend && text.trim()) sendMessage(text); + } + }} + ></textarea> + <input + id="send-message" + type="button" + class="btn btn-accent" + disabled={!canSend} + value={canSend + ? $t("dm.sendMessage") + : $t("dm.relaysNotFound").replace("[R]", selectedChat ? name(selectedChat) : "")} + onclick={async () => sendMessage(text)} + /> + {#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> + .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 { + overflow-y: auto; + max-height: 50vh; + scrollbar-width: thin; + } + + #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; + } + + .light-message { + background-color: #ddd; + } + + .dark-message { + background-color: #222; + } + + .input-bar { + padding: 0.5rem 0; + display: flex; + flex-direction: column; + gap: 0.5rem; + } +</style> diff --git a/src/routes/(app)/messages/[recipient]/+page.server.ts b/src/routes/(app)/messages/[recipient]/+page.server.ts deleted file mode 100644 index feb0d86f..00000000 --- a/src/routes/(app)/messages/[recipient]/+page.server.ts +++ /dev/null @@ -1,22 +0,0 @@ -import { get } from "$lib/utils"; -import type { Message } from "$lib/types"; - -export async function load({ params, parent, url }) { - const { user } = await parent(); - const since = url.searchParams.get("since") || 0; - - let messages: Message[] = []; - const recipient = await get(`/users/${params.recipient}`); - - try { - messages = await get(`/${user.pubkey}/${since}/messages`); - - messages = messages - .filter((m: Message) => m.recipient?.id === recipient.id || m.author?.id === recipient.id) - .sort((a, b) => a.created_at - b.created_at); - } catch (e) { - console.log("failed to fetch nostr messages", e); - } - - return { messages, recipient, user }; -} diff --git a/src/routes/(app)/messages/[recipient]/+page.svelte b/src/routes/(app)/messages/[recipient]/+page.svelte deleted file mode 100644 index 29c4478a..00000000 --- a/src/routes/(app)/messages/[recipient]/+page.svelte +++ /dev/null @@ -1,189 +0,0 @@ -<script lang="ts"> - import { run, preventDefault } from "svelte/legacy"; - - import { cubicInOut } from "svelte/easing"; - import { browser } from "$app/environment"; - import { t } from "$lib/translations"; - import { format } from "date-fns"; - import { enhance } from "$app/forms"; - import Avatar from "$comp/Avatar.svelte"; - import { back, fail, focus } from "$lib/utils"; - import { sign, send, encrypt, decrypt } from "$lib/nostr"; - import { event as e, password } from "$lib/store"; - import { tick, onMount } from "svelte"; - - let { data } = $props(); - - function fadeScale( - node: any, - { delay = 0, duration = 200, easing = (x: number) => x, baseScale = 0 }, - ) { - const o = +getComputedStyle(node).opacity; - const m = getComputedStyle(node).transform.match(/scale\(([0-9.]+)\)/); - const s: any = m ? m[1] : 1; - const is = 1 - baseScale; - - return { - delay, - duration, - css: (t) => { - const eased = easing(t); - return `opacity: ${eased * o}; transform: scale(${eased * s * is + baseScale})`; - }, - }; - } - - let messages: any = $state((() => data.messages)()); - let recipient: any = $state((() => data.recipient)()); - let user: any = $state((() => data.user)()); - let input: any, - pane: any = $state(); - - let initialize = async (p: any) => { - await Promise.all( - messages.map(async (event) => (event.message = await decrypt({ event, user }))), - ); - - messages = messages; - tick().then(() => pane && (pane.scrollTop = pane.scrollHeight)); - }; - - e.subscribe(async (event: any) => { - let found = ~messages.findIndex((m: any) => m.id === event?.id); - if (event?.recipient.id === user.id && !found) { - event.message = await decrypt({ event, user }); - messages.push(event); - messages = messages; - tick().then(() => pane && (pane.scrollTop = pane.scrollHeight)); - } - }); - - let sent: any, - submitting: any, - message: any = $state(); - let submit = async () => { - submitting = true; - - let event: any = { - pubkey: user.pubkey, - created_at: Math.floor(Date.now() / 1000), - kind: 4, - tags: [["p", recipient.pubkey]], - }; - - event.message = message; - - message = ""; - - event.author = user; - event.recipient = recipient; - - messages.push(event); - messages = messages; - tick().then(() => pane && (pane.scrollTop = pane.scrollHeight)); - - try { - event.content = await encrypt({ - message: event.message, - recipient: recipient.pubkey, - user, - }); - const { getEventHash } = await import("nostr-tools"); - event.id = getEventHash(event); - await sign(event); - await send(event); - - sent = true; - } catch (err: any) { - console.log(err); - fail("Failed to send message"); - } - - message = ""; - submitting = false; - }; - - let keydown = (e: any) => { - if (e.key === "Enter") { - e.preventDefault(); - submit(); - } - }; - run(() => { - initialize($password); - }); -</script> - -<div class="container max-w-xl mx-auto px-4 space-y-5"> - <button type="button" class="hover:opacity-80" data-sveltekit-preload-data="false" onclick={back} aria-label="Go back"> - <iconify-icon noobserver icon="ph:arrow-left-bold" width="40"></iconify-icon> - </button> - - <div - class="h-[50vh] max-h-[50vh] overflow-y-scroll scrollbar-thin scrollbar-thumb-[#F2F6FC] scrollbar-track-white pr-8" - bind:this={pane} - > - {#each messages as { id, author, message, created_at, pubkey }} - {@const ours = pubkey === user.pubkey} - {@const theirs = !ours} - - {#if message} - <div - class="flex gap-2" - class:flex-row-reverse={theirs} - class:justify-end={theirs} - in:fadeScale={{ - easing: cubicInOut, - baseScale: 0.5, - }} - > - <div class="rounded-2xl px-4 py-2 max-w-[300px] mb-1 text-lg" class:ours class:theirs> - {message} - </div> - <div class="mt-auto"> - <Avatar user={author} size={12} /> - </div> - </div> - <div class="text-sm text-gray-400 mb-6" class:text-right={ours}> - {format(new Date(created_at * 1000), "MMM d")}, {format(new Date(created_at * 1000), "h:mm aa")} - </div> - {/if} - {/each} - </div> - - <form - method="POST" - class="w-full border rounded-xl outline-none gap-4 flex p-0 pr-2" - onsubmit={preventDefault(submit)} - > - <input type="hidden" name="requester_id" value={user.id} /> - <input type="hidden" name="recipient" value={recipient.username} /> - - <div - use:focus - contenteditable - class="grow break-all py-4 outline-none mt-0 pl-4" - bind:innerHTML={message} - onkeydown={keydown} - role="textbox" - aria-multiline="true" - aria-label="Message" - tabindex="0" - ></div> - <button type="submit" class="my-auto shrink-0" aria-label="Send message"> - <iconify-icon noobserver icon="ph:paper-plane-right-bold" width="32"></iconify-icon> - </button> - </form> -</div> - -<style> - @reference "../../../../app.css"; - - .ours { - @apply bg-gradient-to-r from-[#F2F6FC] to-[#E1E3FF] text-black rounded-br-none ml-auto; - } - - .theirs { - @apply bg-gray-100 text-black rounded-bl-none; - } -</style> diff --git a/src/routes/(static)/register/auth/+page.svelte b/src/routes/(static)/register/auth/+page.svelte index afb39953..232f4290 100644 --- a/src/routes/(static)/register/auth/+page.svelte +++ b/src/routes/(static)/register/auth/+page.svelte @@ -186,6 +186,7 @@ let passkeyRegister = async () => { passkeyLoading = true; + let passkeyToken: string | null = null; try { let pw = Array.from(crypto.getRandomValues(new Uint8Array(32)), (b) => b.toString(16).padStart(2, "0"), @@ -212,8 +213,10 @@ return; } - const { token, sk } = result.data; - const { prfKey, credentialId } = await registerPasskey(token); + passkeyToken = result.data.token; + const passkeySk = result.data.sk; + + const { prfKey, credentialId } = await registerPasskey(passkeyToken!); // Generate canonical key and derive nostr key before activate // so we can pass the pubkey to the server-side activate action @@ -230,9 +233,9 @@ } const activateData = new FormData(); - activateData.append("token", token); + activateData.append("token", passkeyToken!); activateData.append("username", username); - if (sk) activateData.append("sk", sk); + if (passkeySk) activateData.append("sk", passkeySk); if (nostrPubkey) activateData.append("pubkey", nostrPubkey); activateData.append("loginRedirect", (redirect ?? "") as string); @@ -261,6 +264,19 @@ applyAction(activateResult); } catch (e: any) { + // Clean up the account created by passkeyCreate if passkey registration failed + if (passkeyToken) { + try { + await fetch("/post/user/delete", { + method: "POST", + headers: { + "Content-Type": "application/json", + authorization: `Bearer ${passkeyToken}`, + }, + body: JSON.stringify({}), + }); + } catch {} + } if (e.name !== "NotAllowedError") { fail(e.message || "Passkey registration failed"); } |
