diff --git a/lib/discord/src/design.ts b/lib/discord/src/design.ts index 3c3a9d0..0508056 100644 --- a/lib/discord/src/design.ts +++ b/lib/discord/src/design.ts @@ -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,25 +26,21 @@ 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( - atLeast(DesignMinimumDependencies, [ - [ImportTrackerModuleId], - [ImportTrackerModuleId], - [ReactNativeModuleId, ImportTrackerModuleId], - [ImportTrackerModuleId], - ImportTrackerModuleId, - ]), + atLeast(DesignMinimumDependencies, [ + [ImportTrackerModuleId], + [ImportTrackerModuleId], + [ReactNativeModuleId, ImportTrackerModuleId], + [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: {}, diff --git a/lib/discord/src/flux/dispatcher.ts b/lib/discord/src/flux/dispatcher.ts index b840048..734dd7a 100644 --- a/lib/discord/src/flux/dispatcher.ts +++ b/lib/discord/src/flux/dispatcher.ts @@ -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', () => {}) diff --git a/lib/discord/src/types/index.ts b/lib/discord/src/types/index.ts index 5aa566d..aa27caf 100644 --- a/lib/discord/src/types/index.ts +++ b/lib/discord/src/types/index.ts @@ -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 diff --git a/lib/modules/src/metro/runtime.ts b/lib/modules/src/metro/runtime.ts index 81f0e13..98404f5 100644 --- a/lib/modules/src/metro/runtime.ts +++ b/lib/modules/src/metro/runtime.ts @@ -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 = {}) } diff --git a/src/plugins/start/recovery/components/ErrorBoundaryScreen.tsx b/src/plugins/preinit/recovery/components/ErrorBoundaryScreen.tsx similarity index 100% rename from src/plugins/start/recovery/components/ErrorBoundaryScreen.tsx rename to src/plugins/preinit/recovery/components/ErrorBoundaryScreen.tsx diff --git a/src/plugins/preinit/recovery/components/alerts.tsx b/src/plugins/preinit/recovery/components/alerts.tsx new file mode 100644 index 0000000..9c7ec52 --- /dev/null +++ b/src/plugins/preinit/recovery/components/alerts.tsx @@ -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 ( + + { + // React Navigation will bubble our requests to the main navigator if we're already in settings + navigation.navigate('settings', { + screen: RouteNames.RevengePlugins, + }) + }} + /> + + + } + /> + ) +} + +export function LoaderOutdatedModal() { + const info = getBridgeInfo() + if (!info) throw new Error('Failed to get native bridge info') + + return ( + + } + /> + ) +} diff --git a/src/plugins/start/recovery/definitions/RevengeEnterRecoveryModeSetting.tsx b/src/plugins/preinit/recovery/definitions/RevengeEnterRecoveryModeSetting.tsx similarity index 100% rename from src/plugins/start/recovery/definitions/RevengeEnterRecoveryModeSetting.tsx rename to src/plugins/preinit/recovery/definitions/RevengeEnterRecoveryModeSetting.tsx diff --git a/src/plugins/preinit/recovery/index.tsx b/src/plugins/preinit/recovery/index.tsx new file mode 100644 index 0000000..2120ef8 --- /dev/null +++ b/src/plugins/preinit/recovery/index.tsx @@ -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', + , + ) + } + + if (assetsCache.outdated || modulesCache.outdated) { + Actions.AlertActionCreators.openAlert( + 'revenge-loader-outdated', + , + ) + } + }) + }, + }, + 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 = (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('ErrorBoundary'), + exports => { + unsub() + + instead( + exports.prototype, + 'render', + function (this: DiscordErrorBoundary) { + if (this.state.error) + return ( + { + 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 +} diff --git a/src/plugins/preinit/recovery/settings.tsx b/src/plugins/preinit/recovery/settings.tsx new file mode 100644 index 0000000..a2f5907 --- /dev/null +++ b/src/plugins/preinit/recovery/settings.tsx @@ -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, + ) +}) diff --git a/src/plugins/start/recovery/index.tsx b/src/plugins/start/recovery/index.tsx deleted file mode 100644 index 5fdf73d..0000000 --- a/src/plugins/start/recovery/index.tsx +++ /dev/null @@ -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', - , - ) - }) - } - - if (assetsCache.outdated || modulesCache.outdated) { - asap(() => { - AlertActionCreators.openAlert( - 'revenge-loader-outdated', - , - ) - }) - } - }, - }, - 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 = (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('ErrorBoundary'), - exports => { - unsub() - - instead( - exports.prototype, - 'render', - function (this: DiscordErrorBoundary) { - if (this.state.error) - return ( - { - 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 ( - - { - // React Navigation will bubble our requests to the main navigator if we're already in settings - navigation.navigate('settings', { - screen: RouteNames.RevengePlugins, - }) - }} - /> - - - } - /> - ) -} - -function LoaderOutdatedModal() { - const info = getBridgeInfo() - if (!info) throw new Error('Failed to get native bridge info') - - return ( - - } - /> - ) -} diff --git a/src/plugins/start/staff-settings/index.ts b/src/plugins/start/staff-settings/index.ts index a81297c..1cc1384 100644 --- a/src/plugins/start/staff-settings/index.ts +++ b/src/plugins/start/staff-settings/index.ts @@ -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() }), ) }, diff --git a/src/plugins/start/user-badges/data.json b/src/plugins/start/user-badges/data.json index 19c7084..83caa85 100644 --- a/src/plugins/start/user-badges/data.json +++ b/src/plugins/start/user-badges/data.json @@ -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"] } 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 +}