From fb5d32ba91edf9442cb8ca2c3254408fca969d58 Mon Sep 17 00:00:00 2001 From: Jake <134332787+justjxke@users.noreply.github.com> Date: Fri, 3 Apr 2026 18:08:31 +0100 Subject: [PATCH] feat: Add day & night toggles for online and local themes (#949) --- src/api/Settings.ts | 4 + src/api/Themes.ts | 46 +++- src/components/ThemeSettings/ThemesTab.tsx | 272 ++++++++++++++++----- 3 files changed, 251 insertions(+), 71 deletions(-) diff --git a/src/api/Settings.ts b/src/api/Settings.ts index ce5b7a085..1951a2a3e 100644 --- a/src/api/Settings.ts +++ b/src/api/Settings.ts @@ -26,6 +26,8 @@ import plugins from "~plugins"; const logger = new Logger("Settings"); +export type ThemeActivationMode = "always" | "light" | "dark"; + export interface SettingsPluginUiElement { enabled: boolean; // TODO @@ -47,6 +49,7 @@ export interface Settings { enableOnlineThemes: boolean; pinnedThemes: string[]; themeNames: Record; + themeActivationModes: Partial>; enableReactDevtools: boolean; themeLinks: string[]; mainWindowFrameless: boolean; @@ -116,6 +119,7 @@ const DefaultSettings: Settings = { enableOnlineThemes: true, pinnedThemes: [], themeNames: {}, + themeActivationModes: {}, enableReactDevtools: false, mainWindowFrameless: false, frameless: false, diff --git a/src/api/Themes.ts b/src/api/Themes.ts index 620b778cf..b4f41cbf9 100644 --- a/src/api/Themes.ts +++ b/src/api/Themes.ts @@ -16,7 +16,7 @@ * along with this program. If not, see . */ -import { Settings, SettingsStore } from "@api/Settings"; +import { Settings, SettingsStore, type ThemeActivationMode } from "@api/Settings"; import { createAndAppendStyle } from "@utils/css"; import { ThemeStore } from "@vencord/discord-types"; import { PopoutWindowStore } from "@webpack/common"; @@ -26,6 +26,16 @@ import { coreStyleRootNode, managedStyleRootNode, userStyleRootNode, vencordRoot let style: HTMLStyleElement; let themesStyle: HTMLStyleElement; +function getThemeActivationMode(themeId: string) { + return Settings.themeActivationModes?.[themeId] ?? "always"; +} + +function shouldApplyTheme(mode: ThemeActivationMode, activeTheme?: "light" | "dark") { + if (mode === "always") return true; + if (!activeTheme) return false; + return mode === activeTheme; +} + async function toggle(isEnabled: boolean) { if (!style) { if (isEnabled) { @@ -55,29 +65,38 @@ async function initThemes() { ? undefined : ThemeStore.theme === "light" ? "light" : "dark"; - const links = enabledThemeLinks - .map(rawLink => { - const match = /^@(light|dark) (.*)/.exec(rawLink); - if (!match) return rawLink; + const links = new Set(); - const [, mode, link] = match; - return mode === activeTheme ? link : null; - }) - .filter(link => link !== null); + for (const rawLink of enabledThemeLinks) { + const match = /^@(light|dark) (.*)/.exec(rawLink); + const link = match?.[2] ?? rawLink; + const mode = getThemeActivationMode(rawLink); + + if (shouldApplyTheme(mode, activeTheme)) { + links.add(link); + } + } if (IS_WEB) { for (const theme of enabledThemes) { + const mode = getThemeActivationMode(theme); + if (!shouldApplyTheme(mode, activeTheme)) continue; + const themeData = await VencordNative.themes.getThemeData(theme); if (!themeData) continue; const blob = new Blob([themeData], { type: "text/css" }); - links.push(URL.createObjectURL(blob)); + links.add(URL.createObjectURL(blob)); } } else { - const localThemes = enabledThemes.map(theme => `vencord:///themes/${theme}?v=${Date.now()}`); - links.push(...localThemes); + const version = Date.now(); + for (const theme of enabledThemes) { + const mode = getThemeActivationMode(theme); + if (!shouldApplyTheme(mode, activeTheme)) continue; + links.add(`vencord:///themes/${theme}?v=${version}`); + } } - themesStyle.textContent = links.map(link => `@import url("${link.trim()}");`).join("\n"); + themesStyle.textContent = Array.from(links).map(link => `@import url("${link.trim()}");`).join("\n"); updatePopoutWindows(); } @@ -120,6 +139,7 @@ document.addEventListener("DOMContentLoaded", () => { SettingsStore.addChangeListener("enabledThemeLinks", initThemes); SettingsStore.addChangeListener("enabledThemes", initThemes); + SettingsStore.addChangeListener("themeActivationModes", initThemes); window.addEventListener("message", event => { const { discordPopoutEvent } = event.data || {}; diff --git a/src/components/ThemeSettings/ThemesTab.tsx b/src/components/ThemeSettings/ThemesTab.tsx index 190ad5a27..2c4a734c9 100644 --- a/src/components/ThemeSettings/ThemesTab.tsx +++ b/src/components/ThemeSettings/ThemesTab.tsx @@ -19,7 +19,7 @@ import "./themesStyles.css"; import { isPluginEnabled } from "@api/PluginManager"; -import { Settings, useSettings } from "@api/Settings"; +import { Settings, type ThemeActivationMode, useSettings } from "@api/Settings"; import { Button } from "@components/Button"; import { Divider } from "@components/Divider"; import { ErrorCard } from "@components/ErrorCard"; @@ -59,6 +59,74 @@ const LinkIcon = findComponentByCodeLazy("M16.32 14.72a1 1 0 0 1 0-1.41l2.51-2.5 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 ( @@ -145,6 +213,8 @@ interface OtherThemeCardProps { onDownload?: () => void; themeLink?: string; isLocal?: boolean; + activationMode?: ThemeActivationMode; + onActivationModeChange?: (mode: ThemeActivationMode) => void; } interface UserCSSCardProps { @@ -153,12 +223,48 @@ interface UserCSSCardProps { onChange: (enabled: boolean) => void; onDelete: () => void; onSettingsReset: () => void; + activationMode?: ThemeActivationMode; + onActivationModeChange?: (mode: ThemeActivationMode) => void; } -function UserCSSThemeCard({ theme, enabled, onChange, onDelete, onSettingsReset }: UserCSSCardProps) { +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 ( )} - {theme.vars && ( -
openModal(modalProps => - ) - }> + {canOpenThemeMenu && ( +
)} - {IS_WEB && ( -
- -
- )} } footer={ @@ -204,16 +302,26 @@ function UserCSSThemeCard({ theme, enabled, onChange, onDelete, onSettingsReset • )} {!!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 }: OtherThemeCardProps) { +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 && ( )} - + )); }; @@ -353,6 +461,12 @@ function OtherThemeCard({ theme, enabled, onChange, onDelete, showDeleteButton, Discord Server )} + {activationMode !== "always" && ( + <> + {!!(theme.website || theme.invite) && •} + {getThemeActivationModeLabel(activationMode)} + + )} } @@ -368,10 +482,11 @@ interface UnifiedTheme { enabled: boolean; header: UserThemeHeader | UserstyleHeader; link?: string; + activationMode: ThemeActivationMode; } function ThemesTab() { - const settings = useSettings(["themeLinks", "enabledThemeLinks", "enabledThemes", "enableOnlineThemes", "pinnedThemes"]); + const settings = useSettings(["themeLinks", "enabledThemeLinks", "enabledThemes", "enableOnlineThemes", "pinnedThemes", "themeActivationModes.*"]); const fileInputRef = useState(null)[1]; const [currentThemeLink, setCurrentThemeLink] = useState(""); @@ -386,16 +501,15 @@ function ThemesTab() { const [filter, setFilter] = useState(ThemeFilter.All); useEffect(() => { - updateThemes(); + void updateThemes(); }, []); async function updateThemes() { - await changeThemeLibraryURLs(); - refreshLocalThemes(); - refreshOnlineThemes(); + changeThemeLibraryURLs(); + await Promise.allSettled([refreshLocalThemes(), refreshOnlineThemes()]); } - async function changeThemeLibraryURLs() { + 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"); @@ -411,43 +525,49 @@ function ThemesTab() { for (const { fileName, content } of themes) { if (!fileName.endsWith(".css")) continue; - if ((!IS_WEB || "legcord" in window) && fileName.endsWith(".user.css")) { - const header = await usercssParse(content, fileName); + try { + inferAndStoreThemeActivationMode(fileName, content); - themeInfo.push({ - type: "usercss", - header - }); + if ((!IS_WEB || "legcord" in window) && fileName.endsWith(".user.css")) { + const header = await usercssParse(content, fileName); - Settings.userCssVars[header.id] ??= {}; + themeInfo.push({ + type: "usercss", + header + }); - for (const [name, varInfo] of Object.entries(header.vars ?? {})) { - let normalizedValue = ""; + Settings.userCssVars[header.id] ??= {}; - 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; + 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; } - - Settings.userCssVars[header.id][name] ??= normalizedValue; + } else { + themeInfo.push({ + type: "other", + header: getThemeInfo(stripBOM(content), fileName) + }); } - } else { - themeInfo.push({ - type: "other", - header: getThemeInfo(stripBOM(content), fileName) - }); + } catch { + continue; } } @@ -504,9 +624,10 @@ function ThemesTab() { 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 (err) { - console.warn(`Theme fetch failed for ${link}:`, err); + } catch { return null; } }) @@ -523,12 +644,35 @@ function ThemesTab() { } } + 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); - settings.pinnedThemes = settings.pinnedThemes.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); @@ -542,6 +686,8 @@ function ThemesTab() { 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 => @@ -587,7 +733,8 @@ function ThemesTab() { name: customName ?? theme.name ?? theme.fileName, enabled: settings.enabledThemeLinks.includes(theme.link), header: { ...theme, customName }, - link: theme.link + link: theme.link, + activationMode: settings.themeActivationModes?.[theme.link] ?? "always", }); } @@ -601,12 +748,13 @@ function ThemesTab() { themeType: type, name, enabled: settings.enabledThemes.includes(header.fileName), - header + header, + activationMode: settings.themeActivationModes?.[header.fileName] ?? "always", }); } return themes; - }, [onlineThemes, userThemes, themeNames, settings.enabledThemeLinks, settings.enabledThemes]); + }, [onlineThemes, userThemes, themeNames, settings.enabledThemeLinks, settings.enabledThemes, settings.themeActivationModes]); const filteredThemes = useMemo(() => { let themes = allThemes; @@ -808,6 +956,8 @@ function ThemesTab() { onRefresh={() => refreshOnlineTheme(onlineTheme.link)} onDownload={() => downloadTheme(onlineTheme.link, onlineTheme.name ?? "theme")} isLocal={false} + activationMode={theme.activationMode} + onActivationModeChange={mode => setThemeActivationMode(onlineTheme.link, mode)} onEditName={newName => { const updatedNames = { ...themeNames, [onlineTheme.link]: newName }; setThemeNames(updatedNames); @@ -829,11 +979,14 @@ function ThemesTab() { onChange={enabled => onLocalThemeChange(usercssTheme.fileName, enabled)} onDelete={async () => { onLocalThemeChange(usercssTheme.fileName, false); + clearThemeState(usercssTheme.fileName); await VencordNative.themes.deleteTheme(usercssTheme.fileName); refreshLocalThemes(); }} onSettingsReset={refreshLocalThemes} theme={usercssTheme} + activationMode={theme.activationMode} + onActivationModeChange={mode => setThemeActivationMode(usercssTheme.fileName, mode)} /> ); } @@ -846,6 +999,7 @@ function ThemesTab() { onChange={enabled => onLocalThemeChange(localTheme.fileName, enabled)} onDelete={async () => { onLocalThemeChange(localTheme.fileName, false); + clearThemeState(localTheme.fileName); await VencordNative.themes.deleteTheme(localTheme.fileName); refreshLocalThemes(); }} @@ -856,6 +1010,8 @@ function ThemesTab() { onRefresh={refreshLocalThemes} isLocal theme={localTheme} + activationMode={theme.activationMode} + onActivationModeChange={mode => setThemeActivationMode(localTheme.fileName, mode)} /> ); })}