import { useEffect, useMemo, useRef } from 'react' import { useRuntimeConfig } from '@/hooks/use-clash' import { useGroupsDelays } from '@/hooks/use-group-delays' import { useVerge } from '@/hooks/use-verge' import { useAppRefreshers, useProxiesData } from '@/providers/app-data-context' import delayManager, { type DelaySnapshot } from '@/services/delay' import { isInteractableMember, resolveMember, selectGlobalChainNodes, selectRuleChainMembers, type ProxyGroupView, type ProxyViewV1, type ResolvedProxyMember, } from '@/types/proxy-view' import { debugLog } from '@/utils/debug' import { filterSort } from './use-filter-sort' import { DEFAULT_STATE, useHeadStateNew, type HeadState, } from './use-head-state' import { useWindowWidth } from './use-window-width' export interface ResolvedMemberOccurrence { memberIndex: number member: ResolvedProxyMember } type ProxyGroup = ProxyGroupView export interface IRenderItem { type: 0 | 1 | 2 | 3 | 4 key: string group: ProxyGroup member?: ResolvedMemberOccurrence memberCol?: ResolvedMemberOccurrence[] col?: number headState?: HeadState icon?: string testUrl?: string } /** * Whether the list about to be drawn contains anything a user would call content. * * Derived from the list itself rather than predicted beside it. The prediction that used to * live in the empty-state model asked a different question in chain mode — whether any * Selector or URLTest group existed — which is unrelated to what the chain list is actually * built from, so both a false "empty" and a false "not empty" were reachable. * * A group header alone counts only when the group is visible; every other row is content by * definition, including the members of a group that is hidden but expanded. */ export const hasRenderableItems = ( renderList: readonly IRenderItem[], ): boolean => renderList.some((item) => item.type !== 0 || !item.group.hidden) type GroupCache = { now: string | undefined members: ProxyGroupView['members'] headState: HeadState col: number latencyTimeout: number | undefined /// This group's own delays. Compared by identity so that a test settling in one group /// does not throw away every other group's sorted order. delays: DelaySnapshot | undefined items: IRenderItem[] } type RuntimeConfigWithProxySequence = IConfigData & { proxies?: unknown } const resolveOccurrences = (view: ProxyViewV1, group: ProxyGroupView) => group.members.map((member, memberIndex) => ({ memberIndex, member: resolveMember(view, member), })) const memberKey = ( group: ProxyGroupView, occurrence: ResolvedMemberOccurrence, ) => { const { memberIndex, member } = occurrence const identity = member.kind === 'node' ? member.node.recordId : member.ref.name return `${group.name}:${memberIndex}:${identity}` } const calculateColumns = (width: number, configCol: number): number => { if (configCol > 0 && configCol < 6) return configCol if (width > 1920) return 5 if (width > 1450) return 4 if (width > 1024) return 3 if (width >= 600) return 2 return 1 } const groupOccurrences = (list: T[], size: number): T[][] => list.reduce((acc, item) => { const lastGroup = acc[acc.length - 1] if (!lastGroup || lastGroup.length >= size) acc.push([item]) else lastGroup.push(item) return acc }, []) const CHAIN_DELAY_GROUP = 'chain-mode' const virtualGroup = (members: ProxyGroupView['members']): ProxyGroupView => ({ name: CHAIN_DELAY_GROUP, type: 'Selector', udp: false, xudp: false, tfo: false, mptcp: false, smux: false, history: [], extra: {}, members, }) export const useRenderList = ( mode: string, isChainMode?: boolean, selectedGroup?: string | null, ) => { const { proxyView } = useProxiesData() const { refreshProxy } = useAppRefreshers() const { verge } = useVerge() const { width } = useWindowWidth() const [headStates, setHeadState] = useHeadStateNew() const latencyTimeout = verge?.default_latency_timeout const { data: runtimeConfig } = useRuntimeConfig(!!isChainMode) const runtimeProxies = ( runtimeConfig as RuntimeConfigWithProxySequence | null )?.proxies const col = useMemo( () => calculateColumns(width, verge?.proxy_layout_column || 6), [width, verge?.proxy_layout_column], ) const chainOccurrences = useMemo(() => { if (!proxyView || !isChainMode) return [] if (mode === 'rule' && selectedGroup) { return selectRuleChainMembers(proxyView, selectedGroup) } if (!runtimeConfig) return [] return selectGlobalChainNodes(proxyView, runtimeProxies).map( (node, memberIndex) => ({ memberIndex, member: { kind: 'node' as const, ref: { kind: 'node' as const, name: node.name, recordId: node.recordId, }, node, }, }), ) }, [ isChainMode, mode, proxyView, runtimeConfig, runtimeProxies, selectedGroup, ]) const chainOccurrencesRef = useRef(chainOccurrences) chainOccurrencesRef.current = chainOccurrences const chainDelayGroup = mode === 'rule' && selectedGroup ? selectedGroup : CHAIN_DELAY_GROUP const chainDelayKey = chainOccurrences .map(({ member }) => { if (member.kind !== 'node') return `${member.kind}:${member.ref.name}` const source = member.node.source return source.kind === 'provider' ? `provider:${source.providerName}:${source.proxyName}` : `core:${source.proxyName}` }) .join('\u0000') useEffect(() => { if (!isChainMode || !chainDelayKey) return const interactable = chainOccurrencesRef.current .map(({ member }) => member) .filter(isInteractableMember) if (interactable.length === 0) return const handle = setTimeout(() => { const timeout = verge?.default_latency_timeout || 10000 debugLog(`[ChainMode] 开始计算 ${interactable.length} 个节点的延迟`) void delayManager.checkListDelay(interactable, chainDelayGroup, timeout) }, 100) return () => { clearTimeout(handle) } }, [ chainDelayGroup, chainDelayKey, isChainMode, verge?.default_latency_timeout, ]) // Every group this list draws, so a test settling in any of them re-sorts that group. const renderedGroupNames = useMemo(() => { if (!proxyView) return [] if (isChainMode) return selectedGroup ? [selectedGroup] : [CHAIN_DELAY_GROUP] return mode === 'rule' || mode === 'script' ? proxyView.groups.map(({ name }) => name) : proxyView.global ? [proxyView.global.name] : [] }, [isChainMode, mode, proxyView, selectedGroup]) const groupDelays = useGroupsDelays(renderedGroupNames) const groupCacheRef = useRef>(new Map()) const prevListRef = useRef([]) const renderList = useMemo(() => { if (!proxyView) return [] if (isChainMode) { const selected = mode === 'rule' ? proxyView.groups.find(({ name }) => name === selectedGroup) : undefined const group = selected ?? virtualGroup([]) const occurrences = filterSort( chainOccurrences, selected?.name ?? CHAIN_DELAY_GROUP, '', 0, latencyTimeout!, ) if (col > 1) { return groupOccurrences(occurrences, col).map((memberCol) => ({ type: 4, key: `chain-col:${memberKey(group, memberCol[0])}`, group, headState: DEFAULT_STATE, col, memberCol, })) } return occurrences.map((member) => ({ type: 2, key: `chain:${memberKey(group, member)}`, group, member, headState: DEFAULT_STATE, })) } const useRule = mode === 'rule' || mode === 'script' const renderGroups = useRule ? proxyView.groups : proxyView.global === null ? [] : [proxyView.global] const cache = groupCacheRef.current let anyChanged = false const retList = renderGroups.flatMap((group) => { const headState = headStates[group.name] || DEFAULT_STATE const cached = cache.get(group.name) if ( cached && cached.now === group.now && cached.members === group.members && cached.headState === headState && cached.col === col && cached.latencyTimeout === latencyTimeout && cached.delays === groupDelays.get(group.name) ) { return cached.items } anyChanged = true const ret: IRenderItem[] = [ { type: 0, key: group.name, group, headState, icon: group.icon!, testUrl: group.testUrl!, }, ] if (headState.open || !useRule) { const occurrences = filterSort( resolveOccurrences(proxyView, group), group.name, headState.filterText, headState.sortType, latencyTimeout!, { matchCase: headState.filterMatchCase, matchWholeWord: headState.filterMatchWholeWord, useRegularExpression: headState.filterUseRegularExpression, }, ) if (!useRule) { ret.push({ type: 1, key: `head-${group.name}`, group, headState }) } if (occurrences.length === 0) { ret.push({ type: 3, key: `empty-${group.name}`, group, headState }) } else if (col > 1) { ret.push( ...groupOccurrences(occurrences, col).map((memberCol) => ({ type: 4 as const, key: `col:${memberKey(group, memberCol[0])}`, group, headState, col, memberCol, })), ) } else { ret.push( ...occurrences.map((member) => ({ type: 2 as const, key: memberKey(group, member), group, member, headState, })), ) } } cache.set(group.name, { now: group.now!, members: group.members, headState, col, latencyTimeout: latencyTimeout!, delays: groupDelays.get(group.name), items: ret, }) return ret }) const filtered = !useRule ? retList.slice(1) : retList.filter((item) => !item.group.hidden) if (!anyChanged && prevListRef.current.length === filtered.length) { return prevListRef.current } prevListRef.current = filtered return filtered }, [ chainOccurrences, col, groupDelays, headStates, isChainMode, latencyTimeout, mode, proxyView, selectedGroup, ]) return { renderList, onProxies: refreshProxy, onHeadState: setHeadState, currentColumns: col, } }