diff --git a/lib/discord/src/modules/settings/_internal.ts b/lib/discord/src/modules/settings/_internal.ts index ff4bf26..7a70943 100644 --- a/lib/discord/src/modules/settings/_internal.ts +++ b/lib/discord/src/modules/settings/_internal.ts @@ -12,9 +12,17 @@ export const sConfig: Record = {} export const sSubscriptions = new Set() +/** + * Refresh IDs and re-render callbacks for the settings UI. + * + * IDs increment when a refresh is requested. + * Patches check if they changed and recomputes/rerenders the hooks/components. + */ export const sRefresher = { - navigator: false, - overviewScreen: false, + navigator: 0, + overviewScreen: 0, callNavigator: noop, + callHookHarness: noop, callOverviewScreen: noop, + callSearchableSettingsList: noop, } diff --git a/lib/discord/src/modules/settings/index.ts b/lib/discord/src/modules/settings/index.ts index e1291c0..67b6583 100644 --- a/lib/discord/src/modules/settings/index.ts +++ b/lib/discord/src/modules/settings/index.ts @@ -138,18 +138,22 @@ export function addSettingsItemToSection( } } -/** - * Refreshes the SettingsOverviewScreen. - */ -export function refreshSettingsOverviewScreen() { - sRefresher.overviewScreen = true +function refreshSettingsOverviewScreen() { + sRefresher.overviewScreen++ sRefresher.callOverviewScreen() } -/** - * Refreshes the settings navigator. - */ -export function refreshSettingsNavigator() { - sRefresher.navigator = true +function refreshSettingsNavigator() { + sRefresher.navigator++ + sRefresher.callHookHarness() sRefresher.callNavigator() + sRefresher.callSearchableSettingsList() +} + +/** + * Refreshes the settings UI. + */ +export function refreshSettings() { + refreshSettingsOverviewScreen() + refreshSettingsNavigator() } diff --git a/lib/discord/src/types/index.ts b/lib/discord/src/types/index.ts index 8532085..7778c73 100644 --- a/lib/discord/src/types/index.ts +++ b/lib/discord/src/types/index.ts @@ -4,6 +4,7 @@ import type { ComponentProps, ComponentType, FC, + MemoExoticComponent, ReactElement, ReactNode, RefAttributes, @@ -623,6 +624,7 @@ export namespace DiscordModules { export namespace Settings { export interface SettingListRenderer { SettingsList: SettingsList + SearchableSettingsList: SearchableSettingsList } export interface SettingsListProps { @@ -640,7 +642,14 @@ export namespace DiscordModules { } } - export type SettingsList = FC + export type SettingsList = MemoExoticComponent< + FC + > + + // TODO: Type props properly + export type SearchableSettingsList = MemoExoticComponent< + FC + > export interface SettingsSection { label: string diff --git a/lib/utils/src/types.ts b/lib/utils/src/types.ts index 07f171d..7eac49b 100644 --- a/lib/utils/src/types.ts +++ b/lib/utils/src/types.ts @@ -2,6 +2,7 @@ export type Nullish = null | undefined export type If = T extends true ? Then : Else export type Not = T extends true ? false : true export type AnyObject = Record +export type AnyFunction = (...args: any[]) => any export type LogicalOr = T1 extends true ? true : T2 extends true @@ -18,6 +19,9 @@ export type DeepPartial = { export type ExtractPredicate = T extends (arg: any) => arg is infer R ? R : never +export type KeyWithType = { + [K in keyof O]: O[K] extends T ? K : never +}[keyof O] /// PLUGIN API EXTENSIONS diff --git a/src/plugins/start/developer-kit/index.ts b/src/plugins/start/developer-kit/index.ts index 3e919b6..882a351 100644 --- a/src/plugins/start/developer-kit/index.ts +++ b/src/plugins/start/developer-kit/index.ts @@ -1,7 +1,6 @@ import { onSettingsModulesLoaded, - refreshSettingsNavigator, - refreshSettingsOverviewScreen, + refreshSettings, } from '@revenge-mod/discord/modules/settings' import { InternalPluginFlags, @@ -57,8 +56,7 @@ registerInternalPlugin<{ jsonStorage: Storage }>( onSettingsModulesLoaded(utils.register) if (isPluginStartedLate(api_.plugin)) { - refreshSettingsOverviewScreen() - refreshSettingsNavigator() + refreshSettings() } const settings = await api.jsonStorage.get() @@ -71,7 +69,7 @@ registerInternalPlugin<{ jsonStorage: Storage }>( if (settings.reactDevTools.autoConnect) rdt.connect() }, stop({ cleanup }) { - cleanup(refreshSettingsOverviewScreen, refreshSettingsNavigator) + cleanup(refreshSettings) }, }, PluginFlags.Enabled, diff --git a/src/plugins/start/settings.plugins/index.ts b/src/plugins/start/settings.plugins/index.ts index 13a450d..c69d730 100644 --- a/src/plugins/start/settings.plugins/index.ts +++ b/src/plugins/start/settings.plugins/index.ts @@ -1,13 +1,8 @@ import { ToastActionCreators } from '@revenge-mod/discord/actions' -import { - onSettingsModulesLoaded, - refreshSettingsNavigator, - refreshSettingsOverviewScreen, -} from '@revenge-mod/discord/modules/settings' +import { onSettingsModulesLoaded } from '@revenge-mod/discord/modules/settings' import { JsonStorageUpdateMode } from '@revenge-mod/json-storage' import { InternalPluginFlags, - isPluginStartedLate, PluginFlags, registerInternalPlugin, } from '@revenge-mod/plugins/_' @@ -63,18 +58,10 @@ registerInternalPlugin<{ jsonStorage: Storage }>( import('./register') }) - if (isPluginStartedLate(api_.plugin)) { - refreshSettingsOverviewScreen() - refreshSettingsNavigator() - } - const settings = await api.jsonStorage.get() autoUpdateService(settings) defaultRepoRestoreService(settings, api.jsonStorage) }, - stop({ cleanup }) { - cleanup(refreshSettingsOverviewScreen, refreshSettingsNavigator) - }, }, PluginFlags.Enabled, InternalPluginFlags.Internal | InternalPluginFlags.Essential, diff --git a/src/plugins/start/settings.plugins/plugins.tsx b/src/plugins/start/settings.plugins/plugins.tsx index 9f675fd..9517ec7 100644 --- a/src/plugins/start/settings.plugins/plugins.tsx +++ b/src/plugins/start/settings.plugins/plugins.tsx @@ -4,7 +4,7 @@ import { ToastActionCreators, } from '@revenge-mod/discord/actions' import { - refreshSettingsNavigator, + refreshSettings, registerSettingsItem, } from '@revenge-mod/discord/modules/settings' import { @@ -72,12 +72,10 @@ pEmitter.on('started', plugin => { getComponent: () => PluginSettings, }, }), - refreshSettingsNavigator, - // TODO(PalmDevs): In the future, we may allow pinning plugin settings, so we'll need this cleanup - // () => refreshSettingsOverviewScreen(), + refreshSettings, ) - refreshSettingsNavigator() + refreshSettings() } }) diff --git a/src/plugins/start/settings/index.ts b/src/plugins/start/settings/index.ts index 7c2e3a0..488030e 100644 --- a/src/plugins/start/settings/index.ts +++ b/src/plugins/start/settings/index.ts @@ -1,8 +1,9 @@ import { sRefresher, sSections } from '@revenge-mod/discord/_/modules/settings' import { onSettingsModulesLoaded } from '@revenge-mod/discord/modules/settings' +import defer * as Renderer from '@revenge-mod/discord/modules/settings/renderer' import { waitForModuleWithImportedPath } from '@revenge-mod/discord/utils/modules/finders' import { waitForModules } from '@revenge-mod/modules/finders' -import { withName } from '@revenge-mod/modules/finders/filters' +import { withName, withProps } from '@revenge-mod/modules/finders/filters' import { instead } from '@revenge-mod/patcher' import { InternalPluginFlags, @@ -14,9 +15,33 @@ import { asap, noop } from '@revenge-mod/utils/callback' import { getCurrentStack } from '@revenge-mod/utils/error' import { useReRender } from '@revenge-mod/utils/react' import { useEffect } from 'react' -import type { FC } from 'react' +import type { SettingsSection } from '@revenge-mod/discord/modules/settings' +import type { AnyFunction, KeyWithType } from '@revenge-mod/utils/types' +import type { FC, MemoExoticComponent, useMemo } from 'react' + +interface MemoComponentModule { + default: MemoExoticComponent> +} + +interface UseSettingSearchResultsModule { + useSettingSearchResults: AnyFunction +} + +interface SettingsOverviewScreenModule { + default: FC +} + +interface OverviewSettingsNode { + sections?: SettingsSection[] +} + +type UseMemoHook = (args: any[], useMemo_: typeof useMemo) => any + +type RefreshIdKey = KeyWithType +type RefreshCallbackKey = KeyWithType void> let DEBUG_patchedNavigator = false +let DEBUG_patchedHookHarness = false const pluginSettings = registerInternalPlugin( { @@ -32,25 +57,42 @@ const pluginSettings = registerInternalPlugin( // @as-require import('./register') - // Debug warnings - asap(() => { - if (__DEV__ && !DEBUG_patchedNavigator) - DEBUG_warnUnpatchedNavigator() - }) + patchSearchableSettingsList() + + if (__DEV__) asap(DEBUG_warnUnpatchedModules) }) - waitForModuleWithImportedPath( + waitForModuleWithImportedPath( + 'modules/settings/native/renderer/SettingHookHarness.tsx', + patchSettingHookHarness, + ) + + waitForModuleWithImportedPath( 'modules/user_settings/core/native/SettingsNavigator.tsx', - exports => { - patchSettingsNavigator(exports) - }, + patchSettingsNavigator, ) const unsubSOS = waitForModules( withName('SettingsOverviewScreen'), exports => { unsubSOS() - patchSettingsOverviewScreen(exports) + patchSettingsOverviewScreen( + exports as SettingsOverviewScreenModule, + ) + }, + { + cached: true, + returnNamespace: true, + }, + ) + + const unsubUSSR = waitForModules( + withProps('useSettingSearchResults'), + exports => { + unsubUSSR() + patchUseSettingSearchResults( + exports as UseSettingSearchResultsModule, + ) }, { cached: true, @@ -63,102 +105,193 @@ const pluginSettings = registerInternalPlugin( InternalPluginFlags.Internal | InternalPluginFlags.Essential, ) -function patchSettingsNavigator(exports: any) { +export default pluginSettings + +// #region Patches + +function patchSettingHookHarness(exports: MemoComponentModule) { instead(exports.default, 'type', (args, orig) => { - const reRender = useReRender() - useEffect(() => { - sRefresher.callNavigator = reRender + useRefresherCallback('callHookHarness') + return Reflect.apply(orig, undefined, args) + }) - return () => { - sRefresher.navigator = false - sRefresher.callNavigator = noop - } - }, [reRender]) + DEBUG_patchedHookHarness = true +} - // useMemo(() => getSettingScreens(), []) - const unpatchMemo = instead(React, 'useMemo', (args, orig) => { - if (!args[1]?.length && sRefresher.navigator) { - args[1] = undefined - sRefresher.navigator = false - } +function patchSettingsNavigator(exports: MemoComponentModule) { + const shouldRefresh = createRefreshTracker('navigator') - return Reflect.apply(orig, React, args) - }) - - const el = Reflect.apply(orig, undefined, args) - unpatchMemo() - return el + // useMemo(() => getSettingScreens(), []) + instead(exports.default, 'type', (args, orig) => { + useRefresherCallback('callNavigator') + return applyWithMemoRefresh(orig, args, shouldRefresh()) }) DEBUG_patchedNavigator = true } -let sectionsInst: object | undefined +function patchSettingsOverviewScreen(exports: SettingsOverviewScreenModule) { + const shouldRefresh = createRefreshTracker('overviewScreen') -function patchSettingsOverviewScreen(exports: any) { + // The sections array our sections were last added to. + let patchedSections: SettingsSection[] | undefined + let refreshing = false + + /** + * In useOverviewSettings (called by SettingsOverviewScreen): + * + * const hasPremiumSubscriptionToDisplay = useHasPremiumSubscriptionToDisplay() + * const sections = useMemo(() => + * (...constructed sections array...), + * [hasPremiumSubscriptionToDisplay]) + */ + const useMemoHook: UseMemoHook = (args, useMemo) => { + // Reconstruct the sections, so newly registered ones are included + const node: OverviewSettingsNode | undefined = refreshing + ? refreshMemo(args, useMemo) + : Reflect.apply(useMemo, React, args) + + const sections = node?.sections + if (!sections) return node + + // Add our custom sections here, and only do this per instance + if (patchedSections !== sections) { + for (const section of Object.values(sSections)) + if (section.index) sections.splice(section.index, 0, section) + else sections.unshift(section) + + patchedSections = sections + } + + // The screen only updates if the sections array changes identity + if (refreshing) { + node.sections = patchedSections = [...sections] + refreshing = false + } + + return node + } + + instead(exports, 'default', (args, orig) => { + useRefresherCallback('callOverviewScreen') + + refreshing = shouldRefresh() + return applyWithUseMemoHook(useMemoHook, orig, args) + }) +} + +function patchSearchableSettingsList() { + const shouldRefresh = createRefreshTracker('navigator') + + // Renders (and memoizes) the results of useSettingSearchResults instead( - exports as { - default: FC - }, - 'default', + Renderer.SettingListRenderer.SearchableSettingsList, + 'type', (args, orig) => { - const reRender = useReRender() - useEffect(() => { - sRefresher.callOverviewScreen = reRender - - return () => { - sRefresher.overviewScreen = false - sRefresher.callOverviewScreen = noop - } - }, [reRender]) - - /** - * In useOverviewSettings (called by SettingsOverviewScreen): - * - * const hasPremiumSubscriptionToDisplay = useHasPremiumSubscriptionToDisplay() - * const sections = useMemo(() => - * (...constructed sections array...), - * [hasPremiumSubscriptionToDisplay]) - */ - const unpatchMemo = instead(React, 'useMemo', (args, orig) => { - if (sRefresher.overviewScreen) args[1] = undefined - - const node = Reflect.apply(orig, React, args) - const sections = node.sections - - // Add our custom sections here, and only do this per instance! - if (sectionsInst !== sections) { - for (const section of Object.values(sSections)) - if (section.index) - sections.splice(section.index, 0, section) - else sections.unshift(section) - - sectionsInst = sections - } - - if (sRefresher.overviewScreen) { - node.sections = sectionsInst = [...sections] - sRefresher.overviewScreen = false - } - - return node - }) - - const el = Reflect.apply(orig, undefined, args) - unpatchMemo() - return el + useRefresherCallback('callSearchableSettingsList') + return applyWithMemoRefresh(orig, args, shouldRefresh()) }, ) } -export default pluginSettings +function patchUseSettingSearchResults(exports: UseSettingSearchResultsModule) { + const shouldRefresh = createRefreshTracker('navigator') + + // useMemo(() => getSettingSearchableTitles(), []) + instead(exports, 'useSettingSearchResults', (args, orig) => + applyWithMemoRefresh(orig, args, shouldRefresh()), + ) +} + +// #region Refreshing /** - * Warns the developer that SettingsNavigator was not patched. + * Creates a tracker for a refresh ID, telling whether a refresh has been + * requested since it was last called. + * + * Every patch needs its own tracker, as they all render (and therefore consume + * refreshes) independently from each other. + * + * @param key The refresh ID to track. */ -function DEBUG_warnUnpatchedNavigator() { - nativeLoggingHook( - `\u001b[31mSettingsNavigator was not patched\n${getCurrentStack()}\u001b[0m`, - 2, - ) +function createRefreshTracker(key: RefreshIdKey) { + let lastId = sRefresher[key] + + return () => { + const id = sRefresher[key] + if (id === lastId) return false + + lastId = id + return true + } +} + +/** + * Registers the component's re-render function as a refresher callback for as long as it is mounted. + * + * @param key The callback to register as. + */ +function useRefresherCallback(key: RefreshCallbackKey) { + const reRender = useReRender() + + useEffect(() => { + sRefresher[key] = reRender + + return () => { + sRefresher[key] = noop + } + }, [key, reRender]) +} + +/** Recomputes a memo. */ +const refreshMemo: UseMemoHook = (args, useMemo) => { + // Pass no dependency array + args[1] = undefined + return Reflect.apply(useMemo, React, args) +} + +/** + * Applies a component's render function (or a hook), refreshing the memos it creates if needed. + * + * @param fn The function to apply. + * @param args The arguments to apply the function with. + * @param refresh Whether the memos should be refreshed. + */ +function applyWithMemoRefresh( + fn: AnyFunction, + args: unknown[], + refresh: boolean, +) { + return refresh + ? applyWithUseMemoHook(refreshMemo, fn, args) + : Reflect.apply(fn, undefined, args) +} + +function applyWithUseMemoHook( + hook: UseMemoHook, + fn: AnyFunction, + args: unknown[], +) { + const unpatch = instead(React, 'useMemo', hook) + + try { + return Reflect.apply(fn, undefined, args) + } finally { + unpatch() + } +} + +// #region Debug + +/** + * Warns the developer about settings modules that were never patched. + */ +function DEBUG_warnUnpatchedModules() { + if (!DEBUG_patchedNavigator) DEBUG_warn('SettingsNavigator was not patched') + if (!DEBUG_patchedHookHarness) + DEBUG_warn('SettingHookHarness was not patched') +} + +function DEBUG_warn(message: string) { + nativeLoggingHook(`\u001b[31m${message}\n${getCurrentStack()}\u001b[0m`, 2) }