2025-10-12 06:13:36 +02:00
/ *
* Vencord , a Discord client mod
* Copyright ( c ) 2025 Vendicated and contributors
* SPDX - License - Identifier : GPL - 3.0 - or - later
* /
import "./settings.css" ;
2025-11-16 15:17:35 +01:00
import { isPluginEnabled } from "@api/PluginManager" ;
2025-10-12 06:13:36 +02:00
import { Divider } from "@components/Divider" ;
import { Heading } from "@components/Heading" ;
import { resolveError } from "@components/settings/tabs/plugins/components/Common" ;
import { debounce } from "@shared/debounce" ;
2025-12-20 01:02:43 +01:00
import { classNameFactory } from "@utils/css" ;
2025-10-12 06:13:36 +02:00
import { ActivityType } from "@vencord/discord-types/enums" ;
import { Select , Text , TextInput , useState } from "@webpack/common" ;
2025-11-16 15:17:35 +01:00
import CustomRPCPlugin , { setRpc , settings , TimestampMode } from "." ;
2025-10-12 06:13:36 +02:00
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 ) ;
2025-11-16 15:17:35 +01:00
if ( isPluginEnabled ( CustomRPCPlugin . name ) ) setRpc ( ) ;
2025-10-12 06:13:36 +02:00
} ) ;
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 ;
}
2025-11-02 18:24:16 +02:00
function isUrlValid ( value : string ) {
if ( value && ! /^https?:\/\/.+/ . test ( value ) ) return "Must be a valid URL." ;
return true ;
}
2025-10-12 06:13:36 +02:00
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 }
fix many bugs found by Claude (#4436)
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).
2026-07-26 17:07:47 +02:00
select = { v = > {
settings . store [ settingsKey ] = v ;
updateRPC ( ) ;
} }
2025-10-12 06:13:36 +02:00
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 ) } ,
] } / >
2025-11-02 18:24:16 +02:00
< 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 } ,
] } / >
2025-10-12 06:13:36 +02:00
< 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 ,
2025-11-02 18:24:16 +02:00
isValid : isNumberValid ,
disabled : s.type !== ActivityType . PLAYING ,
2025-10-12 06:13:36 +02:00
} ,
{
settingsKey : "partyMaxSize" ,
label : "Maximum Party Size" ,
transform : parseNumber ,
2025-11-02 18:24:16 +02:00
isValid : isNumberValid ,
disabled : s.type !== ActivityType . PLAYING ,
2025-10-12 06:13:36 +02:00
} ,
] } / >
< Divider / >
< PairSetting data = { [
{ settingsKey : "imageBig" , label : "Large Image URL/Key" , isValid : isImageKeyValid } ,
{ settingsKey : "imageBigTooltip" , label : "Large Image Text" , isValid : maxLength128 } ,
] } / >
2025-11-02 18:24:16 +02:00
< SingleSetting settingsKey = "imageBigURL" label = "Large Image clickable URL" isValid = { isUrlValid } / >
2025-10-12 06:13:36 +02:00
< PairSetting data = { [
{ settingsKey : "imageSmall" , label : "Small Image URL/Key" , isValid : isImageKeyValid } ,
{ settingsKey : "imageSmallTooltip" , label : "Small Image Text" , isValid : maxLength128 } ,
] } / >
2025-11-02 18:24:16 +02:00
< SingleSetting settingsKey = "imageSmallURL" label = "Small Image clickable URL" isValid = { isUrlValid } / >
2025-10-12 06:13:36 +02:00
< Divider / >
< PairSetting data = { [
{ settingsKey : "buttonOneText" , label : "Button1 Text" , isValid : makeValidator ( 31 ) } ,
2025-11-02 18:24:16 +02:00
{ settingsKey : "buttonOneURL" , label : "Button1 URL" , isValid : isUrlValid } ,
2025-10-12 06:13:36 +02:00
] } / >
< PairSetting data = { [
{ settingsKey : "buttonTwoText" , label : "Button2 Text" , isValid : makeValidator ( 31 ) } ,
2025-11-02 18:24:16 +02:00
{ settingsKey : "buttonTwoURL" , label : "Button2 URL" , isValid : isUrlValid } ,
2025-10-12 06:13:36 +02:00
] } / >
< 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 >
) ;
}