Compare commits
7 commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
cecebe4c69 | ||
|
|
c8d820cb15 | ||
|
|
9525f98289 | ||
|
|
b1d65fbda2 | ||
|
|
858244cab9 | ||
|
|
8f028b5a56 | ||
|
|
2172bc862b |
14 changed files with 481 additions and 392 deletions
|
|
@ -1,5 +1,5 @@
|
||||||
import { lookupModule, lookupModules } from '@revenge-mod/modules/finders'
|
import { lookupModule, lookupModules } from '@revenge-mod/modules/finders'
|
||||||
import { withDependencies } from '@revenge-mod/modules/finders/filters'
|
import { withDependencies, withProps } from '@revenge-mod/modules/finders/filters'
|
||||||
import {
|
import {
|
||||||
ReactJSXRuntimeModuleId,
|
ReactJSXRuntimeModuleId,
|
||||||
ReactModuleId,
|
ReactModuleId,
|
||||||
|
|
@ -26,25 +26,21 @@ export let Design: Design = proxify(
|
||||||
() => {
|
() => {
|
||||||
// ID: 13171
|
// ID: 13171
|
||||||
// [3909, 4608, 5182, 13172, 2, ...] (141 dependencies)
|
// [3909, 4608, 5182, 13172, 2, ...] (141 dependencies)
|
||||||
const [, id] = lookupModule(
|
const [module] = lookupModule(
|
||||||
|
withProps('TableRow', 'Button').and(
|
||||||
withDependencies<Design>(
|
withDependencies<Design>(
|
||||||
atLeast(DesignMinimumDependencies, [
|
atLeast(DesignMinimumDependencies, [
|
||||||
[ImportTrackerModuleId],
|
[ImportTrackerModuleId],
|
||||||
[ImportTrackerModuleId],
|
[ImportTrackerModuleId],
|
||||||
[ReactNativeModuleId, ImportTrackerModuleId],
|
[ReactNativeModuleId, ImportTrackerModuleId],
|
||||||
[ImportTrackerModuleId],
|
[ImportTrackerModuleId],
|
||||||
ImportTrackerModuleId,
|
ImportTrackerModuleId,
|
||||||
]),
|
]),
|
||||||
|
)
|
||||||
).keyAs('revenge.discord.design.Design'),
|
).keyAs('revenge.discord.design.Design'),
|
||||||
{
|
|
||||||
initialize: false,
|
|
||||||
},
|
|
||||||
)
|
)
|
||||||
|
|
||||||
if (id === undefined) return
|
if (module) return (Design = module)
|
||||||
|
|
||||||
const module = __r(id)!
|
|
||||||
if (module.TableRow && module.Button) return (Design = module)
|
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
hint: {},
|
hint: {},
|
||||||
|
|
|
||||||
|
|
@ -27,7 +27,7 @@ export function onAnyFluxEventDispatched(patch: FluxEventDispatchPatch) {
|
||||||
* @param patch The patch function to apply when the event is dispatched.
|
* @param patch The patch function to apply when the event is dispatched.
|
||||||
* @returns A function that can be used to remove the patch.
|
* @returns A function that can be used to remove the patch.
|
||||||
*
|
*
|
||||||
* @example Blocking the disptach
|
* @example Blocking the dispatch
|
||||||
* ```ts
|
* ```ts
|
||||||
* // Returning falsy values will prevent the event from being dispatched.
|
* // Returning falsy values will prevent the event from being dispatched.
|
||||||
* onFluxEventDispatched('TYPING_START', () => {})
|
* onFluxEventDispatched('TYPING_START', () => {})
|
||||||
|
|
|
||||||
|
|
@ -152,10 +152,14 @@ export namespace DiscordModules {
|
||||||
verboseDangerously(...args: unknown[]): void
|
verboseDangerously(...args: unknown[]): void
|
||||||
verbose(...args: unknown[]): void
|
verbose(...args: unknown[]): void
|
||||||
}
|
}
|
||||||
|
|
||||||
export namespace Actions {
|
export namespace Actions {
|
||||||
export interface AlertActionCreators {
|
export interface AlertActionCreators {
|
||||||
openAlert(key: string, alert: ReactElement): void
|
openAlert(
|
||||||
|
key: string,
|
||||||
|
alert: ReactElement,
|
||||||
|
onDismiss?: () => unknown,
|
||||||
|
options?: { dismissable?: boolean },
|
||||||
|
): void
|
||||||
dismissAlert(key: string): void
|
dismissAlert(key: string): void
|
||||||
dismissAlerts(): void
|
dismissAlerts(): void
|
||||||
// TODO
|
// TODO
|
||||||
|
|
|
||||||
|
|
@ -15,7 +15,7 @@ import {
|
||||||
} from './subscriptions/_internal'
|
} from './subscriptions/_internal'
|
||||||
import type { Metro } from '../types'
|
import type { Metro } from '../types'
|
||||||
|
|
||||||
export const mErrorChain: Metro.ModuleID[] = []
|
export const mErrorChain: [Metro.ModuleID, unknown][] = []
|
||||||
|
|
||||||
export const Initialized = 1 << 0
|
export const Initialized = 1 << 0
|
||||||
const HasError = 1 << 1
|
const HasError = 1 << 1
|
||||||
|
|
@ -80,7 +80,7 @@ export const metroRequire = (moduleId => {
|
||||||
// if (global.ErrorUtils) global.ErrorUtils.reportFatalError(e)
|
// if (global.ErrorUtils) global.ErrorUtils.reportFatalError(e)
|
||||||
// else throw e
|
// else throw e
|
||||||
|
|
||||||
mErrorChain.push(moduleId)
|
mErrorChain.push([moduleId, e])
|
||||||
|
|
||||||
return (moduleObject.exports = {})
|
return (moduleObject.exports = {})
|
||||||
}
|
}
|
||||||
|
|
|
||||||
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',
|
icon: 'StaffBadgeIcon',
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
start({ cleanup, logger }) {
|
start({ cleanup, logger, plugin }) {
|
||||||
const CircleInformationIcon = lookupGeneratedIconComponent(
|
const CircleInformationIcon = lookupGeneratedIconComponent(
|
||||||
'CircleInformationIcon',
|
'CircleInformationIcon',
|
||||||
'CircleInformationIcon-secondary',
|
'CircleInformationIcon-secondary',
|
||||||
'CircleInformationIcon-primary',
|
'CircleInformationIcon-primary',
|
||||||
)
|
)
|
||||||
|
|
||||||
|
const showToast = () =>
|
||||||
|
ToastActionCreators.open({
|
||||||
|
key: 'staff-settings-action',
|
||||||
|
content: 'Navigate out of Settings to apply changes',
|
||||||
|
IconComponent: CircleInformationIcon,
|
||||||
|
})
|
||||||
|
|
||||||
function reset() {
|
function reset() {
|
||||||
getStore<{
|
getStore<{
|
||||||
initialize(): void
|
initialize(): void
|
||||||
|
|
@ -43,12 +50,6 @@ registerInternalPlugin(
|
||||||
|
|
||||||
store.initialize()
|
store.initialize()
|
||||||
unpatch()
|
unpatch()
|
||||||
|
|
||||||
ToastActionCreators.open({
|
|
||||||
key: 'staff-settings-action',
|
|
||||||
content: 'Navigate out of Settings to apply changes',
|
|
||||||
IconComponent: CircleInformationIcon,
|
|
||||||
})
|
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -69,9 +70,11 @@ registerInternalPlugin(
|
||||||
).getCurrentUser(),
|
).getCurrentUser(),
|
||||||
),
|
),
|
||||||
reset,
|
reset,
|
||||||
|
showToast,
|
||||||
)
|
)
|
||||||
|
|
||||||
reset()
|
reset()
|
||||||
|
if (plugin.startedLate) showToast()
|
||||||
}),
|
}),
|
||||||
)
|
)
|
||||||
},
|
},
|
||||||
|
|
|
||||||
|
|
@ -1,8 +1,4 @@
|
||||||
{
|
{
|
||||||
"629368283354628116": ["revenge_team"],
|
"629368283354628116": ["revenge_team"],
|
||||||
"399862294143696897": ["revenge_team"],
|
"399862294143696897": ["revenge_team"]
|
||||||
"853550207039832084": ["revenge_team"],
|
|
||||||
"485376328980365312": ["revenge_team"],
|
|
||||||
"737323631117598811": ["revenge_team"],
|
|
||||||
"826489186327724095": ["revenge_team"]
|
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -7,23 +7,18 @@ import {
|
||||||
PluginFlags,
|
PluginFlags,
|
||||||
registerInternalPlugin,
|
registerInternalPlugin,
|
||||||
} from '@revenge-mod/plugins/_'
|
} from '@revenge-mod/plugins/_'
|
||||||
import { afterJSX, beforeJSX } from '@revenge-mod/react/jsx-runtime'
|
import { insteadJSX } from '@revenge-mod/react/jsx-runtime'
|
||||||
import { findInReactFiber } from '@revenge-mod/utils/react'
|
import { createElement, isValidElement } from 'react'
|
||||||
import { Image } from 'react-native'
|
import { Image } from 'react-native'
|
||||||
import { Badges, UsersWithBadges } from './constants'
|
import { Badges, UsersWithBadges } from './constants'
|
||||||
import { styles, useBadgeStyles } from './styles'
|
import { styles, useBadgeStyles } from './styles'
|
||||||
import { afterRendered } from './utils'
|
import { mapElementTree, patchRender } from './utils'
|
||||||
import type { FC, ReactElement } from 'react'
|
import type { FC, ReactElement, ReactNode } from 'react'
|
||||||
import type {
|
import type { ImageSourcePropType } from 'react-native'
|
||||||
ImageProps,
|
|
||||||
ImageSourcePropType,
|
|
||||||
ImageStyle,
|
|
||||||
PressableProps,
|
|
||||||
StyleProp,
|
|
||||||
} from 'react-native'
|
|
||||||
import type { Badge, BadgeId } from './constants'
|
import type { Badge, BadgeId } from './constants'
|
||||||
|
import type { RenderPatch } from './utils'
|
||||||
|
|
||||||
const DUMMY_SYMBOL = {} as unknown as string
|
type BadgeStyles = ReturnType<typeof useBadgeStyles>
|
||||||
|
|
||||||
interface ProfileBadgeProps {
|
interface ProfileBadgeProps {
|
||||||
id: string
|
id: string
|
||||||
|
|
@ -46,9 +41,10 @@ interface ProfileBadgeRowsProps {
|
||||||
showToastOnPress?: boolean
|
showToastOnPress?: boolean
|
||||||
}
|
}
|
||||||
|
|
||||||
type ViewWithProfileBadges = ReactElement<{
|
const DummyBadgeId = {} as unknown as string
|
||||||
children: Array<ReactElement<ProfileBadgeProps, FC<ProfileBadgeProps>>>
|
const DummyBadges = [
|
||||||
}>
|
{ id: DummyBadgeId },
|
||||||
|
] as unknown as ProfileBadgeRowsProps['badges']
|
||||||
|
|
||||||
registerInternalPlugin(
|
registerInternalPlugin(
|
||||||
{
|
{
|
||||||
|
|
@ -66,28 +62,25 @@ registerInternalPlugin(
|
||||||
),
|
),
|
||||||
({ ProfileBadgeRows }) => {
|
({ ProfileBadgeRows }) => {
|
||||||
cleanup(
|
cleanup(
|
||||||
beforeJSX(ProfileBadgeRows, args => {
|
insteadJSX(
|
||||||
const [, props] = args
|
ProfileBadgeRows,
|
||||||
|
([type, props, key], jsx) => {
|
||||||
|
if (
|
||||||
|
!UsersWithBadges[props.userId] ||
|
||||||
|
typeof type !== 'function'
|
||||||
|
)
|
||||||
|
return jsx(type, props, key)
|
||||||
|
|
||||||
// Inject dummy badge for users with custom badges to ensure ProfileBadge components exist
|
// Inject dummy badge for users with custom badges to ensure ProfileBadge components exist
|
||||||
if (
|
return jsx(
|
||||||
// has custom badges
|
patchRender(type, injectCustomBadges),
|
||||||
UsersWithBadges[props.userId] &&
|
props.badges.length
|
||||||
// but no actual badges
|
? props
|
||||||
!props.badges.length
|
: { ...props, badges: DummyBadges },
|
||||||
)
|
key,
|
||||||
// @ts-expect-error: This will never be rendered
|
)
|
||||||
props.badges.push({ id: DUMMY_SYMBOL })
|
},
|
||||||
|
),
|
||||||
return args
|
|
||||||
}),
|
|
||||||
)
|
|
||||||
|
|
||||||
cleanup(
|
|
||||||
afterJSX(ProfileBadgeRows, el => {
|
|
||||||
patchProfileBadgeRows(el)
|
|
||||||
return el
|
|
||||||
}),
|
|
||||||
)
|
)
|
||||||
},
|
},
|
||||||
)
|
)
|
||||||
|
|
@ -103,92 +96,102 @@ registerInternalPlugin(
|
||||||
InternalPluginFlags.Internal | InternalPluginFlags.Essential,
|
InternalPluginFlags.Internal | InternalPluginFlags.Essential,
|
||||||
)
|
)
|
||||||
|
|
||||||
function patchProfileBadgeRows(
|
/** Matched structurally, as minified builds don't preserve `type.name`. */
|
||||||
element: ReactElement<ProfileBadgeRowsProps, FC<ProfileBadgeRowsProps>>,
|
function isProfileBadgeElement(
|
||||||
): void {
|
node: ReactNode,
|
||||||
const unpatch = afterRendered(element, el => {
|
): node is ReactElement<ProfileBadgeProps, FC<ProfileBadgeProps>> {
|
||||||
unpatch()
|
if (!isValidElement(node) || typeof node.type !== 'function') return false
|
||||||
|
|
||||||
const container = findInReactFiber(
|
const props = node.props as Partial<ProfileBadgeProps> | null
|
||||||
el as ReactElement,
|
return props != null && 'badgeSize' in props && 'userId' in props
|
||||||
isViewContainingProfileBadgeElements,
|
}
|
||||||
)
|
|
||||||
|
|
||||||
if (!container) return el
|
const injectCustomBadges: RenderPatch<ProfileBadgeRowsProps> = (
|
||||||
|
rendered,
|
||||||
|
{ userId },
|
||||||
|
) => {
|
||||||
|
const userBadges = UsersWithBadges[userId]
|
||||||
|
if (!userBadges) return rendered
|
||||||
|
|
||||||
const [{ type: ProfileBadge, props }] = container.props.children
|
return mapElementTree(rendered, element => {
|
||||||
const userBadges = UsersWithBadges[props.userId]
|
const { children } = element.props
|
||||||
|
if (!Array.isArray(children)) return
|
||||||
|
|
||||||
if (userBadges) {
|
const template = children.find(isProfileBadgeElement)
|
||||||
for (const badgeId of userBadges) {
|
if (!template) return
|
||||||
const badge = Badges[badgeId]
|
|
||||||
if (!badge) continue
|
|
||||||
|
|
||||||
const badgeElement = (
|
const ProfileBadge = template.type
|
||||||
<ProfileBadge
|
const customBadges: ReactElement[] = []
|
||||||
{...props}
|
|
||||||
key={badgeId}
|
|
||||||
id={badgeId}
|
|
||||||
label={badge.label}
|
|
||||||
source={badge.icon}
|
|
||||||
/>
|
|
||||||
)
|
|
||||||
|
|
||||||
patchProfileBadge(badgeElement, badgeId, badge)
|
for (const id of userBadges) {
|
||||||
container.props.children.push(badgeElement)
|
const badge = Badges[id]
|
||||||
}
|
if (!badge) continue
|
||||||
|
|
||||||
// Remove dummy badge if it exists
|
customBadges.push(
|
||||||
if (props.id === DUMMY_SYMBOL) container.props.children.shift()
|
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(
|
const patchCustomBadge: RenderPatch<ProfileBadgeProps, BadgeStyles> = (
|
||||||
element: ReactElement<ProfileBadgeProps, FC<ProfileBadgeProps>>,
|
rendered,
|
||||||
id: BadgeId,
|
{ id },
|
||||||
badge: Badge,
|
badgeStyles,
|
||||||
): void {
|
) => {
|
||||||
|
const badge = Badges[id as BadgeId]
|
||||||
|
if (!badge) return rendered
|
||||||
|
|
||||||
const { bnw, showDialog } = badge
|
const { bnw, showDialog } = badge
|
||||||
// Return early if we don't actually need to patch this badge
|
if (!bnw && !showDialog) return rendered
|
||||||
if (!bnw && !showDialog) return
|
|
||||||
|
|
||||||
const unpatch = afterRendered(element, el => {
|
let tinted = false
|
||||||
unpatch()
|
let pressablePatched = false
|
||||||
|
|
||||||
// This will not result in a conditional hook error because badges do not change flags during runtime
|
// Only patched the most shallow Pressable
|
||||||
const badgeStyles = useBadgeStyles()
|
return mapElementTree(rendered, element => {
|
||||||
|
if (bnw && !tinted && element.type === Image) {
|
||||||
if (showDialog) {
|
tinted = true
|
||||||
const pressable = findInReactFiber(
|
return { style: [element.props.style, badgeStyles.tinted] }
|
||||||
el as ReactElement,
|
|
||||||
(node): node is ReactElement<PressableProps> =>
|
|
||||||
node.type?.render,
|
|
||||||
)
|
|
||||||
|
|
||||||
if (pressable)
|
|
||||||
pressable.props.onPress = () =>
|
|
||||||
openBadgeDialog(id, badge, badgeStyles)
|
|
||||||
}
|
}
|
||||||
|
|
||||||
if (bnw) {
|
if (
|
||||||
const image = findInReactFiber(
|
showDialog &&
|
||||||
el as ReactElement,
|
!pressablePatched &&
|
||||||
(node): node is ReactElement<ImageProps> => node.type === Image,
|
// Match by behaviour, as matching .type === Image picks whichever wrapper component happens to come first
|
||||||
)
|
('onPress' in element.props ||
|
||||||
|
element.props.accessibilityRole === 'button')
|
||||||
if (image) {
|
) {
|
||||||
const styles = image.props.style as Extract<
|
pressablePatched = true
|
||||||
StyleProp<ImageStyle>,
|
return {
|
||||||
any[]
|
onPress: () =>
|
||||||
>
|
openBadgeDialog(id as BadgeId, badge, badgeStyles),
|
||||||
styles.push(badgeStyles.tinted)
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
return el
|
return undefined
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -197,10 +200,10 @@ const { AlertActionButton, AlertModal, Stack, Text } = Design
|
||||||
function openBadgeDialog(
|
function openBadgeDialog(
|
||||||
id: BadgeId,
|
id: BadgeId,
|
||||||
{ label, description, bnw, icon }: Badge,
|
{ label, description, bnw, icon }: Badge,
|
||||||
badgeStyles: ReturnType<typeof useBadgeStyles>,
|
badgeStyles: BadgeStyles,
|
||||||
): void {
|
): void {
|
||||||
AlertActionCreators.openAlert(
|
AlertActionCreators.openAlert(
|
||||||
`REVENGE_PROFILE_BADGE-${id}`,
|
`revenge-profile-badge-${id}`,
|
||||||
<AlertModal
|
<AlertModal
|
||||||
title={
|
title={
|
||||||
<Stack style={styles.stack}>
|
<Stack style={styles.stack}>
|
||||||
|
|
@ -217,12 +220,7 @@ function openBadgeDialog(
|
||||||
</Stack>
|
</Stack>
|
||||||
}
|
}
|
||||||
content={description}
|
content={description}
|
||||||
actions={<AlertActionButton text="Okay" />}
|
actions={<AlertActionButton text="OK" />}
|
||||||
/>,
|
/>,
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
const isViewContainingProfileBadgeElements = (
|
|
||||||
node: any,
|
|
||||||
): node is ViewWithProfileBadges =>
|
|
||||||
node.props?.children?.[0]?.type.name === 'ProfileBadge'
|
|
||||||
|
|
|
||||||
|
|
@ -1,7 +1,90 @@
|
||||||
import { after } from '@revenge-mod/patcher'
|
import { cloneElement, isValidElement } from 'react'
|
||||||
import type { FC, ReactElement, ReactNode } from 'react'
|
import type { FC, ReactElement, ReactNode } from 'react'
|
||||||
|
|
||||||
export const afterRendered = (
|
type AnyElement = ReactElement<Record<string, any>>
|
||||||
el: ReactElement<any, FC<any>>,
|
|
||||||
hook: (el: ReactNode) => ReactNode,
|
export type ElementVisitor = (
|
||||||
) => after(el, 'type', el => (el instanceof Promise ? el.then(hook) : hook(el)))
|
element: AnyElement,
|
||||||
|
) => Record<string, any> | undefined
|
||||||
|
|
||||||
|
/** Copies a rendered element tree, merging in the props the visitor returns. */
|
||||||
|
export function mapElementTree(
|
||||||
|
node: ReactNode,
|
||||||
|
visit: ElementVisitor,
|
||||||
|
): ReactNode {
|
||||||
|
if (Array.isArray(node)) {
|
||||||
|
let changed = false
|
||||||
|
|
||||||
|
const next = node.map(child => {
|
||||||
|
const mapped = mapElementTree(child, visit)
|
||||||
|
if (mapped !== child) changed = true
|
||||||
|
return mapped
|
||||||
|
})
|
||||||
|
|
||||||
|
return changed ? next : node
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!isValidElement(node)) return node
|
||||||
|
|
||||||
|
const element = node as AnyElement
|
||||||
|
const patch = visit(element)
|
||||||
|
|
||||||
|
// The visitor took over the subtree, so don't descend into it
|
||||||
|
if (patch && 'children' in patch) return cloneElement(element, patch)
|
||||||
|
|
||||||
|
const { children } = element.props
|
||||||
|
const nextChildren =
|
||||||
|
children === undefined ? children : mapElementTree(children, visit)
|
||||||
|
|
||||||
|
// Nothing below matched either, so hand back the very same element
|
||||||
|
if (!patch && nextChildren === children) return node
|
||||||
|
|
||||||
|
return cloneElement(
|
||||||
|
element,
|
||||||
|
nextChildren === children
|
||||||
|
? patch
|
||||||
|
: { ...patch, children: nextChildren },
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
const patchedComponents = new WeakMap<FC<any>, FC<any>>()
|
||||||
|
|
||||||
|
export type RenderPatch<P, C = void> = (
|
||||||
|
rendered: ReactNode,
|
||||||
|
props: P,
|
||||||
|
context: C,
|
||||||
|
) => ReactNode
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Creates and caches a component rendering `Component`, passing its output through `patch`.
|
||||||
|
*
|
||||||
|
* @param Component The component to wrap. Called directly, so its hooks belong to the wrapper.
|
||||||
|
* @param patch The patch to apply to the rendered output. Must not call hooks.
|
||||||
|
* @param useContext Provides whatever the patch needs from hooks. Always called, before the component renders.
|
||||||
|
* @returns The wrapper component.
|
||||||
|
*/
|
||||||
|
export function patchRender<P extends object, C = void>(
|
||||||
|
Component: FC<P>,
|
||||||
|
patch: RenderPatch<P, C>,
|
||||||
|
useContext?: () => C,
|
||||||
|
): FC<P> {
|
||||||
|
const cached = patchedComponents.get(Component)
|
||||||
|
if (cached) return cached
|
||||||
|
|
||||||
|
const Patched: FC<P> = props => {
|
||||||
|
const context = useContext?.() as C
|
||||||
|
const rendered = Component(props)
|
||||||
|
|
||||||
|
return rendered instanceof Promise
|
||||||
|
? rendered.then(node => patch(node, props, context))
|
||||||
|
: patch(rendered, props, context)
|
||||||
|
}
|
||||||
|
|
||||||
|
Object.defineProperty(Patched, 'name', {
|
||||||
|
value: `Patched(${Component.name || 'Component'})`,
|
||||||
|
})
|
||||||
|
|
||||||
|
patchedComponents.set(Component, Patched)
|
||||||
|
|
||||||
|
return Patched
|
||||||
|
}
|
||||||
|
|
|
||||||
Loading…
Add table
Reference in a new issue