From 525f5968268fbb8a1e132875f88c99d3a6afd5f8 Mon Sep 17 00:00:00 2001 From: Joona <69722179+Masterjoona@users.noreply.github.com> Date: Wed, 31 Dec 2025 19:35:59 +0200 Subject: [PATCH] add Vencord styles to Discord popout windows (#3080) Co-authored-by: prism Co-authored-by: V --- .../src/stores/PopoutWindowStore.d.ts | 246 ++++++++++++++++++ packages/discord-types/src/stores/index.d.ts | 1 + packages/discord-types/src/utils.d.ts | 29 +-- src/api/Themes.ts | 31 ++- src/plugins/blurNsfw/index.ts | 6 +- src/plugins/clientTheme/utils/styleUtils.ts | 28 +- src/webpack/common/stores.ts | 2 + 7 files changed, 298 insertions(+), 45 deletions(-) create mode 100644 packages/discord-types/src/stores/PopoutWindowStore.d.ts diff --git a/packages/discord-types/src/stores/PopoutWindowStore.d.ts b/packages/discord-types/src/stores/PopoutWindowStore.d.ts new file mode 100644 index 00000000..2a9cd0b6 --- /dev/null +++ b/packages/discord-types/src/stores/PopoutWindowStore.d.ts @@ -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; + + /** + * 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; +} diff --git a/packages/discord-types/src/stores/index.d.ts b/packages/discord-types/src/stores/index.d.ts index f0b475de..12f70760 100644 --- a/packages/discord-types/src/stores/index.d.ts +++ b/packages/discord-types/src/stores/index.d.ts @@ -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"; diff --git a/packages/discord-types/src/utils.d.ts b/packages/discord-types/src/utils.d.ts index a51ffea2..e17844f4 100644 --- a/packages/discord-types/src/utils.d.ts +++ b/packages/discord-types/src/utils.d.ts @@ -224,34 +224,7 @@ export interface ExpressionPickerStore { useExpressionPickerStore(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 { diff --git a/src/api/Themes.ts b/src/api/Themes.ts index dca7d8c0..b4cac6c5 100644 --- a/src/api/Themes.ts +++ b/src/api/Themes.ts @@ -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); } diff --git a/src/plugins/blurNsfw/index.ts b/src/plugins/blurNsfw/index.ts index 1c1d79d5..958bac57 100644 --- a/src/plugins/blurNsfw/index.ts +++ b/src/plugins/blurNsfw/index.ts @@ -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(); }, diff --git a/src/plugins/clientTheme/utils/styleUtils.ts b/src/plugins/clientTheme/utils/styleUtils.ts index ca36730a..4c9ee5e5 100644 --- a/src/plugins/clientTheme/utils/styleUtils.ts +++ b/src/plugins/clientTheme/utils/styleUtils.ts @@ -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; 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; } diff --git a/src/webpack/common/stores.ts b/src/webpack/common/stores.ts index f1ea29a9..0d43e635 100644 --- a/src/webpack/common/stores.ts +++ b/src/webpack/common/stores.ts @@ -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.