Merge branch 'revenge-mod:main' into main
Some checks failed
Build / Build (push) Failing after 9s

This commit is contained in:
Rosie Val 2026-08-15 13:04:01 -04:00 • committed by GitHub
commit cecebe4c69
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
14 changed files with 481 additions and 392 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 } from '@revenge-mod/modules/finders/filters' import { withDependencies, withProps } from '@revenge-mod/modules/finders/filters'
import { import {
ReactJSXRuntimeModuleId, ReactJSXRuntimeModuleId,
ReactModuleId, ReactModuleId,
@ -26,7 +26,8 @@ export let Design: Design = proxify(
() => { () => {
// ID: 13171 // ID: 13171
// [3909, 4608, 5182, 13172, 2, ...] (141 dependencies) // [3909, 4608, 5182, 13172, 2, ...] (141 dependencies)
const [, id] = lookupModule( const [module] = lookupModule(
withProps('TableRow', 'Button').and(
withDependencies<Design>( withDependencies<Design>(
atLeast(DesignMinimumDependencies, [ atLeast(DesignMinimumDependencies, [
[ImportTrackerModuleId], [ImportTrackerModuleId],
@ -35,16 +36,11 @@ export let Design: Design = proxify(
[ImportTrackerModuleId], [ImportTrackerModuleId],
ImportTrackerModuleId, ImportTrackerModuleId,
]), ]),
)
).keyAs('revenge.discord.design.Design'), ).keyAs('revenge.discord.design.Design'),
{
initialize: false,
},
) )
if (id === undefined) return if (module) return (Design = module)
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 disptach * @example Blocking the dispatch
* ```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,10 +152,14 @@ 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(key: string, alert: ReactElement): void openAlert(
key: string,
alert: ReactElement,
onDismiss?: () => unknown,
options?: { dismissable?: boolean },
): void
dismissAlert(key: string): void dismissAlert(key: string): void
dismissAlerts(): void dismissAlerts(): void
// TODO // TODO

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[] = [] export const mErrorChain: [Metro.ModuleID, unknown][] = []
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) mErrorChain.push([moduleId, e])
return (moduleObject.exports = {}) return (moduleObject.exports = {})
} }

View file

@ -0,0 +1,50 @@
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

@ -0,0 +1,190 @@
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

@ -0,0 +1,13 @@
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

@ -1,244 +0,0 @@
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

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

View file

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

View file

@ -1,7 +1,90 @@
import { after } from '@revenge-mod/patcher' import { cloneElement, isValidElement } from 'react'
import type { FC, ReactElement, ReactNode } from 'react' import type { FC, ReactElement, ReactNode } from 'react'
export const afterRendered = ( type AnyElement = ReactElement<Record<string, any>>
el: ReactElement<any, FC<any>>,
hook: (el: ReactNode) => ReactNode, export type ElementVisitor = (
) => after(el, 'type', el => (el instanceof Promise ? el.then(hook) : hook(el))) 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
}