fix(messages): @mentions leaked the sender's private remark

Reported + reproduced live: a group owner's own private friend remark
for a member ("小号1"/"私密备注勿外传" in testing) ended up baked into
that member's @mention text, visible to every other group member with
no idea what it referred to.

Root cause confirmed by reading server/im_http.go's userSummaryPayload:
GET .../members?mentionable=true's `display_name` unconditionally
prefers the CALLING viewer's own remark for that member when one is
set. That's correct for a list only the viewer sees, but MentionPicker
was using it to build the mention's label, which gets baked into the
message's shared Drafty content once (mentions.ts's buildMentionedContent)
and read identically by everyone from then on -- unlike a sender-name
label, which every viewer's own client re-renders fresh per message and
can legitimately show their own remark for.

Switches MentionPicker's label (both the picker's own visible list and
the mention baked into the sent message) to the same response's
`nickname` field (copyUserNickname in the same backend function),
which reads straight off the member's public profile and is never
remark-shadowed. Verified live: set a real friend remark, confirmed
the picker still showed the real nickname (not the remark), picked it,
and confirmed the actually-sent message's @mention text was the real
nickname too -- while the unrelated sender-name label elsewhere in the
same conversation correctly kept showing the remark, confirming the
fix didn't touch that intentionally-different behavior.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
This commit is contained in:
Your Name
2026-09-24 16:10:03 +08:00
co-authored by Claude Sonnet 5
parent cbb01085c4
commit 304283c8d8
2 changed files with 47 additions and 21 deletions
+31 -21
View File
@@ -50,28 +50,38 @@ export function MentionPicker({ topicId, query, onPick }: MentionPickerProps) {
<span className="text-sm text-text-body">所有人</span>
</button>
) : null}
{items.map((m) => (
<button
key={m.user_id}
type="button"
onClick={() => onPick({ userId: m.user_id, label: m.display_name || m.user_id })}
className="flex w-full items-center gap-2 px-3 py-2 text-left hover:bg-[#f2f6fd]"
>
<span className={`grid h-7 w-7 flex-none place-items-center rounded-lg text-xs font-medium text-white ${avatarBgClass(m.user_id)}`}>
{avatarLetter(m.display_name)}
</span>
<span className="min-w-0 flex-1 truncate text-sm text-text-body">{m.display_name || m.user_id}</span>
{m.role !== 'member' ? (
<span
className={`flex-none rounded-[3px] border px-1 text-[10px] leading-[14px] ${
m.role === 'owner' ? 'border-warn text-warn' : 'border-accent text-accent'
}`}
>
{ROLE_LABEL[m.role]}
{items.map((m) => {
// Real, reported bug: `display_name` here silently prefers the CURRENT viewer's own
// private remark for this member (see GroupMember.display_name's own doc comment) --
// fine for a list only the viewer sees, wrong for an @mention, whose label gets baked
// into the message's shared content once and read by every OTHER member from then on,
// who have no idea what the sender's personal remark for this person means. `nickname`
// is that member's own real public name, unaffected by anyone's remark, so it stays
// correct for whoever else ends up reading the mention.
const label = m.nickname || m.display_name || m.user_id;
return (
<button
key={m.user_id}
type="button"
onClick={() => onPick({ userId: m.user_id, label })}
className="flex w-full items-center gap-2 px-3 py-2 text-left hover:bg-[#f2f6fd]"
>
<span className={`grid h-7 w-7 flex-none place-items-center rounded-lg text-xs font-medium text-white ${avatarBgClass(m.user_id)}`}>
{avatarLetter(label)}
</span>
) : null}
</button>
))}
<span className="min-w-0 flex-1 truncate text-sm text-text-body">{label}</span>
{m.role !== 'member' ? (
<span
className={`flex-none rounded-[3px] border px-1 text-[10px] leading-[14px] ${
m.role === 'owner' ? 'border-warn text-warn' : 'border-accent text-accent'
}`}
>
{ROLE_LABEL[m.role]}
</span>
) : null}
</button>
);
})}
</>
)}
</div>
+16
View File
@@ -147,7 +147,23 @@ export type GroupRole = 'owner' | 'admin' | 'member';
export interface GroupMember {
user_id: string;
/**
* Real, live-confirmed gotcha (2026-09-24): this is NOT always the member's own real name.
* Reading server/im_http.go's userSummaryPayload confirms `display_name` here unconditionally
* prefers the CALLING viewer's own private remark for that member when one is set, falling back
* to their real public nickname only when the viewer has no remark for them -- correct and
* intended for rendering a list to yourself (README's member list/search), completely wrong for
* anything that gets baked into shared message CONTENT once and read by everyone from then on
* (an @mention's label): a real reported bug was exactly this -- a group owner's own private
* remark for a member ("小号1") ended up as that member's permanent @mention text, which every
* OTHER member then saw with no idea who it meant. Use `nickname` (below) instead for that case.
*/
display_name?: string;
/** The member's own real public profile nickname -- same backend response, but from
* copyUserNickname(), which reads straight off their public profile and is NEVER shadowed by the
* viewer's own remark the way `display_name` above is. This is what stays correct for everyone
* once baked into shared content (an @mention's label), not just for the viewer's own screen. */
nickname?: string;
avatar_file_id?: string;
role: GroupRole;
mentionable?: boolean;