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 { withDependencies, withProps } from '@revenge-mod/modules/finders/filters'
|
||||
import { withDependencies } from '@revenge-mod/modules/finders/filters'
|
||||
import {
|
||||
ReactJSXRuntimeModuleId,
|
||||
ReactModuleId,
|
||||
|
|
@ -26,21 +26,25 @@ export let Design: Design = proxify(
|
|||
() => {
|
||||
// ID: 13171
|
||||
// [3909, 4608, 5182, 13172, 2, ...] (141 dependencies)
|
||||
const [module] = lookupModule(
|
||||
withProps('TableRow', 'Button').and(
|
||||
const [, id] = lookupModule(
|
||||
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 (module) return (Design = module)
|
||||
if (id === undefined) return
|
||||
|
||||
const module = __r(id)!
|
||||
if (module.TableRow && module.Button) 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 dispatch
|
||||
* @example Blocking the disptach
|
||||
* ```ts
|
||||
* // Returning falsy values will prevent the event from being dispatched.
|
||||
* onFluxEventDispatched('TYPING_START', () => {})
|
||||
|
|
|
|||
|
|
@ -152,14 +152,10 @@ export namespace DiscordModules {
|
|||
verboseDangerously(...args: unknown[]): void
|
||||
verbose(...args: unknown[]): void
|
||||
}
|
||||
|
||||
export namespace Actions {
|
||||
export interface AlertActionCreators {
|
||||
openAlert(
|
||||
key: string,
|
||||
alert: ReactElement,
|
||||
onDismiss?: () => unknown,
|
||||
options?: { dismissable?: boolean },
|
||||
): void
|
||||
openAlert(key: string, alert: ReactElement): void
|
||||
dismissAlert(key: string): void
|
||||
dismissAlerts(): void
|
||||
// TODO
|
||||
|
|
@ -503,6 +499,7 @@ 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, unknown][] = []
|
||||
export const mErrorChain: Metro.ModuleID[] = []
|
||||
|
||||
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, e])
|
||||
mErrorChain.push(moduleId)
|
||||
|
||||
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/_'
|
||||
import {
|
||||
listRepoPlugins,
|
||||
listRepos,
|
||||
refreshRepo,
|
||||
} from '@revenge-mod/plugins/_/repositories'
|
||||
import { PluginStatus } from '@revenge-mod/plugins/constants'
|
||||
|
|
@ -39,7 +38,11 @@ import {
|
|||
showPluginClearDataConfirmation,
|
||||
showPluginUninstallConfirmation,
|
||||
} from '../utils/alerts'
|
||||
import { messageOf, runInstallFlow, showErrorToast } from '../utils/repos'
|
||||
import {
|
||||
messageOf,
|
||||
runInstallFlowWithInternalRepos,
|
||||
showErrorToast,
|
||||
} from '../utils/repos'
|
||||
import { InstalledPluginSwitch, PluginInfo } from './PluginCard'
|
||||
import { usePluginEnabled, usePluginStatus } from './PluginStateProvider'
|
||||
import PluginTooltipsProvider, {
|
||||
|
|
@ -47,7 +50,6 @@ 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
|
||||
|
|
@ -131,7 +133,8 @@ function PluginOptions({ plugin, sheetKey }: PluginOptionsActionSheetProps) {
|
|||
}}
|
||||
/>
|
||||
<StatusSection plugin={plugin} />
|
||||
<AdvancedSection plugin={plugin} sheetKey={sheetKey} />
|
||||
<ChannelSection plugin={plugin} />
|
||||
<AdvancedSection plugin={plugin} />
|
||||
</Stack>
|
||||
)
|
||||
}
|
||||
|
|
@ -173,92 +176,72 @@ 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 [listing, setListing] = useState<RepoPluginListing | null>(null)
|
||||
const [resetKey, setResetKey] = useState(0)
|
||||
const [channels, setChannels] = useState<Record<string, string>>({})
|
||||
const [selected, setSelected] = useState(source.channel)
|
||||
|
||||
useEffect(() => {
|
||||
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
|
||||
}
|
||||
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(() => {}),
|
||||
)
|
||||
}, [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) => {
|
||||
if (selected === source.channel) return
|
||||
|
||||
const targetVersion = listing.channels[selected]
|
||||
if (!targetVersion) return
|
||||
const handleChange = (value: string) => {
|
||||
setSelected(value)
|
||||
|
||||
const targetVersion = channels[value]!
|
||||
if (targetVersion === plugin.manifest.version) {
|
||||
setResetKey(k => k + 1)
|
||||
ToastActionCreators.open({
|
||||
key: 'REVENGE_PLUGIN_CHANNEL_UP_TO_DATE',
|
||||
content: 'This channel is already installed',
|
||||
key: 'REVENGE_PLUGIN_VERSION_ALREADY_INSTALLED',
|
||||
content: 'This version is already installed',
|
||||
IconComponent: () => <TableRowAssetIcon name="CircleCheckIcon" />,
|
||||
})
|
||||
return
|
||||
}
|
||||
|
||||
const repos = await listRepos()
|
||||
const internalUrls = repos
|
||||
.filter(r => r.internal)
|
||||
.map(r => r.url)
|
||||
|
||||
ActionSheetActionCreators.hideActionSheet(sheetKey)
|
||||
runInstallFlow(
|
||||
runInstallFlowWithInternalRepos(
|
||||
plugin.manifest.id,
|
||||
undefined,
|
||||
selected,
|
||||
[...internalUrls, source.repo],
|
||||
value,
|
||||
source.repo,
|
||||
)
|
||||
}
|
||||
|
||||
return (
|
||||
<TableRadioGroup
|
||||
key={resetKey}
|
||||
title="Channel"
|
||||
defaultValue={source.channel}
|
||||
value={selected}
|
||||
onChange={v => handleChange(v as string)}
|
||||
>
|
||||
{Object.keys(listing.channels).map(c => (
|
||||
{Object.keys(channels).map(c => (
|
||||
<TableRadioRow key={c} label={c} value={c} />
|
||||
))}
|
||||
</TableRadioGroup>
|
||||
)
|
||||
}
|
||||
|
||||
function AdvancedSection({
|
||||
plugin,
|
||||
sheetKey,
|
||||
}: {
|
||||
plugin: AnyPlugin
|
||||
sheetKey: string
|
||||
}) {
|
||||
function AdvancedSection({ plugin }: { plugin: AnyPlugin }) {
|
||||
const meta = getInternalPluginMeta(plugin)
|
||||
const dependents = getPluginDependents(plugin, true)
|
||||
const dependencies = getPluginDependencies(plugin, false)
|
||||
|
|
@ -266,73 +249,70 @@ function AdvancedSection({
|
|||
const { id, name } = plugin.manifest
|
||||
|
||||
return (
|
||||
<>
|
||||
<ChannelSection plugin={plugin} sheetKey={sheetKey} />
|
||||
<TableRowGroup title="Advanced">
|
||||
<IdRow id={id} />
|
||||
<RepositoryRow
|
||||
text={repositoryText}
|
||||
copyable={!!meta.source?.repo}
|
||||
/>
|
||||
<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 && (
|
||||
<TableRow
|
||||
icon={<TableRowAssetIcon name="FlagIcon" />}
|
||||
label="Flags"
|
||||
subLabel={bitFieldToString(PluginFlags, meta.flags)}
|
||||
label="Internal Flags"
|
||||
subLabel={bitFieldToString(
|
||||
InternalPluginFlags,
|
||||
meta.iflags,
|
||||
)}
|
||||
/>
|
||||
{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>
|
||||
</>
|
||||
)}
|
||||
{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,3 +170,21 @@ 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,20 +20,13 @@ registerInternalPlugin(
|
|||
icon: 'StaffBadgeIcon',
|
||||
},
|
||||
{
|
||||
start({ cleanup, logger, plugin }) {
|
||||
start({ cleanup, logger }) {
|
||||
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
|
||||
|
|
@ -50,6 +43,12 @@ registerInternalPlugin(
|
|||
|
||||
store.initialize()
|
||||
unpatch()
|
||||
|
||||
ToastActionCreators.open({
|
||||
key: 'staff-settings-action',
|
||||
content: 'Navigate out of Settings to apply changes',
|
||||
IconComponent: CircleInformationIcon,
|
||||
})
|
||||
})
|
||||
}
|
||||
|
||||
|
|
@ -70,11 +69,9 @@ registerInternalPlugin(
|
|||
).getCurrentUser(),
|
||||
),
|
||||
reset,
|
||||
showToast,
|
||||
)
|
||||
|
||||
reset()
|
||||
if (plugin.startedLate) showToast()
|
||||
}),
|
||||
)
|
||||
},
|
||||
|
|
|
|||
|
|
@ -1,4 +1,8 @@
|
|||
{
|
||||
"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,
|
||||
registerInternalPlugin,
|
||||
} from '@revenge-mod/plugins/_'
|
||||
import { insteadJSX } from '@revenge-mod/react/jsx-runtime'
|
||||
import { createElement, isValidElement } from 'react'
|
||||
import { afterJSX, beforeJSX } from '@revenge-mod/react/jsx-runtime'
|
||||
import { findInReactFiber } from '@revenge-mod/utils/react'
|
||||
import { Image } from 'react-native'
|
||||
import { Badges, UsersWithBadges } from './constants'
|
||||
import { styles, useBadgeStyles } from './styles'
|
||||
import { mapElementTree, patchRender } from './utils'
|
||||
import type { FC, ReactElement, ReactNode } from 'react'
|
||||
import type { ImageSourcePropType } from 'react-native'
|
||||
import { afterRendered } from './utils'
|
||||
import type { FC, ReactElement } from 'react'
|
||||
import type {
|
||||
ImageProps,
|
||||
ImageSourcePropType,
|
||||
ImageStyle,
|
||||
PressableProps,
|
||||
StyleProp,
|
||||
} from 'react-native'
|
||||
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 {
|
||||
id: string
|
||||
|
|
@ -41,10 +46,9 @@ interface ProfileBadgeRowsProps {
|
|||
showToastOnPress?: boolean
|
||||
}
|
||||
|
||||
const DummyBadgeId = {} as unknown as string
|
||||
const DummyBadges = [
|
||||
{ id: DummyBadgeId },
|
||||
] as unknown as ProfileBadgeRowsProps['badges']
|
||||
type ViewWithProfileBadges = ReactElement<{
|
||||
children: Array<ReactElement<ProfileBadgeProps, FC<ProfileBadgeProps>>>
|
||||
}>
|
||||
|
||||
registerInternalPlugin(
|
||||
{
|
||||
|
|
@ -62,25 +66,28 @@ registerInternalPlugin(
|
|||
),
|
||||
({ ProfileBadgeRows }) => {
|
||||
cleanup(
|
||||
insteadJSX(
|
||||
ProfileBadgeRows,
|
||||
([type, props, key], jsx) => {
|
||||
if (
|
||||
!UsersWithBadges[props.userId] ||
|
||||
typeof type !== 'function'
|
||||
)
|
||||
return jsx(type, props, key)
|
||||
beforeJSX(ProfileBadgeRows, args => {
|
||||
const [, props] = args
|
||||
|
||||
// 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,
|
||||
)
|
||||
},
|
||||
),
|
||||
// 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
|
||||
}),
|
||||
)
|
||||
},
|
||||
)
|
||||
|
|
@ -96,102 +103,92 @@ registerInternalPlugin(
|
|||
InternalPluginFlags.Internal | InternalPluginFlags.Essential,
|
||||
)
|
||||
|
||||
/** 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
|
||||
function patchProfileBadgeRows(
|
||||
element: ReactElement<ProfileBadgeRowsProps, FC<ProfileBadgeRowsProps>>,
|
||||
): void {
|
||||
const unpatch = afterRendered(element, el => {
|
||||
unpatch()
|
||||
|
||||
const props = node.props as Partial<ProfileBadgeProps> | null
|
||||
return props != null && 'badgeSize' in props && 'userId' in props
|
||||
}
|
||||
const container = findInReactFiber(
|
||||
el as ReactElement,
|
||||
isViewContainingProfileBadgeElements,
|
||||
)
|
||||
|
||||
const injectCustomBadges: RenderPatch<ProfileBadgeRowsProps> = (
|
||||
rendered,
|
||||
{ userId },
|
||||
) => {
|
||||
const userBadges = UsersWithBadges[userId]
|
||||
if (!userBadges) return rendered
|
||||
if (!container) return el
|
||||
|
||||
return mapElementTree(rendered, element => {
|
||||
const { children } = element.props
|
||||
if (!Array.isArray(children)) return
|
||||
const [{ type: ProfileBadge, props }] = container.props.children
|
||||
const userBadges = UsersWithBadges[props.userId]
|
||||
|
||||
const template = children.find(isProfileBadgeElement)
|
||||
if (!template) return
|
||||
if (userBadges) {
|
||||
for (const badgeId of userBadges) {
|
||||
const badge = Badges[badgeId]
|
||||
if (!badge) continue
|
||||
|
||||
const ProfileBadge = template.type
|
||||
const customBadges: ReactElement[] = []
|
||||
const badgeElement = (
|
||||
<ProfileBadge
|
||||
{...props}
|
||||
key={badgeId}
|
||||
id={badgeId}
|
||||
label={badge.label}
|
||||
source={badge.icon}
|
||||
/>
|
||||
)
|
||||
|
||||
for (const id of userBadges) {
|
||||
const badge = Badges[id]
|
||||
if (!badge) continue
|
||||
patchProfileBadge(badgeElement, badgeId, badge)
|
||||
container.props.children.push(badgeElement)
|
||||
}
|
||||
|
||||
customBadges.push(
|
||||
createElement(
|
||||
patchRender(ProfileBadge, patchCustomBadge, useBadgeStyles),
|
||||
{
|
||||
...template.props,
|
||||
key: `revenge-badge-${id}`,
|
||||
id,
|
||||
label: badge.label,
|
||||
source: badge.icon,
|
||||
},
|
||||
),
|
||||
)
|
||||
// Remove dummy badge if it exists
|
||||
if (props.id === DUMMY_SYMBOL) container.props.children.shift()
|
||||
}
|
||||
|
||||
if (!customBadges.length) return
|
||||
|
||||
return {
|
||||
children: [
|
||||
...children.filter(
|
||||
child =>
|
||||
!isProfileBadgeElement(child) ||
|
||||
child.props.id !== DummyBadgeId,
|
||||
),
|
||||
...customBadges,
|
||||
],
|
||||
}
|
||||
return el
|
||||
})
|
||||
}
|
||||
|
||||
const patchCustomBadge: RenderPatch<ProfileBadgeProps, BadgeStyles> = (
|
||||
rendered,
|
||||
{ id },
|
||||
badgeStyles,
|
||||
) => {
|
||||
const badge = Badges[id as BadgeId]
|
||||
if (!badge) return rendered
|
||||
|
||||
function patchProfileBadge(
|
||||
element: ReactElement<ProfileBadgeProps, FC<ProfileBadgeProps>>,
|
||||
id: BadgeId,
|
||||
badge: Badge,
|
||||
): void {
|
||||
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
|
||||
let pressablePatched = false
|
||||
const unpatch = afterRendered(element, el => {
|
||||
unpatch()
|
||||
|
||||
// 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] }
|
||||
// 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)
|
||||
}
|
||||
|
||||
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),
|
||||
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)
|
||||
}
|
||||
}
|
||||
|
||||
return undefined
|
||||
return el
|
||||
})
|
||||
}
|
||||
|
||||
|
|
@ -200,10 +197,10 @@ const { AlertActionButton, AlertModal, Stack, Text } = Design
|
|||
function openBadgeDialog(
|
||||
id: BadgeId,
|
||||
{ label, description, bnw, icon }: Badge,
|
||||
badgeStyles: BadgeStyles,
|
||||
badgeStyles: ReturnType<typeof useBadgeStyles>,
|
||||
): void {
|
||||
AlertActionCreators.openAlert(
|
||||
`revenge-profile-badge-${id}`,
|
||||
`REVENGE_PROFILE_BADGE-${id}`,
|
||||
<AlertModal
|
||||
title={
|
||||
<Stack style={styles.stack}>
|
||||
|
|
@ -220,7 +217,12 @@ function openBadgeDialog(
|
|||
</Stack>
|
||||
}
|
||||
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'
|
||||
|
||||
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
|
||||
}
|
||||
export const afterRendered = (
|
||||
el: ReactElement<any, FC<any>>,
|
||||
hook: (el: ReactNode) => ReactNode,
|
||||
) => after(el, 'type', el => (el instanceof Promise ? el.then(hook) : hook(el)))
|
||||
|
|
|
|||
Loading…
Add table
Reference in a new issue