Compare commits
No commits in common. "hosting" and "main" have entirely different histories.
51 changed files with 1785 additions and 1423 deletions
54
.github/workflows/host-bundle.yml
vendored
54
.github/workflows/host-bundle.yml
vendored
|
|
@ -1,54 +0,0 @@
|
||||||
name: Host Revenge Bundle
|
|
||||||
|
|
||||||
on:
|
|
||||||
workflow_dispatch:
|
|
||||||
push:
|
|
||||||
branches:
|
|
||||||
- main
|
|
||||||
|
|
||||||
jobs:
|
|
||||||
build-and-host:
|
|
||||||
runs-on: ubuntu-latest
|
|
||||||
permissions:
|
|
||||||
contents: write
|
|
||||||
steps:
|
|
||||||
- name: Checkout
|
|
||||||
uses: actions/checkout@v4
|
|
||||||
with:
|
|
||||||
fetch-depth: 0
|
|
||||||
|
|
||||||
- name: Restore Bun cache
|
|
||||||
uses: actions/cache@v4
|
|
||||||
with:
|
|
||||||
path: ~/.bun/install/cache
|
|
||||||
key: ${{ runner.os }}-bun-${{ hashFiles('**/bun.lock') }}
|
|
||||||
restore-keys: |
|
|
||||||
${{ runner.os }}-bun-
|
|
||||||
|
|
||||||
- name: Setup Bun
|
|
||||||
uses: oven-sh/setup-bun@v2
|
|
||||||
with:
|
|
||||||
bun-version: latest
|
|
||||||
|
|
||||||
- name: Install dependencies
|
|
||||||
run: bun install --frozen-lockfile --production
|
|
||||||
|
|
||||||
- name: Build production bundle
|
|
||||||
run: bun run build
|
|
||||||
|
|
||||||
- name: Publish bundle to hosting branch
|
|
||||||
run: |
|
|
||||||
cp dist/revenge.bundle /tmp/revenge.bundle
|
|
||||||
git config --global user.name "github-actions[bot]"
|
|
||||||
git config --global user.email "41898282+github-actions[bot]@users.noreply.github.com"
|
|
||||||
if git show-ref --verify --quiet "refs/heads/hosting"; then
|
|
||||||
git checkout hosting
|
|
||||||
git rm -rf .
|
|
||||||
else
|
|
||||||
git checkout --orphan hosting
|
|
||||||
fi
|
|
||||||
rm -rf *
|
|
||||||
cp /tmp/revenge.bundle ./revenge.bundle
|
|
||||||
git add revenge.bundle
|
|
||||||
git commit -m "Host bundle from ${GITHUB_SHA}" || true
|
|
||||||
git push --force origin hosting
|
|
||||||
|
|
@ -1,5 +0,0 @@
|
||||||
# Contributors
|
|
||||||
|
|
||||||
Thanks to everyone who has helped make Revenge Next better!
|
|
||||||
|
|
||||||
- Rosie (ressurexitcalcator)
|
|
||||||
|
|
@ -120,3 +120,5 @@ bun run types
|
||||||
<br>
|
<br>
|
||||||
<sub>Bundlers will need to map imports to property access on `revenge` turning `kebab-case` and `snake_case` to `camelCase`.</sub>
|
<sub>Bundlers will need to map imports to property access on `revenge` turning `kebab-case` and `snake_case` to `camelCase`.</sub>
|
||||||
<sub>Example: `@revenge-mod/discord/modules/main_tabs_v2` to `revenge.discord.modules.mainTabsV2`</sub>
|
<sub>Example: `@revenge-mod/discord/modules/main_tabs_v2` to `revenge.discord.modules.mainTabsV2`</sub>
|
||||||
|
<br>
|
||||||
|
<sub>Exception for `@revenge-mod/externals` which property accesses should turn into `PascalCase` instead of `camelCase`.</sub>
|
||||||
|
|
|
||||||
|
|
@ -1,6 +1,5 @@
|
||||||
import { lookupModule, lookupModules } from '@revenge-mod/modules/finders'
|
import { lookupModule, lookupModules } from '@revenge-mod/modules/finders'
|
||||||
import { withDependencies } from '@revenge-mod/modules/finders/filters'
|
import { withDependencies, withProps } from '@revenge-mod/modules/finders/filters'
|
||||||
import { getModuleDependencies } from '@revenge-mod/modules/metro/utils'
|
|
||||||
import {
|
import {
|
||||||
ReactJSXRuntimeModuleId,
|
ReactJSXRuntimeModuleId,
|
||||||
ReactModuleId,
|
ReactModuleId,
|
||||||
|
|
@ -10,7 +9,7 @@ import { proxify } from '@revenge-mod/utils/proxy'
|
||||||
import { ImportTrackerModuleId } from './common/import-tracker'
|
import { ImportTrackerModuleId } from './common/import-tracker'
|
||||||
import type { DiscordModules } from './types'
|
import type { DiscordModules } from './types'
|
||||||
|
|
||||||
const { loose } = withDependencies
|
const { atLeast, last, loose, relative } = withDependencies
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* The lowest amount of dependencies `design/native.tsx` is expected to have.
|
* The lowest amount of dependencies `design/native.tsx` is expected to have.
|
||||||
|
|
@ -27,26 +26,21 @@ export let Design: Design = proxify(
|
||||||
() => {
|
() => {
|
||||||
// ID: 13171
|
// ID: 13171
|
||||||
// [3909, 4608, 5182, 13172, 2, ...] (141 dependencies)
|
// [3909, 4608, 5182, 13172, 2, ...] (141 dependencies)
|
||||||
const [, id] = lookupModule(
|
const [module] = lookupModule(
|
||||||
|
withProps('TableRow', 'Button').and(
|
||||||
withDependencies<Design>(
|
withDependencies<Design>(
|
||||||
loose([
|
atLeast(DesignMinimumDependencies, [
|
||||||
[ImportTrackerModuleId],
|
[ImportTrackerModuleId],
|
||||||
[ImportTrackerModuleId],
|
[ImportTrackerModuleId],
|
||||||
[ReactNativeModuleId, ImportTrackerModuleId],
|
[ReactNativeModuleId, ImportTrackerModuleId],
|
||||||
[ImportTrackerModuleId],
|
[ImportTrackerModuleId],
|
||||||
ImportTrackerModuleId,
|
ImportTrackerModuleId,
|
||||||
...Array<null>(DesignMinimumDependencies - 5).fill(null),
|
|
||||||
]),
|
]),
|
||||||
|
)
|
||||||
).keyAs('revenge.discord.design.Design'),
|
).keyAs('revenge.discord.design.Design'),
|
||||||
{
|
|
||||||
initialize: false,
|
|
||||||
},
|
|
||||||
)
|
)
|
||||||
|
|
||||||
if (id === undefined) return
|
if (module) return (Design = module)
|
||||||
|
|
||||||
const module = __r(id)!
|
|
||||||
if (module.TableRow && module.Button) return (Design = module)
|
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
hint: {},
|
hint: {},
|
||||||
|
|
@ -55,9 +49,6 @@ export let Design: Design = proxify(
|
||||||
|
|
||||||
// design/components/Forms/native/FormSwitch.native.tsx
|
// design/components/Forms/native/FormSwitch.native.tsx
|
||||||
export let FormSwitch: DiscordModules.Components.FormSwitch = proxify(() => {
|
export let FormSwitch: DiscordModules.Components.FormSwitch = proxify(() => {
|
||||||
// TODO: Possibly come up with a better dependency fingerprinting API
|
|
||||||
// to not have to deal with this bullshit
|
|
||||||
|
|
||||||
for (const [, id] of lookupModules(
|
for (const [, id] of lookupModules(
|
||||||
withDependencies(
|
withDependencies(
|
||||||
loose([
|
loose([
|
||||||
|
|
@ -66,18 +57,26 @@ export let FormSwitch: DiscordModules.Components.FormSwitch = proxify(() => {
|
||||||
ReactNativeModuleId,
|
ReactNativeModuleId,
|
||||||
ReactJSXRuntimeModuleId,
|
ReactJSXRuntimeModuleId,
|
||||||
]),
|
]),
|
||||||
).keyAs('revenge.discord.design.FormSwitch'),
|
)
|
||||||
|
.and(
|
||||||
|
withDependencies(
|
||||||
|
last([
|
||||||
|
relative(1),
|
||||||
|
relative(2),
|
||||||
|
null,
|
||||||
|
null,
|
||||||
|
ImportTrackerModuleId,
|
||||||
|
]),
|
||||||
|
),
|
||||||
|
)
|
||||||
|
.keyAs('revenge.discord.design.FormSwitch'),
|
||||||
{
|
{
|
||||||
initialize: false,
|
initialize: false,
|
||||||
},
|
},
|
||||||
)) {
|
)) {
|
||||||
const deps = getModuleDependencies(id)!
|
|
||||||
if (deps.at(-1) !== ImportTrackerModuleId) continue
|
|
||||||
if (deps.at(-4) === id + 2 && deps.at(-5) === id + 1) {
|
|
||||||
const FormSwitch_ = __r(id)!.FormSwitch
|
const FormSwitch_ = __r(id)!.FormSwitch
|
||||||
if (FormSwitch_) return (FormSwitch = FormSwitch_)
|
if (FormSwitch_) return (FormSwitch = FormSwitch_)
|
||||||
}
|
}
|
||||||
}
|
|
||||||
})!
|
})!
|
||||||
|
|
||||||
export interface Design {
|
export interface Design {
|
||||||
|
|
|
||||||
|
|
@ -27,7 +27,7 @@ export function onAnyFluxEventDispatched(patch: FluxEventDispatchPatch) {
|
||||||
* @param patch The patch function to apply when the event is dispatched.
|
* @param patch The patch function to apply when the event is dispatched.
|
||||||
* @returns A function that can be used to remove the patch.
|
* @returns A function that can be used to remove the patch.
|
||||||
*
|
*
|
||||||
* @example Blocking the disptach
|
* @example Blocking the dispatch
|
||||||
* ```ts
|
* ```ts
|
||||||
* // Returning falsy values will prevent the event from being dispatched.
|
* // Returning falsy values will prevent the event from being dispatched.
|
||||||
* onFluxEventDispatched('TYPING_START', () => {})
|
* onFluxEventDispatched('TYPING_START', () => {})
|
||||||
|
|
|
||||||
|
|
@ -1,3 +1,4 @@
|
||||||
|
import { DispatcherModuleId } from '@revenge-mod/discord/common/flux'
|
||||||
import { ImportTrackerModuleId } from '@revenge-mod/discord/common/import-tracker'
|
import { ImportTrackerModuleId } from '@revenge-mod/discord/common/import-tracker'
|
||||||
import {
|
import {
|
||||||
lookupModule,
|
lookupModule,
|
||||||
|
|
@ -7,9 +8,8 @@ import {
|
||||||
import {
|
import {
|
||||||
createFilterGenerator,
|
createFilterGenerator,
|
||||||
withDependencies,
|
withDependencies,
|
||||||
withName,
|
|
||||||
} from '@revenge-mod/modules/finders/filters'
|
} from '@revenge-mod/modules/finders/filters'
|
||||||
import { getModuleDependencies } from '@revenge-mod/modules/metro/utils'
|
import { isModuleExportBad } from '@revenge-mod/modules/metro/utils'
|
||||||
import { asap, noop } from '@revenge-mod/utils/callback'
|
import { asap, noop } from '@revenge-mod/utils/callback'
|
||||||
import {
|
import {
|
||||||
cache,
|
cache,
|
||||||
|
|
@ -17,10 +17,7 @@ import {
|
||||||
Uncached,
|
Uncached,
|
||||||
} from '../../../modules/src/caches'
|
} from '../../../modules/src/caches'
|
||||||
import { FilterResultFlags } from '../../../modules/src/finders/_internal'
|
import { FilterResultFlags } from '../../../modules/src/finders/_internal'
|
||||||
import {
|
import { FilterScopes } from '../../../modules/src/finders/filters/constants'
|
||||||
FilterFlag,
|
|
||||||
FilterScopes,
|
|
||||||
} from '../../../modules/src/finders/filters/constants'
|
|
||||||
import type {
|
import type {
|
||||||
Filter,
|
Filter,
|
||||||
FilterGenerator,
|
FilterGenerator,
|
||||||
|
|
@ -66,32 +63,16 @@ export function getStore<T>(
|
||||||
|
|
||||||
/// STORE FILTERING
|
/// STORE FILTERING
|
||||||
|
|
||||||
/*
|
const { last, includes } = withDependencies
|
||||||
Flux Store dependencies: [
|
|
||||||
_classCallCheck
|
|
||||||
_createClass
|
|
||||||
_possibleConstructorReturn
|
|
||||||
bound getPrototypeOf
|
|
||||||
_inherits
|
|
||||||
(...), // (any amount of dependencies)
|
|
||||||
ImportTracker
|
|
||||||
]
|
|
||||||
*/
|
|
||||||
|
|
||||||
const withLeadingFluxStoreDeps = withDependencies(
|
// The import tracker is checked first, as it is far cheaper than resolving includes
|
||||||
withDependencies.loose([
|
const withFluxStoreDeps = withDependencies(last([ImportTrackerModuleId])).and(
|
||||||
withName('_classCallCheck'),
|
withDependencies(includes([DispatcherModuleId])),
|
||||||
withName('_createClass'),
|
|
||||||
withName('_possibleConstructorReturn'),
|
|
||||||
withName('bound getPrototypeOf'),
|
|
||||||
withName('_inherits'),
|
|
||||||
]),
|
|
||||||
)
|
)
|
||||||
|
|
||||||
export type WithStore = FilterGenerator<
|
export type WithStore = FilterGenerator<
|
||||||
<T>() => Filter<{
|
<T>() => Filter<{
|
||||||
Result: DiscordModules.Flux.Store<T>
|
Result: DiscordModules.Flux.Store<T>
|
||||||
RequiresExports: boolean
|
|
||||||
Scopes: [
|
Scopes: [
|
||||||
typeof FilterScopes.Uninitialized,
|
typeof FilterScopes.Uninitialized,
|
||||||
typeof FilterScopes.Initialized,
|
typeof FilterScopes.Initialized,
|
||||||
|
|
@ -103,27 +84,22 @@ export type WithStore = FilterGenerator<
|
||||||
* A dynamic filter that matches all Flux stores.
|
* A dynamic filter that matches all Flux stores.
|
||||||
*/
|
*/
|
||||||
export const withStore = createFilterGenerator(
|
export const withStore = createFilterGenerator(
|
||||||
(_, id, exports) => {
|
(_, id, exports, initialized) => {
|
||||||
if (exports) return Boolean(exports._dispatchToken)
|
if (initialized)
|
||||||
else {
|
return (
|
||||||
if (!withLeadingFluxStoreDeps(id)) return false
|
!isModuleExportBad(exports) && Boolean(exports._dispatchToken)
|
||||||
const deps = getModuleDependencies(id)!
|
)
|
||||||
return deps[deps.length - 1] === ImportTrackerModuleId
|
|
||||||
}
|
return withFluxStoreDeps(id, undefined, false)
|
||||||
},
|
},
|
||||||
() => 'revenge.discord.store',
|
() => 'revenge.discord.store',
|
||||||
FilterFlag.Dynamic,
|
|
||||||
FilterScopes.Uninitialized | FilterScopes.Initialized,
|
FilterScopes.Uninitialized | FilterScopes.Initialized,
|
||||||
) as WithStore
|
) as WithStore
|
||||||
|
|
||||||
export type WithStoreName = FilterGenerator<
|
export type WithStoreName = FilterGenerator<
|
||||||
<T>(name: string) => Filter<{
|
<T>(name: string) => Filter<{
|
||||||
Result: DiscordModules.Flux.Store<T>
|
Result: DiscordModules.Flux.Store<T>
|
||||||
RequiresExports: true
|
Scopes: [typeof FilterScopes.Initialized]
|
||||||
Scopes: [
|
|
||||||
typeof FilterScopes.Uninitialized,
|
|
||||||
typeof FilterScopes.Initialized,
|
|
||||||
]
|
|
||||||
}>
|
}>
|
||||||
>
|
>
|
||||||
|
|
||||||
|
|
@ -131,11 +107,21 @@ export type WithStoreName = FilterGenerator<
|
||||||
* A with-exports filter that matches a Flux store by its name.
|
* A with-exports filter that matches a Flux store by its name.
|
||||||
*/
|
*/
|
||||||
export const withStoreName = createFilterGenerator(
|
export const withStoreName = createFilterGenerator(
|
||||||
([name], _, exports) =>
|
([name], _, exports) => {
|
||||||
exports?.getName?.length === 0 && exports.getName() === name,
|
if (isModuleExportBad(exports)) return false
|
||||||
|
|
||||||
|
const getNameMethod = exports.getName
|
||||||
|
|
||||||
|
return (
|
||||||
|
typeof getNameMethod === 'function' &&
|
||||||
|
getNameMethod.length === 0 &&
|
||||||
|
// Needs to be bound to exports since it's a class
|
||||||
|
exports.getName() === name
|
||||||
|
)
|
||||||
|
},
|
||||||
([name]) => `revenge.discord.storeName(${name})`,
|
([name]) => `revenge.discord.storeName(${name})`,
|
||||||
FilterFlag.RequiresExports,
|
// Uninitialized to make sure cached results resolve immediately
|
||||||
FilterScopes.Uninitialized | FilterScopes.Initialized,
|
FilterScopes.Initialized | FilterScopes.Uninitialized,
|
||||||
) as WithStoreName
|
) as WithStoreName
|
||||||
|
|
||||||
/// STORE CACHING
|
/// STORE CACHING
|
||||||
|
|
@ -155,5 +141,6 @@ if (cache === Uncached)
|
||||||
asap(() => {
|
asap(() => {
|
||||||
const lookup = lookupModules(withStore())
|
const lookup = lookupModules(withStore())
|
||||||
|
|
||||||
while (lookup.next().done);
|
// Initialize all stores
|
||||||
|
for (const _ of lookup);
|
||||||
})
|
})
|
||||||
|
|
|
||||||
|
|
@ -1,10 +1,11 @@
|
||||||
import type { StackScreenProps } from '@react-navigation/stack'
|
import type { StackScreenProps } from '@react-navigation/stack'
|
||||||
import type { ReactNavigationParamList } from '@revenge-mod/externals/react-navigation'
|
import type { ReactNavigationParamList } from '@revenge-mod/externals/react-navigation'
|
||||||
import type { AnyFunction } from '@revenge-mod/utils/types'
|
import type { AnyFunction, AnyObject } from '@revenge-mod/utils/types'
|
||||||
import type {
|
import type {
|
||||||
ComponentProps,
|
ComponentProps,
|
||||||
ComponentType,
|
ComponentType,
|
||||||
FC,
|
FC,
|
||||||
|
ForwardRefExoticComponent,
|
||||||
MemoExoticComponent,
|
MemoExoticComponent,
|
||||||
ReactElement,
|
ReactElement,
|
||||||
ReactNode,
|
ReactNode,
|
||||||
|
|
@ -151,10 +152,14 @@ export namespace DiscordModules {
|
||||||
verboseDangerously(...args: unknown[]): void
|
verboseDangerously(...args: unknown[]): void
|
||||||
verbose(...args: unknown[]): void
|
verbose(...args: unknown[]): void
|
||||||
}
|
}
|
||||||
|
|
||||||
export namespace Actions {
|
export namespace Actions {
|
||||||
export interface AlertActionCreators {
|
export interface AlertActionCreators {
|
||||||
openAlert(key: string, alert: ReactElement): void
|
openAlert(
|
||||||
|
key: string,
|
||||||
|
alert: ReactElement,
|
||||||
|
onDismiss?: () => unknown,
|
||||||
|
options?: { dismissable?: boolean },
|
||||||
|
): void
|
||||||
dismissAlert(key: string): void
|
dismissAlert(key: string): void
|
||||||
dismissAlerts(): void
|
dismissAlerts(): void
|
||||||
// TODO
|
// TODO
|
||||||
|
|
@ -180,9 +185,20 @@ export namespace DiscordModules {
|
||||||
openLazy<T extends ComponentType<any>>(
|
openLazy<T extends ComponentType<any>>(
|
||||||
sheet: Promise<{ default: T }>,
|
sheet: Promise<{ default: T }>,
|
||||||
key: string,
|
key: string,
|
||||||
props: ComponentProps<T>,
|
props: {
|
||||||
|
impressionName?: string
|
||||||
|
impressionProperties?: AnyObject
|
||||||
|
backdropKind?: string
|
||||||
|
disableHapticOnOpen?: boolean
|
||||||
|
appEntryKey?: string
|
||||||
|
} & ComponentProps<T>,
|
||||||
|
// See ActionSheetStore reducer
|
||||||
|
stackingBehavior?: 'replaceTopSheet' | 'replaceAll' | 'stack',
|
||||||
): void
|
): void
|
||||||
hideActionSheet(key?: string): void
|
hideActionSheet(key?: string): void
|
||||||
|
hideAllActionSheets(): void
|
||||||
|
setActionSheetZIndex(zIndex: number): void
|
||||||
|
resetActionSheetsForAppEntryKey(appEntryKey: string): void
|
||||||
}
|
}
|
||||||
|
|
||||||
// export namespace ActionSheetActionCreators {
|
// export namespace ActionSheetActionCreators {
|
||||||
|
|
@ -263,6 +279,7 @@ export namespace DiscordModules {
|
||||||
| 'tertiary'
|
| 'tertiary'
|
||||||
| 'destructive'
|
| 'destructive'
|
||||||
| 'active'
|
| 'active'
|
||||||
|
| 'expressive'
|
||||||
| 'primary-overlay'
|
| 'primary-overlay'
|
||||||
| 'secondary-overlay'
|
| 'secondary-overlay'
|
||||||
loading?: boolean
|
loading?: boolean
|
||||||
|
|
@ -396,10 +413,50 @@ export namespace DiscordModules {
|
||||||
// export type FormCheckbox = FC
|
// export type FormCheckbox = FC
|
||||||
|
|
||||||
export interface ActionSheetProps {
|
export interface ActionSheetProps {
|
||||||
children: ReactNode
|
scrollable?: boolean
|
||||||
|
startExpanded?: boolean
|
||||||
|
/** Whether the bottom sheet handle is disabled. */
|
||||||
|
handleDisabled?: boolean
|
||||||
|
showGradient?: boolean
|
||||||
|
|
||||||
|
startHeight?: number
|
||||||
|
maxHeight?: number
|
||||||
|
containerHeight?: number
|
||||||
|
contentHeight?: number
|
||||||
|
backdropOpacity?: number
|
||||||
|
|
||||||
|
children?: ReactNode
|
||||||
|
header?: ReactNode
|
||||||
|
footer?: ReactNode
|
||||||
|
extraContent?: ReactNode
|
||||||
|
backdropChildren?: ReactNode
|
||||||
|
|
||||||
|
handleComponent?: ComponentType<any> | null
|
||||||
|
backgroundComponent?: ComponentType<any>
|
||||||
|
|
||||||
|
bodyStyles?: StyleProp<ViewStyle>
|
||||||
|
contentStyles?: StyleProp<ViewStyle>
|
||||||
|
backgroundStyles?: StyleProp<ViewStyle>
|
||||||
|
borderGradient?: string[] | Record<string, any>
|
||||||
|
|
||||||
|
onExpand?: () => void
|
||||||
|
onDismiss?: () => void
|
||||||
|
animatedIndex?: unknown
|
||||||
|
|
||||||
|
keyboardShouldPersistTaps?: 'always' | 'never' | 'handled' | boolean
|
||||||
|
dismissAccessibilityLabel?: string
|
||||||
}
|
}
|
||||||
|
|
||||||
export type ActionSheet = FC<ActionSheetProps>
|
export type ActionSheet = ForwardRefExoticComponent<
|
||||||
|
ActionSheetProps &
|
||||||
|
// See useBottomSheetImperativeHandle
|
||||||
|
RefAttributes<{
|
||||||
|
expandActionSheet(): void
|
||||||
|
closeActionSheet(force?: boolean): void
|
||||||
|
collapseActionSheet(): void
|
||||||
|
snapToIndex(index: number): void
|
||||||
|
}>
|
||||||
|
>
|
||||||
|
|
||||||
export interface ActionSheetCloseButtonProps
|
export interface ActionSheetCloseButtonProps
|
||||||
extends Pick<ComponentProps<IconButton>, 'variant' | 'onPress'> {}
|
extends Pick<ComponentProps<IconButton>, 'variant' | 'onPress'> {}
|
||||||
|
|
|
||||||
|
|
@ -2,7 +2,7 @@ import { getCurrentStack } from '@revenge-mod/utils/error'
|
||||||
import { cacheFilterResultForId } from '../caches'
|
import { cacheFilterResultForId } from '../caches'
|
||||||
import { mInitialized } from '../metro/patches'
|
import { mInitialized } from '../metro/patches'
|
||||||
import { metroRequire } from '../metro/runtime'
|
import { metroRequire } from '../metro/runtime'
|
||||||
import { FilterFlag } from './filters'
|
import { isModuleInitialized } from '../metro/utils'
|
||||||
import type { If } from '@revenge-mod/utils/types'
|
import type { If } from '@revenge-mod/utils/types'
|
||||||
import type { Metro } from '../types'
|
import type { Metro } from '../types'
|
||||||
import type { Filter } from './filters'
|
import type { Filter } from './filters'
|
||||||
|
|
@ -72,34 +72,14 @@ const noDefaultExportsCache = new Set<Metro.ModuleID>()
|
||||||
// Currently, we only have options that are relevant for checking exports
|
// Currently, we only have options that are relevant for checking exports
|
||||||
// If we add more options later on, do NOT forget about
|
// If we add more options later on, do NOT forget about
|
||||||
// adding them here, and passing them in the lookup* functions
|
// adding them here, and passing them in the lookup* functions
|
||||||
export function runFilter(
|
|
||||||
filter: Filter<{ Result: any; RequiresExports: false; Scopes: any[] }>,
|
|
||||||
id: Metro.ModuleID,
|
|
||||||
): FilterResultFlag | undefined
|
|
||||||
|
|
||||||
export function runFilter(
|
|
||||||
filter: Filter,
|
|
||||||
id: Metro.ModuleID,
|
|
||||||
exports: Metro.ModuleExports,
|
|
||||||
options?: RunFilterOptions,
|
|
||||||
): FilterResultFlag | undefined
|
|
||||||
|
|
||||||
export function runFilter(
|
export function runFilter(
|
||||||
filter: Filter,
|
filter: Filter,
|
||||||
id: Metro.ModuleID,
|
id: Metro.ModuleID,
|
||||||
exports?: Metro.ModuleExports,
|
exports?: Metro.ModuleExports,
|
||||||
options?: RunFilterOptions,
|
options?: RunFilterOptions,
|
||||||
): FilterResultFlag | undefined {
|
): FilterResultFlag | undefined {
|
||||||
if (exports === undefined) {
|
if (!isModuleInitialized(id)) {
|
||||||
if (filter.flags & FilterFlag.RequiresExports) return
|
if (filter(id, undefined, false)) {
|
||||||
|
|
||||||
const filter_ = filter as Filter<{
|
|
||||||
Result: any
|
|
||||||
RequiresExports: false
|
|
||||||
Scopes: any[]
|
|
||||||
}>
|
|
||||||
|
|
||||||
if (filter_(id)) {
|
|
||||||
if (options?.initialize ?? true) {
|
if (options?.initialize ?? true) {
|
||||||
const module = metroRequire(id)
|
const module = metroRequire(id)
|
||||||
// Check if the required module is not blacklisted
|
// Check if the required module is not blacklisted
|
||||||
|
|
@ -118,13 +98,7 @@ export function runFilter(
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
const filter_ = filter as Filter<{
|
if (filter(id, exports, true))
|
||||||
Result: any
|
|
||||||
RequiresExports: true
|
|
||||||
Scopes: any[]
|
|
||||||
}>
|
|
||||||
|
|
||||||
if (filter_(id, exports))
|
|
||||||
return cacheFilterResultForId(
|
return cacheFilterResultForId(
|
||||||
filter.key,
|
filter.key,
|
||||||
id,
|
id,
|
||||||
|
|
@ -133,13 +107,15 @@ export function runFilter(
|
||||||
|
|
||||||
if (options?.skipDefault) return
|
if (options?.skipDefault) return
|
||||||
if (noDefaultExportsCache.has(id)) return
|
if (noDefaultExportsCache.has(id)) return
|
||||||
if (!('default' in exports)) {
|
|
||||||
|
const type = typeof exports
|
||||||
|
if (type !== 'object' || exports === null || !('default' in exports)) {
|
||||||
noDefaultExportsCache.add(id)
|
noDefaultExportsCache.add(id)
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
const { default: defaultExport } = exports
|
const { default: defaultExport } = exports
|
||||||
if (filter_(id, defaultExport))
|
if (filter(id, defaultExport, true))
|
||||||
return cacheFilterResultForId(filter.key, id, FilterResultFlags.Default)
|
return cacheFilterResultForId(filter.key, id, FilterResultFlags.Default)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -1,4 +1,4 @@
|
||||||
import { FilterFlag } from './constants'
|
import { FilterScopes } from '.'
|
||||||
import { createFilterGenerator } from './utils'
|
import { createFilterGenerator } from './utils'
|
||||||
import type { Metro } from '@revenge-mod/modules/types'
|
import type { Metro } from '@revenge-mod/modules/types'
|
||||||
import type {
|
import type {
|
||||||
|
|
@ -6,33 +6,48 @@ import type {
|
||||||
FilterBase,
|
FilterBase,
|
||||||
FilterGenerator,
|
FilterGenerator,
|
||||||
FilterInfoOf,
|
FilterInfoOf,
|
||||||
|
FilterScopeValue,
|
||||||
MergeFilterInfo,
|
MergeFilterInfo,
|
||||||
UnionFilterInfo,
|
UnionFilterInfo,
|
||||||
} from '.'
|
} from '.'
|
||||||
|
|
||||||
|
/** {@link FilterScopes.All} sets no predicate assumptions. Only {@link FilterScopes.Uninitialized} implies exportless. */
|
||||||
|
const canFilterUninitialized = (scopes: FilterScopeValue) =>
|
||||||
|
Boolean(scopes & FilterScopes.Uninitialized)
|
||||||
|
|
||||||
|
/**
|
||||||
|
* If both or neither can filter uninitialized, positional order is preserved.
|
||||||
|
*
|
||||||
|
* If only one can, the one that can will be used as a prefilter for uninitialized modules,
|
||||||
|
* and the other will only run on initialized modules.
|
||||||
|
*/
|
||||||
const compositeHandler = <G extends (a: FilterBase, b: FilterBase) => any>(
|
const compositeHandler = <G extends (a: FilterBase, b: FilterBase) => any>(
|
||||||
sameHandler: G,
|
directHandler: G,
|
||||||
dynamicHandler: G,
|
prefilteredHandler: G,
|
||||||
) =>
|
) =>
|
||||||
((a, b) =>
|
((a, b) => {
|
||||||
a.flags === b.flags
|
const ea = canFilterUninitialized(a.scopes)
|
||||||
? sameHandler(a, b)
|
return ea === canFilterUninitialized(b.scopes)
|
||||||
: a.flags === FilterFlag.RequiresExports
|
? directHandler(a, b)
|
||||||
? dynamicHandler(a, b)
|
: ea
|
||||||
: dynamicHandler(b, a)) as G
|
? prefilteredHandler(b, a)
|
||||||
|
: prefilteredHandler(a, b)
|
||||||
|
}) as G
|
||||||
|
|
||||||
const compositeArrayHandler = <
|
const compositeArrayHandler = <
|
||||||
G extends (args: [a: FilterBase, b: FilterBase]) => any,
|
G extends (args: [a: FilterBase, b: FilterBase]) => any,
|
||||||
>(
|
>(
|
||||||
sameHandler: G,
|
directHandler: G,
|
||||||
dynamicHandler: G,
|
prefilteredHandler: G,
|
||||||
) =>
|
) =>
|
||||||
(([a, b]) =>
|
(([a, b]) => {
|
||||||
a.flags === b.flags
|
const ea = canFilterUninitialized(a.scopes)
|
||||||
? sameHandler([a, b])
|
return ea === canFilterUninitialized(b.scopes)
|
||||||
: a.flags === FilterFlag.RequiresExports
|
? directHandler([a, b])
|
||||||
? dynamicHandler([a, b])
|
: ea
|
||||||
: dynamicHandler([b, a])) as G
|
? prefilteredHandler([b, a])
|
||||||
|
: prefilteredHandler([a, b])
|
||||||
|
}) as G
|
||||||
|
|
||||||
export type And = FilterGenerator<
|
export type And = FilterGenerator<
|
||||||
<F1 extends FilterBase, F2 extends FilterBase>(
|
<F1 extends FilterBase, F2 extends FilterBase>(
|
||||||
|
|
@ -46,49 +61,49 @@ const andKeyGenerator = ([a, b]: Parameters<And>) =>
|
||||||
|
|
||||||
const andScopesGenerator = ([a, b]: Parameters<And>) => a.scopes | b.scopes
|
const andScopesGenerator = ([a, b]: Parameters<And>) => a.scopes | b.scopes
|
||||||
|
|
||||||
const sameFlagsAnd = createFilterGenerator(
|
const directAnd = createFilterGenerator(
|
||||||
([a, b], id, exports) => a(id, exports) && b(id, exports),
|
([a, b], id, exports, initialized) =>
|
||||||
|
a(id, exports, initialized) && b(id, exports, initialized),
|
||||||
andKeyGenerator,
|
andKeyGenerator,
|
||||||
([a]) => a.flags,
|
|
||||||
andScopesGenerator,
|
andScopesGenerator,
|
||||||
) as And
|
) as And
|
||||||
|
|
||||||
const andFallbackCache = new WeakMap<FilterBase, Set<Metro.ModuleID>>()
|
const andPrefilterCache = new WeakMap<FilterBase, Set<Metro.ModuleID>>()
|
||||||
|
|
||||||
const dynamicFlagsAnd = createFilterGenerator(
|
const prefilteredAnd = createFilterGenerator(
|
||||||
([filter, fallbackFilter], id, exports) => {
|
([filter, prefilter], id, exports, initialized) => {
|
||||||
if (exports) {
|
if (initialized) {
|
||||||
if (filter(id, exports)) {
|
if (filter(id, exports, true)) {
|
||||||
// Avoid running the fallback filter if we already know the first one passed
|
// Avoid running the prefilter again if we already know it passed
|
||||||
const cache = andFallbackCache.get(fallbackFilter)
|
const cache = andPrefilterCache.get(prefilter)
|
||||||
return (
|
return (
|
||||||
// biome-ignore lint/complexity/useOptionalChain: Hot path should be optimized
|
// biome-ignore lint/complexity/useOptionalChain: Hot path should be optimized
|
||||||
(cache && cache.has(id)) || fallbackFilter(id, exports)
|
(cache && cache.has(id)) || prefilter(id, exports, true)
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
return false
|
return false
|
||||||
}
|
}
|
||||||
|
|
||||||
const result = fallbackFilter(id)
|
const result = prefilter(id, undefined, false)
|
||||||
if (result) {
|
if (result) {
|
||||||
// Cache fallback hits to avoid calling the fallback filter again
|
// Cache prefilter hits to avoid calling the prefilter again
|
||||||
// Fallback filters are usually more expensive
|
// Prefilters are usually more expensive
|
||||||
let set = andFallbackCache.get(fallbackFilter)
|
let set = andPrefilterCache.get(prefilter)
|
||||||
if (!set) andFallbackCache.set(fallbackFilter, (set = new Set()))
|
if (!set) andPrefilterCache.set(prefilter, (set = new Set()))
|
||||||
set.add(id)
|
set.add(id)
|
||||||
}
|
}
|
||||||
return result
|
return result
|
||||||
},
|
},
|
||||||
andKeyGenerator,
|
andKeyGenerator,
|
||||||
FilterFlag.Dynamic,
|
|
||||||
andScopesGenerator,
|
andScopesGenerator,
|
||||||
) as And
|
) as And
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Combines two filters into one, returning true if **every** filter matches.
|
* Combines two filters into one, returning true if **every** filter matches.
|
||||||
*
|
*
|
||||||
* If each filter has different flags,
|
* If only one of the filters can run on uninitialized modules ({@link FilterScopes.Uninitialized}),
|
||||||
|
* it is used as the prefilter for uninitialized modules, and the other only runs once a candidate is initialized.
|
||||||
*
|
*
|
||||||
* @param filters The filters to combine.
|
* @param filters The filters to combine.
|
||||||
*
|
*
|
||||||
|
|
@ -111,23 +126,13 @@ const dynamicFlagsAnd = createFilterGenerator(
|
||||||
* )
|
* )
|
||||||
* ```
|
* ```
|
||||||
*/
|
*/
|
||||||
export const and = Object.assign(
|
export const and = Object.assign(compositeHandler(directAnd, prefilteredAnd), {
|
||||||
compositeHandler(sameFlagsAnd, dynamicFlagsAnd),
|
keyFor: compositeArrayHandler(directAnd.keyFor, prefilteredAnd.keyFor),
|
||||||
{
|
|
||||||
keyFor: compositeArrayHandler(
|
|
||||||
sameFlagsAnd.keyFor,
|
|
||||||
dynamicFlagsAnd.keyFor,
|
|
||||||
),
|
|
||||||
flagsFor: compositeArrayHandler(
|
|
||||||
sameFlagsAnd.flagsFor,
|
|
||||||
dynamicFlagsAnd.flagsFor,
|
|
||||||
),
|
|
||||||
defaultScopesFor: compositeArrayHandler(
|
defaultScopesFor: compositeArrayHandler(
|
||||||
sameFlagsAnd.defaultScopesFor,
|
directAnd.defaultScopesFor,
|
||||||
dynamicFlagsAnd.defaultScopesFor,
|
prefilteredAnd.defaultScopesFor,
|
||||||
),
|
),
|
||||||
},
|
}) satisfies And
|
||||||
) satisfies And
|
|
||||||
|
|
||||||
export type Or = FilterGenerator<
|
export type Or = FilterGenerator<
|
||||||
<F1 extends FilterBase, F2 extends FilterBase>(
|
<F1 extends FilterBase, F2 extends FilterBase>(
|
||||||
|
|
@ -141,21 +146,21 @@ const orKeyGenerator = ([a, b]: Parameters<Or>) =>
|
||||||
|
|
||||||
const orScopesGenerator = ([a, b]: Parameters<Or>) => a.scopes | b.scopes
|
const orScopesGenerator = ([a, b]: Parameters<Or>) => a.scopes | b.scopes
|
||||||
|
|
||||||
const sameFlagOr = createFilterGenerator(
|
const directOr = createFilterGenerator(
|
||||||
([a, b], id, exports) => a(id, exports) || b(id, exports),
|
([a, b], id, exports, initialized) =>
|
||||||
|
a(id, exports, initialized) || b(id, exports, initialized),
|
||||||
orKeyGenerator,
|
orKeyGenerator,
|
||||||
([a]) => a.flags,
|
|
||||||
orScopesGenerator,
|
orScopesGenerator,
|
||||||
) as Or
|
) as Or
|
||||||
|
|
||||||
const dynamicFlagOr = createFilterGenerator(
|
const prefilteredOr = createFilterGenerator(
|
||||||
([filter, fallbackFilter], id, exports) => {
|
([filter, prefilter], id, exports, initialized) => {
|
||||||
// TODO(PalmDevs): Potential optimization: Add fallback cache here too?
|
// TODO(PalmDevs): Potential optimization: Add prefilter cache here too?
|
||||||
if (exports) return filter(id, exports) || fallbackFilter(id, exports)
|
if (initialized)
|
||||||
return fallbackFilter(id)
|
return filter(id, exports, true) || prefilter(id, exports, true)
|
||||||
|
return prefilter(id, undefined, false)
|
||||||
},
|
},
|
||||||
orKeyGenerator,
|
orKeyGenerator,
|
||||||
FilterFlag.Dynamic,
|
|
||||||
orScopesGenerator,
|
orScopesGenerator,
|
||||||
) as Or
|
) as Or
|
||||||
|
|
||||||
|
|
@ -183,17 +188,10 @@ const dynamicFlagOr = createFilterGenerator(
|
||||||
* )
|
* )
|
||||||
* ```
|
* ```
|
||||||
*/
|
*/
|
||||||
export const or = Object.assign(
|
export const or = Object.assign(compositeHandler<Or>(directOr, prefilteredOr), {
|
||||||
compositeHandler<Or>(sameFlagOr, dynamicFlagOr),
|
keyFor: compositeArrayHandler(directOr.keyFor, prefilteredOr.keyFor),
|
||||||
{
|
|
||||||
keyFor: compositeArrayHandler(sameFlagOr.keyFor, dynamicFlagOr.keyFor),
|
|
||||||
flagsFor: compositeArrayHandler(
|
|
||||||
sameFlagOr.flagsFor,
|
|
||||||
dynamicFlagOr.flagsFor,
|
|
||||||
),
|
|
||||||
defaultScopesFor: compositeArrayHandler(
|
defaultScopesFor: compositeArrayHandler(
|
||||||
sameFlagOr.defaultScopesFor,
|
directOr.defaultScopesFor,
|
||||||
dynamicFlagOr.defaultScopesFor,
|
prefilteredOr.defaultScopesFor,
|
||||||
),
|
),
|
||||||
},
|
}) satisfies Or
|
||||||
) satisfies Or
|
|
||||||
|
|
|
||||||
|
|
@ -1,21 +1,3 @@
|
||||||
export const FilterFlag = {
|
|
||||||
/**
|
|
||||||
* This filter works with and without module exports.
|
|
||||||
* Allowing for both initialized and uninitialized modules to be matched.
|
|
||||||
*/
|
|
||||||
Dynamic: 0,
|
|
||||||
/**
|
|
||||||
* This filter requires module exports to work.
|
|
||||||
* Only initialized modules will be matched.
|
|
||||||
*/
|
|
||||||
RequiresExports: 1,
|
|
||||||
} as const
|
|
||||||
|
|
||||||
/**
|
|
||||||
* @see {@link FilterFlag}
|
|
||||||
*/
|
|
||||||
export type FilterFlag = number
|
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Scopes to limit filters to certain module states.
|
* Scopes to limit filters to certain module states.
|
||||||
*/
|
*/
|
||||||
|
|
@ -23,10 +5,16 @@ export const FilterScopes = {
|
||||||
/**
|
/**
|
||||||
* Include all modules (both initialized and uninitialized, including blacklisted).
|
* Include all modules (both initialized and uninitialized, including blacklisted).
|
||||||
* This overrides {@link FilterScopes.Uninitialized} and {@link FilterScopes.Initialized}.
|
* This overrides {@link FilterScopes.Uninitialized} and {@link FilterScopes.Initialized}.
|
||||||
|
*
|
||||||
|
* **Filter generators generally don't need this scope.**
|
||||||
|
*
|
||||||
|
* When combining multiple filters with composite filters, the {@link FilterScopes.All} scope doesn't set assumptions for the filter predicate.
|
||||||
|
* It only decides which modules to run the predicate against.
|
||||||
|
* **Filter generators must include {@link FilterScopes.Uninitialized} and/or {@link FilterScopes.Initialized} as well.**
|
||||||
*/
|
*/
|
||||||
All: 1,
|
All: 1,
|
||||||
/**
|
/**
|
||||||
* Include uninitialized modules in the search.
|
* Include uninitialized modules in the search. Implies the predicate can run without exports.
|
||||||
*/
|
*/
|
||||||
Uninitialized: 2,
|
Uninitialized: 2,
|
||||||
/**
|
/**
|
||||||
|
|
@ -47,10 +35,6 @@ export interface FilterInfo {
|
||||||
* The result type of the filter.
|
* The result type of the filter.
|
||||||
*/
|
*/
|
||||||
Result: any
|
Result: any
|
||||||
/**
|
|
||||||
* Whether the filter requires exports to work.
|
|
||||||
*/
|
|
||||||
RequiresExports: boolean
|
|
||||||
/**
|
/**
|
||||||
* Scopes the filter matches modules in.
|
* Scopes the filter matches modules in.
|
||||||
*/
|
*/
|
||||||
|
|
@ -59,6 +43,5 @@ export interface FilterInfo {
|
||||||
|
|
||||||
export interface DefaultFilterInfo extends FilterInfo {
|
export interface DefaultFilterInfo extends FilterInfo {
|
||||||
Result: any
|
Result: any
|
||||||
RequiresExports: boolean
|
|
||||||
Scopes: FilterScope[]
|
Scopes: FilterScope[]
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -4,7 +4,7 @@ import {
|
||||||
getModuleDependencies,
|
getModuleDependencies,
|
||||||
} from '../../metro/utils'
|
} from '../../metro/utils'
|
||||||
import { runFilter } from '../_internal'
|
import { runFilter } from '../_internal'
|
||||||
import { FilterFlag, FilterScopes } from './constants'
|
import { FilterScopes } from './constants'
|
||||||
import { createFilterGenerator } from './utils'
|
import { createFilterGenerator } from './utils'
|
||||||
import type { Metro } from '../../types'
|
import type { Metro } from '../../types'
|
||||||
import type { Filter, FilterGenerator } from './utils'
|
import type { Filter, FilterGenerator } from './utils'
|
||||||
|
|
@ -18,8 +18,18 @@ export interface ComparableDependencyMap
|
||||||
| ComparableDependencyMap
|
| ComparableDependencyMap
|
||||||
| Filter
|
| Filter
|
||||||
> {
|
> {
|
||||||
|
// loose
|
||||||
l?: boolean
|
l?: boolean
|
||||||
|
// relative
|
||||||
r?: number
|
r?: number
|
||||||
|
// skip
|
||||||
|
s?: number
|
||||||
|
// atLeast
|
||||||
|
n?: number
|
||||||
|
// atMost
|
||||||
|
x?: number
|
||||||
|
// includes
|
||||||
|
i?: boolean
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
|
|
@ -30,6 +40,9 @@ export interface ComparableDependencyMap
|
||||||
* To do proper fingerprinting for modules:
|
* To do proper fingerprinting for modules:
|
||||||
* @see {@link withDependencies.loose} to loosen the checks.
|
* @see {@link withDependencies.loose} to loosen the checks.
|
||||||
* @see {@link withDependencies.relative} to compare dependencies relatively.
|
* @see {@link withDependencies.relative} to compare dependencies relatively.
|
||||||
|
* @see {@link withDependencies.skip} and {@link withDependencies.last} to move where comparison starts.
|
||||||
|
* @see {@link withDependencies.atLeast} and {@link withDependencies.atMost} to bound the dependency count.
|
||||||
|
* @see {@link withDependencies.includes} to compare without caring about order.
|
||||||
*
|
*
|
||||||
* @example
|
* @example
|
||||||
* ```ts
|
* ```ts
|
||||||
|
|
@ -75,30 +88,30 @@ export interface ComparableDependencyMap
|
||||||
*/
|
*/
|
||||||
const withDependencies_ = createFilterGenerator<Parameters<WithDependencies>>(
|
const withDependencies_ = createFilterGenerator<Parameters<WithDependencies>>(
|
||||||
([deps], id) => depCompare(getModuleDependencies(id)!, deps, id, id),
|
([deps], id) => depCompare(getModuleDependencies(id)!, deps, id, id),
|
||||||
|
// The dep map is keyed as a nested set, so its modifiers are already part of the key
|
||||||
deps => `revenge.deps(${depGenFilterKey(deps)})`,
|
deps => `revenge.deps(${depGenFilterKey(deps)})`,
|
||||||
FilterFlag.Dynamic,
|
|
||||||
FilterScopes.Uninitialized | FilterScopes.Initialized,
|
FilterScopes.Uninitialized | FilterScopes.Initialized,
|
||||||
) as WithDependencies
|
) as WithDependencies
|
||||||
|
|
||||||
export const withDependencies = __DEV__
|
export const withDependencies = __DEV__
|
||||||
? (((deps: ComparableDependencyMap) => {
|
? (Object.assign((deps: ComparableDependencyMap) => {
|
||||||
// Warn about using undefined in deps, which is likely a mistake
|
DEBUG_validateWithDependenciesFilter(deps)
|
||||||
for (let i = 0; i < deps.length; i++) {
|
|
||||||
if (deps[i] === undefined)
|
|
||||||
DEBUG_warnBadWithDependenciesFilter(deps, i)
|
|
||||||
}
|
|
||||||
|
|
||||||
return withDependencies_(deps)
|
return withDependencies_(deps)
|
||||||
}) as WithDependencies)
|
}, withDependencies_) as WithDependencies)
|
||||||
: withDependencies_
|
: withDependencies_
|
||||||
|
|
||||||
withDependencies.loose = loose
|
withDependencies.loose = loose
|
||||||
withDependencies.relative = relative
|
withDependencies.relative = relative
|
||||||
|
withDependencies.skip = skip
|
||||||
|
withDependencies.last = last
|
||||||
|
withDependencies.atLeast = atLeast
|
||||||
|
withDependencies.atMost = atMost
|
||||||
|
withDependencies.includes = includes
|
||||||
|
|
||||||
type WithDependencies = FilterGenerator<
|
type WithDependencies = FilterGenerator<
|
||||||
<T>(deps: ComparableDependencyMap) => Filter<{
|
<T>(deps: ComparableDependencyMap) => Filter<{
|
||||||
Result: T
|
Result: T
|
||||||
RequiresExports: false
|
|
||||||
Scopes: [
|
Scopes: [
|
||||||
typeof FilterScopes.Uninitialized,
|
typeof FilterScopes.Uninitialized,
|
||||||
typeof FilterScopes.Initialized,
|
typeof FilterScopes.Initialized,
|
||||||
|
|
@ -107,6 +120,11 @@ type WithDependencies = FilterGenerator<
|
||||||
> & {
|
> & {
|
||||||
loose: typeof loose
|
loose: typeof loose
|
||||||
relative: typeof relative
|
relative: typeof relative
|
||||||
|
skip: typeof skip
|
||||||
|
last: typeof last
|
||||||
|
atLeast: typeof atLeast
|
||||||
|
atMost: typeof atMost
|
||||||
|
includes: typeof includes
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
|
|
@ -123,6 +141,96 @@ function loose(deps: ComparableDependencyMap) {
|
||||||
return deps
|
return deps
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Skip a number of dependencies before comparing positionally.
|
||||||
|
*
|
||||||
|
* Passing `Infinity` anchors the set to the end, matching the **last** `deps.length` dependencies.
|
||||||
|
* Anything before them is unconstrained.
|
||||||
|
*
|
||||||
|
* @param amount The amount of dependencies to skip from the start, or `Infinity` to anchor to the end.
|
||||||
|
* @param deps The dependency map to skip in. This permanently modifies the array.
|
||||||
|
* @returns The modified dependency map.
|
||||||
|
*
|
||||||
|
* @see {@link withDependencies.last} for the `Infinity` shorthand.
|
||||||
|
*/
|
||||||
|
function skip(amount: number, deps: ComparableDependencyMap = []) {
|
||||||
|
deps.s = amount
|
||||||
|
return deps
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Match the **last** `deps.length` dependencies, leaving anything before them unconstrained.
|
||||||
|
*
|
||||||
|
* Shorthand for {@link withDependencies.skip} with `Infinity`.
|
||||||
|
* Prefer this over leading comparisons when a module's trailing dependencies are the stable part of its fingerprint.
|
||||||
|
*
|
||||||
|
* @param deps The dependency map to anchor to the end. This permanently modifies the array.
|
||||||
|
* @returns The modified dependency map.
|
||||||
|
*
|
||||||
|
* @example
|
||||||
|
* ```ts
|
||||||
|
* const { last, relative } = withDependencies
|
||||||
|
*
|
||||||
|
* // Matches modules whose last three dependencies are [Any, module ID + 1, 2]
|
||||||
|
* withDependencies(last([null, relative(1), 2]))
|
||||||
|
* ```
|
||||||
|
*/
|
||||||
|
function last(deps: ComparableDependencyMap = []) {
|
||||||
|
deps.s = Infinity
|
||||||
|
return deps
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Require the module to have at least `count` dependencies.
|
||||||
|
*
|
||||||
|
* This implies {@link withDependencies.loose}, as an exact length check would never pass alongside a bound.
|
||||||
|
*
|
||||||
|
* @param count The minimum amount of dependencies.
|
||||||
|
* @param deps The dependency map to bound. This permanently modifies the array.
|
||||||
|
* @returns The modified dependency map.
|
||||||
|
*/
|
||||||
|
function atLeast(count: number, deps: ComparableDependencyMap = []) {
|
||||||
|
deps.n = count
|
||||||
|
deps.l = true
|
||||||
|
return deps
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Require the module to have at most `count` dependencies.
|
||||||
|
*
|
||||||
|
* This implies {@link withDependencies.loose}, as an exact length check would never pass alongside a bound.
|
||||||
|
*
|
||||||
|
* @param count The maximum amount of dependencies.
|
||||||
|
* @param deps The dependency map to bound. This permanently modifies the array.
|
||||||
|
* @returns The modified dependency map.
|
||||||
|
*/
|
||||||
|
function atMost(count: number, deps: ComparableDependencyMap = []) {
|
||||||
|
deps.x = count
|
||||||
|
deps.l = true
|
||||||
|
return deps
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Compare the set without caring about order or position, only that every dependency exists somewhere.
|
||||||
|
*
|
||||||
|
* Entries are matched independently, so two identical entries can both match the same dependency.
|
||||||
|
* Dynamic (`null`) entries are meaningless here and are ignored.
|
||||||
|
*
|
||||||
|
* **This is much more expensive than positional comparison**, as every entry is compared against every dependency.
|
||||||
|
* Bound it with {@link withDependencies.atLeast} or {@link withDependencies.atMost} where possible, as those are checked first.
|
||||||
|
*
|
||||||
|
* @param deps The dependency map to compare unordered. This permanently modifies the array.
|
||||||
|
* @returns The modified dependency map.
|
||||||
|
*/
|
||||||
|
function includes(deps: ComparableDependencyMap) {
|
||||||
|
if (__DEV__)
|
||||||
|
for (let i = 0; i < deps.length; i++)
|
||||||
|
if (deps[i] == null) DEBUG_warnBadIncludesDependency(deps, i)
|
||||||
|
|
||||||
|
deps.i = true
|
||||||
|
return deps
|
||||||
|
}
|
||||||
|
|
||||||
const RelativeSignBit = 1 << 30
|
const RelativeSignBit = 1 << 30
|
||||||
const RelativeBit = 1 << 29
|
const RelativeBit = 1 << 29
|
||||||
const RelativeRootBit = 1 << 28
|
const RelativeRootBit = 1 << 28
|
||||||
|
|
@ -192,6 +300,70 @@ function DEBUG_warnBadWithDependenciesFilter(
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Warns the developer about a dynamic dependency in a `withDependencies.includes` set, which matches anything and is therefore a no-op.
|
||||||
|
*/
|
||||||
|
function DEBUG_warnBadIncludesDependency(
|
||||||
|
deps: ComparableDependencyMap,
|
||||||
|
index: number,
|
||||||
|
) {
|
||||||
|
nativeLoggingHook(
|
||||||
|
`\u001b[33mBad withDependencies.includes set, dynamic ID at index ${index} matches anything: [${depGenFilterKey(deps)}]\n${getCurrentStack()}\u001b[0m`,
|
||||||
|
2,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Warns the developer about dependency count bounds that contradict, or are already implied by, the set being compared.
|
||||||
|
*/
|
||||||
|
function DEBUG_warnBadDependencyBounds(
|
||||||
|
deps: ComparableDependencyMap,
|
||||||
|
reason: string,
|
||||||
|
) {
|
||||||
|
nativeLoggingHook(
|
||||||
|
`\u001b[33mBad withDependencies filter, ${reason}: [${depGenFilterKey(deps)}]\n${getCurrentStack()}\u001b[0m`,
|
||||||
|
2,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Validates a dependency map and its nested sets, warning about mistakes that would otherwise silently never match.
|
||||||
|
*/
|
||||||
|
function DEBUG_validateWithDependenciesFilter(deps: ComparableDependencyMap) {
|
||||||
|
const { n: min, x: max, s: skip } = deps
|
||||||
|
|
||||||
|
// The lowest dependency count the set itself can match.
|
||||||
|
// Unordered sets have no window, so they only ever constrain the count through bounds.
|
||||||
|
const floor = deps.i
|
||||||
|
? 0
|
||||||
|
: (skip === undefined || skip === Infinity ? 0 : skip) + deps.length
|
||||||
|
|
||||||
|
if (max !== undefined && max < floor)
|
||||||
|
DEBUG_warnBadDependencyBounds(
|
||||||
|
deps,
|
||||||
|
`atMost(${max}) can never match, the set requires at least ${floor} dependencies`,
|
||||||
|
)
|
||||||
|
else if (min !== undefined && max !== undefined && min > max)
|
||||||
|
DEBUG_warnBadDependencyBounds(
|
||||||
|
deps,
|
||||||
|
`atLeast(${min}) can never match, atMost(${max}) is lower`,
|
||||||
|
)
|
||||||
|
else if (min !== undefined && min <= floor)
|
||||||
|
DEBUG_warnBadDependencyBounds(
|
||||||
|
deps,
|
||||||
|
`atLeast(${min}) does nothing, the set already requires at least ${floor} dependencies`,
|
||||||
|
)
|
||||||
|
|
||||||
|
for (let i = 0; i < deps.length; i++) {
|
||||||
|
const dep = deps[i]
|
||||||
|
|
||||||
|
// Warn about using undefined in deps, which is likely a mistake
|
||||||
|
if (dep === undefined) DEBUG_warnBadWithDependenciesFilter(deps, i)
|
||||||
|
else if (typeof dep === 'object' && dep !== null)
|
||||||
|
DEBUG_validateWithDependenciesFilter(dep)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
function depCompare(
|
function depCompare(
|
||||||
a: Metro.ModuleID[],
|
a: Metro.ModuleID[],
|
||||||
b: ComparableDependencyMap,
|
b: ComparableDependencyMap,
|
||||||
|
|
@ -200,7 +372,29 @@ function depCompare(
|
||||||
): boolean {
|
): boolean {
|
||||||
const lenA = a.length
|
const lenA = a.length
|
||||||
const lenB = b.length
|
const lenB = b.length
|
||||||
|
|
||||||
|
const min = b.n
|
||||||
|
if (min !== undefined && lenA < min) return false
|
||||||
|
|
||||||
|
const max = b.x
|
||||||
|
if (max !== undefined && lenA > max) return false
|
||||||
|
|
||||||
|
if (b.i) return depIncludesCompare(a, b, root, parent)
|
||||||
|
|
||||||
|
// Index in a where positional comparison starts
|
||||||
|
let start = 0
|
||||||
|
const skip = b.s
|
||||||
|
|
||||||
|
if (skip === undefined) {
|
||||||
if (b.l ? lenA < lenB : lenA !== lenB) return false
|
if (b.l ? lenA < lenB : lenA !== lenB) return false
|
||||||
|
} else if (skip === Infinity) {
|
||||||
|
// Anchor to the end, so everything before the last lenB dependencies is unconstrained
|
||||||
|
start = lenA - lenB
|
||||||
|
if (start < 0) return false
|
||||||
|
} else {
|
||||||
|
start = skip
|
||||||
|
if (b.l ? lenA < start + lenB : lenA !== start + lenB) return false
|
||||||
|
}
|
||||||
|
|
||||||
for (let i = 0; i < lenB; i++) {
|
for (let i = 0; i < lenB; i++) {
|
||||||
const compare = b[i]
|
const compare = b[i]
|
||||||
|
|
@ -211,42 +405,70 @@ function depCompare(
|
||||||
// Skip dynamic
|
// Skip dynamic
|
||||||
if (compare == null) continue
|
if (compare == null) continue
|
||||||
|
|
||||||
const id = a[i]
|
const id = a[start + i]
|
||||||
|
|
||||||
// Very rare case where a module dependency has an unused `null` dependency
|
// Very rare case where a module dependency has an unused `null` dependency
|
||||||
// TODO: Probably a Metro/Discord bug?
|
// TODO: Probably a Metro/Discord bug?
|
||||||
if (id === null) return false
|
if (id === null) return false
|
||||||
|
|
||||||
switch (typeof compare) {
|
if (!depMatches(compare, id, root, parent)) return false
|
||||||
case 'function': {
|
|
||||||
const filter = compare
|
|
||||||
if (!runFilter(filter, id, getInitializedModuleExports(id)))
|
|
||||||
return false
|
|
||||||
|
|
||||||
continue
|
|
||||||
}
|
}
|
||||||
|
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
|
||||||
|
function depIncludesCompare(
|
||||||
|
a: Metro.ModuleID[],
|
||||||
|
b: ComparableDependencyMap,
|
||||||
|
root: Metro.ModuleID,
|
||||||
|
parent: Metro.ModuleID,
|
||||||
|
): boolean {
|
||||||
|
const lenA = a.length
|
||||||
|
|
||||||
|
for (let i = 0; i < b.length; i++) {
|
||||||
|
const compare = b[i]
|
||||||
|
|
||||||
|
// Dynamic dependencies match anything, so they carry no meaning when unordered
|
||||||
|
if (compare == null) continue
|
||||||
|
|
||||||
|
let found = false
|
||||||
|
|
||||||
|
for (let j = 0; j < lenA; j++) {
|
||||||
|
const id = a[j]
|
||||||
|
if (id !== null && depMatches(compare, id, root, parent)) {
|
||||||
|
found = true
|
||||||
|
break
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!found) return false
|
||||||
|
}
|
||||||
|
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
|
||||||
|
function depMatches(
|
||||||
|
compare: NonNullable<ComparableDependencyMap[number]>,
|
||||||
|
id: Metro.ModuleID,
|
||||||
|
root: Metro.ModuleID,
|
||||||
|
parent: Metro.ModuleID,
|
||||||
|
): boolean {
|
||||||
|
switch (typeof compare) {
|
||||||
|
case 'function':
|
||||||
|
return !!runFilter(compare, id, getInitializedModuleExports(id))
|
||||||
case 'object': {
|
case 'object': {
|
||||||
const nested = compare as ComparableDependencyMap
|
const nested = compare
|
||||||
|
|
||||||
// relative.withDependencies?
|
// relative.withDependencies?
|
||||||
if (nested.r && !depShallowCompare(nested.r, id, root, parent))
|
if (nested.r && !depShallowCompare(nested.r, id, root, parent))
|
||||||
return false
|
return false
|
||||||
|
|
||||||
if (depCompare(getModuleDependencies(id)!, nested, root, id))
|
return depCompare(getModuleDependencies(id)!, nested, root, id)
|
||||||
continue
|
|
||||||
|
|
||||||
return false
|
|
||||||
}
|
}
|
||||||
default: {
|
default:
|
||||||
if (depShallowCompare(compare as number, id, root, parent))
|
return depShallowCompare(compare, id, root, parent)
|
||||||
continue
|
|
||||||
return false
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
|
||||||
|
|
||||||
return true
|
|
||||||
}
|
|
||||||
|
|
||||||
function depShallowCompare(
|
function depShallowCompare(
|
||||||
compare: number,
|
compare: number,
|
||||||
|
|
@ -291,11 +513,7 @@ function depGenFilterKey(deps: ComparableDependencyMap): string {
|
||||||
case 'object': {
|
case 'object': {
|
||||||
// It's a nested dependency array
|
// It's a nested dependency array
|
||||||
const nested = dep as ComparableDependencyMap
|
const nested = dep as ComparableDependencyMap
|
||||||
if (nested.l) key += '#'
|
key += `${depGenModifierKey(nested)}[${depGenFilterKey(nested)}],`
|
||||||
// relative.withDependencies?
|
|
||||||
if (nested.r) key += `${depGenRelativeKeyPart(nested.r)}:`
|
|
||||||
|
|
||||||
key += `[${depGenFilterKey(nested)}],`
|
|
||||||
break
|
break
|
||||||
}
|
}
|
||||||
default: {
|
default: {
|
||||||
|
|
@ -311,6 +529,20 @@ function depGenFilterKey(deps: ComparableDependencyMap): string {
|
||||||
return key.substring(0, key.length - 1)
|
return key.substring(0, key.length - 1)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function depGenModifierKey(deps: ComparableDependencyMap): string {
|
||||||
|
let key = ''
|
||||||
|
|
||||||
|
if (deps.i) key += '?'
|
||||||
|
if (deps.l) key += '#'
|
||||||
|
if (deps.n !== undefined) key += `>${deps.n}`
|
||||||
|
if (deps.x !== undefined) key += `<${deps.x}`
|
||||||
|
if (deps.s !== undefined) key += `+${deps.s === Infinity ? '*' : deps.s}`
|
||||||
|
// relative.withDependencies?
|
||||||
|
if (deps.r) key += `${depGenRelativeKeyPart(deps.r)}:`
|
||||||
|
|
||||||
|
return key
|
||||||
|
}
|
||||||
|
|
||||||
function depGenRelativeKeyPart(dep: number) {
|
function depGenRelativeKeyPart(dep: number) {
|
||||||
const magnitude = depGetRelMagnitude(dep)
|
const magnitude = depGetRelMagnitude(dep)
|
||||||
const prefix = dep & RelativeRootBit ? '~' : '^'
|
const prefix = dep & RelativeRootBit ? '~' : '^'
|
||||||
|
|
|
||||||
|
|
@ -1,5 +1,5 @@
|
||||||
import { isModuleExportBad } from '@revenge-mod/modules/metro/utils'
|
import { isModuleExportBad } from '@revenge-mod/modules/metro/utils'
|
||||||
import { FilterFlag, FilterScopes } from './constants'
|
import { FilterScopes } from './constants'
|
||||||
import { createFilterGenerator } from './utils'
|
import { createFilterGenerator } from './utils'
|
||||||
import type { Filter, FilterGenerator } from './utils'
|
import type { Filter, FilterGenerator } from './utils'
|
||||||
|
|
||||||
|
|
@ -8,7 +8,6 @@ export * from './utils'
|
||||||
|
|
||||||
type FilterRequiringExports<T> = Filter<{
|
type FilterRequiringExports<T> = Filter<{
|
||||||
Result: T
|
Result: T
|
||||||
RequiresExports: true
|
|
||||||
Scopes: [typeof FilterScopes.Initialized]
|
Scopes: [typeof FilterScopes.Initialized]
|
||||||
}>
|
}>
|
||||||
|
|
||||||
|
|
@ -41,7 +40,6 @@ export const withProps = createFilterGenerator<Parameters<WithProps>>(
|
||||||
return false
|
return false
|
||||||
},
|
},
|
||||||
props => `revenge.props(${props.join(',')})`,
|
props => `revenge.props(${props.join(',')})`,
|
||||||
FilterFlag.RequiresExports,
|
|
||||||
FilterScopes.Initialized,
|
FilterScopes.Initialized,
|
||||||
) as WithProps
|
) as WithProps
|
||||||
|
|
||||||
|
|
@ -67,7 +65,6 @@ export const withoutProps = createFilterGenerator<Parameters<WithoutProps>>(
|
||||||
return true
|
return true
|
||||||
},
|
},
|
||||||
props => `revenge.withoutProps(${props.join(',')})`,
|
props => `revenge.withoutProps(${props.join(',')})`,
|
||||||
FilterFlag.RequiresExports,
|
|
||||||
FilterScopes.Initialized,
|
FilterScopes.Initialized,
|
||||||
) as WithoutProps
|
) as WithoutProps
|
||||||
|
|
||||||
|
|
@ -97,7 +94,6 @@ export const withSingleProp = createFilterGenerator<Parameters<WithSingleProp>>(
|
||||||
return false
|
return false
|
||||||
},
|
},
|
||||||
([prop]) => `revenge.singleProp(${prop})`,
|
([prop]) => `revenge.singleProp(${prop})`,
|
||||||
FilterFlag.RequiresExports,
|
|
||||||
FilterScopes.Initialized,
|
FilterScopes.Initialized,
|
||||||
) as WithSingleProp
|
) as WithSingleProp
|
||||||
|
|
||||||
|
|
@ -138,7 +134,6 @@ export type WithSingleProp = FilterGenerator<
|
||||||
export const withName = createFilterGenerator<Parameters<WithName>>(
|
export const withName = createFilterGenerator<Parameters<WithName>>(
|
||||||
([name], _, exports) => exports?.name === name,
|
([name], _, exports) => exports?.name === name,
|
||||||
([name]) => `revenge.name(${name})`,
|
([name]) => `revenge.name(${name})`,
|
||||||
FilterFlag.RequiresExports,
|
|
||||||
FilterScopes.Initialized,
|
FilterScopes.Initialized,
|
||||||
) as WithName
|
) as WithName
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -1,40 +1,32 @@
|
||||||
|
import { isModuleInitialized } from '@revenge-mod/modules/metro/utils'
|
||||||
import { getErrorStack } from '@revenge-mod/utils/error'
|
import { getErrorStack } from '@revenge-mod/utils/error'
|
||||||
import { withProps } from '.'
|
import { withProps } from '.'
|
||||||
import { and, or } from './composite'
|
import { and, or } from './composite'
|
||||||
import { FilterScopes } from './constants'
|
import { FilterScopes } from './constants'
|
||||||
import type { Metro } from '@revenge-mod/modules/types'
|
import type { Metro } from '@revenge-mod/modules/types'
|
||||||
import type { If, LogicalAnd } from '@revenge-mod/utils/types'
|
|
||||||
import type {
|
import type {
|
||||||
DefaultFilterInfo,
|
DefaultFilterInfo,
|
||||||
FilterFlag,
|
|
||||||
FilterInfo,
|
FilterInfo,
|
||||||
FilterScope,
|
FilterScope,
|
||||||
FilterScopes as FilterScopesObject,
|
|
||||||
FilterScopeValue,
|
FilterScopeValue,
|
||||||
} from './constants'
|
} from './constants'
|
||||||
|
|
||||||
export type FilterResult<F> = F extends Filter<infer I> ? I['Result'] : never
|
export type FilterResult<F> = F extends Filter<infer I> ? I['Result'] : never
|
||||||
|
|
||||||
export type FilterRequiresExports<F> =
|
|
||||||
F extends Filter<infer I> ? I['RequiresExports'] : never
|
|
||||||
|
|
||||||
export type FilterInfoOf<F> = F extends Filter<infer I> ? I : FilterInfo
|
export type FilterInfoOf<F> = F extends Filter<infer I> ? I : FilterInfo
|
||||||
|
|
||||||
|
declare const FilterInfoBrand: unique symbol
|
||||||
|
|
||||||
export interface FilterBase<Info extends FilterInfo = DefaultFilterInfo> {
|
export interface FilterBase<Info extends FilterInfo = DefaultFilterInfo> {
|
||||||
(
|
(
|
||||||
...args: If<
|
id: Metro.ModuleID,
|
||||||
Info['RequiresExports'],
|
exports?: Metro.ModuleExports,
|
||||||
[id: Metro.ModuleID, exports: Metro.ModuleExports],
|
initialized?: boolean,
|
||||||
[id: Metro.ModuleID, exports?: never]
|
|
||||||
>
|
|
||||||
): boolean
|
): boolean
|
||||||
key: string
|
key: string
|
||||||
flags: If<
|
|
||||||
Info['RequiresExports'],
|
|
||||||
(typeof FilterFlag)['RequiresExports'],
|
|
||||||
FilterFlag
|
|
||||||
>
|
|
||||||
scopes: FilterScopeValue
|
scopes: FilterScopeValue
|
||||||
|
/** @internal */
|
||||||
|
readonly [FilterInfoBrand]?: Info
|
||||||
}
|
}
|
||||||
|
|
||||||
export type Filter<Info extends FilterInfo = DefaultFilterInfo> =
|
export type Filter<Info extends FilterInfo = DefaultFilterInfo> =
|
||||||
|
|
@ -42,13 +34,11 @@ export type Filter<Info extends FilterInfo = DefaultFilterInfo> =
|
||||||
|
|
||||||
export type MergeFilterInfo<I1 extends FilterInfo, I2 extends FilterInfo> = {
|
export type MergeFilterInfo<I1 extends FilterInfo, I2 extends FilterInfo> = {
|
||||||
Result: I1['Result'] & I2['Result']
|
Result: I1['Result'] & I2['Result']
|
||||||
RequiresExports: LogicalAnd<I1['RequiresExports'], I2['RequiresExports']>
|
|
||||||
Scopes: [...I1['Scopes'], ...I2['Scopes']]
|
Scopes: [...I1['Scopes'], ...I2['Scopes']]
|
||||||
}
|
}
|
||||||
|
|
||||||
export type UnionFilterInfo<I1 extends FilterInfo, I2 extends FilterInfo> = {
|
export type UnionFilterInfo<I1 extends FilterInfo, I2 extends FilterInfo> = {
|
||||||
Result: I1['Result'] | I2['Result']
|
Result: I1['Result'] | I2['Result']
|
||||||
RequiresExports: LogicalAnd<I1['RequiresExports'], I2['RequiresExports']>
|
|
||||||
Scopes: [...I1['Scopes'], ...I2['Scopes']]
|
Scopes: [...I1['Scopes'], ...I2['Scopes']]
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -91,25 +81,12 @@ export interface FilterHelpers<Info extends FilterInfo = DefaultFilterInfo> {
|
||||||
*/
|
*/
|
||||||
scope<T extends Filter<Info>, const S extends FilterScope[]>(
|
scope<T extends Filter<Info>, const S extends FilterScope[]>(
|
||||||
this: T,
|
this: T,
|
||||||
...scopes: If<
|
...scopes: S
|
||||||
Info['RequiresExports'],
|
): Filter<Info & { Scopes: S }>
|
||||||
[typeof FilterScopesObject.Initialized],
|
|
||||||
S
|
|
||||||
>
|
|
||||||
): Filter<
|
|
||||||
Info & {
|
|
||||||
Scopes: If<
|
|
||||||
Info['RequiresExports'],
|
|
||||||
[typeof FilterScopesObject.Initialized],
|
|
||||||
S
|
|
||||||
>
|
|
||||||
}
|
|
||||||
>
|
|
||||||
}
|
}
|
||||||
|
|
||||||
export type FilterGenerator<G extends (...args: any[]) => Filter> = G & {
|
export type FilterGenerator<G extends (...args: any[]) => Filter> = G & {
|
||||||
keyFor(args: Parameters<G>): string
|
keyFor(args: Parameters<G>): string
|
||||||
flagsFor(args: Parameters<G>): FilterFlag
|
|
||||||
defaultScopesFor(args: Parameters<G>): FilterScopeValue
|
defaultScopesFor(args: Parameters<G>): FilterScopeValue
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -129,7 +106,8 @@ const Helpers: FilterHelpers = Object.setPrototypeOf(
|
||||||
const newFilter = ((
|
const newFilter = ((
|
||||||
id: Metro.ModuleID,
|
id: Metro.ModuleID,
|
||||||
exports?: Metro.ModuleExports,
|
exports?: Metro.ModuleExports,
|
||||||
) => this(id, exports)) as typeof this
|
initialized?: boolean,
|
||||||
|
) => this(id, exports, initialized)) as typeof this
|
||||||
|
|
||||||
Object.assign(newFilter, this)
|
Object.assign(newFilter, this)
|
||||||
|
|
||||||
|
|
@ -139,7 +117,9 @@ const Helpers: FilterHelpers = Object.setPrototypeOf(
|
||||||
const filter = this.new()
|
const filter = this.new()
|
||||||
filter.scopes = 0
|
filter.scopes = 0
|
||||||
for (const scope of scopes) filter.scopes |= scope
|
for (const scope of scopes) filter.scopes |= scope
|
||||||
return filter
|
return filter as Filter<
|
||||||
|
FilterInfoOf<typeof filter> & { Scopes: typeof scopes }
|
||||||
|
>
|
||||||
},
|
},
|
||||||
} satisfies FilterHelpers,
|
} satisfies FilterHelpers,
|
||||||
Function.prototype,
|
Function.prototype,
|
||||||
|
|
@ -150,15 +130,14 @@ const Helpers: FilterHelpers = Object.setPrototypeOf(
|
||||||
*
|
*
|
||||||
* @param filter The function that filters the modules.
|
* @param filter The function that filters the modules.
|
||||||
* @param keyFor The function that generates the key for the filter.
|
* @param keyFor The function that generates the key for the filter.
|
||||||
* @param flagFor The function that generates the flags for the filter, or a static flag.
|
|
||||||
* @param defaultScopesFor The function that generates the default scopes for the filter, or static scopes. Defaults to {@link FilterScopes.Initialized}.
|
* @param defaultScopesFor The function that generates the default scopes for the filter, or static scopes. Defaults to {@link FilterScopes.Initialized}.
|
||||||
* @returns A function that generates a filter with the specified arguments.
|
* @returns A function that generates a filter with the specified arguments.
|
||||||
*
|
*
|
||||||
* @example
|
* @example
|
||||||
* ```ts
|
* ```ts
|
||||||
* const custom = createFilterGenerator<[arg1: number, arg2: string]>(
|
* const custom = createFilterGenerator<[arg1: number, arg2: string]>(
|
||||||
* ([arg1, arg2], id, exports) => {
|
* ([arg1, arg2], id, exports, initialized) => {
|
||||||
* // WARNING: exports can be Proxy or nullish, so be careful when using it
|
* // WARNING: exports can be a Proxy, nullish, or a primitive, so be careful when using it
|
||||||
* // filter logic
|
* // filter logic
|
||||||
* return true
|
* return true
|
||||||
* },
|
* },
|
||||||
|
|
@ -173,16 +152,15 @@ export function createFilterGenerator<A extends any[]>(
|
||||||
args: A,
|
args: A,
|
||||||
id: Metro.ModuleID,
|
id: Metro.ModuleID,
|
||||||
exports: Metro.ModuleExports,
|
exports: Metro.ModuleExports,
|
||||||
|
initialized: boolean,
|
||||||
) => boolean,
|
) => boolean,
|
||||||
keyFor: (args: A) => string,
|
keyFor: (args: A) => string,
|
||||||
flagFor: ((args: A) => FilterFlag) | FilterFlag,
|
|
||||||
defaultScopesFor?: ((args: A) => FilterScopeValue) | FilterScopeValue,
|
defaultScopesFor?: ((args: A) => FilterScopeValue) | FilterScopeValue,
|
||||||
): FilterGenerator<(...args: A) => Filter>
|
): FilterGenerator<(...args: A) => Filter>
|
||||||
|
|
||||||
export function createFilterGenerator<A extends any[]>(
|
export function createFilterGenerator<A extends any[]>(
|
||||||
filter: (args: A, id: Metro.ModuleID) => boolean,
|
filter: (args: A, id: Metro.ModuleID) => boolean,
|
||||||
keyFor: (args: A) => string,
|
keyFor: (args: A) => string,
|
||||||
flagFor: ((args: A) => FilterFlag) | FilterFlag,
|
|
||||||
defaultScopesFor?: ((args: A) => FilterScopeValue) | FilterScopeValue,
|
defaultScopesFor?: ((args: A) => FilterScopeValue) | FilterScopeValue,
|
||||||
): FilterGenerator<(...args: A) => Filter>
|
): FilterGenerator<(...args: A) => Filter>
|
||||||
|
|
||||||
|
|
@ -190,16 +168,14 @@ export function createFilterGenerator<A extends any[]>(
|
||||||
filter: (
|
filter: (
|
||||||
args: A,
|
args: A,
|
||||||
id: Metro.ModuleID,
|
id: Metro.ModuleID,
|
||||||
exports?: Metro.ModuleExports,
|
exports: Metro.ModuleExports,
|
||||||
|
initialized: boolean,
|
||||||
) => boolean,
|
) => boolean,
|
||||||
keyFor: (args: A) => string,
|
keyFor: (args: A) => string,
|
||||||
flagFor: ((args: A) => FilterFlag) | FilterFlag,
|
|
||||||
defaultScopesFor?: ((args: A) => FilterScopeValue) | FilterScopeValue,
|
defaultScopesFor?: ((args: A) => FilterScopeValue) | FilterScopeValue,
|
||||||
): FilterGenerator<(...args: A) => Filter> {
|
): FilterGenerator<(...args: A) => Filter> {
|
||||||
type GeneratorType = ReturnType<typeof createFilterGenerator<A>>
|
type GeneratorType = ReturnType<typeof createFilterGenerator<A>>
|
||||||
|
|
||||||
const isFlagsStatic = typeof flagFor === 'number'
|
|
||||||
|
|
||||||
const defaultScopes = defaultScopesFor ?? FilterScopes.Initialized
|
const defaultScopes = defaultScopesFor ?? FilterScopes.Initialized
|
||||||
const isDefaultScopesStatic = typeof defaultScopes === 'number'
|
const isDefaultScopesStatic = typeof defaultScopes === 'number'
|
||||||
|
|
||||||
|
|
@ -207,9 +183,10 @@ export function createFilterGenerator<A extends any[]>(
|
||||||
const filter_ = ((
|
const filter_ = ((
|
||||||
id: Metro.ModuleID,
|
id: Metro.ModuleID,
|
||||||
exports?: Metro.ModuleExports,
|
exports?: Metro.ModuleExports,
|
||||||
|
initialized: boolean = Boolean(isModuleInitialized(id)),
|
||||||
) => {
|
) => {
|
||||||
try {
|
try {
|
||||||
return filter(args, id, exports)
|
return filter(args, id, exports, initialized)
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
DEBUG_warnFilterThrown(id, keyFor(args), e)
|
DEBUG_warnFilterThrown(id, keyFor(args), e)
|
||||||
return false
|
return false
|
||||||
|
|
@ -217,17 +194,12 @@ export function createFilterGenerator<A extends any[]>(
|
||||||
}) as ReturnType<GeneratorType>
|
}) as ReturnType<GeneratorType>
|
||||||
|
|
||||||
filter_.key = keyFor(args)
|
filter_.key = keyFor(args)
|
||||||
filter_.flags = isFlagsStatic ? flagFor : flagFor(args)
|
|
||||||
filter_.scopes = isDefaultScopesStatic
|
filter_.scopes = isDefaultScopesStatic
|
||||||
? defaultScopes
|
? defaultScopes
|
||||||
: defaultScopes(args)
|
: defaultScopes(args)
|
||||||
return Object.setPrototypeOf(filter_, Helpers)
|
return Object.setPrototypeOf(filter_, Helpers)
|
||||||
}
|
}
|
||||||
|
|
||||||
generator.flagsFor = isFlagsStatic
|
|
||||||
? () => flagFor
|
|
||||||
: (args: A) => flagFor(args)
|
|
||||||
|
|
||||||
generator.defaultScopesFor = isDefaultScopesStatic
|
generator.defaultScopesFor = isDefaultScopesStatic
|
||||||
? () => defaultScopes
|
? () => defaultScopes
|
||||||
: defaultScopes
|
: defaultScopes
|
||||||
|
|
|
||||||
|
|
@ -183,11 +183,19 @@ export function* lookupModules(filter: Filter, options?: LookupModulesOptions) {
|
||||||
}
|
}
|
||||||
|
|
||||||
if (includeUninit)
|
if (includeUninit)
|
||||||
for (const id of mUninitialized) {
|
// Must snapshot because partial matches can cause modules that haven't been looped over to be initialized
|
||||||
|
// which would remove them from the Set
|
||||||
|
for (const id of Array.from(mUninitialized)) {
|
||||||
// biome-ignore lint/complexity/useOptionalChain: Hot path should be optimized
|
// biome-ignore lint/complexity/useOptionalChain: Hot path should be optimized
|
||||||
if (cached && cached.has(id)) continue
|
if (cached && cached.has(id)) continue
|
||||||
|
|
||||||
const flag = runFilter(filter, id, undefined, options)
|
const flag = runFilter(
|
||||||
|
filter,
|
||||||
|
id,
|
||||||
|
// Could be initialized by a previous partial match
|
||||||
|
getInitializedModuleExports(id),
|
||||||
|
options,
|
||||||
|
)
|
||||||
if (flag) {
|
if (flag) {
|
||||||
notFound = false
|
notFound = false
|
||||||
|
|
||||||
|
|
@ -308,8 +316,15 @@ export function lookupModule(filter: Filter, options?: LookupModulesOptions) {
|
||||||
}
|
}
|
||||||
|
|
||||||
if (includeUninit)
|
if (includeUninit)
|
||||||
for (const id of mUninitialized) {
|
// See equivalent code in `lookupModules` for why this is snapshotted
|
||||||
const flag = runFilter(filter, id, undefined, options)
|
for (const id of [...mUninitialized]) {
|
||||||
|
const flag = runFilter(
|
||||||
|
filter,
|
||||||
|
id,
|
||||||
|
// See equivalent code in `lookupModules` for why this isn't undefined
|
||||||
|
getInitializedModuleExports(id),
|
||||||
|
options,
|
||||||
|
)
|
||||||
if (flag) {
|
if (flag) {
|
||||||
if (__BUILD_FLAG_DEBUG_MODULE_LOOKUPS__)
|
if (__BUILD_FLAG_DEBUG_MODULE_LOOKUPS__)
|
||||||
DEBUG_logLookupMatched(filter.key, flag, id)
|
DEBUG_logLookupMatched(filter.key, flag, id)
|
||||||
|
|
|
||||||
|
|
@ -8,14 +8,14 @@
|
||||||
import { callNativeMethodSync } from '@revenge-mod/modules/native'
|
import { callNativeMethodSync } from '@revenge-mod/modules/native'
|
||||||
import { getErrorStack } from '@revenge-mod/utils/error'
|
import { getErrorStack } from '@revenge-mod/utils/error'
|
||||||
import { FullVersion } from '~constants'
|
import { FullVersion } from '~constants'
|
||||||
import { loadModuleFromSegment, mInitializingId, mList } from './patches'
|
import { loadModuleFromSegment, mList } from './patches'
|
||||||
import {
|
import {
|
||||||
executeInitializeSubscriptions,
|
executeInitializeSubscriptions,
|
||||||
executeRequireSubscriptions,
|
executeRequireSubscriptions,
|
||||||
} from './subscriptions/_internal'
|
} from './subscriptions/_internal'
|
||||||
import type { Metro } from '../types'
|
import type { Metro } from '../types'
|
||||||
|
|
||||||
export const mErrorChain: Metro.ModuleID[] = []
|
export const mErrorChain: [Metro.ModuleID, unknown][] = []
|
||||||
|
|
||||||
export const Initialized = 1 << 0
|
export const Initialized = 1 << 0
|
||||||
const HasError = 1 << 1
|
const HasError = 1 << 1
|
||||||
|
|
@ -62,7 +62,7 @@ export const metroRequire = (moduleId => {
|
||||||
mod.flags = (flags & NotInitializedOrInitializingMask) | HasError
|
mod.flags = (flags & NotInitializedOrInitializingMask) | HasError
|
||||||
mod.error = e
|
mod.error = e
|
||||||
|
|
||||||
const msg = `Module ${mInitializingId} failed to initialize:\n\n${getErrorStack(e)}`
|
const msg = `Module ${moduleId} failed to initialize:\n\n${getErrorStack(e)}`
|
||||||
|
|
||||||
if (__DEV__) {
|
if (__DEV__) {
|
||||||
callNativeMethodSync('revenge.alertError', [msg, FullVersion])
|
callNativeMethodSync('revenge.alertError', [msg, FullVersion])
|
||||||
|
|
@ -71,7 +71,7 @@ export const metroRequire = (moduleId => {
|
||||||
}
|
}
|
||||||
|
|
||||||
// Some modifications can cause modules to fail by initializing them in the wrong order, we can't just blacklist them
|
// Some modifications can cause modules to fail by initializing them in the wrong order, we can't just blacklist them
|
||||||
// cacheBlacklistedModule(mInitializingId)
|
// cacheBlacklistedModule(moduleId)
|
||||||
|
|
||||||
// So... it wasn't a great idea to throw, Discord has pushed a broken build that has some failing modules
|
// So... it wasn't a great idea to throw, Discord has pushed a broken build that has some failing modules
|
||||||
// Vanilla Metro would swallow the error and just return an empty object as the exports..., insanity
|
// Vanilla Metro would swallow the error and just return an empty object as the exports..., insanity
|
||||||
|
|
@ -80,7 +80,7 @@ export const metroRequire = (moduleId => {
|
||||||
// if (global.ErrorUtils) global.ErrorUtils.reportFatalError(e)
|
// if (global.ErrorUtils) global.ErrorUtils.reportFatalError(e)
|
||||||
// else throw e
|
// else throw e
|
||||||
|
|
||||||
mErrorChain.push(moduleId)
|
mErrorChain.push([moduleId, e])
|
||||||
|
|
||||||
return (moduleObject.exports = {})
|
return (moduleObject.exports = {})
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -50,7 +50,11 @@ export function isModuleExportBad(
|
||||||
return (
|
return (
|
||||||
// Nullish?
|
// Nullish?
|
||||||
exp == null ||
|
exp == null ||
|
||||||
// Does it have some non-existent key? (Turbo modules)
|
// Does it have some non-existent key? (proxies)
|
||||||
(typeof exp === 'object' && CATCH_ALL_TEST in exp)
|
(typeof exp === 'object' &&
|
||||||
|
// Implementing Reflect.get
|
||||||
|
(exp[CATCH_ALL_TEST] !== undefined ||
|
||||||
|
// Implementing Reflect.ownKeys
|
||||||
|
CATCH_ALL_TEST in exp))
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -21,6 +21,7 @@ import {
|
||||||
ApiDependencyId,
|
ApiDependencyId,
|
||||||
computePendingNodes,
|
computePendingNodes,
|
||||||
DiscordDependencyId,
|
DiscordDependencyId,
|
||||||
|
isReservedDependency,
|
||||||
pLeafOrSingleNodes,
|
pLeafOrSingleNodes,
|
||||||
pListOrdered,
|
pListOrdered,
|
||||||
pPending,
|
pPending,
|
||||||
|
|
@ -347,9 +348,12 @@ export function registerInternalPlugin<O extends PluginApiExtensionsOptions>(
|
||||||
manifest.version ??= InternalPluginVersion
|
manifest.version ??= InternalPluginVersion
|
||||||
// TODO: This has to be shared from native somehow.
|
// TODO: This has to be shared from native somehow.
|
||||||
manifest.format ??= 1
|
manifest.format ??= 1
|
||||||
|
|
||||||
|
if (!isReservedDependency(manifest.id)) {
|
||||||
manifest.dependencies ??= {}
|
manifest.dependencies ??= {}
|
||||||
manifest.dependencies[ApiDependencyId] ??= { version: '*' }
|
manifest.dependencies[ApiDependencyId] ??= { version: '*' }
|
||||||
manifest.dependencies[DiscordDependencyId] ??= { version: '*' }
|
manifest.dependencies[DiscordDependencyId] ??= { version: '*' }
|
||||||
|
}
|
||||||
|
|
||||||
return register(manifest as PluginManifest, options, defflags, iflags)
|
return register(manifest as PluginManifest, options, defflags, iflags)
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -160,8 +160,6 @@ export interface RepoUpdate {
|
||||||
installed: string
|
installed: string
|
||||||
available: string
|
available: string
|
||||||
channel: string
|
channel: string
|
||||||
/** Repository URL this update was found in, added by {@link listAllUpdates}. */
|
|
||||||
repo?: string
|
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
|
|
@ -229,7 +227,7 @@ export async function listAllUpdates(): Promise<{
|
||||||
.map(repo =>
|
.map(repo =>
|
||||||
listUpdates(repo.url).then(
|
listUpdates(repo.url).then(
|
||||||
result => {
|
result => {
|
||||||
updates.push(...result.map(u => ({ ...u, repo: repo.url })))
|
updates.push(...result)
|
||||||
},
|
},
|
||||||
error => {
|
error => {
|
||||||
errors.push({ url: repo.url, error })
|
errors.push({ url: repo.url, error })
|
||||||
|
|
|
||||||
|
|
@ -7,42 +7,50 @@ import {
|
||||||
withDependencies,
|
withDependencies,
|
||||||
} from '@revenge-mod/modules/finders/filters'
|
} from '@revenge-mod/modules/finders/filters'
|
||||||
import { isModuleExportBad } from '@revenge-mod/modules/metro/utils'
|
import { isModuleExportBad } from '@revenge-mod/modules/metro/utils'
|
||||||
import {
|
import { FilterScopes } from '../../modules/src/finders/filters/constants'
|
||||||
ReactJSXRuntimeModuleId,
|
|
||||||
ReactModuleId,
|
|
||||||
ReactNativeModuleId,
|
|
||||||
} from '@revenge-mod/react'
|
|
||||||
import {
|
|
||||||
FilterFlag,
|
|
||||||
FilterScopes,
|
|
||||||
} from '../../modules/src/finders/filters/constants'
|
|
||||||
import type {
|
import type {
|
||||||
Filter,
|
Filter,
|
||||||
FilterGenerator,
|
FilterGenerator,
|
||||||
} from '@revenge-mod/modules/finders/filters'
|
} from '@revenge-mod/modules/finders/filters'
|
||||||
|
import type { Metro } from '@revenge-mod/modules/types'
|
||||||
import type { FC } from 'react'
|
import type { FC } from 'react'
|
||||||
|
|
||||||
// [React, ReactJsxRuntime, (Tokens), (BaseIconImage), (Asset), ImportTracker]
|
const { last } = withDependencies
|
||||||
// Asset = relativeDep(1)
|
|
||||||
const IconComponentFilter = [
|
|
||||||
ReactModuleId,
|
|
||||||
ReactJSXRuntimeModuleId,
|
|
||||||
TokensModuleId,
|
|
||||||
null,
|
|
||||||
null,
|
|
||||||
ImportTrackerModuleId,
|
|
||||||
]
|
|
||||||
|
|
||||||
// Generated IconComponents that have multiple assets, there is no specific length, so the filter has to be generated on-the-fly.
|
const depsFilters: Record<string, Filter> = {}
|
||||||
// [React, ReactNative, ReactJsxRuntime, (Tokens), (BaseIconImage), (...Assets), ImportTracker]
|
|
||||||
// ...Assets = relativeDep(1..n)
|
/**
|
||||||
const MultiIconComponentFilterBase = [
|
* Builds a dependency filter for an icon component.
|
||||||
ReactModuleId,
|
*
|
||||||
ReactNativeModuleId,
|
* `[React, (ReactNative), ReactJsxRuntime, Tokens, (BaseIconImage), (...Assets), ImportTracker]`
|
||||||
ReactJSXRuntimeModuleId,
|
*
|
||||||
TokensModuleId,
|
* `ReactNative` is only present on components with multiple assets, and it sits right after `React`,
|
||||||
null,
|
* so everything from `ReactJsxRuntime` onwards is at a fixed distance from the end on both shapes.
|
||||||
]
|
*
|
||||||
|
* @param names The component name, then the asset names if the component has multiple assets.
|
||||||
|
* @returns The filter, or `undefined` if any of the assets aren't registered yet.
|
||||||
|
*/
|
||||||
|
function depsFilterFor(names: string[]) {
|
||||||
|
const key = names.join(',')
|
||||||
|
const cached = depsFilters[key]
|
||||||
|
if (cached) return cached
|
||||||
|
|
||||||
|
// Components with multiple assets are named separately from their assets
|
||||||
|
const assets = names.length > 1 ? names.slice(1) : names
|
||||||
|
const mids: Metro.ModuleID[] = []
|
||||||
|
|
||||||
|
for (const name of assets) {
|
||||||
|
const mid = getAssetByName(name)?.moduleId
|
||||||
|
// Module ID can never be zero, so if it's falsy, it means the asset was not found.
|
||||||
|
if (!mid) return
|
||||||
|
|
||||||
|
mids.push(mid)
|
||||||
|
}
|
||||||
|
|
||||||
|
return (depsFilters[key] = withDependencies(
|
||||||
|
last([TokensModuleId, null, ...mids, ImportTrackerModuleId]),
|
||||||
|
))
|
||||||
|
}
|
||||||
|
|
||||||
export type WithGeneratedIconComponent = FilterGenerator<
|
export type WithGeneratedIconComponent = FilterGenerator<
|
||||||
<N extends string>(
|
<N extends string>(
|
||||||
|
|
@ -50,7 +58,6 @@ export type WithGeneratedIconComponent = FilterGenerator<
|
||||||
...assets: string[]
|
...assets: string[]
|
||||||
) => Filter<{
|
) => Filter<{
|
||||||
Result: { [K in N]: FC<any> }
|
Result: { [K in N]: FC<any> }
|
||||||
RequiresExports: boolean
|
|
||||||
Scopes: [
|
Scopes: [
|
||||||
typeof FilterScopes.Uninitialized,
|
typeof FilterScopes.Uninitialized,
|
||||||
typeof FilterScopes.Initialized,
|
typeof FilterScopes.Initialized,
|
||||||
|
|
@ -86,47 +93,13 @@ export type WithGeneratedIconComponent = FilterGenerator<
|
||||||
export const withGeneratedIconComponent = createFilterGenerator<
|
export const withGeneratedIconComponent = createFilterGenerator<
|
||||||
Parameters<WithGeneratedIconComponent>
|
Parameters<WithGeneratedIconComponent>
|
||||||
>(
|
>(
|
||||||
(names, id, exports) => {
|
(names, id, exports, initialized) => {
|
||||||
if (typeof exports === 'object' && isModuleExportBad(exports)) {
|
// Icon components always export their component, so unusable exports can never match
|
||||||
if (typeof exports[names[0]] === 'function')
|
if (initialized && isModuleExportBad(exports)) return false
|
||||||
return Object.keys(exports).length === 1
|
|
||||||
} else {
|
|
||||||
let filter = IconComponentFilter
|
|
||||||
|
|
||||||
if (names.length > 1) {
|
return depsFilterFor(names)?.(id, exports, initialized) ?? false
|
||||||
const mids = []
|
|
||||||
|
|
||||||
for (let i = 1; i < names.length; i++) {
|
|
||||||
const name = names[i]
|
|
||||||
const mid = getAssetByName(name)?.moduleId
|
|
||||||
// Module ID can never be zero, so if it's falsy, it means the asset was not found.
|
|
||||||
if (!mid) return false
|
|
||||||
|
|
||||||
mids.push(mid)
|
|
||||||
}
|
|
||||||
|
|
||||||
filter = [
|
|
||||||
...MultiIconComponentFilterBase,
|
|
||||||
...mids,
|
|
||||||
ImportTrackerModuleId,
|
|
||||||
]
|
|
||||||
} else {
|
|
||||||
const [name] = names
|
|
||||||
|
|
||||||
const mid = getAssetByName(name)?.moduleId
|
|
||||||
// Module ID can never be zero, so if it's falsy, it means the asset was not found.
|
|
||||||
if (!mid) return false
|
|
||||||
|
|
||||||
IconComponentFilter[4] = mid
|
|
||||||
}
|
|
||||||
|
|
||||||
return withDependencies(filter)(id, exports)
|
|
||||||
}
|
|
||||||
|
|
||||||
return false
|
|
||||||
},
|
},
|
||||||
names => `revenge.utils.discord.generatedIconComponent(${names.join(',')})`,
|
names => `revenge.utils.discord.generatedIconComponent(${names.join(',')})`,
|
||||||
FilterFlag.Dynamic,
|
|
||||||
FilterScopes.Uninitialized | FilterScopes.Initialized,
|
FilterScopes.Uninitialized | FilterScopes.Initialized,
|
||||||
) as WithGeneratedIconComponent
|
) as WithGeneratedIconComponent
|
||||||
|
|
||||||
|
|
|
||||||
BIN
revenge.bundle
BIN
revenge.bundle
Binary file not shown.
|
|
@ -79,7 +79,8 @@ export default async function build(dev = Dev, log = true) {
|
||||||
.trim(),
|
.trim(),
|
||||||
),
|
),
|
||||||
__DEV__: String(dev),
|
__DEV__: String(dev),
|
||||||
// __BUILD_DEFAULT_PLUGIN_REPOSITORY_URL__: ...,
|
__BUILD_DEFAULT_PLUGIN_REPOSITORY_URL__: 'undefined',
|
||||||
|
__BUILD_DONATE_URL__: 'undefined',
|
||||||
|
|
||||||
// See types/build.d.ts for what these flags do
|
// See types/build.d.ts for what these flags do
|
||||||
__BUILD_FLAG_DEBUG_MODULE_LOOKUPS__: String(dev),
|
__BUILD_FLAG_DEBUG_MODULE_LOOKUPS__: String(dev),
|
||||||
|
|
|
||||||
BIN
src/assets/RevengeDonate.webp
Normal file
BIN
src/assets/RevengeDonate.webp
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 14 KiB |
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" />
|
|
||||||
}
|
|
||||||
/>
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|
@ -32,11 +32,6 @@ export interface BrowseFilterAndSortActionSheetProps {
|
||||||
/** Checked repository URLs. */
|
/** Checked repository URLs. */
|
||||||
checked: string[]
|
checked: string[]
|
||||||
setChecked: (urls: string[]) => void
|
setChecked: (urls: string[]) => void
|
||||||
/** Available release channels across all listings. */
|
|
||||||
channels: string[]
|
|
||||||
/** Selected release channel. Empty string means default. */
|
|
||||||
channel: string
|
|
||||||
setChannel: (channel: string) => void
|
|
||||||
sort: BrowseSortKey
|
sort: BrowseSortKey
|
||||||
setSort: (sort: BrowseSortKey) => void
|
setSort: (sort: BrowseSortKey) => void
|
||||||
reverse: boolean
|
reverse: boolean
|
||||||
|
|
@ -47,9 +42,6 @@ export default function BrowseFilterAndSortActionSheet({
|
||||||
repos,
|
repos,
|
||||||
checked,
|
checked,
|
||||||
setChecked,
|
setChecked,
|
||||||
channels,
|
|
||||||
channel,
|
|
||||||
setChannel,
|
|
||||||
sort,
|
sort,
|
||||||
setSort,
|
setSort,
|
||||||
reverse,
|
reverse,
|
||||||
|
|
@ -84,19 +76,6 @@ export default function BrowseFilterAndSortActionSheet({
|
||||||
)
|
)
|
||||||
})}
|
})}
|
||||||
</TableRowGroup>
|
</TableRowGroup>
|
||||||
<TableRadioGroup
|
|
||||||
title="Release channel"
|
|
||||||
defaultValue={channel}
|
|
||||||
onChange={v => setChannel(v as string)}
|
|
||||||
>
|
|
||||||
<TableRadioRow
|
|
||||||
label="Default (latest / first available)"
|
|
||||||
value=""
|
|
||||||
/>
|
|
||||||
{channels.map(c => (
|
|
||||||
<TableRadioRow key={c} label={c} value={c} />
|
|
||||||
))}
|
|
||||||
</TableRadioGroup>
|
|
||||||
<TableRadioGroup
|
<TableRadioGroup
|
||||||
title="Sort by"
|
title="Sort by"
|
||||||
defaultValue={sort}
|
defaultValue={sort}
|
||||||
|
|
|
||||||
|
|
@ -1,10 +1,19 @@
|
||||||
import { getAssetIdByName } from '@revenge-mod/assets'
|
import { getAssetIdByName } from '@revenge-mod/assets'
|
||||||
import { ActionSheetActionCreators } from '@revenge-mod/discord/actions'
|
import { ActionSheetActionCreators } from '@revenge-mod/discord/actions'
|
||||||
import { Design } from '@revenge-mod/discord/design'
|
import { Design } from '@revenge-mod/discord/design'
|
||||||
|
import { useState } from 'react'
|
||||||
import { PluginInfo } from './PluginCard'
|
import { PluginInfo } from './PluginCard'
|
||||||
import { IdRow, RepositoryRow } from './PluginOptionsActionSheet'
|
import { IdRow, RepositoryRow } from './PluginOptionsActionSheet'
|
||||||
|
import type { RepoPluginListing } from '@revenge-mod/plugins/_/repositories'
|
||||||
|
|
||||||
const { ActionSheet, Button, Stack, TableRowGroup } = Design
|
const {
|
||||||
|
ActionSheet,
|
||||||
|
Button,
|
||||||
|
Stack,
|
||||||
|
TableRowGroup,
|
||||||
|
TableRadioGroup,
|
||||||
|
TableRadioRow,
|
||||||
|
} = Design
|
||||||
|
|
||||||
const DownloadIcon = getAssetIdByName('DownloadIcon', 'png')!
|
const DownloadIcon = getAssetIdByName('DownloadIcon', 'png')!
|
||||||
|
|
||||||
|
|
@ -15,9 +24,11 @@ export interface BrowsePluginActionSheetProps {
|
||||||
version: string
|
version: string
|
||||||
icon?: string
|
icon?: string
|
||||||
id: string
|
id: string
|
||||||
|
listing: RepoPluginListing
|
||||||
|
channel: string
|
||||||
/** Display text for the Repository row, eg. `Name (url)`. */
|
/** Display text for the Repository row, eg. `Name (url)`. */
|
||||||
repositoryText: string
|
repositoryText: string
|
||||||
onInstall: () => void
|
onInstall: (channel?: string, version?: string) => void
|
||||||
sheetKey: string
|
sheetKey: string
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -31,17 +42,26 @@ export default function BrowsePluginActionSheet({
|
||||||
version,
|
version,
|
||||||
icon,
|
icon,
|
||||||
id,
|
id,
|
||||||
|
listing,
|
||||||
|
channel,
|
||||||
repositoryText,
|
repositoryText,
|
||||||
onInstall,
|
onInstall,
|
||||||
sheetKey,
|
sheetKey,
|
||||||
}: BrowsePluginActionSheetProps) {
|
}: BrowsePluginActionSheetProps) {
|
||||||
|
const [selectedChannel, setSelectedChannel] = useState(channel)
|
||||||
|
const selectedVersion = selectedChannel
|
||||||
|
? (listing.channels[selectedChannel] ?? '')
|
||||||
|
: version
|
||||||
|
|
||||||
|
const channelNames = Object.keys(listing.channels)
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<ActionSheet>
|
<ActionSheet>
|
||||||
<Stack spacing={24} style={{ paddingTop: 8 }}>
|
<Stack spacing={24} style={{ paddingTop: 8 }}>
|
||||||
<PluginInfo
|
<PluginInfo
|
||||||
name={name}
|
name={name}
|
||||||
author={author}
|
author={author}
|
||||||
version={version}
|
version={selectedVersion}
|
||||||
description={description}
|
description={description}
|
||||||
icon={icon}
|
icon={icon}
|
||||||
actions={
|
actions={
|
||||||
|
|
@ -53,11 +73,26 @@ export default function BrowsePluginActionSheet({
|
||||||
ActionSheetActionCreators.hideActionSheet(
|
ActionSheetActionCreators.hideActionSheet(
|
||||||
sheetKey,
|
sheetKey,
|
||||||
)
|
)
|
||||||
onInstall()
|
onInstall(selectedChannel, selectedVersion)
|
||||||
}}
|
}}
|
||||||
/>
|
/>
|
||||||
}
|
}
|
||||||
/>
|
/>
|
||||||
|
{channelNames.length > 0 && (
|
||||||
|
<TableRadioGroup
|
||||||
|
title="Channel"
|
||||||
|
defaultValue={selectedChannel}
|
||||||
|
onChange={v => setSelectedChannel(v as string)}
|
||||||
|
>
|
||||||
|
{channelNames.map(c => (
|
||||||
|
<TableRadioRow
|
||||||
|
key={c}
|
||||||
|
label={c}
|
||||||
|
value={c}
|
||||||
|
/>
|
||||||
|
))}
|
||||||
|
</TableRadioGroup>
|
||||||
|
)}
|
||||||
<TableRowGroup title="Advanced">
|
<TableRowGroup title="Advanced">
|
||||||
<IdRow id={id} />
|
<IdRow id={id} />
|
||||||
<RepositoryRow text={repositoryText} copyable />
|
<RepositoryRow text={repositoryText} copyable />
|
||||||
|
|
|
||||||
|
|
@ -21,12 +21,9 @@ import {
|
||||||
} from '../utils/sheets'
|
} from '../utils/sheets'
|
||||||
import { PluginIcon } from './PluginIcon'
|
import { PluginIcon } from './PluginIcon'
|
||||||
import { usePluginEnabled } from './PluginStateProvider'
|
import { usePluginEnabled } from './PluginStateProvider'
|
||||||
import {
|
import { PluginTooltip, usePluginTooltip } from './TooltipProvider'
|
||||||
useClickOutsideTooltip,
|
|
||||||
useEnablePluginTooltip,
|
|
||||||
useEssentialPluginTooltip,
|
|
||||||
} from './TooltipProvider'
|
|
||||||
import type { AnyPlugin, InternalPluginMeta } from '@revenge-mod/plugins/_'
|
import type { AnyPlugin, InternalPluginMeta } from '@revenge-mod/plugins/_'
|
||||||
|
import type { RepoPluginListing } from '@revenge-mod/plugins/_/repositories'
|
||||||
|
|
||||||
const { Card, Text, Stack, IconButton, Button, createStyles } = Design
|
const { Card, Text, Stack, IconButton, Button, createStyles } = Design
|
||||||
|
|
||||||
|
|
@ -151,14 +148,12 @@ export const InstalledPluginCard = memo(function InstalledPluginCard({
|
||||||
|
|
||||||
const toggleDisabled = essential || pendingUpdate
|
const toggleDisabled = essential || pendingUpdate
|
||||||
|
|
||||||
const enableTooltip = useEnablePluginTooltip()
|
const [settingsRef, showEnableTooltip] = usePluginTooltip(
|
||||||
const essentialTooltip = useEssentialPluginTooltip()
|
PluginTooltip.Enable,
|
||||||
|
)
|
||||||
|
|
||||||
const settingsRef = useClickOutsideTooltip(useEnablePluginTooltip, () => {})
|
const [switchRef, showToggleTooltip] = usePluginTooltip(
|
||||||
|
essential ? PluginTooltip.Essential : PluginTooltip.PendingUpdate,
|
||||||
const switchRef = useClickOutsideTooltip(
|
|
||||||
useEssentialPluginTooltip,
|
|
||||||
() => {},
|
|
||||||
)
|
)
|
||||||
|
|
||||||
return (
|
return (
|
||||||
|
|
@ -181,12 +176,7 @@ export const InstalledPluginCard = memo(function InstalledPluginCard({
|
||||||
{plugin.SettingsComponent && (
|
{plugin.SettingsComponent && (
|
||||||
<Pressable
|
<Pressable
|
||||||
onPress={() => {
|
onPress={() => {
|
||||||
if (!startable)
|
if (!startable) showEnableTooltip()
|
||||||
requestAnimationFrame(() => {
|
|
||||||
enableTooltip.targetRef.current =
|
|
||||||
settingsRef.current
|
|
||||||
enableTooltip.setVisible(true)
|
|
||||||
})
|
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
<IconButton
|
<IconButton
|
||||||
|
|
@ -203,12 +193,7 @@ export const InstalledPluginCard = memo(function InstalledPluginCard({
|
||||||
)}
|
)}
|
||||||
<Pressable
|
<Pressable
|
||||||
onPress={() => {
|
onPress={() => {
|
||||||
if (toggleDisabled)
|
if (toggleDisabled) showToggleTooltip()
|
||||||
requestAnimationFrame(() => {
|
|
||||||
essentialTooltip.targetRef.current =
|
|
||||||
switchRef.current
|
|
||||||
essentialTooltip.setVisible(true)
|
|
||||||
})
|
|
||||||
}}
|
}}
|
||||||
ref={switchRef}
|
ref={switchRef}
|
||||||
>
|
>
|
||||||
|
|
@ -262,6 +247,8 @@ export const BrowsePluginCard = memo(function BrowsePluginCard({
|
||||||
author,
|
author,
|
||||||
icon,
|
icon,
|
||||||
id,
|
id,
|
||||||
|
listing,
|
||||||
|
channel,
|
||||||
repositoryText,
|
repositoryText,
|
||||||
onInstall,
|
onInstall,
|
||||||
}: {
|
}: {
|
||||||
|
|
@ -271,8 +258,10 @@ export const BrowsePluginCard = memo(function BrowsePluginCard({
|
||||||
author: string
|
author: string
|
||||||
icon?: string
|
icon?: string
|
||||||
id: string
|
id: string
|
||||||
|
listing: RepoPluginListing
|
||||||
|
channel: string
|
||||||
repositoryText: string
|
repositoryText: string
|
||||||
onInstall: () => void
|
onInstall: (channel?: string, version?: string) => void
|
||||||
}) {
|
}) {
|
||||||
return (
|
return (
|
||||||
<PluginCard
|
<PluginCard
|
||||||
|
|
@ -295,6 +284,8 @@ export const BrowsePluginCard = memo(function BrowsePluginCard({
|
||||||
version,
|
version,
|
||||||
icon,
|
icon,
|
||||||
id,
|
id,
|
||||||
|
listing,
|
||||||
|
channel,
|
||||||
repositoryText,
|
repositoryText,
|
||||||
onInstall,
|
onInstall,
|
||||||
})
|
})
|
||||||
|
|
|
||||||
|
|
@ -8,8 +8,10 @@ const PuzzlePieceIcon = getAssetIdByName('PuzzlePieceIcon', 'png')!
|
||||||
export function PluginIcon({
|
export function PluginIcon({
|
||||||
icon,
|
icon,
|
||||||
size = 20,
|
size = 20,
|
||||||
|
danger = false,
|
||||||
}: {
|
}: {
|
||||||
icon?: string
|
icon?: string
|
||||||
|
danger?: boolean
|
||||||
size?: number
|
size?: number
|
||||||
}) {
|
}) {
|
||||||
const styles = usePluginIconStyles()
|
const styles = usePluginIconStyles()
|
||||||
|
|
@ -26,7 +28,11 @@ export function PluginIcon({
|
||||||
? (getAssetIdByName(icon) ?? PuzzlePieceIcon)
|
? (getAssetIdByName(icon) ?? PuzzlePieceIcon)
|
||||||
: PuzzlePieceIcon
|
: PuzzlePieceIcon
|
||||||
}
|
}
|
||||||
style={[!dataUrl && styles.icon, { width: size, height: size }]}
|
style={[
|
||||||
|
!dataUrl && styles.icon,
|
||||||
|
danger && styles.danger,
|
||||||
|
{ width: size, height: size },
|
||||||
|
]}
|
||||||
/>
|
/>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
@ -35,4 +41,7 @@ const usePluginIconStyles = Design.createStyles({
|
||||||
icon: {
|
icon: {
|
||||||
tintColor: Tokens.default.colors.TEXT_DEFAULT,
|
tintColor: Tokens.default.colors.TEXT_DEFAULT,
|
||||||
},
|
},
|
||||||
|
danger: {
|
||||||
|
tintColor: Tokens.default.colors.TEXT_FEEDBACK_CRITICAL,
|
||||||
|
},
|
||||||
})
|
})
|
||||||
|
|
|
||||||
|
|
@ -9,7 +9,7 @@ import {
|
||||||
PLUGIN_CARD_HALF_GUTTER,
|
PLUGIN_CARD_HALF_GUTTER,
|
||||||
PluginCard,
|
PluginCard,
|
||||||
} from './PluginCard'
|
} from './PluginCard'
|
||||||
import { useResetTooltips } from './TooltipProvider'
|
import { useHidePluginTooltips } from './TooltipProvider'
|
||||||
import type { AnyPlugin, InternalPluginMeta } from '@revenge-mod/plugins/_'
|
import type { AnyPlugin, InternalPluginMeta } from '@revenge-mod/plugins/_'
|
||||||
import type { RepoPluginListing } from '@revenge-mod/plugins/_/repositories'
|
import type { RepoPluginListing } from '@revenge-mod/plugins/_/repositories'
|
||||||
import type { FlashListProps } from '@shopify/flash-list'
|
import type { FlashListProps } from '@shopify/flash-list'
|
||||||
|
|
@ -25,14 +25,14 @@ export function PluginFlashList({
|
||||||
FlashListProps<AnyPlugin>,
|
FlashListProps<AnyPlugin>,
|
||||||
'onContentSizeChange'
|
'onContentSizeChange'
|
||||||
>) {
|
>) {
|
||||||
const resetTooltips = useResetTooltips()
|
const hideTooltips = useHidePluginTooltips()
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<FlashList
|
<FlashList
|
||||||
style={gutterCompensation}
|
style={gutterCompensation}
|
||||||
onContentSizeChange={onContentSizeChange}
|
onContentSizeChange={onContentSizeChange}
|
||||||
data={plugins}
|
data={plugins}
|
||||||
onScrollBeginDrag={resetTooltips}
|
onScrollBeginDrag={hideTooltips}
|
||||||
fadingEdgeLength={plugins.length === 1 ? 0 : 16}
|
fadingEdgeLength={plugins.length === 1 ? 0 : 16}
|
||||||
keyExtractor={plugin => plugin.manifest.id}
|
keyExtractor={plugin => plugin.manifest.id}
|
||||||
renderItem={({
|
renderItem={({
|
||||||
|
|
@ -60,7 +60,7 @@ export function InstalledPluginMasonryFlashList({
|
||||||
plugins: (readonly [AnyPlugin, InternalPluginMeta])[]
|
plugins: (readonly [AnyPlugin, InternalPluginMeta])[]
|
||||||
}) {
|
}) {
|
||||||
const numColumns = useNumColumns()
|
const numColumns = useNumColumns()
|
||||||
const resetTooltips = useResetTooltips()
|
const hideTooltips = useHidePluginTooltips()
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<FlashList
|
<FlashList
|
||||||
|
|
@ -69,7 +69,7 @@ export function InstalledPluginMasonryFlashList({
|
||||||
// FAB is 56px tall, plus 16px spacing on top and bottom
|
// FAB is 56px tall, plus 16px spacing on top and bottom
|
||||||
contentContainerStyle={{ paddingBottom: 56 + 2 * 16 }}
|
contentContainerStyle={{ paddingBottom: 56 + 2 * 16 }}
|
||||||
data={plugins}
|
data={plugins}
|
||||||
onScrollBeginDrag={resetTooltips}
|
onScrollBeginDrag={hideTooltips}
|
||||||
fadingEdgeLength={16}
|
fadingEdgeLength={16}
|
||||||
keyExtractor={([plugin]) => plugin.manifest.id}
|
keyExtractor={([plugin]) => plugin.manifest.id}
|
||||||
numColumns={numColumns}
|
numColumns={numColumns}
|
||||||
|
|
@ -121,10 +121,14 @@ export function BrowsePluginMasonryFlashList({
|
||||||
onInstall,
|
onInstall,
|
||||||
}: {
|
}: {
|
||||||
entries: BrowseEntry[]
|
entries: BrowseEntry[]
|
||||||
onInstall: (entry: BrowseEntry) => void
|
onInstall: (
|
||||||
|
entry: BrowseEntry,
|
||||||
|
channel?: string,
|
||||||
|
version?: string,
|
||||||
|
) => void
|
||||||
}) {
|
}) {
|
||||||
const numColumns = useNumColumns()
|
const numColumns = useNumColumns()
|
||||||
const resetTooltips = useResetTooltips()
|
const hideTooltips = useHidePluginTooltips()
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<FlashList
|
<FlashList
|
||||||
|
|
@ -132,7 +136,7 @@ export function BrowsePluginMasonryFlashList({
|
||||||
style={gutterCompensation}
|
style={gutterCompensation}
|
||||||
contentContainerStyle={{ paddingBottom: 16 }}
|
contentContainerStyle={{ paddingBottom: 16 }}
|
||||||
data={entries}
|
data={entries}
|
||||||
onScrollBeginDrag={resetTooltips}
|
onScrollBeginDrag={hideTooltips}
|
||||||
fadingEdgeLength={16}
|
fadingEdgeLength={16}
|
||||||
keyExtractor={entry => entry.key}
|
keyExtractor={entry => entry.key}
|
||||||
numColumns={numColumns}
|
numColumns={numColumns}
|
||||||
|
|
@ -152,8 +156,12 @@ export function BrowsePluginMasonryFlashList({
|
||||||
author={listing.author}
|
author={listing.author}
|
||||||
icon={listing.icon ?? undefined}
|
icon={listing.icon ?? undefined}
|
||||||
id={listing.id}
|
id={listing.id}
|
||||||
|
listing={listing}
|
||||||
|
channel={entry.channel ?? ''}
|
||||||
repositoryText={entry.repositoryText}
|
repositoryText={entry.repositoryText}
|
||||||
onInstall={() => onInstall(entry)}
|
onInstall={(channel, version) =>
|
||||||
|
onInstall(entry, channel, version)
|
||||||
|
}
|
||||||
/>
|
/>
|
||||||
)
|
)
|
||||||
}}
|
}}
|
||||||
|
|
|
||||||
|
|
@ -1,33 +0,0 @@
|
||||||
import { TableRowAssetIcon } from '@revenge-mod/components'
|
|
||||||
import { Design } from '@revenge-mod/discord/design'
|
|
||||||
import type { AnyPlugin } from '@revenge-mod/plugins/_'
|
|
||||||
|
|
||||||
const { ActionSheet, Stack, TableRow, TableRowGroup } = Design
|
|
||||||
|
|
||||||
export interface PluginListActionSheetProps {
|
|
||||||
title: string
|
|
||||||
plugins: AnyPlugin[]
|
|
||||||
sheetKey: string
|
|
||||||
}
|
|
||||||
|
|
||||||
export default function PluginListActionSheet({
|
|
||||||
title,
|
|
||||||
plugins,
|
|
||||||
}: PluginListActionSheetProps) {
|
|
||||||
return (
|
|
||||||
<ActionSheet>
|
|
||||||
<Stack spacing={24} style={{ paddingTop: 8 }}>
|
|
||||||
<TableRowGroup title={title}>
|
|
||||||
{plugins.map(plugin => (
|
|
||||||
<TableRow
|
|
||||||
key={plugin.manifest.id}
|
|
||||||
icon={<TableRowAssetIcon name="ListBulletsIcon" />}
|
|
||||||
label={plugin.manifest.name}
|
|
||||||
subLabel={plugin.manifest.id}
|
|
||||||
/>
|
|
||||||
))}
|
|
||||||
</TableRowGroup>
|
|
||||||
</Stack>
|
|
||||||
</ActionSheet>
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|
@ -19,17 +19,15 @@ import {
|
||||||
isPluginPendingUpdate,
|
isPluginPendingUpdate,
|
||||||
isPluginStartable,
|
isPluginStartable,
|
||||||
PluginFlags,
|
PluginFlags,
|
||||||
|
pList,
|
||||||
runPluginLate,
|
runPluginLate,
|
||||||
stopPlugin,
|
stopPlugin,
|
||||||
} from '@revenge-mod/plugins/_'
|
} from '@revenge-mod/plugins/_'
|
||||||
import {
|
import {
|
||||||
installFromRepo,
|
listRepoPlugins,
|
||||||
listRepos,
|
listRepos,
|
||||||
listUpdates,
|
|
||||||
planInstall,
|
|
||||||
refreshRepo,
|
refreshRepo,
|
||||||
} from '@revenge-mod/plugins/_/repositories'
|
} from '@revenge-mod/plugins/_/repositories'
|
||||||
import type { RepoUpdate } from '@revenge-mod/plugins/_/repositories'
|
|
||||||
import { PluginStatus } from '@revenge-mod/plugins/constants'
|
import { PluginStatus } from '@revenge-mod/plugins/constants'
|
||||||
import { formatVersion } from '@revenge-mod/plugins/utils'
|
import { formatVersion } from '@revenge-mod/plugins/utils'
|
||||||
import { lookupGeneratedIconComponent } from '@revenge-mod/utils/discord'
|
import { lookupGeneratedIconComponent } from '@revenge-mod/utils/discord'
|
||||||
|
|
@ -41,22 +39,30 @@ import {
|
||||||
showPluginClearDataConfirmation,
|
showPluginClearDataConfirmation,
|
||||||
showPluginUninstallConfirmation,
|
showPluginUninstallConfirmation,
|
||||||
} from '../utils/alerts'
|
} from '../utils/alerts'
|
||||||
import { confirmPlan, messageOf, showErrorToast } from '../utils/repos'
|
import { messageOf, runInstallFlow, 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 {
|
import PluginTooltipsProvider, {
|
||||||
EnablePluginTooltipProvider,
|
PluginTooltip,
|
||||||
useClickOutsideTooltip,
|
usePluginTooltip,
|
||||||
useEnablePluginTooltip,
|
|
||||||
} 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
|
||||||
sheetKey: string
|
sheetKey: string
|
||||||
}
|
}
|
||||||
|
|
||||||
const { ActionSheet, IconButton, TableRowGroup, TableRow, Stack } = Design
|
const {
|
||||||
|
ActionSheet,
|
||||||
|
IconButton,
|
||||||
|
TableRowGroup,
|
||||||
|
TableRow,
|
||||||
|
TableRadioGroup,
|
||||||
|
TableRadioRow,
|
||||||
|
Stack,
|
||||||
|
} = Design
|
||||||
|
|
||||||
const FileWarningIcon = getAssetIdByName('FileWarningIcon', 'png')!
|
const FileWarningIcon = getAssetIdByName('FileWarningIcon', 'png')!
|
||||||
const PlayIcon = getAssetIdByName('PlayIcon', 'png')!
|
const PlayIcon = getAssetIdByName('PlayIcon', 'png')!
|
||||||
|
|
@ -68,14 +74,29 @@ export default function PluginOptionsActionSheet({
|
||||||
plugin,
|
plugin,
|
||||||
sheetKey,
|
sheetKey,
|
||||||
}: PluginOptionsActionSheetProps) {
|
}: PluginOptionsActionSheetProps) {
|
||||||
|
return (
|
||||||
|
<ActionSheet>
|
||||||
|
<ClickOutsideProvider>
|
||||||
|
<PluginTooltipsProvider>
|
||||||
|
<PluginOptions plugin={plugin} sheetKey={sheetKey} />
|
||||||
|
</PluginTooltipsProvider>
|
||||||
|
</ClickOutsideProvider>
|
||||||
|
</ActionSheet>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
function PluginOptions({ plugin, sheetKey }: PluginOptionsActionSheetProps) {
|
||||||
const enabled = usePluginEnabled(plugin)
|
const enabled = usePluginEnabled(plugin)
|
||||||
const meta = getInternalPluginMeta(plugin)
|
const meta = getInternalPluginMeta(plugin)
|
||||||
const essential = isPluginEssential(meta)
|
const essential = isPluginEssential(meta)
|
||||||
const pendingUpdate = isPluginPendingUpdate(plugin)
|
const pendingUpdate = isPluginPendingUpdate(plugin)
|
||||||
const { name, author, description, icon, version } = plugin.manifest
|
const { name, author, description, icon, version } = plugin.manifest
|
||||||
|
|
||||||
|
const [switchRef, showPendingUpdateTooltip] = usePluginTooltip(
|
||||||
|
PluginTooltip.PendingUpdate,
|
||||||
|
)
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<ActionSheet>
|
|
||||||
<Stack spacing={24} style={{ paddingTop: 8 }}>
|
<Stack spacing={24} style={{ paddingTop: 8 }}>
|
||||||
<PluginInfo
|
<PluginInfo
|
||||||
name={name}
|
name={name}
|
||||||
|
|
@ -85,6 +106,12 @@ export default function PluginOptionsActionSheet({
|
||||||
icon={icon}
|
icon={icon}
|
||||||
actions={
|
actions={
|
||||||
!essential && (
|
!essential && (
|
||||||
|
<Pressable
|
||||||
|
onPress={() => {
|
||||||
|
if (pendingUpdate) showPendingUpdateTooltip()
|
||||||
|
}}
|
||||||
|
ref={switchRef}
|
||||||
|
>
|
||||||
<InstalledPluginSwitch
|
<InstalledPluginSwitch
|
||||||
enabled={enabled}
|
enabled={enabled}
|
||||||
plugin={plugin}
|
plugin={plugin}
|
||||||
|
|
@ -93,25 +120,19 @@ export default function PluginOptionsActionSheet({
|
||||||
)}
|
)}
|
||||||
toggleDisabled={pendingUpdate}
|
toggleDisabled={pendingUpdate}
|
||||||
/>
|
/>
|
||||||
|
</Pressable>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
/>
|
/>
|
||||||
<ClickOutsideProvider>
|
|
||||||
<EnablePluginTooltipProvider>
|
|
||||||
<PluginActions
|
<PluginActions
|
||||||
plugin={plugin}
|
plugin={plugin}
|
||||||
closeSheet={() => {
|
closeSheet={() => {
|
||||||
ActionSheetActionCreators.hideActionSheet(
|
ActionSheetActionCreators.hideActionSheet(sheetKey)
|
||||||
sheetKey,
|
|
||||||
)
|
|
||||||
}}
|
}}
|
||||||
/>
|
/>
|
||||||
</EnablePluginTooltipProvider>
|
|
||||||
</ClickOutsideProvider>
|
|
||||||
<StatusSection plugin={plugin} />
|
<StatusSection plugin={plugin} />
|
||||||
<AdvancedSection plugin={plugin} />
|
<AdvancedSection plugin={plugin} sheetKey={sheetKey} />
|
||||||
</Stack>
|
</Stack>
|
||||||
</ActionSheet>
|
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -150,86 +171,94 @@ function StatusSection({ plugin }: { plugin: AnyPlugin }) {
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
function UpdateRow({ plugin }: { plugin: AnyPlugin }) {
|
function ChannelSection({
|
||||||
const [busy, setBusy] = useState(false)
|
plugin,
|
||||||
const [checked, setChecked] = useState(false)
|
sheetKey,
|
||||||
const [update, setUpdate] = useState<RepoUpdate | null>(null)
|
}: {
|
||||||
|
plugin: AnyPlugin
|
||||||
|
sheetKey: string
|
||||||
|
}) {
|
||||||
const meta = getInternalPluginMeta(plugin)
|
const meta = getInternalPluginMeta(plugin)
|
||||||
const repoUrl = meta.source?.repo
|
const source = meta.source
|
||||||
if (isPluginInternal(meta) || !repoUrl) return null
|
if (isPluginInternal(meta) || !source?.repo) return null
|
||||||
|
|
||||||
const check = async () => {
|
const [listing, setListing] = useState<RepoPluginListing | null>(null)
|
||||||
setBusy(true)
|
const [resetKey, setResetKey] = useState(0)
|
||||||
try {
|
|
||||||
await refreshRepo(repoUrl)
|
|
||||||
const updates = await listUpdates(repoUrl)
|
|
||||||
const found =
|
|
||||||
updates.find(u => u.id === plugin.manifest.id) ?? null
|
|
||||||
setUpdate(found)
|
|
||||||
setChecked(true)
|
|
||||||
} catch (e) {
|
|
||||||
showErrorToast(messageOf(e))
|
|
||||||
} finally {
|
|
||||||
setBusy(false)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
const doUpdate = async () => {
|
useEffect(() => {
|
||||||
if (!update) return
|
let cancelled = false
|
||||||
setBusy(true)
|
;(async () => {
|
||||||
try {
|
try {
|
||||||
const plan = await planInstall(
|
const listings = await listRepoPlugins(source.repo)
|
||||||
update.id,
|
const found = listings.find(l => l.id === plugin.manifest.id)
|
||||||
undefined,
|
if (found && !cancelled) setListing(found)
|
||||||
update.channel,
|
} catch {
|
||||||
)
|
try {
|
||||||
const accepted = await confirmPlan(plan)
|
await refreshRepo(source.repo)
|
||||||
if (!accepted) return
|
const listings = await listRepoPlugins(source.repo)
|
||||||
await installFromRepo(plan)
|
const found = listings.find(l => l.id === plugin.manifest.id)
|
||||||
setUpdate(null)
|
if (found && !cancelled) setListing(found)
|
||||||
setChecked(true)
|
} catch {}
|
||||||
|
}
|
||||||
|
})()
|
||||||
|
return () => {
|
||||||
|
cancelled = true
|
||||||
|
}
|
||||||
|
}, [source.repo, plugin.manifest.id])
|
||||||
|
|
||||||
|
if (!listing || Object.keys(listing.channels).length === 0) return null
|
||||||
|
|
||||||
|
const handleChange = async (selected: string) => {
|
||||||
|
if (selected === source.channel) return
|
||||||
|
|
||||||
|
const targetVersion = listing.channels[selected]
|
||||||
|
if (!targetVersion) return
|
||||||
|
|
||||||
|
if (targetVersion === plugin.manifest.version) {
|
||||||
|
setResetKey(k => k + 1)
|
||||||
ToastActionCreators.open({
|
ToastActionCreators.open({
|
||||||
key: 'revenge-plugin-update-pending',
|
key: 'REVENGE_PLUGIN_CHANNEL_UP_TO_DATE',
|
||||||
content: 'Update downloaded. Reload to apply.',
|
content: 'This channel is already installed',
|
||||||
|
IconComponent: () => <TableRowAssetIcon name="CircleCheckIcon" />,
|
||||||
})
|
})
|
||||||
} catch (e) {
|
return
|
||||||
showErrorToast(messageOf(e))
|
|
||||||
} finally {
|
|
||||||
setBusy(false)
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
const icon = update
|
const repos = await listRepos()
|
||||||
? 'DownloadIcon'
|
const internalUrls = repos
|
||||||
: checked
|
.filter(r => r.internal)
|
||||||
? 'CircleCheckIcon'
|
.map(r => r.url)
|
||||||
: 'RefreshIcon'
|
|
||||||
|
|
||||||
const label = update
|
ActionSheetActionCreators.hideActionSheet(sheetKey)
|
||||||
? 'Update'
|
runInstallFlow(
|
||||||
: checked
|
plugin.manifest.id,
|
||||||
? 'Up to date'
|
undefined,
|
||||||
: 'Check for updates'
|
selected,
|
||||||
|
[...internalUrls, source.repo],
|
||||||
const subLabel = update
|
)
|
||||||
? `${update.installed} → ${update.available} (${update.channel})`
|
}
|
||||||
: checked
|
|
||||||
? 'This plugin is on the latest available version'
|
|
||||||
: 'Tap to refresh this plugin\'s repository'
|
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<TableRow
|
<TableRadioGroup
|
||||||
icon={<TableRowAssetIcon name={icon} />}
|
key={resetKey}
|
||||||
label={label}
|
title="Channel"
|
||||||
subLabel={subLabel}
|
defaultValue={source.channel}
|
||||||
onPress={update ? doUpdate : check}
|
onChange={v => handleChange(v as string)}
|
||||||
disabled={busy}
|
>
|
||||||
/>
|
{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 meta = getInternalPluginMeta(plugin)
|
||||||
const dependents = getPluginDependents(plugin, true)
|
const dependents = getPluginDependents(plugin, true)
|
||||||
const dependencies = getPluginDependencies(plugin, false)
|
const dependencies = getPluginDependencies(plugin, false)
|
||||||
|
|
@ -237,8 +266,9 @@ function AdvancedSection({ plugin }: { plugin: AnyPlugin }) {
|
||||||
const { id, name } = plugin.manifest
|
const { id, name } = plugin.manifest
|
||||||
|
|
||||||
return (
|
return (
|
||||||
|
<>
|
||||||
|
<ChannelSection plugin={plugin} sheetKey={sheetKey} />
|
||||||
<TableRowGroup title="Advanced">
|
<TableRowGroup title="Advanced">
|
||||||
<UpdateRow plugin={plugin} />
|
|
||||||
<IdRow id={id} />
|
<IdRow id={id} />
|
||||||
<RepositoryRow
|
<RepositoryRow
|
||||||
text={repositoryText}
|
text={repositoryText}
|
||||||
|
|
@ -266,13 +296,19 @@ function AdvancedSection({ plugin }: { plugin: AnyPlugin }) {
|
||||||
subLabel={`${name} depends on ${dependencies.length} other plugins`}
|
subLabel={`${name} depends on ${dependencies.length} other plugins`}
|
||||||
onPress={() => {
|
onPress={() => {
|
||||||
ActionSheetActionCreators.openLazy(
|
ActionSheetActionCreators.openLazy(
|
||||||
import('./PluginListActionSheet'),
|
import('./PluginRelationsListActionSheet'),
|
||||||
`plugin-deps-${id}`,
|
`plugin-deps-${id}`,
|
||||||
{
|
{
|
||||||
title: `Dependencies of ${name}`,
|
title: `Dependencies of ${name}`,
|
||||||
|
unsatisfiedTitle: `Unsatisfied dependencies of ${name}`,
|
||||||
|
unsatisfiedPlugins:
|
||||||
|
meta.unsatisfiedOptionalDependencies.map(
|
||||||
|
id => pList.get(id) ?? id,
|
||||||
|
),
|
||||||
plugins: dependencies,
|
plugins: dependencies,
|
||||||
sheetKey: `plugin-deps-${id}`,
|
dependencyMap: plugin.manifest.dependencies!,
|
||||||
},
|
},
|
||||||
|
'stack',
|
||||||
)
|
)
|
||||||
}}
|
}}
|
||||||
/>
|
/>
|
||||||
|
|
@ -284,18 +320,19 @@ function AdvancedSection({ plugin }: { plugin: AnyPlugin }) {
|
||||||
subLabel={`${dependents.length} other plugins depend on ${name}`}
|
subLabel={`${dependents.length} other plugins depend on ${name}`}
|
||||||
onPress={() => {
|
onPress={() => {
|
||||||
ActionSheetActionCreators.openLazy(
|
ActionSheetActionCreators.openLazy(
|
||||||
import('./PluginListActionSheet'),
|
import('./PluginRelationsListActionSheet'),
|
||||||
`plugin-dependents-${id}`,
|
`plugin-dependents-${id}`,
|
||||||
{
|
{
|
||||||
title: `Dependents of ${name}`,
|
title: `Dependents of ${name}`,
|
||||||
plugins: dependents,
|
plugins: dependents,
|
||||||
sheetKey: `plugin-dependents-${id}`,
|
|
||||||
},
|
},
|
||||||
|
'stack',
|
||||||
)
|
)
|
||||||
}}
|
}}
|
||||||
/>
|
/>
|
||||||
)}
|
)}
|
||||||
</TableRowGroup>
|
</TableRowGroup>
|
||||||
|
</>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -381,8 +418,9 @@ function PluginActions({
|
||||||
plugin: AnyPlugin
|
plugin: AnyPlugin
|
||||||
closeSheet: () => void
|
closeSheet: () => void
|
||||||
}) {
|
}) {
|
||||||
const enableTooltip = useEnablePluginTooltip()
|
const [settingsRef, showEnableTooltip] = usePluginTooltip(
|
||||||
const settingsRef = useClickOutsideTooltip(useEnablePluginTooltip, () => {})
|
PluginTooltip.Enable,
|
||||||
|
)
|
||||||
const meta = getInternalPluginMeta(plugin)
|
const meta = getInternalPluginMeta(plugin)
|
||||||
const startable = isPluginStartable(plugin)
|
const startable = isPluginStartable(plugin)
|
||||||
const enabled = usePluginEnabled(plugin)
|
const enabled = usePluginEnabled(plugin)
|
||||||
|
|
@ -436,12 +474,7 @@ function PluginActions({
|
||||||
{plugin.SettingsComponent && (
|
{plugin.SettingsComponent && (
|
||||||
<Pressable
|
<Pressable
|
||||||
onPress={() => {
|
onPress={() => {
|
||||||
if (!startable)
|
if (!startable) showEnableTooltip()
|
||||||
requestAnimationFrame(() => {
|
|
||||||
enableTooltip.targetRef.current =
|
|
||||||
settingsRef.current
|
|
||||||
enableTooltip.setVisible(true)
|
|
||||||
})
|
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
<IconButton
|
<IconButton
|
||||||
|
|
|
||||||
|
|
@ -0,0 +1,131 @@
|
||||||
|
import { Design } from '@revenge-mod/discord/design'
|
||||||
|
import { Clipboard } from '@revenge-mod/externals/react-native-clipboard'
|
||||||
|
import { formatVersion } from '@revenge-mod/plugins/utils'
|
||||||
|
import { PluginIcon } from './PluginIcon'
|
||||||
|
import type { DiscordModules } from '@revenge-mod/discord/types'
|
||||||
|
import type { AnyPlugin } from '@revenge-mod/plugins/_'
|
||||||
|
import type {
|
||||||
|
PluginDependency,
|
||||||
|
PluginManifest,
|
||||||
|
} from '@revenge-mod/plugins/types'
|
||||||
|
|
||||||
|
const { ActionSheet, Stack, TableRow, TableRowGroup } = Design
|
||||||
|
|
||||||
|
export interface PluginRelationsListActionSheetProps {
|
||||||
|
title: string
|
||||||
|
unsatisfiedTitle?: string
|
||||||
|
dependencyMap?: NonNullable<PluginManifest['dependencies']>
|
||||||
|
plugins: AnyPlugin[]
|
||||||
|
unsatisfiedPlugins?: Array<string | AnyPlugin>
|
||||||
|
}
|
||||||
|
|
||||||
|
interface PluginRowProps {
|
||||||
|
plugin: AnyPlugin
|
||||||
|
variant?: DiscordModules.Components.TableRowProps['variant']
|
||||||
|
dependency?: PluginDependency
|
||||||
|
showInstalledVersion?: boolean
|
||||||
|
}
|
||||||
|
|
||||||
|
const formatDependencyRequirement = (dependency: PluginDependency) =>
|
||||||
|
`(${dependency.version ?? '*'} ${dependency.optional ? 'optional' : 'required'})`
|
||||||
|
|
||||||
|
function PluginRow({
|
||||||
|
plugin,
|
||||||
|
dependency,
|
||||||
|
variant,
|
||||||
|
showInstalledVersion = false,
|
||||||
|
}: PluginRowProps) {
|
||||||
|
const { id, name, version, icon } = plugin.manifest
|
||||||
|
const installedVersion = showInstalledVersion
|
||||||
|
? `@${formatVersion(version)}`
|
||||||
|
: ''
|
||||||
|
const depRequirement = dependency
|
||||||
|
? ` ${formatDependencyRequirement(dependency)}`
|
||||||
|
: ''
|
||||||
|
|
||||||
|
const subLabel = `${id}${installedVersion}${depRequirement}`
|
||||||
|
|
||||||
|
return (
|
||||||
|
<TableRow
|
||||||
|
icon={
|
||||||
|
<PluginIcon
|
||||||
|
danger={variant === 'danger'}
|
||||||
|
icon={icon ?? undefined}
|
||||||
|
size={24}
|
||||||
|
/>
|
||||||
|
}
|
||||||
|
label={name}
|
||||||
|
subLabel={subLabel}
|
||||||
|
variant={variant}
|
||||||
|
onPress={() => Clipboard.setString(subLabel)}
|
||||||
|
/>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
export default function PluginRelationsListActionSheet({
|
||||||
|
title,
|
||||||
|
unsatisfiedTitle,
|
||||||
|
dependencyMap,
|
||||||
|
plugins,
|
||||||
|
unsatisfiedPlugins,
|
||||||
|
}: PluginRelationsListActionSheetProps) {
|
||||||
|
return (
|
||||||
|
<ActionSheet>
|
||||||
|
<Stack spacing={24} style={{ paddingTop: 8 }}>
|
||||||
|
<TableRowGroup title={title}>
|
||||||
|
{plugins.map(plugin => (
|
||||||
|
<PluginRow
|
||||||
|
key={plugin.manifest.id}
|
||||||
|
plugin={plugin}
|
||||||
|
// biome-ignore lint/suspicious/noNonNullAssertedOptionalChain: Can't be undefined
|
||||||
|
dependency={dependencyMap?.[plugin.manifest.id]!}
|
||||||
|
showInstalledVersion
|
||||||
|
/>
|
||||||
|
))}
|
||||||
|
</TableRowGroup>
|
||||||
|
|
||||||
|
{Boolean(unsatisfiedPlugins?.length) && (
|
||||||
|
<TableRowGroup title={unsatisfiedTitle}>
|
||||||
|
{unsatisfiedPlugins?.map(plugin => {
|
||||||
|
if (typeof plugin === 'string') {
|
||||||
|
// biome-ignore lint/suspicious/noNonNullAssertedOptionalChain: Can't be undefined
|
||||||
|
const dep = dependencyMap?.[plugin]!
|
||||||
|
|
||||||
|
return (
|
||||||
|
<TableRow
|
||||||
|
key={plugin}
|
||||||
|
icon={
|
||||||
|
<PluginIcon
|
||||||
|
icon="CircleQuestionIcon"
|
||||||
|
danger
|
||||||
|
size={24}
|
||||||
|
/>
|
||||||
|
}
|
||||||
|
label={plugin}
|
||||||
|
subLabel={`${plugin} (not installed) ${formatDependencyRequirement(dep)}`}
|
||||||
|
variant="danger"
|
||||||
|
onPress={() =>
|
||||||
|
Clipboard.setString(plugin)
|
||||||
|
}
|
||||||
|
/>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<PluginRow
|
||||||
|
key={plugin.manifest.id}
|
||||||
|
plugin={plugin}
|
||||||
|
variant="danger"
|
||||||
|
dependency={
|
||||||
|
// biome-ignore lint/suspicious/noNonNullAssertedOptionalChain: Can't be undefined
|
||||||
|
dependencyMap?.[plugin.manifest.id]!
|
||||||
|
}
|
||||||
|
/>
|
||||||
|
)
|
||||||
|
})}
|
||||||
|
</TableRowGroup>
|
||||||
|
)}
|
||||||
|
</Stack>
|
||||||
|
</ActionSheet>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
@ -1,112 +0,0 @@
|
||||||
import { Design } from '@revenge-mod/discord/design'
|
|
||||||
import {
|
|
||||||
listRepoPlugins,
|
|
||||||
type RepoPluginListing,
|
|
||||||
type RepoUpdate,
|
|
||||||
} from '@revenge-mod/plugins/_/repositories'
|
|
||||||
import { useEffect, useState } from 'react'
|
|
||||||
import { View } from 'react-native'
|
|
||||||
|
|
||||||
const { AlertModal, AlertActionButton, Text } = Design
|
|
||||||
|
|
||||||
interface UpdateDetail {
|
|
||||||
id: string
|
|
||||||
name: string
|
|
||||||
author: string
|
|
||||||
installed: string
|
|
||||||
available: string
|
|
||||||
channel: string
|
|
||||||
}
|
|
||||||
|
|
||||||
export default function PluginUpdatesFoundAlert({
|
|
||||||
updates,
|
|
||||||
action,
|
|
||||||
}: {
|
|
||||||
updates: RepoUpdate[]
|
|
||||||
action: () => Promise<void>
|
|
||||||
}) {
|
|
||||||
const [details, setDetails] = useState<UpdateDetail[] | null>(null)
|
|
||||||
|
|
||||||
useEffect(() => {
|
|
||||||
const repoMap = new Map<string, RepoUpdate[]>()
|
|
||||||
for (const update of updates) {
|
|
||||||
const repo = update.repo!
|
|
||||||
if (!repoMap.has(repo)) repoMap.set(repo, [])
|
|
||||||
repoMap.get(repo)!.push(update)
|
|
||||||
}
|
|
||||||
|
|
||||||
const allDetails: UpdateDetail[] = []
|
|
||||||
|
|
||||||
Promise.all(
|
|
||||||
[...repoMap.entries()].map(async ([repo, repoUpdates]) => {
|
|
||||||
let listings: RepoPluginListing[]
|
|
||||||
try {
|
|
||||||
listings = await listRepoPlugins(repo)
|
|
||||||
} catch {
|
|
||||||
listings = []
|
|
||||||
}
|
|
||||||
|
|
||||||
for (const update of repoUpdates) {
|
|
||||||
const listing = listings.find(l => l.id === update.id)
|
|
||||||
allDetails.push({
|
|
||||||
id: update.id,
|
|
||||||
name: listing?.name ?? update.id,
|
|
||||||
author: listing?.author ?? 'Unknown',
|
|
||||||
installed: update.installed,
|
|
||||||
available: update.available,
|
|
||||||
channel: update.channel,
|
|
||||||
})
|
|
||||||
}
|
|
||||||
}),
|
|
||||||
).then(() => setDetails(allDetails))
|
|
||||||
}, [updates])
|
|
||||||
|
|
||||||
return (
|
|
||||||
<AlertModal
|
|
||||||
title="Updates Found"
|
|
||||||
content={
|
|
||||||
<Text color="text-default">
|
|
||||||
The following plugins have updates available:
|
|
||||||
</Text>
|
|
||||||
}
|
|
||||||
extraContent={
|
|
||||||
<View style={{ paddingTop: 8 }}>
|
|
||||||
{details === null ? (
|
|
||||||
<Text color="text-muted">Loading...</Text>
|
|
||||||
) : (
|
|
||||||
details.map(detail => (
|
|
||||||
<View
|
|
||||||
key={detail.id}
|
|
||||||
style={{ marginBottom: 12 }}
|
|
||||||
>
|
|
||||||
<Text
|
|
||||||
variant="text-md/semibold"
|
|
||||||
color="text-default"
|
|
||||||
>
|
|
||||||
{detail.name}
|
|
||||||
</Text>
|
|
||||||
<Text color="text-muted">
|
|
||||||
{detail.installed} → {detail.available} (
|
|
||||||
{detail.channel})
|
|
||||||
</Text>
|
|
||||||
<Text color="text-muted">
|
|
||||||
by {detail.author}
|
|
||||||
</Text>
|
|
||||||
</View>
|
|
||||||
))
|
|
||||||
)}
|
|
||||||
</View>
|
|
||||||
}
|
|
||||||
actions={
|
|
||||||
<>
|
|
||||||
<AlertActionButton
|
|
||||||
onPress={action}
|
|
||||||
text="Update & Reload"
|
|
||||||
variant="primary"
|
|
||||||
/>
|
|
||||||
<AlertActionButton text="Not now" variant="secondary" />
|
|
||||||
</>
|
|
||||||
}
|
|
||||||
/>
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|
@ -18,8 +18,7 @@ export default function RepoRemoveConfirmationAlert({
|
||||||
<Text variant="text-md/semibold" color="text-default">
|
<Text variant="text-md/semibold" color="text-default">
|
||||||
{repo.name ?? repo.url}
|
{repo.name ?? repo.url}
|
||||||
</Text>{' '}
|
</Text>{' '}
|
||||||
will be removed. You can restore the default repositories by
|
will be removed.
|
||||||
clearing Plugin Settings data.
|
|
||||||
</Text>
|
</Text>
|
||||||
}
|
}
|
||||||
actions={
|
actions={
|
||||||
|
|
|
||||||
|
|
@ -1,109 +1,134 @@
|
||||||
import { Design } from '@revenge-mod/discord/design'
|
import { Design } from '@revenge-mod/discord/design'
|
||||||
import { createContext, useContext, useRef, useState } from 'react'
|
import {
|
||||||
import { useClickOutside } from 'react-native-click-outside'
|
createContext,
|
||||||
import type { RefObject } from 'react'
|
useCallback,
|
||||||
|
useContext,
|
||||||
|
useMemo,
|
||||||
|
useRef,
|
||||||
|
useState,
|
||||||
|
} from 'react'
|
||||||
|
import {
|
||||||
|
ClickOutsideProvider,
|
||||||
|
useClickOutside,
|
||||||
|
} from 'react-native-click-outside'
|
||||||
import type { View } from 'react-native'
|
import type { View } from 'react-native'
|
||||||
|
|
||||||
const { useTooltip } = Design
|
const { useTooltip } = Design
|
||||||
|
|
||||||
interface TooltipContextValue {
|
export const PluginTooltip = {
|
||||||
targetRef: RefObject<View | null>
|
/** Shown when an action requires the plugin to be enabled first. */
|
||||||
setVisible: (visible: boolean) => void
|
Enable: 'enable',
|
||||||
|
/** Shown when an action is blocked because the plugin is essential. */
|
||||||
|
Essential: 'essential',
|
||||||
|
/** Shown when an action is blocked because the plugin has a pending update. */
|
||||||
|
PendingUpdate: 'pendingUpdate',
|
||||||
|
} as const
|
||||||
|
|
||||||
|
export type PluginTooltip = (typeof PluginTooltip)[keyof typeof PluginTooltip]
|
||||||
|
|
||||||
|
const Labels: Record<PluginTooltip, string> = {
|
||||||
|
[PluginTooltip.Enable]: 'Plugin must be enabled first',
|
||||||
|
[PluginTooltip.Essential]: 'Plugin needed for Revenge to function properly',
|
||||||
|
[PluginTooltip.PendingUpdate]:
|
||||||
|
'Plugin has a pending update, reload to apply it',
|
||||||
}
|
}
|
||||||
|
|
||||||
const EnablePluginTooltipContext = createContext<TooltipContextValue | null>(
|
interface PluginTooltipsContextValue {
|
||||||
null,
|
show: (tooltip: PluginTooltip, target: View | null) => void
|
||||||
)
|
hide: () => void
|
||||||
const EssentialPluginTooltipContext = createContext<TooltipContextValue | null>(
|
}
|
||||||
|
|
||||||
|
const PluginTooltipsContext = createContext<PluginTooltipsContextValue | null>(
|
||||||
null,
|
null,
|
||||||
)
|
)
|
||||||
|
|
||||||
export function EnablePluginTooltipProvider({
|
export default function PluginTooltipsProvider({
|
||||||
children,
|
children,
|
||||||
}: {
|
}: {
|
||||||
children: React.ReactNode
|
children: React.ReactNode
|
||||||
}) {
|
}) {
|
||||||
const [visible, setVisible] = useState(false)
|
const [visible, setVisible] = useState<PluginTooltip | null>(null)
|
||||||
const targetRef = useRef<View | null>(null)
|
|
||||||
|
|
||||||
useTooltip(targetRef, {
|
// Targets are assigned by `show`, right before the tooltip becomes visible
|
||||||
label: 'Plugin must be enabled first',
|
const enableTarget = useRef<View | null>(null)
|
||||||
|
const essentialTarget = useRef<View | null>(null)
|
||||||
|
const pendingUpdateTarget = useRef<View | null>(null)
|
||||||
|
|
||||||
|
useTooltip(enableTarget, {
|
||||||
|
label: Labels[PluginTooltip.Enable],
|
||||||
position: 'top',
|
position: 'top',
|
||||||
visible,
|
visible: visible === PluginTooltip.Enable,
|
||||||
})
|
})
|
||||||
|
|
||||||
|
useTooltip(essentialTarget, {
|
||||||
|
label: Labels[PluginTooltip.Essential],
|
||||||
|
position: 'top',
|
||||||
|
visible: visible === PluginTooltip.Essential,
|
||||||
|
})
|
||||||
|
|
||||||
|
useTooltip(pendingUpdateTarget, {
|
||||||
|
label: Labels[PluginTooltip.PendingUpdate],
|
||||||
|
position: 'top',
|
||||||
|
visible: visible === PluginTooltip.PendingUpdate,
|
||||||
|
})
|
||||||
|
|
||||||
|
const value = useMemo<PluginTooltipsContextValue>(() => {
|
||||||
|
const targets = {
|
||||||
|
[PluginTooltip.Enable]: enableTarget,
|
||||||
|
[PluginTooltip.Essential]: essentialTarget,
|
||||||
|
[PluginTooltip.PendingUpdate]: pendingUpdateTarget,
|
||||||
|
}
|
||||||
|
|
||||||
|
return {
|
||||||
|
show: (tooltip, target) => {
|
||||||
|
targets[tooltip].current = target
|
||||||
|
setVisible(tooltip)
|
||||||
|
},
|
||||||
|
hide: () => setVisible(null),
|
||||||
|
}
|
||||||
|
}, [])
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<EnablePluginTooltipContext.Provider value={{ targetRef, setVisible }}>
|
<ClickOutsideProvider>
|
||||||
|
<PluginTooltipsContext.Provider value={value}>
|
||||||
{children}
|
{children}
|
||||||
</EnablePluginTooltipContext.Provider>
|
</PluginTooltipsContext.Provider>
|
||||||
|
</ClickOutsideProvider>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
export function EssentialPluginTooltipProvider({
|
/**
|
||||||
children,
|
* Binds an element to a plugin tooltip.
|
||||||
}: {
|
*
|
||||||
children: React.ReactNode
|
* @param tooltip The tooltip to show for the element.
|
||||||
}) {
|
* @returns The ref to attach to the element, and a callback showing the tooltip on it.
|
||||||
const [visible, setVisible] = useState(false)
|
*/
|
||||||
const targetRef = useRef<View | null>(null)
|
export function usePluginTooltip(tooltip: PluginTooltip) {
|
||||||
|
const context = useContext(PluginTooltipsContext)
|
||||||
useTooltip(targetRef, {
|
if (!context)
|
||||||
label: 'Plugin needed for Revenge to function properly',
|
|
||||||
position: 'top',
|
|
||||||
visible,
|
|
||||||
})
|
|
||||||
|
|
||||||
return (
|
|
||||||
<EssentialPluginTooltipContext.Provider
|
|
||||||
value={{ targetRef, setVisible }}
|
|
||||||
>
|
|
||||||
{children}
|
|
||||||
</EssentialPluginTooltipContext.Provider>
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|
||||||
export function useEnablePluginTooltip() {
|
|
||||||
const context = useContext(EnablePluginTooltipContext)
|
|
||||||
if (!context) {
|
|
||||||
throw new Error(
|
throw new Error(
|
||||||
'useEnablePluginTooltip must be used within EnablePluginTooltipProvider',
|
'usePluginTooltip must be used within PluginTooltipsProvider',
|
||||||
)
|
)
|
||||||
}
|
|
||||||
return context
|
|
||||||
}
|
|
||||||
|
|
||||||
export function useEssentialPluginTooltip() {
|
const { show, hide } = context
|
||||||
const context = useContext(EssentialPluginTooltipContext)
|
|
||||||
if (!context) {
|
|
||||||
throw new Error(
|
|
||||||
'useEssentialPluginTooltip must be used within EssentialPluginTooltipProvider',
|
|
||||||
)
|
|
||||||
}
|
|
||||||
return context
|
|
||||||
}
|
|
||||||
|
|
||||||
export function useResetTooltips() {
|
// Tapping anywhere else dismisses the tooltip
|
||||||
const enableTooltip = useContext(EnablePluginTooltipContext)
|
const ref = useClickOutside<View>(hide)
|
||||||
const essentialTooltip = useContext(EssentialPluginTooltipContext)
|
|
||||||
|
|
||||||
return () => {
|
const showTooltip = useCallback(() => {
|
||||||
enableTooltip?.setVisible(false)
|
// Tapping the element itself also counts as a tap outside of it (the
|
||||||
essentialTooltip?.setVisible(false)
|
// press target wraps the ref), so showing waits for the next frame
|
||||||
}
|
requestAnimationFrame(() => {
|
||||||
}
|
show(tooltip, ref.current)
|
||||||
|
|
||||||
export function useClickOutsideTooltip(
|
|
||||||
contextHook: () => TooltipContextValue,
|
|
||||||
onClickOutside: () => void,
|
|
||||||
) {
|
|
||||||
const { targetRef, setVisible } = contextHook()
|
|
||||||
|
|
||||||
const ref = useClickOutside<View>(() => {
|
|
||||||
setVisible(false)
|
|
||||||
if (targetRef.current === ref.current) {
|
|
||||||
onClickOutside()
|
|
||||||
}
|
|
||||||
})
|
})
|
||||||
|
}, [show, tooltip, ref])
|
||||||
|
|
||||||
return ref
|
return [ref, showTooltip] as const
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Hides the visible plugin tooltip, if any.. */
|
||||||
|
export function useHidePluginTooltips() {
|
||||||
|
const context = useContext(PluginTooltipsContext)
|
||||||
|
|
||||||
|
return useCallback(() => context?.hide(), [context])
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -1,22 +1,18 @@
|
||||||
import { ToastActionCreators } from '@revenge-mod/discord/actions'
|
import { ToastActionCreators } from '@revenge-mod/discord/actions'
|
||||||
import { onSettingsModulesLoaded } from '@revenge-mod/discord/modules/settings'
|
import { onSettingsModulesLoaded } from '@revenge-mod/discord/modules/settings'
|
||||||
import { JsonStorageUpdateMode } from '@revenge-mod/json-storage'
|
import { JsonStorageUpdateMode } from '@revenge-mod/json-storage'
|
||||||
import { reloadApp } from '@revenge-mod/modules/native/app'
|
|
||||||
import {
|
import {
|
||||||
InternalPluginFlags,
|
InternalPluginFlags,
|
||||||
PluginFlags,
|
PluginFlags,
|
||||||
registerInternalPlugin,
|
registerInternalPlugin,
|
||||||
} from '@revenge-mod/plugins/_'
|
} from '@revenge-mod/plugins/_'
|
||||||
import {
|
import {
|
||||||
listAllUpdates,
|
|
||||||
refreshAllRepos,
|
refreshAllRepos,
|
||||||
updateAllPlugins,
|
updateAllPlugins,
|
||||||
} from '@revenge-mod/plugins/_/repositories'
|
} from '@revenge-mod/plugins/_/repositories'
|
||||||
import { lookupGeneratedIconComponent } from '@revenge-mod/utils/discord'
|
import { lookupGeneratedIconComponent } from '@revenge-mod/utils/discord'
|
||||||
import pluginSettings from '../settings'
|
import pluginSettings from '../settings'
|
||||||
import { showPluginUpdatesFoundAlert } from './utils/alerts'
|
|
||||||
import { addDefaultRepoIfNeeded } from './repos'
|
import { addDefaultRepoIfNeeded } from './repos'
|
||||||
import { showErrorToast } from './utils/repos'
|
|
||||||
import type { JsonStorage } from '@revenge-mod/json-storage'
|
import type { JsonStorage } from '@revenge-mod/json-storage'
|
||||||
import type { PluginApi } from '@revenge-mod/plugins/types'
|
import type { PluginApi } from '@revenge-mod/plugins/types'
|
||||||
|
|
||||||
|
|
@ -26,7 +22,7 @@ export interface Storage {
|
||||||
defaultRepoRestored?: boolean
|
defaultRepoRestored?: boolean
|
||||||
}
|
}
|
||||||
|
|
||||||
const OUTDATED_THRESHOLD = 24 * 60 * 60 * 1000 // 1 day
|
const OUTDATED_THRESHOLD = 60 * 60 * 1000 // 1 hour
|
||||||
const AUTO_UPDATE_CHECK_DELAY = 10_000
|
const AUTO_UPDATE_CHECK_DELAY = 10_000
|
||||||
|
|
||||||
const CircleXIconComponent = lookupGeneratedIconComponent(
|
const CircleXIconComponent = lookupGeneratedIconComponent(
|
||||||
|
|
@ -82,33 +78,11 @@ function autoUpdateService(settings: Storage) {
|
||||||
setTimeout(async () => {
|
setTimeout(async () => {
|
||||||
try {
|
try {
|
||||||
const { errors } = await refreshAllRepos()
|
const { errors } = await refreshAllRepos()
|
||||||
|
await updateAllPlugins()
|
||||||
|
|
||||||
if (errors.length) {
|
if (!errors.length)
|
||||||
ToastActionCreators.open({
|
await api.jsonStorage.set({
|
||||||
key: 'PLUGIN_UPDATE_CHECK_FAILED',
|
lastUpdateCheck: Date.now(),
|
||||||
content: 'Failed to refresh some plugin repositories',
|
|
||||||
IconComponent: CircleXIconComponent,
|
|
||||||
})
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
const { updates } = await listAllUpdates()
|
|
||||||
await api.jsonStorage.set({ lastUpdateCheck: Date.now() })
|
|
||||||
|
|
||||||
if (!updates.length) return
|
|
||||||
|
|
||||||
showPluginUpdatesFoundAlert(updates, async () => {
|
|
||||||
const { errors: updateErrors } = await updateAllPlugins()
|
|
||||||
|
|
||||||
if (updateErrors.length) {
|
|
||||||
showErrorToast(
|
|
||||||
updateErrors
|
|
||||||
.map(e => `${e.id}: ${e.error}`)
|
|
||||||
.join('\n'),
|
|
||||||
)
|
|
||||||
} else {
|
|
||||||
reloadApp()
|
|
||||||
}
|
|
||||||
})
|
})
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
ToastActionCreators.open({
|
ToastActionCreators.open({
|
||||||
|
|
|
||||||
|
|
@ -7,14 +7,16 @@ import { noop } from '@revenge-mod/utils/callback'
|
||||||
import { api } from '.'
|
import { api } from '.'
|
||||||
import type { Repo, RepoConfigEntry } from '@revenge-mod/plugins/_/repositories'
|
import type { Repo, RepoConfigEntry } from '@revenge-mod/plugins/_/repositories'
|
||||||
|
|
||||||
export async function addDefaultRepoIfNeeded() {
|
export async function addDefaultRepoIfNeeded(
|
||||||
if (!__BUILD_DEFAULT_PLUGIN_REPOSITORY_URL__) return
|
refresh = false,
|
||||||
|
): Promise<boolean> {
|
||||||
|
if (!__BUILD_DEFAULT_PLUGIN_REPOSITORY_URL__) return false
|
||||||
|
|
||||||
const repos = await listRepos()
|
const repos = await listRepos()
|
||||||
if (
|
if (
|
||||||
repos.some(repo => repo.url === __BUILD_DEFAULT_PLUGIN_REPOSITORY_URL__)
|
repos.some(repo => repo.url === __BUILD_DEFAULT_PLUGIN_REPOSITORY_URL__)
|
||||||
)
|
)
|
||||||
return
|
return false
|
||||||
|
|
||||||
const userRepos = toConfig(repos.filter(repo => !repo.internal))
|
const userRepos = toConfig(repos.filter(repo => !repo.internal))
|
||||||
await setRepos([
|
await setRepos([
|
||||||
|
|
@ -24,7 +26,10 @@ export async function addDefaultRepoIfNeeded() {
|
||||||
|
|
||||||
await api.jsonStorage.set({ defaultRepoRestored: true }).catch(noop)
|
await api.jsonStorage.set({ defaultRepoRestored: true }).catch(noop)
|
||||||
|
|
||||||
|
if (refresh)
|
||||||
await refreshRepo(__BUILD_DEFAULT_PLUGIN_REPOSITORY_URL__).catch(noop)
|
await refreshRepo(__BUILD_DEFAULT_PLUGIN_REPOSITORY_URL__).catch(noop)
|
||||||
|
|
||||||
|
return true
|
||||||
}
|
}
|
||||||
|
|
||||||
export function toConfig(list: Repo[]): RepoConfigEntry[] {
|
export function toConfig(list: Repo[]): RepoConfigEntry[] {
|
||||||
|
|
|
||||||
|
|
@ -3,6 +3,7 @@ import { FormSwitch } from '@revenge-mod/components'
|
||||||
import { styles } from '@revenge-mod/components/_'
|
import { styles } from '@revenge-mod/components/_'
|
||||||
import Page from '@revenge-mod/components/Page'
|
import Page from '@revenge-mod/components/Page'
|
||||||
import TableRowAssetIcon from '@revenge-mod/components/TableRowAssetIcon'
|
import TableRowAssetIcon from '@revenge-mod/components/TableRowAssetIcon'
|
||||||
|
import { ToastActionCreators } from '@revenge-mod/discord/actions'
|
||||||
import { Design } from '@revenge-mod/discord/design'
|
import { Design } from '@revenge-mod/discord/design'
|
||||||
import { Clipboard } from '@revenge-mod/externals/react-native-clipboard'
|
import { Clipboard } from '@revenge-mod/externals/react-native-clipboard'
|
||||||
import { callNativeMethod } from '@revenge-mod/modules/native'
|
import { callNativeMethod } from '@revenge-mod/modules/native'
|
||||||
|
|
@ -17,12 +18,12 @@ import {
|
||||||
updateAllPlugins,
|
updateAllPlugins,
|
||||||
} from '@revenge-mod/plugins/_/repositories'
|
} from '@revenge-mod/plugins/_/repositories'
|
||||||
import { lookupGeneratedIconComponent } from '@revenge-mod/utils/discord'
|
import { lookupGeneratedIconComponent } from '@revenge-mod/utils/discord'
|
||||||
import { useCallback, useEffect, useState } from 'react'
|
import { useCallback, useEffect, useReducer, useState } from 'react'
|
||||||
import { ScrollView, View } from 'react-native'
|
import { ScrollView, View } from 'react-native'
|
||||||
import { api } from '..'
|
import { api } from '..'
|
||||||
import { addDefaultRepoIfNeeded, toConfig } from '../repos'
|
import { addDefaultRepoIfNeeded, toConfig } from '../repos'
|
||||||
import { formatBytes, messageOf, showErrorToast } from '../utils/repos'
|
|
||||||
import { showRemoveRepoConfirmation } from '../utils/alerts'
|
import { showRemoveRepoConfirmation } from '../utils/alerts'
|
||||||
|
import { formatBytes, messageOf, showErrorToast } from '../utils/repos'
|
||||||
import type {
|
import type {
|
||||||
DownloadProgressEvent,
|
DownloadProgressEvent,
|
||||||
Repo,
|
Repo,
|
||||||
|
|
@ -102,7 +103,8 @@ function UserRepoRow({
|
||||||
label: 'Delete',
|
label: 'Delete',
|
||||||
IconComponent: TrashIconComponent,
|
IconComponent: TrashIconComponent,
|
||||||
variant: 'destructive' as const,
|
variant: 'destructive' as const,
|
||||||
action: () => showRemoveRepoConfirmation(repo, () => onRemove(repo)),
|
action: () =>
|
||||||
|
showRemoveRepoConfirmation(repo, () => onRemove(repo)),
|
||||||
},
|
},
|
||||||
],
|
],
|
||||||
]
|
]
|
||||||
|
|
@ -146,6 +148,7 @@ export default function RevengePluginsAdvancedSettingScreen() {
|
||||||
Record<string, RepoStateEvent['state']>
|
Record<string, RepoStateEvent['state']>
|
||||||
>({})
|
>({})
|
||||||
const [progress, setProgress] = useState<DownloadProgressEvent | null>(null)
|
const [progress, setProgress] = useState<DownloadProgressEvent | null>(null)
|
||||||
|
const [n, forceUpdate] = useReducer(x => ~x, 0)
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
const onRepoState = (event: RepoStateEvent) => {
|
const onRepoState = (event: RepoStateEvent) => {
|
||||||
|
|
@ -169,7 +172,8 @@ export default function RevengePluginsAdvancedSettingScreen() {
|
||||||
listRepos().then(setReposState, e => showErrorToast(messageOf(e)))
|
listRepos().then(setReposState, e => showErrorToast(messageOf(e)))
|
||||||
}, [])
|
}, [])
|
||||||
|
|
||||||
useEffect(refresh, [refresh])
|
// biome-ignore lint/correctness/useExhaustiveDependencies: forceUpdate so we can refresh the screen
|
||||||
|
useEffect(refresh, [refresh, n])
|
||||||
|
|
||||||
const commit = useCallback(
|
const commit = useCallback(
|
||||||
async (config: RepoConfigEntry[]) => {
|
async (config: RepoConfigEntry[]) => {
|
||||||
|
|
@ -213,7 +217,6 @@ export default function RevengePluginsAdvancedSettingScreen() {
|
||||||
|
|
||||||
const removeRepo = useCallback(
|
const removeRepo = useCallback(
|
||||||
async (repo: Repo) => {
|
async (repo: Repo) => {
|
||||||
|
|
||||||
await commit(toConfig(userRepos.filter(r => r.url !== repo.url)))
|
await commit(toConfig(userRepos.filter(r => r.url !== repo.url)))
|
||||||
|
|
||||||
// Plugins installed from the removed repository turn Sideloaded
|
// Plugins installed from the removed repository turn Sideloaded
|
||||||
|
|
@ -372,12 +375,24 @@ export default function RevengePluginsAdvancedSettingScreen() {
|
||||||
/>
|
/>
|
||||||
<TableRow
|
<TableRow
|
||||||
icon={<TableRowAssetIcon name="GlobeEarthIcon" />}
|
icon={<TableRowAssetIcon name="GlobeEarthIcon" />}
|
||||||
label="Restore default repositories"
|
label="Restore default repository"
|
||||||
subLabel="Add the bundled default repository if it's missing"
|
onPress={async () => {
|
||||||
onPress={() => {
|
try {
|
||||||
addDefaultRepoIfNeeded().catch(e =>
|
const restored =
|
||||||
showErrorToast(messageOf(e)),
|
await addDefaultRepoIfNeeded(true)
|
||||||
)
|
if (!restored) {
|
||||||
|
ToastActionCreators.open({
|
||||||
|
key: 'revenge-default-repo-nothing',
|
||||||
|
content: 'Nothing to restore',
|
||||||
|
})
|
||||||
|
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
forceUpdate()
|
||||||
|
} catch (e) {
|
||||||
|
showErrorToast(messageOf(e))
|
||||||
|
}
|
||||||
}}
|
}}
|
||||||
/>
|
/>
|
||||||
</TableRowGroup>
|
</TableRowGroup>
|
||||||
|
|
|
||||||
|
|
@ -13,13 +13,9 @@ import {
|
||||||
import { debounce } from '@revenge-mod/utils/callback'
|
import { debounce } from '@revenge-mod/utils/callback'
|
||||||
import { useCallback, useEffect, useMemo, useState } from 'react'
|
import { useCallback, useEffect, useMemo, useState } from 'react'
|
||||||
import { View } from 'react-native'
|
import { View } from 'react-native'
|
||||||
import { ClickOutsideProvider } from 'react-native-click-outside'
|
|
||||||
import { BrowsePluginMasonryFlashList } from '../components/PluginList'
|
import { BrowsePluginMasonryFlashList } from '../components/PluginList'
|
||||||
import PluginStatesProvider from '../components/PluginStateProvider'
|
import PluginStatesProvider from '../components/PluginStateProvider'
|
||||||
import {
|
import PluginTooltipsProvider from '../components/TooltipProvider'
|
||||||
EnablePluginTooltipProvider,
|
|
||||||
EssentialPluginTooltipProvider,
|
|
||||||
} from '../components/TooltipProvider'
|
|
||||||
import { runInstallFlow } from '../utils/repos'
|
import { runInstallFlow } from '../utils/repos'
|
||||||
import type { RepoPluginListing } from '@revenge-mod/plugins/_/repositories'
|
import type { RepoPluginListing } from '@revenge-mod/plugins/_/repositories'
|
||||||
import type { BrowseSortKey } from '../components/BrowseFilterAndSortActionSheet'
|
import type { BrowseSortKey } from '../components/BrowseFilterAndSortActionSheet'
|
||||||
|
|
@ -34,17 +30,13 @@ const SearchDebounceTime = 100
|
||||||
export default function RevengePluginsBrowseSettingScreen() {
|
export default function RevengePluginsBrowseSettingScreen() {
|
||||||
return (
|
return (
|
||||||
<LayerScope>
|
<LayerScope>
|
||||||
<ClickOutsideProvider>
|
|
||||||
<PluginStatesProvider>
|
<PluginStatesProvider>
|
||||||
<Page spacing={16}>
|
<Page spacing={16}>
|
||||||
<EssentialPluginTooltipProvider>
|
<PluginTooltipsProvider>
|
||||||
<EnablePluginTooltipProvider>
|
|
||||||
<Screen />
|
<Screen />
|
||||||
</EnablePluginTooltipProvider>
|
</PluginTooltipsProvider>
|
||||||
</EssentialPluginTooltipProvider>
|
|
||||||
</Page>
|
</Page>
|
||||||
</PluginStatesProvider>
|
</PluginStatesProvider>
|
||||||
</ClickOutsideProvider>
|
|
||||||
</LayerScope>
|
</LayerScope>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
@ -82,18 +74,15 @@ function Screen() {
|
||||||
const [excluded, setExcluded] = useState<string[]>([])
|
const [excluded, setExcluded] = useState<string[]>([])
|
||||||
const [sort, setSort] = useState<BrowseSortKey>('name')
|
const [sort, setSort] = useState<BrowseSortKey>('name')
|
||||||
const [reverse, setReverse] = useState(false)
|
const [reverse, setReverse] = useState(false)
|
||||||
const [channel, setChannel] = useState('')
|
|
||||||
const [channels, setChannels] = useState<string[]>([])
|
|
||||||
|
|
||||||
const hasFilter = useMemo(
|
const hasFilter = useMemo(
|
||||||
() => excluded.length > 0 || sort !== 'name' || reverse || channel !== '',
|
() => excluded.length > 0 || sort !== 'name' || reverse,
|
||||||
[excluded, sort, reverse, channel],
|
[excluded, sort, reverse],
|
||||||
)
|
)
|
||||||
|
|
||||||
const load = useCallback(async () => {
|
const load = useCallback(async () => {
|
||||||
const repos = await listRepos()
|
const repos = await listRepos()
|
||||||
const all: BrowseEntry[] = []
|
const all: BrowseEntry[] = []
|
||||||
const allChannels = new Set<string>()
|
|
||||||
|
|
||||||
setInternalRepos(
|
setInternalRepos(
|
||||||
repos.filter(repo => repo.internal).map(repo => repo.url),
|
repos.filter(repo => repo.internal).map(repo => repo.url),
|
||||||
|
|
@ -111,9 +100,7 @@ function Screen() {
|
||||||
|
|
||||||
for (const listing of listings) {
|
for (const listing of listings) {
|
||||||
const plugin = pList.get(listing.id)
|
const plugin = pList.get(listing.id)
|
||||||
for (const c of Object.keys(listing.channels))
|
const displayChannel = displayChannelOf(listing, '')
|
||||||
allChannels.add(c)
|
|
||||||
const displayChannel = displayChannelOf(listing, channel)
|
|
||||||
const displayVersion = displayChannel
|
const displayVersion = displayChannel
|
||||||
? (listing.channels[displayChannel] ?? '')
|
? (listing.channels[displayChannel] ?? '')
|
||||||
: ''
|
: ''
|
||||||
|
|
@ -143,9 +130,8 @@ function Screen() {
|
||||||
),
|
),
|
||||||
)
|
)
|
||||||
|
|
||||||
setChannels([...allChannels].sort())
|
|
||||||
setEntries(all)
|
setEntries(all)
|
||||||
}, [channel, setChannels])
|
}, [])
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
// Show cached indexes right away, then refresh everything
|
// Show cached indexes right away, then refresh everything
|
||||||
|
|
@ -167,12 +153,24 @@ function Screen() {
|
||||||
}, [load])
|
}, [load])
|
||||||
|
|
||||||
const install = useCallback(
|
const install = useCallback(
|
||||||
async (entry: BrowseEntry) => {
|
async (
|
||||||
|
entry: BrowseEntry,
|
||||||
|
channel?: string,
|
||||||
|
version?: string,
|
||||||
|
) => {
|
||||||
|
const targetChannel = channel || entry.channel
|
||||||
|
const targetVersion =
|
||||||
|
version ||
|
||||||
|
(targetChannel
|
||||||
|
? (entry.listing.channels[targetChannel] ?? '')
|
||||||
|
: '') ||
|
||||||
|
entry.version
|
||||||
|
|
||||||
// Pin the displayed version, channel, and repository, so the plan matches the card
|
// Pin the displayed version, channel, and repository, so the plan matches the card
|
||||||
await runInstallFlow(
|
await runInstallFlow(
|
||||||
entry.listing.id,
|
entry.listing.id,
|
||||||
entry.version || undefined,
|
targetVersion || undefined,
|
||||||
entry.channel,
|
targetChannel,
|
||||||
// Internal repos so external plugins can link against internal plugins as well
|
// Internal repos so external plugins can link against internal plugins as well
|
||||||
[...internalRepos, entry.repoUrl],
|
[...internalRepos, entry.repoUrl],
|
||||||
)
|
)
|
||||||
|
|
@ -239,9 +237,6 @@ function Screen() {
|
||||||
.map(repo => repo.url)
|
.map(repo => repo.url)
|
||||||
.filter(url => !urls.includes(url)),
|
.filter(url => !urls.includes(url)),
|
||||||
),
|
),
|
||||||
channels,
|
|
||||||
channel,
|
|
||||||
setChannel,
|
|
||||||
sort,
|
sort,
|
||||||
setSort,
|
setSort,
|
||||||
reverse,
|
reverse,
|
||||||
|
|
|
||||||
|
|
@ -28,14 +28,10 @@ import {
|
||||||
useState,
|
useState,
|
||||||
} from 'react'
|
} from 'react'
|
||||||
import { Image, View } from 'react-native'
|
import { Image, View } from 'react-native'
|
||||||
import { ClickOutsideProvider } from 'react-native-click-outside'
|
|
||||||
import RevengeIcon from '~assets/RevengeIcon'
|
import RevengeIcon from '~assets/RevengeIcon'
|
||||||
import { InstalledPluginMasonryFlashList } from '../components/PluginList'
|
import { InstalledPluginMasonryFlashList } from '../components/PluginList'
|
||||||
import PluginStatesProvider from '../components/PluginStateProvider'
|
import PluginStatesProvider from '../components/PluginStateProvider'
|
||||||
import {
|
import PluginTooltipsProvider from '../components/TooltipProvider'
|
||||||
EnablePluginTooltipProvider,
|
|
||||||
EssentialPluginTooltipProvider,
|
|
||||||
} from '../components/TooltipProvider'
|
|
||||||
import { RouteNames, Setting } from '../constants'
|
import { RouteNames, Setting } from '../constants'
|
||||||
import type { NavigationProp, RouteProp } from '@react-navigation/core'
|
import type { NavigationProp, RouteProp } from '@react-navigation/core'
|
||||||
import type { ReactNavigationParamList } from '@revenge-mod/externals/react-navigation'
|
import type { ReactNavigationParamList } from '@revenge-mod/externals/react-navigation'
|
||||||
|
|
@ -51,17 +47,13 @@ const PlusLargeIcon = getAssetIdByName('PlusLargeIcon')!
|
||||||
export default function RevengePluginsSettingScreen() {
|
export default function RevengePluginsSettingScreen() {
|
||||||
return (
|
return (
|
||||||
<LayerScope>
|
<LayerScope>
|
||||||
<ClickOutsideProvider>
|
|
||||||
<PluginStatesProvider>
|
<PluginStatesProvider>
|
||||||
|
<PluginTooltipsProvider>
|
||||||
<Page spacing={16}>
|
<Page spacing={16}>
|
||||||
<EssentialPluginTooltipProvider>
|
|
||||||
<EnablePluginTooltipProvider>
|
|
||||||
<Screen />
|
<Screen />
|
||||||
</EnablePluginTooltipProvider>
|
|
||||||
</EssentialPluginTooltipProvider>
|
|
||||||
</Page>
|
</Page>
|
||||||
|
</PluginTooltipsProvider>
|
||||||
</PluginStatesProvider>
|
</PluginStatesProvider>
|
||||||
</ClickOutsideProvider>
|
|
||||||
</LayerScope>
|
</LayerScope>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -13,9 +13,8 @@ import PluginHasDependentsAlert from '../components/PluginHasDependentsAlert'
|
||||||
import PluginMissingDependenciesAlert from '../components/PluginMissingDependenciesAlert'
|
import PluginMissingDependenciesAlert from '../components/PluginMissingDependenciesAlert'
|
||||||
import PluginStatesProvider from '../components/PluginStateProvider'
|
import PluginStatesProvider from '../components/PluginStateProvider'
|
||||||
import PluginUninstallConfirmationAlert from '../components/PluginUninstallConfirmationAlert'
|
import PluginUninstallConfirmationAlert from '../components/PluginUninstallConfirmationAlert'
|
||||||
import PluginUpdatesFoundAlert from '../components/PluginUpdatesFoundAlert'
|
|
||||||
import RepoRemoveConfirmationAlert from '../components/RepoRemoveConfirmationAlert'
|
import RepoRemoveConfirmationAlert from '../components/RepoRemoveConfirmationAlert'
|
||||||
import type { Repo, RepoUpdate } from '@revenge-mod/plugins/_/repositories'
|
import type { Repo } from '@revenge-mod/plugins/_/repositories'
|
||||||
import type { AnyPlugin } from '@revenge-mod/plugins/_'
|
import type { AnyPlugin } from '@revenge-mod/plugins/_'
|
||||||
|
|
||||||
export function showPluginClearDataConfirmation(
|
export function showPluginClearDataConfirmation(
|
||||||
|
|
@ -135,13 +134,3 @@ export function showPluginHasDependentsAlert(
|
||||||
</PluginStatesProvider>,
|
</PluginStatesProvider>,
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
export function showPluginUpdatesFoundAlert(
|
|
||||||
updates: RepoUpdate[],
|
|
||||||
action: () => Promise<void>,
|
|
||||||
) {
|
|
||||||
AlertActionCreators.openAlert(
|
|
||||||
'plugin-updates-found',
|
|
||||||
<PluginUpdatesFoundAlert updates={updates} action={action} />,
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|
|
||||||
|
|
@ -1,8 +1,21 @@
|
||||||
|
import { getAssetIdByName } from '@revenge-mod/assets'
|
||||||
|
import { styles } from '@revenge-mod/components/_'
|
||||||
|
import { Design } from '@revenge-mod/discord/design'
|
||||||
import { SettingListRenderer } from '@revenge-mod/discord/modules/settings/renderer'
|
import { SettingListRenderer } from '@revenge-mod/discord/modules/settings/renderer'
|
||||||
|
import { Image, Linking, ScrollView, StyleSheet, View } from 'react-native'
|
||||||
|
import RevengeDonate from '~assets/RevengeDonate'
|
||||||
import { Setting } from '../constants'
|
import { Setting } from '../constants'
|
||||||
|
|
||||||
|
const { Button, Card, Stack, Text } = Design
|
||||||
|
|
||||||
export default function RevengeSettingScreen() {
|
export default function RevengeSettingScreen() {
|
||||||
return (
|
return (
|
||||||
|
<ScrollView style={{ flex: 1 }}>
|
||||||
|
<Stack spacing={0}>
|
||||||
|
<View style={{ padding: 16, paddingBottom: 0 }}>
|
||||||
|
<DonateCard />
|
||||||
|
</View>
|
||||||
|
<View>
|
||||||
<SettingListRenderer.SettingsList
|
<SettingListRenderer.SettingsList
|
||||||
node={{
|
node={{
|
||||||
type: 'list',
|
type: 'list',
|
||||||
|
|
@ -35,5 +48,92 @@ export default function RevengeSettingScreen() {
|
||||||
],
|
],
|
||||||
}}
|
}}
|
||||||
/>
|
/>
|
||||||
|
</View>
|
||||||
|
</Stack>
|
||||||
|
</ScrollView>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
const cardStyles = StyleSheet.create({
|
||||||
|
background: {
|
||||||
|
position: 'absolute',
|
||||||
|
top: 0,
|
||||||
|
left: 0,
|
||||||
|
width: '100%',
|
||||||
|
height: '100%',
|
||||||
|
},
|
||||||
|
illustration: {
|
||||||
|
width: 96,
|
||||||
|
height: 96,
|
||||||
|
},
|
||||||
|
})
|
||||||
|
|
||||||
|
function DonateCard() {
|
||||||
|
return (
|
||||||
|
<Card
|
||||||
|
style={{
|
||||||
|
position: 'relative',
|
||||||
|
padding: 0,
|
||||||
|
overflow: 'hidden',
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<View
|
||||||
|
style={[
|
||||||
|
cardStyles.background,
|
||||||
|
{
|
||||||
|
opacity: 0.5,
|
||||||
|
experimental_backgroundImage:
|
||||||
|
'linear-gradient(166deg, rgba(255, 131, 29, 0.33), rgba(145, 47, 47, 0)), ' +
|
||||||
|
'linear-gradient(45deg, rgba(145, 47, 47, 0), rgba(255, 22, 22, 0.5))',
|
||||||
|
},
|
||||||
|
]}
|
||||||
|
/>
|
||||||
|
<View style={{ padding: 16 }}>
|
||||||
|
<Stack style={styles.grow} direction="horizontal" spacing={16}>
|
||||||
|
<Stack spacing={16} style={styles.flex}>
|
||||||
|
<Stack spacing={6} style={styles.flex}>
|
||||||
|
<Text
|
||||||
|
variant="heading-lg/semibold"
|
||||||
|
color="text-strong"
|
||||||
|
>
|
||||||
|
Support Revenge
|
||||||
|
</Text>
|
||||||
|
<Text variant="text-md/medium">
|
||||||
|
You can support the development of Revenge by
|
||||||
|
donating or contributing!
|
||||||
|
</Text>
|
||||||
|
</Stack>
|
||||||
|
<Stack spacing={12} direction="horizontal">
|
||||||
|
{__BUILD_DONATE_URL__ && (
|
||||||
|
<Button
|
||||||
|
size="sm"
|
||||||
|
icon={getAssetIdByName('HeartIcon')}
|
||||||
|
text="Donate"
|
||||||
|
variant="expressive"
|
||||||
|
onPress={() => {
|
||||||
|
Linking.openURL(__BUILD_DONATE_URL__)
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
<Button
|
||||||
|
size="sm"
|
||||||
|
icon={getAssetIdByName('HandRequestSpeakIcon')}
|
||||||
|
text="Contribute"
|
||||||
|
variant="primary-overlay"
|
||||||
|
onPress={() => {
|
||||||
|
Linking.openURL(
|
||||||
|
__BUILD_SOURCE_REPOSITORY_URL__,
|
||||||
|
)
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
</Stack>
|
||||||
|
</Stack>
|
||||||
|
<Image
|
||||||
|
source={RevengeDonate}
|
||||||
|
style={cardStyles.illustration}
|
||||||
|
/>
|
||||||
|
</Stack>
|
||||||
|
</View>
|
||||||
|
</Card>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -20,13 +20,20 @@ registerInternalPlugin(
|
||||||
icon: 'StaffBadgeIcon',
|
icon: 'StaffBadgeIcon',
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
start({ cleanup, logger }) {
|
start({ cleanup, logger, plugin }) {
|
||||||
const CircleInformationIcon = lookupGeneratedIconComponent(
|
const CircleInformationIcon = lookupGeneratedIconComponent(
|
||||||
'CircleInformationIcon',
|
'CircleInformationIcon',
|
||||||
'CircleInformationIcon-secondary',
|
'CircleInformationIcon-secondary',
|
||||||
'CircleInformationIcon-primary',
|
'CircleInformationIcon-primary',
|
||||||
)
|
)
|
||||||
|
|
||||||
|
const showToast = () =>
|
||||||
|
ToastActionCreators.open({
|
||||||
|
key: 'staff-settings-action',
|
||||||
|
content: 'Navigate out of Settings to apply changes',
|
||||||
|
IconComponent: CircleInformationIcon,
|
||||||
|
})
|
||||||
|
|
||||||
function reset() {
|
function reset() {
|
||||||
getStore<{
|
getStore<{
|
||||||
initialize(): void
|
initialize(): void
|
||||||
|
|
@ -43,12 +50,6 @@ registerInternalPlugin(
|
||||||
|
|
||||||
store.initialize()
|
store.initialize()
|
||||||
unpatch()
|
unpatch()
|
||||||
|
|
||||||
ToastActionCreators.open({
|
|
||||||
key: 'staff-settings-action',
|
|
||||||
content: 'Navigate out of Settings to apply changes',
|
|
||||||
IconComponent: CircleInformationIcon,
|
|
||||||
})
|
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -69,9 +70,11 @@ registerInternalPlugin(
|
||||||
).getCurrentUser(),
|
).getCurrentUser(),
|
||||||
),
|
),
|
||||||
reset,
|
reset,
|
||||||
|
showToast,
|
||||||
)
|
)
|
||||||
|
|
||||||
reset()
|
reset()
|
||||||
|
if (plugin.startedLate) showToast()
|
||||||
}),
|
}),
|
||||||
)
|
)
|
||||||
},
|
},
|
||||||
|
|
|
||||||
|
|
@ -1,8 +1,4 @@
|
||||||
{
|
{
|
||||||
"629368283354628116": ["revenge_team"],
|
"629368283354628116": ["revenge_team"],
|
||||||
"399862294143696897": ["revenge_team"],
|
"399862294143696897": ["revenge_team"]
|
||||||
"853550207039832084": ["revenge_team"],
|
|
||||||
"485376328980365312": ["revenge_team"],
|
|
||||||
"737323631117598811": ["revenge_team"],
|
|
||||||
"826489186327724095": ["revenge_team"]
|
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -7,23 +7,18 @@ import {
|
||||||
PluginFlags,
|
PluginFlags,
|
||||||
registerInternalPlugin,
|
registerInternalPlugin,
|
||||||
} from '@revenge-mod/plugins/_'
|
} from '@revenge-mod/plugins/_'
|
||||||
import { afterJSX, beforeJSX } from '@revenge-mod/react/jsx-runtime'
|
import { insteadJSX } from '@revenge-mod/react/jsx-runtime'
|
||||||
import { findInReactFiber } from '@revenge-mod/utils/react'
|
import { createElement, isValidElement } from 'react'
|
||||||
import { Image } from 'react-native'
|
import { Image } from 'react-native'
|
||||||
import { Badges, UsersWithBadges } from './constants'
|
import { Badges, UsersWithBadges } from './constants'
|
||||||
import { styles, useBadgeStyles } from './styles'
|
import { styles, useBadgeStyles } from './styles'
|
||||||
import { afterRendered } from './utils'
|
import { mapElementTree, patchRender } from './utils'
|
||||||
import type { FC, ReactElement } from 'react'
|
import type { FC, ReactElement, ReactNode } from 'react'
|
||||||
import type {
|
import type { ImageSourcePropType } from 'react-native'
|
||||||
ImageProps,
|
|
||||||
ImageSourcePropType,
|
|
||||||
ImageStyle,
|
|
||||||
PressableProps,
|
|
||||||
StyleProp,
|
|
||||||
} from 'react-native'
|
|
||||||
import type { Badge, BadgeId } from './constants'
|
import type { Badge, BadgeId } from './constants'
|
||||||
|
import type { RenderPatch } from './utils'
|
||||||
|
|
||||||
const DUMMY_SYMBOL = {} as unknown as string
|
type BadgeStyles = ReturnType<typeof useBadgeStyles>
|
||||||
|
|
||||||
interface ProfileBadgeProps {
|
interface ProfileBadgeProps {
|
||||||
id: string
|
id: string
|
||||||
|
|
@ -46,9 +41,10 @@ interface ProfileBadgeRowsProps {
|
||||||
showToastOnPress?: boolean
|
showToastOnPress?: boolean
|
||||||
}
|
}
|
||||||
|
|
||||||
type ViewWithProfileBadges = ReactElement<{
|
const DummyBadgeId = {} as unknown as string
|
||||||
children: Array<ReactElement<ProfileBadgeProps, FC<ProfileBadgeProps>>>
|
const DummyBadges = [
|
||||||
}>
|
{ id: DummyBadgeId },
|
||||||
|
] as unknown as ProfileBadgeRowsProps['badges']
|
||||||
|
|
||||||
registerInternalPlugin(
|
registerInternalPlugin(
|
||||||
{
|
{
|
||||||
|
|
@ -66,28 +62,25 @@ registerInternalPlugin(
|
||||||
),
|
),
|
||||||
({ ProfileBadgeRows }) => {
|
({ ProfileBadgeRows }) => {
|
||||||
cleanup(
|
cleanup(
|
||||||
beforeJSX(ProfileBadgeRows, args => {
|
insteadJSX(
|
||||||
const [, props] = args
|
ProfileBadgeRows,
|
||||||
|
([type, props, key], jsx) => {
|
||||||
|
if (
|
||||||
|
!UsersWithBadges[props.userId] ||
|
||||||
|
typeof type !== 'function'
|
||||||
|
)
|
||||||
|
return jsx(type, props, key)
|
||||||
|
|
||||||
// Inject dummy badge for users with custom badges to ensure ProfileBadge components exist
|
// Inject dummy badge for users with custom badges to ensure ProfileBadge components exist
|
||||||
if (
|
return jsx(
|
||||||
// has custom badges
|
patchRender(type, injectCustomBadges),
|
||||||
UsersWithBadges[props.userId] &&
|
props.badges.length
|
||||||
// but no actual badges
|
? props
|
||||||
!props.badges.length
|
: { ...props, badges: DummyBadges },
|
||||||
|
key,
|
||||||
)
|
)
|
||||||
// @ts-expect-error: This will never be rendered
|
},
|
||||||
props.badges.push({ id: DUMMY_SYMBOL })
|
),
|
||||||
|
|
||||||
return args
|
|
||||||
}),
|
|
||||||
)
|
|
||||||
|
|
||||||
cleanup(
|
|
||||||
afterJSX(ProfileBadgeRows, el => {
|
|
||||||
patchProfileBadgeRows(el)
|
|
||||||
return el
|
|
||||||
}),
|
|
||||||
)
|
)
|
||||||
},
|
},
|
||||||
)
|
)
|
||||||
|
|
@ -103,92 +96,102 @@ registerInternalPlugin(
|
||||||
InternalPluginFlags.Internal | InternalPluginFlags.Essential,
|
InternalPluginFlags.Internal | InternalPluginFlags.Essential,
|
||||||
)
|
)
|
||||||
|
|
||||||
function patchProfileBadgeRows(
|
/** Matched structurally, as minified builds don't preserve `type.name`. */
|
||||||
element: ReactElement<ProfileBadgeRowsProps, FC<ProfileBadgeRowsProps>>,
|
function isProfileBadgeElement(
|
||||||
): void {
|
node: ReactNode,
|
||||||
const unpatch = afterRendered(element, el => {
|
): node is ReactElement<ProfileBadgeProps, FC<ProfileBadgeProps>> {
|
||||||
unpatch()
|
if (!isValidElement(node) || typeof node.type !== 'function') return false
|
||||||
|
|
||||||
const container = findInReactFiber(
|
const props = node.props as Partial<ProfileBadgeProps> | null
|
||||||
el as ReactElement,
|
return props != null && 'badgeSize' in props && 'userId' in props
|
||||||
isViewContainingProfileBadgeElements,
|
}
|
||||||
)
|
|
||||||
|
|
||||||
if (!container) return el
|
const injectCustomBadges: RenderPatch<ProfileBadgeRowsProps> = (
|
||||||
|
rendered,
|
||||||
|
{ userId },
|
||||||
|
) => {
|
||||||
|
const userBadges = UsersWithBadges[userId]
|
||||||
|
if (!userBadges) return rendered
|
||||||
|
|
||||||
const [{ type: ProfileBadge, props }] = container.props.children
|
return mapElementTree(rendered, element => {
|
||||||
const userBadges = UsersWithBadges[props.userId]
|
const { children } = element.props
|
||||||
|
if (!Array.isArray(children)) return
|
||||||
|
|
||||||
if (userBadges) {
|
const template = children.find(isProfileBadgeElement)
|
||||||
for (const badgeId of userBadges) {
|
if (!template) return
|
||||||
const badge = Badges[badgeId]
|
|
||||||
|
const ProfileBadge = template.type
|
||||||
|
const customBadges: ReactElement[] = []
|
||||||
|
|
||||||
|
for (const id of userBadges) {
|
||||||
|
const badge = Badges[id]
|
||||||
if (!badge) continue
|
if (!badge) continue
|
||||||
|
|
||||||
const badgeElement = (
|
customBadges.push(
|
||||||
<ProfileBadge
|
createElement(
|
||||||
{...props}
|
patchRender(ProfileBadge, patchCustomBadge, useBadgeStyles),
|
||||||
key={badgeId}
|
{
|
||||||
id={badgeId}
|
...template.props,
|
||||||
label={badge.label}
|
key: `revenge-badge-${id}`,
|
||||||
source={badge.icon}
|
id,
|
||||||
/>
|
label: badge.label,
|
||||||
|
source: badge.icon,
|
||||||
|
},
|
||||||
|
),
|
||||||
)
|
)
|
||||||
|
|
||||||
patchProfileBadge(badgeElement, badgeId, badge)
|
|
||||||
container.props.children.push(badgeElement)
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// Remove dummy badge if it exists
|
if (!customBadges.length) return
|
||||||
if (props.id === DUMMY_SYMBOL) container.props.children.shift()
|
|
||||||
}
|
|
||||||
|
|
||||||
return el
|
return {
|
||||||
|
children: [
|
||||||
|
...children.filter(
|
||||||
|
child =>
|
||||||
|
!isProfileBadgeElement(child) ||
|
||||||
|
child.props.id !== DummyBadgeId,
|
||||||
|
),
|
||||||
|
...customBadges,
|
||||||
|
],
|
||||||
|
}
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
function patchProfileBadge(
|
const patchCustomBadge: RenderPatch<ProfileBadgeProps, BadgeStyles> = (
|
||||||
element: ReactElement<ProfileBadgeProps, FC<ProfileBadgeProps>>,
|
rendered,
|
||||||
id: BadgeId,
|
{ id },
|
||||||
badge: Badge,
|
badgeStyles,
|
||||||
): void {
|
) => {
|
||||||
|
const badge = Badges[id as BadgeId]
|
||||||
|
if (!badge) return rendered
|
||||||
|
|
||||||
const { bnw, showDialog } = badge
|
const { bnw, showDialog } = badge
|
||||||
// Return early if we don't actually need to patch this badge
|
if (!bnw && !showDialog) return rendered
|
||||||
if (!bnw && !showDialog) return
|
|
||||||
|
|
||||||
const unpatch = afterRendered(element, el => {
|
let tinted = false
|
||||||
unpatch()
|
let pressablePatched = false
|
||||||
|
|
||||||
// This will not result in a conditional hook error because badges do not change flags during runtime
|
// Only patched the most shallow Pressable
|
||||||
const badgeStyles = useBadgeStyles()
|
return mapElementTree(rendered, element => {
|
||||||
|
if (bnw && !tinted && element.type === Image) {
|
||||||
if (showDialog) {
|
tinted = true
|
||||||
const pressable = findInReactFiber(
|
return { style: [element.props.style, badgeStyles.tinted] }
|
||||||
el as ReactElement,
|
|
||||||
(node): node is ReactElement<PressableProps> =>
|
|
||||||
node.type?.render,
|
|
||||||
)
|
|
||||||
|
|
||||||
if (pressable)
|
|
||||||
pressable.props.onPress = () =>
|
|
||||||
openBadgeDialog(id, badge, badgeStyles)
|
|
||||||
}
|
}
|
||||||
|
|
||||||
if (bnw) {
|
if (
|
||||||
const image = findInReactFiber(
|
showDialog &&
|
||||||
el as ReactElement,
|
!pressablePatched &&
|
||||||
(node): node is ReactElement<ImageProps> => node.type === Image,
|
// Match by behaviour, as matching .type === Image picks whichever wrapper component happens to come first
|
||||||
)
|
('onPress' in element.props ||
|
||||||
|
element.props.accessibilityRole === 'button')
|
||||||
if (image) {
|
) {
|
||||||
const styles = image.props.style as Extract<
|
pressablePatched = true
|
||||||
StyleProp<ImageStyle>,
|
return {
|
||||||
any[]
|
onPress: () =>
|
||||||
>
|
openBadgeDialog(id as BadgeId, badge, badgeStyles),
|
||||||
styles.push(badgeStyles.tinted)
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
return el
|
return undefined
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -197,10 +200,10 @@ const { AlertActionButton, AlertModal, Stack, Text } = Design
|
||||||
function openBadgeDialog(
|
function openBadgeDialog(
|
||||||
id: BadgeId,
|
id: BadgeId,
|
||||||
{ label, description, bnw, icon }: Badge,
|
{ label, description, bnw, icon }: Badge,
|
||||||
badgeStyles: ReturnType<typeof useBadgeStyles>,
|
badgeStyles: BadgeStyles,
|
||||||
): void {
|
): void {
|
||||||
AlertActionCreators.openAlert(
|
AlertActionCreators.openAlert(
|
||||||
`REVENGE_PROFILE_BADGE-${id}`,
|
`revenge-profile-badge-${id}`,
|
||||||
<AlertModal
|
<AlertModal
|
||||||
title={
|
title={
|
||||||
<Stack style={styles.stack}>
|
<Stack style={styles.stack}>
|
||||||
|
|
@ -217,12 +220,7 @@ function openBadgeDialog(
|
||||||
</Stack>
|
</Stack>
|
||||||
}
|
}
|
||||||
content={description}
|
content={description}
|
||||||
actions={<AlertActionButton text="Okay" />}
|
actions={<AlertActionButton text="OK" />}
|
||||||
/>,
|
/>,
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
const isViewContainingProfileBadgeElements = (
|
|
||||||
node: any,
|
|
||||||
): node is ViewWithProfileBadges =>
|
|
||||||
node.props?.children?.[0]?.type.name === 'ProfileBadge'
|
|
||||||
|
|
|
||||||
|
|
@ -1,7 +1,90 @@
|
||||||
import { after } from '@revenge-mod/patcher'
|
import { cloneElement, isValidElement } from 'react'
|
||||||
import type { FC, ReactElement, ReactNode } from 'react'
|
import type { FC, ReactElement, ReactNode } from 'react'
|
||||||
|
|
||||||
export const afterRendered = (
|
type AnyElement = ReactElement<Record<string, any>>
|
||||||
el: ReactElement<any, FC<any>>,
|
|
||||||
hook: (el: ReactNode) => ReactNode,
|
export type ElementVisitor = (
|
||||||
) => after(el, 'type', el => (el instanceof Promise ? el.then(hook) : hook(el)))
|
element: AnyElement,
|
||||||
|
) => Record<string, any> | undefined
|
||||||
|
|
||||||
|
/** Copies a rendered element tree, merging in the props the visitor returns. */
|
||||||
|
export function mapElementTree(
|
||||||
|
node: ReactNode,
|
||||||
|
visit: ElementVisitor,
|
||||||
|
): ReactNode {
|
||||||
|
if (Array.isArray(node)) {
|
||||||
|
let changed = false
|
||||||
|
|
||||||
|
const next = node.map(child => {
|
||||||
|
const mapped = mapElementTree(child, visit)
|
||||||
|
if (mapped !== child) changed = true
|
||||||
|
return mapped
|
||||||
|
})
|
||||||
|
|
||||||
|
return changed ? next : node
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!isValidElement(node)) return node
|
||||||
|
|
||||||
|
const element = node as AnyElement
|
||||||
|
const patch = visit(element)
|
||||||
|
|
||||||
|
// The visitor took over the subtree, so don't descend into it
|
||||||
|
if (patch && 'children' in patch) return cloneElement(element, patch)
|
||||||
|
|
||||||
|
const { children } = element.props
|
||||||
|
const nextChildren =
|
||||||
|
children === undefined ? children : mapElementTree(children, visit)
|
||||||
|
|
||||||
|
// Nothing below matched either, so hand back the very same element
|
||||||
|
if (!patch && nextChildren === children) return node
|
||||||
|
|
||||||
|
return cloneElement(
|
||||||
|
element,
|
||||||
|
nextChildren === children
|
||||||
|
? patch
|
||||||
|
: { ...patch, children: nextChildren },
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
const patchedComponents = new WeakMap<FC<any>, FC<any>>()
|
||||||
|
|
||||||
|
export type RenderPatch<P, C = void> = (
|
||||||
|
rendered: ReactNode,
|
||||||
|
props: P,
|
||||||
|
context: C,
|
||||||
|
) => ReactNode
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Creates and caches a component rendering `Component`, passing its output through `patch`.
|
||||||
|
*
|
||||||
|
* @param Component The component to wrap. Called directly, so its hooks belong to the wrapper.
|
||||||
|
* @param patch The patch to apply to the rendered output. Must not call hooks.
|
||||||
|
* @param useContext Provides whatever the patch needs from hooks. Always called, before the component renders.
|
||||||
|
* @returns The wrapper component.
|
||||||
|
*/
|
||||||
|
export function patchRender<P extends object, C = void>(
|
||||||
|
Component: FC<P>,
|
||||||
|
patch: RenderPatch<P, C>,
|
||||||
|
useContext?: () => C,
|
||||||
|
): FC<P> {
|
||||||
|
const cached = patchedComponents.get(Component)
|
||||||
|
if (cached) return cached
|
||||||
|
|
||||||
|
const Patched: FC<P> = props => {
|
||||||
|
const context = useContext?.() as C
|
||||||
|
const rendered = Component(props)
|
||||||
|
|
||||||
|
return rendered instanceof Promise
|
||||||
|
? rendered.then(node => patch(node, props, context))
|
||||||
|
: patch(rendered, props, context)
|
||||||
|
}
|
||||||
|
|
||||||
|
Object.defineProperty(Patched, 'name', {
|
||||||
|
value: `Patched(${Component.name || 'Component'})`,
|
||||||
|
})
|
||||||
|
|
||||||
|
patchedComponents.set(Component, Patched)
|
||||||
|
|
||||||
|
return Patched
|
||||||
|
}
|
||||||
|
|
|
||||||
1
types/build.d.ts
vendored
1
types/build.d.ts
vendored
|
|
@ -3,6 +3,7 @@
|
||||||
declare global {
|
declare global {
|
||||||
const __DEV__: boolean
|
const __DEV__: boolean
|
||||||
const __BUILD_DISCORD_SERVER_URL__: string
|
const __BUILD_DISCORD_SERVER_URL__: string
|
||||||
|
const __BUILD_DONATE_URL__: string | undefined
|
||||||
const __BUILD_SOURCE_REPOSITORY_URL__: string
|
const __BUILD_SOURCE_REPOSITORY_URL__: string
|
||||||
const __BUILD_DEFAULT_PLUGIN_REPOSITORY_URL__: string | undefined
|
const __BUILD_DEFAULT_PLUGIN_REPOSITORY_URL__: string | undefined
|
||||||
const __BUILD_LICENSE_URL__: string
|
const __BUILD_LICENSE_URL__: string
|
||||||
|
|
|
||||||
Loading…
Add table
Reference in a new issue