Files
im_webapp/web/src/features/messages/MessageBubble.tsx
T
Your NameandClaude Sonnet 5 93d4077d1e fix(web): code review round 1 remaining UI fixes
- EmojiPanel: add missing 手势/物品 categories, 12-column grid per README §3.3
- DetailColumn: add 投诉举报 row, real ID/region/签名 via new getUserProfile
  API, chevron_right icons now use --color-icon-arrow token (were tertiary
  text color), regroup rows so dividers only sit between sections not every
  row (matches design-code reference exactly)
- MessageBubble/ConversationView: right-clicked message bubble now gets the
  spec's outline while its context menu is open
- ChatListColumn/AppShell: pinned 系统消息/文件助手 rows using confirmed
  bootstrap entries/entry_badges topic names ("sys"/"slf"); route to the
  existing SectionPlaceholder pattern since their own dedicated layouts are
  Phase 8, not built yet

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-09-08 03:58:06 +08:00

112 lines
4.1 KiB
TypeScript

import type { StoredMessage, Topic } from 'tinode-sdk';
import { Icon } from '@/app/Icon';
import { avatarBgClass, avatarLetter } from '@/lib/avatar';
import { isRecalled, isServerDeleted, systemNoticeText } 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';
interface MessageRowProps {
topic: Topic;
msg: StoredMessage;
mine: boolean;
isGroup: boolean;
peerName: string;
myUserId: string | null;
selectMode?: boolean;
selected?: boolean;
/** README §3.5: "选中气泡加 outline:2px solid #1677ff" -- true while this message is the open
* context menu's target. */
menuActive?: boolean;
onToggleSelect?: (seq: number) => void;
onContextMenu?: (e: React.MouseEvent, msg: StoredMessage) => void;
}
export function MessageRow({
topic,
msg,
mine,
isGroup,
peerName,
myUserId,
selectMode,
selected,
menuActive,
onToggleSelect,
onContextMenu,
}: MessageRowProps) {
if (isServerDeleted(msg)) {
return <div className="py-1 text-center text-xs text-text-tertiary-4">此消息已被删除</div>;
}
const noticeText = systemNoticeText(msg);
if (noticeText) {
return (
<div 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 retry = () => {
if (typeof msg.seq === 'number') retryMessage(topic, msg.seq);
};
return (
<div className="flex items-center gap-2">
{selectMode ? (
<button
type="button"
onClick={() => typeof msg.seq === 'number' && onToggleSelect?.(msg.seq)}
className={`grid h-5 w-5 flex-none place-items-center rounded-full text-[13px] text-white ${
selected ? 'bg-accent' : 'border-[1.5px] border-[#c5cad2]'
}`}
>
{selected ? <Icon name="check" className="text-[14px]" /> : null}
</button>
) : null}
<div
onContextMenu={(e) => {
if (selectMode) return;
e.preventDefault();
onContextMenu?.(e, msg);
}}
className={`flex flex-1 items-end gap-2.5 ${mine ? 'flex-row-reverse' : ''}`}
>
<span
className={`grid h-9 w-9 flex-none place-items-center rounded-lg text-sm font-medium text-white ${avatarBgClass(mine ? myUserId || '' : msg.from || peerName)}`}
>
{avatarLetter(mine ? '我' : peerName)}
</span>
<div className={`flex max-w-[60%] flex-col gap-1 ${mine ? 'items-end' : 'items-start'}`}>
{/* README §3.2: the failed-send icon sits beside the bubble, not below it with the retry text. */}
<div className={`flex items-end gap-1.5 ${mine ? 'flex-row-reverse' : ''}`}>
{failed ? <Icon name="error" className="flex-none text-[20px] text-danger-text" /> : 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) ? '⚠ 消息已删除' : renderDrafty(msg.content)}
</div>
</div>
{failed ? (
<button type="button" onClick={retry} className="text-xs text-danger-text">
发送失败,点击重试
</button>
) : (
<span className="text-[11px] text-text-tertiary-3">{sending ? '发送中…' : statusLabel}</span>
)}
</div>
</div>
</div>
);
}