From 2272b42c6fb1155f7c8cfae3553f4255844a79a0 Mon Sep 17 00:00:00 2001 From: Your Name Date: Tue, 8 Sep 2026 02:01:53 +0800 Subject: [PATCH] feat(web): message context menu, recall, and basic multi-select MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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 --- .../features/messages/ConversationView.tsx | 214 +++++++++++++----- web/src/features/messages/MessageBubble.tsx | 72 ++++-- .../features/messages/MessageContextMenu.tsx | 82 +++++++ web/src/lib/api/bootstrap.ts | 17 ++ web/src/lib/api/hooks.ts | 8 + web/src/lib/api/messages.ts | 26 +++ web/src/lib/messages/actions.ts | 28 +++ 7 files changed, 368 insertions(+), 79 deletions(-) create mode 100644 web/src/features/messages/MessageContextMenu.tsx create mode 100644 web/src/lib/api/bootstrap.ts create mode 100644 web/src/lib/api/hooks.ts create mode 100644 web/src/lib/api/messages.ts create mode 100644 web/src/lib/messages/actions.ts diff --git a/web/src/features/messages/ConversationView.tsx b/web/src/features/messages/ConversationView.tsx index 1639fa60..2a1a0285 100644 --- a/web/src/features/messages/ConversationView.tsx +++ b/web/src/features/messages/ConversationView.tsx @@ -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(); @@ -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(null); + const [selectMode, setSelectMode] = useState(false); + const [selectedSeqs, setSelectedSeqs] = useState>(new Set()); const streamRef = useRef(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 (
-
- - - - - {avatarLetter(peerName)} - -
- {peerName} - {contact?.online ? '在线' : '离线'} -
-
-
- - - - -
+ {selectMode ? ( + <> + 已选择 {selectedSeqs.size} 条消息 + + + ) : ( + <> +
+ + + + + {avatarLetter(peerName)} + +
+ {peerName} + + {contact?.online ? '在线' : '离线'} + +
+
+
+ + + + +
+ + )}
@@ -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 })} /> ))}
-
-
- - - - - - -
-