Merge branch 'Vendicated:main' into main

This commit is contained in:
camila 2023-12-15 12:26:21 -06:00 committed by GitHub
commit 1d3579d788
No known key found for this signature in database
GPG key ID: 4AEE18F83AFDEB23
80 changed files with 2324 additions and 203 deletions

View file

@ -1,9 +1,6 @@
name: test
on:
push:
branches:
- main
- dev
pull_request:
branches:
- main

View file

@ -1,7 +1,7 @@
{
"editor.formatOnSave": true,
"editor.codeActionsOnSave": {
"source.fixAll.eslint": true
"source.fixAll.eslint": "explicit"
},
"[typescript]": {
"editor.defaultFormatter": "vscode.typescript-language-features"

View file

@ -1,7 +1,7 @@
{
"name": "vencord",
"private": "true",
"version": "1.6.4",
"version": "1.6.5",
"description": "The cutest Discord client mod",
"homepage": "https://github.com/Vendicated/Vencord#readme",
"bugs": {
@ -17,7 +17,7 @@
"doc": "docs"
},
"scripts": {
"build": "node scripts/build/build.mjs",
"build": "node --require=./scripts/suppressExperimentalWarnings.js scripts/build/build.mjs",
"buildWeb": "node --require=./scripts/suppressExperimentalWarnings.js scripts/build/buildWeb.mjs",
"generatePluginJson": "tsx scripts/generatePluginList.ts",
"inject": "node scripts/runInstaller.mjs",
@ -28,7 +28,7 @@
"testWeb": "pnpm lint && pnpm buildWeb && pnpm testTsc",
"testTsc": "tsc --noEmit",
"uninject": "node scripts/runInstaller.mjs",
"watch": "node scripts/build/build.mjs --watch"
"watch": "node --require=./scripts/suppressExperimentalWarnings.js scripts/build/build.mjs --watch"
},
"dependencies": {
"@sapphi-red/web-noise-suppressor": "0.3.3",
@ -68,7 +68,8 @@
"tsx": "^3.12.7",
"type-fest": "^3.9.0",
"typescript": "^5.0.4",
"zip-local": "^0.3.5"
"zip-local": "^0.3.5",
"zustand": "^3.7.2"
},
"packageManager": "pnpm@8.10.2",
"pnpm": {

View file

@ -1,9 +1,5 @@
lockfileVersion: '6.0'
settings:
autoInstallPeers: true
excludeLinksFromLockfile: false
patchedDependencies:
eslint-plugin-path-alias@1.0.0:
hash: m6sma4g6bh67km3q6igf6uxaja
@ -123,6 +119,9 @@ devDependencies:
zip-local:
specifier: ^0.3.5
version: 0.3.5
zustand:
specifier: ^3.7.2
version: 3.7.2
packages:
@ -3450,8 +3449,22 @@ packages:
q: 1.5.1
dev: true
/zustand@3.7.2:
resolution: {integrity: sha512-PIJDIZKtokhof+9+60cpockVOq05sJzHCriyvaLBmEJixseQ1a5Kdov6fWZfWOu5SK9c+FhH1jU0tntLxRJYMA==}
engines: {node: '>=12.7.0'}
peerDependencies:
react: '>=16.8'
peerDependenciesMeta:
react:
optional: true
dev: true
github.com/mattdesl/gifenc/64842fca317b112a8590f8fef2bf3825da8f6fe3:
resolution: {tarball: https://codeload.github.com/mattdesl/gifenc/tar.gz/64842fca317b112a8590f8fef2bf3825da8f6fe3}
name: gifenc
version: 1.0.3
dev: false
settings:
autoInstallPeers: true
excludeLinksFromLockfile: false

View file

@ -76,7 +76,11 @@ const globNativesPlugin = {
if (!await existsAsync(dirPath)) continue;
const plugins = await readdir(dirPath);
for (const p of plugins) {
if (!await existsAsync(join(dirPath, p, "native.ts"))) continue;
const nativePath = join(dirPath, p, "native.ts");
const indexNativePath = join(dirPath, p, "native/index.ts");
if (!(await existsAsync(nativePath)) && !(await existsAsync(indexNativePath)))
continue;
const nameParts = p.split(".");
const namePartsWithoutTarget = nameParts.length === 1 ? nameParts : nameParts.slice(0, -1);

View file

@ -105,7 +105,14 @@ async function printReport() {
console.log();
report.otherErrors = report.otherErrors.filter(e => !IGNORED_DISCORD_ERRORS.some(regex => e.match(regex)));
const ignoredErrors = [] as string[];
report.otherErrors = report.otherErrors.filter(e => {
if (IGNORED_DISCORD_ERRORS.some(regex => e.match(regex))) {
ignoredErrors.push(e);
return false;
}
return true;
});
console.log("## Discord Errors");
report.otherErrors.forEach(e => {
@ -114,6 +121,13 @@ async function printReport() {
console.log();
console.log("## Ignored Discord Errors");
ignoredErrors.forEach(e => {
console.log(`- ${toCodeBlock(e)}`);
});
console.log();
if (process.env.DISCORD_WEBHOOK) {
await fetch(process.env.DISCORD_WEBHOOK, {
method: "POST",
@ -123,7 +137,7 @@ async function printReport() {
body: JSON.stringify({
description: "Here's the latest Vencord Report!",
username: "Vencord Reporter" + (CANARY ? " (Canary)" : ""),
avatar_url: "https://cdn.discordapp.com/icons/1015060230222131221/6101cff21e241cebb60c4a01563d0c01.webp?size=512",
avatar_url: "https://cdn.discordapp.com/avatars/1017176847865352332/c312b6b44179ae6817de7e4b09e9c6af.webp?size=512",
embeds: [
{
title: "Bad Patches",
@ -197,9 +211,12 @@ page.on("console", async e => {
switch (tag) {
case "WebpackInterceptor:":
const patchFailMatch = message.match(/Patch by (.+?) (had no effect|errored|found no module) \(Module id is (.+?)\): (.+)/)!;
if (!patchFailMatch) break;
process.exitCode = 1;
const [, plugin, type, id, regex] = message.match(/Patch by (.+?) (had no effect|errored|found no module) \(Module id is (.+?)\): (.+)/)!;
const [, plugin, type, id, regex] = patchFailMatch;
report.badPatches.push({
plugin,
type,
@ -239,7 +256,7 @@ page.on("console", async e => {
).then(a => a.join(" ").trim());
if (text.length && !text.startsWith("Failed to load resource: the server responded with a status of") && !text.includes("found no module Filter:")) {
if (text.length && !text.startsWith("Failed to load resource: the server responded with a status of") && !text.includes("Webpack")) {
console.error("[Unexpected Error]", text);
report.otherErrors.push(text);
}
@ -279,6 +296,7 @@ function runTime(token: string) {
p.patches?.forEach(patch => {
patch.plugin = p.name;
delete patch.predicate;
delete patch.group;
if (!Array.isArray(patch.replacement))
patch.replacement = [patch.replacement];
@ -321,15 +339,15 @@ function runTime(token: string) {
await (wreq as any).el(sym);
delete Object.prototype[sym];
const validChunksEntryPoints = [] as string[];
const validChunks = [] as string[];
const invalidChunks = [] as string[];
const validChunksEntryPoints = new Set<string>();
const validChunks = new Set<string>();
const invalidChunks = new Set<string>();
if (!chunks) throw new Error("Failed to get chunks");
chunksLoop:
for (const entryPoint in chunks) {
const chunkIds = chunks[entryPoint];
let invalidEntryPoint = false;
for (const id of chunkIds) {
if (!wreq.u(id)) continue;
@ -339,14 +357,16 @@ function runTime(token: string) {
.then(t => t.includes(".module.wasm") || !t.includes("(this.webpackChunkdiscord_app=this.webpackChunkdiscord_app||[]).push"));
if (isWasm) {
invalidChunks.push(id);
continue chunksLoop;
invalidChunks.add(id);
invalidEntryPoint = true;
continue;
}
validChunks.push(id);
validChunks.add(id);
}
validChunksEntryPoints.push(entryPoint);
if (!invalidEntryPoint)
validChunksEntryPoints.add(entryPoint);
}
for (const entryPoint of validChunksEntryPoints) {
@ -359,7 +379,7 @@ function runTime(token: string) {
const allChunks = Function("return " + (wreq.u.toString().match(/(?<=\()\{.+?\}/s)?.[0] ?? "null"))() as Record<string | number, string[]> | null;
if (!allChunks) throw new Error("Failed to get all chunks");
const chunksLeft = Object.keys(allChunks).filter(id => {
return !(validChunks.includes(id) || invalidChunks.includes(id));
return !(validChunks.has(id) || invalidChunks.has(id));
});
for (const id of chunksLeft) {
@ -406,15 +426,21 @@ function runTime(token: string) {
if (method === "proxyLazyWebpack" || method === "LazyComponentWebpack") {
const [factory] = args;
result = factory();
} else if (method === "extractAndLoadChunks") {
const [code, matcher] = args;
const module = Vencord.Webpack.findModuleFactory(...code);
if (module) result = module.toString().match(Vencord.Util.canonicalizeMatch(matcher));
} else {
// @ts-ignore
result = Vencord.Webpack[method](...args);
}
if (result == null || ("$$get" in result && result.$$get() == null)) throw "a rock at ben shapiro";
if (result == null || ("$$vencordInternal" in result && result.$$vencordInternal() == null)) throw "a rock at ben shapiro";
} catch (e) {
let logMessage = searchType;
if (method === "find" || method === "proxyLazyWebpack" || method === "LazyComponentWebpack") logMessage += `(${args[0].toString().slice(0, 147)}...)`;
else if (method === "extractAndLoadChunks") logMessage += `([${args[0].map(arg => `"${arg}"`).join(", ")}], ${args[1].toString()})`;
else logMessage += `(${args.map(arg => `"${arg}"`).join(", ")})`;
console.log("[PUP_WEBPACK_FIND_FAIL]", logMessage);

View file

@ -18,14 +18,13 @@
import { mergeDefaults } from "@utils/misc";
import { findByPropsLazy } from "@webpack";
import { SnowflakeUtils } from "@webpack/common";
import { MessageActions, SnowflakeUtils } from "@webpack/common";
import { Message } from "discord-types/general";
import type { PartialDeep } from "type-fest";
import { Argument } from "./types";
const MessageCreator = findByPropsLazy("createBotMessage");
const MessageSender = findByPropsLazy("receiveMessage");
export function generateId() {
return `-${SnowflakeUtils.fromTimestamp(Date.now())}`;
@ -40,7 +39,7 @@ export function generateId() {
export function sendBotMessage(channelId: string, message: PartialDeep<Message>): Message {
const botMessage = MessageCreator.createBotMessage({ channelId, content: "", embeds: [] });
MessageSender.receiveMessage(channelId, mergeDefaults(message, botMessage));
MessageActions.receiveMessage(channelId, mergeDefaults(message, botMessage));
return message as Message;
}

View file

@ -38,7 +38,21 @@ export interface Settings {
frameless: boolean;
transparent: boolean;
winCtrlQ: boolean;
macosTranslucency: boolean;
macosVibrancyStyle:
| "content"
| "fullscreen-ui"
| "header"
| "hud"
| "menu"
| "popover"
| "selection"
| "sidebar"
| "titlebar"
| "tooltip"
| "under-page"
| "window"
| undefined;
macosTranslucency: boolean | undefined;
disableMinSize: boolean;
winNativeTitleBar: boolean;
plugins: {
@ -74,7 +88,9 @@ const DefaultSettings: Settings = {
frameless: false,
transparent: false,
winCtrlQ: false,
macosTranslucency: false,
// Replaced by macosVibrancyStyle
macosTranslucency: undefined,
macosVibrancyStyle: undefined,
disableMinSize: false,
winNativeTitleBar: false,
plugins: {},

View file

@ -255,3 +255,38 @@ export function DeleteIcon(props: IconProps) {
</Icon>
);
}
export function PlusIcon(props: IconProps) {
return (
<Icon
{...props}
className={classes(props.className, "vc-plus-icon")}
viewBox="0 0 18 18"
>
<polygon
fill-rule="nonzero"
fill="currentColor"
points="15 10 10 10 10 15 8 15 8 10 3 10 3 8 8 8 8 3 10 3 10 8 15 8"
/>
</Icon>
);
}
export function NoEntrySignIcon(props: IconProps) {
return (
<Icon
{...props}
className={classes(props.className, "vc-no-entry-sign-icon")}
viewBox="0 0 24 24"
>
<path
d="M0 0h24v24H0z"
fill="none"
/>
<path
fill="currentColor"
d="M12 2C6.48 2 2 6.48 2 12s4.48 10 10 10 10-4.48 10-10S17.52 2 12 2zm0 18c-4.42 0-8-3.58-8-8 0-1.85.63-3.55 1.69-4.9L16.9 18.31C15.55 19.37 13.85 20 12 20zm6.31-3.1L7.1 5.69C8.45 4.63 10.15 4 12 4c4.42 0 8 3.58 8 8 0 1.85-.63 3.55-1.69 4.9z"
/>
</Icon>
);
}

View file

@ -108,7 +108,7 @@ function ReplacementComponent({ module, match, replacement, setReplacementError
function renderDiff() {
return diff?.map(p => {
const color = p.added ? "lime" : p.removed ? "red" : "grey";
return <div style={{ color, userSelect: "text" }}>{p.value}</div>;
return <div style={{ color, userSelect: "text", wordBreak: "break-all", lineBreak: "anywhere" }}>{p.value}</div>;
});
}

View file

@ -21,12 +21,13 @@ import { classNameFactory } from "@api/Styles";
import { Flex } from "@components/Flex";
import { DeleteIcon } from "@components/Icons";
import { Link } from "@components/Link";
import { openInviteModal } from "@utils/discord";
import { Margins } from "@utils/margins";
import { classes } from "@utils/misc";
import { showItemInFolder } from "@utils/native";
import { useAwaiter } from "@utils/react";
import { findByPropsLazy, findLazy } from "@webpack";
import { Button, Card, FluxDispatcher, Forms, React, showToast, TabBar, TextArea, useEffect, useRef, useState } from "@webpack/common";
import { Button, Card, Forms, React, showToast, TabBar, TextArea, useEffect, useRef, useState } from "@webpack/common";
import { UserThemeHeader } from "main/themes";
import type { ComponentType, Ref, SyntheticEvent } from "react";
@ -125,15 +126,7 @@ function ThemeCard({ theme, enabled, onChange, onDelete }: ThemeCardProps) {
href={`https://discord.gg/${theme.invite}`}
onClick={async e => {
e.preventDefault();
const { invite } = await InviteActions.resolveInvite(theme.invite, "Desktop Modal");
if (!invite) return showToast("Invalid or expired invite");
FluxDispatcher.dispatch({
type: "INVITE_MODAL_OPEN",
invite,
code: theme.invite,
context: "APP"
});
theme.invite != null && openInviteModal(theme.invite).catch(() => showToast("Invalid or expired invite"));
}}
>
Discord Server

View file

@ -48,6 +48,15 @@ function VencordSettings() {
const isWindows = navigator.platform.toLowerCase().startsWith("win");
const isMac = navigator.platform.toLowerCase().startsWith("mac");
const needsVibrancySettings = IS_DISCORD_DESKTOP && isMac;
// One-time migration of the old setting to the new one if necessary.
React.useEffect(() => {
if (settings.macosTranslucency === true && !settings.macosVibrancyStyle) {
settings.macosVibrancyStyle = "sidebar";
settings.macosTranslucency = undefined;
}
}, []);
const Switches: Array<false | {
key: KeysOfType<typeof settings, boolean>;
@ -89,11 +98,6 @@ function VencordSettings() {
title: "Disable minimum window size",
note: "Requires a full restart"
},
IS_DISCORD_DESKTOP && isMac && {
key: "macosTranslucency",
title: "Enable translucent window",
note: "Requires a full restart"
}
];
return (
@ -152,6 +156,71 @@ function VencordSettings() {
</Forms.FormSection>
{needsVibrancySettings && <>
<Forms.FormTitle tag="h5">Window vibrancy style (requires restart)</Forms.FormTitle>
<Select
className={Margins.bottom20}
placeholder="Window vibrancy style"
options={[
// Sorted from most opaque to most transparent
{
label: "No vibrancy", default: !settings.macosTranslucency, value: undefined
},
{
label: "Under Page (window tinting)",
value: "under-page"
},
{
label: "Content",
value: "content"
},
{
label: "Window",
value: "window"
},
{
label: "Selection",
value: "selection"
},
{
label: "Titlebar",
value: "titlebar"
},
{
label: "Header",
value: "header"
},
{
label: "Sidebar (old value for transparent windows)",
value: "sidebar",
default: settings.macosTranslucency
},
{
label: "Tooltip",
value: "tooltip"
},
{
label: "Menu",
value: "menu"
},
{
label: "Popover",
value: "popover"
},
{
label: "Fullscreen UI (transparent but slightly muted)",
value: "fullscreen-ui"
},
{
label: "HUD (Most transparent)",
value: "hud"
},
]}
select={v => settings.macosVibrancyStyle = v}
isSelected={v => settings.macosVibrancyStyle === v}
serialize={identity} />
</>}
{typeof Notification !== "undefined" && <NotificationSection settings={settings.notifications} />}
</SettingsTab>
);

View file

@ -85,9 +85,15 @@ if (!IS_VANILLA) {
options.backgroundColor = "#00000000";
}
if (settings.macosTranslucency && process.platform === "darwin") {
const needsVibrancy = process.platform === "darwin" || (settings.macosVibrancyStyle || settings.macosTranslucency);
if (needsVibrancy) {
options.backgroundColor = "#00000000";
if (settings.macosTranslucency) {
options.vibrancy = "sidebar";
} else if (settings.macosVibrancyStyle) {
options.vibrancy = settings.macosVibrancyStyle;
}
}
process.env.DISCORD_PRELOAD = original;

View file

@ -22,14 +22,13 @@ import ErrorBoundary from "@components/ErrorBoundary";
import { Flex } from "@components/Flex";
import { Heart } from "@components/Heart";
import { Devs } from "@utils/constants";
import { Logger } from "@utils/Logger";
import { Margins } from "@utils/margins";
import { isPluginDev } from "@utils/misc";
import { closeModal, Modals, openModal } from "@utils/modal";
import definePlugin from "@utils/types";
import { Forms, Toasts } from "@webpack/common";
const CONTRIBUTOR_BADGE = "https://cdn.discordapp.com/attachments/1033680203433660458/1092089947126780035/favicon.png";
const CONTRIBUTOR_BADGE = "https://vencord.dev/assets/favicon.png";
const ContributorBadge: ProfileBadge = {
description: "Vencord Contributor",
@ -45,7 +44,7 @@ const ContributorBadge: ProfileBadge = {
link: "https://github.com/Vendicated/Vencord"
};
let DonorBadges = {} as Record<string, Pick<ProfileBadge, "image" | "description">[]>;
let DonorBadges = {} as Record<string, Array<Record<"tooltip" | "badge", string>>>;
async function loadBadges(noCache = false) {
DonorBadges = {};
@ -54,19 +53,8 @@ async function loadBadges(noCache = false) {
if (noCache)
init.cache = "no-cache";
const badges = await fetch("https://gist.githubusercontent.com/Vendicated/51a3dd775f6920429ec6e9b735ca7f01/raw/badges.csv", init)
.then(r => r.text());
const lines = badges.trim().split("\n");
if (lines.shift() !== "id,tooltip,image") {
new Logger("BadgeAPI").error("Invalid badges.csv file!");
return;
}
for (const line of lines) {
const [id, description, image] = line.split(",");
(DonorBadges[id] ??= []).push({ image, description });
}
DonorBadges = await fetch("https://badges.vencord.dev/badges.json", init)
.then(r => r.json());
}
export default definePlugin({
@ -127,7 +115,8 @@ export default definePlugin({
getDonorBadges(userId: string) {
return DonorBadges[userId]?.map(badge => ({
...badge,
image: badge.badge,
description: badge.tooltip,
position: BadgePosition.START,
props: {
style: {

View file

@ -46,6 +46,14 @@ export default definePlugin({
match: /(?<=\.activityEmoji,.+?animate:)\i/,
replace: "!0"
}
},
{
// Guild Banner
find: ".animatedBannerHoverLayer,onMouseEnter:",
replacement: {
match: /(?<=guildBanner:\i,animate:)\i(?=}\))/,
replace: "!0"
}
}
]
});

View file

@ -124,6 +124,18 @@ export const defaultRules = [
"t@*.x.com",
"s@*.x.com",
"ref_*@*.x.com",
"t@*.fixupx.com",
"s@*.fixupx.com",
"ref_*@*.fixupx.com",
"t@*.fxtwitter.com",
"s@*.fxtwitter.com",
"ref_*@*.fxtwitter.com",
"t@*.twittpr.com",
"s@*.twittpr.com",
"ref_*@*.twittpr.com",
"t@*.fixvx.com",
"s@*.fixvx.com",
"ref_*@*.fixvx.com",
"tt_medium",
"tt_content",
"lr@yandex.*",

View file

@ -15,7 +15,7 @@ import definePlugin, { OptionType, StartAt } from "@utils/types";
import { findComponentByCodeLazy } from "@webpack";
import { Button, Forms } from "@webpack/common";
const ColorPicker = findComponentByCodeLazy(".Messages.USER_SETTINGS_PROFILE_COLOR_SELECT_COLOR");
const ColorPicker = findComponentByCodeLazy(".Messages.USER_SETTINGS_PROFILE_COLOR_SELECT_COLOR", ".BACKGROUND_PRIMARY)");
const colorPresets = [
"#1E1514", "#172019", "#13171B", "#1C1C28", "#402D2D",

View file

@ -63,6 +63,7 @@ export default definePlugin({
let fakeRenderWin: WeakRef<Window> | undefined;
const find = newFindWrapper(f => f);
const findByProps = newFindWrapper(filters.byProps);
return {
...Vencord.Webpack.Common,
wp: Vencord.Webpack,
@ -73,13 +74,13 @@ export default definePlugin({
wpexs: (code: string) => extract(Webpack.findModuleId(code)!),
find,
findAll,
findByProps: newFindWrapper(filters.byProps),
findByProps,
findAllByProps: (...props: string[]) => findAll(filters.byProps(...props)),
findByCode: newFindWrapper(filters.byCode),
findAllByCode: (code: string) => findAll(filters.byCode(code)),
findComponentByCode: newFindWrapper(filters.componentByCode),
findAllComponentsByCode: (...code: string[]) => findAll(filters.componentByCode(...code)),
findExportedComponent: (...props: string[]) => find(...props)[props[0]],
findExportedComponent: (...props: string[]) => findByProps(...props)[props[0]],
findStore: newFindWrapper(filters.byStoreName),
PluginsApi: Vencord.Plugins,
plugins: Vencord.Plugins.plugins,

View file

@ -23,12 +23,26 @@ import { Logger } from "@utils/Logger";
import { closeAllModals } from "@utils/modal";
import definePlugin, { OptionType } from "@utils/types";
import { maybePromptToUpdate } from "@utils/updater";
import { findByPropsLazy } from "@webpack";
import { FluxDispatcher, NavigationRouter } from "@webpack/common";
import { filters, findBulk, proxyLazyWebpack } from "@webpack";
import { FluxDispatcher, NavigationRouter, SelectedChannelStore } from "@webpack/common";
import type { ReactElement } from "react";
const CrashHandlerLogger = new Logger("CrashHandler");
const ModalStack = findByPropsLazy("pushLazy", "popAll");
const { ModalStack, DraftManager, DraftType, closeExpressionPicker } = proxyLazyWebpack(() => {
const modules = findBulk(
filters.byProps("pushLazy", "popAll"),
filters.byProps("clearDraft", "saveDraft"),
filters.byProps("DraftType"),
filters.byProps("closeExpressionPicker", "openExpressionPicker"),
);
return {
ModalStack: modules[0],
DraftManager: modules[1],
DraftType: modules[2]?.DraftType,
closeExpressionPicker: modules[3]?.closeExpressionPicker,
};
});
const settings = definePluginSettings({
attemptToPreventCrashes: {
@ -115,13 +129,27 @@ export default definePlugin({
} catch { }
}
try {
const channelId = SelectedChannelStore.getChannelId();
DraftManager.clearDraft(channelId, DraftType.ChannelMessage);
DraftManager.clearDraft(channelId, DraftType.FirstThreadMessage);
} catch (err) {
CrashHandlerLogger.debug("Failed to clear drafts.", err);
}
try {
closeExpressionPicker();
}
catch (err) {
CrashHandlerLogger.debug("Failed to close expression picker.", err);
}
try {
FluxDispatcher.dispatch({ type: "CONTEXT_MENU_CLOSE" });
} catch (err) {
CrashHandlerLogger.debug("Failed to close open context menu.", err);
}
try {
ModalStack?.popAll();
ModalStack.popAll();
} catch (err) {
CrashHandlerLogger.debug("Failed to close old modals.", err);
}

View file

@ -60,7 +60,7 @@ async function embedDidMount(this: Component<Props>) {
if (hasTitle) {
embed.dearrow.oldTitle = embed.rawTitle;
embed.rawTitle = titles[0].title;
embed.rawTitle = titles[0].title.replace(/ >(\S)/g, " $1");
}
if (hasThumb) {

View file

@ -0,0 +1,17 @@
# Decor
Custom avatar decorations!
![Custom decorations in chat](https://github.com/Vendicated/Vencord/assets/30497388/b0c4c4c8-8723-42a8-b50f-195ad4e26136)
Create and use your own custom avatar decorations, or pick your favorite from the presets.
You'll be able to see the custom avatar decorations of other users of this plugin, and they'll be able to see your custom avatar decoration.
You can select and manage your custom avatar decorations under the "Profiles" page in settings, or in the plugin settings.
![Custom decorations management](https://github.com/Vendicated/Vencord/assets/30497388/74fe8a9e-a2a2-4b29-bc10-9eaa58208ad4)
Review the [guidelines](https://github.com/decor-discord/.github/blob/main/GUIDELINES.md) before creating your own custom avatar decoration.
Join the [Discord server](https://discord.gg/dXp2SdxDcP) for support and notifications on your decoration's review.

168
src/plugins/decor/index.tsx Normal file
View file

@ -0,0 +1,168 @@
/*
* Vencord, a Discord client mod
* Copyright (c) 2023 Vendicated, FieryFlames and contributors
* SPDX-License-Identifier: GPL-3.0-or-later
*/
import "./ui/styles.css";
import { definePluginSettings } from "@api/Settings";
import ErrorBoundary from "@components/ErrorBoundary";
import { Link } from "@components/Link";
import { Devs } from "@utils/constants";
import { Margins } from "@utils/margins";
import { classes } from "@utils/misc";
import { closeAllModals } from "@utils/modal";
import definePlugin, { OptionType } from "@utils/types";
import { findByPropsLazy } from "@webpack";
import { FluxDispatcher, Forms, UserStore } from "@webpack/common";
import { CDN_URL, RAW_SKU_ID, SKU_ID } from "./lib/constants";
import { useAuthorizationStore } from "./lib/stores/AuthorizationStore";
import { useCurrentUserDecorationsStore } from "./lib/stores/CurrentUserDecorationsStore";
import { useUserDecorAvatarDecoration, useUsersDecorationsStore } from "./lib/stores/UsersDecorationsStore";
import { setDecorationGridDecoration, setDecorationGridItem } from "./ui/components";
import DecorSection from "./ui/components/DecorSection";
const { isAnimatedAvatarDecoration } = findByPropsLazy("isAnimatedAvatarDecoration");
export interface AvatarDecoration {
asset: string;
skuId: string;
}
const settings = definePluginSettings({
changeDecoration: {
type: OptionType.COMPONENT,
description: "Change your avatar decoration",
component() {
return <div>
<DecorSection hideTitle hideDivider noMargin />
<Forms.FormText type="description" className={classes(Margins.top8, Margins.bottom8)}>
You can also access Decor decorations from the <Link
href="/settings/profile-customization"
onClick={e => {
e.preventDefault();
closeAllModals();
FluxDispatcher.dispatch({ type: "USER_SETTINGS_MODAL_SET_SECTION", section: "Profile Customization" });
}}
>Profiles</Link> page.
</Forms.FormText>
</div>;
}
}
});
export default definePlugin({
name: "Decor",
description: "Create and use your own custom avatar decorations, or pick your favorite from the presets.",
authors: [Devs.FieryFlames],
patches: [
// Patch MediaResolver to return correct URL for Decor avatar decorations
{
find: "getAvatarDecorationURL:",
replacement: {
match: /(?<=function \i\(\i\){)(?=let{avatarDecoration)/,
replace: "const vcDecorDecoration=$self.getDecorAvatarDecorationURL(arguments[0]);if(vcDecorDecoration)return vcDecorDecoration;"
}
},
// Patch profile customization settings to include Decor section
{
find: "DefaultCustomizationSections",
replacement: {
match: /(?<={user:\i},"decoration"\),)/,
replace: "$self.DecorSection(),"
}
},
// Decoration modal module
{
find: ".decorationGridItem",
replacement: [
{
match: /(?<==)\i=>{let{children.{20,100}decorationGridItem/,
replace: "$self.DecorationGridItem=$&"
},
{
match: /(?<==)\i=>{let{user:\i,avatarDecoration.{300,600}decorationGridItemChurned/,
replace: "$self.DecorationGridDecoration=$&"
},
// Remove NEW label from decor avatar decorations
{
match: /(?<=\.Section\.PREMIUM_PURCHASE&&\i;if\()(?<=avatarDecoration:(\i).+?)/,
replace: "$1.skuId===$self.SKU_ID||"
}
]
},
{
find: "isAvatarDecorationAnimating:",
group: true,
replacement: [
// Add Decor avatar decoration hook to avatar decoration hook
{
match: /(?<=TryItOut:\i}\),)(?<=user:(\i).+?)/,
replace: "vcDecorAvatarDecoration=$self.useUserDecorAvatarDecoration($1),"
},
// Use added hook
{
match: /(?<={avatarDecoration:).{1,20}?(?=,)(?<=avatarDecorationOverride:(\i).+?)/,
replace: "$1??vcDecorAvatarDecoration??($&)"
},
// Make memo depend on added hook
{
match: /(?<=size:\i}\),\[)/,
replace: "vcDecorAvatarDecoration,"
}
]
},
// Current user area, at bottom of channels/dm list
{
find: "renderAvatarWithPopout(){",
replacement: [
// Use Decor avatar decoration hook
{
match: /(?<=getAvatarDecorationURL\)\({avatarDecoration:)(\i).avatarDecoration(?=,)/,
replace: "$self.useUserDecorAvatarDecoration($1)??$&"
}
]
}
],
settings,
flux: {
CONNECTION_OPEN: () => {
useAuthorizationStore.getState().init();
useCurrentUserDecorationsStore.getState().clear();
useUsersDecorationsStore.getState().fetch(UserStore.getCurrentUser().id, true);
},
USER_PROFILE_MODAL_OPEN: data => {
useUsersDecorationsStore.getState().fetch(data.userId, true);
},
},
set DecorationGridItem(e: any) {
setDecorationGridItem(e);
},
set DecorationGridDecoration(e: any) {
setDecorationGridDecoration(e);
},
SKU_ID,
useUserDecorAvatarDecoration,
async start() {
useUsersDecorationsStore.getState().fetch(UserStore.getCurrentUser().id, true);
},
getDecorAvatarDecorationURL({ avatarDecoration, canAnimate }: { avatarDecoration: AvatarDecoration | null; canAnimate?: boolean; }) {
// Only Decor avatar decorations have this SKU ID
if (avatarDecoration?.skuId === SKU_ID) {
const url = new URL(`${CDN_URL}/${avatarDecoration.asset}.png`);
url.searchParams.set("animate", (!!canAnimate && isAnimatedAvatarDecoration(avatarDecoration.asset)).toString());
return url.toString();
} else if (avatarDecoration?.skuId === RAW_SKU_ID) {
return avatarDecoration.asset;
}
},
DecorSection: ErrorBoundary.wrap(DecorSection)
});

View file

@ -0,0 +1,83 @@
/*
* Vencord, a Discord client mod
* Copyright (c) 2023 Vendicated and contributors
* SPDX-License-Identifier: GPL-3.0-or-later
*/
import { API_URL } from "./constants";
import { useAuthorizationStore } from "./stores/AuthorizationStore";
export interface Preset {
id: string;
name: string;
description: string | null;
decorations: Decoration[];
authorIds: string[];
}
export interface Decoration {
hash: string;
animated: boolean;
alt: string | null;
authorId: string | null;
reviewed: boolean | null;
presetId: string | null;
}
export interface NewDecoration {
file: File;
alt: string | null;
}
export async function fetchApi(url: RequestInfo, options?: RequestInit) {
const res = await fetch(url, {
...options,
headers: {
...options?.headers,
Authorization: `Bearer ${useAuthorizationStore.getState().token}`
}
});
if (res.ok) return res;
else throw new Error(await res.text());
}
export const getUsersDecorations = async (ids?: string[]): Promise<Record<string, string | null>> => {
if (ids?.length === 0) return {};
const url = new URL(API_URL + "/users");
if (ids && ids.length !== 0) url.searchParams.set("ids", JSON.stringify(ids));
return await fetch(url).then(c => c.json());
};
export const getUserDecorations = async (id: string = "@me"): Promise<Decoration[]> =>
fetchApi(API_URL + `/users/${id}/decorations`).then(c => c.json());
export const getUserDecoration = async (id: string = "@me"): Promise<Decoration | null> =>
fetchApi(API_URL + `/users/${id}/decoration`).then(c => c.json());
export const setUserDecoration = async (decoration: Decoration | NewDecoration | null, id: string = "@me"): Promise<string | Decoration> => {
const formData = new FormData();
if (!decoration) {
formData.append("hash", "null");
} else if ("hash" in decoration) {
formData.append("hash", decoration.hash);
} else if ("file" in decoration) {
formData.append("image", decoration.file);
formData.append("alt", decoration.alt ?? "null");
}
return fetchApi(API_URL + `/users/${id}/decoration`, { method: "PUT", body: formData }).then(c =>
decoration && "file" in decoration ? c.json() : c.text()
);
};
export const getDecoration = async (hash: string): Promise<Decoration> => fetch(API_URL + `/decorations/${hash}`).then(c => c.json());
export const deleteDecoration = async (hash: string): Promise<void> => {
await fetchApi(API_URL + `/decorations/${hash}`, { method: "DELETE" });
};
export const getPresets = async (): Promise<Preset[]> => fetch(API_URL + "/decorations/presets").then(c => c.json());

View file

@ -0,0 +1,16 @@
/*
* Vencord, a Discord client mod
* Copyright (c) 2023 Vendicated and contributors
* SPDX-License-Identifier: GPL-3.0-or-later
*/
export const BASE_URL = "https://decor.fieryflames.dev";
export const API_URL = BASE_URL + "/api";
export const AUTHORIZE_URL = API_URL + "/authorize";
export const CDN_URL = "https://ugc.decor.fieryflames.dev";
export const CLIENT_ID = "1096966363416899624";
export const SKU_ID = "100101099111114"; // decor in ascii numbers
export const RAW_SKU_ID = "11497119"; // raw in ascii numbers
export const GUILD_ID = "1096357702931841148";
export const INVITE_KEY = "dXp2SdxDcP";
export const DECORATION_FETCH_COOLDOWN = 1000 * 60 * 60 * 4; // 4 hours

View file

@ -0,0 +1,102 @@
/*
* Vencord, a Discord client mod
* Copyright (c) 2023 Vendicated and contributors
* SPDX-License-Identifier: GPL-3.0-or-later
*/
import { DataStore } from "@api/index";
import { proxyLazy } from "@utils/lazy";
import { Logger } from "@utils/Logger";
import { openModal } from "@utils/modal";
import { OAuth2AuthorizeModal, showToast, Toasts, UserStore, zustandCreate, zustandPersist } from "@webpack/common";
import type { StateStorage } from "zustand/middleware";
import { AUTHORIZE_URL, CLIENT_ID } from "../constants";
interface AuthorizationState {
token: string | null;
tokens: Record<string, string>;
init: () => void;
authorize: () => Promise<void>;
setToken: (token: string) => void;
remove: (id: string) => void;
isAuthorized: () => boolean;
}
const indexedDBStorage: StateStorage = {
async getItem(name: string): Promise<string | null> {
return DataStore.get(name).then(v => v ?? null);
},
async setItem(name: string, value: string): Promise<void> {
await DataStore.set(name, value);
},
async removeItem(name: string): Promise<void> {
await DataStore.del(name);
},
};
// TODO: Move switching accounts subscription inside the store?
export const useAuthorizationStore = proxyLazy(() => zustandCreate<AuthorizationState>(
zustandPersist(
(set, get) => ({
token: null,
tokens: {},
init: () => { set({ token: get().tokens[UserStore.getCurrentUser().id] ?? null }); },
setToken: (token: string) => set({ token, tokens: { ...get().tokens, [UserStore.getCurrentUser().id]: token } }),
remove: (id: string) => {
const { tokens, init } = get();
const newTokens = { ...tokens };
delete newTokens[id];
set({ tokens: newTokens });
init();
},
async authorize() {
return new Promise((resolve, reject) => openModal(props =>
<OAuth2AuthorizeModal
{...props}
scopes={["identify"]}
responseType="code"
redirectUri={AUTHORIZE_URL}
permissions={0n}
clientId={CLIENT_ID}
cancelCompletesFlow={false}
callback={async (response: any) => {
try {
const url = new URL(response.location);
url.searchParams.append("client", "vencord");
const req = await fetch(url);
if (req?.ok) {
const token = await req.text();
get().setToken(token);
} else {
throw new Error("Request not OK");
}
resolve(void 0);
} catch (e) {
if (e instanceof Error) {
showToast(`Failed to authorize: ${e.message}`, Toasts.Type.FAILURE);
new Logger("Decor").error("Failed to authorize", e);
reject(e);
}
}
}}
/>, {
onCloseCallback() {
reject(new Error("Authorization cancelled"));
},
}
));
},
isAuthorized: () => !!get().token,
}),
{
name: "decor-auth",
getStorage: () => indexedDBStorage,
partialize: state => ({ tokens: state.tokens }),
onRehydrateStorage: () => state => state?.init()
}
)
));

View file

@ -0,0 +1,56 @@
/*
* Vencord, a Discord client mod
* Copyright (c) 2023 Vendicated and contributors
* SPDX-License-Identifier: GPL-3.0-or-later
*/
import { proxyLazy } from "@utils/lazy";
import { UserStore, zustandCreate } from "@webpack/common";
import { Decoration, deleteDecoration, getUserDecoration, getUserDecorations, NewDecoration, setUserDecoration } from "../api";
import { decorationToAsset } from "../utils/decoration";
import { useUsersDecorationsStore } from "./UsersDecorationsStore";
interface UserDecorationsState {
decorations: Decoration[];
selectedDecoration: Decoration | null;
fetch: () => Promise<void>;
delete: (decoration: Decoration | string) => Promise<void>;
create: (decoration: NewDecoration) => Promise<void>;
select: (decoration: Decoration | null) => Promise<void>;
clear: () => void;
}
export const useCurrentUserDecorationsStore = proxyLazy(() => zustandCreate<UserDecorationsState>((set, get) => ({
decorations: [],
selectedDecoration: null,
async fetch() {
const decorations = await getUserDecorations();
const selectedDecoration = await getUserDecoration();
set({ decorations, selectedDecoration });
},
async create(newDecoration: NewDecoration) {
const decoration = (await setUserDecoration(newDecoration)) as Decoration;
set({ decorations: [...get().decorations, decoration] });
},
async delete(decoration: Decoration | string) {
const hash = typeof decoration === "object" ? decoration.hash : decoration;
await deleteDecoration(hash);
const { selectedDecoration, decorations } = get();
const newState = {
decorations: decorations.filter(d => d.hash !== hash),
selectedDecoration: selectedDecoration?.hash === hash ? null : selectedDecoration
};
set(newState);
},
async select(decoration: Decoration | null) {
if (get().selectedDecoration === decoration) return;
set({ selectedDecoration: decoration });
setUserDecoration(decoration);
useUsersDecorationsStore.getState().set(UserStore.getCurrentUser().id, decoration ? decorationToAsset(decoration) : null);
},
clear: () => set({ decorations: [], selectedDecoration: null })
})));

View file

@ -0,0 +1,118 @@
/*
* Vencord, a Discord client mod
* Copyright (c) 2023 Vendicated and contributors
* SPDX-License-Identifier: GPL-3.0-or-later
*/
import { debounce } from "@utils/debounce";
import { proxyLazy } from "@utils/lazy";
import { useEffect, useState, zustandCreate } from "@webpack/common";
import { User } from "discord-types/general";
import { AvatarDecoration } from "../../";
import { getUsersDecorations } from "../api";
import { DECORATION_FETCH_COOLDOWN, SKU_ID } from "../constants";
interface UserDecorationData {
asset: string | null;
fetchedAt: Date;
}
interface UsersDecorationsState {
usersDecorations: Map<string, UserDecorationData>;
fetchQueue: Set<string>;
bulkFetch: () => Promise<void>;
fetch: (userId: string, force?: boolean) => Promise<void>;
fetchMany: (userIds: string[]) => Promise<void>;
get: (userId: string) => UserDecorationData | undefined;
getAsset: (userId: string) => string | null | undefined;
has: (userId: string) => boolean;
set: (userId: string, decoration: string | null) => void;
}
export const useUsersDecorationsStore = proxyLazy(() => zustandCreate<UsersDecorationsState>((set, get) => ({
usersDecorations: new Map<string, UserDecorationData>(),
fetchQueue: new Set(),
bulkFetch: debounce(async () => {
const { fetchQueue, usersDecorations } = get();
if (fetchQueue.size === 0) return;
set({ fetchQueue: new Set() });
const fetchIds = Array.from(fetchQueue);
const fetchedUsersDecorations = await getUsersDecorations(fetchIds);
const newUsersDecorations = new Map(usersDecorations);
const now = new Date();
for (const fetchId of fetchIds) {
const newDecoration = fetchedUsersDecorations[fetchId] ?? null;
newUsersDecorations.set(fetchId, { asset: newDecoration, fetchedAt: now });
}
set({ usersDecorations: newUsersDecorations });
}),
async fetch(userId: string, force: boolean = false) {
const { usersDecorations, fetchQueue, bulkFetch } = get();
const { fetchedAt } = usersDecorations.get(userId) ?? {};
if (fetchedAt) {
if (!force && Date.now() - fetchedAt.getTime() < DECORATION_FETCH_COOLDOWN) return;
}
set({ fetchQueue: new Set(fetchQueue).add(userId) });
bulkFetch();
},
async fetchMany(userIds) {
if (!userIds.length) return;
const { usersDecorations, fetchQueue, bulkFetch } = get();
const newFetchQueue = new Set(fetchQueue);
const now = Date.now();
for (const userId of userIds) {
const { fetchedAt } = usersDecorations.get(userId) ?? {};
if (fetchedAt) {
if (now - fetchedAt.getTime() < DECORATION_FETCH_COOLDOWN) continue;
}
newFetchQueue.add(userId);
}
set({ fetchQueue: newFetchQueue });
bulkFetch();
},
get(userId: string) { return get().usersDecorations.get(userId); },
getAsset(userId: string) { return get().usersDecorations.get(userId)?.asset; },
has(userId: string) { return get().usersDecorations.has(userId); },
set(userId: string, decoration: string | null) {
const { usersDecorations } = get();
const newUsersDecorations = new Map(usersDecorations);
newUsersDecorations.set(userId, { asset: decoration, fetchedAt: new Date() });
set({ usersDecorations: newUsersDecorations });
}
})));
export function useUserDecorAvatarDecoration(user?: User): AvatarDecoration | null | undefined {
const [decorAvatarDecoration, setDecorAvatarDecoration] = useState<string | null>(user ? useUsersDecorationsStore.getState().getAsset(user.id) ?? null : null);
useEffect(() => {
const destructor = useUsersDecorationsStore.subscribe(
state => {
if (!user) return;
const newDecorAvatarDecoration = state.getAsset(user.id);
if (!newDecorAvatarDecoration) return;
if (decorAvatarDecoration !== newDecorAvatarDecoration) setDecorAvatarDecoration(newDecorAvatarDecoration);
}
);
if (user) {
const { fetch: fetchUserDecorAvatarDecoration } = useUsersDecorationsStore.getState();
fetchUserDecorAvatarDecoration(user.id);
}
return destructor;
}, []);
return decorAvatarDecoration ? { asset: decorAvatarDecoration, skuId: SKU_ID } : null;
}

View file

@ -0,0 +1,17 @@
/*
* Vencord, a Discord client mod
* Copyright (c) 2023 Vendicated and contributors
* SPDX-License-Identifier: GPL-3.0-or-later
*/
import { AvatarDecoration } from "../../";
import { Decoration } from "../api";
import { SKU_ID } from "../constants";
export function decorationToAsset(decoration: Decoration) {
return `${decoration.animated ? "a_" : ""}${decoration.hash}`;
}
export function decorationToAvatarDecoration(decoration: Decoration): AvatarDecoration {
return { asset: decorationToAsset(decoration), skuId: SKU_ID };
}

View file

@ -0,0 +1,35 @@
/*
* Vencord, a Discord client mod
* Copyright (c) 2023 Vendicated and contributors
* SPDX-License-Identifier: GPL-3.0-or-later
*/
import { ContextMenuApi } from "@webpack/common";
import type { HTMLProps } from "react";
import { Decoration } from "../../lib/api";
import { decorationToAvatarDecoration } from "../../lib/utils/decoration";
import { DecorationGridDecoration } from ".";
import DecorationContextMenu from "./DecorationContextMenu";
interface DecorDecorationGridDecorationProps extends HTMLProps<HTMLDivElement> {
decoration: Decoration;
isSelected: boolean;
onSelect: () => void;
}
export default function DecorDecorationGridDecoration(props: DecorDecorationGridDecorationProps) {
const { decoration } = props;
return <DecorationGridDecoration
{...props}
onContextMenu={e => {
ContextMenuApi.openContextMenu(e, () => (
<DecorationContextMenu
decoration={decoration}
/>
));
}}
avatarDecoration={decorationToAvatarDecoration(decoration)}
/>;
}

View file

@ -0,0 +1,59 @@
/*
* Vencord, a Discord client mod
* Copyright (c) 2023 Vendicated and contributors
* SPDX-License-Identifier: GPL-3.0-or-later
*/
import { Flex } from "@components/Flex";
import { findByCodeLazy } from "@webpack";
import { Button, useEffect } from "@webpack/common";
import { useAuthorizationStore } from "../../lib/stores/AuthorizationStore";
import { useCurrentUserDecorationsStore } from "../../lib/stores/CurrentUserDecorationsStore";
import { cl } from "../";
import { openChangeDecorationModal } from "../modals/ChangeDecorationModal";
const CustomizationSection = findByCodeLazy(".customizationSectionBackground");
interface DecorSectionProps {
hideTitle?: boolean;
hideDivider?: boolean;
noMargin?: boolean;
}
export default function DecorSection({ hideTitle = false, hideDivider = false, noMargin = false }: DecorSectionProps) {
const authorization = useAuthorizationStore();
const { selectedDecoration, select: selectDecoration, fetch: fetchDecorations } = useCurrentUserDecorationsStore();
useEffect(() => {
if (authorization.isAuthorized()) fetchDecorations();
}, [authorization.token]);
return <CustomizationSection
title={!hideTitle && "Decor"}
hasBackground={true}
hideDivider={hideDivider}
className={noMargin && cl("section-remove-margin")}
>
<Flex>
<Button
onClick={() => {
if (!authorization.isAuthorized()) {
authorization.authorize().then(openChangeDecorationModal).catch(() => { });
} else openChangeDecorationModal();
}}
size={Button.Sizes.SMALL}
>
Change Decoration
</Button>
{selectedDecoration && authorization.isAuthorized() && <Button
onClick={() => selectDecoration(null)}
color={Button.Colors.PRIMARY}
size={Button.Sizes.SMALL}
look={Button.Looks.LINK}
>
Remove Decoration
</Button>}
</Flex>
</CustomizationSection>;
}

View file

@ -0,0 +1,47 @@
/*
* Vencord, a Discord client mod
* Copyright (c) 2023 Vendicated and contributors
* SPDX-License-Identifier: GPL-3.0-or-later
*/
import { CopyIcon, DeleteIcon } from "@components/Icons";
import { Alerts, Clipboard, ContextMenuApi, Menu, UserStore } from "webpack/common";
import { Decoration } from "../../lib/api";
import { useCurrentUserDecorationsStore } from "../../lib/stores/CurrentUserDecorationsStore";
import { cl } from "../";
export default function DecorationContextMenu({ decoration }: { decoration: Decoration; }) {
const { delete: deleteDecoration } = useCurrentUserDecorationsStore();
return <Menu.Menu
navId={cl("decoration-context-menu")}
onClose={ContextMenuApi.closeContextMenu}
aria-label="Decoration Options"
>
<Menu.MenuItem
id={cl("decoration-context-menu-copy-hash")}
label="Copy Decoration Hash"
icon={CopyIcon}
action={() => Clipboard.copy(decoration.hash)}
/>
{decoration.authorId === UserStore.getCurrentUser().id &&
<Menu.MenuItem
id={cl("decoration-context-menu-delete")}
label="Delete Decoration"
color="danger"
icon={DeleteIcon}
action={() => Alerts.show({
title: "Delete Decoration",
body: `Are you sure you want to delete ${decoration.alt}?`,
confirmText: "Delete",
confirmColor: cl("danger-btn"),
cancelText: "Cancel",
onConfirm() {
deleteDecoration(decoration);
}
})}
/>
}
</Menu.Menu>;
}

View file

@ -0,0 +1,30 @@
/*
* Vencord, a Discord client mod
* Copyright (c) 2023 Vendicated and contributors
* SPDX-License-Identifier: GPL-3.0-or-later
*/
import { PlusIcon } from "@components/Icons";
import { i18n, Text } from "@webpack/common";
import { HTMLProps } from "react";
import { DecorationGridItem } from ".";
type DecorationGridCreateProps = HTMLProps<HTMLDivElement> & {
onSelect: () => void;
};
export default function DecorationGridCreate(props: DecorationGridCreateProps) {
return <DecorationGridItem
{...props}
isSelected={false}
>
<PlusIcon />
<Text
variant="text-xs/normal"
color="header-primary"
>
{i18n.Messages.CREATE}
</Text>
</DecorationGridItem >;
}

View file

@ -0,0 +1,30 @@
/*
* Vencord, a Discord client mod
* Copyright (c) 2023 Vendicated and contributors
* SPDX-License-Identifier: GPL-3.0-or-later
*/
import { NoEntrySignIcon } from "@components/Icons";
import { i18n, Text } from "@webpack/common";
import { HTMLProps } from "react";
import { DecorationGridItem } from ".";
type DecorationGridNoneProps = HTMLProps<HTMLDivElement> & {
isSelected: boolean;
onSelect: () => void;
};
export default function DecorationGridNone(props: DecorationGridNoneProps) {
return <DecorationGridItem
{...props}
>
<NoEntrySignIcon />
<Text
variant="text-xs/normal"
color="header-primary"
>
{i18n.Messages.NONE}
</Text>
</DecorationGridItem >;
}

View file

@ -0,0 +1,28 @@
/*
* Vencord, a Discord client mod
* Copyright (c) 2023 Vendicated and contributors
* SPDX-License-Identifier: GPL-3.0-or-later
*/
import { React } from "@webpack/common";
import { cl } from "../";
export interface GridProps<ItemT> {
renderItem: (item: ItemT) => JSX.Element;
getItemKey: (item: ItemT) => string;
itemKeyPrefix?: string;
items: Array<ItemT>;
}
export default function Grid<ItemT,>({ renderItem, getItemKey, itemKeyPrefix: ikp, items }: GridProps<ItemT>) {
return <div className={cl("sectioned-grid-list-grid")}>
{items.map(item =>
<React.Fragment
key={`${ikp ? `${ikp}-` : ""}${getItemKey(item)}`}
>
{renderItem(item)}
</React.Fragment>
)}
</div>;
}

View file

@ -0,0 +1,38 @@
/*
* Vencord, a Discord client mod
* Copyright (c) 2023 Vendicated and contributors
* SPDX-License-Identifier: GPL-3.0-or-later
*/
import { classes } from "@utils/misc";
import { findByPropsLazy } from "@webpack";
import { React } from "@webpack/common";
import { cl } from "../";
import Grid, { GridProps } from "./Grid";
const ScrollerClasses = findByPropsLazy("managedReactiveScroller");
type Section<SectionT, ItemT> = SectionT & {
items: Array<ItemT>;
};
interface SectionedGridListProps<ItemT, SectionT, SectionU = Section<SectionT, ItemT>> extends Omit<GridProps<ItemT>, "items"> {
renderSectionHeader: (section: SectionU) => JSX.Element;
getSectionKey: (section: SectionU) => string;
sections: SectionU[];
}
export default function SectionedGridList<ItemT, SectionU,>(props: SectionedGridListProps<ItemT, SectionU>) {
return <div className={classes(cl("sectioned-grid-list-container"), ScrollerClasses.thin)}>
{props.sections.map(section => <div key={props.getSectionKey(section)} className={cl("sectioned-grid-list-section")}>
{props.renderSectionHeader(section)}
<Grid
renderItem={props.renderItem}
getItemKey={props.getItemKey}
itemKeyPrefix={props.getSectionKey(section)}
items={section.items}
/>
</div>)}
</div>;
}

View file

@ -0,0 +1,33 @@
/*
* Vencord, a Discord client mod
* Copyright (c) 2023 Vendicated and contributors
* SPDX-License-Identifier: GPL-3.0-or-later
*/
import { findComponentByCode, LazyComponentWebpack } from "@webpack";
import { React } from "@webpack/common";
import type { ComponentType, HTMLProps, PropsWithChildren } from "react";
import { AvatarDecoration } from "../..";
type DecorationGridItemComponent = ComponentType<PropsWithChildren<HTMLProps<HTMLDivElement>> & {
onSelect: () => void,
isSelected: boolean,
}>;
export let DecorationGridItem: DecorationGridItemComponent;
export const setDecorationGridItem = v => DecorationGridItem = v;
export const AvatarDecorationModalPreview = LazyComponentWebpack(() => {
const component = findComponentByCode("AvatarDecorationModalPreview");
return React.memo(component);
});
type DecorationGridDecorationComponent = React.ComponentType<HTMLProps<HTMLDivElement> & {
avatarDecoration: AvatarDecoration;
onSelect: () => void,
isSelected: boolean,
}>;
export let DecorationGridDecoration: DecorationGridDecorationComponent;
export const setDecorationGridDecoration = v => DecorationGridDecoration = v;

View file

@ -0,0 +1,13 @@
/*
* Vencord, a Discord client mod
* Copyright (c) 2023 Vendicated and contributors
* SPDX-License-Identifier: GPL-3.0-or-later
*/
import { classNameFactory } from "@api/Styles";
import { extractAndLoadChunksLazy } from "@webpack";
export const cl = classNameFactory("vc-decor-");
export const requireAvatarDecorationModal = extractAndLoadChunksLazy(["openAvatarDecorationModal:"]);
export const requireCreateStickerModal = extractAndLoadChunksLazy(["stickerInspected]:"]);

View file

@ -0,0 +1,270 @@
/*
* Vencord, a Discord client mod
* Copyright (c) 2023 Vendicated and contributors
* SPDX-License-Identifier: GPL-3.0-or-later
*/
import { Flex } from "@components/Flex";
import { openInviteModal } from "@utils/discord";
import { Margins } from "@utils/margins";
import { classes } from "@utils/misc";
import { closeAllModals, ModalCloseButton, ModalContent, ModalFooter, ModalHeader, ModalRoot, ModalSize, openModal } from "@utils/modal";
import { findByPropsLazy, findComponentByCodeLazy } from "@webpack";
import { Alerts, Button, FluxDispatcher, Forms, GuildStore, NavigationRouter, Parser, Text, Tooltip, useEffect, UserStore, UserUtils, useState } from "@webpack/common";
import { User } from "discord-types/general";
import { Decoration, getPresets, Preset } from "../../lib/api";
import { GUILD_ID, INVITE_KEY } from "../../lib/constants";
import { useAuthorizationStore } from "../../lib/stores/AuthorizationStore";
import { useCurrentUserDecorationsStore } from "../../lib/stores/CurrentUserDecorationsStore";
import { decorationToAvatarDecoration } from "../../lib/utils/decoration";
import { cl, requireAvatarDecorationModal } from "../";
import { AvatarDecorationModalPreview } from "../components";
import DecorationGridCreate from "../components/DecorationGridCreate";
import DecorationGridNone from "../components/DecorationGridNone";
import DecorDecorationGridDecoration from "../components/DecorDecorationGridDecoration";
import SectionedGridList from "../components/SectionedGridList";
import { openCreateDecorationModal } from "./CreateDecorationModal";
const UserSummaryItem = findComponentByCodeLazy("defaultRenderUser", "showDefaultAvatarsForNullUsers");
const DecorationModalStyles = findByPropsLazy("modalFooterShopButton");
function usePresets() {
const [presets, setPresets] = useState<Preset[]>([]);
useEffect(() => { getPresets().then(setPresets); }, []);
return presets;
}
interface Section {
title: string;
subtitle?: string;
sectionKey: string;
items: ("none" | "create" | Decoration)[];
authorIds?: string[];
}
function SectionHeader({ section }: { section: Section; }) {
const hasSubtitle = typeof section.subtitle !== "undefined";
const hasAuthorIds = typeof section.authorIds !== "undefined";
const [authors, setAuthors] = useState<User[]>([]);
useEffect(() => {
(async () => {
if (!section.authorIds) return;
for (const authorId of section.authorIds) {
const author = UserStore.getUser(authorId) ?? await UserUtils.getUser(authorId);
setAuthors(authors => [...authors, author]);
}
})();
}, [section.authorIds]);
return <div>
<Flex>
<Forms.FormTitle style={{ flexGrow: 1 }}>{section.title}</Forms.FormTitle>
{hasAuthorIds && <UserSummaryItem
users={authors}
guildId={undefined}
renderIcon={false}
max={5}
showDefaultAvatarsForNullUsers
size={16}
showUserPopout
className={Margins.bottom8}
/>
}
</Flex>
{hasSubtitle &&
<Forms.FormText type="description" className={Margins.bottom8}>
{section.subtitle}
</Forms.FormText>
}
</div>;
}
export default function ChangeDecorationModal(props: any) {
// undefined = not trying, null = none, Decoration = selected
const [tryingDecoration, setTryingDecoration] = useState<Decoration | null | undefined>(undefined);
const isTryingDecoration = typeof tryingDecoration !== "undefined";
const avatarDecorationOverride = tryingDecoration != null ? decorationToAvatarDecoration(tryingDecoration) : tryingDecoration;
const {
decorations,
selectedDecoration,
fetch: fetchUserDecorations,
select: selectDecoration
} = useCurrentUserDecorationsStore();
useEffect(() => {
fetchUserDecorations();
}, []);
const activeSelectedDecoration = isTryingDecoration ? tryingDecoration : selectedDecoration;
const activeDecorationHasAuthor = typeof activeSelectedDecoration?.authorId !== "undefined";
const hasDecorationPendingReview = decorations.some(d => d.reviewed === false);
const presets = usePresets();
const presetDecorations = presets.flatMap(preset => preset.decorations);
const activeDecorationPreset = presets.find(preset => preset.id === activeSelectedDecoration?.presetId);
const isActiveDecorationPreset = typeof activeDecorationPreset !== "undefined";
const ownDecorations = decorations.filter(d => !presetDecorations.some(p => p.hash === d.hash));
const data = [
{
title: "Your Decorations",
sectionKey: "ownDecorations",
items: ["none", ...ownDecorations, "create"]
},
...presets.map(preset => ({
title: preset.name,
subtitle: preset.description || undefined,
sectionKey: `preset-${preset.id}`,
items: preset.decorations,
authorIds: preset.authorIds
}))
] as Section[];
return <ModalRoot
{...props}
size={ModalSize.DYNAMIC}
className={DecorationModalStyles.modal}
>
<ModalHeader separator={false} className={cl("modal-header")}>
<Text
color="header-primary"
variant="heading-lg/semibold"
tag="h1"
style={{ flexGrow: 1 }}
>
Change Decoration
</Text>
<ModalCloseButton onClick={props.onClose} />
</ModalHeader>
<ModalContent
className={cl("change-decoration-modal-content")}
scrollbarType="none"
>
<SectionedGridList
renderItem={item => {
if (typeof item === "string") {
switch (item) {
case "none":
return <DecorationGridNone
className={cl("change-decoration-modal-decoration")}
isSelected={activeSelectedDecoration === null}
onSelect={() => setTryingDecoration(null)}
/>;
case "create":
return <Tooltip text="You already have a decoration pending review" shouldShow={hasDecorationPendingReview}>
{tooltipProps => <DecorationGridCreate
className={cl("change-decoration-modal-decoration")}
{...tooltipProps}
onSelect={!hasDecorationPendingReview ? openCreateDecorationModal : () => { }}
/>}
</Tooltip>;
}
} else {
return <Tooltip text={"Pending review"} shouldShow={item.reviewed === false}>
{tooltipProps => (
<DecorDecorationGridDecoration
{...tooltipProps}
className={cl("change-decoration-modal-decoration")}
onSelect={item.reviewed !== false ? () => setTryingDecoration(item) : () => { }}
isSelected={activeSelectedDecoration?.hash === item.hash}
decoration={item}
/>
)}
</Tooltip>;
}
}}
getItemKey={item => typeof item === "string" ? item : item.hash}
getSectionKey={section => section.sectionKey}
renderSectionHeader={section => <SectionHeader section={section} />}
sections={data}
/>
<div className={cl("change-decoration-modal-preview")}>
<AvatarDecorationModalPreview
avatarDecorationOverride={avatarDecorationOverride}
user={UserStore.getCurrentUser()}
/>
{isActiveDecorationPreset && <Forms.FormTitle className="">Part of the {activeDecorationPreset.name} Preset</Forms.FormTitle>}
{typeof activeSelectedDecoration === "object" &&
<Text
variant="text-sm/semibold"
color="header-primary"
>
{activeSelectedDecoration?.alt}
</Text>
}
{activeDecorationHasAuthor && <Text key={`createdBy-${activeSelectedDecoration.authorId}`}>Created by {Parser.parse(`<@${activeSelectedDecoration.authorId}>`)}</Text>}
</div>
</ModalContent>
<ModalFooter className={classes(cl("change-decoration-modal-footer", cl("modal-footer")))}>
<div className={cl("change-decoration-modal-footer-btn-container")}>
<Button
onClick={() => {
selectDecoration(tryingDecoration!).then(props.onClose);
}}
disabled={!isTryingDecoration}
>
Apply
</Button>
<Button
onClick={props.onClose}
color={Button.Colors.PRIMARY}
look={Button.Looks.LINK}
>
Cancel
</Button>
</div>
<div className={cl("change-decoration-modal-footer-btn-container")}>
<Button
onClick={() => Alerts.show({
title: "Log Out",
body: "Are you sure you want to log out of Decor?",
confirmText: "Log Out",
confirmColor: cl("danger-btn"),
cancelText: "Cancel",
onConfirm() {
useAuthorizationStore.getState().remove(UserStore.getCurrentUser().id);
props.onClose();
}
})}
color={Button.Colors.PRIMARY}
look={Button.Looks.LINK}
>
Log Out
</Button>
<Tooltip text="Join Decor's Discord Server for notifications on your decoration's review, and when new presets are released">
{tooltipProps => <Button
{...tooltipProps}
onClick={async () => {
if (!GuildStore.getGuild(GUILD_ID)) {
const inviteAccepted = await openInviteModal(INVITE_KEY);
if (inviteAccepted) {
closeAllModals();
FluxDispatcher.dispatch({ type: "LAYER_POP_ALL" });
}
} else {
props.onClose();
FluxDispatcher.dispatch({ type: "LAYER_POP_ALL" });
NavigationRouter.transitionToGuild(GUILD_ID);
}
}}
color={Button.Colors.PRIMARY}
look={Button.Looks.LINK}
>
Discord Server
</Button>}
</Tooltip>
</div>
</ModalFooter>
</ModalRoot>;
}
export const openChangeDecorationModal = () =>
requireAvatarDecorationModal().then(() => openModal(props => <ChangeDecorationModal {...props} />));

View file

@ -0,0 +1,163 @@
/*
* Vencord, a Discord client mod
* Copyright (c) 2023 Vendicated and contributors
* SPDX-License-Identifier: GPL-3.0-or-later
*/
import { Link } from "@components/Link";
import { openInviteModal } from "@utils/discord";
import { Margins } from "@utils/margins";
import { closeAllModals, ModalCloseButton, ModalContent, ModalFooter, ModalHeader, ModalRoot, ModalSize, openModal } from "@utils/modal";
import { findByPropsLazy, findComponentByCodeLazy } from "@webpack";
import { Button, FluxDispatcher, Forms, GuildStore, NavigationRouter, Text, TextInput, useEffect, useMemo, UserStore, useState } from "@webpack/common";
import { GUILD_ID, INVITE_KEY, RAW_SKU_ID } from "../../lib/constants";
import { useCurrentUserDecorationsStore } from "../../lib/stores/CurrentUserDecorationsStore";
import { cl, requireAvatarDecorationModal, requireCreateStickerModal } from "../";
import { AvatarDecorationModalPreview } from "../components";
const DecorationModalStyles = findByPropsLazy("modalFooterShopButton");
const FileUpload = findComponentByCodeLazy("fileUploadInput,");
function useObjectURL(object: Blob | MediaSource | null) {
const [url, setUrl] = useState<string | null>(null);
useEffect(() => {
if (!object) return;
const objectUrl = URL.createObjectURL(object);
setUrl(objectUrl);
return () => {
URL.revokeObjectURL(objectUrl);
setUrl(null);
};
}, [object]);
return url;
}
export default function CreateDecorationModal(props) {
const [name, setName] = useState("");
const [file, setFile] = useState<File | null>(null);
const [submitting, setSubmitting] = useState(false);
const [error, setError] = useState<Error | null>(null);
useEffect(() => {
if (error) setError(null);
}, [file]);
const { create: createDecoration } = useCurrentUserDecorationsStore();
const fileUrl = useObjectURL(file);
const decoration = useMemo(() => fileUrl ? { asset: fileUrl, skuId: RAW_SKU_ID } : null, [fileUrl]);
return <ModalRoot
{...props}
size={ModalSize.MEDIUM}
className={DecorationModalStyles.modal}
>
<ModalHeader separator={false} className={cl("modal-header")}>
<Text
color="header-primary"
variant="heading-lg/semibold"
tag="h1"
style={{ flexGrow: 1 }}
>
Create Decoration
</Text>
<ModalCloseButton onClick={props.onClose} />
</ModalHeader>
<ModalContent
className={cl("create-decoration-modal-content")}
scrollbarType="none"
>
<div className={cl("create-decoration-modal-form-preview-container")}>
<div className={cl("create-decoration-modal-form")}>
{error !== null && <Text color="text-danger" variant="text-xs/normal">{error.message}</Text>}
<Forms.FormSection title="File">
<FileUpload
filename={file?.name}
placeholder="Choose a file"
buttonText="Browse"
filters={[{ name: "Decoration file", extensions: ["png", "apng"] }]}
onFileSelect={setFile}
/>
<Forms.FormText type="description" className={Margins.top8}>
File should be APNG or PNG.
</Forms.FormText>
</Forms.FormSection>
<Forms.FormSection title="Name">
<TextInput
placeholder="Companion Cube"
value={name}
onChange={setName}
/>
<Forms.FormText type="description" className={Margins.top8}>
This name will be used when referring to this decoration.
</Forms.FormText>
</Forms.FormSection>
</div>
<div>
<AvatarDecorationModalPreview
avatarDecorationOverride={decoration}
user={UserStore.getCurrentUser()}
/>
</div>
</div>
<Forms.FormText type="description" className={Margins.bottom16}>
Make sure your decoration does not violate <Link
href="https://github.com/decor-discord/.github/blob/main/GUIDELINES.md"
>
the guidelines
</Link> before creating your decoration.
<br />You can receive updates on your decoration's review by joining <Link
href={`https://discord.gg/${INVITE_KEY}`}
onClick={async e => {
e.preventDefault();
if (!GuildStore.getGuild(GUILD_ID)) {
const inviteAccepted = await openInviteModal(INVITE_KEY);
if (inviteAccepted) {
closeAllModals();
FluxDispatcher.dispatch({ type: "LAYER_POP_ALL" });
}
} else {
closeAllModals();
FluxDispatcher.dispatch({ type: "LAYER_POP_ALL" });
NavigationRouter.transitionToGuild(GUILD_ID);
}
}}
>
Decor's Discord server
</Link>.
</Forms.FormText>
</ModalContent>
<ModalFooter className={cl("modal-footer")}>
<Button
onClick={() => {
setSubmitting(true);
createDecoration({ alt: name, file: file! })
.then(props.onClose).catch(e => { setSubmitting(false); setError(e); });
}}
disabled={!file || !name}
submitting={submitting}
>
Create
</Button>
<Button
onClick={props.onClose}
color={Button.Colors.PRIMARY}
look={Button.Looks.LINK}
>
Cancel
</Button>
</ModalFooter>
</ModalRoot>;
}
export const openCreateDecorationModal = () =>
Promise.all([requireAvatarDecorationModal(), requireCreateStickerModal()])
.then(() => openModal(props => <CreateDecorationModal {...props} />));

View file

@ -0,0 +1,80 @@
.vc-decor-danger-btn {
color: var(--white-500);
background-color: var(--button-danger-background);
}
.vc-decor-change-decoration-modal-content {
position: relative;
display: flex;
border-radius: 5px 5px 0 0;
padding: 0 16px;
gap: 4px
}
.vc-decor-change-decoration-modal-preview {
display: flex;
flex-direction: column;
margin-top: 24px;
gap: 8px;
max-width: 280px;
}
.vc-decor-change-decoration-modal-decoration {
width: 80px;
height: 80px;
}
.vc-decor-change-decoration-modal-footer {
justify-content: space-between;
}
.vc-decor-change-decoration-modal-footer-btn-container {
display: flex;
flex-direction: row-reverse;
}
.vc-decor-create-decoration-modal-content {
display: flex;
flex-direction: column;
gap: 20px;
padding: 0 16px;
}
.vc-decor-create-decoration-modal-form-preview-container {
display: flex;
gap: 16px;
}
.vc-decor-modal-header {
padding: 16px;
}
.vc-decor-modal-footer {
padding: 16px;
}
.vc-decor-create-decoration-modal-form {
display: flex;
flex-direction: column;
flex-grow: 1;
gap: 16px;
}
.vc-decor-sectioned-grid-list-container {
display: flex;
flex-direction: column;
overflow: hidden scroll;
max-height: 512px;
width: 352px; /* ((80 + 8 (grid gap)) * desired columns) (scrolled takes the extra 8 padding off conveniently) */
gap: 12px;
}
.vc-decor-sectioned-grid-list-grid {
display: flex;
flex-wrap: wrap;
gap: 8px
}
.vc-decor-section-remove-margin {
margin-bottom: 0;
}

View file

@ -215,6 +215,9 @@ function initWs(isManual = false) {
case "ModuleId":
results = Object.keys(search(parsedArgs[0]));
break;
case "ComponentByCode":
results = findAll(filters.componentByCode(...parsedArgs));
break;
default:
return reply("Unknown Find Type " + type);
}

View file

@ -359,7 +359,7 @@ export default definePlugin({
},
// Separate patch for allowing using custom app icons
{
find: "location:\"AppIconHome\"",
find: ".FreemiumAppIconIds.DEFAULT&&(",
replacement: {
match: /\i\.\i\.isPremium\(\i\.\i\.getCurrentUser\(\)\)/,
replace: "true"
@ -787,7 +787,14 @@ export default definePlugin({
if (sticker.available !== false && (canUseStickers || sticker.guild_id === guildId))
break stickerBypass;
const link = this.getStickerLink(sticker.id);
// [12/12/2023]
// Work around an annoying bug where getStickerLink will return StickerType.GIF,
// but will give us a normal non animated png for no reason
// TODO: Remove this workaround when it's not needed anymore
let link = this.getStickerLink(sticker.id);
if (sticker.format_type === StickerType.GIF && link.includes(".png")) {
link = link.replace(".png", ".gif");
}
if (sticker.format_type === StickerType.APNG) {
this.sendAnimatedSticker(link, sticker.id, channelId);
return { cancel: true };

View file

@ -14,10 +14,12 @@ export default definePlugin({
patches: [
{
find: "handleImageLoad=",
replacement: {
match: /(?<=getSrc\(\i\){.+?format:)\i/,
replace: "null"
replacement: [
{
match: /(?<=getSrc\(\i\){.+?return )\i\.SUPPORTS_WEBP.+?:(?=\i&&\(\i="png"\))/,
replace: ""
}
]
}
]
});

View file

@ -28,21 +28,22 @@ import style from "./style.css?managed";
const Button = findComponentByCodeLazy("Button.Sizes.NONE,disabled:");
function makeIcon(showCurrentGame?: boolean) {
const controllerIcon = "M3.06 20.4q-1.53 0-2.37-1.065T.06 16.74l1.26-9q.27-1.8 1.605-2.97T6.06 3.6h11.88q1.8 0 3.135 1.17t1.605 2.97l1.26 9q.21 1.53-.63 2.595T20.94 20.4q-.63 0-1.17-.225T18.78 19.5l-2.7-2.7H7.92l-2.7 2.7q-.45.45-.99.675t-1.17.225Zm14.94-7.2q.51 0 .855-.345T19.2 12q0-.51-.345-.855T18 10.8q-.51 0-.855.345T16.8 12q0 .51.345 .855T18 13.2Zm-2.4-3.6q.51 0 .855-.345T16.8 8.4q0-.51-.345-.855T15.6 7.2q-.51 0-.855.345T14.4 8.4q0 .51.345 .855T15.6 9.6ZM6.9 13.2h1.8v-2.1h2.1v-1.8h-2.1v-2.1h-1.8v2.1h-2.1v1.8h2.1v2.1Z";
return function () {
return (
<svg
width="20"
height="20"
viewBox="0 0 24 24"
>
<path fill="currentColor" mask="url(#gameActivityMask)" d="M3.06 20.4q-1.53 0-2.37-1.065T.06 16.74l1.26-9q.27-1.8 1.605-2.97T6.06 3.6h11.88q1.8 0 3.135 1.17t1.605 2.97l1.26 9q.21 1.53-.63 2.595T20.94 20.4q-.63 0-1.17-.225T18.78 19.5l-2.7-2.7H7.92l-2.7 2.7q-.45.45-.99.675t-1.17.225Zm14.94-7.2q.51 0 .855-.345T19.2 12q0-.51-.345-.855T18 10.8q-.51 0-.855.345T16.8 12q0 .51.345 .855T18 13.2Zm-2.4-3.6q.51 0 .855-.345T16.8 8.4q0-.51-.345-.855T15.6 7.2q-.51 0-.855.345T14.4 8.4q0 .51.345 .855T15.6 9.6ZM6.9 13.2h1.8v-2.1h2.1v-1.8h-2.1v-2.1h-1.8v2.1h-2.1v1.8h2.1v2.1Z" />
{!showCurrentGame && <>
<svg width="20" height="20" viewBox="0 0 24 24">
{showCurrentGame ? (
<path fill="currentColor" d={controllerIcon} />
) : (
<>
<mask id="gameActivityMask" >
<rect fill="white" x="0" y="0" width="24" height="24" />
<path fill="black" d="M23.27 4.54 19.46.73 .73 19.46 4.54 23.27 23.27 4.54Z" />
<path fill="black" d="M23.27 4.73 19.27 .73 -.27 20.27 3.73 24.27Z" />
</mask>
<path fill="var(--status-danger)" d="M23 2.27 21.73 1 1 21.73 2.27 23 23 2.27Z" />
</>}
<path fill="var(--status-danger)" mask="url(#gameActivityMask)" d={controllerIcon} />
<path fill="var(--status-danger)" d="M22.7 2.7a1 1 0 0 0-1.4-1.4l-20 20a1 1 0 1 0 1.4 1.4Z" />
</>
)}
</svg>
);
};

View file

@ -20,7 +20,7 @@ import { definePluginSettings } from "@api/Settings";
import { Devs } from "@utils/constants";
import definePlugin, { OptionType } from "@utils/types";
import { findByPropsLazy } from "@webpack";
import { ContextMenuApi, FluxDispatcher, Menu } from "@webpack/common";
import { ContextMenuApi, FluxDispatcher, Menu, MessageActions } from "@webpack/common";
import { Channel, Message } from "discord-types/general";
interface Sticker {
@ -49,7 +49,6 @@ const settings = definePluginSettings({
unholyMultiGreetEnabled?: boolean;
}>();
const MessageActions = findByPropsLazy("sendGreetMessage");
const { WELCOME_STICKERS } = findByPropsLazy("WELCOME_STICKERS");
function greet(channel: Channel, message: Message, stickers: string[]) {

View file

@ -22,7 +22,7 @@ import definePlugin from "@utils/types";
export default definePlugin({
name: "iLoveSpam",
description: "Do not hide messages from 'likely spammers'",
authors: [Devs.botato, Devs.Animal],
authors: [Devs.botato, Devs.Nyako],
patches: [
{
find: "hasFlag:{writable",

View file

@ -72,6 +72,7 @@ export default definePlugin({
if (event.detail < 2) return;
if (settings.store.requireModifier && !event.ctrlKey && !event.shiftKey) return;
if (channel.guild_id && !PermissionStore.can(PermissionsBits.SEND_MESSAGES, channel)) return;
if (msg.deleted === true) return;
if (isMe) {
if (!settings.store.enableDoubleClickToEdit || EditStore.isEditing(channel.id, msg.id)) return;
@ -81,6 +82,9 @@ export default definePlugin({
} else {
if (!settings.store.enableDoubleClickToReply) return;
const EPHEMERAL = 64;
if (msg.hasFlag(EPHEMERAL)) return;
FluxDispatcher.dispatch({
type: "CREATE_PENDING_REPLY",
channel,

View file

@ -19,7 +19,9 @@
import { definePluginSettings } from "@api/Settings";
import { Devs } from "@utils/constants";
import definePlugin, { OptionType } from "@utils/types";
import { findByProps } from "@webpack";
import { findByPropsLazy } from "@webpack";
const { updateGuildNotificationSettings } = findByPropsLazy("updateGuildNotificationSettings");
const settings = definePluginSettings({
guild: {
@ -63,7 +65,7 @@ export default definePlugin({
handleMute(guildId: string | null) {
if (guildId === "@me" || guildId === "null" || guildId == null) return;
findByProps("updateGuildNotificationSettings").updateGuildNotificationSettings(guildId,
updateGuildNotificationSettings(guildId,
{
muted: settings.store.guild,
suppress_everyone: settings.store.everyone,

View file

@ -0,0 +1,3 @@
# NotificationVolume
Set a separate volume for notifications and in-app sounds (e.g. messages, call sound, mute/unmute) helping your ears stay healthy for many years to come.

View file

@ -0,0 +1,35 @@
/*
* Vencord, a Discord client mod
* Copyright (c) 2023 Vendicated and contributors
* SPDX-License-Identifier: GPL-3.0-or-later
*/
import { definePluginSettings } from "@api/Settings";
import { Devs } from "@utils/constants";
import definePlugin, { OptionType } from "@utils/types";
const settings = definePluginSettings({
notificationVolume: {
type: OptionType.SLIDER,
description: "Notification volume",
markers: [0, 25, 50, 75, 100],
default: 100,
stickToMarkers: false
}
});
export default definePlugin({
name: "NotificationVolume",
description: "Save your ears and set a separate volume for notifications and in-app sounds",
authors: [Devs.philipbry],
settings,
patches: [
{
find: "_ensureAudio(){",
replacement: {
match: /onloadeddata=\(\)=>\{.\.volume=/,
replace: "$&$self.settings.store.notificationVolume/100*"
},
},
],
});

View file

@ -28,7 +28,8 @@ export default definePlugin({
start() {
fetch("https://raw.githubusercontent.com/adryd325/oneko.js/8fa8a1864aa71cd7a794d58bc139e755e96a236c/oneko.js")
.then(x => x.text())
.then(s => s.replace("./oneko.gif", "https://raw.githubusercontent.com/adryd325/oneko.js/14bab15a755d0e35cd4ae19c931d96d306f99f42/oneko.gif"))
.then(s => s.replace("./oneko.gif", "https://raw.githubusercontent.com/adryd325/oneko.js/14bab15a755d0e35cd4ae19c931d96d306f99f42/oneko.gif")
.replace("(isReducedMotion)", "(false)"))
.then(eval);
},

View file

@ -35,15 +35,13 @@ interface UserPermission {
type UserPermissions = Array<UserPermission>;
const Classes = proxyLazyWebpack(() => {
const modules = findBulk(
const Classes = proxyLazyWebpack(() =>
Object.assign({}, ...findBulk(
filters.byProps("roles", "rolePill", "rolePillBorder"),
filters.byProps("roleCircle", "dotBorderBase", "dotBorderColor"),
filters.byProps("roleNameOverflow", "root", "roleName", "roleRemoveButton")
);
return Object.assign({}, ...modules);
}) as Record<"roles" | "rolePill" | "rolePillBorder" | "desaturateUserColors" | "flex" | "alignCenter" | "justifyCenter" | "svg" | "background" | "dot" | "dotBorderColor" | "roleCircle" | "dotBorderBase" | "flex" | "alignCenter" | "justifyCenter" | "wrap" | "root" | "role" | "roleRemoveButton" | "roleDot" | "roleFlowerStar" | "roleRemoveIcon" | "roleRemoveIconFocused" | "roleVerifiedIcon" | "roleName" | "roleNameOverflow" | "actionButton" | "overflowButton" | "addButton" | "addButtonIcon" | "overflowRolesPopout" | "overflowRolesPopoutArrowWrapper" | "overflowRolesPopoutArrow" | "popoutBottom" | "popoutTop" | "overflowRolesPopoutHeader" | "overflowRolesPopoutHeaderIcon" | "overflowRolesPopoutHeaderText" | "roleIcon", string>;
))
) as Record<"roles" | "rolePill" | "rolePillBorder" | "desaturateUserColors" | "flex" | "alignCenter" | "justifyCenter" | "svg" | "background" | "dot" | "dotBorderColor" | "roleCircle" | "dotBorderBase" | "flex" | "alignCenter" | "justifyCenter" | "wrap" | "root" | "role" | "roleRemoveButton" | "roleDot" | "roleFlowerStar" | "roleRemoveIcon" | "roleRemoveIconFocused" | "roleVerifiedIcon" | "roleName" | "roleNameOverflow" | "actionButton" | "overflowButton" | "addButton" | "addButtonIcon" | "overflowRolesPopout" | "overflowRolesPopoutArrowWrapper" | "overflowRolesPopoutArrow" | "popoutBottom" | "popoutTop" | "overflowRolesPopoutHeader" | "overflowRolesPopoutHeaderIcon" | "overflowRolesPopoutHeaderText" | "roleIcon", string>;
function UserPermissionsComponent({ guild, guildMember, showBorder }: { guild: Guild; guildMember: GuildMember; showBorder: boolean; }) {
const stns = settings.use(["permissionsSortOrder"]);

View file

@ -55,13 +55,13 @@ const Icons = {
};
type Platform = keyof typeof Icons;
const StatusUtils = findByPropsLazy("getStatusColor", "StatusTypes");
const StatusUtils = findByPropsLazy("useStatusFillColor", "StatusTypes");
const PlatformIcon = ({ platform, status, small }: { platform: Platform, status: string; small: boolean; }) => {
const tooltip = platform[0].toUpperCase() + platform.slice(1);
const Icon = Icons[platform] ?? Icons.desktop;
return <Icon color={`var(--${StatusUtils.getStatusColor(status)}`} tooltip={tooltip} small={small} />;
return <Icon color={StatusUtils.useStatusFillColor(status)} tooltip={tooltip} small={small} />;
};
const getStatus = (id: string): Record<Platform, string> => PresenceStore.getState()?.clientStatuses?.[id];

View file

@ -31,7 +31,7 @@ export default definePlugin({
start() {
addButton("QuickMention", msg => {
const channel = ChannelStore.getChannel(msg.channel_id);
if (!PermissionStore.can(PermissionsBits.SEND_MESSAGES, channel)) return null;
if (channel.guild_id && !PermissionStore.can(PermissionsBits.SEND_MESSAGES, channel)) return null;
return {
label: "Quick Mention",

View file

@ -18,27 +18,28 @@
import { Devs } from "@utils/constants";
import definePlugin from "@utils/types";
import { React } from "@webpack/common";
let ERROR_CODES: any;
const CODES_URL =
"https://raw.githubusercontent.com/facebook/react/17.0.2/scripts/error-codes/codes.json";
export default definePlugin({
name: "ReactErrorDecoder",
description: 'Replaces "Minifed React Error" with the actual error.',
authors: [Devs.Cyn],
authors: [Devs.Cyn, Devs.maisymoe],
patches: [
{
find: '"https://reactjs.org/docs/error-decoder.html?invariant="',
replacement: {
match: /(function .\(.\)){(for\(var .="https:\/\/reactjs\.org\/docs\/error-decoder\.html\?invariant="\+.,.=1;.<arguments\.length;.\+\+\).\+="&args\[\]="\+encodeURIComponent\(arguments\[.\]\);return"Minified React error #"\+.\+"; visit "\+.\+" for the full message or use the non-minified dev environment for full errors and additional helpful warnings.")}/,
replace: (_, func, original) =>
`${func}{var decoded=Vencord.Plugins.plugins.ReactErrorDecoder.decodeError.apply(null, arguments);if(decoded)return decoded;${original}}`,
`${func}{var decoded=$self.decodeError.apply(null, arguments);if(decoded)return decoded;${original}}`,
},
},
],
async start() {
const CODES_URL = `https://raw.githubusercontent.com/facebook/react/v${React.version}/scripts/error-codes/codes.json`;
ERROR_CODES = await fetch(CODES_URL)
.then(res => res.json())
.catch(e => console.error("[ReactErrorDecoder] Failed to fetch React error codes\n", e));

View file

@ -42,6 +42,13 @@ export default definePlugin({
match: /codeBlock:\{react\((\i),(\i),(\i)\)\{/,
replace: "$&return $self.renderHighlighter($1,$2,$3);"
}
},
{
find: ".PREVIEW_NUM_LINES",
replacement: {
match: /(?<=function \i\((\i)\)\{)(?=let\{text:\i,language:)/,
replace: "return $self.renderHighlighter({lang:$1.language,content:$1.text});"
}
}
],
start: async () => {

View file

@ -77,7 +77,7 @@ export default definePlugin({
},
// Do not check for unreads when selecting the render level if the channel is hidden
{
match: /(?=!\(0,\i\.getHasImportantUnread\)\(this\.record\))/,
match: /(?<=&&)(?=!\i\.\i\.hasUnread\(this\.record\.id\))/,
replace: "$self.isHiddenChannel(this.record)||"
},
// Make channels we dont have access to be the same level as normal ones
@ -334,12 +334,12 @@ export default definePlugin({
replacement: [
{
// Remove the divider and the open chat button for the HiddenChannelLockScreen
match: /"more-options-popout"\)\),(?<=let{channel:(\i).+?inCall:(\i).+?)/,
match: /"more-options-popout"\)\),(?<=channel:(\i).+?inCall:(\i).+?)/,
replace: (m, channel, inCall) => `${m}${inCall}||!$self.isHiddenChannel(${channel},true)&&`
},
{
// Remove invite users button for the HiddenChannelLockScreen
match: /"popup".{0,100}?if\((?<=let{channel:(\i).+?inCall:(\i).+?)/,
match: /"popup".{0,100}?if\((?<=channel:(\i).+?inCall:(\i).+?)/,
replace: (m, channel, inCall) => `${m}(${inCall}||!$self.isHiddenChannel(${channel},true))&&`
},
]

View file

@ -80,16 +80,15 @@ function SilentMessageToggle(chatBoxProps: {
style={{ padding: "0 6px" }}
>
<div className={ButtonWrapperClasses.buttonWrapper}>
<svg
width="24"
height="24"
viewBox="0 0 24 24"
>
<g fill="currentColor">
<path d="M18 10.7101C15.1085 9.84957 13 7.17102 13 4C13 3.69264 13.0198 3.3899 13.0582 3.093C12.7147 3.03189 12.3611 3 12 3C8.686 3 6 5.686 6 9V14C6 15.657 4.656 17 3 17V18H21V17C19.344 17 18 15.657 18 14V10.7101ZM8.55493 19C9.24793 20.19 10.5239 21 11.9999 21C13.4759 21 14.7519 20.19 15.4449 19H8.55493Z" />
<path d="M18.2624 5.50209L21 2.5V1H16.0349V2.49791H18.476L16 5.61088V7H21V5.50209H18.2624Z" />
{!enabled && <line x1="22" y1="2" x2="2" y2="22" stroke="var(--red-500)" stroke-width="2.5" />}
</g>
<svg width="24" height="24" viewBox="0 0 24 24">
<path fill="currentColor" mask="url(#_)" d="M18 10.7101C15.1085 9.84957 13 7.17102 13 4c0-.30736.0198-.6101.0582-.907C12.7147 3.03189 12.3611 3 12 3 8.686 3 6 5.686 6 9v5c0 1.657-1.344 3-3 3v1h18v-1c-1.656 0-3-1.343-3-3v-3.2899ZM8.55493 19c.693 1.19 1.96897 2 3.44497 2s2.752-.81 3.445-2H8.55493ZM18.2624 5.50209 21 2.5V1h-4.9651v1.49791h2.4411L16 5.61088V7h5V5.50209h-2.7376Z" />
{!enabled && <>
<mask id="_">
<path fill="#fff" d="M0 0h24v24H0Z" />
<path stroke="#000" stroke-width="5.99068" d="M0 24 24 0" />
</mask>
<path fill="var(--status-danger)" d="m21.178 1.70703 1.414 1.414L4.12103 21.593l-1.414-1.415L21.178 1.70703Z" />
</>}
</svg>
</div>
</Button>

View file

@ -55,13 +55,19 @@ export default definePlugin({
replace: "return [$self.renderPlayer(),$1]"
}
},
// Adds POST and a Marker to the SpotifyAPI (so we can easily find it)
{
find: ".PLAYER_DEVICES",
replacement: {
replacement: [{
// Adds POST and a Marker to the SpotifyAPI (so we can easily find it)
match: /get:(\i)\.bind\(null,(\i\.\i)\.get\)/,
replace: "post:$1.bind(null,$2.post),$&"
}
},
{
// Spotify Connect API returns status 202 instead of 204 when skipping tracks.
// Discord rejects 202 which causes the request to send twice. This patch prevents this.
match: /202===\i\.status/,
replace: "false",
}]
},
// Discord doesn't give you the repeat kind, only a boolean
{

View file

@ -20,7 +20,7 @@ import { ApplicationCommandInputType, sendBotMessage } from "@api/Commands";
import { Devs } from "@utils/constants";
import definePlugin from "@utils/types";
import { findByPropsLazy } from "@webpack";
import { FluxDispatcher } from "@webpack/common";
import { FluxDispatcher, MessageActions } from "@webpack/common";
interface Album {
id: string;
@ -53,7 +53,6 @@ interface Track {
}
const Spotify = findByPropsLazy("getPlayerState");
const MessageCreator = findByPropsLazy("getSendMessageOptionsForReply", "sendMessage");
const PendingReplyStore = findByPropsLazy("getPendingReply");
function sendMessage(channelId, message) {
@ -65,7 +64,7 @@ function sendMessage(channelId, message) {
...message
};
const reply = PendingReplyStore.getPendingReply(channelId);
MessageCreator.sendMessage(channelId, message, void 0, MessageCreator.getSendMessageOptionsForReply(reply))
MessageActions.sendMessage(channelId, message, void 0, MessageActions.getSendMessageOptionsForReply(reply))
.then(() => {
if (reply) {
FluxDispatcher.dispatch({ type: "DELETE_PENDING_REPLY", channelId });

View file

@ -46,10 +46,10 @@ export default definePlugin({
}
},
{
find: ".hasAvailableBurstCurrency)",
find: ".trackEmojiSearchEmpty,200",
replacement: {
match: /(?<=\.useBurstReactionsExperiment.{0,20})useState\(!1\)(?=.+?(\i===\i\.EmojiIntention.REACTION))/,
replace: "useState($self.settings.store.superReactByDefault && $1)"
match: /(\.trackEmojiSearchEmpty,200(?=.+?isBurstReaction:(\i).+?(\i===\i\.EmojiIntention.REACTION)).+?\[\2,\i\]=\i\.useState\().+?\)/,
replace: (_, rest, isBurstReactionVariable, isReactionIntention) => `${rest}$self.settings.store.superReactByDefault&&${isReactionIntention})`
}
}
],

View file

@ -20,17 +20,12 @@ import { definePluginSettings, Settings } from "@api/Settings";
import ErrorBoundary from "@components/ErrorBoundary";
import { Devs } from "@utils/constants";
import definePlugin, { OptionType } from "@utils/types";
import { find, findStoreLazy, LazyComponentWebpack } from "@webpack";
import { ChannelStore, GuildMemberStore, i18n, RelationshipStore, Tooltip, UserStore, useStateFromStores } from "@webpack/common";
import { findExportedComponentLazy, findStoreLazy } from "@webpack";
import { ChannelStore, GuildMemberStore, i18n, RelationshipStore, SelectedChannelStore, Tooltip, UserStore, useStateFromStores } from "@webpack/common";
import { buildSeveralUsers } from "../typingTweaks";
const ThreeDots = LazyComponentWebpack(() => {
// This doesn't really need to explicitly find Dots' own module, but it's fine
const res = find(m => m.Dots && !m.Menu);
return res?.Dots;
});
const ThreeDots = findExportedComponentLazy("Dots", "AnimatedDots");
const TypingStore = findStoreLazy("TypingStore");
const UserGuildSettingsStore = findStoreLazy("UserGuildSettingsStore");
@ -52,7 +47,7 @@ function TypingIndicator({ channelId }: { channelId: string; }) {
return oldKeys.length === currentKeys.length && currentKeys.every(key => old[key] != null);
}
);
const currentChannelId: string = useStateFromStores([SelectedChannelStore], () => SelectedChannelStore.getChannelId());
const guildId = ChannelStore.getChannel(channelId).guild_id;
if (!settings.store.includeMutedChannels) {
@ -60,6 +55,10 @@ function TypingIndicator({ channelId }: { channelId: string; }) {
if (isChannelMuted) return null;
}
if (!settings.store.includeCurrentChannel) {
if (currentChannelId === channelId) return null;
}
const myId = UserStore.getCurrentUser()?.id;
const typingUsersArray = Object.keys(typingUsers).filter(id => id !== myId && !(RelationshipStore.isBlocked(id) && !settings.store.includeBlockedUsers));
@ -106,6 +105,11 @@ function TypingIndicator({ channelId }: { channelId: string; }) {
}
const settings = definePluginSettings({
includeCurrentChannel: {
type: OptionType.BOOLEAN,
description: "Whether to show the typing indicator for the currently selected channel",
default: true
},
includeMutedChannels: {
type: OptionType.BOOLEAN,
description: "Whether to show the typing indicator for muted channels.",

View file

@ -23,14 +23,11 @@ import { Settings } from "@api/Settings";
import ErrorBoundary from "@components/ErrorBoundary";
import { Devs } from "@utils/constants";
import definePlugin from "@utils/types";
import { filters, find, LazyComponentWebpack } from "@webpack";
import { findExportedComponentLazy } from "@webpack";
import { Menu, Popout, useState } from "@webpack/common";
import type { ReactNode } from "react";
const HeaderBarIcon = LazyComponentWebpack(() => {
const filter = filters.byCode(".HEADER_BAR_BADGE");
return find(m => m.Icon && filter(m.Icon))?.Icon;
});
const HeaderBarIcon = findExportedComponentLazy("Icon", "Divider");
function VencordPopout(onClose: () => void) {
const pluginEntries = [] as ReactNode[];

View file

@ -25,7 +25,7 @@ interface VoiceMessageProps {
src: string;
waveform: string;
}
const VoiceMessage = findComponentByCodeLazy<VoiceMessageProps>("waveform:");
const VoiceMessage = findComponentByCodeLazy<VoiceMessageProps>("waveform:", "onVolumeChange");
export type VoicePreviewOptions = {
src?: string;

View file

@ -26,7 +26,7 @@ import { useAwaiter } from "@utils/react";
import definePlugin from "@utils/types";
import { chooseFile } from "@utils/web";
import { findByPropsLazy, findStoreLazy } from "@webpack";
import { Button, FluxDispatcher, Forms, lodash, Menu, PermissionsBits, PermissionStore, RestAPI, SelectedChannelStore, showToast, SnowflakeUtils, Toasts, useEffect, useState } from "@webpack/common";
import { Button, FluxDispatcher, Forms, lodash, Menu, MessageActions, PermissionsBits, PermissionStore, RestAPI, SelectedChannelStore, showToast, SnowflakeUtils, Toasts, useEffect, useState } from "@webpack/common";
import { ComponentType } from "react";
import { VoiceRecorderDesktop } from "./DesktopRecorder";
@ -36,7 +36,6 @@ import { VoicePreview } from "./VoicePreview";
import { VoiceRecorderWeb } from "./WebRecorder";
const CloudUtils = findByPropsLazy("CloudUpload");
const MessageCreator = findByPropsLazy("getSendMessageOptionsForReply", "sendMessage");
const PendingReplyStore = findStoreLazy("PendingReplyStore");
const OptionClasses = findByPropsLazy("optionName", "optionIcon", "optionLabel");
@ -100,7 +99,7 @@ function sendAudio(blob: Blob, meta: AudioMetadata) {
waveform: meta.waveform,
duration_secs: meta.duration,
}],
message_reference: reply ? MessageCreator.getSendMessageOptionsForReply(reply)?.messageReference : null,
message_reference: reply ? MessageActions.getSendMessageOptionsForReply(reply)?.messageReference : null,
}
});
});

View file

@ -20,9 +20,11 @@ import { definePluginSettings } from "@api/Settings";
import { Devs } from "@utils/constants";
import definePlugin, { OptionType } from "@utils/types";
import { saveFile } from "@utils/web";
import { findByProps } from "@webpack";
import { findByPropsLazy } from "@webpack";
import { Clipboard, ComponentDispatch } from "@webpack/common";
const ctxMenuCallbacks = findByPropsLazy("contextMenuCallbackNative");
async function fetchImage(url: string) {
const res = await fetch(url);
if (res.status !== 200) return;
@ -55,7 +57,6 @@ export default definePlugin({
start() {
if (settings.store.addBack) {
const ctxMenuCallbacks = findByProps("contextMenuCallbackNative");
window.removeEventListener("contextmenu", ctxMenuCallbacks.contextMenuCallbackWeb);
window.addEventListener("contextmenu", ctxMenuCallbacks.contextMenuCallbackNative);
this.changedListeners = true;
@ -64,7 +65,6 @@ export default definePlugin({
stop() {
if (this.changedListeners) {
const ctxMenuCallbacks = findByProps("contextMenuCallbackNative");
window.removeEventListener("contextmenu", ctxMenuCallbacks.contextMenuCallbackNative);
window.addEventListener("contextmenu", ctxMenuCallbacks.contextMenuCallbackWeb);
}

View file

@ -0,0 +1,15 @@
# XSOverlay Notifier
Sends Discord messages to [XSOverlay](https://store.steampowered.com/app/1173510/XSOverlay/) for easier viewing while using VR.
## Preview
![](https://github.com/Vendicated/Vencord/assets/24845294/205d2055-bb4a-44e4-b7e3-265391bccd40)
![](https://github.com/Vendicated/Vencord/assets/24845294/f15eff61-2d52-4620-bcab-808ecb1606d2)
## Usage
- Enable this plugin
- Set plugin settings as desired
- Open XSOverlay
- get ping spammed

View file

@ -0,0 +1,288 @@
/*
* Vencord, a Discord client mod
* Copyright (c) 2023 Vendicated and contributors
* SPDX-License-Identifier: GPL-3.0-or-later
*/
import { definePluginSettings } from "@api/Settings";
import { makeRange } from "@components/PluginSettings/components";
import { Devs } from "@utils/constants";
import { Logger } from "@utils/Logger";
import definePlugin, { OptionType, PluginNative } from "@utils/types";
import { findByPropsLazy } from "@webpack";
import { ChannelStore, GuildStore, UserStore } from "@webpack/common";
import type { Channel, Embed, GuildMember, MessageAttachment, User } from "discord-types/general";
const enum ChannelTypes {
DM = 1,
GROUP_DM = 3
}
interface Message {
guild_id: string,
attachments: MessageAttachment[],
author: User,
channel_id: string,
components: any[],
content: string,
edited_timestamp: string,
embeds: Embed[],
sticker_items?: Sticker[],
flags: number,
id: string,
member: GuildMember,
mention_everyone: boolean,
mention_roles: string[],
mentions: Mention[],
nonce: string,
pinned: false,
referenced_message: any,
timestamp: string,
tts: boolean,
type: number;
}
interface Mention {
avatar: string,
avatar_decoration_data: any,
discriminator: string,
global_name: string,
id: string,
public_flags: number,
username: string;
}
interface Sticker {
t: "Sticker";
description: string;
format_type: number;
guild_id: string;
id: string;
name: string;
tags: string;
type: number;
}
interface Call {
channel_id: string,
guild_id: string,
message_id: string,
region: string,
ringing: string[];
}
const MuteStore = findByPropsLazy("isSuppressEveryoneEnabled");
const XSLog = new Logger("XSOverlay");
const settings = definePluginSettings({
ignoreBots: {
type: OptionType.BOOLEAN,
description: "Ignore messages from bots",
default: false
},
pingColor: {
type: OptionType.STRING,
description: "User mention color",
default: "#7289da"
},
channelPingColor: {
type: OptionType.STRING,
description: "Channel mention color",
default: "#8a2be2"
},
soundPath: {
type: OptionType.STRING,
description: "Notification sound (default/warning/error)",
default: "default"
},
timeout: {
type: OptionType.NUMBER,
description: "Notif duration (secs)",
default: 1.0,
},
opacity: {
type: OptionType.SLIDER,
description: "Notif opacity",
default: 1,
markers: makeRange(0, 1, 0.1)
},
volume: {
type: OptionType.SLIDER,
description: "Volume",
default: 0.2,
markers: makeRange(0, 1, 0.1)
},
});
const Native = VencordNative.pluginHelpers.XsOverlay as PluginNative<typeof import("./native")>;
export default definePlugin({
name: "XSOverlay",
description: "Forwards discord notifications to XSOverlay, for easy viewing in VR",
authors: [Devs.Nyako],
tags: ["vr", "notify"],
settings,
flux: {
CALL_UPDATE({ call }: { call: Call; }) {
if (call?.ringing?.includes(UserStore.getCurrentUser().id)) {
const channel = ChannelStore.getChannel(call.channel_id);
sendOtherNotif("Incoming call", `${channel.name} is calling you...`);
}
},
MESSAGE_CREATE({ message, optimistic }: { message: Message; optimistic: boolean; }) {
// Apparently without this try/catch, discord's socket connection dies if any part of this errors
try {
if (optimistic) return;
const channel = ChannelStore.getChannel(message.channel_id);
if (!shouldNotify(message, channel)) return;
const pingColor = settings.store.pingColor.replaceAll("#", "").trim();
const channelPingColor = settings.store.channelPingColor.replaceAll("#", "").trim();
let finalMsg = message.content;
let titleString = "";
if (channel.guild_id) {
const guild = GuildStore.getGuild(channel.guild_id);
titleString = `${message.author.username} (${guild.name}, #${channel.name})`;
}
switch (channel.type) {
case ChannelTypes.DM:
titleString = message.author.username.trim();
break;
case ChannelTypes.GROUP_DM:
const channelName = channel.name.trim() ?? channel.rawRecipients.map(e => e.username).join(", ");
titleString = `${message.author.username} (${channelName})`;
break;
}
if (message.referenced_message) {
titleString += " (reply)";
}
if (message.embeds.length > 0) {
finalMsg += " [embed] ";
if (message.content === "") {
finalMsg = "sent message embed(s)";
}
}
if (message.sticker_items) {
finalMsg += " [sticker] ";
if (message.content === "") {
finalMsg = "sent a sticker";
}
}
const images = message.attachments.filter(e =>
typeof e?.content_type === "string"
&& e?.content_type.startsWith("image")
);
images.forEach(img => {
finalMsg += ` [image: ${img.filename}] `;
});
message.attachments.filter(a => a && !a.content_type?.startsWith("image")).forEach(a => {
finalMsg += ` [attachment: ${a.filename}] `;
});
// make mentions readable
if (message.mentions.length > 0) {
finalMsg = finalMsg.replace(/<@!?(\d{17,20})>/g, (_, id) => `<color=#${pingColor}><b>@${UserStore.getUser(id)?.username || "unknown-user"}</color></b>`);
}
if (message.mention_roles.length > 0) {
for (const roleId of message.mention_roles) {
const role = GuildStore.getGuild(channel.guild_id).roles[roleId];
if (!role) continue;
const roleColor = role.colorString ?? `#${pingColor}`;
finalMsg = finalMsg.replace(`<@&${roleId}>`, `<b><color=${roleColor}>@${role.name}</color></b>`);
}
}
// make emotes and channel mentions readable
const emoteMatches = finalMsg.match(new RegExp("(<a?:\\w+:\\d+>)", "g"));
const channelMatches = finalMsg.match(new RegExp("<(#\\d+)>", "g"));
if (emoteMatches) {
for (const eMatch of emoteMatches) {
finalMsg = finalMsg.replace(new RegExp(`${eMatch}`, "g"), `:${eMatch.split(":")[1]}:`);
}
}
if (channelMatches) {
for (const cMatch of channelMatches) {
let channelId = cMatch.split("<#")[1];
channelId = channelId.substring(0, channelId.length - 1);
finalMsg = finalMsg.replace(new RegExp(`${cMatch}`, "g"), `<b><color=#${channelPingColor}>#${ChannelStore.getChannel(channelId).name}</color></b>`);
}
}
sendMsgNotif(titleString, finalMsg, message);
} catch (err) {
XSLog.error(`Failed to catch MESSAGE_CREATE: ${err}`);
}
}
}
});
function sendMsgNotif(titleString: string, content: string, message: Message) {
fetch(`https://cdn.discordapp.com/avatars/${message.author.id}/${message.author.avatar}.png?size=128`).then(response => response.arrayBuffer()).then(result => {
const msgData = {
messageType: 1,
index: 0,
timeout: settings.store.timeout,
height: calculateHeight(cleanMessage(content)),
opacity: settings.store.opacity,
volume: settings.store.volume,
audioPath: settings.store.soundPath,
title: titleString,
content: content,
useBase64Icon: true,
icon: result,
sourceApp: "Vencord"
};
Native.sendToOverlay(msgData);
});
}
function sendOtherNotif(content: string, titleString: string) {
const msgData = {
messageType: 1,
index: 0,
timeout: settings.store.timeout,
height: calculateHeight(cleanMessage(content)),
opacity: settings.store.opacity,
volume: settings.store.volume,
audioPath: settings.store.soundPath,
title: titleString,
content: content,
useBase64Icon: false,
icon: null,
sourceApp: "Vencord"
};
Native.sendToOverlay(msgData);
}
function shouldNotify(message: Message, channel: Channel) {
const currentUser = UserStore.getCurrentUser();
if (message.author.id === currentUser.id) return false;
if (message.author.bot && settings.store.ignoreBots) return false;
if (MuteStore.allowAllMessages(channel) || message.mention_everyone && !MuteStore.isSuppressEveryoneEnabled(message.guild_id)) return true;
return message.mentions.some(m => m.id === currentUser.id);
}
function calculateHeight(content: string) {
if (content.length <= 100) return 100;
if (content.length <= 200) return 150;
if (content.length <= 300) return 200;
return 250;
}
function cleanMessage(content: string) {
return content.replace(new RegExp("<[^>]*>", "g"), "");
}

View file

@ -0,0 +1,16 @@
/*
* Vencord, a Discord client mod
* Copyright (c) 2023 Vendicated and contributors
* SPDX-License-Identifier: GPL-3.0-or-later
*/
import { createSocket, Socket } from "dgram";
let xsoSocket: Socket;
export function sendToOverlay(_, data: any) {
data.icon = Buffer.from(data.icon).toString("base64");
const json = JSON.stringify(data);
xsoSocket ??= createSocket("udp4");
xsoSocket.send(json, 42069, "127.0.0.1");
}

View file

@ -19,8 +19,7 @@
import * as DataStore from "@api/DataStore";
import { showNotification } from "@api/Notifications";
import { Settings } from "@api/Settings";
import { findByProps } from "@webpack";
import { UserStore } from "@webpack/common";
import { OAuth2AuthorizeModal, UserStore } from "@webpack/common";
import { Logger } from "./Logger";
import { openModal } from "./modal";
@ -91,8 +90,6 @@ export async function authorizeCloud() {
return;
}
const { OAuth2AuthorizeModal } = findByProps("OAuth2AuthorizeModal");
openModal((props: any) => <OAuth2AuthorizeModal
{...props}
scopes={["identify"]}

View file

@ -78,8 +78,8 @@ export const Devs = /* #__PURE__*/ Object.freeze({
name: "Samu",
id: 702973430449832038n,
},
Animal: {
name: "Animal",
Nyako: {
name: "nyako",
id: 118437263754395652n
},
MaiKokain: {
@ -391,6 +391,18 @@ export const Devs = /* #__PURE__*/ Object.freeze({
name: "ant0n",
id: 145224646868860928n
},
philipbry: {
name: "philipbry",
id: 554994003318276106n
},
Korbo: {
name: "Korbo",
id: 455856406420258827n
},
maisymoe: {
name: "maisy",
id: 257109471589957632n,
},
} satisfies Record<string, Dev>);
// iife so #__PURE__ works correctly

View file

@ -17,14 +17,42 @@
*/
import { MessageObject } from "@api/MessageEvents";
import { findByPropsLazy } from "@webpack";
import { ChannelStore, ComponentDispatch, FluxDispatcher, GuildStore, MaskedLink, ModalImageClasses, PrivateChannelsStore, RestAPI, SelectedChannelStore, SelectedGuildStore, UserProfileStore, UserSettingsActionCreators, UserUtils } from "@webpack/common";
import { ChannelStore, ComponentDispatch, FluxDispatcher, GuildStore, InviteActions, MaskedLink, MessageActions, ModalImageClasses, PrivateChannelsStore, RestAPI, SelectedChannelStore, SelectedGuildStore, UserProfileActions, UserProfileStore, UserSettingsActionCreators, UserUtils } from "@webpack/common";
import { Guild, Message, User } from "discord-types/general";
import { ImageModal, ModalRoot, ModalSize, openModal } from "./modal";
const MessageActions = findByPropsLazy("editMessage", "sendMessage");
const UserProfileActions = findByPropsLazy("openUserProfileModal", "closeUserProfileModal");
/**
* Open the invite modal
* @param code The invite code
* @returns Whether the invite was accepted
*/
export async function openInviteModal(code: string) {
const { invite } = await InviteActions.resolveInvite(code, "Desktop Modal");
if (!invite) throw new Error("Invalid invite: " + code);
FluxDispatcher.dispatch({
type: "INVITE_MODAL_OPEN",
invite,
code,
context: "APP"
});
return new Promise<boolean>(r => {
let onClose: () => void, onAccept: () => void;
let inviteAccepted = false;
FluxDispatcher.subscribe("INVITE_ACCEPT", onAccept = () => {
inviteAccepted = true;
});
FluxDispatcher.subscribe("INVITE_MODAL_CLOSE", onClose = () => {
FluxDispatcher.unsubscribe("INVITE_MODAL_CLOSE", onClose);
FluxDispatcher.unsubscribe("INVITE_ACCEPT", onAccept);
r(inviteAccepted);
});
});
}
export function getCurrentChannel() {
return ChannelStore.getChannel(SelectedChannelStore.getChannelId());

View file

@ -23,7 +23,7 @@ export function LazyComponent<T extends object = any>(factory: () => React.Compo
return <Component {...props} />;
};
LazyComponent.$$get = get;
LazyComponent.$$vencordInternal = get;
return LazyComponent as ComponentType<T>;
}

View file

@ -16,7 +16,7 @@
* along with this program. If not, see <https://www.gnu.org/licenses/>.
*/
import { findByProps, findByPropsLazy } from "@webpack";
import { findByPropsLazy, findExportedComponentLazy } from "@webpack";
import type { ComponentType, PropsWithChildren, ReactNode, Ref } from "react";
import { LazyComponent } from "./react";
@ -118,7 +118,7 @@ export type ImageModal = ComponentType<{
shouldHideMediaOptions?: boolean;
}>;
export const ImageModal = LazyComponent(() => findByProps("ImageModal").ImageModal as ImageModal);
export const ImageModal = findExportedComponentLazy("ImageModal") as ImageModal;
export const ModalRoot = LazyComponent(() => Modals.ModalRoot);
export const ModalHeader = LazyComponent(() => Modals.ModalHeader);

View file

@ -17,7 +17,7 @@
*/
// eslint-disable-next-line path-alias/no-relative
import { filters, waitFor } from "@webpack";
import { filters, findByPropsLazy, waitFor } from "@webpack";
import { waitForComponent } from "./internal";
import * as t from "./types/components";
@ -55,6 +55,8 @@ export const MaskedLink = waitForComponent<t.MaskedLink>("MaskedLink", m => m?.t
export const Timestamp = waitForComponent<t.Timestamp>("Timestamp", filters.byCode(".Messages.MESSAGE_EDITED_TIMESTAMP_A11Y_LABEL.format"));
export const Flex = waitForComponent<t.Flex>("Flex", ["Justify", "Align", "Wrap"]);
export const { OAuth2AuthorizeModal } = findByPropsLazy("OAuth2AuthorizeModal");
waitFor(["FormItem", "Button"], m => {
({ useToken, Card, Button, FormSwitch: Switch, Tooltip, TextInput, TextArea, Text, Select, SearchableSelect, Slider, ButtonLooks, TabBar, Popout, Dialog, Paginator, ScrollerThin, Clickable, Avatar } = m);
Forms = m;

View file

@ -126,6 +126,7 @@ export type Button = ComponentType<PropsWithChildren<Omit<HTMLProps<HTMLButtonEl
buttonRef?: Ref<HTMLButtonElement>;
focusProps?: any;
submitting?: boolean;
submittingStartedLabel?: string;
submittingFinishedLabel?: string;

View file

@ -19,7 +19,7 @@
import type { Channel, User } from "discord-types/general";
// eslint-disable-next-line path-alias/no-relative
import { _resolveReady, findByPropsLazy, findLazy, waitFor } from "../webpack";
import { _resolveReady, filters, findByCodeLazy, findByPropsLazy, findLazy, waitFor } from "../webpack";
import type * as t from "./types/utils";
export let FluxDispatcher: t.FluxDispatcher;
@ -127,5 +127,13 @@ export const NavigationRouter: t.NavigationRouter = findByPropsLazy("transitionT
export let SettingsRouter: any;
waitFor(["open", "saveAccountChanges"], m => SettingsRouter = m);
const { Permissions } = findLazy(m => typeof m.Permissions?.ADMINISTRATOR === "bigint") as { Permissions: t.PermissionsBits; };
export { Permissions as PermissionsBits };
export const { Permissions: PermissionsBits } = findLazy(m => typeof m.Permissions?.ADMINISTRATOR === "bigint") as { Permissions: t.PermissionsBits; };
export const zustandCreate: typeof import("zustand").default = findByCodeLazy("will be removed in v4");
const persistFilter = filters.byCode("[zustand persist middleware]");
export const { persist: zustandPersist }: typeof import("zustand/middleware") = findLazy(m => m.persist && persistFilter(m.persist));
export const MessageActions = findByPropsLazy("editMessage", "sendMessage");
export const UserProfileActions = findByPropsLazy("openUserProfileModal", "closeUserProfileModal");
export const InviteActions = findByPropsLazy("resolveInvite");

View file

@ -58,6 +58,9 @@ if (window[WEBPACK_CHUNK]) {
// normally, this is populated via webpackGlobal.push, which we patch below.
// However, Discord has their .m prepopulated.
// Thus, we use this hack to immediately access their wreq.m and patch all already existing factories
//
// Update: Discord now has TWO webpack instances. Their normal one and sentry
// Sentry does not push chunks to the global at all, so this same patch now also handles their sentry modules
Object.defineProperty(Function.prototype, "m", {
set(v: any) {
// When using react devtools or other extensions, we may also catch their webpack here.
@ -65,8 +68,6 @@ if (window[WEBPACK_CHUNK]) {
if (new Error().stack?.includes("discord.com")) {
logger.info("Found webpack module factory");
patchFactories(v);
delete (Function.prototype as any).m;
}
Object.defineProperty(this, "m", {
@ -142,7 +143,7 @@ function patchFactories(factories: Record<string | number, (module: { exports: a
// There are (at the time of writing) 11 modules exporting the window
// Make these non enumerable to improve webpack search performance
if (exports === window) {
if (exports === window && require.c) {
Object.defineProperty(require.c, id, {
value: require.c[id],
enumerable: false,
@ -152,11 +153,9 @@ function patchFactories(factories: Record<string | number, (module: { exports: a
return;
}
const numberId = Number(id);
for (const callback of listeners) {
try {
callback(exports, numberId);
callback(exports, id);
} catch (err) {
logger.error("Error in webpack listener", err);
}
@ -166,10 +165,10 @@ function patchFactories(factories: Record<string | number, (module: { exports: a
try {
if (filter(exports)) {
subscriptions.delete(filter);
callback(exports, numberId);
callback(exports, id);
} else if (exports.default && filter(exports.default)) {
subscriptions.delete(filter);
callback(exports.default, numberId);
callback(exports.default, id);
}
} catch (err) {
logger.error("Error while firing callback for webpack chunk", err);
@ -212,7 +211,7 @@ function patchFactories(factories: Record<string | number, (module: { exports: a
}
if (patch.group) {
logger.warn(`Undoing patch ${patch.find} by ${patch.plugin} because replacement ${replacement.match} had no effect`);
logger.warn(`Undoing patch group ${patch.find} by ${patch.plugin} because replacement ${replacement.match} had no effect`);
code = previousCode;
mod = previousMod;
patchedBy.delete(patch.plugin);
@ -260,7 +259,7 @@ function patchFactories(factories: Record<string | number, (module: { exports: a
patchedBy.delete(patch.plugin);
if (patch.group) {
logger.warn(`Undoing patch ${patch.find} by ${patch.plugin} because replacement ${replacement.match} errored`);
logger.warn(`Undoing patch group ${patch.find} by ${patch.plugin} because replacement ${replacement.match} errored`);
code = previousCode;
mod = previousMod;
break;

View file

@ -19,6 +19,7 @@
import { proxyLazy } from "@utils/lazy";
import { LazyComponent } from "@utils/lazyReact";
import { Logger } from "@utils/Logger";
import { canonicalizeMatch } from "@utils/patches";
import type { WebpackInstance } from "discord-types/other";
import { traceFunction } from "../debug/Tracer";
@ -69,7 +70,7 @@ export const filters = {
export const subscriptions = new Map<FilterFn, CallbackFn>();
export const listeners = new Set<CallbackFn>();
export type CallbackFn = (mod: any, id: number) => void;
export type CallbackFn = (mod: any, id: string) => void;
export function _initWebpack(instance: typeof window.webpackChunkdiscord_app) {
if (cache !== void 0) throw "no.";
@ -111,12 +112,12 @@ export const find = traceFunction("find", function find(filter: FilterFn, { isIn
if (!mod?.exports) continue;
if (filter(mod.exports)) {
return isWaitFor ? [mod.exports, Number(key)] : mod.exports;
return isWaitFor ? [mod.exports, key] : mod.exports;
}
if (mod.exports.default && filter(mod.exports.default)) {
const found = mod.exports.default;
return isWaitFor ? [found, Number(key)] : found;
return isWaitFor ? [found, key] : found;
}
}
@ -214,18 +215,21 @@ export const findBulk = traceFunction("findBulk", function findBulk(...filterFns
});
/**
* Find the id of a module by its code
* @param code Code
* @returns number or null
* Find the id of the first module factory that includes all the given code
* @returns string or null
*/
export const findModuleId = traceFunction("findModuleId", function findModuleId(code: string) {
export const findModuleId = traceFunction("findModuleId", function findModuleId(...code: string[]) {
outer:
for (const id in wreq.m) {
if (wreq.m[id].toString().includes(code)) {
return Number(id);
const str = wreq.m[id].toString();
for (const c of code) {
if (!str.includes(c)) continue outer;
}
return id;
}
const err = new Error("Didn't find module with code:\n" + code);
const err = new Error("Didn't find module with code(s):\n" + code.join("\n"));
if (IS_DEV) {
if (!devToolsOpen)
// Strict behaviour in DevBuilds to fail early and make sure the issue is found
@ -237,7 +241,18 @@ export const findModuleId = traceFunction("findModuleId", function findModuleId(
return null;
});
export const lazyWebpackSearchHistory = [] as Array<["find" | "findByProps" | "findByCode" | "findStore" | "findComponent" | "findComponentByCode" | "findExportedComponent" | "waitFor" | "waitForComponent" | "waitForStore" | "proxyLazyWebpack" | "LazyComponentWebpack", any[]]>;
/**
* Find the first module factory that includes all the given code
* @returns The module factory or null
*/
export function findModuleFactory(...code: string[]) {
const id = findModuleId(...code);
if (!id) return null;
return wreq.m[id];
}
export const lazyWebpackSearchHistory = [] as Array<["find" | "findByProps" | "findByCode" | "findStore" | "findComponent" | "findComponentByCode" | "findExportedComponent" | "waitFor" | "waitForComponent" | "waitForStore" | "proxyLazyWebpack" | "LazyComponentWebpack" | "extractAndLoadChunks", any[]]>;
/**
* This is just a wrapper around {@link proxyLazy} to make our reporter test for your webpack finds.
@ -272,7 +287,7 @@ export function LazyComponentWebpack<T extends object = any>(factory: () => any,
}
/**
* find but lazy
* Find the first module that matches the filter, lazily
*/
export function findLazy(filter: FilterFn) {
if (IS_DEV) lazyWebpackSearchHistory.push(["find", [filter]]);
@ -291,7 +306,7 @@ export function findByProps(...props: string[]) {
}
/**
* findByProps but lazy
* Find the first module that has the specified properties, lazily
*/
export function findByPropsLazy(...props: string[]) {
if (IS_DEV) lazyWebpackSearchHistory.push(["findByProps", props]);
@ -300,7 +315,7 @@ export function findByPropsLazy(...props: string[]) {
}
/**
* Find a function by its code
* Find the first function that includes all the given code
*/
export function findByCode(...code: string[]) {
const res = find(filters.byCode(...code), { isIndirect: true });
@ -310,7 +325,7 @@ export function findByCode(...code: string[]) {
}
/**
* findByCode but lazy
* Find the first function that includes all the given code, lazily
*/
export function findByCodeLazy(...code: string[]) {
if (IS_DEV) lazyWebpackSearchHistory.push(["findByCode", code]);
@ -329,7 +344,7 @@ export function findStore(name: string) {
}
/**
* findStore but lazy
* Find a store by its displayName, lazily
*/
export function findStoreLazy(name: string) {
if (IS_DEV) lazyWebpackSearchHistory.push(["findStore", [name]]);
@ -353,7 +368,13 @@ export function findComponentByCode(...code: string[]) {
export function findComponentLazy<T extends object = any>(filter: FilterFn) {
if (IS_DEV) lazyWebpackSearchHistory.push(["findComponent", [filter]]);
return LazyComponent<T>(() => find(filter));
return LazyComponent<T>(() => {
const res = find(filter, { isIndirect: true });
if (!res)
handleModuleNotFound("findComponent", filter);
return res;
});
}
/**
@ -362,7 +383,12 @@ export function findComponentLazy<T extends object = any>(filter: FilterFn) {
export function findComponentByCodeLazy<T extends object = any>(...code: string[]) {
if (IS_DEV) lazyWebpackSearchHistory.push(["findComponentByCode", code]);
return LazyComponent<T>(() => findComponentByCode(...code));
return LazyComponent<T>(() => {
const res = find(filters.componentByCode(...code), { isIndirect: true });
if (!res)
handleModuleNotFound("findComponentByCode", ...code);
return res;
});
}
/**
@ -371,7 +397,68 @@ export function findComponentByCodeLazy<T extends object = any>(...code: string[
export function findExportedComponentLazy<T extends object = any>(...props: string[]) {
if (IS_DEV) lazyWebpackSearchHistory.push(["findExportedComponent", props]);
return LazyComponent<T>(() => findByProps(...props)?.[props[0]]);
return LazyComponent<T>(() => {
const res = find(filters.byProps(...props), { isIndirect: true });
if (!res)
handleModuleNotFound("findExportedComponent", ...props);
return res[props[0]];
});
}
/**
* Extract and load chunks using their entry point
* @param code An array of all the code the module factory containing the entry point (as of using it to load chunks) must include
* @param matcher A RegExp that returns the entry point id as the first capture group. Defaults to a matcher that captures the first entry point found in the module factory
*/
export async function extractAndLoadChunks(code: string[], matcher: RegExp = /\.el\("(.+?)"\)(?<=(\i)\.el.+?)\.then\(\2\.bind\(\2,"\1"\)\)/) {
const module = findModuleFactory(...code);
if (!module) {
const err = new Error("extractAndLoadChunks: Couldn't find module factory");
logger.warn(err, "Code:", code, "Matcher:", matcher);
return;
}
const match = module.toString().match(canonicalizeMatch(matcher));
if (!match) {
const err = new Error("extractAndLoadChunks: Couldn't find entry point id in module factory code");
logger.warn(err, "Code:", code, "Matcher:", matcher);
// Strict behaviour in DevBuilds to fail early and make sure the issue is found
if (IS_DEV && !devToolsOpen)
throw err;
return;
}
const [, id] = match;
if (!id || !Number(id)) {
const err = new Error("extractAndLoadChunks: Matcher didn't return a capturing group with the entry point, or the entry point returned wasn't a number");
logger.warn(err, "Code:", code, "Matcher:", matcher);
// Strict behaviour in DevBuilds to fail early and make sure the issue is found
if (IS_DEV && !devToolsOpen)
throw err;
return;
}
await (wreq as any).el(id);
return wreq(id as any);
}
/**
* This is just a wrapper around {@link extractAndLoadChunks} to make our reporter test for your webpack finds.
*
* Extract and load chunks using their entry point
* @param code An array of all the code the module factory containing the entry point (as of using it to load chunks) must include
* @param matcher A RegExp that returns the entry point id as the first capture group. Defaults to a matcher that captures the first entry point found in the module factory
* @returns A function that loads the chunks on first call
*/
export function extractAndLoadChunksLazy(code: string[], matcher: RegExp = /\.el\("(.+?)"\)(?<=(\i)\.el.+?)\.then\(\2\.bind\(\2,"\1"\)\)/) {
if (IS_DEV) lazyWebpackSearchHistory.push(["extractAndLoadChunks", [code, matcher]]);
return () => extractAndLoadChunks(code, matcher);
}
/**
@ -433,7 +520,7 @@ export function search(...filters: Array<string | RegExp>) {
* so putting breakpoints or similar will have no effect.
* @param id The id of the module to extract
*/
export function extract(id: number) {
export function extract(id: string | number) {
const mod = wreq.m[id] as Function;
if (!mod) return null;

View file

@ -2,6 +2,7 @@
"compilerOptions": {
"allowSyntheticDefaultImports": true,
"esModuleInterop": true,
"skipLibCheck": true,
"lib": [
"DOM",
"DOM.Iterable",