This commit is contained in:
commit
cecebe4c69
14 changed files with 481 additions and 392 deletions
|
|
@ -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<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 (id === undefined) return
|
||||
|
||||
const module = __r(id)!
|
||||
if (module.TableRow && module.Button) return (Design = module)
|
||||
if (module) return (Design = module)
|
||||
},
|
||||
{
|
||||
hint: {},
|
||||
|
|
|
|||
|
|
@ -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', () => {})
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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 = {})
|
||||
}
|
||||
|
|
|
|||
50
src/plugins/preinit/recovery/components/alerts.tsx
Normal file
50
src/plugins/preinit/recovery/components/alerts.tsx
Normal 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" />
|
||||
}
|
||||
/>
|
||||
)
|
||||
}
|
||||
190
src/plugins/preinit/recovery/index.tsx
Normal file
190
src/plugins/preinit/recovery/index.tsx
Normal 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
|
||||
}
|
||||
13
src/plugins/preinit/recovery/settings.tsx
Normal file
13
src/plugins/preinit/recovery/settings.tsx
Normal 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,
|
||||
)
|
||||
})
|
||||
|
|
@ -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" />
|
||||
}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
|
@ -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()
|
||||
}),
|
||||
)
|
||||
},
|
||||
|
|
|
|||
|
|
@ -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"]
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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
|
||||
)
|
||||
// @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<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]
|
||||
if (!badge) continue
|
||||
const template = children.find(isProfileBadgeElement)
|
||||
if (!template) return
|
||||
|
||||
const badgeElement = (
|
||||
<ProfileBadge
|
||||
{...props}
|
||||
key={badgeId}
|
||||
id={badgeId}
|
||||
label={badge.label}
|
||||
source={badge.icon}
|
||||
/>
|
||||
)
|
||||
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<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'
|
||||
|
|
|
|||
|
|
@ -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
|
||||
}
|
||||
|
|
|
|||
Loading…
Add table
Reference in a new issue