import WebpackModules from "@modules/webpackmodules"; import Patcher from "@modules/patcher"; import Logger from "@common/logger"; import React from "@modules/react"; let startupComplete = false; const ModulesBundle = WebpackModules.getByProps("MenuItem", "Menu"); const MenuComponents = { Separator: ModulesBundle?.MenuSeparator, CheckboxItem: ModulesBundle?.MenuCheckboxItem, RadioItem: ModulesBundle?.MenuRadioItem, ControlItem: ModulesBundle?.MenuControlItem, Group: ModulesBundle?.MenuGroup, Item: ModulesBundle?.MenuItem, Menu: ModulesBundle?.Menu, }; startupComplete = Object.values(MenuComponents).every(v => v); const ContextMenuActions = (() => { const out = {}; try { const ActionsModule = WebpackModules.getModule(m => Object.values(m).some(v => typeof v === "function" && v.toString().includes("CONTEXT_MENU_CLOSE")), {searchExports: false}); for (const key of Object.keys(ActionsModule)) { if (ActionsModule[key].toString().includes("CONTEXT_MENU_CLOSE")) { out.closeContextMenu = ActionsModule[key]; } else if (ActionsModule[key].toString().includes("renderLazy")) { out.openContextMenu = ActionsModule[key]; } } startupComplete &&= typeof(out.closeContextMenu) === "function" && typeof(out.openContextMenu) === "function"; } catch (error) { startupComplete = false; Logger.stacktrace("ContextMenu~Components", "Fatal startup error:", error); Object.assign(out, { closeContextMenu: () => {}, openContextMenu: () => {} }); } return out; })(); class MenuPatcher { static MAX_PATCH_ITERATIONS = 10; static patches = {}; static subPatches = new WeakMap(); static initialize() { if (!startupComplete) return Logger.warn("ContextMenu~Patcher", "Startup wasn't successfully, aborting initialization."); const {module, key} = (() => { const foundModule = WebpackModules.getModule(m => Object.values(m).some(v => typeof v === "function" && v.toString().includes("CONTEXT_MENU_CLOSE")), {searchExports: false}); const foundKey = Object.keys(foundModule).find(k => foundModule[k].length === 3); return {module: foundModule, key: foundKey}; })(); Patcher.before("ContextMenuPatcher", module, key, (_, methodArguments) => { const promise = methodArguments[1]; methodArguments[1] = async function () { const render = await promise.apply(this, arguments); return props => { const res = render(props); if (res?.props.navId) { MenuPatcher.runPatches(res.props.navId, res, props); } else if (typeof res?.type === "function") { MenuPatcher.patchRecursive(res, "type"); } return res; }; }; }); } static patchRecursive(target, method, iteration = 0) { if (iteration >= this.MAX_PATCH_ITERATIONS) return; const proxyFunction = this.subPatches.get(target[method]) ?? (() => { const originalFunction = target[method]; const depth = ++iteration; function patch() { const res = originalFunction.apply(this, arguments); if (!res) return res; if (res.props?.navId ?? res.props?.children?.props?.navId) { MenuPatcher.runPatches(res.props.navId ?? res.props?.children?.props?.navId, res, arguments[0]); } else { const layer = res.props.children ? res.props.children : res; if (typeof layer?.type == "function") { MenuPatcher.patchRecursive(layer, "type", depth); } } return res; } patch._originalFunction = originalFunction; Object.assign(patch, originalFunction); this.subPatches.set(originalFunction, patch); return patch; })(); target[method] = proxyFunction; } static runPatches(id, res, props) { if (!this.patches[id]) return; for (const patch of this.patches[id]) { try { patch(res, props); } catch (error) { Logger.error("ContextMenu~runPatches", `Could not run ${id} patch for`, patch, error); } } } static patch(id, callback) { this.patches[id] ??= new Set(); this.patches[id].add(callback); } static unpatch(id, callback) { this.patches[id]?.delete(callback); } } /** * `ContextMenu` is a module to help patch and create context menus. Instance is accessible through the {@link BdApi}. * @type ContextMenu * @summary {@link ContextMenu} is a utility class for interacting with React internals. * @name ContextMenu */ class ContextMenu { /** * Allows you to patch a given context menu. Acts as a wrapper around the `Patcher`. * * @param {string} navId Discord's internal `navId` used to identify context menus * @param {function} callback Callback function that accepts the React render tree * @returns {function} A function that automatically unpatches */ patch(navId, callback) { MenuPatcher.patch(navId, callback); return () => MenuPatcher.unpatch(navId, callback); } /** * Allows you to remove the patch added to a given context menu. * * @param {string} navId The original `navId` from patching * @param {function} callback The original callback from patching */ unpatch(navId, callback) { MenuPatcher.unpatch(navId, callback); } /** * Builds a single menu item. The only prop shown here is the type, the rest should * match the actual component being built. View those to see what options exist * for each, they often have less in common than you might think. * * @param {object} props Props used to build the item * @param {string} [props.type="text"] Type of the item, options: text, submenu, toggle, radio, custom, separator * @returns {object} The created component * * @example * // Creates a single menu item that prints "MENU ITEM" on click * ContextMenu.buildItem({ * label: "Menu Item", * action: () => {console.log("MENU ITEM");} * }); * * @example * // Creates a single toggle item that starts unchecked * // and print the new value on every toggle * ContextMenu.buildItem({ * type: "toggle", * label: "Item Toggle", * checked: false, * action: (newValue) => {console.log(newValue);} * }); */ buildItem(props) { const {type} = props; if (type === "separator") return React.createElement(MenuComponents.Separator); let Component = MenuComponents.Item; if (type === "submenu") { if (!props.children) props.children = this.buildMenuChildren(props.render || props.items); } else if (type === "toggle" || type === "radio") { Component = type === "toggle" ? MenuComponents.CheckboxItem : MenuComponents.RadioItem; if (props.active) props.checked = props.active; } else if (type === "control") { Component = MenuComponents.ControlItem; } if (!props.id) props.id = `${props.label.replace(/^[^a-z]+|[^\w-]+/gi, "-")}`; if (props.danger) props.color = "danger"; if (props.onClick && !props.action) props.action = props.onClick; props.extended = true; // This is done to make sure the UI actually displays the on/off correctly if (type === "toggle") { const [active, doToggle] = React.useState(props.checked || false); const originalAction = props.action; props.checked = active; props.action = function(ev) { originalAction(ev); doToggle(!active); }; } return React.createElement(Component, props); } /** * Creates the all the items **and groups** of a context menu recursively. * There is no hard limit to the number of groups within groups or number * of items in a menu. * * @param {Array} setup Array of item props used to build items. See {@link ContextMenu.buildItem}. * @returns {Array} Array of the created component * * @example * // Creates a single item group item with a toggle item * ContextMenu.buildMenuChildren([{ * type: "group", * items: [{ * type: "toggle", * label: "Item Toggle", * active: false, * action: (newValue) => {console.log(newValue);} * }] * }]); * * @example * // Creates two item groups with a single toggle item each * ContextMenu.buildMenuChildren([{ * type: "group", * items: [{ * type: "toggle", * label: "Item Toggle", * active: false, * action: (newValue) => { * console.log(newValue); * } * }] * }, { * type: "group", * items: [{ * type: "toggle", * label: "Item Toggle", * active: false, * action: (newValue) => { * console.log(newValue); * } * }] * }]); */ buildMenuChildren(setup) { const mapper = s => { if (s.type === "group") return buildGroup(s); return this.buildItem(s); }; const buildGroup = function(group) { const items = group.items.map(mapper).filter(i => i); return React.createElement(MenuComponents.Group, null, items); }; return setup.map(mapper).filter(i => i); } /** * Creates the menu *component* including the wrapping `ContextMenu`. * Calls {@link ContextMenu.buildMenuChildren} under the covers. * Used to call in combination with {@link ContextMenu.open}. * * @param {Array} setup Array of item props used to build items. See {@link ContextMenu.buildMenuChildren}. * @returns {function} The unique context menu component */ buildMenu(setup) { return (props) => {return React.createElement(MenuComponents.Menu, props, this.buildMenuChildren(setup));}; } /** * Function that allows you to open an entire context menu. Recommended to build the menu with this module. * * @param {MouseEvent} event The context menu event. This can be emulated, requires target, and all X, Y locations. * @param {function} menuComponent Component to render. This can be any React component or output of {@link ContextMenu.buildMenu}. * @param {object} config Configuration/props for the context menu * @param {string} [config.position="right"] Default position for the menu, options: "left", "right" * @param {string} [config.align="top"] Default alignment for the menu, options: "bottom", "top" * @param {function} [config.onClose] Function to run when the menu is closed */ open(event, menuComponent, config) { return ContextMenuActions.openContextMenu(event, function(e) { return React.createElement(menuComponent, Object.assign({}, e, {onClose: ContextMenuActions.closeContextMenu})); }, config); } /** * Closes the current opened context menu immediately. */ close() {ContextMenuActions.closeContextMenu();} } Object.assign(ContextMenu.prototype, MenuComponents); Object.freeze(ContextMenu); Object.freeze(ContextMenu.prototype); try { MenuPatcher.initialize(); } catch (error) { Logger.error("ContextMenu~Patcher", "Fatal error:", error); } export default ContextMenu;