Files
bitvid/js/ui/profileModal/ProfileDirectMessageController.js
T
thePR0M3TH3ANandClaude Opus 4.8 ab68ba51d0 dm: make a selected recipient the active conversation in the right pane
Clicking "Message" added the user to the sidebar list but the right-hand
message pane stayed on the previously-active conversation. setDirectMessageRecipient
called setFocusedDmConversation (which only tracks read-state) and never set
activeDmConversationId — the value buildDmConversationData reads to choose which
thread the right pane renders — so it kept resolving the stale id.

Set activeDmConversationId directly (mirroring handleDmConversationSelect), and
switch the mobile pane to the thread for explicit selections (not the auto
thread-default).

Regression: tests/ui/dm-set-recipient-active.test.mjs.

Co-Authored-By: Claude Opus 4.8 <[email protected]>
2026-06-23 21:01:04 -04:00

739 lines
21 KiB
JavaScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
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) {
// Make this recipient's conversation the active one so a freshly-selected
// recipient (e.g. the "Message" button for someone with no existing
// thread) isn't overridden by a stale activeDmConversationId pointing at
// the top of the inbox. setFocusedDmConversation only tracks read-state —
// activeDmConversationId is what buildDmConversationData reads to decide
// which thread the right-hand pane renders, so set it directly (mirroring
// handleDmConversationSelect).
const actor = this.helper.resolveActiveDmActor();
const conversationId = actor
? this.helper.buildDmConversationId(actor, nextRecipient)
: "";
if (conversationId) {
this.activeDmConversationId = conversationId;
// Selecting a recipient explicitly (not the auto thread-default) means
// the user wants to see that thread — switch the mobile pane to it too.
if (reason !== "thread-default") {
this.dmMobileView = "thread";
}
this.setFocusedDmConversation(conversationId);
}
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);
}
handleDmConversationSelect(...args) {
return this.actions.handleDmConversationSelect(...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);
}
// DM app-shell callbacks. The renderer wires these on `this.controller`, but the
// delegations were missing — so Send / mark-read / settings / toggles all threw
// "is not a function" and the app-shell DM UI couldn't send a message.
handleDmAppShellSendMessage(...args) {
return this.actions.handleDmAppShellSendMessage(...args);
}
handleDmConversationMarkRead(...args) {
return this.actions.handleDmConversationMarkRead(...args);
}
handleDmMarkAllConversationsRead(...args) {
return this.actions.handleDmMarkAllConversationsRead(...args);
}
handleReadReceiptsToggle(...args) {
return this.actions.handleReadReceiptsToggle(...args);
}
handleTypingIndicatorsToggle(...args) {
return this.actions.handleTypingIndicatorsToggle(...args);
}
openDmSettingsModal(...args) {
return this.actions.openDmSettingsModal(...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);
}
// ProfileModerationController calls dmController.resolveProfileSummaryForPubkey
// (and createCompactProfileSummary, already delegated below). The method lives on
// the helper, so without this delegation the moderation-settings UI threw
// "is not a function" on open.
resolveProfileSummaryForPubkey(...args) {
return this.helper.resolveProfileSummaryForPubkey(...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);
}
}