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 { withDependencies } from '@revenge-mod/modules/finders/filters'
import { withDependencies, withProps } from '@revenge-mod/modules/finders/filters'
import {
ReactJSXRuntimeModuleId,
ReactModuleId,
@ -26,7 +26,8 @@ export let Design: Design = proxify(
() => {
// ID: 13171
// [3909, 4608, 5182, 13172, 2, ...] (141 dependencies)
const [, id] = lookupModule(
const [module] = lookupModule(
withProps('TableRow', 'Button').and(
withDependencies<Design>(
atLeast(DesignMinimumDependencies, [
[ImportTrackerModuleId],
@ -35,16 +36,11 @@ export let Design: Design = proxify(
[ImportTrackerModuleId],
ImportTrackerModuleId,
]),
)
).keyAs('revenge.discord.design.Design'),
{
initialize: false,
},
)
if (id === undefined) return
const module = __r(id)!
if (module.TableRow && module.Button) return (Design = module)
if (module) 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 disptach
* @example Blocking the dispatch
* ```ts
* // Returning falsy values will prevent the event from being dispatched.
* onFluxEventDispatched('TYPING_START', () => {})

View file

@ -152,10 +152,14 @@ export namespace DiscordModules {
verboseDangerously(...args: unknown[]): void
verbose(...args: unknown[]): void
}
export namespace Actions {
export interface AlertActionCreators {
openAlert(key: string, alert: ReactElement): void
openAlert(
key: string,
alert: ReactElement,
onDismiss?: () => unknown,
options?: { dismissable?: boolean },
): void
dismissAlert(key: string): void
dismissAlerts(): void
// TODO

View file

@ -15,7 +15,7 @@ import {
} from './subscriptions/_internal'
import type { Metro } from '../types'
export const mErrorChain: Metro.ModuleID[] = []
export const mErrorChain: [Metro.ModuleID, unknown][] = []
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)
mErrorChain.push([moduleId, e])
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',
},
{
start({ cleanup, logger }) {
start({ cleanup, logger, plugin }) {
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
@ -43,12 +50,6 @@ registerInternalPlugin(
store.initialize()
unpatch()
ToastActionCreators.open({
key: 'staff-settings-action',
content: 'Navigate out of Settings to apply changes',
IconComponent: CircleInformationIcon,
})
})
}
@ -69,9 +70,11 @@ registerInternalPlugin(
).getCurrentUser(),
),
reset,
showToast,
)
reset()
if (plugin.startedLate) showToast()
}),
)
},

View file

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

View file

@ -7,23 +7,18 @@ import {
PluginFlags,
registerInternalPlugin,
} from '@revenge-mod/plugins/_'
import { afterJSX, beforeJSX } from '@revenge-mod/react/jsx-runtime'
import { findInReactFiber } from '@revenge-mod/utils/react'
import { insteadJSX } from '@revenge-mod/react/jsx-runtime'
import { createElement, isValidElement } from 'react'
import { Image } from 'react-native'
import { Badges, UsersWithBadges } from './constants'
import { styles, useBadgeStyles } from './styles'
import { afterRendered } from './utils'
import type { FC, ReactElement } from 'react'
import type {
ImageProps,
ImageSourcePropType,
ImageStyle,
PressableProps,
StyleProp,
} from 'react-native'
import { mapElementTree, patchRender } from './utils'
import type { FC, ReactElement, ReactNode } from 'react'
import type { ImageSourcePropType } from 'react-native'
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 {
id: string
@ -46,9 +41,10 @@ interface ProfileBadgeRowsProps {
showToastOnPress?: boolean
}
type ViewWithProfileBadges = ReactElement<{
children: Array<ReactElement<ProfileBadgeProps, FC<ProfileBadgeProps>>>
}>
const DummyBadgeId = {} as unknown as string
const DummyBadges = [
{ id: DummyBadgeId },
] as unknown as ProfileBadgeRowsProps['badges']
registerInternalPlugin(
{
@ -66,28 +62,25 @@ registerInternalPlugin(
),
({ ProfileBadgeRows }) => {
cleanup(
beforeJSX(ProfileBadgeRows, args => {
const [, props] = args
insteadJSX(
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
if (
// has custom badges
UsersWithBadges[props.userId] &&
// but no actual badges
!props.badges.length
return jsx(
patchRender(type, injectCustomBadges),
props.badges.length
? props
: { ...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,
)
function patchProfileBadgeRows(
element: ReactElement<ProfileBadgeRowsProps, FC<ProfileBadgeRowsProps>>,
): void {
const unpatch = afterRendered(element, el => {
unpatch()
/** 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
const container = findInReactFiber(
el as ReactElement,
isViewContainingProfileBadgeElements,
)
const props = node.props as Partial<ProfileBadgeProps> | null
return props != null && 'badgeSize' in props && 'userId' in props
}
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
const userBadges = UsersWithBadges[props.userId]
return mapElementTree(rendered, element => {
const { children } = element.props
if (!Array.isArray(children)) return
if (userBadges) {
for (const badgeId of userBadges) {
const badge = Badges[badgeId]
const template = children.find(isProfileBadgeElement)
if (!template) return
const ProfileBadge = template.type
const customBadges: ReactElement[] = []
for (const id of userBadges) {
const badge = Badges[id]
if (!badge) continue
const badgeElement = (
<ProfileBadge
{...props}
key={badgeId}
id={badgeId}
label={badge.label}
source={badge.icon}
/>
customBadges.push(
createElement(
patchRender(ProfileBadge, patchCustomBadge, useBadgeStyles),
{
...template.props,
key: `revenge-badge-${id}`,
id,
label: badge.label,
source: badge.icon,
},
),
)
patchProfileBadge(badgeElement, badgeId, badge)
container.props.children.push(badgeElement)
}
// Remove dummy badge if it exists
if (props.id === DUMMY_SYMBOL) container.props.children.shift()
}
if (!customBadges.length) return
return el
return {
children: [
...children.filter(
child =>
!isProfileBadgeElement(child) ||
child.props.id !== DummyBadgeId,
),
...customBadges,
],
}
})
}
function patchProfileBadge(
element: ReactElement<ProfileBadgeProps, FC<ProfileBadgeProps>>,
id: BadgeId,
badge: Badge,
): void {
const patchCustomBadge: RenderPatch<ProfileBadgeProps, BadgeStyles> = (
rendered,
{ id },
badgeStyles,
) => {
const badge = Badges[id as BadgeId]
if (!badge) return rendered
const { bnw, showDialog } = badge
// Return early if we don't actually need to patch this badge
if (!bnw && !showDialog) return
if (!bnw && !showDialog) return rendered
const unpatch = afterRendered(element, el => {
unpatch()
let tinted = false
let pressablePatched = false
// 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)
// 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] }
}
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)
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),
}
}
return el
return undefined
})
}
@ -197,10 +200,10 @@ const { AlertActionButton, AlertModal, Stack, Text } = Design
function openBadgeDialog(
id: BadgeId,
{ label, description, bnw, icon }: Badge,
badgeStyles: ReturnType<typeof useBadgeStyles>,
badgeStyles: BadgeStyles,
): void {
AlertActionCreators.openAlert(
`REVENGE_PROFILE_BADGE-${id}`,
`revenge-profile-badge-${id}`,
<AlertModal
title={
<Stack style={styles.stack}>
@ -217,12 +220,7 @@ function openBadgeDialog(
</Stack>
}
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'
export const afterRendered = (
el: ReactElement<any, FC<any>>,
hook: (el: ReactNode) => ReactNode,
) => after(el, 'type', el => (el instanceof Promise ? el.then(hook) : hook(el)))
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
}