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:
parent
2bf7709ba3
commit
2293e288d1
5 changed files with 228 additions and 108 deletions
|
|
@ -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 => {
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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() {
|
||||
|
|
|
|||
|
|
@ -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]
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
Loading…
Add table
Reference in a new issue