diff options
author | megumin <megumin.bakaretsurie@gmail.com> | 2022-10-17 20:18:25 +0100 |
---|---|---|
committer | GitHub <noreply@github.com> | 2022-10-17 20:18:25 +0100 |
commit | 5625d63e46c43132676148a86739025c15fa5f2d (patch) | |
tree | 3699e126ad86cc2972b3aecfe4eeaef378e9e9f4 /src/components/PluginSettings | |
parent | ae730e83984cbf4dc804eebbf260a055bfe635c0 (diff) | |
download | Vencord-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/PluginSettings')
8 files changed, 661 insertions, 0 deletions
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" +}; |