diff --git a/lib/plugins/src/_internal/index.ts b/lib/plugins/src/_internal/index.ts index 8884d99..8ec2499 100644 --- a/lib/plugins/src/_internal/index.ts +++ b/lib/plugins/src/_internal/index.ts @@ -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( 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) diff --git a/lib/plugins/src/init.ts b/lib/plugins/src/init.ts index 531d9c6..79d1c5b 100644 --- a/lib/plugins/src/init.ts +++ b/lib/plugins/src/init.ts @@ -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) diff --git a/lib/plugins/src/preinit.ts b/lib/plugins/src/preinit.ts index 78632ad..d961dbf 100644 --- a/lib/plugins/src/preinit.ts +++ b/lib/plugins/src/preinit.ts @@ -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) diff --git a/lib/plugins/src/start.ts b/lib/plugins/src/start.ts index bfea0c1..9ac8741 100644 --- a/lib/plugins/src/start.ts +++ b/lib/plugins/src/start.ts @@ -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) diff --git a/src/plugins/start/settings/components/NavigatorHeaderWithIcon.tsx b/src/plugins/start/settings/components/NavigatorHeaderWithIcon.tsx index df090b5..3d01d51 100644 --- a/src/plugins/start/settings/components/NavigatorHeaderWithIcon.tsx +++ b/src/plugins/start/settings/components/NavigatorHeaderWithIcon.tsx @@ -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 ( + } title={title} /> ) } + +const useNavigatorHeaderStyles = createStyles({ + header: { + width: 24, + height: 24, + marginEnd: 8, + tintColor: tokens.colors.HEADER_PRIMARY, + }, +}) diff --git a/src/plugins/start/settings/components/PluginCard.tsx b/src/plugins/start/settings/components/PluginCard.tsx index 71b4390..43868f2 100644 --- a/src/plugins/start/settings/components/PluginCard.tsx +++ b/src/plugins/start/settings/components/PluginCard.tsx @@ -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(() => { - 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() - - requestAnimationFrame(() => { - enableTooltipTarget.current = - settingsRef.current - setEnablePluginTooltipVisible?.(true) - }) - }} - > - { - navigation.navigate(plugin.manifest.id) - }} - disabled={!enabled} - /> - - )} - { - if (essential) { - e.stopPropagation() - resetTooltips() - - requestAnimationFrame(() => { - essentialTooltipTarget.current = - switchRef.current - setEssentialPluginTooltipVisible?.(true) - }) - } - }} - ref={switchRef} - > - - + {actions} { + setEnablePluginTooltipVisible?.(false) + }) + + const switchRef = useClickOutsideTooltip(essentialTooltipTarget, () => { + setEssentialPluginTooltipVisible?.(false) + }) + + return ( + + {plugin.SettingsComponent && ( + { + if (enabled) return + + e.stopPropagation() + + requestAnimationFrame(() => { + enableTooltipTarget.current = + settingsRef.current + setEnablePluginTooltipVisible!(true) + }) + }} + > + { + navigation.navigate(plugin.manifest.id) + }} + disabled={!enabled} + /> + + )} + { + if (essential) { + e.stopPropagation() + + requestAnimationFrame(() => { + essentialTooltipTarget.current = + switchRef.current + setEssentialPluginTooltipVisible!(true) + }) + } + }} + ref={switchRef} + > + { + if (enabled) + return handleEnablePlugin( + plugin, + dependencies, + ) + else + return handleDisablePlugin( + plugin, + dependents, + ) + }} + value={enabled} + /> + + + } + /> + ) +}) + +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', + + + , + ) + 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', + + + , + ) + else await action() +} + +function PluginHasDependenciesAlert({ + plugin, + dependencies, + action, +}: { + plugin: AnyPlugin + dependencies: AnyPlugin[] + action: () => Promise +}) { + const { height: windowHeight } = useWindowDimensions() + const maxHeight = PixelRatio.get() * windowHeight * 0.35 - 64 + + return ( + + Plugin{' '} + + {plugin.manifest.name} + {' '} + depends on other plugins to function. + {'\n'} + Enabling it will also enable the following plugins: + + } + extraContent={ + + + + } + actions={ + <> + + + + } + /> + ) +} + +function PluginHasDependentsAlert({ + plugin, + dependents, + action, +}: { + plugin: AnyPlugin + dependents: AnyPlugin[] + action: () => Promise +}) { + const { height: windowHeight } = useWindowDimensions() + const maxHeight = PixelRatio.get() * windowHeight * 0.35 - 64 + + return ( + + Other plugins depend on{' '} + + {plugin.manifest.name} + {' '} + to function. + {'\n'} + Disabling it will also disable the following plugins: + + } + extraContent={ + + + + } + actions={ + <> + + + + } + /> + ) +} + 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', }, diff --git a/src/plugins/start/settings/components/PluginList.tsx b/src/plugins/start/settings/components/PluginList.tsx new file mode 100644 index 0000000..a34d794 --- /dev/null +++ b/src/plugins/start/settings/components/PluginList.tsx @@ -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 ( + plugin.manifest.id} + estimatedItemSize={PLUGIN_CARD_ESTIMATED_SIZE} + renderItem={({ + item: { + manifest: { name, description, author, icon }, + }, + index, + }) => ( + + )} + /> + ) +} + +export function InstalledPluginMasonryFlashList({ + plugins, +}: { + plugins: (readonly [AnyPlugin, InternalPluginMeta])[] +}) { + const { width } = useWindowDimensions() + const numColumns = Math.floor((width - 16) / 448) + + return ( + plugin.manifest.id} + estimatedItemSize={PLUGIN_CARD_ESTIMATED_SIZE} + numColumns={numColumns} + ListEmptyComponent={NoPlugins} + renderItem={({ item: [plugin, meta], columnIndex, index }) => ( + plugins.length - numColumns && + columnIndex <= plugins.length % numColumns + } + columnEnd={columnIndex === numColumns - 1} + /> + )} + /> + ) +} + +function NoPlugins() { + return ( + + No plugins found. Try changing your query or filters. + + ) +} diff --git a/src/plugins/start/settings/components/PluginStateProvider.tsx b/src/plugins/start/settings/components/PluginStateProvider.tsx index 414b76e..e24d024 100644 --- a/src/plugins/start/settings/components/PluginStateProvider.tsx +++ b/src/plugins/start/settings/components/PluginStateProvider.tsx @@ -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(() => { 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] diff --git a/src/plugins/start/settings/components/TooltipProvider.tsx b/src/plugins/start/settings/components/TooltipProvider.tsx index e8887f1..500c6fd 100644 --- a/src/plugins/start/settings/components/TooltipProvider.tsx +++ b/src/plugins/start/settings/components/TooltipProvider.tsx @@ -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, + onClickOutside: () => void, +) { + const ref = useClickOutside(() => { + resetTooltips() + if (target.current === ref.current) onClickOutside() + }) + + return ref +} diff --git a/src/plugins/start/settings/definitions/HermesVersionSetting.tsx b/src/plugins/start/settings/definitions/HermesVersionSetting.tsx index a6813af..0dc2b7b 100644 --- a/src/plugins/start/settings/definitions/HermesVersionSetting.tsx +++ b/src/plugins/start/settings/definitions/HermesVersionSetting.tsx @@ -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: () => , - title: () => 'Hermes', - useDescription: () => `${props['Bytecode Version']} (${props.Build})`, - type: 'static', -} +const HermesVersionSetting: SettingsItem = CopyableSetting( + { + parent: Setting.Revenge, + IconComponent: () => , + title: () => 'Hermes', + }, + () => `${props['Bytecode Version']} (${props.Build})`, +) export default HermesVersionSetting diff --git a/src/plugins/start/settings/definitions/RevengePluginsSetting.tsx b/src/plugins/start/settings/definitions/RevengePluginsSetting.tsx index 965ed13..cfad49e 100644 --- a/src/plugins/start/settings/definitions/RevengePluginsSetting.tsx +++ b/src/plugins/start/settings/definitions/RevengePluginsSetting.tsx @@ -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', - - Plugin{' '} - - {plugin.manifest.name} - {' '} - requires a reload to apply changes. - - } - actions={ - <> - { - BundleUpdaterManager.reload() - }} - /> - - - } - />, + , ) + } +} + +function PluginReloadRequiredAlert({ plugins }: { plugins: AnyPlugin[] }) { + return ( + + The following plugins require a reload to apply changes: + {'\n'} + {plugins.map(plugin => ( + <> + + {plugin.manifest.name} + + {', '} + + ))} + + } + actions={ + <> + { + BundleUpdaterManager.reload() + }} + /> + + + } + /> + ) } export default RevengePluginsSetting diff --git a/src/plugins/start/settings/index.ts b/src/plugins/start/settings/index.ts index 038d93d..7a42ee7 100644 --- a/src/plugins/start/settings/index.ts +++ b/src/plugins/start/settings/index.ts @@ -50,7 +50,7 @@ const pluginSettings = registerPlugin( }, }, PluginFlags.Enabled, - InternalPluginFlags.Internal | InternalPluginFlags.Essential, + InternalPluginFlags.Internal, ) function patchSettingsNavigator(exports: any) { diff --git a/src/plugins/start/settings/screens/RevengePluginsSettingScreen.tsx b/src/plugins/start/settings/screens/RevengePluginsSettingScreen.tsx index a730ee4..dad4d16 100644 --- a/src/plugins/start/settings/screens/RevengePluginsSettingScreen.tsx +++ b/src/plugins/start/settings/screens/RevengePluginsSettingScreen.tsx @@ -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() { } /> - + ) } - -function PluginMasonryFlashList({ - plugins, -}: { - plugins: (readonly [AnyPlugin, InternalPluginMeta])[] -}) { - const { width, height } = useWindowDimensions() - const numColumns = Math.floor((width - 16) / 448) - - return ( - plugin.manifest.id} - estimatedListSize={{ width: width - 32, height: height - 160 }} - estimatedItemSize={116} - numColumns={numColumns} - ListEmptyComponent={NoPlugins} - renderItem={({ item: [plugin, meta], columnIndex }) => ( - - )} - /> - ) -} - -function NoPlugins() { - return ( - - No plugins found. Try changing your query or filters. - - ) -}