mirror of
https://github.com/PR0M3TH3AN/bitvid.git
synced 2026-10-31 09:52:24 +00:00
[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:
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." },
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
}
|
||||||
+316
-3545
File diff suppressed because it is too large.
Load diff
@@ -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.
|
// These files are known to be oversized and are decomposition targets.
|
||||||
// They must NOT grow beyond (recorded + GROWTH_MARGIN).
|
// They must NOT grow beyond (recorded + GROWTH_MARGIN).
|
||||||
const GRANDFATHERED = {
|
const GRANDFATHERED = {
|
||||||
"js/ui/profileModalController.js": 11963,
|
"js/ui/profileModalController.js": 8753,
|
||||||
"js/ui/components/VideoModal.js": 6284,
|
"js/ui/components/VideoModal.js": 6284,
|
||||||
"js/app.js": 5602,
|
"js/app.js": 5602,
|
||||||
"js/channelProfile.js": 5529,
|
"js/channelProfile.js": 5529,
|
||||||
|
|||||||
@@ -1014,7 +1014,7 @@ test('show()/hide() toggle panes, trap focus, and refresh the wallet pane', asyn
|
|||||||
};
|
};
|
||||||
|
|
||||||
let relaysPopulated = 0;
|
let relaysPopulated = 0;
|
||||||
controller.populateProfileRelays = () => {
|
controller.relayController.populateProfileRelays = () => {
|
||||||
relaysPopulated += 1;
|
relaysPopulated += 1;
|
||||||
};
|
};
|
||||||
|
|
||||||
@@ -1074,10 +1074,10 @@ test('populateProfileRelays renders entries and wires action buttons', async ()
|
|||||||
|
|
||||||
const modeCalls = [];
|
const modeCalls = [];
|
||||||
const removeCalls = [];
|
const removeCalls = [];
|
||||||
controller.handleRelayModeToggle = (url) => {
|
controller.relayController.handleRelayModeToggle = (url) => {
|
||||||
modeCalls.push(url);
|
modeCalls.push(url);
|
||||||
};
|
};
|
||||||
controller.handleRemoveRelay = (url) => {
|
controller.relayController.handleRemoveRelay = (url) => {
|
||||||
removeCalls.push(url);
|
removeCalls.push(url);
|
||||||
};
|
};
|
||||||
|
|
||||||
|
|||||||
Reference in new issue
Block a user