aboutsummaryrefslogtreecommitdiff
diff options
context:
space:
mode:
authorVen <vendicated@riseup.net>2022-11-01 01:49:41 +0100
committerGitHub <noreply@github.com>2022-11-01 01:49:41 +0100
commit04d6f341ee3122e36044739d533a69e4312dd116 (patch)
tree971e61d6ee490ec02a682b7d6fa68bb08cffe68a
parent0c25278c5923ff7fdc22730557da00601b8cf1d3 (diff)
downloadVencord-04d6f341ee3122e36044739d533a69e4312dd116.tar.gz
Vencord-04d6f341ee3122e36044739d533a69e4312dd116.tar.bz2
Vencord-04d6f341ee3122e36044739d533a69e4312dd116.zip
PatchHelper, a tool to help you write patches (#182)
-rw-r--r--package.json1
-rw-r--r--pnpm-lock.yaml8
-rw-r--r--src/components/CheckedTextInput.tsx68
-rw-r--r--src/components/PatchHelper.tsx279
-rw-r--r--src/components/PluginSettings/PluginModal.tsx68
-rw-r--r--src/components/PluginSettings/components/SettingTextComponent.tsx2
-rw-r--r--src/components/PluginSettings/index.tsx7
-rw-r--r--src/components/index.ts1
-rw-r--r--src/plugins/settings.ts2
-rw-r--r--src/webpack/common.tsx1
10 files changed, 394 insertions, 43 deletions
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 <https://www.gnu.org/licenses/>.
+*/
+
+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<string>();
+
+ function handleChange(v: string) {
+ setValue(v);
+ const res = validate(v);
+ if (res === true) {
+ setError(void 0);
+ onChange(v);
+ } else {
+ setError(res);
+ }
+ }
+
+ return (
+ <>
+ <TextInput
+ type="text"
+ value={value}
+ onChange={handleChange}
+ error={error}
+ />
+ </>
+ );
+}
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 <https://www.gnu.org/licenses/>.
+*/
+
+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 <Forms.FormText>Regex doesn't match!</Forms.FormText>;
+
+ 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 <span style={{ color }}>{p.value}</span>;
+ });
+ }
+
+ return (
+ <>
+ <Forms.FormTitle>Module {id}</Forms.FormTitle>
+
+ {!!matchResult?.[0]?.length && (
+ <>
+ <Forms.FormTitle>Match</Forms.FormTitle>
+ {renderMatch()}
+ </>)
+ }
+
+ {!!diff?.length && (
+ <>
+ <Forms.FormTitle>Diff</Forms.FormTitle>
+ {renderDiff()}
+ </>
+ )}
+
+ {!!diff?.length && (
+ <Button className={Margins.marginTop20} onClick={() => {
+ try {
+ Function(patchedCode);
+ setCompileResult([true, "Compiled successfully"]);
+ } catch (err) {
+ setCompileResult([false, (err as Error).message]);
+ }
+ }}>Compile</Button>
+ )}
+
+ {compileResult &&
+ <Forms.FormText style={{ color: compileResult[0] ? "var(--text-positive)" : "var(--text-danger)" }}>
+ {compileResult[1]}
+ </Forms.FormText>
+ }
+ </>
+ );
+}
+
+function ReplacementInput({ replacement, setReplacement, replacementError }) {
+ const [isFunc, setIsFunc] = React.useState(false);
+ const [error, setError] = React.useState<string>();
+
+ 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 (
+ <>
+ <Forms.FormTitle>replacement</Forms.FormTitle>
+ <TextInput
+ value={replacement?.toString()}
+ onChange={onChange}
+ error={error ?? replacementError}
+ />
+
+ <Switch
+ className={Margins.marginTop8}
+ value={isFunc}
+ onChange={setIsFunc}
+ note="'replacement' will be evaled if this is toggled"
+ hideBorder={true}
+ >
+ Treat as Function
+ </Switch>
+ </>
+ );
+}
+
+function PatchHelper() {
+ const [find, setFind] = React.useState<string>("");
+ const [match, setMatch] = React.useState<string>("");
+ const [replacement, setReplacement] = React.useState<string | Function>("");
+
+ const [replacementError, setReplacementError] = React.useState<string>();
+
+ const [module, setModule] = React.useState<[number, Function]>();
+ const [findError, setFindError] = React.useState<string>();
+
+ const code = React.useMemo(() => {
+ return `
+{
+ find: ${JSON.stringify(find)},
+ replacement: {
+ match: /${match.replace(/(?<!\\)\//g, "\\/")}/,
+ replacement: ${typeof replacement === "function" ? replacement.toString() : JSON.stringify(replacement)}
+ }
+}
+ `.trim();
+ }, [find, match, replacement]);
+
+ function onFindChange(v: string) {
+ setFindError(void 0);
+ setFind(v);
+ if (v.length) {
+ findCandidates({ find: v, setModule, setError: setFindError });
+ }
+ }
+
+ function onMatchChange(v: string) {
+ try {
+ new RegExp(v);
+ setFindError(void 0);
+ setMatch(v);
+ } catch (e: any) {
+ setFindError((e as Error).message);
+ }
+ }
+
+ return (
+ <>
+ <Forms.FormTitle>find</Forms.FormTitle>
+ <TextInput
+ type="text"
+ value={find}
+ onChange={onFindChange}
+ error={findError}
+ />
+
+ <Forms.FormTitle>match</Forms.FormTitle>
+ <CheckedTextInput
+ value={match}
+ onChange={onMatchChange}
+ validate={v => {
+ try {
+ return (new RegExp(v), true);
+ } catch (e) {
+ return (e as Error).message;
+ }
+ }}
+ />
+
+ <ReplacementInput
+ replacement={replacement}
+ setReplacement={setReplacement}
+ replacementError={replacementError}
+ />
+
+ <Forms.FormDivider />
+ {module && (
+ <ReplacementComponent
+ module={module}
+ match={new RegExp(match)}
+ replacement={replacement}
+ setReplacementError={setReplacementError}
+ />
+ )}
+
+ {!!(find && match && replacement) && (
+ <>
+ <Forms.FormTitle className={Margins.marginTop20}>Code</Forms.FormTitle>
+ {Parser.parse(makeCodeblock(code, "ts"))}
+ <Button onClick={() => Clipboard.copy(code)}>Copy to Clipboard</Button>
+ </>
+ )}
+ </>
+ );
+}
+
+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, React.ComponentType<ISettingElementProps<any>>> = {
+ [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<Partial<User>[]>([]);
@@ -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 <Forms.FormText>There are no settings for this plugin.</Forms.FormText>;
}
- 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(<SettingSelectComponent key={key} option={setting} {...props} />);
- break;
- }
- case OptionType.STRING: {
- options.push(<SettingInputComponent key={key} option={setting} {...props} />);
- break;
- }
- case OptionType.NUMBER:
- case OptionType.BIGINT: {
- options.push(<SettingNumericComponent key={key} option={setting} {...props} />);
- break;
- }
- case OptionType.BOOLEAN: {
- options.push(<SettingBooleanComponent key={key} option={setting} {...props} />);
- break;
- }
- case OptionType.SLIDER: {
- options.push(<SettingSliderComponent key={key} option={setting} {...props} />);
- break;
- }
- case OptionType.COMPONENT: {
- options.push(<SettingCustomComponent key={key} option={setting} {...props} />);
- break;
- }
- }
- }
+ const Component = Components[setting.type];
+ return (
+ <Component
+ id={key}
+ key={key}
+ option={setting}
+ onChange={onChange}
+ onError={onError}
+ pluginSettings={pluginSettings}
+ />
+ );
+ });
+
return <Flex flexDirection="column" style={{ gap: 12 }}>{options}</Flex>;
}
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<PluginOptionString>) {
+export function SettingTextComponent({ option, pluginSettings, id, onChange, onError }: ISettingElementProps<PluginOptionString>) {
const [state, setState] = React.useState(pluginSettings[id] ?? option.default ?? null);
const [error, setError] = React.useState<string | null>(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 <https://www.gnu.org/licenses/>.
*/
+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;