From e5698015206b7e25687f19844ea7a4eff1e0f1f2 Mon Sep 17 00:00:00 2001
From: thePR0M3TH3AN <53631862+PR0M3TH3AN@users.noreply.github.com>
Date: Mon, 19 Jan 2026 14:40:21 -0500
Subject: [PATCH] Add DM app shell mapping for profile modal
---
components/profile-modal.html | 30 ++
js/ui/profileModalController.js | 703 ++++++++++++++++++++++++++++++--
2 files changed, 706 insertions(+), 27 deletions(-)
diff --git a/components/profile-modal.html b/components/profile-modal.html
index b10ac6e2..b9f4cee5 100644
--- a/components/profile-modal.html
+++ b/components/profile-modal.html
@@ -1067,6 +1067,36 @@
role="tabpanel"
aria-labelledby="profileNavMessages"
>
+
+
+
Direct message privacy
+
+ Control read receipts and typing indicators for direct messages.
+
+
+
+
+
+
+
this.resolveDirectMessageRemote(entry, actor) === remote)
+ : [];
+
+ threadMessages.sort((a, b) => (a?.timestamp || 0) - (b?.timestamp || 0));
+
+ const timeline = [];
+ let lastDayLabel = "";
+
+ threadMessages.forEach((message) => {
+ const dayLabel = this.formatMessageDayLabel(message?.timestamp);
+ if (dayLabel && dayLabel !== lastDayLabel) {
+ timeline.push({ type: "day", label: dayLabel });
+ lastDayLabel = dayLabel;
+ }
+
+ timeline.push({
+ id: this.resolveDirectMessageEventId(message) || "",
+ direction: message?.direction || "incoming",
+ body: this.resolveDirectMessageBody(message) || "Encrypted message",
+ timestamp: this.formatMessageClockTime(message?.timestamp),
+ status: this.resolveDirectMessageStatus(message),
+ });
+ });
+
+ return timeline;
+ }
+
+ async buildDmConversationData(messages, { actorPubkey } = {}) {
+ const actor = this.normalizeHexPubkey(
+ actorPubkey || this.resolveActiveDmActor(),
+ );
+ if (!actor) {
+ return {
+ actor: "",
+ conversations: [],
+ activeConversationId: "",
+ activeThread: null,
+ activeRemotePubkey: null,
+ timeline: [],
+ };
+ }
+
+ const threads = this.groupDirectMessages(messages, actor);
+ const remoteKeys = new Set();
+ threads.forEach((thread) => {
+ if (thread.remoteHex) {
+ remoteKeys.add(thread.remoteHex);
+ }
+ });
+
+ if (
+ remoteKeys.size &&
+ this.services.batchFetchProfiles &&
+ typeof this.services.batchFetchProfiles === "function"
+ ) {
+ try {
+ await this.services.batchFetchProfiles(remoteKeys);
+ } catch (error) {
+ devLogger.warn(
+ "[profileModal] Failed to fetch DM profile metadata:",
+ error,
+ );
+ }
+ }
+
+ const conversations = threads.map((thread) => {
+ const conversationId = this.buildDmConversationId(actor, thread.remoteHex);
+ const summary = this.resolveProfileSummaryForPubkey(thread.remoteHex);
+ const preview = this.resolveDirectMessagePreviewForConversation(thread.latestMessage);
+ const unreadCount =
+ this.nostrService &&
+ typeof this.nostrService.getDirectMessageUnseenCount === "function" &&
+ conversationId
+ ? this.nostrService.getDirectMessageUnseenCount(conversationId)
+ : 0;
+ const recipientContext = this.buildDmRecipientContext(thread.remoteHex);
+
+ return {
+ id: conversationId,
+ name: summary.displayName,
+ preview: preview || "Encrypted message",
+ timestamp: this.formatConversationTimestamp(thread.latestTimestamp),
+ unreadCount,
+ avatarSrc: summary.avatarSrc,
+ status: summary.status,
+ pubkey: thread.remoteHex,
+ lightningAddress: summary.lightningAddress,
+ relayHints: Array.isArray(recipientContext?.relayHints)
+ ? recipientContext.relayHints
+ : [],
+ };
+ });
+
+ const conversationMap = new Map();
+ threads.forEach((thread) => {
+ const conversationId = this.buildDmConversationId(actor, thread.remoteHex);
+ if (conversationId) {
+ conversationMap.set(conversationId, thread);
+ }
+ });
+
+ const storedRecipient = this.resolveActiveDmRecipient();
+ const storedConversationId =
+ storedRecipient && actor
+ ? this.buildDmConversationId(actor, storedRecipient)
+ : "";
+ const preferredConversationId =
+ this.activeDmConversationId || storedConversationId;
+ const fallbackConversationId = conversations[0]?.id || "";
+ const activeConversationId =
+ preferredConversationId && conversationMap.has(preferredConversationId)
+ ? preferredConversationId
+ : fallbackConversationId;
+ const activeThread = activeConversationId
+ ? conversationMap.get(activeConversationId)
+ : null;
+ const activeRemotePubkey =
+ activeThread?.remoteHex ||
+ (activeConversationId
+ ? this.resolveRemoteForConversationId(activeConversationId, actor)
+ : storedRecipient) ||
+ null;
+
+ if (activeConversationId && this.activeDmConversationId !== activeConversationId) {
+ this.activeDmConversationId = activeConversationId;
+ }
+
+ return {
+ actor,
+ conversations,
+ activeConversationId,
+ activeThread,
+ activeRemotePubkey,
+ timeline:
+ activeRemotePubkey && actor
+ ? this.buildDmMessageTimeline(messages, {
+ actorPubkey: actor,
+ remotePubkey: activeRemotePubkey,
+ })
+ : [],
+ };
+ }
+
groupDirectMessages(messages, actorPubkey) {
const normalizedActor = actorPubkey
? this.normalizeHexPubkey(actorPubkey)
@@ -3864,11 +4238,17 @@ export class ProfileModalController {
}
async renderProfileMessages(messages, { actorPubkey = null } = {}) {
+ if (this.dmAppShellContainer instanceof HTMLElement) {
+ await this.renderDmAppShell(messages, { actorPubkey });
+ }
+
if (!(this.profileMessagesList instanceof HTMLElement)) {
- this.pendingMessagesRender = {
- messages: Array.isArray(messages) ? messages : [],
- actorPubkey,
- };
+ if (!(this.dmAppShellContainer instanceof HTMLElement)) {
+ this.pendingMessagesRender = {
+ messages: Array.isArray(messages) ? messages : [],
+ actorPubkey,
+ };
+ }
return;
}
@@ -3935,6 +4315,288 @@ export class ProfileModalController {
void this.renderDirectMessageConversation();
}
+ async renderDmAppShell(messages, { actorPubkey = null } = {}) {
+ const container =
+ this.dmAppShellContainer instanceof HTMLElement
+ ? this.dmAppShellContainer
+ : null;
+ if (!container) {
+ return;
+ }
+
+ const snapshot = Array.isArray(messages) ? messages : this.directMessagesCache;
+ const {
+ actor,
+ conversations,
+ activeConversationId,
+ activeThread,
+ timeline,
+ } = await this.buildDmConversationData(snapshot, { actorPubkey });
+
+ const currentRecipient = this.resolveActiveDmRecipient();
+ if (!currentRecipient && activeThread?.remoteHex) {
+ this.setDirectMessageRecipient(activeThread.remoteHex, {
+ reason: "thread-default",
+ });
+ }
+
+ const loadingState = this.messagesLoadingState || "idle";
+ const conversationState =
+ loadingState === "loading"
+ ? "loading"
+ : loadingState === "error"
+ ? "error"
+ : loadingState === "empty" || loadingState === "unauthenticated"
+ ? "empty"
+ : "idle";
+
+ const hasActiveConversation = Boolean(activeConversationId);
+ const threadState =
+ conversationState === "loading"
+ ? "loading"
+ : !hasActiveConversation
+ ? "empty"
+ : timeline.length
+ ? "idle"
+ : "empty";
+
+ const privacyMode = this.resolveConversationPrivacyMode(
+ activeThread?.latestMessage,
+ );
+
+ container.innerHTML = "";
+
+ try {
+ this.dmAppShell = new AppShell({
+ document,
+ conversations,
+ activeConversationId,
+ conversationState,
+ messages: timeline,
+ threadState,
+ privacyMode,
+ composerState: this.dmComposerState || "idle",
+ notifications: [],
+ onSelectConversation: (conversation) => {
+ void this.handleDmConversationSelect(conversation);
+ },
+ onSendMessage: (messageText, payload) => {
+ void this.handleDmAppShellSendMessage(messageText, payload);
+ },
+ });
+ } catch (error) {
+ this.dmAppShell = null;
+ devLogger.warn("[profileModal] Failed to render DM app shell:", error);
+ return;
+ }
+
+ const root =
+ this.dmAppShell &&
+ typeof this.dmAppShell.getRoot === "function"
+ ? this.dmAppShell.getRoot()
+ : null;
+ if (!(root instanceof HTMLElement)) {
+ devLogger.warn("[profileModal] DM app shell root missing.");
+ return;
+ }
+
+ container.appendChild(root);
+
+ if (actor && activeConversationId) {
+ void this.nostrService?.acknowledgeRenderedDirectMessages?.(
+ activeConversationId,
+ Date.now() / 1000,
+ );
+ }
+ }
+
+ setFocusedDmConversation(conversationId) {
+ if (
+ !this.nostrService ||
+ typeof this.nostrService.setFocusedDirectMessageConversation !== "function"
+ ) {
+ return;
+ }
+
+ if (
+ this.focusedDmConversationId &&
+ this.focusedDmConversationId !== conversationId
+ ) {
+ this.nostrService.setFocusedDirectMessageConversation(
+ this.focusedDmConversationId,
+ false,
+ );
+ }
+
+ if (conversationId) {
+ this.nostrService.setFocusedDirectMessageConversation(conversationId, true);
+ this.focusedDmConversationId = conversationId;
+ } else {
+ this.focusedDmConversationId = "";
+ }
+ }
+
+ async handleDmConversationSelect(conversation) {
+ const conversationId =
+ conversation && typeof conversation.id === "string"
+ ? conversation.id.trim()
+ : "";
+ if (!conversationId) {
+ return;
+ }
+
+ const actor = this.resolveActiveDmActor();
+ const remote = this.resolveRemoteForConversationId(conversationId, actor);
+
+ this.activeDmConversationId = conversationId;
+ if (remote) {
+ this.setDirectMessageRecipient(remote, { reason: "thread-select" });
+ }
+
+ this.setFocusedDmConversation(conversationId);
+
+ await this.renderDmAppShell(this.directMessagesCache, {
+ actorPubkey: actor,
+ });
+ }
+
+ async handleDmAppShellSendMessage(messageText, payload = {}) {
+ const normalizedPayload =
+ payload && typeof payload === "object" ? payload : {};
+ const message =
+ typeof messageText === "string" ? messageText.trim() : "";
+ const attachments = Array.isArray(normalizedPayload.attachments)
+ ? normalizedPayload.attachments
+ : [];
+
+ if (!message && !attachments.length) {
+ this.showError("Please enter a message or attach a file.");
+ this.dmComposerState = "error";
+ await this.renderDmAppShell(this.directMessagesCache, {
+ actorPubkey: this.resolveActiveDmActor(),
+ });
+ return;
+ }
+
+ const actor = this.resolveActiveDmActor();
+ const activeConversationId =
+ this.activeDmConversationId ||
+ (actor && this.resolveActiveDmRecipient()
+ ? this.buildDmConversationId(actor, this.resolveActiveDmRecipient())
+ : "");
+ const targetHex =
+ this.resolveRemoteForConversationId(activeConversationId, actor) ||
+ this.resolveActiveDmRecipient();
+
+ const target =
+ typeof targetHex === "string" && typeof this.safeEncodeNpub === "function"
+ ? this.safeEncodeNpub(targetHex)
+ : "";
+ if (!target) {
+ this.showError("Please select a message recipient.");
+ this.dmComposerState = "error";
+ await this.renderDmAppShell(this.directMessagesCache, {
+ actorPubkey: actor,
+ });
+ return;
+ }
+
+ if (
+ !this.services.nostrClient ||
+ typeof this.services.nostrClient.sendDirectMessage !== "function"
+ ) {
+ this.showError("Direct message service unavailable.");
+ this.dmComposerState = "error";
+ await this.renderDmAppShell(this.directMessagesCache, {
+ actorPubkey: actor,
+ });
+ return;
+ }
+
+ const privacyMode =
+ typeof normalizedPayload.privacyMode === "string"
+ ? normalizedPayload.privacyMode.trim().toLowerCase()
+ : "nip04";
+ const useNip17 = privacyMode === "nip17" || privacyMode === "private";
+
+ if (attachments.length && !useNip17) {
+ this.showError(
+ "Attachments require NIP-17 delivery. Enable the privacy toggle to send files.",
+ );
+ this.dmComposerState = "error";
+ await this.renderDmAppShell(this.directMessagesCache, {
+ actorPubkey: actor,
+ });
+ return;
+ }
+
+ const recipientContext = this.buildDmRecipientContext(targetHex);
+ const recipientRelayHints = Array.isArray(recipientContext?.relayHints)
+ ? recipientContext.relayHints
+ : [];
+ const senderRelayHints = this.getActiveDmRelayPreferences();
+
+ if (useNip17 && !recipientRelayHints.length) {
+ this.showStatus(
+ "Privacy warning: this recipient has not shared NIP-17 relays, so we'll use your default relays.",
+ );
+ }
+
+ this.dmComposerState = "sending";
+ await this.renderDmAppShell(this.directMessagesCache, {
+ actorPubkey: actor,
+ });
+
+ try {
+ const result = await this.services.nostrClient.sendDirectMessage(
+ target,
+ message,
+ null,
+ useNip17
+ ? {
+ useNip17: true,
+ recipientRelayHints,
+ senderRelayHints,
+ attachments,
+ }
+ : {},
+ );
+
+ if (result?.ok) {
+ 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.",
+ );
+ }
+ if (
+ this.nostrService &&
+ typeof this.nostrService.loadDirectMessages === "function"
+ ) {
+ await this.nostrService.loadDirectMessages({
+ actorPubkey: actor,
+ initialLoad: false,
+ });
+ }
+ this.dmComposerState = "idle";
+ } else {
+ const errorCode =
+ typeof result?.error === "string" ? result.error : "unknown";
+ userLogger.warn("[profileModal] Failed to send direct message:", errorCode);
+ this.showError(this.describeDirectMessageSendError(errorCode));
+ this.dmComposerState = "error";
+ }
+ } catch (error) {
+ userLogger.error("[profileModal] Unexpected DM send failure:", error);
+ this.showError("Unable to send message. Please try again.");
+ this.dmComposerState = "error";
+ } finally {
+ await this.renderDmAppShell(this.directMessagesCache, {
+ actorPubkey: actor,
+ });
+ }
+ }
+
async populateProfileMessages(options = {}) {
const settings =
options && typeof options === "object" ? options : { force: false };
@@ -4051,28 +4713,9 @@ export class ProfileModalController {
if (!container) {
return;
}
-
- container.innerHTML = "";
-
- try {
- this.dmAppShell = new AppShell({ document });
- } catch (error) {
- this.dmAppShell = null;
- devLogger.warn("[profileModal] Failed to mount DM app shell:", error);
- return;
- }
-
- const root =
- this.dmAppShell &&
- typeof this.dmAppShell.getRoot === "function"
- ? this.dmAppShell.getRoot()
- : null;
- if (!(root instanceof HTMLElement)) {
- devLogger.warn("[profileModal] DM app shell root missing.");
- return;
- }
-
- container.appendChild(root);
+ void this.renderDmAppShell(this.directMessagesCache, {
+ actorPubkey: this.directMessagesLastActor,
+ });
}
unmountDmAppShell() {
@@ -4164,6 +4807,12 @@ export class ProfileModalController {
} else {
this.setMessagesLoadingState("empty");
}
+
+ if (this.dmAppShellContainer instanceof HTMLElement) {
+ void this.renderDmAppShell(this.directMessagesCache, {
+ actorPubkey: actor,
+ });
+ }
}
handleDirectMessagesError(detail = {}) {