feat(plugins/settings): add sheet for additional plugin options

This commit is contained in:
PalmDevs 2025-10-22 22:20:58 +07:00
parent 2bc2a9fd08
commit de063fe260
No known key found for this signature in database
GPG key ID: 6FAE8F8677ACA8BA
8 changed files with 495 additions and 191 deletions

View file

@ -1,27 +1,15 @@
import { useNavigation } from '@react-navigation/native'
import { getAssetIdByName } from '@revenge-mod/assets'
import { styles } from '@revenge-mod/components/_'
import FormSwitch from '@revenge-mod/components/FormSwitch'
import { AlertActionCreators } from '@revenge-mod/discord/actions'
import { Tokens } from '@revenge-mod/discord/common'
import { Design } from '@revenge-mod/discord/design'
import {
enablePlugin,
getPluginDependencies,
isPluginEnabled,
isPluginEssential,
runPluginLate,
} from '@revenge-mod/plugins/_'
import { isPluginEssential } from '@revenge-mod/plugins/_'
import { memo } from 'react'
import {
Image,
PixelRatio,
Pressable,
useWindowDimensions,
View,
} from 'react-native'
import { PluginFlashList } from './PluginList'
import PluginStatesProvider, { usePluginEnabled } from './PluginStateProvider'
import { Image, Pressable } from 'react-native'
import { handleDisablePlugin, handleEnablePlugin } from '../utils/actions'
import { openPluginSettings } from '../utils/alerts'
import { showPluginOptionsActionSheet } from '../utils/sheets'
import { usePluginEnabled } from './PluginStateProvider'
import {
enableTooltipTarget,
essentialTooltipTarget,
@ -35,6 +23,7 @@ const { Card, Text, Stack, IconButton, createStyles } = Design
const PuzzlePieceIcon = getAssetIdByName('PuzzlePieceIcon', 'png')!
const SettingsIcon = getAssetIdByName('SettingsIcon', 'png')!
const MoreVerticalIcon = getAssetIdByName('MoreVerticalIcon', 'png')!
export const PLUGIN_CARD_ESTIMATED_SIZE = 116
@ -66,6 +55,37 @@ export const PluginCard = memo(function PluginCard({
rowEnd && styles_.rowEnd,
]}
>
<PluginInfo
name={name}
description={description}
author={author}
icon={icon}
actions={actions}
aligned
/>
</Card>
)
})
export const PluginInfo = memo(function PluginInfo({
name,
description,
author,
icon,
actions,
aligned,
}: {
name: string
description: string
author: string
icon?: string
actions?: React.ReactNode
aligned?: boolean
}) {
const styles_ = usePluginCardStyles()
return (
<Stack>
<Stack
direction="horizontal"
style={[styles.grow, styles_.topContainer]}
@ -89,7 +109,10 @@ export const PluginCard = memo(function PluginCard({
</Stack>
{actions}
</Stack>
<Stack spacing={4} style={[styles_.alignedContainer, styles.grow]}>
<Stack
spacing={4}
style={[aligned && styles_.alignedContainer, styles.grow]}
>
<Text
color="text-muted"
style={styles.grow}
@ -101,7 +124,7 @@ export const PluginCard = memo(function PluginCard({
{description}
</Text>
</Stack>
</Card>
</Stack>
)
})
@ -116,16 +139,13 @@ export const InstalledPluginCard = memo(function InstalledPluginCard({
rowEnd?: boolean
columnEnd?: boolean
}) {
const navigation = useNavigation()
const enabled = usePluginEnabled(plugin)
const {
manifest: { name, description, author, icon },
} = plugin
const dependencies = getPluginDependencies(plugin)
const essential = isPluginEssential(meta)
const { dependents } = meta
const settingsRef = useClickOutsideTooltip(enableTooltipTarget, () => {
setEnablePluginTooltipVisible?.(false)
@ -145,6 +165,14 @@ export const InstalledPluginCard = memo(function InstalledPluginCard({
columnEnd={columnEnd}
actions={
<>
<IconButton
size="sm"
variant="secondary"
icon={MoreVerticalIcon}
onPress={() => {
showPluginOptionsActionSheet(plugin)
}}
/>
{plugin.SettingsComponent && (
<Pressable
onPress={e => {
@ -165,7 +193,7 @@ export const InstalledPluginCard = memo(function InstalledPluginCard({
variant="secondary"
icon={SettingsIcon}
onPress={() => {
navigation.navigate(plugin.manifest.id)
openPluginSettings(plugin)
}}
disabled={!enabled}
/>
@ -186,20 +214,12 @@ export const InstalledPluginCard = memo(function InstalledPluginCard({
ref={switchRef}
>
<FormSwitch
// Only FormSwitch listens for props changes, so we use a unique key just for this one.
// 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 => {
if (enabled)
return handleEnablePlugin(
plugin,
dependencies,
)
else
return handleDisablePlugin(
plugin,
dependents,
)
if (enabled) handleEnablePlugin(plugin)
else handleDisablePlugin(plugin)
}}
value={enabled}
/>
@ -210,162 +230,6 @@ export const InstalledPluginCard = memo(function InstalledPluginCard({
)
})
async function handleEnablePlugin(
plugin: AnyPlugin,
dependencies: AnyPlugin[],
) {
const disabledDeps = dependencies.filter(dep => !isPluginEnabled(dep))
async function action() {
await enablePlugin(plugin)
await runPluginLate(plugin)
}
if (disabledDeps.length)
AlertActionCreators.openAlert(
'plugin-has-dependencies',
<PluginStatesProvider>
<PluginHasDependenciesAlert
plugin={plugin}
dependencies={disabledDeps}
action={action}
/>
</PluginStatesProvider>,
)
else await action()
}
async function handleDisablePlugin(plugin: AnyPlugin, dependents: AnyPlugin[]) {
const action = plugin.disable
const enabledDeps = dependents.filter(isPluginEnabled)
if (enabledDeps.length)
AlertActionCreators.openAlert(
'plugin-has-dependents',
<PluginStatesProvider>
<PluginHasDependentsAlert
plugin={plugin}
dependents={enabledDeps}
action={action}
/>
</PluginStatesProvider>,
)
else await action()
}
function PluginHasDependenciesAlert({
plugin,
dependencies,
action,
}: {
plugin: AnyPlugin
dependencies: AnyPlugin[]
action: () => Promise<void>
}) {
const { height: windowHeight } = useWindowDimensions()
const maxHeight = PixelRatio.get() * windowHeight * 0.35 - 64
return (
<Design.AlertModal
title="Plugin has dependencies"
content={
<Text color="header-secondary">
Plugin{' '}
<Text variant="text-md/semibold" color="text-normal">
{plugin.manifest.name}
</Text>{' '}
depends on other plugins to function.
{'\n'}
Enabling it will also enable the following plugins:
</Text>
}
extraContent={
<View
style={{
height: Math.min(
PLUGIN_CARD_ESTIMATED_SIZE * dependencies.length,
maxHeight,
),
maxHeight,
}}
>
<PluginFlashList plugins={dependencies} />
</View>
}
actions={
<>
<Design.AlertActionButton
onPress={action}
text="Enable all"
variant="primary"
/>
<Design.AlertActionButton
text="Cancel"
variant="secondary"
/>
</>
}
/>
)
}
function PluginHasDependentsAlert({
plugin,
dependents,
action,
}: {
plugin: AnyPlugin
dependents: AnyPlugin[]
action: () => Promise<void>
}) {
const { height: windowHeight } = useWindowDimensions()
const maxHeight = PixelRatio.get() * windowHeight * 0.35 - 64
return (
<Design.AlertModal
title="Plugin has dependents"
content={
<Text color="header-secondary">
Other plugins depend on{' '}
<Text variant="text-md/semibold" color="text-normal">
{plugin.manifest.name}
</Text>{' '}
to function.
{'\n'}
Disabling it will also disable the following plugins:
</Text>
}
extraContent={
<View
style={{
height: Math.min(
PLUGIN_CARD_ESTIMATED_SIZE * dependents.length,
maxHeight,
),
maxHeight,
}}
>
<PluginFlashList plugins={dependents} />
</View>
}
actions={
<>
<Design.AlertActionButton
text="Disable all"
variant="destructive"
onPress={action}
/>
<Design.AlertActionButton
text="Cancel"
variant="secondary"
/>
</>
}
/>
)
}
const usePluginCardStyles = createStyles({
icon: {
width: 20,

View file

@ -0,0 +1,37 @@
import { Design } from '@revenge-mod/discord/design'
import type { AnyPlugin } from '@revenge-mod/plugins/_'
const { AlertModal, AlertActionButton, Text } = Design
export default function PluginClearDataConfirmationAlert({
plugin,
action,
}: {
plugin: AnyPlugin
action: () => Promise<void>
}) {
return (
<AlertModal
title="Clear plugin data?"
content={
<Text color="header-secondary">
All data, including files and settings for{' '}
<Text variant="text-md/semibold" color="text-normal">
{plugin.manifest.name}
</Text>{' '}
will be deleted
</Text>
}
actions={
<>
<AlertActionButton
onPress={action}
text="Clear"
variant="destructive"
/>
<AlertActionButton text="Cancel" variant="secondary" />
</>
}
/>
)
}

View file

@ -0,0 +1,60 @@
import { Design } from '@revenge-mod/discord/design'
import { PixelRatio, useWindowDimensions, View } from 'react-native'
import { PLUGIN_CARD_ESTIMATED_SIZE } from './PluginCard'
import { PluginFlashList } from './PluginList'
import type { AnyPlugin } from '@revenge-mod/plugins/_'
const { AlertModal, AlertActionButton, Text } = Design
export default function PluginHasDependenciesAlert({
plugin,
dependencies,
action,
}: {
plugin: AnyPlugin
dependencies: AnyPlugin[]
action: () => Promise<void>
}) {
const { height: windowHeight } = useWindowDimensions()
const maxHeight = PixelRatio.get() * windowHeight * 0.35 - 64
return (
<AlertModal
title="Plugin has dependencies"
content={
<Text color="header-secondary">
Plugin{' '}
<Text variant="text-md/semibold" color="text-normal">
{plugin.manifest.name}
</Text>{' '}
depends on other plugins to function.
{'\n'}
Enabling it will also enable the following plugins:
</Text>
}
extraContent={
<View
style={{
height: Math.min(
PLUGIN_CARD_ESTIMATED_SIZE * dependencies.length,
maxHeight,
),
maxHeight,
}}
>
<PluginFlashList plugins={dependencies} />
</View>
}
actions={
<>
<AlertActionButton
onPress={action}
text="Enable all"
variant="primary"
/>
<AlertActionButton text="Cancel" variant="secondary" />
</>
}
/>
)
}

View file

@ -0,0 +1,60 @@
import { Design } from '@revenge-mod/discord/design'
import { PixelRatio, useWindowDimensions, View } from 'react-native'
import { PLUGIN_CARD_ESTIMATED_SIZE } from './PluginCard'
import { PluginFlashList } from './PluginList'
import type { AnyPlugin } from '@revenge-mod/plugins/_'
const { AlertModal, AlertActionButton, Text } = Design
export default function PluginHasDependentsAlert({
plugin,
dependents,
action,
}: {
plugin: AnyPlugin
dependents: AnyPlugin[]
action: () => Promise<void>
}) {
const { height: windowHeight } = useWindowDimensions()
const maxHeight = PixelRatio.get() * windowHeight * 0.35 - 64
return (
<AlertModal
title="Plugin has dependents"
content={
<Text color="header-secondary">
Other plugins depend on{' '}
<Text variant="text-md/semibold" color="text-normal">
{plugin.manifest.name}
</Text>{' '}
to function.
{'\n'}
Disabling it will also disable the following plugins:
</Text>
}
extraContent={
<View
style={{
height: Math.min(
PLUGIN_CARD_ESTIMATED_SIZE * dependents.length,
maxHeight,
),
maxHeight,
}}
>
<PluginFlashList plugins={dependents} />
</View>
}
actions={
<>
<AlertActionButton
text="Disable all"
variant="destructive"
onPress={action}
/>
<AlertActionButton text="Cancel" variant="secondary" />
</>
}
/>
)
}

View file

@ -0,0 +1,152 @@
import { getAssetIdByName } from '@revenge-mod/assets'
import { FormSwitch } from '@revenge-mod/components'
import {
ActionSheetActionCreators,
ToastActionCreators,
} from '@revenge-mod/discord/actions'
import { Design } from '@revenge-mod/discord/design'
import { Clipboard } from '@revenge-mod/externals/react-native-clipboard'
import {
getInternalPluginMeta,
isPluginEssential,
} from '@revenge-mod/plugins/_'
import { lookupGeneratedIconComponent } from '@revenge-mod/utils/discord'
import { getErrorStack } from '@revenge-mod/utils/error'
import { handleDisablePlugin, handleEnablePlugin } from '../utils/actions'
import {
openPluginSettings,
showPluginClearDataConfirmation,
} from '../utils/alerts'
import { PluginInfo } from './PluginCard'
import { usePluginEnabled } from './PluginStateProvider'
import type { AnyPlugin } from '@revenge-mod/plugins/_'
export interface PluginOptionsActionSheetProps {
plugin: AnyPlugin
sheetKey: string
}
const { ActionSheet, IconButton, TableRowGroup, TableRow, Stack } = Design
const FileWarningIcon = getAssetIdByName('FileWarningIcon', 'png')!
const SettingsIcon = getAssetIdByName('SettingsIcon', 'png')!
export default function PluginOptionsActionSheet({
plugin,
sheetKey,
}: PluginOptionsActionSheetProps) {
const enabled = usePluginEnabled(plugin)
const meta = getInternalPluginMeta(plugin)
const { dependencies, dependents } = meta!
const essential = isPluginEssential(meta)
const {
manifest: { id, name, author, description, icon },
errors,
flags,
} = plugin
const closeActionSheet = () => {
ActionSheetActionCreators.hideActionSheet(sheetKey)
}
return (
<ActionSheet>
<Stack spacing={16}>
<PluginInfo
name={name}
author={author}
description={description}
icon={icon}
actions={
!essential && (
<FormSwitch
value={enabled}
onValueChange={v => {
if (v) handleEnablePlugin(plugin)
else handleDisablePlugin(plugin)
}}
/>
)
}
/>
<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()
}}
/>
)}
</Stack>
<TableRowGroup title="Status">
<TableRow label="Flags (TODO)" subLabel={`${flags}`} />
{errors.length && (
<TableRow
label="Errors"
subLabel={`${errors.length} errors. Tap to copy.`}
onPress={() => {
Clipboard.setString(
errors.map(getErrorStack).join('\n'),
)
showCopiedToClipboardToast()
}}
/>
)}
</TableRowGroup>
<TableRowGroup title="Advanced">
<TableRow
label="ID"
subLabel={id}
onPress={() => {
Clipboard.setString(id)
showCopiedToClipboardToast()
}}
/>
{dependencies?.length && (
<TableRow
label="Dependencies (TODO)"
subLabel={`${name} depends on ${dependencies.length} other plugins`}
/>
)}
{dependents?.length && (
<TableRow
label="Dependents (TODO)"
subLabel={`${dependents.length} other plugins depend on ${name}`}
/>
)}
</TableRowGroup>
</Stack>
</ActionSheet>
)
}
const CopyIcon = lookupGeneratedIconComponent('CopyIcon')!
function showCopiedToClipboardToast() {
ToastActionCreators.open({
key: 'REVENGE_PLUGIN_SETTINGS_COPIED',
content: 'Copied to clipboard',
IconComponent: CopyIcon,
})
}

View file

@ -0,0 +1,38 @@
import {
disablePlugin,
enablePlugin,
getInternalPluginMeta,
getPluginDependencies,
isPluginEnabled,
runPluginLate,
} from '@revenge-mod/plugins/_'
import {
showPluginHasDependenciesAlert,
showPluginHasDependentsAlert,
} from './alerts'
import type { AnyPlugin } from '@revenge-mod/plugins/_'
export async function handleEnablePlugin(plugin: AnyPlugin) {
const dependencies = getPluginDependencies(plugin)!
const disabledDeps = dependencies?.filter(dep => !isPluginEnabled(dep))
async function action() {
await enablePlugin(plugin)
await runPluginLate(plugin)
}
if (disabledDeps?.length)
showPluginHasDependenciesAlert(plugin, disabledDeps, action)
else await action()
}
export async function handleDisablePlugin(plugin: AnyPlugin) {
const { dependents } = getInternalPluginMeta(plugin)!
const action = () => disablePlugin(plugin)
const enabledDeps = dependents.filter(isPluginEnabled)
if (enabledDeps.length)
showPluginHasDependentsAlert(plugin, enabledDeps, action)
else await action()
}

View file

@ -0,0 +1,73 @@
import { AlertActionCreators } from '@revenge-mod/discord/actions'
import { RootNavigationRef } from '@revenge-mod/discord/modules/main_tabs_v2'
import PluginClearDataConfirmationAlert from '../components/PluginClearDataConfirmationAlert'
import PluginHasDependenciesAlert from '../components/PluginHasDependenciesAlert'
import PluginHasDependentsAlert from '../components/PluginHasDependentsAlert'
import PluginStatesProvider from '../components/PluginStateProvider'
import type { AnyPlugin } from '@revenge-mod/plugins/_'
import type { InitPluginApi } from '@revenge-mod/plugins/types'
import type { AnyObject } from '@revenge-mod/utils/types'
export function showPluginClearDataConfirmation(
plugin: AnyPlugin,
callback: () => void,
) {
const KEY = 'plugin-clear-data-confirmation'
async function action() {
const { storage } = plugin.api as InitPluginApi<{ storage: AnyObject }>
if (await storage.exists()) await storage.delete()
callback()
}
AlertActionCreators.openAlert(
KEY,
<PluginClearDataConfirmationAlert plugin={plugin} action={action} />,
)
}
export function openPluginSettings(plugin: AnyPlugin) {
if (!plugin.SettingsComponent) return
const navigation = RootNavigationRef.getRootNavigationRef()
if (navigation.isReady()) navigation.navigate(plugin.manifest.id)
else
navigation.addListener('ready', function self() {
navigation.navigate(plugin.manifest.id)
navigation.removeListener('ready', self)
})
}
export function showPluginHasDependenciesAlert(
plugin: AnyPlugin,
dependencies: AnyPlugin[],
action: () => Promise<void>,
) {
AlertActionCreators.openAlert(
'plugin-has-dependencies',
<PluginStatesProvider>
<PluginHasDependenciesAlert
plugin={plugin}
dependencies={dependencies}
action={action}
/>
</PluginStatesProvider>,
)
}
export function showPluginHasDependentsAlert(
plugin: AnyPlugin,
dependents: AnyPlugin[],
action: () => Promise<void>,
) {
AlertActionCreators.openAlert(
'plugin-has-dependents',
<PluginStatesProvider>
<PluginHasDependentsAlert
plugin={plugin}
dependents={dependents}
action={action}
/>
</PluginStatesProvider>,
)
}

View file

@ -0,0 +1,20 @@
import { ActionSheetActionCreators } from '@revenge-mod/discord/actions'
import PluginStatesProvider from '../components/PluginStateProvider'
import type { AnyPlugin } from '@revenge-mod/plugins/_'
import type { PluginOptionsActionSheetProps } from '../components/PluginOptionsActionSheet'
export function showPluginOptionsActionSheet(plugin: AnyPlugin) {
const KEY = 'plugin-options-action-sheet'
ActionSheetActionCreators.openLazy(
import('../components/PluginOptionsActionSheet').then(m => ({
default: (props: PluginOptionsActionSheetProps) => (
<PluginStatesProvider>
<m.default {...props} plugin={plugin} />
</PluginStatesProvider>
),
})),
KEY,
{ plugin, sheetKey: KEY },
)
}