From 5625d63e46c43132676148a86739025c15fa5f2d Mon Sep 17 00:00:00 2001 From: megumin Date: Mon, 17 Oct 2022 20:18:25 +0100 Subject: [PATCH] Settings 2.0 (#107) Co-authored-by: Vendicated --- src/components/ErrorBoundary.tsx | 8 +- src/components/Flex.tsx | 2 +- src/components/PluginSettings/PluginModal.tsx | 202 +++++++++++++++ .../components/SettingBooleanComponent.tsx | 51 ++++ .../components/SettingNumericComponent.tsx | 50 ++++ .../components/SettingSelectComponent.tsx | 44 ++++ .../components/SettingTextComponent.tsx | 39 +++ .../PluginSettings/components/index.ts | 17 ++ src/components/PluginSettings/index.tsx | 234 ++++++++++++++++++ src/components/PluginSettings/styles.ts | 24 ++ src/components/Settings.tsx | 96 +------ src/components/index.ts | 1 + src/plugins/experiments.ts | 21 -- src/plugins/experiments.tsx | 74 ++++++ src/plugins/index.ts | 1 + src/plugins/isStaff.ts | 34 --- src/plugins/settings.ts | 6 +- src/plugins/viewIcons.tsx | 20 +- src/utils/modal.tsx | 87 ++++--- src/utils/types.ts | 89 +++++++ src/webpack/common.tsx | 36 ++- src/webpack/patchWebpack.ts | 1 + 22 files changed, 945 insertions(+), 192 deletions(-) create mode 100644 src/components/PluginSettings/PluginModal.tsx create mode 100644 src/components/PluginSettings/components/SettingBooleanComponent.tsx create mode 100644 src/components/PluginSettings/components/SettingNumericComponent.tsx create mode 100644 src/components/PluginSettings/components/SettingSelectComponent.tsx create mode 100644 src/components/PluginSettings/components/SettingTextComponent.tsx create mode 100644 src/components/PluginSettings/components/index.ts create mode 100644 src/components/PluginSettings/index.tsx create mode 100644 src/components/PluginSettings/styles.ts delete mode 100644 src/plugins/experiments.ts create mode 100644 src/plugins/experiments.tsx delete mode 100644 src/plugins/isStaff.ts diff --git a/src/components/ErrorBoundary.tsx b/src/components/ErrorBoundary.tsx index c62acb09e..bb0c3365b 100644 --- a/src/components/ErrorBoundary.tsx +++ b/src/components/ErrorBoundary.tsx @@ -5,6 +5,7 @@ import { ErrorCard } from "./ErrorCard"; interface Props { fallback?: React.ComponentType>; onError?(error: Error, errorInfo: React.ErrorInfo): void; + message?: string; } const color = "#e78284"; @@ -58,15 +59,14 @@ export default class ErrorBoundary extends React.Component; + const msg = this.props.message || "An error occurred while rendering this Component. More info can be found below and in your console."; + return (

Oh no!

-

- An error occurred while rendering this Component. More info can be found below - and in your console. -

+

{msg}

{this.state.message} {!!this.state.stack && ( diff --git a/src/components/Flex.tsx b/src/components/Flex.tsx index eda3b333a..8a80f02ae 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>) { 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 000000000..a32430041 --- /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> = 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[]>([]); + + const pluginSettings = useSettings().plugins[plugin.name]; + + const [tempSettings, setTempSettings] = React.useState>({}); + + const [errors, setErrors] = React.useState>({}); + + 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 There are no settings for this plugin.; + } + + 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(); + break; + } + case OptionType.STRING: { + options.push(); + break; + } + case OptionType.NUMBER: + case OptionType.BIGINT: { + options.push(); + break; + } + case OptionType.BOOLEAN: { + options.push(); + } + } + } + return {options}; + } + + 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 ( + u.name).join(", ")}> + {({ onMouseEnter, onMouseLeave }) => ( +
+ +{sliceCount} +
+ )} +
+ ); + } + + return ( + + + {plugin.name} + + + + About {plugin.name} + {plugin.description} +
+ +
+
+ {!!plugin.settingsAboutComponent && ( +
+ + + + + +
+ )} + + Settings + {renderSettings()} + +
+ + + + + {({ onMouseEnter, onMouseLeave }) => ( + + )} + + + +
+ ); +} diff --git a/src/components/PluginSettings/components/SettingBooleanComponent.tsx b/src/components/PluginSettings/components/SettingBooleanComponent.tsx new file mode 100644 index 000000000..62dd4d5e5 --- /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) { + const def = pluginSettings[id] ?? option.default; + + const [state, setState] = React.useState(def ?? false); + const [error, setError] = React.useState(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 ( + + {option.description} + v === state} + serialize={v => String(v)} + {...option.componentProps} + /> + {error && {error}} + + ); +} diff --git a/src/components/PluginSettings/components/SettingTextComponent.tsx b/src/components/PluginSettings/components/SettingTextComponent.tsx new file mode 100644 index 000000000..0bfe3fbec --- /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) { + const [state, setState] = React.useState(pluginSettings[id] ?? option.default ?? null); + const [error, setError] = React.useState(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 ( + + {option.description} + + {error && {error}} + + ); +} diff --git a/src/components/PluginSettings/components/index.ts b/src/components/PluginSettings/components/index.ts new file mode 100644 index 000000000..d1fe7d628 --- /dev/null +++ b/src/components/PluginSettings/components/index.ts @@ -0,0 +1,17 @@ +import { PluginOptionBase } from "../../../utils/types"; + +export interface ISettingElementProps { + 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 000000000..f8cb73c19 --- /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 { + 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 ; + }; + }); + } + + 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 ( + openModal()} onMouseEnter={onMouseEnter} onMouseLeave={onMouseLeave}> + {plugin.name} + {plugin.description} + + + {plugin.options && Click to configure} + + + ); +} + +export default ErrorBoundary.wrap(function Settings() { + const settings = useSettings(); + const changes = React.useMemo(() => new ChangeList(), []); + + React.useEffect(() => { + return () => void (changes.hasChanges && Alerts.show({ + title: "Restart required", + body: ( + <> +

The following plugins require a restart:

+
{changes.map((s, i) => ( + <> + {i > 0 && ", "} + {Parser.parse("`" + s + "`")} + + ))}
+ + ), + confirmText: "Restart now", + cancelText: "Later!", + onConfirm: () => location.reload() + })); + }, []); + + const depMap = React.useMemo(() => { + const o = {} as Record; + 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 ( + + + Plugins + +
+ +
+