diff --git a/web/src/features/messages/ConversationView.tsx b/web/src/features/messages/ConversationView.tsx index 75df60f5..17babf83 100644 --- a/web/src/features/messages/ConversationView.tsx +++ b/web/src/features/messages/ConversationView.tsx @@ -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(); + 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(); + 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 ?
加载中…
: 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 ( ): 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; }