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 { withDependencies, withProps } from '@revenge-mod/modules/finders/filters'
import { withDependencies } from '@revenge-mod/modules/finders/filters'
import {
ReactJSXRuntimeModuleId,
ReactModuleId,
@ -26,21 +26,25 @@ export let Design: Design = proxify(
() => {
// ID: 13171
// [3909, 4608, 5182, 13172, 2, ...] (141 dependencies)
const [module] = lookupModule(
withProps('TableRow', 'Button').and(
const [, id] = lookupModule(
withDependencies<Design>(
atLeast(DesignMinimumDependencies, [
[ImportTrackerModuleId],
[ImportTrackerModuleId],
[ReactNativeModuleId, ImportTrackerModuleId],
[ImportTrackerModuleId],
ImportTrackerModuleId,
]),
)
atLeast(DesignMinimumDependencies, [
[ImportTrackerModuleId],
[ImportTrackerModuleId],
[ReactNativeModuleId, ImportTrackerModuleId],
[ImportTrackerModuleId],
ImportTrackerModuleId,
]),
).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: {},

View file

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

View file

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

View file

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

View file

@ -170,3 +170,21 @@ export async function runInstallFlow(
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',
},
{
start({ cleanup, logger, plugin }) {
start({ cleanup, logger }) {
const CircleInformationIcon = lookupGeneratedIconComponent(
'CircleInformationIcon',
'CircleInformationIcon-secondary',
'CircleInformationIcon-primary',
)
const showToast = () =>
ToastActionCreators.open({
key: 'staff-settings-action',
content: 'Navigate out of Settings to apply changes',
IconComponent: CircleInformationIcon,
})
function reset() {
getStore<{
initialize(): void
@ -50,6 +43,12 @@ registerInternalPlugin(
store.initialize()
unpatch()
ToastActionCreators.open({
key: 'staff-settings-action',
content: 'Navigate out of Settings to apply changes',
IconComponent: CircleInformationIcon,
})
})
}
@ -70,11 +69,9 @@ registerInternalPlugin(
).getCurrentUser(),
),
reset,
showToast,
)
reset()
if (plugin.startedLate) showToast()
}),
)
},

View file

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