Add DM recipient handling in profile modal

This commit is contained in:
thePR0M3TH3AN committed 2026-01-19 09:22:35 -05:00
1 parent cd72b72ea1
commit eb52fbdf11
5 files changed
+452 -12

No files matched your search

+3 -3
View File
@@ -1031,9 +1031,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">Preferred DM relay</h4>
<h4 class="text-xs font-semibold text-text">Recipient DM relays</h4>
<p class="text-xs text-muted">
Select a relay to prioritize encrypted message delivery.
Use the relays advertised in kind 10050 to prioritize encrypted delivery.
</p>
</div>
<button
@@ -1042,7 +1042,7 @@
class="btn-ghost focus-ring inline-flex items-center gap-2"
data-size="sm"
>
Manage relays
Use DM relays
</button>
</div>
<div class="flex flex-wrap items-center justify-between gap-3 border-t border-border/60 pt-3">
+168
View File
@@ -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({
+8
View File
@@ -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.");
+9
View File
@@ -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({
+264 -9
View File
@@ -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();
});
}