Compare commits

...

231 Commits

Author SHA1 Message Date
Vendicated
79b35d5797 initial custom plugin loading 2023-04-20 01:19:13 +02:00
V
62194674eb Delete highResAvatars.ts 2023-04-20 00:45:24 +02:00
V
04da98498f Restore src/Vencord.ts 2023-04-20 00:44:48 +02:00
Vendicated
6fa0fb017b more additions for external plugins 2023-04-20 00:41:02 +02:00
Vendicated
11ecc45b71 more fixes 2023-04-19 23:12:39 +02:00
Vendicated
82cd8d98f6 convert import aliases 2023-04-19 23:08:07 +02:00
Vendicated
c815f1c5f3 Run autofix to sort these imports! 2023-04-19 22:59:57 +02:00
Vendicated
e248f58d9f stuffs 3 2023-04-19 22:37:58 +02:00
Vendicated
3171b78a36 stuffs 2 2023-04-19 21:49:12 +02:00
Vendicated
525aa3af33 stuffs 2023-04-19 21:36:17 +02:00
Vendicated
b7299ea2cc npm types package 2023-04-19 20:18:42 +02:00
Vendicated
8dd70f5d1a Fix inserting text when markdown preview is off 2023-04-18 23:13:10 +02:00
Amsyar Rasyiq
8be6c6e3ce fix(ReviewDB): fix margin in self-profile preview (#935)
Co-authored-by: V <vendicated@riseup.net>
2023-04-18 18:53:11 +02:00
V
7e96b5dcfb RelationShipNotifier: Support multiple users (#944) 2023-04-18 16:52:46 +00:00
Đỗ Văn Hoài Tuân
99a7d78e9b [skip ci] USRBG: Update README link to a more accurate one (#943) 2023-04-18 16:04:17 +02:00
Nuckyz
e70d00d008 BadgesAPI: Don't depend on getBadges module not being undefined (#942) 2023-04-18 05:25:14 +00:00
Nuckyz
c0ac6a4b86 SilentMessageToggle: Option to persist state (#941) 2023-04-18 04:54:21 +00:00
Vendicated
29749e93c7 CI Reporter fixes 2023-04-18 02:54:22 +02:00
Vendicated
993c6be219 Fix badges 2023-04-18 02:47:48 +02:00
Đỗ Văn Hoài Tuân
e2e1cf2bfd feat(plugin): USRBG (#844)
Co-authored-by: amsyarasyiq <82711525+amsyarasyiq@users.noreply.github.com>
Co-authored-by: Vendicated <vendicated@riseup.net>
2023-04-18 00:18:18 +02:00
Vendicated
59e3c2c609 ci: fix cding into wrong folder 2023-04-17 04:09:20 +02:00
Nuckyz
43d7ca4c30 Make chat bar buttons have a consistent size (#927)
Co-authored-by: V <vendicated@riseup.net>
2023-04-17 02:09:11 +00:00
V
5305447f44 firefox: Fix csp (QuickCss, themes, some plugins) (#554) 2023-04-17 04:05:01 +02:00
Vendicated
76e74b3e40 SendTimestamps: Fix chatbox having a scrollbar 2023-04-17 02:55:08 +02:00
Vendicated
e767da4b08 Fix errors on setups with no SpeechSynthesis support (part 2) 2023-04-17 01:52:46 +02:00
Vendicated
e4f3f57a28 bump to v1.1.7 2023-04-17 01:48:53 +02:00
Lily • Lylythii
72f6dd84ee SilentTyping: Make Tooltip text casing consistent w other plugins (#918) 2023-04-17 01:46:51 +02:00
Mufaro
9c929a4d98 messageTags: Fix duplicate entries & replies (#922)
Co-authored-by: V <vendicated@riseup.net>
2023-04-16 23:44:33 +00:00
Luca LeBlanc
dac9cad873 Improve FakeNitro emoji popup message (#924)
Co-authored-by: Nuckyz <61953774+Nuckyz@users.noreply.github.com>
Co-authored-by: V <vendicated@riseup.net>
2023-04-17 01:43:11 +02:00
Vendicated
6fd5c7874f Remove ContextMenuAPIs from plugin dependencies (now required) 2023-04-17 01:39:16 +02:00
Vendicated
a56dfe269c Improve unintuitive plugin descriptions 2023-04-17 01:37:55 +02:00
Vendicated
7d55a81bac MessageLogger: Add toggle deleted highlight rightclick button 2023-04-17 01:27:25 +02:00
Vendicated
ce64631310 MessageLogger: Apply styles to gifs, stickers & new markdown 2023-04-17 01:18:45 +02:00
Vendicated
1caaa78490 PinDMs: Add option to sort by most recent message 2023-04-17 01:06:54 +02:00
Vendicated
d35654b887 Improve themes tab instructions 2023-04-17 00:23:22 +02:00
Vendicated
ca5d24385f Fix errors on setups with no Notifications/SpeechSynthesis support 2023-04-17 00:21:49 +02:00
V
cb3bd4b881 ci: Also upload extension zips 2023-04-15 15:02:31 +02:00
Lewis Crichton
ff3589d157 CloudSync: fix accidently applying stale settings (#915
* modify the cloud timestamp before importing

* use util/native relaunch

* lambda why

* "should work!" - Vendicated, 15th April 2023
2023-04-15 14:50:00 +02:00
dimden
7a98f1dfcb Make Vencord Extension run in iframes (#914)
Fixes support for https://github.com/NeverDecaf/discord-PWA
2023-04-15 14:49:48 +02:00
fawn
9e6d3459e3 fix(smyn): reversed settings (#913) 2023-04-15 12:20:49 +02:00
Jack
ea30ca418f feat: Add AlwaysAnimate plugin (#908)
Co-authored-by: V <vendicated@riseup.net>
2023-04-15 05:10:03 +00:00
Nuckyz
1f7ec93a24 SHC: Small improvements (#907) 2023-04-15 03:40:42 +00:00
Nuckyz
336c7bdd5e SHC: Fix emoji rendering & allowed users/roles edge cases (#895)
Co-authored-by: V <vendicated@riseup.net>
2023-04-15 05:02:08 +02:00
V
88ad4f1b05 SendTimestamps (#891)
Co-authored-by: Tyler Flowers <contact@ggtylerr.dev>
2023-04-15 04:42:18 +02:00
Sofia Lima
f75f887861 feat(plugin): ShowMeYourName (#901) 2023-04-15 03:58:22 +02:00
Nuckyz
96f640da67 Make Context Menu API support hooks (#902)
Co-authored-by: V <vendicated@riseup.net>
2023-04-15 02:47:03 +02:00
Manti
e8809fc57b add reviewdb bacc (#898) 2023-04-15 02:34:53 +02:00
Ryan Cao
ca91ef4e39 feat(moreUserTags): add HTML data attributes to user tags (#883)
Co-authored-by: V <vendicated@riseup.net>
2023-04-15 02:31:36 +02:00
V
db7fc3769b Fix settings on Vencord Mobile (#905) 2023-04-15 02:27:11 +02:00
V
6c719f5ee9 PinDMs (#879) 2023-04-15 02:26:46 +02:00
Nuckyz
c6fd8cae16 Fix MuteNewGuild (#896) 2023-04-14 18:02:03 +02:00
V
1adbf9e41a Delete muteNewGuild.ts
This plugin has a bad bug that breaks all DM notifications. Removed for now to prevent more damage, will be back once fixed!

I will try to make this future update fix the problem for any users affected by it.
For now, if you're affected, see https://gist.github.com/Vendicated/c86016b7fa68a754885ecd82a6ac0f2a for a quick fix
2023-04-14 08:53:40 +02:00
Nuckyz
aee6bed48c Revert "Delete DisableDMCallIdle: Discord removed this 'feature'" (#889)
THEY ADDED IT BACK WHYYYY
2023-04-14 03:37:14 +02:00
Vendicated
c8817e805f Fix badges 2023-04-13 21:04:19 +02:00
Vendicated
c6f0d0763c Remove noisy notifications from notification log 2023-04-13 19:15:36 +02:00
Vendicated
3bd3012aa9 Delete DisableDMCallIdle: Discord removed this 'feature' 2023-04-13 19:12:09 +02:00
Vendicated
694a693a8e MemberCount: Fall back to approx member count if necessary 2023-04-13 19:10:20 +02:00
Elliott Tallis
ed827c2d81 Shiki: Make CodeBlock button texts not copyable (#864) 2023-04-13 04:26:43 +02:00
Kode
71849cac9a Fix ViewIcons on webhooks and default avatars! (#880)
Co-authored-by: Nuckyz <61953774+Nuckyz@users.noreply.github.com>
Co-authored-by: Vendicated <vendicated@riseup.net>
2023-04-13 04:25:38 +02:00
Nuckyz
e34da54271 Option to transform emotes/stickers in compound messages (#876)
+ ContextMenu refactor to not call callbacks for same children multiple times

Co-authored-by: V <vendicated@riseup.net>
2023-04-13 04:22:38 +02:00
Vendicated
cfe41ef656 ViewIcons: Add format setting & user context menu + cleanup 2023-04-12 03:27:31 +02:00
V
4d836524c1 GreetStickerPicker: greet with stickers of your choice (#866)
Co-authored-by: Nuckyz <61953774+Nuckyz@users.noreply.github.com>
2023-04-12 02:33:51 +02:00
Dziurwa
edc96387f5 Fix FriendInvites (#874) 2023-04-12 02:33:05 +02:00
Vendicated
358eb6ad8e ImageZoom Fixes 2023-04-11 02:00:42 +02:00
Syncx
c997cb4958 ImageZoom: Fix not being able to close carousels on sides (#857)
Co-authored-by: V <vendicated@riseup.net>
2023-04-10 22:38:37 +00:00
Kode
83dab24fb9 PronounDB: Fix pronouns flickering (#862)
Co-authored-by: V <vendicated@riseup.net>
2023-04-11 00:37:24 +02:00
Vendicated
8a305d2d11 clean up spotify controls css 2023-04-11 00:32:11 +02:00
Ezzud
7eb12f0fb7 SpotifyControls: Fix flashing button row when using show on hover (#850)
Co-authored-by: V <vendicated@riseup.net>
2023-04-10 22:21:30 +00:00
Luna
0a3dc5c6e8 VoiceUserShow: Fix lack of bottom margin (#853)
Co-authored-by: V <vendicated@riseup.net>
2023-04-10 22:10:21 +00:00
Cat
b21516d44e Make QuickCss window title consistent with other windows (#859)
Co-authored-by: V <vendicated@riseup.net>
2023-04-11 00:07:36 +02:00
Nuckyz
65f7cf9503 Nicer GameActivity & SilentMsg UX; Fix [object Object] jumpscare (#863) 2023-04-10 23:59:48 +02:00
V
40a7aa5079 UserScript: Fix cors check 2023-04-09 06:58:29 +02:00
Amsyar Rasyiq
c4a3d25d37 feat(NoAutoReplyMention): Inverse shift reply behaviour (#839)
Co-authored-by: V <vendicated@riseup.net>
2023-04-09 06:55:04 +02:00
Ryan Cao
613fa9a57b feat: add translucency option for macOS (#849)
Co-authored-by: V <vendicated@riseup.net>
2023-04-09 02:06:09 +00:00
V
08822dd190 Improvements for VencordDesktop (#847) 2023-04-09 04:04:02 +02:00
Đỗ Văn Hoài Tuân
bfa20f2634 feat(setting): Disable minimum window size #834 (#848) 2023-04-09 03:41:55 +02:00
Vendicated
840da146b9 UX: Make possibly copy-relevant text in settings copyable 2023-04-08 23:28:12 +02:00
Vendicated
acc874c34f WebContextMenus: Fix jpegs being uncopyable 2023-04-08 23:05:38 +02:00
Vendicated
0dee968e98 WebContextMenus: Don't include queryparams in filename on save 2023-04-08 22:57:34 +02:00
Vendicated
09e919f0c6 bump to 1.1.6 2023-04-08 03:53:32 +02:00
V
eaf1af75bd WebContextMenus: Port more menus (#818)
Co-authored-by: Nuckyz <61953774+Nuckyz@users.noreply.github.com>
2023-04-08 03:51:37 +02:00
exit
7c514e4b1d SupportHelper: Add missing dependency - CommandsAPI (#823)
Co-authored-by: V <vendicated@riseup.net>
2023-04-07 19:19:12 +00:00
LordElias
1432baa28b ignore userplugins when linting (#822)
Co-authored-by: V <vendicated@riseup.net>
2023-04-07 19:17:54 +00:00
exit
f1f61195c3 InvisibleChat: Add missing dependency on MessagePopoverAPI (#817)
Co-authored-by: V <vendicated@riseup.net>
2023-04-07 19:16:18 +00:00
Đỗ Văn Hoài Tuân
8fefa2b716 FakeNitro: Fix stickers with space in name #819 (#820) 2023-04-07 21:15:11 +02:00
Ryan Cao
2a0c30b66d feat(moreusertags): add option to not show more tags for bots (#812)
Co-authored-by: V <vendicated@riseup.net>
2023-04-07 00:31:21 +00:00
Lewis Crichton
97f8d4d515 feat: Cloud settings sync (#505)
Co-authored-by: Ven <vendicated@riseup.net>
2023-04-07 02:27:18 +02:00
Vendicated
2672dea8e3 ci: bump action 2023-04-06 03:34:02 +02:00
Vendicated
63f5b0a663 bump pnpm to v8 2023-04-06 03:34:02 +02:00
ActuallyTheSun
e40ebacc5b feat(plugin): WebhookTags -> MoreUserTags (#378)
Co-authored-by: Cloudburst <18114966+C10udburst@users.noreply.github.com>
Co-authored-by: Vendicated <vendicated@riseup.net>
2023-04-06 03:28:38 +02:00
LordElias
e261c93563 feat(plugin): User Voice Show (#694)
Co-authored-by: V <vendicated@riseup.net>
Co-authored-by: Nuckyz <61953774+Nuckyz@users.noreply.github.com>
2023-04-06 03:22:54 +02:00
Syncx
df7357b357 feat(plugin): Image Zoom (#510)
Co-authored-by: Nuckyz <61953774+Nuckyz@users.noreply.github.com>
Co-authored-by: Ven <vendicated@riseup.net>
2023-04-06 01:06:11 +00:00
Đỗ Văn Hoài Tuân
2e6c5eacf7 BetterFolders: Fix Close all not working (#808) 2023-04-06 03:02:53 +02:00
Dziurwa
c9fd404012 Fix FriendInvites (#802)
Co-authored-by: Vendicated <vendicated@riseup.net>
2023-04-05 23:01:11 +02:00
V
814302e272 Fix Badges (#801) 2023-04-05 22:45:14 +02:00
Anubis
72ba83924c SpotifyControls: add album art hover transition (#797) 2023-04-05 22:45:03 +02:00
Nuckyz
9d742094cb ShowHiddenChannels: Use Discord's new overlay vars (#795)
* Fix SHC css for new Discord vars

* I'm dumb

* improvements to work with themes

---------

Co-authored-by: V <vendicated@riseup.net>
2023-04-05 20:44:03 +00:00
Nuckyz
38f3aac98d Fix VolumeBooster and improve ContextMenuAPI patch (#793)
Co-authored-by: V <vendicated@riseup.net>
2023-04-05 03:07:17 +00:00
Nuckyz
12ffb9d642 Fake Nitro Transform Stickers option and other stuff (#683)
Co-authored-by: V <vendicated@riseup.net>
2023-04-05 05:06:04 +02:00
Vendicated
99391a4f0e fix generatePluginList 2023-04-05 04:54:54 +02:00
Vendicated
6492908a62 VencordDesktop: Fix Updater 2023-04-05 04:34:39 +02:00
Vendicated
676bc612d9 VencordDesktop: Include web plugins & use proper showItemInFolder 2023-04-05 04:09:42 +02:00
Vendicated
d8a5e43034 Fix Themes Tab 2023-04-04 22:24:16 +02:00
Vendicated
8ad710abca Fix ContextMenuAPI 2023-04-04 22:19:52 +02:00
Vendicated
368cb7bc6b Fix Toasts 2023-04-04 21:51:03 +02:00
Vendicated
4aa7a052d0 Bump to v1.1.5 2023-04-04 21:29:39 +02:00
Vendicated
f088f17a0a Remove accidently introduced patch 2023-04-04 21:28:38 +02:00
Vendicated
a55c758b0e Fix SpotifyControls 2023-04-04 21:27:44 +02:00
Vendicated
f092f434fe Fix Vencord 2023-04-04 21:14:55 +02:00
Remty
2e6dfaa879 FakeProfileThemes: add usage guide (#778)
Co-authored-by: V <vendicated@riseup.net>
2023-04-04 13:28:41 +00:00
Nuckyz
96dc2e12d0 Fix Web & Game Activity Toggle (#777) 2023-04-04 15:26:53 +02:00
Đỗ Văn Hoài Tuân
d931790ed0 BetterFolders: Fix unread indicator & read all buttons being duplicated (#776) 2023-04-04 05:33:11 +02:00
V
6b26c12bfa Add additional build flavours for Vencord Desktop (#765) 2023-04-04 01:16:29 +02:00
Vendicated
5bb08bdb64 SpotifyControls: Fix crashing on canary
Vencord is still pretty broken on Canary and likely will be for a bit,
but this should at least fix instantly crashing
2023-04-03 21:25:14 +02:00
Vendicated
405be7ef13 Fix weird style on username sheet 2023-04-03 03:13:54 +02:00
Vendicated
a7e2fb48ba fix oopsie 2023-04-03 02:36:54 +02:00
Nuckyz
ae80749dd8 Game Activity Toggle and SettingsStoreAPI (#587) 2023-04-03 02:13:44 +02:00
Vendicated
8c47b7080d QuickReply & Up Key: Do not attempt to edit/reply to logged deleted message 2023-04-02 22:14:58 +02:00
Juby210
8378638ee4 BetterFolders: fix mentions display (#761)
closes #759
2023-04-02 20:31:10 +02:00
V
7c563471f6 Fix typo 2023-04-02 18:31:23 +02:00
Juby210
29382d2781 Add BetterFolders plugin (#530)
Co-authored-by: Ven <vendicated@riseup.net>
2023-04-02 17:43:06 +02:00
Vendicated
6226672ee8 Web: Update extension icon from trolley to Vencord logo 2023-04-02 16:55:36 +02:00
V
5b5ee82f27 Update Contributor Badge to new logo 2023-04-02 16:16:15 +02:00
Remty
62f74f5917 feat(plugin): FakeProfileThemes (#710) 2023-04-02 16:12:19 +02:00
V
265c7a18a7 Delete corruptMp4s.ts
Discord/Electron fixed this bug, so mp4s created by this plugin just look normal on Electron 22, not fixable
2023-04-02 04:33:17 +02:00
Vendicated
462f191051 Bump to v1.1.4 2023-04-02 04:26:05 +02:00
V
6960a439c9 Add Notification log (#745) 2023-04-01 02:47:49 +02:00
Vendicated
4dff1c5bd5 RelationShipNotifier: Delay by 5s to fix false positives 2023-03-31 17:17:50 +02:00
nick
2c8ebdce7d feat(plugin): RelationshipNotifier (#450)
Co-authored-by: Ven <vendicated@riseup.net>
2023-03-31 05:07:35 +00:00
Nuckyz
dae7cb67ef Fix IgnoreActivities broken patch (#743) 2023-03-31 04:11:15 +00:00
Berlin
081b01b667 feat(plugin): Wikisearch (#585)
Co-authored-by: Ven <vendicated@riseup.net>
2023-03-31 04:09:19 +00:00
Vendicated
5340ea7ba0 Add back window transparency with temporary unsafe settings key 2023-03-31 05:59:45 +02:00
Vendicated
84a649a671 docs: fix ToC 2023-03-31 05:56:08 +02:00
Vendicated
efd9927696 Fix broken plugins 2023-03-31 05:55:25 +02:00
V
c86a34a15d Update 1_INSTALLING.md 2023-03-31 05:30:45 +02:00
Vendicated
ff16513f21 Fix onHeadersReceived clashes when using OpenAsar (fix github raw styles) 2023-03-31 01:18:57 +02:00
Vendicated
906c265aea FakeNitro: Fix fake emote rendering incorrectly in thread previews 2023-03-31 00:15:51 +02:00
Vendicated
708c16176b Remove transparency feature
This not only causes incredibly confusion among users because they
expect it to work without themes, it also causes freezes/whitescreens
for some users. Thus, this feature is disabled for now until someone
contributes a fix!
2023-03-30 23:48:26 +02:00
whqwert
035d1e24b2 feat(SpotifyControls): Fix background color for built-in themes (#731)
Co-authored-by: V <vendicated@riseup.net>
2023-03-30 17:09:04 +02:00
Vendicated
48e9b1be7a new Plugin: GifPaste - Insert Gif links instead of sending 2023-03-30 15:58:20 +02:00
Vendicated
6acdaf207d NoTrack: Update description & authors 2023-03-30 01:41:18 +02:00
Vendicated
9d41b360c9 Fix NoTrack 2023-03-30 01:35:42 +02:00
Vendicated
12cbd73e7f SpotifyControls: Add right click menus to title/album/artists 2023-03-30 01:29:34 +02:00
Phil
420b068094 Fix makeProxy returning stale proxies after assigning objects (#722) 2023-03-28 18:26:57 +00:00
Vendicated
ee943c4284 Bump to v1.1.3 2023-03-28 19:09:48 +02:00
Vendicated
337b3709d6 types: Make ErrorBoundary.wrap explicitly return Function 2023-03-28 19:06:58 +02:00
Elliott Tallis
eb318c678f feat(ViewRaw): Improve View Raw action icon (#720)
Co-authored-by: Ven <vendicated@riseup.net>
2023-03-28 16:59:30 +00:00
Vendicated
081df6beb7 Fix SilentMessage/SilentTyping toggles showing in wrong sections
Closes #656
2023-03-28 18:56:12 +02:00
Vendicated
ab911b48b5 TypingTweaks: Make names open profile on click
Closes #718
2023-03-28 18:43:45 +02:00
Skye
8cb3491086 feat(uwuify): improve uwuification algorithm (#706)
Co-authored-by: Ven <vendicated@riseup.net>
2023-03-28 16:23:51 +00:00
Lewis Crichton
ee794d140f fix: no more theme box obliteration (#707)
Co-authored-by: Ven <vendicated@riseup.net>
2023-03-28 16:20:06 +00:00
Vendicated
a00542b61b MessageLinkEmbeds: Fix weird commas in title 2023-03-26 01:27:30 +01:00
Vendicated
041a13c9d3 DevCompanion: Always use original 2023-03-26 01:27:01 +01:00
Lewis Crichton
24aa90bd9c fix API plugins being force enabled unconditionally (#704)
* only enable dependencies if required

* fixme note
2023-03-25 15:20:00 +00:00
Ven
c574f53417 Add Code of Conduct (#680) 2023-03-25 12:41:39 +00:00
Nuckyz
92b84a9e94 Fix broken patches (#701) 2023-03-25 08:42:26 +00:00
RuiNtD
bbf3c74cb2 Update LastFM plugin (#483)
Co-authored-by: Ven <vendicated@riseup.net>
Co-authored-by: Sofia Lima <me@dzshn.xyz>
2023-03-25 04:00:27 +01:00
hunter
93cb51a975 feat(MessageEvents): Promisable send/edit listeners (#514)
* promisable send/edit listeners

* added self

* Apply suggestions from code review

Co-authored-by: Nuckyz <61953774+Nuckyz@users.noreply.github.com>

* fix patches

---------

Co-authored-by: Nuckyz <61953774+Nuckyz@users.noreply.github.com>
Co-authored-by: Ven <vendicated@riseup.net>
2023-03-25 03:54:20 +01:00
Syncx
0b4ae729a3 feat(plugin): SearchReply (#551)
Co-authored-by: Nuckyz <61953774+Nuckyz@users.noreply.github.com>
Co-authored-by: Vendicated <vendicated@riseup.net>
2023-03-25 02:37:29 +00:00
TheKodeToad
b90392576e PronounDB: Add support for compact mode & clean up (#604) 2023-03-25 01:30:24 +00:00
Ven
e143260891 MessageLogger: Add context menu entry to remove history (#693) 2023-03-25 00:55:40 +00:00
Đỗ Văn Hoài Tuân
644c5c4faa Make Vencord title look consistent with Discord (#685)
closes (#649)
2023-03-25 00:42:18 +00:00
Ven
8d8cedd72c Also add Emote Cloner to Emote picker rightclick menu (#664) 2023-03-24 03:42:38 +01:00
Nuckyz
082ac62eda feat(FakeNitro): Transform fake emojis into real ones (#669) 2023-03-23 10:45:39 +00:00
Nuckyz
7923a790e6 Fix MessagePopoverAPI and any error Fake Nitro client theme bypass might have (#665) 2023-03-23 02:11:28 -03:00
Vendicated
1368c25824 ci: Auto generate plugin json 2023-03-23 04:37:53 +01:00
iwa
d0b3678ad6 fix messagelogger deleted styles (#642)
Co-authored-by: Ven <vendicated@riseup.net>
2023-03-22 04:37:04 +01:00
Vendicated
cae8b1a93b Bump to v1.1.2 2023-03-22 04:07:12 +01:00
Nuckyz
a1c1fec8cb Improve Fake Nitro client themes bypass (#654) 2023-03-22 03:01:32 +00:00
Lewis Crichton
55a66dbb39 fix(RoleColorEverywhere): MessageLinkEmbeds DM error (#648) 2023-03-21 23:57:53 -03:00
Nuckyz
a2f0c912f0 Fix Fake Nitro making Discord unusable and ColorSighted not working sometimes (#640) 2023-03-21 09:41:31 +00:00
Nuckyz
e29bbf73aa Fix Nitro Themes with FakeNitro (#639) 2023-03-21 09:03:28 +00:00
Nuckyz
0ba3e9f469 I'm sorry for hurting you Fake Nitro (#637) 2023-03-21 06:41:11 +00:00
Nuckyz
6f200e9218 Fix grammar and SHC patches matching wrong var (#636) 2023-03-21 06:30:09 +00:00
Nuckyz
586b26d2d4 Fixes and ShowHiddenChannels improvements (#634)
~ Fixes #630
~ Fixes #618
2023-03-21 06:07:16 +00:00
Ven
d482d33d6f Fix the infamous MessageClickActions bug 2023-03-21 03:19:02 +01:00
Dossy Shiobara
37c2a8a5de fix: settings input validation and error handling (#609)
Co-authored-by: Ven <vendicated@riseup.net>
2023-03-21 02:16:01 +00:00
Dossy Shiobara
265547213c docs: clarify empty patches array behavior (#610)
Co-authored-by: Ven <vendicated@riseup.net>
2023-03-21 02:14:27 +00:00
afn
87e46f5a5a chore(friendInvites): improve descriptions formatting (#628) 2023-03-21 03:13:11 +01:00
Nuckyz
e36f4e5b0a Fixes and make guild tooltip show users inside hidden voice channels (#613)
* Fix #509

* Fix #597

* Fix #594
2023-03-19 22:03:33 -03:00
Xinto
4aff11421f Replace update notices with notifications (#558) 2023-03-19 09:21:26 +00:00
Nuckyz
ea642d9e90 Fix #598 (#612) 2023-03-19 08:44:11 +00:00
fawn
17c3496542 feat(typingIndicator): Option to not show indicator for blocked users (#513) 2023-03-19 05:13:17 -03:00
Nuckyz
0fb79b763d Improvements, changes and fixes (#611) 2023-03-19 04:53:00 -03:00
whqwert
5873bde6a6 fix(apiMessagePopover): fix match (#608) 2023-03-18 22:38:08 +01:00
Nuckyz
0b79387800 feat(PlatformIndicators): Colored mobile indicator option (#536)
Co-authored-by: Ven <vendicated@riseup.net>
2023-03-18 04:58:49 +01:00
Lewis Crichton
6b493bc7d9 feat(plugin): F8Break (#581)
Co-authored-by: Ven <vendicated@riseup.net>
2023-03-18 04:54:19 +01:00
LordElias
de53bc7991 messageLogger: fix edited timestamp styling & add i18n (#607) 2023-03-18 04:37:55 +01:00
Lewis Crichton
4c5a56a8a5 fix(RoleColorEverywhere): Chat mentions (#605) 2023-03-15 22:27:46 -03:00
Vendicated
ed873ef9de Bump to v1.1.1 2023-03-15 18:01:04 +01:00
LordElias
d8a553feb0 improve MessageLogger deleted image hover animation (#603) 2023-03-14 18:48:36 +01:00
Ven
4717612090 :shipit: 2023-03-12 16:37:41 +01:00
Vendicated
5d1283bd85 Add Web/Desktop specific plugin capabilities; misc fixes 2023-03-11 14:18:32 +01:00
Ven
3b945b87b8 Delete src/plugins/reviewDB directory
Api owner refusing to properly moderate hate speech and related illegal / ToS infringing content
2023-03-11 12:26:54 +01:00
Vendicated
19c762f9c1 DevCompanion: Fix Deps 2023-03-11 00:28:27 +01:00
Vendicated
990adf7527 Fix casing in filename 2023-03-11 00:27:02 +01:00
Vendicated
983414d024 Add DevCompanion plugin (https://github.com/Vencord/Companion) 2023-03-11 00:25:49 +01:00
Vendicated
d5c05d857f Add DevOnly plugin capability 2023-03-11 00:25:32 +01:00
Nuckyz
bff6788546 feat(plugins): SilentMessageToggle (#586)
Co-authored-by: Ven <vendicated@riseup.net>
2023-03-09 01:19:28 +01:00
Nuckyz
253183a16a Fix Emote Cloner and improve ReverseImageSearch (#489) 2023-03-08 04:01:24 -03:00
Nuckyz
0fb3901a18 Fix Context Menu API (#583) 2023-03-08 06:01:15 +00:00
Nuckyz
1b199ec5d8 feat: Context Menu API (#496) 2023-03-08 01:59:50 -03:00
Nuckyz
40395d562a Improvements for patches and misc stuff (#582) 2023-03-08 00:11:59 -03:00
Nuckyz
7322c3af04 Fix Crash Loops and prevent metrics (#580) 2023-03-06 22:54:01 +01:00
Nuckyz
36c27f1111 VCDoubleClick: Fix applying to non voice channels (#572) 2023-03-06 02:39:53 +01:00
Nuckyz
95db6c32a3 Fix Ignore Activities button on platforms different than Windows (#528)
Co-authored-by: Ven <vendicated@riseup.net>
2023-03-06 00:12:52 +01:00
Nuckyz
bed5e98bb0 Misc fixes and improvements (#555)
Co-authored-by: Ven <vendicated@riseup.net>
2023-03-05 22:49:59 +01:00
Nico
a5392e5c53 fix(silentTyping): fix chatbar icon patch (#570) 2023-03-05 22:30:37 +01:00
Sammy
abbd298b31 Fix(InvisibleChat) Fix chatbar icon patch (closes #560) (#566)
Co-authored-by: Ven <vendicated@riseup.net>
2023-03-05 22:05:46 +01:00
Nuckyz
e219aaa062 Notifications: Permanent option and close button (#563)
Co-authored-by: Ven <vendicated@riseup.net>
2023-03-04 18:49:15 +01:00
Vendicated
cab72e1be6 Strongly type useSettings (supersedes #559) 2023-03-04 18:41:32 +01:00
Berlin
92372bde1d Update 1_INSTALLING.md (#562) 2023-03-03 23:55:21 +00:00
megumin
6747276a87 Add admin warnings to INSTALLING.md (#561) 2023-03-03 23:07:48 +00:00
Vendicated
03915b7533 Bump to v1.1.0 2023-03-02 21:19:33 +01:00
Vendicated
5e2ec368ad patches: Make $self more robust 2023-03-02 21:17:15 +01:00
Vendicated
ab8c93fbac Rewrite MessageLinkEmbeds part 2 2023-03-02 21:05:09 +01:00
Vendicated
d6a3edefd9 Rewrite MessageLinkEmbeds to improve Code Quality 2023-03-02 21:01:31 +01:00
Vendicated
727297ec4e Fix messageLinkEmbeds 2023-03-02 18:49:24 +01:00
megumin
eccc4b0be1 feat(plugins): add FixInbox plugin (#552) 2023-03-02 04:55:30 +00:00
Vendicated
8465140bc4 Bump to v1.0.9 2023-03-01 21:40:31 +01:00
Lewis Crichton
e6ccb751a0 Fix for latest Discord Update (#550)
Co-authored-by: Nuckyz <61953774+Nuckyz@users.noreply.github.com>
Co-authored-by: Vendicated <vendicated@riseup.net>
2023-03-01 21:35:08 +01:00
Marvin Witt
dfc7a15083 chore: extend description of NoDevtoolsWarning plugin (#545)
Co-authored-by: Ven <vendicated@riseup.net>
2023-03-01 18:32:58 +01:00
Vendicated
37003edae9 fix(Notifications): Correctly close errored notifications 2023-03-01 05:45:17 +01:00
Nuckyz
faa90eccd3 feat: Crash Handler (#531)
Co-authored-by: Ven <vendicated@riseup.net>
2023-03-01 05:26:13 +01:00
Cloudburst
c91b0df607 GMPolyfill: add header prop (#543) 2023-02-28 23:13:49 +01:00
Ven
f56d99e133 Update README.md 2023-02-28 22:38:02 +01:00
Ven
c690662802 Improve README 2023-02-28 22:37:09 +01:00
Vendicated
4918d699d5 Windows: Add Option to use native titlebar ~ Closes #537 2023-02-28 22:17:39 +01:00
Justice Almanzar
5ec517875e typings for defaultless settings (#512)
* typings for defaultless settings

* fix other silly typings

* type guard utils

---------

Co-authored-by: Ven <vendicated@riseup.net>
2023-02-28 06:12:35 +01:00
Vendicated
cf56ad985b oop oop oop 2023-02-28 02:43:58 +01:00
Vendicated
c09d1558f7 Add SupportHelper plugin 2023-02-28 02:40:45 +01:00
232 changed files with 10620 additions and 3063 deletions

View File

@ -37,9 +37,12 @@ jobs:
- name: Build
run: pnpm build --standalone
- name: Generate plugin list
run: pnpm generatePluginJson dist/plugins.json
- name: Clean up obsolete files
run: |
rm -rf dist/extension* Vencord.user.css
rm -rf dist/*-unpacked Vencord.user.css vencordDesktopRenderer.css vencordDesktopRenderer.css.map
- name: Get some values needed for the release
id: release_values

View File

@ -35,15 +35,15 @@ jobs:
- name: Publish extension
run: |
cd dist/extension-unpacked
# Do not fail so that even if chrome fails, firefox gets a shot. But also store exit code to fail workflow later
EXIT_CODE=0
# Chrome
cd dist/chromium-unpacked
pnpx chrome-webstore-upload-cli@2.1.0 upload --auto-publish || EXIT_CODE=$?
# Firefox
cd ../firefox-unpacked
npm i -g web-ext@7.4.0 web-ext-submit@7.4.0
web-ext-submit || EXIT_CODE=$?
@ -58,4 +58,3 @@ jobs:
# Firefox
WEB_EXT_API_KEY: ${{ secrets.WEBEXT_USER }}
WEB_EXT_API_SECRET: ${{ secrets.WEBEXT_SECRET }}

View File

@ -36,7 +36,7 @@ jobs:
export PATH="$PWD/node_modules/.bin:$PATH"
export CHROMIUM_BIN=$(which chromium-browser)
esbuild test/generateReport.ts > dist/report.mjs
esbuild scripts/generateReport.ts > dist/report.mjs
node dist/report.mjs >> $GITHUB_STEP_SUMMARY
env:
DISCORD_TOKEN: ${{ secrets.DISCORD_TOKEN }}
@ -50,7 +50,7 @@ jobs:
export CHROMIUM_BIN=$(which chromium-browser)
export USE_CANARY=true
esbuild test/generateReport.ts > dist/report.mjs
esbuild scripts/generateReport.ts > dist/report.mjs
node dist/report.mjs >> $GITHUB_STEP_SUMMARY
env:
DISCORD_TOKEN: ${{ secrets.DISCORD_TOKEN }}

View File

@ -15,7 +15,7 @@ jobs:
- uses: pnpm/action-setup@v2 # Install pnpm using packageManager key in package.json
- name: Use Node.js 18
uses: actions/setup-node@v2
uses: actions/setup-node@v3
with:
node-version: 18
cache: "pnpm"

20
CODE_OF_CONDUCT.md Normal file
View File

@ -0,0 +1,20 @@
# Code of Conduct
Our community is welcoming to everyone, regardless of their characteristics.
As such, we expect you to treat everyone with respect and contribute to an open and welcoming community.
DO
- have empathy and be nice to others
- be respectful of differing opinions, even if you disagree
- give and accept constructive criticism
DON'T
- use offensive or derogatory language
- troll or spam
- personally attack or harass others
Repetitive violations of these guidelines might get your access to the repository restricted.
If you feel like a user is violating these guidelines or feel treated unfairly, please refrain from publicly challenging them and instead contact a Moderator on our Discord server or send an email to vendicated+conduct@riseup.net!

View File

@ -4,12 +4,14 @@ The cutest Discord client mod
## Features
- Super easy to install (one click installer)
- 90+ plugins built in: [See a list](https://gist.github.com/Vendicated/8696cde7b92548064a3ae92ead84d033)
- Some highlights: SpotifyControls, Experiments, NoTrack, MessageLogger, QuickReply, Free Emotes/Stickers, CustomCommands, ShowHiddenChannels, PronounDB
- Super easy to install (Download Installer, open, click install button, done)
- 100+ plugins built in: [See a list](https://gist.github.com/Vendicated/8696cde7b92548064a3ae92ead84d033)
- Some highlights: SpotifyControls, GameActivityToggle, Experiments, NoTrack, MessageLogger, QuickReply, Free Emotes/Stickers, CustomCommands, ShowHiddenChannels, PronounDB
- Fairly lightweight despite the many inbuilt plugins
- Excellent Browser Support: Run Vencord in your Browser via extension or UserScript
- Works on any Discord branch: Stable, Canary or PTB all work (though for the best experience I recommend stable!)
- Custom CSS and Themes: Inbuilt css editor with support to import any css files (including BetterDiscord themes)
- Works in all Electron versions (Confirmed working on versions 13-23)
- Privacy friendly, blocks Discord analytics & crash reporting out of the box and has no telemetry
- Maintained very actively, broken plugins are usually fixed within 12 hours
## Installing / Uninstalling
@ -20,7 +22,7 @@ The cutest Discord client mod
[![Get it on the Firefox Webstore](https://blog.mozilla.org/addons/files/2015/11/get-the-addon.png)](https://addons.mozilla.org/en-GB/firefox/addon/vencord-web/) [![Get it on the Chrome Webstore](https://storage.googleapis.com/web-dev-uploads/image/WlD8wC6g8khYWPJUsQceQkhXSlv1/UV4C4ybeBTsZt43U4xis.png)](https://chrome.google.com/webstore/detail/vencord-web/cbghhgpcnddeihccjmnadmkaejncjndb)
Or use the [UserScript](https://raw.githubusercontent.com/Vencord/builds/main/Vencord.user.js) - Please note that QuickCSS and plugins making use of external resources will not work with the UserScript.
Or use the [UserScript](https://raw.githubusercontent.com/Vencord/builds/main/Vencord.user.js) - Please note that the CSS Editor, Themes loaded from remote sources and co. will not work in the UserScript. Use the extension if you need any of those
## Building from Source
@ -39,3 +41,8 @@ See [CONTRIBUTING.md](CONTRIBUTING.md) and [Megu's Plugin Guide!](docs/2_PLUGINS
[join]: https://discord.gg/D9uwnFnqmd
[join] [join] [join] [join] [join] [join] [join] [join] [join] [join] [join] [join] [join] [join] [join] [join] [join] [join] [join] [join] [join] [join] [join] [join] [join] [join] [join] [join] [join] [join] [join] [join] [join] [join] [join] [join] [join] [join] [join] [join] [join] [join] [join] [join] [join] [join] [join] [join] [join] [join] [join] [join] [join] [join] [join] [join] [join] [join] [join] [join] [join] [join] [join] [join] [join] [join] [join] [join] [join] [join] [join] [join] [join] [join] [join] [join] [join] [join] [join] [join] [join] [join] [join] [join] [join] [join] [join] [join] [join] [join] [join] [join] [join] [join] [join] [join] [join] [join] [join] [join] [join] [join] [join] [join] [join] [join] [join] [join] [join] [join] [join] [join] [join] [join] [join] [join] [join] [join] [join] [join] [join] [join] [join] [join] [join] [join] [join] [join] [join] [join] [join] [join] [join] [join] [join] [join] [join] [join] [join] [join] [join] [join] [join] [join] [join] [join] [join] [join] [join] [join] [join] [join] [join] [join] [join] [join] [join] [join] [join] [join] [join] [join] [join] [join] [join] [join] [join] [join] [join] [join] [join] [join] [join] [join] [join] [join] [join] [join] [join] [join] [join] [join] [join] [join] [join] [join] [join] [join] [join] [join] [join] [join] [join] [join] [join] [join] [join] [join] [join] [join] [join] [join] [join] [join] [join] [join] [join] [join] [join] [join] [join] [join] [join] [join] [join] [join] [join] [join] [join] [join] [join] [join] [join] [join] [join] [join] [join] [join] [join] [join] [join] [join] [join] [join] [join] [join] [join] [join] [join] [join] [join] [join] [join] [join] [join] [join] [join] [join] [join] [join] [join] [join] [join] [join] [join] [join] [join] [join] [join] [join] [join] [join] [join] [join] [join] [join] [join] [join] [join] [join] [join] [join] [join] [join] [join] [join] [join] [join] [join] [join] [join] [join] [join] [join] [join] [join] [join] [join] [join] [join] [join] [join] [join] [join] [join] [join] [join] [join] [join] [join] [join] [join] [join] [join] [join] [join] [join] [join] [join] [join] [join] [join] [join] [join] [join] [join] [join] [join] [join] [join] [join] [join] [join] [join] [join] [join] [join] [join] [join] [join] [join] [join] [join] [join] [join] [join] [join] [join] [join] [join] [join] [join] [join] [join] [join] [join] [join] [join] [join] [join] [join] [join] [join] [join] [join] [join] [join] [join] [join] [join] [join] [join] [join] [join] [join] [join] [join] [join] [join] [join] [join] [join] [join] [join] [join] [join] [join] [join] [join] [join] [join] [join] [join] [join] [join] [join] [join] [join] [join] [join] [join] [join] [join] [join]
## Disclaimer
Discord is trademark of Discord Inc. and solely mentioned for the sake of descriptivity.
Mention of it does not imply any affiliation with or endorsement by Discord Inc.

View File

@ -59,8 +59,8 @@ async function checkCors(url, method) {
const origin = headers["access-control-allow-origin"];
if (origin !== "*" && origin !== window.location.origin) return false;
const methods = headers["access-control-allow-methods"]?.split(/,\s/g);
if (methods && !methods.includes(method)) return false;
const methods = headers["access-control-allow-methods"]?.toLowerCase().split(/,\s/g);
if (methods && !methods.includes(method.toLowerCase())) return false;
return true;
}
@ -92,6 +92,7 @@ function GM_fetch(url, opt) {
resp.arrayBuffer = () => blobTo("arrayBuffer", blob);
resp.text = () => blobTo("text", blob);
resp.json = async () => JSON.parse(await blobTo("text", blob));
resp.headers = new Headers(parseHeaders(resp.responseHeaders));
resolve(resp);
};
options.ontimeout = () => reject("fetch timeout");

32
browser/background.js Normal file
View File

@ -0,0 +1,32 @@
/**
* @template T
* @param {T[]} arr
* @param {(v: T) => boolean} predicate
*/
function removeFirst(arr, predicate) {
const idx = arr.findIndex(predicate);
if (idx !== -1) arr.splice(idx, 1);
}
chrome.webRequest.onHeadersReceived.addListener(
({ responseHeaders, type, url }) => {
if (!responseHeaders) return;
if (type === "main_frame") {
// In main frame requests, the CSP needs to be removed to enable fetching of custom css
// as desired by the user
removeFirst(responseHeaders, h => h.name.toLowerCase() === "content-security-policy");
} else if (type === "stylesheet" && url.startsWith("https://raw.githubusercontent.com")) {
// Most users will load css from GitHub, but GitHub doesn't set the correct content type,
// so we fix it here
removeFirst(responseHeaders, h => h.name.toLowerCase() === "content-type");
responseHeaders.push({
name: "Content-Type",
value: "text/css"
});
}
return { responseHeaders };
},
{ urls: ["https://raw.githubusercontent.com/*", "*://*.discord.com/*"], types: ["main_frame", "stylesheet"] },
["blocking", "responseHeaders"]
);

Binary file not shown.

Before

Width:  |  Height:  |  Size: 21 KiB

After

Width:  |  Height:  |  Size: 1.1 KiB

View File

@ -21,7 +21,8 @@
{
"run_at": "document_start",
"matches": ["*://*.discord.com/*"],
"js": ["content.js"]
"js": ["content.js"],
"all_frames": true
}
],

41
browser/manifestv2.json Normal file
View File

@ -0,0 +1,41 @@
{
"manifest_version": 2,
"minimum_chrome_version": "91",
"name": "Vencord Web",
"description": "The cutest Discord mod now in your browser",
"author": "Vendicated",
"homepage_url": "https://github.com/Vendicated/Vencord",
"icons": {
"128": "icon.png"
},
"permissions": [
"webRequest",
"webRequestBlocking",
"*://*.discord.com/*",
"https://raw.githubusercontent.com/*"
],
"content_scripts": [
{
"run_at": "document_start",
"matches": ["*://*.discord.com/*"],
"js": ["content.js"],
"all_frames": true
}
],
"background": {
"scripts": ["background.js"]
},
"web_accessible_resources": ["dist/Vencord.js", "dist/Vencord.css"],
"browser_specific_settings": {
"gecko": {
"id": "vencord-firefox@vendicated.dev",
"strict_min_version": "91.0"
}
}
}

View File

@ -15,7 +15,7 @@
]
},
"condition": {
"resourceTypes": ["main_frame"]
"resourceTypes": ["main_frame", "sub_frame"]
}
},
{

View File

@ -13,12 +13,6 @@ Welcome to Megu's Installation Guide! In this file, you will learn about how to
- [Installing Vencord](#installing-vencord)
- [Updating Vencord](#updating-vencord)
- [Uninstalling Vencord](#uninstalling-vencord)
- [Manually Installing Vencord](#manually-installing-vencord)
- [On Windows](#on-windows)
- [On Linux](#on-linux)
- [On MacOS](#on-macos)
- [Manual Patching](#manual-patching)
- [Manually Uninstalling Vencord](#manually-uninstalling-vencord)
## Dependencies
@ -27,16 +21,16 @@ Welcome to Megu's Installation Guide! In this file, you will learn about how to
## Installing Vencord
> :exclamation: If this doesn't work, see [Manually Installing Vencord](#manually-installing-vencord)
Install `pnpm`:
> :exclamation: this may need to be run as admin depending on your system, and you may need to close and reopen your terminal.
> :exclamation: This next command may need to be run as admin/root depending on your system, and you may need to close and reopen your terminal for pnpm to be in your PATH.
```shell
npm i -g pnpm
```
> :exclamation: **IMPORTANT** Make sure you aren't using an admin/root terminal from here onwards. It **will** mess up your Discord/Vencord instance and you **will** most likely have to reinstall.
Clone Vencord:
```shell
@ -101,102 +95,4 @@ Simply run:
pnpm uninject
```
The above command may ask you to also run:
```shell
pnpm install --frozen-lockfile
pnpm uninject
```
## Manually Installing Vencord
- [Windows](#on-windows)
- [Linux](#on-linux)
- [MacOS](#on-macos)
### On Windows
Press Win+R and enter: `%LocalAppData%` and hit enter. In this page, find the page (Discord, DiscordPTB, DiscordCanary, etc) that you want to patch.
Now follow the instructions at [Manual Patching](#manual-patching)
### On Linux
The Discord folder is usually in one of the following paths:
- /usr/share
- /usr/lib64
- /opt
- /home/$USER/.local/share
If you use flatpak, it will usually be in one of the following paths:
- /var/lib/flatpak/app/com.discordapp.Discord/current/active/files
- /home/$USER/.local/share/flatpak/app/com.discordapp.Discord/current/active/files
You will need to give flatpak access to vencord with one of the following commands:
> :exclamation: If not on stable, replace `com.discordapp.Discord` with your branch name, e.g., `com.discordapp.DiscordCanary`
> :exclamation: Replace `/path/to/vencord/` with the path to your vencord folder (NOT the dist folder)
If Discord flatpak install is in /home/:
```shell
flatpak override --user com.discordapp.Discord --filesystem="/path/to/vencord/"
```
If Discord flatpak install not in /home/:
```shell
sudo flatpak override com.discordapp.Discord --filesystem="/path/to/vencord"
```
Now follow the instructions at [Manual Patching](#manual-patching)
### On MacOS
Open finder and go to your Applications folder. Right-Click on the Discord application you want to patch, and view contents.
Go to the `Contents/Resources` folder.
Now follow the instructions at [Manual Patching](#manual-patching)
### Manual Patching
> :exclamation: If using Flatpak on linux, go to the folder that contains the `app.asar` file, and skip to where we create the `app` folder below.
> :exclamation: On Linux/MacOS, there's a chance there won't be an `app-<number>` folder, but there probably is a `resources` folder, so keep reading :)
Inside there, look for the `app-<number>` folders. If you have multiple, use the highest number. If that doesn't work, do it for the rest of the `app-<number>` folders.
Inside there, go to the `resources` folder. There should be a file called `app.asar`. If there isn't, look at a different `app-<number>` folder instead.
Make a new folder in `resources` called `app`. In here, we will make two files:
`package.json` and `index.js`
In `index.js`:
> :exclamation: Replace the path in the first line with the path to `patcher.js` in your vencord dist folder.
> On Windows, you can get this by shift-rightclicking the patcher.js file and selecting "copy as path"
```js
require("C:/Users/<your user>/path/to/vencord/dist/patcher.js");
```
And in `package.json`:
```json
{ "name": "discord", "main": "index.js" }
```
Finally, fully close & reopen your Discord client and check to see that `Vencord` appears in settings!
### Manually Uninstalling Vencord
> :exclamation: Do not delete `app.asar` - Only delete the `app` folder we created.
Use the instructions above to find the `app` folder, and delete it. Then Close & Reopen Discord.
If you need more help, ask in the support channel in our [Discord Server](https://discord.gg/D9uwnFnqmd).

View File

@ -26,6 +26,10 @@ export default definePlugin({
name: "Your Name",
},
],
// Delete `patches` if you are not using code patches, as it will make
// your plugin require restarts, and your stop() method will not be
// invoked at all. The presence of the key in the object alone is
// enough to trigger this behavior, even if the value is an empty array.
patches: [],
// Delete these two below if you are only using code patches
start() {},

View File

@ -1,9 +1,8 @@
{
"name": "vencord",
"private": "true",
"version": "1.0.8",
"version": "1.1.9",
"description": "The cutest Discord client mod",
"keywords": [],
"homepage": "https://github.com/Vendicated/Vencord#readme",
"bugs": {
"url": "https://github.com/Vendicated/Vencord/issues"
@ -20,20 +19,24 @@
"scripts": {
"build": "node scripts/build/build.mjs",
"buildWeb": "node --require=./scripts/suppressExperimentalWarnings.js scripts/build/buildWeb.mjs",
"generatePluginJson": "tsx scripts/generatePluginList.ts",
"inject": "node scripts/runInstaller.mjs",
"lint": "eslint . --ext .js,.jsx,.ts,.tsx",
"lint-styles": "stylelint \"src/**/*.css\"",
"lint": "eslint . --ext .js,.jsx,.ts,.tsx --ignore-pattern src/userplugins",
"lint-styles": "stylelint \"src/**/*.css\" --ignore-pattern src/userplugins",
"lint:fix": "pnpm lint --fix",
"test": "pnpm build && pnpm lint && pnpm lint-styles && pnpm testTsc",
"testWeb": "pnpm lint && pnpm buildWeb && pnpm testTsc",
"testTsc": "tsc --noEmit",
"uninject": "node scripts/runInstaller.mjs",
"watch": "node scripts/build/build.mjs --watch"
"watch": "node scripts/build/build.mjs --watch",
"buildTypes": "ttsc --emitDeclarationOnly --declaration --outDir packages/vencord-types"
},
"dependencies": {
"@vap/core": "0.0.12",
"@vap/shiki": "0.10.3",
"fflate": "^0.7.4"
"fflate": "^0.7.4",
"nanoid": "^4.0.2",
"virtual-merge": "^1.0.1"
},
"devDependencies": {
"@types/diff": "^5.0.2",
@ -59,10 +62,13 @@
"standalone-electron-types": "^1.0.0",
"stylelint": "^14.16.1",
"stylelint-config-standard": "^29.0.0",
"tsx": "^3.12.6",
"ttypescript": "^1.5.15",
"type-fest": "^3.5.3",
"typescript": "^4.9.4"
"typescript": "^4.9.4",
"typescript-transform-paths": "^3.4.6"
},
"packageManager": "pnpm@7.13.4",
"packageManager": "pnpm@8.1.1",
"pnpm": {
"patchedDependencies": {
"eslint-plugin-path-alias@1.0.0": "patches/eslint-plugin-path-alias@1.0.0.patch",
@ -89,6 +95,7 @@
"sourceDir": "./dist/extension-v2-unpacked"
},
"engines": {
"node": ">=18"
"node": ">=18",
"pnpm": ">=8"
}
}

7
packages/vencord-types/.gitignore vendored Normal file
View File

@ -0,0 +1,7 @@
*
!.*ignore
!package.json
!README.md
!prepare.ts
!index.d.ts
!globals.d.ts

View File

@ -0,0 +1,3 @@
node_modules
prepare.ts
.gitignore

View File

@ -0,0 +1,11 @@
# Vencord Types
Typings for Vencord's api, published to npm
```sh
npm i @vencord/types
yarn add @vencord/types
pnpm add @vencord/types
```

24
packages/vencord-types/globals.d.ts vendored Normal file
View File

@ -0,0 +1,24 @@
/*
* 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/>.
*/
declare global {
export var VencordNative: typeof import("./VencordNative").default;
export var Vencord: typeof import("./Vencord");
}
export { };

5
packages/vencord-types/index.d.ts vendored Normal file
View File

@ -0,0 +1,5 @@
/* eslint-disable */
/// <reference path="Vencord.d.ts" />
/// <reference path="globals.d.ts" />
/// <reference path="modules.d.ts" />

View File

@ -0,0 +1,26 @@
{
"name": "@vencord/types",
"private": false,
"version": "0.1.3",
"description": "",
"types": "index.d.ts",
"scripts": {
"prepublishOnly": "tsx ./prepare.ts",
"test": "echo \"Error: no test specified\" && exit 1"
},
"keywords": [],
"author": "Vencord",
"license": "GPL-3.0",
"devDependencies": {
"tsx": "^3.12.6"
},
"dependencies": {
"@types/lodash": "^4.14.191",
"@types/node": "^18.11.18",
"@types/react": "^18.0.27",
"@types/react-dom": "^18.0.10",
"discord-types": "^1.3.26",
"standalone-electron-types": "^1.0.0",
"type-fest": "^3.5.3"
}
}

View File

@ -0,0 +1,44 @@
/*
* Vencord, a modification for Discord's desktop app
* Copyright (c) 2023 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 { cpSync, readdirSync, rmSync } from "fs";
import { join } from "path";
const SRC = join(__dirname, "..", "..", "src");
for (const file of ["preload.d.ts", "userplugins", "main", "debug"]) {
rmSync(join(__dirname, file), { recursive: true, force: true });
}
function copyDtsFiles(from: string, to: string) {
for (const file of readdirSync(from, { withFileTypes: true })) {
// bad
if (from === SRC && file.name === "globals.d.ts") continue;
const fullFrom = join(from, file.name);
const fullTo = join(to, file.name);
if (file.isDirectory()) {
copyDtsFiles(fullFrom, fullTo);
} else if (file.name.endsWith(".d.ts")) {
cpSync(fullFrom, fullTo);
}
}
}
copyDtsFiles(SRC, __dirname);

1499
pnpm-lock.yaml generated

File diff suppressed because it is too large Load Diff

2
pnpm-workspace.yaml Normal file
View File

@ -0,0 +1,2 @@
packages:
- packages/*

View File

@ -48,6 +48,7 @@ const sourceMapFooter = s => watch ? "" : `//# sourceMappingURL=vencord://${s}.j
const sourcemap = watch ? "inline" : "external";
await Promise.all([
// common preload
esbuild.build({
...nodeCommonOpts,
entryPoints: ["src/preload.ts"],
@ -55,12 +56,19 @@ await Promise.all([
footer: { js: "//# sourceURL=VencordPreload\n" + sourceMapFooter("preload") },
sourcemap,
}),
// Discord Desktop main & renderer
esbuild.build({
...nodeCommonOpts,
entryPoints: ["src/patcher.ts"],
entryPoints: ["src/main/index.ts"],
outfile: "dist/patcher.js",
footer: { js: "//# sourceURL=VencordPatcher\n" + sourceMapFooter("patcher") },
sourcemap,
define: {
...defines,
IS_DISCORD_DESKTOP: true,
IS_VENCORD_DESKTOP: false
}
}),
esbuild.build({
...commonOpts,
@ -68,16 +76,52 @@ await Promise.all([
outfile: "dist/renderer.js",
format: "iife",
target: ["esnext"],
footer: { js: "//# sourceURL=VencordRenderer\n" + sourceMapFooter("renderer") },
footer: { js: "Vencord.Plugins.loadExternalPlugins();\n//# sourceURL=VencordRenderer\n" + sourceMapFooter("renderer") },
globalName: "Vencord",
sourcemap,
plugins: [
globPlugins,
globPlugins("discordDesktop"),
...commonOpts.plugins
],
define: {
...defines,
IS_WEB: false
IS_WEB: false,
IS_DISCORD_DESKTOP: true,
IS_VENCORD_DESKTOP: false
}
}),
// Vencord Desktop main & renderer
esbuild.build({
...nodeCommonOpts,
entryPoints: ["src/main/index.ts"],
outfile: "dist/vencordDesktopMain.js",
footer: { js: "//# sourceURL=VencordDesktopMain\n" + sourceMapFooter("vencordDesktopMain") },
sourcemap,
define: {
...defines,
IS_DISCORD_DESKTOP: false,
IS_VENCORD_DESKTOP: true
}
}),
esbuild.build({
...commonOpts,
entryPoints: ["src/Vencord.ts"],
outfile: "dist/vencordDesktopRenderer.js",
format: "iife",
target: ["esnext"],
footer: { js: "Vencord.Plugins.loadExternalPlugins();\n//# sourceURL=VencordDesktopRenderer\n" + sourceMapFooter("vencordDesktopRenderer") },
globalName: "Vencord",
sourcemap,
plugins: [
globPlugins("vencordDesktop"),
...commonOpts.plugins
],
define: {
...defines,
IS_WEB: false,
IS_DISCORD_DESKTOP: false,
IS_VENCORD_DESKTOP: true
}
}),
]).catch(err => {

View File

@ -36,16 +36,18 @@ const commonOptions = {
entryPoints: ["browser/Vencord.ts"],
globalName: "Vencord",
format: "iife",
external: ["plugins", "git-hash"],
external: ["plugins", "git-hash", "/assets/*"],
plugins: [
globPlugins,
globPlugins("web"),
...commonOpts.plugins,
],
target: ["esnext"],
define: {
IS_WEB: "true",
IS_STANDALONE: "true",
IS_DEV: JSON.stringify(watch)
IS_DEV: JSON.stringify(watch),
IS_DISCORD_DESKTOP: "false",
IS_VENCORD_DESKTOP: "false"
}
};
@ -140,6 +142,7 @@ const appendCssRuntime = readFile("dist/Vencord.user.css", "utf-8").then(content
await Promise.all([
appendCssRuntime,
buildPluginZip("extension.zip", ["modifyResponseHeaders.json", "content.js", "manifest.json", "icon.png"], true),
buildPluginZip("extension-unpacked", ["modifyResponseHeaders.json", "content.js", "manifest.json", "icon.png"], false),
buildPluginZip("chromium-unpacked", ["modifyResponseHeaders.json", "content.js", "manifest.json", "icon.png"], false),
buildPluginZip("firefox-unpacked", ["background.js", "content.js", "manifestv2.json", "icon.png"], false),
]);

View File

@ -33,6 +33,8 @@ export const banner = {
`.trim()
};
const isWeb = process.argv.slice(0, 2).some(f => f.endsWith("buildWeb.mjs"));
// https://github.com/evanw/esbuild/issues/619#issuecomment-751995294
/**
* @type {import("esbuild").Plugin}
@ -46,9 +48,9 @@ export const makeAllPackagesExternalPlugin = {
};
/**
* @type {import("esbuild").Plugin}
* @type {(kind: "web" | "discordDesktop" | "vencordDesktop") => import("esbuild").Plugin}
*/
export const globPlugins = {
export const globPlugins = kind => ({
name: "glob-plugins",
setup: build => {
const filter = /^~plugins$/;
@ -69,9 +71,17 @@ export const globPlugins = {
const files = await readdir(`./src/${dir}`);
for (const file of files) {
if (file.startsWith(".")) continue;
if (file === "index.ts") {
continue;
if (file === "index.ts") continue;
const fileBits = file.split(".");
if (fileBits.length > 2 && ["ts", "tsx"].includes(fileBits.at(-1))) {
const mod = fileBits.at(-2);
if (mod === "dev" && !watch) continue;
if (mod === "web" && kind === "discordDesktop") continue;
if (mod === "desktop" && kind === "web") continue;
if (mod === "discordDesktop" && kind !== "discordDesktop") continue;
if (mod === "vencordDesktop" && kind !== "vencordDesktop") continue;
}
const mod = `p${i}`;
code += `import ${mod} from "./${dir}/${file.replace(/\.tsx?$/, "")}";\n`;
plugins += `[${mod}.name]:${mod},\n`;
@ -85,7 +95,7 @@ export const globPlugins = {
};
});
}
};
});
/**
* @type {import("esbuild").Plugin}
@ -185,7 +195,7 @@ export const commonOpts = {
legalComments: "linked",
banner,
plugins: [fileIncludePlugin, gitHashPlugin, gitRemotePlugin, stylePlugin],
external: ["~plugins", "~git-hash", "~git-remote"],
external: ["~plugins", "~git-hash", "~git-remote", "/assets/*"],
inject: ["./scripts/build/inject/react.mjs"],
jsxFactory: "VencordCreateElement",
jsxFragment: "VencordFragment",

View File

@ -0,0 +1,191 @@
/*
* Vencord, a modification for Discord's desktop app
* Copyright (c) 2023 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 { Dirent, readdirSync, readFileSync, writeFileSync } from "fs";
import { access, readFile } from "fs/promises";
import { join } from "path";
import { BigIntLiteral, createSourceFile, Identifier, isArrayLiteralExpression, isCallExpression, isExportAssignment, isIdentifier, isObjectLiteralExpression, isPropertyAccessExpression, isPropertyAssignment, isStringLiteral, isVariableStatement, NamedDeclaration, NodeArray, ObjectLiteralExpression, ScriptTarget, StringLiteral, SyntaxKind } from "typescript";
interface Dev {
name: string;
id: string;
}
interface PluginData {
name: string;
description: string;
authors: Dev[];
dependencies: string[];
hasPatches: boolean;
hasCommands: boolean;
required: boolean;
enabledByDefault: boolean;
target: "discordDesktop" | "vencordDesktop" | "web" | "dev";
}
const devs = {} as Record<string, Dev>;
function getName(node: NamedDeclaration) {
return node.name && isIdentifier(node.name) ? node.name.text : undefined;
}
function hasName(node: NamedDeclaration, name: string) {
return getName(node) === name;
}
function getObjectProp(node: ObjectLiteralExpression, name: string) {
const prop = node.properties.find(p => hasName(p, name));
if (prop && isPropertyAssignment(prop)) return prop.initializer;
return prop;
}
function parseDevs() {
const file = createSourceFile("constants.ts", readFileSync("src/utils/constants.ts", "utf8"), ScriptTarget.Latest);
for (const child of file.getChildAt(0).getChildren()) {
if (!isVariableStatement(child)) continue;
const devsDeclaration = child.declarationList.declarations.find(d => hasName(d, "Devs"));
if (!devsDeclaration?.initializer || !isCallExpression(devsDeclaration.initializer)) continue;
const value = devsDeclaration.initializer.arguments[0];
if (!isObjectLiteralExpression(value)) return;
for (const prop of value.properties) {
const name = (prop.name as Identifier).text;
const value = isPropertyAssignment(prop) ? prop.initializer : prop;
if (!isObjectLiteralExpression(value)) throw new Error(`Failed to parse devs: ${name} is not an object literal`);
devs[name] = {
name: (getObjectProp(value, "name") as StringLiteral).text,
id: (getObjectProp(value, "id") as BigIntLiteral).text.slice(0, -1)
};
}
return;
}
throw new Error("Could not find Devs constant");
}
async function parseFile(fileName: string) {
const file = createSourceFile(fileName, await readFile(fileName, "utf8"), ScriptTarget.Latest);
const fail = (reason: string) => {
return new Error(`Invalid plugin ${fileName}, because ${reason}`);
};
for (const node of file.getChildAt(0).getChildren()) {
if (!isExportAssignment(node) || !isCallExpression(node.expression)) continue;
const call = node.expression;
if (!isIdentifier(call.expression) || call.expression.text !== "definePlugin") continue;
const pluginObj = node.expression.arguments[0];
if (!isObjectLiteralExpression(pluginObj)) throw fail("no object literal passed to definePlugin");
const data = {
hasPatches: false,
hasCommands: false,
enabledByDefault: false,
required: false,
} as PluginData;
for (const prop of pluginObj.properties) {
const key = getName(prop);
const value = isPropertyAssignment(prop) ? prop.initializer : prop;
switch (key) {
case "name":
case "description":
if (!isStringLiteral(value)) throw fail(`${key} is not a string literal`);
data[key] = value.text;
break;
case "patches":
data.hasPatches = true;
break;
case "commands":
data.hasCommands = true;
break;
case "authors":
if (!isArrayLiteralExpression(value)) throw fail("authors is not an array literal");
data.authors = value.elements.map(e => {
if (!isPropertyAccessExpression(e)) throw fail("authors array contains non-property access expressions");
return devs[getName(e)!];
});
break;
case "dependencies":
if (!isArrayLiteralExpression(value)) throw fail("dependencies is not an array literal");
const { elements } = value;
if (elements.some(e => !isStringLiteral(e))) throw fail("dependencies array contains non-string elements");
data.dependencies = (elements as NodeArray<StringLiteral>).map(e => e.text);
break;
case "required":
case "enabledByDefault":
data[key] = value.kind === SyntaxKind.TrueKeyword;
if (!data[key] && value.kind !== SyntaxKind.FalseKeyword) throw fail(`${key} is not a boolean literal`);
break;
}
}
if (!data.name || !data.description || !data.authors) throw fail("name, description or authors are missing");
const fileBits = fileName.split(".");
if (fileBits.length > 2 && ["ts", "tsx"].includes(fileBits.at(-1)!)) {
const mod = fileBits.at(-2)!;
if (!["web", "discordDesktop", "vencordDesktop", "dev"].includes(mod)) throw fail(`invalid target ${fileBits.at(-2)}`);
data.target = mod as any;
}
return data;
}
throw fail("no default export called 'definePlugin' found");
}
async function getEntryPoint(dirent: Dirent) {
const base = join("./src/plugins", dirent.name);
if (!dirent.isDirectory()) return base;
for (const name of ["index.ts", "index.tsx"]) {
const full = join(base, name);
try {
await access(full);
return full;
} catch { }
}
throw new Error(`${dirent.name}: Couldn't find entry point`);
}
(async () => {
parseDevs();
const plugins = readdirSync("./src/plugins", { withFileTypes: true }).filter(d => d.name !== "index.ts");
const promises = plugins.map(async dirent => parseFile(await getEntryPoint(dirent)));
const data = JSON.stringify(await Promise.all(promises));
if (process.argv.length > 2) {
writeFileSync(process.argv[2], data);
} else {
console.log(data);
}
})();

View File

@ -186,7 +186,16 @@ page.on("console", async e => {
} else if (isDebug) {
console.error(e.text());
} else if (level === "error") {
const text = e.text();
const text = await Promise.all(
e.args().map(async a => {
try {
return await maybeGetError(a) || await a.jsonValue();
} catch (e) {
return a.toString();
}
})
).then(a => a.join(" "));
if (!text.startsWith("Failed to load resource: the server responded with a status of")) {
console.error("Got unexpected error", text);
report.otherErrors.push(text);
@ -258,7 +267,7 @@ function runTime(token: string) {
if (!isWasm)
await wreq.e(id as any);
await new Promise(r => setTimeout(r, 100));
await new Promise(r => setTimeout(r, 500));
}
console.error("[PUP_DEBUG]", "Finished loading chunks!");

View File

@ -27,20 +27,48 @@ export { PlainSettings, Settings };
import "./utils/quickCss";
import "./webpack/patchWebpack";
import { popNotice, showNotice } from "./api/Notices";
import { showNotification } from "./api/Notifications";
import { PlainSettings, Settings } from "./api/settings";
import { patches, PMLogger, startAllPlugins } from "./plugins";
import { localStorage } from "./utils/localStorage";
import { relaunch } from "./utils/native";
import { getCloudSettings, putCloudSettings } from "./utils/settingsSync";
import { checkForUpdates, rebuild, update, UpdateLogger } from "./utils/updater";
import { onceReady } from "./webpack";
import { SettingsRouter } from "./webpack/common";
export let Components: any;
async function syncSettings() {
if (
Settings.cloud.settingsSync && // if it's enabled
Settings.cloud.authenticated // if cloud integrations are enabled
) {
if (localStorage.Vencord_settingsDirty) {
await putCloudSettings();
delete localStorage.Vencord_settingsDirty;
} else if (await getCloudSettings(false)) { // if we synchronized something (false means no sync)
// we show a notification here instead of allowing getCloudSettings() to show one to declutter the amount of
// potential notifications that might occur. getCloudSettings() will always send a notification regardless if
// there was an error to notify the user, but besides that we only want to show one notification instead of all
// of the possible ones it has (such as when your settings are newer).
showNotification({
title: "Cloud Settings",
body: "Your settings have been updated! Click here to restart to fully apply changes!",
color: "var(--green-360)",
onClick: relaunch
});
}
}
}
async function init() {
await onceReady;
startAllPlugins();
Components = await import("./components");
syncSettings();
if (!IS_WEB) {
try {
const isOutdated = await checkForUpdates();
@ -48,33 +76,28 @@ async function init() {
if (Settings.autoUpdate) {
await update();
const needsFullRestart = await rebuild();
setTimeout(() => {
showNotice(
"Vencord has been updated!",
"Restart",
() => {
if (needsFullRestart)
window.DiscordNative.app.relaunch();
else
location.reload();
}
);
}, 10_000);
await rebuild();
if (Settings.autoUpdateNotification)
setTimeout(() => showNotification({
title: "Vencord has been updated!",
body: "Click here to restart",
permanent: true,
noPersist: true,
onClick: relaunch
}), 10_000);
return;
}
if (Settings.notifyAboutUpdates)
setTimeout(() => {
showNotice(
"A Vencord update is available!",
"View Update",
() => {
popNotice();
SettingsRouter.open("VencordUpdater");
}
);
}, 10_000);
setTimeout(() => showNotification({
title: "A Vencord update is available!",
body: "Click here to view the update",
permanent: true,
noPersist: true,
onClick() {
SettingsRouter.open("VencordUpdater");
}
}), 10_000);
} catch (err) {
UpdateLogger.error("Failed to check for updates", err);
}
@ -95,3 +118,12 @@ async function init() {
}
init();
if (IS_DISCORD_DESKTOP && Settings.winNativeTitleBar && navigator.platform.toLowerCase().startsWith("win")) {
document.addEventListener("DOMContentLoaded", () => {
document.head.append(Object.assign(document.createElement("style"), {
id: "vencord-native-titlebar-style",
textContent: "[class*=titleBar-]{display: none!important}"
}));
}, { once: true });
}

View File

@ -29,11 +29,12 @@ export enum BadgePosition {
export interface ProfileBadge {
/** The tooltip to show on hover. Required for image badges */
tooltip?: string;
description?: string;
/** Custom component for the badge (tooltip not included) */
component?: ComponentType<ProfileBadge & BadgeUserArgs>;
/** The custom image to use */
image?: string;
link?: string;
/** Action to perform when you click the badge */
onClick?(): void;
/** Should the user display this badge? */
@ -69,17 +70,19 @@ export function removeBadge(badge: ProfileBadge) {
* Inject badges into the profile badges array.
* You probably don't need to use this.
*/
export function inject(badgeArray: ProfileBadge[], args: BadgeUserArgs) {
export function _getBadges(args: BadgeUserArgs) {
const badges = [] as ProfileBadge[];
for (const badge of Badges) {
if (!badge.shouldShow || badge.shouldShow(args)) {
badge.position === BadgePosition.START
? badgeArray.unshift({ ...badge, ...args })
: badgeArray.push({ ...badge, ...args });
? badges.unshift({ ...badge, ...args })
: badges.push({ ...badge, ...args });
}
}
(Plugins.BadgeAPI as any).addDonorBadge(badgeArray, args.user.id);
const donorBadge = (Plugins.BadgeAPI as any).getDonorBadge(args.user.id);
if (donorBadge) badges.unshift(donorBadge);
return badgeArray;
return badges;
}
export interface BadgeUserArgs {

View File

@ -111,6 +111,7 @@ function registerSubCommands(cmd: Command, plugin: string) {
...o,
type: ApplicationCommandType.CHAT_INPUT,
name: `${cmd.name} ${o.name}`,
id: `${o.name}-${cmd.id}`,
displayName: `${cmd.name} ${o.name}`,
subCommandPath: [{
name: o.name,

155
src/api/ContextMenu.ts Normal file
View File

@ -0,0 +1,155 @@
/*
* Vencord, a modification for Discord's desktop app
* Copyright (c) 2023 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 Logger from "@utils/Logger";
import type { ReactElement } from "react";
type ContextMenuPatchCallbackReturn = (() => void) | void;
/**
* @param children The rendered context menu elements
* @param args Any arguments passed into making the context menu, like the guild, channel, user or message for example
* @returns A callback which is only ran once used to modify the context menu elements (Use to avoid duplicates)
*/
export type NavContextMenuPatchCallback = (children: Array<React.ReactElement>, ...args: Array<any>) => ContextMenuPatchCallbackReturn;
/**
* @param navId The navId of the context menu being patched
* @param children The rendered context menu elements
* @param args Any arguments passed into making the context menu, like the guild, channel, user or message for example
* @returns A callback which is only ran once used to modify the context menu elements (Use to avoid duplicates)
*/
export type GlobalContextMenuPatchCallback = (navId: string, children: Array<React.ReactElement>, ...args: Array<any>) => ContextMenuPatchCallbackReturn;
const ContextMenuLogger = new Logger("ContextMenu");
export const navPatches = new Map<string, Set<NavContextMenuPatchCallback>>();
export const globalPatches = new Set<GlobalContextMenuPatchCallback>();
/**
* Add a context menu patch
* @param navId The navId(s) for the context menu(s) to patch
* @param patch The patch to be applied
*/
export function addContextMenuPatch(navId: string | Array<string>, patch: NavContextMenuPatchCallback) {
if (!Array.isArray(navId)) navId = [navId];
for (const id of navId) {
let contextMenuPatches = navPatches.get(id);
if (!contextMenuPatches) {
contextMenuPatches = new Set();
navPatches.set(id, contextMenuPatches);
}
contextMenuPatches.add(patch);
}
}
/**
* Add a global context menu patch that fires the patch for all context menus
* @param patch The patch to be applied
*/
export function addGlobalContextMenuPatch(patch: GlobalContextMenuPatchCallback) {
globalPatches.add(patch);
}
/**
* Remove a context menu patch
* @param navId The navId(s) for the context menu(s) to remove the patch
* @param patch The patch to be removed
* @returns Wheter the patch was sucessfully removed from the context menu(s)
*/
export function removeContextMenuPatch<T extends string | Array<string>>(navId: T, patch: NavContextMenuPatchCallback): T extends string ? boolean : Array<boolean> {
const navIds = Array.isArray(navId) ? navId : [navId as string];
const results = navIds.map(id => navPatches.get(id)?.delete(patch) ?? false);
return (Array.isArray(navId) ? results : results[0]) as T extends string ? boolean : Array<boolean>;
}
/**
* Remove a global context menu patch
* @param patch The patch to be removed
* @returns Wheter the patch was sucessfully removed
*/
export function removeGlobalContextMenuPatch(patch: GlobalContextMenuPatchCallback): boolean {
return globalPatches.delete(patch);
}
/**
* A helper function for finding the children array of a group nested inside a context menu based on the id of one of its childs
* @param id The id of the child
* @param children The context menu children
*/
export function findGroupChildrenByChildId(id: string, children: Array<React.ReactElement>, _itemsArray?: Array<React.ReactElement>): Array<React.ReactElement> | null {
for (const child of children) {
if (child == null) continue;
if (child.props?.id === id) return _itemsArray ?? null;
let nextChildren = child.props?.children;
if (nextChildren) {
if (!Array.isArray(nextChildren)) {
nextChildren = [nextChildren];
child.props.children = nextChildren;
}
const found = findGroupChildrenByChildId(id, nextChildren, nextChildren);
if (found !== null) return found;
}
}
return null;
}
interface ContextMenuProps {
contextMenuApiArguments?: Array<any>;
navId: string;
children: Array<ReactElement>;
"aria-label": string;
onSelect: (() => void) | undefined;
onClose: (callback: (...args: Array<any>) => any) => void;
}
const patchedMenus = new WeakSet();
export function _patchContextMenu(props: ContextMenuProps) {
props.contextMenuApiArguments ??= [];
const contextMenuPatches = navPatches.get(props.navId);
if (!Array.isArray(props.children)) props.children = [props.children];
if (contextMenuPatches) {
for (const patch of contextMenuPatches) {
try {
const callback = patch(props.children, ...props.contextMenuApiArguments);
if (!patchedMenus.has(props)) callback?.();
} catch (err) {
ContextMenuLogger.error(`Patch for ${props.navId} errored,`, err);
}
}
}
for (const patch of globalPatches) {
try {
const callback = patch(props.navId, props.children, ...props.contextMenuApiArguments);
if (!patchedMenus.has(props)) callback?.();
} catch (err) {
ContextMenuLogger.error("Global patch errored,", err);
}
}
patchedMenus.add(props);
}

View File

@ -19,6 +19,7 @@
import Logger from "@utils/Logger";
import { MessageStore } from "@webpack/common";
import type { Channel, Message } from "discord-types/general";
import type { Promisable } from "type-fest";
const MessageEventsLogger = new Logger("MessageEvents", "#e5c890");
@ -41,16 +42,16 @@ export interface MessageExtra {
stickerIds?: string[];
}
export type SendListener = (channelId: string, messageObj: MessageObject, extra: MessageExtra) => void | { cancel: boolean; };
export type EditListener = (channelId: string, messageId: string, messageObj: MessageObject) => void;
export type SendListener = (channelId: string, messageObj: MessageObject, extra: MessageExtra) => Promisable<void | { cancel: boolean; }>;
export type EditListener = (channelId: string, messageId: string, messageObj: MessageObject) => Promisable<void>;
const sendListeners = new Set<SendListener>();
const editListeners = new Set<EditListener>();
export function _handlePreSend(channelId: string, messageObj: MessageObject, extra: MessageExtra) {
export async function _handlePreSend(channelId: string, messageObj: MessageObject, extra: MessageExtra) {
for (const listener of sendListeners) {
try {
const result = listener(channelId, messageObj, extra);
const result = await listener(channelId, messageObj, extra);
if (result && result.cancel === true) {
return true;
}
@ -61,10 +62,10 @@ export function _handlePreSend(channelId: string, messageObj: MessageObject, ext
return false;
}
export function _handlePreEdit(channelId: string, messageId: string, messageObj: MessageObject) {
export async function _handlePreEdit(channelId: string, messageId: string, messageObj: MessageObject) {
for (const listener of editListeners) {
try {
listener(channelId, messageId, messageObj);
await listener(channelId, messageId, messageObj);
} catch (e) {
MessageEventsLogger.error("MessageEditHandler: Listener encountered an unknown error\n", e);
}

View File

@ -20,7 +20,8 @@ import "./styles.css";
import { useSettings } from "@api/settings";
import ErrorBoundary from "@components/ErrorBoundary";
import { Forms, React, useEffect, useMemo, useState, useStateFromStores, WindowStore } from "@webpack/common";
import { classes } from "@utils/misc";
import { React, useEffect, useMemo, useState, useStateFromStores, WindowStore } from "@webpack/common";
import { NotificationData } from "./Notifications";
@ -32,8 +33,11 @@ export default ErrorBoundary.wrap(function NotificationComponent({
icon,
onClick,
onClose,
image
}: NotificationData) {
image,
permanent,
className,
dismissOnClick
}: NotificationData & { className?: string; }) {
const { timeout, position } = useSettings(["notifications.timeout", "notifications.position"]).notifications;
const hasFocus = useStateFromStores([WindowStore], () => WindowStore.isFocused());
@ -43,7 +47,7 @@ export default ErrorBoundary.wrap(function NotificationComponent({
const start = useMemo(() => Date.now(), [timeout, isHover, hasFocus]);
useEffect(() => {
if (isHover || !hasFocus || timeout === 0) return void setElapsed(0);
if (isHover || !hasFocus || timeout === 0 || permanent) return void setElapsed(0);
const intervalId = setInterval(() => {
const elapsed = Date.now() - start;
@ -60,9 +64,13 @@ export default ErrorBoundary.wrap(function NotificationComponent({
return (
<button
className="vc-notification-root"
className={classes("vc-notification-root", className)}
style={position === "bottom-right" ? { bottom: "1rem" } : { top: "3rem" }}
onClick={onClick}
onClick={() => {
onClick?.();
if (dismissOnClick !== false)
onClose!();
}}
onContextMenu={e => {
e.preventDefault();
e.stopPropagation();
@ -74,14 +82,35 @@ export default ErrorBoundary.wrap(function NotificationComponent({
<div className="vc-notification">
{icon && <img className="vc-notification-icon" src={icon} alt="" />}
<div className="vc-notification-content">
<Forms.FormTitle tag="h2">{title}</Forms.FormTitle>
<div className="vc-notification-header">
<h2 className="vc-notification-title">{title}</h2>
<button
className="vc-notification-close-btn"
onClick={e => {
e.preventDefault();
e.stopPropagation();
onClose!();
}}
>
<svg
width="24"
height="24"
viewBox="0 0 24 24"
role="img"
aria-labelledby="vc-notification-dismiss-title"
>
<title id="vc-notification-dismiss-title">Dismiss Notification</title>
<path fill="currentColor" d="M18.4 4L12 10.4L5.6 4L4 5.6L10.4 12L4 18.4L5.6 20L12 13.6L18.4 20L20 18.4L13.6 12L20 5.6L18.4 4Z" />
</svg>
</button>
</div>
<div>
{richBody ?? <p className="vc-notification-p">{body}</p>}
</div>
</div>
</div>
{image && <img className="vc-notification-img" src={image} alt="" />}
{timeout !== 0 && (
{timeout !== 0 && !permanent && (
<div
className="vc-notification-progressbar"
style={{ width: `${(1 - timeoutProgress) * 100}%`, backgroundColor: color || "var(--brand-experiment)" }}
@ -89,4 +118,6 @@ export default ErrorBoundary.wrap(function NotificationComponent({
)}
</button>
);
}, {
onError: ({ props }) => props.onClose!()
});

View File

@ -23,6 +23,7 @@ import type { ReactNode } from "react";
import type { Root } from "react-dom/client";
import NotificationComponent from "./NotificationComponent";
import { persistNotification } from "./notificationLog";
const NotificationQueue = new Queue();
@ -54,6 +55,12 @@ export interface NotificationData {
onClick?(): void;
onClose?(): void;
color?: string;
/** Whether this notification should not have a timeout */
permanent?: boolean;
/** Whether this notification should not be persisted in the Notification Log */
noPersist?: boolean;
/** Whether this notification should be dismissed when clicked (defaults to true) */
dismissOnClick?: boolean;
}
function _showNotification(notification: NotificationData, id: number) {
@ -70,6 +77,8 @@ function _showNotification(notification: NotificationData, id: number) {
}
function shouldBeNative() {
if (typeof Notification === "undefined") return false;
const { useNative } = Settings.notifications;
if (useNative === "always") return true;
if (useNative === "not-focused") return !document.hasFocus();
@ -84,6 +93,8 @@ export async function requestPermission() {
}
export async function showNotification(data: NotificationData) {
persistNotification(data);
if (shouldBeNative() && await requestPermission()) {
const { title, body, icon, image, onClick = null, onClose = null } = data;
const n = new Notification(title, {

View File

@ -0,0 +1,203 @@
/*
* Vencord, a modification for Discord's desktop app
* Copyright (c) 2023 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 * as DataStore from "@api/DataStore";
import { Settings } from "@api/settings";
import { classNameFactory } from "@api/Styles";
import { useAwaiter } from "@utils/misc";
import { closeModal, ModalCloseButton, ModalContent, ModalFooter, ModalHeader, ModalProps, ModalRoot, ModalSize, openModal } from "@utils/modal";
import { Alerts, Button, Forms, moment, React, Text, Timestamp, useEffect, useReducer, useState } from "@webpack/common";
import { nanoid } from "nanoid";
import type { DispatchWithoutAction } from "react";
import NotificationComponent from "./NotificationComponent";
import type { NotificationData } from "./Notifications";
interface PersistentNotificationData extends Pick<NotificationData, "title" | "body" | "image" | "icon" | "color"> {
timestamp: number;
id: string;
}
const KEY = "notification-log";
const getLog = async () => {
const log = await DataStore.get(KEY) as PersistentNotificationData[] | undefined;
return log ?? [];
};
const cl = classNameFactory("vc-notification-log-");
const signals = new Set<DispatchWithoutAction>();
export async function persistNotification(notification: NotificationData) {
if (notification.noPersist) return;
const limit = Settings.notifications.logLimit;
if (limit === 0) return;
await DataStore.update(KEY, (old: PersistentNotificationData[] | undefined) => {
const log = old ?? [];
// Omit stuff we don't need
const {
onClick, onClose, richBody, permanent, noPersist, dismissOnClick,
...pureNotification
} = notification;
log.unshift({
...pureNotification,
timestamp: Date.now(),
id: nanoid()
});
if (log.length > limit && limit !== 200)
log.length = limit;
return log;
});
signals.forEach(x => x());
}
export async function deleteNotification(timestamp: number) {
const log = await getLog();
const index = log.findIndex(x => x.timestamp === timestamp);
if (index === -1) return;
log.splice(index, 1);
await DataStore.set(KEY, log);
signals.forEach(x => x());
}
export function useLogs() {
const [signal, setSignal] = useReducer(x => x + 1, 0);
useEffect(() => {
signals.add(setSignal);
return () => void signals.delete(setSignal);
}, []);
const [log, _, pending] = useAwaiter(getLog, {
fallbackValue: [],
deps: [signal]
});
return [log, pending] as const;
}
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}>
<NotificationComponent
{...data}
permanent={true}
dismissOnClick={false}
onClose={() => {
if (removing) return;
setRemoving(true);
setTimeout(() => deleteNotification(data.timestamp), 200);
}}
richBody={
<div className={cl("body")}>
{data.body}
<Timestamp timestamp={moment(data.timestamp)} className={cl("timestamp")} />
</div>
}
/>
</div>
);
}
export function NotificationLog({ log, pending }: { log: PersistentNotificationData[], pending: boolean; }) {
if (!log.length && !pending)
return (
<div className={cl("container")}>
<div className={cl("empty")} />
<Forms.FormText style={{ textAlign: "center" }}>
No notifications yet
</Forms.FormText>
</div>
);
return (
<div className={cl("container")}>
{log.map(n => <NotificationEntry data={n} key={n.id} />)}
</div>
);
}
function LogModal({ modalProps, close }: { modalProps: ModalProps; close(): void; }) {
const [log, pending] = useLogs();
return (
<ModalRoot {...modalProps} size={ModalSize.LARGE}>
<ModalHeader>
<Text variant="heading-lg/semibold" style={{ flexGrow: 1 }}>Notification Log</Text>
<ModalCloseButton onClick={close} />
</ModalHeader>
<ModalContent>
<NotificationLog log={log} pending={pending} />
</ModalContent>
<ModalFooter>
<Button
disabled={log.length === 0}
onClick={() => {
Alerts.show({
title: "Are you sure?",
body: `This will permanently remove ${log.length} notification${log.length === 1 ? "" : "s"}. This action cannot be undone.`,
async onConfirm() {
await DataStore.set(KEY, []);
signals.forEach(x => x());
},
confirmText: "Do it!",
confirmColor: "vc-notification-log-danger-btn",
cancelText: "Nevermind"
});
}}
>
Clear Notification Log
</Button>
</ModalFooter>
</ModalRoot>
);
}
export function openNotificationLogModal() {
const key = openModal(modalProps => (
<LogModal
modalProps={modalProps}
close={() => closeModal(key)}
/>
));
}

View File

@ -3,16 +3,20 @@
all: unset;
display: flex;
flex-direction: column;
width: 25vw;
min-height: 10vh;
color: var(--text-normal);
background-color: var(--background-secondary-alt);
position: absolute;
z-index: 2147483647;
right: 1rem;
border-radius: 6px;
overflow: hidden;
cursor: pointer;
width: 100%;
}
.vc-notification-root:not(.vc-notification-log-wrapper > .vc-notification-root) {
position: absolute;
z-index: 2147483647;
right: 1rem;
width: 25vw;
min-height: 10vh;
}
.vc-notification {
@ -22,17 +26,42 @@
gap: 1.25rem;
}
.vc-notification-content {
width: 100%;
}
.vc-notification-header {
display: flex;
justify-content: space-between;
}
.vc-notification-title {
color: var(--header-primary);
font-size: 1rem;
font-weight: 600;
line-height: 1.25rem;
text-transform: uppercase;
}
.vc-notification-close-btn {
all: unset;
cursor: pointer;
color: var(--interactive-normal);
opacity: 0.5;
transition: opacity 0.2s ease-in-out, color 0.2s ease-in-out;
}
.vc-notification-close-btn:hover {
color: var(--interactive-hover);
opacity: 1;
}
.vc-notification-icon {
height: 4rem;
width: 4rem;
border-radius: 6px;
}
/* Discord adding 3km margin to generic tags */
.vc-notification h2 {
margin: unset;
}
.vc-notification-progressbar {
height: 0.25rem;
border-radius: 5px;
@ -47,3 +76,47 @@
.vc-notification-img {
width: 100%;
}
.vc-notification-log-empty {
height: 218px;
background: url("/assets/b36de980b174d7b798c89f35c116e5c6.svg") center no-repeat;
margin-bottom: 40px;
}
.vc-notification-log-container {
display: flex;
flex-direction: column;
padding: 1em;
overflow: hidden;
}
.vc-notification-log-wrapper {
transition: 200ms ease;
transition-property: height, opacity;
}
.vc-notification-log-wrapper:not(:last-child) {
margin-bottom: 1em;
}
.vc-notification-log-removing {
height: 0 !important;
opacity: 0;
margin-bottom: 1em;
}
.vc-notification-log-body {
display: flex;
flex-direction: column;
}
.vc-notification-log-timestamp {
margin-left: auto;
font-size: 0.8em;
font-weight: lighter;
}
.vc-notification-log-danger-btn {
color: var(--white-500);
background-color: var(--button-danger-background);
}

69
src/api/SettingsStore.ts Normal file
View File

@ -0,0 +1,69 @@
/*
* Vencord, a modification for Discord's desktop app
* Copyright (c) 2023 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 Logger from "@utils/Logger";
import { proxyLazy } from "@utils/proxyLazy";
import { findModuleId, wreq } from "@webpack";
import { Settings } from "./settings";
interface Setting<T> {
/**
* Get the setting value
*/
getSetting(): T;
/**
* Update the setting value
* @param value The new value
*/
updateSetting(value: T | ((old: T) => T)): Promise<void>;
/**
* React hook for automatically updating components when the setting is updated
*/
useSetting(): T;
settingsStoreApiGroup: string;
settingsStoreApiName: string;
}
const SettingsStores: Array<Setting<any>> | undefined = proxyLazy(() => {
const modId = findModuleId('"textAndImages","renderSpoilers"');
if (modId == null) return new Logger("SettingsStoreAPI").error("Didn't find stores module.");
const mod = wreq(modId);
if (mod == null) return;
return Object.values(mod).filter((s: any) => s?.settingsStoreApiGroup) as any;
});
/**
* Get the store for a setting
* @param group The setting group
* @param name The name of the setting
*/
export function getSettingStore<T = any>(group: string, name: string): Setting<T> | undefined {
if (!Settings.plugins.SettingsStoreAPI.enabled) throw new Error("Cannot use SettingsStoreAPI without setting as dependency.");
return SettingsStores?.find(s => s?.settingsStoreApiGroup === group && s?.settingsStoreApiName === name);
}
/**
* getSettingStore but lazy
*/
export function getSettingStoreLazy<T = any>(group: string, name: string) {
return proxyLazy(() => getSettingStore<T>(group, name));
}

View File

@ -18,6 +18,7 @@
import * as $Badges from "./Badges";
import * as $Commands from "./Commands";
import * as $ContextMenu from "./ContextMenu";
import * as $DataStore from "./DataStore";
import * as $MemberListDecorators from "./MemberListDecorators";
import * as $MessageAccessories from "./MessageAccessories";
@ -27,6 +28,8 @@ import * as $MessagePopover from "./MessagePopover";
import * as $Notices from "./Notices";
import * as $Notifications from "./Notifications";
import * as $ServerList from "./ServerList";
import * as $Settings from "./settings";
import * as $SettingsStore from "./SettingsStore";
import * as $Styles from "./Styles";
/**
@ -84,6 +87,10 @@ export const MessageDecorations = $MessageDecorations;
* An API allowing you to add components to member list users, in both DM's and servers
*/
export const MemberListDecorators = $MemberListDecorators;
/**
* An API allowing you to read, manipulate and automatically update components based on Discord settings
*/
export const SettingsStore = $SettingsStore;
/**
* An API allowing you to dynamically load styles
* a
@ -93,3 +100,14 @@ export const Styles = $Styles;
* An API allowing you to display notifications
*/
export const Notifications = $Notifications;
/**
* An api allowing you to patch and add/remove items to/from context menus
*/
export const ContextMenu = $ContextMenu;
/**
* Settings lol
*/
export const Settings = $Settings;
export const settings = $Settings;

View File

@ -16,9 +16,12 @@
* along with this program. If not, see <https://www.gnu.org/licenses/>.
*/
import { debounce } from "@utils/debounce";
import IpcEvents from "@utils/IpcEvents";
import { localStorage } from "@utils/localStorage";
import Logger from "@utils/Logger";
import { mergeDefaults } from "@utils/misc";
import { putCloudSettings } from "@utils/settingsSync";
import { DefinedSettings, OptionType, SettingsChecks, SettingsDefinition } from "@utils/types";
import { React } from "@webpack/common";
@ -28,12 +31,16 @@ const logger = new Logger("Settings");
export interface Settings {
notifyAboutUpdates: boolean;
autoUpdate: boolean;
autoUpdateNotification: boolean,
useQuickCss: boolean;
enableReactDevtools: boolean;
themeLinks: string[];
frameless: boolean;
transparent: boolean;
winCtrlQ: boolean;
macosTranslucency: boolean;
disableMinSize: boolean;
winNativeTitleBar: boolean;
plugins: {
[plugin: string]: {
enabled: boolean;
@ -45,24 +52,44 @@ export interface Settings {
timeout: number;
position: "top-right" | "bottom-right";
useNative: "always" | "never" | "not-focused";
logLimit: number;
};
cloud: {
authenticated: boolean;
url: string;
settingsSync: boolean;
settingsSyncVersion: number;
};
}
const DefaultSettings: Settings = {
notifyAboutUpdates: true,
autoUpdate: false,
autoUpdateNotification: true,
useQuickCss: true,
themeLinks: [],
enableReactDevtools: false,
frameless: false,
transparent: false,
winCtrlQ: false,
macosTranslucency: false,
disableMinSize: false,
winNativeTitleBar: false,
plugins: {},
notifications: {
timeout: 5000,
position: "bottom-right",
useNative: "not-focused"
useNative: "not-focused",
logLimit: 50
},
cloud: {
authenticated: false,
url: "https://api.vencord.dev/",
settingsSync: false,
settingsSyncVersion: 0
}
};
@ -74,6 +101,13 @@ try {
logger.error("An error occurred while loading the settings. Corrupt settings file?\n", err);
}
const saveSettingsOnFrequentAction = debounce(async () => {
if (Settings.cloud.settingsSync && Settings.cloud.authenticated) {
await putCloudSettings();
delete localStorage.Vencord_settingsDirty;
}
}, 60_000);
type SubscriptionCallback = ((newValue: any, path: string) => void) & { _path?: string; };
const subscriptions = new Set<SubscriptionCallback>();
@ -90,7 +124,7 @@ function makeProxy(settings: any, root = settings, path = ""): Settings {
// Return empty for plugins with no settings
if (path === "plugins" && p in plugins)
return target[p] = makeProxy({
enabled: plugins[p].required ?? false
enabled: plugins[p].required ?? plugins[p].enabledByDefault ?? false
}, root, `plugins.${p}`);
// Since the property is not set, check if this is a plugin's setting and if so, try to resolve
@ -129,12 +163,16 @@ function makeProxy(settings: any, root = settings, path = ""): Settings {
target[p] = v;
// Call any listeners that are listening to a setting of this path
const setPath = `${path}${path && "."}${p}`;
delete proxyCache[setPath];
for (const subscription of subscriptions) {
if (!subscription._path || subscription._path === setPath) {
subscription(v, setPath);
}
}
// And don't forget to persist the settings!
PlainSettings.cloud.settingsSyncVersion = Date.now();
localStorage.Vencord_settingsDirty = true;
saveSettingsOnFrequentAction();
VencordNative.ipc.invoke(IpcEvents.SET_SETTINGS, JSON.stringify(root, null, 4));
return true;
}
@ -165,11 +203,11 @@ export const Settings = makeProxy(settings);
* @returns Settings
*/
// TODO: Representing paths as essentially "string[].join('.')" wont allow dots in paths, change to "paths?: string[][]" later
export function useSettings(paths?: string[]) {
export function useSettings(paths?: UseSettings<Settings>[]) {
const [, forceUpdate] = React.useReducer(() => ({}), {});
const onUpdate: SubscriptionCallback = paths
? (value, path) => paths.includes(path) && forceUpdate()
? (value, path) => paths.includes(path as UseSettings<Settings>) && forceUpdate()
: forceUpdate;
React.useEffect(() => {
@ -227,7 +265,7 @@ export function definePluginSettings<D extends SettingsDefinition, C extends Set
return Settings.plugins[definedSettings.pluginName] as any;
},
use: settings => useSettings(
settings?.map(name => `plugins.${definedSettings.pluginName}.${name}`)
settings?.map(name => `plugins.${definedSettings.pluginName}.${name}`) as UseSettings<Settings>[]
).plugins[definedSettings.pluginName] as any,
def,
checks: checks ?? {},
@ -235,3 +273,15 @@ export function definePluginSettings<D extends SettingsDefinition, C extends Set
};
return definedSettings;
}
type UseSettings<T extends object> = ResolveUseSettings<T>[keyof T];
type ResolveUseSettings<T extends object> = {
[Key in keyof T]:
Key extends string
? T[Key] extends Record<string, unknown>
// @ts-ignore "Type instantiation is excessively deep and possibly infinite"
? UseSettings<T[Key]> extends string ? `${Key}.${UseSettings<T[Key]>}` : never
: Key
: never;
};

View File

@ -23,15 +23,18 @@ import { React } from "@webpack/common";
import { ErrorCard } from "./ErrorCard";
interface Props {
interface Props<T = any> {
/** Render nothing if an error occurs */
noop?: boolean;
/** Fallback component to render if an error occurs */
fallback?: React.ComponentType<React.PropsWithChildren<{ error: any; message: string; stack: string; }>>;
/** called when an error occurs */
onError?(error: Error, errorInfo: React.ErrorInfo): void;
/** called when an error occurs. The props property is only available if using .wrap */
onError?(data: { error: Error, errorInfo: React.ErrorInfo, props: T; }): void;
/** Custom error message */
message?: string;
/** The props passed to the wrapped component. Only used by wrap */
wrappedProps?: T;
}
const color = "#e78284";
@ -66,7 +69,7 @@ const ErrorBoundary = LazyComponent(() => {
}
componentDidCatch(error: Error, errorInfo: React.ErrorInfo) {
this.props.onError?.(error, errorInfo);
this.props.onError?.({ error, errorInfo, props: this.props.wrappedProps });
logger.error("A component threw an Error\n", error);
logger.error("Component Stack", errorInfo.componentStack);
}
@ -102,11 +105,11 @@ const ErrorBoundary = LazyComponent(() => {
};
}) as
React.ComponentType<React.PropsWithChildren<Props>> & {
wrap<T extends object = any>(Component: React.ComponentType<T>, errorBoundaryProps?: Props): React.ComponentType<T>;
wrap<T extends object = any>(Component: React.ComponentType<T>, errorBoundaryProps?: Omit<Props<T>, "wrappedProps">): React.FunctionComponent<T>;
};
ErrorBoundary.wrap = (Component, errorBoundaryProps) => props => (
<ErrorBoundary {...errorBoundaryProps}>
<ErrorBoundary {...errorBoundaryProps} wrappedProps={props}>
<Component {...props} />
</ErrorBoundary>
);

View File

@ -17,10 +17,12 @@
*/
import { debounce } from "@utils/debounce";
import { Margins } from "@utils/margins";
import { makeCodeblock } from "@utils/misc";
import { canonicalizeMatch, canonicalizeReplace, ReplaceFn } from "@utils/patches";
import { canonicalizeMatch, canonicalizeReplace } from "@utils/patches";
import { ReplaceFn } from "@utils/types";
import { search } from "@webpack";
import { Button, Clipboard, Forms, Margins, Parser, React, Switch, Text, TextInput } from "@webpack/common";
import { Button, Clipboard, Forms, Parser, React, Switch, Text, TextInput } from "@webpack/common";
import { CheckedTextInput } from "./CheckedTextInput";
import ErrorBoundary from "./ErrorBoundary";
@ -128,7 +130,7 @@ function ReplacementComponent({ module, match, replacement, setReplacementError
)}
{!!diff?.length && (
<Button className={Margins.marginTop20} onClick={() => {
<Button className={Margins.top20} onClick={() => {
try {
Function(patchedCode.replace(/^function\(/, "function patchedModule("));
setCompileResult([true, "Compiled successfully"]);
@ -184,9 +186,10 @@ function ReplacementInput({ replacement, setReplacement, replacementError }) {
error={error ?? replacementError}
/>
{!isFunc && (
<>
<div className="vc-text-selectable">
<Forms.FormTitle>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",
@ -198,11 +201,11 @@ function ReplacementInput({ replacement, setReplacement, replacementError }) {
{Parser.parse("`" + placeholder + "`")}: {desc}
</Forms.FormText>
))}
</>
</div>
)}
<Switch
className={Margins.marginTop8}
className={Margins.top8}
value={isFunc}
onChange={setIsFunc}
note="'replacement' will be evaled if this is toggled"
@ -256,7 +259,7 @@ function PatchHelper() {
return (
<Forms.FormSection>
<Text variant="heading-md/normal" tag="h2" className={Margins.marginBottom8}>Patch Helper</Text>
<Text variant="heading-md/normal" tag="h2" className={Margins.bottom8}>Patch Helper</Text>
<Forms.FormTitle>find</Forms.FormTitle>
<TextInput
type="text"
@ -296,7 +299,7 @@ function PatchHelper() {
{!!(find && match && replacement) && (
<>
<Forms.FormTitle className={Margins.marginTop20}>Code</Forms.FormTitle>
<Forms.FormTitle className={Margins.top20}>Code</Forms.FormTitle>
<div style={{ userSelect: "text" }}>{Parser.parse(makeCodeblock(code, "ts"))}</div>
<Button onClick={() => Clipboard.copy(code)}>Copy to Clipboard</Button>
</>

View File

@ -20,7 +20,8 @@ import { generateId } from "@api/Commands";
import { useSettings } from "@api/settings";
import ErrorBoundary from "@components/ErrorBoundary";
import { Flex } from "@components/Flex";
import { LazyComponent } from "@utils/misc";
import { Margins } from "@utils/margins";
import { classes, LazyComponent } from "@utils/misc";
import { ModalCloseButton, ModalContent, ModalFooter, ModalHeader, ModalProps, ModalRoot, ModalSize } from "@utils/modal";
import { proxyLazy } from "@utils/proxyLazy";
import { OptionType, Plugin } from "@utils/types";
@ -174,7 +175,7 @@ export default function PluginModal({ plugin, onRestartNeeded, onClose, transiti
}
return (
<ModalRoot transitionState={transitionState} size={ModalSize.MEDIUM}>
<ModalRoot transitionState={transitionState} size={ModalSize.MEDIUM} className="vc-text-selectable">
<ModalHeader separator={false}>
<Text variant="heading-lg/semibold" style={{ flexGrow: 1 }}>{plugin.name}</Text>
<ModalCloseButton onClick={onClose} />
@ -198,7 +199,7 @@ export default function PluginModal({ plugin, onRestartNeeded, onClose, transiti
</div>
</Forms.FormSection>
{!!plugin.settingsAboutComponent && (
<div style={{ marginBottom: 8 }}>
<div className={classes(Margins.bottom8, "vc-text-selectable")}>
<Forms.FormSection>
<ErrorBoundary message="An error occurred while rendering this plugin's custom InfoComponent">
<plugin.settingsAboutComponent tempSettings={tempSettings} />

View File

@ -38,9 +38,12 @@ export function SettingNumericComponent({ option, pluginSettings, definedSetting
function handleChange(newValue) {
const isValid = option.isValid?.call(definedSettings, newValue) ?? true;
setError(null);
if (typeof isValid === "string") setError(isValid);
else if (!isValid) setError("Invalid input provided.");
else if (option.type === OptionType.NUMBER && BigInt(newValue) >= MAX_SAFE_NUMBER) {
if (option.type === OptionType.NUMBER && BigInt(newValue) >= MAX_SAFE_NUMBER) {
setState(`${Number.MAX_SAFE_INTEGER}`);
onChange(serialize(newValue));
} else {

View File

@ -36,6 +36,7 @@ export function SettingSelectComponent({ option, pluginSettings, definedSettings
if (typeof isValid === "string") setError(isValid);
else if (!isValid) setError("Invalid input provided.");
else {
setError(null);
setState(newValue);
onChange(newValue);
}

View File

@ -34,6 +34,7 @@ export function SettingTextComponent({ option, pluginSettings, definedSettings,
if (typeof isValid === "string") setError(isValid);
else if (!isValid) setError("Invalid input provided.");
else {
setError(null);
setState(newValue);
onChange(newValue);
}

View File

@ -30,11 +30,12 @@ import PluginModal from "@components/PluginSettings/PluginModal";
import { Switch } from "@components/Switch";
import { ChangeList } from "@utils/ChangeList";
import Logger from "@utils/Logger";
import { Margins } from "@utils/margins";
import { classes, LazyComponent, useAwaiter } from "@utils/misc";
import { openModalLazy } from "@utils/modal";
import { Plugin } from "@utils/types";
import { findByCode, findByPropsLazy } from "@webpack";
import { Alerts, Button, Card, Forms, Margins, Parser, React, Select, Text, TextInput, Toasts, Tooltip } from "@webpack/common";
import { Alerts, Button, Card, Forms, Parser, React, Select, Text, TextInput, Toasts, Tooltip } from "@webpack/common";
import Plugins from "~plugins";
@ -45,6 +46,7 @@ const cl = classNameFactory("vc-plugins-");
const logger = new Logger("PluginSettings", "#a6d189");
const InputStyles = findByPropsLazy("inputDefault", "inputWrapper");
const ButtonClasses = findByPropsLazy("button", "disabled", "enabled");
const CogWheel = LazyComponent(() => findByCode("18.564C15.797 19.099 14.932 19.498 14 19.738V22H10V19.738C9.069"));
const InfoIcon = LazyComponent(() => findByCode("4.4408921e-16 C4.4771525,-1.77635684e-15 4.4408921e-16"));
@ -92,7 +94,7 @@ interface PluginCardProps extends React.HTMLProps<HTMLDivElement> {
}
function PluginCard({ plugin, disabled, onRestartNeeded, onMouseEnter, onMouseLeave, isNew }: PluginCardProps) {
const settings = useSettings([`plugins.${plugin.name}`]).plugins[plugin.name];
const settings = useSettings([`plugins.${plugin.name}.enabled`]).plugins[plugin.name];
const isEnabled = () => settings.enabled ?? false;
@ -153,7 +155,7 @@ function PluginCard({ plugin, disabled, onRestartNeeded, onMouseEnter, onMouseLe
<Text variant="text-md/bold" className={cl("name")}>
{plugin.name}{isNew && <Badge text="NEW" color="#ED4245" />}
</Text>
<button role="switch" onClick={() => openModal()} className={classes("button-12Fmur", cl("info-button"))}>
<button role="switch" onClick={() => openModal()} className={classes(ButtonClasses.button, cl("info-button"))}>
{plugin.options
? <CogWheel />
: <InfoIcon width="24" height="24" />}
@ -296,15 +298,15 @@ export default ErrorBoundary.wrap(function PluginSettings() {
}
return (
<Forms.FormSection className={Margins.marginTop16}>
<Forms.FormSection className={Margins.top16}>
<ReloadRequiredCard required={changes.hasChanges} />
<Forms.FormTitle tag="h5" className={classes(Margins.marginTop20, Margins.marginBottom8)}>
<Forms.FormTitle tag="h5" className={classes(Margins.top20, Margins.bottom8)}>
Filters
</Forms.FormTitle>
<div className={cl("filter-controls")}>
<TextInput autoFocus value={searchValue.value} placeholder="Search for a plugin..." onChange={onSearch} className={Margins.marginBottom20} />
<TextInput autoFocus value={searchValue.value} placeholder="Search for a plugin..." onChange={onSearch} className={Margins.bottom20} />
<div className={InputStyles.inputWrapper}>
<Select
className={InputStyles.inputDefault}
@ -321,15 +323,15 @@ export default ErrorBoundary.wrap(function PluginSettings() {
</div>
</div>
<Forms.FormTitle className={Margins.marginTop20}>Plugins</Forms.FormTitle>
<Forms.FormTitle className={Margins.top20}>Plugins</Forms.FormTitle>
<div className={cl("grid")}>
{plugins}
</div>
<Forms.FormDivider className={Margins.marginTop20} />
<Forms.FormDivider className={Margins.top20} />
<Forms.FormTitle tag="h5" className={classes(Margins.marginTop20, Margins.marginBottom8)}>
<Forms.FormTitle tag="h5" className={classes(Margins.top20, Margins.bottom8)}>
Required Plugins
</Forms.FormTitle>
<div className={cl("grid")}>

View File

@ -18,6 +18,7 @@
import "./Switch.css";
import { classes } from "@utils/misc";
import { findByPropsLazy } from "@webpack";
interface SwitchProps {
@ -33,7 +34,7 @@ const SwitchClasses = findByPropsLazy("slider", "input", "container");
export function Switch({ checked, onChange, disabled }: SwitchProps) {
return (
<div>
<div className={`${SwitchClasses.container} default-colors`} style={{
<div className={classes(SwitchClasses.container, "default-colors", checked ? SwitchClasses.checked : void 0)} style={{
backgroundColor: checked ? SWITCH_ON : SWITCH_OFF,
opacity: disabled ? 0.3 : 1
}}>

View File

@ -18,28 +18,30 @@
import ErrorBoundary from "@components/ErrorBoundary";
import { Flex } from "@components/Flex";
import { Margins } from "@utils/margins";
import { classes } from "@utils/misc";
import { downloadSettingsBackup, uploadSettingsBackup } from "@utils/settingsSync";
import { Button, Card, Forms, Margins, Text } from "@webpack/common";
import { Button, Card, Forms, Text } from "@webpack/common";
function BackupRestoreTab() {
return (
<Forms.FormSection title="Settings Sync" className={Margins.marginTop16}>
<Forms.FormSection title="Settings Sync" className={Margins.top16}>
<Card className={classes("vc-settings-card", "vc-backup-restore-card")}>
<Flex flexDirection="column">
<strong>Warning</strong>
<span>Importing a settings file will overwrite your current settings.</span>
</Flex>
</Card>
<Text variant="text-md/normal" className={Margins.marginBottom8}>
<Text variant="text-md/normal" className={Margins.bottom8}>
You can import and export your Vencord settings as a JSON file.
This allows you to easily transfer your settings to another device,
or recover your settings after reinstalling Vencord or Discord.
</Text>
<Text variant="text-md/normal" className={Margins.marginBottom8}>
<Text variant="text-md/normal" className={Margins.bottom8}>
Settings Export contains:
<ul>
<li>&mdash; Custom QuickCSS</li>
<li>&mdash; Theme Links</li>
<li>&mdash; Plugin Settings</li>
</ul>
</Text>

View File

@ -0,0 +1,164 @@
/*
* Vencord, a modification for Discord's desktop app
* Copyright (c) 2023 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 { showNotification } from "@api/Notifications";
import { Settings, useSettings } from "@api/settings";
import { CheckedTextInput } from "@components/CheckedTextInput";
import ErrorBoundary from "@components/ErrorBoundary";
import { Link } from "@components/Link";
import { authorizeCloud, 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";
function validateUrl(url: string) {
try {
new URL(url);
return true;
} catch {
return "Invalid URL";
}
}
async function eraseAllData() {
const res = await fetch(new URL("/v1/", getCloudUrl()), {
method: "DELETE",
headers: new Headers({
Authorization: await getCloudAuth()
})
});
if (!res.ok) {
cloudLogger.error(`Failed to erase data, API returned ${res.status}`);
showNotification({
title: "Cloud Integrations",
body: `Could not erase all data (API returned ${res.status}), please contact support.`,
color: "var(--red-360)"
});
return;
}
Settings.cloud.authenticated = false;
await deauthorizeCloud();
showNotification({
title: "Cloud Integrations",
body: "Successfully erased all data.",
color: "var(--green-360)"
});
}
function SettingsSyncSection() {
const { cloud } = useSettings(["cloud.authenticated", "cloud.settingsSync"]);
const sectionEnabled = cloud.authenticated && cloud.settingsSync;
return (
<Forms.FormSection title="Settings Sync" className={Margins.top16}>
<Forms.FormText variant="text-md/normal" className={Margins.bottom20}>
Synchronize your settings to the cloud. This allows easy synchronization across multiple devices with
minimal effort.
</Forms.FormText>
<Switch
key="cloud-sync"
disabled={!cloud.authenticated}
value={cloud.settingsSync}
onChange={v => { cloud.settingsSync = v; }}
>
Settings Sync
</Switch>
<div className="vc-cloud-settings-sync-grid">
<Button
size={Button.Sizes.SMALL}
disabled={!sectionEnabled}
onClick={() => putCloudSettings()}
>Sync to Cloud</Button>
<Tooltip text="This will overwrite your local settings with the ones on the cloud. Use wisely!">
{({ onMouseLeave, onMouseEnter }) => (
<Button
onMouseLeave={onMouseLeave}
onMouseEnter={onMouseEnter}
size={Button.Sizes.SMALL}
color={Button.Colors.RED}
disabled={!sectionEnabled}
onClick={() => getCloudSettings(true, true)}
>Sync from Cloud</Button>
)}
</Tooltip>
<Button
size={Button.Sizes.SMALL}
color={Button.Colors.RED}
disabled={!sectionEnabled}
onClick={() => deleteCloudSettings()}
>Delete Cloud Settings</Button>
</div>
</Forms.FormSection>
);
}
function CloudTab() {
const settings = useSettings(["cloud.authenticated", "cloud.url"]);
return (
<>
<Forms.FormSection title="Cloud Settings" className={Margins.top16}>
<Forms.FormText variant="text-md/normal" className={Margins.bottom20}>
Vencord comes with a cloud integration that adds goodies like settings sync across devices.
It <Link href="https://vencord.dev/cloud/privacy">respects your privacy</Link>, and
the <Link href="https://github.com/Vencord/Backend">source code</Link> is AGPL 3.0 licensed so you
can host it yourself.
</Forms.FormText>
<Switch
key="backend"
value={settings.cloud.authenticated}
onChange={v => { v && authorizeCloud(); if (!v) settings.cloud.authenticated = v; }}
note="This will request authorization if you have not yet set up cloud integrations."
>
Enable Cloud Integrations
</Switch>
<Forms.FormTitle tag="h5">Backend URL</Forms.FormTitle>
<Forms.FormText className={Margins.bottom8}>
Which backend to use when using cloud integrations.
</Forms.FormText>
<CheckedTextInput
key="backendUrl"
value={settings.cloud.url}
onChange={v => { settings.cloud.url = v; settings.cloud.authenticated = false; deauthorizeCloud(); }}
validate={validateUrl}
/>
<Button
className={Margins.top8}
size={Button.Sizes.MEDIUM}
color={Button.Colors.RED}
disabled={!settings.cloud.authenticated}
onClick={() => Alerts.show({
title: "Are you sure?",
body: "Once your data is erased, we cannot recover it. There's no going back!",
onConfirm: eraseAllData,
confirmText: "Erase it!",
confirmColor: "vc-cloud-erase-data-danger-btn",
cancelText: "Nevermind"
})}
>Erase All Data</Button>
<Forms.FormDivider className={Margins.top16} />
</Forms.FormSection >
<SettingsSyncSection />
</>
);
}
export default ErrorBoundary.wrap(CloudTab);

View File

@ -19,9 +19,10 @@
import { useSettings } from "@api/settings";
import ErrorBoundary from "@components/ErrorBoundary";
import { Link } from "@components/Link";
import { Margins } from "@utils/margins";
import { useAwaiter } from "@utils/misc";
import { findLazy } from "@webpack";
import { Card, Forms, Margins, React, TextArea } from "@webpack/common";
import { Card, Forms, React, TextArea } from "@webpack/common";
const TextAreaProps = findLazy(m => typeof m.textarea === "string");
@ -51,7 +52,7 @@ function Validators({ themeLinks }: { themeLinks: string[]; }) {
return (
<>
<Forms.FormTitle className={Margins.marginTop20} tag="h5">Validator</Forms.FormTitle>
<Forms.FormTitle className={Margins.top20} tag="h5">Validator</Forms.FormTitle>
<Forms.FormText>This section will tell you whether your themes can successfully be loaded</Forms.FormText>
<div>
{themeLinks.map(link => (
@ -89,11 +90,11 @@ export default ErrorBoundary.wrap(function () {
return (
<>
<Card className="vc-settings-card">
<Forms.FormTitle tag="h5">Paste links to .css / .theme.css files here</Forms.FormTitle>
<Card className="vc-settings-card vc-text-selectable">
<Forms.FormTitle tag="h5">Paste links to .theme.css files here</Forms.FormTitle>
<Forms.FormText>One link per line</Forms.FormText>
<Forms.FormText>Make sure to use the raw links or github.io links!</Forms.FormText>
<Forms.FormDivider className={Margins.marginTop8 + " " + Margins.marginBottom8} />
<Forms.FormDivider className={Margins.top8 + " " + Margins.bottom8} />
<Forms.FormTitle tag="h5">Find Themes:</Forms.FormTitle>
<div style={{ marginBottom: ".5em" }}>
<Link style={{ marginRight: ".5em" }} href="https://betterdiscord.app/themes">
@ -102,7 +103,7 @@ export default ErrorBoundary.wrap(function () {
<Link href="https://github.com/search?q=discord+theme">GitHub</Link>
</div>
<Forms.FormText>If using the BD site, click on "Source" somewhere below the Download button</Forms.FormText>
<Forms.FormText>In the GitHub repository of your theme, find X.theme.css / X.css, click on it, then click the "Raw" button</Forms.FormText>
<Forms.FormText>In the GitHub repository of your theme, find X.theme.css, click on it, then click the "Raw" button</Forms.FormText>
<Forms.FormText>
If the theme has configuration that requires you to edit the file:
<ul>
@ -115,13 +116,9 @@ export default ErrorBoundary.wrap(function () {
</Card>
<Forms.FormTitle tag="h5">Themes</Forms.FormTitle>
<TextArea
style={{
padding: ".5em",
border: "1px solid var(--background-modifier-accent)"
}}
value={themeText}
onChange={e => setThemeText(e.currentTarget.value)}
className={TextAreaProps.textarea}
onChange={setThemeText}
className={`${TextAreaProps.textarea} vc-settings-theme-links`}
placeholder="Theme Links"
spellCheck={false}
onBlur={onBlur}

View File

@ -22,9 +22,11 @@ import { ErrorCard } from "@components/ErrorCard";
import { Flex } from "@components/Flex";
import { handleComponentFailed } from "@components/handleComponentFailed";
import { Link } from "@components/Link";
import { Margins } from "@utils/margins";
import { classes, useAwaiter } from "@utils/misc";
import { relaunch } from "@utils/native";
import { changes, checkForUpdates, getRepo, isNewer, rebuild, update, updateError, UpdateLogger } from "@utils/updater";
import { Alerts, Button, Card, Forms, Margins, Parser, React, Switch, Toasts } from "@webpack/common";
import { Alerts, Button, Card, Forms, Parser, React, Switch, Toasts } from "@webpack/common";
import gitHash from "~git-hash";
@ -109,21 +111,21 @@ function Updatable(props: CommonProps) {
</ErrorCard>
</>
) : (
<Forms.FormText className={Margins.marginBottom8}>
<Forms.FormText className={Margins.bottom8}>
{isOutdated ? `There are ${updates.length} Updates` : "Up to Date!"}
</Forms.FormText>
)}
{isOutdated && <Changes updates={updates} {...props} />}
<Flex className={classes(Margins.marginBottom8, Margins.marginTop8)}>
<Flex className={classes(Margins.bottom8, Margins.top8)}>
{isOutdated && <Button
size={Button.Sizes.SMALL}
disabled={isUpdating || isChecking}
onClick={withDispatcher(setIsUpdating, async () => {
if (await update()) {
setUpdates([]);
const needFullRestart = await rebuild();
await rebuild();
await new Promise<void>(r => {
Alerts.show({
title: "Update Success!",
@ -131,10 +133,7 @@ function Updatable(props: CommonProps) {
confirmText: "Restart",
cancelText: "Not now!",
onConfirm() {
if (needFullRestart)
window.DiscordNative.app.relaunch();
else
location.reload();
relaunch();
r();
},
onCancel: r
@ -175,7 +174,7 @@ function Updatable(props: CommonProps) {
function Newer(props: CommonProps) {
return (
<>
<Forms.FormText className={Margins.marginBottom8}>
<Forms.FormText className={Margins.bottom8}>
Your local copy has more recent commits. Please stash or reset them.
</Forms.FormText>
<Changes {...props} updates={changes} />
@ -184,7 +183,7 @@ function Newer(props: CommonProps) {
}
function Updater() {
const settings = useSettings(["notifyAboutUpdates", "autoUpdate"]);
const settings = useSettings(["notifyAboutUpdates", "autoUpdate", "autoUpdateNotification"]);
const [repo, err, repoPending] = useAwaiter(getRepo, { fallbackValue: "Loading..." });
@ -199,12 +198,12 @@ function Updater() {
};
return (
<Forms.FormSection className={Margins.marginTop16}>
<Forms.FormSection className={Margins.top16}>
<Forms.FormTitle tag="h5">Updater Settings</Forms.FormTitle>
<Switch
value={settings.notifyAboutUpdates}
onChange={(v: boolean) => settings.notifyAboutUpdates = v}
note="Shows a toast on startup"
note="Shows a notification on startup"
disabled={settings.autoUpdate}
>
Get notified about new updates
@ -216,16 +215,32 @@ function Updater() {
>
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.FormText className="vc-text-selectable">
{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.marginTop8 + " " + Margins.marginBottom8} />
<Forms.FormDivider className={Margins.top8 + " " + Margins.bottom8} />
<Forms.FormTitle tag="h5">Updates</Forms.FormTitle>

View File

@ -17,7 +17,8 @@
*/
import { useSettings } from "@api/settings";
import { openNotificationLogModal } from "@api/Notifications/notificationLog";
import { Settings, useSettings } from "@api/settings";
import { classNameFactory } from "@api/Styles";
import DonateButton from "@components/DonateButton";
import ErrorBoundary from "@components/ErrorBoundary";
@ -25,6 +26,7 @@ import { ErrorCard } from "@components/ErrorCard";
import IpcEvents from "@utils/IpcEvents";
import { Margins } from "@utils/margins";
import { identity, useAwaiter } from "@utils/misc";
import { relaunch, showItemInFolder } from "@utils/native";
import { Button, Card, Forms, React, Select, Slider, Switch } from "@webpack/common";
const cl = classNameFactory("vc-settings-");
@ -41,11 +43,11 @@ function VencordSettings() {
fallbackValue: "Loading..."
});
const settings = useSettings();
const notifSettings = settings.notifications;
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 Switches: Array<false | {
key: KeysOfType<typeof settings, boolean>;
@ -63,12 +65,16 @@ function VencordSettings() {
title: "Enable React Developer Tools",
note: "Requires a full restart"
},
!IS_WEB && !isWindows && {
!IS_WEB && (!IS_DISCORD_DESKTOP || !isWindows ? {
key: "frameless",
title: "Disable the window frame",
note: "Requires a full restart"
},
!IS_WEB && {
} : {
key: "winNativeTitleBar",
title: "Use Windows' native title bar instead of Discord's custom one",
note: "Requires a full restart"
}),
!IS_WEB && false /* This causes electron to freeze / white screen for some people */ && {
key: "transparent",
title: "Enable window transparency",
note: "Requires a full restart"
@ -77,6 +83,16 @@ function VencordSettings() {
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"
},
IS_DISCORD_DESKTOP && isMac && {
key: "macosTranslucency",
title: "Enable translucent window",
note: "Requires a full restart"
}
];
@ -95,7 +111,7 @@ function VencordSettings() {
) : (
<React.Fragment>
<Button
onClick={() => window.DiscordNative.app.relaunch()}
onClick={relaunch}
size={Button.Sizes.SMALL}>
Restart Client
</Button>
@ -106,7 +122,7 @@ function VencordSettings() {
Open QuickCSS File
</Button>
<Button
onClick={() => window.DiscordNative.fileManager.showItemInFolder(settingsDir)}
onClick={() => showItemInFolder(settingsDir)}
size={Button.Sizes.SMALL}
disabled={settingsDirPending}>
Open Settings Folder
@ -141,8 +157,16 @@ function VencordSettings() {
</Forms.FormSection>
{typeof Notification !== "undefined" && <NotificationSection settings={settings.notifications} />}
</React.Fragment>
);
}
function NotificationSection({ settings }: { settings: typeof Settings["notifications"]; }) {
return (
<>
<Forms.FormTitle tag="h5">Notification Style</Forms.FormTitle>
{notifSettings.useNative !== "never" && Notification.permission === "denied" && (
{settings.useNative !== "never" && Notification?.permission === "denied" && (
<ErrorCard style={{ padding: "1em" }} className={Margins.bottom8}>
<Forms.FormTitle tag="h5">Desktop Notification Permission denied</Forms.FormTitle>
<Forms.FormText>You have denied Notification Permissions. Thus, Desktop notifications will not work!</Forms.FormText>
@ -161,44 +185,66 @@ function VencordSettings() {
{ label: "Only use Desktop notifications when Discord is not focused", value: "not-focused", default: true },
{ label: "Always use Desktop notifications", value: "always" },
{ label: "Always use Vencord notifications", value: "never" },
]satisfies Array<{ value: typeof settings["notifications"]["useNative"]; } & Record<string, any>>}
] satisfies Array<{ value: typeof settings["useNative"]; } & Record<string, any>>}
closeOnSelect={true}
select={v => notifSettings.useNative = v}
isSelected={v => v === notifSettings.useNative}
select={v => settings.useNative = v}
isSelected={v => v === settings.useNative}
serialize={identity}
/>
<Forms.FormTitle tag="h5" className={Margins.top16 + " " + Margins.bottom8}>Notification Position</Forms.FormTitle>
<Select
isDisabled={notifSettings.useNative === "always"}
isDisabled={settings.useNative === "always"}
placeholder="Notification Position"
options={[
{ label: "Bottom Right", value: "bottom-right", default: true },
{ label: "Top Right", value: "top-right" },
]satisfies Array<{ value: typeof settings["notifications"]["position"]; } & Record<string, any>>}
select={v => notifSettings.position = v}
isSelected={v => v === notifSettings.position}
] satisfies Array<{ value: typeof settings["position"]; } & Record<string, any>>}
select={v => settings.position = v}
isSelected={v => v === settings.position}
serialize={identity}
/>
<Forms.FormTitle tag="h5" className={Margins.top16 + " " + Margins.bottom8}>Notification Timeout</Forms.FormTitle>
<Forms.FormText className={Margins.bottom16}>Set to 0s to never automatically time out</Forms.FormText>
<Slider
disabled={notifSettings.useNative === "always"}
disabled={settings.useNative === "always"}
markers={[0, 1000, 2500, 5000, 10_000, 20_000]}
minValue={0}
maxValue={20_000}
initialValue={notifSettings.timeout}
onValueChange={v => notifSettings.timeout = v}
initialValue={settings.timeout}
onValueChange={v => settings.timeout = v}
onValueRender={v => (v / 1000).toFixed(2) + "s"}
onMarkerRender={v => (v / 1000) + "s"}
stickToMarkers={false}
/>
</React.Fragment>
<Forms.FormTitle tag="h5" className={Margins.top16 + " " + Margins.bottom8}>Notification Log Limit</Forms.FormTitle>
<Forms.FormText className={Margins.bottom16}>
The amount of notifications to save in the log until old ones are removed.
Set to <code>0</code> to disable Notification log and <code></code> to never automatically remove old Notifications
</Forms.FormText>
<Slider
markers={[0, 25, 50, 75, 100, 200]}
minValue={0}
maxValue={200}
stickToMarkers={true}
initialValue={settings.logLimit}
onValueChange={v => settings.logLimit = v}
onValueRender={v => v === 200 ? "∞" : v}
onMarkerRender={v => v === 200 ? "∞" : v}
/>
<Button
onClick={openNotificationLogModal}
disabled={settings.logLimit === 0}
>
Open Notification Log
</Button>
</>
);
}
interface DonateCardProps {
image: string;
}

View File

@ -20,10 +20,12 @@ import "./settingsStyles.css";
import { classNameFactory } from "@api/Styles";
import ErrorBoundary from "@components/ErrorBoundary";
import { findByCodeLazy } from "@webpack";
import { Forms, SettingsRouter, Text } from "@webpack/common";
import { handleComponentFailed } from "@components/handleComponentFailed";
import { isMobile } from "@utils/misc";
import { Forms, SettingsRouter, TabBar, Text } from "@webpack/common";
import BackupRestoreTab from "./BackupRestoreTab";
import CloudTab from "./CloudTab";
import PluginsTab from "./PluginsTab";
import ThemesTab from "./ThemesTab";
import Updater from "./Updater";
@ -31,8 +33,6 @@ import VencordSettings from "./VencordTab";
const cl = classNameFactory("vc-settings-");
const TabBar = findByCodeLazy('[role="tab"][aria-disabled="false"]');
interface SettingsProps {
tab: string;
}
@ -47,7 +47,8 @@ const SettingsTabs: Record<string, SettingsTab> = {
VencordPlugins: { name: "Plugins", component: () => <PluginsTab /> },
VencordThemes: { name: "Themes", component: () => <ThemesTab /> },
VencordUpdater: { name: "Updater" }, // Only show updater if IS_WEB is false
VencordSettingsSync: { name: "Backup & Restore", component: () => <BackupRestoreTab /> },
VencordCloud: { name: "Cloud", component: () => <CloudTab /> },
VencordSettingsSync: { name: "Backup & Restore", component: () => <BackupRestoreTab /> }
};
if (!IS_WEB) SettingsTabs.VencordUpdater.component = () => Updater && <Updater />;
@ -55,14 +56,17 @@ if (!IS_WEB) SettingsTabs.VencordUpdater.component = () => Updater && <Updater /
function Settings(props: SettingsProps) {
const { tab = "VencordSettings" } = props;
const CurrentTab = SettingsTabs[tab]?.component;
const CurrentTab = SettingsTabs[tab]?.component ?? null;
if (isMobile) {
return CurrentTab && <CurrentTab />;
}
return <Forms.FormSection>
<Text variant="heading-md/normal" tag="h2">Vencord Settings</Text>
<Text variant="heading-lg/semibold" style={{ color: "var(--header-primary)" }} tag="h2">Vencord Settings</Text>
<TabBar
type={TabBar.Types.TOP}
look={TabBar.Looks.BRAND}
type="top"
look="brand"
className={cl("tab-bar")}
selectedItem={tab}
onItemSelect={SettingsRouter.open}
@ -83,7 +87,7 @@ function Settings(props: SettingsProps) {
}
export default function (props: SettingsProps) {
return <ErrorBoundary>
return <ErrorBoundary onError={handleComponentFailed}>
<Settings tab={props.tab} />
</ErrorBoundary>;
}

View File

@ -38,3 +38,31 @@
color: var(--info-warning-text);
margin-top: 0;
}
.vc-settings-theme-links {
/* Needed to fix bad themes that hide certain textarea elements for whatever eldritch reason */
display: inline-block !important;
color: var(--text-normal) !important;
padding: 0.5em;
border: 1px solid var(--background-modifier-accent);
}
.vc-cloud-settings-sync-grid {
display: grid;
grid-template-columns: repeat(3, 1fr);
grid-gap: 1em;
}
.vc-cloud-erase-data-danger-btn {
color: var(--white-500);
background-color: var(--button-danger-background);
}
.vc-text-selectable,
.vc-text-selectable :not(a, button) {
/* make text selectable, silly discord makes the entirety of settings not selectable */
user-select: text;
/* discord also sets cursor: default which prevents the cursor from showing as text */
cursor: initial;
}

View File

@ -16,29 +16,12 @@
* along with this program. If not, see <https://www.gnu.org/licenses/>.
*/
import { isOutdated, rebuild, update } from "@utils/updater";
import { maybePromptToUpdate } from "@utils/updater";
export async function handleComponentFailed() {
if (isOutdated) {
setImmediate(async () => {
const wantsUpdate = confirm(
"Uh Oh! Failed to render this Page." +
" However, there is an update available that might fix it." +
" Would you like to update and restart now?"
);
if (wantsUpdate) {
try {
await update();
await rebuild();
if (IS_WEB)
location.reload();
else
DiscordNative.app.relaunch();
} catch (e) {
console.error(e);
alert("That also failed :( Try updating or reinstalling with the installer!");
}
}
});
}
export function handleComponentFailed() {
maybePromptToUpdate(
"Uh Oh! Failed to render this Page." +
" However, there is an update available that might fix it." +
" Would you like to update and restart now?"
);
}

View File

@ -16,6 +16,13 @@
* along with this program. If not, see <https://www.gnu.org/licenses/>.
*/
export { default as ErrorBoundary } from "./ErrorBoundary";
export * from "./ErrorCard";
export * from "./Flex";
export * from "./Heart";
export * from "./Link";
export { default as PatchHelper } from "./PatchHelper";
export { default as PluginSettings } from "./PluginSettings";
export * from "./Switch";
export { default as VencordSettings } from "./VencordSettings";

View File

@ -3,7 +3,7 @@
<head>
<meta charset="utf-8">
<title>QuickCss Editor</title>
<title>Vencord QuickCSS Editor</title>
<link rel="stylesheet" data-name="vs/editor/editor.main"
href="https://cdnjs.cloudflare.com/ajax/libs/monaco-editor/0.34.0/min/vs/editor/editor.main.min.css">
<style>

7
src/globals.d.ts vendored
View File

@ -35,6 +35,8 @@ declare global {
export var IS_WEB: boolean;
export var IS_DEV: boolean;
export var IS_STANDALONE: boolean;
export var IS_DISCORD_DESKTOP: boolean;
export var IS_VENCORD_DESKTOP: boolean;
export var VencordNative: typeof import("./VencordNative").default;
export var Vencord: typeof import("./Vencord");
@ -51,10 +53,11 @@ declare global {
* Only available when running in Electron, undefined on web.
* Thus, avoid using this or only use it inside an {@link IS_WEB} guard.
*
* If you really must use it, mark your plugin as Desktop App only via
* `target: "DESKTOP"`
* If you really must use it, mark your plugin as Desktop App only by naming it Foo.desktop.ts(x)
*/
export var DiscordNative: any;
export var VencordDesktop: any;
export var VencordDesktopNative: any;
interface Window {
webpackChunkdiscord_app: {

110
src/main/index.ts Normal file
View File

@ -0,0 +1,110 @@
/*
* Vencord, a modification for Discord's desktop app
* Copyright (c) 2023 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 { app, protocol, session } from "electron";
import { join } from "path";
import { getSettings } from "./ipcMain";
import { IS_VANILLA } from "./utils/constants";
import { installExt } from "./utils/extensions";
if (IS_VENCORD_DESKTOP || !IS_VANILLA) {
app.whenReady().then(() => {
// Source Maps! Maybe there's a better way but since the renderer is executed
// from a string I don't think any other form of sourcemaps would work
protocol.registerFileProtocol("vencord", ({ url: unsafeUrl }, cb) => {
let url = unsafeUrl.slice("vencord://".length);
if (url.endsWith("/")) url = url.slice(0, -1);
switch (url) {
case "renderer.js.map":
case "vencordDesktopRenderer.js.map":
case "preload.js.map":
case "patcher.js.map":
case "vencordDesktopMain.js.map":
cb(join(__dirname, url));
break;
default:
cb({ statusCode: 403 });
}
});
try {
if (getSettings().enableReactDevtools)
installExt("fmkadmapgofadopljbjfkapdkoienihi")
.then(() => console.info("[Vencord] Installed React Developer Tools"))
.catch(err => console.error("[Vencord] Failed to install React Developer Tools", err));
} catch { }
// Remove CSP
type PolicyResult = Record<string, string[]>;
const parsePolicy = (policy: string): PolicyResult => {
const result: PolicyResult = {};
policy.split(";").forEach(directive => {
const [directiveKey, ...directiveValue] = directive.trim().split(/\s+/g);
if (directiveKey && !Object.prototype.hasOwnProperty.call(result, directiveKey)) {
result[directiveKey] = directiveValue;
}
});
return result;
};
const stringifyPolicy = (policy: PolicyResult): string =>
Object.entries(policy)
.filter(([, values]) => values?.length)
.map(directive => directive.flat().join(" "))
.join("; ");
function patchCsp(headers: Record<string, string[]>, header: string) {
if (header in headers) {
const csp = parsePolicy(headers[header][0]);
for (const directive of ["style-src", "connect-src", "img-src", "font-src", "media-src", "worker-src"]) {
csp[directive] = ["*", "blob:", "data:", "'unsafe-inline'"];
}
// TODO: Restrict this to only imported packages with fixed version.
// Perhaps auto generate with esbuild
csp["script-src"] ??= [];
csp["script-src"].push("'unsafe-eval'", "https://unpkg.com", "https://cdnjs.cloudflare.com");
headers[header] = [stringifyPolicy(csp)];
}
}
session.defaultSession.webRequest.onHeadersReceived(({ responseHeaders, resourceType }, cb) => {
if (responseHeaders) {
if (resourceType === "mainFrame")
patchCsp(responseHeaders, "content-security-policy");
// Fix hosts that don't properly set the css content type, such as
// raw.githubusercontent.com
if (resourceType === "stylesheet")
responseHeaders["content-type"] = ["text/css"];
}
cb({ cancel: false, responseHeaders });
});
// assign a noop to onHeadersReceived to prevent other mods from adding their own incompatible ones.
// For instance, OpenAsar adds their own that doesn't fix content-type for stylesheets which makes it
// impossible to load css from github raw despite our fix above
session.defaultSession.webRequest.onHeadersReceived = () => { };
});
}
if (IS_DISCORD_DESKTOP) {
require("./patcher");
}

View File

@ -22,13 +22,13 @@ import { debounce } from "@utils/debounce";
import IpcEvents from "@utils/IpcEvents";
import { Queue } from "@utils/Queue";
import { BrowserWindow, ipcMain, shell } from "electron";
import { mkdirSync, readFileSync, watch } from "fs";
import { mkdirSync, readdirSync, readFileSync, watch } from "fs";
import { open, readFile, writeFile } from "fs/promises";
import { join } from "path";
import monacoHtml from "~fileContent/../components/monacoWin.html;base64";
import { ALLOWED_PROTOCOLS, QUICKCSS_PATH, SETTINGS_DIR, SETTINGS_FILE } from "./constants";
import { ALLOWED_PROTOCOLS, PLUGINS_DIR, QUICKCSS_PATH, SETTINGS_DIR, SETTINGS_FILE } from "./utils/constants";
mkdirSync(SETTINGS_DIR, { recursive: true });
@ -44,6 +44,14 @@ export function readSettings() {
}
}
export function getSettings(): typeof import("@api/settings").Settings {
try {
return JSON.parse(readSettings());
} catch {
return {} as any;
}
}
ipcMain.handle(IpcEvents.OPEN_QUICKCSS, () => shell.openPath(QUICKCSS_PATH));
ipcMain.handle(IpcEvents.OPEN_EXTERNAL, (_, url) => {
@ -85,7 +93,7 @@ export function initIpc(mainWindow: BrowserWindow) {
ipcMain.handle(IpcEvents.OPEN_MONACO_EDITOR, async () => {
const win = new BrowserWindow({
title: "QuickCss Editor",
title: "Vencord QuickCSS Editor",
autoHideMenuBar: true,
darkTheme: true,
webPreferences: {
@ -97,3 +105,15 @@ ipcMain.handle(IpcEvents.OPEN_MONACO_EDITOR, async () => {
});
await win.loadURL(`data:text/html;base64,${monacoHtml}`);
});
ipcMain.on(IpcEvents.GET_PLUGINS, e => {
try {
const files = readdirSync(PLUGINS_DIR).filter(f => f.endsWith(".js"));
console.log(files);
e.returnValue = files.map(f => [f, readFileSync(join(PLUGINS_DIR, f), "utf-8")]);
} catch (err) {
console.error(err);
e.returnValue = [];
}
});

View File

@ -20,9 +20,8 @@ import { onceDefined } from "@utils/onceDefined";
import electron, { app, BrowserWindowConstructorOptions, Menu } from "electron";
import { dirname, join } from "path";
import { initIpc } from "./ipcMain";
import { installExt } from "./ipcMain/extensions";
import { readSettings } from "./ipcMain/index";
import { getSettings, initIpc } from "./ipcMain";
import { IS_VANILLA } from "./utils/constants";
console.log("[Vencord] Starting up...");
@ -41,11 +40,8 @@ require.main!.filename = join(asarPath, discordPkg.main);
// @ts-ignore Untyped method? Dies from cringe
app.setAppPath(asarPath);
if (!process.argv.includes("--vanilla")) {
let settings: typeof import("@api/settings").Settings = {} as any;
try {
settings = JSON.parse(readSettings());
} catch { }
if (!IS_VANILLA) {
const settings = getSettings();
// Repatch after host updates on Windows
if (process.platform === "win32") {
@ -79,12 +75,21 @@ if (!process.argv.includes("--vanilla")) {
options.webPreferences.sandbox = false;
if (settings.frameless) {
options.frame = false;
} else if (process.platform === "win32" && settings.winNativeTitleBar) {
delete options.frame;
}
if (settings.transparent) {
// This causes electron to freeze / white screen for some people
if ((settings as any).transparentUNSAFE_USE_AT_OWN_RISK) {
options.transparent = true;
options.backgroundColor = "#00000000";
}
if (settings.macosTranslucency && process.platform === "darwin") {
options.backgroundColor = "#00000000";
options.vibrancy = "sidebar";
}
process.env.DISCORD_PRELOAD = original;
super(options);
@ -106,85 +111,19 @@ if (!process.argv.includes("--vanilla")) {
BrowserWindow
};
// Patch appSettings to force enable devtools
onceDefined(global, "appSettings", s =>
s.set("DANGEROUS_ENABLE_DEVTOOLS_ONLY_ENABLE_IF_YOU_KNOW_WHAT_YOURE_DOING", true)
);
// Patch appSettings to force enable devtools and optionally disable min size
onceDefined(global, "appSettings", s => {
s.set("DANGEROUS_ENABLE_DEVTOOLS_ONLY_ENABLE_IF_YOU_KNOW_WHAT_YOURE_DOING", true);
if (settings.disableMinSize) {
s.set("MIN_WIDTH", 0);
s.set("MIN_HEIGHT", 0);
} else {
s.set("MIN_WIDTH", 940);
s.set("MIN_HEIGHT", 500);
}
});
process.env.DATA_DIR = join(app.getPath("userData"), "..", "Vencord");
electron.app.whenReady().then(() => {
// Source Maps! Maybe there's a better way but since the renderer is executed
// from a string I don't think any other form of sourcemaps would work
electron.protocol.registerFileProtocol("vencord", ({ url: unsafeUrl }, cb) => {
let url = unsafeUrl.slice("vencord://".length);
if (url.endsWith("/")) url = url.slice(0, -1);
switch (url) {
case "renderer.js.map":
case "preload.js.map":
case "patcher.js.map": // doubt
cb(join(__dirname, url));
break;
default:
cb({ statusCode: 403 });
}
});
try {
if (settings?.enableReactDevtools)
installExt("fmkadmapgofadopljbjfkapdkoienihi")
.then(() => console.info("[Vencord] Installed React Developer Tools"))
.catch(err => console.error("[Vencord] Failed to install React Developer Tools", err));
} catch { }
// Remove CSP
type PolicyResult = Record<string, string[]>;
const parsePolicy = (policy: string): PolicyResult => {
const result: PolicyResult = {};
policy.split(";").forEach(directive => {
const [directiveKey, ...directiveValue] = directive.trim().split(/\s+/g);
if (directiveKey && !Object.prototype.hasOwnProperty.call(result, directiveKey)) {
result[directiveKey] = directiveValue;
}
});
return result;
};
const stringifyPolicy = (policy: PolicyResult): string =>
Object.entries(policy)
.filter(([, values]) => values?.length)
.map(directive => directive.flat().join(" "))
.join("; ");
function patchCsp(headers: Record<string, string[]>, header: string) {
if (header in headers) {
const csp = parsePolicy(headers[header][0]);
for (const directive of ["style-src", "connect-src", "img-src", "font-src", "media-src", "worker-src"]) {
csp[directive] = ["*", "blob:", "data:", "'unsafe-inline'"];
}
// TODO: Restrict this to only imported packages with fixed version.
// Perhaps auto generate with esbuild
csp["script-src"] ??= [];
csp["script-src"].push("'unsafe-eval'", "https://unpkg.com", "https://cdnjs.cloudflare.com");
headers[header] = [stringifyPolicy(csp)];
}
}
electron.session.defaultSession.webRequest.onHeadersReceived(({ responseHeaders, resourceType }, cb) => {
if (responseHeaders) {
if (resourceType === "mainFrame")
patchCsp(responseHeaders, "content-security-policy");
// Fix hosts that don't properly set the css content type, such as
// raw.githubusercontent.com
if (resourceType === "stylesheet")
responseHeaders["content-type"] = ["text/css"];
}
cb({ cancel: false, responseHeaders });
});
});
} else {
console.log("[Vencord] Running in vanilla mode. Not loading Vencord");
}

View File

@ -16,28 +16,13 @@
* along with this program. If not, see <https://www.gnu.org/licenses/>.
*/
import { createHash } from "crypto";
import { createReadStream } from "fs";
import { join } from "path";
export async function calculateHashes() {
const hashes = {} as Record<string, string>;
await Promise.all(
["patcher.js", "preload.js", "renderer.js", "renderer.css"].map(file => new Promise<void>(r => {
const fis = createReadStream(join(__dirname, file));
const hash = createHash("sha1", { encoding: "hex" });
fis.once("end", () => {
hash.end();
hashes[file] = hash.read();
r();
});
fis.pipe(hash);
}))
);
return hashes;
}
export const VENCORD_FILES = [
IS_DISCORD_DESKTOP ? "patcher.js" : "vencordDesktopMain.js",
"preload.js",
IS_DISCORD_DESKTOP ? "renderer.js" : "vencordDesktopRenderer.js",
"renderer.css"
];
export function serializeErrors(func: (...args: any[]) => any) {
return async function () {

View File

@ -22,7 +22,7 @@ import { ipcMain } from "electron";
import { join } from "path";
import { promisify } from "util";
import { calculateHashes, serializeErrors } from "./common";
import { serializeErrors } from "./common";
const VENCORD_SRC_DIR = join(__dirname, "..");
@ -76,7 +76,6 @@ async function build() {
return !res.stderr.includes("Build failed");
}
ipcMain.handle(IpcEvents.GET_HASHES, serializeErrors(calculateHashes));
ipcMain.handle(IpcEvents.GET_REPO, serializeErrors(getRepo));
ipcMain.handle(IpcEvents.GET_UPDATES, serializeErrors(calculateGitChanges));
ipcMain.handle(IpcEvents.UPDATE, serializeErrors(pull));

View File

@ -25,8 +25,8 @@ import { join } from "path";
import gitHash from "~git-hash";
import gitRemote from "~git-remote";
import { get } from "../simpleGet";
import { calculateHashes, serializeErrors } from "./common";
import { get } from "../utils/simpleGet";
import { serializeErrors, VENCORD_FILES } from "./common";
const API_BASE = `https://api.github.com/repos/${gitRemote}`;
let PendingUpdates = [] as [string, string][];
@ -66,7 +66,7 @@ async function fetchUpdates() {
return false;
data.assets.forEach(({ name, browser_download_url }) => {
if (["patcher.js", "preload.js", "renderer.js", "renderer.css"].some(s => name.startsWith(s))) {
if (VENCORD_FILES.some(s => name.startsWith(s))) {
PendingUpdates.push([name, browser_download_url]);
}
});
@ -75,13 +75,15 @@ async function fetchUpdates() {
async function applyUpdates() {
await Promise.all(PendingUpdates.map(
async ([name, data]) => writeFile(join(__dirname, name), await get(data)))
);
async ([name, data]) => writeFile(
join(__dirname, name),
await get(data)
)
));
PendingUpdates = [];
return true;
}
ipcMain.handle(IpcEvents.GET_HASHES, serializeErrors(calculateHashes));
ipcMain.handle(IpcEvents.GET_REPO, serializeErrors(() => `https://github.com/${gitRemote}`));
ipcMain.handle(IpcEvents.GET_UPDATES, serializeErrors(calculateGitChanges));
ipcMain.handle(IpcEvents.UPDATE, serializeErrors(fetchUpdates));

View File

@ -25,6 +25,7 @@ export const DATA_DIR = process.env.VENCORD_USER_DATA_DIR ?? (
: join(app.getPath("userData"), "..", "Vencord")
);
export const SETTINGS_DIR = join(DATA_DIR, "settings");
export const PLUGINS_DIR = join(DATA_DIR, "plugins");
export const QUICKCSS_PATH = join(SETTINGS_DIR, "quickCss.css");
export const SETTINGS_FILE = join(SETTINGS_DIR, "settings.json");
export const ALLOWED_PROTOCOLS = [
@ -33,3 +34,5 @@ export const ALLOWED_PROTOCOLS = [
"steam:",
"spotify:"
];
export const IS_VANILLA = /* @__PURE__ */ process.argv.includes("--vanilla");

2
src/modules.d.ts vendored
View File

@ -20,7 +20,7 @@
/// <reference types="standalone-electron-types"/>
declare module "~plugins" {
const plugins: Record<string, import("@utils/types").Plugin>;
const plugins: Record<string, import("./utils/types").Plugin>;
export default plugins;
}

View File

@ -1,6 +1,6 @@
/*
* Vencord, a modification for Discord's desktop app
* Copyright (c) 2022 Vendicated and contributors
* Copyright (c) 2023 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
@ -20,16 +20,18 @@ import { Devs } from "@utils/constants";
import definePlugin from "@utils/types";
export default definePlugin({
name: "MuteNewGuild",
description: "Mutes newly joined guilds",
authors: [Devs.Glitch],
name: "AlwaysAnimate",
description: "Animates anything that can be animated, besides status emojis.",
authors: [Devs.FieryFlames],
patches: [
{
find: ",acceptInvite:function",
find: ".canAnimate",
all: true,
replacement: {
match: /(\w=null!==[^;]+)/,
replace: "$1;Vencord.Webpack.findByProps('updateGuildNotificationSettings').updateGuildNotificationSettings($1,{'muted':true,'suppress_everyone':true,'suppress_roles':true})"
match: /\.canAnimate\b/g,
replace: ".canAnimate || true"
}
}
],
]
});

View File

@ -32,10 +32,10 @@ export default definePlugin({
}
},
{
find: "\"github.com\":new RegExp(\"\\\\/releases\\\\S*\\\\/download\"),",
find: '"7z","ade","adp"',
replacement: {
match: /const o=JSON.parse\('\[.+?'\)/,
replace: "const o=[]"
match: /JSON\.parse\('\[.+?'\)/,
replace: "[]"
}
}
]

View File

@ -16,7 +16,7 @@
* along with this program. If not, see <https://www.gnu.org/licenses/>.
*/
import { BadgePosition, ProfileBadge } from "@api/Badges";
import { BadgePosition, BadgeUserArgs, ProfileBadge } from "@api/Badges";
import DonateButton from "@components/DonateButton";
import ErrorBoundary from "@components/ErrorBoundary";
import { Flex } from "@components/Flex";
@ -24,17 +24,18 @@ import { Heart } from "@components/Heart";
import { Devs } from "@utils/constants";
import IpcEvents from "@utils/IpcEvents";
import Logger from "@utils/Logger";
import { Margins } from "@utils/margins";
import { closeModal, Modals, openModal } from "@utils/modal";
import definePlugin from "@utils/types";
import { Forms, Margins } from "@webpack/common";
import { Forms } from "@webpack/common";
const CONTRIBUTOR_BADGE = "https://media.discordapp.net/stickers/1026517526106087454.webp";
const CONTRIBUTOR_BADGE = "https://cdn.discordapp.com/attachments/1033680203433660458/1092089947126780035/favicon.png";
/** List of vencord contributor IDs */
const contributorIds: string[] = Object.values(Devs).map(d => d.id.toString());
const ContributorBadge: ProfileBadge = {
tooltip: "Vencord Contributor",
description: "Vencord Contributor",
image: CONTRIBUTOR_BADGE,
position: BadgePosition.START,
props: {
@ -44,45 +45,48 @@ const ContributorBadge: ProfileBadge = {
}
},
shouldShow: ({ user }) => contributorIds.includes(user.id),
onClick: () => VencordNative.ipc.invoke(IpcEvents.OPEN_EXTERNAL, "https://github.com/Vendicated/Vencord")
link: "https://github.com/Vendicated/Vencord"
};
const DonorBadges = {} as Record<string, Pick<ProfileBadge, "image" | "tooltip">>;
const DonorBadges = {} as Record<string, Pick<ProfileBadge, "image" | "description">>;
export default definePlugin({
name: "BadgeAPI",
description: "API to add badges to users.",
authors: [Devs.Megu],
authors: [Devs.Megu, Devs.Ven, Devs.TheSun],
required: true,
patches: [
/* Patch the badges array */
{
find: "PREMIUM_GUILD_SUBSCRIPTION_TOOLTIP.format({date:",
replacement: {
match: /&&((\w{1,3})\.push\({tooltip:\w{1,3}\.\w{1,3}\.Messages\.PREMIUM_GUILD_SUBSCRIPTION_TOOLTIP\.format.+?;)(?:return\s\w{1,3};?})/,
replace: (_, m, badgeArray) => `&&${m} return Vencord.Api.Badges.inject(${badgeArray}, arguments[0]);}`,
}
},
/* Patch the badge list component on user profiles */
{
find: "Messages.PROFILE_USER_BADGES,role:",
replacement: [
{
match: /src:(\w{1,3})\[(\w{1,3})\.key\],/,
// <img src={badge.image ?? imageMap[badge.key]} {...badge.props} />
replace: (_, imageMap, badge) => `src: ${badge}.image ?? ${imageMap}[${badge}.key], ...${badge}.props,`
match: /null==\i\?void 0:(\i)\.getBadges\(\)/,
replace: (_, badgesMod) => `Vencord.Api.Badges._getBadges(arguments[0]).concat(${badgesMod}?.getBadges()??[])`,
},
{
match: /spacing:(\d{1,2}),children:(.{1,40}(\i)\.jsx.+?(\i)\.onClick.+?\)})},/,
// if the badge provides it's own component, render that instead of an image
// the badge also includes info about the user that has it (type BadgeUserArgs), which is why it's passed as props
replace: (_, s, origBadgeComponent, React, badge) =>
`spacing:${s},children:${badge}.component ? () => (0,${React}.jsx)(${badge}.component, { ...${badge} }) : ${origBadgeComponent}},`
// alt: "", aria-hidden: false, src: originalSrc
match: /alt:" ","aria-hidden":!0,src:(?=(\i)\.src)/g,
// ...badge.props, ..., src: badge.image ?? ...
replace: "...$1.props,$& $1.image??"
},
{
match: /children:function(?<=(\i)\.(?:tooltip|description),spacing:\d.+?)/g,
replace: "children:$1.component ? () => $self.renderBadgeComponent($1) : function"
},
{
match: /onClick:function(?=.{0,200}href:(\i)\.link)/,
replace: "onClick:$1.onClick??function"
}
]
}
],
renderBadgeComponent: ErrorBoundary.wrap((badge: ProfileBadge & BadgeUserArgs) => {
const Component = badge.component!;
return <Component {...badge} />;
}, { noop: true }),
async start() {
Vencord.Api.Badges.addBadge(ContributorBadge);
const badges = await fetch("https://gist.githubusercontent.com/Vendicated/51a3dd775f6920429ec6e9b735ca7f01/raw/badges.csv").then(r => r.text());
@ -92,15 +96,15 @@ export default definePlugin({
return;
}
for (const line of lines) {
const [id, tooltip, image] = line.split(",");
DonorBadges[id] = { image, tooltip };
const [id, description, image] = line.split(",");
DonorBadges[id] = { image, description };
}
},
addDonorBadge(badges: ProfileBadge[], userId: string) {
getDonorBadge(userId: string) {
const badge = DonorBadges[userId];
if (badge) {
badges.unshift({
return {
...badge,
position: BadgePosition.START,
props: {
@ -150,7 +154,7 @@ export default definePlugin({
<Forms.FormText>
This Badge is a special perk for Vencord Donors
</Forms.FormText>
<Forms.FormText className={Margins.marginTop20}>
<Forms.FormText className={Margins.top20}>
Please consider supporting the development of Vencord by becoming a donor. It would mean a lot!!
</Forms.FormText>
</div>
@ -164,7 +168,7 @@ export default definePlugin({
</ErrorBoundary>
));
},
});
};
}
}
});

View File

@ -20,27 +20,26 @@ import { Devs } from "@utils/constants";
import definePlugin from "@utils/types";
export default definePlugin({
name: "WebContextMenus",
description: "Re-adds some of context menu items missing on the web version of Discord, namely Copy/Open Link",
authors: [Devs.Ven],
target: "WEB",
name: "ContextMenuAPI",
description: "API for adding/removing items to/from context menus.",
authors: [Devs.Nuckyz, Devs.Ven],
required: true,
patches: [{
// There is literally no reason for Discord to make this Desktop only.
// The only thing broken is copy, but they already have a different copy function
// with web support????
find: "open-native-link",
replacement: [
{
// if (isNative || null ==
match: /if\(!\w\..{1,3}\|\|null==/,
replace: "if(null=="
},
// Fix silly Discord calling the non web support copy
{
match: /\w\.default\.copy/,
replace: "Vencord.Webpack.Common.Clipboard.copy"
patches: [
{
find: "♫ (つ。◕‿‿◕。)つ ♪",
replacement: {
match: /(?<=function \i\((\i)\){)(?=var \i,\i=\i\.navId)/,
replace: (_, props) => `Vencord.Api.ContextMenu._patchContextMenu(${props});`
}
]
}]
},
{
find: ".Menu,{",
all: true,
replacement: {
match: /Menu,{(?<=\.jsxs?\)\(\i\.Menu,{)/g,
replace: "$&contextMenuApiArguments:typeof arguments!=='undefined'?arguments:[],"
}
}
]
});

View File

@ -1,82 +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 { migratePluginSettings } from "@api/settings";
import { Devs } from "@utils/constants";
import definePlugin from "@utils/types";
// duplicate values have multiple branches with different types. Just include all to be safe
const nameMap = {
radio: "MenuRadioItem",
separator: "MenuSeparator",
checkbox: "MenuCheckboxItem",
groupstart: "MenuGroup",
control: "MenuControlItem",
compositecontrol: "MenuControlItem",
item: "MenuItem",
customitem: "MenuItem",
};
migratePluginSettings("MenuItemDeobfuscatorAPI", "MenuItemDeobfuscatorApi");
export default definePlugin({
name: "MenuItemDeobfuscatorAPI",
description: "Deobfuscates Discord's Menu Item module",
authors: [Devs.Ven],
patches: [
{
find: '"Menu API',
replacement: {
match: /function.{0,80}type===(\i)\).{0,50}navigable:.+?Menu API/s,
replace: (m, mod) => {
let nicenNames = "";
const redefines = [] as string[];
// if (t.type === m.MenuItem)
const typeCheckRe = /\(.{1,3}\.type===(.{1,5})\)/g;
// push({type:"item"})
const pushTypeRe = /type:"(\w+)"/g;
let typeMatch: RegExpExecArray | null;
// for each if (t.type === ...)
while ((typeMatch = typeCheckRe.exec(m)) !== null) {
// extract the current menu item
const item = typeMatch[1];
// Set the starting index of the second regex to that of the first to start
// matching from after the if
pushTypeRe.lastIndex = typeCheckRe.lastIndex;
// extract the first type: "..."
const type = pushTypeRe.exec(m)?.[1];
if (type && type in nameMap) {
const name = nameMap[type];
nicenNames += `Object.defineProperty(${item},"name",{value:"${name}"});`;
redefines.push(`${name}:${item}`);
}
}
if (redefines.length < 6) {
console.warn("[ApiMenuItemDeobfuscator] Expected to at least remap 6 items, only remapped", redefines.length);
}
// Merge all our redefines with the actual module
return `${nicenNames}Object.assign(${mod},{${redefines.join(",")}});${m}`;
},
},
},
],
});

View File

@ -22,22 +22,22 @@ import definePlugin from "@utils/types";
export default definePlugin({
name: "MessageEventsAPI",
description: "Api required by anything using message events.",
authors: [Devs.Arjix],
authors: [Devs.Arjix, Devs.hunt],
patches: [
{
find: "sendMessage:function",
find: '"MessageActionCreators"',
replacement: [{
match: /(?<=_sendMessage:function\([^)]+\)){/,
replace: "{if(Vencord.Api.MessageEvents._handlePreSend(...arguments)){return;};"
match: /_sendMessage:(function\([^)]+\)){/,
replace: "_sendMessage:async $1{if(await Vencord.Api.MessageEvents._handlePreSend(...arguments))return;"
}, {
match: /(?<=\beditMessage:function\([^)]+\)){/,
replace: "{Vencord.Api.MessageEvents._handlePreEdit(...arguments);"
match: /\beditMessage:(function\([^)]+\)){/,
replace: "editMessage:async $1{await Vencord.Api.MessageEvents._handlePreEdit(...arguments);"
}]
},
{
find: '("interactionUsernameProfile',
replacement: {
match: /var \w=(\w)\.id,\w=(\w)\.id;return .{1,2}\.useCallback\(\(?function\((.{1,2})\){/,
match: /var \i=(\i)\.id,\i=(\i)\.id;return \i\.useCallback\(\(?function\((\i)\){/,
replace: (m, message, channel, event) =>
// the message param is shadowed by the event param, so need to alias them
`var _msg=${message},_chan=${channel};${m}Vencord.Api.MessageEvents._handleClick(_msg, _chan, ${event});`

View File

@ -22,16 +22,16 @@ import definePlugin from "@utils/types";
export default definePlugin({
name: "MessagePopoverAPI",
description: "API to add buttons to message popovers.",
authors: [Devs.KingFish, Devs.Ven],
authors: [Devs.KingFish, Devs.Ven, Devs.Nuckyz],
patches: [{
find: "Messages.MESSAGE_UTILITIES_A11Y_LABEL",
replacement: {
// foo && !bar ? createElement(blah,...makeElement(addReactionData))
match: /(\i&&!\i)\?\(0,\i\.jsxs?\)\(.{0,20}renderPopout:.{0,300}?(\i)\(.{3,20}\{key:"add-reaction".+?\}/,
replace: (m, bools, makeElement) => {
// foo && !bar ? createElement(reactionStuffs)... createElement(blah,...makeElement(reply-other))
match: /\i&&!\i\?\(0,\i\.jsxs?\)\(.{0,200}renderEmojiPicker:.{0,500}\?(\i)\(\{key:"reply-other"/,
replace: (m, makeElement) => {
const msg = m.match(/message:(.{1,3}),/)?.[1];
if (!msg) throw new Error("Could not find message variable");
return `...(${bools}?Vencord.Api.MessagePopover._buildPopoverElements(${msg},${makeElement}):[]),${m}`;
return `...Vencord.Api.MessagePopover._buildPopoverElements(${msg},${makeElement}),${m}`;
}
}
}],

View File

@ -29,13 +29,12 @@ export default definePlugin({
find: 'displayName="NoticeStore"',
replacement: [
{
match: /;.{1,2}=null;.{0,70}getPremiumSubscription/g,
replace:
";if(Vencord.Api.Notices.currentNotice)return false$&"
match: /(?=;\i=null;.{0,70}getPremiumSubscription)/g,
replace: ";if(Vencord.Api.Notices.currentNotice)return false"
},
{
match: /(?<=,NOTICE_DISMISS:function\(\i\){)(?=if\(null==(\i)\))/,
replace: 'if($1?.id=="VencordNotice")return($1=null,Vencord.Api.Notices.nextNotice(),true);'
replace: (_, notice) => `if(${notice}.id=="VencordNotice")return(${notice}=null,Vencord.Api.Notices.nextNotice(),true);`
}
]
}

View File

@ -0,0 +1,38 @@
/*
* 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 { Devs } from "@utils/constants";
import definePlugin from "@utils/types";
export default definePlugin({
name: "SettingsStoreAPI",
description: "Patches Discord's SettingsStores to expose their group and name",
authors: [Devs.Nuckyz],
patches: [
{
find: '"textAndImages","renderSpoilers"',
replacement: [
{
match: /(?<=INFREQUENT_USER_ACTION.{0,20}),useSetting:function/,
replace: ",settingsStoreApiGroup:arguments[0],settingsStoreApiName:arguments[1]$&"
}
]
}
]
});

View File

@ -48,7 +48,6 @@ export default definePlugin({
name: "WebRichPresence (arRPC)",
description: "Client plugin for arRPC to enable RPC on Discord Web (experimental)",
authors: [Devs.Ducko],
target: "WEB",
settingsAboutComponent: () => (
<>
@ -60,6 +59,9 @@ export default definePlugin({
),
async start() {
// ArmCord comes with its own arRPC implementation, so this plugin just confuses users
if ("armcord" in window) return;
if (ws) ws.close();
ws = new WebSocket("ws://127.0.0.1:1337"); // try to open WebSocket

View File

@ -0,0 +1,84 @@
/*
* Vencord, a modification for Discord's desktop app
* Copyright (c) 2023 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 } from "@api/settings";
import { classNameFactory } from "@api/Styles";
import ErrorBoundary from "@components/ErrorBoundary";
import { findByPropsLazy, findStoreLazy } from "@webpack";
import { i18n, React, useStateFromStores } from "@webpack/common";
const cl = classNameFactory("vc-bf-");
const classes = findByPropsLazy("sidebar", "guilds");
const Animations = findByPropsLazy("a", "animated", "useTransition");
const ChannelRTCStore = findStoreLazy("ChannelRTCStore");
const ExpandedGuildFolderStore = findStoreLazy("ExpandedGuildFolderStore");
function Guilds(props: {
className: string;
bfGuildFolders: any[];
}) {
// @ts-expect-error
const res = Vencord.Plugins.plugins.BetterFolders.Guilds(props);
const scrollerProps = res.props.children?.props?.children?.[1]?.props;
if (scrollerProps?.children) {
const servers = scrollerProps.children.find(c => c?.props?.["aria-label"] === i18n.Messages.SERVERS);
if (servers) scrollerProps.children = servers;
}
return res;
}
export default ErrorBoundary.wrap(() => {
const expandedFolders = useStateFromStores([ExpandedGuildFolderStore], () => ExpandedGuildFolderStore.getExpandedFolders());
const fullscreen = useStateFromStores([ChannelRTCStore], () => ChannelRTCStore.isFullscreenInContext());
const guilds = document.querySelector(`.${classes.guilds}`);
const visible = !!expandedFolders.size;
const className = cl("folder-sidebar", { fullscreen });
const Sidebar = (
<Guilds
className={classes.guilds}
bfGuildFolders={Array.from(expandedFolders)}
/>
);
if (!guilds || !Settings.plugins.BetterFolders.sidebarAnim)
return visible
? <div className={className}>{Sidebar}</div>
: null;
return (
<Animations.Transition
items={visible}
from={{ width: 0 }}
enter={{ width: guilds.getBoundingClientRect().width }}
leave={{ width: 0 }}
config={{ duration: 200 }}
>
{(style, show) => show && (
<Animations.animated.div style={style} className={className}>
{Sidebar}
</Animations.animated.div>
)}
</Animations.Transition>
);
}, { noop: true });

View File

@ -0,0 +1,17 @@
.vc-bf-folder-sidebar [class*="wrapper-"] > [class*="listItem-"]:first-of-type,
.vc-bf-folder-sidebar [class*="unreadMentionsIndicator"] {
display: none;
}
.vc-bf-folder-sidebar [class*="expandedFolderBackground-"] {
background-color: transparent;
}
.vc-bf-folder-sidebar {
display: flex;
}
.vc-bf-fullscreen {
width: 0 !important;
visibility: hidden;
}

View File

@ -0,0 +1,177 @@
/*
* Vencord, a modification for Discord's desktop app
* Copyright (c) 2023 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 "./betterFolders.css";
import { definePluginSettings } from "@api/settings";
import { Devs } from "@utils/constants";
import definePlugin, { OptionType } from "@utils/types";
import { findByPropsLazy, findLazy, findStoreLazy } from "@webpack";
import { FluxDispatcher } from "@webpack/common";
import FolderSideBar from "./FolderSideBar";
const GuildsTree = findLazy(m => m.prototype?.convertToFolder);
const GuildFolderStore = findStoreLazy("SortedGuildStore");
const ExpandedFolderStore = findStoreLazy("ExpandedGuildFolderStore");
const FolderUtils = findByPropsLazy("move", "toggleGuildFolderExpand");
const settings = definePluginSettings({
sidebar: {
type: OptionType.BOOLEAN,
description: "Display servers from folder on dedicated sidebar",
default: true,
},
sidebarAnim: {
type: OptionType.BOOLEAN,
description: "Animate opening the folder sidebar",
default: true,
},
closeAllFolders: {
type: OptionType.BOOLEAN,
description: "Close all folders when selecting a server not in a folder",
default: false,
},
closeAllHomeButton: {
type: OptionType.BOOLEAN,
description: "Close all folders when clicking on the home button",
default: false,
},
closeOthers: {
type: OptionType.BOOLEAN,
description: "Close other folders when opening a folder",
default: false,
},
forceOpen: {
type: OptionType.BOOLEAN,
description: "Force a folder to open when switching to a server of that folder",
default: false,
},
});
export default definePlugin({
name: "BetterFolders",
description: "Shows server folders on dedicated sidebar and adds folder related improvements",
authors: [Devs.juby, Devs.AutumnVN],
patches: [
{
find: '("guildsnav")',
predicate: () => settings.store.sidebar,
replacement: [
{
match: /(\i)\(\){return \i\(\(0,\i\.jsx\)\("div",{className:\i\(\)\.guildSeparator}\)\)}/,
replace: "$&$self.Separator=$1;"
},
// Folder component patch
{
match: /\i\(\(function\(\i,\i,\i\){var \i=\i\.key;return.+\(\i\)},\i\)}\)\)/,
replace: "arguments[0].bfHideServers?null:$&"
},
// BEGIN Guilds component patch
{
match: /(\i)\.themeOverride,(.{15,25}\(function\(\){var \i=)(\i\.\i\.getGuildsTree\(\))/,
replace: "$1.themeOverride,bfPatch=$1.bfGuildFolders,$2bfPatch?$self.getGuildsTree(bfPatch,$3):$3"
},
{
match: /return(\(0,\i\.jsx\))(\(\i,{)(folderNode:\i,setNodeRef:\i\.setNodeRef,draggable:!0,.+},\i\.id\));case/,
replace: "var bfHideServers=typeof bfPatch==='undefined',folder=$1$2bfHideServers,$3;return !bfHideServers&&arguments[1]?[$1($self.Separator,{}),folder]:folder;case"
},
// END
{
match: /\("guildsnav"\);return\(0,\i\.jsx\)\(.{1,6},{navigator:\i,children:\(0,\i\.jsx\)\(/,
replace: "$&$self.Guilds="
}
]
},
{
find: "APPLICATION_LIBRARY,render",
predicate: () => settings.store.sidebar,
replacement: {
match: /(\(0,\i\.jsx\))\(\i\..,{className:\i\(\)\.guilds,themeOverride:\i}\)/,
replace: "$&,$1($self.FolderSideBar,{})"
}
},
{
find: '("guildsnav")',
predicate: () => settings.store.closeAllHomeButton,
replacement: {
match: ",onClick:function(){if(!__OVERLAY__){",
replace: "$&$self.closeFolders();"
}
}
],
settings,
start() {
const getGuildFolder = (id: string) => GuildFolderStore.getGuildFolders().find(f => f.guildIds.includes(id));
FluxDispatcher.subscribe("CHANNEL_SELECT", this.onSwitch = data => {
if (!settings.store.closeAllFolders && !settings.store.forceOpen)
return;
if (this.lastGuildId !== data.guildId) {
this.lastGuildId = data.guildId;
const guildFolder = getGuildFolder(data.guildId);
if (guildFolder?.folderId) {
if (settings.store.forceOpen && !ExpandedFolderStore.isFolderExpanded(guildFolder.folderId))
FolderUtils.toggleGuildFolderExpand(guildFolder.folderId);
} else if (settings.store.closeAllFolders)
this.closeFolders();
}
});
FluxDispatcher.subscribe("TOGGLE_GUILD_FOLDER_EXPAND", this.onToggleFolder = e => {
if (settings.store.closeOthers && !this.dispatching)
FluxDispatcher.wait(() => {
const expandedFolders = ExpandedFolderStore.getExpandedFolders();
if (expandedFolders.size > 1) {
this.dispatching = true;
for (const id of expandedFolders) if (id !== e.folderId)
FolderUtils.toggleGuildFolderExpand(id);
this.dispatching = false;
}
});
});
},
stop() {
FluxDispatcher.unsubscribe("CHANNEL_SELECT", this.onSwitch);
FluxDispatcher.unsubscribe("TOGGLE_GUILD_FOLDER_EXPAND", this.onToggleFolder);
},
FolderSideBar,
getGuildsTree(folders, oldTree) {
const tree = new GuildsTree();
tree.root.children = oldTree.root.children.filter(e => folders.includes(e.id));
tree.nodes = folders.map(id => oldTree.nodes[id]);
return tree;
},
closeFolders() {
for (const id of ExpandedFolderStore.getExpandedFolders())
FolderUtils.toggleGuildFolderExpand(id);
},
});

View File

@ -37,14 +37,14 @@ export default definePlugin({
},
},
{
find: '"username"===',
find: '"dot"===',
all: true,
predicate: () => Settings.plugins.BetterRoleDot.bothStyles,
replacement: {
match: /"(?:username|dot)"===\w(?!\.\w)/g,
match: /"(?:username|dot)"===\i(?!\.\i)/g,
replace: "true",
},
},
}
],
options: {

View File

@ -27,11 +27,16 @@ export default definePlugin({
{
find: "Masks.STATUS_ONLINE",
replacement: {
// we can use global replacement here - these are specific to the status icons and are used nowhere else,
// so it keeps the patch and plugin small and simple
match: /Masks\.STATUS_(?:IDLE|DND|STREAMING|OFFLINE)/g,
replace: "Masks.STATUS_ONLINE"
}
},
{
find: ".AVATAR_STATUS_MOBILE_16;",
replacement: {
match: /(\.fromIsMobile,.+?)\i.status/,
replace: (_, rest) => `${rest}"online"`
}
}
]
});

View File

@ -17,10 +17,13 @@
*/
import { Devs } from "@utils/constants";
import { relaunch } from "@utils/native";
import { canonicalizeMatch, canonicalizeReplace, canonicalizeReplacement } from "@utils/patches";
import definePlugin from "@utils/types";
import * as Webpack from "@webpack";
import { extract, filters, findAll, search } from "@webpack";
import { React } from "@webpack/common";
import { React, ReactDOM } from "@webpack/common";
import type { ComponentType } from "react";
const WEB_ONLY = (f: string) => () => {
throw new Error(`'${f}' is Discord Desktop only.`);
@ -32,14 +35,14 @@ export default definePlugin({
authors: [Devs.Ven],
getShortcuts() {
function newFindWrapper(filterFactory: (props: any) => Webpack.FilterFn) {
const cache = new Map<string, any>();
function newFindWrapper(filterFactory: (...props: any[]) => Webpack.FilterFn) {
const cache = new Map<string, unknown>();
return function (filterProps: any) {
return function (...filterProps: unknown[]) {
const cacheKey = String(filterProps);
if (cache.has(cacheKey)) return cache.get(cacheKey);
const matches = findAll(filterFactory(filterProps));
const matches = findAll(filterFactory(...filterProps));
const result = (() => {
switch (matches.length) {
@ -58,8 +61,9 @@ export default definePlugin({
};
}
let fakeRenderWin: WeakRef<Window> | undefined;
return {
wp: Webpack,
wp: Vencord.Webpack,
wpc: Webpack.wreq.c,
wreq: Webpack.wreq,
wpsearch: search,
@ -71,13 +75,25 @@ export default definePlugin({
findAllByProps: (...props: string[]) => findAll(filters.byProps(...props)),
findByCode: newFindWrapper(filters.byCode),
findAllByCode: (code: string) => findAll(filters.byCode(code)),
findStore: newFindWrapper(filters.byStoreName),
PluginsApi: Vencord.Plugins,
plugins: Vencord.Plugins.plugins,
React,
Settings: Vencord.Settings,
Api: Vencord.Api,
reload: () => location.reload(),
restart: IS_WEB ? WEB_ONLY("restart") : window.DiscordNative.app.relaunch
restart: IS_WEB ? WEB_ONLY("restart") : relaunch,
canonicalizeMatch,
canonicalizeReplace,
canonicalizeReplacement,
fakeRender: (component: ComponentType, props: any) => {
const prevWin = fakeRenderWin?.deref();
const win = prevWin?.closed === false ? prevWin : window.open("about:blank", "Fake Render", "popup,width=500,height=500")!;
fakeRenderWin = new WeakRef(win);
win.focus();
ReactDOM.render(React.createElement(component, props), win.document.body);
}
};
},

View File

@ -1,105 +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 { ApplicationCommandOptionType, sendBotMessage } from "@api/Commands";
import { findOption } from "@api/Commands/commandHelpers";
import { ApplicationCommandInputType } from "@api/Commands/types";
import { Devs } from "@utils/constants";
import definePlugin from "@utils/types";
import { findByCode, findByProps } from "@webpack";
const DRAFT_TYPE = 0;
export default definePlugin({
name: "CorruptMp4s",
description: "Create corrupt mp4s with extremely high or negative duration",
authors: [Devs.Ven],
dependencies: ["CommandsAPI"],
commands: [{
name: "corrupt",
description: "Create a corrupt mp4 with extremely high or negative duration",
inputType: ApplicationCommandInputType.BUILT_IN,
options: [
{
name: "mp4",
description: "the video to corrupt",
type: ApplicationCommandOptionType.ATTACHMENT,
required: true
},
{
name: "kind",
description: "the kind of corruption",
type: ApplicationCommandOptionType.STRING,
choices: [
{
name: "infinite",
value: "infinite",
label: "Very high duration"
},
{
name: "negative",
value: "negative",
label: "Negative duration"
}
]
}
],
execute: async (args, ctx) => {
const UploadStore = findByProps("getUploads");
const upload = UploadStore.getUploads(ctx.channel.id, DRAFT_TYPE)[0];
const video = upload?.item?.file as File | undefined;
if (video?.type !== "video/mp4")
return void sendBotMessage(ctx.channel.id, {
content: "Please upload a mp4 file"
});
const corruption = findOption<string>(args, "kind", "infinite");
const buf = new Uint8Array(await video.arrayBuffer());
let found = false;
// adapted from https://github.com/GeopJr/exorcism/blob/c9a12d77ccbcb49c987b385eafae250906efc297/src/App.svelte#L41-L48
for (let i = 0; i < buf.length; i++) {
if (buf[i] === 0x6d && buf[i + 1] === 0x76 && buf[i + 2] === 0x68 && buf[i + 3] === 0x64) {
let start = i + 18;
buf[start++] = 0x00;
buf[start++] = 0x01;
buf[start++] = corruption === "negative" ? 0xff : 0x7f;
buf[start++] = 0xff;
buf[start++] = 0xff;
buf[start++] = corruption === "negative" ? 0xf0 : 0xff;
found = true;
break;
}
}
if (!found) {
return void sendBotMessage(ctx.channel.id, {
content: "Could not find signature. Is this even a mp4?"
});
}
const newName = video.name.replace(/\.mp4$/i, ".corrupt.mp4");
const promptToUpload = findByCode("UPLOAD_FILE_LIMIT_ERROR");
const file = new File([buf], newName, { type: "video/mp4" });
setTimeout(() => promptToUpload([file], ctx.channel, DRAFT_TYPE), 10);
}
}]
});

165
src/plugins/crashHandler.ts Normal file
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 { showNotification } from "@api/Notifications";
import { definePluginSettings } from "@api/settings";
import { Devs } from "@utils/constants";
import Logger from "@utils/Logger";
import { closeAllModals } from "@utils/modal";
import definePlugin, { OptionType } from "@utils/types";
import { maybePromptToUpdate } from "@utils/updater";
import { FluxDispatcher, NavigationRouter } from "@webpack/common";
import type { ReactElement } from "react";
const CrashHandlerLogger = new Logger("CrashHandler");
const settings = definePluginSettings({
attemptToPreventCrashes: {
type: OptionType.BOOLEAN,
description: "Whether to attempt to prevent Discord crashes.",
default: true
},
attemptToNavigateToHome: {
type: OptionType.BOOLEAN,
description: "Whether to attempt to navigate to the home when preventing Discord crashes.",
default: false
}
});
let crashCount: number = 0;
let lastCrashTimestamp: number = 0;
let shouldAttemptNextHandle = false;
export default definePlugin({
name: "CrashHandler",
description: "Utility plugin for handling and possibly recovering from Crashes without a restart",
authors: [Devs.Nuckyz],
enabledByDefault: true,
popAllModals: undefined as (() => void) | undefined,
settings,
patches: [
{
find: ".Messages.ERRORS_UNEXPECTED_CRASH",
replacement: {
match: /(?=this\.setState\()/,
replace: "$self.handleCrash(this)||"
}
},
{
find: 'dispatch({type:"MODAL_POP_ALL"})',
replacement: {
match: /"MODAL_POP_ALL".+?};(?<=(\i)=function.+?)/,
replace: (m, popAll) => `${m}$self.popAllModals=${popAll};`
}
}
],
handleCrash(_this: ReactElement & { forceUpdate: () => void; }) {
if (Date.now() - lastCrashTimestamp <= 1_000 && !shouldAttemptNextHandle) return true;
shouldAttemptNextHandle = false;
if (++crashCount > 5) {
try {
showNotification({
color: "#eed202",
title: "Discord has crashed!",
body: "Awn :( Discord has crashed more than five times, not attempting to recover.",
noPersist: true,
});
} catch { }
lastCrashTimestamp = Date.now();
return false;
}
setTimeout(() => crashCount--, 60_000);
try {
if (crashCount === 1) maybePromptToUpdate("Uh oh, Discord has just crashed... but good news, there is a Vencord update available that might fix this issue! Would you like to update now?", true);
if (settings.store.attemptToPreventCrashes) {
this.handlePreventCrash(_this);
return true;
}
return false;
} catch (err) {
CrashHandlerLogger.error("Failed to handle crash", err);
return false;
} finally {
lastCrashTimestamp = Date.now();
}
},
handlePreventCrash(_this: ReactElement & { forceUpdate: () => void; }) {
if (Date.now() - lastCrashTimestamp >= 1_000) {
try {
showNotification({
color: "#eed202",
title: "Discord has crashed!",
body: "Attempting to recover...",
noPersist: true,
});
} catch { }
}
try {
FluxDispatcher.dispatch({ type: "CONTEXT_MENU_CLOSE" });
} catch (err) {
CrashHandlerLogger.debug("Failed to close open context menu.", err);
}
try {
this.popAllModals?.();
} catch (err) {
CrashHandlerLogger.debug("Failed to close old modals.", err);
}
try {
closeAllModals();
} catch (err) {
CrashHandlerLogger.debug("Failed to close all open modals.", err);
}
try {
FluxDispatcher.dispatch({ type: "USER_PROFILE_MODAL_CLOSE" });
} catch (err) {
CrashHandlerLogger.debug("Failed to close user popout.", err);
}
try {
FluxDispatcher.dispatch({ type: "LAYER_POP_ALL" });
} catch (err) {
CrashHandlerLogger.debug("Failed to pop all layers.", err);
}
if (settings.store.attemptToNavigateToHome) {
try {
NavigationRouter.transitionTo("/channels/@me");
} catch (err) {
CrashHandlerLogger.debug("Failed to navigate to home", err);
}
}
try {
shouldAttemptNextHandle = true;
_this.forceUpdate();
} catch (err) {
CrashHandlerLogger.debug("Failed to update crash handler component.", err);
}
}
});

View File

@ -19,6 +19,7 @@
import { definePluginSettings } from "@api/settings";
import { Link } from "@components/Link";
import { Devs } from "@utils/constants";
import { isTruthy } from "@utils/guards";
import { useAwaiter } from "@utils/misc";
import definePlugin, { OptionType } from "@utils/types";
import { filters, findByCodeLazy, findByPropsLazy, mapMangledModuleLazy } from "@webpack";
@ -56,11 +57,11 @@ interface ActivityAssets {
}
interface Activity {
state: string;
state?: string;
details?: string;
timestamps?: {
start?: Number;
end?: Number;
start?: number;
end?: number;
};
assets?: ActivityAssets;
buttons?: Array<string>;
@ -70,7 +71,7 @@ interface Activity {
button_urls?: Array<string>;
};
type: ActivityType;
flags: Number;
flags: number;
}
enum ActivityType {
@ -93,13 +94,13 @@ const numOpt = (description: string) => ({
onChange: setRpc
}) as const;
const choice = (label: string, value: any, _default?: Boolean) => ({
const choice = (label: string, value: any, _default?: boolean) => ({
label,
value,
default: _default
}) as const;
const choiceOpt = (description: string, options) => ({
const choiceOpt = <T,>(description: string, options: T) => ({
type: OptionType.SELECT,
description,
onChange: setRpc,
@ -173,13 +174,13 @@ async function createActivity(): Promise<Activity | undefined> {
activity.buttons = [
buttonOneText,
buttonTwoText
].filter(Boolean);
].filter(isTruthy);
activity.metadata = {
button_urls: [
buttonOneURL,
buttonTwoURL
].filter(Boolean)
].filter(isTruthy)
};
}
@ -206,17 +207,16 @@ async function createActivity(): Promise<Activity | undefined> {
delete activity[k];
}
// WHAT DO YOU WANT FROM ME
// eslint-disable-next-line consistent-return
return activity;
}
async function setRpc(disable?: Boolean) {
async function setRpc(disable?: boolean) {
const activity: Activity | undefined = await createActivity();
FluxDispatcher.dispatch({
type: "LOCAL_ACTIVITY_UPDATE",
activity: !disable ? activity : {}
activity: !disable ? activity : null,
socketId: "CustomRPC",
});
}

View File

@ -0,0 +1,270 @@
/*
* Vencord, a modification for Discord's desktop app
* Copyright (c) 2023 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 { addContextMenuPatch, NavContextMenuPatchCallback, removeContextMenuPatch } from "@api/ContextMenu";
import { showNotification } from "@api/Notifications";
import { definePluginSettings } from "@api/settings";
import { Devs } from "@utils/constants";
import Logger from "@utils/Logger";
import { canonicalizeMatch, canonicalizeReplace } from "@utils/patches";
import definePlugin, { OptionType } from "@utils/types";
import { filters, findAll, search } from "@webpack";
import { Menu } from "@webpack/common";
const PORT = 8485;
const NAV_ID = "dev-companion-reconnect";
const logger = new Logger("DevCompanion");
let socket: WebSocket | undefined;
type Node = StringNode | RegexNode | FunctionNode;
interface StringNode {
type: "string";
value: string;
}
interface RegexNode {
type: "regex";
value: {
pattern: string;
flags: string;
};
}
interface FunctionNode {
type: "function";
value: string;
}
interface PatchData {
find: string;
replacement: {
match: StringNode | RegexNode;
replace: StringNode | FunctionNode;
}[];
}
interface FindData {
type: string;
args: Array<StringNode | FunctionNode>;
}
const settings = definePluginSettings({
notifyOnAutoConnect: {
description: "Whether to notify when Dev Companion has automatically connected.",
type: OptionType.BOOLEAN,
default: true
}
});
function parseNode(node: Node) {
switch (node.type) {
case "string":
return node.value;
case "regex":
return new RegExp(node.value.pattern, node.value.flags);
case "function":
// We LOVE remote code execution
// Safety: This comes from localhost only, which actually means we have less permissions than the source,
// since we're running in the browser sandbox, whereas the sender has host access
return (0, eval)(node.value);
default:
throw new Error("Unknown Node Type " + (node as any).type);
}
}
function initWs(isManual = false) {
let wasConnected = isManual;
let hasErrored = false;
const ws = socket = new WebSocket(`ws://localhost:${PORT}`);
ws.addEventListener("open", () => {
wasConnected = true;
logger.info("Connected to WebSocket");
(settings.store.notifyOnAutoConnect || isManual) && showNotification({
title: "Dev Companion Connected",
body: "Connected to WebSocket",
noPersist: true
});
});
ws.addEventListener("error", e => {
if (!wasConnected) return;
hasErrored = true;
logger.error("Dev Companion Error:", e);
showNotification({
title: "Dev Companion Error",
body: (e as ErrorEvent).message || "No Error Message",
color: "var(--status-danger, red)",
noPersist: true,
});
});
ws.addEventListener("close", e => {
if (!wasConnected || hasErrored) return;
logger.info("Dev Companion Disconnected:", e.code, e.reason);
showNotification({
title: "Dev Companion Disconnected",
body: e.reason || "No Reason provided",
color: "var(--status-danger, red)",
noPersist: true,
});
});
ws.addEventListener("message", e => {
try {
var { nonce, type, data } = JSON.parse(e.data);
} catch (err) {
logger.error("Invalid JSON:", err, "\n" + e.data);
return;
}
function reply(error?: string) {
const data = { nonce, ok: !error } as Record<string, unknown>;
if (error) data.error = error;
ws.send(JSON.stringify(data));
}
logger.info("Received Message:", type, "\n", data);
switch (type) {
case "testPatch": {
const { find, replacement } = data as PatchData;
const candidates = search(find);
const keys = Object.keys(candidates);
if (keys.length !== 1)
return reply("Expected exactly one 'find' matches, found " + keys.length);
const mod = candidates[keys[0]];
let src = String(mod.original ?? mod).replaceAll("\n", "");
if (src.startsWith("function(")) {
src = "0," + src;
}
let i = 0;
for (const { match, replace } of replacement) {
i++;
try {
const matcher = canonicalizeMatch(parseNode(match));
const replacement = canonicalizeReplace(parseNode(replace), "PlaceHolderPluginName");
const newSource = src.replace(matcher, replacement as string);
if (src === newSource) throw "Had no effect";
Function(newSource);
src = newSource;
} catch (err) {
return reply(`Replacement ${i} failed: ${err}`);
}
}
reply();
break;
}
case "testFind": {
const { type, args } = data as FindData;
try {
var parsedArgs = args.map(parseNode);
} catch (err) {
return reply("Failed to parse args: " + err);
}
try {
let results: any[];
switch (type.replace("find", "").replace("Lazy", "")) {
case "":
results = findAll(parsedArgs[0]);
break;
case "ByProps":
results = findAll(filters.byProps(...parsedArgs));
break;
case "Store":
results = findAll(filters.byStoreName(parsedArgs[0]));
break;
case "ByCode":
results = findAll(filters.byCode(...parsedArgs));
break;
case "ModuleId":
results = Object.keys(search(parsedArgs[0]));
break;
default:
return reply("Unknown Find Type " + type);
}
const uniqueResultsCount = new Set(results).size;
if (uniqueResultsCount === 0) throw "No results";
if (uniqueResultsCount > 1) throw "Found more than one result! Make this filter more specific";
} catch (err) {
return reply("Failed to find: " + err);
}
reply();
break;
}
default:
reply("Unknown Type " + type);
break;
}
});
}
const contextMenuPatch: NavContextMenuPatchCallback = children => () => {
children.unshift(
<Menu.MenuItem
id={NAV_ID}
label="Reconnect Dev Companion"
action={() => {
socket?.close(1000, "Reconnecting");
initWs(true);
}}
/>
);
};
export default definePlugin({
name: "DevCompanion",
description: "Dev Companion Plugin",
authors: [Devs.Ven],
settings,
start() {
initWs();
addContextMenuPatch("user-settings-cog", contextMenuPatch);
},
stop() {
socket?.close(1000, "Plugin Stopped");
socket = void 0;
removeContextMenuPatch("user-settings-cog", contextMenuPatch);
}
});

View File

@ -27,9 +27,9 @@ export default definePlugin({
{
find: ".Messages.BOT_CALL_IDLE_DISCONNECT",
replacement: {
match: /function (?<functionName>.{1,3})\(\){.{1,100}\.Messages\.BOT_CALL_IDLE_DISCONNECT.+?}}/,
replace: "function $<functionName>(){}",
},
},
],
match: /(?<=function \i\(\){)(?=.{1,100}\.Messages\.BOT_CALL_IDLE_DISCONNECT)/,
replace: "return;"
}
}
]
});

View File

@ -16,15 +16,15 @@
* along with this program. If not, see <https://www.gnu.org/licenses/>.
*/
import { migratePluginSettings, Settings } from "@api/settings";
import { addContextMenuPatch, findGroupChildrenByChildId, NavContextMenuPatchCallback, removeContextMenuPatch } from "@api/ContextMenu";
import { CheckedTextInput } from "@components/CheckedTextInput";
import { Devs } from "@utils/constants";
import Logger from "@utils/Logger";
import { makeLazy } from "@utils/misc";
import { Margins } from "@utils/margins";
import { ModalContent, ModalHeader, ModalRoot, openModal } from "@utils/modal";
import definePlugin from "@utils/types";
import { findByCodeLazy, findByPropsLazy } from "@webpack";
import { Forms, GuildStore, Margins, Menu, PermissionStore, React, Toasts, Tooltip, UserStore } from "@webpack/common";
import { Forms, GuildStore, Menu, PermissionStore, React, Toasts, Tooltip, UserStore } from "@webpack/common";
const MANAGE_EMOJIS_AND_STICKERS = 1n << 30n;
@ -96,7 +96,7 @@ function CloneModal({ id, name: emojiName, isAnimated }: { id: string; name: str
return (
<>
<Forms.FormTitle className={Margins.marginTop20}>Custom Name</Forms.FormTitle>
<Forms.FormTitle className={Margins.top20}>Custom Name</Forms.FormTitle>
<CheckedTextInput
value={name}
onChange={setName}
@ -175,50 +175,12 @@ function CloneModal({ id, name: emojiName, isAnimated }: { id: string; name: str
);
}
migratePluginSettings("EmoteCloner", "EmoteYoink");
export default definePlugin({
name: "EmoteCloner",
description: "Adds a Clone context menu item to emotes to clone them your own server",
authors: [Devs.Ven],
dependencies: ["MenuItemDeobfuscatorAPI"],
patches: [{
// Literally copy pasted from ReverseImageSearch lol
find: "open-native-link",
replacement: {
match: /id:"open-native-link".{0,200}\(\{href:(.{0,3}),.{0,200}\},"open-native-link"\)/,
replace: "$&,$self.makeMenu(arguments[2])"
},
},
// Also copy pasted from Reverse Image Search
{
// pass the target to the open link menu so we can grab its data
find: "REMOVE_ALL_REACTIONS_CONFIRM_BODY,",
predicate: makeLazy(() => !Settings.plugins.ReverseImageSearch.enabled),
noWarn: true,
replacement: {
match: /(?<props>.).onHeightUpdate.{0,200}(.)=(.)=.\.url;.+?\(null!=\3\?\3:\2[^)]+/,
replace: "$&,$<props>.target"
}
}],
makeMenu(htmlElement: HTMLImageElement) {
if (htmlElement?.dataset.type !== "emoji")
return null;
const { id } = htmlElement.dataset;
const name = htmlElement.alt.match(/:(.*)(?:~\d+)?:/)?.[1];
if (!name || !id)
return null;
const isAnimated = new URL(htmlElement.src).pathname.endsWith(".gif");
return <Menu.MenuItem
function buildMenuItem(id: string, name: string, isAnimated: boolean) {
return (
<Menu.MenuItem
id="emote-cloner"
key="emote-cloner"
label="Clone"
label="Clone Emote"
action={() =>
openModal(modalProps => (
<ModalRoot {...modalProps}>
@ -240,7 +202,48 @@ export default definePlugin({
</ModalRoot>
))
}
>
</Menu.MenuItem>;
/>
);
}
function isGifUrl(url: string) {
return new URL(url).pathname.endsWith(".gif");
}
const messageContextMenuPatch: NavContextMenuPatchCallback = (children, props) => () => {
const { favoriteableId, itemHref, itemSrc, favoriteableType } = props ?? {};
if (!favoriteableId || favoriteableType !== "emoji") return;
const match = props.message.content.match(RegExp(`<a?:(\\w+)(?:~\\d+)?:${favoriteableId}>|https://cdn\\.discordapp\\.com/emojis/${favoriteableId}\\.`));
if (!match) return;
const name = match[1] ?? "FakeNitroEmoji";
const group = findGroupChildrenByChildId("copy-link", children);
if (group) group.push(buildMenuItem(favoriteableId, name, isGifUrl(itemHref ?? itemSrc)));
};
const expressionPickerPatch: NavContextMenuPatchCallback = (children, props: { target: HTMLElement; }) => () => {
const { id, name, type } = props?.target?.dataset ?? {};
if (!id || !name || type !== "emoji") return;
const firstChild = props.target.firstChild as HTMLImageElement;
children.push(buildMenuItem(id, name, firstChild && isGifUrl(firstChild.src)));
};
export default definePlugin({
name: "EmoteCloner",
description: "Adds a Clone context menu item to emotes to clone them your own server",
authors: [Devs.Ven, Devs.Nuckyz],
start() {
addContextMenuPatch("message", messageContextMenuPatch);
addContextMenuPatch("expression-picker", expressionPickerPatch);
},
stop() {
removeContextMenuPatch("message", messageContextMenuPatch);
removeContextMenuPatch("expression-picker", expressionPickerPatch);
}
});

View File

@ -16,7 +16,7 @@
* along with this program. If not, see <https://www.gnu.org/licenses/>.
*/
import { Settings } from "@api/settings";
import { definePluginSettings } from "@api/settings";
import { Devs } from "@utils/constants";
import definePlugin, { OptionType } from "@utils/types";
import { findByPropsLazy } from "@webpack";
@ -24,49 +24,71 @@ import { Forms, React } from "@webpack/common";
const KbdStyles = findByPropsLazy("key", "removeBuildOverride");
const settings = definePluginSettings({
enableIsStaff: {
description: "Enable isStaff",
type: OptionType.BOOLEAN,
default: false,
restartNeeded: true
},
forceStagingBanner: {
description: "Whether to force Staging banner under user area.",
type: OptionType.BOOLEAN,
default: false,
restartNeeded: true
}
});
export default definePlugin({
name: "Experiments",
description: "Enable Access to Experiments in Discord!",
authors: [
Devs.Megu,
Devs.Ven,
Devs.Nickyux,
Devs.BanTheNons
Devs.BanTheNons,
Devs.Nuckyz
],
description: "Enable Access to Experiments in Discord!",
patches: [{
find: "Object.defineProperties(this,{isDeveloper",
replacement: {
match: /(?<={isDeveloper:\{[^}]+,get:function\(\)\{return )\w/,
replace: "true"
settings,
patches: [
{
find: "Object.defineProperties(this,{isDeveloper",
replacement: {
match: /(?<={isDeveloper:\{[^}]+?,get:function\(\)\{return )\w/,
replace: "true"
}
},
}, {
find: 'type:"user",revision',
replacement: {
match: /!(\w{1,3})&&"CONNECTION_OPEN".+?;/g,
replace: "$1=!0;"
{
find: 'type:"user",revision',
replacement: {
match: /!(\i)&&"CONNECTION_OPEN".+?;/g,
replace: "$1=!0;"
}
},
}, {
find: ".isStaff=function(){",
predicate: () => Settings.plugins.Experiments.enableIsStaff === true,
replacement: [
{
match: /return\s*(\w+)\.hasFlag\((.+?)\.STAFF\)}/,
replace: "return Vencord.Webpack.Common.UserStore.getCurrentUser().id===$1.id||$1.hasFlag($2.STAFF)}"
},
{
match: /hasFreePremium=function\(\){return this.isStaff\(\)\s*\|\|/,
replace: "hasFreePremium=function(){return ",
},
],
}],
options: {
enableIsStaff: {
description: "Enable isStaff (requires restart)",
type: OptionType.BOOLEAN,
default: false,
restartNeeded: true,
{
find: ".isStaff=function(){",
predicate: () => settings.store.enableIsStaff,
replacement: [
{
match: /return\s*?(\i)\.hasFlag\((\i\.\i)\.STAFF\)}/,
replace: (_, user, flags) => `return Vencord.Webpack.Common.UserStore.getCurrentUser().id===${user}.id||${user}.hasFlag(${flags}.STAFF)}`
},
{
match: /hasFreePremium=function\(\){return this.isStaff\(\)\s*?\|\|/,
replace: "hasFreePremium=function(){return ",
}
]
},
{
find: ".Messages.DEV_NOTICE_STAGING",
predicate: () => settings.store.forceStagingBanner,
replacement: {
match: /"staging"===window\.GLOBAL_ENV\.RELEASE_CHANNEL/,
replace: "true"
}
}
},
],
settingsAboutComponent: () => {
const isMacOS = navigator.platform.includes("Mac");

42
src/plugins/f8break.ts Normal file
View File

@ -0,0 +1,42 @@
/*
* Vencord, a modification for Discord's desktop app
* Copyright (c) 2023 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 { Devs } from "@utils/constants";
import definePlugin from "@utils/types";
export default definePlugin({
name: "F8Break",
description: "Pause the client when you press F8 with DevTools (+ breakpoints) open.",
authors: [Devs.lewisakura],
start() {
window.addEventListener("keydown", this.event);
},
stop() {
window.removeEventListener("keydown", this.event);
},
event(e: KeyboardEvent) {
if (e.code === "F8") {
// Hi! You've just paused the client. Pressing F8 in DevTools or in the main window will unpause it again.
// It's up to you on what to do, friend. Happy travels!
debugger;
}
}
});

View File

@ -1,359 +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 { addPreEditListener, addPreSendListener, removePreEditListener, removePreSendListener } from "@api/MessageEvents";
import { migratePluginSettings, Settings } from "@api/settings";
import { Devs } from "@utils/constants";
import { ApngDisposeOp, getGifEncoder, importApngJs } from "@utils/dependencies";
import definePlugin, { OptionType } from "@utils/types";
import { findByCodeLazy, findByPropsLazy } from "@webpack";
import { ChannelStore, PermissionStore, UserStore } from "@webpack/common";
const DRAFT_TYPE = 0;
const promptToUpload = findByCodeLazy("UPLOAD_FILE_LIMIT_ERROR");
const USE_EXTERNAL_EMOJIS = 1n << 18n;
const USE_EXTERNAL_STICKERS = 1n << 37n;
enum EmojiIntentions {
REACTION = 0,
STATUS = 1,
COMMUNITY_CONTENT = 2,
CHAT = 3,
GUILD_STICKER_RELATED_EMOJI = 4,
GUILD_ROLE_BENEFIT_EMOJI = 5,
COMMUNITY_CONTENT_ONLY = 6,
SOUNDBOARD = 7
}
interface BaseSticker {
available: boolean;
description: string;
format_type: number;
id: string;
name: string;
tags: string;
type: number;
}
interface GuildSticker extends BaseSticker {
guild_id: string;
}
interface DiscordSticker extends BaseSticker {
pack_id: string;
}
type Sticker = GuildSticker | DiscordSticker;
interface StickerPack {
id: string;
name: string;
sku_id: string;
description: string;
cover_sticker_id: string;
banner_asset_id: string;
stickers: Sticker[];
}
migratePluginSettings("FakeNitro", "NitroBypass");
export default definePlugin({
name: "FakeNitro",
authors: [Devs.Arjix, Devs.D3SOX, Devs.Ven, Devs.obscurity, Devs.captain],
description: "Allows you to stream in nitro quality, send fake emojis/stickers and use client themes.",
dependencies: ["MessageEventsAPI"],
patches: [
{
find: ".PREMIUM_LOCKED;",
predicate: () => Settings.plugins.FakeNitro.enableEmojiBypass === true,
replacement: [
{
match: /(?<=(?<intention>\i)=\i\.intention)/,
replace: ",fakeNitroIntention=$<intention>"
},
{
match: /(?<=\.(?:canUseEmojisEverywhere|canUseAnimatedEmojis)\(\i)(?=\))/g,
replace: ",fakeNitroIntention"
},
{
match: /(?<=&&!\i&&)!(?<canUseExternal>\i)(?=\)return \i\.\i\.DISALLOW_EXTERNAL;)/,
replace: `(!$<canUseExternal>&&![${EmojiIntentions.CHAT},${EmojiIntentions.GUILD_STICKER_RELATED_EMOJI}].includes(fakeNitroIntention))`
}
]
},
{
find: "canUseAnimatedEmojis:function",
predicate: () => Settings.plugins.FakeNitro.enableEmojiBypass === true,
replacement: {
match: /(?<=(?:canUseEmojisEverywhere|canUseAnimatedEmojis):function\((?<user>\i))\){(?<premiumCheck>.+?\))/g,
replace: `,fakeNitroIntention){$<premiumCheck>||fakeNitroIntention===undefined||[${EmojiIntentions.CHAT},${EmojiIntentions.GUILD_STICKER_RELATED_EMOJI}].includes(fakeNitroIntention)`
}
},
{
find: "canUseStickersEverywhere:function",
predicate: () => Settings.plugins.FakeNitro.enableStickerBypass === true,
replacement: {
match: /canUseStickersEverywhere:function\(.+?\{/,
replace: "$&return true;"
},
},
{
find: "\"SENDABLE\"",
predicate: () => Settings.plugins.FakeNitro.enableStickerBypass === true,
replacement: {
match: /(\w+)\.available\?/,
replace: "true?"
}
},
{
find: "canStreamHighQuality:function",
predicate: () => Settings.plugins.FakeNitro.enableStreamQualityBypass === true,
replacement: [
"canUseHighVideoUploadQuality",
"canStreamHighQuality",
"canStreamMidQuality"
].map(func => {
return {
match: new RegExp(`${func}:function\\(.+?\\{`),
replace: "$&return true;"
};
})
},
{
find: "STREAM_FPS_OPTION.format",
predicate: () => Settings.plugins.FakeNitro.enableStreamQualityBypass === true,
replacement: {
match: /(userPremiumType|guildPremiumTier):.{0,10}TIER_\d,?/g,
replace: ""
}
},
{
find: "canUseClientThemes:function",
replacement: {
match: /(?<=canUseClientThemes:function\(\i\){)/,
replace: "return true;"
}
}
],
options: {
enableEmojiBypass: {
description: "Allow sending fake emojis",
type: OptionType.BOOLEAN,
default: true,
restartNeeded: true,
},
emojiSize: {
description: "Size of the emojis when sending",
type: OptionType.SLIDER,
default: 48,
markers: [32, 48, 64, 128, 160, 256, 512],
},
enableStickerBypass: {
description: "Allow sending fake stickers",
type: OptionType.BOOLEAN,
default: true,
restartNeeded: true,
},
stickerSize: {
description: "Size of the stickers when sending",
type: OptionType.SLIDER,
default: 160,
markers: [32, 64, 128, 160, 256, 512],
},
enableStreamQualityBypass: {
description: "Allow streaming in nitro quality",
type: OptionType.BOOLEAN,
default: true,
restartNeeded: true,
}
},
get guildId() {
return window.location.href.split("channels/")[1].split("/")[0];
},
get canUseEmotes() {
return (UserStore.getCurrentUser().premiumType ?? 0) > 0;
},
get canUseStickers() {
return (UserStore.getCurrentUser().premiumType ?? 0) > 1;
},
hasPermissionToUseExternalEmojis(channelId: string) {
const channel = ChannelStore.getChannel(channelId);
if (!channel || channel.isDM() || channel.isGroupDM() || channel.isMultiUserDM()) return true;
return PermissionStore.can(USE_EXTERNAL_EMOJIS, channel);
},
hasPermissionToUseExternalStickers(channelId: string) {
const channel = ChannelStore.getChannel(channelId);
if (!channel || channel.isDM() || channel.isGroupDM() || channel.isMultiUserDM()) return true;
return PermissionStore.can(USE_EXTERNAL_STICKERS, channel);
},
getStickerLink(stickerId: string) {
return `https://media.discordapp.net/stickers/${stickerId}.png?size=${Settings.plugins.FakeNitro.stickerSize}`;
},
async sendAnimatedSticker(stickerLink: string, stickerId: string, channelId: string) {
const [{ parseURL }, {
GIFEncoder,
quantize,
applyPalette
}] = await Promise.all([importApngJs(), getGifEncoder()]);
const { frames, width, height } = await parseURL(stickerLink);
const gif = new GIFEncoder();
const resolution = Settings.plugins.FakeNitro.stickerSize;
const canvas = document.createElement("canvas");
canvas.width = resolution;
canvas.height = resolution;
const ctx = canvas.getContext("2d", {
willReadFrequently: true
})!;
const scale = resolution / Math.max(width, height);
ctx.scale(scale, scale);
let lastImg: HTMLImageElement | null = null;
for (const { left, top, width, height, disposeOp, img, delay } of frames) {
ctx.drawImage(img, left, top, width, height);
const { data } = ctx.getImageData(0, 0, resolution, resolution);
const palette = quantize(data, 256);
const index = applyPalette(data, palette);
gif.writeFrame(index, resolution, resolution, {
transparent: true,
palette,
delay,
});
if (disposeOp === ApngDisposeOp.BACKGROUND) {
ctx.clearRect(left, top, width, height);
} else if (disposeOp === ApngDisposeOp.PREVIOUS && lastImg) {
ctx.drawImage(lastImg, left, top, width, height);
}
lastImg = img;
}
gif.finish();
const file = new File([gif.bytesView()], `${stickerId}.gif`, { type: "image/gif" });
promptToUpload([file], ChannelStore.getChannel(channelId), DRAFT_TYPE);
},
start() {
const settings = Settings.plugins.FakeNitro;
if (!settings.enableEmojiBypass && !settings.enableStickerBypass) {
return;
}
const EmojiStore = findByPropsLazy("getCustomEmojiById");
const StickerStore = findByPropsLazy("getAllGuildStickers") as {
getPremiumPacks(): StickerPack[];
getAllGuildStickers(): Map<string, Sticker[]>;
getStickerById(id: string): Sticker | undefined;
};
function getWordBoundary(origStr: string, offset: number) {
return (!origStr[offset] || /\s/.test(origStr[offset])) ? "" : " ";
}
this.preSend = addPreSendListener((channelId, messageObj, extra) => {
const { guildId } = this;
stickerBypass: {
if (!settings.enableStickerBypass)
break stickerBypass;
const sticker = StickerStore.getStickerById(extra?.stickerIds?.[0]!);
if (!sticker)
break stickerBypass;
if (sticker.available !== false && ((this.canUseStickers && this.hasPermissionToUseExternalStickers(channelId)) || (sticker as GuildSticker)?.guild_id === guildId))
break stickerBypass;
let link = this.getStickerLink(sticker.id);
if (sticker.format_type === 2) {
this.sendAnimatedSticker(this.getStickerLink(sticker.id), sticker.id, channelId);
return { cancel: true };
} else {
if ("pack_id" in sticker) {
const packId = sticker.pack_id === "847199849233514549"
// Discord moved these stickers into a different pack at some point, but
// Distok still uses the old id
? "749043879713701898"
: sticker.pack_id;
link = `https://distok.top/stickers/${packId}/${sticker.id}.gif`;
}
delete extra.stickerIds;
messageObj.content += " " + link;
}
}
if ((!this.canUseEmotes || !this.hasPermissionToUseExternalEmojis(channelId)) && settings.enableEmojiBypass) {
for (const emoji of messageObj.validNonShortcutEmojis) {
if (!emoji.require_colons) continue;
if (emoji.guildId === guildId && !emoji.animated) continue;
const emojiString = `<${emoji.animated ? "a" : ""}:${emoji.originalName || emoji.name}:${emoji.id}>`;
const url = emoji.url.replace(/\?size=\d+/, `?size=${Settings.plugins.FakeNitro.emojiSize}`);
messageObj.content = messageObj.content.replace(emojiString, (match, offset, origStr) => {
return `${getWordBoundary(origStr, offset - 1)}${url}${getWordBoundary(origStr, offset + match.length)}`;
});
}
}
return { cancel: false };
});
this.preEdit = addPreEditListener((channelId, __, messageObj) => {
if (this.canUseEmotes && this.hasPermissionToUseExternalEmojis(channelId)) return;
const { guildId } = this;
for (const [emojiStr, _, emojiId] of messageObj.content.matchAll(/(?<!\\)<a?:(\w+):(\d+)>/ig)) {
const emoji = EmojiStore.getCustomEmojiById(emojiId);
if (emoji == null || (emoji.guildId === guildId && !emoji.animated)) continue;
if (!emoji.require_colons) continue;
const url = emoji.url.replace(/\?size=\d+/, `?size=${Settings.plugins.FakeNitro.emojiSize}`);
messageObj.content = messageObj.content.replace(emojiStr, (match, offset, origStr) => {
return `${getWordBoundary(origStr, offset - 1)}${url}${getWordBoundary(origStr, offset + match.length)}`;
});
}
});
},
stop() {
removePreSendListener(this.preSend);
removePreEditListener(this.preEdit);
}
});

717
src/plugins/fakeNitro.tsx Normal file
View File

@ -0,0 +1,717 @@
/*
* 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 { addPreEditListener, addPreSendListener, removePreEditListener, removePreSendListener } from "@api/MessageEvents";
import { definePluginSettings, migratePluginSettings, Settings } from "@api/settings";
import { Devs } from "@utils/constants";
import { ApngBlendOp, ApngDisposeOp, getGifEncoder, importApngJs } from "@utils/dependencies";
import { getCurrentGuild } from "@utils/discord";
import { proxyLazy } from "@utils/proxyLazy";
import definePlugin, { OptionType } from "@utils/types";
import { findByCodeLazy, findByPropsLazy, findLazy, findStoreLazy } from "@webpack";
import { ChannelStore, FluxDispatcher, Parser, PermissionStore, UserStore } from "@webpack/common";
import type { Message } from "discord-types/general";
import type { ReactNode } from "react";
const DRAFT_TYPE = 0;
const promptToUpload = findByCodeLazy("UPLOAD_FILE_LIMIT_ERROR");
const UserSettingsProtoStore = findStoreLazy("UserSettingsProtoStore");
const PreloadedUserSettingsProtoHandler = findLazy(m => m.ProtoClass?.typeName === "discord_protos.discord_users.v1.PreloadedUserSettings");
const ReaderFactory = findByPropsLazy("readerFactory");
const StickerStore = findStoreLazy("StickersStore") as {
getPremiumPacks(): StickerPack[];
getAllGuildStickers(): Map<string, Sticker[]>;
getStickerById(id: string): Sticker | undefined;
};
const EmojiStore = findStoreLazy("EmojiStore");
function searchProtoClass(localName: string, parentProtoClass: any) {
if (!parentProtoClass) return;
const field = parentProtoClass.fields.find(field => field.localName === localName);
if (!field) return;
const getter: any = Object.values(field).find(value => typeof value === "function");
return getter?.();
}
const AppearanceSettingsProto = proxyLazy(() => searchProtoClass("appearance", PreloadedUserSettingsProtoHandler.ProtoClass));
const ClientThemeSettingsProto = proxyLazy(() => searchProtoClass("clientThemeSettings", AppearanceSettingsProto));
const USE_EXTERNAL_EMOJIS = 1n << 18n;
const USE_EXTERNAL_STICKERS = 1n << 37n;
enum EmojiIntentions {
REACTION = 0,
STATUS = 1,
COMMUNITY_CONTENT = 2,
CHAT = 3,
GUILD_STICKER_RELATED_EMOJI = 4,
GUILD_ROLE_BENEFIT_EMOJI = 5,
COMMUNITY_CONTENT_ONLY = 6,
SOUNDBOARD = 7
}
interface BaseSticker {
available: boolean;
description: string;
format_type: number;
id: string;
name: string;
tags: string;
type: number;
}
interface GuildSticker extends BaseSticker {
guild_id: string;
}
interface DiscordSticker extends BaseSticker {
pack_id: string;
}
type Sticker = GuildSticker | DiscordSticker;
interface StickerPack {
id: string;
name: string;
sku_id: string;
description: string;
cover_sticker_id: string;
banner_asset_id: string;
stickers: Sticker[];
}
const fakeNitroEmojiRegex = /\/emojis\/(\d+?)\.(png|webp|gif)/;
const fakeNitroStickerRegex = /\/stickers\/(\d+?)\./;
const fakeNitroGifStickerRegex = /\/attachments\/\d+?\/\d+?\/(\d+?)\.gif/;
const settings = definePluginSettings({
enableEmojiBypass: {
description: "Allow sending fake emojis",
type: OptionType.BOOLEAN,
default: true,
restartNeeded: true
},
emojiSize: {
description: "Size of the emojis when sending",
type: OptionType.SLIDER,
default: 48,
markers: [32, 48, 64, 128, 160, 256, 512]
},
transformEmojis: {
description: "Whether to transform fake emojis into real ones",
type: OptionType.BOOLEAN,
default: true,
restartNeeded: true
},
enableStickerBypass: {
description: "Allow sending fake stickers",
type: OptionType.BOOLEAN,
default: true,
restartNeeded: true
},
stickerSize: {
description: "Size of the stickers when sending",
type: OptionType.SLIDER,
default: 160,
markers: [32, 64, 128, 160, 256, 512]
},
transformStickers: {
description: "Whether to transform fake stickers into real ones",
type: OptionType.BOOLEAN,
default: true,
restartNeeded: true
},
transformCompoundSentence: {
description: "Whether to transform fake stickers and emojis in compound sentences (sentences with more content than just the fake emoji or sticker link)",
type: OptionType.BOOLEAN,
default: false
},
enableStreamQualityBypass: {
description: "Allow streaming in nitro quality",
type: OptionType.BOOLEAN,
default: true,
restartNeeded: true
}
});
migratePluginSettings("FakeNitro", "NitroBypass");
export default definePlugin({
name: "FakeNitro",
authors: [Devs.Arjix, Devs.D3SOX, Devs.Ven, Devs.obscurity, Devs.captain, Devs.Nuckyz, Devs.AutumnVN],
description: "Allows you to stream in nitro quality, send fake emojis/stickers and use client themes.",
dependencies: ["MessageEventsAPI"],
settings,
patches: [
{
find: ".PREMIUM_LOCKED;",
predicate: () => settings.store.enableEmojiBypass,
replacement: [
{
match: /(?<=(\i)=\i\.intention)/,
replace: (_, intention) => `,fakeNitroIntention=${intention}`
},
{
match: /\.(?:canUseEmojisEverywhere|canUseAnimatedEmojis)\(\i(?=\))/g,
replace: '$&,typeof fakeNitroIntention!=="undefined"?fakeNitroIntention:void 0'
},
{
match: /(&&!\i&&)!(\i)(?=\)return \i\.\i\.DISALLOW_EXTERNAL;)/,
replace: (_, rest, canUseExternal) => `${rest}(!${canUseExternal}&&(typeof fakeNitroIntention==="undefined"||![${EmojiIntentions.CHAT},${EmojiIntentions.GUILD_STICKER_RELATED_EMOJI}].includes(fakeNitroIntention)))`
}
]
},
{
find: "canUseAnimatedEmojis:function",
predicate: () => settings.store.enableEmojiBypass,
replacement: {
match: /((?:canUseEmojisEverywhere|canUseAnimatedEmojis):function\(\i)\){(.+?\))/g,
replace: (_, rest, premiumCheck) => `${rest},fakeNitroIntention){${premiumCheck}||fakeNitroIntention==null||[${EmojiIntentions.CHAT},${EmojiIntentions.GUILD_STICKER_RELATED_EMOJI}].includes(fakeNitroIntention)`
}
},
{
find: "canUseStickersEverywhere:function",
predicate: () => settings.store.enableStickerBypass,
replacement: {
match: /canUseStickersEverywhere:function\(\i\){/,
replace: "$&return true;"
},
},
{
find: "\"SENDABLE\"",
predicate: () => settings.store.enableStickerBypass,
replacement: {
match: /(\w+)\.available\?/,
replace: "true?"
}
},
{
find: "canStreamHighQuality:function",
predicate: () => settings.store.enableStreamQualityBypass,
replacement: [
"canUseHighVideoUploadQuality",
"canStreamHighQuality",
"canStreamMidQuality"
].map(func => {
return {
match: new RegExp(`${func}:function\\(\\i\\){`),
replace: "$&return true;"
};
})
},
{
find: "STREAM_FPS_OPTION.format",
predicate: () => settings.store.enableStreamQualityBypass,
replacement: {
match: /(userPremiumType|guildPremiumTier):.{0,10}TIER_\d,?/g,
replace: ""
}
},
{
find: "canUseClientThemes:function",
replacement: {
match: /canUseClientThemes:function\(\i\){/,
replace: "$&return true;"
}
},
{
find: '.displayName="UserSettingsProtoStore"',
replacement: [
{
match: /CONNECTION_OPEN:function\((\i)\){/,
replace: (m, props) => `${m}$self.handleProtoChange(${props}.userSettingsProto,${props}.user);`
},
{
match: /=(\i)\.local;/,
replace: (m, props) => `${m}${props}.local||$self.handleProtoChange(${props}.settings.proto);`
}
]
},
{
find: "updateTheme:function",
replacement: {
match: /(function \i\(\i\){var (\i)=\i\.backgroundGradientPresetId.+?)(\i\.\i\.updateAsync.+?theme=(.+?);.+?\),\i\))/,
replace: (_, rest, backgroundGradientPresetId, originalCall, theme) => `${rest}$self.handleGradientThemeSelect(${backgroundGradientPresetId},${theme},()=>${originalCall});`
}
},
{
find: '["strong","em","u","text","inlineCode","s","spoiler"]',
replacement: [
{
predicate: () => settings.store.transformEmojis,
match: /1!==(\i)\.length\|\|1!==\i\.length/,
replace: (m, content) => `${m}||$self.shouldKeepEmojiLink(${content}[0])`
},
{
predicate: () => settings.store.transformEmojis || settings.store.transformStickers,
match: /(?=return{hasSpoilerEmbeds:\i,content:(\i)})/,
replace: (_, content) => `${content}=$self.patchFakeNitroEmojisOrRemoveStickersLinks(${content},arguments[2]?.formatInline);`
}
]
},
{
find: "renderEmbeds=function",
replacement: [
{
predicate: () => settings.store.transformEmojis || settings.store.transformStickers,
match: /(renderEmbeds=function\((\i)\){)(.+?embeds\.map\(\(function\((\i)\){)/,
replace: (_, rest1, message, rest2, embed) => `${rest1}const fakeNitroMessage=${message};${rest2}if($self.shouldIgnoreEmbed(${embed},fakeNitroMessage))return null;`
},
{
predicate: () => settings.store.transformStickers,
match: /renderStickersAccessories=function\((\i)\){var (\i)=\(0,\i\.\i\)\(\i\),/,
replace: (m, message, stickers) => `${m}${stickers}=$self.patchFakeNitroStickers(${stickers},${message}),`
},
{
predicate: () => settings.store.transformStickers,
match: /renderAttachments=function\(\i\){var (\i)=\i.attachments.+?;/,
replace: (m, attachments) => `${m}${attachments}=$self.filterAttachments(${attachments});`
}
]
},
{
find: ".STICKER_IN_MESSAGE_HOVER,",
predicate: () => settings.store.transformStickers,
replacement: [
{
match: /var (\i)=\i\.renderableSticker,.{0,50}closePopout.+?channel:\i,closePopout:\i,/,
replace: (m, renderableSticker) => `${m}renderableSticker:${renderableSticker},`
},
{
match: /(emojiSection.{0,50}description:)(\i)(?<=(\i)\.sticker,.+?)(?=,)/,
replace: (_, rest, reactNode, props) => `${rest}$self.addFakeNotice("STICKER",${reactNode},!!${props}.renderableSticker?.fake)`
}
]
},
{
find: ".Messages.EMOJI_POPOUT_PREMIUM_JOINED_GUILD_DESCRIPTION",
predicate: () => settings.store.transformEmojis,
replacement: {
match: /((\i)=\i\.node,\i=\i\.emojiSourceDiscoverableGuild)(.+?return )(.{0,450}Messages\.EMOJI_POPOUT_PREMIUM_JOINED_GUILD_DESCRIPTION.+?}\))/,
replace: (_, rest1, node, rest2, reactNode) => `${rest1},fakeNitroNode=${node}${rest2}$self.addFakeNotice("EMOJI",${reactNode},fakeNitroNode.fake)`
}
}
],
get guildId() {
return getCurrentGuild()?.id;
},
get canUseEmotes() {
return (UserStore.getCurrentUser().premiumType ?? 0) > 0;
},
get canUseStickers() {
return (UserStore.getCurrentUser().premiumType ?? 0) > 1;
},
handleProtoChange(proto: any, user: any) {
if ((!proto.appearance && !AppearanceSettingsProto) || !UserSettingsProtoStore) return;
const premiumType: number = user?.premium_type ?? UserStore?.getCurrentUser()?.premiumType ?? 0;
if (premiumType !== 2) {
proto.appearance ??= AppearanceSettingsProto.create();
if (UserSettingsProtoStore.settings.appearance?.theme != null) {
proto.appearance.theme = UserSettingsProtoStore.settings.appearance.theme;
}
if (UserSettingsProtoStore.settings.appearance?.clientThemeSettings?.backgroundGradientPresetId?.value != null && ClientThemeSettingsProto) {
const clientThemeSettingsDummyProto = ClientThemeSettingsProto.create({
backgroundGradientPresetId: {
value: UserSettingsProtoStore.settings.appearance.clientThemeSettings.backgroundGradientPresetId.value
}
});
proto.appearance.clientThemeSettings ??= clientThemeSettingsDummyProto;
proto.appearance.clientThemeSettings.backgroundGradientPresetId = clientThemeSettingsDummyProto.backgroundGradientPresetId;
}
}
},
handleGradientThemeSelect(backgroundGradientPresetId: number | undefined, theme: number, original: () => void) {
const premiumType = UserStore?.getCurrentUser()?.premiumType ?? 0;
if (premiumType === 2 || backgroundGradientPresetId == null) return original();
if (!AppearanceSettingsProto || !ClientThemeSettingsProto || !ReaderFactory) return;
const currentAppearanceProto = PreloadedUserSettingsProtoHandler.getCurrentValue().appearance;
const newAppearanceProto = currentAppearanceProto != null
? AppearanceSettingsProto.fromBinary(AppearanceSettingsProto.toBinary(currentAppearanceProto), ReaderFactory)
: AppearanceSettingsProto.create();
newAppearanceProto.theme = theme;
const clientThemeSettingsDummyProto = ClientThemeSettingsProto.create({
backgroundGradientPresetId: {
value: backgroundGradientPresetId
}
});
newAppearanceProto.clientThemeSettings ??= clientThemeSettingsDummyProto;
newAppearanceProto.clientThemeSettings.backgroundGradientPresetId = clientThemeSettingsDummyProto.backgroundGradientPresetId;
const proto = PreloadedUserSettingsProtoHandler.ProtoClass.create();
proto.appearance = newAppearanceProto;
FluxDispatcher.dispatch({
type: "USER_SETTINGS_PROTO_UPDATE",
local: true,
partial: true,
settings: {
type: 1,
proto
}
});
},
patchFakeNitroEmojisOrRemoveStickersLinks(content: Array<any>, inline: boolean) {
if (content.length > 1 && !settings.store.transformCompoundSentence) return content;
const newContent: Array<any> = [];
let nextIndex = content.length;
for (const element of content) {
if (element.props?.trusted == null) {
newContent.push(element);
continue;
}
if (settings.store.transformEmojis) {
const fakeNitroMatch = element.props.href.match(fakeNitroEmojiRegex);
if (fakeNitroMatch) {
let url: URL | null = null;
try {
url = new URL(element.props.href);
} catch { }
const emojiName = EmojiStore.getCustomEmojiById(fakeNitroMatch[1])?.name ?? url?.searchParams.get("name") ?? "FakeNitroEmoji";
newContent.push(Parser.defaultRules.customEmoji.react({
jumboable: !inline && content.length === 1,
animated: fakeNitroMatch[2] === "gif",
emojiId: fakeNitroMatch[1],
name: emojiName,
fake: true
}, void 0, { key: String(nextIndex++) }));
continue;
}
}
if (settings.store.transformStickers) {
if (fakeNitroStickerRegex.test(element.props.href)) continue;
const gifMatch = element.props.href.match(fakeNitroGifStickerRegex);
if (gifMatch) {
// There is no way to differentiate a regular gif attachment from a fake nitro animated sticker, so we check if the StickerStore contains the id of the fake sticker
if (StickerStore.getStickerById(gifMatch[1])) continue;
}
}
newContent.push(element);
}
const firstContent = newContent[0];
if (typeof firstContent === "string") newContent[0] = firstContent.trimStart();
return newContent;
},
patchFakeNitroStickers(stickers: Array<any>, message: Message) {
const itemsToMaybePush: Array<string> = [];
const contentItems = message.content.split(/\s/);
if (contentItems.length === 1 && !settings.store.transformCompoundSentence) itemsToMaybePush.push(contentItems[0]);
else itemsToMaybePush.push(...contentItems);
itemsToMaybePush.push(...message.attachments.filter(attachment => attachment.content_type === "image/gif").map(attachment => attachment.url));
for (const item of itemsToMaybePush) {
const imgMatch = item.match(fakeNitroStickerRegex);
if (imgMatch) {
let url: URL | null = null;
try {
url = new URL(item);
} catch { }
const stickerName = StickerStore.getStickerById(imgMatch[1])?.name ?? url?.searchParams.get("name") ?? "FakeNitroSticker";
stickers.push({
format_type: 1,
id: imgMatch[1],
name: stickerName,
fake: true
});
continue;
}
const gifMatch = item.match(fakeNitroGifStickerRegex);
if (gifMatch) {
if (!StickerStore.getStickerById(gifMatch[1])) continue;
const stickerName = StickerStore.getStickerById(gifMatch[1])?.name ?? "FakeNitroSticker";
stickers.push({
format_type: 2,
id: gifMatch[1],
name: stickerName,
fake: true
});
}
}
return stickers;
},
shouldIgnoreEmbed(embed: Message["embeds"][number], message: Message) {
if (message.content.split(/\s/).length > 1 && !settings.store.transformCompoundSentence) return false;
switch (embed.type) {
case "image": {
if (settings.store.transformEmojis) {
if (fakeNitroEmojiRegex.test(embed.url!)) return true;
}
if (settings.store.transformStickers) {
if (fakeNitroStickerRegex.test(embed.url!)) return true;
const gifMatch = embed.url!.match(fakeNitroGifStickerRegex);
if (gifMatch) {
// There is no way to differentiate a regular gif attachment from a fake nitro animated sticker, so we check if the StickerStore contains the id of the fake sticker
if (StickerStore.getStickerById(gifMatch[1])) return true;
}
}
break;
}
}
return false;
},
filterAttachments(attachments: Message["attachments"]) {
return attachments.filter(attachment => {
if (attachment.content_type !== "image/gif") return true;
const match = attachment.url.match(fakeNitroGifStickerRegex);
if (match) {
// There is no way to differentiate a regular gif attachment from a fake nitro animated sticker, so we check if the StickerStore contains the id of the fake sticker
if (StickerStore.getStickerById(match[1])) return false;
}
return true;
});
},
shouldKeepEmojiLink(link: any) {
return link.target && fakeNitroEmojiRegex.test(link.target);
},
addFakeNotice(type: "STICKER" | "EMOJI", node: Array<ReactNode>, fake: boolean) {
if (!fake) return node;
node = Array.isArray(node) ? node : [node];
switch (type) {
case "STICKER": {
node.push(" This is a FakeNitro sticker and renders like a real sticker only for you. Appears as a link to non-plugin users.");
return node;
}
case "EMOJI": {
node.push(" This is a FakeNitro emoji and renders like a real emoji only for you. Appears as a link to non-plugin users.");
return node;
}
}
},
hasPermissionToUseExternalEmojis(channelId: string) {
const channel = ChannelStore.getChannel(channelId);
if (!channel || channel.isDM() || channel.isGroupDM() || channel.isMultiUserDM()) return true;
return PermissionStore.can(USE_EXTERNAL_EMOJIS, channel);
},
hasPermissionToUseExternalStickers(channelId: string) {
const channel = ChannelStore.getChannel(channelId);
if (!channel || channel.isDM() || channel.isGroupDM() || channel.isMultiUserDM()) return true;
return PermissionStore.can(USE_EXTERNAL_STICKERS, channel);
},
getStickerLink(stickerId: string) {
return `https://media.discordapp.net/stickers/${stickerId}.png?size=${Settings.plugins.FakeNitro.stickerSize}`;
},
async sendAnimatedSticker(stickerLink: string, stickerId: string, channelId: string) {
const [{ parseURL }, {
GIFEncoder,
quantize,
applyPalette
}] = await Promise.all([importApngJs(), getGifEncoder()]);
const { frames, width, height } = await parseURL(stickerLink);
const gif = new GIFEncoder();
const resolution = Settings.plugins.FakeNitro.stickerSize;
const canvas = document.createElement("canvas");
canvas.width = resolution;
canvas.height = resolution;
const ctx = canvas.getContext("2d", {
willReadFrequently: true
})!;
const scale = resolution / Math.max(width, height);
ctx.scale(scale, scale);
let previousFrameData: ImageData;
for (const frame of frames) {
const { left, top, width, height, img, delay, blendOp, disposeOp } = frame;
previousFrameData = ctx.getImageData(left, top, width, height);
if (blendOp === ApngBlendOp.SOURCE) {
ctx.clearRect(left, top, width, height);
}
ctx.drawImage(img, left, top, width, height);
const { data } = ctx.getImageData(0, 0, resolution, resolution);
const palette = quantize(data, 256);
const index = applyPalette(data, palette);
gif.writeFrame(index, resolution, resolution, {
transparent: true,
palette,
delay
});
if (disposeOp === ApngDisposeOp.BACKGROUND) {
ctx.clearRect(left, top, width, height);
} else if (disposeOp === ApngDisposeOp.PREVIOUS) {
ctx.putImageData(previousFrameData, left, top);
}
}
gif.finish();
const file = new File([gif.bytesView()], `${stickerId}.gif`, { type: "image/gif" });
promptToUpload([file], ChannelStore.getChannel(channelId), DRAFT_TYPE);
},
start() {
const settings = Settings.plugins.FakeNitro;
if (!settings.enableEmojiBypass && !settings.enableStickerBypass) {
return;
}
function getWordBoundary(origStr: string, offset: number) {
return (!origStr[offset] || /\s/.test(origStr[offset])) ? "" : " ";
}
this.preSend = addPreSendListener((channelId, messageObj, extra) => {
const { guildId } = this;
stickerBypass: {
if (!settings.enableStickerBypass)
break stickerBypass;
const sticker = StickerStore.getStickerById(extra?.stickerIds?.[0]!);
if (!sticker)
break stickerBypass;
if (sticker.available !== false && ((this.canUseStickers && this.hasPermissionToUseExternalStickers(channelId)) || (sticker as GuildSticker)?.guild_id === guildId))
break stickerBypass;
let link = this.getStickerLink(sticker.id);
if (sticker.format_type === 2) {
this.sendAnimatedSticker(link, sticker.id, channelId);
return { cancel: true };
} else {
if ("pack_id" in sticker) {
const packId = sticker.pack_id === "847199849233514549"
// Discord moved these stickers into a different pack at some point, but
// Distok still uses the old id
? "749043879713701898"
: sticker.pack_id;
link = `https://distok.top/stickers/${packId}/${sticker.id}.gif`;
}
delete extra.stickerIds;
messageObj.content += " " + link + `&name=${encodeURIComponent(sticker.name)}`;
}
}
if ((!this.canUseEmotes || !this.hasPermissionToUseExternalEmojis(channelId)) && settings.enableEmojiBypass) {
for (const emoji of messageObj.validNonShortcutEmojis) {
if (!emoji.require_colons) continue;
if (emoji.guildId === guildId && !emoji.animated) continue;
const emojiString = `<${emoji.animated ? "a" : ""}:${emoji.originalName || emoji.name}:${emoji.id}>`;
const url = emoji.url.replace(/\?size=\d+/, "?" + new URLSearchParams({
size: Settings.plugins.FakeNitro.emojiSize,
name: encodeURIComponent(emoji.name)
}));
messageObj.content = messageObj.content.replace(emojiString, (match, offset, origStr) => {
return `${getWordBoundary(origStr, offset - 1)}${url}${getWordBoundary(origStr, offset + match.length)}`;
});
}
}
return { cancel: false };
});
this.preEdit = addPreEditListener((channelId, __, messageObj) => {
if (this.canUseEmotes && this.hasPermissionToUseExternalEmojis(channelId)) return;
const { guildId } = this;
for (const [emojiStr, _, emojiId] of messageObj.content.matchAll(/(?<!\\)<a?:(\w+):(\d+)>/ig)) {
const emoji = EmojiStore.getCustomEmojiById(emojiId);
if (emoji == null || (emoji.guildId === guildId && !emoji.animated)) continue;
if (!emoji.require_colons) continue;
const url = emoji.url.replace(/\?size=\d+/, "?" + new URLSearchParams({
size: Settings.plugins.FakeNitro.emojiSize,
name: encodeURIComponent(emoji.name)
}));
messageObj.content = messageObj.content.replace(emojiStr, (match, offset, origStr) => {
return `${getWordBoundary(origStr, offset - 1)}${url}${getWordBoundary(origStr, offset + match.length)}`;
});
}
});
},
stop() {
removePreSendListener(this.preSend);
removePreEditListener(this.preEdit);
}
});

View File

@ -0,0 +1,145 @@
/*
* Vencord, a modification for Discord's desktop app
* Copyright (c) 2023 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/>.
*/
// This plugin is a port from Alyxia's Vendetta plugin
import { definePluginSettings } from "@api/settings";
import ErrorBoundary from "@components/ErrorBoundary";
import { Devs } from "@utils/constants";
import { Margins } from "@utils/margins";
import { copyWithToast } from "@utils/misc";
import definePlugin, { OptionType } from "@utils/types";
import { Button, Forms } from "@webpack/common";
import { User } from "discord-types/general";
import virtualMerge from "virtual-merge";
interface UserProfile extends User {
themeColors?: Array<number>;
}
interface Colors {
primary: number;
accent: number;
}
function encode(primary: number, accent: number): string {
const message = `[#${primary.toString(16).padStart(6, "0")},#${accent.toString(16).padStart(6, "0")}]`;
const padding = "";
const encoded = Array.from(message)
.map(x => x.codePointAt(0))
.filter(x => x! >= 0x20 && x! <= 0x7f)
.map(x => String.fromCodePoint(x! + 0xe0000))
.join("");
return (padding || "") + " " + encoded;
}
// Courtesy of Cynthia.
function decode(bio: string): Array<number> | null {
if (bio == null) return null;
const colorString = bio.match(
/\u{e005b}\u{e0023}([\u{e0061}-\u{e0066}\u{e0041}-\u{e0046}\u{e0030}-\u{e0039}]+?)\u{e002c}\u{e0023}([\u{e0061}-\u{e0066}\u{e0041}-\u{e0046}\u{e0030}-\u{e0039}]+?)\u{e005d}/u,
);
if (colorString != null) {
const parsed = [...colorString[0]]
.map(x => String.fromCodePoint(x.codePointAt(0)! - 0xe0000))
.join("");
const colors = parsed
.substring(1, parsed.length - 1)
.split(",")
.map(x => parseInt(x.replace("#", "0x"), 16));
return colors;
} else {
return null;
}
}
const settings = definePluginSettings({
nitroFirst: {
description: "Default color source if both are present",
type: OptionType.SELECT,
options: [
{ label: "Nitro colors", value: true, default: true },
{ label: "Fake colors", value: false },
]
}
});
export default definePlugin({
name: "FakeProfileThemes",
description: "Allows profile theming by hiding the colors in your bio thanks to invisible 3y3 encoding",
authors: [Devs.Alyxia, Devs.Remty],
patches: [
{
find: "getUserProfile=",
replacement: {
match: /(?<=getUserProfile=function\(\i\){return )(\i\[\i\])/,
replace: "$self.colorDecodeHook($1)"
}
}, {
find: ".USER_SETTINGS_PROFILE_THEME_ACCENT",
replacement: {
match: /RESET_PROFILE_THEME}\)(?<=},color:(\i).+?},color:(\i).+?)/,
replace: "$&,$self.addCopy3y3Button({primary:$1,accent:$2})"
}
}
],
settingsAboutComponent: () => (
<Forms.FormSection>
<Forms.FormTitle tag="h3">Usage</Forms.FormTitle>
<Forms.FormText>
After enabling this plugin, you will see custom colors in the profiles of other people using compatible plugins. <br />
To set your own colors:
<ul>
<li> go to your profile settings</li>
<li> choose your own colors in the Nitro preview</li>
<li> click the "Copy 3y3" button</li>
<li> paste the invisible text anywhere in your bio</li>
</ul><br />
<b>Please note:</b> if you are using a theme which hides nitro ads, you should disable it temporarily to set colors.
</Forms.FormText>
</Forms.FormSection>),
settings,
colorDecodeHook(user: UserProfile) {
if (user) {
// don't replace colors if already set with nitro
if (settings.store.nitroFirst && user.themeColors) return user;
const colors = decode(user.bio);
if (colors) {
return virtualMerge(user, {
premiumType: 2,
themeColors: colors
});
}
}
return user;
},
addCopy3y3Button: ErrorBoundary.wrap(function ({ primary, accent }: Colors) {
return <Button
onClick={() => {
const colorString = encode(primary, accent);
copyWithToast(colorString);
}}
color={Button.Colors.PRIMARY}
size={Button.Sizes.XLARGE}
className={Margins.left16}
>Copy 3y3
</Button >;
}, { noop: true }),
});

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