From c7e2383736d3ef99f069226bbafad9fbddfeb030 Mon Sep 17 00:00:00 2001 From: Adam Soltys Date: Sun, 1 Mar 2026 18:51:16 -0800 Subject: messages ui --- src/lib/nip17.ts | 52 ++++ src/lib/seed.ts | 41 +++ src/routes/(app)/+layout.svelte | 3 + src/routes/(app)/dm/+page.svelte | 371 +++++++++++++------------ src/routes/(static)/register/auth/+page.svelte | 38 ++- test/e2e/dm-subscribe.spec.ts | 139 +++++++++ test/unified-seed.test.ts | 41 +++ 7 files changed, 511 insertions(+), 174 deletions(-) create mode 100644 src/lib/seed.ts create mode 100644 test/e2e/dm-subscribe.spec.ts 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(); + 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 @@
@@ -346,23 +374,21 @@ {/if} {#if !creatingNewChat} {#each chats as c} - {#if (selectedChat && selectedChat.pubkey === c.pubkey) || !(muted && muted.has(c.pubkey))} - - {/if} + {/each} {:else if searchQuery !== ""} - - - {#if !(muted && muted.has(selectedChat.pubkey))} -
- {#each dates as day} -

{formatDate(new Date(day * 86400 * 1000))}

-
    - {#each messageRumours.get(day) as rumour} - {#if rumour.pubkey === user.pubkey && pTagKeys(rumour).includes(selectedChat.pubkey)} -
  • + {#each dates as day} +

    {formatDate(new Date(day * 86400 * 1000))}

    +
      + {#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)} +
    • +
      {rumour.content} {TIME.format(new Date(rumour.created_at * 1000))} -
    • - {:else if rumour.pubkey === selectedChat.pubkey && pTagKeys(rumour).includes(user.pubkey)} -
    • + {#if isLastInRun && user.picture} + {user.username} + {:else} +
      + {/if} +
    • + {:else if rumour.pubkey === selectedChat.pubkey && pTagKeys(rumour).includes(user.pubkey)} +
    • + {#if isLastInRun && selectedChat.picture} + {name(selectedChat)} + {:else} +
      + {/if} +
      {rumour.content} {TIME.format(new Date(rumour.created_at * 1000))} -
    • - {/if} - {/each} -
    - {/each} -
- - +
+ + {/if} + {/each} + + {/each} +
+ + +
+ {sendError}

{/if} - {/if} - - {#if !(muted && muted.has(selectedChat.pubkey))} - - - : - - - . -

{$t("dm.fadedWarning")}

- {#if canSend && !canSendExpiring} -

- {$t("dm.cantSendExpiring").replace("[R]", name(selectedChat))} -

- {/if} - {/if} +
{:else if chats.length == 0}

{$t("dm.noChats")}

{: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; - } 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); + }); +}); -- cgit v1.2.3