Merge remote-tracking branch 'upstream/dev' into dev
Some checks are pending
Release / Build Equicord (push) Waiting to run
Test / Test (push) Waiting to run

This commit is contained in:
thororen1234 2025-05-04 18:23:31 -04:00
commit 77eae73831
No known key found for this signature in database
14 changed files with 389 additions and 323 deletions

View file

@ -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
? <div style={barStyle}>{Sidebar}</div>
? <div className="vc-betterFolders-sidebar" style={sidebarStyle}>{Sidebar}</div>
: null;
}
@ -62,9 +61,9 @@ export default ErrorBoundary.wrap(guildsBarProps => {
leave={{ width: 0 }}
config={{ duration: 200 }}
>
{(animationStyle, show) =>
{(animationStyle: any, show: any) =>
show && (
<Animations.animated.div style={{ ...animationStyle, ...barStyle }}>
<Animations.animated.div className="vc-betterFolders-sidebar" style={{ ...animationStyle, ...sidebarStyle }}>
{Sidebar}
</Animations.animated.div>
)

View file

@ -16,14 +16,15 @@
* along with this program. If not, see <https://www.gnu.org/licenses/>.
*/
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<any>) {
return useMemo(() => {
if (!isBetterFolders || expandedFolderIds == null) return originalTree;
wrapGuildNodeComponent(node: any, originalComponent: () => ReactNode, isBetterFolders: boolean, expandedFolderIds?: Set<any>) {
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 (
<div style={{ display: "none" }}>
{originalComponent()}
</div>
);
},
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;
}

View file

@ -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"

View file

@ -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;
}

View file

@ -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 (
<div className={cl("settings")}>
<div className={cl("container")}>
<div className={cl("settings-labels")}>
<Forms.FormTitle tag="h3">Theme Color</Forms.FormTitle>
<Forms.FormText>Add a color to your Discord client theme</Forms.FormText>
</div>
<ColorPicker
color={parseInt(settings.store.color, 16)}
onChange={onPickColor}
showEyeDropper={false}
suggestedColors={colorPresets}
/>
</div>
{(contrastWarning || nitroThemeEnabled) && (<>
<ErrorCard className={Margins.top8}>
<Forms.FormTitle tag="h2">Your theme won't look good!</Forms.FormTitle>
{contrastWarning && <Forms.FormText>{">"} Selected color won't contrast well with text</Forms.FormText>}
{nitroThemeEnabled && <Forms.FormText>{">"} Nitro themes aren't supported</Forms.FormText>}
<div className={cl("buttons-container")}>
{(contrastWarning && fixableContrast) && <Button onClick={() => setDiscordTheme(oppositeTheme)} color={Button.Colors.RED}>Switch to {oppositeTheme} mode</Button>}
{(nitroThemeEnabled) && <Button onClick={() => setDiscordTheme(currentTheme)} color={Button.Colors.RED}>Disable Nitro Theme</Button>}
</div>
</ErrorCard>
</>)}
</div>
);
}
export function ResetThemeColorComponent() {
return (
<Button onClick={() => onPickColor(0x313338)}>
Reset Theme Color
</Button>
);
}

View file

@ -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 (
<div className={cl("settings")}>
<div className={cl("container")}>
<div className={cl("settings-labels")}>
<Forms.FormTitle tag="h3">Theme Color</Forms.FormTitle>
<Forms.FormText>Add a color to your Discord client theme</Forms.FormText>
</div>
<ColorPicker
color={parseInt(settings.store.color, 16)}
onChange={onPickColor}
showEyeDropper={false}
suggestedColors={colorPresets}
/>
</div>
{(contrastWarning || nitroThemeEnabled) && (<>
<Forms.FormDivider className={classes(Margins.top8, Margins.bottom8)} />
<div className={`client-theme-contrast-warning ${contrastWarning ? (isLightTheme ? "theme-dark" : "theme-light") : ""}`}>
<div className={cl("warning")}>
<Forms.FormText className={cl("warning-text")}>Warning, your theme won't look good:</Forms.FormText>
{contrastWarning && <Forms.FormText className={cl("warning-text")}>Selected color won't contrast well with text</Forms.FormText>}
{nitroThemeEnabled && <Forms.FormText className={cl("warning-text")}>Nitro themes aren't supported</Forms.FormText>}
</div>
{(contrastWarning && fixableContrast) && <Button onClick={() => setTheme(oppositeTheme)} color={Button.Colors.RED}>Switch to {oppositeTheme} mode</Button>}
{(nitroThemeEnabled) && <Button onClick={() => setTheme(theme)} color={Button.Colors.RED}>Disable Nitro Theme</Button>}
</div>
</>)}
</div>
);
}
const settings = definePluginSettings({
export const settings = definePluginSettings({
color: {
type: OptionType.COMPONENT,
default: "313338",
component: ThemeSettings
component: ThemeSettingsComponent
},
resetColor: {
type: OptionType.COMPONENT,
component: () => (
<Button onClick={() => onPickColor(0x313338)}>
Reset Theme Color
</Button>
)
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<string, number>, 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<string, number>;
const visualRefreshVariableLightness = {} as Record<string, number>;
// 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<string> {
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;
}

View file

@ -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;
}

View file

@ -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<string> {
const styleLinkNodes = document.querySelectorAll<HTMLLinkElement>('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<string, number>;
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<string, number>, 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");
}

View file

@ -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}),"

View file

@ -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);

View file

@ -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;
}

View file

@ -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",

View file

@ -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: [

View file

@ -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 {