[IMP] mail: Send voice messages in discuss chats and channels

With this commit, users can send voice messages in channels and chat.
There's a new button in composer to record audio from the microphone,
up to 1 minute clip duration. This adds a voice attachment with its
dedicated voice player that shows waveforms and allows playback.

To ensure compatibility in all supported browsers, we choose to
encode voice recording with `audio/mp3` thanks to lib `lamejs`.

Task-3240168

closes odoo/odoo#117036

Related: odoo/enterprise#45482
Signed-off-by: Sébastien Theys (seb) <seb@odoo.com>
This commit is contained in:
Maryam Kia
2023-08-09 13:20:46 +02:00
parent 3d38f684d5
commit 8d039cafe3
43 changed files with 16950 additions and 190 deletions
+4 -2
View File
@@ -126,6 +126,7 @@ For more specific needs, you may also assign custom-defined actions
'mail/static/src/scss/variables/derived_variables.scss',
'mail/static/src/scss/*.scss',
'mail/static/lib/**/*',
('remove', 'mail/static/lib/lame/lame.js'),
'mail/static/src/js/**/*',
'mail/static/src/core/common/**/*',
'mail/static/src/core/web/**/*',
@@ -160,7 +161,9 @@ For more specific needs, you may also assign custom-defined actions
('remove', 'mail/static/tests/tours/**/*'),
('remove', 'mail/static/tests/helpers/**/*'),
],
'mail.assets_lamejs': [
'mail/static/lib/lame/lame.js',
],
'mail.assets_public': [
('include', 'web._assets_helpers'),
('include', 'web._assets_backend_helpers'),
@@ -181,7 +184,6 @@ For more specific needs, you may also assign custom-defined actions
'web/static/src/legacy/js/owl_compatibility.js',
'web/static/src/legacy/js/services/session.js',
'web/static/src/legacy/utils.js',
'mail/static/src/core/common/**/*',
# 'mail/static/src/core/public/**/*',
'mail/static/src/**/common/**/*',
+2 -8
View File
@@ -32,14 +32,8 @@ class AttachmentController(http.Controller):
vals["access_token"] = env["ir.attachment"]._generate_access_token()
try:
attachment = env["ir.attachment"].create(vals)
attachment._post_add_create()
attachmentData = {
"filename": ufile.filename,
"id": attachment.id,
"mimetype": attachment.mimetype,
"name": attachment.name,
"size": attachment.file_size,
}
attachment._post_add_create(**kwargs)
attachmentData = attachment._attachment_format()[0]
if attachment.access_token:
attachmentData["accessToken"] = attachment.access_token
except AccessError:
@@ -5,3 +5,4 @@ from . import channel
from . import gif
from . import public_page
from . import rtc
from . import voice
+17
View File
@@ -0,0 +1,17 @@
# Part of Odoo. See LICENSE file for full copyright and licensing details.
from odoo import http
from odoo.http import request
from odoo.tools import file_open
class VoiceController(http.Controller):
@http.route("/discuss/voice/worklet_processor", methods=["GET"], type="http", auth="public")
def voice_worklet_processor(self):
return request.make_response(
file_open("mail/static/src/discuss/voice_message/worklets/processor.js", "rb").read(),
headers=[
("Content-Type", "application/javascript"),
],
)
+1
View File
@@ -8,6 +8,7 @@ from . import discuss_channel_member
from . import discuss_channel_rtc_session
from . import discuss_channel
from . import discuss_gif_favorite
from . import discuss_voice_metadata
from . import mail_guest
# odoo models
@@ -0,0 +1,12 @@
# Part of Odoo. See LICENSE file for full copyright and licensing details.
from odoo import fields, models
class DiscussVoiceMetadata(models.Model):
_name = "discuss.voice.metadata"
_description = "Metadata for voice attachments"
attachment_id = fields.Many2one(
"ir.attachment", ondelete="cascade", auto_join=True, copy=False, index=True
)
+14 -1
View File
@@ -1,11 +1,13 @@
# Part of Odoo. See LICENSE file for full copyright and licensing details.
from odoo import api, models
from odoo import api, models, fields
class IrAttachment(models.Model):
_inherit = "ir.attachment"
voice_ids = fields.One2many("discuss.voice.metadata", "attachment_id")
def _bus_notification_target(self):
self.ensure_one()
if self.res_model == "discuss.channel" and self.res_id:
@@ -27,3 +29,14 @@ class IrAttachment(models.Model):
.env
)
return super()._get_upload_env(thread_model, thread_id)
def _attachment_format(self):
attachment_format = super()._attachment_format()
for a in attachment_format:
a["voice"] = bool(self.browse(a["id"]).with_prefetch(self._prefetch_ids).sudo().voice_ids)
return attachment_format
def _post_add_create(self, **kwargs):
super()._post_add_create()
if kwargs.get('voice'):
self.env["discuss.voice.metadata"].create([{"attachment_id": attachment.id} for attachment in self])
+14 -16
View File
@@ -33,10 +33,10 @@ class IrAttachment(models.Model):
except (AccessError, MissingError):
raise UserError(_("The attachment %s does not exist or you do not have the rights to access it.", attachment.id))
def _post_add_create(self):
def _post_add_create(self, **kwargs):
""" Overrides behaviour when the attachment is created through the controller
"""
super(IrAttachment, self)._post_add_create()
super(IrAttachment, self)._post_add_create(**kwargs)
for record in self:
record.register_as_main_attachment(force=False)
@@ -74,20 +74,18 @@ class IrAttachment(models.Model):
def _attachment_format(self):
safari = request and request.httprequest.user_agent and request.httprequest.user_agent.browser == 'safari'
return [
{
'checksum': attachment.checksum,
'id': attachment.id,
'filename': attachment.name,
'name': attachment.name,
'mimetype': 'application/octet-stream' if safari and attachment.mimetype and 'video' in attachment.mimetype else attachment.mimetype,
'originThread': [('insert', {
'id': attachment.res_id,
'model': attachment.res_model,
})],
}
for attachment in self
]
return [{
'checksum': attachment.checksum,
'id': attachment.id,
'filename': attachment.name,
'name': attachment.name,
"size": attachment.file_size,
'mimetype': 'application/octet-stream' if safari and attachment.mimetype and 'video' in attachment.mimetype else attachment.mimetype,
'originThread': [('insert', {
'id': attachment.res_id,
'model': attachment.res_model,
})],
} for attachment in self]
@api.model
def _get_upload_env(self, thread_model, thread_id):
+1
View File
@@ -68,3 +68,4 @@ access_mail_template_reset,access.mail.template.reset,model_mail_template_reset,
ir_actions_report_access_user,ir.actions.report.access.user,base.model_ir_actions_report,base.group_user,1,0,0,0
access_mail_link_preview_admin,mail.link.preview.admin,model_mail_link_preview,base.group_erp_manager,1,1,1,1
access_discuss_gif_favorite,discuss.gif.favorite,model_discuss_gif_favorite,base.group_user,1,1,1,1
access_discuss_voice_metadata_user,discuss.voice.metadata.user,model_discuss_voice_metadata,,0,0,0,0
1 id name model_id:id group_id:id perm_read perm_write perm_create perm_unlink
68 ir_actions_report_access_user ir.actions.report.access.user base.model_ir_actions_report base.group_user 1 0 0 0
69 access_mail_link_preview_admin mail.link.preview.admin model_mail_link_preview base.group_erp_manager 1 1 1 1
70 access_discuss_gif_favorite discuss.gif.favorite model_discuss_gif_favorite base.group_user 1 1 1 1
71 access_discuss_voice_metadata_user discuss.voice.metadata.user model_discuss_voice_metadata 0 0 0 0
File diff suppressed because it is too large Load Diff
@@ -48,22 +48,27 @@
<div class="d-flex flex-grow-1 flex-wrap mt-1 mx-1" t-att-class="{
'justify-content-end': isInChatWindowAndIsAlignedRight and !env.inComposer,
}">
<!-- t-attf-class overridden in extensions -->
<div t-foreach="nonImagesAttachments" t-as="attachment" t-key="attachment.id"
class="o-mail-AttachmentCard d-flex rounded bg-300 mb-1 me-1 mw-100 overflow-auto"
class="o-mail-AttachmentCard d-flex rounded mb-1 me-1 mw-100 overflow-auto"
t-att-class="{
'ms-1': isInChatWindowAndIsAlignedRight,
'me-1': !isInChatWindowAndIsAlignedRight,
'o-viewable': attachment.isViewable,
'o-isUploading': attachment.uploading,
}" t-att-title="attachment.name ? attachment.name : undefined" role="menu" t-att-aria-label="attachment.name"
t-on-click="() => this.fileViewer.open(attachment, props.attachments)"
}"
t-attf-class="bg-300"
t-att-title="attachment.name ? attachment.name : undefined" role="menu" t-att-aria-label="attachment.name"
t-on-click="() => this.fileViewer.open(attachment, props.attachments)"
>
<!-- o_image from mimetype.scss -->
<div class="o-mail-AttachmentCard-image o_image flex-shrink-0 m-1" role="menuitem" aria-label="Preview" t-att-tabindex="-1" t-att-aria-disabled="false" t-att-data-mimetype="attachment.mimetype"/>
<div class="overflow-auto d-flex justify-content-center flex-column px-1">
<div t-if="attachment.name" class="text-truncate" t-esc="attachment.name"/>
<small t-if="attachment.extension" class="text-uppercase" t-esc="attachment.extension"/>
</div>
<t t-ref="nonImageMain">
<div class="o-mail-AttachmentCard-image o_image flex-shrink-0 m-1" role="menuitem" aria-label="Preview" t-att-tabindex="-1" t-att-aria-disabled="false" t-att-data-mimetype="attachment.mimetype"/>
<div class="overflow-auto d-flex justify-content-center flex-column px-1">
<div t-if="attachment.name" class="text-truncate" t-esc="attachment.name"/>
<small t-if="attachment.extension" class="text-uppercase" t-esc="attachment.extension"/>
</div>
</t>
<div class="o-mail-AttachmentCard-aside position-relative rounded-end overflow-hidden" t-att-class="{ 'o-hasMultipleActions d-flex flex-column': attachment.isDeletable and !env.inComposer }">
<div t-if="attachment.uploading" class="d-flex justify-content-center align-items-center w-100 h-100" title="Uploading">
<i class="fa fa-spin fa-spinner"/>
@@ -72,13 +77,17 @@
<i class="fa fa-check"/>
</div>
<button t-if="attachment.isDeletable"
class="o-mail-AttachmentCard-unlink btn top-0 justify-content-center align-items-center d-flex w-100 h-100 rounded-0"
class="o-mail-AttachmentCard-unlink btn top-0 justify-content-center align-items-center d-flex w-100 h-100 rounded-0 border-0"
t-attf-class="{{ env.inComposer ? 'o-inComposer position-absolute btn-primary transition-base' : 'bg-300' }}"
t-on-click.stop="() => this.onClickUnlink(attachment)" title="Remove"
>
<i class="fa fa-trash" role="img" aria-label="Remove"/>
</button>
<button t-if="canDownload(attachment)" class="btn d-flex justify-content-center align-items-center w-100 h-100 rounded-0 bg-300" t-on-click.stop="() => this.onClickDownload(attachment)" title="Download">
<!-- t-attf-class overridden in extensions -->
<button t-if="canDownload(attachment)" class="btn d-flex justify-content-center align-items-center w-100 h-100 rounded-0"
t-attf-class="bg-300"
t-on-click.stop="() => this.onClickDownload(attachment)" title="Download"
>
<i class="fa fa-download" role="img" aria-label="Download"/>
</button>
</div>
@@ -8,6 +8,10 @@ import { registry } from "@web/core/registry";
export class AttachmentService {
constructor(env, services) {
this.setup(env, services);
}
setup(env, services) {
this.env = env;
/** @type {import("@mail/core/common/store_service").Store} */
this.store = services["mail.store"];
@@ -69,6 +73,9 @@ export class AttachmentService {
* @param {Attachment} attachment
*/
remove(attachment) {
if (attachment.tmpUrl) {
URL.revokeObjectURL(attachment.tmpUrl);
}
delete this.store.attachments[attachment.id];
if (attachment.originThread) {
removeFromArrayWithPredicate(
@@ -0,0 +1,190 @@
/* @odoo-module */
import { createLocalId } from "@mail/utils/common/misc";
import { _t } from "@web/core/l10n/translation";
import { registry } from "@web/core/registry";
import { Deferred } from "@web/core/utils/concurrency";
let nextId = -1;
export class AttachmentUploadService {
constructor(env, services) {
this.setup(env, services);
}
setup(env, services) {
this.env = env;
this.fileUploadService = services["file_upload"];
/** @type {import("@mail/core/common/store_service").Store} */
this.store = services["mail.store"];
this.notificationService = services["notification"];
/** @type {import("@mail/core/common/thread_service").ThreadService} */
this.threadService = services["mail.thread"];
/** @type {import("@mail/core/common/attachment_service").AttachmentService} */
this.attachmentService = services["mail.attachment"];
this.abortByAttachmentId = new Map();
this.deferredByAttachmentId = new Map();
this.uploadingAttachmentIds = new Set();
this.hookersByTmpId = new Map();
this.fileUploadService.bus.addEventListener(
"FILE_UPLOAD_ADDED",
({ detail: { upload } }) => {
const tmpId = parseInt(upload.data.get("temporary_id"));
if (!this.uploadingAttachmentIds.has(tmpId)) {
return;
}
const hooker = this.hookersByTmpId.get(tmpId);
const threadId = parseInt(upload.data.get("thread_id"));
const threadModel = upload.data.get("thread_model");
const tmpUrl = upload.data.get("tmp_url");
const originThread = this.threadService.insert({
model: threadModel,
id: threadId,
});
this.abortByAttachmentId.set(tmpId, upload.xhr.abort.bind(upload.xhr));
const attachment = this.attachmentService.insert(
this._makeAttachmentData(
upload,
tmpId,
hooker.composer ? undefined : originThread,
tmpUrl
)
);
if (hooker.composer) {
hooker.composer.attachments.push(attachment);
}
}
);
this.fileUploadService.bus.addEventListener(
"FILE_UPLOAD_LOADED",
({ detail: { upload } }) => {
const tmpId = parseInt(upload.data.get("temporary_id"));
if (!this.uploadingAttachmentIds.has(tmpId)) {
return;
}
this.uploadingAttachmentIds.delete(tmpId);
this.abortByAttachmentId.delete(tmpId);
const hooker = this.hookersByTmpId.get(tmpId);
if (upload.xhr.status === 413) {
this.notificationService.add(_t("File too large"), { type: "danger" });
this.hookersByTmpId.delete(tmpId);
return;
}
if (upload.xhr.status !== 200) {
this.notificationService.add(_t("Server error"), { type: "danger" });
this.hookersByTmpId.delete(tmpId);
return;
}
const response = JSON.parse(upload.xhr.response);
if (response.error) {
this.notificationService.add(response.error, { type: "danger" });
this.hookersByTmpId.delete(tmpId);
return;
}
const threadId = parseInt(upload.data.get("thread_id"));
const threadModel = upload.data.get("thread_model");
const originThread = this.store.threads[createLocalId(threadModel, threadId)];
const attachment = this.attachmentService.insert({
...response,
extension: upload.title.split(".").pop(),
originThread: hooker.composer ? undefined : originThread,
});
if (hooker.composer) {
const index = hooker.composer.attachments.findIndex(({ id }) => id === tmpId);
if (index >= 0) {
hooker.composer.attachments[index] = attachment;
} else {
hooker.composer.attachments.push(attachment);
}
}
const def = this.deferredByAttachmentId.get(tmpId);
this.unlink(this.store.attachments[tmpId]);
if (def) {
def.resolve(attachment);
this.deferredByAttachmentId.delete(tmpId);
}
hooker.onFileUploaded?.();
this.hookersByTmpId.delete(tmpId);
}
);
this.fileUploadService.bus.addEventListener(
"FILE_UPLOAD_ERROR",
({ detail: { upload } }) => {
const tmpId = parseInt(upload.data.get("temporary_id"));
if (!this.uploadingAttachmentIds.has(tmpId)) {
return;
}
this.abortByAttachmentId.delete(tmpId);
this.deferredByAttachmentId.delete(tmpId);
this.uploadingAttachmentIds.delete(parseInt(tmpId));
this.hookersByTmpId.delete(tmpId);
}
);
}
async unlink(attachment) {
const abort = this.abortByAttachmentId.get(attachment.id);
const def = this.deferredByAttachmentId.get(attachment.id);
if (abort) {
abort();
def.resolve();
}
this.abortByAttachmentId.delete(attachment.id);
this.deferredByAttachmentId.delete(attachment.id);
await this.attachmentService.delete(attachment);
}
async uploadFile(hooker, file, options) {
const tmpId = nextId--;
this.hookersByTmpId.set(tmpId, hooker);
this.uploadingAttachmentIds.add(tmpId);
await this.fileUploadService
.upload("/mail/attachment/upload", [file], {
buildFormData: (formData) => {
this._makeFormData(formData, file, hooker, tmpId, options);
},
})
.catch((e) => {
if (e.name !== "AbortError") {
throw e;
}
});
const uploadDoneDeferred = new Deferred();
this.deferredByAttachmentId.set(tmpId, uploadDoneDeferred);
return uploadDoneDeferred;
}
_makeFormData(formData, file, hooker, tmpId, options) {
formData.append("thread_id", hooker.thread.id);
formData.append("tmp_url", URL.createObjectURL(file));
formData.append("thread_model", hooker.thread.model);
formData.append("is_pending", Boolean(hooker.composer));
formData.append("temporary_id", tmpId);
return formData;
}
_makeAttachmentData(upload, tmpId, originThread, tmpUrl) {
const attachmentData = {
filename: upload.title,
id: tmpId,
mimetype: upload.type,
name: upload.title,
originThread: originThread,
extension: upload.title.split(".").pop(),
uploading: true,
tmpUrl,
};
return attachmentData;
}
}
export const attachmentUploadService = {
dependencies: ["file_upload", "mail.attachment", "mail.store", "mail.thread", "notification"],
start(env, services) {
return new AttachmentUploadService(env, services);
},
};
registry.category("services").add("mail.attachment_upload", attachmentUploadService);
@@ -2,10 +2,7 @@
import { useState } from "@odoo/owl";
import { _t } from "@web/core/l10n/translation";
import { Deferred } from "@web/core/utils/concurrency";
import { useBus, useService } from "@web/core/utils/hooks";
import { createLocalId } from "@mail/utils/common/misc";
import { useService } from "@web/core/utils/hooks";
function dataUrlToBlob(data, type) {
const binData = window.atob(data);
@@ -14,7 +11,26 @@ function dataUrlToBlob(data, type) {
return new Blob([uiArr], { type });
}
let nextId = -1;
export class AttachmentUploader {
constructor(thread, { composer, onFileUploaded } = {}) {
/** @type {import("@mail/core/common/attachment_upload_service").AttachmentUploadService} */
this.attachmentUploadService = useService("mail.attachment_upload");
Object.assign(this, { thread, composer, onFileUploaded });
}
uploadData({ data, name, type }) {
const file = new File([dataUrlToBlob(data, type)], name, { type });
return this.uploadFile(file);
}
async uploadFile(file, options) {
return this.attachmentUploadService.uploadFile(this, file, options);
}
async unlink(attachment) {
await this.attachmentUploadService.unlink(attachment);
}
}
/**
* @param {import("@mail/core/common/thread_model").Thread} thread
@@ -23,137 +39,5 @@ let nextId = -1;
* @param {function} [param1.onFileUploaded]
*/
export function useAttachmentUploader(thread, { composer, onFileUploaded } = {}) {
const { bus, upload } = useService("file_upload");
const notificationService = useService("notification");
/** @type {import("@mail/core/common/store_service").Store} */
const store = useService("mail.store");
/** @type {import("@mail/core/common/thread_service").ThreadService} */
const threadService = useService("mail.thread");
/** @type {import("@mail/core/common/attachment_service").AttachmentService} */
const attachmentService = useService("mail.attachment");
const abortByAttachmentId = new Map();
const deferredByAttachmentId = new Map();
const uploadingAttachmentIds = new Set();
const state = useState({
thread,
uploadData({ data, name, type }) {
const file = new File([dataUrlToBlob(data, type)], name, { type });
return this.uploadFile(file);
},
async uploadFile(file) {
const tmpId = nextId--;
uploadingAttachmentIds.add(tmpId);
await upload("/mail/attachment/upload", [file], {
buildFormData(formData) {
formData.append("thread_id", state.thread.id);
formData.append("tmp_url", URL.createObjectURL(file));
formData.append("thread_model", state.thread.model);
formData.append("is_pending", Boolean(composer));
formData.append("temporary_id", tmpId);
},
}).catch((e) => {
if (e.name !== "AbortError") {
throw e;
}
});
const uploadDoneDeferred = new Deferred();
deferredByAttachmentId.set(tmpId, uploadDoneDeferred);
return uploadDoneDeferred;
},
async unlink(attachment) {
const abort = abortByAttachmentId.get(attachment.id);
const def = deferredByAttachmentId.get(attachment.id);
if (abort) {
abort();
def.resolve();
}
abortByAttachmentId.delete(attachment.id);
deferredByAttachmentId.delete(attachment.id);
await attachmentService.delete(attachment);
},
clear() {
abortByAttachmentId.clear();
deferredByAttachmentId.clear();
uploadingAttachmentIds.clear();
},
});
useBus(bus, "FILE_UPLOAD_ADDED", ({ detail: { upload } }) => {
const tmpId = parseInt(upload.data.get("temporary_id"));
if (!uploadingAttachmentIds.has(tmpId)) {
return;
}
const threadId = parseInt(upload.data.get("thread_id"));
const threadModel = upload.data.get("thread_model");
const tmpUrl = upload.data.get("tmp_url");
const originThread = threadService.insert({ model: threadModel, id: threadId });
abortByAttachmentId.set(tmpId, upload.xhr.abort.bind(upload.xhr));
const attachment = attachmentService.insert({
filename: upload.title,
id: tmpId,
mimetype: upload.type,
name: upload.title,
originThread: composer ? undefined : originThread,
extension: upload.title.split(".").pop(),
uploading: true,
tmpUrl,
});
if (composer) {
composer.attachments.push(attachment);
}
});
useBus(bus, "FILE_UPLOAD_LOADED", ({ detail: { upload } }) => {
const tmpId = parseInt(upload.data.get("temporary_id"));
if (!uploadingAttachmentIds.has(tmpId)) {
return;
}
uploadingAttachmentIds.delete(tmpId);
abortByAttachmentId.delete(tmpId);
if (upload.xhr.status === 413) {
notificationService.add(_t("File too large"), { type: "danger" });
return;
}
if (upload.xhr.status !== 200) {
notificationService.add(_t("Server error"), { type: "danger" });
return;
}
const response = JSON.parse(upload.xhr.response);
if (response.error) {
notificationService.add(response.error, { type: "danger" });
return;
}
const threadId = parseInt(upload.data.get("thread_id"));
const threadModel = upload.data.get("thread_model");
const originThread = store.threads[createLocalId(threadModel, threadId)];
const attachment = attachmentService.insert({
...response,
extension: upload.title.split(".").pop(),
originThread: composer ? undefined : originThread,
});
if (composer) {
const index = composer.attachments.findIndex(({ id }) => id === tmpId);
if (index >= 0) {
composer.attachments[index] = attachment;
} else {
composer.attachments.push(attachment);
}
}
const def = deferredByAttachmentId.get(tmpId);
state.unlink(store.attachments[tmpId]);
if (def) {
def.resolve(attachment);
deferredByAttachmentId.delete(tmpId);
}
onFileUploaded?.();
});
useBus(bus, "FILE_UPLOAD_ERROR", ({ detail: { upload } }) => {
const tmpId = parseInt(upload.data.get("temporary_id"));
if (!uploadingAttachmentIds.has(tmpId)) {
return;
}
abortByAttachmentId.delete(tmpId);
deferredByAttachmentId.delete(tmpId);
uploadingAttachmentIds.delete(parseInt(upload.data.get("temporary_id")));
});
return state;
return useState(new AttachmentUploader(...arguments));
}
@@ -487,7 +487,6 @@ export class Composer extends Component {
}
clear() {
this.attachmentUploader?.clear();
this.threadService.clearComposer(this.props.composer);
}
@@ -92,7 +92,7 @@
<AttachmentList
t-if="allowUpload and props.composer.attachments.length > 0"
attachments="props.composer.attachments"
unlinkAttachment="attachmentUploader.unlink"
unlinkAttachment.bind="(...args) => attachmentUploader.unlink(...args)"
imagesHeight="50"/>
<EmojiPicker t-if="ui.isSmall and state.keyboard === KEYBOARD.EMOJI" className="'o-mail-Composer-bottomPicker w-100'" onSelect="(str) => this.addEmoji(str)"/>
</div>
@@ -23,6 +23,7 @@ export class SoundEffects {
unmute: { defaultVolume: 0.2, path: "/mail/static/src/audio/unmute_1" },
};
}
/**
* @param {String} param0 soundEffectName
* @param {Object} param1
+1 -1
View File
@@ -125,7 +125,7 @@ export class Chatter extends Component {
return;
}
}
files.forEach(this.attachmentUploader.uploadFile);
files.forEach((file) => this.attachmentUploader.uploadFile(file));
this.state.isAttachmentBoxOpened = true;
}
},
@@ -0,0 +1,9 @@
/* @odoo-module */
import { AttachmentList } from "@mail/core/common/attachment_list";
import { VoicePlayer } from "@mail/discuss/voice_message/common/voice_player";
import { patch } from "@web/core/utils/patch";
patch(AttachmentList, {
components: { ...AttachmentList.components, VoicePlayer },
});
@@ -0,0 +1,21 @@
<?xml version="1.0" encoding="UTF-8"?>
<templates xml:space="preserve">
<t t-inherit="mail.AttachmentList" t-inherit-mode="extension" owl="1">
<xpath expr="//*[@t-ref='nonImageMain']" position="replace">
<t t-if="attachment.isVoice" class="o-mail-AttachmentCard d-flex rounded mb-1 me-1 mw-100 bg-100">
<VoicePlayer attachment="attachment"/>
</t>
<t t-else="">$0</t>
</xpath>
<xpath expr="//div[hasclass('o-mail-AttachmentCard')]" position="attributes">
<attribute name="t-attf-class" remove="bg-300" add="{{ attachment.isVoice ? 'bg-100' : 'bg-300'}}"/>
<attribute name="t-on-click">() => !attachment.isVoice ? this.fileViewer.open(attachment, props.attachments) : ""</attribute>
</xpath>
<xpath expr="//button[hasclass('o-mail-AttachmentCard-unlink')]" position="attributes">
<attribute name="t-attf-class">{{ env.inComposer ? 'o-inComposer position-absolute btn-primary transition-base' : (attachment.isVoice ? 'bg-100' : 'bg-300') }}</attribute>
</xpath>
<xpath expr="//button[@title='Download']" position="attributes">
<attribute name="t-attf-class" remove="bg-300" add="{{ attachment.isVoice ? 'bg-100' : 'bg-300'}}"/>
</xpath>
</t>
</templates>
@@ -0,0 +1,10 @@
/* @odoo-module */
import { Attachment } from "@mail/core/common/attachment_model";
import { patch } from "@web/core/utils/patch";
patch(Attachment.prototype, {
get isViewable() {
return !this.isVoice && super.isViewable;
},
});
@@ -0,0 +1,28 @@
/* @odoo-module */
import { AttachmentService, attachmentService } from "@mail/core/common/attachment_service";
import { patch } from "@web/core/utils/patch";
patch(AttachmentService.prototype, {
setup(env, services) {
super.setup(...arguments);
/** @type {import("@mail/discuss/voice_message/common/voice_message_service").VoiceMessageService} */
this.voiceMessageService = services["discuss.voice_message"];
},
update(attachment, data) {
super.update(...arguments);
if (data["voice"]) {
attachment.isVoice = true;
}
},
remove(attachment) {
if (attachment.isVoice && attachment.id > 0) {
this.voiceMessageService.activePlayer = null;
}
super.remove(...arguments);
},
});
patch(attachmentService, {
dependencies: [...attachmentService.dependencies, "discuss.voice_message"],
});
@@ -0,0 +1,22 @@
/* @odoo-module */
import { AttachmentUploadService } from "@mail/core/common/attachment_upload_service";
import { patch } from "@web/core/utils/patch";
patch(AttachmentUploadService.prototype, {
_makeAttachmentData(upload, tmpId, originThread, tmpUrl) {
const attachmentData = super._makeAttachmentData(...arguments);
if (upload.data.get("voice")) {
attachmentData.voice = upload.data.get("voice");
}
return attachmentData;
},
_makeFormData(formData, file, hooker, tmpId, options) {
super._makeFormData(...arguments);
if (options?.isVoice) {
formData.append("voice", options.isVoice);
}
return formData;
},
});
@@ -0,0 +1,11 @@
/* @odoo-module */
import { Composer } from "@mail/core/common/composer_model";
import { patch } from "@web/core/utils/patch";
patch(Composer.prototype, {
get voiceAttachment() {
return this.attachments.find((attachment) => attachment.isVoice);
},
});
@@ -0,0 +1,26 @@
/* @odoo-module */
import { Composer } from "@mail/core/common/composer";
import { VoiceRecorder } from "@mail/discuss/voice_message/common/voice_recorder";
import { patch } from "@web/core/utils/patch";
patch(Composer, {
components: { ...Composer.components, VoiceRecorder },
});
patch(Composer.prototype, {
setup() {
super.setup();
this.state.recording = false;
},
get isSendButtonDisabled() {
return this.state.recording || super.isSendButtonDisabled;
},
onKeydown(ev) {
if (ev.key === "Enter" && this.state.recording) {
ev.preventDefault();
return;
}
return super.onKeydown(ev);
},
});
@@ -0,0 +1,11 @@
<?xml version="1.0" encoding="UTF-8"?>
<templates xml:space="preserve">
<t t-inherit="mail.Composer" t-inherit-mode="extension" owl="1">
<xpath expr="//FileUploader" position="after">
<VoiceRecorder t-if="thread and thread.type !== 'chatter' and allowUpload"
composer="props.composer"
attachmentUploader="attachmentUploader"
onchangeRecording.bind="() => this.state.recording = !this.state.recording"/>
</xpath>
</t>
</templates>
@@ -0,0 +1,69 @@
/* @odoo-module */
const MAX_SAMPLES = 1152;
export class Mp3Encoder {
/** @type {Object} */
config;
/** @type {boolean} */
encoding;
/** @type {lameJs.Mp3Encoder} */
mp3Encoder;
/** @type {Int16Array} */
samplesMono;
constructor(config = {}) {
this.config = {
sampleRate: 44100,
bitRate: 128,
};
Object.assign(this.config, config);
// eslint-disable-next-line no-undef
this.mp3Encoder = new lamejs.Mp3Encoder(1, this.config.sampleRate, this.config.bitRate);
this.samplesMono = null;
this.clearBuffer();
}
clearBuffer() {
this.dataBuffer = [];
}
appendToBuffer(buffer) {
this.dataBuffer.push(new Int8Array(buffer));
}
floatTo16BitPCM(input, output) {
for (let i = 0; i < input.length; i++) {
const s = Math.max(-1, Math.min(1, input[i]));
output[i] = s < 0 ? s * 0x8000 : s * 0x7fff;
}
}
convertBuffer(arrayBuffer) {
const data = new Float32Array(arrayBuffer);
const out = new Int16Array(arrayBuffer.length);
this.floatTo16BitPCM(data, out);
return out;
}
encode(arrayBuffer) {
this.encoding = true;
this.samplesMono = this.convertBuffer(arrayBuffer);
let remaining = this.samplesMono.length;
for (let i = 0; remaining >= 0; i += MAX_SAMPLES) {
const left = this.samplesMono.subarray(i, i + MAX_SAMPLES);
const mp3buffer = this.mp3Encoder.encodeBuffer(left);
this.appendToBuffer(mp3buffer);
remaining -= MAX_SAMPLES;
}
}
finish() {
if (this.encoding) {
this.appendToBuffer(this.mp3Encoder.flush());
return this.dataBuffer;
} else {
return [];
}
}
}
@@ -0,0 +1,32 @@
/* @odoo-module */
import { getBundle, loadBundle } from "@web/core/assets";
import { registry } from "@web/core/registry";
import { memoize } from "@web/core/utils/functions";
const loader = {
loadLamejs: memoize(() => getBundle("mail.assets_lamejs").then(loadBundle)),
};
export async function loadLamejs() {
try {
await loader.loadLamejs();
} catch {
// Could be intentional (tour ended successfully while lamejs still loading)
}
}
export class VoiceMessageService {
constructor(env) {
/** @type {import("@mail/discuss/voice_message/common/voice_player").VoicePlayer} */
this.activePlayer = null;
}
}
export const voiceMessageService = {
start(env) {
return new VoiceMessageService(env);
},
};
registry.category("services").add("discuss.voice_message", voiceMessageService);
@@ -0,0 +1,381 @@
/* @odoo-module */
import { Component, useState, onMounted, onWillUnmount, useEffect, useRef } from "@odoo/owl";
import { browser } from "@web/core/browser/browser";
import { useService } from "@web/core/utils/hooks";
import { url } from "@web/core/utils/urls";
const WAVE_COLOR = "#7775";
/**
* @typedef {Object} Props
* @property {import("@mail/attachments/attachment_model").Attachment} attachment
* @extends {Component<Props, Env>}
*/
export class VoicePlayer extends Component {
static props = ["attachment"];
static template = "mail.VoicePlayer";
/** @type {number} */
lastPlaytime = 0;
/** @type {number} */
lastPos = 0;
/** @type {number} */
startPosition = 0;
/** @type {string} */
progressColor;
/** @type {GainNode} */
gainNode;
/** @type {AudioContext} */
audioCtx;
scheduledPause;
/** @type {AudioBuffer} */
buffer;
/** @type {AnalyserNode} */
analyser;
/** @type {AudioBufferSourceNode} */
source;
/** @type {number} */
width;
/** @type {number} */
height;
/** @type {HTMLElement} */
wrapper;
/** @type {HTMLElement} */
progressWave;
/** @type {CanvasRenderingContext2D} */
waveCtx;
/** @type {CanvasRenderingContext2D} */
progressCtx;
setup() {
this.wrapperRef = useRef("wrapper");
this.drawerRef = useRef("drawer");
this.waveRef = useRef("wave");
this.progressRef = useRef("progress");
/** @type {import("@mail/discuss/voice_message/common/voice_message_service").VoiceMessageService} */
this.voiceMessageService = useService("discuss.voice_message");
this.state = useState({
paused: true,
playing: false,
repeat: false,
visualTime: "-- : --",
});
useEffect(
(playing) => {
if (playing) {
this.addOnAudioProcess();
}
},
() => [this.state.playing]
);
useEffect(
(uploading) => {
if (uploading) {
return;
}
if (this.wasUploading && !uploading) {
this.makeAudio();
}
this.wasUploading = uploading;
},
() => [this.props.attachment.uploading]
);
onMounted(() => {
this.initElements();
this.wrapper.addEventListener("click", (e) => {
if (this.props.attachment.uploading) {
return;
}
const clientX = (e.targetTouches ? e.targetTouches[0] : e).clientX;
const bcr = this.wrapper.getBoundingClientRect();
const progressPixels = clientX - bcr.left;
const progress = Math.min(
Math.max(0, progressPixels / this.wrapper.scrollWidth),
1
);
this.seekTo(progress);
});
if (!this.props.attachment.uploading) {
this.makeAudio();
}
this.wasUploading = this.props.attachment.uploading;
});
onWillUnmount(() => {
if (this.state.playing) {
this.pause();
}
this.destroyWebAudio();
});
}
makeAudio() {
this.audioCtx = new browser.AudioContext();
this.gainNode = this.audioCtx.createGain();
this.gainNode.connect(this.audioCtx.destination);
this.analyser = this.audioCtx.createAnalyser();
this.analyser.connect(this.gainNode);
this.fetchFile(
url(this.props.attachment.urlRoute, {
...this.props.attachment.urlQueryParams,
})
).then((arrayBuffer) => this.drawBuffer(arrayBuffer));
}
async fetchFile(url) {
const response = await fetch(url);
if (!response.ok) {
throw new Error("HTTP error status: " + response.status);
}
const arrayBuffer = await response.arrayBuffer();
return arrayBuffer;
}
getPlayedTime() {
return this.audioCtx.currentTime - this.lastPlaytime;
}
getCurrentTime() {
if (this.state.paused) {
return this.startPosition;
} else {
return this.startPosition + this.getPlayedTime();
}
}
play() {
if (this.voiceMessageService.activePlayer) {
this.voiceMessageService.activePlayer.pause();
}
this.voiceMessageService.activePlayer = this;
this.state.repeat = false;
this.createSource();
const { start, end } = this.seekToElapsed();
this.scheduledPause = end;
this.source.start(0, start);
this.state.playing = true;
this.state.paused = false;
}
pause(options) {
this.voiceMessageService.activePlayer = null;
if (options?.end) {
this.state.repeat = true;
}
this.scheduledPause = null;
this.startPosition += this.getPlayedTime();
if (this.source) {
try {
this.source.stop();
} catch (e) {
if (e.name === "InvalidStateError") {
return;
}
throw e;
}
}
if (!options?.continue) {
this.state.paused = true;
this.state.playing = false;
}
}
getPeaks() {
const peaks = [];
const sampleSize = this.buffer.length / this.width;
const sampleStep = Math.floor(sampleSize / 10);
const chan = this.buffer.getChannelData(0);
let i;
for (i = 0; i < this.width; i++) {
const start = Math.floor(i * sampleSize);
const end = Math.floor(start + sampleSize);
let min = chan[start];
let max = min;
let j;
for (j = start; j < end; j += sampleStep) {
const value = chan[j];
if (value > max) {
max = value;
}
if (value < min) {
min = value;
}
}
peaks[i] = max;
}
return peaks;
}
createSource() {
this.source?.disconnect();
this.source = this.audioCtx.createBufferSource();
this.source.buffer = this.buffer;
this.source.connect(this.analyser);
}
/**
* @param {number} [start] float representing start time
* @param {number} [end] float representing end time
* @returns {Object} res
* @returns {number} res.start
* @returns {number} res.end
*/
seekToElapsed(start, end) {
this.scheduledPause = null;
if (start === undefined) {
start = this.getCurrentTime();
if (start >= this.buffer.duration) {
start = 0;
}
}
if (end === undefined) {
end = this.buffer.duration;
}
this.startPosition = start;
this.lastPlaytime = this.audioCtx.currentTime;
return { start, end };
}
onProgress(progress) {
const position = Math.round(progress * this.width);
if (position < this.lastPos || position - this.lastPos >= 1) {
this.lastPos = position;
this.progressWave.style.width = position + "px";
}
}
seekTo(progress) {
if (this.state.playing) {
this.pause({ continue: true });
}
this.state.repeat = false;
const elapsedTime = progress * this.buffer.duration;
this.state.visualTime = this.generateTime(Math.floor(elapsedTime));
this.seekToElapsed(elapsedTime);
this.onProgress(progress);
if (this.state.playing) {
this.play();
}
}
async drawBuffer(arrayBuffer) {
const buffer = await this.audioCtx.decodeAudioData(arrayBuffer);
this.state.visualTime = this.generateTime(Math.floor(buffer.duration));
this.startPosition = 0;
this.lastPlaytime = this.audioCtx.currentTime;
this.buffer = buffer;
this.createSource();
this.drawWave(this.getPeaks());
}
async destroyWebAudio() {
this.source?.disconnect();
this.gainNode?.disconnect();
this.analyser?.disconnect();
try {
await this.audioCtx?.close();
} catch (e) {
if (e.name === "InvalidStateError") {
return;
}
throw e;
}
}
addOnAudioProcess() {
if (owl.status(this) === "destroyed") {
return;
}
const time = this.getCurrentTime();
if (time >= this.scheduledPause && this.state.playing) {
this.pause({ end: true });
} else if (this.state.playing) {
this.state.visualTime = this.generateTime(Math.floor(time));
const playedPercents = this.getCurrentTime() / this.buffer.duration;
this.onProgress(playedPercents);
requestAnimationFrame(() => this.addOnAudioProcess());
}
}
generateTime(timeInSecond) {
const second = timeInSecond % 60;
const minute = Math.floor(timeInSecond / 60);
return (
(minute < 10 ? "0" + minute : minute) + " : " + (second < 10 ? "0" + second : second)
);
}
initElements() {
this.wrapper = this.wrapperRef.el;
this.progressWave = this.drawerRef.el;
this.progressColor = getComputedStyle(this.wrapper).getPropertyValue("--primary");
this.width = this.wrapper.clientWidth;
this.height = this.wrapper.clientHeight;
const wave = this.waveRef.el;
wave.width = this.width;
wave.height = this.height;
this.waveCtx = wave.getContext("2d");
this.waveCtx.fillStyle = WAVE_COLOR;
const progress = this.progressRef.el;
progress.width = this.width;
progress.height = this.height;
this.progressCtx = progress.getContext("2d");
this.progressCtx.fillStyle = this.progressColor;
}
drawWave(peaks) {
return requestAnimationFrame(() => {
this.drawLines(peaks);
this.fillRect(0, this.height / 2, this.width, 0.5);
});
}
fillRect(x, y, width, height) {
const intersection = {
x1: x,
y1: y,
x2: x + width,
y2: y + height,
};
if (intersection.x1 < intersection.x2) {
this.fillRects(
intersection.x1,
intersection.y1,
intersection.x2 - intersection.x1,
intersection.y2 - intersection.y1
);
}
}
fillRects(x, y, width, height) {
this.waveCtx.fillRect(x, y, width, height);
this.progressCtx.fillRect(x, y, width, height);
}
drawLines(peaks) {
this.drawLineToContext(this.waveCtx, peaks);
this.drawLineToContext(this.progressCtx, peaks);
}
drawLineToContext(ctx, peaks) {
const maxPeak = Math.max(...peaks);
let i, peak;
for (i = 0; i <= peaks.length; i++) {
peak = peaks[i];
const h = (peak * this.height) / maxPeak;
ctx.fillRect(i, (this.height - h) / 2, 1.5, h);
}
}
onClickPlayPause() {
if (this.props.attachment.uploading) {
return;
}
if (this.state.paused) {
this.play();
} else {
this.pause();
}
}
}
@@ -0,0 +1,8 @@
.o-mail-VoicePlayer-wrapper {
height: 40px;
width: 80px;
}
.o-mail-VoicePlayer-drawer {
z-index: 1;
}
@@ -0,0 +1,27 @@
<?xml version="1.0" encoding="UTF-8"?>
<templates xml:space="preserve">
<t t-name="mail.VoicePlayer" owl="1">
<div class="o-mail-VoicePlayer d-flex align-items-center my-1 position-relative">
<t t-set="playOrPause"><t t-if="state.repeat">Repeat</t><t t-elif="state.playing">Pause</t><t t-else="">Play</t></t>
<button class="border-0 btn shadow-none rounded-pill fa fa-fw ms-2 me-3 bg-100"
t-att-class="{
'fa-stop text-danger btn-sm': state.playing,
'btn-light btn-sm': !state.playing,
'fa-play': !state.playing and !state.repeat,
'fa-repeat': !state.playing and state.repeat,
}"
t-att-title="playOrPause" t-on-click.stop="() => this.onClickPlayPause()"/>
<div class="o-mail-VoicePlayer-wrapper">
<div t-ref="wrapper" class="h-100 position-relative">
<div class="o-mail-VoicePlayer-drawer position-absolute top-0 bottom-0 overflow-hidden" t-ref="drawer">
<canvas class="position-absolute" t-ref="progress"/>
</div>
<canvas class="position-absolute" t-ref="wave"/>
</div>
</div>
<div class="mx-2 text-muted" style="font-variant-numeric: tabular-nums;"><t t-esc="state.visualTime"/></div>
</div>
</t>
</templates>
@@ -0,0 +1,3 @@
.o-mail-VoiceRecorder.o-recording {
@extend .bg-300;
}
@@ -0,0 +1,208 @@
/* @odoo-module */
import { Component, useState, onWillUnmount } from "@odoo/owl";
import { useService } from "@web/core/utils/hooks";
import { sprintf } from "@web/core/utils/strings";
import { _t } from "@web/core/l10n/translation";
import { browser } from "@web/core/browser/browser";
import { Mp3Encoder } from "./mp3_encoder";
import { loadLamejs } from "@mail/discuss/voice_message/common/voice_message_service";
/**
* @typedef {Object} Props
* @property {import("@mail/composer/composer_model").Composer} composer
* @property {function} [attachmentUploader]
* @property {function} [onchangeRecording]
* @extends {Component<Props, Env>}
*/
export class VoiceRecorder extends Component {
static props = ["composer", "attachmentUploader", "onchangeRecording?"];
static template = "mail.VoiceRecorder";
/** @type {MediaStream} */
microphone;
/** @type {number} */
startTimeStamp;
/** @type {AudioContext} */
audioContext;
/** @type {MediaStreamAudioSourceNode} */
streamSource;
/** @type {AudioWorkletNode} */
processor;
/** @type {Mp3Encoder} */
encoder;
/** @type {import("@mail/core/common/user_settings_service").UserSettings} */
userSettings;
/** @type {ReturnType<typeof import("@web/core/notifications/notification_service").notificationService.start>} */
notification;
/** @type {Object} */
config;
/** @type {import("@mail/discuss/voice_message/common/voice_message_service").VoiceMessageService} */
voiceMessageService;
setup() {
this.state = useState({
limitWarning: false,
isActionPending: false,
recording: false,
elapsed: "00 : 00",
});
this.notification = useService("notification");
this.userSettings = useService("mail.user_settings");
this.voiceMessageService = useState(useService("discuss.voice_message"));
this.config = {
// 128 or 160 kbit/s – mid-range bitrate quality
bitRate: 128,
};
onWillUnmount(() => {
if (this.state.recording) {
this.notification.add(_t("Voice recording stopped"), { type: "warning" });
this.stopRecording();
} else {
this.cleanUp();
}
});
}
filename() {
return (
"Voice-" +
new Date().toISOString().split("T")[0] +
"-" +
Math.floor(Math.random() * 100000) +
".mp3"
);
}
async startRecording() {
if (this.state.isActionPending) {
return;
}
this.state.isActionPending = true;
if (!this.microphone) {
try {
this.microphone = await browser.navigator.mediaDevices.getUserMedia({
audio: this.userSettings.audioConstraints,
});
} catch {
this.notification.add(
sprintf(_t('"%(hostname)s" needs to access your microphone'), {
hostname: window.location.host,
}),
{ type: "warning" }
);
this.state.isActionPending = false;
return;
}
}
this.state.elapsed = "00 : 00";
this.props.onchangeRecording?.();
this.state.recording = true;
this.audioContext = new browser.AudioContext();
await loadLamejs();
await this.audioContext.audioWorklet.addModule("/discuss/voice/worklet_processor");
this.processor = new browser.AudioWorkletNode(this.audioContext, "processor");
this.processor.port.onmessage = (e) => {
if (this.state.recording && !this.startTimeStamp) {
this.startTimeStamp = e.timeStamp;
}
if (!this.startTimeStamp) {
return;
}
const elapsedSeconds = Math.floor((e.timeStamp - this.startTimeStamp) / 1000);
const second = elapsedSeconds % 60;
const minute = Math.floor(elapsedSeconds / 60);
this.state.elapsed =
(minute < 10 ? "0" + minute : minute) +
" : " +
(second < 10 ? "0" + second : second);
if (elapsedSeconds > 55 && elapsedSeconds < 60) {
this.state.limitWarning = true;
}
if (elapsedSeconds === 60) {
this.notification.add(
_t("The duration of voice messages is limited to 1 minute."),
{ type: "warning" }
);
this.stopRecording();
}
if (!e.data) {
return;
}
this._encode(e.data);
};
this.streamSource = this.audioContext.createMediaStreamSource(this.microphone);
// Start to get microphone data
this.streamSource.connect(this.processor);
this.processor.connect(this.audioContext.destination);
this.config.sampleRate = this.audioContext.sampleRate;
this.encoder = new Mp3Encoder(this.config);
this.state.isActionPending = false;
}
_encode(data) {
this.encoder.encode(data);
}
_getEncoderBuffer() {
return this.encoder.finish();
}
_makeFile(buffer, type) {
return new File(buffer, this.filename(), { type });
}
stopRecording() {
this.getMp3()
.then((buffer) => {
const file = this._makeFile(buffer, "audio/mp3");
this.props.attachmentUploader.uploadFile(file, { isVoice: true });
})
.catch(() => {});
this.cleanUp();
}
cleanUp({ unmounting = false } = {}) {
if (this.processor && this.streamSource) {
// Clean up the Web Audio API resources.
this.streamSource.disconnect();
this.processor.disconnect();
if (this.audioContext && this.audioContext.state !== "closed") {
// If all references using this.audioContext are destroyed, context is
// closed automatically. DOMException is fired when trying to close again
this.audioContext.close();
}
}
this.startTimeStamp = false;
this.microphone?.getTracks().forEach((track) => track.stop());
this.microphone = null;
this.state.recording = false;
this.state.limitWarning = false;
if (!unmounting) {
this.props.onchangeRecording?.();
}
}
getMp3() {
const finalBuffer = this._getEncoderBuffer();
return new Promise((resolve, reject) => {
if (finalBuffer.length === 0) {
reject(new Error("No buffer to send"));
} else {
resolve(finalBuffer);
this.encoder.clearBuffer();
}
});
}
onClick(ev) {
if (this.state.recording) {
this.stopRecording();
} else {
this.startRecording();
}
}
}
@@ -0,0 +1,19 @@
.o-mail-VoiceRecorder.o-recording {
@extend .bg-200;
}
.o-mail-VoiceRecorder-dot {
animation: flash 2s ease infinite;
animation-iteration-count: 2;
}
.o-mail-VoiceRecorder-elapsed {
width: max-content;
&.o-active {
transition: max-width 2s ease-in-out, width 1s ease-in-out;
max-width: 500px;
}
&:not(.o-active) {
transition: max-width 2s ease-in-out;
}
}
@@ -0,0 +1,16 @@
<?xml version="1.0" encoding="UTF-8"?>
<templates xml:space="preserve">
<t t-name="mail.VoiceRecorder" owl="1">
<div class="o-mail-VoiceRecorder d-flex align-items-center" t-att-class="{ 'o-recording rounded': state.recording }">
<t t-set="title"><t t-if="!state.recording">Voice Message</t><t t-else="">Stop Recording</t></t>
<div class="o-mail-VoiceRecorder-elapsed" t-att-class="{
'o-active recording ms-2 me-1': state.recording,
'mw-0': !state.recording,
'text-danger': state.limitWarning,
}" style="font-variant-numeric: tabular-nums;"><span class="d-flex text-truncate" t-esc="state.elapsed"/></div>
<button t-att-disabled="state.isActionPending or props.composer.voiceAttachment" class="border-0 btn rounded-pill" t-att-title="title" t-on-click="onClick"><i t-att-class="!state.recording ? 'fa fa-microphone' : 'fa fa-circle text-danger o-mail-VoiceRecorder-dot'"/></button>
</div>
</t>
</templates>
@@ -0,0 +1,11 @@
class AudioProcessor extends AudioWorkletProcessor {
constructor(options) {
super();
}
process(allInputs) {
const inputs = allInputs[0][0];
this.port.postMessage(inputs);
return true;
}
}
registerProcessor("processor", AudioProcessor);
@@ -0,0 +1,183 @@
/* @odoo-module */
import { Command } from "@mail/../tests/helpers/command";
import {
click,
createFile,
mockGetMedia,
nextAnimationFrame,
start,
startServer,
waitUntil,
} from "@mail/../tests/helpers/test_utils";
import { VoicePlayer } from "@mail/discuss/voice_message/common/voice_player";
import { VoiceRecorder } from "@mail/discuss/voice_message/common/voice_recorder";
import { patchDate, patchWithCleanup } from "@web/../tests/helpers/utils";
import { browser } from "@web/core/browser/browser";
import { Deferred } from "@web/core/utils/concurrency";
import { url } from "@web/core/utils/urls";
QUnit.module("voice message");
let audioProcessor;
function patchAudio() {
const {
AnalyserNode,
AudioBufferSourceNode,
AudioContext,
AudioWorkletNode,
GainNode,
MediaStreamAudioSourceNode,
} = browser;
Object.assign(browser, {
AnalyserNode: class {
connect() {}
disconnect() {}
},
AudioBufferSourceNode: class {
buffer;
constructor() {}
connect() {}
disconnect() {}
start() {}
stop() {}
},
AudioContext: class {
audioWorklet;
currentTime;
destination;
sampleRate;
state;
constructor() {
this.audioWorklet = {
addModule(url) {},
};
}
close() {}
/** @returns {AnalyserNode} */
createAnalyser() {
return new browser.AnalyserNode();
}
/** @returns {AudioBufferSourceNode} */
createBufferSource() {
return new browser.AudioBufferSourceNode();
}
/** @returns {GainNode} */
createGain() {
return new browser.GainNode();
}
/** @returns {MediaStreamAudioSourceNode} */
createMediaStreamSource(microphone) {
return new browser.MediaStreamAudioSourceNode();
}
/** @returns {AudioBuffer} */
decodeAudioData(...args) {
return new AudioContext().decodeAudioData(...args);
}
},
AudioWorkletNode: class {
port;
constructor(audioContext, processorName) {
this.port = {
onmessage(e) {},
postMessage(data) {
this.onmessage({ data, timeStamp: new Date().getTime() });
},
};
audioProcessor = this;
}
connect() {
this.port.postMessage();
}
disconnect() {}
process(allInputs) {
const inputs = allInputs[0][0];
this.port.postMessage(inputs);
return true;
}
},
GainNode: class {
connect() {}
close() {}
disconnect() {}
},
MediaStreamAudioSourceNode: class {
connect(processor) {}
disconnect() {}
},
});
return () => {
Object.assign(browser, {
AnalyserNode,
AudioBufferSourceNode,
AudioContext,
AudioWorkletNode,
GainNode,
MediaStreamAudioSourceNode,
});
};
}
QUnit.test("make voice message in chat", async (assert) => {
const file = await createFile({
content: Array(500).map(() => new Int8Array()), // some non-empty content
contentType: "audio/mp3",
name: "test.mp3",
size: 25_000,
});
const voicePlayerDrawing = new Deferred();
patchWithCleanup(VoiceRecorder.prototype, {
_encode() {},
_getEncoderBuffer() {
return Array(500).map(() => new Int8Array());
},
_makeFile() {
return file;
},
});
patchWithCleanup(VoicePlayer.prototype, {
async drawWave(...args) {
voicePlayerDrawing.resolve();
return super.drawWave(...args);
},
async fetchFile() {
return super.fetchFile(url("/mail/static/src/audio/call_02_in_.mp3"));
},
});
patchDate(2023, 6, 31, 13, 0, 0);
mockGetMedia();
const cleanUp = patchAudio();
const pyEnv = await startServer();
const partnerId = pyEnv["res.partner"].create({ name: "Demo" });
const channelId = pyEnv["discuss.channel"].create({
channel_member_ids: [
Command.create({ partner_id: pyEnv.currentPartnerId }),
Command.create({ partner_id: partnerId }),
],
channel_type: "chat",
});
const { openDiscuss } = await start();
await openDiscuss(channelId);
assert.containsOnce($, "button[title='Voice Message']");
await click("button[title='Voice Message']");
assert.containsOnce($, ".o-mail-VoiceRecorder");
assert.containsOnce($, ".o-mail-VoiceRecorder:contains(00 : 00)");
// simulate 10 sec elapsed
patchDate(2023, 6, 31, 13, 0, 11); // +1 so exactly 10 sec elapsed
// simulate some microphone data
audioProcessor.process([[new Float32Array(128)]]);
await waitUntil(".o-mail-VoiceRecorder:contains(00 : 10)");
assert.containsOnce($, "button[title='Stop Recording']");
await click("button[title='Stop Recording']");
assert.containsOnce($, ".o-mail-VoicePlayer");
// wait for audio stream decode + drawing of waves
await voicePlayerDrawing;
await nextAnimationFrame();
assert.containsOnce($, ".o-mail-VoicePlayer button[title='Play']");
assert.containsN($, ".o-mail-VoicePlayer canvas", 2); // 1 for global waveforms, 1 for played waveforms
assert.containsOnce($, ".o-mail-VoicePlayer:contains(00 : 04)"); // duration of call_02_in_.mp3
assert.containsOnce($, ".o-mail-VoiceRecorder button[title='Voice Message']");
assert.ok($(".o-mail-VoiceRecorder button[title='Voice Message']")[0].disabled);
cleanUp();
});
@@ -20,14 +20,10 @@ patch(MockServer.prototype, {
res_id: id,
res_model: model,
});
const attachment = this.getRecords("ir.attachment", [["id", "=", attachmentId]])[0];
return {
filename: attachment.name,
name: attachment.name,
id: attachment.id,
mimetype: attachment.mimetype,
size: attachment.file_size,
};
if (args.body.get("voice")) {
this.mockCreate("discuss.voice.metadata", { attachment_id: attachmentId });
}
return this._mockIrAttachment_attachmentFormat([attachmentId])[0];
}
return super.performRPC(...arguments);
},
@@ -27,6 +27,7 @@ patch(MockServer.prototype, {
id: attachment.id,
mimetype: attachment.mimetype,
name: attachment.name,
size: attachment.file_size,
};
res["originThread"] = [
[
@@ -37,6 +38,12 @@ patch(MockServer.prototype, {
},
],
];
const voice = this.getRecords("discuss.voice.metadata", [
["attachment_id", "=", attachment.id],
])[0];
if (voice) {
res.voice = true;
}
return res;
});
},
@@ -23,6 +23,7 @@ addModelNamesToFetch([
"discuss.channel.member",
"discuss.channel.rtc.session",
"discuss.gif.favorite",
"discuss.voice.metadata",
"mail.followers",
"mail.guest",
"mail.link.preview",
@@ -3,6 +3,7 @@
import { getPyEnv, startServer } from "@bus/../tests/helpers/mock_python_environment";
import { loadEmoji } from "@web/core/emoji_picker/emoji_picker";
import { loadLamejs } from "@mail/discuss/voice_message/common/voice_message_service";
import { patchBrowserNotification } from "@mail/../tests/helpers/patch_notifications";
import { getAdvanceTime } from "@mail/../tests/helpers/time_control";
import { getWebClientReady } from "@mail/../tests/helpers/webclient_setup";
@@ -23,8 +24,9 @@ import { doAction, getActionManagerServerData } from "@web/../tests/webclient/he
const { afterNextRender } = App;
// load emoji data once, when the test suite starts.
// load emoji data and lamejs once, when the test suite starts.
QUnit.begin(loadEmoji);
QUnit.begin(loadLamejs);
registryNamesToCloneWithCleanup.push("mock_server_callbacks");
//------------------------------------------------------------------------------
+3 -3
View File
@@ -401,7 +401,7 @@ class TestMailAPIPerformance(BaseMailPerformance):
composer_form.attachment_ids.add(attachment)
composer = composer_form.save()
with self.assertQueryCount(admin=48, employee=48): # tm+com 46/46
with self.assertQueryCount(admin=49, employee=49): # tm+com 46/46
composer._action_send_mail()
# notifications
@@ -1228,7 +1228,7 @@ class TestMailComplexPerformance(BaseMailPerformance):
"""
messages_all = self.messages_all.with_env(self.env)
with self.assertQueryCount(employee=24): # test_mail: 24
with self.assertQueryCount(employee=25): # test_mail: 24
res = messages_all.message_format()
self.assertEqual(len(res), 2*2)
@@ -1241,7 +1241,7 @@ class TestMailComplexPerformance(BaseMailPerformance):
def test_message_format_single(self):
message = self.messages_all[0].with_env(self.env)
with self.assertQueryCount(employee=21): # test_mail: 21
with self.assertQueryCount(employee=22): # test_mail: 21
res = message.message_format()
self.assertEqual(len(res), 1)
@@ -22,7 +22,13 @@ try {
export const browser = {
addEventListener: window.addEventListener.bind(window),
AnalyserNode: window.AnalyserNode,
Audio: window.Audio,
AudioBufferSourceNode: window.AudioBufferSourceNode,
AudioContext: window.AudioContext,
AudioWorkletNode: window.AudioWorkletNode,
GainNode: window.GainNode,
MediaStreamAudioSourceNode: window.MediaStreamAudioSourceNode,
removeEventListener: window.removeEventListener.bind(window),
setTimeout: window.setTimeout.bind(window),
clearTimeout: window.clearTimeout.bind(window),