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 = {}) {