feat: 优化聊天记录缓存、虚拟分页与群聊媒体展示

- 使用持久化缓存加速启动并异步读取 WCDB 消息
- 修复空群缓存、头像和群成员名称丢失问题
- 修复引用图片缩略图、虚拟卸载缓存和图片预览
- 修正引用消息发送者显示为群 ID 的问题
This commit is contained in:
电摇小子
2026-07-28 02:45:20 +08:00
parent 9348c5ce4a
commit 7db845ac7e
18 changed files with 1130 additions and 281 deletions
+276 -122
View File
@@ -40,7 +40,9 @@ interface SelfInfo {
const MAC_KEY_FAQ_URL = 'https://github.com/hicccc77/WeFlow/blob/main/docs/MAC-KEY-FAQ.md'
const MESSAGE_MONITOR_DEBOUNCE_MS = 8000
const INITIAL_MESSAGE_COUNT = 20
const MESSAGE_PAGE_SIZE = 100
const MESSAGE_PREFETCH_COUNT = INITIAL_MESSAGE_COUNT + MESSAGE_PAGE_SIZE
const EXPORT_PREVIEW_LIMIT = 20
const getMessageIdentity = (message: Message): string => {
if (message.localId) return `local:${message.localId}`
@@ -48,6 +50,70 @@ const getMessageIdentity = (message: Message): string => {
return `${message.createTime || 0}:${message.from}:${message.type}:${message.content}`
}
const normalizeQuotedText = (value: string | undefined): string =>
String(value || '')
.replace(/\s+/g, ' ')
.trim()
const isInternalReferenceSender = (value: string | undefined): boolean => {
const sender = String(value || '').trim()
return (
!sender ||
sender.endsWith('@chatroom') ||
sender.startsWith('wxid_') ||
/^[a-z0-9_@.-]{12,}$/i.test(sender)
)
}
const enrichQuotedMessages = (messages: Message[], referenceMessages: Message[]): Message[] => {
const imageDatNameByMd5 = new Map<string, string>()
const messagesByContent = new Map<string, Message[]>()
for (const message of referenceMessages) {
if (
message.contentData?.type === 'image' &&
message.contentData.md5 &&
message.contentData.datName
) {
imageDatNameByMd5.set(message.contentData.md5, message.contentData.datName)
}
const content = normalizeQuotedText(message.content)
if (!content) continue
const candidates = messagesByContent.get(content) || []
candidates.push(message)
messagesByContent.set(content, candidates)
}
return messages.map((message) => {
if (message.contentData?.type !== 'quote') return message
const quote = message.contentData
let quotedImageDatName = quote.quotedImageDatName
if (!quotedImageDatName && quote.quotedImageMd5) {
quotedImageDatName = imageDatNameByMd5.get(quote.quotedImageMd5)
}
let quotedSender = quote.quotedSender
if (isInternalReferenceSender(quotedSender)) {
const candidates = messagesByContent.get(normalizeQuotedText(quote.quotedContent)) || []
const source = candidates
.filter((candidate) => (candidate.createTime || 0) <= (message.createTime || Infinity))
.sort((left, right) => (right.createTime || 0) - (left.createTime || 0))[0]
if (source?.name && !isInternalReferenceSender(source.name)) quotedSender = source.name
}
if (
quotedSender === quote.quotedSender &&
quotedImageDatName === quote.quotedImageDatName
) {
return message
}
return {
...message,
contentData: { ...quote, quotedSender, quotedImageDatName }
}
})
}
const areMessagesEquivalent = (left: Message[], right: Message[]): boolean => {
if (left === right) return true
if (left.length !== right.length) return false
@@ -79,7 +145,7 @@ type StartupProgress = {
}
const formatGroupMemberName = (member: GroupSnapshot['members'][number]): string =>
member.nickname || member.wxid
member.groupNickname || member.nickname || member.remark || member.wechatNickname || member.wxid
const buildSyntheticGroupMessages = (
previous: GroupSnapshot | null,
@@ -150,7 +216,6 @@ function App(): React.ReactElement {
const [messages, setMessages] = useState<Message[]>([])
const [isMessagesLoading, setIsMessagesLoading] = useState(false)
const [filteredContacts, setFilteredContacts] = useState<Contact[]>([])
const [dateRange, setDateRange] = useState('today') // 默认今天
const [contentFilter, setContentFilter] = useState('')
const [isFetchingDbKey, setIsFetchingDbKey] = useState(false)
const [dbKeyStatus, setDbKeyStatus] = useState('')
@@ -195,6 +260,10 @@ function App(): React.ReactElement {
const currentGroupSnapshotRef = React.useRef<GroupSnapshot | null>(null)
const syntheticGroupMessagesRef = React.useRef<Record<string, Message[]>>({})
const groupMemberMetaRef = React.useRef<Record<string, Map<string, GroupMemberMeta>>>({})
const messageHistoryRef = React.useRef<Message[]>([])
const messagesRef = React.useRef<Message[]>([])
const messagePrefetchRef = React.useRef<Promise<void> | null>(null)
messagesRef.current = messages
React.useEffect(() => {
if (!reportNotice) return
const timer = window.setTimeout(() => setReportNotice(''), 3200)
@@ -486,7 +555,32 @@ function App(): React.ReactElement {
}
if (!autoLoginEnabled) return
try {
const result = await window.api.initDb(key)
const startupCacheReady = await loadStartupCache()
const initPromise = window.api.initDb(key)
if (startupCacheReady) {
setIsAuthenticated(true)
setIsDatabaseConnected(false)
setBootState('login')
void initPromise.then(async (result) => {
const success = typeof result === 'boolean' ? result : result.success
if (!success) {
const error = typeof result === 'boolean' ? '' : result.error
setDbKeyStatus(`后台连接失败${error ? `: ${error}` : ''}`)
setDbKeyStatusKind('error')
return
}
setIsNativeMonitorActive(typeof result !== 'boolean' && result.monitoring === true)
setIsDatabaseConnected(true)
setDbKeyStatus('已连接数据库')
// Cached contacts/self info are enough for startup. Native refresh is
// intentionally user-triggered so it cannot freeze the first session.
}).catch((error) => {
console.warn('[Startup] background database init failed:', error)
setDbKeyStatusKind('error')
})
return
}
const result = await initPromise
if (!active) return
const success = typeof result === 'boolean' ? result : result.success
if (success) {
@@ -497,9 +591,13 @@ function App(): React.ReactElement {
setIsDatabaseConnected(true)
setDbKeyStatus('已自动连接')
setDbKeyStatusKind('success')
await loadContacts()
await refreshSelfInfo(3)
setIsAuthenticated(true)
const hasBootstrap = await loadBootstrapCache()
if (hasBootstrap) {
setIsAuthenticated(true)
} else {
await Promise.all([loadContacts(), refreshSelfInfo(3)])
setIsAuthenticated(true)
}
} else {
const error = typeof result === 'boolean' ? '' : result.error
setDbKeyStatus(`自动连接失败,请重新输入${error ? `: ${error}` : ''}`)
@@ -569,7 +667,7 @@ function App(): React.ReactElement {
detail: '正在读取本地缓存',
percent: 25
})
await loadBootstrapCache()
const hasBootstrap = await loadBootstrapCache()
// 持久化手动输入的密钥,供下次启动继续使用
void window.api.saveDbKey(keyToUse).catch(() => undefined)
void window.api.getSettings().then((current) => {
@@ -585,15 +683,28 @@ function App(): React.ReactElement {
})
// 账号识别依赖联系人数据就绪。返回登录后数据已被清空,如果先查账号,
// 会出现“数据库已连接,但账号未连接”的分离状态。手动连接与启动自动连接保持同一顺序。
await loadContacts({ waitForAvatars: false })
await refreshSelfInfo(3)
if (hasBootstrap) {
// Cached contacts are sufficient for the first paint. Refresh native data in the background.
setIsAuthenticated(true)
void Promise.all([
loadContacts({ waitForAvatars: false }),
refreshSelfInfo(3)
]).catch((error) => {
console.warn('[Startup] background refresh failed:', error)
})
} else {
await Promise.all([
loadContacts({ waitForAvatars: false }),
refreshSelfInfo(3)
])
setIsAuthenticated(true)
}
setStartupProgress({
title: '加载完成',
subtitle: '正在进入主页面',
detail: '联系人和头像已准备好',
percent: 100
})
setIsAuthenticated(true)
setIsDatabaseConnected(true)
setBootState('login')
window.setTimeout(() => {
@@ -646,10 +757,14 @@ function App(): React.ReactElement {
const applyGroupMemberMeta = React.useCallback(
(contact: Contact | null, baseMessages: Message[]): Message[] => {
if (!contact || contact.type !== 'group') return baseMessages
const enrichedMessages = enrichQuotedMessages(baseMessages, [
...messageHistoryRef.current,
...baseMessages
])
const memberMap = groupMemberMetaRef.current[contact.md5]
if (!memberMap || memberMap.size === 0) return baseMessages
if (!memberMap || memberMap.size === 0) return enrichedMessages
return baseMessages.map((message) => {
return enrichedMessages.map((message) => {
const senderId = String(message.senderId || message.name || '').trim()
if (!senderId) return message
const member = memberMap.get(senderId)
@@ -672,23 +787,57 @@ function App(): React.ReactElement {
[]
)
const storeGroupMemberMeta = React.useCallback(
(contact: Contact, snapshot: GroupSnapshot): void => {
currentGroupSnapshotRef.current = snapshot
groupMemberMetaRef.current[contact.md5] = new Map(
snapshot.members.map((member) => [
member.wxid,
{ nickname: formatGroupMemberName(member), avatar: member.avatar || '' }
])
)
},
[]
)
const loadGroupMemberMeta = React.useCallback(
async (contact: Contact | null): Promise<GroupSnapshot | null> => {
if (!contact || contact.type !== 'group') return null
const snapshot = await logGroupSnapshot(contact, 'load-member-meta')
if (!snapshot) return null
currentGroupSnapshotRef.current = snapshot
groupMemberMetaRef.current[contact.md5] = new Map(
snapshot.members.map((member) => [
member.wxid,
{ nickname: member.nickname || member.wxid, avatar: member.avatar || '' }
])
)
storeGroupMemberMeta(contact, snapshot)
return snapshot
},
[logGroupSnapshot]
[logGroupSnapshot, storeGroupMemberMeta]
)
const handleReloadCurrentAvatars = React.useCallback(async (): Promise<void> => {
const contact = selectedContact
if (!contact) return
try {
const avatars = await window.api.getContactAvatars([contact.m_nsUsrName])
const avatar = avatars[contact.m_nsUsrName]
if (avatar) {
const updateContact = (item: Contact): Contact =>
item.md5 === contact.md5 ? { ...item, avatar } : item
setContacts((current) => current.map(updateContact))
setFilteredContacts((current) => current.map(updateContact))
setSelectedContact((current) =>
current?.md5 === contact.md5 ? updateContact(current) : current
)
}
} catch (error) {
console.warn('[Contacts] current avatar reload failed:', error)
}
const snapshot = await loadGroupMemberMeta(contact)
if (!snapshot || selectedContactMd5Ref.current !== contact.md5) return
setMessages((current) =>
applyGroupMemberMeta(contact, mergeSyntheticMessages(contact, current, snapshot.roomId))
)
}, [applyGroupMemberMeta, loadGroupMemberMeta, mergeSyntheticMessages, selectedContact])
const handleAutoGetDbKey = async (): Promise<void> => {
if (isFetchingDbKey) return
setIsFetchingDbKey(true)
@@ -761,65 +910,57 @@ function App(): React.ReactElement {
setStartupProgress(null)
}
const getDateRangeParams = (
range: string
): { startTime: number | undefined; endTime: number | undefined } => {
const now = new Date()
const startOfToday = new Date(now.getFullYear(), now.getMonth(), now.getDate()).getTime() / 1000
let startTime: number | undefined
let endTime: number | undefined
switch (range) {
case 'today':
startTime = startOfToday
break
case 'yesterday':
startTime = startOfToday - 86400
endTime = startOfToday - 1 // 昨天结束
break
case '7':
startTime = Math.floor(Date.now() / 1000) - 7 * 86400
break
case '30':
startTime = Math.floor(Date.now() / 1000) - 30 * 86400
break
case 'all':
startTime = undefined
break
default:
startTime = startOfToday
}
return { startTime, endTime }
}
const handleSelectContact = async (contact: Contact): Promise<void> => {
const handleSelectContact = async (contact: Contact, forceLive = false): Promise<void> => {
setSelectedContact(contact)
selectedContactMd5Ref.current = contact.md5
currentGroupSnapshotRef.current = null
setIsMessagesLoading(true)
const { startTime, endTime } = getDateRangeParams(dateRange)
const cachedMsgs = await window.api.getCachedMessages(contact.md5, startTime, endTime)
const cachedPage = await window.api.getCachedMessagePage(contact.md5)
const cachedMsgs = cachedPage.messages
if (selectedContactMd5Ref.current !== contact.md5) return
if (cachedMsgs.length) {
setMessages(
applyGroupMemberMeta(
contact,
mergeSyntheticMessages(contact, cachedMsgs.slice(-MESSAGE_PAGE_SIZE))
)
)
} else {
setMessages([])
if (cachedPage.groupSnapshot) {
storeGroupMemberMeta(contact, cachedPage.groupSnapshot)
}
messageHistoryRef.current = cachedMsgs
setMessages(
applyGroupMemberMeta(
contact,
mergeSyntheticMessages(contact, cachedMsgs.slice(-INITIAL_MESSAGE_COUNT))
)
)
const needsLivePage =
forceLive || (isDatabaseConnected && cachedMsgs.length < MESSAGE_PREFETCH_COUNT)
if (!needsLivePage) {
setIsMessagesLoading(false)
if (contact.type === 'group' && cachedMsgs.length > 0 && !cachedPage.groupSnapshot) {
window.setTimeout(() => {
void loadGroupMemberMeta(contact).then((snapshot) => {
if (!snapshot || selectedContactMd5Ref.current !== contact.md5) return
setMessages((current) =>
applyGroupMemberMeta(
contact,
mergeSyntheticMessages(contact, current, snapshot.roomId)
)
)
})
}, 0)
}
return
}
if (cachedMsgs.length >= INITIAL_MESSAGE_COUNT) setIsMessagesLoading(false)
await waitForPaint()
try {
const msgs = await window.api.getMessages(contact.md5, startTime, endTime, {
limit: MESSAGE_PAGE_SIZE
const loadLivePage = async (): Promise<void> => {
const msgs = await window.api.getMessages(contact.md5, undefined, undefined, {
limit: MESSAGE_PREFETCH_COUNT
})
if (selectedContactMd5Ref.current !== contact.md5) return
const cachedMessages = applyGroupMemberMeta(contact, mergeSyntheticMessages(contact, msgs))
setMessages(cachedMessages)
if (contact.type === 'group') {
messageHistoryRef.current = msgs
const visibleMessages = applyGroupMemberMeta(
contact,
mergeSyntheticMessages(contact, msgs.slice(-INITIAL_MESSAGE_COUNT))
)
setMessages(visibleMessages)
if (contact.type === 'group' && visibleMessages.length > 0) {
window.setTimeout(() => {
void loadGroupMemberMeta(contact).then((snapshot) => {
if (selectedContactMd5Ref.current !== contact.md5) return
@@ -833,28 +974,79 @@ function App(): React.ReactElement {
})
}, 120)
}
}
const prefetchPromise = loadLivePage()
messagePrefetchRef.current = prefetchPromise
try {
await prefetchPromise
} finally {
if (messagePrefetchRef.current === prefetchPromise) messagePrefetchRef.current = null
if (selectedContactMd5Ref.current === contact.md5) setIsMessagesLoading(false)
}
}
React.useEffect(() => {
if (!isDatabaseConnected || !selectedContact || messages.length > 0) return
// The first live page uses async native cursors, so a cache miss can be filled
// without blocking the Electron main thread.
void handleSelectContact(selectedContact)
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [isDatabaseConnected])
const loadStartupCache = async (): Promise<boolean> => {
try {
const cache = await window.api.getStartupCache()
if (!cache?.contacts.length) return false
setContacts(cache.contacts)
setFilteredContacts(cache.contacts)
if (cache.self) setSelfInfo(cache.self)
return true
} catch (error) {
console.warn('[StartupCache] load failed:', error)
return false
}
}
const handleLoadOlderMessages = async (): Promise<void> => {
const contact = selectedContact
if (!contact || isMessagesLoading || messages.length === 0) return
const oldestTime = messages[0]?.createTime
if (!contact || messagesRef.current.length === 0) return
if (messagePrefetchRef.current) await messagePrefetchRef.current
if (selectedContactMd5Ref.current !== contact.md5) return
const currentMessages = messagesRef.current
const historyMessages = messageHistoryRef.current
const firstVisibleIndex = historyMessages.findIndex(
(message) => getMessageIdentity(message) === getMessageIdentity(currentMessages[0])
)
if (firstVisibleIndex > 0) {
const inMemoryOlder = historyMessages.slice(
Math.max(0, firstVisibleIndex - MESSAGE_PAGE_SIZE),
firstVisibleIndex
)
setMessages((current) =>
applyGroupMemberMeta(
contact,
mergeSyntheticMessages(contact, mergeMessagePages(inMemoryOlder, current))
)
)
return
}
const oldestTime = currentMessages[0]?.createTime
if (!oldestTime) return
const { startTime } = getDateRangeParams(dateRange)
if (startTime && oldestTime <= startTime) return
setIsMessagesLoading(true)
try {
const olderMessages = await window.api.getMessages(
const cachedPage = await window.api.getCachedMessagePage(
contact.md5,
startTime,
oldestTime - 1,
{ limit: MESSAGE_PAGE_SIZE }
undefined,
oldestTime - 1
)
const olderMessages = cachedPage.hit
? cachedPage.messages
: await window.api.getMessages(contact.md5, undefined, oldestTime - 1, {
limit: MESSAGE_PAGE_SIZE
})
if (selectedContactMd5Ref.current !== contact.md5) return
messageHistoryRef.current = mergeMessagePages(olderMessages, historyMessages)
setMessages((current) =>
applyGroupMemberMeta(contact, mergeSyntheticMessages(contact, mergeMessagePages(olderMessages, current)))
)
@@ -878,40 +1070,6 @@ function App(): React.ReactElement {
}
}
const handleDateRangeChange = (range: string): void => {
setDateRange(range)
if (selectedContact) {
const { startTime, endTime } = getDateRangeParams(range)
window.api
.getCachedMessages(selectedContact.md5, startTime, endTime)
.then((cachedMessages) => {
if (!cachedMessages.length) return
setMessages(
applyGroupMemberMeta(
selectedContact,
mergeSyntheticMessages(selectedContact, cachedMessages.slice(-MESSAGE_PAGE_SIZE))
)
)
})
setIsMessagesLoading(true)
window.api
.getMessages(selectedContact.md5, startTime, endTime, { limit: MESSAGE_PAGE_SIZE })
.then((nextMessages) => {
setMessages(
applyGroupMemberMeta(
selectedContact,
mergeSyntheticMessages(selectedContact, nextMessages)
)
)
setIsMessagesLoading(false)
})
.catch((error) => {
console.warn('[Messages] date range load failed:', error)
setIsMessagesLoading(false)
})
}
}
React.useEffect(() => {
if (!isAuthenticated || !selectedContact || !isNativeMonitorActive) return
@@ -928,12 +1086,11 @@ function App(): React.ReactElement {
}
refreshInFlight = true
try {
const range = getDateRangeParams(dateRange)
const latestMessages = await window.api.getMessages(
contactMd5,
range.startTime,
range.endTime,
{ limit: MESSAGE_PAGE_SIZE }
undefined,
undefined,
{ limit: INITIAL_MESSAGE_COUNT }
)
const nextMessages = applyGroupMemberMeta(
selectedContact,
@@ -975,7 +1132,6 @@ function App(): React.ReactElement {
unsubscribe()
}
}, [
dateRange,
isAuthenticated,
isNativeMonitorActive,
selectedContact,
@@ -1201,8 +1357,6 @@ function App(): React.ReactElement {
onSearch={handleSearchContacts}
onContentFilter={setContentFilter}
width={sidebarWidth}
dateRange={dateRange}
onDateRangeChange={handleDateRangeChange}
selfInfo={selfInfo}
dbReady={isDatabaseConnected}
onOpenSettings={openSettings}
@@ -1214,11 +1368,11 @@ function App(): React.ReactElement {
messages={messages}
isLoadingMessages={isMessagesLoading}
contentFilter={contentFilter}
dateRange={dateRange}
onContentFilterChange={setContentFilter}
onRefresh={() => selectedContact && handleSelectContact(selectedContact)}
onRefresh={() => selectedContact && handleSelectContact(selectedContact, true)}
onRefreshData={loadContacts}
onLoadOlderMessages={() => void handleLoadOlderMessages()}
onReloadAvatars={handleReloadCurrentAvatars}
onLoadOlderMessages={handleLoadOlderMessages}
onCreateGroupReport={handleOpenReportWorkspace}
isAiLoading={reportGeneration.isGenerating}
/>
+41
View File
@@ -1803,6 +1803,41 @@ body {
accent-color: var(--wxex-brand);
}
.chat-avatar-note {
max-width: 320px;
color: var(--wxex-text-muted);
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
.chat-avatar-reload {
display: inline-flex;
align-items: center;
gap: 4px;
white-space: nowrap;
}
.chat-avatar-reload svg {
width: 14px;
height: 14px;
fill: none;
stroke: currentColor;
stroke-linecap: round;
stroke-linejoin: round;
stroke-width: 1.8;
}
.chat-avatar-reload svg.is-spinning {
animation: chat-avatar-spin 0.8s linear infinite;
}
@keyframes chat-avatar-spin {
to {
transform: rotate(360deg);
}
}
.chat-status-separator {
width: 1px;
height: 14px;
@@ -1827,6 +1862,12 @@ body {
cursor: default;
}
@media (max-width: 1120px) {
.chat-avatar-note {
display: none;
}
}
.empty-conversation-state {
display: flex;
flex-direction: column;
+37 -45
View File
@@ -11,62 +11,24 @@ interface ChatWindowProps {
messages: Message[]
isLoadingMessages?: boolean
contentFilter?: string
dateRange?: string
onContentFilterChange?: (keyword: string) => void
onRefresh?: () => void
onRefreshData?: () => void
onLoadOlderMessages?: () => void
onReloadAvatars?: () => Promise<void>
onLoadOlderMessages?: () => Promise<void>
onCreateGroupReport?: () => void
isAiLoading?: boolean
}
const DATE_RANGE_LABELS: Record<string, string> = {
today: '今天',
yesterday: '昨日',
'7': '7 天',
'30': '30 天',
all: '全部'
}
const formatClock = (date: Date): string =>
`${String(date.getHours()).padStart(2, '0')}:${String(date.getMinutes()).padStart(2, '0')}`
const formatRangeDate = (date: Date, now: Date): string => {
const clock = formatClock(date)
if (date.getFullYear() === now.getFullYear()) {
return `${date.getMonth() + 1}${date.getDate()}${clock}`
}
return `${date.getFullYear()}${date.getMonth() + 1}${date.getDate()}${clock}`
}
const getChatHeaderRangeLabel = (range: string): string => {
const now = new Date()
const startOfToday = new Date(now.getFullYear(), now.getMonth(), now.getDate())
const endOfYesterday = new Date(startOfToday.getTime() - 60_000)
if (range === 'today') return `今天 00:00—现在`
if (range === 'yesterday') return `昨天 00:00—${formatClock(endOfYesterday)}`
if (range === '7') {
const start = new Date(Date.now() - 7 * 86400000)
return `${formatRangeDate(start, now)}—现在`
}
if (range === '30') {
const start = new Date(Date.now() - 30 * 86400000)
return `${formatRangeDate(start, now)}—现在`
}
if (range === 'all') return '全部记录'
return DATE_RANGE_LABELS[range] || '当前范围'
}
const ChatWindow: React.FC<ChatWindowProps> = ({
contact,
messages,
isLoadingMessages,
contentFilter,
dateRange = 'today',
onContentFilterChange,
onRefresh,
onRefreshData,
onReloadAvatars,
onLoadOlderMessages,
onCreateGroupReport,
isAiLoading = false
@@ -86,6 +48,8 @@ const ChatWindow: React.FC<ChatWindowProps> = ({
)
const [showAvatar, setShowAvatar] = useState(true)
const [isAtLatest, setIsAtLatest] = useState(true)
const [isReloadingAvatars, setIsReloadingAvatars] = useState(false)
const previousScrollTopRef = useRef(0)
const scrollToBottom = useCallback((): void => {
messagesEndRef.current?.scrollIntoView({ behavior: 'auto' })
@@ -95,15 +59,34 @@ const ChatWindow: React.FC<ChatWindowProps> = ({
const handleMessageListScroll = useCallback((event: React.UIEvent<HTMLDivElement>): void => {
const target = event.currentTarget
const distanceToBottom = target.scrollHeight - target.scrollTop - target.clientHeight
setIsAtLatest(distanceToBottom <= 24)
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])
useEffect(() => {
if (!isAtLatest) return
const frame = window.requestAnimationFrame(() => scrollToBottom())
return () => window.cancelAnimationFrame(frame)
}, [isAtLatest, messages, scrollToBottom])
useEffect(() => {
if (!isAtLatest) 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, scrollToBottom])
const openImagePreview = (imageUrl: string): void => {
setPreviewImage(imageUrl)
setImageScale(1)
@@ -155,6 +138,16 @@ const ChatWindow: React.FC<ChatWindowProps> = ({
imageDragRef.current = null
}
const handleReloadAvatars = async (): Promise<void> => {
if (!onReloadAvatars || isReloadingAvatars) return
setIsReloadingAvatars(true)
try {
await onReloadAvatars()
} finally {
setIsReloadingAvatars(false)
}
}
useEffect(() => {
if (!previewImage) return
@@ -186,14 +179,11 @@ const ChatWindow: React.FC<ChatWindowProps> = ({
}, [messages, contentFilter])
if (!contact) return <EmptyConversationState />
const dateRangeLabel = getChatHeaderRangeLabel(dateRange)
return (
<div className="chat-window">
<ChatHeader
contact={contact}
isGroupChat={isGroupChat}
dateRangeLabel={dateRangeLabel}
loadedCount={messages.length}
filteredCount={filteredMessages.length}
contentFilter={contentFilter || ''}
@@ -221,7 +211,9 @@ const ChatWindow: React.FC<ChatWindowProps> = ({
count={filteredMessages.length}
showAvatar={showAvatar}
isAtLatest={isAtLatest}
isReloadingAvatars={isReloadingAvatars}
onShowAvatarChange={setShowAvatar}
onReloadAvatars={() => void handleReloadAvatars()}
onJumpToLatest={scrollToBottom}
/>
+50 -2
View File
@@ -1,6 +1,45 @@
import { useState, useCallback, useEffect, useRef } from 'react'
import type { JSX, MouseEvent } from 'react'
type CachedImage = { data: string; isThumbnail: boolean }
const MAX_IMAGE_CACHE_ENTRIES = 80
const imageDataUrlCache = new Map<string, CachedImage>()
function imageCacheKeys(imageMd5?: string, imageDatName?: string): string[] {
return [imageMd5 ? `md5:${imageMd5}` : '', imageDatName ? `dat:${imageDatName}` : ''].filter(
Boolean
)
}
function getCachedImage(imageMd5?: string, imageDatName?: string): CachedImage | undefined {
for (const key of imageCacheKeys(imageMd5, imageDatName)) {
const cached = imageDataUrlCache.get(key)
if (cached) {
imageDataUrlCache.delete(key)
imageDataUrlCache.set(key, cached)
return cached
}
}
return undefined
}
function cacheImage(
imageMd5: string | undefined,
imageDatName: string | undefined,
cached: CachedImage
): void {
for (const key of imageCacheKeys(imageMd5, imageDatName)) {
imageDataUrlCache.delete(key)
imageDataUrlCache.set(key, cached)
}
while (imageDataUrlCache.size > MAX_IMAGE_CACHE_ENTRIES) {
const oldestKey = imageDataUrlCache.keys().next().value
if (!oldestKey) break
imageDataUrlCache.delete(oldestKey)
}
}
interface ImageBubbleProps {
imageMd5?: string
imageDatName?: string
@@ -16,11 +55,12 @@ export function ImageBubble({
isThumb = false,
onImageClick
}: ImageBubbleProps): JSX.Element {
const [imageUrl, setImageUrl] = useState<string | null>(null)
const initialCachedImage = getCachedImage(imageMd5, imageDatName)
const [imageUrl, setImageUrl] = useState<string | null>(initialCachedImage?.data || null)
const [loading, setLoading] = useState(false)
const [upgrading, setUpgrading] = useState(false)
const [error, setError] = useState<string | null>(null)
const [isThumbnail, setIsThumbnail] = useState(false)
const [isThumbnail, setIsThumbnail] = useState(Boolean(initialCachedImage?.isThumbnail))
const containerRef = useRef<HTMLDivElement>(null)
const loadImage = useCallback(async () => {
@@ -34,6 +74,10 @@ export function ImageBubble({
try {
const result = await window.api.getImage(imageMd5, imageDatName || isThumb, sessionId)
if (result.success && result.data?.startsWith('data:image/')) {
cacheImage(imageMd5, imageDatName, {
data: result.data,
isThumbnail: Boolean(result.isThumb)
})
setImageUrl(result.data)
setIsThumbnail(Boolean(result.isThumb))
setError(null)
@@ -92,6 +136,10 @@ export function ImageBubble({
force: true
})
if (result.success && result.data?.startsWith('data:image/')) {
cacheImage(imageMd5, imageDatName, {
data: result.data,
isThumbnail: Boolean(result.isThumb)
})
setImageUrl(result.data)
setIsThumbnail(Boolean(result.isThumb))
setError(null)
@@ -6,7 +6,6 @@ import { AiIcon, MoreIcon, RefreshIcon, SearchIcon } from './icons'
interface ChatHeaderProps {
contact: Contact
isGroupChat: boolean
dateRangeLabel: string
loadedCount: number
filteredCount: number
contentFilter: string
@@ -20,7 +19,6 @@ interface ChatHeaderProps {
export function ChatHeader({
contact,
isGroupChat,
dateRangeLabel,
loadedCount,
filteredCount,
contentFilter,
@@ -65,7 +63,6 @@ export function ChatHeader({
<h2>{displayName}</h2>
<div className="chat-title-meta">
<span>{typeLabel}</span>
<span>{dateRangeLabel}</span>
<span>{visibleCount} </span>
</div>
</div>
@@ -1,10 +1,13 @@
import React from 'react'
import { RefreshIcon } from './icons'
interface ChatStatusBarProps {
count: number
showAvatar: boolean
isAtLatest: boolean
isReloadingAvatars: boolean
onShowAvatarChange: (show: boolean) => void
onReloadAvatars: () => void
onJumpToLatest: () => void
}
@@ -12,7 +15,9 @@ export function ChatStatusBar({
count,
showAvatar,
isAtLatest,
isReloadingAvatars,
onShowAvatarChange,
onReloadAvatars,
onJumpToLatest
}: ChatStatusBarProps): React.ReactElement {
const jumpDisabled = isAtLatest || count === 0
@@ -29,6 +34,22 @@ export function ChatStatusBar({
/>
<span></span>
</label>
<span
className="chat-avatar-note"
title="头像或名称缺失时,请先在微信中进入该聊天,再尝试重新加载"
>
</span>
<button
type="button"
className="chat-avatar-reload"
onClick={onReloadAvatars}
disabled={isReloadingAvatars}
title="尝试重新加载当前聊天的头像和名称"
>
<RefreshIcon className={isReloadingAvatars ? 'is-spinning' : ''} />
<span>{isReloadingAvatars ? '加载中' : '重新加载头像'}</span>
</button>
<span className="chat-status-separator" aria-hidden />
<button type="button" onClick={onJumpToLatest} disabled={jumpDisabled}>
{jumpDisabled ? '已是最新消息' : '跳转到最新消息'}
@@ -57,7 +57,7 @@ export function MessageGroup({
)}
{!isMine && !shouldShowAvatar && <div className="message-avatar-spacer" aria-hidden />}
<div className="message-stack">
{!isMine && isGroupChat && shouldShowAvatar && (
{!isMine && isGroupChat && (
<div className="message-sender-name">{displayName}</div>
)}
{group.messages.map((message, index) => (
@@ -14,7 +14,7 @@ interface MessageListProps {
listRef: React.RefObject<HTMLDivElement | null>
bottomRef: React.RefObject<HTMLDivElement | null>
onScroll: (event: React.UIEvent<HTMLDivElement>) => void
onReachTop?: () => void
onReachTop?: () => Promise<void>
onImageClick: (imageUrl: string) => void
}
@@ -32,6 +32,9 @@ export function MessageList({
onImageClick
}: MessageListProps): React.ReactElement {
const groups = React.useMemo(() => buildMessageGroups(messages), [messages])
const groupsRef = React.useRef(groups)
const loadingOlderRef = React.useRef(false)
groupsRef.current = groups
// TanStack Virtual intentionally exposes mutable measurement methods.
// eslint-disable-next-line react-hooks/incompatible-library
const virtualizer = useVirtualizer({
@@ -45,7 +48,54 @@ export function MessageList({
const handleScroll = (event: React.UIEvent<HTMLDivElement>): void => {
onScroll(event)
if (event.currentTarget.scrollTop < 120) onReachTop?.()
const scrollElement = event.currentTarget
if (
scrollElement.scrollTop >= 48 ||
loadingOlderRef.current ||
isLoadingMessages ||
!onReachTop
) {
return
}
loadingOlderRef.current = true
const previousGroupCount = groups.length
const previousScrollTop = scrollElement.scrollTop
const previousScrollHeight = scrollElement.scrollHeight
const anchorMessageId = groups[0]?.messages[0]?.id
void (async () => {
try {
await onReachTop()
for (let frame = 0; frame < 8; frame += 1) {
if (groupsRef.current.length > previousGroupCount) break
await new Promise<void>((resolve) => window.requestAnimationFrame(() => resolve()))
}
if (!anchorMessageId) return
const anchorIndex = groupsRef.current.findIndex((group) =>
group.messages.some((message) => message.id === anchorMessageId)
)
if (anchorIndex <= 0) return
virtualizer.measure()
await new Promise<void>((resolve) => window.requestAnimationFrame(() => resolve()))
const addedHeight = scrollElement.scrollHeight - previousScrollHeight
if (addedHeight > 0) {
scrollElement.scrollTop = previousScrollTop + addedHeight
} else {
virtualizer.scrollToIndex(anchorIndex, { align: 'start' })
}
// Variable-height groups can finish measuring one frame later. Preserve
// the same message anchor after that final measurement as well.
await new Promise<void>((resolve) => window.requestAnimationFrame(() => resolve()))
if (scrollElement.scrollTop < 48) {
virtualizer.scrollToIndex(anchorIndex, { align: 'start' })
}
} finally {
window.setTimeout(() => {
loadingOlderRef.current = false
}, 250)
}
})()
}
return (
@@ -19,8 +19,6 @@ export interface ConversationSidebarProps {
onSearch: (keyword: string) => void
onContentFilter: (keyword: string) => void
width: number
dateRange: string
onDateRangeChange: (range: string) => void
selfInfo: SelfInfo | null
dbReady: boolean
onOpenSettings: () => void
@@ -37,8 +35,6 @@ export function ConversationSidebar({
onSelectContact,
onSearch,
width,
dateRange,
onDateRangeChange,
selfInfo,
dbReady,
onOpenSettings
@@ -83,9 +79,7 @@ export function ConversationSidebar({
<ConversationSidebarHeader
totalCount={contacts.length}
searchValue={searchTerm}
dateRange={dateRange}
onSearchChange={handleSearchChange}
onDateRangeChange={onDateRangeChange}
/>
<div ref={listRef} className="conversation-list" aria-label="会话列表">
<div className="conversation-virtual-content" style={{ height: `${virtualizer.getTotalSize()}px` }}>
@@ -1,21 +1,16 @@
import React from 'react'
import { ConversationSearch } from './ConversationSearch'
import { DateRangeSelector } from './DateRangeSelector'
interface ConversationSidebarHeaderProps {
totalCount: number
searchValue: string
dateRange: string
onSearchChange: (value: string) => void
onDateRangeChange: (range: string) => void
}
export function ConversationSidebarHeader({
totalCount,
searchValue,
dateRange,
onSearchChange,
onDateRangeChange
onSearchChange
}: ConversationSidebarHeaderProps): React.ReactElement {
return (
<div className="conversation-sidebar-header">
@@ -24,7 +19,6 @@ export function ConversationSidebarHeader({
<span>{totalCount} </span>
</div>
<ConversationSearch value={searchValue} onChange={onSearchChange} />
<DateRangeSelector value={dateRange} onChange={onDateRangeChange} />
</div>
)
}
@@ -1,31 +0,0 @@
import React from 'react'
interface DateRangeSelectorProps {
value: string
onChange: (range: string) => void
}
const DATE_RANGE_OPTIONS = [
{ key: 'today', label: '今天' },
{ key: 'yesterday', label: '昨日' },
{ key: '7', label: '7 天' },
{ key: '30', label: '30 天' },
{ key: 'all', label: '全部' }
]
export function DateRangeSelector({ value, onChange }: DateRangeSelectorProps): React.ReactElement {
return (
<div className="conversation-date-range" aria-label="时间范围">
{DATE_RANGE_OPTIONS.map((item) => (
<button
key={item.key}
type="button"
className={`conversation-date-range-button ${value === item.key ? 'active' : ''}`}
onClick={() => onChange(item.key)}
>
{item.label}
</button>
))}
</div>
)
}