diff --git a/package.json b/package.json index 5823ddb..0382b67 100644 --- a/package.json +++ b/package.json @@ -37,6 +37,7 @@ "@types/diff": "^5.0.2", "@types/node": "^18.7.13", "@types/react": "^18.0.17", + "@types/react-dom": "^18.0.8", "@types/yazl": "^2.4.2", "@typescript-eslint/parser": "^5.39.0", "discord-types": "^1.3.26", diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 1da6aec..be5228e 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -4,6 +4,7 @@ specifiers: '@types/diff': ^5.0.2 '@types/node': ^18.7.13 '@types/react': ^18.0.17 + '@types/react-dom': ^18.0.8 '@types/yazl': ^2.4.2 '@typescript-eslint/parser': ^5.39.0 console-menu: ^0.1.0 @@ -28,6 +29,7 @@ devDependencies: '@types/diff': 5.0.2 '@types/node': 18.7.13 '@types/react': 18.0.17 + '@types/react-dom': 18.0.8 '@types/yazl': 2.4.2 '@typescript-eslint/parser': 5.39.0_ypn2ylkkyfa5i233caldtndbqa discord-types: 1.3.26 @@ -129,6 +131,12 @@ packages: resolution: {integrity: sha512-JCB8C6SnDoQf0cNycqd/35A7MjcnK+ZTqE7judS6o7utxUCg6imJg3QK2qzHKszlTjcj2cn+NwMB2i96ubpj7w==} dev: true + /@types/react-dom/18.0.8: + resolution: {integrity: sha512-C3GYO0HLaOkk9dDAz3Dl4sbe4AKUGTCfFIZsz3n/82dPNN8Du533HzKatDxeUYWu24wJgMP1xICqkWk1YOLOIw==} + dependencies: + '@types/react': 18.0.17 + dev: true + /@types/react/17.0.2: resolution: {integrity: sha512-Xt40xQsrkdvjn1EyWe1Bc0dJLcil/9x2vAuW7ya+PuQip4UYUaXyhzWmAbwRsdMgwOFHpfp7/FFZebDU6Y8VHA==} dependencies: diff --git a/src/components/CheckedTextInput.tsx b/src/components/CheckedTextInput.tsx new file mode 100644 index 0000000..e97519d --- /dev/null +++ b/src/components/CheckedTextInput.tsx @@ -0,0 +1,68 @@ +/* + * 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 { React, TextInput } from "../webpack/common"; + +// TODO: Refactor settings to use this as well +interface TextInputProps { + /** + * WARNING: Changing this between renders will have no effect! + */ + value: string; + /** + * This will only be called if the new value passed validate() + */ + onChange(newValue: string): void; + /** + * Optionally validate the user input + * Return true if the input is valid + * Otherwise, return a string containing the reason for this input being invalid + */ + validate(v: string): true | string; +} + +/** + * A very simple wrapper around Discord's TextInput that validates input and shows + * the user an error message and only calls your onChange when the input is valid + */ +export function CheckedTextInput({ value: initialValue, onChange, validate }: TextInputProps) { + const [value, setValue] = React.useState(initialValue); + const [error, setError] = React.useState(); + + function handleChange(v: string) { + setValue(v); + const res = validate(v); + if (res === true) { + setError(void 0); + onChange(v); + } else { + setError(res); + } + } + + return ( + <> + + + ); +} diff --git a/src/components/PatchHelper.tsx b/src/components/PatchHelper.tsx new file mode 100644 index 0000000..b490051 --- /dev/null +++ b/src/components/PatchHelper.tsx @@ -0,0 +1,279 @@ +/* + * 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 { makeCodeblock } from "../utils"; +import { debounce } from "../utils/debounce"; +import { Button, Clipboard, Forms, Margins, Parser, React, Switch, TextInput } from "../webpack/common"; +import { search } from "../webpack/webpack"; +import { CheckedTextInput } from "./CheckedTextInput"; +import ErrorBoundary from "./ErrorBoundary"; + +// Do not include diff in non dev builds (side effects import) +if (IS_DEV) { + var differ = require("diff") as typeof import("diff"); +} + +const findCandidates = debounce(function ({ find, setModule, setError }) { + const candidates = search(find); + const keys = Object.keys(candidates); + const len = keys.length; + if (len === 0) + setError("No match. Perhaps that module is lazy loaded?"); + else if (len !== 1) + setError("Multiple matches. Please refine your filter"); + else + setModule([keys[0], candidates[keys[0]]]); +}); + +function ReplacementComponent({ module, match, replacement, setReplacementError }) { + const [id, fact] = module; + const [compileResult, setCompileResult] = React.useState<[boolean, string]>(); + + const [patchedCode, matchResult, diff] = React.useMemo(() => { + const src: string = fact.toString().replaceAll("\n", ""); + try { + var patched = src.replace(match, replacement); + setReplacementError(void 0); + } catch (e) { + setReplacementError((e as Error).message); + return ["", [], []]; + } + const m = src.match(match); + return [patched, m, makeDiff(src, patched, m)]; + }, [id, match, replacement]); + + function makeDiff(original: string, patched: string, match: RegExpMatchArray | null) { + if (!match || original === patched) return null; + + const changeSize = patched.length - original.length; + + // Use 200 surrounding characters of context + const start = Math.max(0, match.index! - 200); + const end = Math.min(original.length, match.index! + match[0].length + 200); + // (changeSize may be negative) + const endPatched = end + changeSize; + + const context = original.slice(start, end); + const patchedContext = patched.slice(start, endPatched); + + return differ.diffWordsWithSpace(context, patchedContext); + } + + function renderMatch() { + if (!matchResult) + return Regex doesn't match!; + + const fullMatch = matchResult[0] ? makeCodeblock(matchResult[0], "js") : ""; + const groups = matchResult.length > 1 + ? makeCodeblock(matchResult.slice(1).map((g, i) => `Group ${i}: ${g}`).join("\n"), "yml") + : ""; + + return ( + <> + {Parser.parse(fullMatch)} + {Parser.parse(groups)} + + ); + } + + function renderDiff() { + return diff?.map(p => { + const color = p.added ? "lime" : p.removed ? "red" : "grey"; + return {p.value}; + }); + } + + return ( + <> + Module {id} + + {!!matchResult?.[0]?.length && ( + <> + Match + {renderMatch()} + ) + } + + {!!diff?.length && ( + <> + Diff + {renderDiff()} + + )} + + {!!diff?.length && ( + + )} + + {compileResult && + + {compileResult[1]} + + } + + ); +} + +function ReplacementInput({ replacement, setReplacement, replacementError }) { + const [isFunc, setIsFunc] = React.useState(false); + const [error, setError] = React.useState(); + + function onChange(v: string) { + setError(void 0); + + if (isFunc) { + try { + const func = (0, eval)(v); + if (typeof func === "function") + setReplacement(() => func); + else + setError("Replacement must be a function"); + } catch (e) { + setReplacement(v); + setError((e as Error).message); + } + } else { + setReplacement(v); + } + } + + React.useEffect( + () => void (isFunc ? onChange(replacement) : setError(void 0)), + [isFunc] + ); + + return ( + <> + replacement + + + + Treat as Function + + + ); +} + +function PatchHelper() { + const [find, setFind] = React.useState(""); + const [match, setMatch] = React.useState(""); + const [replacement, setReplacement] = React.useState(""); + + const [replacementError, setReplacementError] = React.useState(); + + const [module, setModule] = React.useState<[number, Function]>(); + const [findError, setFindError] = React.useState(); + + const code = React.useMemo(() => { + return ` +{ + find: ${JSON.stringify(find)}, + replacement: { + match: /${match.replace(/(? + find + + + match + { + try { + return (new RegExp(v), true); + } catch (e) { + return (e as Error).message; + } + }} + /> + + + + + {module && ( + + )} + + {!!(find && match && replacement) && ( + <> + Code + {Parser.parse(makeCodeblock(code, "ts"))} + + + )} + + ); +} + +export default IS_DEV ? ErrorBoundary.wrap(PatchHelper) : null; diff --git a/src/components/PluginSettings/PluginModal.tsx b/src/components/PluginSettings/PluginModal.tsx index 4c14d61..9e13f63 100644 --- a/src/components/PluginSettings/PluginModal.tsx +++ b/src/components/PluginSettings/PluginModal.tsx @@ -29,12 +29,13 @@ import { Button, FluxDispatcher, Forms, React, Text, Tooltip, UserStore, UserUti import ErrorBoundary from "../ErrorBoundary"; import { Flex } from "../Flex"; import { + ISettingElementProps, SettingBooleanComponent, SettingCustomComponent, - SettingInputComponent, SettingNumericComponent, SettingSelectComponent, - SettingSliderComponent + SettingSliderComponent, + SettingTextComponent } from "./components"; const UserSummaryItem = lazyWebpack(filters.byCode("defaultRenderUser", "showDefaultAvatarsForNullUsers")); @@ -60,6 +61,16 @@ function makeDummyUser(user: { name: string, id: BigInt; }) { return newUser; } +const Components: Record>> = { + [OptionType.STRING]: SettingTextComponent, + [OptionType.NUMBER]: SettingNumericComponent, + [OptionType.BIGINT]: SettingNumericComponent, + [OptionType.BOOLEAN]: SettingBooleanComponent, + [OptionType.SELECT]: SettingSelectComponent, + [OptionType.SLIDER]: SettingSliderComponent, + [OptionType.COMPONENT]: SettingCustomComponent +}; + export default function PluginModal({ plugin, onRestartNeeded, onClose, transitionState }: PluginModalProps) { const [authors, setAuthors] = React.useState[]>([]); @@ -75,8 +86,10 @@ export default function PluginModal({ plugin, onRestartNeeded, onClose, transiti React.useEffect(() => { (async () => { for (const user of plugin.authors.slice(0, 6)) { - const author = user.id ? await UserUtils.fetchUser(`${user.id}`).catch(() => null) : makeDummyUser(user); - setAuthors(a => [...a, author || makeDummyUser(user)]); + const author = user.id + ? await UserUtils.fetchUser(`${user.id}`).catch(() => makeDummyUser(user)) + : makeDummyUser(user); + setAuthors(a => [...a, author]); } })(); }, []); @@ -111,9 +124,8 @@ export default function PluginModal({ plugin, onRestartNeeded, onClose, transiti return There are no settings for this plugin.; } - const options: JSX.Element[] = []; - for (const [key, setting] of Object.entries(plugin.options)) { - function onChange(newValue) { + const options = Object.entries(plugin.options).map(([key, setting]) => { + function onChange(newValue: any) { setTempSettings(s => ({ ...s, [key]: newValue })); } @@ -121,35 +133,19 @@ export default function PluginModal({ plugin, onRestartNeeded, onClose, transiti setErrors(e => ({ ...e, [key]: hasError })); } - const props = { onChange, pluginSettings, id: key, onError }; - switch (setting.type) { - case OptionType.SELECT: { - options.push(); - break; - } - case OptionType.STRING: { - options.push(); - break; - } - case OptionType.NUMBER: - case OptionType.BIGINT: { - options.push(); - break; - } - case OptionType.BOOLEAN: { - options.push(); - break; - } - case OptionType.SLIDER: { - options.push(); - break; - } - case OptionType.COMPONENT: { - options.push(); - break; - } - } - } + const Component = Components[setting.type]; + return ( + + ); + }); + return {options}; } diff --git a/src/components/PluginSettings/components/SettingTextComponent.tsx b/src/components/PluginSettings/components/SettingTextComponent.tsx index 216a2a1..f76bd58 100644 --- a/src/components/PluginSettings/components/SettingTextComponent.tsx +++ b/src/components/PluginSettings/components/SettingTextComponent.tsx @@ -20,7 +20,7 @@ import { PluginOptionString } from "../../../utils/types"; import { Forms, React, TextInput } from "../../../webpack/common"; import { ISettingElementProps } from "."; -export function SettingInputComponent({ option, pluginSettings, id, onChange, onError }: ISettingElementProps) { +export function SettingTextComponent({ option, pluginSettings, id, onChange, onError }: ISettingElementProps) { const [state, setState] = React.useState(pluginSettings[id] ?? option.default ?? null); const [error, setError] = React.useState(null); diff --git a/src/components/PluginSettings/index.tsx b/src/components/PluginSettings/index.tsx index 9ab1396..a5116c4 100644 --- a/src/components/PluginSettings/index.tsx +++ b/src/components/PluginSettings/index.tsx @@ -220,11 +220,6 @@ export default ErrorBoundary.wrap(function Settings() { return o; }, []); - function hasDependents(plugin: Plugin) { - const enabledDependants = depMap[plugin.name]?.filter(d => settings.plugins[d].enabled); - return !!enabledDependants?.length; - } - const sortedPlugins = React.useMemo(() => Object.values(Plugins) .sort((a, b) => a.name.localeCompare(b.name)), []); @@ -264,7 +259,7 @@ export default ErrorBoundary.wrap(function Settings() { { label: "Show Enabled", value: "enabled" }, { label: "Show Disabled", value: "disabled" } ]} - serialize={v => String(v)} + serialize={String} select={onStatusChange} isSelected={v => v === searchValue.status} closeOnSelect={true} diff --git a/src/components/index.ts b/src/components/index.ts index 6f7ffba..80d2cd1 100644 --- a/src/components/index.ts +++ b/src/components/index.ts @@ -16,6 +16,7 @@ * along with this program. If not, see . */ +export { default as PatchHelper } from "./PatchHelper"; export { default as PluginSettings } from "./PluginSettings"; export { default as Settings } from "./Settings"; export { default as Updater } from "./Updater"; diff --git a/src/plugins/settings.ts b/src/plugins/settings.ts index df27ca1..f8ef7d8 100644 --- a/src/plugins/settings.ts +++ b/src/plugins/settings.ts @@ -55,11 +55,13 @@ export default definePlugin({ match: /\{section:(.{1,2})\.ID\.HEADER,\s*label:(.{1,2})\..{1,2}\.Messages\.ACTIVITY_SETTINGS\}/, replace: (m, mod) => { const updater = !IS_WEB ? '{section:"VencordUpdater",label:"Updater",element:Vencord.Components.Updater},' : ""; + const patchHelper = IS_DEV ? '{section:"VencordPatchHelper",label:"PatchHelper",element:Vencord.Components.PatchHelper},' : ""; return ( `{section:${mod}.ID.HEADER,label:"Vencord"},` + '{section:"VencordSetting",label:"Vencord",element:Vencord.Components.Settings},' + '{section:"VencordPlugins",label:"Plugins",element:Vencord.Components.PluginSettings},' + updater + + patchHelper + `{section:${mod}.ID.DIVIDER},${m}` ); } diff --git a/src/webpack/common.tsx b/src/webpack/common.tsx index 4c59102..77d339c 100644 --- a/src/webpack/common.tsx +++ b/src/webpack/common.tsx @@ -27,6 +27,7 @@ export const Margins = lazyWebpack(filters.byProps("marginTop20")); export let FluxDispatcher: Other.FluxDispatcher; export let React: typeof import("react"); +export const ReactDOM: typeof import("react-dom") = lazyWebpack(filters.byProps("createPortal", "render")); export let GuildStore: Stores.GuildStore; export let UserStore: Stores.UserStore;