[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:
@@ -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/**/*',
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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"),
|
||||
],
|
||||
)
|
||||
@@ -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
|
||||
)
|
||||
@@ -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])
|
||||
|
||||
@@ -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):
|
||||
|
||||
@@ -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
|
||||
|
||||
|
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
|
||||
|
||||
@@ -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");
|
||||
|
||||
//------------------------------------------------------------------------------
|
||||
|
||||
@@ -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),
|
||||
|
||||
Reference in New Issue
Block a user