Files
im_webapp/web/src/features/messages/MessageBubble.tsx
T
Your Name 425b06aa71 消息列表和聊天窗口接入真实上传头像,替换字母色块(联系人页此前已接入)
用户报告:头像上传后,联系人页面已经能正确显示,但消息列表(会话列表)和
聊天对话框(顶部头像+消息气泡头像+详情栏)仍然只显示字母色块。

根因:联系人页面(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复测结果一致,不是个例。也就是说这条修复目前
"接线是对的",但只要这个后端问题不解决,任何地方(含联系人页、头像上传页
本身)新上传的头像实际都不会真的显示成图片,只会退回字母色块——这是后端
问题,按惯例记录不在这个仓库里修。
2026-09-16 23:37:31 +08:00

219 lines
11 KiB
TypeScript

import type { StoredMessage, Topic } from 'tinode-sdk';
import { Link } from 'react-router-dom';
import { Avatar } from '@/app/Avatar';
import { Icon } from '@/app/Icon';
import { SelectionCheckbox } from '@/app/SelectionCheckbox';
import { callResultDisplay, contactCardContentOf, isRecalled, isServerDeleted, systemNoticeText, type ContactCardContent } from '@/lib/messages/dispatch';
import { renderDrafty } from '@/lib/messages/drafty';
import { formatBubbleTime, formatEventTimestamp } from '@/lib/messages/time';
import { isFailedStatus, isSendingStatus, outgoingStatusLabel } from '@/lib/messages/status';
import { retryMessage } from '@/lib/tinode/send';
import { ensureTopicAttached } from '@/lib/tinode/topics';
/** 设计稿 4.3:气泡上方角色标签,边框样式(不是填色)-- 群主 #ff951c、管理员 #1677ff,均不加背景。
* 与 GroupMembersModal/GroupDetailColumn 里另外两种角色标签(填色 tag、圆角数字角标)故意不共用
* 一个组件:三处的视觉规格互不相同,抽象成一个带 variant 参数的组件不会比各写各的更清楚。 */
/** Distinct from a regular Drafty-rendered bubble -- see `contactCardContentOf`'s own doc comment
* for why this is dispatched by `head['x-im-type']` rather than a Drafty style tag. Tapping opens
* the real profile page, the one thing a contact-card message is fundamentally for. */
function ContactCardBubble({ content }: { content: ContactCardContent }) {
const name = content.display_name || content.public_account || content.user_id;
return (
<Link to={`/contacts/${content.user_id}`} className="flex w-[220px] items-center gap-3 no-underline">
<Avatar id={content.user_id} name={name} avatarFileId={content.avatar_file_id} className="h-11 w-11 flex-none rounded-lg text-base" />
<span className="flex min-w-0 flex-col">
<span className="truncate text-sm font-medium text-text-body">{name}</span>
{content.public_account ? <span className="truncate text-xs text-text-tertiary-2">ID: {content.public_account}</span> : null}
</span>
</Link>
);
}
function MessageRoleTag({ role }: { role: 'owner' | 'admin' | undefined }) {
if (role === 'owner') return <span className="rounded-[3px] border border-warn px-1 text-[10px] leading-[14px] text-warn">群主</span>;
if (role === 'admin') return <span className="rounded-[3px] border border-accent px-1 text-[10px] leading-[14px] text-accent">管理员</span>;
return null;
}
interface MessageRowProps {
topic: Topic;
msg: StoredMessage;
mine: boolean;
isGroup: boolean;
peerName: string;
/** Only meaningful when `isGroup` -- the sender's real group-scoped name/role, resolved by
* ConversationView from its own member-list fetch. `peerName` alone is the GROUP's name, wrong
* for per-message attribution once there's more than one possible sender. */
senderName?: string;
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;
selected?: boolean;
/** README §3.5: "选中气泡加 outline:2px solid #1677ff" -- true while this message is the open
* context menu's target. */
menuActive?: boolean;
/** True for a few seconds right after jumping here from a 查找聊天记录 search result (ConversationView's
* scroll-to-message flow) -- a brief background flash, distinct from `menuActive`'s permanent-while-
* open outline, since the two states mean different things (context menu open vs. "you asked to find
* this one") and can't reuse the same visual without conflating them. */
highlighted?: boolean;
onToggleSelect?: (seq: number) => void;
onContextMenu?: (e: React.MouseEvent, msg: StoredMessage) => void;
/** Fired when the inline "重试" retry (below) itself fails -- e.g. still offline. Without this the
* failed-send icon just silently stays put with no feedback at all that the retry attempt was
* even made. */
onError?: (message: string) => void;
}
export function MessageRow({
topic,
msg,
mine,
isGroup,
peerName,
senderName,
senderRole,
avatarFileId,
selectMode,
selected,
menuActive,
highlighted,
onToggleSelect,
onContextMenu,
onError,
}: MessageRowProps) {
if (isServerDeleted(msg)) {
return (
<div data-seq={msg.seq} className="py-1 text-center text-xs text-text-tertiary-4">
此消息已被删除
</div>
);
}
const noticeText = systemNoticeText(msg);
if (noticeText) {
return (
<div data-seq={msg.seq} className="flex flex-col items-center gap-1 py-1">
<div className="text-xs text-text-tertiary-4">{noticeText}</div>
{msg.ts ? <div className="text-xs text-text-tertiary-4">{formatEventTimestamp(msg.ts)}</div> : null}
</div>
);
}
const failed = isFailedStatus(topic, msg);
const sending = isSendingStatus(topic, msg);
const statusLabel = mine ? outgoingStatusLabel(topic, msg, isGroup) : msg.ts ? formatBubbleTime(msg.ts) : '';
const callResult = callResultDisplay(msg, mine);
const contactCard = contactCardContentOf(msg);
const retry = () => {
if (typeof msg.seq !== 'number') return;
const seq = msg.seq;
// The original send most likely failed BECAUSE the topic was detached (see ConversationView's
// submit() for the same reasoning) -- retrying without re-attaching first would just fail the
// exact same way again.
ensureTopicAttached(topic.name)
.then(() => retryMessage(topic, seq))
.catch(() => onError?.('重试发送失败'));
};
return (
<div data-seq={msg.seq} className={`flex items-center gap-2 ${highlighted ? 'animate-[jump-highlight_2s_ease-out]' : ''}`}>
{selectMode ? (
// A recalled message (content nulled out, rendered as the "⚠ 消息已删除" placeholder below)
// has nothing real to forward/favorite -- an empty flex-none spacer of the same size keeps
// every row's content left-edge aligned instead of the checkbox's absence shifting this one
// row over, while still refusing to let it join a batch action.
isRecalled(msg) ? (
<div className="h-5 w-5 flex-none" />
) : (
<button type="button" onClick={() => typeof msg.seq === 'number' && onToggleSelect?.(msg.seq)} className="flex-none">
<SelectionCheckbox selected={selected} />
</button>
)
) : null}
<div
onContextMenu={(e) => {
if (selectMode) return;
e.preventDefault();
onContextMenu?.(e, msg);
}}
className={`flex flex-1 items-start gap-2.5 ${mine ? 'flex-row-reverse' : ''}`}
>
{/* Group members' avatar/name go to their profile (same destination ContactCardBubble's own
card already uses, which works for a non-friend group member too -- FriendProfileView's
relationship-aware buttons handle that case, it isn't friends-only). Not for `mine` -- that
slot always reads "我", and viewing "your own profile" via a route built around a two-party
relationship isn't a case this app has ever needed to support. Not during `selectMode`
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. */}
{isGroup && !mine && !selectMode && msg.from ? (
<Link to={`/contacts/${msg.from}`} className="flex-none no-underline">
<Avatar id={msg.from} name={senderName || msg.from} avatarFileId={avatarFileId} className="h-9 w-9 rounded-lg text-sm" />
</Link>
) : (
<Avatar
id={msg.from || (isGroup ? '' : peerName)}
name={mine ? '我' : isGroup ? senderName || msg.from : peerName}
avatarFileId={avatarFileId}
fallbackBgClass={mine ? 'bg-accent' : undefined}
className="h-9 w-9 flex-none rounded-lg text-sm"
/>
)}
<div className={`flex max-w-[60%] flex-col gap-1 ${mine ? 'items-end' : 'items-start'}`}>
{isGroup ? (
<div className={`flex items-center gap-1 px-0.5 text-xs text-text-tertiary-4 ${mine ? 'flex-row-reverse' : ''}`}>
{!mine && !selectMode && msg.from ? (
<Link to={`/contacts/${msg.from}`} className="text-text-tertiary-4 no-underline hover:underline">
{senderName || msg.from}
</Link>
) : (
<span>{mine ? '我' : senderName || msg.from}</span>
)}
<MessageRoleTag role={senderRole === 'owner' || senderRole === 'admin' ? senderRole : undefined} />
</div>
) : null}
{/* README §3.2: the failed-send icon sits beside the bubble, not below it with the retry text.
items-center (not -end) so the icon stays vertically centered on the bubble regardless of
how many lines the bubble wraps to; no flex-row-reverse here (unlike the outer avatar row)
-- this icon only ever appears on `mine` bubbles and belongs on the OUTER edge, away from
the avatar, which is what the DOM order [icon, bubble] already gives without reversing it. */}
<div className="flex items-center gap-1.5">
{failed ? <Icon name="error" className="flex-none text-[20px] text-unread" /> : null}
<div
className={`px-3.5 py-2.5 text-[15px] leading-[1.55] break-words ${
mine ? 'rounded-[12px_4px_12px_12px] bg-bubble-mine' : 'rounded-[4px_12px_12px_12px] bg-white shadow-bubble'
} ${isRecalled(msg) ? 'text-text-tertiary-4 italic' : ''} ${menuActive ? 'outline outline-2 outline-offset-2 outline-accent' : ''}`}
>
{isRecalled(msg) ? (
'⚠ 消息已删除'
) : callResult ? (
<span className="flex items-center gap-1.5">
<Icon name={callResult.icon} className={`text-[18px] ${callResult.danger ? 'text-danger-text' : 'text-current'}`} />
{callResult.text}
</span>
) : contactCard ? (
<ContactCardBubble content={contactCard} />
) : (
renderDrafty(msg.content)
)}
</div>
</div>
{failed ? (
<button type="button" onClick={retry} className="text-xs text-unread">
发送失败,点击重试
</button>
) : (
<span className="text-[11px] text-text-tertiary-3">{sending ? '发送中…' : statusLabel}</span>
)}
</div>
</div>
</div>
);
}