Merge remote-tracking branch 'origin/main' into plugin-ux-fix

This commit is contained in:
PalmDevs 2026-08-13 00:07:07 +07:00
commit 4c57f5c257
No known key found for this signature in database
GPG key ID: 6FAE8F8677ACA8BA
25 changed files with 831 additions and 585 deletions

View file

@ -120,3 +120,5 @@ bun run types
<br> <br>
<sub>Bundlers will need to map imports to property access on `revenge` turning `kebab-case` and `snake_case` to `camelCase`.</sub> <sub>Bundlers will need to map imports to property access on `revenge` turning `kebab-case` and `snake_case` to `camelCase`.</sub>
<sub>Example: `@revenge-mod/discord/modules/main_tabs_v2` to `revenge.discord.modules.mainTabsV2`</sub> <sub>Example: `@revenge-mod/discord/modules/main_tabs_v2` to `revenge.discord.modules.mainTabsV2`</sub>
<br>
<sub>Exception for `@revenge-mod/externals` which property accesses should turn into `PascalCase` instead of `camelCase`.</sub>

View file

@ -1,6 +1,5 @@
import { lookupModule, lookupModules } from '@revenge-mod/modules/finders' import { lookupModule, lookupModules } from '@revenge-mod/modules/finders'
import { withDependencies } from '@revenge-mod/modules/finders/filters' import { withDependencies } from '@revenge-mod/modules/finders/filters'
import { getModuleDependencies } from '@revenge-mod/modules/metro/utils'
import { import {
ReactJSXRuntimeModuleId, ReactJSXRuntimeModuleId,
ReactModuleId, ReactModuleId,
@ -10,7 +9,7 @@ import { proxify } from '@revenge-mod/utils/proxy'
import { ImportTrackerModuleId } from './common/import-tracker' import { ImportTrackerModuleId } from './common/import-tracker'
import type { DiscordModules } from './types' import type { DiscordModules } from './types'
const { loose } = withDependencies const { atLeast, last, loose, relative } = withDependencies
/** /**
* The lowest amount of dependencies `design/native.tsx` is expected to have. * The lowest amount of dependencies `design/native.tsx` is expected to have.
@ -29,13 +28,12 @@ export let Design: Design = proxify(
// [3909, 4608, 5182, 13172, 2, ...] (141 dependencies) // [3909, 4608, 5182, 13172, 2, ...] (141 dependencies)
const [, id] = lookupModule( const [, id] = lookupModule(
withDependencies<Design>( withDependencies<Design>(
loose([ atLeast(DesignMinimumDependencies, [
[ImportTrackerModuleId], [ImportTrackerModuleId],
[ImportTrackerModuleId], [ImportTrackerModuleId],
[ReactNativeModuleId, ImportTrackerModuleId], [ReactNativeModuleId, ImportTrackerModuleId],
[ImportTrackerModuleId], [ImportTrackerModuleId],
ImportTrackerModuleId, ImportTrackerModuleId,
...Array<null>(DesignMinimumDependencies - 5).fill(null),
]), ]),
).keyAs('revenge.discord.design.Design'), ).keyAs('revenge.discord.design.Design'),
{ {
@ -55,9 +53,6 @@ export let Design: Design = proxify(
// design/components/Forms/native/FormSwitch.native.tsx // design/components/Forms/native/FormSwitch.native.tsx
export let FormSwitch: DiscordModules.Components.FormSwitch = proxify(() => { export let FormSwitch: DiscordModules.Components.FormSwitch = proxify(() => {
// TODO: Possibly come up with a better dependency fingerprinting API
// to not have to deal with this bullshit
for (const [, id] of lookupModules( for (const [, id] of lookupModules(
withDependencies( withDependencies(
loose([ loose([
@ -66,17 +61,25 @@ export let FormSwitch: DiscordModules.Components.FormSwitch = proxify(() => {
ReactNativeModuleId, ReactNativeModuleId,
ReactJSXRuntimeModuleId, ReactJSXRuntimeModuleId,
]), ]),
).keyAs('revenge.discord.design.FormSwitch'), )
.and(
withDependencies(
last([
relative(1),
relative(2),
null,
null,
ImportTrackerModuleId,
]),
),
)
.keyAs('revenge.discord.design.FormSwitch'),
{ {
initialize: false, initialize: false,
}, },
)) { )) {
const deps = getModuleDependencies(id)! const FormSwitch_ = __r(id)!.FormSwitch
if (deps.at(-1) !== ImportTrackerModuleId) continue if (FormSwitch_) return (FormSwitch = FormSwitch_)
if (deps.at(-4) === id + 2 && deps.at(-5) === id + 1) {
const FormSwitch_ = __r(id)!.FormSwitch
if (FormSwitch_) return (FormSwitch = FormSwitch_)
}
} }
})! })!

View file

@ -1,3 +1,4 @@
import { DispatcherModuleId } from '@revenge-mod/discord/common/flux'
import { ImportTrackerModuleId } from '@revenge-mod/discord/common/import-tracker' import { ImportTrackerModuleId } from '@revenge-mod/discord/common/import-tracker'
import { import {
lookupModule, lookupModule,
@ -7,9 +8,8 @@ import {
import { import {
createFilterGenerator, createFilterGenerator,
withDependencies, withDependencies,
withName,
} from '@revenge-mod/modules/finders/filters' } from '@revenge-mod/modules/finders/filters'
import { getModuleDependencies } from '@revenge-mod/modules/metro/utils' import { isModuleExportBad } from '@revenge-mod/modules/metro/utils'
import { asap, noop } from '@revenge-mod/utils/callback' import { asap, noop } from '@revenge-mod/utils/callback'
import { import {
cache, cache,
@ -17,10 +17,7 @@ import {
Uncached, Uncached,
} from '../../../modules/src/caches' } from '../../../modules/src/caches'
import { FilterResultFlags } from '../../../modules/src/finders/_internal' import { FilterResultFlags } from '../../../modules/src/finders/_internal'
import { import { FilterScopes } from '../../../modules/src/finders/filters/constants'
FilterFlag,
FilterScopes,
} from '../../../modules/src/finders/filters/constants'
import type { import type {
Filter, Filter,
FilterGenerator, FilterGenerator,
@ -66,32 +63,16 @@ export function getStore<T>(
/// STORE FILTERING /// STORE FILTERING
/* const { last, includes } = withDependencies
Flux Store dependencies: [
_classCallCheck
_createClass
_possibleConstructorReturn
bound getPrototypeOf
_inherits
(...), // (any amount of dependencies)
ImportTracker
]
*/
const withLeadingFluxStoreDeps = withDependencies( // The import tracker is checked first, as it is far cheaper than resolving includes
withDependencies.loose([ const withFluxStoreDeps = withDependencies(last([ImportTrackerModuleId])).and(
withName('_classCallCheck'), withDependencies(includes([DispatcherModuleId])),
withName('_createClass'),
withName('_possibleConstructorReturn'),
withName('bound getPrototypeOf'),
withName('_inherits'),
]),
) )
export type WithStore = FilterGenerator< export type WithStore = FilterGenerator<
<T>() => Filter<{ <T>() => Filter<{
Result: DiscordModules.Flux.Store<T> Result: DiscordModules.Flux.Store<T>
RequiresExports: boolean
Scopes: [ Scopes: [
typeof FilterScopes.Uninitialized, typeof FilterScopes.Uninitialized,
typeof FilterScopes.Initialized, typeof FilterScopes.Initialized,
@ -103,27 +84,22 @@ export type WithStore = FilterGenerator<
* A dynamic filter that matches all Flux stores. * A dynamic filter that matches all Flux stores.
*/ */
export const withStore = createFilterGenerator( export const withStore = createFilterGenerator(
(_, id, exports) => { (_, id, exports, initialized) => {
if (exports) return Boolean(exports._dispatchToken) if (initialized)
else { return (
if (!withLeadingFluxStoreDeps(id)) return false !isModuleExportBad(exports) && Boolean(exports._dispatchToken)
const deps = getModuleDependencies(id)! )
return deps[deps.length - 1] === ImportTrackerModuleId
} return withFluxStoreDeps(id, undefined, false)
}, },
() => 'revenge.discord.store', () => 'revenge.discord.store',
FilterFlag.Dynamic,
FilterScopes.Uninitialized | FilterScopes.Initialized, FilterScopes.Uninitialized | FilterScopes.Initialized,
) as WithStore ) as WithStore
export type WithStoreName = FilterGenerator< export type WithStoreName = FilterGenerator<
<T>(name: string) => Filter<{ <T>(name: string) => Filter<{
Result: DiscordModules.Flux.Store<T> Result: DiscordModules.Flux.Store<T>
RequiresExports: true Scopes: [typeof FilterScopes.Initialized]
Scopes: [
typeof FilterScopes.Uninitialized,
typeof FilterScopes.Initialized,
]
}> }>
> >
@ -131,11 +107,21 @@ export type WithStoreName = FilterGenerator<
* A with-exports filter that matches a Flux store by its name. * A with-exports filter that matches a Flux store by its name.
*/ */
export const withStoreName = createFilterGenerator( export const withStoreName = createFilterGenerator(
([name], _, exports) => ([name], _, exports) => {
exports?.getName?.length === 0 && exports.getName() === name, if (isModuleExportBad(exports)) return false
const getNameMethod = exports.getName
return (
typeof getNameMethod === 'function' &&
getNameMethod.length === 0 &&
// Needs to be bound to exports since it's a class
exports.getName() === name
)
},
([name]) => `revenge.discord.storeName(${name})`, ([name]) => `revenge.discord.storeName(${name})`,
FilterFlag.RequiresExports, // Uninitialized to make sure cached results resolve immediately
FilterScopes.Uninitialized | FilterScopes.Initialized, FilterScopes.Initialized | FilterScopes.Uninitialized,
) as WithStoreName ) as WithStoreName
/// STORE CACHING /// STORE CACHING
@ -155,5 +141,6 @@ if (cache === Uncached)
asap(() => { asap(() => {
const lookup = lookupModules(withStore()) const lookup = lookupModules(withStore())
while (lookup.next().done); // Initialize all stores
for (const _ of lookup);
}) })

View file

@ -263,6 +263,7 @@ export namespace DiscordModules {
| 'tertiary' | 'tertiary'
| 'destructive' | 'destructive'
| 'active' | 'active'
| 'expressive'
| 'primary-overlay' | 'primary-overlay'
| 'secondary-overlay' | 'secondary-overlay'
loading?: boolean loading?: boolean

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 { FilterFlag } from './filters' import { isModuleInitialized } from '../metro/utils'
import type { If } from '@revenge-mod/utils/types' import type { If } from '@revenge-mod/utils/types'
import type { Metro } from '../types' import type { Metro } from '../types'
import type { Filter } from './filters' import type { Filter } from './filters'
@ -72,34 +72,14 @@ const noDefaultExportsCache = new Set<Metro.ModuleID>()
// Currently, we only have options that are relevant for checking exports // Currently, we only have options that are relevant for checking exports
// If we add more options later on, do NOT forget about // If we add more options later on, do NOT forget about
// adding them here, and passing them in the lookup* functions // adding them here, and passing them in the lookup* functions
export function runFilter(
filter: Filter<{ Result: any; RequiresExports: false; Scopes: any[] }>,
id: Metro.ModuleID,
): FilterResultFlag | undefined
export function runFilter(
filter: Filter,
id: Metro.ModuleID,
exports: Metro.ModuleExports,
options?: RunFilterOptions,
): FilterResultFlag | undefined
export function runFilter( export function runFilter(
filter: Filter, filter: Filter,
id: Metro.ModuleID, id: Metro.ModuleID,
exports?: Metro.ModuleExports, exports?: Metro.ModuleExports,
options?: RunFilterOptions, options?: RunFilterOptions,
): FilterResultFlag | undefined { ): FilterResultFlag | undefined {
if (exports === undefined) { if (!isModuleInitialized(id)) {
if (filter.flags & FilterFlag.RequiresExports) return if (filter(id, undefined, false)) {
const filter_ = filter as Filter<{
Result: any
RequiresExports: false
Scopes: any[]
}>
if (filter_(id)) {
if (options?.initialize ?? true) { if (options?.initialize ?? true) {
const module = metroRequire(id) const module = metroRequire(id)
// Check if the required module is not blacklisted // Check if the required module is not blacklisted
@ -118,13 +98,7 @@ export function runFilter(
return return
} }
const filter_ = filter as Filter<{ if (filter(id, exports, true))
Result: any
RequiresExports: true
Scopes: any[]
}>
if (filter_(id, exports))
return cacheFilterResultForId( return cacheFilterResultForId(
filter.key, filter.key,
id, id,
@ -133,13 +107,15 @@ export function runFilter(
if (options?.skipDefault) return if (options?.skipDefault) return
if (noDefaultExportsCache.has(id)) return if (noDefaultExportsCache.has(id)) return
if (!('default' in exports)) {
const type = typeof exports
if (type !== 'object' || exports === null || !('default' in exports)) {
noDefaultExportsCache.add(id) noDefaultExportsCache.add(id)
return return
} }
const { default: defaultExport } = exports const { default: defaultExport } = exports
if (filter_(id, defaultExport)) if (filter(id, defaultExport, true))
return cacheFilterResultForId(filter.key, id, FilterResultFlags.Default) return cacheFilterResultForId(filter.key, id, FilterResultFlags.Default)
} }

View file

@ -1,4 +1,4 @@
import { FilterFlag } from './constants' import { FilterScopes } from '.'
import { createFilterGenerator } from './utils' import { createFilterGenerator } from './utils'
import type { Metro } from '@revenge-mod/modules/types' import type { Metro } from '@revenge-mod/modules/types'
import type { import type {
@ -6,33 +6,48 @@ import type {
FilterBase, FilterBase,
FilterGenerator, FilterGenerator,
FilterInfoOf, FilterInfoOf,
FilterScopeValue,
MergeFilterInfo, MergeFilterInfo,
UnionFilterInfo, UnionFilterInfo,
} from '.' } from '.'
/** {@link FilterScopes.All} sets no predicate assumptions. Only {@link FilterScopes.Uninitialized} implies exportless. */
const canFilterUninitialized = (scopes: FilterScopeValue) =>
Boolean(scopes & FilterScopes.Uninitialized)
/**
* If both or neither can filter uninitialized, positional order is preserved.
*
* If only one can, the one that can will be used as a prefilter for uninitialized modules,
* and the other will only run on initialized modules.
*/
const compositeHandler = <G extends (a: FilterBase, b: FilterBase) => any>( const compositeHandler = <G extends (a: FilterBase, b: FilterBase) => any>(
sameHandler: G, directHandler: G,
dynamicHandler: G, prefilteredHandler: G,
) => ) =>
((a, b) => ((a, b) => {
a.flags === b.flags const ea = canFilterUninitialized(a.scopes)
? sameHandler(a, b) return ea === canFilterUninitialized(b.scopes)
: a.flags === FilterFlag.RequiresExports ? directHandler(a, b)
? dynamicHandler(a, b) : ea
: dynamicHandler(b, a)) as G ? prefilteredHandler(b, a)
: prefilteredHandler(a, b)
}) as G
const compositeArrayHandler = < const compositeArrayHandler = <
G extends (args: [a: FilterBase, b: FilterBase]) => any, G extends (args: [a: FilterBase, b: FilterBase]) => any,
>( >(
sameHandler: G, directHandler: G,
dynamicHandler: G, prefilteredHandler: G,
) => ) =>
(([a, b]) => (([a, b]) => {
a.flags === b.flags const ea = canFilterUninitialized(a.scopes)
? sameHandler([a, b]) return ea === canFilterUninitialized(b.scopes)
: a.flags === FilterFlag.RequiresExports ? directHandler([a, b])
? dynamicHandler([a, b]) : ea
: dynamicHandler([b, a])) as G ? prefilteredHandler([b, a])
: prefilteredHandler([a, b])
}) as G
export type And = FilterGenerator< export type And = FilterGenerator<
<F1 extends FilterBase, F2 extends FilterBase>( <F1 extends FilterBase, F2 extends FilterBase>(
@ -46,49 +61,49 @@ const andKeyGenerator = ([a, b]: Parameters<And>) =>
const andScopesGenerator = ([a, b]: Parameters<And>) => a.scopes | b.scopes const andScopesGenerator = ([a, b]: Parameters<And>) => a.scopes | b.scopes
const sameFlagsAnd = createFilterGenerator( const directAnd = createFilterGenerator(
([a, b], id, exports) => a(id, exports) && b(id, exports), ([a, b], id, exports, initialized) =>
a(id, exports, initialized) && b(id, exports, initialized),
andKeyGenerator, andKeyGenerator,
([a]) => a.flags,
andScopesGenerator, andScopesGenerator,
) as And ) as And
const andFallbackCache = new WeakMap<FilterBase, Set<Metro.ModuleID>>() const andPrefilterCache = new WeakMap<FilterBase, Set<Metro.ModuleID>>()
const dynamicFlagsAnd = createFilterGenerator( const prefilteredAnd = createFilterGenerator(
([filter, fallbackFilter], id, exports) => { ([filter, prefilter], id, exports, initialized) => {
if (exports) { if (initialized) {
if (filter(id, exports)) { if (filter(id, exports, true)) {
// Avoid running the fallback filter if we already know the first one passed // Avoid running the prefilter again if we already know it passed
const cache = andFallbackCache.get(fallbackFilter) const cache = andPrefilterCache.get(prefilter)
return ( return (
// biome-ignore lint/complexity/useOptionalChain: Hot path should be optimized // biome-ignore lint/complexity/useOptionalChain: Hot path should be optimized
(cache && cache.has(id)) || fallbackFilter(id, exports) (cache && cache.has(id)) || prefilter(id, exports, true)
) )
} }
return false return false
} }
const result = fallbackFilter(id) const result = prefilter(id, undefined, false)
if (result) { if (result) {
// Cache fallback hits to avoid calling the fallback filter again // Cache prefilter hits to avoid calling the prefilter again
// Fallback filters are usually more expensive // Prefilters are usually more expensive
let set = andFallbackCache.get(fallbackFilter) let set = andPrefilterCache.get(prefilter)
if (!set) andFallbackCache.set(fallbackFilter, (set = new Set())) if (!set) andPrefilterCache.set(prefilter, (set = new Set()))
set.add(id) set.add(id)
} }
return result return result
}, },
andKeyGenerator, andKeyGenerator,
FilterFlag.Dynamic,
andScopesGenerator, andScopesGenerator,
) as And ) as And
/** /**
* Combines two filters into one, returning true if **every** filter matches. * Combines two filters into one, returning true if **every** filter matches.
* *
* If each filter has different flags, * If only one of the filters can run on uninitialized modules ({@link FilterScopes.Uninitialized}),
* it is used as the prefilter for uninitialized modules, and the other only runs once a candidate is initialized.
* *
* @param filters The filters to combine. * @param filters The filters to combine.
* *
@ -111,23 +126,13 @@ const dynamicFlagsAnd = createFilterGenerator(
* ) * )
* ``` * ```
*/ */
export const and = Object.assign( export const and = Object.assign(compositeHandler(directAnd, prefilteredAnd), {
compositeHandler(sameFlagsAnd, dynamicFlagsAnd), keyFor: compositeArrayHandler(directAnd.keyFor, prefilteredAnd.keyFor),
{ defaultScopesFor: compositeArrayHandler(
keyFor: compositeArrayHandler( directAnd.defaultScopesFor,
sameFlagsAnd.keyFor, prefilteredAnd.defaultScopesFor,
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>(
@ -141,21 +146,21 @@ const orKeyGenerator = ([a, b]: Parameters<Or>) =>
const orScopesGenerator = ([a, b]: Parameters<Or>) => a.scopes | b.scopes const orScopesGenerator = ([a, b]: Parameters<Or>) => a.scopes | b.scopes
const sameFlagOr = createFilterGenerator( const directOr = createFilterGenerator(
([a, b], id, exports) => a(id, exports) || b(id, exports), ([a, b], id, exports, initialized) =>
a(id, exports, initialized) || b(id, exports, initialized),
orKeyGenerator, orKeyGenerator,
([a]) => a.flags,
orScopesGenerator, orScopesGenerator,
) as Or ) as Or
const dynamicFlagOr = createFilterGenerator( const prefilteredOr = createFilterGenerator(
([filter, fallbackFilter], id, exports) => { ([filter, prefilter], id, exports, initialized) => {
// TODO(PalmDevs): Potential optimization: Add fallback cache here too? // TODO(PalmDevs): Potential optimization: Add prefilter cache here too?
if (exports) return filter(id, exports) || fallbackFilter(id, exports) if (initialized)
return fallbackFilter(id) return filter(id, exports, true) || prefilter(id, exports, true)
return prefilter(id, undefined, false)
}, },
orKeyGenerator, orKeyGenerator,
FilterFlag.Dynamic,
orScopesGenerator, orScopesGenerator,
) as Or ) as Or
@ -183,17 +188,10 @@ const dynamicFlagOr = createFilterGenerator(
* ) * )
* ``` * ```
*/ */
export const or = Object.assign( export const or = Object.assign(compositeHandler<Or>(directOr, prefilteredOr), {
compositeHandler<Or>(sameFlagOr, dynamicFlagOr), keyFor: compositeArrayHandler(directOr.keyFor, prefilteredOr.keyFor),
{ defaultScopesFor: compositeArrayHandler(
keyFor: compositeArrayHandler(sameFlagOr.keyFor, dynamicFlagOr.keyFor), directOr.defaultScopesFor,
flagsFor: compositeArrayHandler( prefilteredOr.defaultScopesFor,
sameFlagOr.flagsFor, ),
dynamicFlagOr.flagsFor, }) satisfies Or
),
defaultScopesFor: compositeArrayHandler(
sameFlagOr.defaultScopesFor,
dynamicFlagOr.defaultScopesFor,
),
},
) satisfies Or

View file

@ -1,21 +1,3 @@
export const FilterFlag = {
/**
* This filter works with and without module exports.
* Allowing for both initialized and uninitialized modules to be matched.
*/
Dynamic: 0,
/**
* This filter requires module exports to work.
* Only initialized modules will be matched.
*/
RequiresExports: 1,
} as const
/**
* @see {@link FilterFlag}
*/
export type FilterFlag = number
/** /**
* Scopes to limit filters to certain module states. * Scopes to limit filters to certain module states.
*/ */
@ -23,10 +5,16 @@ export const FilterScopes = {
/** /**
* Include all modules (both initialized and uninitialized, including blacklisted). * Include all modules (both initialized and uninitialized, including blacklisted).
* This overrides {@link FilterScopes.Uninitialized} and {@link FilterScopes.Initialized}. * This overrides {@link FilterScopes.Uninitialized} and {@link FilterScopes.Initialized}.
*
* **Filter generators generally don't need this scope.**
*
* When combining multiple filters with composite filters, the {@link FilterScopes.All} scope doesn't set assumptions for the filter predicate.
* It only decides which modules to run the predicate against.
* **Filter generators must include {@link FilterScopes.Uninitialized} and/or {@link FilterScopes.Initialized} as well.**
*/ */
All: 1, All: 1,
/** /**
* Include uninitialized modules in the search. * Include uninitialized modules in the search. Implies the predicate can run without exports.
*/ */
Uninitialized: 2, Uninitialized: 2,
/** /**
@ -47,10 +35,6 @@ export interface FilterInfo {
* The result type of the filter. * The result type of the filter.
*/ */
Result: any Result: any
/**
* Whether the filter requires exports to work.
*/
RequiresExports: boolean
/** /**
* Scopes the filter matches modules in. * Scopes the filter matches modules in.
*/ */
@ -59,6 +43,5 @@ export interface FilterInfo {
export interface DefaultFilterInfo extends FilterInfo { export interface DefaultFilterInfo extends FilterInfo {
Result: any Result: any
RequiresExports: boolean
Scopes: FilterScope[] Scopes: FilterScope[]
} }

View file

@ -4,7 +4,7 @@ import {
getModuleDependencies, getModuleDependencies,
} from '../../metro/utils' } from '../../metro/utils'
import { runFilter } from '../_internal' import { runFilter } from '../_internal'
import { FilterFlag, FilterScopes } from './constants' import { FilterScopes } from './constants'
import { createFilterGenerator } from './utils' import { createFilterGenerator } from './utils'
import type { Metro } from '../../types' import type { Metro } from '../../types'
import type { Filter, FilterGenerator } from './utils' import type { Filter, FilterGenerator } from './utils'
@ -18,8 +18,18 @@ export interface ComparableDependencyMap
| ComparableDependencyMap | ComparableDependencyMap
| Filter | Filter
> { > {
// loose
l?: boolean l?: boolean
// relative
r?: number r?: number
// skip
s?: number
// atLeast
n?: number
// atMost
x?: number
// includes
i?: boolean
} }
/** /**
@ -30,6 +40,9 @@ export interface ComparableDependencyMap
* To do proper fingerprinting for modules: * To do proper fingerprinting for modules:
* @see {@link withDependencies.loose} to loosen the checks. * @see {@link withDependencies.loose} to loosen the checks.
* @see {@link withDependencies.relative} to compare dependencies relatively. * @see {@link withDependencies.relative} to compare dependencies relatively.
* @see {@link withDependencies.skip} and {@link withDependencies.last} to move where comparison starts.
* @see {@link withDependencies.atLeast} and {@link withDependencies.atMost} to bound the dependency count.
* @see {@link withDependencies.includes} to compare without caring about order.
* *
* @example * @example
* ```ts * ```ts
@ -75,30 +88,30 @@ export interface ComparableDependencyMap
*/ */
const withDependencies_ = createFilterGenerator<Parameters<WithDependencies>>( const withDependencies_ = createFilterGenerator<Parameters<WithDependencies>>(
([deps], id) => depCompare(getModuleDependencies(id)!, deps, id, id), ([deps], id) => depCompare(getModuleDependencies(id)!, deps, id, id),
// The dep map is keyed as a nested set, so its modifiers are already part of the key
deps => `revenge.deps(${depGenFilterKey(deps)})`, deps => `revenge.deps(${depGenFilterKey(deps)})`,
FilterFlag.Dynamic,
FilterScopes.Uninitialized | FilterScopes.Initialized, FilterScopes.Uninitialized | FilterScopes.Initialized,
) as WithDependencies ) as WithDependencies
export const withDependencies = __DEV__ export const withDependencies = __DEV__
? (((deps: ComparableDependencyMap) => { ? (Object.assign((deps: ComparableDependencyMap) => {
// Warn about using undefined in deps, which is likely a mistake DEBUG_validateWithDependenciesFilter(deps)
for (let i = 0; i < deps.length; i++) {
if (deps[i] === undefined)
DEBUG_warnBadWithDependenciesFilter(deps, i)
}
return withDependencies_(deps) return withDependencies_(deps)
}) as WithDependencies) }, withDependencies_) as WithDependencies)
: withDependencies_ : withDependencies_
withDependencies.loose = loose withDependencies.loose = loose
withDependencies.relative = relative withDependencies.relative = relative
withDependencies.skip = skip
withDependencies.last = last
withDependencies.atLeast = atLeast
withDependencies.atMost = atMost
withDependencies.includes = includes
type WithDependencies = FilterGenerator< type WithDependencies = FilterGenerator<
<T>(deps: ComparableDependencyMap) => Filter<{ <T>(deps: ComparableDependencyMap) => Filter<{
Result: T Result: T
RequiresExports: false
Scopes: [ Scopes: [
typeof FilterScopes.Uninitialized, typeof FilterScopes.Uninitialized,
typeof FilterScopes.Initialized, typeof FilterScopes.Initialized,
@ -107,6 +120,11 @@ type WithDependencies = FilterGenerator<
> & { > & {
loose: typeof loose loose: typeof loose
relative: typeof relative relative: typeof relative
skip: typeof skip
last: typeof last
atLeast: typeof atLeast
atMost: typeof atMost
includes: typeof includes
} }
/** /**
@ -123,6 +141,96 @@ function loose(deps: ComparableDependencyMap) {
return deps return deps
} }
/**
* Skip a number of dependencies before comparing positionally.
*
* Passing `Infinity` anchors the set to the end, matching the **last** `deps.length` dependencies.
* Anything before them is unconstrained.
*
* @param amount The amount of dependencies to skip from the start, or `Infinity` to anchor to the end.
* @param deps The dependency map to skip in. This permanently modifies the array.
* @returns The modified dependency map.
*
* @see {@link withDependencies.last} for the `Infinity` shorthand.
*/
function skip(amount: number, deps: ComparableDependencyMap = []) {
deps.s = amount
return deps
}
/**
* Match the **last** `deps.length` dependencies, leaving anything before them unconstrained.
*
* Shorthand for {@link withDependencies.skip} with `Infinity`.
* Prefer this over leading comparisons when a module's trailing dependencies are the stable part of its fingerprint.
*
* @param deps The dependency map to anchor to the end. This permanently modifies the array.
* @returns The modified dependency map.
*
* @example
* ```ts
* const { last, relative } = withDependencies
*
* // Matches modules whose last three dependencies are [Any, module ID + 1, 2]
* withDependencies(last([null, relative(1), 2]))
* ```
*/
function last(deps: ComparableDependencyMap = []) {
deps.s = Infinity
return deps
}
/**
* Require the module to have at least `count` dependencies.
*
* This implies {@link withDependencies.loose}, as an exact length check would never pass alongside a bound.
*
* @param count The minimum amount of dependencies.
* @param deps The dependency map to bound. This permanently modifies the array.
* @returns The modified dependency map.
*/
function atLeast(count: number, deps: ComparableDependencyMap = []) {
deps.n = count
deps.l = true
return deps
}
/**
* Require the module to have at most `count` dependencies.
*
* This implies {@link withDependencies.loose}, as an exact length check would never pass alongside a bound.
*
* @param count The maximum amount of dependencies.
* @param deps The dependency map to bound. This permanently modifies the array.
* @returns The modified dependency map.
*/
function atMost(count: number, deps: ComparableDependencyMap = []) {
deps.x = count
deps.l = true
return deps
}
/**
* Compare the set without caring about order or position, only that every dependency exists somewhere.
*
* Entries are matched independently, so two identical entries can both match the same dependency.
* Dynamic (`null`) entries are meaningless here and are ignored.
*
* **This is much more expensive than positional comparison**, as every entry is compared against every dependency.
* Bound it with {@link withDependencies.atLeast} or {@link withDependencies.atMost} where possible, as those are checked first.
*
* @param deps The dependency map to compare unordered. This permanently modifies the array.
* @returns The modified dependency map.
*/
function includes(deps: ComparableDependencyMap) {
if (__DEV__)
for (let i = 0; i < deps.length; i++)
if (deps[i] == null) DEBUG_warnBadIncludesDependency(deps, i)
deps.i = true
return deps
}
const RelativeSignBit = 1 << 30 const RelativeSignBit = 1 << 30
const RelativeBit = 1 << 29 const RelativeBit = 1 << 29
const RelativeRootBit = 1 << 28 const RelativeRootBit = 1 << 28
@ -192,6 +300,70 @@ function DEBUG_warnBadWithDependenciesFilter(
) )
} }
/**
* Warns the developer about a dynamic dependency in a `withDependencies.includes` set, which matches anything and is therefore a no-op.
*/
function DEBUG_warnBadIncludesDependency(
deps: ComparableDependencyMap,
index: number,
) {
nativeLoggingHook(
`\u001b[33mBad withDependencies.includes set, dynamic ID at index ${index} matches anything: [${depGenFilterKey(deps)}]\n${getCurrentStack()}\u001b[0m`,
2,
)
}
/**
* Warns the developer about dependency count bounds that contradict, or are already implied by, the set being compared.
*/
function DEBUG_warnBadDependencyBounds(
deps: ComparableDependencyMap,
reason: string,
) {
nativeLoggingHook(
`\u001b[33mBad withDependencies filter, ${reason}: [${depGenFilterKey(deps)}]\n${getCurrentStack()}\u001b[0m`,
2,
)
}
/**
* Validates a dependency map and its nested sets, warning about mistakes that would otherwise silently never match.
*/
function DEBUG_validateWithDependenciesFilter(deps: ComparableDependencyMap) {
const { n: min, x: max, s: skip } = deps
// The lowest dependency count the set itself can match.
// Unordered sets have no window, so they only ever constrain the count through bounds.
const floor = deps.i
? 0
: (skip === undefined || skip === Infinity ? 0 : skip) + deps.length
if (max !== undefined && max < floor)
DEBUG_warnBadDependencyBounds(
deps,
`atMost(${max}) can never match, the set requires at least ${floor} dependencies`,
)
else if (min !== undefined && max !== undefined && min > max)
DEBUG_warnBadDependencyBounds(
deps,
`atLeast(${min}) can never match, atMost(${max}) is lower`,
)
else if (min !== undefined && min <= floor)
DEBUG_warnBadDependencyBounds(
deps,
`atLeast(${min}) does nothing, the set already requires at least ${floor} dependencies`,
)
for (let i = 0; i < deps.length; i++) {
const dep = deps[i]
// Warn about using undefined in deps, which is likely a mistake
if (dep === undefined) DEBUG_warnBadWithDependenciesFilter(deps, i)
else if (typeof dep === 'object' && dep !== null)
DEBUG_validateWithDependenciesFilter(dep)
}
}
function depCompare( function depCompare(
a: Metro.ModuleID[], a: Metro.ModuleID[],
b: ComparableDependencyMap, b: ComparableDependencyMap,
@ -200,7 +372,29 @@ function depCompare(
): boolean { ): boolean {
const lenA = a.length const lenA = a.length
const lenB = b.length const lenB = b.length
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]
@ -211,43 +405,71 @@ function depCompare(
// Skip dynamic // Skip dynamic
if (compare == null) continue if (compare == null) continue
const id = a[i] const id = a[start + i]
// Very rare case where a module dependency has an unused `null` dependency // Very rare case where a module dependency has an unused `null` dependency
// TODO: Probably a Metro/Discord bug? // TODO: Probably a Metro/Discord bug?
if (id === null) return false if (id === null) return false
switch (typeof compare) { if (!depMatches(compare, id, root, parent)) return false
case 'function': {
const filter = compare
if (!runFilter(filter, id, getInitializedModuleExports(id)))
return false
continue
}
case 'object': {
const nested = compare as ComparableDependencyMap
// relative.withDependencies?
if (nested.r && !depShallowCompare(nested.r, id, root, parent))
return false
if (depCompare(getModuleDependencies(id)!, nested, root, id))
continue
return false
}
default: {
if (depShallowCompare(compare as number, id, root, parent))
continue
return false
}
}
} }
return true return true
} }
function depIncludesCompare(
a: Metro.ModuleID[],
b: ComparableDependencyMap,
root: Metro.ModuleID,
parent: Metro.ModuleID,
): boolean {
const lenA = a.length
for (let i = 0; i < b.length; i++) {
const compare = b[i]
// Dynamic dependencies match anything, so they carry no meaning when unordered
if (compare == null) continue
let found = false
for (let j = 0; j < lenA; j++) {
const id = a[j]
if (id !== null && depMatches(compare, id, root, parent)) {
found = true
break
}
}
if (!found) return false
}
return true
}
function depMatches(
compare: NonNullable<ComparableDependencyMap[number]>,
id: Metro.ModuleID,
root: Metro.ModuleID,
parent: Metro.ModuleID,
): boolean {
switch (typeof compare) {
case 'function':
return !!runFilter(compare, id, getInitializedModuleExports(id))
case 'object': {
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,
@ -291,11 +513,7 @@ function depGenFilterKey(deps: ComparableDependencyMap): string {
case 'object': { case 'object': {
// It's a nested dependency array // It's a nested dependency array
const nested = dep as ComparableDependencyMap const nested = dep as ComparableDependencyMap
if (nested.l) key += '#' key += `${depGenModifierKey(nested)}[${depGenFilterKey(nested)}],`
// relative.withDependencies?
if (nested.r) key += `${depGenRelativeKeyPart(nested.r)}:`
key += `[${depGenFilterKey(nested)}],`
break break
} }
default: { default: {
@ -311,6 +529,20 @@ function depGenFilterKey(deps: ComparableDependencyMap): string {
return key.substring(0, key.length - 1) return key.substring(0, key.length - 1)
} }
function depGenModifierKey(deps: ComparableDependencyMap): string {
let key = ''
if (deps.i) key += '?'
if (deps.l) key += '#'
if (deps.n !== undefined) key += `>${deps.n}`
if (deps.x !== undefined) key += `<${deps.x}`
if (deps.s !== undefined) key += `+${deps.s === Infinity ? '*' : deps.s}`
// relative.withDependencies?
if (deps.r) key += `${depGenRelativeKeyPart(deps.r)}:`
return key
}
function depGenRelativeKeyPart(dep: number) { function depGenRelativeKeyPart(dep: number) {
const magnitude = depGetRelMagnitude(dep) const magnitude = depGetRelMagnitude(dep)
const prefix = dep & RelativeRootBit ? '~' : '^' const prefix = dep & RelativeRootBit ? '~' : '^'

View file

@ -1,5 +1,5 @@
import { isModuleExportBad } from '@revenge-mod/modules/metro/utils' import { isModuleExportBad } from '@revenge-mod/modules/metro/utils'
import { FilterFlag, FilterScopes } from './constants' import { FilterScopes } from './constants'
import { createFilterGenerator } from './utils' import { createFilterGenerator } from './utils'
import type { Filter, FilterGenerator } from './utils' import type { Filter, FilterGenerator } from './utils'
@ -8,7 +8,6 @@ export * from './utils'
type FilterRequiringExports<T> = Filter<{ type FilterRequiringExports<T> = Filter<{
Result: T Result: T
RequiresExports: true
Scopes: [typeof FilterScopes.Initialized] Scopes: [typeof FilterScopes.Initialized]
}> }>
@ -41,7 +40,6 @@ export const withProps = createFilterGenerator<Parameters<WithProps>>(
return false return false
}, },
props => `revenge.props(${props.join(',')})`, props => `revenge.props(${props.join(',')})`,
FilterFlag.RequiresExports,
FilterScopes.Initialized, FilterScopes.Initialized,
) as WithProps ) as WithProps
@ -67,7 +65,6 @@ export const withoutProps = createFilterGenerator<Parameters<WithoutProps>>(
return true return true
}, },
props => `revenge.withoutProps(${props.join(',')})`, props => `revenge.withoutProps(${props.join(',')})`,
FilterFlag.RequiresExports,
FilterScopes.Initialized, FilterScopes.Initialized,
) as WithoutProps ) as WithoutProps
@ -97,7 +94,6 @@ export const withSingleProp = createFilterGenerator<Parameters<WithSingleProp>>(
return false return false
}, },
([prop]) => `revenge.singleProp(${prop})`, ([prop]) => `revenge.singleProp(${prop})`,
FilterFlag.RequiresExports,
FilterScopes.Initialized, FilterScopes.Initialized,
) as WithSingleProp ) as WithSingleProp
@ -138,7 +134,6 @@ export type WithSingleProp = FilterGenerator<
export const withName = createFilterGenerator<Parameters<WithName>>( export const withName = createFilterGenerator<Parameters<WithName>>(
([name], _, exports) => exports?.name === name, ([name], _, exports) => exports?.name === name,
([name]) => `revenge.name(${name})`, ([name]) => `revenge.name(${name})`,
FilterFlag.RequiresExports,
FilterScopes.Initialized, FilterScopes.Initialized,
) as WithName ) as WithName

View file

@ -1,40 +1,32 @@
import { isModuleInitialized } from '@revenge-mod/modules/metro/utils'
import { getErrorStack } from '@revenge-mod/utils/error' import { getErrorStack } from '@revenge-mod/utils/error'
import { withProps } from '.' import { withProps } from '.'
import { and, or } from './composite' import { and, or } from './composite'
import { FilterScopes } from './constants' import { FilterScopes } from './constants'
import type { Metro } from '@revenge-mod/modules/types' import type { Metro } from '@revenge-mod/modules/types'
import type { If, LogicalAnd } from '@revenge-mod/utils/types'
import type { import type {
DefaultFilterInfo, DefaultFilterInfo,
FilterFlag,
FilterInfo, FilterInfo,
FilterScope, FilterScope,
FilterScopes as FilterScopesObject,
FilterScopeValue, FilterScopeValue,
} from './constants' } from './constants'
export type FilterResult<F> = F extends Filter<infer I> ? I['Result'] : never export type FilterResult<F> = F extends Filter<infer I> ? I['Result'] : never
export type FilterRequiresExports<F> =
F extends Filter<infer I> ? I['RequiresExports'] : never
export type FilterInfoOf<F> = F extends Filter<infer I> ? I : FilterInfo export type FilterInfoOf<F> = F extends Filter<infer I> ? I : FilterInfo
declare const FilterInfoBrand: unique symbol
export interface FilterBase<Info extends FilterInfo = DefaultFilterInfo> { export interface FilterBase<Info extends FilterInfo = DefaultFilterInfo> {
( (
...args: If< id: Metro.ModuleID,
Info['RequiresExports'], exports?: Metro.ModuleExports,
[id: Metro.ModuleID, exports: Metro.ModuleExports], initialized?: boolean,
[id: Metro.ModuleID, exports?: never]
>
): boolean ): boolean
key: string key: string
flags: If<
Info['RequiresExports'],
(typeof FilterFlag)['RequiresExports'],
FilterFlag
>
scopes: FilterScopeValue scopes: FilterScopeValue
/** @internal */
readonly [FilterInfoBrand]?: Info
} }
export type Filter<Info extends FilterInfo = DefaultFilterInfo> = export type Filter<Info extends FilterInfo = DefaultFilterInfo> =
@ -42,13 +34,11 @@ export type Filter<Info extends FilterInfo = DefaultFilterInfo> =
export type MergeFilterInfo<I1 extends FilterInfo, I2 extends FilterInfo> = { export type MergeFilterInfo<I1 extends FilterInfo, I2 extends FilterInfo> = {
Result: I1['Result'] & I2['Result'] Result: I1['Result'] & I2['Result']
RequiresExports: LogicalAnd<I1['RequiresExports'], I2['RequiresExports']>
Scopes: [...I1['Scopes'], ...I2['Scopes']] Scopes: [...I1['Scopes'], ...I2['Scopes']]
} }
export type UnionFilterInfo<I1 extends FilterInfo, I2 extends FilterInfo> = { export type UnionFilterInfo<I1 extends FilterInfo, I2 extends FilterInfo> = {
Result: I1['Result'] | I2['Result'] Result: I1['Result'] | I2['Result']
RequiresExports: LogicalAnd<I1['RequiresExports'], I2['RequiresExports']>
Scopes: [...I1['Scopes'], ...I2['Scopes']] Scopes: [...I1['Scopes'], ...I2['Scopes']]
} }
@ -91,25 +81,12 @@ export interface FilterHelpers<Info extends FilterInfo = DefaultFilterInfo> {
*/ */
scope<T extends Filter<Info>, const S extends FilterScope[]>( scope<T extends Filter<Info>, const S extends FilterScope[]>(
this: T, this: T,
...scopes: If< ...scopes: S
Info['RequiresExports'], ): Filter<Info & { Scopes: S }>
[typeof FilterScopesObject.Initialized],
S
>
): Filter<
Info & {
Scopes: If<
Info['RequiresExports'],
[typeof FilterScopesObject.Initialized],
S
>
}
>
} }
export type FilterGenerator<G extends (...args: any[]) => Filter> = G & { export type FilterGenerator<G extends (...args: any[]) => Filter> = G & {
keyFor(args: Parameters<G>): string keyFor(args: Parameters<G>): string
flagsFor(args: Parameters<G>): FilterFlag
defaultScopesFor(args: Parameters<G>): FilterScopeValue defaultScopesFor(args: Parameters<G>): FilterScopeValue
} }
@ -129,7 +106,8 @@ const Helpers: FilterHelpers = Object.setPrototypeOf(
const newFilter = (( const newFilter = ((
id: Metro.ModuleID, id: Metro.ModuleID,
exports?: Metro.ModuleExports, exports?: Metro.ModuleExports,
) => this(id, exports)) as typeof this initialized?: boolean,
) => this(id, exports, initialized)) as typeof this
Object.assign(newFilter, this) Object.assign(newFilter, this)
@ -139,7 +117,9 @@ const Helpers: FilterHelpers = Object.setPrototypeOf(
const filter = this.new() const filter = this.new()
filter.scopes = 0 filter.scopes = 0
for (const scope of scopes) filter.scopes |= scope for (const scope of scopes) filter.scopes |= scope
return filter return filter as Filter<
FilterInfoOf<typeof filter> & { Scopes: typeof scopes }
>
}, },
} satisfies FilterHelpers, } satisfies FilterHelpers,
Function.prototype, Function.prototype,
@ -150,15 +130,14 @@ const Helpers: FilterHelpers = Object.setPrototypeOf(
* *
* @param filter The function that filters the modules. * @param filter The function that filters the modules.
* @param keyFor The function that generates the key for the filter. * @param keyFor The function that generates the key for the filter.
* @param flagFor The function that generates the flags for the filter, or a static flag.
* @param defaultScopesFor The function that generates the default scopes for the filter, or static scopes. Defaults to {@link FilterScopes.Initialized}. * @param defaultScopesFor The function that generates the default scopes for the filter, or static scopes. Defaults to {@link FilterScopes.Initialized}.
* @returns A function that generates a filter with the specified arguments. * @returns A function that generates a filter with the specified arguments.
* *
* @example * @example
* ```ts * ```ts
* const custom = createFilterGenerator<[arg1: number, arg2: string]>( * const custom = createFilterGenerator<[arg1: number, arg2: string]>(
* ([arg1, arg2], id, exports) => { * ([arg1, arg2], id, exports, initialized) => {
* // WARNING: exports can be Proxy or nullish, so be careful when using it * // WARNING: exports can be a Proxy, nullish, or a primitive, so be careful when using it
* // filter logic * // filter logic
* return true * return true
* }, * },
@ -173,16 +152,15 @@ export function createFilterGenerator<A extends any[]>(
args: A, args: A,
id: Metro.ModuleID, id: Metro.ModuleID,
exports: Metro.ModuleExports, exports: Metro.ModuleExports,
initialized: boolean,
) => boolean, ) => boolean,
keyFor: (args: A) => string, keyFor: (args: A) => string,
flagFor: ((args: A) => FilterFlag) | FilterFlag,
defaultScopesFor?: ((args: A) => FilterScopeValue) | FilterScopeValue, defaultScopesFor?: ((args: A) => FilterScopeValue) | FilterScopeValue,
): FilterGenerator<(...args: A) => Filter> ): FilterGenerator<(...args: A) => Filter>
export function createFilterGenerator<A extends any[]>( export function createFilterGenerator<A extends any[]>(
filter: (args: A, id: Metro.ModuleID) => boolean, filter: (args: A, id: Metro.ModuleID) => boolean,
keyFor: (args: A) => string, keyFor: (args: A) => string,
flagFor: ((args: A) => FilterFlag) | FilterFlag,
defaultScopesFor?: ((args: A) => FilterScopeValue) | FilterScopeValue, defaultScopesFor?: ((args: A) => FilterScopeValue) | FilterScopeValue,
): FilterGenerator<(...args: A) => Filter> ): FilterGenerator<(...args: A) => Filter>
@ -190,16 +168,14 @@ export function createFilterGenerator<A extends any[]>(
filter: ( filter: (
args: A, args: A,
id: Metro.ModuleID, id: Metro.ModuleID,
exports?: Metro.ModuleExports, exports: Metro.ModuleExports,
initialized: boolean,
) => boolean, ) => boolean,
keyFor: (args: A) => string, keyFor: (args: A) => string,
flagFor: ((args: A) => FilterFlag) | FilterFlag,
defaultScopesFor?: ((args: A) => FilterScopeValue) | FilterScopeValue, defaultScopesFor?: ((args: A) => FilterScopeValue) | FilterScopeValue,
): FilterGenerator<(...args: A) => Filter> { ): FilterGenerator<(...args: A) => Filter> {
type GeneratorType = ReturnType<typeof createFilterGenerator<A>> type GeneratorType = ReturnType<typeof createFilterGenerator<A>>
const isFlagsStatic = typeof flagFor === 'number'
const defaultScopes = defaultScopesFor ?? FilterScopes.Initialized const defaultScopes = defaultScopesFor ?? FilterScopes.Initialized
const isDefaultScopesStatic = typeof defaultScopes === 'number' const isDefaultScopesStatic = typeof defaultScopes === 'number'
@ -207,9 +183,10 @@ export function createFilterGenerator<A extends any[]>(
const filter_ = (( const filter_ = ((
id: Metro.ModuleID, id: Metro.ModuleID,
exports?: Metro.ModuleExports, exports?: Metro.ModuleExports,
initialized: boolean = Boolean(isModuleInitialized(id)),
) => { ) => {
try { try {
return filter(args, id, exports) return filter(args, id, exports, initialized)
} catch (e) { } catch (e) {
DEBUG_warnFilterThrown(id, keyFor(args), e) DEBUG_warnFilterThrown(id, keyFor(args), e)
return false return false
@ -217,17 +194,12 @@ export function createFilterGenerator<A extends any[]>(
}) as ReturnType<GeneratorType> }) as ReturnType<GeneratorType>
filter_.key = keyFor(args) filter_.key = keyFor(args)
filter_.flags = isFlagsStatic ? flagFor : flagFor(args)
filter_.scopes = isDefaultScopesStatic filter_.scopes = isDefaultScopesStatic
? defaultScopes ? defaultScopes
: defaultScopes(args) : defaultScopes(args)
return Object.setPrototypeOf(filter_, Helpers) return Object.setPrototypeOf(filter_, Helpers)
} }
generator.flagsFor = isFlagsStatic
? () => flagFor
: (args: A) => flagFor(args)
generator.defaultScopesFor = isDefaultScopesStatic generator.defaultScopesFor = isDefaultScopesStatic
? () => defaultScopes ? () => defaultScopes
: defaultScopes : defaultScopes

View file

@ -183,11 +183,19 @@ export function* lookupModules(filter: Filter, options?: LookupModulesOptions) {
} }
if (includeUninit) if (includeUninit)
for (const id of mUninitialized) { // Must snapshot because partial matches can cause modules that haven't been looped over to be initialized
// which would remove them from the Set
for (const id of Array.from(mUninitialized)) {
// biome-ignore lint/complexity/useOptionalChain: Hot path should be optimized // biome-ignore lint/complexity/useOptionalChain: Hot path should be optimized
if (cached && cached.has(id)) continue if (cached && cached.has(id)) continue
const flag = runFilter(filter, id, undefined, options) const flag = runFilter(
filter,
id,
// Could be initialized by a previous partial match
getInitializedModuleExports(id),
options,
)
if (flag) { if (flag) {
notFound = false notFound = false
@ -308,8 +316,15 @@ export function lookupModule(filter: Filter, options?: LookupModulesOptions) {
} }
if (includeUninit) if (includeUninit)
for (const id of mUninitialized) { // See equivalent code in `lookupModules` for why this is snapshotted
const flag = runFilter(filter, id, undefined, options) for (const id of [...mUninitialized]) {
const flag = runFilter(
filter,
id,
// See equivalent code in `lookupModules` for why this isn't undefined
getInitializedModuleExports(id),
options,
)
if (flag) { if (flag) {
if (__BUILD_FLAG_DEBUG_MODULE_LOOKUPS__) if (__BUILD_FLAG_DEBUG_MODULE_LOOKUPS__)
DEBUG_logLookupMatched(filter.key, flag, id) DEBUG_logLookupMatched(filter.key, flag, id)

View file

@ -8,7 +8,7 @@
import { callNativeMethodSync } from '@revenge-mod/modules/native' import { callNativeMethodSync } from '@revenge-mod/modules/native'
import { getErrorStack } from '@revenge-mod/utils/error' import { getErrorStack } from '@revenge-mod/utils/error'
import { FullVersion } from '~constants' import { FullVersion } from '~constants'
import { loadModuleFromSegment, mInitializingId, mList } from './patches' import { loadModuleFromSegment, mList } from './patches'
import { import {
executeInitializeSubscriptions, executeInitializeSubscriptions,
executeRequireSubscriptions, executeRequireSubscriptions,
@ -62,7 +62,7 @@ export const metroRequire = (moduleId => {
mod.flags = (flags & NotInitializedOrInitializingMask) | HasError mod.flags = (flags & NotInitializedOrInitializingMask) | HasError
mod.error = e mod.error = e
const msg = `Module ${mInitializingId} failed to initialize:\n\n${getErrorStack(e)}` const msg = `Module ${moduleId} failed to initialize:\n\n${getErrorStack(e)}`
if (__DEV__) { if (__DEV__) {
callNativeMethodSync('revenge.alertError', [msg, FullVersion]) callNativeMethodSync('revenge.alertError', [msg, FullVersion])
@ -71,7 +71,7 @@ export const metroRequire = (moduleId => {
} }
// Some modifications can cause modules to fail by initializing them in the wrong order, we can't just blacklist them // Some modifications can cause modules to fail by initializing them in the wrong order, we can't just blacklist them
// cacheBlacklistedModule(mInitializingId) // cacheBlacklistedModule(moduleId)
// So... it wasn't a great idea to throw, Discord has pushed a broken build that has some failing modules // So... it wasn't a great idea to throw, Discord has pushed a broken build that has some failing modules
// Vanilla Metro would swallow the error and just return an empty object as the exports..., insanity // Vanilla Metro would swallow the error and just return an empty object as the exports..., insanity

View file

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

View file

@ -7,42 +7,50 @@ import {
withDependencies, withDependencies,
} from '@revenge-mod/modules/finders/filters' } from '@revenge-mod/modules/finders/filters'
import { isModuleExportBad } from '@revenge-mod/modules/metro/utils' import { isModuleExportBad } from '@revenge-mod/modules/metro/utils'
import { import { FilterScopes } from '../../modules/src/finders/filters/constants'
ReactJSXRuntimeModuleId,
ReactModuleId,
ReactNativeModuleId,
} from '@revenge-mod/react'
import {
FilterFlag,
FilterScopes,
} from '../../modules/src/finders/filters/constants'
import type { import type {
Filter, Filter,
FilterGenerator, FilterGenerator,
} from '@revenge-mod/modules/finders/filters' } from '@revenge-mod/modules/finders/filters'
import type { Metro } from '@revenge-mod/modules/types'
import type { FC } from 'react' import type { FC } from 'react'
// [React, ReactJsxRuntime, (Tokens), (BaseIconImage), (Asset), ImportTracker] const { last } = withDependencies
// Asset = relativeDep(1)
const IconComponentFilter = [
ReactModuleId,
ReactJSXRuntimeModuleId,
TokensModuleId,
null,
null,
ImportTrackerModuleId,
]
// Generated IconComponents that have multiple assets, there is no specific length, so the filter has to be generated on-the-fly. const depsFilters: Record<string, Filter> = {}
// [React, ReactNative, ReactJsxRuntime, (Tokens), (BaseIconImage), (...Assets), ImportTracker]
// ...Assets = relativeDep(1..n) /**
const MultiIconComponentFilterBase = [ * Builds a dependency filter for an icon component.
ReactModuleId, *
ReactNativeModuleId, * `[React, (ReactNative), ReactJsxRuntime, Tokens, (BaseIconImage), (...Assets), ImportTracker]`
ReactJSXRuntimeModuleId, *
TokensModuleId, * `ReactNative` is only present on components with multiple assets, and it sits right after `React`,
null, * so everything from `ReactJsxRuntime` onwards is at a fixed distance from the end on both shapes.
] *
* @param names The component name, then the asset names if the component has multiple assets.
* @returns The filter, or `undefined` if any of the assets aren't registered yet.
*/
function depsFilterFor(names: string[]) {
const key = names.join(',')
const cached = depsFilters[key]
if (cached) return cached
// Components with multiple assets are named separately from their assets
const assets = names.length > 1 ? names.slice(1) : names
const mids: Metro.ModuleID[] = []
for (const name of assets) {
const mid = getAssetByName(name)?.moduleId
// Module ID can never be zero, so if it's falsy, it means the asset was not found.
if (!mid) return
mids.push(mid)
}
return (depsFilters[key] = withDependencies(
last([TokensModuleId, null, ...mids, ImportTrackerModuleId]),
))
}
export type WithGeneratedIconComponent = FilterGenerator< export type WithGeneratedIconComponent = FilterGenerator<
<N extends string>( <N extends string>(
@ -50,7 +58,6 @@ export type WithGeneratedIconComponent = FilterGenerator<
...assets: string[] ...assets: string[]
) => Filter<{ ) => Filter<{
Result: { [K in N]: FC<any> } Result: { [K in N]: FC<any> }
RequiresExports: boolean
Scopes: [ Scopes: [
typeof FilterScopes.Uninitialized, typeof FilterScopes.Uninitialized,
typeof FilterScopes.Initialized, typeof FilterScopes.Initialized,
@ -86,47 +93,13 @@ export type WithGeneratedIconComponent = FilterGenerator<
export const withGeneratedIconComponent = createFilterGenerator< export const withGeneratedIconComponent = createFilterGenerator<
Parameters<WithGeneratedIconComponent> Parameters<WithGeneratedIconComponent>
>( >(
(names, id, exports) => { (names, id, exports, initialized) => {
if (typeof exports === 'object' && isModuleExportBad(exports)) { // Icon components always export their component, so unusable exports can never match
if (typeof exports[names[0]] === 'function') if (initialized && isModuleExportBad(exports)) return false
return Object.keys(exports).length === 1
} else {
let filter = IconComponentFilter
if (names.length > 1) { return depsFilterFor(names)?.(id, exports, initialized) ?? false
const mids = []
for (let i = 1; i < names.length; i++) {
const name = names[i]
const mid = getAssetByName(name)?.moduleId
// Module ID can never be zero, so if it's falsy, it means the asset was not found.
if (!mid) return false
mids.push(mid)
}
filter = [
...MultiIconComponentFilterBase,
...mids,
ImportTrackerModuleId,
]
} else {
const [name] = names
const mid = getAssetByName(name)?.moduleId
// Module ID can never be zero, so if it's falsy, it means the asset was not found.
if (!mid) return false
IconComponentFilter[4] = mid
}
return withDependencies(filter)(id, exports)
}
return false
}, },
names => `revenge.utils.discord.generatedIconComponent(${names.join(',')})`, names => `revenge.utils.discord.generatedIconComponent(${names.join(',')})`,
FilterFlag.Dynamic,
FilterScopes.Uninitialized | FilterScopes.Initialized, FilterScopes.Uninitialized | FilterScopes.Initialized,
) as WithGeneratedIconComponent ) as WithGeneratedIconComponent

View file

@ -79,7 +79,8 @@ export default async function build(dev = Dev, log = true) {
.trim(), .trim(),
), ),
__DEV__: String(dev), __DEV__: String(dev),
// __BUILD_DEFAULT_PLUGIN_REPOSITORY_URL__: ..., __BUILD_DEFAULT_PLUGIN_REPOSITORY_URL__: 'undefined',
__BUILD_DONATE_URL__: 'undefined',
// See types/build.d.ts for what these flags do // See types/build.d.ts for what these flags do
__BUILD_FLAG_DEBUG_MODULE_LOOKUPS__: String(dev), __BUILD_FLAG_DEBUG_MODULE_LOOKUPS__: String(dev),

Binary file not shown.

After

Width:  |  Height:  |  Size: 14 KiB

View file

@ -21,11 +21,7 @@ import {
} from '../utils/sheets' } from '../utils/sheets'
import { PluginIcon } from './PluginIcon' import { PluginIcon } from './PluginIcon'
import { usePluginEnabled } from './PluginStateProvider' import { usePluginEnabled } from './PluginStateProvider'
import { import { PluginTooltip, usePluginTooltip } from './TooltipProvider'
useClickOutsideTooltip,
useEnablePluginTooltip,
useEssentialPluginTooltip,
} from './TooltipProvider'
import type { AnyPlugin, InternalPluginMeta } from '@revenge-mod/plugins/_' import type { AnyPlugin, InternalPluginMeta } from '@revenge-mod/plugins/_'
import type { RepoPluginListing } from '@revenge-mod/plugins/_/repositories' import type { RepoPluginListing } from '@revenge-mod/plugins/_/repositories'
@ -152,14 +148,12 @@ export const InstalledPluginCard = memo(function InstalledPluginCard({
const toggleDisabled = essential || pendingUpdate const toggleDisabled = essential || pendingUpdate
const enableTooltip = useEnablePluginTooltip() const [settingsRef, showEnableTooltip] = usePluginTooltip(
const essentialTooltip = useEssentialPluginTooltip() PluginTooltip.Enable,
)
const settingsRef = useClickOutsideTooltip(useEnablePluginTooltip, () => {}) const [switchRef, showToggleTooltip] = usePluginTooltip(
essential ? PluginTooltip.Essential : PluginTooltip.PendingUpdate,
const switchRef = useClickOutsideTooltip(
useEssentialPluginTooltip,
() => {},
) )
return ( return (
@ -182,12 +176,7 @@ export const InstalledPluginCard = memo(function InstalledPluginCard({
{plugin.SettingsComponent && ( {plugin.SettingsComponent && (
<Pressable <Pressable
onPress={() => { onPress={() => {
if (!startable) if (!startable) showEnableTooltip()
requestAnimationFrame(() => {
enableTooltip.targetRef.current =
settingsRef.current
enableTooltip.setVisible(true)
})
}} }}
> >
<IconButton <IconButton
@ -204,12 +193,7 @@ export const InstalledPluginCard = memo(function InstalledPluginCard({
)} )}
<Pressable <Pressable
onPress={() => { onPress={() => {
if (toggleDisabled) if (toggleDisabled) showToggleTooltip()
requestAnimationFrame(() => {
essentialTooltip.targetRef.current =
switchRef.current
essentialTooltip.setVisible(true)
})
}} }}
ref={switchRef} ref={switchRef}
> >

View file

@ -9,7 +9,7 @@ import {
PLUGIN_CARD_HALF_GUTTER, PLUGIN_CARD_HALF_GUTTER,
PluginCard, PluginCard,
} from './PluginCard' } from './PluginCard'
import { useResetTooltips } from './TooltipProvider' import { useHidePluginTooltips } from './TooltipProvider'
import type { AnyPlugin, InternalPluginMeta } from '@revenge-mod/plugins/_' import type { AnyPlugin, InternalPluginMeta } from '@revenge-mod/plugins/_'
import type { RepoPluginListing } from '@revenge-mod/plugins/_/repositories' import type { RepoPluginListing } from '@revenge-mod/plugins/_/repositories'
import type { FlashListProps } from '@shopify/flash-list' import type { FlashListProps } from '@shopify/flash-list'
@ -25,14 +25,14 @@ export function PluginFlashList({
FlashListProps<AnyPlugin>, FlashListProps<AnyPlugin>,
'onContentSizeChange' 'onContentSizeChange'
>) { >) {
const resetTooltips = useResetTooltips() const hideTooltips = useHidePluginTooltips()
return ( return (
<FlashList <FlashList
style={gutterCompensation} style={gutterCompensation}
onContentSizeChange={onContentSizeChange} onContentSizeChange={onContentSizeChange}
data={plugins} data={plugins}
onScrollBeginDrag={resetTooltips} onScrollBeginDrag={hideTooltips}
fadingEdgeLength={plugins.length === 1 ? 0 : 16} fadingEdgeLength={plugins.length === 1 ? 0 : 16}
keyExtractor={plugin => plugin.manifest.id} keyExtractor={plugin => plugin.manifest.id}
renderItem={({ renderItem={({
@ -60,7 +60,7 @@ export function InstalledPluginMasonryFlashList({
plugins: (readonly [AnyPlugin, InternalPluginMeta])[] plugins: (readonly [AnyPlugin, InternalPluginMeta])[]
}) { }) {
const numColumns = useNumColumns() const numColumns = useNumColumns()
const resetTooltips = useResetTooltips() const hideTooltips = useHidePluginTooltips()
return ( return (
<FlashList <FlashList
@ -69,7 +69,7 @@ export function InstalledPluginMasonryFlashList({
// FAB is 56px tall, plus 16px spacing on top and bottom // FAB is 56px tall, plus 16px spacing on top and bottom
contentContainerStyle={{ paddingBottom: 56 + 2 * 16 }} contentContainerStyle={{ paddingBottom: 56 + 2 * 16 }}
data={plugins} data={plugins}
onScrollBeginDrag={resetTooltips} onScrollBeginDrag={hideTooltips}
fadingEdgeLength={16} fadingEdgeLength={16}
keyExtractor={([plugin]) => plugin.manifest.id} keyExtractor={([plugin]) => plugin.manifest.id}
numColumns={numColumns} numColumns={numColumns}
@ -128,7 +128,7 @@ export function BrowsePluginMasonryFlashList({
) => void ) => void
}) { }) {
const numColumns = useNumColumns() const numColumns = useNumColumns()
const resetTooltips = useResetTooltips() const hideTooltips = useHidePluginTooltips()
return ( return (
<FlashList <FlashList
@ -136,7 +136,7 @@ export function BrowsePluginMasonryFlashList({
style={gutterCompensation} style={gutterCompensation}
contentContainerStyle={{ paddingBottom: 16 }} contentContainerStyle={{ paddingBottom: 16 }}
data={entries} data={entries}
onScrollBeginDrag={resetTooltips} onScrollBeginDrag={hideTooltips}
fadingEdgeLength={16} fadingEdgeLength={16}
keyExtractor={entry => entry.key} keyExtractor={entry => entry.key}
numColumns={numColumns} numColumns={numColumns}

View file

@ -37,10 +37,9 @@ import {
import { messageOf, showErrorToast } from '../utils/repos' import { 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 { import PluginTooltipsProvider, {
EnablePluginTooltipProvider, PluginTooltip,
useClickOutsideTooltip, usePluginTooltip,
useEnablePluginTooltip,
} from './TooltipProvider' } from './TooltipProvider'
import type { AnyPlugin } from '@revenge-mod/plugins/_' import type { AnyPlugin } from '@revenge-mod/plugins/_'
@ -61,23 +60,44 @@ export default function PluginOptionsActionSheet({
plugin, plugin,
sheetKey, sheetKey,
}: PluginOptionsActionSheetProps) { }: PluginOptionsActionSheetProps) {
return (
<ActionSheet>
<ClickOutsideProvider>
<PluginTooltipsProvider>
<PluginOptions plugin={plugin} sheetKey={sheetKey} />
</PluginTooltipsProvider>
</ClickOutsideProvider>
</ActionSheet>
)
}
function PluginOptions({ plugin, sheetKey }: PluginOptionsActionSheetProps) {
const enabled = usePluginEnabled(plugin) const enabled = usePluginEnabled(plugin)
const meta = getInternalPluginMeta(plugin) const meta = getInternalPluginMeta(plugin)
const essential = isPluginEssential(meta) const essential = isPluginEssential(meta)
const pendingUpdate = isPluginPendingUpdate(plugin) const pendingUpdate = isPluginPendingUpdate(plugin)
const { name, author, description, icon, version } = plugin.manifest const { name, author, description, icon, version } = plugin.manifest
const [switchRef, showPendingUpdateTooltip] = usePluginTooltip(
PluginTooltip.PendingUpdate,
)
return ( return (
<ActionSheet> <Stack spacing={24} style={{ paddingTop: 8 }}>
<Stack spacing={24} style={{ paddingTop: 8 }}> <PluginInfo
<PluginInfo name={name}
name={name} author={author}
author={author} version={formatVersion(version)}
version={formatVersion(version)} description={description}
description={description} icon={icon}
icon={icon} actions={
actions={ !essential && (
!essential && ( <Pressable
onPress={() => {
if (pendingUpdate) showPendingUpdateTooltip()
}}
ref={switchRef}
>
<InstalledPluginSwitch <InstalledPluginSwitch
enabled={enabled} enabled={enabled}
plugin={plugin} plugin={plugin}
@ -86,25 +106,19 @@ export default function PluginOptionsActionSheet({
)} )}
toggleDisabled={pendingUpdate} toggleDisabled={pendingUpdate}
/> />
) </Pressable>
} )
/> }
<ClickOutsideProvider> />
<EnablePluginTooltipProvider> <PluginActions
<PluginActions plugin={plugin}
plugin={plugin} closeSheet={() => {
closeSheet={() => { ActionSheetActionCreators.hideActionSheet(sheetKey)
ActionSheetActionCreators.hideActionSheet( }}
sheetKey, />
) <StatusSection plugin={plugin} />
}} <AdvancedSection plugin={plugin} />
/> </Stack>
</EnablePluginTooltipProvider>
</ClickOutsideProvider>
<StatusSection plugin={plugin} />
<AdvancedSection plugin={plugin} />
</Stack>
</ActionSheet>
) )
} }
@ -292,8 +306,9 @@ function PluginActions({
plugin: AnyPlugin plugin: AnyPlugin
closeSheet: () => void closeSheet: () => void
}) { }) {
const enableTooltip = useEnablePluginTooltip() const [settingsRef, showEnableTooltip] = usePluginTooltip(
const settingsRef = useClickOutsideTooltip(useEnablePluginTooltip, () => {}) PluginTooltip.Enable,
)
const meta = getInternalPluginMeta(plugin) const meta = getInternalPluginMeta(plugin)
const startable = isPluginStartable(plugin) const startable = isPluginStartable(plugin)
const enabled = usePluginEnabled(plugin) const enabled = usePluginEnabled(plugin)
@ -347,12 +362,7 @@ function PluginActions({
{plugin.SettingsComponent && ( {plugin.SettingsComponent && (
<Pressable <Pressable
onPress={() => { onPress={() => {
if (!startable) if (!startable) showEnableTooltip()
requestAnimationFrame(() => {
enableTooltip.targetRef.current =
settingsRef.current
enableTooltip.setVisible(true)
})
}} }}
> >
<IconButton <IconButton

View file

@ -1,109 +1,134 @@
import { Design } from '@revenge-mod/discord/design' import { Design } from '@revenge-mod/discord/design'
import { createContext, useContext, useRef, useState } from 'react' import {
import { useClickOutside } from 'react-native-click-outside' createContext,
import type { RefObject } from 'react' useCallback,
useContext,
useMemo,
useRef,
useState,
} from 'react'
import {
ClickOutsideProvider,
useClickOutside,
} from 'react-native-click-outside'
import type { View } from 'react-native' import type { View } from 'react-native'
const { useTooltip } = Design const { useTooltip } = Design
interface TooltipContextValue { export const PluginTooltip = {
targetRef: RefObject<View | null> /** Shown when an action requires the plugin to be enabled first. */
setVisible: (visible: boolean) => void Enable: 'enable',
/** Shown when an action is blocked because the plugin is essential. */
Essential: 'essential',
/** Shown when an action is blocked because the plugin has a pending update. */
PendingUpdate: 'pendingUpdate',
} as const
export type PluginTooltip = (typeof PluginTooltip)[keyof typeof PluginTooltip]
const Labels: Record<PluginTooltip, string> = {
[PluginTooltip.Enable]: 'Plugin must be enabled first',
[PluginTooltip.Essential]: 'Plugin needed for Revenge to function properly',
[PluginTooltip.PendingUpdate]:
'Plugin has a pending update, reload to apply it',
} }
const EnablePluginTooltipContext = createContext<TooltipContextValue | null>( interface PluginTooltipsContextValue {
null, show: (tooltip: PluginTooltip, target: View | null) => void
) hide: () => void
const EssentialPluginTooltipContext = createContext<TooltipContextValue | null>( }
const PluginTooltipsContext = createContext<PluginTooltipsContextValue | null>(
null, null,
) )
export function EnablePluginTooltipProvider({ export default function PluginTooltipsProvider({
children, children,
}: { }: {
children: React.ReactNode children: React.ReactNode
}) { }) {
const [visible, setVisible] = useState(false) const [visible, setVisible] = useState<PluginTooltip | null>(null)
const targetRef = useRef<View | null>(null)
useTooltip(targetRef, { // Targets are assigned by `show`, right before the tooltip becomes visible
label: 'Plugin must be enabled first', const enableTarget = useRef<View | null>(null)
const essentialTarget = useRef<View | null>(null)
const pendingUpdateTarget = useRef<View | null>(null)
useTooltip(enableTarget, {
label: Labels[PluginTooltip.Enable],
position: 'top', position: 'top',
visible, visible: visible === PluginTooltip.Enable,
}) })
return ( useTooltip(essentialTarget, {
<EnablePluginTooltipContext.Provider value={{ targetRef, setVisible }}> label: Labels[PluginTooltip.Essential],
{children}
</EnablePluginTooltipContext.Provider>
)
}
export function EssentialPluginTooltipProvider({
children,
}: {
children: React.ReactNode
}) {
const [visible, setVisible] = useState(false)
const targetRef = useRef<View | null>(null)
useTooltip(targetRef, {
label: 'Plugin needed for Revenge to function properly',
position: 'top', position: 'top',
visible, visible: visible === PluginTooltip.Essential,
}) })
return ( useTooltip(pendingUpdateTarget, {
<EssentialPluginTooltipContext.Provider label: Labels[PluginTooltip.PendingUpdate],
value={{ targetRef, setVisible }} position: 'top',
> visible: visible === PluginTooltip.PendingUpdate,
{children} })
</EssentialPluginTooltipContext.Provider>
)
}
export function useEnablePluginTooltip() { const value = useMemo<PluginTooltipsContextValue>(() => {
const context = useContext(EnablePluginTooltipContext) const targets = {
if (!context) { [PluginTooltip.Enable]: enableTarget,
throw new Error( [PluginTooltip.Essential]: essentialTarget,
'useEnablePluginTooltip must be used within EnablePluginTooltipProvider', [PluginTooltip.PendingUpdate]: pendingUpdateTarget,
)
}
return context
}
export function useEssentialPluginTooltip() {
const context = useContext(EssentialPluginTooltipContext)
if (!context) {
throw new Error(
'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 return {
show: (tooltip, target) => {
targets[tooltip].current = target
setVisible(tooltip)
},
hide: () => setVisible(null),
}
}, [])
return (
<ClickOutsideProvider>
<PluginTooltipsContext.Provider value={value}>
{children}
</PluginTooltipsContext.Provider>
</ClickOutsideProvider>
)
}
/**
* Binds an element to a plugin tooltip.
*
* @param tooltip The tooltip to show for the element.
* @returns The ref to attach to the element, and a callback showing the tooltip on it.
*/
export function usePluginTooltip(tooltip: PluginTooltip) {
const context = useContext(PluginTooltipsContext)
if (!context)
throw new Error(
'usePluginTooltip must be used within PluginTooltipsProvider',
)
const { show, hide } = 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 useHidePluginTooltips() {
const context = useContext(PluginTooltipsContext)
return useCallback(() => context?.hide(), [context])
} }

View file

@ -22,7 +22,7 @@ export interface Storage {
defaultRepoRestored?: boolean defaultRepoRestored?: boolean
} }
const OUTDATED_THRESHOLD = 24 * 60 * 60 * 1000 // 1 day const OUTDATED_THRESHOLD = 60 * 60 * 1000 // 1 hour
const AUTO_UPDATE_CHECK_DELAY = 10_000 const AUTO_UPDATE_CHECK_DELAY = 10_000
const CircleXIconComponent = lookupGeneratedIconComponent( const CircleXIconComponent = lookupGeneratedIconComponent(

View file

@ -13,13 +13,9 @@ import {
import { debounce } from '@revenge-mod/utils/callback' import { debounce } from '@revenge-mod/utils/callback'
import { useCallback, useEffect, useMemo, useState } from 'react' import { useCallback, useEffect, useMemo, useState } from 'react'
import { View } from 'react-native' import { View } from 'react-native'
import { ClickOutsideProvider } from 'react-native-click-outside'
import { BrowsePluginMasonryFlashList } from '../components/PluginList' import { BrowsePluginMasonryFlashList } from '../components/PluginList'
import PluginStatesProvider from '../components/PluginStateProvider' import PluginStatesProvider from '../components/PluginStateProvider'
import { import PluginTooltipsProvider from '../components/TooltipProvider'
EnablePluginTooltipProvider,
EssentialPluginTooltipProvider,
} from '../components/TooltipProvider'
import { runInstallFlow } from '../utils/repos' import { runInstallFlow } from '../utils/repos'
import type { RepoPluginListing } from '@revenge-mod/plugins/_/repositories' import type { RepoPluginListing } from '@revenge-mod/plugins/_/repositories'
import type { BrowseSortKey } from '../components/BrowseFilterAndSortActionSheet' import type { BrowseSortKey } from '../components/BrowseFilterAndSortActionSheet'
@ -34,17 +30,13 @@ const SearchDebounceTime = 100
export default function RevengePluginsBrowseSettingScreen() { export default function RevengePluginsBrowseSettingScreen() {
return ( return (
<LayerScope> <LayerScope>
<ClickOutsideProvider> <PluginStatesProvider>
<PluginStatesProvider> <Page spacing={16}>
<Page spacing={16}> <PluginTooltipsProvider>
<EssentialPluginTooltipProvider> <Screen />
<EnablePluginTooltipProvider> </PluginTooltipsProvider>
<Screen /> </Page>
</EnablePluginTooltipProvider> </PluginStatesProvider>
</EssentialPluginTooltipProvider>
</Page>
</PluginStatesProvider>
</ClickOutsideProvider>
</LayerScope> </LayerScope>
) )
} }

View file

@ -28,14 +28,10 @@ import {
useState, useState,
} from 'react' } from 'react'
import { Image, View } from 'react-native' import { Image, View } from 'react-native'
import { ClickOutsideProvider } from 'react-native-click-outside'
import RevengeIcon from '~assets/RevengeIcon' import RevengeIcon from '~assets/RevengeIcon'
import { InstalledPluginMasonryFlashList } from '../components/PluginList' import { InstalledPluginMasonryFlashList } from '../components/PluginList'
import PluginStatesProvider from '../components/PluginStateProvider' import PluginStatesProvider from '../components/PluginStateProvider'
import { import PluginTooltipsProvider from '../components/TooltipProvider'
EnablePluginTooltipProvider,
EssentialPluginTooltipProvider,
} from '../components/TooltipProvider'
import { RouteNames, Setting } from '../constants' import { RouteNames, Setting } from '../constants'
import type { NavigationProp, RouteProp } from '@react-navigation/core' import type { NavigationProp, RouteProp } from '@react-navigation/core'
import type { ReactNavigationParamList } from '@revenge-mod/externals/react-navigation' import type { ReactNavigationParamList } from '@revenge-mod/externals/react-navigation'
@ -51,17 +47,13 @@ const PlusLargeIcon = getAssetIdByName('PlusLargeIcon')!
export default function RevengePluginsSettingScreen() { export default function RevengePluginsSettingScreen() {
return ( return (
<LayerScope> <LayerScope>
<ClickOutsideProvider> <PluginStatesProvider>
<PluginStatesProvider> <PluginTooltipsProvider>
<Page spacing={16}> <Page spacing={16}>
<EssentialPluginTooltipProvider> <Screen />
<EnablePluginTooltipProvider>
<Screen />
</EnablePluginTooltipProvider>
</EssentialPluginTooltipProvider>
</Page> </Page>
</PluginStatesProvider> </PluginTooltipsProvider>
</ClickOutsideProvider> </PluginStatesProvider>
</LayerScope> </LayerScope>
) )
} }

View file

@ -1,39 +1,139 @@
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 (
<SettingListRenderer.SettingsList <ScrollView style={{ flex: 1 }}>
node={{ <Stack spacing={0}>
type: 'list', <View style={{ padding: 16, paddingBottom: 0 }}>
sections: [ <DonateCard />
{ </View>
label: 'Revenge', <View>
settings: [ <SettingListRenderer.SettingsList
Setting.RevengeVersion, node={{
Setting.LoaderVersion, type: 'list',
Setting.RevengeDiscord, sections: [
Setting.RevengeSourceRepository, {
Setting.RevengeLicense, label: 'Revenge',
], settings: [
}, Setting.RevengeVersion,
{ Setting.LoaderVersion,
label: 'Versions', Setting.RevengeDiscord,
settings: [ Setting.RevengeSourceRepository,
Setting.ReactVersion, Setting.RevengeLicense,
Setting.ReactNativeVersion, ],
Setting.HermesVersion, },
], {
}, label: 'Versions',
{ settings: [
label: 'Actions', Setting.ReactVersion,
settings: [ Setting.ReactNativeVersion,
Setting.Reload, Setting.HermesVersion,
Setting.RevengeEnterRecoveryMode, ],
], },
}, {
], 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,
experimental_backgroundImage:
'linear-gradient(166deg, rgba(255, 131, 29, 0.33), rgba(145, 47, 47, 0)), ' +
'linear-gradient(45deg, rgba(145, 47, 47, 0), rgba(255, 22, 22, 0.5))',
},
]}
/>
<View style={{ padding: 16 }}>
<Stack style={styles.grow} direction="horizontal" spacing={16}>
<Stack spacing={16} style={styles.flex}>
<Stack spacing={6} style={styles.flex}>
<Text
variant="heading-lg/semibold"
color="text-strong"
>
Support Revenge
</Text>
<Text variant="text-md/medium">
You can support the development of Revenge by
donating or contributing!
</Text>
</Stack>
<Stack spacing={12} direction="horizontal">
{__BUILD_DONATE_URL__ && (
<Button
size="sm"
icon={getAssetIdByName('HeartIcon')}
text="Donate"
variant="expressive"
onPress={() => {
Linking.openURL(__BUILD_DONATE_URL__)
}}
/>
)}
<Button
size="sm"
icon={getAssetIdByName('HandRequestSpeakIcon')}
text="Contribute"
variant="primary-overlay"
onPress={() => {
Linking.openURL(
__BUILD_SOURCE_REPOSITORY_URL__,
)
}}
/>
</Stack>
</Stack>
<Image
source={RevengeDonate}
style={cardStyles.illustration}
/>
</Stack>
</View>
</Card>
) )
} }

1
types/build.d.ts vendored
View file

@ -3,6 +3,7 @@
declare global { declare global {
const __DEV__: boolean const __DEV__: boolean
const __BUILD_DISCORD_SERVER_URL__: string const __BUILD_DISCORD_SERVER_URL__: string
const __BUILD_DONATE_URL__: string | undefined
const __BUILD_SOURCE_REPOSITORY_URL__: string const __BUILD_SOURCE_REPOSITORY_URL__: string
const __BUILD_DEFAULT_PLUGIN_REPOSITORY_URL__: string | undefined const __BUILD_DEFAULT_PLUGIN_REPOSITORY_URL__: string | undefined
const __BUILD_LICENSE_URL__: string const __BUILD_LICENSE_URL__: string