aboutsummaryrefslogtreecommitdiffstats
path: root/src
diff options
context:
space:
mode:
authorAdam Soltys <[email protected]>2026-03-01 21:09:04 -0800
committerAdam Soltys <[email protected]>2026-03-01 21:09:04 -0800
commitcbbd98ae5446b11f2a432dee7ffea291cdf2ee21 (patch)
treea397fe8afa5527cf9d86e4fd854d69d8f56fabfe /src
parentc7e2383736d3ef99f069226bbafad9fbddfeb030 (diff)
downloadcoinos-ui-cbbd98ae5446b11f2a432dee7ffea291cdf2ee21.tar.gz
coinos-ui-cbbd98ae5446b11f2a432dee7ffea291cdf2ee21.zip
messages
Diffstat (limited to 'src')
-rw-r--r--src/components/Menu.svelte2
-rw-r--r--src/lib/nip17.ts44
-rw-r--r--src/routes/(app)/[username]/(profile)/+layout.svelte2
-rw-r--r--src/routes/(app)/dm/+page.svelte694
-rw-r--r--src/routes/(app)/messages/+layout.svelte56
-rw-r--r--src/routes/(app)/messages/+page.server.ts43
-rw-r--r--src/routes/(app)/messages/+page.svelte200
-rw-r--r--src/routes/(app)/messages/[pubkey]/+page.svelte394
-rw-r--r--src/routes/(app)/messages/[recipient]/+page.server.ts22
-rw-r--r--src/routes/(app)/messages/[recipient]/+page.svelte189
-rw-r--r--src/routes/(static)/register/auth/+page.svelte24
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");
}