Merge branch 'dev' into bun

This commit is contained in:
thororen1234 2025-06-09 22:12:04 -04:00
commit b415877b2f
No known key found for this signature in database
73 changed files with 1365 additions and 642 deletions

View file

@ -6,12 +6,12 @@
Equicord is a fork of [Vencord](https://github.com/Vendicated/Vencord), with over 300+ plugins.
You can join our [discord server](https://discord.gg/5Xh2W87egW) for commits, changes, chat or even support.<br><br></br>
You can join our [discord server](https://discord.gg/5Xh2W87egW) for commits, changes, chat or even support.
### Extra included plugins
<details>
<summary>183 additional plugins</summary>
<summary>186 additional plugins</summary>
### All Platforms
@ -20,6 +20,7 @@ You can join our [discord server](https://discord.gg/5Xh2W87egW) for commits, ch
- AlwaysExpandProfile by thororen
- AmITyping by MrDiamond
- Anammox by Kyuuhachi
- AudiobookShelfRPC by vMohammad
- AtSomeone by Joona
- BannersEverywhere by ImLvna & AutumnVN
- BetterActivities by D3SOX, Arjix, AutumnVN
@ -95,6 +96,7 @@ You can join our [discord server](https://discord.gg/5Xh2W87egW) for commits, ch
- InRole by nin0dev
- InstantScreenshare by HAHALOSAH & thororen
- IRememberYou by zoodogood
- JellyfinRichPresence by vMohammad
- Jumpscare by Surgedevs
- JumpToStart by Samwich
- KeyboardSounds by HypedDomi
@ -160,6 +162,7 @@ You can join our [discord server](https://discord.gg/5Xh2W87egW) for commits, ch
- StatusPresets by iamme
- SteamStatusSync by niko
- StickerBlocker by Samwich
- StreamingCodecDisabler by davidkra230
- TalkInReverse by Tolgchu
- TeX by Kyuuhachi
- TextToSpeech by Samwich

View file

@ -20,15 +20,12 @@
/// <reference path="../src/globals.d.ts" />
import monacoHtmlLocal from "file://monacoWin.html?minify";
import monacoHtmlCdn from "file://../src/main/monacoWin.html?minify";
import * as DataStore from "../src/api/DataStore";
import { debounce } from "../src/utils";
import { debounce, localStorage } from "../src/utils";
import { EXTENSION_BASE_URL } from "../src/utils/web-metadata";
import { getTheme, Theme } from "../src/utils/discord";
import { Settings } from "../src/Vencord";
// Discord deletes this so need to store in variable
const { localStorage } = window;
import { getStylusWebStoreUrl } from "@utils/web";
// listeners for ipc.on
const cssListeners = new Set<(css: string) => void>();
@ -76,6 +73,14 @@ window.VencordNative = {
addThemeChangeListener: NOOP,
openFile: NOOP_ASYNC,
async openEditor() {
if (IS_USERSCRIPT) {
const shouldOpenWebStore = confirm("QuickCSS is not supported on the Userscript. You can instead use the Stylus extension.\n\nDo you want to open the Stylus web store page?");
if (shouldOpenWebStore) {
window.open(getStylusWebStoreUrl(), "_blank");
}
return;
}
const features = `popup,width=${Math.min(window.innerWidth, 1000)},height=${Math.min(window.innerHeight, 1000)}`;
const win = open("about:blank", "VencordQuickCss", features);
if (!win) {
@ -91,7 +96,7 @@ window.VencordNative = {
? "vs-light"
: "vs-dark";
win.document.write(IS_EXTENSION ? monacoHtmlLocal : monacoHtmlCdn);
win.document.write(monacoHtmlLocal);
},
},

View file

@ -1,7 +1,7 @@
{
"name": "equicord",
"private": "true",
"version": "1.12.2",
"version": "1.12.4",
"description": "The other cutest Discord client mod",
"homepage": "https://github.com/Equicord/Equicord#readme",
"bugs": {
@ -92,7 +92,9 @@
"zustand": "^3.7.2"
},
"packageManager": "bun@1.1.0",
"trustedDependencies": ["esbuild"],
"trustedDependencies": [
"esbuild"
],
"engines": {
"node": ">=18",
"bun": ">=1.0.0"

View file

@ -34,6 +34,7 @@ const defines = stringifyValues({
IS_UPDATER_DISABLED,
IS_WEB: false,
IS_EXTENSION: false,
IS_USERSCRIPT: false,
VERSION,
BUILD_TIMESTAMP
});

View file

@ -43,6 +43,7 @@ const commonOptions = {
define: stringifyValues({
IS_WEB: true,
IS_EXTENSION: false,
IS_USERSCRIPT: false,
IS_STANDALONE: true,
IS_DEV,
IS_REPORTER,
@ -108,6 +109,7 @@ const buildConfigs = [
inject: ["browser/GMPolyfill.js", ...(commonOptions?.inject || [])],
define: {
...commonOptions.define,
IS_USERSCRIPT: "true",
window: "unsafeWindow",
},
outfile: "dist/Vencord.user.js",

View file

@ -146,7 +146,7 @@ export const globPlugins = kind => ({
});
build.onLoad({ filter, namespace: "import-plugins" }, async () => {
const pluginDirs = ["plugins/_api", "plugins/_core", "plugins", "userplugins", "equicordplugins", "equicordplugins/_core"];
const pluginDirs = ["plugins/_api", "plugins/_core", "plugins", "userplugins", "equicordplugins"];
let code = "";
let pluginsCode = "\n";
let metaCode = "\n";

View file

@ -264,7 +264,7 @@ function isPluginFile({ name }: { name: string; }) {
const plugins = [] as PluginData[];
await Promise.all(["src/plugins", "src/plugins/_core", "src/equicordplugins", "src/equicordplugins/_core"].flatMap(dir =>
await Promise.all(["src/plugins", "src/plugins/_core", "src/equicordplugins"].flatMap(dir =>
readdirSync(dir, { withFileTypes: true })
.filter(isPluginFile)
.map(async dirent => {

View file

@ -153,8 +153,12 @@ async function init() {
if (!IS_WEB && !IS_UPDATER_DISABLED) {
runUpdateCheck();
// this tends to get really annoying, so only do this if the user has auto-update without notification enabled
if (Settings.autoUpdate && !Settings.autoUpdateNotification) {
setInterval(runUpdateCheck, 1000 * 60 * 30); // 30 minutes
}
}
if (IS_DEV) {
const pendingPatches = patches.filter(p => !p.all && p.predicate?.() !== false);

View file

@ -48,7 +48,7 @@ export function _modifyAccessories(
) {
for (const [key, accessory] of accessories.entries()) {
const res = (
<ErrorBoundary message={`Failed to render ${key} Message Accessory`} key={key}>
<ErrorBoundary noop message={`Failed to render ${key} Message Accessory`} key={key}>
<accessory.render {...props} />
</ErrorBoundary>
);

View file

@ -11,6 +11,10 @@
width: 100%;
}
.visual-refresh .vc-notification-root {
background-color: var(--background-base-low);
}
.vc-notification-root:not(.vc-notification-log-wrapper > .vc-notification-root) {
position: absolute;
z-index: 2147483647;

View file

@ -75,10 +75,15 @@ const ErrorBoundary = LazyComponent(() => {
logger.error(`${this.props.message || "A component threw an Error"}\n`, error, errorInfo.componentStack);
}
get isNoop() {
if (IS_DEV) return false;
return this.props.noop;
}
render() {
if (this.state.error === NO_ERROR) return this.props.children;
if (this.props.noop) return null;
if (this.isNoop) return null;
if (this.props.fallback)
return (

View file

@ -4,4 +4,8 @@
border: 1px solid #e78284;
border-radius: 5px;
color: var(--text-normal, white);
& a:hover {
text-decoration: underline;
}
}

View file

@ -28,6 +28,9 @@ export function Link(props: React.PropsWithChildren<Props>) {
props.style.pointerEvents = "none";
props["aria-disabled"] = true;
}
props.rel ??= "noreferrer";
return (
<a role="link" target="_blank" {...props}>
{props.children}

View file

@ -244,7 +244,7 @@ export default function PluginSettings() {
}));
}, []);
const depMap = React.useMemo(() => {
const depMap = useMemo(() => {
const o = {} as Record<string, string[]>;
for (const plugin in Plugins) {
const deps = Plugins[plugin].dependencies;

View file

@ -34,6 +34,7 @@ import { useAwaiter } from "@utils/react";
import type { ThemeHeader } from "@utils/themes";
import { getThemeInfo, stripBOM, type UserThemeHeader } from "@utils/themes/bd";
import { usercssParse } from "@utils/themes/usercss";
import { getStylusWebStoreUrl } from "@utils/web";
import { findLazy } from "@webpack";
import { Button, Card, Forms, React, showToast, TabBar, TextInput, Tooltip, useEffect, useMemo, useRef, useState } from "@webpack/common";
import type { ComponentType, Ref, SyntheticEvent } from "react";
@ -502,4 +503,20 @@ function ThemesTab() {
);
}
export default wrapTab(ThemesTab, "Themes");
function UserscriptThemesTab() {
return (
<SettingsTab title="Themes">
<Card className="vc-settings-card">
<Forms.FormTitle tag="h5">Themes are not supported on the Userscript!</Forms.FormTitle>
<Forms.FormText>
You can instead install themes with the <Link href={getStylusWebStoreUrl()}>Stylus extension</Link>!
</Forms.FormText>
</Card>
</SettingsTab>
);
}
export default IS_USERSCRIPT
? wrapTab(UserscriptThemesTab, "Themes")
: wrapTab(ThemesTab, "Themes");

View file

@ -0,0 +1,250 @@
/*
* Vencord, a Discord client mod
* Copyright (c) 2025 Vendicated and contributors
* SPDX-License-Identifier: GPL-3.0-or-later
*/
// alot of the code is from JellyfinRPC
import { definePluginSettings } from "@api/Settings";
import { EquicordDevs } from "@utils/constants";
import { Logger } from "@utils/Logger";
import definePlugin, { OptionType } from "@utils/types";
import { ApplicationAssetUtils, FluxDispatcher, Forms, showToast } from "@webpack/common";
interface ActivityAssets {
large_image?: string;
large_text?: string;
small_image?: string;
small_text?: string;
}
interface Activity {
state: string;
details?: string;
timestamps?: {
start?: number;
};
assets?: ActivityAssets;
name: string;
application_id: string;
metadata?: {
button_urls?: Array<string>;
};
type: number;
flags: number;
}
interface MediaData {
name: string;
type: string;
author?: string;
series?: string;
duration?: number;
currentTime?: number;
progress?: number;
url?: string;
imageUrl?: string;
isFinished?: boolean;
}
const settings = definePluginSettings({
serverUrl: {
description: "AudioBookShelf server URL (e.g., https://abs.example.com)",
type: OptionType.STRING,
},
username: {
description: "AudioBookShelf username",
type: OptionType.STRING,
},
password: {
description: "AudioBookShelf password",
type: OptionType.STRING,
},
});
const applicationId = "1381423044907503636";
const logger = new Logger("AudioBookShelfRichPresence");
let authToken: string | null = null;
async function getApplicationAsset(key: string): Promise<string> {
return (await ApplicationAssetUtils.fetchAssetIds(applicationId, [key]))[0];
}
function setActivity(activity: Activity | null) {
FluxDispatcher.dispatch({
type: "LOCAL_ACTIVITY_UPDATE",
activity,
socketId: "ABSRPC",
});
}
export default definePlugin({
name: "AudioBookShelfRichPresence",
description: "Rich presence for AudioBookShelf media server",
authors: [EquicordDevs.vmohammad],
settingsAboutComponent: () => (
<>
<Forms.FormTitle tag="h3">How to connect to AudioBookShelf</Forms.FormTitle>
<Forms.FormText>
Enter your AudioBookShelf server URL, username, and password to display your currently playing audiobooks as Discord Rich Presence.
<br /><br />
The plugin will automatically authenticate and fetch your listening progress.
</Forms.FormText>
</>
),
settings,
start() {
this.updatePresence();
this.updateInterval = setInterval(() => { this.updatePresence(); }, 10000);
},
stop() {
clearInterval(this.updateInterval);
},
async authenticate(): Promise<boolean> {
if (!settings.store.serverUrl || !settings.store.username || !settings.store.password) {
logger.warn("AudioBookShelf server URL, username, or password is not set in settings.");
showToast("AudioBookShelf RPC is not configured.", "failure", {
duration: 15000,
});
return false;
}
try {
const baseUrl = settings.store.serverUrl.replace(/\/$/, "");
const url = `${baseUrl}/login`;
const res = await fetch(url, {
method: "POST",
headers: {
"Content-Type": "application/json",
},
body: JSON.stringify({
username: settings.store.username,
password: settings.store.password,
}),
});
if (!res.ok) throw `${res.status} ${res.statusText}`;
const data = await res.json();
authToken = data.user?.token;
return !!authToken;
} catch (e) {
logger.error("Failed to authenticate with AudioBookShelf", e);
authToken = null;
return false;
}
},
async fetchMediaData(): Promise<MediaData | null> {
if (!authToken && !(await this.authenticate())) {
return null;
}
const isPlayingNow = session => {
const now = Date.now();
const lastUpdate = session.updatedAt;
const diffSeconds = (now - lastUpdate) / 1000;
return diffSeconds <= 30;
};
try {
const baseUrl = settings.store.serverUrl!.replace(/\/$/, "");
const url = `${baseUrl}/api/me/listening-sessions`;
const res = await fetch(url, {
headers: {
"Authorization": `Bearer ${authToken}`,
},
});
if (!res.ok) {
if (res.status === 401) {
authToken = null;
if (await this.authenticate()) {
return this.fetchMediaData();
}
}
throw `${res.status} ${res.statusText}`;
}
const { sessions } = await res.json();
const activeSession = sessions.find((session: any) =>
session.updatedAt && !session.isFinished
);
if (!activeSession || !isPlayingNow(activeSession)) return null;
const { mediaMetadata: media, mediaType, duration, currentTime, libraryItemId } = activeSession;
if (!media) return null;
console.log(media);
return {
name: media.title || "Unknown",
type: mediaType || "book",
author: media.author || media.publisher,
series: media.series[0]?.name,
duration,
currentTime,
imageUrl: libraryItemId ? `${baseUrl}/api/items/${libraryItemId}/cover` : undefined,
isFinished: activeSession.isFinished || false,
};
} catch (e) {
logger.error("Failed to query AudioBookShelf API", e);
return null;
}
},
async updatePresence() {
setActivity(await this.getActivity());
},
async getActivity(): Promise<Activity | null> {
const mediaData = await this.fetchMediaData();
if (!mediaData || mediaData.isFinished) return null;
const largeImage = mediaData.imageUrl;
console.log("Large Image URL:", largeImage);
const assets: ActivityAssets = {
large_image: largeImage ? await getApplicationAsset(largeImage) : await getApplicationAsset("audiobookshelf"),
large_text: mediaData.series || mediaData.author || undefined,
};
const getDetails = () => {
return mediaData.name;
};
const getState = () => {
if (mediaData.series && mediaData.author) {
return `${mediaData.series}${mediaData.author}`;
}
return mediaData.author || "AudioBook";
};
const timestamps = mediaData.currentTime && mediaData.duration ? {
start: Date.now() - (mediaData.currentTime * 1000),
end: Date.now() + ((mediaData.duration - mediaData.currentTime) * 1000)
} : undefined;
return {
application_id: applicationId,
name: "AudioBookShelf",
details: getDetails(),
state: getState(),
assets,
timestamps,
type: 2,
flags: 1,
};
}
});

View file

@ -1,11 +0,0 @@
/*
* Vencord, a Discord client mod
* Copyright (c) 2024 Vendicated and contributors
* SPDX-License-Identifier: GPL-3.0-or-later
*/
import type { SVGProps } from "react";
export function SpotifyIcon(props: SVGProps<SVGSVGElement>) {
return (<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 256 256" {...props}><path fill="#1ed760" d="M128 0C57.308 0 0 57.309 0 128c0 70.696 57.309 128 128 128c70.697 0 128-57.304 128-128C256 57.314 198.697.007 127.998.007zm58.699 184.614c-2.293 3.76-7.215 4.952-10.975 2.644c-30.053-18.357-67.885-22.515-112.44-12.335a7.981 7.981 0 0 1-9.552-6.007a7.968 7.968 0 0 1 6-9.553c48.76-11.14 90.583-6.344 124.323 14.276c3.76 2.308 4.952 7.215 2.644 10.975m15.667-34.853c-2.89 4.695-9.034 6.178-13.726 3.289c-34.406-21.148-86.853-27.273-127.548-14.92c-5.278 1.594-10.852-1.38-12.454-6.649c-1.59-5.278 1.386-10.842 6.655-12.446c46.485-14.106 104.275-7.273 143.787 17.007c4.692 2.89 6.175 9.034 3.286 13.72zm1.345-36.293C162.457 88.964 94.394 86.71 55.007 98.666c-6.325 1.918-13.014-1.653-14.93-7.978c-1.917-6.328 1.65-13.012 7.98-14.935C93.27 62.027 168.434 64.68 215.929 92.876c5.702 3.376 7.566 10.724 4.188 16.405c-3.362 5.69-10.73 7.565-16.4 4.187z"></path></svg>);
}

View file

@ -35,16 +35,17 @@ export default definePlugin({
patches: [
{
// Patch activity icons
find: "isBlockedOrIgnored(null",
find: '"ActivityStatus"',
replacement: {
match: /(?<=hideTooltip:.{0,4}}=(\i).*?{}\))\]/,
replace: ",$self.patchActivityList($1)]"
},
predicate: () => settings.store.memberList,
all: true
},
{
// Show all activities in the user popout/sidebar
find: "hasAvatarForGuild(null",
find: '"UserProfilePopoutBody"',
replacement: {
match: /(?<=(\i)\.id\)\}\)\),(\i).*?)\(0,.{0,100}\i\.id,onClose:\i\}\)/,
replace: "$self.showAllActivitiesComponent({ activity: $2, user: $1 })"

View file

@ -10,14 +10,13 @@ import { React, Tooltip } from "@webpack/common";
import { JSX } from "react";
import { ActivityTooltip } from "../components/ActivityTooltip";
import { SpotifyIcon } from "../components/SpotifyIcon";
import { TwitchIcon } from "../components/TwitchIcon";
import { settings } from "../settings";
import { ActivityListIcon, ActivityListProps, ApplicationIcon, IconCSSProperties } from "../types";
import { cl, getApplicationIcons } from "../utils";
// if discord one day decides to change their icon this needs to be updated
const DefaultActivityIcon = findComponentByCodeLazy("M6,7 L2,7 L2,6 L6,6 L6,7 Z M8,5 L2,5 L2,4 L8,4 L8,5 Z M8,3 L2,3 L2,2 L8,2 L8,3 Z M8.88888889,0 L1.11111111,0 C0.494444444,0 0,0.494444444 0,1.11111111 L0,8.88888889 C0,9.50253861 0.497461389,10 1.11111111,10 L8.88888889,10 C9.50253861,10 10,9.50253861 10,8.88888889 L10,1.11111111 C10,0.494444444 9.5,0 8.88888889,0 Z");
// Discord no longer shows an icon here by default but we use the one from the popout now here
const DefaultActivityIcon = findComponentByCodeLazy("M5 2a3 3 0 0 0-3 3v14a3 3 0 0 0 3 3h14a3 3 0 0 0 3-3V5a3 3 0 0 0-3-3H5Zm6.81 7c-.54 0-1 .26-1.23.61A1 1 0 0 1 8.92 8.5 3.49 3.49 0 0 1 11.82 7c1.81 0 3.43 1.38 3.43 3.25 0 1.45-.98 2.61-2.27 3.06a1 1 0 0 1-1.96.37l-.19-1a1 1 0 0 1 .98-1.18c.87 0 1.44-.63 1.44-1.25S12.68 9 11.81 9ZM13 16a1 1 0 1 1-2 0 1 1 0 0 1 2 0Zm7-10.5a1.5 1.5 0 1 1-3 0 1.5 1.5 0 0 1 3 0ZM18.5 20a1.5 1.5 0 1 0 0-3 1.5 1.5 0 0 0 0 3ZM7 18.5a1.5 1.5 0 1 1-3 0 1.5 1.5 0 0 1 3 0ZM5.5 7a1.5 1.5 0 1 0 0-3 1.5 1.5 0 0 0 0 3Z");
export function patchActivityList({ activities, user, hideTooltip }: ActivityListProps): JSX.Element | null {
const icons: ActivityListIcon[] = [];
@ -57,7 +56,6 @@ export function patchActivityList({ activities, user, hideTooltip }: ActivityLis
}
};
addActivityIcon("Twitch", TwitchIcon);
addActivityIcon("Spotify", SpotifyIcon);
if (icons.length) {
const iconStyle: IconCSSProperties = {
@ -86,7 +84,7 @@ export function patchActivityList({ activities, user, hideTooltip }: ActivityLis
// We need to filter out custom statuses
const shouldShow = activities.filter(a => a.type !== 4).length !== icons.length;
if (shouldShow) {
return <DefaultActivityIcon />;
return <DefaultActivityIcon size="xs" />;
}
}

View file

@ -39,11 +39,11 @@ export function getActivityApplication(activity: Activity | null) {
export function getApplicationIcons(activities: Activity[], preferSmall = false): ApplicationIcon[] {
const applicationIcons: ApplicationIcon[] = [];
const applications = activities.filter(activity => activity.application_id || activity.platform);
const applications = activities.filter(activity => activity.application_id || activity.platform || activity?.id?.startsWith("spotify:"));
for (const activity of applications) {
const { assets, application_id, platform } = activity;
if (!application_id && !platform) continue;
const { assets, application_id, platform, id } = activity;
if (!application_id && !platform && !id.startsWith("spotify:")) continue;
if (assets) {
const { small_image, small_text, large_image, large_text } = assets;
@ -59,6 +59,12 @@ export function getApplicationIcons(activities: Activity[], preferSmall = false)
activity
});
}
} else if (image.startsWith("spotify:")) {
const url = `https://i.scdn.co/image/${image.split(":")[1]}`;
applicationIcons.push({
image: { src: url, alt },
activity
});
} else {
const src = `https://cdn.discordapp.com/app-assets/${application_id}/${image}.png`;
applicationIcons.push({

View file

@ -9,7 +9,7 @@ import definePlugin from "@utils/types";
export default definePlugin({
name: "BypassPinPrompt",
description: "Bypass the pin prompt when pinning messages",
description: "Bypass the pin prompt when using the pin functions",
authors: [EquicordDevs.thororen],
patches: [
{

View file

@ -0,0 +1,9 @@
/*
* Vencord, a Discord client mod
* Copyright (c) 2025 Vendicated and contributors
* SPDX-License-Identifier: GPL-3.0-or-later
*/
import { CspPolicies, MediaScriptsAndCssSrc } from "@main/csp";
CspPolicies["*"] = MediaScriptsAndCssSrc;

View file

@ -147,7 +147,7 @@ function VencordPopoutButton() {
function ToolboxFragmentWrapper({ children }: { children: ReactNode[]; }) {
children.splice(
children.length - 1, 0,
<ErrorBoundary noop={true}>
<ErrorBoundary noop>
<VencordPopoutButton />
</ErrorBoundary>
);

View file

@ -24,8 +24,6 @@ export const reverseExtensionMap = Object.entries(extensionMap).reduce((acc, [ta
return acc;
}, {} as Record<string, string>);
type ExtUpload = Upload & { fixExtension?: boolean; };
export default definePlugin({
name: "FixFileExtensions",
authors: [EquicordDevs.thororen],
@ -34,15 +32,21 @@ export default definePlugin({
patches: [
// Taken from AnonymiseFileNames
{
find: 'type:"UPLOAD_START"',
replacement: {
match: /await \i\.uploadFiles\((\i),/,
replace: "$1.forEach($self.fixExt),$&"
find: "async uploadFiles(",
replacement: [
{
match: /async uploadFiles\((\i),\i\){/,
replace: "$&$1.forEach($self.fixExt);"
},
{
match: /async uploadFilesSimple\((\i)\){/,
replace: "$&$1.forEach($self.fixExt);"
}
],
predicate: () => !Settings.plugins.AnonymiseFileNames.enabled,
},
],
fixExt(upload: ExtUpload) {
fixExt(upload: Upload) {
const file = upload.filename;
const tarMatch = tarExtMatcher.exec(file);
const extIdx = tarMatch?.index ?? file.lastIndexOf(".");

View file

@ -24,7 +24,7 @@ export default async (
}: FurudoSettings,
repliedMessage?: Message
): Promise<string> => {
const completion = await fetch("http://localhost:11434/api/chat", {
const completion = await fetch("http://127.0.0.1:11434/api/chat", {
method: "POST",
headers: {
"Content-Type": "application/json",

View file

@ -0,0 +1,215 @@
/*
* Vencord, a Discord client mod
* Copyright (c) 2025 Vendicated and contributors
* SPDX-License-Identifier: GPL-3.0-or-later
*/
// alot of the code is from LastFMRichPresence
import { definePluginSettings } from "@api/Settings";
import { EquicordDevs } from "@utils/constants";
import { Logger } from "@utils/Logger";
import definePlugin, { OptionType } from "@utils/types";
import { ApplicationAssetUtils, FluxDispatcher, Forms, showToast } from "@webpack/common";
interface ActivityAssets {
large_image?: string;
large_text?: string;
small_image?: string;
small_text?: string;
}
interface Activity {
state: string;
details?: string;
timestamps?: {
start?: number;
};
assets?: ActivityAssets;
name: string;
application_id: string;
metadata?: {
button_urls?: Array<string>;
};
type: number;
flags: number;
}
interface MediaData {
name: string;
type: string;
artist?: string;
album?: string;
seriesName?: string;
seasonNumber?: number;
episodeNumber?: number;
year?: number;
url?: string;
imageUrl?: string;
duration?: number;
position?: number;
}
const settings = definePluginSettings({
serverUrl: {
description: "Jellyfin server URL (e.g., https://jellyfin.example.com)",
type: OptionType.STRING,
},
apiKey: {
description: "Jellyfin API key obtained from your Jellyfin administration dashboard",
type: OptionType.STRING,
},
userId: {
description: "Jellyfin user ID obtained from your user profile URL",
type: OptionType.STRING,
},
});
const applicationId = "1381368130164625469";
const logger = new Logger("JellyfinRichPresence");
async function getApplicationAsset(key: string): Promise<string> {
return (await ApplicationAssetUtils.fetchAssetIds(applicationId, [key]))[0];
}
function setActivity(activity: Activity | null) {
FluxDispatcher.dispatch({
type: "LOCAL_ACTIVITY_UPDATE",
activity,
socketId: "Jellyfin",
});
}
export default definePlugin({
name: "JellyfinRichPresence",
description: "Rich presence for Jellyfin media server",
authors: [EquicordDevs.vmohammad],
settingsAboutComponent: () => (
<>
<Forms.FormTitle tag="h3">How to get an API key</Forms.FormTitle>
<Forms.FormText>
An API key is required to fetch your current media. To get one, go to your
Jellyfin dashboard, navigate to Administration {">"} API Keys and
create a new API key. <br /> <br />
You'll also need your User ID, which can be found in the url of your user profile page.
</Forms.FormText>
</>
),
settings,
start() {
this.updatePresence();
this.updateInterval = setInterval(() => { this.updatePresence(); }, 10000);
},
stop() {
clearInterval(this.updateInterval);
},
async fetchMediaData(): Promise<MediaData | null> {
if (!settings.store.serverUrl || !settings.store.apiKey || !settings.store.userId) {
logger.warn("Jellyfin server URL, API key, or user ID is not set in settings.");
showToast("JellyfinRPC is not configured.", "failure", {
duration: 15000,
});
return null;
}
try {
const baseUrl = settings.store.serverUrl.replace(/\/$/, "");
const url = `${baseUrl}/Sessions?api_key=${settings.store.apiKey}`;
const res = await fetch(url);
if (!res.ok) throw `${res.status} ${res.statusText}`;
const sessions = await res.json();
const userSession = sessions.find((session: any) =>
session.UserId === settings.store.userId && session.NowPlayingItem
);
if (!userSession || !userSession.NowPlayingItem) return null;
const item = userSession.NowPlayingItem;
const playState = userSession.PlayState;
if (playState?.IsPaused) return null;
const imageUrl = item.ImageTags?.Primary
? `${baseUrl}/Items/${item.Id}/Images/Primary`
: undefined;
return {
name: item.Name || "Unknown",
type: item.Type,
artist: item.Artists?.[0] || item.AlbumArtist,
album: item.Album,
seriesName: item.SeriesName,
seasonNumber: item.ParentIndexNumber,
episodeNumber: item.IndexNumber,
year: item.ProductionYear,
url: `${baseUrl}/web/#!/details?id=${item.Id}`,
imageUrl,
duration: item.RunTimeTicks ? Math.floor(item.RunTimeTicks / 10000000) : undefined,
position: playState?.PositionTicks ? Math.floor(playState.PositionTicks / 10000000) : undefined
};
} catch (e) {
logger.error("Failed to query Jellyfin API", e);
return null;
}
},
async updatePresence() {
setActivity(await this.getActivity());
},
async getActivity(): Promise<Activity | null> {
const mediaData = await this.fetchMediaData();
if (!mediaData) return null;
const largeImage = mediaData.imageUrl;
const assets: ActivityAssets = {
large_image: largeImage ? await getApplicationAsset(largeImage) : await getApplicationAsset("jellyfin"),
large_text: mediaData.album || mediaData.seriesName || undefined,
};
const getDetails = () => {
if (mediaData.type === "Episode" && mediaData.seriesName) {
return mediaData.name;
}
return mediaData.name;
};
const getState = () => {
if (mediaData.type === "Episode" && mediaData.seriesName) {
const season = mediaData.seasonNumber ? `S${mediaData.seasonNumber}` : "";
const episode = mediaData.episodeNumber ? `E${mediaData.episodeNumber}` : "";
return `${mediaData.seriesName} ${season}${episode}`.trim();
}
return mediaData.artist || (mediaData.year ? `(${mediaData.year})` : undefined);
};
const timestamps = mediaData.position && mediaData.duration ? {
start: Date.now() - (mediaData.position * 1000),
end: Date.now() + ((mediaData.duration - mediaData.position) * 1000)
} : undefined;
return {
application_id: applicationId,
name: "Jellyfin",
details: getDetails(),
state: getState() || "something",
assets,
timestamps,
type: 3,
flags: 1,
};
}
});

View file

@ -51,40 +51,40 @@ async function openCompleteQuestUI() {
const applicationId = quest.config.application.id;
const applicationName = quest.config.application.name;
const taskName = ["WATCH_VIDEO", "PLAY_ON_DESKTOP", "STREAM_ON_DESKTOP", "PLAY_ACTIVITY"].find(x => quest.config.taskConfig.tasks[x] != null);
const icon = `https://cdn.discordapp.com/quests/${quest.id}/${theme}/${quest.config.assets.gameTile}`;
// @ts-ignore
const secondsNeeded = quest.config.taskConfig.tasks[taskName].target;
// @ts-ignore
const secondsDone = quest.userStatus?.progress?.[taskName]?.value ?? 0;
const icon = `https://cdn.discordapp.com/assets/quests/${quest.id}/${theme}/${quest.config.assets.gameTile}`;
let secondsDone = quest.userStatus?.progress?.[taskName]?.value ?? 0;
if (taskName === "WATCH_VIDEO") {
const tolerance = 2, speed = 10;
const diff = Math.floor((Date.now() - new Date(quest.userStatus.enrolledAt).getTime()) / 1000);
const startingPoint = Math.min(Math.max(Math.ceil(secondsDone), diff), secondsNeeded);
const maxFuture = 10, speed = 7, interval = 1;
const enrolledAt = new Date(quest.userStatus.enrolledAt).getTime();
const fn = async () => {
for (let i = startingPoint; i <= secondsNeeded; i += speed) {
try {
await RestAPI.post({ url: `/quests/${quest.id}/video-progress`, body: { timestamp: Math.min(secondsNeeded, i + Math.random()) } });
} catch (ex) {
console.log("Failed to send increment of", i, ex);
while (true) {
const maxAllowed = Math.floor((Date.now() - enrolledAt) / 1000) + maxFuture;
const diff = maxAllowed - secondsDone;
const timestamp = secondsDone + speed;
if (diff >= speed) {
await RestAPI.post({ url: `/quests/${quest.id}/video-progress`, body: { timestamp: Math.min(secondsNeeded, timestamp + Math.random()) } });
secondsDone = Math.min(secondsNeeded, timestamp);
}
await new Promise(resolve => setTimeout(resolve, tolerance * 1000));
if (timestamp >= secondsNeeded) {
break;
}
await new Promise(resolve => setTimeout(resolve, interval * 1000));
}
if ((secondsNeeded - secondsDone) % speed !== 0) {
await RestAPI.post({ url: `/quests/${quest.id}/video-progress`, body: { timestamp: secondsNeeded } });
showNotification({
title: `${applicationName} - Quest Completer`,
body: "Quest Completed.",
icon: icon,
});
}
};
fn();
showNotification({
title: `${applicationName} - Quest Completer`,
body: `Wait for ${Math.ceil((secondsNeeded - startingPoint) / speed * tolerance)} more seconds.`,
body: `Spoofing video for ${applicationName}.`,
icon: icon,
});
console.log(`Spoofing video for ${applicationName}.`);
} else if (taskName === "PLAY_ON_DESKTOP") {
RestAPI.get({ url: `/applications/public?application_ids=${applicationId}` }).then(res => {
const appData = res.body[0];

View file

@ -0,0 +1,81 @@
/*
* Vencord, a Discord client mod
* Copyright (c) 2025 Vendicated and contributors
* SPDX-License-Identifier: GPL-3.0-or-later
*/
import { definePluginSettings, Settings } from "@api/Settings";
import { EquicordDevs } from "@utils/constants";
import definePlugin, { OptionType } from "@utils/types";
import { findStoreLazy } from "@webpack";
let mediaEngine = findStoreLazy("MediaEngineStore");
const originalCodecStatuses: {
AV1: boolean,
H265: boolean,
H264: boolean;
} = {
AV1: true,
H265: true,
H264: true
};
const settings = definePluginSettings({
disableAv1Codec: {
description: "Make Discord not consider using AV1 for streaming.",
type: OptionType.BOOLEAN,
default: false
},
disableH265Codec: {
description: "Make Discord not consider using H265 for streaming.",
type: OptionType.BOOLEAN,
default: false
},
disableH264Codec: {
description: "Make Discord not consider using H264 for streaming.",
type: OptionType.BOOLEAN,
default: false
},
});
export default definePlugin({
name: "StreamingCodecDisabler",
description: "Disable codecs for streaming of your choice",
authors: [EquicordDevs.davidkra230],
settings,
patches: [
{
find: "setVideoBroadcast(this.shouldConnectionBroadcastVideo",
replacement: {
match: /setGoLiveSource\(.,.\)\{/,
replace: "$&$self.updateDisabledCodecs();"
},
}
],
async updateDisabledCodecs() {
mediaEngine.setAv1Enabled(originalCodecStatuses.AV1 && !Settings.plugins.StreamingCodecDisabler.disableAv1Codec);
mediaEngine.setH265Enabled(originalCodecStatuses.H265 && !Settings.plugins.StreamingCodecDisabler.disableH265Codec);
mediaEngine.setH264Enabled(originalCodecStatuses.H264 && !Settings.plugins.StreamingCodecDisabler.disableH264Codec);
},
async start() {
mediaEngine = mediaEngine.getMediaEngine();
const options = Object.keys(originalCodecStatuses);
// [{"codec":"","decode":false,"encode":false}]
const CodecCapabilities = JSON.parse(await new Promise(res => mediaEngine.getCodecCapabilities(res)));
CodecCapabilities.forEach((codec: { codec: string; encode: boolean; }) => {
if (options.includes(codec.codec)) {
originalCodecStatuses[codec.codec] = codec.encode;
}
});
},
async stop() {
mediaEngine.setAv1Enabled(originalCodecStatuses.AV1);
mediaEngine.setH265Enabled(originalCodecStatuses.H265);
mediaEngine.setH264Enabled(originalCodecStatuses.H264);
}
});

View file

@ -8,7 +8,6 @@ import { openModal } from "@utils/index";
import { OAuth2AuthorizeModal, showToast, Toasts } from "@webpack/common";
const databaseTimezones: Record<string, { value: string | null; }> = {};
const DOMAIN = "https://timezone.creations.works";
const REDIRECT_URI = `${DOMAIN}/auth/discord/callback`;
const CLIENT_ID = "1377021506810417173";
@ -26,7 +25,6 @@ export async function loadDatabaseTimezones(): Promise<boolean> {
const res = await fetch(`${DOMAIN}/list`, {
headers: { Accept: "application/json" }
});
if (res.ok) {
const json = await res.json();
for (const id in json) {
@ -34,10 +32,8 @@ export async function loadDatabaseTimezones(): Promise<boolean> {
value: json[id]?.timezone ?? null
};
}
return true;
}
return false;
} catch (e) {
console.error("Failed to fetch timezones list:", e);
@ -45,30 +41,93 @@ export async function loadDatabaseTimezones(): Promise<boolean> {
}
}
async function checkAuthentication(): Promise<boolean> {
try {
const res = await fetch(`${DOMAIN}/me`, {
credentials: "include",
headers: { Accept: "application/json" }
});
return res.ok;
} catch (e) {
console.error("Failed to check authentication:", e);
return false;
}
}
export async function setTimezone(timezone: string): Promise<boolean> {
const res = await fetch(`${DOMAIN}/set?timezone=${encodeURIComponent(timezone)}`, {
const isAuthenticated = await checkAuthentication();
if (!isAuthenticated) {
return new Promise(resolve => {
authModal(() => {
setTimezoneInternal(timezone).then(resolve);
});
});
}
return setTimezoneInternal(timezone);
}
async function setTimezoneInternal(timezone: string): Promise<boolean> {
const formData = new URLSearchParams();
formData.append("timezone", timezone);
try {
const res = await fetch(`${DOMAIN}/set`, {
method: "POST",
headers: {
"Content-Type": "application/json",
"Content-Type": "application/x-www-form-urlencoded",
Accept: "application/json"
},
credentials: "include"
credentials: "include",
body: formData
});
return res.ok;
if (!res.ok) {
const error = await res.json().catch(() => ({ message: "Unknown error" }));
showToast(error.message || "Failed to set timezone", Toasts.Type.FAILURE);
return false;
}
showToast("Timezone updated successfully!", Toasts.Type.SUCCESS);
return true;
} catch (e) {
console.error("Error setting timezone:", e);
showToast("Failed to set timezone", Toasts.Type.FAILURE);
return false;
}
}
export async function deleteTimezone(): Promise<boolean> {
const isAuthenticated = await checkAuthentication();
if (!isAuthenticated) {
showToast("You must be logged in to delete your timezone", Toasts.Type.FAILURE);
return false;
}
try {
const res = await fetch(`${DOMAIN}/delete`, {
method: "POST",
method: "DELETE",
headers: {
"Content-Type": "application/json",
Accept: "application/json"
},
credentials: "include"
});
return res.ok;
if (!res.ok) {
const error = await res.json().catch(() => ({ message: "Unknown error" }));
showToast(error.message || "Failed to delete timezone", Toasts.Type.FAILURE);
return false;
}
showToast("Timezone deleted successfully!", Toasts.Type.SUCCESS);
return true;
} catch (e) {
console.error("Error deleting timezone:", e);
showToast("Failed to delete timezone", Toasts.Type.FAILURE);
return false;
}
}
export function authModal(callback?: () => void) {
@ -83,21 +142,17 @@ export function authModal(callback?: () => void) {
cancelCompletesFlow={false}
callback={async (res: any) => {
if (!res || !res.location) return;
try {
const url = new URL(res.location);
const r = await fetch(url, {
credentials: "include",
headers: { Accept: "application/json" }
});
const json = await r.json();
if (!r.ok) {
showToast(json.message ?? "Authorization failed", Toasts.Type.FAILURE);
return;
}
showToast("Authorization successful!", Toasts.Type.SUCCESS);
callback?.();
} catch (e) {

View file

@ -17,7 +17,7 @@ import { findByPropsLazy } from "@webpack";
import { Button, Menu, showToast, Toasts, Tooltip, useEffect, UserStore, useState } from "@webpack/common";
import { Message, User } from "discord-types/general";
import { authModal, deleteTimezone, getTimezone, loadDatabaseTimezones, setUserDatabaseTimezone } from "./database";
import { deleteTimezone, getTimezone, loadDatabaseTimezones, setUserDatabaseTimezone } from "./database";
import { SetTimezoneModal } from "./TimezoneModal";
export let timezones: Record<string, string | null> = {};
@ -68,9 +68,7 @@ export const settings = definePluginSettings({
type: OptionType.COMPONENT,
component: () => (
<Button onClick={() => {
authModal(async () => {
openModal(modalProps => <SetTimezoneModal userId={UserStore.getCurrentUser().id} modalProps={modalProps} database={true} />);
});
}}>
Set Timezone on Database
</Button>
@ -83,11 +81,19 @@ export const settings = definePluginSettings({
component: () => (
<Button
color={Button.Colors.RED}
onClick={() => {
authModal(async () => {
onClick={async () => {
try {
await setUserDatabaseTimezone(UserStore.getCurrentUser().id, null);
await deleteTimezone();
});
const success = await deleteTimezone();
if (success) {
showToast("Database timezone reset successfully!", Toasts.Type.SUCCESS);
} else {
showToast("Failed to reset database timezone", Toasts.Type.FAILURE);
}
} catch (error) {
console.error("Error resetting database timezone:", error);
showToast("Failed to reset database timezone", Toasts.Type.FAILURE);
}
}}
>
Reset Database Timezones
@ -228,9 +234,7 @@ export default definePlugin({
toolboxActions: {
"Set Database Timezone": () => {
authModal(async () => {
openModal(modalProps => <SetTimezoneModal userId={UserStore.getCurrentUser().id} modalProps={modalProps} database={true} />);
});
},
"Refresh Database Timezones": async () => {
try {
@ -265,9 +269,7 @@ export default definePlugin({
<Button
color={Button.Colors.GREEN}
onClick={() => {
authModal(async () => {
openModal(modalProps => <SetTimezoneModal userId={UserStore.getCurrentUser().id} modalProps={modalProps} database={true} />);
});
}}
>
Want to save your timezone to the database? Click here to set it.

View file

@ -29,7 +29,7 @@ export default definePlugin({
authors: [Devs.AutumnVN],
start() {
(function connect() {
ws = new WebSocket("ws://localhost:24050/websocket/v2");
ws = new WebSocket("ws://127.0.0.1:24050/websocket/v2");
ws.addEventListener("error", () => ws.close());
ws.addEventListener("close", () => wsReconnect = setTimeout(connect, 5000));
ws.addEventListener("message", ({ data }) => throttledOnMessage(data));

View file

@ -8,8 +8,7 @@ import { NavContextMenuPatchCallback } from "@api/ContextMenu";
import { openModal } from "@utils/modal";
import { ChannelStore, FluxDispatcher, Menu } from "@webpack/common";
import { SetCustomWallpaperModal, SetDiscordWallpaperModal } from "./modal";
import { ChatWallpaperStore, fetchWallpapers } from "./util";
import { SetWallpaperModal } from "./modal";
const addWallpaperMenu = (channelId?: string, guildId?: string) => {
@ -22,25 +21,18 @@ const addWallpaperMenu = (channelId?: string, guildId?: string) => {
url,
});
};
return (
<Menu.MenuItem label="Wallpaper Free" key="vc-wpfree-menu" id="vc-wpfree-menu">
<Menu.MenuItem
label="Set custom wallpaper"
id="vc-wpfree-set-custom"
action={() => openModal(props => <SetCustomWallpaperModal props={props} onSelect={setWallpaper} />)}
/>
<Menu.MenuItem
label="Set a Discord wallpaper"
id="vc-wpfree-set-discord"
action={async () => {
ChatWallpaperStore.shouldFetchWallpapers && await fetchWallpapers();
openModal(props => <SetDiscordWallpaperModal props={props} onSelect={setWallpaper} />);
}}
label="Set Wallpaper"
id="vc-wpfree-set-wallpaper"
action={() => openModal(props => <SetWallpaperModal props={props} onSelect={setWallpaper} />)}
/>
<Menu.MenuSeparator />
<Menu.MenuItem
label="Remove Custom Wallpaper"
id="vc-wpfree-remove"
label="Remove Wallpaper"
id="vc-wpfree-remove-wallpaper"
color="danger"
action={() => setWallpaper(void 0)}
/>

View file

@ -5,38 +5,37 @@
*/
import { ModalContent, ModalHeader, ModalProps, ModalRoot, ModalSize } from "@utils/modal";
import { Button, lodash, Text, TextInput, useState, useStateFromStores } from "@webpack/common";
import { ChatWallpaperStore, Wallpaper } from "./util";
import { Button, Text, TextInput, useState } from "@webpack/common";
interface Props {
props: ModalProps;
onSelect: (url: string) => void;
}
export function SetCustomWallpaperModal({ props, onSelect }: Props) {
export function SetWallpaperModal({ props, onSelect }: Props) {
const [url, setUrl] = useState("");
return (
<ModalRoot {...props} size={ModalSize.SMALL}>
<ModalHeader>
<Text variant="heading-lg/normal" style={{ marginBottom: 8 }}>
Set a custom wallpaper
Set wallpaper
</Text>
</ModalHeader>
<ModalContent>
<div style={{ display: "flex", flexDirection: "column", gap: 16 }}>
<Text>The image url</Text>
<TextInput
placeholder="The image url"
value={url}
onChange={setUrl}
onChange={u => {
setUrl(u);
}}
autoFocus
/>
{url && (
<img
alt=""
src={url}
alt="Wallpaper preview"
style={{
display: "block",
width: "100%",
@ -63,54 +62,3 @@ export function SetCustomWallpaperModal({ props, onSelect }: Props) {
</ModalRoot >
);
}
export function SetDiscordWallpaperModal({ props, onSelect }: Props) {
const discordWallpapers: Wallpaper[] = useStateFromStores([ChatWallpaperStore], () => ChatWallpaperStore.wallpapers);
return (
<ModalRoot {...props} size={ModalSize.MEDIUM}>
<ModalHeader>
<Text variant="heading-lg/normal" style={{ marginBottom: 8 }}>
Choose a Discord Wallpaper
</Text>
</ModalHeader>
<ModalContent>
<div className="vc-wpfree-discord-wp-modal">
{lodash.chunk(discordWallpapers, 2).map(group => {
const main = group[0];
return (
<div key={main.id} className="vc-wpfree-discord-wp-icon-container">
<figure style={{ margin: 0, textAlign: "center" }}>
<img
className="vc-wpfree-discord-wp-icon-img"
src={`https://cdn.discordapp.com/assets/content/${main.default.icon}`}
alt={main.label}
/>
<figcaption>
<Text variant="text-md/normal">{main.label}</Text>
</figcaption>
</figure>
<div className="vc-wpfree-discord-set-buttons">
{group.map(wp => (
<Button
key={wp.id}
size={Button.Sizes.SMALL}
color={Button.Colors.BRAND}
onClick={() => {
onSelect(`https://cdn.discordapp.com/assets/content/${wp.default.asset}`);
props.onClose();
}}
>
{wp.isBlurred ? "Blurred" : "Normal"}
</Button>
))}
</div>
</div>
);
})}
</div>
</ModalContent>
</ModalRoot>
);
}

View file

@ -6,13 +6,9 @@
import { openModal } from "@utils/modal";
import { makeCodeblock } from "@utils/text";
import { findByCodeLazy, findStoreLazy } from "@webpack";
import { Button, FluxDispatcher, Parser } from "@webpack/common";
import { SetCustomWallpaperModal, SetDiscordWallpaperModal } from "./modal";
export const ChatWallpaperStore = findStoreLazy("ChatWallpaperStore");
export const fetchWallpapers = findByCodeLazy('type:"FETCH_CHAT_WALLPAPERS_SUCCESS"');
import { SetWallpaperModal } from "./modal";
export function GlobalDefaultComponent() {
const setGlobal = (url?: string) => {
@ -26,13 +22,8 @@ export function GlobalDefaultComponent() {
return (
<>
<Button onClick={() => {
openModal(props => <SetCustomWallpaperModal props={props} onSelect={setGlobal} />);
}}>Set a global custom wallpaper</Button>
<Button onClick={async () => {
ChatWallpaperStore.shouldFetchWallpapers && await fetchWallpapers();
openModal(props => <SetDiscordWallpaperModal props={props} onSelect={setGlobal} />);
}}>Set a global Discord wallpaper</Button>
openModal(props => <SetWallpaperModal props={props} onSelect={setGlobal} />);
}}>Set a global wallpaper</Button>
<Button
color={Button.Colors.RED}
@ -52,30 +43,10 @@ export function GlobalDefaultComponent() {
export function TipsComponent() {
const tipText = `
[class^=wallpaperContainer] {
.vc-wpfree-wp-container {
transform: scaleX(-1); /* flip it horizontally */
filter: blur(4px); /* apply a blur */
opacity: 0.7; /* self-explanatory */
}`;
return Parser.parse(makeCodeblock(tipText, "css"));
}
export interface Wallpaper {
id: string;
label: string;
default: Default;
variants: Variants;
isBlurred: boolean;
designGroupId: string;
}
export interface Default {
asset: string;
icon: string;
thumbhash: string;
opacity?: number;
}
export interface Variants {
dark: Default;
}

View file

@ -7,59 +7,47 @@
import "./styles.css";
import { definePluginSettings } from "@api/Settings";
import { ErrorBoundary } from "@components/index";
import { Devs } from "@utils/constants";
import definePlugin, { OptionType } from "@utils/types";
import { useStateFromStores } from "@webpack/common";
import { Channel } from "discord-types/general";
import { ChannelContextPatch, GuildContextPatch, UserContextPatch } from "./components/ctxmenu";
import { GlobalDefaultComponent, TipsComponent, Wallpaper } from "./components/util";
import { GlobalDefaultComponent, TipsComponent } from "./components/util";
import { WallpaperFreeStore } from "./store";
const settings = definePluginSettings({
forceReplace: {
description: "If a dm wallpaper is already set, your custom wallpaper will be used instead.",
type: OptionType.BOOLEAN,
default: false,
export const settings = definePluginSettings({
globalDefault: {
description: "Set a global default wallpaper for all channels.",
type: OptionType.COMPONENT,
component: GlobalDefaultComponent
},
stylingTips: {
description: "",
type: OptionType.COMPONENT,
component: TipsComponent,
},
globalDefault: {
description: "Set a global default wallpaper for all channels.",
type: OptionType.COMPONENT,
component: GlobalDefaultComponent
}
});
export default definePlugin({
name: "WallpaperFree",
authors: [Devs.Joona],
description: "Use the DM wallpapers anywhere or set a custom wallpaper",
description: "Recreation of the old DM wallpaper experiment; Set a background image for any channel or server.",
patches: [
{
find: ".wallpaperContainer,",
find: ".handleSendMessage,onResize",
group: true,
replacement: [
{
match: /return null==(\i).+?\?null:/,
replace: "const vcWpFreeCustom = $self.customWallpaper(arguments[0].channel,$1);return !($1||vcWpFreeCustom)?null:"
match: /return.{1,150},(?=keyboardModeEnabled)/,
replace: "const vcWallpaperFreeUrl=$self.WallpaperState(arguments[0].channel);$&vcWallpaperFreeUrl,"
},
{
match: /,{chatWallpaperState:/,
replace: "$&vcWpFreeCustom||"
},
{
match: /(\i)=(.{1,50}asset.+?(?=,\i=))(?=.+?concat\(\1)/,
replace: "$1=arguments[0].chatWallpaperState.vcWallpaperUrl||($2)"
},
{
match: /(\i\.isViewable&&)(null!=\i)/,
replace: "$1($2||arguments[0].chatWallpaperState.vcWallpaperUrl)"
},
match: /}\)]}\)](?=.{1,30}messages-)/,
replace: "$&.toSpliced(0,0,$self.Wallpaper({url:this.props.vcWallpaperFreeUrl}))"
}
]
}
],
@ -71,21 +59,17 @@ export default definePlugin({
"guild-context": GuildContextPatch,
"gdm-context": ChannelContextPatch,
},
customWallpaper(channel: Channel, wp: Wallpaper | undefined) {
const { forceReplace } = settings.use(["forceReplace"]);
const url = useStateFromStores([WallpaperFreeStore], () => WallpaperFreeStore.getUrl(channel));
Wallpaper({ url }: { url: string; }) {
// no we cant place the hook here
if (!url) return null;
if (!forceReplace && wp?.id)
return wp;
if (url) {
return {
wallpaperId: "id",
vcWallpaperUrl: url,
isViewable: true,
};
}
return void 0;
return <ErrorBoundary noop>
<div className="wallpaperContainer vc-wpfree-wp-container" style={{
backgroundImage: `url(${url})`,
}}></div>
</ErrorBoundary>;
},
WallpaperState(channel: Channel) {
return useStateFromStores([WallpaperFreeStore], () => WallpaperFreeStore.getUrl(channel));
}
});

View file

@ -1,30 +1,8 @@
.vc-wpfree-discord-wp-modal {
display: grid;
grid-template-columns: repeat(auto-fit, minmax(220px, 1fr));
gap: 24px;
padding: 8px 0;
}
.vc-wpfree-discord-wp-icon-container {
border-radius: 10px;
box-shadow: 0 2px 8px rgb(0 0 0 / 8%);
padding: 16px;
display: flex;
flex-direction: column;
align-items: center
}
.vc-wpfree-discord-wp-icon-img {
width: 120px;
height: 68px;
object-fit: cover;
border-radius: 6px;
margin-bottom: 8px;
border: 1px solid var(--background-modifier-accent);
}
.vc-wpfree-discord-set-buttons {
display: flex;
gap: 8px;
margin-top: 12px
.vc-wpfree-wp-container,
.wallpaperContainer {
background-position: 100% 100%;
background-repeat: no-repeat;
background-size: cover;
inset: 0;
position: absolute;
}

3
src/globals.d.ts vendored
View file

@ -29,11 +29,12 @@ declare global {
* replace: "IS_WEB?foo:bar"
* // GOOD
* replace: IS_WEB ? "foo" : "bar"
* // also good
* // also okay
* replace: `${IS_WEB}?foo:bar`
*/
export var IS_WEB: boolean;
export var IS_EXTENSION: boolean;
export var IS_USERSCRIPT: boolean;
export var IS_STANDALONE: boolean;
export var IS_UPDATER_DISABLED: boolean;
export var IS_DEV: boolean;

142
src/main/csp.ts Normal file
View file

@ -0,0 +1,142 @@
/*
* Vencord, a Discord client mod
* Copyright (c) 2025 Vendicated and contributors
* SPDX-License-Identifier: GPL-3.0-or-later
*/
import { session } from "electron";
type PolicyMap = Record<string, string[]>;
export const ConnectSrc = ["connect-src"];
export const MediaSrc = [...ConnectSrc, "img-src", "media-src"];
export const CssSrc = ["style-src", "font-src"];
export const MediaAndCssSrc = [...MediaSrc, ...CssSrc];
export const MediaScriptsAndCssSrc = [...MediaAndCssSrc, "script-src", "worker-src"];
// Plugins can whitelist their own domains by importing this object in their native.ts
// script and just adding to it. But generally, you should just edit this file instead
export const CspPolicies: PolicyMap = {
"*.github.io": MediaAndCssSrc, // GitHub pages, used by most themes
"github.com": MediaAndCssSrc, // GitHub content (stuff uploaded to markdown forms), used by most themes
"raw.githubusercontent.com": MediaAndCssSrc, // GitHub raw, used by some themes
"*.gitlab.io": MediaAndCssSrc, // GitLab pages, used by some themes
"gitlab.com": MediaAndCssSrc, // GitLab raw, used by some themes
"*.codeberg.page": MediaAndCssSrc, // Codeberg pages, used by some themes
"codeberg.org": MediaAndCssSrc, // Codeberg raw, used by some themes
"*.githack.com": MediaAndCssSrc, // githack (namely raw.githack.com), used by some themes
"jsdelivr.net": MediaAndCssSrc, // jsDelivr, used by very few themes
"fonts.googleapis.com": CssSrc, // Google Fonts, used by many themes
"i.imgur.com": MediaSrc, // Imgur, used by some themes
"i.ibb.co": MediaSrc, // ImgBB, used by some themes
"i.pinimg.com": MediaSrc, // Pinterest, used by some themes
"*.tenor.com": MediaSrc, // Tenor, used by some themes
"files.catbox.moe": MediaSrc, // Catbox, used by some themes
"cdn.discordapp.com": MediaAndCssSrc, // Discord CDN, used by Vencord and some themes to load media
"media.discordapp.net": MediaSrc, // Discord media CDN, possible alternative to Discord CDN
// CDNs used for some things by Vencord.
// FIXME: we really should not be using CDNs anymore
"cdnjs.cloudflare.com": MediaScriptsAndCssSrc,
"cdn.jsdelivr.net": MediaScriptsAndCssSrc,
// Function Specific
"api.github.com": ConnectSrc, // used for updating Vencord itself
"ws.audioscrobbler.com": ConnectSrc, // Last.fm API
"translate-pa.googleapis.com": ConnectSrc, // Google Translate API
"*.vencord.dev": MediaSrc, // VenCloud (api.vencord.dev) and Badges (badges.vencord.dev)
"manti.vendicated.dev": MediaSrc, // ReviewDB API
"decor.fieryflames.dev": ConnectSrc, // Decor API
"ugc.decor.fieryflames.dev": MediaSrc, // Decor CDN
"sponsor.ajay.app": ConnectSrc, // Dearrow API
"dearrow-thumb.ajay.app": MediaSrc, // Dearrow Thumbnail CDN
"usrbg.is-hardly.online": MediaSrc, // USRBG API
"icons.duckduckgo.com": MediaSrc, // DuckDuckGo Favicon API (Reverse Image Search)
};
const findHeader = (headers: PolicyMap, headerName: Lowercase<string>) => {
return Object.keys(headers).find(h => h.toLowerCase() === headerName);
};
const parsePolicy = (policy: string): PolicyMap => {
const result: PolicyMap = {};
policy.split(";").forEach(directive => {
const [directiveKey, ...directiveValue] = directive.trim().split(/\s+/g);
if (directiveKey && !Object.prototype.hasOwnProperty.call(result, directiveKey)) {
result[directiveKey] = directiveValue;
}
});
return result;
};
const stringifyPolicy = (policy: PolicyMap): string =>
Object.entries(policy)
.filter(([, values]) => values?.length)
.map(directive => directive.flat().join(" "))
.join("; ");
const patchCsp = (headers: PolicyMap) => {
const reportOnlyHeader = findHeader(headers, "content-security-policy-report-only");
if (reportOnlyHeader)
delete headers[reportOnlyHeader];
const header = findHeader(headers, "content-security-policy");
if (header) {
const csp = parsePolicy(headers[header][0]);
const pushDirective = (directive: string, ...values: string[]) => {
csp[directive] ??= [...(csp["default-src"] ?? [])];
csp[directive].push(...values);
};
pushDirective("style-src", "'unsafe-inline'");
// we could make unsafe-inline safe by using strict-dynamic with a random nonce on our Vencord loader script https://content-security-policy.com/strict-dynamic/
// HOWEVER, at the time of writing (24 Jan 2025), Discord is INSANE and also uses unsafe-inline
// Once they stop using it, we also should
pushDirective("script-src", "'unsafe-inline'", "'unsafe-eval'");
for (const directive of ["style-src", "connect-src", "img-src", "font-src", "media-src", "worker-src"]) {
pushDirective(directive, "blob:", "data:", "vencord:");
}
for (const [host, directives] of Object.entries(CspPolicies)) {
for (const directive of directives) {
pushDirective(directive, host);
}
}
headers[header] = [stringifyPolicy(csp)];
}
};
export function initCsp() {
session.defaultSession.webRequest.onHeadersReceived(({ responseHeaders, resourceType }, cb) => {
if (responseHeaders) {
if (resourceType === "mainFrame")
patchCsp(responseHeaders);
// Fix hosts that don't properly set the css content type, such as
// raw.githubusercontent.com
if (resourceType === "stylesheet") {
const header = findHeader(responseHeaders, "content-type");
if (header)
responseHeaders[header] = ["text/css"];
}
}
cb({ cancel: false, responseHeaders });
});
// assign a noop to onHeadersReceived to prevent other mods from adding their own incompatible ones.
// For instance, OpenAsar adds their own that doesn't fix content-type for stylesheets which makes it
// impossible to load css from github raw despite our fix above
session.defaultSession.webRequest.onHeadersReceived = () => { };
}

View file

@ -16,9 +16,11 @@
* along with this program. If not, see <https://www.gnu.org/licenses/>.
*/
import { app, protocol, session } from "electron";
import { app, net, protocol } from "electron";
import { join } from "path";
import { pathToFileURL } from "url";
import { initCsp } from "./csp";
import { ensureSafePath } from "./ipcMain";
import { RendererSettings } from "./settings";
import { IS_VANILLA, THEMES_DIR } from "./utils/constants";
@ -26,55 +28,71 @@ import { installExt } from "./utils/extensions";
if (!IS_VANILLA && !IS_EXTENSION) {
app.whenReady().then(() => {
// Source Maps! Maybe there's a better way but since the renderer is executed
// from a string I don't think any other form of sourcemaps would work
protocol.registerFileProtocol("vencord", ({ url: unsafeUrl }, cb) => {
let url = unsafeUrl.slice("vencord://".length);
protocol.handle("vencord", ({ url: unsafeUrl }) => {
let url = decodeURI(unsafeUrl).slice("vencord://".length).replace(/\?v=\d+$/, "");
if (url.endsWith("/")) url = url.slice(0, -1);
if (url.startsWith("/themes/")) {
const theme = url.slice("/themes/".length);
const safeUrl = ensureSafePath(THEMES_DIR, theme);
if (!safeUrl) {
cb({ statusCode: 403 });
return;
return new Response(null, {
status: 404
});
}
cb(safeUrl.replace(/\?v=\d+$/, ""));
return;
return net.fetch(pathToFileURL(safeUrl).toString());
}
// Source Maps! Maybe there's a better way but since the renderer is executed
// from a string I don't think any other form of sourcemaps would work
switch (url) {
case "renderer.js.map":
case "preload.js.map":
case "patcher.js.map":
case "main.js.map":
cb(join(__dirname, url));
break;
return net.fetch(pathToFileURL(join(__dirname, url)).toString());
default:
cb({ statusCode: 403 });
return new Response(null, {
status: 404
});
}
});
protocol.registerFileProtocol("equicord", ({ url: unsafeUrl }, cb) => {
let url = unsafeUrl.slice("equicord://".length);
protocol.handle("equicord", ({ url: unsafeUrl }) => {
let url = decodeURI(unsafeUrl).slice("equicord://".length).replace(/\?v=\d+$/, "");
if (url.endsWith("/")) url = url.slice(0, -1);
if (url.startsWith("/themes/")) {
const theme = url.slice("/themes/".length);
const safeUrl = ensureSafePath(THEMES_DIR, theme);
if (!safeUrl) {
cb({ statusCode: 403 });
return;
return new Response(null, {
status: 404
});
}
cb(safeUrl.replace(/\?v=\d+$/, ""));
return;
return net.fetch(pathToFileURL(safeUrl).toString());
}
// Source Maps! Maybe there's a better way but since the renderer is executed
// from a string I don't think any other form of sourcemaps would work
switch (url) {
case "renderer.js.map":
case "preload.js.map":
case "patcher.js.map":
case "main.js.map":
cb(join(__dirname, url));
break;
return net.fetch(pathToFileURL(join(__dirname, url)).toString());
default:
cb({ statusCode: 403 });
return new Response(null, {
status: 404
});
}
});
@ -86,70 +104,7 @@ if (!IS_VANILLA && !IS_EXTENSION) {
} catch { }
const findHeader = (headers: Record<string, string[]>, headerName: Lowercase<string>) => {
return Object.keys(headers).find(h => h.toLowerCase() === headerName);
};
// Remove CSP
type PolicyResult = Record<string, string[]>;
const parsePolicy = (policy: string): PolicyResult => {
const result: PolicyResult = {};
policy.split(";").forEach(directive => {
const [directiveKey, ...directiveValue] = directive.trim().split(/\s+/g);
if (directiveKey && !Object.prototype.hasOwnProperty.call(result, directiveKey)) {
result[directiveKey] = directiveValue;
}
});
return result;
};
const stringifyPolicy = (policy: PolicyResult): string =>
Object.entries(policy)
.filter(([, values]) => values?.length)
.map(directive => directive.flat().join(" "))
.join("; ");
const patchCsp = (headers: Record<string, string[]>) => {
const header = findHeader(headers, "content-security-policy");
if (header) {
const csp = parsePolicy(headers[header][0]);
for (const directive of ["style-src", "connect-src", "img-src", "font-src", "media-src", "worker-src"]) {
csp[directive] ??= [];
csp[directive].push("*", "blob:", "data:", "vencord:", "'unsafe-inline'");
}
// TODO: Restrict this to only imported packages with fixed version.
// Perhaps auto generate with esbuild
csp["script-src"] ??= [];
csp["script-src"].push("'unsafe-eval'", "https://cdn.jsdelivr.net", "https://cdnjs.cloudflare.com");
headers[header] = [stringifyPolicy(csp)];
}
};
session.defaultSession.webRequest.onHeadersReceived(({ responseHeaders, resourceType }, cb) => {
if (responseHeaders) {
if (resourceType === "mainFrame")
patchCsp(responseHeaders);
// Fix hosts that don't properly set the css content type, such as
// raw.githubusercontent.com
if (resourceType === "stylesheet") {
const header = findHeader(responseHeaders, "content-type");
if (header)
responseHeaders[header] = ["text/css"];
}
}
cb({ cancel: false, responseHeaders });
});
// assign a noop to onHeadersReceived to prevent other mods from adding their own incompatible ones.
// For instance, OpenAsar adds their own that doesn't fix content-type for stylesheets which makes it
// impossible to load css from github raw despite our fix above
session.defaultSession.webRequest.onHeadersReceived = () => { };
initCsp();
});
}

View file

@ -34,7 +34,7 @@ import { makeLinksOpenExternally } from "./utils/externalLinks";
mkdirSync(THEMES_DIR, { recursive: true });
export function ensureSafePath(basePath: string, path: string) {
const normalizedBasePath = normalize(basePath);
const normalizedBasePath = normalize(basePath + "/");
const newPath = join(basePath, path);
const normalizedPath = normalize(newPath);
return normalizedPath.startsWith(normalizedBasePath) ? normalizedPath : null;

View file

@ -30,7 +30,10 @@ export function serializeErrors(func: (...args: any[]) => any) {
ok: false,
error: e instanceof Error ? {
// prototypes get lost, so turn error into plain object
...e
...e,
message: e.message,
name: e.name,
stack: e.stack
} : e
};
}

View file

@ -16,7 +16,7 @@
* along with this program. If not, see <https://www.gnu.org/licenses/>.
*/
import { get } from "@main/utils/simpleGet";
import { fetchBuffer, fetchJson } from "@main/utils/http";
import { IpcEvents } from "@shared/IpcEvents";
import { VENCORD_USER_AGENT } from "@shared/vencordUserAgent";
import { ipcMain } from "electron";
@ -30,8 +30,8 @@ import { ASAR_FILE, serializeErrors } from "./common";
const API_BASE = `https://api.github.com/repos/${gitRemote}`;
let PendingUpdate: string | null = null;
async function githubGet(endpoint: string) {
return get(API_BASE + endpoint, {
async function githubGet<T = any>(endpoint: string) {
return fetchJson<T>(API_BASE + endpoint, {
headers: {
Accept: "application/vnd.github+json",
// "All API requests MUST include a valid User-Agent header.
@ -45,9 +45,8 @@ async function calculateGitChanges() {
const isOutdated = await fetchUpdates();
if (!isOutdated) return [];
const res = await githubGet(`/compare/${gitHash}...HEAD`);
const data = await githubGet(`/compare/${gitHash}...HEAD`);
const data = JSON.parse(res.toString("utf-8"));
return data.commits.map((c: any) => ({
// github api only sends the long sha
hash: c.sha.slice(0, 7),
@ -57,9 +56,8 @@ async function calculateGitChanges() {
}
async function fetchUpdates() {
const release = await githubGet("/releases/latest");
const data = await githubGet("/releases/latest");
const data = JSON.parse(release.toString());
const hash = data.name.slice(data.name.lastIndexOf(" ") + 1);
if (hash === gitHash)
return false;
@ -74,7 +72,7 @@ async function fetchUpdates() {
async function applyUpdates() {
if (!PendingUpdate) return true;
const data = await get(PendingUpdate);
const data = await fetchBuffer(PendingUpdate);
originalWriteFileSync(__dirname, data);
PendingUpdate = null;

View file

@ -24,7 +24,7 @@ import { join } from "path";
import { DATA_DIR } from "./constants";
import { crxToZip } from "./crxToZip";
import { get } from "./simpleGet";
import { fetchBuffer } from "./http";
const extensionCacheDir = join(DATA_DIR, "ExtensionCache");
@ -69,13 +69,14 @@ export async function installExt(id: string) {
} catch (err) {
const url = `https://clients2.google.com/service/update2/crx?response=redirect&acceptformat=crx2,crx3&x=id%3D${id}%26uc&prodversion=${process.versions.chrome}`;
const buf = await get(url, {
const buf = await fetchBuffer(url, {
headers: {
"User-Agent": `Electron ${process.versions.electron} ~ Equicord (https://github.com/Equicord/Equicord)`
}
});
await extract(crxToZip(buf), extDir).catch(console.error);
await extract(crxToZip(buf), extDir)
.catch(err => console.error(`Failed to extract extension ${id}`, err));
}
session.defaultSession.loadExtension(extDir);

70
src/main/utils/http.ts Normal file
View file

@ -0,0 +1,70 @@
/*
* Vencord, a modification for Discord's desktop app
* Copyright (c) 2022 Vendicated and contributors
*
* This program is free software: you can redistribute it and/or modify
* it under the terms of the GNU General Public License as published by
* the Free Software Foundation, either version 3 of the License, or
* (at your option) any later version.
*
* This program is distributed in the hope that it will be useful,
* but WITHOUT ANY WARRANTY; without even the implied warranty of
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
* GNU General Public License for more details.
*
* You should have received a copy of the GNU General Public License
* along with this program. If not, see <https://www.gnu.org/licenses/>.
*/
import { createWriteStream } from "original-fs";
import { Readable } from "stream";
import { finished } from "stream/promises";
type Url = string | URL;
export async function checkedFetch(url: Url, options?: RequestInit) {
try {
var res = await fetch(url, options);
} catch (err) {
if (err instanceof Error && err.cause) {
err = err.cause;
}
throw new Error(`${options?.method ?? "GET"} ${url} failed: ${err}`);
}
if (res.ok) {
return res;
}
let message = `${options?.method ?? "GET"} ${url}: ${res.status} ${res.statusText}`;
try {
const reason = await res.text();
message += `\n${reason}`;
} catch { }
throw new Error(message);
}
export async function fetchJson<T = any>(url: Url, options?: RequestInit) {
const res = await checkedFetch(url, options);
return res.json() as Promise<T>;
}
export async function fetchBuffer(url: Url, options?: RequestInit) {
const res = await checkedFetch(url, options);
const buf = await res.arrayBuffer();
return Buffer.from(buf);
}
export async function downloadToFile(url: Url, path: string, options?: RequestInit) {
const res = await checkedFetch(url, options);
if (!res.body) {
throw new Error(`Download ${url}: response body is empty`);
}
// @ts-expect-error weird type conflict
const body = Readable.fromWeb(res.body);
await finished(body.pipe(createWriteStream(path)));
}

View file

@ -1,37 +0,0 @@
/*
* Vencord, a modification for Discord's desktop app
* Copyright (c) 2022 Vendicated and contributors
*
* This program is free software: you can redistribute it and/or modify
* it under the terms of the GNU General Public License as published by
* the Free Software Foundation, either version 3 of the License, or
* (at your option) any later version.
*
* This program is distributed in the hope that it will be useful,
* but WITHOUT ANY WARRANTY; without even the implied warranty of
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
* GNU General Public License for more details.
*
* You should have received a copy of the GNU General Public License
* along with this program. If not, see <https://www.gnu.org/licenses/>.
*/
import https from "https";
export function get(url: string, options: https.RequestOptions = {}) {
return new Promise<Buffer>((resolve, reject) => {
https.get(url, options, res => {
const { statusCode, statusMessage, headers } = res;
if (statusCode! >= 400)
return void reject(`${statusCode}: ${statusMessage} - ${url}`);
if (statusCode! >= 300)
return void resolve(get(headers.location!, options));
const chunks = [] as Buffer[];
res.on("error", reject);
res.on("data", chunk => chunks.push(chunk));
res.once("end", () => resolve(Buffer.concat(chunks)));
});
});
}

View file

@ -31,7 +31,7 @@ import { User } from "discord-types/general";
import { EquicordDonorModal, VencordDonorModal } from "./modals";
const CONTRIBUTOR_BADGE = "https://vencord.dev/assets/favicon.png";
const CONTRIBUTOR_BADGE = "https://cdn.discordapp.com/emojis/1092089799109775453.png?size=64";
const EQUICORD_CONTRIBUTOR_BADGE = "https://i.imgur.com/57ATLZu.png";
const EQUICORD_DONOR_BADGE = "https://cdn.nest.rip/uploads/78cb1e77-b7a6-4242-9089-e91f866159bf.png";

View file

@ -79,11 +79,17 @@ export default definePlugin({
patches: [
{
find: 'type:"UPLOAD_START"',
replacement: {
match: /await \i\.uploadFiles\((\i),/,
replace: "$1.forEach($self.anonymise),$&"
find: "async uploadFiles(",
replacement: [
{
match: /async uploadFiles\((\i),\i\){/,
replace: "$&$1.forEach($self.anonymise);"
},
{
match: /async uploadFilesSimple\((\i)\){/,
replace: "$&$1.forEach($self.anonymise);"
}
],
},
{
find: "#{intl::ATTACHMENT_UTILITIES_SPOILER}",

View file

@ -20,7 +20,6 @@ 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 } from "@webpack/common";
@ -350,7 +349,7 @@ export default definePlugin({
}
try {
return child?.props?.["aria-label"] === getIntlMessage("SERVERS");
return child?.props?.renderTreeNode !== null;
} catch (e) {
console.error(e);
return true;
@ -390,14 +389,6 @@ export default definePlugin({
}
},
makeNewButtonFilter(isBetterFolders: boolean) {
return child => {
if (!isBetterFolders) return true;
return !child?.props?.barClassName;
};
},
shouldShowTransition(props: any) {
// Pending guilds
if (props?.folderNode?.id === 1) return true;

View file

@ -139,5 +139,5 @@ export default definePlugin({
}
},
DecorSection: ErrorBoundary.wrap(DecorSection)
DecorSection: ErrorBoundary.wrap(DecorSection, { noop: true })
});

View file

@ -29,7 +29,7 @@ export let socket: WebSocket | undefined;
export function initWs(isManual = false) {
let wasConnected = isManual;
let hasErrored = false;
const ws = socket = new WebSocket(`ws://localhost:${PORT}`);
const ws = socket = new WebSocket(`ws://127.0.0.1:${PORT}`);
function replyData(data: OutgoingMessage) {
ws.send(JSON.stringify(data));

View file

@ -118,7 +118,7 @@ export default definePlugin({
renderSearchBar(instance: Instance, SearchBarComponent: TSearchBarComponent) {
this.instance = instance;
return (
<ErrorBoundary noop={true}>
<ErrorBoundary noop>
<SearchBar instance={instance} SearchBarComponent={SearchBarComponent} />
</ErrorBoundary>
);

View file

@ -98,7 +98,7 @@ export default definePlugin({
src={`${location.protocol}//${window.GLOBAL_ENV.CDN_HOST}/role-icons/${roleId}/${role.icon}.webp?size=24&quality=lossless`}
/>
);
}),
}, { noop: true }),
});
function getUsernameString(username: string) {

View file

@ -20,7 +20,8 @@ import { definePluginSettings } from "@api/Settings";
import { Devs } from "@utils/constants";
import definePlugin, { OptionType } from "@utils/types";
import { findByPropsLazy } from "@webpack";
import { FluxDispatcher, PermissionsBits, PermissionStore, UserStore } from "@webpack/common";
import { FluxDispatcher, PermissionsBits, PermissionStore, UserStore, WindowStore } from "@webpack/common";
import NoReplyMentionPlugin from "plugins/noReplyMention";
const MessageActions = findByPropsLazy("deleteMessage", "startEditMessage");
const EditStore = findByPropsLazy("isEditing", "isEditingAny");
@ -28,6 +29,7 @@ const EditStore = findByPropsLazy("isEditing", "isEditingAny");
let isDeletePressed = false;
const keydown = (e: KeyboardEvent) => e.key === "Backspace" && (isDeletePressed = true);
const keyup = (e: KeyboardEvent) => e.key === "Backspace" && (isDeletePressed = false);
const focusChanged = () => !WindowStore.isFocused() && (isDeletePressed = false);
const settings = definePluginSettings({
enableDeleteOnClick: {
@ -62,11 +64,13 @@ export default definePlugin({
start() {
document.addEventListener("keydown", keydown);
document.addEventListener("keyup", keyup);
WindowStore.addChangeListener(focusChanged);
},
stop() {
document.removeEventListener("keydown", keydown);
document.removeEventListener("keyup", keyup);
WindowStore.removeChangeListener(focusChanged);
},
onMessageClick(msg: any, channel, event) {
@ -89,9 +93,8 @@ export default definePlugin({
if (msg.hasFlag(EPHEMERAL)) return;
const isShiftPress = event.shiftKey && !settings.store.requireModifier;
const NoReplyMention = Vencord.Plugins.plugins.NoReplyMention as any as typeof import("../noReplyMention").default;
const shouldMention = Vencord.Plugins.isPluginEnabled("NoReplyMention")
? NoReplyMention.shouldMention(msg, isShiftPress)
const shouldMention = Vencord.Plugins.isPluginEnabled(NoReplyMentionPlugin.name)
? NoReplyMentionPlugin.shouldMention(msg, isShiftPress)
: !isShiftPress;
FluxDispatcher.dispatch({

View file

@ -20,7 +20,6 @@ import { addMessageAccessory, removeMessageAccessory } from "@api/MessageAccesso
import { updateMessage } from "@api/MessageUpdater";
import { definePluginSettings } from "@api/Settings";
import { getUserSettingLazy } from "@api/UserSettings";
import ErrorBoundary from "@components/ErrorBoundary";
import { Devs } from "@utils/constants.js";
import { classes } from "@utils/misc";
import { Queue } from "@utils/Queue";
@ -373,7 +372,7 @@ export default definePlugin({
settings,
start() {
addMessageAccessory("messageLinkEmbed", props => {
addMessageAccessory("MessageLinkEmbeds", props => {
if (!messageLinkRegex.test(props.message.content))
return null;
@ -381,15 +380,13 @@ export default definePlugin({
messageLinkRegex.lastIndex = 0;
return (
<ErrorBoundary>
<MessageEmbedAccessory
message={props.message}
/>
</ErrorBoundary>
);
}, 4 /* just above rich embeds */);
},
stop() {
removeMessageAccessory("messageLinkEmbed");
removeMessageAccessory("MessageLinkEmbeds");
}
});

View file

@ -121,14 +121,9 @@ export default definePlugin({
},
// Make the gap between each item smaller so our tab can fit.
{
match: /className:\i\.tabBar/,
replace: '$& + " vc-mutual-gdms-modal-v2-tab-bar"'
match: /type:"top",/,
replace: '$&className:"vc-mutual-gdms-modal-v2-tab-bar",'
},
// Make the tab bar item text smaller so our tab can fit.
{
match: /(\.tabBarItem.+?variant:)"heading-md\/normal"/,
replace: '$1"heading-sm/normal"'
}
]
},
{
@ -209,5 +204,5 @@ export default definePlugin({
/>
</>
);
})
}, { noop: true })
});

View file

@ -3,5 +3,5 @@
}
.vc-mutual-gdms-modal-v2-tab-bar {
gap: 12px;
--space-xl: 16px;
}

View file

@ -75,5 +75,5 @@ export default definePlugin({
}}> Pause Indefinitely.</a>}
</div>
);
})
}, { noop: true })
});

View file

@ -16,19 +16,20 @@
* along with this program. If not, see <https://www.gnu.org/licenses/>.
*/
import { definePluginSettings, Settings } from "@api/Settings";
import { definePluginSettings } from "@api/Settings";
import { Devs } from "@utils/constants";
import definePlugin, { OptionType } from "@utils/types";
import { findByPropsLazy } from "@webpack";
import { ChannelStore, ComponentDispatch, FluxDispatcher as Dispatcher, MessageStore, PermissionsBits, PermissionStore, SelectedChannelStore, UserStore } from "@webpack/common";
import { ChannelStore, ComponentDispatch, FluxDispatcher as Dispatcher, MessageActions, MessageStore, PermissionsBits, PermissionStore, SelectedChannelStore, UserStore } from "@webpack/common";
import { Message } from "discord-types/general";
import NoBlockedMessagesPlugin from "plugins/noBlockedMessages";
import NoReplyMentionPlugin from "plugins/noReplyMention";
const Kangaroo = findByPropsLazy("jumpToMessage");
const RelationshipStore = findByPropsLazy("getRelationships", "isBlocked");
const isMac = navigator.platform.includes("Mac"); // bruh
let replyIdx = -1;
let editIdx = -1;
let currentlyReplyingId: string | null = null;
let currentlyEditingId: string | null = null;
const enum MentionOptions {
@ -69,36 +70,29 @@ export default definePlugin({
flux: {
DELETE_PENDING_REPLY() {
replyIdx = -1;
currentlyReplyingId = null;
},
MESSAGE_END_EDIT() {
editIdx = -1;
currentlyEditingId = null;
},
CHANNEL_SELECT() {
currentlyReplyingId = null;
currentlyEditingId = null;
},
MESSAGE_START_EDIT: onStartEdit,
CREATE_PENDING_REPLY: onCreatePendingReply
}
});
function calculateIdx(messages: Message[], id: string) {
const idx = messages.findIndex(m => m.id === id);
return idx === -1
? idx
: messages.length - idx - 1;
}
function onStartEdit({ channelId, messageId, _isQuickEdit }: any) {
function onStartEdit({ messageId, _isQuickEdit }: any) {
if (_isQuickEdit) return;
const meId = UserStore.getCurrentUser().id;
const messages = MessageStore.getMessages(channelId)._array.filter(m => m.author.id === meId);
editIdx = calculateIdx(messages, messageId);
currentlyEditingId = messageId;
}
function onCreatePendingReply({ message, _isQuickReply }: { message: Message; _isQuickReply: boolean; }) {
if (_isQuickReply) return;
replyIdx = calculateIdx(MessageStore.getMessages(message.channel_id)._array, message.id);
currentlyReplyingId = message.id;
}
const isCtrl = (e: KeyboardEvent) => isMac ? e.metaKey : e.ctrlKey;
@ -123,10 +117,10 @@ function jumpIfOffScreen(channelId: string, messageId: string) {
const vh = Math.max(document.documentElement.clientHeight, window.innerHeight);
const rect = element.getBoundingClientRect();
const isOffscreen = rect.bottom < 200 || rect.top - vh >= -200;
const isOffscreen = rect.bottom < 150 || rect.top - vh >= -150;
if (isOffscreen) {
Kangaroo.jumpToMessage({
MessageActions.jumpToMessage({
channelId,
messageId,
flash: false,
@ -137,44 +131,48 @@ function jumpIfOffScreen(channelId: string, messageId: string) {
function getNextMessage(isUp: boolean, isReply: boolean) {
let messages: Array<Message & { deleted?: boolean; }> = MessageStore.getMessages(SelectedChannelStore.getChannelId())._array;
if (!isReply) {
// we are editing so only include own
const meId = UserStore.getCurrentUser().id;
messages = messages.filter(m => m.author.id === meId);
}
const hasNoBlockedMessages = Vencord.Plugins.isPluginEnabled(NoBlockedMessagesPlugin.name);
if (Vencord.Plugins.isPluginEnabled("NoBlockedMessages")) {
messages = messages.filter(m => !RelationshipStore.isBlocked(m.author.id));
}
messages = messages.filter(m => {
if (m.deleted) return false;
if (!isReply && m.author.id !== meId) return false; // editing only own messages
if (hasNoBlockedMessages && NoBlockedMessagesPlugin.shouldIgnoreMessage(m)) return false;
const mutate = (i: number) => isUp
? Math.min(messages.length - 1, i + 1)
: Math.max(-1, i - 1);
return true;
});
const findNextNonDeleted = (i: number) => {
do {
i = mutate(i);
} while (i !== -1 && messages[messages.length - i - 1]?.deleted === true);
return i;
const findNextNonDeleted = (id: string | null) => {
if (id === null) return messages[messages.length - 1];
const idx = messages.findIndex(m => m.id === id);
if (idx === -1) return messages[messages.length - 1];
const i = isUp ? idx - 1 : idx + 1;
return messages[i] ?? null;
};
let i: number;
if (isReply)
replyIdx = i = findNextNonDeleted(replyIdx);
else
editIdx = i = findNextNonDeleted(editIdx);
return i === - 1 ? undefined : messages[messages.length - i - 1];
if (isReply) {
const msg = findNextNonDeleted(currentlyReplyingId);
currentlyReplyingId = msg?.id ?? null;
return msg;
} else {
const msg = findNextNonDeleted(currentlyEditingId);
currentlyEditingId = msg?.id ?? null;
return msg;
}
}
function shouldMention(message) {
const { enabled, userList, shouldPingListed } = Settings.plugins.NoReplyMention;
const shouldPing = !enabled || (shouldPingListed === userList.includes(message.author.id));
function shouldMention(message: Message) {
switch (settings.store.shouldMention) {
case MentionOptions.NO_REPLY_MENTION_PLUGIN: return shouldPing;
case MentionOptions.DISABLED: return false;
default: return true;
case MentionOptions.NO_REPLY_MENTION_PLUGIN:
if (!Vencord.Plugins.isPluginEnabled(NoReplyMentionPlugin.name)) return true;
return NoReplyMentionPlugin.shouldMention(message, false);
case MentionOptions.DISABLED:
return false;
default:
return true;
}
}
@ -182,13 +180,16 @@ function shouldMention(message) {
function nextReply(isUp: boolean) {
const currChannel = ChannelStore.getChannel(SelectedChannelStore.getChannelId());
if (currChannel.guild_id && !PermissionStore.can(PermissionsBits.SEND_MESSAGES, currChannel)) return;
const message = getNextMessage(isUp, true);
if (!message)
if (!message) {
return void Dispatcher.dispatch({
type: "DELETE_PENDING_REPLY",
channelId: SelectedChannelStore.getChannelId(),
});
}
const channel = ChannelStore.getChannel(message.channel_id);
const meId = UserStore.getCurrentUser().id;
@ -200,6 +201,7 @@ function nextReply(isUp: boolean) {
showMentionToggle: !channel.isPrivate() && message.author.id !== meId,
_isQuickReply: true
});
ComponentDispatch.dispatchToLastSubscribed("TEXTAREA_FOCUS");
jumpIfOffScreen(channel.id, message.id);
}
@ -210,11 +212,13 @@ function nextEdit(isUp: boolean) {
if (currChannel.guild_id && !PermissionStore.can(PermissionsBits.SEND_MESSAGES, currChannel)) return;
const message = getNextMessage(isUp, false);
if (!message)
if (!message) {
return Dispatcher.dispatch({
type: "MESSAGE_END_EDIT",
channelId: SelectedChannelStore.getChannelId()
});
}
Dispatcher.dispatch({
type: "MESSAGE_START_EDIT",
channelId: message.channel_id,
@ -222,5 +226,6 @@ function nextEdit(isUp: boolean) {
content: message.content,
_isQuickEdit: true
});
jumpIfOffScreen(message.channel_id, message.id);
}

View file

@ -53,14 +53,12 @@ function makeSearchItem(src: string) {
<Flex style={{ alignItems: "center", gap: "0.5em" }}>
<img
style={{
borderRadius: i >= 3 // Do not round Google, Yandex & SauceNAO
? "50%"
: void 0
borderRadius: "50%",
}}
aria-hidden="true"
height={16}
width={16}
src={new URL("/favicon.ico", Engines[engine]).toString().replace("lens.", "")}
src={`https://icons.duckduckgo.com/ip3/${new URL(Engines[engine]).host}.ico`}
/>
{engine}
</Flex>

View file

@ -530,6 +530,7 @@ export default definePlugin({
if (channel.channelId != null) channel = ChannelStore.getChannel(channel.channelId);
if (channel == null || channel.isDM() || channel.isGroupDM() || channel.isMultiUserDM()) return false;
if (["browse", "customize", "guide"].includes(channel.id)) return false;
return !PermissionStore.can(PermissionsBits.VIEW_CHANNEL, channel) || checkConnect && !PermissionStore.can(PermissionsBits.CONNECT, channel);
} catch (e) {

View file

@ -86,5 +86,5 @@ export default definePlugin({
</TooltipContainer>
)}
</div>;
})
}, { noop: true })
});

View file

@ -111,7 +111,7 @@ export default definePlugin({
},
{
// Changes the indicator to keep the user object when creating the list of typing users
match: /\.map\((\i)=>\i\.\i\.getName\(\i,\i\.id,\1\)\)/,
match: /\.map\((\i)=>\i\.\i\.getName\(\i(?:\.guild_id)?,\i\.id,\1\)\)/,
replace: ""
},
{

View file

@ -132,10 +132,16 @@ export default definePlugin({
{
find: "Copy image not supported",
replacement: {
match: /(?<=(?:canSaveImage|canCopyImage)\((\i,\i)?\)\{.{0,150})!\i\.isPlatformEmbedded/g,
replacement: [
{
match: /(?<=(?:canSaveImage|canCopyImage)\(.{0,120}?)!\i\.isPlatformEmbedded/g,
replace: "false"
},
{
match: /canCopyImage\(.+?(?=return"function"==typeof \i\.clipboard\.copyImage)/,
replace: "$&return true;"
}
]
},
// Add back Copy & Save Image
{
@ -147,7 +153,7 @@ export default definePlugin({
replace: "false"
},
{
match: /return\s*?\[.{0,50}?(?=\?.{0,25}?id:"copy-image")/,
match: /return\s*?\[.{0,50}?(?=\?\(0,\i\.jsxs?.{0,100}?id:"copy-image")/,
replace: "return [true"
},
{

View file

@ -25,8 +25,8 @@ export default definePlugin({
replace: ";b=AS:800000;level-asymmetry-allowed=1"
},
{
match: "useinbandfec=1",
replace: "useinbandfec=1;stereo=1;sprop-stereo=1"
match: /;usedtx=".concat\((\i)\?"0":"1"\)/,
replace: '$&.concat($1?";stereo=1;sprop-stereo=1":"")'
}
]
}

View file

@ -57,7 +57,7 @@ export interface Dev {
*/
export const Devs = /* #__PURE__*/ Object.freeze({
Ven: {
name: "Vee",
name: "V",
id: 343383572805058560n
},
Arjix: {
@ -211,7 +211,7 @@ export const Devs = /* #__PURE__*/ Object.freeze({
},
axyie: {
name: "'ax",
id: 273562710745284628n
id: 929877747151548487n,
},
pointy: {
name: "pointy",
@ -604,7 +604,11 @@ export const Devs = /* #__PURE__*/ Object.freeze({
},
samsam: {
name: "samsam",
id: 836452332387565589n,
id: 400482410279469056n,
},
Cootshk: {
name: "Cootshk",
id: 921605971577548820n
},
} satisfies Record<string, Dev>);
@ -1078,6 +1082,10 @@ export const EquicordDevs = Object.freeze({
name: "bbgaming25k",
id: 851222385528274964n,
},
davidkra230: {
name: "davidkra230",
id: 652699312631054356n,
},
GroupXyz: {
name: "GroupXyz",
id: 950033410229944331n

View file

@ -0,0 +1,34 @@
/*
* Vencord, a Discord client mod
* Copyright (c) 2025 Vendicated and contributors
* SPDX-License-Identifier: GPL-3.0-or-later
*/
import { useLayoutEffect } from "@webpack/common";
import { useForceUpdater } from "./react";
const cssRelevantDirectives = ["style-src", "img-src", "font-src"] as const;
export const CspBlockedUrls = new Set<string>();
const CspErrorListeners = new Set<() => void>();
document.addEventListener("securitypolicyviolation", ({ effectiveDirective, blockedURI }) => {
if (!blockedURI || !cssRelevantDirectives.includes(effectiveDirective as any)) return;
CspBlockedUrls.add(blockedURI);
CspErrorListeners.forEach(listener => listener());
});
export function useCspErrors() {
const forceUpdate = useForceUpdater();
useLayoutEffect(() => {
CspErrorListeners.add(forceUpdate);
return () => void CspErrorListeners.delete(forceUpdate);
}, [forceUpdate]);
return [...CspBlockedUrls] as const;
}

View file

@ -21,6 +21,7 @@ export * from "../shared/onceDefined";
export * from "./ChangeList";
export * from "./clipboard";
export * from "./constants";
export * from "./cspViolations";
export * from "./discord";
export * from "./guards";
export * from "./intlHash";

View file

@ -39,7 +39,7 @@ async function initSystemValues() {
createStyle("vencord-os-theme-values").textContent = `:root{${variables}}`;
}
export async function toggle(isEnabled: boolean) {
async function toggle(isEnabled: boolean) {
if (!style) {
if (isEnabled) {
style = createStyle("vencord-custom-css");
@ -92,6 +92,8 @@ async function initThemes() {
}
document.addEventListener("DOMContentLoaded", () => {
if (IS_USERSCRIPT) return;
initSystemValues();
initThemes();
@ -104,9 +106,11 @@ document.addEventListener("DOMContentLoaded", () => {
if (!IS_WEB) {
VencordNative.quickCss.addThemeChangeListener(initThemes);
}
});
}, { once: true });
export function initQuickCssThemeStore() {
if (IS_USERSCRIPT) return;
initThemes();
let currentTheme = ThemeStore.theme;

View file

@ -53,3 +53,11 @@ export function chooseFile(mimeTypes: string) {
setImmediate(() => document.body.removeChild(input));
});
}
export function getStylusWebStoreUrl() {
const isChromium = (navigator as any).userAgentData?.brands?.some(b => b.brand === "Chromium");
return isChromium
? "https://chromewebstore.google.com/detail/stylus/clngdbkpkpeebahjckkjfobafhncgmne"
: "https://addons.mozilla.org/firefox/addon/styl-us/";
}

View file

@ -141,7 +141,7 @@ export const UserUtils = {
export const UploadManager = findByPropsLazy("clearAll", "addFile");
export const UploadHandler = {
promptToUpload: findByCodeLazy("#{intl::ATTACHMENT_TOO_MANY_ERROR_TITLE}") as (files: File[], channel: Channel, draftType: Number) => void
promptToUpload: findByCodeLazy("=!0,showLargeMessageDialog:") as (files: File[], channel: Channel, draftType: Number) => void
};
export const ApplicationAssetUtils = mapMangledModuleLazy("getAssetImage: size must === [", {