Plugin
VoiceMessages
Allows you to send voice messages like on mobile. To do so, right click the upload button and click Send Voice Message
1
/*2
* FiveCord — a Discord client mod3
* Copyright (c) 2025 FiveCord4
* SPDX-License-Identifier: GPL-3.0-or-later5
*/6
7
import "./styles.css";8
9
import { NavContextMenuPatchCallback } from "@api/ContextMenu";10
import { Card } from "@components/Card";11
import { Microphone } from "@components/Icons";12
import { Link } from "@components/Link";13
import { Paragraph } from "@components/Paragraph";14
import { Devs } from "@utils/constants";15
import { classNameFactory } from "@utils/css";16
import { Margins } from "@utils/margins";17
import { useAwaiter } from "@utils/react";18
import definePlugin from "@utils/types";19
import { chooseFile } from "@utils/web";20
import { CloudUpload as TCloudUpload, RenderModalProps } from "@vencord/discord-types";21
import { CloudUploadPlatform } from "@vencord/discord-types/enums";22
import { findLazy } from "@webpack";23
import { Button, Constants, FluxDispatcher, Forms, lodash, Menu, MessageActions, Modal,openModal, PendingReplyStore, PermissionsBits, PermissionStore, RestAPI, SelectedChannelStore, showToast, SnowflakeUtils, Toasts, useEffect, useState } from "@webpack/common";24
import { ComponentType } from "react";25
26
import { VoiceRecorderDesktop } from "./DesktopRecorder";27
import { settings } from "./settings";28
import { VoicePreview } from "./VoicePreview";29
import { VoiceRecorderWeb } from "./WebRecorder";30
31
const CloudUpload: typeof TCloudUpload = findLazy(m => m.prototype?.trackUploadFinished);32
33
export const cl = classNameFactory("vc-vmsg-");34
export type VoiceRecorder = ComponentType<{35
setAudioBlob(blob: Blob): void;36
onRecordingChange?(recording: boolean): void;37
}>;38
39
export interface VoiceMessageProps {40
src: string;41
waveform: string;42
}43
export let VoiceMessage: ComponentType<VoiceMessageProps> = () => null;44
45
const VoiceRecorder = IS_DISCORD_DESKTOP ? VoiceRecorderDesktop : VoiceRecorderWeb;46
47
const 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.MenuItem52
id="vc-send-vmsg"53
iconLeft={Microphone}54
leadingAccessory={{55
type: "icon",56
icon: Microphone57
}}58
label="Send Voice Message"59
action={() => openModal(modalProps => <VoiceMessageModal modalProps={modalProps} />)}60
/>61
);62
};63
64
export 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": ctxMenuPatch87
}88
});89
90
type AudioMetadata = {91
waveform: string,92
duration: number,93
};94
const EMPTY_META: AudioMetadata = {95
waveform: "AAAAAAAAAAAA",96
duration: 1,97
};98
99
function 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
136
function 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
147
function 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 bins165
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 bin169
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 easing179
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
<Modal199
{...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: !blob210
}]}211
>212
<div className={cl("buttons")}>213
<VoiceRecorder214
setAudioBlob={blob => {215
setBlob(blob);216
setBlobUrl(blob);217
}}218
onRecordingChange={setRecording}219
/>220
221
<Button222
onClick={async () => {223
const file = await chooseFile("audio/*");224
if (file) {225
setBlob(file);226
setBlobUrl(file);227
}228
}}229
>230
Upload File231
</Button>232
</div>233
234
<Forms.FormTitle>Preview</Forms.FormTitle>235
{metaError236
? <Paragraph className={cl("error")}>Failed to parse selected audio file: {metaError.message}</Paragraph>237
: (238
<VoicePreview239
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