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]>
This commit is contained in:
thePR0M3TH3ANandClaude Opus 4.8 committed 2026-06-23 20:37:30 -04:00
1 parent 4f7baa3e99
commit d29287a580
5 files changed
+193 -11

No files matched your search

@@ -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") {
@@ -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) {
@@ -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");
+116
View File
@@ -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);
});
+14
View File
@@ -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,