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
import "./styles.css";2
3
import { NavContextMenuPatchCallback } from "@api/ContextMenu";4
import { Card } from "@components/Card";5
import { Microphone } from "@components/Icons";6
import { Link } from "@components/Link";7
import { Paragraph } from "@components/Paragraph";8
import { Devs } from "@utils/constants";9
import { classNameFactory } from "@utils/css";10
import { Margins } from "@utils/margins";11
import { useAwaiter } from "@utils/react";12
import definePlugin from "@utils/types";13
import { chooseFile } from "@utils/web";14
import { CloudUpload as TCloudUpload, RenderModalProps } from "@vencord/discord-types";15
import { CloudUploadPlatform } from "@vencord/discord-types/enums";16
import { findLazy } from "@webpack";17
import { Button, Constants, FluxDispatcher, Forms, lodash, Menu, MessageActions, Modal,openModal, PendingReplyStore, PermissionsBits, PermissionStore, RestAPI, SelectedChannelStore, showToast, SnowflakeUtils, Toasts, useEffect, useState } from "@webpack/common";18
import { ComponentType } from "react";19
20
import { VoiceRecorderDesktop } from "./DesktopRecorder";21
import { settings } from "./settings";22
import { VoicePreview } from "./VoicePreview";23
import { VoiceRecorderWeb } from "./WebRecorder";24
25
const CloudUpload: typeof TCloudUpload = findLazy(m => m.prototype?.trackUploadFinished);26
27
export const cl = classNameFactory("vc-vmsg-");28
export type VoiceRecorder = ComponentType<{29
setAudioBlob(blob: Blob): void;30
onRecordingChange?(recording: boolean): void;31
}>;32
33
export interface VoiceMessageProps {34
src: string;35
waveform: string;36
}37
export let VoiceMessage: ComponentType<VoiceMessageProps> = () => null;38
39
const VoiceRecorder = IS_DISCORD_DESKTOP ? VoiceRecorderDesktop : VoiceRecorderWeb;40
41
const 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.MenuItem46
id="vc-send-vmsg"47
iconLeft={Microphone}48
leadingAccessory={{49
type: "icon",50
icon: Microphone51
}}52
label="Send Voice Message"53
action={() => openModal(modalProps => <VoiceMessageModal modalProps={modalProps} />)}54
/>55
);56
};57
58
export 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": ctxMenuPatch81
}82
});83
84
type AudioMetadata = {85
waveform: string,86
duration: number,87
};88
const EMPTY_META: AudioMetadata = {89
waveform: "AAAAAAAAAAAA",90
duration: 1,91
};92
93
function 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
130
function 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
141
function 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 bins159
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 bin163
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 easing173
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
<Modal193
{...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: !blob204
}]}205
>206
<div className={cl("buttons")}>207
<VoiceRecorder208
setAudioBlob={blob => {209
setBlob(blob);210
setBlobUrl(blob);211
}}212
onRecordingChange={setRecording}213
/>214
215
<Button216
onClick={async () => {217
const file = await chooseFile("audio/*");218
if (file) {219
setBlob(file);220
setBlobUrl(file);221
}222
}}223
>224
Upload File225
</Button>226
</div>227
228
<Forms.FormTitle>Preview</Forms.FormTitle>229
{metaError230
? <Paragraph className={cl("error")}>Failed to parse selected audio file: {metaError.message}</Paragraph>231
: (232
<VoicePreview233
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