feat(web): message context menu, recall, and basic multi-select
Right-click a message for 复制/撤回/删除/多选 (README §3.5). 引用/收藏/翻译/
转发 deliberately left out of this pass -- quote and forward need their own
composer-state flows, and there's no confirmed single-message favorite
endpoint yet; a menu item that doesn't work is worse than a smaller correct
menu.
- lib/messages/actions.ts: gates recall to own messages, server-confirmed
seq (not the SDK's local pending-seq placeholder), and bootstrap's
message_policy.recall_window_seconds -- scoped to P2P for now, no
group-admin recall yet.
- lib/api/bootstrap.ts + api/hooks.ts: first GET /bootstrap wiring
(message_policy, forward limits, etc.), cached indefinitely per session.
- lib/api/messages.ts: recall goes through REST, not native WS -- and
requires a body `{version, reason}` this backend tracks per-message
independently of Tinode's own seq (confirmed in
docs/IM-前后端对接文档.md:1248 and by hitting a live 400
MALFORMED_REQUEST/version first, since no version field is actually
delivered anywhere in the WS {data} packet this app reads messages from).
Defaults to version:1 -- the correct value for any never-before-edited
message, which covers the common case; a real edit/recall race surfaces
as a version-conflict error rather than being silently papered over.
- Basic multi-select: enter via the context menu's "多选" (no long-press
gesture yet), header becomes "已选择 N 条消息"+取消, bottom bar currently
offers only 删除 (bulk 转发/收藏 wait on those features existing).
Verified end to end: recall correctly withheld past the 120s window and
correctly available within it, the real recall REST call round-trips and
the message disappears locally; multi-select mode enters/exits and
pre-selects the long-pressed/right-clicked message.
Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
This commit is contained in:
co-authored by
Claude Sonnet 5
parent
7f5756671d
commit
2272b42c6f
@@ -14,9 +14,13 @@ interface MessageRowProps {
|
||||
mine: boolean;
|
||||
isGroup: boolean;
|
||||
peerName: string;
|
||||
selectMode?: boolean;
|
||||
selected?: boolean;
|
||||
onToggleSelect?: (seq: number) => void;
|
||||
onContextMenu?: (e: React.MouseEvent, msg: StoredMessage) => void;
|
||||
}
|
||||
|
||||
export function MessageRow({ topic, msg, mine, isGroup, peerName }: MessageRowProps) {
|
||||
export function MessageRow({ topic, msg, mine, isGroup, peerName, selectMode, selected, onToggleSelect, onContextMenu }: MessageRowProps) {
|
||||
if (isServerDeleted(msg)) {
|
||||
return <div className="py-1 text-center text-xs text-text-tertiary-4">此消息已被删除</div>;
|
||||
}
|
||||
@@ -40,32 +44,52 @@ export function MessageRow({ topic, msg, mine, isGroup, peerName }: MessageRowPr
|
||||
};
|
||||
|
||||
return (
|
||||
<div className={`flex items-end gap-2.5 ${mine ? 'flex-row-reverse' : ''}`}>
|
||||
{!mine ? (
|
||||
<span
|
||||
className={`grid h-9 w-9 flex-none place-items-center rounded-lg text-sm font-medium text-white ${avatarBgClass(msg.from || peerName)}`}
|
||||
<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]'
|
||||
}`}
|
||||
>
|
||||
{avatarLetter(peerName)}
|
||||
</span>
|
||||
) : (
|
||||
<span className="w-9 flex-none" />
|
||||
)}
|
||||
<div className={`flex max-w-[60%] flex-col gap-1 ${mine ? 'items-end' : 'items-start'}`}>
|
||||
<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' : ''}`}
|
||||
>
|
||||
{isRecalled(msg) ? '⚠ 消息已删除' : renderDrafty(msg.content)}
|
||||
</div>
|
||||
{failed ? (
|
||||
<button type="button" onClick={retry} className="flex items-center gap-1 text-xs text-danger-text">
|
||||
<Icon name="error" className="text-[16px]" />
|
||||
发送失败,点击重试
|
||||
</button>
|
||||
{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' : ''}`}
|
||||
>
|
||||
{!mine ? (
|
||||
<span
|
||||
className={`grid h-9 w-9 flex-none place-items-center rounded-lg text-sm font-medium text-white ${avatarBgClass(msg.from || peerName)}`}
|
||||
>
|
||||
{avatarLetter(peerName)}
|
||||
</span>
|
||||
) : (
|
||||
<span className="text-[11px] text-text-tertiary-3">{sending ? '发送中…' : statusLabel}</span>
|
||||
<span className="w-9 flex-none" />
|
||||
)}
|
||||
<div className={`flex max-w-[60%] flex-col gap-1 ${mine ? 'items-end' : 'items-start'}`}>
|
||||
<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' : ''}`}
|
||||
>
|
||||
{isRecalled(msg) ? '⚠ 消息已删除' : renderDrafty(msg.content)}
|
||||
</div>
|
||||
{failed ? (
|
||||
<button type="button" onClick={retry} className="flex items-center gap-1 text-xs text-danger-text">
|
||||
<Icon name="error" className="text-[16px]" />
|
||||
发送失败,点击重试
|
||||
</button>
|
||||
) : (
|
||||
<span className="text-[11px] text-text-tertiary-3">{sending ? '发送中…' : statusLabel}</span>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
|
||||
Reference in New Issue
Block a user