Compare commits

..

1 commit
main ... pr/ui

Author SHA1 Message Date
Rosie Val
4e444cf55d Address review comments on channel switching 2026-08-16 12:45:24 -04:00
16 changed files with 509 additions and 599 deletions

View file

@ -1,5 +1,5 @@
import { lookupModule, lookupModules } from '@revenge-mod/modules/finders' import { lookupModule, lookupModules } from '@revenge-mod/modules/finders'
import { withDependencies, withProps } from '@revenge-mod/modules/finders/filters' import { withDependencies } from '@revenge-mod/modules/finders/filters'
import { import {
ReactJSXRuntimeModuleId, ReactJSXRuntimeModuleId,
ReactModuleId, ReactModuleId,
@ -26,21 +26,25 @@ export let Design: Design = proxify(
() => { () => {
// ID: 13171 // ID: 13171
// [3909, 4608, 5182, 13172, 2, ...] (141 dependencies) // [3909, 4608, 5182, 13172, 2, ...] (141 dependencies)
const [module] = lookupModule( const [, id] = lookupModule(
withProps('TableRow', 'Button').and(
withDependencies<Design>( withDependencies<Design>(
atLeast(DesignMinimumDependencies, [ atLeast(DesignMinimumDependencies, [
[ImportTrackerModuleId], [ImportTrackerModuleId],
[ImportTrackerModuleId], [ImportTrackerModuleId],
[ReactNativeModuleId, ImportTrackerModuleId], [ReactNativeModuleId, ImportTrackerModuleId],
[ImportTrackerModuleId], [ImportTrackerModuleId],
ImportTrackerModuleId, ImportTrackerModuleId,
]), ]),
)
).keyAs('revenge.discord.design.Design'), ).keyAs('revenge.discord.design.Design'),
{
initialize: false,
},
) )
if (module) return (Design = module) if (id === undefined) return
const module = __r(id)!
if (module.TableRow && module.Button) return (Design = module)
}, },
{ {
hint: {}, hint: {},

View file

@ -27,7 +27,7 @@ export function onAnyFluxEventDispatched(patch: FluxEventDispatchPatch) {
* @param patch The patch function to apply when the event is dispatched. * @param patch The patch function to apply when the event is dispatched.
* @returns A function that can be used to remove the patch. * @returns A function that can be used to remove the patch.
* *
* @example Blocking the dispatch * @example Blocking the disptach
* ```ts * ```ts
* // Returning falsy values will prevent the event from being dispatched. * // Returning falsy values will prevent the event from being dispatched.
* onFluxEventDispatched('TYPING_START', () => {}) * onFluxEventDispatched('TYPING_START', () => {})

View file

@ -152,14 +152,10 @@ export namespace DiscordModules {
verboseDangerously(...args: unknown[]): void verboseDangerously(...args: unknown[]): void
verbose(...args: unknown[]): void verbose(...args: unknown[]): void
} }
export namespace Actions { export namespace Actions {
export interface AlertActionCreators { export interface AlertActionCreators {
openAlert( openAlert(key: string, alert: ReactElement): void
key: string,
alert: ReactElement,
onDismiss?: () => unknown,
options?: { dismissable?: boolean },
): void
dismissAlert(key: string): void dismissAlert(key: string): void
dismissAlerts(): void dismissAlerts(): void
// TODO // TODO
@ -503,6 +499,7 @@ export namespace DiscordModules {
extends TableRowGroupProps { extends TableRowGroupProps {
children: ReactNode children: ReactNode
onChange: (value: T) => void onChange: (value: T) => void
value?: T
defaultValue?: T defaultValue?: T
} }

View file

@ -15,7 +15,7 @@ import {
} from './subscriptions/_internal' } from './subscriptions/_internal'
import type { Metro } from '../types' import type { Metro } from '../types'
export const mErrorChain: [Metro.ModuleID, unknown][] = [] export const mErrorChain: Metro.ModuleID[] = []
export const Initialized = 1 << 0 export const Initialized = 1 << 0
const HasError = 1 << 1 const HasError = 1 << 1
@ -80,7 +80,7 @@ export const metroRequire = (moduleId => {
// if (global.ErrorUtils) global.ErrorUtils.reportFatalError(e) // if (global.ErrorUtils) global.ErrorUtils.reportFatalError(e)
// else throw e // else throw e
mErrorChain.push([moduleId, e]) mErrorChain.push(moduleId)
return (moduleObject.exports = {}) return (moduleObject.exports = {})
} }

View file

@ -1,50 +0,0 @@
import { Design } from '@revenge-mod/discord/design'
import { RootNavigationRef } from '@revenge-mod/discord/modules/main_tabs_v2'
import { getBridgeInfo } from '@revenge-mod/modules/native'
import { RouteNames } from '~plugins/start/settings.plugins/constants'
export function RecoveryModal() {
const navigation = RootNavigationRef.getRootNavigationRef()
return (
<Design.AlertModal
title="Recovery Mode"
content={
"You are now running with default plugins. Additional plugins can't be started in Recovery Mode.\n\nDisable or uninstall plugins that might be causing issues, then reload the app to exit Recovery Mode."
}
actions={
<>
<Design.AlertActionButton
variant="primary"
text="View plugins"
onPress={() => {
// React Navigation will bubble our requests to the main navigator if we're already in settings
navigation.navigate('settings', {
screen: RouteNames.RevengePlugins,
})
}}
/>
<Design.AlertActionButton
variant="secondary"
text="Got it"
/>
</>
}
/>
)
}
export function LoaderOutdatedModal() {
const info = getBridgeInfo()
if (!info) throw new Error('Failed to get native bridge info')
return (
<Design.AlertModal
title="Loader Outdated"
content={`Your loader is outdated. Update to the latest version to receive fixes and ensure compatibility with all plugins.\n\nYou're currently running ${info.name} v${info.version}`}
actions={
<Design.AlertActionButton variant="primary" text="Got it" />
}
/>
)
}

View file

@ -1,190 +0,0 @@
import defer * as Actions from '@revenge-mod/discord/actions'
import { onFluxEventDispatched } from '@revenge-mod/discord/flux'
import { waitForModules } from '@revenge-mod/modules/finders'
import { withName } from '@revenge-mod/modules/finders/filters'
import { callNativeMethod } from '@revenge-mod/modules/native'
import { instead } from '@revenge-mod/patcher'
import {
InternalPluginFlags,
isDefaultsOnlyBoot,
PluginFlags,
registerInternalPlugin,
} from '@revenge-mod/plugins/_'
import { onRunApplicationFinished } from '@revenge-mod/react/native'
import { asap } from '@revenge-mod/utils/callback'
import { getErrorStack } from '@revenge-mod/utils/error'
import { AppState } from 'react-native'
import { FullVersion } from '~constants'
import { cache as assetsCache } from '../../../../lib/assets/src/caches'
import { cache as modulesCache } from '../../../../lib/modules/src/caches'
import { mErrorChain } from '../../../../lib/modules/src/metro/runtime'
import pluginSettings from '../../start/settings'
import defer * as Alerts from './components/alerts'
import defer * as ErrorBoundaryScreen from './components/ErrorBoundaryScreen'
import type { Component, ReactNode } from 'react'
registerInternalPlugin(
{
id: 'revenge.recovery',
name: 'Recovery',
description:
'Handles errors and provides troubleshooting options for Revenge.',
author: 'Revenge',
icon: 'ShieldIcon',
dependencies: { [pluginSettings]: {} },
},
{
preInit({ cleanup }) {
cleanup(freezeDetectionService())
},
start({ cleanup }) {
// @as-require
import('./settings')
cleanup(errorBoundaryService())
asap(() => {
if (isDefaultsOnlyBoot) {
Actions.AlertActionCreators.openAlert(
'revenge-recovery',
<Alerts.RecoveryModal />,
)
}
if (assetsCache.outdated || modulesCache.outdated) {
Actions.AlertActionCreators.openAlert(
'revenge-loader-outdated',
<Alerts.LoaderOutdatedModal />,
)
}
})
},
},
PluginFlags.Enabled,
InternalPluginFlags.Internal | InternalPluginFlags.Essential,
)
const FreezeDetectionTimeout = 5000
function freezeDetectionService() {
let currentId: number | undefined
let cleared = false
let sub: { remove(): void } | undefined
let unsubFlux: (() => void) | undefined
const setTimer = () => {
onRunApplicationFinished(() => {
if (cleared) return
if (currentId !== undefined) clearTimeout(currentId)
currentId = setTimeout(() => {
currentId = undefined
if (AppState.currentState !== 'active') return
try {
let text =
'App was unable to start. This is likely caused by a plugin.\n\n' +
'You can launch Recovery Mode in the Recovery menu.'
if (mErrorChain.length > 0) {
text += `\n\nErroring modules chain: ${mErrorChain.map(([id]) => id).join(', ')}`
text += `\n\nStacks:\n\n${mErrorChain.map(([id, stack]) => `[${id}] ${getErrorStack(stack)}`).join('\n\n')}`
} else {
text += '\n\nNo erroring modules were detected.'
}
callNativeMethod('revenge.alertError', [text, FullVersion])
} catch (e) {
console.error(
'Failed to call native method "revenge.alertError":',
e,
)
}
}, FreezeDetectionTimeout)
})
}
const clear = <T,>(e?: T) => {
cleared = true
if (currentId !== undefined) {
clearTimeout(currentId)
currentId = undefined
}
sub?.remove()
unsubFlux?.()
return e
}
unsubFlux = onFluxEventDispatched('APP_STATE_UPDATE', clear)
// clear() may have run before unsubStore/unsubFlux was assigned, so run it again to unsub
if (cleared) {
clear()
return clear
}
sub = AppState.addEventListener('change', state => {
if (state === 'active') setTimer()
else if (currentId !== undefined) {
clearTimeout(currentId)
currentId = undefined
}
})
if (AppState.currentState === 'active') setTimer()
return clear
}
function errorBoundaryService() {
const unsub = waitForModules(
withName<typeof DiscordErrorBoundary>('ErrorBoundary'),
exports => {
unsub()
instead(
exports.prototype,
'render',
function (this: DiscordErrorBoundary) {
if (this.state.error)
return (
<ErrorBoundaryScreen.default
error={this.state.error}
reload={this.handleReload.bind(this)}
rerender={() => {
this.setState({
error: null,
info: null,
})
}}
/>
)
return this.props.children
},
)
},
{
cached: true,
},
)
return unsub
}
declare class DiscordErrorBoundary extends Component<
{ children: ReactNode },
{
error: (Error & { componentStack?: string }) | unknown | null
info: { componentStack?: string } | null
}
> {
// render() is always called with `this` as the instance of DiscordErrorBoundary
render(this: DiscordErrorBoundary): ReactNode
discordErrorsSet: boolean
handleReload(): void
}

View file

@ -1,13 +0,0 @@
import {
onSettingsModulesLoaded,
registerSettingsItem,
} from '@revenge-mod/discord/modules/settings'
import { Setting } from '~plugins/start/settings/constants'
import RevengeEnterRecoveryModeSetting from './definitions/RevengeEnterRecoveryModeSetting'
onSettingsModulesLoaded(() => {
registerSettingsItem(
Setting.RevengeEnterRecoveryMode,
RevengeEnterRecoveryModeSetting,
)
})

View file

@ -0,0 +1,244 @@
import { AlertActionCreators } from '@revenge-mod/discord/actions'
import { Design } from '@revenge-mod/discord/design'
import { getStore, onFluxEventDispatched } from '@revenge-mod/discord/flux'
import { RootNavigationRef } from '@revenge-mod/discord/modules/main_tabs_v2'
import {
onSettingsModulesLoaded,
registerSettingsItem,
} from '@revenge-mod/discord/modules/settings'
import { waitForModules } from '@revenge-mod/modules/finders'
import { withName } from '@revenge-mod/modules/finders/filters'
import { callNativeMethod, getBridgeInfo } from '@revenge-mod/modules/native'
import { instead } from '@revenge-mod/patcher'
import {
InternalPluginFlags,
isDefaultsOnlyBoot,
PluginFlags,
registerInternalPlugin,
} from '@revenge-mod/plugins/_'
import { asap } from '@revenge-mod/utils/callback'
import { AppState } from 'react-native'
import { FullVersion } from '~constants'
import { cache as assetsCache } from '../../../../lib/assets/src/caches'
import { cache as modulesCache } from '../../../../lib/modules/src/caches'
import { mErrorChain } from '../../../../lib/modules/src/metro/runtime'
import pluginSettings from '../settings'
import { Setting } from '../settings/constants'
import { RouteNames } from '../settings.plugins/constants'
import ErrorBoundaryScreen from './components/ErrorBoundaryScreen'
import RevengeEnterRecoveryModeSetting from './definitions/RevengeEnterRecoveryModeSetting'
import type { Component, ReactNode } from 'react'
registerInternalPlugin(
{
id: 'revenge.recovery',
name: 'Recovery',
description:
'Handles errors and provides troubleshooting options for Revenge.',
author: 'Revenge',
icon: 'ShieldIcon',
dependencies: { [pluginSettings]: {} },
},
{
start({ cleanup }) {
onSettingsModulesLoaded(() => {
registerSettingsItem(
Setting.RevengeEnterRecoveryMode,
RevengeEnterRecoveryModeSetting,
)
})
cleanup(errorBoundaryService(), freezeDetectionService())
if (isDefaultsOnlyBoot) {
asap(() => {
AlertActionCreators.openAlert(
'revenge-recovery',
<RecoveryModal />,
)
})
}
if (assetsCache.outdated || modulesCache.outdated) {
asap(() => {
AlertActionCreators.openAlert(
'revenge-loader-outdated',
<LoaderOutdatedModal />,
)
})
}
},
},
PluginFlags.Enabled,
InternalPluginFlags.Internal | InternalPluginFlags.Essential,
)
const FreezeDetectionTimeout = 5000
function freezeDetectionService() {
let currentId: number | undefined
let cleared = false
let sub: { remove(): void } | undefined
let unsubStore: (() => void) | undefined
let unsubFlux: (() => void) | undefined
const setTimer = () => {
if (cleared) return
if (currentId !== undefined) clearTimeout(currentId)
currentId = setTimeout(() => {
currentId = undefined
if (AppState.currentState !== 'active') return
try {
callNativeMethod('revenge.alertError', [
`App was unable to start. This is likely caused by a plugin.\n\nYou can launch Recovery Mode in the Recovery menu.\n\nErroring modules chain: ${mErrorChain.join(', ') || 'None'}`,
FullVersion,
])
} catch (e) {
console.error(
'Failed to call native method "revenge.alertError":',
e,
)
}
}, FreezeDetectionTimeout)
}
const clear = <T,>(e?: T) => {
cleared = true
if (currentId !== undefined) {
clearTimeout(currentId)
currentId = undefined
}
sub?.remove()
unsubStore?.()
unsubFlux?.()
return e
}
// If the store initializes, the app is probably working fine, so we can cancel the timer
unsubStore = getStore('AppStateStore', clear)
// clear() may have run before unsubStore was assigned, so run it again to unsub
if (cleared) {
clear()
return clear
}
unsubFlux = onFluxEventDispatched('APP_STATE_UPDATE', clear)
if (cleared) {
clear()
return clear
}
sub = AppState.addEventListener('change', state => {
if (state === 'active') setTimer()
else if (currentId !== undefined) {
clearTimeout(currentId)
currentId = undefined
}
})
if (AppState.currentState === 'active') setTimer()
return clear
}
function errorBoundaryService() {
const unsub = waitForModules(
withName<typeof DiscordErrorBoundary>('ErrorBoundary'),
exports => {
unsub()
instead(
exports.prototype,
'render',
function (this: DiscordErrorBoundary) {
if (this.state.error)
return (
<ErrorBoundaryScreen
error={this.state.error}
reload={this.handleReload.bind(this)}
rerender={() => {
this.setState({
error: null,
info: null,
})
}}
/>
)
return this.props.children
},
)
},
{
cached: true,
},
)
return unsub
}
declare class DiscordErrorBoundary extends Component<
{ children: ReactNode },
{
error: (Error & { componentStack?: string }) | unknown | null
info: { componentStack?: string } | null
}
> {
// render() is always called with `this` as the instance of DiscordErrorBoundary
render(this: DiscordErrorBoundary): ReactNode
discordErrorsSet: boolean
handleReload(): void
}
function RecoveryModal() {
const navigation = RootNavigationRef.getRootNavigationRef()
return (
<Design.AlertModal
title="Recovery Mode"
content={
"You are now running with default plugins. Additional plugins can't be started in Recovery Mode.\n\nDisable or uninstall plugins that might be causing issues, then reload the app to exit Recovery Mode."
}
actions={
<>
<Design.AlertActionButton
variant="primary"
text="View plugins"
onPress={() => {
// React Navigation will bubble our requests to the main navigator if we're already in settings
navigation.navigate('settings', {
screen: RouteNames.RevengePlugins,
})
}}
/>
<Design.AlertActionButton
variant="secondary"
text="Got it"
/>
</>
}
/>
)
}
function LoaderOutdatedModal() {
const info = getBridgeInfo()
if (!info) throw new Error('Failed to get native bridge info')
return (
<Design.AlertModal
title="Loader Outdated"
content={`Your loader is outdated. Update to the latest version to receive fixes and ensure compatibility with all plugins.\n\nYou're currently running ${info.name} v${info.version}`}
actions={
<Design.AlertActionButton variant="primary" text="Got it" />
}
/>
)
}

View file

@ -25,7 +25,6 @@ import {
} from '@revenge-mod/plugins/_' } from '@revenge-mod/plugins/_'
import { import {
listRepoPlugins, listRepoPlugins,
listRepos,
refreshRepo, refreshRepo,
} from '@revenge-mod/plugins/_/repositories' } from '@revenge-mod/plugins/_/repositories'
import { PluginStatus } from '@revenge-mod/plugins/constants' import { PluginStatus } from '@revenge-mod/plugins/constants'
@ -39,7 +38,11 @@ import {
showPluginClearDataConfirmation, showPluginClearDataConfirmation,
showPluginUninstallConfirmation, showPluginUninstallConfirmation,
} from '../utils/alerts' } from '../utils/alerts'
import { messageOf, runInstallFlow, showErrorToast } from '../utils/repos' import {
messageOf,
runInstallFlowWithInternalRepos,
showErrorToast,
} from '../utils/repos'
import { InstalledPluginSwitch, PluginInfo } from './PluginCard' import { InstalledPluginSwitch, PluginInfo } from './PluginCard'
import { usePluginEnabled, usePluginStatus } from './PluginStateProvider' import { usePluginEnabled, usePluginStatus } from './PluginStateProvider'
import PluginTooltipsProvider, { import PluginTooltipsProvider, {
@ -47,7 +50,6 @@ import PluginTooltipsProvider, {
usePluginTooltip, usePluginTooltip,
} from './TooltipProvider' } from './TooltipProvider'
import type { AnyPlugin } from '@revenge-mod/plugins/_' import type { AnyPlugin } from '@revenge-mod/plugins/_'
import type { RepoPluginListing } from '@revenge-mod/plugins/_/repositories'
export interface PluginOptionsActionSheetProps { export interface PluginOptionsActionSheetProps {
plugin: AnyPlugin plugin: AnyPlugin
@ -131,7 +133,8 @@ function PluginOptions({ plugin, sheetKey }: PluginOptionsActionSheetProps) {
}} }}
/> />
<StatusSection plugin={plugin} /> <StatusSection plugin={plugin} />
<AdvancedSection plugin={plugin} sheetKey={sheetKey} /> <ChannelSection plugin={plugin} />
<AdvancedSection plugin={plugin} />
</Stack> </Stack>
) )
} }
@ -173,92 +176,72 @@ function StatusSection({ plugin }: { plugin: AnyPlugin }) {
function ChannelSection({ function ChannelSection({
plugin, plugin,
sheetKey,
}: { }: {
plugin: AnyPlugin plugin: AnyPlugin
sheetKey: string
}) { }) {
const meta = getInternalPluginMeta(plugin) const meta = getInternalPluginMeta(plugin)
const source = meta.source const source = meta.source
if (isPluginInternal(meta) || !source?.repo) return null if (isPluginInternal(meta) || !source?.repo) return null
const [listing, setListing] = useState<RepoPluginListing | null>(null) const [channels, setChannels] = useState<Record<string, string>>({})
const [resetKey, setResetKey] = useState(0) const [selected, setSelected] = useState(source.channel)
useEffect(() => { useEffect(() => {
let cancelled = false listRepoPlugins(source.repo)
;(async () => { .then(listings => {
try { const listing = listings.find(l => l.id === plugin.manifest.id)
const listings = await listRepoPlugins(source.repo) if (listing) setChannels(listing.channels)
const found = listings.find(l => l.id === plugin.manifest.id) })
if (found && !cancelled) setListing(found) .catch(() =>
} catch { refreshRepo(source.repo)
try { .then(() => listRepoPlugins(source.repo))
await refreshRepo(source.repo) .then(listings => {
const listings = await listRepoPlugins(source.repo) const listing = listings.find(
const found = listings.find(l => l.id === plugin.manifest.id) l => l.id === plugin.manifest.id,
if (found && !cancelled) setListing(found) )
} catch {} if (listing) setChannels(listing.channels)
} })
})() .catch(() => {}),
return () => { )
cancelled = true
}
}, [source.repo, plugin.manifest.id]) }, [source.repo, plugin.manifest.id])
if (!listing || Object.keys(listing.channels).length === 0) return null if (Object.keys(channels).length === 0) return null
const handleChange = async (selected: string) => { const handleChange = (value: string) => {
if (selected === source.channel) return setSelected(value)
const targetVersion = listing.channels[selected]
if (!targetVersion) return
const targetVersion = channels[value]!
if (targetVersion === plugin.manifest.version) { if (targetVersion === plugin.manifest.version) {
setResetKey(k => k + 1)
ToastActionCreators.open({ ToastActionCreators.open({
key: 'REVENGE_PLUGIN_CHANNEL_UP_TO_DATE', key: 'REVENGE_PLUGIN_VERSION_ALREADY_INSTALLED',
content: 'This channel is already installed', content: 'This version is already installed',
IconComponent: () => <TableRowAssetIcon name="CircleCheckIcon" />, IconComponent: () => <TableRowAssetIcon name="CircleCheckIcon" />,
}) })
return return
} }
const repos = await listRepos() runInstallFlowWithInternalRepos(
const internalUrls = repos
.filter(r => r.internal)
.map(r => r.url)
ActionSheetActionCreators.hideActionSheet(sheetKey)
runInstallFlow(
plugin.manifest.id, plugin.manifest.id,
undefined, undefined,
selected, value,
[...internalUrls, source.repo], source.repo,
) )
} }
return ( return (
<TableRadioGroup <TableRadioGroup
key={resetKey}
title="Channel" title="Channel"
defaultValue={source.channel} value={selected}
onChange={v => handleChange(v as string)} onChange={v => handleChange(v as string)}
> >
{Object.keys(listing.channels).map(c => ( {Object.keys(channels).map(c => (
<TableRadioRow key={c} label={c} value={c} /> <TableRadioRow key={c} label={c} value={c} />
))} ))}
</TableRadioGroup> </TableRadioGroup>
) )
} }
function AdvancedSection({ function AdvancedSection({ plugin }: { plugin: AnyPlugin }) {
plugin,
sheetKey,
}: {
plugin: AnyPlugin
sheetKey: string
}) {
const meta = getInternalPluginMeta(plugin) const meta = getInternalPluginMeta(plugin)
const dependents = getPluginDependents(plugin, true) const dependents = getPluginDependents(plugin, true)
const dependencies = getPluginDependencies(plugin, false) const dependencies = getPluginDependencies(plugin, false)
@ -266,73 +249,70 @@ function AdvancedSection({
const { id, name } = plugin.manifest const { id, name } = plugin.manifest
return ( return (
<> <TableRowGroup title="Advanced">
<ChannelSection plugin={plugin} sheetKey={sheetKey} /> <IdRow id={id} />
<TableRowGroup title="Advanced"> <RepositoryRow
<IdRow id={id} /> text={repositoryText}
<RepositoryRow copyable={!!meta.source?.repo}
text={repositoryText} />
copyable={!!meta.source?.repo} <TableRow
/> icon={<TableRowAssetIcon name="FlagIcon" />}
label="Flags"
subLabel={bitFieldToString(PluginFlags, meta.flags)}
/>
{meta.iflags > 0 && (
<TableRow <TableRow
icon={<TableRowAssetIcon name="FlagIcon" />} icon={<TableRowAssetIcon name="FlagIcon" />}
label="Flags" label="Internal Flags"
subLabel={bitFieldToString(PluginFlags, meta.flags)} subLabel={bitFieldToString(
InternalPluginFlags,
meta.iflags,
)}
/> />
{meta.iflags > 0 && ( )}
<TableRow {dependencies.length > 0 && (
icon={<TableRowAssetIcon name="FlagIcon" />} <TableRow
label="Internal Flags" icon={<TableRowAssetIcon name="ListBulletsIcon" />}
subLabel={bitFieldToString( label="Dependencies"
InternalPluginFlags, subLabel={`${name} depends on ${dependencies.length} other plugins`}
meta.iflags, onPress={() => {
)} ActionSheetActionCreators.openLazy(
/> import('./PluginRelationsListActionSheet'),
)} `plugin-deps-${id}`,
{dependencies.length > 0 && ( {
<TableRow title: `Dependencies of ${name}`,
icon={<TableRowAssetIcon name="ListBulletsIcon" />} unsatisfiedTitle: `Unsatisfied dependencies of ${name}`,
label="Dependencies" unsatisfiedPlugins:
subLabel={`${name} depends on ${dependencies.length} other plugins`} meta.unsatisfiedOptionalDependencies.map(
onPress={() => { id => pList.get(id) ?? id,
ActionSheetActionCreators.openLazy( ),
import('./PluginRelationsListActionSheet'), plugins: dependencies,
`plugin-deps-${id}`, dependencyMap: plugin.manifest.dependencies!,
{ },
title: `Dependencies of ${name}`, 'stack',
unsatisfiedTitle: `Unsatisfied dependencies of ${name}`, )
unsatisfiedPlugins: }}
meta.unsatisfiedOptionalDependencies.map( />
id => pList.get(id) ?? id, )}
), {dependents.length > 0 && (
plugins: dependencies, <TableRow
dependencyMap: plugin.manifest.dependencies!, icon={<TableRowAssetIcon name="ListBulletsIcon" />}
}, label="Dependents"
'stack', subLabel={`${dependents.length} other plugins depend on ${name}`}
) onPress={() => {
}} ActionSheetActionCreators.openLazy(
/> import('./PluginRelationsListActionSheet'),
)} `plugin-dependents-${id}`,
{dependents.length > 0 && ( {
<TableRow title: `Dependents of ${name}`,
icon={<TableRowAssetIcon name="ListBulletsIcon" />} plugins: dependents,
label="Dependents" },
subLabel={`${dependents.length} other plugins depend on ${name}`} 'stack',
onPress={() => { )
ActionSheetActionCreators.openLazy( }}
import('./PluginRelationsListActionSheet'), />
`plugin-dependents-${id}`, )}
{ </TableRowGroup>
title: `Dependents of ${name}`,
plugins: dependents,
},
'stack',
)
}}
/>
)}
</TableRowGroup>
</>
) )
} }

View file

@ -170,3 +170,21 @@ export async function runInstallFlow(
return false return false
} }
} }
/**
* Same as {@link runInstallFlow}, but automatically includes every internal repository plus
* the target repository in `filteredRepos`, so repo-installed plugins can be updated in place.
*/
export async function runInstallFlowWithInternalRepos(
id: string,
version?: string,
channel?: string,
repo?: string,
): Promise<boolean> {
const repos = await listRepos()
const filteredRepos = repos
.filter(r => r.internal)
.map(r => r.url)
if (repo) filteredRepos.push(repo)
return runInstallFlow(id, version, channel, filteredRepos)
}

View file

@ -20,20 +20,13 @@ registerInternalPlugin(
icon: 'StaffBadgeIcon', icon: 'StaffBadgeIcon',
}, },
{ {
start({ cleanup, logger, plugin }) { start({ cleanup, logger }) {
const CircleInformationIcon = lookupGeneratedIconComponent( const CircleInformationIcon = lookupGeneratedIconComponent(
'CircleInformationIcon', 'CircleInformationIcon',
'CircleInformationIcon-secondary', 'CircleInformationIcon-secondary',
'CircleInformationIcon-primary', 'CircleInformationIcon-primary',
) )
const showToast = () =>
ToastActionCreators.open({
key: 'staff-settings-action',
content: 'Navigate out of Settings to apply changes',
IconComponent: CircleInformationIcon,
})
function reset() { function reset() {
getStore<{ getStore<{
initialize(): void initialize(): void
@ -50,6 +43,12 @@ registerInternalPlugin(
store.initialize() store.initialize()
unpatch() unpatch()
ToastActionCreators.open({
key: 'staff-settings-action',
content: 'Navigate out of Settings to apply changes',
IconComponent: CircleInformationIcon,
})
}) })
} }
@ -70,11 +69,9 @@ registerInternalPlugin(
).getCurrentUser(), ).getCurrentUser(),
), ),
reset, reset,
showToast,
) )
reset() reset()
if (plugin.startedLate) showToast()
}), }),
) )
}, },

View file

@ -1,4 +1,8 @@
{ {
"629368283354628116": ["revenge_team"], "629368283354628116": ["revenge_team"],
"399862294143696897": ["revenge_team"] "399862294143696897": ["revenge_team"],
"853550207039832084": ["revenge_team"],
"485376328980365312": ["revenge_team"],
"737323631117598811": ["revenge_team"],
"826489186327724095": ["revenge_team"]
} }

View file

@ -7,18 +7,23 @@ import {
PluginFlags, PluginFlags,
registerInternalPlugin, registerInternalPlugin,
} from '@revenge-mod/plugins/_' } from '@revenge-mod/plugins/_'
import { insteadJSX } from '@revenge-mod/react/jsx-runtime' import { afterJSX, beforeJSX } from '@revenge-mod/react/jsx-runtime'
import { createElement, isValidElement } from 'react' import { findInReactFiber } from '@revenge-mod/utils/react'
import { Image } from 'react-native' import { Image } from 'react-native'
import { Badges, UsersWithBadges } from './constants' import { Badges, UsersWithBadges } from './constants'
import { styles, useBadgeStyles } from './styles' import { styles, useBadgeStyles } from './styles'
import { mapElementTree, patchRender } from './utils' import { afterRendered } from './utils'
import type { FC, ReactElement, ReactNode } from 'react' import type { FC, ReactElement } from 'react'
import type { ImageSourcePropType } from 'react-native' import type {
ImageProps,
ImageSourcePropType,
ImageStyle,
PressableProps,
StyleProp,
} from 'react-native'
import type { Badge, BadgeId } from './constants' import type { Badge, BadgeId } from './constants'
import type { RenderPatch } from './utils'
type BadgeStyles = ReturnType<typeof useBadgeStyles> const DUMMY_SYMBOL = {} as unknown as string
interface ProfileBadgeProps { interface ProfileBadgeProps {
id: string id: string
@ -41,10 +46,9 @@ interface ProfileBadgeRowsProps {
showToastOnPress?: boolean showToastOnPress?: boolean
} }
const DummyBadgeId = {} as unknown as string type ViewWithProfileBadges = ReactElement<{
const DummyBadges = [ children: Array<ReactElement<ProfileBadgeProps, FC<ProfileBadgeProps>>>
{ id: DummyBadgeId }, }>
] as unknown as ProfileBadgeRowsProps['badges']
registerInternalPlugin( registerInternalPlugin(
{ {
@ -62,25 +66,28 @@ registerInternalPlugin(
), ),
({ ProfileBadgeRows }) => { ({ ProfileBadgeRows }) => {
cleanup( cleanup(
insteadJSX( beforeJSX(ProfileBadgeRows, args => {
ProfileBadgeRows, const [, props] = args
([type, props, key], jsx) => {
if (
!UsersWithBadges[props.userId] ||
typeof type !== 'function'
)
return jsx(type, props, key)
// Inject dummy badge for users with custom badges to ensure ProfileBadge components exist // Inject dummy badge for users with custom badges to ensure ProfileBadge components exist
return jsx( if (
patchRender(type, injectCustomBadges), // has custom badges
props.badges.length UsersWithBadges[props.userId] &&
? props // but no actual badges
: { ...props, badges: DummyBadges }, !props.badges.length
key, )
) // @ts-expect-error: This will never be rendered
}, props.badges.push({ id: DUMMY_SYMBOL })
),
return args
}),
)
cleanup(
afterJSX(ProfileBadgeRows, el => {
patchProfileBadgeRows(el)
return el
}),
) )
}, },
) )
@ -96,102 +103,92 @@ registerInternalPlugin(
InternalPluginFlags.Internal | InternalPluginFlags.Essential, InternalPluginFlags.Internal | InternalPluginFlags.Essential,
) )
/** Matched structurally, as minified builds don't preserve `type.name`. */ function patchProfileBadgeRows(
function isProfileBadgeElement( element: ReactElement<ProfileBadgeRowsProps, FC<ProfileBadgeRowsProps>>,
node: ReactNode, ): void {
): node is ReactElement<ProfileBadgeProps, FC<ProfileBadgeProps>> { const unpatch = afterRendered(element, el => {
if (!isValidElement(node) || typeof node.type !== 'function') return false unpatch()
const props = node.props as Partial<ProfileBadgeProps> | null const container = findInReactFiber(
return props != null && 'badgeSize' in props && 'userId' in props el as ReactElement,
} isViewContainingProfileBadgeElements,
)
const injectCustomBadges: RenderPatch<ProfileBadgeRowsProps> = ( if (!container) return el
rendered,
{ userId },
) => {
const userBadges = UsersWithBadges[userId]
if (!userBadges) return rendered
return mapElementTree(rendered, element => { const [{ type: ProfileBadge, props }] = container.props.children
const { children } = element.props const userBadges = UsersWithBadges[props.userId]
if (!Array.isArray(children)) return
const template = children.find(isProfileBadgeElement) if (userBadges) {
if (!template) return for (const badgeId of userBadges) {
const badge = Badges[badgeId]
if (!badge) continue
const ProfileBadge = template.type const badgeElement = (
const customBadges: ReactElement[] = [] <ProfileBadge
{...props}
key={badgeId}
id={badgeId}
label={badge.label}
source={badge.icon}
/>
)
for (const id of userBadges) { patchProfileBadge(badgeElement, badgeId, badge)
const badge = Badges[id] container.props.children.push(badgeElement)
if (!badge) continue }
customBadges.push( // Remove dummy badge if it exists
createElement( if (props.id === DUMMY_SYMBOL) container.props.children.shift()
patchRender(ProfileBadge, patchCustomBadge, useBadgeStyles),
{
...template.props,
key: `revenge-badge-${id}`,
id,
label: badge.label,
source: badge.icon,
},
),
)
} }
if (!customBadges.length) return return el
return {
children: [
...children.filter(
child =>
!isProfileBadgeElement(child) ||
child.props.id !== DummyBadgeId,
),
...customBadges,
],
}
}) })
} }
const patchCustomBadge: RenderPatch<ProfileBadgeProps, BadgeStyles> = ( function patchProfileBadge(
rendered, element: ReactElement<ProfileBadgeProps, FC<ProfileBadgeProps>>,
{ id }, id: BadgeId,
badgeStyles, badge: Badge,
) => { ): void {
const badge = Badges[id as BadgeId]
if (!badge) return rendered
const { bnw, showDialog } = badge const { bnw, showDialog } = badge
if (!bnw && !showDialog) return rendered // Return early if we don't actually need to patch this badge
if (!bnw && !showDialog) return
let tinted = false const unpatch = afterRendered(element, el => {
let pressablePatched = false unpatch()
// Only patched the most shallow Pressable // This will not result in a conditional hook error because badges do not change flags during runtime
return mapElementTree(rendered, element => { const badgeStyles = useBadgeStyles()
if (bnw && !tinted && element.type === Image) {
tinted = true if (showDialog) {
return { style: [element.props.style, badgeStyles.tinted] } const pressable = findInReactFiber(
el as ReactElement,
(node): node is ReactElement<PressableProps> =>
node.type?.render,
)
if (pressable)
pressable.props.onPress = () =>
openBadgeDialog(id, badge, badgeStyles)
} }
if ( if (bnw) {
showDialog && const image = findInReactFiber(
!pressablePatched && el as ReactElement,
// Match by behaviour, as matching .type === Image picks whichever wrapper component happens to come first (node): node is ReactElement<ImageProps> => node.type === Image,
('onPress' in element.props || )
element.props.accessibilityRole === 'button')
) { if (image) {
pressablePatched = true const styles = image.props.style as Extract<
return { StyleProp<ImageStyle>,
onPress: () => any[]
openBadgeDialog(id as BadgeId, badge, badgeStyles), >
styles.push(badgeStyles.tinted)
} }
} }
return undefined return el
}) })
} }
@ -200,10 +197,10 @@ const { AlertActionButton, AlertModal, Stack, Text } = Design
function openBadgeDialog( function openBadgeDialog(
id: BadgeId, id: BadgeId,
{ label, description, bnw, icon }: Badge, { label, description, bnw, icon }: Badge,
badgeStyles: BadgeStyles, badgeStyles: ReturnType<typeof useBadgeStyles>,
): void { ): void {
AlertActionCreators.openAlert( AlertActionCreators.openAlert(
`revenge-profile-badge-${id}`, `REVENGE_PROFILE_BADGE-${id}`,
<AlertModal <AlertModal
title={ title={
<Stack style={styles.stack}> <Stack style={styles.stack}>
@ -220,7 +217,12 @@ function openBadgeDialog(
</Stack> </Stack>
} }
content={description} content={description}
actions={<AlertActionButton text="OK" />} actions={<AlertActionButton text="Okay" />}
/>, />,
) )
} }
const isViewContainingProfileBadgeElements = (
node: any,
): node is ViewWithProfileBadges =>
node.props?.children?.[0]?.type.name === 'ProfileBadge'

View file

@ -1,90 +1,7 @@
import { cloneElement, isValidElement } from 'react' import { after } from '@revenge-mod/patcher'
import type { FC, ReactElement, ReactNode } from 'react' import type { FC, ReactElement, ReactNode } from 'react'
type AnyElement = ReactElement<Record<string, any>> export const afterRendered = (
el: ReactElement<any, FC<any>>,
export type ElementVisitor = ( hook: (el: ReactNode) => ReactNode,
element: AnyElement, ) => after(el, 'type', el => (el instanceof Promise ? el.then(hook) : hook(el)))
) => Record<string, any> | undefined
/** Copies a rendered element tree, merging in the props the visitor returns. */
export function mapElementTree(
node: ReactNode,
visit: ElementVisitor,
): ReactNode {
if (Array.isArray(node)) {
let changed = false
const next = node.map(child => {
const mapped = mapElementTree(child, visit)
if (mapped !== child) changed = true
return mapped
})
return changed ? next : node
}
if (!isValidElement(node)) return node
const element = node as AnyElement
const patch = visit(element)
// The visitor took over the subtree, so don't descend into it
if (patch && 'children' in patch) return cloneElement(element, patch)
const { children } = element.props
const nextChildren =
children === undefined ? children : mapElementTree(children, visit)
// Nothing below matched either, so hand back the very same element
if (!patch && nextChildren === children) return node
return cloneElement(
element,
nextChildren === children
? patch
: { ...patch, children: nextChildren },
)
}
const patchedComponents = new WeakMap<FC<any>, FC<any>>()
export type RenderPatch<P, C = void> = (
rendered: ReactNode,
props: P,
context: C,
) => ReactNode
/**
* Creates and caches a component rendering `Component`, passing its output through `patch`.
*
* @param Component The component to wrap. Called directly, so its hooks belong to the wrapper.
* @param patch The patch to apply to the rendered output. Must not call hooks.
* @param useContext Provides whatever the patch needs from hooks. Always called, before the component renders.
* @returns The wrapper component.
*/
export function patchRender<P extends object, C = void>(
Component: FC<P>,
patch: RenderPatch<P, C>,
useContext?: () => C,
): FC<P> {
const cached = patchedComponents.get(Component)
if (cached) return cached
const Patched: FC<P> = props => {
const context = useContext?.() as C
const rendered = Component(props)
return rendered instanceof Promise
? rendered.then(node => patch(node, props, context))
: patch(rendered, props, context)
}
Object.defineProperty(Patched, 'name', {
value: `Patched(${Component.name || 'Component'})`,
})
patchedComponents.set(Component, Patched)
return Patched
}