diff --git a/js/ui/profileModal/ProfileDirectMessageController.js b/js/ui/profileModal/ProfileDirectMessageController.js index 07e0018b..3ee263c2 100644 --- a/js/ui/profileModal/ProfileDirectMessageController.js +++ b/js/ui/profileModal/ProfileDirectMessageController.js @@ -242,6 +242,17 @@ export class ProfileDirectMessageController { this.updateMessageThreadSelection(nextRecipient); if (nextRecipient) { + // Focus this recipient's conversation 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. + const actor = this.helper.resolveActiveDmActor(); + const conversationId = actor + ? this.helper.buildDmConversationId(actor, nextRecipient) + : ""; + if (conversationId) { + this.setFocusedDmConversation(conversationId); + } void this.helper.ensureDmRecipientData(nextRecipient); this.renderer.setMessagesAnnouncement("Ready to message this recipient."); } else if (reason === "clear") { diff --git a/js/ui/profileModal/ProfileDirectMessageHelper.js b/js/ui/profileModal/ProfileDirectMessageHelper.js index bbd71102..874b3f54 100644 --- a/js/ui/profileModal/ProfileDirectMessageHelper.js +++ b/js/ui/profileModal/ProfileDirectMessageHelper.js @@ -635,13 +635,49 @@ export class ProfileDirectMessageHelper { 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 || ""; - const activeConversationId = - preferredConversationId && conversationMap.has(preferredConversationId) - ? preferredConversationId - : fallbackConversationId; + // 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; @@ -649,7 +685,11 @@ export class ProfileDirectMessageHelper { activeThread?.remoteHex || (activeConversationId ? this.resolveRemoteForConversationId(activeConversationId, actor) - : storedRecipient) || + : 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) { diff --git a/js/ui/profileModal/ProfileDirectMessageRenderer.js b/js/ui/profileModal/ProfileDirectMessageRenderer.js index 7595b1af..fe11cfd2 100644 --- a/js/ui/profileModal/ProfileDirectMessageRenderer.js +++ b/js/ui/profileModal/ProfileDirectMessageRenderer.js @@ -566,11 +566,14 @@ export class ProfileDirectMessageRenderer { } const activeRecipient = this.controller.helper.resolveActiveDmRecipient(); - const hasActiveRecipient = - activeRecipient && - threadsToRender.some((thread) => thread.remoteHex === activeRecipient); - if (threadsToRender.length && !hasActiveRecipient) { + if (activeRecipient) { + // Respect an explicitly-selected recipient even if it has no thread yet + // (e.g. the "Message" button for someone you've never DMed). Defaulting + // to threadsToRender[0] here is what made the inbox jump to the top + // conversation instead of the person you chose. + this.updateMessageThreadSelection(activeRecipient); + } else if (threadsToRender.length) { this.controller.setDirectMessageRecipient(threadsToRender[0].remoteHex, { reason: "thread-default", }); @@ -579,8 +582,6 @@ export class ProfileDirectMessageRenderer { threadsToRender[0].remoteHex, ); this.controller.setFocusedDmConversation(conversationId); - } else if (hasActiveRecipient) { - this.updateMessageThreadSelection(activeRecipient); } this.profileMessagesList.classList.remove("hidden"); diff --git a/tests/ui/dm-conversation-focus.test.mjs b/tests/ui/dm-conversation-focus.test.mjs new file mode 100644 index 00000000..c19d187d --- /dev/null +++ b/tests/ui/dm-conversation-focus.test.mjs @@ -0,0 +1,116 @@ +// Regression: clicking the "Message" button on the profile of someone you've +// never DMed sets the recipient, but buildDmConversationData used to discard it +// (no existing thread => not in conversationMap => fall back to conversations[0]) +// and silently focus the TOP conversation instead. Any message you then sent +// went to the wrong person ("it says sent but I don't see it"). +// +// Scenario (SCN-dm-focus-new-recipient): +// Given an inbox with one existing thread (Bob), +// And the user has explicitly selected a brand-new recipient (Carol) with no +// messages yet, +// When the DM conversation data is built, +// Then the active conversation is Carol's (not Bob's), Carol appears as a +// synthesized conversation, and the active remote pubkey is Carol — so the +// composer targets Carol. + +import test from "node:test"; +import assert from "node:assert/strict"; + +import { ProfileDirectMessageHelper } from "../../js/ui/profileModal/ProfileDirectMessageHelper.js"; + +const ACTOR = "a".repeat(64); +const BOB = "b".repeat(64); +const CAROL = "c".repeat(64); + +function buildHelper({ storedRecipient }) { + const mainController = { + normalizeHexPubkey: (value) => + typeof value === "string" && value.trim() ? value.trim() : "", + getActivePubkey: () => ACTOR, + safeEncodeNpub: (hex) => `npub_${hex.slice(0, 6)}`, + formatShortNpub: (npub) => npub, + services: { + nostrClient: {}, + getProfileCacheEntry: () => null, + }, + state: { + getDmRecipient: () => storedRecipient, + getDmRelayHints: () => [], + getDmRelayPreferences: () => [], + }, + nostrService: { + getDirectMessageUnseenCount: () => 0, + }, + }; + const controller = { + activeDmConversationId: "", + directMessagesCache: [], + }; + const helper = new ProfileDirectMessageHelper(mainController, controller); + + // Keep the focus-selection logic real; stub only presentation helpers that + // would otherwise require a full profile cache. + helper.resolveProfileSummaryForPubkey = (pubkey) => ({ + displayName: `name_${pubkey.slice(0, 4)}`, + avatarSrc: "", + status: "", + lightningAddress: "", + }); + helper.resolveDirectMessagePreviewForConversation = () => "preview"; + helper.buildDmMessageTimeline = () => []; + + return helper; +} + +// One existing message thread with Bob. +const MESSAGES = [ + { + ok: true, + actorPubkey: ACTOR, + senderPubkey: BOB, + recipientPubkey: ACTOR, + remotePubkey: BOB, + created_at: 1000, + content: "hi from bob", + scheme: "nip04", + }, +]; + +test("focuses a freshly-selected recipient with no thread instead of the top conversation", async () => { + const helper = buildHelper({ storedRecipient: CAROL }); + + const data = await helper.buildDmConversationData(MESSAGES, { actorPubkey: ACTOR }); + + const carolId = helper.buildDmConversationId(ACTOR, CAROL); + const bobId = helper.buildDmConversationId(ACTOR, BOB); + + assert.equal( + data.activeConversationId, + carolId, + "active conversation must be the selected recipient (Carol), not the top thread (Bob)", + ); + assert.notEqual(data.activeConversationId, bobId); + assert.equal( + data.activeRemotePubkey, + CAROL, + "composer target (activeRemotePubkey) must be Carol", + ); + assert.ok( + data.conversations.some((c) => c.pubkey === CAROL), + "a synthesized conversation for Carol must be present in the list", + ); +}); + +test("still defaults to the top conversation when no recipient is selected", async () => { + const helper = buildHelper({ storedRecipient: null }); + + const data = await helper.buildDmConversationData(MESSAGES, { actorPubkey: ACTOR }); + + const bobId = helper.buildDmConversationId(ACTOR, BOB); + assert.equal( + data.activeConversationId, + bobId, + "with no explicit recipient, default to the most recent thread (Bob)", + ); + assert.equal(data.activeRemotePubkey, BOB); +}); diff --git a/todo/TODO_2026-06-20_pre-launch.md b/todo/TODO_2026-06-20_pre-launch.md index 2a9df65e..0923a7c5 100644 --- a/todo/TODO_2026-06-20_pre-launch.md +++ b/todo/TODO_2026-06-20_pre-launch.md @@ -249,6 +249,20 @@ un-hides when WebTorrent flips green). The real gaps: publish, NIP-04 default / NIP-17) was fine. Regression test `tests/dm-controller-delegations.test.mjs`. VERIFY live: typing + Send actually delivers a message. +- [x] **Follow-up: "Message" button targeted the wrong person.** Clicking Message + on a profile (or 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 went to THAT person ("says sent but I don't see it"). Fixes: (a) + `setDirectMessageRecipient` now focuses the selected recipient's + conversation; (b) `buildDmConversationData` synthesizes an empty + conversation for a selected-but-threadless recipient and honors it as active + (incl. `activeRemotePubkey` fallback for the composer target); (c) the legacy + `renderProfileMessages` list no longer clobbers a non-null recipient. + Regression: `tests/ui/dm-conversation-focus.test.mjs`. VERIFY live: Message a + brand-new person, confirm the thread opens on THEM and the sent message lands + in that conversation after refresh. ### 20. Feed / grid loading reliability — some videos missing (BUG) Two related intermittent symptoms; likely the same root cause (a one-shot,