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 ( {props => ( )} onToggle(repo, enabled)} /> } /> ) } export default function RevengePluginsAdvancedSettingScreen() { // Internal repositories are native-managed and not part of the config. const [repos, setReposState] = useState([]) const [url, setUrl] = useState('') const [busy, setBusy] = useState(false) const [updates, setUpdates] = useState(null) const [repoStates, setRepoStates] = useState< Record >({}) const [progress, setProgress] = useState(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 (