From 66f42161d0b3fa8a229b41190e6e68b75142b575 Mon Sep 17 00:00:00 2001 From: Federico Di Leo <38290480+FedeIlLeone@users.noreply.github.com> Date: Wed, 30 Oct 2024 21:37:24 +0100 Subject: [PATCH 1/2] refactor: context menu api (#651) * refactor: context menu api * chore: lint * feat: accept functions for sectionId and indexInSection * chore: add TODO comment for raw context items * fix: types for raw context menu items --- src/renderer/coremods/contextMenu/index.tsx | 154 +++++++--------- .../coremods/contextMenu/plaintextPatches.ts | 6 +- .../modules/components/ContextMenu.tsx | 174 +++++++++--------- src/renderer/modules/injector.ts | 10 +- src/types/coremods/contextMenu.ts | 32 ++-- 5 files changed, 181 insertions(+), 195 deletions(-) diff --git a/src/renderer/coremods/contextMenu/index.tsx b/src/renderer/coremods/contextMenu/index.tsx index 45bcd2832..156036b73 100644 --- a/src/renderer/coremods/contextMenu/index.tsx +++ b/src/renderer/coremods/contextMenu/index.tsx @@ -1,7 +1,6 @@ -import { React, components } from "@common"; -import type { ContextMenuProps } from "@components/ContextMenu"; +import { React, components, lodash } from "@common"; +import type { MenuProps } from "@components/ContextMenu"; import type { - ContextItem, ContextMenuTypes, GetContextItem, RawContextItem, @@ -10,37 +9,35 @@ import { Logger } from "../../modules/logger"; const logger = Logger.api("ContextMenu"); -export const menuItems = {} as Record< - ContextMenuTypes, - | Array<{ getItem: GetContextItem; sectionId: number | undefined; indexInSection: number }> - | undefined ->; +interface MenuItem { + getItem: GetContextItem; + sectionId: number | ((props: ContextMenuProps) => number) | undefined; + indexInSection: number | ((props: ContextMenuProps) => number); +} + +export type ContextMenuProps = MenuProps & { + data: Array>; +}; + +export const menuItems: Record = {}; /** * Converts data into a React element. Any elements or falsy value will be returned as is * @param raw The data to convert * @returns The converted item */ -function makeItem(raw: RawContextItem | ContextItem | undefined | void): ContextItem | undefined { - // Occasionally React won't be loaded when this function is ran, so we don't return anything - // eslint-disable-next-line @typescript-eslint/no-unnecessary-condition - if (!React) return undefined; - - if (!raw) { - // If something falsy is passed, let it through - // Discord just skips over them too - return raw as ContextItem | undefined; - } - if (React.isValidElement(raw)) { - // We can't construct something that's already made - return raw as ContextItem | undefined; - } +function makeItem(raw: ReturnType): React.ReactElement | undefined { + if (!raw) return; + if (React.isValidElement(raw)) return raw; - const { type, ...props } = raw; - if (props.children) { - props.children = props.children.map((child: RawContextItem | ContextItem | undefined) => - makeItem(child), - ); + const { type, ...props } = raw as RawContextItem; + + if ("children" in props && props.children) { + if (Array.isArray(props.children)) { + props.children = props.children.map((child: ReturnType) => makeItem(child)); + } else { + props.children = makeItem(props.children as ReturnType); + } } return React.createElement(type as React.FC, props as Record); @@ -50,15 +47,15 @@ function makeItem(raw: RawContextItem | ContextItem | undefined | void): Context * Add an item to any context menu * @param navId The id of the menu you want to insert to * @param getItem A function that creates and returns the menu item - * @param sectionId The number of the section to add to. Defaults to replugged's section + * @param sectionId The number of the section to add to. Defaults to Replugged's section * @param indexInSection The index in the section to add to. Defaults to the end position * @returns A callback to de-register the function */ export function addContextMenuItem( navId: ContextMenuTypes, getItem: GetContextItem, - sectionId: number | undefined, - indexInSection: number, + sectionId: number | ((props: ContextMenuProps) => number) | undefined, + indexInSection: number | ((props: ContextMenuProps) => number), ): () => void { menuItems[navId] ||= []; @@ -76,75 +73,62 @@ export function removeContextMenuItem(navId: ContextMenuTypes, getItem: GetConte menuItems[navId] = menuItems[navId]?.filter((item) => item.getItem !== getItem); } -type ContextMenuData = ContextMenuProps["ContextMenu"] & { - children: React.ReactElement | React.ReactElement[]; - data: Array>; - navId: ContextMenuTypes; - plugged?: boolean; -}; - /** * @internal * @hidden */ -export function _insertMenuItems(menu: ContextMenuData): void { - const { navId } = menu; - - // No items to insert - if (!menuItems[navId]) return; +export function _insertMenuItems(props: ContextMenuProps): ContextMenuProps { + const menuItemsPatches = menuItems[props.navId]; + if (!menuItemsPatches) return props; - // Already inserted items - // If this isn't here, another group of items is added every update - if (menu.plugged) return; + props = { + ...props, + // Shallow clone the children array and objects + children: lodash.cloneDeep(props.children), + }; - // We delay getting the items until now, as importing at the start of the file causes Discord to hang - // Using `await import(...)` is undesirable because the new items will only appear once the menu is interacted with const { MenuGroup } = components; - //if (!MenuGroup) return; - - // The data as passed as Arguments from the calling function, so we just grab what we want from it - const data = menu.data[0]; - const repluggedGroup = ; - repluggedGroup.props.id = "replugged"; repluggedGroup.props.children = []; - // Add in the new menu items right above the DevMode Copy ID - // If the user doesn't have DevMode enabled, the new items will be at the bottom - if (!Array.isArray(menu.children)) menu.children = [menu.children]; - const hasCopyId = menu.children - .at(-1) - ?.props?.children?.props?.id?.startsWith("devmode-copy-id-"); - if (hasCopyId) { - menu.children.splice(-1, 0, repluggedGroup); - } else { - menu.children.push(repluggedGroup); - } + if (!Array.isArray(props.children)) props.children = [props.children]; - menuItems[navId]?.forEach((item) => { + menuItemsPatches.forEach(({ getItem, sectionId, indexInSection }) => { try { - const res = makeItem(item.getItem(data, menu)) as - | (ContextItem & { props: { id?: string } }) - | undefined; - if (res?.props) { - // add in unique ids - res.props.id = `${res.props.id || "repluggedItem"}-${Math.random() - .toString(36) - .substring(2)}`; - } - - if (!Array.isArray(menu.children)) menu.children = [menu.children]; - const section = - typeof item.sectionId === "undefined" ? repluggedGroup : menu.children.at(item.sectionId); - if (!section) { - logger.error("Couldn't find section", item.sectionId, menu.children); - return; + const item = makeItem(getItem(props.data[0], props)); + if (!item) return; + + if (sectionId !== undefined && Array.isArray(props.children)) { + sectionId = typeof sectionId === "function" ? sectionId(props) : sectionId; + const section = props.children.at(sectionId); + + if (!section) { + logger.error("Couldn't find section", sectionId, props.children); + return; + } + + if (!Array.isArray(section.props.children)) + section.props.children = [section.props.children]; + + indexInSection = + typeof indexInSection === "function" ? indexInSection(props) : indexInSection; + section.props.children.splice(indexInSection, 0, item); + } else { + repluggedGroup.props.children.push(item); } - section.props.children.splice(item.indexInSection, 0, res); - } catch (err) { - logger.error("Error while running GetContextItem function", err, item.getItem); + } catch (e) { + logger.error(`Failed to add item to menu ${props.navId}`, e); } }); - menu.plugged = true; + const hasCopyId = props.children + .at(-1) + ?.props?.children?.props?.id?.startsWith("devmode-copy-id-"); + if (hasCopyId) { + props.children.splice(-1, 0, repluggedGroup); + } else { + props.children.push(repluggedGroup); + } + + return props; } diff --git a/src/renderer/coremods/contextMenu/plaintextPatches.ts b/src/renderer/coremods/contextMenu/plaintextPatches.ts index dc1001cd3..69533b264 100644 --- a/src/renderer/coremods/contextMenu/plaintextPatches.ts +++ b/src/renderer/coremods/contextMenu/plaintextPatches.ts @@ -2,12 +2,12 @@ import type { PlaintextPatch } from "src/types"; export default [ { - find: 'Error("Menu', + find: "♫ (つ。◕‿‿◕。)つ ♪", replacements: [ { match: /((\w+)\){)(var\s*\w+;let{navId:)/, - replace: (_, prefix, menu, suffix) => - `${prefix}replugged.coremods.coremods.contextMenu._insertMenuItems(${menu});${suffix}`, + replace: (_, prefix, props, suffix) => + `${prefix}${props}=replugged.coremods.coremods.contextMenu._insertMenuItems(${props});${suffix}`, }, ], }, diff --git a/src/renderer/modules/components/ContextMenu.tsx b/src/renderer/modules/components/ContextMenu.tsx index accec6479..46abf2bb7 100644 --- a/src/renderer/modules/components/ContextMenu.tsx +++ b/src/renderer/modules/components/ContextMenu.tsx @@ -10,55 +10,69 @@ const ItemColors = { SUCCESS: "success", } as const; -interface MenuProps { +export interface MenuProps { navId: string; - children: React.ReactElement | React.ReactElement[]; - onClose: () => void; variant?: "fixed" | "flexible"; - className?: string; - style?: React.CSSProperties; hideScroller?: boolean; + className?: string; + children: React.ReactElement | React.ReactElement[]; + onClose: () => void; onSelect?: () => void; "aria-label"?: string; } -interface MenuGroupProps { - children?: React.ReactNode; - label?: string; - className?: string; - color?: (typeof ItemColors)[keyof typeof ItemColors]; +interface ItemProps { + "aria-expanded"?: boolean; + "aria-haspopup"?: boolean; + role: string; + id: string; + tabIndex: number; + onFocus: () => void; + onMouseEnter: () => void; } -interface MenuItemProps { +interface ExtraItemProps { + hasSubmenu?: boolean; + isFocused: boolean; + menuItemProps: ItemProps; + onClose?: () => void; +} + +interface MenuCheckboxItemProps { id: string; color?: (typeof ItemColors)[keyof typeof ItemColors]; - label?: string; - icon?: React.ComponentType; - showIconFirst?: boolean; - imageUrl?: string; - hint?: React.ReactNode; + label?: React.FC | React.ReactNode; + checked?: boolean; subtext?: React.ReactNode; disabled?: boolean; action?: React.MouseEventHandler; - onFocus?: () => void; className?: string; focusedClassName?: string; - subMenuIconClassName?: string; - dontCloseOnActionIfHoldingShiftKey?: boolean; - iconProps?: Record; - sparkle?: boolean; } -interface MenuSubmenuListItemProps extends MenuItemProps { +interface MenuCompositeControlItemProps { + id: string; + color?: (typeof ItemColors)[keyof typeof ItemColors]; + disabled?: boolean; + showDefaultFocus?: boolean; children: React.ReactNode; - childRowHeight: number; - onChildrenScroll?: () => void; - listClassName?: string; + interactive?: boolean; } -interface MenuSubmenuItemProps extends MenuItemProps { - children: React.ReactNode; - subMenuClassName?: string; +interface MenuControlItemProps { + id: string; + color?: (typeof ItemColors)[keyof typeof ItemColors]; + label?: React.ReactNode; + control: ( + data: { + onClose: () => void; + disabled: boolean | undefined; + isFocused: boolean; + }, + ref?: React.Ref<{ activate: () => boolean; blur: () => void; focus: () => void }>, + ) => React.ReactElement; + disabled?: boolean; + showDefaultFocus?: boolean; } interface MenuCustomItemProps { @@ -74,97 +88,81 @@ interface MenuCustomItemProps { keepItemStyles?: boolean; action?: React.MouseEventHandler; dontCloseOnActionIfHoldingShiftKey?: boolean; + dontCloseOnAction?: boolean; } -interface MenuCheckboxItemProps { +interface MenuGroupProps { + children?: React.ReactNode; + label?: string; + className?: string; + color?: (typeof ItemColors)[keyof typeof ItemColors]; +} + +interface MenuItemProps { id: string; color?: (typeof ItemColors)[keyof typeof ItemColors]; - label?: string; - checked?: boolean; - subtext?: string; + label?: React.FC | React.ReactNode; + icon?: React.ComponentType; + iconLeft?: React.FC | React.ReactNode; + iconLeftSize?: "xxs" | "xs" | "sm" | "md" | "lg" | "custom"; + hint?: React.FC | React.ReactNode; + subtext?: React.ReactNode; disabled?: boolean; action?: React.MouseEventHandler; + onFocus?: () => void; className?: string; focusedClassName?: string; + subMenuIconClassName?: string; + dontCloseOnActionIfHoldingShiftKey?: boolean; + dontCloseOnAction?: boolean; + iconProps?: Record; + sparkle?: boolean; } interface MenuRadioItemProps { id: string; color?: (typeof ItemColors)[keyof typeof ItemColors]; - label?: string; + label?: React.FC | React.ReactNode; checked?: boolean; - subtext?: string; + subtext?: React.ReactNode; disabled?: boolean; action?: React.MouseEventHandler; } -interface MenuControlItemProps { - id: string; - color?: (typeof ItemColors)[keyof typeof ItemColors]; - label?: string; - control: ( - data: { - onClose: () => void; - disabled: boolean; - isFocused: boolean; - }, - ref?: React.Ref<{ activate: () => boolean; blur: () => void; focus: () => void }>, - ) => React.ReactElement; - disabled?: boolean; - showDefaultFocus?: boolean; -} - -interface MenuCompositeControlItemProps { - id: string; +interface MenuSubmenuItemProps extends MenuItemProps { children: React.ReactNode; - interactive?: boolean; - color?: (typeof ItemColors)[keyof typeof ItemColors]; - disabled?: boolean; - showDefaultFocus?: boolean; + subMenuClassName?: string; } -export interface ContextMenuProps { - ContextMenu: MenuProps; - MenuSeparator: unknown; - MenuGroup: MenuGroupProps; - MenuItem: MenuItemProps | MenuCustomItemProps | MenuSubmenuListItemProps | MenuSubmenuItemProps; - MenuCheckboxItem: MenuCheckboxItemProps; - MenuRadioItem: MenuRadioItemProps; - MenuControlItem: MenuControlItemProps | MenuCompositeControlItemProps; +interface MenuSubmenuListItemProps extends MenuItemProps { + children: React.ReactNode; + childRowHeight: number; + onChildrenScroll?: () => void; + listClassName?: string; } -export type ContextMenuComponents = { - [K in keyof ContextMenuProps]: React.FC; -}; - -export type ContextMenuElements = { - [K in keyof ContextMenuProps]: React.ReactElement; -}; - -export type ContextMenuType = ContextMenuComponents & { +export interface ContextMenuType { + ContextMenu: React.FC; ItemColors: typeof ItemColors; -}; - -export type modType = Record< - | "Menu" - | "MenuSeparator" - | "MenuCheckboxItem" - | "MenuRadioItem" - | "MenuControlItem" - | "MenuGroup" - | "MenuItem", - React.ComponentType ->; + MenuCheckboxItem: React.FC; + MenuControlItem: React.FC; + MenuGroup: React.FC; + MenuItem: React.FC< + MenuItemProps | MenuCustomItemProps | MenuSubmenuListItemProps | MenuSubmenuItemProps + >; + MenuRadioItem: React.FC; + MenuSeparator: React.FC; +} const Menu = { - ItemColors, ContextMenu: components.Menu, - MenuSeparator: components.MenuSeparator, + ItemColors, MenuCheckboxItem: components.MenuCheckboxItem, - MenuRadioItem: components.MenuRadioItem, MenuControlItem: components.MenuControlItem, MenuGroup: components.MenuGroup, MenuItem: components.MenuItem, + MenuRadioItem: components.MenuRadioItem, + MenuSeparator: components.MenuSeparator, } as ContextMenuType; export default Menu; diff --git a/src/renderer/modules/injector.ts b/src/renderer/modules/injector.ts index b78072de4..d5613b4bf 100644 --- a/src/renderer/modules/injector.ts +++ b/src/renderer/modules/injector.ts @@ -1,11 +1,11 @@ -import type { CommandOptions } from "../../types/discord"; import type { RepluggedCommand } from "../../types/coremods/commands"; import type { ContextMenuTypes, GetContextItem } from "../../types/coremods/contextMenu"; import type { GetButtonItem } from "../../types/coremods/message"; +import type { CommandOptions } from "../../types/discord"; import type { AnyFunction } from "../../types/util"; import type { ObjectExports } from "../../types/webpack"; import { CommandManager } from "../apis/commands"; -import { addContextMenuItem } from "../coremods/contextMenu"; +import { type ContextMenuProps, addContextMenuItem } from "../coremods/contextMenu"; import { addButton } from "../coremods/messagePopover"; enum InjectionTypes { @@ -333,7 +333,7 @@ export class Injector { * const injector = new Injector(); * * export function start() { - * injector.utils.addMenuItem(ContextMenuTypes.UserContext, // Right-clicking a user + * injector.utils.addMenuItem(ContextMenuTypes.UserContext, // Right-clicking a user * (data, menu) => { * return = Record>( navId: ContextMenuTypes, item: GetContextItem, - sectionId: number | undefined = undefined, - indexInSection = Infinity, // Last item + sectionId: number | ((props: ContextMenuProps) => number) | undefined = undefined, + indexInSection: number | ((props: ContextMenuProps) => number) = Infinity, // Last item ) => { const uninjector = addContextMenuItem( navId, diff --git a/src/types/coremods/contextMenu.ts b/src/types/coremods/contextMenu.ts index 745b38d14..286c256fe 100644 --- a/src/types/coremods/contextMenu.ts +++ b/src/types/coremods/contextMenu.ts @@ -1,23 +1,27 @@ -import type { - ContextMenuComponents, - ContextMenuElements, - ContextMenuProps, -} from "../../renderer/modules/components/ContextMenu"; +import type { ContextMenuType, MenuProps } from "../../renderer/modules/components/ContextMenu"; +import type React from "react"; -export interface RawContextItem { - type: ContextMenuComponents[keyof ContextMenuComponents]; - children?: Array; - action?(): unknown; +type ContextMenuComponents = Omit; - [key: string]: unknown; -} +type RawContextMenuProps = { + [K in keyof ContextMenuComponents]: React.ComponentProps & { + type: ContextMenuComponents[K]; + }; +}; + +type WithRawChildren = T extends { children: React.ReactNode } + ? Omit & { children: RawContextItem | RawContextItem[] } + : T; -export type ContextItem = ContextMenuElements[keyof ContextMenuElements]; +export type RawContextItem< + T extends + RawContextMenuProps[keyof RawContextMenuProps] = RawContextMenuProps[keyof RawContextMenuProps], +> = WithRawChildren; export type GetContextItem = Record> = ( data: T, - menu: ContextMenuProps["ContextMenu"], -) => RawContextItem | ContextItem | undefined | void; + menu: MenuProps, +) => RawContextItem | React.ReactElement | undefined | void; /** * An enum for the navIds of context menus across Discord From 1df773738e8c72e14efc0f9e6eb3a80952af7d18 Mon Sep 17 00:00:00 2001 From: Nanakusa <73281112+yofukashino@users.noreply.github.com> Date: Tue, 5 Nov 2024 00:30:05 +0530 Subject: [PATCH 2/2] Module Updater fix (#650) fix --- src/main/index.ts | 5 +++-- 1 file changed, 3 insertions(+), 2 deletions(-) diff --git a/src/main/index.ts b/src/main/index.ts index acbb2dc45..8479570e1 100644 --- a/src/main/index.ts +++ b/src/main/index.ts @@ -7,7 +7,8 @@ import { getSetting } from "./ipc/settings"; const electronPath = require.resolve("electron"); const discordPath = join(dirname(require.main!.filename), "..", "app.orig.asar"); -// require.main!.filename = discordMain; +const discordPackage = require(join(discordPath, "package.json")); +require.main!.filename = join(discordPath, discordPackage.main); Object.defineProperty(global, "appSettings", { set: (v /* : typeof global.appSettings*/) => { @@ -185,4 +186,4 @@ electron.app.once("ready", () => { // This module is required this way at runtime. require("./ipc"); -require("module")._load(discordPath); +require(discordPath);