fix(chat): repair search result sender info

This commit is contained in:
2977094657
2026-03-16 10:17:40 +08:00
parent 1f676254a9
commit 66a2b3224f
4 changed files with 740 additions and 148 deletions

View File

@@ -50,6 +50,21 @@
border-radius: inherit; border-radius: inherit;
} }
.avatar-loading {
width: 100%;
height: 100%;
display: flex;
align-items: center;
justify-content: center;
color: var(--text-tertiary, #999);
background-color: var(--bg-tertiary, #e0e0e0);
border-radius: inherit;
.avatar-loading-icon {
animation: avatar-spin 0.9s linear infinite;
}
}
/* Loading Skeleton */ /* Loading Skeleton */
.avatar-skeleton { .avatar-skeleton {
position: absolute; position: absolute;
@@ -76,4 +91,14 @@
background-position: -200% 0; background-position: -200% 0;
} }
} }
}
@keyframes avatar-spin {
0% {
transform: rotate(0deg);
}
100% {
transform: rotate(360deg);
}
}
}

View File

@@ -1,5 +1,5 @@
import React, { useState, useEffect, useRef, useMemo } from 'react' import React, { useState, useEffect, useRef, useMemo } from 'react'
import { User } from 'lucide-react' import { Loader2, User } from 'lucide-react'
import { avatarLoadQueue } from '../utils/AvatarLoadQueue' import { avatarLoadQueue } from '../utils/AvatarLoadQueue'
import './Avatar.scss' import './Avatar.scss'
@@ -13,6 +13,7 @@ interface AvatarProps {
shape?: 'circle' | 'square' | 'rounded' shape?: 'circle' | 'square' | 'rounded'
className?: string className?: string
lazy?: boolean lazy?: boolean
loading?: boolean
onClick?: () => void onClick?: () => void
} }
@@ -23,12 +24,14 @@ export const Avatar = React.memo(function Avatar({
shape = 'rounded', shape = 'rounded',
className = '', className = '',
lazy = true, lazy = true,
loading = false,
onClick onClick
}: AvatarProps) { }: AvatarProps) {
// 如果 URL 已在缓存中,则直接标记为已加载,不显示骨架屏和淡入动画 // 如果 URL 已在缓存中,则直接标记为已加载,不显示骨架屏和淡入动画
const isCached = useMemo(() => src ? loadedAvatarCache.has(src) : false, [src]) const isCached = useMemo(() => src ? loadedAvatarCache.has(src) : false, [src])
const isFailed = useMemo(() => src ? avatarLoadQueue.hasFailed(src) : false, [src])
const [imageLoaded, setImageLoaded] = useState(isCached) const [imageLoaded, setImageLoaded] = useState(isCached)
const [imageError, setImageError] = useState(false) const [imageError, setImageError] = useState(isFailed)
const [shouldLoad, setShouldLoad] = useState(!lazy || isCached) const [shouldLoad, setShouldLoad] = useState(!lazy || isCached)
const [isInQueue, setIsInQueue] = useState(false) const [isInQueue, setIsInQueue] = useState(false)
const imgRef = useRef<HTMLImageElement>(null) const imgRef = useRef<HTMLImageElement>(null)
@@ -42,7 +45,7 @@ export const Avatar = React.memo(function Avatar({
// Intersection Observer for lazy loading // Intersection Observer for lazy loading
useEffect(() => { useEffect(() => {
if (!lazy || shouldLoad || isInQueue || !src || !containerRef.current || isCached) return if (!lazy || shouldLoad || isInQueue || !src || !containerRef.current || isCached || imageError || isFailed) return
const observer = new IntersectionObserver( const observer = new IntersectionObserver(
(entries) => { (entries) => {
@@ -50,10 +53,11 @@ export const Avatar = React.memo(function Avatar({
if (entry.isIntersecting && !isInQueue) { if (entry.isIntersecting && !isInQueue) {
setIsInQueue(true) setIsInQueue(true)
avatarLoadQueue.enqueue(src).then(() => { avatarLoadQueue.enqueue(src).then(() => {
setImageError(false)
setShouldLoad(true) setShouldLoad(true)
}).catch(() => { }).catch(() => {
// 加载失败不要立刻显示错误,让浏览器渲染去报错 setImageError(true)
setShouldLoad(true) setShouldLoad(false)
}).finally(() => { }).finally(() => {
setIsInQueue(false) setIsInQueue(false)
}) })
@@ -67,14 +71,18 @@ export const Avatar = React.memo(function Avatar({
observer.observe(containerRef.current) observer.observe(containerRef.current)
return () => observer.disconnect() return () => observer.disconnect()
}, [src, lazy, shouldLoad, isInQueue, isCached]) }, [src, lazy, shouldLoad, isInQueue, isCached, imageError, isFailed])
// Reset state when src changes // Reset state when src changes
useEffect(() => { useEffect(() => {
const cached = src ? loadedAvatarCache.has(src) : false const cached = src ? loadedAvatarCache.has(src) : false
const failed = src ? avatarLoadQueue.hasFailed(src) : false
setImageLoaded(cached) setImageLoaded(cached)
setImageError(false) setImageError(failed)
if (lazy && !cached) { if (failed) {
setShouldLoad(false)
setIsInQueue(false)
} else if (lazy && !cached) {
setShouldLoad(false) setShouldLoad(false)
setIsInQueue(false) setIsInQueue(false)
} else { } else {
@@ -95,6 +103,7 @@ export const Avatar = React.memo(function Avatar({
} }
const hasValidUrl = !!src && !imageError && shouldLoad const hasValidUrl = !!src && !imageError && shouldLoad
const shouldShowLoadingPlaceholder = loading && !hasValidUrl && !imageError
return ( return (
<div <div
@@ -112,13 +121,30 @@ export const Avatar = React.memo(function Avatar({
alt={name || 'avatar'} alt={name || 'avatar'}
className={`avatar-image ${imageLoaded ? 'loaded' : ''} ${isCached ? 'instant' : ''}`} className={`avatar-image ${imageLoaded ? 'loaded' : ''} ${isCached ? 'instant' : ''}`}
onLoad={() => { onLoad={() => {
if (src) loadedAvatarCache.add(src) if (src) {
avatarLoadQueue.clearFailed(src)
loadedAvatarCache.add(src)
}
setImageLoaded(true) setImageLoaded(true)
setImageError(false)
}}
onError={() => {
if (src) {
avatarLoadQueue.markFailed(src)
loadedAvatarCache.delete(src)
}
setImageLoaded(false)
setImageError(true)
setShouldLoad(false)
}} }}
onError={() => setImageError(true)}
loading={lazy ? "lazy" : "eager"} loading={lazy ? "lazy" : "eager"}
referrerPolicy="no-referrer"
/> />
</> </>
) : shouldShowLoadingPlaceholder ? (
<div className="avatar-loading">
<Loader2 size="50%" className="avatar-loading-icon" />
</div>
) : ( ) : (
<div className="avatar-placeholder"> <div className="avatar-placeholder">
{name ? <span className="avatar-letter">{getAvatarLetter()}</span> : <User size="50%" />} {name ? <span className="avatar-letter">{getAvatarLetter()}</span> : <User size="50%" />}

View File

@@ -34,6 +34,88 @@ const SYSTEM_MESSAGE_TYPES = [
266287972401, // 拍一拍 266287972401, // 拍一拍
] ]
interface PendingInSessionSearchPayload {
sessionId: string
keyword: string
firstMsgTime: number
results: Message[]
}
function sortMessagesByCreateTimeDesc<T extends Pick<Message, 'createTime' | 'localId'>>(items: T[]): T[] {
return [...items].sort((a, b) => {
const timeDiff = (b.createTime || 0) - (a.createTime || 0)
if (timeDiff !== 0) return timeDiff
return (b.localId || 0) - (a.localId || 0)
})
}
function normalizeSearchIdentityText(value?: string | null): string | undefined {
const normalized = String(value || '').trim()
if (!normalized) return undefined
const lower = normalized.toLowerCase()
if (normalized === '未知' || lower === 'unknown' || lower === 'null' || lower === 'undefined') {
return undefined
}
if (lower.startsWith('unknown_sender_')) {
return undefined
}
return normalized
}
function normalizeSearchAvatarUrl(value?: string | null): string | undefined {
const normalized = String(value || '').trim()
if (!normalized) return undefined
const lower = normalized.toLowerCase()
if (lower === 'null' || lower === 'undefined') {
return undefined
}
return normalized
}
function isWxidLikeSearchIdentity(value?: string | null): boolean {
const normalized = String(value || '').trim().toLowerCase()
if (!normalized) return false
if (normalized.startsWith('wxid_')) return true
const suffixMatch = normalized.match(/^(.+)_([a-z0-9]{4})$/i)
return Boolean(suffixMatch && suffixMatch[1].startsWith('wxid_'))
}
function resolveSearchSenderDisplayName(
displayName?: string | null,
senderUsername?: string | null,
sessionId?: string | null
): string | undefined {
const normalizedDisplayName = normalizeSearchIdentityText(displayName)
if (!normalizedDisplayName) return undefined
const normalizedSenderUsername = normalizeSearchIdentityText(senderUsername)
const normalizedSessionId = normalizeSearchIdentityText(sessionId)
if (normalizedSessionId && normalizedDisplayName === normalizedSessionId) {
return undefined
}
if (isWxidLikeSearchIdentity(normalizedDisplayName)) {
return undefined
}
if (
normalizedSenderUsername &&
normalizedDisplayName === normalizedSenderUsername &&
isWxidLikeSearchIdentity(normalizedSenderUsername)
) {
return undefined
}
return normalizedDisplayName
}
function resolveSearchSenderUsernameFallback(value?: string | null): string | undefined {
const normalized = normalizeSearchIdentityText(value)
if (!normalized || isWxidLikeSearchIdentity(normalized)) {
return undefined
}
return normalized
}
interface XmlField { interface XmlField {
key: string; key: string;
value: string; value: string;
@@ -668,15 +750,19 @@ function ChatPage(props: ChatPageProps) {
// 会话内搜索 // 会话内搜索
const [showInSessionSearch, setShowInSessionSearch] = useState(false) const [showInSessionSearch, setShowInSessionSearch] = useState(false)
const [inSessionQuery, setInSessionQuery] = useState('') const [inSessionQuery, setInSessionQuery] = useState('')
const [inSessionResults, setInSessionResults] = useState<any[]>([]) const [inSessionResults, setInSessionResults] = useState<Message[]>([])
const [inSessionSearching, setInSessionSearching] = useState(false) const [inSessionSearching, setInSessionSearching] = useState(false)
const [inSessionEnriching, setInSessionEnriching] = useState(false)
const [inSessionSearchError, setInSessionSearchError] = useState<string | null>(null)
const inSessionSearchRef = useRef<HTMLInputElement>(null) const inSessionSearchRef = useRef<HTMLInputElement>(null)
// 全局消息搜索 // 全局消息搜索
const [showGlobalMsgSearch, setShowGlobalMsgSearch] = useState(false) const [showGlobalMsgSearch, setShowGlobalMsgSearch] = useState(false)
const [globalMsgQuery, setGlobalMsgQuery] = useState('') const [globalMsgQuery, setGlobalMsgQuery] = useState('')
const [globalMsgResults, setGlobalMsgResults] = useState<Message[]>([]) const [globalMsgResults, setGlobalMsgResults] = useState<Array<Message & { sessionId: string }>>([])
const [globalMsgSearching, setGlobalMsgSearching] = useState(false) const [globalMsgSearching, setGlobalMsgSearching] = useState(false)
const pendingInSessionSearchRef = useRef<{ keyword: string; firstMsgTime: number; results: any[] } | null>(null) const [globalMsgSearchError, setGlobalMsgSearchError] = useState<string | null>(null)
const pendingInSessionSearchRef = useRef<PendingInSessionSearchPayload | null>(null)
const pendingGlobalMsgSearchReplayRef = useRef<string | null>(null)
// 自定义删除确认对话框 // 自定义删除确认对话框
const [deleteConfirm, setDeleteConfirm] = useState<{ const [deleteConfirm, setDeleteConfirm] = useState<{
@@ -2574,51 +2660,19 @@ function ChatPage(props: ChatPageProps) {
setIsSessionSwitching(false) setIsSessionSwitching(false)
// 处理从全局搜索跳转过来的情况 // 处理从全局搜索跳转过来的情况
if (pendingInSessionSearchRef.current) { const pendingSearch = pendingInSessionSearchRef.current
const { keyword, firstMsgTime, results } = pendingInSessionSearchRef.current if (pendingSearch?.sessionId === sessionId) {
pendingInSessionSearchRef.current = null pendingInSessionSearchRef.current = null
void applyPendingInSessionSearch(sessionId, pendingSearch, options.switchRequestSeq)
setShowInSessionSearch(true)
setInSessionQuery(keyword)
if (firstMsgTime > 0) {
handleJumpDateSelect(new Date(firstMsgTime * 1000))
}
// 先获取完整消息,再补充发送者信息
const sid = currentSessionId
if (sid) {
Promise.all(
results.map(async (msg: any) => {
try {
const full = await window.electronAPI.chat.getMessages(sid, 0, 3, msg.createTime, msg.createTime, false)
const found = full?.messages?.find((m: any) => m.localId === msg.localId) || msg
if (found.senderUsername) {
const contact = await window.electronAPI.chat.getContact(found.senderUsername)
if (contact) {
found.senderDisplayName = contact.remark || contact.nickName || found.senderUsername
}
const avatarData = await window.electronAPI.chat.getContactAvatar(found.senderUsername)
if (avatarData?.avatarUrl) {
found.senderAvatarUrl = avatarData.avatarUrl
}
}
return found
} catch {
return msg
}
})
).then(enriched => setInSessionResults(enriched))
}
} }
} }
} }
} }
} }
const handleJumpDateSelect = useCallback((date: Date) => { const handleJumpDateSelect = useCallback((date: Date, options: { sessionId?: string; switchRequestSeq?: number } = {}) => {
if (!currentSessionId) return const targetSessionId = String(options.sessionId || currentSessionRef.current || currentSessionId || '').trim()
if (!targetSessionId) return
const targetDate = new Date(date) const targetDate = new Date(date)
const end = Math.floor(targetDate.setHours(23, 59, 59, 999) / 1000) const end = Math.floor(targetDate.setHours(23, 59, 59, 999) / 1000)
// 日期跳转采用“锚点定位”而非“当天过滤”: // 日期跳转采用“锚点定位”而非“当天过滤”:
@@ -2628,9 +2682,377 @@ function ChatPage(props: ChatPageProps) {
setJumpStartTime(0) setJumpStartTime(0)
setJumpEndTime(end) setJumpEndTime(end)
setShowJumpPopover(false) setShowJumpPopover(false)
void loadMessages(currentSessionId, 0, 0, end, false) void loadMessages(targetSessionId, 0, 0, end, false, {
switchRequestSeq: options.switchRequestSeq
})
}, [currentSessionId, loadMessages]) }, [currentSessionId, loadMessages])
const cancelInSessionSearchTasks = useCallback(() => {
inSessionSearchGenRef.current += 1
if (inSessionSearchTimerRef.current) {
clearTimeout(inSessionSearchTimerRef.current)
inSessionSearchTimerRef.current = null
}
setInSessionSearching(false)
setInSessionEnriching(false)
}, [])
const resolveSearchSessionContext = useCallback((sessionId?: string) => {
const normalizedSessionId = String(sessionId || currentSessionRef.current || currentSessionId || '').trim()
const currentSearchSession = normalizedSessionId && Array.isArray(sessions)
? sessions.find(session => session.username === normalizedSessionId)
: undefined
const resolvedSession = currentSearchSession
? (
standaloneSessionWindow &&
normalizedInitialSessionId &&
currentSearchSession.username === normalizedInitialSessionId
? {
...currentSearchSession,
displayName: currentSearchSession.displayName || fallbackDisplayName || currentSearchSession.username,
avatarUrl: currentSearchSession.avatarUrl || fallbackAvatarUrl || undefined
}
: currentSearchSession
)
: (
normalizedSessionId
? {
username: normalizedSessionId,
displayName: fallbackDisplayName || normalizedSessionId,
avatarUrl: fallbackAvatarUrl || undefined
} as ChatSession
: undefined
)
const isGroupSearchSession = Boolean(
resolvedSession && (
isGroupChatSession(resolvedSession.username) ||
(
standaloneSessionWindow &&
resolvedSession.username === normalizedInitialSessionId &&
normalizedStandaloneInitialContactType === 'group'
)
)
)
const isDirectSearchSession = Boolean(
resolvedSession &&
isSingleContactSession(resolvedSession.username) &&
!isGroupSearchSession
)
return {
normalizedSessionId,
resolvedSession,
isDirectSearchSession,
isGroupSearchSession,
resolvedSessionDisplayName: normalizeSearchIdentityText(resolvedSession?.displayName) || normalizedSessionId || undefined,
resolvedSessionAvatarUrl: normalizeSearchAvatarUrl(resolvedSession?.avatarUrl)
}
}, [
currentSessionId,
fallbackAvatarUrl,
fallbackDisplayName,
normalizedInitialSessionId,
normalizedStandaloneInitialContactType,
sessions,
standaloneSessionWindow,
isGroupChatSession
])
const hydrateInSessionSearchResults = useCallback((rawMessages: Message[], sessionId?: string) => {
const sortedMessages = sortMessagesByCreateTimeDesc(rawMessages || [])
if (sortedMessages.length === 0) return []
const {
normalizedSessionId,
isDirectSearchSession,
resolvedSessionDisplayName,
resolvedSessionAvatarUrl
} = resolveSearchSessionContext(sessionId)
const resolvedSessionUsernameFallback = resolveSearchSenderUsernameFallback(normalizedSessionId)
return sortedMessages.map((message) => {
const senderUsername = normalizeSearchIdentityText(message.senderUsername) || message.senderUsername
const senderDisplayName = resolveSearchSenderDisplayName(
message.senderDisplayName,
senderUsername,
normalizedSessionId
)
const senderUsernameFallback = resolveSearchSenderUsernameFallback(senderUsername)
const senderAvatarUrl = normalizeSearchAvatarUrl(message.senderAvatarUrl)
const nextSenderDisplayName = message.isSend === 1
? (senderDisplayName || '我')
: (
senderDisplayName ||
(isDirectSearchSession ? resolvedSessionDisplayName : undefined) ||
senderUsernameFallback ||
(isDirectSearchSession ? resolvedSessionUsernameFallback : undefined) ||
'未知'
)
const nextSenderAvatarUrl = message.isSend === 1
? (senderAvatarUrl || myAvatarUrl)
: (senderAvatarUrl || (isDirectSearchSession ? resolvedSessionAvatarUrl : undefined))
if (
senderUsername === message.senderUsername &&
nextSenderDisplayName === message.senderDisplayName &&
nextSenderAvatarUrl === message.senderAvatarUrl
) {
return message
}
return {
...message,
senderUsername,
senderDisplayName: nextSenderDisplayName,
senderAvatarUrl: nextSenderAvatarUrl
}
})
}, [currentSessionId, myAvatarUrl, resolveSearchSessionContext])
const enrichMessagesWithSenderProfiles = useCallback(async (rawMessages: Message[], sessionId?: string) => {
let messages = hydrateInSessionSearchResults(rawMessages, sessionId)
if (messages.length === 0) return []
const sessionContext = resolveSearchSessionContext(sessionId)
const { normalizedSessionId, isDirectSearchSession, isGroupSearchSession } = sessionContext
let resolvedSessionDisplayName = sessionContext.resolvedSessionDisplayName
let resolvedSessionAvatarUrl = sessionContext.resolvedSessionAvatarUrl
if (
normalizedSessionId &&
isDirectSearchSession &&
(
!resolvedSessionAvatarUrl ||
!resolvedSessionDisplayName ||
resolvedSessionDisplayName === normalizedSessionId
)
) {
try {
const result = await window.electronAPI.chat.enrichSessionsContactInfo([normalizedSessionId])
const profile = result.success && result.contacts ? result.contacts[normalizedSessionId] : undefined
const profileDisplayName = resolveSearchSenderDisplayName(
profile?.displayName,
normalizedSessionId,
normalizedSessionId
)
const profileAvatarUrl = normalizeSearchAvatarUrl(profile?.avatarUrl)
if (profileDisplayName) {
resolvedSessionDisplayName = profileDisplayName
}
if (profileAvatarUrl) {
resolvedSessionAvatarUrl = profileAvatarUrl
}
if (profileDisplayName || profileAvatarUrl) {
messages = messages.map((message) => {
if (message.isSend === 1) return message
const preservedDisplayName = resolveSearchSenderDisplayName(
message.senderDisplayName,
message.senderUsername,
normalizedSessionId
)
return {
...message,
senderDisplayName: preservedDisplayName ||
profileDisplayName ||
resolvedSessionDisplayName ||
resolveSearchSenderUsernameFallback(message.senderUsername) ||
message.senderDisplayName,
senderAvatarUrl: normalizeSearchAvatarUrl(message.senderAvatarUrl) || profileAvatarUrl || resolvedSessionAvatarUrl || message.senderAvatarUrl
}
})
}
} catch {
// ignore session profile enrichment errors and keep raw search results usable
}
}
if (normalizedSessionId && isGroupSearchSession) {
const missingSenderMessages = messages.filter((message) => {
if (message.localId <= 0) return false
if (message.isSend === 1) return false
return !normalizeSearchIdentityText(message.senderUsername)
})
if (missingSenderMessages.length > 0) {
const messageByLocalId = new Map<number, Message>()
for (let index = 0; index < missingSenderMessages.length; index += 8) {
const batch = missingSenderMessages.slice(index, index + 8)
const detailResults = await Promise.allSettled(
batch.map(async (message) => {
const result = await window.electronAPI.chat.getMessage(normalizedSessionId, message.localId)
if (!result.success || !result.message) return null
return {
localId: message.localId,
message: hydrateInSessionSearchResults([{
...message,
...result.message,
parsedContent: message.parsedContent || result.message.parsedContent,
rawContent: message.rawContent || result.message.rawContent,
content: message.content || result.message.content
} as Message], normalizedSessionId)[0]
}
})
)
for (const detail of detailResults) {
if (detail.status !== 'fulfilled' || !detail.value?.message) continue
messageByLocalId.set(detail.value.localId, detail.value.message)
}
}
if (messageByLocalId.size > 0) {
messages = messages.map(message => messageByLocalId.get(message.localId) || message)
}
}
}
const profileMap = new Map<string, { avatarUrl?: string; displayName?: string }>()
const pendingLoads: Array<Promise<void>> = []
const missingUsernames: string[] = []
const usernames = [...new Set(
messages
.map((message) => normalizeSearchIdentityText(message.senderUsername))
.filter((username): username is string => Boolean(username))
)]
for (const username of usernames) {
const cached = senderAvatarCache.get(username)
if (cached) {
profileMap.set(username, cached)
continue
}
const pending = senderAvatarLoading.get(username)
if (pending) {
pendingLoads.push(
pending.then((profile) => {
if (profile) {
profileMap.set(username, profile)
}
}).catch(() => {})
)
continue
}
missingUsernames.push(username)
}
if (pendingLoads.length > 0) {
await Promise.allSettled(pendingLoads)
}
if (missingUsernames.length > 0) {
try {
const result = await window.electronAPI.chat.enrichSessionsContactInfo(missingUsernames)
if (result.success && result.contacts) {
for (const [username, profile] of Object.entries(result.contacts)) {
const normalizedProfile = {
avatarUrl: profile.avatarUrl,
displayName: profile.displayName
}
profileMap.set(username, normalizedProfile)
senderAvatarCache.set(username, normalizedProfile)
}
}
} catch {
// ignore sender enrichment errors and keep raw search results usable
}
}
return messages.map((message) => {
const sender = normalizeSearchIdentityText(message.senderUsername)
const profile = sender ? profileMap.get(sender) : undefined
const profileDisplayName = resolveSearchSenderDisplayName(
profile?.displayName,
sender,
normalizedSessionId
)
const currentSenderDisplayName = resolveSearchSenderDisplayName(
message.senderDisplayName,
sender,
normalizedSessionId
)
const senderUsernameFallback = resolveSearchSenderUsernameFallback(sender)
const sessionUsernameFallback = resolveSearchSenderUsernameFallback(normalizedSessionId)
const currentSenderAvatarUrl = normalizeSearchAvatarUrl(message.senderAvatarUrl)
const nextSenderDisplayName = message.isSend === 1
? (currentSenderDisplayName || profileDisplayName || '我')
: (
profileDisplayName ||
currentSenderDisplayName ||
(isDirectSearchSession ? resolvedSessionDisplayName : undefined) ||
senderUsernameFallback ||
(isDirectSearchSession ? sessionUsernameFallback : undefined) ||
'未知'
)
const nextSenderAvatarUrl = message.isSend === 1
? (currentSenderAvatarUrl || myAvatarUrl || normalizeSearchAvatarUrl(profile?.avatarUrl))
: (
currentSenderAvatarUrl ||
normalizeSearchAvatarUrl(profile?.avatarUrl) ||
(isDirectSearchSession ? resolvedSessionAvatarUrl : undefined)
)
if (
sender === message.senderUsername &&
nextSenderDisplayName === message.senderDisplayName &&
nextSenderAvatarUrl === message.senderAvatarUrl
) {
return message
}
return {
...message,
senderUsername: sender || message.senderUsername,
senderDisplayName: nextSenderDisplayName,
senderAvatarUrl: nextSenderAvatarUrl
}
})
}, [
currentSessionId,
hydrateInSessionSearchResults,
myAvatarUrl,
resolveSearchSessionContext
])
const applyPendingInSessionSearch = useCallback(async (
sessionId: string,
payload: PendingInSessionSearchPayload,
switchRequestSeq?: number
) => {
const normalizedSessionId = String(sessionId || '').trim()
if (!normalizedSessionId) return
if (payload.sessionId !== normalizedSessionId) return
if (switchRequestSeq && switchRequestSeq !== sessionSwitchRequestSeqRef.current) return
if (currentSessionRef.current !== normalizedSessionId) return
const immediateResults = hydrateInSessionSearchResults(payload.results || [], normalizedSessionId)
setShowInSessionSearch(true)
setInSessionQuery(payload.keyword)
setInSessionSearchError(null)
setInSessionResults(immediateResults)
if (payload.firstMsgTime > 0) {
handleJumpDateSelect(new Date(payload.firstMsgTime * 1000), {
sessionId: normalizedSessionId,
switchRequestSeq
})
}
setInSessionEnriching(true)
void enrichMessagesWithSenderProfiles(immediateResults, normalizedSessionId).then((enrichedResults) => {
if (switchRequestSeq && switchRequestSeq !== sessionSwitchRequestSeqRef.current) return
if (currentSessionRef.current !== normalizedSessionId) return
setInSessionResults(enrichedResults)
}).catch((error) => {
console.warn('[InSessionSearch] 恢复全局搜索结果发送者信息失败:', error)
}).finally(() => {
if (switchRequestSeq && switchRequestSeq !== sessionSwitchRequestSeqRef.current) return
if (currentSessionRef.current !== normalizedSessionId) return
setInSessionEnriching(false)
})
}, [enrichMessagesWithSenderProfiles, handleJumpDateSelect, hydrateInSessionSearchResults])
// 加载更晚的消息 // 加载更晚的消息
const loadLaterMessages = useCallback(async () => { const loadLaterMessages = useCallback(async () => {
if (!currentSessionId || isLoadingMore || isLoadingMessages || messages.length === 0) return if (!currentSessionId || isLoadingMore || isLoadingMessages || messages.length === 0) return
@@ -2696,12 +3118,19 @@ function ChatPage(props: ChatPageProps) {
if (!normalizedSessionId || (!options.force && normalizedSessionId === currentSessionId)) return if (!normalizedSessionId || (!options.force && normalizedSessionId === currentSessionId)) return
const switchRequestSeq = sessionSwitchRequestSeqRef.current + 1 const switchRequestSeq = sessionSwitchRequestSeqRef.current + 1
sessionSwitchRequestSeqRef.current = switchRequestSeq sessionSwitchRequestSeqRef.current = switchRequestSeq
currentSessionRef.current = normalizedSessionId
const pendingSearch = pendingInSessionSearchRef.current
const shouldPreservePendingSearch = pendingSearch?.sessionId === normalizedSessionId
cancelInSessionSearchTasks()
// 清空会话内搜索状态(除非是从全局搜索跳转过来) // 清空会话内搜索状态(除非是从全局搜索跳转过来)
if (!pendingInSessionSearchRef.current) { if (!shouldPreservePendingSearch) {
pendingInSessionSearchRef.current = null
setShowInSessionSearch(false) setShowInSessionSearch(false)
setInSessionQuery('') setInSessionQuery('')
setInSessionResults([]) setInSessionResults([])
setInSessionSearchError(null)
} }
setCurrentSession(normalizedSessionId, { preserveMessages: false }) setCurrentSession(normalizedSessionId, { preserveMessages: false })
@@ -2714,41 +3143,9 @@ function ChatPage(props: ChatPageProps) {
setIsSessionSwitching(false) setIsSessionSwitching(false)
// 处理从全局搜索跳转过来的情况 // 处理从全局搜索跳转过来的情况
if (pendingInSessionSearchRef.current) { if (pendingSearch?.sessionId === normalizedSessionId) {
const { keyword, firstMsgTime, results } = pendingInSessionSearchRef.current
pendingInSessionSearchRef.current = null pendingInSessionSearchRef.current = null
void applyPendingInSessionSearch(normalizedSessionId, pendingSearch, switchRequestSeq)
setShowInSessionSearch(true)
setInSessionQuery(keyword)
if (firstMsgTime > 0) {
handleJumpDateSelect(new Date(firstMsgTime * 1000))
}
// 先获取完整消息,再补充发送者信息
const sid = normalizedSessionId
Promise.all(
results.map(async (msg: any) => {
try {
const full = await window.electronAPI.chat.getMessages(sid, 0, 3, msg.createTime, msg.createTime, false)
const found = full?.messages?.find((m: any) => m.localId === msg.localId) || msg
if (found.senderUsername) {
const contact = await window.electronAPI.chat.getContact(found.senderUsername)
if (contact) {
found.senderDisplayName = contact.remark || contact.nickName || found.senderUsername
}
const avatarData = await window.electronAPI.chat.getContactAvatar(found.senderUsername)
if (avatarData?.avatarUrl) {
found.senderAvatarUrl = avatarData.avatarUrl
}
}
return found
} catch {
return msg
}
})
).then(enriched => setInSessionResults(enriched))
} }
void refreshSessionIncrementally(normalizedSessionId, switchRequestSeq) void refreshSessionIncrementally(normalizedSessionId, switchRequestSeq)
@@ -2786,7 +3183,9 @@ function ChatPage(props: ChatPageProps) {
restoreSessionWindowCache, restoreSessionWindowCache,
refreshSessionIncrementally, refreshSessionIncrementally,
hydrateSessionPreview, hydrateSessionPreview,
loadMessages loadMessages,
cancelInSessionSearchTasks,
applyPendingInSessionSearch
]) ])
// 选择会话 // 选择会话
@@ -2815,12 +3214,16 @@ function ChatPage(props: ChatPageProps) {
const handleInSessionSearch = useCallback(async (keyword: string) => { const handleInSessionSearch = useCallback(async (keyword: string) => {
setInSessionQuery(keyword) setInSessionQuery(keyword)
if (inSessionSearchTimerRef.current) clearTimeout(inSessionSearchTimerRef.current) if (inSessionSearchTimerRef.current) clearTimeout(inSessionSearchTimerRef.current)
inSessionSearchTimerRef.current = null
inSessionSearchGenRef.current += 1 inSessionSearchGenRef.current += 1
if (!keyword.trim() || !currentSessionId) { if (!keyword.trim() || !currentSessionId) {
setInSessionResults([]) setInSessionResults([])
setInSessionSearchError(null)
setInSessionSearching(false) setInSessionSearching(false)
setInSessionEnriching(false)
return return
} }
setInSessionSearchError(null)
const gen = inSessionSearchGenRef.current const gen = inSessionSearchGenRef.current
const sid = currentSessionId const sid = currentSessionId
inSessionSearchTimerRef.current = setTimeout(async () => { inSessionSearchTimerRef.current = setTimeout(async () => {
@@ -2828,89 +3231,127 @@ function ChatPage(props: ChatPageProps) {
setInSessionSearching(true) setInSessionSearching(true)
try { try {
const res = await window.electronAPI.chat.searchMessages(keyword.trim(), sid, 50, 0) const res = await window.electronAPI.chat.searchMessages(keyword.trim(), sid, 50, 0)
if (gen !== inSessionSearchGenRef.current) return if (!res?.success) {
const messages = res?.messages || [] throw new Error(res?.error || '搜索失败')
}
if (gen !== inSessionSearchGenRef.current || currentSessionRef.current !== sid) return
const messages = hydrateInSessionSearchResults(res?.messages || [], sid)
setInSessionResults(messages)
setInSessionSearchError(null)
// 补充联系人信息 setInSessionEnriching(true)
const enriched = await Promise.all( void enrichMessagesWithSenderProfiles(messages, sid).then((enriched) => {
messages.map(async (msg: any) => { if (gen !== inSessionSearchGenRef.current || currentSessionRef.current !== sid) return
if (msg.senderUsername) { setInSessionResults(enriched)
try { }).catch((error) => {
const contact = await window.electronAPI.chat.getContact(msg.senderUsername) console.warn('[InSessionSearch] 补充发送者信息失败:', error)
if (contact) { }).finally(() => {
msg.senderDisplayName = contact.remark || contact.nickName || msg.senderUsername if (gen !== inSessionSearchGenRef.current || currentSessionRef.current !== sid) return
} setInSessionEnriching(false)
const avatarData = await window.electronAPI.chat.getContactAvatar(msg.senderUsername) })
if (avatarData?.avatarUrl) { } catch (error) {
msg.senderAvatarUrl = avatarData.avatarUrl if (gen !== inSessionSearchGenRef.current || currentSessionRef.current !== sid) return
}
} catch {}
}
return msg
})
)
if (gen !== inSessionSearchGenRef.current) return
console.log('补充后:', enriched[0])
setInSessionResults(enriched)
} catch {
if (gen !== inSessionSearchGenRef.current) return
setInSessionResults([]) setInSessionResults([])
setInSessionSearchError(error instanceof Error ? error.message : String(error))
setInSessionEnriching(false)
} finally { } finally {
if (gen === inSessionSearchGenRef.current) setInSessionSearching(false) if (gen === inSessionSearchGenRef.current) setInSessionSearching(false)
} }
}, 500) }, 500)
}, [currentSessionId]) }, [currentSessionId, enrichMessagesWithSenderProfiles, hydrateInSessionSearchResults])
const handleToggleInSessionSearch = useCallback(() => { const handleToggleInSessionSearch = useCallback(() => {
setShowInSessionSearch(v => { setShowInSessionSearch(v => {
if (v) { if (v) {
inSessionSearchGenRef.current += 1 cancelInSessionSearchTasks()
if (inSessionSearchTimerRef.current) clearTimeout(inSessionSearchTimerRef.current)
setInSessionQuery('') setInSessionQuery('')
setInSessionResults([]) setInSessionResults([])
setInSessionSearching(false) setInSessionSearchError(null)
} else { } else {
setTimeout(() => inSessionSearchRef.current?.focus(), 50) setTimeout(() => inSessionSearchRef.current?.focus(), 50)
} }
return !v return !v
}) })
}, []) }, [cancelInSessionSearchTasks])
// 全局消息搜索 // 全局消息搜索
const globalMsgSearchTimerRef = useRef<ReturnType<typeof setTimeout> | null>(null) const globalMsgSearchTimerRef = useRef<ReturnType<typeof setTimeout> | null>(null)
const globalMsgSearchGenRef = useRef(0) const globalMsgSearchGenRef = useRef(0)
const handleGlobalMsgSearch = useCallback(async (keyword: string) => { const handleGlobalMsgSearch = useCallback(async (keyword: string) => {
const normalizedKeyword = keyword.trim()
setGlobalMsgQuery(keyword) setGlobalMsgQuery(keyword)
if (globalMsgSearchTimerRef.current) clearTimeout(globalMsgSearchTimerRef.current) if (globalMsgSearchTimerRef.current) clearTimeout(globalMsgSearchTimerRef.current)
globalMsgSearchTimerRef.current = null
globalMsgSearchGenRef.current += 1 globalMsgSearchGenRef.current += 1
if (!keyword.trim()) { if (!normalizedKeyword) {
pendingGlobalMsgSearchReplayRef.current = null
setGlobalMsgResults([]) setGlobalMsgResults([])
setGlobalMsgSearchError(null)
setShowGlobalMsgSearch(false) setShowGlobalMsgSearch(false)
setGlobalMsgSearching(false) setGlobalMsgSearching(false)
return return
} }
setShowGlobalMsgSearch(true) setShowGlobalMsgSearch(true)
setGlobalMsgSearchError(null)
const sessionList = Array.isArray(sessionsRef.current) ? sessionsRef.current.filter((session) => String(session.username || '').trim()) : []
if (!isConnectedRef.current || sessionList.length === 0) {
pendingGlobalMsgSearchReplayRef.current = normalizedKeyword
setGlobalMsgResults([])
setGlobalMsgSearchError(null)
setGlobalMsgSearching(false)
return
}
pendingGlobalMsgSearchReplayRef.current = null
const gen = globalMsgSearchGenRef.current const gen = globalMsgSearchGenRef.current
globalMsgSearchTimerRef.current = setTimeout(async () => { globalMsgSearchTimerRef.current = setTimeout(async () => {
if (gen !== globalMsgSearchGenRef.current) return if (gen !== globalMsgSearchGenRef.current) return
setGlobalMsgSearching(true) setGlobalMsgSearching(true)
try { try {
const results: Array<Message & { sessionId: string }> = [] const results: Array<Message & { sessionId: string }> = []
for (const session of sessions) { const concurrency = 6
const res = await window.electronAPI.chat.searchMessages(keyword.trim(), session.username, 10, 0)
if (res?.messages) { for (let index = 0; index < sessionList.length; index += concurrency) {
results.push(...res.messages.map(msg => ({ ...msg, sessionId: session.username }))) const chunk = sessionList.slice(index, index + concurrency)
const chunkResults = await Promise.allSettled(
chunk.map(async (session) => {
const res = await window.electronAPI.chat.searchMessages(normalizedKeyword, session.username, 10, 0)
if (!res?.success) {
throw new Error(res?.error || `搜索失败: ${session.username}`)
}
if (!res?.messages?.length) return []
return res.messages.map((msg) => ({ ...msg, sessionId: session.username }))
})
)
if (gen !== globalMsgSearchGenRef.current) return
for (const item of chunkResults) {
if (item.status === 'rejected') {
throw item.reason instanceof Error ? item.reason : new Error(String(item.reason))
}
if (item.value.length > 0) {
results.push(...item.value)
}
} }
} }
results.sort((a, b) => {
const timeDiff = (b.createTime || 0) - (a.createTime || 0)
if (timeDiff !== 0) return timeDiff
return (b.localId || 0) - (a.localId || 0)
})
if (gen !== globalMsgSearchGenRef.current) return if (gen !== globalMsgSearchGenRef.current) return
setGlobalMsgResults(results as any) setGlobalMsgResults(results)
} catch { setGlobalMsgSearchError(null)
} catch (error) {
if (gen !== globalMsgSearchGenRef.current) return if (gen !== globalMsgSearchGenRef.current) return
setGlobalMsgResults([]) setGlobalMsgResults([])
setGlobalMsgSearchError(error instanceof Error ? error.message : String(error))
} finally { } finally {
if (gen === globalMsgSearchGenRef.current) setGlobalMsgSearching(false) if (gen === globalMsgSearchGenRef.current) setGlobalMsgSearching(false)
setGlobalMsgSearching(false)
} }
}, 500) }, 500)
}, []) }, [])
@@ -2918,11 +3359,13 @@ function ChatPage(props: ChatPageProps) {
const handleCloseGlobalMsgSearch = useCallback(() => { const handleCloseGlobalMsgSearch = useCallback(() => {
globalMsgSearchGenRef.current += 1 globalMsgSearchGenRef.current += 1
if (globalMsgSearchTimerRef.current) clearTimeout(globalMsgSearchTimerRef.current) if (globalMsgSearchTimerRef.current) clearTimeout(globalMsgSearchTimerRef.current)
globalMsgSearchTimerRef.current = null
pendingGlobalMsgSearchReplayRef.current = null
setShowGlobalMsgSearch(false) setShowGlobalMsgSearch(false)
setGlobalMsgQuery('') setGlobalMsgQuery('')
setGlobalMsgResults([]) setGlobalMsgResults([])
setGlobalMsgSearchError(null)
setGlobalMsgSearching(false) setGlobalMsgSearching(false)
if (globalMsgSearchTimerRef.current) clearTimeout(globalMsgSearchTimerRef.current)
}, []) }, [])
// 滚动加载更多 + 显示/隐藏回到底部按钮(优化:节流,避免频繁执行) // 滚动加载更多 + 显示/隐藏回到底部按钮(优化:节流,避免频繁执行)
@@ -3205,6 +3648,28 @@ function ChatPage(props: ChatPageProps) {
isConnectedRef.current = isConnected isConnectedRef.current = isConnected
}, [isConnected]) }, [isConnected])
useEffect(() => {
const replayKeyword = pendingGlobalMsgSearchReplayRef.current
if (!replayKeyword || !isConnected || sessions.length === 0) return
pendingGlobalMsgSearchReplayRef.current = null
void handleGlobalMsgSearch(replayKeyword)
}, [isConnected, sessions.length, handleGlobalMsgSearch])
useEffect(() => {
return () => {
inSessionSearchGenRef.current += 1
if (inSessionSearchTimerRef.current) {
clearTimeout(inSessionSearchTimerRef.current)
inSessionSearchTimerRef.current = null
}
globalMsgSearchGenRef.current += 1
if (globalMsgSearchTimerRef.current) {
clearTimeout(globalMsgSearchTimerRef.current)
globalMsgSearchTimerRef.current = null
}
}
}, [])
useEffect(() => { useEffect(() => {
searchKeywordRef.current = searchKeyword searchKeywordRef.current = searchKeyword
}, [searchKeyword]) }, [searchKeyword])
@@ -4284,6 +4749,11 @@ function ChatPage(props: ChatPageProps) {
<Loader2 className="spin" size={20} /> <Loader2 className="spin" size={20} />
<span>...</span> <span>...</span>
</div> </div>
) : globalMsgSearchError ? (
<div className="no-results">
<AlertCircle size={32} />
<p>{globalMsgSearchError}</p>
</div>
) : globalMsgResults.length > 0 ? ( ) : globalMsgResults.length > 0 ? (
<> <>
<div className="search-section-header"></div> <div className="search-section-header"></div>
@@ -4306,11 +4776,12 @@ function ChatPage(props: ChatPageProps) {
onClick={() => { onClick={() => {
if (session) { if (session) {
pendingInSessionSearchRef.current = { pendingInSessionSearchRef.current = {
sessionId,
keyword: globalMsgQuery, keyword: globalMsgQuery,
firstMsgTime: firstMsg.createTime || 0, firstMsgTime: firstMsg.createTime || 0,
results: messages results: messages
}; }
handleSelectSession(session); handleSelectSession(session)
} }
}} }}
> >
@@ -4626,40 +5097,74 @@ function ChatPage(props: ChatPageProps) {
</div> </div>
{inSessionQuery && ( {inSessionQuery && (
<div className="search-result-header"> <div className="search-result-header">
{inSessionSearching ? '搜索中...' : `找到 ${inSessionResults.length} 条结果`} {inSessionSearching
? '搜索中...'
: inSessionSearchError
? '搜索失败'
: `找到 ${inSessionResults.length} 条结果`}
</div>
)}
{inSessionQuery && !inSessionSearching && inSessionSearchError && (
<div className="no-results">
<AlertCircle size={32} />
<p>{inSessionSearchError}</p>
</div> </div>
)} )}
{inSessionResults.length > 0 && ( {inSessionResults.length > 0 && (
<div className="in-session-results"> <div className="in-session-results">
{inSessionResults.map((msg, i) => { {inSessionResults.map((msg, i) => {
const msgData = msg as any; const resolvedSenderDisplayName = resolveSearchSenderDisplayName(
const senderName = msgData.senderDisplayName || msgData.senderUsername || '未知'; msg.senderDisplayName,
const senderAvatar = msgData.senderAvatarUrl; msg.senderUsername,
currentSessionId
)
const resolvedSenderUsername = resolveSearchSenderUsernameFallback(msg.senderUsername)
const resolvedSenderAvatarUrl = normalizeSearchAvatarUrl(msg.senderAvatarUrl)
const resolvedCurrentSessionName = normalizeSearchIdentityText(currentSession?.displayName) ||
resolveSearchSenderUsernameFallback(currentSession?.username) ||
resolveSearchSenderUsernameFallback(currentSessionId)
const senderName = resolvedSenderDisplayName || (
msg.isSend === 1
? '我'
: (isCurrentSessionPrivateSnsSupported
? resolvedCurrentSessionName || (inSessionEnriching ? '加载中...' : '未知')
: resolvedSenderUsername || (inSessionEnriching ? '加载中...' : '未知成员'))
)
const senderAvatar = resolvedSenderAvatarUrl || (
msg.isSend === 1
? myAvatarUrl
: (isCurrentSessionPrivateSnsSupported ? normalizeSearchAvatarUrl(currentSession?.avatarUrl) : undefined)
)
const senderAvatarLoading = inSessionEnriching && !senderAvatar
const previewText = (msg.parsedContent || msg.content || '').slice(0, 80)
const displayTime = msg.createTime
? new Date(msg.createTime * 1000).toLocaleString('zh-CN', { month: '2-digit', day: '2-digit', hour: '2-digit', minute: '2-digit' })
: ''
return ( return (
<div key={i} className="result-item" onClick={() => { <div key={i} className="result-item" onClick={() => {
const ts = msg.createTime || msgData.create_time; const ts = msg.createTime
if (ts && currentSessionId) { if (ts && currentSessionId) {
setCurrentOffset(0); setCurrentOffset(0)
setJumpStartTime(0); setJumpStartTime(0)
setJumpEndTime(0); setJumpEndTime(0)
void loadMessages(currentSessionId, 0, ts - 1, ts + 1, false); void loadMessages(currentSessionId, 0, ts - 1, ts + 1, false)
} }
}}> }}>
<div className="result-header"> <div className="result-header">
<Avatar src={senderAvatar} name={senderName} size={32} /> <Avatar src={senderAvatar} name={senderName} size={32} loading={senderAvatarLoading} />
</div> </div>
<div className="result-content"> <div className="result-content">
<span className="result-sender">{senderName}</span> <span className="result-sender">{senderName}</span>
<span className="result-text">{(msg.content || msgData.strContent || msgData.message_content || msgData.parsedContent || '').slice(0, 80)}</span> <span className="result-text">{previewText}</span>
</div> </div>
<span className="result-time">{msg.createTime || msgData.create_time ? new Date((msg.createTime || msgData.create_time) * 1000).toLocaleString('zh-CN', { month: '2-digit', day: '2-digit', hour: '2-digit', minute: '2-digit' }) : ''}</span> <span className="result-time">{displayTime}</span>
</div> </div>
); )
})} })}
</div> </div>
)} )}
{inSessionQuery && !inSessionSearching && inSessionResults.length === 0 && ( {inSessionQuery && !inSessionSearching && !inSessionSearchError && inSessionResults.length === 0 && (
<div className="no-results"> <div className="no-results">
<MessageSquare size={32} /> <MessageSquare size={32} />
<p></p> <p></p>

View File

@@ -3,9 +3,11 @@
export class AvatarLoadQueue { export class AvatarLoadQueue {
private queue: Array<{ url: string; resolve: () => void; reject: (error: Error) => void }> = [] private queue: Array<{ url: string; resolve: () => void; reject: (error: Error) => void }> = []
private loading = new Map<string, Promise<void>>() private loading = new Map<string, Promise<void>>()
private failed = new Map<string, number>()
private activeCount = 0 private activeCount = 0
private readonly maxConcurrent = 3 private readonly maxConcurrent = 3
private readonly delayBetweenBatches = 10 private readonly delayBetweenBatches = 10
private readonly failedTtlMs = 10 * 60 * 1000
private static instance: AvatarLoadQueue private static instance: AvatarLoadQueue
@@ -18,6 +20,9 @@ export class AvatarLoadQueue {
async enqueue(url: string): Promise<void> { async enqueue(url: string): Promise<void> {
if (!url) return Promise.resolve() if (!url) return Promise.resolve()
if (this.hasFailed(url)) {
return Promise.reject(new Error(`Failed: ${url}`))
}
// 核心修复:防止重复并发请求同一个 URL // 核心修复:防止重复并发请求同一个 URL
const existingPromise = this.loading.get(url) const existingPromise = this.loading.get(url)
@@ -31,13 +36,40 @@ export class AvatarLoadQueue {
}) })
this.loading.set(url, loadPromise) this.loading.set(url, loadPromise)
loadPromise.finally(() => { void loadPromise.then(
this.loading.delete(url) () => {
}) this.loading.delete(url)
this.clearFailed(url)
},
() => {
this.loading.delete(url)
}
)
return loadPromise return loadPromise
} }
hasFailed(url: string): boolean {
if (!url) return false
const failedAt = this.failed.get(url)
if (!failedAt) return false
if (Date.now() - failedAt > this.failedTtlMs) {
this.failed.delete(url)
return false
}
return true
}
markFailed(url: string) {
if (!url) return
this.failed.set(url, Date.now())
}
clearFailed(url: string) {
if (!url) return
this.failed.delete(url)
}
private async processQueue() { private async processQueue() {
if (this.activeCount >= this.maxConcurrent || this.queue.length === 0) { if (this.activeCount >= this.maxConcurrent || this.queue.length === 0) {
return return
@@ -49,13 +81,16 @@ export class AvatarLoadQueue {
this.activeCount++ this.activeCount++
const img = new Image() const img = new Image()
img.referrerPolicy = 'no-referrer'
img.onload = () => { img.onload = () => {
this.activeCount-- this.activeCount--
this.clearFailed(task.url)
task.resolve() task.resolve()
setTimeout(() => this.processQueue(), this.delayBetweenBatches) setTimeout(() => this.processQueue(), this.delayBetweenBatches)
} }
img.onerror = () => { img.onerror = () => {
this.activeCount-- this.activeCount--
this.markFailed(task.url)
task.reject(new Error(`Failed: ${task.url}`)) task.reject(new Error(`Failed: ${task.url}`))
setTimeout(() => this.processQueue(), this.delayBetweenBatches) setTimeout(() => this.processQueue(), this.delayBetweenBatches)
} }
@@ -67,6 +102,7 @@ export class AvatarLoadQueue {
clear() { clear() {
this.queue = [] this.queue = []
this.loading.clear() this.loading.clear()
this.failed.clear()
this.activeCount = 0 this.activeCount = 0
} }
} }