374 lines
12 KiB
TypeScript
374 lines
12 KiB
TypeScript
import { getAssetIdByName } from '@revenge-mod/assets'
|
|
import { TableRowAssetIcon } 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 {
|
|
formatPluginError,
|
|
getInternalPluginMeta,
|
|
getPluginDependencies,
|
|
getPluginDependents,
|
|
InternalPluginFlags,
|
|
isDefaultsOnlyBoot,
|
|
isPluginEnabledInSavedStates,
|
|
isPluginEssential,
|
|
isPluginInternal,
|
|
isPluginPendingUpdate,
|
|
isPluginStartable,
|
|
PluginFlags,
|
|
runPluginLate,
|
|
stopPlugin,
|
|
} from '@revenge-mod/plugins/_'
|
|
import { listRepos } from '@revenge-mod/plugins/_/repositories'
|
|
import { PluginStatus } from '@revenge-mod/plugins/constants'
|
|
import { formatVersion } from '@revenge-mod/plugins/utils'
|
|
import { lookupGeneratedIconComponent } from '@revenge-mod/utils/discord'
|
|
import { useEffect, useState } from 'react'
|
|
import { Pressable } from 'react-native'
|
|
import { ClickOutsideProvider } from 'react-native-click-outside'
|
|
import {
|
|
openPluginSettings,
|
|
showPluginClearDataConfirmation,
|
|
showPluginUninstallConfirmation,
|
|
} from '../utils/alerts'
|
|
import { messageOf, showErrorToast } from '../utils/repos'
|
|
import { InstalledPluginSwitch, PluginInfo } from './PluginCard'
|
|
import { usePluginEnabled, usePluginStatus } from './PluginStateProvider'
|
|
import PluginTooltipsProvider, {
|
|
PluginTooltip,
|
|
usePluginTooltip,
|
|
} from './TooltipProvider'
|
|
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 PlayIcon = getAssetIdByName('PlayIcon', 'png')!
|
|
const SettingsIcon = getAssetIdByName('SettingsIcon', 'png')!
|
|
const StopIcon = getAssetIdByName('StopIcon', 'png')!
|
|
const TrashIcon = getAssetIdByName('TrashIcon', 'png')!
|
|
|
|
export default function PluginOptionsActionSheet({
|
|
plugin,
|
|
sheetKey,
|
|
}: PluginOptionsActionSheetProps) {
|
|
return (
|
|
<ActionSheet>
|
|
<ClickOutsideProvider>
|
|
<PluginTooltipsProvider>
|
|
<PluginOptions plugin={plugin} sheetKey={sheetKey} />
|
|
</PluginTooltipsProvider>
|
|
</ClickOutsideProvider>
|
|
</ActionSheet>
|
|
)
|
|
}
|
|
|
|
function PluginOptions({ plugin, sheetKey }: PluginOptionsActionSheetProps) {
|
|
const enabled = usePluginEnabled(plugin)
|
|
const meta = getInternalPluginMeta(plugin)
|
|
const essential = isPluginEssential(meta)
|
|
const pendingUpdate = isPluginPendingUpdate(plugin)
|
|
const { name, author, description, icon, version } = plugin.manifest
|
|
|
|
const [switchRef, showPendingUpdateTooltip] = usePluginTooltip(
|
|
PluginTooltip.PendingUpdate,
|
|
)
|
|
|
|
return (
|
|
<Stack spacing={24} style={{ paddingTop: 8 }}>
|
|
<PluginInfo
|
|
name={name}
|
|
author={author}
|
|
version={formatVersion(version)}
|
|
description={description}
|
|
icon={icon}
|
|
actions={
|
|
!essential && (
|
|
<Pressable
|
|
onPress={() => {
|
|
if (pendingUpdate) showPendingUpdateTooltip()
|
|
}}
|
|
ref={switchRef}
|
|
>
|
|
<InstalledPluginSwitch
|
|
enabled={enabled}
|
|
plugin={plugin}
|
|
savedEnabled={isPluginEnabledInSavedStates(
|
|
plugin,
|
|
)}
|
|
toggleDisabled={pendingUpdate}
|
|
/>
|
|
</Pressable>
|
|
)
|
|
}
|
|
/>
|
|
<PluginActions
|
|
plugin={plugin}
|
|
closeSheet={() => {
|
|
ActionSheetActionCreators.hideActionSheet(sheetKey)
|
|
}}
|
|
/>
|
|
<StatusSection plugin={plugin} />
|
|
<AdvancedSection plugin={plugin} />
|
|
</Stack>
|
|
)
|
|
}
|
|
|
|
function StatusSection({ plugin }: { plugin: AnyPlugin }) {
|
|
const status = usePluginStatus(plugin)
|
|
const meta = getInternalPluginMeta(plugin)
|
|
const errors = [...plugin.errors, ...meta.nativeErrors]
|
|
|
|
return (
|
|
<TableRowGroup title="Status">
|
|
<TableRow
|
|
icon={<TableRowAssetIcon name="CircleInformationIcon" />}
|
|
label="Status (TODO)"
|
|
subLabel={bitFieldToString(PluginStatus, status)}
|
|
/>
|
|
{errors.length > 0 && (
|
|
<TableRow
|
|
variant="danger"
|
|
label="Errors"
|
|
icon={
|
|
<TableRowAssetIcon
|
|
variant="danger"
|
|
name="CircleErrorIcon"
|
|
/>
|
|
}
|
|
subLabel={`${errors.length} errors. Tap to copy.`}
|
|
onPress={() => {
|
|
Clipboard.setString(
|
|
errors.map(formatPluginError).join('\n\n'),
|
|
)
|
|
showCopiedToClipboardToast()
|
|
}}
|
|
/>
|
|
)}
|
|
</TableRowGroup>
|
|
)
|
|
}
|
|
|
|
function AdvancedSection({ plugin }: { plugin: AnyPlugin }) {
|
|
const meta = getInternalPluginMeta(plugin)
|
|
const dependents = getPluginDependents(plugin, true)
|
|
const dependencies = getPluginDependencies(plugin, false)
|
|
const repositoryText = usePluginRepositoryText(plugin)
|
|
const { id, name } = plugin.manifest
|
|
|
|
return (
|
|
<TableRowGroup title="Advanced">
|
|
<IdRow id={id} />
|
|
<RepositoryRow
|
|
text={repositoryText}
|
|
copyable={!!meta.source?.repo}
|
|
/>
|
|
<TableRow
|
|
icon={<TableRowAssetIcon name="FlagIcon" />}
|
|
label="Flags"
|
|
subLabel={bitFieldToString(PluginFlags, meta.flags)}
|
|
/>
|
|
{meta.iflags > 0 && (
|
|
<TableRow
|
|
icon={<TableRowAssetIcon name="FlagIcon" />}
|
|
label="Internal Flags"
|
|
subLabel={bitFieldToString(
|
|
InternalPluginFlags,
|
|
meta.iflags,
|
|
)}
|
|
/>
|
|
)}
|
|
{dependencies.length > 0 && (
|
|
<TableRow
|
|
icon={<TableRowAssetIcon name="ListBulletsIcon" />}
|
|
label="Dependencies (TODO)"
|
|
subLabel={`${name} depends on ${dependencies.length} other plugins`}
|
|
/>
|
|
)}
|
|
{dependents.length > 0 && (
|
|
<TableRow
|
|
icon={<TableRowAssetIcon name="ListBulletsIcon" />}
|
|
label="Dependents (TODO)"
|
|
subLabel={`${dependents.length} other plugins depend on ${name}`}
|
|
/>
|
|
)}
|
|
</TableRowGroup>
|
|
)
|
|
}
|
|
|
|
function bitFieldToString(map: Record<string, number>, bitField: number) {
|
|
return (
|
|
Object.entries(map)
|
|
.filter(([, value]) => bitField & value)
|
|
.map(([key]) => key)
|
|
.join(', ') || '-'
|
|
)
|
|
}
|
|
|
|
export function IdRow({ id }: { id: string }) {
|
|
return (
|
|
<TableRow
|
|
icon={<TableRowAssetIcon name="IdIcon" />}
|
|
label="ID"
|
|
subLabel={id}
|
|
onPress={() => {
|
|
Clipboard.setString(id)
|
|
showCopiedToClipboardToast()
|
|
}}
|
|
/>
|
|
)
|
|
}
|
|
|
|
export function RepositoryRow({
|
|
text,
|
|
copyable,
|
|
}: {
|
|
text: string
|
|
copyable?: boolean
|
|
}) {
|
|
return (
|
|
<TableRow
|
|
icon={<TableRowAssetIcon name="GlobeEarthIcon" />}
|
|
label="Repository"
|
|
subLabel={text}
|
|
onPress={
|
|
copyable
|
|
? () => {
|
|
Clipboard.setString(text)
|
|
showCopiedToClipboardToast()
|
|
}
|
|
: undefined
|
|
}
|
|
/>
|
|
)
|
|
}
|
|
|
|
function usePluginRepositoryText(plugin: AnyPlugin) {
|
|
const meta = getInternalPluginMeta(plugin)
|
|
const internal = isPluginInternal(meta)
|
|
const repoUrl = meta.source?.repo ?? null
|
|
const hasUrl = !internal && repoUrl
|
|
|
|
const [repoName, setRepoName] = useState<string | null>(null)
|
|
|
|
useEffect(() => {
|
|
if (!hasUrl) return
|
|
listRepos().then(
|
|
repos => {
|
|
const repo = repos.find(r => r.url === repoUrl)
|
|
if (repo?.name) setRepoName(repo.name)
|
|
},
|
|
() => {},
|
|
)
|
|
}, [repoUrl, hasUrl])
|
|
|
|
return hasUrl
|
|
? repoName
|
|
? `${repoName} (${repoUrl})`
|
|
: repoUrl
|
|
: internal
|
|
? 'Built-in'
|
|
: 'Sideloaded'
|
|
}
|
|
|
|
function PluginActions({
|
|
plugin,
|
|
closeSheet,
|
|
}: {
|
|
plugin: AnyPlugin
|
|
closeSheet: () => void
|
|
}) {
|
|
const [settingsRef, showEnableTooltip] = usePluginTooltip(
|
|
PluginTooltip.Enable,
|
|
)
|
|
const meta = getInternalPluginMeta(plugin)
|
|
const startable = isPluginStartable(plugin)
|
|
const enabled = usePluginEnabled(plugin)
|
|
// Any lifecycle progress counts as running, stop waits for in-flight lifecycles
|
|
const running = Boolean(usePluginStatus(plugin))
|
|
|
|
return (
|
|
<Stack
|
|
direction="horizontal"
|
|
justify="space-around"
|
|
style={{ paddingHorizontal: 8, paddingVertical: 16 }}
|
|
>
|
|
{enabled && !isPluginEssential(meta) && (
|
|
<IconButton
|
|
variant="secondary"
|
|
size="lg"
|
|
icon={running ? StopIcon : PlayIcon}
|
|
label={running ? 'Stop' : 'Start'}
|
|
// Nothing can start in a defaults-only boot, stopping a default plugin is still fine
|
|
disabled={!running && (!startable || isDefaultsOnlyBoot)}
|
|
onPress={async () => {
|
|
try {
|
|
if (running) await stopPlugin(plugin)
|
|
else await runPluginLate(plugin)
|
|
} catch (e) {
|
|
showErrorToast(messageOf(e))
|
|
}
|
|
}}
|
|
/>
|
|
)}
|
|
<IconButton
|
|
variant="secondary"
|
|
size="lg"
|
|
icon={FileWarningIcon}
|
|
label="Clear Data"
|
|
onPress={() => {
|
|
showPluginClearDataConfirmation(plugin, closeSheet)
|
|
}}
|
|
/>
|
|
{!isPluginInternal(meta) && (
|
|
<IconButton
|
|
variant="destructive"
|
|
size="lg"
|
|
icon={TrashIcon}
|
|
label="Uninstall"
|
|
onPress={() => {
|
|
showPluginUninstallConfirmation(plugin, closeSheet)
|
|
}}
|
|
/>
|
|
)}
|
|
{plugin.SettingsComponent && (
|
|
<Pressable
|
|
onPress={() => {
|
|
if (!startable) showEnableTooltip()
|
|
}}
|
|
>
|
|
<IconButton
|
|
ref={settingsRef}
|
|
variant="secondary"
|
|
size="lg"
|
|
icon={SettingsIcon}
|
|
label="Settings"
|
|
disabled={!startable}
|
|
onPress={() => {
|
|
openPluginSettings(plugin)
|
|
closeSheet()
|
|
}}
|
|
/>
|
|
</Pressable>
|
|
)}
|
|
</Stack>
|
|
)
|
|
}
|
|
|
|
const CopyIcon = lookupGeneratedIconComponent('CopyIcon')!
|
|
|
|
export function showCopiedToClipboardToast() {
|
|
ToastActionCreators.open({
|
|
key: 'REVENGE_PLUGIN_SETTINGS_COPIED',
|
|
content: 'Copied to clipboard',
|
|
IconComponent: CopyIcon,
|
|
})
|
|
}
|