Compare commits

..

26 Commits

Author SHA1 Message Date
Vendicated
09e919f0c6 bump to 1.1.6 2023-04-08 03:53:32 +02:00
V
eaf1af75bd WebContextMenus: Port more menus (#818)
Co-authored-by: Nuckyz <61953774+Nuckyz@users.noreply.github.com>
2023-04-08 03:51:37 +02:00
exit
7c514e4b1d SupportHelper: Add missing dependency - CommandsAPI (#823)
Co-authored-by: V <vendicated@riseup.net>
2023-04-07 19:19:12 +00:00
LordElias
1432baa28b ignore userplugins when linting (#822)
Co-authored-by: V <vendicated@riseup.net>
2023-04-07 19:17:54 +00:00
exit
f1f61195c3 InvisibleChat: Add missing dependency on MessagePopoverAPI (#817)
Co-authored-by: V <vendicated@riseup.net>
2023-04-07 19:16:18 +00:00
Đỗ Văn Hoài Tuân
8fefa2b716 FakeNitro: Fix stickers with space in name #819 (#820) 2023-04-07 21:15:11 +02:00
Ryan Cao
2a0c30b66d feat(moreusertags): add option to not show more tags for bots (#812)
Co-authored-by: V <vendicated@riseup.net>
2023-04-07 00:31:21 +00:00
Lewis Crichton
97f8d4d515 feat: Cloud settings sync (#505)
Co-authored-by: Ven <vendicated@riseup.net>
2023-04-07 02:27:18 +02:00
Vendicated
2672dea8e3 ci: bump action 2023-04-06 03:34:02 +02:00
Vendicated
63f5b0a663 bump pnpm to v8 2023-04-06 03:34:02 +02:00
ActuallyTheSun
e40ebacc5b feat(plugin): WebhookTags -> MoreUserTags (#378)
Co-authored-by: Cloudburst <18114966+C10udburst@users.noreply.github.com>
Co-authored-by: Vendicated <vendicated@riseup.net>
2023-04-06 03:28:38 +02:00
LordElias
e261c93563 feat(plugin): User Voice Show (#694)
Co-authored-by: V <vendicated@riseup.net>
Co-authored-by: Nuckyz <61953774+Nuckyz@users.noreply.github.com>
2023-04-06 03:22:54 +02:00
Syncx
df7357b357 feat(plugin): Image Zoom (#510)
Co-authored-by: Nuckyz <61953774+Nuckyz@users.noreply.github.com>
Co-authored-by: Ven <vendicated@riseup.net>
2023-04-06 01:06:11 +00:00
Đỗ Văn Hoài Tuân
2e6c5eacf7 BetterFolders: Fix Close all not working (#808) 2023-04-06 03:02:53 +02:00
Dziurwa
c9fd404012 Fix FriendInvites (#802)
Co-authored-by: Vendicated <vendicated@riseup.net>
2023-04-05 23:01:11 +02:00
V
814302e272 Fix Badges (#801) 2023-04-05 22:45:14 +02:00
Anubis
72ba83924c SpotifyControls: add album art hover transition (#797) 2023-04-05 22:45:03 +02:00
Nuckyz
9d742094cb ShowHiddenChannels: Use Discord's new overlay vars (#795)
* Fix SHC css for new Discord vars

* I'm dumb

* improvements to work with themes

---------

Co-authored-by: V <vendicated@riseup.net>
2023-04-05 20:44:03 +00:00
Nuckyz
38f3aac98d Fix VolumeBooster and improve ContextMenuAPI patch (#793)
Co-authored-by: V <vendicated@riseup.net>
2023-04-05 03:07:17 +00:00
Nuckyz
12ffb9d642 Fake Nitro Transform Stickers option and other stuff (#683)
Co-authored-by: V <vendicated@riseup.net>
2023-04-05 05:06:04 +02:00
Vendicated
99391a4f0e fix generatePluginList 2023-04-05 04:54:54 +02:00
Vendicated
6492908a62 VencordDesktop: Fix Updater 2023-04-05 04:34:39 +02:00
Vendicated
676bc612d9 VencordDesktop: Include web plugins & use proper showItemInFolder 2023-04-05 04:09:42 +02:00
Vendicated
d8a5e43034 Fix Themes Tab 2023-04-04 22:24:16 +02:00
Vendicated
8ad710abca Fix ContextMenuAPI 2023-04-04 22:19:52 +02:00
Vendicated
368cb7bc6b Fix Toasts 2023-04-04 21:51:03 +02:00
51 changed files with 2750 additions and 859 deletions

View File

@ -15,7 +15,7 @@ jobs:
- uses: pnpm/action-setup@v2 # Install pnpm using packageManager key in package.json - uses: pnpm/action-setup@v2 # Install pnpm using packageManager key in package.json
- name: Use Node.js 18 - name: Use Node.js 18
uses: actions/setup-node@v2 uses: actions/setup-node@v3
with: with:
node-version: 18 node-version: 18
cache: "pnpm" cache: "pnpm"

View File

@ -1,7 +1,7 @@
{ {
"name": "vencord", "name": "vencord",
"private": "true", "private": "true",
"version": "1.1.5", "version": "1.1.6",
"description": "The cutest Discord client mod", "description": "The cutest Discord client mod",
"homepage": "https://github.com/Vendicated/Vencord#readme", "homepage": "https://github.com/Vendicated/Vencord#readme",
"bugs": { "bugs": {
@ -21,8 +21,8 @@
"buildWeb": "node --require=./scripts/suppressExperimentalWarnings.js scripts/build/buildWeb.mjs", "buildWeb": "node --require=./scripts/suppressExperimentalWarnings.js scripts/build/buildWeb.mjs",
"generatePluginJson": "tsx scripts/generatePluginList.ts", "generatePluginJson": "tsx scripts/generatePluginList.ts",
"inject": "node scripts/runInstaller.mjs", "inject": "node scripts/runInstaller.mjs",
"lint": "eslint . --ext .js,.jsx,.ts,.tsx", "lint": "eslint . --ext .js,.jsx,.ts,.tsx --ignore-pattern src/userplugins",
"lint-styles": "stylelint \"src/**/*.css\"", "lint-styles": "stylelint \"src/**/*.css\" --ignore-pattern src/userplugins",
"lint:fix": "pnpm lint --fix", "lint:fix": "pnpm lint --fix",
"test": "pnpm build && pnpm lint && pnpm lint-styles && pnpm testTsc", "test": "pnpm build && pnpm lint && pnpm lint-styles && pnpm testTsc",
"testWeb": "pnpm lint && pnpm buildWeb && pnpm testTsc", "testWeb": "pnpm lint && pnpm buildWeb && pnpm testTsc",
@ -65,7 +65,7 @@
"type-fest": "^3.5.3", "type-fest": "^3.5.3",
"typescript": "^4.9.4" "typescript": "^4.9.4"
}, },
"packageManager": "pnpm@7.13.4", "packageManager": "pnpm@8.1.1",
"pnpm": { "pnpm": {
"patchedDependencies": { "patchedDependencies": {
"eslint-plugin-path-alias@1.0.0": "patches/eslint-plugin-path-alias@1.0.0.patch", "eslint-plugin-path-alias@1.0.0": "patches/eslint-plugin-path-alias@1.0.0.patch",
@ -92,6 +92,7 @@
"sourceDir": "./dist/extension-v2-unpacked" "sourceDir": "./dist/extension-v2-unpacked"
}, },
"engines": { "engines": {
"node": ">=18" "node": ">=18",
"pnpm": ">=8"
} }
} }

1153
pnpm-lock.yaml generated

File diff suppressed because it is too large Load Diff

View File

@ -80,7 +80,7 @@ await Promise.all([
globalName: "Vencord", globalName: "Vencord",
sourcemap, sourcemap,
plugins: [ plugins: [
globPlugins, globPlugins("discordDesktop"),
...commonOpts.plugins ...commonOpts.plugins
], ],
define: { define: {
@ -114,7 +114,7 @@ await Promise.all([
globalName: "Vencord", globalName: "Vencord",
sourcemap, sourcemap,
plugins: [ plugins: [
globPlugins, globPlugins("vencordDesktop"),
...commonOpts.plugins ...commonOpts.plugins
], ],
define: { define: {

View File

@ -38,7 +38,7 @@ const commonOptions = {
format: "iife", format: "iife",
external: ["plugins", "git-hash", "/assets/*"], external: ["plugins", "git-hash", "/assets/*"],
plugins: [ plugins: [
globPlugins, globPlugins("web"),
...commonOpts.plugins, ...commonOpts.plugins,
], ],
target: ["esnext"], target: ["esnext"],

View File

@ -48,9 +48,9 @@ export const makeAllPackagesExternalPlugin = {
}; };
/** /**
* @type {import("esbuild").Plugin} * @type {(kind: "web" | "discordDesktop" | "vencordDesktop") => import("esbuild").Plugin}
*/ */
export const globPlugins = { export const globPlugins = kind => ({
name: "glob-plugins", name: "glob-plugins",
setup: build => { setup: build => {
const filter = /^~plugins$/; const filter = /^~plugins$/;
@ -76,8 +76,10 @@ export const globPlugins = {
if (fileBits.length > 2 && ["ts", "tsx"].includes(fileBits.at(-1))) { if (fileBits.length > 2 && ["ts", "tsx"].includes(fileBits.at(-1))) {
const mod = fileBits.at(-2); const mod = fileBits.at(-2);
if (mod === "dev" && !watch) continue; if (mod === "dev" && !watch) continue;
if (mod === "web" && !isWeb) continue; if (mod === "web" && kind === "discordDesktop") continue;
if (mod === "desktop" && isWeb) continue; if (mod === "desktop" && kind === "web") continue;
if (mod === "discordDesktop" && kind !== "discordDesktop") continue;
if (mod === "vencordDesktop" && kind !== "vencordDesktop") continue;
} }
const mod = `p${i}`; const mod = `p${i}`;
@ -93,7 +95,7 @@ export const globPlugins = {
}; };
}); });
} }
}; });
/** /**
* @type {import("esbuild").Plugin} * @type {import("esbuild").Plugin}

View File

@ -35,7 +35,7 @@ interface PluginData {
hasCommands: boolean; hasCommands: boolean;
required: boolean; required: boolean;
enabledByDefault: boolean; enabledByDefault: boolean;
target: "desktop" | "web" | "dev"; target: "discordDesktop" | "vencordDesktop" | "web" | "dev";
} }
const devs = {} as Record<string, Dev>; const devs = {} as Record<string, Dev>;
@ -150,7 +150,7 @@ async function parseFile(fileName: string) {
const fileBits = fileName.split("."); const fileBits = fileName.split(".");
if (fileBits.length > 2 && ["ts", "tsx"].includes(fileBits.at(-1)!)) { if (fileBits.length > 2 && ["ts", "tsx"].includes(fileBits.at(-1)!)) {
const mod = fileBits.at(-2)!; const mod = fileBits.at(-2)!;
if (!["web", "desktop", "dev"].includes(mod)) throw fail(`invalid target ${fileBits.at(-2)}`); if (!["web", "discordDesktop", "vencordDesktop", "dev"].includes(mod)) throw fail(`invalid target ${fileBits.at(-2)}`);
data.target = mod as any; data.target = mod as any;
} }

View File

@ -30,17 +30,44 @@ import "./webpack/patchWebpack";
import { showNotification } from "./api/Notifications"; import { showNotification } from "./api/Notifications";
import { PlainSettings, Settings } from "./api/settings"; import { PlainSettings, Settings } from "./api/settings";
import { patches, PMLogger, startAllPlugins } from "./plugins"; import { patches, PMLogger, startAllPlugins } from "./plugins";
import { checkForUpdates, rebuild, update,UpdateLogger } from "./utils/updater"; import { localStorage } from "./utils/localStorage";
import { getCloudSettings, putCloudSettings } from "./utils/settingsSync";
import { checkForUpdates, rebuild, update, UpdateLogger } from "./utils/updater";
import { onceReady } from "./webpack"; import { onceReady } from "./webpack";
import { SettingsRouter } from "./webpack/common"; import { SettingsRouter } from "./webpack/common";
export let Components: any; export let Components: any;
async function syncSettings() {
if (
Settings.cloud.settingsSync && // if it's enabled
Settings.cloud.authenticated // if cloud integrations are enabled
) {
if (localStorage.Vencord_settingsDirty) {
await putCloudSettings();
delete localStorage.Vencord_settingsDirty;
} else if (await getCloudSettings(false)) { // if we synchronized something (false means no sync)
// we show a notification here instead of allowing getCloudSettings() to show one to declutter the amount of
// potential notifications that might occur. getCloudSettings() will always send a notification regardless if
// there was an error to notify the user, but besides that we only want to show one notification instead of all
// of the possible ones it has (such as when your settings are newer).
showNotification({
title: "Cloud Settings",
body: "Your settings have been updated! Click here to restart to fully apply changes!",
color: "var(--green-360)",
onClick: () => window.DiscordNative.app.relaunch()
});
}
}
}
async function init() { async function init() {
await onceReady; await onceReady;
startAllPlugins(); startAllPlugins();
Components = await import("./components"); Components = await import("./components");
syncSettings();
if (!IS_WEB) { if (!IS_WEB) {
try { try {
const isOutdated = await checkForUpdates(); const isOutdated = await checkForUpdates();

View File

@ -16,9 +16,12 @@
* along with this program. If not, see <https://www.gnu.org/licenses/>. * along with this program. If not, see <https://www.gnu.org/licenses/>.
*/ */
import { debounce } from "@utils/debounce";
import IpcEvents from "@utils/IpcEvents"; import IpcEvents from "@utils/IpcEvents";
import { localStorage } from "@utils/localStorage";
import Logger from "@utils/Logger"; import Logger from "@utils/Logger";
import { mergeDefaults } from "@utils/misc"; import { mergeDefaults } from "@utils/misc";
import { putCloudSettings } from "@utils/settingsSync";
import { DefinedSettings, OptionType, SettingsChecks, SettingsDefinition } from "@utils/types"; import { DefinedSettings, OptionType, SettingsChecks, SettingsDefinition } from "@utils/types";
import { React } from "@webpack/common"; import { React } from "@webpack/common";
@ -49,6 +52,13 @@ export interface Settings {
useNative: "always" | "never" | "not-focused"; useNative: "always" | "never" | "not-focused";
logLimit: number; logLimit: number;
}; };
cloud: {
authenticated: boolean;
url: string;
settingsSync: boolean;
settingsSyncVersion: number;
};
} }
const DefaultSettings: Settings = { const DefaultSettings: Settings = {
@ -69,6 +79,13 @@ const DefaultSettings: Settings = {
position: "bottom-right", position: "bottom-right",
useNative: "not-focused", useNative: "not-focused",
logLimit: 50 logLimit: 50
},
cloud: {
authenticated: false,
url: "https://api.vencord.dev/",
settingsSync: false,
settingsSyncVersion: 0
} }
}; };
@ -80,6 +97,13 @@ try {
logger.error("An error occurred while loading the settings. Corrupt settings file?\n", err); logger.error("An error occurred while loading the settings. Corrupt settings file?\n", err);
} }
const saveSettingsOnFrequentAction = debounce(async () => {
if (Settings.cloud.settingsSync && Settings.cloud.authenticated) {
await putCloudSettings();
delete localStorage.Vencord_settingsDirty;
}
}, 60_000);
type SubscriptionCallback = ((newValue: any, path: string) => void) & { _path?: string; }; type SubscriptionCallback = ((newValue: any, path: string) => void) & { _path?: string; };
const subscriptions = new Set<SubscriptionCallback>(); const subscriptions = new Set<SubscriptionCallback>();
@ -142,6 +166,9 @@ function makeProxy(settings: any, root = settings, path = ""): Settings {
} }
} }
// And don't forget to persist the settings! // And don't forget to persist the settings!
PlainSettings.cloud.settingsSyncVersion = Date.now();
localStorage.Vencord_settingsDirty = true;
saveSettingsOnFrequentAction();
VencordNative.ipc.invoke(IpcEvents.SET_SETTINGS, JSON.stringify(root, null, 4)); VencordNative.ipc.invoke(IpcEvents.SET_SETTINGS, JSON.stringify(root, null, 4));
return true; return true;
} }

View File

@ -0,0 +1,164 @@
/*
* Vencord, a modification for Discord's desktop app
* Copyright (c) 2023 Vendicated and contributors
*
* This program is free software: you can redistribute it and/or modify
* it under the terms of the GNU General Public License as published by
* the Free Software Foundation, either version 3 of the License, or
* (at your option) any later version.
*
* This program is distributed in the hope that it will be useful,
* but WITHOUT ANY WARRANTY; without even the implied warranty of
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
* GNU General Public License for more details.
*
* You should have received a copy of the GNU General Public License
* along with this program. If not, see <https://www.gnu.org/licenses/>.
*/
import { showNotification } from "@api/Notifications";
import { Settings, useSettings } from "@api/settings";
import { CheckedTextInput } from "@components/CheckedTextInput";
import ErrorBoundary from "@components/ErrorBoundary";
import { Link } from "@components/Link";
import { authorizeCloud, cloudLogger, deauthorizeCloud, getCloudAuth, getCloudUrl } from "@utils/cloud";
import { Margins } from "@utils/margins";
import { deleteCloudSettings, getCloudSettings, putCloudSettings } from "@utils/settingsSync";
import { Alerts, Button, Forms, Switch, Tooltip } from "@webpack/common";
function validateUrl(url: string) {
try {
new URL(url);
return true;
} catch {
return "Invalid URL";
}
}
async function eraseAllData() {
const res = await fetch(new URL("/v1/", getCloudUrl()), {
method: "DELETE",
headers: new Headers({
Authorization: await getCloudAuth()
})
});
if (!res.ok) {
cloudLogger.error(`Failed to erase data, API returned ${res.status}`);
showNotification({
title: "Cloud Integrations",
body: `Could not erase all data (API returned ${res.status}), please contact support.`,
color: "var(--red-360)"
});
return;
}
Settings.cloud.authenticated = false;
await deauthorizeCloud();
showNotification({
title: "Cloud Integrations",
body: "Successfully erased all data.",
color: "var(--green-360)"
});
}
function SettingsSyncSection() {
const { cloud } = useSettings(["cloud.authenticated", "cloud.settingsSync"]);
const sectionEnabled = cloud.authenticated && cloud.settingsSync;
return (
<Forms.FormSection title="Settings Sync" className={Margins.top16}>
<Forms.FormText variant="text-md/normal" className={Margins.bottom20}>
Synchronize your settings to the cloud. This allows easy synchronization across multiple devices with
minimal effort.
</Forms.FormText>
<Switch
key="cloud-sync"
disabled={!cloud.authenticated}
value={cloud.settingsSync}
onChange={v => { cloud.settingsSync = v; }}
>
Settings Sync
</Switch>
<div className="vc-cloud-settings-sync-grid">
<Button
size={Button.Sizes.SMALL}
disabled={!sectionEnabled}
onClick={() => putCloudSettings()}
>Sync to Cloud</Button>
<Tooltip text="This will overwrite your local settings with the ones on the cloud. Use wisely!">
{({ onMouseLeave, onMouseEnter }) => (
<Button
onMouseLeave={onMouseLeave}
onMouseEnter={onMouseEnter}
size={Button.Sizes.SMALL}
color={Button.Colors.RED}
disabled={!sectionEnabled}
onClick={() => getCloudSettings(true, true)}
>Sync from Cloud</Button>
)}
</Tooltip>
<Button
size={Button.Sizes.SMALL}
color={Button.Colors.RED}
disabled={!sectionEnabled}
onClick={() => deleteCloudSettings()}
>Delete Cloud Settings</Button>
</div>
</Forms.FormSection>
);
}
function CloudTab() {
const settings = useSettings(["cloud.authenticated", "cloud.url"]);
return (
<>
<Forms.FormSection title="Cloud Settings" className={Margins.top16}>
<Forms.FormText variant="text-md/normal" className={Margins.bottom20}>
Vencord comes with a cloud integration that adds goodies like settings sync across devices.
It <Link href="https://vencord.dev/cloud/privacy">respects your privacy</Link>, and
the <Link href="https://github.com/Vencord/Backend">source code</Link> is AGPL 3.0 licensed so you
can host it yourself.
</Forms.FormText>
<Switch
key="backend"
value={settings.cloud.authenticated}
onChange={v => { v && authorizeCloud(); if (!v) settings.cloud.authenticated = v; }}
note="This will request authorization if you have not yet set up cloud integrations."
>
Enable Cloud Integrations
</Switch>
<Forms.FormTitle tag="h5">Backend URL</Forms.FormTitle>
<Forms.FormText className={Margins.bottom8}>
Which backend to use when using cloud integrations.
</Forms.FormText>
<CheckedTextInput
key="backendUrl"
value={settings.cloud.url}
onChange={v => { settings.cloud.url = v; settings.cloud.authenticated = false; deauthorizeCloud(); }}
validate={validateUrl}
/>
<Button
className={Margins.top8}
size={Button.Sizes.MEDIUM}
color={Button.Colors.RED}
disabled={!settings.cloud.authenticated}
onClick={() => Alerts.show({
title: "Are you sure?",
body: "Once your data is erased, we cannot recover it. There's no going back!",
onConfirm: eraseAllData,
confirmText: "Erase it!",
confirmColor: "vc-cloud-erase-data-danger-btn",
cancelText: "Nevermind"
})}
>Erase All Data</Button>
<Forms.FormDivider className={Margins.top16} />
</Forms.FormSection >
<SettingsSyncSection />
</>
);
}
export default ErrorBoundary.wrap(CloudTab);

View File

@ -117,7 +117,7 @@ export default ErrorBoundary.wrap(function () {
<Forms.FormTitle tag="h5">Themes</Forms.FormTitle> <Forms.FormTitle tag="h5">Themes</Forms.FormTitle>
<TextArea <TextArea
value={themeText} value={themeText}
onChange={e => setThemeText(e.currentTarget.value)} onChange={setThemeText}
className={`${TextAreaProps.textarea} vc-settings-theme-links`} className={`${TextAreaProps.textarea} vc-settings-theme-links`}
placeholder="Theme Links" placeholder="Theme Links"
spellCheck={false} spellCheck={false}

View File

@ -26,7 +26,7 @@ import { ErrorCard } from "@components/ErrorCard";
import IpcEvents from "@utils/IpcEvents"; import IpcEvents from "@utils/IpcEvents";
import { Margins } from "@utils/margins"; import { Margins } from "@utils/margins";
import { identity, useAwaiter } from "@utils/misc"; import { identity, useAwaiter } from "@utils/misc";
import { relaunch } from "@utils/native"; import { relaunch, showItemInFolder } from "@utils/native";
import { Button, Card, Forms, React, Select, Slider, Switch } from "@webpack/common"; import { Button, Card, Forms, React, Select, Slider, Switch } from "@webpack/common";
const cl = classNameFactory("vc-settings-"); const cl = classNameFactory("vc-settings-");
@ -65,7 +65,7 @@ function VencordSettings() {
title: "Enable React Developer Tools", title: "Enable React Developer Tools",
note: "Requires a full restart" note: "Requires a full restart"
}, },
!IS_WEB && (!isWindows ? { !IS_WEB && (!IS_DISCORD_DESKTOP || !isWindows ? {
key: "frameless", key: "frameless",
title: "Disable the window frame", title: "Disable the window frame",
note: "Requires a full restart" note: "Requires a full restart"
@ -112,8 +112,7 @@ function VencordSettings() {
Open QuickCSS File Open QuickCSS File
</Button> </Button>
<Button <Button
// FIXME: Vencord Desktop support onClick={() => showItemInFolder(settingsDir)}
onClick={() => window.DiscordNative.fileManager.showItemInFolder(settingsDir)}
size={Button.Sizes.SMALL} size={Button.Sizes.SMALL}
disabled={settingsDirPending}> disabled={settingsDirPending}>
Open Settings Folder Open Settings Folder

View File

@ -24,6 +24,7 @@ import { handleComponentFailed } from "@components/handleComponentFailed";
import { Forms, SettingsRouter, TabBar, Text } from "@webpack/common"; import { Forms, SettingsRouter, TabBar, Text } from "@webpack/common";
import BackupRestoreTab from "./BackupRestoreTab"; import BackupRestoreTab from "./BackupRestoreTab";
import CloudTab from "./CloudTab";
import PluginsTab from "./PluginsTab"; import PluginsTab from "./PluginsTab";
import ThemesTab from "./ThemesTab"; import ThemesTab from "./ThemesTab";
import Updater from "./Updater"; import Updater from "./Updater";
@ -45,7 +46,8 @@ const SettingsTabs: Record<string, SettingsTab> = {
VencordPlugins: { name: "Plugins", component: () => <PluginsTab /> }, VencordPlugins: { name: "Plugins", component: () => <PluginsTab /> },
VencordThemes: { name: "Themes", component: () => <ThemesTab /> }, VencordThemes: { name: "Themes", component: () => <ThemesTab /> },
VencordUpdater: { name: "Updater" }, // Only show updater if IS_WEB is false VencordUpdater: { name: "Updater" }, // Only show updater if IS_WEB is false
VencordSettingsSync: { name: "Backup & Restore", component: () => <BackupRestoreTab /> }, VencordCloud: { name: "Cloud", component: () => <CloudTab /> },
VencordSettingsSync: { name: "Backup & Restore", component: () => <BackupRestoreTab /> }
}; };
if (!IS_WEB) SettingsTabs.VencordUpdater.component = () => Updater && <Updater />; if (!IS_WEB) SettingsTabs.VencordUpdater.component = () => Updater && <Updater />;

View File

@ -46,3 +46,14 @@
padding: 0.5em; padding: 0.5em;
border: 1px solid var(--background-modifier-accent); border: 1px solid var(--background-modifier-accent);
} }
.vc-cloud-settings-sync-grid {
display: grid;
grid-template-columns: repeat(3, 1fr);
grid-gap: 1em;
}
.vc-cloud-erase-data-danger-btn {
color: var(--white-500);
background-color: var(--button-danger-background);
}

View File

@ -24,7 +24,7 @@ export async function calculateHashes() {
const hashes = {} as Record<string, string>; const hashes = {} as Record<string, string>;
await Promise.all( await Promise.all(
["patcher.js", "preload.js", "renderer.js", "renderer.css"].map(file => new Promise<void>(r => { [IS_DISCORD_DESKTOP ? "patcher.js" : "main.js", "preload.js", "renderer.js", "renderer.css"].map(file => new Promise<void>(r => {
const fis = createReadStream(join(__dirname, file)); const fis = createReadStream(join(__dirname, file));
const hash = createHash("sha1", { encoding: "hex" }); const hash = createHash("sha1", { encoding: "hex" });
fis.once("end", () => { fis.once("end", () => {

View File

@ -16,7 +16,7 @@
* along with this program. If not, see <https://www.gnu.org/licenses/>. * along with this program. If not, see <https://www.gnu.org/licenses/>.
*/ */
import { BadgePosition, ProfileBadge } from "@api/Badges"; import { BadgePosition, BadgeUserArgs, ProfileBadge } from "@api/Badges";
import DonateButton from "@components/DonateButton"; import DonateButton from "@components/DonateButton";
import ErrorBoundary from "@components/ErrorBoundary"; import ErrorBoundary from "@components/ErrorBoundary";
import { Flex } from "@components/Flex"; import { Flex } from "@components/Flex";
@ -53,14 +53,14 @@ const DonorBadges = {} as Record<string, Pick<ProfileBadge, "image" | "tooltip">
export default definePlugin({ export default definePlugin({
name: "BadgeAPI", name: "BadgeAPI",
description: "API to add badges to users.", description: "API to add badges to users.",
authors: [Devs.Megu], authors: [Devs.Megu, Devs.Ven, Devs.TheSun],
required: true, required: true,
patches: [ patches: [
/* Patch the badges array */ /* Patch the badges array */
{ {
find: "PREMIUM_GUILD_SUBSCRIPTION_TOOLTIP.format({date:", find: "Messages.PROFILE_USER_BADGES,",
replacement: { replacement: {
match: /&&((\w{1,3})\.push\({tooltip:\w{1,3}\.\w{1,3}\.Messages\.PREMIUM_GUILD_SUBSCRIPTION_TOOLTIP\.format.+?;)(?:return\s\w{1,3};?})/, match: /&&((\i)\.push\({tooltip:\i\.\i\.Messages\.PREMIUM_GUILD_SUBSCRIPTION_TOOLTIP\.format.+?;)(?:return\s\i;?})/,
replace: (_, m, badgeArray) => `&&${m} return Vencord.Api.Badges.inject(${badgeArray}, arguments[0]);}`, replace: (_, m, badgeArray) => `&&${m} return Vencord.Api.Badges.inject(${badgeArray}, arguments[0]);}`,
} }
}, },
@ -69,21 +69,23 @@ export default definePlugin({
find: "Messages.PROFILE_USER_BADGES,role:", find: "Messages.PROFILE_USER_BADGES,role:",
replacement: [ replacement: [
{ {
match: /src:(\w{1,3})\[(\w{1,3})\.key\],/, match: /src:(\i)\[(\i)\.key\],/g,
// <img src={badge.image ?? imageMap[badge.key]} {...badge.props} /> // <img src={badge.image ?? imageMap[badge.key]} {...badge.props} />
replace: (_, imageMap, badge) => `src: ${badge}.image ?? ${imageMap}[${badge}.key], ...${badge}.props,` replace: (_, imageMap, badge) => `src: ${badge}.image ?? ${imageMap}[${badge}.key], ...${badge}.props,`
}, },
{ {
match: /spacing:(\d{1,2}),children:(.{1,40}(\i)\.jsx.+?(\i)\.onClick.+?\)})},/, match: /children:function(?<=(\i)\.(?:tooltip|description),spacing:\d.+?)/g,
// if the badge provides it's own component, render that instead of an image replace: "children:$1.component ? () => $self.renderBadgeComponent($1) : function"
// the badge also includes info about the user that has it (type BadgeUserArgs), which is why it's passed as props
replace: (_, s, origBadgeComponent, React, badge) =>
`spacing:${s},children:${badge}.component ? () => (0,${React}.jsx)(${badge}.component, { ...${badge} }) : ${origBadgeComponent}},`
} }
] ]
} }
], ],
renderBadgeComponent: ErrorBoundary.wrap((badge: ProfileBadge & BadgeUserArgs) => {
const Component = badge.component!;
return <Component {...badge} />;
}, { noop: true }),
async start() { async start() {
Vencord.Api.Badges.addBadge(ContributorBadge); Vencord.Api.Badges.addBadge(ContributorBadge);
const badges = await fetch("https://gist.githubusercontent.com/Vendicated/51a3dd775f6920429ec6e9b735ca7f01/raw/badges.csv").then(r => r.text()); const badges = await fetch("https://gist.githubusercontent.com/Vendicated/51a3dd775f6920429ec6e9b735ca7f01/raw/badges.csv").then(r => r.text());

View File

@ -16,77 +16,13 @@
* along with this program. If not, see <https://www.gnu.org/licenses/>. * along with this program. If not, see <https://www.gnu.org/licenses/>.
*/ */
import { Settings } from "@api/settings";
import { Devs } from "@utils/constants"; import { Devs } from "@utils/constants";
import definePlugin, { type PatchReplacement } from "@utils/types"; import definePlugin from "@utils/types";
import { addListener, removeListener } from "@webpack";
/**
* The last var name corresponding to the Context Menu API (Discord, not ours) module
*/
let lastVarName = "";
/**
* @param target The patch replacement object
* @param exportKey The key exporting the build Context Menu component function
*/
function makeReplacementProxy(target: PatchReplacement, exportKey: string) {
return new Proxy(target, {
get(_, p) {
if (p === "match") return RegExp(`${exportKey},{(?<=${lastVarName}\\.${exportKey},{)`, "g");
// @ts-expect-error
return Reflect.get(...arguments);
}
});
}
function listener(exports: any, id: number) {
if (!Settings.plugins.ContextMenuAPI.enabled) return removeListener(listener);
if (typeof exports !== "object" || exports === null) return;
for (const key in exports) if (key.length <= 3) {
const prop = exports[key];
if (typeof prop !== "function") continue;
const str = Function.prototype.toString.call(prop);
if (str.includes('path:["empty"]')) {
Vencord.Plugins.patches.push({
plugin: "ContextMenuAPI",
all: true,
noWarn: true,
find: "navId:",
replacement: [
{
// Set the lastVarName for our proxy to use
match: RegExp(`${id}(?<=(\\i)=.+?)`),
replace: (id, varName) => {
lastVarName = varName;
return id;
}
},
/**
* We are using a proxy here to utilize the whole code the patcher gives us, instead of matching the entire module (which is super slow)
* Our proxy returns the corresponding match for that module utilizing lastVarName, which is set by the patch before
*/
makeReplacementProxy({
match: "", // Needed to canonicalizeDescriptor
replace: "$&contextMenuApiArguments:arguments,",
}, key)
]
});
removeListener(listener);
}
}
}
addListener(listener);
export default definePlugin({ export default definePlugin({
name: "ContextMenuAPI", name: "ContextMenuAPI",
description: "API for adding/removing items to/from context menus.", description: "API for adding/removing items to/from context menus.",
authors: [Devs.Nuckyz], authors: [Devs.Nuckyz, Devs.Ven],
patches: [ patches: [
{ {
find: "♫ (つ。◕‿‿◕。)つ ♪", find: "♫ (つ。◕‿‿◕。)つ ♪",
@ -94,6 +30,14 @@ export default definePlugin({
match: /(?<=function \i\((\i)\){)(?=var \i,\i=\i\.navId)/, match: /(?<=function \i\((\i)\){)(?=var \i,\i=\i\.navId)/,
replace: (_, props) => `Vencord.Api.ContextMenu._patchContextMenu(${props});` replace: (_, props) => `Vencord.Api.ContextMenu._patchContextMenu(${props});`
} }
},
{
find: ".Menu,{",
all: true,
replacement: {
match: /Menu,{(?<=\.jsxs?\)\(\i\.Menu,{)/g,
replace: "$&contextMenuApiArguments:typeof arguments!=='undefined'?arguments:[],"
}
} }
] ]
}); });

View File

@ -67,7 +67,7 @@ const settings = definePluginSettings({
export default definePlugin({ export default definePlugin({
name: "BetterFolders", name: "BetterFolders",
description: "Shows server folders on dedicated sidebar and adds folder related improvements", description: "Shows server folders on dedicated sidebar and adds folder related improvements",
authors: [Devs.juby], authors: [Devs.juby, Devs.AutumnVN],
patches: [ patches: [
{ {
find: '("guildsnav")', find: '("guildsnav")',
@ -122,7 +122,7 @@ export default definePlugin({
settings, settings,
start() { start() {
const getGuildFolder = (id: string) => GuildFolderStore.guildFolders.find(f => f.guildIds.includes(id)); const getGuildFolder = (id: string) => GuildFolderStore.getGuildFolders().find(f => f.guildIds.includes(id));
FluxDispatcher.subscribe("CHANNEL_SELECT", this.onSwitch = data => { FluxDispatcher.subscribe("CHANNEL_SELECT", this.onSwitch = data => {
if (!settings.store.closeAllFolders && !settings.store.forceOpen) if (!settings.store.closeAllFolders && !settings.store.forceOpen)

View File

@ -43,6 +43,7 @@ const settings = definePluginSettings({
let crashCount: number = 0; let crashCount: number = 0;
let lastCrashTimestamp: number = 0; let lastCrashTimestamp: number = 0;
let shouldAttemptNextHandle = false;
export default definePlugin({ export default definePlugin({
name: "CrashHandler", name: "CrashHandler",
@ -72,6 +73,10 @@ export default definePlugin({
], ],
handleCrash(_this: ReactElement & { forceUpdate: () => void; }) { handleCrash(_this: ReactElement & { forceUpdate: () => void; }) {
if (Date.now() - lastCrashTimestamp <= 1_000 && !shouldAttemptNextHandle) return true;
shouldAttemptNextHandle = false;
if (++crashCount > 5) { if (++crashCount > 5) {
try { try {
showNotification({ showNotification({
@ -151,6 +156,7 @@ export default definePlugin({
} }
try { try {
shouldAttemptNextHandle = true;
_this.forceUpdate(); _this.forceUpdate();
} catch (err) { } catch (err) {
CrashHandlerLogger.debug("Failed to update crash handler component.", err); CrashHandlerLogger.debug("Failed to update crash handler component.", err);

View File

@ -17,20 +17,28 @@
*/ */
import { addPreEditListener, addPreSendListener, removePreEditListener, removePreSendListener } from "@api/MessageEvents"; import { addPreEditListener, addPreSendListener, removePreEditListener, removePreSendListener } from "@api/MessageEvents";
import { migratePluginSettings, Settings } from "@api/settings"; import { definePluginSettings, migratePluginSettings, Settings } from "@api/settings";
import { Devs } from "@utils/constants"; import { Devs } from "@utils/constants";
import { ApngDisposeOp, getGifEncoder, importApngJs } from "@utils/dependencies"; import { ApngBlendOp, ApngDisposeOp, getGifEncoder, importApngJs } from "@utils/dependencies";
import { getCurrentGuild } from "@utils/discord"; import { getCurrentGuild } from "@utils/discord";
import { proxyLazy } from "@utils/proxyLazy"; import { proxyLazy } from "@utils/proxyLazy";
import definePlugin, { OptionType } from "@utils/types"; import definePlugin, { OptionType } from "@utils/types";
import { findByCodeLazy, findByPropsLazy, findLazy, findStoreLazy } from "@webpack"; import { findByCodeLazy, findByPropsLazy, findLazy, findStoreLazy } from "@webpack";
import { ChannelStore, FluxDispatcher, PermissionStore, UserStore } from "@webpack/common"; import { ChannelStore, FluxDispatcher, Parser, PermissionStore, UserStore } from "@webpack/common";
import type { Message } from "discord-types/general";
const DRAFT_TYPE = 0; const DRAFT_TYPE = 0;
const promptToUpload = findByCodeLazy("UPLOAD_FILE_LIMIT_ERROR"); const promptToUpload = findByCodeLazy("UPLOAD_FILE_LIMIT_ERROR");
const UserSettingsProtoStore = findStoreLazy("UserSettingsProtoStore"); const UserSettingsProtoStore = findStoreLazy("UserSettingsProtoStore");
const PreloadedUserSettingsProtoHandler = findLazy(m => m.ProtoClass?.typeName === "discord_protos.discord_users.v1.PreloadedUserSettings"); const PreloadedUserSettingsProtoHandler = findLazy(m => m.ProtoClass?.typeName === "discord_protos.discord_users.v1.PreloadedUserSettings");
const ReaderFactory = findByPropsLazy("readerFactory"); const ReaderFactory = findByPropsLazy("readerFactory");
const StickerStore = findStoreLazy("StickersStore") as {
getPremiumPacks(): StickerPack[];
getAllGuildStickers(): Map<string, Sticker[]>;
getStickerById(id: string): Sticker | undefined;
};
const EmojiStore = findStoreLazy("EmojiStore");
function searchProtoClass(localName: string, parentProtoClass: any) { function searchProtoClass(localName: string, parentProtoClass: any) {
if (!parentProtoClass) return; if (!parentProtoClass) return;
@ -86,18 +94,69 @@ interface StickerPack {
stickers: Sticker[]; stickers: Sticker[];
} }
const fakeNitroEmojiRegex = /\/emojis\/(\d+?)\.(png|webp|gif)/;
const fakeNitroStickerRegex = /\/stickers\/(\d+?)\./;
const fakeNitroGifStickerRegex = /\/attachments\/\d+?\/\d+?\/(\d+?)\.gif/;
const settings = definePluginSettings({
enableEmojiBypass: {
description: "Allow sending fake emojis",
type: OptionType.BOOLEAN,
default: true,
restartNeeded: true
},
emojiSize: {
description: "Size of the emojis when sending",
type: OptionType.SLIDER,
default: 48,
markers: [32, 48, 64, 128, 160, 256, 512]
},
transformEmojis: {
description: "Whether to transform fake emojis into real ones",
type: OptionType.BOOLEAN,
default: true,
restartNeeded: true
},
enableStickerBypass: {
description: "Allow sending fake stickers",
type: OptionType.BOOLEAN,
default: true,
restartNeeded: true
},
stickerSize: {
description: "Size of the stickers when sending",
type: OptionType.SLIDER,
default: 160,
markers: [32, 64, 128, 160, 256, 512]
},
transformStickers: {
description: "Whether to transform fake stickers into real ones",
type: OptionType.BOOLEAN,
default: true,
restartNeeded: true
},
enableStreamQualityBypass: {
description: "Allow streaming in nitro quality",
type: OptionType.BOOLEAN,
default: true,
restartNeeded: true
}
});
migratePluginSettings("FakeNitro", "NitroBypass"); migratePluginSettings("FakeNitro", "NitroBypass");
export default definePlugin({ export default definePlugin({
name: "FakeNitro", name: "FakeNitro",
authors: [Devs.Arjix, Devs.D3SOX, Devs.Ven, Devs.obscurity, Devs.captain, Devs.Nuckyz], authors: [Devs.Arjix, Devs.D3SOX, Devs.Ven, Devs.obscurity, Devs.captain, Devs.Nuckyz, Devs.AutumnVN],
description: "Allows you to stream in nitro quality, send fake emojis/stickers and use client themes.", description: "Allows you to stream in nitro quality, send fake emojis/stickers and use client themes.",
dependencies: ["MessageEventsAPI"], dependencies: ["MessageEventsAPI"],
settings,
patches: [ patches: [
{ {
find: ".PREMIUM_LOCKED;", find: ".PREMIUM_LOCKED;",
predicate: () => Settings.plugins.FakeNitro.enableEmojiBypass === true, predicate: () => settings.store.enableEmojiBypass,
replacement: [ replacement: [
{ {
match: /(?<=(\i)=\i\.intention)/, match: /(?<=(\i)=\i\.intention)/,
@ -115,7 +174,7 @@ export default definePlugin({
}, },
{ {
find: "canUseAnimatedEmojis:function", find: "canUseAnimatedEmojis:function",
predicate: () => Settings.plugins.FakeNitro.enableEmojiBypass === true, predicate: () => settings.store.enableEmojiBypass,
replacement: { replacement: {
match: /((?:canUseEmojisEverywhere|canUseAnimatedEmojis):function\(\i)\){(.+?\))/g, match: /((?:canUseEmojisEverywhere|canUseAnimatedEmojis):function\(\i)\){(.+?\))/g,
replace: (_, rest, premiumCheck) => `${rest},fakeNitroIntention){${premiumCheck}||fakeNitroIntention==null||[${EmojiIntentions.CHAT},${EmojiIntentions.GUILD_STICKER_RELATED_EMOJI}].includes(fakeNitroIntention)` replace: (_, rest, premiumCheck) => `${rest},fakeNitroIntention){${premiumCheck}||fakeNitroIntention==null||[${EmojiIntentions.CHAT},${EmojiIntentions.GUILD_STICKER_RELATED_EMOJI}].includes(fakeNitroIntention)`
@ -123,7 +182,7 @@ export default definePlugin({
}, },
{ {
find: "canUseStickersEverywhere:function", find: "canUseStickersEverywhere:function",
predicate: () => Settings.plugins.FakeNitro.enableStickerBypass === true, predicate: () => settings.store.enableStickerBypass,
replacement: { replacement: {
match: /canUseStickersEverywhere:function\(\i\){/, match: /canUseStickersEverywhere:function\(\i\){/,
replace: "$&return true;" replace: "$&return true;"
@ -131,7 +190,7 @@ export default definePlugin({
}, },
{ {
find: "\"SENDABLE\"", find: "\"SENDABLE\"",
predicate: () => Settings.plugins.FakeNitro.enableStickerBypass === true, predicate: () => settings.store.enableStickerBypass,
replacement: { replacement: {
match: /(\w+)\.available\?/, match: /(\w+)\.available\?/,
replace: "true?" replace: "true?"
@ -139,7 +198,7 @@ export default definePlugin({
}, },
{ {
find: "canStreamHighQuality:function", find: "canStreamHighQuality:function",
predicate: () => Settings.plugins.FakeNitro.enableStreamQualityBypass === true, predicate: () => settings.store.enableStreamQualityBypass,
replacement: [ replacement: [
"canUseHighVideoUploadQuality", "canUseHighVideoUploadQuality",
"canStreamHighQuality", "canStreamHighQuality",
@ -153,7 +212,7 @@ export default definePlugin({
}, },
{ {
find: "STREAM_FPS_OPTION.format", find: "STREAM_FPS_OPTION.format",
predicate: () => Settings.plugins.FakeNitro.enableStreamQualityBypass === true, predicate: () => settings.store.enableStreamQualityBypass,
replacement: { replacement: {
match: /(userPremiumType|guildPremiumTier):.{0,10}TIER_\d,?/g, match: /(userPremiumType|guildPremiumTier):.{0,10}TIER_\d,?/g,
replace: "" replace: ""
@ -186,34 +245,61 @@ export default definePlugin({
replace: (_, rest, backgroundGradientPresetId, originalCall, theme) => `${rest}$self.handleGradientThemeSelect(${backgroundGradientPresetId},${theme},()=>${originalCall});` replace: (_, rest, backgroundGradientPresetId, originalCall, theme) => `${rest}$self.handleGradientThemeSelect(${backgroundGradientPresetId},${theme},()=>${originalCall});`
} }
}, },
{
find: 'jumboable?"jumbo":"default"',
predicate: () => Settings.plugins.FakeNitro.transformEmojis === true,
replacement: {
match: /jumboable\?"jumbo":"default",emojiId.+?}}\)},(?<=(\i)=function\(\i\){var \i=\i\.node.+?)/,
replace: (m, component) => `${m}fakeNitroEmojiComponentExport=($self.EmojiComponent=${component},void 0),`
}
},
{ {
find: '["strong","em","u","text","inlineCode","s","spoiler"]', find: '["strong","em","u","text","inlineCode","s","spoiler"]',
predicate: () => Settings.plugins.FakeNitro.transformEmojis === true,
replacement: [ replacement: [
{ {
predicate: () => settings.store.transformEmojis,
match: /1!==(\i)\.length\|\|1!==\i\.length/, match: /1!==(\i)\.length\|\|1!==\i\.length/,
replace: (m, content) => `${m}||${content}[0].target?.startsWith("https://cdn.discordapp.com/emojis/")` replace: (m, content) => `${m}||$self.shouldKeepEmojiLink(${content}[0])`
}, },
{ {
predicate: () => settings.store.transformEmojis || settings.store.transformStickers,
match: /(?=return{hasSpoilerEmbeds:\i,content:(\i)})/, match: /(?=return{hasSpoilerEmbeds:\i,content:(\i)})/,
replace: (_, content) => `${content}=$self.patchFakeNitroEmojis(${content},arguments[2]?.formatInline);` replace: (_, content) => `${content}=$self.patchFakeNitroEmojisOrRemoveStickersLinks(${content},arguments[2]?.formatInline);`
} }
] ]
}, },
{ {
find: "renderEmbeds=function", find: "renderEmbeds=function",
predicate: () => Settings.plugins.FakeNitro.transformEmojis === true, replacement: [
{
predicate: () => settings.store.transformEmojis || settings.store.transformStickers,
match: /(renderEmbeds=function\((\i)\){)(.+?embeds\.map\(\(function\((\i)\){)/,
replace: (_, rest1, message, rest2, embed) => `${rest1}const fakeNitroMessage=${message};${rest2}if($self.shouldIgnoreEmbed(${embed},fakeNitroMessage))return null;`
},
{
predicate: () => settings.store.transformStickers,
match: /renderStickersAccessories=function\((\i)\){var (\i)=\(0,\i\.\i\)\(\i\),/,
replace: (m, message, stickers) => `${m}${stickers}=$self.patchFakeNitroStickers(${stickers},${message}),`
},
{
predicate: () => settings.store.transformStickers,
match: /renderAttachments=function\(\i\){var (\i)=\i.attachments.+?;/,
replace: (m, attachments) => `${m}${attachments}=$self.filterAttachments(${attachments});`
}
]
},
{
find: ".STICKER_IN_MESSAGE_HOVER,",
predicate: () => settings.store.transformStickers,
replacement: [
{
match: /var (\i)=\i\.renderableSticker,.{0,50}closePopout.+?channel:\i,closePopout:\i,/,
replace: (m, renderableSticker) => `${m}renderableSticker:${renderableSticker},`
},
{
match: /emojiSection.{0,50}description:\i(?<=(\i)\.sticker,.+?)(?=,)/,
replace: (m, props) => `${m}+(${props}.renderableSticker?.fake?" This is a Fake Nitro sticker. Only you can see it rendered like a real one, for non Vencord users it will show as a link.":"")`
}
]
},
{
find: ".Messages.EMOJI_POPOUT_PREMIUM_JOINED_GUILD_DESCRIPTION",
predicate: () => settings.store.transformEmojis,
replacement: { replacement: {
match: /renderEmbeds=function\(\i\){.+?embeds\.map\(\(function\((\i)\){/, match: /((\i)=\i\.node,\i=\i\.emojiSourceDiscoverableGuild)(.+?return) (.{0,450}Messages\.EMOJI_POPOUT_PREMIUM_JOINED_GUILD_DESCRIPTION.+?}\))/,
replace: (m, embed) => `${m}if(${embed}.url?.startsWith("https://cdn.discordapp.com/emojis/"))return null;` replace: (_, rest1, node, rest2, messages) => `${rest1},fakeNitroNode=${node}${rest2}(${messages})+(fakeNitroNode.fake?" This is a Fake Nitro emoji. Only you can see it rendered like a real one, for non Vencord users it will show as a link.":"")`
} }
} }
], ],
@ -331,37 +417,146 @@ export default definePlugin({
}); });
}, },
EmojiComponent: null as any, patchFakeNitroEmojisOrRemoveStickersLinks(content: Array<any>, inline: boolean) {
if (content.length > 1) return content;
patchFakeNitroEmojis(content: Array<any>, inline: boolean) {
if (!this.EmojiComponent) return content;
const newContent: Array<any> = []; const newContent: Array<any> = [];
let nextIndex = content.length;
for (const element of content) { for (const element of content) {
if (element.props?.trusted == null) { if (element.props?.trusted == null) {
newContent.push(element); newContent.push(element);
continue; continue;
} }
const fakeNitroMatch = element.props.href.match(/https:\/\/cdn\.discordapp\.com\/emojis\/(\d+?)\.(png|webp|gif).+?(?=\s|$)/); if (settings.store.transformEmojis) {
if (!fakeNitroMatch) { const fakeNitroMatch = element.props.href.match(fakeNitroEmojiRegex);
newContent.push(element); if (fakeNitroMatch) {
let url: URL | null = null;
try {
url = new URL(element.props.href);
} catch { }
const emojiName = EmojiStore.getCustomEmojiById(fakeNitroMatch[1])?.name ?? url?.searchParams.get("name") ?? "FakeNitroEmoji";
newContent.push(Parser.defaultRules.customEmoji.react({
jumboable: !inline,
animated: fakeNitroMatch[2] === "gif",
emojiId: fakeNitroMatch[1],
name: emojiName,
fake: true
}, void 0, { key: String(nextIndex++) }));
continue;
}
}
if (settings.store.transformStickers) {
if (fakeNitroStickerRegex.test(element.props.href)) continue;
const gifMatch = element.props.href.match(fakeNitroGifStickerRegex);
if (gifMatch) {
// There is no way to differentiate a regular gif attachment from a fake nitro animated sticker, so we check if the StickerStore contains the id of the fake sticker
if (StickerStore.getStickerById(gifMatch[1])) continue;
}
}
newContent.push(element);
}
const firstTextElementIdx = newContent.findIndex(element => typeof element === "string");
if (firstTextElementIdx !== -1) newContent[firstTextElementIdx] = newContent[firstTextElementIdx].trimStart();
return newContent;
},
patchFakeNitroStickers(stickers: Array<any>, message: Message) {
const itemsToMaybePush: Array<string> = [];
const contentItems = message.content.split(/\s/);
if (contentItems.length === 1) itemsToMaybePush.push(contentItems[0]);
itemsToMaybePush.push(...message.attachments.filter(attachment => attachment.content_type === "image/gif").map(attachment => attachment.url));
for (const item of itemsToMaybePush) {
const imgMatch = item.match(fakeNitroStickerRegex);
if (imgMatch) {
let url: URL | null = null;
try {
url = new URL(item);
} catch { }
const stickerName = StickerStore.getStickerById(imgMatch[1])?.name ?? url?.searchParams.get("name") ?? "FakeNitroSticker";
stickers.push({
format_type: 1,
id: imgMatch[1],
name: stickerName,
fake: true
});
continue; continue;
} }
newContent.push(( const gifMatch = item.match(fakeNitroGifStickerRegex);
<this.EmojiComponent node={{ if (gifMatch) {
type: "customEmoji", if (!StickerStore.getStickerById(gifMatch[1])) continue;
jumboable: !inline && content.length === 1,
animated: fakeNitroMatch[2] === "gif", const stickerName = StickerStore.getStickerById(gifMatch[1])?.name ?? "FakeNitroSticker";
name: ":FakeNitroEmoji:", stickers.push({
emojiId: fakeNitroMatch[1] format_type: 2,
}} /> id: gifMatch[1],
)); name: stickerName,
fake: true
});
}
} }
return newContent; return stickers;
},
shouldIgnoreEmbed(embed: Message["embeds"][number], message: Message) {
if (message.content.split(/\s/).length > 1) return false;
switch (embed.type) {
case "image": {
if (settings.store.transformEmojis) {
if (fakeNitroEmojiRegex.test(embed.url!)) return true;
}
if (settings.store.transformStickers) {
if (fakeNitroStickerRegex.test(embed.url!)) return true;
const gifMatch = embed.url!.match(fakeNitroGifStickerRegex);
if (gifMatch) {
// There is no way to differentiate a regular gif attachment from a fake nitro animated sticker, so we check if the StickerStore contains the id of the fake sticker
if (StickerStore.getStickerById(gifMatch[1])) return true;
}
}
break;
}
}
return false;
},
filterAttachments(attachments: Message["attachments"]) {
return attachments.filter(attachment => {
if (attachment.content_type !== "image/gif") return true;
const match = attachment.url.match(fakeNitroGifStickerRegex);
if (match) {
// There is no way to differentiate a regular gif attachment from a fake nitro animated sticker, so we check if the StickerStore contains the id of the fake sticker
if (StickerStore.getStickerById(match[1])) return false;
}
return true;
});
},
shouldKeepEmojiLink(link: any) {
return link.target && fakeNitroEmojiRegex.test(link.target);
}, },
hasPermissionToUseExternalEmojis(channelId: string) { hasPermissionToUseExternalEmojis(channelId: string) {
@ -407,8 +602,17 @@ export default definePlugin({
const scale = resolution / Math.max(width, height); const scale = resolution / Math.max(width, height);
ctx.scale(scale, scale); ctx.scale(scale, scale);
let lastImg: HTMLImageElement | null = null; let previousFrameData: ImageData;
for (const { left, top, width, height, disposeOp, img, delay } of frames) {
for (const frame of frames) {
const { left, top, width, height, img, delay, blendOp, disposeOp } = frame;
previousFrameData = ctx.getImageData(left, top, width, height);
if (blendOp === ApngBlendOp.SOURCE) {
ctx.clearRect(left, top, width, height);
}
ctx.drawImage(img, left, top, width, height); ctx.drawImage(img, left, top, width, height);
const { data } = ctx.getImageData(0, 0, resolution, resolution); const { data } = ctx.getImageData(0, 0, resolution, resolution);
@ -419,19 +623,18 @@ export default definePlugin({
gif.writeFrame(index, resolution, resolution, { gif.writeFrame(index, resolution, resolution, {
transparent: true, transparent: true,
palette, palette,
delay, delay
}); });
if (disposeOp === ApngDisposeOp.BACKGROUND) { if (disposeOp === ApngDisposeOp.BACKGROUND) {
ctx.clearRect(left, top, width, height); ctx.clearRect(left, top, width, height);
} else if (disposeOp === ApngDisposeOp.PREVIOUS && lastImg) { } else if (disposeOp === ApngDisposeOp.PREVIOUS) {
ctx.drawImage(lastImg, left, top, width, height); ctx.putImageData(previousFrameData, left, top);
} }
lastImg = img;
} }
gif.finish(); gif.finish();
const file = new File([gif.bytesView()], `${stickerId}.gif`, { type: "image/gif" }); const file = new File([gif.bytesView()], `${stickerId}.gif`, { type: "image/gif" });
promptToUpload([file], ChannelStore.getChannel(channelId), DRAFT_TYPE); promptToUpload([file], ChannelStore.getChannel(channelId), DRAFT_TYPE);
}, },
@ -442,13 +645,6 @@ export default definePlugin({
return; return;
} }
const EmojiStore = findByPropsLazy("getCustomEmojiById");
const StickerStore = findByPropsLazy("getAllGuildStickers") as {
getPremiumPacks(): StickerPack[];
getAllGuildStickers(): Map<string, Sticker[]>;
getStickerById(id: string): Sticker | undefined;
};
function getWordBoundary(origStr: string, offset: number) { function getWordBoundary(origStr: string, offset: number) {
return (!origStr[offset] || /\s/.test(origStr[offset])) ? "" : " "; return (!origStr[offset] || /\s/.test(origStr[offset])) ? "" : " ";
} }
@ -469,7 +665,7 @@ export default definePlugin({
let link = this.getStickerLink(sticker.id); let link = this.getStickerLink(sticker.id);
if (sticker.format_type === 2) { if (sticker.format_type === 2) {
this.sendAnimatedSticker(this.getStickerLink(sticker.id), sticker.id, channelId); this.sendAnimatedSticker(link, sticker.id, channelId);
return { cancel: true }; return { cancel: true };
} else { } else {
if ("pack_id" in sticker) { if ("pack_id" in sticker) {
@ -483,7 +679,7 @@ export default definePlugin({
} }
delete extra.stickerIds; delete extra.stickerIds;
messageObj.content += " " + link; messageObj.content += " " + link + `&name=${encodeURIComponent(sticker.name)}`;
} }
} }
@ -493,7 +689,10 @@ export default definePlugin({
if (emoji.guildId === guildId && !emoji.animated) continue; if (emoji.guildId === guildId && !emoji.animated) continue;
const emojiString = `<${emoji.animated ? "a" : ""}:${emoji.originalName || emoji.name}:${emoji.id}>`; const emojiString = `<${emoji.animated ? "a" : ""}:${emoji.originalName || emoji.name}:${emoji.id}>`;
const url = emoji.url.replace(/\?size=\d+/, `?size=${Settings.plugins.FakeNitro.emojiSize}`); const url = emoji.url.replace(/\?size=\d+/, "?" + new URLSearchParams({
size: Settings.plugins.FakeNitro.emojiSize,
name: encodeURIComponent(emoji.name)
}));
messageObj.content = messageObj.content.replace(emojiString, (match, offset, origStr) => { messageObj.content = messageObj.content.replace(emojiString, (match, offset, origStr) => {
return `${getWordBoundary(origStr, offset - 1)}${url}${getWordBoundary(origStr, offset + match.length)}`; return `${getWordBoundary(origStr, offset - 1)}${url}${getWordBoundary(origStr, offset + match.length)}`;
}); });
@ -513,7 +712,10 @@ export default definePlugin({
if (emoji == null || (emoji.guildId === guildId && !emoji.animated)) continue; if (emoji == null || (emoji.guildId === guildId && !emoji.animated)) continue;
if (!emoji.require_colons) continue; if (!emoji.require_colons) continue;
const url = emoji.url.replace(/\?size=\d+/, `?size=${Settings.plugins.FakeNitro.emojiSize}`); const url = emoji.url.replace(/\?size=\d+/, "?" + new URLSearchParams({
size: Settings.plugins.FakeNitro.emojiSize,
name: encodeURIComponent(emoji.name)
}));
messageObj.content = messageObj.content.replace(emojiStr, (match, offset, origStr) => { messageObj.content = messageObj.content.replace(emojiStr, (match, offset, origStr) => {
return `${getWordBoundary(origStr, offset - 1)}${url}${getWordBoundary(origStr, offset + match.length)}`; return `${getWordBoundary(origStr, offset - 1)}${url}${getWordBoundary(origStr, offset + match.length)}`;
}); });

View File

@ -19,12 +19,16 @@
import { ApplicationCommandInputType, sendBotMessage } from "@api/Commands"; import { ApplicationCommandInputType, sendBotMessage } from "@api/Commands";
import { Devs } from "@utils/constants"; import { Devs } from "@utils/constants";
import definePlugin from "@utils/types"; import definePlugin from "@utils/types";
import { findByProps } from "@webpack"; import { findByPropsLazy } from "@webpack";
import { RestAPI, UserStore } from "@webpack/common";
const FriendInvites = findByPropsLazy("createFriendInvite");
const uuid = findByPropsLazy("v4", "v1");
export default definePlugin({ export default definePlugin({
name: "FriendInvites", name: "FriendInvites",
description: "Create and manage friend invite links via slash commands (/create friend invite, /view friend invites, /revoke friend invites).", description: "Create and manage friend invite links via slash commands (/create friend invite, /view friend invites, /revoke friend invites).",
authors: [Devs.afn], authors: [Devs.afn, Devs.Dziurwa],
dependencies: ["CommandsAPI"], dependencies: ["CommandsAPI"],
commands: [ commands: [
{ {
@ -32,14 +36,31 @@ export default definePlugin({
description: "Generates a friend invite link.", description: "Generates a friend invite link.",
inputType: ApplicationCommandInputType.BOT, inputType: ApplicationCommandInputType.BOT,
execute: async (_, ctx) => { execute: async (_, ctx) => {
const friendInvites = findByProps("createFriendInvite"); if (!UserStore.getCurrentUser().phone)
const createInvite = await friendInvites.createFriendInvite(); return sendBotMessage(ctx.channel.id, {
content: "You need to have a phone number connected to your account to create a friend invite!"
});
return void sendBotMessage(ctx.channel.id, { const random = uuid.v4();
const invite = await RestAPI.post({
url: "/friend-finder/find-friends",
body: {
modified_contacts: {
[random]: [1, "", ""]
}
}
}).then(res =>
FriendInvites.createFriendInvite({
code: res.body.invite_suggestions[0][3],
recipient_phone_number_or_email: random
})
);
sendBotMessage(ctx.channel.id, {
content: ` content: `
discord.gg/${createInvite.code} · discord.gg/${invite.code} ·
Expires: <t:${new Date(createInvite.expires_at).getTime() / 1000}:R> · Expires: <t:${new Date(invite.expires_at).getTime() / 1000}:R> ·
Max uses: \`${createInvite.max_uses}\` Max uses: \`${invite.max_uses}\`
`.trim().replace(/\s+/g, " ") `.trim().replace(/\s+/g, " ")
}); });
}, },
@ -49,15 +70,16 @@ export default definePlugin({
description: "View a list of all generated friend invites.", description: "View a list of all generated friend invites.",
inputType: ApplicationCommandInputType.BOT, inputType: ApplicationCommandInputType.BOT,
execute: async (_, ctx) => { execute: async (_, ctx) => {
const friendInvites = findByProps("createFriendInvite"); const invites = await FriendInvites.getAllFriendInvites();
const invites = await friendInvites.getAllFriendInvites();
const friendInviteList = invites.map(i => const friendInviteList = invites.map(i =>
`_discord.gg/${i.code}_ · `
_discord.gg/${i.code}_ ·
Expires: <t:${new Date(i.expires_at).getTime() / 1000}:R> · Expires: <t:${new Date(i.expires_at).getTime() / 1000}:R> ·
Times used: \`${i.uses}/${i.max_uses}\``.trim().replace(/\s+/g, " ") Times used: \`${i.uses}/${i.max_uses}\`
`.trim().replace(/\s+/g, " ")
); );
return void sendBotMessage(ctx.channel.id, { sendBotMessage(ctx.channel.id, {
content: friendInviteList.join("\n") || "You have no active friend invites!" content: friendInviteList.join("\n") || "You have no active friend invites!"
}); });
}, },
@ -67,7 +89,7 @@ export default definePlugin({
description: "Revokes all generated friend invites.", description: "Revokes all generated friend invites.",
inputType: ApplicationCommandInputType.BOT, inputType: ApplicationCommandInputType.BOT,
execute: async (_, ctx) => { execute: async (_, ctx) => {
await findByProps("createFriendInvite").revokeFriendInvites(); await FriendInvites.revokeFriendInvites();
return void sendBotMessage(ctx.channel.id, { return void sendBotMessage(ctx.channel.id, {
content: "All friend invites have been revoked." content: "All friend invites have been revoked."

View File

@ -0,0 +1,198 @@
/*
* Vencord, a modification for Discord's desktop app
* Copyright (c) 2023 Vendicated and contributors
*
* This program is free software: you can redistribute it and/or modify
* it under the terms of the GNU General Public License as published by
* the Free Software Foundation, either version 3 of the License, or
* (at your option) any later version.
*
* This program is distributed in the hope that it will be useful,
* but WITHOUT ANY WARRANTY; without even the implied warranty of
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
* GNU General Public License for more details.
*
* You should have received a copy of the GNU General Public License
* along with this program. If not, see <https://www.gnu.org/licenses/>.
*/
import { FluxDispatcher, React, useRef, useState } from "@webpack/common";
import { ELEMENT_ID } from "../constants";
import { settings } from "../index";
import { waitFor } from "../utils/waitFor";
interface Vec2 {
x: number,
y: number;
}
export interface MagnifierProps {
zoom: number;
size: number,
instance: any;
}
export const Magnifier: React.FC<MagnifierProps> = ({ instance, size: initialSize, zoom: initalZoom }) => {
const [ready, setReady] = useState(false);
const [lensPosition, setLensPosition] = useState<Vec2>({ x: 0, y: 0 });
const [imagePosition, setImagePosition] = useState<Vec2>({ x: 0, y: 0 });
const [opacity, setOpacity] = useState(0);
const isShiftDown = useRef(false);
const zoom = useRef(initalZoom);
const size = useRef(initialSize);
const element = useRef<HTMLDivElement | null>(null);
const currentVideoElementRef = useRef<HTMLVideoElement | null>(null);
const originalVideoElementRef = useRef<HTMLVideoElement | null>(null);
const imageRef = useRef<HTMLImageElement | null>(null);
// since we accessing document im gonna use useLayoutEffect
React.useLayoutEffect(() => {
const onKeyDown = (e: KeyboardEvent) => {
if (e.key === "Shift") {
isShiftDown.current = true;
}
};
const onKeyUp = (e: KeyboardEvent) => {
if (e.key === "Shift") {
isShiftDown.current = false;
}
};
const syncVideos = () => {
currentVideoElementRef.current!.currentTime = originalVideoElementRef.current!.currentTime;
};
const updateMousePosition = (e: MouseEvent) => {
if (instance.state.mouseOver && instance.state.mouseDown) {
const offset = size.current / 2;
const pos = { x: e.pageX, y: e.pageY };
const x = -((pos.x - element.current!.getBoundingClientRect().left) * zoom.current - offset);
const y = -((pos.y - element.current!.getBoundingClientRect().top) * zoom.current - offset);
setLensPosition({ x: e.x - offset, y: e.y - offset });
setImagePosition({ x, y });
setOpacity(1);
} else {
setOpacity(0);
}
};
const onMouseDown = (e: MouseEvent) => {
if (instance.state.mouseOver && e.button === 0 /* left click */) {
zoom.current = settings.store.zoom;
size.current = settings.store.size;
// close context menu if open
if (document.getElementById("image-context")) {
FluxDispatcher.dispatch({ type: "CONTEXT_MENU_CLOSE" });
}
updateMousePosition(e);
setOpacity(1);
}
};
const onMouseUp = () => {
setOpacity(0);
if (settings.store.saveZoomValues) {
settings.store.zoom = zoom.current;
settings.store.size = size.current;
}
};
const onWheel = async (e: WheelEvent) => {
if (instance.state.mouseOver && instance.state.mouseDown && !isShiftDown.current) {
const val = zoom.current + ((e.deltaY / 100) * (settings.store.invertScroll ? -1 : 1)) * settings.store.zoomSpeed;
zoom.current = val <= 1 ? 1 : val;
updateMousePosition(e);
}
if (instance.state.mouseOver && instance.state.mouseDown && isShiftDown.current) {
const val = size.current + (e.deltaY * (settings.store.invertScroll ? -1 : 1)) * settings.store.zoomSpeed;
size.current = val <= 50 ? 50 : val;
updateMousePosition(e);
}
};
waitFor(() => instance.state.readyState === "READY", () => {
const elem = document.getElementById(ELEMENT_ID) as HTMLDivElement;
element.current = elem;
elem.firstElementChild!.setAttribute("draggable", "false");
if (instance.props.animated) {
originalVideoElementRef.current = elem!.querySelector("video")!;
originalVideoElementRef.current.addEventListener("timeupdate", syncVideos);
setReady(true);
} else {
setReady(true);
}
});
document.addEventListener("keydown", onKeyDown);
document.addEventListener("keyup", onKeyUp);
document.addEventListener("mousemove", updateMousePosition);
document.addEventListener("mousedown", onMouseDown);
document.addEventListener("mouseup", onMouseUp);
document.addEventListener("wheel", onWheel);
return () => {
document.removeEventListener("keydown", onKeyDown);
document.removeEventListener("keyup", onKeyUp);
document.removeEventListener("mousemove", updateMousePosition);
document.removeEventListener("mousedown", onMouseDown);
document.removeEventListener("mouseup", onMouseUp);
document.removeEventListener("wheel", onWheel);
if (settings.store.saveZoomValues) {
settings.store.zoom = zoom.current;
settings.store.size = size.current;
}
};
}, []);
if (!ready) return null;
const box = element.current!.getBoundingClientRect();
return (
<div
className="lens"
style={{
opacity,
width: size.current + "px",
height: size.current + "px",
transform: `translate(${lensPosition.x}px, ${lensPosition.y}px)`,
}}
>
{instance.props.animated ?
(
<video
ref={currentVideoElementRef}
style={{
position: "absolute",
left: `${imagePosition.x}px`,
top: `${imagePosition.y}px`
}}
width={`${box.width * zoom.current}px`}
height={`${box.height * zoom.current}px`}
poster={instance.props.src}
src={originalVideoElementRef.current?.src ?? instance.props.src}
autoPlay
loop
/>
) : (
<img
ref={imageRef}
style={{
position: "absolute",
transform: `translate(${imagePosition.x}px, ${imagePosition.y}px)`
}}
width={`${box.width * zoom.current}px`}
height={`${box.height * zoom.current}px`}
src={instance.props.src} alt=""
/>
)}
</div>
);
};

View File

@ -0,0 +1,19 @@
/*
* Vencord, a modification for Discord's desktop app
* Copyright (c) 2023 Vendicated and contributors
*
* This program is free software: you can redistribute it and/or modify
* it under the terms of the GNU General Public License as published by
* the Free Software Foundation, either version 3 of the License, or
* (at your option) any later version.
*
* This program is distributed in the hope that it will be useful,
* but WITHOUT ANY WARRANTY; without even the implied warranty of
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
* GNU General Public License for more details.
*
* You should have received a copy of the GNU General Public License
* along with this program. If not, see <https://www.gnu.org/licenses/>.
*/
export const ELEMENT_ID = "magnify-modal";

View File

@ -0,0 +1,234 @@
/*
* Vencord, a modification for Discord's desktop app
* Copyright (c) 2023 Vendicated and contributors
*
* This program is free software: you can redistribute it and/or modify
* it under the terms of the GNU General Public License as published by
* the Free Software Foundation, either version 3 of the License, or
* (at your option) any later version.
*
* This program is distributed in the hope that it will be useful,
* but WITHOUT ANY WARRANTY; without even the implied warranty of
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
* GNU General Public License for more details.
*
* You should have received a copy of the GNU General Public License
* along with this program. If not, see <https://www.gnu.org/licenses/>.
*/
import "./styles.css";
import { addContextMenuPatch, NavContextMenuPatchCallback, removeContextMenuPatch } from "@api/ContextMenu";
import { definePluginSettings } from "@api/settings";
import { makeRange } from "@components/PluginSettings/components";
import { Devs } from "@utils/constants";
import { debounce } from "@utils/debounce";
import definePlugin, { OptionType } from "@utils/types";
import { Menu, React, ReactDOM } from "@webpack/common";
import type { Root } from "react-dom/client";
import { Magnifier, MagnifierProps } from "./components/Magnifier";
import { ELEMENT_ID } from "./constants";
export const settings = definePluginSettings({
saveZoomValues: {
type: OptionType.BOOLEAN,
description: "Whether to save zoom and lens size values",
default: true,
},
preventCarouselFromClosingOnClick: {
type: OptionType.BOOLEAN,
// Thanks chat gpt
description: "Allow the image modal in the image slideshow thing / carousel to remain open when clicking on the image",
default: true,
},
invertScroll: {
type: OptionType.BOOLEAN,
description: "Invert scroll",
default: true,
},
zoom: {
description: "Zoom of the lens",
type: OptionType.SLIDER,
markers: makeRange(1, 50, 4),
default: 2,
stickToMarkers: false,
},
size: {
description: "Radius / Size of the lens",
type: OptionType.SLIDER,
markers: makeRange(50, 1000, 50),
default: 100,
stickToMarkers: false,
},
zoomSpeed: {
description: "How fast the zoom / lens size changes",
type: OptionType.SLIDER,
markers: makeRange(0.1, 5, 0.2),
default: 0.5,
stickToMarkers: false,
},
});
const imageContextMenuPatch: NavContextMenuPatchCallback = (children, _) => {
if (!children.some(child => child?.props?.id === "image-zoom")) {
children.push(
<Menu.MenuGroup id="image-zoom">
{/* thanks SpotifyControls */}
<Menu.MenuControlItem
id="zoom"
label="Zoom"
control={(props, ref) => (
<Menu.MenuSliderControl
ref={ref}
{...props}
minValue={1}
maxValue={50}
value={settings.store.zoom}
onChange={debounce((value: number) => settings.store.zoom = value, 100)}
/>
)}
/>
<Menu.MenuControlItem
id="size"
label="Lens Size"
control={(props, ref) => (
<Menu.MenuSliderControl
ref={ref}
{...props}
minValue={50}
maxValue={1000}
value={settings.store.size}
onChange={debounce((value: number) => settings.store.size = value, 100)}
/>
)}
/>
<Menu.MenuControlItem
id="zoom-speed"
label="Zoom Speed"
control={(props, ref) => (
<Menu.MenuSliderControl
ref={ref}
{...props}
minValue={0.1}
maxValue={5}
value={settings.store.zoomSpeed}
onChange={debounce((value: number) => settings.store.zoomSpeed = value, 100)}
renderValue={(value: number) => `${value.toFixed(3)}x`}
/>
)}
/>
</Menu.MenuGroup>
);
}
};
export default definePlugin({
name: "ImageZoom",
description: "Lets you zoom in to images and gifs. Use scroll wheel to zoom in and shift + scroll wheel to increase lens radius / size",
authors: [Devs.Aria],
patches: [
{
find: '"renderLinkComponent","maxWidth"',
replacement: {
match: /(return\(.{1,100}\(\)\.wrapper.{1,100})(src)/,
replace: `$1id: '${ELEMENT_ID}',$2`
}
},
{
find: "handleImageLoad=",
replacement: [
{
match: /(render=function\(\){.{1,500}limitResponsiveWidth.{1,600})onMouseEnter:/,
replace: "$1...$self.makeProps(this),onMouseEnter:"
},
{
match: /componentDidMount=function\(\){/,
replace: "$&$self.renderMagnifier(this);",
},
{
match: /componentWillUnmount=function\(\){/,
replace: "$&$self.unMountMagnifier();"
}
]
},
{
find: ".carouselModal,",
replacement: {
match: /onClick:(\i),/,
replace: "onClick:$self.settings.store.preventCarouselFromClosingOnClick ? () => {} : $1,"
}
}
],
settings,
// to stop from rendering twice /shrug
currentMagnifierElement: null as React.FunctionComponentElement<MagnifierProps & JSX.IntrinsicAttributes> | null,
element: null as HTMLDivElement | null,
Magnifier,
root: null as Root | null,
makeProps(instance) {
return {
onMouseOver: () => this.onMouseOver(instance),
onMouseOut: () => this.onMouseOut(instance),
onMouseDown: (e: React.MouseEvent) => this.onMouseDown(e, instance),
onMouseUp: () => this.onMouseUp(instance),
id: instance.props.id,
};
},
renderMagnifier(instance) {
if (instance.props.id === ELEMENT_ID) {
if (!this.currentMagnifierElement) {
this.currentMagnifierElement = <Magnifier size={settings.store.size} zoom={settings.store.zoom} instance={instance} />;
this.root = ReactDOM.createRoot(this.element!);
this.root.render(this.currentMagnifierElement);
}
}
},
unMountMagnifier() {
this.root?.unmount();
this.currentMagnifierElement = null;
this.root = null;
},
onMouseOver(instance) {
instance.setState((state: any) => ({ ...state, mouseOver: true }));
},
onMouseOut(instance) {
instance.setState((state: any) => ({ ...state, mouseOver: false }));
},
onMouseDown(e: React.MouseEvent, instance) {
if (e.button === 0 /* left */)
instance.setState((state: any) => ({ ...state, mouseDown: true }));
},
onMouseUp(instance) {
instance.setState((state: any) => ({ ...state, mouseDown: false }));
},
start() {
addContextMenuPatch("image-context", imageContextMenuPatch);
this.element = document.createElement("div");
this.element.classList.add("MagnifierContainer");
document.body.appendChild(this.element);
},
stop() {
// so componenetWillUnMount gets called if Magnifier component is still alive
this.root && this.root.unmount();
this.element?.remove();
removeContextMenuPatch("image-context", imageContextMenuPatch);
}
});

View File

@ -0,0 +1,31 @@
.lens {
position: absolute;
inset: 0;
z-index: 9999;
border: 2px solid grey;
border-radius: 50%;
overflow: hidden;
cursor: none;
box-shadow: inset 0 0 10px 2px grey;
filter: drop-shadow(0 0 2px grey);
pointer-events: none;
}
.zoom img {
position: absolute;
top: 50%;
left: 50%;
transform: translate(-50%, -50%);
}
/* make the carousel take up less space so we can click the backdrop and exit out of it */
[class^="focusLock"] > [class^="carouselModal"] {
height: fit-content;
box-shadow: none;
}
[class^="focusLock"] > [class^="carouselModal"] > div {
height: fit-content;
top: 50%;
transform: translateY(-50%);
}

View File

@ -0,0 +1,22 @@
/*
* Vencord, a modification for Discord's desktop app
* Copyright (c) 2023 Vendicated and contributors
*
* This program is free software: you can redistribute it and/or modify
* it under the terms of the GNU General Public License as published by
* the Free Software Foundation, either version 3 of the License, or
* (at your option) any later version.
*
* This program is distributed in the hope that it will be useful,
* but WITHOUT ANY WARRANTY; without even the implied warranty of
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
* GNU General Public License for more details.
*
* You should have received a copy of the GNU General Public License
* along with this program. If not, see <https://www.gnu.org/licenses/>.
*/
export function waitFor(condition: () => boolean, cb: () => void) {
if (condition()) cb();
else requestAnimationFrame(() => waitFor(condition, cb));
}

View File

@ -119,6 +119,7 @@ export default definePlugin({
name: "InvisibleChat", name: "InvisibleChat",
description: "Encrypt your Messages in a non-suspicious way! This plugin makes requests to >>https://embed.sammcheese.net<< to provide embeds to decrypted links!", description: "Encrypt your Messages in a non-suspicious way! This plugin makes requests to >>https://embed.sammcheese.net<< to provide embeds to decrypted links!",
authors: [Devs.SammCheese], authors: [Devs.SammCheese],
dependencies: ["MessagePopoverAPI"],
patches: [ patches: [
{ {
// Indicator // Indicator

280
src/plugins/moreUserTags.ts Normal file
View File

@ -0,0 +1,280 @@
/*
* Vencord, a modification for Discord's desktop app
* Copyright (c) 2022 Vendicated and contributors
*
* This program is free software: you can redistribute it and/or modify
* it under the terms of the GNU General Public License as published by
* the Free Software Foundation, either version 3 of the License, or
* (at your option) any later version.
*
* This program is distributed in the hope that it will be useful,
* but WITHOUT ANY WARRANTY; without even the implied warranty of
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
* GNU General Public License for more details.
*
* You should have received a copy of the GNU General Public License
* along with this program. If not, see <https://www.gnu.org/licenses/>.
*/
import { definePluginSettings } from "@api/settings";
import { Devs } from "@utils/constants";
import { proxyLazy } from "@utils/proxyLazy.js";
import definePlugin, { OptionType } from "@utils/types";
import { find, findByPropsLazy } from "@webpack";
import { ChannelStore, GuildStore } from "@webpack/common";
import { Channel, Message, User } from "discord-types/general";
type PermissionName = "CREATE_INSTANT_INVITE" | "KICK_MEMBERS" | "BAN_MEMBERS" | "ADMINISTRATOR" | "MANAGE_CHANNELS" | "MANAGE_GUILD" | "CHANGE_NICKNAME" | "MANAGE_NICKNAMES" | "MANAGE_ROLES" | "MANAGE_WEBHOOKS" | "MANAGE_GUILD_EXPRESSIONS" | "CREATE_GUILD_EXPRESSIONS" | "VIEW_AUDIT_LOG" | "VIEW_CHANNEL" | "VIEW_GUILD_ANALYTICS" | "VIEW_CREATOR_MONETIZATION_ANALYTICS" | "MODERATE_MEMBERS" | "SEND_MESSAGES" | "SEND_TTS_MESSAGES" | "MANAGE_MESSAGES" | "EMBED_LINKS" | "ATTACH_FILES" | "READ_MESSAGE_HISTORY" | "MENTION_EVERYONE" | "USE_EXTERNAL_EMOJIS" | "ADD_REACTIONS" | "USE_APPLICATION_COMMANDS" | "MANAGE_THREADS" | "CREATE_PUBLIC_THREADS" | "CREATE_PRIVATE_THREADS" | "USE_EXTERNAL_STICKERS" | "SEND_MESSAGES_IN_THREADS" | "CONNECT" | "SPEAK" | "MUTE_MEMBERS" | "DEAFEN_MEMBERS" | "MOVE_MEMBERS" | "USE_VAD" | "PRIORITY_SPEAKER" | "STREAM" | "USE_EMBEDDED_ACTIVITIES" | "USE_SOUNDBOARD" | "USE_EXTERNAL_SOUNDS" | "REQUEST_TO_SPEAK" | "MANAGE_EVENTS" | "CREATE_EVENTS";
interface Tag {
// name used for identifying, must be alphanumeric + underscores
name: string;
// name shown on the tag itself, can be anything probably; automatically uppercase'd
displayName: string;
description: string;
permissions?: PermissionName[];
condition?(message: Message | null, user: User, channel: Channel): boolean;
}
const CLYDE_ID = "1081004946872352958";
// PermissionStore.computePermissions is not the same function and doesn't work here
const PermissionUtil = findByPropsLazy("computePermissions", "canEveryoneRole") as {
computePermissions({ ...args }): bigint;
};
const Permissions = findByPropsLazy("SEND_MESSAGES", "VIEW_CREATOR_MONETIZATION_ANALYTICS") as Record<PermissionName, bigint>;
const Tags = proxyLazy(() => find(m => m.Types?.[0] === "BOT").Types) as Record<string, number>;
const isWebhook = (message: Message, user: User) => !!message?.webhookId && user.isNonUserBot();
const tags: Tag[] = [
{
name: "WEBHOOK",
displayName: "Webhook",
description: "Messages sent by webhooks",
condition: isWebhook
}, {
name: "OWNER",
displayName: "Owner",
description: "Owns the server",
condition: (_, user, channel) => GuildStore.getGuild(channel?.guild_id)?.ownerId === user.id
}, {
name: "ADMINISTRATOR",
displayName: "Admin",
description: "Has the administrator permission",
permissions: ["ADMINISTRATOR"]
}, {
name: "MODERATOR_STAFF",
displayName: "Staff",
description: "Can manage the server, channels or roles",
permissions: ["MANAGE_GUILD", "MANAGE_CHANNELS", "MANAGE_ROLES"]
}, {
name: "MODERATOR",
displayName: "Mod",
description: "Can manage messages or kick/ban people",
permissions: ["MANAGE_MESSAGES", "KICK_MEMBERS", "BAN_MEMBERS"]
}, {
name: "VOICE_MODERATOR",
displayName: "VC Mod",
description: "Can manage voice chats",
permissions: ["MOVE_MEMBERS", "MUTE_MEMBERS", "DEAFEN_MEMBERS"]
}
];
const settings = definePluginSettings({
dontShowForBots: {
description: "Don't show tags (not including the webhook tag) for bots",
type: OptionType.BOOLEAN
},
dontShowBotTag: {
description: "Don't show [BOT] text for bots with other tags (verified bots will still have checkmark)",
type: OptionType.BOOLEAN
},
...Object.fromEntries(tags.map(({ name, displayName, description }) => [
`visibility_${name}`, {
description: `Show ${displayName} tags (${description})`,
type: OptionType.SELECT,
options: [
{
label: "Always",
value: "always",
default: true
}, {
label: "Only in chat",
value: "chat"
}, {
label: "Only in member list and profiles",
value: "not-chat"
}, {
label: "Never",
value: "never"
}
]
}
]))
});
export default definePlugin({
name: "MoreUserTags",
description: "Adds tags for webhooks and moderative roles (owner, admin, etc.)",
authors: [Devs.Cyn, Devs.TheSun],
settings,
patches: [
// add tags to the tag list
{
find: '.BOT=0]="BOT"',
replacement: [
// add tags to the exported tags list (the Tags variable here)
{
match: /(\i)\[.\.BOT=0\]="BOT";/,
replace: "$&$1=$self.addTagVariants($1);"
},
// make the tag show the right text
{
match: /(switch\((\i)\){.+?)case (\i)\.BOT:default:(\i)=(\i\.\i\.Messages)\.BOT_TAG_BOT/,
replace: (_, origSwitch, variant, tags, displayedText, strings) =>
`${origSwitch}default:{${displayedText} = $self.getTagText(${tags}[${variant}], ${strings})}`
},
// show OP tags correctly
{
match: /(\i)=(\i)===\i\.ORIGINAL_POSTER/,
replace: "$1=$self.isOPTag($2)"
}
],
},
// in messages
{
find: ".Types.ORIGINAL_POSTER",
replacement: {
match: /return null==(\i)\?null:\(0,/,
replace: "$1=$self.getTag({...arguments[0],origType:$1,location:'chat'});$&"
}
},
// in the member list
{
find: ".renderBot=function(){",
replacement: {
match: /this.props.user;return null!=(\i)&&.{0,10}\?(.{0,50})\.botTag/,
replace: "this.props.user;var type=$self.getTag({...this.props,origType:$1.bot?0:null,location:'not-chat'});\
return type!==null?$2.botTag,type"
}
},
// pass channel id down props to be used in profiles
{
find: ".hasAvatarForGuild(null==",
replacement: {
match: /\.usernameSection,user/,
replace: ".usernameSection,moreTags_channelId:arguments[0].channelId,user"
}
},
{
find: 'copyMetaData:"User Tag"',
replacement: {
match: /discriminatorClass:(.{1,100}),botClass:/,
replace: "discriminatorClass:$1,moreTags_channelId:arguments[0].moreTags_channelId,botClass:"
}
},
// in profiles
{
find: ",botType:",
replacement: {
match: /,botType:(\i\((\i)\)),/g,
replace: ",botType:$self.getTag({user:$2,channelId:arguments[0].moreTags_channelId,origType:$1,location:'not-chat'}),"
}
},
],
getPermissions(user: User, channel: Channel): string[] {
const guild = GuildStore.getGuild(channel?.guild_id);
if (!guild) return [];
const permissions = PermissionUtil.computePermissions({ user, context: guild, overwrites: channel.permissionOverwrites });
return Object.entries(Permissions)
.map(([perm, permInt]) =>
permissions & permInt ? perm : ""
)
.filter(Boolean);
},
addTagVariants(val: any /* i cant think of a good name */) {
let i = 100;
tags.forEach(({ name }) => {
val[name] = ++i;
val[i] = name;
val[`${name}-BOT`] = ++i;
val[i] = `${name}-BOT`;
val[`${name}-OP`] = ++i;
val[i] = `${name}-OP`;
});
return val;
},
isOPTag: (tag: number) => tag === Tags.ORIGINAL_POSTER || tags.some(t => tag === Tags[`${t.name}-OP`]),
getTagText(passedTagName: string, strings: Record<string, string>) {
if (!passedTagName) return "BOT";
const [tagName, variant] = passedTagName.split("-");
const tag = tags.find(({ name }) => tagName === name);
if (!tag) return "BOT";
if (variant === "BOT" && tagName !== "WEBHOOK" && this.settings.store.dontShowForBots) return strings.BOT_TAG_BOT;
switch (variant) {
case "OP":
return `${strings.BOT_TAG_FORUM_ORIGINAL_POSTER}${tag.displayName}`;
case "BOT":
return `${strings.BOT_TAG_BOT}${tag.displayName}`;
default:
return tag.displayName;
}
},
getTag({
message, user, channelId, origType, location, channel
}: {
message?: Message,
user: User,
channel?: Channel & { isForumPost(): boolean; },
channelId?: string;
origType?: number;
location: string;
}): number | null {
if (location === "chat" && user.id === "1")
return Tags.OFFICIAL;
if (user.id === CLYDE_ID)
return Tags.AI;
let type = typeof origType === "number" ? origType : null;
channel ??= ChannelStore.getChannel(channelId!) as any;
if (!channel) return type;
const settings = this.settings.store;
const perms = this.getPermissions(user, channel);
for (const tag of tags) {
switch (settings[`visibility_${tag.name}`]) {
case "always":
case location:
break;
default:
continue;
}
if (
tag.permissions?.some(perm => perms.includes(perm)) ||
(tag.condition?.(message!, user, channel))
) {
if (channel.isForumPost() && channel.ownerId === user.id)
type = Tags[`${tag.name}-OP`];
else if (user.bot && !isWebhook(message!, user) && !settings.dontShowBotTag)
type = Tags[`${tag.name}-BOT`];
else
type = Tags[tag.name];
break;
}
}
return type;
}
});

View File

@ -106,6 +106,13 @@ export default definePlugin({
onClick: makeOnClick("VencordUpdater") onClick: makeOnClick("VencordUpdater")
}); });
cats.push({
section: "VencordCloud",
label: "Cloud",
element: () => <SettingsComponent tab="VencordCloud" />,
onClick: makeOnClick("VencordCloud")
});
cats.push({ cats.push({
section: "VencordSettingsSync", section: "VencordSettingsSync",
label: "Backup & Restore", label: "Backup & Restore",

View File

@ -77,18 +77,14 @@ enum ChannelFlags {
REQUIRE_TAG = 1 << 4 REQUIRE_TAG = 1 << 4
} }
let EmojiComponent: ComponentType<any>;
let ChannelBeginHeader: ComponentType<any>; let ChannelBeginHeader: ComponentType<any>;
export function setEmojiComponent(component: ComponentType<any>) {
EmojiComponent = component;
}
export function setChannelBeginHeaderComponent(component: ComponentType<any>) { export function setChannelBeginHeaderComponent(component: ComponentType<any>) {
ChannelBeginHeader = component; ChannelBeginHeader = component;
} }
const ChatScrollClasses = findByPropsLazy("auto", "content", "scrollerBase"); const ChatScrollClasses = findByPropsLazy("auto", "content", "scrollerBase");
const ChatClasses = findByPropsLazy("chat", "content", "noChat", "chatContent");
const TagComponent = LazyComponent(() => find(m => { const TagComponent = LazyComponent(() => find(m => {
if (typeof m !== "function") return false; if (typeof m !== "function") return false;
@ -164,7 +160,7 @@ function HiddenChannelLockScreen({ channel }: { channel: ExtendedChannel; }) {
} }
return ( return (
<div className={ChatScrollClasses.auto + " " + "shc-lock-screen-outer-container"}> <div className={ChatScrollClasses.auto + " " + ChatScrollClasses.customTheme + " " + ChatClasses.chatContent + " " + "shc-lock-screen-outer-container"}>
<div className="shc-lock-screen-container"> <div className="shc-lock-screen-container">
<img className="shc-lock-screen-logo" src={HiddenChannelLogo} /> <img className="shc-lock-screen-logo" src={HiddenChannelLogo} />
@ -245,11 +241,10 @@ function HiddenChannelLockScreen({ channel }: { channel: ExtendedChannel; }) {
{defaultReactionEmoji != null && {defaultReactionEmoji != null &&
<div className="shc-lock-screen-default-emoji-container"> <div className="shc-lock-screen-default-emoji-container">
<Text variant="text-md/normal">Default reaction emoji:</Text> <Text variant="text-md/normal">Default reaction emoji:</Text>
<EmojiComponent node={{ {Parser.defaultRules[defaultReactionEmoji.emojiName ? "emoji" : "customEmoji"].react({
type: defaultReactionEmoji.emojiName ? "emoji" : "customEmoji",
name: defaultReactionEmoji.emojiName ?? "", name: defaultReactionEmoji.emojiName ?? "",
emojiId: defaultReactionEmoji.emojiId emojiId: defaultReactionEmoji.emojiId
}} /> })}
</div> </div>
} }
{channel.hasFlag(ChannelFlags.REQUIRE_TAG) && {channel.hasFlag(ChannelFlags.REQUIRE_TAG) &&

View File

@ -27,7 +27,7 @@ import { findByPropsLazy } from "@webpack";
import { ChannelStore, PermissionStore, Tooltip } from "@webpack/common"; import { ChannelStore, PermissionStore, Tooltip } from "@webpack/common";
import { Channel } from "discord-types/general"; import { Channel } from "discord-types/general";
import HiddenChannelLockScreen, { setChannelBeginHeaderComponent, setEmojiComponent } from "./components/HiddenChannelLockScreen"; import HiddenChannelLockScreen, { setChannelBeginHeaderComponent } from "./components/HiddenChannelLockScreen";
const ChannelListClasses = findByPropsLazy("channelName", "subtitle", "modeMuted", "iconContainer"); const ChannelListClasses = findByPropsLazy("channelName", "subtitle", "modeMuted", "iconContainer");
@ -234,14 +234,6 @@ export default definePlugin({
replace: ".filter(ch=>!$self.isHiddenChannel(ch))" replace: ".filter(ch=>!$self.isHiddenChannel(ch))"
} }
}, },
// Export the emoji component used on the lock screen
{
find: 'jumboable?"jumbo":"default"',
replacement: {
match: /jumboable\?"jumbo":"default",emojiId.+?}}\)},(?<=(\i)=function\(\i\){var \i=\i\.node.+?)/,
replace: (m, component) => `${m}shcEmojiComponentExport=($self.setEmojiComponent(${component}),void 0),`
}
},
{ {
find: ".Messages.ROLE_REQUIRED_SINGLE_USER_MESSAGE", find: ".Messages.ROLE_REQUIRED_SINGLE_USER_MESSAGE",
replacement: [ replacement: [
@ -403,7 +395,6 @@ export default definePlugin({
} }
], ],
setEmojiComponent,
setChannelBeginHeaderComponent, setChannelBeginHeaderComponent,
isHiddenChannel(channel: Channel & { channelId?: string; }, checkConnect = false) { isHiddenChannel(channel: Channel & { channelId?: string; }, checkConnect = false) {

View File

@ -1,5 +1,4 @@
.shc-lock-screen-outer-container { .shc-lock-screen-outer-container {
background-color: var(--background-primary);
overflow: hidden scroll; overflow: hidden scroll;
flex: 1 1 auto; flex: 1 1 auto;
height: 100%; height: 100%;
@ -41,14 +40,14 @@
.shc-lock-screen-topic-container { .shc-lock-screen-topic-container {
color: var(--text-normal); color: var(--text-normal);
background-color: var(--background-secondary); background: var(--bg-overlay-3, var(--background-secondary));
border-radius: 5px; border-radius: 5px;
padding: 10px; padding: 10px;
max-width: 70vw; max-width: 70vw;
} }
.shc-lock-screen-tags-container { .shc-lock-screen-tags-container {
background-color: var(--background-secondary); background: var(--bg-overlay-3, var(--background-secondary));
border-radius: 5px; border-radius: 5px;
padding: 10px; padding: 10px;
max-width: 70vw; max-width: 70vw;
@ -84,7 +83,7 @@
} }
.shc-lock-screen-default-emoji-container > [class^="emojiContainer"] { .shc-lock-screen-default-emoji-container > [class^="emojiContainer"] {
background-color: var(--background-secondary); background: var(--bg-overlay-3, var(--background-secondary));
border-radius: 8px; border-radius: 8px;
padding: 3px 4px; padding: 3px 4px;
margin-left: 5px; margin-left: 5px;
@ -94,7 +93,7 @@
display: flex; display: flex;
flex-direction: column; flex-direction: column;
align-items: center; align-items: center;
background-color: var(--background-secondary); background: var(--bg-overlay-3, var(--background-secondary));
border-radius: 5px; border-radius: 5px;
padding: 10px; padding: 10px;
max-width: 70vw; max-width: 70vw;

View File

@ -140,6 +140,7 @@
#vc-spotify-album-image { #vc-spotify-album-image {
border-radius: 3px; border-radius: 3px;
transition: filter 0.2s;
} }
#vc-spotify-album-image:hover { #vc-spotify-album-image:hover {

View File

@ -35,6 +35,7 @@ export default definePlugin({
required: true, required: true,
description: "Helps us provide support to you", description: "Helps us provide support to you",
authors: [Devs.Ven], authors: [Devs.Ven],
dependencies: ["CommandsAPI"],
commands: [{ commands: [{
name: "vencord-debug", name: "vencord-debug",

View File

@ -0,0 +1,18 @@
.vc-uvs-button > div {
white-space: normal !important;
}
.vc-uvs-button {
width: 100%;
margin: auto;
height: unset;
}
.vc-uvs-header {
color: var(--header-primary);
margin-bottom: 6px;
}
.vc-uvs-modal-margin > [class^="section"] {
margin: 0 12px;
}

View File

@ -0,0 +1,61 @@
/*
* Vencord, a modification for Discord's desktop app
* Copyright (c) 2023 Vendicated and contributors
*
* This program is free software: you can redistribute it and/or modify
* it under the terms of the GNU General Public License as published by
* the Free Software Foundation, either version 3 of the License, or
* (at your option) any later version.
*
* This program is distributed in the hope that it will be useful,
* but WITHOUT ANY WARRANTY; without even the implied warranty of
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
* GNU General Public License for more details.
*
* You should have received a copy of the GNU General Public License
* along with this program. If not, see <https://www.gnu.org/licenses/>.
*/
import "./VoiceChannelSection.css";
import { findByCodeLazy, findByPropsLazy } from "@webpack";
import { Button, Forms, PermissionStore, Toasts } from "@webpack/common";
import { Channel } from "discord-types/general";
const ChannelActions = findByPropsLazy("selectChannel", "selectVoiceChannel");
const UserPopoutSection = findByCodeLazy(".lastSection", ".children");
const CONNECT = 1n << 20n;
interface VoiceChannelFieldProps {
channel: Channel;
label: string;
showHeader: boolean;
}
export const VoiceChannelSection = ({ channel, label, showHeader }: VoiceChannelFieldProps) => (
<UserPopoutSection>
{showHeader && <Forms.FormTitle className="vc-uvs-header">In a voice channel</Forms.FormTitle>}
<Button
className="vc-uvs-button"
color={Button.Colors.TRANSPARENT}
size={Button.Sizes.SMALL}
onClick={() => {
if (PermissionStore.can(CONNECT, channel))
ChannelActions.selectVoiceChannel(channel.id);
else
Toasts.show({
message: "Insufficient permissions to enter the channel.",
id: "user-voice-show-insufficient-permissions",
type: Toasts.Type.FAILURE,
options: {
position: Toasts.Position.BOTTOM,
}
});
}}
>
{label}
</Button>
</UserPopoutSection>
);

View File

@ -0,0 +1,105 @@
/*
* Vencord, a modification for Discord's desktop app
* Copyright (c) 2023 Vendicated and contributors
*
* This program is free software: you can redistribute it and/or modify
* it under the terms of the GNU General Public License as published by
* the Free Software Foundation, either version 3 of the License, or
* (at your option) any later version.
*
* This program is distributed in the hope that it will be useful,
* but WITHOUT ANY WARRANTY; without even the implied warranty of
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
* GNU General Public License for more details.
*
* You should have received a copy of the GNU General Public License
* along with this program. If not, see <https://www.gnu.org/licenses/>.
*/
import { definePluginSettings } from "@api/settings";
import ErrorBoundary from "@components/ErrorBoundary";
import { Devs } from "@utils/constants";
import definePlugin, { OptionType } from "@utils/types";
import { findStoreLazy } from "@webpack";
import { ChannelStore, GuildStore } from "@webpack/common";
import { User } from "discord-types/general";
import { VoiceChannelSection } from "./components/VoiceChannelSection";
const VoiceStateStore = findStoreLazy("VoiceStateStore");
const settings = definePluginSettings({
showInUserProfileModal: {
type: OptionType.BOOLEAN,
description: "Show a user's voice channel in their profile modal",
default: true,
},
showVoiceChannelSectionHeader: {
type: OptionType.BOOLEAN,
description: 'Whether to show "IN A VOICE CHANNEL" above the join button',
default: true,
}
});
interface UserProps {
user: User;
}
const VoiceChannelField = ErrorBoundary.wrap(({ user }: UserProps) => {
const { channelId } = VoiceStateStore.getVoiceStateForUser(user.id) ?? {};
if (!channelId) return null;
const channel = ChannelStore.getChannel(channelId);
const guild = GuildStore.getGuild(channel.guild_id);
if (!guild) return null; // When in DM call
const result = `${guild.name} | ${channel.name}`;
return (
<VoiceChannelSection
channel={channel}
label={result}
showHeader={settings.store.showVoiceChannelSectionHeader}
/>
);
});
export default definePlugin({
name: "UserVoiceShow",
description: "Shows whether a User is currently in a voice channel somewhere in their profile",
authors: [Devs.LordElias],
settings,
patchModal({ user }: UserProps) {
if (!settings.store.showInUserProfileModal)
return null;
return (
<div className="vc-uvs-modal-margin">
<VoiceChannelField user={user} />
</div>
);
},
patchPopout: ({ user }: UserProps) => <VoiceChannelField user={user} />,
patches: [
{
find: ".showCopiableUsername",
replacement: {
match: /\(0,\w\.jsx\)\(\w{2},{user:\w,setNote/,
// paste my fancy custom button above the message field
replace: "$self.patchPopout(arguments[0]),$&",
}
},
{
find: ".USER_PROFILE_MODAL",
replacement: {
match: /,{user:\w{1,2}}\)(?!;case)/,
// paste my fancy custom button below the username
replace: "$&,$self.patchModal(arguments[0])",
}
}
],
});

View File

@ -56,7 +56,7 @@ export default definePlugin({
find: "AudioContextSettingsMigrated", find: "AudioContextSettingsMigrated",
replacement: [ replacement: [
{ {
match: /(?<=updateAsync\("audioContextSettings".{0,350}return \i\.volume=)\i(?=})/, match: /(?<=isLocalMute\(\i,\i\),volume:.+?volume:)\i(?=})/,
replace: "$&>200?200:$&" replace: "$&>200?200:$&"
}, },
{ {

View File

@ -16,31 +16,211 @@
* along with this program. If not, see <https://www.gnu.org/licenses/>. * along with this program. If not, see <https://www.gnu.org/licenses/>.
*/ */
import { definePluginSettings } from "@api/settings";
import { Devs } from "@utils/constants"; import { Devs } from "@utils/constants";
import definePlugin from "@utils/types"; import definePlugin, { OptionType } from "@utils/types";
import { saveFile } from "@utils/web";
import { findByProps, findLazy } from "@webpack";
import { Clipboard } from "@webpack/common";
async function fetchImage(url: string) {
const res = await fetch(url);
if (res.status !== 200) return;
return await res.blob();
}
const MiniDispatcher = findLazy(m => m.emitter?._events?.INSERT_TEXT);
const settings = definePluginSettings({
// This needs to be all in one setting because to enable any of these, we need to make Discord use their desktop context
// menu handler instead of the web one, which breaks the other menus that aren't enabled
addBack: {
type: OptionType.BOOLEAN,
description: "Add back the Discord context menus for images, links and the chat input bar",
// Web slate menu has proper spellcheck suggestions and image context menu is also pretty good,
// so disable this by default. Vencord Desktop just doesn't, so enable by default
default: IS_VENCORD_DESKTOP,
restartNeeded: true
}
});
export default definePlugin({ export default definePlugin({
name: "WebContextMenus", name: "WebContextMenus",
description: "Re-adds some of context menu items missing on the web version of Discord, namely Copy/Open Link", description: "Re-adds context menus missing in the web version of Discord: Images, ChatInputBar, Links, 'Copy Link', 'Open Link', 'Copy Image', 'Save Image'",
authors: [Devs.Ven], authors: [Devs.Ven],
enabledByDefault: true, enabledByDefault: true,
patches: [{ settings,
// There is literally no reason for Discord to make this Desktop only.
// The only thing broken is copy, but they already have a different copy function start() {
// with web support???? if (settings.store.addBack) {
find: "open-native-link", const ctxMenuCallbacks = findByProps("contextMenuCallbackNative");
replacement: [ window.removeEventListener("contextmenu", ctxMenuCallbacks.contextMenuCallbackWeb);
{ window.addEventListener("contextmenu", ctxMenuCallbacks.contextMenuCallbackNative);
// if (isNative || null == this.changedListeners = true;
match: /if\(!\w\..{1,3}\|\|null==/, }
replace: "if(null==" },
},
// Fix silly Discord calling the non web support copy stop() {
{ if (this.changedListeners) {
match: /\w\.default\.copy/, const ctxMenuCallbacks = findByProps("contextMenuCallbackNative");
replace: "Vencord.Webpack.Common.Clipboard.copy" window.removeEventListener("contextmenu", ctxMenuCallbacks.contextMenuCallbackNative);
window.addEventListener("contextmenu", ctxMenuCallbacks.contextMenuCallbackWeb);
}
},
patches: [
// Add back Copy & Open Link
{
// There is literally no reason for Discord to make this Desktop only.
// The only thing broken is copy, but they already have a different copy function
// with web support????
find: "open-native-link",
replacement: [
{
// if (IS_DESKTOP || null == ...)
match: /if\(!\i\.\i\|\|null==/,
replace: "if(null=="
},
// Fix silly Discord calling the non web support copy
{
match: /\w\.default\.copy/,
replace: "Vencord.Webpack.Common.Clipboard.copy"
}
]
},
// Add back Copy & Save Image
{
find: 'id:"copy-image"',
replacement: [
{
// if (!IS_WEB || null ==
match: /if\(!\i\.\i\|\|null==/,
replace: "if(null=="
},
{
match: /return\s*?\[\i\.default\.canCopyImage\(\)/,
replace: "return [true"
},
{
match: /(?<=COPY_IMAGE_MENU_ITEM,)action:/,
replace: "action:()=>$self.copyImage(arguments[0]),oldAction:"
},
{
match: /(?<=SAVE_IMAGE_MENU_ITEM,)action:/,
replace: "action:()=>$self.saveImage(arguments[0]),oldAction:"
},
]
},
// Add back image context menu
{
find: 'navId:"image-context"',
predicate: () => settings.store.addBack,
replacement: {
// return IS_DESKTOP ? React.createElement(Menu, ...)
match: /return \i\.\i\?(?=\(0,\i\.jsxs?\)\(\i\.Menu)/,
replace: "return true?"
} }
] },
}]
// Add back link context menu
{
find: '"interactionUsernameProfile"',
predicate: () => settings.store.addBack,
replacement: {
match: /if\("A"===\i\.tagName&&""!==\i\.textContent\)/,
replace: "if(false)"
}
},
// Add back slate / text input context menu
{
find: '"slate-toolbar"',
predicate: () => settings.store.addBack,
replacement: {
match: /(?<=\.handleContextMenu=.+?"bottom";)\i\.\i\?/,
replace: "true?"
}
},
{
find: 'navId:"textarea-context"',
predicate: () => settings.store.addBack,
replacement: [
{
// desktopOnlyEntries = makeEntries(), spellcheckChildren = desktopOnlyEntries[0], languageChildren = desktopOnlyEntries[1]
match: /\i=.{0,30}text:\i,target:\i,onHeightUpdate:\i\}\),2\),(\i)=\i\[0\],(\i)=\i\[1\]/,
// set spellcheckChildren & languageChildren to empty arrays, so just in case patch 3 fails, we don't
// reference undefined variables
replace: "$1=[],$2=[]",
},
{
// if (!IS_DESKTOP) return
match: /(?<=showApplicationCommandSuggestions;)if\(!\i\.\i\)/,
replace: "if(false)"
},
{
// do not add menu items for entries removed in patch 1. Using a lookbehind for group 1 is slow,
// so just capture and add back
match: /("submit-button".+?)(\(0,\i\.jsx\)\(\i\.MenuGroup,\{children:\i\}\),){2}/,
replace: "$1"
},
{
// Change calls to DiscordNative.clipboard to us instead
match: /\b\i\.default\.(copy|cut|paste)/g,
replace: "$self.$1"
}
]
}
// TODO: Maybe add spellcheck for VencordDesktop
],
async copyImage(url: string) {
const data = await fetchImage(url);
if (!data) return;
await navigator.clipboard.write([
new ClipboardItem({
[data.type]: data
})
]);
},
async saveImage(url: string) {
const data = await fetchImage(url);
if (!data) return;
const name = url.split("/").pop()!;
const file = new File([data], name, { type: data.type });
saveFile(file);
},
copy() {
const selection = document.getSelection();
if (!selection) return;
Clipboard.copy(selection.toString());
},
cut() {
this.copy();
MiniDispatcher.dispatch("INSERT_TEXT", { rawText: "" });
},
async paste() {
const text = await navigator.clipboard.readText();
const data = new DataTransfer();
data.setData("text/plain", text);
document.dispatchEvent(
new ClipboardEvent("paste", {
clipboardData: data
})
);
}
}); });

View File

@ -1,51 +0,0 @@
/*
* Vencord, a modification for Discord's desktop app
* Copyright (c) 2022 Vendicated and contributors
*
* This program is free software: you can redistribute it and/or modify
* it under the terms of the GNU General Public License as published by
* the Free Software Foundation, either version 3 of the License, or
* (at your option) any later version.
*
* This program is distributed in the hope that it will be useful,
* but WITHOUT ANY WARRANTY; without even the implied warranty of
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
* GNU General Public License for more details.
*
* You should have received a copy of the GNU General Public License
* along with this program. If not, see <https://www.gnu.org/licenses/>.
*/
import { Devs } from "@utils/constants";
import definePlugin from "@utils/types";
export default definePlugin({
name: "Webhook Tags",
description: "Changes the bot tag to say webhook for webhooks",
authors: [Devs.Cyn],
patches: [
{
find: '.BOT=0]="BOT"',
replacement: [
{
match: /(.)\[.\.BOT=0\]="BOT";/,
replace: (orig, types) =>
`${types}[${types}.WEBHOOK=99]="WEBHOOK";${orig}`,
},
{
match: /case (.)\.BOT:default:(.)=/,
replace: (orig, types, text) =>
`case ${types}.WEBHOOK:${text}="WEBHOOK";break;${orig}`,
},
],
},
{
find: ".Types.ORIGINAL_POSTER",
replacement: {
match: /return null==(.)\?null:\(0,.{1,3}\.jsxs?\)\((.{1,3}\.\i)/,
replace: (orig, type, BotTag) =>
`if(arguments[0].message.webhookId&&arguments[0].user.isNonUserBot()){${type}=${BotTag}.Types.WEBHOOK}${orig}`,
},
},
],
});

124
src/utils/cloud.tsx Normal file
View File

@ -0,0 +1,124 @@
/*
* Vencord, a modification for Discord's desktop app
* Copyright (c) 2023 Vendicated and contributors
*
* This program is free software: you can redistribute it and/or modify
* it under the terms of the GNU General Public License as published by
* the Free Software Foundation, either version 3 of the License, or
* (at your option) any later version.
*
* This program is distributed in the hope that it will be useful,
* but WITHOUT ANY WARRANTY; without even the implied warranty of
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
* GNU General Public License for more details.
*
* You should have received a copy of the GNU General Public License
* along with this program. If not, see <https://www.gnu.org/licenses/>.
*/
import * 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 Logger from "./Logger";
import { openModal } from "./modal";
export const cloudLogger = new Logger("Cloud", "#39b7e0");
export const getCloudUrl = () => new URL(Settings.cloud.url);
export async function getAuthorization() {
const secrets = await DataStore.get<Record<string, string>>("Vencord_cloudSecret") ?? {};
return secrets[getCloudUrl().origin];
}
async function setAuthorization(secret: string) {
await DataStore.update<Record<string, string>>("Vencord_cloudSecret", secrets => {
secrets ??= {};
secrets[getCloudUrl().origin] = secret;
return secrets;
});
}
export async function deauthorizeCloud() {
await DataStore.update<Record<string, string>>("Vencord_cloudSecret", secrets => {
secrets ??= {};
delete secrets[getCloudUrl().origin];
return secrets;
});
}
export async function authorizeCloud() {
if (await getAuthorization() !== undefined) {
Settings.cloud.authenticated = true;
return;
}
try {
const oauthConfiguration = await fetch(new URL("/v1/oauth/settings", getCloudUrl()));
var { clientId, redirectUri } = await oauthConfiguration.json();
} catch {
showNotification({
title: "Cloud Integration",
body: "Setup failed (couldn't retrieve OAuth configuration)."
});
Settings.cloud.authenticated = false;
return;
}
const { OAuth2AuthorizeModal } = findByProps("OAuth2AuthorizeModal");
openModal((props: any) => <OAuth2AuthorizeModal
{...props}
scopes={["identify"]}
responseType="code"
redirectUri={redirectUri}
permissions={0n}
clientId={clientId}
cancelCompletesFlow={false}
callback={async (callbackUrl: string) => {
if (!callbackUrl) {
Settings.cloud.authenticated = false;
return;
}
try {
const res = await fetch(callbackUrl, {
headers: new Headers({ Accept: "application/json" })
});
const { secret } = await res.json();
if (secret) {
cloudLogger.info("Authorized with secret");
await setAuthorization(secret);
showNotification({
title: "Cloud Integration",
body: "Cloud integrations enabled!"
});
Settings.cloud.authenticated = true;
} else {
showNotification({
title: "Cloud Integration",
body: "Setup failed (no secret returned?)."
});
Settings.cloud.authenticated = false;
}
} catch (e: any) {
cloudLogger.error("Failed to authorize", e);
showNotification({
title: "Cloud Integration",
body: `Setup failed (${e.toString()}).`
});
Settings.cloud.authenticated = false;
}
}
}
/>);
}
export async function getCloudAuth() {
const userId = UserStore.getCurrentUser().id;
const secret = await getAuthorization();
return window.btoa(`${secret}:${userId}`);
}

View File

@ -226,6 +226,10 @@ export const Devs = /* #__PURE__*/ Object.freeze({
name: "TheKodeToad", name: "TheKodeToad",
id: 706152404072267788n id: 706152404072267788n
}, },
LordElias: {
name: "LordElias",
id: 319460781567639554n
},
juby: { juby: {
name: "Juby210", name: "Juby210",
id: 324622488644616195n id: 324622488644616195n
@ -241,5 +245,13 @@ export const Devs = /* #__PURE__*/ Object.freeze({
skyevg: { skyevg: {
name: "skyevg", name: "skyevg",
id: 1090310844283363348n id: 1090310844283363348n
},
Dziurwa: {
name: "Dziurwa",
id: 787017887877169173n
},
AutumnVN: {
name: "AutumnVN",
id: 393694671383166998n
} }
}); });

19
src/utils/localStorage.ts Normal file
View File

@ -0,0 +1,19 @@
/*
* Vencord, a modification for Discord's desktop app
* Copyright (c) 2023 Vendicated and contributors
*
* This program is free software: you can redistribute it and/or modify
* it under the terms of the GNU General Public License as published by
* the Free Software Foundation, either version 3 of the License, or
* (at your option) any later version.
*
* This program is distributed in the hope that it will be useful,
* but WITHOUT ANY WARRANTY; without even the implied warranty of
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
* GNU General Public License for more details.
*
* You should have received a copy of the GNU General Public License
* along with this program. If not, see <https://www.gnu.org/licenses/>.
*/
export const { localStorage } = window;

View File

@ -22,3 +22,10 @@ export function relaunch() {
else else
window.VencordDesktop.app.relaunch(); window.VencordDesktop.app.relaunch();
} }
export function showItemInFolder(path: string) {
if (IS_DISCORD_DESKTOP)
window.DiscordNative.fileManager.showItemInFolder(path);
else
window.VencordDesktop.fileManager.showItemInFolder(path);
}

View File

@ -16,10 +16,15 @@
* along with this program. If not, see <https://www.gnu.org/licenses/>. * along with this program. If not, see <https://www.gnu.org/licenses/>.
*/ */
import { showNotification } from "@api/Notifications";
import { PlainSettings, Settings } from "@api/settings";
import { Toasts } from "@webpack/common"; import { Toasts } from "@webpack/common";
import { deflateSync, inflateSync } from "fflate";
import { getCloudAuth, getCloudUrl } from "./cloud";
import IpcEvents from "./IpcEvents"; import IpcEvents from "./IpcEvents";
import Logger from "./Logger"; import Logger from "./Logger";
import { saveFile } from "./web";
export async function importSettings(data: string) { export async function importSettings(data: string) {
try { try {
@ -50,31 +55,22 @@ export async function downloadSettingsBackup() {
if (IS_DISCORD_DESKTOP) { if (IS_DISCORD_DESKTOP) {
DiscordNative.fileManager.saveWithDialog(data, filename); DiscordNative.fileManager.saveWithDialog(data, filename);
} else { } else {
const file = new File([data], filename, { type: "application/json" }); saveFile(new File([data], filename, { type: "application/json" }));
const a = document.createElement("a");
a.href = URL.createObjectURL(file);
a.download = filename;
document.body.appendChild(a);
a.click();
setImmediate(() => {
URL.revokeObjectURL(a.href);
document.body.removeChild(a);
});
} }
} }
const toastSuccess = () => Toasts.show({ const toast = (type: number, message: string) =>
type: Toasts.Type.SUCCESS, Toasts.show({
message: "Settings successfully imported. Restart to apply changes!", type,
id: Toasts.genId() message,
}); id: Toasts.genId()
});
const toastFailure = (err: any) => Toasts.show({ const toastSuccess = () =>
type: Toasts.Type.FAILURE, toast(Toasts.Type.SUCCESS, "Settings successfully imported. Restart to apply changes!");
message: `Failed to import settings: ${String(err)}`,
id: Toasts.genId() const toastFailure = (err: any) =>
}); toast(Toasts.Type.FAILURE, `Failed to import settings: ${String(err)}`);
export async function uploadSettingsBackup(showToast = true): Promise<void> { export async function uploadSettingsBackup(showToast = true): Promise<void> {
if (IS_DISCORD_DESKTOP) { if (IS_DISCORD_DESKTOP) {
@ -121,3 +117,169 @@ export async function uploadSettingsBackup(showToast = true): Promise<void> {
setImmediate(() => document.body.removeChild(input)); setImmediate(() => document.body.removeChild(input));
} }
} }
// Cloud settings
const cloudSettingsLogger = new Logger("Cloud:Settings", "#39b7e0");
export async function putCloudSettings() {
const settings = await exportSettings();
try {
const res = await fetch(new URL("/v1/settings", getCloudUrl()), {
method: "PUT",
headers: new Headers({
Authorization: await getCloudAuth(),
"Content-Type": "application/octet-stream"
}),
body: deflateSync(new TextEncoder().encode(settings))
});
if (!res.ok) {
cloudSettingsLogger.error(`Failed to sync up, API returned ${res.status}`);
showNotification({
title: "Cloud Settings",
body: `Could not synchronize settings to cloud (API returned ${res.status}).`,
color: "var(--red-360)"
});
return;
}
const { written } = await res.json();
PlainSettings.cloud.settingsSyncVersion = written;
VencordNative.ipc.invoke(IpcEvents.SET_SETTINGS, JSON.stringify(PlainSettings, null, 4));
cloudSettingsLogger.info("Settings uploaded to cloud successfully");
showNotification({
title: "Cloud Settings",
body: "Synchronized your settings to the cloud!",
color: "var(--green-360)"
});
} catch (e: any) {
cloudSettingsLogger.error("Failed to sync up", e);
showNotification({
title: "Cloud Settings",
body: `Could not synchronize settings to the cloud (${e.toString()}).`,
color: "var(--red-360)"
});
}
}
export async function getCloudSettings(shouldNotify = true, force = false) {
try {
const res = await fetch(new URL("/v1/settings", getCloudUrl()), {
method: "GET",
headers: new Headers({
Authorization: await getCloudAuth(),
Accept: "application/octet-stream",
"If-None-Match": Settings.cloud.settingsSyncVersion.toString()
}),
});
if (res.status === 404) {
cloudSettingsLogger.info("No settings on the cloud");
if (shouldNotify)
showNotification({
title: "Cloud Settings",
body: "There are no settings in the cloud."
});
return false;
}
if (res.status === 304) {
cloudSettingsLogger.info("Settings up to date");
if (shouldNotify)
showNotification({
title: "Cloud Settings",
body: "Your settings are up to date."
});
return false;
}
if (!res.ok) {
cloudSettingsLogger.error(`Failed to sync down, API returned ${res.status}`);
showNotification({
title: "Cloud Settings",
body: `Could not synchronize settings from the cloud (API returned ${res.status}).`,
color: "var(--red-360)"
});
return false;
}
const written = Number(res.headers.get("etag")!);
const localWritten = Settings.cloud.settingsSyncVersion;
// don't need to check for written > localWritten because the server will return 304 due to if-none-match
if (!force && written < localWritten) {
if (shouldNotify)
showNotification({
title: "Cloud Settings",
body: "Your local settings are newer than the cloud ones."
});
return;
}
const data = await res.arrayBuffer();
const settings = new TextDecoder().decode(inflateSync(new Uint8Array(data)));
await importSettings(settings);
// sync with server timestamp instead of local one
PlainSettings.cloud.settingsSyncVersion = written;
VencordNative.ipc.invoke(IpcEvents.SET_SETTINGS, JSON.stringify(PlainSettings, null, 4));
cloudSettingsLogger.info("Settings loaded from cloud successfully");
if (shouldNotify)
showNotification({
title: "Cloud Settings",
body: "Your settings have been updated! Click here to restart to fully apply changes!",
color: "var(--green-360)",
onClick: () => window.DiscordNative.app.relaunch()
});
return true;
} catch (e: any) {
cloudSettingsLogger.error("Failed to sync down", e);
showNotification({
title: "Cloud Settings",
body: `Could not synchronize settings from the cloud (${e.toString()}).`,
color: "var(--red-360)"
});
return false;
}
}
export async function deleteCloudSettings() {
try {
const res = await fetch(new URL("/v1/settings", getCloudUrl()), {
method: "DELETE",
headers: new Headers({
Authorization: await getCloudAuth()
}),
});
if (!res.ok) {
cloudSettingsLogger.error(`Failed to delete, API returned ${res.status}`);
showNotification({
title: "Cloud Settings",
body: `Could not delete settings (API returned ${res.status}).`,
color: "var(--red-360)"
});
return;
}
cloudSettingsLogger.info("Settings deleted from cloud successfully");
showNotification({
title: "Cloud Settings",
body: "Settings deleted from cloud!",
color: "var(--green-360)"
});
} catch (e: any) {
cloudSettingsLogger.error("Failed to delete", e);
showNotification({
title: "Cloud Settings",
body: `Could not delete settings (${e.toString()}).`,
color: "var(--red-360)"
});
}
}

View File

@ -62,7 +62,7 @@ export function getRepo() {
return Unwrap(VencordNative.ipc.invoke<IpcRes<string>>(IpcEvents.GET_REPO)); return Unwrap(VencordNative.ipc.invoke<IpcRes<string>>(IpcEvents.GET_REPO));
} }
type Hashes = Record<"patcher.js" | "preload.js" | "renderer.js" | "renderer.css", string>; type Hashes = Record<"patcher.js" | "main.js" | "preload.js" | "renderer.js" | "renderer.css", string>;
/** /**
* @returns true if hard restart is required * @returns true if hard restart is required
@ -75,8 +75,11 @@ export async function rebuild() {
const newHashes = await Unwrap(VencordNative.ipc.invoke<IpcRes<Hashes>>(IpcEvents.GET_HASHES)); const newHashes = await Unwrap(VencordNative.ipc.invoke<IpcRes<Hashes>>(IpcEvents.GET_HASHES));
return oldHashes["patcher.js"] !== newHashes["patcher.js"] || if (oldHashes["preload.js"] !== newHashes["preload.js"]) return true;
oldHashes["preload.js"] !== newHashes["preload.js"]; if (IS_DISCORD_DESKTOP && oldHashes["patcher.js"] !== newHashes["patcher.js"]) return true;
if (IS_VENCORD_DESKTOP && oldHashes["main.js"] !== newHashes["main.js"]) return true;
return false;
} }
export async function maybePromptToUpdate(confirmMessage: string, checkForDev = false) { export async function maybePromptToUpdate(confirmMessage: string, checkForDev = false) {

30
src/utils/web.ts Normal file
View File

@ -0,0 +1,30 @@
/*
* Vencord, a modification for Discord's desktop app
* Copyright (c) 2023 Vendicated and contributors
*
* This program is free software: you can redistribute it and/or modify
* it under the terms of the GNU General Public License as published by
* the Free Software Foundation, either version 3 of the License, or
* (at your option) any later version.
*
* This program is distributed in the hope that it will be useful,
* but WITHOUT ANY WARRANTY; without even the implied warranty of
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
* GNU General Public License for more details.
*
* You should have received a copy of the GNU General Public License
* along with this program. If not, see <https://www.gnu.org/licenses/>.
*/
export function saveFile(file: File) {
const a = document.createElement("a");
a.href = URL.createObjectURL(file);
a.download = file.name;
document.body.appendChild(a);
a.click();
setImmediate(() => {
URL.revokeObjectURL(a.href);
document.body.removeChild(a);
});
}

View File

@ -186,7 +186,9 @@ export type TextInput = ComponentType<PropsWithChildren<{
Sizes: Record<"DEFAULT" | "MINI", string>; Sizes: Record<"DEFAULT" | "MINI", string>;
}; };
export type TextArea = ComponentType<PropsWithRef<HTMLProps<HTMLTextAreaElement>>>; export type TextArea = ComponentType<PropsWithRef<Omit<HTMLProps<HTMLTextAreaElement>, "onChange"> & {
onChange(v: string): void;
}>>;
interface SelectOption { interface SelectOption {
disabled?: boolean; disabled?: boolean;

View File

@ -103,8 +103,10 @@ waitFor(["dispatch", "subscribe"], m => {
// This is the same module but this is easier // This is the same module but this is easier
waitFor(filters.byCode("currentToast?"), m => Toasts.show = m); waitFor("showToast", m => {
waitFor(filters.byCode("currentToast:null"), m => Toasts.pop = m); Toasts.show = m.showToast;
Toasts.pop = m.popToast;
});
waitFor(["show", "close"], m => Alerts = m); waitFor(["show", "close"], m => Alerts = m);
waitFor("parseTopic", m => Parser = m); waitFor("parseTopic", m => Parser = m);