mirror of
https://github.com/clash-verge-rev/clash-verge-rev.git
synced 2026-10-26 05:42:07 +08:00
perf: improve drag performance for large profile lists
This commit is contained in:
@@ -65,6 +65,7 @@
|
||||
- 优化页面不可见时的 Mihomo WebSocket 订阅,减少后台资源占用
|
||||
- 代理组支持分别恢复普通模式和链式代理模式的滚动位置
|
||||
- 重构改进订阅配置切换逻辑
|
||||
- 优化大量订阅卡片时的拖拽排序性能
|
||||
|
||||
</details>
|
||||
|
||||
|
||||
@@ -1,5 +1,7 @@
|
||||
import { useSortable } from '@dnd-kit/sortable'
|
||||
import { CSS } from '@dnd-kit/utilities'
|
||||
import type {
|
||||
DraggableAttributes,
|
||||
DraggableSyntheticListeners,
|
||||
} from '@dnd-kit/core'
|
||||
import {
|
||||
CheckBoxOutlineBlankRounded,
|
||||
CheckBoxRounded,
|
||||
@@ -16,11 +18,17 @@ import {
|
||||
MenuItem,
|
||||
Typography,
|
||||
} from '@mui/material'
|
||||
import { listen } from '@tauri-apps/api/event'
|
||||
import { open } from '@tauri-apps/plugin-shell'
|
||||
import { useLockFn } from 'ahooks'
|
||||
import dayjs from 'dayjs'
|
||||
import { useCallback, useEffect, useReducer, useRef, useState } from 'react'
|
||||
import {
|
||||
memo,
|
||||
useCallback,
|
||||
useEffect,
|
||||
useReducer,
|
||||
useRef,
|
||||
useState,
|
||||
} from 'react'
|
||||
import { useTranslation } from 'react-i18next'
|
||||
|
||||
import { BaseDialog } from '@/components/base'
|
||||
@@ -49,8 +57,7 @@ const round = keyframes`
|
||||
to { transform: rotate(360deg); }
|
||||
`
|
||||
|
||||
interface Props {
|
||||
id: string
|
||||
export interface ProfileItemProps {
|
||||
selected: boolean
|
||||
activating: boolean
|
||||
itemData: IProfileItem
|
||||
@@ -62,11 +69,15 @@ interface Props {
|
||||
batchMode?: boolean
|
||||
isSelected?: boolean
|
||||
onSelectionChange?: () => void
|
||||
timerUpdateRevision: number
|
||||
completedUpdateRevision: number
|
||||
dragHandleRef: (node: HTMLElement | null) => void
|
||||
dragHandleAttributes: DraggableAttributes
|
||||
dragHandleListeners: DraggableSyntheticListeners
|
||||
}
|
||||
|
||||
export const ProfileItem = (props: Props) => {
|
||||
const ProfileItemBase = (props: ProfileItemProps) => {
|
||||
const {
|
||||
id,
|
||||
selected,
|
||||
activating,
|
||||
itemData,
|
||||
@@ -78,17 +89,12 @@ export const ProfileItem = (props: Props) => {
|
||||
batchMode,
|
||||
isSelected,
|
||||
onSelectionChange,
|
||||
timerUpdateRevision,
|
||||
completedUpdateRevision,
|
||||
dragHandleRef,
|
||||
dragHandleAttributes,
|
||||
dragHandleListeners,
|
||||
} = props
|
||||
const {
|
||||
attributes,
|
||||
listeners,
|
||||
setNodeRef,
|
||||
transform,
|
||||
transition,
|
||||
isDragging,
|
||||
} = useSortable({
|
||||
id,
|
||||
})
|
||||
|
||||
const { t } = useTranslation()
|
||||
const [anchorEl, setAnchorEl] = useState<HTMLElement | null>(null)
|
||||
@@ -121,71 +127,75 @@ export const ProfileItem = (props: Props) => {
|
||||
const { uid, name = 'Profile', extra, updated = 0, option } = itemData
|
||||
|
||||
// 获取下次更新时间的函数
|
||||
const fetchNextUpdateTime = useLockFn(async (forceRefresh = false) => {
|
||||
if (
|
||||
itemData.option?.update_interval &&
|
||||
itemData.option.update_interval > 0
|
||||
) {
|
||||
try {
|
||||
debugLog(`尝试获取配置 ${itemData.uid} 的下次更新时间`)
|
||||
const fetchNextUpdateTimeCallback = useCallback(
|
||||
async (forceRefresh = false) => {
|
||||
if (
|
||||
itemData.option?.update_interval &&
|
||||
itemData.option.update_interval > 0
|
||||
) {
|
||||
try {
|
||||
debugLog(`尝试获取配置 ${itemData.uid} 的下次更新时间`)
|
||||
|
||||
// 如果需要强制刷新,先触发Timer.refresh()
|
||||
if (forceRefresh) {
|
||||
// 这里可以通过一个新的API来触发刷新,但目前我们依赖patch_profile中的刷新
|
||||
debugLog(`强制刷新定时器任务`)
|
||||
}
|
||||
// 如果需要强制刷新,先触发Timer.refresh()
|
||||
if (forceRefresh) {
|
||||
// 这里可以通过一个新的API来触发刷新,但目前我们依赖patch_profile中的刷新
|
||||
debugLog(`强制刷新定时器任务`)
|
||||
}
|
||||
|
||||
const nextUpdate = await getNextUpdateTime(itemData.uid)
|
||||
debugLog(`获取到下次更新时间结果:`, nextUpdate)
|
||||
const nextUpdate = await getNextUpdateTime(itemData.uid)
|
||||
debugLog(`获取到下次更新时间结果:`, nextUpdate)
|
||||
|
||||
if (nextUpdate) {
|
||||
const nextUpdateDate = dayjs(nextUpdate * 1000)
|
||||
const now = dayjs()
|
||||
if (nextUpdate) {
|
||||
const nextUpdateDate = dayjs(nextUpdate * 1000)
|
||||
const now = dayjs()
|
||||
|
||||
// 如果已经过期,显示"更新失败"
|
||||
if (nextUpdateDate.isBefore(now)) {
|
||||
setNextUpdateTime(
|
||||
t('profiles.components.profileItem.status.lastUpdateFailed'),
|
||||
)
|
||||
} else {
|
||||
// 否则显示剩余时间
|
||||
const diffMinutes = nextUpdateDate.diff(now, 'minute')
|
||||
// 如果已经过期,显示"更新失败"
|
||||
if (nextUpdateDate.isBefore(now)) {
|
||||
setNextUpdateTime(
|
||||
t('profiles.components.profileItem.status.lastUpdateFailed'),
|
||||
)
|
||||
} else {
|
||||
// 否则显示剩余时间
|
||||
const diffMinutes = nextUpdateDate.diff(now, 'minute')
|
||||
|
||||
if (diffMinutes < 60) {
|
||||
if (diffMinutes <= 0) {
|
||||
setNextUpdateTime(
|
||||
`${t('profiles.components.profileItem.status.nextUp')} <1m`,
|
||||
)
|
||||
if (diffMinutes < 60) {
|
||||
if (diffMinutes <= 0) {
|
||||
setNextUpdateTime(
|
||||
`${t('profiles.components.profileItem.status.nextUp')} <1m`,
|
||||
)
|
||||
} else {
|
||||
setNextUpdateTime(
|
||||
`${t('profiles.components.profileItem.status.nextUp')} ${diffMinutes}m`,
|
||||
)
|
||||
}
|
||||
} else {
|
||||
const hours = Math.floor(diffMinutes / 60)
|
||||
const mins = diffMinutes % 60
|
||||
setNextUpdateTime(
|
||||
`${t('profiles.components.profileItem.status.nextUp')} ${diffMinutes}m`,
|
||||
`${t('profiles.components.profileItem.status.nextUp')} ${hours}h ${mins}m`,
|
||||
)
|
||||
}
|
||||
} else {
|
||||
const hours = Math.floor(diffMinutes / 60)
|
||||
const mins = diffMinutes % 60
|
||||
setNextUpdateTime(
|
||||
`${t('profiles.components.profileItem.status.nextUp')} ${hours}h ${mins}m`,
|
||||
)
|
||||
}
|
||||
} else {
|
||||
debugLog(`返回的下次更新时间为空`)
|
||||
setNextUpdateTime(
|
||||
t('profiles.components.profileItem.status.noSchedule'),
|
||||
)
|
||||
}
|
||||
} else {
|
||||
debugLog(`返回的下次更新时间为空`)
|
||||
setNextUpdateTime(
|
||||
t('profiles.components.profileItem.status.noSchedule'),
|
||||
)
|
||||
} catch (err) {
|
||||
console.error(`获取下次更新时间出错:`, err)
|
||||
setNextUpdateTime(t('profiles.components.profileItem.status.unknown'))
|
||||
}
|
||||
} catch (err) {
|
||||
console.error(`获取下次更新时间出错:`, err)
|
||||
setNextUpdateTime(t('profiles.components.profileItem.status.unknown'))
|
||||
} else {
|
||||
debugLog(`该配置未设置更新间隔或间隔为0`)
|
||||
setNextUpdateTime(
|
||||
t('profiles.components.profileItem.status.autoUpdateDisabled'),
|
||||
)
|
||||
}
|
||||
} else {
|
||||
debugLog(`该配置未设置更新间隔或间隔为0`)
|
||||
setNextUpdateTime(
|
||||
t('profiles.components.profileItem.status.autoUpdateDisabled'),
|
||||
)
|
||||
}
|
||||
})
|
||||
},
|
||||
[itemData.option?.update_interval, itemData.uid, t],
|
||||
)
|
||||
const fetchNextUpdateTime = useLockFn(fetchNextUpdateTimeCallback)
|
||||
|
||||
// 切换显示模式的函数
|
||||
const toggleUpdateTimeDisplay = (e: React.MouseEvent) => {
|
||||
@@ -214,40 +224,28 @@ export const ProfileItem = (props: Props) => {
|
||||
updated,
|
||||
])
|
||||
|
||||
// 订阅定时器更新事件
|
||||
// 页面统一订阅定时器事件,这里只响应当前配置的更新信号
|
||||
useEffect(() => {
|
||||
let disposed = false
|
||||
let unlistenTimerUpdate: (() => void) | undefined
|
||||
if (timerUpdateRevision === 0 || !showNextUpdateRef.current) return
|
||||
|
||||
listen<string>('verge://timer-updated', ({ payload: updatedUid }) => {
|
||||
// 只有当更新的是当前配置时才刷新显示
|
||||
if (updatedUid === itemData.uid && showNextUpdateRef.current) {
|
||||
debugLog(`收到定时器更新事件: uid=${updatedUid}`)
|
||||
if (refreshTimeoutRef.current !== undefined) {
|
||||
clearTimeout(refreshTimeoutRef.current)
|
||||
}
|
||||
refreshTimeoutRef.current = window.setTimeout(() => {
|
||||
fetchNextUpdateTime(true)
|
||||
}, 1000)
|
||||
}
|
||||
})
|
||||
.then((unlisten) => {
|
||||
if (disposed) {
|
||||
unlisten()
|
||||
return
|
||||
}
|
||||
unlistenTimerUpdate = unlisten
|
||||
})
|
||||
.catch(console.error)
|
||||
if (refreshTimeoutRef.current !== undefined) {
|
||||
clearTimeout(refreshTimeoutRef.current)
|
||||
}
|
||||
refreshTimeoutRef.current = window.setTimeout(() => {
|
||||
fetchNextUpdateTime(true)
|
||||
}, 1000)
|
||||
|
||||
return () => {
|
||||
disposed = true
|
||||
if (refreshTimeoutRef.current !== undefined) {
|
||||
clearTimeout(refreshTimeoutRef.current)
|
||||
}
|
||||
unlistenTimerUpdate?.()
|
||||
}
|
||||
}, [fetchNextUpdateTime, itemData.uid])
|
||||
}, [fetchNextUpdateTime, timerUpdateRevision])
|
||||
|
||||
useEffect(() => {
|
||||
if (completedUpdateRevision === 0 || !showNextUpdateRef.current) return
|
||||
fetchNextUpdateTime()
|
||||
}, [completedUpdateRevision, fetchNextUpdateTime])
|
||||
|
||||
// local file mode
|
||||
// remote file mode
|
||||
@@ -605,52 +603,6 @@ export const ProfileItem = (props: Props) => {
|
||||
justifyContent: 'space-between',
|
||||
}
|
||||
|
||||
// 监听自动更新事件
|
||||
useEffect(() => {
|
||||
let disposed = false
|
||||
let unlisteners: Array<() => void> = []
|
||||
|
||||
Promise.allSettled([
|
||||
listen<{ uid?: string }>('profile-update-started', ({ payload }) => {
|
||||
if (payload.uid === itemData.uid) {
|
||||
setLoading(true)
|
||||
}
|
||||
}),
|
||||
listen<{ uid?: string }>('profile-update-completed', ({ payload }) => {
|
||||
if (payload.uid !== itemData.uid) {
|
||||
return
|
||||
}
|
||||
|
||||
setLoading(false)
|
||||
// 刷新 profile 数据以获取最新的 updated 时间戳
|
||||
void mutateProfiles()
|
||||
// 更新完成后刷新显示
|
||||
if (showNextUpdateRef.current) {
|
||||
fetchNextUpdateTime()
|
||||
}
|
||||
}),
|
||||
]).then((results) => {
|
||||
const registeredUnlisteners = results.flatMap((result) =>
|
||||
result.status === 'fulfilled' ? [result.value] : [],
|
||||
)
|
||||
|
||||
if (disposed || results.some((result) => result.status === 'rejected')) {
|
||||
registeredUnlisteners.forEach((unlisten) => unlisten())
|
||||
results.forEach((result) => {
|
||||
if (result.status === 'rejected') console.error(result.reason)
|
||||
})
|
||||
return
|
||||
}
|
||||
|
||||
unlisteners = registeredUnlisteners
|
||||
})
|
||||
|
||||
return () => {
|
||||
disposed = true
|
||||
unlisteners.forEach((unlisten) => unlisten())
|
||||
}
|
||||
}, [fetchNextUpdateTime, itemData.uid, mutateProfiles, setLoading])
|
||||
|
||||
const handleSaveProfileDocument = useLockFn(async () => {
|
||||
const currentValue = profileDocument.value
|
||||
if (!(await saveProfileFile(uid, currentValue))) {
|
||||
@@ -684,14 +636,7 @@ export const ProfileItem = (props: Props) => {
|
||||
})
|
||||
|
||||
return (
|
||||
<Box
|
||||
sx={{
|
||||
position: 'relative',
|
||||
transform: CSS.Transform.toString(transform),
|
||||
transition,
|
||||
zIndex: isDragging ? 'calc(infinity)' : undefined,
|
||||
}}
|
||||
>
|
||||
<Box sx={{ position: 'relative' }}>
|
||||
<ProfileBox
|
||||
aria-selected={selected}
|
||||
onClick={(e) => {
|
||||
@@ -756,14 +701,14 @@ export const ProfileItem = (props: Props) => {
|
||||
</IconButton>
|
||||
)}
|
||||
<Box
|
||||
ref={setNodeRef}
|
||||
ref={dragHandleRef}
|
||||
sx={{
|
||||
display: 'flex',
|
||||
margin: 'auto 0',
|
||||
...(batchMode && { marginLeft: '-4px' }),
|
||||
}}
|
||||
{...attributes}
|
||||
{...listeners}
|
||||
{...dragHandleAttributes}
|
||||
{...dragHandleListeners}
|
||||
>
|
||||
<DragIndicatorRounded
|
||||
sx={[
|
||||
@@ -1041,6 +986,8 @@ export const ProfileItem = (props: Props) => {
|
||||
)
|
||||
}
|
||||
|
||||
export const ProfileItem = memo(ProfileItemBase)
|
||||
|
||||
function parseUrl(url?: string) {
|
||||
if (!url) return ''
|
||||
const regex = /https?:\/\/(.+?)\//
|
||||
|
||||
@@ -0,0 +1,44 @@
|
||||
import { useSortable } from '@dnd-kit/sortable'
|
||||
import { CSS } from '@dnd-kit/utilities'
|
||||
import { Box } from '@mui/material'
|
||||
|
||||
import { ProfileItem, type ProfileItemProps } from './profile-item'
|
||||
|
||||
type SortableProfileItemProps = Omit<
|
||||
ProfileItemProps,
|
||||
'dragHandleRef' | 'dragHandleAttributes' | 'dragHandleListeners'
|
||||
> & {
|
||||
id: string
|
||||
}
|
||||
|
||||
export const SortableProfileItem = ({
|
||||
id,
|
||||
...profileItemProps
|
||||
}: SortableProfileItemProps) => {
|
||||
const {
|
||||
attributes,
|
||||
listeners,
|
||||
setNodeRef,
|
||||
transform,
|
||||
transition,
|
||||
isDragging,
|
||||
} = useSortable({ id })
|
||||
|
||||
return (
|
||||
<Box
|
||||
sx={{
|
||||
position: 'relative',
|
||||
transform: CSS.Transform.toString(transform),
|
||||
transition,
|
||||
zIndex: isDragging ? 'calc(infinity)' : undefined,
|
||||
}}
|
||||
>
|
||||
<ProfileItem
|
||||
{...profileItemProps}
|
||||
dragHandleRef={setNodeRef}
|
||||
dragHandleAttributes={attributes}
|
||||
dragHandleListeners={listeners}
|
||||
/>
|
||||
</Box>
|
||||
)
|
||||
}
|
||||
+106
-4
@@ -8,7 +8,11 @@ import {
|
||||
useSensor,
|
||||
useSensors,
|
||||
} from '@dnd-kit/core'
|
||||
import { SortableContext, sortableKeyboardCoordinates } from '@dnd-kit/sortable'
|
||||
import {
|
||||
SortableContext,
|
||||
sortableKeyboardCoordinates,
|
||||
type SortingStrategy,
|
||||
} from '@dnd-kit/sortable'
|
||||
import {
|
||||
CheckBoxOutlineBlankRounded,
|
||||
CheckBoxRounded,
|
||||
@@ -21,7 +25,7 @@ import {
|
||||
TextSnippetOutlined,
|
||||
} from '@mui/icons-material'
|
||||
import { Box, Button, Divider, Grid, IconButton, Stack } from '@mui/material'
|
||||
import { TauriEvent } from '@tauri-apps/api/event'
|
||||
import { listen, TauriEvent } from '@tauri-apps/api/event'
|
||||
import { readText } from '@tauri-apps/plugin-clipboard-manager'
|
||||
import { readTextFile } from '@tauri-apps/plugin-fs'
|
||||
import { useLockFn } from 'ahooks'
|
||||
@@ -36,12 +40,12 @@ import {
|
||||
BaseStyledTextField,
|
||||
type DialogRef,
|
||||
} from '@/components/base'
|
||||
import { ProfileItem } from '@/components/profile/profile-item'
|
||||
import { ProfileMore } from '@/components/profile/profile-more'
|
||||
import {
|
||||
ProfileViewer,
|
||||
type ProfileViewerRef,
|
||||
} from '@/components/profile/profile-viewer'
|
||||
import { SortableProfileItem } from '@/components/profile/sortable-profile-item'
|
||||
import { ConfigViewer } from '@/components/setting/mods/config-viewer'
|
||||
import { useListen } from '@/hooks/use-listen'
|
||||
import { useProfiles } from '@/hooks/use-profiles'
|
||||
@@ -73,6 +77,43 @@ import { debugLog } from '@/utils/debug'
|
||||
const PROFILE_UPDATE_WORKER_LIMIT = 8
|
||||
const PROFILE_SWITCH_LOADING_DELAY = 400
|
||||
|
||||
// Equivalent to rectSortingStrategy without copying the full rect array for every item.
|
||||
const profileRectSortingStrategy: SortingStrategy = ({
|
||||
rects,
|
||||
activeIndex,
|
||||
overIndex,
|
||||
index,
|
||||
}) => {
|
||||
let newIndex = index
|
||||
|
||||
if (index === activeIndex) {
|
||||
newIndex = overIndex
|
||||
} else if (
|
||||
activeIndex < overIndex &&
|
||||
index > activeIndex &&
|
||||
index <= overIndex
|
||||
) {
|
||||
newIndex = index - 1
|
||||
} else if (
|
||||
activeIndex > overIndex &&
|
||||
index >= overIndex &&
|
||||
index < activeIndex
|
||||
) {
|
||||
newIndex = index + 1
|
||||
}
|
||||
|
||||
const oldRect = rects[index]
|
||||
const newRect = rects[newIndex]
|
||||
if (!oldRect || !newRect) return null
|
||||
|
||||
return {
|
||||
x: newRect.left - oldRect.left,
|
||||
y: newRect.top - oldRect.top,
|
||||
scaleX: newRect.width / oldRect.width,
|
||||
scaleY: newRect.height / oldRect.height,
|
||||
}
|
||||
}
|
||||
|
||||
interface ProfileSwitchRequest {
|
||||
profile: string
|
||||
notifySuccess: boolean
|
||||
@@ -97,6 +138,12 @@ const ProfilePage = () => {
|
||||
string | null
|
||||
>(null)
|
||||
const [loading, setLoading] = useState(false)
|
||||
const [timerUpdateRevisions, setTimerUpdateRevisions] = useState<
|
||||
Map<string, number>
|
||||
>(() => new Map())
|
||||
const [completedUpdateRevisions, setCompletedUpdateRevisions] = useState<
|
||||
Map<string, number>
|
||||
>(() => new Map())
|
||||
|
||||
// Batch selection states
|
||||
const [batchMode, setBatchMode] = useState(false)
|
||||
@@ -536,6 +583,54 @@ const ProfilePage = () => {
|
||||
},
|
||||
[setLoadingCache],
|
||||
)
|
||||
|
||||
useEffect(() => {
|
||||
let disposed = false
|
||||
let unlisteners: Array<() => void> = []
|
||||
|
||||
Promise.allSettled([
|
||||
listen<{ uid?: string }>('profile-update-started', ({ payload }) => {
|
||||
if (payload.uid) setLoadingProfiles([payload.uid], true)
|
||||
}),
|
||||
listen<{ uid?: string }>('profile-update-completed', ({ payload }) => {
|
||||
const { uid } = payload
|
||||
if (!uid) return
|
||||
setLoadingProfiles([uid], false)
|
||||
setCompletedUpdateRevisions((current) => {
|
||||
const next = new Map(current)
|
||||
next.set(uid, (next.get(uid) ?? 0) + 1)
|
||||
return next
|
||||
})
|
||||
void mutateProfiles()
|
||||
}),
|
||||
listen<string>('verge://timer-updated', ({ payload: uid }) => {
|
||||
setTimerUpdateRevisions((current) => {
|
||||
const next = new Map(current)
|
||||
next.set(uid, (next.get(uid) ?? 0) + 1)
|
||||
return next
|
||||
})
|
||||
}),
|
||||
]).then((results) => {
|
||||
const registeredUnlisteners = results.flatMap((result) =>
|
||||
result.status === 'fulfilled' ? [result.value] : [],
|
||||
)
|
||||
results.forEach((result) => {
|
||||
if (result.status === 'rejected') console.error(result.reason)
|
||||
})
|
||||
|
||||
if (disposed) {
|
||||
registeredUnlisteners.forEach((unlisten) => unlisten())
|
||||
} else {
|
||||
unlisteners = registeredUnlisteners
|
||||
}
|
||||
})
|
||||
|
||||
return () => {
|
||||
disposed = true
|
||||
unlisteners.forEach((unlisten) => unlisten())
|
||||
}
|
||||
}, [mutateProfiles, setLoadingProfiles])
|
||||
|
||||
const runProfileUpdates = useCallback(
|
||||
async (uids: string[]) => {
|
||||
if (uids.length === 0) return
|
||||
@@ -889,13 +984,14 @@ const ProfilePage = () => {
|
||||
<Box sx={{ mb: 1.5 }}>
|
||||
<Grid container spacing={{ xs: 1, lg: 1 }}>
|
||||
<SortableContext
|
||||
strategy={profileRectSortingStrategy}
|
||||
items={profileItems.map((x) => {
|
||||
return x.uid
|
||||
})}
|
||||
>
|
||||
{profileItems.map((item) => (
|
||||
<Grid size={{ xs: 12, sm: 6, md: 4, lg: 3 }} key={item.file}>
|
||||
<ProfileItem
|
||||
<SortableProfileItem
|
||||
id={item.uid}
|
||||
selected={(switchTarget ?? profiles.current) === item.uid}
|
||||
activating={
|
||||
@@ -903,6 +999,12 @@ const ProfilePage = () => {
|
||||
visibleSwitchingProfile === item.uid
|
||||
}
|
||||
itemData={item}
|
||||
timerUpdateRevision={
|
||||
timerUpdateRevisions.get(item.uid) ?? 0
|
||||
}
|
||||
completedUpdateRevision={
|
||||
completedUpdateRevisions.get(item.uid) ?? 0
|
||||
}
|
||||
mutateProfiles={mutateProfiles}
|
||||
onSelect={(f) => onSelect(item.uid, f)}
|
||||
onEdit={() => viewerRef.current?.edit(item)}
|
||||
|
||||
Reference in New Issue
Block a user