Compare commits
1 commit
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
4e444cf55d |
16 changed files with 509 additions and 599 deletions
|
|
@ -1,5 +1,5 @@
|
||||||
import { lookupModule, lookupModules } from '@revenge-mod/modules/finders'
|
import { lookupModule, lookupModules } from '@revenge-mod/modules/finders'
|
||||||
import { withDependencies, withProps } from '@revenge-mod/modules/finders/filters'
|
import { withDependencies } from '@revenge-mod/modules/finders/filters'
|
||||||
import {
|
import {
|
||||||
ReactJSXRuntimeModuleId,
|
ReactJSXRuntimeModuleId,
|
||||||
ReactModuleId,
|
ReactModuleId,
|
||||||
|
|
@ -26,21 +26,25 @@ export let Design: Design = proxify(
|
||||||
() => {
|
() => {
|
||||||
// ID: 13171
|
// ID: 13171
|
||||||
// [3909, 4608, 5182, 13172, 2, ...] (141 dependencies)
|
// [3909, 4608, 5182, 13172, 2, ...] (141 dependencies)
|
||||||
const [module] = lookupModule(
|
const [, id] = 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 (module) return (Design = module)
|
if (id === undefined) return
|
||||||
|
|
||||||
|
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 dispatch
|
* @example Blocking the disptach
|
||||||
* ```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,14 +152,10 @@ 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(
|
openAlert(key: string, alert: ReactElement): void
|
||||||
key: string,
|
|
||||||
alert: ReactElement,
|
|
||||||
onDismiss?: () => unknown,
|
|
||||||
options?: { dismissable?: boolean },
|
|
||||||
): void
|
|
||||||
dismissAlert(key: string): void
|
dismissAlert(key: string): void
|
||||||
dismissAlerts(): void
|
dismissAlerts(): void
|
||||||
// TODO
|
// TODO
|
||||||
|
|
@ -503,6 +499,7 @@ export namespace DiscordModules {
|
||||||
extends TableRowGroupProps {
|
extends TableRowGroupProps {
|
||||||
children: ReactNode
|
children: ReactNode
|
||||||
onChange: (value: T) => void
|
onChange: (value: T) => void
|
||||||
|
value?: T
|
||||||
defaultValue?: T
|
defaultValue?: T
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -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, unknown][] = []
|
export const mErrorChain: Metro.ModuleID[] = []
|
||||||
|
|
||||||
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, e])
|
mErrorChain.push(moduleId)
|
||||||
|
|
||||||
return (moduleObject.exports = {})
|
return (moduleObject.exports = {})
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -1,50 +0,0 @@
|
||||||
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" />
|
|
||||||
}
|
|
||||||
/>
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|
@ -1,190 +0,0 @@
|
||||||
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
|
|
||||||
}
|
|
||||||
|
|
@ -1,13 +0,0 @@
|
||||||
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,
|
|
||||||
)
|
|
||||||
})
|
|
||||||
244
src/plugins/start/recovery/index.tsx
Normal file
244
src/plugins/start/recovery/index.tsx
Normal file
|
|
@ -0,0 +1,244 @@
|
||||||
|
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" />
|
||||||
|
}
|
||||||
|
/>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
@ -25,7 +25,6 @@ import {
|
||||||
} from '@revenge-mod/plugins/_'
|
} from '@revenge-mod/plugins/_'
|
||||||
import {
|
import {
|
||||||
listRepoPlugins,
|
listRepoPlugins,
|
||||||
listRepos,
|
|
||||||
refreshRepo,
|
refreshRepo,
|
||||||
} from '@revenge-mod/plugins/_/repositories'
|
} from '@revenge-mod/plugins/_/repositories'
|
||||||
import { PluginStatus } from '@revenge-mod/plugins/constants'
|
import { PluginStatus } from '@revenge-mod/plugins/constants'
|
||||||
|
|
@ -39,7 +38,11 @@ import {
|
||||||
showPluginClearDataConfirmation,
|
showPluginClearDataConfirmation,
|
||||||
showPluginUninstallConfirmation,
|
showPluginUninstallConfirmation,
|
||||||
} from '../utils/alerts'
|
} from '../utils/alerts'
|
||||||
import { messageOf, runInstallFlow, showErrorToast } from '../utils/repos'
|
import {
|
||||||
|
messageOf,
|
||||||
|
runInstallFlowWithInternalRepos,
|
||||||
|
showErrorToast,
|
||||||
|
} from '../utils/repos'
|
||||||
import { InstalledPluginSwitch, PluginInfo } from './PluginCard'
|
import { InstalledPluginSwitch, PluginInfo } from './PluginCard'
|
||||||
import { usePluginEnabled, usePluginStatus } from './PluginStateProvider'
|
import { usePluginEnabled, usePluginStatus } from './PluginStateProvider'
|
||||||
import PluginTooltipsProvider, {
|
import PluginTooltipsProvider, {
|
||||||
|
|
@ -47,7 +50,6 @@ import PluginTooltipsProvider, {
|
||||||
usePluginTooltip,
|
usePluginTooltip,
|
||||||
} from './TooltipProvider'
|
} from './TooltipProvider'
|
||||||
import type { AnyPlugin } from '@revenge-mod/plugins/_'
|
import type { AnyPlugin } from '@revenge-mod/plugins/_'
|
||||||
import type { RepoPluginListing } from '@revenge-mod/plugins/_/repositories'
|
|
||||||
|
|
||||||
export interface PluginOptionsActionSheetProps {
|
export interface PluginOptionsActionSheetProps {
|
||||||
plugin: AnyPlugin
|
plugin: AnyPlugin
|
||||||
|
|
@ -131,7 +133,8 @@ function PluginOptions({ plugin, sheetKey }: PluginOptionsActionSheetProps) {
|
||||||
}}
|
}}
|
||||||
/>
|
/>
|
||||||
<StatusSection plugin={plugin} />
|
<StatusSection plugin={plugin} />
|
||||||
<AdvancedSection plugin={plugin} sheetKey={sheetKey} />
|
<ChannelSection plugin={plugin} />
|
||||||
|
<AdvancedSection plugin={plugin} />
|
||||||
</Stack>
|
</Stack>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
@ -173,92 +176,72 @@ function StatusSection({ plugin }: { plugin: AnyPlugin }) {
|
||||||
|
|
||||||
function ChannelSection({
|
function ChannelSection({
|
||||||
plugin,
|
plugin,
|
||||||
sheetKey,
|
|
||||||
}: {
|
}: {
|
||||||
plugin: AnyPlugin
|
plugin: AnyPlugin
|
||||||
sheetKey: string
|
|
||||||
}) {
|
}) {
|
||||||
const meta = getInternalPluginMeta(plugin)
|
const meta = getInternalPluginMeta(plugin)
|
||||||
const source = meta.source
|
const source = meta.source
|
||||||
if (isPluginInternal(meta) || !source?.repo) return null
|
if (isPluginInternal(meta) || !source?.repo) return null
|
||||||
|
|
||||||
const [listing, setListing] = useState<RepoPluginListing | null>(null)
|
const [channels, setChannels] = useState<Record<string, string>>({})
|
||||||
const [resetKey, setResetKey] = useState(0)
|
const [selected, setSelected] = useState(source.channel)
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
let cancelled = false
|
listRepoPlugins(source.repo)
|
||||||
;(async () => {
|
.then(listings => {
|
||||||
try {
|
const listing = listings.find(l => l.id === plugin.manifest.id)
|
||||||
const listings = await listRepoPlugins(source.repo)
|
if (listing) setChannels(listing.channels)
|
||||||
const found = listings.find(l => l.id === plugin.manifest.id)
|
})
|
||||||
if (found && !cancelled) setListing(found)
|
.catch(() =>
|
||||||
} catch {
|
refreshRepo(source.repo)
|
||||||
try {
|
.then(() => listRepoPlugins(source.repo))
|
||||||
await refreshRepo(source.repo)
|
.then(listings => {
|
||||||
const listings = await listRepoPlugins(source.repo)
|
const listing = listings.find(
|
||||||
const found = listings.find(l => l.id === plugin.manifest.id)
|
l => l.id === plugin.manifest.id,
|
||||||
if (found && !cancelled) setListing(found)
|
)
|
||||||
} catch {}
|
if (listing) setChannels(listing.channels)
|
||||||
}
|
})
|
||||||
})()
|
.catch(() => {}),
|
||||||
return () => {
|
)
|
||||||
cancelled = true
|
|
||||||
}
|
|
||||||
}, [source.repo, plugin.manifest.id])
|
}, [source.repo, plugin.manifest.id])
|
||||||
|
|
||||||
if (!listing || Object.keys(listing.channels).length === 0) return null
|
if (Object.keys(channels).length === 0) return null
|
||||||
|
|
||||||
const handleChange = async (selected: string) => {
|
const handleChange = (value: string) => {
|
||||||
if (selected === source.channel) return
|
setSelected(value)
|
||||||
|
|
||||||
const targetVersion = listing.channels[selected]
|
|
||||||
if (!targetVersion) return
|
|
||||||
|
|
||||||
|
const targetVersion = channels[value]!
|
||||||
if (targetVersion === plugin.manifest.version) {
|
if (targetVersion === plugin.manifest.version) {
|
||||||
setResetKey(k => k + 1)
|
|
||||||
ToastActionCreators.open({
|
ToastActionCreators.open({
|
||||||
key: 'REVENGE_PLUGIN_CHANNEL_UP_TO_DATE',
|
key: 'REVENGE_PLUGIN_VERSION_ALREADY_INSTALLED',
|
||||||
content: 'This channel is already installed',
|
content: 'This version is already installed',
|
||||||
IconComponent: () => <TableRowAssetIcon name="CircleCheckIcon" />,
|
IconComponent: () => <TableRowAssetIcon name="CircleCheckIcon" />,
|
||||||
})
|
})
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
const repos = await listRepos()
|
runInstallFlowWithInternalRepos(
|
||||||
const internalUrls = repos
|
|
||||||
.filter(r => r.internal)
|
|
||||||
.map(r => r.url)
|
|
||||||
|
|
||||||
ActionSheetActionCreators.hideActionSheet(sheetKey)
|
|
||||||
runInstallFlow(
|
|
||||||
plugin.manifest.id,
|
plugin.manifest.id,
|
||||||
undefined,
|
undefined,
|
||||||
selected,
|
value,
|
||||||
[...internalUrls, source.repo],
|
source.repo,
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<TableRadioGroup
|
<TableRadioGroup
|
||||||
key={resetKey}
|
|
||||||
title="Channel"
|
title="Channel"
|
||||||
defaultValue={source.channel}
|
value={selected}
|
||||||
onChange={v => handleChange(v as string)}
|
onChange={v => handleChange(v as string)}
|
||||||
>
|
>
|
||||||
{Object.keys(listing.channels).map(c => (
|
{Object.keys(channels).map(c => (
|
||||||
<TableRadioRow key={c} label={c} value={c} />
|
<TableRadioRow key={c} label={c} value={c} />
|
||||||
))}
|
))}
|
||||||
</TableRadioGroup>
|
</TableRadioGroup>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
function AdvancedSection({
|
function AdvancedSection({ plugin }: { plugin: AnyPlugin }) {
|
||||||
plugin,
|
|
||||||
sheetKey,
|
|
||||||
}: {
|
|
||||||
plugin: AnyPlugin
|
|
||||||
sheetKey: string
|
|
||||||
}) {
|
|
||||||
const meta = getInternalPluginMeta(plugin)
|
const meta = getInternalPluginMeta(plugin)
|
||||||
const dependents = getPluginDependents(plugin, true)
|
const dependents = getPluginDependents(plugin, true)
|
||||||
const dependencies = getPluginDependencies(plugin, false)
|
const dependencies = getPluginDependencies(plugin, false)
|
||||||
|
|
@ -266,73 +249,70 @@ function AdvancedSection({
|
||||||
const { id, name } = plugin.manifest
|
const { id, name } = plugin.manifest
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<>
|
<TableRowGroup title="Advanced">
|
||||||
<ChannelSection plugin={plugin} sheetKey={sheetKey} />
|
<IdRow id={id} />
|
||||||
<TableRowGroup title="Advanced">
|
<RepositoryRow
|
||||||
<IdRow id={id} />
|
text={repositoryText}
|
||||||
<RepositoryRow
|
copyable={!!meta.source?.repo}
|
||||||
text={repositoryText}
|
/>
|
||||||
copyable={!!meta.source?.repo}
|
<TableRow
|
||||||
/>
|
icon={<TableRowAssetIcon name="FlagIcon" />}
|
||||||
|
label="Flags"
|
||||||
|
subLabel={bitFieldToString(PluginFlags, meta.flags)}
|
||||||
|
/>
|
||||||
|
{meta.iflags > 0 && (
|
||||||
<TableRow
|
<TableRow
|
||||||
icon={<TableRowAssetIcon name="FlagIcon" />}
|
icon={<TableRowAssetIcon name="FlagIcon" />}
|
||||||
label="Flags"
|
label="Internal Flags"
|
||||||
subLabel={bitFieldToString(PluginFlags, meta.flags)}
|
subLabel={bitFieldToString(
|
||||||
|
InternalPluginFlags,
|
||||||
|
meta.iflags,
|
||||||
|
)}
|
||||||
/>
|
/>
|
||||||
{meta.iflags > 0 && (
|
)}
|
||||||
<TableRow
|
{dependencies.length > 0 && (
|
||||||
icon={<TableRowAssetIcon name="FlagIcon" />}
|
<TableRow
|
||||||
label="Internal Flags"
|
icon={<TableRowAssetIcon name="ListBulletsIcon" />}
|
||||||
subLabel={bitFieldToString(
|
label="Dependencies"
|
||||||
InternalPluginFlags,
|
subLabel={`${name} depends on ${dependencies.length} other plugins`}
|
||||||
meta.iflags,
|
onPress={() => {
|
||||||
)}
|
ActionSheetActionCreators.openLazy(
|
||||||
/>
|
import('./PluginRelationsListActionSheet'),
|
||||||
)}
|
`plugin-deps-${id}`,
|
||||||
{dependencies.length > 0 && (
|
{
|
||||||
<TableRow
|
title: `Dependencies of ${name}`,
|
||||||
icon={<TableRowAssetIcon name="ListBulletsIcon" />}
|
unsatisfiedTitle: `Unsatisfied dependencies of ${name}`,
|
||||||
label="Dependencies"
|
unsatisfiedPlugins:
|
||||||
subLabel={`${name} depends on ${dependencies.length} other plugins`}
|
meta.unsatisfiedOptionalDependencies.map(
|
||||||
onPress={() => {
|
id => pList.get(id) ?? id,
|
||||||
ActionSheetActionCreators.openLazy(
|
),
|
||||||
import('./PluginRelationsListActionSheet'),
|
plugins: dependencies,
|
||||||
`plugin-deps-${id}`,
|
dependencyMap: plugin.manifest.dependencies!,
|
||||||
{
|
},
|
||||||
title: `Dependencies of ${name}`,
|
'stack',
|
||||||
unsatisfiedTitle: `Unsatisfied dependencies of ${name}`,
|
)
|
||||||
unsatisfiedPlugins:
|
}}
|
||||||
meta.unsatisfiedOptionalDependencies.map(
|
/>
|
||||||
id => pList.get(id) ?? id,
|
)}
|
||||||
),
|
{dependents.length > 0 && (
|
||||||
plugins: dependencies,
|
<TableRow
|
||||||
dependencyMap: plugin.manifest.dependencies!,
|
icon={<TableRowAssetIcon name="ListBulletsIcon" />}
|
||||||
},
|
label="Dependents"
|
||||||
'stack',
|
subLabel={`${dependents.length} other plugins depend on ${name}`}
|
||||||
)
|
onPress={() => {
|
||||||
}}
|
ActionSheetActionCreators.openLazy(
|
||||||
/>
|
import('./PluginRelationsListActionSheet'),
|
||||||
)}
|
`plugin-dependents-${id}`,
|
||||||
{dependents.length > 0 && (
|
{
|
||||||
<TableRow
|
title: `Dependents of ${name}`,
|
||||||
icon={<TableRowAssetIcon name="ListBulletsIcon" />}
|
plugins: dependents,
|
||||||
label="Dependents"
|
},
|
||||||
subLabel={`${dependents.length} other plugins depend on ${name}`}
|
'stack',
|
||||||
onPress={() => {
|
)
|
||||||
ActionSheetActionCreators.openLazy(
|
}}
|
||||||
import('./PluginRelationsListActionSheet'),
|
/>
|
||||||
`plugin-dependents-${id}`,
|
)}
|
||||||
{
|
</TableRowGroup>
|
||||||
title: `Dependents of ${name}`,
|
|
||||||
plugins: dependents,
|
|
||||||
},
|
|
||||||
'stack',
|
|
||||||
)
|
|
||||||
}}
|
|
||||||
/>
|
|
||||||
)}
|
|
||||||
</TableRowGroup>
|
|
||||||
</>
|
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -170,3 +170,21 @@ export async function runInstallFlow(
|
||||||
return false
|
return false
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Same as {@link runInstallFlow}, but automatically includes every internal repository plus
|
||||||
|
* the target repository in `filteredRepos`, so repo-installed plugins can be updated in place.
|
||||||
|
*/
|
||||||
|
export async function runInstallFlowWithInternalRepos(
|
||||||
|
id: string,
|
||||||
|
version?: string,
|
||||||
|
channel?: string,
|
||||||
|
repo?: string,
|
||||||
|
): Promise<boolean> {
|
||||||
|
const repos = await listRepos()
|
||||||
|
const filteredRepos = repos
|
||||||
|
.filter(r => r.internal)
|
||||||
|
.map(r => r.url)
|
||||||
|
if (repo) filteredRepos.push(repo)
|
||||||
|
return runInstallFlow(id, version, channel, filteredRepos)
|
||||||
|
}
|
||||||
|
|
|
||||||
|
|
@ -20,20 +20,13 @@ registerInternalPlugin(
|
||||||
icon: 'StaffBadgeIcon',
|
icon: 'StaffBadgeIcon',
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
start({ cleanup, logger, plugin }) {
|
start({ cleanup, logger }) {
|
||||||
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
|
||||||
|
|
@ -50,6 +43,12 @@ registerInternalPlugin(
|
||||||
|
|
||||||
store.initialize()
|
store.initialize()
|
||||||
unpatch()
|
unpatch()
|
||||||
|
|
||||||
|
ToastActionCreators.open({
|
||||||
|
key: 'staff-settings-action',
|
||||||
|
content: 'Navigate out of Settings to apply changes',
|
||||||
|
IconComponent: CircleInformationIcon,
|
||||||
|
})
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -70,11 +69,9 @@ registerInternalPlugin(
|
||||||
).getCurrentUser(),
|
).getCurrentUser(),
|
||||||
),
|
),
|
||||||
reset,
|
reset,
|
||||||
showToast,
|
|
||||||
)
|
)
|
||||||
|
|
||||||
reset()
|
reset()
|
||||||
if (plugin.startedLate) showToast()
|
|
||||||
}),
|
}),
|
||||||
)
|
)
|
||||||
},
|
},
|
||||||
|
|
|
||||||
|
|
@ -1,4 +1,8 @@
|
||||||
{
|
{
|
||||||
"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,18 +7,23 @@ import {
|
||||||
PluginFlags,
|
PluginFlags,
|
||||||
registerInternalPlugin,
|
registerInternalPlugin,
|
||||||
} from '@revenge-mod/plugins/_'
|
} from '@revenge-mod/plugins/_'
|
||||||
import { insteadJSX } from '@revenge-mod/react/jsx-runtime'
|
import { afterJSX, beforeJSX } from '@revenge-mod/react/jsx-runtime'
|
||||||
import { createElement, isValidElement } from 'react'
|
import { findInReactFiber } from '@revenge-mod/utils/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 { mapElementTree, patchRender } from './utils'
|
import { afterRendered } from './utils'
|
||||||
import type { FC, ReactElement, ReactNode } from 'react'
|
import type { FC, ReactElement } from 'react'
|
||||||
import type { ImageSourcePropType } from 'react-native'
|
import type {
|
||||||
|
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'
|
|
||||||
|
|
||||||
type BadgeStyles = ReturnType<typeof useBadgeStyles>
|
const DUMMY_SYMBOL = {} as unknown as string
|
||||||
|
|
||||||
interface ProfileBadgeProps {
|
interface ProfileBadgeProps {
|
||||||
id: string
|
id: string
|
||||||
|
|
@ -41,10 +46,9 @@ interface ProfileBadgeRowsProps {
|
||||||
showToastOnPress?: boolean
|
showToastOnPress?: boolean
|
||||||
}
|
}
|
||||||
|
|
||||||
const DummyBadgeId = {} as unknown as string
|
type ViewWithProfileBadges = ReactElement<{
|
||||||
const DummyBadges = [
|
children: Array<ReactElement<ProfileBadgeProps, FC<ProfileBadgeProps>>>
|
||||||
{ id: DummyBadgeId },
|
}>
|
||||||
] as unknown as ProfileBadgeRowsProps['badges']
|
|
||||||
|
|
||||||
registerInternalPlugin(
|
registerInternalPlugin(
|
||||||
{
|
{
|
||||||
|
|
@ -62,25 +66,28 @@ registerInternalPlugin(
|
||||||
),
|
),
|
||||||
({ ProfileBadgeRows }) => {
|
({ ProfileBadgeRows }) => {
|
||||||
cleanup(
|
cleanup(
|
||||||
insteadJSX(
|
beforeJSX(ProfileBadgeRows, args => {
|
||||||
ProfileBadgeRows,
|
const [, props] = args
|
||||||
([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
|
||||||
return jsx(
|
if (
|
||||||
patchRender(type, injectCustomBadges),
|
// has custom badges
|
||||||
props.badges.length
|
UsersWithBadges[props.userId] &&
|
||||||
? props
|
// but no actual badges
|
||||||
: { ...props, badges: DummyBadges },
|
!props.badges.length
|
||||||
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
|
||||||
|
}),
|
||||||
)
|
)
|
||||||
},
|
},
|
||||||
)
|
)
|
||||||
|
|
@ -96,102 +103,92 @@ registerInternalPlugin(
|
||||||
InternalPluginFlags.Internal | InternalPluginFlags.Essential,
|
InternalPluginFlags.Internal | InternalPluginFlags.Essential,
|
||||||
)
|
)
|
||||||
|
|
||||||
/** Matched structurally, as minified builds don't preserve `type.name`. */
|
function patchProfileBadgeRows(
|
||||||
function isProfileBadgeElement(
|
element: ReactElement<ProfileBadgeRowsProps, FC<ProfileBadgeRowsProps>>,
|
||||||
node: ReactNode,
|
): void {
|
||||||
): node is ReactElement<ProfileBadgeProps, FC<ProfileBadgeProps>> {
|
const unpatch = afterRendered(element, el => {
|
||||||
if (!isValidElement(node) || typeof node.type !== 'function') return false
|
unpatch()
|
||||||
|
|
||||||
const props = node.props as Partial<ProfileBadgeProps> | null
|
const container = findInReactFiber(
|
||||||
return props != null && 'badgeSize' in props && 'userId' in props
|
el as ReactElement,
|
||||||
}
|
isViewContainingProfileBadgeElements,
|
||||||
|
)
|
||||||
|
|
||||||
const injectCustomBadges: RenderPatch<ProfileBadgeRowsProps> = (
|
if (!container) return el
|
||||||
rendered,
|
|
||||||
{ userId },
|
|
||||||
) => {
|
|
||||||
const userBadges = UsersWithBadges[userId]
|
|
||||||
if (!userBadges) return rendered
|
|
||||||
|
|
||||||
return mapElementTree(rendered, element => {
|
const [{ type: ProfileBadge, props }] = container.props.children
|
||||||
const { children } = element.props
|
const userBadges = UsersWithBadges[props.userId]
|
||||||
if (!Array.isArray(children)) return
|
|
||||||
|
|
||||||
const template = children.find(isProfileBadgeElement)
|
if (userBadges) {
|
||||||
if (!template) return
|
for (const badgeId of userBadges) {
|
||||||
|
const badge = Badges[badgeId]
|
||||||
|
if (!badge) continue
|
||||||
|
|
||||||
const ProfileBadge = template.type
|
const badgeElement = (
|
||||||
const customBadges: ReactElement[] = []
|
<ProfileBadge
|
||||||
|
{...props}
|
||||||
|
key={badgeId}
|
||||||
|
id={badgeId}
|
||||||
|
label={badge.label}
|
||||||
|
source={badge.icon}
|
||||||
|
/>
|
||||||
|
)
|
||||||
|
|
||||||
for (const id of userBadges) {
|
patchProfileBadge(badgeElement, badgeId, badge)
|
||||||
const badge = Badges[id]
|
container.props.children.push(badgeElement)
|
||||||
if (!badge) continue
|
}
|
||||||
|
|
||||||
customBadges.push(
|
// Remove dummy badge if it exists
|
||||||
createElement(
|
if (props.id === DUMMY_SYMBOL) container.props.children.shift()
|
||||||
patchRender(ProfileBadge, patchCustomBadge, useBadgeStyles),
|
|
||||||
{
|
|
||||||
...template.props,
|
|
||||||
key: `revenge-badge-${id}`,
|
|
||||||
id,
|
|
||||||
label: badge.label,
|
|
||||||
source: badge.icon,
|
|
||||||
},
|
|
||||||
),
|
|
||||||
)
|
|
||||||
}
|
}
|
||||||
|
|
||||||
if (!customBadges.length) return
|
return el
|
||||||
|
|
||||||
return {
|
|
||||||
children: [
|
|
||||||
...children.filter(
|
|
||||||
child =>
|
|
||||||
!isProfileBadgeElement(child) ||
|
|
||||||
child.props.id !== DummyBadgeId,
|
|
||||||
),
|
|
||||||
...customBadges,
|
|
||||||
],
|
|
||||||
}
|
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
const patchCustomBadge: RenderPatch<ProfileBadgeProps, BadgeStyles> = (
|
function patchProfileBadge(
|
||||||
rendered,
|
element: ReactElement<ProfileBadgeProps, FC<ProfileBadgeProps>>,
|
||||||
{ id },
|
id: BadgeId,
|
||||||
badgeStyles,
|
badge: Badge,
|
||||||
) => {
|
): void {
|
||||||
const badge = Badges[id as BadgeId]
|
|
||||||
if (!badge) return rendered
|
|
||||||
|
|
||||||
const { bnw, showDialog } = badge
|
const { bnw, showDialog } = badge
|
||||||
if (!bnw && !showDialog) return rendered
|
// Return early if we don't actually need to patch this badge
|
||||||
|
if (!bnw && !showDialog) return
|
||||||
|
|
||||||
let tinted = false
|
const unpatch = afterRendered(element, el => {
|
||||||
let pressablePatched = false
|
unpatch()
|
||||||
|
|
||||||
// Only patched the most shallow Pressable
|
// This will not result in a conditional hook error because badges do not change flags during runtime
|
||||||
return mapElementTree(rendered, element => {
|
const badgeStyles = useBadgeStyles()
|
||||||
if (bnw && !tinted && element.type === Image) {
|
|
||||||
tinted = true
|
if (showDialog) {
|
||||||
return { style: [element.props.style, badgeStyles.tinted] }
|
const pressable = findInReactFiber(
|
||||||
|
el as ReactElement,
|
||||||
|
(node): node is ReactElement<PressableProps> =>
|
||||||
|
node.type?.render,
|
||||||
|
)
|
||||||
|
|
||||||
|
if (pressable)
|
||||||
|
pressable.props.onPress = () =>
|
||||||
|
openBadgeDialog(id, badge, badgeStyles)
|
||||||
}
|
}
|
||||||
|
|
||||||
if (
|
if (bnw) {
|
||||||
showDialog &&
|
const image = findInReactFiber(
|
||||||
!pressablePatched &&
|
el as ReactElement,
|
||||||
// Match by behaviour, as matching .type === Image picks whichever wrapper component happens to come first
|
(node): node is ReactElement<ImageProps> => node.type === Image,
|
||||||
('onPress' in element.props ||
|
)
|
||||||
element.props.accessibilityRole === 'button')
|
|
||||||
) {
|
if (image) {
|
||||||
pressablePatched = true
|
const styles = image.props.style as Extract<
|
||||||
return {
|
StyleProp<ImageStyle>,
|
||||||
onPress: () =>
|
any[]
|
||||||
openBadgeDialog(id as BadgeId, badge, badgeStyles),
|
>
|
||||||
|
styles.push(badgeStyles.tinted)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
return undefined
|
return el
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -200,10 +197,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: BadgeStyles,
|
badgeStyles: ReturnType<typeof useBadgeStyles>,
|
||||||
): 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}>
|
||||||
|
|
@ -220,7 +217,12 @@ function openBadgeDialog(
|
||||||
</Stack>
|
</Stack>
|
||||||
}
|
}
|
||||||
content={description}
|
content={description}
|
||||||
actions={<AlertActionButton text="OK" />}
|
actions={<AlertActionButton text="Okay" />}
|
||||||
/>,
|
/>,
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const isViewContainingProfileBadgeElements = (
|
||||||
|
node: any,
|
||||||
|
): node is ViewWithProfileBadges =>
|
||||||
|
node.props?.children?.[0]?.type.name === 'ProfileBadge'
|
||||||
|
|
|
||||||
|
|
@ -1,90 +1,7 @@
|
||||||
import { cloneElement, isValidElement } from 'react'
|
import { after } from '@revenge-mod/patcher'
|
||||||
import type { FC, ReactElement, ReactNode } from 'react'
|
import type { FC, ReactElement, ReactNode } from 'react'
|
||||||
|
|
||||||
type AnyElement = ReactElement<Record<string, any>>
|
export const afterRendered = (
|
||||||
|
el: ReactElement<any, FC<any>>,
|
||||||
export type ElementVisitor = (
|
hook: (el: ReactNode) => ReactNode,
|
||||||
element: AnyElement,
|
) => after(el, 'type', el => (el instanceof Promise ? el.then(hook) : hook(el)))
|
||||||
) => 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