From 51b550adaac39beb508bb1b16a50de29d69dcde3 Mon Sep 17 00:00:00 2001 From: thePR0M3TH3AN <53631862+PR0M3TH3AN@users.noreply.github.com> Date: Mon, 19 Jan 2026 10:31:13 -0500 Subject: [PATCH] Improve DM relay selection fallback handling --- js/app.js | 8 +-- js/nostr/client.js | 104 ++++++++++++++------------------ js/services/dmNostrService.js | 73 ++++++++++++++++++++++ js/services/nostrService.js | 47 ++++++++++++++- js/ui/profileModalController.js | 42 +++++++++---- 5 files changed, 194 insertions(+), 80 deletions(-) diff --git a/js/app.js b/js/app.js index 4d7e185c..22eb78b0 100644 --- a/js/app.js +++ b/js/app.js @@ -3223,11 +3223,9 @@ class Application { : []; if (enabled && !relayHints.length) { - this.showError("Recipient has not shared NIP-17 relay hints yet."); - if (controller?.setPrivacyToggleState) { - controller.setPrivacyToggleState(false); - } - return; + this.showStatus( + "Privacy warning: this recipient has not shared NIP-17 relays, so we'll use your default relays.", + ); } this.showStatus( diff --git a/js/nostr/client.js b/js/nostr/client.js index 505a1e11..01919386 100644 --- a/js/nostr/client.js +++ b/js/nostr/client.js @@ -113,6 +113,10 @@ import { } from "./dmDecryptWorkerClient.js"; import { devLogger, userLogger } from "../utils/logger.js"; import { updateConversationFromMessage, writeMessages } from "../storage/dmDb.js"; +import { + DM_RELAY_WARNING_FALLBACK, + resolveDmRelaySelection, +} from "../services/dmNostrService.js"; import { DEFAULT_NIP07_ENCRYPTION_METHODS, DEFAULT_NIP07_PERMISSION_METHODS, @@ -4480,68 +4484,14 @@ 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( + const resolveNip17RelaySelection = async (pubkey, relayHints) => { + const discoveryRelays = 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 @@ -4549,7 +4499,41 @@ export class NostrClient { ? this.relays : RELAY_URLS, ); - const senderRelayTargets = senderRelays.length ? senderRelays : fallbackRelays; + + return resolveDmRelaySelection({ + pubkey, + relayHints, + discoveryRelays, + fallbackRelays, + pool: this.pool, + log: { dev: devLogger, user: userLogger }, + }); + }; + + if (useNip17) { + if (!signerCapabilities.nip44 || typeof signer.nip44Encrypt !== "function") { + return { ok: false, error: "nip44-unsupported" }; + } + + const recipientSelection = await resolveNip17RelaySelection( + targetHex, + resolvedOptions.recipientRelayHints, + ); + + if (!recipientSelection.relays.length) { + return { ok: false, error: "nip17-relays-unavailable" }; + } + + const senderSelection = await resolveNip17RelaySelection( + actorHex, + resolvedOptions.senderRelayHints, + ); + const senderRelayTargets = senderSelection.relays; + const relayWarning = + recipientSelection.warning === DM_RELAY_WARNING_FALLBACK || + senderSelection.warning === DM_RELAY_WARNING_FALLBACK + ? DM_RELAY_WARNING_FALLBACK + : null; const tools = await ensureNostrTools(); const getPublicKey = @@ -4690,7 +4674,7 @@ export class NostrClient { try { recipientGiftWrap = await buildGiftWrapForRecipient( targetHex, - recipientRelays[0] || "", + recipientSelection.relays[0] || "", ); } catch (error) { devLogger.warn("[nostr] Failed to build NIP-17 recipient wrap.", error); @@ -4726,7 +4710,7 @@ export class NostrClient { } const recipientPublishResults = await Promise.all( - recipientRelays.map((url) => + recipientSelection.relays.map((url) => publishEventToRelay(this.pool, url, recipientGiftWrap.wrap), ), ); @@ -4776,7 +4760,7 @@ export class NostrClient { devLogger.warn("[nostr] Failed to persist DM publish state.", error); } - return { ok: true }; + return relayWarning ? { ok: true, warning: relayWarning } : { ok: true }; } const encryptionErrors = []; diff --git a/js/services/dmNostrService.js b/js/services/dmNostrService.js index 00fc3eea..deafcc2a 100644 --- a/js/services/dmNostrService.js +++ b/js/services/dmNostrService.js @@ -1,4 +1,5 @@ import { ensureNostrTools, resolveSimplePoolConstructor, shimLegacySimplePoolMethods } from "../nostr/toolkit.js"; +import { sanitizeRelayList } from "../nostr/nip46Client.js"; import { publishEventToRelay } from "../nostrPublish.js"; import logger from "../utils/logger.js"; @@ -9,6 +10,7 @@ const DEFAULT_BACKOFF = { }; const MAX_SEEN_EVENT_IDS = 5000; +export const DM_RELAY_WARNING_FALLBACK = "dm-relays-fallback"; function normalizeRelayList(relays) { if (!Array.isArray(relays)) { @@ -44,6 +46,77 @@ function normalizeLogger(candidate) { return logger; } +function extractRelayHintsFromEvent(event) { + const tags = Array.isArray(event?.tags) ? event.tags : []; + return sanitizeRelayList( + tags + .filter((tag) => Array.isArray(tag) && tag[0] === "relay") + .map((tag) => (typeof tag[1] === "string" ? tag[1].trim() : "")), + ); +} + +export async function resolveDmRelaySelection({ + pubkey, + relayHints = [], + discoveryRelays = [], + fallbackRelays = [], + pool = null, + log = null, +} = {}) { + const resolvedLogger = normalizeLogger(log || logger); + const normalizedPubkey = normalizePubkey(pubkey); + const hintedRelays = sanitizeRelayList(Array.isArray(relayHints) ? relayHints : []); + + if (hintedRelays.length) { + return { relays: hintedRelays, source: "hints", warning: null }; + } + + const discoveryList = sanitizeRelayList( + Array.isArray(discoveryRelays) ? discoveryRelays : [], + ); + const fallbackList = sanitizeRelayList( + Array.isArray(fallbackRelays) && fallbackRelays.length + ? fallbackRelays + : discoveryRelays, + ); + + if ( + normalizedPubkey && + pool && + typeof pool.list === "function" && + discoveryList.length + ) { + try { + const events = await pool.list(discoveryList, [ + { kinds: [10050], authors: [normalizedPubkey], limit: 1 }, + ]); + const sorted = Array.isArray(events) + ? events + .filter((entry) => entry && entry.pubkey === normalizedPubkey) + .sort((a, b) => (b?.created_at || 0) - (a?.created_at || 0)) + : []; + if (sorted.length) { + const relays = extractRelayHintsFromEvent(sorted[0]); + if (relays.length) { + return { relays, source: "kind-10050", warning: null }; + } + } + } catch (error) { + resolvedLogger.dev.warn("[dmNostrService] Failed to fetch DM relay hints.", error); + } + } + + if (fallbackList.length) { + return { + relays: fallbackList, + source: "fallback", + warning: DM_RELAY_WARNING_FALLBACK, + }; + } + + return { relays: [], source: "none", warning: null }; +} + function clampNumber(value, minimum, maximum) { if (!Number.isFinite(value)) { return minimum; diff --git a/js/services/nostrService.js b/js/services/nostrService.js index 15c2e371..31c1bbfc 100644 --- a/js/services/nostrService.js +++ b/js/services/nostrService.js @@ -2,8 +2,12 @@ import { nostrClient } from "../nostrClientFacade.js"; import { convertEventToVideo } from "../nostr/index.js"; import { accessControl } from "../accessControl.js"; import { ALLOW_NSFW_CONTENT } from "../config.js"; -import { userLogger } from "../utils/logger.js"; +import { devLogger, userLogger } from "../utils/logger.js"; import moderationService from "./moderationService.js"; +import { + DM_RELAY_WARNING_FALLBACK, + resolveDmRelaySelection, +} from "./dmNostrService.js"; import { loadDirectMessageSnapshot, saveDirectMessageSnapshot, @@ -874,7 +878,7 @@ export class NostrService { return snapshot; } - ensureDirectMessageSubscription({ actorPubkey, relays, ...handlers } = {}) { + async ensureDirectMessageSubscription({ actorPubkey, relays, ...handlers } = {}) { if (this.dmSubscription) { return this.dmSubscription; } @@ -885,11 +889,40 @@ export class NostrService { return null; } + let resolvedRelays = Array.isArray(relays) ? relays : null; + let relaySelection = null; + + if (!resolvedRelays) { + const discoveryRelays = Array.isArray(this.nostrClient?.readRelays) + ? this.nostrClient.readRelays + : Array.isArray(this.nostrClient?.relays) + ? this.nostrClient.relays + : []; + + let pool = this.nostrClient?.pool || null; + if (!pool && typeof this.nostrClient?.ensurePool === "function") { + try { + pool = await this.nostrClient.ensurePool(); + } catch (error) { + devLogger.warn("[nostrService] Failed to initialize DM relay pool.", error); + } + } + + relaySelection = await resolveDmRelaySelection({ + pubkey: normalizedActor, + discoveryRelays, + fallbackRelays: discoveryRelays, + pool, + log: { dev: devLogger, user: userLogger }, + }); + resolvedRelays = relaySelection.relays; + } + try { const subscription = this.nostrClient.subscribeDirectMessages( normalizedActor, { - relays, + relays: resolvedRelays, onEvent: handlers.onEvent, onMessage: (message, context = {}) => { this.applyDirectMessage(message, { @@ -957,6 +990,14 @@ export class NostrService { this.dmSubscription = subscription; this.dmActorPubkey = normalizedActor; this.emit("directMessages:subscribed", { subscription }); + if (relaySelection?.warning === DM_RELAY_WARNING_FALLBACK) { + this.emit("directMessages:relayWarning", { + actorPubkey: normalizedActor, + warning: relaySelection.warning, + relays: resolvedRelays, + source: relaySelection.source, + }); + } return subscription; } catch (error) { userLogger.warn( diff --git a/js/ui/profileModalController.js b/js/ui/profileModalController.js index debc2acd..44452bb5 100644 --- a/js/ui/profileModalController.js +++ b/js/ui/profileModalController.js @@ -1752,6 +1752,9 @@ export class ProfileModalController { subscribe("directMessages:failure", (detail) => { this.handleDirectMessagesError(detail); }); + subscribe("directMessages:relayWarning", (detail) => { + this.handleDirectMessagesRelayWarning(detail); + }); this.directMessagesUnsubscribes = unsubscribes; @@ -1995,7 +1998,7 @@ export class ProfileModalController { } } - ensureDirectMessageSubscription(actorPubkey = null) { + async ensureDirectMessageSubscription(actorPubkey = null) { if ( !this.nostrService || typeof this.nostrService.ensureDirectMessageSubscription !== "function" @@ -2028,7 +2031,7 @@ export class ProfileModalController { let subscription = null; try { - subscription = this.nostrService.ensureDirectMessageSubscription({ + subscription = await this.nostrService.ensureDirectMessageSubscription({ actorPubkey: normalizedActor, }); } catch (error) { @@ -2099,7 +2102,7 @@ export class ProfileModalController { } this.setMessagesLoadingState("loading"); - this.ensureDirectMessageSubscription(normalized); + void this.ensureDirectMessageSubscription(normalized); this.updateMessagesReloadState(); if (this.getActivePane() === "messages") { @@ -2329,10 +2332,9 @@ export class ProfileModalController { : []; if (enabled && !relayHints.length) { - this.showError("Recipient has not shared NIP-17 relay hints yet."); - this.setPrivacyToggleState(false); - this.dmPrivacyToggleTouched = false; - return; + this.showStatus( + "Privacy warning: this recipient has not shared NIP-17 relays, so we'll use your default relays.", + ); } this.dmPrivacyToggleTouched = true; @@ -2356,6 +2358,8 @@ export class ProfileModalController { 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-relays-unavailable": + return "No DM relays are available to deliver this message."; case "nip17-keygen-failed": return "We couldn’t create secure wrapper keys for NIP-17 delivery."; case "extension-permission-denied": @@ -2411,10 +2415,9 @@ export class ProfileModalController { : false; if (useNip17 && !recipientRelayHints.length) { - this.showError("Recipient has not shared NIP-17 relay hints yet."); - this.setPrivacyToggleState(false); - this.dmPrivacyToggleTouched = false; - return; + this.showStatus( + "Privacy warning: this recipient has not shared NIP-17 relays, so we'll use your default relays.", + ); } if ( @@ -2447,6 +2450,11 @@ export class ProfileModalController { if (result?.ok) { input.value = ""; this.showSuccess("Message sent."); + if (result?.warning === "dm-relays-fallback") { + this.showStatus( + "Privacy warning: this message used default relays because no NIP-17 relay list was found.", + ); + } void this.populateProfileMessages({ force: true, reason: "send-message" }); return; } @@ -2949,7 +2957,7 @@ export class ProfileModalController { } } - this.ensureDirectMessageSubscription(actor); + void this.ensureDirectMessageSubscription(actor); } resumeProfileMessages() { @@ -3051,6 +3059,16 @@ export class ProfileModalController { this.updateMessagesReloadState(); } + handleDirectMessagesRelayWarning(detail = {}) { + if (detail?.warning !== "dm-relays-fallback") { + return; + } + + this.showStatus( + "Privacy warning: direct messages are using your default relays because no NIP-17 relay list is available.", + ); + } + registerEventListeners() { if (this.closeButton instanceof HTMLElement) { this.closeButton.addEventListener("click", () => {