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() {