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 ( {name} {content.public_account ? ID: {content.public_account} : null} ); } function MessageRoleTag({ role }: { role: 'owner' | 'admin' | undefined }) { if (role === 'owner') return 群主; if (role === 'admin') return 管理员; 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 (
此消息已被删除
); } const noticeText = systemNoticeText(msg); if (noticeText) { return (
{noticeText}
{msg.ts ?
{formatEventTimestamp(msg.ts)}
: null}
); } 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 (
{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) ? (
) : ( ) ) : null}
{ 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 ? ( ) : ( )}
{isGroup ? (
{!mine && !selectMode && msg.from ? ( {senderName || msg.from} ) : ( {mine ? '我' : senderName || msg.from} )}
) : 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. */}
{failed ? : null}
{isRecalled(msg) ? ( '⚠ 消息已删除' ) : callResult ? ( {callResult.text} ) : contactCard ? ( ) : ( renderDrafty(msg.content) )}
{failed ? ( ) : ( {sending ? '发送中…' : statusLabel} )}
); }