fix(messages): never render raw uid as group sender name (#32)

Senders missing from the member list (left/removed) fell back to msg.from verbatim, and nameless members arrive from the REST API with the uid itself as display_name. Resolve both through a shared groupSenderLabel helper that substitutes a fixed placeholder for uid-shaped names (message bubbles, quoted replies, history search).
This commit is contained in:
Austin
2026-09-20 16:42:35 +08:00
parent 41728017cb
commit 1952f0ebcd
4 changed files with 31 additions and 6 deletions
@@ -27,6 +27,7 @@ import { availableActions, recallRemainingSeconds, type MessageAction } from '@/
import { isRecalled } from '@/lib/messages/dispatch'; import { isRecalled } from '@/lib/messages/dispatch';
import { renderDrafty } from '@/lib/messages/drafty'; import { renderDrafty } from '@/lib/messages/drafty';
import { buildMentionedContent, detectMentionTrigger, type PendingMention } from '@/lib/messages/mentions'; import { buildMentionedContent, detectMentionTrigger, type PendingMention } from '@/lib/messages/mentions';
import { groupSenderLabel } from '@/lib/messages/sender-name';
import { sendFile, sendImage, sendVoice } from '@/lib/tinode/attachments'; import { sendFile, sendImage, sendVoice } from '@/lib/tinode/attachments';
import { isTopicGone } from '@/lib/tinode/errors'; import { isTopicGone } from '@/lib/tinode/errors';
import { useChatList, useTinode, useTopicMessages } from '@/lib/tinode/hooks'; import { useChatList, useTinode, useTopicMessages } from '@/lib/tinode/hooks';
@@ -568,7 +569,7 @@ export function ConversationView() {
// second, divergent resolution path. // second, divergent resolution path.
const quotedSenderLabel = (fromUserId: string | undefined) => { const quotedSenderLabel = (fromUserId: string | undefined) => {
if (myUserId && fromUserId === myUserId) return '我'; if (myUserId && fromUserId === myUserId) return '我';
if (isGroup && fromUserId) return memberByUserId.get(fromUserId)?.display_name || fromUserId; if (isGroup && fromUserId) return groupSenderLabel(memberByUserId.get(fromUserId)?.display_name);
return peerName; return peerName;
}; };
@@ -9,6 +9,7 @@ import { checkUrlSafety } from '@/lib/api/security';
import { avatarBgClass, avatarLetter } from '@/lib/avatar'; import { avatarBgClass, avatarLetter } from '@/lib/avatar';
import { useAuthedBlobUrl, useDebouncedValue, useEscapeKey } from '@/lib/hooks'; import { useAuthedBlobUrl, useDebouncedValue, useEscapeKey } from '@/lib/hooks';
import { renderDrafty } from '@/lib/messages/drafty'; import { renderDrafty } from '@/lib/messages/drafty';
import { groupSenderLabel } from '@/lib/messages/sender-name';
import { formatEventTimestamp } from '@/lib/messages/time'; import { formatEventTimestamp } from '@/lib/messages/time';
import { requestConfirm } from '@/stores/confirmDialog'; import { requestConfirm } from '@/stores/confirmDialog';
import { notify } from '@/stores/globalToast'; import { notify } from '@/stores/globalToast';
@@ -163,7 +164,7 @@ export function HistorySearchModal({ topicId, initialTab = 'text', onClose, isGr
const senderLabel = (from: string): string => { const senderLabel = (from: string): string => {
const full = normalizeSender(from); const full = normalizeSender(from);
if (myUserId && full === myUserId) return '我'; if (myUserId && full === myUserId) return '我';
if (isGroup) return membersQuery.data?.find((m) => m.user_id === full)?.display_name || from; if (isGroup) return groupSenderLabel(membersQuery.data?.find((m) => m.user_id === full)?.display_name);
return peerName; return peerName;
}; };
+5 -4
View File
@@ -9,6 +9,7 @@ import { callResultDisplay, contactCardContentOf, isRecalled, isServerDeleted, s
import { renderDrafty } from '@/lib/messages/drafty'; import { renderDrafty } from '@/lib/messages/drafty';
import { formatBubbleTime, formatEventTimestamp } from '@/lib/messages/time'; import { formatBubbleTime, formatEventTimestamp } from '@/lib/messages/time';
import { isFailedStatus, isSendingStatus, outgoingStatusLabel } from '@/lib/messages/status'; import { isFailedStatus, isSendingStatus, outgoingStatusLabel } from '@/lib/messages/status';
import { groupSenderLabel } from '@/lib/messages/sender-name';
import { retryMessage } from '@/lib/tinode/send'; import { retryMessage } from '@/lib/tinode/send';
import { ensureTopicAttached } from '@/lib/tinode/topics'; import { ensureTopicAttached } from '@/lib/tinode/topics';
@@ -154,12 +155,12 @@ export function MessageRow({
not navigate away and lose whatever's already been picked. */} not navigate away and lose whatever's already been picked. */}
{isGroup && !mine && !selectMode && msg.from ? ( {isGroup && !mine && !selectMode && msg.from ? (
<Link to={`/contacts/${msg.from}`} className="flex-none no-underline"> <Link to={`/contacts/${msg.from}`} className="flex-none no-underline">
<Avatar id={msg.from} name={senderName || msg.from} avatarFileId={avatarFileId} className="h-9 w-9 rounded-lg text-sm" /> <Avatar id={msg.from} name={groupSenderLabel(senderName)} avatarFileId={avatarFileId} className="h-9 w-9 rounded-lg text-sm" />
</Link> </Link>
) : ( ) : (
<Avatar <Avatar
id={msg.from || (isGroup ? '' : peerName)} id={msg.from || (isGroup ? '' : peerName)}
name={mine ? '我' : isGroup ? senderName || msg.from : peerName} name={mine ? '我' : isGroup ? groupSenderLabel(senderName) : peerName}
avatarFileId={avatarFileId} avatarFileId={avatarFileId}
fallbackBgClass={mine ? 'bg-accent' : undefined} fallbackBgClass={mine ? 'bg-accent' : undefined}
className="h-9 w-9 flex-none rounded-lg text-sm" className="h-9 w-9 flex-none rounded-lg text-sm"
@@ -170,10 +171,10 @@ export function MessageRow({
<div className={`flex items-center gap-1 px-0.5 text-xs text-text-tertiary-4 ${mine ? 'flex-row-reverse' : ''}`}> <div className={`flex items-center gap-1 px-0.5 text-xs text-text-tertiary-4 ${mine ? 'flex-row-reverse' : ''}`}>
{!mine && !selectMode && msg.from ? ( {!mine && !selectMode && msg.from ? (
<Link to={`/contacts/${msg.from}`} className="text-text-tertiary-4 no-underline hover:underline"> <Link to={`/contacts/${msg.from}`} className="text-text-tertiary-4 no-underline hover:underline">
{senderName || msg.from} {groupSenderLabel(senderName)}
</Link> </Link>
) : ( ) : (
<span>{mine ? '我' : senderName || msg.from}</span> <span>{mine ? '我' : groupSenderLabel(senderName)}</span>
)} )}
<MessageRoleTag role={senderRole === 'owner' || senderRole === 'admin' ? senderRole : undefined} /> <MessageRoleTag role={senderRole === 'owner' || senderRole === 'admin' ? senderRole : undefined} />
</div> </div>
+22
View File
@@ -0,0 +1,22 @@
/**
* Sender display label for group chat surfaces (bubbles, quoted replies, history search).
*
* Two distinct paths used to leak the raw `usr...` id into the message list (Zentao #32):
* - the sender is no longer in the member list (left/was removed) so there is no resolved
* name at all, and the row fell back to `msg.from` verbatim;
* - the member IS in the list but has no nickname/remark anywhere, so the server's own
* display-name fallback in userSummaryPayload is the user id itself.
* Both must render a fixed placeholder instead -- a uid is an internal identifier, not a
* user-facing name. Membership of a uid is decided by shape (`usr…` followed by id charset),
* because the server's fallback means the raw id can arrive *inside* `senderName`.
*/
const UID_SHAPE = /^usr[A-Za-z0-9_-]+$/;
export const UNKNOWN_SENDER_LABEL = '未知用户';
export function groupSenderLabel(senderName: string | undefined): string {
const name = senderName?.trim();
if (name && !UID_SHAPE.test(name)) return name;
// No usable name -- never show the raw uid even when it IS available; it's not a user-facing name.
return UNKNOWN_SENDER_LABEL;
}