aboutsummaryrefslogtreecommitdiff
path: root/src/components
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/components
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/components')
-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
12 files changed, 672 insertions, 92 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";