diff --git a/web/src/features/messages/ConversationView.tsx b/web/src/features/messages/ConversationView.tsx
index 580e9f1f..75df60f5 100644
--- a/web/src/features/messages/ConversationView.tsx
+++ b/web/src/features/messages/ConversationView.tsx
@@ -27,6 +27,7 @@ import { availableActions, recallRemainingSeconds, type MessageAction } from '@/
import { isRecalled } from '@/lib/messages/dispatch';
import { renderDrafty } from '@/lib/messages/drafty';
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 { isTopicGone } from '@/lib/tinode/errors';
import { useChatList, useTinode, useTopicMessages } from '@/lib/tinode/hooks';
@@ -568,7 +569,7 @@ export function ConversationView() {
// second, divergent resolution path.
const quotedSenderLabel = (fromUserId: string | undefined) => {
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;
};
diff --git a/web/src/features/messages/HistorySearchModal.tsx b/web/src/features/messages/HistorySearchModal.tsx
index 035928c9..4b54f579 100644
--- a/web/src/features/messages/HistorySearchModal.tsx
+++ b/web/src/features/messages/HistorySearchModal.tsx
@@ -9,6 +9,7 @@ import { checkUrlSafety } from '@/lib/api/security';
import { avatarBgClass, avatarLetter } from '@/lib/avatar';
import { useAuthedBlobUrl, useDebouncedValue, useEscapeKey } from '@/lib/hooks';
import { renderDrafty } from '@/lib/messages/drafty';
+import { groupSenderLabel } from '@/lib/messages/sender-name';
import { formatEventTimestamp } from '@/lib/messages/time';
import { requestConfirm } from '@/stores/confirmDialog';
import { notify } from '@/stores/globalToast';
@@ -163,7 +164,7 @@ export function HistorySearchModal({ topicId, initialTab = 'text', onClose, isGr
const senderLabel = (from: string): string => {
const full = normalizeSender(from);
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;
};
diff --git a/web/src/features/messages/MessageBubble.tsx b/web/src/features/messages/MessageBubble.tsx
index cd1d2e07..b11e4724 100644
--- a/web/src/features/messages/MessageBubble.tsx
+++ b/web/src/features/messages/MessageBubble.tsx
@@ -9,6 +9,7 @@ import { callResultDisplay, contactCardContentOf, isRecalled, isServerDeleted, s
import { renderDrafty } from '@/lib/messages/drafty';
import { formatBubbleTime, formatEventTimestamp } from '@/lib/messages/time';
import { isFailedStatus, isSendingStatus, outgoingStatusLabel } from '@/lib/messages/status';
+import { groupSenderLabel } from '@/lib/messages/sender-name';
import { retryMessage } from '@/lib/tinode/send';
import { ensureTopicAttached } from '@/lib/tinode/topics';
@@ -154,12 +155,12 @@ export function MessageRow({
not navigate away and lose whatever's already been picked. */}
{isGroup && !mine && !selectMode && msg.from ? (
-
+
) : (
{!mine && !selectMode && msg.from ? (
- {senderName || msg.from}
+ {groupSenderLabel(senderName)}
) : (
- {mine ? '我' : senderName || msg.from}
+ {mine ? '我' : groupSenderLabel(senderName)}
)}
diff --git a/web/src/lib/messages/sender-name.ts b/web/src/lib/messages/sender-name.ts
new file mode 100644
index 00000000..7551da22
--- /dev/null
+++ b/web/src/lib/messages/sender-name.ts
@@ -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;
+}