diff --git a/README.md b/README.md
index fc15b73..28dc06d 100644
--- a/README.md
+++ b/README.md
@@ -120,3 +120,5 @@ bun run types
Bundlers will need to map imports to property access on `revenge` turning `kebab-case` and `snake_case` to `camelCase`.
Example: `@revenge-mod/discord/modules/main_tabs_v2` to `revenge.discord.modules.mainTabsV2`
+
+Exception for `@revenge-mod/externals` which property accesses should turn into `PascalCase` instead of `camelCase`.
diff --git a/lib/discord/src/design.ts b/lib/discord/src/design.ts
index d17f2e6..3c3a9d0 100644
--- a/lib/discord/src/design.ts
+++ b/lib/discord/src/design.ts
@@ -1,6 +1,5 @@
import { lookupModule, lookupModules } from '@revenge-mod/modules/finders'
import { withDependencies } from '@revenge-mod/modules/finders/filters'
-import { getModuleDependencies } from '@revenge-mod/modules/metro/utils'
import {
ReactJSXRuntimeModuleId,
ReactModuleId,
@@ -10,7 +9,7 @@ import { proxify } from '@revenge-mod/utils/proxy'
import { ImportTrackerModuleId } from './common/import-tracker'
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.
@@ -29,13 +28,12 @@ export let Design: Design = proxify(
// [3909, 4608, 5182, 13172, 2, ...] (141 dependencies)
const [, id] = lookupModule(
withDependencies(
- loose([
+ atLeast(DesignMinimumDependencies, [
[ImportTrackerModuleId],
[ImportTrackerModuleId],
[ReactNativeModuleId, ImportTrackerModuleId],
[ImportTrackerModuleId],
ImportTrackerModuleId,
- ...Array(DesignMinimumDependencies - 5).fill(null),
]),
).keyAs('revenge.discord.design.Design'),
{
@@ -55,9 +53,6 @@ export let Design: Design = proxify(
// design/components/Forms/native/FormSwitch.native.tsx
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(
withDependencies(
loose([
@@ -66,17 +61,25 @@ export let FormSwitch: DiscordModules.Components.FormSwitch = proxify(() => {
ReactNativeModuleId,
ReactJSXRuntimeModuleId,
]),
- ).keyAs('revenge.discord.design.FormSwitch'),
+ )
+ .and(
+ withDependencies(
+ last([
+ relative(1),
+ relative(2),
+ null,
+ null,
+ ImportTrackerModuleId,
+ ]),
+ ),
+ )
+ .keyAs('revenge.discord.design.FormSwitch'),
{
initialize: false,
},
)) {
- const deps = getModuleDependencies(id)!
- if (deps.at(-1) !== ImportTrackerModuleId) continue
- if (deps.at(-4) === id + 2 && deps.at(-5) === id + 1) {
- const FormSwitch_ = __r(id)!.FormSwitch
- if (FormSwitch_) return (FormSwitch = FormSwitch_)
- }
+ const FormSwitch_ = __r(id)!.FormSwitch
+ if (FormSwitch_) return (FormSwitch = FormSwitch_)
}
})!
diff --git a/lib/discord/src/flux/stores.ts b/lib/discord/src/flux/stores.ts
index 00eb471..727b388 100644
--- a/lib/discord/src/flux/stores.ts
+++ b/lib/discord/src/flux/stores.ts
@@ -1,3 +1,4 @@
+import { DispatcherModuleId } from '@revenge-mod/discord/common/flux'
import { ImportTrackerModuleId } from '@revenge-mod/discord/common/import-tracker'
import {
lookupModule,
@@ -7,9 +8,8 @@ import {
import {
createFilterGenerator,
withDependencies,
- withName,
} 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 {
cache,
@@ -17,10 +17,7 @@ import {
Uncached,
} from '../../../modules/src/caches'
import { FilterResultFlags } from '../../../modules/src/finders/_internal'
-import {
- FilterFlag,
- FilterScopes,
-} from '../../../modules/src/finders/filters/constants'
+import { FilterScopes } from '../../../modules/src/finders/filters/constants'
import type {
Filter,
FilterGenerator,
@@ -66,32 +63,16 @@ export function getStore(
/// STORE FILTERING
-/*
- Flux Store dependencies: [
- _classCallCheck
- _createClass
- _possibleConstructorReturn
- bound getPrototypeOf
- _inherits
- (...), // (any amount of dependencies)
- ImportTracker
- ]
-*/
+const { last, includes } = withDependencies
-const withLeadingFluxStoreDeps = withDependencies(
- withDependencies.loose([
- withName('_classCallCheck'),
- withName('_createClass'),
- withName('_possibleConstructorReturn'),
- withName('bound getPrototypeOf'),
- withName('_inherits'),
- ]),
+// The import tracker is checked first, as it is far cheaper than resolving includes
+const withFluxStoreDeps = withDependencies(last([ImportTrackerModuleId])).and(
+ withDependencies(includes([DispatcherModuleId])),
)
export type WithStore = FilterGenerator<
() => Filter<{
Result: DiscordModules.Flux.Store
- RequiresExports: boolean
Scopes: [
typeof FilterScopes.Uninitialized,
typeof FilterScopes.Initialized,
@@ -103,27 +84,22 @@ export type WithStore = FilterGenerator<
* A dynamic filter that matches all Flux stores.
*/
export const withStore = createFilterGenerator(
- (_, id, exports) => {
- if (exports) return Boolean(exports._dispatchToken)
- else {
- if (!withLeadingFluxStoreDeps(id)) return false
- const deps = getModuleDependencies(id)!
- return deps[deps.length - 1] === ImportTrackerModuleId
- }
+ (_, id, exports, initialized) => {
+ if (initialized)
+ return (
+ !isModuleExportBad(exports) && Boolean(exports._dispatchToken)
+ )
+
+ return withFluxStoreDeps(id, undefined, false)
},
() => 'revenge.discord.store',
- FilterFlag.Dynamic,
FilterScopes.Uninitialized | FilterScopes.Initialized,
) as WithStore
export type WithStoreName = FilterGenerator<
(name: string) => Filter<{
Result: DiscordModules.Flux.Store
- RequiresExports: true
- Scopes: [
- typeof FilterScopes.Uninitialized,
- typeof FilterScopes.Initialized,
- ]
+ Scopes: [typeof FilterScopes.Initialized]
}>
>
@@ -131,11 +107,21 @@ export type WithStoreName = FilterGenerator<
* A with-exports filter that matches a Flux store by its name.
*/
export const withStoreName = createFilterGenerator(
- ([name], _, exports) =>
- exports?.getName?.length === 0 && exports.getName() === name,
+ ([name], _, exports) => {
+ 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})`,
- FilterFlag.RequiresExports,
- FilterScopes.Uninitialized | FilterScopes.Initialized,
+ // Uninitialized to make sure cached results resolve immediately
+ FilterScopes.Initialized | FilterScopes.Uninitialized,
) as WithStoreName
/// STORE CACHING
@@ -155,5 +141,6 @@ if (cache === Uncached)
asap(() => {
const lookup = lookupModules(withStore())
- while (lookup.next().done);
+ // Initialize all stores
+ for (const _ of lookup);
})
diff --git a/lib/discord/src/types/index.ts b/lib/discord/src/types/index.ts
index bdea852..f87f343 100644
--- a/lib/discord/src/types/index.ts
+++ b/lib/discord/src/types/index.ts
@@ -263,6 +263,7 @@ export namespace DiscordModules {
| 'tertiary'
| 'destructive'
| 'active'
+ | 'expressive'
| 'primary-overlay'
| 'secondary-overlay'
loading?: boolean
diff --git a/lib/modules/src/finders/_internal.ts b/lib/modules/src/finders/_internal.ts
index 6266d69..88187d4 100644
--- a/lib/modules/src/finders/_internal.ts
+++ b/lib/modules/src/finders/_internal.ts
@@ -2,7 +2,7 @@ import { getCurrentStack } from '@revenge-mod/utils/error'
import { cacheFilterResultForId } from '../caches'
import { mInitialized } from '../metro/patches'
import { metroRequire } from '../metro/runtime'
-import { FilterFlag } from './filters'
+import { isModuleInitialized } from '../metro/utils'
import type { If } from '@revenge-mod/utils/types'
import type { Metro } from '../types'
import type { Filter } from './filters'
@@ -72,34 +72,14 @@ const noDefaultExportsCache = new Set()
// Currently, we only have options that are relevant for checking exports
// If we add more options later on, do NOT forget about
// 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(
filter: Filter,
id: Metro.ModuleID,
exports?: Metro.ModuleExports,
options?: RunFilterOptions,
): FilterResultFlag | undefined {
- if (exports === undefined) {
- if (filter.flags & FilterFlag.RequiresExports) return
-
- const filter_ = filter as Filter<{
- Result: any
- RequiresExports: false
- Scopes: any[]
- }>
-
- if (filter_(id)) {
+ if (!isModuleInitialized(id)) {
+ if (filter(id, undefined, false)) {
if (options?.initialize ?? true) {
const module = metroRequire(id)
// Check if the required module is not blacklisted
@@ -118,13 +98,7 @@ export function runFilter(
return
}
- const filter_ = filter as Filter<{
- Result: any
- RequiresExports: true
- Scopes: any[]
- }>
-
- if (filter_(id, exports))
+ if (filter(id, exports, true))
return cacheFilterResultForId(
filter.key,
id,
@@ -133,13 +107,15 @@ export function runFilter(
if (options?.skipDefault) 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)
return
}
const { default: defaultExport } = exports
- if (filter_(id, defaultExport))
+ if (filter(id, defaultExport, true))
return cacheFilterResultForId(filter.key, id, FilterResultFlags.Default)
}
diff --git a/lib/modules/src/finders/filters/composite.ts b/lib/modules/src/finders/filters/composite.ts
index 6bfe13c..571da96 100644
--- a/lib/modules/src/finders/filters/composite.ts
+++ b/lib/modules/src/finders/filters/composite.ts
@@ -1,4 +1,4 @@
-import { FilterFlag } from './constants'
+import { FilterScopes } from '.'
import { createFilterGenerator } from './utils'
import type { Metro } from '@revenge-mod/modules/types'
import type {
@@ -6,33 +6,48 @@ import type {
FilterBase,
FilterGenerator,
FilterInfoOf,
+ FilterScopeValue,
MergeFilterInfo,
UnionFilterInfo,
} 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 = any>(
- sameHandler: G,
- dynamicHandler: G,
+ directHandler: G,
+ prefilteredHandler: G,
) =>
- ((a, b) =>
- a.flags === b.flags
- ? sameHandler(a, b)
- : a.flags === FilterFlag.RequiresExports
- ? dynamicHandler(a, b)
- : dynamicHandler(b, a)) as G
+ ((a, b) => {
+ const ea = canFilterUninitialized(a.scopes)
+ return ea === canFilterUninitialized(b.scopes)
+ ? directHandler(a, b)
+ : ea
+ ? prefilteredHandler(b, a)
+ : prefilteredHandler(a, b)
+ }) as G
const compositeArrayHandler = <
G extends (args: [a: FilterBase, b: FilterBase]) => any,
>(
- sameHandler: G,
- dynamicHandler: G,
+ directHandler: G,
+ prefilteredHandler: G,
) =>
- (([a, b]) =>
- a.flags === b.flags
- ? sameHandler([a, b])
- : a.flags === FilterFlag.RequiresExports
- ? dynamicHandler([a, b])
- : dynamicHandler([b, a])) as G
+ (([a, b]) => {
+ const ea = canFilterUninitialized(a.scopes)
+ return ea === canFilterUninitialized(b.scopes)
+ ? directHandler([a, b])
+ : ea
+ ? prefilteredHandler([b, a])
+ : prefilteredHandler([a, b])
+ }) as G
export type And = FilterGenerator<
(
@@ -46,49 +61,49 @@ const andKeyGenerator = ([a, b]: Parameters) =>
const andScopesGenerator = ([a, b]: Parameters) => a.scopes | b.scopes
-const sameFlagsAnd = createFilterGenerator(
- ([a, b], id, exports) => a(id, exports) && b(id, exports),
+const directAnd = createFilterGenerator(
+ ([a, b], id, exports, initialized) =>
+ a(id, exports, initialized) && b(id, exports, initialized),
andKeyGenerator,
- ([a]) => a.flags,
andScopesGenerator,
) as And
-const andFallbackCache = new WeakMap>()
+const andPrefilterCache = new WeakMap>()
-const dynamicFlagsAnd = createFilterGenerator(
- ([filter, fallbackFilter], id, exports) => {
- if (exports) {
- if (filter(id, exports)) {
- // Avoid running the fallback filter if we already know the first one passed
- const cache = andFallbackCache.get(fallbackFilter)
+const prefilteredAnd = createFilterGenerator(
+ ([filter, prefilter], id, exports, initialized) => {
+ if (initialized) {
+ if (filter(id, exports, true)) {
+ // Avoid running the prefilter again if we already know it passed
+ const cache = andPrefilterCache.get(prefilter)
return (
// 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
}
- const result = fallbackFilter(id)
+ const result = prefilter(id, undefined, false)
if (result) {
- // Cache fallback hits to avoid calling the fallback filter again
- // Fallback filters are usually more expensive
- let set = andFallbackCache.get(fallbackFilter)
- if (!set) andFallbackCache.set(fallbackFilter, (set = new Set()))
+ // Cache prefilter hits to avoid calling the prefilter again
+ // Prefilters are usually more expensive
+ let set = andPrefilterCache.get(prefilter)
+ if (!set) andPrefilterCache.set(prefilter, (set = new Set()))
set.add(id)
}
return result
},
andKeyGenerator,
- FilterFlag.Dynamic,
andScopesGenerator,
) as And
/**
* 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.
*
@@ -111,23 +126,13 @@ const dynamicFlagsAnd = createFilterGenerator(
* )
* ```
*/
-export const and = Object.assign(
- compositeHandler(sameFlagsAnd, dynamicFlagsAnd),
- {
- keyFor: compositeArrayHandler(
- sameFlagsAnd.keyFor,
- dynamicFlagsAnd.keyFor,
- ),
- flagsFor: compositeArrayHandler(
- sameFlagsAnd.flagsFor,
- dynamicFlagsAnd.flagsFor,
- ),
- defaultScopesFor: compositeArrayHandler(
- sameFlagsAnd.defaultScopesFor,
- dynamicFlagsAnd.defaultScopesFor,
- ),
- },
-) satisfies And
+export const and = Object.assign(compositeHandler(directAnd, prefilteredAnd), {
+ keyFor: compositeArrayHandler(directAnd.keyFor, prefilteredAnd.keyFor),
+ defaultScopesFor: compositeArrayHandler(
+ directAnd.defaultScopesFor,
+ prefilteredAnd.defaultScopesFor,
+ ),
+}) satisfies And
export type Or = FilterGenerator<
(
@@ -141,21 +146,21 @@ const orKeyGenerator = ([a, b]: Parameters) =>
const orScopesGenerator = ([a, b]: Parameters) => a.scopes | b.scopes
-const sameFlagOr = createFilterGenerator(
- ([a, b], id, exports) => a(id, exports) || b(id, exports),
+const directOr = createFilterGenerator(
+ ([a, b], id, exports, initialized) =>
+ a(id, exports, initialized) || b(id, exports, initialized),
orKeyGenerator,
- ([a]) => a.flags,
orScopesGenerator,
) as Or
-const dynamicFlagOr = createFilterGenerator(
- ([filter, fallbackFilter], id, exports) => {
- // TODO(PalmDevs): Potential optimization: Add fallback cache here too?
- if (exports) return filter(id, exports) || fallbackFilter(id, exports)
- return fallbackFilter(id)
+const prefilteredOr = createFilterGenerator(
+ ([filter, prefilter], id, exports, initialized) => {
+ // TODO(PalmDevs): Potential optimization: Add prefilter cache here too?
+ if (initialized)
+ return filter(id, exports, true) || prefilter(id, exports, true)
+ return prefilter(id, undefined, false)
},
orKeyGenerator,
- FilterFlag.Dynamic,
orScopesGenerator,
) as Or
@@ -183,17 +188,10 @@ const dynamicFlagOr = createFilterGenerator(
* )
* ```
*/
-export const or = Object.assign(
- compositeHandler(sameFlagOr, dynamicFlagOr),
- {
- keyFor: compositeArrayHandler(sameFlagOr.keyFor, dynamicFlagOr.keyFor),
- flagsFor: compositeArrayHandler(
- sameFlagOr.flagsFor,
- dynamicFlagOr.flagsFor,
- ),
- defaultScopesFor: compositeArrayHandler(
- sameFlagOr.defaultScopesFor,
- dynamicFlagOr.defaultScopesFor,
- ),
- },
-) satisfies Or
+export const or = Object.assign(compositeHandler(directOr, prefilteredOr), {
+ keyFor: compositeArrayHandler(directOr.keyFor, prefilteredOr.keyFor),
+ defaultScopesFor: compositeArrayHandler(
+ directOr.defaultScopesFor,
+ prefilteredOr.defaultScopesFor,
+ ),
+}) satisfies Or
diff --git a/lib/modules/src/finders/filters/constants.ts b/lib/modules/src/finders/filters/constants.ts
index 5871853..571dc71 100644
--- a/lib/modules/src/finders/filters/constants.ts
+++ b/lib/modules/src/finders/filters/constants.ts
@@ -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.
*/
@@ -23,10 +5,16 @@ export const FilterScopes = {
/**
* Include all modules (both initialized and uninitialized, including blacklisted).
* 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,
/**
- * Include uninitialized modules in the search.
+ * Include uninitialized modules in the search. Implies the predicate can run without exports.
*/
Uninitialized: 2,
/**
@@ -47,10 +35,6 @@ export interface FilterInfo {
* The result type of the filter.
*/
Result: any
- /**
- * Whether the filter requires exports to work.
- */
- RequiresExports: boolean
/**
* Scopes the filter matches modules in.
*/
@@ -59,6 +43,5 @@ export interface FilterInfo {
export interface DefaultFilterInfo extends FilterInfo {
Result: any
- RequiresExports: boolean
Scopes: FilterScope[]
}
diff --git a/lib/modules/src/finders/filters/dynamic.ts b/lib/modules/src/finders/filters/dynamic.ts
index dc8de9e..956a3b8 100644
--- a/lib/modules/src/finders/filters/dynamic.ts
+++ b/lib/modules/src/finders/filters/dynamic.ts
@@ -4,7 +4,7 @@ import {
getModuleDependencies,
} from '../../metro/utils'
import { runFilter } from '../_internal'
-import { FilterFlag, FilterScopes } from './constants'
+import { FilterScopes } from './constants'
import { createFilterGenerator } from './utils'
import type { Metro } from '../../types'
import type { Filter, FilterGenerator } from './utils'
@@ -18,8 +18,18 @@ export interface ComparableDependencyMap
| ComparableDependencyMap
| Filter
> {
+ // loose
l?: boolean
+ // relative
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:
* @see {@link withDependencies.loose} to loosen the checks.
* @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
* ```ts
@@ -75,30 +88,30 @@ export interface ComparableDependencyMap
*/
const withDependencies_ = createFilterGenerator>(
([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)})`,
- FilterFlag.Dynamic,
FilterScopes.Uninitialized | FilterScopes.Initialized,
) as WithDependencies
export const withDependencies = __DEV__
- ? (((deps: ComparableDependencyMap) => {
- // 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)
- }
+ ? (Object.assign((deps: ComparableDependencyMap) => {
+ DEBUG_validateWithDependenciesFilter(deps)
return withDependencies_(deps)
- }) as WithDependencies)
+ }, withDependencies_) as WithDependencies)
: withDependencies_
withDependencies.loose = loose
withDependencies.relative = relative
+withDependencies.skip = skip
+withDependencies.last = last
+withDependencies.atLeast = atLeast
+withDependencies.atMost = atMost
+withDependencies.includes = includes
type WithDependencies = FilterGenerator<
(deps: ComparableDependencyMap) => Filter<{
Result: T
- RequiresExports: false
Scopes: [
typeof FilterScopes.Uninitialized,
typeof FilterScopes.Initialized,
@@ -107,6 +120,11 @@ type WithDependencies = FilterGenerator<
> & {
loose: typeof loose
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
}
+/**
+ * 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 RelativeBit = 1 << 29
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(
a: Metro.ModuleID[],
b: ComparableDependencyMap,
@@ -200,7 +372,29 @@ function depCompare(
): boolean {
const lenA = a.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++) {
const compare = b[i]
@@ -211,43 +405,71 @@ function depCompare(
// Skip dynamic
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
// TODO: Probably a Metro/Discord bug?
if (id === null) return false
- switch (typeof compare) {
- 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
- }
- }
+ if (!depMatches(compare, id, root, parent)) return false
}
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,
+ 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(
compare: number,
id: Metro.ModuleID,
@@ -291,11 +513,7 @@ function depGenFilterKey(deps: ComparableDependencyMap): string {
case 'object': {
// It's a nested dependency array
const nested = dep as ComparableDependencyMap
- if (nested.l) key += '#'
- // relative.withDependencies?
- if (nested.r) key += `${depGenRelativeKeyPart(nested.r)}:`
-
- key += `[${depGenFilterKey(nested)}],`
+ key += `${depGenModifierKey(nested)}[${depGenFilterKey(nested)}],`
break
}
default: {
@@ -311,6 +529,20 @@ function depGenFilterKey(deps: ComparableDependencyMap): string {
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) {
const magnitude = depGetRelMagnitude(dep)
const prefix = dep & RelativeRootBit ? '~' : '^'
diff --git a/lib/modules/src/finders/filters/index.ts b/lib/modules/src/finders/filters/index.ts
index a8439eb..3db77aa 100644
--- a/lib/modules/src/finders/filters/index.ts
+++ b/lib/modules/src/finders/filters/index.ts
@@ -1,5 +1,5 @@
import { isModuleExportBad } from '@revenge-mod/modules/metro/utils'
-import { FilterFlag, FilterScopes } from './constants'
+import { FilterScopes } from './constants'
import { createFilterGenerator } from './utils'
import type { Filter, FilterGenerator } from './utils'
@@ -8,7 +8,6 @@ export * from './utils'
type FilterRequiringExports = Filter<{
Result: T
- RequiresExports: true
Scopes: [typeof FilterScopes.Initialized]
}>
@@ -41,7 +40,6 @@ export const withProps = createFilterGenerator>(
return false
},
props => `revenge.props(${props.join(',')})`,
- FilterFlag.RequiresExports,
FilterScopes.Initialized,
) as WithProps
@@ -67,7 +65,6 @@ export const withoutProps = createFilterGenerator>(
return true
},
props => `revenge.withoutProps(${props.join(',')})`,
- FilterFlag.RequiresExports,
FilterScopes.Initialized,
) as WithoutProps
@@ -97,7 +94,6 @@ export const withSingleProp = createFilterGenerator>(
return false
},
([prop]) => `revenge.singleProp(${prop})`,
- FilterFlag.RequiresExports,
FilterScopes.Initialized,
) as WithSingleProp
@@ -138,7 +134,6 @@ export type WithSingleProp = FilterGenerator<
export const withName = createFilterGenerator>(
([name], _, exports) => exports?.name === name,
([name]) => `revenge.name(${name})`,
- FilterFlag.RequiresExports,
FilterScopes.Initialized,
) as WithName
diff --git a/lib/modules/src/finders/filters/utils.ts b/lib/modules/src/finders/filters/utils.ts
index 0f3d101..e853899 100644
--- a/lib/modules/src/finders/filters/utils.ts
+++ b/lib/modules/src/finders/filters/utils.ts
@@ -1,40 +1,32 @@
+import { isModuleInitialized } from '@revenge-mod/modules/metro/utils'
import { getErrorStack } from '@revenge-mod/utils/error'
import { withProps } from '.'
import { and, or } from './composite'
import { FilterScopes } from './constants'
import type { Metro } from '@revenge-mod/modules/types'
-import type { If, LogicalAnd } from '@revenge-mod/utils/types'
import type {
DefaultFilterInfo,
- FilterFlag,
FilterInfo,
FilterScope,
- FilterScopes as FilterScopesObject,
FilterScopeValue,
} from './constants'
export type FilterResult = F extends Filter ? I['Result'] : never
-export type FilterRequiresExports =
- F extends Filter ? I['RequiresExports'] : never
-
export type FilterInfoOf = F extends Filter ? I : FilterInfo
+declare const FilterInfoBrand: unique symbol
+
export interface FilterBase {
(
- ...args: If<
- Info['RequiresExports'],
- [id: Metro.ModuleID, exports: Metro.ModuleExports],
- [id: Metro.ModuleID, exports?: never]
- >
+ id: Metro.ModuleID,
+ exports?: Metro.ModuleExports,
+ initialized?: boolean,
): boolean
key: string
- flags: If<
- Info['RequiresExports'],
- (typeof FilterFlag)['RequiresExports'],
- FilterFlag
- >
scopes: FilterScopeValue
+ /** @internal */
+ readonly [FilterInfoBrand]?: Info
}
export type Filter =
@@ -42,13 +34,11 @@ export type Filter =
export type MergeFilterInfo = {
Result: I1['Result'] & I2['Result']
- RequiresExports: LogicalAnd
Scopes: [...I1['Scopes'], ...I2['Scopes']]
}
export type UnionFilterInfo = {
Result: I1['Result'] | I2['Result']
- RequiresExports: LogicalAnd
Scopes: [...I1['Scopes'], ...I2['Scopes']]
}
@@ -91,25 +81,12 @@ export interface FilterHelpers {
*/
scope, const S extends FilterScope[]>(
this: T,
- ...scopes: If<
- Info['RequiresExports'],
- [typeof FilterScopesObject.Initialized],
- S
- >
- ): Filter<
- Info & {
- Scopes: If<
- Info['RequiresExports'],
- [typeof FilterScopesObject.Initialized],
- S
- >
- }
- >
+ ...scopes: S
+ ): Filter
}
export type FilterGenerator Filter> = G & {
keyFor(args: Parameters): string
- flagsFor(args: Parameters): FilterFlag
defaultScopesFor(args: Parameters): FilterScopeValue
}
@@ -129,7 +106,8 @@ const Helpers: FilterHelpers = Object.setPrototypeOf(
const newFilter = ((
id: Metro.ModuleID,
exports?: Metro.ModuleExports,
- ) => this(id, exports)) as typeof this
+ initialized?: boolean,
+ ) => this(id, exports, initialized)) as typeof this
Object.assign(newFilter, this)
@@ -139,7 +117,9 @@ const Helpers: FilterHelpers = Object.setPrototypeOf(
const filter = this.new()
filter.scopes = 0
for (const scope of scopes) filter.scopes |= scope
- return filter
+ return filter as Filter<
+ FilterInfoOf & { Scopes: typeof scopes }
+ >
},
} satisfies FilterHelpers,
Function.prototype,
@@ -150,15 +130,14 @@ const Helpers: FilterHelpers = Object.setPrototypeOf(
*
* @param filter The function that filters the modules.
* @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}.
* @returns A function that generates a filter with the specified arguments.
*
* @example
* ```ts
* const custom = createFilterGenerator<[arg1: number, arg2: string]>(
- * ([arg1, arg2], id, exports) => {
- * // WARNING: exports can be Proxy or nullish, so be careful when using it
+ * ([arg1, arg2], id, exports, initialized) => {
+ * // WARNING: exports can be a Proxy, nullish, or a primitive, so be careful when using it
* // filter logic
* return true
* },
@@ -173,16 +152,15 @@ export function createFilterGenerator(
args: A,
id: Metro.ModuleID,
exports: Metro.ModuleExports,
+ initialized: boolean,
) => boolean,
keyFor: (args: A) => string,
- flagFor: ((args: A) => FilterFlag) | FilterFlag,
defaultScopesFor?: ((args: A) => FilterScopeValue) | FilterScopeValue,
): FilterGenerator<(...args: A) => Filter>
export function createFilterGenerator(
filter: (args: A, id: Metro.ModuleID) => boolean,
keyFor: (args: A) => string,
- flagFor: ((args: A) => FilterFlag) | FilterFlag,
defaultScopesFor?: ((args: A) => FilterScopeValue) | FilterScopeValue,
): FilterGenerator<(...args: A) => Filter>
@@ -190,16 +168,14 @@ export function createFilterGenerator(
filter: (
args: A,
id: Metro.ModuleID,
- exports?: Metro.ModuleExports,
+ exports: Metro.ModuleExports,
+ initialized: boolean,
) => boolean,
keyFor: (args: A) => string,
- flagFor: ((args: A) => FilterFlag) | FilterFlag,
defaultScopesFor?: ((args: A) => FilterScopeValue) | FilterScopeValue,
): FilterGenerator<(...args: A) => Filter> {
type GeneratorType = ReturnType>
- const isFlagsStatic = typeof flagFor === 'number'
-
const defaultScopes = defaultScopesFor ?? FilterScopes.Initialized
const isDefaultScopesStatic = typeof defaultScopes === 'number'
@@ -207,9 +183,10 @@ export function createFilterGenerator(
const filter_ = ((
id: Metro.ModuleID,
exports?: Metro.ModuleExports,
+ initialized: boolean = Boolean(isModuleInitialized(id)),
) => {
try {
- return filter(args, id, exports)
+ return filter(args, id, exports, initialized)
} catch (e) {
DEBUG_warnFilterThrown(id, keyFor(args), e)
return false
@@ -217,17 +194,12 @@ export function createFilterGenerator(
}) as ReturnType
filter_.key = keyFor(args)
- filter_.flags = isFlagsStatic ? flagFor : flagFor(args)
filter_.scopes = isDefaultScopesStatic
? defaultScopes
: defaultScopes(args)
return Object.setPrototypeOf(filter_, Helpers)
}
- generator.flagsFor = isFlagsStatic
- ? () => flagFor
- : (args: A) => flagFor(args)
-
generator.defaultScopesFor = isDefaultScopesStatic
? () => defaultScopes
: defaultScopes
diff --git a/lib/modules/src/finders/lookup.ts b/lib/modules/src/finders/lookup.ts
index c517e3a..9623246 100644
--- a/lib/modules/src/finders/lookup.ts
+++ b/lib/modules/src/finders/lookup.ts
@@ -183,11 +183,19 @@ export function* lookupModules(filter: Filter, options?: LookupModulesOptions) {
}
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
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) {
notFound = false
@@ -308,8 +316,15 @@ export function lookupModule(filter: Filter, options?: LookupModulesOptions) {
}
if (includeUninit)
- for (const id of mUninitialized) {
- const flag = runFilter(filter, id, undefined, options)
+ // See equivalent code in `lookupModules` for why this is snapshotted
+ 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 (__BUILD_FLAG_DEBUG_MODULE_LOOKUPS__)
DEBUG_logLookupMatched(filter.key, flag, id)
diff --git a/lib/modules/src/metro/runtime.ts b/lib/modules/src/metro/runtime.ts
index 5e7cbf1..81f0e13 100644
--- a/lib/modules/src/metro/runtime.ts
+++ b/lib/modules/src/metro/runtime.ts
@@ -8,7 +8,7 @@
import { callNativeMethodSync } from '@revenge-mod/modules/native'
import { getErrorStack } from '@revenge-mod/utils/error'
import { FullVersion } from '~constants'
-import { loadModuleFromSegment, mInitializingId, mList } from './patches'
+import { loadModuleFromSegment, mList } from './patches'
import {
executeInitializeSubscriptions,
executeRequireSubscriptions,
@@ -62,7 +62,7 @@ export const metroRequire = (moduleId => {
mod.flags = (flags & NotInitializedOrInitializingMask) | HasError
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__) {
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
- // cacheBlacklistedModule(mInitializingId)
+ // cacheBlacklistedModule(moduleId)
// 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
diff --git a/lib/modules/src/metro/utils.ts b/lib/modules/src/metro/utils.ts
index ab33bd3..d974561 100644
--- a/lib/modules/src/metro/utils.ts
+++ b/lib/modules/src/metro/utils.ts
@@ -50,7 +50,11 @@ export function isModuleExportBad(
return (
// Nullish?
exp == null ||
- // Does it have some non-existent key? (Turbo modules)
- (typeof exp === 'object' && CATCH_ALL_TEST in exp)
+ // Does it have some non-existent key? (proxies)
+ (typeof exp === 'object' &&
+ // Implementing Reflect.get
+ (exp[CATCH_ALL_TEST] !== undefined ||
+ // Implementing Reflect.ownKeys
+ CATCH_ALL_TEST in exp))
)
}
diff --git a/lib/utils/src/discord.ts b/lib/utils/src/discord.ts
index b058856..e4f0454 100644
--- a/lib/utils/src/discord.ts
+++ b/lib/utils/src/discord.ts
@@ -7,42 +7,50 @@ import {
withDependencies,
} from '@revenge-mod/modules/finders/filters'
import { isModuleExportBad } from '@revenge-mod/modules/metro/utils'
-import {
- ReactJSXRuntimeModuleId,
- ReactModuleId,
- ReactNativeModuleId,
-} from '@revenge-mod/react'
-import {
- FilterFlag,
- FilterScopes,
-} from '../../modules/src/finders/filters/constants'
+import { FilterScopes } from '../../modules/src/finders/filters/constants'
import type {
Filter,
FilterGenerator,
} from '@revenge-mod/modules/finders/filters'
+import type { Metro } from '@revenge-mod/modules/types'
import type { FC } from 'react'
-// [React, ReactJsxRuntime, (Tokens), (BaseIconImage), (Asset), ImportTracker]
-// Asset = relativeDep(1)
-const IconComponentFilter = [
- ReactModuleId,
- ReactJSXRuntimeModuleId,
- TokensModuleId,
- null,
- null,
- ImportTrackerModuleId,
-]
+const { last } = withDependencies
-// 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)
-const MultiIconComponentFilterBase = [
- ReactModuleId,
- ReactNativeModuleId,
- ReactJSXRuntimeModuleId,
- TokensModuleId,
- null,
-]
+const depsFilters: Record = {}
+
+/**
+ * Builds a dependency filter for an icon component.
+ *
+ * `[React, (ReactNative), ReactJsxRuntime, Tokens, (BaseIconImage), (...Assets), ImportTracker]`
+ *
+ * `ReactNative` is only present on components with multiple assets, and it sits right after `React`,
+ * 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<
(
@@ -50,7 +58,6 @@ export type WithGeneratedIconComponent = FilterGenerator<
...assets: string[]
) => Filter<{
Result: { [K in N]: FC }
- RequiresExports: boolean
Scopes: [
typeof FilterScopes.Uninitialized,
typeof FilterScopes.Initialized,
@@ -86,47 +93,13 @@ export type WithGeneratedIconComponent = FilterGenerator<
export const withGeneratedIconComponent = createFilterGenerator<
Parameters
>(
- (names, id, exports) => {
- if (typeof exports === 'object' && isModuleExportBad(exports)) {
- if (typeof exports[names[0]] === 'function')
- return Object.keys(exports).length === 1
- } else {
- let filter = IconComponentFilter
+ (names, id, exports, initialized) => {
+ // Icon components always export their component, so unusable exports can never match
+ if (initialized && isModuleExportBad(exports)) return 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
+ return depsFilterFor(names)?.(id, exports, initialized) ?? false
},
names => `revenge.utils.discord.generatedIconComponent(${names.join(',')})`,
- FilterFlag.Dynamic,
FilterScopes.Uninitialized | FilterScopes.Initialized,
) as WithGeneratedIconComponent
diff --git a/scripts/build.ts b/scripts/build.ts
index 22f583a..69ac97c 100644
--- a/scripts/build.ts
+++ b/scripts/build.ts
@@ -79,7 +79,8 @@ export default async function build(dev = Dev, log = true) {
.trim(),
),
__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
__BUILD_FLAG_DEBUG_MODULE_LOOKUPS__: String(dev),
diff --git a/src/assets/RevengeDonate.webp b/src/assets/RevengeDonate.webp
new file mode 100644
index 0000000..bbbec70
Binary files /dev/null and b/src/assets/RevengeDonate.webp differ
diff --git a/src/plugins/start/settings.plugins/components/PluginCard.tsx b/src/plugins/start/settings.plugins/components/PluginCard.tsx
index a079d9c..a7a752e 100644
--- a/src/plugins/start/settings.plugins/components/PluginCard.tsx
+++ b/src/plugins/start/settings.plugins/components/PluginCard.tsx
@@ -21,11 +21,7 @@ import {
} from '../utils/sheets'
import { PluginIcon } from './PluginIcon'
import { usePluginEnabled } from './PluginStateProvider'
-import {
- useClickOutsideTooltip,
- useEnablePluginTooltip,
- useEssentialPluginTooltip,
-} from './TooltipProvider'
+import { PluginTooltip, usePluginTooltip } from './TooltipProvider'
import type { AnyPlugin, InternalPluginMeta } from '@revenge-mod/plugins/_'
import type { RepoPluginListing } from '@revenge-mod/plugins/_/repositories'
@@ -152,14 +148,12 @@ export const InstalledPluginCard = memo(function InstalledPluginCard({
const toggleDisabled = essential || pendingUpdate
- const enableTooltip = useEnablePluginTooltip()
- const essentialTooltip = useEssentialPluginTooltip()
+ const [settingsRef, showEnableTooltip] = usePluginTooltip(
+ PluginTooltip.Enable,
+ )
- const settingsRef = useClickOutsideTooltip(useEnablePluginTooltip, () => {})
-
- const switchRef = useClickOutsideTooltip(
- useEssentialPluginTooltip,
- () => {},
+ const [switchRef, showToggleTooltip] = usePluginTooltip(
+ essential ? PluginTooltip.Essential : PluginTooltip.PendingUpdate,
)
return (
@@ -182,12 +176,7 @@ export const InstalledPluginCard = memo(function InstalledPluginCard({
{plugin.SettingsComponent && (
{
- if (!startable)
- requestAnimationFrame(() => {
- enableTooltip.targetRef.current =
- settingsRef.current
- enableTooltip.setVisible(true)
- })
+ if (!startable) showEnableTooltip()
}}
>
{
- if (toggleDisabled)
- requestAnimationFrame(() => {
- essentialTooltip.targetRef.current =
- switchRef.current
- essentialTooltip.setVisible(true)
- })
+ if (toggleDisabled) showToggleTooltip()
}}
ref={switchRef}
>
diff --git a/src/plugins/start/settings.plugins/components/PluginList.tsx b/src/plugins/start/settings.plugins/components/PluginList.tsx
index c5d5af4..7041de1 100644
--- a/src/plugins/start/settings.plugins/components/PluginList.tsx
+++ b/src/plugins/start/settings.plugins/components/PluginList.tsx
@@ -9,7 +9,7 @@ import {
PLUGIN_CARD_HALF_GUTTER,
PluginCard,
} from './PluginCard'
-import { useResetTooltips } from './TooltipProvider'
+import { useHidePluginTooltips } from './TooltipProvider'
import type { AnyPlugin, InternalPluginMeta } from '@revenge-mod/plugins/_'
import type { RepoPluginListing } from '@revenge-mod/plugins/_/repositories'
import type { FlashListProps } from '@shopify/flash-list'
@@ -25,14 +25,14 @@ export function PluginFlashList({
FlashListProps,
'onContentSizeChange'
>) {
- const resetTooltips = useResetTooltips()
+ const hideTooltips = useHidePluginTooltips()
return (
plugin.manifest.id}
renderItem={({
@@ -60,7 +60,7 @@ export function InstalledPluginMasonryFlashList({
plugins: (readonly [AnyPlugin, InternalPluginMeta])[]
}) {
const numColumns = useNumColumns()
- const resetTooltips = useResetTooltips()
+ const hideTooltips = useHidePluginTooltips()
return (
plugin.manifest.id}
numColumns={numColumns}
@@ -128,7 +128,7 @@ export function BrowsePluginMasonryFlashList({
) => void
}) {
const numColumns = useNumColumns()
- const resetTooltips = useResetTooltips()
+ const hideTooltips = useHidePluginTooltips()
return (
entry.key}
numColumns={numColumns}
diff --git a/src/plugins/start/settings.plugins/components/PluginOptionsActionSheet.tsx b/src/plugins/start/settings.plugins/components/PluginOptionsActionSheet.tsx
index a062f24..ba1a76b 100644
--- a/src/plugins/start/settings.plugins/components/PluginOptionsActionSheet.tsx
+++ b/src/plugins/start/settings.plugins/components/PluginOptionsActionSheet.tsx
@@ -37,10 +37,9 @@ import {
import { messageOf, showErrorToast } from '../utils/repos'
import { InstalledPluginSwitch, PluginInfo } from './PluginCard'
import { usePluginEnabled, usePluginStatus } from './PluginStateProvider'
-import {
- EnablePluginTooltipProvider,
- useClickOutsideTooltip,
- useEnablePluginTooltip,
+import PluginTooltipsProvider, {
+ PluginTooltip,
+ usePluginTooltip,
} from './TooltipProvider'
import type { AnyPlugin } from '@revenge-mod/plugins/_'
@@ -61,23 +60,44 @@ export default function PluginOptionsActionSheet({
plugin,
sheetKey,
}: PluginOptionsActionSheetProps) {
+ return (
+
+
+
+
+
+
+
+ )
+}
+
+function PluginOptions({ plugin, sheetKey }: PluginOptionsActionSheetProps) {
const enabled = usePluginEnabled(plugin)
const meta = getInternalPluginMeta(plugin)
const essential = isPluginEssential(meta)
const pendingUpdate = isPluginPendingUpdate(plugin)
const { name, author, description, icon, version } = plugin.manifest
+ const [switchRef, showPendingUpdateTooltip] = usePluginTooltip(
+ PluginTooltip.PendingUpdate,
+ )
+
return (
-
-
-
+ {
+ if (pendingUpdate) showPendingUpdateTooltip()
+ }}
+ ref={switchRef}
+ >
- )
- }
- />
-
-
- {
- ActionSheetActionCreators.hideActionSheet(
- sheetKey,
- )
- }}
- />
-
-
-
-
-
-
+
+ )
+ }
+ />
+ {
+ ActionSheetActionCreators.hideActionSheet(sheetKey)
+ }}
+ />
+
+
+
)
}
@@ -292,8 +306,9 @@ function PluginActions({
plugin: AnyPlugin
closeSheet: () => void
}) {
- const enableTooltip = useEnablePluginTooltip()
- const settingsRef = useClickOutsideTooltip(useEnablePluginTooltip, () => {})
+ const [settingsRef, showEnableTooltip] = usePluginTooltip(
+ PluginTooltip.Enable,
+ )
const meta = getInternalPluginMeta(plugin)
const startable = isPluginStartable(plugin)
const enabled = usePluginEnabled(plugin)
@@ -347,12 +362,7 @@ function PluginActions({
{plugin.SettingsComponent && (
{
- if (!startable)
- requestAnimationFrame(() => {
- enableTooltip.targetRef.current =
- settingsRef.current
- enableTooltip.setVisible(true)
- })
+ if (!startable) showEnableTooltip()
}}
>
- setVisible: (visible: boolean) => void
+export const PluginTooltip = {
+ /** Shown when an action requires the plugin to be enabled first. */
+ 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.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(
- null,
-)
-const EssentialPluginTooltipContext = createContext(
+interface PluginTooltipsContextValue {
+ show: (tooltip: PluginTooltip, target: View | null) => void
+ hide: () => void
+}
+
+const PluginTooltipsContext = createContext(
null,
)
-export function EnablePluginTooltipProvider({
+export default function PluginTooltipsProvider({
children,
}: {
children: React.ReactNode
}) {
- const [visible, setVisible] = useState(false)
- const targetRef = useRef(null)
+ const [visible, setVisible] = useState(null)
- useTooltip(targetRef, {
- label: 'Plugin must be enabled first',
+ // Targets are assigned by `show`, right before the tooltip becomes visible
+ const enableTarget = useRef(null)
+ const essentialTarget = useRef(null)
+ const pendingUpdateTarget = useRef(null)
+
+ useTooltip(enableTarget, {
+ label: Labels[PluginTooltip.Enable],
position: 'top',
- visible,
+ visible: visible === PluginTooltip.Enable,
})
- return (
-
- {children}
-
- )
-}
-
-export function EssentialPluginTooltipProvider({
- children,
-}: {
- children: React.ReactNode
-}) {
- const [visible, setVisible] = useState(false)
- const targetRef = useRef(null)
-
- useTooltip(targetRef, {
- label: 'Plugin needed for Revenge to function properly',
+ useTooltip(essentialTarget, {
+ label: Labels[PluginTooltip.Essential],
position: 'top',
- visible,
+ visible: visible === PluginTooltip.Essential,
})
- return (
-
- {children}
-
- )
-}
+ useTooltip(pendingUpdateTarget, {
+ label: Labels[PluginTooltip.PendingUpdate],
+ position: 'top',
+ visible: visible === PluginTooltip.PendingUpdate,
+ })
-export function useEnablePluginTooltip() {
- const context = useContext(EnablePluginTooltipContext)
- if (!context) {
- throw new Error(
- 'useEnablePluginTooltip must be used within EnablePluginTooltipProvider',
- )
- }
- 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(() => {
- setVisible(false)
- if (targetRef.current === ref.current) {
- onClickOutside()
+ const value = useMemo(() => {
+ const targets = {
+ [PluginTooltip.Enable]: enableTarget,
+ [PluginTooltip.Essential]: essentialTarget,
+ [PluginTooltip.PendingUpdate]: pendingUpdateTarget,
}
- })
- return ref
+ return {
+ show: (tooltip, target) => {
+ targets[tooltip].current = target
+ setVisible(tooltip)
+ },
+ hide: () => setVisible(null),
+ }
+ }, [])
+
+ return (
+
+
+ {children}
+
+
+ )
+}
+
+/**
+ * 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(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])
}
diff --git a/src/plugins/start/settings.plugins/index.ts b/src/plugins/start/settings.plugins/index.ts
index bf33bfc..94b7173 100644
--- a/src/plugins/start/settings.plugins/index.ts
+++ b/src/plugins/start/settings.plugins/index.ts
@@ -22,7 +22,7 @@ export interface Storage {
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 CircleXIconComponent = lookupGeneratedIconComponent(
diff --git a/src/plugins/start/settings.plugins/screens/RevengePluginsBrowseSettingScreen.tsx b/src/plugins/start/settings.plugins/screens/RevengePluginsBrowseSettingScreen.tsx
index d638a04..4a50027 100644
--- a/src/plugins/start/settings.plugins/screens/RevengePluginsBrowseSettingScreen.tsx
+++ b/src/plugins/start/settings.plugins/screens/RevengePluginsBrowseSettingScreen.tsx
@@ -13,13 +13,9 @@ import {
import { debounce } from '@revenge-mod/utils/callback'
import { useCallback, useEffect, useMemo, useState } from 'react'
import { View } from 'react-native'
-import { ClickOutsideProvider } from 'react-native-click-outside'
import { BrowsePluginMasonryFlashList } from '../components/PluginList'
import PluginStatesProvider from '../components/PluginStateProvider'
-import {
- EnablePluginTooltipProvider,
- EssentialPluginTooltipProvider,
-} from '../components/TooltipProvider'
+import PluginTooltipsProvider from '../components/TooltipProvider'
import { runInstallFlow } from '../utils/repos'
import type { RepoPluginListing } from '@revenge-mod/plugins/_/repositories'
import type { BrowseSortKey } from '../components/BrowseFilterAndSortActionSheet'
@@ -34,17 +30,13 @@ const SearchDebounceTime = 100
export default function RevengePluginsBrowseSettingScreen() {
return (
-
-
-
-
-
-
-
-
-
-
-
+
+
+
+
+
+
+
)
}
diff --git a/src/plugins/start/settings.plugins/screens/RevengePluginsSettingScreen.tsx b/src/plugins/start/settings.plugins/screens/RevengePluginsSettingScreen.tsx
index 505c335..b984dcf 100644
--- a/src/plugins/start/settings.plugins/screens/RevengePluginsSettingScreen.tsx
+++ b/src/plugins/start/settings.plugins/screens/RevengePluginsSettingScreen.tsx
@@ -28,14 +28,10 @@ import {
useState,
} from 'react'
import { Image, View } from 'react-native'
-import { ClickOutsideProvider } from 'react-native-click-outside'
import RevengeIcon from '~assets/RevengeIcon'
import { InstalledPluginMasonryFlashList } from '../components/PluginList'
import PluginStatesProvider from '../components/PluginStateProvider'
-import {
- EnablePluginTooltipProvider,
- EssentialPluginTooltipProvider,
-} from '../components/TooltipProvider'
+import PluginTooltipsProvider from '../components/TooltipProvider'
import { RouteNames, Setting } from '../constants'
import type { NavigationProp, RouteProp } from '@react-navigation/core'
import type { ReactNavigationParamList } from '@revenge-mod/externals/react-navigation'
@@ -51,17 +47,13 @@ const PlusLargeIcon = getAssetIdByName('PlusLargeIcon')!
export default function RevengePluginsSettingScreen() {
return (
-
-
+
+
-
-
-
-
-
+
-
-
+
+
)
}
diff --git a/src/plugins/start/settings/screens/RevengeSettingScreen.tsx b/src/plugins/start/settings/screens/RevengeSettingScreen.tsx
index c598398..05065d9 100644
--- a/src/plugins/start/settings/screens/RevengeSettingScreen.tsx
+++ b/src/plugins/start/settings/screens/RevengeSettingScreen.tsx
@@ -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 { Image, Linking, ScrollView, StyleSheet, View } from 'react-native'
+import RevengeDonate from '~assets/RevengeDonate'
import { Setting } from '../constants'
+const { Button, Card, Stack, Text } = Design
+
export default function RevengeSettingScreen() {
return (
-
+
+
+
+
+
+
+
+
+
+
+ )
+}
+
+const cardStyles = StyleSheet.create({
+ background: {
+ position: 'absolute',
+ top: 0,
+ left: 0,
+ width: '100%',
+ height: '100%',
+ },
+ illustration: {
+ width: 96,
+ height: 96,
+ },
+})
+
+function DonateCard() {
+ return (
+
+
+
+
+
+
+
+ Support Revenge
+
+
+ You can support the development of Revenge by
+ donating or contributing!
+
+
+
+ {__BUILD_DONATE_URL__ && (
+
+
+
+
+
+
)
}
diff --git a/types/build.d.ts b/types/build.d.ts
index 54c3aa7..417c57f 100644
--- a/types/build.d.ts
+++ b/types/build.d.ts
@@ -3,6 +3,7 @@
declare global {
const __DEV__: boolean
const __BUILD_DISCORD_SERVER_URL__: string
+ const __BUILD_DONATE_URL__: string | undefined
const __BUILD_SOURCE_REPOSITORY_URL__: string
const __BUILD_DEFAULT_PLUGIN_REPOSITORY_URL__: string | undefined
const __BUILD_LICENSE_URL__: string