aboutsummaryrefslogtreecommitdiffstats
diff options
context:
space:
mode:
authorAdam Soltys <[email protected]>2026-03-01 18:51:16 -0800
committerAdam Soltys <[email protected]>2026-03-01 18:51:16 -0800
commitc7e2383736d3ef99f069226bbafad9fbddfeb030 (patch)
treee71947a1e627cd1a4802808661ed88866a234f81
parente3b7208ae23d7b56126b0b32b66dfa48140dc1f2 (diff)
downloadcoinos-ui-c7e2383736d3ef99f069226bbafad9fbddfeb030.tar.gz
coinos-ui-c7e2383736d3ef99f069226bbafad9fbddfeb030.zip
messages ui
-rw-r--r--src/lib/nip17.ts52
-rw-r--r--src/lib/seed.ts41
-rw-r--r--src/routes/(app)/+layout.svelte3
-rw-r--r--src/routes/(app)/dm/+page.svelte369
-rw-r--r--src/routes/(static)/register/auth/+page.svelte38
-rw-r--r--test/e2e/dm-subscribe.spec.ts139
-rw-r--r--test/unified-seed.test.ts41
7 files changed, 510 insertions, 173 deletions
diff --git a/src/lib/nip17.ts b/src/lib/nip17.ts
index 29a14549..1400b5a5 100644
--- a/src/lib/nip17.ts
+++ b/src/lib/nip17.ts
@@ -302,6 +302,58 @@ const createAndPublishMessageEvent = async (
return publishToPreferred(event, eventFor, expiryDays != null);
};
+// Subscribes to new incoming gift wraps for the user.
+// Decrypts new events and passes the rumour to onNewRumour().
+// Returns a close function to stop the subscription.
+// Uses Relay directly instead of SimplePool.subscribeMany to avoid
+// a filter double-nesting bug in nostr-tools v2.23.0.
+export const subscribeToMessages = async (
+ user: any,
+ onNewRumour: (rumour: any) => void,
+): Promise<() => void> => {
+ const preferredRelays = await getPreferredRelays(user.pubkey);
+ const relayUrls = Array.from(new Set([...preferredRelays, ...DM_RELAYS_LIST]));
+ const since = Math.floor(Date.now() / 1000) - TWO_DAYS;
+ const seen = new Set<string>();
+ let eoseCount = 0;
+
+ const closers: (() => void)[] = [];
+
+ for (const url of relayUrls) {
+ try {
+ const relay = await Relay.connect(url);
+ const sub = relay.subscribe(
+ [{ kinds: [1059], "#p": [user.pubkey], since }],
+ {
+ onevent: async (event) => {
+ if (seen.has(event.id)) return;
+ seen.add(event.id);
+ if (eoseCount < relayUrls.length) return;
+ if (expired(event)) return;
+ try {
+ const rumour: any = await decrypt(event, user);
+ const expires = expiration(event);
+ if (expires) rumour.expiration = expires;
+ onNewRumour(rumour);
+ } catch (e) {
+ console.error("Failed to decrypt subscription event:", event.id, e);
+ }
+ },
+ oneose: () => {
+ eoseCount++;
+ },
+ },
+ );
+ closers.push(() => sub.close());
+ } catch (e) {
+ console.error("Failed to connect to relay for subscription:", url, e);
+ eoseCount++;
+ }
+ }
+
+ return () => closers.forEach((close) => close());
+};
+
// Sends a NIP-17 message from `user` to `recipient`.
// If expiryDays is set, messages will expire after that many days.
export const send = async (message: string, user: any, recipient: any, expiryDays?: number) => {
diff --git a/src/lib/seed.ts b/src/lib/seed.ts
new file mode 100644
index 00000000..3d58bcb5
--- /dev/null
+++ b/src/lib/seed.ts
@@ -0,0 +1,41 @@
+import { getWalletEntropy } from "$lib/walletEntropy";
+import { signer } from "$lib/store";
+
+export async function deriveNostrKey(entropy: ArrayBuffer) {
+ const [{ entropyToMnemonic }, { wordlist }, { privateKeyFromSeedWords }, { getPublicKey }] =
+ await Promise.all([
+ import("@scure/bip39"),
+ import("@scure/bip39/wordlists/english.js"),
+ import("nostr-tools/nip06"),
+ import("nostr-tools"),
+ ]);
+
+ const ent = new Uint8Array(entropy).slice(0, 16);
+ const mnemonic = entropyToMnemonic(ent, wordlist);
+ const sk = privateKeyFromSeedWords(mnemonic);
+ const pubkey = getPublicKey(sk);
+ return { sk, pubkey };
+}
+
+export async function autoUnlockNostr(userPubkey: string) {
+ if (localStorage.getItem("nsec")) return;
+
+ const entropy = await getWalletEntropy();
+ if (!entropy) return;
+
+ try {
+ const { sk, pubkey } = await deriveNostrKey(entropy);
+ if (pubkey !== userPubkey) return;
+
+ const { nip19 } = await import("nostr-tools");
+ const { bytesToHex } = await import("@noble/hashes/utils.js");
+ localStorage.setItem("nsec", nip19.nsecEncode(sk));
+ signer.set({
+ method: "nsec",
+ params: { sk: bytesToHex(sk), pk: pubkey },
+ ready: true,
+ });
+ } catch (e) {
+ console.error("Nostr auto-unlock failed:", e);
+ }
+}
diff --git a/src/routes/(app)/+layout.svelte b/src/routes/(app)/+layout.svelte
index e06ec990..40f28444 100644
--- a/src/routes/(app)/+layout.svelte
+++ b/src/routes/(app)/+layout.svelte
@@ -185,6 +185,9 @@
// Auto-unlock ark wallet if silent entropy is available
if (!$arkkey) autoUnlockArk();
+
+ // Auto-unlock nostr key if silent entropy is available
+ import("$lib/seed").then(({ autoUnlockNostr }) => autoUnlockNostr(user.pubkey));
}
// if (window.NDEFReader) {
diff --git a/src/routes/(app)/dm/+page.svelte b/src/routes/(app)/dm/+page.svelte
index b7026f94..1453ccd6 100644
--- a/src/routes/(app)/dm/+page.svelte
+++ b/src/routes/(app)/dm/+page.svelte
@@ -1,5 +1,5 @@
<script lang="ts">
- import { tick, onMount } from "svelte";
+ import { tick, onMount, onDestroy } from "svelte";
import { isValid, queryProfile } from "nostr-tools/nip05";
import * as toolsnip17 from "nostr-tools/nip17";
@@ -9,11 +9,23 @@
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 { 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);
@@ -24,14 +36,13 @@
})();
/// chat selection
- const usernameFromPubkey = async (pubkey: string): Promise<string | null> => {
+ const coinosUserFromPubkey = async (pubkey: string): Promise<any> => {
const response = await fetch(`/api/users/${pubkey}`);
if (response.ok) {
- const userInfo = await response.json();
- return userInfo.anon ? null : userInfo.username;
- } else {
- return null;
+ const info = await response.json();
+ return info.anon ? null : info;
}
+ return null;
};
const userInfo = async (pubkey: string) => {
@@ -41,9 +52,10 @@
const nostrUserInfo = (await getNostrUserInfo(pubkey)) || {};
const valid = "nip05" in nostrUserInfo && (await isValid(pubkey, nostrUserInfo.nip05));
- const username = await usernameFromPubkey(pubkey);
+ const coinosUser = await coinosUserFromPubkey(pubkey);
return {
- coinosUsername: username,
+ coinosUsername: coinosUser?.username || null,
+ picture: coinosUser?.picture || nostrUserInfo?.picture || null,
nostrName: nostrUserInfo?.name,
nip05: nostrUserInfo?.nip05,
nip05Valid: valid,
@@ -55,18 +67,6 @@
return userInfo.coinosUsername || userInfo.nostrName || $t("dm.anonymous");
};
- const id = (userInfo: any): string => {
- if (userInfo.nip05 && userInfo.nip05.slice(0, 2) == "_@") {
- return userInfo.nip05.slice(2);
- }
-
- return userInfo.nip05 || npubEncode(userInfo.pubkey).slice(0, 16) + "...";
- };
-
- const idValid = (userInfo: any): boolean => {
- return !userInfo.nip05 || userInfo.nip05Valid;
- };
-
const rumourInvolves = (rumour: any, pubkey: string) => {
if (rumour.pubkey === pubkey) {
return true;
@@ -135,10 +135,7 @@
let messageRumours: any = $state([]);
let dates: any[] = $state([]);
let relayWarningShown = $state(false);
- let expiryEnabled = $state(false);
- let expiryDays = $state(7);
let canSend = $state(false);
- let canSendExpiring = $state(false);
let nostrUserInfo: any = $state({});
const selectedChatEmpty = () => !messageRumours || messageRumours.size == 0;
@@ -175,7 +172,6 @@
userInfo(selectedChat.pubkey).then((info) => (nostrUserInfo = info));
libnip17.getPreferredRelays(selectedChat.pubkey).then((relays) => {
canSend = relays && relays.length > 0;
- relaysSupporting(relays, [40]).then((supporting) => (canSendExpiring = supporting.length > 0));
});
updateEvents();
};
@@ -248,10 +244,7 @@
dates.sort((d1, d2) => d1 - d2);
await tick();
- const messages = document.getElementById("messages");
- if (messages) {
- messages.scrollTop = messages.scrollHeight;
- }
+ scrollChat();
};
const TIME = new Intl.DateTimeFormat(undefined, {
@@ -279,19 +272,16 @@
}
};
- const expirationClose = (expiration: number) => {
- return expiration <= Date.now() / 1000 + 2 * 86400;
- };
-
let sendError = $state("");
const sendMessage = async (message: string) => {
sendError = "";
try {
- const expiry = expiryEnabled ? expiryDays : undefined;
- await libnip17.send(message, user, selectedChat, expiry);
+ 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");
@@ -306,17 +296,55 @@
relayWarningShown = false;
}))();
- let muted = $state(new Set<string>());
- (() => mutedAccounts(data.user).then((m) => (muted = m)))();
+ let closeSub: (() => void) | undefined;
- const toggleMute = async (pubkey: string) => {
- if (muted && muted.has(pubkey)) {
- await unmute(user, pubkey);
- } else {
- await mute(user, pubkey, true);
- }
- muted = await mutedAccounts(user);
- };
+ 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">
@@ -346,23 +374,21 @@
{/if}
{#if !creatingNewChat}
{#each chats as c}
- {#if (selectedChat && selectedChat.pubkey === c.pubkey) || !(muted && muted.has(c.pubkey))}
- <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)}
- >
- <span class={"text-xl" + (muted && muted.has(c.pubkey) ? " secondary" : "")}>
- {name(c)}
- </span>
- <span class={(idValid(c) ? "" : "invalid ") + "secondary text-xs"}>{id(c)}</span>
- </button>
- {/if}
+ <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
@@ -397,66 +423,65 @@
</h1>
{#if selectedChat}
- <h1>
- {#if selectedChat.coinosUsername}
- <a class="text-3xl" href="/{selectedChat.coinosUsername}">{name(selectedChat)}</a>
- {:else}
- <span class="text-3xl">{name(selectedChat)}</span>
- {/if}
- <span
- class={"secondary timestamp text-small" + (idValid(selectedChat) ? "" : " invalid")}
- title={npubEncode(selectedChat.pubkey)}
- >
- {id(selectedChat)}
- </span>
- <button
- class={"small-btn push-right " +
- ($theme === "light" ? "light-chat-btn" : "dark-chat-btn")}
- onclick={() => toggleMute(selectedChat.pubkey)}
- >
- {$t(muted && muted.has(selectedChat.pubkey) ? "dm.unmute" : "dm.mute")}
- </button>
- </h1>
-
- {#if !(muted && muted.has(selectedChat.pubkey))}
- <div id="messages" style="overflow-y: scroll; max-height: 600px;">
- {#each dates as day}
- <p class="date-header secondary">{formatDate(new Date(day * 86400 * 1000))}</p>
- <ul>
- {#each messageRumours.get(day) as rumour}
- {#if rumour.pubkey === user.pubkey && pTagKeys(rumour).includes(selectedChat.pubkey)}
- <li
- class={(rumour.expiration && expirationClose(rumour.expiration)
- ? "secondary "
- : "") +
- ($theme === "light" ? "light-message " : "dark-message ") +
- "message by-user"}
+ <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>
- </li>
- {:else if rumour.pubkey === selectedChat.pubkey && pTagKeys(rumour).includes(user.pubkey)}
- <li
- class={(rumour.expiration && expirationClose(rumour.expiration)
- ? "secondary "
- : "") +
- ($theme === "light" ? "light-message " : "dark-message ") +
- "message by-other"}
+ </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>
- </li>
- {/if}
- {/each}
- </ul>
- {/each}
- </div>
+ </div>
+ </li>
+ {/if}
+ {/each}
+ </ul>
+ {/each}
+ <div id="messages-end"></div>
+ </div>
- <textarea id="message-contents" bind:value={text}></textarea>
+ <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"
@@ -470,36 +495,7 @@
{#if sendError}
<p class="warning"><em>{sendError}</em></p>
{/if}
- {/if}
-
- {#if !(muted && muted.has(selectedChat.pubkey))}
- <input
- type="checkbox"
- id="expiryCheckbox"
- class="tiny"
- bind:checked={expiryEnabled}
- disabled={!canSendExpiring}
- />
- <label for="expiryCheckbox">{$t("dm.expiry")}</label>
- :
- <input
- type="number"
- class="short small-width vcenter"
- bind:value={expiryDays}
- disabled={!expiryEnabled}
- min="1"
- step="1"
- max="99999"
- />
- <label for="expiryCheckbox">{$t("dm.days")}</label>
- .
- <p class="secondary">{$t("dm.fadedWarning")}</p>
- {#if canSend && !canSendExpiring}
- <p class="warning">
- <em>{$t("dm.cantSendExpiring").replace("[R]", name(selectedChat))}</em>
- </p>
- {/if}
- {/if}
+ </div>
{:else if chats.length == 0}
<p>{$t("dm.noChats")}</p>
{:else}
@@ -529,27 +525,11 @@
text-decoration-line: underline;
}
- .inline {
- white-space: nowrap;
- }
-
.short {
height: 1.5em;
margin-bottom: 10px;
}
- .small-width {
- width: 6em;
- }
-
- .tiny {
- width: 2em;
- }
-
- .invalid {
- text-decoration: line-through;
- }
-
.super-container {
display: flex;
}
@@ -581,12 +561,17 @@
.tall-btn {
min-height: 3em;
width: 100%;
+ display: flex;
+ align-items: center;
+ gap: 0.5rem;
}
- .small-btn {
- padding: 0 15px;
- border-radius: 5px;
- text-align: left;
+ .sidebar-avatar {
+ width: 32px;
+ height: 32px;
+ border-radius: 9999px;
+ object-fit: cover;
+ flex-shrink: 0;
}
.push-right {
@@ -597,6 +582,35 @@
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;
}
@@ -636,6 +650,25 @@
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;
@@ -651,15 +684,11 @@
background-color: #222;
}
- .by-user {
- margin: 3px 0px 3px auto;
- }
-
- .by-other {
- margin: 3px auto 3px 0px;
+ .input-bar {
+ padding: 0.5rem 0;
+ display: flex;
+ flex-direction: column;
+ gap: 0.5rem;
}
- .vcenter {
- margin: auto 0px;
- }
</style>
diff --git a/src/routes/(static)/register/auth/+page.svelte b/src/routes/(static)/register/auth/+page.svelte
index 32539079..afb39953 100644
--- a/src/routes/(static)/register/auth/+page.svelte
+++ b/src/routes/(static)/register/auth/+page.svelte
@@ -13,7 +13,7 @@
import PasswordInput from "$comp/PasswordInput.svelte";
import Spinner from "$comp/Spinner.svelte";
- import { fail, focus } from "$lib/utils";
+ import { fail, focus, post } from "$lib/utils";
import { registerPasskey } from "$lib/passkey";
import { rememberPrfKey, defaultRememberForMs } from "$lib/passwordCache";
import { avatar, signer, password, pin, loginRedirect } from "$lib/store";
@@ -158,8 +158,21 @@
const { sk } = await deriveAuthKeypair(user.username, user.password);
const canonicalKey = generateCanonicalKey();
const wrapped = await wrapCanonicalKey(sk.buffer as ArrayBuffer, canonicalKey);
- await saveEncryptedKeys({ [passwordMethodId()]: wrapped });
rememberPrfKey(canonicalKey, defaultRememberForMs);
+
+ const encryptedKeys = { [passwordMethodId()]: wrapped };
+ let nostrPubkey: string | undefined;
+ try {
+ const { deriveNostrKey } = await import("$lib/seed");
+ const { nip19 } = await import("nostr-tools");
+ const nostr = await deriveNostrKey(canonicalKey);
+ localStorage.setItem("nsec", nip19.nsecEncode(nostr.sk));
+ nostrPubkey = nostr.pubkey;
+ } catch (e) {
+ console.log("Nostr key derivation failed", e);
+ }
+
+ await post("/post/user", nostrPubkey ? { encryptedKeys, pubkey: nostrPubkey } : { encryptedKeys });
} catch (e) {
console.log("Canonical key generation failed", e);
}
@@ -202,10 +215,25 @@
const { token, sk } = result.data;
const { prfKey, credentialId } = await registerPasskey(token);
+ // Generate canonical key and derive nostr key before activate
+ // so we can pass the pubkey to the server-side activate action
+ const canonicalKey = generateCanonicalKey();
+ let nostrPubkey: string | undefined;
+ let nostrSk: Uint8Array | undefined;
+ try {
+ const { deriveNostrKey } = await import("$lib/seed");
+ const nostr = await deriveNostrKey(canonicalKey);
+ nostrPubkey = nostr.pubkey;
+ nostrSk = nostr.sk;
+ } catch (e) {
+ console.log("Nostr key derivation failed", e);
+ }
+
const activateData = new FormData();
activateData.append("token", token);
activateData.append("username", username);
if (sk) activateData.append("sk", sk);
+ if (nostrPubkey) activateData.append("pubkey", nostrPubkey);
activateData.append("loginRedirect", (redirect ?? "") as string);
const activateResponse = await fetch("/register/auth?/activate", {
@@ -217,10 +245,14 @@
if (activateResult.type === "success" || activateResult.type === "redirect") {
try {
- const canonicalKey = generateCanonicalKey();
const wrapped = await wrapCanonicalKey(prfKey, canonicalKey);
await saveEncryptedKeys({ [passkeyMethodId(credentialId)]: wrapped });
rememberPrfKey(canonicalKey, defaultRememberForMs);
+
+ if (nostrSk) {
+ const { nip19 } = await import("nostr-tools");
+ localStorage.setItem("nsec", nip19.nsecEncode(nostrSk));
+ }
} catch (e) {
console.log("Canonical key generation failed", e);
}
diff --git a/test/e2e/dm-subscribe.spec.ts b/test/e2e/dm-subscribe.spec.ts
new file mode 100644
index 00000000..70fbae76
--- /dev/null
+++ b/test/e2e/dm-subscribe.spec.ts
@@ -0,0 +1,139 @@
+import { expect, test } from "@playwright/test";
+import { login, bobUsername, bobPassword, aliceUsername, alicePassword } from "./helpers";
+
+test("recipient receives DM via relay subscription in real-time", async ({ browser }) => {
+ test.setTimeout(60_000);
+
+ // --- Set up Alice (recipient) ---
+ const aliceContext = await browser.newContext();
+ const alicePage = await aliceContext.newPage();
+
+ const aliceConsole: string[] = [];
+ alicePage.on("console", (msg) => aliceConsole.push(`[${msg.type()}] ${msg.text()}`));
+
+ // Track WebSocket frames on Alice's relay connections
+ const aliceWsFrames: { url: string; dir: string; text: string; time: number }[] = [];
+ alicePage.on("websocket", (ws) => {
+ const url = ws.url();
+ console.log(`[alice-ws] connected: ${url}`);
+ ws.on("framesent", (frame) => {
+ aliceWsFrames.push({ url, dir: "sent", text: frame.payload.toString(), time: Date.now() });
+ });
+ ws.on("framereceived", (frame) => {
+ aliceWsFrames.push({ url, dir: "recv", text: frame.payload.toString(), time: Date.now() });
+ });
+ });
+
+ await login(alicePage, aliceUsername, alicePassword);
+ await alicePage.goto(`/dm?username=${bobUsername}`);
+ await alicePage.waitForLoadState("domcontentloaded");
+
+ const aliceTextarea = alicePage.locator("#message-contents");
+ await expect(aliceTextarea).toBeVisible({ timeout: 15_000 });
+
+ // Wait for subscription to be fully established (EOSE received)
+ await alicePage.waitForTimeout(3_000);
+
+ // Verify subscription REQ was sent correctly (NOT double-nested)
+ const subReqs = aliceWsFrames.filter(
+ (f) => f.dir === "sent" && f.text.includes('"REQ"') && f.text.includes("1059") && f.text.includes('"since"'),
+ );
+ console.log(`[alice] Subscription REQs: ${subReqs.length}`);
+ for (const req of subReqs) {
+ console.log(`[alice] ${req.url}: ${req.text.slice(0, 200)}`);
+ // Verify filter is NOT double-nested (the bug was [[{...}]] instead of [{...}])
+ const parsed = JSON.parse(req.text);
+ // REQ format: ["REQ", subId, filter1, filter2, ...]
+ // Filter should be an object, not an array
+ expect(typeof parsed[2], "Filter should be an object, not double-nested array").toBe("object");
+ expect(Array.isArray(parsed[2]), "Filter should not be an array").toBeFalsy();
+ }
+ expect(subReqs.length, "Should have at least one subscription REQ").toBeGreaterThanOrEqual(1);
+
+ // Verify EOSE was received for the subscription
+ const subEoses = aliceWsFrames.filter(
+ (f) => f.dir === "recv" && f.text.includes('"EOSE"'),
+ );
+ console.log(`[alice] EOSE messages: ${subEoses.length}`);
+ expect(subEoses.length, "Should have received EOSE").toBeGreaterThanOrEqual(1);
+
+ // Record the timestamp before Bob sends
+ const beforeSend = Date.now();
+
+ // --- Set up Bob (sender) ---
+ const bobContext = await browser.newContext();
+ const bobPage = await bobContext.newPage();
+
+ const bobWsFrames: string[] = [];
+ bobPage.on("websocket", (ws) => {
+ ws.on("framereceived", (frame) => bobWsFrames.push(frame.payload.toString()));
+ });
+
+ await login(bobPage, bobUsername, bobPassword);
+ await bobPage.goto(`/dm?username=${aliceUsername}`);
+ await bobPage.waitForLoadState("domcontentloaded");
+
+ const bobTextarea = bobPage.locator("#message-contents");
+ await expect(bobTextarea).toBeVisible({ timeout: 15_000 });
+
+ const sendButton = bobPage.locator("#send-message");
+ await expect(sendButton).toBeEnabled({ timeout: 5_000 });
+
+ // --- Bob sends a message ---
+ const testMessage = `realtime-test-${Date.now()}`;
+ console.log(`[bob] Sending: ${testMessage}`);
+ await bobTextarea.fill(testMessage);
+ await sendButton.click();
+ await expect(bobTextarea).toHaveValue("", { timeout: 15_000 });
+ console.log("[bob] Message sent");
+
+ // Verify Bob's relay accepted the events
+ const bobAccepts = bobWsFrames.filter((m) => m.includes('"OK"') && m.includes(",true,"));
+ console.log(`[bob] Relay accepted ${bobAccepts.length} events`);
+ expect(bobAccepts.length, "Relay should accept at least 1 event").toBeGreaterThanOrEqual(1);
+
+ // --- Check if Alice's subscription received the new event ---
+ // Wait a few seconds for the relay to deliver the event to Alice
+ await alicePage.waitForTimeout(5_000);
+
+ // Look for new EVENT messages received by Alice AFTER Bob sent
+ const newEvents = aliceWsFrames.filter(
+ (f) =>
+ f.dir === "recv" &&
+ f.text.includes('"EVENT"') &&
+ f.time > beforeSend,
+ );
+ console.log(`[alice] New EVENT frames received after Bob sent: ${newEvents.length}`);
+ for (const ev of newEvents) {
+ console.log(`[alice] ${ev.url}: ${ev.text.slice(0, 200)}`);
+ }
+
+ // Check specifically for kind 1059 events (gift wraps)
+ const newGiftWraps = newEvents.filter((f) => {
+ try {
+ const parsed = JSON.parse(f.text);
+ return parsed[2]?.kind === 1059;
+ } catch {
+ return false;
+ }
+ });
+ console.log(`[alice] New kind-1059 gift wraps received: ${newGiftWraps.length}`);
+
+ // The subscription should deliver at least one new gift-wrapped event
+ expect(
+ newGiftWraps.length,
+ "Alice's subscription should receive Bob's gift-wrapped message from the relay",
+ ).toBeGreaterThanOrEqual(1);
+
+ // Log any decrypt attempts from subscription handler
+ const subDecryptLogs = aliceConsole.filter(
+ (l) => l.includes("subscription event"),
+ );
+ if (subDecryptLogs.length) {
+ console.log("[alice] Subscription decrypt attempts:");
+ for (const l of subDecryptLogs) console.log(`[alice] ${l}`);
+ }
+
+ await bobContext.close();
+ await aliceContext.close();
+});
diff --git a/test/unified-seed.test.ts b/test/unified-seed.test.ts
index 4410dbea..1bdf35d3 100644
--- a/test/unified-seed.test.ts
+++ b/test/unified-seed.test.ts
@@ -10,6 +10,8 @@ import {
import { wordlist } from "@scure/bip39/wordlists/english.js";
import { bytesToHex, hexToBytes } from "@noble/hashes/utils.js";
import { encrypt, decrypt } from "nostr-tools/nip49";
+import { privateKeyFromSeedWords } from "nostr-tools/nip06";
+import { getPublicKey } from "nostr-tools";
const versions = {
private: 0x04358394,
@@ -217,3 +219,42 @@ describe("backward compatibility", () => {
expect(() => decrypt(encrypted, "wrongpassword")).toThrow();
});
});
+
+describe("NIP-06 nostr key derivation", () => {
+ const mnemonic = generateMnemonic(wordlist);
+
+ it("derives a deterministic nostr key from mnemonic via NIP-06 path", () => {
+ const sk = privateKeyFromSeedWords(mnemonic);
+ const pubkey = getPublicKey(sk);
+
+ expect(sk).toBeInstanceOf(Uint8Array);
+ expect(sk.length).toBe(32);
+ expect(pubkey).toMatch(/^[0-9a-f]{64}$/);
+
+ // Same mnemonic produces same key
+ const sk2 = privateKeyFromSeedWords(mnemonic);
+ expect(bytesToHex(sk2)).toBe(bytesToHex(sk));
+ });
+
+ it("derives nostr key from entropy round-trip", () => {
+ const entropy = mnemonicToEntropy(mnemonic, wordlist);
+ const recovered = entropyToMnemonic(entropy, wordlist);
+ expect(recovered).toBe(mnemonic);
+
+ const sk = privateKeyFromSeedWords(mnemonic);
+ const skRecovered = privateKeyFromSeedWords(recovered);
+ expect(bytesToHex(skRecovered)).toBe(bytesToHex(sk));
+ });
+
+ it("nostr NIP-06 key differs from Bitcoin and Ark keys", async () => {
+ const seed = await mnemonicToSeed(mnemonic, password);
+ const master = HDKey.fromMasterSeed(seed, versions);
+
+ const btcKey = bytesToHex(master.derive("m/84'/0'/0'").privateKey!);
+ const arkKey = bytesToHex(master.derive("m/86'/0'/0'/0/0").privateKey!);
+ const nostrKey = bytesToHex(privateKeyFromSeedWords(mnemonic));
+
+ expect(nostrKey).not.toBe(btcKey);
+ expect(nostrKey).not.toBe(arkKey);
+ });
+});