Compare commits

..

20 commits

Author SHA1 Message Date
Rosie Val
f28eba295c Update & reload from startup update alert 2026-08-09 00:58:55 -04:00
Rosie Val
a7385a58df Add startup update-confirmation alert with plugin details 2026-08-09 00:54:54 -04:00
Rosie Val
2cf998542f Add per-plugin Check for updates row 2026-08-09 00:41:35 -04:00
Rosie Val
b6e5cdb318 Re-add fork-only host-bundle workflow 2026-08-09 00:15:26 -04:00
Rosie Val
6ae7b9a3b1 Remove fork-only host-bundle workflow 2026-08-09 00:13:44 -04:00
Rosie Val
bb7b480e6b Emit errored events for plugin native and JS errors 2026-08-08 23:27:35 -04:00
Rosie Val
efb6eb1801 Add fallback FlashList fingerprints without ImportTracker 2026-08-08 23:19:31 -04:00
Rosie Val
df891b938c Add fallback fingerprint for main_tabs_v2 without ImportTracker 2026-08-08 22:07:10 -04:00
Rosie Val
366936db8c Add Rosie to contributors 2026-08-08 22:05:27 -04:00
Rosie Val
c848cec45b Fix PluginStateProvider import path 2026-08-08 21:53:02 -04:00
Rosie Val
b520419d43 Add manual restore default repositories button 2026-08-08 21:37:27 -04:00
Rosie Val
1e446bf8df Remove TODO now that default repo restore has UI 2026-08-08 21:37:26 -04:00
Rosie Val
48880cb8a2 Compute and apply user-selected release channel in browse 2026-08-08 21:36:15 -04:00
Rosie Val
d2cef996fd Add release channel picker to browse filter sheet 2026-08-08 21:36:14 -04:00
Rosie Val
b8453d7cfd Use repo removal confirmation when deleting repositories 2026-08-08 21:30:35 -04:00
Rosie Val
5ef88bd5e8 Wire up repo removal confirmation alert 2026-08-08 21:30:24 -04:00
Rosie Val
baca849042 Add repo removal confirmation alert component 2026-08-08 21:30:23 -04:00
Rosie Val
d8b0d5971a Remove (TODO) labels and add dependency/dependent popup menus 2026-08-08 21:04:20 -04:00
Rosie Val
be54a06e31 Add PluginListActionSheet for dependency and dependent popups 2026-08-08 21:02:10 -04:00
Rosie Val
ec51cbedcb Add bundle hosting workflow for test instances 2026-08-08 20:34:46 -04:00
52 changed files with 1454 additions and 1788 deletions

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

@ -0,0 +1,54 @@
name: Host Revenge Bundle
on:
workflow_dispatch:
push:
branches:
- main
jobs:
build-and-host:
runs-on: ubuntu-latest
permissions:
contents: write
steps:
- name: Checkout
uses: actions/checkout@v4
with:
fetch-depth: 0
- name: Restore Bun cache
uses: actions/cache@v4
with:
path: ~/.bun/install/cache
key: ${{ runner.os }}-bun-${{ hashFiles('**/bun.lock') }}
restore-keys: |
${{ runner.os }}-bun-
- name: Setup Bun
uses: oven-sh/setup-bun@v2
with:
bun-version: latest
- name: Install dependencies
run: bun install --frozen-lockfile --production
- name: Build production bundle
run: bun run build
- name: Publish bundle to hosting branch
run: |
cp dist/revenge.bundle /tmp/revenge.bundle
git config --global user.name "github-actions[bot]"
git config --global user.email "41898282+github-actions[bot]@users.noreply.github.com"
if git show-ref --verify --quiet "refs/heads/hosting"; then
git checkout hosting
git rm -rf .
else
git checkout --orphan hosting
fi
rm -rf *
cp /tmp/revenge.bundle ./revenge.bundle
git add revenge.bundle
git commit -m "Host bundle from ${GITHUB_SHA}" || true
git push --force origin hosting

5
CONTRIBUTORS.md Normal file
View file

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

View file

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

View file

@ -1,5 +1,6 @@
import { lookupModule, lookupModules } from '@revenge-mod/modules/finders'
import { withDependencies, withProps } from '@revenge-mod/modules/finders/filters'
import { withDependencies } from '@revenge-mod/modules/finders/filters'
import { getModuleDependencies } from '@revenge-mod/modules/metro/utils'
import {
ReactJSXRuntimeModuleId,
ReactModuleId,
@ -9,7 +10,7 @@ import { proxify } from '@revenge-mod/utils/proxy'
import { ImportTrackerModuleId } from './common/import-tracker'
import type { DiscordModules } from './types'
const { atLeast, last, loose, relative } = withDependencies
const { loose } = withDependencies
/**
* The lowest amount of dependencies `design/native.tsx` is expected to have.
@ -26,21 +27,26 @@ export let Design: Design = proxify(
() => {
// ID: 13171
// [3909, 4608, 5182, 13172, 2, ...] (141 dependencies)
const [module] = lookupModule(
withProps('TableRow', 'Button').and(
const [, id] = lookupModule(
withDependencies<Design>(
atLeast(DesignMinimumDependencies, [
loose([
[ImportTrackerModuleId],
[ImportTrackerModuleId],
[ReactNativeModuleId, ImportTrackerModuleId],
[ImportTrackerModuleId],
ImportTrackerModuleId,
...Array<null>(DesignMinimumDependencies - 5).fill(null),
]),
)
).keyAs('revenge.discord.design.Design'),
{
initialize: false,
},
)
if (module) return (Design = module)
if (id === undefined) return
const module = __r(id)!
if (module.TableRow && module.Button) return (Design = module)
},
{
hint: {},
@ -49,6 +55,9 @@ 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([
@ -57,26 +66,18 @@ export let FormSwitch: DiscordModules.Components.FormSwitch = proxify(() => {
ReactNativeModuleId,
ReactJSXRuntimeModuleId,
]),
)
.and(
withDependencies(
last([
relative(1),
relative(2),
null,
null,
ImportTrackerModuleId,
]),
),
)
.keyAs('revenge.discord.design.FormSwitch'),
).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_)
}
}
})!
export interface Design {

View file

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

View file

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

View file

@ -29,9 +29,18 @@ export let RootNavigationRef: RootNavigationRef = proxify(
2,
),
]),
// TODO: Decouple?
ImportTrackerModuleId,
]).or(
withDependencies([
loose([
relative.withDependencies([], 1),
relative.withDependencies(
loose([relative(1), relative(2)]),
2,
),
]),
]),
),
)
.keyAs('revenge.discord.modules.mainTabsV2.RootNavigationRef'),
)

View file

@ -1,11 +1,10 @@
import type { StackScreenProps } from '@react-navigation/stack'
import type { ReactNavigationParamList } from '@revenge-mod/externals/react-navigation'
import type { AnyFunction, AnyObject } from '@revenge-mod/utils/types'
import type { AnyFunction } from '@revenge-mod/utils/types'
import type {
ComponentProps,
ComponentType,
FC,
ForwardRefExoticComponent,
MemoExoticComponent,
ReactElement,
ReactNode,
@ -152,14 +151,10 @@ export namespace DiscordModules {
verboseDangerously(...args: unknown[]): void
verbose(...args: unknown[]): void
}
export namespace Actions {
export interface AlertActionCreators {
openAlert(
key: string,
alert: ReactElement,
onDismiss?: () => unknown,
options?: { dismissable?: boolean },
): void
openAlert(key: string, alert: ReactElement): void
dismissAlert(key: string): void
dismissAlerts(): void
// TODO
@ -185,20 +180,9 @@ export namespace DiscordModules {
openLazy<T extends ComponentType<any>>(
sheet: Promise<{ default: T }>,
key: string,
props: {
impressionName?: string
impressionProperties?: AnyObject
backdropKind?: string
disableHapticOnOpen?: boolean
appEntryKey?: string
} & ComponentProps<T>,
// See ActionSheetStore reducer
stackingBehavior?: 'replaceTopSheet' | 'replaceAll' | 'stack',
props: ComponentProps<T>,
): void
hideActionSheet(key?: string): void
hideAllActionSheets(): void
setActionSheetZIndex(zIndex: number): void
resetActionSheetsForAppEntryKey(appEntryKey: string): void
}
// export namespace ActionSheetActionCreators {
@ -279,7 +263,6 @@ export namespace DiscordModules {
| 'tertiary'
| 'destructive'
| 'active'
| 'expressive'
| 'primary-overlay'
| 'secondary-overlay'
loading?: boolean
@ -413,50 +396,10 @@ export namespace DiscordModules {
// export type FormCheckbox = FC
export interface ActionSheetProps {
scrollable?: boolean
startExpanded?: boolean
/** Whether the bottom sheet handle is disabled. */
handleDisabled?: boolean
showGradient?: boolean
startHeight?: number
maxHeight?: number
containerHeight?: number
contentHeight?: number
backdropOpacity?: number
children?: ReactNode
header?: ReactNode
footer?: ReactNode
extraContent?: ReactNode
backdropChildren?: ReactNode
handleComponent?: ComponentType<any> | null
backgroundComponent?: ComponentType<any>
bodyStyles?: StyleProp<ViewStyle>
contentStyles?: StyleProp<ViewStyle>
backgroundStyles?: StyleProp<ViewStyle>
borderGradient?: string[] | Record<string, any>
onExpand?: () => void
onDismiss?: () => void
animatedIndex?: unknown
keyboardShouldPersistTaps?: 'always' | 'never' | 'handled' | boolean
dismissAccessibilityLabel?: string
children: ReactNode
}
export type ActionSheet = ForwardRefExoticComponent<
ActionSheetProps &
// See useBottomSheetImperativeHandle
RefAttributes<{
expandActionSheet(): void
closeActionSheet(force?: boolean): void
collapseActionSheet(): void
snapToIndex(index: number): void
}>
>
export type ActionSheet = FC<ActionSheetProps>
export interface ActionSheetCloseButtonProps
extends Pick<ComponentProps<IconButton>, 'variant' | 'onPress'> {}

View file

@ -25,7 +25,6 @@ export let FlashList: typeof import('@shopify/flash-list') = proxify(
null,
null,
null,
// TODO: Decouple?
ImportTrackerModuleId,
]).or(
// TODO: Remove when stable > 340206+
@ -38,9 +37,32 @@ export let FlashList: typeof import('@shopify/flash-list') = proxify(
null,
null,
null,
// TODO: Decouple?
ImportTrackerModuleId,
]),
).or(
withDependencies([
ReactModuleId,
ReactNativeModuleId,
ReactJSXRuntimeModuleId,
null,
null,
null,
null,
null,
null,
]),
).or(
// [React, JSXRuntime, (Platform), (FlashListExports), (Reanimated), (RNBottomSheet), (BottomSheet)]
withDependencies([
ReactModuleId,
ReactJSXRuntimeModuleId,
null,
null,
null,
null,
null,
null,
]),
),
)
.keyAs('revenge.externals.Shopify.FlashList'),

View file

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

View file

@ -1,4 +1,4 @@
import { FilterScopes } from '.'
import { FilterFlag } from './constants'
import { createFilterGenerator } from './utils'
import type { Metro } from '@revenge-mod/modules/types'
import type {
@ -6,48 +6,33 @@ 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 = <G extends (a: FilterBase, b: FilterBase) => any>(
directHandler: G,
prefilteredHandler: G,
sameHandler: G,
dynamicHandler: 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
((a, b) =>
a.flags === b.flags
? sameHandler(a, b)
: a.flags === FilterFlag.RequiresExports
? dynamicHandler(a, b)
: dynamicHandler(b, a)) as G
const compositeArrayHandler = <
G extends (args: [a: FilterBase, b: FilterBase]) => any,
>(
directHandler: G,
prefilteredHandler: G,
sameHandler: G,
dynamicHandler: 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
(([a, b]) =>
a.flags === b.flags
? sameHandler([a, b])
: a.flags === FilterFlag.RequiresExports
? dynamicHandler([a, b])
: dynamicHandler([b, a])) as G
export type And = FilterGenerator<
<F1 extends FilterBase, F2 extends FilterBase>(
@ -61,49 +46,49 @@ const andKeyGenerator = ([a, b]: Parameters<And>) =>
const andScopesGenerator = ([a, b]: Parameters<And>) => a.scopes | b.scopes
const directAnd = createFilterGenerator(
([a, b], id, exports, initialized) =>
a(id, exports, initialized) && b(id, exports, initialized),
const sameFlagsAnd = createFilterGenerator(
([a, b], id, exports) => a(id, exports) && b(id, exports),
andKeyGenerator,
([a]) => a.flags,
andScopesGenerator,
) as And
const andPrefilterCache = new WeakMap<FilterBase, Set<Metro.ModuleID>>()
const andFallbackCache = new WeakMap<FilterBase, Set<Metro.ModuleID>>()
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)
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)
return (
// biome-ignore lint/complexity/useOptionalChain: Hot path should be optimized
(cache && cache.has(id)) || prefilter(id, exports, true)
(cache && cache.has(id)) || fallbackFilter(id, exports)
)
}
return false
}
const result = prefilter(id, undefined, false)
const result = fallbackFilter(id)
if (result) {
// 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()))
// 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()))
set.add(id)
}
return result
},
andKeyGenerator,
FilterFlag.Dynamic,
andScopesGenerator,
) as And
/**
* Combines two filters into one, returning true if **every** filter matches.
*
* If only one of the filters can run on uninitialized modules ({@link FilterScopes.Uninitialized}),
* it is used as the prefilter for uninitialized modules, and the other only runs once a candidate is initialized.
* If each filter has different flags,
*
* @param filters The filters to combine.
*
@ -126,13 +111,23 @@ const prefilteredAnd = createFilterGenerator(
* )
* ```
*/
export const and = Object.assign(compositeHandler(directAnd, prefilteredAnd), {
keyFor: compositeArrayHandler(directAnd.keyFor, prefilteredAnd.keyFor),
defaultScopesFor: compositeArrayHandler(
directAnd.defaultScopesFor,
prefilteredAnd.defaultScopesFor,
export const and = Object.assign(
compositeHandler(sameFlagsAnd, dynamicFlagsAnd),
{
keyFor: compositeArrayHandler(
sameFlagsAnd.keyFor,
dynamicFlagsAnd.keyFor,
),
}) satisfies And
flagsFor: compositeArrayHandler(
sameFlagsAnd.flagsFor,
dynamicFlagsAnd.flagsFor,
),
defaultScopesFor: compositeArrayHandler(
sameFlagsAnd.defaultScopesFor,
dynamicFlagsAnd.defaultScopesFor,
),
},
) satisfies And
export type Or = FilterGenerator<
<F1 extends FilterBase, F2 extends FilterBase>(
@ -146,21 +141,21 @@ const orKeyGenerator = ([a, b]: Parameters<Or>) =>
const orScopesGenerator = ([a, b]: Parameters<Or>) => a.scopes | b.scopes
const directOr = createFilterGenerator(
([a, b], id, exports, initialized) =>
a(id, exports, initialized) || b(id, exports, initialized),
const sameFlagOr = createFilterGenerator(
([a, b], id, exports) => a(id, exports) || b(id, exports),
orKeyGenerator,
([a]) => a.flags,
orScopesGenerator,
) as Or
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)
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)
},
orKeyGenerator,
FilterFlag.Dynamic,
orScopesGenerator,
) as Or
@ -188,10 +183,17 @@ const prefilteredOr = createFilterGenerator(
* )
* ```
*/
export const or = Object.assign(compositeHandler<Or>(directOr, prefilteredOr), {
keyFor: compositeArrayHandler(directOr.keyFor, prefilteredOr.keyFor),
defaultScopesFor: compositeArrayHandler(
directOr.defaultScopesFor,
prefilteredOr.defaultScopesFor,
export const or = Object.assign(
compositeHandler<Or>(sameFlagOr, dynamicFlagOr),
{
keyFor: compositeArrayHandler(sameFlagOr.keyFor, dynamicFlagOr.keyFor),
flagsFor: compositeArrayHandler(
sameFlagOr.flagsFor,
dynamicFlagOr.flagsFor,
),
}) satisfies Or
defaultScopesFor: compositeArrayHandler(
sameFlagOr.defaultScopesFor,
dynamicFlagOr.defaultScopesFor,
),
},
) satisfies Or

View file

@ -1,3 +1,21 @@
export const FilterFlag = {
/**
* This filter works with and without module exports.
* Allowing for both initialized and uninitialized modules to be matched.
*/
Dynamic: 0,
/**
* This filter requires module exports to work.
* Only initialized modules will be matched.
*/
RequiresExports: 1,
} as const
/**
* @see {@link FilterFlag}
*/
export type FilterFlag = number
/**
* Scopes to limit filters to certain module states.
*/
@ -5,16 +23,10 @@ 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. Implies the predicate can run without exports.
* Include uninitialized modules in the search.
*/
Uninitialized: 2,
/**
@ -35,6 +47,10 @@ 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.
*/
@ -43,5 +59,6 @@ export interface FilterInfo {
export interface DefaultFilterInfo extends FilterInfo {
Result: any
RequiresExports: boolean
Scopes: FilterScope[]
}

View file

@ -4,7 +4,7 @@ import {
getModuleDependencies,
} from '../../metro/utils'
import { runFilter } from '../_internal'
import { FilterScopes } from './constants'
import { FilterFlag, FilterScopes } from './constants'
import { createFilterGenerator } from './utils'
import type { Metro } from '../../types'
import type { Filter, FilterGenerator } from './utils'
@ -18,18 +18,8 @@ export interface ComparableDependencyMap
| ComparableDependencyMap
| Filter
> {
// loose
l?: boolean
// relative
r?: number
// skip
s?: number
// atLeast
n?: number
// atMost
x?: number
// includes
i?: boolean
}
/**
@ -40,9 +30,6 @@ export interface ComparableDependencyMap
* To do proper fingerprinting for modules:
* @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
@ -88,30 +75,30 @@ export interface ComparableDependencyMap
*/
const withDependencies_ = createFilterGenerator<Parameters<WithDependencies>>(
([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__
? (Object.assign((deps: ComparableDependencyMap) => {
DEBUG_validateWithDependenciesFilter(deps)
? (((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)
}
return withDependencies_(deps)
}, withDependencies_) as 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<
<T>(deps: ComparableDependencyMap) => Filter<{
Result: T
RequiresExports: false
Scopes: [
typeof FilterScopes.Uninitialized,
typeof FilterScopes.Initialized,
@ -120,11 +107,6 @@ type WithDependencies = FilterGenerator<
> & {
loose: typeof loose
relative: typeof relative
skip: typeof skip
last: typeof last
atLeast: typeof atLeast
atMost: typeof atMost
includes: typeof includes
}
/**
@ -141,96 +123,6 @@ function loose(deps: ComparableDependencyMap) {
return deps
}
/**
* 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
@ -300,70 +192,6 @@ function DEBUG_warnBadWithDependenciesFilter(
)
}
/**
* Warns the developer about a dynamic dependency in a `withDependencies.includes` set, which matches anything and is therefore a no-op.
*/
function DEBUG_warnBadIncludesDependency(
deps: ComparableDependencyMap,
index: number,
) {
nativeLoggingHook(
`\u001b[33mBad withDependencies.includes set, dynamic ID at index ${index} matches anything: [${depGenFilterKey(deps)}]\n${getCurrentStack()}\u001b[0m`,
2,
)
}
/**
* Warns the developer about dependency count bounds that contradict, or are already implied by, the set being compared.
*/
function DEBUG_warnBadDependencyBounds(
deps: ComparableDependencyMap,
reason: string,
) {
nativeLoggingHook(
`\u001b[33mBad withDependencies filter, ${reason}: [${depGenFilterKey(deps)}]\n${getCurrentStack()}\u001b[0m`,
2,
)
}
/**
* Validates a dependency map and its nested sets, warning about mistakes that would otherwise silently never match.
*/
function DEBUG_validateWithDependenciesFilter(deps: ComparableDependencyMap) {
const { n: min, x: max, s: skip } = deps
// The lowest dependency count the set itself can match.
// Unordered sets have no window, so they only ever constrain the count through bounds.
const floor = deps.i
? 0
: (skip === undefined || skip === Infinity ? 0 : skip) + deps.length
if (max !== undefined && max < floor)
DEBUG_warnBadDependencyBounds(
deps,
`atMost(${max}) can never match, the set requires at least ${floor} dependencies`,
)
else if (min !== undefined && max !== undefined && min > max)
DEBUG_warnBadDependencyBounds(
deps,
`atLeast(${min}) can never match, atMost(${max}) is lower`,
)
else if (min !== undefined && min <= floor)
DEBUG_warnBadDependencyBounds(
deps,
`atLeast(${min}) does nothing, the set already requires at least ${floor} dependencies`,
)
for (let i = 0; i < deps.length; i++) {
const dep = deps[i]
// Warn about using undefined in deps, which is likely a mistake
if (dep === undefined) DEBUG_warnBadWithDependenciesFilter(deps, i)
else if (typeof dep === 'object' && dep !== null)
DEBUG_validateWithDependenciesFilter(dep)
}
}
function depCompare(
a: Metro.ModuleID[],
b: ComparableDependencyMap,
@ -372,29 +200,7 @@ function depCompare(
): boolean {
const lenA = a.length
const lenB = b.length
const min = b.n
if (min !== undefined && lenA < min) return false
const max = b.x
if (max !== undefined && lenA > max) return false
if (b.i) return depIncludesCompare(a, b, root, parent)
// Index in a where positional comparison starts
let start = 0
const skip = b.s
if (skip === undefined) {
if (b.l ? lenA < lenB : lenA !== lenB) return false
} 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]
@ -405,70 +211,42 @@ function depCompare(
// Skip dynamic
if (compare == null) continue
const id = a[start + i]
const id = a[i]
// Very rare case where a module dependency has an unused `null` dependency
// TODO: Probably a Metro/Discord bug?
if (id === null) 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<ComparableDependencyMap[number]>,
id: Metro.ModuleID,
root: Metro.ModuleID,
parent: Metro.ModuleID,
): boolean {
switch (typeof compare) {
case 'function':
return !!runFilter(compare, id, getInitializedModuleExports(id))
case 'function': {
const filter = compare
if (!runFilter(filter, id, getInitializedModuleExports(id)))
return false
continue
}
case 'object': {
const nested = compare
const nested = compare as ComparableDependencyMap
// relative.withDependencies?
if (nested.r && !depShallowCompare(nested.r, id, root, parent))
return false
return depCompare(getModuleDependencies(id)!, nested, root, id)
if (depCompare(getModuleDependencies(id)!, nested, root, id))
continue
return false
}
default:
return depShallowCompare(compare, id, root, parent)
default: {
if (depShallowCompare(compare as number, id, root, parent))
continue
return false
}
}
}
return true
}
function depShallowCompare(
compare: number,
@ -513,7 +291,11 @@ function depGenFilterKey(deps: ComparableDependencyMap): string {
case 'object': {
// It's a nested dependency array
const nested = dep as ComparableDependencyMap
key += `${depGenModifierKey(nested)}[${depGenFilterKey(nested)}],`
if (nested.l) key += '#'
// relative.withDependencies?
if (nested.r) key += `${depGenRelativeKeyPart(nested.r)}:`
key += `[${depGenFilterKey(nested)}],`
break
}
default: {
@ -529,20 +311,6 @@ 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 ? '~' : '^'

View file

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

View file

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

View file

@ -183,19 +183,11 @@ export function* lookupModules(filter: Filter, options?: LookupModulesOptions) {
}
if (includeUninit)
// 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)) {
for (const id of mUninitialized) {
// biome-ignore lint/complexity/useOptionalChain: Hot path should be optimized
if (cached && cached.has(id)) continue
const flag = runFilter(
filter,
id,
// Could be initialized by a previous partial match
getInitializedModuleExports(id),
options,
)
const flag = runFilter(filter, id, undefined, options)
if (flag) {
notFound = false
@ -316,15 +308,8 @@ export function lookupModule(filter: Filter, options?: LookupModulesOptions) {
}
if (includeUninit)
// 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,
)
for (const id of mUninitialized) {
const flag = runFilter(filter, id, undefined, options)
if (flag) {
if (__BUILD_FLAG_DEBUG_MODULE_LOOKUPS__)
DEBUG_logLookupMatched(filter.key, flag, id)

View file

@ -8,14 +8,14 @@
import { callNativeMethodSync } from '@revenge-mod/modules/native'
import { getErrorStack } from '@revenge-mod/utils/error'
import { FullVersion } from '~constants'
import { loadModuleFromSegment, mList } from './patches'
import { loadModuleFromSegment, mInitializingId, mList } from './patches'
import {
executeInitializeSubscriptions,
executeRequireSubscriptions,
} from './subscriptions/_internal'
import type { Metro } from '../types'
export const mErrorChain: [Metro.ModuleID, unknown][] = []
export const mErrorChain: Metro.ModuleID[] = []
export const Initialized = 1 << 0
const HasError = 1 << 1
@ -62,7 +62,7 @@ export const metroRequire = (moduleId => {
mod.flags = (flags & NotInitializedOrInitializingMask) | HasError
mod.error = e
const msg = `Module ${moduleId} failed to initialize:\n\n${getErrorStack(e)}`
const msg = `Module ${mInitializingId} failed to initialize:\n\n${getErrorStack(e)}`
if (__DEV__) {
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(moduleId)
// cacheBlacklistedModule(mInitializingId)
// 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
@ -80,7 +80,7 @@ export const metroRequire = (moduleId => {
// if (global.ErrorUtils) global.ErrorUtils.reportFatalError(e)
// else throw e
mErrorChain.push([moduleId, e])
mErrorChain.push(moduleId)
return (moduleObject.exports = {})
}

View file

@ -50,11 +50,7 @@ export function isModuleExportBad(
return (
// Nullish?
exp == null ||
// 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))
// Does it have some non-existent key? (Turbo modules)
(typeof exp === 'object' && CATCH_ALL_TEST in exp)
)
}

View file

@ -21,7 +21,6 @@ import {
ApiDependencyId,
computePendingNodes,
DiscordDependencyId,
isReservedDependency,
pLeafOrSingleNodes,
pListOrdered,
pPending,
@ -310,8 +309,7 @@ registerJSMethod(
const meta = getInternalPluginMeta(plugin)
meta.nativeErrors = Object.freeze(errors)
// TODO: Do we need an errored event specifically?
// Nudge open UI to re-render the errors row
pEmitter.emit('errored', plugin, errors)
pEmitter.emit('flagUpdate', plugin)
},
)
@ -348,12 +346,9 @@ export function registerInternalPlugin<O extends PluginApiExtensionsOptions>(
manifest.version ??= InternalPluginVersion
// TODO: This has to be shared from native somehow.
manifest.format ??= 1
if (!isReservedDependency(manifest.id)) {
manifest.dependencies ??= {}
manifest.dependencies[ApiDependencyId] ??= { version: '*' }
manifest.dependencies[DiscordDependencyId] ??= { version: '*' }
}
return register(manifest as PluginManifest, options, defflags, iflags)
}
@ -581,9 +576,7 @@ export function isPluginStartable(plugin: AnyPlugin): boolean {
*/
export async function handlePluginError(e: unknown, plugin: AnyPlugin) {
;(plugin.errors as unknown[]).push(e)
// TODO: Emit errored event so UI can update?
// Update: errored event removed (but status changes are still emitted), so UI should update fine
pEmitter.emit('errored', plugin, e)
nativeLoggingHook(
`\u001b[31mPlugin "${plugin.manifest.id}" encountered an error: ${

View file

@ -160,6 +160,8 @@ export interface RepoUpdate {
installed: string
available: string
channel: string
/** Repository URL this update was found in, added by {@link listAllUpdates}. */
repo?: string
}
/**
@ -227,7 +229,7 @@ export async function listAllUpdates(): Promise<{
.map(repo =>
listUpdates(repo.url).then(
result => {
updates.push(...result)
updates.push(...result.map(u => ({ ...u, repo: repo.url })))
},
error => {
errors.push({ url: repo.url, error })

View file

@ -7,50 +7,42 @@ import {
withDependencies,
} from '@revenge-mod/modules/finders/filters'
import { isModuleExportBad } from '@revenge-mod/modules/metro/utils'
import { FilterScopes } from '../../modules/src/finders/filters/constants'
import {
ReactJSXRuntimeModuleId,
ReactModuleId,
ReactNativeModuleId,
} from '@revenge-mod/react'
import {
FilterFlag,
FilterScopes,
} from '../../modules/src/finders/filters/constants'
import type {
Filter,
FilterGenerator,
} from '@revenge-mod/modules/finders/filters'
import type { Metro } from '@revenge-mod/modules/types'
import type { FC } from 'react'
const { last } = withDependencies
// [React, ReactJsxRuntime, (Tokens), (BaseIconImage), (Asset), ImportTracker]
// Asset = relativeDep(1)
const IconComponentFilter = [
ReactModuleId,
ReactJSXRuntimeModuleId,
TokensModuleId,
null,
null,
ImportTrackerModuleId,
]
const depsFilters: Record<string, Filter> = {}
/**
* 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]),
))
}
// 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,
]
export type WithGeneratedIconComponent = FilterGenerator<
<N extends string>(
@ -58,6 +50,7 @@ export type WithGeneratedIconComponent = FilterGenerator<
...assets: string[]
) => Filter<{
Result: { [K in N]: FC<any> }
RequiresExports: boolean
Scopes: [
typeof FilterScopes.Uninitialized,
typeof FilterScopes.Initialized,
@ -93,13 +86,47 @@ export type WithGeneratedIconComponent = FilterGenerator<
export const withGeneratedIconComponent = createFilterGenerator<
Parameters<WithGeneratedIconComponent>
>(
(names, id, exports, initialized) => {
// Icon components always export their component, so unusable exports can never match
if (initialized && isModuleExportBad(exports)) return false
(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
return depsFilterFor(names)?.(id, exports, initialized) ?? false
if (names.length > 1) {
const mids = []
for (let i = 1; i < names.length; i++) {
const name = names[i]
const mid = getAssetByName(name)?.moduleId
// Module ID can never be zero, so if it's falsy, it means the asset was not found.
if (!mid) return false
mids.push(mid)
}
filter = [
...MultiIconComponentFilterBase,
...mids,
ImportTrackerModuleId,
]
} else {
const [name] = names
const mid = getAssetByName(name)?.moduleId
// Module ID can never be zero, so if it's falsy, it means the asset was not found.
if (!mid) return false
IconComponentFilter[4] = mid
}
return withDependencies(filter)(id, exports)
}
return false
},
names => `revenge.utils.discord.generatedIconComponent(${names.join(',')})`,
FilterFlag.Dynamic,
FilterScopes.Uninitialized | FilterScopes.Initialized,
) as WithGeneratedIconComponent

View file

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

Binary file not shown.

Before

Width:  |  Height:  |  Size: 14 KiB

View file

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

View file

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

View file

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

View file

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

View file

@ -32,6 +32,11 @@ export interface BrowseFilterAndSortActionSheetProps {
/** Checked repository URLs. */
checked: string[]
setChecked: (urls: string[]) => void
/** Available release channels across all listings. */
channels: string[]
/** Selected release channel. Empty string means default. */
channel: string
setChannel: (channel: string) => void
sort: BrowseSortKey
setSort: (sort: BrowseSortKey) => void
reverse: boolean
@ -42,6 +47,9 @@ export default function BrowseFilterAndSortActionSheet({
repos,
checked,
setChecked,
channels,
channel,
setChannel,
sort,
setSort,
reverse,
@ -76,6 +84,19 @@ export default function BrowseFilterAndSortActionSheet({
)
})}
</TableRowGroup>
<TableRadioGroup
title="Release channel"
defaultValue={channel}
onChange={v => setChannel(v as string)}
>
<TableRadioRow
label="Default (latest / first available)"
value=""
/>
{channels.map(c => (
<TableRadioRow key={c} label={c} value={c} />
))}
</TableRadioGroup>
<TableRadioGroup
title="Sort by"
defaultValue={sort}

View file

@ -1,19 +1,10 @@
import { getAssetIdByName } from '@revenge-mod/assets'
import { ActionSheetActionCreators } from '@revenge-mod/discord/actions'
import { Design } from '@revenge-mod/discord/design'
import { useState } from 'react'
import { PluginInfo } from './PluginCard'
import { IdRow, RepositoryRow } from './PluginOptionsActionSheet'
import type { RepoPluginListing } from '@revenge-mod/plugins/_/repositories'
const {
ActionSheet,
Button,
Stack,
TableRowGroup,
TableRadioGroup,
TableRadioRow,
} = Design
const { ActionSheet, Button, Stack, TableRowGroup } = Design
const DownloadIcon = getAssetIdByName('DownloadIcon', 'png')!
@ -24,11 +15,9 @@ export interface BrowsePluginActionSheetProps {
version: string
icon?: string
id: string
listing: RepoPluginListing
channel: string
/** Display text for the Repository row, eg. `Name (url)`. */
repositoryText: string
onInstall: (channel?: string, version?: string) => void
onInstall: () => void
sheetKey: string
}
@ -42,26 +31,17 @@ export default function BrowsePluginActionSheet({
version,
icon,
id,
listing,
channel,
repositoryText,
onInstall,
sheetKey,
}: BrowsePluginActionSheetProps) {
const [selectedChannel, setSelectedChannel] = useState(channel)
const selectedVersion = selectedChannel
? (listing.channels[selectedChannel] ?? '')
: version
const channelNames = Object.keys(listing.channels)
return (
<ActionSheet>
<Stack spacing={24} style={{ paddingTop: 8 }}>
<PluginInfo
name={name}
author={author}
version={selectedVersion}
version={version}
description={description}
icon={icon}
actions={
@ -73,26 +53,11 @@ export default function BrowsePluginActionSheet({
ActionSheetActionCreators.hideActionSheet(
sheetKey,
)
onInstall(selectedChannel, selectedVersion)
onInstall()
}}
/>
}
/>
{channelNames.length > 0 && (
<TableRadioGroup
title="Channel"
defaultValue={selectedChannel}
onChange={v => setSelectedChannel(v as string)}
>
{channelNames.map(c => (
<TableRadioRow
key={c}
label={c}
value={c}
/>
))}
</TableRadioGroup>
)}
<TableRowGroup title="Advanced">
<IdRow id={id} />
<RepositoryRow text={repositoryText} copyable />

View file

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

View file

@ -8,10 +8,8 @@ const PuzzlePieceIcon = getAssetIdByName('PuzzlePieceIcon', 'png')!
export function PluginIcon({
icon,
size = 20,
danger = false,
}: {
icon?: string
danger?: boolean
size?: number
}) {
const styles = usePluginIconStyles()
@ -28,11 +26,7 @@ export function PluginIcon({
? (getAssetIdByName(icon) ?? PuzzlePieceIcon)
: PuzzlePieceIcon
}
style={[
!dataUrl && styles.icon,
danger && styles.danger,
{ width: size, height: size },
]}
style={[!dataUrl && styles.icon, { width: size, height: size }]}
/>
)
}
@ -41,7 +35,4 @@ const usePluginIconStyles = Design.createStyles({
icon: {
tintColor: Tokens.default.colors.TEXT_DEFAULT,
},
danger: {
tintColor: Tokens.default.colors.TEXT_FEEDBACK_CRITICAL,
},
})

View file

@ -9,7 +9,7 @@ import {
PLUGIN_CARD_HALF_GUTTER,
PluginCard,
} from './PluginCard'
import { useHidePluginTooltips } from './TooltipProvider'
import { useResetTooltips } 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<AnyPlugin>,
'onContentSizeChange'
>) {
const hideTooltips = useHidePluginTooltips()
const resetTooltips = useResetTooltips()
return (
<FlashList
style={gutterCompensation}
onContentSizeChange={onContentSizeChange}
data={plugins}
onScrollBeginDrag={hideTooltips}
onScrollBeginDrag={resetTooltips}
fadingEdgeLength={plugins.length === 1 ? 0 : 16}
keyExtractor={plugin => plugin.manifest.id}
renderItem={({
@ -60,7 +60,7 @@ export function InstalledPluginMasonryFlashList({
plugins: (readonly [AnyPlugin, InternalPluginMeta])[]
}) {
const numColumns = useNumColumns()
const hideTooltips = useHidePluginTooltips()
const resetTooltips = useResetTooltips()
return (
<FlashList
@ -69,7 +69,7 @@ export function InstalledPluginMasonryFlashList({
// FAB is 56px tall, plus 16px spacing on top and bottom
contentContainerStyle={{ paddingBottom: 56 + 2 * 16 }}
data={plugins}
onScrollBeginDrag={hideTooltips}
onScrollBeginDrag={resetTooltips}
fadingEdgeLength={16}
keyExtractor={([plugin]) => plugin.manifest.id}
numColumns={numColumns}
@ -121,14 +121,10 @@ export function BrowsePluginMasonryFlashList({
onInstall,
}: {
entries: BrowseEntry[]
onInstall: (
entry: BrowseEntry,
channel?: string,
version?: string,
) => void
onInstall: (entry: BrowseEntry) => void
}) {
const numColumns = useNumColumns()
const hideTooltips = useHidePluginTooltips()
const resetTooltips = useResetTooltips()
return (
<FlashList
@ -136,7 +132,7 @@ export function BrowsePluginMasonryFlashList({
style={gutterCompensation}
contentContainerStyle={{ paddingBottom: 16 }}
data={entries}
onScrollBeginDrag={hideTooltips}
onScrollBeginDrag={resetTooltips}
fadingEdgeLength={16}
keyExtractor={entry => entry.key}
numColumns={numColumns}
@ -156,12 +152,8 @@ export function BrowsePluginMasonryFlashList({
author={listing.author}
icon={listing.icon ?? undefined}
id={listing.id}
listing={listing}
channel={entry.channel ?? ''}
repositoryText={entry.repositoryText}
onInstall={(channel, version) =>
onInstall(entry, channel, version)
}
onInstall={() => onInstall(entry)}
/>
)
}}

View file

@ -0,0 +1,33 @@
import { TableRowAssetIcon } from '@revenge-mod/components'
import { Design } from '@revenge-mod/discord/design'
import type { AnyPlugin } from '@revenge-mod/plugins/_'
const { ActionSheet, Stack, TableRow, TableRowGroup } = Design
export interface PluginListActionSheetProps {
title: string
plugins: AnyPlugin[]
sheetKey: string
}
export default function PluginListActionSheet({
title,
plugins,
}: PluginListActionSheetProps) {
return (
<ActionSheet>
<Stack spacing={24} style={{ paddingTop: 8 }}>
<TableRowGroup title={title}>
{plugins.map(plugin => (
<TableRow
key={plugin.manifest.id}
icon={<TableRowAssetIcon name="ListBulletsIcon" />}
label={plugin.manifest.name}
subLabel={plugin.manifest.id}
/>
))}
</TableRowGroup>
</Stack>
</ActionSheet>
)
}

View file

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

View file

@ -1,131 +0,0 @@
import { Design } from '@revenge-mod/discord/design'
import { Clipboard } from '@revenge-mod/externals/react-native-clipboard'
import { formatVersion } from '@revenge-mod/plugins/utils'
import { PluginIcon } from './PluginIcon'
import type { DiscordModules } from '@revenge-mod/discord/types'
import type { AnyPlugin } from '@revenge-mod/plugins/_'
import type {
PluginDependency,
PluginManifest,
} from '@revenge-mod/plugins/types'
const { ActionSheet, Stack, TableRow, TableRowGroup } = Design
export interface PluginRelationsListActionSheetProps {
title: string
unsatisfiedTitle?: string
dependencyMap?: NonNullable<PluginManifest['dependencies']>
plugins: AnyPlugin[]
unsatisfiedPlugins?: Array<string | AnyPlugin>
}
interface PluginRowProps {
plugin: AnyPlugin
variant?: DiscordModules.Components.TableRowProps['variant']
dependency?: PluginDependency
showInstalledVersion?: boolean
}
const formatDependencyRequirement = (dependency: PluginDependency) =>
`(${dependency.version ?? '*'} ${dependency.optional ? 'optional' : 'required'})`
function PluginRow({
plugin,
dependency,
variant,
showInstalledVersion = false,
}: PluginRowProps) {
const { id, name, version, icon } = plugin.manifest
const installedVersion = showInstalledVersion
? `@${formatVersion(version)}`
: ''
const depRequirement = dependency
? ` ${formatDependencyRequirement(dependency)}`
: ''
const subLabel = `${id}${installedVersion}${depRequirement}`
return (
<TableRow
icon={
<PluginIcon
danger={variant === 'danger'}
icon={icon ?? undefined}
size={24}
/>
}
label={name}
subLabel={subLabel}
variant={variant}
onPress={() => Clipboard.setString(subLabel)}
/>
)
}
export default function PluginRelationsListActionSheet({
title,
unsatisfiedTitle,
dependencyMap,
plugins,
unsatisfiedPlugins,
}: PluginRelationsListActionSheetProps) {
return (
<ActionSheet>
<Stack spacing={24} style={{ paddingTop: 8 }}>
<TableRowGroup title={title}>
{plugins.map(plugin => (
<PluginRow
key={plugin.manifest.id}
plugin={plugin}
// biome-ignore lint/suspicious/noNonNullAssertedOptionalChain: Can't be undefined
dependency={dependencyMap?.[plugin.manifest.id]!}
showInstalledVersion
/>
))}
</TableRowGroup>
{Boolean(unsatisfiedPlugins?.length) && (
<TableRowGroup title={unsatisfiedTitle}>
{unsatisfiedPlugins?.map(plugin => {
if (typeof plugin === 'string') {
// biome-ignore lint/suspicious/noNonNullAssertedOptionalChain: Can't be undefined
const dep = dependencyMap?.[plugin]!
return (
<TableRow
key={plugin}
icon={
<PluginIcon
icon="CircleQuestionIcon"
danger
size={24}
/>
}
label={plugin}
subLabel={`${plugin} (not installed) ${formatDependencyRequirement(dep)}`}
variant="danger"
onPress={() =>
Clipboard.setString(plugin)
}
/>
)
}
return (
<PluginRow
key={plugin.manifest.id}
plugin={plugin}
variant="danger"
dependency={
// biome-ignore lint/suspicious/noNonNullAssertedOptionalChain: Can't be undefined
dependencyMap?.[plugin.manifest.id]!
}
/>
)
})}
</TableRowGroup>
)}
</Stack>
</ActionSheet>
)
}

View file

@ -0,0 +1,112 @@
import { Design } from '@revenge-mod/discord/design'
import {
listRepoPlugins,
type RepoPluginListing,
type RepoUpdate,
} from '@revenge-mod/plugins/_/repositories'
import { useEffect, useState } from 'react'
import { View } from 'react-native'
const { AlertModal, AlertActionButton, Text } = Design
interface UpdateDetail {
id: string
name: string
author: string
installed: string
available: string
channel: string
}
export default function PluginUpdatesFoundAlert({
updates,
action,
}: {
updates: RepoUpdate[]
action: () => Promise<void>
}) {
const [details, setDetails] = useState<UpdateDetail[] | null>(null)
useEffect(() => {
const repoMap = new Map<string, RepoUpdate[]>()
for (const update of updates) {
const repo = update.repo!
if (!repoMap.has(repo)) repoMap.set(repo, [])
repoMap.get(repo)!.push(update)
}
const allDetails: UpdateDetail[] = []
Promise.all(
[...repoMap.entries()].map(async ([repo, repoUpdates]) => {
let listings: RepoPluginListing[]
try {
listings = await listRepoPlugins(repo)
} catch {
listings = []
}
for (const update of repoUpdates) {
const listing = listings.find(l => l.id === update.id)
allDetails.push({
id: update.id,
name: listing?.name ?? update.id,
author: listing?.author ?? 'Unknown',
installed: update.installed,
available: update.available,
channel: update.channel,
})
}
}),
).then(() => setDetails(allDetails))
}, [updates])
return (
<AlertModal
title="Updates Found"
content={
<Text color="text-default">
The following plugins have updates available:
</Text>
}
extraContent={
<View style={{ paddingTop: 8 }}>
{details === null ? (
<Text color="text-muted">Loading...</Text>
) : (
details.map(detail => (
<View
key={detail.id}
style={{ marginBottom: 12 }}
>
<Text
variant="text-md/semibold"
color="text-default"
>
{detail.name}
</Text>
<Text color="text-muted">
{detail.installed} → {detail.available} (
{detail.channel})
</Text>
<Text color="text-muted">
by {detail.author}
</Text>
</View>
))
)}
</View>
}
actions={
<>
<AlertActionButton
onPress={action}
text="Update & Reload"
variant="primary"
/>
<AlertActionButton text="Not now" variant="secondary" />
</>
}
/>
)
}

View file

@ -18,7 +18,8 @@ export default function RepoRemoveConfirmationAlert({
<Text variant="text-md/semibold" color="text-default">
{repo.name ?? repo.url}
</Text>{' '}
will be removed.
will be removed. You can restore the default repositories by
clearing Plugin Settings data.
</Text>
}
actions={

View file

@ -1,134 +1,109 @@
import { Design } from '@revenge-mod/discord/design'
import {
createContext,
useCallback,
useContext,
useMemo,
useRef,
useState,
} from 'react'
import {
ClickOutsideProvider,
useClickOutside,
} from 'react-native-click-outside'
import { createContext, useContext, useRef, useState } from 'react'
import { useClickOutside } from 'react-native-click-outside'
import type { RefObject } from 'react'
import type { View } from 'react-native'
const { useTooltip } = Design
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, string> = {
[PluginTooltip.Enable]: 'Plugin must be enabled first',
[PluginTooltip.Essential]: 'Plugin needed for Revenge to function properly',
[PluginTooltip.PendingUpdate]:
'Plugin has a pending update, reload to apply it',
interface TooltipContextValue {
targetRef: RefObject<View | null>
setVisible: (visible: boolean) => void
}
interface PluginTooltipsContextValue {
show: (tooltip: PluginTooltip, target: View | null) => void
hide: () => void
}
const PluginTooltipsContext = createContext<PluginTooltipsContextValue | null>(
const EnablePluginTooltipContext = createContext<TooltipContextValue | null>(
null,
)
const EssentialPluginTooltipContext = createContext<TooltipContextValue | null>(
null,
)
export default function PluginTooltipsProvider({
export function EnablePluginTooltipProvider({
children,
}: {
children: React.ReactNode
}) {
const [visible, setVisible] = useState<PluginTooltip | null>(null)
const [visible, setVisible] = useState(false)
const targetRef = useRef<View | null>(null)
// Targets are assigned by `show`, right before the tooltip becomes visible
const enableTarget = useRef<View | null>(null)
const essentialTarget = useRef<View | null>(null)
const pendingUpdateTarget = useRef<View | null>(null)
useTooltip(enableTarget, {
label: Labels[PluginTooltip.Enable],
useTooltip(targetRef, {
label: 'Plugin must be enabled first',
position: 'top',
visible: visible === PluginTooltip.Enable,
visible,
})
useTooltip(essentialTarget, {
label: Labels[PluginTooltip.Essential],
position: 'top',
visible: visible === PluginTooltip.Essential,
})
useTooltip(pendingUpdateTarget, {
label: Labels[PluginTooltip.PendingUpdate],
position: 'top',
visible: visible === PluginTooltip.PendingUpdate,
})
const value = useMemo<PluginTooltipsContextValue>(() => {
const targets = {
[PluginTooltip.Enable]: enableTarget,
[PluginTooltip.Essential]: essentialTarget,
[PluginTooltip.PendingUpdate]: pendingUpdateTarget,
}
return {
show: (tooltip, target) => {
targets[tooltip].current = target
setVisible(tooltip)
},
hide: () => setVisible(null),
}
}, [])
return (
<ClickOutsideProvider>
<PluginTooltipsContext.Provider value={value}>
<EnablePluginTooltipContext.Provider value={{ targetRef, setVisible }}>
{children}
</PluginTooltipsContext.Provider>
</ClickOutsideProvider>
</EnablePluginTooltipContext.Provider>
)
}
/**
* 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',
)
export function EssentialPluginTooltipProvider({
children,
}: {
children: React.ReactNode
}) {
const [visible, setVisible] = useState(false)
const targetRef = useRef<View | null>(null)
const { show, hide } = context
// Tapping anywhere else dismisses the tooltip
const ref = useClickOutside<View>(hide)
const showTooltip = useCallback(() => {
// Tapping the element itself also counts as a tap outside of it (the
// press target wraps the ref), so showing waits for the next frame
requestAnimationFrame(() => {
show(tooltip, ref.current)
useTooltip(targetRef, {
label: 'Plugin needed for Revenge to function properly',
position: 'top',
visible,
})
}, [show, tooltip, ref])
return [ref, showTooltip] as const
return (
<EssentialPluginTooltipContext.Provider
value={{ targetRef, setVisible }}
>
{children}
</EssentialPluginTooltipContext.Provider>
)
}
/** Hides the visible plugin tooltip, if any.. */
export function useHidePluginTooltips() {
const context = useContext(PluginTooltipsContext)
return useCallback(() => context?.hide(), [context])
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<View>(() => {
setVisible(false)
if (targetRef.current === ref.current) {
onClickOutside()
}
})
return ref
}

View file

@ -1,18 +1,22 @@
import { ToastActionCreators } from '@revenge-mod/discord/actions'
import { onSettingsModulesLoaded } from '@revenge-mod/discord/modules/settings'
import { JsonStorageUpdateMode } from '@revenge-mod/json-storage'
import { reloadApp } from '@revenge-mod/modules/native/app'
import {
InternalPluginFlags,
PluginFlags,
registerInternalPlugin,
} from '@revenge-mod/plugins/_'
import {
listAllUpdates,
refreshAllRepos,
updateAllPlugins,
} from '@revenge-mod/plugins/_/repositories'
import { lookupGeneratedIconComponent } from '@revenge-mod/utils/discord'
import pluginSettings from '../settings'
import { showPluginUpdatesFoundAlert } from './utils/alerts'
import { addDefaultRepoIfNeeded } from './repos'
import { showErrorToast } from './utils/repos'
import type { JsonStorage } from '@revenge-mod/json-storage'
import type { PluginApi } from '@revenge-mod/plugins/types'
@ -22,7 +26,7 @@ export interface Storage {
defaultRepoRestored?: boolean
}
const OUTDATED_THRESHOLD = 60 * 60 * 1000 // 1 hour
const OUTDATED_THRESHOLD = 24 * 60 * 60 * 1000 // 1 day
const AUTO_UPDATE_CHECK_DELAY = 10_000
const CircleXIconComponent = lookupGeneratedIconComponent(
@ -78,11 +82,33 @@ function autoUpdateService(settings: Storage) {
setTimeout(async () => {
try {
const { errors } = await refreshAllRepos()
await updateAllPlugins()
if (!errors.length)
await api.jsonStorage.set({
lastUpdateCheck: Date.now(),
if (errors.length) {
ToastActionCreators.open({
key: 'PLUGIN_UPDATE_CHECK_FAILED',
content: 'Failed to refresh some plugin repositories',
IconComponent: CircleXIconComponent,
})
return
}
const { updates } = await listAllUpdates()
await api.jsonStorage.set({ lastUpdateCheck: Date.now() })
if (!updates.length) return
showPluginUpdatesFoundAlert(updates, async () => {
const { errors: updateErrors } = await updateAllPlugins()
if (updateErrors.length) {
showErrorToast(
updateErrors
.map(e => `${e.id}: ${e.error}`)
.join('\n'),
)
} else {
reloadApp()
}
})
} catch (e) {
ToastActionCreators.open({

View file

@ -7,16 +7,14 @@ import { noop } from '@revenge-mod/utils/callback'
import { api } from '.'
import type { Repo, RepoConfigEntry } from '@revenge-mod/plugins/_/repositories'
export async function addDefaultRepoIfNeeded(
refresh = false,
): Promise<boolean> {
if (!__BUILD_DEFAULT_PLUGIN_REPOSITORY_URL__) return false
export async function addDefaultRepoIfNeeded() {
if (!__BUILD_DEFAULT_PLUGIN_REPOSITORY_URL__) return
const repos = await listRepos()
if (
repos.some(repo => repo.url === __BUILD_DEFAULT_PLUGIN_REPOSITORY_URL__)
)
return false
return
const userRepos = toConfig(repos.filter(repo => !repo.internal))
await setRepos([
@ -26,10 +24,7 @@ export async function addDefaultRepoIfNeeded(
await api.jsonStorage.set({ defaultRepoRestored: true }).catch(noop)
if (refresh)
await refreshRepo(__BUILD_DEFAULT_PLUGIN_REPOSITORY_URL__).catch(noop)
return true
}
export function toConfig(list: Repo[]): RepoConfigEntry[] {

View file

@ -3,7 +3,6 @@ import { FormSwitch } from '@revenge-mod/components'
import { styles } from '@revenge-mod/components/_'
import Page from '@revenge-mod/components/Page'
import TableRowAssetIcon from '@revenge-mod/components/TableRowAssetIcon'
import { ToastActionCreators } from '@revenge-mod/discord/actions'
import { Design } from '@revenge-mod/discord/design'
import { Clipboard } from '@revenge-mod/externals/react-native-clipboard'
import { callNativeMethod } from '@revenge-mod/modules/native'
@ -18,12 +17,12 @@ import {
updateAllPlugins,
} from '@revenge-mod/plugins/_/repositories'
import { lookupGeneratedIconComponent } from '@revenge-mod/utils/discord'
import { useCallback, useEffect, useReducer, useState } from 'react'
import { useCallback, useEffect, useState } from 'react'
import { ScrollView, View } from 'react-native'
import { api } from '..'
import { addDefaultRepoIfNeeded, toConfig } from '../repos'
import { showRemoveRepoConfirmation } from '../utils/alerts'
import { formatBytes, messageOf, showErrorToast } from '../utils/repos'
import { showRemoveRepoConfirmation } from '../utils/alerts'
import type {
DownloadProgressEvent,
Repo,
@ -103,8 +102,7 @@ function UserRepoRow({
label: 'Delete',
IconComponent: TrashIconComponent,
variant: 'destructive' as const,
action: () =>
showRemoveRepoConfirmation(repo, () => onRemove(repo)),
action: () => showRemoveRepoConfirmation(repo, () => onRemove(repo)),
},
],
]
@ -148,7 +146,6 @@ export default function RevengePluginsAdvancedSettingScreen() {
Record<string, RepoStateEvent['state']>
>({})
const [progress, setProgress] = useState<DownloadProgressEvent | null>(null)
const [n, forceUpdate] = useReducer(x => ~x, 0)
useEffect(() => {
const onRepoState = (event: RepoStateEvent) => {
@ -172,8 +169,7 @@ export default function RevengePluginsAdvancedSettingScreen() {
listRepos().then(setReposState, e => showErrorToast(messageOf(e)))
}, [])
// biome-ignore lint/correctness/useExhaustiveDependencies: forceUpdate so we can refresh the screen
useEffect(refresh, [refresh, n])
useEffect(refresh, [refresh])
const commit = useCallback(
async (config: RepoConfigEntry[]) => {
@ -217,6 +213,7 @@ export default function RevengePluginsAdvancedSettingScreen() {
const removeRepo = useCallback(
async (repo: Repo) => {
await commit(toConfig(userRepos.filter(r => r.url !== repo.url)))
// Plugins installed from the removed repository turn Sideloaded
@ -375,24 +372,12 @@ export default function RevengePluginsAdvancedSettingScreen() {
/>
<TableRow
icon={<TableRowAssetIcon name="GlobeEarthIcon" />}
label="Restore default repository"
onPress={async () => {
try {
const restored =
await addDefaultRepoIfNeeded(true)
if (!restored) {
ToastActionCreators.open({
key: 'revenge-default-repo-nothing',
content: 'Nothing to restore',
})
return
}
forceUpdate()
} catch (e) {
showErrorToast(messageOf(e))
}
label="Restore default repositories"
subLabel="Add the bundled default repository if it's missing"
onPress={() => {
addDefaultRepoIfNeeded().catch(e =>
showErrorToast(messageOf(e)),
)
}}
/>
</TableRowGroup>

View file

@ -13,9 +13,13 @@ 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 PluginTooltipsProvider from '../components/TooltipProvider'
import {
EnablePluginTooltipProvider,
EssentialPluginTooltipProvider,
} from '../components/TooltipProvider'
import { runInstallFlow } from '../utils/repos'
import type { RepoPluginListing } from '@revenge-mod/plugins/_/repositories'
import type { BrowseSortKey } from '../components/BrowseFilterAndSortActionSheet'
@ -30,13 +34,17 @@ const SearchDebounceTime = 100
export default function RevengePluginsBrowseSettingScreen() {
return (
<LayerScope>
<ClickOutsideProvider>
<PluginStatesProvider>
<Page spacing={16}>
<PluginTooltipsProvider>
<EssentialPluginTooltipProvider>
<EnablePluginTooltipProvider>
<Screen />
</PluginTooltipsProvider>
</EnablePluginTooltipProvider>
</EssentialPluginTooltipProvider>
</Page>
</PluginStatesProvider>
</ClickOutsideProvider>
</LayerScope>
)
}
@ -74,15 +82,18 @@ function Screen() {
const [excluded, setExcluded] = useState<string[]>([])
const [sort, setSort] = useState<BrowseSortKey>('name')
const [reverse, setReverse] = useState(false)
const [channel, setChannel] = useState('')
const [channels, setChannels] = useState<string[]>([])
const hasFilter = useMemo(
() => excluded.length > 0 || sort !== 'name' || reverse,
[excluded, sort, reverse],
() => excluded.length > 0 || sort !== 'name' || reverse || channel !== '',
[excluded, sort, reverse, channel],
)
const load = useCallback(async () => {
const repos = await listRepos()
const all: BrowseEntry[] = []
const allChannels = new Set<string>()
setInternalRepos(
repos.filter(repo => repo.internal).map(repo => repo.url),
@ -100,7 +111,9 @@ function Screen() {
for (const listing of listings) {
const plugin = pList.get(listing.id)
const displayChannel = displayChannelOf(listing, '')
for (const c of Object.keys(listing.channels))
allChannels.add(c)
const displayChannel = displayChannelOf(listing, channel)
const displayVersion = displayChannel
? (listing.channels[displayChannel] ?? '')
: ''
@ -130,8 +143,9 @@ function Screen() {
),
)
setChannels([...allChannels].sort())
setEntries(all)
}, [])
}, [channel, setChannels])
useEffect(() => {
// Show cached indexes right away, then refresh everything
@ -153,24 +167,12 @@ function Screen() {
}, [load])
const install = useCallback(
async (
entry: BrowseEntry,
channel?: string,
version?: string,
) => {
const targetChannel = channel || entry.channel
const targetVersion =
version ||
(targetChannel
? (entry.listing.channels[targetChannel] ?? '')
: '') ||
entry.version
async (entry: BrowseEntry) => {
// Pin the displayed version, channel, and repository, so the plan matches the card
await runInstallFlow(
entry.listing.id,
targetVersion || undefined,
targetChannel,
entry.version || undefined,
entry.channel,
// Internal repos so external plugins can link against internal plugins as well
[...internalRepos, entry.repoUrl],
)
@ -237,6 +239,9 @@ function Screen() {
.map(repo => repo.url)
.filter(url => !urls.includes(url)),
),
channels,
channel,
setChannel,
sort,
setSort,
reverse,

View file

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

View file

@ -13,8 +13,9 @@ import PluginHasDependentsAlert from '../components/PluginHasDependentsAlert'
import PluginMissingDependenciesAlert from '../components/PluginMissingDependenciesAlert'
import PluginStatesProvider from '../components/PluginStateProvider'
import PluginUninstallConfirmationAlert from '../components/PluginUninstallConfirmationAlert'
import PluginUpdatesFoundAlert from '../components/PluginUpdatesFoundAlert'
import RepoRemoveConfirmationAlert from '../components/RepoRemoveConfirmationAlert'
import type { Repo } from '@revenge-mod/plugins/_/repositories'
import type { Repo, RepoUpdate } from '@revenge-mod/plugins/_/repositories'
import type { AnyPlugin } from '@revenge-mod/plugins/_'
export function showPluginClearDataConfirmation(
@ -134,3 +135,13 @@ export function showPluginHasDependentsAlert(
</PluginStatesProvider>,
)
}
export function showPluginUpdatesFoundAlert(
updates: RepoUpdate[],
action: () => Promise<void>,
) {
AlertActionCreators.openAlert(
'plugin-updates-found',
<PluginUpdatesFoundAlert updates={updates} action={action} />,
)
}

View file

@ -1,21 +1,8 @@
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 (
<ScrollView style={{ flex: 1 }}>
<Stack spacing={0}>
<View style={{ padding: 16, paddingBottom: 0 }}>
<DonateCard />
</View>
<View>
<SettingListRenderer.SettingsList
node={{
type: 'list',
@ -48,92 +35,5 @@ export default function RevengeSettingScreen() {
],
}}
/>
</View>
</Stack>
</ScrollView>
)
}
const cardStyles = StyleSheet.create({
background: {
position: 'absolute',
top: 0,
left: 0,
width: '100%',
height: '100%',
},
illustration: {
width: 96,
height: 96,
},
})
function DonateCard() {
return (
<Card
style={{
position: 'relative',
padding: 0,
overflow: 'hidden',
}}
>
<View
style={[
cardStyles.background,
{
opacity: 0.5,
experimental_backgroundImage:
'linear-gradient(166deg, rgba(255, 131, 29, 0.33), rgba(145, 47, 47, 0)), ' +
'linear-gradient(45deg, rgba(145, 47, 47, 0), rgba(255, 22, 22, 0.5))',
},
]}
/>
<View style={{ padding: 16 }}>
<Stack style={styles.grow} direction="horizontal" spacing={16}>
<Stack spacing={16} style={styles.flex}>
<Stack spacing={6} style={styles.flex}>
<Text
variant="heading-lg/semibold"
color="text-strong"
>
Support Revenge
</Text>
<Text variant="text-md/medium">
You can support the development of Revenge by
donating or contributing!
</Text>
</Stack>
<Stack spacing={12} direction="horizontal">
{__BUILD_DONATE_URL__ && (
<Button
size="sm"
icon={getAssetIdByName('HeartIcon')}
text="Donate"
variant="expressive"
onPress={() => {
Linking.openURL(__BUILD_DONATE_URL__)
}}
/>
)}
<Button
size="sm"
icon={getAssetIdByName('HandRequestSpeakIcon')}
text="Contribute"
variant="primary-overlay"
onPress={() => {
Linking.openURL(
__BUILD_SOURCE_REPOSITORY_URL__,
)
}}
/>
</Stack>
</Stack>
<Image
source={RevengeDonate}
style={cardStyles.illustration}
/>
</Stack>
</View>
</Card>
)
}

View file

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

View file

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

View file

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

View file

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

1
types/build.d.ts vendored
View file

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