From 858244cab9420156722ff2fab7b6eb40a62c7d44 Mon Sep 17 00:00:00 2001 From: PalmDevs Date: Thu, 13 Aug 2026 02:18:48 +0700 Subject: [PATCH] fix(plugins/user-badges): make more resilient --- src/plugins/start/user-badges/index.tsx | 218 ++++++++++++------------ src/plugins/start/user-badges/utils.ts | 93 +++++++++- 2 files changed, 196 insertions(+), 115 deletions(-) diff --git a/src/plugins/start/user-badges/index.tsx b/src/plugins/start/user-badges/index.tsx index 983d860..8d52ceb 100644 --- a/src/plugins/start/user-badges/index.tsx +++ b/src/plugins/start/user-badges/index.tsx @@ -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 interface ProfileBadgeProps { id: string @@ -46,9 +41,10 @@ interface ProfileBadgeRowsProps { showToastOnPress?: boolean } -type ViewWithProfileBadges = ReactElement<{ - children: Array>> -}> +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 - ) - // @ts-expect-error: This will never be rendered - props.badges.push({ id: DUMMY_SYMBOL }) - - return args - }), - ) - - cleanup( - afterJSX(ProfileBadgeRows, el => { - patchProfileBadgeRows(el) - return el - }), + // 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, + ) + }, + ), ) }, ) @@ -103,92 +96,102 @@ registerInternalPlugin( InternalPluginFlags.Internal | InternalPluginFlags.Essential, ) -function patchProfileBadgeRows( - element: ReactElement>, -): void { - const unpatch = afterRendered(element, el => { - unpatch() +/** Matched structurally, as minified builds don't preserve `type.name`. */ +function isProfileBadgeElement( + node: ReactNode, +): node is ReactElement> { + if (!isValidElement(node) || typeof node.type !== 'function') return false - const container = findInReactFiber( - el as ReactElement, - isViewContainingProfileBadgeElements, - ) + const props = node.props as Partial | null + return props != null && 'badgeSize' in props && 'userId' in props +} - if (!container) return el +const injectCustomBadges: RenderPatch = ( + 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] - if (!badge) continue + const template = children.find(isProfileBadgeElement) + if (!template) return - const badgeElement = ( - - ) + const ProfileBadge = template.type + const customBadges: ReactElement[] = [] - patchProfileBadge(badgeElement, badgeId, badge) - container.props.children.push(badgeElement) - } + for (const id of userBadges) { + const badge = Badges[id] + if (!badge) continue - // Remove dummy badge if it exists - if (props.id === DUMMY_SYMBOL) container.props.children.shift() + customBadges.push( + createElement( + patchRender(ProfileBadge, patchCustomBadge, useBadgeStyles), + { + ...template.props, + key: `revenge-badge-${id}`, + id, + label: badge.label, + source: badge.icon, + }, + ), + ) } - return el + if (!customBadges.length) return + + return { + children: [ + ...children.filter( + child => + !isProfileBadgeElement(child) || + child.props.id !== DummyBadgeId, + ), + ...customBadges, + ], + } }) } -function patchProfileBadge( - element: ReactElement>, - id: BadgeId, - badge: Badge, -): void { +const patchCustomBadge: RenderPatch = ( + 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 => - 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 => node.type === Image, - ) - - if (image) { - const styles = image.props.style as Extract< - StyleProp, - 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, + badgeStyles: BadgeStyles, ): void { AlertActionCreators.openAlert( - `REVENGE_PROFILE_BADGE-${id}`, + `revenge-profile-badge-${id}`, @@ -217,12 +220,7 @@ function openBadgeDialog( } content={description} - actions={} + actions={} />, ) } - -const isViewContainingProfileBadgeElements = ( - node: any, -): node is ViewWithProfileBadges => - node.props?.children?.[0]?.type.name === 'ProfileBadge' diff --git a/src/plugins/start/user-badges/utils.ts b/src/plugins/start/user-badges/utils.ts index 309cbd0..7084e0e 100644 --- a/src/plugins/start/user-badges/utils.ts +++ b/src/plugins/start/user-badges/utils.ts @@ -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>, - hook: (el: ReactNode) => ReactNode, -) => after(el, 'type', el => (el instanceof Promise ? el.then(hook) : hook(el))) +type AnyElement = ReactElement> + +export type ElementVisitor = ( + element: AnyElement, +) => Record | 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>() + +export type RenderPatch = ( + 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

( + Component: FC

, + patch: RenderPatch, + useContext?: () => C, +): FC

{ + const cached = patchedComponents.get(Component) + if (cached) return cached + + const Patched: FC

= 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 +}