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();
});
}