Compare commits

..

117 commits
hive ... main

Author SHA1 Message Date
Eve
b79b102833
add the plugin
Some checks failed
Build DevBuild / Build (push) Has been cancelled
Sync to Codeberg / codeberg (push) Has been cancelled
test / test (push) Has been cancelled
2025-09-29 20:56:18 -04:00
Eve
2a33d719ee Bump Vencord version. 2025-09-29 16:53:07 -04:00
sadan4
8943c90cb0
arRPC: increase connection timeout to 5s (#3680)
Co-authored-by: V <vendicated@riseup.net>
2025-09-28 21:34:23 +00:00
V
f040133412
QuickReply/MessageClickActions: ignore non-replyable & ephemeral messages (#3692)
Co-authored-by: YashRaj <91825864+YashRajCodes@users.noreply.github.com>
2025-09-28 23:18:23 +02:00
TheRealClarity
631c763fc4
YoutubeAdblock: fix blocking in watch together activity (#3690) 2025-09-28 22:46:14 +02:00
sadan4
79c5cf5304
fix plugins for latest Discord update (#3681)
Co-authored-by: Nuckyz <61953774+Nuckyz@users.noreply.github.com>
2025-09-27 21:59:18 +02:00
Vendicated
c5a1bbd7db
GameActivityToggle: fix background colour when using nameplate 2025-09-25 15:19:45 +02:00
thororen
7c839be64f
BetterFolders: close folder if the last server is removed (#3658)
Co-authored-by: V <vendicated@riseup.net>
2025-09-24 14:49:50 +00:00
Vendicated
cb845b5224
PlatformIndicators: update indicators in real time if status changes
Closes #3516
2025-09-23 23:58:47 +02:00
Vendicated
746c824020
Fix Debug Logging toggle not working
Closes #3268
2025-09-23 22:24:58 +02:00
Vendicated
228b85a0c8
bump to v1.13.0 2025-09-22 05:18:10 +02:00
thororen
2e9d67f0b4
add Vencord badges to user settings section (#3667)
Co-authored-by: V <vendicated@riseup.net>
2025-09-22 05:15:14 +02:00
nin0
edd68fe08e
AppleMusicRichPresence: add status display type (#3669)
Co-authored-by: V <vendicated@riseup.net>
2025-09-22 03:08:08 +00:00
quarty
80872f4ab9
MessageLatency: add option to ignore own messages (#3677)
Co-authored-by: V <vendicated@riseup.net>
2025-09-22 02:44:13 +00:00
thororen
8c2dc84f3b
Settings: fix debug info layout (#3673) 2025-09-22 04:38:57 +02:00
Vendicated
3005906a28
CallTimer: fix horizontal text cutoff 2025-09-20 20:37:28 +02:00
Gabriel
56d25b03f9
UserVoiceShow: Improve tooltip & add icons for muted/deafened (#3630)
Co-authored-by: V <vendicated@riseup.net>
Co-authored-by: Nuckyz <61953774+Nuckyz@users.noreply.github.com>
2025-09-17 03:35:01 +02:00
sadan4
fbc2dbe781
FakeNitro: fix nitro themes not working for some users (#3666) 2025-09-10 04:36:34 +02:00
thororen
9c0af5adee
Fix broken MessagePopoverAPI not working (#3661) 2025-09-09 16:28:37 +00:00
Vendicated
479d01a1b9
fix FavGifSearch regression 2025-09-09 03:57:00 +02:00
thororen
8eabb11125
ClearURLs: use rules from ClearURLs browser extension (#3657)
Co-authored-by: V <vendicated@riseup.net>
2025-09-09 01:35:01 +00:00
Nuckyz
98058f0cae
Fix mistakes 2025-09-08 22:27:41 -03:00
Nuckyz
50eb62045a
Fix FavoriteGifSearch & broken Menu component find 2025-09-08 22:18:39 -03:00
Nuckyz
4e8d22b4d5
NoPendingCount: Improve slow patch 2025-09-08 22:02:04 -03:00
sadan4
51c23ff796
FakeNitro: Add custom client theme color picker (#3534) 2025-09-08 21:51:03 -03:00
Vendicated
dc72ee3809
GameActivityToggle: fix overflow 2025-09-09 02:23:57 +02:00
thororen1234
4c7acbbbc7
fix NoPendingCount 2025-09-09 02:08:44 +02:00
thororen
0f29eab3ea
MemberCount: use circle svg instead of css hacks (#3653)
fixes some deformations
2025-09-09 02:03:12 +02:00
V
c38aac23fd
improve various types (#3663)
Co-authored-by: V <vendicated@riseup.net>
Co-authored-by: John Davis <70701251+gobuster@users.noreply.github.com>
Co-authored-by: sadan4 <117494111+sadan4@users.noreply.github.com>
2025-09-08 23:48:53 +00:00
Ryan Cao
84957b0e88
improve wordsFromCamel correctness (#3621)
Co-authored-by: V <vendicated@riseup.net>
2025-09-09 00:49:24 +02:00
ww
a4e1d026ea
VolumeBooster: make multiplier option more flexible (#3656) 2025-09-08 01:30:08 +00:00
Vendicated
b225f2ec6c
SpotifyControls: add copy song/artist/album name options 2025-09-08 02:55:02 +02:00
nin0
efecbae75b
LastFMRPC: add setting to show artist/song name in member list (#3629) 2025-09-08 00:14:38 +00:00
thororen
1cfc3fb8f8
fix OnePingPerDM (#3648)
also removes a now obsolete patch from FakeNitro
2025-09-06 18:27:29 +02:00
u32
26074b7f18
MessageLatency: fix bot check (#3523) 2025-09-05 02:04:08 +00:00
Vendicated
e857f6806f
ShowMeYourName: respect streamer mode 2025-09-05 03:55:38 +02:00
Gleb P
b6e96a4d3b
MemberCount: also show members in voice (#2937)
Co-authored-by: Vendicated <vendicated@riseup.net>
2025-09-05 03:37:13 +02:00
sadan4
1d00ba4161
fix patches for Experiments and Vencord Toolbox (#3647) 2025-09-04 21:59:30 +02:00
union
77b016de36
ReverseImageSearch: add Bing (#2793) 2025-09-04 02:46:02 +02:00
union
b7f19bbe37
NoReplyMention: add role whitelist / blacklist (#2794)
Co-authored-by: V <vendicated@riseup.net>
2025-09-04 02:41:06 +02:00
Vendicated
9700ec9cd2
fix minor bugs 2025-09-03 04:37:38 +02:00
Vendicated
65c85a5222
CallTimer: fix overflow when using aligned chat input
Co-Authored-By: sadan4 <117494111+sadan4@users.noreply.github.com>
Co-Authored-By: God
2025-09-03 04:26:33 +02:00
sadan4
8789973bf5
WhoReacted: remove ugly more users tooltip (#3640)
Co-authored-by: Vendicated <vendicated@riseup.net>
2025-09-03 03:41:46 +02:00
ayuxia
4ff3614dc0
ShowMeYourName: support friend nicknames (#3639)
Co-authored-by: V <vendicated@riseup.net>
2025-09-03 03:19:12 +02:00
thororen
5c69d340d9
Fix MutualGroupDMs & Decor broken patches (#3644) 2025-09-02 23:44:25 +00:00
Etorix
75a2506c51
ViewRaw: Adjust icon size to match other icons (#3605) 2025-09-02 16:42:17 +00:00
jamesbt365
9b0ae0fd90
Translate: support automod & forwarded messages (#3367)
Co-authored-by: V <vendicated@riseup.net>
2025-09-02 04:20:56 +02:00
sadan4
f0f75aa918
AlwaysAnimate: Add nameplates support (#3641) 2025-09-01 23:59:15 +00:00
Nuckyz
8ebfd9a190
NoTypingAnimation: Fix not working due to broken patches 2025-09-01 20:53:43 -03:00
Vendicated
17b90beee1
add context menu options to vencord badges 2025-08-31 05:18:47 +02:00
Nuckyz
8807564053
ConsoleJanitor: Fix outdated settings margin 2025-08-29 16:23:23 -03:00
sadan4
aca30bcb9a
ShowHiddenChannels: Fix broken patch (#3635) 2025-08-29 19:02:45 +00:00
thororen
67aff64fed
MutualGroupDMs: Fix broken patch (#3634) 2025-08-29 16:01:27 -03:00
Vendicated
c5888c25f7
bump to v1.12.13 2025-08-29 02:52:44 +02:00
thororen
19c1eaed18 fix failing to find the Select component (#3631) 2025-08-28 04:55:03 +02:00
Nuckyz
5dee746986 Fix IgnoreActivities 2025-08-28 04:55:03 +02:00
Vendicated
76a60e07e9
fix SuperReactionTweaks 2025-08-23 02:16:32 +02:00
Vendicated
abe910d80d
fix not being able to dismiss Vencord Notices 2025-08-23 02:02:02 +02:00
Vendicated
4a35cf1769
Toolbox: fix & move to the titlebar 2025-08-23 01:57:27 +02:00
fawn
643656d798
new plugin ImageFilename: displays image filename tooltips on hover (#3617)
Co-authored-by: Vendicated <vendicated@riseup.net>
2025-08-19 16:41:02 +02:00
Vendicated
0bb2ed6b72
bump to v1.12.12 2025-08-18 18:58:40 +02:00
Nuckyz
330c3cead7
Fix plugins broken by latest Discord update (#3609) 2025-08-17 17:33:23 -03:00
Vendicated
93294673de
bump to v1.12.11 2025-08-14 21:48:28 +02:00
sadan4
204f916b2a
fix Settings UI and various plugins (#3608)
Co-authored-by: Vendicated <vendicated@riseup.net>
2025-08-14 21:42:58 +02:00
Vendicated
f356f647ff
bump to v1.12.10 2025-08-13 12:58:29 +02:00
Vendicated
aad88fe9cd
fix plugins sending messages 2025-08-13 12:55:24 +02:00
sadan4
72329f901c
AccountPanelServerProfile: fix right click menu (#3600)
Co-authored-by: Vendicated <vendicated@riseup.net>
2025-08-12 03:28:19 +02:00
Vendicated
27b2e97e3f
ReviewDB: fix input 2025-08-12 03:00:47 +02:00
Nuckyz
d9e2732a8d
Fix MessageEventsAPI broken patch 2025-08-11 15:35:29 -03:00
Vendicated
0c89314d49
PermissionFreeWill: don't break permission toggles 2025-08-07 23:38:37 +02:00
byeoon
4403aee3c1
New plugin CopyStickerLinks: adds Copy/Open Link option to stickers (#3191)
Co-authored-by: V <vendicated@riseup.net>
2025-08-07 22:24:13 +02:00
sadan4
7e028267f1
SpotifyShareCommands: correctly handle local tracks (#3592) 2025-08-07 21:04:59 +02:00
Nuckyz
c7e799e935
ShowHiddenChannels: Fix incorrect allowed users and roles component 2025-08-07 09:29:37 -03:00
Vendicated
98efe13b97
ShowHiddenThings: fix crash when viewing Mod View 2025-08-07 04:24:34 +02:00
Vendicated
164fd43cc4
Fix IgnoreActivities 2025-08-06 22:01:24 -03:00
Nuckyz
fe2ed0776f
ShowHiddenChannels: Fix showing allowed roles 2025-08-06 22:01:24 -03:00
sadan4
74d78d89ed
fix TypingTweaks (#3586)
Co-authored-by: V <vendicated@riseup.net>
2025-08-07 02:11:38 +02:00
sadan4
6a66b7f54f
fix plugins broken by Discord update (#3583)
Co-authored-by: Nuckyz <61953774+Nuckyz@users.noreply.github.com>
Co-authored-by: V <vendicated@riseup.net>
2025-08-06 22:16:07 +02:00
Zordan
36c15d619e
HideAttachments: correctly support forwarded Messages (#3587)
Co-authored-by: V <vendicated@riseup.net>
2025-08-06 16:26:00 +02:00
V
a2253cb4ae
remove old discord ui workarounds & legacy code (#3585)
Co-authored-by: sadan <117494111+sadan4@users.noreply.github.com>
Co-authored-by: Nuckyz <61953774+Nuckyz@users.noreply.github.com>
2025-08-05 23:57:51 +02:00
V
6380111f32
Notification Log: fix lag if there are too many entries (#3584)
Use Discord's lazy list implementation for only rendering what's on screen
2025-08-05 19:20:28 +02:00
Vendicated
1ebd412392
BetterUploadButton: don't affect other chat buttons 2025-08-03 17:04:50 +02:00
Vendicated
a02d1afdf0
bump to v1.12.8 2025-08-03 16:26:08 +02:00
Vendicated
38e46f89cf
BetterUploadButton: fix right click action not working 2025-08-03 16:24:00 +02:00
sadan4
22d3fb10e9
fix plugins broken by latest Discord update (#3574)
Co-authored-by: V <vendicated@riseup.net>
2025-08-03 13:05:26 +00:00
Vendicated
fa672a347d
BetterSessions: fix ui 2025-08-01 01:51:38 +02:00
sadan4
cb36cf5706
fix Plugins broken by recent Discord changes (#3569)
Co-authored-by: Vendicated <vendicated@riseup.net>
2025-07-31 22:31:33 +02:00
Nuckyz
29a2bcbf07
Fix BetterUploadButton not working 2025-07-26 11:31:30 -03:00
Vendicated
03fe7d15cf
bump to v1.12.7 2025-07-22 22:45:28 +02:00
Vendicated
6fb685b959
Fix Plugin settings page
Co-Authored-By: sadan <117494111+sadan4@users.noreply.github.com>
2025-07-22 22:43:31 +02:00
sadan4
8b36013264
fix GreetStickerPicker (#3561) 2025-07-22 22:35:35 +02:00
Vendicated
50e2ad776b
Plugin Settings: improve headings 2025-07-21 12:32:20 +02:00
Vendicated
4ab084c0ac
make ShowMeYourName patch safer 2025-07-21 12:13:13 +02:00
Vendicated
113a1f4b86
fix ShowMeYourName 2025-07-21 12:09:36 +02:00
Nuckyz
25cd6b7069
Remove unused and non unique webpack common style classes find 2025-07-17 22:06:22 -03:00
Nuckyz
f6f0624e52
Fix broken Decor style classes find 2025-07-17 22:05:42 -03:00
Nuckyz
cdda1224ff
UserVoiceShow: Improve icon in User Profile Modal V2 2025-07-16 20:23:06 -03:00
Nuckyz
d0869c41cd
Settings: Improve layout of a setting section and error 2025-07-16 18:15:52 -03:00
Nuckyz
828358bd2e
IrcColors: Fix chat colors broken patch 2025-07-15 15:52:48 -03:00
V
3f51ee1b2a
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
2025-07-15 15:57:24 +02:00
Nuckyz
a33e81d1cb
Bump to v1.12.6 2025-07-14 21:05:04 -03:00
Nuckyz
f3874d0a26
Fix Plugin Settings broken webpack find 2025-07-14 21:02:44 -03:00
Nuckyz
ad810df978
Attempt to make OverrideForumDefaults not always slow 2025-07-14 17:09:21 -03:00
Nuckyz
1a98d54e3a
Fix Experiments embed patches 2025-07-14 17:07:43 -03:00
Nuckyz
5dd6722528
Fix MessagePopoverAPI incorrectly hiding the emoji button 2025-07-14 17:07:22 -03:00
sadan4
6787e98003
FakeProfileThemes: fix error when own profile is not loaded (#3514)
Co-authored-by: V <vendicated@riseup.net>
2025-07-10 22:38:27 +02:00
sadan4
18f083b7e6
fix ImageZoom & FakeProfileThemes (#3546)
Co-authored-by: Vendicated <vendicated@riseup.net>
2025-07-10 19:56:12 +00:00
Vendicated
19f4d7cdac
fix various plugins still using outdated Discord classes 2025-07-10 01:37:32 +02:00
Vendicated
8e446e44ab
Online Themes: fix & improve ui 2025-07-10 01:37:13 +02:00
Cookie
a17803c1c4
Settings: remove outdated style (#3490) 2025-07-09 22:42:38 +00:00
V
dc9064326b
refactor discord types into separate npm package (#3520)
Co-authored-by: V <vendicated@riseup.net>
Co-authored-by: sadan <117494111+sadan4@users.noreply.github.com>
Co-authored-by: ezzud <contact@ezzud.fr>
2025-07-10 00:38:39 +02:00
nyx
c55833d95f
ShowMeYourName: fix gradient role compatibility (#3495)
Co-authored-by: V <vendicated@riseup.net>
2025-07-08 01:05:56 +02:00
Amia
44f7ccafcb
MutualGroupDMs: fix member count label (#3541) 2025-07-08 01:04:14 +02:00
Etorix
643122e323
Experiments: fix edge case in experiment rollout detection (#3497)
Co-authored-by: V <vendicated@riseup.net>
2025-07-04 16:09:10 +02:00
Amia
310d8e6140
Fix ImplicitRelationships & RelationshipNotifier (#3539) 2025-07-04 15:04:12 +02:00
Vendicated
1142cab05c
Merge remote-tracking branch 'origin/main' into dev 2025-07-04 14:59:44 +02:00
sadan4
c653e36137
fix Expression Cloner, ServerInfo & SeeSummaries plugins (#3536) 2025-07-04 02:14:59 +02:00
244 changed files with 4591 additions and 3551 deletions

View file

Before

Width:  |  Height:  |  Size: 31 KiB

After

Width:  |  Height:  |  Size: 31 KiB

Before After
Before After

2
.gitignore vendored
View file

@ -22,4 +22,4 @@ lerna-debug.log*
src/userplugins
ExtensionCache/
settings/
/settings

View file

@ -13,11 +13,14 @@
"typescript.format.semicolons": "insert",
"typescript.preferences.quoteStyle": "double",
"javascript.preferences.quoteStyle": "double",
"gitlens.remotes": [
{
"domain": "codeberg.org",
"type": "Gitea"
}
]
}
],
"css.format.spaceAroundSelectorSeparator": true,
"[css]": {
"editor.defaultFormatter": "vscode.css-language-features"
}
}

View file

@ -1,12 +1,3 @@
# A fork. A fork. We're a fork.
Installing is the same as the Vencord devs laid out: [(Install)](https://docs.vencord.dev/installing/)
* Beyond that, be sure to clone *this* repository instead of their upstream one.
* `git clone https://git.dorkbutt.lol/dorkbutt/vencord`
* After completing the steps, go to Settings > Vencord > Plugins and search for
"FORKED - usrbg" and enable it. Be sure to tweak its settings!
# Vencord
![](https://img.shields.io/github/package-json/v/Vendicated/Vencord?style=for-the-badge&logo=github&logoColor=d3869b&label=&color=1d2021&labelColor=282828)

View file

@ -1,7 +1,7 @@
{
"name": "vencord",
"private": "true",
"version": "1.12.6",
"version": "1.13.0",
"description": "The cutest Discord client mod",
"homepage": "https://github.com/Vendicated/Vencord#readme",
"bugs": {

View file

@ -0,0 +1 @@
Hint: https://docs.discord.food is an incredible resource and allows you to copy paste complete enums and interfaces

View file

@ -0,0 +1,30 @@
export const enum ActivityType {
PLAYING = 0,
STREAMING = 1,
LISTENING = 2,
WATCHING = 3,
CUSTOM_STATUS = 4,
COMPETING = 5,
HANG_STATUS = 6
}
export const enum ActivityFlags {
INSTANCE = 1 << 0,
JOIN = 1 << 1,
/** @deprecated */
SPECTATE = 1 << 2,
/** @deprecated */
JOIN_REQUEST = 1 << 3,
SYNC = 1 << 4,
PLAY = 1 << 5,
PARTY_PRIVACY_FRIENDS = 1 << 6,
PARTY_PRIVACY_VOICE_CHANNEL = 1 << 7,
EMBEDDED = 1 << 8,
CONTEXTLESS = 1 << 9
}
export const enum ActivityStatusDisplayType {
NAME = 0,
STATE = 1,
DETAILS = 2
}

View file

@ -0,0 +1,15 @@
export const enum ChannelType {
GUILD_TEXT = 0,
DM = 1,
GUILD_VOICE = 2,
GROUP_DM = 3,
GUILD_CATEGORY = 4,
GUILD_ANNOUNCEMENT = 5,
ANNOUNCEMENT_THREAD = 10,
PUBLIC_THREAD = 11,
PRIVATE_THREAD = 12,
GUILD_STAGE_VOICE = 13,
GUILD_DIRECTORY = 14,
GUILD_FORUM = 15,
GUILD_MEDIA = 16
}

View file

@ -1 +1,5 @@
export * from "./activity";
export * from "./channel";
export * from "./commands";
export * from "./messages";
export * from "./misc";

View file

@ -0,0 +1,596 @@
export const enum StickerType {
/** an official sticker in a pack */
STANDARD = 1,
/** a sticker uploaded to a guild for the guild's members */
GUILD = 2
}
export const enum StickerFormatType {
PNG = 1,
APNG = 2,
LOTTIE = 3,
GIF = 4
}
export const enum MessageType {
/**
* A default message (see below)
*
* Value: 0
* Name: DEFAULT
* Rendered Content: "{content}"
* Deletable: true
*/
DEFAULT = 0,
/**
* A message sent when a user is added to a group DM or thread
*
* Value: 1
* Name: RECIPIENT_ADD
* Rendered Content: "{author} added {mentions [0] } to the {group/thread}."
* Deletable: false
*/
RECIPIENT_ADD = 1,
/**
* A message sent when a user is removed from a group DM or thread
*
* Value: 2
* Name: RECIPIENT_REMOVE
* Rendered Content: "{author} removed {mentions [0] } from the {group/thread}."
* Deletable: false
*/
RECIPIENT_REMOVE = 2,
/**
* A message sent when a user creates a call in a private channel
*
* Value: 3
* Name: CALL
* Rendered Content: participated ? "{author} started a call{ended ? " that lasted {duration}" : " Join the call"}." : "You missed a call from {author} that lasted {duration}."
* Deletable: false
*/
CALL = 3,
/**
* A message sent when a group DM or thread's name is changed
*
* Value: 4
* Name: CHANNEL_NAME_CHANGE
* Rendered Content: "{author} changed the {is_forum ? "post title" : "channel name"}: {content} "
* Deletable: false
*/
CHANNEL_NAME_CHANGE = 4,
/**
* A message sent when a group DM's icon is changed
*
* Value: 5
* Name: CHANNEL_ICON_CHANGE
* Rendered Content: "{author} changed the channel icon."
* Deletable: false
*/
CHANNEL_ICON_CHANGE = 5,
/**
* A message sent when a message is pinned in a channel
*
* Value: 6
* Name: CHANNEL_PINNED_MESSAGE
* Rendered Content: "{author} pinned a message to this channel."
* Deletable: true
*/
CHANNEL_PINNED_MESSAGE = 6,
/**
* A message sent when a user joins a guild
*
* Value: 7
* Name: USER_JOIN
* Rendered Content: See user join message type , obtained via the formula timestamp_ms % 13
* Deletable: true
*/
USER_JOIN = 7,
/**
* A message sent when a user subscribes to (boosts) a guild
*
* Value: 8
* Name: PREMIUM_GUILD_SUBSCRIPTION
* Rendered Content: "{author} just boosted the server{content ? " {content} times"}!"
* Deletable: true
*/
PREMIUM_GUILD_SUBSCRIPTION = 8,
/**
* A message sent when a user subscribes to (boosts) a guild to tier 1
*
* Value: 9
* Name: PREMIUM_GUILD_SUBSCRIPTION_TIER_1
* Rendered Content: "{author} just boosted the server{content ? " {content} times"}! {guild} has achieved Level 1! "
* Deletable: true
*/
PREMIUM_GUILD_SUBSCRIPTION_TIER_1 = 9,
/**
* A message sent when a user subscribes to (boosts) a guild to tier 2
*
* Value: 10
* Name: PREMIUM_GUILD_SUBSCRIPTION_TIER_2
* Rendered Content: "{author} just boosted the server{content ? " {content} times"}! {guild} has achieved Level 2! "
* Deletable: true
*/
PREMIUM_GUILD_SUBSCRIPTION_TIER_2 = 10,
/**
* A message sent when a user subscribes to (boosts) a guild to tier 3
*
* Value: 11
* Name: PREMIUM_GUILD_SUBSCRIPTION_TIER_3
* Rendered Content: "{author} just boosted the server{content ? " {content} times"}! {guild} has achieved Level 3! "
* Deletable: true
*/
PREMIUM_GUILD_SUBSCRIPTION_TIER_3 = 11,
/**
* A message sent when a news channel is followed
*
* Value: 12
* Name: CHANNEL_FOLLOW_ADD
* Rendered Content: "{author} has added {content} to this channel. Its most important updates will show up here."
* Deletable: true
*/
CHANNEL_FOLLOW_ADD = 12,
/**
* A message sent when a guild is disqualified from discovery
*
* Value: 14
* Name: GUILD_DISCOVERY_DISQUALIFIED
* Rendered Content: "This server has been removed from Server Discovery because it no longer passes all the requirements. Check Server Settings for more details."
* Deletable: true
*/
GUILD_DISCOVERY_DISQUALIFIED = 14,
/**
* A message sent when a guild requalifies for discovery
*
* Value: 15
* Name: GUILD_DISCOVERY_REQUALIFIED
* Rendered Content: "This server is eligible for Server Discovery again and has been automatically relisted!"
* Deletable: true
*/
GUILD_DISCOVERY_REQUALIFIED = 15,
/**
* A message sent when a guild has failed discovery requirements for a week
*
* Value: 16
* Name: GUILD_DISCOVERY_GRACE_PERIOD_INITIAL_WARNING
* Rendered Content: "This server has failed Discovery activity requirements for 1 week. If this server fails for 4 weeks in a row, it will be automatically removed from Discovery."
* Deletable: true
*/
GUILD_DISCOVERY_GRACE_PERIOD_INITIAL_WARNING = 16,
/**
* A message sent when a guild has failed discovery requirements for 3 weeks
*
* Value: 17
* Name: GUILD_DISCOVERY_GRACE_PERIOD_FINAL_WARNING
* Rendered Content: "This server has failed Discovery activity requirements for 3 weeks in a row. If this server fails for 1 more week, it will be removed from Discovery."
* Deletable: true
*/
GUILD_DISCOVERY_GRACE_PERIOD_FINAL_WARNING = 17,
/**
* A message sent when a thread is created
*
* Value: 18
* Name: THREAD_CREATED
* Rendered Content: "{author} started a thread: {content} . See all threads."
* Deletable: true
*/
THREAD_CREATED = 18,
/**
* A message sent when a user replies to a message
*
* Value: 19
* Name: REPLY
* Rendered Content: "{content}"
* Deletable: true
*/
REPLY = 19,
/**
* A message sent when a user uses a slash command
*
* Value: 20
* Name: CHAT_INPUT_COMMAND
* Rendered Content: "{content}"
* Deletable: true
*/
CHAT_INPUT_COMMAND = 20,
/**
* A message sent when a thread starter message is added to a thread
*
* Value: 21
* Name: THREAD_STARTER_MESSAGE
* Rendered Content: "{referenced_message?.content}" ?? "Sorry, we couldn't load the first message in this thread"
* Deletable: false
*/
THREAD_STARTER_MESSAGE = 21,
/**
* A message sent to remind users to invite friends to a guild
*
* Value: 22
* Name: GUILD_INVITE_REMINDER
* Rendered Content: "Wondering who to invite?\nStart by inviting anyone who can help you build the server!"
* Deletable: true
*/
GUILD_INVITE_REMINDER = 22,
/**
* A message sent when a user uses a context menu command
*
* Value: 23
* Name: CONTEXT_MENU_COMMAND
* Rendered Content: "{content}"
* Deletable: true
*/
CONTEXT_MENU_COMMAND = 23,
/**
* A message sent when auto moderation takes an action
*
* Value: 24
* Name: AUTO_MODERATION_ACTION
* Rendered Content: Special embed rendered from embeds[0]
* Deletable: true 1
*/
AUTO_MODERATION_ACTION = 24,
/**
* A message sent when a user purchases or renews a role subscription
*
* Value: 25
* Name: ROLE_SUBSCRIPTION_PURCHASE
* Rendered Content: "{author} {is_renewal ? "renewed" : "joined"} {role_subscription.tier_name} and has been a subscriber of {guild} for {role_subscription.total_months_subscribed} month(?s)!"
* Deletable: true
*/
ROLE_SUBSCRIPTION_PURCHASE = 25,
/**
* A message sent when a user is upsold to a premium interaction
*
* Value: 26
* Name: INTERACTION_PREMIUM_UPSELL
* Rendered Content: "{content}"
* Deletable: true
*/
INTERACTION_PREMIUM_UPSELL = 26,
/**
* A message sent when a stage channel starts
*
* Value: 27
* Name: STAGE_START
* Rendered Content: "{author} started {content} "
* Deletable: true
*/
STAGE_START = 27,
/**
* A message sent when a stage channel ends
*
* Value: 28
* Name: STAGE_END
* Rendered Content: "{author} ended {content} "
* Deletable: true
*/
STAGE_END = 28,
/**
* A message sent when a user starts speaking in a stage channel
*
* Value: 29
* Name: STAGE_SPEAKER
* Rendered Content: "{author} is now a speaker."
* Deletable: true
*/
STAGE_SPEAKER = 29,
/**
* A message sent when a user raises their hand in a stage channel
*
* Value: 30
* Name: STAGE_RAISE_HAND
* Rendered Content: "{author} requested to speak."
* Deletable: true
*/
STAGE_RAISE_HAND = 30,
/**
* A message sent when a stage channel's topic is changed
*
* Value: 31
* Name: STAGE_TOPIC
* Rendered Content: "{author} changed the Stage topic: {content} "
* Deletable: true
*/
STAGE_TOPIC = 31,
/**
* A message sent when a user purchases an application premium subscription
*
* Value: 32
* Name: GUILD_APPLICATION_PREMIUM_SUBSCRIPTION
* Rendered Content: "{author} upgraded {application ?? "a deleted application"} to premium for this server!"
* Deletable: true
*/
GUILD_APPLICATION_PREMIUM_SUBSCRIPTION = 32,
/**
* A message sent when a user gifts a premium (Nitro) referral
*
* Value: 35
* Name: PREMIUM_REFERRAL
* Rendered Content: "{content}"
* Deletable: true
*/
PREMIUM_REFERRAL = 35,
/**
* A message sent when a user enabled lockdown for the guild
*
* Value: 36
* Name: GUILD_INCIDENT_ALERT_MODE_ENABLED
* Rendered Content: "{author} enabled security actions until {content}."
* Deletable: true
*/
GUILD_INCIDENT_ALERT_MODE_ENABLED = 36,
/**
* A message sent when a user disables lockdown for the guild
*
* Value: 37
* Name: GUILD_INCIDENT_ALERT_MODE_DISABLED
* Rendered Content: "{author} disabled security actions."
* Deletable: true
*/
GUILD_INCIDENT_ALERT_MODE_DISABLED = 37,
/**
* A message sent when a user reports a raid for the guild
*
* Value: 38
* Name: GUILD_INCIDENT_REPORT_RAID
* Rendered Content: "{author} reported a raid in {guild}."
* Deletable: true
*/
GUILD_INCIDENT_REPORT_RAID = 38,
/**
* A message sent when a user reports a false alarm for the guild
*
* Value: 39
* Name: GUILD_INCIDENT_REPORT_FALSE_ALARM
* Rendered Content: "{author} reported a false alarm in {guild}."
* Deletable: true
*/
GUILD_INCIDENT_REPORT_FALSE_ALARM = 39,
/**
* A message sent when no one sends a message in the current channel for 1 hour
*
* Value: 40
* Name: GUILD_DEADCHAT_REVIVE_PROMPT
* Rendered Content: "{content}"
* Deletable: true
*/
GUILD_DEADCHAT_REVIVE_PROMPT = 40,
/**
* A message sent when a user buys another user a gift
*
* Value: 41
* Name: CUSTOM_GIFT
* Rendered Content: Special embed rendered from embeds[0].url and gift_info
* Deletable: true
*/
CUSTOM_GIFT = 41,
/**
* Value: 42
* Name: GUILD_GAMING_STATS_PROMPT
* Rendered Content: "{content}"
* Deletable: true
*/
GUILD_GAMING_STATS_PROMPT = 42,
/**
* A message sent when a user purchases a guild product
*
* Value: 44
* Name: PURCHASE_NOTIFICATION
* Rendered Content: "{author} has purchased {purchase_notification.guild_product_purchase.product_name}!"
* Deletable: true
*/
PURCHASE_NOTIFICATION = 44,
/**
* A message sent when a poll is finalized
*
* Value: 46
* Name: POLL_RESULT
* Rendered Content: Special embed rendered from embeds[0]
* Deletable: true
*/
POLL_RESULT = 46,
/**
* A message sent by the Discord Updates account when a new changelog is posted
*
* Value: 47
* Name: CHANGELOG
* Rendered Content: "{content}"
* Deletable: true
*/
CHANGELOG = 47,
/**
* A message sent when a Nitro promotion is triggered
*
* Value: 48
* Name: NITRO_NOTIFICATION
* Rendered Content: Special embed rendered from content
* Deletable: true
*/
NITRO_NOTIFICATION = 48,
/**
* A message sent when a voice channel is linked to a lobby
*
* Value: 49
* Name: CHANNEL_LINKED_TO_LOBBY
* Rendered Content: "{content}"
* Deletable: true
*/
CHANNEL_LINKED_TO_LOBBY = 49,
/**
* A local-only ephemeral message sent when a user is prompted to gift Nitro to a friend on their friendship anniversary
*
* Value: 50
* Name: GIFTING_PROMPT
* Rendered Content: Special embed
* Deletable: true
*/
GIFTING_PROMPT = 50,
/**
* A local-only message sent when a user receives an in-game message NUX
*
* Value: 51
* Name: IN_GAME_MESSAGE_NUX
* Rendered Content: "{author} messaged you from {application.name}. In-game chat may not include rich messaging features such as images, polls, or apps. Learn More "
* Deletable: true
*/
IN_GAME_MESSAGE_NUX = 51,
/**
* A message sent when a user accepts a guild join request
*
* Value: 52
* Name: GUILD_JOIN_REQUEST_ACCEPT_NOTIFICATION 2
* Rendered Content: "{join_request.user}'s application to {content} was approved! Welcome!"
* Deletable: true
*/
GUILD_JOIN_REQUEST_ACCEPT_NOTIFICATION = 52,
/**
* A message sent when a user rejects a guild join request
*
* Value: 53
* Name: GUILD_JOIN_REQUEST_REJECT_NOTIFICATION 2
* Rendered Content: "{join_request.user}'s application to {content} was rejected."
* Deletable: true
*/
GUILD_JOIN_REQUEST_REJECT_NOTIFICATION = 53,
/**
* A message sent when a user withdraws a guild join request
*
* Value: 54
* Name: GUILD_JOIN_REQUEST_WITHDRAWN_NOTIFICATION 2
* Rendered Content: "{join_request.user}'s application to {content} has been withdrawn."
* Deletable: true
*/
GUILD_JOIN_REQUEST_WITHDRAWN_NOTIFICATION = 54,
/**
* A message sent when a user upgrades to HD streaming
*
* Value: 55
* Name: HD_STREAMING_UPGRADED
* Rendered Content: "{author} activated HD Splash Potion "
* Deletable: true
*/
HD_STREAMING_UPGRADED = 55,
/**
* A message sent when a user resolves a moderation report by deleting the offending message
*
* Value: 58
* Name: REPORT_TO_MOD_DELETED_MESSAGE
* Rendered Content: "{author} deleted the message"
* Deletable: true
*/
REPORT_TO_MOD_DELETED_MESSAGE = 58,
/**
* A message sent when a user resolves a moderation report by timing out the offending user
*
* Value: 59
* Name: REPORT_TO_MOD_TIMEOUT_USER
* Rendered Content: "{author} timed out {mentions [0] }"
* Deletable: true
*/
REPORT_TO_MOD_TIMEOUT_USER = 59,
/**
* A message sent when a user resolves a moderation report by kicking the offending user
*
* Value: 60
* Name: REPORT_TO_MOD_KICK_USER
* Rendered Content: "{author} kicked {mentions [0] }"
* Deletable: true
*/
REPORT_TO_MOD_KICK_USER = 60,
/**
* A message sent when a user resolves a moderation report by banning the offending user
*
* Value: 61
* Name: REPORT_TO_MOD_BAN_USER
* Rendered Content: "{author} banned {mentions [0] }"
* Deletable: true
*/
REPORT_TO_MOD_BAN_USER = 61,
/**
* A message sent when a user resolves a moderation report
*
* Value: 62
* Name: REPORT_TO_MOD_CLOSED_REPORT
* Rendered Content: "{author} resolved this flag"
* Deletable: true
*/
REPORT_TO_MOD_CLOSED_REPORT = 62,
/**
* A message sent when a user adds a new emoji to a guild
*
* Value: 63
* Name: EMOJI_ADDED
* Rendered Content: "{author} added a new emoji, {content} :{emoji.name}: "
* Deletable: true
*/
EMOJI_ADDED = 63,
}
export const enum MessageFlags {
/**
* Message has been published to subscribed channels (via Channel Following)
*
* Value: 1 << 0
*/
CROSSPOSTED = 1 << 0,
/**
* Message originated from a message in another channel (via Channel Following)
*/
IS_CROSSPOST = 1 << 1,
/**
* Embeds will not be included when serializing this message
*/
SUPPRESS_EMBEDS = 1 << 2,
/**
* Source message for this crosspost has been deleted (via Channel Following)
*/
SOURCE_MESSAGE_DELETED = 1 << 3,
/**
* Message came from the urgent message system
*/
URGENT = 1 << 4,
/**
* Message has an associated thread, with the same ID as the message
*/
HAS_THREAD = 1 << 5,
/**
* Message is only visible to the user who invoked the interaction
*/
EPHEMERAL = 1 << 6,
/**
* Message is an interaction response and the bot is "thinking"
*/
LOADING = 1 << 7,
/**
* Some roles were not mentioned and added to the thread
*/
FAILED_TO_MENTION_SOME_ROLES_IN_THREAD = 1 << 8,
/**
* Message is hidden from the guild's feed
*/
GUILD_FEED_HIDDEN = 1 << 9,
/**
* Message contains a link that impersonates Discord
*/
SHOULD_SHOW_LINK_NOT_DISCORD_WARNING = 1 << 10,
/**
* Message will not trigger push and desktop notifications
*/
SUPPRESS_NOTIFICATIONS = 1 << 12,
/**
* Message's audio attachment is rendered as a voice message
*/
IS_VOICE_MESSAGE = 1 << 13,
/**
* Message has a forwarded message snapshot attached
*/
HAS_SNAPSHOT = 1 << 14,
/**
* Message contains components from version 2 of the UI kit
*/
IS_COMPONENTS_V2 = 1 << 15,
/**
* Message was triggered by the social layer integration
*/
SENT_BY_SOCIAL_LAYER_INTEGRATION = 1 << 16,
}

View file

@ -0,0 +1,4 @@
export const enum CloudUploadPlatform {
REACT_NATIVE = 0,
WEB = 1,
}

View file

@ -1,8 +1,3 @@
export interface ImageModalClasses {
image: string,
modal: string,
}
export interface ButtonWrapperClasses {
hoverScale: string;
buttonWrapper: string;

View file

@ -0,0 +1,36 @@
import { ActivityFlags, ActivityStatusDisplayType, ActivityType } from "../../enums";
export interface ActivityAssets {
large_image?: string;
large_text?: string;
small_image?: string;
small_text?: string;
}
export interface ActivityButton {
label: string;
url: string;
}
export interface Activity {
name: string;
application_id: string;
type: ActivityType;
state?: string;
state_url?: string;
details?: string;
details_url?: string;
url?: string;
flags: ActivityFlags;
status_display_type?: ActivityStatusDisplayType;
timestamps?: {
start?: number;
end?: number;
};
assets?: ActivityAssets;
buttons?: string[];
metadata?: {
button_urls?: Array<string>;
};
}

View file

@ -1,3 +1,4 @@
export * from "./Activity";
export * from "./Application";
export * from "./Channel";
export * from "./Guild";

View file

@ -2,8 +2,9 @@ import { CommandOption } from './Commands';
import { User, UserJSON } from '../User';
import { Embed, EmbedJSON } from './Embed';
import { DiscordRecord } from "../Record";
import { MessageFlags, MessageType, StickerFormatType } from "../../../enums";
/**
/*
* TODO: looks like discord has moved over to Date instead of Moment;
*/
export class Message extends DiscordRecord {
@ -34,7 +35,7 @@ export class Message extends DiscordRecord {
customRenderedContent: unknown;
editedTimestamp: Date;
embeds: Embed[];
flags: number;
flags: MessageFlags;
giftCodes: string[];
id: string;
interaction: {
@ -83,20 +84,23 @@ export class Message extends DiscordRecord {
channel_id: string;
message_id: string;
} | undefined;
messageSnapshots: {
message: Message;
}[];
nick: unknown; // probably a string
nonce: string | undefined;
pinned: boolean;
reactions: MessageReaction[];
state: string;
stickerItems: {
format_type: number;
format_type: StickerFormatType;
id: string;
name: string;
}[];
stickers: unknown[];
timestamp: moment.Moment;
tts: boolean;
type: number;
type: MessageType;
webhookId: string | undefined;
/**
@ -117,10 +121,13 @@ export class Message extends DiscordRecord {
removeReaction(emoji: ReactionEmoji, fromCurrentUser: boolean): Message;
getChannelId(): string;
hasFlag(flag: number): boolean;
hasFlag(flag: MessageFlags): boolean;
isCommandType(): boolean;
isEdited(): boolean;
isSystemDM(): boolean;
/** Vencord added */
deleted?: boolean;
}
/** A smaller Message object found in FluxDispatcher and elsewhere. */
@ -189,3 +196,9 @@ export interface MessageReaction {
emoji: ReactionEmoji;
me: boolean;
}
// Object.keys(findByProps("REPLYABLE")).map(JSON.stringify).join("|")
export type MessageTypeSets = Record<
"UNDELETABLE" | "GUILD_DISCOVERY_STATUS" | "USER_MESSAGE" | "NOTIFIABLE_SYSTEM_MESSAGE" | "REPLYABLE" | "FORWARDABLE" | "REFERENCED_MESSAGE_AVAILABLE" | "AVAILABLE_IN_GUILD_FEED" | "DEADCHAT_PROMPTS" | "NON_COLLAPSIBLE" | "NON_PARSED" | "AUTOMOD_INCIDENT_ACTIONS" | "SELF_MENTIONABLE_SYSTEM" | "SCHEDULABLE",
Set<MessageType>
>;

View file

@ -0,0 +1,35 @@
import { StickerFormatType, StickerType } from "../../../enums";
interface BaseSticker {
asset: string;
available: boolean;
description: string;
format_type: StickerFormatType;
id: string;
name: string;
sort_value?: number;
/** a comma separated string */
tags: string;
}
export interface PackSticker extends BaseSticker {
pack_id: string;
type: StickerType.STANDARD;
}
export interface GuildSticker extends BaseSticker {
guild_id: string;
type: StickerType.GUILD;
}
export type Sticker = PackSticker | GuildSticker;
export interface PremiumStickerPack {
banner_asset_id?: string;
cover_sticker_id?: string;
description: string;
id: string;
name: string;
sku_id: string;
stickers: PackSticker[];
}

View file

@ -2,3 +2,4 @@ export * from "./Commands";
export * from "./Message";
export * from "./Embed";
export * from "./Emoji";
export * from "./Sticker";

View file

@ -43,12 +43,7 @@ export type FormDivider = ComponentType<{
}>;
export type FormText = ComponentType<PropsWithChildren<{
disabled?: boolean;
selectable?: boolean;
/** defaults to FormText.Types.DEFAULT */
type?: string;
}> & TextProps> & { Types: FormTextTypes; };
export type FormText = ComponentType<TextProps>;
export type Tooltip = ComponentType<{
text: ReactNode | ComponentType;
@ -244,8 +239,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 {
@ -472,19 +469,66 @@ export type MaskedLink = ComponentType<PropsWithChildren<{
channelId?: string;
}>>;
export type ScrollerThin = ComponentType<PropsWithChildren<{
export interface ScrollerBaseProps {
className?: string;
style?: CSSProperties;
dir?: "ltr";
orientation?: "horizontal" | "vertical" | "auto";
paddingFix?: boolean;
fade?: boolean;
onClose?(): void;
onScroll?(): void;
}
export type ScrollerThin = ComponentType<PropsWithChildren<ScrollerBaseProps & {
orientation?: "horizontal" | "vertical" | "auto";
fade?: boolean;
}>>;
interface BaseListItem {
anchorId: any;
listIndex: number;
offsetTop: number;
section: number;
}
interface ListSection extends BaseListItem {
type: "section";
}
interface ListRow extends BaseListItem {
type: "row";
row: number;
rowIndex: number;
}
export type ListScrollerThin = ComponentType<ScrollerBaseProps & {
sections: number[];
renderSection?: (item: ListSection) => React.ReactNode;
renderRow: (item: ListRow) => React.ReactNode;
renderFooter?: (item: any) => React.ReactNode;
renderSidebar?: (listVisible: boolean, sidebarVisible: boolean) => React.ReactNode;
wrapSection?: (section: number, children: React.ReactNode) => React.ReactNode;
sectionHeight: number;
rowHeight: number;
footerHeight?: number;
sidebarHeight?: number;
chunkSize?: number;
paddingTop?: number;
paddingBottom?: number;
fade?: boolean;
onResize?: Function;
getAnchorId?: any;
innerTag?: string;
innerId?: string;
innerClassName?: string;
innerRole?: string;
innerAriaLabel?: string;
// Yes, Discord uses this casing
innerAriaMultiselectable?: boolean;
innerAriaOrientation?: "vertical" | "horizontal";
}>;
export type Clickable = <T extends "a" | "div" | "span" | "li" = "div">(props: PropsWithChildren<ComponentPropsWithRef<T>> & {
tag?: T;
}) => ReactNode;

View file

@ -4,6 +4,7 @@ export * from "./components";
export * from "./flux";
export * from "./fluxEvents";
export * from "./menu";
export * from "./modules";
export * from "./stores";
export * from "./utils";
export * as Webpack from "../webpack";

View file

@ -0,0 +1,74 @@
import EventEmitter from "events";
import { CloudUploadPlatform } from "../../enums";
interface BaseUploadItem {
platform: CloudUploadPlatform;
id?: string;
origin?: string;
isThumbnail?: boolean;
clip?: unknown;
}
export interface ReactNativeUploadItem extends BaseUploadItem {
platform: CloudUploadPlatform.REACT_NATIVE;
uri: string;
filename?: string;
mimeType?: string;
durationSecs?: number;
waveform?: string;
isRemix?: boolean;
}
export interface WebUploadItem extends BaseUploadItem {
platform: CloudUploadPlatform.WEB;
file: File;
}
export type CloudUploadItem = ReactNativeUploadItem | WebUploadItem;
export class CloudUpload extends EventEmitter {
constructor(item: CloudUploadItem, channelId: string, showLargeMessageDialog?: boolean, reactNativeFileIndex?: number);
channelId: string;
classification: string;
clip: unknown;
contentHash: unknown;
currentSize: number;
description: string | null;
durationSecs: number | undefined;
etag: string | undefined;
error: unknown;
filename: string;
id: string;
isImage: boolean;
isRemix: boolean | undefined;
isThumbnail: boolean;
isVideo: boolean;
item: {
file: File;
platform: CloudUploadPlatform;
origin: string;
};
loaded: number;
mimeType: string;
origin: string;
postCompressionSize: number | undefined;
preCompressionSize: number;
responseUrl: string;
sensitive: boolean;
showLargeMessageDialog: boolean;
spoiler: boolean;
startTime: number;
status: "NOT_STARTED" | "STARTED" | "UPLOADING" | "ERROR" | "COMPLETED" | "CANCELLED" | "REMOVED_FROM_MSG_DRAFT";
uniqueId: string;
uploadedFilename: string;
waveform: string | undefined;
// there are many more methods than just these but I didn't find them particularly useful
upload(): Promise<void>;
cancel(): void;
delete(): Promise<void>;
getSize(): number;
maybeConvertToWebP(): Promise<void>;
removeFromMsgDraft(): void;
}

View file

@ -0,0 +1 @@
export * from "./CloudUpload";

View file

@ -0,0 +1,11 @@
import { FluxStore } from "..";
export class AuthenticationStore extends FluxStore {
/**
* Gets the id of the current user
*/
getId(): string;
// This Store has a lot more methods related to everything Auth, but they really should
// not be needed, so they are not typed
}

View file

@ -1,7 +1,8 @@
import { FluxStore, Role } from "..";
// TODO: add the rest of the methods for GuildRoleStore
export class GuildRoleStore extends FluxStore {
getRole(guildId: string, roleId: string): Role;
getRoles(guildId: string): Record<string, Role>;
getAllGuildRoles(): Record<string, Record<string, Role>>;
getSortedRoles(guildId: string): Role[];
getRolesSnapshot(guildId: string): Record<string, Role>;
}

View file

@ -15,6 +15,11 @@ export class RelationshipStore extends FluxStore {
isFriend(userId: string): boolean;
isBlocked(userId: string): boolean;
isIgnored(userId: string): boolean;
/**
* @see {@link isBlocked}
* @see {@link isIgnored}
*/
isBlockedOrIgnored(userId: string): boolean;
getSince(userId: string): string;
getMutableRelationships(): Map<string, number>;

View file

@ -0,0 +1,15 @@
import { FluxStore, GuildSticker, PremiumStickerPack, Sticker } from "..";
export type StickerGuildMap = Map<string, GuildSticker[]>;
export class StickersStore extends FluxStore {
getAllGuildStickers(): StickerGuildMap;
getRawStickersByGuild(): StickerGuildMap;
getPremiumPacks(): PremiumStickerPack[];
getStickerById(id: string): Sticker | undefined;
getStickerPack(id: string): PremiumStickerPack | undefined;
getStickersByGuildId(guildId: string): Sticker[] | undefined;
isPremiumPack(id: string): boolean;
}

View file

@ -0,0 +1,11 @@
import { FluxStore } from "@vencord/discord-types";
export class StreamerModeStore extends FluxStore {
get autoToggle(): boolean;
get disableNotifications(): boolean;
get disableSounds(): boolean;
get enableContentProtection(): boolean;
get enabled(): boolean;
get hideInstantInvites(): boolean;
get hidePersonalInformation(): boolean;
}

View file

@ -0,0 +1,9 @@
import { FluxStore } from "..";
export class TypingStore extends FluxStore {
/**
* returns a map of user ids to timeout ids
*/
getTypingUsers(channelId: string): Record<string, number>;
isTyping(channelId: string, userId: string): boolean;
}

View file

@ -0,0 +1,42 @@
import { DiscordRecord } from "../common";
import { FluxStore } from "./FluxStore";
export type UserVoiceStateRecords = Record<string, VoiceState>;
export type VoiceStates = Record<string, UserVoiceStateRecords>;
export interface VoiceState extends DiscordRecord {
userId: string;
channelId: string | null | undefined;
sessionId: string | null | undefined;
mute: boolean;
deaf: boolean;
selfMute: boolean;
selfDeaf: boolean;
selfVideo: boolean;
selfStream: boolean | undefined;
suppress: boolean;
requestToSpeakTimestamp: string | null | undefined;
discoverable: boolean;
isVoiceMuted(): boolean;
isVoiceDeafened(): boolean;
}
export class VoiceStateStore extends FluxStore {
getAllVoiceStates(): VoiceStates;
getVoiceStates(guildId?: string | null): UserVoiceStateRecords;
getVoiceStatesForChannel(channelId: string): UserVoiceStateRecords;
getVideoVoiceStatesForChannel(channelId: string): UserVoiceStateRecords;
getVoiceState(guildId: string | null, userId: string): VoiceState | undefined;
getUserVoiceChannelId(guildId: string | null, userId: string): string | undefined;
getVoiceStateForChannel(channelId: string, userId?: string): VoiceState | undefined;
getVoiceStateForUser(userId: string): VoiceState | undefined;
getCurrentClientVoiceChannelId(guildId: string | null): string | undefined;
isCurrentClientInVoiceChannel(): boolean;
isInChannel(channelId: string, userId?: string): boolean;
hasVideo(channelId: string): boolean;
}

View file

@ -1,4 +1,5 @@
// please keep in alphabetical order
export * from "./AuthenticationStore";
export * from "./ChannelStore";
export * from "./DraftStore";
export * from "./EmojiStore";
@ -10,9 +11,13 @@ export * from "./MessageStore";
export * from "./RelationshipStore";
export * from "./SelectedChannelStore";
export * from "./SelectedGuildStore";
export * from "./StickersStore";
export * from "./StreamerModeStore";
export * from "./ThemeStore";
export * from "./TypingStore";
export * from "./UserProfileStore";
export * from "./UserStore";
export * from "./VoiceStateStore";
export * from "./WindowStore";
/**

View file

@ -6,13 +6,15 @@ import type { FluxEvents } from "./fluxEvents";
export { FluxEvents };
type FluxEventsAutoComplete = LiteralUnion<FluxEvents, string>;
export interface FluxDispatcher {
_actionHandlers: any;
_subscriptions: any;
dispatch(event: { [key: string]: unknown; type: FluxEvents; }): Promise<void>;
dispatch(event: { [key: string]: unknown; type: FluxEventsAutoComplete; }): Promise<void>;
isDispatching(): boolean;
subscribe(event: FluxEvents, callback: (data: any) => void): void;
unsubscribe(event: FluxEvents, callback: (data: any) => void): void;
subscribe(event: FluxEventsAutoComplete, callback: (data: any) => void): void;
unsubscribe(event: FluxEventsAutoComplete, callback: (data: any) => void): void;
wait(callback: () => void): void;
}

View file

@ -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}"

View file

@ -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
@ -35,7 +34,9 @@ export interface ProfileBadge {
image?: string;
link?: string;
/** Action to perform when you click the badge */
onClick?(event: React.MouseEvent<HTMLButtonElement, MouseEvent>, props: BadgeUserArgs): void;
onClick?(event: React.MouseEvent, props: ProfileBadge & BadgeUserArgs): void;
/** Action to perform when you right click the badge */
onContextMenu?(event: React.MouseEvent, props: BadgeUserArgs & BadgeUserArgs): void;
/** Should the user display this badge? */
shouldShow?(userInfo: BadgeUserArgs): boolean;
/** Optional props (e.g. style) for the badge, ignored for component badges */
@ -77,21 +78,34 @@ export function removeProfileBadge(badge: ProfileBadge) {
export function _getBadges(args: BadgeUserArgs) {
const badges = [] as ProfileBadge[];
for (const badge of Badges) {
if (!badge.shouldShow || badge.shouldShow(args)) {
const b = badge.getBadges
? badge.getBadges(args).map(b => {
b.component &&= ErrorBoundary.wrap(b.component, { noop: true });
return b;
})
: [{ ...badge, ...args }];
if (badge.shouldShow && !badge.shouldShow(args)) {
continue;
}
badge.position === BadgePosition.START
? badges.unshift(...b)
: badges.push(...b);
const b = badge.getBadges
? badge.getBadges(args).map(badge => ({
...args,
...badge,
component: badge.component && ErrorBoundary.wrap(badge.component, { noop: true })
}))
: [{ ...args, ...badge }];
if (badge.position === BadgePosition.START) {
badges.unshift(...b);
} else {
badges.push(...b);
}
}
const donorBadges = (Plugins.BadgeAPI as unknown as typeof import("../plugins/_api/badges").default).getDonorBadges(args.userId);
if (donorBadges) badges.unshift(...donorBadges);
const donorBadges = BadgeAPIPlugin.getDonorBadges(args.userId);
if (donorBadges) {
badges.unshift(
...donorBadges.map(badge => ({
...args,
...badge,
}))
);
}
return badges;
}

View file

@ -17,7 +17,7 @@
*/
import { Logger } from "@utils/Logger";
import type { Channel, CustomEmoji, Message } from "@vencord/discord-types";
import type { Channel, CloudUpload, CustomEmoji, Message } from "@vencord/discord-types";
import { MessageStore } from "@webpack/common";
import type { Promisable } from "type-fest";
@ -30,30 +30,6 @@ export interface MessageObject {
tts: boolean;
}
export interface Upload {
classification: string;
currentSize: number;
description: string | null;
filename: string;
id: string;
isImage: boolean;
isVideo: boolean;
item: {
file: File;
platform: number;
};
loaded: number;
mimeType: string;
preCompressionSize: number;
responseUrl: string;
sensitive: boolean;
showLargeMessageDialog: boolean;
spoiler: boolean;
status: "NOT_STARTED" | "STARTED" | "UPLOADING" | "ERROR" | "COMPLETED" | "CANCELLED";
uniqueId: string;
uploadedFilename: string;
}
export interface MessageReplyOptions {
messageReference: Message["messageReference"];
allowedMentions?: {
@ -62,9 +38,9 @@ export interface MessageReplyOptions {
};
}
export interface MessageExtra {
export interface MessageOptions {
stickers?: string[];
uploads?: Upload[];
uploads?: CloudUpload[];
replyOptions: MessageReplyOptions;
content: string;
channel: Channel;
@ -72,17 +48,17 @@ export interface MessageExtra {
openWarningPopout: (props: any) => any;
}
export type MessageSendListener = (channelId: string, messageObj: MessageObject, extra: MessageExtra) => Promisable<void | { cancel: boolean; }>;
export type MessageSendListener = (channelId: string, messageObj: MessageObject, options: MessageOptions) => Promisable<void | { cancel: boolean; }>;
export type MessageEditListener = (channelId: string, messageId: string, messageObj: MessageObject) => Promisable<void | { cancel: boolean; }>;
const sendListeners = new Set<MessageSendListener>();
const editListeners = new Set<MessageEditListener>();
export async function _handlePreSend(channelId: string, messageObj: MessageObject, extra: MessageExtra, replyOptions: MessageReplyOptions) {
extra.replyOptions = replyOptions;
export async function _handlePreSend(channelId: string, messageObj: MessageObject, options: MessageOptions, replyOptions: MessageReplyOptions) {
options.replyOptions = replyOptions;
for (const listener of sendListeners) {
try {
const result = await listener(channelId, messageObj, extra);
const result = await listener(channelId, messageObj, options);
if (result?.cancel) {
return true;
}

View file

@ -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();
}

View file

@ -16,7 +16,10 @@
* along with this program. If not, see <https://www.gnu.org/licenses/>.
*/
import ErrorBoundary from "@components/ErrorBoundary";
import { isPrimitiveReactNode } from "@utils/react";
import { waitFor } from "@webpack";
import { ReactNode } from "react";
let NoticesModule: any;
waitFor(m => m.show && m.dismiss && !m.suppressAll, m => NoticesModule = m);
@ -36,7 +39,11 @@ export function nextNotice() {
}
}
export function showNotice(message: string, buttonText: string, onOkClick: () => void) {
noticesQueue.push(["GENERIC", message, buttonText, onOkClick]);
export function showNotice(message: ReactNode, buttonText: string, onOkClick: () => void) {
const notice = isPrimitiveReactNode(message)
? message
: <ErrorBoundary fallback={() => "Error Showing Notice"}>{message}</ErrorBoundary>;
noticesQueue.push(["GENERIC", notice, buttonText, onOkClick]);
if (!currentNotice) nextNotice();
}

View file

@ -104,9 +104,7 @@ export default ErrorBoundary.wrap(function NotificationComponent({
</svg>
</button>
</div>
<div>
{richBody ?? <p className="vc-notification-p">{body}</p>}
</div>
{richBody ?? <p className="vc-notification-p">{body}</p>}
</div>
</div>
{image && <img className="vc-notification-img" src={image} alt="" />}

View file

@ -20,10 +20,10 @@ 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 { closeModal, ModalCloseButton, ModalContent, ModalFooter, ModalHeader, ModalProps, ModalRoot, ModalSize, openModal } from "@utils/modal";
import { openNotificationSettingsModal } from "@components/settings/tabs/vencord/NotificationSettings";
import { closeModal, ModalCloseButton, 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";
import { Alerts, Button, Forms, ListScrollerThin, React, Text, Timestamp, useEffect, useReducer, useState } from "@webpack/common";
import { nanoid } from "nanoid";
import type { DispatchWithoutAction } from "react";
@ -103,21 +103,9 @@ export function useLogs() {
function NotificationEntry({ data }: { data: PersistentNotificationData; }) {
const [removing, setRemoving] = useState(false);
const ref = React.useRef<HTMLDivElement>(null);
useEffect(() => {
const div = ref.current!;
const setHeight = () => {
if (div.clientHeight === 0) return requestAnimationFrame(setHeight);
div.style.height = `${div.clientHeight}px`;
};
setHeight();
}, []);
return (
<div className={cl("wrapper", { removing })} ref={ref}>
<div className={cl("wrapper", { removing })}>
<NotificationComponent
{...data}
permanent={true}
@ -129,13 +117,13 @@ function NotificationEntry({ data }: { data: PersistentNotificationData; }) {
setTimeout(() => deleteNotification(data.timestamp), 200);
}}
richBody={
<div className={cl("body")}>
{data.body}
<div className={cl("body-wrapper")}>
<div className={cl("body")}>{data.body}</div>
<Timestamp timestamp={new Date(data.timestamp)} className={cl("timestamp")} />
</div>
}
/>
</div>
</div >
);
}
@ -151,9 +139,14 @@ export function NotificationLog({ log, pending }: { log: PersistentNotificationD
);
return (
<div className={cl("container")}>
{log.map(n => <NotificationEntry data={n} key={n.id} />)}
</div>
<ListScrollerThin
className={cl("container")}
sections={[log.length]}
sectionHeight={0}
rowHeight={120}
renderSection={() => null}
renderRow={item => <NotificationEntry data={log[item.row]} key={log[item.row].id} />}
/>
);
}
@ -161,15 +154,15 @@ function LogModal({ modalProps, close }: { modalProps: ModalProps; close(): void
const [log, pending] = useLogs();
return (
<ModalRoot {...modalProps} size={ModalSize.LARGE}>
<ModalRoot {...modalProps} size={ModalSize.LARGE} className={cl("modal")}>
<ModalHeader>
<Text variant="heading-lg/semibold" style={{ flexGrow: 1 }}>Notification Log</Text>
<ModalCloseButton onClick={close} />
</ModalHeader>
<ModalContent>
<div style={{ width: "100%" }}>
<NotificationLog log={log} pending={pending} />
</ModalContent>
</div>
<ModalFooter>
<Flex>

View file

@ -4,17 +4,13 @@
display: flex;
flex-direction: column;
color: var(--text-default);
background-color: var(--background-base-lower-alt);
background-color: var(--background-base-low);
border-radius: 6px;
overflow: hidden;
cursor: pointer;
width: 100%;
}
.visual-refresh .vc-notification-root {
background-color: var(--background-base-low);
}
.vc-notification-root:not(.vc-notification-log-wrapper > .vc-notification-root) {
position: absolute;
z-index: 2147483647;
@ -32,6 +28,7 @@
.vc-notification-content {
width: 100%;
overflow: hidden;
}
.vc-notification-header {
@ -81,6 +78,11 @@
width: 100%;
}
.vc-notification-log-modal {
max-width: 962px;
width: clamp(var(--modal-width-large, 800px), 962px, 85vw);
}
.vc-notification-log-empty {
height: 218px;
background: url("/assets/b36de980b174d7b798c89f35c116e5c6.svg") center no-repeat;
@ -88,19 +90,23 @@
}
.vc-notification-log-container {
display: flex;
flex-direction: column;
padding: 1em;
overflow: hidden;
max-height: min(750px, 75vh);
width: 100%;
}
.vc-notification-log-wrapper {
height: 120px;
width: 100%;
padding-bottom: 16px;
box-sizing: border-box;
transition: 200ms ease;
transition-property: height, opacity;
}
.vc-notification-log-wrapper:not(:last-child) {
margin-bottom: 1em;
/* stylelint-disable-next-line no-descending-specificity */
.vc-notification-root {
height: 104px;
}
}
.vc-notification-log-removing {
@ -109,9 +115,18 @@
margin-bottom: 1em;
}
.vc-notification-log-body {
.vc-notification-log-body-wrapper {
display: flex;
flex-direction: column;
width: 100%;
box-sizing: border-box;
}
.vc-notification-log-body {
white-space: nowrap;
text-overflow: ellipsis;
overflow: hidden;
line-height: 1.2em;
}
.vc-notification-log-timestamp {
@ -123,4 +138,4 @@
.vc-notification-log-danger-btn {
color: var(--white-500);
background-color: var(--button-danger-background);
}
}

View file

@ -18,7 +18,6 @@
import { React, TextInput } from "@webpack/common";
// TODO: Refactor settings to use this as well
interface TextInputProps {
/**
* WARNING: Changing this between renders will have no effect!

View file

@ -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 (

View file

@ -1,63 +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 { wordsFromCamel, wordsToTitle } from "@utils/text";
import { PluginOptionBoolean } from "@utils/types";
import { Forms, React, Switch } from "@webpack/common";
import { ISettingElementProps } from ".";
export function SettingBooleanComponent({ option, pluginSettings, definedSettings, id, onChange, onError }: ISettingElementProps<PluginOptionBoolean>) {
const def = pluginSettings[id] ?? option.default;
const [state, setState] = React.useState(def ?? false);
const [error, setError] = React.useState<string | null>(null);
React.useEffect(() => {
onError(error !== null);
}, [error]);
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);
onChange(newValue);
}
}
return (
<Forms.FormSection>
<Switch
value={state}
onChange={handleChange}
note={option.description}
disabled={option.disabled?.call(definedSettings) ?? false}
{...option.componentProps}
hideBorder
style={{ marginBottom: "0.5em" }}
>
{wordsToTitle(wordsFromCamel(id))}
</Switch>
{error && <Forms.FormText style={{ color: "var(--text-danger)" }}>{error}</Forms.FormText>}
</Forms.FormSection>
);
}

View file

@ -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";

View file

@ -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;

View file

@ -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");

View file

@ -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");

View file

@ -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();
}
};

View file

@ -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");

View file

@ -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";

View file

@ -1,6 +1,7 @@
.vc-addon-card {
background-color: var(--background-base-lower-alt);
background-color: var(--card-primary-bg);
color: var(--interactive-active);
border: 1px solid var(--border-subtle);
border-radius: 8px;
display: block;
height: 100%;
@ -11,26 +12,15 @@
box-sizing: border-box;
}
.visual-refresh .vc-addon-card {
background-color: var(--card-primary-bg);
border: 1px solid var(--border-subtle);
}
.vc-addon-card-disabled {
opacity: 0.6;
}
.vc-addon-card:hover {
background-color: var(--background-tertiary);
transform: translateY(-1px);
box-shadow: var(--elevation-high);
}
.visual-refresh .vc-addon-card:hover {
/* same as non-hover, here to overwrite the non-refresh hover background */
background-color: var(--card-primary-bg);
}
.vc-addon-header {
margin-top: auto;
display: flex;
@ -104,4 +94,4 @@
.vc-addon-title:hover {
overflow: visible;
animation: vc-addon-title var(--duration) linear infinite;
}
}

View file

@ -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}

View file

@ -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,

View file

@ -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"

View file

@ -14,7 +14,7 @@
.vc-settings-quickActions-pill {
all: unset;
background: var(--background-base-lower);
background: var(--button-secondary-background);
color: var(--header-secondary);
display: flex;
align-items: center;
@ -26,7 +26,7 @@
}
.vc-settings-quickActions-pill:hover {
background: var(--background-base-lower-alt);
background: var(--button-secondary-background-hover);
transform: translateY(-1px);
box-shadow: var(--elevation-high);
}
@ -36,15 +36,7 @@
outline-offset: 2px;
}
.visual-refresh .vc-settings-quickActions-pill {
background: var(--button-secondary-background);
}
.visual-refresh .vc-settings-quickActions-pill:hover {
background: var(--button-secondary-background-hover);
}
.vc-settings-quickActions-img {
width: 24px;
height: 24px;
}
}

View file

@ -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";

View file

@ -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";

View 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";

View file

@ -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";

View 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";

View 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>}
</>
);
}

View 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>
)}
</>
);
}

View 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 { 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>
</>
);
}

View 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;

View file

@ -11,7 +11,7 @@
.vc-author-modal-name {
text-transform: none;
flex-grow: 0;
background: var(--background-tertiary);
background: var(--background-base-lowest);
border-radius: 0 9999px 9999px 0;
padding: 6px 0.8em 6px 0.5em;
font-size: 20px;
@ -26,7 +26,7 @@
position: absolute;
height: 100%;
width: 32px;
background: var(--background-tertiary);
background: var(--background-base-lowest);
z-index: -1;
left: -32px;
top: 0;
@ -48,4 +48,4 @@
display: grid;
gap: 0.5em;
margin-top: 0.75em;
}
}

View file

@ -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-");

View file

@ -2,11 +2,11 @@
height: 32px;
width: 32px;
border-radius: 50%;
border: 4px solid var(--background-tertiary);
border: 4px solid var(--background-base-lowest);
box-sizing: border-box
}
.vc-settings-modal-links {
display: flex;
gap: 0.2em;
}
}

View file

@ -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"} />;

View 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>
} />
);
}

View file

@ -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 { classes, isObjectEmpty } from "@utils/misc";
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,63 +87,40 @@ 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 options = Object.entries(plugin.options).map(([key, setting]) => {
if (setting.type === OptionType.CUSTOM || setting.hidden) return null;
function onError(hasError: boolean) {
setErrors(e => ({ ...e, [key]: hasError }));
}
function onChange(newValue: any) {
const option = plugin.options?.[key];
if (!option || option.type === OptionType.CUSTOM) return;
const Component = Components[setting.type];
return (
<Component
id={key}
key={key}
option={setting}
onChange={onChange}
onError={onError}
pluginSettings={pluginSettings}
definedSettings={plugin.settings}
/>
);
});
pluginSettings[key] = newValue;
return <Flex flexDirection="column" style={{ gap: 12, marginBottom: 16 }}>{options}</Flex>;
}
if (option.restartNeeded) onRestartNeeded(key);
}
const Component = OptionComponentMap[setting.type];
return (
<Component
id={key}
key={key}
option={setting}
onChange={debounce(onChange)}
pluginSettings={pluginSettings}
definedSettings={plugin.settings}
/>
);
});
return (
<div className="vc-plugins-settings">
{options}
</div>
);
}
function renderMoreUsers(_label: string, count: number) {
@ -192,38 +143,16 @@ 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>
*/}
<ModalHeader separator={false} className={Margins.bottom8}>
<Text variant="heading-xl/bold" style={{ flexGrow: 1 }}>{plugin.name}</Text>
<ModalCloseButton onClick={onClose} />
</ModalHeader>
<ModalContent>
<ModalContent className={Margins.bottom16}>
<Forms.FormSection>
<Flex className={cl("info")}>
<Forms.FormText className={cl("description")}>{plugin.description}</Forms.FormText>
@ -240,16 +169,14 @@ export default function PluginModal({ plugin, onRestartNeeded, onClose, transiti
</div>
)}
</Flex>
<Forms.FormTitle tag="h3" style={{ marginTop: 8, marginBottom: 0 }}>Authors</Forms.FormTitle>
<div style={{ width: "fit-content", marginBottom: 8 }}>
<Text variant="heading-lg/semibold" className={classes(Margins.top8, Margins.bottom8)}>Authors</Text>
<div style={{ width: "fit-content" }}>
<UserSummaryItem
users={authors}
count={plugin.authors.length}
guildId={undefined}
renderIcon={false}
max={6}
showDefaultAvatarsForNullUsers
showUserPopout
renderMoreUsers={renderMoreUsers}
renderUser={(user: User) => (
<Clickable
@ -267,59 +194,32 @@ export default function PluginModal({ plugin, onRestartNeeded, onClose, transiti
/>
</div>
</Forms.FormSection>
{!!plugin.settingsAboutComponent && (
<div className={Margins.bottom8}>
<div className={Margins.top16}>
<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>
<Forms.FormSection>
<Text variant="heading-lg/semibold" className={classes(Margins.top16, Margins.bottom8)}>Settings</Text>
{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)}
/>
));
}

View file

@ -0,0 +1,48 @@
/*
* 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 { Switch } from "@components/settings/Switch";
import { PluginOptionBoolean } from "@utils/types";
import { React, useState } from "@webpack/common";
import { resolveError, SettingProps, SettingsSection } from "./Common";
export function BooleanSetting({ option, pluginSettings, definedSettings, id, onChange }: SettingProps<PluginOptionBoolean>) {
const def = pluginSettings[id] ?? option.default;
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;
setState(newValue);
setError(resolveError(isValid));
if (isValid === true) {
onChange(newValue);
}
}
return (
<SettingsSection name={id} description={option.description} error={error} inlineSetting>
<Switch checked={state} onChange={handleChange} />
</SettingsSection>
);
}

View 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 { classNameFactory } from "@api/Styles";
import { classes } from "@utils/misc";
import { wordsFromCamel, wordsToTitle } from "@utils/text";
import { DefinedSettings, PluginOptionBase } from "@utils/types";
import { Text } from "@webpack/common";
import { PropsWithChildren } from "react";
export const cl = classNameFactory("vc-plugins-setting-");
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;
inlineSetting?: boolean;
}
export function SettingsSection({ name, description, error, inlineSetting, children }: SettingsSectionProps) {
return (
<div className={cl("section")}>
<div className={classes(cl("content"), inlineSetting && cl("inline"))}>
<div className={cl("label")}>
{name && <Text className={cl("title")} variant="text-md/medium">{wordsToTitle(wordsFromCamel(name))}</Text>}
{description && <Text className={cl("description")} variant="text-sm/normal">{description}</Text>}
</div>
{children}
</div>
{error && <Text className={cl("error")} variant="text-sm/normal">{error}</Text>}
</div>
);
}

View file

@ -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 });
}

View file

@ -16,58 +16,49 @@
* 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]+"
placeholder={option.placeholder ?? "Enter a number"}
value={state}
onChange={handleChange}
placeholder={option.placeholder ?? "Enter a number"}
disabled={option.disabled?.call(definedSettings) ?? false}
{...option.componentProps}
/>
{error && <Forms.FormText style={{ color: "var(--text-danger)" }}>{error}</Forms.FormText>}
</Forms.FormSection>
</SettingsSection>
);
}

View file

@ -16,50 +16,41 @@
* 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);
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}
placeholder={option.placeholder ?? "Select an option"}
options={option.options}
maxVisibleItems={5}
closeOnSelect={true}
select={handleChange}
isSelected={v => v === state}
serialize={v => String(v)}
isDisabled={option.disabled?.call(definedSettings) ?? false}
{...option.componentProps}
/>
{error && <Forms.FormText style={{ color: "var(--text-danger)" }}>{error}</Forms.FormText>}
</Forms.FormSection>
</SettingsSection>
);
}

View file

@ -16,46 +16,29 @@
* 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}
minValue={option.markers[0]}
maxValue={option.markers[option.markers.length - 1]}
@ -63,9 +46,10 @@ export function SettingSliderComponent({ option, pluginSettings, definedSettings
onValueChange={handleChange}
onValueRender={(v: number) => String(v.toFixed(2))}
stickToMarkers={option.stickToMarkers ?? true}
disabled={option.disabled?.call(definedSettings) ?? false}
{...option.componentProps}
/>
</Forms.FormSection>
</SettingsSection>
);
}

View file

@ -16,45 +16,37 @@
* 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);
onChange(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"
placeholder={option.placeholder ?? "Enter a value"}
value={state}
onChange={handleChange}
placeholder={option.placeholder ?? "Enter a value"}
disabled={option.disabled?.call(definedSettings) ?? false}
maxLength={null}
disabled={option.disabled?.call(definedSettings) ?? false}
{...option.componentProps}
/>
{error && <Forms.FormText style={{ color: "var(--text-danger)" }}>{error}</Forms.FormText>}
</Forms.FormSection>
</SettingsSection>
);
}

View file

@ -0,0 +1,41 @@
/*
* 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 { 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,
};

View file

@ -0,0 +1,35 @@
.vc-plugins-setting-section {
display: flex;
flex-direction: column;
gap: 0.5em;
}
.vc-plugins-setting-content {
display: flex;
flex-direction: column;
gap: 0.5em;
}
.vc-plugins-setting-inline {
flex-direction: row;
align-items: center;
justify-content: space-between;
}
.vc-plugins-setting-label {
display: flex;
flex-direction: column;
gap: 0.25em;
}
.vc-plugins-setting-title {
color: var(--header-primary);
}
.vc-plugins-setting-description {
color: var(--header-secondary);
}
.vc-plugins-setting-error {
color: var(--text-danger);
}

View file

@ -19,153 +19,53 @@
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 ? (
<>
<Forms.FormTitle tag="h5">Restart required!</Forms.FormTitle>
<Forms.FormText className={cl("dep-text")}>
Restart now to apply new plugins and their settings
</Forms.FormText>
<Button onClick={() => location.reload()} className={cl("restart-button")}>
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>
<Forms.FormText>Plugins with a cog wheel have settings you can modify!</Forms.FormText>
</>
)}
{required
? (
<>
<Forms.FormTitle tag="h5">Restart required!</Forms.FormTitle>
<Forms.FormText className={cl("dep-text")}>
Restart now to apply new plugins and their settings
</Forms.FormText>
<Button onClick={() => location.reload()} className={cl("restart-button")}>
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>
<Forms.FormText>Plugins with a cog wheel have settings you can modify!</Forms.FormText>
</>
)}
</Card>
);
}
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,31 +104,32 @@ 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({
title: "Restart required",
body: (
<>
<p>The following plugins require a restart:</p>
<div>{changes.map((s, i) => (
<>
{i > 0 && ", "}
{Parser.parse("`" + s + "`")}
</>
))}</div>
</>
),
confirmText: "Restart now",
cancelText: "Later!",
onConfirm: () => location.reload()
}));
useCleanupEffect(() => {
if (changes.hasChanges)
Alerts.show({
title: "Restart required",
body: (
<>
<p>The following plugins require a restart:</p>
<div>{changes.map((s, i) => (
<>
{i > 0 && ", "}
{Parser.parse("`" + s.split(".")[0] + "`")}
</>
))}</div>
</>
),
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)}
@ -349,7 +262,6 @@ export default function PluginSettings() {
select={onStatusChange}
isSelected={v => v === searchValue.status}
closeOnSelect={true}
className={InputStyles.input}
/>
</div>
</div>
@ -386,9 +298,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");

View file

@ -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;
@ -74,3 +74,9 @@
.vc-plugins-info-icon:not(:hover, :focus) {
color: var(--text-muted);
}
.vc-plugins-settings {
display: flex;
flex-direction: column;
gap: 1.25em;
}

View file

@ -54,7 +54,7 @@
}
.vc-settings-theme-links:focus {
background-color: var(--background-tertiary);
background-color: var(--background-base-lowest);
}
.vc-cloud-settings-sync-grid {
@ -74,4 +74,4 @@
.vc-updater-modal-close-button {
float: right;
}
}

View file

@ -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");

View file

@ -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);

View 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>
);
}

View 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>
</>
);
}

View 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>
</>
);
}

View 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>
}
/>
);
}

View 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");

Some files were not shown because too many files have changed in this diff Show more