From 425b06aa719210fb4db8e73d8a6e7dbd9de74c27 Mon Sep 17 00:00:00 2001 From: Your Name Date: Wed, 16 Sep 2026 23:37:31 +0800 Subject: [PATCH] =?UTF-8?q?=E6=B6=88=E6=81=AF=E5=88=97=E8=A1=A8=E5=92=8C?= =?UTF-8?q?=E8=81=8A=E5=A4=A9=E7=AA=97=E5=8F=A3=E6=8E=A5=E5=85=A5=E7=9C=9F?= =?UTF-8?q?=E5=AE=9E=E4=B8=8A=E4=BC=A0=E5=A4=B4=E5=83=8F=EF=BC=8C=E6=9B=BF?= =?UTF-8?q?=E6=8D=A2=E5=AD=97=E6=AF=8D=E8=89=B2=E5=9D=97=EF=BC=88=E8=81=94?= =?UTF-8?q?=E7=B3=BB=E4=BA=BA=E9=A1=B5=E6=AD=A4=E5=89=8D=E5=B7=B2=E6=8E=A5?= =?UTF-8?q?=E5=85=A5=EF=BC=89?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 用户报告:头像上传后,联系人页面已经能正确显示,但消息列表(会话列表)和 聊天对话框(顶部头像+消息气泡头像+详情栏)仍然只显示字母色块。 根因:联系人页面(ContactsListColumn/FriendProfileView等)走的是REST /friends、/users/{id}/profile这些接口,响应里天然带avatar_file_id字段, 接的是同一个Avatar组件。但消息列表/聊天窗口这条线读的是Tinode SDK本地维护的 话题缓存(me.contacts()/topic.public),这个对象里根本没有avatar_file_id 这个字段(那是REST专属字段,SDK的public.fn/public.photo走的是完全不同的同步 机制)——所以即使Avatar组件本身没问题,这几个位置也从来没被塞过真实的 avatar_file_id,只能落到字母兜底分支。 修复:在这几处渲染点旁路补一份轻量的id->avatar_file_id映射,全部复用已经在 发的请求(不新增网络开销): - ChatListColumn.tsx(会话列表):群聊复用自己已有的groups查询(本来就为了 显示"N人"徽标在发),单聊新增一个与ContactsListColumn同key的/friends查询 (对方是好友时才有数据,走查询缓存去重)。 - ConversationView.tsx(聊天窗口顶部+消息气泡):群聊复用自己已有的group profile查询;P2P peer新增一个与DetailColumn同key的/users/{id}/profile 查询;"我"发的消息复用(不再限定isSelf才拉)已有的/me查询。 - DetailColumn.tsx(P2P详情栏大头像):本来就在查/friends和/user-profile, 只是从来没把里面的avatar_file_id接到头像上。 - GroupDetailColumn.tsx(群详情栏大头像+成员格子头像,随上一条commit一起 提交):同样是本来就在查的group/members数据里现成的字段。 Avatar.tsx新增可选的fallbackBgClass prop——"我"发的消息气泡头像,字母兜底 色块设计上固定是强调色而不是按id哈希取色,原来内联写死bg-accent,现在通过 这个prop走统一组件不用另外复制一份渲染逻辑。 已知未覆盖(跟此前记录的已知缺口一致,本次未处理):群成员选择器/邀请弹层/ 群管理弹层等曝光度更低的头像位置仍是字母色块。 重要:本次修复只是把avatar_file_id正确传给了Avatar组件——实机测试时发现 GET /v0/file/s/{id}这个下载接口目前会302重定向到一个跨域地址, useAuthedBlobUrl用fetch+鉴权头去请求时,浏览器跟着重定向到那个跨域地址后 被CORS拦截(Failed to fetch,redirect:'manual'下确认type是opaqueredirect)。 用三个不同账号/不同file_id复测结果一致,不是个例。也就是说这条修复目前 "接线是对的",但只要这个后端问题不解决,任何地方(含联系人页、头像上传页 本身)新上传的头像实际都不会真的显示成图片,只会退回字母色块——这是后端 问题,按惯例记录不在这个仓库里修。 --- web/src/app/Avatar.tsx | 10 ++++-- web/src/features/messages/ChatListColumn.tsx | 18 +++++++--- .../features/messages/ConversationView.tsx | 34 ++++++++++++++----- web/src/features/messages/DetailColumn.tsx | 14 +++++--- web/src/features/messages/MessageBubble.tsx | 27 ++++++++------- 5 files changed, 71 insertions(+), 32 deletions(-) diff --git a/web/src/app/Avatar.tsx b/web/src/app/Avatar.tsx index a120f7a2..75ad4aa8 100644 --- a/web/src/app/Avatar.tsx +++ b/web/src/app/Avatar.tsx @@ -9,6 +9,10 @@ interface AvatarProps { /** Sizing/rounding/font-size only (e.g. "h-20 w-20 rounded-[20px] text-2xl") -- the fallback tile's * own color/centering/weight are applied internally, not expected from the caller. */ className?: string; + /** Overrides the hashed `avatarBgClass(id)` fallback color -- e.g. "bg-accent" for the current + * user's own message-row avatar, which by design is always the fixed accent color rather than a + * per-id hash like every other tile. No effect once avatarFileId resolves to a real image. */ + fallbackBgClass?: string; } /** @@ -20,12 +24,14 @@ interface AvatarProps { * `/v0/file/s/{id}` shape `useAuthedBlobUrl` (same authenticated-fetch-to-blob-URL mechanism already * used for chat attachments) expects. */ -export function Avatar({ id, name, avatarFileId, className = '' }: AvatarProps) { +export function Avatar({ id, name, avatarFileId, className = '', fallbackBgClass }: AvatarProps) { const remote = useAuthedBlobUrl(avatarFileId ? `/v0/file/s/${avatarFileId}` : undefined); if (remote.url) { return ; } return ( - {avatarLetter(name)} + + {avatarLetter(name)} + ); } diff --git a/web/src/features/messages/ChatListColumn.tsx b/web/src/features/messages/ChatListColumn.tsx index 241c2a26..89e15c63 100644 --- a/web/src/features/messages/ChatListColumn.tsx +++ b/web/src/features/messages/ChatListColumn.tsx @@ -3,13 +3,14 @@ import { useState } from 'react'; import { useQuery } from '@tanstack/react-query'; import { Link, useLocation, useNavigate, useParams } from 'react-router-dom'; +import { Avatar } from '@/app/Avatar'; import { Icon } from '@/app/Icon'; import { CreateGroupModal } from '@/features/groups/CreateGroupModal'; import { fetchAllPages } from '@/lib/api/client'; +import { getFriends } from '@/lib/api/friends'; import { getGroups } from '@/lib/api/groups'; import { useBootstrap } from '@/lib/api/hooks'; import { getMe } from '@/lib/api/profile'; -import { avatarBgClass, avatarLetter } from '@/lib/avatar'; import { formatMessagePreview } from '@/lib/messages/preview'; import { formatListTime } from '@/lib/messages/time'; import { useChatList, useTinode } from '@/lib/tinode/hooks'; @@ -67,6 +68,13 @@ export function ChatListColumn() { // regardless (docs/IM-前后端对接文档.md:1444), not one request per group row. const groupsQuery = useQuery({ queryKey: ['groups', 'all'], queryFn: () => fetchAllPages((cursor) => getGroups('', '', cursor)) }); const memberCountByTopic = new Map(groupsQuery.data?.map((g) => [g.topic, g.member_count])); + const avatarByGroupTopic = new Map(groupsQuery.data?.map((g) => [g.topic, g.avatar_file_id])); + // Same real gap as the conversation screen's header/message-row avatars (see ConversationView.tsx): + // a P2P row's `contact` object comes from the Tinode SDK's own local cache, which was never taught + // to carry the REST-only `avatar_file_id` this app uploads avatars through -- shares ContactsListColumn's + // exact `['friends']` query key, so this is a cache read whenever that page has already been visited. + const friendsQuery = useQuery({ queryKey: ['friends'], queryFn: () => fetchAllPages((cursor) => getFriends('', cursor)) }); + const avatarByUserId = new Map(friendsQuery.data?.map((f) => [f.user_id, f.avatar_file_id])); const [query, setQuery] = useState(''); const [creatingGroup, setCreatingGroup] = useState(false); const sorted = [...contacts].sort((a, b) => { @@ -143,6 +151,8 @@ export function ChatListColumn() { const isGroup = tinode.getTopic(contact.topic).isGroupType(); const memberCount = isGroup ? memberCountByTopic.get(contact.topic) : undefined; const muted = !!contact.private?.mute_notifications; + const avatarFileId = + contact.topic === 'slf' ? meQuery.data?.profile.avatar_file_id : isGroup ? avatarByGroupTopic.get(contact.topic) : avatarByUserId.get(contact.topic); return ( - - {avatarLetter(name)} + + {contact.online ? ( ) : null} diff --git a/web/src/features/messages/ConversationView.tsx b/web/src/features/messages/ConversationView.tsx index 596df97e..adf58de1 100644 --- a/web/src/features/messages/ConversationView.tsx +++ b/web/src/features/messages/ConversationView.tsx @@ -5,6 +5,7 @@ import { Link, useNavigate, useOutletContext, useParams } from 'react-router-dom import type { StoredMessage } from 'tinode-sdk'; import { Drafty } from 'tinode-sdk'; +import { Avatar } from '@/app/Avatar'; import { Icon } from '@/app/Icon'; import { Toast } from '@/app/Toast'; import { EmojiPanel } from '@/features/messages/EmojiPanel'; @@ -18,8 +19,8 @@ import { JoinRequestsModal } from '@/features/groups/JoinRequestsModal'; import { useBootstrap } from '@/lib/api/hooks'; import { favoriteMessages, getTopicDraft, type MediaType, putTopicDraft, recallMessage } from '@/lib/api/messages'; import { getAllGroupMembers, getAllGroupMutes, getGroup, getGroupGovernance, getGroupSummary } from '@/lib/api/groups'; +import { getUserProfile } from '@/lib/api/friends'; import { getMe } from '@/lib/api/profile'; -import { avatarBgClass, avatarLetter } from '@/lib/avatar'; import { FETCH_PAGE_TRIGGER, MESSAGES_PAGE } from '@/lib/config'; import { useClickOutside } from '@/lib/hooks'; import { availableActions, recallRemainingSeconds, type MessageAction } from '@/lib/messages/actions'; @@ -457,7 +458,22 @@ export function ConversationView() { // so `contact` below is always undefined for it -- shares AppShell's/SettingsLayout's `['me']` // query so this doesn't add a request beyond whichever of those mounts first. const isSelf = topicId === 'slf'; - const meQuery = useQuery({ queryKey: ['me'], queryFn: getMe, enabled: isSelf }); + // No longer gated to `enabled: isSelf` -- every conversation (not just chatting with yourself) + // needs your OWN avatar_file_id now, for the "mine" message-row avatar below. Shares the same + // `['me']` key NavRail/ChatListColumn/SettingsLayout already keep warm, so this is a cache read in + // the normal case, not a new request. + const meQuery = useQuery({ queryKey: ['me'], queryFn: getMe }); + // Real gap this was written to close: the chat header/message-row avatars only ever drew the + // letter-tile fallback, even after a real custom avatar was uploaded and correctly showing up on + // the contacts pages (FriendProfileView/ContactsListColumn) -- those read this exact same REST + // profile, the conversation screen just never fetched it. Same query DetailColumn already runs + // (`['user-profile', topicId]`), so this dedupes with it whenever the detail panel happens to be + // open, and only fetches for a real P2P peer, never for a group or `slf`. + const peerProfileQuery = useQuery({ + queryKey: ['user-profile', topicId], + queryFn: () => getUserProfile(topicId || ''), + enabled: !!topicId && !isGroup && !isSelf, + }); const membersQuery = useQuery({ queryKey: ['group-members', topicId], queryFn: () => getAllGroupMembers(topicId || ''), @@ -506,6 +522,9 @@ export function ConversationView() { const topic = tinode.getTopic(topicId); const peerName = isSelf ? meQuery.data?.profile.nickname || '我' : contact?.public?.fn || topicId; const groupMemberCount = groupProfileQuery.data?.member_count ?? membersQuery.data?.length ?? 0; + const myAvatarFileId = meQuery.data?.profile.avatar_file_id; + const peerAvatarFileId = isSelf ? myAvatarFileId : peerProfileQuery.data?.avatar_file_id; + const headerAvatarFileId = isGroup ? groupProfileQuery.data?.avatar_file_id : peerAvatarFileId; // `peerName` is the GROUP's own name for a group topic, not any member's -- reusing it to label // "who is being quoted" (as both the reply-composer preview and the actual sent quote block used // to) meant every single group-chat reply permanently displayed "回复 <群名>:…" for every member, @@ -805,11 +824,7 @@ export function ConversationView() {