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