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:
co-authored by
Claude Sonnet 5
parent
ca7af8baf9
commit
74351af905
@@ -1,6 +1,6 @@
|
||||
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 type { StoredMessage } from 'tinode-sdk';
|
||||
import { Drafty } from 'tinode-sdk';
|
||||
@@ -542,6 +542,38 @@ export function ConversationView() {
|
||||
enabled: isGroup,
|
||||
});
|
||||
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 isGroupAdmin = myMember?.role === 'owner' || myMember?.role === 'admin';
|
||||
const groupSublineParts = [
|
||||
@@ -569,7 +601,9 @@ export function ConversationView() {
|
||||
// second, divergent resolution path.
|
||||
const quotedSenderLabel = (fromUserId: string | undefined) => {
|
||||
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;
|
||||
};
|
||||
|
||||
@@ -944,8 +978,13 @@ export function ConversationView() {
|
||||
{loadingOlder ? <div className="py-2 text-center text-xs text-text-tertiary-4">加载中…</div> : null}
|
||||
{messages.map((msg) => {
|
||||
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 rowAvatarFileId = mine ? myAvatarFileId : isGroup ? sender?.avatar_file_id : peerAvatarFileId;
|
||||
const rowAvatarFileId = mine
|
||||
? myAvatarFileId
|
||||
: isGroup
|
||||
? sender?.avatar_file_id || staleSender?.avatar_file_id
|
||||
: peerAvatarFileId;
|
||||
return (
|
||||
<MessageRow
|
||||
key={msg.seq}
|
||||
@@ -954,7 +993,7 @@ export function ConversationView() {
|
||||
mine={mine}
|
||||
isGroup={isGroup}
|
||||
peerName={peerName}
|
||||
senderName={sender?.display_name}
|
||||
senderName={sender?.display_name || staleSender?.display_name}
|
||||
senderRole={sender?.role}
|
||||
avatarFileId={rowAvatarFileId}
|
||||
selectMode={selectMode}
|
||||
|
||||
@@ -14,9 +14,19 @@ 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;
|
||||
/**
|
||||
* 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;
|
||||
}
|
||||
// No usable name -- never show the raw uid even when it IS available; it's not a user-facing name.
|
||||
return UNKNOWN_SENDER_LABEL;
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user