diff options
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 |