[decompose] extract DM and Relay controllers from ProfileModalController

Extracted ProfileDirectMessageController (split into Controller, Actions, Renderer, Helper) and ProfileRelayController from ProfileModalController to reduce file size and improve maintainability.

ProfileModalController.js size reduced from ~12000 lines to ~8750 lines.
Updated unit tests to reflect the new structure.

Co-authored-by: PR0M3TH3AN <[email protected]>
This commit is contained in:
google-labs-jules[bot]andPR0M3TH3AN committed 2026-02-11 21:15:06 +00:00
1 parent 617ec08666
commit 7300977a6d
8 files changed
+4058 -3549

No files matched your search

@@ -0,0 +1,928 @@
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";
const TYPING_INDICATOR_TTL_SECONDS = 15;
const TYPING_INDICATOR_COOLDOWN_MS = 4000;
export class ProfileDirectMessageActions {
constructor(mainController, controller) {
this.mainController = mainController;
this.controller = controller;
}
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.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: 5000 },
);
}
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.generateAttachmentId(file),
file,
name: file.name,
type: file.type || "application/octet-stream",
size: file.size,
previewUrl,
status: "pending",
progress: 0,
});
});
input.value = "";
this.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: 5000 },
);
}
if (
!this.mainController.services.nostrClient ||
typeof this.mainController.services.nostrClient.sendDirectMessage !== "function"
) {
this.mainController.showError("Direct message service unavailable.");
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.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.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: 5000 },
);
}
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.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.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.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.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;
}
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: 5000 },
);
}
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: 5000 },
);
}
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.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.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: 5000 },
);
}
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.profileMessagesRelayStatus instanceof HTMLElement)) {
return;
}
const text = typeof message === "string" ? message.trim() : "";
this.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.");
}
}
@@ -0,0 +1,679 @@
import { AppShell } from "../dm/index.js";
import { devLogger, userLogger } from "../../utils/logger.js";
import { extractAttachmentsFromMessage, formatAttachmentSize, describeAttachment } from "../../attachments/attachmentUtils.js";
import { downloadAttachment } from "../../services/attachmentService.js";
import { formatTimeAgo } from "../../utils/formatters.js";
import { createInternalDefaultDmPrivacySettings } from "../profileModalContract.js";
import { sanitizeRelayList } from "../../nostr/nip46Client.js";
import { DMSettingsModalController } from "../dm/DMSettingsModalController.js";
import { ProfileDirectMessageHelper } from "./ProfileDirectMessageHelper.js";
import { ProfileDirectMessageRenderer } from "./ProfileDirectMessageRenderer.js";
import { ProfileDirectMessageActions } from "./ProfileDirectMessageActions.js";
const DIRECT_MESSAGES_BATCH_DELAY_MS = 250;
const FALLBACK_PROFILE_AVATAR = "assets/svg/default-profile.svg";
const noop = () => {};
export class ProfileDirectMessageController {
constructor(mainController) {
this.mainController = mainController;
this.directMessagesCache = [];
this.directMessagesLastActor = null;
this.directMessagesSubscription = null;
this.directMessagesUnsubscribes = [];
this.profileMessagesRenderToken = 0;
this.messagesLoadingState = "idle";
this.dmComposerState = "idle";
this.dmMobileView = "list";
this.activeDmConversationId = "";
this.focusedDmConversationId = "";
this.dmPrivacyToggleTouched = false;
this.activeMessagesRequest = false;
this.hasShownRelayWarning = false;
this.enableNip17RelayWarning = false;
this.dmReadReceiptCache = new Set();
this.dmSettingsModalController = new DMSettingsModalController();
this.helper = new ProfileDirectMessageHelper(mainController, this);
this.renderer = new ProfileDirectMessageRenderer(mainController, this);
this.actions = new ProfileDirectMessageActions(mainController, this);
this.dmTypingLastSentAt = 0;
this.dmAttachmentQueue = [];
this.dmAttachmentUploads = new Map();
this.messagesInitialLoadPending = true;
this.messagesViewActive = false;
}
initializeDirectMessagesService() {
this.teardownDirectMessagesService();
if (!this.mainController.nostrService || typeof this.mainController.nostrService.on !== "function") {
return;
}
const unsubscribes = [];
const subscribe = (eventName, handler) => {
try {
const unsubscribe = this.mainController.nostrService.on(eventName, handler);
if (typeof unsubscribe === "function") {
unsubscribes.push(unsubscribe);
}
} catch (error) {
devLogger.warn(
`[profileModal] Failed to subscribe to ${eventName} direct message events:`,
error,
);
}
};
subscribe("directMessages:updated", (detail) => {
this.actions.handleDirectMessagesUpdated(detail);
});
subscribe("directMessages:cleared", () => {
this.actions.handleDirectMessagesCleared();
});
subscribe("directMessages:error", (detail) => {
this.actions.handleDirectMessagesError(detail);
});
subscribe("directMessages:failure", (detail) => {
this.actions.handleDirectMessagesError(detail);
});
subscribe("directMessages:relayWarning", (detail) => {
this.actions.handleDirectMessagesRelayWarning(detail);
});
this.directMessagesUnsubscribes = unsubscribes;
const actor = this.helper.resolveActiveDmActor();
if (actor) {
this.directMessagesLastActor = actor;
}
if (
this.mainController.nostrService &&
typeof this.mainController.nostrService.hydrateDirectMessagesFromStore === "function"
) {
void this.mainController.nostrService
.hydrateDirectMessagesFromStore({ emit: true })
.then((messages) => {
if (Array.isArray(messages)) {
this.directMessagesCache = messages;
const active = this.helper.resolveActiveDmActor();
if (active) {
this.directMessagesLastActor = active;
}
}
})
.catch((error) => {
devLogger.warn(
"[profileModal] Failed to hydrate cached direct messages:",
error,
);
});
}
}
teardownDirectMessagesService() {
if (!Array.isArray(this.directMessagesUnsubscribes)) {
this.directMessagesUnsubscribes = [];
return;
}
while (this.directMessagesUnsubscribes.length) {
const unsubscribe = this.directMessagesUnsubscribes.pop();
if (typeof unsubscribe === "function") {
try {
unsubscribe();
} catch (error) {
devLogger.warn(
"[profileModal] Failed to remove direct message event listener:",
error,
);
}
}
}
}
getDmPrivacySettingsSnapshot() {
const fallback = createInternalDefaultDmPrivacySettings();
if (typeof this.mainController.state.getDmPrivacySettings !== "function") {
return { ...fallback };
}
const settings = this.mainController.state.getDmPrivacySettings();
return {
readReceiptsEnabled:
typeof settings?.readReceiptsEnabled === "boolean"
? settings.readReceiptsEnabled
: fallback.readReceiptsEnabled,
typingIndicatorsEnabled:
typeof settings?.typingIndicatorsEnabled === "boolean"
? settings.typingIndicatorsEnabled
: fallback.typingIndicatorsEnabled,
};
}
persistDmPrivacySettings(partial = {}) {
if (typeof this.mainController.state.setDmPrivacySettings !== "function") {
return this.getDmPrivacySettingsSnapshot();
}
const resolved =
partial && typeof partial === "object" ? partial : {};
const current = this.getDmPrivacySettingsSnapshot();
const merged = {
readReceiptsEnabled:
typeof resolved.readReceiptsEnabled === "boolean"
? resolved.readReceiptsEnabled
: current.readReceiptsEnabled,
typingIndicatorsEnabled:
typeof resolved.typingIndicatorsEnabled === "boolean"
? resolved.typingIndicatorsEnabled
: current.typingIndicatorsEnabled,
};
return this.mainController.state.setDmPrivacySettings(merged);
}
syncDmPrivacySettingsUi() {
// Legacy UI toggles removed.
// This method is kept for backwards compatibility with call sites that might expect it,
// though the settings are now managed via AppShell.
}
async handleDmSettingsPublish(relays) {
const owner = this.helper.resolveActiveDmRelayOwner();
if (!owner) {
return { ok: false, error: "not-logged-in" };
}
// Update local state first
this.setActiveDmRelayPreferences(relays);
// Publish using existing logic
const callback = this.mainController.callbacks.onPublishDmRelayPreferences;
if (!callback || callback === noop) {
return { ok: false, error: "unavailable" };
}
try {
const result = await callback({
pubkey: owner,
relays,
controller: this,
});
if (result?.ok) {
this.populateDmRelayPreferences(); // Refresh old UI just in case
return result;
}
return { ok: false, error: result?.error || "failed" };
} catch (error) {
return { ok: false, error: error };
}
}
setDirectMessageRecipient(pubkey, { reason = "manual" } = {}) {
const normalized = this.mainController.normalizeHexPubkey(pubkey);
const nextRecipient = normalized || null;
if (typeof this.mainController.state.setDmRecipient === "function") {
this.mainController.state.setDmRecipient(nextRecipient);
}
this.dmPrivacyToggleTouched = false;
this.updateMessageThreadSelection(nextRecipient);
if (nextRecipient) {
void this.helper.ensureDmRecipientData(nextRecipient);
this.renderer.setMessagesAnnouncement("Ready to message this recipient.");
} else if (reason === "clear") {
this.renderer.setMessagesAnnouncement("Message recipient cleared.");
this.setFocusedDmConversation("");
}
void this.renderer.renderDirectMessageConversation();
if (this.renderer.dmAppShellContainer instanceof HTMLElement) {
void this.renderer.renderDmAppShell(this.directMessagesCache, {
actorPubkey: this.helper.resolveActiveDmActor(),
});
}
return nextRecipient;
}
updateMessageThreadSelection(activeRecipient) {
if (!(this.renderer.profileMessagesList instanceof HTMLElement)) {
return;
}
const normalized = this.mainController.normalizeHexPubkey(activeRecipient);
const items = Array.from(
this.renderer.profileMessagesList.querySelectorAll("[data-remote-pubkey]"),
);
items.forEach((item) => {
if (!(item instanceof HTMLElement)) {
return;
}
const remote = this.mainController.normalizeHexPubkey(item.dataset.remotePubkey);
const isActive = normalized && remote === normalized;
item.dataset.state = isActive ? "active" : "inactive";
});
}
focusMessageComposer() {
const input = this.renderer.profileMessageInput;
if (input instanceof HTMLTextAreaElement) {
input.focus();
input.select();
}
}
async ensureDirectMessageSubscription(actorPubkey = null) {
if (
!this.mainController.nostrService ||
typeof this.mainController.nostrService.ensureDirectMessageSubscription !== "function"
) {
return null;
}
const normalizedActor = actorPubkey
? this.mainController.normalizeHexPubkey(actorPubkey)
: this.helper.resolveActiveDmActor();
if (!normalizedActor) {
return null;
}
if (
this.directMessagesSubscription &&
this.directMessagesSubscription.actor === normalizedActor
) {
return this.directMessagesSubscription.subscription || null;
}
if (
this.directMessagesSubscription &&
this.directMessagesSubscription.actor &&
this.directMessagesSubscription.actor !== normalizedActor
) {
this.resetDirectMessageSubscription();
}
let subscription = null;
try {
subscription = await this.mainController.nostrService.ensureDirectMessageSubscription({
actorPubkey: normalizedActor,
});
} catch (error) {
userLogger.warn(
"[profileModal] Failed to subscribe to direct messages:",
error,
);
return null;
}
this.directMessagesSubscription = {
actor: normalizedActor,
subscription,
};
return subscription;
}
resetDirectMessageSubscription() {
if (
this.directMessagesSubscription &&
this.mainController.nostrService &&
typeof this.mainController.nostrService.stopDirectMessageSubscription === "function"
) {
try {
this.mainController.nostrService.stopDirectMessageSubscription();
} catch (error) {
devLogger.warn(
"[profileModal] Failed to stop direct message subscription:",
error,
);
}
}
this.directMessagesSubscription = null;
}
async maybePublishReadReceipt(messages, { recipientPubkey } = {}) {
if (!Array.isArray(messages) || !messages.length) {
return;
}
const settings = this.getDmPrivacySettingsSnapshot();
if (!settings.readReceiptsEnabled) {
return;
}
if (
!this.mainController.services.nostrClient ||
typeof this.mainController.services.nostrClient.publishDmReadReceipt !== "function"
) {
return;
}
const normalizedRecipient =
typeof recipientPubkey === "string"
? this.mainController.normalizeHexPubkey(recipientPubkey)
: "";
if (!normalizedRecipient) {
return;
}
let latestMessage = null;
for (let index = messages.length - 1; index >= 0; index -= 1) {
const entry = messages[index];
if (
entry &&
typeof entry === "object" &&
entry.direction === "incoming"
) {
const eventId = this.helper.resolveDirectMessageEventId(entry);
if (eventId) {
latestMessage = entry;
break;
}
}
}
if (!latestMessage) {
return;
}
const eventId = this.helper.resolveDirectMessageEventId(latestMessage);
if (!eventId) {
return;
}
const cacheKey = `${normalizedRecipient}:${eventId}`;
if (this.dmReadReceiptCache.has(cacheKey)) {
return;
}
const relayHints = this.helper.buildDmRecipientContext(normalizedRecipient)?.relayHints || [];
const messageKind = this.helper.resolveDirectMessageKind(latestMessage);
try {
const result = await this.mainController.services.nostrClient.publishDmReadReceipt({
eventId,
recipientPubkey: normalizedRecipient,
messageKind,
relays: relayHints,
});
if (result?.ok) {
this.dmReadReceiptCache.add(cacheKey);
}
} catch (error) {
devLogger.warn("[profileModal] Failed to publish read receipt:", error);
}
}
describeDirectMessageSendError(code) {
switch (code) {
case "sign-event-unavailable":
return "Connect a Nostr signer to send messages.";
case "encryption-unsupported":
return "Your signer does not support NIP-04 encryption.";
case "nip44-unsupported":
return "Your signer does not support NIP-44 encryption required for NIP-17.";
case "nip17-relays-missing":
return "Recipient has not shared NIP-17 relay hints yet.";
case "nip17-relays-unavailable":
return "No DM relays are available to deliver this message.";
case "nip17-keygen-failed":
return "We couldn’t create secure wrapper keys for NIP-17 delivery.";
case "extension-permission-denied":
return "Please grant your Nostr extension permission to send messages.";
case "extension-encryption-permission-denied":
return "Please grant your Nostr extension encryption permission to send messages.";
case "missing-actor-pubkey":
return "We couldn’t determine your public key to send this message.";
case "nostr-uninitialized":
return "Direct messages are still connecting to relays. Please try again.";
case "signature-failed":
return "We couldn’t sign the message. Please reconnect your signer and try again.";
case "encryption-failed":
return "We couldn’t encrypt the message. Please try again.";
case "publish-failed":
return "Failed to deliver this message to any relay. Please try again.";
case "invalid-target":
return "Select a valid recipient before sending.";
case "empty-message":
return "Please enter a message or attach a file.";
case "attachments-unsupported":
return "Attachments require NIP-17 delivery. Enable the privacy toggle to send files.";
default:
return "Unable to send message. Please try again.";
}
}
setFocusedDmConversation(conversationId) {
if (
!this.mainController.nostrService ||
typeof this.mainController.nostrService.setFocusedDirectMessageConversation !== "function"
) {
return;
}
if (
this.focusedDmConversationId &&
this.focusedDmConversationId !== conversationId
) {
this.mainController.nostrService.setFocusedDirectMessageConversation(
this.focusedDmConversationId,
false,
);
}
if (conversationId) {
this.mainController.nostrService.setFocusedDirectMessageConversation(conversationId, true);
this.focusedDmConversationId = conversationId;
} else {
this.focusedDmConversationId = "";
}
}
setMessagesLoadingState(...args) {
return this.renderer.setMessagesLoadingState(...args);
}
updateMessagePrivacyModeDisplay(...args) {
return this.renderer.updateMessagePrivacyModeDisplay(...args);
}
updateMessagesReloadState(...args) {
return this.renderer.updateMessagesReloadState(...args);
}
updateMessageComposerState(...args) {
return this.renderer.updateMessageComposerState(...args);
}
setMessagesAnnouncement(...args) {
return this.renderer.setMessagesAnnouncement(...args);
}
renderProfileMessages(...args) {
return this.renderer.renderProfileMessages(...args);
}
renderDmAppShell(...args) {
return this.renderer.renderDmAppShell(...args);
}
mountDmAppShell(...args) {
return this.renderer.mountDmAppShell(...args);
}
unmountDmAppShell(...args) {
return this.renderer.unmountDmAppShell(...args);
}
populateDmRelayPreferences(...args) {
return this.actions.populateDmRelayPreferences(...args);
}
populateProfileMessages(...args) {
return this.actions.populateProfileMessages(...args);
}
resumeProfileMessages(...args) {
return this.actions.resumeProfileMessages(...args);
}
pauseProfileMessages(...args) {
return this.actions.pauseProfileMessages(...args);
}
handleSendDmRequest(...args) {
return this.actions.handleSendDmRequest(...args);
}
handleOpenDmRelaysRequest(...args) {
return this.actions.handleOpenDmRelaysRequest(...args);
}
handlePrivacyToggle(...args) {
return this.actions.handlePrivacyToggle(...args);
}
handleLinkPreviewToggle(...args) {
return this.actions.handleLinkPreviewToggle(...args);
}
handleSendProfileMessage(...args) {
return this.actions.handleSendProfileMessage(...args);
}
handleAttachmentSelection(...args) {
return this.actions.handleAttachmentSelection(...args);
}
handleDirectMessagesUpdated(...args) {
return this.actions.handleDirectMessagesUpdated(...args);
}
handleDirectMessagesCleared(...args) {
return this.actions.handleDirectMessagesCleared(...args);
}
handleDirectMessagesError(...args) {
return this.actions.handleDirectMessagesError(...args);
}
handleDirectMessagesRelayWarning(...args) {
return this.actions.handleDirectMessagesRelayWarning(...args);
}
resolveActiveDmActor(...args) {
return this.helper.resolveActiveDmActor(...args);
}
resolveActiveDmRecipient(...args) {
return this.helper.resolveActiveDmRecipient(...args);
}
createCompactProfileSummary(...args) {
return this.renderer.createCompactProfileSummary(...args);
}
setDmRelayPreferencesStatus(...args) {
return this.actions.setDmRelayPreferencesStatus(...args);
}
setActiveDmRelayPreferences(...args) {
return this.actions.setActiveDmRelayPreferences(...args);
}
handleAddDmRelayPreference(...args) {
return this.actions.handleAddDmRelayPreference(...args);
}
handleRemoveDmRelayPreference(...args) {
return this.actions.handleRemoveDmRelayPreference(...args);
}
resolveActiveDmRelayOwner(...args) {
return this.helper.resolveActiveDmRelayOwner(...args);
}
getActiveDmRelayPreferences(...args) {
return this.helper.getActiveDmRelayPreferences(...args);
}
}
@@ -0,0 +1,758 @@
import { devLogger, userLogger } from "../../utils/logger.js";
import { extractAttachmentsFromMessage, formatAttachmentSize, describeAttachment } from "../../attachments/attachmentUtils.js";
import { formatTimeAgo } from "../../utils/formatters.js";
const FALLBACK_PROFILE_AVATAR = "assets/svg/default-profile.svg";
export class ProfileDirectMessageHelper {
constructor(mainController, controller) {
this.mainController = mainController;
this.controller = controller;
}
resolveActiveDmActor() {
const active = this.mainController.normalizeHexPubkey(this.mainController.getActivePubkey());
if (active) {
return active;
}
const client = this.mainController.services.nostrClient || null;
if (client) {
if (typeof client.pubkey === "string" && client.pubkey.trim()) {
const normalizedClient = this.mainController.normalizeHexPubkey(client.pubkey);
if (normalizedClient) {
return normalizedClient;
}
}
if (
client.sessionActor &&
typeof client.sessionActor.pubkey === "string" &&
client.sessionActor.pubkey.trim()
) {
const session = this.mainController.normalizeHexPubkey(client.sessionActor.pubkey);
if (session) {
return session;
}
}
}
return null;
}
resolveActiveDmRecipient() {
const candidate =
typeof this.mainController.state.getDmRecipient === "function"
? this.mainController.state.getDmRecipient()
: null;
const normalized = this.mainController.normalizeHexPubkey(candidate);
if (normalized) {
return normalized;
}
return null;
}
resolveActiveDmRelayOwner() {
const active = this.mainController.normalizeHexPubkey(this.mainController.getActivePubkey());
if (active) {
return active;
}
return this.resolveActiveDmActor();
}
getActiveDmRelayPreferences() {
const owner = this.resolveActiveDmRelayOwner();
if (!owner || typeof this.mainController.state.getDmRelayPreferences !== "function") {
return [];
}
const relays = this.mainController.state.getDmRelayPreferences(owner);
return Array.isArray(relays) ? relays.slice() : [];
}
buildDmRecipientContext(pubkey) {
const normalized = this.mainController.normalizeHexPubkey(pubkey);
if (!normalized) {
return null;
}
const npub =
typeof this.mainController.safeEncodeNpub === "function"
? this.mainController.safeEncodeNpub(normalized)
: null;
const cacheEntry =
typeof this.mainController.services.getProfileCacheEntry === "function"
? this.mainController.services.getProfileCacheEntry(normalized)
: null;
const profile = cacheEntry?.profile || null;
const displayName =
profile?.display_name?.trim?.() ||
profile?.name?.trim?.() ||
(typeof this.mainController.formatShortNpub === "function"
? this.mainController.formatShortNpub(npub)
: npub) ||
npub ||
"Unknown profile";
const relayHints =
typeof this.mainController.state.getDmRelayHints === "function"
? this.mainController.state.getDmRelayHints(normalized)
: [];
return {
pubkey: normalized,
npub,
profile,
displayName,
relayHints: Array.isArray(relayHints) ? relayHints.slice() : [],
};
}
async ensureDmRecipientData(pubkey) {
const normalized = this.mainController.normalizeHexPubkey(pubkey);
if (!normalized) {
return null;
}
if (
typeof this.mainController.services.batchFetchProfiles === "function"
) {
try {
await this.mainController.services.batchFetchProfiles([normalized]);
} catch (error) {
devLogger.warn(
"[profileModal] Failed to fetch DM recipient metadata:",
error,
);
}
}
if (typeof this.mainController.services.fetchDmRelayHints === "function") {
try {
const hints = await this.mainController.services.fetchDmRelayHints(normalized);
if (typeof this.mainController.state.setDmRelayHints === "function") {
this.mainController.state.setDmRelayHints(normalized, hints);
}
} catch (error) {
devLogger.warn(
"[profileModal] Failed to fetch DM relay hints:",
error,
);
}
}
const context = this.buildDmRecipientContext(normalized);
this.updateDmPrivacyToggleForRecipient(context);
return context;
}
resolveDirectMessageEventId(message) {
if (!message || typeof message !== "object") {
return "";
}
const eventId =
typeof message.event?.id === "string" ? message.event.id.trim() : "";
if (eventId) {
return eventId;
}
const innerId =
typeof message.message?.id === "string" ? message.message.id.trim() : "";
return innerId;
}
resolveDirectMessageKind(message) {
if (!message || typeof message !== "object") {
return null;
}
if (Number.isFinite(message?.event?.kind)) {
return Number(message.event.kind);
}
if (Number.isFinite(message?.message?.kind)) {
return Number(message.message.kind);
}
return null;
}
extractDirectMessagePreview(entry) {
if (!entry || typeof entry !== "object") {
return "";
}
const candidates = [];
if (typeof entry.plaintext === "string") {
candidates.push(entry.plaintext);
}
if (typeof entry.preview === "string") {
candidates.push(entry.preview);
}
if (
entry.snapshot &&
typeof entry.snapshot.preview === "string" &&
entry.snapshot.preview.trim()
) {
candidates.push(entry.snapshot.preview);
}
if (entry.message && typeof entry.message.content === "string") {
candidates.push(entry.message.content);
}
const preview = candidates.find((value) => value && value.trim());
return preview ? preview.trim() : "";
}
resolveProfileSummaryForPubkey(pubkey) {
const normalized = this.mainController.normalizeHexPubkey(pubkey);
const fallbackNpub =
normalized && typeof this.mainController.safeEncodeNpub === "function"
? this.mainController.safeEncodeNpub(normalized)
: null;
const formattedNpub =
typeof this.mainController.formatShortNpub === "function"
? this.mainController.formatShortNpub(fallbackNpub)
: fallbackNpub;
let displayName = formattedNpub || fallbackNpub || "Unknown profile";
let avatarSrc = FALLBACK_PROFILE_AVATAR;
let lightningAddress = "";
let status = "";
if (normalized && typeof this.mainController.services.getProfileCacheEntry === "function") {
const cacheEntry = this.mainController.services.getProfileCacheEntry(normalized);
const profile = cacheEntry?.profile || null;
if (profile) {
if (typeof profile.display_name === "string" && profile.display_name.trim()) {
displayName = profile.display_name.trim();
} else if (typeof profile.name === "string" && profile.name.trim()) {
displayName = profile.name.trim();
}
if (typeof profile.picture === "string" && profile.picture.trim()) {
avatarSrc = profile.picture.trim();
}
if (typeof profile.lud16 === "string" && profile.lud16.trim()) {
lightningAddress = profile.lud16.trim();
} else if (typeof profile.lud06 === "string" && profile.lud06.trim()) {
lightningAddress = profile.lud06.trim();
}
if (typeof profile.status === "string" && profile.status.trim()) {
status = profile.status.trim();
}
}
}
return {
displayName,
displayNpub: formattedNpub || fallbackNpub || "npub unavailable",
avatarSrc,
lightningAddress,
status,
};
}
formatMessageTimestamp(timestamp) {
const numeric = Number(timestamp);
if (!Number.isFinite(numeric) || numeric <= 0) {
return { display: "", iso: "" };
}
try {
const date = new Date(numeric * 1000);
return {
display: date.toLocaleString(undefined, {
year: "numeric",
month: "short",
day: "numeric",
hour: "numeric",
minute: "2-digit",
}),
iso: date.toISOString(),
};
} catch (error) {
return { display: "", iso: "" };
}
}
buildDmConversationId(actorPubkey, remotePubkey) {
const normalizedActor = this.mainController.normalizeHexPubkey(actorPubkey);
const normalizedRemote = this.mainController.normalizeHexPubkey(remotePubkey);
if (!normalizedActor || !normalizedRemote) {
return "";
}
return `dm:${[normalizedActor, normalizedRemote].sort().join(":")}`;
}
formatConversationTimestamp(timestamp) {
const numeric = Number(timestamp);
if (!Number.isFinite(numeric) || numeric <= 0) {
return "";
}
try {
return formatTimeAgo(numeric);
} catch (error) {
return "";
}
}
formatMessageClockTime(timestamp) {
const numeric = Number(timestamp);
if (!Number.isFinite(numeric) || numeric <= 0) {
return "";
}
try {
const date = new Date(numeric * 1000);
return date.toLocaleTimeString(undefined, {
hour: "numeric",
minute: "2-digit",
});
} catch (error) {
return "";
}
}
formatMessageDayLabel(timestamp) {
const numeric = Number(timestamp);
if (!Number.isFinite(numeric) || numeric <= 0) {
return "";
}
try {
const date = new Date(numeric * 1000);
const today = new Date();
const startOfToday = new Date(
today.getFullYear(),
today.getMonth(),
today.getDate(),
);
const startOfMessageDay = new Date(
date.getFullYear(),
date.getMonth(),
date.getDate(),
);
const diffDays = Math.round(
(startOfToday - startOfMessageDay) / (1000 * 60 * 60 * 24),
);
if (diffDays === 0) {
return "Today";
}
if (diffDays === 1) {
return "Yesterday";
}
return date.toLocaleDateString(undefined, {
year: "numeric",
month: "short",
day: "numeric",
});
} catch (error) {
return "";
}
}
resolveDirectMessageStatus(message) {
if (!message || typeof message !== "object") {
return "sent";
}
const status =
typeof message.status === "string"
? message.status
: typeof message.deliveryStatus === "string"
? message.deliveryStatus
: typeof message.state === "string"
? message.state
: "";
return status ? status.trim() : "sent";
}
resolveDirectMessageBody(message) {
if (!message || typeof message !== "object") {
return "";
}
if (typeof message.plaintext === "string" && message.plaintext.trim()) {
return message.plaintext.trim();
}
const preview = this.extractDirectMessagePreview(message);
if (preview) {
return preview;
}
const attachments = extractAttachmentsFromMessage(message);
if (attachments.length) {
return describeAttachment(attachments[0]);
}
return "";
}
resolveDirectMessagePreviewForConversation(message) {
if (!message || typeof message !== "object") {
return "";
}
const preview = this.extractDirectMessagePreview(message);
if (preview) {
return preview;
}
const attachments = extractAttachmentsFromMessage(message);
if (attachments.length) {
return describeAttachment(attachments[0]);
}
return "";
}
resolveRemoteForConversationId(conversationId, actorPubkey) {
const actor = this.mainController.normalizeHexPubkey(actorPubkey || this.resolveActiveDmActor());
const normalizedConversationId =
typeof conversationId === "string" ? conversationId.trim() : "";
if (!actor || !normalizedConversationId) {
return null;
}
for (const entry of Array.isArray(this.controller.directMessagesCache) ? this.controller.directMessagesCache : []) {
const remote = this.resolveDirectMessageRemote(entry, actor);
if (!remote) {
continue;
}
const resolvedId = this.buildDmConversationId(actor, remote);
if (resolvedId && resolvedId === normalizedConversationId) {
return remote;
}
}
return null;
}
resolveConversationPrivacyMode(latestMessage) {
const scheme = this.resolveDirectMessageScheme(latestMessage);
if (scheme.includes("nip17") || scheme.includes("nip44")) {
return "nip17";
}
return "nip04";
}
getDirectMessagesForConversation(conversationId, actorPubkey = null) {
const actor = this.mainController.normalizeHexPubkey(actorPubkey || this.resolveActiveDmActor());
if (!actor || !conversationId) {
return [];
}
const remote = this.resolveRemoteForConversationId(conversationId, actor);
if (!remote) {
return [];
}
return (Array.isArray(this.controller.directMessagesCache) ? this.controller.directMessagesCache : [])
.filter(
(entry) =>
entry &&
entry.ok === true &&
this.resolveDirectMessageRemote(entry, actor) === remote,
)
.sort((a, b) => (a?.timestamp || 0) - (b?.timestamp || 0));
}
getLatestDirectMessageTimestampForConversation(conversationId, actorPubkey = null) {
const messages = this.getDirectMessagesForConversation(
conversationId,
actorPubkey,
);
if (!messages.length) {
return 0;
}
const last = messages[messages.length - 1];
return Number(last?.timestamp) || 0;
}
buildDmMessageTimeline(messages, { actorPubkey, remotePubkey } = {}) {
const actor = this.mainController.normalizeHexPubkey(actorPubkey);
const remote = this.mainController.normalizeHexPubkey(remotePubkey);
if (!actor || !remote) {
return [];
}
const threadMessages = Array.isArray(messages)
? messages.filter((entry) => 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.mainController.normalizeHexPubkey(
actorPubkey || this.resolveActiveDmActor(),
);
if (!actor) {
return {
actor: "",
conversations: [],
activeConversationId: "",
activeThread: null,
activeRemotePubkey: null,
timeline: [],
};
}
const allThreads = this.groupDirectMessages(messages, actor);
const blocksService = this.mainController.services.userBlocks;
const isRemoteBlocked =
blocksService && typeof blocksService.isBlocked === "function"
? (pubkey) => blocksService.isBlocked(pubkey)
: () => false;
const threads = allThreads.filter(
(thread) => !thread.remoteHex || !isRemoteBlocked(thread.remoteHex),
);
const remoteKeys = new Set();
threads.forEach((thread) => {
if (thread.remoteHex) {
remoteKeys.add(thread.remoteHex);
}
});
if (
remoteKeys.size &&
this.mainController.services.batchFetchProfiles &&
typeof this.mainController.services.batchFetchProfiles === "function"
) {
try {
await this.mainController.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.mainController.nostrService &&
typeof this.mainController.nostrService.getDirectMessageUnseenCount === "function" &&
conversationId
? this.mainController.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.controller.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.controller.activeDmConversationId !== activeConversationId) {
this.controller.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.mainController.normalizeHexPubkey(actorPubkey)
: this.resolveActiveDmActor();
const threadMap = new Map();
for (const entry of Array.isArray(messages) ? messages : []) {
if (!entry || entry.ok !== true) {
continue;
}
const remoteHex = this.resolveDirectMessageRemote(entry, normalizedActor);
if (!remoteHex) {
continue;
}
const thread = threadMap.get(remoteHex) || {
remoteHex,
messages: [],
latestTimestamp: 0,
latestMessage: null,
};
thread.messages.push(entry);
const ts = Number(entry.timestamp) || 0;
if (!thread.latestMessage || ts > thread.latestTimestamp) {
thread.latestTimestamp = ts;
thread.latestMessage = entry;
}
threadMap.set(remoteHex, thread);
}
const threads = Array.from(threadMap.values());
threads.sort((a, b) => (b.latestTimestamp || 0) - (a.latestTimestamp || 0));
return threads;
}
resolveDirectMessageRemote(entry, actorPubkey = null) {
if (!entry || entry.ok !== true) {
return null;
}
const normalizedActor = actorPubkey
? this.mainController.normalizeHexPubkey(actorPubkey)
: this.resolveActiveDmActor();
if (typeof entry.remotePubkey === "string") {
const directRemote = this.mainController.normalizeHexPubkey(entry.remotePubkey);
if (directRemote && directRemote !== normalizedActor) {
return directRemote;
}
}
if (entry.snapshot && typeof entry.snapshot.remotePubkey === "string") {
const snapshotRemote = this.mainController.normalizeHexPubkey(entry.snapshot.remotePubkey);
if (snapshotRemote && snapshotRemote !== normalizedActor) {
return snapshotRemote;
}
}
const direction =
typeof entry.direction === "string" ? entry.direction.toLowerCase() : "";
const senderHex =
entry.sender && typeof entry.sender.pubkey === "string"
? this.mainController.normalizeHexPubkey(entry.sender.pubkey)
: null;
if (direction === "incoming" && senderHex && senderHex !== normalizedActor) {
return senderHex;
}
if (Array.isArray(entry.recipients)) {
for (const recipient of entry.recipients) {
const candidate =
recipient && typeof recipient.pubkey === "string"
? this.mainController.normalizeHexPubkey(recipient.pubkey)
: null;
if (candidate && candidate !== normalizedActor) {
return candidate;
}
}
}
if (direction === "outgoing" && senderHex && senderHex !== normalizedActor) {
return senderHex;
}
if (entry.message && typeof entry.message.pubkey === "string") {
const messagePubkey = this.mainController.normalizeHexPubkey(entry.message.pubkey);
if (messagePubkey && messagePubkey !== normalizedActor) {
return messagePubkey;
}
}
if (entry.event && typeof entry.event.pubkey === "string") {
const eventPubkey = this.mainController.normalizeHexPubkey(entry.event.pubkey);
if (eventPubkey && eventPubkey !== normalizedActor) {
return eventPubkey;
}
}
if (senderHex && senderHex !== normalizedActor) {
return senderHex;
}
return null;
}
}
@@ -0,0 +1,907 @@
import { AppShell } from "../dm/index.js";
import { devLogger, userLogger } from "../../utils/logger.js";
import { extractAttachmentsFromMessage, formatAttachmentSize, describeAttachment } from "../../attachments/attachmentUtils.js";
import { downloadAttachment } from "../../services/attachmentService.js";
const DIRECT_MESSAGES_BATCH_DELAY_MS = 250;
export class ProfileDirectMessageRenderer {
constructor(mainController, controller) {
this.mainController = mainController;
this.controller = controller;
this.dmAppShellContainer = null;
this.profileMessagesPane = null;
this.profileMessagesList = null;
this.profileMessagesEmpty = null;
this.profileMessagesLoading = null;
this.profileMessagesError = null;
this.profileMessagesStatus = null;
this.profileMessagesReloadButton = null;
this.profileMessagesConversation = null;
this.profileMessagesConversationEmpty = null;
this.profileMessageInput = null;
this.profileMessageSendButton = null;
this.profileMessageAttachmentInput = null;
this.profileMessageAttachmentButton = null;
this.profileMessageAttachmentEncrypt = null;
this.profileMessageAttachmentList = null;
this.profileMessageAttachmentClearCache = null;
this.profileMessagesComposerHelper = null;
this.profileMessagesSendDmButton = null;
this.profileMessagesOpenRelaysButton = null;
this.profileMessagesPrivacyToggle = null;
this.profileMessagesPrivacyMode = null;
this.profileMessagesUnreadDot = null;
this.profileLinkPreviewAutoToggle = null;
this.profileMessagesRelayList = null;
this.profileMessagesRelayInput = null;
this.profileMessagesRelayAdd = null;
this.directMessagesRenderTimeout = null;
this.pendingDirectMessagesUpdate = null;
this.pendingMessagesRender = null;
this.messagesStatusClearTimeout = null;
this.dmAppShell = null;
}
cacheDomReferences() {
this.dmAppShellContainer = document.getElementById("dmAppShellMount") || null;
this.profileMessagesPane = document.getElementById("profilePaneMessages") || null;
this.profileMessagesList = document.getElementById("profileMessagesList") || null;
this.profileMessagesEmpty = document.getElementById("profileMessagesEmpty") || null;
this.profileMessagesLoading = document.getElementById("profileMessagesLoading") || null;
this.profileMessagesError = document.getElementById("profileMessagesError") || null;
this.profileMessagesStatus = document.getElementById("profileMessagesStatus") || null;
this.profileMessagesReloadButton = document.getElementById("profileMessagesReload") || null;
this.profileMessagesConversation = document.getElementById("profileMessagesConversation") || null;
this.profileMessagesConversationEmpty = document.getElementById("profileMessagesConversationEmpty") || null;
this.profileMessageInput = document.getElementById("profileMessageInput") || null;
this.profileMessageSendButton = document.getElementById("profileMessageSendBtn") || null;
this.profileMessageAttachmentInput = document.getElementById("profileMessageAttachmentInput") || null;
this.profileMessageAttachmentButton = document.getElementById("profileMessageAttachmentButton") || null;
this.profileMessageAttachmentEncrypt = document.getElementById("profileMessageAttachmentEncrypt") || null;
this.profileMessageAttachmentList = document.getElementById("profileMessageAttachmentList") || null;
this.profileMessageAttachmentClearCache = document.getElementById("profileMessageAttachmentClearCache") || null;
this.profileMessagesComposerHelper = document.getElementById("profileMessagesComposerHelper") || null;
this.profileMessagesSendDmButton = document.getElementById("profileMessagesSendDm") || null;
this.profileMessagesOpenRelaysButton = document.getElementById("profileMessagesOpenRelays") || null;
this.profileMessagesPrivacyToggle = document.getElementById("profileMessagesPrivacyToggle") || null;
this.profileMessagesPrivacyMode = document.getElementById("profileMessagesPrivacyMode") || null;
this.profileMessagesUnreadDot = document.getElementById("profileMessagesUnreadDot") || null;
this.profileLinkPreviewAutoToggle = document.getElementById("profileLinkPreviewAutoToggle") || null;
this.profileMessagesRelayList = document.getElementById("profileMessagesRelayList") || null;
this.profileMessagesRelayInput = document.getElementById("profileMessagesRelayInput") || null;
this.profileMessagesRelayAdd = document.getElementById("profileMessagesRelayAdd") || null;
}
updateMessagePrivacyModeDisplay() {
if (!(this.profileMessagesPrivacyMode instanceof HTMLElement)) {
return;
}
const isNip17 =
this.profileMessagesPrivacyToggle instanceof HTMLInputElement
? this.profileMessagesPrivacyToggle.checked
: false;
const label = isNip17 ? "NIP-17" : "NIP-04";
this.profileMessagesPrivacyMode.textContent = `Privacy: ${label}`;
this.profileMessagesPrivacyMode.title = isNip17
? "NIP-17 gift-wraps your DM so relays only see the wrapper and relay hints."
: "NIP-04 sends a direct encrypted DM; relays can still see sender and recipient metadata.";
}
setMessagesLoadingState(state, options = {}) {
const normalized = typeof state === "string" ? state : "idle";
const defaults = {
idle: "",
loading: "Fetching direct messages from relays…",
ready: "",
empty: "No direct messages yet.",
unauthenticated: "Sign in to view your direct messages.",
error: "Failed to load direct messages. Try again later.",
};
const providedMessage =
typeof options.message === "string" && options.message.trim()
? options.message.trim()
: "";
const message = providedMessage || defaults[normalized] || "";
this.controller.messagesLoadingState = normalized;
if (this.profileMessagesPane instanceof HTMLElement) {
this.profileMessagesPane.setAttribute("data-messages-state", normalized);
}
const toggleVisibility = (element, shouldShow) => {
if (!(element instanceof HTMLElement)) {
return;
}
if (shouldShow) {
element.classList.remove("hidden");
element.removeAttribute("hidden");
} else {
element.classList.add("hidden");
element.setAttribute("hidden", "");
}
};
toggleVisibility(
this.profileMessagesLoading,
normalized === "loading",
);
if (this.profileMessagesError instanceof HTMLElement) {
if (normalized === "error") {
this.profileMessagesError.textContent = message;
toggleVisibility(this.profileMessagesError, true);
} else {
this.profileMessagesError.textContent = "";
toggleVisibility(this.profileMessagesError, false);
}
}
if (this.profileMessagesEmpty instanceof HTMLElement) {
if (normalized === "empty" || normalized === "unauthenticated") {
this.profileMessagesEmpty.textContent = message || defaults[normalized];
toggleVisibility(this.profileMessagesEmpty, true);
} else {
toggleVisibility(this.profileMessagesEmpty, false);
}
}
const hasMessages =
Array.isArray(this.controller.directMessagesCache) &&
this.controller.directMessagesCache.length > 0;
if (this.profileMessagesList instanceof HTMLElement) {
if (normalized === "loading" || normalized === "unauthenticated") {
toggleVisibility(this.profileMessagesList, false);
} else if (hasMessages) {
toggleVisibility(this.profileMessagesList, true);
}
}
if (this.profileMessagesStatus instanceof HTMLElement) {
if (message && normalized !== "error") {
this.profileMessagesStatus.textContent = message;
} else if (normalized === "error") {
this.profileMessagesStatus.textContent = "";
} else if (providedMessage) {
this.profileMessagesStatus.textContent = providedMessage;
} else {
this.profileMessagesStatus.textContent = "";
}
}
this.updateMessagesReloadState();
this.updateMessageComposerState();
if (this.dmAppShellContainer instanceof HTMLElement) {
void this.renderDmAppShell(this.controller.directMessagesCache, {
actorPubkey: this.controller.helper.resolveActiveDmActor(),
});
}
}
updateMessagesReloadState() {
const button = this.profileMessagesReloadButton;
if (!(button instanceof HTMLElement)) {
return;
}
const actor = this.controller.helper.resolveActiveDmActor();
const disabled =
!actor ||
this.controller.messagesLoadingState === "loading" ||
this.controller.activeMessagesRequest !== null;
if ("disabled" in button) {
button.disabled = disabled;
}
if (disabled) {
button.setAttribute("aria-disabled", "true");
} else {
button.removeAttribute("aria-disabled");
}
}
updateMessageComposerState() {
const input = this.profileMessageInput;
const button = this.profileMessageSendButton;
const helper = this.profileMessagesComposerHelper;
const attachmentInput = this.profileMessageAttachmentInput;
const attachmentButton = this.profileMessageAttachmentButton;
const attachmentEncrypt = this.profileMessageAttachmentEncrypt;
const attachmentClearCache = this.profileMessageAttachmentClearCache;
const shouldDisable = this.controller.messagesLoadingState === "unauthenticated";
const applyDisabledState = (element) => {
if (!(element instanceof HTMLElement) || !("disabled" in element)) {
return;
}
element.disabled = shouldDisable;
if (shouldDisable) {
element.setAttribute("aria-disabled", "true");
} else {
element.removeAttribute("aria-disabled");
}
};
applyDisabledState(input);
applyDisabledState(button);
applyDisabledState(attachmentInput);
applyDisabledState(attachmentButton);
applyDisabledState(attachmentEncrypt);
applyDisabledState(attachmentClearCache);
if (helper instanceof HTMLElement) {
if (shouldDisable) {
helper.textContent = "Sign in to send messages.";
helper.classList.remove("hidden");
helper.removeAttribute("hidden");
} else {
helper.classList.add("hidden");
helper.setAttribute("hidden", "");
}
}
this.updateMessagePrivacyModeDisplay();
}
setMessagesAnnouncement(message) {
if (!(this.profileMessagesStatus instanceof HTMLElement)) {
return;
}
const content = typeof message === "string" ? message.trim() : "";
if (!content) {
this.profileMessagesStatus.textContent = "";
if (this.messagesStatusClearTimeout) {
clearTimeout(this.messagesStatusClearTimeout);
this.messagesStatusClearTimeout = null;
}
return;
}
this.profileMessagesStatus.textContent = content;
if (typeof window !== "undefined" && window && window.setTimeout) {
if (this.messagesStatusClearTimeout) {
clearTimeout(this.messagesStatusClearTimeout);
}
this.messagesStatusClearTimeout = window.setTimeout(() => {
if (this.profileMessagesStatus) {
this.profileMessagesStatus.textContent = "";
}
this.messagesStatusClearTimeout = null;
}, 2500);
}
}
async renderDirectMessageConversation() {
const container = this.profileMessagesConversation;
const emptyState = this.profileMessagesConversationEmpty;
const actor = this.controller.helper.resolveActiveDmActor();
const recipient = this.controller.helper.resolveActiveDmRecipient();
if (!(container instanceof HTMLElement)) {
return;
}
container.textContent = "";
if (!actor || !recipient || !this.controller.directMessagesCache.length) {
container.classList.add("hidden");
container.setAttribute("hidden", "");
if (emptyState instanceof HTMLElement) {
emptyState.classList.remove("hidden");
emptyState.removeAttribute("hidden");
}
return;
}
const messages = this.controller.directMessagesCache
.filter((entry) => this.controller.helper.resolveDirectMessageRemote(entry, actor) === recipient)
.sort((a, b) => (a.timestamp || 0) - (b.timestamp || 0));
if (!messages.length) {
container.classList.add("hidden");
container.setAttribute("hidden", "");
if (emptyState instanceof HTMLElement) {
emptyState.classList.remove("hidden");
emptyState.removeAttribute("hidden");
}
return;
}
if (emptyState instanceof HTMLElement) {
emptyState.classList.add("hidden");
emptyState.setAttribute("hidden", "");
}
void this.controller.maybePublishReadReceipt(messages, {
recipientPubkey: recipient,
});
messages.forEach((message) => {
const item = document.createElement("div");
item.className = "card flex flex-col gap-2 p-3";
const body = document.createElement("div");
body.className = "text-sm text-text whitespace-pre-line";
const text = typeof message.plaintext === "string" ? message.plaintext.trim() : "";
body.textContent = text || "Attachment";
item.appendChild(body);
const attachments = extractAttachmentsFromMessage(message);
attachments.forEach((attachment) => {
const attachmentCard = document.createElement("div");
attachmentCard.className = "flex flex-col gap-2 rounded-lg border border-border/60 p-3";
const title = document.createElement("div");
title.className = "text-xs font-semibold text-text";
title.textContent = describeAttachment(attachment);
attachmentCard.appendChild(title);
const meta = document.createElement("div");
meta.className = "text-3xs text-muted";
const sizeLabel = formatAttachmentSize(attachment.size);
const typeLabel = attachment.type || "file";
meta.textContent = sizeLabel ? `${typeLabel} · ${sizeLabel}` : typeLabel;
attachmentCard.appendChild(meta);
const progress = document.createElement("progress");
progress.className = "progress";
progress.value = 0;
progress.max = 1;
progress.dataset.variant = "surface";
attachmentCard.appendChild(progress);
const status = document.createElement("div");
status.className = "text-3xs text-muted";
status.textContent = attachment.encrypted
? "Decrypting attachment…"
: "Downloading attachment…";
attachmentCard.appendChild(status);
item.appendChild(attachmentCard);
downloadAttachment({
url: attachment.url,
expectedHash: attachment.x,
key: attachment.key,
mimeType: attachment.type,
onProgress: (fraction) => {
progress.value = Number.isFinite(fraction) ? fraction : progress.value;
},
})
.then((result) => {
progress.value = 1;
progress.classList.add("hidden");
progress.setAttribute("hidden", "");
status.textContent = attachment.encrypted ? "Decrypted." : "Ready.";
if (!result?.objectUrl) {
return;
}
if (attachment.type?.startsWith("image/")) {
const img = document.createElement("img");
img.src = result.objectUrl;
img.alt = attachment.name || "Attachment preview";
img.className = "h-40 w-full rounded-lg object-cover";
attachmentCard.appendChild(img);
} else if (attachment.type?.startsWith("video/")) {
const video = document.createElement("video");
video.src = result.objectUrl;
video.controls = true;
video.className = "w-full rounded-lg";
attachmentCard.appendChild(video);
} else if (attachment.type?.startsWith("audio/")) {
const audio = document.createElement("audio");
audio.src = result.objectUrl;
audio.controls = true;
audio.className = "w-full";
attachmentCard.appendChild(audio);
} else {
const link = document.createElement("a");
link.href = result.objectUrl;
link.textContent = "Download attachment";
link.className = "text-xs text-accent underline-offset-2 hover:underline";
link.download = attachment.name || "attachment";
attachmentCard.appendChild(link);
}
})
.catch((error) => {
status.textContent =
error && typeof error.message === "string"
? error.message
: "Attachment download failed.";
status.classList.add("text-critical");
});
});
container.appendChild(item);
});
container.classList.remove("hidden");
container.removeAttribute("hidden");
}
createDirectMessageThreadItem(thread) {
if (!thread || !thread.remoteHex) {
return null;
}
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";
const summary = this.controller.helper.resolveProfileSummaryForPubkey(thread.remoteHex);
const summaryNode = this.createCompactProfileSummary({
displayName: summary.displayName,
displayNpub: summary.displayNpub,
avatarSrc: summary.avatarSrc,
size: "sm",
});
header.appendChild(summaryNode);
const timestampMeta = this.controller.helper.formatMessageTimestamp(thread.latestTimestamp);
if (timestampMeta.display) {
const timeEl = document.createElement("time");
timeEl.className =
"text-3xs font-semibold uppercase tracking-extra-wide text-muted";
if (timestampMeta.iso) {
timeEl.setAttribute("datetime", timestampMeta.iso);
}
timeEl.textContent = timestampMeta.display;
header.appendChild(timeEl);
}
item.appendChild(header);
const previewText = this.controller.helper.extractDirectMessagePreview(thread.latestMessage);
const previewEl = document.createElement("p");
previewEl.className = "text-sm text-text whitespace-pre-line";
previewEl.textContent = previewText || "Encrypted message";
item.appendChild(previewEl);
const meta = document.createElement("div");
meta.className = "flex flex-wrap items-center gap-2";
const direction =
typeof thread.latestMessage?.direction === "string"
? thread.latestMessage.direction.toLowerCase()
: "";
if (direction) {
const directionPill = document.createElement("span");
directionPill.className = "pill";
directionPill.dataset.variant = direction === "incoming" ? "info" : "muted";
directionPill.textContent =
direction === "incoming"
? "Incoming message"
: direction === "outgoing"
? "Sent message"
: "Message";
meta.appendChild(directionPill);
}
const countPill = document.createElement("span");
countPill.className = "pill";
countPill.dataset.variant = "muted";
const messageCount = Array.isArray(thread.messages)
? thread.messages.length
: 0;
countPill.textContent =
messageCount === 1 ? "1 message" : `${messageCount} messages`;
meta.appendChild(countPill);
const scheme =
typeof thread.latestMessage?.scheme === "string"
? thread.latestMessage.scheme.toUpperCase()
: "";
if (scheme) {
const schemePill = document.createElement("span");
schemePill.className = "pill";
schemePill.dataset.variant = "muted";
schemePill.textContent = scheme;
meta.appendChild(schemePill);
}
item.appendChild(meta);
item.addEventListener("click", () => {
this.controller.setDirectMessageRecipient(thread.remoteHex, {
reason: "thread-select",
});
this.controller.focusMessageComposer();
});
return item;
}
async renderProfileMessages(messages, { actorPubkey = null } = {}) {
if (this.dmAppShellContainer instanceof HTMLElement) {
await this.renderDmAppShell(messages, { actorPubkey });
}
if (!(this.profileMessagesList instanceof HTMLElement)) {
if (!(this.dmAppShellContainer instanceof HTMLElement)) {
this.pendingMessagesRender = {
messages: Array.isArray(messages) ? messages : [],
actorPubkey,
};
}
return;
}
this.pendingMessagesRender = null;
const normalizedActor = actorPubkey
? this.mainController.normalizeHexPubkey(actorPubkey)
: this.controller.helper.resolveActiveDmActor();
const renderToken = (this.controller.profileMessagesRenderToken += 1);
const renderThreads = (threadsToRender) => {
this.profileMessagesList.textContent = "";
if (!threadsToRender.length) {
this.profileMessagesList.classList.add("hidden");
this.profileMessagesList.setAttribute("hidden", "");
void this.renderDirectMessageConversation();
return;
}
for (const thread of threadsToRender) {
const item = this.createDirectMessageThreadItem(thread);
if (item) {
this.profileMessagesList.appendChild(item);
}
}
const activeRecipient = this.controller.helper.resolveActiveDmRecipient();
const hasActiveRecipient =
activeRecipient &&
threadsToRender.some((thread) => thread.remoteHex === activeRecipient);
if (threadsToRender.length && !hasActiveRecipient) {
this.controller.setDirectMessageRecipient(threadsToRender[0].remoteHex, {
reason: "thread-default",
});
const conversationId = this.controller.helper.buildDmConversationId(
normalizedActor,
threadsToRender[0].remoteHex,
);
this.controller.setFocusedDmConversation(conversationId);
} else if (hasActiveRecipient) {
this.updateMessageThreadSelection(activeRecipient);
}
this.profileMessagesList.classList.remove("hidden");
this.profileMessagesList.removeAttribute("hidden");
void this.renderDirectMessageConversation();
};
const threads = this.controller.helper.groupDirectMessages(messages, normalizedActor);
renderThreads(threads);
const remoteKeys = new Set();
for (const thread of threads) {
if (thread.remoteHex) {
remoteKeys.add(thread.remoteHex);
}
}
if (
remoteKeys.size &&
this.mainController.services.batchFetchProfiles &&
typeof this.mainController.services.batchFetchProfiles === "function"
) {
Promise.resolve(this.mainController.services.batchFetchProfiles(remoteKeys))
.then(() => {
if (this.controller.profileMessagesRenderToken !== renderToken) {
return;
}
const latestMessages = Array.isArray(this.controller.directMessagesCache)
? this.controller.directMessagesCache
: messages;
const latestActor = this.controller.helper.resolveActiveDmActor() || normalizedActor;
const refreshedThreads = this.controller.helper.groupDirectMessages(
latestMessages,
latestActor,
);
renderThreads(refreshedThreads);
})
.catch((error) => {
devLogger.warn(
"[profileModal] Failed to fetch DM profile metadata:",
error,
);
});
}
}
async renderDmAppShell(messages, { actorPubkey = null } = {}) {
const container =
this.dmAppShellContainer instanceof HTMLElement
? this.dmAppShellContainer
: null;
if (!container) {
return;
}
const snapshot = Array.isArray(messages) ? messages : this.controller.directMessagesCache;
const {
actor,
conversations,
activeConversationId,
activeThread,
timeline,
} = await this.controller.helper.buildDmConversationData(snapshot, { actorPubkey });
this.controller.setFocusedDmConversation(activeConversationId);
const currentRecipient = this.controller.helper.resolveActiveDmRecipient();
if (!currentRecipient && activeThread?.remoteHex) {
this.controller.setDirectMessageRecipient(activeThread.remoteHex, {
reason: "thread-default",
});
}
const loadingState = this.controller.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.controller.helper.resolveConversationPrivacyMode(
activeThread?.latestMessage,
);
const currentUserSummary = this.controller.helper.resolveProfileSummaryForPubkey(
this.controller.helper.resolveActiveDmActor(),
);
const currentUserAvatarUrl = currentUserSummary?.avatarSrc || "";
container.textContent = "";
try {
const dmPrivacySettings = this.getDmPrivacySettingsSnapshot();
this.dmAppShell = new AppShell({
document,
currentUserAvatarUrl,
conversations,
activeConversationId,
conversationState,
messages: timeline,
threadState,
privacyMode,
dmPrivacySettings,
composerState: this.controller.dmComposerState || "idle",
notifications: [],
zapConfig: {
signer: this.mainController.services.nostrClient,
},
mobileView: this.controller.dmMobileView || "list",
onSelectConversation: (conversation) => {
void this.controller.handleDmConversationSelect(conversation);
},
onRefreshConversations: () => {
this.controller.populateProfileMessages({ force: true });
},
onBack: () => {
this.controller.dmMobileView = "list";
void this.renderDmAppShell(this.controller.directMessagesCache, {
actorPubkey: this.controller.helper.resolveActiveDmActor(),
});
},
onSendMessage: (messageText, payload) => {
void this.controller.handleDmAppShellSendMessage(messageText, payload);
},
onMarkConversationRead: (conversation) => {
void this.controller.handleDmConversationMarkRead(conversation);
},
onMarkAllRead: () => {
void this.controller.handleDmMarkAllConversationsRead();
},
onToggleReadReceipts: (enabled) => {
this.controller.handleReadReceiptsToggle(enabled);
},
onToggleTypingIndicators: (enabled) => {
this.controller.handleTypingIndicatorsToggle(enabled);
},
onOpenSettings: () => {
this.controller.openDmSettingsModal();
},
});
} 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;
}
root.classList.add("bg-transparent");
container.appendChild(root);
if (actor && activeConversationId) {
const renderedUntil =
this.controller.helper.getLatestDirectMessageTimestampForConversation(
activeConversationId,
actor,
) || Date.now() / 1000;
void this.mainController.nostrService?.acknowledgeRenderedDirectMessages?.(
activeConversationId,
renderedUntil,
);
}
}
mountDmAppShell() {
const container =
this.dmAppShellContainer instanceof HTMLElement
? this.dmAppShellContainer
: null;
if (!container) {
return;
}
void this.renderDmAppShell(this.controller.directMessagesCache, {
actorPubkey: this.controller.directMessagesLastActor,
});
}
unmountDmAppShell() {
const container =
this.dmAppShellContainer instanceof HTMLElement
? this.dmAppShellContainer
: null;
if (container) {
container.textContent = "";
}
this.dmAppShell = null;
}
clearDirectMessagesUpdateQueue() {
if (this.directMessagesRenderTimeout) {
const clearTimeoutFn =
typeof window !== "undefined" && typeof window.clearTimeout === "function"
? window.clearTimeout.bind(window)
: clearTimeout;
clearTimeoutFn(this.directMessagesRenderTimeout);
this.directMessagesRenderTimeout = null;
}
this.pendingDirectMessagesUpdate = null;
}
scheduleDirectMessagesRender(payload = null) {
if (!payload) {
return;
}
this.pendingDirectMessagesUpdate = payload;
if (this.directMessagesRenderTimeout) {
return;
}
const scheduleTimeout =
typeof window !== "undefined" && typeof window.setTimeout === "function"
? window.setTimeout.bind(window)
: setTimeout;
this.directMessagesRenderTimeout = scheduleTimeout(() => {
this.directMessagesRenderTimeout = null;
this.flushDirectMessagesRender();
}, DIRECT_MESSAGES_BATCH_DELAY_MS);
}
flushDirectMessagesRender() {
const pending = this.pendingDirectMessagesUpdate;
this.pendingDirectMessagesUpdate = null;
if (!pending) {
return;
}
const { messages, actorPubkey, reason } = pending;
void this.renderProfileMessages(messages, { actorPubkey })
.then(() => {
if (!messages.length) {
this.setMessagesLoadingState("empty");
} else {
this.setMessagesLoadingState("ready");
}
if (reason === "subscription") {
this.setMessagesAnnouncement("New direct message received.");
} else if (reason === "load") {
this.setMessagesAnnouncement(
messages.length === 1
? "1 direct message thread synced."
: `${messages.length} direct message threads synced.`,
);
}
})
.catch((error) => {
devLogger.warn(
"[profileModal] Failed to render direct messages after update:",
error,
);
});
}
createCompactProfileSummary({
displayName,
displayNpub,
avatarSrc,
size = "sm",
} = {}) {
const sizeClassMap = {
xs: "h-8 w-8",
sm: "h-10 w-10",
md: "h-12 w-12",
};
const avatarSize = sizeClassMap[size] || sizeClassMap.sm;
const safeName = displayName?.trim() || "Unknown profile";
const safeNpub = displayNpub?.trim() || "npub unavailable";
const avatarUrl = avatarSrc || FALLBACK_PROFILE_AVATAR;
const container = document.createElement("div");
container.className = "min-w-0 flex flex-1 items-center gap-2";
const avatarWrapper = document.createElement("span");
avatarWrapper.className = `flex ${avatarSize} flex-shrink-0 items-center justify-center overflow-hidden rounded-full border border-overlay-strong bg-overlay-panel-soft`;
const avatarImg = document.createElement("img");
avatarImg.className = "h-full w-full object-cover";
avatarImg.src = avatarUrl;
avatarImg.alt = `${safeName} avatar`;
avatarWrapper.appendChild(avatarImg);
const textStack = document.createElement("div");
textStack.className = "min-w-0 flex flex-col";
const nameEl = document.createElement("p");
nameEl.className = "truncate text-xs font-semibold text-primary";
nameEl.textContent = safeName;
const npubEl = document.createElement("p");
npubEl.className = "break-all font-mono text-2xs text-muted";
npubEl.textContent = safeNpub;
textStack.append(nameEl, npubEl);
container.append(avatarWrapper, textStack);
return container;
}
}
@@ -0,0 +1,466 @@
import { devLogger, userLogger } from "../../utils/logger.js";
import { sanitizeRelayList } from "../../nostr/nip46Client.js";
const noop = () => {};
export class ProfileRelayController {
constructor(mainController) {
this.mainController = mainController;
this.relayList = null;
this.relayInput = null;
this.addRelayButton = null;
this.restoreRelaysButton = null;
this.relayHealthStatus = null;
this.relayHealthTelemetryToggle = null;
this.profileRelayRefreshBtn = null;
this.profileRestoreRelaysBtn = null;
this.profileAddRelayBtn = null;
}
cacheDomReferences() {
this.relayList = document.getElementById("relayList") || null;
this.relayInput = document.getElementById("relayInput") || null;
this.addRelayButton = document.getElementById("addRelayBtn") || null;
this.restoreRelaysButton = document.getElementById("restoreRelaysBtn") || null;
this.relayHealthStatus = document.getElementById("relayHealthStatus") || null;
this.relayHealthTelemetryToggle = document.getElementById("relayHealthTelemetryOptIn") || null;
this.profileRelayRefreshBtn = document.getElementById("relayListRefreshBtn") || null;
this.profileRestoreRelaysBtn = this.restoreRelaysButton;
this.profileAddRelayBtn = this.addRelayButton;
}
populateProfileRelays(relayEntries = null) {
if (!this.relayList) {
return;
}
const sourceEntries = Array.isArray(relayEntries)
? relayEntries
: this.mainController.services.relayManager.getEntries();
const relays = sourceEntries
.map((entry) => {
if (typeof entry === "string") {
const trimmed = entry.trim();
return trimmed ? { url: trimmed, mode: "both" } : null;
}
if (entry && typeof entry === "object") {
const url = typeof entry.url === "string" ? entry.url.trim() : "";
if (!url) {
return null;
}
const mode = typeof entry.mode === "string" ? entry.mode : "both";
const normalizedMode =
mode === "read" || mode === "write" ? mode : "both";
return {
url,
mode: normalizedMode,
read: entry.read !== false,
write: entry.write !== false,
};
}
return null;
})
.filter((entry) => entry && typeof entry.url === "string");
this.relayList.textContent = "";
if (!relays.length) {
const emptyState = document.createElement("li");
emptyState.className =
"card border border-dashed border-surface-strong p-4 text-center text-sm text-muted";
emptyState.textContent = "No relays configured.";
this.relayList.appendChild(emptyState);
return;
}
relays.forEach((entry) => {
const item = document.createElement("li");
item.className = "card flex items-start justify-between gap-4 p-4";
item.dataset.relayUrl = entry.url;
const info = document.createElement("div");
info.className = "flex-1 min-w-0";
const urlEl = document.createElement("p");
urlEl.className = "text-sm font-medium text-primary break-all";
urlEl.textContent = entry.url;
const statusEl = document.createElement("p");
statusEl.className = "mt-1 text-xs text-muted";
let modeLabel = "Read & write";
if (entry.mode === "read") {
modeLabel = "Read only";
} else if (entry.mode === "write") {
modeLabel = "Write only";
}
statusEl.textContent = modeLabel;
const health = document.createElement("div");
health.className = "flex flex-wrap items-center gap-x-3 gap-y-1 mt-1 text-2xs text-muted empty:hidden";
health.dataset.role = "relay-health";
info.appendChild(urlEl);
info.appendChild(statusEl);
info.appendChild(health);
const actions = document.createElement("div");
actions.className = "flex items-center gap-2";
const editBtn = document.createElement("button");
editBtn.type = "button";
editBtn.className = "btn-ghost focus-ring text-xs";
editBtn.textContent = "Change mode";
editBtn.title = "Cycle between read-only, write-only, or read/write modes.";
editBtn.addEventListener("click", () => {
void this.handleRelayModeToggle(entry.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.handleRemoveRelay(entry.url);
});
actions.appendChild(editBtn);
actions.appendChild(removeBtn);
item.appendChild(info);
item.appendChild(actions);
this.relayList.appendChild(item);
});
}
updateRelayHealthStatus(message = "") {
if (!this.relayHealthStatus) {
return;
}
const text = typeof message === "string" ? message.trim() : "";
this.relayHealthStatus.textContent = text;
}
updateRelayHealthIndicators(snapshot = []) {
if (!this.relayList) {
return;
}
if (!Array.isArray(snapshot)) {
return;
}
snapshot.forEach((entry) => {
const url = entry.url;
const item = this.relayList.querySelector(
`li[data-relay-url="${CSS.escape(url)}"]`,
);
if (!item) {
return;
}
const healthContainer = item.querySelector('[data-role="relay-health"]');
if (!healthContainer) {
return;
}
healthContainer.textContent = "";
const createBadge = (label, value, colorClass) => {
const span = document.createElement("span");
span.className = "inline-flex items-center gap-1 bg-surface-strong/30 px-1.5 py-0.5 rounded";
const l = document.createElement("span");
l.textContent = label;
const v = document.createElement("span");
v.className = colorClass || "text-text";
v.textContent = value;
span.appendChild(l);
span.appendChild(v);
return span;
};
if (Number.isFinite(entry.lastLatencyMs)) {
let color = "text-status-success";
if (entry.lastLatencyMs > 1000) color = "text-status-danger";
else if (entry.lastLatencyMs > 300) color = "text-status-warning";
healthContainer.appendChild(
createBadge("Ping:", `${entry.lastLatencyMs}ms`, color),
);
}
if (entry.errorCount > 0) {
healthContainer.appendChild(
createBadge("Errors:", `${entry.errorCount}`, "text-status-danger"),
);
}
});
}
handleRelayHealthTelemetryToggle() {
const service = this.mainController.services?.relayHealthService;
if (!service || !(this.relayHealthTelemetryToggle instanceof HTMLInputElement)) {
return;
}
const enabled = service.setTelemetryOptIn(
this.relayHealthTelemetryToggle.checked,
);
this.relayHealthTelemetryToggle.checked = enabled;
this.updateRelayHealthStatus(
enabled ? "Relay health telemetry enabled." : "Relay health telemetry disabled.",
);
}
async refreshRelayHealthPanel({ forceRefresh = false, reason = "" } = {}) {
const service = this.mainController.services?.relayHealthService;
if (!service) {
return [];
}
if (this.relayHealthTelemetryToggle instanceof HTMLInputElement) {
this.relayHealthTelemetryToggle.checked = service.getTelemetryOptIn();
}
const snapshot = service.getSnapshot();
this.updateRelayHealthIndicators(snapshot);
if (!forceRefresh) {
return snapshot;
}
if (this.relayHealthRefreshPromise) {
return this.relayHealthRefreshPromise;
}
const statusMessage =
reason === "manual" ? "Refreshing relay health…" : "Checking relays…";
this.updateRelayHealthStatus(statusMessage);
const refreshPromise = service
.refresh()
.then((latest) => {
this.updateRelayHealthIndicators(latest);
this.updateRelayHealthStatus("Relay health updated.");
return latest;
})
.catch((error) => {
this.updateRelayHealthStatus("Failed to refresh relay health.");
this.mainController.showError("Failed to refresh relay health.");
devLogger.warn("[profileModal] Relay health refresh failed:", error);
return [];
})
.finally(() => {
if (this.relayHealthRefreshPromise === refreshPromise) {
this.relayHealthRefreshPromise = null;
}
});
this.relayHealthRefreshPromise = refreshPromise;
return refreshPromise;
}
async handleRelayOperation(meta = {}, {
successMessage = "Relay preferences updated.",
skipPublishIfUnchanged = true,
unchangedMessage = null,
} = {}) {
const operationContext = {
...meta,
ok: false,
changed: false,
reason: null,
error: null,
publishResult: null,
operationResult: null,
};
const activePubkey = this.mainController.normalizeHexPubkey(this.mainController.getActivePubkey());
if (!activePubkey) {
this.mainController.showError("Please login to manage your relays.");
operationContext.reason = "no-active-pubkey";
return operationContext;
}
let result;
try {
result = await this.runRelayOperation({
...meta,
activePubkey,
skipPublishIfUnchanged,
});
} catch (error) {
const message =
error && typeof error.message === "string" && error.message.trim()
? error.message.trim()
: "Failed to update relay preferences.";
operationContext.reason = error?.code || "callback-error";
operationContext.error = error;
this.mainController.showError(message);
return operationContext;
}
if (result && typeof result === "object") {
operationContext.ok = Boolean(result.ok);
operationContext.changed = Boolean(result.changed);
operationContext.reason =
typeof result.reason === "string" ? result.reason : operationContext.reason;
operationContext.error = result.error ?? operationContext.error;
operationContext.publishResult =
result.publishResult ?? operationContext.publishResult;
operationContext.operationResult =
result.operationResult ?? operationContext.operationResult;
}
if (!operationContext.changed && skipPublishIfUnchanged) {
const reason = operationContext.reason || "unchanged";
operationContext.reason = reason;
if (reason === "duplicate") {
this.mainController.showSuccess("Relay is already configured.");
} else if (typeof unchangedMessage === "string" && unchangedMessage) {
this.mainController.showSuccess(unchangedMessage);
}
this.populateProfileRelays();
void this.refreshRelayHealthPanel({ forceRefresh: true, reason: "relay-update" });
return operationContext;
}
this.populateProfileRelays();
void this.refreshRelayHealthPanel({ forceRefresh: true, reason: "relay-update" });
if (operationContext.ok) {
if (successMessage) {
this.mainController.showSuccess(successMessage);
}
return operationContext;
}
const message =
operationContext.error &&
typeof operationContext.error.message === "string" &&
operationContext.error.message.trim()
? operationContext.error.message.trim()
: "Failed to publish relay configuration. Please try again.";
if (operationContext.reason !== "no-active-pubkey") {
this.mainController.showError(message);
}
return operationContext;
}
async handleAddRelay() {
const rawValue =
typeof this.relayInput?.value === "string"
? this.relayInput.value
: "";
const trimmed = rawValue.trim();
const context = {
input: this.relayInput,
rawValue,
url: trimmed,
result: null,
success: false,
reason: null,
};
if (!trimmed) {
this.mainController.showError("Enter a relay URL to add.");
context.reason = "empty";
this.mainController.callbacks.onAddRelay(context, this);
return context;
}
const operationResult = await this.handleRelayOperation(
{ action: "add", url: trimmed },
{
successMessage: "Relay saved.",
unchangedMessage: "Relay is already configured.",
},
);
if (this.relayInput) {
this.relayInput.value = "";
}
context.result = operationResult;
context.success = !!operationResult?.ok;
context.reason = operationResult?.reason || null;
this.mainController.callbacks.onAddRelay(context, this);
return context;
}
async handleRestoreRelays() {
const context = {
confirmed: false,
result: null,
success: false,
reason: null,
};
const confirmed = window.confirm("Restore the recommended relay defaults?");
context.confirmed = confirmed;
if (!confirmed) {
context.reason = "cancelled";
this.mainController.callbacks.onRestoreRelays(context, this);
return context;
}
const operationResult = await this.handleRelayOperation(
{ action: "restore" },
{
successMessage: "Relay defaults restored.",
unchangedMessage: "Relay defaults are already in use.",
},
);
context.result = operationResult;
context.success = !!operationResult?.ok;
context.reason = operationResult?.reason || null;
this.mainController.callbacks.onRestoreRelays(context, this);
this.mainController.callbacks.onRelayRestore({
controller: this,
context,
});
return context;
}
async handleRelayModeToggle(url) {
if (!url) {
return;
}
const context = await this.handleRelayOperation(
{ action: "mode-toggle", url },
{ successMessage: "Relay mode updated." },
);
this.mainController.callbacks.onRelayModeToggle({
controller: this,
url,
context,
});
}
async handleRemoveRelay(url) {
if (!url) {
return;
}
const confirmed = window.confirm(
`Remove ${url} from your relay list?`,
);
if (!confirmed) {
return;
}
await this.handleRelayOperation(
{ action: "remove", url },
{ successMessage: "Relay removed." },
);
}
}
File diff suppressed because it is too large. Load diff
+1 -1
View File
@@ -26,7 +26,7 @@ const GROWTH_MARGIN = 50; // lines — allowlisted files may grow by this much f
// These files are known to be oversized and are decomposition targets.
// They must NOT grow beyond (recorded + GROWTH_MARGIN).
const GRANDFATHERED = {
"js/ui/profileModalController.js": 11963,
"js/ui/profileModalController.js": 8753,
"js/ui/components/VideoModal.js": 6284,
"js/app.js": 5602,
"js/channelProfile.js": 5529,
+3 -3
View File
@@ -1014,7 +1014,7 @@ test('show()/hide() toggle panes, trap focus, and refresh the wallet pane', asyn
};
let relaysPopulated = 0;
controller.populateProfileRelays = () => {
controller.relayController.populateProfileRelays = () => {
relaysPopulated += 1;
};
@@ -1074,10 +1074,10 @@ test('populateProfileRelays renders entries and wires action buttons', async ()
const modeCalls = [];
const removeCalls = [];
controller.handleRelayModeToggle = (url) => {
controller.relayController.handleRelayModeToggle = (url) => {
modeCalls.push(url);
};
controller.handleRemoveRelay = (url) => {
controller.relayController.handleRemoveRelay = (url) => {
removeCalls.push(url);
};