Fixes: - Webpack Patcher: incorrect patch error handling could lead to some functional patches not being applied - CommandsAPI: subcommands were impossible to unregister - useAwaiter: on error, the value was set to null even if a fallbackValue was specified, violating the type contract - Patcher: build check boolean logic was grouped incorrectly due to missing parantheses - CustomCommands: commands weren't unregistered on stop, leading to error when enabling, disabling & re-renabling the plugin - Notification Log: delete was by timestamp instead of ID and non-atomic - FixSpotifyEmbeds: settings listener was registered inside browser-window-created event handler, leading to duplicate listeners - CustomRPC: changing activity type or timestamp didn't update the RPC - CopyStickerLinks: message patch had non-sense code, just removed it - XSOverlay: UDP socket wasn't closed on stop - VoiceMessages: Web recorder never closed the mic media stream, which continues showing the OS mic indicator - PictureInPicture: Video clone wasn't properly cleaned up, which leaked memory - Themes (Extension version): theme object urls were never cleaned up, which leaked memory on theme change - Themes (Extension version): Upload Theme button hitbox was only the text, not the whole button. Also added Drag & Drop from file manager support - Dearrow: toggling for one embed flipped the global dearrow by default setting - Extension Installer: the zip extract function was vulnerable to path traversal, although we only ever used this for React DevTools obtained via https so it has low impact - FileWatchers: ensure always properly cleaned up - components/Link: mutated props which is a very bad practice All fixes are human written, only the audit was done by Claude Fable (thanks for ur tokens, Zere).
295 lines
10 KiB
TypeScript
295 lines
10 KiB
TypeScript
/*
|
|
* Vencord, a Discord client mod
|
|
* Copyright (c) 2025 Vendicated and contributors
|
|
* SPDX-License-Identifier: GPL-3.0-or-later
|
|
*/
|
|
|
|
import "./settings.css";
|
|
|
|
import { isPluginEnabled } from "@api/PluginManager";
|
|
import { Divider } from "@components/Divider";
|
|
import { Heading } from "@components/Heading";
|
|
import { resolveError } from "@components/settings/tabs/plugins/components/Common";
|
|
import { debounce } from "@shared/debounce";
|
|
import { classNameFactory } from "@utils/css";
|
|
import { ActivityType } from "@vencord/discord-types/enums";
|
|
import { Select, Text, TextInput, useState } from "@webpack/common";
|
|
|
|
import CustomRPCPlugin, { setRpc, settings, TimestampMode } from ".";
|
|
|
|
const cl = classNameFactory("vc-customRPC-settings-");
|
|
|
|
type SettingsKey = keyof typeof settings.store;
|
|
|
|
interface TextOption<T> {
|
|
settingsKey: SettingsKey;
|
|
label: string;
|
|
disabled?: boolean;
|
|
transform?: (value: string) => T;
|
|
isValid?: (value: T) => true | string;
|
|
}
|
|
|
|
interface SelectOption<T> {
|
|
settingsKey: SettingsKey;
|
|
label: string;
|
|
disabled?: boolean;
|
|
options: { label: string; value: T; default?: boolean; }[];
|
|
}
|
|
|
|
const makeValidator = (maxLength: number, isRequired = false) => (value: string) => {
|
|
if (isRequired && !value) return "This field is required.";
|
|
if (value.length > maxLength) return `Must be not longer than ${maxLength} characters.`;
|
|
return true;
|
|
};
|
|
|
|
const maxLength128 = makeValidator(128);
|
|
|
|
function isAppIdValid(value: string) {
|
|
if (!/^\d{16,21}$/.test(value)) return "Must be a valid Discord ID.";
|
|
return true;
|
|
}
|
|
|
|
const updateRPC = debounce(() => {
|
|
setRpc(true);
|
|
if (isPluginEnabled(CustomRPCPlugin.name)) setRpc();
|
|
});
|
|
|
|
function isStreamLinkDisabled() {
|
|
return settings.store.type !== ActivityType.STREAMING;
|
|
}
|
|
|
|
function isStreamLinkValid(value: string) {
|
|
if (!isStreamLinkDisabled() && !/https?:\/\/(www\.)?(twitch\.tv|youtube\.com)\/\w+/.test(value)) return "Streaming link must be a valid URL.";
|
|
if (value && value.length > 512) return "Streaming link must be not longer than 512 characters.";
|
|
return true;
|
|
}
|
|
|
|
function parseNumber(value: string) {
|
|
return value ? parseInt(value, 10) : 0;
|
|
}
|
|
|
|
function isNumberValid(value: number) {
|
|
if (isNaN(value)) return "Must be a number.";
|
|
if (value < 0) return "Must be a positive number.";
|
|
return true;
|
|
}
|
|
|
|
function isUrlValid(value: string) {
|
|
if (value && !/^https?:\/\/.+/.test(value)) return "Must be a valid URL.";
|
|
return true;
|
|
}
|
|
|
|
function isImageKeyValid(value: string) {
|
|
if (/https?:\/\/(cdn|media)\.discordapp\.(com|net)\//.test(value)) return "Don't use a Discord link. Use an Imgur image link instead.";
|
|
if (/https?:\/\/(?!i\.)?imgur\.com\//.test(value)) return "Imgur link must be a direct link to the image (e.g. https://i.imgur.com/...). Right click the image and click 'Copy image address'";
|
|
if (/https?:\/\/(?!media\.)?tenor\.com\//.test(value)) return "Tenor link must be a direct link to the image (e.g. https://media.tenor.com/...). Right click the GIF and click 'Copy image address'";
|
|
return true;
|
|
}
|
|
|
|
function PairSetting<T>(props: { data: [TextOption<T>, TextOption<T>]; }) {
|
|
const [left, right] = props.data;
|
|
|
|
return (
|
|
<div className={cl("pair")}>
|
|
<SingleSetting {...left} />
|
|
<SingleSetting {...right} />
|
|
</div>
|
|
);
|
|
}
|
|
|
|
function SingleSetting<T>({ settingsKey, label, disabled, isValid, transform }: TextOption<T>) {
|
|
const [state, setState] = useState(settings.store[settingsKey] ?? "");
|
|
const [error, setError] = useState<string | null>(null);
|
|
|
|
function handleChange(newValue: any) {
|
|
if (transform) newValue = transform(newValue);
|
|
|
|
const valid = isValid?.(newValue) ?? true;
|
|
|
|
setState(newValue);
|
|
setError(resolveError(valid));
|
|
|
|
if (valid === true) {
|
|
settings.store[settingsKey] = newValue;
|
|
updateRPC();
|
|
}
|
|
}
|
|
|
|
return (
|
|
<div className={cl("single", { disabled })}>
|
|
<Heading tag="h5">{label}</Heading>
|
|
<TextInput
|
|
type="text"
|
|
placeholder={"Enter a value"}
|
|
value={state}
|
|
onChange={handleChange}
|
|
disabled={disabled}
|
|
/>
|
|
{error && <Text className={cl("error")} variant="text-sm/normal">{error}</Text>}
|
|
</div>
|
|
);
|
|
}
|
|
|
|
function SelectSetting<T>({ settingsKey, label, options, disabled }: SelectOption<T>) {
|
|
return (
|
|
<div className={cl("single", { disabled })}>
|
|
<Heading tag="h5">{label}</Heading>
|
|
<Select
|
|
placeholder={"Select an option"}
|
|
options={options}
|
|
maxVisibleItems={5}
|
|
closeOnSelect={true}
|
|
select={v => {
|
|
settings.store[settingsKey] = v;
|
|
updateRPC();
|
|
}}
|
|
isSelected={v => v === settings.store[settingsKey]}
|
|
serialize={v => String(v)}
|
|
isDisabled={disabled}
|
|
/>
|
|
</div>
|
|
);
|
|
}
|
|
|
|
export function RPCSettings() {
|
|
const s = settings.use();
|
|
|
|
return (
|
|
<div className={cl("root")}>
|
|
<SelectSetting
|
|
settingsKey="type"
|
|
label="Activity Type"
|
|
options={[
|
|
{
|
|
label: "Playing",
|
|
value: ActivityType.PLAYING,
|
|
default: true
|
|
},
|
|
{
|
|
label: "Streaming",
|
|
value: ActivityType.STREAMING
|
|
},
|
|
{
|
|
label: "Listening",
|
|
value: ActivityType.LISTENING
|
|
},
|
|
{
|
|
label: "Watching",
|
|
value: ActivityType.WATCHING
|
|
},
|
|
{
|
|
label: "Competing",
|
|
value: ActivityType.COMPETING
|
|
}
|
|
]}
|
|
/>
|
|
|
|
<PairSetting data={[
|
|
{ settingsKey: "appID", label: "Application ID", isValid: isAppIdValid },
|
|
{ settingsKey: "appName", label: "Application Name", isValid: makeValidator(128, true) },
|
|
]} />
|
|
|
|
<PairSetting data={[
|
|
{ settingsKey: "details", label: "Detail (line 1)", isValid: maxLength128 },
|
|
{ settingsKey: "detailsURL", label: "Detail URL", isValid: isUrlValid },
|
|
]} />
|
|
|
|
<PairSetting data={[
|
|
{ settingsKey: "state", label: "State (line 2)", isValid: maxLength128 },
|
|
{ settingsKey: "stateURL", label: "State URL", isValid: isUrlValid },
|
|
]} />
|
|
|
|
<SingleSetting
|
|
settingsKey="streamLink"
|
|
label="Stream Link (Twitch or YouTube, only if activity type is Streaming)"
|
|
disabled={s.type !== ActivityType.STREAMING}
|
|
isValid={isStreamLinkValid}
|
|
/>
|
|
|
|
<PairSetting data={[
|
|
{
|
|
settingsKey: "partySize",
|
|
label: "Party Size",
|
|
transform: parseNumber,
|
|
isValid: isNumberValid,
|
|
disabled: s.type !== ActivityType.PLAYING,
|
|
},
|
|
{
|
|
settingsKey: "partyMaxSize",
|
|
label: "Maximum Party Size",
|
|
transform: parseNumber,
|
|
isValid: isNumberValid,
|
|
disabled: s.type !== ActivityType.PLAYING,
|
|
},
|
|
]} />
|
|
|
|
<Divider />
|
|
|
|
<PairSetting data={[
|
|
{ settingsKey: "imageBig", label: "Large Image URL/Key", isValid: isImageKeyValid },
|
|
{ settingsKey: "imageBigTooltip", label: "Large Image Text", isValid: maxLength128 },
|
|
]} />
|
|
<SingleSetting settingsKey="imageBigURL" label="Large Image clickable URL" isValid={isUrlValid} />
|
|
|
|
<PairSetting data={[
|
|
{ settingsKey: "imageSmall", label: "Small Image URL/Key", isValid: isImageKeyValid },
|
|
{ settingsKey: "imageSmallTooltip", label: "Small Image Text", isValid: maxLength128 },
|
|
]} />
|
|
<SingleSetting settingsKey="imageSmallURL" label="Small Image clickable URL" isValid={isUrlValid} />
|
|
|
|
<Divider />
|
|
|
|
<PairSetting data={[
|
|
{ settingsKey: "buttonOneText", label: "Button1 Text", isValid: makeValidator(31) },
|
|
{ settingsKey: "buttonOneURL", label: "Button1 URL", isValid: isUrlValid },
|
|
]} />
|
|
<PairSetting data={[
|
|
{ settingsKey: "buttonTwoText", label: "Button2 Text", isValid: makeValidator(31) },
|
|
{ settingsKey: "buttonTwoURL", label: "Button2 URL", isValid: isUrlValid },
|
|
]} />
|
|
|
|
<Divider />
|
|
|
|
<SelectSetting
|
|
settingsKey="timestampMode"
|
|
label="Timestamp Mode"
|
|
options={[
|
|
{
|
|
label: "None",
|
|
value: TimestampMode.NONE,
|
|
default: true
|
|
},
|
|
{
|
|
label: "Since discord open",
|
|
value: TimestampMode.NOW
|
|
},
|
|
{
|
|
label: "Same as your current time (not reset after 24h)",
|
|
value: TimestampMode.TIME
|
|
},
|
|
{
|
|
label: "Custom",
|
|
value: TimestampMode.CUSTOM
|
|
}
|
|
]}
|
|
/>
|
|
|
|
<PairSetting data={[
|
|
{
|
|
settingsKey: "startTime",
|
|
label: "Start Timestamp (in milliseconds)",
|
|
transform: parseNumber,
|
|
isValid: isNumberValid,
|
|
disabled: s.timestampMode !== TimestampMode.CUSTOM,
|
|
},
|
|
{
|
|
settingsKey: "endTime",
|
|
label: "End Timestamp (in milliseconds)",
|
|
transform: parseNumber,
|
|
isValid: isNumberValid,
|
|
disabled: s.timestampMode !== TimestampMode.CUSTOM,
|
|
},
|
|
]} />
|
|
</div>
|
|
);
|
|
}
|