- 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>
112 lines
4.1 KiB
TypeScript
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>
|
|
);
|
|
}
|