Plugin

VoiceMessages

Allows you to send voice messages like on mobile. To do so, right click the upload button and click Send Voice Message

Voice
index.tsx
Download

Source

src/plugins/voiceMessages/index.tsx
1import "./styles.css";
2
3import { NavContextMenuPatchCallback } from "@api/ContextMenu";
4import { Card } from "@components/Card";
5import { Microphone } from "@components/Icons";
6import { Link } from "@components/Link";
7import { Paragraph } from "@components/Paragraph";
8import { Devs } from "@utils/constants";
9import { classNameFactory } from "@utils/css";
10import { Margins } from "@utils/margins";
11import { useAwaiter } from "@utils/react";
12import definePlugin from "@utils/types";
13import { chooseFile } from "@utils/web";
14import { CloudUpload as TCloudUpload, RenderModalProps } from "@vencord/discord-types";
15import { CloudUploadPlatform } from "@vencord/discord-types/enums";
16import { findLazy } from "@webpack";
17import { Button, Constants, FluxDispatcher, Forms, lodash, Menu, MessageActions, Modal,openModal, PendingReplyStore, PermissionsBits, PermissionStore, RestAPI, SelectedChannelStore, showToast, SnowflakeUtils, Toasts, useEffect, useState } from "@webpack/common";
18import { ComponentType } from "react";
19
20import { VoiceRecorderDesktop } from "./DesktopRecorder";
21import { settings } from "./settings";
22import { VoicePreview } from "./VoicePreview";
23import { VoiceRecorderWeb } from "./WebRecorder";
24
25const CloudUpload: typeof TCloudUpload = findLazy(m => m.prototype?.trackUploadFinished);
26
27export const cl = classNameFactory("vc-vmsg-");
28export type VoiceRecorder = ComponentType<{
29 setAudioBlob(blob: Blob): void;
30 onRecordingChange?(recording: boolean): void;
31}>;
32
33export interface VoiceMessageProps {
34 src: string;
35 waveform: string;
36}
37export let VoiceMessage: ComponentType<VoiceMessageProps> = () => null;
38
39const VoiceRecorder = IS_DISCORD_DESKTOP ? VoiceRecorderDesktop : VoiceRecorderWeb;
40
41const ctxMenuPatch: NavContextMenuPatchCallback = (children, props) => {
42 if (props.channel.guild_id && !(PermissionStore.can(PermissionsBits.SEND_VOICE_MESSAGES, props.channel) && PermissionStore.can(PermissionsBits.SEND_MESSAGES, props.channel))) return;
43
44 children.push(
45 <Menu.MenuItem
46 id="vc-send-vmsg"
47 iconLeft={Microphone}
48 leadingAccessory={{
49 type: "icon",
50 icon: Microphone
51 }}
52 label="Send Voice Message"
53 action={() => openModal(modalProps => <VoiceMessageModal modalProps={modalProps} />)}
54 />
55 );
56};
57
58export default definePlugin({
59 name: "VoiceMessages",
60 description: "Allows you to send voice messages like on mobile. To do so, right click the upload button and click Send Voice Message",
61 tags: ["Voice"],
62 authors: [Devs.Ven, Devs.Vap, Devs.Nickyux],
63 settings,
64
65 patches: [
66 {
67 find: "#{intl::PAUSE_VOICE_MESSAGE_A11Y_LABEL}",
68 replacement: {
69 match: /(?<=\i=)(?=\i\.memo\(.{0,50}?=1,onVolumeChange:[^}]+?waveform:[^}]+?playbackCacheKey:)/,
70 replace: "$self.VoiceMessage=",
71 }
72 }
73 ],
74
75 set VoiceMessage(value) {
76 VoiceMessage = value;
77 },
78
79 contextMenus: {
80 "channel-attach": ctxMenuPatch
81 }
82});
83
84type AudioMetadata = {
85 waveform: string,
86 duration: number,
87};
88const EMPTY_META: AudioMetadata = {
89 waveform: "AAAAAAAAAAAA",
90 duration: 1,
91};
92
93function sendAudio(blob: Blob, meta: AudioMetadata) {
94 const channelId = SelectedChannelStore.getChannelId();
95 const reply = PendingReplyStore.getPendingReply(channelId);
96 if (reply) FluxDispatcher.dispatch({ type: "DELETE_PENDING_REPLY", channelId });
97
98 const upload = new CloudUpload({
99 file: new File([blob], "voice-message.ogg", { type: "audio/ogg; codecs=opus" }),
100 isThumbnail: false,
101 platform: CloudUploadPlatform.WEB,
102 }, channelId);
103
104 upload.on("complete", () => {
105 RestAPI.post({
106 url: Constants.Endpoints.MESSAGES(channelId),
107 body: {
108 flags: 1 << 13,
109 channel_id: channelId,
110 content: "",
111 nonce: SnowflakeUtils.fromTimestamp(Date.now()),
112 sticker_ids: [],
113 type: 0,
114 attachments: [{
115 id: "0",
116 filename: upload.filename,
117 uploaded_filename: upload.uploadedFilename,
118 waveform: meta.waveform,
119 duration_secs: meta.duration,
120 }],
121 message_reference: reply ? MessageActions.getSendMessageOptionsForReply(reply)?.messageReference : null,
122 }
123 });
124 });
125 upload.on("error", () => showToast("Failed to upload voice message", Toasts.Type.FAILURE));
126
127 upload.upload();
128}
129
130function useObjectUrl() {
131 const [url, setUrl] = useState<string>();
132 const setWithFree = (blob: Blob) => {
133 if (url)
134 URL.revokeObjectURL(url);
135 setUrl(URL.createObjectURL(blob));
136 };
137
138 return [url, setWithFree] as const;
139}
140
141function VoiceMessageModal({ modalProps }: { modalProps: RenderModalProps; }) {
142 const [isRecording, setRecording] = useState(false);
143 const [blob, setBlob] = useState<Blob>();
144 const [blobUrl, setBlobUrl] = useObjectUrl();
145
146 useEffect(() => () => {
147 if (blobUrl)
148 URL.revokeObjectURL(blobUrl);
149 }, [blobUrl]);
150
151 const [meta, metaError] = useAwaiter(async () => {
152 if (!blob) return EMPTY_META;
153
154 const audioContext = new AudioContext();
155 const audioBuffer = await audioContext.decodeAudioData(await blob.arrayBuffer());
156 const channelData = audioBuffer.getChannelData(0);
157
158 // average the samples into much lower resolution bins, maximum of 256 total bins
159 const bins = new Uint8Array(lodash.clamp(Math.floor(audioBuffer.duration * 10), Math.min(32, channelData.length), 256));
160 const samplesPerBin = Math.floor(channelData.length / bins.length);
161
162 // Get root mean square of each bin
163 for (let binIdx = 0; binIdx < bins.length; binIdx++) {
164 let squares = 0;
165 for (let sampleOffset = 0; sampleOffset < samplesPerBin; sampleOffset++) {
166 const sampleIdx = binIdx * samplesPerBin + sampleOffset;
167 squares += channelData[sampleIdx] ** 2;
168 }
169 bins[binIdx] = ~~(Math.sqrt(squares / samplesPerBin) * 0xFF);
170 }
171
172 // Normalize bins with easing
173 const maxBin = Math.max(...bins);
174 const ratio = 1 + (0xFF / maxBin - 1) * Math.min(1, 100 * (maxBin / 0xFF) ** 3);
175 for (let i = 0; i < bins.length; i++) bins[i] = Math.min(0xFF, ~~(bins[i] * ratio));
176
177 return {
178 waveform: window.btoa(String.fromCharCode(...bins)),
179 duration: audioBuffer.duration,
180 };
181 }, {
182 deps: [blob],
183 fallbackValue: EMPTY_META,
184 });
185
186 const isUnsupportedFormat = blob && (
187 !blob.type.startsWith("audio/ogg")
188 || blob.type.includes("codecs") && !blob.type.includes("opus")
189 );
190
191 return (
192 <Modal
193 {...modalProps}
194 title="Record Voice Message"
195 actions={[{
196 text: "Send",
197 variant: "primary",
198 onClick: () => {
199 sendAudio(blob!, meta ?? EMPTY_META);
200 modalProps.onClose();
201 showToast("Now sending voice message... Please be patient", Toasts.Type.MESSAGE);
202 },
203 disabled: !blob
204 }]}
205 >
206 <div className={cl("buttons")}>
207 <VoiceRecorder
208 setAudioBlob={blob => {
209 setBlob(blob);
210 setBlobUrl(blob);
211 }}
212 onRecordingChange={setRecording}
213 />
214
215 <Button
216 onClick={async () => {
217 const file = await chooseFile("audio/*");
218 if (file) {
219 setBlob(file);
220 setBlobUrl(file);
221 }
222 }}
223 >
224 Upload File
225 </Button>
226 </div>
227
228 <Forms.FormTitle>Preview</Forms.FormTitle>
229 {metaError
230 ? <Paragraph className={cl("error")}>Failed to parse selected audio file: {metaError.message}</Paragraph>
231 : (
232 <VoicePreview
233 src={blobUrl}
234 waveform={meta.waveform}
235 recording={isRecording}
236 />
237 )}
238
239 {isUnsupportedFormat && (
240 <Card variant="warning" className={Margins.top16} defaultPadding>
241 <Forms.FormText>Voice Messages have to be OggOpus to be playable on iOS. This file is <code>{blob.type}</code> so it will not be playable on iOS.</Forms.FormText>
242
243 <Forms.FormText className={Margins.top8}>
244 To fix it, first convert it to OggOpus, for example using the <Link href="https:class="ts-cmt">//convertio.co/mp3-opus/">convertio web converter</Link>
245 </Forms.FormText>
246 </Card>
247 )}
248 </Modal>
249 );
250}
251