Compare commits
61 Commits
componentU
...
v1.4.0
Author | SHA1 | Date | |
---|---|---|---|
|
1d77ab0ade | ||
|
9268cf3ffb | ||
|
208371c471 | ||
|
c69c6f8cb7 | ||
|
f2c6fcaa3b | ||
|
abf62f28db | ||
|
8620a1d86d | ||
|
198b35ffdc | ||
|
b4d0d95731 | ||
|
f785aa1473 | ||
|
d56e6560e5 | ||
|
a7e74ee4d5 | ||
|
1340f023a3 | ||
|
2bf0c324d7 | ||
|
f621cdb50b | ||
|
9717001783 | ||
|
065ab75627 | ||
|
8aea72c1be | ||
|
bea7a1711e | ||
|
e52ae62441 | ||
|
7cd1d4c60f | ||
|
2a318e390e | ||
|
7c7723bfb1 | ||
|
2db0e71e5b | ||
|
cde8074f44 | ||
|
8b1630bc99 | ||
|
bf34b2ae43 | ||
|
cb5f23d9b5 | ||
|
cd2cbfa0ef | ||
|
232e340fab | ||
|
8027daa2b0 | ||
|
0f7b9f588e | ||
|
93482ac2a5 | ||
|
994c3b3c92 | ||
|
c696c186e8 | ||
|
30d5e2108f | ||
|
1eabd1b701 | ||
|
1cbf2b43e1 | ||
|
4c197d5d51 | ||
|
f89027f46a | ||
|
07a0ebb1d2 | ||
|
f09b44b0d5 | ||
|
b607eebcb7 | ||
|
0936ca2985 | ||
|
13bde79ec8 | ||
|
b592defaaf | ||
|
73354973a3 | ||
|
e12c0e546c | ||
|
088a8bd1b6 | ||
|
51adb26d01 | ||
|
cb980a1cad | ||
|
69b10c1f07 | ||
|
8e9ba7c7ee | ||
|
12e3c9234d | ||
|
1d8dcef394 | ||
|
4fe2845234 | ||
|
5e71ed286e | ||
|
5edbd2391d | ||
|
8472c3823e | ||
|
2103e52115 | ||
|
afbfb641e8 |
@ -1,7 +1,7 @@
|
||||
{
|
||||
"name": "vencord",
|
||||
"private": "true",
|
||||
"version": "1.2.9",
|
||||
"version": "1.4.0",
|
||||
"description": "The cutest Discord client mod",
|
||||
"homepage": "https://github.com/Vendicated/Vencord#readme",
|
||||
"bugs": {
|
||||
|
@ -151,7 +151,6 @@ async function parseFile(fileName: string) {
|
||||
case "required":
|
||||
case "enabledByDefault":
|
||||
data[key] = value.kind === SyntaxKind.TrueKeyword;
|
||||
if (!data[key] && value.kind !== SyntaxKind.FalseKeyword) throw fail(`${key} is not a boolean literal`);
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
@ -58,4 +58,13 @@ export default {
|
||||
getVersions: () => process.versions as Partial<NodeJS.ProcessVersions>,
|
||||
openExternal: (url: string) => invoke<void>(IpcEvents.OPEN_EXTERNAL, url)
|
||||
},
|
||||
|
||||
pluginHelpers: {
|
||||
OpenInApp: {
|
||||
resolveRedirect: (url: string) => invoke<string>(IpcEvents.OPEN_IN_APP__RESOLVE_REDIRECT, url),
|
||||
},
|
||||
VoiceMessages: {
|
||||
readRecording: () => invoke<Uint8Array | null>(IpcEvents.VOICE_MESSAGES_READ_RECORDING),
|
||||
}
|
||||
}
|
||||
};
|
||||
|
@ -141,7 +141,7 @@ export const compileStyle = (style: Style) => {
|
||||
*/
|
||||
export const classNameToSelector = (name: string, prefix = "") => name.split(" ").map(n => `.${prefix}${n}`).join("");
|
||||
|
||||
type ClassNameFactoryArg = string | string[] | Record<string, unknown>;
|
||||
type ClassNameFactoryArg = string | string[] | Record<string, unknown> | false | null | undefined | 0 | "";
|
||||
/**
|
||||
* @param prefix The prefix to add to each class, defaults to `""`
|
||||
* @returns A classname generator function
|
||||
@ -154,9 +154,9 @@ type ClassNameFactoryArg = string | string[] | Record<string, unknown>;
|
||||
export const classNameFactory = (prefix: string = "") => (...args: ClassNameFactoryArg[]) => {
|
||||
const classNames = new Set<string>();
|
||||
for (const arg of args) {
|
||||
if (typeof arg === "string") classNames.add(arg);
|
||||
if (arg && typeof arg === "string") classNames.add(arg);
|
||||
else if (Array.isArray(arg)) arg.forEach(name => classNames.add(name));
|
||||
else if (typeof arg === "object") Object.entries(arg).forEach(([name, value]) => value && classNames.add(name));
|
||||
else if (arg && typeof arg === "object") Object.entries(arg).forEach(([name, value]) => value && classNames.add(name));
|
||||
}
|
||||
return Array.from(classNames, name => prefix + name).join(" ");
|
||||
};
|
||||
|
@ -18,7 +18,6 @@
|
||||
|
||||
import * as $Badges from "./Badges";
|
||||
import * as $Commands from "./Commands";
|
||||
import * as $ComponentUpdater from "./ComponentUpdater";
|
||||
import * as $ContextMenu from "./ContextMenu";
|
||||
import * as $DataStore from "./DataStore";
|
||||
import * as $MemberListDecorators from "./MemberListDecorators";
|
||||
@ -110,8 +109,3 @@ export const Notifications = $Notifications;
|
||||
* An api allowing you to patch and add/remove items to/from context menus
|
||||
*/
|
||||
export const ContextMenu = $ContextMenu;
|
||||
|
||||
/**
|
||||
* An api allowing you to update/rerender components
|
||||
*/
|
||||
export const ComponentUpdater = $ComponentUpdater;
|
||||
|
@ -190,3 +190,16 @@ export function ImageInvisible(props: IconProps) {
|
||||
</Icon>
|
||||
);
|
||||
}
|
||||
|
||||
export function Microphone(props: IconProps) {
|
||||
return (
|
||||
<Icon
|
||||
{...props}
|
||||
className={classes(props.className, "vc-microphone")}
|
||||
viewBox="0 0 24 24"
|
||||
>
|
||||
<path fill-rule="evenodd" clip-rule="evenodd" d="M14.99 11C14.99 12.66 13.66 14 12 14C10.34 14 9 12.66 9 11V5C9 3.34 10.34 2 12 2C13.66 2 15 3.34 15 5L14.99 11ZM12 16.1C14.76 16.1 17.3 14 17.3 11H19C19 14.42 16.28 17.24 13 17.72V21H11V17.72C7.72 17.23 5 14.41 5 11H6.7C6.7 14 9.24 16.1 12 16.1ZM12 4C11.2 4 11 4.66667 11 5V11C11 11.3333 11.2 12 12 12C12.8 12 13 11.3333 13 11V5C13 4.66667 12.8 4 12 4Z" fill="currentColor" />
|
||||
<path fill-rule="evenodd" clip-rule="evenodd" d="M14.99 11C14.99 12.66 13.66 14 12 14C10.34 14 9 12.66 9 11V5C9 3.34 10.34 2 12 2C13.66 2 15 3.34 15 5L14.99 11ZM12 16.1C14.76 16.1 17.3 14 17.3 11H19C19 14.42 16.28 17.24 13 17.72V22H11V17.72C7.72 17.23 5 14.41 5 11H6.7C6.7 14 9.24 16.1 12 16.1Z" fill="currentColor" />
|
||||
</Icon >
|
||||
);
|
||||
}
|
||||
|
@ -16,8 +16,9 @@
|
||||
* along with this program. If not, see <https://www.gnu.org/licenses/>.
|
||||
*/
|
||||
|
||||
import { wordsFromCamel, wordsToTitle } from "@utils/text";
|
||||
import { PluginOptionBoolean } from "@utils/types";
|
||||
import { Forms, React, Select } from "@webpack/common";
|
||||
import { Forms, React, Switch } from "@webpack/common";
|
||||
|
||||
import { ISettingElementProps } from ".";
|
||||
|
||||
@ -31,11 +32,6 @@ export function SettingBooleanComponent({ option, pluginSettings, definedSetting
|
||||
onError(error !== null);
|
||||
}, [error]);
|
||||
|
||||
const options = [
|
||||
{ label: "Enabled", value: true, default: def === true },
|
||||
{ label: "Disabled", value: false, default: typeof def === "undefined" || def === false },
|
||||
];
|
||||
|
||||
function handleChange(newValue: boolean): void {
|
||||
const isValid = option.isValid?.call(definedSettings, newValue) ?? true;
|
||||
if (typeof isValid === "string") setError(isValid);
|
||||
@ -49,18 +45,17 @@ export function SettingBooleanComponent({ option, pluginSettings, definedSetting
|
||||
|
||||
return (
|
||||
<Forms.FormSection>
|
||||
<Forms.FormTitle>{option.description}</Forms.FormTitle>
|
||||
<Select
|
||||
isDisabled={option.disabled?.call(definedSettings) ?? false}
|
||||
options={options}
|
||||
placeholder={option.placeholder ?? "Select an option"}
|
||||
maxVisibleItems={5}
|
||||
closeOnSelect={true}
|
||||
select={handleChange}
|
||||
isSelected={v => v === state}
|
||||
serialize={v => String(v)}
|
||||
<Switch
|
||||
value={state}
|
||||
onChange={handleChange}
|
||||
note={option.description}
|
||||
disabled={option.disabled?.call(definedSettings) ?? false}
|
||||
{...option.componentProps}
|
||||
/>
|
||||
hideBorder
|
||||
style={{ marginBottom: "0.5em" }}
|
||||
>
|
||||
{wordsToTitle(wordsFromCamel(id))}
|
||||
</Switch>
|
||||
{error && <Forms.FormText style={{ color: "var(--text-danger)" }}>{error}</Forms.FormText>}
|
||||
</Forms.FormSection>
|
||||
);
|
||||
|
@ -176,7 +176,8 @@ function PluginCard({ plugin, disabled, onRestartNeeded, onMouseEnter, onMouseLe
|
||||
const enum SearchStatus {
|
||||
ALL,
|
||||
ENABLED,
|
||||
DISABLED
|
||||
DISABLED,
|
||||
NEW
|
||||
}
|
||||
|
||||
export default function PluginSettings() {
|
||||
@ -229,6 +230,7 @@ export default function PluginSettings() {
|
||||
const enabled = settings.plugins[plugin.name]?.enabled;
|
||||
if (enabled && searchValue.status === SearchStatus.DISABLED) return false;
|
||||
if (!enabled && searchValue.status === SearchStatus.ENABLED) return false;
|
||||
if (searchValue.status === SearchStatus.NEW && !newPlugins?.includes(plugin.name)) return false;
|
||||
if (!searchValue.value.length) return true;
|
||||
|
||||
const v = searchValue.value.toLowerCase();
|
||||
@ -321,7 +323,8 @@ export default function PluginSettings() {
|
||||
options={[
|
||||
{ label: "Show All", value: SearchStatus.ALL, default: true },
|
||||
{ label: "Show Enabled", value: SearchStatus.ENABLED },
|
||||
{ label: "Show Disabled", value: SearchStatus.DISABLED }
|
||||
{ label: "Show Disabled", value: SearchStatus.DISABLED },
|
||||
{ label: "Show New", value: SearchStatus.NEW }
|
||||
]}
|
||||
serialize={String}
|
||||
select={onStatusChange}
|
||||
|
@ -86,7 +86,7 @@ function SettingsSyncSection() {
|
||||
<Button
|
||||
size={Button.Sizes.SMALL}
|
||||
disabled={!sectionEnabled}
|
||||
onClick={() => putCloudSettings()}
|
||||
onClick={() => putCloudSettings(true)}
|
||||
>Sync to Cloud</Button>
|
||||
<Tooltip text="This will overwrite your local settings with the ones on the cloud. Use wisely!">
|
||||
{({ onMouseLeave, onMouseEnter }) => (
|
||||
|
@ -21,7 +21,7 @@ import { Link } from "@components/Link";
|
||||
import { Margins } from "@utils/margins";
|
||||
import { useAwaiter } from "@utils/react";
|
||||
import { findLazy } from "@webpack";
|
||||
import { Card, Forms, React, TextArea } from "@webpack/common";
|
||||
import { Button, Card, Forms, React, TextArea } from "@webpack/common";
|
||||
|
||||
import { SettingsTab, wrapTab } from "./shared";
|
||||
|
||||
@ -112,7 +112,16 @@ function ThemesTab() {
|
||||
<li>• Click the fork button on the top right</li>
|
||||
<li>• Edit the file</li>
|
||||
<li>• Use the link to your own repository instead</li>
|
||||
<li>• Use the link to your own repository instead </li>
|
||||
<li>OR</li>
|
||||
<li>• Paste the contents of the edited theme file into the QuickCSS editor</li>
|
||||
</ul>
|
||||
<Forms.FormDivider className={Margins.top8 + " " + Margins.bottom16} />
|
||||
<Button
|
||||
onClick={() => VencordNative.quickCss.openEditor()}
|
||||
size={Button.Sizes.SMALL}>
|
||||
Open QuickCSS File
|
||||
</Button>
|
||||
</Forms.FormText>
|
||||
</Card>
|
||||
<Forms.FormTitle tag="h5">Themes</Forms.FormTitle>
|
||||
|
@ -5,8 +5,8 @@
|
||||
<title>Vencord QuickCSS Editor</title>
|
||||
<link
|
||||
rel="stylesheet"
|
||||
href="https://cdnjs.cloudflare.com/ajax/libs/monaco-editor/0.37.1/min/vs/editor/editor.main.min.css"
|
||||
integrity="sha512-wB3xfL98hWg1bpkVYSyL0js/Jx9s7FsDg9aYO6nOMSJTgPuk/PFqxXQJKKSUjteZjeYrfgo9NFBOA1r9HwDuZw=="
|
||||
href="https://cdnjs.cloudflare.com/ajax/libs/monaco-editor/0.40.0/min/vs/editor/editor.main.min.css"
|
||||
integrity="sha512-MOoQ02h80hklccfLrXFYkCzG+WVjORflOp9Zp8dltiaRP+35LYnO4LKOklR64oMGfGgJDLO8WJpkM1o5gZXYZQ=="
|
||||
crossorigin="anonymous"
|
||||
referrerpolicy="no-referrer"
|
||||
/>
|
||||
@ -29,8 +29,8 @@
|
||||
<body>
|
||||
<div id="container"></div>
|
||||
<script
|
||||
src="https://cdnjs.cloudflare.com/ajax/libs/monaco-editor/0.37.1/min/vs/loader.min.js"
|
||||
integrity="sha512-A+6SvPGkIN9Rf0mUXmW4xh7rDvALXf/f0VtOUiHlDUSPknu2kcfz1KzLpOJyL2pO+nZS13hhIjLqVgiQExLJrw=="
|
||||
src="https://cdnjs.cloudflare.com/ajax/libs/monaco-editor/0.40.0/min/vs/loader.min.js"
|
||||
integrity="sha512-QzMpXeCPciAHP4wbYlV2PYgrQcaEkDQUjzkPU4xnjyVSD9T36/udamxtNBqb4qK4/bMQMPZ8ayrBe9hrGdBFjQ=="
|
||||
crossorigin="anonymous"
|
||||
referrerpolicy="no-referrer"
|
||||
></script>
|
||||
@ -38,7 +38,7 @@
|
||||
<script>
|
||||
require.config({
|
||||
paths: {
|
||||
vs: "https://cdnjs.cloudflare.com/ajax/libs/monaco-editor/0.37.1/min/vs",
|
||||
vs: "https://cdnjs.cloudflare.com/ajax/libs/monaco-editor/0.40.0/min/vs",
|
||||
},
|
||||
});
|
||||
|
||||
|
@ -17,6 +17,7 @@
|
||||
*/
|
||||
|
||||
import "./updater";
|
||||
import "./ipcPlugins";
|
||||
|
||||
import { debounce } from "@utils/debounce";
|
||||
import { IpcEvents } from "@utils/IpcEvents";
|
||||
|
62
src/main/ipcPlugins.ts
Normal file
62
src/main/ipcPlugins.ts
Normal file
@ -0,0 +1,62 @@
|
||||
/*
|
||||
* 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 { IpcEvents } from "@utils/IpcEvents";
|
||||
import { app, ipcMain } from "electron";
|
||||
import { readFile } from "fs/promises";
|
||||
import { request } from "https";
|
||||
import { join } from "path";
|
||||
|
||||
// #region OpenInApp
|
||||
// These links don't support CORS, so this has to be native
|
||||
const validRedirectUrls = /^https:\/\/(spotify\.link|s\.team)\/.+$/;
|
||||
|
||||
function getRedirect(url: string) {
|
||||
return new Promise<string>((resolve, reject) => {
|
||||
const req = request(new URL(url), { method: "HEAD" }, res => {
|
||||
resolve(
|
||||
res.headers.location
|
||||
? getRedirect(res.headers.location)
|
||||
: url
|
||||
);
|
||||
});
|
||||
req.on("error", reject);
|
||||
req.end();
|
||||
});
|
||||
}
|
||||
|
||||
ipcMain.handle(IpcEvents.OPEN_IN_APP__RESOLVE_REDIRECT, async (_, url: string) => {
|
||||
if (!validRedirectUrls.test(url)) return url;
|
||||
|
||||
return getRedirect(url);
|
||||
});
|
||||
// #endregion
|
||||
|
||||
|
||||
// #region VoiceMessages
|
||||
ipcMain.handle(IpcEvents.VOICE_MESSAGES_READ_RECORDING, async () => {
|
||||
const path = join(app.getPath("userData"), "module_data/discord_voice/recording.ogg");
|
||||
try {
|
||||
const buf = await readFile(path);
|
||||
return new Uint8Array(buf.buffer);
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
});
|
||||
|
||||
// #endregion
|
@ -31,7 +31,8 @@ export const ALLOWED_PROTOCOLS = [
|
||||
"https:",
|
||||
"http:",
|
||||
"steam:",
|
||||
"spotify:"
|
||||
"spotify:",
|
||||
"com.epicgames.launcher:",
|
||||
];
|
||||
|
||||
export const IS_VANILLA = /* @__PURE__ */ process.argv.includes("--vanilla");
|
||||
|
@ -80,8 +80,8 @@ export default definePlugin({
|
||||
find: "Messages.PROFILE_USER_BADGES,role:",
|
||||
replacement: [
|
||||
{
|
||||
match: /(?<=(\i)\.isTryItOutFlow,)(.{0,300})null==\i\?void 0:(\i)\.getBadges\(\)/,
|
||||
replace: (_, props, restCode, badgesMod) => `vencordProps=${props},${restCode}Vencord.Api.Badges._getBadges(vencordProps).concat(${badgesMod}?.getBadges()??[])`,
|
||||
match: /&&(\i)\.push\(\{id:"premium".+?\}\);/,
|
||||
replace: "$&$1.unshift(...Vencord.Api.Badges._getBadges(arguments[0]));",
|
||||
},
|
||||
{
|
||||
// alt: "", aria-hidden: false, src: originalSrc
|
||||
|
@ -1,51 +0,0 @@
|
||||
/*
|
||||
* 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 { Devs } from "@utils/constants";
|
||||
import { useForceUpdater } from "@utils/react";
|
||||
import definePlugin from "@utils/types";
|
||||
import { useEffect } from "@webpack/common";
|
||||
import { Channel, Message } from "discord-types/general";
|
||||
|
||||
const forceUpdaters = new Map<string, () => void>();
|
||||
|
||||
function useUpdater(data: { channel: Channel; message: Message; }) {
|
||||
const forceUpdater = useForceUpdater();
|
||||
|
||||
useEffect(() => {
|
||||
forceUpdaters.set(data.message.id, forceUpdater);
|
||||
return () => void forceUpdaters.delete(data.message.id);
|
||||
}, [data.message.id]);
|
||||
}
|
||||
|
||||
export default definePlugin({
|
||||
name: "ComponentUpdaterAPI",
|
||||
description: "API to update / force rerender several components, such as messages",
|
||||
authors: [Devs.Ven],
|
||||
|
||||
patches: [{
|
||||
find: ".renderContentOnly;",
|
||||
replacement: {
|
||||
match: /=(\i)\.renderContentOnly;/,
|
||||
replace: "$&$self.useUpdater($1);"
|
||||
}
|
||||
}],
|
||||
|
||||
useUpdater,
|
||||
forceUpdaters
|
||||
});
|
@ -19,6 +19,7 @@
|
||||
import { Settings } from "@api/Settings";
|
||||
import ErrorBoundary from "@components/ErrorBoundary";
|
||||
import { Devs } from "@utils/constants";
|
||||
import { useTimer } from "@utils/react";
|
||||
import definePlugin, { OptionType } from "@utils/types";
|
||||
import { React } from "@webpack/common";
|
||||
|
||||
@ -85,17 +86,10 @@ export default definePlugin({
|
||||
},
|
||||
|
||||
Timer({ channelId }: { channelId: string; }) {
|
||||
const [time, setTime] = React.useState(0);
|
||||
const startTime = React.useMemo(() => Date.now(), [channelId]);
|
||||
const time = useTimer({
|
||||
deps: [channelId]
|
||||
});
|
||||
|
||||
React.useEffect(() => {
|
||||
const interval = setInterval(() => setTime(Date.now() - startTime), 1000);
|
||||
return () => {
|
||||
clearInterval(interval);
|
||||
setTime(0);
|
||||
};
|
||||
}, [channelId]);
|
||||
|
||||
return <p style={{ margin: 0 }}>Connected for {formatDuration(time)}</p>;
|
||||
return <p style={{ margin: 0 }}>Connected for <span style={{ fontFamily: "var(--font-code)" }}>{formatDuration(time)}</span></p>;
|
||||
}
|
||||
});
|
||||
|
@ -135,4 +135,5 @@ export const defaultRules = [
|
||||
"utm_campaign",
|
||||
"utm_term",
|
||||
"si@open.spotify.com",
|
||||
"igshid",
|
||||
];
|
||||
|
@ -21,7 +21,7 @@ import definePlugin from "@utils/types";
|
||||
|
||||
export default definePlugin({
|
||||
name: "DisableDMCallIdle",
|
||||
description: "Disables automatically getting kicked from a DM voice call after 5 minutes.",
|
||||
description: "Disables automatically getting kicked from a DM voice call after 3 minutes.",
|
||||
authors: [Devs.Nuckyz],
|
||||
patches: [
|
||||
{
|
||||
|
@ -565,7 +565,11 @@ export default definePlugin({
|
||||
|
||||
switch (embed.type) {
|
||||
case "image": {
|
||||
if (!settings.store.transformCompoundSentence && !contentItems.includes(embed.url!) && !contentItems.includes(embed.image!.proxyURL)) return false;
|
||||
if (
|
||||
!settings.store.transformCompoundSentence
|
||||
&& !contentItems.includes(embed.url!)
|
||||
&& !contentItems.includes(embed.image?.proxyURL!)
|
||||
) return false;
|
||||
|
||||
if (settings.store.transformEmojis) {
|
||||
if (fakeNitroEmojiRegex.test(embed.url!)) return true;
|
||||
|
241
src/plugins/favGifSearch.tsx
Normal file
241
src/plugins/favGifSearch.tsx
Normal file
@ -0,0 +1,241 @@
|
||||
/*
|
||||
* 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 { findByPropsLazy } from "@webpack";
|
||||
import { useCallback, useEffect, useRef, useState } from "@webpack/common";
|
||||
|
||||
interface SearchBarComponentProps {
|
||||
ref?: React.MutableRefObject<any>;
|
||||
autoFocus: boolean;
|
||||
className: string;
|
||||
size: string;
|
||||
onChange: (query: string) => void;
|
||||
onClear: () => void;
|
||||
query: string;
|
||||
placeholder: string;
|
||||
}
|
||||
|
||||
type TSearchBarComponent =
|
||||
React.FC<SearchBarComponentProps> & { Sizes: Record<"SMALL" | "MEDIUM" | "LARGE", string>; };
|
||||
|
||||
interface Gif {
|
||||
format: number;
|
||||
src: string;
|
||||
width: number;
|
||||
height: number;
|
||||
order: number;
|
||||
url: string;
|
||||
}
|
||||
|
||||
interface Instance {
|
||||
dead?: boolean;
|
||||
state: {
|
||||
resultType?: string;
|
||||
};
|
||||
props: {
|
||||
favCopy: Gif[],
|
||||
|
||||
favorites: Gif[],
|
||||
},
|
||||
forceUpdate: () => void;
|
||||
}
|
||||
|
||||
|
||||
const containerClasses: { searchBar: string; } = findByPropsLazy("searchBar", "searchHeader", "gutterSize");
|
||||
|
||||
export const settings = definePluginSettings({
|
||||
searchOption: {
|
||||
type: OptionType.SELECT,
|
||||
description: "The part of the url you want to search",
|
||||
options: [
|
||||
{
|
||||
label: "Entire Url",
|
||||
value: "url"
|
||||
},
|
||||
{
|
||||
label: "Path Only (/somegif.gif)",
|
||||
value: "path"
|
||||
},
|
||||
{
|
||||
label: "Host & Path (tenor.com somgif.gif)",
|
||||
value: "hostandpath",
|
||||
default: true
|
||||
}
|
||||
] as const
|
||||
}
|
||||
});
|
||||
|
||||
export default definePlugin({
|
||||
name: "FavoriteGifSearch",
|
||||
authors: [Devs.Aria],
|
||||
description: "Adds a search bar for favorite gifs",
|
||||
|
||||
patches: [
|
||||
{
|
||||
find: "renderCategoryExtras",
|
||||
replacement: [
|
||||
{
|
||||
// https://regex101.com/r/4uHtTE/1
|
||||
// ($1 renderHeaderContent=function { ... switch (x) ... case FAVORITES:return) ($2) ($3 case default:return r.jsx(($<searchComp>), {...props}))
|
||||
match: /(renderHeaderContent=function.{1,150}FAVORITES:return)(.{1,150};)(case.{1,200}default:return\(0,\i\.jsx\)\((?<searchComp>\i\.\i))/,
|
||||
replace: "$1 this.state.resultType === \"Favorites\" ? $self.renderSearchBar(this, $<searchComp>) : $2; $3"
|
||||
},
|
||||
{
|
||||
// to persist filtered favorites when component re-renders.
|
||||
// when resizing the window the component rerenders and we loose the filtered favorites and have to type in the search bar to get them again
|
||||
match: /(,suggestions:\i,favorites:)(\i),/,
|
||||
replace: "$1$self.getFav($2),favCopy:$2,"
|
||||
}
|
||||
|
||||
]
|
||||
}
|
||||
],
|
||||
|
||||
settings,
|
||||
|
||||
getTargetString,
|
||||
|
||||
instance: null as Instance | null,
|
||||
renderSearchBar(instance: Instance, SearchBarComponent: TSearchBarComponent) {
|
||||
this.instance = instance;
|
||||
return (
|
||||
<ErrorBoundary noop={true}>
|
||||
<SearchBar instance={instance} SearchBarComponent={SearchBarComponent} />
|
||||
</ErrorBoundary>
|
||||
);
|
||||
},
|
||||
|
||||
getFav(favorites: Gif[]) {
|
||||
if (!this.instance || this.instance.dead) return favorites;
|
||||
const { favorites: filteredFavorites } = this.instance.props;
|
||||
|
||||
return filteredFavorites != null && filteredFavorites?.length !== favorites.length ? filteredFavorites : favorites;
|
||||
|
||||
}
|
||||
});
|
||||
|
||||
|
||||
function SearchBar({ instance, SearchBarComponent }: { instance: Instance; SearchBarComponent: TSearchBarComponent; }) {
|
||||
const [query, setQuery] = useState("");
|
||||
const ref = useRef<{ containerRef?: React.MutableRefObject<HTMLDivElement>; } | null>(null);
|
||||
|
||||
const onChange = useCallback((searchQuery: string) => {
|
||||
setQuery(searchQuery);
|
||||
const { props } = instance;
|
||||
|
||||
// return early
|
||||
if (searchQuery === "") {
|
||||
props.favorites = props.favCopy;
|
||||
instance.forceUpdate();
|
||||
return;
|
||||
}
|
||||
|
||||
|
||||
// scroll back to top
|
||||
ref.current?.containerRef?.current
|
||||
.closest("#gif-picker-tab-panel")
|
||||
?.querySelector("[class|=\"content\"]")
|
||||
?.firstElementChild?.scrollTo(0, 0);
|
||||
|
||||
|
||||
const result =
|
||||
props.favCopy
|
||||
.map(gif => ({
|
||||
score: fuzzySearch(searchQuery.toLowerCase(), getTargetString(gif.url ?? gif.src).replace(/(%20|[_-])/g, " ").toLowerCase()),
|
||||
gif,
|
||||
}))
|
||||
.filter(m => m.score != null) as { score: number; gif: Gif; }[];
|
||||
|
||||
result.sort((a, b) => b.score - a.score);
|
||||
props.favorites = result.map(e => e.gif);
|
||||
|
||||
instance.forceUpdate();
|
||||
}, [instance.state]);
|
||||
|
||||
useEffect(() => {
|
||||
return () => {
|
||||
instance.dead = true;
|
||||
};
|
||||
}, []);
|
||||
|
||||
return (
|
||||
<SearchBarComponent
|
||||
ref={ref}
|
||||
autoFocus={true}
|
||||
className={containerClasses.searchBar}
|
||||
size={SearchBarComponent.Sizes.MEDIUM}
|
||||
onChange={onChange}
|
||||
onClear={() => {
|
||||
setQuery("");
|
||||
if (instance.props.favCopy != null) {
|
||||
instance.props.favorites = instance.props.favCopy;
|
||||
instance.forceUpdate();
|
||||
}
|
||||
}}
|
||||
query={query}
|
||||
placeholder="Search Favorite Gifs"
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
|
||||
|
||||
export function getTargetString(urlStr: string) {
|
||||
const url = new URL(urlStr);
|
||||
switch (settings.store.searchOption) {
|
||||
case "url":
|
||||
return url.href;
|
||||
case "path":
|
||||
if (url.host === "media.discordapp.net" || url.host === "tenor.com")
|
||||
// /attachments/899763415290097664/1095711736461537381/attachment-1.gif -> attachment-1.gif
|
||||
// /view/some-gif-hi-24248063 -> some-gif-hi-24248063
|
||||
return url.pathname.split("/").at(-1) ?? url.pathname;
|
||||
return url.pathname;
|
||||
case "hostandpath":
|
||||
if (url.host === "media.discordapp.net" || url.host === "tenor.com")
|
||||
return `${url.host} ${url.pathname.split("/").at(-1) ?? url.pathname}`;
|
||||
return `${url.host} ${url.pathname}`;
|
||||
|
||||
default:
|
||||
return "";
|
||||
}
|
||||
}
|
||||
|
||||
function fuzzySearch(searchQuery: string, searchString: string) {
|
||||
let searchIndex = 0;
|
||||
let score = 0;
|
||||
|
||||
for (let i = 0; i < searchString.length; i++) {
|
||||
if (searchString[i] === searchQuery[searchIndex]) {
|
||||
score++;
|
||||
searchIndex++;
|
||||
} else {
|
||||
score--;
|
||||
}
|
||||
|
||||
if (searchIndex === searchQuery.length) {
|
||||
return score;
|
||||
}
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
@ -45,7 +45,7 @@ function ToggleIconOff() {
|
||||
className={RegisteredGamesClasses.overlayToggleIconOff}
|
||||
height="24"
|
||||
width="24"
|
||||
viewBox="0 0 32 26"
|
||||
viewBox="0 2.2 32 26"
|
||||
aria-hidden={true}
|
||||
role="img"
|
||||
>
|
||||
@ -77,7 +77,7 @@ function ToggleIconOn({ forceWhite }: { forceWhite?: boolean; }) {
|
||||
className={RegisteredGamesClasses.overlayToggleIconOn}
|
||||
height="24"
|
||||
width="24"
|
||||
viewBox="0 0 32 26"
|
||||
viewBox="0 2.2 32 26"
|
||||
>
|
||||
<path
|
||||
className={forceWhite ? "" : RegisteredGamesClasses.fill}
|
||||
@ -119,7 +119,7 @@ function ToggleActivityComponentWithBackground({ activity }: { activity: Ignored
|
||||
return (
|
||||
<div
|
||||
className={`${TryItOutClasses.tryItOutBadge} ${BaseShapeRoundClasses.baseShapeRound}`}
|
||||
style={{ padding: "0px 2px" }}
|
||||
style={{ padding: "0px 2px", height: 28 }}
|
||||
>
|
||||
<ToggleActivityComponent activity={activity} forceWhite={true} />
|
||||
</div>
|
||||
@ -157,10 +157,16 @@ export default definePlugin({
|
||||
},
|
||||
{
|
||||
find: ".overlayBadge",
|
||||
replacement: {
|
||||
match: /(?<=\(\)\.badgeContainer.+?(\i)\.name}\):null)/,
|
||||
replace: (_, props) => `,$self.renderToggleActivityButton(${props})`
|
||||
replacement: [
|
||||
{
|
||||
match: /(?<=\(\)\.badgeContainer,children:).{0,50}?name:(\i)\.name.+?null/,
|
||||
replace: (m, props) => `[${m},$self.renderToggleActivityButton(${props})]`
|
||||
},
|
||||
{
|
||||
match: /(?<=\(\)\.badgeContainer,children:).{0,50}?name:(\i\.application)\.name.+?null/,
|
||||
replace: (m, props) => `${m},$self.renderToggleActivityButton(${props})`
|
||||
}
|
||||
]
|
||||
},
|
||||
{
|
||||
find: '.displayName="LocalActivityStore"',
|
||||
|
@ -16,6 +16,7 @@
|
||||
* along with this program. If not, see <https://www.gnu.org/licenses/>.
|
||||
*/
|
||||
|
||||
import { classNameFactory } from "@api/Styles";
|
||||
import { FluxDispatcher, React, useRef, useState } from "@webpack/common";
|
||||
|
||||
import { ELEMENT_ID } from "../constants";
|
||||
@ -33,6 +34,8 @@ export interface MagnifierProps {
|
||||
instance: any;
|
||||
}
|
||||
|
||||
const cl = classNameFactory("vc-imgzoom-");
|
||||
|
||||
export const Magnifier: React.FC<MagnifierProps> = ({ instance, size: initialSize, zoom: initalZoom }) => {
|
||||
const [ready, setReady] = useState(false);
|
||||
|
||||
@ -156,7 +159,7 @@ export const Magnifier: React.FC<MagnifierProps> = ({ instance, size: initialSiz
|
||||
|
||||
return (
|
||||
<div
|
||||
className="vc-imgzoom-lens"
|
||||
className={cl("lens", { "nearest-neighbor": settings.store.nearestNeighbour, square: settings.store.square })}
|
||||
style={{
|
||||
opacity,
|
||||
width: size.current + "px",
|
||||
|
@ -23,7 +23,7 @@ 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 { ContextMenu, Menu, React, ReactDOM } from "@webpack/common";
|
||||
import type { Root } from "react-dom/client";
|
||||
|
||||
import { Magnifier, MagnifierProps } from "./components/Magnifier";
|
||||
@ -50,6 +50,18 @@ export const settings = definePluginSettings({
|
||||
default: true,
|
||||
},
|
||||
|
||||
nearestNeighbour: {
|
||||
type: OptionType.BOOLEAN,
|
||||
description: "Use Nearest Neighbour Interpolation when scaling images",
|
||||
default: false,
|
||||
},
|
||||
|
||||
square: {
|
||||
type: OptionType.BOOLEAN,
|
||||
description: "Make the lens square",
|
||||
default: false,
|
||||
},
|
||||
|
||||
zoom: {
|
||||
description: "Zoom of the lens",
|
||||
type: OptionType.SLIDER,
|
||||
@ -78,9 +90,17 @@ export const settings = definePluginSettings({
|
||||
const imageContextMenuPatch: NavContextMenuPatchCallback = children => () => {
|
||||
children.push(
|
||||
<Menu.MenuGroup id="image-zoom">
|
||||
{/* thanks SpotifyControls */}
|
||||
<Menu.MenuCheckboxItem
|
||||
id="vc-square"
|
||||
label="Square Lens"
|
||||
checked={settings.store.square}
|
||||
action={() => {
|
||||
settings.store.square = !settings.store.square;
|
||||
ContextMenu.close();
|
||||
}}
|
||||
/>
|
||||
<Menu.MenuControlItem
|
||||
id="zoom"
|
||||
id="vc-zoom"
|
||||
label="Zoom"
|
||||
control={(props, ref) => (
|
||||
<Menu.MenuSliderControl
|
||||
@ -94,7 +114,7 @@ const imageContextMenuPatch: NavContextMenuPatchCallback = children => () => {
|
||||
)}
|
||||
/>
|
||||
<Menu.MenuControlItem
|
||||
id="size"
|
||||
id="vc-size"
|
||||
label="Lens Size"
|
||||
control={(props, ref) => (
|
||||
<Menu.MenuSliderControl
|
||||
@ -108,7 +128,7 @@ const imageContextMenuPatch: NavContextMenuPatchCallback = children => () => {
|
||||
)}
|
||||
/>
|
||||
<Menu.MenuControlItem
|
||||
id="zoom-speed"
|
||||
id="vc-zoom-speed"
|
||||
label="Zoom Speed"
|
||||
control={(props, ref) => (
|
||||
<Menu.MenuSliderControl
|
||||
|
@ -11,6 +11,14 @@
|
||||
pointer-events: none;
|
||||
}
|
||||
|
||||
.vc-imgzoom-square {
|
||||
border-radius: 0;
|
||||
}
|
||||
|
||||
.vc-imgzoom-nearest-neighbor > img {
|
||||
image-rendering: pixelated; /* https://googlechrome.github.io/samples/image-rendering-pixelated/index.html */
|
||||
}
|
||||
|
||||
/* make the carousel take up less space so we can click the backdrop and exit out of it */
|
||||
[class|="carouselModal"] {
|
||||
height: fit-content;
|
||||
|
@ -132,7 +132,7 @@ export default definePlugin({
|
||||
find: ".Messages.MESSAGE_EDITED,",
|
||||
replacement: {
|
||||
match: /var .,.,.=(.)\.className,.=.\.message,.=.\.children,.=.\.content,.=.\.onUpdate/gm,
|
||||
replace: "try {$1 && $self.INV_REGEX.test($1.content[0]) ? $1.content.push($self.indicator()) : null } catch {};$&"
|
||||
replace: "try {$1 && $self.INV_REGEX.test($1.message.content) ? $1.content.push($self.indicator()) : null } catch {};$&"
|
||||
}
|
||||
},
|
||||
{
|
||||
@ -200,8 +200,11 @@ export default definePlugin({
|
||||
},
|
||||
});
|
||||
|
||||
if (urlCheck?.length)
|
||||
message.embeds.push(await this.getEmbed(new URL(urlCheck[0])));
|
||||
if (urlCheck?.length) {
|
||||
const embed = await this.getEmbed(new URL(urlCheck[0]));
|
||||
if (embed)
|
||||
message.embeds.push(embed);
|
||||
}
|
||||
|
||||
this.updateMessage(message);
|
||||
},
|
||||
|
@ -106,7 +106,7 @@ export default definePlugin({
|
||||
find: ".isSidebarVisible,",
|
||||
replacement: {
|
||||
match: /(var (\i)=\i\.className.+?children):\[(\i\.useMemo[^}]+"aria-multiselectable")/,
|
||||
replace: "$1:[$2.startsWith('members')?$self.render():null,$3"
|
||||
replace: "$1:[$2?.startsWith('members')?$self.render():null,$3"
|
||||
}
|
||||
}],
|
||||
|
||||
|
@ -16,7 +16,6 @@
|
||||
* along with this program. If not, see <https://www.gnu.org/licenses/>.
|
||||
*/
|
||||
|
||||
import { updateMessageComponent } from "@api/ComponentUpdater";
|
||||
import { addAccessory } from "@api/MessageAccessories";
|
||||
import { definePluginSettings } from "@api/Settings";
|
||||
import ErrorBoundary from "@components/ErrorBoundary";
|
||||
@ -29,6 +28,7 @@ import { find, findByCode, findByPropsLazy } from "@webpack";
|
||||
import {
|
||||
Button,
|
||||
ChannelStore,
|
||||
FluxDispatcher,
|
||||
GuildStore,
|
||||
MessageStore,
|
||||
Parser,
|
||||
@ -93,6 +93,26 @@ const settings = definePluginSettings({
|
||||
}
|
||||
]
|
||||
},
|
||||
listMode: {
|
||||
description: "Whether to use ID list as blacklist or whitelist",
|
||||
type: OptionType.SELECT,
|
||||
options: [
|
||||
{
|
||||
label: "Blacklist",
|
||||
value: "blacklist",
|
||||
default: true
|
||||
},
|
||||
{
|
||||
label: "Whitelist",
|
||||
value: "whitelist"
|
||||
}
|
||||
]
|
||||
},
|
||||
idList: {
|
||||
description: "Guild/channel/user IDs to blacklist or whitelist (separate with comma)",
|
||||
type: OptionType.STRING,
|
||||
default: ""
|
||||
},
|
||||
clearMessageCache: {
|
||||
type: OptionType.COMPONENT,
|
||||
description: "Clear the linked message cache",
|
||||
@ -217,6 +237,13 @@ function MessageEmbedAccessory({ message }: { message: Message; }) {
|
||||
continue;
|
||||
}
|
||||
|
||||
const { listMode, idList } = settings.store;
|
||||
|
||||
const isListed = [guildID, channelID, message.author.id].some(id => id && idList.includes(id));
|
||||
|
||||
if (listMode === "blacklist" && isListed) continue;
|
||||
if (listMode === "whitelist" && !isListed) continue;
|
||||
|
||||
let linkedMessage = messageCache.get(messageID)?.message;
|
||||
if (!linkedMessage) {
|
||||
linkedMessage ??= MessageStore.getMessage(channelID, messageID);
|
||||
@ -228,7 +255,10 @@ function MessageEmbedAccessory({ message }: { message: Message; }) {
|
||||
delete msg.interaction;
|
||||
|
||||
messageFetchQueue.push(() => fetchMessage(channelID, messageID)
|
||||
.then(m => m && updateMessageComponent(message.id))
|
||||
.then(m => m && FluxDispatcher.dispatch({
|
||||
type: "MESSAGE_UPDATE",
|
||||
message: msg
|
||||
}))
|
||||
);
|
||||
continue;
|
||||
}
|
||||
@ -332,7 +362,7 @@ function AutomodEmbedAccessory(props: MessageEmbedProps): JSX.Element | null {
|
||||
export default definePlugin({
|
||||
name: "MessageLinkEmbeds",
|
||||
description: "Adds a preview to messages that link another message",
|
||||
authors: [Devs.TheSun, Devs.Ven],
|
||||
authors: [Devs.TheSun, Devs.Ven, Devs.RyanCaoDev],
|
||||
dependencies: ["MessageAccessoriesAPI"],
|
||||
patches: [
|
||||
{
|
||||
@ -360,7 +390,9 @@ export default definePlugin({
|
||||
|
||||
return (
|
||||
<ErrorBoundary>
|
||||
<MessageEmbedAccessory message={props.message} />
|
||||
<MessageEmbedAccessory
|
||||
message={props.message}
|
||||
/>
|
||||
</ErrorBoundary>
|
||||
);
|
||||
}, 4 /* just above rich embeds */);
|
||||
|
@ -152,14 +152,24 @@ export default definePlugin({
|
||||
type: OptionType.STRING,
|
||||
description: "Comma-separated list of user IDs to ignore",
|
||||
default: ""
|
||||
}
|
||||
},
|
||||
ignoreChannels: {
|
||||
type: OptionType.STRING,
|
||||
description: "Comma-separated list of channel IDs to ignore",
|
||||
default: ""
|
||||
},
|
||||
ignoreGuilds: {
|
||||
type: OptionType.STRING,
|
||||
description: "Comma-separated list of guild IDs to ignore",
|
||||
default: ""
|
||||
},
|
||||
},
|
||||
|
||||
handleDelete(cache: any, data: { ids: string[], id: string; mlDeleted?: boolean; }, isBulk: boolean) {
|
||||
try {
|
||||
if (cache == null || (!isBulk && !cache.has(data.id))) return cache;
|
||||
|
||||
const { ignoreBots, ignoreSelf, ignoreUsers } = Settings.plugins.MessageLogger;
|
||||
const { ignoreBots, ignoreSelf, ignoreUsers, ignoreChannels, ignoreGuilds } = Settings.plugins.MessageLogger;
|
||||
const myId = UserStore.getCurrentUser().id;
|
||||
|
||||
function mutate(id: string) {
|
||||
@ -171,7 +181,9 @@ export default definePlugin({
|
||||
(msg.flags & EPHEMERAL) === EPHEMERAL ||
|
||||
ignoreBots && msg.author?.bot ||
|
||||
ignoreSelf && msg.author?.id === myId ||
|
||||
ignoreUsers.includes(msg.author?.id);
|
||||
ignoreUsers.includes(msg.author?.id) ||
|
||||
ignoreChannels.includes(msg.channel_id) ||
|
||||
ignoreGuilds.includes(msg.guild_id);
|
||||
|
||||
if (shouldIgnore) {
|
||||
cache = cache.remove(id);
|
||||
|
@ -21,7 +21,7 @@ import { makeRange } from "@components/PluginSettings/components/SettingSliderCo
|
||||
import { Devs } from "@utils/constants";
|
||||
import { sleep } from "@utils/misc";
|
||||
import definePlugin, { OptionType } from "@utils/types";
|
||||
import { SelectedChannelStore, UserStore } from "@webpack/common";
|
||||
import { RelationshipStore, SelectedChannelStore, UserStore } from "@webpack/common";
|
||||
import { Message, ReactionEmoji } from "discord-types/general";
|
||||
|
||||
interface IMessageCreate {
|
||||
@ -37,6 +37,7 @@ interface IReactionAdd {
|
||||
optimistic: boolean;
|
||||
channelId: string;
|
||||
messageId: string;
|
||||
messageAuthorId: string;
|
||||
userId: "195136840355807232";
|
||||
emoji: ReactionEmoji;
|
||||
}
|
||||
@ -71,6 +72,11 @@ const settings = definePluginSettings({
|
||||
description: "Ignore bots",
|
||||
type: OptionType.BOOLEAN,
|
||||
default: true
|
||||
},
|
||||
ignoreBlocked: {
|
||||
description: "Ignore blocked users",
|
||||
type: OptionType.BOOLEAN,
|
||||
default: true
|
||||
}
|
||||
});
|
||||
|
||||
@ -85,6 +91,7 @@ export default definePlugin({
|
||||
if (optimistic || type !== "MESSAGE_CREATE") return;
|
||||
if (message.state === "SENDING") return;
|
||||
if (settings.store.ignoreBots && message.author?.bot) return;
|
||||
if (settings.store.ignoreBlocked && RelationshipStore.isBlocked(message.author?.id)) return;
|
||||
if (!message.content) return;
|
||||
if (channelId !== SelectedChannelStore.getChannelId()) return;
|
||||
|
||||
@ -96,9 +103,10 @@ export default definePlugin({
|
||||
}
|
||||
},
|
||||
|
||||
MESSAGE_REACTION_ADD({ optimistic, type, channelId, userId, emoji }: IReactionAdd) {
|
||||
MESSAGE_REACTION_ADD({ optimistic, type, channelId, userId, messageAuthorId, emoji }: IReactionAdd) {
|
||||
if (optimistic || type !== "MESSAGE_REACTION_ADD") return;
|
||||
if (settings.store.ignoreBots && UserStore.getUser(userId)?.bot) return;
|
||||
if (settings.store.ignoreBlocked && RelationshipStore.isBlocked(messageAuthorId)) return;
|
||||
if (channelId !== SelectedChannelStore.getChannelId()) return;
|
||||
|
||||
const name = emoji.name.toLowerCase();
|
||||
|
147
src/plugins/openInApp.ts
Normal file
147
src/plugins/openInApp.ts
Normal file
@ -0,0 +1,147 @@
|
||||
/*
|
||||
* 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 { Devs } from "@utils/constants";
|
||||
import definePlugin, { OptionType } from "@utils/types";
|
||||
import { showToast, Toasts } from "@webpack/common";
|
||||
import type { MouseEvent } from "react";
|
||||
|
||||
const ShortUrlMatcher = /^https:\/\/(spotify\.link|s\.team)\/.+$/;
|
||||
const SpotifyMatcher = /^https:\/\/open\.spotify\.com\/(track|album|artist|playlist|user)\/(.+)(?:\?.+?)?$/;
|
||||
const SteamMatcher = /^https:\/\/(steamcommunity\.com|(?:help|store)\.steampowered\.com)\/.+$/;
|
||||
const EpicMatcher = /^https:\/\/store\.epicgames\.com\/(.+)$/;
|
||||
|
||||
const settings = definePluginSettings({
|
||||
spotify: {
|
||||
type: OptionType.BOOLEAN,
|
||||
description: "Open Spotify links in the Spotify app",
|
||||
default: true,
|
||||
},
|
||||
steam: {
|
||||
type: OptionType.BOOLEAN,
|
||||
description: "Open Steam links in the Steam app",
|
||||
default: true,
|
||||
},
|
||||
epic: {
|
||||
type: OptionType.BOOLEAN,
|
||||
description: "Open Epic Games links in the Epic Games Launcher",
|
||||
default: true,
|
||||
}
|
||||
});
|
||||
|
||||
export default definePlugin({
|
||||
name: "OpenInApp",
|
||||
description: "Open Spotify, Steam and Epic Games URLs in their respective apps instead of your browser",
|
||||
authors: [Devs.Ven],
|
||||
settings,
|
||||
|
||||
patches: [
|
||||
{
|
||||
find: '"MaskedLinkStore"',
|
||||
replacement: {
|
||||
match: /return ((\i)\.apply\(this,arguments\))(?=\}function \i.{0,200}\.trusted)/,
|
||||
replace: "return $self.handleLink(...arguments).then(handled => handled || $1)"
|
||||
}
|
||||
},
|
||||
// Make Spotify profile activity links open in app on web
|
||||
{
|
||||
find: "WEB_OPEN(",
|
||||
predicate: () => !IS_DISCORD_DESKTOP && settings.store.spotify,
|
||||
replacement: {
|
||||
match: /\i\.\i\.isProtocolRegistered\(\)(.{0,100})window.open/g,
|
||||
replace: "true$1VencordNative.native.openExternal"
|
||||
}
|
||||
},
|
||||
{
|
||||
find: ".CONNECTED_ACCOUNT_VIEWED,",
|
||||
replacement: {
|
||||
match: /(?<=href:\i,onClick:function\(\i\)\{)(?=\i=(\i)\.type,.{0,50}CONNECTED_ACCOUNT_VIEWED)/,
|
||||
replace: "$self.handleAccountView(arguments[0],$1.type,$1.id);"
|
||||
}
|
||||
}
|
||||
],
|
||||
|
||||
async handleLink(data: { href: string; }, event?: MouseEvent) {
|
||||
if (!data) return false;
|
||||
|
||||
let url = data.href;
|
||||
if (!IS_WEB && ShortUrlMatcher.test(url)) {
|
||||
event?.preventDefault();
|
||||
// CORS jumpscare
|
||||
url = await VencordNative.pluginHelpers.OpenInApp.resolveRedirect(url);
|
||||
}
|
||||
|
||||
spotify: {
|
||||
if (!settings.store.spotify) break spotify;
|
||||
|
||||
const match = SpotifyMatcher.exec(url);
|
||||
if (!match) break spotify;
|
||||
|
||||
const [, type, id] = match;
|
||||
VencordNative.native.openExternal(`spotify:${type}:${id}`);
|
||||
|
||||
event?.preventDefault();
|
||||
return true;
|
||||
}
|
||||
|
||||
steam: {
|
||||
if (!settings.store.steam) break steam;
|
||||
|
||||
if (!SteamMatcher.test(url)) break steam;
|
||||
|
||||
VencordNative.native.openExternal(`steam://openurl/${url}`);
|
||||
event?.preventDefault();
|
||||
|
||||
// Steam does not focus itself so show a toast so it's slightly less confusing
|
||||
showToast("Opened link in Steam", Toasts.Type.SUCCESS);
|
||||
return true;
|
||||
}
|
||||
|
||||
epic: {
|
||||
if (!settings.store.epic) break epic;
|
||||
|
||||
const match = EpicMatcher.exec(url);
|
||||
if (!match) break epic;
|
||||
|
||||
VencordNative.native.openExternal(`com.epicgames.launcher://store/${match[1]}`);
|
||||
event?.preventDefault();
|
||||
|
||||
return true;
|
||||
}
|
||||
|
||||
// in case short url didn't end up being something we can handle
|
||||
if (event?.defaultPrevented) {
|
||||
window.open(url, "_blank");
|
||||
return true;
|
||||
}
|
||||
|
||||
return false;
|
||||
},
|
||||
|
||||
handleAccountView(event: { preventDefault(): void; }, platformType: string, userId: string) {
|
||||
if (platformType === "spotify" && settings.store.spotify) {
|
||||
VencordNative.native.openExternal(`spotify:user:${userId}`);
|
||||
event.preventDefault();
|
||||
} else if (platformType === "steam" && settings.store.steam) {
|
||||
VencordNative.native.openExternal(`steam://openurl/https://steamcommunity.com/profiles/${userId}`);
|
||||
showToast("Opened link in Steam", Toasts.Type.SUCCESS);
|
||||
event.preventDefault();
|
||||
}
|
||||
}
|
||||
});
|
@ -52,7 +52,7 @@ export function CompactPronounsChatComponentWrapper({ message }: { message: Mess
|
||||
}
|
||||
|
||||
function PronounsChatComponent({ message }: { message: Message; }) {
|
||||
const result = useFormattedPronouns(message.author.id);
|
||||
const [result] = useFormattedPronouns(message.author.id);
|
||||
|
||||
return result
|
||||
? (
|
||||
@ -64,7 +64,7 @@ function PronounsChatComponent({ message }: { message: Message; }) {
|
||||
}
|
||||
|
||||
export function CompactPronounsChatComponent({ message }: { message: Message; }) {
|
||||
const result = useFormattedPronouns(message.author.id);
|
||||
const [result] = useFormattedPronouns(message.author.id);
|
||||
|
||||
return result
|
||||
? (
|
||||
|
@ -26,6 +26,11 @@ import { CompactPronounsChatComponentWrapper, PronounsChatComponentWrapper } fro
|
||||
import { useProfilePronouns } from "./pronoundbUtils";
|
||||
import { settings } from "./settings";
|
||||
|
||||
const PRONOUN_TOOLTIP_PATCH = {
|
||||
match: /text:(.{0,10}.Messages\.USER_PROFILE_PRONOUNS)(?=,)/,
|
||||
replace: '$& + (typeof vcPronounSource !== "undefined" ? ` (${vcPronounSource})` : "")'
|
||||
};
|
||||
|
||||
export default definePlugin({
|
||||
name: "PronounDB",
|
||||
authors: [Devs.Tyman, Devs.TheKodeToad, Devs.Ven],
|
||||
@ -50,18 +55,24 @@ export default definePlugin({
|
||||
// Patch the profile popout username header to use our pronoun hook instead of Discord's pronouns
|
||||
{
|
||||
find: ".userTagNoNickname",
|
||||
replacement: {
|
||||
match: /=(\i)\.pronouns/,
|
||||
replace: "=$self.useProfilePronouns($1.user.id,$1.pronouns)"
|
||||
}
|
||||
replacement: [
|
||||
{
|
||||
match: /,(\i)=(\i)\.pronouns/,
|
||||
replace: ",[$1,vcPronounSource]=$self.useProfilePronouns($2.user.id)"
|
||||
},
|
||||
PRONOUN_TOOLTIP_PATCH
|
||||
]
|
||||
},
|
||||
// Patch the profile modal username header to use our pronoun hook instead of Discord's pronouns
|
||||
{
|
||||
find: ".USER_PROFILE_ACTIVITY",
|
||||
replacement: {
|
||||
match: /\).showPronouns/,
|
||||
replace: ").showPronouns||true;const vcPronounce=$self.useProfilePronouns(arguments[0].user.id,arguments[0].displayProfile?.pronouns);if(arguments[0].displayProfile&&vcPronounce)arguments[0].displayProfile.pronouns=vcPronounce"
|
||||
}
|
||||
replacement: [
|
||||
{
|
||||
match: /getGlobalName\(\i\);(?<=displayProfile.{0,200})/,
|
||||
replace: "$&const [vcPronounce,vcPronounSource]=$self.useProfilePronouns(arguments[0].user.id,true);if(arguments[0].displayProfile&&vcPronounce)arguments[0].displayProfile.pronouns=vcPronounce;"
|
||||
},
|
||||
PRONOUN_TOOLTIP_PATCH
|
||||
]
|
||||
}
|
||||
],
|
||||
|
||||
|
@ -29,6 +29,9 @@ import { PronounCode, PronounMapping, PronounsResponse } from "./types";
|
||||
|
||||
const UserProfileStore = findStoreLazy("UserProfileStore");
|
||||
|
||||
type PronounsWithSource = [string | null, string];
|
||||
const EmptyPronouns: PronounsWithSource = [null, ""];
|
||||
|
||||
export const enum PronounsFormat {
|
||||
Lowercase = "LOWERCASE",
|
||||
Capitalized = "CAPITALIZED"
|
||||
@ -55,52 +58,59 @@ const bulkFetch = debounce(async () => {
|
||||
}
|
||||
});
|
||||
|
||||
function getDiscordPronouns(id: string) {
|
||||
function getDiscordPronouns(id: string, useGlobalProfile: boolean = false) {
|
||||
const globalPronouns = UserProfileStore.getUserProfile(id)?.pronouns;
|
||||
|
||||
if (useGlobalProfile) return globalPronouns;
|
||||
|
||||
return (
|
||||
UserProfileStore.getGuildMemberProfile(id, getCurrentChannel()?.guild_id)?.pronouns
|
||||
|| UserProfileStore.getUserProfile(id)?.pronouns
|
||||
|| globalPronouns
|
||||
);
|
||||
}
|
||||
|
||||
export function useFormattedPronouns(id: string, discordPronouns: string = getDiscordPronouns(id)): string | null {
|
||||
const [result] = useAwaiter(() => fetchPronouns(id, discordPronouns), {
|
||||
fallbackValue: getCachedPronouns(id, discordPronouns),
|
||||
export function useFormattedPronouns(id: string, useGlobalProfile: boolean = false): PronounsWithSource {
|
||||
// Discord is so stupid you can put tons of newlines in pronouns
|
||||
const discordPronouns = getDiscordPronouns(id, useGlobalProfile)?.trim().replace(NewLineRe, " ");
|
||||
|
||||
const [result] = useAwaiter(() => fetchPronouns(id), {
|
||||
fallbackValue: getCachedPronouns(id),
|
||||
onError: e => console.error("Fetching pronouns failed: ", e)
|
||||
});
|
||||
|
||||
if (result && result !== "unspecified")
|
||||
return Object.hasOwn(PronounMapping, result)
|
||||
? formatPronouns(result) // PronounDB
|
||||
: result; // Discord
|
||||
if (settings.store.pronounSource === PronounSource.PreferDiscord && discordPronouns)
|
||||
return [discordPronouns, "Discord"];
|
||||
|
||||
return null;
|
||||
if (result && result !== "unspecified")
|
||||
return [formatPronouns(result), "PronounDB"];
|
||||
|
||||
return [discordPronouns, "Discord"];
|
||||
}
|
||||
|
||||
export function useProfilePronouns(id: string, discordPronouns: string) {
|
||||
const pronouns = useFormattedPronouns(id, discordPronouns);
|
||||
export function useProfilePronouns(id: string, useGlobalProfile: boolean = false): PronounsWithSource {
|
||||
const pronouns = useFormattedPronouns(id, useGlobalProfile);
|
||||
|
||||
if (!settings.store.showInProfile) return null;
|
||||
if (!settings.store.showSelf && id === UserStore.getCurrentUser().id) return null;
|
||||
if (!settings.store.showInProfile) return EmptyPronouns;
|
||||
if (!settings.store.showSelf && id === UserStore.getCurrentUser().id) return EmptyPronouns;
|
||||
|
||||
return pronouns;
|
||||
}
|
||||
|
||||
|
||||
// Gets the cached pronouns, if you're too impatient for a promise!
|
||||
export function getCachedPronouns(id: string, discordPronouns: string): string | null {
|
||||
if (settings.store.pronounSource === PronounSource.PreferDiscord && discordPronouns)
|
||||
return discordPronouns;
|
||||
const NewLineRe = /\n+/g;
|
||||
|
||||
// Gets the cached pronouns, if you're too impatient for a promise!
|
||||
export function getCachedPronouns(id: string): string | null {
|
||||
const cached = cache[id];
|
||||
if (cached && cached !== "unspecified") return cached;
|
||||
|
||||
return discordPronouns || cached || null;
|
||||
return cached || null;
|
||||
}
|
||||
|
||||
// Fetches the pronouns for one id, returning a promise that resolves if it was cached, or once the request is completed
|
||||
export function fetchPronouns(id: string, discordPronouns: string): Promise<string> {
|
||||
export function fetchPronouns(id: string): Promise<string> {
|
||||
return new Promise(res => {
|
||||
const cached = getCachedPronouns(id, discordPronouns);
|
||||
const cached = getCachedPronouns(id);
|
||||
if (cached) return res(cached);
|
||||
|
||||
// If there is already a request added, then just add this callback to it
|
||||
|
@ -50,7 +50,7 @@ export async function onRelationshipRemove({ relationship: { type, id } }: Relat
|
||||
() => openUserProfile(user.id)
|
||||
);
|
||||
break;
|
||||
case RelationshipType.FRIEND_REQUEST:
|
||||
case RelationshipType.INCOMING_REQUEST:
|
||||
if (settings.store.friendRequestCancels)
|
||||
notify(
|
||||
`A friend request from ${getUniqueUsername(user)} has been removed.`,
|
||||
|
@ -58,5 +58,7 @@ export const enum ChannelType {
|
||||
|
||||
export const enum RelationshipType {
|
||||
FRIEND = 1,
|
||||
FRIEND_REQUEST = 3,
|
||||
BLOCKED = 2,
|
||||
INCOMING_REQUEST = 3,
|
||||
OUTGOING_REQUEST = 4,
|
||||
}
|
||||
|
@ -80,7 +80,10 @@ export async function syncAndRunChecks() {
|
||||
|
||||
if (settings.store.friendRequestCancels && oldFriends?.requests?.length) {
|
||||
for (const id of oldFriends.requests) {
|
||||
if (friends.requests.includes(id)) continue;
|
||||
if (
|
||||
friends.requests.includes(id) ||
|
||||
[RelationshipType.FRIEND, RelationshipType.BLOCKED, RelationshipType.OUTGOING_REQUEST].includes(RelationshipStore.getRelationshipType(id))
|
||||
) continue;
|
||||
|
||||
const user = await UserUtils.fetchUser(id).catch(() => void 0);
|
||||
if (user)
|
||||
@ -164,7 +167,7 @@ export async function syncFriends() {
|
||||
case RelationshipType.FRIEND:
|
||||
friends.friends.push(id);
|
||||
break;
|
||||
case RelationshipType.FRIEND_REQUEST:
|
||||
case RelationshipType.INCOMING_REQUEST:
|
||||
friends.requests.push(id);
|
||||
break;
|
||||
}
|
||||
|
@ -28,8 +28,8 @@ export default definePlugin({
|
||||
{
|
||||
find: ".Messages.MESSAGE_UTILITIES_A11Y_LABEL",
|
||||
replacement: {
|
||||
// isExpanded: V, (?<=,V = shiftKeyDown && !H...;)
|
||||
match: /isExpanded:(\i),(?<=,\1=\i&&(!.+);.+?)/,
|
||||
// isExpanded: V, (?<=,V = shiftKeyDown && !H...,|;)
|
||||
match: /isExpanded:(\i),(?<=,\1=\i&&(?=(!.+?)[,;]).+?)/,
|
||||
replace: "isExpanded:$2,"
|
||||
}
|
||||
}
|
||||
|
@ -147,6 +147,13 @@ function CompactConnectionComponent({ connection, theme }: { connection: Connect
|
||||
className="vc-user-connection"
|
||||
href={url}
|
||||
target="_blank"
|
||||
onClick={e => {
|
||||
if (Vencord.Plugins.isPluginEnabled("OpenInApp")) {
|
||||
const OpenInApp = Vencord.Plugins.plugins.OpenInApp as any as typeof import("../openInApp").default;
|
||||
// handleLink will .preventDefault() if applicable
|
||||
OpenInApp.handleLink(e.currentTarget, e);
|
||||
}
|
||||
}}
|
||||
>
|
||||
{img}
|
||||
</a>
|
||||
|
@ -29,7 +29,7 @@ import type { Channel, Role } from "discord-types/general";
|
||||
|
||||
import HiddenChannelLockScreen, { setChannelBeginHeaderComponent } from "./components/HiddenChannelLockScreen";
|
||||
|
||||
const ChannelListClasses = findByPropsLazy("channelName", "subtitle", "modeMuted", "iconContainer");
|
||||
const ChannelListClasses = findByPropsLazy("channelEmoji", "unread", "icon");
|
||||
|
||||
export const VIEW_CHANNEL = 1n << 10n;
|
||||
const CONNECT = 1n << 20n;
|
||||
@ -342,32 +342,27 @@ export default definePlugin({
|
||||
]
|
||||
},
|
||||
{
|
||||
find: "Guild voice channel without guild id.",
|
||||
find: "useNotificationSettingsItem: channel cannot be undefined",
|
||||
replacement: [
|
||||
{
|
||||
// Render our HiddenChannelLockScreen component instead of the main stage channel component
|
||||
match: /Guild voice channel without guild id.+?children:(?<=(\i)\.getGuildId\(\).+?)(?=.{0,20}?}\)}function)/,
|
||||
match: /"124px".+?children:(?<=var (\i)=\i\.channel.+?)(?=.{0,20}?}\)}function)/,
|
||||
replace: (m, channel) => `${m}$self.isHiddenChannel(${channel})?$self.HiddenChannelLockScreen(${channel}):`
|
||||
},
|
||||
{
|
||||
// Disable useless components for the HiddenChannelLockScreen of stage channels
|
||||
match: /render(?!Header).{0,30}?:(?<=(\i)\.getGuildId\(\).+?Guild voice channel without guild id.+?)/g,
|
||||
match: /render(?:BottomLeft|BottomCenter|BottomRight|ChatToasts):(?<=var (\i)=\i\.channel.+?)/g,
|
||||
replace: (m, channel) => `${m}$self.isHiddenChannel(${channel})?null:`
|
||||
},
|
||||
// Prevent Discord from replacing our route if we aren't connected to the stage channel
|
||||
{
|
||||
match: /(?=!\i&&!\i&&!\i.{0,80}?(\i)\.getGuildId\(\).{0,50}?Guild voice channel without guild id)(?<=if\()/,
|
||||
replace: (_, channel) => `!$self.isHiddenChannel(${channel})&&`
|
||||
},
|
||||
{
|
||||
// Disable gradients for the HiddenChannelLockScreen of stage channels
|
||||
match: /Guild voice channel without guild id.+?disableGradients:(?<=(\i)\.getGuildId\(\).+?)/,
|
||||
match: /"124px".+?disableGradients:(?<=(\i)\.getGuildId\(\).+?)/,
|
||||
replace: (m, channel) => `${m}$self.isHiddenChannel(${channel})||`
|
||||
},
|
||||
{
|
||||
// Disable strange styles applied to the header for the HiddenChannelLockScreen of stage channels
|
||||
match: /Guild voice channel without guild id.+?style:(?<=(\i)\.getGuildId\(\).+?)/,
|
||||
replace: (m, channel) => `${m}$self.isHiddenChannel(${channel})?undefined:`
|
||||
match: /"124px".+?style:(?<=(\i)\.getGuildId\(\).+?)/,
|
||||
replace: (m, channel) => `${m}$self.isHiddenChannel(${channel})?void 0:`
|
||||
},
|
||||
{
|
||||
// Remove the divider and amount of users in stage channel components for the HiddenChannelLockScreen
|
||||
@ -376,7 +371,7 @@ export default definePlugin({
|
||||
},
|
||||
{
|
||||
// Remove the open chat button for the HiddenChannelLockScreen
|
||||
match: /"recents".+?null,(?=.+?channelId:(\i)\.id,showRequestToSpeakSidebar)/,
|
||||
match: /"recents".+?&&(?=\(.+?channelId:(\i)\.id,showRequestToSpeakSidebar)/,
|
||||
replace: (m, channel) => `${m}!$self.isHiddenChannel(${channel})&&`
|
||||
}
|
||||
],
|
||||
@ -477,7 +472,7 @@ export default definePlugin({
|
||||
|
||||
HiddenChannelLockScreen: (channel: any) => <HiddenChannelLockScreen channel={channel} />,
|
||||
|
||||
LockIcon: () => (
|
||||
LockIcon: ErrorBoundary.wrap(() => (
|
||||
<svg
|
||||
className={ChannelListClasses.icon}
|
||||
height="18"
|
||||
@ -488,7 +483,7 @@ export default definePlugin({
|
||||
>
|
||||
<path className="shc-evenodd-fill-current-color" d="M17 11V7C17 4.243 14.756 2 12 2C9.242 2 7 4.243 7 7V11C5.897 11 5 11.896 5 13V20C5 21.103 5.897 22 7 22H17C18.103 22 19 21.103 19 20V13C19 11.896 18.103 11 17 11ZM12 18C11.172 18 10.5 17.328 10.5 16.5C10.5 15.672 11.172 15 12 15C12.828 15 13.5 15.672 13.5 16.5C13.5 17.328 12.828 18 12 18ZM15 11H9V7C9 5.346 10.346 4 12 4C13.654 4 15 5.346 15 7V11Z" />
|
||||
</svg>
|
||||
),
|
||||
), { noop: true }),
|
||||
|
||||
HiddenChannelIcon: ErrorBoundary.wrap(() => (
|
||||
<Tooltip text="Hidden Channel">
|
||||
|
@ -21,8 +21,8 @@ import "./spotifyStyles.css";
|
||||
import ErrorBoundary from "@components/ErrorBoundary";
|
||||
import { Flex } from "@components/Flex";
|
||||
import { ImageIcon, LinkIcon, OpenExternalIcon } from "@components/Icons";
|
||||
import { Link } from "@components/Link";
|
||||
import { debounce } from "@utils/debounce";
|
||||
import { openImageModal } from "@utils/discord";
|
||||
import { classes, copyWithToast } from "@utils/misc";
|
||||
import { ContextMenu, FluxDispatcher, Forms, Menu, React, useEffect, useState, useStateFromStores } from "@webpack/common";
|
||||
|
||||
@ -231,7 +231,7 @@ function AlbumContextMenu({ track }: { track: Track; }) {
|
||||
id="view-cover"
|
||||
label="View Album Cover"
|
||||
// trolley
|
||||
action={() => (Vencord.Plugins.plugins.ViewIcons as any).openImage(track.album.image.url)}
|
||||
action={() => openImageModal(track.album.image.url)}
|
||||
icon={ImageIcon}
|
||||
/>
|
||||
<Menu.MenuControlItem
|
||||
@ -253,6 +253,16 @@ function AlbumContextMenu({ track }: { track: Track; }) {
|
||||
);
|
||||
}
|
||||
|
||||
function makeLinkProps(name: string, condition: unknown, path: string) {
|
||||
if (!condition) return {};
|
||||
|
||||
return {
|
||||
role: "link",
|
||||
onClick: () => SpotifyStore.openExternal(path),
|
||||
onContextMenu: makeContextMenu(name, path)
|
||||
} satisfies React.HTMLAttributes<HTMLElement>;
|
||||
}
|
||||
|
||||
function Info({ track }: { track: Track; }) {
|
||||
const img = track?.album?.image;
|
||||
|
||||
@ -288,12 +298,8 @@ function Info({ track }: { track: Track; }) {
|
||||
variant="text-sm/semibold"
|
||||
id={cl("song-title")}
|
||||
className={cl("ellipoverflow")}
|
||||
role={track.id ? "link" : undefined}
|
||||
title={track.name}
|
||||
onClick={track.id ? () => {
|
||||
SpotifyStore.openExternal(`/track/${track.id}`);
|
||||
} : void 0}
|
||||
onContextMenu={track.id ? makeContextMenu("Song", `/track/${track.id}`) : void 0}
|
||||
{...makeLinkProps("Song", track.id, `/track/${track.id}`)}
|
||||
>
|
||||
{track.name}
|
||||
</Forms.FormText>
|
||||
@ -302,16 +308,14 @@ function Info({ track }: { track: Track; }) {
|
||||
by
|
||||
{track.artists.map((a, i) => (
|
||||
<React.Fragment key={a.name}>
|
||||
<Link
|
||||
<span
|
||||
className={cl("artist")}
|
||||
disabled={!a.id}
|
||||
href={`https://open.spotify.com/artist/${a.id}`}
|
||||
style={{ fontSize: "inherit" }}
|
||||
title={a.name}
|
||||
onContextMenu={makeContextMenu("Artist", `/artist/${a.id}`)}
|
||||
{...makeLinkProps("Artist", a.id, `/artist/${a.id}`)}
|
||||
>
|
||||
{a.name}
|
||||
</Link>
|
||||
</span>
|
||||
{i !== track.artists.length - 1 && <span className={cl("comma")}>{", "}</span>}
|
||||
</React.Fragment>
|
||||
))}
|
||||
@ -320,17 +324,15 @@ function Info({ track }: { track: Track; }) {
|
||||
{track.album.name && (
|
||||
<Forms.FormText variant="text-sm/normal" className={cl("ellipoverflow")}>
|
||||
on
|
||||
<Link id={cl("album-title")}
|
||||
href={`https://open.spotify.com/album/${track.album.id}`}
|
||||
target="_blank"
|
||||
<span
|
||||
id={cl("album-title")}
|
||||
className={cl("album")}
|
||||
disabled={!track.album.id}
|
||||
style={{ fontSize: "inherit" }}
|
||||
title={track.album.name}
|
||||
onContextMenu={makeContextMenu("Album", `/album/${track.album.id}`)}
|
||||
{...makeLinkProps("Album", track.album.id, `/album/${track.album.id}`)}
|
||||
>
|
||||
{track.album.name}
|
||||
</Link>
|
||||
</span>
|
||||
</Forms.FormText>
|
||||
)}
|
||||
</div>
|
||||
|
@ -89,7 +89,7 @@ export const SpotifyStore = proxyLazy(() => {
|
||||
public isSettingPosition = false;
|
||||
|
||||
public openExternal(path: string) {
|
||||
const url = Settings.plugins.SpotifyControls.useSpotifyUris
|
||||
const url = Settings.plugins.SpotifyControls.useSpotifyUris || Vencord.Plugins.isPluginEnabled("OpenInApp")
|
||||
? "spotify:" + path.replaceAll("/", (_, idx) => idx === 0 ? "" : ":")
|
||||
: "https://open.spotify.com" + path;
|
||||
|
||||
|
@ -131,8 +131,8 @@
|
||||
color: var(--header-secondary);
|
||||
}
|
||||
|
||||
.vc-spotify-artist:hover,
|
||||
#vc-spotify-album-title:hover,
|
||||
.vc-spotify-artist[role="link"]:hover,
|
||||
#vc-spotify-album-title[role="link"]:hover,
|
||||
#vc-spotify-song-title[role="link"]:hover {
|
||||
text-decoration: underline;
|
||||
cursor: pointer;
|
||||
|
@ -44,6 +44,9 @@ export function TranslationAccessory({ message }: { message: Message; }) {
|
||||
const [translation, setTranslation] = useState<TranslationValue>();
|
||||
|
||||
useEffect(() => {
|
||||
// Ignore MessageLinkEmbeds messages
|
||||
if ((message as any).vencordEmbeddedBy) return;
|
||||
|
||||
TranslationSetters.set(message.id, setTranslation);
|
||||
|
||||
return () => void TranslationSetters.delete(message.id);
|
||||
|
@ -18,19 +18,39 @@
|
||||
|
||||
import "./styles.css";
|
||||
|
||||
import { addContextMenuPatch, findGroupChildrenByChildId, NavContextMenuPatchCallback, removeContextMenuPatch } from "@api/ContextMenu";
|
||||
import { addAccessory, removeAccessory } from "@api/MessageAccessories";
|
||||
import { addPreSendListener, removePreSendListener } from "@api/MessageEvents";
|
||||
import { addButton, removeButton } from "@api/MessagePopover";
|
||||
import ErrorBoundary from "@components/ErrorBoundary";
|
||||
import { Devs } from "@utils/constants";
|
||||
import definePlugin from "@utils/types";
|
||||
import { ChannelStore } from "@webpack/common";
|
||||
import { ChannelStore, Menu } from "@webpack/common";
|
||||
|
||||
import { settings } from "./settings";
|
||||
import { TranslateChatBarIcon, TranslateIcon } from "./TranslateIcon";
|
||||
import { handleTranslate, TranslationAccessory } from "./TranslationAccessory";
|
||||
import { translate } from "./utils";
|
||||
|
||||
const messageCtxPatch: NavContextMenuPatchCallback = (children, { message }) => () => {
|
||||
if (!message.content) return;
|
||||
|
||||
const group = findGroupChildrenByChildId("copy-text", children);
|
||||
if (!group) return;
|
||||
|
||||
group.splice(group.findIndex(c => c?.props?.id === "copy-text") + 1, 0, (
|
||||
<Menu.MenuItem
|
||||
id="vc-trans"
|
||||
label="Translate"
|
||||
icon={TranslateIcon}
|
||||
action={async () => {
|
||||
const trans = await translate("received", message.content);
|
||||
handleTranslate(message.id, trans);
|
||||
}}
|
||||
/>
|
||||
));
|
||||
};
|
||||
|
||||
export default definePlugin({
|
||||
name: "Translate",
|
||||
description: "Translate messages with Google Translate",
|
||||
@ -53,6 +73,8 @@ export default definePlugin({
|
||||
start() {
|
||||
addAccessory("vc-translation", props => <TranslationAccessory message={props.message} />);
|
||||
|
||||
addContextMenuPatch("message", messageCtxPatch);
|
||||
|
||||
addButton("vc-translate", message => {
|
||||
if (!message.content) return null;
|
||||
|
||||
@ -78,6 +100,7 @@ export default definePlugin({
|
||||
|
||||
stop() {
|
||||
removePreSendListener(this.preSend);
|
||||
removeContextMenuPatch("message", messageCtxPatch);
|
||||
removeButton("vc-translate");
|
||||
removeAccessory("vc-translation");
|
||||
},
|
||||
|
@ -1,143 +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 { findOption, RequiredMessageOption } from "@api/Commands";
|
||||
import { addPreEditListener, addPreSendListener, MessageObject, removePreEditListener, removePreSendListener } from "@api/MessageEvents";
|
||||
import { definePluginSettings } from "@api/Settings";
|
||||
import { Devs } from "@utils/constants";
|
||||
import definePlugin, { OptionType } from "@utils/types";
|
||||
|
||||
const endings = [
|
||||
"rawr x3",
|
||||
"OwO",
|
||||
"UwU",
|
||||
"o.O",
|
||||
"-.-",
|
||||
">w<",
|
||||
"(⑅˘꒳˘)",
|
||||
"(ꈍᴗꈍ)",
|
||||
"(˘ω˘)",
|
||||
"(U ᵕ U❁)",
|
||||
"σωσ",
|
||||
"òωó",
|
||||
"(///ˬ///✿)",
|
||||
"(U ﹏ U)",
|
||||
"( ͡o ω ͡o )",
|
||||
"ʘwʘ",
|
||||
":3",
|
||||
":3", // important enough to have twice
|
||||
"XD",
|
||||
"nyaa~~",
|
||||
"mya",
|
||||
">_<",
|
||||
"😳",
|
||||
"🥺",
|
||||
"😳😳😳",
|
||||
"rawr",
|
||||
"^^",
|
||||
"^^;;",
|
||||
"(ˆ ﻌ ˆ)♡",
|
||||
"^•ﻌ•^",
|
||||
"/(^•ω•^)",
|
||||
"(✿oωo)"
|
||||
];
|
||||
|
||||
const replacements = [
|
||||
["small", "smol"],
|
||||
["cute", "kawaii~"],
|
||||
["fluff", "floof"],
|
||||
["love", "luv"],
|
||||
["stupid", "baka"],
|
||||
["what", "nani"],
|
||||
["meow", "nya~"],
|
||||
["hello", "hewwo"],
|
||||
];
|
||||
|
||||
const settings = definePluginSettings({
|
||||
uwuEveryMessage: {
|
||||
description: "Make every single message uwuified",
|
||||
type: OptionType.BOOLEAN,
|
||||
default: false,
|
||||
restartNeeded: false
|
||||
}
|
||||
});
|
||||
|
||||
function selectRandomElement(arr) {
|
||||
// generate a random index based on the length of the array
|
||||
const randomIndex = Math.floor(Math.random() * arr.length);
|
||||
|
||||
// return the element at the randomly generated index
|
||||
return arr[randomIndex];
|
||||
}
|
||||
|
||||
|
||||
function uwuify(message: string): string {
|
||||
message = message.toLowerCase();
|
||||
// words
|
||||
for (const pair of replacements) {
|
||||
message = message.replaceAll(pair[0], pair[1]);
|
||||
}
|
||||
message = message
|
||||
.replaceAll(/([ \t\n])n/g, "$1ny") // nyaify
|
||||
.replaceAll(/[lr]/g, "w") // [lr] > w
|
||||
.replaceAll(/([ \t\n])([a-z])/g, (_, p1, p2) => Math.random() < .5 ? `${p1}${p2}-${p2}` : `${p1}${p2}`) // stutter
|
||||
.replaceAll(/([^.,!][.,!])([ \t\n])/g, (_, p1, p2) => `${p1} ${selectRandomElement(endings)}${p2}`); // endings
|
||||
return message;
|
||||
}
|
||||
|
||||
|
||||
|
||||
// actual command declaration
|
||||
export default definePlugin({
|
||||
name: "UwUifier",
|
||||
description: "Simply uwuify commands",
|
||||
authors: [Devs.echo, Devs.skyevg, Devs.PandaNinjas],
|
||||
dependencies: ["CommandsAPI", "MessageEventsAPI"],
|
||||
settings,
|
||||
|
||||
commands: [
|
||||
{
|
||||
name: "uwuify",
|
||||
description: "uwuifies your messages",
|
||||
options: [RequiredMessageOption],
|
||||
|
||||
execute: opts => ({
|
||||
content: uwuify(findOption(opts, "message", "")),
|
||||
}),
|
||||
},
|
||||
],
|
||||
|
||||
onSend(msg: MessageObject) {
|
||||
// Only run when it's enabled
|
||||
if (settings.store.uwuEveryMessage) {
|
||||
msg.content = uwuify(msg.content);
|
||||
}
|
||||
},
|
||||
|
||||
start() {
|
||||
this.preSend = addPreSendListener((_, msg) => this.onSend(msg));
|
||||
this.preEdit = addPreEditListener((_cid, _mid, msg) =>
|
||||
this.onSend(msg)
|
||||
);
|
||||
},
|
||||
|
||||
stop() {
|
||||
removePreSendListener(this.preSend);
|
||||
removePreEditListener(this.preEdit);
|
||||
},
|
||||
});
|
@ -75,7 +75,7 @@ function MentionWrapper({ data, UserMention, RoleMention, parse, props }: Mentio
|
||||
const mention = children?.[0]?.props?.children;
|
||||
if (typeof mention !== "string") return;
|
||||
|
||||
const id = mention.match(/<@(\d+)>/)?.[1];
|
||||
const id = mention.match(/<@!?(\d+)>/)?.[1];
|
||||
if (!id) return;
|
||||
|
||||
if (fetching.has(id))
|
||||
|
@ -156,6 +156,8 @@ export default definePlugin({
|
||||
const myChanId = SelectedChannelStore.getVoiceChannelId();
|
||||
const myId = UserStore.getCurrentUser().id;
|
||||
|
||||
if (ChannelStore.getChannel(myChanId!)?.type === 13 /* Stage Channel */) return;
|
||||
|
||||
for (const state of voiceStates) {
|
||||
const { userId, channelId, oldChannelId } = state;
|
||||
const isMe = userId === myId;
|
||||
|
@ -1,3 +1,8 @@
|
||||
.vc-toolbox-btn,
|
||||
.vc-toolbox-btn svg {
|
||||
-webkit-app-region: no-drag;
|
||||
}
|
||||
|
||||
.vc-toolbox-btn svg {
|
||||
color: var(--interactive-normal);
|
||||
}
|
||||
|
@ -35,7 +35,7 @@ interface UserContextProps {
|
||||
}
|
||||
|
||||
interface GuildContextProps {
|
||||
guild: Guild;
|
||||
guild?: Guild;
|
||||
}
|
||||
|
||||
const settings = definePluginSettings({
|
||||
@ -100,7 +100,8 @@ const UserContext: NavContextMenuPatchCallback = (children, { user, guildId }: U
|
||||
action={() => openImage(BannerStore.getGuildMemberAvatarURLSimple({
|
||||
userId: user.id,
|
||||
avatar: memberAvatar,
|
||||
guildId
|
||||
guildId,
|
||||
canAnimate: true
|
||||
}, true))}
|
||||
icon={ImageIcon}
|
||||
/>
|
||||
@ -109,7 +110,10 @@ const UserContext: NavContextMenuPatchCallback = (children, { user, guildId }: U
|
||||
));
|
||||
};
|
||||
|
||||
const GuildContext: NavContextMenuPatchCallback = (children, { guild: { id, icon, banner } }: GuildContextProps) => () => {
|
||||
const GuildContext: NavContextMenuPatchCallback = (children, { guild }: GuildContextProps) => () => {
|
||||
if(!guild) return;
|
||||
|
||||
const { id, icon, banner } = guild;
|
||||
if (!banner && !icon) return;
|
||||
|
||||
children.splice(-1, 0, (
|
||||
@ -181,8 +185,8 @@ export default definePlugin({
|
||||
// style: { backgroundImage: shouldShowBanner ? "url(".concat(bannerUrl,
|
||||
match: /style:\{(?=backgroundImage:(\i&&\i)\?"url\("\.concat\((\i),)/,
|
||||
replace:
|
||||
// onClick: () => shouldShowBanner && openImage(bannerUrl), style: { cursor: shouldShowBanner ? "pointer" : void 0,
|
||||
'onClick:()=>$1&&$self.openImage($2),style:{cursor:$1?"pointer":void 0,'
|
||||
// onClick: () => shouldShowBanner && ev.target.style.backgroundImage && openImage(bannerUrl), style: { cursor: shouldShowBanner ? "pointer" : void 0,
|
||||
'onClick:ev=>$1&&ev.target.style.backgroundImage&&$self.openImage($2),style:{cursor:$1?"pointer":void 0,'
|
||||
}
|
||||
},
|
||||
{
|
||||
|
68
src/plugins/voiceMessages/DesktopRecorder.tsx
Normal file
68
src/plugins/voiceMessages/DesktopRecorder.tsx
Normal file
@ -0,0 +1,68 @@
|
||||
/*
|
||||
* 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 { Button, showToast, Toasts, useState } from "@webpack/common";
|
||||
|
||||
import type { VoiceRecorder } from ".";
|
||||
import { settings } from "./settings";
|
||||
|
||||
export const VoiceRecorderDesktop: VoiceRecorder = ({ setAudioBlob, onRecordingChange }) => {
|
||||
const [recording, setRecording] = useState(false);
|
||||
|
||||
const changeRecording = (recording: boolean) => {
|
||||
setRecording(recording);
|
||||
onRecordingChange?.(recording);
|
||||
};
|
||||
|
||||
function toggleRecording() {
|
||||
const discordVoice = DiscordNative.nativeModules.requireModule("discord_voice");
|
||||
const nowRecording = !recording;
|
||||
|
||||
if (nowRecording) {
|
||||
discordVoice.startLocalAudioRecording(
|
||||
{
|
||||
echoCancellation: settings.store.echoCancellation,
|
||||
noiseCancellation: settings.store.noiseSuppression,
|
||||
},
|
||||
(success: boolean) => {
|
||||
if (success)
|
||||
changeRecording(true);
|
||||
else
|
||||
showToast("Failed to start recording", Toasts.Type.FAILURE);
|
||||
}
|
||||
);
|
||||
} else {
|
||||
discordVoice.stopLocalAudioRecording(async (filePath: string) => {
|
||||
if (filePath) {
|
||||
const buf = await VencordNative.pluginHelpers.VoiceMessages.readRecording();
|
||||
if (buf)
|
||||
setAudioBlob(new Blob([buf], { type: "audio/ogg; codecs=opus" }));
|
||||
else
|
||||
showToast("Failed to finish recording", Toasts.Type.FAILURE);
|
||||
}
|
||||
changeRecording(false);
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<Button onClick={toggleRecording}>
|
||||
{recording ? "Stop" : "Start"} recording
|
||||
</Button>
|
||||
);
|
||||
};
|
57
src/plugins/voiceMessages/VoicePreview.tsx
Normal file
57
src/plugins/voiceMessages/VoicePreview.tsx
Normal file
@ -0,0 +1,57 @@
|
||||
/*
|
||||
* 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 { LazyComponent, useTimer } from "@utils/react";
|
||||
import { findByCode } from "@webpack";
|
||||
|
||||
import { cl } from "./utils";
|
||||
|
||||
interface VoiceMessageProps {
|
||||
src: string;
|
||||
waveform: string;
|
||||
}
|
||||
const VoiceMessage = LazyComponent<VoiceMessageProps>(() => findByCode('["onVolumeChange","volume","onMute"]'));
|
||||
|
||||
export type VoicePreviewOptions = {
|
||||
src?: string;
|
||||
waveform: string;
|
||||
recording?: boolean;
|
||||
};
|
||||
export const VoicePreview = ({
|
||||
src,
|
||||
waveform,
|
||||
recording,
|
||||
}: VoicePreviewOptions) => {
|
||||
const durationMs = useTimer({
|
||||
deps: [recording]
|
||||
});
|
||||
|
||||
const durationSeconds = recording ? Math.floor(durationMs / 1000) : 0;
|
||||
const durationDisplay = Math.floor(durationSeconds / 60) + ":" + (durationSeconds % 60).toString().padStart(2, "0");
|
||||
|
||||
if (src && !recording)
|
||||
return <VoiceMessage key={src} src={src} waveform={waveform} />;
|
||||
|
||||
return (
|
||||
<div className={cl("preview", recording ? "preview-recording" : [])}>
|
||||
<div className={cl("preview-indicator")} />
|
||||
<div className={cl("preview-time")}>{durationDisplay}</div>
|
||||
<div className={cl("preview-label")}>{recording ? "RECORDING" : "----"}</div>
|
||||
</div>
|
||||
);
|
||||
};
|
87
src/plugins/voiceMessages/WebRecorder.tsx
Normal file
87
src/plugins/voiceMessages/WebRecorder.tsx
Normal file
@ -0,0 +1,87 @@
|
||||
/*
|
||||
* 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 { Button, useState } from "@webpack/common";
|
||||
|
||||
import type { VoiceRecorder } from ".";
|
||||
import { settings } from "./settings";
|
||||
|
||||
export const VoiceRecorderWeb: VoiceRecorder = ({ setAudioBlob, onRecordingChange }) => {
|
||||
const [recording, setRecording] = useState(false);
|
||||
const [paused, setPaused] = useState(false);
|
||||
const [recorder, setRecorder] = useState<MediaRecorder>();
|
||||
const [chunks, setChunks] = useState<Blob[]>([]);
|
||||
|
||||
const changeRecording = (recording: boolean) => {
|
||||
setRecording(recording);
|
||||
onRecordingChange?.(recording);
|
||||
};
|
||||
|
||||
function toggleRecording() {
|
||||
const nowRecording = !recording;
|
||||
|
||||
if (nowRecording) {
|
||||
navigator.mediaDevices.getUserMedia({
|
||||
audio: {
|
||||
echoCancellation: settings.store.echoCancellation,
|
||||
noiseSuppression: settings.store.noiseSuppression,
|
||||
}
|
||||
}).then(stream => {
|
||||
const chunks = [] as Blob[];
|
||||
setChunks(chunks);
|
||||
|
||||
const recorder = new MediaRecorder(stream);
|
||||
setRecorder(recorder);
|
||||
recorder.addEventListener("dataavailable", e => {
|
||||
chunks.push(e.data);
|
||||
});
|
||||
recorder.start();
|
||||
|
||||
changeRecording(true);
|
||||
});
|
||||
} else {
|
||||
if (recorder) {
|
||||
recorder.addEventListener("stop", () => {
|
||||
setAudioBlob(new Blob(chunks, { type: "audio/ogg; codecs=opus" }));
|
||||
|
||||
changeRecording(false);
|
||||
});
|
||||
recorder.stop();
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<>
|
||||
<Button onClick={toggleRecording}>
|
||||
{recording ? "Stop" : "Start"} recording
|
||||
</Button>
|
||||
|
||||
<Button
|
||||
disabled={!recording}
|
||||
onClick={() => {
|
||||
setPaused(!paused);
|
||||
if (paused) recorder?.resume();
|
||||
else recorder?.pause();
|
||||
}}
|
||||
>
|
||||
{paused ? "Resume" : "Pause"} recording
|
||||
</Button>
|
||||
</>
|
||||
);
|
||||
};
|
235
src/plugins/voiceMessages/index.tsx
Normal file
235
src/plugins/voiceMessages/index.tsx
Normal file
@ -0,0 +1,235 @@
|
||||
/*
|
||||
* 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 { Flex } from "@components/Flex";
|
||||
import { Microphone } from "@components/Icons";
|
||||
import { Devs } from "@utils/constants";
|
||||
import { ModalContent, ModalFooter, ModalHeader, ModalProps, ModalRoot, openModal } from "@utils/modal";
|
||||
import { useAwaiter } from "@utils/react";
|
||||
import definePlugin from "@utils/types";
|
||||
import { chooseFile } from "@utils/web";
|
||||
import { findLazy } from "@webpack";
|
||||
import { Button, Forms, Menu, PermissionsBits, PermissionStore, RestAPI, SelectedChannelStore, showToast, SnowflakeUtils, Toasts, useEffect, useState } from "@webpack/common";
|
||||
import { ComponentType } from "react";
|
||||
|
||||
import { VoiceRecorderDesktop } from "./DesktopRecorder";
|
||||
import { settings } from "./settings";
|
||||
import { cl } from "./utils";
|
||||
import { VoicePreview } from "./VoicePreview";
|
||||
import { VoiceRecorderWeb } from "./WebRecorder";
|
||||
|
||||
const CloudUpload = findLazy(m => m.prototype?.uploadFileToCloud);
|
||||
|
||||
export type VoiceRecorder = ComponentType<{
|
||||
setAudioBlob(blob: Blob): void;
|
||||
onRecordingChange?(recording: boolean): void;
|
||||
}>;
|
||||
|
||||
const VoiceRecorder = IS_DISCORD_DESKTOP ? VoiceRecorderDesktop : VoiceRecorderWeb;
|
||||
|
||||
export default definePlugin({
|
||||
name: "VoiceMessages",
|
||||
description: "Allows you to send voice messages like on mobile. To do so, right click the upload button and click Send Voice Message",
|
||||
authors: [Devs.Ven, Devs.Vap],
|
||||
settings,
|
||||
|
||||
start() {
|
||||
addContextMenuPatch("channel-attach", ctxMenuPatch);
|
||||
},
|
||||
|
||||
stop() {
|
||||
removeContextMenuPatch("channel-attach", ctxMenuPatch);
|
||||
}
|
||||
});
|
||||
|
||||
type AudioMetadata = {
|
||||
waveform: string,
|
||||
duration: number,
|
||||
};
|
||||
const EMPTY_META: AudioMetadata = {
|
||||
waveform: "AAAAAAAAAAAA",
|
||||
duration: 1,
|
||||
};
|
||||
|
||||
function sendAudio(blob: Blob, meta: AudioMetadata) {
|
||||
const channelId = SelectedChannelStore.getChannelId();
|
||||
|
||||
const upload = new CloudUpload({
|
||||
file: new File([blob], "voice-message.ogg", { type: "audio/ogg; codecs=opus" }),
|
||||
isClip: false,
|
||||
isThumbnail: false,
|
||||
platform: 1,
|
||||
}, channelId, false, 0);
|
||||
|
||||
upload.on("complete", () => {
|
||||
RestAPI.post({
|
||||
url: `/channels/${channelId}/messages`,
|
||||
body: {
|
||||
flags: 1 << 13,
|
||||
channel_id: channelId,
|
||||
content: "",
|
||||
nonce: SnowflakeUtils.fromTimestamp(Date.now()),
|
||||
sticker_ids: [],
|
||||
type: 0,
|
||||
attachments: [{
|
||||
id: "0",
|
||||
filename: upload.filename,
|
||||
uploaded_filename: upload.uploadedFilename,
|
||||
waveform: meta.waveform,
|
||||
duration_secs: meta.duration,
|
||||
}]
|
||||
}
|
||||
});
|
||||
});
|
||||
upload.on("error", () => showToast("Failed to upload voice message", Toasts.Type.FAILURE));
|
||||
|
||||
upload.upload();
|
||||
}
|
||||
|
||||
function useObjectUrl() {
|
||||
const [url, setUrl] = useState<string>();
|
||||
const setWithFree = (blob: Blob) => {
|
||||
if (url)
|
||||
URL.revokeObjectURL(url);
|
||||
setUrl(URL.createObjectURL(blob));
|
||||
};
|
||||
|
||||
return [url, setWithFree] as const;
|
||||
}
|
||||
|
||||
function Modal({ modalProps }: { modalProps: ModalProps; }) {
|
||||
const [isRecording, setRecording] = useState(false);
|
||||
const [blob, setBlob] = useState<Blob>();
|
||||
const [blobUrl, setBlobUrl] = useObjectUrl();
|
||||
|
||||
useEffect(() => () => {
|
||||
if (blobUrl)
|
||||
URL.revokeObjectURL(blobUrl);
|
||||
}, [blobUrl]);
|
||||
|
||||
const [meta] = useAwaiter(async () => {
|
||||
if (!blob) return EMPTY_META;
|
||||
|
||||
const audioContext = new AudioContext();
|
||||
const audioBuffer = await audioContext.decodeAudioData(await blob.arrayBuffer());
|
||||
const channelData = audioBuffer.getChannelData(0);
|
||||
|
||||
// average the samples into much lower resolution bins, maximum of 256 total bins
|
||||
const bins = new Uint8Array(window._.clamp(Math.floor(audioBuffer.duration * 10), Math.min(32, channelData.length), 256));
|
||||
const samplesPerBin = Math.floor(channelData.length / bins.length);
|
||||
|
||||
// Get root mean square of each bin
|
||||
for (let binIdx = 0; binIdx < bins.length; binIdx++) {
|
||||
let squares = 0;
|
||||
for (let sampleOffset = 0; sampleOffset < samplesPerBin; sampleOffset++) {
|
||||
const sampleIdx = binIdx * samplesPerBin + sampleOffset;
|
||||
squares += channelData[sampleIdx] ** 2;
|
||||
}
|
||||
bins[binIdx] = ~~(Math.sqrt(squares / samplesPerBin) * 0xFF);
|
||||
}
|
||||
|
||||
// Normalize bins with easing
|
||||
const maxBin = Math.max(...bins);
|
||||
const ratio = 1 + (0xFF / maxBin - 1) * Math.min(1, 100 * (maxBin / 0xFF) ** 3);
|
||||
for (let i = 0; i < bins.length; i++) bins[i] = Math.min(0xFF, ~~(bins[i] * ratio));
|
||||
|
||||
return {
|
||||
waveform: window.btoa(String.fromCharCode(...bins)),
|
||||
duration: audioBuffer.duration,
|
||||
};
|
||||
}, {
|
||||
deps: [blob],
|
||||
fallbackValue: EMPTY_META,
|
||||
});
|
||||
|
||||
return (
|
||||
<ModalRoot {...modalProps}>
|
||||
<ModalHeader>
|
||||
<Forms.FormTitle>Record Voice Message</Forms.FormTitle>
|
||||
</ModalHeader>
|
||||
|
||||
<ModalContent className={cl("modal")}>
|
||||
<div className={cl("buttons")}>
|
||||
<VoiceRecorder
|
||||
setAudioBlob={blob => {
|
||||
setBlob(blob);
|
||||
setBlobUrl(blob);
|
||||
}}
|
||||
onRecordingChange={setRecording}
|
||||
/>
|
||||
|
||||
<Button
|
||||
onClick={async () => {
|
||||
const file = await chooseFile("audio/*");
|
||||
if (file) {
|
||||
setBlob(file);
|
||||
setBlobUrl(file);
|
||||
}
|
||||
}}
|
||||
>
|
||||
Upload File
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
<Forms.FormTitle>Preview</Forms.FormTitle>
|
||||
<VoicePreview
|
||||
src={blobUrl}
|
||||
waveform={meta.waveform}
|
||||
recording={isRecording}
|
||||
/>
|
||||
|
||||
</ModalContent>
|
||||
|
||||
<ModalFooter>
|
||||
<Button
|
||||
disabled={!blob}
|
||||
onClick={() => {
|
||||
sendAudio(blob!, meta);
|
||||
modalProps.onClose();
|
||||
showToast("Now sending voice message... Please be patient", Toasts.Type.MESSAGE);
|
||||
}}
|
||||
>
|
||||
Send
|
||||
</Button>
|
||||
</ModalFooter>
|
||||
</ModalRoot>
|
||||
);
|
||||
}
|
||||
|
||||
const ctxMenuPatch: NavContextMenuPatchCallback = (children, props) => () => {
|
||||
if (props.channel.guild_id && !PermissionStore.can(PermissionsBits.SEND_VOICE_MESSAGES, props.channel)) return;
|
||||
|
||||
children.push(
|
||||
<Menu.MenuItem
|
||||
id="vc-send-vmsg"
|
||||
label={
|
||||
<>
|
||||
<Flex flexDirection="row" style={{ alignItems: "center", gap: 8 }}>
|
||||
<Microphone height={24} width={24} />
|
||||
Send voice message
|
||||
</Flex>
|
||||
</>
|
||||
}
|
||||
action={() => openModal(modalProps => <Modal modalProps={modalProps} />)}
|
||||
/>
|
||||
);
|
||||
};
|
||||
|
33
src/plugins/voiceMessages/settings.ts
Normal file
33
src/plugins/voiceMessages/settings.ts
Normal file
@ -0,0 +1,33 @@
|
||||
/*
|
||||
* 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 { OptionType } from "@utils/types";
|
||||
|
||||
export const settings = definePluginSettings({
|
||||
noiseSuppression: {
|
||||
type: OptionType.BOOLEAN,
|
||||
description: "Noise Suppression",
|
||||
default: true,
|
||||
},
|
||||
echoCancellation: {
|
||||
type: OptionType.BOOLEAN,
|
||||
description: "Echo Cancellation",
|
||||
default: true,
|
||||
},
|
||||
});
|
54
src/plugins/voiceMessages/styles.css
Normal file
54
src/plugins/voiceMessages/styles.css
Normal file
@ -0,0 +1,54 @@
|
||||
.vc-vmsg-modal {
|
||||
padding: 1em;
|
||||
}
|
||||
|
||||
.vc-vmsg-buttons {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(3, minmax(0, 1fr));
|
||||
gap: 0.5em;
|
||||
margin-bottom: 1em;
|
||||
}
|
||||
|
||||
.vc-vmsg-modal audio {
|
||||
width: 100%;
|
||||
}
|
||||
|
||||
.vc-vmsg-preview {
|
||||
color: var(--text-normal);
|
||||
border-radius: 24px;
|
||||
background-color: var(--background-secondary);
|
||||
position: relative;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
padding: 0 16px;
|
||||
height: 48px;
|
||||
}
|
||||
|
||||
.vc-vmsg-preview-indicator {
|
||||
background: var(--button-secondary-background);
|
||||
width: 16px;
|
||||
height: 16px;
|
||||
border-radius: 50%;
|
||||
transition: background 0.2s ease-in-out;
|
||||
}
|
||||
|
||||
.vc-vmsg-preview-recording .vc-vmsg-preview-indicator {
|
||||
background: var(--status-danger);
|
||||
}
|
||||
|
||||
.vc-vmsg-preview-time {
|
||||
opacity: 0.8;
|
||||
margin: 0 0.5em;
|
||||
font-size: 80%;
|
||||
|
||||
/* monospace so different digits have same size */
|
||||
font-family: var(--font-code);
|
||||
}
|
||||
|
||||
.vc-vmsg-preview-label {
|
||||
opacity: 0.5;
|
||||
letter-spacing: 0.125em;
|
||||
font-weight: 600;
|
||||
flex: 1;
|
||||
text-align: center;
|
||||
}
|
@ -16,14 +16,6 @@
|
||||
* along with this program. If not, see <https://www.gnu.org/licenses/>.
|
||||
*/
|
||||
|
||||
import { proxyLazy } from "@utils/lazy";
|
||||
import { classNameFactory } from "@api/Styles";
|
||||
|
||||
const p = proxyLazy<typeof import("plugins/_api/componentUpdater").default>(() => Vencord.Plugins.plugins.ComponentUpdaterAPI as any);
|
||||
|
||||
/**
|
||||
* Rerender a specific message
|
||||
* @param messageId The id of the message to rerender
|
||||
*/
|
||||
export function updateMessageComponent(messageId: string) {
|
||||
p.forceUpdaters.get(messageId)?.();
|
||||
}
|
||||
export const cl = classNameFactory("vc-vmsg-");
|
@ -47,9 +47,10 @@ const settings = definePluginSettings({
|
||||
|
||||
export default definePlugin({
|
||||
name: "WebContextMenus",
|
||||
description: "Re-adds context menus missing in the web version of Discord: Images, ChatInputBar, Links, 'Copy Link', 'Open Link', 'Copy Image', 'Save Image'",
|
||||
description: "Re-adds context menus missing in the web version of Discord: Links & Images (Copy/Open Link/Image), Text Area (Copy, Cut, Paste, SpellCheck)",
|
||||
authors: [Devs.Ven],
|
||||
enabledByDefault: true,
|
||||
required: IS_VENCORD_DESKTOP,
|
||||
|
||||
settings,
|
||||
|
||||
@ -146,36 +147,30 @@ export default definePlugin({
|
||||
}
|
||||
},
|
||||
{
|
||||
find: ':"command-suggestions"',
|
||||
find: 'navId:"textarea-context"',
|
||||
all: true,
|
||||
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 null;
|
||||
match: /if\(!\i\.\i\)return null;/,
|
||||
replace: ""
|
||||
},
|
||||
{
|
||||
// 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"
|
||||
}
|
||||
]
|
||||
},
|
||||
{
|
||||
find: '"add-to-dictionary"',
|
||||
predicate: () => settings.store.addBack,
|
||||
replacement: {
|
||||
match: /var \i=\i\.text,/,
|
||||
replace: "return [null,null];$&"
|
||||
}
|
||||
}
|
||||
|
||||
// TODO: Maybe add spellcheck for VencordDesktop
|
||||
],
|
||||
|
||||
async copyImage(url: string) {
|
||||
|
79
src/plugins/webKeybinds.vencordDesktop.ts
Normal file
79
src/plugins/webKeybinds.vencordDesktop.ts
Normal file
@ -0,0 +1,79 @@
|
||||
/*
|
||||
* 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 { Devs } from "@utils/constants";
|
||||
import definePlugin from "@utils/types";
|
||||
import { findLazy, mapMangledModuleLazy } from "@webpack";
|
||||
import { ComponentDispatch, FluxDispatcher, NavigationRouter, SelectedGuildStore, SettingsRouter } from "@webpack/common";
|
||||
|
||||
const GuildNavBinds = mapMangledModuleLazy("mod+alt+down", {
|
||||
CtrlTab: m => m.binds?.at(-1) === "ctrl+tab",
|
||||
CtrlShiftTab: m => m.binds?.at(-1) === "ctrl+shift+tab",
|
||||
});
|
||||
|
||||
const DigitBinds = findLazy(m => m.binds?.[0] === "mod+1");
|
||||
|
||||
export default definePlugin({
|
||||
name: "WebKeybinds",
|
||||
description: "Re-adds keybinds missing in the web version of Discord: ctrl+t, ctrl+shift+t, ctrl+tab, ctrl+shift+tab, ctrl+1-9, ctrl+,",
|
||||
authors: [Devs.Ven],
|
||||
enabledByDefault: true,
|
||||
|
||||
onKey(e: KeyboardEvent) {
|
||||
const hasCtrl = e.ctrlKey || (e.metaKey && navigator.platform.includes("Mac"));
|
||||
|
||||
if (hasCtrl) switch (e.key) {
|
||||
case "t":
|
||||
case "T":
|
||||
e.preventDefault();
|
||||
if (e.shiftKey) {
|
||||
if (SelectedGuildStore.getGuildId()) NavigationRouter.transitionToGuild("@me");
|
||||
ComponentDispatch.safeDispatch("TOGGLE_DM_CREATE");
|
||||
} else {
|
||||
FluxDispatcher.dispatch({
|
||||
type: "QUICKSWITCHER_SHOW",
|
||||
query: "",
|
||||
queryMode: null
|
||||
});
|
||||
}
|
||||
break;
|
||||
case ",":
|
||||
e.preventDefault();
|
||||
SettingsRouter.open("My Account");
|
||||
break;
|
||||
case "Tab":
|
||||
const handler = e.shiftKey ? GuildNavBinds.CtrlShiftTab : GuildNavBinds.CtrlTab;
|
||||
handler.action(e);
|
||||
break;
|
||||
default:
|
||||
if (e.key >= "1" && e.key <= "9") {
|
||||
e.preventDefault();
|
||||
DigitBinds.action(e, `mod+${e.key}`);
|
||||
}
|
||||
break;
|
||||
}
|
||||
},
|
||||
|
||||
start() {
|
||||
document.addEventListener("keydown", this.onKey);
|
||||
},
|
||||
|
||||
stop() {
|
||||
document.removeEventListener("keydown", this.onKey);
|
||||
}
|
||||
});
|
@ -30,4 +30,7 @@ export const enum IpcEvents {
|
||||
UPDATE = "VencordUpdate",
|
||||
BUILD = "VencordBuild",
|
||||
OPEN_MONACO_EDITOR = "VencordOpenMonacoEditor",
|
||||
|
||||
OPEN_IN_APP__RESOLVE_REDIRECT = "VencordOIAResolveRedirect",
|
||||
VOICE_MESSAGES_READ_RECORDING = "VencordVMReadRecording",
|
||||
}
|
||||
|
@ -28,15 +28,39 @@ import { openModal } from "./modal";
|
||||
export const cloudLogger = new Logger("Cloud", "#39b7e0");
|
||||
export const getCloudUrl = () => new URL(Settings.cloud.url);
|
||||
|
||||
const cloudUrlOrigin = () => getCloudUrl().origin;
|
||||
const getUserId = () => {
|
||||
const id = UserStore.getCurrentUser()?.id;
|
||||
if (!id) throw new Error("User not yet logged in");
|
||||
return id;
|
||||
};
|
||||
|
||||
export async function getAuthorization() {
|
||||
const secrets = await DataStore.get<Record<string, string>>("Vencord_cloudSecret") ?? {};
|
||||
return secrets[getCloudUrl().origin];
|
||||
|
||||
const origin = cloudUrlOrigin();
|
||||
|
||||
// we need to migrate from the old format here
|
||||
if (secrets[origin]) {
|
||||
await DataStore.update<Record<string, string>>("Vencord_cloudSecret", secrets => {
|
||||
secrets ??= {};
|
||||
// use the current user ID
|
||||
secrets[`${origin}:${getUserId()}`] = secrets[origin];
|
||||
delete secrets[origin];
|
||||
return secrets;
|
||||
});
|
||||
|
||||
// since this doesn't update the original object, we'll early return the existing authorization
|
||||
return secrets[origin];
|
||||
}
|
||||
|
||||
return secrets[`${origin}:${getUserId()}`];
|
||||
}
|
||||
|
||||
async function setAuthorization(secret: string) {
|
||||
await DataStore.update<Record<string, string>>("Vencord_cloudSecret", secrets => {
|
||||
secrets ??= {};
|
||||
secrets[getCloudUrl().origin] = secret;
|
||||
secrets[`${cloudUrlOrigin()}:${getUserId()}`] = secret;
|
||||
return secrets;
|
||||
});
|
||||
}
|
||||
@ -44,7 +68,7 @@ async function setAuthorization(secret: string) {
|
||||
export async function deauthorizeCloud() {
|
||||
await DataStore.update<Record<string, string>>("Vencord_cloudSecret", secrets => {
|
||||
secrets ??= {};
|
||||
delete secrets[getCloudUrl().origin];
|
||||
delete secrets[`${cloudUrlOrigin()}:${getUserId()}`];
|
||||
return secrets;
|
||||
});
|
||||
}
|
||||
@ -117,8 +141,7 @@ export async function authorizeCloud() {
|
||||
}
|
||||
|
||||
export async function getCloudAuth() {
|
||||
const userId = UserStore.getCurrentUser().id;
|
||||
const secret = await getAuthorization();
|
||||
|
||||
return window.btoa(`${secret}:${userId}`);
|
||||
return window.btoa(`${secret}:${getUserId()}`);
|
||||
}
|
||||
|
@ -265,7 +265,7 @@ export const Devs = /* #__PURE__*/ Object.freeze({
|
||||
},
|
||||
Dziurwa: {
|
||||
name: "Dziurwa",
|
||||
id: 787017887877169173n
|
||||
id: 1034579679526526976n
|
||||
},
|
||||
AutumnVN: {
|
||||
name: "AutumnVN",
|
||||
@ -329,7 +329,7 @@ export const Devs = /* #__PURE__*/ Object.freeze({
|
||||
},
|
||||
rad: {
|
||||
name: "rad",
|
||||
id: 113027285765885952n
|
||||
id: 610945092504780823n
|
||||
},
|
||||
HypedDomi: {
|
||||
name: "HypedDomi",
|
||||
|
@ -27,8 +27,8 @@ const unconfigurable = ["arguments", "caller", "prototype"];
|
||||
|
||||
const handler: ProxyHandler<any> = {};
|
||||
|
||||
const GET_KEY = Symbol.for("vencord.lazy.get");
|
||||
const CACHED_KEY = Symbol.for("vencord.lazy.cached");
|
||||
const kGET = Symbol.for("vencord.lazy.get");
|
||||
const kCACHE = Symbol.for("vencord.lazy.cached");
|
||||
|
||||
for (const method of [
|
||||
"apply",
|
||||
@ -46,11 +46,11 @@ for (const method of [
|
||||
"setPrototypeOf"
|
||||
]) {
|
||||
handler[method] =
|
||||
(target: any, ...args: any[]) => Reflect[method](target[GET_KEY](), ...args);
|
||||
(target: any, ...args: any[]) => Reflect[method](target[kGET](), ...args);
|
||||
}
|
||||
|
||||
handler.ownKeys = target => {
|
||||
const v = target[GET_KEY]();
|
||||
const v = target[kGET]();
|
||||
const keys = Reflect.ownKeys(v);
|
||||
for (const key of unconfigurable) {
|
||||
if (!keys.includes(key)) keys.push(key);
|
||||
@ -62,7 +62,7 @@ handler.getOwnPropertyDescriptor = (target, p) => {
|
||||
if (typeof p === "string" && unconfigurable.includes(p))
|
||||
return Reflect.getOwnPropertyDescriptor(target, p);
|
||||
|
||||
const descriptor = Reflect.getOwnPropertyDescriptor(target[GET_KEY](), p);
|
||||
const descriptor = Reflect.getOwnPropertyDescriptor(target[kGET](), p);
|
||||
|
||||
if (descriptor) Object.defineProperty(target, p, descriptor);
|
||||
return descriptor;
|
||||
@ -72,15 +72,22 @@ handler.getOwnPropertyDescriptor = (target, p) => {
|
||||
* Wraps the result of {@see makeLazy} in a Proxy you can consume as if it wasn't lazy.
|
||||
* On first property access, the lazy is evaluated
|
||||
* @param factory lazy factory
|
||||
* @param attempts how many times to try to evaluate the lazy before giving up
|
||||
* @returns Proxy
|
||||
*
|
||||
* Note that the example below exists already as an api, see {@link findByPropsLazy}
|
||||
* @example const mod = proxyLazy(() => findByProps("blah")); console.log(mod.blah);
|
||||
*/
|
||||
export function proxyLazy<T>(factory: () => T): T {
|
||||
const proxyDummy: { (): void;[CACHED_KEY]?: T;[GET_KEY](): T; } = Object.assign(function () { }, {
|
||||
[CACHED_KEY]: void 0,
|
||||
[GET_KEY]: () => proxyDummy[CACHED_KEY] ??= factory(),
|
||||
export function proxyLazy<T>(factory: () => T, attempts = 5): T {
|
||||
let tries = 0;
|
||||
const proxyDummy = Object.assign(function () { }, {
|
||||
[kCACHE]: void 0 as T | undefined,
|
||||
[kGET]() {
|
||||
if (!proxyDummy[kCACHE] && attempts > tries++) {
|
||||
proxyDummy[kCACHE] = factory();
|
||||
}
|
||||
return proxyDummy[kCACHE];
|
||||
}
|
||||
});
|
||||
|
||||
return new Proxy(proxyDummy, handler) as any;
|
||||
|
@ -27,8 +27,12 @@ export async function toggle(isEnabled: boolean) {
|
||||
if (isEnabled) {
|
||||
style = document.createElement("style");
|
||||
style.id = "vencord-custom-css";
|
||||
document.head.appendChild(style);
|
||||
VencordNative.quickCss.addChangeListener(css => style.textContent = css);
|
||||
document.documentElement.appendChild(style);
|
||||
VencordNative.quickCss.addChangeListener(css => {
|
||||
style.textContent = css;
|
||||
// At the time of writing this, changing textContent resets the disabled state
|
||||
style.disabled = !Settings.useQuickCss;
|
||||
});
|
||||
style.textContent = await VencordNative.quickCss.get();
|
||||
}
|
||||
} else
|
||||
@ -39,7 +43,7 @@ async function initThemes() {
|
||||
if (!themesStyle) {
|
||||
themesStyle = document.createElement("style");
|
||||
themesStyle.id = "vencord-themes";
|
||||
document.head.appendChild(themesStyle);
|
||||
document.documentElement.appendChild(themesStyle);
|
||||
}
|
||||
|
||||
const { themeLinks } = Settings;
|
||||
|
@ -16,7 +16,7 @@
|
||||
* along with this program. If not, see <https://www.gnu.org/licenses/>.
|
||||
*/
|
||||
|
||||
import { React, useEffect, useReducer, useState } from "@webpack/common";
|
||||
import { React, useEffect, useMemo, useReducer, useState } from "@webpack/common";
|
||||
|
||||
import { makeLazy } from "./lazy";
|
||||
import { checkIntersecting } from "./misc";
|
||||
@ -135,3 +135,24 @@ export function LazyComponent<T extends object = any>(factory: () => React.Compo
|
||||
return <Component {...props} />;
|
||||
};
|
||||
}
|
||||
|
||||
interface TimerOpts {
|
||||
interval?: number;
|
||||
deps?: unknown[];
|
||||
}
|
||||
|
||||
export function useTimer({ interval = 1000, deps = [] }: TimerOpts) {
|
||||
const [time, setTime] = useState(0);
|
||||
const start = useMemo(() => Date.now(), deps);
|
||||
|
||||
useEffect(() => {
|
||||
const intervalId = setInterval(() => setTime(Date.now() - start), interval);
|
||||
|
||||
return () => {
|
||||
setTime(0);
|
||||
clearInterval(intervalId);
|
||||
};
|
||||
}, deps);
|
||||
|
||||
return time;
|
||||
}
|
||||
|
@ -23,7 +23,7 @@ import { deflateSync, inflateSync } from "fflate";
|
||||
|
||||
import { getCloudAuth, getCloudUrl } from "./cloud";
|
||||
import { Logger } from "./Logger";
|
||||
import { saveFile } from "./web";
|
||||
import { chooseFile, saveFile } from "./web";
|
||||
|
||||
export async function importSettings(data: string) {
|
||||
try {
|
||||
@ -41,10 +41,10 @@ export async function importSettings(data: string) {
|
||||
throw new Error("Invalid Settings. Is this even a Vencord Settings file?");
|
||||
}
|
||||
|
||||
export async function exportSettings() {
|
||||
export async function exportSettings({ minify }: { minify?: boolean; } = {}) {
|
||||
const settings = JSON.parse(VencordNative.settings.get());
|
||||
const quickCss = await VencordNative.quickCss.get();
|
||||
return JSON.stringify({ settings, quickCss }, null, 4);
|
||||
return JSON.stringify({ settings, quickCss }, null, minify ? undefined : 4);
|
||||
}
|
||||
|
||||
export async function downloadSettingsBackup() {
|
||||
@ -91,12 +91,7 @@ export async function uploadSettingsBackup(showToast = true): Promise<void> {
|
||||
}
|
||||
}
|
||||
} else {
|
||||
const input = document.createElement("input");
|
||||
input.type = "file";
|
||||
input.style.display = "none";
|
||||
input.accept = "application/json";
|
||||
input.onchange = async () => {
|
||||
const file = input.files?.[0];
|
||||
const file = await chooseFile("application/json");
|
||||
if (!file) return;
|
||||
|
||||
const reader = new FileReader();
|
||||
@ -110,19 +105,14 @@ export async function uploadSettingsBackup(showToast = true): Promise<void> {
|
||||
}
|
||||
};
|
||||
reader.readAsText(file);
|
||||
};
|
||||
|
||||
document.body.appendChild(input);
|
||||
input.click();
|
||||
setImmediate(() => document.body.removeChild(input));
|
||||
}
|
||||
}
|
||||
|
||||
// Cloud settings
|
||||
const cloudSettingsLogger = new Logger("Cloud:Settings", "#39b7e0");
|
||||
|
||||
export async function putCloudSettings() {
|
||||
const settings = await exportSettings();
|
||||
export async function putCloudSettings(manual?: boolean) {
|
||||
const settings = await exportSettings({ minify: true });
|
||||
|
||||
try {
|
||||
const res = await fetch(new URL("/v1/settings", getCloudUrl()), {
|
||||
@ -149,6 +139,14 @@ export async function putCloudSettings() {
|
||||
VencordNative.settings.set(JSON.stringify(PlainSettings, null, 4));
|
||||
|
||||
cloudSettingsLogger.info("Settings uploaded to cloud successfully");
|
||||
|
||||
if (manual) {
|
||||
showNotification({
|
||||
title: "Cloud Settings",
|
||||
body: "Synchronized settings to the cloud!",
|
||||
noPersist: true,
|
||||
});
|
||||
}
|
||||
} catch (e: any) {
|
||||
cloudSettingsLogger.error("Failed to sync up", e);
|
||||
showNotification({
|
||||
|
@ -16,6 +16,10 @@
|
||||
* along with this program. If not, see <https://www.gnu.org/licenses/>.
|
||||
*/
|
||||
|
||||
/**
|
||||
* Prompts the user to save a file to their system
|
||||
* @param file The file to save
|
||||
*/
|
||||
export function saveFile(file: File) {
|
||||
const a = document.createElement("a");
|
||||
a.href = URL.createObjectURL(file);
|
||||
@ -28,3 +32,24 @@ export function saveFile(file: File) {
|
||||
document.body.removeChild(a);
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Prompts the user to choose a file from their system
|
||||
* @param mimeTypes A comma separated list of mime types to accept, see https://developer.mozilla.org/en-US/docs/Web/HTML/Attributes/accept#unique_file_type_specifiers
|
||||
* @returns A promise that resolves to the chosen file or null if the user cancels
|
||||
*/
|
||||
export function chooseFile(mimeTypes: string) {
|
||||
return new Promise<File | null>(resolve => {
|
||||
const input = document.createElement("input");
|
||||
input.type = "file";
|
||||
input.style.display = "none";
|
||||
input.accept = mimeTypes;
|
||||
input.onchange = async () => {
|
||||
resolve(input.files?.[0] ?? null);
|
||||
};
|
||||
|
||||
document.body.appendChild(input);
|
||||
input.click();
|
||||
setImmediate(() => document.body.removeChild(input));
|
||||
});
|
||||
}
|
||||
|
7
src/webpack/common/types/utils.d.ts
vendored
7
src/webpack/common/types/utils.d.ts
vendored
@ -96,6 +96,7 @@ export type Permissions = "CREATE_INSTANT_INVITE"
|
||||
| "MANAGE_ROLES"
|
||||
| "MANAGE_WEBHOOKS"
|
||||
| "MANAGE_GUILD_EXPRESSIONS"
|
||||
| "CREATE_GUILD_EXPRESSIONS"
|
||||
| "VIEW_AUDIT_LOG"
|
||||
| "VIEW_CHANNEL"
|
||||
| "VIEW_GUILD_ANALYTICS"
|
||||
@ -116,6 +117,7 @@ export type Permissions = "CREATE_INSTANT_INVITE"
|
||||
| "CREATE_PRIVATE_THREADS"
|
||||
| "USE_EXTERNAL_STICKERS"
|
||||
| "SEND_MESSAGES_IN_THREADS"
|
||||
| "SEND_VOICE_MESSAGES"
|
||||
| "CONNECT"
|
||||
| "SPEAK"
|
||||
| "MUTE_MEMBERS"
|
||||
@ -125,8 +127,11 @@ export type Permissions = "CREATE_INSTANT_INVITE"
|
||||
| "PRIORITY_SPEAKER"
|
||||
| "STREAM"
|
||||
| "USE_EMBEDDED_ACTIVITIES"
|
||||
| "USE_SOUNDBOARD"
|
||||
| "USE_EXTERNAL_SOUNDS"
|
||||
| "REQUEST_TO_SPEAK"
|
||||
| "MANAGE_EVENTS";
|
||||
| "MANAGE_EVENTS"
|
||||
| "CREATE_EVENTS";
|
||||
|
||||
export type PermissionsBits = Record<Permissions, bigint>;
|
||||
|
||||
|
@ -77,6 +77,17 @@ export const Toasts = {
|
||||
}
|
||||
};
|
||||
|
||||
/**
|
||||
* Show a simple toast. If you need more options, use Toasts.show manually
|
||||
*/
|
||||
export function showToast(message: string, type = ToastType.MESSAGE) {
|
||||
Toasts.show({
|
||||
id: Toasts.genId(),
|
||||
message,
|
||||
type
|
||||
});
|
||||
}
|
||||
|
||||
export const UserUtils = {
|
||||
fetchUser: findByCodeLazy(".USER(", "getUser") as (id: string) => Promise<User>,
|
||||
};
|
||||
|
Reference in New Issue
Block a user