diff --git a/browser/VencordNativeStub.ts b/browser/VencordNativeStub.ts index 746d41e48..dbd86aae1 100644 --- a/browser/VencordNativeStub.ts +++ b/browser/VencordNativeStub.ts @@ -45,7 +45,7 @@ window.VencordNative = { deleteTheme: (fileName: string) => DataStore.del(fileName, themeStore), getThemesDir: async () => "", getThemesList: () => DataStore.entries(themeStore).then(entries => - entries.map(([name, css]) => ({ fileName: name as string, content: css })) + entries.map(([name, css]) => getThemeInfo(css, name.toString())) ), getThemeData: (fileName: string) => DataStore.get(fileName, themeStore), getSystemValues: async () => ({}), diff --git a/package.json b/package.json index 38f399325..55de31691 100644 --- a/package.json +++ b/package.json @@ -47,9 +47,6 @@ "@intrnl/xxhash64": "^0.1.2", "@song-spotlight/api": "^2.2.0", "@streamparser/json": "^0.0.22", - "@types/less": "^3.0.8", - "@types/stylus": "^0.48.42", - "@types/tinycolor2": "^1.4.6", "@vap/core": "0.0.12", "@vap/shiki": "0.10.5", "fflate": "^0.8.3", @@ -60,7 +57,6 @@ "monaco-editor": "^0.55.1", "nanoid": "^5.1.16", "native-file-system-adapter": "^3.0.1", - "usercss-meta": "^0.12.0", "virtual-merge": "^1.0.1", "yaml-js": "^0.3.1" }, @@ -101,7 +97,7 @@ "typescript-transform-paths": "^3.5.6", "zip-local": "^0.3.5" }, - "packageManager": "pnpm@11.10.0", + "packageManager": "pnpm@11.12.0", "engines": { "node": ">=22" } diff --git a/patches/@types__less@3.0.8.patch b/patches/@types__less@3.0.8.patch deleted file mode 100644 index b9791284d..000000000 --- a/patches/@types__less@3.0.8.patch +++ /dev/null @@ -1,13 +0,0 @@ -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; \ No newline at end of file diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 8e7809a23..d5cb50d58 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -4,9 +4,6 @@ settings: autoInstallPeers: true excludeLinksFromLockfile: false -patchedDependencies: - '@types/less@3.0.8': 641e6c93bb737bac7fc283416857bd095cd85bcbcba63becb7a8bbcc78f73076 - importers: .: @@ -26,15 +23,6 @@ importers: '@streamparser/json': specifier: ^0.0.22 version: 0.0.22 - '@types/less': - specifier: ^3.0.8 - version: 3.0.8(patch_hash=641e6c93bb737bac7fc283416857bd095cd85bcbcba63becb7a8bbcc78f73076) - '@types/stylus': - specifier: ^0.48.42 - version: 0.48.42 - '@types/tinycolor2': - specifier: ^1.4.6 - version: 1.4.6 '@vap/core': specifier: 0.0.12 version: 0.0.12 @@ -65,9 +53,6 @@ importers: native-file-system-adapter: specifier: ^3.0.1 version: 3.0.1 - usercss-meta: - specifier: ^0.12.0 - version: 0.12.0 virtual-merge: specifier: ^1.0.1 version: 1.0.2 @@ -622,9 +607,6 @@ packages: '@types/jsonfile@6.1.4': resolution: {integrity: sha512-D5qGUYwjvnNNextdU59/+fI+spnwtTFmyQP0h+PfIOSkNfpU6AOICUOkm4i0OnSk+NyjdPJrxCDro0sJsWlRpQ==} - '@types/less@3.0.8': - resolution: {integrity: sha512-Gjm4+H9noDJgu5EdT3rUw5MhPBag46fiOy27BefvWkNL8mlZnKnCaVVVTLKj6RYXed9b62CPKnPav9govyQDzA==} - '@types/lodash@4.17.19': resolution: {integrity: sha512-NYqRyg/hIQrYPT9lbOeYc3kIRabJDn/k4qQHIXUpx88CBDww2fD15Sg5kbXlW86zm2XEW4g0QxkTI3/Kfkc7xQ==} @@ -634,9 +616,6 @@ packages: '@types/node@22.20.0': resolution: {integrity: sha512-QWlFW2wf3nTjC13/DqRnBpR4ZO36VJH/JVBkA/vcnmbTBNQIlnObqyqZE1tUR7+Ni23Lda8R1BxMfbXRpCUx5g==} - '@types/node@25.6.0': - resolution: {integrity: sha512-+qIYRKdNYJwY3vRCZMdJbPLJAtGjQBudzZzdzwQYkEPQd+PJGixUL5QfvCLDaULoLv+RhT3LDkwEfKaAkgSmNQ==} - '@types/node@26.0.1': resolution: {integrity: sha512-fc3KiUoBt6kie0N9bIW3E47vZsuaMf0PM2AaUpLCLT0s/LvX1nxAim6Fc049cNxODPpGm6qRAuUOB86SkRuPQw==} @@ -656,12 +635,6 @@ packages: '@types/react@19.1.0': resolution: {integrity: sha512-UaicktuQI+9UKyA4njtDOGBD/67t8YEBt2xdfqu8+gP9hqPUPsiXlNPcpS2gVdjmis5GKPG3fCxbQLVgxsQZ8w==} - '@types/stylus@0.48.42': - resolution: {integrity: sha512-CPGlr5teL4sqdap+EOowMifLuNGeIoLwc0VQ7u/BPxo+ocqiNa5jeVt0H0IVBblEh6ZwX1sGpIQIFnSSr8NBQA==} - - '@types/tinycolor2@1.4.6': - resolution: {integrity: sha512-iEN8J0BoMnsWBqjVbWH/c0G0Hh7O21lpR2/+PrvAVgWdzL7eexIFm4JN/Wn10PTcmNdtS6U67r499mlWMXOxNw==} - '@types/trusted-types@2.0.7': resolution: {integrity: sha512-ScaPdn1dQczgbl0QFTeTOmVHFULt394XJgOQNoyVhZ6r2vLnMLJfBPd53SB52T/3G36VI1/g2MZaX0cwDuXsfw==} @@ -2341,9 +2314,6 @@ packages: undici-types@6.21.0: resolution: {integrity: sha512-iwDZqg0QAGrg9Rav5H4n0M64c3mkR59cJ6wQp+7C4nI0gsmExaedaYLNO44eT4AtBBwjbTiGPMlt2Md0T9H9JQ==} - undici-types@7.19.2: - resolution: {integrity: sha512-qYVnV5OEm2AW8cJMCpdV20CDyaN3g0AjDlOGf1OW4iaDEx8MwdtChUp4zu4H0VP3nDRF/8RKWH+IPp9uW0YGZg==} - undici-types@8.3.0: resolution: {integrity: sha512-j375ScV60dom+YkPFIfTLcOiPxkN/buHz5GobjLhixFuANaNs3C9l4GmrWqejgXWJ7BbJcFYpTEUkS1Ge8bpZQ==} @@ -2358,10 +2328,6 @@ packages: uri-js@4.4.1: resolution: {integrity: sha512-7rKUyy33Q1yc98pQ1DAmLtwX109F7TIfWlW1Ydo8Wl1ii1SeHieeh0HHfPeL2fMXK6z0s8ecKs9frCuLJvndBg==} - usercss-meta@0.12.0: - resolution: {integrity: sha512-zKrXCKdpeIwtVe87omxGo9URf+7mbozduMZEg79dmT4KB3XJwfIkEi/Uk0PcTwR/nZLtAK1+k7isgbGB/g6E7Q==} - engines: {node: '>=8.3'} - util-deprecate@1.0.2: resolution: {integrity: sha512-EPD5q1uXyFxJpCrLnCc1nHnq3gOa6DZBocAIiI2TaSCA7VCJ1UJDMagCzIkXNsUYfD1daK//LTEQ8xiIbrHtcw==} @@ -2761,8 +2727,6 @@ snapshots: dependencies: '@types/node': 26.0.1 - '@types/less@3.0.8(patch_hash=641e6c93bb737bac7fc283416857bd095cd85bcbcba63becb7a8bbcc78f73076)': {} - '@types/lodash@4.17.19': {} '@types/lodash@4.17.24': {} @@ -2771,10 +2735,6 @@ snapshots: dependencies: undici-types: 6.21.0 - '@types/node@25.6.0': - dependencies: - undici-types: 7.19.2 - '@types/node@26.0.1': dependencies: undici-types: 8.3.0 @@ -2795,12 +2755,6 @@ snapshots: dependencies: csstype: 3.2.3 - '@types/stylus@0.48.42': - dependencies: - '@types/node': 25.6.0 - - '@types/tinycolor2@1.4.6': {} - '@types/trusted-types@2.0.7': optional: true @@ -4929,8 +4883,6 @@ snapshots: undici-types@6.21.0: {} - undici-types@7.19.2: {} - undici-types@8.3.0: {} unicorn-magic@0.4.0: {} @@ -4941,8 +4893,6 @@ snapshots: dependencies: punycode: 2.3.1 - usercss-meta@0.12.0: {} - util-deprecate@1.0.2: {} virtual-merge@1.0.2: {} diff --git a/pnpm-workspace.yaml b/pnpm-workspace.yaml index c6cfde866..fec5edddc 100644 --- a/pnpm-workspace.yaml +++ b/pnpm-workspace.yaml @@ -1,7 +1,5 @@ packages: - packages/* -patchedDependencies: - "@types/less@3.0.8": patches/@types__less@3.0.8.patch peerDependencyRules: ignoreMissing: - eslint-plugin-import @@ -18,4 +16,4 @@ allowBuilds: pmOnFail: warn verifyDepsBeforeRun: prompt minimumReleaseAgeExclude: - - '@song-spotlight/api@2.2.0' + - "@song-spotlight/api@2.2.0" diff --git a/src/VencordNative.ts b/src/VencordNative.ts index 59969777f..a5ca42366 100644 --- a/src/VencordNative.ts +++ b/src/VencordNative.ts @@ -7,6 +7,7 @@ import type { Settings } from "@api/Settings"; import type { CspRequestResult } from "@main/csp/manager"; import type { PluginIpcMappings } from "@main/ipcPlugins"; +import { UserThemeHeader } from "@main/themes"; import { IpcEvents } from "@shared/IpcEvents"; import type { IpcRes } from "@utils/types"; import { ipcRenderer } from "electron/renderer"; @@ -34,9 +35,10 @@ export default { uploadTheme: async (fileName: string, fileData: string): Promise => { throw new Error("uploadTheme is WEB only"); }, - deleteTheme: (fileName: string) => invoke(IpcEvents.DELETE_THEME, fileName), - getThemesDir: () => invoke(IpcEvents.GET_THEMES_DIR), - getThemesList: () => invoke>(IpcEvents.GET_THEMES_LIST), + deleteTheme: async (fileName: string): Promise => { + throw new Error("deleteTheme is WEB only"); + }, + getThemesList: () => invoke>(IpcEvents.GET_THEMES_LIST), getThemeData: (fileName: string) => invoke(IpcEvents.GET_THEME_DATA, fileName), getSystemValues: () => invoke>(IpcEvents.GET_THEME_SYSTEM_VALUES), diff --git a/src/api/Settings.ts b/src/api/Settings.ts index 767d9b269..069b13929 100644 --- a/src/api/Settings.ts +++ b/src/api/Settings.ts @@ -101,12 +101,6 @@ export interface Settings { }; ignoreResetWarning: boolean; - - userCssVars: { - [themeId: string]: { - [varName: string]: string; - }; - }; } const DefaultSettings: Settings = { @@ -153,8 +147,6 @@ const DefaultSettings: Settings = { }, ignoreResetWarning: false, - - userCssVars: {} }; const settings = !IS_REPORTER ? VencordNative.settings.get() : {} as Settings; diff --git a/src/components/ThemeSettings/ThemesTab.tsx b/src/components/ThemeSettings/ThemesTab.tsx deleted file mode 100644 index f6e137d94..000000000 --- a/src/components/ThemeSettings/ThemesTab.tsx +++ /dev/null @@ -1,1099 +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 . -*/ - -import "./themesStyles.css"; - -import { isPluginEnabled } from "@api/PluginManager"; -import { Settings, type ThemeActivationMode, useSettings } from "@api/Settings"; -import { Button } from "@components/Button"; -import { Divider } from "@components/Divider"; -import { ErrorCard } from "@components/ErrorCard"; -import { Flex } from "@components/Flex"; -import { FormSwitch } from "@components/FormSwitch"; -import { Heading } from "@components/Heading"; -import { CogWheel, DeleteIcon, FolderIcon, PaintbrushIcon, PencilIcon, PluginIcon, PlusIcon, RestartIcon } from "@components/Icons"; -import { Link } from "@components/Link"; -import { Notice } from "@components/Notice"; -import { Paragraph } from "@components/Paragraph"; -import { AddonCard, openPluginModal, QuickAction, QuickActionCard, SettingsTab, wrapTab } from "@components/settings"; -import { OnlineThemeCard } from "@components/settings/OnlineThemeCard"; -import { CspBlockedUrls, useCspErrors } from "@utils/cspViolations"; -import { classNameFactory } from "@utils/css"; -import { copyWithToast, openInviteModal } from "@utils/discord"; -import { Margins } from "@utils/margins"; -import { classes } from "@utils/misc"; -import { relaunch, showItemInFolder } from "@utils/native"; -import { useAwaiter, useForceUpdater } 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 { getStylusWebStoreUrl } from "@utils/web"; -import { findComponentByCodeLazy, findLazy } from "@webpack"; -import { Alerts, Menu, openModal, React, Select, showToast, TextInput, Toasts, Tooltip, useEffect, useMemo, useState } from "@webpack/common"; -import { ContextMenuApi } from "@webpack/common/menu"; -import type { ComponentType, Ref, SyntheticEvent } from "react"; -import type { UserstyleHeader } from "usercss-meta"; - -import Plugins from "~plugins"; - -const PinIcon = findComponentByCodeLazy("1-.06-.63L6.16"); -const HomeIcon = findComponentByCodeLazy("m2.4 8.4 8.38-6.46a2"); -const RefreshIcon = findComponentByCodeLazy("M21 2a1 1 0 0 1 1 1v6"); -const LinkIcon = findComponentByCodeLazy("M16.32 14.72a1 1 0 0 1 0-1.41l2.51-2.51"); -const DiscordIcon = findComponentByCodeLazy("1.6 5.64-2.87"); -const DownloadIcon = findComponentByCodeLazy("1.42l3.3 3.3V3a1"); - -const themeActivationModeOptions: { value: ThemeActivationMode; label: string; }[] = [ - { value: "always", label: "Always on" }, - { value: "light", label: "Light only" }, - { value: "dark", label: "Dark only" } -]; - -function getThemeActivationModeLabel(mode: ThemeActivationMode) { - return themeActivationModeOptions.find(option => option.value === mode)?.label ?? "Always on"; -} - -function inferThemeActivationMode(css: string) { - let text = css.replace(/^\uFEFF/, ""); - - while (true) { - const trimmed = text.trimStart(); - if (trimmed !== text) text = trimmed; - - const comment = /^\/\*[\s\S]*?\*\/\s*/.exec(text); - if (!comment) break; - text = text.slice(comment[0].length); - } - - const match = /^@(light|dark)\b/i.exec(text); - return match?.[1].toLowerCase() as ThemeActivationMode | undefined; -} - -function inferAndStoreThemeActivationMode(themeId: string, css: string) { - const activationMode = Settings.themeActivationModes?.[themeId] ?? inferThemeActivationMode(css); - if (!activationMode || themeId in (Settings.themeActivationModes ?? {})) return; - - Settings.themeActivationModes = { - ...(Settings.themeActivationModes ?? {}), - [themeId]: activationMode, - }; -} - -function ThemeActivationMenu({ themeId, activationMode, onActivationModeChange, children }: { - themeId: string; - activationMode: ThemeActivationMode; - onActivationModeChange?: (mode: ThemeActivationMode) => void; - children?: React.ReactNode; -}) { - const [selectedMode, setSelectedMode] = useState(activationMode); - - return ( - - {onActivationModeChange && ( - - {themeActivationModeOptions.map(option => ( - { - setSelectedMode(option.value); - onActivationModeChange(option.value); - }} - /> - ))} - - )} - {children} - - ); -} - -function LocalThemeIcon({ size }: { size?: string; }) { - const sizeVal = size === "sm" ? 16 : 24; - return ( - - - - ); -} - -function OnlineThemeIcon({ size }: { size?: string; }) { - const sizeVal = size === "sm" ? 16 : 24; - return ( - - - - ); -} - -import { UserCSSSettingsModal } from "./UserCSSModal"; - -type FileInput = ComponentType<{ - ref: Ref; - onChange: (e: SyntheticEvent) => void; - multiple?: boolean; - filters?: { name?: string; extensions: string[]; }[]; -}>; -const FileInput: FileInput = findLazy(m => m.prototype?.activateUploadDialogue && m.prototype.setRef); - -const cl = classNameFactory("vc-settings-theme-"); - -enum ThemeFilter { - All = "all", - Online = "online", - Local = "local", - Enabled = "enabled", - Disabled = "disabled" -} - -const filterOptions = [ - { label: "Show All", value: ThemeFilter.All }, - { label: "Online Themes", value: ThemeFilter.Online }, - { label: "Local Themes", value: ThemeFilter.Local }, - { label: "Enabled", value: ThemeFilter.Enabled }, - { label: "Disabled", value: ThemeFilter.Disabled } -]; - -function Validator({ link, onValidate }: { link: string; onValidate: (valid: boolean) => void; }) { - const [res, err, pending] = useAwaiter(() => fetch(link).then(res => { - if (res.status > 300) throw `${res.status} ${res.statusText}`; - const contentType = res.headers.get("Content-Type"); - if (!contentType?.startsWith("text/css") && !contentType?.startsWith("text/plain")) { - onValidate(false); - throw "Not a CSS file. Remember to use the raw link!"; - } - - onValidate(true); - return "Okay!"; - })); - - const text = pending - ? "Checking..." - : err - ? `Error: ${err instanceof Error ? err.message : String(err)}` - : "Valid!"; - - return {text}; -} - -interface OtherThemeCardProps { - theme: UserThemeHeader; - enabled: boolean; - onChange: (enabled: boolean) => void; - onDelete: () => void; - showDeleteButton?: boolean; - onEditName?: (newName: string) => void; - disabled?: boolean; - onPin?: () => void; - isPinned?: boolean; - onRefresh?: () => void; - onOpenFolder?: () => void; - onCopyUrl?: () => void; - onDownload?: () => void; - themeLink?: string; - isLocal?: boolean; - activationMode?: ThemeActivationMode; - onActivationModeChange?: (mode: ThemeActivationMode) => void; -} - -interface UserCSSCardProps { - theme: UserstyleHeader; - enabled: boolean; - onChange: (enabled: boolean) => void; - onDelete: () => void; - onSettingsReset: () => void; - activationMode?: ThemeActivationMode; - onActivationModeChange?: (mode: ThemeActivationMode) => void; -} - -function UserCSSThemeCard({ theme, enabled, onChange, onDelete, onSettingsReset, activationMode = "always", onActivationModeChange }: UserCSSCardProps) { - const missingPlugins = useMemo(() => - theme.requiredPlugins?.filter(p => !isPluginEnabled(p)), [theme]); - - const openThemeMenu = (e: React.MouseEvent) => { - ContextMenuApi.openContextMenu(e, () => ( - - {theme.vars && ( - openModal(modalProps => - ) - } - /> - )} - {IS_WEB && ( - <> - - - - )} - - )); - }; - - const canOpenThemeMenu = !!theme.vars || !!onActivationModeChange || IS_WEB; - - return ( - 0} - infoButton={ - <> - {missingPlugins && missingPlugins.length > 0 && ( - - {({ onMouseLeave, onMouseEnter }) => ( -
- -
- )} -
- )} - {canOpenThemeMenu && ( -
- -
- )} - - } - footer={ - - {!!theme.homepageURL && Homepage} - {!!(theme.homepageURL && theme.supportURL) && ( - • - )} - {!!theme.supportURL && Support} - {activationMode !== "always" && ( - <> - {!!(theme.homepageURL || theme.supportURL) && •} - {getThemeActivationModeLabel(activationMode)} - - )} - - } - /> - ); -} - -function OtherThemeCard({ theme, enabled, onChange, onDelete, showDeleteButton, onEditName, disabled, onPin, isPinned, onRefresh, onOpenFolder, onCopyUrl, onDownload, themeLink, isLocal, activationMode = "always", onActivationModeChange }: OtherThemeCardProps) { - const openThemeMenu = (e: React.MouseEvent) => { - ContextMenuApi.openContextMenu(e, () => ( - - {onPin && ( - - )} - {theme.website && ( - window.open(theme.website, "_blank")} - /> - )} - {theme.invite && ( - { - openInviteModal(theme.invite!).catch(() => - showToast("Invalid or expired invite") - ); - }} - /> - )} - {onCopyUrl && themeLink && ( - - )} - {onDownload && ( - - )} - {onOpenFolder && ( - - )} - {onRefresh && ( - - )} - {(IS_WEB || showDeleteButton) && onDelete && ( - <> - - onDelete()} - /> - - )} - - )); - }; - - return ( - - - - ) - } - footer={ - - - {({ onMouseLeave, onMouseEnter }) => ( -
- {isLocal ? : } -
- )} -
- {isPinned && ( - - {({ onMouseLeave, onMouseEnter }) => ( -
- -
- )} -
- )} - {!!theme.website && Website} - {!!(theme.website && theme.invite) && ( - • - )} - {!!theme.invite && ( - { - e.preventDefault(); - theme.invite != null && - openInviteModal(theme.invite).catch(() => - showToast("Invalid or expired invite") - ); - }} - > - Discord Server - - )} - {activationMode !== "always" && ( - <> - {!!(theme.website || theme.invite) && •} - {getThemeActivationModeLabel(activationMode)} - - )} -
- } - - onEditName={onEditName} - /> - ); -} - -interface UnifiedTheme { - type: "local" | "online"; - themeType: "usercss" | "other"; - name: string; - enabled: boolean; - header: UserThemeHeader | UserstyleHeader; - link?: string; - activationMode: ThemeActivationMode; -} - -function ThemesTab() { - const settings = useSettings(["themeLinks", "enabledThemeLinks", "enabledThemes", "enableOnlineThemes", "pinnedThemes", "themeActivationModes.*"]); - - const fileInputRef = useState(null)[1]; - const [currentThemeLink, setCurrentThemeLink] = useState(""); - const [themeLinkValid, setThemeLinkValid] = useState(false); - const [userThemes, setUserThemes] = useState(null); - const [onlineThemes, setOnlineThemes] = useState<(UserThemeHeader & { link: string; })[] | null>(null); - const [themeNames, setThemeNames] = useState>(() => { - return settings.themeNames ?? {}; - }); - const [themeDir] = useAwaiter(VencordNative.themes.getThemesDir); - const [searchQuery, setSearchQuery] = useState(""); - const [filter, setFilter] = useState(ThemeFilter.All); - - useEffect(() => { - void updateThemes(); - }, []); - - async function updateThemes() { - changeThemeLibraryURLs(); - await Promise.allSettled([refreshLocalThemes(), refreshOnlineThemes()]); - } - - function changeThemeLibraryURLs() { - settings.themeLinks = settings.themeLinks.map(link => { - if (link.startsWith("https://discord-themes.com/api")) { - return link.replace("https://discord-themes.com/api", "https://themes.equicord.org/api"); - } - return link; - }); - } - - async function refreshLocalThemes() { - const themes = await VencordNative.themes.getThemesList(); - const themeInfo: ThemeHeader[] = []; - - for (const { fileName, content } of themes) { - if (!fileName.endsWith(".css")) continue; - - try { - inferAndStoreThemeActivationMode(fileName, content); - - if ((!IS_WEB || "legcord" in window) && fileName.endsWith(".user.css")) { - 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 { - themeInfo.push({ - type: "other", - header: getThemeInfo(stripBOM(content), fileName) - }); - } - } catch { - continue; - } - } - - setUserThemes(themeInfo); - } - - function onLocalThemeChange(fileName: string, value: boolean) { - if (value) { - if (settings.enabledThemes.includes(fileName)) return; - settings.enabledThemes = [...settings.enabledThemes, fileName]; - } else { - settings.enabledThemes = settings.enabledThemes.filter(f => f !== fileName); - } - } - - async function onFileUpload(e: SyntheticEvent) { - e.stopPropagation(); - e.preventDefault(); - if (!e.currentTarget?.files?.length) return; - const { files } = e.currentTarget; - - const uploads = Array.from(files, file => { - const { name } = file; - if (!name.endsWith(".css")) return; - - return new Promise((resolve, reject) => { - const reader = new FileReader(); - reader.onload = () => { - VencordNative.themes.uploadTheme(name, reader.result as string) - .then(resolve) - .catch(reject); - }; - reader.readAsText(file); - }); - }); - - await Promise.all(uploads); - refreshLocalThemes(); - } - - function addThemeLink(link: string) { - if (!themeLinkValid) return; - if (settings.themeLinks.includes(link)) return; - - settings.themeLinks = [...settings.themeLinks, link]; - setCurrentThemeLink(""); - refreshOnlineThemes(); - } - - async function refreshOnlineThemes() { - const themes = await Promise.all( - settings.themeLinks.map(async link => { - try { - const res = await fetch(link); - if (!res.ok) throw new Error(`Failed to fetch ${link}`); - const css = await res.text(); - inferAndStoreThemeActivationMode(link, css); - - return { ...getThemeInfo(css, link), link }; - } catch { - return null; - } - }) - ); - setOnlineThemes(themes.filter(theme => theme !== null)); - } - - function onThemeLinkEnabledChange(link: string, enabled: boolean) { - if (enabled) { - if (settings.enabledThemeLinks.includes(link)) return; - settings.enabledThemeLinks = [...settings.enabledThemeLinks, link]; - } else { - settings.enabledThemeLinks = settings.enabledThemeLinks.filter(f => f !== link); - } - } - - function clearThemeState(themeId: string) { - settings.pinnedThemes = settings.pinnedThemes.filter(f => f !== themeId); - settings.enabledThemes = settings.enabledThemes.filter(f => f !== themeId); - settings.enabledThemeLinks = settings.enabledThemeLinks.filter(f => f !== themeId); - settings.themeNames = Object.fromEntries(Object.entries(settings.themeNames).filter(([key]) => key !== themeId)); - - const themeActivationModes = { ...(settings.themeActivationModes ?? {}) }; - delete themeActivationModes[themeId]; - settings.themeActivationModes = themeActivationModes; - } - - function deleteThemeLink(link: string) { - settings.themeLinks = settings.themeLinks.filter(f => f !== link); - clearThemeState(link); - refreshOnlineThemes(); - } - - function setThemeActivationMode(themeId: string, mode: ThemeActivationMode) { - const themeActivationModes = { ...(settings.themeActivationModes ?? {}) }; - - if (mode === "always") { - delete themeActivationModes[themeId]; - } else { - themeActivationModes[themeId] = mode; - } - - settings.themeActivationModes = themeActivationModes; - } - - function togglePinTheme(themeId: string) { - if (settings.pinnedThemes.includes(themeId)) { - settings.pinnedThemes = settings.pinnedThemes.filter(f => f !== themeId); - } else { - settings.pinnedThemes = [...settings.pinnedThemes, themeId]; - } - } - - async function refreshOnlineTheme(link: string) { - try { - const res = await fetch(link); - if (!res.ok) throw new Error(`Failed to fetch ${link}`); - const css = await res.text(); - inferAndStoreThemeActivationMode(link, css); - - const updatedTheme = { ...getThemeInfo(css, link), link }; - - setOnlineThemes(prev => - prev?.map(t => t.link === link ? updatedTheme : t) ?? null - ); - showToast("Theme refreshed!", Toasts.Type.SUCCESS); - } catch { - showToast("Failed to refresh theme", Toasts.Type.FAILURE); - } - } - - async function downloadTheme(link: string, name: string) { - try { - const res = await fetch(link); - if (!res.ok) throw new Error(`Failed to fetch ${link}`); - const css = await res.text(); - const fileName = name.replace(/[^a-z0-9]/gi, "-") + ".css"; - - if (IS_DISCORD_DESKTOP) { - DiscordNative.fileManager.saveWithDialog(new TextEncoder().encode(css), fileName); - } else { - const blob = new Blob([css], { type: "text/css" }); - const url = URL.createObjectURL(blob); - const a = document.createElement("a"); - a.href = url; - a.download = fileName; - a.click(); - URL.revokeObjectURL(url); - } - } catch { - showToast("Failed to download theme", Toasts.Type.FAILURE); - } - } - - const allThemes = useMemo((): UnifiedTheme[] => { - const themes: UnifiedTheme[] = []; - - for (const theme of onlineThemes ?? []) { - const customName = themeNames[theme.link] ?? null; - themes.push({ - type: "online", - themeType: "other", - name: customName ?? theme.name ?? theme.fileName, - enabled: settings.enabledThemeLinks.includes(theme.link), - header: { ...theme, customName }, - link: theme.link, - activationMode: settings.themeActivationModes?.[theme.link] ?? "always", - }); - } - - for (const { type, header } of userThemes ?? []) { - const name = type === "usercss" - ? (header as UserstyleHeader).name ?? "Unknown" - : (header as UserThemeHeader).name ?? (header as UserThemeHeader).fileName; - - themes.push({ - type: "local", - themeType: type, - name, - enabled: settings.enabledThemes.includes(header.fileName), - header, - activationMode: settings.themeActivationModes?.[header.fileName] ?? "always", - }); - } - - return themes; - }, [onlineThemes, userThemes, themeNames, settings.enabledThemeLinks, settings.enabledThemes, settings.themeActivationModes]); - - const filteredThemes = useMemo(() => { - let themes = allThemes; - - if (searchQuery.trim()) { - const query = searchQuery.toLowerCase(); - themes = themes.filter(t => t.name.toLowerCase().includes(query)); - } - - switch (filter) { - case ThemeFilter.Online: - themes = themes.filter(t => t.type === "online"); - break; - case ThemeFilter.Local: - themes = themes.filter(t => t.type === "local"); - break; - case ThemeFilter.Enabled: - themes = themes.filter(t => t.enabled); - break; - case ThemeFilter.Disabled: - themes = themes.filter(t => !t.enabled); - break; - } - - const getThemeId = (t: UnifiedTheme) => t.type === "online" ? t.link! : (t.header as UserThemeHeader).fileName; - themes.sort((a, b) => { - const aId = getThemeId(a); - const bId = getThemeId(b); - const aPinIndex = settings.pinnedThemes.indexOf(aId); - const bPinIndex = settings.pinnedThemes.indexOf(bId); - const aIsPinned = aPinIndex !== -1; - const bIsPinned = bPinIndex !== -1; - - if (aIsPinned && !bIsPinned) return -1; - if (!aIsPinned && bIsPinned) return 1; - if (aIsPinned && bIsPinned) return aPinIndex - bPinIndex; - return 0; - }); - - return themes; - }, [allThemes, searchQuery, filter, settings.pinnedThemes]); - - const localCount = allThemes.filter(t => t.type === "local").length; - const onlineCount = allThemes.filter(t => t.type === "online").length; - const enabledCount = allThemes.filter(t => t.enabled).length; - - return ( - - - - Theme Management - - Customize Discord's appearance with themes. Add local .css files or load themes directly from URLs. Themes with a cog wheel icon have customizable settings you can modify. - - - Quick Actions - - Shortcuts for managing your themes. Open your themes folder to add new themes, use QuickCSS for quick style tweaks, or reload themes after making changes. - - - - {IS_WEB ? ( - - Upload Theme - - - } - Icon={PlusIcon} - /> - ) : ( - VencordNative.themes.openFolder()} - Icon={FolderIcon} - /> - )} - - VencordNative.quickCss.openEditor()} - Icon={PaintbrushIcon} - /> - {Settings.plugins.ClientTheme.enabled && ( - openPluginModal(Plugins.ClientTheme)} - Icon={PencilIcon} - /> - )} - - - - - Online Themes - - Load themes directly from URLs instead of local files. Online themes auto-update when the source changes, so you always have the latest version without manual downloads. - - { - settings.enableOnlineThemes = value; - if (!value) { - settings.enabledThemeLinks = []; - } - }} - /> - - - Looking for themes? Check out BetterDiscord Themes or search on GitHub. When downloading from BetterDiscord, click "Download" and place the .theme.css file into your themes folder. - - -
- - -
- {currentThemeLink && ( -
- -
- )} - - - - Installed Themes - - Manage your themes here. Local themes load from your themes folder, online themes from URLs. Themes with a cog wheel icon have customizable settings. - - - {allThemes.length} theme{allThemes.length !== 1 ? "s" : ""} installed ({localCount} local, {onlineCount} online) · {enabledCount} enabled - - -
- -
- v === themeSettings[name]} - serialize={identity} - /> - - ); -} diff --git a/src/components/ThemeSettings/components/SettingTextComponent.tsx b/src/components/ThemeSettings/components/SettingTextComponent.tsx deleted file mode 100644 index 9fe8bb1b0..000000000 --- a/src/components/ThemeSettings/components/SettingTextComponent.tsx +++ /dev/null @@ -1,31 +0,0 @@ -/* - * Vencord, a Discord client mod - * Copyright (c) 2023 Vendicated and contributors - * SPDX-License-Identifier: GPL-3.0-or-later - */ - -import { Heading } from "@components/Heading"; -import { TextInput } from "@webpack/common"; - -interface Props { - label: string; - name: string; - themeSettings: Record; -} - -export function SettingTextComponent({ label, name, themeSettings }: Props) { - function handleChange(value: string) { - themeSettings[name] = value; - } - - return ( -
- {label} - -
- ); -} diff --git a/src/components/ThemeSettings/components/colorStyles.css b/src/components/ThemeSettings/components/colorStyles.css deleted file mode 100644 index 2c770bda3..000000000 --- a/src/components/ThemeSettings/components/colorStyles.css +++ /dev/null @@ -1,19 +0,0 @@ -.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(--text-strong); - line-height: 24px; - font-size: 16px; - font-weight: 500; - overflow-wrap: break-word; -} \ No newline at end of file diff --git a/src/components/ThemeSettings/components/index.ts b/src/components/ThemeSettings/components/index.ts deleted file mode 100644 index 8b9f48cde..000000000 --- a/src/components/ThemeSettings/components/index.ts +++ /dev/null @@ -1,12 +0,0 @@ -/* - * 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"; diff --git a/src/components/ThemeSettings/components/styles.css b/src/components/ThemeSettings/components/styles.css deleted file mode 100644 index 422bcd940..000000000 --- a/src/components/ThemeSettings/components/styles.css +++ /dev/null @@ -1,3 +0,0 @@ -.vc-settings-boolean-switch { - margin-bottom: 0.5em; -} \ No newline at end of file diff --git a/src/components/settings/tabs/index.ts b/src/components/settings/tabs/index.ts index 6b5cd2432..e079c9a8a 100644 --- a/src/components/settings/tabs/index.ts +++ b/src/components/settings/tabs/index.ts @@ -6,7 +6,6 @@ import "./styles.css"; -export { default as ThemesTab } from "../../ThemeSettings/ThemesTab"; export * from "./BaseTab"; export { default as ChangelogTab } from "./changelog"; export { default as PatchHelperTab } from "./patchHelper"; @@ -15,5 +14,6 @@ export { openContributorModal } from "./plugins/ContributorModal"; export { openPluginModal } from "./plugins/PluginModal"; export { default as BackupAndRestoreTab } from "./sync/BackupAndRestoreTab"; export { default as CloudTab } from "./sync/CloudTab"; +export { default as ThemesTab } from "./themes"; export { default as UpdaterTab } from "./updater"; export { default as VencordTab } from "./vencord"; diff --git a/src/components/settings/tabs/themes/OnlineThemes.tsx b/src/components/settings/tabs/themes/OnlineThemes.tsx new file mode 100644 index 000000000..52259d980 --- /dev/null +++ b/src/components/settings/tabs/themes/OnlineThemes.tsx @@ -0,0 +1,98 @@ +/* + * Vencord, a Discord client mod + * Copyright (c) 2026 Vendicated and contributors + * SPDX-License-Identifier: GPL-3.0-or-later + */ + +import { Button } from "@components/Button"; +import { FormSwitch } from "@components/FormSwitch"; +import { Heading } from "@components/Heading"; +import { Link } from "@components/Link"; +import { Notice } from "@components/Notice"; +import { Paragraph } from "@components/Paragraph"; +import { classNameFactory } from "@utils/css"; +import { Margins } from "@utils/margins"; +import { useAwaiter } from "@utils/react"; +import { React, TextInput } from "@webpack/common"; + +const cl = classNameFactory("vc-settings-theme-"); + +export function Validator({ link, onValidate }: { link: string; onValidate: (valid: boolean) => void; }) { + const [res, err, pending] = useAwaiter(() => fetch(link).then(res => { + if (res.status > 300) throw `${res.status} ${res.statusText}`; + const contentType = res.headers.get("Content-Type"); + if (!contentType?.startsWith("text/css") && !contentType?.startsWith("text/plain")) { + onValidate(false); + throw "Not a CSS file. Remember to use the raw link!"; + } + + onValidate(true); + return "Okay!"; + })); + + const text = pending + ? "Checking..." + : err + ? `Error: ${err instanceof Error ? err.message : String(err)}` + : "Valid!"; + + return {text}; +} + +export interface OnlineThemesSectionProps { + enableOnlineThemes: boolean; + setEnableOnlineThemes: (value: boolean) => void; + currentThemeLink: string; + setCurrentThemeLink: (value: string) => void; + themeLinkValid: boolean; + setThemeLinkValid: (value: boolean) => void; + addThemeLink: (link: string) => void; +} + +export function OnlineThemesSection({ + enableOnlineThemes, + setEnableOnlineThemes, + currentThemeLink, + setCurrentThemeLink, + themeLinkValid, + setThemeLinkValid, + addThemeLink +}: OnlineThemesSectionProps) { + return ( + <> + Online Themes + + Load themes directly from URLs instead of local files. Online themes auto-update when the source changes, so you always have the latest version without manual downloads. + + + + + Looking for themes? Check out BetterDiscord Themes or search on GitHub. When downloading from BetterDiscord, click "Download" and place the .theme.css file into your themes folder. + + +
+ + +
+ {currentThemeLink && ( +
+ +
+ )} + + ); +} diff --git a/src/components/settings/tabs/themes/QuickActions.tsx b/src/components/settings/tabs/themes/QuickActions.tsx new file mode 100644 index 000000000..07ef7bad6 --- /dev/null +++ b/src/components/settings/tabs/themes/QuickActions.tsx @@ -0,0 +1,76 @@ +/* + * Vencord, a Discord client mod + * Copyright (c) 2026 Vendicated and contributors + * SPDX-License-Identifier: GPL-3.0-or-later + */ + +import { Settings } from "@api/Settings"; +import { FolderIcon, PaintbrushIcon, PencilIcon, PlusIcon, RestartIcon } from "@components/Icons"; +import { QuickAction, QuickActionCard } from "@components/settings"; +import { openPluginModal } from "@components/settings/tabs/plugins/PluginModal"; +import { findLazy } from "@webpack"; +import { React } from "@webpack/common"; +import type { ComponentType, Ref, SyntheticEvent } from "react"; + +import Plugins from "~plugins"; + +type FileInputType = ComponentType<{ + ref: Ref; + onChange: (e: SyntheticEvent) => void; + multiple?: boolean; + filters?: { name?: string; extensions: string[]; }[]; +}>; + +const FileInput: FileInputType = findLazy(m => m.prototype?.activateUploadDialogue && m.prototype.setRef); + +export interface QuickActionsSectionProps { + fileInputRef: any; + onFileUpload: (e: SyntheticEvent) => void; + refreshLocalThemes: () => void; +} + +export function QuickActionsSection({ fileInputRef, onFileUpload, refreshLocalThemes }: QuickActionsSectionProps) { + return ( + + {IS_WEB ? ( + + Upload Theme + + + } + Icon={PlusIcon} + /> + ) : ( + VencordNative.themes.openFolder()} + Icon={FolderIcon} + /> + )} + + VencordNative.quickCss.openEditor()} + Icon={PaintbrushIcon} + /> + {Settings.plugins.ClientTheme.enabled && ( + openPluginModal(Plugins.ClientTheme)} + Icon={PencilIcon} + /> + )} + + ); +} diff --git a/src/components/settings/tabs/themes/ThemeCard.tsx b/src/components/settings/tabs/themes/ThemeCard.tsx new file mode 100644 index 000000000..97ed074fc --- /dev/null +++ b/src/components/settings/tabs/themes/ThemeCard.tsx @@ -0,0 +1,267 @@ +/* + * Vencord, a Discord client mod + * Copyright (c) 2026 Vendicated and contributors + * SPDX-License-Identifier: GPL-3.0-or-later + */ + +import type { ThemeActivationMode } from "@api/Settings"; +import { Flex } from "@components/Flex"; +import { CogWheel, DeleteIcon, FolderIcon } from "@components/Icons"; +import { Link } from "@components/Link"; +import { OnlineThemeCard } from "@components/settings/OnlineThemeCard"; +import { UserThemeHeader } from "@main/themes"; +import { classNameFactory } from "@utils/css"; +import { openInviteModal } from "@utils/discord"; +import { findComponentByCodeLazy } from "@webpack"; +import { Menu, React, showToast, Tooltip, useState } from "@webpack/common"; +import { ContextMenuApi } from "@webpack/common/menu"; + +const PinIcon = findComponentByCodeLazy("1-.06-.63L6.16"); +const HomeIcon = findComponentByCodeLazy("m2.4 8.4 8.38-6.46a2"); +const RefreshIcon = findComponentByCodeLazy("M21 2a1 1 0 0 1 1 1v6"); +const LinkIcon = findComponentByCodeLazy("M16.32 14.72a1 1 0 0 1 0-1.41l2.51-2.51"); +const DiscordIcon = findComponentByCodeLazy("1.6 5.64-2.87"); +const DownloadIcon = findComponentByCodeLazy("1.42l3.3 3.3V3a1"); + +const cl = classNameFactory("vc-settings-theme-"); + +const themeActivationModeOptions: { value: ThemeActivationMode; label: string; }[] = [ + { value: "always", label: "Always on" }, + { value: "light", label: "Light only" }, + { value: "dark", label: "Dark only" } +]; + +export function getThemeActivationModeLabel(mode: ThemeActivationMode) { + return themeActivationModeOptions.find(option => option.value === mode)?.label ?? "Always on"; +} + +export function ThemeActivationMenu({ themeId, activationMode, onActivationModeChange, children }: { + themeId: string; + activationMode: ThemeActivationMode; + onActivationModeChange?: (mode: ThemeActivationMode) => void; + children?: React.ReactNode; +}) { + const [selectedMode, setSelectedMode] = useState(activationMode); + + return ( + + {onActivationModeChange && ( + + {themeActivationModeOptions.map(option => ( + { + setSelectedMode(option.value); + onActivationModeChange(option.value); + }} + /> + ))} + + )} + {children} + + ); +} + +export function LocalThemeIcon({ size }: { size?: string; }) { + const sizeVal = size === "sm" ? 16 : 24; + return ( + + + + ); +} + +export function OnlineThemeIcon({ size }: { size?: string; }) { + const sizeVal = size === "sm" ? 16 : 24; + return ( + + + + ); +} + +export interface ThemeCardProps { + theme: UserThemeHeader; + enabled: boolean; + onChange: (enabled: boolean) => void; + onDelete?: () => void; + showDeleteButton?: boolean; + onEditName?: (newName: string) => void; + disabled?: boolean; + onPin?: () => void; + isPinned?: boolean; + onRefresh?: () => void; + onOpenFolder?: () => void; + onCopyUrl?: () => void; + onDownload?: () => void; + themeLink?: string; + isLocal?: boolean; + activationMode?: ThemeActivationMode; + onActivationModeChange?: (mode: ThemeActivationMode) => void; +} + +export function ThemeCard({ theme, enabled, onChange, onDelete, showDeleteButton, onEditName, disabled, onPin, isPinned, onRefresh, onOpenFolder, onCopyUrl, onDownload, themeLink, isLocal, activationMode = "always", onActivationModeChange }: ThemeCardProps) { + const openThemeMenu = (e: React.MouseEvent) => { + ContextMenuApi.openContextMenu(e, () => ( + + {onPin && ( + + )} + {theme.website && ( + window.open(theme.website, "_blank")} + /> + )} + {theme.invite && ( + { + openInviteModal(theme.invite!).catch(() => + showToast("Invalid or expired invite") + ); + }} + /> + )} + {onCopyUrl && themeLink && ( + + )} + {onDownload && ( + + )} + {onOpenFolder && ( + + )} + {onRefresh && ( + + )} + {(IS_WEB || showDeleteButton) && onDelete && ( + <> + + onDelete()} + /> + + )} + + )); + }; + + return ( + + +
+ ) + } + footer={ + + + {({ onMouseLeave, onMouseEnter }) => ( +
+ {isLocal ? : } +
+ )} +
+ {isPinned && ( + + {({ onMouseLeave, onMouseEnter }) => ( +
+ +
+ )} +
+ )} + {!!theme.website && Website} + {!!(theme.website && theme.invite) && ( + • + )} + {!!theme.invite && ( + { + e.preventDefault(); + theme.invite != null && + openInviteModal(theme.invite).catch(() => + showToast("Invalid or expired invite") + ); + }} + > + Discord Server + + )} + {activationMode !== "always" && ( + <> + {!!(theme.website || theme.invite) && •} + {getThemeActivationModeLabel(activationMode)} + + )} +
+ } + + onEditName={onEditName} + /> + ); +} diff --git a/src/components/settings/tabs/themes/index.tsx b/src/components/settings/tabs/themes/index.tsx new file mode 100644 index 000000000..11b1ccfff --- /dev/null +++ b/src/components/settings/tabs/themes/index.tsx @@ -0,0 +1,484 @@ +/* + * Vencord, a Discord client mod + * Copyright (c) 2026 Vendicated and contributors + * SPDX-License-Identifier: GPL-3.0-or-later + */ + +import "./styles.css"; + +import { Settings, type ThemeActivationMode, useSettings } from "@api/Settings"; +import { Divider } from "@components/Divider"; +import { Heading } from "@components/Heading"; +import { Link } from "@components/Link"; +import { Paragraph } from "@components/Paragraph"; +import { SettingsTab, wrapTab } from "@components/settings/tabs/BaseTab"; +import { getThemeInfo, UserThemeHeader } from "@main/themes"; +import { classNameFactory } from "@utils/css"; +import { copyWithToast } from "@utils/discord"; +import { Margins } from "@utils/margins"; +import { classes } from "@utils/misc"; +import { getStylusWebStoreUrl } from "@utils/web"; +import { React, Select, showToast, TextInput, Toasts, useEffect, useMemo, useRef, useState } from "@webpack/common"; + +import { OnlineThemesSection } from "./OnlineThemes"; +import { QuickActionsSection } from "./QuickActions"; +import { ThemeCard } from "./ThemeCard"; + +const cl = classNameFactory("vc-settings-theme-"); + +enum ThemeFilter { + All = "all", + Online = "online", + Local = "local", + Enabled = "enabled", + Disabled = "disabled" +} + +const filterOptions = [ + { label: "Show All", value: ThemeFilter.All }, + { label: "Online Themes", value: ThemeFilter.Online }, + { label: "Local Themes", value: ThemeFilter.Local }, + { label: "Enabled", value: ThemeFilter.Enabled }, + { label: "Disabled", value: ThemeFilter.Disabled } +]; + +function inferThemeActivationMode(css: string) { + let text = css.replace(/^\uFEFF/, ""); + + while (true) { + const trimmed = text.trimStart(); + if (trimmed !== text) text = trimmed; + + const comment = /^\/\*[\s\S]*?\*\/\s*/.exec(text); + if (!comment) break; + text = text.slice(comment[0].length); + } + + const match = /^@(light|dark)\b/i.exec(text); + return match?.[1].toLowerCase() as ThemeActivationMode | undefined; +} + +function inferAndStoreThemeActivationMode(themeId: string, css: string) { + const activationMode = Settings.themeActivationModes?.[themeId] ?? inferThemeActivationMode(css); + if (!activationMode || themeId in (Settings.themeActivationModes ?? {})) return; + + Settings.themeActivationModes = { + ...(Settings.themeActivationModes ?? {}), + [themeId]: activationMode, + }; +} + +interface UnifiedTheme { + type: "local" | "online"; + name: string; + enabled: boolean; + header: UserThemeHeader; + link?: string; + activationMode: ThemeActivationMode; +} + +function ThemesTab() { + const settings = useSettings(["themeLinks", "enabledThemeLinks", "enabledThemes", "enableOnlineThemes", "pinnedThemes", "themeActivationModes.*"]); + + const fileInputRef = useRef(null); + const [currentThemeLink, setCurrentThemeLink] = useState(""); + const [themeLinkValid, setThemeLinkValid] = useState(false); + const [userThemes, setUserThemes] = useState(null); + const [onlineThemes, setOnlineThemes] = useState<(UserThemeHeader & { link: string; })[] | null>(null); + const [themeNames, setThemeNames] = useState>(() => { + return settings.themeNames ?? {}; + }); + const [searchQuery, setSearchQuery] = useState(""); + const [filter, setFilter] = useState(ThemeFilter.All); + + useEffect(() => { + void updateThemes(); + }, []); + + async function updateThemes() { + await Promise.allSettled([refreshLocalThemes(), refreshOnlineThemes()]); + } + + async function refreshLocalThemes() { + const themes = await VencordNative.themes.getThemesList(); + setUserThemes(themes); + } + + function onLocalThemeChange(fileName: string, value: boolean) { + if (value) { + if (settings.enabledThemes.includes(fileName)) return; + settings.enabledThemes = [...settings.enabledThemes, fileName]; + } else { + settings.enabledThemes = settings.enabledThemes.filter(f => f !== fileName); + } + } + + async function onFileUpload(e: React.SyntheticEvent) { + e.stopPropagation(); + e.preventDefault(); + if (!e.currentTarget?.files?.length) return; + const { files } = e.currentTarget; + + const uploads = Array.from(files, file => { + const { name } = file; + if (!name.endsWith(".css")) return; + + return new Promise((resolve, reject) => { + const reader = new FileReader(); + reader.onload = () => { + VencordNative.themes.uploadTheme(name, reader.result as string) + .then(resolve) + .catch(reject); + }; + reader.readAsText(file); + }); + }); + + await Promise.all(uploads); + refreshLocalThemes(); + } + + function addThemeLink(link: string) { + if (!themeLinkValid) return; + if (settings.themeLinks.includes(link)) return; + + settings.themeLinks = [...settings.themeLinks, link]; + setCurrentThemeLink(""); + refreshOnlineThemes(); + } + + async function refreshOnlineThemes() { + const themes = await Promise.all( + settings.themeLinks.map(async link => { + try { + const res = await fetch(link); + if (!res.ok) throw new Error(`Failed to fetch ${link}`); + const css = await res.text(); + inferAndStoreThemeActivationMode(link, css); + + return { ...getThemeInfo(css, link), link }; + } catch { + return null; + } + }) + ); + setOnlineThemes(themes.filter(theme => theme !== null)); + } + + function onThemeLinkEnabledChange(link: string, enabled: boolean) { + if (enabled) { + if (settings.enabledThemeLinks.includes(link)) return; + settings.enabledThemeLinks = [...settings.enabledThemeLinks, link]; + } else { + settings.enabledThemeLinks = settings.enabledThemeLinks.filter(f => f !== link); + } + } + + function clearThemeState(themeId: string) { + settings.pinnedThemes = settings.pinnedThemes.filter(f => f !== themeId); + settings.enabledThemes = settings.enabledThemes.filter(f => f !== themeId); + settings.enabledThemeLinks = settings.enabledThemeLinks.filter(f => f !== themeId); + settings.themeNames = Object.fromEntries(Object.entries(settings.themeNames).filter(([key]) => key !== themeId)); + + const themeActivationModes = { ...(settings.themeActivationModes ?? {}) }; + delete themeActivationModes[themeId]; + settings.themeActivationModes = themeActivationModes; + } + + function deleteThemeLink(link: string) { + settings.themeLinks = settings.themeLinks.filter(f => f !== link); + clearThemeState(link); + refreshOnlineThemes(); + } + + function setThemeActivationMode(themeId: string, mode: ThemeActivationMode) { + const themeActivationModes = { ...(settings.themeActivationModes ?? {}) }; + + if (mode === "always") { + delete themeActivationModes[themeId]; + } else { + themeActivationModes[themeId] = mode; + } + + settings.themeActivationModes = themeActivationModes; + } + + function togglePinTheme(themeId: string) { + if (settings.pinnedThemes.includes(themeId)) { + settings.pinnedThemes = settings.pinnedThemes.filter(f => f !== themeId); + } else { + settings.pinnedThemes = [...settings.pinnedThemes, themeId]; + } + } + + async function refreshOnlineTheme(link: string) { + try { + const res = await fetch(link); + if (!res.ok) throw new Error(`Failed to fetch ${link}`); + const css = await res.text(); + inferAndStoreThemeActivationMode(link, css); + + const updatedTheme = { ...getThemeInfo(css, link), link }; + + setOnlineThemes(prev => + prev?.map(t => t.link === link ? updatedTheme : t) ?? null + ); + showToast("Theme refreshed!", Toasts.Type.SUCCESS); + } catch { + showToast("Failed to refresh theme", Toasts.Type.FAILURE); + } + } + + async function downloadTheme(link: string, name: string) { + try { + const res = await fetch(link); + if (!res.ok) throw new Error(`Failed to fetch ${link}`); + const css = await res.text(); + const fileName = name.replace(/[^a-z0-9]/gi, "-") + ".css"; + + if (IS_DISCORD_DESKTOP) { + DiscordNative.fileManager.saveWithDialog(new TextEncoder().encode(css), fileName); + } else { + const blob = new Blob([css], { type: "text/css" }); + const url = URL.createObjectURL(blob); + const a = document.createElement("a"); + a.href = url; + a.download = fileName; + a.click(); + URL.revokeObjectURL(url); + } + } catch { + showToast("Failed to download theme", Toasts.Type.FAILURE); + } + } + + const allThemes = useMemo((): UnifiedTheme[] => { + const themes: UnifiedTheme[] = []; + + for (const theme of onlineThemes ?? []) { + const customName = themeNames[theme.link] ?? null; + themes.push({ + type: "online", + name: customName ?? theme.name ?? theme.fileName, + enabled: settings.enabledThemeLinks.includes(theme.link), + header: { ...theme, customName }, + link: theme.link, + activationMode: settings.themeActivationModes?.[theme.link] ?? "always", + }); + } + + for (const header of userThemes ?? []) { + const name = header.name ?? header.fileName; + + themes.push({ + type: "local", + name, + enabled: settings.enabledThemes.includes(header.fileName), + header, + activationMode: settings.themeActivationModes?.[header.fileName] ?? "always", + }); + } + + return themes; + }, [onlineThemes, userThemes, themeNames, settings.enabledThemeLinks, settings.enabledThemes, settings.themeActivationModes]); + + const filteredThemes = useMemo(() => { + let themes = allThemes; + + if (searchQuery.trim()) { + const query = searchQuery.toLowerCase(); + themes = themes.filter(t => t.name.toLowerCase().includes(query)); + } + + switch (filter) { + case ThemeFilter.Online: + themes = themes.filter(t => t.type === "online"); + break; + case ThemeFilter.Local: + themes = themes.filter(t => t.type === "local"); + break; + case ThemeFilter.Enabled: + themes = themes.filter(t => t.enabled); + break; + case ThemeFilter.Disabled: + themes = themes.filter(t => !t.enabled); + break; + } + + const getThemeId = (t: UnifiedTheme) => t.type === "online" ? t.link! : t.header.fileName; + themes.sort((a, b) => { + const aId = getThemeId(a); + const bId = getThemeId(b); + const aPinIndex = settings.pinnedThemes.indexOf(aId); + const bPinIndex = settings.pinnedThemes.indexOf(bId); + const aIsPinned = aPinIndex !== -1; + const bIsPinned = bPinIndex !== -1; + + if (aIsPinned && !bIsPinned) return -1; + if (!aIsPinned && bIsPinned) return 1; + if (aIsPinned && bIsPinned) return aPinIndex - bPinIndex; + return 0; + }); + + return themes; + }, [allThemes, searchQuery, filter, settings.pinnedThemes]); + + const localCount = allThemes.filter(t => t.type === "local").length; + const onlineCount = allThemes.filter(t => t.type === "online").length; + const enabledCount = allThemes.filter(t => t.enabled).length; + + return ( + + Theme Management + + Customize Discord's appearance with themes. Add local .css files or load themes directly from URLs. Themes with a cog wheel icon have customizable settings you can modify. + + + Quick Actions + + Shortcuts for managing your themes. Open your themes folder to add new themes, use QuickCSS for quick style tweaks, or reload themes after making changes. + + + + + + + { + settings.enableOnlineThemes = value; + if (!value) { + settings.enabledThemeLinks = []; + } + }} + currentThemeLink={currentThemeLink} + setCurrentThemeLink={setCurrentThemeLink} + themeLinkValid={themeLinkValid} + setThemeLinkValid={setThemeLinkValid} + addThemeLink={addThemeLink} + /> + + + + Installed Themes + + Manage your themes here. Local themes load from your themes folder, online themes from URLs. Themes with a cog wheel icon have customizable settings. + + + {allThemes.length} theme{allThemes.length !== 1 ? "s" : ""} installed ({localCount} local, {onlineCount} online) · {enabledCount} enabled + + +
+ +
+