From 8620a1d86d302c23d51c1f6795f32cc8eb5e9207 Mon Sep 17 00:00:00 2001 From: V Date: Wed, 26 Jul 2023 01:27:04 +0200 Subject: [PATCH] New plugin: VoiceMessages (#1380) Co-authored-by: V Co-authored-by: Justice Almanzar --- src/VencordNative.ts | 3 + src/components/Icons.tsx | 13 + src/main/ipcPlugins.ts | 18 +- src/plugins/callTimer.tsx | 16 +- src/plugins/voiceMessages/DesktopRecorder.tsx | 68 +++++ src/plugins/voiceMessages/VoicePreview.tsx | 57 +++++ src/plugins/voiceMessages/WebRecorder.tsx | 87 +++++++ src/plugins/voiceMessages/index.tsx | 235 ++++++++++++++++++ src/plugins/voiceMessages/settings.ts | 33 +++ src/plugins/voiceMessages/styles.css | 54 ++++ src/plugins/voiceMessages/utils.ts | 21 ++ src/utils/IpcEvents.ts | 1 + src/utils/react.tsx | 23 +- src/utils/settingsSync.ts | 36 +-- src/utils/web.ts | 25 ++ src/webpack/common/types/utils.d.ts | 7 +- 16 files changed, 660 insertions(+), 37 deletions(-) create mode 100644 src/plugins/voiceMessages/DesktopRecorder.tsx create mode 100644 src/plugins/voiceMessages/VoicePreview.tsx create mode 100644 src/plugins/voiceMessages/WebRecorder.tsx create mode 100644 src/plugins/voiceMessages/index.tsx create mode 100644 src/plugins/voiceMessages/settings.ts create mode 100644 src/plugins/voiceMessages/styles.css create mode 100644 src/plugins/voiceMessages/utils.ts diff --git a/src/VencordNative.ts b/src/VencordNative.ts index a7c16ef63..4e34f3d49 100644 --- a/src/VencordNative.ts +++ b/src/VencordNative.ts @@ -63,5 +63,8 @@ export default { OpenInApp: { resolveRedirect: (url: string) => invoke(IpcEvents.OPEN_IN_APP__RESOLVE_REDIRECT, url), }, + VoiceMessages: { + readRecording: () => invoke(IpcEvents.VOICE_MESSAGES_READ_RECORDING), + } } }; diff --git a/src/components/Icons.tsx b/src/components/Icons.tsx index 96df3dc7f..a24045efd 100644 --- a/src/components/Icons.tsx +++ b/src/components/Icons.tsx @@ -190,3 +190,16 @@ export function ImageInvisible(props: IconProps) { ); } + +export function Microphone(props: IconProps) { + return ( + + + + + ); +} diff --git a/src/main/ipcPlugins.ts b/src/main/ipcPlugins.ts index ac2f3d771..7ab4c25fc 100644 --- a/src/main/ipcPlugins.ts +++ b/src/main/ipcPlugins.ts @@ -17,8 +17,10 @@ */ import { IpcEvents } from "@utils/IpcEvents"; -import { ipcMain } from "electron"; +import { app, ipcMain } from "electron"; +import { readFile } from "fs/promises"; import { request } from "https"; +import { join } from "path"; // #region OpenInApp // These links don't support CORS, so this has to be native @@ -44,3 +46,17 @@ ipcMain.handle(IpcEvents.OPEN_IN_APP__RESOLVE_REDIRECT, async (_, url: string) = return getRedirect(url); }); // #endregion + + +// #region VoiceMessages +ipcMain.handle(IpcEvents.VOICE_MESSAGES_READ_RECORDING, async () => { + const path = join(app.getPath("userData"), "module_data/discord_voice/recording.ogg"); + try { + const buf = await readFile(path); + return new Uint8Array(buf.buffer); + } catch { + return null; + } +}); + +// #endregion diff --git a/src/plugins/callTimer.tsx b/src/plugins/callTimer.tsx index 9490eba0b..2e0aa9650 100644 --- a/src/plugins/callTimer.tsx +++ b/src/plugins/callTimer.tsx @@ -19,6 +19,7 @@ import { Settings } from "@api/Settings"; import ErrorBoundary from "@components/ErrorBoundary"; import { Devs } from "@utils/constants"; +import { useTimer } from "@utils/react"; import definePlugin, { OptionType } from "@utils/types"; import { React } from "@webpack/common"; @@ -85,17 +86,10 @@ export default definePlugin({ }, Timer({ channelId }: { channelId: string; }) { - const [time, setTime] = React.useState(0); - const startTime = React.useMemo(() => Date.now(), [channelId]); + const time = useTimer({ + deps: [channelId] + }); - React.useEffect(() => { - const interval = setInterval(() => setTime(Date.now() - startTime), 1000); - return () => { - clearInterval(interval); - setTime(0); - }; - }, [channelId]); - - return

Connected for {formatDuration(time)}

; + return

Connected for {formatDuration(time)}

; } }); diff --git a/src/plugins/voiceMessages/DesktopRecorder.tsx b/src/plugins/voiceMessages/DesktopRecorder.tsx new file mode 100644 index 000000000..176faf3cc --- /dev/null +++ b/src/plugins/voiceMessages/DesktopRecorder.tsx @@ -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 . +*/ + +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 ( + + ); +}; diff --git a/src/plugins/voiceMessages/VoicePreview.tsx b/src/plugins/voiceMessages/VoicePreview.tsx new file mode 100644 index 000000000..70830a920 --- /dev/null +++ b/src/plugins/voiceMessages/VoicePreview.tsx @@ -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 . +*/ + +import { LazyComponent, useTimer } from "@utils/react"; +import { findByCode } from "@webpack"; + +import { cl } from "./utils"; + +interface VoiceMessageProps { + src: string; + waveform: string; +} +const VoiceMessage = LazyComponent(() => 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 ; + + return ( +
+
+
{durationDisplay}
+
{recording ? "RECORDING" : "----"}
+
+ ); +}; diff --git a/src/plugins/voiceMessages/WebRecorder.tsx b/src/plugins/voiceMessages/WebRecorder.tsx new file mode 100644 index 000000000..423a2699a --- /dev/null +++ b/src/plugins/voiceMessages/WebRecorder.tsx @@ -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 . +*/ + +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(); + const [chunks, setChunks] = useState([]); + + 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 ( + <> + + + + + ); +}; diff --git a/src/plugins/voiceMessages/index.tsx b/src/plugins/voiceMessages/index.tsx new file mode 100644 index 000000000..be5a3f381 --- /dev/null +++ b/src/plugins/voiceMessages/index.tsx @@ -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 . +*/ + +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(); + 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(); + 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 ( + + + Record Voice Message + + + +
+ { + setBlob(blob); + setBlobUrl(blob); + }} + onRecordingChange={setRecording} + /> + + +
+ + Preview + + +
+ + + + +
+ ); +} + +const ctxMenuPatch: NavContextMenuPatchCallback = (children, props) => () => { + if (props.channel.guild_id && !PermissionStore.can(PermissionsBits.SEND_VOICE_MESSAGES, props.channel)) return; + + children.push( + + + + Send voice message + + + } + action={() => openModal(modalProps => )} + /> + ); +}; + diff --git a/src/plugins/voiceMessages/settings.ts b/src/plugins/voiceMessages/settings.ts new file mode 100644 index 000000000..7e34817b0 --- /dev/null +++ b/src/plugins/voiceMessages/settings.ts @@ -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 . +*/ + +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, + }, +}); diff --git a/src/plugins/voiceMessages/styles.css b/src/plugins/voiceMessages/styles.css new file mode 100644 index 000000000..1e2b1433a --- /dev/null +++ b/src/plugins/voiceMessages/styles.css @@ -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; +} diff --git a/src/plugins/voiceMessages/utils.ts b/src/plugins/voiceMessages/utils.ts new file mode 100644 index 000000000..dcfd6f26c --- /dev/null +++ b/src/plugins/voiceMessages/utils.ts @@ -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 . +*/ + +import { classNameFactory } from "@api/Styles"; + +export const cl = classNameFactory("vc-vmsg-"); diff --git a/src/utils/IpcEvents.ts b/src/utils/IpcEvents.ts index 41d40a73d..6994c91eb 100644 --- a/src/utils/IpcEvents.ts +++ b/src/utils/IpcEvents.ts @@ -32,4 +32,5 @@ export const enum IpcEvents { OPEN_MONACO_EDITOR = "VencordOpenMonacoEditor", OPEN_IN_APP__RESOLVE_REDIRECT = "VencordOIAResolveRedirect", + VOICE_MESSAGES_READ_RECORDING = "VencordVMReadRecording", } diff --git a/src/utils/react.tsx b/src/utils/react.tsx index a4c71528c..e8c108153 100644 --- a/src/utils/react.tsx +++ b/src/utils/react.tsx @@ -16,7 +16,7 @@ * along with this program. If not, see . */ -import { React, useEffect, useReducer, useState } from "@webpack/common"; +import { React, useEffect, useMemo, useReducer, useState } from "@webpack/common"; import { makeLazy } from "./lazy"; import { checkIntersecting } from "./misc"; @@ -135,3 +135,24 @@ export function LazyComponent(factory: () => React.Compo return ; }; } + +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; +} diff --git a/src/utils/settingsSync.ts b/src/utils/settingsSync.ts index 72c876f63..8766cbbbb 100644 --- a/src/utils/settingsSync.ts +++ b/src/utils/settingsSync.ts @@ -23,7 +23,7 @@ import { deflateSync, inflateSync } from "fflate"; import { getCloudAuth, getCloudUrl } from "./cloud"; import { Logger } from "./Logger"; -import { saveFile } from "./web"; +import { chooseFile, saveFile } from "./web"; export async function importSettings(data: string) { try { @@ -91,30 +91,20 @@ export async function uploadSettingsBackup(showToast = true): Promise { } } } else { - const input = document.createElement("input"); - input.type = "file"; - input.style.display = "none"; - input.accept = "application/json"; - input.onchange = async () => { - const file = input.files?.[0]; - if (!file) return; + const file = await chooseFile("application/json"); + if (!file) return; - const reader = new FileReader(); - reader.onload = async () => { - try { - await importSettings(reader.result as string); - if (showToast) toastSuccess(); - } catch (err) { - new Logger("SettingsSync").error(err); - if (showToast) toastFailure(err); - } - }; - reader.readAsText(file); + const reader = new FileReader(); + reader.onload = async () => { + try { + await importSettings(reader.result as string); + if (showToast) toastSuccess(); + } catch (err) { + new Logger("SettingsSync").error(err); + if (showToast) toastFailure(err); + } }; - - document.body.appendChild(input); - input.click(); - setImmediate(() => document.body.removeChild(input)); + reader.readAsText(file); } } diff --git a/src/utils/web.ts b/src/utils/web.ts index 9cfe71847..5c46aec04 100644 --- a/src/utils/web.ts +++ b/src/utils/web.ts @@ -16,6 +16,10 @@ * along with this program. If not, see . */ +/** + * Prompts the user to save a file to their system + * @param file The file to save + */ export function saveFile(file: File) { const a = document.createElement("a"); a.href = URL.createObjectURL(file); @@ -28,3 +32,24 @@ export function saveFile(file: File) { document.body.removeChild(a); }); } + +/** + * Prompts the user to choose a file from their system + * @param mimeTypes A comma separated list of mime types to accept, see https://developer.mozilla.org/en-US/docs/Web/HTML/Attributes/accept#unique_file_type_specifiers + * @returns A promise that resolves to the chosen file or null if the user cancels + */ +export function chooseFile(mimeTypes: string) { + return new Promise(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)); + }); +} diff --git a/src/webpack/common/types/utils.d.ts b/src/webpack/common/types/utils.d.ts index 51b3ceee1..7eb5711ae 100644 --- a/src/webpack/common/types/utils.d.ts +++ b/src/webpack/common/types/utils.d.ts @@ -96,6 +96,7 @@ export type Permissions = "CREATE_INSTANT_INVITE" | "MANAGE_ROLES" | "MANAGE_WEBHOOKS" | "MANAGE_GUILD_EXPRESSIONS" + | "CREATE_GUILD_EXPRESSIONS" | "VIEW_AUDIT_LOG" | "VIEW_CHANNEL" | "VIEW_GUILD_ANALYTICS" @@ -116,6 +117,7 @@ export type Permissions = "CREATE_INSTANT_INVITE" | "CREATE_PRIVATE_THREADS" | "USE_EXTERNAL_STICKERS" | "SEND_MESSAGES_IN_THREADS" + | "SEND_VOICE_MESSAGES" | "CONNECT" | "SPEAK" | "MUTE_MEMBERS" @@ -125,8 +127,11 @@ export type Permissions = "CREATE_INSTANT_INVITE" | "PRIORITY_SPEAKER" | "STREAM" | "USE_EMBEDDED_ACTIVITIES" + | "USE_SOUNDBOARD" + | "USE_EXTERNAL_SOUNDS" | "REQUEST_TO_SPEAK" - | "MANAGE_EVENTS"; + | "MANAGE_EVENTS" + | "CREATE_EVENTS"; export type PermissionsBits = Record;