From 74351af905ba3113d224a46ee1339c3f1df2e3ac Mon Sep 17 00:00:00 2001 From: Your Name Date: Mon, 21 Sep 2026 13:50:41 +0800 Subject: [PATCH] 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 --- .../features/messages/ConversationView.tsx | 47 +++++++++++++++++-- web/src/lib/messages/sender-name.ts | 16 +++++-- 2 files changed, 56 insertions(+), 7 deletions(-) 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; }