refactor Settings UI (#3545)
Much improved file structure and cleaner code. Also gets rid of temporary settings & saving and instead applies all changes immediately. Besides that, this change only changes code and doesn't change the ui
This commit is contained in:
parent
a33e81d1cb
commit
3f51ee1b2a
94 changed files with 2120 additions and 2002 deletions
2
.gitignore
vendored
2
.gitignore
vendored
|
|
@ -22,4 +22,4 @@ lerna-debug.log*
|
|||
src/userplugins
|
||||
|
||||
ExtensionCache/
|
||||
settings/
|
||||
/settings
|
||||
|
|
|
|||
2
packages/discord-types/src/components.d.ts
vendored
2
packages/discord-types/src/components.d.ts
vendored
|
|
@ -244,8 +244,10 @@ export type TextInput = ComponentType<PropsWithChildren<{
|
|||
Sizes: Record<"DEFAULT" | "MINI", string>;
|
||||
};
|
||||
|
||||
// FIXME: this is wrong, it's not actually just HTMLTextAreaElement
|
||||
export type TextArea = ComponentType<Omit<HTMLProps<HTMLTextAreaElement>, "onChange"> & {
|
||||
onChange(v: string): void;
|
||||
inputRef?: Ref<HTMLTextAreaElement>;
|
||||
}>;
|
||||
|
||||
interface SelectOption {
|
||||
|
|
|
|||
|
|
@ -16,6 +16,9 @@
|
|||
* along with this program. If not, see <https://www.gnu.org/licenses/>.
|
||||
*/
|
||||
|
||||
// DO NOT REMOVE UNLESS YOU WISH TO FACE THE WRATH OF THE CIRCULAR DEPENDENCY DEMON!!!!!!!
|
||||
import "~plugins";
|
||||
|
||||
export * as Api from "./api";
|
||||
export * as Components from "./components";
|
||||
export * as Plugins from "./plugins";
|
||||
|
|
@ -29,7 +32,8 @@ export { PlainSettings, Settings };
|
|||
import "./utils/quickCss";
|
||||
import "./webpack/patchWebpack";
|
||||
|
||||
import { openUpdaterModal } from "@components/VencordSettings/UpdaterTab";
|
||||
import { openUpdaterModal } from "@components/settings/tabs/updater";
|
||||
import { IS_WINDOWS } from "@utils/constants";
|
||||
import { StartAt } from "@utils/types";
|
||||
|
||||
import { get as dsGet } from "./api/DataStore";
|
||||
|
|
@ -161,7 +165,7 @@ init();
|
|||
document.addEventListener("DOMContentLoaded", () => {
|
||||
startAllPlugins(StartAt.DOMContentLoaded);
|
||||
|
||||
if (IS_DISCORD_DESKTOP && Settings.winNativeTitleBar && navigator.platform.toLowerCase().startsWith("win")) {
|
||||
if (IS_DISCORD_DESKTOP && Settings.winNativeTitleBar && IS_WINDOWS) {
|
||||
document.head.append(Object.assign(document.createElement("style"), {
|
||||
id: "vencord-native-titlebar-style",
|
||||
textContent: "[class*=titleBar]{display: none!important}"
|
||||
|
|
|
|||
|
|
@ -17,10 +17,9 @@
|
|||
*/
|
||||
|
||||
import ErrorBoundary from "@components/ErrorBoundary";
|
||||
import BadgeAPIPlugin from "plugins/_api/badges";
|
||||
import { ComponentType, HTMLProps } from "react";
|
||||
|
||||
import Plugins from "~plugins";
|
||||
|
||||
export const enum BadgePosition {
|
||||
START,
|
||||
END
|
||||
|
|
@ -90,7 +89,7 @@ export function _getBadges(args: BadgeUserArgs) {
|
|||
: badges.push(...b);
|
||||
}
|
||||
}
|
||||
const donorBadges = (Plugins.BadgeAPI as unknown as typeof import("../plugins/_api/badges").default).getDonorBadges(args.userId);
|
||||
const donorBadges = BadgeAPIPlugin.getDonorBadges(args.userId);
|
||||
if (donorBadges) badges.unshift(...donorBadges);
|
||||
|
||||
return badges;
|
||||
|
|
|
|||
|
|
@ -4,7 +4,7 @@
|
|||
* SPDX-License-Identifier: GPL-3.0-or-later
|
||||
*/
|
||||
|
||||
import { FluxStore, Message } from "@vencord/discord-types";
|
||||
import { Message } from "@vencord/discord-types";
|
||||
import { MessageCache, MessageStore } from "@webpack/common";
|
||||
|
||||
/**
|
||||
|
|
@ -24,5 +24,5 @@ export function updateMessage(channelId: string, messageId: string, fields?: Par
|
|||
});
|
||||
|
||||
MessageCache.commit(newChannelMessageCache);
|
||||
(MessageStore as unknown as FluxStore).emitChange();
|
||||
MessageStore.emitChange();
|
||||
}
|
||||
|
|
|
|||
|
|
@ -20,7 +20,7 @@ import * as DataStore from "@api/DataStore";
|
|||
import { Settings } from "@api/Settings";
|
||||
import { classNameFactory } from "@api/Styles";
|
||||
import { Flex } from "@components/Flex";
|
||||
import { openNotificationSettingsModal } from "@components/VencordSettings/NotificationSettings";
|
||||
import { openNotificationSettingsModal } from "@components/settings/tabs/vencord/NotificationSettings";
|
||||
import { closeModal, ModalCloseButton, ModalContent, ModalFooter, ModalHeader, ModalProps, ModalRoot, ModalSize, openModal } from "@utils/modal";
|
||||
import { useAwaiter } from "@utils/react";
|
||||
import { Alerts, Button, Forms, React, Text, Timestamp, useEffect, useReducer, useState } from "@webpack/common";
|
||||
|
|
|
|||
|
|
@ -27,7 +27,6 @@ interface BaseIconProps extends IconProps {
|
|||
}
|
||||
|
||||
type IconProps = JSX.IntrinsicElements["svg"];
|
||||
type ImageProps = JSX.IntrinsicElements["img"];
|
||||
|
||||
function Icon({ height = 24, width = 24, className, children, viewBox, ...svgProps }: PropsWithChildren<BaseIconProps>) {
|
||||
return (
|
||||
|
|
|
|||
|
|
@ -1,43 +0,0 @@
|
|||
/*
|
||||
* Vencord, a modification for Discord's desktop app
|
||||
* Copyright (c) 2022 Vendicated and contributors
|
||||
*
|
||||
* This program is free software: you can redistribute it and/or modify
|
||||
* it under the terms of the GNU General Public License as published by
|
||||
* the Free Software Foundation, either version 3 of the License, or
|
||||
* (at your option) any later version.
|
||||
*
|
||||
* This program is distributed in the hope that it will be useful,
|
||||
* but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
||||
* GNU General Public License for more details.
|
||||
*
|
||||
* You should have received a copy of the GNU General Public License
|
||||
* along with this program. If not, see <https://www.gnu.org/licenses/>.
|
||||
*/
|
||||
|
||||
import { DefinedSettings, PluginOptionBase } from "@utils/types";
|
||||
|
||||
interface ISettingElementPropsBase<T> {
|
||||
option: T;
|
||||
onChange(newValue: any): void;
|
||||
pluginSettings: {
|
||||
[setting: string]: any;
|
||||
enabled: boolean;
|
||||
};
|
||||
id: string;
|
||||
onError(hasError: boolean): void;
|
||||
definedSettings?: DefinedSettings;
|
||||
}
|
||||
|
||||
export type ISettingElementProps<T extends PluginOptionBase> = ISettingElementPropsBase<T>;
|
||||
export type ISettingCustomElementProps<T extends Omit<PluginOptionBase, "description" | "placeholder">> = ISettingElementPropsBase<T>;
|
||||
|
||||
export * from "../../Badge";
|
||||
export * from "./SettingBooleanComponent";
|
||||
export * from "./SettingCustomComponent";
|
||||
export * from "./SettingNumericComponent";
|
||||
export * from "./SettingSelectComponent";
|
||||
export * from "./SettingSliderComponent";
|
||||
export * from "./SettingTextComponent";
|
||||
|
||||
|
|
@ -1,393 +0,0 @@
|
|||
/*
|
||||
* Vencord, a modification for Discord's desktop app
|
||||
* Copyright (c) 2022 Vendicated and contributors
|
||||
*
|
||||
* This program is free software: you can redistribute it and/or modify
|
||||
* it under the terms of the GNU General Public License as published by
|
||||
* the Free Software Foundation, either version 3 of the License, or
|
||||
* (at your option) any later version.
|
||||
*
|
||||
* This program is distributed in the hope that it will be useful,
|
||||
* but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
||||
* GNU General Public License for more details.
|
||||
*
|
||||
* You should have received a copy of the GNU General Public License
|
||||
* along with this program. If not, see <https://www.gnu.org/licenses/>.
|
||||
*/
|
||||
|
||||
import { CodeBlock } from "@components/CodeBlock";
|
||||
import { debounce } from "@shared/debounce";
|
||||
import { copyToClipboard } from "@utils/clipboard";
|
||||
import { Margins } from "@utils/margins";
|
||||
import { canonicalizeMatch, canonicalizeReplace } from "@utils/patches";
|
||||
import { makeCodeblock } from "@utils/text";
|
||||
import { Patch, ReplaceFn } from "@utils/types";
|
||||
import { search } from "@webpack";
|
||||
import { Button, Forms, Parser, React, Switch, TextArea, TextInput } from "@webpack/common";
|
||||
|
||||
import { SettingsTab, wrapTab } from "./shared";
|
||||
|
||||
// Do not include diff in non dev builds (side effects import)
|
||||
if (IS_DEV) {
|
||||
var differ = require("diff") as typeof import("diff");
|
||||
}
|
||||
|
||||
const findCandidates = debounce(function ({ find, setModule, setError }) {
|
||||
const candidates = search(find);
|
||||
const keys = Object.keys(candidates);
|
||||
const len = keys.length;
|
||||
if (len === 0)
|
||||
setError("No match. Perhaps that module is lazy loaded?");
|
||||
else if (len !== 1)
|
||||
setError("Multiple matches. Please refine your filter");
|
||||
else
|
||||
setModule([keys[0], candidates[keys[0]]]);
|
||||
});
|
||||
|
||||
interface ReplacementComponentProps {
|
||||
module: [id: number, factory: Function];
|
||||
match: string;
|
||||
replacement: string | ReplaceFn;
|
||||
setReplacementError(error: any): void;
|
||||
}
|
||||
|
||||
function ReplacementComponent({ module, match, replacement, setReplacementError }: ReplacementComponentProps) {
|
||||
const [id, fact] = module;
|
||||
const [compileResult, setCompileResult] = React.useState<[boolean, string]>();
|
||||
|
||||
const [patchedCode, matchResult, diff] = React.useMemo(() => {
|
||||
const src: string = fact.toString().replaceAll("\n", "");
|
||||
|
||||
try {
|
||||
new RegExp(match);
|
||||
} catch (e) {
|
||||
return ["", [], []];
|
||||
}
|
||||
const canonicalMatch = canonicalizeMatch(new RegExp(match));
|
||||
try {
|
||||
const canonicalReplace = canonicalizeReplace(replacement, 'Vencord.Plugins.plugins["YourPlugin"]');
|
||||
var patched = src.replace(canonicalMatch, canonicalReplace as string);
|
||||
setReplacementError(void 0);
|
||||
} catch (e) {
|
||||
setReplacementError((e as Error).message);
|
||||
return ["", [], []];
|
||||
}
|
||||
const m = src.match(canonicalMatch);
|
||||
return [patched, m, makeDiff(src, patched, m)];
|
||||
}, [id, match, replacement]);
|
||||
|
||||
function makeDiff(original: string, patched: string, match: RegExpMatchArray | null) {
|
||||
if (!match || original === patched) return null;
|
||||
|
||||
const changeSize = patched.length - original.length;
|
||||
|
||||
// Use 200 surrounding characters of context
|
||||
const start = Math.max(0, match.index! - 200);
|
||||
const end = Math.min(original.length, match.index! + match[0].length + 200);
|
||||
// (changeSize may be negative)
|
||||
const endPatched = end + changeSize;
|
||||
|
||||
const context = original.slice(start, end);
|
||||
const patchedContext = patched.slice(start, endPatched);
|
||||
|
||||
return differ.diffWordsWithSpace(context, patchedContext);
|
||||
}
|
||||
|
||||
function renderMatch() {
|
||||
if (!matchResult)
|
||||
return <Forms.FormText>Regex doesn't match!</Forms.FormText>;
|
||||
|
||||
const fullMatch = matchResult[0] ? makeCodeblock(matchResult[0], "js") : "";
|
||||
const groups = matchResult.length > 1
|
||||
? makeCodeblock(matchResult.slice(1).map((g, i) => `Group ${i + 1}: ${g}`).join("\n"), "yml")
|
||||
: "";
|
||||
|
||||
return (
|
||||
<>
|
||||
<div style={{ userSelect: "text" }}>{Parser.parse(fullMatch)}</div>
|
||||
<div style={{ userSelect: "text" }}>{Parser.parse(groups)}</div>
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
function renderDiff() {
|
||||
return diff?.map((p, idx) => {
|
||||
const color = p.added ? "lime" : p.removed ? "red" : "grey";
|
||||
return <div key={idx} style={{ color, userSelect: "text", wordBreak: "break-all", lineBreak: "anywhere" }}>{p.value}</div>;
|
||||
});
|
||||
}
|
||||
|
||||
return (
|
||||
<>
|
||||
<Forms.FormTitle>Module {id}</Forms.FormTitle>
|
||||
|
||||
{!!matchResult?.[0]?.length && (
|
||||
<>
|
||||
<Forms.FormTitle>Match</Forms.FormTitle>
|
||||
{renderMatch()}
|
||||
</>)
|
||||
}
|
||||
|
||||
{!!diff?.length && (
|
||||
<>
|
||||
<Forms.FormTitle>Diff</Forms.FormTitle>
|
||||
{renderDiff()}
|
||||
</>
|
||||
)}
|
||||
|
||||
{!!diff?.length && (
|
||||
<Button className={Margins.top20} onClick={() => {
|
||||
try {
|
||||
Function(patchedCode.replace(/^function\(/, "function patchedModule("));
|
||||
setCompileResult([true, "Compiled successfully"]);
|
||||
} catch (err) {
|
||||
setCompileResult([false, (err as Error).message]);
|
||||
}
|
||||
}}>Compile</Button>
|
||||
)}
|
||||
|
||||
{compileResult &&
|
||||
<Forms.FormText style={{ color: compileResult[0] ? "var(--status-positive)" : "var(--text-danger)" }}>
|
||||
{compileResult[1]}
|
||||
</Forms.FormText>
|
||||
}
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
function ReplacementInput({ replacement, setReplacement, replacementError }) {
|
||||
const [isFunc, setIsFunc] = React.useState(false);
|
||||
const [error, setError] = React.useState<string>();
|
||||
|
||||
function onChange(v: string) {
|
||||
setError(void 0);
|
||||
|
||||
if (isFunc) {
|
||||
try {
|
||||
const func = (0, eval)(v);
|
||||
if (typeof func === "function")
|
||||
setReplacement(() => func);
|
||||
else
|
||||
setError("Replacement must be a function");
|
||||
} catch (e) {
|
||||
setReplacement(v);
|
||||
setError((e as Error).message);
|
||||
}
|
||||
} else {
|
||||
setReplacement(v);
|
||||
}
|
||||
}
|
||||
|
||||
React.useEffect(
|
||||
() => void (isFunc ? onChange(replacement) : setError(void 0)),
|
||||
[isFunc]
|
||||
);
|
||||
|
||||
return (
|
||||
<>
|
||||
{/* FormTitle adds a class if className is not set, so we set it to an empty string to prevent that */}
|
||||
<Forms.FormTitle className="">replacement</Forms.FormTitle>
|
||||
<TextInput
|
||||
value={replacement?.toString()}
|
||||
onChange={onChange}
|
||||
error={error ?? replacementError}
|
||||
/>
|
||||
{!isFunc && (
|
||||
<div>
|
||||
<Forms.FormTitle className={Margins.top8}>Cheat Sheet</Forms.FormTitle>
|
||||
{Object.entries({
|
||||
"\\i": "Special regex escape sequence that matches identifiers (varnames, classnames, etc.)",
|
||||
"$$": "Insert a $",
|
||||
"$&": "Insert the entire match",
|
||||
"$`\u200b": "Insert the substring before the match",
|
||||
"$'": "Insert the substring after the match",
|
||||
"$n": "Insert the nth capturing group ($1, $2...)",
|
||||
"$self": "Insert the plugin instance",
|
||||
}).map(([placeholder, desc]) => (
|
||||
<Forms.FormText key={placeholder}>
|
||||
{Parser.parse("`" + placeholder + "`")}: {desc}
|
||||
</Forms.FormText>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
|
||||
<Switch
|
||||
className={Margins.top8}
|
||||
value={isFunc}
|
||||
onChange={setIsFunc}
|
||||
note="'replacement' will be evaled if this is toggled"
|
||||
hideBorder={true}
|
||||
>
|
||||
Treat as Function
|
||||
</Switch>
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
interface FullPatchInputProps {
|
||||
setFind(v: string): void;
|
||||
setParsedFind(v: string | RegExp): void;
|
||||
setMatch(v: string): void;
|
||||
setReplacement(v: string | ReplaceFn): void;
|
||||
}
|
||||
|
||||
function FullPatchInput({ setFind, setParsedFind, setMatch, setReplacement }: FullPatchInputProps) {
|
||||
const [fullPatch, setFullPatch] = React.useState<string>("");
|
||||
const [fullPatchError, setFullPatchError] = React.useState<string>("");
|
||||
|
||||
function update() {
|
||||
if (fullPatch === "") {
|
||||
setFullPatchError("");
|
||||
|
||||
setFind("");
|
||||
setParsedFind("");
|
||||
setMatch("");
|
||||
setReplacement("");
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
const parsed = (0, eval)(`([${fullPatch}][0])`) as Patch;
|
||||
|
||||
if (!parsed.find) throw new Error("No 'find' field");
|
||||
if (!parsed.replacement) throw new Error("No 'replacement' field");
|
||||
|
||||
if (parsed.replacement instanceof Array) {
|
||||
if (parsed.replacement.length === 0) throw new Error("Invalid replacement");
|
||||
|
||||
parsed.replacement = {
|
||||
match: parsed.replacement[0].match,
|
||||
replace: parsed.replacement[0].replace
|
||||
};
|
||||
}
|
||||
|
||||
if (!parsed.replacement.match) throw new Error("No 'replacement.match' field");
|
||||
if (!parsed.replacement.replace) throw new Error("No 'replacement.replace' field");
|
||||
|
||||
setFind(parsed.find instanceof RegExp ? parsed.find.toString() : parsed.find);
|
||||
setParsedFind(parsed.find);
|
||||
setMatch(parsed.replacement.match instanceof RegExp ? parsed.replacement.match.source : parsed.replacement.match);
|
||||
setReplacement(parsed.replacement.replace);
|
||||
setFullPatchError("");
|
||||
} catch (e) {
|
||||
setFullPatchError((e as Error).message);
|
||||
}
|
||||
}
|
||||
|
||||
return <>
|
||||
<Forms.FormText className={Margins.bottom8}>Paste your full JSON patch here to fill out the fields</Forms.FormText>
|
||||
<TextArea value={fullPatch} onChange={setFullPatch} onBlur={update} />
|
||||
{fullPatchError !== "" && <Forms.FormText style={{ color: "var(--text-danger)" }}>{fullPatchError}</Forms.FormText>}
|
||||
</>;
|
||||
}
|
||||
|
||||
function PatchHelper() {
|
||||
const [find, setFind] = React.useState<string>("");
|
||||
const [parsedFind, setParsedFind] = React.useState<string | RegExp>("");
|
||||
const [match, setMatch] = React.useState<string>("");
|
||||
const [replacement, setReplacement] = React.useState<string | ReplaceFn>("");
|
||||
|
||||
const [replacementError, setReplacementError] = React.useState<string>();
|
||||
|
||||
const [module, setModule] = React.useState<[number, Function]>();
|
||||
const [findError, setFindError] = React.useState<string>();
|
||||
const [matchError, setMatchError] = React.useState<string>();
|
||||
|
||||
const code = React.useMemo(() => {
|
||||
return `
|
||||
{
|
||||
find: ${parsedFind instanceof RegExp ? parsedFind.toString() : JSON.stringify(parsedFind)},
|
||||
replacement: {
|
||||
match: /${match.replace(/(?<!\\)\//g, "\\/")}/,
|
||||
replace: ${typeof replacement === "function" ? replacement.toString() : JSON.stringify(replacement)}
|
||||
}
|
||||
}
|
||||
`.trim();
|
||||
}, [parsedFind, match, replacement]);
|
||||
|
||||
function onFindChange(v: string) {
|
||||
setFind(v);
|
||||
|
||||
try {
|
||||
let parsedFind = v as string | RegExp;
|
||||
if (/^\/.+?\/$/.test(v)) parsedFind = new RegExp(v.slice(1, -1));
|
||||
|
||||
setFindError(void 0);
|
||||
setParsedFind(parsedFind);
|
||||
|
||||
if (v.length) {
|
||||
findCandidates({ find: parsedFind, setModule, setError: setFindError });
|
||||
}
|
||||
} catch (e: any) {
|
||||
setFindError((e as Error).message);
|
||||
}
|
||||
}
|
||||
|
||||
function onMatchChange(v: string) {
|
||||
setMatch(v);
|
||||
|
||||
try {
|
||||
new RegExp(v);
|
||||
setMatchError(void 0);
|
||||
} catch (e: any) {
|
||||
setMatchError((e as Error).message);
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<SettingsTab title="Patch Helper">
|
||||
<Forms.FormTitle>full patch</Forms.FormTitle>
|
||||
<FullPatchInput
|
||||
setFind={onFindChange}
|
||||
setParsedFind={setParsedFind}
|
||||
setMatch={onMatchChange}
|
||||
setReplacement={setReplacement}
|
||||
/>
|
||||
|
||||
<Forms.FormTitle className={Margins.top8}>find</Forms.FormTitle>
|
||||
<TextInput
|
||||
type="text"
|
||||
value={find}
|
||||
onChange={onFindChange}
|
||||
error={findError}
|
||||
/>
|
||||
|
||||
<Forms.FormTitle className={Margins.top8}>match</Forms.FormTitle>
|
||||
<TextInput
|
||||
type="text"
|
||||
value={match}
|
||||
onChange={onMatchChange}
|
||||
error={matchError}
|
||||
/>
|
||||
|
||||
<div className={Margins.top8} />
|
||||
<ReplacementInput
|
||||
replacement={replacement}
|
||||
setReplacement={setReplacement}
|
||||
replacementError={replacementError}
|
||||
/>
|
||||
|
||||
<Forms.FormDivider />
|
||||
{module && (
|
||||
<ReplacementComponent
|
||||
module={module}
|
||||
match={match}
|
||||
replacement={replacement}
|
||||
setReplacementError={setReplacementError}
|
||||
/>
|
||||
)}
|
||||
|
||||
{!!(find && match && replacement) && (
|
||||
<>
|
||||
<Forms.FormTitle className={Margins.top20}>Code</Forms.FormTitle>
|
||||
<CodeBlock lang="js" content={code} />
|
||||
<Button onClick={() => copyToClipboard(code)}>Copy to Clipboard</Button>
|
||||
<Button className={Margins.top8} onClick={() => copyToClipboard("```ts\n" + code + "\n```")}>Copy as Codeblock</Button>
|
||||
</>
|
||||
)}
|
||||
</SettingsTab>
|
||||
);
|
||||
}
|
||||
|
||||
export default IS_DEV ? wrapTab(PatchHelper, "PatchHelper") : null;
|
||||
|
|
@ -1,23 +0,0 @@
|
|||
/*
|
||||
* Vencord, a modification for Discord's desktop app
|
||||
* Copyright (c) 2022 Vendicated and contributors
|
||||
*
|
||||
* This program is free software: you can redistribute it and/or modify
|
||||
* it under the terms of the GNU General Public License as published by
|
||||
* the Free Software Foundation, either version 3 of the License, or
|
||||
* (at your option) any later version.
|
||||
*
|
||||
* This program is distributed in the hope that it will be useful,
|
||||
* but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
||||
* GNU General Public License for more details.
|
||||
*
|
||||
* You should have received a copy of the GNU General Public License
|
||||
* along with this program. If not, see <https://www.gnu.org/licenses/>.
|
||||
*/
|
||||
|
||||
import PluginSettings from "@components/PluginSettings";
|
||||
|
||||
import { wrapTab } from "./shared";
|
||||
|
||||
export default wrapTab(PluginSettings, "Plugins");
|
||||
|
|
@ -1,402 +0,0 @@
|
|||
/*
|
||||
* Vencord, a modification for Discord's desktop app
|
||||
* Copyright (c) 2022 Vendicated and contributors
|
||||
*
|
||||
* This program is free software: you can redistribute it and/or modify
|
||||
* it under the terms of the GNU General Public License as published by
|
||||
* the Free Software Foundation, either version 3 of the License, or
|
||||
* (at your option) any later version.
|
||||
*
|
||||
* This program is distributed in the hope that it will be useful,
|
||||
* but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
||||
* GNU General Public License for more details.
|
||||
*
|
||||
* You should have received a copy of the GNU General Public License
|
||||
* along with this program. If not, see <https://www.gnu.org/licenses/>.
|
||||
*/
|
||||
|
||||
import { Settings, useSettings } from "@api/Settings";
|
||||
import { classNameFactory } from "@api/Styles";
|
||||
import { ErrorCard } from "@components/ErrorCard";
|
||||
import { Flex } from "@components/Flex";
|
||||
import { DeleteIcon, FolderIcon, PaintbrushIcon, PencilIcon, PlusIcon, RestartIcon } from "@components/Icons";
|
||||
import { Link } from "@components/Link";
|
||||
import { openPluginModal } from "@components/PluginSettings/PluginModal";
|
||||
import type { UserThemeHeader } from "@main/themes";
|
||||
import { CspBlockedUrls, useCspErrors } from "@utils/cspViolations";
|
||||
import { openInviteModal } from "@utils/discord";
|
||||
import { Margins } from "@utils/margins";
|
||||
import { classes } from "@utils/misc";
|
||||
import { relaunch } from "@utils/native";
|
||||
import { useForceUpdater } from "@utils/react";
|
||||
import { getStylusWebStoreUrl } from "@utils/web";
|
||||
import { findLazy } from "@webpack";
|
||||
import { Alerts, Button, Card, Forms, React, showToast, TabBar, TextArea, useEffect, useRef, useState } from "@webpack/common";
|
||||
import type { ComponentType, Ref, SyntheticEvent } from "react";
|
||||
|
||||
import Plugins from "~plugins";
|
||||
|
||||
import { AddonCard } from "./AddonCard";
|
||||
import { QuickAction, QuickActionCard } from "./quickActions";
|
||||
import { SettingsTab, wrapTab } from "./shared";
|
||||
|
||||
type FileInput = ComponentType<{
|
||||
ref: Ref<HTMLInputElement>;
|
||||
onChange: (e: SyntheticEvent<HTMLInputElement>) => void;
|
||||
multiple?: boolean;
|
||||
filters?: { name?: string; extensions: string[]; }[];
|
||||
}>;
|
||||
|
||||
const FileInput: FileInput = findLazy(m => m.prototype?.activateUploadDialogue && m.prototype.setRef);
|
||||
|
||||
const cl = classNameFactory("vc-settings-theme-");
|
||||
|
||||
interface ThemeCardProps {
|
||||
theme: UserThemeHeader;
|
||||
enabled: boolean;
|
||||
onChange: (enabled: boolean) => void;
|
||||
onDelete: () => void;
|
||||
}
|
||||
|
||||
function ThemeCard({ theme, enabled, onChange, onDelete }: ThemeCardProps) {
|
||||
return (
|
||||
<AddonCard
|
||||
name={theme.name}
|
||||
description={theme.description}
|
||||
author={theme.author}
|
||||
enabled={enabled}
|
||||
setEnabled={onChange}
|
||||
infoButton={
|
||||
IS_WEB && (
|
||||
<div style={{ cursor: "pointer", color: "var(--status-danger" }} onClick={onDelete}>
|
||||
<DeleteIcon />
|
||||
</div>
|
||||
)
|
||||
}
|
||||
footer={
|
||||
<Flex flexDirection="row" style={{ gap: "0.2em" }}>
|
||||
{!!theme.website && <Link href={theme.website}>Website</Link>}
|
||||
{!!(theme.website && theme.invite) && " • "}
|
||||
{!!theme.invite && (
|
||||
<Link
|
||||
href={`https://discord.gg/${theme.invite}`}
|
||||
onClick={async e => {
|
||||
e.preventDefault();
|
||||
theme.invite != null && openInviteModal(theme.invite).catch(() => showToast("Invalid or expired invite"));
|
||||
}}
|
||||
>
|
||||
Discord Server
|
||||
</Link>
|
||||
)}
|
||||
</Flex>
|
||||
}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
enum ThemeTab {
|
||||
LOCAL,
|
||||
ONLINE
|
||||
}
|
||||
|
||||
function ThemesTab() {
|
||||
const settings = useSettings(["themeLinks", "enabledThemes"]);
|
||||
|
||||
const fileInputRef = useRef<HTMLInputElement>(null);
|
||||
const [currentTab, setCurrentTab] = useState(ThemeTab.LOCAL);
|
||||
const [themeText, setThemeText] = useState(settings.themeLinks.join("\n"));
|
||||
const [userThemes, setUserThemes] = useState<UserThemeHeader[] | null>(null);
|
||||
|
||||
useEffect(() => {
|
||||
refreshLocalThemes();
|
||||
}, []);
|
||||
|
||||
async function refreshLocalThemes() {
|
||||
const themes = await VencordNative.themes.getThemesList();
|
||||
setUserThemes(themes);
|
||||
}
|
||||
|
||||
// When a local theme is enabled/disabled, update the settings
|
||||
function onLocalThemeChange(fileName: string, value: boolean) {
|
||||
if (value) {
|
||||
if (settings.enabledThemes.includes(fileName)) return;
|
||||
settings.enabledThemes = [...settings.enabledThemes, fileName];
|
||||
} else {
|
||||
settings.enabledThemes = settings.enabledThemes.filter(f => f !== fileName);
|
||||
}
|
||||
}
|
||||
|
||||
async function onFileUpload(e: SyntheticEvent<HTMLInputElement>) {
|
||||
e.stopPropagation();
|
||||
e.preventDefault();
|
||||
if (!e.currentTarget?.files?.length) return;
|
||||
const { files } = e.currentTarget;
|
||||
|
||||
const uploads = Array.from(files, file => {
|
||||
const { name } = file;
|
||||
if (!name.endsWith(".css")) return;
|
||||
|
||||
return new Promise<void>((resolve, reject) => {
|
||||
const reader = new FileReader();
|
||||
reader.onload = () => {
|
||||
VencordNative.themes.uploadTheme(name, reader.result as string)
|
||||
.then(resolve)
|
||||
.catch(reject);
|
||||
};
|
||||
reader.readAsText(file);
|
||||
});
|
||||
});
|
||||
|
||||
await Promise.all(uploads);
|
||||
refreshLocalThemes();
|
||||
}
|
||||
|
||||
function renderLocalThemes() {
|
||||
return (
|
||||
<>
|
||||
<Card className="vc-settings-card">
|
||||
<Forms.FormTitle tag="h5">Find Themes:</Forms.FormTitle>
|
||||
<div style={{ marginBottom: ".5em", display: "flex", flexDirection: "column" }}>
|
||||
<Link style={{ marginRight: ".5em" }} href="https://betterdiscord.app/themes">
|
||||
BetterDiscord Themes
|
||||
</Link>
|
||||
<Link href="https://github.com/search?q=discord+theme">GitHub</Link>
|
||||
</div>
|
||||
<Forms.FormText>If using the BD site, click on "Download" and place the downloaded .theme.css file into your themes folder.</Forms.FormText>
|
||||
</Card>
|
||||
|
||||
<Card className="vc-settings-card">
|
||||
<Forms.FormTitle tag="h5">External Resources</Forms.FormTitle>
|
||||
<Forms.FormText>For security reasons, loading resources (styles, fonts, images, ...) from most sites is blocked.</Forms.FormText>
|
||||
<Forms.FormText>Make sure all your assets are hosted on GitHub, GitLab, Codeberg, Imgur, Discord or Google Fonts.</Forms.FormText>
|
||||
</Card>
|
||||
|
||||
<Forms.FormSection title="Local Themes">
|
||||
<QuickActionCard>
|
||||
<>
|
||||
{IS_WEB ?
|
||||
(
|
||||
<QuickAction
|
||||
text={
|
||||
<span style={{ position: "relative" }}>
|
||||
Upload Theme
|
||||
<FileInput
|
||||
ref={fileInputRef}
|
||||
onChange={onFileUpload}
|
||||
multiple={true}
|
||||
filters={[{ extensions: ["css"] }]}
|
||||
/>
|
||||
</span>
|
||||
}
|
||||
Icon={PlusIcon}
|
||||
/>
|
||||
) : (
|
||||
<QuickAction
|
||||
text="Open Themes Folder"
|
||||
action={() => VencordNative.themes.openFolder()}
|
||||
Icon={FolderIcon}
|
||||
/>
|
||||
)}
|
||||
<QuickAction
|
||||
text="Load missing Themes"
|
||||
action={refreshLocalThemes}
|
||||
Icon={RestartIcon}
|
||||
/>
|
||||
<QuickAction
|
||||
text="Edit QuickCSS"
|
||||
action={() => VencordNative.quickCss.openEditor()}
|
||||
Icon={PaintbrushIcon}
|
||||
/>
|
||||
|
||||
{Settings.plugins.ClientTheme.enabled && (
|
||||
<QuickAction
|
||||
text="Edit ClientTheme"
|
||||
action={() => openPluginModal(Plugins.ClientTheme)}
|
||||
Icon={PencilIcon}
|
||||
/>
|
||||
)}
|
||||
</>
|
||||
</QuickActionCard>
|
||||
|
||||
<div className={cl("grid")}>
|
||||
{userThemes?.map(theme => (
|
||||
<ThemeCard
|
||||
key={theme.fileName}
|
||||
enabled={settings.enabledThemes.includes(theme.fileName)}
|
||||
onChange={enabled => onLocalThemeChange(theme.fileName, enabled)}
|
||||
onDelete={async () => {
|
||||
onLocalThemeChange(theme.fileName, false);
|
||||
await VencordNative.themes.deleteTheme(theme.fileName);
|
||||
refreshLocalThemes();
|
||||
}}
|
||||
theme={theme}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
</Forms.FormSection>
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
// When the user leaves the online theme textbox, update the settings
|
||||
function onBlur() {
|
||||
settings.themeLinks = [...new Set(
|
||||
themeText
|
||||
.trim()
|
||||
.split(/\n+/)
|
||||
.map(s => s.trim())
|
||||
.filter(Boolean)
|
||||
)];
|
||||
}
|
||||
|
||||
function renderOnlineThemes() {
|
||||
return (
|
||||
<>
|
||||
<Card className={classes("vc-warning-card", Margins.bottom16)}>
|
||||
<Forms.FormText>
|
||||
This section is for advanced users. If you are having difficulties using it, use the
|
||||
Local Themes tab instead.
|
||||
</Forms.FormText>
|
||||
</Card>
|
||||
<Card className="vc-settings-card">
|
||||
<Forms.FormTitle tag="h5">Paste links to css files here</Forms.FormTitle>
|
||||
<Forms.FormText>One link per line</Forms.FormText>
|
||||
<Forms.FormText>You can prefix lines with @light or @dark to toggle them based on your Discord theme</Forms.FormText>
|
||||
<Forms.FormText>Make sure to use direct links to files (raw or github.io)!</Forms.FormText>
|
||||
</Card>
|
||||
|
||||
<Forms.FormSection title="Online Themes" tag="h5">
|
||||
<TextArea
|
||||
value={themeText}
|
||||
onChange={setThemeText}
|
||||
className={"vc-settings-theme-links"}
|
||||
placeholder="Enter Theme Links..."
|
||||
spellCheck={false}
|
||||
onBlur={onBlur}
|
||||
rows={10}
|
||||
/>
|
||||
</Forms.FormSection>
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<SettingsTab title="Themes">
|
||||
<TabBar
|
||||
type="top"
|
||||
look="brand"
|
||||
className="vc-settings-tab-bar"
|
||||
selectedItem={currentTab}
|
||||
onItemSelect={setCurrentTab}
|
||||
>
|
||||
<TabBar.Item
|
||||
className="vc-settings-tab-bar-item"
|
||||
id={ThemeTab.LOCAL}
|
||||
>
|
||||
Local Themes
|
||||
</TabBar.Item>
|
||||
<TabBar.Item
|
||||
className="vc-settings-tab-bar-item"
|
||||
id={ThemeTab.ONLINE}
|
||||
>
|
||||
Online Themes
|
||||
</TabBar.Item>
|
||||
</TabBar>
|
||||
|
||||
<CspErrorCard />
|
||||
{currentTab === ThemeTab.LOCAL && renderLocalThemes()}
|
||||
{currentTab === ThemeTab.ONLINE && renderOnlineThemes()}
|
||||
</SettingsTab>
|
||||
);
|
||||
}
|
||||
|
||||
export function CspErrorCard() {
|
||||
if (IS_WEB) return null;
|
||||
|
||||
const errors = useCspErrors();
|
||||
const forceUpdate = useForceUpdater();
|
||||
|
||||
if (!errors.length) return null;
|
||||
|
||||
const isImgurHtmlDomain = (url: string) => url.startsWith("https://imgur.com/");
|
||||
|
||||
const allowUrl = async (url: string) => {
|
||||
const { origin: baseUrl, host } = new URL(url);
|
||||
|
||||
const result = await VencordNative.csp.requestAddOverride(baseUrl, ["connect-src", "img-src", "style-src", "font-src"], "Vencord Themes");
|
||||
if (result !== "ok") return;
|
||||
|
||||
CspBlockedUrls.forEach(url => {
|
||||
if (new URL(url).host === host) {
|
||||
CspBlockedUrls.delete(url);
|
||||
}
|
||||
});
|
||||
|
||||
forceUpdate();
|
||||
|
||||
Alerts.show({
|
||||
title: "Restart Required",
|
||||
body: "A restart is required to apply this change",
|
||||
confirmText: "Restart now",
|
||||
cancelText: "Later!",
|
||||
onConfirm: relaunch
|
||||
});
|
||||
};
|
||||
|
||||
const hasImgurHtmlDomain = errors.some(isImgurHtmlDomain);
|
||||
|
||||
return (
|
||||
<ErrorCard className="vc-settings-card">
|
||||
<Forms.FormTitle tag="h5">Blocked Resources</Forms.FormTitle>
|
||||
<Forms.FormText>Some images, styles, or fonts were blocked because they come from disallowed domains.</Forms.FormText>
|
||||
<Forms.FormText>It is highly recommended to move them to GitHub or Imgur. But you may also allow domains if you fully trust them.</Forms.FormText>
|
||||
<Forms.FormText>
|
||||
After allowing a domain, you have to fully close (from tray / task manager) and restart {IS_DISCORD_DESKTOP ? "Discord" : "Vesktop"} to apply the change.
|
||||
</Forms.FormText>
|
||||
|
||||
<Forms.FormTitle tag="h5" className={classes(Margins.top16, Margins.bottom8)}>Blocked URLs</Forms.FormTitle>
|
||||
<div className="vc-settings-csp-list">
|
||||
{errors.map((url, i) => (
|
||||
<div key={url}>
|
||||
{i !== 0 && <Forms.FormDivider className={Margins.bottom8} />}
|
||||
<div className="vc-settings-csp-row">
|
||||
<Link href={url}>{url}</Link>
|
||||
<Button color={Button.Colors.PRIMARY} onClick={() => allowUrl(url)} disabled={isImgurHtmlDomain(url)}>
|
||||
Allow
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
|
||||
{hasImgurHtmlDomain && (
|
||||
<>
|
||||
<Forms.FormDivider className={classes(Margins.top8, Margins.bottom16)} />
|
||||
<Forms.FormText>
|
||||
Imgur links should be direct links in the form of <code>https://i.imgur.com/...</code>
|
||||
</Forms.FormText>
|
||||
<Forms.FormText>To obtain a direct link, right-click the image and select "Copy image address".</Forms.FormText>
|
||||
</>
|
||||
)}
|
||||
</ErrorCard>
|
||||
);
|
||||
}
|
||||
|
||||
function UserscriptThemesTab() {
|
||||
return (
|
||||
<SettingsTab title="Themes">
|
||||
<Card className="vc-settings-card">
|
||||
<Forms.FormTitle tag="h5">Themes are not supported on the Userscript!</Forms.FormTitle>
|
||||
|
||||
<Forms.FormText>
|
||||
You can instead install themes with the <Link href={getStylusWebStoreUrl()}>Stylus extension</Link>!
|
||||
</Forms.FormText>
|
||||
</Card>
|
||||
</SettingsTab>
|
||||
);
|
||||
}
|
||||
|
||||
export default IS_USERSCRIPT
|
||||
? wrapTab(UserscriptThemesTab, "Themes")
|
||||
: wrapTab(ThemesTab, "Themes");
|
||||
|
|
@ -1,268 +0,0 @@
|
|||
/*
|
||||
* Vencord, a modification for Discord's desktop app
|
||||
* Copyright (c) 2022 Vendicated and contributors
|
||||
*
|
||||
* This program is free software: you can redistribute it and/or modify
|
||||
* it under the terms of the GNU General Public License as published by
|
||||
* the Free Software Foundation, either version 3 of the License, or
|
||||
* (at your option) any later version.
|
||||
*
|
||||
* This program is distributed in the hope that it will be useful,
|
||||
* but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
||||
* GNU General Public License for more details.
|
||||
*
|
||||
* You should have received a copy of the GNU General Public License
|
||||
* along with this program. If not, see <https://www.gnu.org/licenses/>.
|
||||
*/
|
||||
|
||||
import { useSettings } from "@api/Settings";
|
||||
import { ErrorCard } from "@components/ErrorCard";
|
||||
import { Flex } from "@components/Flex";
|
||||
import { Link } from "@components/Link";
|
||||
import { Margins } from "@utils/margins";
|
||||
import { classes } from "@utils/misc";
|
||||
import { ModalCloseButton, ModalContent, ModalProps, ModalRoot, ModalSize, openModal } from "@utils/modal";
|
||||
import { relaunch } from "@utils/native";
|
||||
import { useAwaiter } from "@utils/react";
|
||||
import { changes, checkForUpdates, getRepo, isNewer, update, updateError, UpdateLogger } from "@utils/updater";
|
||||
import { Alerts, Button, Card, Forms, Parser, React, Switch, Toasts } from "@webpack/common";
|
||||
|
||||
import gitHash from "~git-hash";
|
||||
|
||||
import { handleSettingsTabError, SettingsTab, wrapTab } from "./shared";
|
||||
|
||||
function withDispatcher(dispatcher: React.Dispatch<React.SetStateAction<boolean>>, action: () => any) {
|
||||
return async () => {
|
||||
dispatcher(true);
|
||||
try {
|
||||
await action();
|
||||
} catch (e: any) {
|
||||
UpdateLogger.error("Failed to update", e);
|
||||
|
||||
let err: string;
|
||||
if (!e) {
|
||||
err = "An unknown error occurred (error is undefined).\nPlease try again.";
|
||||
} else if (e.code && e.cmd) {
|
||||
const { code, path, cmd, stderr } = e;
|
||||
|
||||
if (code === "ENOENT")
|
||||
err = `Command \`${path}\` not found.\nPlease install it and try again`;
|
||||
else {
|
||||
err = `An error occurred while running \`${cmd}\`:\n`;
|
||||
err += stderr || `Code \`${code}\`. See the console for more info`;
|
||||
}
|
||||
|
||||
} else {
|
||||
err = "An unknown error occurred. See the console for more info.";
|
||||
}
|
||||
|
||||
Alerts.show({
|
||||
title: "Oops!",
|
||||
body: (
|
||||
<ErrorCard>
|
||||
{err.split("\n").map((line, idx) => <div key={idx}>{Parser.parse(line)}</div>)}
|
||||
</ErrorCard>
|
||||
)
|
||||
});
|
||||
}
|
||||
finally {
|
||||
dispatcher(false);
|
||||
}
|
||||
};
|
||||
}
|
||||
|
||||
interface CommonProps {
|
||||
repo: string;
|
||||
repoPending: boolean;
|
||||
}
|
||||
|
||||
function HashLink({ repo, hash, disabled = false }: { repo: string, hash: string, disabled?: boolean; }) {
|
||||
return <Link href={`${repo}/commit/${hash}`} disabled={disabled}>
|
||||
{hash}
|
||||
</Link>;
|
||||
}
|
||||
|
||||
function Changes({ updates, repo, repoPending }: CommonProps & { updates: typeof changes; }) {
|
||||
return (
|
||||
<Card style={{ padding: "0 0.5em" }}>
|
||||
{updates.map(({ hash, author, message }) => (
|
||||
<div key={hash} style={{
|
||||
marginTop: "0.5em",
|
||||
marginBottom: "0.5em"
|
||||
}}>
|
||||
<code><HashLink {...{ repo, hash }} disabled={repoPending} /></code>
|
||||
<span style={{
|
||||
marginLeft: "0.5em",
|
||||
color: "var(--text-default)"
|
||||
}}>{message} - {author}</span>
|
||||
</div>
|
||||
))}
|
||||
</Card>
|
||||
);
|
||||
}
|
||||
|
||||
function Updatable(props: CommonProps) {
|
||||
const [updates, setUpdates] = React.useState(changes);
|
||||
const [isChecking, setIsChecking] = React.useState(false);
|
||||
const [isUpdating, setIsUpdating] = React.useState(false);
|
||||
|
||||
const isOutdated = (updates?.length ?? 0) > 0;
|
||||
|
||||
return (
|
||||
<>
|
||||
{!updates && updateError ? (
|
||||
<>
|
||||
<Forms.FormText>Failed to check updates. Check the console for more info</Forms.FormText>
|
||||
<ErrorCard style={{ padding: "1em" }}>
|
||||
<p>{updateError.stderr || updateError.stdout || "An unknown error occurred"}</p>
|
||||
</ErrorCard>
|
||||
</>
|
||||
) : (
|
||||
<Forms.FormText className={Margins.bottom8}>
|
||||
{isOutdated ? (updates.length === 1 ? "There is 1 Update" : `There are ${updates.length} Updates`) : "Up to Date!"}
|
||||
</Forms.FormText>
|
||||
)}
|
||||
|
||||
{isOutdated && <Changes updates={updates} {...props} />}
|
||||
|
||||
<Flex className={classes(Margins.bottom8, Margins.top8)}>
|
||||
{isOutdated && <Button
|
||||
size={Button.Sizes.SMALL}
|
||||
disabled={isUpdating || isChecking}
|
||||
onClick={withDispatcher(setIsUpdating, async () => {
|
||||
if (await update()) {
|
||||
setUpdates([]);
|
||||
await new Promise<void>(r => {
|
||||
Alerts.show({
|
||||
title: "Update Success!",
|
||||
body: "Successfully updated. Restart now to apply the changes?",
|
||||
confirmText: "Restart",
|
||||
cancelText: "Not now!",
|
||||
onConfirm() {
|
||||
relaunch();
|
||||
r();
|
||||
},
|
||||
onCancel: r
|
||||
});
|
||||
});
|
||||
}
|
||||
})}
|
||||
>
|
||||
Update Now
|
||||
</Button>}
|
||||
<Button
|
||||
size={Button.Sizes.SMALL}
|
||||
disabled={isUpdating || isChecking}
|
||||
onClick={withDispatcher(setIsChecking, async () => {
|
||||
const outdated = await checkForUpdates();
|
||||
if (outdated) {
|
||||
setUpdates(changes);
|
||||
} else {
|
||||
setUpdates([]);
|
||||
Toasts.show({
|
||||
message: "No updates found!",
|
||||
id: Toasts.genId(),
|
||||
type: Toasts.Type.MESSAGE,
|
||||
options: {
|
||||
position: Toasts.Position.BOTTOM
|
||||
}
|
||||
});
|
||||
}
|
||||
})}
|
||||
>
|
||||
Check for Updates
|
||||
</Button>
|
||||
</Flex>
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
function Newer(props: CommonProps) {
|
||||
return (
|
||||
<>
|
||||
<Forms.FormText className={Margins.bottom8}>
|
||||
Your local copy has more recent commits. Please stash or reset them.
|
||||
</Forms.FormText>
|
||||
<Changes {...props} updates={changes} />
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
function Updater() {
|
||||
const settings = useSettings(["autoUpdate", "autoUpdateNotification"]);
|
||||
|
||||
const [repo, err, repoPending] = useAwaiter(getRepo, { fallbackValue: "Loading..." });
|
||||
|
||||
React.useEffect(() => {
|
||||
if (err)
|
||||
UpdateLogger.error("Failed to retrieve repo", err);
|
||||
}, [err]);
|
||||
|
||||
const commonProps: CommonProps = {
|
||||
repo,
|
||||
repoPending
|
||||
};
|
||||
|
||||
return (
|
||||
<SettingsTab title="Vencord Updater">
|
||||
<Forms.FormTitle tag="h5">Updater Settings</Forms.FormTitle>
|
||||
<Switch
|
||||
value={settings.autoUpdate}
|
||||
onChange={(v: boolean) => settings.autoUpdate = v}
|
||||
note="Automatically update Vencord without confirmation prompt"
|
||||
>
|
||||
Automatically update
|
||||
</Switch>
|
||||
<Switch
|
||||
value={settings.autoUpdateNotification}
|
||||
onChange={(v: boolean) => settings.autoUpdateNotification = v}
|
||||
note="Shows a notification when Vencord automatically updates"
|
||||
disabled={!settings.autoUpdate}
|
||||
>
|
||||
Get notified when an automatic update completes
|
||||
</Switch>
|
||||
|
||||
<Forms.FormTitle tag="h5">Repo</Forms.FormTitle>
|
||||
|
||||
<Forms.FormText>
|
||||
{repoPending
|
||||
? repo
|
||||
: err
|
||||
? "Failed to retrieve - check console"
|
||||
: (
|
||||
<Link href={repo}>
|
||||
{repo.split("/").slice(-2).join("/")}
|
||||
</Link>
|
||||
)
|
||||
}
|
||||
{" "}(<HashLink hash={gitHash} repo={repo} disabled={repoPending} />)
|
||||
</Forms.FormText>
|
||||
|
||||
<Forms.FormDivider className={Margins.top8 + " " + Margins.bottom8} />
|
||||
|
||||
<Forms.FormTitle tag="h5">Updates</Forms.FormTitle>
|
||||
|
||||
{isNewer ? <Newer {...commonProps} /> : <Updatable {...commonProps} />}
|
||||
</SettingsTab>
|
||||
);
|
||||
}
|
||||
|
||||
export default IS_UPDATER_DISABLED ? null : wrapTab(Updater, "Updater");
|
||||
|
||||
export const openUpdaterModal = IS_UPDATER_DISABLED ? null : function () {
|
||||
const UpdaterTab = wrapTab(Updater, "Updater");
|
||||
|
||||
try {
|
||||
openModal(wrapTab((modalProps: ModalProps) => (
|
||||
<ModalRoot {...modalProps} size={ModalSize.MEDIUM}>
|
||||
<ModalContent className="vc-updater-modal">
|
||||
<ModalCloseButton onClick={modalProps.onClose} className="vc-updater-modal-close-button" />
|
||||
<UpdaterTab />
|
||||
</ModalContent>
|
||||
</ModalRoot>
|
||||
), "UpdaterModal"));
|
||||
} catch {
|
||||
handleSettingsTabError();
|
||||
}
|
||||
};
|
||||
|
|
@ -1,301 +0,0 @@
|
|||
/*
|
||||
* Vencord, a modification for Discord's desktop app
|
||||
* Copyright (c) 2022 Vendicated and contributors
|
||||
*
|
||||
* This program is free software: you can redistribute it and/or modify
|
||||
* it under the terms of the GNU General Public License as published by
|
||||
* the Free Software Foundation, either version 3 of the License, or
|
||||
* (at your option) any later version.
|
||||
*
|
||||
* This program is distributed in the hope that it will be useful,
|
||||
* but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
||||
* GNU General Public License for more details.
|
||||
*
|
||||
* You should have received a copy of the GNU General Public License
|
||||
* along with this program. If not, see <https://www.gnu.org/licenses/>.
|
||||
*/
|
||||
|
||||
import { openNotificationLogModal } from "@api/Notifications/notificationLog";
|
||||
import { useSettings } from "@api/Settings";
|
||||
import { classNameFactory } from "@api/Styles";
|
||||
import DonateButton from "@components/DonateButton";
|
||||
import { openContributorModal } from "@components/PluginSettings/ContributorModal";
|
||||
import { openPluginModal } from "@components/PluginSettings/PluginModal";
|
||||
import { gitRemote } from "@shared/vencordUserAgent";
|
||||
import { DONOR_ROLE_ID, VENCORD_GUILD_ID } from "@utils/constants";
|
||||
import { Margins } from "@utils/margins";
|
||||
import { identity, isPluginDev } from "@utils/misc";
|
||||
import { relaunch } from "@utils/native";
|
||||
import { Button, Forms, GuildMemberStore, React, Select, Switch, UserStore } from "@webpack/common";
|
||||
|
||||
import BadgeAPI from "../../plugins/_api/badges";
|
||||
import { Flex, FolderIcon, GithubIcon, LogIcon, PaintbrushIcon, RestartIcon } from "..";
|
||||
import { openNotificationSettingsModal } from "./NotificationSettings";
|
||||
import { QuickAction, QuickActionCard } from "./quickActions";
|
||||
import { SettingsTab, wrapTab } from "./shared";
|
||||
import { SpecialCard } from "./SpecialCard";
|
||||
|
||||
const cl = classNameFactory("vc-settings-");
|
||||
|
||||
const DEFAULT_DONATE_IMAGE = "https://cdn.discordapp.com/emojis/1026533090627174460.png";
|
||||
const SHIGGY_DONATE_IMAGE = "https://media.discordapp.net/stickers/1039992459209490513.png";
|
||||
|
||||
const VENNIE_DONATOR_IMAGE = "https://cdn.discordapp.com/emojis/1238120638020063377.png";
|
||||
const COZY_CONTRIB_IMAGE = "https://cdn.discordapp.com/emojis/1026533070955872337.png";
|
||||
|
||||
const DONOR_BACKGROUND_IMAGE = "https://media.discordapp.net/stickers/1311070116305436712.png?size=2048";
|
||||
const CONTRIB_BACKGROUND_IMAGE = "https://media.discordapp.net/stickers/1311070166481895484.png?size=2048";
|
||||
|
||||
type KeysOfType<Object, Type> = {
|
||||
[K in keyof Object]: Object[K] extends Type ? K : never;
|
||||
}[keyof Object];
|
||||
|
||||
function VencordSettings() {
|
||||
const settings = useSettings();
|
||||
|
||||
const donateImage = React.useMemo(() => Math.random() > 0.5 ? DEFAULT_DONATE_IMAGE : SHIGGY_DONATE_IMAGE, []);
|
||||
|
||||
const isWindows = navigator.platform.toLowerCase().startsWith("win");
|
||||
const isMac = navigator.platform.toLowerCase().startsWith("mac");
|
||||
const needsVibrancySettings = IS_DISCORD_DESKTOP && isMac;
|
||||
|
||||
const user = UserStore.getCurrentUser();
|
||||
|
||||
const Switches: Array<false | {
|
||||
key: KeysOfType<typeof settings, boolean>;
|
||||
title: string;
|
||||
note: string;
|
||||
}> =
|
||||
[
|
||||
{
|
||||
key: "useQuickCss",
|
||||
title: "Enable Custom CSS",
|
||||
note: "Loads your Custom CSS"
|
||||
},
|
||||
!IS_WEB && {
|
||||
key: "enableReactDevtools",
|
||||
title: "Enable React Developer Tools",
|
||||
note: "Requires a full restart"
|
||||
},
|
||||
!IS_WEB && (!IS_DISCORD_DESKTOP || !isWindows ? {
|
||||
key: "frameless",
|
||||
title: "Disable the window frame",
|
||||
note: "Requires a full restart"
|
||||
} : {
|
||||
key: "winNativeTitleBar",
|
||||
title: "Use Windows' native title bar instead of Discord's custom one",
|
||||
note: "Requires a full restart"
|
||||
}),
|
||||
!IS_WEB && {
|
||||
key: "transparent",
|
||||
title: "Enable window transparency.",
|
||||
note: "You need a theme that supports transparency or this will do nothing. WILL STOP THE WINDOW FROM BEING RESIZABLE!! Requires a full restart"
|
||||
},
|
||||
!IS_WEB && isWindows && {
|
||||
key: "winCtrlQ",
|
||||
title: "Register Ctrl+Q as shortcut to close Discord (Alternative to Alt+F4)",
|
||||
note: "Requires a full restart"
|
||||
},
|
||||
IS_DISCORD_DESKTOP && {
|
||||
key: "disableMinSize",
|
||||
title: "Disable minimum window size",
|
||||
note: "Requires a full restart"
|
||||
},
|
||||
];
|
||||
|
||||
return (
|
||||
<SettingsTab title="Vencord Settings">
|
||||
{isDonor(user?.id)
|
||||
? (
|
||||
<SpecialCard
|
||||
title="Donations"
|
||||
subtitle="Thank you for donating!"
|
||||
description="You can manage your perks at any time by messaging @vending.machine."
|
||||
cardImage={VENNIE_DONATOR_IMAGE}
|
||||
backgroundImage={DONOR_BACKGROUND_IMAGE}
|
||||
backgroundColor="#ED87A9"
|
||||
>
|
||||
<DonateButtonComponent />
|
||||
</SpecialCard>
|
||||
)
|
||||
: (
|
||||
<SpecialCard
|
||||
title="Support the Project"
|
||||
description="Please consider supporting the development of Vencord by donating!"
|
||||
cardImage={donateImage}
|
||||
backgroundImage={DONOR_BACKGROUND_IMAGE}
|
||||
backgroundColor="#c3a3ce"
|
||||
>
|
||||
<DonateButtonComponent />
|
||||
</SpecialCard>
|
||||
)
|
||||
}
|
||||
{isPluginDev(user?.id) && (
|
||||
<SpecialCard
|
||||
title="Contributions"
|
||||
subtitle="Thank you for contributing!"
|
||||
description="Since you've contributed to Vencord you now have a cool new badge!"
|
||||
cardImage={COZY_CONTRIB_IMAGE}
|
||||
backgroundImage={CONTRIB_BACKGROUND_IMAGE}
|
||||
backgroundColor="#EDCC87"
|
||||
buttonTitle="See what you've contributed to"
|
||||
buttonOnClick={() => openContributorModal(user)}
|
||||
/>
|
||||
)}
|
||||
|
||||
<Forms.FormSection title="Quick Actions">
|
||||
<QuickActionCard>
|
||||
<QuickAction
|
||||
Icon={LogIcon}
|
||||
text="Notification Log"
|
||||
action={openNotificationLogModal}
|
||||
/>
|
||||
<QuickAction
|
||||
Icon={PaintbrushIcon}
|
||||
text="Edit QuickCSS"
|
||||
action={() => VencordNative.quickCss.openEditor()}
|
||||
/>
|
||||
{!IS_WEB && (
|
||||
<QuickAction
|
||||
Icon={RestartIcon}
|
||||
text="Relaunch Discord"
|
||||
action={relaunch}
|
||||
/>
|
||||
)}
|
||||
{!IS_WEB && (
|
||||
<QuickAction
|
||||
Icon={FolderIcon}
|
||||
text="Open Settings Folder"
|
||||
action={() => VencordNative.settings.openFolder()}
|
||||
/>
|
||||
)}
|
||||
<QuickAction
|
||||
Icon={GithubIcon}
|
||||
text="View Source Code"
|
||||
action={() => VencordNative.native.openExternal("https://github.com/" + gitRemote)}
|
||||
/>
|
||||
</QuickActionCard>
|
||||
</Forms.FormSection>
|
||||
|
||||
<Forms.FormDivider />
|
||||
|
||||
<Forms.FormSection className={Margins.top16} title="Settings" tag="h5">
|
||||
<Forms.FormText className={Margins.bottom20} style={{ color: "var(--text-muted)" }}>
|
||||
Hint: You can change the position of this settings section in the
|
||||
{" "}<Button
|
||||
look={Button.Looks.BLANK}
|
||||
style={{ color: "var(--text-link)", display: "inline-block" }}
|
||||
onClick={() => openPluginModal(Vencord.Plugins.plugins.Settings)}
|
||||
>
|
||||
settings of the Settings plugin
|
||||
</Button>!
|
||||
</Forms.FormText>
|
||||
|
||||
{Switches.map(s => s && (
|
||||
<Switch
|
||||
key={s.key}
|
||||
value={settings[s.key]}
|
||||
onChange={v => settings[s.key] = v}
|
||||
note={s.note}
|
||||
>
|
||||
{s.title}
|
||||
</Switch>
|
||||
))}
|
||||
</Forms.FormSection>
|
||||
|
||||
|
||||
{needsVibrancySettings && <>
|
||||
<Forms.FormTitle tag="h5">Window vibrancy style (requires restart)</Forms.FormTitle>
|
||||
<Select
|
||||
className={Margins.bottom20}
|
||||
placeholder="Window vibrancy style"
|
||||
options={[
|
||||
// Sorted from most opaque to most transparent
|
||||
{
|
||||
label: "No vibrancy", value: undefined
|
||||
},
|
||||
{
|
||||
label: "Under Page (window tinting)",
|
||||
value: "under-page"
|
||||
},
|
||||
{
|
||||
label: "Content",
|
||||
value: "content"
|
||||
},
|
||||
{
|
||||
label: "Window",
|
||||
value: "window"
|
||||
},
|
||||
{
|
||||
label: "Selection",
|
||||
value: "selection"
|
||||
},
|
||||
{
|
||||
label: "Titlebar",
|
||||
value: "titlebar"
|
||||
},
|
||||
{
|
||||
label: "Header",
|
||||
value: "header"
|
||||
},
|
||||
{
|
||||
label: "Sidebar",
|
||||
value: "sidebar"
|
||||
},
|
||||
{
|
||||
label: "Tooltip",
|
||||
value: "tooltip"
|
||||
},
|
||||
{
|
||||
label: "Menu",
|
||||
value: "menu"
|
||||
},
|
||||
{
|
||||
label: "Popover",
|
||||
value: "popover"
|
||||
},
|
||||
{
|
||||
label: "Fullscreen UI (transparent but slightly muted)",
|
||||
value: "fullscreen-ui"
|
||||
},
|
||||
{
|
||||
label: "HUD (Most transparent)",
|
||||
value: "hud"
|
||||
},
|
||||
]}
|
||||
select={v => settings.macosVibrancyStyle = v}
|
||||
isSelected={v => settings.macosVibrancyStyle === v}
|
||||
serialize={identity} />
|
||||
</>}
|
||||
|
||||
<Forms.FormSection className={Margins.top16} title="Vencord Notifications" tag="h5">
|
||||
<Flex>
|
||||
<Button onClick={openNotificationSettingsModal}>
|
||||
Notification Settings
|
||||
</Button>
|
||||
<Button onClick={openNotificationLogModal}>
|
||||
View Notification Log
|
||||
</Button>
|
||||
</Flex>
|
||||
</Forms.FormSection>
|
||||
</SettingsTab>
|
||||
);
|
||||
}
|
||||
|
||||
function DonateButtonComponent() {
|
||||
return (
|
||||
<DonateButton
|
||||
look={Button.Looks.FILLED}
|
||||
color={Button.Colors.WHITE}
|
||||
style={{ marginTop: "1em" }}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
function isDonor(userId: string): boolean {
|
||||
const donorBadges = BadgeAPI.getDonorBadges(userId);
|
||||
return GuildMemberStore.getMember(VENCORD_GUILD_ID, userId)?.roles.includes(DONOR_ROLE_ID) || !!donorBadges;
|
||||
}
|
||||
|
||||
export default wrapTab(VencordSettings, "Vencord Settings");
|
||||
|
|
@ -4,14 +4,14 @@
|
|||
* SPDX-License-Identifier: GPL-3.0-or-later
|
||||
*/
|
||||
|
||||
export * from "./Badge";
|
||||
export * from "./CheckedTextInput";
|
||||
export * from "./CodeBlock";
|
||||
export * from "./DonateButton";
|
||||
export { default as ErrorBoundary } from "./ErrorBoundary";
|
||||
export * from "./ErrorCard";
|
||||
export * from "./Flex";
|
||||
export * from "./Grid";
|
||||
export * from "./Heart";
|
||||
export * from "./Icons";
|
||||
export * from "./Link";
|
||||
export * from "./Switch";
|
||||
export * from "./settings";
|
||||
|
||||
|
|
|
|||
|
|
@ -16,11 +16,11 @@
|
|||
* along with this program. If not, see <https://www.gnu.org/licenses/>.
|
||||
*/
|
||||
|
||||
import "./addonCard.css";
|
||||
import "./AddonCard.css";
|
||||
|
||||
import { classNameFactory } from "@api/Styles";
|
||||
import { Badge } from "@components/Badge";
|
||||
import { Switch } from "@components/Switch";
|
||||
import { AddonBadge } from "@components/settings/PluginBadge";
|
||||
import { Switch } from "@components/settings/Switch";
|
||||
import { Text, useRef } from "@webpack/common";
|
||||
import type { MouseEventHandler, ReactNode } from "react";
|
||||
|
||||
|
|
@ -44,6 +44,7 @@ interface Props {
|
|||
export function AddonCard({ disabled, isNew, name, infoButton, footer, author, enabled, setEnabled, description, onMouseEnter, onMouseLeave }: Props) {
|
||||
const titleRef = useRef<HTMLDivElement>(null);
|
||||
const titleContainerRef = useRef<HTMLDivElement>(null);
|
||||
|
||||
return (
|
||||
<div
|
||||
className={cl("card", { "card-disabled": disabled })}
|
||||
|
|
@ -67,8 +68,10 @@ export function AddonCard({ disabled, isNew, name, infoButton, footer, author, e
|
|||
>
|
||||
{name}
|
||||
</div>
|
||||
</div>{isNew && <Badge text="NEW" color="#ED4245" />}
|
||||
</div>
|
||||
{isNew && <AddonBadge text="NEW" color="#ED4245" />}
|
||||
</Text>
|
||||
|
||||
{!!author && (
|
||||
<Text variant="text-md/normal" className={cl("author")}>
|
||||
{author}
|
||||
|
|
@ -16,11 +16,10 @@
|
|||
* along with this program. If not, see <https://www.gnu.org/licenses/>.
|
||||
*/
|
||||
|
||||
import { Heart } from "@components/Heart";
|
||||
import { ButtonProps } from "@vencord/discord-types";
|
||||
import { Button } from "@webpack/common";
|
||||
|
||||
import { Heart } from "./Heart";
|
||||
|
||||
export default function DonateButton({
|
||||
look = Button.Looks.LINK,
|
||||
color = Button.Colors.TRANSPARENT,
|
||||
|
|
@ -16,9 +16,9 @@
|
|||
* along with this program. If not, see <https://www.gnu.org/licenses/>.
|
||||
*/
|
||||
|
||||
export function Badge({ text, color }) {
|
||||
export function AddonBadge({ text, color }) {
|
||||
return (
|
||||
<div className="vc-plugins-badge" style={{
|
||||
<div className="vc-addon-badge" style={{
|
||||
backgroundColor: color,
|
||||
justifySelf: "flex-end",
|
||||
marginLeft: "auto"
|
||||
|
|
@ -4,7 +4,7 @@
|
|||
* SPDX-License-Identifier: GPL-3.0-or-later
|
||||
*/
|
||||
|
||||
import "./quickActions.css";
|
||||
import "./QuickAction.css";
|
||||
|
||||
import { classNameFactory } from "@api/Styles";
|
||||
import { Card } from "@webpack/common";
|
||||
|
|
@ -16,7 +16,7 @@
|
|||
* along with this program. If not, see <https://www.gnu.org/licenses/>.
|
||||
*/
|
||||
|
||||
import "./specialCard.css";
|
||||
import "./SpecialCard.css";
|
||||
|
||||
import { classNameFactory } from "@api/Styles";
|
||||
import { Card, Clickable, Forms, React } from "@webpack/common";
|
||||
13
src/components/settings/index.ts
Normal file
13
src/components/settings/index.ts
Normal file
|
|
@ -0,0 +1,13 @@
|
|||
/*
|
||||
* Vencord, a Discord client mod
|
||||
* Copyright (c) 2025 Vendicated and contributors
|
||||
* SPDX-License-Identifier: GPL-3.0-or-later
|
||||
*/
|
||||
|
||||
export * from "./AddonCard";
|
||||
export * from "./DonateButton";
|
||||
export * from "./PluginBadge";
|
||||
export * from "./QuickAction";
|
||||
export * from "./SpecialCard";
|
||||
export * from "./Switch";
|
||||
export * from "./tabs";
|
||||
|
|
@ -16,9 +16,6 @@
|
|||
* along with this program. If not, see <https://www.gnu.org/licenses/>.
|
||||
*/
|
||||
|
||||
import "./settingsStyles.css";
|
||||
import "./themesStyles.css";
|
||||
|
||||
import ErrorBoundary from "@components/ErrorBoundary";
|
||||
import { handleComponentFailed } from "@components/handleComponentFailed";
|
||||
import { Margins } from "@utils/margins";
|
||||
18
src/components/settings/tabs/index.ts
Normal file
18
src/components/settings/tabs/index.ts
Normal file
|
|
@ -0,0 +1,18 @@
|
|||
/*
|
||||
* Vencord, a Discord client mod
|
||||
* Copyright (c) 2025 Vendicated and contributors
|
||||
* SPDX-License-Identifier: GPL-3.0-or-later
|
||||
*/
|
||||
|
||||
import "./styles.css";
|
||||
|
||||
export * from "./BaseTab";
|
||||
export { default as PatchHelperTab } from "./patchHelper";
|
||||
export { default as PluginsTab } from "./plugins";
|
||||
export { openContributorModal } from "./plugins/ContributorModal";
|
||||
export { openPluginModal } from "./plugins/PluginModal";
|
||||
export { default as BackupAndRestoreTab } from "./sync/BackupAndRestoreTab";
|
||||
export { default as CloudTab } from "./sync/CloudTab";
|
||||
export { default as ThemesTab } from "./themes";
|
||||
export { openUpdaterModal, default as UpdaterTab } from "./updater";
|
||||
export { default as VencordTab } from "./vencord";
|
||||
83
src/components/settings/tabs/patchHelper/FullPatchInput.tsx
Normal file
83
src/components/settings/tabs/patchHelper/FullPatchInput.tsx
Normal file
|
|
@ -0,0 +1,83 @@
|
|||
/*
|
||||
* Vencord, a Discord client mod
|
||||
* Copyright (c) 2025 Vendicated and contributors
|
||||
* SPDX-License-Identifier: GPL-3.0-or-later
|
||||
*/
|
||||
|
||||
import { Margins } from "@utils/margins";
|
||||
import { Patch, ReplaceFn } from "@utils/types";
|
||||
import { Forms, TextArea, useEffect, useRef, useState } from "@webpack/common";
|
||||
|
||||
export interface FullPatchInputProps {
|
||||
setFind(v: string): void;
|
||||
setParsedFind(v: string | RegExp): void;
|
||||
setMatch(v: string): void;
|
||||
setReplacement(v: string | ReplaceFn): void;
|
||||
}
|
||||
|
||||
export function FullPatchInput({ setFind, setParsedFind, setMatch, setReplacement }: FullPatchInputProps) {
|
||||
const [patch, setPatch] = useState<string>("");
|
||||
const [error, setError] = useState<string>("");
|
||||
|
||||
const textAreaRef = useRef<HTMLTextAreaElement>(null);
|
||||
|
||||
function update() {
|
||||
if (patch === "") {
|
||||
setError("");
|
||||
|
||||
setFind("");
|
||||
setParsedFind("");
|
||||
setMatch("");
|
||||
setReplacement("");
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
let { find, replacement } = (0, eval)(`([${patch}][0])`) as Patch;
|
||||
|
||||
if (!find) throw new Error("No 'find' field");
|
||||
if (!replacement) throw new Error("No 'replacement' field");
|
||||
|
||||
if (replacement instanceof Array) {
|
||||
if (replacement.length === 0) throw new Error("Invalid replacement");
|
||||
|
||||
// Only test the first replacement
|
||||
replacement = replacement[0];
|
||||
}
|
||||
|
||||
if (!replacement.match) throw new Error("No 'replacement.match' field");
|
||||
if (!replacement.replace) throw new Error("No 'replacement.replace' field");
|
||||
|
||||
setFind(find instanceof RegExp ? `/${find.source}/` : find);
|
||||
setParsedFind(find);
|
||||
setMatch(replacement.match instanceof RegExp ? replacement.match.source : replacement.match);
|
||||
setReplacement(replacement.replace);
|
||||
setError("");
|
||||
} catch (e) {
|
||||
setError((e as Error).message);
|
||||
}
|
||||
}
|
||||
|
||||
useEffect(() => {
|
||||
const { current: textArea } = textAreaRef;
|
||||
if (textArea) {
|
||||
textArea.style.height = "auto";
|
||||
textArea.style.height = `${textArea.scrollHeight}px`;
|
||||
}
|
||||
}, [patch]);
|
||||
|
||||
return (
|
||||
<>
|
||||
<Forms.FormText className={Margins.bottom8}>
|
||||
Paste your full JSON patch here to fill out the fields
|
||||
</Forms.FormText>
|
||||
<TextArea
|
||||
inputRef={textAreaRef}
|
||||
value={patch}
|
||||
onChange={setPatch}
|
||||
onBlur={update}
|
||||
/>
|
||||
{error !== "" && <Forms.FormText style={{ color: "var(--text-danger)" }}>{error}</Forms.FormText>}
|
||||
</>
|
||||
);
|
||||
}
|
||||
149
src/components/settings/tabs/patchHelper/PatchPreview.tsx
Normal file
149
src/components/settings/tabs/patchHelper/PatchPreview.tsx
Normal file
|
|
@ -0,0 +1,149 @@
|
|||
/*
|
||||
* Vencord, a Discord client mod
|
||||
* Copyright (c) 2025 Vendicated and contributors
|
||||
* SPDX-License-Identifier: GPL-3.0-or-later
|
||||
*/
|
||||
|
||||
import { Margins } from "@utils/margins";
|
||||
import { canonicalizeMatch, canonicalizeReplace } from "@utils/patches";
|
||||
import { makeCodeblock } from "@utils/text";
|
||||
import { ReplaceFn } from "@utils/types";
|
||||
import { Button, Forms, Parser, useMemo, useState } from "@webpack/common";
|
||||
import type { Change } from "diff";
|
||||
|
||||
// Do not include diff in non dev builds (side effects import)
|
||||
if (IS_DEV) {
|
||||
var differ = require("diff") as typeof import("diff");
|
||||
}
|
||||
|
||||
interface PatchPreviewProps {
|
||||
module: [id: number, factory: Function];
|
||||
match: string;
|
||||
replacement: string | ReplaceFn;
|
||||
setReplacementError(error: any): void;
|
||||
}
|
||||
|
||||
function makeDiff(original: string, patched: string, match: RegExpMatchArray | null) {
|
||||
if (!match || original === patched) return null;
|
||||
|
||||
const changeSize = patched.length - original.length;
|
||||
|
||||
// Use 200 surrounding characters of context
|
||||
const start = Math.max(0, match.index! - 200);
|
||||
const end = Math.min(original.length, match.index! + match[0].length + 200);
|
||||
// (changeSize may be negative)
|
||||
const endPatched = end + changeSize;
|
||||
|
||||
const context = original.slice(start, end);
|
||||
const patchedContext = patched.slice(start, endPatched);
|
||||
|
||||
return differ.diffWordsWithSpace(context, patchedContext);
|
||||
}
|
||||
|
||||
function Match({ matchResult }: { matchResult: RegExpMatchArray | null; }) {
|
||||
if (!matchResult)
|
||||
return null;
|
||||
|
||||
const fullMatch = matchResult[0]
|
||||
? makeCodeblock(matchResult[0], "js")
|
||||
: "";
|
||||
const groups = matchResult.length > 1
|
||||
? makeCodeblock(matchResult.slice(1).map((g, i) => `Group ${i + 1}: ${g}`).join("\n"), "yml")
|
||||
: "";
|
||||
|
||||
return (
|
||||
<>
|
||||
<Forms.FormTitle>Match</Forms.FormTitle>
|
||||
<div style={{ userSelect: "text" }}>{Parser.parse(fullMatch)}</div>
|
||||
<div style={{ userSelect: "text" }}>{Parser.parse(groups)}</div>
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
function Diff({ diff }: { diff: Change[] | null; }) {
|
||||
if (!diff?.length)
|
||||
return null;
|
||||
|
||||
const diffLines = diff.map((p, idx) => {
|
||||
const color = p.added
|
||||
? "lime"
|
||||
: p.removed
|
||||
? "red"
|
||||
: "grey";
|
||||
|
||||
return (
|
||||
<div
|
||||
key={idx}
|
||||
style={{ color, userSelect: "text", wordBreak: "break-all", lineBreak: "anywhere" }}
|
||||
>
|
||||
{p.value}
|
||||
</div>
|
||||
);
|
||||
});
|
||||
|
||||
return (
|
||||
<>
|
||||
<Forms.FormTitle>Diff</Forms.FormTitle>
|
||||
{diffLines}
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
export function PatchPreview({ module, match, replacement, setReplacementError }: PatchPreviewProps) {
|
||||
const [id, fact] = module;
|
||||
const [compileResult, setCompileResult] = useState<[boolean, string]>();
|
||||
|
||||
const [patchedCode, matchResult, diff] = useMemo<[string, RegExpMatchArray | null, Change[] | null]>(() => {
|
||||
const src: string = fact.toString().replaceAll("\n", "");
|
||||
|
||||
try {
|
||||
new RegExp(match);
|
||||
} catch (e) {
|
||||
return ["", null, null];
|
||||
}
|
||||
|
||||
const canonicalMatch = canonicalizeMatch(new RegExp(match));
|
||||
try {
|
||||
const canonicalReplace = canonicalizeReplace(replacement, 'Vencord.Plugins.plugins["YourPlugin"]');
|
||||
var patched = src.replace(canonicalMatch, canonicalReplace as string);
|
||||
setReplacementError(void 0);
|
||||
} catch (e) {
|
||||
setReplacementError((e as Error).message);
|
||||
return ["", null, null];
|
||||
}
|
||||
|
||||
const m = src.match(canonicalMatch);
|
||||
return [patched, m, makeDiff(src, patched, m)];
|
||||
}, [id, match, replacement]);
|
||||
|
||||
return (
|
||||
<>
|
||||
<Forms.FormTitle>Module {id}</Forms.FormTitle>
|
||||
|
||||
<Match matchResult={matchResult} />
|
||||
<Diff diff={diff} />
|
||||
|
||||
{!!diff?.length && (
|
||||
<Button
|
||||
className={Margins.top20}
|
||||
onClick={() => {
|
||||
try {
|
||||
Function(patchedCode.replace(/^(?=function\()/, "0,"));
|
||||
setCompileResult([true, "Compiled successfully"]);
|
||||
} catch (err) {
|
||||
setCompileResult([false, (err as Error).message]);
|
||||
}
|
||||
}}
|
||||
>
|
||||
Compile
|
||||
</Button>
|
||||
)}
|
||||
|
||||
{compileResult && (
|
||||
<Forms.FormText style={{ color: compileResult[0] ? "var(--status-positive)" : "var(--text-danger)" }}>
|
||||
{compileResult[1]}
|
||||
</Forms.FormText>
|
||||
)}
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
|
@ -0,0 +1,83 @@
|
|||
/*
|
||||
* Vencord, a Discord client mod
|
||||
* Copyright (c) 2025 Vendicated and contributors
|
||||
* SPDX-License-Identifier: GPL-3.0-or-later
|
||||
*/
|
||||
|
||||
import { Margins } from "@utils/margins";
|
||||
import { Forms, Parser, Switch, TextInput, useEffect, useState } from "@webpack/common";
|
||||
|
||||
const RegexGuide = {
|
||||
"\\i": "Special regex escape sequence that matches identifiers (varnames, classnames, etc.)",
|
||||
"$$": "Insert a $",
|
||||
"$&": "Insert the entire match",
|
||||
"$`\u200b": "Insert the substring before the match",
|
||||
"$'": "Insert the substring after the match",
|
||||
"$n": "Insert the nth capturing group ($1, $2...)",
|
||||
"$self": "Insert the plugin instance",
|
||||
} as const;
|
||||
|
||||
export function ReplacementInput({ replacement, setReplacement, replacementError }) {
|
||||
const [isFunc, setIsFunc] = useState(false);
|
||||
const [error, setError] = useState<string>();
|
||||
|
||||
function onChange(v: string) {
|
||||
setError(void 0);
|
||||
|
||||
if (isFunc) {
|
||||
try {
|
||||
const func = (0, eval)(v);
|
||||
if (typeof func === "function")
|
||||
setReplacement(() => func);
|
||||
|
||||
else
|
||||
setError("Replacement must be a function");
|
||||
} catch (e) {
|
||||
setReplacement(v);
|
||||
setError((e as Error).message);
|
||||
}
|
||||
} else {
|
||||
setReplacement(v);
|
||||
}
|
||||
}
|
||||
|
||||
useEffect(() => {
|
||||
if (isFunc)
|
||||
onChange(replacement);
|
||||
else
|
||||
setError(void 0);
|
||||
}, [isFunc]);
|
||||
|
||||
return (
|
||||
<>
|
||||
{/* FormTitle adds a class if className is not set, so we set it to an empty string to prevent that */}
|
||||
<Forms.FormTitle className="">Replacement</Forms.FormTitle>
|
||||
<TextInput
|
||||
value={replacement?.toString()}
|
||||
onChange={onChange}
|
||||
error={error ?? replacementError}
|
||||
/>
|
||||
{!isFunc && (
|
||||
<div>
|
||||
<Forms.FormTitle className={Margins.top8}>Cheat Sheet</Forms.FormTitle>
|
||||
|
||||
{Object.entries(RegexGuide).map(([placeholder, desc]) => (
|
||||
<Forms.FormText key={placeholder}>
|
||||
{Parser.parse("`" + placeholder + "`")}: {desc}
|
||||
</Forms.FormText>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
|
||||
<Switch
|
||||
className={Margins.top16}
|
||||
value={isFunc}
|
||||
onChange={setIsFunc}
|
||||
note="'replacement' will be evaled if this is toggled"
|
||||
hideBorder
|
||||
>
|
||||
Treat as Function
|
||||
</Switch>
|
||||
</>
|
||||
);
|
||||
}
|
||||
165
src/components/settings/tabs/patchHelper/index.tsx
Normal file
165
src/components/settings/tabs/patchHelper/index.tsx
Normal file
|
|
@ -0,0 +1,165 @@
|
|||
/*
|
||||
* Vencord, a modification for Discord's desktop app
|
||||
* Copyright (c) 2022 Vendicated and contributors
|
||||
*
|
||||
* This program is free software: you can redistribute it and/or modify
|
||||
* it under the terms of the GNU General Public License as published by
|
||||
* the Free Software Foundation, either version 3 of the License, or
|
||||
* (at your option) any later version.
|
||||
*
|
||||
* This program is distributed in the hope that it will be useful,
|
||||
* but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
||||
* GNU General Public License for more details.
|
||||
*
|
||||
* You should have received a copy of the GNU General Public License
|
||||
* along with this program. If not, see <https://www.gnu.org/licenses/>.
|
||||
*/
|
||||
|
||||
import { CodeBlock } from "@components/CodeBlock";
|
||||
import { Flex } from "@components/Flex";
|
||||
import { SettingsTab, wrapTab } from "@components/settings/tabs/BaseTab";
|
||||
import { debounce } from "@shared/debounce";
|
||||
import { Margins } from "@utils/margins";
|
||||
import { copyWithToast } from "@utils/misc";
|
||||
import { stripIndent } from "@utils/text";
|
||||
import { ReplaceFn } from "@utils/types";
|
||||
import { search } from "@webpack";
|
||||
import { Button, Forms, React, TextInput, useMemo, useState } from "@webpack/common";
|
||||
|
||||
import { FullPatchInput } from "./FullPatchInput";
|
||||
import { PatchPreview } from "./PatchPreview";
|
||||
import { ReplacementInput } from "./ReplacementInput";
|
||||
|
||||
const findCandidates = debounce(function ({ find, setModule, setError }) {
|
||||
const candidates = search(find);
|
||||
const keys = Object.keys(candidates);
|
||||
const len = keys.length;
|
||||
|
||||
if (len === 0)
|
||||
setError("No match. Perhaps that module is lazy loaded?");
|
||||
else if (len !== 1)
|
||||
setError("Multiple matches. Please refine your filter");
|
||||
else
|
||||
setModule([keys[0], candidates[keys[0]]]);
|
||||
});
|
||||
|
||||
function PatchHelper() {
|
||||
const [find, setFind] = useState("");
|
||||
const [match, setMatch] = useState("");
|
||||
const [replacement, setReplacement] = useState<string | ReplaceFn>("");
|
||||
|
||||
const [parsedFind, setParsedFind] = useState<string | RegExp>("");
|
||||
|
||||
const [findError, setFindError] = useState<string>();
|
||||
const [matchError, setMatchError] = useState<string>();
|
||||
const [replacementError, setReplacementError] = useState<string>();
|
||||
|
||||
const [module, setModule] = useState<[number, Function]>();
|
||||
|
||||
const code = useMemo(() => {
|
||||
const find = parsedFind instanceof RegExp ? parsedFind.toString() : JSON.stringify(parsedFind);
|
||||
const replace = typeof replacement === "function" ? replacement.toString() : JSON.stringify(replacement);
|
||||
|
||||
return stripIndent`
|
||||
{
|
||||
find: ${find},
|
||||
replacement: {
|
||||
match: /${match.replace(/(?<!\\)\//g, "\\/")}/,
|
||||
replace: ${replace}
|
||||
}
|
||||
}
|
||||
`;
|
||||
}, [parsedFind, match, replacement]);
|
||||
|
||||
function onFindChange(v: string) {
|
||||
setFind(v);
|
||||
|
||||
try {
|
||||
let parsedFind = v as string | RegExp;
|
||||
if (/^\/.+?\/$/.test(v)) parsedFind = new RegExp(v.slice(1, -1));
|
||||
|
||||
setFindError(void 0);
|
||||
setParsedFind(parsedFind);
|
||||
|
||||
if (v.length) {
|
||||
findCandidates({ find: parsedFind, setModule, setError: setFindError });
|
||||
}
|
||||
} catch (e: any) {
|
||||
setFindError((e as Error).message);
|
||||
}
|
||||
}
|
||||
|
||||
function onMatchChange(v: string) {
|
||||
setMatch(v);
|
||||
|
||||
try {
|
||||
new RegExp(v);
|
||||
setMatchError(void 0);
|
||||
} catch (e: any) {
|
||||
setMatchError((e as Error).message);
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<SettingsTab title="Patch Helper">
|
||||
<Forms.FormTitle>full patch</Forms.FormTitle>
|
||||
<FullPatchInput
|
||||
setFind={onFindChange}
|
||||
setParsedFind={setParsedFind}
|
||||
setMatch={onMatchChange}
|
||||
setReplacement={setReplacement}
|
||||
/>
|
||||
|
||||
<Forms.FormTitle className={Margins.top8}>find</Forms.FormTitle>
|
||||
<TextInput
|
||||
type="text"
|
||||
value={find}
|
||||
onChange={onFindChange}
|
||||
error={findError}
|
||||
/>
|
||||
|
||||
<Forms.FormTitle className={Margins.top8}>match</Forms.FormTitle>
|
||||
<TextInput
|
||||
type="text"
|
||||
value={match}
|
||||
onChange={onMatchChange}
|
||||
error={matchError}
|
||||
/>
|
||||
|
||||
<div className={Margins.top8} />
|
||||
<ReplacementInput
|
||||
replacement={replacement}
|
||||
setReplacement={setReplacement}
|
||||
replacementError={replacementError}
|
||||
/>
|
||||
|
||||
<Forms.FormDivider />
|
||||
{module && (
|
||||
<PatchPreview
|
||||
module={module}
|
||||
match={match}
|
||||
replacement={replacement}
|
||||
setReplacementError={setReplacementError}
|
||||
/>
|
||||
)}
|
||||
|
||||
{!!(find && match && replacement) && (
|
||||
<>
|
||||
<Forms.FormTitle className={Margins.top20}>Code</Forms.FormTitle>
|
||||
<CodeBlock lang="js" content={code} />
|
||||
<Flex className={Margins.top16}>
|
||||
<Button onClick={() => copyWithToast(code)}>
|
||||
Copy to Clipboard
|
||||
</Button>
|
||||
<Button onClick={() => copyWithToast("```ts\n" + code + "\n```")}>
|
||||
Copy as Codeblock
|
||||
</Button>
|
||||
</Flex>
|
||||
</>
|
||||
)}
|
||||
</SettingsTab>
|
||||
);
|
||||
}
|
||||
|
||||
export default IS_DEV ? wrapTab(PatchHelper, "PatchHelper") : null;
|
||||
|
|
@ -4,7 +4,7 @@
|
|||
* SPDX-License-Identifier: GPL-3.0-or-later
|
||||
*/
|
||||
|
||||
import "./contributorModal.css";
|
||||
import "./ContributorModal.css";
|
||||
|
||||
import { useSettings } from "@api/Settings";
|
||||
import { classNameFactory } from "@api/Styles";
|
||||
|
|
@ -19,8 +19,8 @@ import { Forms, showToast, useEffect, useMemo, UserProfileStore, useStateFromSto
|
|||
|
||||
import Plugins from "~plugins";
|
||||
|
||||
import { PluginCard } from ".";
|
||||
import { GithubButton, WebsiteButton } from "./LinkIconButton";
|
||||
import { PluginCard } from "./PluginCard";
|
||||
|
||||
const cl = classNameFactory("vc-author-modal-");
|
||||
|
||||
|
|
@ -6,11 +6,10 @@
|
|||
|
||||
import "./LinkIconButton.css";
|
||||
|
||||
import { GithubIcon, WebsiteIcon } from "@components/Icons";
|
||||
import { getTheme, Theme } from "@utils/discord";
|
||||
import { MaskedLink, Tooltip } from "@webpack/common";
|
||||
|
||||
import { GithubIcon, WebsiteIcon } from "..";
|
||||
|
||||
export function GithubLinkIcon() {
|
||||
const theme = getTheme() === Theme.Light ? "#000000" : "#FFFFFF";
|
||||
return <GithubIcon aria-hidden fill={theme} className={"vc-settings-modal-link-icon"} />;
|
||||
110
src/components/settings/tabs/plugins/PluginCard.tsx
Normal file
110
src/components/settings/tabs/plugins/PluginCard.tsx
Normal file
|
|
@ -0,0 +1,110 @@
|
|||
/*
|
||||
* Vencord, a Discord client mod
|
||||
* Copyright (c) 2025 Vendicated and contributors
|
||||
* SPDX-License-Identifier: GPL-3.0-or-later
|
||||
*/
|
||||
|
||||
import { showNotice } from "@api/Notices";
|
||||
import { CogWheel, InfoIcon } from "@components/Icons";
|
||||
import { AddonCard } from "@components/settings/AddonCard";
|
||||
import { proxyLazy } from "@utils/lazy";
|
||||
import { classes, isObjectEmpty } from "@utils/misc";
|
||||
import { Plugin } from "@utils/types";
|
||||
import { findByPropsLazy } from "@webpack";
|
||||
import { React, showToast, Toasts } from "@webpack/common";
|
||||
import { Settings } from "Vencord";
|
||||
|
||||
import { cl, logger } from ".";
|
||||
import { openPluginModal } from "./PluginModal";
|
||||
|
||||
// Avoid circular dependency
|
||||
const { startDependenciesRecursive, startPlugin, stopPlugin, isPluginEnabled } = proxyLazy(() => require("plugins") as typeof import("plugins"));
|
||||
|
||||
export const ButtonClasses = findByPropsLazy("button", "disabled", "enabled");
|
||||
|
||||
interface PluginCardProps extends React.HTMLProps<HTMLDivElement> {
|
||||
plugin: Plugin;
|
||||
disabled: boolean;
|
||||
onRestartNeeded(name: string, key: string): void;
|
||||
isNew?: boolean;
|
||||
}
|
||||
|
||||
export function PluginCard({ plugin, disabled, onRestartNeeded, onMouseEnter, onMouseLeave, isNew }: PluginCardProps) {
|
||||
const settings = Settings.plugins[plugin.name];
|
||||
|
||||
const isEnabled = () => isPluginEnabled(plugin.name);
|
||||
|
||||
function toggleEnabled() {
|
||||
const wasEnabled = isEnabled();
|
||||
|
||||
// If we're enabling a plugin, make sure all deps are enabled recursively.
|
||||
if (!wasEnabled) {
|
||||
const { restartNeeded, failures } = startDependenciesRecursive(plugin);
|
||||
|
||||
if (failures.length) {
|
||||
logger.error(`Failed to start dependencies for ${plugin.name}: ${failures.join(", ")}`);
|
||||
showNotice("Failed to start dependencies: " + failures.join(", "), "Close", () => null);
|
||||
return;
|
||||
}
|
||||
|
||||
if (restartNeeded) {
|
||||
// If any dependencies have patches, don't start the plugin yet.
|
||||
settings.enabled = true;
|
||||
onRestartNeeded(plugin.name, "enabled");
|
||||
return;
|
||||
}
|
||||
}
|
||||
|
||||
// if the plugin has patches, dont use stopPlugin/startPlugin. Wait for restart to apply changes.
|
||||
if (plugin.patches?.length) {
|
||||
settings.enabled = !wasEnabled;
|
||||
onRestartNeeded(plugin.name, "enabled");
|
||||
return;
|
||||
}
|
||||
|
||||
// If the plugin is enabled, but hasn't been started, then we can just toggle it off.
|
||||
if (wasEnabled && !plugin.started) {
|
||||
settings.enabled = !wasEnabled;
|
||||
return;
|
||||
}
|
||||
|
||||
const result = wasEnabled ? stopPlugin(plugin) : startPlugin(plugin);
|
||||
|
||||
if (!result) {
|
||||
settings.enabled = false;
|
||||
|
||||
const msg = `Error while ${wasEnabled ? "stopping" : "starting"} plugin ${plugin.name}`;
|
||||
showToast(msg, Toasts.Type.FAILURE, {
|
||||
position: Toasts.Position.BOTTOM,
|
||||
});
|
||||
|
||||
return;
|
||||
}
|
||||
|
||||
settings.enabled = !wasEnabled;
|
||||
}
|
||||
|
||||
return (
|
||||
<AddonCard
|
||||
name={plugin.name}
|
||||
description={plugin.description}
|
||||
isNew={isNew}
|
||||
enabled={isEnabled()}
|
||||
setEnabled={toggleEnabled}
|
||||
disabled={disabled}
|
||||
onMouseEnter={onMouseEnter}
|
||||
onMouseLeave={onMouseLeave}
|
||||
infoButton={
|
||||
<button
|
||||
role="switch"
|
||||
onClick={() => openPluginModal(plugin, onRestartNeeded)}
|
||||
className={classes(ButtonClasses.button, cl("info-button"))}
|
||||
>
|
||||
{plugin.options && !isObjectEmpty(plugin.options)
|
||||
? <CogWheel className={cl("info-icon")} />
|
||||
: <InfoIcon className={cl("info-icon")} />
|
||||
}
|
||||
</button>
|
||||
} />
|
||||
);
|
||||
}
|
||||
|
|
@ -23,41 +23,32 @@ import { useSettings } from "@api/Settings";
|
|||
import { classNameFactory } from "@api/Styles";
|
||||
import ErrorBoundary from "@components/ErrorBoundary";
|
||||
import { Flex } from "@components/Flex";
|
||||
import { debounce } from "@shared/debounce";
|
||||
import { gitRemote } from "@shared/vencordUserAgent";
|
||||
import { proxyLazy } from "@utils/lazy";
|
||||
import { Margins } from "@utils/margins";
|
||||
import { isObjectEmpty } from "@utils/misc";
|
||||
import { ModalCloseButton, ModalContent, ModalFooter, ModalHeader, ModalProps, ModalRoot, ModalSize, openModal } from "@utils/modal";
|
||||
import { ModalCloseButton, ModalContent, ModalHeader, ModalProps, ModalRoot, ModalSize, openModal } from "@utils/modal";
|
||||
import { OptionType, Plugin } from "@utils/types";
|
||||
import { User } from "@vencord/discord-types";
|
||||
import { findByPropsLazy, findComponentByCodeLazy } from "@webpack";
|
||||
import { Button, Clickable, FluxDispatcher, Forms, React, Text, Tooltip, UserStore, UserUtils } from "@webpack/common";
|
||||
import { findByPropsLazy } from "@webpack";
|
||||
import { Clickable, FluxDispatcher, Forms, React, Text, Tooltip, useEffect, UserStore, UserSummaryItem, UserUtils, useState } from "@webpack/common";
|
||||
import { Constructor } from "type-fest";
|
||||
|
||||
import { PluginMeta } from "~plugins";
|
||||
|
||||
import {
|
||||
ISettingCustomElementProps,
|
||||
ISettingElementProps,
|
||||
SettingBooleanComponent,
|
||||
SettingCustomComponent,
|
||||
SettingNumericComponent,
|
||||
SettingSelectComponent,
|
||||
SettingSliderComponent,
|
||||
SettingTextComponent
|
||||
} from "./components";
|
||||
import { OptionComponentMap } from "./components";
|
||||
import { openContributorModal } from "./ContributorModal";
|
||||
import { GithubButton, WebsiteButton } from "./LinkIconButton";
|
||||
|
||||
const cl = classNameFactory("vc-plugin-modal-");
|
||||
|
||||
const UserSummaryItem = findComponentByCodeLazy("defaultRenderUser", "showDefaultAvatarsForNullUsers");
|
||||
const AvatarStyles = findByPropsLazy("moreUsers", "emptyUser", "avatarContainer", "clickableAvatar");
|
||||
const UserRecord: Constructor<Partial<User>> = proxyLazy(() => UserStore.getCurrentUser().constructor) as any;
|
||||
|
||||
interface PluginModalProps extends ModalProps {
|
||||
plugin: Plugin;
|
||||
onRestartNeeded(): void;
|
||||
onRestartNeeded(key: string): void;
|
||||
}
|
||||
|
||||
function makeDummyUser(user: { username: string; id?: string; avatar?: string; }) {
|
||||
|
|
@ -68,39 +59,22 @@ function makeDummyUser(user: { username: string; id?: string; avatar?: string; }
|
|||
/** To stop discord making unwanted requests... */
|
||||
bot: true,
|
||||
});
|
||||
|
||||
FluxDispatcher.dispatch({
|
||||
type: "USER_UPDATE",
|
||||
user: newUser,
|
||||
});
|
||||
|
||||
return newUser;
|
||||
}
|
||||
|
||||
const Components: Record<OptionType, React.ComponentType<ISettingElementProps<any> | ISettingCustomElementProps<any>>> = {
|
||||
[OptionType.STRING]: SettingTextComponent,
|
||||
[OptionType.NUMBER]: SettingNumericComponent,
|
||||
[OptionType.BIGINT]: SettingNumericComponent,
|
||||
[OptionType.BOOLEAN]: SettingBooleanComponent,
|
||||
[OptionType.SELECT]: SettingSelectComponent,
|
||||
[OptionType.SLIDER]: SettingSliderComponent,
|
||||
[OptionType.COMPONENT]: SettingCustomComponent,
|
||||
[OptionType.CUSTOM]: () => null,
|
||||
};
|
||||
|
||||
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 [saveError, setSaveError] = React.useState<string | null>(null);
|
||||
|
||||
const canSubmit = () => Object.values(errors).every(e => !e);
|
||||
|
||||
const hasSettings = Boolean(pluginSettings && plugin.options && !isObjectEmpty(plugin.options));
|
||||
|
||||
React.useEffect(() => {
|
||||
const [authors, setAuthors] = useState<Partial<User>[]>([]);
|
||||
|
||||
useEffect(() => {
|
||||
(async () => {
|
||||
for (const user of plugin.authors.slice(0, 6)) {
|
||||
const author = user.id
|
||||
|
|
@ -113,55 +87,29 @@ export default function PluginModal({ plugin, onRestartNeeded, onClose, transiti
|
|||
})();
|
||||
}, [plugin.authors]);
|
||||
|
||||
async function saveAndClose() {
|
||||
if (!plugin.options) {
|
||||
onClose();
|
||||
return;
|
||||
}
|
||||
|
||||
if (plugin.beforeSave) {
|
||||
const result = await Promise.resolve(plugin.beforeSave(tempSettings));
|
||||
if (result !== true) {
|
||||
setSaveError(result);
|
||||
return;
|
||||
}
|
||||
}
|
||||
|
||||
let restartNeeded = false;
|
||||
for (const [key, value] of Object.entries(tempSettings)) {
|
||||
const option = plugin.options[key];
|
||||
pluginSettings[key] = value;
|
||||
|
||||
if (option.type === OptionType.CUSTOM) continue;
|
||||
if (option?.restartNeeded) restartNeeded = true;
|
||||
}
|
||||
if (restartNeeded) onRestartNeeded();
|
||||
onClose();
|
||||
}
|
||||
|
||||
function renderSettings() {
|
||||
if (!hasSettings || !plugin.options) {
|
||||
if (!hasSettings || !plugin.options)
|
||||
return <Forms.FormText>There are no settings for this plugin.</Forms.FormText>;
|
||||
} else {
|
||||
|
||||
const options = Object.entries(plugin.options).map(([key, setting]) => {
|
||||
if (setting.type === OptionType.CUSTOM || setting.hidden) return null;
|
||||
|
||||
function onChange(newValue: any) {
|
||||
setTempSettings(s => ({ ...s, [key]: newValue }));
|
||||
const option = plugin.options?.[key];
|
||||
if (!option || option.type === OptionType.CUSTOM) return;
|
||||
|
||||
pluginSettings[key] = newValue;
|
||||
|
||||
if (option.restartNeeded) onRestartNeeded(key);
|
||||
}
|
||||
|
||||
function onError(hasError: boolean) {
|
||||
setErrors(e => ({ ...e, [key]: hasError }));
|
||||
}
|
||||
|
||||
const Component = Components[setting.type];
|
||||
const Component = OptionComponentMap[setting.type];
|
||||
return (
|
||||
<Component
|
||||
id={key}
|
||||
key={key}
|
||||
option={setting}
|
||||
onChange={onChange}
|
||||
onError={onError}
|
||||
onChange={debounce(onChange)}
|
||||
pluginSettings={pluginSettings}
|
||||
definedSettings={plugin.settings}
|
||||
/>
|
||||
|
|
@ -170,7 +118,6 @@ export default function PluginModal({ plugin, onRestartNeeded, onClose, transiti
|
|||
|
||||
return <Flex flexDirection="column" style={{ gap: 12, marginBottom: 16 }}>{options}</Flex>;
|
||||
}
|
||||
}
|
||||
|
||||
function renderMoreUsers(_label: string, count: number) {
|
||||
const sliceCount = plugin.authors.length - count;
|
||||
|
|
@ -192,35 +139,12 @@ export default function PluginModal({ plugin, onRestartNeeded, onClose, transiti
|
|||
);
|
||||
}
|
||||
|
||||
/*
|
||||
function switchToPopout() {
|
||||
onClose();
|
||||
|
||||
const PopoutKey = `DISCORD_VENCORD_PLUGIN_SETTINGS_MODAL_${plugin.name}`;
|
||||
PopoutActions.open(
|
||||
PopoutKey,
|
||||
() => <PluginModal
|
||||
transitionState={transitionState}
|
||||
plugin={plugin}
|
||||
onRestartNeeded={onRestartNeeded}
|
||||
onClose={() => PopoutActions.close(PopoutKey)}
|
||||
/>
|
||||
);
|
||||
}
|
||||
*/
|
||||
|
||||
const pluginMeta = PluginMeta[plugin.name];
|
||||
|
||||
return (
|
||||
<ModalRoot transitionState={transitionState} size={ModalSize.MEDIUM}>
|
||||
<ModalHeader separator={false}>
|
||||
<Text variant="heading-lg/semibold" style={{ flexGrow: 1 }}>{plugin.name}</Text>
|
||||
|
||||
{/*
|
||||
<Button look={Button.Looks.BLANK} onClick={switchToPopout}>
|
||||
<OpenExternalIcon aria-label="Open in Popout" />
|
||||
</Button>
|
||||
*/}
|
||||
<ModalCloseButton onClick={onClose} />
|
||||
</ModalHeader>
|
||||
<ModalContent>
|
||||
|
|
@ -244,12 +168,10 @@ export default function PluginModal({ plugin, onRestartNeeded, onClose, transiti
|
|||
<div style={{ width: "fit-content", marginBottom: 8 }}>
|
||||
<UserSummaryItem
|
||||
users={authors}
|
||||
count={plugin.authors.length}
|
||||
guildId={undefined}
|
||||
renderIcon={false}
|
||||
max={6}
|
||||
showDefaultAvatarsForNullUsers
|
||||
showUserPopout
|
||||
renderMoreUsers={renderMoreUsers}
|
||||
renderUser={(user: User) => (
|
||||
<Clickable
|
||||
|
|
@ -267,59 +189,32 @@ export default function PluginModal({ plugin, onRestartNeeded, onClose, transiti
|
|||
/>
|
||||
</div>
|
||||
</Forms.FormSection>
|
||||
|
||||
{!!plugin.settingsAboutComponent && (
|
||||
<div className={Margins.bottom8}>
|
||||
<Forms.FormSection>
|
||||
<ErrorBoundary message="An error occurred while rendering this plugin's custom Info Component">
|
||||
<plugin.settingsAboutComponent tempSettings={tempSettings} />
|
||||
<plugin.settingsAboutComponent />
|
||||
</ErrorBoundary>
|
||||
</Forms.FormSection>
|
||||
</div>
|
||||
)}
|
||||
|
||||
<Forms.FormSection className={Margins.bottom16}>
|
||||
<Forms.FormTitle tag="h3">Settings</Forms.FormTitle>
|
||||
{renderSettings()}
|
||||
</Forms.FormSection>
|
||||
</ModalContent>
|
||||
{hasSettings && <ModalFooter>
|
||||
<Flex flexDirection="column" style={{ width: "100%" }}>
|
||||
<Flex style={{ marginLeft: "auto" }}>
|
||||
<Button
|
||||
onClick={onClose}
|
||||
size={Button.Sizes.SMALL}
|
||||
color={Button.Colors.PRIMARY}
|
||||
look={Button.Looks.LINK}
|
||||
>
|
||||
Cancel
|
||||
</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 & Close
|
||||
</Button>
|
||||
)}
|
||||
</Tooltip>
|
||||
</Flex>
|
||||
{saveError && <Text variant="text-md/semibold" style={{ color: "var(--text-danger)" }}>Error while saving: {saveError}</Text>}
|
||||
</Flex>
|
||||
</ModalFooter>}
|
||||
</ModalRoot>
|
||||
);
|
||||
}
|
||||
|
||||
export function openPluginModal(plugin: Plugin, onRestartNeeded?: (pluginName: string) => void) {
|
||||
export function openPluginModal(plugin: Plugin, onRestartNeeded?: (pluginName: string, key: string) => void) {
|
||||
openModal(modalProps => (
|
||||
<PluginModal
|
||||
{...modalProps}
|
||||
plugin={plugin}
|
||||
onRestartNeeded={() => onRestartNeeded?.(plugin.name)}
|
||||
onRestartNeeded={(key: string) => onRestartNeeded?.(plugin.name, key)}
|
||||
/>
|
||||
));
|
||||
}
|
||||
|
|
@ -18,27 +18,23 @@
|
|||
|
||||
import { wordsFromCamel, wordsToTitle } from "@utils/text";
|
||||
import { PluginOptionBoolean } from "@utils/types";
|
||||
import { Forms, React, Switch } from "@webpack/common";
|
||||
import { Forms, React, Switch, useState } from "@webpack/common";
|
||||
|
||||
import { ISettingElementProps } from ".";
|
||||
import { resolveError, SettingProps } from "./Common";
|
||||
|
||||
export function SettingBooleanComponent({ option, pluginSettings, definedSettings, id, onChange, onError }: ISettingElementProps<PluginOptionBoolean>) {
|
||||
export function BooleanSetting({ option, pluginSettings, definedSettings, id, onChange }: SettingProps<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 [state, setState] = useState(def ?? false);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
|
||||
function handleChange(newValue: boolean): void {
|
||||
const isValid = option.isValid?.call(definedSettings, newValue) ?? true;
|
||||
if (typeof isValid === "string") setError(isValid);
|
||||
else if (!isValid) setError("Invalid input provided.");
|
||||
else {
|
||||
setError(null);
|
||||
|
||||
setState(newValue);
|
||||
setError(resolveError(isValid));
|
||||
|
||||
if (isValid === true) {
|
||||
onChange(newValue);
|
||||
}
|
||||
}
|
||||
48
src/components/settings/tabs/plugins/components/Common.tsx
Normal file
48
src/components/settings/tabs/plugins/components/Common.tsx
Normal file
|
|
@ -0,0 +1,48 @@
|
|||
/*
|
||||
* Vencord, a Discord client mod
|
||||
* Copyright (c) 2025 Vendicated and contributors
|
||||
* SPDX-License-Identifier: GPL-3.0-or-later
|
||||
*/
|
||||
|
||||
import { Margins } from "@utils/margins";
|
||||
import { wordsFromCamel, wordsToTitle } from "@utils/text";
|
||||
import { DefinedSettings, PluginOptionBase } from "@utils/types";
|
||||
import { Forms } from "@webpack/common";
|
||||
import { PropsWithChildren } from "react";
|
||||
|
||||
interface SettingBaseProps<T> {
|
||||
option: T;
|
||||
onChange(newValue: any): void;
|
||||
pluginSettings: {
|
||||
[setting: string]: any;
|
||||
enabled: boolean;
|
||||
};
|
||||
id: string;
|
||||
definedSettings?: DefinedSettings;
|
||||
}
|
||||
|
||||
export type SettingProps<T extends PluginOptionBase> = SettingBaseProps<T>;
|
||||
export type ComponentSettingProps<T extends Omit<PluginOptionBase, "description" | "placeholder">> = SettingBaseProps<T>;
|
||||
|
||||
export function resolveError(isValidResult: boolean | string) {
|
||||
if (typeof isValidResult === "string") return isValidResult;
|
||||
|
||||
return isValidResult ? null : "Invalid input provided";
|
||||
}
|
||||
|
||||
interface SettingsSectionProps extends PropsWithChildren {
|
||||
name: string;
|
||||
description: string;
|
||||
error: string | null;
|
||||
}
|
||||
|
||||
export function SettingsSection({ name, description, error, children }: SettingsSectionProps) {
|
||||
return (
|
||||
<Forms.FormSection>
|
||||
<Forms.FormTitle>{wordsToTitle(wordsFromCamel(name))}</Forms.FormTitle>
|
||||
<Forms.FormText className={Margins.bottom20} type="description">{description}</Forms.FormText>
|
||||
{children}
|
||||
{error && <Forms.FormText style={{ color: "var(--text-danger)" }}>{error}</Forms.FormText>}
|
||||
</Forms.FormSection>
|
||||
);
|
||||
}
|
||||
|
|
@ -18,8 +18,8 @@
|
|||
|
||||
import { PluginOptionComponent } from "@utils/types";
|
||||
|
||||
import { ISettingCustomElementProps } from ".";
|
||||
import { ComponentSettingProps } from "./Common";
|
||||
|
||||
export function SettingCustomComponent({ option, onChange, onError }: ISettingCustomElementProps<PluginOptionComponent>) {
|
||||
return option.component({ setValue: onChange, setError: onError, option });
|
||||
export function ComponentSetting({ option, onChange }: ComponentSettingProps<PluginOptionComponent>) {
|
||||
return option.component({ setValue: onChange, option });
|
||||
}
|
||||
|
|
@ -16,48 +16,40 @@
|
|||
* along with this program. If not, see <https://www.gnu.org/licenses/>.
|
||||
*/
|
||||
|
||||
import { Margins } from "@utils/margins";
|
||||
import { wordsFromCamel, wordsToTitle } from "@utils/text";
|
||||
import { OptionType, PluginOptionNumber } from "@utils/types";
|
||||
import { Forms, React, TextInput } from "@webpack/common";
|
||||
import { React, TextInput, useState } from "@webpack/common";
|
||||
|
||||
import { ISettingElementProps } from ".";
|
||||
import { resolveError, SettingProps, SettingsSection } from "./Common";
|
||||
|
||||
const MAX_SAFE_NUMBER = BigInt(Number.MAX_SAFE_INTEGER);
|
||||
|
||||
export function SettingNumericComponent({ option, pluginSettings, definedSettings, id, onChange, onError }: ISettingElementProps<PluginOptionNumber>) {
|
||||
export function NumberSetting({ option, pluginSettings, definedSettings, id, onChange }: SettingProps<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);
|
||||
const [state, setState] = useState<any>(`${pluginSettings[id] ?? option.default ?? 0}`);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
|
||||
React.useEffect(() => {
|
||||
onError(error !== null);
|
||||
}, [error]);
|
||||
|
||||
function handleChange(newValue) {
|
||||
function handleChange(newValue: any) {
|
||||
const isValid = option.isValid?.call(definedSettings, newValue) ?? true;
|
||||
|
||||
setError(null);
|
||||
if (typeof isValid === "string") setError(isValid);
|
||||
else if (!isValid) setError("Invalid input provided.");
|
||||
setError(resolveError(isValid));
|
||||
|
||||
if (isValid === true) {
|
||||
onChange(serialize(newValue));
|
||||
}
|
||||
|
||||
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 (
|
||||
<Forms.FormSection>
|
||||
<Forms.FormTitle>{wordsToTitle(wordsFromCamel(id))}</Forms.FormTitle>
|
||||
<Forms.FormText className={Margins.bottom20} type="description">{option.description}</Forms.FormText>
|
||||
<SettingsSection name={id} description={option.description} error={error}>
|
||||
<TextInput
|
||||
type="number"
|
||||
pattern="-?[0-9]+"
|
||||
|
|
@ -67,7 +59,6 @@ export function SettingNumericComponent({ option, pluginSettings, definedSetting
|
|||
disabled={option.disabled?.call(definedSettings) ?? false}
|
||||
{...option.componentProps}
|
||||
/>
|
||||
{error && <Forms.FormText style={{ color: "var(--text-danger)" }}>{error}</Forms.FormText>}
|
||||
</Forms.FormSection>
|
||||
</SettingsSection>
|
||||
);
|
||||
}
|
||||
|
|
@ -16,38 +16,30 @@
|
|||
* along with this program. If not, see <https://www.gnu.org/licenses/>.
|
||||
*/
|
||||
|
||||
import { Margins } from "@utils/margins";
|
||||
import { wordsFromCamel, wordsToTitle } from "@utils/text";
|
||||
import { PluginOptionSelect } from "@utils/types";
|
||||
import { Forms, React, Select } from "@webpack/common";
|
||||
import { React, Select, useState } from "@webpack/common";
|
||||
|
||||
import { ISettingElementProps } from ".";
|
||||
import { resolveError, SettingProps, SettingsSection } from "./Common";
|
||||
|
||||
export function SettingSelectComponent({ option, pluginSettings, definedSettings, onChange, onError, id }: ISettingElementProps<PluginOptionSelect>) {
|
||||
export function SelectSetting({ option, pluginSettings, definedSettings, onChange, id }: SettingProps<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);
|
||||
const [state, setState] = useState<any>(def ?? null);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
|
||||
React.useEffect(() => {
|
||||
onError(error !== null);
|
||||
}, [error]);
|
||||
|
||||
function handleChange(newValue) {
|
||||
function handleChange(newValue: any) {
|
||||
const isValid = option.isValid?.call(definedSettings, newValue) ?? true;
|
||||
if (typeof isValid === "string") setError(isValid);
|
||||
else if (!isValid) setError("Invalid input provided.");
|
||||
else {
|
||||
setError(null);
|
||||
|
||||
setState(newValue);
|
||||
setError(resolveError(isValid));
|
||||
|
||||
if (isValid === true) {
|
||||
onChange(newValue);
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<Forms.FormSection>
|
||||
<Forms.FormTitle>{wordsToTitle(wordsFromCamel(id))}</Forms.FormTitle>
|
||||
<Forms.FormText className={Margins.bottom16} type="description">{option.description}</Forms.FormText>
|
||||
<SettingsSection name={id} description={option.description} error={error}>
|
||||
<Select
|
||||
isDisabled={option.disabled?.call(definedSettings) ?? false}
|
||||
options={option.options}
|
||||
|
|
@ -59,7 +51,6 @@ export function SettingSelectComponent({ option, pluginSettings, definedSettings
|
|||
serialize={v => String(v)}
|
||||
{...option.componentProps}
|
||||
/>
|
||||
{error && <Forms.FormText style={{ color: "var(--text-danger)" }}>{error}</Forms.FormText>}
|
||||
</Forms.FormSection>
|
||||
</SettingsSection>
|
||||
);
|
||||
}
|
||||
|
|
@ -16,44 +16,28 @@
|
|||
* along with this program. If not, see <https://www.gnu.org/licenses/>.
|
||||
*/
|
||||
|
||||
import { Margins } from "@utils/margins";
|
||||
import { wordsFromCamel, wordsToTitle } from "@utils/text";
|
||||
import { PluginOptionSlider } from "@utils/types";
|
||||
import { Forms, React, Slider } from "@webpack/common";
|
||||
import { React, Slider, useState } from "@webpack/common";
|
||||
|
||||
import { ISettingElementProps } from ".";
|
||||
import { resolveError, SettingProps, SettingsSection } from "./Common";
|
||||
|
||||
export function makeRange(start: number, end: number, step = 1) {
|
||||
const ranges: number[] = [];
|
||||
for (let value = start; value <= end; value += step) {
|
||||
ranges.push(Math.round(value * 100) / 100);
|
||||
}
|
||||
return ranges;
|
||||
}
|
||||
|
||||
export function SettingSliderComponent({ option, pluginSettings, definedSettings, id, onChange, onError }: ISettingElementProps<PluginOptionSlider>) {
|
||||
export function SliderSetting({ option, pluginSettings, definedSettings, id, onChange }: SettingProps<PluginOptionSlider>) {
|
||||
const def = pluginSettings[id] ?? option.default;
|
||||
|
||||
const [error, setError] = React.useState<string | null>(null);
|
||||
|
||||
React.useEffect(() => {
|
||||
onError(error !== null);
|
||||
}, [error]);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
|
||||
function handleChange(newValue: number): void {
|
||||
const isValid = option.isValid?.call(definedSettings, newValue) ?? true;
|
||||
if (typeof isValid === "string") setError(isValid);
|
||||
else if (!isValid) setError("Invalid input provided.");
|
||||
else {
|
||||
setError(null);
|
||||
|
||||
setError(resolveError(isValid));
|
||||
|
||||
if (isValid === true) {
|
||||
onChange(newValue);
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<Forms.FormSection>
|
||||
<Forms.FormTitle>{wordsToTitle(wordsFromCamel(id))}</Forms.FormTitle>
|
||||
<Forms.FormText className={Margins.bottom20} type="description">{option.description}</Forms.FormText>
|
||||
<SettingsSection name={id} description={option.description} error={error}>
|
||||
<Slider
|
||||
disabled={option.disabled?.call(definedSettings) ?? false}
|
||||
markers={option.markers}
|
||||
|
|
@ -65,7 +49,7 @@ export function SettingSliderComponent({ option, pluginSettings, definedSettings
|
|||
stickToMarkers={option.stickToMarkers ?? true}
|
||||
{...option.componentProps}
|
||||
/>
|
||||
</Forms.FormSection>
|
||||
</SettingsSection>
|
||||
);
|
||||
}
|
||||
|
||||
|
|
@ -16,35 +16,28 @@
|
|||
* along with this program. If not, see <https://www.gnu.org/licenses/>.
|
||||
*/
|
||||
|
||||
import { Margins } from "@utils/margins";
|
||||
import { wordsFromCamel, wordsToTitle } from "@utils/text";
|
||||
import { PluginOptionString } from "@utils/types";
|
||||
import { Forms, React, TextInput } from "@webpack/common";
|
||||
import { React, TextInput, useState } from "@webpack/common";
|
||||
|
||||
import { ISettingElementProps } from ".";
|
||||
import { resolveError, SettingProps, SettingsSection } from "./Common";
|
||||
|
||||
export function SettingTextComponent({ option, pluginSettings, definedSettings, id, onChange, onError }: ISettingElementProps<PluginOptionString>) {
|
||||
const [state, setState] = React.useState(pluginSettings[id] ?? option.default ?? null);
|
||||
const [error, setError] = React.useState<string | null>(null);
|
||||
export function TextSetting({ option, pluginSettings, definedSettings, id, onChange }: SettingProps<PluginOptionString>) {
|
||||
const [state, setState] = useState(pluginSettings[id] ?? option.default ?? null);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
|
||||
React.useEffect(() => {
|
||||
onError(error !== null);
|
||||
}, [error]);
|
||||
|
||||
function handleChange(newValue) {
|
||||
function handleChange(newValue: string) {
|
||||
const isValid = option.isValid?.call(definedSettings, newValue) ?? true;
|
||||
if (typeof isValid === "string") setError(isValid);
|
||||
else if (!isValid) setError("Invalid input provided.");
|
||||
else setError(null);
|
||||
|
||||
setState(newValue);
|
||||
setError(resolveError(isValid));
|
||||
|
||||
if (isValid === true) {
|
||||
onChange(newValue);
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<Forms.FormSection>
|
||||
<Forms.FormTitle>{wordsToTitle(wordsFromCamel(id))}</Forms.FormTitle>
|
||||
<Forms.FormText className={Margins.bottom20} type="description">{option.description}</Forms.FormText>
|
||||
<SettingsSection name={id} description={option.description} error={error}>
|
||||
<TextInput
|
||||
type="text"
|
||||
value={state}
|
||||
|
|
@ -54,7 +47,6 @@ export function SettingTextComponent({ option, pluginSettings, definedSettings,
|
|||
maxLength={null}
|
||||
{...option.componentProps}
|
||||
/>
|
||||
{error && <Forms.FormText style={{ color: "var(--text-danger)" }}>{error}</Forms.FormText>}
|
||||
</Forms.FormSection>
|
||||
</SettingsSection>
|
||||
);
|
||||
}
|
||||
39
src/components/settings/tabs/plugins/components/index.ts
Normal file
39
src/components/settings/tabs/plugins/components/index.ts
Normal file
|
|
@ -0,0 +1,39 @@
|
|||
/*
|
||||
* Vencord, a modification for Discord's desktop app
|
||||
* Copyright (c) 2022 Vendicated and contributors
|
||||
*
|
||||
* This program is free software: you can redistribute it and/or modify
|
||||
* it under the terms of the GNU General Public License as published by
|
||||
* the Free Software Foundation, either version 3 of the License, or
|
||||
* (at your option) any later version.
|
||||
*
|
||||
* This program is distributed in the hope that it will be useful,
|
||||
* but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
||||
* GNU General Public License for more details.
|
||||
*
|
||||
* You should have received a copy of the GNU General Public License
|
||||
* along with this program. If not, see <https://www.gnu.org/licenses/>.
|
||||
*/
|
||||
|
||||
import { OptionType } from "@utils/types";
|
||||
import { ComponentType } from "react";
|
||||
|
||||
import { BooleanSetting } from "./BooleanSetting";
|
||||
import { ComponentSettingProps, SettingProps } from "./Common";
|
||||
import { ComponentSetting } from "./ComponentSetting";
|
||||
import { NumberSetting } from "./NumberSetting";
|
||||
import { SelectSetting } from "./SelectSetting";
|
||||
import { SliderSetting } from "./SliderSetting";
|
||||
import { TextSetting } from "./TextSetting";
|
||||
|
||||
export const OptionComponentMap: Record<OptionType, ComponentType<SettingProps<any> | ComponentSettingProps<any>>> = {
|
||||
[OptionType.STRING]: TextSetting,
|
||||
[OptionType.NUMBER]: NumberSetting,
|
||||
[OptionType.BIGINT]: NumberSetting,
|
||||
[OptionType.BOOLEAN]: BooleanSetting,
|
||||
[OptionType.SELECT]: SelectSetting,
|
||||
[OptionType.SLIDER]: SliderSetting,
|
||||
[OptionType.COMPONENT]: ComponentSetting,
|
||||
[OptionType.CUSTOM]: () => null,
|
||||
};
|
||||
|
|
@ -19,51 +19,32 @@
|
|||
import "./styles.css";
|
||||
|
||||
import * as DataStore from "@api/DataStore";
|
||||
import { showNotice } from "@api/Notices";
|
||||
import { Settings, useSettings } from "@api/Settings";
|
||||
import { useSettings } from "@api/Settings";
|
||||
import { classNameFactory } from "@api/Styles";
|
||||
import { CogWheel, InfoIcon } from "@components/Icons";
|
||||
import { openPluginModal } from "@components/PluginSettings/PluginModal";
|
||||
import { AddonCard } from "@components/VencordSettings/AddonCard";
|
||||
import { SettingsTab } from "@components/VencordSettings/shared";
|
||||
import { SettingsTab, wrapTab } from "@components/settings/tabs/BaseTab";
|
||||
import { ChangeList } from "@utils/ChangeList";
|
||||
import { proxyLazy } from "@utils/lazy";
|
||||
import { Logger } from "@utils/Logger";
|
||||
import { Margins } from "@utils/margins";
|
||||
import { classes, isObjectEmpty } from "@utils/misc";
|
||||
import { useAwaiter } from "@utils/react";
|
||||
import { Plugin } from "@utils/types";
|
||||
import { classes } from "@utils/misc";
|
||||
import { useAwaiter, useCleanupEffect } from "@utils/react";
|
||||
import { findByPropsLazy } from "@webpack";
|
||||
import { Alerts, Button, Card, Forms, lodash, Parser, React, Select, Text, TextInput, Toasts, Tooltip, useMemo } from "@webpack/common";
|
||||
import { Alerts, Button, Card, Forms, lodash, Parser, React, Select, Text, TextInput, Tooltip, useMemo, useState } from "@webpack/common";
|
||||
import { JSX } from "react";
|
||||
|
||||
import Plugins, { ExcludedPlugins } from "~plugins";
|
||||
|
||||
// Avoid circular dependency
|
||||
const { startDependenciesRecursive, startPlugin, stopPlugin } = proxyLazy(() => require("../../plugins"));
|
||||
import { PluginCard } from "./PluginCard";
|
||||
|
||||
const cl = classNameFactory("vc-plugins-");
|
||||
const logger = new Logger("PluginSettings", "#a6d189");
|
||||
export const cl = classNameFactory("vc-plugins-");
|
||||
export const logger = new Logger("PluginSettings", "#a6d189");
|
||||
|
||||
const InputStyles = findByPropsLazy("inputWrapper", "inputError", "error");
|
||||
const ButtonClasses = findByPropsLazy("button", "disabled", "enabled");
|
||||
|
||||
|
||||
function showErrorToast(message: string) {
|
||||
Toasts.show({
|
||||
message,
|
||||
type: Toasts.Type.FAILURE,
|
||||
id: Toasts.genId(),
|
||||
options: {
|
||||
position: Toasts.Position.BOTTOM
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
function ReloadRequiredCard({ required }: { required: boolean; }) {
|
||||
return (
|
||||
<Card className={classes(cl("info-card"), required && "vc-warning-card")}>
|
||||
{required ? (
|
||||
{required
|
||||
? (
|
||||
<>
|
||||
<Forms.FormTitle tag="h5">Restart required!</Forms.FormTitle>
|
||||
<Forms.FormText className={cl("dep-text")}>
|
||||
|
|
@ -73,7 +54,8 @@ function ReloadRequiredCard({ required }: { required: boolean; }) {
|
|||
Restart
|
||||
</Button>
|
||||
</>
|
||||
) : (
|
||||
)
|
||||
: (
|
||||
<>
|
||||
<Forms.FormTitle tag="h5">Plugin Management</Forms.FormTitle>
|
||||
<Forms.FormText>Press the cog wheel or info icon to get more info on a plugin</Forms.FormText>
|
||||
|
|
@ -84,88 +66,6 @@ function ReloadRequiredCard({ required }: { required: boolean; }) {
|
|||
);
|
||||
}
|
||||
|
||||
interface PluginCardProps extends React.HTMLProps<HTMLDivElement> {
|
||||
plugin: Plugin;
|
||||
disabled: boolean;
|
||||
onRestartNeeded(name: string): void;
|
||||
isNew?: boolean;
|
||||
}
|
||||
|
||||
export function PluginCard({ plugin, disabled, onRestartNeeded, onMouseEnter, onMouseLeave, isNew }: PluginCardProps) {
|
||||
const settings = Settings.plugins[plugin.name];
|
||||
|
||||
const isEnabled = () => Vencord.Plugins.isPluginEnabled(plugin.name);
|
||||
|
||||
function toggleEnabled() {
|
||||
const wasEnabled = isEnabled();
|
||||
|
||||
// If we're enabling a plugin, make sure all deps are enabled recursively.
|
||||
if (!wasEnabled) {
|
||||
const { restartNeeded, failures } = startDependenciesRecursive(plugin);
|
||||
if (failures.length) {
|
||||
logger.error(`Failed to start dependencies for ${plugin.name}: ${failures.join(", ")}`);
|
||||
showNotice("Failed to start dependencies: " + failures.join(", "), "Close", () => null);
|
||||
return;
|
||||
} else if (restartNeeded) {
|
||||
// If any dependencies have patches, don't start the plugin yet.
|
||||
settings.enabled = true;
|
||||
onRestartNeeded(plugin.name);
|
||||
return;
|
||||
}
|
||||
}
|
||||
|
||||
// if the plugin has patches, dont use stopPlugin/startPlugin. Wait for restart to apply changes.
|
||||
if (plugin.patches?.length) {
|
||||
settings.enabled = !wasEnabled;
|
||||
onRestartNeeded(plugin.name);
|
||||
return;
|
||||
}
|
||||
|
||||
// If the plugin is enabled, but hasn't been started, then we can just toggle it off.
|
||||
if (wasEnabled && !plugin.started) {
|
||||
settings.enabled = !wasEnabled;
|
||||
return;
|
||||
}
|
||||
|
||||
const result = wasEnabled ? stopPlugin(plugin) : startPlugin(plugin);
|
||||
|
||||
if (!result) {
|
||||
settings.enabled = false;
|
||||
|
||||
const msg = `Error while ${wasEnabled ? "stopping" : "starting"} plugin ${plugin.name}`;
|
||||
logger.error(msg);
|
||||
showErrorToast(msg);
|
||||
return;
|
||||
}
|
||||
|
||||
settings.enabled = !wasEnabled;
|
||||
}
|
||||
|
||||
return (
|
||||
<AddonCard
|
||||
name={plugin.name}
|
||||
description={plugin.description}
|
||||
isNew={isNew}
|
||||
enabled={isEnabled()}
|
||||
setEnabled={toggleEnabled}
|
||||
disabled={disabled}
|
||||
onMouseEnter={onMouseEnter}
|
||||
onMouseLeave={onMouseLeave}
|
||||
infoButton={
|
||||
<button
|
||||
role="switch"
|
||||
onClick={() => openPluginModal(plugin, onRestartNeeded)}
|
||||
className={classes(ButtonClasses.button, cl("info-button"))}
|
||||
>
|
||||
{plugin.options && !isObjectEmpty(plugin.options)
|
||||
? <CogWheel className={cl("info-icon")} />
|
||||
: <InfoIcon className={cl("info-icon")} />}
|
||||
</button>
|
||||
}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
const enum SearchStatus {
|
||||
ALL,
|
||||
ENABLED,
|
||||
|
|
@ -204,12 +104,13 @@ function ExcludedPluginsList({ search }: { search: string; }) {
|
|||
);
|
||||
}
|
||||
|
||||
export default function PluginSettings() {
|
||||
function PluginSettings() {
|
||||
const settings = useSettings();
|
||||
const changes = React.useMemo(() => new ChangeList<string>(), []);
|
||||
const changes = useMemo(() => new ChangeList<string>(), []);
|
||||
|
||||
React.useEffect(() => {
|
||||
return () => void (changes.hasChanges && Alerts.show({
|
||||
useCleanupEffect(() => {
|
||||
if (changes.hasChanges)
|
||||
Alerts.show({
|
||||
title: "Restart required",
|
||||
body: (
|
||||
<>
|
||||
|
|
@ -217,7 +118,7 @@ export default function PluginSettings() {
|
|||
<div>{changes.map((s, i) => (
|
||||
<>
|
||||
{i > 0 && ", "}
|
||||
{Parser.parse("`" + s + "`")}
|
||||
{Parser.parse("`" + s.split(".")[0] + "`")}
|
||||
</>
|
||||
))}</div>
|
||||
</>
|
||||
|
|
@ -225,10 +126,10 @@ export default function PluginSettings() {
|
|||
confirmText: "Restart now",
|
||||
cancelText: "Later!",
|
||||
onConfirm: () => location.reload()
|
||||
}));
|
||||
});
|
||||
}, []);
|
||||
|
||||
const depMap = React.useMemo(() => {
|
||||
const depMap = useMemo(() => {
|
||||
const o = {} as Record<string, string[]>;
|
||||
for (const plugin in Plugins) {
|
||||
const deps = Plugins[plugin].dependencies;
|
||||
|
|
@ -242,10 +143,12 @@ export default function PluginSettings() {
|
|||
return o;
|
||||
}, []);
|
||||
|
||||
const sortedPlugins = useMemo(() => Object.values(Plugins)
|
||||
.sort((a, b) => a.name.localeCompare(b.name)), []);
|
||||
const sortedPlugins = useMemo(() =>
|
||||
Object.values(Plugins).sort((a, b) => a.name.localeCompare(b.name)),
|
||||
[]
|
||||
);
|
||||
|
||||
const [searchValue, setSearchValue] = React.useState({ value: "", status: SearchStatus.ALL });
|
||||
const [searchValue, setSearchValue] = useState({ value: "", status: SearchStatus.ALL });
|
||||
|
||||
const search = searchValue.value.toLowerCase();
|
||||
const onSearch = (query: string) => setSearchValue(prev => ({ ...prev, value: query }));
|
||||
|
|
@ -254,9 +157,19 @@ export default function PluginSettings() {
|
|||
const pluginFilter = (plugin: typeof Plugins[keyof typeof Plugins]) => {
|
||||
const { status } = searchValue;
|
||||
const enabled = Vencord.Plugins.isPluginEnabled(plugin.name);
|
||||
if (enabled && status === SearchStatus.DISABLED) return false;
|
||||
if (!enabled && status === SearchStatus.ENABLED) return false;
|
||||
if (status === SearchStatus.NEW && !newPlugins?.includes(plugin.name)) return false;
|
||||
|
||||
switch (status) {
|
||||
case SearchStatus.DISABLED:
|
||||
if (enabled) return false;
|
||||
break;
|
||||
case SearchStatus.ENABLED:
|
||||
if (!enabled) return false;
|
||||
break;
|
||||
case SearchStatus.NEW:
|
||||
if (!newPlugins?.includes(plugin.name)) return false;
|
||||
break;
|
||||
}
|
||||
|
||||
if (!search.length) return true;
|
||||
|
||||
return (
|
||||
|
|
@ -306,7 +219,7 @@ export default function PluginSettings() {
|
|||
<PluginCard
|
||||
onMouseLeave={onMouseLeave}
|
||||
onMouseEnter={onMouseEnter}
|
||||
onRestartNeeded={name => changes.handleChange(name)}
|
||||
onRestartNeeded={(name, key) => changes.handleChange(`${name}.${key}`)}
|
||||
disabled={true}
|
||||
plugin={p}
|
||||
key={p.name}
|
||||
|
|
@ -317,7 +230,7 @@ export default function PluginSettings() {
|
|||
} else {
|
||||
plugins.push(
|
||||
<PluginCard
|
||||
onRestartNeeded={name => changes.handleChange(name)}
|
||||
onRestartNeeded={(name, key) => changes.handleChange(`${name}.${key}`)}
|
||||
disabled={false}
|
||||
plugin={p}
|
||||
isNew={newPlugins?.includes(p.name)}
|
||||
|
|
@ -386,9 +299,11 @@ export default function PluginSettings() {
|
|||
|
||||
function makeDependencyList(deps: string[]) {
|
||||
return (
|
||||
<React.Fragment>
|
||||
<>
|
||||
<Forms.FormText>This plugin is required by:</Forms.FormText>
|
||||
{deps.map((dep: string) => <Forms.FormText key={dep} className={cl("dep-text")}>{dep}</Forms.FormText>)}
|
||||
</React.Fragment>
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
export default wrapTab(PluginSettings, "Plugins");
|
||||
|
|
@ -42,7 +42,7 @@
|
|||
grid-template-columns: 1fr 150px;
|
||||
}
|
||||
|
||||
.vc-plugins-badge {
|
||||
.vc-addon-badge {
|
||||
padding: 0 6px;
|
||||
font-family: var(--font-display);
|
||||
font-weight: 500;
|
||||
|
|
@ -17,14 +17,13 @@
|
|||
*/
|
||||
|
||||
import { Flex } from "@components/Flex";
|
||||
import { SettingsTab, wrapTab } from "@components/settings/tabs/BaseTab";
|
||||
import { Margins } from "@utils/margins";
|
||||
import { classes } from "@utils/misc";
|
||||
import { downloadSettingsBackup, uploadSettingsBackup } from "@utils/settingsSync";
|
||||
import { Button, Card, Text } from "@webpack/common";
|
||||
|
||||
import { SettingsTab, wrapTab } from "./shared";
|
||||
|
||||
function BackupRestoreTab() {
|
||||
function BackupAndRestoreTab() {
|
||||
return (
|
||||
<SettingsTab title="Backup & Restore">
|
||||
<Card className={classes("vc-settings-card", "vc-backup-restore-card")}>
|
||||
|
|
@ -64,4 +63,4 @@ function BackupRestoreTab() {
|
|||
);
|
||||
}
|
||||
|
||||
export default wrapTab(BackupRestoreTab, "Backup & Restore");
|
||||
export default wrapTab(BackupAndRestoreTab, "Backup & Restore");
|
||||
|
|
@ -21,13 +21,12 @@ import { Settings, useSettings } from "@api/Settings";
|
|||
import { CheckedTextInput } from "@components/CheckedTextInput";
|
||||
import { Grid } from "@components/Grid";
|
||||
import { Link } from "@components/Link";
|
||||
import { SettingsTab, wrapTab } from "@components/settings/tabs/BaseTab";
|
||||
import { authorizeCloud, checkCloudUrlCsp, cloudLogger, deauthorizeCloud, getCloudAuth, getCloudUrl } from "@utils/cloud";
|
||||
import { Margins } from "@utils/margins";
|
||||
import { deleteCloudSettings, getCloudSettings, putCloudSettings } from "@utils/settingsSync";
|
||||
import { Alerts, Button, Forms, Switch, Tooltip } from "@webpack/common";
|
||||
|
||||
import { SettingsTab, wrapTab } from "./shared";
|
||||
|
||||
function validateUrl(url: string) {
|
||||
try {
|
||||
new URL(url);
|
||||
86
src/components/settings/tabs/themes/CspErrorCard.tsx
Normal file
86
src/components/settings/tabs/themes/CspErrorCard.tsx
Normal file
|
|
@ -0,0 +1,86 @@
|
|||
/*
|
||||
* Vencord, a Discord client mod
|
||||
* Copyright (c) 2025 Vendicated and contributors
|
||||
* SPDX-License-Identifier: GPL-3.0-or-later
|
||||
*/
|
||||
|
||||
import { ErrorCard } from "@components/ErrorCard";
|
||||
import { Link } from "@components/Link";
|
||||
import { CspBlockedUrls, useCspErrors } from "@utils/cspViolations";
|
||||
import { Margins } from "@utils/margins";
|
||||
import { classes } from "@utils/misc";
|
||||
import { relaunch } from "@utils/native";
|
||||
import { useForceUpdater } from "@utils/react";
|
||||
import { Alerts, Button, Forms } from "@webpack/common";
|
||||
|
||||
export function CspErrorCard() {
|
||||
if (IS_WEB) return null;
|
||||
|
||||
const errors = useCspErrors();
|
||||
const forceUpdate = useForceUpdater();
|
||||
|
||||
if (!errors.length) return null;
|
||||
|
||||
const isImgurHtmlDomain = (url: string) => url.startsWith("https://imgur.com/");
|
||||
|
||||
const allowUrl = async (url: string) => {
|
||||
const { origin: baseUrl, host } = new URL(url);
|
||||
|
||||
const result = await VencordNative.csp.requestAddOverride(baseUrl, ["connect-src", "img-src", "style-src", "font-src"], "Vencord Themes");
|
||||
if (result !== "ok") return;
|
||||
|
||||
CspBlockedUrls.forEach(url => {
|
||||
if (new URL(url).host === host) {
|
||||
CspBlockedUrls.delete(url);
|
||||
}
|
||||
});
|
||||
|
||||
forceUpdate();
|
||||
|
||||
Alerts.show({
|
||||
title: "Restart Required",
|
||||
body: "A restart is required to apply this change",
|
||||
confirmText: "Restart now",
|
||||
cancelText: "Later!",
|
||||
onConfirm: relaunch
|
||||
});
|
||||
};
|
||||
|
||||
const hasImgurHtmlDomain = errors.some(isImgurHtmlDomain);
|
||||
|
||||
return (
|
||||
<ErrorCard className="vc-settings-card">
|
||||
<Forms.FormTitle tag="h5">Blocked Resources</Forms.FormTitle>
|
||||
<Forms.FormText>Some images, styles, or fonts were blocked because they come from disallowed domains.</Forms.FormText>
|
||||
<Forms.FormText>It is highly recommended to move them to GitHub or Imgur. But you may also allow domains if you fully trust them.</Forms.FormText>
|
||||
<Forms.FormText>
|
||||
After allowing a domain, you have to fully close (from tray / task manager) and restart {IS_DISCORD_DESKTOP ? "Discord" : "Vesktop"} to apply the change.
|
||||
</Forms.FormText>
|
||||
|
||||
<Forms.FormTitle tag="h5" className={classes(Margins.top16, Margins.bottom8)}>Blocked URLs</Forms.FormTitle>
|
||||
<div className="vc-settings-csp-list">
|
||||
{errors.map((url, i) => (
|
||||
<div key={url}>
|
||||
{i !== 0 && <Forms.FormDivider className={Margins.bottom8} />}
|
||||
<div className="vc-settings-csp-row">
|
||||
<Link href={url}>{url}</Link>
|
||||
<Button color={Button.Colors.PRIMARY} onClick={() => allowUrl(url)} disabled={isImgurHtmlDomain(url)}>
|
||||
Allow
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
|
||||
{hasImgurHtmlDomain && (
|
||||
<>
|
||||
<Forms.FormDivider className={classes(Margins.top8, Margins.bottom16)} />
|
||||
<Forms.FormText>
|
||||
Imgur links should be direct links in the form of <code>https://i.imgur.com/...</code>
|
||||
</Forms.FormText>
|
||||
<Forms.FormText>To obtain a direct link, right-click the image and select "Copy image address".</Forms.FormText>
|
||||
</>
|
||||
)}
|
||||
</ErrorCard>
|
||||
);
|
||||
}
|
||||
170
src/components/settings/tabs/themes/LocalThemesTab.tsx
Normal file
170
src/components/settings/tabs/themes/LocalThemesTab.tsx
Normal file
|
|
@ -0,0 +1,170 @@
|
|||
/*
|
||||
* Vencord, a Discord client mod
|
||||
* Copyright (c) 2025 Vendicated and contributors
|
||||
* SPDX-License-Identifier: GPL-3.0-or-later
|
||||
*/
|
||||
|
||||
import { Settings, useSettings } from "@api/Settings";
|
||||
import { classNameFactory } from "@api/Styles";
|
||||
import { FolderIcon, PaintbrushIcon, PencilIcon, PlusIcon, RestartIcon } from "@components/Icons";
|
||||
import { Link } from "@components/Link";
|
||||
import { QuickAction, QuickActionCard } from "@components/settings/QuickAction";
|
||||
import { openPluginModal } from "@components/settings/tabs/plugins/PluginModal";
|
||||
import { UserThemeHeader } from "@main/themes";
|
||||
import { findLazy } from "@webpack";
|
||||
import { Card, Forms, useEffect, useRef, useState } from "@webpack/common";
|
||||
import ClientThemePlugin from "plugins/clientTheme";
|
||||
import type { ComponentType, Ref, SyntheticEvent } from "react";
|
||||
|
||||
import { ThemeCard } from "./ThemeCard";
|
||||
|
||||
const cl = classNameFactory("vc-settings-theme-");
|
||||
|
||||
type FileInput = ComponentType<{
|
||||
ref: Ref<HTMLInputElement>;
|
||||
onChange: (e: SyntheticEvent<HTMLInputElement>) => void;
|
||||
multiple?: boolean;
|
||||
filters?: { name?: string; extensions: string[]; }[];
|
||||
}>;
|
||||
|
||||
const FileInput: FileInput = findLazy(m => m.prototype?.activateUploadDialogue && m.prototype.setRef);
|
||||
|
||||
// When a local theme is enabled/disabled, update the settings
|
||||
function onLocalThemeChange(fileName: string, value: boolean) {
|
||||
if (value) {
|
||||
if (Settings.enabledThemes.includes(fileName)) return;
|
||||
Settings.enabledThemes = [...Settings.enabledThemes, fileName];
|
||||
} else {
|
||||
Settings.enabledThemes = Settings.enabledThemes.filter(f => f !== fileName);
|
||||
}
|
||||
}
|
||||
|
||||
async function onFileUpload(e: SyntheticEvent<HTMLInputElement>) {
|
||||
e.stopPropagation();
|
||||
e.preventDefault();
|
||||
|
||||
if (!e.currentTarget?.files?.length) return;
|
||||
const { files } = e.currentTarget;
|
||||
|
||||
const uploads = Array.from(files, file => {
|
||||
const { name } = file;
|
||||
if (!name.endsWith(".css")) return;
|
||||
|
||||
return new Promise<void>((resolve, reject) => {
|
||||
const reader = new FileReader();
|
||||
reader.onload = () => {
|
||||
VencordNative.themes.uploadTheme(name, reader.result as string)
|
||||
.then(resolve)
|
||||
.catch(reject);
|
||||
};
|
||||
reader.readAsText(file);
|
||||
});
|
||||
});
|
||||
|
||||
await Promise.all(uploads);
|
||||
}
|
||||
|
||||
export function LocalThemesTab() {
|
||||
const settings = useSettings(["enabledThemes"]);
|
||||
|
||||
const fileInputRef = useRef<HTMLInputElement>(null);
|
||||
|
||||
const [userThemes, setUserThemes] = useState<UserThemeHeader[] | null>(null);
|
||||
|
||||
useEffect(() => {
|
||||
refreshLocalThemes();
|
||||
}, []);
|
||||
|
||||
async function refreshLocalThemes() {
|
||||
const themes = await VencordNative.themes.getThemesList();
|
||||
setUserThemes(themes);
|
||||
}
|
||||
|
||||
return (
|
||||
<>
|
||||
<Card className="vc-settings-card">
|
||||
<Forms.FormTitle tag="h5">Find Themes:</Forms.FormTitle>
|
||||
<div style={{ marginBottom: ".5em", display: "flex", flexDirection: "column" }}>
|
||||
<Link style={{ marginRight: ".5em" }} href="https://betterdiscord.app/themes">
|
||||
BetterDiscord Themes
|
||||
</Link>
|
||||
<Link href="https://github.com/search?q=discord+theme">GitHub</Link>
|
||||
</div>
|
||||
<Forms.FormText>If using the BD site, click on "Download" and place the downloaded .theme.css file into your themes folder.</Forms.FormText>
|
||||
</Card>
|
||||
|
||||
<Card className="vc-settings-card">
|
||||
<Forms.FormTitle tag="h5">External Resources</Forms.FormTitle>
|
||||
<Forms.FormText>For security reasons, loading resources (styles, fonts, images, ...) from most sites is blocked.</Forms.FormText>
|
||||
<Forms.FormText>Make sure all your assets are hosted on GitHub, GitLab, Codeberg, Imgur, Discord or Google Fonts.</Forms.FormText>
|
||||
</Card>
|
||||
|
||||
<Forms.FormSection title="Local Themes">
|
||||
<QuickActionCard>
|
||||
<>
|
||||
{IS_WEB ?
|
||||
(
|
||||
<QuickAction
|
||||
text={
|
||||
<span style={{ position: "relative" }}>
|
||||
Upload Theme
|
||||
<FileInput
|
||||
ref={fileInputRef}
|
||||
onChange={async e => {
|
||||
await onFileUpload(e);
|
||||
refreshLocalThemes();
|
||||
}}
|
||||
multiple={true}
|
||||
filters={[{ extensions: ["css"] }]}
|
||||
/>
|
||||
</span>
|
||||
}
|
||||
Icon={PlusIcon}
|
||||
/>
|
||||
) : (
|
||||
<QuickAction
|
||||
text="Open Themes Folder"
|
||||
action={() => VencordNative.themes.openFolder()}
|
||||
Icon={FolderIcon}
|
||||
/>
|
||||
)}
|
||||
<QuickAction
|
||||
text="Load missing Themes"
|
||||
action={refreshLocalThemes}
|
||||
Icon={RestartIcon}
|
||||
/>
|
||||
<QuickAction
|
||||
text="Edit QuickCSS"
|
||||
action={() => VencordNative.quickCss.openEditor()}
|
||||
Icon={PaintbrushIcon}
|
||||
/>
|
||||
|
||||
{Vencord.Plugins.isPluginEnabled(ClientThemePlugin.name) && (
|
||||
<QuickAction
|
||||
text="Edit ClientTheme"
|
||||
action={() => openPluginModal(ClientThemePlugin)}
|
||||
Icon={PencilIcon}
|
||||
/>
|
||||
)}
|
||||
</>
|
||||
</QuickActionCard>
|
||||
|
||||
<div className={cl("grid")}>
|
||||
{userThemes?.map(theme => (
|
||||
<ThemeCard
|
||||
key={theme.fileName}
|
||||
enabled={settings.enabledThemes.includes(theme.fileName)}
|
||||
onChange={enabled => onLocalThemeChange(theme.fileName, enabled)}
|
||||
onDelete={async () => {
|
||||
onLocalThemeChange(theme.fileName, false);
|
||||
await VencordNative.themes.deleteTheme(theme.fileName);
|
||||
refreshLocalThemes();
|
||||
}}
|
||||
theme={theme}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
</Forms.FormSection>
|
||||
</>
|
||||
);
|
||||
}
|
||||
56
src/components/settings/tabs/themes/OnlineThemesTab.tsx
Normal file
56
src/components/settings/tabs/themes/OnlineThemesTab.tsx
Normal file
|
|
@ -0,0 +1,56 @@
|
|||
/*
|
||||
* Vencord, a Discord client mod
|
||||
* Copyright (c) 2025 Vendicated and contributors
|
||||
* SPDX-License-Identifier: GPL-3.0-or-later
|
||||
*/
|
||||
|
||||
import { useSettings } from "@api/Settings";
|
||||
import { Margins } from "@utils/margins";
|
||||
import { classes } from "@utils/misc";
|
||||
import { Card, Forms, TextArea, useState } from "@webpack/common";
|
||||
|
||||
export function OnlineThemesTab() {
|
||||
const settings = useSettings(["themeLinks"]);
|
||||
|
||||
const [themeText, setThemeText] = useState(settings.themeLinks.join("\n"));
|
||||
|
||||
// When the user leaves the online theme textbox, update the settings
|
||||
function onBlur() {
|
||||
settings.themeLinks = [...new Set(
|
||||
themeText
|
||||
.trim()
|
||||
.split(/\n+/)
|
||||
.map(s => s.trim())
|
||||
.filter(Boolean)
|
||||
)];
|
||||
}
|
||||
|
||||
return (
|
||||
<>
|
||||
<Card className={classes("vc-warning-card", Margins.bottom16)}>
|
||||
<Forms.FormText>
|
||||
This section is for advanced users. If you are having difficulties using it, use the
|
||||
Local Themes tab instead.
|
||||
</Forms.FormText>
|
||||
</Card>
|
||||
<Card className="vc-settings-card">
|
||||
<Forms.FormTitle tag="h5">Paste links to css files here</Forms.FormTitle>
|
||||
<Forms.FormText>One link per line</Forms.FormText>
|
||||
<Forms.FormText>You can prefix lines with @light or @dark to toggle them based on your Discord theme</Forms.FormText>
|
||||
<Forms.FormText>Make sure to use direct links to files (raw or github.io)!</Forms.FormText>
|
||||
</Card>
|
||||
|
||||
<Forms.FormSection title="Online Themes" tag="h5">
|
||||
<TextArea
|
||||
value={themeText}
|
||||
onChange={setThemeText}
|
||||
className={"vc-settings-theme-links"}
|
||||
placeholder="Enter Theme Links..."
|
||||
spellCheck={false}
|
||||
onBlur={onBlur}
|
||||
rows={10}
|
||||
/>
|
||||
</Forms.FormSection>
|
||||
</>
|
||||
);
|
||||
}
|
||||
56
src/components/settings/tabs/themes/ThemeCard.tsx
Normal file
56
src/components/settings/tabs/themes/ThemeCard.tsx
Normal file
|
|
@ -0,0 +1,56 @@
|
|||
/*
|
||||
* Vencord, a Discord client mod
|
||||
* Copyright (c) 2025 Vendicated and contributors
|
||||
* SPDX-License-Identifier: GPL-3.0-or-later
|
||||
*/
|
||||
|
||||
import { Flex } from "@components/Flex";
|
||||
import { DeleteIcon } from "@components/Icons";
|
||||
import { Link } from "@components/Link";
|
||||
import { AddonCard } from "@components/settings/AddonCard";
|
||||
import { UserThemeHeader } from "@main/themes";
|
||||
import { openInviteModal } from "@utils/discord";
|
||||
import { showToast } from "@webpack/common";
|
||||
|
||||
interface ThemeCardProps {
|
||||
theme: UserThemeHeader;
|
||||
enabled: boolean;
|
||||
onChange: (enabled: boolean) => void;
|
||||
onDelete: () => void;
|
||||
}
|
||||
|
||||
export function ThemeCard({ theme, enabled, onChange, onDelete }: ThemeCardProps) {
|
||||
return (
|
||||
<AddonCard
|
||||
name={theme.name}
|
||||
description={theme.description}
|
||||
author={theme.author}
|
||||
enabled={enabled}
|
||||
setEnabled={onChange}
|
||||
infoButton={
|
||||
IS_WEB && (
|
||||
<div style={{ cursor: "pointer", color: "var(--status-danger" }} onClick={onDelete}>
|
||||
<DeleteIcon />
|
||||
</div>
|
||||
)
|
||||
}
|
||||
footer={
|
||||
<Flex flexDirection="row" style={{ gap: "0.2em" }}>
|
||||
{!!theme.website && <Link href={theme.website}>Website</Link>}
|
||||
{!!(theme.website && theme.invite) && " • "}
|
||||
{!!theme.invite && (
|
||||
<Link
|
||||
href={`https://discord.gg/${theme.invite}`}
|
||||
onClick={async e => {
|
||||
e.preventDefault();
|
||||
theme.invite != null && openInviteModal(theme.invite).catch(() => showToast("Invalid or expired invite"));
|
||||
}}
|
||||
>
|
||||
Discord Server
|
||||
</Link>
|
||||
)}
|
||||
</Flex>
|
||||
}
|
||||
/>
|
||||
);
|
||||
}
|
||||
85
src/components/settings/tabs/themes/index.tsx
Normal file
85
src/components/settings/tabs/themes/index.tsx
Normal file
|
|
@ -0,0 +1,85 @@
|
|||
/*
|
||||
* Vencord, a modification for Discord's desktop app
|
||||
* Copyright (c) 2022 Vendicated and contributors
|
||||
*
|
||||
* This program is free software: you can redistribute it and/or modify
|
||||
* it under the terms of the GNU General Public License as published by
|
||||
* the Free Software Foundation, either version 3 of the License, or
|
||||
* (at your option) any later version.
|
||||
*
|
||||
* This program is distributed in the hope that it will be useful,
|
||||
* but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
||||
* GNU General Public License for more details.
|
||||
*
|
||||
* You should have received a copy of the GNU General Public License
|
||||
* along with this program. If not, see <https://www.gnu.org/licenses/>.
|
||||
*/
|
||||
|
||||
import "./styles.css";
|
||||
|
||||
import { Link } from "@components/Link";
|
||||
import { SettingsTab, wrapTab } from "@components/settings/tabs/BaseTab";
|
||||
import { getStylusWebStoreUrl } from "@utils/web";
|
||||
import { Card, Forms, React, TabBar, useState } from "@webpack/common";
|
||||
|
||||
import { CspErrorCard } from "./CspErrorCard";
|
||||
import { LocalThemesTab } from "./LocalThemesTab";
|
||||
import { OnlineThemesTab } from "./OnlineThemesTab";
|
||||
|
||||
const enum ThemeTab {
|
||||
LOCAL,
|
||||
ONLINE
|
||||
}
|
||||
|
||||
function ThemesTab() {
|
||||
const [currentTab, setCurrentTab] = useState(ThemeTab.LOCAL);
|
||||
|
||||
return (
|
||||
<SettingsTab title="Themes">
|
||||
<TabBar
|
||||
type="top"
|
||||
look="brand"
|
||||
className="vc-settings-tab-bar"
|
||||
selectedItem={currentTab}
|
||||
onItemSelect={setCurrentTab}
|
||||
>
|
||||
<TabBar.Item
|
||||
className="vc-settings-tab-bar-item"
|
||||
id={ThemeTab.LOCAL}
|
||||
>
|
||||
Local Themes
|
||||
</TabBar.Item>
|
||||
<TabBar.Item
|
||||
className="vc-settings-tab-bar-item"
|
||||
id={ThemeTab.ONLINE}
|
||||
>
|
||||
Online Themes
|
||||
</TabBar.Item>
|
||||
</TabBar>
|
||||
|
||||
<CspErrorCard />
|
||||
|
||||
{currentTab === ThemeTab.LOCAL && <LocalThemesTab />}
|
||||
{currentTab === ThemeTab.ONLINE && <OnlineThemesTab />}
|
||||
</SettingsTab>
|
||||
);
|
||||
}
|
||||
|
||||
function UserscriptThemesTab() {
|
||||
return (
|
||||
<SettingsTab title="Themes">
|
||||
<Card className="vc-settings-card">
|
||||
<Forms.FormTitle tag="h5">Themes are not supported on the Userscript!</Forms.FormTitle>
|
||||
|
||||
<Forms.FormText>
|
||||
You can instead install themes with the <Link href={getStylusWebStoreUrl()}>Stylus extension</Link>!
|
||||
</Forms.FormText>
|
||||
</Card>
|
||||
</SettingsTab>
|
||||
);
|
||||
}
|
||||
|
||||
export default IS_USERSCRIPT
|
||||
? wrapTab(UserscriptThemesTab, "Themes")
|
||||
: wrapTab(ThemesTab, "Themes");
|
||||
148
src/components/settings/tabs/updater/Components.tsx
Normal file
148
src/components/settings/tabs/updater/Components.tsx
Normal file
|
|
@ -0,0 +1,148 @@
|
|||
/*
|
||||
* Vencord, a Discord client mod
|
||||
* Copyright (c) 2025 Vendicated and contributors
|
||||
* SPDX-License-Identifier: GPL-3.0-or-later
|
||||
*/
|
||||
|
||||
import { ErrorCard } from "@components/ErrorCard";
|
||||
import { Flex } from "@components/Flex";
|
||||
import { Link } from "@components/Link";
|
||||
import { Margins } from "@utils/margins";
|
||||
import { classes } from "@utils/misc";
|
||||
import { relaunch } from "@utils/native";
|
||||
import { changes, checkForUpdates, update, updateError } from "@utils/updater";
|
||||
import { Alerts, Button, Card, Forms, React, Toasts, useState } from "@webpack/common";
|
||||
|
||||
import { runWithDispatch } from "./runWithDispatch";
|
||||
|
||||
export interface CommonProps {
|
||||
repo: string;
|
||||
repoPending: boolean;
|
||||
}
|
||||
|
||||
export function HashLink({ repo, hash, disabled = false }: { repo: string, hash: string, disabled?: boolean; }) {
|
||||
return (
|
||||
<Link href={`${repo}/commit/${hash}`} disabled={disabled}>
|
||||
{hash}
|
||||
</Link>
|
||||
);
|
||||
}
|
||||
|
||||
export function Changes({ updates, repo, repoPending }: CommonProps & { updates: typeof changes; }) {
|
||||
return (
|
||||
<Card style={{ padding: "0 0.5em" }}>
|
||||
{updates.map(({ hash, author, message }) => (
|
||||
<div
|
||||
key={hash}
|
||||
style={{
|
||||
marginTop: "0.5em",
|
||||
marginBottom: "0.5em"
|
||||
}}
|
||||
>
|
||||
<code>
|
||||
<HashLink {...{ repo, hash }} disabled={repoPending} />
|
||||
</code>
|
||||
|
||||
<span style={{
|
||||
marginLeft: "0.5em",
|
||||
color: "var(--text-default)"
|
||||
}}>
|
||||
{message} - {author}
|
||||
</span>
|
||||
</div>
|
||||
))}
|
||||
</Card>
|
||||
);
|
||||
}
|
||||
|
||||
export function Newer(props: CommonProps) {
|
||||
return (
|
||||
<>
|
||||
<Forms.FormText className={Margins.bottom8}>
|
||||
Your local copy has more recent commits. Please stash or reset them.
|
||||
</Forms.FormText>
|
||||
<Changes {...props} updates={changes} />
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
export function Updatable(props: CommonProps) {
|
||||
const [updates, setUpdates] = useState(changes);
|
||||
const [isChecking, setIsChecking] = useState(false);
|
||||
const [isUpdating, setIsUpdating] = useState(false);
|
||||
|
||||
const isOutdated = (updates?.length ?? 0) > 0;
|
||||
|
||||
return (
|
||||
<>
|
||||
{!updates && updateError ? (
|
||||
<>
|
||||
<Forms.FormText>Failed to check updates. Check the console for more info</Forms.FormText>
|
||||
<ErrorCard style={{ padding: "1em" }}>
|
||||
<p>{updateError.stderr || updateError.stdout || "An unknown error occurred"}</p>
|
||||
</ErrorCard>
|
||||
</>
|
||||
) : (
|
||||
<Forms.FormText className={Margins.bottom8}>
|
||||
{isOutdated ? (updates.length === 1 ? "There is 1 Update" : `There are ${updates.length} Updates`) : "Up to Date!"}
|
||||
</Forms.FormText>
|
||||
)}
|
||||
|
||||
{isOutdated && <Changes updates={updates} {...props} />}
|
||||
|
||||
<Flex className={classes(Margins.bottom8, Margins.top8)}>
|
||||
{isOutdated && (
|
||||
<Button
|
||||
size={Button.Sizes.SMALL}
|
||||
disabled={isUpdating || isChecking}
|
||||
onClick={runWithDispatch(setIsUpdating, async () => {
|
||||
if (await update()) {
|
||||
setUpdates([]);
|
||||
|
||||
await new Promise<void>(r => {
|
||||
Alerts.show({
|
||||
title: "Update Success!",
|
||||
body: "Successfully updated. Restart now to apply the changes?",
|
||||
confirmText: "Restart",
|
||||
cancelText: "Not now!",
|
||||
onConfirm() {
|
||||
relaunch();
|
||||
r();
|
||||
},
|
||||
onCancel: r
|
||||
});
|
||||
});
|
||||
}
|
||||
})}
|
||||
>
|
||||
Update Now
|
||||
</Button>
|
||||
)}
|
||||
<Button
|
||||
size={Button.Sizes.SMALL}
|
||||
disabled={isUpdating || isChecking}
|
||||
onClick={runWithDispatch(setIsChecking, async () => {
|
||||
const outdated = await checkForUpdates();
|
||||
|
||||
if (outdated) {
|
||||
setUpdates(changes);
|
||||
} else {
|
||||
setUpdates([]);
|
||||
|
||||
Toasts.show({
|
||||
message: "No updates found!",
|
||||
id: Toasts.genId(),
|
||||
type: Toasts.Type.MESSAGE,
|
||||
options: {
|
||||
position: Toasts.Position.BOTTOM
|
||||
}
|
||||
});
|
||||
}
|
||||
})}
|
||||
>
|
||||
Check for Updates
|
||||
</Button>
|
||||
</Flex>
|
||||
</>
|
||||
);
|
||||
}
|
||||
115
src/components/settings/tabs/updater/index.tsx
Normal file
115
src/components/settings/tabs/updater/index.tsx
Normal file
|
|
@ -0,0 +1,115 @@
|
|||
/*
|
||||
* Vencord, a modification for Discord's desktop app
|
||||
* Copyright (c) 2022 Vendicated and contributors
|
||||
*
|
||||
* This program is free software: you can redistribute it and/or modify
|
||||
* it under the terms of the GNU General Public License as published by
|
||||
* the Free Software Foundation, either version 3 of the License, or
|
||||
* (at your option) any later version.
|
||||
*
|
||||
* This program is distributed in the hope that it will be useful,
|
||||
* but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
||||
* GNU General Public License for more details.
|
||||
*
|
||||
* You should have received a copy of the GNU General Public License
|
||||
* along with this program. If not, see <https://www.gnu.org/licenses/>.
|
||||
*/
|
||||
|
||||
import { useSettings } from "@api/Settings";
|
||||
import { Link } from "@components/Link";
|
||||
import { handleSettingsTabError, SettingsTab, wrapTab } from "@components/settings/tabs/BaseTab";
|
||||
import { Margins } from "@utils/margins";
|
||||
import { ModalCloseButton, ModalContent, ModalProps, ModalRoot, ModalSize, openModal } from "@utils/modal";
|
||||
import { useAwaiter } from "@utils/react";
|
||||
import { getRepo, isNewer, UpdateLogger } from "@utils/updater";
|
||||
import { Forms, React, Switch } from "@webpack/common";
|
||||
|
||||
import gitHash from "~git-hash";
|
||||
|
||||
import { CommonProps, HashLink, Newer, Updatable } from "./Components";
|
||||
|
||||
function Updater() {
|
||||
const settings = useSettings(["autoUpdate", "autoUpdateNotification"]);
|
||||
|
||||
const [repo, err, repoPending] = useAwaiter(getRepo, {
|
||||
fallbackValue: "Loading...",
|
||||
onError: e => UpdateLogger.error("Failed to retrieve repo", err)
|
||||
});
|
||||
|
||||
const commonProps: CommonProps = {
|
||||
repo,
|
||||
repoPending
|
||||
};
|
||||
|
||||
return (
|
||||
<SettingsTab title="Vencord Updater">
|
||||
<Forms.FormTitle tag="h5">Updater Settings</Forms.FormTitle>
|
||||
|
||||
<Switch
|
||||
value={settings.autoUpdate}
|
||||
onChange={(v: boolean) => settings.autoUpdate = v}
|
||||
note="Automatically update Vencord without confirmation prompt"
|
||||
>
|
||||
Automatically update
|
||||
</Switch>
|
||||
<Switch
|
||||
value={settings.autoUpdateNotification}
|
||||
onChange={(v: boolean) => settings.autoUpdateNotification = v}
|
||||
note="Show a notification when Vencord automatically updates"
|
||||
disabled={!settings.autoUpdate}
|
||||
>
|
||||
Get notified when an automatic update completes
|
||||
</Switch>
|
||||
|
||||
<Forms.FormTitle tag="h5">Repo</Forms.FormTitle>
|
||||
|
||||
<Forms.FormText>
|
||||
{repoPending
|
||||
? repo
|
||||
: err
|
||||
? "Failed to retrieve - check console"
|
||||
: (
|
||||
<Link href={repo}>
|
||||
{repo.split("/").slice(-2).join("/")}
|
||||
</Link>
|
||||
)
|
||||
}
|
||||
{" "}
|
||||
(<HashLink hash={gitHash} repo={repo} disabled={repoPending} />)
|
||||
</Forms.FormText>
|
||||
|
||||
<Forms.FormDivider className={Margins.top8 + " " + Margins.bottom8} />
|
||||
|
||||
<Forms.FormTitle tag="h5">Updates</Forms.FormTitle>
|
||||
|
||||
{isNewer
|
||||
? <Newer {...commonProps} />
|
||||
: <Updatable {...commonProps} />
|
||||
}
|
||||
</SettingsTab>
|
||||
);
|
||||
}
|
||||
|
||||
export default IS_UPDATER_DISABLED
|
||||
? null
|
||||
: wrapTab(Updater, "Updater");
|
||||
|
||||
export const openUpdaterModal = IS_UPDATER_DISABLED
|
||||
? null
|
||||
: function () {
|
||||
const UpdaterTab = wrapTab(Updater, "Updater");
|
||||
|
||||
try {
|
||||
openModal(wrapTab((modalProps: ModalProps) => (
|
||||
<ModalRoot {...modalProps} size={ModalSize.MEDIUM}>
|
||||
<ModalContent className="vc-updater-modal">
|
||||
<ModalCloseButton onClick={modalProps.onClose} className="vc-updater-modal-close-button" />
|
||||
<UpdaterTab />
|
||||
</ModalContent>
|
||||
</ModalRoot>
|
||||
), "UpdaterModal"));
|
||||
} catch {
|
||||
handleSettingsTabError();
|
||||
}
|
||||
};
|
||||
50
src/components/settings/tabs/updater/runWithDispatch.tsx
Normal file
50
src/components/settings/tabs/updater/runWithDispatch.tsx
Normal file
|
|
@ -0,0 +1,50 @@
|
|||
/*
|
||||
* Vencord, a Discord client mod
|
||||
* Copyright (c) 2025 Vendicated and contributors
|
||||
* SPDX-License-Identifier: GPL-3.0-or-later
|
||||
*/
|
||||
|
||||
import { ErrorCard } from "@components/ErrorCard";
|
||||
import { UpdateLogger } from "@utils/updater";
|
||||
import { Alerts, Parser } from "@webpack/common";
|
||||
|
||||
function getErrorMessage(e: any) {
|
||||
if (!e?.code || !e.cmd)
|
||||
return "An unknown error occurred.\nPlease try again or see the console for more info.";
|
||||
|
||||
const { code, path, cmd, stderr } = e;
|
||||
|
||||
if (code === "ENOENT")
|
||||
return `Command \`${path}\` not found.\nPlease install it and try again.`;
|
||||
|
||||
const extra = stderr || `Code \`${code}\`. See the console for more info.`;
|
||||
|
||||
return `An error occurred while running \`${cmd}\`:\n${extra}`;
|
||||
}
|
||||
|
||||
export function runWithDispatch(dispatch: React.Dispatch<React.SetStateAction<boolean>>, action: () => any) {
|
||||
return async () => {
|
||||
dispatch(true);
|
||||
|
||||
try {
|
||||
await action();
|
||||
} catch (e: any) {
|
||||
UpdateLogger.error(e);
|
||||
|
||||
const err = getErrorMessage(e);
|
||||
|
||||
Alerts.show({
|
||||
title: "Oops!",
|
||||
body: (
|
||||
<ErrorCard>
|
||||
{err.split("\n").map((line, idx) =>
|
||||
<div key={idx}>{Parser.parse(line)}</div>
|
||||
)}
|
||||
</ErrorCard>
|
||||
)
|
||||
});
|
||||
} finally {
|
||||
dispatch(false);
|
||||
}
|
||||
};
|
||||
}
|
||||
25
src/components/settings/tabs/vencord/DonateButton.tsx
Normal file
25
src/components/settings/tabs/vencord/DonateButton.tsx
Normal file
|
|
@ -0,0 +1,25 @@
|
|||
/*
|
||||
* Vencord, a Discord client mod
|
||||
* Copyright (c) 2025 Vendicated and contributors
|
||||
* SPDX-License-Identifier: GPL-3.0-or-later
|
||||
*/
|
||||
|
||||
import DonateButton from "@components/settings/DonateButton";
|
||||
import { DONOR_ROLE_ID, VENCORD_GUILD_ID } from "@utils/constants";
|
||||
import { Button, GuildMemberStore } from "@webpack/common";
|
||||
import BadgeAPI from "plugins/_api/badges";
|
||||
|
||||
export const isDonor = (userId: string) => !!(
|
||||
BadgeAPI.getDonorBadges(userId)?.length > 0
|
||||
|| GuildMemberStore.getMember(VENCORD_GUILD_ID, userId)?.roles.includes(DONOR_ROLE_ID)
|
||||
);
|
||||
|
||||
export function DonateButtonComponent() {
|
||||
return (
|
||||
<DonateButton
|
||||
look={Button.Looks.FILLED}
|
||||
color={Button.Colors.WHITE}
|
||||
style={{ marginTop: "1em" }}
|
||||
/>
|
||||
);
|
||||
}
|
||||
80
src/components/settings/tabs/vencord/MacVibrancySettings.tsx
Normal file
80
src/components/settings/tabs/vencord/MacVibrancySettings.tsx
Normal file
|
|
@ -0,0 +1,80 @@
|
|||
/*
|
||||
* Vencord, a Discord client mod
|
||||
* Copyright (c) 2025 Vendicated and contributors
|
||||
* SPDX-License-Identifier: GPL-3.0-or-later
|
||||
*/
|
||||
|
||||
import { useSettings } from "@api/Settings";
|
||||
import { Margins } from "@utils/margins";
|
||||
import { identity } from "@utils/misc";
|
||||
import { Forms, Select } from "@webpack/common";
|
||||
|
||||
export function VibrancySettings() {
|
||||
const settings = useSettings(["macosVibrancyStyle"]);
|
||||
|
||||
return (
|
||||
<>
|
||||
<Forms.FormTitle tag="h5">Window vibrancy style (requires restart)</Forms.FormTitle>
|
||||
<Select
|
||||
className={Margins.bottom20}
|
||||
placeholder="Window vibrancy style"
|
||||
options={[
|
||||
// Sorted from most opaque to most transparent
|
||||
{
|
||||
label: "No vibrancy", value: undefined
|
||||
},
|
||||
{
|
||||
label: "Under Page (window tinting)",
|
||||
value: "under-page"
|
||||
},
|
||||
{
|
||||
label: "Content",
|
||||
value: "content"
|
||||
},
|
||||
{
|
||||
label: "Window",
|
||||
value: "window"
|
||||
},
|
||||
{
|
||||
label: "Selection",
|
||||
value: "selection"
|
||||
},
|
||||
{
|
||||
label: "Titlebar",
|
||||
value: "titlebar"
|
||||
},
|
||||
{
|
||||
label: "Header",
|
||||
value: "header"
|
||||
},
|
||||
{
|
||||
label: "Sidebar",
|
||||
value: "sidebar"
|
||||
},
|
||||
{
|
||||
label: "Tooltip",
|
||||
value: "tooltip"
|
||||
},
|
||||
{
|
||||
label: "Menu",
|
||||
value: "menu"
|
||||
},
|
||||
{
|
||||
label: "Popover",
|
||||
value: "popover"
|
||||
},
|
||||
{
|
||||
label: "Fullscreen UI (transparent but slightly muted)",
|
||||
value: "fullscreen-ui"
|
||||
},
|
||||
{
|
||||
label: "HUD (Most transparent)",
|
||||
value: "hud"
|
||||
},
|
||||
]}
|
||||
select={v => settings.macosVibrancyStyle = v}
|
||||
isSelected={v => settings.macosVibrancyStyle === v}
|
||||
serialize={identity} />
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
|
@ -4,15 +4,46 @@
|
|||
* SPDX-License-Identifier: GPL-3.0-or-later
|
||||
*/
|
||||
|
||||
import { openNotificationLogModal } from "@api/Notifications/notificationLog";
|
||||
import { useSettings } from "@api/Settings";
|
||||
import { ErrorCard } from "@components/ErrorCard";
|
||||
import { Flex } from "@components/Flex";
|
||||
import { Margins } from "@utils/margins";
|
||||
import { identity } from "@utils/misc";
|
||||
import { ModalCloseButton, ModalContent, ModalHeader, ModalRoot, ModalSize, openModal } from "@utils/modal";
|
||||
import { Forms, Select, Slider, Text } from "@webpack/common";
|
||||
import { Button, Forms, Select, Slider, Text } from "@webpack/common";
|
||||
|
||||
import { ErrorCard } from "..";
|
||||
export function NotificationSection() {
|
||||
return (
|
||||
<Forms.FormSection className={Margins.top16} title="Vencord Notifications" tag="h5">
|
||||
<Flex>
|
||||
<Button onClick={openNotificationSettingsModal}>
|
||||
Notification Settings
|
||||
</Button>
|
||||
<Button onClick={openNotificationLogModal}>
|
||||
View Notification Log
|
||||
</Button>
|
||||
</Flex>
|
||||
</Forms.FormSection>
|
||||
);
|
||||
}
|
||||
|
||||
export function NotificationSettings() {
|
||||
export function openNotificationSettingsModal() {
|
||||
openModal(props => (
|
||||
<ModalRoot {...props} size={ModalSize.MEDIUM}>
|
||||
<ModalHeader>
|
||||
<Text variant="heading-lg/semibold" style={{ flexGrow: 1 }}>Notification Settings</Text>
|
||||
<ModalCloseButton onClick={props.onClose} />
|
||||
</ModalHeader>
|
||||
|
||||
<ModalContent>
|
||||
<NotificationSettings />
|
||||
</ModalContent>
|
||||
</ModalRoot>
|
||||
));
|
||||
}
|
||||
|
||||
function NotificationSettings() {
|
||||
const settings = useSettings().notifications;
|
||||
|
||||
return (
|
||||
|
|
@ -89,18 +120,3 @@ export function NotificationSettings() {
|
|||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export function openNotificationSettingsModal() {
|
||||
openModal(props => (
|
||||
<ModalRoot {...props} size={ModalSize.MEDIUM}>
|
||||
<ModalHeader>
|
||||
<Text variant="heading-lg/semibold" style={{ flexGrow: 1 }}>Notification Settings</Text>
|
||||
<ModalCloseButton onClick={props.onClose} />
|
||||
</ModalHeader>
|
||||
|
||||
<ModalContent>
|
||||
<NotificationSettings />
|
||||
</ModalContent>
|
||||
</ModalRoot>
|
||||
));
|
||||
}
|
||||
211
src/components/settings/tabs/vencord/index.tsx
Normal file
211
src/components/settings/tabs/vencord/index.tsx
Normal file
|
|
@ -0,0 +1,211 @@
|
|||
/*
|
||||
* Vencord, a modification for Discord's desktop app
|
||||
* Copyright (c) 2022 Vendicated and contributors
|
||||
*
|
||||
* This program is free software: you can redistribute it and/or modify
|
||||
* it under the terms of the GNU General Public License as published by
|
||||
* the Free Software Foundation, either version 3 of the License, or
|
||||
* (at your option) any later version.
|
||||
*
|
||||
* This program is distributed in the hope that it will be useful,
|
||||
* but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
||||
* GNU General Public License for more details.
|
||||
*
|
||||
* You should have received a copy of the GNU General Public License
|
||||
* along with this program. If not, see <https://www.gnu.org/licenses/>.
|
||||
*/
|
||||
|
||||
import { openNotificationLogModal } from "@api/Notifications/notificationLog";
|
||||
import { useSettings } from "@api/Settings";
|
||||
import { FolderIcon, GithubIcon, LogIcon, PaintbrushIcon, RestartIcon } from "@components/index";
|
||||
import { QuickAction, QuickActionCard } from "@components/settings/QuickAction";
|
||||
import { SpecialCard } from "@components/settings/SpecialCard";
|
||||
import { SettingsTab, wrapTab } from "@components/settings/tabs/BaseTab";
|
||||
import { openContributorModal } from "@components/settings/tabs/plugins/ContributorModal";
|
||||
import { openPluginModal } from "@components/settings/tabs/plugins/PluginModal";
|
||||
import { gitRemote } from "@shared/vencordUserAgent";
|
||||
import { IS_MAC, IS_WINDOWS } from "@utils/constants";
|
||||
import { Margins } from "@utils/margins";
|
||||
import { isPluginDev } from "@utils/misc";
|
||||
import { relaunch } from "@utils/native";
|
||||
import { Forms, React, Switch, useMemo, UserStore } from "@webpack/common";
|
||||
|
||||
import { DonateButtonComponent, isDonor } from "./DonateButton";
|
||||
import { VibrancySettings } from "./MacVibrancySettings";
|
||||
import { NotificationSection } from "./NotificationSettings";
|
||||
|
||||
const DEFAULT_DONATE_IMAGE = "https://cdn.discordapp.com/emojis/1026533090627174460.png";
|
||||
const SHIGGY_DONATE_IMAGE = "https://media.discordapp.net/stickers/1039992459209490513.png";
|
||||
const VENNIE_DONATOR_IMAGE = "https://cdn.discordapp.com/emojis/1238120638020063377.png";
|
||||
const COZY_CONTRIB_IMAGE = "https://cdn.discordapp.com/emojis/1026533070955872337.png";
|
||||
const DONOR_BACKGROUND_IMAGE = "https://media.discordapp.net/stickers/1311070116305436712.png?size=2048";
|
||||
const CONTRIB_BACKGROUND_IMAGE = "https://media.discordapp.net/stickers/1311070166481895484.png?size=2048";
|
||||
|
||||
type KeysOfType<Object, Type> = {
|
||||
[K in keyof Object]: Object[K] extends Type ? K : never;
|
||||
}[keyof Object];
|
||||
|
||||
function Switches() {
|
||||
const settings = useSettings(["useQuickCss", "enableReactDevtools", "frameless", "winNativeTitleBar", "transparent", "winCtrlQ", "disableMinSize"]);
|
||||
|
||||
const Switches = [
|
||||
{
|
||||
key: "useQuickCss",
|
||||
title: "Enable Custom CSS",
|
||||
note: "Loads your Custom CSS"
|
||||
},
|
||||
!IS_WEB && {
|
||||
key: "enableReactDevtools",
|
||||
title: "Enable React Developer Tools",
|
||||
note: "Requires a full restart"
|
||||
},
|
||||
!IS_WEB && (!IS_DISCORD_DESKTOP || !IS_WINDOWS ? {
|
||||
key: "frameless",
|
||||
title: "Disable the window frame",
|
||||
note: "Requires a full restart"
|
||||
} : {
|
||||
key: "winNativeTitleBar",
|
||||
title: "Use Windows' native title bar instead of Discord's custom one",
|
||||
note: "Requires a full restart"
|
||||
}),
|
||||
!IS_WEB && {
|
||||
key: "transparent",
|
||||
title: "Enable window transparency.",
|
||||
note: "You need a theme that supports transparency or this will do nothing. WILL STOP THE WINDOW FROM BEING RESIZABLE!! Requires a full restart"
|
||||
},
|
||||
!IS_WEB && IS_WINDOWS && {
|
||||
key: "winCtrlQ",
|
||||
title: "Register Ctrl+Q as shortcut to close Discord (Alternative to Alt+F4)",
|
||||
note: "Requires a full restart"
|
||||
},
|
||||
IS_DISCORD_DESKTOP && {
|
||||
key: "disableMinSize",
|
||||
title: "Disable minimum window size",
|
||||
note: "Requires a full restart"
|
||||
},
|
||||
] satisfies Array<false | {
|
||||
key: KeysOfType<typeof settings, boolean>;
|
||||
title: string;
|
||||
note: string;
|
||||
}>;
|
||||
|
||||
return Switches.map(s => s && (
|
||||
<Switch
|
||||
key={s.key}
|
||||
value={settings[s.key]}
|
||||
onChange={v => settings[s.key] = v}
|
||||
note={s.note}
|
||||
>
|
||||
{s.title}
|
||||
</Switch>
|
||||
));
|
||||
}
|
||||
|
||||
function VencordSettings() {
|
||||
const donateImage = useMemo(() =>
|
||||
Math.random() > 0.5 ? DEFAULT_DONATE_IMAGE : SHIGGY_DONATE_IMAGE,
|
||||
[]
|
||||
);
|
||||
|
||||
const needsVibrancySettings = IS_DISCORD_DESKTOP && IS_MAC;
|
||||
|
||||
const user = UserStore.getCurrentUser();
|
||||
|
||||
return (
|
||||
<SettingsTab title="Vencord Settings">
|
||||
{isDonor(user?.id)
|
||||
? (
|
||||
<SpecialCard
|
||||
title="Donations"
|
||||
subtitle="Thank you for donating!"
|
||||
description="You can manage your perks at any time by messaging @vending.machine."
|
||||
cardImage={VENNIE_DONATOR_IMAGE}
|
||||
backgroundImage={DONOR_BACKGROUND_IMAGE}
|
||||
backgroundColor="#ED87A9"
|
||||
>
|
||||
<DonateButtonComponent />
|
||||
</SpecialCard>
|
||||
)
|
||||
: (
|
||||
<SpecialCard
|
||||
title="Support the Project"
|
||||
description="Please consider supporting the development of Vencord by donating!"
|
||||
cardImage={donateImage}
|
||||
backgroundImage={DONOR_BACKGROUND_IMAGE}
|
||||
backgroundColor="#c3a3ce"
|
||||
>
|
||||
<DonateButtonComponent />
|
||||
</SpecialCard>
|
||||
)
|
||||
}
|
||||
|
||||
{isPluginDev(user?.id) && (
|
||||
<SpecialCard
|
||||
title="Contributions"
|
||||
subtitle="Thank you for contributing!"
|
||||
description="Since you've contributed to Vencord you now have a cool new badge!"
|
||||
cardImage={COZY_CONTRIB_IMAGE}
|
||||
backgroundImage={CONTRIB_BACKGROUND_IMAGE}
|
||||
backgroundColor="#EDCC87"
|
||||
buttonTitle="See what you've contributed to"
|
||||
buttonOnClick={() => openContributorModal(user)}
|
||||
/>
|
||||
)}
|
||||
|
||||
<Forms.FormSection title="Quick Actions">
|
||||
<QuickActionCard>
|
||||
<QuickAction
|
||||
Icon={LogIcon}
|
||||
text="Notification Log"
|
||||
action={openNotificationLogModal}
|
||||
/>
|
||||
<QuickAction
|
||||
Icon={PaintbrushIcon}
|
||||
text="Edit QuickCSS"
|
||||
action={() => VencordNative.quickCss.openEditor()}
|
||||
/>
|
||||
{!IS_WEB && (
|
||||
<>
|
||||
<QuickAction
|
||||
Icon={RestartIcon}
|
||||
text="Relaunch Discord"
|
||||
action={relaunch}
|
||||
/>
|
||||
<QuickAction
|
||||
Icon={FolderIcon}
|
||||
text="Open Settings Folder"
|
||||
action={() => VencordNative.settings.openFolder()}
|
||||
/>
|
||||
</>
|
||||
)}
|
||||
<QuickAction
|
||||
Icon={GithubIcon}
|
||||
text="View Source Code"
|
||||
action={() => VencordNative.native.openExternal("https://github.com/" + gitRemote)}
|
||||
/>
|
||||
</QuickActionCard>
|
||||
</Forms.FormSection>
|
||||
|
||||
<Forms.FormDivider />
|
||||
|
||||
<Forms.FormSection className={Margins.top16} title="Settings" tag="h5">
|
||||
<Forms.FormText className={Margins.bottom20} style={{ color: "var(--text-muted)" }}>
|
||||
Hint: You can change the position of this settings section in the{" "}
|
||||
<a onClick={() => openPluginModal(Vencord.Plugins.plugins.Settings)}>
|
||||
settings of the Settings plugin
|
||||
</a>!
|
||||
</Forms.FormText>
|
||||
|
||||
<Switches />
|
||||
</Forms.FormSection>
|
||||
|
||||
|
||||
{needsVibrancySettings && <VibrancySettings />}
|
||||
|
||||
<NotificationSection />
|
||||
</SettingsTab>
|
||||
);
|
||||
}
|
||||
|
||||
export default wrapTab(VencordSettings, "Vencord Settings");
|
||||
|
|
@ -19,11 +19,11 @@
|
|||
import "./fixDiscordBadgePadding.css";
|
||||
|
||||
import { _getBadges, BadgePosition, BadgeUserArgs, ProfileBadge } from "@api/Badges";
|
||||
import DonateButton from "@components/DonateButton";
|
||||
import ErrorBoundary from "@components/ErrorBoundary";
|
||||
import { Flex } from "@components/Flex";
|
||||
import { Heart } from "@components/Heart";
|
||||
import { openContributorModal } from "@components/PluginSettings/ContributorModal";
|
||||
import DonateButton from "@components/settings/DonateButton";
|
||||
import { openContributorModal } from "@components/settings/tabs";
|
||||
import { Devs } from "@utils/constants";
|
||||
import { Logger } from "@utils/Logger";
|
||||
import { Margins } from "@utils/margins";
|
||||
|
|
|
|||
|
|
@ -17,13 +17,7 @@
|
|||
*/
|
||||
|
||||
import { Settings } from "@api/Settings";
|
||||
import BackupAndRestoreTab from "@components/VencordSettings/BackupAndRestoreTab";
|
||||
import CloudTab from "@components/VencordSettings/CloudTab";
|
||||
import PatchHelperTab from "@components/VencordSettings/PatchHelperTab";
|
||||
import PluginsTab from "@components/VencordSettings/PluginsTab";
|
||||
import ThemesTab from "@components/VencordSettings/ThemesTab";
|
||||
import UpdaterTab from "@components/VencordSettings/UpdaterTab";
|
||||
import VencordTab from "@components/VencordSettings/VencordTab";
|
||||
import { BackupAndRestoreTab, CloudTab, PatchHelperTab, PluginsTab, ThemesTab, UpdaterTab, VencordTab } from "@components/settings/tabs";
|
||||
import { Devs } from "@utils/constants";
|
||||
import { getIntlMessage } from "@utils/discord";
|
||||
import definePlugin, { OptionType } from "@utils/types";
|
||||
|
|
@ -90,7 +84,7 @@ export default definePlugin({
|
|||
className: "vc-settings-header"
|
||||
},
|
||||
{
|
||||
section: "VencordSettings",
|
||||
section: "settings/tabs",
|
||||
label: "Vencord",
|
||||
element: VencordTab,
|
||||
className: "vc-settings"
|
||||
|
|
@ -120,7 +114,7 @@ export default definePlugin({
|
|||
className: "vc-cloud"
|
||||
},
|
||||
{
|
||||
section: "VencordSettingsSync",
|
||||
section: "settings/tabsSync",
|
||||
label: "Backup & Restore",
|
||||
element: BackupAndRestoreTab,
|
||||
className: "vc-backup-restore"
|
||||
|
|
|
|||
|
|
@ -21,7 +21,7 @@ import { getUserSettingLazy } from "@api/UserSettings";
|
|||
import ErrorBoundary from "@components/ErrorBoundary";
|
||||
import { Flex } from "@components/Flex";
|
||||
import { Link } from "@components/Link";
|
||||
import { openUpdaterModal } from "@components/VencordSettings/UpdaterTab";
|
||||
import { openUpdaterModal } from "@components/settings/tabs/updater";
|
||||
import { CONTRIB_ROLE_ID, Devs, DONOR_ROLE_ID, KNOWN_ISSUES_CHANNEL_ID, REGULAR_ROLE_ID, SUPPORT_CATEGORY_ID, SUPPORT_CHANNEL_ID, VENBOT_USER_ID, VENCORD_GUILD_ID } from "@utils/constants";
|
||||
import { sendMessage } from "@utils/discord";
|
||||
import { Logger } from "@utils/Logger";
|
||||
|
|
@ -87,7 +87,7 @@ async function generateDebugInfoMessage() {
|
|||
`v${VERSION} • [${gitHash}](<https://github.com/Vendicated/Vencord/commit/${gitHash}>)` +
|
||||
`${SettingsPlugin.additionalInfo} - ${Intl.DateTimeFormat("en-GB", { dateStyle: "medium" }).format(BUILD_TIMESTAMP)}`,
|
||||
Client: `${RELEASE_CHANNEL} ~ ${client}`,
|
||||
Platform: window.navigator.platform
|
||||
Platform: navigator.platform
|
||||
};
|
||||
|
||||
if (IS_DISCORD_DESKTOP) {
|
||||
|
|
|
|||
|
|
@ -5,7 +5,7 @@
|
|||
*/
|
||||
|
||||
import { definePluginSettings } from "@api/Settings";
|
||||
import { Devs } from "@utils/constants";
|
||||
import { Devs, IS_MAC } from "@utils/constants";
|
||||
import definePlugin, { OptionType, PluginNative, ReporterTestable } from "@utils/types";
|
||||
import { ApplicationAssetUtils, FluxDispatcher, Forms } from "@webpack/common";
|
||||
|
||||
|
|
@ -173,7 +173,7 @@ export default definePlugin({
|
|||
name: "AppleMusicRichPresence",
|
||||
description: "Discord rich presence for your Apple Music!",
|
||||
authors: [Devs.RyanCaoDev],
|
||||
hidden: !navigator.platform.startsWith("Mac"),
|
||||
hidden: !IS_MAC,
|
||||
reporterTestable: ReporterTestable.None,
|
||||
|
||||
settingsAboutComponent() {
|
||||
|
|
|
|||
|
|
@ -4,7 +4,7 @@
|
|||
* SPDX-License-Identifier: GPL-3.0-or-later
|
||||
*/
|
||||
|
||||
import { openPluginModal } from "@components/PluginSettings/PluginModal";
|
||||
import { openPluginModal } from "@components/settings/tabs";
|
||||
import { getIntlMessage } from "@utils/discord";
|
||||
import { isObjectEmpty } from "@utils/misc";
|
||||
import { Alerts, Menu, useMemo, useState } from "@webpack/common";
|
||||
|
|
|
|||
|
|
@ -5,7 +5,8 @@
|
|||
*/
|
||||
|
||||
import { definePluginSettings } from "@api/Settings";
|
||||
import { ErrorBoundary, Flex } from "@components/index";
|
||||
import ErrorBoundary from "@components/ErrorBoundary";
|
||||
import { Flex } from "@components/Flex";
|
||||
import { Devs } from "@utils/constants";
|
||||
import { Margins } from "@utils/margins";
|
||||
import definePlugin, { defineDefault, OptionType, StartAt } from "@utils/types";
|
||||
|
|
|
|||
|
|
@ -5,7 +5,7 @@
|
|||
*/
|
||||
|
||||
import { definePluginSettings } from "@api/Settings";
|
||||
import { Devs } from "@utils/constants";
|
||||
import { Devs, IS_MAC } from "@utils/constants";
|
||||
import definePlugin, { OptionType } from "@utils/types";
|
||||
|
||||
export default definePlugin({
|
||||
|
|
@ -64,7 +64,7 @@ export default definePlugin({
|
|||
result = event.shiftKey;
|
||||
break;
|
||||
case "ctrl+enter":
|
||||
result = navigator.platform.includes("Mac") ? event.metaKey : event.ctrlKey;
|
||||
result = IS_MAC ? event.metaKey : event.ctrlKey;
|
||||
break;
|
||||
case "enter":
|
||||
result = !event.shiftKey && !event.ctrlKey;
|
||||
|
|
|
|||
|
|
@ -6,9 +6,8 @@
|
|||
|
||||
import { Notices } from "@api/index";
|
||||
import { definePluginSettings } from "@api/Settings";
|
||||
import { makeRange } from "@components/PluginSettings/components";
|
||||
import { Devs } from "@utils/constants";
|
||||
import definePlugin, { OptionType } from "@utils/types";
|
||||
import definePlugin, { makeRange, OptionType } from "@utils/types";
|
||||
import { FluxDispatcher } from "@webpack/common";
|
||||
|
||||
const settings = definePluginSettings({
|
||||
|
|
|
|||
|
|
@ -12,8 +12,7 @@ import { classes, copyWithToast } from "@utils/misc";
|
|||
import { closeAllModals, ModalCloseButton, ModalContent, ModalFooter, ModalHeader, ModalProps, ModalRoot, ModalSize, openModal } from "@utils/modal";
|
||||
import { Queue } from "@utils/Queue";
|
||||
import { User } from "@vencord/discord-types";
|
||||
import { findComponentByCodeLazy } from "@webpack";
|
||||
import { Alerts, Button, FluxDispatcher, Forms, GuildStore, NavigationRouter, Parser, Text, Tooltip, useEffect, UserStore, UserUtils, useState } from "@webpack/common";
|
||||
import { Alerts, Button, FluxDispatcher, Forms, GuildStore, NavigationRouter, Parser, Text, Tooltip, useEffect, UserStore, UserSummaryItem, UserUtils, useState } from "@webpack/common";
|
||||
|
||||
import { Decoration, getPresets, Preset } from "../../lib/api";
|
||||
import { GUILD_ID, INVITE_KEY } from "../../lib/constants";
|
||||
|
|
@ -30,8 +29,6 @@ import SectionedGridList from "../components/SectionedGridList";
|
|||
import { openCreateDecorationModal } from "./CreateDecorationModal";
|
||||
import { openGuidelinesModal } from "./GuidelinesModal";
|
||||
|
||||
const UserSummaryItem = findComponentByCodeLazy("defaultRenderUser", "showDefaultAvatarsForNullUsers");
|
||||
|
||||
function usePresets() {
|
||||
const [presets, setPresets] = useState<Preset[]>([]);
|
||||
useEffect(() => { getPresets().then(setPresets); }, []);
|
||||
|
|
|
|||
|
|
@ -20,7 +20,7 @@ import { definePluginSettings } from "@api/Settings";
|
|||
import { disableStyle, enableStyle } from "@api/Styles";
|
||||
import ErrorBoundary from "@components/ErrorBoundary";
|
||||
import { ErrorCard } from "@components/ErrorCard";
|
||||
import { Devs } from "@utils/constants";
|
||||
import { Devs, IS_MAC } from "@utils/constants";
|
||||
import { Margins } from "@utils/margins";
|
||||
import definePlugin, { OptionType } from "@utils/types";
|
||||
import { findByPropsLazy, findLazy } from "@webpack";
|
||||
|
|
@ -31,9 +31,8 @@ import hideBugReport from "./hideBugReport.css?managed";
|
|||
const KbdStyles = findByPropsLazy("key", "combo");
|
||||
const BugReporterExperiment = findLazy(m => m?.definition?.id === "2024-09_bug_reporter");
|
||||
|
||||
const isMacOS = navigator.platform.includes("Mac");
|
||||
const modKey = isMacOS ? "cmd" : "ctrl";
|
||||
const altKey = isMacOS ? "opt" : "alt";
|
||||
const modKey = IS_MAC ? "cmd" : "ctrl";
|
||||
const altKey = IS_MAC ? "opt" : "alt";
|
||||
|
||||
const settings = definePluginSettings({
|
||||
toolbarDevMenu: {
|
||||
|
|
|
|||
|
|
@ -5,9 +5,8 @@
|
|||
*/
|
||||
|
||||
import { definePluginSettings } from "@api/Settings";
|
||||
import { makeRange } from "@components/PluginSettings/components";
|
||||
import { Devs } from "@utils/constants";
|
||||
import definePlugin, { OptionType } from "@utils/types";
|
||||
import definePlugin, { makeRange, OptionType } from "@utils/types";
|
||||
|
||||
// The entire code of this plugin can be found in ipcPlugins
|
||||
export default definePlugin({
|
||||
|
|
|
|||
|
|
@ -18,11 +18,10 @@
|
|||
|
||||
import { NavContextMenuPatchCallback } from "@api/ContextMenu";
|
||||
import { definePluginSettings } from "@api/Settings";
|
||||
import { makeRange } from "@components/PluginSettings/components";
|
||||
import { debounce } from "@shared/debounce";
|
||||
import { Devs } from "@utils/constants";
|
||||
import { Logger } from "@utils/Logger";
|
||||
import definePlugin, { OptionType } from "@utils/types";
|
||||
import definePlugin, { makeRange, OptionType } from "@utils/types";
|
||||
import { createRoot, Menu } from "@webpack/common";
|
||||
import { JSX } from "react";
|
||||
import type { Root } from "react-dom/client";
|
||||
|
|
|
|||
|
|
@ -17,14 +17,13 @@
|
|||
*/
|
||||
|
||||
import { definePluginSettings } from "@api/Settings";
|
||||
import { Devs } from "@utils/constants";
|
||||
import { Devs, IS_MAC } from "@utils/constants";
|
||||
import definePlugin, { OptionType } from "@utils/types";
|
||||
import { Message } from "@vencord/discord-types";
|
||||
import { ChannelStore, ComponentDispatch, FluxDispatcher as Dispatcher, MessageActions, MessageStore, PermissionsBits, PermissionStore, SelectedChannelStore, UserStore } from "@webpack/common";
|
||||
import NoBlockedMessagesPlugin from "plugins/noBlockedMessages";
|
||||
import NoReplyMentionPlugin from "plugins/noReplyMention";
|
||||
|
||||
const isMac = navigator.platform.includes("Mac"); // bruh
|
||||
let currentlyReplyingId: string | null = null;
|
||||
let currentlyEditingId: string | null = null;
|
||||
|
||||
|
|
@ -91,8 +90,8 @@ function onCreatePendingReply({ message, _isQuickReply }: { message: Message; _i
|
|||
currentlyReplyingId = message.id;
|
||||
}
|
||||
|
||||
const isCtrl = (e: KeyboardEvent) => isMac ? e.metaKey : e.ctrlKey;
|
||||
const isAltOrMeta = (e: KeyboardEvent) => e.altKey || (!isMac && e.metaKey);
|
||||
const isCtrl = (e: KeyboardEvent) => IS_MAC ? e.metaKey : e.ctrlKey;
|
||||
const isAltOrMeta = (e: KeyboardEvent) => e.altKey || (!IS_MAC && e.metaKey);
|
||||
|
||||
function onKeydown(e: KeyboardEvent) {
|
||||
const isUp = e.key === "ArrowUp";
|
||||
|
|
|
|||
|
|
@ -18,10 +18,9 @@
|
|||
|
||||
import { definePluginSettings } from "@api/Settings";
|
||||
import ErrorBoundary from "@components/ErrorBoundary";
|
||||
import { makeRange } from "@components/PluginSettings/components";
|
||||
import { Devs } from "@utils/constants";
|
||||
import { Logger } from "@utils/Logger";
|
||||
import definePlugin, { OptionType } from "@utils/types";
|
||||
import definePlugin, { makeRange, OptionType } from "@utils/types";
|
||||
import { findByCodeLazy } from "@webpack";
|
||||
import { ChannelStore, GuildMemberStore, GuildRoleStore, GuildStore } from "@webpack/common";
|
||||
|
||||
|
|
|
|||
|
|
@ -41,7 +41,6 @@ export interface HighlighterProps {
|
|||
lang?: string;
|
||||
content: string;
|
||||
isPreview: boolean;
|
||||
tempSettings?: Record<string, any>;
|
||||
}
|
||||
|
||||
export const createHighlighter = (props: HighlighterProps) => (
|
||||
|
|
@ -55,13 +54,12 @@ export const Highlighter = ({
|
|||
lang,
|
||||
content,
|
||||
isPreview,
|
||||
tempSettings,
|
||||
}: HighlighterProps) => {
|
||||
const {
|
||||
tryHljs,
|
||||
useDevIcon,
|
||||
bgOpacity,
|
||||
} = useShikiSettings(["tryHljs", "useDevIcon", "bgOpacity"], tempSettings);
|
||||
} = useShikiSettings(["tryHljs", "useDevIcon", "bgOpacity"]);
|
||||
const { id: currentThemeId, theme: currentTheme } = useTheme();
|
||||
|
||||
const shikiLang = lang ? resolveLang(lang) : null;
|
||||
|
|
|
|||
|
|
@ -16,32 +16,27 @@
|
|||
* along with this program. If not, see <https://www.gnu.org/licenses/>.
|
||||
*/
|
||||
|
||||
import { PartialExcept } from "@utils/types";
|
||||
import { React } from "@webpack/common";
|
||||
|
||||
import { shiki } from "../api/shiki";
|
||||
import { settings as pluginSettings, ShikiSettings } from "../settings";
|
||||
|
||||
export function useShikiSettings<F extends keyof ShikiSettings>(settingKeys: F[], overrides?: Partial<ShikiSettings>) {
|
||||
const settings: Partial<ShikiSettings> = pluginSettings.use(settingKeys);
|
||||
export function useShikiSettings<F extends keyof ShikiSettings>(settingKeys: F[]) {
|
||||
const settings = pluginSettings.use([...settingKeys, "customTheme", "theme"]);
|
||||
const [isLoading, setLoading] = React.useState(false);
|
||||
|
||||
const withOverrides = { ...settings, ...overrides } as PartialExcept<ShikiSettings, F>;
|
||||
const themeUrl = withOverrides.customTheme || withOverrides.theme;
|
||||
const themeUrl = settings.customTheme || settings.theme;
|
||||
|
||||
if (overrides) {
|
||||
const willChangeTheme = shiki.currentThemeUrl && themeUrl && themeUrl !== shiki.currentThemeUrl;
|
||||
const noOverrides = Object.keys(overrides).length === 0;
|
||||
|
||||
if (isLoading && (!willChangeTheme || noOverrides)) setLoading(false);
|
||||
if (isLoading && (!willChangeTheme)) setLoading(false);
|
||||
if (!isLoading && willChangeTheme) {
|
||||
setLoading(true);
|
||||
shiki.setTheme(themeUrl);
|
||||
}
|
||||
}
|
||||
|
||||
return {
|
||||
...withOverrides,
|
||||
isThemeLoading: themeUrl !== shiki.currentThemeUrl,
|
||||
...settings,
|
||||
isThemeLoading: isLoading,
|
||||
};
|
||||
}
|
||||
|
|
|
|||
|
|
@ -63,11 +63,10 @@ export default definePlugin({
|
|||
shiki.destroy();
|
||||
clearStyles();
|
||||
},
|
||||
settingsAboutComponent: ({ tempSettings }) => createHighlighter({
|
||||
settingsAboutComponent: () => createHighlighter({
|
||||
lang: "tsx",
|
||||
content: previewExampleText,
|
||||
isPreview: true,
|
||||
tempSettings,
|
||||
isPreview: true
|
||||
}),
|
||||
|
||||
// exports
|
||||
|
|
|
|||
|
|
@ -28,6 +28,7 @@ import definePlugin, { OptionType } from "@utils/types";
|
|||
import { ConnectedAccount, User } from "@vencord/discord-types";
|
||||
import { findByCodeLazy, findByPropsLazy } from "@webpack";
|
||||
import { Tooltip, UserProfileStore } from "@webpack/common";
|
||||
import OpenInAppPlugin from "plugins/openInApp";
|
||||
|
||||
import { VerifiedIcon } from "./VerifiedIcon";
|
||||
|
||||
|
|
@ -133,9 +134,8 @@ function CompactConnectionComponent({ connection, theme }: { connection: Connect
|
|||
rel="noreferrer"
|
||||
onClick={e => {
|
||||
if (Vencord.Plugins.isPluginEnabled("OpenInApp")) {
|
||||
const OpenInApp = Vencord.Plugins.plugins.OpenInApp as any as typeof import("../openInApp").default;
|
||||
// handleLink will .preventDefault() if applicable
|
||||
OpenInApp.handleLink(e.currentTarget, e);
|
||||
OpenInAppPlugin.handleLink(e.currentTarget, e);
|
||||
}
|
||||
}}
|
||||
>
|
||||
|
|
|
|||
|
|
@ -24,12 +24,11 @@ import { Devs } from "@utils/constants";
|
|||
import { getIntlMessage } from "@utils/discord";
|
||||
import definePlugin, { OptionType } from "@utils/types";
|
||||
import { findComponentByCodeLazy, findStoreLazy } from "@webpack";
|
||||
import { GuildMemberStore, RelationshipStore, SelectedChannelStore, Tooltip, UserStore, useStateFromStores } from "@webpack/common";
|
||||
import { GuildMemberStore, RelationshipStore, SelectedChannelStore, Tooltip, UserStore, UserSummaryItem, useStateFromStores } from "@webpack/common";
|
||||
|
||||
import { buildSeveralUsers } from "../typingTweaks";
|
||||
|
||||
const ThreeDots = findComponentByCodeLazy(".dots,", "dotRadius:");
|
||||
const UserSummaryItem = findComponentByCodeLazy("defaultRenderUser", "showDefaultAvatarsForNullUsers");
|
||||
|
||||
const TypingStore = findStoreLazy("TypingStore");
|
||||
const UserGuildSettingsStore = findStoreLazy("UserGuildSettingsStore");
|
||||
|
|
|
|||
|
|
@ -5,9 +5,8 @@
|
|||
*/
|
||||
|
||||
import { definePluginSettings } from "@api/Settings";
|
||||
import { makeRange } from "@components/PluginSettings/components";
|
||||
import { Devs } from "@utils/constants";
|
||||
import definePlugin, { OptionType } from "@utils/types";
|
||||
import definePlugin, { makeRange, OptionType } from "@utils/types";
|
||||
|
||||
const settings = definePluginSettings({
|
||||
zoomMultiplier: {
|
||||
|
|
|
|||
|
|
@ -9,7 +9,7 @@ import ErrorBoundary from "@components/ErrorBoundary";
|
|||
import { classes } from "@utils/misc";
|
||||
import { Channel } from "@vencord/discord-types";
|
||||
import { filters, findByCodeLazy, findByPropsLazy, findComponentByCodeLazy, findStoreLazy, mapMangledModuleLazy } from "@webpack";
|
||||
import { ChannelRouter, ChannelStore, GuildStore, IconUtils, match, P, PermissionsBits, PermissionStore, React, showToast, Text, Toasts, Tooltip, useMemo, UserStore, useStateFromStores } from "@webpack/common";
|
||||
import { ChannelRouter, ChannelStore, GuildStore, IconUtils, match, P, PermissionsBits, PermissionStore, React, showToast, Text, Toasts, Tooltip, useMemo, UserStore, UserSummaryItem, useStateFromStores } from "@webpack/common";
|
||||
|
||||
const cl = classNameFactory("vc-uvs-");
|
||||
|
||||
|
|
@ -20,7 +20,6 @@ const { useChannelName } = mapMangledModuleLazy("#{intl::GROUP_DM_ALONE}", {
|
|||
const getDMChannelIcon = findByCodeLazy(".getChannelIconURL({");
|
||||
const VoiceStateStore = findStoreLazy("VoiceStateStore");
|
||||
|
||||
const UserSummaryItem = findComponentByCodeLazy("defaultRenderUser", "showDefaultAvatarsForNullUsers");
|
||||
const Avatar = findComponentByCodeLazy(".status)/2):0");
|
||||
const GroupDMAvatars = findComponentByCodeLazy("frontSrc:", "getAvatarURL");
|
||||
|
||||
|
|
|
|||
|
|
@ -17,7 +17,7 @@
|
|||
*/
|
||||
|
||||
import { ErrorCard } from "@components/ErrorCard";
|
||||
import { Devs } from "@utils/constants";
|
||||
import { Devs, IS_LINUX } from "@utils/constants";
|
||||
import { Logger } from "@utils/Logger";
|
||||
import { Margins } from "@utils/margins";
|
||||
import { wordsToTitle } from "@utils/text";
|
||||
|
|
@ -44,9 +44,11 @@ const VoiceStateStore = findByPropsLazy("getVoiceStatesForChannel", "getCurrentC
|
|||
// Filtering out events is not as simple as just dropping duplicates, as otherwise mute, unmute, mute would
|
||||
// not say the second mute, which would lead you to believe they're unmuted
|
||||
|
||||
function speak(text: string, { volume, rate } = settings.store) {
|
||||
function speak(text: string) {
|
||||
if (!text) return;
|
||||
|
||||
const { volume, rate } = settings.store;
|
||||
|
||||
const speech = new SpeechSynthesisUtterance(text);
|
||||
const voice = getCurrentVoice();
|
||||
speech.voice = voice!;
|
||||
|
|
@ -139,19 +141,17 @@ function updateStatuses(type: string, { deaf, mute, selfDeaf, selfMute, userId,
|
|||
}
|
||||
*/
|
||||
|
||||
function playSample(tempSettings: any, type: string) {
|
||||
const s = Object.assign({}, settings.plain, tempSettings);
|
||||
function playSample(type: string) {
|
||||
const currentUser = UserStore.getCurrentUser();
|
||||
const myGuildId = SelectedGuildStore.getGuildId();
|
||||
|
||||
speak(formatText(
|
||||
s[type + "Message"],
|
||||
settings.store[type + "Message"],
|
||||
currentUser.username,
|
||||
"general",
|
||||
currentUser.globalName ?? currentUser.username,
|
||||
GuildMemberStore.getNick(myGuildId!, currentUser.id) ?? currentUser.username),
|
||||
s
|
||||
);
|
||||
GuildMemberStore.getNick(myGuildId!, currentUser.id) ?? currentUser.username
|
||||
));
|
||||
}
|
||||
|
||||
export default definePlugin({
|
||||
|
|
@ -222,7 +222,7 @@ export default definePlugin({
|
|||
|
||||
},
|
||||
|
||||
settingsAboutComponent({ tempSettings: s }) {
|
||||
settingsAboutComponent() {
|
||||
const [hasVoices, hasEnglishVoices] = useMemo(() => {
|
||||
const voices = speechSynthesis.getVoices();
|
||||
return [voices.length !== 0, voices.some(v => v.lang.startsWith("en"))];
|
||||
|
|
@ -236,7 +236,7 @@ export default definePlugin({
|
|||
let errorComponent: ReactElement<any> | null = null;
|
||||
if (!hasVoices) {
|
||||
let error = "No narrator voices found. ";
|
||||
error += navigator.platform?.toLowerCase().includes("linux")
|
||||
error += IS_LINUX
|
||||
? "Install speech-dispatcher or espeak and run Discord with the --enable-speech-dispatcher flag"
|
||||
: "Try installing some in the Narrator settings of your Operating System";
|
||||
errorComponent = <ErrorCard>{error}</ErrorCard>;
|
||||
|
|
@ -265,7 +265,7 @@ export default definePlugin({
|
|||
className={"vc-narrator-buttons"}
|
||||
>
|
||||
{types.map(t => (
|
||||
<Button key={t} onClick={() => playSample(s, t)}>
|
||||
<Button key={t} onClick={() => playSample(t)}>
|
||||
{wordsToTitle([t])}
|
||||
</Button>
|
||||
))}
|
||||
|
|
|
|||
|
|
@ -17,9 +17,8 @@
|
|||
*/
|
||||
|
||||
import { definePluginSettings } from "@api/Settings";
|
||||
import { makeRange } from "@components/PluginSettings/components";
|
||||
import { Devs } from "@utils/constants";
|
||||
import definePlugin, { OptionType } from "@utils/types";
|
||||
import definePlugin, { makeRange, OptionType } from "@utils/types";
|
||||
|
||||
const settings = definePluginSettings({
|
||||
multiplier: {
|
||||
|
|
|
|||
|
|
@ -16,7 +16,7 @@
|
|||
* along with this program. If not, see <https://www.gnu.org/licenses/>.
|
||||
*/
|
||||
|
||||
import { Devs } from "@utils/constants";
|
||||
import { Devs, IS_MAC } from "@utils/constants";
|
||||
import definePlugin from "@utils/types";
|
||||
import { findByPropsLazy } from "@webpack";
|
||||
import { ComponentDispatch, FluxDispatcher, NavigationRouter, SelectedGuildStore, SettingsRouter } from "@webpack/common";
|
||||
|
|
@ -30,7 +30,7 @@ export default definePlugin({
|
|||
enabledByDefault: true,
|
||||
|
||||
onKey(e: KeyboardEvent) {
|
||||
const hasCtrl = e.ctrlKey || (e.metaKey && navigator.platform.includes("Mac"));
|
||||
const hasCtrl = e.ctrlKey || (e.metaKey && IS_MAC);
|
||||
|
||||
if (hasCtrl) switch (e.key) {
|
||||
case "t":
|
||||
|
|
|
|||
|
|
@ -23,10 +23,9 @@ import { Queue } from "@utils/Queue";
|
|||
import { useForceUpdater } from "@utils/react";
|
||||
import definePlugin from "@utils/types";
|
||||
import { CustomEmoji, Message, ReactionEmoji, User } from "@vencord/discord-types";
|
||||
import { findByPropsLazy, findComponentByCodeLazy } from "@webpack";
|
||||
import { ChannelStore, Constants, FluxDispatcher, React, RestAPI, Tooltip, useEffect, useLayoutEffect } from "@webpack/common";
|
||||
import { findByPropsLazy } from "@webpack";
|
||||
import { ChannelStore, Constants, FluxDispatcher, React, RestAPI, Tooltip, useEffect, useLayoutEffect, UserSummaryItem } from "@webpack/common";
|
||||
|
||||
const UserSummaryItem = findComponentByCodeLazy("defaultRenderUser", "showDefaultAvatarsForNullUsers");
|
||||
const AvatarStyles = findByPropsLazy("moreUsers", "emptyUser", "avatarContainer", "clickableAvatar");
|
||||
let Scroll: any = null;
|
||||
const queue = new Queue();
|
||||
|
|
|
|||
|
|
@ -5,10 +5,9 @@
|
|||
*/
|
||||
|
||||
import { definePluginSettings } from "@api/Settings";
|
||||
import { makeRange } from "@components/PluginSettings/components";
|
||||
import { Devs } from "@utils/constants";
|
||||
import { Logger } from "@utils/Logger";
|
||||
import definePlugin, { OptionType, PluginNative, ReporterTestable } from "@utils/types";
|
||||
import definePlugin, { makeRange, OptionType, PluginNative, ReporterTestable } from "@utils/types";
|
||||
import type { Channel, Embed, GuildMember, MessageAttachment, User } from "@vencord/discord-types";
|
||||
import { findByCodeLazy, findLazy } from "@webpack";
|
||||
import { Button, ChannelStore, GuildRoleStore, GuildStore, UserStore } from "@webpack/common";
|
||||
|
|
|
|||
|
|
@ -26,6 +26,11 @@ export const SUPPORT_CHANNEL_ID = "1026515880080842772";
|
|||
export const SUPPORT_CATEGORY_ID = "1108135649699180705";
|
||||
export const KNOWN_ISSUES_CHANNEL_ID = "1222936386626129920";
|
||||
|
||||
const platform = navigator.platform.toLowerCase();
|
||||
export const IS_WINDOWS = platform.startsWith("win");
|
||||
export const IS_MAC = platform.startsWith("mac");
|
||||
export const IS_LINUX = platform.startsWith("linux");
|
||||
|
||||
export interface Dev {
|
||||
name: string;
|
||||
id: bigint;
|
||||
|
|
|
|||
|
|
@ -145,3 +145,10 @@ export function useTimer({ interval = 1000, deps = [] }: TimerOpts) {
|
|||
|
||||
return time;
|
||||
}
|
||||
|
||||
export function useCleanupEffect(
|
||||
effect: () => void,
|
||||
deps?: React.DependencyList
|
||||
): void {
|
||||
useEffect(() => effect, deps);
|
||||
}
|
||||
|
|
|
|||
|
|
@ -26,11 +26,18 @@ import { MessageClickListener, MessageEditListener, MessageSendListener } from "
|
|||
import { MessagePopoverButtonFactory } from "@api/MessagePopover";
|
||||
import { Command, FluxEvents } from "@vencord/discord-types";
|
||||
import { ReactNode } from "react";
|
||||
import { Promisable } from "type-fest";
|
||||
|
||||
// exists to export default definePlugin({...})
|
||||
export default function definePlugin<P extends PluginDef>(p: P & Record<string, any>) {
|
||||
return p;
|
||||
export default function definePlugin<P extends PluginDef>(p: P & Record<PropertyKey, any>) {
|
||||
return p as typeof p & Plugin;
|
||||
}
|
||||
|
||||
export function makeRange(start: number, end: number, step = 1) {
|
||||
const ranges: number[] = [];
|
||||
for (let value = start; value <= end; value += step) {
|
||||
ranges.push(Math.round(value * 100) / 100);
|
||||
}
|
||||
return ranges;
|
||||
}
|
||||
|
||||
export type ReplaceFn = (match: string, ...groups: string[]) => string;
|
||||
|
|
@ -135,18 +142,11 @@ export interface PluginDef {
|
|||
* Optionally provide settings that the user can configure in the Plugins tab of settings.
|
||||
*/
|
||||
settings?: DefinedSettings;
|
||||
/**
|
||||
* Check that this returns true before allowing a save to complete.
|
||||
* If a string is returned, show the error to the user.
|
||||
*/
|
||||
beforeSave?(options: Record<string, any>): Promisable<true | string>;
|
||||
/**
|
||||
* Allows you to specify a custom Component that will be rendered in your
|
||||
* plugin's settings page
|
||||
*/
|
||||
settingsAboutComponent?: React.ComponentType<{
|
||||
tempSettings?: Record<string, any>;
|
||||
}>;
|
||||
settingsAboutComponent?: React.ComponentType<{}>;
|
||||
/**
|
||||
* Allows you to subscribe to Flux events
|
||||
*/
|
||||
|
|
@ -322,13 +322,6 @@ export interface IPluginOptionComponentProps {
|
|||
* NOTE: The user will still need to click save to apply these changes.
|
||||
*/
|
||||
setValue(newValue: any): void;
|
||||
/**
|
||||
* Set to true to prevent the user from saving.
|
||||
*
|
||||
* NOTE: This will not show the error to the user. It will only stop them saving.
|
||||
* Make sure to show the error in your component.
|
||||
*/
|
||||
setError(error: boolean): void;
|
||||
/**
|
||||
* The options object
|
||||
*/
|
||||
|
|
|
|||
|
|
@ -67,6 +67,7 @@ export const Avatar = waitForComponent<t.Avatar>("Avatar", filters.componentByCo
|
|||
|
||||
export const ColorPicker = waitForComponent<t.ColorPicker>("ColorPicker", filters.componentByCode("#{intl::USER_SETTINGS_PROFILE_COLOR_SELECT_COLOR}", "showEyeDropper"));
|
||||
|
||||
export const UserSummaryItem = waitForComponent("UserSummaryItem", filters.componentByCode("defaultRenderUser", "showDefaultAvatarsForNullUsers"));
|
||||
|
||||
export let createScroller: (scrollbarClassName: string, fadeClassName: string, customThemeClassName: string) => t.ScrollerThin;
|
||||
export let scrollerClasses: Record<string, string>;
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue