aboutsummaryrefslogtreecommitdiff
path: root/src
diff options
context:
space:
mode:
authormegumin <megumin.bakaretsurie@gmail.com>2022-10-17 20:18:25 +0100
committerGitHub <noreply@github.com>2022-10-17 20:18:25 +0100
commit5625d63e46c43132676148a86739025c15fa5f2d (patch)
tree3699e126ad86cc2972b3aecfe4eeaef378e9e9f4 /src
parentae730e83984cbf4dc804eebbf260a055bfe635c0 (diff)
downloadVencord-5625d63e46c43132676148a86739025c15fa5f2d.tar.gz
Vencord-5625d63e46c43132676148a86739025c15fa5f2d.tar.bz2
Vencord-5625d63e46c43132676148a86739025c15fa5f2d.zip
Settings 2.0 (#107)
Co-authored-by: Vendicated <vendicated@riseup.net>
Diffstat (limited to 'src')
-rw-r--r--src/components/ErrorBoundary.tsx8
-rw-r--r--src/components/Flex.tsx2
-rw-r--r--src/components/PluginSettings/PluginModal.tsx202
-rw-r--r--src/components/PluginSettings/components/SettingBooleanComponent.tsx51
-rw-r--r--src/components/PluginSettings/components/SettingNumericComponent.tsx50
-rw-r--r--src/components/PluginSettings/components/SettingSelectComponent.tsx44
-rw-r--r--src/components/PluginSettings/components/SettingTextComponent.tsx39
-rw-r--r--src/components/PluginSettings/components/index.ts17
-rw-r--r--src/components/PluginSettings/index.tsx234
-rw-r--r--src/components/PluginSettings/styles.ts24
-rw-r--r--src/components/Settings.tsx92
-rw-r--r--src/components/index.ts1
-rw-r--r--src/plugins/experiments.ts21
-rw-r--r--src/plugins/experiments.tsx74
-rw-r--r--src/plugins/index.ts1
-rw-r--r--src/plugins/isStaff.ts34
-rw-r--r--src/plugins/settings.ts6
-rw-r--r--src/plugins/viewIcons.tsx20
-rw-r--r--src/utils/modal.tsx91
-rw-r--r--src/utils/types.ts89
-rw-r--r--src/webpack/common.tsx36
-rw-r--r--src/webpack/patchWebpack.ts1
22 files changed, 945 insertions, 192 deletions
diff --git a/src/components/ErrorBoundary.tsx b/src/components/ErrorBoundary.tsx
index c62acb0..bb0c336 100644
--- a/src/components/ErrorBoundary.tsx
+++ b/src/components/ErrorBoundary.tsx
@@ -5,6 +5,7 @@ import { ErrorCard } from "./ErrorCard";
interface Props {
fallback?: React.ComponentType<React.PropsWithChildren<{ error: any; message: string; stack: string; }>>;
onError?(error: Error, errorInfo: React.ErrorInfo): void;
+ message?: string;
}
const color = "#e78284";
@@ -58,15 +59,14 @@ export default class ErrorBoundary extends React.Component<React.PropsWithChildr
{...this.state}
/>;
+ const msg = this.props.message || "An error occurred while rendering this Component. More info can be found below and in your console.";
+
return (
<ErrorCard style={{
overflow: "hidden",
}}>
<h1>Oh no!</h1>
- <p>
- An error occurred while rendering this Component. More info can be found below
- and in your console.
- </p>
+ <p>{msg}</p>
<code>
{this.state.message}
{!!this.state.stack && (
diff --git a/src/components/Flex.tsx b/src/components/Flex.tsx
index eda3b33..8a80f02 100644
--- a/src/components/Flex.tsx
+++ b/src/components/Flex.tsx
@@ -4,7 +4,7 @@ export function Flex(props: React.PropsWithChildren<{
flexDirection?: React.CSSProperties["flexDirection"];
style?: React.CSSProperties;
className?: string;
-}>) {
+} & React.HTMLProps<HTMLDivElement>>) {
props.style ??= {};
props.style.flexDirection ||= props.flexDirection;
props.style.gap ??= "1em";
diff --git a/src/components/PluginSettings/PluginModal.tsx b/src/components/PluginSettings/PluginModal.tsx
new file mode 100644
index 0000000..a324300
--- /dev/null
+++ b/src/components/PluginSettings/PluginModal.tsx
@@ -0,0 +1,202 @@
+import { User } from "discord-types/general";
+import { Constructor } from "type-fest";
+
+import { generateId } from "../../api/Commands";
+import { useSettings } from "../../api/settings";
+import { lazyWebpack, proxyLazy } from "../../utils";
+import { ModalContent, ModalFooter, ModalHeader, ModalProps, ModalRoot, ModalSize } from "../../utils/modal";
+import { OptionType, Plugin } from "../../utils/types";
+import { filters } from "../../webpack";
+import { Button, FluxDispatcher, Forms, React, Text, Tooltip, UserStore, UserUtils } from "../../webpack/common";
+import ErrorBoundary from "../ErrorBoundary";
+import { Flex } from "../Flex";
+import {
+ SettingBooleanComponent,
+ SettingInputComponent,
+ SettingNumericComponent,
+ SettingSelectComponent,
+} from "./components";
+
+const { FormSection, FormText, FormTitle } = Forms;
+
+const UserSummaryItem = lazyWebpack(filters.byCode("defaultRenderUser", "showDefaultAvatarsForNullUsers"));
+const AvatarStyles = lazyWebpack(filters.byProps(["moreUsers", "emptyUser", "avatarContainer", "clickableAvatar"]));
+const UserRecord: Constructor<Partial<User>> = proxyLazy(() => UserStore.getCurrentUser().constructor) as any;
+
+interface PluginModalProps extends ModalProps {
+ plugin: Plugin;
+ onRestartNeeded(): void;
+}
+
+/** To stop discord making unwanted requests... */
+function makeDummyUser(user: { name: string, id: BigInt; }) {
+ const newUser = new UserRecord({
+ username: user.name,
+ id: generateId(),
+ bot: true,
+ });
+ FluxDispatcher.dispatch({
+ type: "USER_UPDATE",
+ user: newUser,
+ });
+ return newUser;
+}
+
+export default function PluginModal({ plugin, onRestartNeeded, onClose, transitionState }: PluginModalProps) {
+ const [authors, setAuthors] = React.useState<Partial<User>[]>([]);
+
+ const pluginSettings = useSettings().plugins[plugin.name];
+
+ const [tempSettings, setTempSettings] = React.useState<Record<string, any>>({});
+
+ const [errors, setErrors] = React.useState<Record<string, boolean>>({});
+
+ const canSubmit = () => Object.values(errors).every(e => !e);
+
+ 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)]);
+ }
+ })();
+ }, []);
+
+ function saveAndClose() {
+ if (!plugin.options) {
+ onClose();
+ return;
+ }
+ let restartNeeded = false;
+ for (const [key, value] of Object.entries(tempSettings)) {
+ const option = plugin.options[key];
+ pluginSettings[key] = value;
+ option?.onChange?.(value);
+ if (option?.restartNeeded) restartNeeded = true;
+ }
+ if (restartNeeded) onRestartNeeded();
+ onClose();
+ }
+
+ function renderSettings() {
+ if (!pluginSettings || !plugin.options) {
+ return <FormText>There are no settings for this plugin.</FormText>;
+ }
+
+ const options: JSX.Element[] = [];
+ for (const [key, setting] of Object.entries(plugin.options)) {
+ function onChange(newValue) {
+ setTempSettings(s => ({ ...s, [key]: newValue }));
+ }
+
+ function onError(hasError: boolean) {
+ 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} />);
+ }
+ }
+ }
+ return <Flex flexDirection="column" style={{ gap: 12 }}>{options}</Flex>;
+ }
+
+ function renderMoreUsers(_label: string, count: number) {
+ const sliceCount = plugin.authors.length - count;
+ const sliceStart = plugin.authors.length - sliceCount;
+ const sliceEnd = sliceStart + plugin.authors.length - count;
+
+ return (
+ <Tooltip text={plugin.authors.slice(sliceStart, sliceEnd).map(u => u.name).join(", ")}>
+ {({ onMouseEnter, onMouseLeave }) => (
+ <div
+ className={AvatarStyles.moreUsers}
+ onMouseEnter={onMouseEnter}
+ onMouseLeave={onMouseLeave}
+ >
+ +{sliceCount}
+ </div>
+ )}
+ </Tooltip>
+ );
+ }
+
+ return (
+ <ModalRoot transitionState={transitionState} size={ModalSize.MEDIUM}>
+ <ModalHeader>
+ <Text variant="heading-md/bold">{plugin.name}</Text>
+ </ModalHeader>
+ <ModalContent style={{ marginBottom: 8, marginTop: 8 }}>
+ <FormSection>
+ <FormTitle tag="h3">About {plugin.name}</FormTitle>
+ <FormText>{plugin.description}</FormText>
+ <div style={{ marginTop: 8, marginBottom: 8, width: "fit-content" }}>
+ <UserSummaryItem
+ users={authors}
+ count={plugin.authors.length}
+ guildId={undefined}
+ renderIcon={false}
+ max={6}
+ showDefaultAvatarsForNullUsers
+ showUserPopout
+ renderMoreUsers={renderMoreUsers}
+ />
+ </div>
+ </FormSection>
+ {!!plugin.settingsAboutComponent && (
+ <div style={{ marginBottom: 8 }}>
+ <FormSection>
+ <ErrorBoundary message="An error occurred while rendering this plugin's custom InfoComponent">
+ <plugin.settingsAboutComponent />
+ </ErrorBoundary>
+ </FormSection>
+ </div>
+ )}
+ <FormSection>
+ <FormTitle tag="h3">Settings</FormTitle>
+ {renderSettings()}
+ </FormSection>
+ </ModalContent>
+ <ModalFooter>
+ <Flex>
+ <Button
+ onClick={onClose}
+ size={Button.Sizes.SMALL}
+ color={Button.Colors.RED}
+ >
+ Exit Without Saving
+ </Button>
+ <Tooltip text="You must fix all errors before saving" shouldShow={!canSubmit()}>
+ {({ onMouseEnter, onMouseLeave }) => (
+ <Button
+ size={Button.Sizes.SMALL}
+ color={Button.Colors.BRAND}
+ onClick={saveAndClose}
+ onMouseEnter={onMouseEnter}
+ onMouseLeave={onMouseLeave}
+ disabled={!canSubmit()}
+ >
+ Save & Exit
+ </Button>
+ )}
+ </Tooltip>
+ </Flex>
+ </ModalFooter>
+ </ModalRoot>
+ );
+}
diff --git a/src/components/PluginSettings/components/SettingBooleanComponent.tsx b/src/components/PluginSettings/components/SettingBooleanComponent.tsx
new file mode 100644
index 0000000..62dd4d5
--- /dev/null
+++ b/src/components/PluginSettings/components/SettingBooleanComponent.tsx
@@ -0,0 +1,51 @@
+import { ISettingElementProps } from ".";
+import { PluginOptionBoolean } from "../../../utils/types";
+import { Forms, React, Select } from "../../../webpack/common";
+
+const { FormSection, FormTitle, FormText } = Forms;
+
+export function SettingBooleanComponent({ option, pluginSettings, id, onChange, onError }: ISettingElementProps<PluginOptionBoolean>) {
+ const def = pluginSettings[id] ?? option.default;
+
+ const [state, setState] = React.useState(def ?? false);
+ const [error, setError] = React.useState<string | null>(null);
+
+ React.useEffect(() => {
+ onError(error !== null);
+ }, [error]);
+
+ const options = [
+ { label: "Enabled", value: true, default: def === true },
+ { label: "Disabled", value: false, default: typeof def === "undefined" || def === false },
+ ];
+
+ function handleChange(newValue: boolean): void {
+ let isValid = (option.isValid && option.isValid(newValue)) ?? true;
+ if (typeof isValid === "string") setError(isValid);
+ else if (!isValid) setError("Invalid input provided.");
+ else {
+ setError(null);
+ setState(newValue);
+ onChange(newValue);
+ }
+ }
+
+ return (
+ <FormSection>
+ <FormTitle>{option.description}</FormTitle>
+ <Select
+ isDisabled={option.disabled?.() ?? false}
+ options={options}
+ placeholder={option.placeholder ?? "Select an option"}
+ maxVisibleItems={5}
+ closeOnSelect={true}
+ select={handleChange}
+ isSelected={v => v === state}
+ serialize={v => String(v)}
+ {...option.componentProps}
+ />
+ {error && <FormText style={{ color: "var(--text-danger)" }}>{error}</FormText>}
+ </FormSection>
+ );
+}
+
diff --git a/src/components/PluginSettings/components/SettingNumericComponent.tsx b/src/components/PluginSettings/components/SettingNumericComponent.tsx
new file mode 100644
index 0000000..fe3fea1
--- /dev/null
+++ b/src/components/PluginSettings/components/SettingNumericComponent.tsx
@@ -0,0 +1,50 @@
+import { ISettingElementProps } from ".";
+import { OptionType, PluginOptionNumber } from "../../../utils/types";
+import { Forms, React, TextInput } from "../../../webpack/common";
+
+const { FormSection, FormTitle, FormText } = Forms;
+
+const MAX_SAFE_NUMBER = BigInt(Number.MAX_SAFE_INTEGER);
+
+export function SettingNumericComponent({ option, pluginSettings, id, onChange, onError }: ISettingElementProps<PluginOptionNumber>) {
+ function serialize(value: any) {
+ if (option.type === OptionType.BIGINT) return BigInt(value);
+ return Number(value);
+ }
+
+ const [state, setState] = React.useState<any>(`${pluginSettings[id] ?? option.default ?? 0}`);
+ const [error, setError] = React.useState<string | null>(null);
+
+ React.useEffect(() => {
+ onError(error !== null);
+ }, [error]);
+
+ function handleChange(newValue) {
+ let isValid = (option.isValid && option.isValid(newValue)) ?? true;
+ if (typeof isValid === "string") setError(isValid);
+ else if (!isValid) setError("Invalid input provided.");
+ else if (option.type === OptionType.NUMBER && BigInt(newValue) >= MAX_SAFE_NUMBER) {
+ setState(`${Number.MAX_SAFE_INTEGER}`);
+ onChange(serialize(newValue));
+ } else {
+ setState(newValue);
+ onChange(serialize(newValue));
+ }
+ }
+
+ return (
+ <FormSection>
+ <FormTitle>{option.description}</FormTitle>
+ <TextInput
+ type="number"
+ pattern="-?[0-9]+"
+ value={state}
+ onChange={handleChange}
+ placeholder={option.placeholder ?? "Enter a number"}
+ disabled={option.disabled?.() ?? false}
+ {...option.componentProps}
+ />
+ {error && <FormText style={{ color: "var(--text-danger)" }}>{error}</FormText>}
+ </FormSection>
+ );
+}
diff --git a/src/components/PluginSettings/components/SettingSelectComponent.tsx b/src/components/PluginSettings/components/SettingSelectComponent.tsx
new file mode 100644
index 0000000..703ffbb
--- /dev/null
+++ b/src/components/PluginSettings/components/SettingSelectComponent.tsx
@@ -0,0 +1,44 @@
+import { ISettingElementProps } from ".";
+import { PluginOptionSelect } from "../../../utils/types";
+import { Forms, React, Select } from "../../../webpack/common";
+
+const { FormSection, FormTitle, FormText } = Forms;
+
+export function SettingSelectComponent({ option, pluginSettings, onChange, onError, id }: ISettingElementProps<PluginOptionSelect>) {
+ const def = pluginSettings[id] ?? option.options?.find(o => o.default)?.value;
+
+ const [state, setState] = React.useState<any>(def ?? null);
+ const [error, setError] = React.useState<string | null>(null);
+
+ React.useEffect(() => {
+ onError(error !== null);
+ }, [error]);
+
+ function handleChange(newValue) {
+ let isValid = (option.isValid && option.isValid(newValue)) ?? true;
+ if (typeof isValid === "string") setError(isValid);
+ else if (!isValid) setError("Invalid input provided.");
+ else {
+ setState(newValue);
+ onChange(newValue);
+ }
+ }
+
+ return (
+ <FormSection>
+ <FormTitle>{option.description}</FormTitle>
+ <Select
+ isDisabled={option.disabled?.() ?? false}
+ options={option.options}
+ placeholder={option.placeholder ?? "Select an option"}
+ maxVisibleItems={5}
+ closeOnSelect={true}
+ select={handleChange}
+ isSelected={v => v === state}
+ serialize={v => String(v)}
+ {...option.componentProps}
+ />
+ {error && <FormText style={{ color: "var(--text-danger)" }}>{error}</FormText>}
+ </FormSection>
+ );
+}
diff --git a/src/components/PluginSettings/components/SettingTextComponent.tsx b/src/components/PluginSettings/components/SettingTextComponent.tsx
new file mode 100644
index 0000000..0bfe3fb
--- /dev/null
+++ b/src/components/PluginSettings/components/SettingTextComponent.tsx
@@ -0,0 +1,39 @@
+import { ISettingElementProps } from ".";
+import { PluginOptionString } from "../../../utils/types";
+import { Forms, React, TextInput } from "../../../webpack/common";
+
+const { FormSection, FormTitle, FormText } = Forms;
+
+export function SettingInputComponent({ 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);
+
+ React.useEffect(() => {
+ onError(error !== null);
+ }, [error]);
+
+ function handleChange(newValue) {
+ let isValid = (option.isValid && option.isValid(newValue)) ?? true;
+ if (typeof isValid === "string") setError(isValid);
+ else if (!isValid) setError("Invalid input provided.");
+ else {
+ setState(newValue);
+ onChange(newValue);
+ }
+ }
+
+ return (
+ <FormSection>
+ <FormTitle>{option.description}</FormTitle>
+ <TextInput
+ type="text"
+ value={state}
+ onChange={handleChange}
+ placeholder={option.placeholder ?? "Enter a value"}
+ disabled={option.disabled?.() ?? false}
+ {...option.componentProps}
+ />
+ {error && <FormText style={{ color: "var(--text-danger)" }}>{error}</FormText>}
+ </FormSection>
+ );
+}
diff --git a/src/components/PluginSettings/components/index.ts b/src/components/PluginSettings/components/index.ts
new file mode 100644
index 0000000..d1fe7d6
--- /dev/null
+++ b/src/components/PluginSettings/components/index.ts
@@ -0,0 +1,17 @@
+import { PluginOptionBase } from "../../../utils/types";
+
+export interface ISettingElementProps<T extends PluginOptionBase> {
+ option: T;
+ onChange(newValue: any): void;
+ pluginSettings: {
+ [setting: string]: any;
+ enabled: boolean;
+ };
+ id: string;
+ onError(hasError: boolean): void;
+}
+
+export * from "./SettingBooleanComponent";
+export * from "./SettingNumericComponent";
+export * from "./SettingSelectComponent";
+export * from "./SettingTextComponent";
diff --git a/src/components/PluginSettings/index.tsx b/src/components/PluginSettings/index.tsx
new file mode 100644
index 0000000..f8cb73c
--- /dev/null
+++ b/src/components/PluginSettings/index.tsx
@@ -0,0 +1,234 @@
+import Plugins from "plugins";
+
+import { Settings, useSettings } from "../../api/settings";
+import { startPlugin, stopPlugin } from "../../plugins";
+import { Modals } from "../../utils";
+import { ChangeList } from "../../utils/ChangeList";
+import { classes, lazyWebpack } from "../../utils/misc";
+import { Plugin } from "../../utils/types";
+import { filters } from "../../webpack";
+import { Alerts, Button, Forms, Margins, Parser, React, Text, TextInput, Toasts, Tooltip } from "../../webpack/common";
+import ErrorBoundary from "../ErrorBoundary";
+import { Flex } from "../Flex";
+import PluginModal from "./PluginModal";
+import * as styles from "./styles";
+
+const Select = lazyWebpack(filters.byCode("optionClassName", "popoutPosition", "autoFocus", "maxVisibleItems"));
+const InputStyles = lazyWebpack(filters.byProps(["inputDefault", "inputWrapper"]));
+
+function showErrorToast(message: string) {
+ Toasts.show({
+ message,
+ type: Toasts.Type.FAILURE,
+ id: Toasts.genId(),
+ options: {
+ position: Toasts.Position.BOTTOM
+ }
+ });
+}
+
+interface PluginCardProps extends React.HTMLProps<HTMLDivElement> {
+ plugin: Plugin;
+ disabled: boolean;
+ onRestartNeeded(): void;
+}
+
+function PluginCard({ plugin, disabled, onRestartNeeded, onMouseEnter, onMouseLeave }: PluginCardProps) {
+ const settings = useSettings().plugins[plugin.name];
+
+ function isEnabled() {
+ return settings?.enabled || plugin.started;
+ }
+
+ function openModal() {
+ Modals.openModalLazy(async () => {
+ return modalProps => {
+ return <PluginModal {...modalProps} plugin={plugin} onRestartNeeded={onRestartNeeded} />;
+ };
+ });
+ }
+
+ function toggleEnabled() {
+ const enabled = isEnabled();
+ const result = enabled ? stopPlugin(plugin) : startPlugin(plugin);
+ const action = enabled ? "stop" : "start";
+ if (!result) {
+ showErrorToast(`Failed to ${action} plugin: ${plugin.name}`);
+ return;
+ }
+ settings.enabled = !settings.enabled;
+ if (plugin.patches) onRestartNeeded();
+ }
+
+ return (
+ <Flex style={styles.PluginsGridItem} flexDirection="column" onClick={() => openModal()} onMouseEnter={onMouseEnter} onMouseLeave={onMouseLeave}>
+ <Text variant="text-md/bold">{plugin.name}</Text>
+ <Text variant="text-md/normal" style={{ height: 40, overflow: "hidden" }}>{plugin.description}</Text>
+ <Flex flexDirection="row-reverse" style={{ marginTop: "auto", width: "100%", justifyContent: "space-between" }}>
+ <Button
+ onClick={e => {
+ e.preventDefault();
+ e.stopPropagation();
+ toggleEnabled();
+ }}
+ disabled={disabled}
+ color={isEnabled() ? Button.Colors.RED : Button.Colors.GREEN}
+ >
+ {isEnabled() ? "Disable" : "Enable"}
+ </Button>
+ {plugin.options && <Forms.FormText style={{ cursor: "pointer", margin: "auto 0 auto 10px" }}>Click to configure</Forms.FormText>}
+ </Flex>
+ </Flex>
+ );
+}
+
+export default ErrorBoundary.wrap(function Settings() {
+ const settings = useSettings();
+ const changes = React.useMemo(() => new ChangeList<string>(), []);
+
+ React.useEffect(() => {
+ return () => void (changes.hasChanges && Alerts.show({
+ title: "Restart required",
+ body: (
+ <>
+ <p>The following plugins require a restart:</p>
+ <div>{changes.map((s, i) => (
+ <>
+ {i > 0 && ", "}
+ {Parser.parse("`" + s + "`")}
+ </>
+ ))}</div>
+ </>
+ ),
+ confirmText: "Restart now",
+ cancelText: "Later!",
+ onConfirm: () => location.reload()
+ }));
+ }, []);
+
+ const depMap = React.useMemo(() => {
+ const o = {} as Record<string, string[]>;
+ for (const plugin in Plugins) {
+ const deps = Plugins[plugin].dependencies;
+ if (deps) {
+ for (const dep of deps) {
+ o[dep] ??= [];
+ o[dep].push(plugin);
+ }
+ }
+ }
+ 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)), []);
+
+ const [searchValue, setSearchValue] = React.useState({ value: "", status: "all" });
+
+ const onSearch = (query: string) => setSearchValue(prev => ({ ...prev, value: query }));
+ const onStatusChange = (status: string) => setSearchValue(prev => ({ ...prev, status }));
+
+ const pluginFilter = (plugin: typeof Plugins[keyof typeof Plugins]) => {
+ const showEnabled = searchValue.status === "enabled" || searchValue.status === "all";
+ const showDisabled = searchValue.status === "disabled" || searchValue.status === "all";
+ const enabled = settings.plugins[plugin.name]?.enabled || plugin.started;
+ return (
+ ((showEnabled && enabled) || (showDisabled && !enabled)) &&
+ (
+ plugin.name.toLowerCase().includes(searchValue.value.toLowerCase()) ||
+ plugin.description.toLowerCase().includes(searchValue.value.toLowerCase())
+ )
+ );
+ };
+
+ return (
+ <Forms.FormSection tag="h1" title="Vencord">
+ <Forms.FormTitle tag="h5" className={classes(Margins.marginTop20, Margins.marginBottom8)}>
+ Plugins
+ </Forms.FormTitle>
+ <div style={styles.FiltersBar}>
+ <TextInput value={searchValue.value} placeholder={"Search for a plugin..."} onChange={onSearch} style={{ marginBottom: 24 }} />
+ <div className={InputStyles.inputWrapper}>
+ <Select
+ className={InputStyles.inputDefault}
+ options={[
+ { label: "Show All", value: "all", default: true },
+ { label: "Show Enabled", value: "enabled" },
+ { label: "Show Disabled", value: "disabled" }
+ ]}
+ serialize={v => String(v)}
+ select={onStatusChange}
+ isSelected={v => v === searchValue.status}
+ closeOnSelect={true}
+ />
+ </div>
+ </div>
+
+ <div style={styles.PluginsGrid}>
+ {sortedPlugins?.length ? sortedPlugins
+ .filter(a => !a.required && !dependencyCheck(a.name, depMap).length && pluginFilter(a))
+ .map(plugin => {
+ const enabledDependants = depMap[plugin.name]?.filter(d => settings.plugins[d].enabled);
+ const dependency = enabledDependants?.length;
+ return <PluginCard
+ onRestartNeeded={() => {
+ changes.handleChange(plugin.name);
+ }}
+ disabled={plugin.required || !!dependency}
+ plugin={plugin}
+ />;
+ })
+ : <Text variant="text-md/normal">No plugins meet search criteria.</Text>
+ }
+ </div>
+ <Forms.FormDivider />
+ <Forms.FormTitle tag="h5" className={classes(Margins.marginTop20, Margins.marginBottom8)}>
+ Required Plugins
+ </Forms.FormTitle>
+ <div style={styles.PluginsGrid}>
+ {sortedPlugins?.length ? sortedPlugins
+ .filter(a => a.required || dependencyCheck(a.name, depMap).length && pluginFilter(a))
+ .map(plugin => {
+ const enabledDependants = depMap[plugin.name]?.filter(d => settings.plugins[d].enabled);
+ const dependency = enabledDependants?.length;
+ const tooltipText = plugin.required
+ ? "This plugin is required for Vencord to function."
+ : makeDependencyList(dependencyCheck(plugin.name, depMap));
+ return <Tooltip text={tooltipText}>
+ {({ onMouseLeave, onMouseEnter }) => (
+ <PluginCard
+ onMouseLeave={onMouseLeave}
+ onMouseEnter={onMouseEnter}
+ onRestartNeeded={() => {
+ changes.handleChange(plugin.name);
+ }}
+ disabled={plugin.required || !!dependency}
+ plugin={plugin}
+ />
+ )}
+ </Tooltip>;
+ })
+ : <Text variant="text-md/normal">No plugins meet search criteria.</Text>
+ }
+ </div>
+ </Forms.FormSection >
+ );
+});
+
+function makeDependencyList(deps: string[]) {
+ return (
+ <React.Fragment>
+ <Forms.FormText>This plugin is required by:</Forms.FormText>
+ {deps.map((dep: string) => <Forms.FormText style={{ margin: "0 auto" }}>{dep}</Forms.FormText>)}
+ </React.Fragment>
+ );
+}
+
+function dependencyCheck(pluginName: string, depMap: Record<string, string[]>): string[] {
+ return depMap[pluginName] || [];
+}
diff --git a/src/components/PluginSettings/styles.ts b/src/components/PluginSettings/styles.ts
new file mode 100644
index 0000000..836c60e
--- /dev/null
+++ b/src/components/PluginSettings/styles.ts
@@ -0,0 +1,24 @@
+export const PluginsGrid: React.CSSProperties = {
+ marginTop: 16,
+ display: "grid",
+ gridGap: 16,
+ gridTemplateColumns: "repeat(auto-fill, minmax(250px, 1fr))",
+};
+
+export const PluginsGridItem: React.CSSProperties = {
+ backgroundColor: "var(--background-modifier-selected)",
+ color: "var(--interactive-active)",
+ borderRadius: 3,
+ cursor: "pointer",
+ display: "block",
+ height: 150,
+ padding: 10,
+ width: "100%",
+};
+
+export const FiltersBar: React.CSSProperties = {
+ gap: 10,
+ height: 40,
+ gridTemplateColumns: "1fr 150px",
+ display: "grid"
+};
diff --git a/src/components/Settings.tsx b/src/components/Settings.tsx
index 2a2cc5d..ba7d71d 100644
--- a/src/components/Settings.tsx
+++ b/src/components/Settings.tsx
@@ -1,25 +1,10 @@
-import { classes, humanFriendlyJoin, useAwaiter } from "../utils/misc";
-import Plugins from "plugins";
import { useSettings } from "../api/settings";
+import { ChangeList } from "../utils/ChangeList";
import IpcEvents from "../utils/IpcEvents";
-
-import { Button, Switch, Forms, React, Margins, Toasts, Alerts, Parser } from "../webpack/common";
+import { useAwaiter } from "../utils/misc";
+import { Alerts, Button, Forms, Margins, Parser, React, Switch } from "../webpack/common";
import ErrorBoundary from "./ErrorBoundary";
-import { startPlugin } from "../plugins";
-import { stopPlugin } from "../plugins/index";
import { Flex } from "./Flex";
-import { ChangeList } from "../utils/ChangeList";
-
-function showErrorToast(message: string) {
- Toasts.show({
- message,
- type: Toasts.Type.FAILURE,
- id: Toasts.genId(),
- options: {
- position: Toasts.Position.BOTTOM
- }
- });
-}
export default ErrorBoundary.wrap(function Settings() {
const [settingsDir, , settingsDirPending] = useAwaiter(() => VencordNative.ipc.invoke<string>(IpcEvents.GET_SETTINGS_DIR), "Loading...");
@@ -46,21 +31,6 @@ export default ErrorBoundary.wrap(function Settings() {
}));
}, []);
- const depMap = React.useMemo(() => {
- const o = {} as Record<string, string[]>;
- for (const plugin in Plugins) {
- const deps = Plugins[plugin].dependencies;
- if (deps) {
- for (const dep of deps) {
- o[dep] ??= [];
- o[dep].push(plugin);
- }
- }
- }
- return o;
- }, []);
-
- const sortedPlugins = React.useMemo(() => Object.values(Plugins).sort((a, b) => a.name.localeCompare(b.name)), []);
return (
<Forms.FormSection tag="h1" title="Vencord">
@@ -69,10 +39,10 @@ export default ErrorBoundary.wrap(function Settings() {
</Forms.FormTitle>
<Forms.FormText>
- SettingsDir: <code style={{ userSelect: "text", cursor: "text" }}>{settingsDir}</code>
+ Settings Directory: <code style={{ userSelect: "text", cursor: "text" }}>{settingsDir}</code>
</Forms.FormText>
- {!IS_WEB && <Flex className={Margins.marginBottom20}>
+ {!IS_WEB && <Flex className={Margins.marginBottom20} style={{ marginTop: 8 }}>
<Button
onClick={() => window.DiscordNative.app.relaunch()}
size={Button.Sizes.SMALL}
@@ -118,58 +88,6 @@ export default ErrorBoundary.wrap(function Settings() {
>
Get notified about new Updates
</Switch>}
-
- <Forms.FormDivider />
-
- <Forms.FormTitle tag="h5" className={classes(Margins.marginTop20, Margins.marginBottom8)}>
- Plugins
- </Forms.FormTitle>
-
- {sortedPlugins.map(p => {
- const enabledDependants = depMap[p.name]?.filter(d => settings.plugins[d].enabled);
- const dependency = enabledDependants?.length;
-
- return (
- <Switch
- disabled={p.required || dependency}
- key={p.name}
- value={settings.plugins[p.name].enabled || p.required || dependency}
- onChange={(v: boolean) => {
- settings.plugins[p.name].enabled = v;
- let needsRestart = Boolean(p.patches?.length);
- if (v) {
- p.dependencies?.forEach(d => {
- const dep = Plugins[d];
- needsRestart ||= Boolean(dep.patches?.length && !settings.plugins[d].enabled);
- settings.plugins[d].enabled = true;
- if (!needsRestart && !dep.started && !startPlugin(dep)) {
- showErrorToast(`Failed to start dependency ${d}. Check the console for more info.`);
- }
- });
- if (!needsRestart && !p.started && !startPlugin(p)) {
- showErrorToast(`Failed to start plugin ${p.name}. Check the console for more info.`);
- }
- } else {
- if ((p.started || !p.start && p.commands?.length) && !stopPlugin(p)) {
- showErrorToast(`Failed to stop plugin ${p.name}. Check the console for more info.`);
- }
- }
- if (needsRestart) changes.handleChange(p.name);
- }}
- note={p.description}
- tooltipNote={
- p.required ?
- "This plugin is required. Thus you cannot disable it."
- : dependency ?
- `${humanFriendlyJoin(enabledDependants)} ${enabledDependants.length === 1 ? "depends" : "depend"} on this plugin. Thus you cannot disable it.`
- : null
- }
- >
- {p.name}
- </Switch>
- );
- })
- }
</Forms.FormSection >
);
});
diff --git a/src/components/index.ts b/src/components/index.ts
index de53489..8843c11 100644
--- a/src/components/index.ts
+++ b/src/components/index.ts
@@ -1,2 +1,3 @@
export { default as Settings } from "./Settings";
+export { default as PluginSettings } from "./PluginSettings";
export { default as Updater } from "./Updater";
diff --git a/src/plugins/experiments.ts b/src/plugins/experiments.ts
deleted file mode 100644
index b441a76..0000000
--- a/src/plugins/experiments.ts
+++ /dev/null
@@ -1,21 +0,0 @@
-import { Devs } from "../utils/constants";
-import definePlugin from "../utils/types";
-
-export default definePlugin({
- name: "Experiments",
- authors: [Devs.Ven, Devs.Megu],
- description: "Enable Experiments",
- patches: [{
- find: "Object.defineProperties(this,{isDeveloper",
- replacement: {
- match: /(?<={isDeveloper:\{[^}]+,get:function\(\)\{return )\w/,
- replace: "true"
- }
- }, {
- find: 'type:"user",revision',
- replacement: {
- match: /(\w)\|\|"CONNECTION_OPEN".+?;/g,
- replace: "$1=!0;"
- }
- }]
-});
diff --git a/src/plugins/experiments.tsx b/src/plugins/experiments.tsx
new file mode 100644
index 0000000..4cc2167
--- /dev/null
+++ b/src/plugins/experiments.tsx
@@ -0,0 +1,74 @@
+import { lazyWebpack } from "../utils";
+import { Devs } from "../utils/constants";
+import definePlugin, { OptionType } from "../utils/types";
+import { Settings } from "../Vencord";
+import { filters } from "../webpack";
+import { Forms, React } from "../webpack/common";
+
+const KbdStyles = lazyWebpack(filters.byProps(["key", "removeBuildOverride"]));
+
+export default definePlugin({
+ name: "Experiments",
+ authors: [
+ Devs.Megu,
+ Devs.Ven,
+ { name: "Nickyux", id: 427146305651998721n },
+ { name: "BanTheNons", id: 460478012794863637n },
+ ],
+ description: "Enable Access to Experiments in Discord!",
+ patches: [{
+ find: "Object.defineProperties(this,{isDeveloper",
+ replacement: {
+ match: /(?<={isDeveloper:\{[^}]+,get:function\(\)\{return )\w/,
+ replace: "true"
+ },
+ }, {
+ find: 'type:"user",revision',
+ replacement: {
+ match: /(\w)\|\|"CONNECTION_OPEN".+?;/g,
+ replace: "$1=!0;"
+ },
+ }, {
+ find: ".isStaff=function(){",
+ predicate: () => Settings.plugins["Experiments"].enableIsStaff === true,
+ replacement: [
+ {
+ match: /return\s*(\w+)\.hasFlag\((.+?)\.STAFF\)}/,
+ replace: "return Vencord.Webpack.Common.UserStore.getCurrentUser().id===$1.id||$1.hasFlag($2.STAFF)}"
+ },
+ {
+ match: /hasFreePremium=function\(\){return this.is Staff\(\)\s*\|\|/,
+ replace: "hasFreePremium=function(){return ",
+ },
+ ],
+ }],
+ options: {
+ enableIsStaff: {
+ description: "Enable isStaff (requires restart)",
+ type: OptionType.BOOLEAN,
+ default: false,
+ restartNeeded: true,
+ }
+ },
+
+ settingsAboutComponent: () => {
+ const isMacOS = navigator.platform.includes("Mac");
+ const modKey = isMacOS ? "cmd" : "ctrl";
+ const altKey = isMacOS ? "opt" : "alt";
+ return (
+ <React.Fragment>
+ <Forms.FormTitle tag="h3">More Information</Forms.FormTitle>
+ <Forms.FormText variant="text-md/normal">
+ You can enable client DevTools{" "}
+ <kbd className={KbdStyles.key}>{modKey}</kbd> +{" "}
+ <kbd className={KbdStyles.key}>{altKey}</kbd> +{" "}
+ <kbd className={KbdStyles.key}>O</kbd>{" "}
+ after enabling <code>isStaff</code> below
+ </Forms.FormText>
+ <Forms.FormText>
+ and then toggling <code>Enable DevTools</code> in the <code>Developer Options</code> tab in settings.
+ </Forms.FormText>
+ </React.Fragment>
+ );
+ }
+});
diff --git a/src/plugins/index.ts b/src/plugins/index.ts
index 44f1e83..10536c4 100644
--- a/src/plugins/index.ts
+++ b/src/plugins/index.ts
@@ -1,4 +1,5 @@
import Plugins from "plugins";
+
import { registerCommand, unregisterCommand } from "../api/Commands";
import { Settings } from "../api/settings";
import Logger from "../utils/logger";
diff --git a/src/plugins/isStaff.ts b/src/plugins/isStaff.ts
deleted file mode 100644
index c5a95d0..0000000
--- a/src/plugins/isStaff.ts
+++ /dev/null
@@ -1,34 +0,0 @@
-import { Devs } from "../utils/constants";
-import definePlugin from "../utils/types";
-
-export default definePlugin({
- name: "isStaff",
- description:
- "Gives access to client devtools & other things locked behind isStaff",
- authors: [
- Devs.Megu,
- {
- name: "Nickyux",
- id: 427146305651998721n
- },
- {
- name: "BanTheNons",
- id: 460478012794863637n
- }
- ],
- patches: [
- {
- find: ".isStaff=function(){",
- replacement: [
- {
- match: /return\s*(\w+)\.hasFlag\((.+?)\.STAFF\)}/,
- replace: "return Vencord.Webpack.Common.UserStore.getCurrentUser().id===$1.id||$1.hasFlag($2.STAFF)}"
- },
- {
- match: /hasFreePremium=function\(\){return this.isStaff\(\)\s*\|\|/,
- replace: "hasFreePremium=function(){return ",
- },
- ],
- },
- ],
-});
diff --git a/src/plugins/settings.ts b/src/plugins/settings.ts
index d26688a..0c218ac 100644
--- a/src/plugins/settings.ts
+++ b/src/plugins/settings.ts
@@ -1,11 +1,12 @@
-import definePlugin from "../utils/types";
import gitHash from "git-hash";
+
import { Devs } from "../utils/constants";
+import definePlugin from "../utils/types";
export default definePlugin({
name: "Settings",
description: "Adds Settings UI and debug info",
- authors: [Devs.Ven],
+ authors: [Devs.Ven, Devs.Megu],
required: true,
patches: [{
find: "().versionHash",
@@ -33,6 +34,7 @@ export default definePlugin({
return (
`{section:${mod}.ID.HEADER,label:"Vencord"},` +
'{section:"VencordSetting",label:"Vencord",element:Vencord.Components.Settings},' +
+ '{section:"VencordPlugins",label:"Plugins",element:Vencord.Components.PluginSettings},' +
updater +
`{section:${mod}.ID.DIVIDER},${m}`
);
diff --git a/src/plugins/viewIcons.tsx b/src/plugins/viewIcons.tsx
index a55b8b8..d6cbf60 100644
--- a/src/plugins/viewIcons.tsx
+++ b/src/plugins/viewIcons.tsx
@@ -1,7 +1,7 @@
import { Devs } from "../utils/constants";
import definePlugin from "../utils/types";
import { lazyWebpack, makeLazy } from "../utils/misc";
-import { ModalSize, openModal } from "../utils/modal";
+import { ModalRoot, ModalSize, openModal } from "../utils/modal";
import { find } from "../webpack";
import { React } from "../webpack/common";
@@ -15,14 +15,16 @@ export default definePlugin({
description: "Makes Avatars/Banners in user profiles clickable, and adds Guild Context Menu Entries to View Banner/Icon.",
openImage(url: string) {
- openModal(() => (
- <ImageModal
- shouldAnimate={true}
- original={url}
- src={url}
- renderLinkComponent={props => React.createElement(getMaskedLink(), props)}
- />
- ), { size: ModalSize.DYNAMIC });
+ openModal(modalProps => (
+ <ModalRoot size={ModalSize.DYNAMIC} {...modalProps}>
+ <ImageModal
+ shouldAnimate={true}
+ original={url}
+ src={url}
+ renderLinkComponent={props => React.createElement(getMaskedLink(), props)}
+ />
+ </ModalRoot>
+ ));
},
patches: [
diff --git a/src/utils/modal.tsx b/src/utils/modal.tsx
index f142aee..4c8df6c 100644
--- a/src/utils/modal.tsx
+++ b/src/utils/modal.tsx
@@ -1,15 +1,6 @@
import { filters } from "../webpack";
-import { lazyWebpack } from "./misc";
import { mapMangledModuleLazy } from "../webpack/webpack";
-const ModalRoot = lazyWebpack(filters.byCode("headerIdIsManaged:"));
-const Modals = mapMangledModuleLazy("onCloseRequest:null!=", {
- openModal: filters.byCode("onCloseRequest:null!="),
- closeModal: filters.byCode("onCloseCallback&&")
-});
-
-let modalId = 1337;
-
export enum ModalSize {
SMALL = "small",
MEDIUM = "medium",
@@ -17,26 +8,64 @@ export enum ModalSize {
DYNAMIC = "dynamic",
}
-/**
- * Open a modal
- * @param Component The component to render in the modal
- * @returns The key of this modal. This can be used to close the modal later with closeModal
- */
-export function openModal(Component: React.ComponentType, modalProps: Record<string, any>) {
- let key = `Vencord${modalId++}`;
- Modals.openModal(props => (
- <ModalRoot {...props} {...modalProps}>
- <Component />
- </ModalRoot>
- ), { modalKey: key });
-
- return key;
-}
-
-/**
- * Close a modal by key. The id you need for this is returned by openModal.
- * @param key The key of the modal to close
- */
-export function closeModal(key: string) {
- Modals.closeModal(key);
+enum ModalTransitionState {
+ ENTERING,
+ ENTERED,
+ EXITING,
+ EXITED,
+ HIDDEN,
+}
+
+export interface ModalProps {
+ transitionState: ModalTransitionState;
+ onClose(): Promise<void>;
+}
+
+export interface ModalOptions {
+ modalKey?: string;
+ onCloseRequest?: (() => void);
+ onCloseCallback?: (() => void);
+}
+
+interface ModalRootProps {
+ transitionState: ModalTransitionState;
+ children: React.ReactNode;
+ size?: ModalSize;
+ role?: "alertdialog" | "dialog";
+ className?: string;
+ onAnimationEnd?(): string;
+}
+
+type RenderFunction = (props: ModalProps) => React.ReactNode;
+
+export const Modals = mapMangledModuleLazy(".onAnimationEnd,", {
+ ModalRoot: filters.byCode("headerIdIsManaged:"),
+ ModalHeader: filters.byCode("children", "separator", "wrap", "NO_WRAP", "grow", "shrink", "id", "header"),
+ ModalContent: filters.byCode("scrollerRef", "content", "className", "children"),
+ ModalFooter: filters.byCode("HORIZONTAL_REVERSE", "START", "STRETCH", "NO_WRAP", "footerSeparator"),
+ ModalCloseButton: filters.byCode("closeWithCircleBackground", "hideOnFullscreen"),
+});
+
+export const ModalRoot = (props: ModalRootProps) => <Modals.ModalRoot {...props} />;
+export const ModalHeader = (props: any) => <Modals.ModalHeader {...props} />;
+export const ModalContent = (props: any) => <Modals.ModalContent {...props} />;
+export const ModalFooter = (props: any) => <Modals.ModalFooter {...props} />;
+export const ModalCloseButton = (props: any) => <Modals.ModalCloseButton {...props} />;
+
+const ModalAPI = mapMangledModuleLazy("onCloseRequest:null!=", {
+ openModal: filters.byCode("onCloseRequest:null!="),
+ closeModal: filters.byCode("onCloseCallback&&"),
+ openModalLazy: m => m?.length === 1 && filters.byCode(".apply(this,arguments)")(m),
+});
+
+export function openModalLazy(render: () => Promise<RenderFunction>, options?: ModalOptions & { contextKey?: string; }): Promise<string> {
+ return ModalAPI.openModalLazy(render, options);
+}
+
+export function openModal(render: RenderFunction, options?: ModalOptions, contextKey?: string): string {
+ return ModalAPI.openModal(render, options, contextKey);
+}
+
+export function closeModal(modalKey: string, contextKey?: string): void {
+ return ModalAPI.closeModal(modalKey, contextKey);
}
diff --git a/src/utils/types.ts b/src/utils/types.ts
index f7ccdb6..5ed95e4 100644
--- a/src/utils/types.ts
+++ b/src/utils/types.ts
@@ -15,6 +15,7 @@ export interface Patch {
find: string;
replacement: PatchReplacement | PatchReplacement[];
all?: boolean;
+ predicate?(): boolean;
}
export interface PluginAuthor {
@@ -34,13 +35,101 @@ interface PluginDef {
start?(): void;
stop?(): void;
patches?: Omit<Patch, "plugin">[];
+ /**
+ * List of commands. If you specify these, you must add CommandsAPI to dependencies
+ */
commands?: Command[];
+ /**
+ * A list of other plugins that your plugin depends on.
+ * These will automatically be enabled and loaded before your plugin
+ * Common examples are CommandsAPI, MessageEventsAPI...
+ */
dependencies?: string[],
+ /**
+ * Whether this plugin is required and forcefully enabled
+ */
required?: boolean;
/**
* Set this if your plugin only works on Browser or Desktop, not both
*/
target?: "WEB" | "DESKTOP" | "BOTH";
+ /**
+ * Optionally provide settings that the user can configure in the Plugins tab of settings.
+ */
+ options?: Record<string, PluginOptionsItem>;
+ /**
+ * Allows you to specify a custom Component that will be rendered in your
+ * plugin's settings page
+ */
+ settingsAboutComponent?: React.ComponentType;
+}
+
+export enum OptionType {
+ STRING,
+ NUMBER,
+ BIGINT,
+ BOOLEAN,
+ SELECT,
+}
+
+export type PluginOptionsItem =
+ | PluginOptionString
+ | PluginOptionNumber
+ | PluginOptionBoolean
+ | PluginOptionSelect;
+
+export interface PluginOptionBase {
+ description: string;
+ placeholder?: string;
+ onChange?(newValue: any): void;
+ disabled?(): boolean;
+ restartNeeded?: boolean;
+ componentProps?: Record<string, any>;
+ /**
+ * Set this if the setting only works on Browser or Desktop, not both
+ */
+ target?: "WEB" | "DESKTOP" | "BOTH";
+}
+
+export interface PluginOptionString extends PluginOptionBase {
+ type: OptionType.STRING;
+ /**
+ * Prevents the user from saving settings if this is false or a string
+ */
+ isValid?(value: string): boolean | string;
+ default?: string;
+}
+
+export interface PluginOptionNumber extends PluginOptionBase {
+ type: OptionType.NUMBER | OptionType.BIGINT;
+ /**
+ * Prevents the user from saving settings if this is false or a string
+ */
+ isValid?(value: number | BigInt): boolean | string;
+ default?: number;
+}
+
+export interface PluginOptionBoolean extends PluginOptionBase {
+ type: OptionType.BOOLEAN;
+ /**
+ * Prevents the user from saving settings if this is false or a string
+ */
+ isValid?(value: boolean): boolean | string;
+ default?: boolean;
+}
+
+export interface PluginOptionSelect extends PluginOptionBase {
+ type: OptionType.SELECT;
+ /**
+ * Prevents the user from saving settings if this is false or a string
+ */
+ isValid?(value: PluginOptionSelectOption): boolean | string;
+ options: PluginOptionSelectOption[];
+}
+export interface PluginOptionSelectOption {
+ label: string;
+ value: string | number | boolean;
+ default?: boolean;
}
export type IpcRes<V = any> = { ok: true; value: V; } | { ok: false, error: any; };
diff --git a/src/webpack/common.tsx b/src/webpack/common.tsx
index b795c06..8077a9f 100644
--- a/src/webpack/common.tsx
+++ b/src/webpack/common.tsx
@@ -1,9 +1,11 @@
-import { waitFor, filters, _resolveReady } from "./webpack";
+import { User } from "discord-types/general";
+
+import { lazyWebpack } from "../utils/misc";
+import { _resolveReady, filters, waitFor } from "./webpack";
+
import type Components from "discord-types/components";
import type Stores from "discord-types/stores";
import type Other from "discord-types/other";
-import { lazyWebpack } from "../utils/misc";
-
export const Margins = lazyWebpack(filters.byProps(["marginTop20"]));
export let FluxDispatcher: Other.FluxDispatcher;
@@ -25,6 +27,10 @@ export let Button: any;
export let Switch: any;
export let Tooltip: Components.Tooltip;
export let Router: any;
+export let TextInput: any;
+export let Text: (props: TextProps) => JSX.Element;
+
+export const Select = lazyWebpack(filters.byCode("optionClassName", "popoutPosition", "autoFocus", "maxVisibleItems"));
export let Parser: any;
export let Alerts: {
@@ -82,6 +88,10 @@ export const Toasts = {
}
};
+export const UserUtils = {
+ fetchUser: lazyWebpack(filters.byCode(".USER(", "getUser")) as (id: string) => Promise<User>,
+};
+
waitFor("useState", m => React = m);
waitFor(["dispatch", "subscribe"], m => {
FluxDispatcher = m;
@@ -120,3 +130,23 @@ waitFor(["show", "close"], m => Alerts = m);
waitFor("parseTopic", m => Parser = m);
waitFor(["open", "saveAccountChanges"], m => Router = m);
+waitFor(["defaultProps", "Sizes", "contextType"], m => TextInput = m);
+
+waitFor(m => {
+ if (typeof m !== "function") return false;
+ const s = m.toString();
+ return (s.length < 1500 && s.includes("data-text-variant") && s.includes("always-white"));
+}, m => Text = m);
+
+export type TextProps = React.PropsWithChildren & {
+ variant: TextVariant;
+ style?: React.CSSProperties;
+ color?: string;
+ tag?: "div" | "span" | "p" | "strong";
+ selectable?: boolean;
+ lineClamp?: number;
+ id?: string;
+ className?: string;
+};
+
+export type TextVariant = "heading-sm/normal" | "heading-sm/medium" | "heading-sm/bold" | "heading-md/normal" | "heading-md/medium" | "heading-md/bold" | "heading-lg/normal" | "heading-lg/medium" | "heading-lg/bold" | "heading-xl/normal" | "heading-xl/medium" | "heading-xl/bold" | "heading-xxl/normal" | "heading-xxl/medium" | "heading-xxl/bold" | "eyebrow" | "heading-deprecated-14/normal" | "heading-deprecated-14/medium" | "heading-deprecated-14/bold" | "text-xxs/normal" | "text-xxs/medium" | "text-xxs/semibold" | "text-xxs/bold" | "text-xs/normal" | "text-xs/medium" | "text-xs/semibold" | "text-xs/bold" | "text-sm/normal" | "text-sm/medium" | "text-sm/semibold" | "text-sm/bold" | "text-md/normal" | "text-md/medium" | "text-md/semibold" | "text-md/bold" | "text-lg/normal" | "text-lg/medium" | "text-lg/semibold" | "text-lg/bold" | "display-md" | "display-lg" | "code";
diff --git a/src/webpack/patchWebpack.ts b/src/webpack/patchWebpack.ts
index 54034df..7d03d66 100644
--- a/src/webpack/patchWebpack.ts
+++ b/src/webpack/patchWebpack.ts
@@ -100,6 +100,7 @@ function patchPush() {
for (let i = 0; i < patches.length; i++) {
const patch = patches[i];
+ if (patch.predicate && !patch.predicate()) continue;
if (code.includes(patch.find)) {
patchedBy.add(patch.plugin);
// @ts-ignore we change all patch.replacement to array in plugins/index