diff options
| author | Adam Soltys <[email protected]> | 2026-03-01 18:51:16 -0800 |
|---|---|---|
| committer | Adam Soltys <[email protected]> | 2026-03-01 18:51:16 -0800 |
| commit | c7e2383736d3ef99f069226bbafad9fbddfeb030 (patch) | |
| tree | e71947a1e627cd1a4802808661ed88866a234f81 | |
| parent | e3b7208ae23d7b56126b0b32b66dfa48140dc1f2 (diff) | |
| download | coinos-ui-c7e2383736d3ef99f069226bbafad9fbddfeb030.tar.gz coinos-ui-c7e2383736d3ef99f069226bbafad9fbddfeb030.zip | |
messages ui
| -rw-r--r-- | src/lib/nip17.ts | 52 | ||||
| -rw-r--r-- | src/lib/seed.ts | 41 | ||||
| -rw-r--r-- | src/routes/(app)/+layout.svelte | 3 | ||||
| -rw-r--r-- | src/routes/(app)/dm/+page.svelte | 369 | ||||
| -rw-r--r-- | src/routes/(static)/register/auth/+page.svelte | 38 | ||||
| -rw-r--r-- | test/e2e/dm-subscribe.spec.ts | 139 | ||||
| -rw-r--r-- | test/unified-seed.test.ts | 41 |
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); + }); +}); |
