mirror of
https://wget.la/https://github.com/Wxw-Gu/WechatExplorer
synced 2026-10-05 12:34:35 +08:00
229 lines
7.9 KiB
TypeScript
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
|