import { devLogger, userLogger } from "../../utils/logger.js"; import { extractAttachmentsFromMessage, formatAttachmentSize, describeAttachment } from "../../attachments/attachmentUtils.js"; import { downloadAttachment, clearAttachmentCache, getAttachmentCacheStats } from "../../services/attachmentService.js"; import { getLinkPreviewSettings, setLinkPreviewAutoFetch } from "../../utils/linkPreviewSettings.js"; import { sanitizeRelayList } from "../../nostr/nip46Client.js"; import { SHORT_TIMEOUT_MS } from "../../constants.js"; const TYPING_INDICATOR_TTL_SECONDS = 15; const TYPING_INDICATOR_COOLDOWN_MS = 4000; export class ProfileDirectMessageActions { constructor(mainController, controller) { this.mainController = mainController; this.controller = controller; } getDmPrivacySettingsSnapshot(...args) { return this.controller.getDmPrivacySettingsSnapshot(...args); } persistDmPrivacySettings(...args) { return this.controller.persistDmPrivacySettings(...args); } syncDmPrivacySettingsUi(...args) { return this.controller.syncDmPrivacySettingsUi(...args); } focusMessageComposer(...args) { return this.controller.focusMessageComposer(...args); } setDirectMessageRecipient(...args) { return this.controller.setDirectMessageRecipient(...args); } handleReadReceiptsToggle(enabled) { this.persistDmPrivacySettings({ readReceiptsEnabled: Boolean(enabled), }); this.syncDmPrivacySettingsUi(); this.mainController.showStatus( enabled ? "Read receipts enabled for direct messages." : "Read receipts disabled.", ); } handleTypingIndicatorsToggle(enabled) { this.persistDmPrivacySettings({ typingIndicatorsEnabled: Boolean(enabled), }); this.syncDmPrivacySettingsUi(); this.mainController.showStatus( enabled ? "Typing indicators enabled for direct messages." : "Typing indicators disabled.", ); } handleLinkPreviewToggle(enabled) { setLinkPreviewAutoFetch(Boolean(enabled)); } openDmSettingsModal() { const owner = this.controller.helper.resolveActiveDmRelayOwner(); if (!owner) { this.mainController.showError("Please sign in to manage DM settings."); return; } const privacySettings = this.getDmPrivacySettingsSnapshot(); const relayHints = this.controller.helper.getActiveDmRelayPreferences(); this.controller.dmSettingsModalController.show({ privacySettings, relayHints, onPrivacyChange: (key, value) => { this.persistDmPrivacySettings({ [key]: value }); if (key === "readReceiptsEnabled") { this.mainController.showStatus( value ? "Read receipts enabled." : "Read receipts disabled.", ); } else if (key === "typingIndicatorsEnabled") { this.mainController.showStatus( value ? "Typing indicators enabled." : "Typing indicators disabled.", ); } }, onPublishRelays: async (urls) => { return this.controller.handleDmSettingsPublish(urls); }, }); } async handleSendDmRequest() { const recipient = this.controller.helper.resolveActiveDmRecipient(); if (!recipient) { this.mainController.showError("Please select a message recipient."); return; } const context = await this.controller.helper.ensureDmRecipientData(recipient); this.focusMessageComposer(); const callback = this.mainController.callbacks.onSendDm; if (callback && callback !== noop) { callback({ actorPubkey: this.controller.helper.resolveActiveDmActor(), recipient: context, controller: this, }); } } async handleOpenDmRelaysRequest() { const recipient = this.controller.helper.resolveActiveDmRecipient(); if (!recipient) { this.mainController.showError("Please select a message recipient."); return; } const context = await this.controller.helper.ensureDmRecipientData(recipient); const callback = this.mainController.callbacks.onOpenRelays; if (callback && callback !== noop) { callback({ controller: this, recipient: context }); } } handlePrivacyToggle(enabled) { const recipientContext = this.controller.helper.buildDmRecipientContext( this.controller.helper.resolveActiveDmRecipient(), ); const relayHints = Array.isArray(recipientContext?.relayHints) ? recipientContext.relayHints : []; if (this.controller.enableNip17RelayWarning && enabled && !relayHints.length) { this.mainController.showStatus( "Privacy warning: this recipient has not shared NIP-17 relays, so we'll use your default relays.", { autoHideMs: SHORT_TIMEOUT_MS }, ); } this.dmPrivacyToggleTouched = true; this.controller.renderer.updateMessagePrivacyModeDisplay(); const callback = this.mainController.callbacks.onTogglePrivacy; if (callback && callback !== noop) { callback({ controller: this, enabled: Boolean(enabled), recipient: recipientContext, }); } } handleAttachmentSelection() { const input = this.controller.renderer.profileMessageAttachmentInput; if (!(input instanceof HTMLInputElement) || !input.files) { return; } const files = Array.from(input.files); if (!files.length) { return; } files.forEach((file) => { const previewUrl = typeof URL !== "undefined" ? URL.createObjectURL(file) : ""; this.controller.dmAttachmentQueue.push({ id: this.mainController.generateAttachmentId(file), file, name: file.name, type: file.type || "application/octet-stream", size: file.size, previewUrl, status: "pending", progress: 0, }); }); input.value = ""; this.mainController.renderAttachmentQueue(); } async handleSendProfileMessage() { const input = this.controller.renderer.profileMessageInput; if (!(input instanceof HTMLTextAreaElement)) { return; } const message = typeof input.value === "string" ? input.value.trim() : ""; const hasAttachments = this.controller.dmAttachmentQueue.length > 0; if (!message && !hasAttachments) { this.mainController.showError("Please enter a message or attach a file."); return; } const targetHex = this.controller.helper.resolveActiveDmRecipient(); const target = typeof targetHex === "string" && typeof this.mainController.safeEncodeNpub === "function" ? this.mainController.safeEncodeNpub(targetHex) : ""; if (!target) { this.mainController.showError("Please select a message recipient."); return; } const recipientContext = this.controller.helper.buildDmRecipientContext(targetHex); const recipientRelayHints = Array.isArray(recipientContext?.relayHints) ? recipientContext.relayHints : []; const useNip17 = this.controller.renderer.profileMessagesPrivacyToggle instanceof HTMLInputElement ? this.controller.renderer.profileMessagesPrivacyToggle.checked : false; const senderRelayHints = this.controller.helper.getActiveDmRelayPreferences(); if (hasAttachments && !useNip17) { this.mainController.showError( "Attachments require NIP-17 delivery. Enable the privacy toggle to send files.", ); return; } if (this.controller.enableNip17RelayWarning && useNip17 && !recipientRelayHints.length) { this.mainController.showStatus( "Privacy warning: this recipient has not shared NIP-17 relays, so we'll use your default relays.", { autoHideMs: SHORT_TIMEOUT_MS }, ); } if ( !this.mainController.services.nostrClient || typeof this.mainController.services.nostrClient.sendDirectMessage !== "function" ) { this.mainController.showError("Direct message service unavailable."); return; } // DMs are encrypted (NIP-17 gift wrap / NIP-04). A reloaded nsec session can // lose its in-memory key; re-unlock it with one passphrase prompt rather than // failing to send (TODO #57). Only user-driven outcomes stop the send; other // cases fall through to sendDirectMessage's own error handling. const ensureSigner = this.mainController.services?.ensureEncryptionCapableSigner; if (typeof ensureSigner === "function") { let ensured = { ok: true }; try { ensured = await ensureSigner({ pubkey: this.controller.helper.resolveActiveDmActor(), need: "encrypt", promptMessage: "Re-enter your PIN / passphrase to unlock your key and send messages.", }); } catch (error) { ensured = { ok: false, reason: "ensure-failed" }; } if ( ensured && (ensured.reason === "cancelled" || ensured.reason === "bad-passphrase") ) { return; } } const sendButton = this.controller.renderer.profileMessageSendButton; if (sendButton instanceof HTMLElement && "disabled" in sendButton) { sendButton.disabled = true; sendButton.setAttribute("aria-disabled", "true"); } try { let attachmentPayloads = []; if (hasAttachments) { try { attachmentPayloads = await this.mainController.uploadAttachmentQueue( this.controller.helper.resolveActiveDmActor(), ); } catch (error) { const messageText = error && typeof error.message === "string" ? error.message : "Attachment upload failed."; this.mainController.showError(messageText); return; } } const result = await this.mainController.services.nostrClient.sendDirectMessage( target, message, null, useNip17 ? { useNip17: true, recipientRelayHints, senderRelayHints, attachments: attachmentPayloads, } : {}, ); if (result?.ok) { input.value = ""; this.mainController.resetAttachmentQueue({ clearInput: true }); this.mainController.showSuccess("Message sent."); if (this.controller.enableNip17RelayWarning && result?.warning === "dm-relays-fallback") { this.mainController.showStatus( "Privacy warning: this message used default relays because no NIP-17 relay list was found.", { autoHideMs: SHORT_TIMEOUT_MS }, ); } void this.populateProfileMessages({ force: true, reason: "send-message" }); return; } const errorCode = typeof result?.error === "string" ? result.error : "unknown"; userLogger.warn("[profileModal] Failed to send direct message:", errorCode); this.mainController.showError(this.controller.describeDirectMessageSendError(errorCode)); } catch (error) { userLogger.error("[profileModal] Unexpected DM send failure:", error); this.mainController.showError("Unable to send message. Please try again."); } finally { this.controller.renderer.updateMessageComposerState(); } } async handleDmConversationSelect(conversation) { const conversationId = conversation && typeof conversation.id === "string" ? conversation.id.trim() : ""; if (!conversationId) { return; } this.controller.dmMobileView = "thread"; const actor = this.controller.helper.resolveActiveDmActor(); const remote = this.controller.helper.resolveRemoteForConversationId(conversationId, actor); this.controller.activeDmConversationId = conversationId; if (remote) { this.setDirectMessageRecipient(remote, { reason: "thread-select" }); } this.controller.setFocusedDmConversation(conversationId); await this.controller.renderer.renderDmAppShell(this.controller.directMessagesCache, { actorPubkey: actor, }); } async handleDmConversationMarkRead(conversation) { const conversationId = conversation && typeof conversation.id === "string" ? conversation.id.trim() : this.controller.activeDmConversationId; if (!conversationId) { return; } const actor = this.controller.helper.resolveActiveDmActor(); if ( !actor || !this.mainController.nostrService || typeof this.mainController.nostrService.acknowledgeRenderedDirectMessages !== "function" ) { return; } const renderedUntil = this.controller.helper.getLatestDirectMessageTimestampForConversation( conversationId, actor, ); try { await this.mainController.nostrService.acknowledgeRenderedDirectMessages( conversationId, renderedUntil, ); } catch (error) { devLogger.warn("[profileModal] Failed to mark conversation read:", error); } const recipient = this.controller.helper.resolveRemoteForConversationId(conversationId, actor); const messages = this.controller.helper.getDirectMessagesForConversation(conversationId, actor); if (recipient && messages.length) { void this.controller.maybePublishReadReceipt(messages, { recipientPubkey: recipient, }); } await this.controller.renderer.renderDmAppShell(this.controller.directMessagesCache, { actorPubkey: actor, }); } async handleDmMarkAllConversationsRead() { if ( !this.mainController.nostrService || typeof this.mainController.nostrService.acknowledgeRenderedDirectMessages !== "function" ) { return; } const actor = this.controller.helper.resolveActiveDmActor(); if (!actor) { return; } const summaries = typeof this.mainController.nostrService.listDirectMessageConversationSummaries === "function" ? await this.mainController.nostrService.listDirectMessageConversationSummaries() : []; const list = Array.isArray(summaries) ? summaries : []; for (const summary of list) { const conversationId = typeof summary?.conversation_id === "string" ? summary.conversation_id.trim() : typeof summary?.conversationId === "string" ? summary.conversationId.trim() : ""; if (!conversationId) { continue; } const renderedUntil = Number(summary?.last_message_at) || Number(summary?.downloaded_until) || Number(summary?.opened_until) || this.controller.helper.getLatestDirectMessageTimestampForConversation(conversationId, actor); try { await this.mainController.nostrService.acknowledgeRenderedDirectMessages( conversationId, renderedUntil, ); } catch (error) { devLogger.warn( "[profileModal] Failed to mark conversation read:", error, ); } const recipient = this.controller.helper.resolveRemoteForConversationId(conversationId, actor); const messages = this.controller.helper.getDirectMessagesForConversation(conversationId, actor); if (recipient && messages.length) { void this.controller.maybePublishReadReceipt(messages, { recipientPubkey: recipient, }); } } await this.controller.renderer.renderDmAppShell(this.controller.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.mainController.showError("Please enter a message or attach a file."); this.controller.dmComposerState = "error"; await this.controller.renderer.renderDmAppShell(this.controller.directMessagesCache, { actorPubkey: this.controller.helper.resolveActiveDmActor(), }); return; } const actor = this.controller.helper.resolveActiveDmActor(); const activeConversationId = this.controller.activeDmConversationId || (actor && this.controller.helper.resolveActiveDmRecipient() ? this.controller.helper.buildDmConversationId(actor, this.controller.helper.resolveActiveDmRecipient()) : ""); const targetHex = this.controller.helper.resolveRemoteForConversationId(activeConversationId, actor) || this.controller.helper.resolveActiveDmRecipient(); const target = typeof targetHex === "string" && typeof this.mainController.safeEncodeNpub === "function" ? this.mainController.safeEncodeNpub(targetHex) : ""; if (!target) { this.mainController.showError("Please select a message recipient."); this.controller.dmComposerState = "error"; await this.controller.renderer.renderDmAppShell(this.controller.directMessagesCache, { actorPubkey: actor, }); return; } if ( !this.mainController.services.nostrClient || typeof this.mainController.services.nostrClient.sendDirectMessage !== "function" ) { this.mainController.showError("Direct message service unavailable."); this.controller.dmComposerState = "error"; await this.controller.renderer.renderDmAppShell(this.controller.directMessagesCache, { actorPubkey: actor, }); return; } // Re-unlock a reloaded nsec signer before sending (TODO #57). Same gate as the // profile-modal composer; only user-driven outcomes stop the send. const ensureSigner = this.mainController.services?.ensureEncryptionCapableSigner; if (typeof ensureSigner === "function") { let ensured = { ok: true }; try { ensured = await ensureSigner({ pubkey: actor, need: "encrypt", promptMessage: "Re-enter your PIN / passphrase to unlock your key and send messages.", }); } catch (error) { ensured = { ok: false, reason: "ensure-failed" }; } if ( ensured && (ensured.reason === "cancelled" || ensured.reason === "bad-passphrase") ) { this.controller.dmComposerState = "idle"; await this.controller.renderer.renderDmAppShell( this.controller.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.mainController.showError( "Attachments require NIP-17 delivery. Enable the privacy toggle to send files.", ); this.controller.dmComposerState = "error"; await this.controller.renderer.renderDmAppShell(this.controller.directMessagesCache, { actorPubkey: actor, }); return; } const recipientContext = this.controller.helper.buildDmRecipientContext(targetHex); const recipientRelayHints = Array.isArray(recipientContext?.relayHints) ? recipientContext.relayHints : []; const senderRelayHints = this.controller.helper.getActiveDmRelayPreferences(); if (useNip17 && !recipientRelayHints.length) { this.mainController.showStatus( "Privacy warning: this recipient has not shared NIP-17 relays, so we'll use your default relays.", { autoHideMs: SHORT_TIMEOUT_MS }, ); } this.controller.dmComposerState = "sending"; await this.controller.renderer.renderDmAppShell(this.controller.directMessagesCache, { actorPubkey: actor, }); try { const result = await this.mainController.services.nostrClient.sendDirectMessage( target, message, null, useNip17 ? { useNip17: true, recipientRelayHints, senderRelayHints, attachments, } : {}, ); if (result?.ok) { this.mainController.showSuccess("Message sent."); if (result?.warning === "dm-relays-fallback") { this.mainController.showStatus( "Privacy warning: this message used default relays because no NIP-17 relay list was found.", { autoHideMs: SHORT_TIMEOUT_MS }, ); } if ( this.mainController.nostrService && typeof this.mainController.nostrService.loadDirectMessages === "function" ) { await this.mainController.nostrService.loadDirectMessages({ actorPubkey: actor, initialLoad: false, }); } this.controller.dmComposerState = "idle"; } else { const errorCode = typeof result?.error === "string" ? result.error : "unknown"; userLogger.warn("[profileModal] Failed to send direct message:", errorCode); this.mainController.showError(this.controller.describeDirectMessageSendError(errorCode)); this.controller.dmComposerState = "error"; } } catch (error) { userLogger.error("[profileModal] Unexpected DM send failure:", error); this.mainController.showError("Unable to send message. Please try again."); this.controller.dmComposerState = "error"; } finally { await this.controller.renderer.renderDmAppShell(this.controller.directMessagesCache, { actorPubkey: actor, }); } } async populateProfileMessages(options = {}) { const settings = options && typeof options === "object" ? options : { force: false }; const { force = false } = settings; const actor = this.controller.helper.resolveActiveDmActor(); if (!actor) { this.mainController.clearProfileMessages(); return; } if ( !this.mainController.nostrService || typeof this.mainController.nostrService.loadDirectMessages !== "function" ) { this.controller.renderer.setMessagesLoadingState("error", { message: "Direct message service unavailable.", }); return; } if ( !force && !this.controller.messagesInitialLoadPending && Array.isArray(this.controller.directMessagesCache) && this.controller.directMessagesCache.length ) { await this.controller.renderer.renderProfileMessages(this.controller.directMessagesCache, { actorPubkey: actor, }); this.controller.renderer.setMessagesLoadingState("ready"); return; } const requestId = Symbol("messagesLoad"); this.controller.activeMessagesRequest = requestId; this.controller.messagesInitialLoadPending = false; this.controller.renderer.setMessagesLoadingState("loading"); if ( this.controller.directMessagesLastActor && this.controller.directMessagesLastActor !== actor ) { this.controller.resetDirectMessageSubscription(); if ( typeof this.mainController.nostrService.clearDirectMessages === "function" ) { try { this.mainController.nostrService.clearDirectMessages({ emit: true }); } catch (error) { devLogger.warn( "[profileModal] Failed to clear direct messages cache before reload:", error, ); } } } try { let snapshot = await this.mainController.nostrService.loadDirectMessages({ actorPubkey: actor, initialLoad: true, }); if (!Array.isArray(snapshot)) { snapshot = []; } if (this.controller.activeMessagesRequest !== requestId) { return; } this.controller.directMessagesCache = snapshot; this.controller.directMessagesLastActor = actor; await this.controller.renderer.renderProfileMessages(snapshot, { actorPubkey: actor }); if (!snapshot.length) { this.controller.renderer.setMessagesLoadingState("empty"); } else { this.controller.renderer.setMessagesLoadingState("ready", { message: snapshot.length === 1 ? "1 direct message thread loaded." : `${snapshot.length} direct message threads loaded.`, }); } } catch (error) { if (this.controller.activeMessagesRequest === requestId) { userLogger.error( "[profileModal] Failed to load direct messages:", error, ); this.controller.renderer.setMessagesLoadingState("error", { message: "Failed to load direct messages. Try again later.", }); this.controller.messagesInitialLoadPending = true; } return; } finally { if (this.controller.activeMessagesRequest === requestId) { this.controller.activeMessagesRequest = null; this.controller.renderer.updateMessagesReloadState(); } } void this.controller.ensureDirectMessageSubscription(actor); } resumeProfileMessages() { this.controller.messagesViewActive = true; this.controller.renderer.mountDmAppShell(); this.controller.renderer.updateMessagesReloadState(); } pauseProfileMessages() { this.controller.messagesViewActive = false; this.controller.renderer.unmountDmAppShell(); this.controller.renderer.updateMessagesReloadState(); } handleDirectMessagesUpdated(detail = {}) { if ( this.controller.activeMessagesRequest && detail?.reason !== "load-incremental" ) { return; } const messages = Array.isArray(detail?.messages) ? detail.messages : []; this.controller.directMessagesCache = messages; const actor = this.controller.helper.resolveActiveDmActor(); if (!actor) { this.controller.renderer.setMessagesLoadingState("unauthenticated"); this.controller.renderer.clearDirectMessagesUpdateQueue(); return; } this.controller.directMessagesLastActor = actor; this.controller.renderer.scheduleDirectMessagesRender({ messages, actorPubkey: actor, reason: typeof detail?.reason === "string" ? detail.reason : "", }); } handleDirectMessagesCleared() { if (this.controller.activeMessagesRequest) { return; } this.controller.renderer.clearDirectMessagesUpdateQueue(); this.controller.directMessagesCache = []; this.setDirectMessageRecipient(null, { reason: "clear" }); if (this.controller.renderer.profileMessagesList instanceof HTMLElement) { this.controller.renderer.profileMessagesList.textContent = ""; this.controller.renderer.profileMessagesList.classList.add("hidden"); this.controller.renderer.profileMessagesList.setAttribute("hidden", ""); } const actor = this.controller.helper.resolveActiveDmActor(); if (!actor) { this.controller.renderer.setMessagesLoadingState("unauthenticated"); } else { this.controller.renderer.setMessagesLoadingState("empty"); } if (this.controller.renderer.dmAppShellContainer instanceof HTMLElement) { void this.controller.renderer.renderDmAppShell(this.controller.directMessagesCache, { actorPubkey: actor, }); } } handleDirectMessagesError(detail = {}) { const error = detail?.error || detail?.failure || detail; const reason = detail?.context?.reason || ""; const errorCode = error?.code || ""; const errorMessage = typeof error === "string" ? error : typeof error?.message === "string" ? error.message : ""; const requiresNip44Decryptor = typeof errorMessage === "string" && errorMessage.includes("Gift wrap events require a NIP-44 decryptor"); const isBenign = reason === "no-decryptors" || errorCode === "decryption-failed" || (typeof error === "string" && error.includes("no-decryptors")); if (isBenign) { devLogger.info("[profileModal] Direct message sync info:", error); } else { userLogger.warn( "[profileModal] Direct message sync issue detected:", error, ); } if (this.controller.activeMessagesRequest) { return; } if (requiresNip44Decryptor) { const nip44Message = "NIP-17 direct messages require a NIP-44-capable signer or extension. Unlock or update your extension to continue."; if (!this.controller.directMessagesCache.length) { this.controller.renderer.setMessagesLoadingState("error", { message: nip44Message, }); return; } this.controller.renderer.setMessagesAnnouncement(nip44Message); this.controller.renderer.updateMessagesReloadState(); return; } if (!this.controller.directMessagesCache.length) { this.controller.renderer.setMessagesLoadingState("error", { message: "Unable to sync direct messages right now.", }); return; } this.controller.renderer.setMessagesAnnouncement("Unable to sync direct messages right now."); this.controller.renderer.updateMessagesReloadState(); } handleDirectMessagesRelayWarning(detail = {}) { if (!this.controller.enableNip17RelayWarning) { return; } if (detail?.warning !== "dm-relays-fallback") { return; } if (this.controller.hasShownRelayWarning) { return; } this.controller.hasShownRelayWarning = true; this.mainController.showStatus( "Privacy warning: direct messages are using your default relays because no NIP-17 relay list is available.", { autoHideMs: SHORT_TIMEOUT_MS }, ); } setActiveDmRelayPreferences(relays = []) { const owner = this.controller.helper.resolveActiveDmRelayOwner(); if (!owner || typeof this.mainController.state.setDmRelayPreferences !== "function") { return []; } return this.mainController.state.setDmRelayPreferences(owner, relays); } setDmRelayPreferencesStatus(message = "") { if (!(this.controller.renderer.profileMessagesRelayStatus instanceof HTMLElement)) { return; } const text = typeof message === "string" ? message.trim() : ""; this.controller.renderer.profileMessagesRelayStatus.textContent = text; } populateDmRelayPreferences() { if (!(this.controller.renderer.profileMessagesRelayList instanceof HTMLElement)) { return; } const owner = this.controller.helper.resolveActiveDmRelayOwner(); const relays = this.controller.helper.getActiveDmRelayPreferences(); this.controller.renderer.profileMessagesRelayList.textContent = ""; if (!owner) { const emptyState = document.createElement("li"); emptyState.className = "card border border-dashed border-border/60 p-4 text-center text-xs text-muted"; emptyState.textContent = "Sign in to add DM relay hints."; this.controller.renderer.profileMessagesRelayList.appendChild(emptyState); this.setDmRelayPreferencesStatus(""); return; } if (!relays.length) { const emptyState = document.createElement("li"); emptyState.className = "card border border-dashed border-border/60 p-4 text-center text-xs text-muted"; emptyState.textContent = "No DM relay hints yet."; this.controller.renderer.profileMessagesRelayList.appendChild(emptyState); return; } relays.forEach((url) => { const item = document.createElement("li"); item.className = "card flex items-center justify-between gap-3 p-3"; const label = document.createElement("p"); label.className = "text-xs font-medium text-text break-all"; label.textContent = url; const removeBtn = document.createElement("button"); removeBtn.type = "button"; removeBtn.className = "btn-ghost focus-ring text-xs"; removeBtn.dataset.variant = "danger"; removeBtn.textContent = "Remove"; removeBtn.addEventListener("click", () => { void this.handleRemoveDmRelayPreference(url); }); item.appendChild(label); item.appendChild(removeBtn); this.controller.renderer.profileMessagesRelayList.appendChild(item); }); } async handleAddDmRelayPreference() { const input = this.controller.renderer.profileMessagesRelayInput; if (!(input instanceof HTMLInputElement)) { return; } const owner = this.controller.helper.resolveActiveDmRelayOwner(); if (!owner) { this.mainController.showError("Please sign in to save DM relay hints."); return; } const rawValue = typeof input.value === "string" ? input.value.trim() : ""; const sanitized = sanitizeRelayList([rawValue]); const relayUrl = sanitized[0]; if (!relayUrl) { this.mainController.showError("Enter a valid WSS relay URL."); return; } const current = this.controller.helper.getActiveDmRelayPreferences(); const next = sanitizeRelayList([...current, relayUrl]); this.setActiveDmRelayPreferences(next); input.value = ""; this.populateDmRelayPreferences(); this.setDmRelayPreferencesStatus("DM relay hint added."); } async handleRemoveDmRelayPreference(url) { const owner = this.controller.helper.resolveActiveDmRelayOwner(); if (!owner) { this.mainController.showError("Please sign in to update DM relay hints."); return; } const target = typeof url === "string" ? url.trim() : ""; if (!target) { return; } const current = this.controller.helper.getActiveDmRelayPreferences(); const next = current.filter((entry) => entry !== target); this.setActiveDmRelayPreferences(next); this.populateDmRelayPreferences(); this.setDmRelayPreferencesStatus("DM relay hint removed."); } }