fix(messages): merge stale-sender-profile fallback into #32's uid-shape guard

Two independent fixes for the same raw-uid-leak bug landed in parallel:
upstream added groupSenderLabel() to catch uid-shaped names by regex
(covers a still-present member whose display_name IS the uid); the local
change instead resolved a left-group sender's real name via a standalone
getUserProfile() lookup (covers a member no longer in memberByUserId at
all). Neither alone covers both paths. groupSenderLabel now takes multiple
candidates in priority order -- member-list name first, then the fetched
stale-sender name, falling through to the placeholder only when neither
resolves to something that isn't uid-shaped -- so a left member with a
resolvable profile still shows their real name instead of the placeholder,
while the still-present-but-nameless case is unaffected.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
This commit is contained in:
Your Name
2026-09-21 13:50:41 +08:00
co-authored by Claude Sonnet 5
parent ca7af8baf9
commit 74351af905
2 changed files with 56 additions and 7 deletions
+43 -4
View File
@@ -1,6 +1,6 @@
import { useCallback, useEffect, useLayoutEffect, useMemo, useRef, useState } from 'react'; import { useCallback, useEffect, useLayoutEffect, useMemo, useRef, useState } from 'react';
import { useQuery } from '@tanstack/react-query'; import { useQueries, useQuery } from '@tanstack/react-query';
import { Link, useNavigate, useOutletContext, useParams } from 'react-router-dom'; import { Link, useNavigate, useOutletContext, useParams } from 'react-router-dom';
import type { StoredMessage } from 'tinode-sdk'; import type { StoredMessage } from 'tinode-sdk';
import { Drafty } from 'tinode-sdk'; import { Drafty } from 'tinode-sdk';
@@ -542,6 +542,38 @@ export function ConversationView() {
enabled: isGroup, enabled: isGroup,
}); });
const memberByUserId = useMemo(() => new Map((membersQuery.data || []).map((m) => [m.user_id, m])), [membersQuery.data]); const memberByUserId = useMemo(() => new Map((membersQuery.data || []).map((m) => [m.user_id, m])), [membersQuery.data]);
// `memberByUserId` only ever holds CURRENT members (`getAllGroupMembers`) -- a historical message
// from someone who has since left/been removed has no entry there, and used to fall all the way
// back to the raw `msg.from` uid string in the bubble (confirmed live, 2026-09-20: a real message
// from a member who then left the group rendered as "usrp77kUAkgqpU" instead of "systest02").
// Fetching each such uid's standalone profile (same `getUserProfile`/`['user-profile', id]` P2P
// already uses above) recovers a real display name/avatar in the common case; `retry:false` and a
// swallowed error mean a stranger this account has no visibility into (403, same as the already-
// documented `usrW9ijv236hHA` case) just falls back to the uid exactly as before -- never worse.
const staleSenderIds = useMemo(() => {
if (!isGroup) return [] as string[];
const ids = new Set<string>();
for (const msg of messages) {
if (msg.from && !memberByUserId.has(msg.from)) ids.add(msg.from);
}
return Array.from(ids);
}, [isGroup, messages, memberByUserId]);
const staleSenderQueries = useQueries({
queries: staleSenderIds.map((uid) => ({
queryKey: ['user-profile', uid],
queryFn: () => getUserProfile(uid),
staleTime: Infinity,
retry: false,
})),
});
const staleSenderById = useMemo(() => {
const map = new Map<string, { display_name: string; avatar_file_id?: string }>();
staleSenderIds.forEach((uid, i) => {
const data = staleSenderQueries[i]?.data;
if (data) map.set(uid, data);
});
return map;
}, [staleSenderIds, staleSenderQueries]);
const myMember = myUserId ? memberByUserId.get(myUserId) : undefined; const myMember = myUserId ? memberByUserId.get(myUserId) : undefined;
const isGroupAdmin = myMember?.role === 'owner' || myMember?.role === 'admin'; const isGroupAdmin = myMember?.role === 'owner' || myMember?.role === 'admin';
const groupSublineParts = [ const groupSublineParts = [
@@ -569,7 +601,9 @@ 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 groupSenderLabel(memberByUserId.get(fromUserId)?.display_name); if (isGroup && fromUserId) {
return groupSenderLabel(memberByUserId.get(fromUserId)?.display_name, staleSenderById.get(fromUserId)?.display_name);
}
return peerName; return peerName;
}; };
@@ -944,8 +978,13 @@ export function ConversationView() {
{loadingOlder ? <div className="py-2 text-center text-xs text-text-tertiary-4">加载中…</div> : null} {loadingOlder ? <div className="py-2 text-center text-xs text-text-tertiary-4">加载中…</div> : null}
{messages.map((msg) => { {messages.map((msg) => {
const sender = isGroup && msg.from ? memberByUserId.get(msg.from) : undefined; const sender = isGroup && msg.from ? memberByUserId.get(msg.from) : undefined;
const staleSender = isGroup && msg.from && !sender ? staleSenderById.get(msg.from) : undefined;
const mine = !!myUserId && msg.from === myUserId; const mine = !!myUserId && msg.from === myUserId;
const rowAvatarFileId = mine ? myAvatarFileId : isGroup ? sender?.avatar_file_id : peerAvatarFileId; const rowAvatarFileId = mine
? myAvatarFileId
: isGroup
? sender?.avatar_file_id || staleSender?.avatar_file_id
: peerAvatarFileId;
return ( return (
<MessageRow <MessageRow
key={msg.seq} key={msg.seq}
@@ -954,7 +993,7 @@ export function ConversationView() {
mine={mine} mine={mine}
isGroup={isGroup} isGroup={isGroup}
peerName={peerName} peerName={peerName}
senderName={sender?.display_name} senderName={sender?.display_name || staleSender?.display_name}
senderRole={sender?.role} senderRole={sender?.role}
avatarFileId={rowAvatarFileId} avatarFileId={rowAvatarFileId}
selectMode={selectMode} selectMode={selectMode}
+12 -2
View File
@@ -14,9 +14,19 @@ const UID_SHAPE = /^usr[A-Za-z0-9_-]+$/;
export const UNKNOWN_SENDER_LABEL = '未知用户'; export const UNKNOWN_SENDER_LABEL = '未知用户';
export function groupSenderLabel(senderName: string | undefined): string { /**
const name = senderName?.trim(); * Accepts multiple name candidates in priority order (e.g. the current member list's
* `display_name`, then a standalone profile lookup for a sender who has since left the group)
* and returns the first one that isn't uid-shaped. A left-group member's own profile can
* usually still be resolved by id and is a real name worth showing instead of the placeholder;
* a still-present member whose `display_name` IS the uid (the second bug path above) has no
* better candidate to fall back to and correctly lands on the placeholder either way.
*/
export function groupSenderLabel(...candidates: Array<string | undefined>): string {
for (const candidate of candidates) {
const name = candidate?.trim();
if (name && !UID_SHAPE.test(name)) return name; 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. // No usable name -- never show the raw uid even when it IS available; it's not a user-facing name.
return UNKNOWN_SENDER_LABEL; return UNKNOWN_SENDER_LABEL;
} }