From 56f2c1807c27e4cd4a8725e6635c86cd122a7dc6 Mon Sep 17 00:00:00 2001 From: thePR0M3TH3AN <53631862+PR0M3TH3AN@users.noreply.github.com> Date: Mon, 19 Jan 2026 08:39:43 -0500 Subject: [PATCH] Implement worker-based NIP-04 DM sending --- js/nostr/client.js | 255 ++++++++++++++++++++++---------- js/nostr/nip04Worker.js | 91 ++++++++++++ js/nostr/nip04WorkerClient.js | 108 ++++++++++++++ js/storage/dmDb.js | 8 + js/ui/profileModalController.js | 2 +- 5 files changed, 388 insertions(+), 76 deletions(-) create mode 100644 js/nostr/nip04Worker.js create mode 100644 js/nostr/nip04WorkerClient.js diff --git a/js/nostr/client.js b/js/nostr/client.js index 9a263105..f58721c7 100644 --- a/js/nostr/client.js +++ b/js/nostr/client.js @@ -106,7 +106,9 @@ import { resolveSimplePoolConstructor, shimLegacySimplePoolMethods, } from "./toolkit.js"; +import { encryptNip04InWorker } from "./nip04WorkerClient.js"; import { devLogger, userLogger } from "../utils/logger.js"; +import { updateConversationFromMessage, writeMessages } from "../storage/dmDb.js"; import { DEFAULT_NIP07_ENCRYPTION_METHODS, DEFAULT_NIP07_PERMISSION_METHODS, @@ -4324,7 +4326,14 @@ export class NostrClient { return newPromise; } - async sendDirectMessage(targetNpub, message, actorPubkeyOverride = null) { + async sendDirectMessage(targetNpub, message, actorPubkeyOverride = null, options = {}) { + let resolvedOptions = options; + let resolvedActorOverride = actorPubkeyOverride; + if (actorPubkeyOverride && typeof actorPubkeyOverride === "object") { + resolvedOptions = actorPubkeyOverride; + resolvedActorOverride = null; + } + const trimmedTarget = typeof targetNpub === "string" ? targetNpub.trim() : ""; const trimmedMessage = typeof message === "string" ? message.trim() : ""; @@ -4341,12 +4350,12 @@ export class NostrClient { } const activeSignerCandidate = - typeof actorPubkeyOverride === "string" && actorPubkeyOverride.trim() - ? resolveActiveSigner(actorPubkeyOverride) + typeof resolvedActorOverride === "string" && resolvedActorOverride.trim() + ? resolveActiveSigner(resolvedActorOverride) : resolveActiveSigner(this.pubkey); const baseActiveSigner = activeSignerCandidate || getActiveSigner(); const signer = - typeof actorPubkeyOverride === "string" && actorPubkeyOverride.trim() + typeof resolvedActorOverride === "string" && resolvedActorOverride.trim() ? activeSignerCandidate : baseActiveSigner ? resolveActiveSigner(baseActiveSigner.pubkey || this.pubkey) @@ -4374,8 +4383,8 @@ export class NostrClient { } let actorHex = - typeof actorPubkeyOverride === "string" && actorPubkeyOverride.trim() - ? actorPubkeyOverride.trim() + typeof resolvedActorOverride === "string" && resolvedActorOverride.trim() + ? resolvedActorOverride.trim() : ""; if (!actorHex && typeof this.pubkey === "string") { @@ -4386,67 +4395,87 @@ export class NostrClient { actorHex = signer.pubkey; } - if (!actorHex) { - return { ok: false, error: "missing-actor-pubkey" }; - } - const targetHex = decodeNpubToHex(trimmedTarget); if (!targetHex) { return { ok: false, error: "invalid-target" }; } - const encryptionCandidates = []; - const signerCapabilities = resolveSignerCapabilities(signer); - const allowNip04Fallback = signerCapabilities.nip04 === true; - if ( - signerCapabilities.nip44 && - typeof signer.nip44Encrypt === "function" - ) { - encryptionCandidates.push({ - scheme: "nip44", - encrypt: signer.nip44Encrypt, - }); - } - if ( - signerCapabilities.nip04 && - typeof signer.nip04Encrypt === "function" - ) { - encryptionCandidates.push({ - scheme: "nip04", - encrypt: signer.nip04Encrypt, - }); + const signingAdapter = + resolvedOptions?.signingAdapter && + typeof resolvedOptions.signingAdapter === "object" + ? resolvedOptions.signingAdapter + : signer && typeof signer.signEvent === "function" + ? { + signEvent: (event) => signer.signEvent(event), + getPubkey: async () => actorHex || signer.pubkey || "", + getDisplayName: async () => "", + } + : null; + + if (!signingAdapter || typeof signingAdapter.signEvent !== "function") { + return { ok: false, error: "sign-event-unavailable" }; } + if (typeof signingAdapter.getPubkey === "function") { + try { + const adapterPubkey = await signingAdapter.getPubkey(); + if (typeof adapterPubkey === "string" && adapterPubkey.trim()) { + actorHex = adapterPubkey.trim(); + } + } catch (error) { + devLogger.warn("[nostr] Failed to resolve DM pubkey from adapter.", error); + } + } + + if (!actorHex) { + return { ok: false, error: "missing-actor-pubkey" }; + } + + const signerCapabilities = resolveSignerCapabilities(signer); const encryptionErrors = []; let ciphertext = ""; - for (const candidate of encryptionCandidates) { + const normalizedActorHex = normalizeActorKey(actorHex); + const sessionActor = this.sessionActor; + const sessionPrivateKey = + sessionActor && + typeof sessionActor.pubkey === "string" && + sessionActor.pubkey.toLowerCase() === normalizedActorHex && + typeof sessionActor.privateKey === "string" + ? sessionActor.privateKey + : ""; + + if (sessionPrivateKey) { try { - const encrypted = await candidate.encrypt(targetHex, trimmedMessage); + ciphertext = await encryptNip04InWorker({ + privateKey: sessionPrivateKey, + targetPubkey: targetHex, + plaintext: trimmedMessage, + }); + } catch (error) { + encryptionErrors.push({ scheme: "nip04-worker", error }); + } + } + + if ( + !ciphertext && + signerCapabilities.nip04 && + typeof signer.nip04Encrypt === "function" + ) { + try { + const encrypted = await signer.nip04Encrypt(targetHex, trimmedMessage); if (typeof encrypted === "string" && encrypted) { ciphertext = encrypted; - break; } } catch (error) { - encryptionErrors.push({ scheme: candidate.scheme, error }); + encryptionErrors.push({ scheme: "nip04", error }); } } if (!ciphertext) { - const normalizedActorHex = normalizeActorKey(actorHex); - const sessionActor = this.sessionActor; - const sessionMatchesActor = - sessionActor && - typeof sessionActor.pubkey === "string" && - sessionActor.pubkey.toLowerCase() === normalizedActorHex && - typeof sessionActor.privateKey === "string" && - sessionActor.privateKey; - - const canFallbackWithSession = sessionMatchesActor && allowNip04Fallback; - - if (!encryptionCandidates.length && !canFallbackWithSession) { + if (!sessionPrivateKey && !signerCapabilities.nip04) { const error = new Error( - "Your signer does not support NIP-44 or NIP-04 encryption.", + "Your signer does not support NIP-04 encryption.", ); userLogger.warn("[nostr] Encryption unsupported for DM send.", error); return { @@ -4456,31 +4485,6 @@ export class NostrClient { }; } - if (canFallbackWithSession) { - try { - const tools = (await ensureNostrTools()) || getCachedNostrTools(); - if (tools?.nip04 && typeof tools.nip04.encrypt === "function") { - const encrypted = await tools.nip04.encrypt( - sessionActor.privateKey, - targetHex, - trimmedMessage, - ); - if (typeof encrypted === "string" && encrypted) { - ciphertext = encrypted; - } - } else { - encryptionErrors.push({ - scheme: "nip04", - error: new Error("nostr-tools nip04 helpers unavailable"), - }); - } - } catch (error) { - encryptionErrors.push({ scheme: "nip04", error }); - } - } - } - - if (!ciphertext) { const details = encryptionErrors.length === 1 ? encryptionErrors[0].error @@ -4501,13 +4505,103 @@ export class NostrClient { let signedEvent; try { - signedEvent = await queueSignEvent(signer, event); + signedEvent = await signingAdapter.signEvent(event); } catch (error) { return { ok: false, error: "signature-failed", details: error }; } - const relays = - Array.isArray(this.relays) && this.relays.length ? this.relays : RELAY_URLS; + if (!signedEvent || typeof signedEvent.id !== "string") { + return { ok: false, error: "signature-failed" }; + } + + const conversationId = `dm:${[actorHex, targetHex].sort().join(":")}`; + const dmRecord = { + id: signedEvent.id, + conversation_id: conversationId, + sender_pubkey: actorHex, + receiver_pubkey: targetHex, + created_at: event.created_at, + kind: event.kind, + content: trimmedMessage, + tags: event.tags, + status: "pending", + seen: true, + }; + + try { + await writeMessages(dmRecord); + await updateConversationFromMessage(dmRecord, { + preview: trimmedMessage, + unseenDelta: 0, + }); + } catch (error) { + devLogger.warn("[nostr] Failed to persist outgoing DM.", error); + } + + const relayListCandidates = sanitizeRelayList( + Array.isArray(this.readRelays) && this.readRelays.length + ? this.readRelays + : Array.isArray(this.relays) && this.relays.length + ? this.relays + : RELAY_URLS, + ); + + const parseRecipientRelays = (relayEvent) => { + const tags = Array.isArray(relayEvent?.tags) ? relayEvent.tags : []; + const seen = new Set(); + const candidates = []; + + tags.forEach((tag) => { + if (!Array.isArray(tag) || tag[0] !== "r") { + return; + } + const url = typeof tag[1] === "string" ? tag[1].trim() : ""; + if (!url) { + return; + } + const marker = + typeof tag[2] === "string" ? tag[2].trim().toLowerCase() : ""; + if (marker === "write") { + return; + } + if (!seen.has(url)) { + seen.add(url); + candidates.push(url); + } + }); + + return sanitizeRelayList(candidates); + }; + + let recipientRelays = []; + if (relayListCandidates.length) { + try { + await this.ensurePool(); + const events = await this.pool.list(relayListCandidates, [ + { kinds: [10002], authors: [targetHex], limit: 1 }, + ]); + const sorted = Array.isArray(events) + ? events + .filter((entry) => entry && entry.pubkey === targetHex) + .sort((a, b) => (b?.created_at || 0) - (a?.created_at || 0)) + : []; + if (sorted.length) { + recipientRelays = parseRecipientRelays(sorted[0]); + } + } catch (error) { + devLogger.warn("[nostr] Failed to load recipient relay list.", error); + } + } + + const fallbackRelays = sanitizeRelayList( + Array.isArray(this.writeRelays) && this.writeRelays.length + ? this.writeRelays + : Array.isArray(this.relays) && this.relays.length + ? this.relays + : RELAY_URLS, + ); + + const relays = recipientRelays.length ? recipientRelays : fallbackRelays; const publishResults = await Promise.all( relays.map((url) => publishEventToRelay(this.pool, url, signedEvent)) @@ -4515,6 +4609,11 @@ export class NostrClient { const success = publishResults.some((result) => result.success); if (!success) { + try { + await writeMessages({ ...dmRecord, status: "failed" }); + } catch (error) { + devLogger.warn("[nostr] Failed to persist DM failure state.", error); + } return { ok: false, error: "publish-failed", @@ -4522,6 +4621,12 @@ export class NostrClient { }; } + try { + await writeMessages({ ...dmRecord, status: "published" }); + } catch (error) { + devLogger.warn("[nostr] Failed to persist DM publish state.", error); + } + return { ok: true }; } diff --git a/js/nostr/nip04Worker.js b/js/nostr/nip04Worker.js new file mode 100644 index 00000000..942d7169 --- /dev/null +++ b/js/nostr/nip04Worker.js @@ -0,0 +1,91 @@ +import { ensureNostrTools, getCachedNostrTools } from "./toolkit.js"; +import { devLogger } from "../utils/logger.js"; + +const workerScope = typeof self !== "undefined" ? self : null; + +const postResponse = (payload) => { + if (!workerScope || typeof workerScope.postMessage !== "function") { + return; + } + workerScope.postMessage(payload); +}; + +const ensureNip04 = async () => { + const tools = (await ensureNostrTools()) || getCachedNostrTools(); + const nip04 = tools?.nip04; + if (!nip04 || typeof nip04.encrypt !== "function") { + throw new Error("nip04-unavailable"); + } + return nip04; +}; + +const normalizePayload = (payload) => { + if (!payload || typeof payload !== "object") { + return null; + } + + const id = + typeof payload.id === "string" || typeof payload.id === "number" + ? payload.id + : null; + const privateKey = + typeof payload.privateKey === "string" ? payload.privateKey.trim() : ""; + const targetPubkey = + typeof payload.targetPubkey === "string" ? payload.targetPubkey.trim() : ""; + const plaintext = + typeof payload.plaintext === "string" ? payload.plaintext : ""; + + if (!id || !privateKey || !targetPubkey) { + return null; + } + + return { + id, + privateKey, + targetPubkey, + plaintext, + }; +}; + +const handleMessage = async (event) => { + const payload = normalizePayload(event?.data); + if (!payload) { + postResponse({ + id: event?.data?.id ?? null, + ok: false, + error: { message: "invalid-payload" }, + }); + return; + } + + try { + const nip04 = await ensureNip04(); + const ciphertext = await nip04.encrypt( + payload.privateKey, + payload.targetPubkey, + payload.plaintext, + ); + + postResponse({ + id: payload.id, + ok: true, + ciphertext, + }); + } catch (error) { + devLogger.warn("[nip04Worker] Encryption failed", error); + postResponse({ + id: payload.id, + ok: false, + error: { + message: error?.message || "nip04-worker-failed", + name: error?.name || "Error", + }, + }); + } +}; + +if (workerScope?.addEventListener) { + workerScope.addEventListener("message", (event) => { + void handleMessage(event); + }); +} diff --git a/js/nostr/nip04WorkerClient.js b/js/nostr/nip04WorkerClient.js new file mode 100644 index 00000000..9d6e0c8b --- /dev/null +++ b/js/nostr/nip04WorkerClient.js @@ -0,0 +1,108 @@ +import { devLogger } from "../utils/logger.js"; + +const DEFAULT_TIMEOUT_MS = 12000; + +let workerInstance = null; +let workerReady = false; +let requestId = 0; +const pending = new Map(); + +function attachWorkerListeners(worker) { + if (!worker || workerReady) { + return; + } + + worker.addEventListener("message", (event) => { + const data = event?.data || {}; + const id = data.id; + if (!pending.has(id)) { + return; + } + + const entry = pending.get(id); + pending.delete(id); + clearTimeout(entry.timeoutId); + + if (data.ok && typeof data.ciphertext === "string") { + entry.resolve(data.ciphertext); + return; + } + + const message = data?.error?.message || "nip04-worker-error"; + const error = new Error(message); + error.name = data?.error?.name || "Error"; + entry.reject(error); + }); + + worker.addEventListener("error", (error) => { + devLogger.warn("[nip04WorkerClient] Worker error", error); + for (const entry of pending.values()) { + clearTimeout(entry.timeoutId); + entry.reject(error instanceof Error ? error : new Error("nip04-worker-error")); + } + pending.clear(); + }); + + workerReady = true; +} + +function ensureWorker() { + if (workerInstance) { + return workerInstance; + } + + if (typeof Worker === "undefined") { + return null; + } + + try { + workerInstance = new Worker(new URL("./nip04Worker.js", import.meta.url), { + type: "module", + }); + attachWorkerListeners(workerInstance); + } catch (error) { + devLogger.warn("[nip04WorkerClient] Failed to create worker", error); + workerInstance = null; + } + + return workerInstance; +} + +export function encryptNip04InWorker({ + privateKey, + targetPubkey, + plaintext, + timeoutMs = DEFAULT_TIMEOUT_MS, +} = {}) { + const worker = ensureWorker(); + if (!worker) { + return Promise.reject(new Error("nip04-worker-unavailable")); + } + + const trimmedPrivateKey = + typeof privateKey === "string" ? privateKey.trim() : ""; + const trimmedTarget = + typeof targetPubkey === "string" ? targetPubkey.trim() : ""; + const message = typeof plaintext === "string" ? plaintext : ""; + + if (!trimmedPrivateKey || !trimmedTarget) { + return Promise.reject(new Error("nip04-worker-invalid-input")); + } + + const id = `${Date.now()}-${requestId++}`; + + return new Promise((resolve, reject) => { + const timeoutId = setTimeout(() => { + pending.delete(id); + reject(new Error("nip04-worker-timeout")); + }, timeoutMs); + + pending.set(id, { resolve, reject, timeoutId }); + worker.postMessage({ + id, + privateKey: trimmedPrivateKey, + targetPubkey: trimmedTarget, + plaintext: message, + }); + }); +} diff --git a/js/storage/dmDb.js b/js/storage/dmDb.js index 8ec1e875..7b1cc322 100644 --- a/js/storage/dmDb.js +++ b/js/storage/dmDb.js @@ -135,6 +135,12 @@ function normalizeMessage(raw) { const receiverPubkey = normalizePubkey( raw.receiver_pubkey ?? raw.receiverPubkey ?? raw.to_pubkey, ); + const statusRaw = typeof raw.status === "string" ? raw.status.trim() : ""; + const status = + statusRaw === "pending" || statusRaw === "published" || statusRaw === "failed" + ? statusRaw + : ""; + const seen = raw.seen === true; return { id, @@ -146,6 +152,8 @@ function normalizeMessage(raw) { content: typeof raw.content === "string" ? raw.content : "", tags: Array.isArray(raw.tags) ? raw.tags : [], relay: typeof raw.relay === "string" ? raw.relay : "", + status, + seen, }; } diff --git a/js/ui/profileModalController.js b/js/ui/profileModalController.js index e7fd360f..1de8b9b7 100644 --- a/js/ui/profileModalController.js +++ b/js/ui/profileModalController.js @@ -2085,7 +2085,7 @@ export class ProfileModalController { case "sign-event-unavailable": return "Connect a Nostr signer to send messages."; case "encryption-unsupported": - return "Your signer does not support NIP-44 or NIP-04 encryption."; + return "Your signer does not support NIP-04 encryption."; case "extension-permission-denied": return "Please grant your Nostr extension permission to send messages."; case "missing-actor-pubkey":