From fc3d80e7caffe521b30f87fe46f129cdbe44c640 Mon Sep 17 00:00:00 2001 From: PalmDevs Date: Wed, 11 Jun 2025 21:41:29 +0700 Subject: [PATCH] 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 --- bun.lock | 5 + package.json | 5 +- .../screens/RevengeDeveloperSettingScreen.tsx | 2 +- .../screens/RevengePluginsSettingScreen.tsx | 317 ++++++++++-------- src/plugins/init/settings/types.ts | 4 + src/plugins/init/settings/utils.tsx | 58 ++++ 6 files changed, 258 insertions(+), 133 deletions(-) create mode 100644 src/plugins/init/settings/utils.tsx diff --git a/bun.lock b/bun.lock index 1b71a8d..aecfa64 100644 --- a/bun.lock +++ b/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=="], diff --git a/package.json b/package.json index ba4e5d7..e067112 100644 --- a/package.json +++ b/package.json @@ -37,5 +37,8 @@ "workspaces": [ "lib/*", "plugins/*" - ] + ], + "dependencies": { + "react-native-click-outside": "^0.1.1" + } } diff --git a/src/plugins/init/developer-settings/screens/RevengeDeveloperSettingScreen.tsx b/src/plugins/init/developer-settings/screens/RevengeDeveloperSettingScreen.tsx index 48ead2c..d541610 100644 --- a/src/plugins/init/developer-settings/screens/RevengeDeveloperSettingScreen.tsx +++ b/src/plugins/init/developer-settings/screens/RevengeDeveloperSettingScreen.tsx @@ -16,7 +16,7 @@ const CircleCheckIcon = lookupGeneratedIconComponent( // TODO(plugins/settings): debug bridge export default function RevengeDeveloperSettingScreen() { return ( - + {globalThis.__REACT_DEVTOOLS__ && } + + + + + + + ) +} + +/* + 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 = { 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['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['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 ( - - + + 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, }, ) } /> - - ( - - )} + + - + ) } -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 ( + setTooltipVisible?.(false)} + data={plugins} + estimatedItemSize={116} + numColumns={numColumns} + renderItem={({ item: [plugin, iflags], columnIndex }) => ( + + )} + /> + ) +} 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(() => setTooltipVisible?.(false)) return ( - + {name} {plugin.SettingsComponent && ( - navigatePluginSettings(plugin)} - /> + { + if (enabled) return + + e.stopPropagation() + tooltipTargetRef.current = ref.current + + setTooltipVisible?.(true) + }} + > + navigatePluginSettings(plugin)} + disabled={!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() - 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: () => ( - - } - 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, + }, +}) diff --git a/src/plugins/init/settings/types.ts b/src/plugins/init/settings/types.ts index f8a3a7d..5dca611 100644 --- a/src/plugins/init/settings/types.ts +++ b/src/plugins/init/settings/types.ts @@ -13,4 +13,8 @@ type RevengeSettingsParamList = { render: ComponentType options?: StackNavigationOptions } +} & { + [K in (typeof RouteNames)[(typeof Setting)['RevengePlugins']]]: { + sort?: string + } } diff --git a/src/plugins/init/settings/utils.tsx b/src/plugins/init/settings/utils.tsx new file mode 100644 index 0000000..0a51f2e --- /dev/null +++ b/src/plugins/init/settings/utils.tsx @@ -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() + if (!navigation.isReady()) return + + navigation.navigate(RouteNames[Setting.RevengeCustomPage], { + render: () => + createElement(plugin.SettingsComponent!, { + api: api as PluginApi, + }), + options: plugin.manifest.icon + ? { + headerTitle: () => ( + + } + title={plugin.manifest.name} + /> + ), + } + : { + title: plugin.manifest.name, + }, + }) +}