aboutsummaryrefslogtreecommitdiffstats
diff options
context:
space:
mode:
authorAdam Soltys <[email protected]>2026-03-01 14:30:39 -0800
committerAdam Soltys <[email protected]>2026-03-01 14:30:39 -0800
commitf3d2029a61138d69437c1815a41fa6db3fd6198a (patch)
tree149ed8baa69561d4d82436388049189fa411ea23
parent0eb128fdd59cd550beda23549a47df75442d997f (diff)
downloadcoinos-ui-f3d2029a61138d69437c1815a41fa6db3fd6198a.tar.gz
coinos-ui-f3d2029a61138d69437c1815a41fa6db3fd6198a.zip
dm test
-rw-r--r--src/lib/nip07.ts29
-rw-r--r--src/lib/nip17.ts10
-rw-r--r--src/locales/en.json3
-rw-r--r--src/routes/(app)/dm/+page.svelte19
-rw-r--r--test/e2e/dm-send.spec.ts88
5 files changed, 139 insertions, 10 deletions
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<Uint8Array | null> => {
+ 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<Uint8Array | null> => {
@@ -27,6 +50,10 @@ export const ensureSigner = async (userPubkey: string): Promise<Uint8Array | nul
return hexToBytes(currentSigner.params.sk);
}
+ // Try to load from localStorage nsec before triggering the modal
+ const skFromStorage = await tryNsecFromStorage();
+ if (skFromStorage) return skFromStorage;
+
// Clear invalid signer state before triggering modal
if (
currentSigner?.ready &&
diff --git a/src/lib/nip17.ts b/src/lib/nip17.ts
index 97191a18..29a14549 100644
--- a/src/lib/nip17.ts
+++ b/src/lib/nip17.ts
@@ -3,12 +3,10 @@ import { bytesToHex, hexToBytes } from "@noble/hashes/utils.js";
import { finalizeEvent, getEventHash, generateSecretKey, getPublicKey } from "nostr-tools/pure";
import { SimplePool } from "nostr-tools/pool";
import { Relay } from "nostr-tools/relay";
-import { unwrapEvent } from "nostr-tools/nip17";
-
import { ensureSigner } from "$lib/nip07";
import { relaysSupporting } from "$lib/nip11";
import { expired, expiration } from "$lib/nip40";
-import { encrypt, u } from "$lib/nip44";
+import { encrypt, decrypt as nip44Decrypt, u } from "$lib/nip44";
const TWO_DAYS = 2 * 24 * 60 * 60;
const randomTimestamp = () => Math.floor(Date.now() / 1000 - Math.random() * TWO_DAYS);
@@ -156,7 +154,11 @@ export const decrypt = async (wrapped: any, user: any): Promise<any> => {
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}
+ <p class="warning"><em>{sendError}</em></p>
+ {/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();
+});