add Vencord styles to Discord popout windows (#3080)

Co-authored-by: prism <snawalt420@proton.me>
Co-authored-by: V <vendicated@riseup.net>
This commit is contained in:
Joona 2025-12-31 19:35:59 +02:00 committed by GitHub
parent ed1acc3baa
commit 525f596826
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
7 changed files with 298 additions and 45 deletions

View file

@ -0,0 +1,246 @@
import { FluxStore } from "..";
/**
* Known popout window key constants.
* Used as the key parameter for PopoutWindowStore and PopoutActions methods.
*/
export type PopoutWindowKey =
| "DISCORD_CHANNEL_CALL_POPOUT"
| "DISCORD_CALL_TILE_POPOUT"
| "DISCORD_SOUNDBOARD"
| "DISCORD_RTC_DEBUG_POPOUT"
| "DISCORD_CHANNEL_POPOUT"
| "DISCORD_ACTIVITY_POPOUT"
| "DISCORD_OVERLAY_POPOUT"
| "DISCORD_DEVTOOLS_POPOUT";
/**
* Popout window lifecycle event types.
* Sent via postMessage from popout to parent window.
*/
export type PopoutWindowEventType = "loaded" | "unloaded";
/**
* Persisted window position and size state.
* Saved to localStorage and restored when reopening popouts.
*/
export interface PopoutWindowState {
/** window x position on screen in pixels. */
x: number;
/** window y position on screen in pixels. */
y: number;
/** window inner width in pixels. */
width: number;
/** window inner height in pixels. */
height: number;
/** whether window stays above other windows, only on desktop app. */
alwaysOnTop?: boolean;
}
/**
* Features passed to window.open() for popout configuration.
* Merged with default features (menubar, toolbar, location, directories = false).
*/
export interface BrowserWindowFeatures {
/** whether to show browser toolbar. */
toolbar?: boolean;
/** whether to show menu bar. */
menubar?: boolean;
/** whether to show location/address bar. */
location?: boolean;
/** whether to show directory buttons. */
directories?: boolean;
/** window width in pixels. */
width?: number;
/** window height in pixels. */
height?: number;
/** default width if no persisted state exists. */
defaultWidth?: number;
/** default height if no persisted state exists. */
defaultHeight?: number;
/** window left position in pixels. */
left?: number;
/** window top position in pixels. */
top?: number;
/** default always-on-top state, defaults to false. */
defaultAlwaysOnTop?: boolean;
/** whether window can be moved by user. */
movable?: boolean;
/** whether window can be resized by user. */
resizable?: boolean;
/** whether window has a frame/border. */
frame?: boolean;
/** whether window stays above other windows. */
alwaysOnTop?: boolean;
/** whether window has a shadow (macOS). */
hasShadow?: boolean;
/** whether window background is transparent. */
transparent?: boolean;
/** whether to hide window from taskbar. */
skipTaskbar?: boolean;
/** title bar style, null for default. */
titleBarStyle?: string | null;
/** window background color as hex string. */
backgroundColor?: string;
/** whether this is an out-of-process overlay window. */
outOfProcessOverlay?: boolean;
}
/**
* Manages Discord's popout windows (voice calls, activities, etc.).
* Extends PersistedStore to save window positions across sessions.
*
* Handles Flux actions:
* - POPOUT_WINDOW_OPEN: opens a new popout window
* - POPOUT_WINDOW_CLOSE: closes a popout window
* - POPOUT_WINDOW_SET_ALWAYS_ON_TOP: toggles always-on-top (desktop only)
* - POPOUT_WINDOW_ADD_STYLESHEET: injects stylesheet into all open popouts
* - LOGOUT: closes all popout windows
*/
export class PopoutWindowStore extends FluxStore {
/**
* Gets the Window object for a popout.
* @param key unique identifier for the popout window
* @returns Window reference or undefined if not open
*/
getWindow(key: string): Window | undefined;
/**
* Gets persisted position/size state for a window.
* State is saved when window closes and restored when reopened.
* @param key unique identifier for the popout window
* @returns saved state or undefined if never opened
*/
getWindowState(key: string): PopoutWindowState | undefined;
/**
* Gets all currently open popout window keys.
* @returns array of window key identifiers
*/
getWindowKeys(): string[];
/**
* Checks if a popout window is currently open.
* @param key unique identifier for the popout window
* @returns true if window exists and is not closed
*/
getWindowOpen(key: string): boolean;
/**
* Checks if a popout window has always-on-top enabled.
* Only functional on desktop app (isPlatformEmbedded).
* @param key unique identifier for the popout window
* @returns true if always-on-top is enabled
*/
getIsAlwaysOnTop(key: string): boolean;
/**
* Checks if a popout window's document has focus.
* @param key unique identifier for the popout window
* @returns true if window document has focus
*/
getWindowFocused(key: string): boolean;
/**
* Checks if a popout window is visible (not minimized/hidden).
* Uses document.visibilityState === "visible".
* @param key unique identifier for the popout window
* @returns true if window is visible
*/
getWindowVisible(key: string): boolean;
/**
* Gets all persisted window states.
* Keyed by window identifier, contains position/size data.
* @returns record of window key to persisted state
*/
getState(): Record<string, PopoutWindowState>;
/**
* Checks if a window is fully initialized and ready for rendering.
* A window is fully initialized when it has:
* - Window object created
* - React root mounted
* - Render function stored
* @param key unique identifier for the popout window
* @returns true if window is fully initialized
*/
isWindowFullyInitialized(key: string): boolean;
/**
* Checks if a popout window is in fullscreen mode.
* Checks if document.fullscreenElement.id === "app-mount".
* @param key unique identifier for the popout window
* @returns true if window is fullscreen
*/
isWindowFullScreen(key: string): boolean;
/**
* Unmounts and closes a popout window.
* Saves current position/size before closing.
* Logs warning if window was not fully initialized.
* @param key unique identifier for the popout window
*/
unmountWindow(key: string): void;
}
/**
* Actions for managing popout windows.
* Dispatches Flux actions to PopoutWindowStore.
*/
export interface PopoutActions {
/**
* Opens a new popout window.
* If window with key already exists and is not out-of-process:
* - On desktop: focuses the existing window via native module
* - On web: calls window.focus()
* @param key unique identifier for the popout window
* @param render function that returns React element to render, receives key as arg
* @param features window features (size, position, etc.)
*/
open(key: string, render: (key: string) => React.ReactNode, features?: BrowserWindowFeatures): void;
/**
* Closes a popout window.
* Saves position/size state before closing unless preventPopoutClose setting is true.
* @param key unique identifier for the popout window
*/
close(key: string): void;
/**
* Sets always-on-top state for a popout window.
* Only functional on desktop app (isPlatformEmbedded).
* @param key unique identifier for the popout window
* @param alwaysOnTop whether window should stay above others
*/
setAlwaysOnTop(key: string, alwaysOnTop: boolean): void;
/**
* Note: Not actually in the Webpack Common. You have to add it yourself if you want to use it
*
* Injects a stylesheet into all open popout windows.
* Validates origin matches current host or webpack public path.
* @param url stylesheet URL to inject
* @param integrity optional SRI integrity hash
*/
addStylesheet?(url: string, integrity?: string): void;
/**
* Note: Not actually in the Webpack Common. You have to add it yourself if you want to use it
*
* Opens a channel call popout for voice/video calls.
* Dispatches CHANNEL_CALL_POPOUT_WINDOW_OPEN action.
* @param channel channel object to open call popout for
*/
openChannelCallPopout?(channel: { id: string; }): void;
/**
* Note: Not actually in the Webpack Common. You have to add it yourself if you want to use it
*
* Opens a call tile popout for a specific participant.
* Dispatches CALL_TILE_POPOUT_WINDOW_OPEN action.
* @param channelId channel ID of the call
* @param participantId user ID of the participant
*/
openCallTilePopout?(channelId: string, participantId: string): void;
}

View file

@ -24,6 +24,7 @@ export * from "./MessageStore";
export * from "./NotificationSettingsStore";
export * from "./OverridePremiumTypeStore";
export * from "./PermissionStore";
export * from "./PopoutWindowStore";
export * from "./PresenceStore";
export * from "./ReadStateStore";
export * from "./RelationshipStore";

View file

@ -224,34 +224,7 @@ export interface ExpressionPickerStore {
useExpressionPickerStore<T>(selector: (state: ExpressionPickerStoreState) => T): T;
}
export interface BrowserWindowFeatures {
toolbar?: boolean;
menubar?: boolean;
location?: boolean;
directories?: boolean;
width?: number;
height?: number;
defaultWidth?: number;
defaultHeight?: number;
left?: number;
top?: number;
defaultAlwaysOnTop?: boolean;
movable?: boolean;
resizable?: boolean;
frame?: boolean;
alwaysOnTop?: boolean;
hasShadow?: boolean;
transparent?: boolean;
skipTaskbar?: boolean;
titleBarStyle?: string | null;
backgroundColor?: string;
}
export interface PopoutActions {
open(key: string, render: (windowKey: string) => ReactNode, features?: BrowserWindowFeatures);
close(key: string): void;
setAlwaysOnTop(key: string, alwaysOnTop: boolean): void;
}
export { BrowserWindowFeatures, PopoutActions } from "./stores/PopoutWindowStore";
export type UserNameUtilsTagInclude = LiteralUnion<"auto" | "always" | "never", string>;
export interface UserNameUtilsTagOptions {

View file

@ -19,8 +19,9 @@
import { Settings, SettingsStore } from "@api/Settings";
import { createAndAppendStyle } from "@utils/css";
import { ThemeStore } from "@vencord/discord-types";
import { PopoutWindowStore } from "@webpack/common";
import { userStyleRootNode } from "./Styles";
import { userStyleRootNode, vencordRootNode } from "./Styles";
let style: HTMLStyleElement;
let themesStyle: HTMLStyleElement;
@ -33,6 +34,7 @@ async function toggle(isEnabled: boolean) {
style.textContent = css;
// At the time of writing this, changing textContent resets the disabled state
style.disabled = !Settings.useQuickCss;
updatePopoutWindows();
});
style.textContent = await VencordNative.quickCss.get();
}
@ -76,6 +78,25 @@ async function initThemes() {
}
themesStyle.textContent = links.map(link => `@import url("${link.trim()}");`).join("\n");
updatePopoutWindows();
}
function applyToPopout(popoutWindow: Window | undefined) {
if (!popoutWindow?.document) return;
const doc = popoutWindow.document;
doc.querySelector("vencord-root")?.remove();
doc.documentElement.appendChild(vencordRootNode.cloneNode(true));
}
function updatePopoutWindows() {
const windowKeys = PopoutWindowStore.getWindowKeys();
for (const key of windowKeys) {
const popoutWindow = PopoutWindowStore.getWindow(key);
applyToPopout(popoutWindow);
}
}
document.addEventListener("DOMContentLoaded", () => {
@ -89,6 +110,14 @@ document.addEventListener("DOMContentLoaded", () => {
SettingsStore.addChangeListener("themeLinks", initThemes);
SettingsStore.addChangeListener("enabledThemes", initThemes);
window.addEventListener("message", event => {
const { discordPopoutEvent } = event.data || {};
if (discordPopoutEvent?.type !== "loaded") return;
const popoutWindow = PopoutWindowStore.getWindow(discordPopoutEvent.key);
applyToPopout(popoutWindow);
});
if (!IS_WEB) {
VencordNative.quickCss.addThemeChangeListener(initThemes);
}

View file

@ -17,7 +17,9 @@
*/
import { Settings } from "@api/Settings";
import { managedStyleRootNode } from "@api/Styles";
import { Devs } from "@utils/constants";
import { createAndAppendStyle } from "@utils/css";
import definePlugin, { OptionType } from "@utils/types";
let style: HTMLStyleElement;
@ -61,9 +63,7 @@ export default definePlugin({
},
start() {
style = document.createElement("style");
style.id = "VcBlurNsfw";
document.head.appendChild(style);
style = createAndAppendStyle("VcBlurNsfw", managedStyleRootNode);
setCss();
},

View file

@ -4,10 +4,15 @@
* SPDX-License-Identifier: GPL-3.0-or-later
*/
import { hexToHSL } from "./colorUtils";
import { managedStyleRootNode } from "@api/Styles";
import { createAndAppendStyle } from "@utils/css";
import { hexToHSL } from "./colorUtils";
const VARS_STYLE_ID = "vc-clientTheme-vars";
const OVERRIDES_STYLE_ID = "vc-clientTheme-overrides";
type StyleId = typeof VARS_STYLE_ID | typeof OVERRIDES_STYLE_ID;
const styleCache = {} as Record<StyleId, HTMLStyleElement | null>;
export function createOrUpdateThemeColorVars(color: string) {
const { hue, saturation, lightness } = hexToHSL(color);
@ -25,23 +30,20 @@ export async function startClientTheme(color: string) {
}
export function disableClientTheme() {
document.getElementById(VARS_STYLE_ID)?.remove();
document.getElementById(OVERRIDES_STYLE_ID)?.remove();
styleCache[VARS_STYLE_ID]?.remove();
styleCache[OVERRIDES_STYLE_ID]?.remove();
styleCache[VARS_STYLE_ID] = null;
styleCache[OVERRIDES_STYLE_ID] = null;
}
function getOrCreateStyle(styleId: string) {
const existingStyle = document.getElementById(styleId);
if (existingStyle) {
return existingStyle as HTMLStyleElement;
function getOrCreateStyle(styleId: StyleId) {
if (!styleCache[styleId]) {
styleCache[styleId] = createAndAppendStyle(styleId, managedStyleRootNode);
}
const newStyle = document.createElement("style");
newStyle.id = styleId;
return document.head.appendChild(newStyle);
return styleCache[styleId];
}
function createOrUpdateStyle(styleId: string, css: string) {
function createOrUpdateStyle(styleId: StyleId, css: string) {
const style = getOrCreateStyle(styleId);
style.textContent = css;
}

View file

@ -78,6 +78,7 @@ export let InviteStore: t.InviteStore;
export let LocaleStore: t.LocaleStore;
export let RTCConnectionStore: t.RTCConnectionStore;
export let SoundboardStore: t.SoundboardStore;
export let PopoutWindowStore: t.PopoutWindowStore;
/**
* @see jsdoc of {@link t.useStateFromStores}
@ -128,6 +129,7 @@ waitForStore("InviteStore", m => InviteStore = m);
waitForStore("LocaleStore", m => LocaleStore = m);
waitForStore("RTCConnectionStore", m => RTCConnectionStore = m);
waitForStore("SoundboardStore", m => SoundboardStore = m);
waitForStore("PopoutWindowStore", m => PopoutWindowStore = m);
waitForStore("ThemeStore", m => {
ThemeStore = m;
// Importing this directly causes all webpack commons to be imported, which can easily cause circular dependencies.