Implement worker-based NIP-04 DM sending

This commit is contained in:
thePR0M3TH3AN committed 2026-01-19 08:39:43 -05:00
1 parent d87c5dbbcc
commit 56f2c1807c
5 files changed
+388 -76

No files matched your search

+180 -75
View File
@@ -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 };
}
+91
View File
@@ -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);
});
}
+108
View File
@@ -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,
});
});
}
+8
View File
@@ -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,
};
}
+1 -1
View File
@@ -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":