Compare commits
34 Commits
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 |
@ -1,7 +1,7 @@
|
|||||||
{
|
{
|
||||||
"name": "vencord",
|
"name": "vencord",
|
||||||
"private": "true",
|
"private": "true",
|
||||||
"version": "1.3.2",
|
"version": "1.4.0",
|
||||||
"description": "The cutest Discord client mod",
|
"description": "The cutest Discord client mod",
|
||||||
"homepage": "https://github.com/Vendicated/Vencord#readme",
|
"homepage": "https://github.com/Vendicated/Vencord#readme",
|
||||||
"bugs": {
|
"bugs": {
|
||||||
|
@ -63,5 +63,8 @@ export default {
|
|||||||
OpenInApp: {
|
OpenInApp: {
|
||||||
resolveRedirect: (url: string) => invoke<string>(IpcEvents.OPEN_IN_APP__RESOLVE_REDIRECT, url),
|
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("");
|
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 `""`
|
* @param prefix The prefix to add to each class, defaults to `""`
|
||||||
* @returns A classname generator function
|
* @returns A classname generator function
|
||||||
@ -154,9 +154,9 @@ type ClassNameFactoryArg = string | string[] | Record<string, unknown>;
|
|||||||
export const classNameFactory = (prefix: string = "") => (...args: ClassNameFactoryArg[]) => {
|
export const classNameFactory = (prefix: string = "") => (...args: ClassNameFactoryArg[]) => {
|
||||||
const classNames = new Set<string>();
|
const classNames = new Set<string>();
|
||||||
for (const arg of args) {
|
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 (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(" ");
|
return Array.from(classNames, name => prefix + name).join(" ");
|
||||||
};
|
};
|
||||||
|
@ -190,3 +190,16 @@ export function ImageInvisible(props: IconProps) {
|
|||||||
</Icon>
|
</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/>.
|
* along with this program. If not, see <https://www.gnu.org/licenses/>.
|
||||||
*/
|
*/
|
||||||
|
|
||||||
|
import { wordsFromCamel, wordsToTitle } from "@utils/text";
|
||||||
import { PluginOptionBoolean } from "@utils/types";
|
import { PluginOptionBoolean } from "@utils/types";
|
||||||
import { Forms, React, Select } from "@webpack/common";
|
import { Forms, React, Switch } from "@webpack/common";
|
||||||
|
|
||||||
import { ISettingElementProps } from ".";
|
import { ISettingElementProps } from ".";
|
||||||
|
|
||||||
@ -31,11 +32,6 @@ export function SettingBooleanComponent({ option, pluginSettings, definedSetting
|
|||||||
onError(error !== null);
|
onError(error !== null);
|
||||||
}, [error]);
|
}, [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 {
|
function handleChange(newValue: boolean): void {
|
||||||
const isValid = option.isValid?.call(definedSettings, newValue) ?? true;
|
const isValid = option.isValid?.call(definedSettings, newValue) ?? true;
|
||||||
if (typeof isValid === "string") setError(isValid);
|
if (typeof isValid === "string") setError(isValid);
|
||||||
@ -49,18 +45,17 @@ export function SettingBooleanComponent({ option, pluginSettings, definedSetting
|
|||||||
|
|
||||||
return (
|
return (
|
||||||
<Forms.FormSection>
|
<Forms.FormSection>
|
||||||
<Forms.FormTitle>{option.description}</Forms.FormTitle>
|
<Switch
|
||||||
<Select
|
value={state}
|
||||||
isDisabled={option.disabled?.call(definedSettings) ?? false}
|
onChange={handleChange}
|
||||||
options={options}
|
note={option.description}
|
||||||
placeholder={option.placeholder ?? "Select an option"}
|
disabled={option.disabled?.call(definedSettings) ?? false}
|
||||||
maxVisibleItems={5}
|
|
||||||
closeOnSelect={true}
|
|
||||||
select={handleChange}
|
|
||||||
isSelected={v => v === state}
|
|
||||||
serialize={v => String(v)}
|
|
||||||
{...option.componentProps}
|
{...option.componentProps}
|
||||||
/>
|
hideBorder
|
||||||
|
style={{ marginBottom: "0.5em" }}
|
||||||
|
>
|
||||||
|
{wordsToTitle(wordsFromCamel(id))}
|
||||||
|
</Switch>
|
||||||
{error && <Forms.FormText style={{ color: "var(--text-danger)" }}>{error}</Forms.FormText>}
|
{error && <Forms.FormText style={{ color: "var(--text-danger)" }}>{error}</Forms.FormText>}
|
||||||
</Forms.FormSection>
|
</Forms.FormSection>
|
||||||
);
|
);
|
||||||
|
@ -176,7 +176,8 @@ function PluginCard({ plugin, disabled, onRestartNeeded, onMouseEnter, onMouseLe
|
|||||||
const enum SearchStatus {
|
const enum SearchStatus {
|
||||||
ALL,
|
ALL,
|
||||||
ENABLED,
|
ENABLED,
|
||||||
DISABLED
|
DISABLED,
|
||||||
|
NEW
|
||||||
}
|
}
|
||||||
|
|
||||||
export default function PluginSettings() {
|
export default function PluginSettings() {
|
||||||
@ -229,6 +230,7 @@ export default function PluginSettings() {
|
|||||||
const enabled = settings.plugins[plugin.name]?.enabled;
|
const enabled = settings.plugins[plugin.name]?.enabled;
|
||||||
if (enabled && searchValue.status === SearchStatus.DISABLED) return false;
|
if (enabled && searchValue.status === SearchStatus.DISABLED) return false;
|
||||||
if (!enabled && searchValue.status === SearchStatus.ENABLED) 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;
|
if (!searchValue.value.length) return true;
|
||||||
|
|
||||||
const v = searchValue.value.toLowerCase();
|
const v = searchValue.value.toLowerCase();
|
||||||
@ -321,7 +323,8 @@ export default function PluginSettings() {
|
|||||||
options={[
|
options={[
|
||||||
{ label: "Show All", value: SearchStatus.ALL, default: true },
|
{ label: "Show All", value: SearchStatus.ALL, default: true },
|
||||||
{ label: "Show Enabled", value: SearchStatus.ENABLED },
|
{ 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}
|
serialize={String}
|
||||||
select={onStatusChange}
|
select={onStatusChange}
|
||||||
|
@ -86,7 +86,7 @@ function SettingsSyncSection() {
|
|||||||
<Button
|
<Button
|
||||||
size={Button.Sizes.SMALL}
|
size={Button.Sizes.SMALL}
|
||||||
disabled={!sectionEnabled}
|
disabled={!sectionEnabled}
|
||||||
onClick={() => putCloudSettings()}
|
onClick={() => putCloudSettings(true)}
|
||||||
>Sync to Cloud</Button>
|
>Sync to Cloud</Button>
|
||||||
<Tooltip text="This will overwrite your local settings with the ones on the cloud. Use wisely!">
|
<Tooltip text="This will overwrite your local settings with the ones on the cloud. Use wisely!">
|
||||||
{({ onMouseLeave, onMouseEnter }) => (
|
{({ onMouseLeave, onMouseEnter }) => (
|
||||||
|
@ -21,7 +21,7 @@ import { Link } from "@components/Link";
|
|||||||
import { Margins } from "@utils/margins";
|
import { Margins } from "@utils/margins";
|
||||||
import { useAwaiter } from "@utils/react";
|
import { useAwaiter } from "@utils/react";
|
||||||
import { findLazy } from "@webpack";
|
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";
|
import { SettingsTab, wrapTab } from "./shared";
|
||||||
|
|
||||||
@ -112,7 +112,16 @@ function ThemesTab() {
|
|||||||
<li>• Click the fork button on the top right</li>
|
<li>• Click the fork button on the top right</li>
|
||||||
<li>• Edit the file</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>• 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>
|
</ul>
|
||||||
|
<Forms.FormDivider className={Margins.top8 + " " + Margins.bottom16} />
|
||||||
|
<Button
|
||||||
|
onClick={() => VencordNative.quickCss.openEditor()}
|
||||||
|
size={Button.Sizes.SMALL}>
|
||||||
|
Open QuickCSS File
|
||||||
|
</Button>
|
||||||
</Forms.FormText>
|
</Forms.FormText>
|
||||||
</Card>
|
</Card>
|
||||||
<Forms.FormTitle tag="h5">Themes</Forms.FormTitle>
|
<Forms.FormTitle tag="h5">Themes</Forms.FormTitle>
|
||||||
|
@ -5,8 +5,8 @@
|
|||||||
<title>Vencord QuickCSS Editor</title>
|
<title>Vencord QuickCSS Editor</title>
|
||||||
<link
|
<link
|
||||||
rel="stylesheet"
|
rel="stylesheet"
|
||||||
href="https://cdnjs.cloudflare.com/ajax/libs/monaco-editor/0.37.1/min/vs/editor/editor.main.min.css"
|
href="https://cdnjs.cloudflare.com/ajax/libs/monaco-editor/0.40.0/min/vs/editor/editor.main.min.css"
|
||||||
integrity="sha512-wB3xfL98hWg1bpkVYSyL0js/Jx9s7FsDg9aYO6nOMSJTgPuk/PFqxXQJKKSUjteZjeYrfgo9NFBOA1r9HwDuZw=="
|
integrity="sha512-MOoQ02h80hklccfLrXFYkCzG+WVjORflOp9Zp8dltiaRP+35LYnO4LKOklR64oMGfGgJDLO8WJpkM1o5gZXYZQ=="
|
||||||
crossorigin="anonymous"
|
crossorigin="anonymous"
|
||||||
referrerpolicy="no-referrer"
|
referrerpolicy="no-referrer"
|
||||||
/>
|
/>
|
||||||
@ -29,8 +29,8 @@
|
|||||||
<body>
|
<body>
|
||||||
<div id="container"></div>
|
<div id="container"></div>
|
||||||
<script
|
<script
|
||||||
src="https://cdnjs.cloudflare.com/ajax/libs/monaco-editor/0.37.1/min/vs/loader.min.js"
|
src="https://cdnjs.cloudflare.com/ajax/libs/monaco-editor/0.40.0/min/vs/loader.min.js"
|
||||||
integrity="sha512-A+6SvPGkIN9Rf0mUXmW4xh7rDvALXf/f0VtOUiHlDUSPknu2kcfz1KzLpOJyL2pO+nZS13hhIjLqVgiQExLJrw=="
|
integrity="sha512-QzMpXeCPciAHP4wbYlV2PYgrQcaEkDQUjzkPU4xnjyVSD9T36/udamxtNBqb4qK4/bMQMPZ8ayrBe9hrGdBFjQ=="
|
||||||
crossorigin="anonymous"
|
crossorigin="anonymous"
|
||||||
referrerpolicy="no-referrer"
|
referrerpolicy="no-referrer"
|
||||||
></script>
|
></script>
|
||||||
@ -38,7 +38,7 @@
|
|||||||
<script>
|
<script>
|
||||||
require.config({
|
require.config({
|
||||||
paths: {
|
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,8 +17,10 @@
|
|||||||
*/
|
*/
|
||||||
|
|
||||||
import { IpcEvents } from "@utils/IpcEvents";
|
import { IpcEvents } from "@utils/IpcEvents";
|
||||||
import { ipcMain } from "electron";
|
import { app, ipcMain } from "electron";
|
||||||
|
import { readFile } from "fs/promises";
|
||||||
import { request } from "https";
|
import { request } from "https";
|
||||||
|
import { join } from "path";
|
||||||
|
|
||||||
// #region OpenInApp
|
// #region OpenInApp
|
||||||
// These links don't support CORS, so this has to be native
|
// These links don't support CORS, so this has to be native
|
||||||
@ -44,3 +46,17 @@ ipcMain.handle(IpcEvents.OPEN_IN_APP__RESOLVE_REDIRECT, async (_, url: string) =
|
|||||||
return getRedirect(url);
|
return getRedirect(url);
|
||||||
});
|
});
|
||||||
// #endregion
|
// #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
|
||||||
|
@ -19,6 +19,7 @@
|
|||||||
import { Settings } from "@api/Settings";
|
import { Settings } from "@api/Settings";
|
||||||
import ErrorBoundary from "@components/ErrorBoundary";
|
import ErrorBoundary from "@components/ErrorBoundary";
|
||||||
import { Devs } from "@utils/constants";
|
import { Devs } from "@utils/constants";
|
||||||
|
import { useTimer } from "@utils/react";
|
||||||
import definePlugin, { OptionType } from "@utils/types";
|
import definePlugin, { OptionType } from "@utils/types";
|
||||||
import { React } from "@webpack/common";
|
import { React } from "@webpack/common";
|
||||||
|
|
||||||
@ -85,17 +86,10 @@ export default definePlugin({
|
|||||||
},
|
},
|
||||||
|
|
||||||
Timer({ channelId }: { channelId: string; }) {
|
Timer({ channelId }: { channelId: string; }) {
|
||||||
const [time, setTime] = React.useState(0);
|
const time = useTimer({
|
||||||
const startTime = React.useMemo(() => Date.now(), [channelId]);
|
deps: [channelId]
|
||||||
|
});
|
||||||
|
|
||||||
React.useEffect(() => {
|
return <p style={{ margin: 0 }}>Connected for <span style={{ fontFamily: "var(--font-code)" }}>{formatDuration(time)}</span></p>;
|
||||||
const interval = setInterval(() => setTime(Date.now() - startTime), 1000);
|
|
||||||
return () => {
|
|
||||||
clearInterval(interval);
|
|
||||||
setTime(0);
|
|
||||||
};
|
|
||||||
}, [channelId]);
|
|
||||||
|
|
||||||
return <p style={{ margin: 0 }}>Connected for {formatDuration(time)}</p>;
|
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
@ -135,4 +135,5 @@ export const defaultRules = [
|
|||||||
"utm_campaign",
|
"utm_campaign",
|
||||||
"utm_term",
|
"utm_term",
|
||||||
"si@open.spotify.com",
|
"si@open.spotify.com",
|
||||||
|
"igshid",
|
||||||
];
|
];
|
||||||
|
@ -21,7 +21,7 @@ import definePlugin from "@utils/types";
|
|||||||
|
|
||||||
export default definePlugin({
|
export default definePlugin({
|
||||||
name: "DisableDMCallIdle",
|
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],
|
authors: [Devs.Nuckyz],
|
||||||
patches: [
|
patches: [
|
||||||
{
|
{
|
||||||
|
@ -565,7 +565,11 @@ export default definePlugin({
|
|||||||
|
|
||||||
switch (embed.type) {
|
switch (embed.type) {
|
||||||
case "image": {
|
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 (settings.store.transformEmojis) {
|
||||||
if (fakeNitroEmojiRegex.test(embed.url!)) return true;
|
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;
|
||||||
|
}
|
@ -106,7 +106,7 @@ export default definePlugin({
|
|||||||
find: ".isSidebarVisible,",
|
find: ".isSidebarVisible,",
|
||||||
replacement: {
|
replacement: {
|
||||||
match: /(var (\i)=\i\.className.+?children):\[(\i\.useMemo[^}]+"aria-multiselectable")/,
|
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"
|
||||||
}
|
}
|
||||||
}],
|
}],
|
||||||
|
|
||||||
|
@ -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: {
|
clearMessageCache: {
|
||||||
type: OptionType.COMPONENT,
|
type: OptionType.COMPONENT,
|
||||||
description: "Clear the linked message cache",
|
description: "Clear the linked message cache",
|
||||||
@ -217,6 +237,13 @@ function MessageEmbedAccessory({ message }: { message: Message; }) {
|
|||||||
continue;
|
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;
|
let linkedMessage = messageCache.get(messageID)?.message;
|
||||||
if (!linkedMessage) {
|
if (!linkedMessage) {
|
||||||
linkedMessage ??= MessageStore.getMessage(channelID, messageID);
|
linkedMessage ??= MessageStore.getMessage(channelID, messageID);
|
||||||
@ -335,7 +362,7 @@ function AutomodEmbedAccessory(props: MessageEmbedProps): JSX.Element | null {
|
|||||||
export default definePlugin({
|
export default definePlugin({
|
||||||
name: "MessageLinkEmbeds",
|
name: "MessageLinkEmbeds",
|
||||||
description: "Adds a preview to messages that link another message",
|
description: "Adds a preview to messages that link another message",
|
||||||
authors: [Devs.TheSun, Devs.Ven],
|
authors: [Devs.TheSun, Devs.Ven, Devs.RyanCaoDev],
|
||||||
dependencies: ["MessageAccessoriesAPI"],
|
dependencies: ["MessageAccessoriesAPI"],
|
||||||
patches: [
|
patches: [
|
||||||
{
|
{
|
||||||
@ -363,7 +390,9 @@ export default definePlugin({
|
|||||||
|
|
||||||
return (
|
return (
|
||||||
<ErrorBoundary>
|
<ErrorBoundary>
|
||||||
<MessageEmbedAccessory message={props.message} />
|
<MessageEmbedAccessory
|
||||||
|
message={props.message}
|
||||||
|
/>
|
||||||
</ErrorBoundary>
|
</ErrorBoundary>
|
||||||
);
|
);
|
||||||
}, 4 /* just above rich embeds */);
|
}, 4 /* just above rich embeds */);
|
||||||
|
@ -152,14 +152,24 @@ export default definePlugin({
|
|||||||
type: OptionType.STRING,
|
type: OptionType.STRING,
|
||||||
description: "Comma-separated list of user IDs to ignore",
|
description: "Comma-separated list of user IDs to ignore",
|
||||||
default: ""
|
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) {
|
handleDelete(cache: any, data: { ids: string[], id: string; mlDeleted?: boolean; }, isBulk: boolean) {
|
||||||
try {
|
try {
|
||||||
if (cache == null || (!isBulk && !cache.has(data.id))) return cache;
|
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;
|
const myId = UserStore.getCurrentUser().id;
|
||||||
|
|
||||||
function mutate(id: string) {
|
function mutate(id: string) {
|
||||||
@ -171,7 +181,9 @@ export default definePlugin({
|
|||||||
(msg.flags & EPHEMERAL) === EPHEMERAL ||
|
(msg.flags & EPHEMERAL) === EPHEMERAL ||
|
||||||
ignoreBots && msg.author?.bot ||
|
ignoreBots && msg.author?.bot ||
|
||||||
ignoreSelf && msg.author?.id === myId ||
|
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) {
|
if (shouldIgnore) {
|
||||||
cache = cache.remove(id);
|
cache = cache.remove(id);
|
||||||
|
@ -20,7 +20,7 @@ import { definePluginSettings } from "@api/Settings";
|
|||||||
import { Devs } from "@utils/constants";
|
import { Devs } from "@utils/constants";
|
||||||
import definePlugin, { OptionType } from "@utils/types";
|
import definePlugin, { OptionType } from "@utils/types";
|
||||||
import { showToast, Toasts } from "@webpack/common";
|
import { showToast, Toasts } from "@webpack/common";
|
||||||
import { MouseEvent } from "react";
|
import type { MouseEvent } from "react";
|
||||||
|
|
||||||
const ShortUrlMatcher = /^https:\/\/(spotify\.link|s\.team)\/.+$/;
|
const ShortUrlMatcher = /^https:\/\/(spotify\.link|s\.team)\/.+$/;
|
||||||
const SpotifyMatcher = /^https:\/\/open\.spotify\.com\/(track|album|artist|playlist|user)\/(.+)(?:\?.+?)?$/;
|
const SpotifyMatcher = /^https:\/\/open\.spotify\.com\/(track|album|artist|playlist|user)\/(.+)(?:\?.+?)?$/;
|
||||||
@ -71,18 +71,18 @@ export default definePlugin({
|
|||||||
{
|
{
|
||||||
find: ".CONNECTED_ACCOUNT_VIEWED,",
|
find: ".CONNECTED_ACCOUNT_VIEWED,",
|
||||||
replacement: {
|
replacement: {
|
||||||
match: /(?<=href:\i,onClick:function\(\)\{)(?=return \i=(\i)\.type,.{0,50}CONNECTED_ACCOUNT_VIEWED)/,
|
match: /(?<=href:\i,onClick:function\(\i\)\{)(?=\i=(\i)\.type,.{0,50}CONNECTED_ACCOUNT_VIEWED)/,
|
||||||
replace: "$self.handleAccountView(arguments[0],$1.type,$1.id);"
|
replace: "$self.handleAccountView(arguments[0],$1.type,$1.id);"
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
],
|
],
|
||||||
|
|
||||||
async handleLink(data: { href: string; }, event: MouseEvent) {
|
async handleLink(data: { href: string; }, event?: MouseEvent) {
|
||||||
if (!data) return false;
|
if (!data) return false;
|
||||||
|
|
||||||
let url = data.href;
|
let url = data.href;
|
||||||
if (!IS_WEB && ShortUrlMatcher.test(url)) {
|
if (!IS_WEB && ShortUrlMatcher.test(url)) {
|
||||||
event.preventDefault();
|
event?.preventDefault();
|
||||||
// CORS jumpscare
|
// CORS jumpscare
|
||||||
url = await VencordNative.pluginHelpers.OpenInApp.resolveRedirect(url);
|
url = await VencordNative.pluginHelpers.OpenInApp.resolveRedirect(url);
|
||||||
}
|
}
|
||||||
@ -96,7 +96,7 @@ export default definePlugin({
|
|||||||
const [, type, id] = match;
|
const [, type, id] = match;
|
||||||
VencordNative.native.openExternal(`spotify:${type}:${id}`);
|
VencordNative.native.openExternal(`spotify:${type}:${id}`);
|
||||||
|
|
||||||
event.preventDefault();
|
event?.preventDefault();
|
||||||
return true;
|
return true;
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -106,7 +106,7 @@ export default definePlugin({
|
|||||||
if (!SteamMatcher.test(url)) break steam;
|
if (!SteamMatcher.test(url)) break steam;
|
||||||
|
|
||||||
VencordNative.native.openExternal(`steam://openurl/${url}`);
|
VencordNative.native.openExternal(`steam://openurl/${url}`);
|
||||||
event.preventDefault();
|
event?.preventDefault();
|
||||||
|
|
||||||
// Steam does not focus itself so show a toast so it's slightly less confusing
|
// Steam does not focus itself so show a toast so it's slightly less confusing
|
||||||
showToast("Opened link in Steam", Toasts.Type.SUCCESS);
|
showToast("Opened link in Steam", Toasts.Type.SUCCESS);
|
||||||
@ -120,13 +120,13 @@ export default definePlugin({
|
|||||||
if (!match) break epic;
|
if (!match) break epic;
|
||||||
|
|
||||||
VencordNative.native.openExternal(`com.epicgames.launcher://store/${match[1]}`);
|
VencordNative.native.openExternal(`com.epicgames.launcher://store/${match[1]}`);
|
||||||
event.preventDefault();
|
event?.preventDefault();
|
||||||
|
|
||||||
return true;
|
return true;
|
||||||
}
|
}
|
||||||
|
|
||||||
// in case short url didn't end up being something we can handle
|
// in case short url didn't end up being something we can handle
|
||||||
if (event.defaultPrevented) {
|
if (event?.defaultPrevented) {
|
||||||
window.open(url, "_blank");
|
window.open(url, "_blank");
|
||||||
return true;
|
return true;
|
||||||
}
|
}
|
||||||
|
@ -69,7 +69,7 @@ export default definePlugin({
|
|||||||
replacement: [
|
replacement: [
|
||||||
{
|
{
|
||||||
match: /getGlobalName\(\i\);(?<=displayProfile.{0,200})/,
|
match: /getGlobalName\(\i\);(?<=displayProfile.{0,200})/,
|
||||||
replace: "$&const [vcPronounce,vcPronounSource]=$self.useProfilePronouns(arguments[0].user.id);if(arguments[0].displayProfile&&vcPronounce)arguments[0].displayProfile.pronouns=vcPronounce;"
|
replace: "$&const [vcPronounce,vcPronounSource]=$self.useProfilePronouns(arguments[0].user.id,true);if(arguments[0].displayProfile&&vcPronounce)arguments[0].displayProfile.pronouns=vcPronounce;"
|
||||||
},
|
},
|
||||||
PRONOUN_TOOLTIP_PATCH
|
PRONOUN_TOOLTIP_PATCH
|
||||||
]
|
]
|
||||||
|
@ -58,16 +58,20 @@ 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 (
|
return (
|
||||||
UserProfileStore.getGuildMemberProfile(id, getCurrentChannel()?.guild_id)?.pronouns
|
UserProfileStore.getGuildMemberProfile(id, getCurrentChannel()?.guild_id)?.pronouns
|
||||||
|| UserProfileStore.getUserProfile(id)?.pronouns
|
|| globalPronouns
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
export function useFormattedPronouns(id: string): PronounsWithSource {
|
export function useFormattedPronouns(id: string, useGlobalProfile: boolean = false): PronounsWithSource {
|
||||||
// Discord is so stupid you can put tons of newlines in pronouns
|
// Discord is so stupid you can put tons of newlines in pronouns
|
||||||
const discordPronouns = getDiscordPronouns(id)?.trim().replace(NewLineRe, " ");
|
const discordPronouns = getDiscordPronouns(id, useGlobalProfile)?.trim().replace(NewLineRe, " ");
|
||||||
|
|
||||||
const [result] = useAwaiter(() => fetchPronouns(id), {
|
const [result] = useAwaiter(() => fetchPronouns(id), {
|
||||||
fallbackValue: getCachedPronouns(id),
|
fallbackValue: getCachedPronouns(id),
|
||||||
@ -83,8 +87,8 @@ export function useFormattedPronouns(id: string): PronounsWithSource {
|
|||||||
return [discordPronouns, "Discord"];
|
return [discordPronouns, "Discord"];
|
||||||
}
|
}
|
||||||
|
|
||||||
export function useProfilePronouns(id: string): PronounsWithSource {
|
export function useProfilePronouns(id: string, useGlobalProfile: boolean = false): PronounsWithSource {
|
||||||
const pronouns = useFormattedPronouns(id);
|
const pronouns = useFormattedPronouns(id, useGlobalProfile);
|
||||||
|
|
||||||
if (!settings.store.showInProfile) return EmptyPronouns;
|
if (!settings.store.showInProfile) return EmptyPronouns;
|
||||||
if (!settings.store.showSelf && id === UserStore.getCurrentUser().id) return EmptyPronouns;
|
if (!settings.store.showSelf && id === UserStore.getCurrentUser().id) return EmptyPronouns;
|
||||||
|
@ -50,7 +50,7 @@ export async function onRelationshipRemove({ relationship: { type, id } }: Relat
|
|||||||
() => openUserProfile(user.id)
|
() => openUserProfile(user.id)
|
||||||
);
|
);
|
||||||
break;
|
break;
|
||||||
case RelationshipType.FRIEND_REQUEST:
|
case RelationshipType.INCOMING_REQUEST:
|
||||||
if (settings.store.friendRequestCancels)
|
if (settings.store.friendRequestCancels)
|
||||||
notify(
|
notify(
|
||||||
`A friend request from ${getUniqueUsername(user)} has been removed.`,
|
`A friend request from ${getUniqueUsername(user)} has been removed.`,
|
||||||
|
@ -58,5 +58,7 @@ export const enum ChannelType {
|
|||||||
|
|
||||||
export const enum RelationshipType {
|
export const enum RelationshipType {
|
||||||
FRIEND = 1,
|
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) {
|
if (settings.store.friendRequestCancels && oldFriends?.requests?.length) {
|
||||||
for (const id of oldFriends.requests) {
|
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);
|
const user = await UserUtils.fetchUser(id).catch(() => void 0);
|
||||||
if (user)
|
if (user)
|
||||||
@ -164,7 +167,7 @@ export async function syncFriends() {
|
|||||||
case RelationshipType.FRIEND:
|
case RelationshipType.FRIEND:
|
||||||
friends.friends.push(id);
|
friends.friends.push(id);
|
||||||
break;
|
break;
|
||||||
case RelationshipType.FRIEND_REQUEST:
|
case RelationshipType.INCOMING_REQUEST:
|
||||||
friends.requests.push(id);
|
friends.requests.push(id);
|
||||||
break;
|
break;
|
||||||
}
|
}
|
||||||
|
@ -28,8 +28,8 @@ export default definePlugin({
|
|||||||
{
|
{
|
||||||
find: ".Messages.MESSAGE_UTILITIES_A11Y_LABEL",
|
find: ".Messages.MESSAGE_UTILITIES_A11Y_LABEL",
|
||||||
replacement: {
|
replacement: {
|
||||||
// isExpanded: V, (?<=,V = shiftKeyDown && !H...;)
|
// isExpanded: V, (?<=,V = shiftKeyDown && !H...,|;)
|
||||||
match: /isExpanded:(\i),(?<=,\1=\i&&(!.+);.+?)/,
|
match: /isExpanded:(\i),(?<=,\1=\i&&(?=(!.+?)[,;]).+?)/,
|
||||||
replace: "isExpanded:$2,"
|
replace: "isExpanded:$2,"
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
@ -29,7 +29,7 @@ import type { Channel, Role } from "discord-types/general";
|
|||||||
|
|
||||||
import HiddenChannelLockScreen, { setChannelBeginHeaderComponent } from "./components/HiddenChannelLockScreen";
|
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;
|
export const VIEW_CHANNEL = 1n << 10n;
|
||||||
const CONNECT = 1n << 20n;
|
const CONNECT = 1n << 20n;
|
||||||
@ -472,7 +472,7 @@ export default definePlugin({
|
|||||||
|
|
||||||
HiddenChannelLockScreen: (channel: any) => <HiddenChannelLockScreen channel={channel} />,
|
HiddenChannelLockScreen: (channel: any) => <HiddenChannelLockScreen channel={channel} />,
|
||||||
|
|
||||||
LockIcon: () => (
|
LockIcon: ErrorBoundary.wrap(() => (
|
||||||
<svg
|
<svg
|
||||||
className={ChannelListClasses.icon}
|
className={ChannelListClasses.icon}
|
||||||
height="18"
|
height="18"
|
||||||
@ -483,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" />
|
<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>
|
</svg>
|
||||||
),
|
), { noop: true }),
|
||||||
|
|
||||||
HiddenChannelIcon: ErrorBoundary.wrap(() => (
|
HiddenChannelIcon: ErrorBoundary.wrap(() => (
|
||||||
<Tooltip text="Hidden Channel">
|
<Tooltip text="Hidden Channel">
|
||||||
|
@ -21,7 +21,6 @@ import "./spotifyStyles.css";
|
|||||||
import ErrorBoundary from "@components/ErrorBoundary";
|
import ErrorBoundary from "@components/ErrorBoundary";
|
||||||
import { Flex } from "@components/Flex";
|
import { Flex } from "@components/Flex";
|
||||||
import { ImageIcon, LinkIcon, OpenExternalIcon } from "@components/Icons";
|
import { ImageIcon, LinkIcon, OpenExternalIcon } from "@components/Icons";
|
||||||
import { Link } from "@components/Link";
|
|
||||||
import { debounce } from "@utils/debounce";
|
import { debounce } from "@utils/debounce";
|
||||||
import { openImageModal } from "@utils/discord";
|
import { openImageModal } from "@utils/discord";
|
||||||
import { classes, copyWithToast } from "@utils/misc";
|
import { classes, copyWithToast } from "@utils/misc";
|
||||||
@ -254,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; }) {
|
function Info({ track }: { track: Track; }) {
|
||||||
const img = track?.album?.image;
|
const img = track?.album?.image;
|
||||||
|
|
||||||
@ -289,12 +298,8 @@ function Info({ track }: { track: Track; }) {
|
|||||||
variant="text-sm/semibold"
|
variant="text-sm/semibold"
|
||||||
id={cl("song-title")}
|
id={cl("song-title")}
|
||||||
className={cl("ellipoverflow")}
|
className={cl("ellipoverflow")}
|
||||||
role={track.id ? "link" : undefined}
|
|
||||||
title={track.name}
|
title={track.name}
|
||||||
onClick={track.id ? () => {
|
{...makeLinkProps("Song", track.id, `/track/${track.id}`)}
|
||||||
SpotifyStore.openExternal(`/track/${track.id}`);
|
|
||||||
} : void 0}
|
|
||||||
onContextMenu={track.id ? makeContextMenu("Song", `/track/${track.id}`) : void 0}
|
|
||||||
>
|
>
|
||||||
{track.name}
|
{track.name}
|
||||||
</Forms.FormText>
|
</Forms.FormText>
|
||||||
@ -303,16 +308,14 @@ function Info({ track }: { track: Track; }) {
|
|||||||
by
|
by
|
||||||
{track.artists.map((a, i) => (
|
{track.artists.map((a, i) => (
|
||||||
<React.Fragment key={a.name}>
|
<React.Fragment key={a.name}>
|
||||||
<Link
|
<span
|
||||||
className={cl("artist")}
|
className={cl("artist")}
|
||||||
disabled={!a.id}
|
|
||||||
href={`https://open.spotify.com/artist/${a.id}`}
|
|
||||||
style={{ fontSize: "inherit" }}
|
style={{ fontSize: "inherit" }}
|
||||||
title={a.name}
|
title={a.name}
|
||||||
onContextMenu={makeContextMenu("Artist", `/artist/${a.id}`)}
|
{...makeLinkProps("Artist", a.id, `/artist/${a.id}`)}
|
||||||
>
|
>
|
||||||
{a.name}
|
{a.name}
|
||||||
</Link>
|
</span>
|
||||||
{i !== track.artists.length - 1 && <span className={cl("comma")}>{", "}</span>}
|
{i !== track.artists.length - 1 && <span className={cl("comma")}>{", "}</span>}
|
||||||
</React.Fragment>
|
</React.Fragment>
|
||||||
))}
|
))}
|
||||||
@ -321,17 +324,15 @@ function Info({ track }: { track: Track; }) {
|
|||||||
{track.album.name && (
|
{track.album.name && (
|
||||||
<Forms.FormText variant="text-sm/normal" className={cl("ellipoverflow")}>
|
<Forms.FormText variant="text-sm/normal" className={cl("ellipoverflow")}>
|
||||||
on
|
on
|
||||||
<Link id={cl("album-title")}
|
<span
|
||||||
href={`https://open.spotify.com/album/${track.album.id}`}
|
id={cl("album-title")}
|
||||||
target="_blank"
|
|
||||||
className={cl("album")}
|
className={cl("album")}
|
||||||
disabled={!track.album.id}
|
|
||||||
style={{ fontSize: "inherit" }}
|
style={{ fontSize: "inherit" }}
|
||||||
title={track.album.name}
|
title={track.album.name}
|
||||||
onContextMenu={makeContextMenu("Album", `/album/${track.album.id}`)}
|
{...makeLinkProps("Album", track.album.id, `/album/${track.album.id}`)}
|
||||||
>
|
>
|
||||||
{track.album.name}
|
{track.album.name}
|
||||||
</Link>
|
</span>
|
||||||
</Forms.FormText>
|
</Forms.FormText>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
|
@ -131,8 +131,8 @@
|
|||||||
color: var(--header-secondary);
|
color: var(--header-secondary);
|
||||||
}
|
}
|
||||||
|
|
||||||
.vc-spotify-artist:hover,
|
.vc-spotify-artist[role="link"]:hover,
|
||||||
#vc-spotify-album-title:hover,
|
#vc-spotify-album-title[role="link"]:hover,
|
||||||
#vc-spotify-song-title[role="link"]:hover {
|
#vc-spotify-song-title[role="link"]:hover {
|
||||||
text-decoration: underline;
|
text-decoration: underline;
|
||||||
cursor: pointer;
|
cursor: pointer;
|
||||||
|
@ -44,6 +44,9 @@ export function TranslationAccessory({ message }: { message: Message; }) {
|
|||||||
const [translation, setTranslation] = useState<TranslationValue>();
|
const [translation, setTranslation] = useState<TranslationValue>();
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
|
// Ignore MessageLinkEmbeds messages
|
||||||
|
if ((message as any).vencordEmbeddedBy) return;
|
||||||
|
|
||||||
TranslationSetters.set(message.id, setTranslation);
|
TranslationSetters.set(message.id, setTranslation);
|
||||||
|
|
||||||
return () => void TranslationSetters.delete(message.id);
|
return () => void TranslationSetters.delete(message.id);
|
||||||
|
@ -18,19 +18,39 @@
|
|||||||
|
|
||||||
import "./styles.css";
|
import "./styles.css";
|
||||||
|
|
||||||
|
import { addContextMenuPatch, findGroupChildrenByChildId, NavContextMenuPatchCallback, removeContextMenuPatch } from "@api/ContextMenu";
|
||||||
import { addAccessory, removeAccessory } from "@api/MessageAccessories";
|
import { addAccessory, removeAccessory } from "@api/MessageAccessories";
|
||||||
import { addPreSendListener, removePreSendListener } from "@api/MessageEvents";
|
import { addPreSendListener, removePreSendListener } from "@api/MessageEvents";
|
||||||
import { addButton, removeButton } from "@api/MessagePopover";
|
import { addButton, removeButton } from "@api/MessagePopover";
|
||||||
import ErrorBoundary from "@components/ErrorBoundary";
|
import ErrorBoundary from "@components/ErrorBoundary";
|
||||||
import { Devs } from "@utils/constants";
|
import { Devs } from "@utils/constants";
|
||||||
import definePlugin from "@utils/types";
|
import definePlugin from "@utils/types";
|
||||||
import { ChannelStore } from "@webpack/common";
|
import { ChannelStore, Menu } from "@webpack/common";
|
||||||
|
|
||||||
import { settings } from "./settings";
|
import { settings } from "./settings";
|
||||||
import { TranslateChatBarIcon, TranslateIcon } from "./TranslateIcon";
|
import { TranslateChatBarIcon, TranslateIcon } from "./TranslateIcon";
|
||||||
import { handleTranslate, TranslationAccessory } from "./TranslationAccessory";
|
import { handleTranslate, TranslationAccessory } from "./TranslationAccessory";
|
||||||
import { translate } from "./utils";
|
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({
|
export default definePlugin({
|
||||||
name: "Translate",
|
name: "Translate",
|
||||||
description: "Translate messages with Google Translate",
|
description: "Translate messages with Google Translate",
|
||||||
@ -53,6 +73,8 @@ export default definePlugin({
|
|||||||
start() {
|
start() {
|
||||||
addAccessory("vc-translation", props => <TranslationAccessory message={props.message} />);
|
addAccessory("vc-translation", props => <TranslationAccessory message={props.message} />);
|
||||||
|
|
||||||
|
addContextMenuPatch("message", messageCtxPatch);
|
||||||
|
|
||||||
addButton("vc-translate", message => {
|
addButton("vc-translate", message => {
|
||||||
if (!message.content) return null;
|
if (!message.content) return null;
|
||||||
|
|
||||||
@ -78,6 +100,7 @@ export default definePlugin({
|
|||||||
|
|
||||||
stop() {
|
stop() {
|
||||||
removePreSendListener(this.preSend);
|
removePreSendListener(this.preSend);
|
||||||
|
removeContextMenuPatch("message", messageCtxPatch);
|
||||||
removeButton("vc-translate");
|
removeButton("vc-translate");
|
||||||
removeAccessory("vc-translation");
|
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);
|
|
||||||
},
|
|
||||||
});
|
|
@ -1,3 +1,8 @@
|
|||||||
|
.vc-toolbox-btn,
|
||||||
|
.vc-toolbox-btn svg {
|
||||||
|
-webkit-app-region: no-drag;
|
||||||
|
}
|
||||||
|
|
||||||
.vc-toolbox-btn svg {
|
.vc-toolbox-btn svg {
|
||||||
color: var(--interactive-normal);
|
color: var(--interactive-normal);
|
||||||
}
|
}
|
||||||
|
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;
|
||||||
|
}
|
21
src/plugins/voiceMessages/utils.ts
Normal file
21
src/plugins/voiceMessages/utils.ts
Normal file
@ -0,0 +1,21 @@
|
|||||||
|
/*
|
||||||
|
* 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 { classNameFactory } from "@api/Styles";
|
||||||
|
|
||||||
|
export const cl = classNameFactory("vc-vmsg-");
|
@ -32,4 +32,5 @@ export const enum IpcEvents {
|
|||||||
OPEN_MONACO_EDITOR = "VencordOpenMonacoEditor",
|
OPEN_MONACO_EDITOR = "VencordOpenMonacoEditor",
|
||||||
|
|
||||||
OPEN_IN_APP__RESOLVE_REDIRECT = "VencordOIAResolveRedirect",
|
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 cloudLogger = new Logger("Cloud", "#39b7e0");
|
||||||
export const getCloudUrl = () => new URL(Settings.cloud.url);
|
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() {
|
export async function getAuthorization() {
|
||||||
const secrets = await DataStore.get<Record<string, string>>("Vencord_cloudSecret") ?? {};
|
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) {
|
async function setAuthorization(secret: string) {
|
||||||
await DataStore.update<Record<string, string>>("Vencord_cloudSecret", secrets => {
|
await DataStore.update<Record<string, string>>("Vencord_cloudSecret", secrets => {
|
||||||
secrets ??= {};
|
secrets ??= {};
|
||||||
secrets[getCloudUrl().origin] = secret;
|
secrets[`${cloudUrlOrigin()}:${getUserId()}`] = secret;
|
||||||
return secrets;
|
return secrets;
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
@ -44,7 +68,7 @@ async function setAuthorization(secret: string) {
|
|||||||
export async function deauthorizeCloud() {
|
export async function deauthorizeCloud() {
|
||||||
await DataStore.update<Record<string, string>>("Vencord_cloudSecret", secrets => {
|
await DataStore.update<Record<string, string>>("Vencord_cloudSecret", secrets => {
|
||||||
secrets ??= {};
|
secrets ??= {};
|
||||||
delete secrets[getCloudUrl().origin];
|
delete secrets[`${cloudUrlOrigin()}:${getUserId()}`];
|
||||||
return secrets;
|
return secrets;
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
@ -117,8 +141,7 @@ export async function authorizeCloud() {
|
|||||||
}
|
}
|
||||||
|
|
||||||
export async function getCloudAuth() {
|
export async function getCloudAuth() {
|
||||||
const userId = UserStore.getCurrentUser().id;
|
|
||||||
const secret = await getAuthorization();
|
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: {
|
Dziurwa: {
|
||||||
name: "Dziurwa",
|
name: "Dziurwa",
|
||||||
id: 787017887877169173n
|
id: 1034579679526526976n
|
||||||
},
|
},
|
||||||
AutumnVN: {
|
AutumnVN: {
|
||||||
name: "AutumnVN",
|
name: "AutumnVN",
|
||||||
@ -329,7 +329,7 @@ export const Devs = /* #__PURE__*/ Object.freeze({
|
|||||||
},
|
},
|
||||||
rad: {
|
rad: {
|
||||||
name: "rad",
|
name: "rad",
|
||||||
id: 113027285765885952n
|
id: 610945092504780823n
|
||||||
},
|
},
|
||||||
HypedDomi: {
|
HypedDomi: {
|
||||||
name: "HypedDomi",
|
name: "HypedDomi",
|
||||||
|
@ -27,8 +27,8 @@ const unconfigurable = ["arguments", "caller", "prototype"];
|
|||||||
|
|
||||||
const handler: ProxyHandler<any> = {};
|
const handler: ProxyHandler<any> = {};
|
||||||
|
|
||||||
const GET_KEY = Symbol.for("vencord.lazy.get");
|
const kGET = Symbol.for("vencord.lazy.get");
|
||||||
const CACHED_KEY = Symbol.for("vencord.lazy.cached");
|
const kCACHE = Symbol.for("vencord.lazy.cached");
|
||||||
|
|
||||||
for (const method of [
|
for (const method of [
|
||||||
"apply",
|
"apply",
|
||||||
@ -46,11 +46,11 @@ for (const method of [
|
|||||||
"setPrototypeOf"
|
"setPrototypeOf"
|
||||||
]) {
|
]) {
|
||||||
handler[method] =
|
handler[method] =
|
||||||
(target: any, ...args: any[]) => Reflect[method](target[GET_KEY](), ...args);
|
(target: any, ...args: any[]) => Reflect[method](target[kGET](), ...args);
|
||||||
}
|
}
|
||||||
|
|
||||||
handler.ownKeys = target => {
|
handler.ownKeys = target => {
|
||||||
const v = target[GET_KEY]();
|
const v = target[kGET]();
|
||||||
const keys = Reflect.ownKeys(v);
|
const keys = Reflect.ownKeys(v);
|
||||||
for (const key of unconfigurable) {
|
for (const key of unconfigurable) {
|
||||||
if (!keys.includes(key)) keys.push(key);
|
if (!keys.includes(key)) keys.push(key);
|
||||||
@ -62,7 +62,7 @@ handler.getOwnPropertyDescriptor = (target, p) => {
|
|||||||
if (typeof p === "string" && unconfigurable.includes(p))
|
if (typeof p === "string" && unconfigurable.includes(p))
|
||||||
return Reflect.getOwnPropertyDescriptor(target, 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);
|
if (descriptor) Object.defineProperty(target, p, descriptor);
|
||||||
return 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.
|
* 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
|
* On first property access, the lazy is evaluated
|
||||||
* @param factory lazy factory
|
* @param factory lazy factory
|
||||||
|
* @param attempts how many times to try to evaluate the lazy before giving up
|
||||||
* @returns Proxy
|
* @returns Proxy
|
||||||
*
|
*
|
||||||
* Note that the example below exists already as an api, see {@link findByPropsLazy}
|
* Note that the example below exists already as an api, see {@link findByPropsLazy}
|
||||||
* @example const mod = proxyLazy(() => findByProps("blah")); console.log(mod.blah);
|
* @example const mod = proxyLazy(() => findByProps("blah")); console.log(mod.blah);
|
||||||
*/
|
*/
|
||||||
export function proxyLazy<T>(factory: () => T): T {
|
export function proxyLazy<T>(factory: () => T, attempts = 5): T {
|
||||||
const proxyDummy: { (): void;[CACHED_KEY]?: T;[GET_KEY](): T; } = Object.assign(function () { }, {
|
let tries = 0;
|
||||||
[CACHED_KEY]: void 0,
|
const proxyDummy = Object.assign(function () { }, {
|
||||||
[GET_KEY]: () => proxyDummy[CACHED_KEY] ??= factory(),
|
[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;
|
return new Proxy(proxyDummy, handler) as any;
|
||||||
|
@ -27,8 +27,12 @@ export async function toggle(isEnabled: boolean) {
|
|||||||
if (isEnabled) {
|
if (isEnabled) {
|
||||||
style = document.createElement("style");
|
style = document.createElement("style");
|
||||||
style.id = "vencord-custom-css";
|
style.id = "vencord-custom-css";
|
||||||
document.head.appendChild(style);
|
document.documentElement.appendChild(style);
|
||||||
VencordNative.quickCss.addChangeListener(css => style.textContent = css);
|
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();
|
style.textContent = await VencordNative.quickCss.get();
|
||||||
}
|
}
|
||||||
} else
|
} else
|
||||||
@ -39,7 +43,7 @@ async function initThemes() {
|
|||||||
if (!themesStyle) {
|
if (!themesStyle) {
|
||||||
themesStyle = document.createElement("style");
|
themesStyle = document.createElement("style");
|
||||||
themesStyle.id = "vencord-themes";
|
themesStyle.id = "vencord-themes";
|
||||||
document.head.appendChild(themesStyle);
|
document.documentElement.appendChild(themesStyle);
|
||||||
}
|
}
|
||||||
|
|
||||||
const { themeLinks } = Settings;
|
const { themeLinks } = Settings;
|
||||||
|
@ -16,7 +16,7 @@
|
|||||||
* along with this program. If not, see <https://www.gnu.org/licenses/>.
|
* along with this program. If not, see <https://www.gnu.org/licenses/>.
|
||||||
*/
|
*/
|
||||||
|
|
||||||
import { React, useEffect, useReducer, useState } from "@webpack/common";
|
import { React, useEffect, useMemo, useReducer, useState } from "@webpack/common";
|
||||||
|
|
||||||
import { makeLazy } from "./lazy";
|
import { makeLazy } from "./lazy";
|
||||||
import { checkIntersecting } from "./misc";
|
import { checkIntersecting } from "./misc";
|
||||||
@ -135,3 +135,24 @@ export function LazyComponent<T extends object = any>(factory: () => React.Compo
|
|||||||
return <Component {...props} />;
|
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 { getCloudAuth, getCloudUrl } from "./cloud";
|
||||||
import { Logger } from "./Logger";
|
import { Logger } from "./Logger";
|
||||||
import { saveFile } from "./web";
|
import { chooseFile, saveFile } from "./web";
|
||||||
|
|
||||||
export async function importSettings(data: string) {
|
export async function importSettings(data: string) {
|
||||||
try {
|
try {
|
||||||
@ -41,10 +41,10 @@ export async function importSettings(data: string) {
|
|||||||
throw new Error("Invalid Settings. Is this even a Vencord Settings file?");
|
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 settings = JSON.parse(VencordNative.settings.get());
|
||||||
const quickCss = await VencordNative.quickCss.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() {
|
export async function downloadSettingsBackup() {
|
||||||
@ -91,12 +91,7 @@ export async function uploadSettingsBackup(showToast = true): Promise<void> {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
} else {
|
} else {
|
||||||
const input = document.createElement("input");
|
const file = await chooseFile("application/json");
|
||||||
input.type = "file";
|
|
||||||
input.style.display = "none";
|
|
||||||
input.accept = "application/json";
|
|
||||||
input.onchange = async () => {
|
|
||||||
const file = input.files?.[0];
|
|
||||||
if (!file) return;
|
if (!file) return;
|
||||||
|
|
||||||
const reader = new FileReader();
|
const reader = new FileReader();
|
||||||
@ -110,19 +105,14 @@ export async function uploadSettingsBackup(showToast = true): Promise<void> {
|
|||||||
}
|
}
|
||||||
};
|
};
|
||||||
reader.readAsText(file);
|
reader.readAsText(file);
|
||||||
};
|
|
||||||
|
|
||||||
document.body.appendChild(input);
|
|
||||||
input.click();
|
|
||||||
setImmediate(() => document.body.removeChild(input));
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// Cloud settings
|
// Cloud settings
|
||||||
const cloudSettingsLogger = new Logger("Cloud:Settings", "#39b7e0");
|
const cloudSettingsLogger = new Logger("Cloud:Settings", "#39b7e0");
|
||||||
|
|
||||||
export async function putCloudSettings() {
|
export async function putCloudSettings(manual?: boolean) {
|
||||||
const settings = await exportSettings();
|
const settings = await exportSettings({ minify: true });
|
||||||
|
|
||||||
try {
|
try {
|
||||||
const res = await fetch(new URL("/v1/settings", getCloudUrl()), {
|
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));
|
VencordNative.settings.set(JSON.stringify(PlainSettings, null, 4));
|
||||||
|
|
||||||
cloudSettingsLogger.info("Settings uploaded to cloud successfully");
|
cloudSettingsLogger.info("Settings uploaded to cloud successfully");
|
||||||
|
|
||||||
|
if (manual) {
|
||||||
|
showNotification({
|
||||||
|
title: "Cloud Settings",
|
||||||
|
body: "Synchronized settings to the cloud!",
|
||||||
|
noPersist: true,
|
||||||
|
});
|
||||||
|
}
|
||||||
} catch (e: any) {
|
} catch (e: any) {
|
||||||
cloudSettingsLogger.error("Failed to sync up", e);
|
cloudSettingsLogger.error("Failed to sync up", e);
|
||||||
showNotification({
|
showNotification({
|
||||||
|
@ -16,6 +16,10 @@
|
|||||||
* along with this program. If not, see <https://www.gnu.org/licenses/>.
|
* 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) {
|
export function saveFile(file: File) {
|
||||||
const a = document.createElement("a");
|
const a = document.createElement("a");
|
||||||
a.href = URL.createObjectURL(file);
|
a.href = URL.createObjectURL(file);
|
||||||
@ -28,3 +32,24 @@ export function saveFile(file: File) {
|
|||||||
document.body.removeChild(a);
|
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_ROLES"
|
||||||
| "MANAGE_WEBHOOKS"
|
| "MANAGE_WEBHOOKS"
|
||||||
| "MANAGE_GUILD_EXPRESSIONS"
|
| "MANAGE_GUILD_EXPRESSIONS"
|
||||||
|
| "CREATE_GUILD_EXPRESSIONS"
|
||||||
| "VIEW_AUDIT_LOG"
|
| "VIEW_AUDIT_LOG"
|
||||||
| "VIEW_CHANNEL"
|
| "VIEW_CHANNEL"
|
||||||
| "VIEW_GUILD_ANALYTICS"
|
| "VIEW_GUILD_ANALYTICS"
|
||||||
@ -116,6 +117,7 @@ export type Permissions = "CREATE_INSTANT_INVITE"
|
|||||||
| "CREATE_PRIVATE_THREADS"
|
| "CREATE_PRIVATE_THREADS"
|
||||||
| "USE_EXTERNAL_STICKERS"
|
| "USE_EXTERNAL_STICKERS"
|
||||||
| "SEND_MESSAGES_IN_THREADS"
|
| "SEND_MESSAGES_IN_THREADS"
|
||||||
|
| "SEND_VOICE_MESSAGES"
|
||||||
| "CONNECT"
|
| "CONNECT"
|
||||||
| "SPEAK"
|
| "SPEAK"
|
||||||
| "MUTE_MEMBERS"
|
| "MUTE_MEMBERS"
|
||||||
@ -125,8 +127,11 @@ export type Permissions = "CREATE_INSTANT_INVITE"
|
|||||||
| "PRIORITY_SPEAKER"
|
| "PRIORITY_SPEAKER"
|
||||||
| "STREAM"
|
| "STREAM"
|
||||||
| "USE_EMBEDDED_ACTIVITIES"
|
| "USE_EMBEDDED_ACTIVITIES"
|
||||||
|
| "USE_SOUNDBOARD"
|
||||||
|
| "USE_EXTERNAL_SOUNDS"
|
||||||
| "REQUEST_TO_SPEAK"
|
| "REQUEST_TO_SPEAK"
|
||||||
| "MANAGE_EVENTS";
|
| "MANAGE_EVENTS"
|
||||||
|
| "CREATE_EVENTS";
|
||||||
|
|
||||||
export type PermissionsBits = Record<Permissions, bigint>;
|
export type PermissionsBits = Record<Permissions, bigint>;
|
||||||
|
|
||||||
|
Reference in New Issue
Block a user