Files
WechatExplorer/src/renderer/src/components/ChatWindow.tsx
T
2026-09-29 16:13:37 +07:00

229 lines
7.9 KiB
TypeScript

import React, { useCallback, useEffect, useRef, useState } from 'react'
import { Message, Contact } from '../../../shared/types'
import { ChatHeader } from './chat/ChatHeader'
import { ChatImageViewer } from './chat/ChatImageViewer'
import { ChatStatusBar } from './chat/ChatStatusBar'
import { DataTrustBar } from './chat/DataTrustBar'
import { EmptyConversationState } from './chat/EmptyConversationState'
import { MessageList } from './chat/MessageList'
import { PersonalWechatSendDialog } from './chat/PersonalWechatSendDialog'
import { mergeGroupExitEvents } from '../../../shared/group-exit-event-message'
import { useGroupExitEvents } from '../hooks/useGroupExitEvents'
interface ChatWindowProps {
contact: Contact | null
messages: Message[]
isLoadingMessages?: boolean
messageHistoryStatus?: 'idle' | 'end' | 'error'
contentFilter?: string
onContentFilterChange?: (keyword: string) => void
onRefresh?: () => void
onReloadAvatars?: () => Promise<void>
onLoadOlderMessages?: () => Promise<void>
onCreateGroupReport?: () => void
onOpenTextToSpeechSettings?: () => void
onOpenPersonalWechatSettings?: () => void
/** 跳到「设置 · 本地索引」(群统计发现索引没追平时用)。 */
onOpenLocalIndexSettings?: () => void
isAiLoading?: boolean
jumpToTime?: number | null
/** 精确跳转目标(消息 id)。与 jumpToTime 取或:任一存在就说明"这是一次跳转"。 */
jumpToMessageId?: string | null
}
const ChatWindow: React.FC<ChatWindowProps> = ({
contact,
messages,
isLoadingMessages,
messageHistoryStatus,
contentFilter,
onContentFilterChange,
onRefresh,
onReloadAvatars,
onLoadOlderMessages,
onCreateGroupReport,
onOpenTextToSpeechSettings,
onOpenPersonalWechatSettings,
onOpenLocalIndexSettings,
isAiLoading = false,
jumpToTime,
jumpToMessageId
}) => {
const isGroupChat = Boolean(
contact?.type === 'group' || contact?.m_nsUsrName?.endsWith('@chatroom')
)
const messageListRef = useRef<HTMLDivElement>(null)
const messagesEndRef = useRef<HTMLDivElement>(null)
const [previewImage, setPreviewImage] = useState<string | null>(null)
const [showAvatar, setShowAvatar] = useState(true)
const [isAtLatest, setIsAtLatest] = useState(true)
const [isReloadingAvatars, setIsReloadingAvatars] = useState(false)
const [sendDialogOpen, setSendDialogOpen] = useState(false)
const previousScrollTopRef = useRef(0)
const scrollToBottom = useCallback((): void => {
messagesEndRef.current?.scrollIntoView({ behavior: 'auto' })
setIsAtLatest(true)
}, [])
const handleMessageListScroll = useCallback((event: React.UIEvent<HTMLDivElement>): void => {
const target = event.currentTarget
const distanceToBottom = target.scrollHeight - target.scrollTop - target.clientHeight
if (distanceToBottom <= 24) {
setIsAtLatest(true)
} else if (target.scrollTop < previousScrollTopRef.current - 1) {
setIsAtLatest(false)
}
previousScrollTopRef.current = target.scrollTop
}, [])
useEffect(() => {
previousScrollTopRef.current = 0
setIsAtLatest(true)
}, [contact?.md5])
// 一次跳转 = 有精确目标或有时间目标。用统一判据,避免"只带了 messageId 但没带时间"
// 时自动滚到底把跳转结果顶掉(那会让用户看到"跳过去了但又被弹回最新")。
const hasJumpTarget =
(jumpToTime !== undefined && jumpToTime !== null) ||
(jumpToMessageId !== undefined && jumpToMessageId !== null)
useEffect(() => {
if (hasJumpTarget) setIsAtLatest(false)
}, [hasJumpTarget])
useEffect(() => {
if (!isAtLatest || hasJumpTarget) return
const frame = window.requestAnimationFrame(() => scrollToBottom())
return () => window.cancelAnimationFrame(frame)
}, [isAtLatest, hasJumpTarget, messages, scrollToBottom])
useEffect(() => {
if (!isAtLatest || hasJumpTarget) return
const content = messageListRef.current?.querySelector('.virtual-message-list')
if (!content) return
const observer = new ResizeObserver(() => scrollToBottom())
observer.observe(content)
return () => observer.disconnect()
}, [contact?.md5, isAtLatest, hasJumpTarget, scrollToBottom])
const openImagePreview = (imageUrl: string): void => {
setPreviewImage(imageUrl)
}
const closeImagePreview = (): void => {
setPreviewImage(null)
}
const handleReloadAvatars = async (): Promise<void> => {
if (!onReloadAvatars || isReloadingAvatars) return
setIsReloadingAvatars(true)
try {
await onReloadAvatars()
} finally {
setIsReloadingAvatars(false)
}
}
/**
* 退群推断事件并入档案消息流。
*
* **先合并再过滤**:反过来的话搜索词就命中不了事件,而「谁退群了」
* 恰恰是最需要能被搜到的内容之一。
*/
const groupExitEvents = useGroupExitEvents(contact)
const allMessages = React.useMemo(
() => (groupExitEvents.length ? mergeGroupExitEvents(messages, groupExitEvents) : messages),
[messages, groupExitEvents]
)
const filteredMessages = React.useMemo(() => {
return allMessages.filter((msg) => {
const filterTypes = (import.meta.env.VITE_FILTER_MSG_TYPES || '')
.split(',')
.map((type) => type.trim())
.filter(Boolean)
const typeMatch = !filterTypes.includes(msg.type)
const contentMatch = !contentFilter || msg.content.includes(contentFilter)
return typeMatch && contentMatch
})
}, [allMessages, contentFilter])
const handleOpenPersonalWechatSend = useCallback(async (): Promise<void> => {
try {
const status = await window.api.getPersonalWechatSenderStatus()
if (!status.canSendVoice) {
if (onOpenPersonalWechatSettings) {
onOpenPersonalWechatSettings()
return
}
}
} catch {
if (onOpenPersonalWechatSettings) {
onOpenPersonalWechatSettings()
return
}
}
setSendDialogOpen(true)
}, [onOpenPersonalWechatSettings])
if (!contact) return <EmptyConversationState />
return (
<div className="chat-window">
<ChatHeader
contact={contact}
isGroupChat={isGroupChat}
loadedCount={messages.length}
filteredCount={filteredMessages.length}
contentFilter={contentFilter || ''}
isAiLoading={isAiLoading}
onContentFilterChange={onContentFilterChange || (() => undefined)}
onRefresh={onRefresh}
onTestSend={() => void handleOpenPersonalWechatSend()}
onOpenAiSettings={onCreateGroupReport || (() => undefined)}
onOpenLocalIndexSettings={onOpenLocalIndexSettings}
/>
<DataTrustBar messageCount={messages.length} />
<MessageList
contact={contact}
messages={filteredMessages}
hiddenMessageCount={0}
isLoadingMessages={isLoadingMessages}
messageHistoryStatus={messageHistoryStatus}
isGroupChat={isGroupChat}
showAvatar={showAvatar}
listRef={messageListRef}
bottomRef={messagesEndRef}
onScroll={handleMessageListScroll}
onReachTop={onLoadOlderMessages}
onImageClick={openImagePreview}
jumpToTime={jumpToTime}
jumpToMessageId={jumpToMessageId}
/>
<ChatStatusBar
count={filteredMessages.length}
showAvatar={showAvatar}
isAtLatest={isAtLatest}
isReloadingAvatars={isReloadingAvatars}
onShowAvatarChange={setShowAvatar}
onReloadAvatars={() => void handleReloadAvatars()}
onJumpToLatest={scrollToBottom}
/>
{sendDialogOpen && (
<PersonalWechatSendDialog
contact={contact}
isGroupChat={isGroupChat}
onClose={() => setSendDialogOpen(false)}
onOpenTextToSpeechSettings={onOpenTextToSpeechSettings}
onOpenPersonalWechatSettings={onOpenPersonalWechatSettings}
/>
)}
{previewImage && <ChatImageViewer imageUrl={previewImage} onClose={closeImagePreview} />}
</div>
)
}
export default ChatWindow