diff --git a/src/plugins/betterFolders/FolderSideBar.tsx b/src/plugins/betterFolders/FolderSideBar.tsx index d2ffe6bb..40329122 100644 --- a/src/plugins/betterFolders/FolderSideBar.tsx +++ b/src/plugins/betterFolders/FolderSideBar.tsx @@ -42,15 +42,14 @@ export default ErrorBoundary.wrap(guildsBarProps => { const guilds = document.querySelector(guildsBarProps.className.split(" ").map(c => `.${c}`).join("")); // We need to display none if we are in fullscreen. Yes this seems horrible doing with css, but it's literally how Discord does it. - // Also display flex otherwise to fix scrolling - const barStyle = { - display: isFullscreen ? "none" : "flex", - gridArea: "betterFoldersSidebar" + // Also display flex otherwise to fix scrolling. + const sidebarStyle = { + display: isFullscreen ? "none" : "flex" } satisfies CSSProperties; if (!guilds || !settings.store.sidebarAnim) { return visible - ?
{Sidebar}
+ ?
{Sidebar}
: null; } @@ -62,9 +61,9 @@ export default ErrorBoundary.wrap(guildsBarProps => { leave={{ width: 0 }} config={{ duration: 200 }} > - {(animationStyle, show) => + {(animationStyle: any, show: any) => show && ( - + {Sidebar} ) diff --git a/src/plugins/betterFolders/index.tsx b/src/plugins/betterFolders/index.tsx index 7740eb2c..193590a0 100644 --- a/src/plugins/betterFolders/index.tsx +++ b/src/plugins/betterFolders/index.tsx @@ -16,14 +16,15 @@ * along with this program. If not, see . */ -import "./sidebarFix.css"; +import "./style.css"; import { definePluginSettings } from "@api/Settings"; import { Devs } from "@utils/constants"; import { getIntlMessage } from "@utils/discord"; import definePlugin, { OptionType } from "@utils/types"; import { findByPropsLazy, findLazy, findStoreLazy } from "@webpack"; -import { FluxDispatcher, useMemo } from "@webpack/common"; +import { FluxDispatcher } from "@webpack/common"; +import { ReactNode } from "react"; import FolderSideBar from "./FolderSideBar"; @@ -134,6 +135,10 @@ export const settings = definePluginSettings({ } }); +const IS_BETTER_FOLDERS_VAR = "typeof isBetterFolders!=='undefined'?isBetterFolders:arguments[0]?.isBetterFolders"; +const BETTER_FOLDERS_EXPANDED_IDS_VAR = "typeof betterFoldersExpandedIds!=='undefined'?betterFoldersExpandedIds:arguments[0]?.betterFoldersExpandedIds"; +const GRID_STYLE_NAME = "vc-betterFolders-sidebar-grid"; + export default definePlugin({ name: "BetterFolders", description: "Shows server folders on dedicated sidebar and adds folder related improvements", @@ -146,28 +151,33 @@ export default definePlugin({ find: '("guildsnav")', predicate: () => settings.store.sidebar, replacement: [ - // Create the isBetterFolders variable in the GuildsBar component + // Create the isBetterFolders and betterFoldersExpandedIds variables in the GuildsBar component // Needed because we access this from a non-arrow closure so we can't use arguments[0] { match: /let{disableAppDownload:\i=\i\.isPlatformEmbedded,isOverlay:.+?(?=}=\i,)/, - replace: "$&,isBetterFolders" + replace: "$&,isBetterFolders,betterFoldersExpandedIds" }, // Export the isBetterFolders and betterFoldersExpandedIds variable to the Guild List component { match: /,{guildDiscoveryButton:\i,/g, replace: "$&isBetterFolders:arguments[0]?.isBetterFolders,betterFoldersExpandedIds:arguments[0]?.betterFoldersExpandedIds," }, - // Export the isBetterFolders variable to the folders component + // Wrap the guild node (guild or folder) component in a div with display: none if it's not an expanded folder or a guild in an expanded folder + { + match: /switch\((\i)\.type\){.+?default:return null}/, + replace: `return $self.wrapGuildNodeComponent($1,()=>{$&},${IS_BETTER_FOLDERS_VAR},${BETTER_FOLDERS_EXPANDED_IDS_VAR});` + }, + // Export the isBetterFolders variable to the folder component { match: /switch\(\i\.type\){case \i\.\i\.FOLDER:.+?folderNode:\i,/, - replace: '$&isBetterFolders:typeof isBetterFolders!=="undefined"?isBetterFolders:false,' + replace: `$&isBetterFolders:${IS_BETTER_FOLDERS_VAR},` }, - // If we are rendering the Better Folders sidebar, we filter out guilds that are not in folders and unexpanded folders + // Make the callback for returning the guild node component depend on isBetterFolders and betterFoldersExpandedIds { - match: /\[(\i)\]=(\(0,\i\.\i\).{0,40}getGuildsTree\(\).+?}\))(?=,)/, - replace: (_, originalTreeVar, rest) => `[betterFoldersOriginalTree]=${rest},${originalTreeVar}=$self.getGuildTree(!!arguments[0]?.isBetterFolders,betterFoldersOriginalTree,arguments[0]?.betterFoldersExpandedIds)` + match: /switch\(\i\.type\).+?,\i,\i\.setNodeRef/, + replace: "$&,arguments[0]?.isBetterFolders,arguments[0]?.betterFoldersExpandedIds" }, - // If we are rendering the Better Folders sidebar, we filter out everything but the servers and folders from the Guild List children + // If we are rendering the Better Folders sidebar, we filter out everything but the guilds and folders from the Guild List children { match: /lastTargetNode:\i\[\i\.length-1\].+?}\)(?::null)?\](?=}\))/, replace: "$&.filter($self.makeGuildsBarGuildListFilter(!!arguments[0]?.isBetterFolders))" @@ -253,8 +263,8 @@ export default definePlugin({ }, { // Add grid styles to fix aligment with other visual refresh elements - match: /(?<=className:)(\i\.base)(?=,)/, - replace: "`${$self.gridStyle} ${$1}`" + match: /(?<=className:)\i\.base(?=,)/, + replace: `"${GRID_STYLE_NAME} "+$&` } ] }, @@ -316,27 +326,25 @@ export default definePlugin({ FolderSideBar, closeFolders, - gridStyle: "vc-betterFolders-sidebar-grid", - getGuildTree(isBetterFolders: boolean, originalTree: any, expandedFolderIds?: Set) { - return useMemo(() => { - if (!isBetterFolders || expandedFolderIds == null) return originalTree; + wrapGuildNodeComponent(node: any, originalComponent: () => ReactNode, isBetterFolders: boolean, expandedFolderIds?: Set) { + if ( + !isBetterFolders || + node.type === "folder" && expandedFolderIds?.has(node.id) || + node.type === "guild" && expandedFolderIds?.has(node.parentId) + ) { + return originalComponent(); + } - const newTree = new GuildsTree(); - // Children is every folder and guild which is not in a folder, this filters out only the expanded folders - newTree.root.children = originalTree.root.children.filter(guildOrFolder => expandedFolderIds.has(guildOrFolder.id)); - // Nodes is every folder and guild, even if it's in a folder, this filters out only the expanded folders and guilds inside them - newTree.nodes = Object.fromEntries( - Object.entries(originalTree.nodes) - .filter(([_, guildOrFolder]: any[]) => expandedFolderIds.has(guildOrFolder.id) || expandedFolderIds.has(guildOrFolder.parentId)) - ); - - return newTree; - }, [isBetterFolders, originalTree, expandedFolderIds]); + return ( +
+ {originalComponent()} +
+ ); }, makeGuildsBarGuildListFilter(isBetterFolders: boolean) { - return child => { + return (child: any) => { if (!isBetterFolders) { return true; } @@ -351,7 +359,7 @@ export default definePlugin({ }, makeGuildsBarSidebarFilter(isBetterFolders: boolean) { - return child => { + return (child: any) => { if (!isBetterFolders) { return true; } diff --git a/src/plugins/betterFolders/sidebarFix.css b/src/plugins/betterFolders/style.css similarity index 68% rename from src/plugins/betterFolders/sidebarFix.css rename to src/plugins/betterFolders/style.css index 7a048eb7..a3c82dcb 100644 --- a/src/plugins/betterFolders/sidebarFix.css +++ b/src/plugins/betterFolders/style.css @@ -1,7 +1,11 @@ -/* These area names need to be hardcoded. Only betterFoldersSidebar is added by the plugin. */ +.vc-betterFolders-sidebar { + grid-area: betterFoldersSidebar +} +/* These area names need to be hardcoded. Only betterFoldersSidebar is added by the plugin. */ .visual-refresh .vc-betterFolders-sidebar-grid { - grid-template-columns: [start] min-content [guildsEnd] min-content [sidebarEnd] min-content [channelsEnd] 1fr [end]; /* stylelint-disable-line value-keyword-case */ + /* stylelint-disable-next-line value-keyword-case */ + grid-template-columns: [start] min-content [guildsEnd] min-content [sidebarEnd] min-content [channelsEnd] 1fr [end]; grid-template-areas: "titleBar titleBar titleBar titleBar" "guildsList betterFoldersSidebar notice notice" diff --git a/src/plugins/clientTheme/clientTheme.css b/src/plugins/clientTheme/clientTheme.css index 795b5457..49cc3e15 100644 --- a/src/plugins/clientTheme/clientTheme.css +++ b/src/plugins/clientTheme/clientTheme.css @@ -19,16 +19,8 @@ border: thin solid var(--background-modifier-accent) !important; } -.vc-clientTheme-warning-text { - color: var(--text-danger); -} - -.vc-clientTheme-contrast-warning { - background-color: var(--background-primary); - padding: 0.5rem; - border-radius: .5rem; +.vc-clientTheme-buttons-container { + margin-top: 16px; display: flex; - flex-direction: row; - justify-content: space-between; - align-items: center; + gap: 4px; } diff --git a/src/plugins/clientTheme/components/Settings.tsx b/src/plugins/clientTheme/components/Settings.tsx new file mode 100644 index 00000000..f38380fa --- /dev/null +++ b/src/plugins/clientTheme/components/Settings.tsx @@ -0,0 +1,104 @@ +/* + * Vencord, a Discord client mod + * Copyright (c) 2025 Vendicated and contributors + * SPDX-License-Identifier: GPL-3.0-or-later + */ + +import { classNameFactory } from "@api/Styles"; +import { ErrorCard } from "@components/ErrorCard"; +import { Margins } from "@utils/margins"; +import { findByCodeLazy, findComponentByCodeLazy, findStoreLazy } from "@webpack"; +import { Button, Forms, ThemeStore, useStateFromStores } from "@webpack/common"; + +import { settings } from ".."; +import { relativeLuminance } from "../utils/colorUtils"; +import { createOrUpdateThemeColorVars } from "../utils/styleUtils"; + +const ColorPicker = findComponentByCodeLazy("#{intl::USER_SETTINGS_PROFILE_COLOR_SELECT_COLOR}", ".BACKGROUND_PRIMARY)"); +const saveClientTheme = findByCodeLazy('type:"UNSYNCED_USER_SETTINGS_UPDATE', '"system"==='); +const NitroThemeStore = findStoreLazy("ClientThemesBackgroundStore"); + +const cl = classNameFactory("vc-clientTheme-"); + +const colorPresets = [ + "#1E1514", "#172019", "#13171B", "#1C1C28", "#402D2D", + "#3A483D", "#344242", "#313D4B", "#2D2F47", "#322B42", + "#3C2E42", "#422938", "#b6908f", "#bfa088", "#d3c77d", + "#86ac86", "#88aab3", "#8693b5", "#8a89ba", "#ad94bb", +]; + +function onPickColor(color: number) { + const hexColor = color.toString(16).padStart(6, "0"); + + settings.store.color = hexColor; + createOrUpdateThemeColorVars(hexColor); +} + +function setDiscordTheme(theme: string) { + saveClientTheme({ theme }); +} + +export function ThemeSettingsComponent() { + const currentTheme = useStateFromStores([ThemeStore], () => ThemeStore.theme); + const isLightTheme = currentTheme === "light"; + const oppositeTheme = isLightTheme ? "Dark" : "Light"; + + const nitroThemeEnabled = useStateFromStores([NitroThemeStore], () => NitroThemeStore.gradientPreset != null); + + const selectedLuminance = relativeLuminance(settings.store.color); + + let contrastWarning = false; + let fixableContrast = true; + + if ((isLightTheme && selectedLuminance < 0.26) || !isLightTheme && selectedLuminance > 0.12) { + contrastWarning = true; + } + + if (selectedLuminance < 0.26 && selectedLuminance > 0.12) { + fixableContrast = false; + } + + // Light mode with values greater than 65 leads to background colors getting crushed together and poor text contrast for muted channels + if (isLightTheme && selectedLuminance > 0.65) { + contrastWarning = true; + fixableContrast = false; + } + + return ( +
+
+
+ Theme Color + Add a color to your Discord client theme +
+ +
+ {(contrastWarning || nitroThemeEnabled) && (<> + + Your theme won't look good! + + {contrastWarning && {">"} Selected color won't contrast well with text} + {nitroThemeEnabled && {">"} Nitro themes aren't supported} + +
+ {(contrastWarning && fixableContrast) && } + {(nitroThemeEnabled) && } +
+
+ )} +
+ ); +} + +export function ResetThemeColorComponent() { + return ( + + ); +} diff --git a/src/plugins/clientTheme/index.tsx b/src/plugins/clientTheme/index.tsx index 72fc4429..984318e2 100644 --- a/src/plugins/clientTheme/index.tsx +++ b/src/plugins/clientTheme/index.tsx @@ -7,104 +7,21 @@ import "./clientTheme.css"; import { definePluginSettings } from "@api/Settings"; -import { classNameFactory } from "@api/Styles"; import { Devs } from "@utils/constants"; -import { Margins } from "@utils/margins"; -import { classes } from "@utils/misc"; import definePlugin, { OptionType, StartAt } from "@utils/types"; -import { findByCodeLazy, findComponentByCodeLazy, findStoreLazy } from "@webpack"; -import { Button, Forms, ThemeStore, useStateFromStores } from "@webpack/common"; -const cl = classNameFactory("vc-clientTheme-"); +import { ResetThemeColorComponent, ThemeSettingsComponent } from "./components/Settings"; +import { disableClientTheme, startClientTheme } from "./utils/styleUtils"; -const ColorPicker = findComponentByCodeLazy("#{intl::USER_SETTINGS_PROFILE_COLOR_SELECT_COLOR}", ".BACKGROUND_PRIMARY)"); - -const colorPresets = [ - "#1E1514", "#172019", "#13171B", "#1C1C28", "#402D2D", - "#3A483D", "#344242", "#313D4B", "#2D2F47", "#322B42", - "#3C2E42", "#422938", "#b6908f", "#bfa088", "#d3c77d", - "#86ac86", "#88aab3", "#8693b5", "#8a89ba", "#ad94bb", -]; - -function onPickColor(color: number) { - const hexColor = color.toString(16).padStart(6, "0"); - - settings.store.color = hexColor; - updateColorVars(hexColor); -} - -const saveClientTheme = findByCodeLazy('type:"UNSYNCED_USER_SETTINGS_UPDATE', '"system"==='); - -function setTheme(theme: string) { - saveClientTheme({ theme }); -} - -const NitroThemeStore = findStoreLazy("ClientThemesBackgroundStore"); - -function ThemeSettings() { - const theme = useStateFromStores([ThemeStore], () => ThemeStore.theme); - const isLightTheme = theme === "light"; - const oppositeTheme = isLightTheme ? "dark" : "light"; - - const nitroTheme = useStateFromStores([NitroThemeStore], () => NitroThemeStore.gradientPreset); - const nitroThemeEnabled = nitroTheme !== undefined; - - const selectedLuminance = relativeLuminance(settings.store.color); - - let contrastWarning = false, fixableContrast = true; - if ((isLightTheme && selectedLuminance < 0.26) || !isLightTheme && selectedLuminance > 0.12) - contrastWarning = true; - if (selectedLuminance < 0.26 && selectedLuminance > 0.12) - fixableContrast = false; - // light mode with values greater than 65 leads to background colors getting crushed together and poor text contrast for muted channels - if (isLightTheme && selectedLuminance > 0.65) { - contrastWarning = true; - fixableContrast = false; - } - - return ( -
-
-
- Theme Color - Add a color to your Discord client theme -
- -
- {(contrastWarning || nitroThemeEnabled) && (<> - -
-
- Warning, your theme won't look good: - {contrastWarning && Selected color won't contrast well with text} - {nitroThemeEnabled && Nitro themes aren't supported} -
- {(contrastWarning && fixableContrast) && } - {(nitroThemeEnabled) && } -
- )} -
- ); -} - -const settings = definePluginSettings({ +export const settings = definePluginSettings({ color: { type: OptionType.COMPONENT, default: "313338", - component: ThemeSettings + component: ThemeSettingsComponent }, resetColor: { type: OptionType.COMPONENT, - component: () => ( - - ) + component: ResetThemeColorComponent } }); @@ -115,184 +32,6 @@ export default definePlugin({ settings, startAt: StartAt.DOMContentLoaded, - async start() { - updateColorVars(settings.store.color); - - const styles = await getStyles(); - generateColorOffsets(styles); - // generateLightModeFixes(styles); - }, - - stop() { - document.getElementById("clientThemeVars")?.remove(); - document.getElementById("clientThemeOffsets")?.remove(); - document.getElementById("clientThemeLightModeFixes")?.remove(); - } + start: () => startClientTheme(settings.store.color), + stop: disableClientTheme }); - -const visualRefreshVariableRegex = /(--neutral-\d{1,3}-hsl):.*?(\S*)%;/g; -const oldVariableRegex = /(--primary-\d{3}-hsl):.*?(\S*)%;/g; -const lightVariableRegex = /^--primary-[1-5]\d{2}-hsl/g; -const darkVariableRegex = /^--primary-[5-9]\d{2}-hsl/g; - -// generates variables per theme by: -// - offset from specified center (light/dark theme get different offsets because light uses 100 for background-primary, while dark uses 600) -function genThemeSpecificOffsets(variableLightness: Record, regex: RegExp | null, centerVariable: string): string { - return Object.entries(variableLightness).filter(([key]) => regex == null || key.search(regex) > -1) - .map(([key, lightness]) => { - const lightnessOffset = lightness - variableLightness[centerVariable]; - const plusOrMinus = lightnessOffset >= 0 ? "+" : "-"; - return `${key}: var(--theme-h) var(--theme-s) calc(var(--theme-l) ${plusOrMinus} ${Math.abs(lightnessOffset).toFixed(2)}%);`; - }) - .join("\n"); -} - -function generateColorOffsets(styles) { - const oldVariableLightness = {} as Record; - const visualRefreshVariableLightness = {} as Record; - - // Get lightness values of --primary variables - for (const [, variable, lightness] of styles.matchAll(oldVariableRegex)) { - oldVariableLightness[variable] = parseFloat(lightness); - } - - for (const [, variable, lightness] of styles.matchAll(visualRefreshVariableRegex)) { - visualRefreshVariableLightness[variable] = parseFloat(lightness); - } - - createStyleSheet("clientThemeOffsets", [ - `.theme-light {\n ${genThemeSpecificOffsets(oldVariableLightness, lightVariableRegex, "--primary-345-hsl")} \n}`, - `.theme-dark {\n ${genThemeSpecificOffsets(oldVariableLightness, darkVariableRegex, "--primary-600-hsl")} \n}`, - `.visual-refresh.theme-light {\n ${genThemeSpecificOffsets(visualRefreshVariableLightness, null, "--neutral-2-hsl")} \n}`, - `.visual-refresh.theme-dark {\n ${genThemeSpecificOffsets(visualRefreshVariableLightness, null, "--neutral-69-hsl")} \n}`, - ].join("\n\n")); -} - -function generateLightModeFixes(styles: string) { - const groupLightUsesW500Regex = /\.theme-light[^{]*\{[^}]*var\(--white-500\)[^}]*}/gm; - // get light capturing groups that mention --white-500 - const relevantStyles = [...styles.matchAll(groupLightUsesW500Regex)].flat(); - - const groupBackgroundRegex = /^([^{]*)\{background:var\(--white-500\)/m; - const groupBackgroundColorRegex = /^([^{]*)\{background-color:var\(--white-500\)/m; - // find all capturing groups that assign background or background-color directly to w500 - const backgroundGroups = mapReject(relevantStyles, entry => captureOne(entry, groupBackgroundRegex)).join(",\n"); - const backgroundColorGroups = mapReject(relevantStyles, entry => captureOne(entry, groupBackgroundColorRegex)).join(",\n"); - // create css to reassign them to --primary-100 - const reassignBackgrounds = `${backgroundGroups} {\n background: var(--primary-100) \n}`; - const reassignBackgroundColors = `${backgroundColorGroups} {\n background-color: var(--primary-100) \n}`; - - const groupBgVarRegex = /\.theme-light\{([^}]*--[^:}]*(?:background|bg)[^:}]*:var\(--white-500\)[^}]*)\}/m; - const bgVarRegex = /^(--[^:]*(?:background|bg)[^:]*):var\(--white-500\)/m; - // get all global variables used for backgrounds - const lightVars = mapReject(relevantStyles, style => captureOne(style, groupBgVarRegex)) // get the insides of capture groups that have at least one background var with w500 - .map(str => str.split(";")).flat(); // captureGroupInsides[] -> cssRule[] - const lightBgVars = mapReject(lightVars, variable => captureOne(variable, bgVarRegex)); // remove vars that aren't for backgrounds or w500 - // create css to reassign every var - const reassignVariables = `.theme-light {\n ${lightBgVars.map(variable => `${variable}: var(--primary-100);`).join("\n")} \n}`; - - createStyleSheet("clientThemeLightModeFixes", [ - reassignBackgrounds, - reassignBackgroundColors, - reassignVariables, - ].join("\n\n")); -} - -function captureOne(str, regex) { - const result = str.match(regex); - return (result === null) ? null : result[1]; -} - -function mapReject(arr, mapFunc) { - return arr.map(mapFunc).filter(Boolean); -} - -function updateColorVars(color: string) { - const { hue, saturation, lightness } = hexToHSL(color); - - let style = document.getElementById("clientThemeVars"); - if (!style) - style = createStyleSheet("clientThemeVars"); - - style.textContent = `:root { - --theme-h: ${hue}; - --theme-s: ${saturation}%; - --theme-l: ${lightness}%; - }`; -} - -function createStyleSheet(id, content = "") { - const style = document.createElement("style"); - style.setAttribute("id", id); - style.textContent = content.split("\n").map(line => line.trim()).join("\n"); - document.body.appendChild(style); - return style; -} - -// returns all of discord's native styles in a single string -async function getStyles(): Promise { - let out = ""; - const styleLinkNodes = document.querySelectorAll('link[rel="stylesheet"]'); - for (const styleLinkNode of styleLinkNodes) { - const cssLink = styleLinkNode.getAttribute("href"); - if (!cssLink) continue; - - const res = await fetch(cssLink); - out += await res.text(); - } - return out; -} - -// https://css-tricks.com/converting-color-spaces-in-javascript/ -function hexToHSL(hexCode: string) { - // Hex => RGB normalized to 0-1 - const r = parseInt(hexCode.substring(0, 2), 16) / 255; - const g = parseInt(hexCode.substring(2, 4), 16) / 255; - const b = parseInt(hexCode.substring(4, 6), 16) / 255; - - // RGB => HSL - const cMax = Math.max(r, g, b); - const cMin = Math.min(r, g, b); - const delta = cMax - cMin; - - let hue: number, saturation: number, lightness: number; - - lightness = (cMax + cMin) / 2; - - if (delta === 0) { - // If r=g=b then the only thing that matters is lightness - hue = 0; - saturation = 0; - } else { - // Magic - saturation = delta / (1 - Math.abs(2 * lightness - 1)); - - if (cMax === r) - hue = ((g - b) / delta) % 6; - else if (cMax === g) - hue = (b - r) / delta + 2; - else - hue = (r - g) / delta + 4; - hue *= 60; - if (hue < 0) - hue += 360; - } - - // Move saturation and lightness from 0-1 to 0-100 - saturation *= 100; - lightness *= 100; - - return { hue, saturation, lightness }; -} - -// https://www.w3.org/TR/WCAG21/#dfn-relative-luminance -function relativeLuminance(hexCode: string) { - const normalize = (x: number) => - x <= 0.03928 ? x / 12.92 : ((x + 0.055) / 1.055) ** 2.4; - - const r = normalize(parseInt(hexCode.substring(0, 2), 16) / 255); - const g = normalize(parseInt(hexCode.substring(2, 4), 16) / 255); - const b = normalize(parseInt(hexCode.substring(4, 6), 16) / 255); - - return r * 0.2126 + g * 0.7152 + b * 0.0722; -} diff --git a/src/plugins/clientTheme/utils/colorUtils.ts b/src/plugins/clientTheme/utils/colorUtils.ts new file mode 100644 index 00000000..88d94714 --- /dev/null +++ b/src/plugins/clientTheme/utils/colorUtils.ts @@ -0,0 +1,65 @@ +/* + * Vencord, a Discord client mod + * Copyright (c) 2025 Vendicated and contributors + * SPDX-License-Identifier: GPL-3.0-or-later + */ + +// https://css-tricks.com/converting-color-spaces-in-javascript/ +export function hexToHSL(hexCode: string) { + // Hex => RGB normalized to 0-1 + const r = parseInt(hexCode.substring(0, 2), 16) / 255; + const g = parseInt(hexCode.substring(2, 4), 16) / 255; + const b = parseInt(hexCode.substring(4, 6), 16) / 255; + + // RGB => HSL + const cMax = Math.max(r, g, b); + const cMin = Math.min(r, g, b); + const delta = cMax - cMin; + + let hue: number; + let saturation: number; + let lightness: number; + + lightness = (cMax + cMin) / 2; + + if (delta === 0) { + // If r=g=b then the only thing that matters is lightness + hue = 0; + saturation = 0; + } else { + // Magic + saturation = delta / (1 - Math.abs(2 * lightness - 1)); + + if (cMax === r) { + hue = ((g - b) / delta) % 6; + } else if (cMax === g) { + hue = (b - r) / delta + 2; + } else { + hue = (r - g) / delta + 4; + } + + hue *= 60; + if (hue < 0) { + hue += 360; + } + } + + // Move saturation and lightness from 0-1 to 0-100 + saturation *= 100; + lightness *= 100; + + return { hue, saturation, lightness }; +} + +// https://www.w3.org/TR/WCAG21/#dfn-relative-luminance +export function relativeLuminance(hexCode: string) { + const normalize = (x: number) => ( + x <= 0.03928 ? x / 12.92 : ((x + 0.055) / 1.055) ** 2.4 + ); + + const r = normalize(parseInt(hexCode.substring(0, 2), 16) / 255); + const g = normalize(parseInt(hexCode.substring(2, 4), 16) / 255); + const b = normalize(parseInt(hexCode.substring(4, 6), 16) / 255); + + return r * 0.2126 + g * 0.7152 + b * 0.0722; +} diff --git a/src/plugins/clientTheme/utils/styleUtils.ts b/src/plugins/clientTheme/utils/styleUtils.ts new file mode 100644 index 00000000..bc6169d4 --- /dev/null +++ b/src/plugins/clientTheme/utils/styleUtils.ts @@ -0,0 +1,90 @@ +/* + * Vencord, a Discord client mod + * Copyright (c) 2025 Vendicated and contributors + * SPDX-License-Identifier: GPL-3.0-or-later + */ + +import { hexToHSL } from "./colorUtils"; + +const VARS_STYLE_ID = "vc-clientTheme-vars"; +const OVERRIDES_STYLE_ID = "vc-clientTheme-overrides"; + +export function createOrUpdateThemeColorVars(color: string) { + const { hue, saturation, lightness } = hexToHSL(color); + + createOrUpdateStyle(VARS_STYLE_ID, `:root { + --theme-h: ${hue}; + --theme-s: ${saturation}%; + --theme-l: ${lightness}%; + }`); +} + +export async function startClientTheme(color: string) { + createOrUpdateThemeColorVars(color); + createColorsOverrides(await getDiscordStyles()); +} + +export function disableClientTheme() { + document.getElementById(VARS_STYLE_ID)?.remove(); + document.getElementById(OVERRIDES_STYLE_ID)?.remove(); +} + +function getOrCreateStyle(styleId: string) { + const existingStyle = document.getElementById(styleId); + if (existingStyle) { + return existingStyle as HTMLStyleElement; + } + + const newStyle = document.createElement("style"); + newStyle.id = styleId; + + return document.head.appendChild(newStyle); +} + +function createOrUpdateStyle(styleId: string, css: string) { + const style = getOrCreateStyle(styleId); + style.textContent = css; +} + +/** + * @returns A string containing all the CSS styles from the Discord client. + */ +async function getDiscordStyles(): Promise { + const styleLinkNodes = document.querySelectorAll('link[rel="stylesheet"]'); + + const cssTexts = await Promise.all(Array.from(styleLinkNodes, async node => { + if (!node.href) + return null; + + return fetch(node.href).then(res => res.text()); + })); + + return cssTexts.filter(Boolean).join("\n"); +} + +const VISUAL_REFRESH_COLORS_VARIABLES_REGEX = /(--neutral-\d{1,3}?-hsl):.+?([\d.]+?)%;/g; + +function createColorsOverrides(styles: string) { + const visualRefreshColorsLightness = {} as Record; + + for (const [, colorVariableName, lightness] of styles.matchAll(VISUAL_REFRESH_COLORS_VARIABLES_REGEX)) { + visualRefreshColorsLightness[colorVariableName] = parseFloat(lightness); + } + + const lightThemeBaseLightness = visualRefreshColorsLightness["--neutral-2-hsl"]; + const darkThemeBaseLightness = visualRefreshColorsLightness["--neutral-69-hsl"]; + + createOrUpdateStyle(OVERRIDES_STYLE_ID, [ + `.visual-refresh.theme-light {\n ${generateNewColorVars(visualRefreshColorsLightness, lightThemeBaseLightness)} \n}`, + `.visual-refresh.theme-dark {\n ${generateNewColorVars(visualRefreshColorsLightness, darkThemeBaseLightness)} \n}`, + ].join("\n\n")); +} + +function generateNewColorVars(colorsLightess: Record, baseLightness: number) { + return Object.entries(colorsLightess).map(([colorVariableName, lightness]) => { + const lightnessOffset = lightness - baseLightness; + const plusOrMinus = lightnessOffset >= 0 ? "+" : "-"; + + return `${colorVariableName}: var(--theme-h) var(--theme-s) calc(var(--theme-l) ${plusOrMinus} ${Math.abs(lightnessOffset).toFixed(2)}%);`; + }).join("\n"); +} diff --git a/src/plugins/friendsSince/index.tsx b/src/plugins/friendsSince/index.tsx index ad4e8eab..b59c8a62 100644 --- a/src/plugins/friendsSince/index.tsx +++ b/src/plugins/friendsSince/index.tsx @@ -32,7 +32,15 @@ export default definePlugin({ }, // User Profile Modal { - find: "#{intl::CONNECTIONS}),scrollIntoView", + find: ".connections,userId:", + replacement: { + match: /#{intl::USER_PROFILE_MEMBER_SINCE}\),.{0,100}userId:(\i\.id),.{0,100}}\)}\),/, + replace: "$&,$self.FriendsSinceComponent({userId:$1,isSidebar:false})," + } + }, + // User Profile Modal v2 + { + find: ".MODAL_V2,onClose:", replacement: { match: /#{intl::USER_PROFILE_MEMBER_SINCE}\),.{0,100}userId:(\i\.id),.{0,100}}\)}\),/, replace: "$&,$self.FriendsSinceComponent({userId:$1,isSidebar:false})," diff --git a/src/plugins/mutualGroupDMs/index.tsx b/src/plugins/mutualGroupDMs/index.tsx index 858a366c..94811ea7 100644 --- a/src/plugins/mutualGroupDMs/index.tsx +++ b/src/plugins/mutualGroupDMs/index.tsx @@ -87,6 +87,7 @@ export default definePlugin({ authors: [Devs.amia], patches: [ + // User Profile Modal { find: ".BOT_DATA_ACCESS?(", replacement: [ @@ -102,7 +103,31 @@ export default definePlugin({ // set the gap to zero to ensure ours stays on screen { match: /className:\i\.tabBar/, - replace: "$& + ' vc-mutual-gdms-tab-bar'" + replace: '$& + " vc-mutual-gdms-modal-tab-bar"' + } + ] + }, + // User Profile Modal v2 + { + find: ".tabBarPanel,children:", + replacement: [ + { + match: /items:(\i),.+?(?=return\(0,\i\.jsxs?\)\("div)/, + replace: "$&$self.pushSection($1,arguments[0].user);" + }, + { + match: /\.tabBarPanel,children:(?=.+?section:(\i))/, + replace: "$&$1==='MUTUAL_GDMS'?$self.renderMutualGDMs(arguments[0]):" + }, + // Make the gap between each item smaller so our tab can fit. + { + match: /className:\i\.tabBar/, + replace: '$& + " vc-mutual-gdms-modal-v2-tab-bar"' + }, + // Make the tab bar item text smaller so our tab can fit. + { + match: /(\.tabBarItem.+?variant:)"heading-lg\/medium"/, + replace: '$1"heading-md/medium"' } ] }, @@ -138,8 +163,8 @@ export default definePlugin({ sections[IS_PATCHED] = true; sections.push({ + text: getMutualGDMCountText(user), section: "MUTUAL_GDMS", - text: getMutualGDMCountText(user) }); } catch (e) { new Logger("MutualGroupDMs").error("Failed to push mutual group dms section:", e); diff --git a/src/plugins/mutualGroupDMs/style.css b/src/plugins/mutualGroupDMs/style.css index 3d06568f..698e21de 100644 --- a/src/plugins/mutualGroupDMs/style.css +++ b/src/plugins/mutualGroupDMs/style.css @@ -1,3 +1,7 @@ -.vc-mutual-gdms-tab-bar { +.vc-mutual-gdms-modal-tab-bar { gap: 0; } + +.vc-mutual-gdms-modal-v2-tab-bar { + gap: 6px; +} diff --git a/src/plugins/userVoiceShow/index.tsx b/src/plugins/userVoiceShow/index.tsx index 5f1a05e5..31417c0e 100644 --- a/src/plugins/userVoiceShow/index.tsx +++ b/src/plugins/userVoiceShow/index.tsx @@ -56,6 +56,34 @@ export default definePlugin({ settings, patches: [ + // User Popout, User Profile Modal, Direct Messages Side Profile + { + find: "#{intl::USER_PROFILE_LOAD_ERROR}", + replacement: { + match: /(\.fetchError.+?\?)null/, + replace: (_, rest) => `${rest}$self.VoiceChannelIndicator({userId:arguments[0]?.userId})` + }, + predicate: () => settings.store.showInUserProfileModal + }, + // To use without the MemberList decorator API + /* // Guild Members List + { + find: ".lostPermission)", + replacement: { + match: /\.lostPermission\).+?(?=avatar:)/, + replace: "$&children:[$self.VoiceChannelIndicator({userId:arguments[0]?.user?.id})]," + }, + predicate: () => settings.store.showVoiceChannelIndicator + }, + // Direct Messages List + { + find: "PrivateChannel.renderAvatar", + replacement: { + match: /#{intl::CLOSE_DM}.+?}\)(?=])/, + replace: "$&,$self.VoiceChannelIndicator({userId:arguments[0]?.user?.id})" + }, + predicate: () => settings.store.showVoiceChannelIndicator + }, */ // Friends List { find: "null!=this.peopleListItemRef.current", diff --git a/src/plugins/viewIcons/index.tsx b/src/plugins/viewIcons/index.tsx index 2cc09fb6..6c45a799 100644 --- a/src/plugins/viewIcons/index.tsx +++ b/src/plugins/viewIcons/index.tsx @@ -190,7 +190,7 @@ export default definePlugin({ }, patches: [ - // Avatar component used in User DMs "User Profile" popup in the right and Profiles Modal pfp + // Avatar component used in User DMs "User Profile" popup in the right and User Profile Modal pfp { find: ".overlay:void 0,status:", replacement: [ diff --git a/src/utils/modal.tsx b/src/utils/modal.tsx index eebdb95e..17bf3987 100644 --- a/src/utils/modal.tsx +++ b/src/utils/modal.tsx @@ -140,7 +140,7 @@ export type MediaModalProps = { shouldHideMediaOptions?: boolean; }; -// modal key: "Media Viewer Modal" +// Modal key: "Media Viewer Modal" export const openMediaModal: (props: MediaModalProps) => void = findByCodeLazy("hasMediaOptions", "shouldHideMediaOptions"); interface ModalAPI {