Merge remote-tracking branch 'upstream/dev' into dev

This commit is contained in:
thororen1234 2025-12-31 23:12:55 -05:00
commit ebc5c975dc
No known key found for this signature in database
12 changed files with 311 additions and 65 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

@ -232,34 +232,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("enabledThemeLinks", 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

@ -41,7 +41,11 @@ export function BooleanSetting({ option, pluginSettings, definedSettings, id, on
return (
<SettingsSection tag="label" name={id} description={option.description} error={error} inlineSetting>
<Switch checked={state} onChange={handleChange} />
<Switch
checked={state}
onChange={handleChange}
disabled={option.disabled?.call(definedSettings) ?? false}
/>
</SettingsSection>
);
}

View file

@ -5,7 +5,9 @@
*/
import { definePluginSettings } 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;
@ -56,9 +58,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

@ -76,7 +76,7 @@ function getGuildMaxStickerSlots(guild: Guild) {
function getUrl(data: Data, size: number) {
if (data.t === "Emoji")
return `${location.protocol}//${window.GLOBAL_ENV.CDN_HOST}/emojis/${data.id}.${data.isAnimated ? "gif" : "png"}?size=${size}&lossless=true`;
return `${location.protocol}//${window.GLOBAL_ENV.CDN_HOST}/emojis/${data.id}.webp?size=${size}&lossless=true`;
return `${window.GLOBAL_ENV.MEDIA_PROXY_ENDPOINT}/stickers/${data.id}.${StickerExtMap[data.format_type]}?size=${size}&lossless=true`;
}

View file

@ -21,13 +21,13 @@ import { definePluginSettings } from "@api/Settings";
import { Paragraph } from "@components/Paragraph";
import { ApngBlendOp, ApngDisposeOp, parseAPNG } from "@utils/apng";
import { Devs } from "@utils/constants";
import { getCurrentGuild, getEmojiURL } from "@utils/discord";
import { getCurrentGuild } from "@utils/discord";
import { Logger } from "@utils/Logger";
import definePlugin, { OptionType, Patch } from "@utils/types";
import type { Emoji, Message, Sticker } from "@vencord/discord-types";
import { StickerFormatType } from "@vencord/discord-types/enums";
import { findByCodeLazy, findByPropsLazy, proxyLazyWebpack } from "@webpack";
import { Alerts, ChannelStore, DraftType, EmojiStore, FluxDispatcher, GuildMemberStore, lodash, OverridePremiumTypeStore, Parser, PermissionsBits, PermissionStore, StickersStore, UploadHandler, UserSettingsActionCreators, UserSettingsProtoStore } from "@webpack/common";
import { Alerts, ChannelStore, DraftType, EmojiStore, FluxDispatcher, Forms, GuildMemberStore, IconUtils, lodash, OverridePremiumTypeStore, Parser, PermissionsBits, PermissionStore, StickersStore, UploadHandler, UserSettingsActionCreators, UserSettingsProtoStore, UserStore } from "@webpack/common";
import { applyPalette, GIFEncoder, quantize } from "gifenc";
import type { ReactElement, ReactNode } from "react";
@ -883,7 +883,7 @@ export default definePlugin({
const emojiString = `<${emoji.animated ? "a" : ""}:${emoji.originalName || emoji.name}:${emoji.id}>`;
const url = new URL(getEmojiURL(emoji.id, emoji.animated, s.emojiSize));
const url = new URL(IconUtils.getEmojiURL({ id: emoji.id, animated: emoji.animated, size: s.emojiSize }));
url.searchParams.set("size", s.emojiSize.toString());
url.searchParams.set("name", emoji.name);
if (emoji.animated) {
@ -919,7 +919,7 @@ export default definePlugin({
hasBypass = true;
const url = new URL(getEmojiURL(emoji.id, emoji.animated, s.emojiSize));
const url = new URL(IconUtils.getEmojiURL({ id: emoji.id, animated: emoji.animated, size: s.emojiSize }));
url.searchParams.set("size", s.emojiSize.toString());
url.searchParams.set("name", emoji.name);

View file

@ -25,7 +25,7 @@ import { useForceUpdater } from "@utils/react";
import definePlugin, { OptionType } from "@utils/types";
import { CustomEmoji, Message, ReactionEmoji, User } from "@vencord/discord-types";
import { findByPropsLazy } from "@webpack";
import { ChannelStore, Constants, FluxDispatcher, React, RestAPI, useEffect, useLayoutEffect, UserSummaryItem } from "@webpack/common";
import { ChannelStore, Constants, FluxDispatcher, React, RestAPI, useEffect, useLayoutEffect, UserStore, UserSummaryItem } from "@webpack/common";
const AvatarStyles = findByPropsLazy("moreUsers", "emptyUser", "avatarContainer", "clickableAvatar");
let Scroll: any = null;
@ -150,7 +150,7 @@ export default definePlugin({
}, [message.id, forceUpdate]);
const reactions = getReactionsWithQueue(message, emoji, type);
const users = [...reactions.values()].filter(Boolean);
const users = Array.from(reactions, ([id]) => UserStore.getUser(id)).filter(Boolean);
return (
<div

View file

@ -18,7 +18,7 @@
import type { MessageObject } from "@api/MessageEvents";
import type { Channel, CloudUpload, Guild, GuildFeatures, Message, User } from "@vencord/discord-types";
import { ChannelActionCreators, ChannelStore, ComponentDispatch, Constants, FluxDispatcher, GuildStore, i18n, IconUtils, InviteActions, MessageActions, RestAPI, SelectedChannelStore, SelectedGuildStore, Toasts, UserProfileActions, UserProfileStore, UserSettingsActionCreators, UserUtils } from "@webpack/common";
import { ChannelActionCreators, ChannelStore, ComponentDispatch, Constants, FluxDispatcher, GuildStore, i18n, InviteActions, MessageActions, RestAPI, SelectedChannelStore, SelectedGuildStore, Toasts, UserProfileActions, UserProfileStore, UserSettingsActionCreators, UserUtils } from "@webpack/common";
import { Except } from "type-fest";
import { copyToClipboard } from "./clipboard";
@ -236,17 +236,6 @@ export function getUniqueUsername(user: User) {
return user.discriminator === "0" ? user.username : user.tag;
}
/**
* Get the URL for an emoji. This function always returns a gif URL for animated emojis, instead of webp
* @param id The emoji id
* @param animated Whether the emoji is animated
* @param size The size for the emoji
*/
export function getEmojiURL(id: string, animated: boolean, size: number) {
const url = IconUtils.getEmojiURL({ id, animated, size });
return animated ? url.replace(".webp", ".gif") : url;
}
// Discord has a similar function in their code
export function getGuildAcronym(guild: Guild): string {
return guild.name

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;
export let ApplicationCommandIndexStore: GenericStore;
/**
@ -129,6 +130,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("ApplicationCommandIndexStore", m => ApplicationCommandIndexStore = m);
waitForStore("ThemeStore", m => {
ThemeStore = m;