mirror of
https://github.com/PR0M3TH3AN/bitvid.git
synced 2026-10-31 18:02:27 +00:00
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:
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");
|
||||
|
||||
@@ -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);
|
||||
});
|
||||
@@ -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,
|
||||
|
||||
Reference in new issue
Block a user