Files
bitvid/js/ui/profileModal/ProfileDirectMessageHelper.js
T
thePR0M3TH3ANandClaude Opus 4.8 d29287a580 dm: focus the selected recipient instead of the top conversation
Clicking "Message" on a profile (or selecting anyone you'd never DMed) set the
recipient, but buildDmConversationData discarded it: a recipient with no
existing thread wasn't in conversationMap, so it fell back to conversations[0]
(the top conversation). The inbox silently jumped to the top thread, and any
message sent then went to the wrong person ("says sent but I don't see it").

Fixes:
- setDirectMessageRecipient now focuses the selected recipient's conversation
  so a stale activeDmConversationId can't win.
- buildDmConversationData synthesizes an empty conversation for a
  selected-but-threadless recipient and treats it as active, including an
  activeRemotePubkey fallback so the composer targets the right person.
- The legacy renderProfileMessages list no longer clobbers a non-null
  recipient with threadsToRender[0].

Regression: tests/ui/dm-conversation-focus.test.mjs.

Co-Authored-By: Claude Opus 4.8 <[email protected]>
2026-06-23 20:37:30 -04:00

827 lines
25 KiB
JavaScript

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;
}
updateDmPrivacyToggleForRecipient(...args) {
if (typeof this.mainController.updateDmPrivacyToggleForRecipient === "function") {
return this.mainController.updateDmPrivacyToggleForRecipient(...args);
}
return undefined;
}
resolveDirectMessageScheme(message) {
if (typeof this.mainController.resolveDirectMessageScheme === "function") {
return this.mainController.resolveDirectMessageScheme(message);
}
if (!message || typeof message !== "object") {
return "";
}
const scheme =
typeof message.scheme === "string"
? message.scheme
: typeof message.encryption_scheme === "string"
? message.encryption_scheme
: typeof message?.decryptor?.scheme === "string"
? message.decryptor.scheme
: "";
return typeof scheme === "string" ? scheme.trim().toLowerCase() : "";
}
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)
: "";
// If a recipient is explicitly selected but has no existing thread yet
// (e.g. the "Message" button on the profile of someone you've never DMed),
// synthesize an empty conversation so the UI focuses THEM. Without this the
// selected recipient falls through to fallbackConversationId and the inbox
// silently jumps to the top conversation — and any message you send goes to
// the wrong person.
if (
storedRecipient &&
storedConversationId &&
!conversationMap.has(storedConversationId) &&
!isRemoteBlocked(storedRecipient)
) {
const summary = this.resolveProfileSummaryForPubkey(storedRecipient);
const recipientContext = this.buildDmRecipientContext(storedRecipient);
conversations.unshift({
id: storedConversationId,
name: summary.displayName,
preview: "No messages yet",
timestamp: "",
unreadCount: 0,
avatarSrc: summary.avatarSrc,
status: summary.status,
pubkey: storedRecipient,
lightningAddress: summary.lightningAddress,
relayHints: Array.isArray(recipientContext?.relayHints)
? recipientContext.relayHints
: [],
});
}
const preferredConversationId =
this.controller.activeDmConversationId || storedConversationId;
const fallbackConversationId = conversations[0]?.id || "";
// A selected recipient with no messages yet won't be in conversationMap, but
// it IS a valid focus target (storedConversationId / the synthetic entry).
const preferredIsSelectable =
preferredConversationId &&
(conversationMap.has(preferredConversationId) ||
preferredConversationId === storedConversationId);
const activeConversationId = preferredIsSelectable
? preferredConversationId
: fallbackConversationId;
const activeThread = activeConversationId
? conversationMap.get(activeConversationId)
: null;
const activeRemotePubkey =
activeThread?.remoteHex ||
(activeConversationId
? this.resolveRemoteForConversationId(activeConversationId, actor)
: null) ||
// A freshly-selected recipient has no messages, so it can't be resolved
// from the cache above — fall back to the stored recipient for the
// synthetic conversation so the composer targets the right person.
(activeConversationId === storedConversationId ? storedRecipient : null) ||
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;
}
}