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