From 1b199ec5d8e0ca3805a9960323ddd267561b4cf6 Mon Sep 17 00:00:00 2001 From: Nuckyz <61953774+Nuckyz@users.noreply.github.com> Date: Wed, 8 Mar 2023 01:59:50 -0300 Subject: [PATCH] feat: Context Menu API (#496) --- src/api/ContextMenu.ts | 141 ++++++++++++++++++++++++++++++++++ src/api/index.ts | 6 ++ src/plugins/apiContextMenu.ts | 69 +++++++++++++++++ src/webpack/patchWebpack.ts | 10 ++- src/webpack/webpack.ts | 23 +++--- 5 files changed, 236 insertions(+), 13 deletions(-) create mode 100644 src/api/ContextMenu.ts create mode 100644 src/plugins/apiContextMenu.ts diff --git a/src/api/ContextMenu.ts b/src/api/ContextMenu.ts new file mode 100644 index 00000000..64671177 --- /dev/null +++ b/src/api/ContextMenu.ts @@ -0,0 +1,141 @@ +/* + * Vencord, a modification for Discord's desktop app + * Copyright (c) 2023 Vendicated and contributors + * + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU General Public License as published by + * the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details. + * + * You should have received a copy of the GNU General Public License + * along with this program. If not, see . +*/ + +import Logger from "@utils/Logger"; +import type { ReactElement } from "react"; + +/** + * @param children The rendered context menu elements + * @param args Any arguments passed into making the context menu, like the guild, channel, user or message for example + */ +export type NavContextMenuPatchCallback = (children: Array, args?: Array) => void; +/** + * @param The navId of the context menu being patched + * @param children The rendered context menu elements + * @param args Any arguments passed into making the context menu, like the guild, channel, user or message for example + */ +export type GlobalContextMenuPatchCallback = (navId: string, children: Array, args?: Array) => void; + +const ContextMenuLogger = new Logger("ContextMenu"); + +export const navPatches = new Map>(); +export const globalPatches = new Set(); + +/** + * Add a context menu patch + * @param navId The navId(s) for the context menu(s) to patch + * @param patch The patch to be applied + */ +export function addContextMenuPatch(navId: string | Array, patch: NavContextMenuPatchCallback) { + if (!Array.isArray(navId)) navId = [navId]; + for (const id of navId) { + let contextMenuPatches = navPatches.get(id); + if (!contextMenuPatches) { + contextMenuPatches = new Set(); + navPatches.set(id, contextMenuPatches); + } + + contextMenuPatches.add(patch); + } +} + +/** + * Add a global context menu patch that fires the patch for all context menus + * @param patch The patch to be applied + */ +export function addGlobalContextMenuPatch(patch: GlobalContextMenuPatchCallback) { + globalPatches.add(patch); +} + +/** + * Remove a context menu patch + * @param navId The navId(s) for the context menu(s) to remove the patch + * @param patch The patch to be removed + * @returns Wheter the patch was sucessfully removed from the context menu(s) + */ +export function removeContextMenuPatch>(navId: T, patch: NavContextMenuPatchCallback): T extends string ? boolean : Array { + const navIds = Array.isArray(navId) ? navId : [navId as string]; + + const results = navIds.map(id => navPatches.get(id)?.delete(patch) ?? false); + + return (Array.isArray(navId) ? results : results[0]) as T extends string ? boolean : Array; +} + +/** + * Remove a global context menu patch + * @returns Wheter the patch was sucessfully removed + */ +export function removeGlobalContextMenuPatch(patch: GlobalContextMenuPatchCallback): boolean { + return globalPatches.delete(patch); +} + +/** + * A helper function for finding the children array of a group nested inside a context menu based on the id of one of its childs + * @param id The id of the child + */ +export function findGroupChildrenByChildId(id: string, children: Array, itemsArray?: Array): Array | null { + for (const child of children) { + if (child === null) continue; + + if (child.props?.id === id) return itemsArray ?? null; + + let nextChildren = child.props?.children; + if (nextChildren) { + if (!Array.isArray(nextChildren)) { + nextChildren = [nextChildren]; + child.props.children = nextChildren; + } + + const found = findGroupChildrenByChildId(id, nextChildren, nextChildren); + if (found !== null) return found; + } + } + + return null; +} + +interface ContextMenuProps { + contextMenuApiArguments?: Array; + navId: string; + children: Array; + "aria-label": string; + onSelect: (() => void) | undefined; + onClose: (callback: (...args: Array) => any) => void; +} + +export function _patchContextMenu(props: ContextMenuProps) { + const contextMenuPatches = navPatches.get(props.navId); + + if (contextMenuPatches) { + for (const patch of contextMenuPatches) { + try { + patch(props.children, props.contextMenuApiArguments); + } catch (err) { + ContextMenuLogger.error(`Patch for ${props.navId} errored,`, err); + } + } + } + + for (const patch of globalPatches) { + try { + patch(props.navId, props.children, props.contextMenuApiArguments); + } catch (err) { + ContextMenuLogger.error("Global patch errored,", err); + } + } +} diff --git a/src/api/index.ts b/src/api/index.ts index abb50934..e4b87bfc 100644 --- a/src/api/index.ts +++ b/src/api/index.ts @@ -18,6 +18,7 @@ import * as $Badges from "./Badges"; import * as $Commands from "./Commands"; +import * as $ContextMenu from "./ContextMenu"; import * as $DataStore from "./DataStore"; import * as $MemberListDecorators from "./MemberListDecorators"; import * as $MessageAccessories from "./MessageAccessories"; @@ -93,3 +94,8 @@ export const Styles = $Styles; * An API allowing you to display notifications */ export const Notifications = $Notifications; + +/** + * An api allowing you to patch and add/remove items to/from context menus + */ +export const ContextMenu = $ContextMenu; diff --git a/src/plugins/apiContextMenu.ts b/src/plugins/apiContextMenu.ts new file mode 100644 index 00000000..131c209e --- /dev/null +++ b/src/plugins/apiContextMenu.ts @@ -0,0 +1,69 @@ +/* + * Vencord, a modification for Discord's desktop app + * Copyright (c) 2022 Vendicated and contributors + * + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU General Public License as published by + * the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details. + * + * You should have received a copy of the GNU General Public License + * along with this program. If not, see . +*/ + +import { Settings } from "@api/settings"; +import { Devs } from "@utils/constants"; +import definePlugin from "@utils/types"; +import { addListener, removeListener } from "@webpack"; + +function listener(exports: any, id: number) { + if (typeof exports !== "object" || exports === null) return; + + for (const key in exports) if (key.length <= 3) { + const prop = exports[key]; + if (typeof prop !== "function") continue; + + const str = Function.prototype.toString.call(prop); + if (str.includes('path:["empty"]')) { + Vencord.Plugins.patches.push({ + plugin: "ContextMenuAPI", + all: true, + noWarn: true, + find: "navId:", + replacement: { + /** Regex explanation + * Use of https://blog.stevenlevithan.com/archives/mimic-atomic-groups to mimick atomic groups: (?=(...))\1 + * Match ${id} and look behind it for the first match of `=`: ${id}(?=(\i)=.+?) + * Match rest of the code until it finds `.${key},{`: .+?\2\.${key},{ + */ + match: RegExp(`(?=(${id}(?<=(\\i)=.+?).+?\\2\\.${key},{))\\1`, "g"), + replace: "$&contextMenuApiArguments:arguments," + } + }); + + removeListener(listener); + } + } +} + +if (Settings.plugins.ContextMenuAPI.enabled) addListener(listener); + +export default definePlugin({ + name: "ContextMenuAPI", + description: "API for adding/removing items to/from context menus.", + authors: [Devs.Nuckyz], + patches: [ + { + find: "♫ (つ。◕‿‿◕。)つ ♪", + replacement: { + match: /(?<=function \i\((\i)\){)(?=var \i,\i=\i\.navId)/, + replace: (_, props) => `Vencord.Api.ContextMenu._patchContextMenu(${props});` + } + } + ] +}); diff --git a/src/webpack/patchWebpack.ts b/src/webpack/patchWebpack.ts index 19ca9517..697ce949 100644 --- a/src/webpack/patchWebpack.ts +++ b/src/webpack/patchWebpack.ts @@ -92,9 +92,11 @@ function patchPush() { return; } + const numberId = Number(id); + for (const callback of listeners) { try { - callback(exports); + callback(exports, numberId); } catch (err) { logger.error("Error in webpack listener", err); } @@ -104,17 +106,17 @@ function patchPush() { try { if (filter(exports)) { subscriptions.delete(filter); - callback(exports); + callback(exports, numberId); } else if (typeof exports === "object") { if (exports.default && filter(exports.default)) { subscriptions.delete(filter); - callback(exports.default); + callback(exports.default, numberId); } for (const nested in exports) if (nested.length <= 3) { if (exports[nested] && filter(exports[nested])) { subscriptions.delete(filter); - callback(exports[nested]); + callback(exports[nested], numberId); } } } diff --git a/src/webpack/webpack.ts b/src/webpack/webpack.ts index 98a0ea89..0d955879 100644 --- a/src/webpack/webpack.ts +++ b/src/webpack/webpack.ts @@ -57,7 +57,7 @@ export const filters = { export const subscriptions = new Map(); export const listeners = new Set(); -export type CallbackFn = (mod: any) => void; +export type CallbackFn = (mod: any, id: number) => void; export function _initWebpack(instance: typeof window.webpackChunkdiscord_app) { if (cache !== void 0) throw "no."; @@ -86,18 +86,23 @@ export const find = traceFunction("find", function find(filter: FilterFn, getDef const mod = cache[key]; if (!mod?.exports) continue; - if (filter(mod.exports)) - return mod.exports; + if (filter(mod.exports)) { + return isWaitFor ? [mod.exports, Number(key)] : mod.exports; + } if (typeof mod.exports !== "object") continue; - if (mod.exports.default && filter(mod.exports.default)) - return getDefault ? mod.exports.default : mod.exports; + if (mod.exports.default && filter(mod.exports.default)) { + const found = getDefault ? mod.exports.default : mod.exports; + return isWaitFor ? [found, Number(key)] : found; + } // the length check makes search about 20% faster for (const nestedMod in mod.exports) if (nestedMod.length <= 3) { const nested = mod.exports[nestedMod]; - if (nested && filter(nested)) return nested; + if (nested && filter(nested)) { + return isWaitFor ? [nested, Number(key)] : nested; + } } } @@ -112,7 +117,7 @@ export const find = traceFunction("find", function find(filter: FilterFn, getDef } } - return null; + return isWaitFor ? [null, null] : null; }); /** @@ -347,8 +352,8 @@ export function waitFor(filter: string | string[] | FilterFn, callback: Callback else if (typeof filter !== "function") throw new Error("filter must be a string, string[] or function, got " + typeof filter); - const existing = find(filter!, true, true); - if (existing) return void callback(existing); + const [existing, id] = find(filter!, true, true); + if (existing) return void callback(existing, id); subscriptions.set(filter, callback); }