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 })} /> ))}
-
-
- - - - - - -
-