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__ && ( +