feat(plugins/settings): see commit description
- Disable plugin settings button when plugin is disabled - Show tooltip on disabled tap - Retain sort options during renavigation - Cleanup and refactor
This commit is contained in:
parent
af2733ef23
commit
fc3d80e7ca
6 changed files with 258 additions and 133 deletions
5
bun.lock
5
bun.lock
|
|
@ -3,6 +3,9 @@
|
|||
"workspaces": {
|
||||
"": {
|
||||
"name": "@revenge-mod/revenge",
|
||||
"dependencies": {
|
||||
"react-native-click-outside": "^0.1.1",
|
||||
},
|
||||
"devDependencies": {
|
||||
"@biomejs/biome": "^2.0.0-beta.6",
|
||||
"@parcel/watcher": "^2.5.1",
|
||||
|
|
@ -1001,6 +1004,8 @@
|
|||
|
||||
"react-native": ["react-native@0.76.6", "", { "dependencies": { "@jest/create-cache-key-function": "^29.6.3", "@react-native/assets-registry": "0.76.6", "@react-native/codegen": "0.76.6", "@react-native/community-cli-plugin": "0.76.6", "@react-native/gradle-plugin": "0.76.6", "@react-native/js-polyfills": "0.76.6", "@react-native/normalize-colors": "0.76.6", "@react-native/virtualized-lists": "0.76.6", "abort-controller": "^3.0.0", "anser": "^1.4.9", "ansi-regex": "^5.0.0", "babel-jest": "^29.7.0", "babel-plugin-syntax-hermes-parser": "^0.23.1", "base64-js": "^1.5.1", "chalk": "^4.0.0", "commander": "^12.0.0", "event-target-shim": "^5.0.1", "flow-enums-runtime": "^0.0.6", "glob": "^7.1.1", "invariant": "^2.2.4", "jest-environment-node": "^29.6.3", "jsc-android": "^250231.0.0", "memoize-one": "^5.0.0", "metro-runtime": "^0.81.0", "metro-source-map": "^0.81.0", "mkdirp": "^0.5.1", "nullthrows": "^1.1.1", "pretty-format": "^29.7.0", "promise": "^8.3.0", "react-devtools-core": "^5.3.1", "react-refresh": "^0.14.0", "regenerator-runtime": "^0.13.2", "scheduler": "0.24.0-canary-efb381bbf-20230505", "semver": "^7.1.3", "stacktrace-parser": "^0.1.10", "whatwg-fetch": "^3.0.0", "ws": "^6.2.3", "yargs": "^17.6.2" }, "peerDependencies": { "@types/react": "^18.2.6", "react": "^18.2.0" }, "optionalPeers": ["@types/react"], "bin": { "react-native": "cli.js" } }, "sha512-AsRi+ud6v6ADH7ZtSOY42kRB4nbM0KtSu450pGO4pDudl4AEK/AF96ai88snb2/VJJSGGa/49QyJVFXxz/qoFg=="],
|
||||
|
||||
"react-native-click-outside": ["react-native-click-outside@0.1.1", "", { "peerDependencies": { "react": "*", "react-native": "*" } }, "sha512-gNLLTGG4sdajzE1m2nHP2JtFN/COznGgS98r8IC7IClIkoNtdAtYOPpNvXGv/Hav6ra4qVtSJUFEyb19TuKneQ=="],
|
||||
|
||||
"react-native-gesture-handler": ["react-native-gesture-handler@2.25.0", "", { "dependencies": { "@egjs/hammerjs": "^2.0.17", "hoist-non-react-statics": "^3.3.0", "invariant": "^2.2.4" }, "peerDependencies": { "react": "*", "react-native": "*" } }, "sha512-NPjJi6mislXxvjxQPU9IYwBjb1Uejp8GvAbE1Lhh+xMIMEvmgAvVIp5cz1P+xAbV6uYcRRArm278+tEInGOqWg=="],
|
||||
|
||||
"react-native-safe-area-context": ["react-native-safe-area-context@5.3.0", "", { "peerDependencies": { "react": "*", "react-native": "*" } }, "sha512-glV9bwuozTjf/JDBIBm+ITnukHNaUT3nucgdeADwjtHsfEN3RL5UO6nq99vvdWv5j/O9yCZBvFncM1BBQ+UvpQ=="],
|
||||
|
|
|
|||
|
|
@ -37,5 +37,8 @@
|
|||
"workspaces": [
|
||||
"lib/*",
|
||||
"plugins/*"
|
||||
]
|
||||
],
|
||||
"dependencies": {
|
||||
"react-native-click-outside": "^0.1.1"
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -16,7 +16,7 @@ const CircleCheckIcon = lookupGeneratedIconComponent(
|
|||
// TODO(plugins/settings): debug bridge
|
||||
export default function RevengeDeveloperSettingScreen() {
|
||||
return (
|
||||
<Design.Stack spacing={0} style={{ flexGrow: 1 }}>
|
||||
<Design.Stack spacing={0} style={{ flex: 1 }}>
|
||||
{globalThis.__REACT_DEVTOOLS__ && <RDTAddrSetting />}
|
||||
<SettingListRenderer.SettingsList
|
||||
sections={[
|
||||
|
|
|
|||
|
|
@ -4,9 +4,8 @@ import Page from '@revenge-mod/components/Page'
|
|||
import SearchInput from '@revenge-mod/components/SearchInput'
|
||||
import { ActionSheetActionCreators } from '@revenge-mod/discord/actions'
|
||||
import { Tokens } from '@revenge-mod/discord/common'
|
||||
import { Stores } from '@revenge-mod/discord/common/flux'
|
||||
import { Design } from '@revenge-mod/discord/design'
|
||||
import { RootNavigationRef } from '@revenge-mod/discord/modules/main_tabs_v2'
|
||||
import { ReactNavigationNative } from '@revenge-mod/externals/react-navigation'
|
||||
import { FlashList } from '@revenge-mod/externals/shopify'
|
||||
import {
|
||||
_metas,
|
||||
|
|
@ -20,40 +19,109 @@ import {
|
|||
import { PluginFlags } from '@revenge-mod/plugins/constants'
|
||||
import { debounce } from '@revenge-mod/utils/callbacks'
|
||||
import { useReRender } from '@revenge-mod/utils/react'
|
||||
import { createElement, useCallback, useMemo, useState } from 'react'
|
||||
import { Image, StyleSheet, useWindowDimensions, View } from 'react-native'
|
||||
import { RouteNames, Setting } from '../constants'
|
||||
import { useCallback, useEffect, useMemo, useState } from 'react'
|
||||
import {
|
||||
Image,
|
||||
Pressable,
|
||||
StyleSheet,
|
||||
useWindowDimensions,
|
||||
View,
|
||||
} from 'react-native'
|
||||
import {
|
||||
ClickOutsideProvider,
|
||||
useClickOutside,
|
||||
} from 'react-native-click-outside'
|
||||
import { navigatePluginSettings } from '../utils'
|
||||
import type { RouteProp } from '@react-navigation/core'
|
||||
import type { ReactNavigationParamList } from '@revenge-mod/externals/react-navigation'
|
||||
import type { InternalPlugin } from '@revenge-mod/plugins/_'
|
||||
import type { PluginApi } from '@revenge-mod/plugins/types'
|
||||
import type { MutableRefObject } from 'react'
|
||||
import type { FilterAndSortActionSheetProps } from '../components/FilterAndSortActionSheet'
|
||||
import type { RouteNames, Setting } from '../constants'
|
||||
|
||||
const { Card, Text, Stack, IconButton } = Design
|
||||
const { Card, Text, Stack, IconButton, LayerScope, useTooltip, createStyles } =
|
||||
Design
|
||||
|
||||
export default function RevengePluginsSettingScreen() {
|
||||
return (
|
||||
<LayerScope>
|
||||
<ClickOutsideProvider>
|
||||
<Page spacing={16}>
|
||||
<Screen />
|
||||
</Page>
|
||||
</ClickOutsideProvider>
|
||||
</LayerScope>
|
||||
)
|
||||
}
|
||||
|
||||
/*
|
||||
Only one tooltip can be visible at a time, so we use a global state to control the visibility of the tooltip.
|
||||
Why? Because we don't want to run useTooltip on every single plugin card. This is ugly, but it works.
|
||||
*/
|
||||
|
||||
let setTooltipVisible: ((v: boolean) => void) | undefined
|
||||
const tooltipTargetRef: MutableRefObject<View | null> = { current: null }
|
||||
|
||||
function EnablePluginTooltipProvider({
|
||||
children,
|
||||
}: {
|
||||
children: React.ReactNode
|
||||
}) {
|
||||
const [visible, setVisible] = useState(false)
|
||||
|
||||
useEffect(() => {
|
||||
setTooltipVisible = setVisible
|
||||
return () => {
|
||||
setTooltipVisible = undefined
|
||||
}
|
||||
}, [])
|
||||
|
||||
useTooltip(tooltipTargetRef, {
|
||||
label: 'Enable plugin to access settings',
|
||||
position: 'top',
|
||||
visible,
|
||||
})
|
||||
|
||||
return children
|
||||
}
|
||||
|
||||
const SearchDebounceTime = 100
|
||||
|
||||
const FilterAndSortActionSheetKey = 'filter-and-sort-plugins'
|
||||
|
||||
const DefaultSort: keyof typeof Sorts = 'Name (A-Z)'
|
||||
const Sorts = {
|
||||
'Name (A-Z)': (a, b) => a.manifest.name.localeCompare(b.manifest.name),
|
||||
'Name (Z-A)': (a, b) => b.manifest.name.localeCompare(a.manifest.name),
|
||||
'Enabled first': (a, b) =>
|
||||
Number(Boolean(b.flags & PluginFlags.Enabled)) -
|
||||
Number(a.flags & PluginFlags.Enabled),
|
||||
(b.flags & PluginFlags.Enabled) - (a.flags & PluginFlags.Enabled),
|
||||
'Disabled first': (a, b) =>
|
||||
Number(a.flags & PluginFlags.Enabled) -
|
||||
Number(b.flags & PluginFlags.Enabled),
|
||||
} as const satisfies FilterAndSortActionSheetProps<string>['sorts']
|
||||
|
||||
const DefaultSort: keyof typeof Sorts = 'Name (A-Z)'
|
||||
|
||||
export default function RevengePluginsSettingScreen() {
|
||||
const [search, setSearch] = useState('')
|
||||
const debouncedSetSearch = useCallback(debounce(setSearch, 100), [])
|
||||
const [sort, setSort] = useState(DefaultSort)
|
||||
(a.flags & PluginFlags.Enabled) - (b.flags & PluginFlags.Enabled),
|
||||
} satisfies FilterAndSortActionSheetProps<string>['sorts']
|
||||
|
||||
function Screen() {
|
||||
const { width } = useWindowDimensions()
|
||||
const numColumns = Math.floor((width - 16) / 448)
|
||||
|
||||
const plugins = useMemo(
|
||||
const navigation = ReactNavigationNative.useNavigation()
|
||||
const route =
|
||||
ReactNavigationNative.useRoute<
|
||||
RouteProp<
|
||||
ReactNavigationParamList,
|
||||
(typeof RouteNames)[typeof Setting.RevengePlugins]
|
||||
>
|
||||
>()
|
||||
|
||||
const [search, setSearch] = useState('')
|
||||
const debouncedSetSearch = useCallback(
|
||||
debounce(setSearch, SearchDebounceTime),
|
||||
[],
|
||||
)
|
||||
|
||||
const [sort, setSort] = useState(
|
||||
(route.params?.sort as keyof typeof Sorts) ?? DefaultSort,
|
||||
)
|
||||
|
||||
const allPlugins = useMemo(
|
||||
() =>
|
||||
[..._plugins.values()].map(
|
||||
plugin => [plugin, _metas.get(plugin.manifest.id)![2]] as const,
|
||||
|
|
@ -61,9 +129,9 @@ export default function RevengePluginsSettingScreen() {
|
|||
[],
|
||||
)
|
||||
|
||||
const filteredPlugins = useMemo(
|
||||
const plugins = useMemo(
|
||||
() =>
|
||||
plugins
|
||||
allPlugins
|
||||
.filter(([plugin]) => {
|
||||
const { name, description, author } = plugin.manifest
|
||||
const query = search.toLowerCase()
|
||||
|
|
@ -73,14 +141,18 @@ export default function RevengePluginsSettingScreen() {
|
|||
author.toLowerCase().includes(query)
|
||||
)
|
||||
})
|
||||
.slice()
|
||||
.sort(([a], [b]) => Sorts[sort](a, b)),
|
||||
[plugins, search, sort],
|
||||
[allPlugins, search, sort],
|
||||
)
|
||||
|
||||
useEffect(() => {
|
||||
navigation.setParams({ sort })
|
||||
ActionSheetActionCreators.hideActionSheet(FilterAndSortActionSheetKey)
|
||||
}, [sort, navigation.setParams])
|
||||
|
||||
return (
|
||||
<Page spacing={16}>
|
||||
<Design.Stack direction="horizontal">
|
||||
<EnablePluginTooltipProvider>
|
||||
<Stack direction="horizontal">
|
||||
<View style={styles.flex}>
|
||||
<SearchInput
|
||||
onChange={(v: string) => debouncedSetSearch(v)}
|
||||
|
|
@ -92,68 +164,56 @@ export default function RevengePluginsSettingScreen() {
|
|||
variant="tertiary"
|
||||
onPress={() =>
|
||||
ActionSheetActionCreators.openLazy(
|
||||
import('../components/FilterAndSortActionSheet'),
|
||||
import(
|
||||
'../components/FilterAndSortActionSheet'
|
||||
) as Promise<{
|
||||
default: typeof import('../components/FilterAndSortActionSheet').default<
|
||||
keyof typeof Sorts
|
||||
>
|
||||
}>,
|
||||
FilterAndSortActionSheetKey,
|
||||
{
|
||||
sorts: Sorts,
|
||||
selectedSort: sort,
|
||||
onSelectSort: key => {
|
||||
setSort(key as keyof typeof Sorts)
|
||||
ActionSheetActionCreators.hideActionSheet(
|
||||
FilterAndSortActionSheetKey,
|
||||
)
|
||||
},
|
||||
onSelectSort: setSort,
|
||||
},
|
||||
)
|
||||
}
|
||||
/>
|
||||
</Design.Stack>
|
||||
<FlashList.MasonryFlashList
|
||||
data={filteredPlugins}
|
||||
estimatedItemSize={108}
|
||||
numColumns={numColumns}
|
||||
renderItem={({ item: [plugin, iflags], columnIndex }) => (
|
||||
<InstalledPluginCard
|
||||
iflags={iflags}
|
||||
key={plugin.manifest.id}
|
||||
plugin={plugin}
|
||||
rightGap={columnIndex + 1 < numColumns}
|
||||
/>
|
||||
)}
|
||||
</Stack>
|
||||
<PluginMasonryFlashList
|
||||
plugins={plugins}
|
||||
numColumns={Math.floor((width - 16) / 448)}
|
||||
/>
|
||||
</Page>
|
||||
</EnablePluginTooltipProvider>
|
||||
)
|
||||
}
|
||||
|
||||
const styles = StyleSheet.create({
|
||||
flex: {
|
||||
flex: 1,
|
||||
},
|
||||
})
|
||||
|
||||
const usePluginCardStyles = Design.createStyles({
|
||||
icon: {
|
||||
width: 20,
|
||||
height: 20,
|
||||
tintColor: Tokens.default.colors.TEXT_NORMAL,
|
||||
},
|
||||
card: {
|
||||
flexGrow: 1,
|
||||
marginBottom: 12,
|
||||
paddingVertical: 12,
|
||||
paddingHorizontal: 12,
|
||||
gap: 4,
|
||||
},
|
||||
rightGap: {
|
||||
marginRight: 12,
|
||||
},
|
||||
topContainer: {
|
||||
alignItems: 'center',
|
||||
},
|
||||
alignedContainer: {
|
||||
paddingLeft: 28,
|
||||
},
|
||||
})
|
||||
function PluginMasonryFlashList({
|
||||
plugins,
|
||||
numColumns,
|
||||
}: {
|
||||
plugins: (readonly [InternalPlugin, number])[]
|
||||
numColumns: number
|
||||
}) {
|
||||
return (
|
||||
<FlashList.MasonryFlashList
|
||||
fadingEdgeLength={16}
|
||||
onScrollBeginDrag={() => setTooltipVisible?.(false)}
|
||||
data={plugins}
|
||||
estimatedItemSize={116}
|
||||
numColumns={numColumns}
|
||||
renderItem={({ item: [plugin, iflags], columnIndex }) => (
|
||||
<InstalledPluginCard
|
||||
iflags={iflags}
|
||||
key={plugin.manifest.id}
|
||||
plugin={plugin}
|
||||
rightGap={columnIndex + 1 < numColumns}
|
||||
/>
|
||||
)}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
function InstalledPluginCard({
|
||||
plugin,
|
||||
|
|
@ -173,9 +233,10 @@ function InstalledPluginCard({
|
|||
const essential = Boolean(iflags & InternalPluginFlags.Essential)
|
||||
const enabled = Boolean(flags & PluginFlags.Enabled)
|
||||
const styles_ = usePluginCardStyles()
|
||||
const ref = useClickOutside<View>(() => setTooltipVisible?.(false))
|
||||
|
||||
return (
|
||||
<Card style={[styles_.card, rightGap && styles_.rightGap]}>
|
||||
<Card style={[styles_.card, styles.flex, rightGap && styles_.rightGap]}>
|
||||
<Stack
|
||||
direction="horizontal"
|
||||
style={[styles.flex, styles_.topContainer]}
|
||||
|
|
@ -192,12 +253,25 @@ function InstalledPluginCard({
|
|||
<Text variant="heading-lg/semibold">{name}</Text>
|
||||
</Stack>
|
||||
{plugin.SettingsComponent && (
|
||||
<Design.IconButton
|
||||
size="sm"
|
||||
variant="secondary"
|
||||
icon={getAssetIdByName('SettingsIcon')!}
|
||||
onPress={() => navigatePluginSettings(plugin)}
|
||||
/>
|
||||
<Pressable
|
||||
onPress={e => {
|
||||
if (enabled) return
|
||||
|
||||
e.stopPropagation()
|
||||
tooltipTargetRef.current = ref.current
|
||||
|
||||
setTooltipVisible?.(true)
|
||||
}}
|
||||
>
|
||||
<IconButton
|
||||
ref={ref}
|
||||
size="sm"
|
||||
variant="secondary"
|
||||
icon={getAssetIdByName('SettingsIcon')!}
|
||||
onPress={() => navigatePluginSettings(plugin)}
|
||||
disabled={!enabled}
|
||||
/>
|
||||
</Pressable>
|
||||
)}
|
||||
<FormSwitch
|
||||
disabled={essential}
|
||||
|
|
@ -213,12 +287,9 @@ function InstalledPluginCard({
|
|||
reRender()
|
||||
|
||||
// TODO(plugins/settings): handle sorting after plugin enabled/disabled
|
||||
// make an event based system for this, so we can register a listener for when plugins are disabled or enabled
|
||||
// and sort the list again afterwards
|
||||
|
||||
// TODO(plugins/settings): show ReloadRequired modal
|
||||
// make an event based system for this, so we can register a listener for when plugins are disabled
|
||||
// and check its flags afterwards
|
||||
// and update the UI accordingly
|
||||
}}
|
||||
value={enabled}
|
||||
/>
|
||||
|
|
@ -239,47 +310,31 @@ function InstalledPluginCard({
|
|||
)
|
||||
}
|
||||
|
||||
// TODO(plugins/settings): Register a custom route instead, so plugin "settings" can actually be pinned and navigated to without hassle.
|
||||
// This would require us to implement the event-based plugin management system first, so we can listen to plugin enable/disable events and update the settings accordingly.
|
||||
export function navigatePluginSettings(plugin: InternalPlugin) {
|
||||
const [api] = _metas.get(plugin.manifest.id)!
|
||||
const navigation =
|
||||
RootNavigationRef.getRootNavigationRef<ReactNavigationParamList>()
|
||||
if (!navigation.isReady()) return
|
||||
const styles = StyleSheet.create({
|
||||
flex: {
|
||||
flex: 1,
|
||||
},
|
||||
})
|
||||
|
||||
navigation.navigate(RouteNames[Setting.RevengeCustomPage], {
|
||||
render: () =>
|
||||
createElement(plugin.SettingsComponent!, {
|
||||
api: api as PluginApi,
|
||||
}),
|
||||
options: plugin.manifest.icon
|
||||
? {
|
||||
headerTitle: () => (
|
||||
<Design.NavigatorHeader
|
||||
icon={
|
||||
<Image
|
||||
style={{
|
||||
width: 24,
|
||||
height: 24,
|
||||
marginEnd: 8,
|
||||
tintColor:
|
||||
Tokens.default.internal.resolveSemanticColor(
|
||||
Stores.ThemeStore.theme,
|
||||
Tokens.default.colors
|
||||
.HEADER_PRIMARY,
|
||||
),
|
||||
}}
|
||||
source={getAssetIdByName(
|
||||
plugin.manifest.icon!,
|
||||
)}
|
||||
/>
|
||||
}
|
||||
title={plugin.manifest.name}
|
||||
/>
|
||||
),
|
||||
}
|
||||
: {
|
||||
title: plugin.manifest.name,
|
||||
},
|
||||
})
|
||||
}
|
||||
const usePluginCardStyles = createStyles({
|
||||
icon: {
|
||||
width: 20,
|
||||
height: 20,
|
||||
tintColor: Tokens.default.colors.TEXT_NORMAL,
|
||||
},
|
||||
card: {
|
||||
marginBottom: 12,
|
||||
paddingVertical: 12,
|
||||
paddingHorizontal: 12,
|
||||
gap: 4,
|
||||
},
|
||||
rightGap: {
|
||||
marginRight: 12,
|
||||
},
|
||||
topContainer: {
|
||||
alignItems: 'center',
|
||||
},
|
||||
alignedContainer: {
|
||||
paddingLeft: 28,
|
||||
},
|
||||
})
|
||||
|
|
|
|||
|
|
@ -13,4 +13,8 @@ type RevengeSettingsParamList = {
|
|||
render: ComponentType
|
||||
options?: StackNavigationOptions
|
||||
}
|
||||
} & {
|
||||
[K in (typeof RouteNames)[(typeof Setting)['RevengePlugins']]]: {
|
||||
sort?: string
|
||||
}
|
||||
}
|
||||
|
|
|
|||
58
src/plugins/init/settings/utils.tsx
Normal file
58
src/plugins/init/settings/utils.tsx
Normal file
|
|
@ -0,0 +1,58 @@
|
|||
import { getAssetIdByName } from '@revenge-mod/assets'
|
||||
import { Tokens } from '@revenge-mod/discord/common'
|
||||
import { Stores } from '@revenge-mod/discord/common/flux'
|
||||
import { Design } from '@revenge-mod/discord/design'
|
||||
import { RootNavigationRef } from '@revenge-mod/discord/modules/main_tabs_v2'
|
||||
import { _metas } from '@revenge-mod/plugins/_'
|
||||
import { createElement } from 'react'
|
||||
import { Image } from 'react-native'
|
||||
import { RouteNames, Setting } from './constants'
|
||||
import type { ReactNavigationParamList } from '@revenge-mod/externals/react-navigation'
|
||||
import type { Plugin, PluginApi } from '@revenge-mod/plugins/types'
|
||||
|
||||
const { NavigatorHeader } = Design
|
||||
|
||||
// TODO(plugins/settings): Register a custom route instead, so plugin "settings" can actually be pinned and navigated to without hassle.
|
||||
// This would require us to implement the event-based plugin management system first, so we can listen to plugin enable/disable events and update the settings accordingly.
|
||||
export function navigatePluginSettings(plugin: Plugin) {
|
||||
const [api] = _metas.get(plugin.manifest.id)!
|
||||
const navigation =
|
||||
RootNavigationRef.getRootNavigationRef<ReactNavigationParamList>()
|
||||
if (!navigation.isReady()) return
|
||||
|
||||
navigation.navigate(RouteNames[Setting.RevengeCustomPage], {
|
||||
render: () =>
|
||||
createElement(plugin.SettingsComponent!, {
|
||||
api: api as PluginApi,
|
||||
}),
|
||||
options: plugin.manifest.icon
|
||||
? {
|
||||
headerTitle: () => (
|
||||
<NavigatorHeader
|
||||
icon={
|
||||
<Image
|
||||
style={{
|
||||
width: 24,
|
||||
height: 24,
|
||||
marginEnd: 8,
|
||||
tintColor:
|
||||
Tokens.default.internal.resolveSemanticColor(
|
||||
Stores.ThemeStore.theme,
|
||||
Tokens.default.colors
|
||||
.HEADER_PRIMARY,
|
||||
),
|
||||
}}
|
||||
source={getAssetIdByName(
|
||||
plugin.manifest.icon!,
|
||||
)}
|
||||
/>
|
||||
}
|
||||
title={plugin.manifest.name}
|
||||
/>
|
||||
),
|
||||
}
|
||||
: {
|
||||
title: plugin.manifest.name,
|
||||
},
|
||||
})
|
||||
}
|
||||
Loading…
Add table
Reference in a new issue