From eb52fbdf11fe02d6ee3475598b18f068403fef3a Mon Sep 17 00:00:00 2001 From: thePR0M3TH3AN <53631862+PR0M3TH3AN@users.noreply.github.com> Date: Mon, 19 Jan 2026 09:22:35 -0500 Subject: [PATCH] Add DM recipient handling in profile modal --- components/profile-modal.html | 6 +- js/app.js | 168 ++++++++++++++++++++ js/channelProfile.js | 8 + js/ui/applicationBootstrap.js | 9 ++ js/ui/profileModalController.js | 273 ++++++++++++++++++++++++++++++-- 5 files changed, 452 insertions(+), 12 deletions(-) diff --git a/components/profile-modal.html b/components/profile-modal.html index 5b19d656..b89f7b1e 100644 --- a/components/profile-modal.html +++ b/components/profile-modal.html @@ -1031,9 +1031,9 @@
-

Preferred DM relay

+

Recipient DM relays

- Select a relay to prioritize encrypted message delivery. + Use the relays advertised in kind 10050 to prioritize encrypted delivery.

diff --git a/js/app.js b/js/app.js index 0f3b3c7c..9ac52aea 100644 --- a/js/app.js +++ b/js/app.js @@ -68,6 +68,7 @@ import { devLogger, userLogger } from "./utils/logger.js"; import createPopover from "./ui/overlay/popoverEngine.js"; import { createVideoSettingsMenuPanel } from "./ui/components/videoMenuRenderers.js"; import moderationService from "./services/moderationService.js"; +import { sanitizeRelayList } from "./nostr/nip46Client.js"; import { initViewCounter, subscribeToVideoViewCount, @@ -220,6 +221,8 @@ class Application { this.modalManager = modalManager; this.modalCreatorProfileRequestToken = null; + this.dmRecipientPubkey = null; + this.dmRelayHints = new Map(); this.commentController = null; this.initializeCommentController(); @@ -3058,6 +3061,171 @@ class Application { }); } + getDmRecipientPubkey() { + return this.dmRecipientPubkey; + } + + setDmRecipientPubkey(pubkey) { + const normalized = this.normalizeHexPubkey(pubkey); + this.dmRecipientPubkey = normalized || null; + return this.dmRecipientPubkey; + } + + getDmRelayHints(pubkey) { + const normalized = this.normalizeHexPubkey(pubkey); + if (!normalized || !(this.dmRelayHints instanceof Map)) { + return []; + } + const hints = this.dmRelayHints.get(normalized); + return Array.isArray(hints) ? hints.slice() : []; + } + + setDmRelayHints(pubkey, hints = []) { + const normalized = this.normalizeHexPubkey(pubkey); + if (!normalized) { + return []; + } + if (!(this.dmRelayHints instanceof Map)) { + this.dmRelayHints = new Map(); + } + const stored = sanitizeRelayList(Array.isArray(hints) ? hints : []); + this.dmRelayHints.set(normalized, stored); + return stored.slice(); + } + + async fetchDmRelayHints(pubkey) { + const normalized = this.normalizeHexPubkey(pubkey); + if (!normalized) { + return []; + } + + const cached = this.getDmRelayHints(normalized); + if (cached.length) { + return cached; + } + + const relayCandidates = + Array.isArray(nostrClient?.readRelays) && nostrClient.readRelays.length + ? nostrClient.readRelays + : Array.isArray(nostrClient?.relays) + ? nostrClient.relays + : []; + + const relayList = sanitizeRelayList(relayCandidates); + if (!relayList.length) { + return []; + } + + if (!nostrClient?.pool || typeof nostrClient.pool.list !== "function") { + return []; + } + + try { + const events = await nostrClient.pool.list(relayList, [ + { kinds: [10050], authors: [normalized], limit: 1 }, + ]); + const sorted = Array.isArray(events) + ? events + .filter((event) => event && event.pubkey === normalized) + .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 relayHints = sanitizeRelayList( + tags + .filter((tag) => Array.isArray(tag) && tag[0] === "relay") + .map((tag) => (typeof tag[1] === "string" ? tag[1].trim() : "")), + ); + + this.setDmRelayHints(normalized, relayHints); + return relayHints; + } catch (error) { + devLogger.warn( + "[Application] Failed to load DM relay hints:", + error, + ); + } + + return []; + } + + openDirectMessageComposer({ recipientPubkey, source = "" } = {}) { + const normalized = this.normalizeHexPubkey(recipientPubkey); + if (!normalized) { + this.showError("Please select a valid message recipient."); + return false; + } + + this.setDmRecipientPubkey(normalized); + + if (this.profileController) { + try { + if (typeof this.profileController.setDirectMessageRecipient === "function") { + this.profileController.setDirectMessageRecipient(normalized, { + reason: source || "external", + }); + } + if (typeof this.profileController.show === "function") { + this.profileController.show("messages"); + } + if (typeof this.profileController.focusMessageComposer === "function") { + this.profileController.focusMessageComposer(); + } + } catch (error) { + devLogger.warn( + "[Application] Failed to open DM composer:", + error, + ); + } + } + + return true; + } + + handleProfileSendDmRequest({ recipient } = {}) { + const recipientPubkey = + typeof recipient?.pubkey === "string" + ? recipient.pubkey + : null; + + return this.openDirectMessageComposer({ + recipientPubkey, + source: "profile-modal", + }); + } + + handleProfileUseDmRelays({ recipient, controller } = {}) { + const relayHints = Array.isArray(recipient?.relayHints) + ? recipient.relayHints + : []; + if (!relayHints.length) { + this.showError("No DM relays found for this recipient."); + return false; + } + + if (recipient?.pubkey) { + this.setDmRelayHints(recipient.pubkey, relayHints); + } + + this.showSuccess("Recipient DM relays ready for use."); + if (controller?.focusMessageComposer) { + controller.focusMessageComposer(); + } + return true; + } + + handleProfilePrivacyToggle({ enabled, controller } = {}) { + if (enabled) { + this.showStatus("Privacy mode is coming in Phase 3."); + if (controller?.setPrivacyToggleState) { + controller.setPrivacyToggleState(false); + } + } + } + updateProfileInDOM(pubkey, profile) { if (this.profileIdentityController) { this.profileIdentityController.updateProfileIdentity({ diff --git a/js/channelProfile.js b/js/channelProfile.js index 496d60b4..03995ff9 100644 --- a/js/channelProfile.js +++ b/js/channelProfile.js @@ -3357,6 +3357,14 @@ function setupChannelMessageControls() { return; } + if (typeof app?.openDirectMessageComposer === "function") { + app.openDirectMessageComposer({ + recipientPubkey: resolvedHex, + source: "channel-profile", + }); + return; + } + const controller = app?.profileController; if (!controller) { app?.showError?.("Messages are unavailable right now."); diff --git a/js/ui/applicationBootstrap.js b/js/ui/applicationBootstrap.js index 7290193e..3d9be7c9 100644 --- a/js/ui/applicationBootstrap.js +++ b/js/ui/applicationBootstrap.js @@ -432,6 +432,7 @@ export default class ApplicationBootstrap { formatShortNpub: (value) => formatShortNpub(value), getProfileCacheEntry: (pubkey) => app.getProfileCacheEntry(pubkey), batchFetchProfiles: (authorSet) => app.batchFetchProfiles(authorSet), + fetchDmRelayHints: (pubkey) => app.fetchDmRelayHints(pubkey), switchProfile: (pubkey) => app.authService.switchProfile(pubkey), removeSavedProfile: (pubkey) => app.authService.removeSavedProfile(pubkey), @@ -499,6 +500,10 @@ export default class ApplicationBootstrap { setStoredActiveProfilePubkey(normalized, options); return getActiveProfilePubkey(); }, + getDmRecipient: () => app.getDmRecipientPubkey(), + setDmRecipient: (pubkey) => app.setDmRecipientPubkey(pubkey), + getDmRelayHints: (pubkey) => app.getDmRelayHints(pubkey), + setDmRelayHints: (pubkey, hints) => app.setDmRelayHints(pubkey, hints), }; const profileModalCallbacks = { @@ -532,6 +537,10 @@ export default class ApplicationBootstrap { app.handleProfileHistoryEvent(payload), onModerationSettingsChange: (payload) => app.handleModerationSettingsChange(payload), + onSendDm: (payload) => app.handleProfileSendDmRequest(payload), + onOpenRelays: (payload) => app.handleProfileUseDmRelays(payload), + onTogglePrivacy: (payload) => + app.handleProfilePrivacyToggle(payload), }; app.profileController = new ProfileModalController({ diff --git a/js/ui/profileModalController.js b/js/ui/profileModalController.js index 1de8b9b7..f2efa723 100644 --- a/js/ui/profileModalController.js +++ b/js/ui/profileModalController.js @@ -275,6 +275,13 @@ const SERVICE_CONTRACT = [ "Fetches and caches profile metadata for a set of pubkeys so the modal can hydrate avatars/names in bulk.", fallback: () => async () => [], }, + { + key: "fetchDmRelayHints", + type: "function", + description: + "Fetches DM relay hints (kind 10050) for a given pubkey.", + fallback: () => async () => [], + }, { key: "switchProfile", type: "function", @@ -694,6 +701,62 @@ const STATE_CONTRACT = [ return internal.walletBusy; }, }, + { + key: "getDmRecipient", + type: "function", + description: + "Returns the currently selected direct message recipient pubkey.", + fallback: (internal) => () => internal.dmRecipient, + }, + { + key: "setDmRecipient", + type: "function", + description: + "Updates the selected direct message recipient pubkey and returns the stored value.", + fallback: (internal) => (pubkey) => { + if (typeof pubkey === "string") { + const trimmed = pubkey.trim(); + internal.dmRecipient = trimmed || null; + } else { + internal.dmRecipient = null; + } + return internal.dmRecipient; + }, + }, + { + key: "getDmRelayHints", + type: "function", + description: + "Returns the cached DM relay hints for a given pubkey.", + fallback: (internal) => (pubkey) => { + const normalized = + typeof pubkey === "string" ? pubkey.trim().toLowerCase() : ""; + if (!normalized || !(internal.dmRelayHints instanceof Map)) { + return []; + } + const hints = internal.dmRelayHints.get(normalized); + return Array.isArray(hints) ? hints.slice() : []; + }, + }, + { + key: "setDmRelayHints", + type: "function", + description: + "Stores DM relay hints for a given pubkey and returns the stored list.", + fallback: (internal) => (pubkey, hints = []) => { + if (!(internal.dmRelayHints instanceof Map)) { + internal.dmRelayHints = new Map(); + } + const normalized = + typeof pubkey === "string" ? pubkey.trim().toLowerCase() : ""; + if (!normalized) { + return []; + } + const stored = Array.isArray(hints) ? hints.slice() : []; + internal.dmRelayHints.set(normalized, stored); + return stored; + }, + }, ]; function buildServicesContract(services = {}, internalState) { @@ -882,6 +945,8 @@ export class ProfileModalController { walletBusy: false, walletSettings: createInternalDefaultNwcSettings(), moderationSettings: createInternalDefaultModerationSettings(), + dmRecipient: null, + dmRelayHints: new Map(), }; this.services = buildServicesContract(services, this.internalState); @@ -1768,6 +1833,148 @@ export class ProfileModalController { return null; } + resolveActiveDmRecipient() { + const candidate = + typeof this.state.getDmRecipient === "function" + ? this.state.getDmRecipient() + : null; + const normalized = this.normalizeHexPubkey(candidate); + if (normalized) { + return normalized; + } + return null; + } + + buildDmRecipientContext(pubkey) { + const normalized = this.normalizeHexPubkey(pubkey); + if (!normalized) { + return null; + } + + const npub = + typeof this.safeEncodeNpub === "function" + ? this.safeEncodeNpub(normalized) + : null; + + const cacheEntry = + typeof this.services.getProfileCacheEntry === "function" + ? this.services.getProfileCacheEntry(normalized) + : null; + const profile = cacheEntry?.profile || null; + + const displayName = + profile?.display_name?.trim?.() || + profile?.name?.trim?.() || + (typeof this.formatShortNpub === "function" + ? this.formatShortNpub(npub) + : npub) || + npub || + "Unknown profile"; + + const relayHints = + typeof this.state.getDmRelayHints === "function" + ? this.state.getDmRelayHints(normalized) + : []; + + return { + pubkey: normalized, + npub, + profile, + displayName, + relayHints: Array.isArray(relayHints) ? relayHints.slice() : [], + }; + } + + async ensureDmRecipientData(pubkey) { + const normalized = this.normalizeHexPubkey(pubkey); + if (!normalized) { + return null; + } + + if ( + typeof this.services.batchFetchProfiles === "function" + ) { + try { + await this.services.batchFetchProfiles([normalized]); + } catch (error) { + devLogger.warn( + "[profileModal] Failed to fetch DM recipient metadata:", + error, + ); + } + } + + if (typeof this.services.fetchDmRelayHints === "function") { + try { + const hints = await this.services.fetchDmRelayHints(normalized); + if (typeof this.state.setDmRelayHints === "function") { + this.state.setDmRelayHints(normalized, hints); + } + } catch (error) { + devLogger.warn( + "[profileModal] Failed to fetch DM relay hints:", + error, + ); + } + } + + return this.buildDmRecipientContext(normalized); + } + + setDirectMessageRecipient(pubkey, { reason = "manual" } = {}) { + const normalized = this.normalizeHexPubkey(pubkey); + const nextRecipient = normalized || null; + + if (typeof this.state.setDmRecipient === "function") { + this.state.setDmRecipient(nextRecipient); + } + + this.updateMessageThreadSelection(nextRecipient); + + if (nextRecipient) { + void this.ensureDmRecipientData(nextRecipient); + this.setMessagesAnnouncement("Ready to message this recipient."); + } else if (reason === "clear") { + this.setMessagesAnnouncement("Message recipient cleared."); + } + + return nextRecipient; + } + + updateMessageThreadSelection(activeRecipient) { + if (!(this.profileMessagesList instanceof HTMLElement)) { + return; + } + + const normalized = this.normalizeHexPubkey(activeRecipient); + const items = Array.from( + this.profileMessagesList.querySelectorAll("[data-remote-pubkey]"), + ); + + items.forEach((item) => { + if (!(item instanceof HTMLElement)) { + return; + } + const remote = this.normalizeHexPubkey(item.dataset.remotePubkey); + const isActive = normalized && remote === normalized; + item.dataset.state = isActive ? "active" : "inactive"; + }); + } + + focusMessageComposer() { + const input = this.profileMessageInput; + if (input instanceof HTMLTextAreaElement) { + input.focus(); + input.select(); + } + } + + setPrivacyToggleState(enabled) { + if (this.profileMessagesPrivacyToggle instanceof HTMLInputElement) { + this.profileMessagesPrivacyToggle.checked = Boolean(enabled); + } + } + ensureDirectMessageSubscription(actorPubkey = null) { if ( !this.nostrService || @@ -1844,6 +2051,8 @@ export class ProfileModalController { ? this.normalizeHexPubkey(actorPubkey) : this.resolveActiveDmActor(); + this.setDirectMessageRecipient(null, { reason: "clear" }); + if ( this.directMessagesSubscription && this.directMessagesSubscription.actor && @@ -2056,27 +2265,49 @@ export class ProfileModalController { } } - handleSendDmRequest() { + async handleSendDmRequest() { + const recipient = this.resolveActiveDmRecipient(); + if (!recipient) { + this.showError("Please select a message recipient."); + return; + } + + const context = await this.ensureDmRecipientData(recipient); + this.focusMessageComposer(); + const callback = this.callbacks.onSendDm; if (callback && callback !== noop) { callback({ actorPubkey: this.resolveActiveDmActor(), + recipient: context, controller: this, }); } } - handleOpenDmRelaysRequest() { + async handleOpenDmRelaysRequest() { + const recipient = this.resolveActiveDmRecipient(); + if (!recipient) { + this.showError("Please select a message recipient."); + return; + } + + const context = await this.ensureDmRecipientData(recipient); + const callback = this.callbacks.onOpenRelays; if (callback && callback !== noop) { - callback({ controller: this }); + callback({ controller: this, recipient: context }); } } handlePrivacyToggle(enabled) { const callback = this.callbacks.onTogglePrivacy; if (callback && callback !== noop) { - callback({ controller: this, enabled: Boolean(enabled) }); + callback({ + controller: this, + enabled: Boolean(enabled), + recipient: this.buildDmRecipientContext(this.resolveActiveDmRecipient()), + }); } } @@ -2119,10 +2350,11 @@ export class ProfileModalController { return; } - const targetCandidate = - this.resolveActiveDmActor() || this.directMessagesLastActor; + const targetHex = this.resolveActiveDmRecipient(); const target = - typeof targetCandidate === "string" ? targetCandidate.trim() : ""; + typeof targetHex === "string" && typeof this.safeEncodeNpub === "function" + ? this.safeEncodeNpub(targetHex) + : ""; if (!target) { this.showError("Please select a message recipient."); return; @@ -2171,6 +2403,7 @@ export class ProfileModalController { this.directMessagesCache = []; this.directMessagesLastActor = this.resolveActiveDmActor(); this.messagesInitialLoadPending = true; + this.setDirectMessageRecipient(null, { reason: "clear" }); if (this.profileMessagesList instanceof HTMLElement) { this.profileMessagesList.innerHTML = ""; @@ -2390,6 +2623,7 @@ export class ProfileModalController { const item = document.createElement("li"); item.className = "card flex flex-col gap-3 p-4"; item.setAttribute("data-remote-pubkey", thread.remoteHex); + item.dataset.state = "inactive"; const header = document.createElement("div"); header.className = "flex items-start justify-between gap-3"; @@ -2467,6 +2701,13 @@ export class ProfileModalController { item.appendChild(meta); + item.addEventListener("click", () => { + this.setDirectMessageRecipient(thread.remoteHex, { + reason: "thread-select", + }); + this.focusMessageComposer(); + }); + return item; } @@ -2523,6 +2764,19 @@ export class ProfileModalController { } } + const activeRecipient = this.resolveActiveDmRecipient(); + const hasActiveRecipient = + activeRecipient && + threads.some((thread) => thread.remoteHex === activeRecipient); + + if (threads.length && !hasActiveRecipient) { + this.setDirectMessageRecipient(threads[0].remoteHex, { + reason: "thread-default", + }); + } else if (hasActiveRecipient) { + this.updateMessageThreadSelection(activeRecipient); + } + this.profileMessagesList.classList.remove("hidden"); this.profileMessagesList.removeAttribute("hidden"); } @@ -2696,6 +2950,7 @@ export class ProfileModalController { } this.directMessagesCache = []; + this.setDirectMessageRecipient(null, { reason: "clear" }); if (this.profileMessagesList instanceof HTMLElement) { this.profileMessagesList.innerHTML = ""; this.profileMessagesList.classList.add("hidden"); @@ -2842,13 +3097,13 @@ export class ProfileModalController { if (this.profileMessagesSendDmButton instanceof HTMLElement) { this.profileMessagesSendDmButton.addEventListener("click", () => { - this.handleSendDmRequest(); + void this.handleSendDmRequest(); }); } if (this.profileMessagesOpenRelaysButton instanceof HTMLElement) { this.profileMessagesOpenRelaysButton.addEventListener("click", () => { - this.handleOpenDmRelaysRequest(); + void this.handleOpenDmRelaysRequest(); }); }