feat: plugin dependencies and dependent dialogs, refactor plugins library and settings plugin

- Refactored the plugins library
- Refactored the settings plugin
This commit is contained in:
PalmDevs 2025-07-25 21:19:59 +07:00
parent 5d7ac417ba
commit 8eea0d12ea
No known key found for this signature in database
GPG key ID: 6FAE8F8677ACA8BA
13 changed files with 618 additions and 307 deletions

View file

@ -2,11 +2,7 @@ import { TypedEventEmitter } from '@revenge-mod/discord/common/utils'
import { getErrorStack } from '@revenge-mod/utils/error'
import { sleepReject } from '@revenge-mod/utils/promise'
import { pUnscopedApi as uapi } from '../apis'
import {
PluginFlags as Flag,
PluginFlags,
PluginStatus as Status,
} from '../constants'
import { PluginFlags as Flag, PluginStatus as Status } from '../constants'
import {
addPluginApiDecorator,
decoratePluginApi,
@ -128,7 +124,7 @@ export function registerPlugin<O extends PluginApiExtensionsOptions>(
pImplicitDeps.add(plugin)
}
// Only add to pending if the plugin is enabled
else if (flags & PluginFlags.Enabled) pPending.add(plugin)
else if (isPluginEnabled(plugin)) pPending.add(plugin)
pEmitter.emit('register', plugin, options)
@ -150,7 +146,7 @@ export function getPluginDependencies(plugin: AnyPlugin): AnyPlugin[] {
const dep = pList.get(depId)
if (dep) {
if (dep.flags & PluginFlags.Enabled) deps.push(dep)
if (isPluginEnabled(dep)) deps.push(dep)
else
throw new Error(
`Plugin "${id}" depends on disabled plugin "${depId}"`,
@ -169,13 +165,29 @@ export function getPluginDependencies(plugin: AnyPlugin): AnyPlugin[] {
return (meta.dependencies = deps)
}
export function isPluginEnabled({ flags }: AnyPlugin): boolean {
return Boolean(flags & Flag.Enabled)
}
export function isPluginEssential({ iflags }: InternalPluginMeta): boolean {
return Boolean(iflags & InternalPluginFlags.Essential)
}
export function isPluginInternal({ iflags }: InternalPluginMeta): boolean {
return Boolean(iflags & InternalPluginFlags.Internal)
}
function guardPluginEnabled(plugin: AnyPlugin) {
if (!isPluginEnabled(plugin))
throw new Error(`Plugin "${plugin.manifest.id}" is not enabled`)
}
/**
* Handles errors that occur in plugins.
*/
async function handlePluginError(e: unknown, plugin: AnyPlugin) {
plugin.errors.push(e)
plugin.flags |= Flag.Errored
const { iflags } = pMetadata.get(plugin)!
nativeLoggingHook(
`\u001b[31mPlugin "${plugin.manifest.id}" encountered an error: ${getErrorStack(e)}\u001b[0m`,
@ -185,14 +197,15 @@ async function handlePluginError(e: unknown, plugin: AnyPlugin) {
plugin.api?.logger?.error('Plugin encountered an error', e)
pEmitter.emit('errored', plugin, e)
if (!(iflags & InternalPluginFlags.Essential)) await plugin.disable()
if (!isPluginEssential(pMetadata.get(plugin)!)) await plugin.disable()
}
/**
* Prepares the plugin API for the preInit lifecycle.
*/
function preparePluginPreInit(plugin: AnyPlugin) {
function tryPreparePluginPreInit(plugin: AnyPlugin) {
const meta = pMetadata.get(plugin)!
if (meta.apiLevel >= PluginApiLevel.PreInit) return
// Clear errors from previous runs
plugin.errors = []
@ -216,8 +229,10 @@ function preparePluginPreInit(plugin: AnyPlugin) {
/**
* Prepares the plugin API for the init lifecycle.
*/
function preparePluginInit(plugin: AnyPlugin) {
function tryPreparePluginInit(plugin: AnyPlugin) {
const meta = pMetadata.get(plugin)!
if (meta.apiLevel >= PluginApiLevel.Init) return
const api = plugin.api as InitPluginApi
api.decorate = decorator => {
@ -231,8 +246,10 @@ function preparePluginInit(plugin: AnyPlugin) {
/**
* Prepares the plugin API for the start lifecycle.
*/
function preparePluginStart(plugin: AnyPlugin) {
function tryPreparePluginStart(plugin: AnyPlugin) {
const meta = pMetadata.get(plugin)!
if (meta.apiLevel >= PluginApiLevel.Start) return
const api = plugin.api as PluginApi
api.decorate = decorator => {
@ -244,18 +261,26 @@ function preparePluginStart(plugin: AnyPlugin) {
}
/**
* Disables a plugin.
* Disables a plugin, as well as all its dependents.
*/
export async function disablePlugin(plugin: AnyPlugin) {
if (!(plugin.flags & Flag.Enabled))
throw new Error(`Plugin "${plugin.manifest.id}" is not enabled`)
guardPluginEnabled(plugin)
const iflags = pMetadata.get(plugin)!.iflags ?? 0
if (iflags & InternalPluginFlags.Essential)
const meta = pMetadata.get(plugin)!
if (isPluginEssential(meta))
throw new Error(
`Plugin "${plugin.manifest.id}" is essential and cannot be disabled`,
)
const { dependents } = meta
await Promise.all(
dependents.map(dep => {
if (dep.flags & Flag.Enabled) return disablePlugin(dep)
}),
)
// Stop the plugin if needed
if (plugin.status && !(plugin.status & Status.Stopping))
await stopPlugin(plugin)
@ -266,55 +291,72 @@ export async function disablePlugin(plugin: AnyPlugin) {
}
/**
* Enables a plugin.
* Enables a plugin, as well as all its dependencies.
*/
export function enablePlugin(plugin: AnyPlugin, late: boolean) {
if (plugin.flags & Flag.Enabled)
export async function enablePlugin(plugin: AnyPlugin) {
if (isPluginEnabled(plugin))
throw new Error(`Plugin "${plugin.manifest.id}" is already enabled`)
await Promise.all(
getPluginDependencies(plugin).map(dep => {
if (dep.flags & Flag.Enabled) return
return runPluginLate(dep)
}),
)
// TODO(plugins): write to storage
plugin.flags |= Flag.Enabled
if (late) plugin.flags |= Flag.EnabledLate
pEmitter.emit('enabled', plugin)
}
export async function runPluginLate(plugin: AnyPlugin) {
guardPluginEnabled(plugin)
if (plugin.status & Status.Started)
throw new Error(`Plugin "${plugin.manifest.id}" is already started`)
plugin.flags |= Flag.EnabledLate
await preInitPlugin(plugin)
await initPlugin(plugin)
await startPlugin(plugin)
}
/**
* Runs the preInit lifecycle of a plugin.
*/
export async function preInitPlugin(plugin: AnyPlugin) {
guardPluginEnabled(plugin)
const {
manifest: { id },
lifecycles,
} = plugin
if (plugin.status & (Status.PreIniting | Status.PreInited))
throw new Error(
`Plugin preInit lifecycle for "${id}" is already running`,
)
tryPreparePluginPreInit(plugin)
const { lifecycles } = plugin
const { promises, handleError } = pMetadata.get(plugin)!
try {
if (!lifecycles.preInit) return
const meta = pMetadata.get(plugin)!
const { promises, handleError } = meta
if (!(plugin.flags & Flag.Enabled))
throw new Error(`Plugin "${id}" is not enabled`)
if (plugin.status & (Status.PreIniting | Status.PreInited))
throw new Error(
`Plugin preInit lifecycle for "${id}" is already running`,
)
preparePluginPreInit(plugin)
plugin.status |= Status.PreIniting
try {
const prom = lifecycles.preInit(plugin.api as PreInitPluginApi)
promises.push(prom)
await prom
// plugin.disable() already handles, so it's in the try block
plugin.status |= Status.PreInited
plugin.status &= ~Status.PreIniting
} catch (e) {
await handleError(e)
} finally {
plugin.status |= Status.PreInited
plugin.status &= ~Status.PreIniting
}
} finally {
pEmitter.emit('preInited', plugin)
@ -325,40 +367,37 @@ export async function preInitPlugin(plugin: AnyPlugin) {
* Runs the init lifecycle of a plugin.
*/
export async function initPlugin(plugin: AnyPlugin) {
guardPluginEnabled(plugin)
const {
manifest: { id },
lifecycles,
} = plugin
const meta = pMetadata.get(plugin)!
if (plugin.status & (Status.Initing | Status.Inited))
throw new Error(`Plugin init lifecycle for "${id}" is already running`)
tryPreparePluginPreInit(plugin)
tryPreparePluginInit(plugin)
const { lifecycles } = plugin
const { promises, handleError } = meta
try {
if (!lifecycles.init) return
const meta = pMetadata.get(plugin)!
const { promises, apiLevel, handleError } = meta
if (!(plugin.flags & Flag.Enabled))
throw new Error(`Plugin "${id}" is not enabled`)
if (plugin.status & (Status.Initing | Status.Inited))
throw new Error(
`Plugin init lifecycle for "${id}" is already running`,
)
if (apiLevel < PluginApiLevel.PreInit) preparePluginPreInit(plugin)
if (apiLevel < PluginApiLevel.Init) preparePluginInit(plugin)
plugin.status |= Status.Initing
try {
const prom = lifecycles.init(plugin.api as InitPluginApi)
promises.push(prom)
await prom
// plugin.disable() already handles, so it's in the try block
plugin.status |= Status.Inited
plugin.status &= ~Status.Initing
} catch (e) {
await handleError(e)
} finally {
plugin.status |= Status.Inited
plugin.status &= ~Status.Initing
}
} finally {
pEmitter.emit('inited', plugin)
@ -369,40 +408,36 @@ export async function initPlugin(plugin: AnyPlugin) {
* Starts a plugin by running its start lifecycle.
*/
export async function startPlugin(plugin: AnyPlugin) {
guardPluginEnabled(plugin)
const {
manifest: { id },
lifecycles,
} = plugin
if (plugin.status & (Status.Starting | Status.Started))
throw new Error(`Plugin start lifecycle for "${id}" is already running`)
tryPreparePluginPreInit(plugin)
tryPreparePluginInit(plugin)
tryPreparePluginStart(plugin)
const { lifecycles } = plugin
const { promises, handleError } = pMetadata.get(plugin)!
try {
if (!lifecycles.start) return
const meta = pMetadata.get(plugin)!
const { promises, apiLevel, handleError } = meta
if (!(plugin.flags & Flag.Enabled))
throw new Error(`Plugin "${id}" is not enabled`)
if (plugin.status & (Status.Starting | Status.Started))
throw new Error(
`Plugin start lifecycle for "${id}" is already running`,
)
if (apiLevel < PluginApiLevel.PreInit) preparePluginPreInit(plugin)
if (apiLevel < PluginApiLevel.Init) preparePluginInit(plugin)
if (apiLevel < PluginApiLevel.Start) preparePluginStart(plugin)
plugin.status |= Status.Starting
try {
const prom = lifecycles.start(plugin.api as PluginApi)
promises.push(prom)
await prom
plugin.status |= Status.Started
plugin.status &= ~Status.Starting
} catch (e) {
await handleError(e)
} finally {
plugin.status |= Status.Started
plugin.status &= ~Status.Starting
}
} finally {
pEmitter.emit('started', plugin)
@ -413,22 +448,23 @@ export async function startPlugin(plugin: AnyPlugin) {
* Stops a plugin by running its stop lifecycle and cleanup functions.
*/
export async function stopPlugin(plugin: AnyPlugin) {
guardPluginEnabled(plugin)
const {
manifest: { id },
lifecycles,
} = plugin
const meta = pMetadata.get(plugin)!
const { promises, iflags, apiLevel } = meta
if (iflags & InternalPluginFlags.Essential)
if (isPluginEssential(meta))
throw new Error(`Plugin "${id}" is essential and cannot be stopped`)
if (!(plugin.flags & Flag.Enabled))
throw new Error(`Plugin "${id}" is not enabled`)
if (plugin.status & Status.Stopping)
throw new Error(`Plugin "${id}" is already stopping`)
const { lifecycles } = plugin
const { promises, handleError } = meta
// Wait for in-progress lifecycles to finish or timeout
if (plugin.status & (Status.PreIniting | Status.Initing | Status.Starting))
await Promise.race([
@ -439,18 +475,13 @@ export async function stopPlugin(plugin: AnyPlugin) {
),
]).catch(e => {
plugin.flags |= Flag.ReloadRequired
handlePluginError(e, plugin)
return handlePluginError(e, plugin)
})
else if (
!(plugin.status & (Status.PreInited | Status.Inited | Status.Started))
)
throw new Error(`Plugin "${id}" is not running`)
// Prepare APIs if needed
if (apiLevel < PluginApiLevel.PreInit) preparePluginPreInit(plugin)
if (apiLevel < PluginApiLevel.Init) preparePluginInit(plugin)
if (apiLevel < PluginApiLevel.Start) preparePluginStart(plugin)
plugin.status |= Status.Stopping
try {
@ -463,7 +494,7 @@ export async function stopPlugin(plugin: AnyPlugin) {
),
])
} catch (e) {
await handlePluginError(e, plugin)
await handleError(e)
} finally {
// Run cleanups
await cleanupPlugin(plugin, meta)

View file

@ -1,8 +1,7 @@
import { initPlugin } from './_internal'
import { initPlugin, isPluginEnabled } from './_internal'
import { computePendingNodes, pListOrdered } from './_internal/dependency-graph'
import { PluginFlags } from './constants'
computePendingNodes()
for (const plugin of pListOrdered)
if (plugin.flags & PluginFlags.Enabled) initPlugin(plugin)
if (isPluginEnabled(plugin)) initPlugin(plugin)

View file

@ -1,8 +1,7 @@
import { preInitPlugin } from './_internal'
import { isPluginEnabled, preInitPlugin } from './_internal'
import { computePendingNodes, pListOrdered } from './_internal/dependency-graph'
import { PluginFlags } from './constants'
computePendingNodes()
for (const plugin of pListOrdered)
if (plugin.flags & PluginFlags.Enabled) preInitPlugin(plugin)
if (isPluginEnabled(plugin)) preInitPlugin(plugin)

View file

@ -1,6 +1,5 @@
import { startPlugin } from './_internal'
import { isPluginEnabled, startPlugin } from './_internal'
import { computePendingNodes, pListOrdered } from './_internal/dependency-graph'
import { PluginFlags } from './constants'
computePendingNodes()
@ -11,4 +10,4 @@ if (__DEV__)
)
for (const plugin of pListOrdered)
if (plugin.flags & PluginFlags.Enabled) startPlugin(plugin)
if (isPluginEnabled(plugin)) startPlugin(plugin)

View file

@ -1,17 +1,11 @@
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 { Image } from 'react-native'
import type { DiscordModules } from '@revenge-mod/discord/types'
const { NavigatorHeader } = Design
const { NavigatorHeader, createStyles } = Design
const { default: tokens } = Tokens
const ThemeStore = Stores.ThemeStore as DiscordModules.Flux.Store<{
theme: string
}>
interface NavigatorHeaderWithIconProps {
title: string
icon: string
@ -21,23 +15,23 @@ export default function NavigatorHeaderWithIcon({
title,
icon,
}: NavigatorHeaderWithIconProps) {
const styles = useNavigatorHeaderStyles()
return (
<NavigatorHeader
icon={
<Image
style={{
width: 24,
height: 24,
marginEnd: 8,
tintColor: tokens.internal.resolveSemanticColor(
ThemeStore.theme,
tokens.colors.HEADER_PRIMARY,
),
}}
source={getAssetIdByName(icon!)}
/>
<Image style={styles.header} source={getAssetIdByName(icon!)} />
}
title={title}
/>
)
}
const useNavigatorHeaderStyles = createStyles({
header: {
width: 24,
height: 24,
marginEnd: 8,
tintColor: tokens.colors.HEADER_PRIMARY,
},
})

View file

@ -1,66 +1,71 @@
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 { ReactNavigationNative } from '@revenge-mod/externals/react-navigation'
import {
enablePlugin,
InternalPluginFlags,
initPlugin,
preInitPlugin,
startPlugin,
getPluginDependencies,
isPluginEnabled,
isPluginEssential,
runPluginLate,
} from '@revenge-mod/plugins/_'
import { memo } from 'react'
import { Image, Pressable } from 'react-native'
import { useClickOutside } from 'react-native-click-outside'
import { usePluginEnabled } from './PluginStateProvider'
import {
Image,
PixelRatio,
Pressable,
useWindowDimensions,
View,
} from 'react-native'
import { PluginFlashList } from './PluginList'
import PluginStatesProvider, { usePluginEnabled } from './PluginStateProvider'
import {
enableTooltipTarget,
essentialTooltipTarget,
resetTooltips,
setEnablePluginTooltipVisible,
setEssentialPluginTooltipVisible,
useClickOutsideTooltip,
} from './TooltipProvider'
import type { AnyPlugin, InternalPluginMeta } 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 const InstalledPluginCard = memo(function InstalledPluginCard({
plugin,
meta,
rightGap,
export const PLUGIN_CARD_ESTIMATED_SIZE = 116
export const PluginCard = memo(function PluginCard({
name,
description,
author,
icon,
actions,
rowEnd,
columnEnd,
}: {
plugin: AnyPlugin
meta: InternalPluginMeta
rightGap?: boolean
name: string
description: string
author: string
icon?: string
actions?: React.ReactNode
rowEnd?: boolean
columnEnd?: boolean
}) {
const navigation = ReactNavigationNative.useNavigation()
const enabled = usePluginEnabled(plugin)
const {
manifest: { name, description, author, icon },
} = plugin
const essential = Boolean(meta.iflags & InternalPluginFlags.Essential)
const styles_ = usePluginCardStyles()
const settingsRef = useClickOutside<View>(() => {
if (enableTooltipTarget.current === settingsRef.current)
setEnablePluginTooltipVisible?.(false)
})
const switchRef = useClickOutside<View>(() => {
if (essentialTooltipTarget.current === switchRef.current)
setEssentialPluginTooltipVisible?.(false)
})
return (
<Card style={[styles_.card, styles.grow, rightGap && styles_.rightGap]}>
<Card
style={[
styles_.card,
styles.grow,
columnEnd && styles_.columnEnd,
rowEnd && styles_.rowEnd,
]}
>
<Stack
direction="horizontal"
style={[styles.grow, styles_.topContainer]}
@ -76,62 +81,7 @@ export const InstalledPluginCard = memo(function InstalledPluginCard({
/>
<Text variant="heading-lg/semibold">{name}</Text>
</Stack>
{plugin.SettingsComponent && (
<Pressable
onPress={e => {
if (enabled) return
e.stopPropagation()
resetTooltips()
requestAnimationFrame(() => {
enableTooltipTarget.current =
settingsRef.current
setEnablePluginTooltipVisible?.(true)
})
}}
>
<IconButton
ref={settingsRef}
size="sm"
variant="secondary"
icon={SettingsIcon}
onPress={() => {
navigation.navigate(plugin.manifest.id)
}}
disabled={!enabled}
/>
</Pressable>
)}
<Pressable
onPress={e => {
if (essential) {
e.stopPropagation()
resetTooltips()
requestAnimationFrame(() => {
essentialTooltipTarget.current =
switchRef.current
setEssentialPluginTooltipVisible?.(true)
})
}
}}
ref={switchRef}
>
<FormSwitch
disabled={essential}
// biome-ignore lint/complexity/useArrowFunction: Async arrows are not supported
onValueChange={async function (v) {
if (v) {
await enablePlugin(plugin, true)
await preInitPlugin(plugin)
await initPlugin(plugin)
await startPlugin(plugin)
} else await plugin.disable()
}}
value={enabled}
/>
</Pressable>
{actions}
</Stack>
<Stack spacing={4} style={[styles_.alignedContainer, styles.grow]}>
<Text
@ -149,6 +99,265 @@ export const InstalledPluginCard = memo(function InstalledPluginCard({
)
})
export const InstalledPluginCard = memo(function InstalledPluginCard({
plugin,
meta,
rowEnd,
columnEnd,
}: {
plugin: AnyPlugin
meta: InternalPluginMeta
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)
})
const switchRef = useClickOutsideTooltip(essentialTooltipTarget, () => {
setEssentialPluginTooltipVisible?.(false)
})
return (
<PluginCard
name={name}
description={description}
author={author}
icon={icon}
rowEnd={rowEnd}
columnEnd={columnEnd}
actions={
<>
{plugin.SettingsComponent && (
<Pressable
onPress={e => {
if (enabled) return
e.stopPropagation()
requestAnimationFrame(() => {
enableTooltipTarget.current =
settingsRef.current
setEnablePluginTooltipVisible!(true)
})
}}
>
<IconButton
ref={settingsRef}
size="sm"
variant="secondary"
icon={SettingsIcon}
onPress={() => {
navigation.navigate(plugin.manifest.id)
}}
disabled={!enabled}
/>
</Pressable>
)}
<Pressable
onPress={e => {
if (essential) {
e.stopPropagation()
requestAnimationFrame(() => {
essentialTooltipTarget.current =
switchRef.current
setEssentialPluginTooltipVisible!(true)
})
}
}}
ref={switchRef}
>
<FormSwitch
disabled={essential}
onValueChange={enabled => {
if (enabled)
return handleEnablePlugin(
plugin,
dependencies,
)
else
return handleDisablePlugin(
plugin,
dependents,
)
}}
value={enabled}
/>
</Pressable>
</>
}
/>
)
})
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,
@ -156,14 +365,18 @@ const usePluginCardStyles = createStyles({
tintColor: Tokens.default.colors.TEXT_NORMAL,
},
card: {
marginBottom: 12,
paddingVertical: 12,
paddingHorizontal: 12,
gap: 4,
},
rightGap: {
marginBottom: 12,
marginRight: 12,
},
rowEnd: {
marginBottom: 0,
},
columnEnd: {
marginRight: 0,
},
topContainer: {
alignItems: 'center',
},

View file

@ -0,0 +1,80 @@
import { Design } from '@revenge-mod/discord/design'
import { FlashList, MasonryFlashList } from '@shopify/flash-list'
import { useWindowDimensions } from 'react-native'
import {
InstalledPluginCard,
PLUGIN_CARD_ESTIMATED_SIZE,
PluginCard,
} from './PluginCard'
import { resetTooltips } from './TooltipProvider'
import type { AnyPlugin, InternalPluginMeta } from '@revenge-mod/plugins/_'
const { Text } = Design
export function PluginFlashList({ plugins }: { plugins: AnyPlugin[] }) {
return (
<FlashList
data={plugins}
onScrollBeginDrag={resetTooltips}
fadingEdgeLength={plugins.length === 1 ? 0 : 16}
keyExtractor={plugin => plugin.manifest.id}
estimatedItemSize={PLUGIN_CARD_ESTIMATED_SIZE}
renderItem={({
item: {
manifest: { name, description, author, icon },
},
index,
}) => (
<PluginCard
name={name}
description={description}
author={author}
icon={icon}
rowEnd={index === plugins.length - 1}
columnEnd
/>
)}
/>
)
}
export function InstalledPluginMasonryFlashList({
plugins,
}: {
plugins: (readonly [AnyPlugin, InternalPluginMeta])[]
}) {
const { width } = useWindowDimensions()
const numColumns = Math.floor((width - 16) / 448)
return (
<MasonryFlashList
contentContainerStyle={{ paddingBottom: 16 }}
data={plugins}
onScrollBeginDrag={resetTooltips}
fadingEdgeLength={16}
keyExtractor={([plugin]) => plugin.manifest.id}
estimatedItemSize={PLUGIN_CARD_ESTIMATED_SIZE}
numColumns={numColumns}
ListEmptyComponent={NoPlugins}
renderItem={({ item: [plugin, meta], columnIndex, index }) => (
<InstalledPluginCard
plugin={plugin}
meta={meta}
rowEnd={
index > plugins.length - numColumns &&
columnIndex <= plugins.length % numColumns
}
columnEnd={columnIndex === numColumns - 1}
/>
)}
/>
)
}
function NoPlugins() {
return (
<Text variant="heading-md/medium" style={{ textAlign: 'center' }}>
No plugins found. Try changing your query or filters.
</Text>
)
}

View file

@ -1,5 +1,4 @@
import { pEmitter, pList } from '@revenge-mod/plugins/_'
import { PluginFlags } from '@revenge-mod/plugins/constants'
import { isPluginEnabled, pEmitter, pList } from '@revenge-mod/plugins/_'
import { createContext, useContext, useEffect, useMemo, useState } from 'react'
import type { AnyPlugin } from '@revenge-mod/plugins/_'
import type { ReactNode } from 'react'
@ -20,9 +19,7 @@ export default function PluginStatesProvider({
const [enabled, setEnabled] = useState<PluginEnabledStates>(() => {
const states: PluginEnabledStates = {}
for (const plugin of pList.values()) {
states[plugin.manifest.id] = Boolean(
plugin.flags & PluginFlags.Enabled,
)
states[plugin.manifest.id] = isPluginEnabled(plugin)
}
return states
})
@ -70,7 +67,7 @@ export function usePluginEnabled(plugin: AnyPlugin) {
if (!context) {
console.warn('usePluginEnabled not used within a PluginStateProvider')
return Boolean(plugin.flags & PluginFlags.Enabled)
return isPluginEnabled(plugin)
}
return context.enabled[plugin.manifest.id]

View file

@ -5,6 +5,7 @@
import { Design } from '@revenge-mod/discord/design'
import { useEffect, useState } from 'react'
import { useClickOutside } from 'react-native-click-outside'
import type { RefObject } from 'react'
import type { View } from 'react-native'
@ -72,3 +73,15 @@ export function resetTooltips() {
setEnablePluginTooltipVisible?.(false)
setEssentialPluginTooltipVisible?.(false)
}
export function useClickOutsideTooltip(
target: RefObject<View | null>,
onClickOutside: () => void,
) {
const ref = useClickOutside<View>(() => {
resetTooltips()
if (target.current === ref.current) onClickOutside()
})
return ref
}

View file

@ -1,16 +1,18 @@
import TableRowAssetIcon from '@revenge-mod/components/TableRowAssetIcon'
import { Setting } from '../constants'
import { CopyableSetting } from './shared'
import type { SettingsItem } from '@revenge-mod/discord/modules/settings'
// @ts-expect-error
const props = HermesInternal.getRuntimeProperties()
const HermesVersionSetting: SettingsItem = {
parent: Setting.Revenge,
IconComponent: () => <TableRowAssetIcon name="TranscriptOutlineIcon" />,
title: () => 'Hermes',
useDescription: () => `${props['Bytecode Version']} (${props.Build})`,
type: 'static',
}
const HermesVersionSetting: SettingsItem = CopyableSetting(
{
parent: Setting.Revenge,
IconComponent: () => <TableRowAssetIcon name="TranscriptOutlineIcon" />,
title: () => 'Hermes',
},
() => `${props['Bytecode Version']} (${props.Build})`,
)
export default HermesVersionSetting

View file

@ -2,7 +2,7 @@ import TableRowAssetIcon from '@revenge-mod/components/TableRowAssetIcon'
import { AlertActionCreators } from '@revenge-mod/discord/actions'
import { Design } from '@revenge-mod/discord/design'
import { BundleUpdaterManager } from '@revenge-mod/discord/native'
import { pEmitter, pList } from '@revenge-mod/plugins/_'
import { isPluginEnabled, pEmitter, pList } from '@revenge-mod/plugins/_'
import { PluginFlags } from '@revenge-mod/plugins/constants'
import { useReRender } from '@revenge-mod/utils/react'
import { useEffect } from 'react'
@ -25,8 +25,7 @@ const RevengePluginsSetting: SettingsItem = {
let enabledCount = 0
for (const plugin of pList.values())
if (plugin.flags & PluginFlags.Enabled) enabledCount++
for (const plugin of pList.values()) if (isPluginEnabled(plugin)) enabledCount++
pEmitter.on('disabled', () => {
enabledCount--
@ -58,34 +57,54 @@ pEmitter.on('started', showReloadRequiredAlertIfNeeded)
pEmitter.on('stopped', showReloadRequiredAlertIfNeeded)
function showReloadRequiredAlertIfNeeded(plugin: AnyPlugin) {
if (plugin.flags & PluginFlags.ReloadRequired)
if (plugin.flags & PluginFlags.ReloadRequired) {
const plugins = [...pList.values()].filter(
plugin => plugin.flags & PluginFlags.ReloadRequired,
)
AlertActionCreators.openAlert(
'plugin-reload-required',
<AlertModal
title="Reload required"
content={
<Text variant="text-md/medium" color="header-secondary">
Plugin{' '}
<Text variant="text-md/bold" color="header-secondary">
{plugin.manifest.name}
</Text>{' '}
requires a reload to apply changes.
</Text>
}
actions={
<>
<AlertActionButton
variant="destructive"
text="Reload"
onPress={() => {
BundleUpdaterManager.reload()
}}
/>
<AlertActionButton variant="secondary" text="Not now" />
</>
}
/>,
<PluginReloadRequiredAlert plugins={plugins} />,
)
}
}
function PluginReloadRequiredAlert({ plugins }: { plugins: AnyPlugin[] }) {
return (
<AlertModal
title="Reload required"
content={
<Text variant="text-md/medium" color="header-secondary">
The following plugins require a reload to apply changes:
{'\n'}
{plugins.map(plugin => (
<>
<Text
key={plugin.manifest.id}
variant="text-md/bold"
color="text-normal"
>
{plugin.manifest.name}
</Text>
{', '}
</>
))}
</Text>
}
actions={
<>
<AlertActionButton
variant="destructive"
text="Reload"
onPress={() => {
BundleUpdaterManager.reload()
}}
/>
<AlertActionButton variant="secondary" text="Not now" />
</>
}
/>
)
}
export default RevengePluginsSetting

View file

@ -50,7 +50,7 @@ const pluginSettings = registerPlugin(
},
},
PluginFlags.Enabled,
InternalPluginFlags.Internal | InternalPluginFlags.Essential,
InternalPluginFlags.Internal,
)
function patchSettingsNavigator(exports: any) {

View file

@ -1,32 +1,35 @@
import { useNavigation, useRoute } from '@react-navigation/native'
import { getAssetIdByName } from '@revenge-mod/assets'
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 { Design } from '@revenge-mod/discord/design'
import { ReactNavigationNative } from '@revenge-mod/externals/react-navigation'
import { FlashList } from '@revenge-mod/externals/shopify'
import { InternalPluginFlags, pList, pMetadata } from '@revenge-mod/plugins/_'
import {
isPluginEnabled,
isPluginEssential,
isPluginInternal,
pList,
pMetadata,
} from '@revenge-mod/plugins/_'
import { PluginFlags } from '@revenge-mod/plugins/constants'
import { debounce } from '@revenge-mod/utils/callback'
import { useCallback, useMemo, useState } from 'react'
import { useWindowDimensions, View } from 'react-native'
import { View } from 'react-native'
import { ClickOutsideProvider } from 'react-native-click-outside'
import RevengeIcon from '~assets/RevengeIcon'
import { InstalledPluginCard } from '../components/PluginCard'
import { InstalledPluginMasonryFlashList } from '../components/PluginList'
import PluginStatesProvider from '../components/PluginStateProvider'
import {
EnablePluginTooltipProvider,
EssentialPluginTooltipProvider,
resetTooltips,
} from '../components/TooltipProvider'
import type { RouteProp } from '@react-navigation/core'
import type { ReactNavigationParamList } from '@revenge-mod/externals/react-navigation'
import type { AnyPlugin, InternalPluginMeta } from '@revenge-mod/plugins/_'
import type { FilterAndSortActionSheetProps } from '../components/FilterAndSortActionSheet'
import type { RouteNames, Setting } from '../constants'
const { Text, Stack, IconButton, LayerScope } = Design
const { Stack, IconButton, LayerScope } = Design
const FiltersHorizontalIcon = getAssetIdByName('FiltersHorizontalIcon', 'png')!
@ -53,20 +56,19 @@ const SearchDebounceTime = 100
const Filters: FilterAndSortActionSheetProps['filters'] = {
Enabled: {
icon: getAssetIdByName('CircleCheckIcon')!,
filter: plugin => Boolean(plugin.flags & PluginFlags.Enabled),
filter: plugin => isPluginEnabled(plugin),
},
Disabled: {
icon: getAssetIdByName('CircleXIcon')!,
filter: plugin => !(plugin.flags & PluginFlags.Enabled),
filter: plugin => !isPluginEnabled(plugin),
},
Internal: {
icon: RevengeIcon,
filter: plugin => Boolean(plugin.flags & InternalPluginFlags.Internal),
filter: (_, meta) => isPluginInternal(meta),
},
Essential: {
icon: getAssetIdByName('StarIcon')!,
filter: (_, meta) =>
Boolean(meta.iflags & InternalPluginFlags.Essential),
filter: (_, meta) => isPluginEssential(meta),
},
} satisfies FilterAndSortActionSheetProps['filters']
const DefaultFilters: FilterAndSortActionSheetProps['filter'] = []
@ -85,9 +87,9 @@ const Sorts = {
} satisfies FilterAndSortActionSheetProps['sorts']
function Screen() {
const navigation = ReactNavigationNative.useNavigation()
const navigation = useNavigation()
const route =
ReactNavigationNative.useRoute<
useRoute<
RouteProp<
ReactNavigationParamList,
(typeof RouteNames)[typeof Setting.RevengePlugins]
@ -182,44 +184,7 @@ function Screen() {
}
/>
</Stack>
<PluginMasonryFlashList plugins={plugins} />
<InstalledPluginMasonryFlashList plugins={plugins} />
</>
)
}
function PluginMasonryFlashList({
plugins,
}: {
plugins: (readonly [AnyPlugin, InternalPluginMeta])[]
}) {
const { width, height } = useWindowDimensions()
const numColumns = Math.floor((width - 16) / 448)
return (
<FlashList.MasonryFlashList
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, meta], columnIndex }) => (
<InstalledPluginCard
plugin={plugin}
meta={meta}
rightGap={columnIndex + 1 < numColumns}
/>
)}
/>
)
}
function NoPlugins() {
return (
<Text variant="heading-md/medium" style={{ textAlign: 'center' }}>
No plugins found. Try changing your query or filters.
</Text>
)
}