mirror of
https://github.com/clash-verge-rev/clash-verge-rev.git
synced 2026-11-10 12:22:07 +08:00
Compare commits
4
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
d88fe8dec2 | ||
|
|
dc641bf1f4 | ||
|
|
73b18a28ed | ||
|
|
f84be8df72 |
@@ -48,6 +48,7 @@
|
|||||||
|
|
||||||
- 优化连接、规则和日志筛选:切换页面后保留过滤条件
|
- 优化连接、规则和日志筛选:切换页面后保留过滤条件
|
||||||
- 优化全局模式代理列表:仅显示 `GLOBAL` 节点,选中其中的策略组时才显示该组
|
- 优化全局模式代理列表:仅显示 `GLOBAL` 节点,选中其中的策略组时才显示该组
|
||||||
|
- 优化连接和规则页面的资源占用,减少连接列表更新及排序时的卡顿
|
||||||
|
|
||||||
**🍎 macOS**
|
**🍎 macOS**
|
||||||
|
|
||||||
|
|||||||
@@ -0,0 +1,40 @@
|
|||||||
|
import { useSyncExternalStore } from 'react'
|
||||||
|
import { afterEach, expect, it, vi } from 'vitest'
|
||||||
|
|
||||||
|
vi.mock('dayjs', () => ({ default: () => ({ from: () => '' }) }))
|
||||||
|
vi.mock('react', () => ({
|
||||||
|
memo: (component: unknown) => component,
|
||||||
|
useSyncExternalStore: vi.fn((_subscribe, getSnapshot) => getSnapshot()),
|
||||||
|
}))
|
||||||
|
|
||||||
|
afterEach(() => {
|
||||||
|
vi.useRealTimers()
|
||||||
|
vi.unstubAllGlobals()
|
||||||
|
})
|
||||||
|
|
||||||
|
it('keeps at most one interval while visible and none while hidden or unsubscribed', async () => {
|
||||||
|
vi.useFakeTimers()
|
||||||
|
vi.stubGlobal('window', globalThis)
|
||||||
|
const visibility = Object.assign(new EventTarget(), { hidden: true })
|
||||||
|
vi.stubGlobal('document', visibility)
|
||||||
|
const { RelativeTime } = await import('./connection-relative-time')
|
||||||
|
RelativeTime({ start: '' })
|
||||||
|
const [subscribe] = vi.mocked(useSyncExternalStore).mock.calls[0]
|
||||||
|
const unsubscribeFirst = subscribe(() => {})
|
||||||
|
const unsubscribeSecond = subscribe(() => {})
|
||||||
|
expect(vi.getTimerCount()).toBe(0)
|
||||||
|
visibility.hidden = false
|
||||||
|
visibility.dispatchEvent(new Event('visibilitychange'))
|
||||||
|
expect(vi.getTimerCount()).toBe(1)
|
||||||
|
visibility.hidden = true
|
||||||
|
visibility.dispatchEvent(new Event('visibilitychange'))
|
||||||
|
expect(vi.getTimerCount()).toBe(0)
|
||||||
|
visibility.hidden = false
|
||||||
|
visibility.dispatchEvent(new Event('visibilitychange'))
|
||||||
|
expect(vi.getTimerCount()).toBe(1)
|
||||||
|
unsubscribeFirst()
|
||||||
|
unsubscribeSecond()
|
||||||
|
expect(vi.getTimerCount()).toBe(0)
|
||||||
|
visibility.dispatchEvent(new Event('visibilitychange'))
|
||||||
|
expect(vi.getTimerCount()).toBe(0)
|
||||||
|
})
|
||||||
@@ -8,7 +8,7 @@ let timerId: number | null = null
|
|||||||
const listeners = new Set<RelativeTimeListener>()
|
const listeners = new Set<RelativeTimeListener>()
|
||||||
|
|
||||||
const startTimer = () => {
|
const startTimer = () => {
|
||||||
if (timerId !== null) return
|
if (timerId !== null || document.hidden || listeners.size === 0) return
|
||||||
|
|
||||||
currentTime = Date.now()
|
currentTime = Date.now()
|
||||||
timerId = window.setInterval(() => {
|
timerId = window.setInterval(() => {
|
||||||
@@ -18,19 +18,34 @@ const startTimer = () => {
|
|||||||
}
|
}
|
||||||
|
|
||||||
const stopTimer = () => {
|
const stopTimer = () => {
|
||||||
if (listeners.size > 0 || timerId === null) return
|
if (timerId === null) return
|
||||||
|
|
||||||
window.clearInterval(timerId)
|
window.clearInterval(timerId)
|
||||||
timerId = null
|
timerId = null
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const handleVisibilityChange = () => {
|
||||||
|
if (document.hidden) {
|
||||||
|
stopTimer()
|
||||||
|
} else {
|
||||||
|
startTimer()
|
||||||
|
listeners.forEach((listener) => listener())
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
const subscribeRelativeTime = (listener: RelativeTimeListener) => {
|
const subscribeRelativeTime = (listener: RelativeTimeListener) => {
|
||||||
listeners.add(listener)
|
listeners.add(listener)
|
||||||
|
if (listeners.size === 1) {
|
||||||
|
document.addEventListener('visibilitychange', handleVisibilityChange)
|
||||||
|
}
|
||||||
startTimer()
|
startTimer()
|
||||||
|
|
||||||
return () => {
|
return () => {
|
||||||
listeners.delete(listener)
|
listeners.delete(listener)
|
||||||
stopTimer()
|
if (listeners.size === 0) {
|
||||||
|
stopTimer()
|
||||||
|
document.removeEventListener('visibilitychange', handleVisibilityChange)
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -0,0 +1,75 @@
|
|||||||
|
import { createElement } from 'react'
|
||||||
|
import { renderToStaticMarkup } from 'react-dom/server'
|
||||||
|
import { expect, it, vi } from 'vitest'
|
||||||
|
|
||||||
|
import { formatConnectionTraffic } from './connection-row-view'
|
||||||
|
import { ConnectionTable } from './connection-table'
|
||||||
|
|
||||||
|
const sorting = vi.hoisted(() => ({ id: 'download', desc: false }))
|
||||||
|
|
||||||
|
vi.mock('react', async (importOriginal) => ({
|
||||||
|
...(await importOriginal<typeof import('react')>()),
|
||||||
|
useState: (initial: unknown) => [
|
||||||
|
initial === null ? sorting : initial,
|
||||||
|
vi.fn(),
|
||||||
|
],
|
||||||
|
}))
|
||||||
|
vi.mock('foxact/use-local-storage', () => ({
|
||||||
|
useLocalStorage: (_key: string, initial: unknown) => [initial, vi.fn()],
|
||||||
|
}))
|
||||||
|
vi.mock('react-i18next', () => ({
|
||||||
|
useTranslation: () => ({ t: (key: string) => key }),
|
||||||
|
}))
|
||||||
|
vi.mock('./connection-column-manager', () => ({
|
||||||
|
ConnectionColumnManager: () => null,
|
||||||
|
}))
|
||||||
|
vi.mock('./connection-relative-time', () => ({
|
||||||
|
RelativeTime: () => null,
|
||||||
|
}))
|
||||||
|
vi.mock('./connection-row-view', async (importOriginal) => {
|
||||||
|
const original =
|
||||||
|
await importOriginal<typeof import('./connection-row-view')>()
|
||||||
|
return {
|
||||||
|
...original,
|
||||||
|
formatConnectionTraffic: vi.fn(original.formatConnectionTraffic),
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
it('formats only virtual rows when sorting numeric columns above the cache bound', () => {
|
||||||
|
const connections: IConnectionsItem[] = Array.from(
|
||||||
|
{ length: 2_101 },
|
||||||
|
(_, i) => ({
|
||||||
|
id: `${i}`,
|
||||||
|
metadata: {
|
||||||
|
network: 'tcp',
|
||||||
|
type: 'HTTP',
|
||||||
|
host: `host-${i}`,
|
||||||
|
sourceIP: '',
|
||||||
|
sourcePort: '',
|
||||||
|
destinationPort: '443',
|
||||||
|
},
|
||||||
|
chains: [],
|
||||||
|
rule: '',
|
||||||
|
rulePayload: '',
|
||||||
|
start: '',
|
||||||
|
upload: (i * 3) % 2_101,
|
||||||
|
download: (i * 5) % 2_101,
|
||||||
|
curUpload: (i * 7) % 2_101,
|
||||||
|
curDownload: (i * 11) % 2_101,
|
||||||
|
}),
|
||||||
|
)
|
||||||
|
|
||||||
|
for (const column of ['download', 'upload', 'dlSpeed', 'ulSpeed']) {
|
||||||
|
sorting.id = column
|
||||||
|
vi.mocked(formatConnectionTraffic).mockClear()
|
||||||
|
renderToStaticMarkup(
|
||||||
|
createElement(ConnectionTable, {
|
||||||
|
connections,
|
||||||
|
onShowDetail: vi.fn(),
|
||||||
|
columnManagerOpen: false,
|
||||||
|
onCloseColumnManager: vi.fn(),
|
||||||
|
}),
|
||||||
|
)
|
||||||
|
expect(formatConnectionTraffic).toHaveBeenCalledTimes(24)
|
||||||
|
}
|
||||||
|
})
|
||||||
@@ -223,6 +223,16 @@ const compareConnectionCellValue = (
|
|||||||
right: IConnectionsItem,
|
right: IConnectionsItem,
|
||||||
getSnapshot: (row: IConnectionsItem) => TableRowSnapshot,
|
getSnapshot: (row: IConnectionsItem) => TableRowSnapshot,
|
||||||
) => {
|
) => {
|
||||||
|
switch (field) {
|
||||||
|
case 'download':
|
||||||
|
return (left.download ?? 0) - (right.download ?? 0)
|
||||||
|
case 'upload':
|
||||||
|
return (left.upload ?? 0) - (right.upload ?? 0)
|
||||||
|
case 'dlSpeed':
|
||||||
|
return (left.curDownload ?? 0) - (right.curDownload ?? 0)
|
||||||
|
case 'ulSpeed':
|
||||||
|
return (left.curUpload ?? 0) - (right.curUpload ?? 0)
|
||||||
|
}
|
||||||
const leftValue = getConnectionCellValue(field, getSnapshot(left))
|
const leftValue = getConnectionCellValue(field, getSnapshot(left))
|
||||||
const rightValue = getConnectionCellValue(field, getSnapshot(right))
|
const rightValue = getConnectionCellValue(field, getSnapshot(right))
|
||||||
|
|
||||||
@@ -491,6 +501,8 @@ export const ConnectionTable = (props: Props) => {
|
|||||||
const [sorting, setSorting] = useState<SortingState | null>(null)
|
const [sorting, setSorting] = useState<SortingState | null>(null)
|
||||||
const [viewport, setViewport] = useState({ scrollTop: 0, height: 0 })
|
const [viewport, setViewport] = useState({ scrollTop: 0, height: 0 })
|
||||||
const scrollContainerRef = useRef<HTMLDivElement | null>(null)
|
const scrollContainerRef = useRef<HTMLDivElement | null>(null)
|
||||||
|
const resizeCleanupRef = useRef<(() => void) | null>(null)
|
||||||
|
useEffect(() => () => resizeCleanupRef.current?.(), [])
|
||||||
const rowSnapshotCacheRef = useRef(new Map<string, TableRowSnapshot>())
|
const rowSnapshotCacheRef = useRef(new Map<string, TableRowSnapshot>())
|
||||||
const getRowSnapshot = useCallback((row: IConnectionsItem) => {
|
const getRowSnapshot = useCallback((row: IConnectionsItem) => {
|
||||||
const cache = rowSnapshotCacheRef.current
|
const cache = rowSnapshotCacheRef.current
|
||||||
@@ -664,6 +676,7 @@ export const ConnectionTable = (props: Props) => {
|
|||||||
minWidth: number,
|
minWidth: number,
|
||||||
maxWidth: number | undefined,
|
maxWidth: number | undefined,
|
||||||
) => {
|
) => {
|
||||||
|
resizeCleanupRef.current?.()
|
||||||
const handleMove = (clientX: number) => {
|
const handleMove = (clientX: number) => {
|
||||||
const nextWidth = Math.max(
|
const nextWidth = Math.max(
|
||||||
minWidth,
|
minWidth,
|
||||||
@@ -686,8 +699,10 @@ export const ConnectionTable = (props: Props) => {
|
|||||||
window.removeEventListener('touchmove', handleTouchMove)
|
window.removeEventListener('touchmove', handleTouchMove)
|
||||||
window.removeEventListener('touchend', cleanup)
|
window.removeEventListener('touchend', cleanup)
|
||||||
window.removeEventListener('touchcancel', cleanup)
|
window.removeEventListener('touchcancel', cleanup)
|
||||||
|
resizeCleanupRef.current = null
|
||||||
}
|
}
|
||||||
|
|
||||||
|
resizeCleanupRef.current = cleanup
|
||||||
window.addEventListener('mousemove', handleMouseMove)
|
window.addEventListener('mousemove', handleMouseMove)
|
||||||
window.addEventListener('mouseup', cleanup)
|
window.addEventListener('mouseup', cleanup)
|
||||||
window.addEventListener('touchmove', handleTouchMove, { passive: true })
|
window.addEventListener('touchmove', handleTouchMove, { passive: true })
|
||||||
|
|||||||
@@ -16,7 +16,8 @@ const COLOR = [
|
|||||||
]
|
]
|
||||||
|
|
||||||
interface Props {
|
interface Props {
|
||||||
value: Rule & { lineNo: number }
|
value: Rule
|
||||||
|
lineNo: number
|
||||||
}
|
}
|
||||||
|
|
||||||
const parseColor = (text: string) => {
|
const parseColor = (text: string) => {
|
||||||
@@ -31,7 +32,7 @@ const parseColor = (text: string) => {
|
|||||||
}
|
}
|
||||||
|
|
||||||
const RuleItem = (props: Props) => {
|
const RuleItem = (props: Props) => {
|
||||||
const { value } = props
|
const { value, lineNo } = props
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<Item sx={{ borderBottom: '1px solid var(--divider-color)' }}>
|
<Item sx={{ borderBottom: '1px solid var(--divider-color)' }}>
|
||||||
@@ -40,7 +41,7 @@ const RuleItem = (props: Props) => {
|
|||||||
variant="body2"
|
variant="body2"
|
||||||
sx={{ lineHeight: 2, minWidth: 30, mr: 2.25, textAlign: 'center' }}
|
sx={{ lineHeight: 2, minWidth: 30, mr: 2.25, textAlign: 'center' }}
|
||||||
>
|
>
|
||||||
{value.lineNo}
|
{lineNo}
|
||||||
</Typography>
|
</Typography>
|
||||||
|
|
||||||
<Box sx={{ userSelect: 'text' }}>
|
<Box sx={{ userSelect: 'text' }}>
|
||||||
|
|||||||
@@ -0,0 +1,79 @@
|
|||||||
|
import { useSyncExternalStore } from 'react'
|
||||||
|
import { afterEach, expect, it, vi } from 'vitest'
|
||||||
|
|
||||||
|
const socket = vi.hoisted(() => ({
|
||||||
|
addListener:
|
||||||
|
vi.fn<
|
||||||
|
(listener: (message: { type: string; data: string }) => void) => void
|
||||||
|
>(),
|
||||||
|
close: async () => {},
|
||||||
|
}))
|
||||||
|
|
||||||
|
vi.mock('react', () => ({
|
||||||
|
useCallback: (callback: unknown) => callback,
|
||||||
|
useMemo: (factory: () => unknown) => factory(),
|
||||||
|
useSyncExternalStore: vi.fn((_subscribe, getSnapshot) => getSnapshot()),
|
||||||
|
}))
|
||||||
|
vi.mock('tauri-plugin-mihomo-api', () => ({
|
||||||
|
MihomoWebSocket: { connect_connections: async () => socket },
|
||||||
|
}))
|
||||||
|
|
||||||
|
afterEach(() => {
|
||||||
|
vi.useRealTimers()
|
||||||
|
vi.unstubAllGlobals()
|
||||||
|
})
|
||||||
|
|
||||||
|
it('reuses unchanged snapshot and list references without notifying subscribers', async () => {
|
||||||
|
vi.useFakeTimers()
|
||||||
|
vi.setSystemTime(10_000)
|
||||||
|
vi.stubGlobal('window', globalThis)
|
||||||
|
const { useConnectionData: readConnectionData } = await import(
|
||||||
|
'./use-connection-data'
|
||||||
|
)
|
||||||
|
readConnectionData()
|
||||||
|
const [subscribe, read] = vi
|
||||||
|
.mocked(
|
||||||
|
useSyncExternalStore<
|
||||||
|
ReturnType<typeof readConnectionData>['response']['data']
|
||||||
|
>,
|
||||||
|
)
|
||||||
|
.mock.calls.at(-1)!
|
||||||
|
const listener = vi.fn()
|
||||||
|
const unsubscribe = subscribe(listener)
|
||||||
|
await Promise.resolve()
|
||||||
|
const onMessage = socket.addListener.mock.calls[0][0]
|
||||||
|
const connections: IConnectionsItem[] = [
|
||||||
|
{
|
||||||
|
id: 'a',
|
||||||
|
metadata: {
|
||||||
|
network: 'tcp',
|
||||||
|
type: 'HTTP',
|
||||||
|
host: 'example.com',
|
||||||
|
sourceIP: '',
|
||||||
|
sourcePort: '',
|
||||||
|
destinationPort: '443',
|
||||||
|
},
|
||||||
|
chains: [],
|
||||||
|
rule: '',
|
||||||
|
rulePayload: '',
|
||||||
|
start: '',
|
||||||
|
upload: 0,
|
||||||
|
download: 0,
|
||||||
|
},
|
||||||
|
]
|
||||||
|
const send = (uploadTotal = 0) => {
|
||||||
|
vi.advanceTimersByTime(500)
|
||||||
|
onMessage({
|
||||||
|
type: 'Text',
|
||||||
|
data: JSON.stringify({ connections, uploadTotal, downloadTotal: 0 }),
|
||||||
|
})
|
||||||
|
return read()
|
||||||
|
}
|
||||||
|
const first = send()
|
||||||
|
expect(send()).toBe(first)
|
||||||
|
expect(listener).toHaveBeenCalledTimes(1)
|
||||||
|
const totalsOnly = send(100)
|
||||||
|
expect(totalsOnly.activeConnections).toBe(first.activeConnections)
|
||||||
|
expect(totalsOnly.closedConnections).toBe(first.closedConnections)
|
||||||
|
unsubscribe()
|
||||||
|
})
|
||||||
@@ -150,25 +150,49 @@ const mergeConnectionSnapshot = (
|
|||||||
const nextConnections = payload.connections ?? []
|
const nextConnections = payload.connections ?? []
|
||||||
const previousActive = previous.activeConnections ?? []
|
const previousActive = previous.activeConnections ?? []
|
||||||
const previousClosed = previous.closedConnections ?? []
|
const previousClosed = previous.closedConnections ?? []
|
||||||
const previousActiveById = new Map<string, IConnectionsItem>()
|
const sameOrder =
|
||||||
|
nextConnections.length === previousActive.length &&
|
||||||
|
nextConnections.every(
|
||||||
|
(connection, i) => connection.id === previousActive[i].id,
|
||||||
|
)
|
||||||
|
let previousActiveById: Map<string, IConnectionsItem> | undefined
|
||||||
|
|
||||||
for (let i = 0; i < previousActive.length; i++) {
|
if (!sameOrder) {
|
||||||
const previousConnection = previousActive[i]
|
previousActiveById = new Map()
|
||||||
previousActiveById.set(previousConnection.id, previousConnection)
|
for (let i = 0; i < previousActive.length; i++) {
|
||||||
|
const previousConnection = previousActive[i]
|
||||||
|
previousActiveById.set(previousConnection.id, previousConnection)
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
const activeConnections: IConnectionsItem[] = []
|
let activeConnections = sameOrder ? previousActive : []
|
||||||
for (let i = 0; i < nextConnections.length; i++) {
|
for (let i = 0; i < nextConnections.length; i++) {
|
||||||
const connection = nextConnections[i]
|
const connection = nextConnections[i]
|
||||||
const previousConnection = previousActiveById.get(connection.id)
|
const previousConnection = sameOrder
|
||||||
if (previousConnection) previousActiveById.delete(connection.id)
|
? previousActive[i]
|
||||||
activeConnections.push(normalizeConnection(connection, previousConnection))
|
: previousActiveById?.get(connection.id)
|
||||||
|
previousActiveById?.delete(connection.id)
|
||||||
|
const normalized = normalizeConnection(connection, previousConnection)
|
||||||
|
if (activeConnections[i] === normalized) continue
|
||||||
|
if (activeConnections === previousActive) {
|
||||||
|
activeConnections = previousActive.slice()
|
||||||
|
}
|
||||||
|
activeConnections[i] = normalized
|
||||||
}
|
}
|
||||||
|
|
||||||
if (previousActiveById.size === 0) {
|
const uploadTotal = payload.uploadTotal ?? 0
|
||||||
|
const downloadTotal = payload.downloadTotal ?? 0
|
||||||
|
if (!previousActiveById?.size) {
|
||||||
|
if (
|
||||||
|
activeConnections === previousActive &&
|
||||||
|
uploadTotal === previous.uploadTotal &&
|
||||||
|
downloadTotal === previous.downloadTotal
|
||||||
|
) {
|
||||||
|
return previous
|
||||||
|
}
|
||||||
return {
|
return {
|
||||||
uploadTotal: payload.uploadTotal ?? 0,
|
uploadTotal,
|
||||||
downloadTotal: payload.downloadTotal ?? 0,
|
downloadTotal,
|
||||||
activeConnections,
|
activeConnections,
|
||||||
closedConnections: previousClosed,
|
closedConnections: previousClosed,
|
||||||
}
|
}
|
||||||
@@ -198,8 +222,8 @@ const mergeConnectionSnapshot = (
|
|||||||
}
|
}
|
||||||
|
|
||||||
return {
|
return {
|
||||||
uploadTotal: payload.uploadTotal ?? 0,
|
uploadTotal,
|
||||||
downloadTotal: payload.downloadTotal ?? 0,
|
downloadTotal,
|
||||||
activeConnections,
|
activeConnections,
|
||||||
closedConnections,
|
closedConnections,
|
||||||
}
|
}
|
||||||
@@ -227,7 +251,9 @@ const flushPendingMessage = () => {
|
|||||||
|
|
||||||
lastFlushAt = Date.now()
|
lastFlushAt = Date.now()
|
||||||
|
|
||||||
connectionData = mergeConnectionSnapshot(payload, connectionData)
|
const nextData = mergeConnectionSnapshot(payload, connectionData)
|
||||||
|
if (nextData === connectionData) return
|
||||||
|
connectionData = nextData
|
||||||
notifyConnectionListeners()
|
notifyConnectionListeners()
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
+11
-8
@@ -34,13 +34,11 @@ const RulesPage = () => {
|
|||||||
}, [refreshRules, refreshRuleProviders, pageVisible])
|
}, [refreshRules, refreshRuleProviders, pageVisible])
|
||||||
|
|
||||||
const filteredRules = useMemo(() => {
|
const filteredRules = useMemo(() => {
|
||||||
const rulesWithLineNo = rules.map((item, index) => ({
|
const indices: number[] = []
|
||||||
...item,
|
for (let i = 0; i < rules.length; i++) {
|
||||||
// UI-only derived data; keep app context/SWR data immutable
|
if (match(rules[i].payload ?? '')) indices.push(i)
|
||||||
lineNo: index + 1,
|
}
|
||||||
}))
|
return indices
|
||||||
|
|
||||||
return rulesWithLineNo.filter((item) => match(item.payload ?? ''))
|
|
||||||
}, [rules, match])
|
}, [rules, match])
|
||||||
|
|
||||||
const handleScroll = useCallback((e: Event) => {
|
const handleScroll = useCallback((e: Event) => {
|
||||||
@@ -90,7 +88,12 @@ const RulesPage = () => {
|
|||||||
ref={virtuosoRef}
|
ref={virtuosoRef}
|
||||||
count={filteredRules.length}
|
count={filteredRules.length}
|
||||||
estimateSize={40}
|
estimateSize={40}
|
||||||
renderItem={(i) => <RuleItem value={filteredRules[i]} />}
|
renderItem={(i) => (
|
||||||
|
<RuleItem
|
||||||
|
value={rules[filteredRules[i]]}
|
||||||
|
lineNo={filteredRules[i] + 1}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
style={{ flex: 1 }}
|
style={{ flex: 1 }}
|
||||||
onScroll={handleScroll}
|
onScroll={handleScroll}
|
||||||
/>
|
/>
|
||||||
|
|||||||
Reference in New Issue
Block a user