feat(plugins/settings): improve additional plugin options sheet

- Disable Settings icon button with tooltip hint if plugin is not enabled
- Added Status
- Moved Flags to Advanced
- Added Internal Flags

Other changes (to make the above work):
- TooltipProvider now properly uses React Contexts
- Added usePluginStatus (PluginStateProvider now subscribes to plugin statuses)
This commit is contained in:
PalmDevs 2025-10-23 17:50:32 +07:00
parent 2bf7709ba3
commit 2293e288d1
No known key found for this signature in database
GPG key ID: 6FAE8F8677ACA8BA
5 changed files with 228 additions and 108 deletions

View file

@ -11,11 +11,9 @@ import { openPluginSettings } from '../utils/alerts'
import { showPluginOptionsActionSheet } from '../utils/sheets'
import { usePluginEnabled } from './PluginStateProvider'
import {
enableTooltipTarget,
essentialTooltipTarget,
setEnablePluginTooltipVisible,
setEssentialPluginTooltipVisible,
useClickOutsideTooltip,
useEnablePluginTooltip,
useEssentialPluginTooltip,
} from './TooltipProvider'
import type { AnyPlugin, InternalPluginMeta } from '@revenge-mod/plugins/_'
@ -147,13 +145,15 @@ export const InstalledPluginCard = memo(function InstalledPluginCard({
const essential = isPluginEssential(meta)
const settingsRef = useClickOutsideTooltip(enableTooltipTarget, () => {
setEnablePluginTooltipVisible?.(false)
})
const enableTooltip = useEnablePluginTooltip()
const essentialTooltip = useEssentialPluginTooltip()
const switchRef = useClickOutsideTooltip(essentialTooltipTarget, () => {
setEssentialPluginTooltipVisible?.(false)
})
const settingsRef = useClickOutsideTooltip(useEnablePluginTooltip, () => {})
const switchRef = useClickOutsideTooltip(
useEssentialPluginTooltip,
() => {},
)
return (
<PluginCard
@ -175,16 +175,13 @@ export const InstalledPluginCard = memo(function InstalledPluginCard({
/>
{plugin.SettingsComponent && (
<Pressable
onPress={e => {
if (enabled) return
e.stopPropagation()
requestAnimationFrame(() => {
enableTooltipTarget.current =
settingsRef.current
setEnablePluginTooltipVisible!(true)
})
onPress={() => {
if (!enabled)
requestAnimationFrame(() => {
enableTooltip.targetRef.current =
settingsRef.current
enableTooltip.setVisible(true)
})
}}
>
<IconButton
@ -200,21 +197,17 @@ export const InstalledPluginCard = memo(function InstalledPluginCard({
</Pressable>
)}
<Pressable
onPress={e => {
if (essential) {
e.stopPropagation()
onPress={() => {
if (essential)
requestAnimationFrame(() => {
essentialTooltipTarget.current =
essentialTooltip.targetRef.current =
switchRef.current
setEssentialPluginTooltipVisible!(true)
essentialTooltip.setVisible(true)
})
}
}}
ref={switchRef}
>
<FormSwitch
// Only FormSwitch listens for props changes, so we use a unique key just for this one (FlashList optimization).
key={plugin.manifest.id}
disabled={essential}
onValueChange={enabled => {

View file

@ -7,7 +7,7 @@ import {
PLUGIN_CARD_ESTIMATED_SIZE,
PluginCard,
} from './PluginCard'
import { resetTooltips } from './TooltipProvider'
import { useResetTooltips } from './TooltipProvider'
import type { AnyPlugin, InternalPluginMeta } from '@revenge-mod/plugins/_'
const { Text } = Design
@ -15,6 +15,7 @@ const { Text } = Design
export function PluginFlashList({ plugins }: { plugins: AnyPlugin[] }) {
const { width, height } = useWindowDimensions()
const actualWidth = width - styles.pagePadding.paddingHorizontal * 2
const resetTooltips = useResetTooltips()
return (
<FlashList
@ -51,6 +52,7 @@ export function InstalledPluginMasonryFlashList({
const { width, height } = useWindowDimensions()
const actualWidth = width - styles.pagePadding.paddingHorizontal * 2
const numColumns = Math.floor(actualWidth / 448) || 1
const resetTooltips = useResetTooltips()
return (
<MasonryFlashList

View file

@ -8,17 +8,26 @@ import { Design } from '@revenge-mod/discord/design'
import { Clipboard } from '@revenge-mod/externals/react-native-clipboard'
import {
getInternalPluginMeta,
InternalPluginFlags,
isPluginEssential,
} from '@revenge-mod/plugins/_'
import { PluginFlags, PluginStatus } from '@revenge-mod/plugins/constants'
import { lookupGeneratedIconComponent } from '@revenge-mod/utils/discord'
import { getErrorStack } from '@revenge-mod/utils/error'
import { Pressable } from 'react-native'
import { ClickOutsideProvider } from 'react-native-click-outside'
import { handleDisablePlugin, handleEnablePlugin } from '../utils/actions'
import {
openPluginSettings,
showPluginClearDataConfirmation,
} from '../utils/alerts'
import { PluginInfo } from './PluginCard'
import { usePluginEnabled } from './PluginStateProvider'
import { usePluginEnabled, usePluginStatus } from './PluginStateProvider'
import {
EnablePluginTooltipProvider,
useClickOutsideTooltip,
useEnablePluginTooltip,
} from './TooltipProvider'
import type { AnyPlugin } from '@revenge-mod/plugins/_'
export interface PluginOptionsActionSheetProps {
@ -35,6 +44,7 @@ export default function PluginOptionsActionSheet({
plugin,
sheetKey,
}: PluginOptionsActionSheetProps) {
const status = usePluginStatus(plugin)
const enabled = usePluginEnabled(plugin)
const meta = getInternalPluginMeta(plugin)
const { dependencies, dependents } = meta!
@ -45,10 +55,6 @@ export default function PluginOptionsActionSheet({
flags,
} = plugin
const closeActionSheet = () => {
ActionSheetActionCreators.hideActionSheet(sheetKey)
}
return (
<ActionSheet>
<Stack spacing={24} style={{ paddingTop: 8 }}>
@ -69,41 +75,25 @@ export default function PluginOptionsActionSheet({
)
}
/>
<Stack
direction="horizontal"
justify="space-around"
style={{ paddingHorizontal: 8, paddingVertical: 16 }}
>
<IconButton
variant="secondary"
size="lg"
icon={FileWarningIcon}
label="Clear Data"
onPress={() => {
showPluginClearDataConfirmation(
plugin,
closeActionSheet,
)
}}
/>
{plugin.SettingsComponent && (
<Design.IconButton
variant="secondary"
size="lg"
icon={SettingsIcon}
label="Settings"
onPress={() => {
openPluginSettings(plugin)
closeActionSheet()
<ClickOutsideProvider>
<EnablePluginTooltipProvider>
<PluginActions
plugin={plugin}
closeSheet={() => {
ActionSheetActionCreators.hideActionSheet(
sheetKey,
)
}}
/>
)}
</Stack>
</EnablePluginTooltipProvider>
</ClickOutsideProvider>
<TableRowGroup title="Status">
<TableRow
icon={<TableRowAssetIcon name="FlagIcon" />}
label="Flags (TODO)"
subLabel={`${flags}`}
icon={
<TableRowAssetIcon name="CircleInformationIcon" />
}
label="Status (TODO)"
subLabel={bitFieldToString(PluginStatus, status)}
/>
{errors.length && (
<TableRow
@ -112,7 +102,7 @@ export default function PluginOptionsActionSheet({
icon={
<TableRowAssetIcon
variant="danger"
name="CircleXIcon"
name="CircleErrorIcon"
/>
}
subLabel={`${errors.length} errors. Tap to copy.`}
@ -135,6 +125,21 @@ export default function PluginOptionsActionSheet({
showCopiedToClipboardToast()
}}
/>
<TableRow
icon={<TableRowAssetIcon name="FlagIcon" />}
label="Flags"
subLabel={bitFieldToString(PluginFlags, flags)}
/>
{meta.iflags && (
<TableRow
icon={<TableRowAssetIcon name="FlagIcon" />}
label="Internal Flags"
subLabel={bitFieldToString(
InternalPluginFlags,
meta.iflags,
)}
/>
)}
{dependencies?.length && (
<TableRow
icon={<TableRowAssetIcon name="ListBulletsIcon" />}
@ -155,6 +160,71 @@ export default function PluginOptionsActionSheet({
)
}
function bitFieldToString(map: Record<string, number>, bitField: number) {
return (
Object.entries(map)
.filter(([, value]) => bitField & value)
.map(([key]) => key)
.join(', ') || '-'
)
}
function PluginActions({
plugin,
closeSheet,
}: {
plugin: AnyPlugin
closeSheet: () => void
}) {
const enabled = usePluginEnabled(plugin)
const enableTooltip = useEnablePluginTooltip()
const settingsRef = useClickOutsideTooltip(useEnablePluginTooltip, () => {})
return (
<Stack
direction="horizontal"
justify="space-around"
style={{ paddingHorizontal: 8, paddingVertical: 16 }}
>
<IconButton
variant="secondary"
size="lg"
icon={FileWarningIcon}
label="Clear Data"
onPress={() => {
showPluginClearDataConfirmation(plugin, closeSheet)
}}
/>
{plugin.SettingsComponent && (
<Pressable
onPress={() => {
if (enabled) return
requestAnimationFrame(() => {
enableTooltip.targetRef.current =
settingsRef.current
enableTooltip.setVisible(true)
})
}}
>
<Design.IconButton
ref={settingsRef}
variant="secondary"
size="lg"
icon={SettingsIcon}
label="Settings"
disabled={!enabled}
onPress={() => {
openPluginSettings(plugin)
closeSheet()
}}
/>
</Pressable>
)}
</Stack>
)
}
const CopyIcon = lookupGeneratedIconComponent('CopyIcon')!
function showCopiedToClipboardToast() {

View file

@ -9,6 +9,7 @@ export interface PluginEnabledStates {
const PluginStatesContext = createContext<{
enabled: PluginEnabledStates
status: { [pluginId: string]: number }
} | null>(null)
export default function PluginStatesProvider({
@ -24,7 +25,22 @@ export default function PluginStatesProvider({
return states
})
const [status, setStatus] = useState<{ [pluginId: string]: number }>(() => {
const statuses: { [pluginId: string]: number } = {}
for (const plugin of pList.values()) {
statuses[plugin.manifest.id] = plugin.status
}
return statuses
})
useEffect(() => {
const handleStatusChange = (plugin: AnyPlugin) => {
setStatus(prev => ({
...prev,
[plugin.manifest.id]: plugin.status,
}))
}
const handleEnabled = (plugin: AnyPlugin) => {
setEnabled(prev => ({
...prev,
@ -39,6 +55,11 @@ export default function PluginStatesProvider({
}))
}
pEmitter.on('preInited', handleStatusChange)
pEmitter.on('inited', handleStatusChange)
pEmitter.on('started', handleStatusChange)
pEmitter.on('stopped', handleStatusChange)
pEmitter.on('enabled', handleEnabled)
pEmitter.on('disabled', handleDisabled)
@ -50,9 +71,10 @@ export default function PluginStatesProvider({
const contextValue = useMemo(
() => ({
enabled: enabled,
enabled,
status,
}),
[enabled],
[enabled, status],
)
return (
@ -72,3 +94,14 @@ export function usePluginEnabled(plugin: AnyPlugin) {
return context.enabled[plugin.manifest.id]
}
export function usePluginStatus(plugin: AnyPlugin) {
const context = useContext(PluginStatesContext)
if (!context) {
console.warn('usePluginStatus not used within a PluginStateProvider')
return plugin.status
}
return context.status[plugin.manifest.id]
}

View file

@ -1,48 +1,42 @@
/*
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 { createContext, useContext, useRef, useState } from 'react'
import { useClickOutside } from 'react-native-click-outside'
import type { RefObject } from 'react'
import type { View } from 'react-native'
const { useTooltip } = Design
export let setEnablePluginTooltipVisible: ((v: boolean) => void) | undefined
export const enableTooltipTarget: RefObject<View | null> = {
current: null,
interface TooltipContextValue {
targetRef: RefObject<View | null>
setVisible: (visible: boolean) => void
}
const EnablePluginTooltipContext = createContext<TooltipContextValue | null>(
null,
)
const EssentialPluginTooltipContext = createContext<TooltipContextValue | null>(
null,
)
export function EnablePluginTooltipProvider({
children,
}: {
children: React.ReactNode
}) {
const [visible, setVisible] = useState(false)
const targetRef = useRef<View | null>(null)
useEffect(() => {
setEnablePluginTooltipVisible = setVisible
return () => {
enableTooltipTarget.current = null
setEnablePluginTooltipVisible = undefined
}
}, [])
useTooltip(enableTooltipTarget, {
useTooltip(targetRef, {
label: 'Plugin must be enabled first',
position: 'top',
visible,
})
return children
}
export let setEssentialPluginTooltipVisible: ((v: boolean) => void) | undefined
export const essentialTooltipTarget: RefObject<View | null> = {
current: null,
return (
<EnablePluginTooltipContext.Provider value={{ targetRef, setVisible }}>
{children}
</EnablePluginTooltipContext.Provider>
)
}
export function EssentialPluginTooltipProvider({
@ -51,36 +45,64 @@ export function EssentialPluginTooltipProvider({
children: React.ReactNode
}) {
const [visible, setVisible] = useState(false)
const targetRef = useRef<View | null>(null)
useEffect(() => {
setEssentialPluginTooltipVisible = setVisible
return () => {
essentialTooltipTarget.current = null
setEssentialPluginTooltipVisible = undefined
}
}, [])
useTooltip(essentialTooltipTarget, {
useTooltip(targetRef, {
label: 'Plugin needed for Revenge to function properly',
position: 'top',
visible,
})
return children
return (
<EssentialPluginTooltipContext.Provider
value={{ targetRef, setVisible }}
>
{children}
</EssentialPluginTooltipContext.Provider>
)
}
export function resetTooltips() {
setEnablePluginTooltipVisible?.(false)
setEssentialPluginTooltipVisible?.(false)
export function useEnablePluginTooltip() {
const context = useContext(EnablePluginTooltipContext)
if (!context) {
throw new Error(
'useEnablePluginTooltip must be used within EnablePluginTooltipProvider',
)
}
return context
}
export function useEssentialPluginTooltip() {
const context = useContext(EssentialPluginTooltipContext)
if (!context) {
throw new Error(
'useEssentialPluginTooltip must be used within EssentialPluginTooltipProvider',
)
}
return context
}
export function useResetTooltips() {
const enableTooltip = useContext(EnablePluginTooltipContext)
const essentialTooltip = useContext(EssentialPluginTooltipContext)
return () => {
enableTooltip?.setVisible(false)
essentialTooltip?.setVisible(false)
}
}
export function useClickOutsideTooltip(
target: RefObject<View | null>,
contextHook: () => TooltipContextValue,
onClickOutside: () => void,
) {
const { targetRef, setVisible } = contextHook()
const ref = useClickOutside<View>(() => {
resetTooltips()
if (target.current === ref.current) onClickOutside()
setVisible(false)
if (targetRef.current === ref.current) {
onClickOutside()
}
})
return ref