revenge-bundle-next/src/plugins/start/settings.plugins/screens/RevengePluginsBrowseSettingScreen.tsx
PalmDevs 9b412a9404
feat!: new repo system + versioning system support + bug fixes
BREAKING CHANGES:
- New versioning system: Allowing any amount of number segments, with prerelease support.
  - Any number of segments is supported: 1, 1.0, 1.0.0, 1.0.0.0, ...
  - Prerelease labels are compared "digit-run", similar to Debian.
  - Plugins must specify a version.
  - User-facing plugins should use CalVer. API plugins should use SemVer.
    Revenge APIs use SemVer while the new `discord` dependency parses the Discord version string directly.
- Dependency is now a Map, with support for version ranges and optional. Suggested URL is removed. (See new repo system)
- Plugins will now have to declare a dependency on `revenge.api` (Revenge API version) and `discord` (Discord version).
- Manifest now requires a format version (currently `1`)

New APIs:
- lib/plugins/utils: Utilities to format versioning

Features:
- New repo system: Repository design is inspired by Linux package repositories.
  - Repos host manifest, versions, channels, download URL/size, and hash for multiple plugins.
  - New dependency system: Plugins are versioned, and shouldn't break when updates come.
    Plugins can now declare dependency on supported Discord/Revenge API versions, so they won't break with auto-updates.
    Dependencies can be optional, but dependency on `revenge.api` and `discord` must be required.
    Dependents are soft-disabled when dependencies don't satisfy. Once satisfied, the plugin can start again.
  - All updates for running plugins REQUIRE a reload to prevent stale linking issues. Both native and JS-side.
  - Update checking is handled entirely by JS. But the native-side still handles installation, indexing, and verification.
  - Local indexes are updated against remote indexes. Dependency checking always uses local indexes.
  - Users can add custom repositories and custom their priority order. URL is the identity.
- Plugins can now have custom data URI icons instead of being restricted to React Native app assets.
- External plugin installs require UI confirmation before installing.
- Pending Update and Pending Reload filters in Plugins page.
- Ability to browse external plugins in repositories. Official repository will be added soon.

Fixes/Refactor:
- Refactored Plugin-related settings to a separate "Plugin Settings" plugin.
- Masonry layout fixes in landscape.
- Updated plugins now show "reload to apply" toast instead.
- Removed dead code.
2026-07-25 23:43:44 +07:00

229 lines
8.4 KiB
TypeScript

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 { getInternalPluginMeta, pEmitter, pList } from '@revenge-mod/plugins/_'
import {
listRepoPlugins,
listRepos,
refreshAllRepos,
} from '@revenge-mod/plugins/_/repositories'
import { debounce } from '@revenge-mod/utils/callback'
import { useCallback, useEffect, useMemo, useState } from 'react'
import { View } from 'react-native'
import { ClickOutsideProvider } from 'react-native-click-outside'
import { BrowsePluginMasonryFlashList } from '../components/PluginList'
import PluginStatesProvider from '../components/PluginStateProvider'
import {
EnablePluginTooltipProvider,
EssentialPluginTooltipProvider,
} from '../components/TooltipProvider'
import { runInstallFlow } from '../utils/repos'
import type { RepoPluginListing } from '@revenge-mod/plugins/_/repositories'
import type { BrowseSortKey } from '../components/BrowseFilterAndSortActionSheet'
import type { BrowseEntry } from '../components/PluginList'
const { Stack, IconButton, LayerScope } = Design
const FiltersHorizontalIcon = getAssetIdByName('FiltersHorizontalIcon', 'png')!
const SearchDebounceTime = 100
export default function RevengePluginsBrowseSettingScreen() {
return (
<LayerScope>
<ClickOutsideProvider>
<PluginStatesProvider>
<Page spacing={16}>
<EssentialPluginTooltipProvider>
<EnablePluginTooltipProvider>
<Screen />
</EnablePluginTooltipProvider>
</EssentialPluginTooltipProvider>
</Page>
</PluginStatesProvider>
</ClickOutsideProvider>
</LayerScope>
)
}
// TODO: Let the user pick a release channel
/** The version a listing displays: its `latest` channel, else the first channel. */
function displayVersionOf(listing: RepoPluginListing) {
return listing.channels.latest ?? Object.values(listing.channels)[0] ?? ''
}
function compareNames(a: BrowseEntry, b: BrowseEntry) {
return a.listing.name.localeCompare(b.listing.name, undefined, {
sensitivity: 'base',
})
}
const Sorts: Record<BrowseSortKey, (a: BrowseEntry, b: BrowseEntry) => number> =
{
name: compareNames,
size: (a, b) => a.size - b.size || compareNames(a, b),
}
function Screen() {
const [entries, setEntries] = useState<BrowseEntry[]>([])
const [search, setSearch] = useState('')
const debouncedSetSearch = useCallback(
debounce(setSearch, SearchDebounceTime),
[],
)
// Unchecked repository URLs, so repos that appear later default to checked
const [excluded, setExcluded] = useState<string[]>([])
const [sort, setSort] = useState<BrowseSortKey>('name')
const [reverse, setReverse] = useState(false)
const load = useCallback(async () => {
const repos = await listRepos()
const all: BrowseEntry[] = []
await Promise.all(
repos
.filter(repo => !repo.internal && repo.enabled)
.map(repo =>
listRepoPlugins(repo.url).then(
listings => {
const repositoryText = repo.name
? `${repo.name} (${repo.url})`
: repo.url
for (const listing of listings) {
const plugin = pList.get(listing.id)
const displayVersion = displayVersionOf(listing)
all.push({
key: `${repo.url}#${listing.id}`,
listing,
repoUrl: repo.url,
repoName: repo.name ?? null,
repositoryText,
version: displayVersion,
size:
listing.versions[displayVersion]
?.size ?? 0,
installed: plugin
? ([
plugin,
getInternalPluginMeta(plugin),
] as const)
: undefined,
})
}
},
// No cached index yet is normal, ignore
() => {},
),
),
)
setEntries(all)
}, [])
useEffect(() => {
// Show cached indexes right away, then refresh everything
load()
refreshAllRepos().then(load, () => {})
}, [load])
// Fresh installs register live, re-mark entries as installed when they do
useEffect(() => {
const handleUpdate = () => load()
pEmitter.on('register', handleUpdate)
pEmitter.on('unregister', handleUpdate)
return () => {
pEmitter.off('register', handleUpdate)
pEmitter.off('unregister', handleUpdate)
}
}, [load])
const install = useCallback(
async (entry: BrowseEntry) => {
await runInstallFlow(entry.listing.id)
load()
},
[load],
)
// Repositories that currently have entries, for the filter sheet
const repos = useMemo(() => {
const seen = new Map<string, string | null>()
for (const entry of entries)
if (!seen.has(entry.repoUrl))
seen.set(entry.repoUrl, entry.repoName)
return [...seen].map(([url, name]) => ({ url, name }))
}, [entries])
const visible = useMemo(() => {
const query = search.toLowerCase()
const result = entries.filter(entry => {
if (excluded.includes(entry.repoUrl)) return false
if (!query) return true
const { name, description, author, id } = entry.listing
return (
name.toLowerCase().includes(query) ||
description.toLowerCase().includes(query) ||
author.toLowerCase().includes(query) ||
id.toLowerCase().includes(query)
)
})
result.sort(Sorts[sort])
if (reverse) result.reverse()
return result
}, [entries, excluded, search, sort, reverse])
return (
<>
<Stack direction="horizontal">
<View style={styles.grow}>
<SearchInput onChange={debouncedSetSearch} size="md" />
</View>
<IconButton
icon={FiltersHorizontalIcon}
variant="tertiary"
onPress={() =>
ActionSheetActionCreators.openLazy(
import(
'../components/BrowseFilterAndSortActionSheet'
),
'browse-filter-and-sort',
{
repos,
checked: repos
.map(repo => repo.url)
.filter(url => !excluded.includes(url)),
setChecked: (urls: string[]) =>
setExcluded(
repos
.map(repo => repo.url)
.filter(url => !urls.includes(url)),
),
sort,
setSort,
reverse,
setReverse,
},
)
}
/>
</Stack>
<BrowsePluginMasonryFlashList
entries={visible}
onInstall={install}
/>
</>
)
}