Compare commits

...

91 commits

Author SHA1 Message Date
Lewis Crichton 269ed18fcc
merge: dev 2024-01-19 17:00:00 +00:00
Lewis Crichton f793f9eb6b
Merge branch 'dev' into feat/usercss 2024-01-14 22:10:10 +00:00
Lewis Crichton 686bb118b4
fix: settings reset not refreshing 2024-01-14 16:37:24 +00:00
Lewis Crichton 6c551650d0
feat: add reset to default button 2024-01-14 16:24:40 +00:00
Lewis Crichton 3cf60f8daa
fix: broken checkbox default parsing 2024-01-14 16:11:26 +00:00
Lewis Crichton e350883b72
chore: remove unneeded import 2024-01-14 13:28:28 +00:00
Lewis Crichton 0aa399c8ed
Merge branch 'dev' into feat/usercss 2024-01-14 13:13:07 +00:00
Lewis Crichton 2ae1a70f7f
Merge branch 'dev' into feat/usercss 2024-01-07 00:21:11 +00:00
Lewis Crichton e947f6aa74
Merge branch 'feat/usercss' of ssh://github.com/lewisakura/Vencord into feat/usercss 2024-01-04 16:07:54 +00:00
Lewis Crichton 0b8429f40c
Merge branch 'dev' of ssh://github.com/Vendicated/Vencord into feat/usercss 2024-01-04 16:07:32 +00:00
Lewis Crichton 15749dc8ce
Merge branch 'dev' into feat/usercss 2024-01-04 16:07:22 +00:00
Lewis Crichton 02272c5b46
perf: dont use cloned array, iterate backwards 2023-12-31 01:10:32 +00:00
Lewis Crichton 6a3c592d5f
fix: hacky way of checking if themes are valid 2023-12-31 00:29:00 +00:00
Lewis Crichton 1c0dff3666
feat: pass vencord variable when compiling
this is another one of those standards-defining things like
`vc-requiredPlugins`, but i do genuinely think passing the client mod
that's in use here will be beneficial in the long run for usercss
authors since they can write one file that works across multiple.
hopefully other mods that implement usercss will adopt this 🤞
2023-12-28 14:05:25 +00:00
Lewis Crichton 2cafedc7e7
feat: make missing plugins prevent enabling the theme 2023-12-28 13:59:36 +00:00
Lewis Crichton 9b89ef58be
feat: better buttons and icons for theme import/export 2023-12-28 13:50:29 +00:00
Lewis Crichton 18b1fe0413
feat: clipboard copying of theme settings
this also removes the stupid state management that every theme setting
had, which I do not remember the justification for but was completely
unnecessary and broke rendering when you update the settings from
outside of these components
2023-12-28 13:23:18 +00:00
Lewis Crichton 85bd99c2ca
fix: web native stub 2023-12-28 02:44:40 +00:00
Lewis Crichton 0413a2399a
Merge branch 'dev' of ssh://github.com/Vendicated/Vencord into feat/usercss 2023-12-28 02:03:22 +00:00
Lewis Crichton 42307ccc0e
fix: Vencord_cloudSecret check (#2077)
finally got around to fixing it - `null` is never a valid return value,
it's `undefined` 🤦
2023-12-28 02:02:49 +00:00
Lewis Crichton 669c7f3964
chore: error handling 2023-12-27 17:53:06 +00:00
Lewis Crichton 85b510b881
merge: dev 2023-12-26 14:50:01 +00:00
Lewis Crichton c25e8ac8c1
Merge branch 'dev' into feat/usercss 2023-12-13 23:20:31 +00:00
Lewis Crichton 34e1c83756
fix: text strings not being done properly 2023-11-30 19:23:26 +00:00
Lewis Crichton ea864b9a00
fix: compiler not properly inserting booleans for less/stylus 2023-11-30 19:04:22 +00:00
Lewis Crichton d7e5c06e83
Merge branch 'dev' into feat/usercss 2023-11-30 17:15:21 +00:00
Lewis Crichton 39860bd05c
fix: make more specific (@Nuckyz) 2023-11-28 19:52:41 +00:00
Lewis Crichton e3aab2b864
Merge branch 'dev' into feat/usercss 2023-11-28 19:51:50 +00:00
Lewis Crichton 66a27a1e79
Merge branch 'dev' into feat/usercss 2023-11-28 18:58:41 +00:00
V 58c6611abe
VoiceMessages: fix preview being blank 2023-11-27 16:05:52 +01:00
Nuckyz fc10bc1e69
Utility function for loading Discord chunks (#2017) 2023-11-27 02:56:57 -03:00
Lewis Crichton f4b846375f
chore: ???? 2023-11-25 17:03:56 +00:00
Lewis Crichton 6881ddbea7
chore: shhhhhh 2023-11-25 17:02:40 +00:00
Lewis Crichton c981325fb3
fix: colorpicker (sorta)
this now uses the lazy-loaded colorpicker, but this only works if said
colorpicker has already been loaded. need to force load before anything.
2023-11-25 17:00:10 +00:00
Lewis Crichton cbdaf7daa6
Merge branch 'feat/usercss' of ssh://github.com/lewisakura/Vencord into feat/usercss 2023-11-25 16:28:39 +00:00
Lewis Crichton f68351b31b
Merge branch 'dev' of ssh://github.com/Vendicated/Vencord into feat/usercss 2023-11-25 16:28:34 +00:00
Lewis Crichton a911dd17b1
Merge branch 'dev' into feat/usercss 2023-11-25 16:28:22 +00:00
Nuckyz 867730a478
Simplify some components finds; Make undo of patch groups more clear 2023-11-24 23:14:18 -03:00
Lewis Crichton 828a882017
Merge branch 'dev' into feat/usercss 2023-11-21 23:49:25 +00:00
Lewis Crichton a57ab38c8c
Merge branch 'dev' into feat/usercss 2023-11-09 01:36:20 +00:00
Lewis Crichton d544d33564
Merge branch 'dev' into feat/usercss 2023-11-07 23:18:01 +00:00
zImPatrick dc3591ba18
Fix FakeNitro sticker bypass (#1964) 2023-11-07 15:58:10 -03:00
Marvin Witt c0f786804a
fix(dearrow): don't replace thumbnail if only original available (#1959) 2023-11-05 02:06:08 +01:00
Lewis Crichton 31fd035bd3
Merge branch 'dev' into feat/usercss 2023-10-26 21:56:29 +01:00
Lewis Crichton 4dbffcb8b8
merge: dev 2023-10-26 21:45:00 +01:00
Lewis Crichton c12dd258a6
style: grr 2023-10-16 22:54:29 +01:00
Lewis Crichton b6547b463b
feat: @vc-requiredPlugins 2023-10-16 22:53:37 +01:00
Lewis Crichton b7cdb96e09
Merge branch 'dev' into feat/usercss 2023-10-16 21:21:44 +01:00
Lewis Crichton ff32014613
Merge branch 'feat/usercss' of ssh://github.com/lewisakura/Vencord into feat/usercss 2023-10-13 16:22:21 +01:00
Lewis Crichton 791eaa06d4
merge: dev 2023-10-13 16:21:40 +01:00
Lewis Crichton 25857377b6
Merge branch 'dev' into feat/usercss 2023-10-01 10:12:46 +01:00
Lewis Crichton eb31ad994e
merge: dev branch 2023-09-27 21:42:29 +01:00
Lewis Crichton 6fbe24a268
feat: don't parse if not able to compile 2023-09-25 19:06:36 +01:00
Lewis Crichton 5bc24a5d78
feat: guards to prevent compiling this on web 2023-09-25 19:00:03 +01:00
Lewis Crichton 91e093a21d
chore: purify 2023-09-25 18:49:38 +01:00
Lewis Crichton f8232694e7
style: 2x2 switches 2023-09-25 18:39:23 +01:00
Lewis Crichton 03bc5cde22
feat: make colorpicker use props for height/width 2023-09-25 18:32:16 +01:00
Lewis Crichton 4325dcf02e
feat: make the color picker look prettier based on switch 2023-09-25 18:27:49 +01:00
Lewis Crichton 1179a9f5a1
fix: dedupe 2023-09-25 18:11:35 +01:00
Lewis Crichton 7105558640
chore: add back warning lost in merge 2023-09-25 18:06:50 +01:00
Lewis Crichton c019a3cc10
merge: i think i did this right? 2023-09-25 18:05:26 +01:00
Lewis Crichton a79fb2718b
chore: de-bdify 2023-09-15 19:42:01 +01:00
Lewis Crichton 06f2239b1a
Merge branch 'dev' into feat/usercss 2023-09-15 19:40:07 +01:00
Lewis Crichton 1be6738715
perf: memoize relatively intensively computed values 2023-09-10 14:23:19 +01:00
Lewis Crichton 12509f8157
chore: clean lol 2023-09-10 14:11:25 +01:00
Lewis Crichton 74f9b1a022
feat: each settings component handles state, + fix selects again lol 2023-09-10 14:09:00 +01:00
Lewis Crichton 482caf0c5b
style: use switch for special case handling 2023-09-10 13:55:51 +01:00
Lewis Crichton b1bdc48769
fix: redundant padding character in usercss id 2023-09-10 13:51:45 +01:00
Lewis Crichton 141b1a7041
fix: missing styles 2023-09-10 13:43:41 +01:00
Lewis Crichton d43eebe0e4
refactor: split components and modal and whatnot 2023-09-10 13:40:04 +01:00
Lewis Crichton f2dc34e023
Merge branch 'feat/usercss' of ssh://github.com/lewisakura/Vencord into feat/usercss 2023-09-09 19:48:39 +01:00
Lewis Crichton f596941f3a
feat: checkbox type to bools in compiled output 2023-09-09 19:48:32 +01:00
Lewis Crichton e4f4802155
Merge branch 'dev' into feat/usercss 2023-09-09 10:57:00 +01:00
Lewis Crichton b7bd5096b6
fix: select defaults not working 2023-09-09 10:53:49 +01:00
Lewis Crichton 9fdd2c7c17
feat: better colorpicker 2023-09-09 10:43:07 +01:00
Lewis Crichton 51059c29e7
feat: non-exact settings subscriptions for live recompile 2023-09-09 10:17:21 +01:00
Lewis Crichton 64848b2fbf
feat: use built in tinycolor 2023-09-09 10:00:41 +01:00
Lewis Crichton b6e20680ff
feat: my suffering is neverending and all i can think of is popups and modals 2023-09-08 22:19:21 +01:00
Lewis Crichton d361edc47d
style: u love refactors ignoring stuff 2023-09-08 16:36:22 +01:00
Lewis Crichton 7174d2e744
perf: move theme parsing out of natives to prevent duplicate dependencies 2023-09-08 16:35:37 +01:00
Lewis Crichton 9a23571b3e
feat: resiliency against bad usercss 2023-09-08 15:54:25 +01:00
Lewis Crichton 723191ba9b
feat: usercss compilation and better settings storage 2023-09-08 14:59:41 +01:00
Lewis Crichton 0cc420fb45
Merge branch 'dev' into feat/usercss 2023-09-07 16:36:21 +01:00
Lewis Crichton a939034bc1
style: dont add unnecessary space 2023-09-07 16:07:45 +01:00
Lewis Crichton b350087a7a
style: for if 2023-09-03 13:26:07 +01:00
Lewis Crichton 19a87e3e94
feat: ranges with units, loading vars from settings 2023-09-02 22:02:32 +01:00
Lewis Crichton c0dff86cb2
fix: don't add empty :root{} 2023-09-02 21:34:55 +01:00
Lewis Crichton 0d66604be5
style: explicit ype qualifier 2023-09-02 21:29:44 +01:00
Lewis Crichton b7fb178f1f
feat: inject css vars for usercss 2023-09-02 21:27:22 +01:00
Lewis Crichton d689b3273b
style: clean up whackass imports 2023-09-02 18:01:55 +01:00
Lewis Crichton 2ef2baafbe
feat: initial usercss support
Parses UserCSS/UserStyle files (.user.css) but doesn't do anything
special yet with the variables. This is a first step towards
supporting UserCSS themes.
2023-09-02 17:51:17 +01:00
30 changed files with 1189 additions and 60 deletions

View file

@ -25,7 +25,6 @@ import * as DataStore from "../src/api/DataStore";
import { debounce } from "../src/utils"; import { debounce } from "../src/utils";
import { EXTENSION_BASE_URL } from "../src/utils/web-metadata"; import { EXTENSION_BASE_URL } from "../src/utils/web-metadata";
import { getTheme, Theme } from "../src/utils/discord"; import { getTheme, Theme } from "../src/utils/discord";
import { getThemeInfo } from "../src/main/themes";
// Discord deletes this so need to store in variable // Discord deletes this so need to store in variable
const { localStorage } = window; const { localStorage } = window;
@ -46,7 +45,7 @@ window.VencordNative = {
deleteTheme: (fileName: string) => DataStore.del(fileName, themeStore), deleteTheme: (fileName: string) => DataStore.del(fileName, themeStore),
getThemesDir: async () => "", getThemesDir: async () => "",
getThemesList: () => DataStore.entries(themeStore).then(entries => getThemesList: () => DataStore.entries(themeStore).then(entries =>
entries.map(([name, css]) => getThemeInfo(css, name.toString())) entries.map(([name, css]) => ({ fileName: name as string, content: css }))
), ),
getThemeData: (fileName: string) => DataStore.get(fileName, themeStore), getThemeData: (fileName: string) => DataStore.get(fileName, themeStore),
getSystemValues: async () => ({}), getSystemValues: async () => ({}),

View file

@ -39,15 +39,19 @@
"gifenc": "github:mattdesl/gifenc#64842fca317b112a8590f8fef2bf3825da8f6fe3", "gifenc": "github:mattdesl/gifenc#64842fca317b112a8590f8fef2bf3825da8f6fe3",
"monaco-editor": "^0.43.0", "monaco-editor": "^0.43.0",
"nanoid": "^4.0.2", "nanoid": "^4.0.2",
"usercss-meta": "^0.12.0",
"virtual-merge": "^1.0.1" "virtual-merge": "^1.0.1"
}, },
"devDependencies": { "devDependencies": {
"@types/chrome": "^0.0.246", "@types/chrome": "^0.0.246",
"@types/diff": "^5.0.3", "@types/diff": "^5.0.3",
"@types/less": "^3.0.4",
"@types/lodash": "^4.14.194", "@types/lodash": "^4.14.194",
"@types/node": "^18.16.3", "@types/node": "^18.16.3",
"@types/react": "^18.2.0", "@types/react": "^18.2.0",
"@types/react-dom": "^18.2.1", "@types/react-dom": "^18.2.1",
"@types/stylus": "^0.48.39",
"@types/tinycolor2": "^1.4.3",
"@types/yazl": "^2.4.2", "@types/yazl": "^2.4.2",
"@typescript-eslint/eslint-plugin": "^5.59.1", "@typescript-eslint/eslint-plugin": "^5.59.1",
"@typescript-eslint/parser": "^5.59.1", "@typescript-eslint/parser": "^5.59.1",
@ -75,7 +79,8 @@
"pnpm": { "pnpm": {
"patchedDependencies": { "patchedDependencies": {
"eslint-plugin-path-alias@1.0.0": "patches/eslint-plugin-path-alias@1.0.0.patch", "eslint-plugin-path-alias@1.0.0": "patches/eslint-plugin-path-alias@1.0.0.patch",
"eslint@8.46.0": "patches/eslint@8.46.0.patch" "eslint@8.46.0": "patches/eslint@8.46.0.patch",
"@types/less@3.0.4": "patches/@types__less@3.0.4.patch"
}, },
"peerDependencyRules": { "peerDependencyRules": {
"ignoreMissing": [ "ignoreMissing": [

View file

@ -0,0 +1,13 @@
diff --git a/index.d.ts b/index.d.ts
index eb4f07d47b932fb9cc8c8cd451ab107f648bd013..18a3e15a1997734e1773718e5be55d252ed9478c 100644
--- a/index.d.ts
+++ b/index.d.ts
@@ -306,7 +306,5 @@ interface LessStatic {
}
declare module "less" {
- export = less;
+ export = LessStatic;
}
-
-declare var less: LessStatic;

View file

@ -1,6 +1,9 @@
lockfileVersion: '6.0' lockfileVersion: '6.0'
patchedDependencies: patchedDependencies:
'@types/less@3.0.4':
hash: krcufrsfhsuxuoj7hocqugs6zi
path: patches/@types__less@3.0.4.patch
eslint-plugin-path-alias@1.0.0: eslint-plugin-path-alias@1.0.0:
hash: m6sma4g6bh67km3q6igf6uxaja hash: m6sma4g6bh67km3q6igf6uxaja
path: patches/eslint-plugin-path-alias@1.0.0.patch path: patches/eslint-plugin-path-alias@1.0.0.patch
@ -33,6 +36,9 @@ dependencies:
nanoid: nanoid:
specifier: ^4.0.2 specifier: ^4.0.2
version: 4.0.2 version: 4.0.2
usercss-meta:
specifier: ^0.12.0
version: 0.12.0
virtual-merge: virtual-merge:
specifier: ^1.0.1 specifier: ^1.0.1
version: 1.0.1 version: 1.0.1
@ -44,6 +50,9 @@ devDependencies:
'@types/diff': '@types/diff':
specifier: ^5.0.3 specifier: ^5.0.3
version: 5.0.3 version: 5.0.3
'@types/less':
specifier: ^3.0.4
version: 3.0.4(patch_hash=krcufrsfhsuxuoj7hocqugs6zi)
'@types/lodash': '@types/lodash':
specifier: ^4.14.194 specifier: ^4.14.194
version: 4.14.194 version: 4.14.194
@ -56,6 +65,12 @@ devDependencies:
'@types/react-dom': '@types/react-dom':
specifier: ^18.2.1 specifier: ^18.2.1
version: 18.2.1 version: 18.2.1
'@types/stylus':
specifier: ^0.48.39
version: 0.48.39
'@types/tinycolor2':
specifier: ^1.4.3
version: 1.4.3
'@types/yazl': '@types/yazl':
specifier: ^2.4.2 specifier: ^2.4.2
version: 2.4.2 version: 2.4.2
@ -564,6 +579,11 @@ packages:
resolution: {integrity: sha512-wOuvG1SN4Us4rez+tylwwwCV1psiNVOkJeM3AUWUNWg/jDQY2+HE/444y5gc+jBmRqASOm2Oeh5c1axHobwRKQ==} resolution: {integrity: sha512-wOuvG1SN4Us4rez+tylwwwCV1psiNVOkJeM3AUWUNWg/jDQY2+HE/444y5gc+jBmRqASOm2Oeh5c1axHobwRKQ==}
dev: true dev: true
/@types/less@3.0.4(patch_hash=krcufrsfhsuxuoj7hocqugs6zi):
resolution: {integrity: sha512-djlMpTdDF+tLaqVpK/0DWGNIr7BFjN8ykDLkgS0sQGYYLop51imRRE3foTjl+dMAH1zFE8bMZAG0VbYPEcSgsA==}
dev: true
patched: true
/@types/lodash@4.14.194: /@types/lodash@4.14.194:
resolution: {integrity: sha512-r22s9tAS7imvBt2lyHC9B8AGwWnXaYb1tY09oyLkXDs4vArpYJzw09nj8MLx5VfciBPGIb+ZwG0ssYnEPJxn/g==} resolution: {integrity: sha512-r22s9tAS7imvBt2lyHC9B8AGwWnXaYb1tY09oyLkXDs4vArpYJzw09nj8MLx5VfciBPGIb+ZwG0ssYnEPJxn/g==}
dev: true dev: true
@ -613,6 +633,16 @@ packages:
resolution: {integrity: sha512-21cFJr9z3g5dW8B0CVI9g2O9beqaThGQ6ZFBqHfwhzLDKUxaqTIy3vnfah/UPkfOiF2pLq+tGz+W8RyCskuslw==} resolution: {integrity: sha512-21cFJr9z3g5dW8B0CVI9g2O9beqaThGQ6ZFBqHfwhzLDKUxaqTIy3vnfah/UPkfOiF2pLq+tGz+W8RyCskuslw==}
dev: true dev: true
/@types/stylus@0.48.39:
resolution: {integrity: sha512-98a0QrJorrq8+Vsan9yfxol2Qr6nvUWBeV3oYnSMks4QdLMebAzZvRd9IuoZOcnB6Erfjcjn1J2J+63MPCxJnw==}
dependencies:
'@types/node': 18.16.3
dev: true
/@types/tinycolor2@1.4.3:
resolution: {integrity: sha512-Kf1w9NE5HEgGxCRyIcRXR/ZYtDv0V8FVPtYHwLxl0O+maGX0erE77pQlD0gpP+/KByMZ87mOA79SjifhSB3PjQ==}
dev: true
/@types/yauzl@2.10.0: /@types/yauzl@2.10.0:
resolution: {integrity: sha512-Cn6WYCm0tXv8p6k+A8PvbDG763EDpBoTzHdA+Q/MF6H3sapGjCm9NzoaJncJS9tUKSuCoDs9XHxYYsQDgxR6kw==} resolution: {integrity: sha512-Cn6WYCm0tXv8p6k+A8PvbDG763EDpBoTzHdA+Q/MF6H3sapGjCm9NzoaJncJS9tUKSuCoDs9XHxYYsQDgxR6kw==}
requiresBuild: true requiresBuild: true
@ -3309,6 +3339,11 @@ packages:
engines: {node: '>=0.10.0'} engines: {node: '>=0.10.0'}
dev: true dev: true
/usercss-meta@0.12.0:
resolution: {integrity: sha512-zKrXCKdpeIwtVe87omxGo9URf+7mbozduMZEg79dmT4KB3XJwfIkEi/Uk0PcTwR/nZLtAK1+k7isgbGB/g6E7Q==}
engines: {node: '>=8.3'}
dev: false
/util-deprecate@1.0.2: /util-deprecate@1.0.2:
resolution: {integrity: sha512-EPD5q1uXyFxJpCrLnCc1nHnq3gOa6DZBocAIiI2TaSCA7VCJ1UJDMagCzIkXNsUYfD1daK//LTEQ8xiIbrHtcw==} resolution: {integrity: sha512-EPD5q1uXyFxJpCrLnCc1nHnq3gOa6DZBocAIiI2TaSCA7VCJ1UJDMagCzIkXNsUYfD1daK//LTEQ8xiIbrHtcw==}
dev: true dev: true

View file

@ -8,7 +8,6 @@ import { IpcEvents } from "@utils/IpcEvents";
import { IpcRes } from "@utils/types"; import { IpcRes } from "@utils/types";
import { ipcRenderer } from "electron"; import { ipcRenderer } from "electron";
import { PluginIpcMappings } from "main/ipcPlugins"; import { PluginIpcMappings } from "main/ipcPlugins";
import type { UserThemeHeader } from "main/themes";
function invoke<T = any>(event: IpcEvents, ...args: any[]) { function invoke<T = any>(event: IpcEvents, ...args: any[]) {
return ipcRenderer.invoke(event, ...args) as Promise<T>; return ipcRenderer.invoke(event, ...args) as Promise<T>;
@ -33,7 +32,7 @@ export default {
uploadTheme: (fileName: string, fileData: string) => invoke<void>(IpcEvents.UPLOAD_THEME, fileName, fileData), uploadTheme: (fileName: string, fileData: string) => invoke<void>(IpcEvents.UPLOAD_THEME, fileName, fileData),
deleteTheme: (fileName: string) => invoke<void>(IpcEvents.DELETE_THEME, fileName), deleteTheme: (fileName: string) => invoke<void>(IpcEvents.DELETE_THEME, fileName),
getThemesDir: () => invoke<string>(IpcEvents.GET_THEMES_DIR), getThemesDir: () => invoke<string>(IpcEvents.GET_THEMES_DIR),
getThemesList: () => invoke<Array<UserThemeHeader>>(IpcEvents.GET_THEMES_LIST), getThemesList: () => invoke<Array<{ fileName: string; content: string; }>>(IpcEvents.GET_THEMES_LIST),
getThemeData: (fileName: string) => invoke<string | undefined>(IpcEvents.GET_THEME_DATA, fileName), getThemeData: (fileName: string) => invoke<string | undefined>(IpcEvents.GET_THEME_DATA, fileName),
getSystemValues: () => invoke<Record<string, string>>(IpcEvents.GET_THEME_SYSTEM_VALUES), getSystemValues: () => invoke<Record<string, string>>(IpcEvents.GET_THEME_SYSTEM_VALUES),
}, },

View file

@ -75,6 +75,12 @@ export interface Settings {
settingsSync: boolean; settingsSync: boolean;
settingsSyncVersion: number; settingsSyncVersion: number;
}; };
userCssVars: {
[themeId: string]: {
[varName: string]: string;
};
};
} }
const DefaultSettings: Settings = { const DefaultSettings: Settings = {
@ -107,7 +113,9 @@ const DefaultSettings: Settings = {
url: "https://api.vencord.dev/", url: "https://api.vencord.dev/",
settingsSync: false, settingsSync: false,
settingsSyncVersion: 0 settingsSyncVersion: 0
} },
userCssVars: {}
}; };
try { try {
@ -125,7 +133,7 @@ const saveSettingsOnFrequentAction = debounce(async () => {
} }
}, 60_000); }, 60_000);
type SubscriptionCallback = ((newValue: any, path: string) => void) & { _paths?: Array<string>; }; type SubscriptionCallback = ((newValue: any, path: string) => void) & { _paths?: Array<string>; _exact?: boolean; };
const subscriptions = new Set<SubscriptionCallback>(); const subscriptions = new Set<SubscriptionCallback>();
const proxyCache = {} as Record<string, any>; const proxyCache = {} as Record<string, any>;
@ -182,7 +190,12 @@ function makeProxy(settings: any, root = settings, path = ""): Settings {
const setPath = `${path}${path && "."}${p}`; const setPath = `${path}${path && "."}${p}`;
delete proxyCache[setPath]; delete proxyCache[setPath];
for (const subscription of subscriptions) { for (const subscription of subscriptions) {
if (!subscription._paths || subscription._paths.includes(setPath)) { if (
!subscription._paths ||
(subscription._exact
? subscription._paths.includes(setPath)
: subscription._paths.some(p => setPath.startsWith(p)))
) {
subscription(v, setPath); subscription(v, setPath);
} }
} }
@ -220,11 +233,14 @@ export const Settings = makeProxy(settings);
* @returns Settings * @returns Settings
*/ */
// TODO: Representing paths as essentially "string[].join('.')" wont allow dots in paths, change to "paths?: string[][]" later // TODO: Representing paths as essentially "string[].join('.')" wont allow dots in paths, change to "paths?: string[][]" later
export function useSettings(paths?: UseSettings<Settings>[]) { export function useSettings(paths?: UseSettings<Settings>[], exact = true) {
const [, forceUpdate] = React.useReducer(() => ({}), {}); const [, forceUpdate] = React.useReducer(() => ({}), {});
const onUpdate: SubscriptionCallback = paths const onUpdate: SubscriptionCallback = paths
? (value, path) => paths.includes(path as UseSettings<Settings>) && forceUpdate() ? (value, path) =>
(exact
? paths.includes(path as UseSettings<Settings>)
: paths.some(p => path.startsWith(p))) && forceUpdate()
: forceUpdate; : forceUpdate;
React.useEffect(() => { React.useEffect(() => {
@ -250,11 +266,12 @@ type ResolvePropDeep<T, P> = P extends "" ? T :
* @example addSettingsListener("", (newValue, path) => console.log(`${path} is now ${newValue}`)) * @example addSettingsListener("", (newValue, path) => console.log(`${path} is now ${newValue}`))
* addSettingsListener("plugins.Unindent.enabled", v => console.log("Unindent is now", v ? "enabled" : "disabled")) * addSettingsListener("plugins.Unindent.enabled", v => console.log("Unindent is now", v ? "enabled" : "disabled"))
*/ */
export function addSettingsListener<Path extends keyof Settings>(path: Path, onUpdate: (newValue: Settings[Path], path: Path) => void): void; export function addSettingsListener<Path extends keyof Settings>(path: Path, onUpdate: (newValue: Settings[Path], path: Path) => void, exact?: boolean): void;
export function addSettingsListener<Path extends string>(path: Path, onUpdate: (newValue: Path extends "" ? any : ResolvePropDeep<Settings, Path>, path: Path extends "" ? string : Path) => void): void; export function addSettingsListener<Path extends string>(path: Path, onUpdate: (newValue: Path extends "" ? any : ResolvePropDeep<Settings, Path>, path: Path extends "" ? string : Path) => void, exact?: boolean): void;
export function addSettingsListener(path: string, onUpdate: (newValue: any, path: string) => void) { export function addSettingsListener(path: string, onUpdate: (newValue: any, path: string) => void, exact = true) {
if (path) if (path)
((onUpdate as SubscriptionCallback)._paths ??= []).push(path); ((onUpdate as SubscriptionCallback)._paths ??= []).push(path);
(onUpdate as SubscriptionCallback)._exact = exact;
subscriptions.add(onUpdate); subscriptions.add(onUpdate);
} }

View file

@ -256,6 +256,24 @@ export function DeleteIcon(props: IconProps) {
); );
} }
/**
* A plugin icon, created by CorellanStoma. https://github.com/CreArts-Community/Settings-Icons
*/
export function PluginIcon(props: IconProps) {
return (
<Icon
{...props}
className={classes(props.className, "vc-plugin-icon")}
viewBox="0 0 24 24"
>
<path
fill="currentColor"
d="M20.5 11H19V7c0-1.1-.9-2-2-2h-4V3.5C13 2.12 11.88 1 10.5 1S8 2.12 8 3.5V5H4c-1.1 0-1.99.9-1.99 2v3.8H3.5c1.49 0 2.7 1.21 2.7 2.7s-1.21 2.7-2.7 2.7H2V20c0 1.1.9 2 2 2h3.8v-1.5c0-1.49 1.21-2.7 2.7-2.7s2.7 1.21 2.7 2.7V22H17c1.1 0 2-.9 2-2v-4h1.5c1.38 0 2.5-1.12 2.5-2.5S21.88 11 20.5 11z"
/>
</Icon>
);
}
export function PlusIcon(props: IconProps) { export function PlusIcon(props: IconProps) {
return ( return (
<Icon <Icon
@ -290,3 +308,40 @@ export function NoEntrySignIcon(props: IconProps) {
</Icon> </Icon>
); );
} }
export function PasteIcon(props: IconProps) {
return (
<Icon
{...props}
className={classes(props.className, "vc-paste-icon")}
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
stroke-width="2"
stroke-linecap="round"
stroke-linejoin="round"
>
<rect width="8" height="4" x="8" y="2" rx="1" ry="1" />
<path d="M16 4h2a2 2 0 0 1 2 2v14a2 2 0 0 1-2 2H6a2 2 0 0 1-2-2V6a2 2 0 0 1 2-2h2" />
</Icon>
);
}
export function ResetIcon(props: IconProps) {
return (
<Icon
{...props}
width="24"
height="24"
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
stroke-width="2"
stroke-linecap="round"
stroke-linejoin="round"
>
<path d="M3 12a9 9 0 1 0 9-9 9.75 9.75 0 0 0-6.74 2.74L3 8" />
<path d="M3 3v5h5" />
</Icon>
);
}

View file

@ -16,25 +16,32 @@
* along with this program. If not, see <https://www.gnu.org/licenses/>. * along with this program. If not, see <https://www.gnu.org/licenses/>.
*/ */
import { useSettings } from "@api/Settings"; import "./themesStyles.css";
import { Settings, useSettings } from "@api/Settings";
import { classNameFactory } from "@api/Styles"; import { classNameFactory } from "@api/Styles";
import { Flex } from "@components/Flex"; import { Flex } from "@components/Flex";
import { DeleteIcon } from "@components/Icons"; import { CogWheel, DeleteIcon, PluginIcon } from "@components/Icons";
import { Link } from "@components/Link"; import { Link } from "@components/Link";
import PluginModal from "@components/PluginSettings/PluginModal"; import PluginModal from "@components/PluginSettings/PluginModal";
import { AddonCard } from "@components/VencordSettings/AddonCard";
import { SettingsTab, wrapTab } from "@components/VencordSettings/shared";
import { openInviteModal } from "@utils/discord"; import { openInviteModal } from "@utils/discord";
import { Margins } from "@utils/margins"; import { Margins } from "@utils/margins";
import { classes } from "@utils/misc"; import { classes } from "@utils/misc";
import { openModal } from "@utils/modal"; import { openModal } from "@utils/modal";
import { showItemInFolder } from "@utils/native"; import { showItemInFolder } from "@utils/native";
import { useAwaiter } from "@utils/react"; import { useAwaiter } from "@utils/react";
import type { ThemeHeader } from "@utils/themes";
import { getThemeInfo, stripBOM, type UserThemeHeader } from "@utils/themes/bd";
import { usercssParse } from "@utils/themes/usercss";
import { findByPropsLazy, findLazy } from "@webpack"; import { findByPropsLazy, findLazy } from "@webpack";
import { Button, Card, Forms, React, showToast, TabBar, TextArea, useEffect, useRef, useState } from "@webpack/common"; import { Button, Card, Forms, React, showToast, TabBar, TextArea, Tooltip, useEffect, useMemo, useRef, useState } from "@webpack/common";
import { UserThemeHeader } from "main/themes";
import type { ComponentType, Ref, SyntheticEvent } from "react"; import type { ComponentType, Ref, SyntheticEvent } from "react";
import type { UserstyleHeader } from "usercss-meta";
import { AddonCard } from "./AddonCard"; import { isPluginEnabled } from "../../plugins";
import { SettingsTab, wrapTab } from "./shared"; import { UserCSSSettingsModal } from "./UserCSSModal";
type FileInput = ComponentType<{ type FileInput = ComponentType<{
ref: Ref<HTMLInputElement>; ref: Ref<HTMLInputElement>;
@ -49,6 +56,7 @@ const TextAreaProps = findLazy(m => typeof m.textarea === "string");
const cl = classNameFactory("vc-settings-theme-"); const cl = classNameFactory("vc-settings-theme-");
function Validator({ link }: { link: string; }) { function Validator({ link }: { link: string; }) {
const [res, err, pending] = useAwaiter(() => fetch(link).then(res => { const [res, err, pending] = useAwaiter(() => fetch(link).then(res => {
if (res.status > 300) throw `${res.status} ${res.statusText}`; if (res.status > 300) throw `${res.status} ${res.statusText}`;
@ -97,14 +105,75 @@ function Validators({ themeLinks }: { themeLinks: string[]; }) {
); );
} }
interface ThemeCardProps { interface OtherThemeCardProps {
theme: UserThemeHeader; theme: UserThemeHeader;
enabled: boolean; enabled: boolean;
onChange: (enabled: boolean) => void; onChange: (enabled: boolean) => void;
onDelete: () => void; onDelete: () => void;
} }
function ThemeCard({ theme, enabled, onChange, onDelete }: ThemeCardProps) { interface UserCSSCardProps {
theme: UserstyleHeader;
enabled: boolean;
onChange: (enabled: boolean) => void;
onDelete: () => void;
onSettingsReset: () => void;
}
function UserCSSThemeCard({ theme, enabled, onChange, onDelete, onSettingsReset }: UserCSSCardProps) {
const missingPlugins = useMemo(() =>
theme.requiredPlugins?.filter(p => !isPluginEnabled(p)), [theme]);
return (
<AddonCard
name={theme.name ?? "Unknown"}
description={theme.description}
author={theme.author ?? "Unknown"}
enabled={enabled}
setEnabled={onChange}
disabled={missingPlugins && missingPlugins.length > 0}
infoButton={
<>
{missingPlugins && missingPlugins.length > 0 && (
<Tooltip text={"The following plugins are required, but aren't enabled: " + missingPlugins.join(", ")}>
{({ onMouseLeave, onMouseEnter }) => (
<div
style={{ color: "var(--status-danger" }}
onMouseEnter={onMouseEnter}
onMouseLeave={onMouseLeave}
>
<PluginIcon />
</div>
)}
</Tooltip>
)}
{theme.vars && (
<div style={{ cursor: "pointer" }} onClick={
() => openModal(modalProps =>
<UserCSSSettingsModal modalProps={modalProps} theme={theme} onSettingsReset={onSettingsReset} />)
}>
<CogWheel />
</div>
)}
{IS_WEB && (
<div style={{ cursor: "pointer", color: "var(--status-danger" }} onClick={onDelete}>
<DeleteIcon />
</div>
)}
</>
}
footer={
<Flex flexDirection="row" style={{ gap: "0.2em" }}>
{!!theme.homepageURL && <Link href={theme.homepageURL}>Homepage</Link>}
{!!(theme.homepageURL && theme.supportURL) && " • "}
{!!theme.supportURL && <Link href={theme.supportURL}>Support</Link>}
</Flex>
}
/>
);
}
function OtherThemeCard({ theme, enabled, onChange, onDelete }: OtherThemeCardProps) {
return ( return (
<AddonCard <AddonCard
name={theme.name} name={theme.name}
@ -151,7 +220,7 @@ function ThemesTab() {
const fileInputRef = useRef<HTMLInputElement>(null); const fileInputRef = useRef<HTMLInputElement>(null);
const [currentTab, setCurrentTab] = useState(ThemeTab.LOCAL); const [currentTab, setCurrentTab] = useState(ThemeTab.LOCAL);
const [themeText, setThemeText] = useState(settings.themeLinks.join("\n")); const [themeText, setThemeText] = useState(settings.themeLinks.join("\n"));
const [userThemes, setUserThemes] = useState<UserThemeHeader[] | null>(null); const [userThemes, setUserThemes] = useState<ThemeHeader[] | null>(null);
const [themeDir, , themeDirPending] = useAwaiter(VencordNative.themes.getThemesDir); const [themeDir, , themeDirPending] = useAwaiter(VencordNative.themes.getThemesDir);
useEffect(() => { useEffect(() => {
@ -160,7 +229,55 @@ function ThemesTab() {
async function refreshLocalThemes() { async function refreshLocalThemes() {
const themes = await VencordNative.themes.getThemesList(); const themes = await VencordNative.themes.getThemesList();
setUserThemes(themes);
const themeInfo: ThemeHeader[] = [];
for (const { fileName, content } of themes) {
if (!fileName.endsWith(".css")) continue;
if ((!IS_WEB || "armcord" in window) && fileName.endsWith(".user.css")) {
// handle it as usercss
const header = await usercssParse(content, fileName);
themeInfo.push({
type: "usercss",
header
});
Settings.userCssVars[header.id] ??= {};
for (const [name, varInfo] of Object.entries(header.vars ?? {})) {
let normalizedValue = "";
switch (varInfo.type) {
case "text":
case "color":
case "checkbox":
normalizedValue = varInfo.default;
break;
case "select":
normalizedValue = varInfo.options.find(v => v.name === varInfo.default)!.value;
break;
case "range":
normalizedValue = `${varInfo.default}${varInfo.units}`;
break;
case "number":
normalizedValue = String(varInfo.default);
break;
}
Settings.userCssVars[header.id][name] ??= normalizedValue;
}
} else {
// presumably BD but could also be plain css
themeInfo.push({
type: "other",
header: getThemeInfo(stripBOM(content), fileName)
});
}
}
setUserThemes(themeInfo);
} }
// When a local theme is enabled/disabled, update the settings // When a local theme is enabled/disabled, update the settings
@ -269,19 +386,33 @@ function ThemesTab() {
</Card> </Card>
<div className={cl("grid")}> <div className={cl("grid")}>
{userThemes?.map(theme => ( {userThemes?.map(({ type, header: theme }: ThemeHeader) => (
<ThemeCard type === "other" ? (
key={theme.fileName} <OtherThemeCard
enabled={settings.enabledThemes.includes(theme.fileName)} key={theme.fileName}
onChange={enabled => onLocalThemeChange(theme.fileName, enabled)} enabled={settings.enabledThemes.includes(theme.fileName)}
onDelete={async () => { onChange={enabled => onLocalThemeChange(theme.fileName, enabled)}
onLocalThemeChange(theme.fileName, false); onDelete={async () => {
await VencordNative.themes.deleteTheme(theme.fileName); onLocalThemeChange(theme.fileName, false);
refreshLocalThemes(); await VencordNative.themes.deleteTheme(theme.fileName);
}} refreshLocalThemes();
theme={theme} }}
/> theme={theme as UserThemeHeader}
))} />
) : (
<UserCSSThemeCard
key={theme.fileName}
enabled={settings.enabledThemes.includes(theme.fileName)}
onChange={enabled => onLocalThemeChange(theme.fileName, enabled)}
onDelete={async () => {
onLocalThemeChange(theme.fileName, false);
await VencordNative.themes.deleteTheme(theme.fileName);
refreshLocalThemes();
}}
onSettingsReset={refreshLocalThemes}
theme={theme as UserstyleHeader}
/>
)))}
</div> </div>
</Forms.FormSection> </Forms.FormSection>
</> </>

View file

@ -0,0 +1,205 @@
/*
* Vencord, a Discord client mod
* Copyright (c) 2023 Vendicated and contributors
* SPDX-License-Identifier: GPL-3.0-or-later
*/
import { Settings, useSettings } from "@api/Settings";
import { Flex } from "@components/Flex";
import { CopyIcon, PasteIcon, ResetIcon } from "@components/Icons";
import { copyWithToast } from "@utils/misc";
import { ModalCloseButton, ModalContent, ModalHeader, ModalProps, ModalRoot } from "@utils/modal";
import { showToast, Text, Toasts, Tooltip } from "@webpack/common";
import { type ReactNode } from "react";
import { UserstyleHeader } from "usercss-meta";
import { SettingBooleanComponent, SettingColorComponent, SettingNumberComponent, SettingRangeComponent, SettingSelectComponent, SettingTextComponent } from "./components";
interface UserCSSSettingsModalProps {
modalProps: ModalProps;
theme: UserstyleHeader;
onSettingsReset: () => void;
}
function ExportButton({ themeSettings }: { themeSettings: Settings["userCssVars"][""]; }) {
return <Tooltip text={"Copy theme settings"}>
{({ onMouseLeave, onMouseEnter }) => (
<div
style={{ cursor: "pointer" }}
onMouseEnter={onMouseEnter}
onMouseLeave={onMouseLeave}
onClick={() => {
copyWithToast(JSON.stringify(themeSettings), "Copied theme settings to clipboard.");
}}>
<CopyIcon />
</div>
)}
</Tooltip>;
}
function ImportButton({ themeSettings }: { themeSettings: Settings["userCssVars"][""]; }) {
return <Tooltip text={"Paste theme settings"}>
{({ onMouseLeave, onMouseEnter }) => (
<div
style={{ cursor: "pointer" }}
onMouseEnter={onMouseEnter}
onMouseLeave={onMouseLeave}
onClick={async () => {
const clip = (await navigator.clipboard.read())[0];
if (!clip) return showToast("Your clipboard is empty.", Toasts.Type.FAILURE);
if (!clip.types.includes("text/plain"))
return showToast("Your clipboard doesn't have valid settings data.", Toasts.Type.FAILURE);
try {
var potentialSettings: Record<string, string> =
JSON.parse(await clip.getType("text/plain").then(b => b.text()));
} catch (e) {
return showToast("Your clipboard doesn't have valid settings data.", Toasts.Type.FAILURE);
}
for (const [key, value] of Object.entries(potentialSettings)) {
if (Object.prototype.hasOwnProperty.call(themeSettings, key))
themeSettings[key] = value;
}
showToast("Pasted theme settings from clipboard.", Toasts.Type.SUCCESS);
}}>
<PasteIcon />
</div>
)}
</Tooltip>;
}
interface ResetButtonProps {
themeSettings: Settings["userCssVars"];
themeId: string;
close: () => Promise<void>;
onReset: () => void;
}
function ResetButton({ themeSettings, themeId, close, onReset }: ResetButtonProps) {
return <Tooltip text={"Reset settings to default"}>
{({ onMouseLeave, onMouseEnter }) => (
<div
style={{ cursor: "pointer" }}
onMouseEnter={onMouseEnter}
onMouseLeave={onMouseLeave}
onClick={async () => {
await close(); // close the modal first to stop rendering
delete themeSettings[themeId];
onReset();
}}>
<ResetIcon />
</div>
)}
</Tooltip>;
}
export function UserCSSSettingsModal({ modalProps, theme, onSettingsReset }: UserCSSSettingsModalProps) {
// @ts-expect-error UseSettings<> can't determine this is a valid key
const { userCssVars } = useSettings(["userCssVars"], false);
const themeVars = userCssVars[theme.id];
const controls: ReactNode[] = [];
for (const [name, varInfo] of Object.entries(theme.vars)) {
switch (varInfo.type) {
case "text": {
controls.push(
<SettingTextComponent
label={varInfo.label}
name={name}
themeSettings={themeVars}
/>
);
break;
}
case "checkbox": {
controls.push(
<SettingBooleanComponent
label={varInfo.label}
name={name}
themeSettings={themeVars}
/>
);
break;
}
case "color": {
controls.push(
<SettingColorComponent
label={varInfo.label}
name={name}
themeSettings={themeVars}
/>
);
break;
}
case "number": {
controls.push(
<SettingNumberComponent
label={varInfo.label}
name={name}
themeSettings={themeVars}
/>
);
break;
}
case "select": {
controls.push(
<SettingSelectComponent
label={varInfo.label}
name={name}
options={varInfo.options}
default={varInfo.default}
themeSettings={themeVars}
/>
);
break;
}
case "range": {
controls.push(
<SettingRangeComponent
label={varInfo.label}
name={name}
default={varInfo.default}
min={varInfo.min}
max={varInfo.max}
step={varInfo.step}
themeSettings={themeVars}
/>
);
break;
}
}
}
return (
<ModalRoot {...modalProps}>
<ModalHeader separator={false}>
<Text variant="heading-lg/semibold" style={{ flexGrow: 1 }}>Settings for {theme.name}</Text>
<Flex style={{ gap: 4, marginRight: 4 }} className="vc-settings-usercss-ie-buttons">
<ExportButton themeSettings={themeVars} />
<ImportButton themeSettings={themeVars} />
<ResetButton themeSettings={userCssVars} themeId={theme.id} close={modalProps.onClose} onReset={onSettingsReset} />
</Flex>
<ModalCloseButton onClick={modalProps.onClose} />
</ModalHeader>
<ModalContent>
<Flex flexDirection="column" style={{ gap: 12, marginBottom: 16 }}>
{controls}
</Flex>
</ModalContent>
</ModalRoot>
);
}

View file

@ -0,0 +1,35 @@
/*
* Vencord, a Discord client mod
* Copyright (c) 2023 Vendicated and contributors
* SPDX-License-Identifier: GPL-3.0-or-later
*/
import { Forms, Switch } from "@webpack/common";
interface Props {
label: string;
name: string;
themeSettings: Record<string, string>;
}
export function SettingBooleanComponent({ label, name, themeSettings }: Props) {
function handleChange(value: boolean) {
const corrected = value ? "1" : "0";
themeSettings[name] = corrected;
}
return (
<Forms.FormSection>
<Switch
key={name}
value={themeSettings[name] === "1"}
onChange={handleChange}
hideBorder
style={{ marginBottom: "0.5em" }}
>
{label}
</Switch>
</Forms.FormSection>
);
}

View file

@ -0,0 +1,52 @@
/*
* Vencord, a Discord client mod
* Copyright (c) 2023 Vendicated and contributors
* SPDX-License-Identifier: GPL-3.0-or-later
*/
import "./colorStyles.css";
import { classNameFactory } from "@api/Styles";
import { findByCodeLazy, findComponentByCodeLazy } from "@webpack";
import { Forms, useMemo } from "@webpack/common";
interface ColorPickerProps {
color: number | null;
showEyeDropper?: boolean;
onChange(value: number | null): void;
}
const ColorPicker = findComponentByCodeLazy<ColorPickerProps>(".Messages.USER_SETTINGS_PROFILE_COLOR_SELECT_COLOR", ".BACKGROUND_PRIMARY)");
// TinyColor is completely unmangled and it's duplicated in two modules! Fun!
const TinyColor: tinycolor.Constructor = findByCodeLazy("this._gradientType=");
const cl = classNameFactory("vc-usercss-settings-color-");
interface Props {
label: string;
name: string;
themeSettings: Record<string, string>;
}
export function SettingColorComponent({ label, name, themeSettings }: Props) {
function handleChange(value: number) {
const corrected = "#" + (value?.toString(16).padStart(6, "0") ?? "000000");
themeSettings[name] = corrected;
}
const normalizedValue = useMemo(() => parseInt(TinyColor(themeSettings[name]).toHex(), 16), [themeSettings[name]]);
return (
<Forms.FormSection>
<div className={cl("swatch-row")}>
<span>{label}</span>
<ColorPicker
key={name}
color={normalizedValue}
onChange={handleChange}
/>
</div>
</Forms.FormSection>
);
}

View file

@ -0,0 +1,32 @@
/*
* Vencord, a Discord client mod
* Copyright (c) 2023 Vendicated and contributors
* SPDX-License-Identifier: GPL-3.0-or-later
*/
import { Forms, TextInput } from "@webpack/common";
interface Props {
label: string;
name: string;
themeSettings: Record<string, string>;
}
export function SettingNumberComponent({ label, name, themeSettings }: Props) {
function handleChange(value: string) {
themeSettings[name] = value;
}
return (
<Forms.FormSection>
<Forms.FormTitle tag="h5">{label}</Forms.FormTitle>
<TextInput
type="number"
pattern="-?[0-9]+"
key={name}
value={themeSettings[name]}
onChange={handleChange}
/>
</Forms.FormSection>
);
}

View file

@ -0,0 +1,52 @@
/*
* Vencord, a Discord client mod
* Copyright (c) 2023 Vendicated and contributors
* SPDX-License-Identifier: GPL-3.0-or-later
*/
import { Forms, Slider, useMemo } from "@webpack/common";
interface Props {
label: string;
name: string;
default: number;
min?: number;
max?: number;
step?: number;
themeSettings: Record<string, string>;
}
export function SettingRangeComponent({ label, name, default: def, min, max, step, themeSettings }: Props) {
function handleChange(value: number) {
const corrected = value.toString();
themeSettings[name] = corrected;
}
const markers = useMemo(() => {
const markers: number[] = [];
// defaults taken from https://github.com/openstyles/stylus/wiki/Writing-UserCSS#default-value
for (let i = (min ?? 0); i <= (max ?? 10); i += (step ?? 1)) {
markers.push(i);
}
return markers;
}, [min, max, step]);
return (
<Forms.FormSection>
<Forms.FormTitle tag="h5">{label}</Forms.FormTitle>
<Slider
initialValue={parseInt(themeSettings[name], 10)}
defaultValue={def}
onValueChange={handleChange}
minValue={min}
maxValue={max}
markers={markers}
stickToMarkers={true}
/>
</Forms.FormSection>
);
}

View file

@ -0,0 +1,51 @@
/*
* Vencord, a Discord client mod
* Copyright (c) 2023 Vendicated and contributors
* SPDX-License-Identifier: GPL-3.0-or-later
*/
import { identity } from "@utils/misc";
import { ComponentTypes, Forms, Select, useMemo } from "@webpack/common";
interface Props {
label: string;
name: string;
options: {
name: string;
label: string;
value: string;
}[];
default: string;
themeSettings: Record<string, string>;
}
export function SettingSelectComponent({ label, name, options, default: def, themeSettings }: Props) {
function handleChange(value: string) {
themeSettings[name] = value;
}
const opts = useMemo(() => options.map(option => ({
disabled: false,
key: option.name,
value: option.value,
default: def === option.name,
label: option.label
} satisfies ComponentTypes.SelectOption)), [options, def]);
return (
<Forms.FormSection>
<Forms.FormTitle tag="h5">{label}</Forms.FormTitle>
<Select
placeholder={label}
key={name}
options={opts}
closeOnSelect={true}
select={handleChange}
isSelected={v => v === themeSettings[name]}
serialize={identity}
/>
</Forms.FormSection>
);
}

View file

@ -0,0 +1,30 @@
/*
* Vencord, a Discord client mod
* Copyright (c) 2023 Vendicated and contributors
* SPDX-License-Identifier: GPL-3.0-or-later
*/
import { Forms, TextInput } from "@webpack/common";
interface Props {
label: string;
name: string;
themeSettings: Record<string, string>;
}
export function SettingTextComponent({ label, name, themeSettings }: Props) {
function handleChange(value: string) {
themeSettings[name] = value;
}
return (
<Forms.FormSection>
<Forms.FormTitle tag="h5">{label}</Forms.FormTitle>
<TextInput
key={name}
value={themeSettings[name]}
onChange={handleChange}
/>
</Forms.FormSection>
);
}

View file

@ -0,0 +1,19 @@
.vc-usercss-settings-color-swatch-row {
display: flex;
flex-direction: row;
width: 100%;
align-items: center;
}
.vc-usercss-settings-color-swatch-row > span {
display: block;
flex: 1;
overflow: hidden;
margin-top: 0;
margin-bottom: 0;
color: var(--header-primary);
line-height: 24px;
font-size: 16px;
font-weight: 500;
word-wrap: break-word;
}

View file

@ -0,0 +1,12 @@
/*
* Vencord, a Discord client mod
* Copyright (c) 2023 Vendicated and contributors
* SPDX-License-Identifier: GPL-3.0-or-later
*/
export * from "./SettingBooleanComponent";
export * from "./SettingColorComponent";
export * from "./SettingNumberComponent";
export * from "./SettingRangeComponent";
export * from "./SettingSelectComponent";
export * from "./SettingTextComponent";

View file

@ -27,3 +27,14 @@
.vc-settings-theme-author::before { .vc-settings-theme-author::before {
content: "by "; content: "by ";
} }
.vc-settings-usercss-ie-buttons > div {
color: var(--interactive-normal);
opacity: .5;
padding: 4px;
}
.vc-settings-usercss-ie-buttons > div:hover {
color: var(--interactive-hover);
opacity: 1;
}

View file

@ -17,7 +17,6 @@
*/ */
import "./settingsStyles.css"; import "./settingsStyles.css";
import "./themesStyles.css";
import ErrorBoundary from "@components/ErrorBoundary"; import ErrorBoundary from "@components/ErrorBoundary";
import { handleComponentFailed } from "@components/handleComponentFailed"; import { handleComponentFailed } from "@components/handleComponentFailed";

View file

@ -29,7 +29,6 @@ import { join, normalize } from "path";
import monacoHtml from "~fileContent/monacoWin.html;base64"; import monacoHtml from "~fileContent/monacoWin.html;base64";
import { getThemeInfo, stripBOM, UserThemeHeader } from "./themes";
import { ALLOWED_PROTOCOLS, QUICKCSS_PATH, SETTINGS_DIR, SETTINGS_FILE, THEMES_DIR } from "./utils/constants"; import { ALLOWED_PROTOCOLS, QUICKCSS_PATH, SETTINGS_DIR, SETTINGS_FILE, THEMES_DIR } from "./utils/constants";
import { makeLinksOpenExternally } from "./utils/externalLinks"; import { makeLinksOpenExternally } from "./utils/externalLinks";
@ -47,21 +46,11 @@ function readCss() {
return readFile(QUICKCSS_PATH, "utf-8").catch(() => ""); return readFile(QUICKCSS_PATH, "utf-8").catch(() => "");
} }
async function listThemes(): Promise<UserThemeHeader[]> { function listThemes(): Promise<{ fileName: string; content: string; }[]> {
const files = await readdir(THEMES_DIR).catch(() => []); return readdir(THEMES_DIR)
.then(files =>
const themeInfo: UserThemeHeader[] = []; Promise.all(files.map(async fileName => ({ fileName, content: await getThemeData(fileName) }))))
.catch(() => []);
for (const fileName of files) {
if (!fileName.endsWith(".css")) continue;
const data = await getThemeData(fileName).then(stripBOM).catch(() => null);
if (data == null) continue;
themeInfo.push(getThemeInfo(data, fileName));
}
return themeInfo;
} }
function getThemeData(fileName: string) { function getThemeData(fileName: string) {

View file

@ -101,7 +101,7 @@ export default definePlugin({
{ {
section: "VencordThemes", section: "VencordThemes",
label: "Themes", label: "Themes",
element: require("@components/VencordSettings/ThemesTab").default, element: require("@components/ThemeSettings/ThemesTab").default,
className: "vc-themes" className: "vc-themes"
}, },
!IS_UPDATER_DISABLED && { !IS_UPDATER_DISABLED && {

View file

@ -16,6 +16,9 @@
* along with this program. If not, see <https://www.gnu.org/licenses/>. * along with this program. If not, see <https://www.gnu.org/licenses/>.
*/ */
import type StylusRenderer = require("stylus/lib/renderer");
import type LessStatic from "less";
import { makeLazy } from "./lazy"; import { makeLazy } from "./lazy";
import { EXTENSION_BASE_URL } from "./web-metadata"; import { EXTENSION_BASE_URL } from "./web-metadata";
@ -84,3 +87,18 @@ export const shikiOnigasmSrc = "https://unpkg.com/@vap/shiki@0.10.3/dist/onig.wa
// @ts-expect-error // @ts-expect-error
export const getStegCloak = /* #__PURE__*/ makeLazy(() => import("https://unpkg.com/stegcloak-dist@1.0.0/index.js")); export const getStegCloak = /* #__PURE__*/ makeLazy(() => import("https://unpkg.com/stegcloak-dist@1.0.0/index.js"));
export const getStylus = /* #__PURE__*/ makeLazy(async () => {
const stylusScript = await fetch("https://unpkg.com/stylus-lang-bundle@0.58.1/dist/stylus-renderer.min.js").then(r => r.text());
// the stylus bundle doesn't have a header that checks for export conditions so we can just patch the script to
// return the renderer itself
const patchedScript = stylusScript.replace("var StylusRenderer=", "return ");
return Function(patchedScript)() as typeof StylusRenderer;
});
export const getLess = /* #__PURE__*/ makeLazy(async () => {
const lessScript = await fetch("https://unpkg.com/less@4.2.0/dist/less.min.js").then(r => r.text());
const module = { exports: {} };
Function("module", "exports", lessScript)(module, module.exports);
return module.exports as LessStatic;
});

View file

@ -17,7 +17,12 @@
*/ */
import { addSettingsListener, Settings } from "@api/Settings"; import { addSettingsListener, Settings } from "@api/Settings";
import { Toasts } from "@webpack/common";
import { Logger } from "./Logger";
import { compileUsercss } from "./themes/usercss/compiler";
const logger = new Logger("QuickCSS");
let style: HTMLStyleElement; let style: HTMLStyleElement;
let themesStyle: HTMLStyleElement; let themesStyle: HTMLStyleElement;
@ -62,15 +67,69 @@ async function initThemes() {
const links: string[] = [...themeLinks]; const links: string[] = [...themeLinks];
if (IS_WEB) { if (IS_WEB) {
for (const theme of enabledThemes) { // make copy so we can remove themes that are missing
const themeData = await VencordNative.themes.getThemeData(theme); for (let i = enabledThemes.length - 1; i >= 0; i--) {
if (!themeData) continue; const theme = enabledThemes[i];
try {
var themeData = await VencordNative.themes.getThemeData(theme);
} catch (e) {
logger.warn("Failed to get theme data for", theme, "(has it gone missing?)", e);
}
if (!themeData) {
// disable the theme since it has problems
Settings.enabledThemes = enabledThemes.splice(enabledThemes.indexOf(theme), 1);
continue;
}
const blob = new Blob([themeData], { type: "text/css" }); const blob = new Blob([themeData], { type: "text/css" });
links.push(URL.createObjectURL(blob)); links.push(URL.createObjectURL(blob));
} }
} else { } else {
const localThemes = enabledThemes.map(theme => `vencord:///themes/${theme}?v=${Date.now()}`); for (let i = enabledThemes.length - 1; i >= 0; i--) {
links.push(...localThemes); const theme = enabledThemes[i];
if (theme.endsWith(".user.css")) continue;
try {
// whilst this is unnecessary here, we're doing it to make sure the theme is valid
await VencordNative.themes.getThemeData(theme);
} catch (e) {
logger.warn("Failed to get theme data for", theme, "(has it gone missing?)", e);
Settings.enabledThemes = enabledThemes.splice(enabledThemes.indexOf(theme), 1);
continue;
}
links.push(`vencord:///themes/${theme}?v=${Date.now()}`);
}
}
if (!IS_WEB || "armcord" in window) {
for (let i = enabledThemes.length - 1; i >= 0; i--) {
const theme = enabledThemes[i];
if (!theme.endsWith(".user.css")) continue;
// UserCSS goes through a compile step first
const css = await compileUsercss(theme);
if (!css) {
// let's not leave the user in the dark about this and point them to where they can find the error
Toasts.show({
message: `Failed to compile ${theme}, check the console for more info.`,
type: Toasts.Type.FAILURE,
id: Toasts.genId(),
options: {
position: Toasts.Position.BOTTOM
}
});
Settings.enabledThemes = enabledThemes.splice(enabledThemes.indexOf(theme), 1);
continue;
}
const blob = new Blob([css], { type: "text/css" });
links.push(URL.createObjectURL(blob));
}
} }
themesStyle.textContent = links.map(link => `@import url("${link.trim()}");`).join("\n"); themesStyle.textContent = links.map(link => `@import url("${link.trim()}");`).join("\n");
@ -85,6 +144,7 @@ document.addEventListener("DOMContentLoaded", () => {
addSettingsListener("themeLinks", initThemes); addSettingsListener("themeLinks", initThemes);
addSettingsListener("enabledThemes", initThemes); addSettingsListener("enabledThemes", initThemes);
addSettingsListener("userCssVars", initThemes, false);
if (!IS_WEB) if (!IS_WEB)
VencordNative.quickCss.addThemeChangeListener(initThemes); VencordNative.quickCss.addThemeChangeListener(initThemes);

17
src/utils/themes/index.ts Normal file
View file

@ -0,0 +1,17 @@
/*
* Vencord, a Discord client mod
* Copyright (c) 2023 Vendicated and contributors
* SPDX-License-Identifier: GPL-3.0-or-later
*/
import type { UserstyleHeader } from "usercss-meta";
import type { UserThemeHeader } from "./bd";
export type ThemeHeader = {
type: "other";
header: UserThemeHeader;
} | {
type: "usercss";
header: UserstyleHeader;
};

View file

@ -0,0 +1,115 @@
/*
* Vencord, a Discord client mod
* Copyright (c) 2023 Vendicated and contributors
* SPDX-License-Identifier: GPL-3.0-or-later
*/
import { Settings } from "@api/Settings";
import { getLess, getStylus } from "@utils/dependencies";
import { Logger } from "@utils/Logger";
import { usercssParse } from ".";
const UserCSSLogger = new Logger("UserCSS:Compiler", "#d2acf5");
const preprocessors: { [preprocessor: string]: (text: string, vars: Record<string, string>) => Promise<string>; } = {
async default(text: string, vars: Record<string, string>) {
const variables = Object.entries(vars)
.map(([name, value]) => `--${name}: ${value}`)
.join("; ");
return `/* ==Vencord== */\n:root{${variables}}\n/* ==/Vencord== */${text}`;
},
async uso(text: string, vars: Record<string, string>) {
for (const [k, v] of Object.entries(vars)) {
text = text.replace(new RegExp(`\\/\\*\\[\\[${k}\\]\\]\\*\\/`, "g"), v);
}
return text;
},
async stylus(text: string, vars: Record<string, string>) {
const StylusRenderer = await getStylus();
const variables = Object.entries(vars)
.map(([name, value]) => `${name} = ${value}`)
.join("\n");
const stylusDoc = `// ==Vencord==\n${variables}\n// ==/Vencord==\n${text}`;
return new StylusRenderer(stylusDoc).render();
},
async less(text: string, vars: Record<string, string>) {
const less = await getLess();
const variables = Object.entries(vars)
.map(([name, value]) => `@${name}: ${value};`)
.join("\n");
const lessDoc = `// ==Vencord==\n${variables}\n// ==/Vencord==\n${text}`;
return less.render(lessDoc).then(r => r.css);
}
};
export async function compileUsercss(fileName: string) {
try {
var themeData = await VencordNative.themes.getThemeData(fileName);
} catch (e) {
UserCSSLogger.warn("Failed to get theme data for", fileName, "(has it gone missing?)", e);
}
if (!themeData) return null;
// UserCSS preprocessor order look like this:
// - use the preprocessor defined
// - if variables are set, `uso`
// - otherwise, `default`
const { vars = {}, preprocessor = Object.keys(vars).length > 0 ? "uso" : "default", id } = await usercssParse(themeData, fileName);
const preprocessorFn = preprocessors[preprocessor];
if (!preprocessorFn) {
UserCSSLogger.error("File", fileName, "requires preprocessor", preprocessor, "which isn't known to Vencord");
return null;
}
const varsToPass = {
vencord: "true"
};
for (const [k, v] of Object.entries(vars)) {
varsToPass[k] = Settings.userCssVars[id]?.[k] ?? v.default;
switch (v.type) {
case "checkbox": {
if (["less", "stylus"].includes(preprocessor)) {
varsToPass[k] = varsToPass[k] === "1" ? "true" : "false";
}
break;
}
case "range": {
varsToPass[k] = `${varsToPass[k]}${v.units ?? "px"}`;
break;
}
case "text": {
if (preprocessor === "stylus") {
varsToPass[k] = `"${varsToPass[k].replace(/"/g, "\" + '\"' + \"")}"`;
} else {
varsToPass[k] = `"${varsToPass[k].replace(/\//g, "\\\\").replace(/"/g, '\\"')}"`;
}
break;
}
}
}
try {
return await preprocessorFn(themeData, varsToPass);
} catch (error) {
UserCSSLogger.error("File", fileName, "failed to compile with preprocessor", preprocessor, error);
return null;
}
}

View file

@ -0,0 +1,44 @@
/*
* Vencord, a Discord client mod
* Copyright (c) 2023 Vendicated and contributors
* SPDX-License-Identifier: GPL-3.0-or-later
*/
import { Logger } from "@utils/Logger";
import { parse as originalParse, UserstyleHeader } from "usercss-meta";
const UserCSSLogger = new Logger("UserCSS", "#d2acf5");
export async function usercssParse(text: string, fileName: string): Promise<UserstyleHeader> {
const { metadata, errors } = originalParse(text.replace(/\r/g, ""), {
allowErrors: true,
unknownKey: "assign"
});
if (errors.length) {
UserCSSLogger.warn("Parsed", fileName, "with errors:", errors);
}
const requiredPlugins = metadata["vc-requiredPlugins"]?.split(",").map(p => p.trim());
return {
...metadata,
fileName,
id: await getUserCssId(metadata),
requiredPlugins
};
}
export async function getUserCssId(header: UserstyleHeader): Promise<string> {
const encoder = new TextEncoder();
const nameBuf = encoder.encode(header.name);
const namespaceBuf = encoder.encode(header.namespace);
const nameHash = new Uint8Array(await window.crypto.subtle.digest("SHA-256", nameBuf));
const namespaceHash = new Uint8Array(await window.crypto.subtle.digest("SHA-256", namespaceBuf));
const idHash = await window.crypto.subtle.digest("SHA-256", new Uint8Array([...nameHash, ...namespaceHash]));
return window.btoa(String.fromCharCode(...new Uint8Array(idHash))).substring(0, 43); // base64 adds one more padding character
}

View file

@ -0,0 +1,134 @@
/*
* Vencord, a Discord client mod
* Copyright (c) 2023 Vendicated and contributors
* SPDX-License-Identifier: GPL-3.0-or-later
*/
declare module "usercss-meta" {
import { Simplify } from "type-fest";
export type UserCSSVariable = Simplify<{ name: string; label: string; } & (
| {
type: "text";
default: string;
}
| {
type: "color";
// Hex, rgb(), rgba()
default: string;
}
| {
type: "checkbox";
default: string;
}
| {
type: "range";
default: number;
min?: number;
max?: number;
step?: number;
units?: string;
}
| {
type: "number";
default: number;
}
| {
type: "select";
default: string;
options: { name: string; label: string; value: string; }[];
}
)>;
export interface UserstyleHeader {
/**
* The unique ID of the UserCSS style.
*
* @vencord Specific to Vencord, not part of the original module.
*/
id: string;
/**
* The file name of the UserCSS style.
*
* @vencord Specific to Vencord, not part of the original module.
*/
fileName: string;
/**
* The required plugins for this style.
*
* @vencord Specific to Vencord, not part of the original module.
* @see {@link vc-requiredPlugins}
*/
requiredPlugins?: string[];
/**
* The name of your style.
*
* The combination of {@link name} and {@link namespace} must be unique.
*/
name: string;
/**
* The namespace of the style. Helps to distinguish between styles with the same name.
*
* The combination of {@link name} and {@link namespace} must be unique.
*/
namespace: string;
/**
* The version of your style.
*/
version: string;
/**
* A short significant description.
*/
description?: string;
/**
* The author of the style.
*/
author?: string;
/**
* The project's homepage.
*
* This is not an update URL. See {@link updateURL}.
*/
homepageURL?: string;
/**
* The URL the user can report issues to the style author.
*/
supportURL?: string;
/**
* The URL used when updating the style.
*/
updateURL?: string;
/**
* The SPDX license identifier for this style. If none is included, the style is assumed to be All Rights Reserved.
*/
license?: string;
/**
* The CSS preprocessor used to write this style.
*/
preprocessor?: "default" | "uso" | "less" | "stylus";
/**
* A list of variables the style defines.
*/
vars: Record<string, UserCSSVariable>;
/**
* Required plugins for this style to work. Comma-separated list of plugin names.
*
* @vencord This is a Vencord-specific extension, however we wish for this to become a standard for client mods
* to implement, hence the more generic namespaced name.
*/
"vc-requiredPlugins"?: string;
}
type UserCSSParseOptions = {
allowErrors: boolean;
unknownKey: "assign";
};
export function parse(text: string, options: UserCSSParseOptions): { metadata: UserstyleHeader; errors: { code: string; args: any; }[]; };
}

View file

@ -191,7 +191,7 @@ export type TextArea = ComponentType<PropsWithRef<Omit<HTMLProps<HTMLTextAreaEle
onChange(v: string): void; onChange(v: string): void;
}>>; }>>;
interface SelectOption { export interface SelectOption {
disabled?: boolean; disabled?: boolean;
value: any; value: any;
label: string; label: string;