Compare commits

..

No commits in common. "main" and "hosting" have entirely different histories.

51 changed files with 1417 additions and 1779 deletions

54
.github/workflows/host-bundle.yml vendored Normal file
View file

@ -0,0 +1,54 @@
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

5
CONTRIBUTORS.md Normal file
View file

@ -0,0 +1,5 @@
# Contributors
Thanks to everyone who has helped make Revenge Next better!
- Rosie (ressurexitcalcator)

View file

@ -120,5 +120,3 @@ 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>

View file

@ -1,5 +1,6 @@
import { lookupModule, lookupModules } from '@revenge-mod/modules/finders' import { lookupModule, lookupModules } from '@revenge-mod/modules/finders'
import { withDependencies, withProps } from '@revenge-mod/modules/finders/filters' import { withDependencies } from '@revenge-mod/modules/finders/filters'
import { getModuleDependencies } from '@revenge-mod/modules/metro/utils'
import { import {
ReactJSXRuntimeModuleId, ReactJSXRuntimeModuleId,
ReactModuleId, ReactModuleId,
@ -9,7 +10,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 { atLeast, last, loose, relative } = withDependencies const { loose } = 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.
@ -26,21 +27,26 @@ export let Design: Design = proxify(
() => { () => {
// ID: 13171 // ID: 13171
// [3909, 4608, 5182, 13172, 2, ...] (141 dependencies) // [3909, 4608, 5182, 13172, 2, ...] (141 dependencies)
const [module] = lookupModule( const [, id] = lookupModule(
withProps('TableRow', 'Button').and(
withDependencies<Design>( withDependencies<Design>(
atLeast(DesignMinimumDependencies, [ loose([
[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 (module) return (Design = module) if (id === undefined) return
const module = __r(id)!
if (module.TableRow && module.Button) return (Design = module)
}, },
{ {
hint: {}, hint: {},
@ -49,6 +55,9 @@ 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([
@ -57,25 +66,17 @@ 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 FormSwitch_ = __r(id)!.FormSwitch const deps = getModuleDependencies(id)!
if (FormSwitch_) return (FormSwitch = FormSwitch_) if (deps.at(-1) !== ImportTrackerModuleId) continue
if (deps.at(-4) === id + 2 && deps.at(-5) === id + 1) {
const FormSwitch_ = __r(id)!.FormSwitch
if (FormSwitch_) return (FormSwitch = FormSwitch_)
}
} }
})! })!

View file

@ -27,7 +27,7 @@ export function onAnyFluxEventDispatched(patch: FluxEventDispatchPatch) {
* @param patch The patch function to apply when the event is dispatched. * @param patch The patch function to apply when the event is dispatched.
* @returns A function that can be used to remove the patch. * @returns A function that can be used to remove the patch.
* *
* @example Blocking the dispatch * @example Blocking the disptach
* ```ts * ```ts
* // Returning falsy values will prevent the event from being dispatched. * // Returning falsy values will prevent the event from being dispatched.
* onFluxEventDispatched('TYPING_START', () => {}) * onFluxEventDispatched('TYPING_START', () => {})

View file

@ -1,4 +1,3 @@
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,
@ -8,8 +7,9 @@ import {
import { import {
createFilterGenerator, createFilterGenerator,
withDependencies, withDependencies,
withName,
} from '@revenge-mod/modules/finders/filters' } from '@revenge-mod/modules/finders/filters'
import { isModuleExportBad } from '@revenge-mod/modules/metro/utils' import { getModuleDependencies } 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,7 +17,10 @@ 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 { FilterScopes } from '../../../modules/src/finders/filters/constants' import {
FilterFlag,
FilterScopes,
} from '../../../modules/src/finders/filters/constants'
import type { import type {
Filter, Filter,
FilterGenerator, FilterGenerator,
@ -63,16 +66,32 @@ 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
]
*/
// The import tracker is checked first, as it is far cheaper than resolving includes const withLeadingFluxStoreDeps = withDependencies(
const withFluxStoreDeps = withDependencies(last([ImportTrackerModuleId])).and( withDependencies.loose([
withDependencies(includes([DispatcherModuleId])), withName('_classCallCheck'),
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,
@ -84,22 +103,27 @@ 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, initialized) => { (_, id, exports) => {
if (initialized) if (exports) return Boolean(exports._dispatchToken)
return ( else {
!isModuleExportBad(exports) && Boolean(exports._dispatchToken) if (!withLeadingFluxStoreDeps(id)) return false
) 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>
Scopes: [typeof FilterScopes.Initialized] RequiresExports: true
Scopes: [
typeof FilterScopes.Uninitialized,
typeof FilterScopes.Initialized,
]
}> }>
> >
@ -107,21 +131,11 @@ 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) =>
if (isModuleExportBad(exports)) return false exports?.getName?.length === 0 && exports.getName() === name,
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})`,
// Uninitialized to make sure cached results resolve immediately FilterFlag.RequiresExports,
FilterScopes.Initialized | FilterScopes.Uninitialized, FilterScopes.Uninitialized | FilterScopes.Initialized,
) as WithStoreName ) as WithStoreName
/// STORE CACHING /// STORE CACHING
@ -141,6 +155,5 @@ if (cache === Uncached)
asap(() => { asap(() => {
const lookup = lookupModules(withStore()) const lookup = lookupModules(withStore())
// Initialize all stores while (lookup.next().done);
for (const _ of lookup);
}) })

View file

@ -1,11 +1,10 @@
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, AnyObject } from '@revenge-mod/utils/types' import type { AnyFunction } from '@revenge-mod/utils/types'
import type { import type {
ComponentProps, ComponentProps,
ComponentType, ComponentType,
FC, FC,
ForwardRefExoticComponent,
MemoExoticComponent, MemoExoticComponent,
ReactElement, ReactElement,
ReactNode, ReactNode,
@ -152,14 +151,10 @@ export namespace DiscordModules {
verboseDangerously(...args: unknown[]): void verboseDangerously(...args: unknown[]): void
verbose(...args: unknown[]): void verbose(...args: unknown[]): void
} }
export namespace Actions { export namespace Actions {
export interface AlertActionCreators { export interface AlertActionCreators {
openAlert( openAlert(key: string, alert: ReactElement): void
key: string,
alert: ReactElement,
onDismiss?: () => unknown,
options?: { dismissable?: boolean },
): void
dismissAlert(key: string): void dismissAlert(key: string): void
dismissAlerts(): void dismissAlerts(): void
// TODO // TODO
@ -185,20 +180,9 @@ 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: { props: ComponentProps<T>,
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 {
@ -279,7 +263,6 @@ export namespace DiscordModules {
| 'tertiary' | 'tertiary'
| 'destructive' | 'destructive'
| 'active' | 'active'
| 'expressive'
| 'primary-overlay' | 'primary-overlay'
| 'secondary-overlay' | 'secondary-overlay'
loading?: boolean loading?: boolean
@ -413,50 +396,10 @@ export namespace DiscordModules {
// export type FormCheckbox = FC // export type FormCheckbox = FC
export interface ActionSheetProps { export interface ActionSheetProps {
scrollable?: boolean children: ReactNode
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 = ForwardRefExoticComponent< export type ActionSheet = FC<ActionSheetProps>
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'> {}

View file

@ -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 { isModuleInitialized } from '../metro/utils' import { FilterFlag } from './filters'
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,14 +72,34 @@ 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 (!isModuleInitialized(id)) { if (exports === undefined) {
if (filter(id, undefined, false)) { if (filter.flags & FilterFlag.RequiresExports) return
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
@ -98,7 +118,13 @@ export function runFilter(
return return
} }
if (filter(id, exports, true)) const filter_ = filter as Filter<{
Result: any
RequiresExports: true
Scopes: any[]
}>
if (filter_(id, exports))
return cacheFilterResultForId( return cacheFilterResultForId(
filter.key, filter.key,
id, id,
@ -107,15 +133,13 @@ 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, true)) if (filter_(id, defaultExport))
return cacheFilterResultForId(filter.key, id, FilterResultFlags.Default) return cacheFilterResultForId(filter.key, id, FilterResultFlags.Default)
} }

View file

@ -1,4 +1,4 @@
import { FilterScopes } from '.' import { FilterFlag } from './constants'
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,48 +6,33 @@ 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>(
directHandler: G, sameHandler: G,
prefilteredHandler: G, dynamicHandler: G,
) => ) =>
((a, b) => { ((a, b) =>
const ea = canFilterUninitialized(a.scopes) a.flags === b.flags
return ea === canFilterUninitialized(b.scopes) ? sameHandler(a, b)
? directHandler(a, b) : a.flags === FilterFlag.RequiresExports
: ea ? dynamicHandler(a, b)
? prefilteredHandler(b, a) : dynamicHandler(b, a)) as G
: 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,
>( >(
directHandler: G, sameHandler: G,
prefilteredHandler: G, dynamicHandler: G,
) => ) =>
(([a, b]) => { (([a, b]) =>
const ea = canFilterUninitialized(a.scopes) a.flags === b.flags
return ea === canFilterUninitialized(b.scopes) ? sameHandler([a, b])
? directHandler([a, b]) : a.flags === FilterFlag.RequiresExports
: ea ? dynamicHandler([a, b])
? prefilteredHandler([b, a]) : dynamicHandler([b, a])) as G
: 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>(
@ -61,49 +46,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 directAnd = createFilterGenerator( const sameFlagsAnd = createFilterGenerator(
([a, b], id, exports, initialized) => ([a, b], id, exports) => a(id, exports) && b(id, exports),
a(id, exports, initialized) && b(id, exports, initialized),
andKeyGenerator, andKeyGenerator,
([a]) => a.flags,
andScopesGenerator, andScopesGenerator,
) as And ) as And
const andPrefilterCache = new WeakMap<FilterBase, Set<Metro.ModuleID>>() const andFallbackCache = new WeakMap<FilterBase, Set<Metro.ModuleID>>()
const prefilteredAnd = createFilterGenerator( const dynamicFlagsAnd = createFilterGenerator(
([filter, prefilter], id, exports, initialized) => { ([filter, fallbackFilter], id, exports) => {
if (initialized) { if (exports) {
if (filter(id, exports, true)) { if (filter(id, exports)) {
// Avoid running the prefilter again if we already know it passed // Avoid running the fallback filter if we already know the first one passed
const cache = andPrefilterCache.get(prefilter) const cache = andFallbackCache.get(fallbackFilter)
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)) || prefilter(id, exports, true) (cache && cache.has(id)) || fallbackFilter(id, exports)
) )
} }
return false return false
} }
const result = prefilter(id, undefined, false) const result = fallbackFilter(id)
if (result) { if (result) {
// Cache prefilter hits to avoid calling the prefilter again // Cache fallback hits to avoid calling the fallback filter again
// Prefilters are usually more expensive // Fallback filters are usually more expensive
let set = andPrefilterCache.get(prefilter) let set = andFallbackCache.get(fallbackFilter)
if (!set) andPrefilterCache.set(prefilter, (set = new Set())) if (!set) andFallbackCache.set(fallbackFilter, (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 only one of the filters can run on uninitialized modules ({@link FilterScopes.Uninitialized}), * If each filter has different flags,
* 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.
* *
@ -126,13 +111,23 @@ const prefilteredAnd = createFilterGenerator(
* ) * )
* ``` * ```
*/ */
export const and = Object.assign(compositeHandler(directAnd, prefilteredAnd), { export const and = Object.assign(
keyFor: compositeArrayHandler(directAnd.keyFor, prefilteredAnd.keyFor), compositeHandler(sameFlagsAnd, dynamicFlagsAnd),
defaultScopesFor: compositeArrayHandler( {
directAnd.defaultScopesFor, keyFor: compositeArrayHandler(
prefilteredAnd.defaultScopesFor, sameFlagsAnd.keyFor,
), dynamicFlagsAnd.keyFor,
}) satisfies And ),
flagsFor: compositeArrayHandler(
sameFlagsAnd.flagsFor,
dynamicFlagsAnd.flagsFor,
),
defaultScopesFor: compositeArrayHandler(
sameFlagsAnd.defaultScopesFor,
dynamicFlagsAnd.defaultScopesFor,
),
},
) satisfies And
export type Or = FilterGenerator< export type Or = FilterGenerator<
<F1 extends FilterBase, F2 extends FilterBase>( <F1 extends FilterBase, F2 extends FilterBase>(
@ -146,21 +141,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 directOr = createFilterGenerator( const sameFlagOr = createFilterGenerator(
([a, b], id, exports, initialized) => ([a, b], id, exports) => a(id, exports) || b(id, exports),
a(id, exports, initialized) || b(id, exports, initialized),
orKeyGenerator, orKeyGenerator,
([a]) => a.flags,
orScopesGenerator, orScopesGenerator,
) as Or ) as Or
const prefilteredOr = createFilterGenerator( const dynamicFlagOr = createFilterGenerator(
([filter, prefilter], id, exports, initialized) => { ([filter, fallbackFilter], id, exports) => {
// TODO(PalmDevs): Potential optimization: Add prefilter cache here too? // TODO(PalmDevs): Potential optimization: Add fallback cache here too?
if (initialized) if (exports) return filter(id, exports) || fallbackFilter(id, exports)
return filter(id, exports, true) || prefilter(id, exports, true) return fallbackFilter(id)
return prefilter(id, undefined, false)
}, },
orKeyGenerator, orKeyGenerator,
FilterFlag.Dynamic,
orScopesGenerator, orScopesGenerator,
) as Or ) as Or
@ -188,10 +183,17 @@ const prefilteredOr = createFilterGenerator(
* ) * )
* ``` * ```
*/ */
export const or = Object.assign(compositeHandler<Or>(directOr, prefilteredOr), { export const or = Object.assign(
keyFor: compositeArrayHandler(directOr.keyFor, prefilteredOr.keyFor), compositeHandler<Or>(sameFlagOr, dynamicFlagOr),
defaultScopesFor: compositeArrayHandler( {
directOr.defaultScopesFor, keyFor: compositeArrayHandler(sameFlagOr.keyFor, dynamicFlagOr.keyFor),
prefilteredOr.defaultScopesFor, flagsFor: compositeArrayHandler(
), sameFlagOr.flagsFor,
}) satisfies Or dynamicFlagOr.flagsFor,
),
defaultScopesFor: compositeArrayHandler(
sameFlagOr.defaultScopesFor,
dynamicFlagOr.defaultScopesFor,
),
},
) satisfies Or

View file

@ -1,3 +1,21 @@
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.
*/ */
@ -5,16 +23,10 @@ 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. Implies the predicate can run without exports. * Include uninitialized modules in the search.
*/ */
Uninitialized: 2, Uninitialized: 2,
/** /**
@ -35,6 +47,10 @@ 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.
*/ */
@ -43,5 +59,6 @@ export interface FilterInfo {
export interface DefaultFilterInfo extends FilterInfo { export interface DefaultFilterInfo extends FilterInfo {
Result: any Result: any
RequiresExports: boolean
Scopes: FilterScope[] Scopes: FilterScope[]
} }

View file

@ -4,7 +4,7 @@ import {
getModuleDependencies, getModuleDependencies,
} from '../../metro/utils' } from '../../metro/utils'
import { runFilter } from '../_internal' import { runFilter } from '../_internal'
import { FilterScopes } from './constants' import { FilterFlag, 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,18 +18,8 @@ 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
} }
/** /**
@ -40,9 +30,6 @@ 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
@ -88,30 +75,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__
? (Object.assign((deps: ComparableDependencyMap) => { ? (((deps: ComparableDependencyMap) => {
DEBUG_validateWithDependenciesFilter(deps) // Warn about using undefined in deps, which is likely a mistake
for (let i = 0; i < deps.length; i++) {
if (deps[i] === undefined)
DEBUG_warnBadWithDependenciesFilter(deps, i)
}
return withDependencies_(deps) return withDependencies_(deps)
}, withDependencies_) as 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,
@ -120,11 +107,6 @@ 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
} }
/** /**
@ -141,96 +123,6 @@ 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
@ -300,70 +192,6 @@ 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,
@ -372,29 +200,7 @@ function depCompare(
): boolean { ): boolean {
const lenA = a.length const lenA = a.length
const lenB = b.length const lenB = b.length
if (b.l ? lenA < lenB : lenA !== lenB) return false
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
} 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]
@ -405,71 +211,43 @@ function depCompare(
// Skip dynamic // Skip dynamic
if (compare == null) continue if (compare == null) continue
const id = a[start + i] const id = a[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
if (!depMatches(compare, id, root, parent)) return false switch (typeof compare) {
} case 'function': {
const filter = compare
if (!runFilter(filter, id, getInitializedModuleExports(id)))
return false
return true continue
} }
case 'object': {
const nested = compare as ComparableDependencyMap
function depIncludesCompare( // relative.withDependencies?
a: Metro.ModuleID[], if (nested.r && !depShallowCompare(nested.r, id, root, parent))
b: ComparableDependencyMap, return false
root: Metro.ModuleID,
parent: Metro.ModuleID,
): boolean {
const lenA = a.length
for (let i = 0; i < b.length; i++) { if (depCompare(getModuleDependencies(id)!, nested, root, id))
const compare = b[i] continue
// Dynamic dependencies match anything, so they carry no meaning when unordered return false
if (compare == null) continue }
default: {
let found = false if (depShallowCompare(compare as number, id, root, parent))
continue
for (let j = 0; j < lenA; j++) { return false
const id = a[j]
if (id !== null && depMatches(compare, id, root, parent)) {
found = true
break
} }
} }
if (!found) return false
} }
return true 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': {
const nested = compare
// relative.withDependencies?
if (nested.r && !depShallowCompare(nested.r, id, root, parent))
return false
return depCompare(getModuleDependencies(id)!, nested, root, id)
}
default:
return depShallowCompare(compare, id, root, parent)
}
}
function depShallowCompare( function depShallowCompare(
compare: number, compare: number,
id: Metro.ModuleID, id: Metro.ModuleID,
@ -513,7 +291,11 @@ 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
key += `${depGenModifierKey(nested)}[${depGenFilterKey(nested)}],` if (nested.l) key += '#'
// relative.withDependencies?
if (nested.r) key += `${depGenRelativeKeyPart(nested.r)}:`
key += `[${depGenFilterKey(nested)}],`
break break
} }
default: { default: {
@ -529,20 +311,6 @@ 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 ? '~' : '^'

View file

@ -1,5 +1,5 @@
import { isModuleExportBad } from '@revenge-mod/modules/metro/utils' import { isModuleExportBad } from '@revenge-mod/modules/metro/utils'
import { FilterScopes } from './constants' import { FilterFlag, FilterScopes } from './constants'
import { createFilterGenerator } from './utils' import { createFilterGenerator } from './utils'
import type { Filter, FilterGenerator } from './utils' import type { Filter, FilterGenerator } from './utils'
@ -8,6 +8,7 @@ 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]
}> }>
@ -40,6 +41,7 @@ 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
@ -65,6 +67,7 @@ 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
@ -94,6 +97,7 @@ 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
@ -134,6 +138,7 @@ 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

View file

@ -1,32 +1,40 @@
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 FilterInfoOf<F> = F extends Filter<infer I> ? I : FilterInfo export type FilterRequiresExports<F> =
F extends Filter<infer I> ? I['RequiresExports'] : never
declare const FilterInfoBrand: unique symbol export type FilterInfoOf<F> = F extends Filter<infer I> ? I : FilterInfo
export interface FilterBase<Info extends FilterInfo = DefaultFilterInfo> { export interface FilterBase<Info extends FilterInfo = DefaultFilterInfo> {
( (
id: Metro.ModuleID, ...args: If<
exports?: Metro.ModuleExports, Info['RequiresExports'],
initialized?: boolean, [id: Metro.ModuleID, exports: Metro.ModuleExports],
[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> =
@ -34,11 +42,13 @@ 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']]
} }
@ -81,12 +91,25 @@ 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: S ...scopes: If<
): Filter<Info & { Scopes: S }> Info['RequiresExports'],
[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
} }
@ -106,8 +129,7 @@ const Helpers: FilterHelpers = Object.setPrototypeOf(
const newFilter = (( const newFilter = ((
id: Metro.ModuleID, id: Metro.ModuleID,
exports?: Metro.ModuleExports, exports?: Metro.ModuleExports,
initialized?: boolean, ) => this(id, exports)) as typeof this
) => this(id, exports, initialized)) as typeof this
Object.assign(newFilter, this) Object.assign(newFilter, this)
@ -117,9 +139,7 @@ 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 as Filter< return filter
FilterInfoOf<typeof filter> & { Scopes: typeof scopes }
>
}, },
} satisfies FilterHelpers, } satisfies FilterHelpers,
Function.prototype, Function.prototype,
@ -130,14 +150,15 @@ 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, initialized) => { * ([arg1, arg2], id, exports) => {
* // WARNING: exports can be a Proxy, nullish, or a primitive, so be careful when using it * // WARNING: exports can be Proxy or nullish, so be careful when using it
* // filter logic * // filter logic
* return true * return true
* }, * },
@ -152,15 +173,16 @@ 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>
@ -168,14 +190,16 @@ 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'
@ -183,10 +207,9 @@ 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, initialized) return filter(args, id, exports)
} catch (e) { } catch (e) {
DEBUG_warnFilterThrown(id, keyFor(args), e) DEBUG_warnFilterThrown(id, keyFor(args), e)
return false return false
@ -194,12 +217,17 @@ 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

View file

@ -183,19 +183,11 @@ export function* lookupModules(filter: Filter, options?: LookupModulesOptions) {
} }
if (includeUninit) if (includeUninit)
// Must snapshot because partial matches can cause modules that haven't been looped over to be initialized for (const id of mUninitialized) {
// 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( const flag = runFilter(filter, id, undefined, options)
filter,
id,
// Could be initialized by a previous partial match
getInitializedModuleExports(id),
options,
)
if (flag) { if (flag) {
notFound = false notFound = false
@ -316,15 +308,8 @@ export function lookupModule(filter: Filter, options?: LookupModulesOptions) {
} }
if (includeUninit) if (includeUninit)
// See equivalent code in `lookupModules` for why this is snapshotted for (const id of mUninitialized) {
for (const id of [...mUninitialized]) { const flag = runFilter(filter, id, undefined, options)
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)

View file

@ -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, mList } from './patches' import { loadModuleFromSegment, mInitializingId, 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, unknown][] = [] export const mErrorChain: Metro.ModuleID[] = []
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 ${moduleId} failed to initialize:\n\n${getErrorStack(e)}` const msg = `Module ${mInitializingId} 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(moduleId) // cacheBlacklistedModule(mInitializingId)
// 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, e]) mErrorChain.push(moduleId)
return (moduleObject.exports = {}) return (moduleObject.exports = {})
} }

View file

@ -50,11 +50,7 @@ export function isModuleExportBad(
return ( return (
// Nullish? // Nullish?
exp == null || exp == null ||
// Does it have some non-existent key? (proxies) // Does it have some non-existent key? (Turbo modules)
(typeof exp === 'object' && (typeof exp === 'object' && CATCH_ALL_TEST in exp)
// Implementing Reflect.get
(exp[CATCH_ALL_TEST] !== undefined ||
// Implementing Reflect.ownKeys
CATCH_ALL_TEST in exp))
) )
} }

View file

@ -21,7 +21,6 @@ import {
ApiDependencyId, ApiDependencyId,
computePendingNodes, computePendingNodes,
DiscordDependencyId, DiscordDependencyId,
isReservedDependency,
pLeafOrSingleNodes, pLeafOrSingleNodes,
pListOrdered, pListOrdered,
pPending, pPending,
@ -348,12 +347,9 @@ 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
manifest.dependencies ??= {}
if (!isReservedDependency(manifest.id)) { manifest.dependencies[ApiDependencyId] ??= { version: '*' }
manifest.dependencies ??= {} manifest.dependencies[DiscordDependencyId] ??= { version: '*' }
manifest.dependencies[ApiDependencyId] ??= { version: '*' }
manifest.dependencies[DiscordDependencyId] ??= { version: '*' }
}
return register(manifest as PluginManifest, options, defflags, iflags) return register(manifest as PluginManifest, options, defflags, iflags)
} }

View file

@ -160,6 +160,8 @@ 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
} }
/** /**
@ -227,7 +229,7 @@ export async function listAllUpdates(): Promise<{
.map(repo => .map(repo =>
listUpdates(repo.url).then( listUpdates(repo.url).then(
result => { result => {
updates.push(...result) updates.push(...result.map(u => ({ ...u, repo: repo.url })))
}, },
error => { error => {
errors.push({ url: repo.url, error }) errors.push({ url: repo.url, error })

View file

@ -7,50 +7,42 @@ 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 { FilterScopes } from '../../modules/src/finders/filters/constants' import {
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'
const { last } = withDependencies // [React, ReactJsxRuntime, (Tokens), (BaseIconImage), (Asset), ImportTracker]
// Asset = relativeDep(1)
const IconComponentFilter = [
ReactModuleId,
ReactJSXRuntimeModuleId,
TokensModuleId,
null,
null,
ImportTrackerModuleId,
]
const depsFilters: Record<string, Filter> = {} // Generated IconComponents that have multiple assets, there is no specific length, so the filter has to be generated on-the-fly.
// [React, ReactNative, ReactJsxRuntime, (Tokens), (BaseIconImage), (...Assets), ImportTracker]
/** // ...Assets = relativeDep(1..n)
* Builds a dependency filter for an icon component. const MultiIconComponentFilterBase = [
* ReactModuleId,
* `[React, (ReactNative), ReactJsxRuntime, Tokens, (BaseIconImage), (...Assets), ImportTracker]` ReactNativeModuleId,
* ReactJSXRuntimeModuleId,
* `ReactNative` is only present on components with multiple assets, and it sits right after `React`, TokensModuleId,
* so everything from `ReactJsxRuntime` onwards is at a fixed distance from the end on both shapes. null,
* ]
* @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>(
@ -58,6 +50,7 @@ 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,
@ -93,13 +86,47 @@ export type WithGeneratedIconComponent = FilterGenerator<
export const withGeneratedIconComponent = createFilterGenerator< export const withGeneratedIconComponent = createFilterGenerator<
Parameters<WithGeneratedIconComponent> Parameters<WithGeneratedIconComponent>
>( >(
(names, id, exports, initialized) => { (names, id, exports) => {
// Icon components always export their component, so unusable exports can never match if (typeof exports === 'object' && isModuleExportBad(exports)) {
if (initialized && isModuleExportBad(exports)) return false if (typeof exports[names[0]] === 'function')
return Object.keys(exports).length === 1
} else {
let filter = IconComponentFilter
return depsFilterFor(names)?.(id, exports, initialized) ?? false if (names.length > 1) {
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 Normal file

Binary file not shown.

View file

@ -79,8 +79,7 @@ export default async function build(dev = Dev, log = true) {
.trim(), .trim(),
), ),
__DEV__: String(dev), __DEV__: String(dev),
__BUILD_DEFAULT_PLUGIN_REPOSITORY_URL__: 'undefined', // __BUILD_DEFAULT_PLUGIN_REPOSITORY_URL__: ...,
__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),

Binary file not shown.

Before

Width:  |  Height:  |  Size: 14 KiB

View file

@ -1,50 +0,0 @@
import { Design } from '@revenge-mod/discord/design'
import { RootNavigationRef } from '@revenge-mod/discord/modules/main_tabs_v2'
import { getBridgeInfo } from '@revenge-mod/modules/native'
import { RouteNames } from '~plugins/start/settings.plugins/constants'
export function RecoveryModal() {
const navigation = RootNavigationRef.getRootNavigationRef()
return (
<Design.AlertModal
title="Recovery Mode"
content={
"You are now running with default plugins. Additional plugins can't be started in Recovery Mode.\n\nDisable or uninstall plugins that might be causing issues, then reload the app to exit Recovery Mode."
}
actions={
<>
<Design.AlertActionButton
variant="primary"
text="View plugins"
onPress={() => {
// React Navigation will bubble our requests to the main navigator if we're already in settings
navigation.navigate('settings', {
screen: RouteNames.RevengePlugins,
})
}}
/>
<Design.AlertActionButton
variant="secondary"
text="Got it"
/>
</>
}
/>
)
}
export function LoaderOutdatedModal() {
const info = getBridgeInfo()
if (!info) throw new Error('Failed to get native bridge info')
return (
<Design.AlertModal
title="Loader Outdated"
content={`Your loader is outdated. Update to the latest version to receive fixes and ensure compatibility with all plugins.\n\nYou're currently running ${info.name} v${info.version}`}
actions={
<Design.AlertActionButton variant="primary" text="Got it" />
}
/>
)
}

View file

@ -1,190 +0,0 @@
import defer * as Actions from '@revenge-mod/discord/actions'
import { onFluxEventDispatched } from '@revenge-mod/discord/flux'
import { waitForModules } from '@revenge-mod/modules/finders'
import { withName } from '@revenge-mod/modules/finders/filters'
import { callNativeMethod } from '@revenge-mod/modules/native'
import { instead } from '@revenge-mod/patcher'
import {
InternalPluginFlags,
isDefaultsOnlyBoot,
PluginFlags,
registerInternalPlugin,
} from '@revenge-mod/plugins/_'
import { onRunApplicationFinished } from '@revenge-mod/react/native'
import { asap } from '@revenge-mod/utils/callback'
import { getErrorStack } from '@revenge-mod/utils/error'
import { AppState } from 'react-native'
import { FullVersion } from '~constants'
import { cache as assetsCache } from '../../../../lib/assets/src/caches'
import { cache as modulesCache } from '../../../../lib/modules/src/caches'
import { mErrorChain } from '../../../../lib/modules/src/metro/runtime'
import pluginSettings from '../../start/settings'
import defer * as Alerts from './components/alerts'
import defer * as ErrorBoundaryScreen from './components/ErrorBoundaryScreen'
import type { Component, ReactNode } from 'react'
registerInternalPlugin(
{
id: 'revenge.recovery',
name: 'Recovery',
description:
'Handles errors and provides troubleshooting options for Revenge.',
author: 'Revenge',
icon: 'ShieldIcon',
dependencies: { [pluginSettings]: {} },
},
{
preInit({ cleanup }) {
cleanup(freezeDetectionService())
},
start({ cleanup }) {
// @as-require
import('./settings')
cleanup(errorBoundaryService())
asap(() => {
if (isDefaultsOnlyBoot) {
Actions.AlertActionCreators.openAlert(
'revenge-recovery',
<Alerts.RecoveryModal />,
)
}
if (assetsCache.outdated || modulesCache.outdated) {
Actions.AlertActionCreators.openAlert(
'revenge-loader-outdated',
<Alerts.LoaderOutdatedModal />,
)
}
})
},
},
PluginFlags.Enabled,
InternalPluginFlags.Internal | InternalPluginFlags.Essential,
)
const FreezeDetectionTimeout = 5000
function freezeDetectionService() {
let currentId: number | undefined
let cleared = false
let sub: { remove(): void } | undefined
let unsubFlux: (() => void) | undefined
const setTimer = () => {
onRunApplicationFinished(() => {
if (cleared) return
if (currentId !== undefined) clearTimeout(currentId)
currentId = setTimeout(() => {
currentId = undefined
if (AppState.currentState !== 'active') return
try {
let text =
'App was unable to start. This is likely caused by a plugin.\n\n' +
'You can launch Recovery Mode in the Recovery menu.'
if (mErrorChain.length > 0) {
text += `\n\nErroring modules chain: ${mErrorChain.map(([id]) => id).join(', ')}`
text += `\n\nStacks:\n\n${mErrorChain.map(([id, stack]) => `[${id}] ${getErrorStack(stack)}`).join('\n\n')}`
} else {
text += '\n\nNo erroring modules were detected.'
}
callNativeMethod('revenge.alertError', [text, FullVersion])
} catch (e) {
console.error(
'Failed to call native method "revenge.alertError":',
e,
)
}
}, FreezeDetectionTimeout)
})
}
const clear = <T,>(e?: T) => {
cleared = true
if (currentId !== undefined) {
clearTimeout(currentId)
currentId = undefined
}
sub?.remove()
unsubFlux?.()
return e
}
unsubFlux = onFluxEventDispatched('APP_STATE_UPDATE', clear)
// clear() may have run before unsubStore/unsubFlux was assigned, so run it again to unsub
if (cleared) {
clear()
return clear
}
sub = AppState.addEventListener('change', state => {
if (state === 'active') setTimer()
else if (currentId !== undefined) {
clearTimeout(currentId)
currentId = undefined
}
})
if (AppState.currentState === 'active') setTimer()
return clear
}
function errorBoundaryService() {
const unsub = waitForModules(
withName<typeof DiscordErrorBoundary>('ErrorBoundary'),
exports => {
unsub()
instead(
exports.prototype,
'render',
function (this: DiscordErrorBoundary) {
if (this.state.error)
return (
<ErrorBoundaryScreen.default
error={this.state.error}
reload={this.handleReload.bind(this)}
rerender={() => {
this.setState({
error: null,
info: null,
})
}}
/>
)
return this.props.children
},
)
},
{
cached: true,
},
)
return unsub
}
declare class DiscordErrorBoundary extends Component<
{ children: ReactNode },
{
error: (Error & { componentStack?: string }) | unknown | null
info: { componentStack?: string } | null
}
> {
// render() is always called with `this` as the instance of DiscordErrorBoundary
render(this: DiscordErrorBoundary): ReactNode
discordErrorsSet: boolean
handleReload(): void
}

View file

@ -1,13 +0,0 @@
import {
onSettingsModulesLoaded,
registerSettingsItem,
} from '@revenge-mod/discord/modules/settings'
import { Setting } from '~plugins/start/settings/constants'
import RevengeEnterRecoveryModeSetting from './definitions/RevengeEnterRecoveryModeSetting'
onSettingsModulesLoaded(() => {
registerSettingsItem(
Setting.RevengeEnterRecoveryMode,
RevengeEnterRecoveryModeSetting,
)
})

View file

@ -0,0 +1,244 @@
import { AlertActionCreators } from '@revenge-mod/discord/actions'
import { Design } from '@revenge-mod/discord/design'
import { getStore, onFluxEventDispatched } from '@revenge-mod/discord/flux'
import { RootNavigationRef } from '@revenge-mod/discord/modules/main_tabs_v2'
import {
onSettingsModulesLoaded,
registerSettingsItem,
} from '@revenge-mod/discord/modules/settings'
import { waitForModules } from '@revenge-mod/modules/finders'
import { withName } from '@revenge-mod/modules/finders/filters'
import { callNativeMethod, getBridgeInfo } from '@revenge-mod/modules/native'
import { instead } from '@revenge-mod/patcher'
import {
InternalPluginFlags,
isDefaultsOnlyBoot,
PluginFlags,
registerInternalPlugin,
} from '@revenge-mod/plugins/_'
import { asap } from '@revenge-mod/utils/callback'
import { AppState } from 'react-native'
import { FullVersion } from '~constants'
import { cache as assetsCache } from '../../../../lib/assets/src/caches'
import { cache as modulesCache } from '../../../../lib/modules/src/caches'
import { mErrorChain } from '../../../../lib/modules/src/metro/runtime'
import pluginSettings from '../settings'
import { Setting } from '../settings/constants'
import { RouteNames } from '../settings.plugins/constants'
import ErrorBoundaryScreen from './components/ErrorBoundaryScreen'
import RevengeEnterRecoveryModeSetting from './definitions/RevengeEnterRecoveryModeSetting'
import type { Component, ReactNode } from 'react'
registerInternalPlugin(
{
id: 'revenge.recovery',
name: 'Recovery',
description:
'Handles errors and provides troubleshooting options for Revenge.',
author: 'Revenge',
icon: 'ShieldIcon',
dependencies: { [pluginSettings]: {} },
},
{
start({ cleanup }) {
onSettingsModulesLoaded(() => {
registerSettingsItem(
Setting.RevengeEnterRecoveryMode,
RevengeEnterRecoveryModeSetting,
)
})
cleanup(errorBoundaryService(), freezeDetectionService())
if (isDefaultsOnlyBoot) {
asap(() => {
AlertActionCreators.openAlert(
'revenge-recovery',
<RecoveryModal />,
)
})
}
if (assetsCache.outdated || modulesCache.outdated) {
asap(() => {
AlertActionCreators.openAlert(
'revenge-loader-outdated',
<LoaderOutdatedModal />,
)
})
}
},
},
PluginFlags.Enabled,
InternalPluginFlags.Internal | InternalPluginFlags.Essential,
)
const FreezeDetectionTimeout = 5000
function freezeDetectionService() {
let currentId: number | undefined
let cleared = false
let sub: { remove(): void } | undefined
let unsubStore: (() => void) | undefined
let unsubFlux: (() => void) | undefined
const setTimer = () => {
if (cleared) return
if (currentId !== undefined) clearTimeout(currentId)
currentId = setTimeout(() => {
currentId = undefined
if (AppState.currentState !== 'active') return
try {
callNativeMethod('revenge.alertError', [
`App was unable to start. This is likely caused by a plugin.\n\nYou can launch Recovery Mode in the Recovery menu.\n\nErroring modules chain: ${mErrorChain.join(', ') || 'None'}`,
FullVersion,
])
} catch (e) {
console.error(
'Failed to call native method "revenge.alertError":',
e,
)
}
}, FreezeDetectionTimeout)
}
const clear = <T,>(e?: T) => {
cleared = true
if (currentId !== undefined) {
clearTimeout(currentId)
currentId = undefined
}
sub?.remove()
unsubStore?.()
unsubFlux?.()
return e
}
// If the store initializes, the app is probably working fine, so we can cancel the timer
unsubStore = getStore('AppStateStore', clear)
// clear() may have run before unsubStore was assigned, so run it again to unsub
if (cleared) {
clear()
return clear
}
unsubFlux = onFluxEventDispatched('APP_STATE_UPDATE', clear)
if (cleared) {
clear()
return clear
}
sub = AppState.addEventListener('change', state => {
if (state === 'active') setTimer()
else if (currentId !== undefined) {
clearTimeout(currentId)
currentId = undefined
}
})
if (AppState.currentState === 'active') setTimer()
return clear
}
function errorBoundaryService() {
const unsub = waitForModules(
withName<typeof DiscordErrorBoundary>('ErrorBoundary'),
exports => {
unsub()
instead(
exports.prototype,
'render',
function (this: DiscordErrorBoundary) {
if (this.state.error)
return (
<ErrorBoundaryScreen
error={this.state.error}
reload={this.handleReload.bind(this)}
rerender={() => {
this.setState({
error: null,
info: null,
})
}}
/>
)
return this.props.children
},
)
},
{
cached: true,
},
)
return unsub
}
declare class DiscordErrorBoundary extends Component<
{ children: ReactNode },
{
error: (Error & { componentStack?: string }) | unknown | null
info: { componentStack?: string } | null
}
> {
// render() is always called with `this` as the instance of DiscordErrorBoundary
render(this: DiscordErrorBoundary): ReactNode
discordErrorsSet: boolean
handleReload(): void
}
function RecoveryModal() {
const navigation = RootNavigationRef.getRootNavigationRef()
return (
<Design.AlertModal
title="Recovery Mode"
content={
"You are now running with default plugins. Additional plugins can't be started in Recovery Mode.\n\nDisable or uninstall plugins that might be causing issues, then reload the app to exit Recovery Mode."
}
actions={
<>
<Design.AlertActionButton
variant="primary"
text="View plugins"
onPress={() => {
// React Navigation will bubble our requests to the main navigator if we're already in settings
navigation.navigate('settings', {
screen: RouteNames.RevengePlugins,
})
}}
/>
<Design.AlertActionButton
variant="secondary"
text="Got it"
/>
</>
}
/>
)
}
function LoaderOutdatedModal() {
const info = getBridgeInfo()
if (!info) throw new Error('Failed to get native bridge info')
return (
<Design.AlertModal
title="Loader Outdated"
content={`Your loader is outdated. Update to the latest version to receive fixes and ensure compatibility with all plugins.\n\nYou're currently running ${info.name} v${info.version}`}
actions={
<Design.AlertActionButton variant="primary" text="Got it" />
}
/>
)
}

View file

@ -32,6 +32,11 @@ 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
@ -42,6 +47,9 @@ export default function BrowseFilterAndSortActionSheet({
repos, repos,
checked, checked,
setChecked, setChecked,
channels,
channel,
setChannel,
sort, sort,
setSort, setSort,
reverse, reverse,
@ -76,6 +84,19 @@ 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}

View file

@ -1,19 +1,10 @@
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 { const { ActionSheet, Button, Stack, TableRowGroup } = Design
ActionSheet,
Button,
Stack,
TableRowGroup,
TableRadioGroup,
TableRadioRow,
} = Design
const DownloadIcon = getAssetIdByName('DownloadIcon', 'png')! const DownloadIcon = getAssetIdByName('DownloadIcon', 'png')!
@ -24,11 +15,9 @@ 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: (channel?: string, version?: string) => void onInstall: () => void
sheetKey: string sheetKey: string
} }
@ -42,26 +31,17 @@ 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={selectedVersion} version={version}
description={description} description={description}
icon={icon} icon={icon}
actions={ actions={
@ -73,26 +53,11 @@ export default function BrowsePluginActionSheet({
ActionSheetActionCreators.hideActionSheet( ActionSheetActionCreators.hideActionSheet(
sheetKey, sheetKey,
) )
onInstall(selectedChannel, selectedVersion) onInstall()
}} }}
/> />
} }
/> />
{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 />

View file

@ -21,9 +21,12 @@ import {
} from '../utils/sheets' } from '../utils/sheets'
import { PluginIcon } from './PluginIcon' import { PluginIcon } from './PluginIcon'
import { usePluginEnabled } from './PluginStateProvider' import { usePluginEnabled } from './PluginStateProvider'
import { PluginTooltip, usePluginTooltip } from './TooltipProvider' import {
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
@ -148,12 +151,14 @@ export const InstalledPluginCard = memo(function InstalledPluginCard({
const toggleDisabled = essential || pendingUpdate const toggleDisabled = essential || pendingUpdate
const [settingsRef, showEnableTooltip] = usePluginTooltip( const enableTooltip = useEnablePluginTooltip()
PluginTooltip.Enable, const essentialTooltip = useEssentialPluginTooltip()
)
const [switchRef, showToggleTooltip] = usePluginTooltip( const settingsRef = useClickOutsideTooltip(useEnablePluginTooltip, () => {})
essential ? PluginTooltip.Essential : PluginTooltip.PendingUpdate,
const switchRef = useClickOutsideTooltip(
useEssentialPluginTooltip,
() => {},
) )
return ( return (
@ -176,7 +181,12 @@ export const InstalledPluginCard = memo(function InstalledPluginCard({
{plugin.SettingsComponent && ( {plugin.SettingsComponent && (
<Pressable <Pressable
onPress={() => { onPress={() => {
if (!startable) showEnableTooltip() if (!startable)
requestAnimationFrame(() => {
enableTooltip.targetRef.current =
settingsRef.current
enableTooltip.setVisible(true)
})
}} }}
> >
<IconButton <IconButton
@ -193,7 +203,12 @@ export const InstalledPluginCard = memo(function InstalledPluginCard({
)} )}
<Pressable <Pressable
onPress={() => { onPress={() => {
if (toggleDisabled) showToggleTooltip() if (toggleDisabled)
requestAnimationFrame(() => {
essentialTooltip.targetRef.current =
switchRef.current
essentialTooltip.setVisible(true)
})
}} }}
ref={switchRef} ref={switchRef}
> >
@ -247,8 +262,6 @@ export const BrowsePluginCard = memo(function BrowsePluginCard({
author, author,
icon, icon,
id, id,
listing,
channel,
repositoryText, repositoryText,
onInstall, onInstall,
}: { }: {
@ -258,10 +271,8 @@ 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: (channel?: string, version?: string) => void onInstall: () => void
}) { }) {
return ( return (
<PluginCard <PluginCard
@ -284,8 +295,6 @@ export const BrowsePluginCard = memo(function BrowsePluginCard({
version, version,
icon, icon,
id, id,
listing,
channel,
repositoryText, repositoryText,
onInstall, onInstall,
}) })

View file

@ -8,10 +8,8 @@ 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()
@ -28,11 +26,7 @@ export function PluginIcon({
? (getAssetIdByName(icon) ?? PuzzlePieceIcon) ? (getAssetIdByName(icon) ?? PuzzlePieceIcon)
: PuzzlePieceIcon : PuzzlePieceIcon
} }
style={[ style={[!dataUrl && styles.icon, { width: size, height: size }]}
!dataUrl && styles.icon,
danger && styles.danger,
{ width: size, height: size },
]}
/> />
) )
} }
@ -41,7 +35,4 @@ 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,
},
}) })

View file

@ -9,7 +9,7 @@ import {
PLUGIN_CARD_HALF_GUTTER, PLUGIN_CARD_HALF_GUTTER,
PluginCard, PluginCard,
} from './PluginCard' } from './PluginCard'
import { useHidePluginTooltips } from './TooltipProvider' import { useResetTooltips } 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 hideTooltips = useHidePluginTooltips() const resetTooltips = useResetTooltips()
return ( return (
<FlashList <FlashList
style={gutterCompensation} style={gutterCompensation}
onContentSizeChange={onContentSizeChange} onContentSizeChange={onContentSizeChange}
data={plugins} data={plugins}
onScrollBeginDrag={hideTooltips} onScrollBeginDrag={resetTooltips}
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 hideTooltips = useHidePluginTooltips() const resetTooltips = useResetTooltips()
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={hideTooltips} onScrollBeginDrag={resetTooltips}
fadingEdgeLength={16} fadingEdgeLength={16}
keyExtractor={([plugin]) => plugin.manifest.id} keyExtractor={([plugin]) => plugin.manifest.id}
numColumns={numColumns} numColumns={numColumns}
@ -121,14 +121,10 @@ export function BrowsePluginMasonryFlashList({
onInstall, onInstall,
}: { }: {
entries: BrowseEntry[] entries: BrowseEntry[]
onInstall: ( onInstall: (entry: BrowseEntry) => void
entry: BrowseEntry,
channel?: string,
version?: string,
) => void
}) { }) {
const numColumns = useNumColumns() const numColumns = useNumColumns()
const hideTooltips = useHidePluginTooltips() const resetTooltips = useResetTooltips()
return ( return (
<FlashList <FlashList
@ -136,7 +132,7 @@ export function BrowsePluginMasonryFlashList({
style={gutterCompensation} style={gutterCompensation}
contentContainerStyle={{ paddingBottom: 16 }} contentContainerStyle={{ paddingBottom: 16 }}
data={entries} data={entries}
onScrollBeginDrag={hideTooltips} onScrollBeginDrag={resetTooltips}
fadingEdgeLength={16} fadingEdgeLength={16}
keyExtractor={entry => entry.key} keyExtractor={entry => entry.key}
numColumns={numColumns} numColumns={numColumns}
@ -156,12 +152,8 @@ 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={(channel, version) => onInstall={() => onInstall(entry)}
onInstall(entry, channel, version)
}
/> />
) )
}} }}

View file

@ -0,0 +1,33 @@
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>
)
}

View file

@ -19,15 +19,17 @@ import {
isPluginPendingUpdate, isPluginPendingUpdate,
isPluginStartable, isPluginStartable,
PluginFlags, PluginFlags,
pList,
runPluginLate, runPluginLate,
stopPlugin, stopPlugin,
} from '@revenge-mod/plugins/_' } from '@revenge-mod/plugins/_'
import { import {
listRepoPlugins, installFromRepo,
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'
@ -39,30 +41,22 @@ import {
showPluginClearDataConfirmation, showPluginClearDataConfirmation,
showPluginUninstallConfirmation, showPluginUninstallConfirmation,
} from '../utils/alerts' } from '../utils/alerts'
import { messageOf, runInstallFlow, showErrorToast } from '../utils/repos' import { confirmPlan, messageOf, showErrorToast } from '../utils/repos'
import { InstalledPluginSwitch, PluginInfo } from './PluginCard' import { InstalledPluginSwitch, PluginInfo } from './PluginCard'
import { usePluginEnabled, usePluginStatus } from './PluginStateProvider' import { usePluginEnabled, usePluginStatus } from './PluginStateProvider'
import PluginTooltipsProvider, { import {
PluginTooltip, EnablePluginTooltipProvider,
usePluginTooltip, useClickOutsideTooltip,
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 { const { ActionSheet, IconButton, TableRowGroup, TableRow, Stack } = Design
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')!
@ -74,44 +68,23 @@ 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 (
<Stack spacing={24} style={{ paddingTop: 8 }}> <ActionSheet>
<PluginInfo <Stack spacing={24} style={{ paddingTop: 8 }}>
name={name} <PluginInfo
author={author} name={name}
version={formatVersion(version)} author={author}
description={description} version={formatVersion(version)}
icon={icon} description={description}
actions={ icon={icon}
!essential && ( actions={
<Pressable !essential && (
onPress={() => {
if (pendingUpdate) showPendingUpdateTooltip()
}}
ref={switchRef}
>
<InstalledPluginSwitch <InstalledPluginSwitch
enabled={enabled} enabled={enabled}
plugin={plugin} plugin={plugin}
@ -120,19 +93,25 @@ function PluginOptions({ plugin, sheetKey }: PluginOptionsActionSheetProps) {
)} )}
toggleDisabled={pendingUpdate} toggleDisabled={pendingUpdate}
/> />
</Pressable> )
) }
} />
/> <ClickOutsideProvider>
<PluginActions <EnablePluginTooltipProvider>
plugin={plugin} <PluginActions
closeSheet={() => { plugin={plugin}
ActionSheetActionCreators.hideActionSheet(sheetKey) closeSheet={() => {
}} ActionSheetActionCreators.hideActionSheet(
/> sheetKey,
<StatusSection plugin={plugin} /> )
<AdvancedSection plugin={plugin} sheetKey={sheetKey} /> }}
</Stack> />
</EnablePluginTooltipProvider>
</ClickOutsideProvider>
<StatusSection plugin={plugin} />
<AdvancedSection plugin={plugin} />
</Stack>
</ActionSheet>
) )
} }
@ -171,94 +150,86 @@ function StatusSection({ plugin }: { plugin: AnyPlugin }) {
) )
} }
function ChannelSection({ function UpdateRow({ plugin }: { plugin: AnyPlugin }) {
plugin, const [busy, setBusy] = useState(false)
sheetKey, const [checked, setChecked] = useState(false)
}: { const [update, setUpdate] = useState<RepoUpdate | null>(null)
plugin: AnyPlugin
sheetKey: string
}) {
const meta = getInternalPluginMeta(plugin) const meta = getInternalPluginMeta(plugin)
const source = meta.source const repoUrl = meta.source?.repo
if (isPluginInternal(meta) || !source?.repo) return null if (isPluginInternal(meta) || !repoUrl) return null
const [listing, setListing] = useState<RepoPluginListing | null>(null) const check = async () => {
const [resetKey, setResetKey] = useState(0) setBusy(true)
try {
useEffect(() => { await refreshRepo(repoUrl)
let cancelled = false const updates = await listUpdates(repoUrl)
;(async () => { const found =
try { updates.find(u => u.id === plugin.manifest.id) ?? null
const listings = await listRepoPlugins(source.repo) setUpdate(found)
const found = listings.find(l => l.id === plugin.manifest.id) setChecked(true)
if (found && !cancelled) setListing(found) } catch (e) {
} catch { showErrorToast(messageOf(e))
try { } finally {
await refreshRepo(source.repo) setBusy(false)
const listings = await listRepoPlugins(source.repo)
const found = listings.find(l => l.id === plugin.manifest.id)
if (found && !cancelled) setListing(found)
} catch {}
}
})()
return () => {
cancelled = true
} }
}, [source.repo, plugin.manifest.id])
if (!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({
key: 'REVENGE_PLUGIN_CHANNEL_UP_TO_DATE',
content: 'This channel is already installed',
IconComponent: () => <TableRowAssetIcon name="CircleCheckIcon" />,
})
return
}
const repos = await listRepos()
const internalUrls = repos
.filter(r => r.internal)
.map(r => r.url)
ActionSheetActionCreators.hideActionSheet(sheetKey)
runInstallFlow(
plugin.manifest.id,
undefined,
selected,
[...internalUrls, source.repo],
)
} }
const doUpdate = async () => {
if (!update) return
setBusy(true)
try {
const plan = await planInstall(
update.id,
undefined,
update.channel,
)
const accepted = await confirmPlan(plan)
if (!accepted) return
await installFromRepo(plan)
setUpdate(null)
setChecked(true)
ToastActionCreators.open({
key: 'revenge-plugin-update-pending',
content: 'Update downloaded. Reload to apply.',
})
} catch (e) {
showErrorToast(messageOf(e))
} finally {
setBusy(false)
}
}
const icon = update
? 'DownloadIcon'
: checked
? 'CircleCheckIcon'
: 'RefreshIcon'
const label = update
? 'Update'
: checked
? 'Up to date'
: 'Check for updates'
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 (
<TableRadioGroup <TableRow
key={resetKey} icon={<TableRowAssetIcon name={icon} />}
title="Channel" label={label}
defaultValue={source.channel} subLabel={subLabel}
onChange={v => handleChange(v as string)} onPress={update ? doUpdate : check}
> disabled={busy}
{Object.keys(listing.channels).map(c => ( />
<TableRadioRow key={c} label={c} value={c} />
))}
</TableRadioGroup>
) )
} }
function AdvancedSection({ function AdvancedSection({ plugin }: { plugin: AnyPlugin }) {
plugin,
sheetKey,
}: {
plugin: AnyPlugin
sheetKey: string
}) {
const meta = getInternalPluginMeta(plugin) const meta = getInternalPluginMeta(plugin)
const dependents = getPluginDependents(plugin, true) const dependents = getPluginDependents(plugin, true)
const dependencies = getPluginDependencies(plugin, false) const dependencies = getPluginDependencies(plugin, false)
@ -266,73 +237,65 @@ function AdvancedSection({
const { id, name } = plugin.manifest const { id, name } = plugin.manifest
return ( return (
<> <TableRowGroup title="Advanced">
<ChannelSection plugin={plugin} sheetKey={sheetKey} /> <UpdateRow plugin={plugin} />
<TableRowGroup title="Advanced"> <IdRow id={id} />
<IdRow id={id} /> <RepositoryRow
<RepositoryRow text={repositoryText}
text={repositoryText} copyable={!!meta.source?.repo}
copyable={!!meta.source?.repo} />
/> <TableRow
icon={<TableRowAssetIcon name="FlagIcon" />}
label="Flags"
subLabel={bitFieldToString(PluginFlags, meta.flags)}
/>
{meta.iflags > 0 && (
<TableRow <TableRow
icon={<TableRowAssetIcon name="FlagIcon" />} icon={<TableRowAssetIcon name="FlagIcon" />}
label="Flags" label="Internal Flags"
subLabel={bitFieldToString(PluginFlags, meta.flags)} subLabel={bitFieldToString(
InternalPluginFlags,
meta.iflags,
)}
/> />
{meta.iflags > 0 && ( )}
<TableRow {dependencies.length > 0 && (
icon={<TableRowAssetIcon name="FlagIcon" />} <TableRow
label="Internal Flags" icon={<TableRowAssetIcon name="ListBulletsIcon" />}
subLabel={bitFieldToString( label="Dependencies"
InternalPluginFlags, subLabel={`${name} depends on ${dependencies.length} other plugins`}
meta.iflags, onPress={() => {
)} ActionSheetActionCreators.openLazy(
/> import('./PluginListActionSheet'),
)} `plugin-deps-${id}`,
{dependencies.length > 0 && ( {
<TableRow title: `Dependencies of ${name}`,
icon={<TableRowAssetIcon name="ListBulletsIcon" />} plugins: dependencies,
label="Dependencies" sheetKey: `plugin-deps-${id}`,
subLabel={`${name} depends on ${dependencies.length} other plugins`} },
onPress={() => { )
ActionSheetActionCreators.openLazy( }}
import('./PluginRelationsListActionSheet'), />
`plugin-deps-${id}`, )}
{ {dependents.length > 0 && (
title: `Dependencies of ${name}`, <TableRow
unsatisfiedTitle: `Unsatisfied dependencies of ${name}`, icon={<TableRowAssetIcon name="ListBulletsIcon" />}
unsatisfiedPlugins: label="Dependents"
meta.unsatisfiedOptionalDependencies.map( subLabel={`${dependents.length} other plugins depend on ${name}`}
id => pList.get(id) ?? id, onPress={() => {
), ActionSheetActionCreators.openLazy(
plugins: dependencies, import('./PluginListActionSheet'),
dependencyMap: plugin.manifest.dependencies!, `plugin-dependents-${id}`,
}, {
'stack', title: `Dependents of ${name}`,
) plugins: dependents,
}} sheetKey: `plugin-dependents-${id}`,
/> },
)} )
{dependents.length > 0 && ( }}
<TableRow />
icon={<TableRowAssetIcon name="ListBulletsIcon" />} )}
label="Dependents" </TableRowGroup>
subLabel={`${dependents.length} other plugins depend on ${name}`}
onPress={() => {
ActionSheetActionCreators.openLazy(
import('./PluginRelationsListActionSheet'),
`plugin-dependents-${id}`,
{
title: `Dependents of ${name}`,
plugins: dependents,
},
'stack',
)
}}
/>
)}
</TableRowGroup>
</>
) )
} }
@ -418,9 +381,8 @@ function PluginActions({
plugin: AnyPlugin plugin: AnyPlugin
closeSheet: () => void closeSheet: () => void
}) { }) {
const [settingsRef, showEnableTooltip] = usePluginTooltip( const enableTooltip = useEnablePluginTooltip()
PluginTooltip.Enable, const settingsRef = useClickOutsideTooltip(useEnablePluginTooltip, () => {})
)
const meta = getInternalPluginMeta(plugin) const meta = getInternalPluginMeta(plugin)
const startable = isPluginStartable(plugin) const startable = isPluginStartable(plugin)
const enabled = usePluginEnabled(plugin) const enabled = usePluginEnabled(plugin)
@ -474,7 +436,12 @@ function PluginActions({
{plugin.SettingsComponent && ( {plugin.SettingsComponent && (
<Pressable <Pressable
onPress={() => { onPress={() => {
if (!startable) showEnableTooltip() if (!startable)
requestAnimationFrame(() => {
enableTooltip.targetRef.current =
settingsRef.current
enableTooltip.setVisible(true)
})
}} }}
> >
<IconButton <IconButton

View file

@ -1,131 +0,0 @@
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>
)
}

View file

@ -0,0 +1,112 @@
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" />
</>
}
/>
)
}

View file

@ -18,7 +18,8 @@ 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. will be removed. You can restore the default repositories by
clearing Plugin Settings data.
</Text> </Text>
} }
actions={ actions={

View file

@ -1,134 +1,109 @@
import { Design } from '@revenge-mod/discord/design' import { Design } from '@revenge-mod/discord/design'
import { import { createContext, useContext, useRef, useState } from 'react'
createContext, import { useClickOutside } from 'react-native-click-outside'
useCallback, import type { RefObject } from 'react'
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
export const PluginTooltip = { interface TooltipContextValue {
/** Shown when an action requires the plugin to be enabled first. */ targetRef: RefObject<View | null>
Enable: 'enable', setVisible: (visible: boolean) => void
/** 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',
} }
interface PluginTooltipsContextValue { const EnablePluginTooltipContext = createContext<TooltipContextValue | null>(
show: (tooltip: PluginTooltip, target: View | null) => void null,
hide: () => void )
} const EssentialPluginTooltipContext = createContext<TooltipContextValue | null>(
const PluginTooltipsContext = createContext<PluginTooltipsContextValue | null>(
null, null,
) )
export default function PluginTooltipsProvider({ export function EnablePluginTooltipProvider({
children, children,
}: { }: {
children: React.ReactNode children: React.ReactNode
}) { }) {
const [visible, setVisible] = useState<PluginTooltip | null>(null) const [visible, setVisible] = useState(false)
const targetRef = useRef<View | null>(null)
// Targets are assigned by `show`, right before the tooltip becomes visible useTooltip(targetRef, {
const enableTarget = useRef<View | null>(null) label: 'Plugin must be enabled first',
const essentialTarget = useRef<View | null>(null)
const pendingUpdateTarget = useRef<View | null>(null)
useTooltip(enableTarget, {
label: Labels[PluginTooltip.Enable],
position: 'top', position: 'top',
visible: visible === PluginTooltip.Enable, visible,
}) })
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 (
<ClickOutsideProvider> <EnablePluginTooltipContext.Provider value={{ targetRef, setVisible }}>
<PluginTooltipsContext.Provider value={value}> {children}
{children} </EnablePluginTooltipContext.Provider>
</PluginTooltipsContext.Provider>
</ClickOutsideProvider>
) )
} }
/** export function EssentialPluginTooltipProvider({
* Binds an element to a plugin tooltip. children,
* }: {
* @param tooltip The tooltip to show for the element. children: React.ReactNode
* @returns The ref to attach to the element, and a callback showing the tooltip on it. }) {
*/ const [visible, setVisible] = useState(false)
export function usePluginTooltip(tooltip: PluginTooltip) { const targetRef = useRef<View | null>(null)
const context = useContext(PluginTooltipsContext)
if (!context) useTooltip(targetRef, {
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(
'usePluginTooltip must be used within PluginTooltipsProvider', 'useEnablePluginTooltip must be used within EnablePluginTooltipProvider',
) )
}
const { show, hide } = context return context
// Tapping anywhere else dismisses the tooltip
const ref = useClickOutside<View>(hide)
const showTooltip = useCallback(() => {
// Tapping the element itself also counts as a tap outside of it (the
// press target wraps the ref), so showing waits for the next frame
requestAnimationFrame(() => {
show(tooltip, ref.current)
})
}, [show, tooltip, ref])
return [ref, showTooltip] as const
} }
/** Hides the visible plugin tooltip, if any.. */ export function useEssentialPluginTooltip() {
export function useHidePluginTooltips() { const context = useContext(EssentialPluginTooltipContext)
const context = useContext(PluginTooltipsContext) if (!context) {
throw new Error(
return useCallback(() => context?.hide(), [context]) 'useEssentialPluginTooltip must be used within EssentialPluginTooltipProvider',
)
}
return context
}
export function useResetTooltips() {
const enableTooltip = useContext(EnablePluginTooltipContext)
const essentialTooltip = useContext(EssentialPluginTooltipContext)
return () => {
enableTooltip?.setVisible(false)
essentialTooltip?.setVisible(false)
}
}
export function useClickOutsideTooltip(
contextHook: () => TooltipContextValue,
onClickOutside: () => void,
) {
const { targetRef, setVisible } = contextHook()
const ref = useClickOutside<View>(() => {
setVisible(false)
if (targetRef.current === ref.current) {
onClickOutside()
}
})
return ref
} }

View file

@ -1,18 +1,22 @@
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'
@ -22,7 +26,7 @@ export interface Storage {
defaultRepoRestored?: boolean defaultRepoRestored?: boolean
} }
const OUTDATED_THRESHOLD = 60 * 60 * 1000 // 1 hour const OUTDATED_THRESHOLD = 24 * 60 * 60 * 1000 // 1 day
const AUTO_UPDATE_CHECK_DELAY = 10_000 const AUTO_UPDATE_CHECK_DELAY = 10_000
const CircleXIconComponent = lookupGeneratedIconComponent( const CircleXIconComponent = lookupGeneratedIconComponent(
@ -78,12 +82,34 @@ 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) {
await api.jsonStorage.set({ ToastActionCreators.open({
lastUpdateCheck: Date.now(), key: 'PLUGIN_UPDATE_CHECK_FAILED',
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({
key: 'PLUGIN_UPDATE_CHECK_FAILED', key: 'PLUGIN_UPDATE_CHECK_FAILED',

View file

@ -7,16 +7,14 @@ 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() {
refresh = false, if (!__BUILD_DEFAULT_PLUGIN_REPOSITORY_URL__) return
): 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 false return
const userRepos = toConfig(repos.filter(repo => !repo.internal)) const userRepos = toConfig(repos.filter(repo => !repo.internal))
await setRepos([ await setRepos([
@ -26,10 +24,7 @@ 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[] {

View file

@ -3,7 +3,6 @@ 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'
@ -18,12 +17,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, useReducer, useState } from 'react' import { useCallback, useEffect, 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 { showRemoveRepoConfirmation } from '../utils/alerts'
import { formatBytes, messageOf, showErrorToast } from '../utils/repos' import { formatBytes, messageOf, showErrorToast } from '../utils/repos'
import { showRemoveRepoConfirmation } from '../utils/alerts'
import type { import type {
DownloadProgressEvent, DownloadProgressEvent,
Repo, Repo,
@ -103,8 +102,7 @@ function UserRepoRow({
label: 'Delete', label: 'Delete',
IconComponent: TrashIconComponent, IconComponent: TrashIconComponent,
variant: 'destructive' as const, variant: 'destructive' as const,
action: () => action: () => showRemoveRepoConfirmation(repo, () => onRemove(repo)),
showRemoveRepoConfirmation(repo, () => onRemove(repo)),
}, },
], ],
] ]
@ -148,7 +146,6 @@ 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) => {
@ -172,8 +169,7 @@ export default function RevengePluginsAdvancedSettingScreen() {
listRepos().then(setReposState, e => showErrorToast(messageOf(e))) listRepos().then(setReposState, e => showErrorToast(messageOf(e)))
}, []) }, [])
// biome-ignore lint/correctness/useExhaustiveDependencies: forceUpdate so we can refresh the screen useEffect(refresh, [refresh])
useEffect(refresh, [refresh, n])
const commit = useCallback( const commit = useCallback(
async (config: RepoConfigEntry[]) => { async (config: RepoConfigEntry[]) => {
@ -217,6 +213,7 @@ 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
@ -375,24 +372,12 @@ export default function RevengePluginsAdvancedSettingScreen() {
/> />
<TableRow <TableRow
icon={<TableRowAssetIcon name="GlobeEarthIcon" />} icon={<TableRowAssetIcon name="GlobeEarthIcon" />}
label="Restore default repository" label="Restore default repositories"
onPress={async () => { subLabel="Add the bundled default repository if it's missing"
try { onPress={() => {
const restored = addDefaultRepoIfNeeded().catch(e =>
await addDefaultRepoIfNeeded(true) showErrorToast(messageOf(e)),
if (!restored) { )
ToastActionCreators.open({
key: 'revenge-default-repo-nothing',
content: 'Nothing to restore',
})
return
}
forceUpdate()
} catch (e) {
showErrorToast(messageOf(e))
}
}} }}
/> />
</TableRowGroup> </TableRowGroup>

View file

@ -13,9 +13,13 @@ 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 PluginTooltipsProvider from '../components/TooltipProvider' import {
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'
@ -30,13 +34,17 @@ const SearchDebounceTime = 100
export default function RevengePluginsBrowseSettingScreen() { export default function RevengePluginsBrowseSettingScreen() {
return ( return (
<LayerScope> <LayerScope>
<PluginStatesProvider> <ClickOutsideProvider>
<Page spacing={16}> <PluginStatesProvider>
<PluginTooltipsProvider> <Page spacing={16}>
<Screen /> <EssentialPluginTooltipProvider>
</PluginTooltipsProvider> <EnablePluginTooltipProvider>
</Page> <Screen />
</PluginStatesProvider> </EnablePluginTooltipProvider>
</EssentialPluginTooltipProvider>
</Page>
</PluginStatesProvider>
</ClickOutsideProvider>
</LayerScope> </LayerScope>
) )
} }
@ -74,15 +82,18 @@ 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, () => excluded.length > 0 || sort !== 'name' || reverse || channel !== '',
[excluded, sort, reverse], [excluded, sort, reverse, channel],
) )
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),
@ -100,7 +111,9 @@ function Screen() {
for (const listing of listings) { for (const listing of listings) {
const plugin = pList.get(listing.id) const plugin = pList.get(listing.id)
const displayChannel = displayChannelOf(listing, '') for (const c of Object.keys(listing.channels))
allChannels.add(c)
const displayChannel = displayChannelOf(listing, channel)
const displayVersion = displayChannel const displayVersion = displayChannel
? (listing.channels[displayChannel] ?? '') ? (listing.channels[displayChannel] ?? '')
: '' : ''
@ -130,8 +143,9 @@ 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
@ -153,24 +167,12 @@ function Screen() {
}, [load]) }, [load])
const install = useCallback( const install = useCallback(
async ( async (entry: BrowseEntry) => {
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,
targetVersion || undefined, entry.version || undefined,
targetChannel, entry.channel,
// 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],
) )
@ -237,6 +239,9 @@ 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,

View file

@ -28,10 +28,14 @@ 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 PluginTooltipsProvider from '../components/TooltipProvider' import {
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'
@ -47,13 +51,17 @@ const PlusLargeIcon = getAssetIdByName('PlusLargeIcon')!
export default function RevengePluginsSettingScreen() { export default function RevengePluginsSettingScreen() {
return ( return (
<LayerScope> <LayerScope>
<PluginStatesProvider> <ClickOutsideProvider>
<PluginTooltipsProvider> <PluginStatesProvider>
<Page spacing={16}> <Page spacing={16}>
<Screen /> <EssentialPluginTooltipProvider>
<EnablePluginTooltipProvider>
<Screen />
</EnablePluginTooltipProvider>
</EssentialPluginTooltipProvider>
</Page> </Page>
</PluginTooltipsProvider> </PluginStatesProvider>
</PluginStatesProvider> </ClickOutsideProvider>
</LayerScope> </LayerScope>
) )
} }

View file

@ -13,8 +13,9 @@ 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 } from '@revenge-mod/plugins/_/repositories' import type { Repo, RepoUpdate } 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(
@ -134,3 +135,13 @@ export function showPluginHasDependentsAlert(
</PluginStatesProvider>, </PluginStatesProvider>,
) )
} }
export function showPluginUpdatesFoundAlert(
updates: RepoUpdate[],
action: () => Promise<void>,
) {
AlertActionCreators.openAlert(
'plugin-updates-found',
<PluginUpdatesFoundAlert updates={updates} action={action} />,
)
}

View file

@ -1,139 +1,39 @@
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 }}> <SettingListRenderer.SettingsList
<Stack spacing={0}> node={{
<View style={{ padding: 16, paddingBottom: 0 }}> type: 'list',
<DonateCard /> sections: [
</View>
<View>
<SettingListRenderer.SettingsList
node={{
type: 'list',
sections: [
{
label: 'Revenge',
settings: [
Setting.RevengeVersion,
Setting.LoaderVersion,
Setting.RevengeDiscord,
Setting.RevengeSourceRepository,
Setting.RevengeLicense,
],
},
{
label: 'Versions',
settings: [
Setting.ReactVersion,
Setting.ReactNativeVersion,
Setting.HermesVersion,
],
},
{
label: 'Actions',
settings: [
Setting.Reload,
Setting.RevengeEnterRecoveryMode,
],
},
],
}}
/>
</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, label: 'Revenge',
experimental_backgroundImage: settings: [
'linear-gradient(166deg, rgba(255, 131, 29, 0.33), rgba(145, 47, 47, 0)), ' + Setting.RevengeVersion,
'linear-gradient(45deg, rgba(145, 47, 47, 0), rgba(255, 22, 22, 0.5))', Setting.LoaderVersion,
Setting.RevengeDiscord,
Setting.RevengeSourceRepository,
Setting.RevengeLicense,
],
}, },
]} {
/> label: 'Versions',
<View style={{ padding: 16 }}> settings: [
<Stack style={styles.grow} direction="horizontal" spacing={16}> Setting.ReactVersion,
<Stack spacing={16} style={styles.flex}> Setting.ReactNativeVersion,
<Stack spacing={6} style={styles.flex}> Setting.HermesVersion,
<Text ],
variant="heading-lg/semibold" },
color="text-strong" {
> label: 'Actions',
Support Revenge settings: [
</Text> Setting.Reload,
<Text variant="text-md/medium"> Setting.RevengeEnterRecoveryMode,
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>
) )
} }

View file

@ -20,20 +20,13 @@ registerInternalPlugin(
icon: 'StaffBadgeIcon', icon: 'StaffBadgeIcon',
}, },
{ {
start({ cleanup, logger, plugin }) { start({ cleanup, logger }) {
const CircleInformationIcon = lookupGeneratedIconComponent( const CircleInformationIcon = lookupGeneratedIconComponent(
'CircleInformationIcon', 'CircleInformationIcon',
'CircleInformationIcon-secondary', 'CircleInformationIcon-secondary',
'CircleInformationIcon-primary', 'CircleInformationIcon-primary',
) )
const showToast = () =>
ToastActionCreators.open({
key: 'staff-settings-action',
content: 'Navigate out of Settings to apply changes',
IconComponent: CircleInformationIcon,
})
function reset() { function reset() {
getStore<{ getStore<{
initialize(): void initialize(): void
@ -50,6 +43,12 @@ registerInternalPlugin(
store.initialize() store.initialize()
unpatch() unpatch()
ToastActionCreators.open({
key: 'staff-settings-action',
content: 'Navigate out of Settings to apply changes',
IconComponent: CircleInformationIcon,
})
}) })
} }
@ -70,11 +69,9 @@ registerInternalPlugin(
).getCurrentUser(), ).getCurrentUser(),
), ),
reset, reset,
showToast,
) )
reset() reset()
if (plugin.startedLate) showToast()
}), }),
) )
}, },

View file

@ -1,4 +1,8 @@
{ {
"629368283354628116": ["revenge_team"], "629368283354628116": ["revenge_team"],
"399862294143696897": ["revenge_team"] "399862294143696897": ["revenge_team"],
"853550207039832084": ["revenge_team"],
"485376328980365312": ["revenge_team"],
"737323631117598811": ["revenge_team"],
"826489186327724095": ["revenge_team"]
} }

View file

@ -7,18 +7,23 @@ import {
PluginFlags, PluginFlags,
registerInternalPlugin, registerInternalPlugin,
} from '@revenge-mod/plugins/_' } from '@revenge-mod/plugins/_'
import { insteadJSX } from '@revenge-mod/react/jsx-runtime' import { afterJSX, beforeJSX } from '@revenge-mod/react/jsx-runtime'
import { createElement, isValidElement } from 'react' import { findInReactFiber } from '@revenge-mod/utils/react'
import { Image } from 'react-native' import { Image } from 'react-native'
import { Badges, UsersWithBadges } from './constants' import { Badges, UsersWithBadges } from './constants'
import { styles, useBadgeStyles } from './styles' import { styles, useBadgeStyles } from './styles'
import { mapElementTree, patchRender } from './utils' import { afterRendered } from './utils'
import type { FC, ReactElement, ReactNode } from 'react' import type { FC, ReactElement } from 'react'
import type { ImageSourcePropType } from 'react-native' import type {
ImageProps,
ImageSourcePropType,
ImageStyle,
PressableProps,
StyleProp,
} from 'react-native'
import type { Badge, BadgeId } from './constants' import type { Badge, BadgeId } from './constants'
import type { RenderPatch } from './utils'
type BadgeStyles = ReturnType<typeof useBadgeStyles> const DUMMY_SYMBOL = {} as unknown as string
interface ProfileBadgeProps { interface ProfileBadgeProps {
id: string id: string
@ -41,10 +46,9 @@ interface ProfileBadgeRowsProps {
showToastOnPress?: boolean showToastOnPress?: boolean
} }
const DummyBadgeId = {} as unknown as string type ViewWithProfileBadges = ReactElement<{
const DummyBadges = [ children: Array<ReactElement<ProfileBadgeProps, FC<ProfileBadgeProps>>>
{ id: DummyBadgeId }, }>
] as unknown as ProfileBadgeRowsProps['badges']
registerInternalPlugin( registerInternalPlugin(
{ {
@ -62,25 +66,28 @@ registerInternalPlugin(
), ),
({ ProfileBadgeRows }) => { ({ ProfileBadgeRows }) => {
cleanup( cleanup(
insteadJSX( beforeJSX(ProfileBadgeRows, args => {
ProfileBadgeRows, const [, props] = args
([type, props, key], jsx) => {
if (
!UsersWithBadges[props.userId] ||
typeof type !== 'function'
)
return jsx(type, props, key)
// Inject dummy badge for users with custom badges to ensure ProfileBadge components exist // Inject dummy badge for users with custom badges to ensure ProfileBadge components exist
return jsx( if (
patchRender(type, injectCustomBadges), // has custom badges
props.badges.length UsersWithBadges[props.userId] &&
? props // but no actual badges
: { ...props, badges: DummyBadges }, !props.badges.length
key, )
) // @ts-expect-error: This will never be rendered
}, props.badges.push({ id: DUMMY_SYMBOL })
),
return args
}),
)
cleanup(
afterJSX(ProfileBadgeRows, el => {
patchProfileBadgeRows(el)
return el
}),
) )
}, },
) )
@ -96,102 +103,92 @@ registerInternalPlugin(
InternalPluginFlags.Internal | InternalPluginFlags.Essential, InternalPluginFlags.Internal | InternalPluginFlags.Essential,
) )
/** Matched structurally, as minified builds don't preserve `type.name`. */ function patchProfileBadgeRows(
function isProfileBadgeElement( element: ReactElement<ProfileBadgeRowsProps, FC<ProfileBadgeRowsProps>>,
node: ReactNode, ): void {
): node is ReactElement<ProfileBadgeProps, FC<ProfileBadgeProps>> { const unpatch = afterRendered(element, el => {
if (!isValidElement(node) || typeof node.type !== 'function') return false unpatch()
const props = node.props as Partial<ProfileBadgeProps> | null const container = findInReactFiber(
return props != null && 'badgeSize' in props && 'userId' in props el as ReactElement,
} isViewContainingProfileBadgeElements,
)
const injectCustomBadges: RenderPatch<ProfileBadgeRowsProps> = ( if (!container) return el
rendered,
{ userId },
) => {
const userBadges = UsersWithBadges[userId]
if (!userBadges) return rendered
return mapElementTree(rendered, element => { const [{ type: ProfileBadge, props }] = container.props.children
const { children } = element.props const userBadges = UsersWithBadges[props.userId]
if (!Array.isArray(children)) return
const template = children.find(isProfileBadgeElement) if (userBadges) {
if (!template) return for (const badgeId of userBadges) {
const badge = Badges[badgeId]
if (!badge) continue
const ProfileBadge = template.type const badgeElement = (
const customBadges: ReactElement[] = [] <ProfileBadge
{...props}
key={badgeId}
id={badgeId}
label={badge.label}
source={badge.icon}
/>
)
for (const id of userBadges) { patchProfileBadge(badgeElement, badgeId, badge)
const badge = Badges[id] container.props.children.push(badgeElement)
if (!badge) continue }
customBadges.push( // Remove dummy badge if it exists
createElement( if (props.id === DUMMY_SYMBOL) container.props.children.shift()
patchRender(ProfileBadge, patchCustomBadge, useBadgeStyles),
{
...template.props,
key: `revenge-badge-${id}`,
id,
label: badge.label,
source: badge.icon,
},
),
)
} }
if (!customBadges.length) return return el
return {
children: [
...children.filter(
child =>
!isProfileBadgeElement(child) ||
child.props.id !== DummyBadgeId,
),
...customBadges,
],
}
}) })
} }
const patchCustomBadge: RenderPatch<ProfileBadgeProps, BadgeStyles> = ( function patchProfileBadge(
rendered, element: ReactElement<ProfileBadgeProps, FC<ProfileBadgeProps>>,
{ id }, id: BadgeId,
badgeStyles, badge: Badge,
) => { ): void {
const badge = Badges[id as BadgeId]
if (!badge) return rendered
const { bnw, showDialog } = badge const { bnw, showDialog } = badge
if (!bnw && !showDialog) return rendered // Return early if we don't actually need to patch this badge
if (!bnw && !showDialog) return
let tinted = false const unpatch = afterRendered(element, el => {
let pressablePatched = false unpatch()
// Only patched the most shallow Pressable // This will not result in a conditional hook error because badges do not change flags during runtime
return mapElementTree(rendered, element => { const badgeStyles = useBadgeStyles()
if (bnw && !tinted && element.type === Image) {
tinted = true if (showDialog) {
return { style: [element.props.style, badgeStyles.tinted] } const pressable = findInReactFiber(
el as ReactElement,
(node): node is ReactElement<PressableProps> =>
node.type?.render,
)
if (pressable)
pressable.props.onPress = () =>
openBadgeDialog(id, badge, badgeStyles)
} }
if ( if (bnw) {
showDialog && const image = findInReactFiber(
!pressablePatched && el as ReactElement,
// Match by behaviour, as matching .type === Image picks whichever wrapper component happens to come first (node): node is ReactElement<ImageProps> => node.type === Image,
('onPress' in element.props || )
element.props.accessibilityRole === 'button')
) { if (image) {
pressablePatched = true const styles = image.props.style as Extract<
return { StyleProp<ImageStyle>,
onPress: () => any[]
openBadgeDialog(id as BadgeId, badge, badgeStyles), >
styles.push(badgeStyles.tinted)
} }
} }
return undefined return el
}) })
} }
@ -200,10 +197,10 @@ const { AlertActionButton, AlertModal, Stack, Text } = Design
function openBadgeDialog( function openBadgeDialog(
id: BadgeId, id: BadgeId,
{ label, description, bnw, icon }: Badge, { label, description, bnw, icon }: Badge,
badgeStyles: BadgeStyles, badgeStyles: ReturnType<typeof useBadgeStyles>,
): void { ): void {
AlertActionCreators.openAlert( AlertActionCreators.openAlert(
`revenge-profile-badge-${id}`, `REVENGE_PROFILE_BADGE-${id}`,
<AlertModal <AlertModal
title={ title={
<Stack style={styles.stack}> <Stack style={styles.stack}>
@ -220,7 +217,12 @@ function openBadgeDialog(
</Stack> </Stack>
} }
content={description} content={description}
actions={<AlertActionButton text="OK" />} actions={<AlertActionButton text="Okay" />}
/>, />,
) )
} }
const isViewContainingProfileBadgeElements = (
node: any,
): node is ViewWithProfileBadges =>
node.props?.children?.[0]?.type.name === 'ProfileBadge'

View file

@ -1,90 +1,7 @@
import { cloneElement, isValidElement } from 'react' import { after } from '@revenge-mod/patcher'
import type { FC, ReactElement, ReactNode } from 'react' import type { FC, ReactElement, ReactNode } from 'react'
type AnyElement = ReactElement<Record<string, any>> export const afterRendered = (
el: ReactElement<any, FC<any>>,
export type ElementVisitor = ( hook: (el: ReactNode) => ReactNode,
element: AnyElement, ) => after(el, 'type', el => (el instanceof Promise ? el.then(hook) : hook(el)))
) => Record<string, any> | undefined
/** Copies a rendered element tree, merging in the props the visitor returns. */
export function mapElementTree(
node: ReactNode,
visit: ElementVisitor,
): ReactNode {
if (Array.isArray(node)) {
let changed = false
const next = node.map(child => {
const mapped = mapElementTree(child, visit)
if (mapped !== child) changed = true
return mapped
})
return changed ? next : node
}
if (!isValidElement(node)) return node
const element = node as AnyElement
const patch = visit(element)
// The visitor took over the subtree, so don't descend into it
if (patch && 'children' in patch) return cloneElement(element, patch)
const { children } = element.props
const nextChildren =
children === undefined ? children : mapElementTree(children, visit)
// Nothing below matched either, so hand back the very same element
if (!patch && nextChildren === children) return node
return cloneElement(
element,
nextChildren === children
? patch
: { ...patch, children: nextChildren },
)
}
const patchedComponents = new WeakMap<FC<any>, FC<any>>()
export type RenderPatch<P, C = void> = (
rendered: ReactNode,
props: P,
context: C,
) => ReactNode
/**
* Creates and caches a component rendering `Component`, passing its output through `patch`.
*
* @param Component The component to wrap. Called directly, so its hooks belong to the wrapper.
* @param patch The patch to apply to the rendered output. Must not call hooks.
* @param useContext Provides whatever the patch needs from hooks. Always called, before the component renders.
* @returns The wrapper component.
*/
export function patchRender<P extends object, C = void>(
Component: FC<P>,
patch: RenderPatch<P, C>,
useContext?: () => C,
): FC<P> {
const cached = patchedComponents.get(Component)
if (cached) return cached
const Patched: FC<P> = props => {
const context = useContext?.() as C
const rendered = Component(props)
return rendered instanceof Promise
? rendered.then(node => patch(node, props, context))
: patch(rendered, props, context)
}
Object.defineProperty(Patched, 'name', {
value: `Patched(${Component.name || 'Component'})`,
})
patchedComponents.set(Component, Patched)
return Patched
}

1
types/build.d.ts vendored
View file

@ -3,7 +3,6 @@
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