feat: Ability to send voice message to channel (#4064)

Created the possibility to send audio as an attachment via the dashboard.
The channels that can send audio are the same channels that can send any type of attachment.
Used RecordRTC (https://github.com/muaz-khan/RecordRTC) to capture the audio and Wavesurfer (https://github.com/katspaugh/wavesurfer.js) to display the audio waves.
RecordRTC can be used to record videos if necessary.

Fixes #1973
This commit is contained in:
giquieu 2022-03-04 11:13:07 -03:00 committed by GitHub
parent b94e67f5d7
commit 96b719017b
No known key found for this signature in database
GPG key ID: 4AEE18F83AFDEB23
8 changed files with 372 additions and 5 deletions

View file

@ -25,6 +25,7 @@
</div>
<div class="remove-file-wrap">
<woot-button
v-if="!isTypeAudio(attachment.resource)"
class="remove--attachment clear secondary"
icon="dismiss"
@click="() => onRemoveAttachment(index)"
@ -58,6 +59,10 @@ export default {
const type = file.content_type || file.type;
return type.includes('image');
},
isTypeAudio(file) {
const type = file.content_type || file.type;
return type.includes('audio');
},
fileName(file) {
return file.filename || file.name;
},

View file

@ -0,0 +1,223 @@
<template>
<div class="audio-wave-wrapper">
<div id="audio-wave"></div>
</div>
</template>
<script>
import WaveSurfer from 'wavesurfer.js';
import MicrophonePlugin from 'wavesurfer.js/dist/plugin/wavesurfer.microphone.js';
import RecordRTC from 'recordrtc';
import inboxMixin from '../../../../shared/mixins/inboxMixin';
import alertMixin from '../../../../shared/mixins/alertMixin';
WaveSurfer.microphone = MicrophonePlugin;
export default {
name: 'WootAudioRecorder',
mixins: [inboxMixin, alertMixin],
data() {
return {
wavesurfer: false,
recorder: false,
recordingInterval: false,
recordingDateStarted: new Date().getTime(),
timeDuration: '00:00',
initialTimeDuration: '00:00',
options: {
container: '#audio-wave',
backend: 'WebAudio',
interact: true,
cursorWidth: 1,
plugins: [
WaveSurfer.microphone.create({
bufferSize: 4096,
numberOfInputChannels: 1,
numberOfOutputChannels: 1,
constraints: {
video: false,
audio: true,
},
}),
],
},
optionsRecorder: {
type: 'audio',
mimeType: 'audio/wav',
disableLogs: true,
recorderType: RecordRTC.StereoAudioRecorder,
sampleRate: 44100,
numberOfAudioChannels: 2,
checkForInactiveTracks: true,
bufferSize: 4096,
},
};
},
computed: {
isRecording() {
if (this.recorder) {
return this.recorder.getState() === 'recording';
}
return false;
},
},
mounted() {
this.wavesurfer = WaveSurfer.create(this.options);
this.wavesurfer.on('play', this.playingRecorder);
this.wavesurfer.on('pause', this.pausedRecorder);
this.wavesurfer.microphone.on('deviceReady', this.startRecording);
this.wavesurfer.microphone.on('deviceError', this.deviceError);
this.wavesurfer.microphone.start();
this.fireStateRecorderTimerChanged(this.initialTimeDuration);
},
beforeDestroy() {
if (this.recorder) {
this.recorder.destroy();
}
if (this.wavesurfer) {
this.wavesurfer.destroy();
}
},
methods: {
startRecording(stream) {
this.recorder = RecordRTC(stream, this.optionsRecorder);
this.recorder.onStateChanged = this.onStateRecorderChanged;
this.recorder.startRecording();
},
stopAudioRecording() {
if (this.isRecording) {
this.recorder.stopRecording(() => {
this.wavesurfer.microphone.stopDevice();
this.wavesurfer.loadBlob(this.recorder.getBlob());
this.wavesurfer.stop();
this.fireRecorderBlob(this.getAudioFile());
});
}
},
getAudioFile() {
if (this.hasAudio()) {
return new File([this.recorder.getBlob()], this.getAudioFileName(), {
type: 'audio/wav',
});
}
return false;
},
hasAudio() {
return !(this.isRecording || this.wavesurfer.isPlaying());
},
playingRecorder() {
this.fireStateRecorderChanged('playing');
},
pausedRecorder() {
this.fireStateRecorderChanged('paused');
},
deviceError(err) {
if (
err?.name &&
(err.name.toLowerCase().includes('notallowederror') ||
err.name.toLowerCase().includes('permissiondeniederror'))
) {
this.showAlert(
this.$t('CONVERSATION.REPLYBOX.TIP_AUDIORECORDER_PERMISSION')
);
this.fireStateRecorderChanged('notallowederror');
} else {
this.showAlert(
this.$t('CONVERSATION.REPLYBOX.TIP_AUDIORECORDER_ERROR')
);
}
},
onStateRecorderChanged(state) {
// recording stopped inactive destroyed
switch (state) {
case 'recording':
this.timerDurationChanged();
break;
case 'stopped':
this.timerDurationChanged();
break;
default:
break;
}
this.fireStateRecorderChanged(state);
},
timerDurationChanged() {
if (this.isRecording) {
this.recordingInterval = setInterval(() => {
this.calculateTimeDuration(
(new Date().getTime() - this.recordingDateStarted) / 1000
);
this.fireStateRecorderTimerChanged(this.timeDuration);
}, 1000);
} else {
clearInterval(this.recordingInterval);
}
},
calculateTimeDuration(secs) {
let hr = Math.floor(secs / 3600);
let min = Math.floor((secs - hr * 3600) / 60);
let sec = Math.floor(secs - hr * 3600 - min * 60);
if (min < 10) {
min = '0' + min;
}
if (sec < 10) {
sec = '0' + sec;
}
if (hr <= 0) {
this.timeDuration = min + ':' + sec;
} else {
if (hr < 10) {
hr = '0' + hr;
}
this.timeDuration = hr + ':' + min + ':' + sec;
}
},
playPause() {
this.wavesurfer.playPause();
},
fireRecorderBlob(blob) {
this.$emit('recorder-blob', {
name: blob.name,
type: blob.type,
size: blob.size,
file: blob,
});
},
fireStateRecorderChanged(state) {
this.$emit('state-recorder-changed', state);
},
fireStateRecorderTimerChanged(duration) {
this.$emit('state-recorder-timer-changed', duration);
},
getAudioFileName() {
const d = new Date();
return `audio-${d.getFullYear()}-${d.getMonth()}-${d.getDate()}-${this.getRandomString()}.wav`;
},
getRandomString() {
if (
window.crypto &&
window.crypto.getRandomValues &&
navigator.userAgent.indexOf('Safari') === -1
) {
let a = window.crypto.getRandomValues(new Uint32Array(3));
let token = '';
for (let i = 0, l = a.length; i < l; i += 1) {
token += a[i].toString(36);
}
return token.toLowerCase();
}
return (Math.random() * new Date().getTime())
.toString(36)
.replace(/\./g, '');
},
},
};
</script>
<style lang="scss">
.audio-wave-wrapper {
min-height: 8rem;
max-height: 12rem;
overflow: hidden;
}
</style>

View file

@ -11,7 +11,6 @@
size="small"
@click="toggleEmojiPicker"
/>
<!-- ensure the same validations for attachment types are implemented in backend models as well -->
<file-upload
ref="upload"
@ -49,6 +48,27 @@
:title="$t('CONVERSATION.REPLYBOX.TIP_FORMAT_ICON')"
@click="toggleFormatMode"
/>
<woot-button
v-if="showAudioRecorderButton"
:icon="!isRecordingAudio ? 'microphone' : 'microphone-off'"
emoji="🎤"
:color-scheme="!isRecordingAudio ? 'secondary' : 'alert'"
variant="smooth"
size="small"
:title="$t('CONVERSATION.REPLYBOX.TIP_AUDIORECORDER_ICON')"
@click="toggleAudioRecorder"
/>
<woot-button
v-if="showAudioPlayStopButton"
:icon="audioRecorderPlayStopIcon"
emoji="🎤"
color-scheme="secondary"
variant="smooth"
size="small"
@click="toggleAudioRecorderPlayPause"
>
<span>{{ recordingAudioDurationText }}</span>
</woot-button>
<woot-button
v-if="showMessageSignatureButton"
v-tooltip.top-end="signatureToggleTooltip"
@ -126,6 +146,10 @@ export default {
type: String,
default: '',
},
recordingAudioDurationText: {
type: String,
default: '',
},
inbox: {
type: Object,
default: () => ({}),
@ -134,6 +158,10 @@ export default {
type: Boolean,
default: false,
},
showAudioRecorder: {
type: Boolean,
default: false,
},
onFileUpload: {
type: Function,
default: () => {},
@ -146,6 +174,22 @@ export default {
type: Function,
default: () => {},
},
toggleAudioRecorder: {
type: Function,
default: () => {},
},
toggleAudioRecorderPlayPause: {
type: Function,
default: () => {},
},
isRecordingAudio: {
type: Boolean,
default: false,
},
recordingAudioState: {
type: String,
default: '',
},
isSendDisabled: {
type: Boolean,
default: false,
@ -192,9 +236,28 @@ export default {
showAttachButton() {
return this.showFileUpload || this.isNote;
},
showAudioRecorderButton() {
return this.showAudioRecorder;
},
showAudioPlayStopButton() {
return this.showAudioRecorder && this.isRecordingAudio;
},
allowedFileTypes() {
return ALLOWED_FILE_TYPES;
},
audioRecorderPlayStopIcon() {
switch (this.recordingAudioState) {
// playing paused recording stopped inactive destroyed
case 'playing':
return 'microphone-pause';
case 'paused':
return 'microphone-play';
case 'stopped':
return 'microphone-play';
default:
return 'microphone-stop';
}
},
showMessageSignatureButton() {
return !this.isPrivate && this.isAnEmailChannel;
},

View file

@ -33,8 +33,15 @@
:cc-emails.sync="ccEmails"
:bcc-emails.sync="bccEmails"
/>
<woot-audio-recorder
v-if="showAudioRecorderEditor"
ref="audioRecorderInput"
@state-recorder-timer-changed="onStateRecorderTimerChanged"
@state-recorder-changed="onStateRecorderChanged"
@recorder-blob="onRecorderBlob"
/>
<resizable-text-area
v-if="!showRichContentEditor"
v-else-if="!showRichContentEditor"
ref="messageInput"
v-model="message"
class="input"
@ -89,10 +96,16 @@
:send-button-text="replyButtonLabel"
:on-file-upload="onFileUpload"
:show-file-upload="showFileUpload"
:show-audio-recorder="showAudioRecorder"
:toggle-emoji-picker="toggleEmojiPicker"
:toggle-audio-recorder="toggleAudioRecorder"
:toggle-audio-recorder-play-pause="toggleAudioRecorderPlayPause"
:show-emoji-picker="showEmojiPicker"
:on-send="sendMessage"
:is-send-disabled="isReplyButtonDisabled"
:recording-audio-duration-text="recordingAudioDuration"
:recording-audio-state="recordingAudioState"
:is-recording-audio="isRecordingAudio"
:set-format-mode="setFormatMode"
:is-on-private-note="isOnPrivateNote"
:is-format-mode="showRichContentEditor"
@ -119,6 +132,7 @@ import ReplyBottomPanel from 'dashboard/components/widgets/WootWriter/ReplyBotto
import Banner from 'dashboard/components/ui/Banner.vue';
import { REPLY_EDITOR_MODES } from 'dashboard/components/widgets/WootWriter/constants';
import WootMessageEditor from 'dashboard/components/widgets/WootWriter/Editor';
import WootAudioRecorder from 'dashboard/components/widgets/WootWriter/AudioRecorder';
import messageFormatterMixin from 'shared/mixins/messageFormatterMixin';
import { checkFileSizeLimit } from 'shared/helpers/FileHelper';
import { MAXIMUM_FILE_UPLOAD_SIZE } from 'shared/constants/messages';
@ -146,6 +160,7 @@ export default {
ReplyEmailHead,
ReplyBottomPanel,
WootMessageEditor,
WootAudioRecorder,
Banner,
},
mixins: [
@ -176,6 +191,9 @@ export default {
showEmojiPicker: false,
showMentions: false,
attachedFiles: [],
isRecordingAudio: false,
recordingAudioState: '',
recordingAudioDuration: '',
isUploading: false,
replyType: REPLY_EDITOR_MODES.REPLY,
mentionSearchKey: '',
@ -270,7 +288,7 @@ export default {
return true;
}
if (this.hasAttachments) return false;
if (this.hasAttachments || this.hasRecordedAudio) return false;
return (
this.isMessageEmpty ||
@ -332,9 +350,21 @@ export default {
hasAttachments() {
return this.attachedFiles.length;
},
hasRecordedAudio() {
return (
this.$refs.audioRecorderInput &&
this.$refs.audioRecorderInput.hasAudio()
);
},
isRichEditorEnabled() {
return this.isAWebWidgetInbox || this.isAnEmailChannel;
},
showAudioRecorder() {
return !this.isOnPrivateNote && this.showFileUpload;
},
showAudioRecorderEditor() {
return this.showAudioRecorder && this.isRecordingAudio;
},
isOnPrivateNote() {
return this.replyType === REPLY_EDITOR_MODES.NOTE;
},
@ -523,6 +553,9 @@ export default {
if (canReply || this.isAWhatsappChannel) this.replyType = mode;
if (this.showRichContentEditor) {
if (this.isRecordingAudio) {
this.toggleAudioRecorder();
}
return;
}
this.$nextTick(() => this.$refs.messageInput.focus());
@ -535,10 +568,26 @@ export default {
this.attachedFiles = [];
this.ccEmails = '';
this.bccEmails = '';
this.isRecordingAudio = false;
},
toggleEmojiPicker() {
this.showEmojiPicker = !this.showEmojiPicker;
},
toggleAudioRecorder() {
this.isRecordingAudio = !this.isRecordingAudio;
this.isRecorderAudioStopped = !this.isRecordingAudio;
if (!this.isRecordingAudio) {
this.clearMessage();
}
},
toggleAudioRecorderPlayPause() {
if (this.isRecordingAudio && !this.isRecorderAudioStopped) {
this.isRecorderAudioStopped = true;
this.$refs.audioRecorderInput.stopAudioRecording();
} else if (this.isRecordingAudio && this.isRecorderAudioStopped) {
this.$refs.audioRecorderInput.playPause();
}
},
hideEmojiPicker() {
if (this.showEmojiPicker) {
this.toggleEmojiPicker();
@ -559,6 +608,20 @@ export default {
onFocus() {
this.isFocused = true;
},
onStateRecorderTimerChanged(time) {
this.recordingAudioDuration = time;
},
onStateRecorderChanged(state) {
this.recordingAudioState = state;
if (state.includes('notallowederror')) {
this.toggleAudioRecorder();
}
},
onRecorderBlob(file) {
if (file) {
this.onFileUpload(file);
}
},
toggleTyping(status) {
const conversationId = this.currentChat.id;
const isPrivate = this.isPrivate;

View file

@ -74,8 +74,14 @@
"TIP_FORMAT_ICON": "Show rich text editor",
"TIP_EMOJI_ICON": "Show emoji selector",
"TIP_ATTACH_ICON": "Attach files",
"TIP_AUDIORECORDER_ICON": "Record audio",
"TIP_AUDIORECORDER_PERMISSION": "Allow access to audio",
"TIP_AUDIORECORDER_ERROR": "Could not open the audio",
"ENTER_TO_SEND": "Enter to send",
"DRAG_DROP": "Drag and drop here to attach",
"START_AUDIO_RECORDING": "Start audio recording",
"STOP_AUDIO_RECORDING": "Stop audio recording",
"": "",
"EMAIL_HEAD": {
"ADD_BCC": "Add bcc",
"CC": {

View file

@ -85,6 +85,11 @@
"merge-outline": "M3 6.75A.75.75 0 0 1 3.75 6h4.5a.75.75 0 0 1 .53.22L13.56 11h5.878L15.72 7.28a.75.75 0 1 1 1.06-1.06l4.998 5a.75.75 0 0 1 0 1.06l-4.998 5a.75.75 0 1 1-1.06-1.06l3.718-3.72H13.56l-4.78 4.78a.75.75 0 0 1-.531.22h-4.5a.75.75 0 0 1 0-1.5h4.19l4.25-4.25L7.94 7.5H3.75A.75.75 0 0 1 3 6.75Z",
"more-horizontal-outline": "M7.75 12a1.75 1.75 0 1 1-3.5 0 1.75 1.75 0 0 1 3.5 0ZM13.75 12a1.75 1.75 0 1 1-3.5 0 1.75 1.75 0 0 1 3.5 0ZM18 13.75a1.75 1.75 0 1 0 0-3.5 1.75 1.75 0 0 0 0 3.5Z",
"more-vertical-outline": "M12 7.75a1.75 1.75 0 1 1 0-3.5 1.75 1.75 0 0 1 0 3.5ZM12 13.75a1.75 1.75 0 1 1 0-3.5 1.75 1.75 0 0 1 0 3.5ZM10.25 18a1.75 1.75 0 1 0 3.5 0 1.75 1.75 0 0 0-3.5 0Z",
"microphone-outline": "M12,2A3,3 0 0,1 15,5V11A3,3 0 0,1 12,14A3,3 0 0,1 9,11V5A3,3 0 0,1 12,2M19,11C19,14.53 16.39,17.44 13,17.93V21H11V17.93C7.61,17.44 5,14.53 5,11H7A5,5 0 0,0 12,16A5,5 0 0,0 17,11H19Z",
"microphone-off-outline": "M19,11C19,12.19 18.66,13.3 18.1,14.28L16.87,13.05C17.14,12.43 17.3,11.74 17.3,11H19M15,11.16L9,5.18V5A3,3 0 0,1 12,2A3,3 0 0,1 15,5V11L15,11.16M4.27,3L21,19.73L19.73,21L15.54,16.81C14.77,17.27 13.91,17.58 13,17.72V21H11V17.72C7.72,17.23 5,14.41 5,11H6.7C6.7,14 9.24,16.1 12,16.1C12.81,16.1 13.6,15.91 14.31,15.58L12.65,13.92L12,14A3,3 0 0,1 9,11V10.28L3,4.27L4.27,3Z",
"microphone-stop-outline": "M12,2A10,10 0 0,0 2,12A10,10 0 0,0 12,22A10,10 0 0,0 22,12A10,10 0 0,0 12,2M12,4C16.41,4 20,7.59 20,12C20,16.41 16.41,20 12,20C7.59,20 4,16.41 4,12C4,7.59 7.59,4 12,4M9,9V15H15V9",
"microphone-pause-outline": "M13,16V8H15V16H13M9,16V8H11V16H9M12,2A10,10 0 0,1 22,12A10,10 0 0,1 12,22A10,10 0 0,1 2,12A10,10 0 0,1 12,2M12,4A8,8 0 0,0 4,12A8,8 0 0,0 12,20A8,8 0 0,0 20,12A8,8 0 0,0 12,4Z",
"microphone-play-outline": "M12,20C7.59,20 4,16.41 4,12C4,7.59 7.59,4 12,4C16.41,4 20,7.59 20,12C20,16.41 16.41,20 12,20M12,2A10,10 0 0,0 2,12A10,10 0 0,0 12,22A10,10 0 0,0 22,12A10,10 0 0,0 12,2M10,16.5L16,12L10,7.5V16.5Z",
"number-symbol-outline": "M10.987 2.89a.75.75 0 1 0-1.474-.28L8.494 7.999 3.75 8a.75.75 0 1 0 0 1.5l4.46-.002-.946 5-4.514.002a.75.75 0 0 0 0 1.5l4.23-.002-.967 5.116a.75.75 0 1 0 1.474.278l1.02-5.395 5.474-.002-.968 5.119a.75.75 0 1 0 1.474.278l1.021-5.398 4.742-.002a.75.75 0 1 0 0-1.5l-4.458.002.946-5 4.512-.002a.75.75 0 1 0 0-1.5l-4.229.002.966-5.104a.75.75 0 0 0-1.474-.28l-1.018 5.385-5.474.002.966-5.107Zm-1.25 6.608 5.474-.003-.946 5-5.474.002.946-5Z",
"open-outline": "M6.25 4.5A1.75 1.75 0 0 0 4.5 6.25v11.5c0 .966.783 1.75 1.75 1.75h11.5a1.75 1.75 0 0 0 1.75-1.75v-4a.75.75 0 0 1 1.5 0v4A3.25 3.25 0 0 1 17.75 21H6.25A3.25 3.25 0 0 1 3 17.75V6.25A3.25 3.25 0 0 1 6.25 3h4a.75.75 0 0 1 0 1.5h-4ZM13 3.75a.75.75 0 0 1 .75-.75h6.5a.75.75 0 0 1 .75.75v6.5a.75.75 0 0 1-1.5 0V5.56l-5.22 5.22a.75.75 0 0 1-1.06-1.06l5.22-5.22h-4.69a.75.75 0 0 1-.75-.75Z",
"people-outline": "M4 13.999 13 14a2 2 0 0 1 1.995 1.85L15 16v1.5C14.999 21 11.284 22 8.5 22c-2.722 0-6.335-.956-6.495-4.27L2 17.5v-1.501c0-1.054.816-1.918 1.85-1.995L4 14ZM15.22 14H20c1.054 0 1.918.816 1.994 1.85L22 16v1c-.001 3.062-2.858 4-5 4a7.16 7.16 0 0 1-2.14-.322c.336-.386.607-.827.802-1.327A6.19 6.19 0 0 0 17 19.5l.267-.006c.985-.043 3.086-.363 3.226-2.289L20.5 17v-1a.501.501 0 0 0-.41-.492L20 15.5h-4.051a2.957 2.957 0 0 0-.595-1.34L15.22 14H20h-4.78ZM4 15.499l-.1.01a.51.51 0 0 0-.254.136.506.506 0 0 0-.136.253l-.01.101V17.5c0 1.009.45 1.722 1.417 2.242.826.445 2.003.714 3.266.753l.317.005.317-.005c1.263-.039 2.439-.308 3.266-.753.906-.488 1.359-1.145 1.412-2.057l.005-.186V16a.501.501 0 0 0-.41-.492L13 15.5l-9-.001ZM8.5 3a4.5 4.5 0 1 1 0 9 4.5 4.5 0 0 1 0-9Zm9 2a3.5 3.5 0 1 1 0 7 3.5 3.5 0 0 1 0-7Zm-9-.5c-1.654 0-3 1.346-3 3s1.346 3 3 3 3-1.346 3-3-1.346-3-3-3Zm9 2c-1.103 0-2 .897-2 2s.897 2 2 2 2-.897 2-2-.897-2-2-2Z",

View file

@ -19,7 +19,7 @@ export const fileSizeInMegaBytes = bytes => {
};
export const checkFileSizeLimit = (file, maximumUploadLimit) => {
const fileSize = file?.file?.size;
const fileSize = file?.file?.size || file?.size;
const fileSizeInMB = fileSizeInMegaBytes(fileSize);
return fileSizeInMB <= maximumUploadLimit;
};

View file

@ -50,6 +50,7 @@
"prosemirror-state": "1.3.4",
"prosemirror-view": "1.18.4",
"query-string": "5",
"recordrtc": "^5.6.2",
"semver": "7.3.5",
"spinkit": "~1.2.5",
"tailwindcss": "^1.9.6",
@ -71,7 +72,8 @@
"vuedraggable": "^2.24.3",
"vuelidate": "0.7.6",
"vuex": "~2.1.1",
"vuex-router-sync": "~4.1.2"
"vuex-router-sync": "~4.1.2",
"wavesurfer.js": "^5.2.0"
},
"devDependencies": {
"@babel/core": "7.13.16",