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 { EmojiPanel } from '@/features/messages/EmojiPanel'; import { ForwardPickerModal } from '@/features/messages/ForwardPickerModal'; import { MessageContextMenu } from '@/features/messages/MessageContextMenu'; import { MessageRow } from '@/features/messages/MessageBubble'; import { VoiceRecorder } from '@/features/messages/VoiceRecorder'; import { useBootstrap } from '@/lib/api/hooks'; import { favoriteMessages, recallMessage } from '@/lib/api/messages'; import { avatarBgClass, avatarLetter } from '@/lib/avatar'; import { FETCH_PAGE_TRIGGER, MESSAGES_PAGE } from '@/lib/config'; import { availableActions, type MessageAction } from '@/lib/messages/actions'; import { renderDrafty } from '@/lib/messages/drafty'; import { sendFile, sendImage, sendVoice } from '@/lib/tinode/attachments'; import { useChatList, useTinode, useTopicMessages } from '@/lib/tinode/hooks'; import { forwardMessage, sendReply, sendText } from '@/lib/tinode/send'; import { closeConversation, openConversation } from '@/lib/tinode/topics'; import { useSessionStore } from '@/stores/session'; interface OutletCtx { onToggleDetail: () => void; /** Whether the detail panel is CURRENTLY shown -- true always at >=1280px (docked, no close * affordance), true/false at 960-1279px depending on the drawer's open state. Drives the header * "i" button's highlight; previously hardcoded to the breakpoint itself (always blue at * 960-1279px regardless of the drawer's real state, always grey at >=1280px despite the panel * being permanently open there) rather than this actual state. */ detailVisible: boolean; } interface ContextMenuState { x: number; y: number; msg: StoredMessage; } export function ConversationView() { const { topicId } = useParams(); const { onToggleDetail, detailVisible } = useOutletContext(); const myUserId = useSessionStore((s) => s.myUserId); const tinode = useTinode(); const contacts = useChatList(); const contact = contacts.find((c) => c.topic === topicId); const messages = useTopicMessages(topicId); const bootstrap = useBootstrap(); // Keyed by topicId, not a single string -- ConversationView is one long-lived instance reused // across every conversation (the route is `:topicId`, React Router doesn't remount on param // change), so a plain single draft leaks the PREVIOUS conversation's unsent text into whichever // one you open next. const [draftByTopic, setDraftByTopic] = useState>({}); const draft = (topicId && draftByTopic[topicId]) || ''; const setDraft = (value: string) => { if (topicId) setDraftByTopic((prev) => ({ ...prev, [topicId]: value })); }; const [menu, setMenu] = useState(null); const [selectMode, setSelectMode] = useState(false); const [selectedSeqs, setSelectedSeqs] = useState>(new Set()); const [loadingOlder, setLoadingOlder] = useState(false); const [recording, setRecording] = useState(false); const [bulkForwardOpen, setBulkForwardOpen] = useState(false); const [replyTo, setReplyTo] = useState(null); const [forwardMsg, setForwardMsg] = useState(null); const [emojiOpen, setEmojiOpen] = useState(false); const streamRef = useRef(null); const autoScrollRef = useRef(true); useEffect(() => { if (!topicId) return undefined; openConversation(topicId).catch(() => {}); return () => closeConversation(topicId); }, [topicId]); useEffect(() => { setSelectMode(false); setSelectedSeqs(new Set()); setMenu(null); setReplyTo(null); setForwardMsg(null); setBulkForwardOpen(false); setEmojiOpen(false); // Unmounting VoiceRecorder stops its media stream via its own cleanup effect -- without this, // a recording started in conversation A silently keeps going after navigating to B, and its // onSend callback (re-created every render off the CURRENT topicId) would publish the eventual // recording into B instead of the conversation the user was actually recording in. setRecording(false); }, [topicId]); useEffect(() => { if (autoScrollRef.current) { streamRef.current?.scrollTo({ top: streamRef.current.scrollHeight }); } }, [messages.length]); useEffect(() => { if (!topicId || messages.length === 0) return; tinode.getTopic(topicId).noteRead(0); }, [topicId, messages.length, tinode]); if (!topicId) return null; const topic = tinode.getTopic(topicId); const peerName = contact?.public?.fn || topicId; const isGroup = topic.isGroupType(); /** * "加载更多" (README §3.2/old app's messages-view.jsx): not simple since/before paging -- computes * which seq ranges are actually missing from the local cache within the currently-visible window * (topic.msgHasMoreMessages) and fills exactly those gaps (topic.getMessagesPage), since messages * can have server-side deletions punching holes in an otherwise-contiguous range. */ const loadOlder = async () => { if (loadingOlder) return; const el = streamRef.current; const min = topic.minMsgSeq(); const max = topic.maxMsgSeq(); const gaps = topic.msgHasMoreMessages(min, max, false); if (gaps.length === 0) return; setLoadingOlder(true); const prevHeight = el?.scrollHeight ?? 0; const prevTop = el?.scrollTop ?? 0; try { await topic.getMessagesPage(MESSAGES_PAGE, gaps, min, max); } finally { setLoadingOlder(false); requestAnimationFrame(() => { if (el) el.scrollTop = prevTop + (el.scrollHeight - prevHeight); }); } }; const handleScroll = () => { const el = streamRef.current; if (!el) return; autoScrollRef.current = el.scrollHeight - el.scrollTop - el.clientHeight < 80; if (el.scrollTop <= FETCH_PAGE_TRIGGER) { loadOlder(); } }; const submit = () => { const text = draft.trim(); if (!text) return; setDraft(''); if (replyTo) { const label = myUserId && replyTo.from === myUserId ? '我' : peerName; sendReply(topic, text, replyTo, label); setReplyTo(null); } else { sendText(topic, text); } }; const handleKeyDown = (e: React.KeyboardEvent) => { if (e.key !== 'Enter' || e.shiftKey) return; // Guards against CJK IME candidate-selection Enter keystrokes being mistaken for submit -- // a real, unfixed bug class in the old app (confirmed by its own research: zero isComposing/ // compositionstart/keyCode-229 guards anywhere in that codebase). if (e.nativeEvent.isComposing) return; e.preventDefault(); 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 bulkFavorite = () => { if (selectedSeqs.size === 0) return; favoriteMessages([...selectedSeqs].map((seq) => ({ topic: topicId, seq }))).catch(() => {}); exitSelectMode(); }; const bulkForward = (targetTopic: string) => { const target = tinode.getTopic(targetTopic); for (const seq of selectedSeqs) { const msg = topic.findMessage(seq); if (msg) forwardMessage(topicId, msg, target); } setBulkForwardOpen(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; case 'quote': setReplyTo(msg); break; case 'favorite': favoriteMessages([{ topic: topicId, seq: msg.seq }]).catch(() => {}); break; case 'forward': setForwardMsg(msg); break; } }; const handlePickImage = (e: React.ChangeEvent) => { const file = e.target.files?.[0]; e.target.value = ''; if (file) sendImage(topic, file).catch(() => {}); }; const handlePickFile = (e: React.ChangeEvent) => { const file = e.target.files?.[0]; e.target.value = ''; if (file) sendFile(topic, file).catch(() => {}); }; return (
{selectMode ? ( <> 已选择 {selectedSeqs.size} 条消息 ) : ( <>
{avatarLetter(peerName)}
{peerName} {contact?.online ? '在线' : '离线'}
)}
{loadingOlder ?
加载中…
: null} {messages.map((msg) => ( setMenu({ x: e.clientX, y: e.clientY, msg: m })} /> ))}
{selectMode ? (
) : (
{replyTo ? (
回复 {myUserId && replyTo.from === myUserId ? '我' : peerName}:{renderDrafty(replyTo.content)}
) : null}
{emojiOpen ? (
{ setDraft(draft + emoji); setEmojiOpen(false); }} />
) : null}
{recording ? ( setRecording(false)} onSend={(blob, durationMs) => { setRecording(false); sendVoice(topic, blob, durationMs).catch(() => {}); }} /> ) : ( <>