Merge pull request #1389 from PR0M3TH3AN/codex/add-nip-17-support-in-composer

Add NIP-17 DM delivery option with gift-wrap sending flow
This commit is contained in:
thePR0M3TH3AN authored and GitHub committed 2026-01-19 10:05:40 -05:00
commit 202b6d8852
4 files changed
+387 -8

No files matched your search

+3 -3
View File
@@ -1047,9 +1047,9 @@
</div>
<div class="flex flex-wrap items-center justify-between gap-3 border-t border-border/60 pt-3">
<div class="space-y-1">
<h4 class="text-xs font-semibold text-text">Privacy mode (coming soon)</h4>
<h4 class="text-xs font-semibold text-text">NIP-17 privacy delivery</h4>
<p class="text-xs text-muted">
Pause inbound DM sync and clear cached previews on this device.
Send gift-wrapped DMs to the relays your recipient advertises in kind 10050.
</p>
</div>
<label
@@ -1061,7 +1061,7 @@
type="checkbox"
class="checkbox"
/>
Enable
Use NIP-17
</label>
</div>
</div>
+14 -3
View File
@@ -3217,13 +3217,24 @@ class Application {
return true;
}
handleProfilePrivacyToggle({ enabled, controller } = {}) {
if (enabled) {
this.showStatus("Privacy mode is coming in Phase 3.");
handleProfilePrivacyToggle({ enabled, controller, recipient } = {}) {
const relayHints = Array.isArray(recipient?.relayHints)
? recipient.relayHints
: [];
if (enabled && !relayHints.length) {
this.showError("Recipient has not shared NIP-17 relay hints yet.");
if (controller?.setPrivacyToggleState) {
controller.setPrivacyToggleState(false);
}
return;
}
this.showStatus(
enabled
? "NIP-17 privacy delivery enabled for this recipient."
: "Using standard DM delivery for this recipient.",
);
}
updateProfileInDOM(pubkey, profile) {
+305
View File
@@ -4376,6 +4376,10 @@ export class NostrClient {
resolvedActorOverride = null;
}
if (!resolvedOptions || typeof resolvedOptions !== "object") {
resolvedOptions = {};
}
const trimmedTarget = typeof targetNpub === "string" ? targetNpub.trim() : "";
const trimmedMessage = typeof message === "string" ? message.trim() : "";
@@ -4474,6 +4478,307 @@ export class NostrClient {
}
const signerCapabilities = resolveSignerCapabilities(signer);
const useNip17 = Boolean(resolvedOptions.useNip17);
const resolveNip17RelayList = async (pubkey, relayHints) => {
const direct = sanitizeRelayList(relayHints);
if (direct.length) {
return direct;
}
const relayCandidates = sanitizeRelayList(
Array.isArray(this.readRelays) && this.readRelays.length
? this.readRelays
: Array.isArray(this.relays) && this.relays.length
? this.relays
: RELAY_URLS,
);
if (!relayCandidates.length || !this.pool) {
return [];
}
try {
await this.ensurePool();
const events = await this.pool.list(relayCandidates, [
{ kinds: [10050], authors: [pubkey], limit: 1 },
]);
const sorted = Array.isArray(events)
? events
.filter((entry) => entry && entry.pubkey === pubkey)
.sort((a, b) => (b?.created_at || 0) - (a?.created_at || 0))
: [];
if (!sorted.length) {
return [];
}
const tags = Array.isArray(sorted[0]?.tags) ? sorted[0].tags : [];
const relayList = tags
.filter((tag) => Array.isArray(tag) && tag[0] === "relay")
.map((tag) => (typeof tag[1] === "string" ? tag[1].trim() : ""))
.filter(Boolean);
return sanitizeRelayList(relayList);
} catch (error) {
devLogger.warn("[nostr] Failed to load NIP-17 relay list.", error);
return [];
}
};
if (useNip17) {
if (!signerCapabilities.nip44 || typeof signer.nip44Encrypt !== "function") {
return { ok: false, error: "nip44-unsupported" };
}
const recipientRelays = await resolveNip17RelayList(
targetHex,
resolvedOptions.recipientRelayHints,
);
if (!recipientRelays.length) {
return { ok: false, error: "nip17-relays-missing" };
}
const senderRelays = await resolveNip17RelayList(
actorHex,
resolvedOptions.senderRelayHints,
);
const fallbackRelays = sanitizeRelayList(
Array.isArray(this.writeRelays) && this.writeRelays.length
? this.writeRelays
: Array.isArray(this.relays) && this.relays.length
? this.relays
: RELAY_URLS,
);
const senderRelayTargets = senderRelays.length ? senderRelays : fallbackRelays;
const tools = await ensureNostrTools();
const getPublicKey =
tools && typeof tools.getPublicKey === "function" ? tools.getPublicKey : null;
if (!getPublicKey) {
return { ok: false, error: "nip17-keygen-failed" };
}
const randomPastTimestamp = () => {
const now = Math.floor(Date.now() / 1000);
const offset = Math.floor(Math.random() * 172800);
return now - offset;
};
const generateEphemeralKeypair = () => {
let privateKey = "";
try {
if (typeof tools.generateSecretKey === "function") {
const secret = tools.generateSecretKey();
if (secret instanceof Uint8Array) {
privateKey = bytesToHex(secret);
}
}
if (!privateKey) {
if (typeof tools.generatePrivateKey === "function") {
privateKey = tools.generatePrivateKey();
} else if (window?.crypto?.getRandomValues) {
const randomBytes = new Uint8Array(32);
window.crypto.getRandomValues(randomBytes);
privateKey = Array.from(randomBytes)
.map((byte) => byte.toString(16).padStart(2, "0"))
.join("");
}
}
} catch (error) {
devLogger.warn("[nostr] Failed to generate NIP-17 wrapper key.", error);
privateKey = "";
}
const normalizedPrivateKey =
typeof privateKey === "string" ? privateKey.trim() : "";
if (!normalizedPrivateKey) {
return null;
}
let pubkey = "";
try {
pubkey = getPublicKey(normalizedPrivateKey);
} catch (error) {
let retrySuccess = false;
try {
if (HEX64_REGEX.test(normalizedPrivateKey)) {
const bytes = new Uint8Array(normalizedPrivateKey.length / 2);
for (let i = 0; i < normalizedPrivateKey.length; i += 2) {
bytes[i / 2] = parseInt(
normalizedPrivateKey.substring(i, i + 2),
16,
);
}
pubkey = getPublicKey(bytes);
retrySuccess = true;
}
} catch (retryError) {
// Fall back to error handling below
}
if (!retrySuccess) {
devLogger.warn(
"[nostr] Failed to derive NIP-17 wrapper pubkey.",
error,
);
return null;
}
}
return { privateKey: normalizedPrivateKey, pubkey };
};
const rumorEvent = {
kind: 14,
created_at: Math.floor(Date.now() / 1000),
tags: [["p", targetHex]],
content: trimmedMessage,
pubkey: actorHex,
};
const buildGiftWrapForRecipient = async (recipientPubkey, relayHint) => {
const sealPayload = {
kind: 13,
created_at: randomPastTimestamp(),
tags: [],
content: await signer.nip44Encrypt(
recipientPubkey,
JSON.stringify(rumorEvent),
),
pubkey: actorHex,
};
const signedSeal = await signingAdapter.signEvent(sealPayload);
if (!signedSeal || typeof signedSeal.id !== "string") {
throw new Error("seal-signature-failed");
}
const wrapperKeys = generateEphemeralKeypair();
if (!wrapperKeys) {
throw new Error("wrapper-keygen-failed");
}
const cipherClosures = await createPrivateKeyCipherClosures(
wrapperKeys.privateKey,
);
if (typeof cipherClosures.nip44Encrypt !== "function") {
throw new Error("wrapper-encryption-unavailable");
}
const wrapCiphertext = await cipherClosures.nip44Encrypt(
recipientPubkey,
JSON.stringify(signedSeal),
);
const wrapTags = relayHint
? [["p", recipientPubkey, relayHint]]
: [["p", recipientPubkey]];
const wrapEvent = {
kind: 1059,
created_at: randomPastTimestamp(),
tags: wrapTags,
content: wrapCiphertext,
pubkey: wrapperKeys.pubkey,
};
return {
wrap: signEventWithPrivateKey(wrapEvent, wrapperKeys.privateKey),
seal: signedSeal,
};
};
let recipientGiftWrap;
try {
recipientGiftWrap = await buildGiftWrapForRecipient(
targetHex,
recipientRelays[0] || "",
);
} catch (error) {
devLogger.warn("[nostr] Failed to build NIP-17 recipient wrap.", error);
const errorCode =
error?.message === "wrapper-keygen-failed"
? "nip17-keygen-failed"
: "encryption-failed";
return { ok: false, error: errorCode, details: error };
}
const conversationId = `dm:${[actorHex, targetHex].sort().join(":")}`;
const dmRecord = {
id: recipientGiftWrap.wrap.id,
conversation_id: conversationId,
sender_pubkey: actorHex,
receiver_pubkey: targetHex,
created_at: rumorEvent.created_at,
kind: rumorEvent.kind,
content: trimmedMessage,
tags: rumorEvent.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 recipientPublishResults = await Promise.all(
recipientRelays.map((url) =>
publishEventToRelay(this.pool, url, recipientGiftWrap.wrap),
),
);
const recipientSuccess = recipientPublishResults.some(
(result) => result.success,
);
if (!recipientSuccess) {
try {
await writeMessages({ ...dmRecord, status: "failed" });
} catch (error) {
devLogger.warn("[nostr] Failed to persist DM failure state.", error);
}
return {
ok: false,
error: "publish-failed",
details: recipientPublishResults.filter((result) => !result.success),
};
}
let senderGiftWrap = null;
try {
senderGiftWrap = await buildGiftWrapForRecipient(
actorHex,
senderRelayTargets[0] || "",
);
} catch (error) {
devLogger.warn("[nostr] Failed to build NIP-17 sender copy.", error);
}
if (senderGiftWrap && senderRelayTargets.length) {
const senderPublishResults = await Promise.all(
senderRelayTargets.map((url) =>
publishEventToRelay(this.pool, url, senderGiftWrap.wrap),
),
);
if (!senderPublishResults.some((result) => result.success)) {
devLogger.warn("[nostr] Failed to publish sender copy of NIP-17 DM.", {
relays: senderRelayTargets,
});
}
}
try {
await writeMessages({ ...dmRecord, status: "published" });
} catch (error) {
devLogger.warn("[nostr] Failed to persist DM publish state.", error);
}
return { ok: true };
}
const encryptionErrors = [];
let ciphertext = "";
+65 -2
View File
@@ -1244,6 +1244,7 @@ export class ProfileModalController {
this.directMessagesUnsubscribes = [];
this.pendingMessagesRender = null;
this.messagesStatusClearTimeout = null;
this.dmPrivacyToggleTouched = false;
this.profileHistoryRenderer = null;
this.profileHistoryRendererConfig = null;
@@ -1919,7 +1920,24 @@ export class ProfileModalController {
}
}
return this.buildDmRecipientContext(normalized);
const context = this.buildDmRecipientContext(normalized);
this.updateDmPrivacyToggleForRecipient(context);
return context;
}
updateDmPrivacyToggleForRecipient(recipientContext, { force = false } = {}) {
if (!recipientContext) {
return;
}
const relayHints = Array.isArray(recipientContext.relayHints)
? recipientContext.relayHints
: [];
const hasHints = relayHints.length > 0;
if (!this.dmPrivacyToggleTouched || force) {
this.setPrivacyToggleState(hasHints);
}
}
setDirectMessageRecipient(pubkey, { reason = "manual" } = {}) {
@@ -1930,6 +1948,7 @@ export class ProfileModalController {
this.state.setDmRecipient(nextRecipient);
}
this.dmPrivacyToggleTouched = false;
this.updateMessageThreadSelection(nextRecipient);
if (nextRecipient) {
@@ -2302,12 +2321,27 @@ export class ProfileModalController {
}
handlePrivacyToggle(enabled) {
const recipientContext = this.buildDmRecipientContext(
this.resolveActiveDmRecipient(),
);
const relayHints = Array.isArray(recipientContext?.relayHints)
? recipientContext.relayHints
: [];
if (enabled && !relayHints.length) {
this.showError("Recipient has not shared NIP-17 relay hints yet.");
this.setPrivacyToggleState(false);
this.dmPrivacyToggleTouched = false;
return;
}
this.dmPrivacyToggleTouched = true;
const callback = this.callbacks.onTogglePrivacy;
if (callback && callback !== noop) {
callback({
controller: this,
enabled: Boolean(enabled),
recipient: this.buildDmRecipientContext(this.resolveActiveDmRecipient()),
recipient: recipientContext,
});
}
}
@@ -2318,6 +2352,12 @@ export class ProfileModalController {
return "Connect a Nostr signer to send messages.";
case "encryption-unsupported":
return "Your signer does not support NIP-04 encryption.";
case "nip44-unsupported":
return "Your signer does not support NIP-44 encryption required for NIP-17.";
case "nip17-relays-missing":
return "Recipient has not shared NIP-17 relay hints yet.";
case "nip17-keygen-failed":
return "We couldn’t create secure wrapper keys for NIP-17 delivery.";
case "extension-permission-denied":
return "Please grant your Nostr extension permission to send messages.";
case "missing-actor-pubkey":
@@ -2361,6 +2401,22 @@ export class ProfileModalController {
return;
}
const recipientContext = this.buildDmRecipientContext(targetHex);
const recipientRelayHints = Array.isArray(recipientContext?.relayHints)
? recipientContext.relayHints
: [];
const useNip17 =
this.profileMessagesPrivacyToggle instanceof HTMLInputElement
? this.profileMessagesPrivacyToggle.checked
: false;
if (useNip17 && !recipientRelayHints.length) {
this.showError("Recipient has not shared NIP-17 relay hints yet.");
this.setPrivacyToggleState(false);
this.dmPrivacyToggleTouched = false;
return;
}
if (
!this.services.nostrClient ||
typeof this.services.nostrClient.sendDirectMessage !== "function"
@@ -2379,6 +2435,13 @@ export class ProfileModalController {
const result = await this.services.nostrClient.sendDirectMessage(
target,
message,
null,
useNip17
? {
useNip17: true,
recipientRelayHints,
}
: {},
);
if (result?.ok) {