From a969a6483281a32c653f8a00bcd22033e0d95471 Mon Sep 17 00:00:00 2001 From: PalmDevs Date: Thu, 12 Jun 2025 22:18:52 +0700 Subject: [PATCH] feat(plugins/settings): see commit description - Added ability to filter plugins - Show tooltip when attempting to disable essential plugins - Moved tooltip providers to its own file - Moved PluginCard to its own file --- .../components/FilterAndSortActionSheet.tsx | 117 ++++++- .../init/settings/components/PluginCard.tsx | 172 +++++++++ .../settings/components/TooltipProvider.tsx | 74 ++++ .../screens/RevengePluginsSettingScreen.tsx | 329 ++++++------------ src/plugins/init/settings/types.ts | 3 + 5 files changed, 458 insertions(+), 237 deletions(-) create mode 100644 src/plugins/init/settings/components/PluginCard.tsx create mode 100644 src/plugins/init/settings/components/TooltipProvider.tsx diff --git a/src/plugins/init/settings/components/FilterAndSortActionSheet.tsx b/src/plugins/init/settings/components/FilterAndSortActionSheet.tsx index 5f8721a..5f29022 100644 --- a/src/plugins/init/settings/components/FilterAndSortActionSheet.tsx +++ b/src/plugins/init/settings/components/FilterAndSortActionSheet.tsx @@ -1,33 +1,120 @@ +import TableRowAssetIcon from '@revenge-mod/components/TableRowAssetIcon' import { Design } from '@revenge-mod/discord/design' +import { useState } from 'react' +import type { AssetId } from '@revenge-mod/assets/types' import type { InternalPlugin } from '@revenge-mod/plugins/_' -export interface FilterAndSortActionSheetProps { - sorts: Record number> - selectedSort: S - onSelectSort: (fn: S) => void +export interface FilterAndSortActionSheetProps { + filters: Record< + string, + [icon: AssetId, (p: InternalPlugin, iflags: number) => boolean] + > + filter: string[] + setFilter: (filters: string[]) => void + matchAll: boolean + setMatchAll: (matchAll: boolean) => void + sorts: Record< + string, + [icon: AssetId, (a: InternalPlugin, b: InternalPlugin) => number] + > + sort: string + setSort: (fn: string) => void + reverse: boolean + setReverse: (reverse: boolean) => void } -const { ActionSheet, BottomSheetTitleHeader, TableRadioGroup, TableRadioRow } = - Design +const { + ActionSheet, + BottomSheetTitleHeader, + TableCheckboxRow, + TableRadioGroup, + TableRadioRow, + TableRowGroup, + TableSwitchRow, +} = Design // TODO(plugins/settings): Add filter options -export default function FilterAndSortActionSheet({ +export default function FilterAndSortActionSheet({ + filters, + filter, + setFilter, + matchAll, + setMatchAll, sorts, - selectedSort, - onSelectSort, -}: FilterAndSortActionSheetProps) { + sort, + setSort, + reverse, + setReverse, +}: FilterAndSortActionSheetProps) { + const [filter_, setFilter_] = useState(filter) + const [sort_, setSort_] = useState(sort) + const [reverse_, setReverse_] = useState(reverse) + const [matchAll_, setMatchAll_] = useState(matchAll ?? false) + return ( + + {Object.entries(filters).map(([label, [icon]]) => { + const checked = filter_.includes(label) + + return ( + } + label={label} + value={label} + checked={checked} + onPress={() => { + const v = checked + ? filter_.filter(f => f !== label) + : [...filter_, label] + + setFilter(v) + setFilter_(v) + }} + /> + ) + })} + + + { + setMatchAll(v) + setMatchAll_(v) + }} + /> + { + setSort(v) + setSort_(v) + }} > - {Object.keys(sorts).map(label => ( - + {Object.entries(sorts).map(([label, [icon]]) => ( + } + label={label} + value={label} + /> ))} + + { + setReverse(v) + setReverse_(v) + }} + /> + ) } diff --git a/src/plugins/init/settings/components/PluginCard.tsx b/src/plugins/init/settings/components/PluginCard.tsx new file mode 100644 index 0000000..700a70f --- /dev/null +++ b/src/plugins/init/settings/components/PluginCard.tsx @@ -0,0 +1,172 @@ +import { getAssetIdByName } from '@revenge-mod/assets' +import { styles } from '@revenge-mod/components/_' +import FormSwitch from '@revenge-mod/components/FormSwitch' +import { Tokens } from '@revenge-mod/discord/common' +import { Design } from '@revenge-mod/discord/design' +import { + enablePlugin, + InternalPluginFlags, + initPlugin, + preInitPlugin, + startPlugin, +} from '@revenge-mod/plugins/_' +import { PluginFlags } from '@revenge-mod/plugins/constants' +import { useReRender } from '@revenge-mod/utils/react' +import { Image, Pressable } from 'react-native' +import { useClickOutside } from 'react-native-click-outside' +import { navigatePluginSettings } from '../utils' +import { + enableTooltipTarget, + essentialTooltipTarget, + resetTooltips, + setEnablePluginTooltipVisible, + setEssentialPluginTooltipVisible, +} from './TooltipProvider' +import type { InternalPlugin } from '@revenge-mod/plugins/_' +import type { View } from 'react-native' + +const { Card, Text, Stack, IconButton, createStyles } = Design + +const PuzzlePieceIcon = getAssetIdByName('PuzzlePieceIcon', 'png')! +const SettingsIcon = getAssetIdByName('SettingsIcon', 'png')! + +export function InstalledPluginCard({ + plugin, + iflags, + rightGap, +}: { + plugin: InternalPlugin + iflags: number + rightGap?: boolean +}) { + const { + manifest: { name, description, author, icon }, + flags, + } = plugin + + const reRender = useReRender() + const essential = Boolean(iflags & InternalPluginFlags.Essential) + const enabled = Boolean(flags & PluginFlags.Enabled) + const styles_ = usePluginCardStyles() + + const settingsRef = useClickOutside(() => { + if (enableTooltipTarget.current === settingsRef.current) + setEnablePluginTooltipVisible?.(false) + }) + + const switchRef = useClickOutside(() => { + if (essentialTooltipTarget.current === switchRef.current) + setEssentialPluginTooltipVisible?.(false) + }) + + return ( + + + + + {name} + + {plugin.SettingsComponent && ( + { + if (enabled) return + + e.stopPropagation() + resetTooltips() + + enableTooltipTarget.current = settingsRef.current + setEnablePluginTooltipVisible?.(true) + }} + > + navigatePluginSettings(plugin)} + disabled={!enabled} + /> + + )} + { + if (essential) { + e.stopPropagation() + resetTooltips() + + essentialTooltipTarget.current = switchRef.current + setEssentialPluginTooltipVisible?.(true) + } + }} + ref={switchRef} + > + + + + + + by {author} + + + {description} + + + + ) +} + +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/components/TooltipProvider.tsx b/src/plugins/init/settings/components/TooltipProvider.tsx new file mode 100644 index 0000000..690cdf5 --- /dev/null +++ b/src/plugins/init/settings/components/TooltipProvider.tsx @@ -0,0 +1,74 @@ +/* + 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. +*/ + +import { Design } from '@revenge-mod/discord/design' +import { useEffect, useState } from 'react' +import type { MutableRefObject } from 'react' +import type { View } from 'react-native' + +const { useTooltip } = Design + +export let setEnablePluginTooltipVisible: ((v: boolean) => void) | undefined +export const enableTooltipTarget: MutableRefObject = { + current: null, +} + +export function EnablePluginTooltipProvider({ + children, +}: { + children: React.ReactNode +}) { + const [visible, setVisible] = useState(false) + + useEffect(() => { + setEnablePluginTooltipVisible = setVisible + return () => { + enableTooltipTarget.current = null + setEnablePluginTooltipVisible = undefined + } + }, []) + + useTooltip(enableTooltipTarget, { + label: 'Plugin must be enabled first', + position: 'top', + visible, + }) + + return children +} + +export let setEssentialPluginTooltipVisible: ((v: boolean) => void) | undefined +export const essentialTooltipTarget: MutableRefObject = { + current: null, +} + +export function EssentialPluginTooltipProvider({ + children, +}: { + children: React.ReactNode +}) { + const [visible, setVisible] = useState(false) + + useEffect(() => { + setEssentialPluginTooltipVisible = setVisible + return () => { + essentialTooltipTarget.current = null + setEssentialPluginTooltipVisible = undefined + } + }, []) + + useTooltip(essentialTooltipTarget, { + label: 'Plugin needed for Revenge to function properly', + position: 'top', + visible, + }) + + return children +} + +export function resetTooltips() { + setEnablePluginTooltipVisible?.(false) + setEssentialPluginTooltipVisible?.(false) +} diff --git a/src/plugins/init/settings/screens/RevengePluginsSettingScreen.tsx b/src/plugins/init/settings/screens/RevengePluginsSettingScreen.tsx index 969d632..d8538db 100644 --- a/src/plugins/init/settings/screens/RevengePluginsSettingScreen.tsx +++ b/src/plugins/init/settings/screens/RevengePluginsSettingScreen.tsx @@ -1,107 +1,86 @@ import { getAssetIdByName } from '@revenge-mod/assets' -import FormSwitch from '@revenge-mod/components/FormSwitch' +import { styles } from '@revenge-mod/components/_' 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 { Design } from '@revenge-mod/discord/design' import { ReactNavigationNative } from '@revenge-mod/externals/react-navigation' import { FlashList } from '@revenge-mod/externals/shopify' -import { - _metas, - _plugins, - enablePlugin, - InternalPluginFlags, - initPlugin, - preInitPlugin, - startPlugin, -} from '@revenge-mod/plugins/_' +import { _metas, _plugins, InternalPluginFlags } from '@revenge-mod/plugins/_' import { PluginFlags } from '@revenge-mod/plugins/constants' import { debounce } from '@revenge-mod/utils/callbacks' -import { useReRender } from '@revenge-mod/utils/react' -import { useCallback, useEffect, useMemo, useState } from 'react' +import { useCallback, useMemo, useState } from 'react' +import { useWindowDimensions, View } from 'react-native' +import { ClickOutsideProvider } from 'react-native-click-outside' +import RevengeIcon from '~assets/RevengeIcon' +import { InstalledPluginCard } from '../components/PluginCard' import { - Image, - Pressable, - StyleSheet, - useWindowDimensions, - View, -} from 'react-native' -import { - ClickOutsideProvider, - useClickOutside, -} from 'react-native-click-outside' -import { navigatePluginSettings } from '../utils' + EnablePluginTooltipProvider, + EssentialPluginTooltipProvider, + resetTooltips, +} from '../components/TooltipProvider' 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 { MutableRefObject } from 'react' import type { FilterAndSortActionSheetProps } from '../components/FilterAndSortActionSheet' import type { RouteNames, Setting } from '../constants' -const { Card, Text, Stack, IconButton, LayerScope, useTooltip, createStyles } = - Design +const { Text, Stack, IconButton, LayerScope } = Design + +const FiltersHorizontalIcon = getAssetIdByName('FiltersHorizontalIcon', 'png')! export default function RevengePluginsSettingScreen() { return ( - + + + + + ) } -/* - 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 Filters: FilterAndSortActionSheetProps['filters'] = { + Enabled: [ + getAssetIdByName('CircleCheckIcon')!, + plugin => Boolean(plugin.flags & PluginFlags.Enabled), + ], + Disabled: [ + getAssetIdByName('CircleXIcon')!, + plugin => !(plugin.flags & PluginFlags.Enabled), + ], + Internal: [ + RevengeIcon, + plugin => Boolean(plugin.flags & InternalPluginFlags.Internal), + ], + Essential: [ + getAssetIdByName('StarIcon')!, + (_, iflags) => Boolean(iflags & InternalPluginFlags.Essential), + ], +} satisfies FilterAndSortActionSheetProps['filters'] +const DefaultFilters: FilterAndSortActionSheetProps['filter'] = [] -const DefaultSort: keyof typeof Sorts = 'Name (A-Z)' +const DefaultSort: keyof typeof Sorts = 'Name' 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) => - (b.flags & PluginFlags.Enabled) - (a.flags & PluginFlags.Enabled), - 'Disabled first': (a, b) => - (a.flags & PluginFlags.Enabled) - (b.flags & PluginFlags.Enabled), -} satisfies FilterAndSortActionSheetProps['sorts'] + Name: [ + getAssetIdByName('IdIcon')!, + (a, b) => a.manifest.name.localeCompare(b.manifest.name), + ], + 'Enabled first': [ + getAssetIdByName('CircleCheckIcon')!, + (a, b) => + (b.flags & PluginFlags.Enabled) - (a.flags & PluginFlags.Enabled), + ], +} satisfies FilterAndSortActionSheetProps['sorts'] function Screen() { - const { width } = useWindowDimensions() - const navigation = ReactNavigationNative.useNavigation() const route = ReactNavigationNative.useRoute< @@ -117,9 +96,11 @@ function Screen() { [], ) - const [sort, setSort] = useState( - (route.params?.sort as keyof typeof Sorts) ?? DefaultSort, - ) + const [filter, setFilter] = useState(route.params?.filter ?? DefaultFilters) + const [matchAll, setMatchAll] = useState(route.params?.matchAll ?? false) + + const [reverse, setReverse] = useState(route.params?.reverse ?? false) + const [sort, setSort] = useState(route.params?.sort ?? DefaultSort) const allPlugins = useMemo( () => @@ -132,6 +113,13 @@ function Screen() { const plugins = useMemo( () => allPlugins + .filter(([plugin, iflags]) => { + if (filter.length === 0) return true + if (matchAll) + return filter.every(f => Filters[f][1](plugin, iflags)) + + return filter.some(f => Filters[f][1](plugin, iflags)) + }) .filter(([plugin]) => { const { name, description, author } = plugin.manifest const query = search.toLowerCase() @@ -141,68 +129,81 @@ function Screen() { author.toLowerCase().includes(query) ) }) - .sort(([a], [b]) => Sorts[sort](a, b)), - [allPlugins, search, sort], + .sort(([a], [b]) => { + const result = Sorts[sort as keyof typeof Sorts][1](a, b) + if (reverse) return -result + return result + }), + [search, sort, reverse, filter, matchAll, allPlugins], ) - useEffect(() => { - navigation.setParams({ sort }) - ActionSheetActionCreators.hideActionSheet(FilterAndSortActionSheetKey) - }, [sort, navigation.setParams]) - return ( - + <> - + debouncedSetSearch(v)} size="md" /> ActionSheetActionCreators.openLazy( - import( - '../components/FilterAndSortActionSheet' - ) as Promise<{ - default: typeof import('../components/FilterAndSortActionSheet').default< - keyof typeof Sorts - > - }>, - FilterAndSortActionSheetKey, + import('../components/FilterAndSortActionSheet'), + 'filter-and-sort-plugins', { + filters: Filters, + filter, + setFilter: filter => { + navigation.setParams({ filter }) + setFilter(filter) + }, + matchAll, + setMatchAll: matchAll => { + navigation.setParams({ matchAll }) + setMatchAll(matchAll) + }, + reverse, + setReverse: reverse => { + navigation.setParams({ reverse }) + setReverse(reverse) + }, sorts: Sorts, - selectedSort: sort, - onSelectSort: setSort, + sort, + setSort: sort => { + navigation.setParams({ sort }) + setSort(sort) + }, }, ) } /> - - + + ) } function PluginMasonryFlashList({ plugins, - numColumns, }: { plugins: (readonly [InternalPlugin, number])[] - numColumns: number }) { + const { width, height } = useWindowDimensions() + const numColumns = Math.floor((width - 16) / 448) + return ( setTooltipVisible?.(false)} data={plugins} + onScrollBeginDrag={resetTooltips} + fadingEdgeLength={16} + keyExtractor={([plugin]) => plugin.manifest.id} + estimatedListSize={{ width: width - 32, height: height - 160 }} estimatedItemSize={116} numColumns={numColumns} + ListEmptyComponent={NoPlugins} renderItem={({ item: [plugin, iflags], columnIndex }) => ( (() => setTooltipVisible?.(false)) - +function NoPlugins() { return ( - - - - - {name} - - {plugin.SettingsComponent && ( - { - if (enabled) return - - e.stopPropagation() - tooltipTargetRef.current = ref.current - - setTooltipVisible?.(true) - }} - > - navigatePluginSettings(plugin)} - disabled={!enabled} - /> - - )} - - - - - by {author} - - - {description} - - - + + No plugins found. Try changing your query or filters. + ) } - -const styles = StyleSheet.create({ - flex: { - flex: 1, - }, -}) - -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 5dca611..d082e51 100644 --- a/src/plugins/init/settings/types.ts +++ b/src/plugins/init/settings/types.ts @@ -16,5 +16,8 @@ type RevengeSettingsParamList = { } & { [K in (typeof RouteNames)[(typeof Setting)['RevengePlugins']]]: { sort?: string + filter?: string[] + matchAll?: boolean + reverse?: boolean } }