Merge remote-tracking branch 'upstream/dev' into feat/linux

This commit is contained in:
H3CoF6
2026-03-17 05:26:23 +08:00
10 changed files with 1019 additions and 50 deletions

View File

@@ -116,6 +116,40 @@ function resolveSearchSenderUsernameFallback(value?: string | null): string | un
return normalized
}
function buildSearchIdentityCandidates(value?: string | null): string[] {
const normalized = normalizeSearchIdentityText(value)
if (!normalized) return []
const lower = normalized.toLowerCase()
const candidates = new Set<string>([lower])
if (lower.startsWith('wxid_')) {
const match = lower.match(/^(wxid_[^_]+)/i)
if (match?.[1]) {
candidates.add(match[1])
}
}
return [...candidates]
}
function isCurrentUserSearchIdentity(
senderUsername?: string | null,
myWxid?: string | null
): boolean {
const senderCandidates = buildSearchIdentityCandidates(senderUsername)
const selfCandidates = buildSearchIdentityCandidates(myWxid)
if (senderCandidates.length === 0 || selfCandidates.length === 0) {
return false
}
for (const sender of senderCandidates) {
for (const self of selfCandidates) {
if (sender === self) return true
if (sender.startsWith(self + '_')) return true
if (self.startsWith(sender + '_')) return true
}
}
return false
}
interface XmlField {
key: string;
value: string;
@@ -2764,6 +2798,7 @@ function ChatPage(props: ChatPageProps) {
const {
normalizedSessionId,
isDirectSearchSession,
isGroupSearchSession,
resolvedSessionDisplayName,
resolvedSessionAvatarUrl
} = resolveSearchSessionContext(sessionId)
@@ -2771,6 +2806,7 @@ function ChatPage(props: ChatPageProps) {
return sortedMessages.map((message) => {
const senderUsername = normalizeSearchIdentityText(message.senderUsername) || message.senderUsername
const inferredSelfFromSender = isGroupSearchSession && isCurrentUserSearchIdentity(senderUsername, myWxid)
const senderDisplayName = resolveSearchSenderDisplayName(
message.senderDisplayName,
senderUsername,
@@ -2778,7 +2814,8 @@ function ChatPage(props: ChatPageProps) {
)
const senderUsernameFallback = resolveSearchSenderUsernameFallback(senderUsername)
const senderAvatarUrl = normalizeSearchAvatarUrl(message.senderAvatarUrl)
const nextSenderDisplayName = message.isSend === 1
const nextIsSend = inferredSelfFromSender ? 1 : message.isSend
const nextSenderDisplayName = nextIsSend === 1
? (senderDisplayName || '我')
: (
senderDisplayName ||
@@ -2787,12 +2824,29 @@ function ChatPage(props: ChatPageProps) {
(isDirectSearchSession ? resolvedSessionUsernameFallback : undefined) ||
'未知'
)
const nextSenderAvatarUrl = message.isSend === 1
const nextSenderAvatarUrl = nextIsSend === 1
? (senderAvatarUrl || myAvatarUrl)
: (senderAvatarUrl || (isDirectSearchSession ? resolvedSessionAvatarUrl : undefined))
if (inferredSelfFromSender) {
console.info('[InSessionSearch][GroupSelfHit][hydrate]', {
sessionId: normalizedSessionId,
localId: message.localId,
senderUsername,
rawIsSend: message.isSend,
nextIsSend,
rawSenderDisplayName: message.senderDisplayName,
nextSenderDisplayName,
rawSenderAvatarUrl: message.senderAvatarUrl,
nextSenderAvatarUrl,
myWxid,
hasMyAvatarUrl: Boolean(myAvatarUrl)
})
}
if (
senderUsername === message.senderUsername &&
nextIsSend === message.isSend &&
nextSenderDisplayName === message.senderDisplayName &&
nextSenderAvatarUrl === message.senderAvatarUrl
) {
@@ -2801,12 +2855,13 @@ function ChatPage(props: ChatPageProps) {
return {
...message,
isSend: nextIsSend,
senderUsername,
senderDisplayName: nextSenderDisplayName,
senderAvatarUrl: nextSenderAvatarUrl
}
})
}, [currentSessionId, myAvatarUrl, resolveSearchSessionContext])
}, [currentSessionId, myAvatarUrl, myWxid, resolveSearchSessionContext])
const enrichMessagesWithSenderProfiles = useCallback(async (rawMessages: Message[], sessionId?: string) => {
let messages = hydrateInSessionSearchResults(rawMessages, sessionId)
@@ -2962,6 +3017,7 @@ function ChatPage(props: ChatPageProps) {
return messages.map((message) => {
const sender = normalizeSearchIdentityText(message.senderUsername)
const profile = sender ? profileMap.get(sender) : undefined
const inferredSelfFromSender = isGroupSearchSession && isCurrentUserSearchIdentity(sender, myWxid)
const profileDisplayName = resolveSearchSenderDisplayName(
profile?.displayName,
sender,
@@ -2975,7 +3031,8 @@ function ChatPage(props: ChatPageProps) {
const senderUsernameFallback = resolveSearchSenderUsernameFallback(sender)
const sessionUsernameFallback = resolveSearchSenderUsernameFallback(normalizedSessionId)
const currentSenderAvatarUrl = normalizeSearchAvatarUrl(message.senderAvatarUrl)
const nextSenderDisplayName = message.isSend === 1
const nextIsSend = inferredSelfFromSender ? 1 : message.isSend
const nextSenderDisplayName = nextIsSend === 1
? (currentSenderDisplayName || profileDisplayName || '我')
: (
profileDisplayName ||
@@ -2985,7 +3042,7 @@ function ChatPage(props: ChatPageProps) {
(isDirectSearchSession ? sessionUsernameFallback : undefined) ||
'未知'
)
const nextSenderAvatarUrl = message.isSend === 1
const nextSenderAvatarUrl = nextIsSend === 1
? (currentSenderAvatarUrl || myAvatarUrl || normalizeSearchAvatarUrl(profile?.avatarUrl))
: (
currentSenderAvatarUrl ||
@@ -2993,8 +3050,27 @@ function ChatPage(props: ChatPageProps) {
(isDirectSearchSession ? resolvedSessionAvatarUrl : undefined)
)
if (inferredSelfFromSender) {
console.info('[InSessionSearch][GroupSelfHit][enrich]', {
sessionId: normalizedSessionId,
localId: message.localId,
senderUsername: sender,
rawIsSend: message.isSend,
nextIsSend,
profileDisplayName,
currentSenderDisplayName,
nextSenderDisplayName,
profileAvatarUrl: normalizeSearchAvatarUrl(profile?.avatarUrl),
currentSenderAvatarUrl,
nextSenderAvatarUrl,
myWxid,
hasMyAvatarUrl: Boolean(myAvatarUrl)
})
}
if (
sender === message.senderUsername &&
nextIsSend === message.isSend &&
nextSenderDisplayName === message.senderDisplayName &&
nextSenderAvatarUrl === message.senderAvatarUrl
) {
@@ -3003,6 +3079,7 @@ function ChatPage(props: ChatPageProps) {
return {
...message,
isSend: nextIsSend,
senderUsername: sender || message.senderUsername,
senderDisplayName: nextSenderDisplayName,
senderAvatarUrl: nextSenderAvatarUrl
@@ -3012,6 +3089,7 @@ function ChatPage(props: ChatPageProps) {
currentSessionId,
hydrateInSessionSearchResults,
myAvatarUrl,
myWxid,
resolveSearchSessionContext
])

View File

@@ -1596,6 +1596,7 @@ function ExportPage() {
const sessionMutualFriendsRunIdRef = useRef(0)
const sessionMutualFriendsWorkerRunningRef = useRef(false)
const sessionMutualFriendsBackgroundFeedTimerRef = useRef<number | null>(null)
const sessionMutualFriendsPersistTimerRef = useRef<number | null>(null)
const sessionMutualFriendsVisibleRangeRef = useRef<{ startIndex: number; endIndex: number }>({
startIndex: 0,
endIndex: -1
@@ -2748,8 +2749,32 @@ function ExportPage() {
window.clearTimeout(sessionMutualFriendsBackgroundFeedTimerRef.current)
sessionMutualFriendsBackgroundFeedTimerRef.current = null
}
if (sessionMutualFriendsPersistTimerRef.current) {
window.clearTimeout(sessionMutualFriendsPersistTimerRef.current)
sessionMutualFriendsPersistTimerRef.current = null
}
}, [])
const flushSessionMutualFriendsCache = useCallback(async () => {
try {
const scopeKey = await ensureExportCacheScope()
await configService.setExportSessionMutualFriendsCache(
scopeKey,
sessionMutualFriendsDirectMetricsRef.current
)
} catch (error) {
console.error('写入导出页共同好友缓存失败:', error)
}
}, [ensureExportCacheScope])
const scheduleFlushSessionMutualFriendsCache = useCallback(() => {
if (sessionMutualFriendsPersistTimerRef.current) return
sessionMutualFriendsPersistTimerRef.current = window.setTimeout(() => {
sessionMutualFriendsPersistTimerRef.current = null
void flushSessionMutualFriendsCache()
}, SESSION_MEDIA_METRIC_CACHE_FLUSH_DELAY_MS)
}, [flushSessionMutualFriendsCache])
const isSessionMutualFriendsReady = useCallback((sessionId: string): boolean => {
if (!sessionId) return true
if (sessionMutualFriendsReadySetRef.current.has(sessionId)) return true
@@ -2879,10 +2904,35 @@ function ExportPage() {
}
}, [getSessionMutualFriendProfile])
const rebuildSessionMutualFriendsStateFromDirectMetrics = useCallback((sessionIds?: string[]) => {
const targets = Array.isArray(sessionIds) && sessionIds.length > 0
? sessionIds
: Object.keys(sessionMutualFriendsDirectMetricsRef.current)
const nextMetrics: Record<string, SessionMutualFriendsMetric> = {}
const readyIds: string[] = []
for (const sessionIdRaw of targets) {
const sessionId = String(sessionIdRaw || '').trim()
if (!sessionId) continue
const rebuilt = rebuildSessionMutualFriendsMetric(sessionId)
if (!rebuilt) continue
nextMetrics[sessionId] = rebuilt
readyIds.push(sessionId)
}
sessionMutualFriendsMetricsRef.current = nextMetrics
setSessionMutualFriendsMetrics(nextMetrics)
if (readyIds.length > 0) {
for (const sessionId of readyIds) {
sessionMutualFriendsReadySetRef.current.add(sessionId)
}
patchSessionLoadTraceStage(readyIds, 'mutualFriends', 'done')
}
}, [patchSessionLoadTraceStage, rebuildSessionMutualFriendsMetric])
const applySessionMutualFriendsMetric = useCallback((sessionId: string, directMetric: SessionMutualFriendsMetric) => {
const normalizedSessionId = String(sessionId || '').trim()
if (!normalizedSessionId) return
sessionMutualFriendsDirectMetricsRef.current[normalizedSessionId] = directMetric
scheduleFlushSessionMutualFriendsCache()
const impactedSessionIds = new Set<string>([normalizedSessionId])
const allSessionIds = sessionsRef.current
@@ -2912,7 +2962,7 @@ function ExportPage() {
}
return changed ? next : prev
})
}, [getSessionMutualFriendProfile, rebuildSessionMutualFriendsMetric])
}, [getSessionMutualFriendProfile, rebuildSessionMutualFriendsMetric, scheduleFlushSessionMutualFriendsCache])
const isSessionMediaMetricReady = useCallback((sessionId: string): boolean => {
if (!sessionId) return true
@@ -3339,11 +3389,13 @@ function ExportPage() {
const [
cachedContactsPayload,
cachedMessageCountsPayload,
cachedContentMetricsPayload
cachedContentMetricsPayload,
cachedMutualFriendsPayload
] = await Promise.all([
loadContactsCaches(scopeKey),
configService.getExportSessionMessageCountCache(scopeKey),
configService.getExportSessionContentMetricCache(scopeKey)
configService.getExportSessionContentMetricCache(scopeKey),
configService.getExportSessionMutualFriendsCache(scopeKey)
])
if (isStale()) return
@@ -3411,6 +3463,15 @@ function ExportPage() {
if (cachedContentMetricReadySessionIds.length > 0) {
patchSessionLoadTraceStage(cachedContentMetricReadySessionIds, 'mediaMetrics', 'done')
}
const cachedMutualFriendDirectMetrics = Object.entries(cachedMutualFriendsPayload?.metrics || {}).reduce<Record<string, SessionMutualFriendsMetric>>((acc, [sessionIdRaw, metricRaw]) => {
const sessionId = String(sessionIdRaw || '').trim()
if (!exportableSessionIdSet.has(sessionId) || !isSingleContactSession(sessionId)) return acc
const metric = metricRaw as SessionMutualFriendsMetric | undefined
if (!metric || !Array.isArray(metric.items) || !Number.isFinite(metric.count)) return acc
acc[sessionId] = metric
return acc
}, {})
const cachedMutualFriendSessionIds = Object.keys(cachedMutualFriendDirectMetrics)
if (isStale()) return
if (Object.keys(cachedMessageCounts).length > 0) {
@@ -3422,6 +3483,13 @@ function ExportPage() {
if (Object.keys(cachedContentMetrics).length > 0) {
mergeSessionContentMetrics(cachedContentMetrics)
}
if (cachedMutualFriendSessionIds.length > 0) {
sessionMutualFriendsDirectMetricsRef.current = cachedMutualFriendDirectMetrics
rebuildSessionMutualFriendsStateFromDirectMetrics(cachedMutualFriendSessionIds)
} else {
sessionMutualFriendsMetricsRef.current = {}
setSessionMutualFriendsMetrics({})
}
setSessions(baseSessions)
sessionsHydratedAtRef.current = Date.now()
void (async () => {
@@ -3622,7 +3690,7 @@ function ExportPage() {
} finally {
if (!isStale()) setIsLoading(false)
}
}, [ensureExportCacheScope, loadContactsCaches, loadSessionMessageCounts, mergeSessionContentMetrics, patchSessionLoadTraceStage, resetSessionMediaMetricLoader, resetSessionMutualFriendsLoader, syncContactTypeCounts])
}, [ensureExportCacheScope, loadContactsCaches, loadSessionMessageCounts, mergeSessionContentMetrics, patchSessionLoadTraceStage, rebuildSessionMutualFriendsStateFromDirectMetrics, resetSessionMediaMetricLoader, resetSessionMutualFriendsLoader, syncContactTypeCounts])
useEffect(() => {
if (!isExportRoute) return
@@ -3630,10 +3698,7 @@ function ExportPage() {
const hasFreshSessionSnapshot = hasBaseConfigReadyRef.current &&
sessionsRef.current.length > 0 &&
now - sessionsHydratedAtRef.current <= EXPORT_REENTER_SESSION_SOFT_REFRESH_MS
const hasFreshSnsSnapshot = hasSeededSnsStatsRef.current &&
now - snsStatsHydratedAtRef.current <= EXPORT_REENTER_SNS_SOFT_REFRESH_MS
void loadBaseConfig()
const baseConfigPromise = loadBaseConfig()
void ensureSharedTabCountsLoaded()
if (!hasFreshSessionSnapshot) {
void loadSessions()
@@ -3641,9 +3706,14 @@ function ExportPage() {
// 朋友圈统计延后一点加载,避免与首屏会话初始化抢占。
const timer = window.setTimeout(() => {
if (!hasFreshSnsSnapshot) {
void loadSnsStats({ full: true })
}
void (async () => {
await baseConfigPromise
const hasFreshSnsSnapshot = hasSeededSnsStatsRef.current &&
Date.now() - snsStatsHydratedAtRef.current <= EXPORT_REENTER_SNS_SOFT_REFRESH_MS
if (!hasFreshSnsSnapshot) {
void loadSnsStats({ full: true })
}
})()
}, 120)
return () => window.clearTimeout(timer)
@@ -4988,9 +5058,14 @@ function ExportPage() {
window.clearTimeout(sessionMutualFriendsBackgroundFeedTimerRef.current)
sessionMutualFriendsBackgroundFeedTimerRef.current = null
}
if (sessionMutualFriendsPersistTimerRef.current) {
window.clearTimeout(sessionMutualFriendsPersistTimerRef.current)
sessionMutualFriendsPersistTimerRef.current = null
}
void flushSessionMediaMetricCache()
void flushSessionMutualFriendsCache()
}
}, [flushSessionMediaMetricCache])
}, [flushSessionMediaMetricCache, flushSessionMutualFriendsCache])
const contactByUsername = useMemo(() => {
const map = new Map<string, ContactInfo>()
@@ -5254,6 +5329,23 @@ function ExportPage() {
console.error('导出页读取会话统计缓存失败:', error)
}
try {
const relationCacheResult = await window.electronAPI.chat.getExportSessionStats(
[normalizedSessionId],
{ includeRelations: true, allowStaleCache: true, cacheOnly: true }
)
if (requestSeq !== detailRequestSeqRef.current) return
if (relationCacheResult.success && relationCacheResult.data) {
const relationMetric = relationCacheResult.data[normalizedSessionId] as SessionExportMetric | undefined
const relationCacheMeta = relationCacheResult.cache?.[normalizedSessionId] as SessionExportCacheMeta | undefined
if (relationMetric) {
applySessionDetailStats(normalizedSessionId, relationMetric, relationCacheMeta, true)
}
}
} catch (error) {
console.error('导出页读取会话关系缓存失败:', error)
}
const lastPreciseAt = sessionPreciseRefreshAtRef.current[preciseCacheKey] || 0
const hasRecentPrecise = Date.now() - lastPreciseAt <= DETAIL_PRECISE_REFRESH_COOLDOWN_MS
const shouldRunPreciseRefresh = !hasRecentPrecise && (!quickMetric || Boolean(quickCacheMeta?.stale))
@@ -5302,16 +5394,36 @@ function ExportPage() {
}
}, [applySessionDetailStats, contactByUsername, mergeSessionContentMetrics, sessionContentMetrics, sessionMessageCounts, sessionRowByUsername])
const loadSessionRelationStats = useCallback(async () => {
const loadSessionRelationStats = useCallback(async (options?: { forceRefresh?: boolean }) => {
const normalizedSessionId = String(sessionDetail?.wxid || '').trim()
if (!normalizedSessionId || isLoadingSessionRelationStats) return
const requestSeq = detailRequestSeqRef.current
const forceRefresh = options?.forceRefresh === true
setIsLoadingSessionRelationStats(true)
try {
if (!forceRefresh) {
const relationCacheResult = await window.electronAPI.chat.getExportSessionStats(
[normalizedSessionId],
{ includeRelations: true, allowStaleCache: true, cacheOnly: true }
)
if (requestSeq !== detailRequestSeqRef.current) return
const relationMetric = relationCacheResult.success && relationCacheResult.data
? relationCacheResult.data[normalizedSessionId] as SessionExportMetric | undefined
: undefined
const relationCacheMeta = relationCacheResult.success
? relationCacheResult.cache?.[normalizedSessionId] as SessionExportCacheMeta | undefined
: undefined
if (relationMetric) {
applySessionDetailStats(normalizedSessionId, relationMetric, relationCacheMeta, true)
return
}
}
const relationResult = await window.electronAPI.chat.getExportSessionStats(
[normalizedSessionId],
{ includeRelations: true, forceRefresh: true, preferAccurateSpecialTypes: true }
{ includeRelations: true, forceRefresh, preferAccurateSpecialTypes: true }
)
if (requestSeq !== detailRequestSeqRef.current) return
@@ -5333,6 +5445,60 @@ function ExportPage() {
}
}, [applySessionDetailStats, isLoadingSessionRelationStats, sessionDetail?.wxid])
const handleRefreshTableData = useCallback(async () => {
const scopeKey = await ensureExportCacheScope()
resetSessionMutualFriendsLoader()
sessionMutualFriendsMetricsRef.current = {}
setSessionMutualFriendsMetrics({})
closeSessionMutualFriendsDialog()
try {
await configService.clearExportSessionMutualFriendsCache(scopeKey)
} catch (error) {
console.error('清理导出页共同好友缓存失败:', error)
}
if (isSessionCountStageReady) {
const visibleTargetIds = collectVisibleSessionMutualFriendsTargets(filteredContacts)
const visibleTargetSet = new Set(visibleTargetIds)
const remainingTargetIds = sessionsRef.current
.filter((session) => session.hasSession && isSingleContactSession(session.username) && !visibleTargetSet.has(session.username))
.map((session) => session.username)
if (visibleTargetIds.length > 0) {
enqueueSessionMutualFriendsRequests(visibleTargetIds, { front: true })
}
if (remainingTargetIds.length > 0) {
enqueueSessionMutualFriendsRequests(remainingTargetIds)
}
scheduleSessionMutualFriendsWorker()
}
await Promise.all([
loadContactsList({ scopeKey }),
loadSnsStats({ full: true }),
loadSnsUserPostCounts({ force: true })
])
if (String(sessionDetail?.wxid || '').trim()) {
void loadSessionRelationStats({ forceRefresh: true })
}
}, [
closeSessionMutualFriendsDialog,
collectVisibleSessionMutualFriendsTargets,
enqueueSessionMutualFriendsRequests,
ensureExportCacheScope,
filteredContacts,
isSessionCountStageReady,
loadContactsList,
loadSessionRelationStats,
loadSnsStats,
loadSnsUserPostCounts,
resetSessionMutualFriendsLoader,
scheduleSessionMutualFriendsWorker,
sessionDetail?.wxid
])
useEffect(() => {
if (!showSessionDetailPanel || !sessionDetailSupportsSnsTimeline) return
if (snsUserPostCountsStatus === 'idle') {
@@ -6371,7 +6537,7 @@ function ExportPage() {
</button>
)}
</div>
<button className="secondary-btn" onClick={() => void loadContactsList()} disabled={isContactsListLoading}>
<button className="secondary-btn" onClick={() => void handleRefreshTableData()} disabled={isContactsListLoading}>
<RefreshCw size={14} className={isContactsListLoading ? 'spin' : ''} />
</button>
@@ -6468,7 +6634,7 @@ function ExportPage() {
<li>3 IPC </li>
</ul>
<div className="issue-actions">
<button className="issue-btn primary" onClick={() => void loadContactsList()}>
<button className="issue-btn primary" onClick={() => void handleRefreshTableData()}>
<RefreshCw size={14} />
<span></span>
</button>