revenge-bundle-next/src/plugins/start/settings.plugins/screens/RevengePluginsAdvancedSettingScreen.tsx

378 lines
14 KiB
TypeScript

import { getAssetIdByName } from '@revenge-mod/assets'
import { FormSwitch } from '@revenge-mod/components'
import { styles } from '@revenge-mod/components/_'
import Page from '@revenge-mod/components/Page'
import TableRowAssetIcon from '@revenge-mod/components/TableRowAssetIcon'
import { Design } from '@revenge-mod/discord/design'
import { Clipboard } from '@revenge-mod/externals/react-native-clipboard'
import { callNativeMethod } from '@revenge-mod/modules/native'
import { resyncPluginSources } from '@revenge-mod/plugins/_'
import {
listAllUpdates,
listRepos,
refreshAllRepos,
refreshRepo,
repoEvents,
setRepos,
updateAllPlugins,
} from '@revenge-mod/plugins/_/repositories'
import { lookupGeneratedIconComponent } from '@revenge-mod/utils/discord'
import { useCallback, useEffect, useState } from 'react'
import { ScrollView, View } from 'react-native'
import { api } from '..'
import { toConfig } from '../repos'
import { formatBytes, messageOf, showErrorToast } from '../utils/repos'
import { showRemoveRepoConfirmation } from '../utils/alerts'
import type {
DownloadProgressEvent,
Repo,
RepoConfigEntry,
RepoStateEvent,
RepoUpdate,
} from '@revenge-mod/plugins/_/repositories'
const {
Button,
ContextMenu,
IconButton,
Stack,
TableRow,
TableRowGroup,
TableSwitchRow,
TextInput,
} = Design
const MoreIcon = getAssetIdByName('MoreVerticalIcon')!
const UpIconComponent = lookupGeneratedIconComponent('ArrowSmallUpIcon')!
const DownIconComponent = lookupGeneratedIconComponent('ArrowSmallDownIcon')!
const TrashIconComponent = lookupGeneratedIconComponent('TrashIcon')!
function repoSubLabel(repo: Repo, state?: RepoStateEvent['state']) {
const start = repo.description || repo.url
if (state === 'refreshing') return `${start} (refreshing...)`
if (state === 'error') return `${start} (refresh failed)`
return start
}
interface UserRepoRowProps {
repo: Repo
state?: RepoStateEvent['state']
onMove: (repo: Repo, delta: number) => void
onRemove: (repo: Repo) => void
onToggle: (repo: Repo, enabled: boolean) => void
}
function UserRepoRow({
repo,
state,
onMove,
onRemove,
onToggle,
}: UserRepoRowProps) {
const menuItems = [
[
{
label: 'Move up',
IconComponent: UpIconComponent,
action: () => onMove(repo, -1),
},
{
label: 'Move down',
IconComponent: DownIconComponent,
action: () => onMove(repo, 1),
},
],
[
{
label: 'Refresh',
IconComponent: lookupGeneratedIconComponent('RefreshIcon')!,
action: () =>
refreshRepo(repo.url).catch(e => {
showErrorToast(messageOf(e))
}),
},
{
label: 'Copy URL',
IconComponent: lookupGeneratedIconComponent('CopyIcon')!,
action: () => {
Clipboard.setString(repo.url)
},
},
{
label: 'Delete',
IconComponent: TrashIconComponent,
variant: 'destructive' as const,
action: () => showRemoveRepoConfirmation(repo, () => onRemove(repo)),
},
],
]
return (
<TableRow
label={repo.name ?? repo.url}
subLabel={repoSubLabel(repo, state)}
trailing={
<Stack direction="horizontal" spacing={8}>
<ContextMenu
items={menuItems}
title={repo.name ?? repo.url}
>
{props => (
<IconButton
{...props}
icon={MoreIcon}
size="sm"
variant="secondary"
/>
)}
</ContextMenu>
<FormSwitch
value={repo.enabled}
onValueChange={enabled => onToggle(repo, enabled)}
/>
</Stack>
}
/>
)
}
export default function RevengePluginsAdvancedSettingScreen() {
// Internal repositories are native-managed and not part of the config.
const [repos, setReposState] = useState<Repo[]>([])
const [url, setUrl] = useState('')
const [busy, setBusy] = useState(false)
const [updates, setUpdates] = useState<RepoUpdate[] | null>(null)
const [repoStates, setRepoStates] = useState<
Record<string, RepoStateEvent['state']>
>({})
const [progress, setProgress] = useState<DownloadProgressEvent | null>(null)
useEffect(() => {
const onRepoState = (event: RepoStateEvent) => {
setRepoStates(states => ({ ...states, [event.url]: event.state }))
}
const onProgress = (event: DownloadProgressEvent) => {
setProgress(event.received >= event.total ? null : event)
}
repoEvents.on('repoState', onRepoState)
repoEvents.on('downloadProgress', onProgress)
return () => {
repoEvents.off('repoState', onRepoState)
repoEvents.off('downloadProgress', onProgress)
}
}, [])
const settings = api.jsonStorage.use()
const refresh = useCallback(() => {
listRepos().then(setReposState, e => showErrorToast(messageOf(e)))
}, [])
useEffect(refresh, [refresh])
const commit = useCallback(
async (config: RepoConfigEntry[]) => {
try {
await setRepos(config)
} catch (e) {
showErrorToast(messageOf(e))
}
refresh()
},
[refresh],
)
const userRepos = repos.filter(repo => !repo.internal)
const move = useCallback(
(repo: Repo, delta: number) => {
const config = toConfig(userRepos)
const from = config.findIndex(entry => entry.url === repo.url)
const to = from + delta
if (from < 0 || to < 0 || to >= config.length) return
;[config[from], config[to]] = [config[to], config[from]]
commit(config)
},
[commit, userRepos],
)
const addRepo = useCallback(async () => {
const added = url.trim()
await commit([...toConfig(userRepos), { url: added, enabled: true }])
setUrl('')
try {
// Refresh the new repository right away so its plugins show up
await refreshRepo(added)
} catch (e) {
showErrorToast(messageOf(e))
}
refresh()
}, [commit, refresh, url, userRepos])
const removeRepo = useCallback(
async (repo: Repo) => {
await commit(toConfig(userRepos.filter(r => r.url !== repo.url)))
// Plugins installed from the removed repository turn Sideloaded
resyncPluginSources().catch(() => {})
},
[commit, userRepos],
)
const toggleRepo = useCallback(
(repo: Repo, enabled: boolean) => {
commit(
toConfig(userRepos).map(entry =>
entry.url === repo.url ? { ...entry, enabled } : entry,
),
)
},
[commit, userRepos],
)
const checkForUpdates = useCallback(async () => {
setBusy(true)
try {
const { errors } = await refreshAllRepos()
if (errors.length)
showErrorToast(
errors.map(e => `${e.url}: ${e.error}`).join('\n'),
)
else await api.jsonStorage.set({ lastUpdateCheck: Date.now() })
const result = await listAllUpdates()
setUpdates(result.updates)
} finally {
setBusy(false)
refresh()
}
}, [refresh])
const updateAll = useCallback(async () => {
setBusy(true)
try {
const { errors } = await updateAllPlugins()
if (errors.length)
showErrorToast(
errors.map(e => `${e.id}: ${e.error}`).join('\n'),
)
setUpdates(null)
} finally {
setBusy(false)
refresh()
}
}, [refresh])
const lastCheckedSubLabel =
updates !== null && !updates.length
? 'All plugins up to date!'
: settings?.lastUpdateCheck !== undefined &&
`Last checked: ${new Date(settings.lastUpdateCheck).toLocaleString()}`
return (
<Page spacing={0}>
<ScrollView keyboardShouldPersistTaps="handled" style={styles.flex}>
<Stack spacing={16} style={{ paddingBottom: 32 }}>
<Stack direction="horizontal" spacing={8}>
<View style={styles.grow}>
<TextInput
label="Repository URL"
onChange={setUrl}
placeholder="https://example.com/plugins"
size="md"
value={url}
/>
</View>
</Stack>
<Button
disabled={!url.trim()}
onPress={addRepo}
text="Add repository"
variant="primary"
/>
<TableRowGroup title="Repositories">
{repos.map(repo =>
repo.internal ? (
<TableRow
icon={<TableRowAssetIcon name="LockIcon" />}
key={repo.url}
label={repo.name ?? repo.url}
subLabel={repo.description ?? repo.url}
/>
) : (
<UserRepoRow
key={repo.url}
repo={repo}
state={repoStates[repo.url]}
onMove={move}
onRemove={removeRepo}
onToggle={toggleRepo}
/>
),
)}
</TableRowGroup>
<TableRowGroup title="Updates">
<TableSwitchRow
label="Update plugins automatically"
subLabel="Check repositories and apply plugin updates after startup."
onValueChange={autoUpdate => {
api.jsonStorage.set({ autoUpdate })
}}
value={settings?.autoUpdate ?? true}
/>
<TableRow
icon={<TableRowAssetIcon name="RefreshIcon" />}
label="Check for updates"
subLabel={lastCheckedSubLabel}
disabled={busy || !userRepos.length}
onPress={checkForUpdates}
/>
{progress ? (
<TableRow
icon={<TableRowAssetIcon name="<EMPTY>" />}
label={`Downloading ${progress.id} ${progress.version}`}
subLabel={`${formatBytes(progress.received)} / ${formatBytes(progress.total)} (${progress.index} of ${progress.count})`}
/>
) : null}
{updates?.length ? (
<>
{updates.map(update => (
<TableRow
icon={
<TableRowAssetIcon name="DownloadIcon" />
}
key={update.id}
label={update.id}
subLabel={`${update.installed} → ${update.available} (${update.channel})`}
/>
))}
<TableRow
icon={
<TableRowAssetIcon name="DownloadIcon" />
}
label="Update all"
onPress={updateAll}
/>
</>
) : null}
</TableRowGroup>
<TableRowGroup title="Advanced">
<TableRow
icon={<TableRowAssetIcon name="DownloadIcon" />}
label="Install from file"
onPress={() =>
callNativeMethod(
'revenge.plugins.installFile',
[],
)
}
/>
</TableRowGroup>
</Stack>
</ScrollView>
</Page>
)
}