Compare commits
7 commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
cecebe4c69 | ||
|
|
c8d820cb15 | ||
|
|
9525f98289 | ||
|
|
b1d65fbda2 | ||
|
|
858244cab9 | ||
|
|
8f028b5a56 | ||
|
|
2172bc862b |
16 changed files with 599 additions and 509 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
|
||||
|
|
@ -499,7 +503,6 @@ export namespace DiscordModules {
|
|||
extends TableRowGroupProps {
|
||||
children: ReactNode
|
||||
onChange: (value: T) => void
|
||||
value?: T
|
||||
defaultValue?: T
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -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" />
|
||||
}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
|
@ -25,6 +25,7 @@ import {
|
|||
} from '@revenge-mod/plugins/_'
|
||||
import {
|
||||
listRepoPlugins,
|
||||
listRepos,
|
||||
refreshRepo,
|
||||
} from '@revenge-mod/plugins/_/repositories'
|
||||
import { PluginStatus } from '@revenge-mod/plugins/constants'
|
||||
|
|
@ -38,11 +39,7 @@ import {
|
|||
showPluginClearDataConfirmation,
|
||||
showPluginUninstallConfirmation,
|
||||
} from '../utils/alerts'
|
||||
import {
|
||||
messageOf,
|
||||
runInstallFlowWithInternalRepos,
|
||||
showErrorToast,
|
||||
} from '../utils/repos'
|
||||
import { messageOf, runInstallFlow, showErrorToast } from '../utils/repos'
|
||||
import { InstalledPluginSwitch, PluginInfo } from './PluginCard'
|
||||
import { usePluginEnabled, usePluginStatus } from './PluginStateProvider'
|
||||
import PluginTooltipsProvider, {
|
||||
|
|
@ -50,6 +47,7 @@ import PluginTooltipsProvider, {
|
|||
usePluginTooltip,
|
||||
} from './TooltipProvider'
|
||||
import type { AnyPlugin } from '@revenge-mod/plugins/_'
|
||||
import type { RepoPluginListing } from '@revenge-mod/plugins/_/repositories'
|
||||
|
||||
export interface PluginOptionsActionSheetProps {
|
||||
plugin: AnyPlugin
|
||||
|
|
@ -133,8 +131,7 @@ function PluginOptions({ plugin, sheetKey }: PluginOptionsActionSheetProps) {
|
|||
}}
|
||||
/>
|
||||
<StatusSection plugin={plugin} />
|
||||
<ChannelSection plugin={plugin} />
|
||||
<AdvancedSection plugin={plugin} />
|
||||
<AdvancedSection plugin={plugin} sheetKey={sheetKey} />
|
||||
</Stack>
|
||||
)
|
||||
}
|
||||
|
|
@ -176,72 +173,92 @@ function StatusSection({ plugin }: { plugin: AnyPlugin }) {
|
|||
|
||||
function ChannelSection({
|
||||
plugin,
|
||||
sheetKey,
|
||||
}: {
|
||||
plugin: AnyPlugin
|
||||
sheetKey: string
|
||||
}) {
|
||||
const meta = getInternalPluginMeta(plugin)
|
||||
const source = meta.source
|
||||
if (isPluginInternal(meta) || !source?.repo) return null
|
||||
|
||||
const [channels, setChannels] = useState<Record<string, string>>({})
|
||||
const [selected, setSelected] = useState(source.channel)
|
||||
const [listing, setListing] = useState<RepoPluginListing | null>(null)
|
||||
const [resetKey, setResetKey] = useState(0)
|
||||
|
||||
useEffect(() => {
|
||||
listRepoPlugins(source.repo)
|
||||
.then(listings => {
|
||||
const listing = listings.find(l => l.id === plugin.manifest.id)
|
||||
if (listing) setChannels(listing.channels)
|
||||
})
|
||||
.catch(() =>
|
||||
refreshRepo(source.repo)
|
||||
.then(() => listRepoPlugins(source.repo))
|
||||
.then(listings => {
|
||||
const listing = listings.find(
|
||||
l => l.id === plugin.manifest.id,
|
||||
)
|
||||
if (listing) setChannels(listing.channels)
|
||||
})
|
||||
.catch(() => {}),
|
||||
)
|
||||
let cancelled = false
|
||||
;(async () => {
|
||||
try {
|
||||
const listings = await listRepoPlugins(source.repo)
|
||||
const found = listings.find(l => l.id === plugin.manifest.id)
|
||||
if (found && !cancelled) setListing(found)
|
||||
} catch {
|
||||
try {
|
||||
await refreshRepo(source.repo)
|
||||
const listings = await listRepoPlugins(source.repo)
|
||||
const found = listings.find(l => l.id === plugin.manifest.id)
|
||||
if (found && !cancelled) setListing(found)
|
||||
} catch {}
|
||||
}
|
||||
})()
|
||||
return () => {
|
||||
cancelled = true
|
||||
}
|
||||
}, [source.repo, plugin.manifest.id])
|
||||
|
||||
if (Object.keys(channels).length === 0) return null
|
||||
if (!listing || Object.keys(listing.channels).length === 0) return null
|
||||
|
||||
const handleChange = (value: string) => {
|
||||
setSelected(value)
|
||||
const handleChange = async (selected: string) => {
|
||||
if (selected === source.channel) return
|
||||
|
||||
const targetVersion = listing.channels[selected]
|
||||
if (!targetVersion) return
|
||||
|
||||
const targetVersion = channels[value]!
|
||||
if (targetVersion === plugin.manifest.version) {
|
||||
setResetKey(k => k + 1)
|
||||
ToastActionCreators.open({
|
||||
key: 'REVENGE_PLUGIN_VERSION_ALREADY_INSTALLED',
|
||||
content: 'This version is already installed',
|
||||
key: 'REVENGE_PLUGIN_CHANNEL_UP_TO_DATE',
|
||||
content: 'This channel is already installed',
|
||||
IconComponent: () => <TableRowAssetIcon name="CircleCheckIcon" />,
|
||||
})
|
||||
return
|
||||
}
|
||||
|
||||
runInstallFlowWithInternalRepos(
|
||||
const repos = await listRepos()
|
||||
const internalUrls = repos
|
||||
.filter(r => r.internal)
|
||||
.map(r => r.url)
|
||||
|
||||
ActionSheetActionCreators.hideActionSheet(sheetKey)
|
||||
runInstallFlow(
|
||||
plugin.manifest.id,
|
||||
undefined,
|
||||
value,
|
||||
source.repo,
|
||||
selected,
|
||||
[...internalUrls, source.repo],
|
||||
)
|
||||
}
|
||||
|
||||
return (
|
||||
<TableRadioGroup
|
||||
key={resetKey}
|
||||
title="Channel"
|
||||
value={selected}
|
||||
defaultValue={source.channel}
|
||||
onChange={v => handleChange(v as string)}
|
||||
>
|
||||
{Object.keys(channels).map(c => (
|
||||
{Object.keys(listing.channels).map(c => (
|
||||
<TableRadioRow key={c} label={c} value={c} />
|
||||
))}
|
||||
</TableRadioGroup>
|
||||
)
|
||||
}
|
||||
|
||||
function AdvancedSection({ plugin }: { plugin: AnyPlugin }) {
|
||||
function AdvancedSection({
|
||||
plugin,
|
||||
sheetKey,
|
||||
}: {
|
||||
plugin: AnyPlugin
|
||||
sheetKey: string
|
||||
}) {
|
||||
const meta = getInternalPluginMeta(plugin)
|
||||
const dependents = getPluginDependents(plugin, true)
|
||||
const dependencies = getPluginDependencies(plugin, false)
|
||||
|
|
@ -249,70 +266,73 @@ function AdvancedSection({ plugin }: { plugin: AnyPlugin }) {
|
|||
const { id, name } = plugin.manifest
|
||||
|
||||
return (
|
||||
<TableRowGroup title="Advanced">
|
||||
<IdRow id={id} />
|
||||
<RepositoryRow
|
||||
text={repositoryText}
|
||||
copyable={!!meta.source?.repo}
|
||||
/>
|
||||
<TableRow
|
||||
icon={<TableRowAssetIcon name="FlagIcon" />}
|
||||
label="Flags"
|
||||
subLabel={bitFieldToString(PluginFlags, meta.flags)}
|
||||
/>
|
||||
{meta.iflags > 0 && (
|
||||
<>
|
||||
<ChannelSection plugin={plugin} sheetKey={sheetKey} />
|
||||
<TableRowGroup title="Advanced">
|
||||
<IdRow id={id} />
|
||||
<RepositoryRow
|
||||
text={repositoryText}
|
||||
copyable={!!meta.source?.repo}
|
||||
/>
|
||||
<TableRow
|
||||
icon={<TableRowAssetIcon name="FlagIcon" />}
|
||||
label="Internal Flags"
|
||||
subLabel={bitFieldToString(
|
||||
InternalPluginFlags,
|
||||
meta.iflags,
|
||||
)}
|
||||
label="Flags"
|
||||
subLabel={bitFieldToString(PluginFlags, meta.flags)}
|
||||
/>
|
||||
)}
|
||||
{dependencies.length > 0 && (
|
||||
<TableRow
|
||||
icon={<TableRowAssetIcon name="ListBulletsIcon" />}
|
||||
label="Dependencies"
|
||||
subLabel={`${name} depends on ${dependencies.length} other plugins`}
|
||||
onPress={() => {
|
||||
ActionSheetActionCreators.openLazy(
|
||||
import('./PluginRelationsListActionSheet'),
|
||||
`plugin-deps-${id}`,
|
||||
{
|
||||
title: `Dependencies of ${name}`,
|
||||
unsatisfiedTitle: `Unsatisfied dependencies of ${name}`,
|
||||
unsatisfiedPlugins:
|
||||
meta.unsatisfiedOptionalDependencies.map(
|
||||
id => pList.get(id) ?? id,
|
||||
),
|
||||
plugins: dependencies,
|
||||
dependencyMap: plugin.manifest.dependencies!,
|
||||
},
|
||||
'stack',
|
||||
)
|
||||
}}
|
||||
/>
|
||||
)}
|
||||
{dependents.length > 0 && (
|
||||
<TableRow
|
||||
icon={<TableRowAssetIcon name="ListBulletsIcon" />}
|
||||
label="Dependents"
|
||||
subLabel={`${dependents.length} other plugins depend on ${name}`}
|
||||
onPress={() => {
|
||||
ActionSheetActionCreators.openLazy(
|
||||
import('./PluginRelationsListActionSheet'),
|
||||
`plugin-dependents-${id}`,
|
||||
{
|
||||
title: `Dependents of ${name}`,
|
||||
plugins: dependents,
|
||||
},
|
||||
'stack',
|
||||
)
|
||||
}}
|
||||
/>
|
||||
)}
|
||||
</TableRowGroup>
|
||||
{meta.iflags > 0 && (
|
||||
<TableRow
|
||||
icon={<TableRowAssetIcon name="FlagIcon" />}
|
||||
label="Internal Flags"
|
||||
subLabel={bitFieldToString(
|
||||
InternalPluginFlags,
|
||||
meta.iflags,
|
||||
)}
|
||||
/>
|
||||
)}
|
||||
{dependencies.length > 0 && (
|
||||
<TableRow
|
||||
icon={<TableRowAssetIcon name="ListBulletsIcon" />}
|
||||
label="Dependencies"
|
||||
subLabel={`${name} depends on ${dependencies.length} other plugins`}
|
||||
onPress={() => {
|
||||
ActionSheetActionCreators.openLazy(
|
||||
import('./PluginRelationsListActionSheet'),
|
||||
`plugin-deps-${id}`,
|
||||
{
|
||||
title: `Dependencies of ${name}`,
|
||||
unsatisfiedTitle: `Unsatisfied dependencies of ${name}`,
|
||||
unsatisfiedPlugins:
|
||||
meta.unsatisfiedOptionalDependencies.map(
|
||||
id => pList.get(id) ?? id,
|
||||
),
|
||||
plugins: dependencies,
|
||||
dependencyMap: plugin.manifest.dependencies!,
|
||||
},
|
||||
'stack',
|
||||
)
|
||||
}}
|
||||
/>
|
||||
)}
|
||||
{dependents.length > 0 && (
|
||||
<TableRow
|
||||
icon={<TableRowAssetIcon name="ListBulletsIcon" />}
|
||||
label="Dependents"
|
||||
subLabel={`${dependents.length} other plugins depend on ${name}`}
|
||||
onPress={() => {
|
||||
ActionSheetActionCreators.openLazy(
|
||||
import('./PluginRelationsListActionSheet'),
|
||||
`plugin-dependents-${id}`,
|
||||
{
|
||||
title: `Dependents of ${name}`,
|
||||
plugins: dependents,
|
||||
},
|
||||
'stack',
|
||||
)
|
||||
}}
|
||||
/>
|
||||
)}
|
||||
</TableRowGroup>
|
||||
</>
|
||||
)
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -170,21 +170,3 @@ export async function runInstallFlow(
|
|||
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,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