mirror of
https://github.com/PR0M3TH3AN/bitvid.git
synced 2026-10-27 07:52:24 +00:00
Merge pull request #2368 from PR0M3TH3AN/decompose-profile-modal-controller-1637476114002717833
Decompose ProfileModalController
This commit is contained in:
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.
|
||||
// They must NOT grow beyond (recorded + GROWTH_MARGIN).
|
||||
const GRANDFATHERED = {
|
||||
"js/ui/profileModalController.js": 11963,
|
||||
"js/ui/profileModalController.js": 8753,
|
||||
"js/ui/components/VideoModal.js": 6284,
|
||||
"js/app.js": 5602,
|
||||
"js/channelProfile.js": 5529,
|
||||
|
||||
@@ -1014,7 +1014,7 @@ test('show()/hide() toggle panes, trap focus, and refresh the wallet pane', asyn
|
||||
};
|
||||
|
||||
let relaysPopulated = 0;
|
||||
controller.populateProfileRelays = () => {
|
||||
controller.relayController.populateProfileRelays = () => {
|
||||
relaysPopulated += 1;
|
||||
};
|
||||
|
||||
@@ -1074,10 +1074,10 @@ test('populateProfileRelays renders entries and wires action buttons', async ()
|
||||
|
||||
const modeCalls = [];
|
||||
const removeCalls = [];
|
||||
controller.handleRelayModeToggle = (url) => {
|
||||
controller.relayController.handleRelayModeToggle = (url) => {
|
||||
modeCalls.push(url);
|
||||
};
|
||||
controller.handleRemoveRelay = (url) => {
|
||||
controller.relayController.handleRemoveRelay = (url) => {
|
||||
removeCalls.push(url);
|
||||
};
|
||||
|
||||
|
||||
Reference in new issue
Block a user