消息列表和聊天窗口接入真实上传头像,替换字母色块(联系人页此前已接入)

用户报告:头像上传后,联系人页面已经能正确显示,但消息列表(会话列表)和
聊天对话框(顶部头像+消息气泡头像+详情栏)仍然只显示字母色块。

根因:联系人页面(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复测结果一致,不是个例。也就是说这条修复目前
"接线是对的",但只要这个后端问题不解决,任何地方(含联系人页、头像上传页
本身)新上传的头像实际都不会真的显示成图片,只会退回字母色块——这是后端
问题,按惯例记录不在这个仓库里修。
This commit is contained in:
Your Name
2026-09-16 23:37:31 +08:00
parent 804618ad63
commit 425b06aa71
5 changed files with 71 additions and 32 deletions
+8 -2
View File
@@ -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 /** 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. */ * own color/centering/weight are applied internally, not expected from the caller. */
className?: string; 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 * `/v0/file/s/{id}` shape `useAuthedBlobUrl` (same authenticated-fetch-to-blob-URL mechanism already
* used for chat attachments) expects. * 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); const remote = useAuthedBlobUrl(avatarFileId ? `/v0/file/s/${avatarFileId}` : undefined);
if (remote.url) { if (remote.url) {
return <img src={remote.url} alt="" className={`object-cover ${className}`} />; return <img src={remote.url} alt="" className={`object-cover ${className}`} />;
} }
return ( return (
<span className={`grid place-items-center font-medium text-white ${avatarBgClass(id)} ${className}`}>{avatarLetter(name)}</span> <span className={`grid place-items-center font-medium text-white ${fallbackBgClass || avatarBgClass(id)} ${className}`}>
{avatarLetter(name)}
</span>
); );
} }
+13 -5
View File
@@ -3,13 +3,14 @@ import { useState } from 'react';
import { useQuery } from '@tanstack/react-query'; import { useQuery } from '@tanstack/react-query';
import { Link, useLocation, useNavigate, useParams } from 'react-router-dom'; import { Link, useLocation, useNavigate, useParams } from 'react-router-dom';
import { Avatar } from '@/app/Avatar';
import { Icon } from '@/app/Icon'; import { Icon } from '@/app/Icon';
import { CreateGroupModal } from '@/features/groups/CreateGroupModal'; import { CreateGroupModal } from '@/features/groups/CreateGroupModal';
import { fetchAllPages } from '@/lib/api/client'; import { fetchAllPages } from '@/lib/api/client';
import { getFriends } from '@/lib/api/friends';
import { getGroups } from '@/lib/api/groups'; import { getGroups } from '@/lib/api/groups';
import { useBootstrap } from '@/lib/api/hooks'; import { useBootstrap } from '@/lib/api/hooks';
import { getMe } from '@/lib/api/profile'; import { getMe } from '@/lib/api/profile';
import { avatarBgClass, avatarLetter } from '@/lib/avatar';
import { formatMessagePreview } from '@/lib/messages/preview'; import { formatMessagePreview } from '@/lib/messages/preview';
import { formatListTime } from '@/lib/messages/time'; import { formatListTime } from '@/lib/messages/time';
import { useChatList, useTinode } from '@/lib/tinode/hooks'; 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. // regardless (docs/IM-前后端对接文档.md:1444), not one request per group row.
const groupsQuery = useQuery({ queryKey: ['groups', 'all'], queryFn: () => fetchAllPages((cursor) => getGroups('', '', cursor)) }); 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 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 [query, setQuery] = useState('');
const [creatingGroup, setCreatingGroup] = useState(false); const [creatingGroup, setCreatingGroup] = useState(false);
const sorted = [...contacts].sort((a, b) => { const sorted = [...contacts].sort((a, b) => {
@@ -143,6 +151,8 @@ export function ChatListColumn() {
const isGroup = tinode.getTopic(contact.topic).isGroupType(); const isGroup = tinode.getTopic(contact.topic).isGroupType();
const memberCount = isGroup ? memberCountByTopic.get(contact.topic) : undefined; const memberCount = isGroup ? memberCountByTopic.get(contact.topic) : undefined;
const muted = !!contact.private?.mute_notifications; 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 ( return (
<Link <Link
key={contact.topic} key={contact.topic}
@@ -151,10 +161,8 @@ export function ChatListColumn() {
active ? 'bg-accent-selected-bg' : 'bg-white hover:bg-[#f6f8fb]' active ? 'bg-accent-selected-bg' : 'bg-white hover:bg-[#f6f8fb]'
}`} }`}
> >
<span <span className="relative block h-11 w-11 flex-none">
className={`relative grid h-11 w-11 place-items-center rounded-lg text-base font-medium text-white ${avatarBgClass(contact.topic || '')}`} <Avatar id={contact.topic || ''} name={name} avatarFileId={avatarFileId} className="h-11 w-11 rounded-lg text-base" />
>
{avatarLetter(name)}
{contact.online ? ( {contact.online ? (
<span className="absolute -bottom-0.5 -right-0.5 h-2.5 w-2.5 rounded-full border-2 border-white bg-online" /> <span className="absolute -bottom-0.5 -right-0.5 h-2.5 w-2.5 rounded-full border-2 border-white bg-online" />
) : null} ) : null}
+26 -8
View File
@@ -5,6 +5,7 @@ import { Link, useNavigate, useOutletContext, useParams } from 'react-router-dom
import type { StoredMessage } from 'tinode-sdk'; import type { StoredMessage } from 'tinode-sdk';
import { Drafty } from 'tinode-sdk'; import { Drafty } from 'tinode-sdk';
import { Avatar } from '@/app/Avatar';
import { Icon } from '@/app/Icon'; import { Icon } from '@/app/Icon';
import { Toast } from '@/app/Toast'; import { Toast } from '@/app/Toast';
import { EmojiPanel } from '@/features/messages/EmojiPanel'; import { EmojiPanel } from '@/features/messages/EmojiPanel';
@@ -18,8 +19,8 @@ import { JoinRequestsModal } from '@/features/groups/JoinRequestsModal';
import { useBootstrap } from '@/lib/api/hooks'; import { useBootstrap } from '@/lib/api/hooks';
import { favoriteMessages, getTopicDraft, type MediaType, putTopicDraft, recallMessage } from '@/lib/api/messages'; import { favoriteMessages, getTopicDraft, type MediaType, putTopicDraft, recallMessage } from '@/lib/api/messages';
import { getAllGroupMembers, getAllGroupMutes, getGroup, getGroupGovernance, getGroupSummary } from '@/lib/api/groups'; import { getAllGroupMembers, getAllGroupMutes, getGroup, getGroupGovernance, getGroupSummary } from '@/lib/api/groups';
import { getUserProfile } from '@/lib/api/friends';
import { getMe } from '@/lib/api/profile'; import { getMe } from '@/lib/api/profile';
import { avatarBgClass, avatarLetter } from '@/lib/avatar';
import { FETCH_PAGE_TRIGGER, MESSAGES_PAGE } from '@/lib/config'; import { FETCH_PAGE_TRIGGER, MESSAGES_PAGE } from '@/lib/config';
import { useClickOutside } from '@/lib/hooks'; import { useClickOutside } from '@/lib/hooks';
import { availableActions, recallRemainingSeconds, type MessageAction } from '@/lib/messages/actions'; 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']` // 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. // query so this doesn't add a request beyond whichever of those mounts first.
const isSelf = topicId === 'slf'; 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({ const membersQuery = useQuery({
queryKey: ['group-members', topicId], queryKey: ['group-members', topicId],
queryFn: () => getAllGroupMembers(topicId || ''), queryFn: () => getAllGroupMembers(topicId || ''),
@@ -506,6 +522,9 @@ export function ConversationView() {
const topic = tinode.getTopic(topicId); const topic = tinode.getTopic(topicId);
const peerName = isSelf ? meQuery.data?.profile.nickname || '我' : contact?.public?.fn || topicId; const peerName = isSelf ? meQuery.data?.profile.nickname || '我' : contact?.public?.fn || topicId;
const groupMemberCount = groupProfileQuery.data?.member_count ?? membersQuery.data?.length ?? 0; 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 // `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 // "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, // to) meant every single group-chat reply permanently displayed "回复 <群名>:…" for every member,
@@ -805,11 +824,7 @@ export function ConversationView() {
<Icon name="arrow_back_ios_new" /> <Icon name="arrow_back_ios_new" />
</Link> </Link>
<button type="button" onClick={onToggleDetail} className="flex items-center gap-2.5 text-left"> <button type="button" onClick={onToggleDetail} className="flex items-center gap-2.5 text-left">
<span <Avatar id={topicId} name={peerName} avatarFileId={headerAvatarFileId} className="h-9.5 w-9.5 flex-none rounded-lg text-sm" />
className={`grid h-9.5 w-9.5 flex-none place-items-center rounded-lg text-sm font-medium text-white ${avatarBgClass(topicId)}`}
>
{avatarLetter(peerName)}
</span>
<div className="flex flex-col"> <div className="flex flex-col">
<strong className="text-base font-semibold text-text-heading"> <strong className="text-base font-semibold text-text-heading">
{peerName} {peerName}
@@ -893,16 +908,19 @@ export function ConversationView() {
{loadingOlder ? <div className="py-2 text-center text-xs text-text-tertiary-4">加载中…</div> : null} {loadingOlder ? <div className="py-2 text-center text-xs text-text-tertiary-4">加载中…</div> : null}
{messages.map((msg) => { {messages.map((msg) => {
const sender = isGroup && msg.from ? memberByUserId.get(msg.from) : undefined; const sender = isGroup && msg.from ? memberByUserId.get(msg.from) : undefined;
const mine = !!myUserId && msg.from === myUserId;
const rowAvatarFileId = mine ? myAvatarFileId : isGroup ? sender?.avatar_file_id : peerAvatarFileId;
return ( return (
<MessageRow <MessageRow
key={msg.seq} key={msg.seq}
topic={topic} topic={topic}
msg={msg} msg={msg}
mine={!!myUserId && msg.from === myUserId} mine={mine}
isGroup={isGroup} isGroup={isGroup}
peerName={peerName} peerName={peerName}
senderName={sender?.display_name} senderName={sender?.display_name}
senderRole={sender?.role} senderRole={sender?.role}
avatarFileId={rowAvatarFileId}
selectMode={selectMode} selectMode={selectMode}
selected={typeof msg.seq === 'number' && selectedSeqs.has(msg.seq)} selected={typeof msg.seq === 'number' && selectedSeqs.has(msg.seq)}
menuActive={typeof msg.seq === 'number' && menu?.msg.seq === msg.seq} menuActive={typeof msg.seq === 'number' && menu?.msg.seq === msg.seq}
+10 -4
View File
@@ -3,6 +3,7 @@ import { useLayoutEffect, useState } from 'react';
import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query'; import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query';
import { useNavigate } from 'react-router-dom'; import { useNavigate } from 'react-router-dom';
import { Avatar } from '@/app/Avatar';
import { Icon } from '@/app/Icon'; import { Icon } from '@/app/Icon';
import { Toast } from '@/app/Toast'; import { Toast } from '@/app/Toast';
import { fetchAllPages } from '@/lib/api/client'; import { fetchAllPages } from '@/lib/api/client';
@@ -18,7 +19,7 @@ import {
updateFriendRemark, updateFriendRemark,
} from '@/lib/api/friends'; } from '@/lib/api/friends';
import type { MediaType } from '@/lib/api/messages'; import type { MediaType } from '@/lib/api/messages';
import { avatarBgClass, avatarLetter } from '@/lib/avatar'; import { getMe } from '@/lib/api/profile';
import { useTinode } from '@/lib/tinode/hooks'; import { useTinode } from '@/lib/tinode/hooks';
import { useCallStore } from '@/stores/calls'; import { useCallStore } from '@/stores/calls';
import { requestConfirm } from '@/stores/confirmDialog'; import { requestConfirm } from '@/stores/confirmDialog';
@@ -88,6 +89,13 @@ export function DetailColumn({ topicId, peerName, onClose, onOpenHistory }: Deta
const idLine = [profileQuery.data?.public_account && `ID:${profileQuery.data.public_account}`, profileQuery.data?.region] const idLine = [profileQuery.data?.public_account && `ID:${profileQuery.data.public_account}`, profileQuery.data?.region]
.filter(Boolean) .filter(Boolean)
.join(' · '); .join(' · ');
// Same real gap as ChatListColumn/ConversationView's avatars -- this panel's header tile only ever
// drew the letter fallback, even though `profileQuery`/`friendsQuery` right above already carry a
// real `avatar_file_id` (that's how ContactsListColumn/FriendProfileView show it correctly). `friend`
// (the `/friends` list, same source the contacts pages already trust) takes priority since it's the
// one confirmed accurate; `profileQuery` covers a P2P peer who isn't (or is no longer) a friend.
const meQuery = useQuery({ queryKey: ['me'], queryFn: getMe, enabled: isSelf });
const avatarFileId = isSelf ? meQuery.data?.profile.avatar_file_id : (friend?.avatar_file_id ?? profileQuery.data?.avatar_file_id);
const settingsQuery = useQuery({ const settingsQuery = useQuery({
queryKey: ['conversation-settings', topicId], queryKey: ['conversation-settings', topicId],
@@ -154,9 +162,7 @@ export function DetailColumn({ topicId, peerName, onClose, onOpenHistory }: Deta
</header> </header>
<div className="flex flex-col items-center gap-1 border-b border-border-list-item px-5 pb-4 pt-6"> <div className="flex flex-col items-center gap-1 border-b border-border-list-item px-5 pb-4 pt-6">
<span className={`grid h-18 w-18 place-items-center rounded-2xl text-2xl font-medium text-white ${avatarBgClass(topicId)}`}> <Avatar id={topicId} name={peerName} avatarFileId={avatarFileId} className="h-18 w-18 rounded-2xl text-2xl" />
{avatarLetter(peerName)}
</span>
<strong className="mt-2 text-[17px] font-semibold text-text-heading">{peerName}</strong> <strong className="mt-2 text-[17px] font-semibold text-text-heading">{peerName}</strong>
{idLine ? <span className="text-xs text-text-tertiary-2">{idLine}</span> : null} {idLine ? <span className="text-xs text-text-tertiary-2">{idLine}</span> : null}
{profileQuery.data?.signature ? ( {profileQuery.data?.signature ? (
+14 -13
View File
@@ -5,7 +5,6 @@ import { Link } from 'react-router-dom';
import { Avatar } from '@/app/Avatar'; import { Avatar } from '@/app/Avatar';
import { Icon } from '@/app/Icon'; import { Icon } from '@/app/Icon';
import { SelectionCheckbox } from '@/app/SelectionCheckbox'; import { SelectionCheckbox } from '@/app/SelectionCheckbox';
import { avatarBgClass, avatarLetter } from '@/lib/avatar';
import { callResultDisplay, contactCardContentOf, isRecalled, isServerDeleted, systemNoticeText, type ContactCardContent } from '@/lib/messages/dispatch'; import { callResultDisplay, contactCardContentOf, isRecalled, isServerDeleted, systemNoticeText, type ContactCardContent } from '@/lib/messages/dispatch';
import { renderDrafty } from '@/lib/messages/drafty'; import { renderDrafty } from '@/lib/messages/drafty';
import { formatBubbleTime, formatEventTimestamp } from '@/lib/messages/time'; import { formatBubbleTime, formatEventTimestamp } from '@/lib/messages/time';
@@ -49,6 +48,10 @@ interface MessageRowProps {
* for per-message attribution once there's more than one possible sender. */ * for per-message attribution once there's more than one possible sender. */
senderName?: string; senderName?: string;
senderRole?: 'owner' | 'admin' | 'member'; senderRole?: 'owner' | 'admin' | 'member';
/** The avatar to show for whoever sent THIS row -- resolved by ConversationView per-message (its
* own `avatar_file_id`, the group member's, or the peer's, depending on `mine`/`isGroup`), not a
* single fixed prop like `peerName`, since every row in a group can have a different sender. */
avatarFileId?: string;
selectMode?: boolean; selectMode?: boolean;
selected?: boolean; selected?: boolean;
/** README §3.5: "选中气泡加 outline:2px solid #1677ff" -- true while this message is the open /** README §3.5: "选中气泡加 outline:2px solid #1677ff" -- true while this message is the open
@@ -75,6 +78,7 @@ export function MessageRow({
peerName, peerName,
senderName, senderName,
senderRole, senderRole,
avatarFileId,
selectMode, selectMode,
selected, selected,
menuActive, menuActive,
@@ -149,20 +153,17 @@ export function MessageRow({
either -- the checkbox is the only thing a tap in this row should do while bulk-selecting, either -- the checkbox is the only thing a tap in this row should do while bulk-selecting,
not navigate away and lose whatever's already been picked. */} not navigate away and lose whatever's already been picked. */}
{isGroup && !mine && !selectMode && msg.from ? ( {isGroup && !mine && !selectMode && msg.from ? (
<Link <Link to={`/contacts/${msg.from}`} className="flex-none no-underline">
to={`/contacts/${msg.from}`} <Avatar id={msg.from} name={senderName || msg.from} avatarFileId={avatarFileId} className="h-9 w-9 rounded-lg text-sm" />
className={`grid h-9 w-9 flex-none place-items-center rounded-lg text-sm font-medium text-white no-underline ${avatarBgClass(msg.from)}`}
>
{avatarLetter(senderName || msg.from)}
</Link> </Link>
) : ( ) : (
<span <Avatar
className={`grid h-9 w-9 flex-none place-items-center rounded-lg text-sm font-medium text-white ${ id={msg.from || (isGroup ? '' : peerName)}
mine ? 'bg-accent' : avatarBgClass(msg.from || (isGroup ? '' : peerName)) name={mine ? '我' : isGroup ? senderName || msg.from : peerName}
}`} avatarFileId={avatarFileId}
> fallbackBgClass={mine ? 'bg-accent' : undefined}
{avatarLetter(mine ? '我' : isGroup ? senderName || msg.from : peerName)} className="h-9 w-9 flex-none rounded-lg text-sm"
</span> />
)} )}
<div className={`flex max-w-[60%] flex-col gap-1 ${mine ? 'items-end' : 'items-start'}`}> <div className={`flex max-w-[60%] flex-col gap-1 ${mine ? 'items-end' : 'items-start'}`}>
{isGroup ? ( {isGroup ? (