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:
Your Name
2026-09-08 02:01:53 +08:00
co-authored by Claude Sonnet 5
parent 7f5756671d
commit 2272b42c6f
7 changed files with 368 additions and 79 deletions
+159 -55
View File
@@ -1,10 +1,16 @@
import { useEffect, useRef, useState } from 'react';
import { Link, useOutletContext, useParams } from 'react-router-dom';
import type { StoredMessage } from 'tinode-sdk';
import { Drafty } from 'tinode-sdk';
import { Icon } from '@/app/Icon';
import { MessageContextMenu } from '@/features/messages/MessageContextMenu';
import { MessageRow } from '@/features/messages/MessageBubble';
import { useBootstrap } from '@/lib/api/hooks';
import { recallMessage } from '@/lib/api/messages';
import { avatarBgClass, avatarLetter } from '@/lib/avatar';
import { availableActions, type MessageAction } from '@/lib/messages/actions';
import { useChatList, useTinode, useTopicMessages } from '@/lib/tinode/hooks';
import { sendText } from '@/lib/tinode/send';
import { closeConversation, openConversation } from '@/lib/tinode/topics';
@@ -14,6 +20,12 @@ interface OutletCtx {
onToggleDetail: () => void;
}
interface ContextMenuState {
x: number;
y: number;
msg: StoredMessage;
}
export function ConversationView() {
const { topicId } = useParams();
const { onToggleDetail } = useOutletContext<OutletCtx>();
@@ -22,7 +34,11 @@ export function ConversationView() {
const contacts = useChatList();
const contact = contacts.find((c) => c.topic === topicId);
const messages = useTopicMessages(topicId);
const bootstrap = useBootstrap();
const [draft, setDraft] = useState('');
const [menu, setMenu] = useState<ContextMenuState | null>(null);
const [selectMode, setSelectMode] = useState(false);
const [selectedSeqs, setSelectedSeqs] = useState<Set<number>>(new Set());
const streamRef = useRef<HTMLDivElement>(null);
useEffect(() => {
@@ -31,6 +47,12 @@ export function ConversationView() {
return () => closeConversation(topicId);
}, [topicId]);
useEffect(() => {
setSelectMode(false);
setSelectedSeqs(new Set());
setMenu(null);
}, [topicId]);
useEffect(() => {
streamRef.current?.scrollTo({ top: streamRef.current.scrollHeight });
}, [messages.length]);
@@ -62,39 +84,98 @@ export function ConversationView() {
submit();
};
const toggleSelect = (seq: number) => {
setSelectedSeqs((prev) => {
const next = new Set(prev);
if (next.has(seq)) next.delete(seq);
else next.add(seq);
return next;
});
};
const exitSelectMode = () => {
setSelectMode(false);
setSelectedSeqs(new Set());
};
const bulkDelete = () => {
if (selectedSeqs.size === 0) return;
if (!window.confirm(`删除已选择的 ${selectedSeqs.size} 条消息?`)) return;
topic.delMessagesList([...selectedSeqs], false);
exitSelectMode();
};
const handleAction = (action: MessageAction) => {
const msg = menu?.msg;
setMenu(null);
if (!msg || typeof msg.seq !== 'number') return;
switch (action) {
case 'copy':
navigator.clipboard.writeText(Drafty.toPlainText(msg.content ?? '')).catch(() => {});
break;
case 'recall':
recallMessage(topicId, msg.seq)
.then(() => topic.delMessagesList([msg.seq], true))
.catch(() => {});
break;
case 'delete':
topic.delMessagesList([msg.seq], false);
break;
case 'select':
setSelectMode(true);
setSelectedSeqs(new Set([msg.seq]));
break;
}
};
return (
<div className="flex h-full w-full flex-col bg-surface-chat">
<header className="flex h-15 flex-none items-center justify-between border-b border-border-panel bg-white px-5">
<div className="flex items-center gap-2.5">
<Link to="/messages" className="grid h-9.5 w-9.5 flex-none place-items-center text-text-heading md:hidden">
<Icon name="arrow_back_ios_new" />
</Link>
<span className={`grid h-9.5 w-9.5 flex-none place-items-center rounded-lg text-sm font-medium text-white ${avatarBgClass(topicId)}`}>
{avatarLetter(peerName)}
</span>
<div className="flex flex-col">
<strong className="text-base font-semibold text-text-heading">{peerName}</strong>
<span className={`text-xs ${contact?.online ? 'text-online' : 'text-text-tertiary-2'}`}>{contact?.online ? '在线' : '离线'}</span>
</div>
</div>
<div className="flex items-center gap-1">
<button type="button" className="grid h-9.5 w-9.5 place-items-center rounded-lg text-text-tertiary-1">
<Icon name="call" />
</button>
<button type="button" className="grid h-9.5 w-9.5 place-items-center rounded-lg text-text-tertiary-1">
<Icon name="videocam" />
</button>
<button type="button" className="grid h-9.5 w-9.5 place-items-center rounded-lg text-text-tertiary-1">
<Icon name="search" />
</button>
<button
type="button"
onClick={onToggleDetail}
className="grid h-9.5 w-9.5 place-items-center rounded-lg text-text-tertiary-1 md:bg-accent-soft-bg md:text-accent lg:bg-transparent lg:text-text-tertiary-1"
>
<Icon name="info" />
</button>
</div>
{selectMode ? (
<>
<span className="text-[15px] font-medium text-text-heading">已选择 {selectedSeqs.size} 条消息</span>
<button type="button" onClick={exitSelectMode} className="text-sm text-accent">
取消
</button>
</>
) : (
<>
<div className="flex items-center gap-2.5">
<Link to="/messages" className="grid h-9.5 w-9.5 flex-none place-items-center text-text-heading md:hidden">
<Icon name="arrow_back_ios_new" />
</Link>
<span
className={`grid h-9.5 w-9.5 flex-none place-items-center rounded-lg text-sm font-medium text-white ${avatarBgClass(topicId)}`}
>
{avatarLetter(peerName)}
</span>
<div className="flex flex-col">
<strong className="text-base font-semibold text-text-heading">{peerName}</strong>
<span className={`text-xs ${contact?.online ? 'text-online' : 'text-text-tertiary-2'}`}>
{contact?.online ? '在线' : '离线'}
</span>
</div>
</div>
<div className="flex items-center gap-1">
<button type="button" className="grid h-9.5 w-9.5 place-items-center rounded-lg text-text-tertiary-1">
<Icon name="call" />
</button>
<button type="button" className="grid h-9.5 w-9.5 place-items-center rounded-lg text-text-tertiary-1">
<Icon name="videocam" />
</button>
<button type="button" className="grid h-9.5 w-9.5 place-items-center rounded-lg text-text-tertiary-1">
<Icon name="search" />
</button>
<button
type="button"
onClick={onToggleDetail}
className="grid h-9.5 w-9.5 place-items-center rounded-lg text-text-tertiary-1 md:bg-accent-soft-bg md:text-accent lg:bg-transparent lg:text-text-tertiary-1"
>
<Icon name="info" />
</button>
</div>
</>
)}
</header>
<div ref={streamRef} className="flex flex-1 flex-col gap-4 overflow-y-auto px-7 py-5">
@@ -106,37 +187,60 @@ export function ConversationView() {
mine={!!myUserId && msg.from === myUserId}
isGroup={isGroup}
peerName={peerName}
selectMode={selectMode}
selected={typeof msg.seq === 'number' && selectedSeqs.has(msg.seq)}
onToggleSelect={toggleSelect}
onContextMenu={(e, m) => setMenu({ x: e.clientX, y: e.clientY, msg: m })}
/>
))}
</div>
<div className="flex-none border-t border-border-panel bg-white px-4 pb-3 pt-2">
<div className="flex items-center gap-1 px-1 pb-1">
<Icon name="sentiment_satisfied_alt" className="grid h-8.5 w-8.5 place-items-center rounded text-[22px] text-icon-default" />
<Icon name="image" className="grid h-8.5 w-8.5 place-items-center rounded text-[22px] text-icon-default" />
<Icon name="attach_file" className="grid h-8.5 w-8.5 place-items-center rounded text-[22px] text-icon-default" />
<Icon name="contact_page" className="grid h-8.5 w-8.5 place-items-center rounded text-[22px] text-icon-default" />
<Icon name="mic_none" className="grid h-8.5 w-8.5 place-items-center rounded text-[22px] text-icon-default" />
<Icon name="history" className="grid h-8.5 w-8.5 place-items-center rounded text-[22px] text-icon-default" />
</div>
<textarea
value={draft}
onChange={(e) => setDraft(e.target.value)}
onKeyDown={handleKeyDown}
placeholder="输入消息,Enter 发送,Shift+Enter 换行"
className="min-h-[72px] w-full resize-none bg-transparent text-[15px] leading-normal outline-none placeholder:text-text-placeholder-1"
/>
<div className="flex justify-end">
<button
type="button"
onClick={submit}
disabled={!draft.trim()}
className="h-9 rounded-lg bg-btn px-5.5 text-sm font-medium text-white shadow-btn disabled:bg-btn-disabled disabled:shadow-none"
>
发送
{selectMode ? (
<div className="flex h-18 flex-none items-center justify-around border-t border-border-panel bg-white">
<button type="button" onClick={bulkDelete} className="flex flex-col items-center gap-1 text-danger-text">
<Icon name="delete_outline" className="text-[22px]" />
<span className="text-xs">删除</span>
</button>
</div>
</div>
) : (
<div className="flex-none border-t border-border-panel bg-white px-4 pb-3 pt-2">
<div className="flex items-center gap-1 px-1 pb-1">
<Icon name="sentiment_satisfied_alt" className="grid h-8.5 w-8.5 place-items-center rounded text-[22px] text-icon-default" />
<Icon name="image" className="grid h-8.5 w-8.5 place-items-center rounded text-[22px] text-icon-default" />
<Icon name="attach_file" className="grid h-8.5 w-8.5 place-items-center rounded text-[22px] text-icon-default" />
<Icon name="contact_page" className="grid h-8.5 w-8.5 place-items-center rounded text-[22px] text-icon-default" />
<Icon name="mic_none" className="grid h-8.5 w-8.5 place-items-center rounded text-[22px] text-icon-default" />
<Icon name="history" className="grid h-8.5 w-8.5 place-items-center rounded text-[22px] text-icon-default" />
</div>
<textarea
value={draft}
onChange={(e) => setDraft(e.target.value)}
onKeyDown={handleKeyDown}
placeholder="输入消息,Enter 发送,Shift+Enter 换行"
className="min-h-[72px] w-full resize-none bg-transparent text-[15px] leading-normal outline-none placeholder:text-text-placeholder-1"
/>
<div className="flex justify-end">
<button
type="button"
onClick={submit}
disabled={!draft.trim()}
className="h-9 rounded-lg bg-btn px-5.5 text-sm font-medium text-white shadow-btn disabled:bg-btn-disabled disabled:shadow-none"
>
发送
</button>
</div>
</div>
)}
{menu ? (
<MessageContextMenu
x={menu.x}
y={menu.y}
actions={availableActions(menu.msg, myUserId, bootstrap.data?.message_policy)}
onAction={handleAction}
onClose={() => setMenu(null)}
/>
) : null}
</div>
);
}