From f3d2029a61138d69437c1815a41fa6db3fd6198a Mon Sep 17 00:00:00 2001 From: Adam Soltys Date: Sun, 1 Mar 2026 14:30:39 -0800 Subject: dm test --- src/lib/nip07.ts | 29 ++++++++++++- src/lib/nip17.ts | 10 +++-- src/locales/en.json | 3 +- src/routes/(app)/dm/+page.svelte | 19 +++++++-- test/e2e/dm-send.spec.ts | 88 ++++++++++++++++++++++++++++++++++++++++ 5 files changed, 139 insertions(+), 10 deletions(-) create mode 100644 test/e2e/dm-send.spec.ts diff --git a/src/lib/nip07.ts b/src/lib/nip07.ts index ee31ee58..ce1cddaf 100644 --- a/src/lib/nip07.ts +++ b/src/lib/nip07.ts @@ -1,11 +1,34 @@ import { bytesToHex, hexToBytes } from "@noble/hashes/utils.js"; -import { finalizeEvent } from "nostr-tools/pure"; +import { finalizeEvent, getPublicKey } from "nostr-tools/pure"; import * as libnip44 from "$lib/nip44"; import { u } from "$lib/nip44"; import { eventToSign, signer } from "$lib/store"; import { get } from "svelte/store"; +// Try to load sk from localStorage nsec and populate the signer store. +const tryNsecFromStorage = async (): Promise => { + const nsec = localStorage.getItem("nsec"); + if (!nsec) return null; + + try { + const { nip19 } = await import("nostr-tools"); + const { type, data } = nip19.decode(nsec); + if (type !== "nsec" || !(data instanceof Uint8Array)) return null; + + const sk = data; + const pk = getPublicKey(sk); + signer.set({ + method: "nsec", + params: { sk: bytesToHex(sk), pk }, + ready: true, + }); + return sk; + } catch { + return null; + } +}; + // Ensures we have access to a secret key, triggering the signer modal if needed. // Returns the sk as Uint8Array, or null if using NIP-07 extension. export const ensureSigner = async (userPubkey: string): Promise => { @@ -27,6 +50,10 @@ export const ensureSigner = async (userPubkey: string): Promise Math.floor(Date.now() / 1000 - Math.random() * TWO_DAYS); @@ -156,7 +154,11 @@ export const decrypt = async (wrapped: any, user: any): Promise => { if (sk === null) { return decryptNIP17MessageNIP07(wrapped); } else { - return unwrapEvent(wrapped, sk); + const skHex = bytesToHex(sk); + const sealedText = nip44Decrypt(wrapped.content, u.getConversationKey(skHex, wrapped.pubkey)); + const sealed = JSON.parse(sealedText); + const rumourText = nip44Decrypt(sealed.content, u.getConversationKey(skHex, sealed.pubkey)); + return JSON.parse(rumourText); } }; diff --git a/src/locales/en.json b/src/locales/en.json index d4feb73e..4249412a 100644 --- a/src/locales/en.json +++ b/src/locales/en.json @@ -205,7 +205,8 @@ "relaysNotFound": "Could not find [R]'s DM relays.", "noRelaysSet": "WARNING: You haven't set any preferred relays. You won't be able to receive any messages or see the messages you've sent.", "nostrSettingsLink": "Go to your Nostr settings to set your relays.", - "anonymous": "Anonymous" + "anonymous": "Anonymous", + "sendFailed": "Failed to send message. The relay may be unavailable." }, "error": { "header": "Error", diff --git a/src/routes/(app)/dm/+page.svelte b/src/routes/(app)/dm/+page.svelte index 1f1df536..b7026f94 100644 --- a/src/routes/(app)/dm/+page.svelte +++ b/src/routes/(app)/dm/+page.svelte @@ -283,11 +283,19 @@ return expiration <= Date.now() / 1000 + 2 * 86400; }; + let sendError = $state(""); + const sendMessage = async (message: string) => { - const expiry = expiryEnabled ? expiryDays : undefined; - await libnip17.send(message, user, selectedChat, expiry); - text = ""; - updateEvents(); + sendError = ""; + try { + const expiry = expiryEnabled ? expiryDays : undefined; + await libnip17.send(message, user, selectedChat, expiry); + text = ""; + updateEvents(); + } catch (e: any) { + console.error("Failed to send message:", e); + sendError = $t("dm.sendFailed"); + } }; (() => libnip17.getPreferredRelays(data.user.pubkey).then((relays) => { @@ -459,6 +467,9 @@ : $t("dm.relaysNotFound").replace("[R]", name(selectedChat))} onclick={async () => sendMessage(text)} /> + {#if sendError} +

{sendError}

+ {/if} {/if} {#if !(muted && muted.has(selectedChat.pubkey))} diff --git a/test/e2e/dm-send.spec.ts b/test/e2e/dm-send.spec.ts new file mode 100644 index 00000000..3930c383 --- /dev/null +++ b/test/e2e/dm-send.spec.ts @@ -0,0 +1,88 @@ +import { expect, test } from "@playwright/test"; +import { login, bobUsername, bobPassword, aliceUsername } from "./helpers"; + +test("bob sends a DM to alice via /dm page", async ({ browser }) => { + const bobContext = await browser.newContext(); + const bobPage = await bobContext.newPage(); + + // Collect console logs for diagnostics + const consoleLogs: string[] = []; + bobPage.on("console", (msg) => consoleLogs.push(`[${msg.type()}] ${msg.text()}`)); + const pageErrors: string[] = []; + bobPage.on("pageerror", (err) => pageErrors.push(err.message)); + + // Track relay WebSocket messages to verify publish + const wsMessages: string[] = []; + bobPage.on("websocket", (ws) => { + ws.on("framesent", (frame) => + wsMessages.push(`sent: ${frame.payload.toString().slice(0, 200)}`), + ); + ws.on("framereceived", (frame) => + wsMessages.push(`recv: ${frame.payload.toString().slice(0, 200)}`), + ); + }); + + // Login as bob (sets auth cookies + nsec in localStorage) + await login(bobPage, bobUsername, bobPassword); + + // Navigate to /dm selecting alice + await bobPage.goto(`/dm?username=${aliceUsername}`); + await bobPage.waitForLoadState("domcontentloaded"); + + // Wait for the message textarea (indicates a chat is selected) + const textarea = bobPage.locator("#message-contents"); + await expect(textarea).toBeVisible({ timeout: 15_000 }); + + // Verify send button is enabled (depends on relay availability) + const sendButton = bobPage.locator("#send-message"); + await expect(sendButton).toBeVisible({ timeout: 5_000 }); + const isDisabled = await sendButton.isDisabled(); + const buttonValue = await sendButton.getAttribute("value"); + console.log(`[e2e] Send button disabled=${isDisabled}, value="${buttonValue}"`); + expect(isDisabled, `Send button is disabled: "${buttonValue}"`).toBeFalsy(); + + // Verify the Nostr sign modal does NOT appear (ensureSigner should use nsec from localStorage) + const signerModal = bobPage.locator("text=Nostr sign"); + const modalBefore = await signerModal.isVisible({ timeout: 500 }).catch(() => false); + expect(modalBefore, "Nostr sign modal should not appear before send").toBeFalsy(); + + // Type and send a test message + const testMessage = `e2e test message ${Date.now()}`; + await textarea.fill(testMessage); + await sendButton.click(); + + // Wait for the send to complete — textarea clears on success + await expect(textarea).toHaveValue("", { timeout: 15_000 }); + + // Verify no error message appeared in the UI + const sendError = bobPage.locator(".warning em"); + const hasError = await sendError + .filter({ hasText: /failed|error/i }) + .isVisible({ timeout: 1_000 }) + .catch(() => false); + if (hasError) { + const errorText = await sendError.innerText(); + console.log(`[e2e] UI error: ${errorText}`); + } + expect(hasError, "No send error should appear").toBeFalsy(); + + // Verify the relay accepted both gift-wrapped events (one for recipient, one for sender copy) + const relayAccepts = wsMessages.filter((m) => m.startsWith("recv:") && m.includes('"OK"') && m.includes(",true,")); + console.log(`[e2e] Relay accepted ${relayAccepts.length} event(s)`); + expect(relayAccepts.length, "Relay should accept at least 1 gift-wrapped event").toBeGreaterThanOrEqual(1); + + // Verify no "blocked" or relay rejection + const relayRejects = wsMessages.filter((m) => m.startsWith("recv:") && m.includes('"OK"') && m.includes(",false,")); + if (relayRejects.length) { + console.log("[e2e] Relay rejections:", relayRejects); + } + expect(relayRejects.length, "Relay should not reject any events").toBe(0); + + // Dump diagnostics + const errors = consoleLogs.filter((l) => /error|fail|blocked/i.test(l)); + if (errors.length) { + console.log("[e2e] Console errors:\n " + errors.join("\n ")); + } + + await bobContext.close(); +}); -- cgit v1.2.3