- 多选模式下Drafty链接、名片消息不再意外触发跳转/开新标签页 - 语音/视频消息在多选模式下点击可正常冒泡选中该行 - 转发排除未确认/发送失败消息,识别uncertain转发结果并单独提示 - 群文件"更多操作"菜单竞态、群成员移出状态竞态 - 收藏页链接卡片new URL()崩溃点加防御,并给登录后应用区域加错误边界 - 个人资料页乐观锁版本冲突时清空草稿避免静默覆盖并发修改 Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
284 lines
14 KiB
TypeScript
284 lines
14 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,
|
|
mobileAttachmentContentOf,
|
|
systemNoticeText,
|
|
type ContactCardContent,
|
|
} from '@/lib/messages/dispatch';
|
|
import { renderDrafty } from '@/lib/messages/drafty';
|
|
import { AudioAttachment, FileAttachment, ImageAttachment, VideoAttachment } from '@/features/messages/AttachmentPreview';
|
|
import { QuoteBar } from '@/features/messages/QuoteBar';
|
|
import { parseReplyHead } from '@/lib/messages/reply';
|
|
import { formatBubbleTime, formatEventTimestamp } from '@/lib/messages/time';
|
|
import { isFailedStatus, isSendingStatus, outgoingStatusLabel } from '@/lib/messages/status';
|
|
import { groupSenderLabel } from '@/lib/messages/sender-name';
|
|
import { retryMessage } from '@/lib/tinode/send';
|
|
import { ensureTopicAttached } from '@/lib/tinode/topics';
|
|
import { openUserCard } from '@/stores/userCard';
|
|
|
|
/** 设计稿 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, locked }: { content: ContactCardContent; locked?: boolean }) {
|
|
const name = content.display_name || content.public_account || content.user_id;
|
|
const inner = (
|
|
<>
|
|
<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>
|
|
</>
|
|
);
|
|
// `locked` (selectMode): same reasoning as the avatar/@mention buttons elsewhere in this file --
|
|
// a tap must only toggle the row, not navigate away and lose whatever's already been picked.
|
|
if (locked) {
|
|
return <div className="flex w-[220px] items-center gap-3">{inner}</div>;
|
|
}
|
|
return (
|
|
<Link to={`/contacts/${content.user_id}`} className="flex w-[220px] items-center gap-3 no-underline">
|
|
{inner}
|
|
</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;
|
|
/** Resolves a QUOTED message's sender uid (from head["x-im-reply"].from) to a display label.
|
|
* Owned by ConversationView so group remark/nickname resolution stays in one place; QuoteBar
|
|
* must never print a raw uid. */
|
|
resolveQuotedName?: (uid: string | undefined) => string;
|
|
/** 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,
|
|
resolveQuotedName,
|
|
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 mobileAttachment = mobileAttachmentContentOf(msg);
|
|
// Quoted-reply snapshot (x-im-reply). Not rendered for a recalled message -- its whole bubble
|
|
// collapses to the "消息已删除" placeholder, quote bar included.
|
|
const replyInfo = isRecalled(msg) ? null : parseReplyHead(msg.head);
|
|
|
|
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?.('重试发送失败'));
|
|
};
|
|
|
|
// In selectMode, the WHOLE row toggles selection (not just the checkbox) -- confirmed by the user
|
|
// that tapping only the small checkbox target is the wrong UX for a bulk-select list. A recalled
|
|
// message renders a spacer instead of a checkbox (see below) and must stay unselectable, so this
|
|
// guards the same way.
|
|
const rowSelectable = selectMode && !isRecalled(msg) && typeof msg.seq === 'number';
|
|
const toggleThisRow = () => rowSelectable && onToggleSelect?.(msg.seq as number);
|
|
|
|
return (
|
|
<div
|
|
data-seq={msg.seq}
|
|
onClick={toggleThisRow}
|
|
className={`flex items-center gap-2 ${rowSelectable ? 'cursor-pointer' : ''} ${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" />
|
|
) : (
|
|
// No onClick of its own -- the row-level handler above already toggles it, and a second
|
|
// handler here would double-toggle on every click (select then immediately un-select).
|
|
<div className="flex-none">
|
|
<SelectionCheckbox selected={selected} />
|
|
</div>
|
|
)
|
|
) : 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. */}
|
|
{!mine && !selectMode && msg.from ? (
|
|
<button type="button" onClick={() => openUserCard(msg.from!)} className="flex-none">
|
|
<Avatar id={msg.from} name={isGroup ? groupSenderLabel(senderName) : peerName} avatarFileId={avatarFileId} className="h-9 w-9 rounded-lg text-sm" />
|
|
</button>
|
|
) : (
|
|
<Avatar
|
|
id={msg.from || (isGroup ? '' : peerName)}
|
|
name={mine ? '我' : isGroup ? groupSenderLabel(senderName) : 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 ? (
|
|
<button type="button" onClick={() => openUserCard(msg.from!)} className="text-text-tertiary-4 hover:underline">
|
|
{groupSenderLabel(senderName)}
|
|
</button>
|
|
) : (
|
|
<span>{mine ? '我' : groupSenderLabel(senderName)}</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' : ''}`}
|
|
>
|
|
{replyInfo ? (
|
|
<QuoteBar topicName={topic.name} reply={replyInfo} resolveName={resolveQuotedName ?? (() => '消息')} locked={selectMode} />
|
|
) : null}
|
|
{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} locked={selectMode} />
|
|
) : mobileAttachment ? (
|
|
mobileAttachment.kind === 'image' ? (
|
|
<ImageAttachment data={mobileAttachment} locked={selectMode} />
|
|
) : mobileAttachment.kind === 'video' ? (
|
|
<VideoAttachment data={{ ...mobileAttachment, previewRef: mobileAttachment.previewRef }} locked={selectMode} />
|
|
) : mobileAttachment.kind === 'voice' ? (
|
|
<AudioAttachment data={mobileAttachment} locked={selectMode} />
|
|
) : (
|
|
<FileAttachment data={mobileAttachment} locked={selectMode} />
|
|
)
|
|
) : (
|
|
renderDrafty(msg.content, undefined, !selectMode)
|
|
)}
|
|
</div>
|
|
</div>
|
|
{failed ? (
|
|
<button
|
|
type="button"
|
|
onClick={(e) => {
|
|
e.stopPropagation();
|
|
retry();
|
|
}}
|
|
className="text-xs text-unread"
|
|
>
|
|
发送失败,点击重试
|
|
</button>
|
|
) : (
|
|
<span className="text-[11px] text-text-tertiary-3">{sending ? '发送中…' : statusLabel}</span>
|
|
)}
|
|
</div>
|
|
</div>
|
|
</div>
|
|
);
|
|
}
|