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:
PalmDevs 2025-06-11 21:41:29 +07:00
parent af2733ef23
commit fc3d80e7ca
No known key found for this signature in database
GPG key ID: 6FAE8F8677ACA8BA
6 changed files with 258 additions and 133 deletions

View file

@ -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=="],

View file

@ -37,5 +37,8 @@
"workspaces": [
"lib/*",
"plugins/*"
]
],
"dependencies": {
"react-native-click-outside": "^0.1.1"
}
}

View file

@ -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={[

View file

@ -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,
},
})

View file

@ -13,4 +13,8 @@ type RevengeSettingsParamList = {
render: ComponentType
options?: StackNavigationOptions
}
} & {
[K in (typeof RouteNames)[(typeof Setting)['RevengePlugins']]]: {
sort?: string
}
}

View 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,
},
})
}