summaryrefslogtreecommitdiff
path: root/addons/mail/static/src/models
diff options
context:
space:
mode:
authorstephanchrst <stephanchrst@gmail.com>2022-05-10 21:51:50 +0700
committerstephanchrst <stephanchrst@gmail.com>2022-05-10 21:51:50 +0700
commit3751379f1e9a4c215fb6eb898b4ccc67659b9ace (patch)
treea44932296ef4a9b71d5f010906253d8c53727726 /addons/mail/static/src/models
parent0a15094050bfde69a06d6eff798e9a8ddf2b8c21 (diff)
initial commit 2
Diffstat (limited to 'addons/mail/static/src/models')
-rw-r--r--addons/mail/static/src/models/activity/activity.js355
-rw-r--r--addons/mail/static/src/models/activity_type/activity_type.js39
-rw-r--r--addons/mail/static/src/models/attachment/attachment.js439
-rw-r--r--addons/mail/static/src/models/attachment/attachment_tests.js144
-rw-r--r--addons/mail/static/src/models/attachment_viewer/attachment_viewer.js59
-rw-r--r--addons/mail/static/src/models/canned_response/canned_response.js107
-rw-r--r--addons/mail/static/src/models/channel_command/channel_command.js130
-rw-r--r--addons/mail/static/src/models/chat_window/chat_window.js480
-rw-r--r--addons/mail/static/src/models/chat_window_manager/chat_window_manager.js487
-rw-r--r--addons/mail/static/src/models/chatter/chatter.js334
-rw-r--r--addons/mail/static/src/models/composer/composer.js1435
-rw-r--r--addons/mail/static/src/models/country/country.js55
-rw-r--r--addons/mail/static/src/models/device/device.js71
-rw-r--r--addons/mail/static/src/models/dialog/dialog.js32
-rw-r--r--addons/mail/static/src/models/dialog_manager/dialog_manager.js52
-rw-r--r--addons/mail/static/src/models/discuss/discuss.js568
-rw-r--r--addons/mail/static/src/models/follower/follower.js293
-rw-r--r--addons/mail/static/src/models/follower_subtype/follower_subtype.js82
-rw-r--r--addons/mail/static/src/models/follower_subtype_list/follower_subtype_list.js22
-rw-r--r--addons/mail/static/src/models/locale/locale.js52
-rw-r--r--addons/mail/static/src/models/mail_template/mail_template.js83
-rw-r--r--addons/mail/static/src/models/message/message.js817
-rw-r--r--addons/mail/static/src/models/message/message_tests.js187
-rw-r--r--addons/mail/static/src/models/message_seen_indicator/message_seen_indicator.js358
-rw-r--r--addons/mail/static/src/models/messaging/messaging.js253
-rw-r--r--addons/mail/static/src/models/messaging/messaging_tests.js126
-rw-r--r--addons/mail/static/src/models/messaging_initializer/messaging_initializer.js304
-rw-r--r--addons/mail/static/src/models/messaging_menu/messaging_menu.js154
-rw-r--r--addons/mail/static/src/models/messaging_notification_handler/messaging_notification_handler.js795
-rw-r--r--addons/mail/static/src/models/model/model.js291
-rw-r--r--addons/mail/static/src/models/notification/notification.js80
-rw-r--r--addons/mail/static/src/models/notification_group/notification_group.js126
-rw-r--r--addons/mail/static/src/models/notification_group_manager/notification_group_manager.js77
-rw-r--r--addons/mail/static/src/models/partner/partner.js527
-rw-r--r--addons/mail/static/src/models/suggested_recipient_info/suggested_recipient_info.js116
-rw-r--r--addons/mail/static/src/models/thread/thread.js2324
-rw-r--r--addons/mail/static/src/models/thread/thread_tests.js150
-rw-r--r--addons/mail/static/src/models/thread_cache/thread_cache.js617
-rw-r--r--addons/mail/static/src/models/thread_partner_seen_info/thread_partner_seen_info.js109
-rw-r--r--addons/mail/static/src/models/thread_view/thread_view.js441
-rw-r--r--addons/mail/static/src/models/thread_view/thread_viewer.js296
-rw-r--r--addons/mail/static/src/models/user/user.js254
42 files changed, 13721 insertions, 0 deletions
diff --git a/addons/mail/static/src/models/activity/activity.js b/addons/mail/static/src/models/activity/activity.js
new file mode 100644
index 00000000..f2023aac
--- /dev/null
+++ b/addons/mail/static/src/models/activity/activity.js
@@ -0,0 +1,355 @@
+odoo.define('mail/static/src/models/activity/activity/js', function (require) {
+'use strict';
+
+const { registerNewModel } = require('mail/static/src/model/model_core.js');
+const { attr, many2many, many2one } = require('mail/static/src/model/model_field.js');
+const { clear } = require('mail/static/src/model/model_field_command.js');
+
+function factory(dependencies) {
+
+ class Activity extends dependencies['mail.model'] {
+
+
+ //----------------------------------------------------------------------
+ // Public
+ //----------------------------------------------------------------------
+
+ /**
+ * Delete the record from database and locally.
+ */
+ async deleteServerRecord() {
+ await this.async(() => this.env.services.rpc({
+ model: 'mail.activity',
+ method: 'unlink',
+ args: [[this.id]],
+ }));
+ this.delete();
+ }
+
+ //----------------------------------------------------------------------
+ // Public
+ //----------------------------------------------------------------------
+
+ /**
+ * @static
+ * @param {Object} data
+ * @return {Object}
+ */
+ static convertData(data) {
+ const data2 = {};
+ if ('activity_category' in data) {
+ data2.category = data.activity_category;
+ }
+ if ('can_write' in data) {
+ data2.canWrite = data.can_write;
+ }
+ if ('create_date' in data) {
+ data2.dateCreate = data.create_date;
+ }
+ if ('date_deadline' in data) {
+ data2.dateDeadline = data.date_deadline;
+ }
+ if ('force_next' in data) {
+ data2.force_next = data.force_next;
+ }
+ if ('icon' in data) {
+ data2.icon = data.icon;
+ }
+ if ('id' in data) {
+ data2.id = data.id;
+ }
+ if ('note' in data) {
+ data2.note = data.note;
+ }
+ if ('state' in data) {
+ data2.state = data.state;
+ }
+ if ('summary' in data) {
+ data2.summary = data.summary;
+ }
+
+ // relation
+ if ('activity_type_id' in data) {
+ if (!data.activity_type_id) {
+ data2.type = [['unlink-all']];
+ } else {
+ data2.type = [
+ ['insert', {
+ displayName: data.activity_type_id[1],
+ id: data.activity_type_id[0],
+ }],
+ ];
+ }
+ }
+ if ('create_uid' in data) {
+ if (!data.create_uid) {
+ data2.creator = [['unlink-all']];
+ } else {
+ data2.creator = [
+ ['insert', {
+ id: data.create_uid[0],
+ display_name: data.create_uid[1],
+ }],
+ ];
+ }
+ }
+ if ('mail_template_ids' in data) {
+ data2.mailTemplates = [['insert', data.mail_template_ids]];
+ }
+ if ('res_id' in data && 'res_model' in data) {
+ data2.thread = [['insert', {
+ id: data.res_id,
+ model: data.res_model,
+ }]];
+ }
+ if ('user_id' in data) {
+ if (!data.user_id) {
+ data2.assignee = [['unlink-all']];
+ } else {
+ data2.assignee = [
+ ['insert', {
+ id: data.user_id[0],
+ display_name: data.user_id[1],
+ }],
+ ];
+ }
+ }
+ if ('request_partner_id' in data) {
+ if (!data.request_partner_id) {
+ data2.requestingPartner = [['unlink']];
+ } else {
+ data2.requestingPartner = [
+ ['insert', {
+ id: data.request_partner_id[0],
+ display_name: data.request_partner_id[1],
+ }],
+ ];
+ }
+ }
+
+ return data2;
+ }
+
+ /**
+ * Opens (legacy) form view dialog to edit current activity and updates
+ * the activity when dialog is closed.
+ */
+ edit() {
+ const action = {
+ type: 'ir.actions.act_window',
+ name: this.env._t("Schedule Activity"),
+ res_model: 'mail.activity',
+ view_mode: 'form',
+ views: [[false, 'form']],
+ target: 'new',
+ context: {
+ default_res_id: this.thread.id,
+ default_res_model: this.thread.model,
+ },
+ res_id: this.id,
+ };
+ this.env.bus.trigger('do-action', {
+ action,
+ options: { on_close: () => this.fetchAndUpdate() },
+ });
+ }
+
+ async fetchAndUpdate() {
+ const [data] = await this.async(() => this.env.services.rpc({
+ model: 'mail.activity',
+ method: 'activity_format',
+ args: [this.id],
+ }, { shadow: true }));
+ let shouldDelete = false;
+ if (data) {
+ this.update(this.constructor.convertData(data));
+ } else {
+ shouldDelete = true;
+ }
+ this.thread.refreshActivities();
+ this.thread.refresh();
+ if (shouldDelete) {
+ this.delete();
+ }
+ }
+
+ /**
+ * @param {Object} param0
+ * @param {mail.attachment[]} [param0.attachments=[]]
+ * @param {string|boolean} [param0.feedback=false]
+ */
+ async markAsDone({ attachments = [], feedback = false }) {
+ const attachmentIds = attachments.map(attachment => attachment.id);
+ await this.async(() => this.env.services.rpc({
+ model: 'mail.activity',
+ method: 'action_feedback',
+ args: [[this.id]],
+ kwargs: {
+ attachment_ids: attachmentIds,
+ feedback,
+ },
+ }));
+ this.thread.refresh();
+ this.delete();
+ }
+
+ /**
+ * @param {Object} param0
+ * @param {string} param0.feedback
+ * @returns {Object}
+ */
+ async markAsDoneAndScheduleNext({ feedback }) {
+ const action = await this.async(() => this.env.services.rpc({
+ model: 'mail.activity',
+ method: 'action_feedback_schedule_next',
+ args: [[this.id]],
+ kwargs: { feedback },
+ }));
+ this.thread.refresh();
+ const thread = this.thread;
+ this.delete();
+ if (!action) {
+ thread.refreshActivities();
+ return;
+ }
+ this.env.bus.trigger('do-action', {
+ action,
+ options: {
+ on_close: () => {
+ thread.refreshActivities();
+ },
+ },
+ });
+ }
+
+ //----------------------------------------------------------------------
+ // Private
+ //----------------------------------------------------------------------
+
+ /**
+ * @override
+ */
+ static _createRecordLocalId(data) {
+ return `${this.modelName}_${data.id}`;
+ }
+
+ /**
+ * @private
+ * @returns {boolean}
+ */
+ _computeIsCurrentPartnerAssignee() {
+ if (!this.assigneePartner || !this.messagingCurrentPartner) {
+ return false;
+ }
+ return this.assigneePartner === this.messagingCurrentPartner;
+ }
+
+ /**
+ * @private
+ * @returns {mail.messaging}
+ */
+ _computeMessaging() {
+ return [['link', this.env.messaging]];
+ }
+
+ /**
+ * Wysiwyg editor put `<p><br></p>` even without a note on the activity.
+ * This compute replaces this almost empty value by an actual empty
+ * value, to reduce the size the empty note takes on the UI.
+ *
+ * @private
+ * @returns {string|undefined}
+ */
+ _computeNote() {
+ if (this.note === '<p><br></p>') {
+ return clear();
+ }
+ return this.note;
+ }
+ }
+
+ Activity.fields = {
+ assignee: many2one('mail.user'),
+ assigneePartner: many2one('mail.partner', {
+ related: 'assignee.partner',
+ }),
+ attachments: many2many('mail.attachment', {
+ inverse: 'activities',
+ }),
+ canWrite: attr({
+ default: false,
+ }),
+ category: attr(),
+ creator: many2one('mail.user'),
+ dateCreate: attr(),
+ dateDeadline: attr(),
+ /**
+ * Backup of the feedback content of an activity to be marked as done in the popover.
+ * Feature-specific to restoring the feedback content when component is re-mounted.
+ * In all other cases, this field value should not be trusted.
+ */
+ feedbackBackup: attr(),
+ force_next: attr({
+ default: false,
+ }),
+ icon: attr(),
+ id: attr(),
+ isCurrentPartnerAssignee: attr({
+ compute: '_computeIsCurrentPartnerAssignee',
+ default: false,
+ dependencies: [
+ 'assigneePartner',
+ 'messagingCurrentPartner',
+ ],
+ }),
+ mailTemplates: many2many('mail.mail_template', {
+ inverse: 'activities',
+ }),
+ messaging: many2one('mail.messaging', {
+ compute: '_computeMessaging',
+ }),
+ messagingCurrentPartner: many2one('mail.partner', {
+ related: 'messaging.currentPartner',
+ }),
+ /**
+ * This value is meant to be returned by the server
+ * (and has been sanitized before stored into db).
+ * Do not use this value in a 't-raw' if the activity has been created
+ * directly from user input and not from server data as it's not escaped.
+ */
+ note: attr({
+ compute: '_computeNote',
+ dependencies: [
+ 'note',
+ ],
+ }),
+ /**
+ * Determines that an activity is linked to a requesting partner or not.
+ * It will be used notably in website slides to know who triggered the
+ * "request access" activity.
+ * Also, be useful when the assigned user is different from the
+ * "source" or "requesting" partner.
+ */
+ requestingPartner: many2one('mail.partner'),
+ state: attr(),
+ summary: attr(),
+ /**
+ * Determines to which "thread" (using `mail.activity.mixin` on the
+ * server) `this` belongs to.
+ */
+ thread: many2one('mail.thread', {
+ inverse: 'activities',
+ }),
+ type: many2one('mail.activity_type', {
+ inverse: 'activities',
+ }),
+ };
+
+ Activity.modelName = 'mail.activity';
+
+ return Activity;
+}
+
+registerNewModel('mail.activity', factory);
+
+});
diff --git a/addons/mail/static/src/models/activity_type/activity_type.js b/addons/mail/static/src/models/activity_type/activity_type.js
new file mode 100644
index 00000000..f8a621a8
--- /dev/null
+++ b/addons/mail/static/src/models/activity_type/activity_type.js
@@ -0,0 +1,39 @@
+odoo.define('mail/static/src/models/activity_type/activity_type.js', function (require) {
+'use strict';
+
+const { registerNewModel } = require('mail/static/src/model/model_core.js');
+const { attr, one2many } = require('mail/static/src/model/model_field.js');
+
+function factory(dependencies) {
+
+ class ActivityType extends dependencies['mail.model'] {
+
+ //----------------------------------------------------------------------
+ // Private
+ //----------------------------------------------------------------------
+
+ /**
+ * @override
+ */
+ static _createRecordLocalId(data) {
+ return `${this.modelName}_${data.id}`;
+ }
+
+ }
+
+ ActivityType.fields = {
+ activities: one2many('mail.activity', {
+ inverse: 'type',
+ }),
+ displayName: attr(),
+ id: attr(),
+ };
+
+ ActivityType.modelName = 'mail.activity_type';
+
+ return ActivityType;
+}
+
+registerNewModel('mail.activity_type', factory);
+
+});
diff --git a/addons/mail/static/src/models/attachment/attachment.js b/addons/mail/static/src/models/attachment/attachment.js
new file mode 100644
index 00000000..a49b0a87
--- /dev/null
+++ b/addons/mail/static/src/models/attachment/attachment.js
@@ -0,0 +1,439 @@
+odoo.define('mail/static/src/models/attachment/attachment.js', function (require) {
+'use strict';
+
+const { registerNewModel } = require('mail/static/src/model/model_core.js');
+const { attr, many2many, many2one } = require('mail/static/src/model/model_field.js');
+const { clear } = require('mail/static/src/model/model_field_command.js');
+
+function factory(dependencies) {
+
+ let nextTemporaryId = -1;
+ function getAttachmentNextTemporaryId() {
+ const id = nextTemporaryId;
+ nextTemporaryId -= 1;
+ return id;
+ }
+ class Attachment extends dependencies['mail.model'] {
+
+ //----------------------------------------------------------------------
+ // Public
+ //----------------------------------------------------------------------
+
+ /**
+ * @static
+ * @param {Object} data
+ * @return {Object}
+ */
+ static convertData(data) {
+ const data2 = {};
+ if ('filename' in data) {
+ data2.filename = data.filename;
+ }
+ if ('id' in data) {
+ data2.id = data.id;
+ }
+ if ('mimetype' in data) {
+ data2.mimetype = data.mimetype;
+ }
+ if ('name' in data) {
+ data2.name = data.name;
+ }
+
+ // relation
+ if ('res_id' in data && 'res_model' in data) {
+ data2.originThread = [['insert', {
+ id: data.res_id,
+ model: data.res_model,
+ }]];
+ }
+
+ return data2;
+ }
+
+ /**
+ * @override
+ */
+ static create(data) {
+ const isMulti = typeof data[Symbol.iterator] === 'function';
+ const dataList = isMulti ? data : [data];
+ for (const data of dataList) {
+ if (!data.id) {
+ data.id = getAttachmentNextTemporaryId();
+ }
+ }
+ return super.create(...arguments);
+ }
+
+ /**
+ * View provided attachment(s), with given attachment initially. Prompts
+ * the attachment viewer.
+ *
+ * @static
+ * @param {Object} param0
+ * @param {mail.attachment} [param0.attachment]
+ * @param {mail.attachments[]} param0.attachments
+ * @returns {string|undefined} unique id of open dialog, if open
+ */
+ static view({ attachment, attachments }) {
+ const hasOtherAttachments = attachments && attachments.length > 0;
+ if (!attachment && !hasOtherAttachments) {
+ return;
+ }
+ if (!attachment && hasOtherAttachments) {
+ attachment = attachments[0];
+ } else if (attachment && !hasOtherAttachments) {
+ attachments = [attachment];
+ }
+ if (!attachments.includes(attachment)) {
+ return;
+ }
+ this.env.messaging.dialogManager.open('mail.attachment_viewer', {
+ attachment: [['link', attachment]],
+ attachments: [['replace', attachments]],
+ });
+ }
+
+ /**
+ * Remove this attachment globally.
+ */
+ async remove() {
+ if (this.isUnlinkPending) {
+ return;
+ }
+ if (!this.isTemporary) {
+ this.update({ isUnlinkPending: true });
+ try {
+ await this.async(() => this.env.services.rpc({
+ model: 'ir.attachment',
+ method: 'unlink',
+ args: [this.id],
+ }, { shadow: true }));
+ } finally {
+ this.update({ isUnlinkPending: false });
+ }
+ } else if (this.uploadingAbortController) {
+ this.uploadingAbortController.abort();
+ }
+ this.delete();
+ }
+
+ //----------------------------------------------------------------------
+ // Private
+ //----------------------------------------------------------------------
+
+ /**
+ * @override
+ */
+ static _createRecordLocalId(data) {
+ return `${this.modelName}_${data.id}`;
+ }
+
+ /**
+ * @private
+ * @returns {mail.composer[]}
+ */
+ _computeComposers() {
+ if (this.isTemporary) {
+ return [];
+ }
+ const relatedTemporaryAttachment = this.env.models['mail.attachment']
+ .find(attachment =>
+ attachment.filename === this.filename &&
+ attachment.isTemporary
+ );
+ if (relatedTemporaryAttachment) {
+ const composers = relatedTemporaryAttachment.composers;
+ relatedTemporaryAttachment.delete();
+ return [['replace', composers]];
+ }
+ return [];
+ }
+
+ /**
+ * @private
+ * @returns {string|undefined}
+ */
+ _computeDefaultSource() {
+ if (this.fileType === 'image') {
+ return `/web/image/${this.id}?unique=1&amp;signature=${this.checksum}&amp;model=ir.attachment`;
+ }
+ if (this.fileType === 'application/pdf') {
+ return `/web/static/lib/pdfjs/web/viewer.html?file=/web/content/${this.id}?model%3Dir.attachment`;
+ }
+ if (this.fileType && this.fileType.includes('text')) {
+ return `/web/content/${this.id}?model%3Dir.attachment`;
+ }
+ if (this.fileType === 'youtu') {
+ const urlArr = this.url.split('/');
+ let token = urlArr[urlArr.length - 1];
+ if (token.includes('watch')) {
+ token = token.split('v=')[1];
+ const amp = token.indexOf('&');
+ if (amp !== -1) {
+ token = token.substring(0, amp);
+ }
+ }
+ return `https://www.youtube.com/embed/${token}`;
+ }
+ if (this.fileType === 'video') {
+ return `/web/content/${this.id}?model=ir.attachment`;
+ }
+ return clear();
+ }
+
+ /**
+ * @private
+ * @returns {string|undefined}
+ */
+ _computeDisplayName() {
+ const displayName = this.name || this.filename;
+ if (displayName) {
+ return displayName;
+ }
+ return clear();
+ }
+
+ /**
+ * @private
+ * @returns {string|undefined}
+ */
+ _computeExtension() {
+ const extension = this.filename && this.filename.split('.').pop();
+ if (extension) {
+ return extension;
+ }
+ return clear();
+ }
+
+ /**
+ * @private
+ * @returns {string|undefined}
+ */
+ _computeFileType() {
+ if (this.type === 'url' && !this.url) {
+ return clear();
+ } else if (!this.mimetype) {
+ return clear();
+ }
+ switch (this.mimetype) {
+ case 'application/pdf':
+ return 'application/pdf';
+ case 'image/bmp':
+ case 'image/gif':
+ case 'image/jpeg':
+ case 'image/png':
+ case 'image/svg+xml':
+ case 'image/tiff':
+ case 'image/x-icon':
+ return 'image';
+ case 'application/javascript':
+ case 'application/json':
+ case 'text/css':
+ case 'text/html':
+ case 'text/plain':
+ return 'text';
+ case 'audio/mpeg':
+ case 'video/x-matroska':
+ case 'video/mp4':
+ case 'video/webm':
+ return 'video';
+ }
+ if (!this.url) {
+ return clear();
+ }
+ if (this.url.match('(.png|.jpg|.gif)')) {
+ return 'image';
+ }
+ if (this.url.includes('youtu')) {
+ return 'youtu';
+ }
+ return clear();
+ }
+
+ /**
+ * @private
+ * @returns {boolean}
+ */
+ _computeIsLinkedToComposer() {
+ return this.composers.length > 0;
+ }
+
+ /**
+ * @private
+ * @returns {boolean}
+ */
+ _computeIsTextFile() {
+ if (!this.fileType) {
+ return false;
+ }
+ return this.fileType === 'text';
+ }
+
+ /**
+ * @private
+ * @returns {boolean}
+ */
+ _computeIsViewable() {
+ switch (this.mimetype) {
+ case 'application/javascript':
+ case 'application/json':
+ case 'application/pdf':
+ case 'audio/mpeg':
+ case 'image/bmp':
+ case 'image/gif':
+ case 'image/jpeg':
+ case 'image/png':
+ case 'image/svg+xml':
+ case 'image/tiff':
+ case 'image/x-icon':
+ case 'text/css':
+ case 'text/html':
+ case 'text/plain':
+ case 'video/x-matroska':
+ case 'video/mp4':
+ case 'video/webm':
+ return true;
+ default:
+ return false;
+ }
+ }
+
+ /**
+ * @deprecated
+ * @private
+ * @returns {string}
+ */
+ _computeMediaType() {
+ return this.mimetype && this.mimetype.split('/').shift();
+ }
+
+ /**
+ * @private
+ * @returns {AbortController|undefined}
+ */
+ _computeUploadingAbortController() {
+ if (this.isTemporary) {
+ if (!this.uploadingAbortController) {
+ const abortController = new AbortController();
+ abortController.signal.onabort = () => {
+ this.env.messagingBus.trigger('o-attachment-upload-abort', {
+ attachment: this
+ });
+ };
+ return abortController;
+ }
+ return this.uploadingAbortController;
+ }
+ return undefined;
+ }
+ }
+
+ Attachment.fields = {
+ activities: many2many('mail.activity', {
+ inverse: 'attachments',
+ }),
+ attachmentViewer: many2many('mail.attachment_viewer', {
+ inverse: 'attachments',
+ }),
+ checkSum: attr(),
+ composers: many2many('mail.composer', {
+ compute: '_computeComposers',
+ inverse: 'attachments',
+ }),
+ defaultSource: attr({
+ compute: '_computeDefaultSource',
+ dependencies: [
+ 'checkSum',
+ 'fileType',
+ 'id',
+ 'url',
+ ],
+ }),
+ displayName: attr({
+ compute: '_computeDisplayName',
+ dependencies: [
+ 'filename',
+ 'name',
+ ],
+ }),
+ extension: attr({
+ compute: '_computeExtension',
+ dependencies: ['filename'],
+ }),
+ filename: attr(),
+ fileType: attr({
+ compute: '_computeFileType',
+ dependencies: [
+ 'mimetype',
+ 'type',
+ 'url',
+ ],
+ }),
+ id: attr(),
+ isLinkedToComposer: attr({
+ compute: '_computeIsLinkedToComposer',
+ dependencies: ['composers'],
+ }),
+ isTemporary: attr({
+ default: false,
+ }),
+ isTextFile: attr({
+ compute: '_computeIsTextFile',
+ dependencies: ['fileType'],
+ }),
+ /**
+ * True if an unlink RPC is pending, used to prevent multiple unlink attempts.
+ */
+ isUnlinkPending: attr({
+ default: false,
+ }),
+ isViewable: attr({
+ compute: '_computeIsViewable',
+ dependencies: [
+ 'mimetype',
+ ],
+ }),
+ /**
+ * @deprecated
+ */
+ mediaType: attr({
+ compute: '_computeMediaType',
+ dependencies: ['mimetype'],
+ }),
+ messages: many2many('mail.message', {
+ inverse: 'attachments',
+ }),
+ mimetype: attr({
+ default: '',
+ }),
+ name: attr(),
+ originThread: many2one('mail.thread', {
+ inverse: 'originThreadAttachments',
+ }),
+ size: attr(),
+ threads: many2many('mail.thread', {
+ inverse: 'attachments',
+ }),
+ type: attr(),
+ /**
+ * Abort Controller linked to the uploading process of this attachment.
+ * Useful in order to cancel the in-progress uploading of this attachment.
+ */
+ uploadingAbortController: attr({
+ compute: '_computeUploadingAbortController',
+ dependencies: [
+ 'isTemporary',
+ 'uploadingAbortController',
+ ],
+ }),
+ url: attr(),
+ };
+
+ Attachment.modelName = 'mail.attachment';
+
+ return Attachment;
+}
+
+registerNewModel('mail.attachment', factory);
+
+});
diff --git a/addons/mail/static/src/models/attachment/attachment_tests.js b/addons/mail/static/src/models/attachment/attachment_tests.js
new file mode 100644
index 00000000..5c09dcae
--- /dev/null
+++ b/addons/mail/static/src/models/attachment/attachment_tests.js
@@ -0,0 +1,144 @@
+odoo.define('mail/static/src/models/attachment/attachment_tests.js', function (require) {
+'use strict';
+
+const { afterEach, beforeEach, start } = require('mail/static/src/utils/test_utils.js');
+
+QUnit.module('mail', {}, function () {
+QUnit.module('models', {}, function () {
+QUnit.module('attachment', {}, function () {
+QUnit.module('attachment_tests.js', {
+ beforeEach() {
+ beforeEach(this);
+
+ this.start = async params => {
+ const { env, widget } = await start(Object.assign({}, params, {
+ data: this.data,
+ }));
+ this.env = env;
+ this.widget = widget;
+ };
+ },
+ afterEach() {
+ afterEach(this);
+ },
+});
+
+QUnit.test('create (txt)', async function (assert) {
+ assert.expect(9);
+
+ await this.start();
+ assert.notOk(this.env.models['mail.attachment'].findFromIdentifyingData({ id: 750 }));
+
+ const attachment = this.env.models['mail.attachment'].create({
+ filename: "test.txt",
+ id: 750,
+ mimetype: 'text/plain',
+ name: "test.txt",
+ });
+ assert.ok(attachment);
+ assert.ok(this.env.models['mail.attachment'].findFromIdentifyingData({ id: 750 }));
+ assert.strictEqual(this.env.models['mail.attachment'].findFromIdentifyingData({ id: 750 }), attachment);
+ assert.strictEqual(attachment.filename, "test.txt");
+ assert.strictEqual(attachment.id, 750);
+ assert.notOk(attachment.isTemporary);
+ assert.strictEqual(attachment.mimetype, 'text/plain');
+ assert.strictEqual(attachment.name, "test.txt");
+});
+
+QUnit.test('displayName', async function (assert) {
+ assert.expect(5);
+
+ await this.start();
+ assert.notOk(this.env.models['mail.attachment'].findFromIdentifyingData({ id: 750 }));
+
+ const attachment = this.env.models['mail.attachment'].create({
+ filename: "test.txt",
+ id: 750,
+ mimetype: 'text/plain',
+ name: "test.txt",
+ });
+ assert.ok(attachment);
+ assert.ok(this.env.models['mail.attachment'].findFromIdentifyingData({ id: 750 }));
+ assert.strictEqual(attachment, this.env.models['mail.attachment'].findFromIdentifyingData({ id: 750 }));
+ assert.strictEqual(attachment.displayName, "test.txt");
+});
+
+QUnit.test('extension', async function (assert) {
+ assert.expect(5);
+
+ await this.start();
+ assert.notOk(this.env.models['mail.attachment'].findFromIdentifyingData({ id: 750 }));
+
+ const attachment = this.env.models['mail.attachment'].create({
+ filename: "test.txt",
+ id: 750,
+ mimetype: 'text/plain',
+ name: "test.txt",
+ });
+ assert.ok(attachment);
+ assert.ok(this.env.models['mail.attachment'].findFromIdentifyingData({ id: 750 }));
+ assert.strictEqual(attachment, this.env.models['mail.attachment'].findFromIdentifyingData({ id: 750 }));
+ assert.strictEqual(attachment.extension, 'txt');
+});
+
+QUnit.test('fileType', async function (assert) {
+ assert.expect(5);
+
+ await this.start();
+ assert.notOk(this.env.models['mail.attachment'].findFromIdentifyingData({ id: 750 }));
+
+ const attachment = this.env.models['mail.attachment'].create({
+ filename: "test.txt",
+ id: 750,
+ mimetype: 'text/plain',
+ name: "test.txt",
+ });
+ assert.ok(attachment);
+ assert.ok(this.env.models['mail.attachment'].findFromIdentifyingData({ id: 750 }));
+ assert.strictEqual(attachment, this.env.models['mail.attachment'].findFromIdentifyingData({
+ id: 750,
+ }));
+ assert.strictEqual(attachment.fileType, 'text');
+});
+
+QUnit.test('isTextFile', async function (assert) {
+ assert.expect(5);
+
+ await this.start();
+ assert.notOk(this.env.models['mail.attachment'].findFromIdentifyingData({ id: 750 }));
+
+ const attachment = this.env.models['mail.attachment'].create({
+ filename: "test.txt",
+ id: 750,
+ mimetype: 'text/plain',
+ name: "test.txt",
+ });
+ assert.ok(attachment);
+ assert.ok(this.env.models['mail.attachment'].findFromIdentifyingData({ id: 750 }));
+ assert.strictEqual(attachment, this.env.models['mail.attachment'].findFromIdentifyingData({ id: 750 }));
+ assert.ok(attachment.isTextFile);
+});
+
+QUnit.test('isViewable', async function (assert) {
+ assert.expect(5);
+
+ await this.start();
+ assert.notOk(this.env.models['mail.attachment'].findFromIdentifyingData({ id: 750 }));
+
+ const attachment = this.env.models['mail.attachment'].create({
+ filename: "test.txt",
+ id: 750,
+ mimetype: 'text/plain',
+ name: "test.txt",
+ });
+ assert.ok(attachment);
+ assert.ok(this.env.models['mail.attachment'].findFromIdentifyingData({ id: 750 }));
+ assert.strictEqual(attachment, this.env.models['mail.attachment'].findFromIdentifyingData({ id: 750 }));
+ assert.ok(attachment.isViewable);
+});
+
+});
+});
+});
+
+});
diff --git a/addons/mail/static/src/models/attachment_viewer/attachment_viewer.js b/addons/mail/static/src/models/attachment_viewer/attachment_viewer.js
new file mode 100644
index 00000000..8a96946c
--- /dev/null
+++ b/addons/mail/static/src/models/attachment_viewer/attachment_viewer.js
@@ -0,0 +1,59 @@
+odoo.define('mail/static/src/models/attachment_viewer/attachment_viewer.js', function (require) {
+'use strict';
+
+const { registerNewModel } = require('mail/static/src/model/model_core.js');
+const { attr, many2many, many2one } = require('mail/static/src/model/model_field.js');
+
+function factory(dependencies) {
+
+ class AttachmentViewer extends dependencies['mail.model'] {
+
+ //----------------------------------------------------------------------
+ // Public
+ //----------------------------------------------------------------------
+
+ /**
+ * Close the attachment viewer by closing its linked dialog.
+ */
+ close() {
+ const dialog = this.env.models['mail.dialog'].find(dialog => dialog.record === this);
+ if (dialog) {
+ dialog.delete();
+ }
+ }
+ }
+
+ AttachmentViewer.fields = {
+ /**
+ * Angle of the image. Changes when the user rotates it.
+ */
+ angle: attr({
+ default: 0,
+ }),
+ attachment: many2one('mail.attachment'),
+ attachments: many2many('mail.attachment', {
+ inverse: 'attachmentViewer',
+ }),
+ /**
+ * Determine whether the image is loading or not. Useful to diplay
+ * a spinner when loading image initially.
+ */
+ isImageLoading: attr({
+ default: false,
+ }),
+ /**
+ * Scale size of the image. Changes when user zooms in/out.
+ */
+ scale: attr({
+ default: 1,
+ }),
+ };
+
+ AttachmentViewer.modelName = 'mail.attachment_viewer';
+
+ return AttachmentViewer;
+}
+
+registerNewModel('mail.attachment_viewer', factory);
+
+});
diff --git a/addons/mail/static/src/models/canned_response/canned_response.js b/addons/mail/static/src/models/canned_response/canned_response.js
new file mode 100644
index 00000000..41e917d2
--- /dev/null
+++ b/addons/mail/static/src/models/canned_response/canned_response.js
@@ -0,0 +1,107 @@
+odoo.define('mail/static/src/models/canned_response/canned_response.js', function (require) {
+'use strict';
+
+const { registerNewModel } = require('mail/static/src/model/model_core.js');
+const { attr } = require('mail/static/src/model/model_field.js');
+const { cleanSearchTerm } = require('mail/static/src/utils/utils.js');
+
+function factory(dependencies) {
+
+ class CannedResponse extends dependencies['mail.model'] {
+
+ /**
+ * Fetches canned responses matching the given search term to extend the
+ * JS knowledge and to update the suggestion list accordingly.
+ *
+ * In practice all canned responses are already fetched at init so this
+ * method does nothing.
+ *
+ * @static
+ * @param {string} searchTerm
+ * @param {Object} [options={}]
+ * @param {mail.thread} [options.thread] prioritize and/or restrict
+ * result in the context of given thread
+ */
+ static fetchSuggestions(searchTerm, { thread } = {}) {}
+
+ /**
+ * Returns a sort function to determine the order of display of canned
+ * responses in the suggestion list.
+ *
+ * @static
+ * @param {string} searchTerm
+ * @param {Object} [options={}]
+ * @param {mail.thread} [options.thread] prioritize result in the
+ * context of given thread
+ * @returns {function}
+ */
+ static getSuggestionSortFunction(searchTerm, { thread } = {}) {
+ const cleanedSearchTerm = cleanSearchTerm(searchTerm);
+ return (a, b) => {
+ const cleanedAName = cleanSearchTerm(a.source || '');
+ const cleanedBName = cleanSearchTerm(b.source || '');
+ if (cleanedAName.startsWith(cleanedSearchTerm) && !cleanedBName.startsWith(cleanedSearchTerm)) {
+ return -1;
+ }
+ if (!cleanedAName.startsWith(cleanedSearchTerm) && cleanedBName.startsWith(cleanedSearchTerm)) {
+ return 1;
+ }
+ if (cleanedAName < cleanedBName) {
+ return -1;
+ }
+ if (cleanedAName > cleanedBName) {
+ return 1;
+ }
+ return a.id - b.id;
+ };
+ }
+
+ /*
+ * Returns canned responses that match the given search term.
+ *
+ * @static
+ * @param {string} searchTerm
+ * @param {Object} [options={}]
+ * @param {mail.thread} [options.thread] prioritize and/or restrict
+ * result in the context of given thread
+ * @returns {[mail.canned_response[], mail.canned_response[]]}
+ */
+ static searchSuggestions(searchTerm, { thread } = {}) {
+ const cleanedSearchTerm = cleanSearchTerm(searchTerm);
+ return [this.env.messaging.cannedResponses.filter(cannedResponse =>
+ cleanSearchTerm(cannedResponse.source).includes(cleanedSearchTerm)
+ )];
+ }
+
+ /**
+ * Returns the text that identifies this canned response in a mention.
+ *
+ * @returns {string}
+ */
+ getMentionText() {
+ return this.substitution;
+ }
+
+ }
+
+ CannedResponse.fields = {
+ id: attr(),
+ /**
+ * The keyword to use a specific canned response.
+ */
+ source: attr(),
+ /**
+ * The canned response itself which will replace the keyword previously
+ * entered.
+ */
+ substitution: attr(),
+ };
+
+ CannedResponse.modelName = 'mail.canned_response';
+
+ return CannedResponse;
+}
+
+registerNewModel('mail.canned_response', factory);
+
+});
diff --git a/addons/mail/static/src/models/channel_command/channel_command.js b/addons/mail/static/src/models/channel_command/channel_command.js
new file mode 100644
index 00000000..728acdb9
--- /dev/null
+++ b/addons/mail/static/src/models/channel_command/channel_command.js
@@ -0,0 +1,130 @@
+odoo.define('mail/static/src/models/channel_command/channel_command.js', function (require) {
+'use strict';
+
+const { registerNewModel } = require('mail/static/src/model/model_core.js');
+const { attr } = require('mail/static/src/model/model_field.js');
+const { cleanSearchTerm } = require('mail/static/src/utils/utils.js');
+
+function factory(dependencies) {
+
+ class ChannelCommand extends dependencies['mail.model'] {
+
+ /**
+ * Fetches channel commands matching the given search term to extend the
+ * JS knowledge and to update the suggestion list accordingly.
+ *
+ * In practice all channel commands are already fetched at init so this
+ * method does nothing.
+ *
+ * @static
+ * @param {string} searchTerm
+ * @param {Object} [options={}]
+ * @param {mail.thread} [options.thread] prioritize and/or restrict
+ * result in the context of given thread
+ */
+ static fetchSuggestions(searchTerm, { thread } = {}) {}
+
+ /**
+ * Returns a sort function to determine the order of display of channel
+ * commands in the suggestion list.
+ *
+ * @static
+ * @param {string} searchTerm
+ * @param {Object} [options={}]
+ * @param {mail.thread} [options.thread] prioritize result in the
+ * context of given thread
+ * @returns {function}
+ */
+ static getSuggestionSortFunction(searchTerm, { thread } = {}) {
+ const cleanedSearchTerm = cleanSearchTerm(searchTerm);
+ return (a, b) => {
+ const isATypeSpecific = a.channel_types;
+ const isBTypeSpecific = b.channel_types;
+ if (isATypeSpecific && !isBTypeSpecific) {
+ return -1;
+ }
+ if (!isATypeSpecific && isBTypeSpecific) {
+ return 1;
+ }
+ const cleanedAName = cleanSearchTerm(a.name || '');
+ const cleanedBName = cleanSearchTerm(b.name || '');
+ if (cleanedAName.startsWith(cleanedSearchTerm) && !cleanedBName.startsWith(cleanedSearchTerm)) {
+ return -1;
+ }
+ if (!cleanedAName.startsWith(cleanedSearchTerm) && cleanedBName.startsWith(cleanedSearchTerm)) {
+ return 1;
+ }
+ if (cleanedAName < cleanedBName) {
+ return -1;
+ }
+ if (cleanedAName > cleanedBName) {
+ return 1;
+ }
+ return a.id - b.id;
+ };
+ }
+
+ /**
+ * Returns channel commands that match the given search term.
+ *
+ * @static
+ * @param {string} searchTerm
+ * @param {Object} [options={}]
+ * @param {mail.thread} [options.thread] prioritize and/or restrict
+ * result in the context of given thread
+ * @returns {[mail.channel_command[], mail.channel_command[]]}
+ */
+ static searchSuggestions(searchTerm, { thread } = {}) {
+ if (thread.model !== 'mail.channel') {
+ // channel commands are channel specific
+ return [[]];
+ }
+ const cleanedSearchTerm = cleanSearchTerm(searchTerm);
+ return [this.env.messaging.commands.filter(command => {
+ if (!cleanSearchTerm(command.name).includes(cleanedSearchTerm)) {
+ return false;
+ }
+ if (command.channel_types) {
+ return command.channel_types.includes(thread.channel_type);
+ }
+ return true;
+ })];
+ }
+
+ /**
+ * Returns the text that identifies this channel command in a mention.
+ *
+ * @returns {string}
+ */
+ getMentionText() {
+ return this.name;
+ }
+
+ }
+
+ ChannelCommand.fields = {
+ /**
+ * Determines on which channel types `this` is available.
+ * Type of the channel (e.g. 'chat', 'channel' or 'groups')
+ * This field should contain an array when filtering is desired.
+ * Otherwise, it should be undefined when all types are allowed.
+ */
+ channel_types: attr(),
+ /**
+ * The command that will be executed.
+ */
+ help: attr(),
+ /**
+ * The keyword to use a specific command.
+ */
+ name: attr(),
+ };
+
+ ChannelCommand.modelName = 'mail.channel_command';
+
+ return ChannelCommand;
+}
+
+registerNewModel('mail.channel_command', factory);
+
+});
diff --git a/addons/mail/static/src/models/chat_window/chat_window.js b/addons/mail/static/src/models/chat_window/chat_window.js
new file mode 100644
index 00000000..49e22742
--- /dev/null
+++ b/addons/mail/static/src/models/chat_window/chat_window.js
@@ -0,0 +1,480 @@
+odoo.define('mail/static/src/models/chat_window/chat_window.js', function (require) {
+'use strict';
+
+const { registerNewModel } = require('mail/static/src/model/model_core.js');
+const { attr, many2one, one2many, one2one } = require('mail/static/src/model/model_field.js');
+const { clear } = require('mail/static/src/model/model_field_command.js');
+
+function factory(dependencies) {
+
+ class ChatWindow extends dependencies['mail.model'] {
+
+ /**
+ * @override
+ */
+ _created() {
+ const res = super._created(...arguments);
+ this._onShowHomeMenu.bind(this);
+ this._onHideHomeMenu.bind(this);
+
+ this.env.messagingBus.on('hide_home_menu', this, this._onHideHomeMenu);
+ this.env.messagingBus.on('show_home_menu', this, this._onShowHomeMenu);
+ return res;
+ }
+
+ /**
+ * @override
+ */
+ _willDelete() {
+ this.env.messagingBus.off('hide_home_menu', this, this._onHideHomeMenu);
+ this.env.messagingBus.off('show_home_menu', this, this._onShowHomeMenu);
+ return super._willDelete(...arguments);
+ }
+
+ //----------------------------------------------------------------------
+ // Public
+ //----------------------------------------------------------------------
+
+ /**
+ * Close this chat window.
+ *
+ * @param {Object} [param0={}]
+ * @param {boolean} [param0.notifyServer]
+ */
+ close({ notifyServer } = {}) {
+ if (notifyServer === undefined) {
+ notifyServer = !this.env.messaging.device.isMobile;
+ }
+ const thread = this.thread;
+ this.delete();
+ // Flux specific: 'closed' fold state should only be saved on the
+ // server when manually closing the chat window. Delete at destroy
+ // or sync from server value for example should not save the value.
+ if (thread && notifyServer) {
+ thread.notifyFoldStateToServer('closed');
+ }
+ if (this.env.device.isMobile && !this.env.messaging.discuss.isOpen) {
+ // If we are in mobile and discuss is not open, it means the
+ // chat window was opened from the messaging menu. In that
+ // case it should be re-opened to simulate it was always
+ // there in the background.
+ this.env.messaging.messagingMenu.update({ isOpen: true });
+ }
+ }
+
+ expand() {
+ if (this.thread) {
+ this.thread.open({ expanded: true });
+ }
+ }
+
+ /**
+ * Programmatically auto-focus an existing chat window.
+ */
+ focus() {
+ this.update({ isDoFocus: true });
+ }
+
+ focusNextVisibleUnfoldedChatWindow() {
+ const nextVisibleUnfoldedChatWindow = this._getNextVisibleUnfoldedChatWindow();
+ if (nextVisibleUnfoldedChatWindow) {
+ nextVisibleUnfoldedChatWindow.focus();
+ }
+ }
+
+ focusPreviousVisibleUnfoldedChatWindow() {
+ const previousVisibleUnfoldedChatWindow =
+ this._getNextVisibleUnfoldedChatWindow({ reverse: true });
+ if (previousVisibleUnfoldedChatWindow) {
+ previousVisibleUnfoldedChatWindow.focus();
+ }
+ }
+
+ /**
+ * @param {Object} [param0={}]
+ * @param {boolean} [param0.notifyServer]
+ */
+ fold({ notifyServer } = {}) {
+ if (notifyServer === undefined) {
+ notifyServer = !this.env.messaging.device.isMobile;
+ }
+ this.update({ isFolded: true });
+ // Flux specific: manually folding the chat window should save the
+ // new state on the server.
+ if (this.thread && notifyServer) {
+ this.thread.notifyFoldStateToServer('folded');
+ }
+ }
+
+ /**
+ * Makes this chat window active, which consists of making it visible,
+ * unfolding it, and focusing it.
+ *
+ * @param {Object} [options]
+ */
+ makeActive(options) {
+ this.makeVisible();
+ this.unfold(options);
+ this.focus();
+ }
+
+ /**
+ * Makes this chat window visible by swapping it with the last visible
+ * chat window, or do nothing if it is already visible.
+ */
+ makeVisible() {
+ if (this.isVisible) {
+ return;
+ }
+ const lastVisible = this.manager.lastVisible;
+ this.manager.swap(this, lastVisible);
+ }
+
+ /**
+ * Shift this chat window to the left on screen.
+ */
+ shiftLeft() {
+ this.manager.shiftLeft(this);
+ }
+
+ /**
+ * Shift this chat window to the right on screen.
+ */
+ shiftRight() {
+ this.manager.shiftRight(this);
+ }
+
+ /**
+ * @param {Object} [param0={}]
+ * @param {boolean} [param0.notifyServer]
+ */
+ unfold({ notifyServer } = {}) {
+ if (notifyServer === undefined) {
+ notifyServer = !this.env.messaging.device.isMobile;
+ }
+ this.update({ isFolded: false });
+ // Flux specific: manually opening the chat window should save the
+ // new state on the server.
+ if (this.thread && notifyServer) {
+ this.thread.notifyFoldStateToServer('open');
+ }
+ }
+
+ //----------------------------------------------------------------------
+ // Private
+ //----------------------------------------------------------------------
+
+ /**
+ * @private
+ * @returns {boolean}
+ */
+ _computeHasNewMessageForm() {
+ return this.isVisible && !this.isFolded && !this.thread;
+ }
+
+ /**
+ * @private
+ * @returns {boolean}
+ */
+ _computeHasShiftLeft() {
+ if (!this.manager) {
+ return false;
+ }
+ const allVisible = this.manager.allOrderedVisible;
+ const index = allVisible.findIndex(visible => visible === this);
+ if (index === -1) {
+ return false;
+ }
+ return index < allVisible.length - 1;
+ }
+
+ /**
+ * @private
+ * @returns {boolean}
+ */
+ _computeHasShiftRight() {
+ if (!this.manager) {
+ return false;
+ }
+ const index = this.manager.allOrderedVisible.findIndex(visible => visible === this);
+ if (index === -1) {
+ return false;
+ }
+ return index > 0;
+ }
+
+ /**
+ * @private
+ * @returns {boolean}
+ */
+ _computeHasThreadView() {
+ return this.isVisible && !this.isFolded && this.thread;
+ }
+
+ /**
+ * @private
+ * @returns {boolean}
+ */
+ _computeIsFolded() {
+ const thread = this.thread;
+ if (thread) {
+ return thread.foldState === 'folded';
+ }
+ return this.isFolded;
+ }
+
+ /**
+ * @private
+ * @returns {boolean}
+ */
+ _computeIsVisible() {
+ if (!this.manager) {
+ return false;
+ }
+ return this.manager.allOrderedVisible.includes(this);
+ }
+
+ /**
+ * @private
+ * @returns {string}
+ */
+ _computeName() {
+ if (this.thread) {
+ return this.thread.displayName;
+ }
+ return this.env._t("New message");
+ }
+
+ /**
+ * @private
+ * @returns {integer|undefined}
+ */
+ _computeVisibleIndex() {
+ if (!this.manager) {
+ return clear();
+ }
+ const visible = this.manager.visual.visible;
+ const index = visible.findIndex(visible => visible.chatWindowLocalId === this.localId);
+ if (index === -1) {
+ return clear();
+ }
+ return index;
+ }
+
+ /**
+ * @private
+ * @returns {integer}
+ */
+ _computeVisibleOffset() {
+ if (!this.manager) {
+ return 0;
+ }
+ const visible = this.manager.visual.visible;
+ const index = visible.findIndex(visible => visible.chatWindowLocalId === this.localId);
+ if (index === -1) {
+ return 0;
+ }
+ return visible[index].offset;
+ }
+
+ /**
+ * Cycles to the next possible visible and unfolded chat window starting
+ * from the `currentChatWindow`, following the natural order based on the
+ * current text direction, and with the possibility to `reverse` based on
+ * the given parameter.
+ *
+ * @private
+ * @param {Object} [param0={}]
+ * @param {boolean} [param0.reverse=false]
+ * @returns {mail.chat_window|undefined}
+ */
+ _getNextVisibleUnfoldedChatWindow({ reverse = false } = {}) {
+ const orderedVisible = this.manager.allOrderedVisible;
+ /**
+ * Return index of next visible chat window of a given visible chat
+ * window index. The direction of "next" chat window depends on
+ * `reverse` option.
+ *
+ * @param {integer} index
+ * @returns {integer}
+ */
+ const _getNextIndex = index => {
+ const directionOffset = reverse ? 1 : -1;
+ let nextIndex = index + directionOffset;
+ if (nextIndex > orderedVisible.length - 1) {
+ nextIndex = 0;
+ }
+ if (nextIndex < 0) {
+ nextIndex = orderedVisible.length - 1;
+ }
+ return nextIndex;
+ };
+
+ const currentIndex = orderedVisible.findIndex(visible => visible === this);
+ let nextIndex = _getNextIndex(currentIndex);
+ let nextToFocus = orderedVisible[nextIndex];
+ while (nextToFocus.isFolded) {
+ nextIndex = _getNextIndex(nextIndex);
+ nextToFocus = orderedVisible[nextIndex];
+ }
+ return nextToFocus;
+ }
+
+ //----------------------------------------------------------------------
+ // Handlers
+ //----------------------------------------------------------------------
+
+ /**
+ * @private
+ */
+ async _onHideHomeMenu() {
+ if (!this.threadView) {
+ return;
+ }
+ this.threadView.addComponentHint('home-menu-hidden');
+ }
+
+ /**
+ * @private
+ */
+ async _onShowHomeMenu() {
+ if (!this.threadView) {
+ return;
+ }
+ this.threadView.addComponentHint('home-menu-shown');
+ }
+
+ }
+
+ ChatWindow.fields = {
+ /**
+ * Determines whether "new message form" should be displayed.
+ */
+ hasNewMessageForm: attr({
+ compute: '_computeHasNewMessageForm',
+ dependencies: [
+ 'isFolded',
+ 'isVisible',
+ 'thread',
+ ],
+ }),
+ hasShiftLeft: attr({
+ compute: '_computeHasShiftLeft',
+ dependencies: ['managerAllOrderedVisible'],
+ default: false,
+ }),
+ hasShiftRight: attr({
+ compute: '_computeHasShiftRight',
+ dependencies: ['managerAllOrderedVisible'],
+ default: false,
+ }),
+ /**
+ * Determines whether `this.thread` should be displayed.
+ */
+ hasThreadView: attr({
+ compute: '_computeHasThreadView',
+ dependencies: [
+ 'isFolded',
+ 'isVisible',
+ 'thread',
+ ],
+ }),
+ /**
+ * Determine whether the chat window should be programmatically
+ * focused by observed component of chat window. Those components
+ * are responsible to unmark this record afterwards, otherwise
+ * any re-render will programmatically set focus again!
+ */
+ isDoFocus: attr({
+ default: false,
+ }),
+ /**
+ * States whether `this` is focused. Useful for visual clue.
+ */
+ isFocused: attr({
+ default: false,
+ }),
+ /**
+ * Determines whether `this` is folded.
+ */
+ isFolded: attr({
+ default: false,
+ }),
+ /**
+ * States whether `this` is visible or not. Should be considered
+ * read-only. Setting this value manually will not make it visible.
+ * @see `makeVisible`
+ */
+ isVisible: attr({
+ compute: '_computeIsVisible',
+ dependencies: [
+ 'managerAllOrderedVisible',
+ ],
+ }),
+ manager: many2one('mail.chat_window_manager', {
+ inverse: 'chatWindows',
+ }),
+ managerAllOrderedVisible: one2many('mail.chat_window', {
+ related: 'manager.allOrderedVisible',
+ }),
+ managerVisual: attr({
+ related: 'manager.visual',
+ }),
+ name: attr({
+ compute: '_computeName',
+ dependencies: [
+ 'thread',
+ 'threadDisplayName',
+ ],
+ }),
+ /**
+ * Determines the `mail.thread` that should be displayed by `this`.
+ * If no `mail.thread` is linked, `this` is considered "new message".
+ */
+ thread: one2one('mail.thread', {
+ inverse: 'chatWindow',
+ }),
+ threadDisplayName: attr({
+ related: 'thread.displayName',
+ }),
+ /**
+ * States the `mail.thread_view` displaying `this.thread`.
+ */
+ threadView: one2one('mail.thread_view', {
+ related: 'threadViewer.threadView',
+ }),
+ /**
+ * Determines the `mail.thread_viewer` managing the display of `this.thread`.
+ */
+ threadViewer: one2one('mail.thread_viewer', {
+ default: [['create']],
+ inverse: 'chatWindow',
+ isCausal: true,
+ }),
+ /**
+ * This field handle the "order" (index) of the visible chatWindow inside the UI.
+ *
+ * Using LTR, the right-most chat window has index 0, and the number is incrementing from right to left.
+ * Using RTL, the left-most chat window has index 0, and the number is incrementing from left to right.
+ */
+ visibleIndex: attr({
+ compute: '_computeVisibleIndex',
+ dependencies: [
+ 'manager',
+ 'managerVisual',
+ ],
+ }),
+ visibleOffset: attr({
+ compute: '_computeVisibleOffset',
+ dependencies: ['managerVisual'],
+ }),
+ };
+
+ ChatWindow.modelName = 'mail.chat_window';
+
+ return ChatWindow;
+}
+
+registerNewModel('mail.chat_window', factory);
+
+});
diff --git a/addons/mail/static/src/models/chat_window_manager/chat_window_manager.js b/addons/mail/static/src/models/chat_window_manager/chat_window_manager.js
new file mode 100644
index 00000000..fc367fef
--- /dev/null
+++ b/addons/mail/static/src/models/chat_window_manager/chat_window_manager.js
@@ -0,0 +1,487 @@
+odoo.define('mail/static/src/models/chat_window_manager/chat_window_manager.js', function (require) {
+'use strict';
+
+const { registerNewModel } = require('mail/static/src/model/model_core.js');
+const { attr, many2one, one2many, one2one } = require('mail/static/src/model/model_field.js');
+
+function factory(dependencies) {
+
+ const BASE_VISUAL = {
+ /**
+ * Amount of visible slots available for chat windows.
+ */
+ availableVisibleSlots: 0,
+ /**
+ * Data related to the hidden menu.
+ */
+ hidden: {
+ /**
+ * List of hidden docked chat windows. Useful to compute counter.
+ * Chat windows are ordered by their `chatWindows` order.
+ */
+ chatWindowLocalIds: [],
+ /**
+ * Whether hidden menu is visible or not
+ */
+ isVisible: false,
+ /**
+ * Offset of hidden menu starting point from the starting point
+ * of chat window manager. Makes only sense if it is visible.
+ */
+ offset: 0,
+ },
+ /**
+ * Data related to visible chat windows. Index determine order of
+ * docked chat windows.
+ *
+ * Value:
+ *
+ * {
+ * chatWindowLocalId,
+ * offset,
+ * }
+ *
+ * Offset is offset of starting point of docked chat window from
+ * starting point of dock chat window manager. Docked chat windows
+ * are ordered by their `chatWindows` order
+ */
+ visible: [],
+ };
+
+
+ class ChatWindowManager extends dependencies['mail.model'] {
+
+ //----------------------------------------------------------------------
+ // Public
+ //----------------------------------------------------------------------
+
+ /**
+ * Close all chat windows.
+ *
+ */
+ closeAll() {
+ const chatWindows = this.allOrdered;
+ for (const chatWindow of chatWindows) {
+ chatWindow.close();
+ }
+ }
+
+ closeHiddenMenu() {
+ this.update({ isHiddenMenuOpen: false });
+ }
+
+ /**
+ * Closes all chat windows related to the given thread.
+ *
+ * @param {mail.thread} thread
+ * @param {Object} [options]
+ */
+ closeThread(thread, options) {
+ for (const chatWindow of this.chatWindows) {
+ if (chatWindow.thread === thread) {
+ chatWindow.close(options);
+ }
+ }
+ }
+
+ openHiddenMenu() {
+ this.update({ isHiddenMenuOpen: true });
+ }
+
+ openNewMessage() {
+ let newMessageChatWindow = this.newMessageChatWindow;
+ if (!newMessageChatWindow) {
+ newMessageChatWindow = this.env.models['mail.chat_window'].create({
+ manager: [['link', this]],
+ });
+ }
+ newMessageChatWindow.makeActive();
+ }
+
+ /**
+ * @param {mail.thread} thread
+ * @param {Object} [param1={}]
+ * @param {boolean} [param1.isFolded=false]
+ * @param {boolean} [param1.makeActive=false]
+ * @param {boolean} [param1.notifyServer]
+ * @param {boolean} [param1.replaceNewMessage=false]
+ */
+ openThread(thread, {
+ isFolded = false,
+ makeActive = false,
+ notifyServer,
+ replaceNewMessage = false
+ } = {}) {
+ if (notifyServer === undefined) {
+ notifyServer = !this.env.messaging.device.isMobile;
+ }
+ let chatWindow = this.chatWindows.find(chatWindow =>
+ chatWindow.thread === thread
+ );
+ if (!chatWindow) {
+ chatWindow = this.env.models['mail.chat_window'].create({
+ isFolded,
+ manager: [['link', this]],
+ thread: [['link', thread]],
+ });
+ } else {
+ chatWindow.update({ isFolded });
+ }
+ if (replaceNewMessage && this.newMessageChatWindow) {
+ this.swap(chatWindow, this.newMessageChatWindow);
+ this.newMessageChatWindow.close();
+ }
+ if (makeActive) {
+ // avoid double notify at this step, it will already be done at
+ // the end of the current method
+ chatWindow.makeActive({ notifyServer: false });
+ }
+ // Flux specific: notify server of chat window being opened.
+ if (notifyServer) {
+ const foldState = chatWindow.isFolded ? 'folded' : 'open';
+ thread.notifyFoldStateToServer(foldState);
+ }
+ }
+
+ /**
+ * Shift provided chat window to the left on screen.
+ *
+ * @param {mail.chat_window} chatWindow
+ */
+ shiftLeft(chatWindow) {
+ const chatWindows = this.allOrdered;
+ const index = chatWindows.findIndex(cw => cw === chatWindow);
+ if (index === chatWindows.length - 1) {
+ // already left-most
+ return;
+ }
+ const otherChatWindow = chatWindows[index + 1];
+ const _newOrdered = [...this._ordered];
+ _newOrdered[index] = otherChatWindow.localId;
+ _newOrdered[index + 1] = chatWindow.localId;
+ this.update({ _ordered: _newOrdered });
+ chatWindow.focus();
+ }
+
+ /**
+ * Shift provided chat window to the right on screen.
+ *
+ * @param {mail.chat_window} chatWindow
+ */
+ shiftRight(chatWindow) {
+ const chatWindows = this.allOrdered;
+ const index = chatWindows.findIndex(cw => cw === chatWindow);
+ if (index === 0) {
+ // already right-most
+ return;
+ }
+ const otherChatWindow = chatWindows[index - 1];
+ const _newOrdered = [...this._ordered];
+ _newOrdered[index] = otherChatWindow.localId;
+ _newOrdered[index - 1] = chatWindow.localId;
+ this.update({ _ordered: _newOrdered });
+ chatWindow.focus();
+ }
+
+ /**
+ * @param {mail.chat_window} chatWindow1
+ * @param {mail.chat_window} chatWindow2
+ */
+ swap(chatWindow1, chatWindow2) {
+ const ordered = this.allOrdered;
+ const index1 = ordered.findIndex(chatWindow => chatWindow === chatWindow1);
+ const index2 = ordered.findIndex(chatWindow => chatWindow === chatWindow2);
+ if (index1 === -1 || index2 === -1) {
+ return;
+ }
+ const _newOrdered = [...this._ordered];
+ _newOrdered[index1] = chatWindow2.localId;
+ _newOrdered[index2] = chatWindow1.localId;
+ this.update({ _ordered: _newOrdered });
+ }
+
+ //----------------------------------------------------------------------
+ // Private
+ //----------------------------------------------------------------------
+
+ /**
+ * @private
+ * @returns {string[]}
+ */
+ _compute_ordered() {
+ // remove unlinked chatWindows
+ const _ordered = this._ordered.filter(chatWindowLocalId =>
+ this.chatWindows.includes(this.env.models['mail.chat_window'].get(chatWindowLocalId))
+ );
+ // add linked chatWindows
+ for (const chatWindow of this.chatWindows) {
+ if (!_ordered.includes(chatWindow.localId)) {
+ _ordered.push(chatWindow.localId);
+ }
+ }
+ return _ordered;
+ }
+
+ /**
+ * // FIXME: dependent on implementation that uses arbitrary order in relations!!
+ *
+ * @private
+ * @returns {mail.chat_window}
+ */
+ _computeAllOrdered() {
+ return [['replace', this._ordered.map(chatWindowLocalId =>
+ this.env.models['mail.chat_window'].get(chatWindowLocalId)
+ )]];
+ }
+
+ /**
+ * @private
+ * @returns {mail.chat_window[]}
+ */
+ _computeAllOrderedHidden() {
+ return [['replace', this.visual.hidden.chatWindowLocalIds.map(chatWindowLocalId =>
+ this.env.models['mail.chat_window'].get(chatWindowLocalId)
+ )]];
+ }
+
+ /**
+ * @private
+ * @returns {mail.chat_window[]}
+ */
+ _computeAllOrderedVisible() {
+ return [['replace', this.visual.visible.map(({ chatWindowLocalId }) =>
+ this.env.models['mail.chat_window'].get(chatWindowLocalId)
+ )]];
+ }
+
+ /**
+ * @private
+ * @returns {boolean}
+ */
+ _computeHasHiddenChatWindows() {
+ return this.allOrderedHidden.length > 0;
+ }
+
+ /**
+ * @private
+ * @returns {boolean}
+ */
+ _computeHasVisibleChatWindows() {
+ return this.allOrderedVisible.length > 0;
+ }
+
+ /**
+ * @private
+ * @returns {mail.chat_window|undefined}
+ */
+ _computeLastVisible() {
+ const { length: l, [l - 1]: lastVisible } = this.allOrderedVisible;
+ if (!lastVisible) {
+ return [['unlink']];
+ }
+ return [['link', lastVisible]];
+ }
+
+ /**
+ * @private
+ * @returns {mail.chat_window|undefined}
+ */
+ _computeNewMessageChatWindow() {
+ const chatWindow = this.allOrdered.find(chatWindow => !chatWindow.thread);
+ if (!chatWindow) {
+ return [['unlink']];
+ }
+ return [['link', chatWindow]];
+ }
+
+ /**
+ * @private
+ * @returns {integer}
+ */
+ _computeUnreadHiddenConversationAmount() {
+ const allHiddenWithThread = this.allOrderedHidden.filter(
+ chatWindow => chatWindow.thread
+ );
+ let amount = 0;
+ for (const chatWindow of allHiddenWithThread) {
+ if (chatWindow.thread.localMessageUnreadCounter > 0) {
+ amount++;
+ }
+ }
+ return amount;
+ }
+
+ /**
+ * @private
+ * @returns {Object}
+ */
+ _computeVisual() {
+ let visual = JSON.parse(JSON.stringify(BASE_VISUAL));
+ if (!this.env.messaging) {
+ return visual;
+ }
+ const device = this.env.messaging.device;
+ const discuss = this.env.messaging.discuss;
+ const BETWEEN_GAP_WIDTH = 5;
+ const CHAT_WINDOW_WIDTH = 325;
+ const END_GAP_WIDTH = device.isMobile ? 0 : 10;
+ const GLOBAL_WINDOW_WIDTH = device.globalWindowInnerWidth;
+ const HIDDEN_MENU_WIDTH = 200; // max width, including width of dropup list items
+ const START_GAP_WIDTH = device.isMobile ? 0 : 10;
+ const chatWindows = this.allOrdered;
+ if (!device.isMobile && discuss.isOpen) {
+ return visual;
+ }
+ if (!chatWindows.length) {
+ return visual;
+ }
+ const relativeGlobalWindowWidth = GLOBAL_WINDOW_WIDTH - START_GAP_WIDTH - END_GAP_WIDTH;
+ let maxAmountWithoutHidden = Math.floor(
+ relativeGlobalWindowWidth / (CHAT_WINDOW_WIDTH + BETWEEN_GAP_WIDTH));
+ let maxAmountWithHidden = Math.floor(
+ (relativeGlobalWindowWidth - HIDDEN_MENU_WIDTH - BETWEEN_GAP_WIDTH) /
+ (CHAT_WINDOW_WIDTH + BETWEEN_GAP_WIDTH));
+ if (device.isMobile) {
+ maxAmountWithoutHidden = 1;
+ maxAmountWithHidden = 1;
+ }
+ if (chatWindows.length <= maxAmountWithoutHidden) {
+ // all visible
+ for (let i = 0; i < chatWindows.length; i++) {
+ const chatWindowLocalId = chatWindows[i].localId;
+ const offset = START_GAP_WIDTH + i * (CHAT_WINDOW_WIDTH + BETWEEN_GAP_WIDTH);
+ visual.visible.push({ chatWindowLocalId, offset });
+ }
+ visual.availableVisibleSlots = maxAmountWithoutHidden;
+ } else if (maxAmountWithHidden > 0) {
+ // some visible, some hidden
+ for (let i = 0; i < maxAmountWithHidden; i++) {
+ const chatWindowLocalId = chatWindows[i].localId;
+ const offset = START_GAP_WIDTH + i * (CHAT_WINDOW_WIDTH + BETWEEN_GAP_WIDTH);
+ visual.visible.push({ chatWindowLocalId, offset });
+ }
+ if (chatWindows.length > maxAmountWithHidden) {
+ visual.hidden.isVisible = !device.isMobile;
+ visual.hidden.offset = visual.visible[maxAmountWithHidden - 1].offset
+ + CHAT_WINDOW_WIDTH + BETWEEN_GAP_WIDTH;
+ }
+ for (let j = maxAmountWithHidden; j < chatWindows.length; j++) {
+ visual.hidden.chatWindowLocalIds.push(chatWindows[j].localId);
+ }
+ visual.availableVisibleSlots = maxAmountWithHidden;
+ } else {
+ // all hidden
+ visual.hidden.isVisible = !device.isMobile;
+ visual.hidden.offset = START_GAP_WIDTH;
+ visual.hidden.chatWindowLocalIds.concat(chatWindows.map(chatWindow => chatWindow.localId));
+ console.warn('cannot display any visible chat windows (screen is too small)');
+ visual.availableVisibleSlots = 0;
+ }
+ return visual;
+ }
+
+ }
+
+ ChatWindowManager.fields = {
+ /**
+ * List of ordered chat windows (list of local ids)
+ */
+ _ordered: attr({
+ compute: '_compute_ordered',
+ default: [],
+ dependencies: [
+ 'chatWindows',
+ ],
+ }),
+ // FIXME: dependent on implementation that uses arbitrary order in relations!!
+ allOrdered: one2many('mail.chat_window', {
+ compute: '_computeAllOrdered',
+ dependencies: [
+ '_ordered',
+ ],
+ }),
+ allOrderedThread: one2many('mail.thread', {
+ related: 'allOrdered.thread',
+ }),
+ allOrderedHidden: one2many('mail.chat_window', {
+ compute: '_computeAllOrderedHidden',
+ dependencies: ['visual'],
+ }),
+ allOrderedHiddenThread: one2many('mail.thread', {
+ related: 'allOrderedHidden.thread',
+ }),
+ allOrderedHiddenThreadMessageUnreadCounter: attr({
+ related: 'allOrderedHiddenThread.localMessageUnreadCounter',
+ }),
+ allOrderedVisible: one2many('mail.chat_window', {
+ compute: '_computeAllOrderedVisible',
+ dependencies: ['visual'],
+ }),
+ chatWindows: one2many('mail.chat_window', {
+ inverse: 'manager',
+ isCausal: true,
+ }),
+ device: one2one('mail.device', {
+ related: 'messaging.device',
+ }),
+ deviceGlobalWindowInnerWidth: attr({
+ related: 'device.globalWindowInnerWidth',
+ }),
+ deviceIsMobile: attr({
+ related: 'device.isMobile',
+ }),
+ discuss: one2one('mail.discuss', {
+ related: 'messaging.discuss',
+ }),
+ discussIsOpen: attr({
+ related: 'discuss.isOpen',
+ }),
+ hasHiddenChatWindows: attr({
+ compute: '_computeHasHiddenChatWindows',
+ dependencies: ['allOrderedHidden'],
+ }),
+ hasVisibleChatWindows: attr({
+ compute: '_computeHasVisibleChatWindows',
+ dependencies: ['allOrderedVisible'],
+ }),
+ isHiddenMenuOpen: attr({
+ default: false,
+ }),
+ lastVisible: many2one('mail.chat_window', {
+ compute: '_computeLastVisible',
+ dependencies: ['allOrderedVisible'],
+ }),
+ messaging: one2one('mail.messaging', {
+ inverse: 'chatWindowManager',
+ }),
+ newMessageChatWindow: one2one('mail.chat_window', {
+ compute: '_computeNewMessageChatWindow',
+ dependencies: [
+ 'allOrdered',
+ 'allOrderedThread',
+ ],
+ }),
+ unreadHiddenConversationAmount: attr({
+ compute: '_computeUnreadHiddenConversationAmount',
+ dependencies: ['allOrderedHiddenThreadMessageUnreadCounter'],
+ }),
+ visual: attr({
+ compute: '_computeVisual',
+ default: BASE_VISUAL,
+ dependencies: [
+ 'allOrdered',
+ 'deviceGlobalWindowInnerWidth',
+ 'deviceIsMobile',
+ 'discussIsOpen',
+ ],
+ }),
+ };
+
+ ChatWindowManager.modelName = 'mail.chat_window_manager';
+
+ return ChatWindowManager;
+}
+
+registerNewModel('mail.chat_window_manager', factory);
+
+});
diff --git a/addons/mail/static/src/models/chatter/chatter.js b/addons/mail/static/src/models/chatter/chatter.js
new file mode 100644
index 00000000..84f611eb
--- /dev/null
+++ b/addons/mail/static/src/models/chatter/chatter.js
@@ -0,0 +1,334 @@
+odoo.define('mail/static/src/models/chatter/chatter.js', function (require) {
+'use strict';
+
+const { registerNewModel } = require('mail/static/src/model/model_core.js');
+const { attr, many2one, one2one } = require('mail/static/src/model/model_field.js');
+
+function factory(dependencies) {
+
+ const getThreadNextTemporaryId = (function () {
+ let tmpId = 0;
+ return () => {
+ tmpId -= 1;
+ return tmpId;
+ };
+ })();
+
+ const getMessageNextTemporaryId = (function () {
+ let tmpId = 0;
+ return () => {
+ tmpId -= 1;
+ return tmpId;
+ };
+ })();
+
+ class Chatter extends dependencies['mail.model'] {
+
+ /**
+ * @override
+ */
+ _willDelete() {
+ this._stopAttachmentsLoading();
+ return super._willDelete(...arguments);
+ }
+
+ //----------------------------------------------------------------------
+ // Public
+ //----------------------------------------------------------------------
+
+ focus() {
+ this.update({ isDoFocus: true });
+ }
+
+ async refresh() {
+ if (this.hasActivities) {
+ this.thread.refreshActivities();
+ }
+ if (this.hasFollowers) {
+ this.thread.refreshFollowers();
+ this.thread.fetchAndUpdateSuggestedRecipients();
+ }
+ if (this.hasMessageList) {
+ this.thread.refresh();
+ }
+ }
+
+ showLogNote() {
+ this.update({ isComposerVisible: true });
+ this.thread.composer.update({ isLog: true });
+ this.focus();
+ }
+
+ showSendMessage() {
+ this.update({ isComposerVisible: true });
+ this.thread.composer.update({ isLog: false });
+ this.focus();
+ }
+
+ toggleActivityBoxVisibility() {
+ this.update({ isActivityBoxVisible: !this.isActivityBoxVisible });
+ }
+
+ //----------------------------------------------------------------------
+ // Private
+ //----------------------------------------------------------------------
+
+ /**
+ * @private
+ * @returns {boolean}
+ */
+ _computeHasThreadView() {
+ return this.thread && this.hasMessageList;
+ }
+
+ /**
+ * @private
+ * @returns {boolean}
+ */
+ _computeIsDisabled() {
+ return !this.thread || this.thread.isTemporary;
+ }
+
+ /**
+ * @private
+ */
+ _onThreadIdOrThreadModelChanged() {
+ if (this.threadId) {
+ if (this.thread && this.thread.isTemporary) {
+ this.thread.delete();
+ }
+ this.update({
+ isAttachmentBoxVisible: this.isAttachmentBoxVisibleInitially,
+ thread: [['insert', {
+ // If the thread was considered to have the activity
+ // mixin once, it will have it forever.
+ hasActivities: this.hasActivities ? true : undefined,
+ id: this.threadId,
+ model: this.threadModel,
+ }]],
+ });
+ if (this.hasActivities) {
+ this.thread.refreshActivities();
+ }
+ if (this.hasFollowers) {
+ this.thread.refreshFollowers();
+ this.thread.fetchAndUpdateSuggestedRecipients();
+ }
+ if (this.hasMessageList) {
+ this.thread.refresh();
+ }
+ } else if (!this.thread || !this.thread.isTemporary) {
+ const currentPartner = this.env.messaging.currentPartner;
+ const message = this.env.models['mail.message'].create({
+ author: [['link', currentPartner]],
+ body: this.env._t("Creating a new record..."),
+ id: getMessageNextTemporaryId(),
+ isTemporary: true,
+ });
+ const nextId = getThreadNextTemporaryId();
+ this.update({
+ isAttachmentBoxVisible: false,
+ thread: [['insert', {
+ areAttachmentsLoaded: true,
+ id: nextId,
+ isTemporary: true,
+ model: this.threadModel,
+ }]],
+ });
+ for (const cache of this.thread.caches) {
+ cache.update({ messages: [['link', message]] });
+ }
+ }
+ }
+
+ /**
+ * @private
+ */
+ _onThreadIsLoadingAttachmentsChanged() {
+ if (!this.thread || !this.thread.isLoadingAttachments) {
+ this._stopAttachmentsLoading();
+ return;
+ }
+ if (this._isPreparingAttachmentsLoading || this.isShowingAttachmentsLoading) {
+ return;
+ }
+ this._prepareAttachmentsLoading();
+ }
+
+ /**
+ * @private
+ */
+ _prepareAttachmentsLoading() {
+ this._isPreparingAttachmentsLoading = true;
+ this._attachmentsLoaderTimeout = this.env.browser.setTimeout(() => {
+ this.update({ isShowingAttachmentsLoading: true });
+ this._isPreparingAttachmentsLoading = false;
+ }, this.env.loadingBaseDelayDuration);
+ }
+
+ /**
+ * @private
+ */
+ _stopAttachmentsLoading() {
+ this.env.browser.clearTimeout(this._attachmentsLoaderTimeout);
+ this._attachmentsLoaderTimeout = null;
+ this.update({ isShowingAttachmentsLoading: false });
+ this._isPreparingAttachmentsLoading = false;
+ }
+
+ }
+
+ Chatter.fields = {
+ composer: many2one('mail.composer', {
+ related: 'thread.composer',
+ }),
+ context: attr({
+ default: {},
+ }),
+ /**
+ * Determines whether `this` should display an activity box.
+ */
+ hasActivities: attr({
+ default: true,
+ }),
+ hasExternalBorder: attr({
+ default: true,
+ }),
+ /**
+ * Determines whether `this` should display followers menu.
+ */
+ hasFollowers: attr({
+ default: true,
+ }),
+ /**
+ * Determines whether `this` should display a message list.
+ */
+ hasMessageList: attr({
+ default: true,
+ }),
+ /**
+ * Whether the message list should manage its scroll.
+ * In particular, when the chatter is on the form view's side,
+ * then the scroll is managed by the message list.
+ * Also, the message list shoud not manage the scroll if it shares it
+ * with the rest of the page.
+ */
+ hasMessageListScrollAdjust: attr({
+ default: false,
+ }),
+ /**
+ * Determines whether `this.thread` should be displayed.
+ */
+ hasThreadView: attr({
+ compute: '_computeHasThreadView',
+ dependencies: [
+ 'hasMessageList',
+ 'thread',
+ ],
+ }),
+ hasTopbarCloseButton: attr({
+ default: false,
+ }),
+ isActivityBoxVisible: attr({
+ default: true,
+ }),
+ /**
+ * Determiners whether the attachment box is currently visible.
+ */
+ isAttachmentBoxVisible: attr({
+ default: false,
+ }),
+ /**
+ * Determiners whether the attachment box is visible initially.
+ */
+ isAttachmentBoxVisibleInitially: attr({
+ default: false,
+ }),
+ isComposerVisible: attr({
+ default: false,
+ }),
+ isDisabled: attr({
+ compute: '_computeIsDisabled',
+ default: false,
+ dependencies: [
+ 'threadIsTemporary',
+ ],
+ }),
+ /**
+ * Determine whether this chatter should be focused at next render.
+ */
+ isDoFocus: attr({
+ default: false,
+ }),
+ isShowingAttachmentsLoading: attr({
+ default: false,
+ }),
+ /**
+ * Not a real field, used to trigger its compute method when one of the
+ * dependencies changes.
+ */
+ onThreadIdOrThreadModelChanged: attr({
+ compute: '_onThreadIdOrThreadModelChanged',
+ dependencies: [
+ 'threadId',
+ 'threadModel',
+ ],
+ }),
+ /**
+ * Not a real field, used to trigger its compute method when one of the
+ * dependencies changes.
+ */
+ onThreadIsLoadingAttachmentsChanged: attr({
+ compute: '_onThreadIsLoadingAttachmentsChanged',
+ dependencies: [
+ 'threadIsLoadingAttachments',
+ ],
+ }),
+ /**
+ * Determines the `mail.thread` that should be displayed by `this`.
+ */
+ thread: many2one('mail.thread'),
+ /**
+ * Determines the id of the thread that will be displayed by `this`.
+ */
+ threadId: attr(),
+ /**
+ * Serves as compute dependency.
+ */
+ threadIsLoadingAttachments: attr({
+ related: 'thread.isLoadingAttachments',
+ }),
+ /**
+ * Serves as compute dependency.
+ */
+ threadIsTemporary: attr({
+ related: 'thread.isTemporary',
+ }),
+ /**
+ * Determines the model of the thread that will be displayed by `this`.
+ */
+ threadModel: attr(),
+ /**
+ * States the `mail.thread_view` displaying `this.thread`.
+ */
+ threadView: one2one('mail.thread_view', {
+ related: 'threadViewer.threadView',
+ }),
+ /**
+ * Determines the `mail.thread_viewer` managing the display of `this.thread`.
+ */
+ threadViewer: one2one('mail.thread_viewer', {
+ default: [['create']],
+ inverse: 'chatter',
+ isCausal: true,
+ }),
+ };
+
+ Chatter.modelName = 'mail.chatter';
+
+ return Chatter;
+}
+
+registerNewModel('mail.chatter', factory);
+
+});
diff --git a/addons/mail/static/src/models/composer/composer.js b/addons/mail/static/src/models/composer/composer.js
new file mode 100644
index 00000000..d20520e3
--- /dev/null
+++ b/addons/mail/static/src/models/composer/composer.js
@@ -0,0 +1,1435 @@
+odoo.define('mail/static/src/models/composer/composer.js', function (require) {
+'use strict';
+
+const emojis = require('mail.emojis');
+const { registerNewModel } = require('mail/static/src/model/model_core.js');
+const { attr, many2many, many2one, one2one } = require('mail/static/src/model/model_field.js');
+const { clear } = require('mail/static/src/model/model_field_command.js');
+const mailUtils = require('mail.utils');
+
+const {
+ addLink,
+ escapeAndCompactTextContent,
+ parseAndTransform,
+} = require('mail.utils');
+
+function factory(dependencies) {
+
+ class Composer extends dependencies['mail.model'] {
+
+ /**
+ * @override
+ */
+ _willCreate() {
+ const res = super._willCreate(...arguments);
+ /**
+ * Determines whether there is a mention RPC currently in progress.
+ * Useful to queue a new call if there is already one pending.
+ */
+ this._hasMentionRpcInProgress = false;
+ /**
+ * Determines the next function to execute after the current mention
+ * RPC is done, if any.
+ */
+ this._nextMentionRpcFunction = undefined;
+ return res;
+ }
+
+ /**
+ * @override
+ */
+ _willDelete() {
+ // Clears the mention queue on deleting the record to prevent
+ // unnecessary RPC.
+ this._nextMentionRpcFunction = undefined;
+ return super._willDelete(...arguments);
+ }
+
+ //----------------------------------------------------------------------
+ // Public
+ //----------------------------------------------------------------------
+
+ /**
+ * Closes the suggestion list.
+ */
+ closeSuggestions() {
+ this.update({ suggestionDelimiterPosition: clear() });
+ }
+
+ /**
+ * @deprecated what this method used to do is now automatically computed
+ * based on composer state
+ */
+ async detectSuggestionDelimiter() {}
+
+ /**
+ * Hides the composer, which only makes sense if the composer is
+ * currently used as a Discuss Inbox reply composer.
+ */
+ discard() {
+ if (this.discussAsReplying) {
+ this.discussAsReplying.clearReplyingToMessage();
+ }
+ }
+
+ /**
+ * Focus this composer and remove focus from all others.
+ * Focus is a global concern, it makes no sense to have multiple composers focused at the
+ * same time.
+ */
+ focus() {
+ const allComposers = this.env.models['mail.composer'].all();
+ for (const otherComposer of allComposers) {
+ if (otherComposer !== this && otherComposer.hasFocus) {
+ otherComposer.update({ hasFocus: false });
+ }
+ }
+ this.update({ hasFocus: true });
+ }
+
+ /**
+ * Inserts text content in text input based on selection.
+ *
+ * @param {string} content
+ */
+ insertIntoTextInput(content) {
+ const partA = this.textInputContent.slice(0, this.textInputCursorStart);
+ const partB = this.textInputContent.slice(
+ this.textInputCursorEnd,
+ this.textInputContent.length
+ );
+ let suggestionDelimiterPosition = this.suggestionDelimiterPosition;
+ if (
+ suggestionDelimiterPosition !== undefined &&
+ suggestionDelimiterPosition >= this.textInputCursorStart
+ ) {
+ suggestionDelimiterPosition = suggestionDelimiterPosition + content.length;
+ }
+ this.update({
+ isLastStateChangeProgrammatic: true,
+ suggestionDelimiterPosition,
+ textInputContent: partA + content + partB,
+ textInputCursorEnd: this.textInputCursorStart + content.length,
+ textInputCursorStart: this.textInputCursorStart + content.length,
+ });
+ }
+
+ insertSuggestion() {
+ const cursorPosition = this.textInputCursorStart;
+ let textLeft = this.textInputContent.substring(
+ 0,
+ this.suggestionDelimiterPosition + 1
+ );
+ let textRight = this.textInputContent.substring(
+ cursorPosition,
+ this.textInputContent.length
+ );
+ if (this.suggestionDelimiter === ':') {
+ textLeft = this.textInputContent.substring(
+ 0,
+ this.suggestionDelimiterPosition
+ );
+ textRight = this.textInputContent.substring(
+ cursorPosition,
+ this.textInputContent.length
+ );
+ }
+ const recordReplacement = this.activeSuggestedRecord.getMentionText();
+ const updateData = {
+ isLastStateChangeProgrammatic: true,
+ textInputContent: textLeft + recordReplacement + ' ' + textRight,
+ textInputCursorEnd: textLeft.length + recordReplacement.length + 1,
+ textInputCursorStart: textLeft.length + recordReplacement.length + 1,
+ };
+ // Specific cases for channel and partner mentions: the message with
+ // the mention will appear in the target channel, or be notified to
+ // the target partner.
+ switch (this.activeSuggestedRecord.constructor.modelName) {
+ case 'mail.thread':
+ Object.assign(updateData, { mentionedChannels: [['link', this.activeSuggestedRecord]] });
+ break;
+ case 'mail.partner':
+ Object.assign(updateData, { mentionedPartners: [['link', this.activeSuggestedRecord]] });
+ break;
+ }
+ this.update(updateData);
+ }
+
+ /**
+ * @private
+ * @returns {mail.partner[]}
+ */
+ _computeRecipients() {
+ const recipients = [...this.mentionedPartners];
+ if (this.thread && !this.isLog) {
+ for (const recipient of this.thread.suggestedRecipientInfoList) {
+ if (recipient.partner && recipient.isSelected) {
+ recipients.push(recipient.partner);
+ }
+ }
+ }
+ return [['replace', recipients]];
+ }
+
+ /**
+ * Open the full composer modal.
+ */
+ async openFullComposer() {
+ const attachmentIds = this.attachments.map(attachment => attachment.id);
+
+ const context = {
+ default_attachment_ids: attachmentIds,
+ default_body: mailUtils.escapeAndCompactTextContent(this.textInputContent),
+ default_is_log: this.isLog,
+ default_model: this.thread.model,
+ default_partner_ids: this.recipients.map(partner => partner.id),
+ default_res_id: this.thread.id,
+ mail_post_autofollow: true,
+ };
+
+ const action = {
+ type: 'ir.actions.act_window',
+ res_model: 'mail.compose.message',
+ view_mode: 'form',
+ views: [[false, 'form']],
+ target: 'new',
+ context: context,
+ };
+ const options = {
+ on_close: () => {
+ if (!this.exists()) {
+ return;
+ }
+ this._reset();
+ this.thread.loadNewMessages();
+ },
+ };
+ await this.env.bus.trigger('do-action', { action, options });
+ }
+
+ /**
+ * Post a message in provided composer's thread based on current composer fields values.
+ */
+ async postMessage() {
+ const thread = this.thread;
+ this.thread.unregisterCurrentPartnerIsTyping({ immediateNotify: true });
+ const escapedAndCompactContent = escapeAndCompactTextContent(this.textInputContent);
+ let body = escapedAndCompactContent.replace(/&nbsp;/g, ' ').trim();
+ // This message will be received from the mail composer as html content
+ // subtype but the urls will not be linkified. If the mail composer
+ // takes the responsibility to linkify the urls we end up with double
+ // linkification a bit everywhere. Ideally we want to keep the content
+ // as text internally and only make html enrichment at display time but
+ // the current design makes this quite hard to do.
+ body = this._generateMentionsLinks(body);
+ body = parseAndTransform(body, addLink);
+ body = this._generateEmojisOnHtml(body);
+ let postData = {
+ attachment_ids: this.attachments.map(attachment => attachment.id),
+ body,
+ channel_ids: this.mentionedChannels.map(channel => channel.id),
+ message_type: 'comment',
+ partner_ids: this.recipients.map(partner => partner.id),
+ };
+ if (this.subjectContent) {
+ postData.subject = this.subjectContent;
+ }
+ try {
+ let messageId;
+ this.update({ isPostingMessage: true });
+ if (thread.model === 'mail.channel') {
+ const command = this._getCommandFromText(body);
+ Object.assign(postData, {
+ subtype_xmlid: 'mail.mt_comment',
+ });
+ if (command) {
+ messageId = await this.async(() => this.env.models['mail.thread'].performRpcExecuteCommand({
+ channelId: thread.id,
+ command: command.name,
+ postData,
+ }));
+ } else {
+ messageId = await this.async(() =>
+ this.env.models['mail.thread'].performRpcMessagePost({
+ postData,
+ threadId: thread.id,
+ threadModel: thread.model,
+ })
+ );
+ }
+ } else {
+ Object.assign(postData, {
+ subtype_xmlid: this.isLog ? 'mail.mt_note' : 'mail.mt_comment',
+ });
+ if (!this.isLog) {
+ postData.context = {
+ mail_post_autofollow: true,
+ };
+ }
+ messageId = await this.async(() =>
+ this.env.models['mail.thread'].performRpcMessagePost({
+ postData,
+ threadId: thread.id,
+ threadModel: thread.model,
+ })
+ );
+ const [messageData] = await this.async(() => this.env.services.rpc({
+ model: 'mail.message',
+ method: 'message_format',
+ args: [[messageId]],
+ }, { shadow: true }));
+ this.env.models['mail.message'].insert(Object.assign(
+ {},
+ this.env.models['mail.message'].convertData(messageData),
+ {
+ originThread: [['insert', {
+ id: thread.id,
+ model: thread.model,
+ }]],
+ })
+ );
+ thread.loadNewMessages();
+ }
+ for (const threadView of this.thread.threadViews) {
+ // Reset auto scroll to be able to see the newly posted message.
+ threadView.update({ hasAutoScrollOnMessageReceived: true });
+ }
+ thread.refreshFollowers();
+ thread.fetchAndUpdateSuggestedRecipients();
+ this._reset();
+ } finally {
+ this.update({ isPostingMessage: false });
+ }
+ }
+
+ /**
+ * Called when current partner is inserting some input in composer.
+ * Useful to notify current partner is currently typing something in the
+ * composer of this thread to all other members.
+ */
+ handleCurrentPartnerIsTyping() {
+ if (!this.thread) {
+ return;
+ }
+ if (
+ this.suggestionModelName === 'mail.channel_command' ||
+ this._getCommandFromText(this.textInputContent)
+ ) {
+ return;
+ }
+ if (this.thread.typingMembers.includes(this.env.messaging.currentPartner)) {
+ this.thread.refreshCurrentPartnerIsTyping();
+ } else {
+ this.thread.registerCurrentPartnerIsTyping();
+ }
+ }
+
+ /**
+ * Sets the first suggestion as active. Main and extra records are
+ * considered together.
+ */
+ setFirstSuggestionActive() {
+ const suggestedRecords = this.mainSuggestedRecords.concat(this.extraSuggestedRecords);
+ const firstRecord = suggestedRecords[0];
+ this.update({ activeSuggestedRecord: [['link', firstRecord]] });
+ }
+
+ /**
+ * Sets the last suggestion as active. Main and extra records are
+ * considered together.
+ */
+ setLastSuggestionActive() {
+ const suggestedRecords = this.mainSuggestedRecords.concat(this.extraSuggestedRecords);
+ const { length, [length - 1]: lastRecord } = suggestedRecords;
+ this.update({ activeSuggestedRecord: [['link', lastRecord]] });
+ }
+
+ /**
+ * Sets the next suggestion as active. Main and extra records are
+ * considered together.
+ */
+ setNextSuggestionActive() {
+ const suggestedRecords = this.mainSuggestedRecords.concat(this.extraSuggestedRecords);
+ const activeElementIndex = suggestedRecords.findIndex(
+ suggestion => suggestion === this.activeSuggestedRecord
+ );
+ if (activeElementIndex === suggestedRecords.length - 1) {
+ // loop when reaching the end of the list
+ this.setFirstSuggestionActive();
+ return;
+ }
+ const nextRecord = suggestedRecords[activeElementIndex + 1];
+ this.update({ activeSuggestedRecord: [['link', nextRecord]] });
+ }
+
+ /**
+ * Sets the previous suggestion as active. Main and extra records are
+ * considered together.
+ */
+ setPreviousSuggestionActive() {
+ const suggestedRecords = this.mainSuggestedRecords.concat(this.extraSuggestedRecords);
+ const activeElementIndex = suggestedRecords.findIndex(
+ suggestion => suggestion === this.activeSuggestedRecord
+ );
+ if (activeElementIndex === 0) {
+ // loop when reaching the start of the list
+ this.setLastSuggestionActive();
+ return;
+ }
+ const previousRecord = suggestedRecords[activeElementIndex - 1];
+ this.update({ activeSuggestedRecord: [['link', previousRecord]] });
+ }
+
+ //----------------------------------------------------------------------
+ // Private
+ //----------------------------------------------------------------------
+
+ /**
+ * @deprecated
+ * @private
+ * @returns {mail.canned_response}
+ */
+ _computeActiveSuggestedCannedResponse() {
+ if (this.suggestionDelimiter === ':' && this.activeSuggestedRecord) {
+ return [['link', this.activeSuggestedRecord]];
+ }
+ return [['unlink']];
+ }
+
+ /**
+ * @deprecated
+ * @private
+ * @returns {mail.thread}
+ */
+ _computeActiveSuggestedChannel() {
+ if (this.suggestionDelimiter === '#' && this.activeSuggestedRecord) {
+ return [['link', this.activeSuggestedRecord]];
+ }
+ return [['unlink']];
+ }
+
+ /**
+ * @deprecated
+ * @private
+ * @returns {mail.channel_command}
+ */
+ _computeActiveSuggestedChannelCommand() {
+ if (this.suggestionDelimiter === '/' && this.activeSuggestedRecord) {
+ return [['link', this.activeSuggestedRecord]];
+ }
+ return [['unlink']];
+ }
+
+ /**
+ * @deprecated
+ * @private
+ * @returns {mail.partner}
+ */
+ _computeActiveSuggestedPartner() {
+ if (this.suggestionDelimiter === '@' && this.activeSuggestedRecord) {
+ return [['link', this.activeSuggestedRecord]];
+ }
+ return [['unlink']];
+ }
+
+ /**
+ * Clears the active suggested record on closing mentions or adapt it if
+ * the active current record is no longer part of the suggestions.
+ *
+ * @private
+ * @returns {mail.model}
+ */
+ _computeActiveSuggestedRecord() {
+ if (
+ this.mainSuggestedRecords.length === 0 &&
+ this.extraSuggestedRecords.length === 0
+ ) {
+ return [['unlink']];
+ }
+ if (
+ this.mainSuggestedRecords.includes(this.activeSuggestedRecord) ||
+ this.extraSuggestedRecords.includes(this.activeSuggestedRecord)
+ ) {
+ return;
+ }
+ const suggestedRecords = this.mainSuggestedRecords.concat(this.extraSuggestedRecords);
+ const firstRecord = suggestedRecords[0];
+ return [['link', firstRecord]];
+ }
+
+ /**
+ * @deprecated
+ * @private
+ * @returns {string}
+ */
+ _computeActiveSuggestedRecordName() {
+ switch (this.suggestionDelimiter) {
+ case '@':
+ return "activeSuggestedPartner";
+ case ':':
+ return "activeSuggestedCannedResponse";
+ case '/':
+ return "activeSuggestedChannelCommand";
+ case '#':
+ return "activeSuggestedChannel";
+ default:
+ return clear();
+ }
+ }
+
+ /**
+ * @private
+ * @returns {boolean}
+ */
+ _computeCanPostMessage() {
+ if (!this.textInputContent && this.attachments.length === 0) {
+ return false;
+ }
+ return !this.hasUploadingAttachment && !this.isPostingMessage;
+ }
+
+ /**
+ * @deprecated
+ * @private
+ * @returns {mail.partner[]}
+ */
+ _computeExtraSuggestedPartners() {
+ if (this.suggestionDelimiter === '@') {
+ return [['replace', this.extraSuggestedRecords]];
+ }
+ return [['unlink-all']];
+ }
+
+ /**
+ * Clears the extra suggested record on closing mentions, and ensures
+ * the extra list does not contain any element already present in the
+ * main list, which is a requirement for the navigation process.
+ *
+ * @private
+ * @returns {mail.model[]}
+ */
+ _computeExtraSuggestedRecords() {
+ if (this.suggestionDelimiterPosition === undefined) {
+ return [['unlink-all']];
+ }
+ return [['unlink', this.mainSuggestedRecords]];
+ }
+
+ /**
+ * @deprecated
+ * @private
+ * @returns {mail.model[]}
+ */
+ _computeExtraSuggestedRecordsList() {
+ return this.extraSuggestedRecords;
+ }
+
+ /**
+ * @deprecated
+ * @private
+ * @returns {string}
+ */
+ _computeExtraSuggestedRecordsListName() {
+ if (this.suggestionDelimiter === '@') {
+ return "extraSuggestedPartners";
+ }
+ return clear();
+ }
+
+ /**
+ * @private
+ * @return {boolean}
+ */
+ _computeHasSuggestions() {
+ return this.mainSuggestedRecords.length > 0 || this.extraSuggestedRecords.length > 0;
+ }
+
+ /**
+ * @private
+ * @returns {boolean}
+ */
+ _computeHasUploadingAttachment() {
+ return this.attachments.some(attachment => attachment.isTemporary);
+ }
+
+ /**
+ * @deprecated
+ * @private
+ * @returns {mail.model[]}
+ */
+ _computeMainSuggestedPartners() {
+ if (this.suggestionDelimiter === '@') {
+ return [['replace', this.mainSuggestedRecords]];
+ }
+ return [['unlink-all']];
+ }
+
+ /**
+ * Clears the main suggested record on closing mentions.
+ *
+ * @private
+ * @returns {mail.model[]}
+ */
+ _computeMainSuggestedRecords() {
+ if (this.suggestionDelimiterPosition === undefined) {
+ return [['unlink-all']];
+ }
+ }
+
+ /**
+ * @deprecated
+ * @private
+ * @returns {mail.model[]}
+ */
+ _computeMainSuggestedRecordsList() {
+ return this.mainSuggestedRecords;
+ }
+
+ /**
+ * @deprecated
+ * @private
+ * @returns {string}
+ */
+ _computeMainSuggestedRecordsListName() {
+ switch (this.suggestionDelimiter) {
+ case '@':
+ return "mainSuggestedPartners";
+ case ':':
+ return "suggestedCannedResponses";
+ case '/':
+ return "suggestedChannelCommands";
+ case '#':
+ return "suggestedChannels";
+ default:
+ return clear();
+ }
+ }
+
+ /**
+ * Detects if mentioned partners are still in the composer text input content
+ * and removes them if not.
+ *
+ * @private
+ * @returns {mail.partner[]}
+ */
+ _computeMentionedPartners() {
+ const unmentionedPartners = [];
+ // ensure the same mention is not used multiple times if multiple
+ // partners have the same name
+ const namesIndex = {};
+ for (const partner of this.mentionedPartners) {
+ const fromIndex = namesIndex[partner.name] !== undefined
+ ? namesIndex[partner.name] + 1 :
+ 0;
+ const index = this.textInputContent.indexOf(`@${partner.name}`, fromIndex);
+ if (index !== -1) {
+ namesIndex[partner.name] = index;
+ } else {
+ unmentionedPartners.push(partner);
+ }
+ }
+ return [['unlink', unmentionedPartners]];
+ }
+
+ /**
+ * Detects if mentioned channels are still in the composer text input content
+ * and removes them if not.
+ *
+ * @private
+ * @returns {mail.partner[]}
+ */
+ _computeMentionedChannels() {
+ const unmentionedChannels = [];
+ // ensure the same mention is not used multiple times if multiple
+ // channels have the same name
+ const namesIndex = {};
+ for (const channel of this.mentionedChannels) {
+ const fromIndex = namesIndex[channel.name] !== undefined
+ ? namesIndex[channel.name] + 1 :
+ 0;
+ const index = this.textInputContent.indexOf(`#${channel.name}`, fromIndex);
+ if (index !== -1) {
+ namesIndex[channel.name] = index;
+ } else {
+ unmentionedChannels.push(channel);
+ }
+ }
+ return [['unlink', unmentionedChannels]];
+ }
+
+ /**
+ * @deprecated
+ * @private
+ * @returns {mail.canned_response[]}
+ */
+ _computeSuggestedCannedResponses() {
+ if (this.suggestionDelimiter === ':') {
+ return [['replace', this.mainSuggestedRecords]];
+ }
+ return [['unlink-all']];
+ }
+
+ /**
+ * @deprecated
+ * @private
+ * @returns {mail.thread[]}
+ */
+ _computeSuggestedChannels() {
+ if (this.suggestionDelimiter === '#') {
+ return [['replace', this.mainSuggestedRecords]];
+ }
+ return [['unlink-all']];
+ }
+
+ /**
+ * @private
+ * @returns {string}
+ */
+ _computeSuggestionDelimiter() {
+ if (
+ this.suggestionDelimiterPosition === undefined ||
+ this.suggestionDelimiterPosition >= this.textInputContent.length
+ ) {
+ return clear();
+ }
+ return this.textInputContent[this.suggestionDelimiterPosition];
+ }
+
+ /**
+ * @private
+ * @returns {integer}
+ */
+ _computeSuggestionDelimiterPosition() {
+ if (this.textInputCursorStart !== this.textInputCursorEnd) {
+ // avoid interfering with multi-char selection
+ return clear();
+ }
+ const candidatePositions = [];
+ // keep the current delimiter if it is still valid
+ if (
+ this.suggestionDelimiterPosition !== undefined &&
+ this.suggestionDelimiterPosition < this.textInputCursorStart
+ ) {
+ candidatePositions.push(this.suggestionDelimiterPosition);
+ }
+ // consider the char before the current cursor position if the
+ // current delimiter is no longer valid (or if there is none)
+ if (this.textInputCursorStart > 0) {
+ candidatePositions.push(this.textInputCursorStart - 1);
+ }
+ const suggestionDelimiters = ['@', ':', '#', '/'];
+ for (const candidatePosition of candidatePositions) {
+ if (
+ candidatePosition < 0 ||
+ candidatePosition >= this.textInputContent.length
+ ) {
+ continue;
+ }
+ const candidateChar = this.textInputContent[candidatePosition];
+ if (candidateChar === '/' && candidatePosition !== 0) {
+ continue;
+ }
+ if (!suggestionDelimiters.includes(candidateChar)) {
+ continue;
+ }
+ const charBeforeCandidate = this.textInputContent[candidatePosition - 1];
+ if (charBeforeCandidate && !/\s/.test(charBeforeCandidate)) {
+ continue;
+ }
+ return candidatePosition;
+ }
+ return clear();
+ }
+
+ /**
+ * @deprecated
+ * @private
+ * @returns {mail.channel_command[]}
+ */
+ _computeSuggestedChannelCommands() {
+ if (this.suggestionDelimiter === '/') {
+ return [['replace', this.mainSuggestedRecords]];
+ }
+ return [['unlink-all']];
+ }
+
+ /**
+ * @private
+ * @returns {string}
+ */
+ _computeSuggestionModelName() {
+ switch (this.suggestionDelimiter) {
+ case '@':
+ return 'mail.partner';
+ case ':':
+ return 'mail.canned_response';
+ case '/':
+ return 'mail.channel_command';
+ case '#':
+ return 'mail.thread';
+ default:
+ return clear();
+ }
+ }
+
+ /**
+ * @private
+ * @returns {string}
+ */
+ _computeSuggestionSearchTerm() {
+ if (
+ this.suggestionDelimiterPosition === undefined ||
+ this.suggestionDelimiterPosition >= this.textInputCursorStart
+ ) {
+ return clear();
+ }
+ return this.textInputContent.substring(this.suggestionDelimiterPosition + 1, this.textInputCursorStart);
+ }
+
+ /**
+ * Executes the given async function, only when the last function
+ * executed by this method terminates. If there is already a pending
+ * function it is replaced by the new one. This ensures the result of
+ * these function come in the same order as the call order, and it also
+ * allows to skip obsolete intermediate calls.
+ *
+ * @private
+ * @param {function} func
+ */
+ async _executeOrQueueFunction(func) {
+ if (this._hasMentionRpcInProgress) {
+ this._nextMentionRpcFunction = func;
+ return;
+ }
+ this._hasMentionRpcInProgress = true;
+ this._nextMentionRpcFunction = undefined;
+ try {
+ await this.async(func);
+ } finally {
+ this._hasMentionRpcInProgress = false;
+ if (this._nextMentionRpcFunction) {
+ this._executeOrQueueFunction(this._nextMentionRpcFunction);
+ }
+ }
+ }
+
+ /**
+ * @private
+ * @param {string} htmlString
+ * @returns {string}
+ */
+ _generateEmojisOnHtml(htmlString) {
+ for (const emoji of emojis) {
+ for (const source of emoji.sources) {
+ const escapedSource = String(source).replace(
+ /([.*+?=^!:${}()|[\]/\\])/g,
+ '\\$1');
+ const regexp = new RegExp(
+ '(\\s|^)(' + escapedSource + ')(?=\\s|$)',
+ 'g');
+ htmlString = htmlString.replace(regexp, '$1' + emoji.unicode);
+ }
+ }
+ return htmlString;
+ }
+
+ /**
+ *
+ * Generates the html link related to the mentioned partner
+ *
+ * @private
+ * @param {string} body
+ * @returns {string}
+ */
+ _generateMentionsLinks(body) {
+ // List of mention data to insert in the body.
+ // Useful to do the final replace after parsing to avoid using the
+ // same tag twice if two different mentions have the same name.
+ const mentions = [];
+ for (const partner of this.mentionedPartners) {
+ const placeholder = `@-mention-partner-${partner.id}`;
+ const text = `@${owl.utils.escape(partner.name)}`;
+ mentions.push({
+ class: 'o_mail_redirect',
+ id: partner.id,
+ model: 'res.partner',
+ placeholder,
+ text,
+ });
+ body = body.replace(text, placeholder);
+ }
+ for (const channel of this.mentionedChannels) {
+ const placeholder = `#-mention-channel-${channel.id}`;
+ const text = `#${owl.utils.escape(channel.name)}`;
+ mentions.push({
+ class: 'o_channel_redirect',
+ id: channel.id,
+ model: 'mail.channel',
+ placeholder,
+ text,
+ });
+ body = body.replace(text, placeholder);
+ }
+ const baseHREF = this.env.session.url('/web');
+ for (const mention of mentions) {
+ const href = `href='${baseHREF}#model=${mention.model}&id=${mention.id}'`;
+ const attClass = `class='${mention.class}'`;
+ const dataOeId = `data-oe-id='${mention.id}'`;
+ const dataOeModel = `data-oe-model='${mention.model}'`;
+ const target = `target='_blank'`;
+ const link = `<a ${href} ${attClass} ${dataOeId} ${dataOeModel} ${target}>${mention.text}</a>`;
+ body = body.replace(mention.placeholder, link);
+ }
+ return body;
+ }
+
+ /**
+ * @private
+ * @param {string} content html content
+ * @returns {mail.channel_command|undefined} command, if any in the content
+ */
+ _getCommandFromText(content) {
+ if (content.startsWith('/')) {
+ const firstWord = content.substring(1).split(/\s/)[0];
+ return this.env.messaging.commands.find(command => {
+ if (command.name !== firstWord) {
+ return false;
+ }
+ if (command.channel_types) {
+ return command.channel_types.includes(this.thread.channel_type);
+ }
+ return true;
+ });
+ }
+ return undefined;
+ }
+
+ /**
+ * Updates the suggestion state based on the currently saved composer
+ * state (in particular content and cursor position).
+ *
+ * @private
+ */
+ _onChangeUpdateSuggestionList() {
+ // Update the suggestion list immediately for a reactive UX...
+ this._updateSuggestionList();
+ // ...and then update it again after the server returned data.
+ this._executeOrQueueFunction(async () => {
+ if (
+ this.suggestionDelimiterPosition === undefined ||
+ this.suggestionSearchTerm === undefined ||
+ !this.suggestionModelName
+ ) {
+ // ignore obsolete call
+ return;
+ }
+ const Model = this.env.models[this.suggestionModelName];
+ const searchTerm = this.suggestionSearchTerm;
+ await this.async(() => Model.fetchSuggestions(searchTerm, { thread: this.thread }));
+ this._updateSuggestionList();
+ if (
+ this.suggestionSearchTerm &&
+ this.suggestionSearchTerm === searchTerm &&
+ this.suggestionModelName &&
+ this.env.models[this.suggestionModelName] === Model &&
+ !this.hasSuggestions
+ ) {
+ this.closeSuggestions();
+ }
+ });
+ }
+
+ /**
+ * @private
+ */
+ _reset() {
+ this.update({
+ attachments: [['unlink-all']],
+ isLastStateChangeProgrammatic: true,
+ mentionedChannels: [['unlink-all']],
+ mentionedPartners: [['unlink-all']],
+ subjectContent: "",
+ textInputContent: '',
+ textInputCursorEnd: 0,
+ textInputCursorStart: 0,
+ });
+ }
+
+ /**
+ * Updates the current suggestion list. This method should be called
+ * whenever the UI has to be refreshed following change in state.
+ *
+ * This method should ideally be a compute, but its dependencies are
+ * currently too complex to express due to accessing plenty of fields
+ * from all records of dynamic models.
+ *
+ * @private
+ */
+ _updateSuggestionList() {
+ if (
+ this.suggestionDelimiterPosition === undefined ||
+ this.suggestionSearchTerm === undefined ||
+ !this.suggestionModelName
+ ) {
+ return;
+ }
+ const Model = this.env.models[this.suggestionModelName];
+ const [
+ mainSuggestedRecords,
+ extraSuggestedRecords = [],
+ ] = Model.searchSuggestions(this.suggestionSearchTerm, { thread: this.thread });
+ const sortFunction = Model.getSuggestionSortFunction(this.suggestionSearchTerm, { thread: this.thread });
+ mainSuggestedRecords.sort(sortFunction);
+ extraSuggestedRecords.sort(sortFunction);
+ // arbitrary limit to avoid displaying too many elements at once
+ // ideally a load more mechanism should be introduced
+ const limit = 8;
+ mainSuggestedRecords.length = Math.min(mainSuggestedRecords.length, limit);
+ extraSuggestedRecords.length = Math.min(extraSuggestedRecords.length, limit - mainSuggestedRecords.length);
+ this.update({
+ extraSuggestedRecords: [['replace', extraSuggestedRecords]],
+ hasToScrollToActiveSuggestion: true,
+ mainSuggestedRecords: [['replace', mainSuggestedRecords]],
+ });
+ }
+
+ /**
+ * Validates user's current typing as a correct mention keyword in order
+ * to trigger mentions suggestions display.
+ * Returns the mention keyword without the suggestion delimiter if it
+ * has been validated and false if not.
+ *
+ * @deprecated
+ * @private
+ * @param {boolean} beginningOnly
+ * @returns {string|boolean}
+ */
+ _validateMentionKeyword(beginningOnly) {
+ // use position before suggestion delimiter because there should be whitespaces
+ // or line feed/carriage return before the suggestion delimiter
+ const beforeSuggestionDelimiterPosition = this.suggestionDelimiterPosition - 1;
+ if (beginningOnly && beforeSuggestionDelimiterPosition > 0) {
+ return false;
+ }
+ let searchStr = this.textInputContent.substring(
+ beforeSuggestionDelimiterPosition,
+ this.textInputCursorStart
+ );
+ // regex string start with suggestion delimiter or whitespace then suggestion delimiter
+ const pattern = "^" + this.suggestionDelimiter + "|^\\s" + this.suggestionDelimiter;
+ const regexStart = new RegExp(pattern, 'g');
+ // trim any left whitespaces or the left line feed/ carriage return
+ // at the beginning of the string
+ searchStr = searchStr.replace(/^\s\s*|^[\n\r]/g, '');
+ if (regexStart.test(searchStr) && searchStr.length) {
+ searchStr = searchStr.replace(pattern, '');
+ return !searchStr.includes(' ') && !/[\r\n]/.test(searchStr)
+ ? searchStr.replace(this.suggestionDelimiter, '')
+ : false;
+ }
+ return false;
+ }
+ }
+
+ Composer.fields = {
+ /**
+ * Deprecated. Use `activeSuggestedRecord` instead.
+ */
+ activeSuggestedCannedResponse: many2one('mail.canned_response', {
+ compute: '_computeActiveSuggestedCannedResponse',
+ dependencies: [
+ 'activeSuggestedRecord',
+ 'suggestionDelimiter',
+ ],
+ }),
+ /**
+ * Deprecated. Use `activeSuggestedRecord` instead.
+ */
+ activeSuggestedChannel: many2one('mail.thread', {
+ compute: '_computeActiveSuggestedChannel',
+ dependencies: [
+ 'activeSuggestedRecord',
+ 'suggestionDelimiter',
+ ],
+ }),
+ /**
+ * Deprecated. Use `activeSuggestedRecord` instead.
+ */
+ activeSuggestedChannelCommand: many2one('mail.channel_command', {
+ compute: '_computeActiveSuggestedChannelCommand',
+ dependencies: [
+ 'activeSuggestedRecord',
+ 'suggestionDelimiter',
+ ],
+ }),
+ /**
+ * Deprecated. Use `activeSuggestedRecord` instead.
+ */
+ activeSuggestedPartner: many2one('mail.partner', {
+ compute: '_computeActiveSuggestedPartner',
+ dependencies: [
+ 'activeSuggestedRecord',
+ 'suggestionDelimiter',
+ ],
+ }),
+ /**
+ * Determines the suggested record that is currently active. This record
+ * is highlighted in the UI and it will be the selected record if the
+ * suggestion is confirmed by the user.
+ */
+ activeSuggestedRecord: many2one('mail.model', {
+ compute: '_computeActiveSuggestedRecord',
+ dependencies: [
+ 'activeSuggestedRecord',
+ 'extraSuggestedRecords',
+ 'mainSuggestedRecords',
+ ],
+ }),
+ /**
+ * Deprecated, suggestions should be used in a manner that does not
+ * depend on their type. Use `activeSuggestedRecord` directly instead.
+ */
+ activeSuggestedRecordName: attr({
+ compute: '_computeActiveSuggestedRecordName',
+ dependencies: [
+ 'suggestionDelimiter',
+ ],
+ }),
+ attachments: many2many('mail.attachment', {
+ inverse: 'composers',
+ }),
+ /**
+ * This field watches the uploading (= temporary) status of attachments
+ * linked to this composer.
+ *
+ * Useful to determine whether there are some attachments that are being
+ * uploaded.
+ */
+ attachmentsAreTemporary: attr({
+ related: 'attachments.isTemporary',
+ }),
+ canPostMessage: attr({
+ compute: '_computeCanPostMessage',
+ dependencies: [
+ 'attachments',
+ 'hasUploadingAttachment',
+ 'isPostingMessage',
+ 'textInputContent',
+ ],
+ default: false,
+ }),
+ /**
+ * Instance of discuss if this composer is used as the reply composer
+ * from Inbox. This field is computed from the inverse relation and
+ * should be considered read-only.
+ */
+ discussAsReplying: one2one('mail.discuss', {
+ inverse: 'replyingToMessageOriginThreadComposer',
+ }),
+ /**
+ * Deprecated. Use `extraSuggestedRecords` instead.
+ */
+ extraSuggestedPartners: many2many('mail.partner', {
+ compute: '_computeExtraSuggestedPartners',
+ dependencies: [
+ 'extraSuggestedRecords',
+ 'suggestionDelimiter',
+ ],
+ }),
+ /**
+ * Determines the extra records that are currently suggested.
+ * Allows to have different model types of mentions through a dynamic
+ * process. 2 arbitrary lists can be provided and the second is defined
+ * as "extra".
+ */
+ extraSuggestedRecords: many2many('mail.model', {
+ compute: '_computeExtraSuggestedRecords',
+ dependencies: [
+ 'extraSuggestedRecords',
+ 'mainSuggestedRecords',
+ 'suggestionDelimiterPosition',
+ ],
+ }),
+ /**
+ * Deprecated. Use `extraSuggestedRecords` instead.
+ */
+ extraSuggestedRecordsList: attr({
+ compute: '_computeExtraSuggestedRecordsList',
+ dependencies: [
+ 'extraSuggestedRecords',
+ ],
+ }),
+ /**
+ * Deprecated, suggestions should be used in a manner that does not
+ * depend on their type. Use `extraSuggestedRecords` directly instead.
+ */
+ extraSuggestedRecordsListName: attr({
+ compute: '_computeExtraSuggestedRecordsListName',
+ dependencies: [
+ 'suggestionDelimiter',
+ ],
+ }),
+ /**
+ * This field determines whether some attachments linked to this
+ * composer are being uploaded.
+ */
+ hasUploadingAttachment: attr({
+ compute: '_computeHasUploadingAttachment',
+ dependencies: [
+ 'attachments',
+ 'attachmentsAreTemporary',
+ ],
+ }),
+ hasFocus: attr({
+ default: false,
+ }),
+ /**
+ * States whether there is any result currently found for the current
+ * suggestion delimiter and search term, if applicable.
+ */
+ hasSuggestions: attr({
+ compute: '_computeHasSuggestions',
+ dependencies: [
+ 'extraSuggestedRecords',
+ 'mainSuggestedRecords',
+ ],
+ default: false,
+ }),
+ /**
+ * Determines whether the currently active suggestion should be scrolled
+ * into view.
+ */
+ hasToScrollToActiveSuggestion: attr({
+ default: false,
+ }),
+ /**
+ * Determines whether the last change (since the last render) was
+ * programmatic. Useful to avoid restoring the state when its change was
+ * from a user action, in particular to prevent the cursor from jumping
+ * to its previous position after the user clicked on the textarea while
+ * it didn't have the focus anymore.
+ */
+ isLastStateChangeProgrammatic: attr({
+ default: false,
+ }),
+ /**
+ * If true composer will log a note, else a comment will be posted.
+ */
+ isLog: attr({
+ default: false,
+ }),
+ /**
+ * Determines whether a post_message request is currently pending.
+ */
+ isPostingMessage: attr(),
+ /**
+ * Deprecated. Use `mainSuggestedRecords` instead.
+ */
+ mainSuggestedPartners: many2many('mail.partner', {
+ compute: '_computeMainSuggestedPartners',
+ dependencies: [
+ 'mainSuggestedRecords',
+ 'suggestionDelimiter',
+ ],
+ }),
+ /**
+ * Determines the main records that are currently suggested.
+ * Allows to have different model types of mentions through a dynamic
+ * process. 2 arbitrary lists can be provided and the first is defined
+ * as "main".
+ */
+ mainSuggestedRecords: many2many('mail.model', {
+ compute: '_computeMainSuggestedRecords',
+ dependencies: [
+ 'mainSuggestedRecords',
+ 'suggestionDelimiterPosition',
+ ],
+ }),
+ /**
+ * Deprecated. Use `mainSuggestedRecords` instead.
+ */
+ mainSuggestedRecordsList: attr({
+ compute: '_computeMainSuggestedRecordsList',
+ dependencies: [
+ 'mainSuggestedRecords',
+ ],
+ }),
+ /**
+ * Deprecated, suggestions should be used in a manner that does not
+ * depend on their type. Use `mainSuggestedRecords` directly instead.
+ */
+ mainSuggestedRecordsListName: attr({
+ compute: '_computeMainSuggestedRecordsListName',
+ dependencies: [
+ 'suggestionDelimiter',
+ ],
+ }),
+ mentionedChannels: many2many('mail.thread', {
+ compute: '_computeMentionedChannels',
+ dependencies: ['textInputContent'],
+ }),
+ mentionedPartners: many2many('mail.partner', {
+ compute: '_computeMentionedPartners',
+ dependencies: [
+ 'mentionedPartners',
+ 'mentionedPartnersName',
+ 'textInputContent',
+ ],
+ }),
+ /**
+ * Serves as compute dependency.
+ */
+ mentionedPartnersName: attr({
+ related: 'mentionedPartners.name',
+ }),
+ /**
+ * Not a real field, used to trigger `_onChangeUpdateSuggestionList`
+ * when one of the dependencies changes.
+ */
+ onChangeUpdateSuggestionList: attr({
+ compute: '_onChangeUpdateSuggestionList',
+ dependencies: [
+ 'suggestionDelimiterPosition',
+ 'suggestionModelName',
+ 'suggestionSearchTerm',
+ 'thread',
+ ],
+ }),
+ /**
+ * Determines the extra `mail.partner` (on top of existing followers)
+ * that will receive the message being composed by `this`, and that will
+ * also be added as follower of `this.thread`.
+ */
+ recipients: many2many('mail.partner', {
+ compute: '_computeRecipients',
+ dependencies: [
+ 'isLog',
+ 'mentionedPartners',
+ 'threadSuggestedRecipientInfoListIsSelected',
+ // FIXME thread.suggestedRecipientInfoList.partner should be a
+ // dependency, but it is currently impossible to have a related
+ // m2o through a m2m. task-2261221
+ ]
+ }),
+ /**
+ * Serves as compute dependency.
+ */
+ threadSuggestedRecipientInfoList: many2many('mail.suggested_recipient_info', {
+ related: 'thread.suggestedRecipientInfoList',
+ }),
+ /**
+ * Serves as compute dependency.
+ */
+ threadSuggestedRecipientInfoListIsSelected: attr({
+ related: 'threadSuggestedRecipientInfoList.isSelected',
+ }),
+ /**
+ * Composer subject input content.
+ */
+ subjectContent: attr({
+ default: "",
+ }),
+ /**
+ * Deprecated. Use `mainSuggestedRecords` instead.
+ */
+ suggestedCannedResponses: many2many('mail.canned_response', {
+ compute: '_computeSuggestedCannedResponses',
+ dependencies: [
+ 'mainSuggestedRecords',
+ 'suggestionDelimiter',
+ ],
+ }),
+ /**
+ * Deprecated. Use `mainSuggestedRecords` instead.
+ */
+ suggestedChannelCommands: many2many('mail.channel_command', {
+ compute: '_computeSuggestedChannelCommands',
+ dependencies: [
+ 'mainSuggestedRecords',
+ 'suggestionDelimiter',
+ ],
+ }),
+ /**
+ * Deprecated. Use `mainSuggestedRecords` instead.
+ */
+ suggestedChannels: many2many('mail.thread', {
+ compute: '_computeSuggestedChannels',
+ dependencies: [
+ 'mainSuggestedRecords',
+ 'suggestionDelimiter',
+ ],
+ }),
+ /**
+ * States which type of suggestion is currently in progress, if any.
+ * The value of this field contains the magic char that corresponds to
+ * the suggestion currently in progress, and it must be one of these:
+ * canned responses (:), channels (#), commands (/) and partners (@)
+ */
+ suggestionDelimiter: attr({
+ compute: '_computeSuggestionDelimiter',
+ dependencies: [
+ 'suggestionDelimiterPosition',
+ 'textInputContent',
+ ],
+ }),
+ /**
+ * States the position inside textInputContent of the suggestion
+ * delimiter currently in consideration. Useful if the delimiter char
+ * appears multiple times in the content.
+ * Note: the position is 0 based so it's important to compare to
+ * `undefined` when checking for the absence of a value.
+ */
+ suggestionDelimiterPosition: attr({
+ compute: '_computeSuggestionDelimiterPosition',
+ dependencies: [
+ 'textInputContent',
+ 'textInputCursorEnd',
+ 'textInputCursorStart',
+ ],
+ }),
+ /**
+ * States the target model name of the suggestion currently in progress,
+ * if any.
+ */
+ suggestionModelName: attr({
+ compute: '_computeSuggestionModelName',
+ dependencies: [
+ 'suggestionDelimiter',
+ ],
+ }),
+ /**
+ * States the search term to use for suggestions (if any).
+ */
+ suggestionSearchTerm: attr({
+ compute: '_computeSuggestionSearchTerm',
+ dependencies: [
+ 'suggestionDelimiterPosition',
+ 'textInputContent',
+ 'textInputCursorStart',
+ ],
+ }),
+ textInputContent: attr({
+ default: "",
+ }),
+ textInputCursorEnd: attr({
+ default: 0,
+ }),
+ textInputCursorStart: attr({
+ default: 0,
+ }),
+ textInputSelectionDirection: attr({
+ default: "none",
+ }),
+ thread: one2one('mail.thread', {
+ inverse: 'composer',
+ }),
+ };
+
+ Composer.modelName = 'mail.composer';
+
+ return Composer;
+}
+
+registerNewModel('mail.composer', factory);
+
+});
diff --git a/addons/mail/static/src/models/country/country.js b/addons/mail/static/src/models/country/country.js
new file mode 100644
index 00000000..fb3617cf
--- /dev/null
+++ b/addons/mail/static/src/models/country/country.js
@@ -0,0 +1,55 @@
+odoo.define('mail/static/src/models/country/country.js', function (require) {
+'use strict';
+
+const { registerNewModel } = require('mail/static/src/model/model_core.js');
+const { attr } = require('mail/static/src/model/model_field.js');
+const { clear } = require('mail/static/src/model/model_field_command.js');
+
+function factory(dependencies) {
+
+ class Country extends dependencies['mail.model'] {
+
+ //----------------------------------------------------------------------
+ // Private
+ //----------------------------------------------------------------------
+
+ /**
+ * @override
+ */
+ static _createRecordLocalId(data) {
+ return `${this.modelName}_${data.id}`;
+ }
+
+ /**
+ * @private
+ * @returns {string|undefined}
+ */
+ _computeFlagUrl() {
+ if (!this.code) {
+ return clear();
+ }
+ return `/base/static/img/country_flags/${this.code}.png`;
+ }
+
+ }
+
+ Country.fields = {
+ code: attr(),
+ flagUrl: attr({
+ compute: '_computeFlagUrl',
+ dependencies: [
+ 'code',
+ ],
+ }),
+ id: attr(),
+ name: attr(),
+ };
+
+ Country.modelName = 'mail.country';
+
+ return Country;
+}
+
+registerNewModel('mail.country', factory);
+
+});
diff --git a/addons/mail/static/src/models/device/device.js b/addons/mail/static/src/models/device/device.js
new file mode 100644
index 00000000..29e664d3
--- /dev/null
+++ b/addons/mail/static/src/models/device/device.js
@@ -0,0 +1,71 @@
+odoo.define('mail/static/src/models/device/device.js', function (require) {
+'use strict';
+
+const { registerNewModel } = require('mail/static/src/model/model_core.js');
+const { attr } = require('mail/static/src/model/model_field.js');
+
+function factory(dependencies) {
+
+ class Device extends dependencies['mail.model'] {
+
+ /**
+ * @override
+ */
+ _created() {
+ const res = super._created(...arguments);
+ this._refresh();
+ this._onResize = _.debounce(() => this._refresh(), 100);
+ return res;
+ }
+
+ /**
+ * @override
+ */
+ _willDelete() {
+ window.removeEventListener('resize', this._onResize);
+ return super._willDelete(...arguments);
+ }
+
+ //----------------------------------------------------------------------
+ // Public
+ //----------------------------------------------------------------------
+
+ /**
+ * Called when messaging is started.
+ */
+ start() {
+ // TODO FIXME Not using this.env.browser because it's proxified, and
+ // addEventListener does not work on proxified window. task-2234596
+ window.addEventListener('resize', this._onResize);
+ }
+
+ //----------------------------------------------------------------------
+ // Private
+ //----------------------------------------------------------------------
+
+ /**
+ * @private
+ */
+ _refresh() {
+ this.update({
+ globalWindowInnerHeight: this.env.browser.innerHeight,
+ globalWindowInnerWidth: this.env.browser.innerWidth,
+ isMobile: this.env.device.isMobile,
+ });
+ }
+ }
+
+ Device.fields = {
+ globalWindowInnerHeight: attr(),
+ globalWindowInnerWidth: attr(),
+ isMobile: attr(),
+ };
+
+ Device.modelName = 'mail.device';
+
+ return Device;
+}
+
+registerNewModel('mail.device', factory);
+
+});
diff --git a/addons/mail/static/src/models/dialog/dialog.js b/addons/mail/static/src/models/dialog/dialog.js
new file mode 100644
index 00000000..018951fe
--- /dev/null
+++ b/addons/mail/static/src/models/dialog/dialog.js
@@ -0,0 +1,32 @@
+odoo.define('mail/static/src/models/dialog/dialog.js', function (require) {
+'use strict';
+
+const { registerNewModel } = require('mail/static/src/model/model_core.js');
+const { many2one, one2one } = require('mail/static/src/model/model_field.js');
+
+function factory(dependencies) {
+
+ class Dialog extends dependencies['mail.model'] {}
+
+ Dialog.fields = {
+ manager: many2one('mail.dialog_manager', {
+ inverse: 'dialogs',
+ }),
+ /**
+ * Content of dialog that is directly linked to a record that models
+ * a UI component, such as AttachmentViewer. These records must be
+ * created from @see `mail.dialog_manager:open()`.
+ */
+ record: one2one('mail.model', {
+ isCausal: true,
+ }),
+ };
+
+ Dialog.modelName = 'mail.dialog';
+
+ return Dialog;
+}
+
+registerNewModel('mail.dialog', factory);
+
+});
diff --git a/addons/mail/static/src/models/dialog_manager/dialog_manager.js b/addons/mail/static/src/models/dialog_manager/dialog_manager.js
new file mode 100644
index 00000000..4d86e340
--- /dev/null
+++ b/addons/mail/static/src/models/dialog_manager/dialog_manager.js
@@ -0,0 +1,52 @@
+odoo.define('mail/static/src/models/dialog_manager/dialog_manager.js', function (require) {
+'use strict';
+
+const { registerNewModel } = require('mail/static/src/model/model_core.js');
+const { one2many } = require('mail/static/src/model/model_field.js');
+
+function factory(dependencies) {
+
+ class DialogManager extends dependencies['mail.model'] {
+
+ //----------------------------------------------------------------------
+ // Public
+ //----------------------------------------------------------------------
+
+ /**
+ * @param {string} modelName
+ * @param {Object} [recordData]
+ */
+ open(modelName, recordData) {
+ if (!modelName) {
+ throw new Error("Dialog should have a link to a model");
+ }
+ const Model = this.env.models[modelName];
+ if (!Model) {
+ throw new Error(`No model exists with name ${modelName}`);
+ }
+ const record = Model.create(recordData);
+ const dialog = this.env.models['mail.dialog'].create({
+ manager: [['link', this]],
+ record: [['link', record]],
+ });
+ return dialog;
+ }
+
+ }
+
+ DialogManager.fields = {
+ // FIXME: dependent on implementation that uses insert order in relations!!
+ dialogs: one2many('mail.dialog', {
+ inverse: 'manager',
+ isCausal: true,
+ }),
+ };
+
+ DialogManager.modelName = 'mail.dialog_manager';
+
+ return DialogManager;
+}
+
+registerNewModel('mail.dialog_manager', factory);
+
+});
diff --git a/addons/mail/static/src/models/discuss/discuss.js b/addons/mail/static/src/models/discuss/discuss.js
new file mode 100644
index 00000000..513b77fd
--- /dev/null
+++ b/addons/mail/static/src/models/discuss/discuss.js
@@ -0,0 +1,568 @@
+odoo.define('mail/static/src/models/discuss.discuss.js', function (require) {
+'use strict';
+
+const { registerNewModel } = require('mail/static/src/model/model_core.js');
+const { attr, many2one, one2many, one2one } = require('mail/static/src/model/model_field.js');
+const { clear } = require('mail/static/src/model/model_field_command.js');
+
+function factory(dependencies) {
+
+ class Discuss extends dependencies['mail.model'] {
+
+ //----------------------------------------------------------------------
+ // Public
+ //----------------------------------------------------------------------
+
+ /**
+ * @param {mail.thread} thread
+ */
+ cancelThreadRenaming(thread) {
+ this.update({ renamingThreads: [['unlink', thread]] });
+ }
+
+ clearIsAddingItem() {
+ this.update({
+ addingChannelValue: "",
+ isAddingChannel: false,
+ isAddingChat: false,
+ });
+ }
+
+ clearReplyingToMessage() {
+ this.update({ replyingToMessage: [['unlink-all']] });
+ }
+
+ /**
+ * Close the discuss app. Should reset its internal state.
+ */
+ close() {
+ this.update({ isOpen: false });
+ }
+
+ focus() {
+ this.update({ isDoFocus: true });
+ }
+
+ /**
+ * @param {Event} ev
+ * @param {Object} ui
+ * @param {Object} ui.item
+ * @param {integer} ui.item.id
+ */
+ async handleAddChannelAutocompleteSelect(ev, ui) {
+ const name = this.addingChannelValue;
+ this.clearIsAddingItem();
+ if (ui.item.special) {
+ const channel = await this.async(() =>
+ this.env.models['mail.thread'].performRpcCreateChannel({
+ name,
+ privacy: ui.item.special,
+ })
+ );
+ channel.open();
+ } else {
+ const channel = await this.async(() =>
+ this.env.models['mail.thread'].performRpcJoinChannel({
+ channelId: ui.item.id,
+ })
+ );
+ channel.open();
+ }
+ }
+
+ /**
+ * @param {Object} req
+ * @param {string} req.term
+ * @param {function} res
+ */
+ async handleAddChannelAutocompleteSource(req, res) {
+ const value = req.term;
+ const escapedValue = owl.utils.escape(value);
+ this.update({ addingChannelValue: value });
+ const domain = [
+ ['channel_type', '=', 'channel'],
+ ['name', 'ilike', value],
+ ];
+ const fields = ['channel_type', 'name', 'public', 'uuid'];
+ const result = await this.async(() => this.env.services.rpc({
+ model: "mail.channel",
+ method: "search_read",
+ kwargs: {
+ domain,
+ fields,
+ },
+ }));
+ const items = result.map(data => {
+ let escapedName = owl.utils.escape(data.name);
+ return Object.assign(data, {
+ label: escapedName,
+ value: escapedName
+ });
+ });
+ // XDU FIXME could use a component but be careful with owl's
+ // renderToString https://github.com/odoo/owl/issues/708
+ items.push({
+ label: _.str.sprintf(
+ `<strong>${this.env._t('Create %s')}</strong>`,
+ `<em><span class="fa fa-hashtag"/>${escapedValue}</em>`,
+ ),
+ escapedValue,
+ special: 'public'
+ }, {
+ label: _.str.sprintf(
+ `<strong>${this.env._t('Create %s')}</strong>`,
+ `<em><span class="fa fa-lock"/>${escapedValue}</em>`,
+ ),
+ escapedValue,
+ special: 'private'
+ });
+ res(items);
+ }
+
+ /**
+ * @param {Event} ev
+ * @param {Object} ui
+ * @param {Object} ui.item
+ * @param {integer} ui.item.id
+ */
+ handleAddChatAutocompleteSelect(ev, ui) {
+ this.env.messaging.openChat({ partnerId: ui.item.id });
+ this.clearIsAddingItem();
+ }
+
+ /**
+ * @param {Object} req
+ * @param {string} req.term
+ * @param {function} res
+ */
+ handleAddChatAutocompleteSource(req, res) {
+ const value = owl.utils.escape(req.term);
+ this.env.models['mail.partner'].imSearch({
+ callback: partners => {
+ const suggestions = partners.map(partner => {
+ return {
+ id: partner.id,
+ value: partner.nameOrDisplayName,
+ label: partner.nameOrDisplayName,
+ };
+ });
+ res(_.sortBy(suggestions, 'label'));
+ },
+ keyword: value,
+ limit: 10,
+ });
+ }
+
+ /**
+ * Open thread from init active id. `initActiveId` is used to refer to
+ * a thread that we may not have full data yet, such as when messaging
+ * is not yet initialized.
+ */
+ openInitThread() {
+ const [model, id] = typeof this.initActiveId === 'number'
+ ? ['mail.channel', this.initActiveId]
+ : this.initActiveId.split('_');
+ const thread = this.env.models['mail.thread'].findFromIdentifyingData({
+ id: model !== 'mail.box' ? Number(id) : id,
+ model,
+ });
+ if (!thread) {
+ return;
+ }
+ thread.open();
+ if (this.env.messaging.device.isMobile && thread.channel_type) {
+ this.update({ activeMobileNavbarTabId: thread.channel_type });
+ }
+ }
+
+
+ /**
+ * Opens the given thread in Discuss, and opens Discuss if necessary.
+ *
+ * @param {mail.thread} thread
+ */
+ async openThread(thread) {
+ this.update({
+ thread: [['link', thread]],
+ });
+ this.focus();
+ if (!this.isOpen) {
+ this.env.bus.trigger('do-action', {
+ action: 'mail.action_discuss',
+ options: {
+ active_id: this.threadToActiveId(this),
+ clear_breadcrumbs: false,
+ on_reverse_breadcrumb: () => this.close(),
+ },
+ });
+ }
+ }
+
+ /**
+ * @param {mail.thread} thread
+ * @param {string} newName
+ */
+ async renameThread(thread, newName) {
+ await this.async(() => thread.rename(newName));
+ this.update({ renamingThreads: [['unlink', thread]] });
+ }
+
+ /**
+ * Action to initiate reply to given message in Inbox. Assumes that
+ * Discuss and Inbox are already opened.
+ *
+ * @param {mail.message} message
+ */
+ replyToMessage(message) {
+ this.update({ replyingToMessage: [['link', message]] });
+ // avoid to reply to a note by a message and vice-versa.
+ // subject to change later by allowing subtype choice.
+ this.replyingToMessageOriginThreadComposer.update({
+ isLog: !message.is_discussion && !message.is_notification
+ });
+ this.focus();
+ }
+
+ /**
+ * @param {mail.thread} thread
+ */
+ setThreadRenaming(thread) {
+ this.update({ renamingThreads: [['link', thread]] });
+ }
+
+ /**
+ * @param {mail.thread} thread
+ * @returns {string}
+ */
+ threadToActiveId(thread) {
+ return `${thread.model}_${thread.id}`;
+ }
+
+ //----------------------------------------------------------------------
+ // Private
+ //----------------------------------------------------------------------
+
+ /**
+ * @private
+ * @returns {string|undefined}
+ */
+ _computeActiveId() {
+ if (!this.thread) {
+ return clear();
+ }
+ return this.threadToActiveId(this.thread);
+ }
+
+ /**
+ * @private
+ * @returns {string}
+ */
+ _computeAddingChannelValue() {
+ if (!this.isOpen) {
+ return "";
+ }
+ return this.addingChannelValue;
+ }
+
+ /**
+ * @private
+ * @returns {boolean}
+ */
+ _computeHasThreadView() {
+ if (!this.thread || !this.isOpen) {
+ return false;
+ }
+ if (
+ this.env.messaging.device.isMobile &&
+ (
+ this.activeMobileNavbarTabId !== 'mailbox' ||
+ this.thread.model !== 'mail.box'
+ )
+ ) {
+ return false;
+ }
+ return true;
+ }
+
+ /**
+ * @private
+ * @returns {boolean}
+ */
+ _computeIsAddingChannel() {
+ if (!this.isOpen) {
+ return false;
+ }
+ return this.isAddingChannel;
+ }
+
+ /**
+ * @private
+ * @returns {boolean}
+ */
+ _computeIsAddingChat() {
+ if (!this.isOpen) {
+ return false;
+ }
+ return this.isAddingChat;
+ }
+
+ /**
+ * @private
+ * @returns {boolean}
+ */
+ _computeIsReplyingToMessage() {
+ return !!this.replyingToMessage;
+ }
+
+ /**
+ * Ensures the reply feature is disabled if discuss is not open.
+ *
+ * @private
+ * @returns {mail.message|undefined}
+ */
+ _computeReplyingToMessage() {
+ if (!this.isOpen) {
+ return [['unlink-all']];
+ }
+ return [];
+ }
+
+
+ /**
+ * Only pinned threads are allowed in discuss.
+ *
+ * @private
+ * @returns {mail.thread|undefined}
+ */
+ _computeThread() {
+ let thread = this.thread;
+ if (this.env.messaging &&
+ this.env.messaging.inbox &&
+ this.env.messaging.device.isMobile &&
+ this.activeMobileNavbarTabId === 'mailbox' &&
+ this.initActiveId !== 'mail.box_inbox' &&
+ !thread
+ ) {
+ // After loading Discuss from an arbitrary tab other then 'mailbox',
+ // switching to 'mailbox' requires to also set its inner-tab ;
+ // by default the 'inbox'.
+ return [['replace', this.env.messaging.inbox]];
+ }
+ if (!thread || !thread.isPinned) {
+ return [['unlink']];
+ }
+ return [];
+ }
+
+ }
+
+ Discuss.fields = {
+ activeId: attr({
+ compute: '_computeActiveId',
+ dependencies: [
+ 'thread',
+ 'threadId',
+ 'threadModel',
+ ],
+ }),
+ /**
+ * Active mobile navbar tab, either 'mailbox', 'chat', or 'channel'.
+ */
+ activeMobileNavbarTabId: attr({
+ default: 'mailbox',
+ }),
+ /**
+ * Value that is used to create a channel from the sidebar.
+ */
+ addingChannelValue: attr({
+ compute: '_computeAddingChannelValue',
+ default: "",
+ dependencies: ['isOpen'],
+ }),
+ /**
+ * Serves as compute dependency.
+ */
+ device: one2one('mail.device', {
+ related: 'messaging.device',
+ }),
+ /**
+ * Serves as compute dependency.
+ */
+ deviceIsMobile: attr({
+ related: 'device.isMobile',
+ }),
+ /**
+ * Determine if the moderation discard dialog is displayed.
+ */
+ hasModerationDiscardDialog: attr({
+ default: false,
+ }),
+ /**
+ * Determine if the moderation reject dialog is displayed.
+ */
+ hasModerationRejectDialog: attr({
+ default: false,
+ }),
+ /**
+ * Determines whether `this.thread` should be displayed.
+ */
+ hasThreadView: attr({
+ compute: '_computeHasThreadView',
+ dependencies: [
+ 'activeMobileNavbarTabId',
+ 'deviceIsMobile',
+ 'isOpen',
+ 'thread',
+ 'threadModel',
+ ],
+ }),
+ /**
+ * Formatted init thread on opening discuss for the first time,
+ * when no active thread is defined. Useful to set a thread to
+ * open without knowing its local id in advance.
+ * Support two formats:
+ * {string} <threadModel>_<threadId>
+ * {int} <channelId> with default model of 'mail.channel'
+ */
+ initActiveId: attr({
+ default: 'mail.box_inbox',
+ }),
+ /**
+ * Determine whether current user is currently adding a channel from
+ * the sidebar.
+ */
+ isAddingChannel: attr({
+ compute: '_computeIsAddingChannel',
+ default: false,
+ dependencies: ['isOpen'],
+ }),
+ /**
+ * Determine whether current user is currently adding a chat from
+ * the sidebar.
+ */
+ isAddingChat: attr({
+ compute: '_computeIsAddingChat',
+ default: false,
+ dependencies: ['isOpen'],
+ }),
+ /**
+ * Determine whether this discuss should be focused at next render.
+ */
+ isDoFocus: attr({
+ default: false,
+ }),
+ /**
+ * Whether the discuss app is open or not. Useful to determine
+ * whether the discuss or chat window logic should be applied.
+ */
+ isOpen: attr({
+ default: false,
+ }),
+ isReplyingToMessage: attr({
+ compute: '_computeIsReplyingToMessage',
+ default: false,
+ dependencies: ['replyingToMessage'],
+ }),
+ isThreadPinned: attr({
+ related: 'thread.isPinned',
+ }),
+ /**
+ * The menu_id of discuss app, received on mail/init_messaging and
+ * used to open discuss from elsewhere.
+ */
+ menu_id: attr({
+ default: null,
+ }),
+ messaging: one2one('mail.messaging', {
+ inverse: 'discuss',
+ }),
+ messagingInbox: many2one('mail.thread', {
+ related: 'messaging.inbox',
+ }),
+ renamingThreads: one2many('mail.thread'),
+ /**
+ * The message that is currently selected as being replied to in Inbox.
+ * There is only one reply composer shown at a time, which depends on
+ * this selected message.
+ */
+ replyingToMessage: many2one('mail.message', {
+ compute: '_computeReplyingToMessage',
+ dependencies: [
+ 'isOpen',
+ 'replyingToMessage',
+ ],
+ }),
+ /**
+ * The thread concerned by the reply feature in Inbox. It depends on the
+ * message set to be replied, and should be considered read-only.
+ */
+ replyingToMessageOriginThread: many2one('mail.thread', {
+ related: 'replyingToMessage.originThread',
+ }),
+ /**
+ * The composer to display for the reply feature in Inbox. It depends
+ * on the message set to be replied, and should be considered read-only.
+ */
+ replyingToMessageOriginThreadComposer: one2one('mail.composer', {
+ inverse: 'discussAsReplying',
+ related: 'replyingToMessageOriginThread.composer',
+ }),
+ /**
+ * Quick search input value in the discuss sidebar (desktop). Useful
+ * to filter channels and chats based on this input content.
+ */
+ sidebarQuickSearchValue: attr({
+ default: "",
+ }),
+ /**
+ * Determines the domain to apply when fetching messages for `this.thread`.
+ * This value should only be written by the control panel.
+ */
+ stringifiedDomain: attr({
+ default: '[]',
+ }),
+ /**
+ * Determines the `mail.thread` that should be displayed by `this`.
+ */
+ thread: many2one('mail.thread', {
+ compute: '_computeThread',
+ dependencies: [
+ 'activeMobileNavbarTabId',
+ 'deviceIsMobile',
+ 'isThreadPinned',
+ 'messaging',
+ 'messagingInbox',
+ 'thread',
+ 'threadModel',
+ ],
+ }),
+ threadId: attr({
+ related: 'thread.id',
+ }),
+ threadModel: attr({
+ related: 'thread.model',
+ }),
+ /**
+ * States the `mail.thread_view` displaying `this.thread`.
+ */
+ threadView: one2one('mail.thread_view', {
+ related: 'threadViewer.threadView',
+ }),
+ /**
+ * Determines the `mail.thread_viewer` managing the display of `this.thread`.
+ */
+ threadViewer: one2one('mail.thread_viewer', {
+ default: [['create']],
+ inverse: 'discuss',
+ isCausal: true,
+ }),
+ };
+
+ Discuss.modelName = 'mail.discuss';
+
+ return Discuss;
+}
+
+registerNewModel('mail.discuss', factory);
+
+});
diff --git a/addons/mail/static/src/models/follower/follower.js b/addons/mail/static/src/models/follower/follower.js
new file mode 100644
index 00000000..493fe836
--- /dev/null
+++ b/addons/mail/static/src/models/follower/follower.js
@@ -0,0 +1,293 @@
+odoo.define('mail/static/src/models/follower.follower.js', function (require) {
+'use strict';
+
+const { registerNewModel } = require('mail/static/src/model/model_core.js');
+const { attr, many2many, many2one } = require('mail/static/src/model/model_field.js');
+
+function factory(dependencies) {
+
+ class Follower extends dependencies['mail.model'] {
+
+ //----------------------------------------------------------------------
+ // Public
+ //----------------------------------------------------------------------
+
+ /**
+ * @static
+ * @param {Object} data
+ * @returns {Object}
+ */
+ static convertData(data) {
+ const data2 = {};
+ if ('channel_id' in data) {
+ if (!data.channel_id) {
+ data2.channel = [['unlink-all']];
+ } else {
+ const channelData = {
+ id: data.channel_id,
+ model: 'mail.channel',
+ name: data.name,
+ };
+ data2.channel = [['insert', channelData]];
+ }
+ }
+ if ('id' in data) {
+ data2.id = data.id;
+ }
+ if ('is_active' in data) {
+ data2.isActive = data.is_active;
+ }
+ if ('is_editable' in data) {
+ data2.isEditable = data.is_editable;
+ }
+ if ('partner_id' in data) {
+ if (!data.partner_id) {
+ data2.partner = [['unlink-all']];
+ } else {
+ const partnerData = {
+ display_name: data.display_name,
+ email: data.email,
+ id: data.partner_id,
+ name: data.name,
+ };
+ data2.partner = [['insert', partnerData]];
+ }
+ }
+ return data2;
+ }
+
+ /**
+ * Close subtypes dialog
+ */
+ closeSubtypes() {
+ this._subtypesListDialog.delete();
+ this._subtypesListDialog = undefined;
+ }
+
+ /**
+ * Opens the most appropriate view that is a profile for this follower.
+ */
+ async openProfile() {
+ if (this.partner) {
+ return this.partner.openProfile();
+ }
+ return this.channel.openProfile();
+ }
+
+ /**
+ * Remove this follower from its related thread.
+ */
+ async remove() {
+ const partner_ids = [];
+ const channel_ids = [];
+ if (this.partner) {
+ partner_ids.push(this.partner.id);
+ } else {
+ channel_ids.push(this.channel.id);
+ }
+ await this.async(() => this.env.services.rpc({
+ model: this.followedThread.model,
+ method: 'message_unsubscribe',
+ args: [[this.followedThread.id], partner_ids, channel_ids]
+ }));
+ const followedThread = this.followedThread;
+ this.delete();
+ followedThread.fetchAndUpdateSuggestedRecipients();
+ }
+
+ /**
+ * @param {mail.follower_subtype} subtype
+ */
+ selectSubtype(subtype) {
+ if (!this.selectedSubtypes.includes(subtype)) {
+ this.update({ selectedSubtypes: [['link', subtype]] });
+ }
+ }
+
+ /**
+ * Show (editable) list of subtypes of this follower.
+ */
+ async showSubtypes() {
+ const subtypesData = await this.async(() => this.env.services.rpc({
+ route: '/mail/read_subscription_data',
+ params: { follower_id: this.id },
+ }));
+ this.update({ subtypes: [['unlink-all']] });
+ for (const data of subtypesData) {
+ const subtype = this.env.models['mail.follower_subtype'].insert(
+ this.env.models['mail.follower_subtype'].convertData(data)
+ );
+ this.update({ subtypes: [['link', subtype]] });
+ if (data.followed) {
+ this.update({ selectedSubtypes: [['link', subtype]] });
+ } else {
+ this.update({ selectedSubtypes: [['unlink', subtype]] });
+ }
+ }
+ this._subtypesListDialog = this.env.messaging.dialogManager.open('mail.follower_subtype_list', {
+ follower: [['link', this]],
+ });
+ }
+
+ /**
+ * @param {mail.follower_subtype} subtype
+ */
+ unselectSubtype(subtype) {
+ if (this.selectedSubtypes.includes(subtype)) {
+ this.update({ selectedSubtypes: [['unlink', subtype]] });
+ }
+ }
+
+ /**
+ * Update server-side subscription of subtypes of this follower.
+ */
+ async updateSubtypes() {
+ if (this.selectedSubtypes.length === 0) {
+ this.remove();
+ } else {
+ const kwargs = {
+ subtype_ids: this.selectedSubtypes.map(subtype => subtype.id),
+ };
+ if (this.partner) {
+ kwargs.partner_ids = [this.partner.id];
+ } else {
+ kwargs.channel_ids = [this.channel.id];
+ }
+ await this.async(() => this.env.services.rpc({
+ model: this.followedThread.model,
+ method: 'message_subscribe',
+ args: [[this.followedThread.id]],
+ kwargs,
+ }));
+ this.env.services['notification'].notify({
+ type: 'success',
+ message: this.env._t("The subscription preferences were successfully applied."),
+ });
+ }
+ this.closeSubtypes();
+ }
+
+ //----------------------------------------------------------------------
+ // Private
+ //----------------------------------------------------------------------
+
+ /**
+ * @override
+ */
+ static _createRecordLocalId(data) {
+ return `${this.modelName}_${data.id}`;
+ }
+
+ /**
+ * @private
+ * @returns {string}
+ */
+ _computeName() {
+ if (this.channel) {
+ return this.channel.name;
+ }
+ if (this.partner) {
+ return this.partner.name;
+ }
+ return '';
+ }
+
+ /**
+ * @private
+ * @returns {integer}
+ */
+ _computeResId() {
+ if (this.partner) {
+ return this.partner.id;
+ }
+ if (this.channel) {
+ return this.channel.id;
+ }
+ return 0;
+ }
+
+ /**
+ * @private
+ * @returns {string}
+ */
+ _computeResModel() {
+ if (this.partner) {
+ return this.partner.model;
+ }
+ if (this.channel) {
+ return this.channel.model;
+ }
+ return '';
+ }
+
+ }
+
+ Follower.fields = {
+ resId: attr({
+ compute: '_computeResId',
+ default: 0,
+ dependencies: [
+ 'channelId',
+ 'partnerId',
+ ],
+ }),
+ channel: many2one('mail.thread'),
+ channelId: attr({
+ related: 'channel.id',
+ }),
+ channelModel: attr({
+ related: 'channel.model',
+ }),
+ channelName: attr({
+ related: 'channel.name',
+ }),
+ displayName: attr({
+ related: 'partner.display_name'
+ }),
+ followedThread: many2one('mail.thread', {
+ inverse: 'followers',
+ }),
+ id: attr(),
+ isActive: attr({
+ default: true,
+ }),
+ isEditable: attr({
+ default: false,
+ }),
+ name: attr({
+ compute: '_computeName',
+ dependencies: [
+ 'channelName',
+ 'partnerName',
+ ],
+ }),
+ partner: many2one('mail.partner'),
+ partnerId: attr({
+ related: 'partner.id',
+ }),
+ partnerModel: attr({
+ related: 'partner.model',
+ }),
+ partnerName: attr({
+ related: 'partner.name',
+ }),
+ resModel: attr({
+ compute: '_computeResModel',
+ default: '',
+ dependencies: [
+ 'channelModel',
+ 'partnerModel',
+ ],
+ }),
+ selectedSubtypes: many2many('mail.follower_subtype'),
+ subtypes: many2many('mail.follower_subtype'),
+ };
+
+ Follower.modelName = 'mail.follower';
+
+ return Follower;
+}
+
+registerNewModel('mail.follower', factory);
+
+});
diff --git a/addons/mail/static/src/models/follower_subtype/follower_subtype.js b/addons/mail/static/src/models/follower_subtype/follower_subtype.js
new file mode 100644
index 00000000..68c16fce
--- /dev/null
+++ b/addons/mail/static/src/models/follower_subtype/follower_subtype.js
@@ -0,0 +1,82 @@
+odoo.define('mail/static/src/models/follower_subtype/follower_subtype.js', function (require) {
+'use strict';
+
+const { registerNewModel } = require('mail/static/src/model/model_core.js');
+const { attr } = require('mail/static/src/model/model_field.js');
+
+function factory(dependencies) {
+
+ class FollowerSubtype extends dependencies['mail.model'] {
+
+ //----------------------------------------------------------------------
+ // Public
+ //----------------------------------------------------------------------
+
+ /**
+ * @static
+ * @param {Object} data
+ * @returns {Object}
+ */
+ static convertData(data) {
+ const data2 = {};
+ if ('default' in data) {
+ data2.isDefault = data.default;
+ }
+ if ('id' in data) {
+ data2.id = data.id;
+ }
+ if ('internal' in data) {
+ data2.isInternal = data.internal;
+ }
+ if ('name' in data) {
+ data2.name = data.name;
+ }
+ if ('parent_model' in data) {
+ data2.parentModel = data.parent_model;
+ }
+ if ('res_model' in data) {
+ data2.resModel = data.res_model;
+ }
+ if ('sequence' in data) {
+ data2.sequence = data.sequence;
+ }
+ return data2;
+ }
+
+ //----------------------------------------------------------------------
+ // Private
+ //----------------------------------------------------------------------
+
+ /**
+ * @override
+ */
+ static _createRecordLocalId(data) {
+ return `${this.modelName}_${data.id}`;
+ }
+
+ }
+
+ FollowerSubtype.fields = {
+ id: attr(),
+ isDefault: attr({
+ default: false,
+ }),
+ isInternal: attr({
+ default: false,
+ }),
+ name: attr(),
+ // AKU FIXME: use relation instead
+ parentModel: attr(),
+ // AKU FIXME: use relation instead
+ resModel: attr(),
+ sequence: attr(),
+ };
+
+ FollowerSubtype.modelName = 'mail.follower_subtype';
+
+ return FollowerSubtype;
+}
+
+registerNewModel('mail.follower_subtype', factory);
+
+});
diff --git a/addons/mail/static/src/models/follower_subtype_list/follower_subtype_list.js b/addons/mail/static/src/models/follower_subtype_list/follower_subtype_list.js
new file mode 100644
index 00000000..9d67cedb
--- /dev/null
+++ b/addons/mail/static/src/models/follower_subtype_list/follower_subtype_list.js
@@ -0,0 +1,22 @@
+odoo.define('mail/static/src/models/follower_subtype_list/follower_subtype_list.js', function (require) {
+'use strict';
+
+const { registerNewModel } = require('mail/static/src/model/model_core.js');
+const { many2one } = require('mail/static/src/model/model_field.js');
+
+function factory(dependencies) {
+
+ class FollowerSubtypeList extends dependencies['mail.model'] {}
+
+ FollowerSubtypeList.fields = {
+ follower: many2one('mail.follower'),
+ };
+
+ FollowerSubtypeList.modelName = 'mail.follower_subtype_list';
+
+ return FollowerSubtypeList;
+}
+
+registerNewModel('mail.follower_subtype_list', factory);
+
+});
diff --git a/addons/mail/static/src/models/locale/locale.js b/addons/mail/static/src/models/locale/locale.js
new file mode 100644
index 00000000..50167f07
--- /dev/null
+++ b/addons/mail/static/src/models/locale/locale.js
@@ -0,0 +1,52 @@
+odoo.define('mail/static/src/models/locale/locale.js', function (require) {
+'use strict';
+
+const { registerNewModel } = require('mail/static/src/model/model_core.js');
+const { attr } = require('mail/static/src/model/model_field.js');
+
+function factory(dependencies) {
+
+ class Locale extends dependencies['mail.model'] {
+
+ //----------------------------------------------------------------------
+ // Private
+ //----------------------------------------------------------------------
+
+ /**
+ * @private
+ * @returns {string}
+ */
+ _computeLanguage() {
+ return this.env._t.database.parameters.code;
+ }
+
+ /**
+ * @private
+ * @returns {string}
+ */
+ _computeTextDirection() {
+ return this.env._t.database.parameters.direction;
+ }
+
+ }
+
+ Locale.fields = {
+ /**
+ * Language used by interface, formatted like {language ISO 2}_{country ISO 2} (eg: fr_FR).
+ */
+ language: attr({
+ compute: '_computeLanguage',
+ }),
+ textDirection: attr({
+ compute: '_computeTextDirection',
+ }),
+ };
+
+ Locale.modelName = 'mail.locale';
+
+ return Locale;
+}
+
+registerNewModel('mail.locale', factory);
+
+});
diff --git a/addons/mail/static/src/models/mail_template/mail_template.js b/addons/mail/static/src/models/mail_template/mail_template.js
new file mode 100644
index 00000000..3144c314
--- /dev/null
+++ b/addons/mail/static/src/models/mail_template/mail_template.js
@@ -0,0 +1,83 @@
+odoo.define('mail/static/src/models/mail_template/mail_template.js', function (require) {
+'use strict';
+
+const { registerNewModel } = require('mail/static/src/model/model_core.js');
+const { attr, many2many } = require('mail/static/src/model/model_field.js');
+
+function factory(dependencies) {
+
+ class MailTemplate extends dependencies['mail.model'] {
+
+ //----------------------------------------------------------------------
+ // Public
+ //----------------------------------------------------------------------
+
+ /**
+ * @param {mail.activity} activity
+ */
+ preview(activity) {
+ const action = {
+ name: this.env._t("Compose Email"),
+ type: 'ir.actions.act_window',
+ res_model: 'mail.compose.message',
+ views: [[false, 'form']],
+ target: 'new',
+ context: {
+ default_res_id: activity.thread.id,
+ default_model: activity.thread.model,
+ default_use_template: true,
+ default_template_id: this.id,
+ force_email: true,
+ },
+ };
+ this.env.bus.trigger('do-action', {
+ action,
+ options: {
+ on_close: () => {
+ activity.thread.refresh();
+ },
+ },
+ });
+ }
+
+ /**
+ * @param {mail.activity} activity
+ */
+ async send(activity) {
+ await this.async(() => this.env.services.rpc({
+ model: activity.thread.model,
+ method: 'activity_send_mail',
+ args: [[activity.thread.id], this.id],
+ }));
+ activity.thread.refresh();
+ }
+
+ //----------------------------------------------------------------------
+ // Private
+ //----------------------------------------------------------------------
+
+ /**
+ * @override
+ */
+ static _createRecordLocalId(data) {
+ return `${this.modelName}_${data.id}`;
+ }
+
+ }
+
+ MailTemplate.fields = {
+ activities: many2many('mail.activity', {
+ inverse: 'mailTemplates',
+ }),
+ id: attr(),
+ name: attr(),
+ };
+
+ MailTemplate.modelName = 'mail.mail_template';
+
+ return MailTemplate;
+}
+
+registerNewModel('mail.mail_template', factory);
+
+});
diff --git a/addons/mail/static/src/models/message/message.js b/addons/mail/static/src/models/message/message.js
new file mode 100644
index 00000000..f5c45bfa
--- /dev/null
+++ b/addons/mail/static/src/models/message/message.js
@@ -0,0 +1,817 @@
+odoo.define('mail/static/src/models/message/message.js', function (require) {
+'use strict';
+
+const emojis = require('mail.emojis');
+const { registerNewModel } = require('mail/static/src/model/model_core.js');
+const { attr, many2many, many2one, one2many } = require('mail/static/src/model/model_field.js');
+const { clear } = require('mail/static/src/model/model_field_command.js');
+const { addLink, htmlToTextContentInline, parseAndTransform, timeFromNow } = require('mail.utils');
+
+const { str_to_datetime } = require('web.time');
+
+function factory(dependencies) {
+
+ class Message extends dependencies['mail.model'] {
+
+ //----------------------------------------------------------------------
+ // Public
+ //----------------------------------------------------------------------
+
+ /**
+ * @static
+ * @param {mail.thread} thread
+ * @param {string} threadStringifiedDomain
+ */
+ static checkAll(thread, threadStringifiedDomain) {
+ const threadCache = thread.cache(threadStringifiedDomain);
+ threadCache.update({ checkedMessages: [['link', threadCache.messages]] });
+ }
+
+ /**
+ * @static
+ * @param {Object} data
+ * @return {Object}
+ */
+ static convertData(data) {
+ const data2 = {};
+ if ('attachment_ids' in data) {
+ if (!data.attachment_ids) {
+ data2.attachments = [['unlink-all']];
+ } else {
+ data2.attachments = [
+ ['insert-and-replace', data.attachment_ids.map(attachmentData =>
+ this.env.models['mail.attachment'].convertData(attachmentData)
+ )],
+ ];
+ }
+ }
+ if ('author_id' in data) {
+ if (!data.author_id) {
+ data2.author = [['unlink-all']];
+ } else if (data.author_id[0] !== 0) {
+ // partner id 0 is a hack of message_format to refer to an
+ // author non-related to a partner. display_name equals
+ // email_from, so this is omitted due to being redundant.
+ data2.author = [
+ ['insert', {
+ display_name: data.author_id[1],
+ id: data.author_id[0],
+ }],
+ ];
+ }
+ }
+ if ('body' in data) {
+ data2.body = data.body;
+ }
+ if ('channel_ids' in data && data.channel_ids) {
+ const channels = data.channel_ids
+ .map(channelId =>
+ this.env.models['mail.thread'].findFromIdentifyingData({
+ id: channelId,
+ model: 'mail.channel',
+ })
+ ).filter(channel => !!channel);
+ data2.serverChannels = [['replace', channels]];
+ }
+ if ('date' in data && data.date) {
+ data2.date = moment(str_to_datetime(data.date));
+ }
+ if ('email_from' in data) {
+ data2.email_from = data.email_from;
+ }
+ if ('history_partner_ids' in data) {
+ data2.isHistory = data.history_partner_ids.includes(this.env.messaging.currentPartner.id);
+ }
+ if ('id' in data) {
+ data2.id = data.id;
+ }
+ if ('is_discussion' in data) {
+ data2.is_discussion = data.is_discussion;
+ }
+ if ('is_note' in data) {
+ data2.is_note = data.is_note;
+ }
+ if ('is_notification' in data) {
+ data2.is_notification = data.is_notification;
+ }
+ if ('message_type' in data) {
+ data2.message_type = data.message_type;
+ }
+ if ('model' in data && 'res_id' in data && data.model && data.res_id) {
+ const originThreadData = {
+ id: data.res_id,
+ model: data.model,
+ };
+ if ('record_name' in data && data.record_name) {
+ originThreadData.name = data.record_name;
+ }
+ if ('res_model_name' in data && data.res_model_name) {
+ originThreadData.model_name = data.res_model_name;
+ }
+ if ('module_icon' in data) {
+ originThreadData.moduleIcon = data.module_icon;
+ }
+ data2.originThread = [['insert', originThreadData]];
+ }
+ if ('moderation_status' in data) {
+ data2.moderation_status = data.moderation_status;
+ }
+ if ('needaction_partner_ids' in data) {
+ data2.isNeedaction = data.needaction_partner_ids.includes(this.env.messaging.currentPartner.id);
+ }
+ if ('notifications' in data) {
+ data2.notifications = [['insert', data.notifications.map(notificationData =>
+ this.env.models['mail.notification'].convertData(notificationData)
+ )]];
+ }
+ if ('starred_partner_ids' in data) {
+ data2.isStarred = data.starred_partner_ids.includes(this.env.messaging.currentPartner.id);
+ }
+ if ('subject' in data) {
+ data2.subject = data.subject;
+ }
+ if ('subtype_description' in data) {
+ data2.subtype_description = data.subtype_description;
+ }
+ if ('subtype_id' in data) {
+ data2.subtype_id = data.subtype_id;
+ }
+ if ('tracking_value_ids' in data) {
+ data2.tracking_value_ids = data.tracking_value_ids;
+ }
+
+ return data2;
+ }
+
+ /**
+ * Mark all messages of current user with given domain as read.
+ *
+ * @static
+ * @param {Array[]} domain
+ */
+ static async markAllAsRead(domain) {
+ await this.env.services.rpc({
+ model: 'mail.message',
+ method: 'mark_all_as_read',
+ kwargs: { domain },
+ });
+ }
+
+ /**
+ * Mark provided messages as read. Messages that have been marked as
+ * read are acknowledged by server with response as longpolling
+ * notification of following format:
+ *
+ * [[dbname, 'res.partner', partnerId], { type: 'mark_as_read' }]
+ *
+ * @see mail.messaging_notification_handler:_handleNotificationPartnerMarkAsRead()
+ *
+ * @static
+ * @param {mail.message[]} messages
+ */
+ static async markAsRead(messages) {
+ await this.env.services.rpc({
+ model: 'mail.message',
+ method: 'set_message_done',
+ args: [messages.map(message => message.id)]
+ });
+ }
+
+ /**
+ * Applies the moderation `decision` on the provided messages.
+ *
+ * @static
+ * @param {mail.message[]} messages
+ * @param {string} decision: 'accept', 'allow', ban', 'discard', or 'reject'
+ * @param {Object|undefined} [kwargs] optional data to pass on
+ * message moderation. This is provided when rejecting the messages
+ * for which title and comment give reason(s) for reject.
+ * @param {string} [kwargs.title]
+ * @param {string} [kwargs.comment]
+ */
+ static async moderate(messages, decision, kwargs) {
+ const messageIds = messages.map(message => message.id);
+ await this.env.services.rpc({
+ model: 'mail.message',
+ method: 'moderate',
+ args: [messageIds, decision],
+ kwargs: kwargs,
+ });
+ }
+ /**
+ * Performs the `message_fetch` RPC on `mail.message`.
+ *
+ * @static
+ * @param {Array[]} domain
+ * @param {integer} [limit]
+ * @param {integer[]} [moderated_channel_ids]
+ * @param {Object} [context]
+ * @returns {mail.message[]}
+ */
+ static async performRpcMessageFetch(domain, limit, moderated_channel_ids, context) {
+ const messagesData = await this.env.services.rpc({
+ model: 'mail.message',
+ method: 'message_fetch',
+ kwargs: {
+ context,
+ domain,
+ limit,
+ moderated_channel_ids,
+ },
+ }, { shadow: true });
+ const messages = this.env.models['mail.message'].insert(messagesData.map(
+ messageData => this.env.models['mail.message'].convertData(messageData)
+ ));
+ // compute seen indicators (if applicable)
+ for (const message of messages) {
+ for (const thread of message.threads) {
+ if (thread.model !== 'mail.channel' || thread.channel_type === 'channel') {
+ // disabled on non-channel threads and
+ // on `channel` channels for performance reasons
+ continue;
+ }
+ this.env.models['mail.message_seen_indicator'].insert({
+ channelId: thread.id,
+ messageId: message.id,
+ });
+ }
+ }
+ return messages;
+ }
+
+ /**
+ * @static
+ * @param {mail.thread} thread
+ * @param {string} threadStringifiedDomain
+ */
+ static uncheckAll(thread, threadStringifiedDomain) {
+ const threadCache = thread.cache(threadStringifiedDomain);
+ threadCache.update({ checkedMessages: [['unlink', threadCache.messages]] });
+ }
+
+ /**
+ * Unstar all starred messages of current user.
+ */
+ static async unstarAll() {
+ await this.env.services.rpc({
+ model: 'mail.message',
+ method: 'unstar_all',
+ });
+ }
+
+ /**
+ * @param {mail.thread} thread
+ * @param {string} threadStringifiedDomain
+ * @returns {boolean}
+ */
+ isChecked(thread, threadStringifiedDomain) {
+ // aku todo
+ const relatedCheckedThreadCache = this.checkedThreadCaches.find(
+ threadCache => (
+ threadCache.thread === thread &&
+ threadCache.stringifiedDomain === threadStringifiedDomain
+ )
+ );
+ return !!relatedCheckedThreadCache;
+ }
+
+ /**
+ * Mark this message as read, so that it no longer appears in current
+ * partner Inbox.
+ */
+ async markAsRead() {
+ await this.async(() => this.env.services.rpc({
+ model: 'mail.message',
+ method: 'set_message_done',
+ args: [[this.id]]
+ }));
+ }
+
+ /**
+ * Applies the moderation `decision` on this message.
+ *
+ * @param {string} decision: 'accept', 'allow', ban', 'discard', or 'reject'
+ * @param {Object|undefined} [kwargs] optional data to pass on
+ * message moderation. This is provided when rejecting the messages
+ * for which title and comment give reason(s) for reject.
+ * @param {string} [kwargs.title]
+ * @param {string} [kwargs.comment]
+ */
+ async moderate(decision, kwargs) {
+ await this.async(() => this.constructor.moderate([this], decision, kwargs));
+ }
+
+ /**
+ * Opens the view that allows to resend the message in case of failure.
+ */
+ openResendAction() {
+ this.env.bus.trigger('do-action', {
+ action: 'mail.mail_resend_message_action',
+ options: {
+ additional_context: {
+ mail_message_to_resend: this.id,
+ },
+ },
+ });
+ }
+
+ /**
+ * Refreshes the value of `dateFromNow` field to the "current now".
+ */
+ refreshDateFromNow() {
+ this.update({ dateFromNow: this._computeDateFromNow() });
+ }
+
+ /**
+ * Action to initiate reply to current message in Discuss Inbox. Assumes
+ * that Discuss and Inbox are already opened.
+ */
+ replyTo() {
+ this.env.messaging.discuss.replyToMessage(this);
+ }
+
+ /**
+ * Toggle check state of this message in the context of the provided
+ * thread and its stringifiedDomain.
+ *
+ * @param {mail.thread} thread
+ * @param {string} threadStringifiedDomain
+ */
+ toggleCheck(thread, threadStringifiedDomain) {
+ const threadCache = thread.cache(threadStringifiedDomain);
+ if (threadCache.checkedMessages.includes(this)) {
+ threadCache.update({ checkedMessages: [['unlink', this]] });
+ } else {
+ threadCache.update({ checkedMessages: [['link', this]] });
+ }
+ }
+
+ /**
+ * Toggle the starred status of the provided message.
+ */
+ async toggleStar() {
+ await this.async(() => this.env.services.rpc({
+ model: 'mail.message',
+ method: 'toggle_message_starred',
+ args: [[this.id]]
+ }));
+ }
+
+ //----------------------------------------------------------------------
+ // Private
+ //----------------------------------------------------------------------
+
+ /**
+ * @override
+ */
+ static _createRecordLocalId(data) {
+ return `${this.modelName}_${data.id}`;
+ }
+
+ /**
+ * @returns {string}
+ */
+ _computeDateFromNow() {
+ if (!this.date) {
+ return clear();
+ }
+ return timeFromNow(this.date);
+ }
+
+ /**
+ * @returns {boolean}
+ */
+ _computeFailureNotifications() {
+ return [['replace', this.notifications.filter(notifications =>
+ ['exception', 'bounce'].includes(notifications.notification_status)
+ )]];
+ }
+
+ /**
+ * @private
+ * @returns {boolean}
+ */
+ _computeHasCheckbox() {
+ return this.isModeratedByCurrentPartner;
+ }
+
+ /**
+ * @private
+ * @returns {boolean}
+ */
+ _computeIsCurrentPartnerAuthor() {
+ return !!(
+ this.author &&
+ this.messagingCurrentPartner &&
+ this.messagingCurrentPartner === this.author
+ );
+ }
+
+ /**
+ * @private
+ * @returns {boolean}
+ */
+ _computeIsBodyEqualSubtypeDescription() {
+ if (!this.body || !this.subtype_description) {
+ return false;
+ }
+ const inlineBody = htmlToTextContentInline(this.body);
+ return inlineBody.toLowerCase() === this.subtype_description.toLowerCase();
+ }
+
+ /**
+ * The method does not attempt to cover all possible cases of empty
+ * messages, but mostly those that happen with a standard flow. Indeed
+ * it is preferable to be defensive and show an empty message sometimes
+ * instead of hiding a non-empty message.
+ *
+ * The main use case for when a message should become empty is for a
+ * message posted with only an attachment (no body) and then the
+ * attachment is deleted.
+ *
+ * The main use case for being defensive with the check is when
+ * receiving a message that has no textual content but has other
+ * meaningful HTML tags (eg. just an <img/>).
+ *
+ * @private
+ * @returns {boolean}
+ */
+ _computeIsEmpty() {
+ const isBodyEmpty = (
+ !this.body ||
+ [
+ '',
+ '<p></p>',
+ '<p><br></p>',
+ '<p><br/></p>',
+ ].includes(this.body.replace(/\s/g, ''))
+ );
+ return (
+ isBodyEmpty &&
+ this.attachments.length === 0 &&
+ this.tracking_value_ids.length === 0 &&
+ !this.subtype_description
+ );
+ }
+
+ /**
+ * @private
+ * @returns {boolean}
+ */
+ _computeIsModeratedByCurrentPartner() {
+ return (
+ this.moderation_status === 'pending_moderation' &&
+ this.originThread &&
+ this.originThread.isModeratedByCurrentPartner
+ );
+ }
+ /**
+ * @private
+ * @returns {boolean}
+ */
+ _computeIsSubjectSimilarToOriginThreadName() {
+ if (
+ !this.subject ||
+ !this.originThread ||
+ !this.originThread.name
+ ) {
+ return false;
+ }
+ const threadName = this.originThread.name.toLowerCase().trim();
+ const prefixList = ['re:', 'fw:', 'fwd:'];
+ let cleanedSubject = this.subject.toLowerCase();
+ let wasSubjectCleaned = true;
+ while (wasSubjectCleaned) {
+ wasSubjectCleaned = false;
+ if (threadName === cleanedSubject) {
+ return true;
+ }
+ for (const prefix of prefixList) {
+ if (cleanedSubject.startsWith(prefix)) {
+ cleanedSubject = cleanedSubject.replace(prefix, '').trim();
+ wasSubjectCleaned = true;
+ break;
+ }
+ }
+ }
+ return false;
+ }
+
+ /**
+ * @private
+ * @returns {mail.messaging}
+ */
+ _computeMessaging() {
+ return [['link', this.env.messaging]];
+ }
+
+ /**
+ * This value is meant to be based on field body which is
+ * returned by the server (and has been sanitized before stored into db).
+ * Do not use this value in a 't-raw' if the message has been created
+ * directly from user input and not from server data as it's not escaped.
+ *
+ * @private
+ * @returns {string}
+ */
+ _computePrettyBody() {
+ let prettyBody;
+ for (const emoji of emojis) {
+ const { unicode } = emoji;
+ const regexp = new RegExp(
+ `(?:^|\\s|<[a-z]*>)(${unicode})(?=\\s|$|</[a-z]*>)`,
+ "g"
+ );
+ const originalBody = this.body;
+ prettyBody = this.body.replace(
+ regexp,
+ ` <span class="o_mail_emoji">${unicode}</span> `
+ );
+ // Idiot-proof limit. If the user had the amazing idea of
+ // copy-pasting thousands of emojis, the image rendering can lead
+ // to memory overflow errors on some browsers (e.g. Chrome). Set an
+ // arbitrary limit to 200 from which we simply don't replace them
+ // (anyway, they are already replaced by the unicode counterpart).
+ if (_.str.count(prettyBody, "o_mail_emoji") > 200) {
+ prettyBody = originalBody;
+ }
+ }
+ // add anchor tags to urls
+ return parseAndTransform(prettyBody, addLink);
+ }
+
+ /**
+ * @private
+ * @returns {mail.thread[]}
+ */
+ _computeThreads() {
+ const threads = [...this.serverChannels];
+ if (this.isHistory) {
+ threads.push(this.env.messaging.history);
+ }
+ if (this.isNeedaction) {
+ threads.push(this.env.messaging.inbox);
+ }
+ if (this.isStarred) {
+ threads.push(this.env.messaging.starred);
+ }
+ if (this.env.messaging.moderation && this.isModeratedByCurrentPartner) {
+ threads.push(this.env.messaging.moderation);
+ }
+ if (this.originThread) {
+ threads.push(this.originThread);
+ }
+ return [['replace', threads]];
+ }
+
+ }
+
+ Message.fields = {
+ attachments: many2many('mail.attachment', {
+ inverse: 'messages',
+ }),
+ author: many2one('mail.partner', {
+ inverse: 'messagesAsAuthor',
+ }),
+ /**
+ * This value is meant to be returned by the server
+ * (and has been sanitized before stored into db).
+ * Do not use this value in a 't-raw' if the message has been created
+ * directly from user input and not from server data as it's not escaped.
+ */
+ body: attr({
+ default: "",
+ }),
+ checkedThreadCaches: many2many('mail.thread_cache', {
+ inverse: 'checkedMessages',
+ }),
+ date: attr({
+ default: moment(),
+ }),
+ /**
+ * States the time elapsed since date up to now.
+ */
+ dateFromNow: attr({
+ compute: '_computeDateFromNow',
+ dependencies: [
+ 'date',
+ ],
+ }),
+ email_from: attr(),
+ failureNotifications: one2many('mail.notification', {
+ compute: '_computeFailureNotifications',
+ dependencies: ['notificationsStatus'],
+ }),
+ hasCheckbox: attr({
+ compute: '_computeHasCheckbox',
+ default: false,
+ dependencies: ['isModeratedByCurrentPartner'],
+ }),
+ id: attr(),
+ isCurrentPartnerAuthor: attr({
+ compute: '_computeIsCurrentPartnerAuthor',
+ default: false,
+ dependencies: [
+ 'author',
+ 'messagingCurrentPartner',
+ ],
+ }),
+ /**
+ * States whether `body` and `subtype_description` contain similar
+ * values.
+ *
+ * This is necessary to avoid displaying both of them together when they
+ * contain duplicate information. This will especially happen with
+ * messages that are posted automatically at the creation of a record
+ * (messages that serve as tracking messages). They do have hard-coded
+ * "record created" body while being assigned a subtype with a
+ * description that states the same information.
+ *
+ * Fixing newer messages is possible by not assigning them a duplicate
+ * body content, but the check here is still necessary to handle
+ * existing messages.
+ *
+ * Limitations:
+ * - A translated subtype description might not match a non-translatable
+ * body created by a user with a different language.
+ * - Their content might be mostly but not exactly the same.
+ */
+ isBodyEqualSubtypeDescription: attr({
+ compute: '_computeIsBodyEqualSubtypeDescription',
+ default: false,
+ dependencies: [
+ 'body',
+ 'subtype_description',
+ ],
+ }),
+ /**
+ * Determine whether the message has to be considered empty or not.
+ *
+ * An empty message has no text, no attachment and no tracking value.
+ */
+ isEmpty: attr({
+ compute: '_computeIsEmpty',
+ dependencies: [
+ 'attachments',
+ 'body',
+ 'subtype_description',
+ 'tracking_value_ids',
+ ],
+ }),
+ isModeratedByCurrentPartner: attr({
+ compute: '_computeIsModeratedByCurrentPartner',
+ default: false,
+ dependencies: [
+ 'moderation_status',
+ 'originThread',
+ 'originThreadIsModeratedByCurrentPartner',
+ ],
+ }),
+ /**
+ * States whether `originThread.name` and `subject` contain similar
+ * values except it contains the extra prefix at the start
+ * of the subject.
+ *
+ * This is necessary to avoid displaying the subject, if
+ * the subject is same as threadname.
+ */
+ isSubjectSimilarToOriginThreadName: attr({
+ compute: '_computeIsSubjectSimilarToOriginThreadName',
+ dependencies: [
+ 'originThread',
+ 'originThreadName',
+ 'subject',
+ ],
+ }),
+ isTemporary: attr({
+ default: false,
+ }),
+ isTransient: attr({
+ default: false,
+ }),
+ is_discussion: attr({
+ default: false,
+ }),
+ /**
+ * Determine whether the message was a needaction. Useful to make it
+ * present in history mailbox.
+ */
+ isHistory: attr({
+ default: false,
+ }),
+ /**
+ * Determine whether the message is needaction. Useful to make it
+ * present in inbox mailbox and messaging menu.
+ */
+ isNeedaction: attr({
+ default: false,
+ }),
+ is_note: attr({
+ default: false,
+ }),
+ is_notification: attr({
+ default: false,
+ }),
+ /**
+ * Determine whether the message is starred. Useful to make it present
+ * in starred mailbox.
+ */
+ isStarred: attr({
+ default: false,
+ }),
+ message_type: attr(),
+ messaging: many2one('mail.messaging', {
+ compute: '_computeMessaging',
+ }),
+ messagingCurrentPartner: many2one('mail.partner', {
+ related: 'messaging.currentPartner',
+ }),
+ messagingHistory: many2one('mail.thread', {
+ related: 'messaging.history',
+ }),
+ messagingInbox: many2one('mail.thread', {
+ related: 'messaging.inbox',
+ }),
+ messagingModeration: many2one('mail.thread', {
+ related: 'messaging.moderation',
+ }),
+ messagingStarred: many2one('mail.thread', {
+ related: 'messaging.starred',
+ }),
+ moderation_status: attr(),
+ notifications: one2many('mail.notification', {
+ inverse: 'message',
+ isCausal: true,
+ }),
+ notificationsStatus: attr({
+ default: [],
+ related: 'notifications.notification_status',
+ }),
+ /**
+ * Origin thread of this message (if any).
+ */
+ originThread: many2one('mail.thread', {
+ inverse: 'messagesAsOriginThread',
+ }),
+ originThreadIsModeratedByCurrentPartner: attr({
+ default: false,
+ related: 'originThread.isModeratedByCurrentPartner',
+ }),
+ /**
+ * Serves as compute dependency for isSubjectSimilarToOriginThreadName
+ */
+ originThreadName: attr({
+ related: 'originThread.name',
+ }),
+ /**
+ * This value is meant to be based on field body which is
+ * returned by the server (and has been sanitized before stored into db).
+ * Do not use this value in a 't-raw' if the message has been created
+ * directly from user input and not from server data as it's not escaped.
+ */
+ prettyBody: attr({
+ compute: '_computePrettyBody',
+ dependencies: ['body'],
+ }),
+ subject: attr(),
+ subtype_description: attr(),
+ subtype_id: attr(),
+ /**
+ * All threads that this message is linked to. This field is read-only.
+ */
+ threads: many2many('mail.thread', {
+ compute: '_computeThreads',
+ dependencies: [
+ 'isHistory',
+ 'isModeratedByCurrentPartner',
+ 'isNeedaction',
+ 'isStarred',
+ 'messagingHistory',
+ 'messagingInbox',
+ 'messagingModeration',
+ 'messagingStarred',
+ 'originThread',
+ 'serverChannels',
+ ],
+ inverse: 'messages',
+ }),
+ tracking_value_ids: attr({
+ default: [],
+ }),
+ /**
+ * All channels containing this message on the server.
+ * Equivalent of python field `channel_ids`.
+ */
+ serverChannels: many2many('mail.thread', {
+ inverse: 'messagesAsServerChannel',
+ }),
+ };
+
+ Message.modelName = 'mail.message';
+
+ return Message;
+}
+
+registerNewModel('mail.message', factory);
+
+});
diff --git a/addons/mail/static/src/models/message/message_tests.js b/addons/mail/static/src/models/message/message_tests.js
new file mode 100644
index 00000000..054e8204
--- /dev/null
+++ b/addons/mail/static/src/models/message/message_tests.js
@@ -0,0 +1,187 @@
+odoo.define('mail/static/src/models/message/message_tests.js', function (require) {
+'use strict';
+
+const { afterEach, beforeEach, start } = require('mail/static/src/utils/test_utils.js');
+
+const { str_to_datetime } = require('web.time');
+
+QUnit.module('mail', {}, function () {
+QUnit.module('models', {}, function () {
+QUnit.module('message', {}, function () {
+QUnit.module('message_tests.js', {
+ beforeEach() {
+ beforeEach(this);
+
+ this.start = async params => {
+ const { env, widget } = await start(Object.assign({}, params, {
+ data: this.data,
+ }));
+ this.env = env;
+ this.widget = widget;
+ };
+ },
+ afterEach() {
+ afterEach(this);
+ },
+});
+
+QUnit.test('create', async function (assert) {
+ assert.expect(31);
+
+ await this.start();
+ assert.notOk(this.env.models['mail.partner'].findFromIdentifyingData({ id: 5 }));
+ assert.notOk(this.env.models['mail.thread'].findFromIdentifyingData({
+ id: 100,
+ model: 'mail.channel',
+ }));
+ assert.notOk(this.env.models['mail.attachment'].findFromIdentifyingData({ id: 750 }));
+ assert.notOk(this.env.models['mail.message'].findFromIdentifyingData({ id: 4000 }));
+
+ const thread = this.env.models['mail.thread'].create({
+ id: 100,
+ model: 'mail.channel',
+ name: "General",
+ });
+ const message = this.env.models['mail.message'].create({
+ attachments: [['insert-and-replace', {
+ filename: "test.txt",
+ id: 750,
+ mimetype: 'text/plain',
+ name: "test.txt",
+ }]],
+ author: [['insert', { id: 5, display_name: "Demo" }]],
+ body: "<p>Test</p>",
+ date: moment(str_to_datetime("2019-05-05 10:00:00")),
+ id: 4000,
+ isNeedaction: true,
+ isStarred: true,
+ originThread: [['link', thread]],
+ });
+
+ assert.ok(this.env.models['mail.partner'].findFromIdentifyingData({ id: 5 }));
+ assert.ok(this.env.models['mail.thread'].findFromIdentifyingData({
+ id: 100,
+ model: 'mail.channel',
+ }));
+ assert.ok(this.env.models['mail.attachment'].findFromIdentifyingData({ id: 750 }));
+ assert.ok(this.env.models['mail.message'].findFromIdentifyingData({ id: 4000 }));
+
+ assert.ok(message);
+ assert.strictEqual(this.env.models['mail.message'].findFromIdentifyingData({ id: 4000 }), message);
+ assert.strictEqual(message.body, "<p>Test</p>");
+ assert.ok(message.date instanceof moment);
+ assert.strictEqual(
+ moment(message.date).utc().format('YYYY-MM-DD hh:mm:ss'),
+ "2019-05-05 10:00:00"
+ );
+ assert.strictEqual(message.id, 4000);
+ assert.strictEqual(message.originThread, this.env.models['mail.thread'].findFromIdentifyingData({
+ id: 100,
+ model: 'mail.channel',
+ }));
+ assert.ok(
+ message.threads.includes(this.env.models['mail.thread'].findFromIdentifyingData({
+ id: 100,
+ model: 'mail.channel',
+ }))
+ );
+ // from partnerId being in needaction_partner_ids
+ assert.ok(message.threads.includes(this.env.messaging.inbox));
+ // from partnerId being in starred_partner_ids
+ assert.ok(message.threads.includes(this.env.messaging.starred));
+ const attachment = this.env.models['mail.attachment'].findFromIdentifyingData({ id: 750 });
+ assert.ok(attachment);
+ assert.strictEqual(attachment.filename, "test.txt");
+ assert.strictEqual(attachment.id, 750);
+ assert.notOk(attachment.isTemporary);
+ assert.strictEqual(attachment.mimetype, 'text/plain');
+ assert.strictEqual(attachment.name, "test.txt");
+ const channel = this.env.models['mail.thread'].findFromIdentifyingData({
+ id: 100,
+ model: 'mail.channel',
+ });
+ assert.ok(channel);
+ assert.strictEqual(channel.model, 'mail.channel');
+ assert.strictEqual(channel.id, 100);
+ assert.strictEqual(channel.name, "General");
+ const partner = this.env.models['mail.partner'].findFromIdentifyingData({ id: 5 });
+ assert.ok(partner);
+ assert.strictEqual(partner.display_name, "Demo");
+ assert.strictEqual(partner.id, 5);
+});
+
+QUnit.test('message without body should be considered empty', async function (assert) {
+ assert.expect(1);
+ await this.start();
+ const message = this.env.models['mail.message'].create({ id: 11 });
+ assert.ok(message.isEmpty);
+});
+
+QUnit.test('message with body "" should be considered empty', async function (assert) {
+ assert.expect(1);
+ await this.start();
+ const message = this.env.models['mail.message'].create({ body: "", id: 11 });
+ assert.ok(message.isEmpty);
+});
+
+QUnit.test('message with body "<p></p>" should be considered empty', async function (assert) {
+ assert.expect(1);
+ await this.start();
+ const message = this.env.models['mail.message'].create({ body: "<p></p>", id: 11 });
+ assert.ok(message.isEmpty);
+});
+
+QUnit.test('message with body "<p><br></p>" should be considered empty', async function (assert) {
+ assert.expect(1);
+ await this.start();
+ const message = this.env.models['mail.message'].create({ body: "<p><br></p>", id: 11 });
+ assert.ok(message.isEmpty);
+});
+
+QUnit.test('message with body "<p><br/></p>" should be considered empty', async function (assert) {
+ assert.expect(1);
+ await this.start();
+ const message = this.env.models['mail.message'].create({ body: "<p><br/></p>", id: 11 });
+ assert.ok(message.isEmpty);
+});
+
+QUnit.test(String.raw`message with body "<p>\n</p>" should be considered empty`, async function (assert) {
+ assert.expect(1);
+ await this.start();
+ const message = this.env.models['mail.message'].create({ body: "<p>\n</p>", id: 11 });
+ assert.ok(message.isEmpty);
+});
+
+QUnit.test(String.raw`message with body "<p>\r\n\r\n</p>" should be considered empty`, async function (assert) {
+ assert.expect(1);
+ await this.start();
+ const message = this.env.models['mail.message'].create({ body: "<p>\r\n\r\n</p>", id: 11 });
+ assert.ok(message.isEmpty);
+});
+
+QUnit.test('message with body "<p> </p> " should be considered empty', async function (assert) {
+ assert.expect(1);
+ await this.start();
+ const message = this.env.models['mail.message'].create({ body: "<p> </p> ", id: 11 });
+ assert.ok(message.isEmpty);
+});
+
+QUnit.test(`message with body "<img src=''>" should not be considered empty`, async function (assert) {
+ assert.expect(1);
+ await this.start();
+ const message = this.env.models['mail.message'].create({ body: "<img src=''>", id: 11 });
+ assert.notOk(message.isEmpty);
+});
+
+QUnit.test('message with body "test" should not be considered empty', async function (assert) {
+ assert.expect(1);
+ await this.start();
+ const message = this.env.models['mail.message'].create({ body: "test", id: 11 });
+ assert.notOk(message.isEmpty);
+});
+
+});
+});
+});
+
+});
diff --git a/addons/mail/static/src/models/message_seen_indicator/message_seen_indicator.js b/addons/mail/static/src/models/message_seen_indicator/message_seen_indicator.js
new file mode 100644
index 00000000..dd1848aa
--- /dev/null
+++ b/addons/mail/static/src/models/message_seen_indicator/message_seen_indicator.js
@@ -0,0 +1,358 @@
+odoo.define('mail/static/src/models/message_seen_indicator/message_seen_indicator.js', function (require) {
+'use strict';
+
+const { registerNewModel } = require('mail/static/src/model/model_core.js');
+const { attr, many2many, many2one, one2many } = require('mail/static/src/model/model_field.js');
+
+function factory(dependencies) {
+
+ class MessageSeenIndicator extends dependencies['mail.model'] {
+
+ //----------------------------------------------------------------------
+ // Public
+ //----------------------------------------------------------------------
+
+ /**
+ * @static
+ * @param {mail.thread} [channel] the concerned thread
+ */
+ static recomputeFetchedValues(channel = undefined) {
+ const indicatorFindFunction = channel ? localIndicator => localIndicator.thread === channel : undefined;
+ const indicators = this.env.models['mail.message_seen_indicator'].all(indicatorFindFunction);
+ for (const indicator of indicators) {
+ indicator.update({
+ hasEveryoneFetched: indicator._computeHasEveryoneFetched(),
+ hasSomeoneFetched: indicator._computeHasSomeoneFetched(),
+ partnersThatHaveFetched: indicator._computePartnersThatHaveFetched(),
+ });
+ }
+ }
+
+ /**
+ * @static
+ * @param {mail.thread} [channel] the concerned thread
+ */
+ static recomputeSeenValues(channel = undefined) {
+ const indicatorFindFunction = channel ? localIndicator => localIndicator.thread === channel : undefined;
+ const indicators = this.env.models['mail.message_seen_indicator'].all(indicatorFindFunction);
+ for (const indicator of indicators) {
+ indicator.update({
+ hasEveryoneSeen: indicator._computeHasEveryoneSeen(),
+ hasSomeoneFetched: indicator._computeHasSomeoneFetched(),
+ hasSomeoneSeen: indicator._computeHasSomeoneSeen(),
+ isMessagePreviousToLastCurrentPartnerMessageSeenByEveryone:
+ indicator._computeIsMessagePreviousToLastCurrentPartnerMessageSeenByEveryone(),
+ partnersThatHaveFetched: indicator._computePartnersThatHaveFetched(),
+ partnersThatHaveSeen: indicator._computePartnersThatHaveSeen(),
+ });
+ }
+ }
+
+ //----------------------------------------------------------------------
+ // Private
+ //----------------------------------------------------------------------
+
+ /**
+ * @override
+ */
+ static _createRecordLocalId(data) {
+ const { channelId, messageId } = data;
+ return `${this.modelName}_${channelId}_${messageId}`;
+ }
+
+ /**
+ * Manually called as not always called when necessary
+ *
+ * @private
+ * @returns {boolean}
+ * @see computeFetchedValues
+ * @see computeSeenValues
+ */
+ _computeHasEveryoneFetched() {
+ if (!this.message || !this.thread || !this.thread.partnerSeenInfos) {
+ return false;
+ }
+ const otherPartnerSeenInfosDidNotFetch =
+ this.thread.partnerSeenInfos.filter(partnerSeenInfo =>
+ partnerSeenInfo.partner !== this.message.author &&
+ (
+ !partnerSeenInfo.lastFetchedMessage ||
+ partnerSeenInfo.lastFetchedMessage.id < this.message.id
+ )
+ );
+ return otherPartnerSeenInfosDidNotFetch.length === 0;
+ }
+
+ /**
+ * Manually called as not always called when necessary
+ *
+ * @private
+ * @returns {boolean}
+ * @see computeSeenValues
+ */
+ _computeHasEveryoneSeen() {
+ if (!this.message || !this.thread || !this.thread.partnerSeenInfos) {
+ return false;
+ }
+ const otherPartnerSeenInfosDidNotSee =
+ this.thread.partnerSeenInfos.filter(partnerSeenInfo =>
+ partnerSeenInfo.partner !== this.message.author &&
+ (
+ !partnerSeenInfo.lastSeenMessage ||
+ partnerSeenInfo.lastSeenMessage.id < this.message.id
+ )
+ );
+ return otherPartnerSeenInfosDidNotSee.length === 0;
+ }
+
+ /**
+ * Manually called as not always called when necessary
+ *
+ * @private
+ * @returns {boolean}
+ * @see computeFetchedValues
+ * @see computeSeenValues
+ */
+ _computeHasSomeoneFetched() {
+ if (!this.message || !this.thread || !this.thread.partnerSeenInfos) {
+ return false;
+ }
+ const otherPartnerSeenInfosFetched =
+ this.thread.partnerSeenInfos.filter(partnerSeenInfo =>
+ partnerSeenInfo.partner !== this.message.author &&
+ partnerSeenInfo.lastFetchedMessage &&
+ partnerSeenInfo.lastFetchedMessage.id >= this.message.id
+ );
+ return otherPartnerSeenInfosFetched.length > 0;
+ }
+
+ /**
+ * Manually called as not always called when necessary
+ *
+ * @private
+ * @returns {boolean}
+ * @see computeSeenValues
+ */
+ _computeHasSomeoneSeen() {
+ if (!this.message || !this.thread || !this.thread.partnerSeenInfos) {
+ return false;
+ }
+ const otherPartnerSeenInfosSeen =
+ this.thread.partnerSeenInfos.filter(partnerSeenInfo =>
+ partnerSeenInfo.partner !== this.message.author &&
+ partnerSeenInfo.lastSeenMessage &&
+ partnerSeenInfo.lastSeenMessage.id >= this.message.id
+ );
+ return otherPartnerSeenInfosSeen.length > 0;
+ }
+
+ /**
+ * Manually called as not always called when necessary
+ *
+ * @private
+ * @returns {boolean}
+ * @see computeSeenValues
+ */
+ _computeIsMessagePreviousToLastCurrentPartnerMessageSeenByEveryone() {
+ if (
+ !this.message ||
+ !this.thread ||
+ !this.thread.lastCurrentPartnerMessageSeenByEveryone
+ ) {
+ return false;
+ }
+ return this.message.id < this.thread.lastCurrentPartnerMessageSeenByEveryone.id;
+ }
+
+ /**
+ * Manually called as not always called when necessary
+ *
+ * @private
+ * @returns {mail.partner[]}
+ * @see computeFetchedValues
+ * @see computeSeenValues
+ */
+ _computePartnersThatHaveFetched() {
+ if (!this.message || !this.thread || !this.thread.partnerSeenInfos) {
+ return [['unlink-all']];
+ }
+ const otherPartnersThatHaveFetched = this.thread.partnerSeenInfos
+ .filter(partnerSeenInfo =>
+ /**
+ * Relation may not be set yet immediately
+ * @see mail.thread_partner_seen_info:partnerId field
+ * FIXME task-2278551
+ */
+ partnerSeenInfo.partner &&
+ partnerSeenInfo.partner !== this.message.author &&
+ partnerSeenInfo.lastFetchedMessage &&
+ partnerSeenInfo.lastFetchedMessage.id >= this.message.id
+ )
+ .map(partnerSeenInfo => partnerSeenInfo.partner);
+ if (otherPartnersThatHaveFetched.length === 0) {
+ return [['unlink-all']];
+ }
+ return [['replace', otherPartnersThatHaveFetched]];
+ }
+
+ /**
+ * Manually called as not always called when necessary
+ *
+ * @private
+ * @returns {mail.partner[]}
+ * @see computeSeenValues
+ */
+ _computePartnersThatHaveSeen() {
+ if (!this.message || !this.thread || !this.thread.partnerSeenInfos) {
+ return [['unlink-all']];
+ }
+ const otherPartnersThatHaveSeen = this.thread.partnerSeenInfos
+ .filter(partnerSeenInfo =>
+ /**
+ * Relation may not be set yet immediately
+ * @see mail.thread_partner_seen_info:partnerId field
+ * FIXME task-2278551
+ */
+ partnerSeenInfo.partner &&
+ partnerSeenInfo.partner !== this.message.author &&
+ partnerSeenInfo.lastSeenMessage &&
+ partnerSeenInfo.lastSeenMessage.id >= this.message.id)
+ .map(partnerSeenInfo => partnerSeenInfo.partner);
+ if (otherPartnersThatHaveSeen.length === 0) {
+ return [['unlink-all']];
+ }
+ return [['replace', otherPartnersThatHaveSeen]];
+ }
+
+ /**
+ * @private
+ * @returns {mail.message}
+ */
+ _computeMessage() {
+ return [['insert', { id: this.messageId }]];
+ }
+
+ /**
+ * @private
+ * @returns {mail.thread}
+ */
+ _computeThread() {
+ return [['insert', {
+ id: this.channelId,
+ model: 'mail.channel',
+ }]];
+ }
+ }
+
+ MessageSeenIndicator.modelName = 'mail.message_seen_indicator';
+
+ MessageSeenIndicator.fields = {
+ /**
+ * The id of the channel this seen indicator is related to.
+ *
+ * Should write on this field to set relation between the channel and
+ * this seen indicator, not on `thread`.
+ *
+ * Reason for not setting the relation directly is the necessity to
+ * uniquely identify a seen indicator based on channel and message from data.
+ * Relational data are list of commands, which is problematic to deduce
+ * identifying records.
+ *
+ * TODO: task-2322536 (normalize relational data) & task-2323665
+ * (required fields) should improve and let us just use the relational
+ * fields.
+ */
+ channelId: attr(),
+ hasEveryoneFetched: attr({
+ compute: '_computeHasEveryoneFetched',
+ default: false,
+ dependencies: ['messageAuthor', 'messageId', 'threadPartnerSeenInfos'],
+ }),
+ hasEveryoneSeen: attr({
+ compute: '_computeHasEveryoneSeen',
+ default: false,
+ dependencies: ['messageAuthor', 'messageId', 'threadPartnerSeenInfos'],
+ }),
+ hasSomeoneFetched: attr({
+ compute: '_computeHasSomeoneFetched',
+ default: false,
+ dependencies: ['messageAuthor', 'messageId', 'threadPartnerSeenInfos'],
+ }),
+ hasSomeoneSeen: attr({
+ compute: '_computeHasSomeoneSeen',
+ default: false,
+ dependencies: ['messageAuthor', 'messageId', 'threadPartnerSeenInfos'],
+ }),
+ id: attr(),
+ isMessagePreviousToLastCurrentPartnerMessageSeenByEveryone: attr({
+ compute: '_computeIsMessagePreviousToLastCurrentPartnerMessageSeenByEveryone',
+ default: false,
+ dependencies: [
+ 'messageId',
+ 'threadLastCurrentPartnerMessageSeenByEveryone',
+ ],
+ }),
+ /**
+ * The message concerned by this seen indicator.
+ * This is automatically computed based on messageId field.
+ * @see messageId
+ */
+ message: many2one('mail.message', {
+ compute: '_computeMessage',
+ dependencies: [
+ 'messageId',
+ ],
+ }),
+ messageAuthor: many2one('mail.partner', {
+ related: 'message.author',
+ }),
+ /**
+ * The id of the message this seen indicator is related to.
+ *
+ * Should write on this field to set relation between the channel and
+ * this seen indicator, not on `message`.
+ *
+ * Reason for not setting the relation directly is the necessity to
+ * uniquely identify a seen indicator based on channel and message from data.
+ * Relational data are list of commands, which is problematic to deduce
+ * identifying records.
+ *
+ * TODO: task-2322536 (normalize relational data) & task-2323665
+ * (required fields) should improve and let us just use the relational
+ * fields.
+ */
+ messageId: attr(),
+ partnersThatHaveFetched: many2many('mail.partner', {
+ compute: '_computePartnersThatHaveFetched',
+ dependencies: ['messageAuthor', 'messageId', 'threadPartnerSeenInfos'],
+ }),
+ partnersThatHaveSeen: many2many('mail.partner', {
+ compute: '_computePartnersThatHaveSeen',
+ dependencies: ['messageAuthor', 'messageId', 'threadPartnerSeenInfos'],
+ }),
+ /**
+ * The thread concerned by this seen indicator.
+ * This is automatically computed based on channelId field.
+ * @see channelId
+ */
+ thread: many2one('mail.thread', {
+ compute: '_computeThread',
+ dependencies: [
+ 'channelId',
+ ],
+ inverse: 'messageSeenIndicators'
+ }),
+ threadPartnerSeenInfos: one2many('mail.thread_partner_seen_info', {
+ related: 'thread.partnerSeenInfos',
+ }),
+ threadLastCurrentPartnerMessageSeenByEveryone: many2one('mail.message', {
+ related: 'thread.lastCurrentPartnerMessageSeenByEveryone',
+ }),
+ };
+
+ return MessageSeenIndicator;
+}
+
+registerNewModel('mail.message_seen_indicator', factory);
+
+});
diff --git a/addons/mail/static/src/models/messaging/messaging.js b/addons/mail/static/src/models/messaging/messaging.js
new file mode 100644
index 00000000..3544e718
--- /dev/null
+++ b/addons/mail/static/src/models/messaging/messaging.js
@@ -0,0 +1,253 @@
+odoo.define('mail/static/src/models/messaging/messaging.js', function (require) {
+'use strict';
+
+const { registerNewModel } = require('mail/static/src/model/model_core.js');
+const { attr, many2many, many2one, one2many, one2one } = require('mail/static/src/model/model_field.js');
+
+function factory(dependencies) {
+
+ class Messaging extends dependencies['mail.model'] {
+
+ /**
+ * @override
+ */
+ _willDelete() {
+ if (this.env.services['bus_service']) {
+ this.env.services['bus_service'].off('window_focus', null, this._handleGlobalWindowFocus);
+ }
+ return super._willDelete(...arguments);
+ }
+
+ /**
+ * Starts messaging and related records.
+ */
+ async start() {
+ this._handleGlobalWindowFocus = this._handleGlobalWindowFocus.bind(this);
+ this.env.services['bus_service'].on('window_focus', null, this._handleGlobalWindowFocus);
+ await this.async(() => this.initializer.start());
+ this.notificationHandler.start();
+ this.update({ isInitialized: true });
+ }
+
+ //----------------------------------------------------------------------
+ // Public
+ //----------------------------------------------------------------------
+
+ /**
+ * @returns {boolean}
+ */
+ isNotificationPermissionDefault() {
+ const windowNotification = this.env.browser.Notification;
+ return windowNotification
+ ? windowNotification.permission === 'default'
+ : false;
+ }
+
+ /**
+ * Open the form view of the record with provided id and model.
+ * Gets the chat with the provided person and returns it.
+ *
+ * If a chat is not appropriate, a notification is displayed instead.
+ *
+ * @param {Object} param0
+ * @param {integer} [param0.partnerId]
+ * @param {integer} [param0.userId]
+ * @param {Object} [options]
+ * @returns {mail.thread|undefined}
+ */
+ async getChat({ partnerId, userId }) {
+ if (userId) {
+ const user = this.env.models['mail.user'].insert({ id: userId });
+ return user.getChat();
+ }
+ if (partnerId) {
+ const partner = this.env.models['mail.partner'].insert({ id: partnerId });
+ return partner.getChat();
+ }
+ }
+
+ /**
+ * Opens a chat with the provided person and returns it.
+ *
+ * If a chat is not appropriate, a notification is displayed instead.
+ *
+ * @param {Object} person forwarded to @see `getChat()`
+ * @param {Object} [options] forwarded to @see `mail.thread:open()`
+ * @returns {mail.thread|undefined}
+ */
+ async openChat(person, options) {
+ const chat = await this.async(() => this.getChat(person));
+ if (!chat) {
+ return;
+ }
+ await this.async(() => chat.open(options));
+ return chat;
+ }
+
+ /**
+ * Opens the form view of the record with provided id and model.
+ *
+ * @param {Object} param0
+ * @param {integer} param0.id
+ * @param {string} param0.model
+ */
+ async openDocument({ id, model }) {
+ this.env.bus.trigger('do-action', {
+ action: {
+ type: 'ir.actions.act_window',
+ res_model: model,
+ views: [[false, 'form']],
+ res_id: id,
+ },
+ });
+ if (this.env.messaging.device.isMobile) {
+ // messaging menu has a higher z-index than views so it must
+ // be closed to ensure the visibility of the view
+ this.env.messaging.messagingMenu.close();
+ }
+ }
+
+ /**
+ * Opens the most appropriate view that is a profile for provided id and
+ * model.
+ *
+ * @param {Object} param0
+ * @param {integer} param0.id
+ * @param {string} param0.model
+ */
+ async openProfile({ id, model }) {
+ if (model === 'res.partner') {
+ const partner = this.env.models['mail.partner'].insert({ id });
+ return partner.openProfile();
+ }
+ if (model === 'res.users') {
+ const user = this.env.models['mail.user'].insert({ id });
+ return user.openProfile();
+ }
+ if (model === 'mail.channel') {
+ let channel = this.env.models['mail.thread'].findFromIdentifyingData({ id, model: 'mail.channel' });
+ if (!channel) {
+ channel = (await this.async(() =>
+ this.env.models['mail.thread'].performRpcChannelInfo({ ids: [id] })
+ ))[0];
+ }
+ if (!channel) {
+ this.env.services['notification'].notify({
+ message: this.env._t("You can only open the profile of existing channels."),
+ type: 'warning',
+ });
+ return;
+ }
+ return channel.openProfile();
+ }
+ return this.env.messaging.openDocument({ id, model });
+ }
+
+ //----------------------------------------------------------------------
+ // Private
+ //----------------------------------------------------------------------
+
+ /**
+ * @private
+ */
+ _handleGlobalWindowFocus() {
+ this.update({ outOfFocusUnreadMessageCounter: 0 });
+ this.env.bus.trigger('set_title_part', {
+ part: '_chat',
+ });
+ }
+
+ }
+
+ Messaging.fields = {
+ cannedResponses: one2many('mail.canned_response'),
+ chatWindowManager: one2one('mail.chat_window_manager', {
+ default: [['create']],
+ inverse: 'messaging',
+ isCausal: true,
+ }),
+ commands: one2many('mail.channel_command'),
+ currentPartner: one2one('mail.partner'),
+ currentUser: one2one('mail.user'),
+ device: one2one('mail.device', {
+ default: [['create']],
+ isCausal: true,
+ }),
+ dialogManager: one2one('mail.dialog_manager', {
+ default: [['create']],
+ isCausal: true,
+ }),
+ discuss: one2one('mail.discuss', {
+ default: [['create']],
+ inverse: 'messaging',
+ isCausal: true,
+ }),
+ /**
+ * Mailbox History.
+ */
+ history: one2one('mail.thread'),
+ /**
+ * Mailbox Inbox.
+ */
+ inbox: one2one('mail.thread'),
+ initializer: one2one('mail.messaging_initializer', {
+ default: [['create']],
+ inverse: 'messaging',
+ isCausal: true,
+ }),
+ isInitialized: attr({
+ default: false,
+ }),
+ locale: one2one('mail.locale', {
+ default: [['create']],
+ isCausal: true,
+ }),
+ messagingMenu: one2one('mail.messaging_menu', {
+ default: [['create']],
+ inverse: 'messaging',
+ isCausal: true,
+ }),
+ /**
+ * Mailbox Moderation.
+ */
+ moderation: one2one('mail.thread'),
+ notificationGroupManager: one2one('mail.notification_group_manager', {
+ default: [['create']],
+ isCausal: true,
+ }),
+ notificationHandler: one2one('mail.messaging_notification_handler', {
+ default: [['create']],
+ inverse: 'messaging',
+ isCausal: true,
+ }),
+ outOfFocusUnreadMessageCounter: attr({
+ default: 0,
+ }),
+ partnerRoot: many2one('mail.partner'),
+ /**
+ * Determines which partner should be considered the public partner,
+ * which is a special partner notably used in livechat.
+ *
+ * @deprecated in favor of `publicPartners` because in multi-website
+ * setup there might be a different public partner per website.
+ */
+ publicPartner: many2one('mail.partner'),
+ /**
+ * Determines which partners should be considered the public partners,
+ * which are special partners notably used in livechat.
+ */
+ publicPartners: many2many('mail.partner'),
+ /**
+ * Mailbox Starred.
+ */
+ starred: one2one('mail.thread'),
+ };
+
+ Messaging.modelName = 'mail.messaging';
+
+ return Messaging;
+}
+
+registerNewModel('mail.messaging', factory);
+
+});
diff --git a/addons/mail/static/src/models/messaging/messaging_tests.js b/addons/mail/static/src/models/messaging/messaging_tests.js
new file mode 100644
index 00000000..b306fbb1
--- /dev/null
+++ b/addons/mail/static/src/models/messaging/messaging_tests.js
@@ -0,0 +1,126 @@
+odoo.define('mail/static/src/models/messaging/messaging_tests.js', function (require) {
+'use strict';
+
+const { afterEach, beforeEach, start } = require('mail/static/src/utils/test_utils.js');
+
+QUnit.module('mail', {}, function () {
+QUnit.module('models', {}, function () {
+QUnit.module('messaging', {}, function () {
+QUnit.module('messaging_tests.js', {
+ beforeEach() {
+ beforeEach(this);
+
+ this.start = async params => {
+ const { env, widget } = await start(Object.assign({}, params, {
+ data: this.data,
+ }));
+ this.env = env;
+ this.widget = widget;
+ };
+ },
+ afterEach() {
+ afterEach(this);
+ },
+}, function () {
+
+QUnit.test('openChat: display notification for partner without user', async function (assert) {
+ assert.expect(2);
+
+ this.data['res.partner'].records.push({ id: 14 });
+ await this.start();
+
+ await this.env.messaging.openChat({ partnerId: 14 });
+ assert.containsOnce(
+ document.body,
+ '.toast .o_notification_content',
+ "should display a toast notification after failing to open chat"
+ );
+ assert.strictEqual(
+ document.querySelector('.o_notification_content').textContent,
+ "You can only chat with partners that have a dedicated user.",
+ "should display the correct information in the notification"
+ );
+});
+
+QUnit.test('openChat: display notification for wrong user', async function (assert) {
+ assert.expect(2);
+
+ await this.start();
+
+ // user id not in this.data
+ await this.env.messaging.openChat({ userId: 14 });
+ assert.containsOnce(
+ document.body,
+ '.toast .o_notification_content',
+ "should display a toast notification after failing to open chat"
+ );
+ assert.strictEqual(
+ document.querySelector('.o_notification_content').textContent,
+ "You can only chat with existing users.",
+ "should display the correct information in the notification"
+ );
+});
+
+QUnit.test('openChat: open new chat for user', async function (assert) {
+ assert.expect(3);
+
+ this.data['res.partner'].records.push({ id: 14 });
+ this.data['res.users'].records.push({ id: 11, partner_id: 14 });
+ await this.start();
+
+ const existingChat = this.env.models['mail.thread'].find(thread =>
+ thread.channel_type === 'chat' &&
+ thread.correspondent &&
+ thread.correspondent.id === 14 &&
+ thread.model === 'mail.channel' &&
+ thread.public === 'private'
+ );
+ assert.notOk(existingChat, 'a chat should not exist with the target partner initially');
+
+ await this.env.messaging.openChat({ partnerId: 14 });
+ const chat = this.env.models['mail.thread'].find(thread =>
+ thread.channel_type === 'chat' &&
+ thread.correspondent &&
+ thread.correspondent.id === 14 &&
+ thread.model === 'mail.channel' &&
+ thread.public === 'private'
+ );
+ assert.ok(chat, 'a chat should exist with the target partner');
+ assert.strictEqual(chat.threadViews.length, 1, 'the chat should be displayed in a `mail.thread_view`');
+});
+
+QUnit.test('openChat: open existing chat for user', async function (assert) {
+ assert.expect(5);
+
+ this.data['res.partner'].records.push({ id: 14 });
+ this.data['res.users'].records.push({ id: 11, partner_id: 14 });
+ this.data['mail.channel'].records.push({
+ channel_type: "chat",
+ id: 10,
+ members: [this.data.currentPartnerId, 14],
+ public: 'private',
+ });
+ await this.start();
+ const existingChat = this.env.models['mail.thread'].find(thread =>
+ thread.channel_type === 'chat' &&
+ thread.correspondent &&
+ thread.correspondent.id === 14 &&
+ thread.model === 'mail.channel' &&
+ thread.public === 'private'
+ );
+ assert.ok(existingChat, 'a chat should initially exist with the target partner');
+ assert.strictEqual(existingChat.threadViews.length, 0, 'the chat should not be displayed in a `mail.thread_view`');
+
+ await this.env.messaging.openChat({ partnerId: 14 });
+ assert.ok(existingChat, 'a chat should still exist with the target partner');
+ assert.strictEqual(existingChat.id, 10, 'the chat should be the existing chat');
+ assert.strictEqual(existingChat.threadViews.length, 1, 'the chat should now be displayed in a `mail.thread_view`');
+});
+
+});
+
+});
+});
+});
+
+});
diff --git a/addons/mail/static/src/models/messaging_initializer/messaging_initializer.js b/addons/mail/static/src/models/messaging_initializer/messaging_initializer.js
new file mode 100644
index 00000000..97d0d3b1
--- /dev/null
+++ b/addons/mail/static/src/models/messaging_initializer/messaging_initializer.js
@@ -0,0 +1,304 @@
+odoo.define('mail/static/src/models/messaging_initializer/messaging_initializer.js', function (require) {
+'use strict';
+
+const { registerNewModel } = require('mail/static/src/model/model_core.js');
+const { one2one } = require('mail/static/src/model/model_field.js');
+const { executeGracefully } = require('mail/static/src/utils/utils.js');
+
+function factory(dependencies) {
+
+ class MessagingInitializer extends dependencies['mail.model'] {
+
+ //----------------------------------------------------------------------
+ // Public
+ //----------------------------------------------------------------------
+
+ /**
+ * Fetch messaging data initially to populate the store specifically for
+ * the current user. This includes pinned channels for instance.
+ */
+ async start() {
+ this.messaging.update({
+ history: [['create', {
+ id: 'history',
+ isServerPinned: true,
+ model: 'mail.box',
+ name: this.env._t("History"),
+ }]],
+ inbox: [['create', {
+ id: 'inbox',
+ isServerPinned: true,
+ model: 'mail.box',
+ name: this.env._t("Inbox"),
+ }]],
+ moderation: [['create', {
+ id: 'moderation',
+ model: 'mail.box',
+ name: this.env._t("Moderation"),
+ }]],
+ starred: [['create', {
+ id: 'starred',
+ isServerPinned: true,
+ model: 'mail.box',
+ name: this.env._t("Starred"),
+ }]],
+ });
+ const device = this.messaging.device;
+ device.start();
+ const context = Object.assign({
+ isMobile: device.isMobile,
+ }, this.env.session.user_context);
+ const discuss = this.messaging.discuss;
+ const data = await this.async(() => this.env.services.rpc({
+ route: '/mail/init_messaging',
+ params: { context: context }
+ }, { shadow: true }));
+ await this.async(() => this._init(data));
+ if (discuss.isOpen) {
+ discuss.openInitThread();
+ }
+ if (this.env.autofetchPartnerImStatus) {
+ this.env.models['mail.partner'].startLoopFetchImStatus();
+ }
+ }
+
+ //----------------------------------------------------------------------
+ // Private
+ //----------------------------------------------------------------------
+
+ /**
+ * @private
+ * @param {Object} param0
+ * @param {Object} param0.channel_slots
+ * @param {Array} [param0.commands=[]]
+ * @param {Object} param0.current_partner
+ * @param {integer} param0.current_user_id
+ * @param {Object} [param0.mail_failures={}]
+ * @param {Object[]} [param0.mention_partner_suggestions=[]]
+ * @param {Object[]} [param0.moderation_channel_ids=[]]
+ * @param {integer} [param0.moderation_counter=0]
+ * @param {integer} [param0.needaction_inbox_counter=0]
+ * @param {Object} param0.partner_root
+ * @param {Object} param0.public_partner
+ * @param {Object[]} param0.public_partners
+ * @param {Object[]} [param0.shortcodes=[]]
+ * @param {integer} [param0.starred_counter=0]
+ */
+ async _init({
+ channel_slots,
+ commands = [],
+ current_partner,
+ current_user_id,
+ mail_failures = {},
+ mention_partner_suggestions = [],
+ menu_id,
+ moderation_channel_ids = [],
+ moderation_counter = 0,
+ needaction_inbox_counter = 0,
+ partner_root,
+ public_partner,
+ public_partners,
+ shortcodes = [],
+ starred_counter = 0
+ }) {
+ const discuss = this.messaging.discuss;
+ // partners first because the rest of the code relies on them
+ this._initPartners({
+ current_partner,
+ current_user_id,
+ moderation_channel_ids,
+ partner_root,
+ public_partner,
+ public_partners,
+ });
+ // mailboxes after partners and before other initializers that might
+ // manipulate threads or messages
+ this._initMailboxes({
+ moderation_channel_ids,
+ moderation_counter,
+ needaction_inbox_counter,
+ starred_counter,
+ });
+ // various suggestions in no particular order
+ this._initCannedResponses(shortcodes);
+ this._initCommands(commands);
+ this._initMentionPartnerSuggestions(mention_partner_suggestions);
+ // channels when the rest of messaging is ready
+ await this.async(() => this._initChannels(channel_slots));
+ // failures after channels
+ this._initMailFailures(mail_failures);
+ discuss.update({ menu_id });
+ }
+
+ /**
+ * @private
+ * @param {Object[]} cannedResponsesData
+ */
+ _initCannedResponses(cannedResponsesData) {
+ this.messaging.update({
+ cannedResponses: [['insert', cannedResponsesData]],
+ });
+ }
+
+ /**
+ * @private
+ * @param {Object} [param0={}]
+ * @param {Object[]} [param0.channel_channel=[]]
+ * @param {Object[]} [param0.channel_direct_message=[]]
+ * @param {Object[]} [param0.channel_private_group=[]]
+ */
+ async _initChannels({
+ channel_channel = [],
+ channel_direct_message = [],
+ channel_private_group = [],
+ } = {}) {
+ const channelsData = channel_channel.concat(channel_direct_message, channel_private_group);
+ return executeGracefully(channelsData.map(channelData => () => {
+ const convertedData = this.env.models['mail.thread'].convertData(channelData);
+ if (!convertedData.members) {
+ // channel_info does not return all members of channel for
+ // performance reasons, but code is expecting to know at
+ // least if the current partner is member of it.
+ // (e.g. to know when to display "invited" notification)
+ // Current partner can always be assumed to be a member of
+ // channels received at init.
+ convertedData.members = [['link', this.env.messaging.currentPartner]];
+ }
+ const channel = this.env.models['mail.thread'].insert(
+ Object.assign({ model: 'mail.channel' }, convertedData)
+ );
+ // flux specific: channels received at init have to be
+ // considered pinned. task-2284357
+ if (!channel.isPinned) {
+ channel.pin();
+ }
+ }));
+ }
+
+ /**
+ * @private
+ * @param {Object[]} commandsData
+ */
+ _initCommands(commandsData) {
+ this.messaging.update({
+ commands: [['insert', commandsData]],
+ });
+ }
+
+ /**
+ * @private
+ * @param {Object} param0
+ * @param {Object[]} [param0.moderation_channel_ids=[]]
+ * @param {integer} param0.moderation_counter
+ * @param {integer} param0.needaction_inbox_counter
+ * @param {integer} param0.starred_counter
+ */
+ _initMailboxes({
+ moderation_channel_ids,
+ moderation_counter,
+ needaction_inbox_counter,
+ starred_counter,
+ }) {
+ this.env.messaging.inbox.update({ counter: needaction_inbox_counter });
+ this.env.messaging.starred.update({ counter: starred_counter });
+ if (moderation_channel_ids.length > 0) {
+ this.messaging.moderation.update({
+ counter: moderation_counter,
+ isServerPinned: true,
+ });
+ }
+ }
+
+ /**
+ * @private
+ * @param {Object} mailFailuresData
+ */
+ async _initMailFailures(mailFailuresData) {
+ await executeGracefully(mailFailuresData.map(messageData => () => {
+ const message = this.env.models['mail.message'].insert(
+ this.env.models['mail.message'].convertData(messageData)
+ );
+ // implicit: failures are sent by the server at initialization
+ // only if the current partner is author of the message
+ if (!message.author && this.messaging.currentPartner) {
+ message.update({ author: [['link', this.messaging.currentPartner]] });
+ }
+ }));
+ this.messaging.notificationGroupManager.computeGroups();
+ // manually force recompute of counter (after computing the groups)
+ this.messaging.messagingMenu.update();
+ }
+
+ /**
+ * @private
+ * @param {Object[]} mentionPartnerSuggestionsData
+ */
+ async _initMentionPartnerSuggestions(mentionPartnerSuggestionsData) {
+ return executeGracefully(mentionPartnerSuggestionsData.map(suggestions => () => {
+ return executeGracefully(suggestions.map(suggestion => () => {
+ this.env.models['mail.partner'].insert(this.env.models['mail.partner'].convertData(suggestion));
+ }));
+ }));
+ }
+
+ /**
+ * @private
+ * @param {Object} current_partner
+ * @param {integer} current_user_id
+ * @param {integer[]} moderation_channel_ids
+ * @param {Object} partner_root
+ * @param {Object} public_partner
+ * @param {Object[]} [public_partners=[]]
+ */
+ _initPartners({
+ current_partner,
+ current_user_id: currentUserId,
+ moderation_channel_ids = [],
+ partner_root,
+ public_partner,
+ public_partners = [],
+ }) {
+ const publicPartner = this.env.models['mail.partner'].convertData(public_partner);
+ this.messaging.update({
+ currentPartner: [['insert', Object.assign(
+ this.env.models['mail.partner'].convertData(current_partner),
+ {
+ moderatedChannels: [
+ ['insert', moderation_channel_ids.map(id => {
+ return {
+ id,
+ model: 'mail.channel',
+ };
+ })],
+ ],
+ user: [['insert', { id: currentUserId }]],
+ }
+ )]],
+ currentUser: [['insert', { id: currentUserId }]],
+ partnerRoot: [['insert', this.env.models['mail.partner'].convertData(partner_root)]],
+ publicPartner: [['insert', publicPartner]],
+ publicPartners: [
+ ['insert', publicPartner],
+ ['insert', public_partners.map(
+ publicPartner => this.env.models['mail.partner'].convertData(publicPartner))
+ ],
+ ],
+ });
+ }
+
+ }
+
+ MessagingInitializer.fields = {
+ messaging: one2one('mail.messaging', {
+ inverse: 'initializer',
+ }),
+ };
+
+ MessagingInitializer.modelName = 'mail.messaging_initializer';
+
+ return MessagingInitializer;
+}
+
+registerNewModel('mail.messaging_initializer', factory);
+
+});
diff --git a/addons/mail/static/src/models/messaging_menu/messaging_menu.js b/addons/mail/static/src/models/messaging_menu/messaging_menu.js
new file mode 100644
index 00000000..60212930
--- /dev/null
+++ b/addons/mail/static/src/models/messaging_menu/messaging_menu.js
@@ -0,0 +1,154 @@
+odoo.define('mail/static/src/models/messaging_menu/messaging_menu.js', function (require) {
+'use strict';
+
+const { registerNewModel } = require('mail/static/src/model/model_core.js');
+const { attr, one2one } = require('mail/static/src/model/model_field.js');
+
+function factory(dependencies) {
+
+ class MessagingMenu extends dependencies['mail.model'] {
+
+ //----------------------------------------------------------------------
+ // Public
+ //----------------------------------------------------------------------
+
+ /**
+ * Close the messaging menu. Should reset its internal state.
+ */
+ close() {
+ this.update({ isOpen: false });
+ }
+
+ /**
+ * Toggle the visibility of the messaging menu "new message" input in
+ * mobile.
+ */
+ toggleMobileNewMessage() {
+ this.update({ isMobileNewMessageToggled: !this.isMobileNewMessageToggled });
+ }
+
+ /**
+ * Toggle whether the messaging menu is open or not.
+ */
+ toggleOpen() {
+ this.update({ isOpen: !this.isOpen });
+ }
+
+ //----------------------------------------------------------------------
+ // Private
+ //----------------------------------------------------------------------
+
+ /**
+ * @private
+ */
+ _computeInboxMessagesAutoloader() {
+ if (!this.isOpen) {
+ return;
+ }
+ const inbox = this.env.messaging.inbox;
+ if (!inbox || !inbox.mainCache) {
+ return;
+ }
+ // populate some needaction messages on threads.
+ inbox.mainCache.update({ isCacheRefreshRequested: true });
+ }
+
+ /**
+ * @private
+ * @returns {integer}
+ */
+ _updateCounter() {
+ if (!this.env.messaging) {
+ return 0;
+ }
+ const inboxMailbox = this.env.messaging.inbox;
+ const unreadChannels = this.env.models['mail.thread'].all(thread =>
+ thread.localMessageUnreadCounter > 0 &&
+ thread.model === 'mail.channel' &&
+ thread.isPinned
+ );
+ let counter = unreadChannels.length;
+ if (inboxMailbox) {
+ counter += inboxMailbox.counter;
+ }
+ if (this.messaging.notificationGroupManager) {
+ counter += this.messaging.notificationGroupManager.groups.reduce(
+ (total, group) => total + group.notifications.length,
+ 0
+ );
+ }
+ if (this.messaging.isNotificationPermissionDefault()) {
+ counter++;
+ }
+ return counter;
+ }
+
+ /**
+ * @override
+ */
+ _updateAfter(previous) {
+ const counter = this._updateCounter();
+ if (this.counter !== counter) {
+ this.update({ counter });
+ }
+ }
+
+ }
+
+ MessagingMenu.fields = {
+ /**
+ * Tab selected in the messaging menu.
+ * Either 'all', 'chat' or 'channel'.
+ */
+ activeTabId: attr({
+ default: 'all',
+ }),
+ counter: attr({
+ default: 0,
+ }),
+ /**
+ * Dummy field to automatically load messages of inbox when messaging
+ * menu is open.
+ *
+ * Useful because needaction notifications require fetching inbox
+ * messages to work.
+ */
+ inboxMessagesAutoloader: attr({
+ compute: '_computeInboxMessagesAutoloader',
+ dependencies: [
+ 'isOpen',
+ 'messagingInbox',
+ 'messagingInboxMainCache',
+ ],
+ }),
+ /**
+ * Determine whether the mobile new message input is visible or not.
+ */
+ isMobileNewMessageToggled: attr({
+ default: false,
+ }),
+ /**
+ * Determine whether the messaging menu dropdown is open or not.
+ */
+ isOpen: attr({
+ default: false,
+ }),
+ messaging: one2one('mail.messaging', {
+ inverse: 'messagingMenu',
+ }),
+ messagingInbox: one2one('mail.thread', {
+ related: 'messaging.inbox',
+ }),
+ messagingInboxMainCache: one2one('mail.thread_cache', {
+ related: 'messagingInbox.mainCache',
+ }),
+ };
+
+ MessagingMenu.modelName = 'mail.messaging_menu';
+
+ return MessagingMenu;
+}
+
+registerNewModel('mail.messaging_menu', factory);
+
+});
diff --git a/addons/mail/static/src/models/messaging_notification_handler/messaging_notification_handler.js b/addons/mail/static/src/models/messaging_notification_handler/messaging_notification_handler.js
new file mode 100644
index 00000000..a42ede1c
--- /dev/null
+++ b/addons/mail/static/src/models/messaging_notification_handler/messaging_notification_handler.js
@@ -0,0 +1,795 @@
+odoo.define('mail/static/src/models/messaging_notification_handler/messaging_notification_handler.js', function (require) {
+'use strict';
+
+const { registerNewModel } = require('mail/static/src/model/model_core.js');
+const { one2one } = require('mail/static/src/model/model_field.js');
+const { decrement, increment } = require('mail/static/src/model/model_field_command.js');
+const { htmlToTextContentInline } = require('mail.utils');
+
+const PREVIEW_MSG_MAX_SIZE = 350; // optimal for native English speakers
+
+function factory(dependencies) {
+
+ class MessagingNotificationHandler extends dependencies['mail.model'] {
+
+ /**
+ * @override
+ */
+ _willDelete() {
+ if (this.env.services['bus_service']) {
+ this.env.services['bus_service'].off('notification');
+ this.env.services['bus_service'].stopPolling();
+ }
+ return super._willDelete(...arguments);
+ }
+
+ //----------------------------------------------------------------------
+ // Public
+ //----------------------------------------------------------------------
+
+ /**
+ * Fetch messaging data initially to populate the store specifically for
+ * the current users. This includes pinned channels for instance.
+ */
+ start() {
+ this.env.services.bus_service.onNotification(null, notifs => this._handleNotifications(notifs));
+ this.env.services.bus_service.startPolling();
+ }
+
+ //----------------------------------------------------------------------
+ // Private
+ //----------------------------------------------------------------------
+
+ /**
+ * @private
+ * @param {Object[]} notifications
+ * @returns {Object[]}
+ */
+ _filterNotificationsOnUnsubscribe(notifications) {
+ const unsubscribedNotif = notifications.find(notif =>
+ notif[1].info === 'unsubscribe');
+ if (unsubscribedNotif) {
+ notifications = notifications.filter(notif =>
+ notif[0][1] !== 'mail.channel' ||
+ notif[0][2] !== unsubscribedNotif[1].id
+ );
+ }
+ return notifications;
+ }
+
+ /**
+ * @private
+ * @param {Object[]} notifications
+ * @param {Array|string} notifications[i][0] meta-data of the notification.
+ * @param {string} notifications[i][0][0] name of database this
+ * notification comes from.
+ * @param {string} notifications[i][0][1] type of notification.
+ * @param {integer} notifications[i][0][2] usually id of related type
+ * of notification. For instance, with `mail.channel`, this is the id
+ * of the channel.
+ * @param {Object} notifications[i][1] payload of the notification
+ */
+ async _handleNotifications(notifications) {
+ const filteredNotifications = this._filterNotificationsOnUnsubscribe(notifications);
+ const proms = filteredNotifications.map(notification => {
+ const [channel, message] = notification;
+ if (typeof channel === 'string') {
+ // uuid notification, only for (livechat) public handler
+ return;
+ }
+ const [, model, id] = channel;
+ switch (model) {
+ case 'ir.needaction':
+ return this._handleNotificationNeedaction(message);
+ case 'mail.channel':
+ return this._handleNotificationChannel(id, message);
+ case 'res.partner':
+ if (id !== this.env.messaging.currentPartner.id) {
+ // ignore broadcast to other partners
+ return;
+ }
+ return this._handleNotificationPartner(Object.assign({}, message));
+ }
+ });
+ await this.async(() => Promise.all(proms));
+ }
+
+ /**
+ * @private
+ * @param {integer} channelId
+ * @param {Object} data
+ * @param {string} [data.info]
+ * @param {boolean} [data.is_typing]
+ * @param {integer} [data.last_message_id]
+ * @param {integer} [data.partner_id]
+ */
+ async _handleNotificationChannel(channelId, data) {
+ const {
+ info,
+ is_typing,
+ last_message_id,
+ partner_id,
+ partner_name,
+ } = data;
+ switch (info) {
+ case 'channel_fetched':
+ return this._handleNotificationChannelFetched(channelId, {
+ last_message_id,
+ partner_id,
+ });
+ case 'channel_seen':
+ return this._handleNotificationChannelSeen(channelId, {
+ last_message_id,
+ partner_id,
+ });
+ case 'typing_status':
+ return this._handleNotificationChannelTypingStatus(channelId, {
+ is_typing,
+ partner_id,
+ partner_name,
+ });
+ default:
+ return this._handleNotificationChannelMessage(channelId, data);
+ }
+ }
+
+ /**
+ * @private
+ * @param {integer} channelId
+ * @param {Object} param1
+ * @param {integer} param1.last_message_id
+ * @param {integer} param1.partner_id
+ */
+ async _handleNotificationChannelFetched(channelId, {
+ last_message_id,
+ partner_id,
+ }) {
+ const channel = this.env.models['mail.thread'].findFromIdentifyingData({
+ id: channelId,
+ model: 'mail.channel',
+ });
+ if (!channel) {
+ // for example seen from another browser, the current one has no
+ // knowledge of the channel
+ return;
+ }
+ if (channel.channel_type === 'channel') {
+ // disabled on `channel` channels for performance reasons
+ return;
+ }
+ this.env.models['mail.thread_partner_seen_info'].insert({
+ channelId: channel.id,
+ lastFetchedMessage: [['insert', { id: last_message_id }]],
+ partnerId: partner_id,
+ });
+ channel.update({
+ messageSeenIndicators: [['insert',
+ {
+ channelId: channel.id,
+ messageId: last_message_id,
+ }
+ ]],
+ });
+ // FIXME force the computing of message values (cf task-2261221)
+ this.env.models['mail.message_seen_indicator'].recomputeFetchedValues(channel);
+ }
+
+ /**
+ * @private
+ * @param {integer} channelId
+ * @param {Object} messageData
+ */
+ async _handleNotificationChannelMessage(channelId, messageData) {
+ let channel = this.env.models['mail.thread'].findFromIdentifyingData({
+ id: channelId,
+ model: 'mail.channel',
+ });
+ const wasChannelExisting = !!channel;
+ const convertedData = this.env.models['mail.message'].convertData(messageData);
+ const oldMessage = this.env.models['mail.message'].findFromIdentifyingData(convertedData);
+ // locally save old values, as insert would overwrite them
+ const oldMessageModerationStatus = (
+ oldMessage && oldMessage.moderation_status
+ );
+ const oldMessageWasModeratedByCurrentPartner = (
+ oldMessage && oldMessage.isModeratedByCurrentPartner
+ );
+
+ // Fetch missing info from channel before going further. Inserting
+ // a channel with incomplete info can lead to issues. This is in
+ // particular the case with the `uuid` field that is assumed
+ // "required" by the rest of the code and is necessary for some
+ // features such as chat windows.
+ if (!channel) {
+ channel = (await this.async(() =>
+ this.env.models['mail.thread'].performRpcChannelInfo({ ids: [channelId] })
+ ))[0];
+ }
+ if (!channel.isPinned) {
+ channel.pin();
+ }
+
+ const message = this.env.models['mail.message'].insert(convertedData);
+ this._notifyThreadViewsMessageReceived(message);
+
+ // If the message was already known: nothing else should be done,
+ // except if it was pending moderation by the current partner, then
+ // decrement the moderation counter.
+ if (oldMessage) {
+ if (
+ oldMessageModerationStatus === 'pending_moderation' &&
+ message.moderation_status !== 'pending_moderation' &&
+ oldMessageWasModeratedByCurrentPartner
+ ) {
+ const moderation = this.env.messaging.moderation;
+ moderation.update({ counter: decrement() });
+ }
+ return;
+ }
+
+ // If the current partner is author, do nothing else.
+ if (message.author === this.env.messaging.currentPartner) {
+ return;
+ }
+
+ // Message from mailing channel should not make a notification in
+ // Odoo for users with notification "Handled by Email".
+ // Channel has been marked as read server-side in this case, so
+ // it should not display a notification by incrementing the
+ // unread counter.
+ if (
+ channel.mass_mailing &&
+ this.env.session.notification_type === 'email'
+ ) {
+ this._handleNotificationChannelSeen(channelId, {
+ last_message_id: messageData.id,
+ partner_id: this.env.messaging.currentPartner.id,
+ });
+ return;
+ }
+ // In all other cases: update counter and notify if necessary
+
+ // Chat from OdooBot is considered disturbing and should only be
+ // shown on the menu, but no notification and no thread open.
+ const isChatWithOdooBot = (
+ channel.correspondent &&
+ channel.correspondent === this.env.messaging.partnerRoot
+ );
+ if (!isChatWithOdooBot) {
+ const isOdooFocused = this.env.services['bus_service'].isOdooFocused();
+ // Notify if out of focus
+ if (!isOdooFocused && channel.isChatChannel) {
+ this._notifyNewChannelMessageWhileOutOfFocus({
+ channel,
+ message,
+ });
+ }
+ if (channel.model === 'mail.channel' && channel.channel_type !== 'channel') {
+ // disabled on non-channel threads and
+ // on `channel` channels for performance reasons
+ channel.markAsFetched();
+ }
+ // open chat on receiving new message if it was not already opened or folded
+ if (channel.channel_type !== 'channel' && !this.env.messaging.device.isMobile && !channel.chatWindow) {
+ this.env.messaging.chatWindowManager.openThread(channel);
+ }
+ }
+
+ // If the channel wasn't known its correct counter was fetched at
+ // the start of the method, no need update it here.
+ if (!wasChannelExisting) {
+ return;
+ }
+ // manually force recompute of counter
+ this.messaging.messagingMenu.update();
+ }
+
+ /**
+ * Called when a channel has been seen, and the server responds with the
+ * last message seen. Useful in order to track last message seen.
+ *
+ * @private
+ * @param {integer} channelId
+ * @param {Object} param1
+ * @param {integer} param1.last_message_id
+ * @param {integer} param1.partner_id
+ */
+ async _handleNotificationChannelSeen(channelId, {
+ last_message_id,
+ partner_id,
+ }) {
+ const channel = this.env.models['mail.thread'].findFromIdentifyingData({
+ id: channelId,
+ model: 'mail.channel',
+ });
+ if (!channel) {
+ // for example seen from another browser, the current one has no
+ // knowledge of the channel
+ return;
+ }
+ const lastMessage = this.env.models['mail.message'].insert({ id: last_message_id });
+ // restrict computation of seen indicator for "non-channel" channels
+ // for performance reasons
+ const shouldComputeSeenIndicators = channel.channel_type !== 'channel';
+ const updateData = {};
+ if (shouldComputeSeenIndicators) {
+ this.env.models['mail.thread_partner_seen_info'].insert({
+ channelId: channel.id,
+ lastSeenMessage: [['link', lastMessage]],
+ partnerId: partner_id,
+ });
+ Object.assign(updateData, {
+ // FIXME should no longer use computeId (task-2335647)
+ messageSeenIndicators: [['insert',
+ {
+ channelId: channel.id,
+ messageId: lastMessage.id,
+ },
+ ]],
+ });
+ }
+ if (this.env.messaging.currentPartner.id === partner_id) {
+ Object.assign(updateData, {
+ lastSeenByCurrentPartnerMessageId: last_message_id,
+ pendingSeenMessageId: undefined,
+ });
+ }
+ channel.update(updateData);
+ if (shouldComputeSeenIndicators) {
+ // FIXME force the computing of thread values (cf task-2261221)
+ this.env.models['mail.thread'].computeLastCurrentPartnerMessageSeenByEveryone(channel);
+ // FIXME force the computing of message values (cf task-2261221)
+ this.env.models['mail.message_seen_indicator'].recomputeSeenValues(channel);
+ }
+ // manually force recompute of counter
+ this.messaging.messagingMenu.update();
+ }
+
+ /**
+ * @private
+ * @param {integer} channelId
+ * @param {Object} param1
+ * @param {boolean} param1.is_typing
+ * @param {integer} param1.partner_id
+ * @param {string} param1.partner_name
+ */
+ _handleNotificationChannelTypingStatus(channelId, { is_typing, partner_id, partner_name }) {
+ const channel = this.env.models['mail.thread'].findFromIdentifyingData({
+ id: channelId,
+ model: 'mail.channel',
+ });
+ if (!channel) {
+ return;
+ }
+ const partner = this.env.models['mail.partner'].insert({
+ id: partner_id,
+ name: partner_name,
+ });
+ if (partner === this.env.messaging.currentPartner) {
+ // Ignore management of current partner is typing notification.
+ return;
+ }
+ if (is_typing) {
+ if (channel.typingMembers.includes(partner)) {
+ channel.refreshOtherMemberTypingMember(partner);
+ } else {
+ channel.registerOtherMemberTypingMember(partner);
+ }
+ } else {
+ if (!channel.typingMembers.includes(partner)) {
+ // Ignore no longer typing notifications of members that
+ // are not registered as typing something.
+ return;
+ }
+ channel.unregisterOtherMemberTypingMember(partner);
+ }
+ }
+
+ /**
+ * @private
+ * @param {Object} data
+ */
+ _handleNotificationNeedaction(data) {
+ const message = this.env.models['mail.message'].insert(
+ this.env.models['mail.message'].convertData(data)
+ );
+ this.env.messaging.inbox.update({ counter: increment() });
+ const originThread = message.originThread;
+ if (originThread && message.isNeedaction) {
+ originThread.update({ message_needaction_counter: increment() });
+ }
+ // manually force recompute of counter
+ this.messaging.messagingMenu.update();
+ }
+
+ /**
+ * @private
+ * @param {Object} data
+ * @param {string} [data.info]
+ * @param {string} [data.type]
+ */
+ async _handleNotificationPartner(data) {
+ const {
+ info,
+ type,
+ } = data;
+ if (type === 'activity_updated') {
+ this.env.bus.trigger('activity_updated', data);
+ } else if (type === 'author') {
+ return this._handleNotificationPartnerAuthor(data);
+ } else if (info === 'channel_seen') {
+ return this._handleNotificationChannelSeen(data.channel_id, data);
+ } else if (type === 'deletion') {
+ return this._handleNotificationPartnerDeletion(data);
+ } else if (type === 'message_notification_update') {
+ return this._handleNotificationPartnerMessageNotificationUpdate(data.elements);
+ } else if (type === 'mark_as_read') {
+ return this._handleNotificationPartnerMarkAsRead(data);
+ } else if (type === 'moderator') {
+ return this._handleNotificationPartnerModerator(data);
+ } else if (type === 'simple_notification') {
+ const escapedMessage = owl.utils.escape(data.message);
+ this.env.services['notification'].notify({
+ message: escapedMessage,
+ sticky: data.sticky,
+ type: data.warning ? 'warning' : 'danger',
+ });
+ } else if (type === 'toggle_star') {
+ return this._handleNotificationPartnerToggleStar(data);
+ } else if (info === 'transient_message') {
+ return this._handleNotificationPartnerTransientMessage(data);
+ } else if (info === 'unsubscribe') {
+ return this._handleNotificationPartnerUnsubscribe(data.id);
+ } else if (type === 'user_connection') {
+ return this._handleNotificationPartnerUserConnection(data);
+ } else if (!type) {
+ return this._handleNotificationPartnerChannel(data);
+ }
+ }
+
+ /**
+ * @private
+ * @param {Object} data
+ * @param {Object} data.message
+ */
+ _handleNotificationPartnerAuthor(data) {
+ this.env.models['mail.message'].insert(
+ this.env.models['mail.message'].convertData(data.message)
+ );
+ }
+
+ /**
+ * @private
+ * @param {Object} data
+ * @param {string} data.channel_type
+ * @param {integer} data.id
+ * @param {string} [data.info]
+ * @param {boolean} data.is_minimized
+ * @param {string} data.name
+ * @param {string} data.state
+ * @param {string} data.uuid
+ */
+ _handleNotificationPartnerChannel(data) {
+ const convertedData = this.env.models['mail.thread'].convertData(
+ Object.assign({ model: 'mail.channel' }, data)
+ );
+ if (!convertedData.members) {
+ // channel_info does not return all members of channel for
+ // performance reasons, but code is expecting to know at
+ // least if the current partner is member of it.
+ // (e.g. to know when to display "invited" notification)
+ // Current partner can always be assumed to be a member of
+ // channels received through this notification.
+ convertedData.members = [['link', this.env.messaging.currentPartner]];
+ }
+ let channel = this.env.models['mail.thread'].findFromIdentifyingData(convertedData);
+ const wasCurrentPartnerMember = (
+ channel &&
+ channel.members.includes(this.env.messaging.currentPartner)
+ );
+
+ channel = this.env.models['mail.thread'].insert(convertedData);
+ if (
+ channel.channel_type === 'channel' &&
+ data.info !== 'creation' &&
+ !wasCurrentPartnerMember
+ ) {
+ this.env.services['notification'].notify({
+ message: _.str.sprintf(
+ this.env._t("You have been invited to: %s"),
+ owl.utils.escape(channel.name)
+ ),
+ type: 'warning',
+ });
+ }
+ // a new thread with unread messages could have been added
+ // manually force recompute of counter
+ this.messaging.messagingMenu.update();
+ }
+
+ /**
+ * @private
+ * @param {Object} param0
+ * @param {integer[]} param0.messag_ids
+ */
+ _handleNotificationPartnerDeletion({ message_ids }) {
+ const moderationMailbox = this.env.messaging.moderation;
+ for (const id of message_ids) {
+ const message = this.env.models['mail.message'].findFromIdentifyingData({ id });
+ if (message) {
+ if (
+ message.moderation_status === 'pending_moderation' &&
+ message.originThread.isModeratedByCurrentPartner
+ ) {
+ moderationMailbox.update({ counter: decrement() });
+ }
+ message.delete();
+ }
+ }
+ // deleting message might have deleted notifications, force recompute
+ this.messaging.notificationGroupManager.computeGroups();
+ // manually force recompute of counter (after computing the groups)
+ this.messaging.messagingMenu.update();
+ }
+
+ /**
+ * @private
+ * @param {Object} data
+ */
+ _handleNotificationPartnerMessageNotificationUpdate(data) {
+ for (const messageData of data) {
+ const message = this.env.models['mail.message'].insert(
+ this.env.models['mail.message'].convertData(messageData)
+ );
+ // implicit: failures are sent by the server as notification
+ // only if the current partner is author of the message
+ if (!message.author && this.messaging.currentPartner) {
+ message.update({ author: [['link', this.messaging.currentPartner]] });
+ }
+ }
+ this.messaging.notificationGroupManager.computeGroups();
+ // manually force recompute of counter (after computing the groups)
+ this.messaging.messagingMenu.update();
+ }
+
+ /**
+ * @private
+ * @param {Object} param0
+ * @param {integer[]} [param0.channel_ids
+ * @param {integer[]} [param0.message_ids=[]]
+ * @param {integer} [param0.needaction_inbox_counter]
+ */
+ _handleNotificationPartnerMarkAsRead({ channel_ids, message_ids = [], needaction_inbox_counter }) {
+ for (const message_id of message_ids) {
+ // We need to ignore all not yet known messages because we don't want them
+ // to be shown partially as they would be linked directly to mainCache
+ // Furthermore, server should not send back all message_ids marked as read
+ // but something like last read message_id or something like that.
+ // (just imagine you mark 1000 messages as read ... )
+ const message = this.env.models['mail.message'].findFromIdentifyingData({ id: message_id });
+ if (!message) {
+ continue;
+ }
+ // update thread counter
+ const originThread = message.originThread;
+ if (originThread && message.isNeedaction) {
+ originThread.update({ message_needaction_counter: decrement() });
+ }
+ // move messages from Inbox to history
+ message.update({
+ isHistory: true,
+ isNeedaction: false,
+ });
+ }
+ const inbox = this.env.messaging.inbox;
+ if (needaction_inbox_counter !== undefined) {
+ inbox.update({ counter: needaction_inbox_counter });
+ } else {
+ // kept for compatibility in stable
+ inbox.update({ counter: decrement(message_ids.length) });
+ }
+ if (inbox.counter > inbox.mainCache.fetchedMessages.length) {
+ // Force refresh Inbox because depending on what was marked as
+ // read the cache might become empty even though there are more
+ // messages on the server.
+ inbox.mainCache.update({ hasToLoadMessages: true });
+ }
+ // manually force recompute of counter
+ this.messaging.messagingMenu.update();
+ }
+
+ /**
+ * @private
+ * @param {Object} param0
+ * @param {Object} param0.message
+ */
+ _handleNotificationPartnerModerator({ message: data }) {
+ this.env.models['mail.message'].insert(
+ this.env.models['mail.message'].convertData(data)
+ );
+ const moderationMailbox = this.env.messaging.moderation;
+ if (moderationMailbox) {
+ moderationMailbox.update({ counter: increment() });
+ }
+ // manually force recompute of counter
+ this.messaging.messagingMenu.update();
+ }
+
+ /**
+ * @private
+ * @param {Object} param0
+ * @param {integer[]} param0.message_ids
+ * @param {boolean} param0.starred
+ */
+ _handleNotificationPartnerToggleStar({ message_ids = [], starred }) {
+ const starredMailbox = this.env.messaging.starred;
+ for (const messageId of message_ids) {
+ const message = this.env.models['mail.message'].findFromIdentifyingData({
+ id: messageId,
+ });
+ if (!message) {
+ continue;
+ }
+ message.update({ isStarred: starred });
+ starredMailbox.update({
+ counter: starred ? increment() : decrement(),
+ });
+ }
+ }
+
+ /**
+ * On receiving a transient message, i.e. a message which does not come
+ * from a member of the channel. Usually a log message, such as one
+ * generated from a command with ('/').
+ *
+ * @private
+ * @param {Object} data
+ */
+ _handleNotificationPartnerTransientMessage(data) {
+ const convertedData = this.env.models['mail.message'].convertData(data);
+ const lastMessageId = this.env.models['mail.message'].all().reduce(
+ (lastMessageId, message) => Math.max(lastMessageId, message.id),
+ 0
+ );
+ const partnerRoot = this.env.messaging.partnerRoot;
+ const message = this.env.models['mail.message'].create(Object.assign(convertedData, {
+ author: [['link', partnerRoot]],
+ id: lastMessageId + 0.01,
+ isTransient: true,
+ }));
+ this._notifyThreadViewsMessageReceived(message);
+ // manually force recompute of counter
+ this.messaging.messagingMenu.update();
+ }
+
+ /**
+ * @private
+ * @param {integer} channelId
+ */
+ _handleNotificationPartnerUnsubscribe(channelId) {
+ const channel = this.env.models['mail.thread'].findFromIdentifyingData({
+ id: channelId,
+ model: 'mail.channel',
+ });
+ if (!channel) {
+ return;
+ }
+ let message;
+ if (channel.correspondent) {
+ const correspondent = channel.correspondent;
+ message = _.str.sprintf(
+ this.env._t("You unpinned your conversation with <b>%s</b>."),
+ owl.utils.escape(correspondent.name)
+ );
+ } else {
+ message = _.str.sprintf(
+ this.env._t("You unsubscribed from <b>%s</b>."),
+ owl.utils.escape(channel.name)
+ );
+ }
+ // We assume that arriving here the server has effectively
+ // unpinned the channel
+ channel.update({ isServerPinned: false });
+ this.env.services['notification'].notify({
+ message,
+ type: 'warning',
+ });
+ }
+
+ /**
+ * @private
+ * @param {Object} param0
+ * @param {string} param0.message
+ * @param {integer} param0.partner_id
+ * @param {string} param0.title
+ */
+ async _handleNotificationPartnerUserConnection({ message, partner_id, title }) {
+ // If the current user invited a new user, and the new user is
+ // connecting for the first time while the current user is present
+ // then open a chat for the current user with the new user.
+ this.env.services['bus_service'].sendNotification(title, message);
+ const chat = await this.async(() =>
+ this.env.messaging.getChat({ partnerId: partner_id }
+ ));
+ if (!chat || this.env.messaging.device.isMobile) {
+ return;
+ }
+ this.env.messaging.chatWindowManager.openThread(chat);
+ }
+
+ /**
+ * @private
+ * @param {Object} param0
+ * @param {mail.thread} param0.channel
+ * @param {mail.message} param0.message
+ */
+ _notifyNewChannelMessageWhileOutOfFocus({ channel, message }) {
+ const author = message.author;
+ const messaging = this.env.messaging;
+ let notificationTitle;
+ if (!author) {
+ notificationTitle = this.env._t("New message");
+ } else {
+ const authorName = author.nameOrDisplayName;
+ if (channel.channel_type === 'channel') {
+ // hack: notification template does not support OWL components,
+ // so we simply use their template to make HTML as if it comes
+ // from component
+ const channelIcon = this.env.qweb.renderToString('mail.ThreadIcon', {
+ env: this.env,
+ thread: channel,
+ });
+ const channelName = owl.utils.escape(channel.displayName);
+ const channelNameWithIcon = channelIcon + channelName;
+ notificationTitle = _.str.sprintf(
+ this.env._t("%s from %s"),
+ owl.utils.escape(authorName),
+ channelNameWithIcon
+ );
+ } else {
+ notificationTitle = owl.utils.escape(authorName);
+ }
+ }
+ const notificationContent = htmlToTextContentInline(message.body).substr(0, PREVIEW_MSG_MAX_SIZE);
+ this.env.services['bus_service'].sendNotification(notificationTitle, notificationContent);
+ messaging.update({ outOfFocusUnreadMessageCounter: increment() });
+ const titlePattern = messaging.outOfFocusUnreadMessageCounter === 1
+ ? this.env._t("%d Message")
+ : this.env._t("%d Messages");
+ this.env.bus.trigger('set_title_part', {
+ part: '_chat',
+ title: _.str.sprintf(titlePattern, messaging.outOfFocusUnreadMessageCounter),
+ });
+ }
+
+ /**
+ * Notifies threadViews about the given message being just received.
+ * This can allow them adjust their scroll position if applicable.
+ *
+ * @private
+ * @param {mail.message}
+ */
+ _notifyThreadViewsMessageReceived(message) {
+ for (const thread of message.threads) {
+ for (const threadView of thread.threadViews) {
+ threadView.addComponentHint('message-received', { message });
+ }
+ }
+ }
+
+ }
+
+ MessagingNotificationHandler.fields = {
+ messaging: one2one('mail.messaging', {
+ inverse: 'notificationHandler',
+ }),
+ };
+
+ MessagingNotificationHandler.modelName = 'mail.messaging_notification_handler';
+
+ return MessagingNotificationHandler;
+}
+
+registerNewModel('mail.messaging_notification_handler', factory);
+
+});
diff --git a/addons/mail/static/src/models/model/model.js b/addons/mail/static/src/models/model/model.js
new file mode 100644
index 00000000..3696332a
--- /dev/null
+++ b/addons/mail/static/src/models/model/model.js
@@ -0,0 +1,291 @@
+odoo.define('mail/static/src/models/Model', function (require) {
+'use strict';
+
+const { registerNewModel } = require('mail/static/src/model/model_core.js');
+const { RecordDeletedError } = require('mail/static/src/model/model_errors.js');
+
+/**
+ * This function generates a class that represent a model. Instances of such
+ * model (or inherited models) represent logical objects used in whole
+ * application. They could represent server record (e.g. Thread, Message) or
+ * UI elements (e.g. MessagingMenu, ChatWindow). These instances are called
+ * "records", while the classes are called "models".
+ */
+function factory() {
+
+ class Model {
+
+ /**
+ * @param {Object} [param0={}]
+ * @param {boolean} [param0.valid=false] if set, this constructor is
+ * called by static method `create()`. This should always be the case.
+ * @throws {Error} in case constructor is called in an invalid way, i.e.
+ * by instantiating the record manually with `new` instead of from
+ * static method `create()`.
+ */
+ constructor({ valid = false } = {}) {
+ if (!valid) {
+ throw new Error("Record must always be instantiated from static method 'create()'");
+ }
+ }
+
+ /**
+ * This function is called during the create cycle, when the record has
+ * already been created, but its values have not yet been assigned.
+ *
+ * It is usually preferable to override @see `_created`.
+ *
+ * The main use case is to prepare the record for the assignation of its
+ * values, for example if a computed field relies on the record to have
+ * some purely technical property correctly set.
+ *
+ * @abstract
+ * @private
+ */
+ _willCreate() {}
+
+ /**
+ * This function is called after the record has been created, more
+ * precisely at the end of the update cycle (which means all implicit
+ * changes such as computes have been applied too).
+ *
+ * The main use case is to register listeners on the record.
+ *
+ * @abstract
+ * @private
+ */
+ _created() {}
+
+ /**
+ * This function is called when the record is about to be deleted. The
+ * record still has all of its fields values accessible, but for all
+ * intents and purposes the record should already be considered
+ * deleted, which means update shouldn't be called inside this method.
+ *
+ * The main use case is to unregister listeners on the record.
+ *
+ * @abstract
+ * @private
+ */
+ _willDelete() {}
+
+ //----------------------------------------------------------------------
+ // Public
+ //----------------------------------------------------------------------
+
+ /**
+ * Returns all records of this model that match provided criteria.
+ *
+ * @static
+ * @param {function} [filterFunc]
+ * @returns {mail.model[]}
+ */
+ static all(filterFunc) {
+ return this.env.modelManager.all(this, filterFunc);
+ }
+
+ /**
+ * This method is used to create new records of this model
+ * with provided data. This is the only way to create them:
+ * instantiation must never been done with keyword `new` outside of this
+ * function, otherwise the record will not be registered.
+ *
+ * @static
+ * @param {Object|Object[]} [data] data object with initial data, including relations.
+ * If data is an iterable, multiple records will be created.
+ * @returns {mail.model|mail.model[]} newly created record(s)
+ */
+ static create(data) {
+ return this.env.modelManager.create(this, data);
+ }
+
+ /**
+ * Get the record that has provided criteria, if it exists.
+ *
+ * @static
+ * @param {function} findFunc
+ * @returns {mail.model|undefined}
+ */
+ static find(findFunc) {
+ return this.env.modelManager.find(this, findFunc);
+ }
+
+ /**
+ * Gets the unique record that matches the given identifying data, if it
+ * exists.
+ * @see `_createRecordLocalId` for criteria of identification.
+ *
+ * @static
+ * @param {Object} data
+ * @returns {mail.model|undefined}
+ */
+ static findFromIdentifyingData(data) {
+ return this.env.modelManager.findFromIdentifyingData(this, data);
+ }
+
+ /**
+ * This method returns the record of this model that matches provided
+ * local id. Useful to convert a local id to a record. Note that even
+ * if there's a record in the system having provided local id, if the
+ * resulting record is not an instance of this model, this getter
+ * assumes the record does not exist.
+ *
+ * @static
+ * @param {string} localId
+ * @param {Object} param1
+ * @param {boolean} [param1.isCheckingInheritance]
+ * @returns {mail.model|undefined}
+ */
+ static get(localId, { isCheckingInheritance } = {}) {
+ return this.env.modelManager.get(this, localId, { isCheckingInheritance });
+ }
+
+ /**
+ * This method creates a record or updates one, depending
+ * on provided data.
+ *
+ * @static
+ * @param {Object|Object[]} data
+ * If data is an iterable, multiple records will be created/updated.
+ * @returns {mail.model|mail.model[]} created or updated record(s).
+ */
+ static insert(data) {
+ return this.env.modelManager.insert(this, data);
+ }
+
+ /**
+ * Perform an async function and wait until it is done. If the record
+ * is deleted, it raises a RecordDeletedError.
+ *
+ * @param {function} func an async function
+ * @throws {RecordDeletedError} in case the current record is not alive
+ * at the end of async function call, whether it's resolved or
+ * rejected.
+ * @throws {any} forwards any error in case the current record is still
+ * alive at the end of rejected async function call.
+ * @returns {any} result of resolved async function.
+ */
+ async async(func) {
+ return new Promise((resolve, reject) => {
+ Promise.resolve(func()).then(result => {
+ if (this.exists()) {
+ resolve(result);
+ } else {
+ reject(new RecordDeletedError(this.localId));
+ }
+ }).catch(error => {
+ if (this.exists()) {
+ reject(error);
+ } else {
+ reject(new RecordDeletedError(this.localId));
+ }
+ });
+ });
+ }
+
+ /**
+ * This method deletes this record.
+ */
+ delete() {
+ this.env.modelManager.delete(this);
+ }
+
+ /**
+ * Returns whether the current record exists.
+ *
+ * @returns {boolean}
+ */
+ exists() {
+ return this.env.modelManager.exists(this.constructor, this);
+ }
+
+ /**
+ * Update this record with provided data.
+ *
+ * @param {Object} [data={}]
+ */
+ update(data = {}) {
+ this.env.modelManager.update(this, data);
+ }
+
+ //----------------------------------------------------------------------
+ // Private
+ //----------------------------------------------------------------------
+
+ /**
+ * This method generates a local id for this record that is
+ * being created at the moment.
+ *
+ * This function helps customizing the local id to ease mapping a local
+ * id to its record for the developer that reads the local id. For
+ * instance, the local id of a thread cache could combine the thread
+ * and stringified domain in its local id, which is much easier to
+ * track relations and records in the system instead of arbitrary
+ * number to differenciate them.
+ *
+ * @static
+ * @private
+ * @param {Object} data
+ * @returns {string}
+ */
+ static _createRecordLocalId(data) {
+ return _.uniqueId(`${this.modelName}_`);
+ }
+
+ /**
+ * This function is called when this record has been explicitly updated
+ * with `.update()` or static method `.create()`, at the end of an
+ * record update cycle. This is a backward-compatible behaviour that
+ * is deprecated: you should use computed fields instead.
+ *
+ * @deprecated
+ * @abstract
+ * @private
+ * @param {Object} previous contains data that have been stored by
+ * `_updateBefore()`. Useful to make extra update decisions based on
+ * previous data.
+ */
+ _updateAfter(previous) {}
+
+ /**
+ * This function is called just at the beginning of an explicit update
+ * on this function, with `.update()` or static method `.create()`. This
+ * is useful to remember previous values of fields in `_updateAfter`.
+ * This is a backward-compatible behaviour that is deprecated: you
+ * should use computed fields instead.
+ *
+ * @deprecated
+ * @abstract
+ * @private
+ * @param {Object} data
+ * @returns {Object}
+ */
+ _updateBefore() {
+ return {};
+ }
+
+ }
+
+ /**
+ * Models should define fields in static prop or getter `fields`.
+ * It contains an object with name of field as key and value are objects
+ * that define the field. There are some helpers to ease the making of these
+ * objects, @see `mail/static/src/model/model_field.js`
+ *
+ * Note: fields of super-class are automatically inherited, therefore a
+ * sub-class should (re-)define fields without copying ancestors' fields.
+ */
+ Model.fields = {};
+
+ /**
+ * Name of the model. Important to refer to appropriate model class
+ * like in relational fields. Name of model classes must be unique.
+ */
+ Model.modelName = 'mail.model';
+
+ return Model;
+}
+
+registerNewModel('mail.model', factory);
+
+});
diff --git a/addons/mail/static/src/models/notification/notification.js b/addons/mail/static/src/models/notification/notification.js
new file mode 100644
index 00000000..047faee9
--- /dev/null
+++ b/addons/mail/static/src/models/notification/notification.js
@@ -0,0 +1,80 @@
+odoo.define('mail/static/src/models/notification/notification.js', function (require) {
+'use strict';
+
+const { registerNewModel } = require('mail/static/src/model/model_core.js');
+const { attr, many2one } = require('mail/static/src/model/model_field.js');
+
+function factory(dependencies) {
+
+ class Notification extends dependencies['mail.model'] {
+
+ //----------------------------------------------------------------------
+ // Public
+ //----------------------------------------------------------------------
+
+ /**
+ * @static
+ * @param {Object} data
+ * @return {Object}
+ */
+ static convertData(data) {
+ const data2 = {};
+ if ('failure_type' in data) {
+ data2.failure_type = data.failure_type;
+ }
+ if ('id' in data) {
+ data2.id = data.id;
+ }
+ if ('notification_status' in data) {
+ data2.notification_status = data.notification_status;
+ }
+ if ('notification_type' in data) {
+ data2.notification_type = data.notification_type;
+ }
+ if ('res_partner_id' in data) {
+ if (!data.res_partner_id) {
+ data2.partner = [['unlink-all']];
+ } else {
+ data2.partner = [
+ ['insert', {
+ display_name: data.res_partner_id[1],
+ id: data.res_partner_id[0],
+ }],
+ ];
+ }
+ }
+ return data2;
+ }
+
+ //----------------------------------------------------------------------
+ // Private
+ //----------------------------------------------------------------------
+
+ /**
+ * @override
+ */
+ static _createRecordLocalId(data) {
+ return `${this.modelName}_${data.id}`;
+ }
+
+ }
+
+ Notification.fields = {
+ failure_type: attr(),
+ id: attr(),
+ message: many2one('mail.message', {
+ inverse: 'notifications',
+ }),
+ notification_status: attr(),
+ notification_type: attr(),
+ partner: many2one('mail.partner'),
+ };
+
+ Notification.modelName = 'mail.notification';
+
+ return Notification;
+}
+
+registerNewModel('mail.notification', factory);
+
+});
diff --git a/addons/mail/static/src/models/notification_group/notification_group.js b/addons/mail/static/src/models/notification_group/notification_group.js
new file mode 100644
index 00000000..89111a4e
--- /dev/null
+++ b/addons/mail/static/src/models/notification_group/notification_group.js
@@ -0,0 +1,126 @@
+odoo.define('mail/static/src/models/notification_group/notification_group.js', function (require) {
+'use strict';
+
+const { registerNewModel } = require('mail/static/src/model/model_core.js');
+const { attr, many2one, one2many } = require('mail/static/src/model/model_field.js');
+
+function factory(dependencies) {
+
+ class NotificationGroup extends dependencies['mail.model'] {
+
+ //----------------------------------------------------------------------
+ // Public
+ //----------------------------------------------------------------------
+
+ /**
+ * Opens the view that allows to cancel all notifications of the group.
+ */
+ openCancelAction() {
+ if (this.notification_type !== 'email') {
+ return;
+ }
+ this.env.bus.trigger('do-action', {
+ action: 'mail.mail_resend_cancel_action',
+ options: {
+ additional_context: {
+ default_model: this.res_model,
+ unread_counter: this.notifications.length,
+ },
+ },
+ });
+ }
+
+ /**
+ * Opens the view that displays either the single record of the group or
+ * all the records in the group.
+ */
+ openDocuments() {
+ if (this.thread) {
+ this.thread.open();
+ } else {
+ this._openDocuments();
+ }
+ }
+
+ //----------------------------------------------------------------------
+ // Private
+ //----------------------------------------------------------------------
+
+ /**
+ * @private
+ * @returns {mail.thread|undefined}
+ */
+ _computeThread() {
+ if (this.res_id) {
+ return [['insert', {
+ id: this.res_id,
+ model: this.res_model,
+ }]];
+ }
+ return [['unlink']];
+ }
+
+ /**
+ * @override
+ */
+ static _createRecordLocalId(data) {
+ return `${this.modelName}_${data.id}`;
+ }
+
+ /**
+ * Opens the view that displays all the records of the group.
+ *
+ * @private
+ */
+ _openDocuments() {
+ if (this.notification_type !== 'email') {
+ return;
+ }
+ this.env.bus.trigger('do-action', {
+ action: {
+ name: this.env._t("Mail Failures"),
+ type: 'ir.actions.act_window',
+ view_mode: 'kanban,list,form',
+ views: [[false, 'kanban'], [false, 'list'], [false, 'form']],
+ target: 'current',
+ res_model: this.res_model,
+ domain: [['message_has_error', '=', true]],
+ },
+ });
+ if (this.env.messaging.device.isMobile) {
+ // messaging menu has a higher z-index than views so it must
+ // be closed to ensure the visibility of the view
+ this.env.messaging.messagingMenu.close();
+ }
+ }
+
+ }
+
+ NotificationGroup.fields = {
+ date: attr(),
+ id: attr(),
+ notification_type: attr(),
+ notifications: one2many('mail.notification'),
+ res_id: attr(),
+ res_model: attr(),
+ res_model_name: attr(),
+ /**
+ * Related thread when the notification group concerns a single thread.
+ */
+ thread: many2one('mail.thread', {
+ compute: '_computeThread',
+ dependencies: [
+ 'res_id',
+ 'res_model',
+ ],
+ })
+ };
+
+ NotificationGroup.modelName = 'mail.notification_group';
+
+ return NotificationGroup;
+}
+
+registerNewModel('mail.notification_group', factory);
+
+});
diff --git a/addons/mail/static/src/models/notification_group_manager/notification_group_manager.js b/addons/mail/static/src/models/notification_group_manager/notification_group_manager.js
new file mode 100644
index 00000000..9c7c38ef
--- /dev/null
+++ b/addons/mail/static/src/models/notification_group_manager/notification_group_manager.js
@@ -0,0 +1,77 @@
+odoo.define('mail/static/src/models/notification_group_manager/notification_group_manager.js', function (require) {
+'use strict';
+
+const { registerNewModel } = require('mail/static/src/model/model_core.js');
+const { one2many } = require('mail/static/src/model/model_field.js');
+
+function factory(dependencies) {
+
+ class NotificationGroupManager extends dependencies['mail.model'] {
+
+ //----------------------------------------------------------------------
+ // Public
+ //----------------------------------------------------------------------
+
+ computeGroups() {
+ for (const group of this.groups) {
+ group.delete();
+ }
+ const groups = [];
+ // TODO batch insert, better logic task-2258605
+ this.env.messaging.currentPartner.failureNotifications.forEach(notification => {
+ const thread = notification.message.originThread;
+ // Notifications are grouped by model and notification_type.
+ // Except for channel where they are also grouped by id because
+ // we want to open the actual channel in discuss or chat window
+ // and not its kanban/list/form view.
+ const channelId = thread.model === 'mail.channel' ? thread.id : null;
+ const id = `${thread.model}/${channelId}/${notification.notification_type}`;
+ const group = this.env.models['mail.notification_group'].insert({
+ id,
+ notification_type: notification.notification_type,
+ res_model: thread.model,
+ res_model_name: thread.model_name,
+ });
+ group.update({ notifications: [['link', notification]] });
+ // keep res_id only if all notifications are for the same record
+ // set null if multiple records are present in the group
+ let res_id = group.res_id;
+ if (group.res_id === undefined) {
+ res_id = thread.id;
+ } else if (group.res_id !== thread.id) {
+ res_id = null;
+ }
+ // keep only the most recent date from all notification messages
+ let date = group.date;
+ if (!date) {
+ date = notification.message.date;
+ } else {
+ date = moment.max(group.date, notification.message.date);
+ }
+ group.update({
+ date,
+ res_id,
+ });
+ // avoid linking the same group twice when adding a notification
+ // to an existing group
+ if (!groups.includes(group)) {
+ groups.push(group);
+ }
+ });
+ this.update({ groups: [['link', groups]] });
+ }
+
+ }
+
+ NotificationGroupManager.fields = {
+ groups: one2many('mail.notification_group'),
+ };
+
+ NotificationGroupManager.modelName = 'mail.notification_group_manager';
+
+ return NotificationGroupManager;
+}
+
+registerNewModel('mail.notification_group_manager', factory);
+
+});
diff --git a/addons/mail/static/src/models/partner/partner.js b/addons/mail/static/src/models/partner/partner.js
new file mode 100644
index 00000000..4d007fb1
--- /dev/null
+++ b/addons/mail/static/src/models/partner/partner.js
@@ -0,0 +1,527 @@
+odoo.define('mail/static/src/models/partner/partner.js', function (require) {
+'use strict';
+
+const { registerNewModel } = require('mail/static/src/model/model_core.js');
+const { attr, many2many, many2one, one2many, one2one } = require('mail/static/src/model/model_field.js');
+const { cleanSearchTerm } = require('mail/static/src/utils/utils.js');
+
+function factory(dependencies) {
+
+ class Partner extends dependencies['mail.model'] {
+
+ //----------------------------------------------------------------------
+ // Public
+ //----------------------------------------------------------------------
+
+ /**
+ * @static
+ * @private
+ * @param {Object} data
+ * @return {Object}
+ */
+ static convertData(data) {
+ const data2 = {};
+ if ('active' in data) {
+ data2.active = data.active;
+ }
+ if ('country' in data) {
+ if (!data.country) {
+ data2.country = [['unlink-all']];
+ } else {
+ data2.country = [['insert', {
+ id: data.country[0],
+ name: data.country[1],
+ }]];
+ }
+ }
+ if ('display_name' in data) {
+ data2.display_name = data.display_name;
+ }
+ if ('email' in data) {
+ data2.email = data.email;
+ }
+ if ('id' in data) {
+ data2.id = data.id;
+ }
+ if ('im_status' in data) {
+ data2.im_status = data.im_status;
+ }
+ if ('name' in data) {
+ data2.name = data.name;
+ }
+
+ // relation
+ if ('user_id' in data) {
+ if (!data.user_id) {
+ data2.user = [['unlink-all']];
+ } else {
+ let user = {};
+ if (Array.isArray(data.user_id)) {
+ user = {
+ id: data.user_id[0],
+ display_name: data.user_id[1],
+ };
+ } else {
+ user = {
+ id: data.user_id,
+ };
+ }
+ user.isInternalUser = data.is_internal_user;
+ data2.user = [['insert', user]];
+ }
+ }
+
+ return data2;
+ }
+
+ /**
+ * Fetches partners matching the given search term to extend the
+ * JS knowledge and to update the suggestion list accordingly.
+ *
+ * @static
+ * @param {string} searchTerm
+ * @param {Object} [options={}]
+ * @param {mail.thread} [options.thread] prioritize and/or restrict
+ * result in the context of given thread
+ */
+ static async fetchSuggestions(searchTerm, { thread } = {}) {
+ const kwargs = { search: searchTerm };
+ const isNonPublicChannel = thread && thread.model === 'mail.channel' && thread.public !== 'public';
+ if (isNonPublicChannel) {
+ kwargs.channel_id = thread.id;
+ }
+ const [
+ mainSuggestedPartners,
+ extraSuggestedPartners,
+ ] = await this.env.services.rpc(
+ {
+ model: 'res.partner',
+ method: 'get_mention_suggestions',
+ kwargs,
+ },
+ { shadow: true },
+ );
+ const partnersData = mainSuggestedPartners.concat(extraSuggestedPartners);
+ const partners = this.env.models['mail.partner'].insert(partnersData.map(data =>
+ this.env.models['mail.partner'].convertData(data)
+ ));
+ if (isNonPublicChannel) {
+ thread.update({ members: [['link', partners]] });
+ }
+ }
+
+ /**
+ * Search for partners matching `keyword`.
+ *
+ * @static
+ * @param {Object} param0
+ * @param {function} param0.callback
+ * @param {string} param0.keyword
+ * @param {integer} [param0.limit=10]
+ */
+ static async imSearch({ callback, keyword, limit = 10 }) {
+ // prefetched partners
+ let partners = [];
+ const cleanedSearchTerm = cleanSearchTerm(keyword);
+ const currentPartner = this.env.messaging.currentPartner;
+ for (const partner of this.all(partner => partner.active)) {
+ if (partners.length < limit) {
+ if (
+ partner !== currentPartner &&
+ partner.name &&
+ partner.user &&
+ cleanSearchTerm(partner.name).includes(cleanedSearchTerm)
+ ) {
+ partners.push(partner);
+ }
+ }
+ }
+ if (!partners.length) {
+ const partnersData = await this.env.services.rpc(
+ {
+ model: 'res.partner',
+ method: 'im_search',
+ args: [keyword, limit]
+ },
+ { shadow: true }
+ );
+ const newPartners = this.insert(partnersData.map(
+ partnerData => this.convertData(partnerData)
+ ));
+ partners.push(...newPartners);
+ }
+ callback(partners);
+ }
+
+ /**
+ * Returns partners that match the given search term.
+ *
+ * @static
+ * @param {string} searchTerm
+ * @param {Object} [options={}]
+ * @param {mail.thread} [options.thread] prioritize and/or restrict
+ * result in the context of given thread
+ * @returns {[mail.partner[], mail.partner[]]}
+ */
+ static searchSuggestions(searchTerm, { thread } = {}) {
+ let partners;
+ const isNonPublicChannel = thread && thread.model === 'mail.channel' && thread.public !== 'public';
+ if (isNonPublicChannel) {
+ // Only return the channel members when in the context of a
+ // non-public channel. Indeed, the message with the mention
+ // would be notified to the mentioned partner, so this prevents
+ // from inadvertently leaking the private message to the
+ // mentioned partner.
+ partners = thread.members;
+ } else {
+ partners = this.env.models['mail.partner'].all();
+ }
+ const cleanedSearchTerm = cleanSearchTerm(searchTerm);
+ const mainSuggestionList = [];
+ const extraSuggestionList = [];
+ for (const partner of partners) {
+ if (
+ (!partner.active && partner !== this.env.messaging.partnerRoot) ||
+ partner.id <= 0 ||
+ this.env.messaging.publicPartners.includes(partner)
+ ) {
+ // ignore archived partners (except OdooBot), temporary
+ // partners (livechat guests), public partners (technical)
+ continue;
+ }
+ if (
+ (partner.nameOrDisplayName && cleanSearchTerm(partner.nameOrDisplayName).includes(cleanedSearchTerm)) ||
+ (partner.email && cleanSearchTerm(partner.email).includes(cleanedSearchTerm))
+ ) {
+ if (partner.user) {
+ mainSuggestionList.push(partner);
+ } else {
+ extraSuggestionList.push(partner);
+ }
+ }
+ }
+ return [mainSuggestionList, extraSuggestionList];
+ }
+
+ /**
+ * @static
+ */
+ static async startLoopFetchImStatus() {
+ await this._fetchImStatus();
+ this._loopFetchImStatus();
+ }
+
+ /**
+ * Checks whether this partner has a related user and links them if
+ * applicable.
+ */
+ async checkIsUser() {
+ const userIds = await this.async(() => this.env.services.rpc({
+ model: 'res.users',
+ method: 'search',
+ args: [[['partner_id', '=', this.id]]],
+ kwargs: {
+ context: { active_test: false },
+ },
+ }, { shadow: true }));
+ this.update({ hasCheckedUser: true });
+ if (userIds.length > 0) {
+ this.update({ user: [['insert', { id: userIds[0] }]] });
+ }
+ }
+
+ /**
+ * Gets the chat between the user of this partner and the current user.
+ *
+ * If a chat is not appropriate, a notification is displayed instead.
+ *
+ * @returns {mail.thread|undefined}
+ */
+ async getChat() {
+ if (!this.user && !this.hasCheckedUser) {
+ await this.async(() => this.checkIsUser());
+ }
+ // prevent chatting with non-users
+ if (!this.user) {
+ this.env.services['notification'].notify({
+ message: this.env._t("You can only chat with partners that have a dedicated user."),
+ type: 'info',
+ });
+ return;
+ }
+ return this.user.getChat();
+ }
+
+ /**
+ * Returns the text that identifies this partner in a mention.
+ *
+ * @returns {string}
+ */
+ getMentionText() {
+ return this.name;
+ }
+
+ /**
+ * Returns a sort function to determine the order of display of partners
+ * in the suggestion list.
+ *
+ * @static
+ * @param {string} searchTerm
+ * @param {Object} [options={}]
+ * @param {mail.thread} [options.thread] prioritize result in the
+ * context of given thread
+ * @returns {function}
+ */
+ static getSuggestionSortFunction(searchTerm, { thread } = {}) {
+ const cleanedSearchTerm = cleanSearchTerm(searchTerm);
+ return (a, b) => {
+ const isAInternalUser = a.user && a.user.isInternalUser;
+ const isBInternalUser = b.user && b.user.isInternalUser;
+ if (isAInternalUser && !isBInternalUser) {
+ return -1;
+ }
+ if (!isAInternalUser && isBInternalUser) {
+ return 1;
+ }
+ if (thread && thread.model === 'mail.channel') {
+ const isAMember = thread.members.includes(a);
+ const isBMember = thread.members.includes(b);
+ if (isAMember && !isBMember) {
+ return -1;
+ }
+ if (!isAMember && isBMember) {
+ return 1;
+ }
+ }
+ if (thread) {
+ const isAFollower = thread.followersPartner.includes(a);
+ const isBFollower = thread.followersPartner.includes(b);
+ if (isAFollower && !isBFollower) {
+ return -1;
+ }
+ if (!isAFollower && isBFollower) {
+ return 1;
+ }
+ }
+ const cleanedAName = cleanSearchTerm(a.nameOrDisplayName || '');
+ const cleanedBName = cleanSearchTerm(b.nameOrDisplayName || '');
+ if (cleanedAName.startsWith(cleanedSearchTerm) && !cleanedBName.startsWith(cleanedSearchTerm)) {
+ return -1;
+ }
+ if (!cleanedAName.startsWith(cleanedSearchTerm) && cleanedBName.startsWith(cleanedSearchTerm)) {
+ return 1;
+ }
+ if (cleanedAName < cleanedBName) {
+ return -1;
+ }
+ if (cleanedAName > cleanedBName) {
+ return 1;
+ }
+ const cleanedAEmail = cleanSearchTerm(a.email || '');
+ const cleanedBEmail = cleanSearchTerm(b.email || '');
+ if (cleanedAEmail.startsWith(cleanedSearchTerm) && !cleanedAEmail.startsWith(cleanedSearchTerm)) {
+ return -1;
+ }
+ if (!cleanedBEmail.startsWith(cleanedSearchTerm) && cleanedBEmail.startsWith(cleanedSearchTerm)) {
+ return 1;
+ }
+ if (cleanedAEmail < cleanedBEmail) {
+ return -1;
+ }
+ if (cleanedAEmail > cleanedBEmail) {
+ return 1;
+ }
+ return a.id - b.id;
+ };
+ }
+
+ /**
+ * Opens a chat between the user of this partner and the current user
+ * and returns it.
+ *
+ * If a chat is not appropriate, a notification is displayed instead.
+ *
+ * @param {Object} [options] forwarded to @see `mail.thread:open()`
+ * @returns {mail.thread|undefined}
+ */
+ async openChat(options) {
+ const chat = await this.async(() => this.getChat());
+ if (!chat) {
+ return;
+ }
+ await this.async(() => chat.open(options));
+ return chat;
+ }
+
+ /**
+ * Opens the most appropriate view that is a profile for this partner.
+ */
+ async openProfile() {
+ return this.env.messaging.openDocument({
+ id: this.id,
+ model: 'res.partner',
+ });
+ }
+
+ //----------------------------------------------------------------------
+ // Private
+ //----------------------------------------------------------------------
+
+ /**
+ * @private
+ * @returns {string}
+ */
+ _computeAvatarUrl() {
+ return `/web/image/res.partner/${this.id}/image_128`;
+ }
+
+ /**
+ * @override
+ */
+ static _createRecordLocalId(data) {
+ return `${this.modelName}_${data.id}`;
+ }
+
+ /**
+ * @static
+ * @private
+ */
+ static async _fetchImStatus() {
+ const partnerIds = [];
+ for (const partner of this.all()) {
+ if (partner.im_status !== 'im_partner' && partner.id > 0) {
+ partnerIds.push(partner.id);
+ }
+ }
+ if (partnerIds.length === 0) {
+ return;
+ }
+ const dataList = await this.env.services.rpc({
+ route: '/longpolling/im_status',
+ params: {
+ partner_ids: partnerIds,
+ },
+ }, { shadow: true });
+ this.insert(dataList);
+ }
+
+ /**
+ * @static
+ * @private
+ */
+ static _loopFetchImStatus() {
+ setTimeout(async () => {
+ await this._fetchImStatus();
+ this._loopFetchImStatus();
+ }, 50 * 1000);
+ }
+
+ /**
+ * @private
+ * @returns {string|undefined}
+ */
+ _computeDisplayName() {
+ return this.display_name || this.user && this.user.display_name;
+ }
+
+ /**
+ * @private
+ * @returns {mail.messaging}
+ */
+ _computeMessaging() {
+ return [['link', this.env.messaging]];
+ }
+
+ /**
+ * @private
+ * @returns {string|undefined}
+ */
+ _computeNameOrDisplayName() {
+ return this.name || this.display_name;
+ }
+
+ }
+
+ Partner.fields = {
+ active: attr({
+ default: true,
+ }),
+ avatarUrl: attr({
+ compute: '_computeAvatarUrl',
+ dependencies: [
+ 'id',
+ ],
+ }),
+ correspondentThreads: one2many('mail.thread', {
+ inverse: 'correspondent',
+ }),
+ country: many2one('mail.country'),
+ display_name: attr({
+ compute: '_computeDisplayName',
+ default: "",
+ dependencies: [
+ 'display_name',
+ 'userDisplayName',
+ ],
+ }),
+ email: attr(),
+ failureNotifications: one2many('mail.notification', {
+ related: 'messagesAsAuthor.failureNotifications',
+ }),
+ /**
+ * Whether an attempt was already made to fetch the user corresponding
+ * to this partner. This prevents doing the same RPC multiple times.
+ */
+ hasCheckedUser: attr({
+ default: false,
+ }),
+ id: attr(),
+ im_status: attr(),
+ memberThreads: many2many('mail.thread', {
+ inverse: 'members',
+ }),
+ messagesAsAuthor: one2many('mail.message', {
+ inverse: 'author',
+ }),
+ /**
+ * Serves as compute dependency.
+ */
+ messaging: many2one('mail.messaging', {
+ compute: '_computeMessaging',
+ }),
+ model: attr({
+ default: 'res.partner',
+ }),
+ /**
+ * Channels that are moderated by this partner.
+ */
+ moderatedChannels: many2many('mail.thread', {
+ inverse: 'moderators',
+ }),
+ name: attr(),
+ nameOrDisplayName: attr({
+ compute: '_computeNameOrDisplayName',
+ dependencies: [
+ 'display_name',
+ 'name',
+ ],
+ }),
+ user: one2one('mail.user', {
+ inverse: 'partner',
+ }),
+ /**
+ * Serves as compute dependency.
+ */
+ userDisplayName: attr({
+ related: 'user.display_name',
+ }),
+ };
+
+ Partner.modelName = 'mail.partner';
+
+ return Partner;
+}
+
+registerNewModel('mail.partner', factory);
+
+});
diff --git a/addons/mail/static/src/models/suggested_recipient_info/suggested_recipient_info.js b/addons/mail/static/src/models/suggested_recipient_info/suggested_recipient_info.js
new file mode 100644
index 00000000..c8f12856
--- /dev/null
+++ b/addons/mail/static/src/models/suggested_recipient_info/suggested_recipient_info.js
@@ -0,0 +1,116 @@
+odoo.define('mail/static/src/models/suggested_recipient_info/suggested_recipient_info.js', function (require) {
+'use strict';
+
+const { registerNewModel } = require('mail/static/src/model/model_core.js');
+const { attr, many2one } = require('mail/static/src/model/model_field.js');
+
+function factory(dependencies) {
+
+ class SuggestedRecipientInfo extends dependencies['mail.model'] {
+
+ //----------------------------------------------------------------------
+ // private
+ //----------------------------------------------------------------------
+
+ /**
+ * @private
+ * @returns {string}
+ */
+ _computeEmail() {
+ return this.partner && this.partner.email || this.email;
+ }
+
+ /**
+ * Prevents selecting a recipient that does not have a partner.
+ *
+ * @private
+ * @returns {boolean}
+ */
+ _computeIsSelected() {
+ return this.partner ? this.isSelected : false;
+ }
+
+ /**
+ * @private
+ * @returns {string}
+ */
+ _computeName() {
+ return this.partner && this.partner.nameOrDisplayName || this.name;
+ }
+
+ }
+
+ SuggestedRecipientInfo.fields = {
+ /**
+ * Determines the email of `this`. It serves as visual clue when
+ * displaying `this`, and also serves as default partner email when
+ * creating a new partner from `this`.
+ */
+ email: attr({
+ compute: '_computeEmail',
+ dependencies: [
+ 'email',
+ 'partnerEmail',
+ ],
+ }),
+ /**
+ * Determines whether `this` will be added to recipients when posting a
+ * new message on `this.thread`.
+ */
+ isSelected: attr({
+ compute: '_computeIsSelected',
+ default: true,
+ dependencies: [
+ 'isSelected',
+ 'partner',
+ ],
+ }),
+ /**
+ * Determines the name of `this`. It serves as visual clue when
+ * displaying `this`, and also serves as default partner name when
+ * creating a new partner from `this`.
+ */
+ name: attr({
+ compute: '_computeName',
+ dependencies: [
+ 'name',
+ 'partnerNameOrDisplayName',
+ ],
+ }),
+ /**
+ * Determines the optional `mail.partner` associated to `this`.
+ */
+ partner: many2one('mail.partner'),
+ /**
+ * Serves as compute dependency.
+ */
+ partnerEmail: attr({
+ related: 'partner.email'
+ }),
+ /**
+ * Serves as compute dependency.
+ */
+ partnerNameOrDisplayName: attr({
+ related: 'partner.nameOrDisplayName'
+ }),
+ /**
+ * Determines why `this` is a suggestion for `this.thread`. It serves as
+ * visual clue when displaying `this`.
+ */
+ reason: attr(),
+ /**
+ * Determines the `mail.thread` concerned by `this.`
+ */
+ thread: many2one('mail.thread', {
+ inverse: 'suggestedRecipientInfoList',
+ }),
+ };
+
+ SuggestedRecipientInfo.modelName = 'mail.suggested_recipient_info';
+
+ return SuggestedRecipientInfo;
+}
+
+registerNewModel('mail.suggested_recipient_info', factory);
+
+});
diff --git a/addons/mail/static/src/models/thread/thread.js b/addons/mail/static/src/models/thread/thread.js
new file mode 100644
index 00000000..1011eec4
--- /dev/null
+++ b/addons/mail/static/src/models/thread/thread.js
@@ -0,0 +1,2324 @@
+odoo.define('mail/static/src/models/thread/thread.js', function (require) {
+'use strict';
+
+const { registerNewModel } = require('mail/static/src/model/model_core.js');
+const { attr, many2many, many2one, one2many, one2one } = require('mail/static/src/model/model_field.js');
+const { clear } = require('mail/static/src/model/model_field_command.js');
+const throttle = require('mail/static/src/utils/throttle/throttle.js');
+const Timer = require('mail/static/src/utils/timer/timer.js');
+const { cleanSearchTerm } = require('mail/static/src/utils/utils.js');
+const mailUtils = require('mail.utils');
+
+function factory(dependencies) {
+
+ class Thread extends dependencies['mail.model'] {
+
+ /**
+ * @override
+ */
+ _willCreate() {
+ const res = super._willCreate(...arguments);
+ /**
+ * Timer of current partner that was currently typing something, but
+ * there is no change on the input for 5 seconds. This is used
+ * in order to automatically notify other members that current
+ * partner has stopped typing something, due to making no changes
+ * on the composer for some time.
+ */
+ this._currentPartnerInactiveTypingTimer = new Timer(
+ this.env,
+ () => this.async(() => this._onCurrentPartnerInactiveTypingTimeout()),
+ 5 * 1000
+ );
+ /**
+ * Last 'is_typing' status of current partner that has been notified
+ * to other members. Useful to prevent spamming typing notifications
+ * to other members if it hasn't changed. An exception is the
+ * current partner long typing scenario where current partner has
+ * to re-send the same typing notification from time to time, so
+ * that other members do not assume he/she is no longer typing
+ * something from not receiving any typing notifications for a
+ * very long time.
+ *
+ * Supported values: true/false/undefined.
+ * undefined makes only sense initially and during current partner
+ * long typing timeout flow.
+ */
+ this._currentPartnerLastNotifiedIsTyping = undefined;
+ /**
+ * Timer of current partner that is typing a very long text. When
+ * the other members do not receive any typing notification for a
+ * long time, they must assume that the related partner is no longer
+ * typing something (e.g. they have closed the browser tab).
+ * This is a timer to let other members know that current partner
+ * is still typing something, so that they should not assume he/she
+ * has stopped typing something.
+ */
+ this._currentPartnerLongTypingTimer = new Timer(
+ this.env,
+ () => this.async(() => this._onCurrentPartnerLongTypingTimeout()),
+ 50 * 1000
+ );
+ /**
+ * Determines whether the next request to notify current partner
+ * typing status should always result to making RPC, regardless of
+ * whether last notified current partner typing status is the same.
+ * Most of the time we do not want to notify if value hasn't
+ * changed, exception being the long typing scenario of current
+ * partner.
+ */
+ this._forceNotifyNextCurrentPartnerTypingStatus = false;
+ /**
+ * Registry of timers of partners currently typing in the thread,
+ * excluding current partner. This is useful in order to
+ * automatically unregister typing members when not receive any
+ * typing notification after a long time. Timers are internally
+ * indexed by partner records as key. The current partner is
+ * ignored in this registry of timers.
+ *
+ * @see registerOtherMemberTypingMember
+ * @see unregisterOtherMemberTypingMember
+ */
+ this._otherMembersLongTypingTimers = new Map();
+
+ /**
+ * Clearable and cancellable throttled version of the
+ * `_notifyCurrentPartnerTypingStatus` method.
+ * This is useful when the current partner posts a message and
+ * types something else afterwards: it must notify immediately that
+ * he/she is typing something, instead of waiting for the throttle
+ * internal timer.
+ *
+ * @see _notifyCurrentPartnerTypingStatus
+ */
+ this._throttleNotifyCurrentPartnerTypingStatus = throttle(
+ this.env,
+ ({ isTyping }) => this.async(() => this._notifyCurrentPartnerTypingStatus({ isTyping })),
+ 2.5 * 1000
+ );
+ return res;
+ }
+
+ /**
+ * @override
+ */
+ _willDelete() {
+ this._currentPartnerInactiveTypingTimer.clear();
+ this._currentPartnerLongTypingTimer.clear();
+ this._throttleNotifyCurrentPartnerTypingStatus.clear();
+ for (const timer of this._otherMembersLongTypingTimers.values()) {
+ timer.clear();
+ }
+ if (this.isTemporary) {
+ for (const message of this.messages) {
+ message.delete();
+ }
+ }
+ return super._willDelete(...arguments);
+ }
+
+ //----------------------------------------------------------------------
+ // Public
+ //----------------------------------------------------------------------
+
+ /**
+ * @static
+ * @param {mail.thread} [thread] the concerned thread
+ */
+ static computeLastCurrentPartnerMessageSeenByEveryone(thread = undefined) {
+ const threads = thread ? [thread] : this.env.models['mail.thread'].all();
+ threads.map(localThread => {
+ localThread.update({
+ lastCurrentPartnerMessageSeenByEveryone: localThread._computeLastCurrentPartnerMessageSeenByEveryone(),
+ });
+ });
+ }
+
+ /**
+ * @static
+ * @param {Object} data
+ * @return {Object}
+ */
+ static convertData(data) {
+ const data2 = {
+ messagesAsServerChannel: [],
+ };
+ if ('model' in data) {
+ data2.model = data.model;
+ }
+ if ('channel_type' in data) {
+ data2.channel_type = data.channel_type;
+ data2.model = 'mail.channel';
+ }
+ if ('create_uid' in data) {
+ data2.creator = [['insert', { id: data.create_uid }]];
+ }
+ if ('custom_channel_name' in data) {
+ data2.custom_channel_name = data.custom_channel_name;
+ }
+ if ('group_based_subscription' in data) {
+ data2.group_based_subscription = data.group_based_subscription;
+ }
+ if ('id' in data) {
+ data2.id = data.id;
+ }
+ if ('is_minimized' in data && 'state' in data) {
+ data2.serverFoldState = data.is_minimized ? data.state : 'closed';
+ }
+ if ('is_moderator' in data) {
+ data2.is_moderator = data.is_moderator;
+ }
+ if ('is_pinned' in data) {
+ data2.isServerPinned = data.is_pinned;
+ }
+ if ('last_message' in data && data.last_message) {
+ data2.messagesAsServerChannel.push(['insert', { id: data.last_message.id }]);
+ data2.serverLastMessageId = data.last_message.id;
+ }
+ if ('last_message_id' in data && data.last_message_id) {
+ data2.messagesAsServerChannel.push(['insert', { id: data.last_message_id }]);
+ data2.serverLastMessageId = data.last_message_id;
+ }
+ if ('mass_mailing' in data) {
+ data2.mass_mailing = data.mass_mailing;
+ }
+ if ('moderation' in data) {
+ data2.moderation = data.moderation;
+ }
+ if ('message_needaction_counter' in data) {
+ data2.message_needaction_counter = data.message_needaction_counter;
+ }
+ if ('message_unread_counter' in data) {
+ data2.serverMessageUnreadCounter = data.message_unread_counter;
+ }
+ if ('name' in data) {
+ data2.name = data.name;
+ }
+ if ('public' in data) {
+ data2.public = data.public;
+ }
+ if ('seen_message_id' in data) {
+ data2.lastSeenByCurrentPartnerMessageId = data.seen_message_id || 0;
+ }
+ if ('uuid' in data) {
+ data2.uuid = data.uuid;
+ }
+
+ // relations
+ if ('members' in data) {
+ if (!data.members) {
+ data2.members = [['unlink-all']];
+ } else {
+ data2.members = [
+ ['insert-and-replace', data.members.map(memberData =>
+ this.env.models['mail.partner'].convertData(memberData)
+ )],
+ ];
+ }
+ }
+ if ('seen_partners_info' in data) {
+ if (!data.seen_partners_info) {
+ data2.partnerSeenInfos = [['unlink-all']];
+ } else {
+ /*
+ * FIXME: not optimal to write on relation given the fact that the relation
+ * will be (re)computed based on given fields.
+ * (here channelId will compute partnerSeenInfo.thread))
+ * task-2336946
+ */
+ data2.partnerSeenInfos = [
+ ['insert-and-replace',
+ data.seen_partners_info.map(
+ ({ fetched_message_id, partner_id, seen_message_id }) => {
+ return {
+ channelId: data2.id,
+ lastFetchedMessage: [fetched_message_id ? ['insert', { id: fetched_message_id }] : ['unlink-all']],
+ lastSeenMessage: [seen_message_id ? ['insert', { id: seen_message_id }] : ['unlink-all']],
+ partnerId: partner_id,
+ };
+ })
+ ]
+ ];
+ if (data.id || this.id) {
+ const messageIds = data.seen_partners_info.reduce((currentSet, { fetched_message_id, seen_message_id }) => {
+ if (fetched_message_id) {
+ currentSet.add(fetched_message_id);
+ }
+ if (seen_message_id) {
+ currentSet.add(seen_message_id);
+ }
+ return currentSet;
+ }, new Set());
+ if (messageIds.size > 0) {
+ /*
+ * FIXME: not optimal to write on relation given the fact that the relation
+ * will be (re)computed based on given fields.
+ * (here channelId will compute messageSeenIndicator.thread))
+ * task-2336946
+ */
+ data2.messageSeenIndicators = [
+ ['insert',
+ [...messageIds].map(messageId => {
+ return {
+ channelId: data.id || this.id,
+ messageId,
+ };
+ })
+ ]
+ ];
+ }
+ }
+ }
+ }
+
+ return data2;
+ }
+
+ /**
+ * Fetches threads matching the given composer search state to extend
+ * the JS knowledge and to update the suggestion list accordingly.
+ * More specifically only thread of model 'mail.channel' are fetched.
+ *
+ * @static
+ * @param {string} searchTerm
+ * @param {Object} [options={}]
+ * @param {mail.thread} [options.thread] prioritize and/or restrict
+ * result in the context of given thread
+ */
+ static async fetchSuggestions(searchTerm, { thread } = {}) {
+ const channelsData = await this.env.services.rpc(
+ {
+ model: 'mail.channel',
+ method: 'get_mention_suggestions',
+ kwargs: { search: searchTerm },
+ },
+ { shadow: true },
+ );
+ this.env.models['mail.thread'].insert(channelsData.map(channelData =>
+ Object.assign(
+ { model: 'mail.channel' },
+ this.env.models['mail.thread'].convertData(channelData),
+ )
+ ));
+ }
+
+ /**
+ * Returns a sort function to determine the order of display of threads
+ * in the suggestion list.
+ *
+ * @static
+ * @param {string} searchTerm
+ * @param {Object} [options={}]
+ * @param {mail.thread} [options.thread] prioritize result in the
+ * context of given thread
+ * @returns {function}
+ */
+ static getSuggestionSortFunction(searchTerm, { thread } = {}) {
+ const cleanedSearchTerm = cleanSearchTerm(searchTerm);
+ return (a, b) => {
+ const isAPublic = a.model === 'mail.channel' && a.public === 'public';
+ const isBPublic = b.model === 'mail.channel' && b.public === 'public';
+ if (isAPublic && !isBPublic) {
+ return -1;
+ }
+ if (!isAPublic && isBPublic) {
+ return 1;
+ }
+ const isMemberOfA = a.model === 'mail.channel' && a.members.includes(this.env.messaging.currentPartner);
+ const isMemberOfB = b.model === 'mail.channel' && b.members.includes(this.env.messaging.currentPartner);
+ if (isMemberOfA && !isMemberOfB) {
+ return -1;
+ }
+ if (!isMemberOfA && isMemberOfB) {
+ return 1;
+ }
+ const cleanedAName = cleanSearchTerm(a.name || '');
+ const cleanedBName = cleanSearchTerm(b.name || '');
+ if (cleanedAName.startsWith(cleanedSearchTerm) && !cleanedBName.startsWith(cleanedSearchTerm)) {
+ return -1;
+ }
+ if (!cleanedAName.startsWith(cleanedSearchTerm) && cleanedBName.startsWith(cleanedSearchTerm)) {
+ return 1;
+ }
+ if (cleanedAName < cleanedBName) {
+ return -1;
+ }
+ if (cleanedAName > cleanedBName) {
+ return 1;
+ }
+ return a.id - b.id;
+ };
+ }
+
+ /**
+ * Load the previews of the specified threads. Basically, it fetches the
+ * last messages, since they are used to display inline content of them.
+ *
+ * @static
+ * @param {mail.thread[]} threads
+ */
+ static async loadPreviews(threads) {
+ const channelIds = threads.reduce((list, thread) => {
+ if (thread.model === 'mail.channel') {
+ return list.concat(thread.id);
+ }
+ return list;
+ }, []);
+ if (channelIds.length === 0) {
+ return;
+ }
+ const channelPreviews = await this.env.services.rpc({
+ model: 'mail.channel',
+ method: 'channel_fetch_preview',
+ args: [channelIds],
+ }, { shadow: true });
+ this.env.models['mail.message'].insert(channelPreviews.filter(p => p.last_message).map(
+ channelPreview => this.env.models['mail.message'].convertData(channelPreview.last_message)
+ ));
+ }
+
+
+ /**
+ * Performs the `channel_fold` RPC on `mail.channel`.
+ *
+ * @static
+ * @param {string} uuid
+ * @param {string} state
+ */
+ static async performRpcChannelFold(uuid, state) {
+ return this.env.services.rpc({
+ model: 'mail.channel',
+ method: 'channel_fold',
+ kwargs: {
+ state,
+ uuid,
+ }
+ }, { shadow: true });
+ }
+
+ /**
+ * Performs the `channel_info` RPC on `mail.channel`.
+ *
+ * @static
+ * @param {Object} param0
+ * @param {integer[]} param0.ids list of id of channels
+ * @returns {mail.thread[]}
+ */
+ static async performRpcChannelInfo({ ids }) {
+ const channelInfos = await this.env.services.rpc({
+ model: 'mail.channel',
+ method: 'channel_info',
+ args: [ids],
+ }, { shadow: true });
+ const channels = this.env.models['mail.thread'].insert(
+ channelInfos.map(channelInfo => this.env.models['mail.thread'].convertData(channelInfo))
+ );
+ // manually force recompute of counter
+ this.env.messaging.messagingMenu.update();
+ return channels;
+ }
+
+ /**
+ * Performs the `channel_seen` RPC on `mail.channel`.
+ *
+ * @static
+ * @param {Object} param0
+ * @param {integer[]} param0.ids list of id of channels
+ * @param {integer[]} param0.lastMessageId
+ */
+ static async performRpcChannelSeen({ ids, lastMessageId }) {
+ return this.env.services.rpc({
+ model: 'mail.channel',
+ method: 'channel_seen',
+ args: [ids],
+ kwargs: {
+ last_message_id: lastMessageId,
+ },
+ }, { shadow: true });
+ }
+
+ /**
+ * Performs the `channel_pin` RPC on `mail.channel`.
+ *
+ * @static
+ * @param {Object} param0
+ * @param {boolean} [param0.pinned=false]
+ * @param {string} param0.uuid
+ */
+ static async performRpcChannelPin({ pinned = false, uuid }) {
+ return this.env.services.rpc({
+ model: 'mail.channel',
+ method: 'channel_pin',
+ kwargs: {
+ uuid,
+ pinned,
+ },
+ }, { shadow: true });
+ }
+
+ /**
+ * Performs the `channel_create` RPC on `mail.channel`.
+ *
+ * @static
+ * @param {Object} param0
+ * @param {string} param0.name
+ * @param {string} [param0.privacy]
+ * @returns {mail.thread} the created channel
+ */
+ static async performRpcCreateChannel({ name, privacy }) {
+ const device = this.env.messaging.device;
+ const data = await this.env.services.rpc({
+ model: 'mail.channel',
+ method: 'channel_create',
+ args: [name, privacy],
+ kwargs: {
+ context: Object.assign({}, this.env.session.user_content, {
+ // optimize the return value by avoiding useless queries
+ // in non-mobile devices
+ isMobile: device.isMobile,
+ }),
+ },
+ });
+ return this.env.models['mail.thread'].insert(
+ this.env.models['mail.thread'].convertData(data)
+ );
+ }
+
+ /**
+ * Performs the `channel_get` RPC on `mail.channel`.
+ *
+ * `openChat` is preferable in business code because it will avoid the
+ * RPC if the chat already exists.
+ *
+ * @static
+ * @param {Object} param0
+ * @param {integer[]} param0.partnerIds
+ * @param {boolean} [param0.pinForCurrentPartner]
+ * @returns {mail.thread|undefined} the created or existing chat
+ */
+ static async performRpcCreateChat({ partnerIds, pinForCurrentPartner }) {
+ const device = this.env.messaging.device;
+ // TODO FIX: potential duplicate chat task-2276490
+ const data = await this.env.services.rpc({
+ model: 'mail.channel',
+ method: 'channel_get',
+ kwargs: {
+ context: Object.assign({}, this.env.session.user_content, {
+ // optimize the return value by avoiding useless queries
+ // in non-mobile devices
+ isMobile: device.isMobile,
+ }),
+ partners_to: partnerIds,
+ pin: pinForCurrentPartner,
+ },
+ });
+ if (!data) {
+ return;
+ }
+ return this.env.models['mail.thread'].insert(
+ this.env.models['mail.thread'].convertData(data)
+ );
+ }
+
+ /**
+ * Performs the `channel_join_and_get_info` RPC on `mail.channel`.
+ *
+ * @static
+ * @param {Object} param0
+ * @param {integer} param0.channelId
+ * @returns {mail.thread} the channel that was joined
+ */
+ static async performRpcJoinChannel({ channelId }) {
+ const device = this.env.messaging.device;
+ const data = await this.env.services.rpc({
+ model: 'mail.channel',
+ method: 'channel_join_and_get_info',
+ args: [[channelId]],
+ kwargs: {
+ context: Object.assign({}, this.env.session.user_content, {
+ // optimize the return value by avoiding useless queries
+ // in non-mobile devices
+ isMobile: device.isMobile,
+ }),
+ },
+ });
+ return this.env.models['mail.thread'].insert(
+ this.env.models['mail.thread'].convertData(data)
+ );
+ }
+
+ /**
+ * Performs the `execute_command` RPC on `mail.channel`.
+ *
+ * @static
+ * @param {Object} param0
+ * @param {integer} param0.channelId
+ * @param {string} param0.command
+ * @param {Object} [param0.postData={}]
+ */
+ static async performRpcExecuteCommand({ channelId, command, postData = {} }) {
+ return this.env.services.rpc({
+ model: 'mail.channel',
+ method: 'execute_command',
+ args: [[channelId]],
+ kwargs: Object.assign({ command }, postData),
+ });
+ }
+
+ /**
+ * Performs the `message_post` RPC on given threadModel.
+ *
+ * @static
+ * @param {Object} param0
+ * @param {Object} param0.postData
+ * @param {integer} param0.threadId
+ * @param {string} param0.threadModel
+ * @return {integer} the posted message id
+ */
+ static async performRpcMessagePost({ postData, threadId, threadModel }) {
+ return this.env.services.rpc({
+ model: threadModel,
+ method: 'message_post',
+ args: [threadId],
+ kwargs: postData,
+ });
+ }
+
+ /**
+ * Performs RPC on the route `/mail/get_suggested_recipients`.
+ *
+ * @static
+ * @param {Object} param0
+ * @param {string} param0.model
+ * @param {integer[]} param0.res_id
+ */
+ static async performRpcMailGetSuggestedRecipients({ model, res_ids }) {
+ const data = await this.env.services.rpc({
+ route: '/mail/get_suggested_recipients',
+ params: {
+ model,
+ res_ids,
+ },
+ }, { shadow: true });
+ for (const id in data) {
+ const recipientInfoList = data[id].map(recipientInfoData => {
+ const [partner_id, emailInfo, reason] = recipientInfoData;
+ const [name, email] = emailInfo && mailUtils.parseEmail(emailInfo);
+ return {
+ email,
+ name,
+ partner: [partner_id ? ['insert', { id: partner_id }] : ['unlink']],
+ reason,
+ };
+ });
+ this.insert({
+ id: parseInt(id),
+ model,
+ suggestedRecipientInfoList: [['insert-and-replace', recipientInfoList]],
+ });
+ }
+ }
+
+ /*
+ * Returns threads that match the given search term. More specially only
+ * threads of model 'mail.channel' are suggested, and if the context
+ * thread is a private channel, only itself is returned if it matches
+ * the search term.
+ *
+ * @static
+ * @param {string} searchTerm
+ * @param {Object} [options={}]
+ * @param {mail.thread} [options.thread] prioritize and/or restrict
+ * result in the context of given thread
+ * @returns {[mail.threads[], mail.threads[]]}
+ */
+ static searchSuggestions(searchTerm, { thread } = {}) {
+ let threads;
+ if (thread && thread.model === 'mail.channel' && thread.public !== 'public') {
+ // Only return the current channel when in the context of a
+ // non-public channel. Indeed, the message with the mention
+ // would appear in the target channel, so this prevents from
+ // inadvertently leaking the private message into the mentioned
+ // channel.
+ threads = [thread];
+ } else {
+ threads = this.env.models['mail.thread'].all();
+ }
+ const cleanedSearchTerm = cleanSearchTerm(searchTerm);
+ return [threads.filter(thread =>
+ !thread.isTemporary &&
+ thread.model === 'mail.channel' &&
+ thread.channel_type === 'channel' &&
+ thread.name &&
+ cleanSearchTerm(thread.name).includes(cleanedSearchTerm)
+ )];
+ }
+
+ /**
+ * @param {string} [stringifiedDomain='[]']
+ * @returns {mail.thread_cache}
+ */
+ cache(stringifiedDomain = '[]') {
+ return this.env.models['mail.thread_cache'].insert({
+ stringifiedDomain,
+ thread: [['link', this]],
+ });
+ }
+
+ /**
+ * Fetch attachments linked to a record. Useful for populating the store
+ * with these attachments, which are used by attachment box in the chatter.
+ */
+ async fetchAttachments() {
+ const attachmentsData = await this.async(() => this.env.services.rpc({
+ model: 'ir.attachment',
+ method: 'search_read',
+ domain: [
+ ['res_id', '=', this.id],
+ ['res_model', '=', this.model],
+ ],
+ fields: ['id', 'name', 'mimetype'],
+ orderBy: [{ name: 'id', asc: false }],
+ }, { shadow: true }));
+ this.update({
+ originThreadAttachments: [['insert-and-replace',
+ attachmentsData.map(data =>
+ this.env.models['mail.attachment'].convertData(data)
+ )
+ ]],
+ });
+ this.update({ areAttachmentsLoaded: true });
+ }
+
+ /**
+ * Fetches suggested recipients.
+ */
+ async fetchAndUpdateSuggestedRecipients() {
+ if (this.isTemporary) {
+ return;
+ }
+ return this.env.models['mail.thread'].performRpcMailGetSuggestedRecipients({
+ model: this.model,
+ res_ids: [this.id],
+ });
+ }
+
+ /**
+ * Add current user to provided thread's followers.
+ */
+ async follow() {
+ await this.async(() => this.env.services.rpc({
+ model: this.model,
+ method: 'message_subscribe',
+ args: [[this.id]],
+ kwargs: {
+ partner_ids: [this.env.messaging.currentPartner.id],
+ context: {}, // FIXME empty context to be overridden in session.js with 'allowed_company_ids' task-2243187
+ },
+ }));
+ this.refreshFollowers();
+ this.fetchAndUpdateSuggestedRecipients();
+ }
+
+ /**
+ * Returns the text that identifies this thread in a mention.
+ *
+ * @returns {string}
+ */
+ getMentionText() {
+ return this.name;
+ }
+
+ /**
+ * Load new messages on the main cache of this thread.
+ */
+ loadNewMessages() {
+ this.mainCache.loadNewMessages();
+ }
+
+ /**
+ * Mark the specified conversation as fetched.
+ */
+ async markAsFetched() {
+ await this.async(() => this.env.services.rpc({
+ model: 'mail.channel',
+ method: 'channel_fetched',
+ args: [[this.id]],
+ }, { shadow: true }));
+ }
+
+ /**
+ * Mark the specified conversation as read/seen.
+ *
+ * @param {mail.message} message the message to be considered as last seen.
+ */
+ async markAsSeen(message) {
+ if (this.model !== 'mail.channel') {
+ return;
+ }
+ if (this.pendingSeenMessageId && message.id <= this.pendingSeenMessageId) {
+ return;
+ }
+ if (
+ this.lastSeenByCurrentPartnerMessageId &&
+ message.id <= this.lastSeenByCurrentPartnerMessageId
+ ) {
+ return;
+ }
+ this.update({ pendingSeenMessageId: message.id });
+ return this.env.models['mail.thread'].performRpcChannelSeen({
+ ids: [this.id],
+ lastMessageId: message.id,
+ });
+ }
+
+ /**
+ * Marks as read all needaction messages with this thread as origin.
+ */
+ async markNeedactionMessagesAsOriginThreadAsRead() {
+ await this.async(() =>
+ this.env.models['mail.message'].markAsRead(this.needactionMessagesAsOriginThread)
+ );
+ }
+
+ /**
+ * Mark as read all needaction messages of this thread.
+ */
+ async markNeedactionMessagesAsRead() {
+ await this.async(() =>
+ this.env.models['mail.message'].markAsRead(this.needactionMessages)
+ );
+ }
+
+ /**
+ * Notifies the server of new fold state. Useful for initial,
+ * cross-tab, and cross-device chat window state synchronization.
+ *
+ * @param {string} state
+ */
+ async notifyFoldStateToServer(state) {
+ if (this.model !== 'mail.channel') {
+ // Server sync of fold state is only supported for channels.
+ return;
+ }
+ if (!this.uuid) {
+ return;
+ }
+ return this.env.models['mail.thread'].performRpcChannelFold(this.uuid, state);
+ }
+
+ /**
+ * Notify server to leave the current channel. Useful for cross-tab
+ * and cross-device chat window state synchronization.
+ *
+ * Only makes sense if isPendingPinned is set to the desired value.
+ */
+ async notifyPinStateToServer() {
+ if (this.isPendingPinned) {
+ await this.env.models['mail.thread'].performRpcChannelPin({
+ pinned: true,
+ uuid: this.uuid,
+ });
+ } else {
+ this.env.models['mail.thread'].performRpcExecuteCommand({
+ channelId: this.id,
+ command: 'leave',
+ });
+ }
+ }
+
+ /**
+ * Opens this thread either as form view, in discuss app, or as a chat
+ * window. The thread will be opened in an "active" matter, which will
+ * interrupt current user flow.
+ *
+ * @param {Object} [param0]
+ * @param {boolean} [param0.expanded=false]
+ */
+ async open({ expanded = false } = {}) {
+ const discuss = this.env.messaging.discuss;
+ // check if thread must be opened in form view
+ if (!['mail.box', 'mail.channel'].includes(this.model)) {
+ if (expanded || discuss.isOpen) {
+ // Close chat window because having the same thread opened
+ // both in chat window and as main document does not look
+ // good.
+ this.env.messaging.chatWindowManager.closeThread(this);
+ return this.env.messaging.openDocument({
+ id: this.id,
+ model: this.model,
+ });
+ }
+ }
+ // check if thread must be opened in discuss
+ const device = this.env.messaging.device;
+ if (
+ (!device.isMobile && (discuss.isOpen || expanded)) ||
+ this.model === 'mail.box'
+ ) {
+ return discuss.openThread(this);
+ }
+ // thread must be opened in chat window
+ return this.env.messaging.chatWindowManager.openThread(this, {
+ makeActive: true,
+ });
+ }
+
+ /**
+ * Opens the most appropriate view that is a profile for this thread.
+ */
+ async openProfile() {
+ return this.env.messaging.openDocument({
+ id: this.id,
+ model: this.model,
+ });
+ }
+
+ /**
+ * Pin this thread and notify server of the change.
+ */
+ async pin() {
+ this.update({ isPendingPinned: true });
+ await this.notifyPinStateToServer();
+ }
+
+ /**
+ * Open a dialog to add channels as followers.
+ */
+ promptAddChannelFollower() {
+ this._promptAddFollower({ mail_invite_follower_channel_only: true });
+ }
+
+ /**
+ * Open a dialog to add partners as followers.
+ */
+ promptAddPartnerFollower() {
+ this._promptAddFollower({ mail_invite_follower_channel_only: false });
+ }
+
+ async refresh() {
+ if (this.isTemporary) {
+ return;
+ }
+ this.loadNewMessages();
+ this.update({ isLoadingAttachments: true });
+ await this.async(() => this.fetchAttachments());
+ this.update({ isLoadingAttachments: false });
+ }
+
+ async refreshActivities() {
+ if (!this.hasActivities) {
+ return;
+ }
+ if (this.isTemporary) {
+ return;
+ }
+ // A bit "extreme", may be improved
+ const [{ activity_ids: newActivityIds }] = await this.async(() => this.env.services.rpc({
+ model: this.model,
+ method: 'read',
+ args: [this.id, ['activity_ids']]
+ }, { shadow: true }));
+ const activitiesData = await this.async(() => this.env.services.rpc({
+ model: 'mail.activity',
+ method: 'activity_format',
+ args: [newActivityIds]
+ }, { shadow: true }));
+ const activities = this.env.models['mail.activity'].insert(activitiesData.map(
+ activityData => this.env.models['mail.activity'].convertData(activityData)
+ ));
+ this.update({ activities: [['replace', activities]] });
+ }
+
+ /**
+ * Refresh followers information from server.
+ */
+ async refreshFollowers() {
+ if (this.isTemporary) {
+ this.update({ followers: [['unlink-all']] });
+ return;
+ }
+ const { followers } = await this.async(() => this.env.services.rpc({
+ route: '/mail/read_followers',
+ params: {
+ res_id: this.id,
+ res_model: this.model,
+ },
+ }, { shadow: true }));
+ this.update({ areFollowersLoaded: true });
+ if (followers.length > 0) {
+ this.update({
+ followers: [['insert-and-replace', followers.map(data =>
+ this.env.models['mail.follower'].convertData(data))
+ ]],
+ });
+ } else {
+ this.update({
+ followers: [['unlink-all']],
+ });
+ }
+ }
+
+ /**
+ * Refresh the typing status of the current partner.
+ */
+ refreshCurrentPartnerIsTyping() {
+ this._currentPartnerInactiveTypingTimer.reset();
+ }
+
+ /**
+ * Called to refresh a registered other member partner that is typing
+ * something.
+ *
+ * @param {mail.partner} partner
+ */
+ refreshOtherMemberTypingMember(partner) {
+ this._otherMembersLongTypingTimers.get(partner).reset();
+ }
+
+ /**
+ * Called when current partner is inserting some input in composer.
+ * Useful to notify current partner is currently typing something in the
+ * composer of this thread to all other members.
+ */
+ async registerCurrentPartnerIsTyping() {
+ // Handling of typing timers.
+ this._currentPartnerInactiveTypingTimer.start();
+ this._currentPartnerLongTypingTimer.start();
+ // Manage typing member relation.
+ const currentPartner = this.env.messaging.currentPartner;
+ const newOrderedTypingMemberLocalIds = this.orderedTypingMemberLocalIds
+ .filter(localId => localId !== currentPartner.localId);
+ newOrderedTypingMemberLocalIds.push(currentPartner.localId);
+ this.update({
+ orderedTypingMemberLocalIds: newOrderedTypingMemberLocalIds,
+ typingMembers: [['link', currentPartner]],
+ });
+ // Notify typing status to other members.
+ await this._throttleNotifyCurrentPartnerTypingStatus({ isTyping: true });
+ }
+
+ /**
+ * Called to register a new other member partner that is typing
+ * something.
+ *
+ * @param {mail.partner} partner
+ */
+ registerOtherMemberTypingMember(partner) {
+ const timer = new Timer(
+ this.env,
+ () => this.async(() => this._onOtherMemberLongTypingTimeout(partner)),
+ 60 * 1000
+ );
+ this._otherMembersLongTypingTimers.set(partner, timer);
+ timer.start();
+ const newOrderedTypingMemberLocalIds = this.orderedTypingMemberLocalIds
+ .filter(localId => localId !== partner.localId);
+ newOrderedTypingMemberLocalIds.push(partner.localId);
+ this.update({
+ orderedTypingMemberLocalIds: newOrderedTypingMemberLocalIds,
+ typingMembers: [['link', partner]],
+ });
+ }
+
+ /**
+ * Rename the given thread with provided new name.
+ *
+ * @param {string} newName
+ */
+ async rename(newName) {
+ if (this.channel_type === 'chat') {
+ await this.async(() => this.env.services.rpc({
+ model: 'mail.channel',
+ method: 'channel_set_custom_name',
+ args: [this.id],
+ kwargs: {
+ name: newName,
+ },
+ }));
+ }
+ this.update({ custom_channel_name: newName });
+ }
+
+ /**
+ * Unfollow current partner from this thread.
+ */
+ async unfollow() {
+ const currentPartnerFollower = this.followers.find(
+ follower => follower.partner === this.env.messaging.currentPartner
+ );
+ await this.async(() => currentPartnerFollower.remove());
+ }
+
+ /**
+ * Unpin this thread and notify server of the change.
+ */
+ async unpin() {
+ this.update({ isPendingPinned: false });
+ await this.notifyPinStateToServer();
+ }
+
+ /**
+ * Called when current partner has explicitly stopped inserting some
+ * input in composer. Useful to notify current partner has currently
+ * stopped typing something in the composer of this thread to all other
+ * members.
+ *
+ * @param {Object} [param0={}]
+ * @param {boolean} [param0.immediateNotify=false] if set, is typing
+ * status of current partner is immediately notified and doesn't
+ * consume throttling at all.
+ */
+ async unregisterCurrentPartnerIsTyping({ immediateNotify = false } = {}) {
+ // Handling of typing timers.
+ this._currentPartnerInactiveTypingTimer.clear();
+ this._currentPartnerLongTypingTimer.clear();
+ // Manage typing member relation.
+ const currentPartner = this.env.messaging.currentPartner;
+ const newOrderedTypingMemberLocalIds = this.orderedTypingMemberLocalIds
+ .filter(localId => localId !== currentPartner.localId);
+ this.update({
+ orderedTypingMemberLocalIds: newOrderedTypingMemberLocalIds,
+ typingMembers: [['unlink', currentPartner]],
+ });
+ // Notify typing status to other members.
+ if (immediateNotify) {
+ this._throttleNotifyCurrentPartnerTypingStatus.clear();
+ }
+ await this.async(
+ () => this._throttleNotifyCurrentPartnerTypingStatus({ isTyping: false })
+ );
+ }
+
+ /**
+ * Called to unregister an other member partner that is no longer typing
+ * something.
+ *
+ * @param {mail.partner} partner
+ */
+ unregisterOtherMemberTypingMember(partner) {
+ this._otherMembersLongTypingTimers.get(partner).clear();
+ this._otherMembersLongTypingTimers.delete(partner);
+ const newOrderedTypingMemberLocalIds = this.orderedTypingMemberLocalIds
+ .filter(localId => localId !== partner.localId);
+ this.update({
+ orderedTypingMemberLocalIds: newOrderedTypingMemberLocalIds,
+ typingMembers: [['unlink', partner]],
+ });
+ }
+
+ /**
+ * Unsubscribe current user from provided channel.
+ */
+ unsubscribe() {
+ this.env.messaging.chatWindowManager.closeThread(this);
+ this.unpin();
+ }
+
+ //----------------------------------------------------------------------
+ // Private
+ //----------------------------------------------------------------------
+
+ /**
+ * @override
+ */
+ static _createRecordLocalId(data) {
+ const { channel_type, id, model } = data;
+ let threadModel = model;
+ if (!threadModel && channel_type) {
+ threadModel = 'mail.channel';
+ }
+ return `${this.modelName}_${threadModel}_${id}`;
+ }
+
+ /**
+ * @private
+ * @returns {mail.attachment[]}
+ */
+ _computeAllAttachments() {
+ const allAttachments = [...new Set(this.originThreadAttachments.concat(this.attachments))]
+ .sort((a1, a2) => {
+ // "uploading" before "uploaded" attachments.
+ if (!a1.isTemporary && a2.isTemporary) {
+ return 1;
+ }
+ if (a1.isTemporary && !a2.isTemporary) {
+ return -1;
+ }
+ // "most-recent" before "oldest" attachments.
+ return Math.abs(a2.id) - Math.abs(a1.id);
+ });
+ return [['replace', allAttachments]];
+ }
+
+ /**
+ * @private
+ * @returns {mail.partner}
+ */
+ _computeCorrespondent() {
+ if (this.channel_type === 'channel') {
+ return [['unlink']];
+ }
+ const correspondents = this.members.filter(partner =>
+ partner !== this.env.messaging.currentPartner
+ );
+ if (correspondents.length === 1) {
+ // 2 members chat
+ return [['link', correspondents[0]]];
+ }
+ if (this.members.length === 1) {
+ // chat with oneself
+ return [['link', this.members[0]]];
+ }
+ return [['unlink']];
+ }
+
+ /**
+ * @private
+ * @returns {string}
+ */
+ _computeDisplayName() {
+ if (this.channel_type === 'chat' && this.correspondent) {
+ return this.custom_channel_name || this.correspondent.nameOrDisplayName;
+ }
+ return this.name;
+ }
+
+ /**
+ * @private
+ * @returns {mail.activity[]}
+ */
+ _computeFutureActivities() {
+ return [['replace', this.activities.filter(activity => activity.state === 'planned')]];
+ }
+
+ /**
+ * @private
+ * @returns {boolean}
+ */
+ _computeHasSeenIndicators() {
+ if (this.model !== 'mail.channel') {
+ return false;
+ }
+ if (this.mass_mailing) {
+ return false;
+ }
+ return ['chat', 'livechat'].includes(this.channel_type);
+ }
+
+ /**
+ * @private
+ * @returns {boolean}
+ */
+ _computeIsChatChannel() {
+ return this.channel_type === 'chat';
+ }
+
+ /**
+ * @private
+ * @returns {boolean}
+ */
+ _computeIsCurrentPartnerFollowing() {
+ return this.followers.some(follower =>
+ follower.partner && follower.partner === this.env.messaging.currentPartner
+ );
+ }
+
+ /**
+ * @private
+ * @returns {boolean}
+ */
+ _computeIsModeratedByCurrentPartner() {
+ if (!this.messaging) {
+ return false;
+ }
+ if (!this.messaging.currentPartner) {
+ return false;
+ }
+ return this.moderators.includes(this.env.messaging.currentPartner);
+ }
+
+ /**
+ * @private
+ * @returns {boolean}
+ */
+ _computeIsPinned() {
+ return this.isPendingPinned !== undefined ? this.isPendingPinned : this.isServerPinned;
+ }
+
+ /**
+ * @private
+ * @returns {mail.message}
+ */
+ _computeLastCurrentPartnerMessageSeenByEveryone() {
+ const otherPartnerSeenInfos =
+ this.partnerSeenInfos.filter(partnerSeenInfo =>
+ partnerSeenInfo.partner !== this.messagingCurrentPartner);
+ if (otherPartnerSeenInfos.length === 0) {
+ return [['unlink-all']];
+ }
+
+ const otherPartnersLastSeenMessageIds =
+ otherPartnerSeenInfos.map(partnerSeenInfo =>
+ partnerSeenInfo.lastSeenMessage ? partnerSeenInfo.lastSeenMessage.id : 0
+ );
+ if (otherPartnersLastSeenMessageIds.length === 0) {
+ return [['unlink-all']];
+ }
+ const lastMessageSeenByAllId = Math.min(
+ ...otherPartnersLastSeenMessageIds
+ );
+ const currentPartnerOrderedSeenMessages =
+ this.orderedNonTransientMessages.filter(message =>
+ message.author === this.messagingCurrentPartner &&
+ message.id <= lastMessageSeenByAllId);
+
+ if (
+ !currentPartnerOrderedSeenMessages ||
+ currentPartnerOrderedSeenMessages.length === 0
+ ) {
+ return [['unlink-all']];
+ }
+ return [['link', currentPartnerOrderedSeenMessages.slice().pop()]];
+ }
+
+ /**
+ * @private
+ * @returns {mail.message|undefined}
+ */
+ _computeLastMessage() {
+ const {
+ length: l,
+ [l - 1]: lastMessage,
+ } = this.orderedMessages;
+ if (lastMessage) {
+ return [['link', lastMessage]];
+ }
+ return [['unlink']];
+ }
+
+ /**
+ * @private
+ * @returns {mail.message|undefined}
+ */
+ _computeLastNonTransientMessage() {
+ const {
+ length: l,
+ [l - 1]: lastMessage,
+ } = this.orderedNonTransientMessages;
+ if (lastMessage) {
+ return [['link', lastMessage]];
+ }
+ return [['unlink']];
+ }
+
+ /**
+ * Adjusts the last seen message received from the server to consider
+ * the following messages also as read if they are either transient
+ * messages or messages from the current partner.
+ *
+ * @private
+ * @returns {integer}
+ */
+ _computeLastSeenByCurrentPartnerMessageId() {
+ const firstMessage = this.orderedMessages[0];
+ if (
+ firstMessage &&
+ this.lastSeenByCurrentPartnerMessageId &&
+ this.lastSeenByCurrentPartnerMessageId < firstMessage.id
+ ) {
+ // no deduction can be made if there is a gap
+ return this.lastSeenByCurrentPartnerMessageId;
+ }
+ let lastSeenByCurrentPartnerMessageId = this.lastSeenByCurrentPartnerMessageId;
+ for (const message of this.orderedMessages) {
+ if (message.id <= this.lastSeenByCurrentPartnerMessageId) {
+ continue;
+ }
+ if (
+ message.author === this.env.messaging.currentPartner ||
+ message.isTransient
+ ) {
+ lastSeenByCurrentPartnerMessageId = message.id;
+ continue;
+ }
+ return lastSeenByCurrentPartnerMessageId;
+ }
+ return lastSeenByCurrentPartnerMessageId;
+ }
+
+ /**
+ * @private
+ * @returns {mail.message|undefined}
+ */
+ _computeLastNeedactionMessage() {
+ const orderedNeedactionMessages = this.needactionMessages.sort(
+ (m1, m2) => m1.id < m2.id ? -1 : 1
+ );
+ const {
+ length: l,
+ [l - 1]: lastNeedactionMessage,
+ } = orderedNeedactionMessages;
+ if (lastNeedactionMessage) {
+ return [['link', lastNeedactionMessage]];
+ }
+ return [['unlink']];
+ }
+
+ /**
+ * @private
+ * @returns {mail.message|undefined}
+ */
+ _computeLastNeedactionMessageAsOriginThread() {
+ const orderedNeedactionMessagesAsOriginThread = this.needactionMessagesAsOriginThread.sort(
+ (m1, m2) => m1.id < m2.id ? -1 : 1
+ );
+ const {
+ length: l,
+ [l - 1]: lastNeedactionMessageAsOriginThread,
+ } = orderedNeedactionMessagesAsOriginThread;
+ if (lastNeedactionMessageAsOriginThread) {
+ return [['link', lastNeedactionMessageAsOriginThread]];
+ }
+ return [['unlink']];
+ }
+
+ /**
+ * @private
+ * @returns {mail.thread_cache}
+ */
+ _computeMainCache() {
+ return [['link', this.cache()]];
+ }
+
+ /**
+ * @private
+ * @returns {integer}
+ */
+ _computeLocalMessageUnreadCounter() {
+ if (this.model !== 'mail.channel') {
+ // unread counter only makes sense on channels
+ return clear();
+ }
+ // By default trust the server up to the last message it used
+ // because it's not possible to do better.
+ let baseCounter = this.serverMessageUnreadCounter;
+ let countFromId = this.serverLastMessageId;
+ // But if the client knows the last seen message that the server
+ // returned (and by assumption all the messages that come after),
+ // the counter can be computed fully locally, ignoring potentially
+ // obsolete values from the server.
+ const firstMessage = this.orderedMessages[0];
+ if (
+ firstMessage &&
+ this.lastSeenByCurrentPartnerMessageId &&
+ this.lastSeenByCurrentPartnerMessageId >= firstMessage.id
+ ) {
+ baseCounter = 0;
+ countFromId = this.lastSeenByCurrentPartnerMessageId;
+ }
+ // Include all the messages that are known locally but the server
+ // didn't take into account.
+ return this.orderedMessages.reduce((total, message) => {
+ if (message.id <= countFromId) {
+ return total;
+ }
+ return total + 1;
+ }, baseCounter);
+ }
+
+ /**
+ * @private
+ * @returns {mail.messaging}
+ */
+ _computeMessaging() {
+ return [['link', this.env.messaging]];
+ }
+
+ /**
+ * @private
+ * @returns {mail.message[]}
+ */
+ _computeNeedactionMessages() {
+ return [['replace', this.messages.filter(message => message.isNeedaction)]];
+ }
+
+ /**
+ * @private
+ * @returns {mail.message[]}
+ */
+ _computeNeedactionMessagesAsOriginThread() {
+ return [['replace', this.messagesAsOriginThread.filter(message => message.isNeedaction)]];
+ }
+
+ /**
+ * @private
+ * @returns {mail.message|undefined}
+ */
+ _computeMessageAfterNewMessageSeparator() {
+ if (this.model !== 'mail.channel') {
+ return [['unlink']];
+ }
+ if (this.localMessageUnreadCounter === 0) {
+ return [['unlink']];
+ }
+ const index = this.orderedMessages.findIndex(message =>
+ message.id === this.lastSeenByCurrentPartnerMessageId
+ );
+ if (index === -1) {
+ return [['unlink']];
+ }
+ const message = this.orderedMessages[index + 1];
+ if (!message) {
+ return [['unlink']];
+ }
+ return [['link', message]];
+ }
+
+ /**
+ * @private
+ * @returns {mail.message[]}
+ */
+ _computeOrderedMessages() {
+ return [['replace', this.messages.sort((m1, m2) => m1.id < m2.id ? -1 : 1)]];
+ }
+
+ /**
+ * @private
+ * @returns {mail.message[]}
+ */
+ _computeOrderedNonTransientMessages() {
+ return [['replace', this.orderedMessages.filter(m => !m.isTransient)]];
+ }
+
+ /**
+ * @private
+ * @returns {mail.partner[]}
+ */
+ _computeOrderedOtherTypingMembers() {
+ return [[
+ 'replace',
+ this.orderedTypingMembers.filter(
+ member => member !== this.env.messaging.currentPartner
+ ),
+ ]];
+ }
+
+ /**
+ * @private
+ * @returns {mail.partner[]}
+ */
+ _computeOrderedTypingMembers() {
+ return [[
+ 'replace',
+ this.orderedTypingMemberLocalIds
+ .map(localId => this.env.models['mail.partner'].get(localId))
+ .filter(member => !!member),
+ ]];
+ }
+
+ /**
+ * @private
+ * @returns {mail.activity[]}
+ */
+ _computeOverdueActivities() {
+ return [['replace', this.activities.filter(activity => activity.state === 'overdue')]];
+ }
+
+ /**
+ * @private
+ * @returns {mail.activity[]}
+ */
+ _computeTodayActivities() {
+ return [['replace', this.activities.filter(activity => activity.state === 'today')]];
+ }
+
+ /**
+ * @private
+ * @returns {string}
+ */
+ _computeTypingStatusText() {
+ if (this.orderedOtherTypingMembers.length === 0) {
+ return this.constructor.fields.typingStatusText.default;
+ }
+ if (this.orderedOtherTypingMembers.length === 1) {
+ return _.str.sprintf(
+ this.env._t("%s is typing..."),
+ this.orderedOtherTypingMembers[0].nameOrDisplayName
+ );
+ }
+ if (this.orderedOtherTypingMembers.length === 2) {
+ return _.str.sprintf(
+ this.env._t("%s and %s are typing..."),
+ this.orderedOtherTypingMembers[0].nameOrDisplayName,
+ this.orderedOtherTypingMembers[1].nameOrDisplayName
+ );
+ }
+ return _.str.sprintf(
+ this.env._t("%s, %s and more are typing..."),
+ this.orderedOtherTypingMembers[0].nameOrDisplayName,
+ this.orderedOtherTypingMembers[1].nameOrDisplayName
+ );
+ }
+
+ /**
+ * Compute an url string that can be used inside a href attribute
+ *
+ * @private
+ * @returns {string}
+ */
+ _computeUrl() {
+ const baseHref = this.env.session.url('/web');
+ if (this.model === 'mail.channel') {
+ return `${baseHref}#action=mail.action_discuss&active_id=${this.model}_${this.id}`;
+ }
+ return `${baseHref}#model=${this.model}&id=${this.id}`;
+ }
+
+ /**
+ * @private
+ * @param {Object} param0
+ * @param {boolean} param0.isTyping
+ */
+ async _notifyCurrentPartnerTypingStatus({ isTyping }) {
+ if (
+ this._forceNotifyNextCurrentPartnerTypingStatus ||
+ isTyping !== this._currentPartnerLastNotifiedIsTyping
+ ) {
+ if (this.model === 'mail.channel') {
+ await this.async(() => this.env.services.rpc({
+ model: 'mail.channel',
+ method: 'notify_typing',
+ args: [this.id],
+ kwargs: { is_typing: isTyping },
+ }, { shadow: true }));
+ }
+ if (isTyping && this._currentPartnerLongTypingTimer.isRunning) {
+ this._currentPartnerLongTypingTimer.reset();
+ }
+ }
+ this._forceNotifyNextCurrentPartnerTypingStatus = false;
+ this._currentPartnerLastNotifiedIsTyping = isTyping;
+ }
+
+ /**
+ * Cleans followers of current thread. In particular, chats are supposed
+ * to work with "members", not with "followers". This clean up is only
+ * necessary to remove illegitimate followers in stable version, it can
+ * be removed in master after proper migration to clean the database.
+ *
+ * @private
+ */
+ _onChangeFollowersPartner() {
+ if (this.channel_type !== 'chat') {
+ return;
+ }
+ for (const follower of this.followers) {
+ if (follower.partner) {
+ follower.remove();
+ }
+ }
+ }
+
+ /**
+ * @private
+ */
+ _onChangeLastSeenByCurrentPartnerMessageId() {
+ this.env.messagingBus.trigger('o-thread-last-seen-by-current-partner-message-id-changed', {
+ thread: this,
+ });
+ }
+
+ /**
+ * @private
+ */
+ _onChangeThreadViews() {
+ if (this.threadViews.length === 0) {
+ return;
+ }
+ /**
+ * Fetches followers of chats when they are displayed for the first
+ * time. This is necessary to clean the followers.
+ * @see `_onChangeFollowersPartner` for more information.
+ */
+ if (this.channel_type === 'chat' && !this.areFollowersLoaded) {
+ this.refreshFollowers();
+ }
+ }
+
+ /**
+ * Handles change of pinned state coming from the server. Useful to
+ * clear pending state once server acknowledged the change.
+ *
+ * @private
+ * @see isPendingPinned
+ */
+ _onIsServerPinnedChanged() {
+ if (this.isServerPinned === this.isPendingPinned) {
+ this.update({ isPendingPinned: clear() });
+ }
+ }
+
+ /**
+ * Handles change of fold state coming from the server. Useful to
+ * synchronize corresponding chat window.
+ *
+ * @private
+ */
+ _onServerFoldStateChanged() {
+ if (!this.env.messaging.chatWindowManager) {
+ // avoid crash during destroy
+ return;
+ }
+ if (this.env.messaging.device.isMobile) {
+ return;
+ }
+ if (this.serverFoldState === 'closed') {
+ this.env.messaging.chatWindowManager.closeThread(this, {
+ notifyServer: false,
+ });
+ } else {
+ this.env.messaging.chatWindowManager.openThread(this, {
+ isFolded: this.serverFoldState === 'folded',
+ notifyServer: false,
+ });
+ }
+ }
+
+ /**
+ * @private
+ * @param {Object} [param0={}]
+ * @param {boolean} [param0.mail_invite_follower_channel_only=false]
+ */
+ _promptAddFollower({ mail_invite_follower_channel_only = false } = {}) {
+ const self = this;
+ const action = {
+ type: 'ir.actions.act_window',
+ res_model: 'mail.wizard.invite',
+ view_mode: 'form',
+ views: [[false, 'form']],
+ name: this.env._t("Invite Follower"),
+ target: 'new',
+ context: {
+ default_res_model: this.model,
+ default_res_id: this.id,
+ mail_invite_follower_channel_only,
+ },
+ };
+ this.env.bus.trigger('do-action', {
+ action,
+ options: {
+ on_close: async () => {
+ await this.async(() => this.refreshFollowers());
+ this.env.bus.trigger('mail.thread:promptAddFollower-closed');
+ },
+ },
+ });
+ }
+
+ //----------------------------------------------------------------------
+ // Handlers
+ //----------------------------------------------------------------------
+
+ /**
+ * @private
+ */
+ async _onCurrentPartnerInactiveTypingTimeout() {
+ await this.async(() => this.unregisterCurrentPartnerIsTyping());
+ }
+
+ /**
+ * Called when current partner has been typing for a very long time.
+ * Immediately notify other members that he/she is still typing.
+ *
+ * @private
+ */
+ async _onCurrentPartnerLongTypingTimeout() {
+ this._forceNotifyNextCurrentPartnerTypingStatus = true;
+ this._throttleNotifyCurrentPartnerTypingStatus.clear();
+ await this.async(
+ () => this._throttleNotifyCurrentPartnerTypingStatus({ isTyping: true })
+ );
+ }
+
+ /**
+ * @private
+ * @param {mail.partner} partner
+ */
+ async _onOtherMemberLongTypingTimeout(partner) {
+ if (!this.typingMembers.includes(partner)) {
+ this._otherMembersLongTypingTimers.delete(partner);
+ return;
+ }
+ this.unregisterOtherMemberTypingMember(partner);
+ }
+
+ }
+
+ Thread.fields = {
+ /**
+ * Determines the `mail.activity` that belong to `this`, assuming `this`
+ * has activities (@see hasActivities).
+ */
+ activities: one2many('mail.activity', {
+ inverse: 'thread',
+ }),
+ /**
+ * Serves as compute dependency.
+ */
+ activitiesState: attr({
+ related: 'activities.state',
+ }),
+ allAttachments: many2many('mail.attachment', {
+ compute: '_computeAllAttachments',
+ dependencies: [
+ 'attachments',
+ 'originThreadAttachments',
+ ],
+ }),
+ areAttachmentsLoaded: attr({
+ default: false,
+ }),
+ /**
+ * States whether followers have been loaded at least once for this
+ * thread.
+ */
+ areFollowersLoaded: attr({
+ default: false,
+ }),
+ attachments: many2many('mail.attachment', {
+ inverse: 'threads',
+ }),
+ caches: one2many('mail.thread_cache', {
+ inverse: 'thread',
+ isCausal: true,
+ }),
+ channel_type: attr(),
+ /**
+ * States the `mail.chat_window` related to `this`. Serves as compute
+ * dependency. It is computed from the inverse relation and it should
+ * otherwise be considered read-only.
+ */
+ chatWindow: one2one('mail.chat_window', {
+ inverse: 'thread',
+ }),
+ /**
+ * Serves as compute dependency.
+ */
+ chatWindowIsFolded: attr({
+ related: 'chatWindow.isFolded',
+ }),
+ composer: one2one('mail.composer', {
+ default: [['create']],
+ inverse: 'thread',
+ isCausal: true,
+ }),
+ correspondent: many2one('mail.partner', {
+ compute: '_computeCorrespondent',
+ dependencies: [
+ 'channel_type',
+ 'members',
+ 'messagingCurrentPartner',
+ ],
+ inverse: 'correspondentThreads',
+ }),
+ correspondentNameOrDisplayName: attr({
+ related: 'correspondent.nameOrDisplayName',
+ }),
+ counter: attr({
+ default: 0,
+ }),
+ creator: many2one('mail.user'),
+ custom_channel_name: attr(),
+ displayName: attr({
+ compute: '_computeDisplayName',
+ dependencies: [
+ 'channel_type',
+ 'correspondent',
+ 'correspondentNameOrDisplayName',
+ 'custom_channel_name',
+ 'name',
+ ],
+ }),
+ followersPartner: many2many('mail.partner', {
+ related: 'followers.partner',
+ }),
+ followers: one2many('mail.follower', {
+ inverse: 'followedThread',
+ }),
+ /**
+ * States the `mail.activity` that belongs to `this` and that are
+ * planned in the future (due later than today).
+ */
+ futureActivities: one2many('mail.activity', {
+ compute: '_computeFutureActivities',
+ dependencies: ['activitiesState'],
+ }),
+ group_based_subscription: attr({
+ default: false,
+ }),
+ /**
+ * States whether `this` has activities (`mail.activity.mixin` server side).
+ */
+ hasActivities: attr({
+ default: false,
+ }),
+ /**
+ * Determine whether this thread has the seen indicators (V and VV)
+ * enabled or not.
+ */
+ hasSeenIndicators: attr({
+ compute: '_computeHasSeenIndicators',
+ default: false,
+ dependencies: [
+ 'channel_type',
+ 'mass_mailing',
+ 'model',
+ ],
+ }),
+ id: attr(),
+ /**
+ * States whether this thread is a `mail.channel` qualified as chat.
+ *
+ * Useful to list chat channels, like in messaging menu with the filter
+ * 'chat'.
+ */
+ isChatChannel: attr({
+ compute: '_computeIsChatChannel',
+ dependencies: [
+ 'channel_type',
+ ],
+ default: false,
+ }),
+ isCurrentPartnerFollowing: attr({
+ compute: '_computeIsCurrentPartnerFollowing',
+ default: false,
+ dependencies: [
+ 'followersPartner',
+ 'messagingCurrentPartner',
+ ],
+ }),
+ /**
+ * States whether `this` is currently loading attachments.
+ */
+ isLoadingAttachments: attr({
+ default: false,
+ }),
+ isModeratedByCurrentPartner: attr({
+ compute: '_computeIsModeratedByCurrentPartner',
+ dependencies: [
+ 'messagingCurrentPartner',
+ 'moderators',
+ ],
+ }),
+ /**
+ * Determine if there is a pending pin state change, which is a change
+ * of pin state requested by the client but not yet confirmed by the
+ * server.
+ *
+ * This field can be updated to immediately change the pin state on the
+ * interface and to notify the server of the new state.
+ */
+ isPendingPinned: attr(),
+ /**
+ * Boolean that determines whether this thread is pinned
+ * in discuss and present in the messaging menu.
+ */
+ isPinned: attr({
+ compute: '_computeIsPinned',
+ dependencies: [
+ 'isPendingPinned',
+ 'isServerPinned',
+ ],
+ }),
+ /**
+ * Determine the last pin state known by the server, which is the pin
+ * state displayed after initialization or when the last pending
+ * pin state change was confirmed by the server.
+ *
+ * This field should be considered read only in most situations. Only
+ * the code handling pin state change from the server should typically
+ * update it.
+ */
+ isServerPinned: attr({
+ default: false,
+ }),
+ isTemporary: attr({
+ default: false,
+ }),
+ is_moderator: attr({
+ default: false,
+ }),
+ lastCurrentPartnerMessageSeenByEveryone: many2one('mail.message', {
+ compute: '_computeLastCurrentPartnerMessageSeenByEveryone',
+ dependencies: [
+ 'messagingCurrentPartner',
+ 'orderedNonTransientMessages',
+ 'partnerSeenInfos',
+ ],
+ }),
+ /**
+ * Last message of the thread, could be a transient one.
+ */
+ lastMessage: many2one('mail.message', {
+ compute: '_computeLastMessage',
+ dependencies: ['orderedMessages'],
+ }),
+ lastNeedactionMessage: many2one('mail.message', {
+ compute: '_computeLastNeedactionMessage',
+ dependencies: ['needactionMessages'],
+ }),
+ /**
+ * States the last known needaction message having this thread as origin.
+ */
+ lastNeedactionMessageAsOriginThread: many2one('mail.message', {
+ compute: '_computeLastNeedactionMessageAsOriginThread',
+ dependencies: [
+ 'needactionMessagesAsOriginThread',
+ ],
+ }),
+ /**
+ * Last non-transient message.
+ */
+ lastNonTransientMessage: many2one('mail.message', {
+ compute: '_computeLastNonTransientMessage',
+ dependencies: ['orderedNonTransientMessages'],
+ }),
+ /**
+ * Last seen message id of the channel by current partner.
+ *
+ * Also, it needs to be kept as an id because it's considered like a "date" and could stay
+ * even if corresponding message is deleted. It is basically used to know which
+ * messages are before or after it.
+ */
+ lastSeenByCurrentPartnerMessageId: attr({
+ compute: '_computeLastSeenByCurrentPartnerMessageId',
+ default: 0,
+ dependencies: [
+ 'lastSeenByCurrentPartnerMessageId',
+ 'messagingCurrentPartner',
+ 'orderedMessages',
+ 'orderedMessagesIsTransient',
+ // FIXME missing dependency 'orderedMessages.author', (task-2261221)
+ ],
+ }),
+ /**
+ * Local value of message unread counter, that means it is based on initial server value and
+ * updated with interface updates.
+ */
+ localMessageUnreadCounter: attr({
+ compute: '_computeLocalMessageUnreadCounter',
+ dependencies: [
+ 'lastSeenByCurrentPartnerMessageId',
+ 'messagingCurrentPartner',
+ 'orderedMessages',
+ 'serverLastMessageId',
+ 'serverMessageUnreadCounter',
+ ],
+ }),
+ mainCache: one2one('mail.thread_cache', {
+ compute: '_computeMainCache',
+ }),
+ mass_mailing: attr({
+ default: false,
+ }),
+ members: many2many('mail.partner', {
+ inverse: 'memberThreads',
+ }),
+ /**
+ * Determines the message before which the "new message" separator must
+ * be positioned, if any.
+ */
+ messageAfterNewMessageSeparator: many2one('mail.message', {
+ compute: '_computeMessageAfterNewMessageSeparator',
+ dependencies: [
+ 'lastSeenByCurrentPartnerMessageId',
+ 'localMessageUnreadCounter',
+ 'model',
+ 'orderedMessages',
+ ],
+ }),
+ message_needaction_counter: attr({
+ default: 0,
+ }),
+ /**
+ * All messages that this thread is linked to.
+ * Note that this field is automatically computed by inverse
+ * computed field. This field is readonly.
+ */
+ messages: many2many('mail.message', {
+ inverse: 'threads',
+ }),
+ /**
+ * All messages that have been originally posted in this thread.
+ */
+ messagesAsOriginThread: one2many('mail.message', {
+ inverse: 'originThread',
+ }),
+ /**
+ * Serves as compute dependency.
+ */
+ messagesAsOriginThreadIsNeedaction: attr({
+ related: 'messagesAsOriginThread.isNeedaction',
+ }),
+ /**
+ * All messages that are contained on this channel on the server.
+ * Equivalent to the inverse of python field `channel_ids`.
+ */
+ messagesAsServerChannel: many2many('mail.message', {
+ inverse: 'serverChannels',
+ }),
+ /**
+ * Serves as compute dependency.
+ */
+ messagesIsNeedaction: attr({
+ related: 'messages.isNeedaction',
+ }),
+ messageSeenIndicators: one2many('mail.message_seen_indicator', {
+ inverse: 'thread',
+ isCausal: true,
+ }),
+ messaging: many2one('mail.messaging', {
+ compute: '_computeMessaging',
+ }),
+ messagingCurrentPartner: many2one('mail.partner', {
+ related: 'messaging.currentPartner',
+ }),
+ model: attr(),
+ model_name: attr(),
+ moderation: attr({
+ default: false,
+ }),
+ /**
+ * Partners that are moderating this thread (only applies to channels).
+ */
+ moderators: many2many('mail.partner', {
+ inverse: 'moderatedChannels',
+ }),
+ moduleIcon: attr(),
+ name: attr(),
+ needactionMessages: many2many('mail.message', {
+ compute: '_computeNeedactionMessages',
+ dependencies: [
+ 'messages',
+ 'messagesIsNeedaction',
+ ],
+ }),
+ /**
+ * States all known needaction messages having this thread as origin.
+ */
+ needactionMessagesAsOriginThread: many2many('mail.message', {
+ compute: '_computeNeedactionMessagesAsOriginThread',
+ dependencies: [
+ 'messagesAsOriginThread',
+ 'messagesAsOriginThreadIsNeedaction',
+ ],
+ }),
+ /**
+ * Not a real field, used to trigger `_onChangeFollowersPartner` when one of
+ * the dependencies changes.
+ */
+ onChangeFollowersPartner: attr({
+ compute: '_onChangeFollowersPartner',
+ dependencies: [
+ 'followersPartner',
+ ],
+ }),
+ /**
+ * Not a real field, used to trigger `_onChangeLastSeenByCurrentPartnerMessageId` when one of
+ * the dependencies changes.
+ */
+ onChangeLastSeenByCurrentPartnerMessageId: attr({
+ compute: '_onChangeLastSeenByCurrentPartnerMessageId',
+ dependencies: [
+ 'lastSeenByCurrentPartnerMessageId',
+ ],
+ }),
+ /**
+ * Not a real field, used to trigger `_onChangeThreadViews` when one of
+ * the dependencies changes.
+ */
+ onChangeThreadView: attr({
+ compute: '_onChangeThreadViews',
+ dependencies: [
+ 'threadViews',
+ ],
+ }),
+ /**
+ * Not a real field, used to trigger `_onIsServerPinnedChanged` when one of
+ * the dependencies changes.
+ */
+ onIsServerPinnedChanged: attr({
+ compute: '_onIsServerPinnedChanged',
+ dependencies: [
+ 'isServerPinned',
+ ],
+ }),
+ /**
+ * Not a real field, used to trigger `_onServerFoldStateChanged` when one of
+ * the dependencies changes.
+ */
+ onServerFoldStateChanged: attr({
+ compute: '_onServerFoldStateChanged',
+ dependencies: [
+ 'serverFoldState',
+ ],
+ }),
+ /**
+ * All messages ordered like they are displayed.
+ */
+ orderedMessages: many2many('mail.message', {
+ compute: '_computeOrderedMessages',
+ dependencies: ['messages'],
+ }),
+ /**
+ * Serves as compute dependency. (task-2261221)
+ */
+ orderedMessagesIsTransient: attr({
+ related: 'orderedMessages.isTransient',
+ }),
+ /**
+ * All messages ordered like they are displayed. This field does not
+ * contain transient messages which are not "real" records.
+ */
+ orderedNonTransientMessages: many2many('mail.message', {
+ compute: '_computeOrderedNonTransientMessages',
+ dependencies: [
+ 'orderedMessages',
+ 'orderedMessagesIsTransient',
+ ],
+ }),
+ /**
+ * Ordered typing members on this thread, excluding the current partner.
+ */
+ orderedOtherTypingMembers: many2many('mail.partner', {
+ compute: '_computeOrderedOtherTypingMembers',
+ dependencies: ['orderedTypingMembers'],
+ }),
+ /**
+ * Ordered typing members on this thread. Lower index means this member
+ * is currently typing for the longest time. This list includes current
+ * partner as typer.
+ */
+ orderedTypingMembers: many2many('mail.partner', {
+ compute: '_computeOrderedTypingMembers',
+ dependencies: [
+ 'orderedTypingMemberLocalIds',
+ 'typingMembers',
+ ],
+ }),
+ /**
+ * Technical attribute to manage ordered list of typing members.
+ */
+ orderedTypingMemberLocalIds: attr({
+ default: [],
+ }),
+ originThreadAttachments: one2many('mail.attachment', {
+ inverse: 'originThread',
+ }),
+ /**
+ * States the `mail.activity` that belongs to `this` and that are
+ * overdue (due earlier than today).
+ */
+ overdueActivities: one2many('mail.activity', {
+ compute: '_computeOverdueActivities',
+ dependencies: ['activitiesState'],
+ }),
+ partnerSeenInfos: one2many('mail.thread_partner_seen_info', {
+ inverse: 'thread',
+ isCausal: true,
+ }),
+ /**
+ * Determine if there is a pending seen message change, which is a change
+ * of seen message requested by the client but not yet confirmed by the
+ * server.
+ */
+ pendingSeenMessageId: attr(),
+ public: attr(),
+ /**
+ * Determine the last fold state known by the server, which is the fold
+ * state displayed after initialization or when the last pending
+ * fold state change was confirmed by the server.
+ *
+ * This field should be considered read only in most situations. Only
+ * the code handling fold state change from the server should typically
+ * update it.
+ */
+ serverFoldState: attr({
+ default: 'closed',
+ }),
+ /**
+ * Last message id considered by the server.
+ *
+ * Useful to compute localMessageUnreadCounter field.
+ *
+ * @see localMessageUnreadCounter
+ */
+ serverLastMessageId: attr({
+ default: 0,
+ }),
+ /**
+ * Message unread counter coming from server.
+ *
+ * Value of this field is unreliable, due to dynamic nature of
+ * messaging. So likely outdated/unsync with server. Should use
+ * localMessageUnreadCounter instead, which smartly guess the actual
+ * message unread counter at all time.
+ *
+ * @see localMessageUnreadCounter
+ */
+ serverMessageUnreadCounter: attr({
+ default: 0,
+ }),
+ /**
+ * Determines the `mail.suggested_recipient_info` concerning `this`.
+ */
+ suggestedRecipientInfoList: one2many('mail.suggested_recipient_info', {
+ inverse: 'thread',
+ }),
+ threadViews: one2many('mail.thread_view', {
+ inverse: 'thread',
+ }),
+ /**
+ * States the `mail.activity` that belongs to `this` and that are due
+ * specifically today.
+ */
+ todayActivities: one2many('mail.activity', {
+ compute: '_computeTodayActivities',
+ dependencies: ['activitiesState'],
+ }),
+ /**
+ * Members that are currently typing something in the composer of this
+ * thread, including current partner.
+ */
+ typingMembers: many2many('mail.partner'),
+ /**
+ * Text that represents the status on this thread about typing members.
+ */
+ typingStatusText: attr({
+ compute: '_computeTypingStatusText',
+ default: '',
+ dependencies: ['orderedOtherTypingMembers'],
+ }),
+ /**
+ * URL to access to the conversation.
+ */
+ url: attr({
+ compute: '_computeUrl',
+ default: '',
+ dependencies: [
+ 'id',
+ 'model',
+ ]
+ }),
+ uuid: attr(),
+ };
+
+ Thread.modelName = 'mail.thread';
+
+ return Thread;
+}
+
+registerNewModel('mail.thread', factory);
+
+});
diff --git a/addons/mail/static/src/models/thread/thread_tests.js b/addons/mail/static/src/models/thread/thread_tests.js
new file mode 100644
index 00000000..a535cf4e
--- /dev/null
+++ b/addons/mail/static/src/models/thread/thread_tests.js
@@ -0,0 +1,150 @@
+odoo.define('mail/static/src/models/thread/thread_tests.js', function (require) {
+'use strict';
+
+const { afterEach, beforeEach, start } = require('mail/static/src/utils/test_utils.js');
+
+QUnit.module('mail', {}, function () {
+QUnit.module('models', {}, function () {
+QUnit.module('thread', {}, function () {
+QUnit.module('thread_tests.js', {
+ beforeEach() {
+ beforeEach(this);
+
+ this.start = async params => {
+ const { env, widget } = await start(Object.assign({}, params, {
+ data: this.data,
+ }));
+ this.env = env;
+ this.widget = widget;
+ };
+ },
+ afterEach() {
+ afterEach(this);
+ },
+});
+
+QUnit.test('inbox & starred mailboxes', async function (assert) {
+ assert.expect(10);
+
+ await this.start();
+ const mailboxInbox = this.env.messaging.inbox;
+ const mailboxStarred = this.env.messaging.starred;
+ assert.ok(mailboxInbox, "should have mailbox inbox");
+ assert.ok(mailboxStarred, "should have mailbox starred");
+ assert.strictEqual(mailboxInbox.model, 'mail.box');
+ assert.strictEqual(mailboxInbox.counter, 0);
+ assert.strictEqual(mailboxInbox.id, 'inbox');
+ assert.strictEqual(mailboxInbox.name, "Inbox"); // language-dependent
+ assert.strictEqual(mailboxStarred.model, 'mail.box');
+ assert.strictEqual(mailboxStarred.counter, 0);
+ assert.strictEqual(mailboxStarred.id, 'starred');
+ assert.strictEqual(mailboxStarred.name, "Starred"); // language-dependent
+});
+
+QUnit.test('create (channel)', async function (assert) {
+ assert.expect(23);
+
+ await this.start();
+ assert.notOk(this.env.models['mail.partner'].findFromIdentifyingData({ id: 9 }));
+ assert.notOk(this.env.models['mail.partner'].findFromIdentifyingData({ id: 10 }));
+ assert.notOk(this.env.models['mail.thread'].findFromIdentifyingData({
+ id: 100,
+ model: 'mail.channel',
+ }));
+
+ const thread = this.env.models['mail.thread'].create({
+ channel_type: 'channel',
+ id: 100,
+ members: [['insert', [{
+ email: "john@example.com",
+ id: 9,
+ name: "John",
+ }, {
+ email: "fred@example.com",
+ id: 10,
+ name: "Fred",
+ }]]],
+ message_needaction_counter: 6,
+ model: 'mail.channel',
+ name: "General",
+ public: 'public',
+ serverMessageUnreadCounter: 5,
+ });
+ assert.ok(thread);
+ assert.ok(this.env.models['mail.partner'].findFromIdentifyingData({ id: 9 }));
+ assert.ok(this.env.models['mail.partner'].findFromIdentifyingData({ id: 10 }));
+ assert.ok(this.env.models['mail.thread'].findFromIdentifyingData({
+ id: 100,
+ model: 'mail.channel',
+ }));
+ const partner9 = this.env.models['mail.partner'].findFromIdentifyingData({ id: 9 });
+ const partner10 = this.env.models['mail.partner'].findFromIdentifyingData({ id: 10 });
+ assert.strictEqual(thread, this.env.models['mail.thread'].findFromIdentifyingData({
+ id: 100,
+ model: 'mail.channel',
+ }));
+ assert.strictEqual(thread.model, 'mail.channel');
+ assert.strictEqual(thread.channel_type, 'channel');
+ assert.strictEqual(thread.id, 100);
+ assert.ok(thread.members.includes(partner9));
+ assert.ok(thread.members.includes(partner10));
+ assert.strictEqual(thread.message_needaction_counter, 6);
+ assert.strictEqual(thread.name, "General");
+ assert.strictEqual(thread.public, 'public');
+ assert.strictEqual(thread.serverMessageUnreadCounter, 5);
+ assert.strictEqual(partner9.email, "john@example.com");
+ assert.strictEqual(partner9.id, 9);
+ assert.strictEqual(partner9.name, "John");
+ assert.strictEqual(partner10.email, "fred@example.com");
+ assert.strictEqual(partner10.id, 10);
+ assert.strictEqual(partner10.name, "Fred");
+});
+
+QUnit.test('create (chat)', async function (assert) {
+ assert.expect(15);
+
+ await this.start();
+ assert.notOk(this.env.models['mail.partner'].findFromIdentifyingData({ id: 5 }));
+ assert.notOk(this.env.models['mail.thread'].findFromIdentifyingData({
+ id: 200,
+ model: 'mail.channel',
+ }));
+
+ const channel = this.env.models['mail.thread'].create({
+ channel_type: 'chat',
+ id: 200,
+ members: [['insert', {
+ email: "demo@example.com",
+ id: 5,
+ im_status: 'online',
+ name: "Demo",
+ }]],
+ model: 'mail.channel',
+ });
+ assert.ok(channel);
+ assert.ok(this.env.models['mail.thread'].findFromIdentifyingData({
+ id: 200,
+ model: 'mail.channel',
+ }));
+ assert.ok(this.env.models['mail.partner'].findFromIdentifyingData({ id: 5 }));
+ const partner = this.env.models['mail.partner'].findFromIdentifyingData({ id: 5 });
+ assert.strictEqual(channel, this.env.models['mail.thread'].findFromIdentifyingData({
+ id: 200,
+ model: 'mail.channel',
+ }));
+ assert.strictEqual(channel.model, 'mail.channel');
+ assert.strictEqual(channel.channel_type, 'chat');
+ assert.strictEqual(channel.id, 200);
+ assert.ok(channel.correspondent);
+ assert.strictEqual(partner, channel.correspondent);
+ assert.strictEqual(partner.email, "demo@example.com");
+ assert.strictEqual(partner.id, 5);
+ assert.strictEqual(partner.im_status, 'online');
+ assert.strictEqual(partner.name, "Demo");
+});
+
+});
+});
+});
+
+});
diff --git a/addons/mail/static/src/models/thread_cache/thread_cache.js b/addons/mail/static/src/models/thread_cache/thread_cache.js
new file mode 100644
index 00000000..1760a509
--- /dev/null
+++ b/addons/mail/static/src/models/thread_cache/thread_cache.js
@@ -0,0 +1,617 @@
+odoo.define('mail/static/src/models/thread_cache/thread_cache.js', function (require) {
+'use strict';
+
+const { registerNewModel } = require('mail/static/src/model/model_core.js');
+const { attr, many2many, many2one, one2many } = require('mail/static/src/model/model_field.js');
+
+function factory(dependencies) {
+
+ class ThreadCache extends dependencies['mail.model'] {
+
+ //----------------------------------------------------------------------
+ // Public
+ //----------------------------------------------------------------------
+
+ /**
+ * @returns {mail.message[]|undefined}
+ */
+ async loadMoreMessages() {
+ if (this.isAllHistoryLoaded || this.isLoading) {
+ return;
+ }
+ if (!this.isLoaded) {
+ this.update({ isCacheRefreshRequested: true });
+ return;
+ }
+ this.update({ isLoadingMore: true });
+ const messageIds = this.fetchedMessages.map(message => message.id);
+ const limit = 30;
+ const fetchedMessages = await this.async(() => this._loadMessages({
+ extraDomain: [['id', '<', Math.min(...messageIds)]],
+ limit,
+ }));
+ this.update({ isLoadingMore: false });
+ if (fetchedMessages.length < limit) {
+ this.update({ isAllHistoryLoaded: true });
+ }
+ for (const threadView of this.threadViews) {
+ threadView.addComponentHint('more-messages-loaded', { fetchedMessages });
+ }
+ return fetchedMessages;
+ }
+
+ /**
+ * @returns {mail.message[]|undefined}
+ */
+ async loadNewMessages() {
+ if (this.isLoading) {
+ return;
+ }
+ if (!this.isLoaded) {
+ this.update({ isCacheRefreshRequested: true });
+ return;
+ }
+ const messageIds = this.fetchedMessages.map(message => message.id);
+ const fetchedMessages = this._loadMessages({
+ extraDomain: [['id', '>', Math.max(...messageIds)]],
+ limit: false,
+ });
+ for (const threadView of this.threadViews) {
+ threadView.addComponentHint('new-messages-loaded', { fetchedMessages });
+ }
+ return fetchedMessages;
+ }
+
+ //----------------------------------------------------------------------
+ // Private
+ //----------------------------------------------------------------------
+
+ /**
+ * @override
+ */
+ static _createRecordLocalId(data) {
+ const {
+ stringifiedDomain = '[]',
+ thread: [[commandInsert, thread]],
+ } = data;
+ return `${this.modelName}_[${thread.localId}]_<${stringifiedDomain}>`;
+ }
+
+ /**
+ * @private
+ */
+ _computeCheckedMessages() {
+ const messagesWithoutCheckbox = this.checkedMessages.filter(
+ message => !message.hasCheckbox
+ );
+ return [['unlink', messagesWithoutCheckbox]];
+ }
+
+ /**
+ * @private
+ * @returns {mail.message[]}
+ */
+ _computeFetchedMessages() {
+ if (!this.thread) {
+ return [['unlink-all']];
+ }
+ const toUnlinkMessages = [];
+ for (const message of this.fetchedMessages) {
+ if (!this.thread.messages.includes(message)) {
+ toUnlinkMessages.push(message);
+ }
+ }
+ return [['unlink', toUnlinkMessages]];
+ }
+
+ /**
+ * @private
+ * @returns {mail.message|undefined}
+ */
+ _computeLastFetchedMessage() {
+ const {
+ length: l,
+ [l - 1]: lastFetchedMessage,
+ } = this.orderedFetchedMessages;
+ if (!lastFetchedMessage) {
+ return [['unlink']];
+ }
+ return [['link', lastFetchedMessage]];
+ }
+
+ /**
+ * @private
+ * @returns {mail.message|undefined}
+ */
+ _computeLastMessage() {
+ const {
+ length: l,
+ [l - 1]: lastMessage,
+ } = this.orderedMessages;
+ if (!lastMessage) {
+ return [['unlink']];
+ }
+ return [['link', lastMessage]];
+ }
+
+ /**
+ * @private
+ * @returns {mail.message[]}
+ */
+ _computeMessages() {
+ if (!this.thread) {
+ return [['unlink-all']];
+ }
+ let messages = this.fetchedMessages;
+ if (this.stringifiedDomain !== '[]') {
+ return [['replace', messages]];
+ }
+ // main cache: adjust with newer messages
+ let newerMessages;
+ if (!this.lastFetchedMessage) {
+ newerMessages = this.thread.messages;
+ } else {
+ newerMessages = this.thread.messages.filter(message =>
+ message.id > this.lastFetchedMessage.id
+ );
+ }
+ messages = messages.concat(newerMessages);
+ return [['replace', messages]];
+ }
+
+ /**
+ *
+ * @private
+ * @returns {mail.message[]}
+ */
+ _computeNonEmptyMessages() {
+ return [['replace', this.messages.filter(message => !message.isEmpty)]];
+ }
+
+ /**
+ * @private
+ * @returns {mail.message[]}
+ */
+ _computeOrderedFetchedMessages() {
+ return [['replace', this.fetchedMessages.sort((m1, m2) => m1.id < m2.id ? -1 : 1)]];
+ }
+
+ /**
+ * @private
+ * @returns {mail.message[]}
+ */
+ _computeOrderedMessages() {
+ return [['replace', this.messages.sort((m1, m2) => m1.id < m2.id ? -1 : 1)]];
+ }
+
+ /**
+ * @private
+ * @returns {boolean}
+ */
+ _computeHasToLoadMessages() {
+ if (!this.thread) {
+ // happens during destroy or compute executed in wrong order
+ return false;
+ }
+ const wasCacheRefreshRequested = this.isCacheRefreshRequested;
+ // mark hint as processed
+ if (this.isCacheRefreshRequested) {
+ this.update({ isCacheRefreshRequested: false });
+ }
+ if (this.thread.isTemporary) {
+ // temporary threads don't exist on the server
+ return false;
+ }
+ if (!wasCacheRefreshRequested && this.threadViews.length === 0) {
+ // don't load message that won't be used
+ return false;
+ }
+ if (this.isLoading) {
+ // avoid duplicate RPC
+ return false;
+ }
+ if (!wasCacheRefreshRequested && this.isLoaded) {
+ // avoid duplicate RPC
+ return false;
+ }
+ const isMainCache = this.thread.mainCache === this;
+ if (isMainCache && this.isLoaded) {
+ // Ignore request on the main cache if it is already loaded or
+ // loading. Indeed the main cache is automatically sync with
+ // server updates already, so there is never a need to refresh
+ // it past the first time.
+ return false;
+ }
+ return true;
+ }
+
+ /**
+ * @private
+ * @returns {mail.message[]}
+ */
+ _computeUncheckedMessages() {
+ return [['replace', this.messages.filter(
+ message => message.hasCheckbox && !this.checkedMessages.includes(message)
+ )]];
+ }
+
+ /**
+ * @private
+ * @param {Array} domain
+ * @returns {Array}
+ */
+ _extendMessageDomain(domain) {
+ const thread = this.thread;
+ if (thread.model === 'mail.channel') {
+ return domain.concat([['channel_ids', 'in', [thread.id]]]);
+ } else if (thread === this.env.messaging.inbox) {
+ return domain.concat([['needaction', '=', true]]);
+ } else if (thread === this.env.messaging.starred) {
+ return domain.concat([
+ ['starred_partner_ids', 'in', [this.env.messaging.currentPartner.id]],
+ ]);
+ } else if (thread === this.env.messaging.history) {
+ return domain.concat([['needaction', '=', false]]);
+ } else if (thread === this.env.messaging.moderation) {
+ return domain.concat([['moderation_status', '=', 'pending_moderation']]);
+ } else {
+ // Avoid to load user_notification as these messages are not
+ // meant to be shown on chatters.
+ return domain.concat([
+ ['message_type', '!=', 'user_notification'],
+ ['model', '=', thread.model],
+ ['res_id', '=', thread.id],
+ ]);
+ }
+ }
+
+ /**
+ * @private
+ * @param {Object} [param0={}]
+ * @param {Array[]} [param0.extraDomain]
+ * @param {integer} [param0.limit=30]
+ * @returns {mail.message[]}
+ */
+ async _loadMessages({ extraDomain, limit = 30 } = {}) {
+ this.update({ isLoading: true });
+ const searchDomain = JSON.parse(this.stringifiedDomain);
+ let domain = searchDomain.length ? searchDomain : [];
+ domain = this._extendMessageDomain(domain);
+ if (extraDomain) {
+ domain = extraDomain.concat(domain);
+ }
+ const context = this.env.session.user_context;
+ const moderated_channel_ids = this.thread.moderation
+ ? [this.thread.id]
+ : undefined;
+ const messages = await this.async(() =>
+ this.env.models['mail.message'].performRpcMessageFetch(
+ domain,
+ limit,
+ moderated_channel_ids,
+ context,
+ )
+ );
+ this.update({
+ fetchedMessages: [['link', messages]],
+ isLoaded: true,
+ isLoading: false,
+ });
+ if (!extraDomain && messages.length < limit) {
+ this.update({ isAllHistoryLoaded: true });
+ }
+ this.env.messagingBus.trigger('o-thread-cache-loaded-messages', {
+ fetchedMessages: messages,
+ threadCache: this,
+ });
+ return messages;
+ }
+
+ /**
+ * Calls "mark all as read" when this thread becomes displayed in a
+ * view (which is notified by `isMarkAllAsReadRequested` being `true`),
+ * but delays the call until some other conditions are met, such as the
+ * messages being loaded.
+ * The reason to wait until messages are loaded is to avoid a race
+ * condition because "mark all as read" will change the state of the
+ * messages in parallel to fetch reading them.
+ *
+ * @private
+ */
+ _onChangeMarkAllAsRead() {
+ if (
+ !this.isMarkAllAsReadRequested ||
+ !this.thread ||
+ !this.thread.mainCache ||
+ !this.isLoaded ||
+ this.isLoading
+ ) {
+ // wait for change of state before deciding what to do
+ return;
+ }
+ this.update({ isMarkAllAsReadRequested: false });
+ if (
+ this.thread.isTemporary ||
+ this.thread.model === 'mail.box' ||
+ this.thread.mainCache !== this ||
+ this.threadViews.length === 0
+ ) {
+ // ignore the request
+ return;
+ }
+ this.env.models['mail.message'].markAllAsRead([
+ ['model', '=', this.thread.model],
+ ['res_id', '=', this.thread.id],
+ ]);
+ }
+
+ /**
+ * Loads this thread cache, by fetching the most recent messages in this
+ * conversation.
+ *
+ * @private
+ */
+ _onHasToLoadMessagesChanged() {
+ if (!this.hasToLoadMessages) {
+ return;
+ }
+ this._loadMessages().then(fetchedMessages => {
+ for (const threadView of this.threadViews) {
+ threadView.addComponentHint('messages-loaded', { fetchedMessages });
+ }
+ });
+ }
+
+ /**
+ * Handles change of messages on this thread cache. This is useful to
+ * refresh non-main caches that are currently displayed when the main
+ * cache receives updates. This is necessary because only the main cache
+ * is aware of changes in real time.
+ */
+ _onMessagesChanged() {
+ if (!this.thread) {
+ return;
+ }
+ if (this.thread.mainCache !== this) {
+ return;
+ }
+ for (const threadView of this.thread.threadViews) {
+ if (threadView.threadCache) {
+ threadView.threadCache.update({ isCacheRefreshRequested: true });
+ }
+ }
+ }
+
+ }
+
+ ThreadCache.fields = {
+ checkedMessages: many2many('mail.message', {
+ compute: '_computeCheckedMessages',
+ dependencies: [
+ 'checkedMessages',
+ 'messagesCheckboxes',
+ ],
+ inverse: 'checkedThreadCaches',
+ }),
+ /**
+ * List of messages that have been fetched by this cache.
+ *
+ * This DOES NOT necessarily includes all messages linked to this thread
+ * cache (@see messages field for that): it just contains list
+ * of successive messages that have been explicitly fetched by this
+ * cache. For all non-main caches, this corresponds to all messages.
+ * For the main cache, however, messages received from longpolling
+ * should be displayed on main cache but they have not been explicitly
+ * fetched by cache, so they ARE NOT in this list (at least, not until a
+ * fetch on this thread cache contains this message).
+ *
+ * The distinction between messages and fetched messages is important
+ * to manage "holes" in message list, while still allowing to display
+ * new messages on main cache of thread in real-time.
+ */
+ fetchedMessages: many2many('mail.message', {
+ // adjust with messages unlinked from thread
+ compute: '_computeFetchedMessages',
+ dependencies: ['threadMessages'],
+ }),
+ /**
+ * Determines whether `this` should load initial messages. This field is
+ * computed and should be considered read-only.
+ * @see `isCacheRefreshRequested` to request manual refresh of messages.
+ */
+ hasToLoadMessages: attr({
+ compute: '_computeHasToLoadMessages',
+ dependencies: [
+ 'isCacheRefreshRequested',
+ 'isLoaded',
+ 'isLoading',
+ 'thread',
+ 'threadIsTemporary',
+ 'threadMainCache',
+ 'threadViews',
+ ],
+ }),
+ isAllHistoryLoaded: attr({
+ default: false,
+ }),
+ isLoaded: attr({
+ default: false,
+ }),
+ isLoading: attr({
+ default: false,
+ }),
+ isLoadingMore: attr({
+ default: false,
+ }),
+ /**
+ * Determines whether `this` should consider refreshing its messages.
+ * This field is a hint that may or may not lead to an actual refresh.
+ * @see `hasToLoadMessages`
+ */
+ isCacheRefreshRequested: attr({
+ default: false,
+ }),
+ /**
+ * Determines whether this cache should consider calling "mark all as
+ * read" on this thread.
+ *
+ * This field is a hint that may or may not lead to an actual call.
+ * @see `_onChangeMarkAllAsRead`
+ */
+ isMarkAllAsReadRequested: attr({
+ default: false,
+ }),
+ /**
+ * Last message that has been fetched by this thread cache.
+ *
+ * This DOES NOT necessarily mean the last message linked to this thread
+ * cache (@see lastMessage field for that). @see fetchedMessages field
+ * for a deeper explanation about "fetched" messages.
+ */
+ lastFetchedMessage: many2one('mail.message', {
+ compute: '_computeLastFetchedMessage',
+ dependencies: ['orderedFetchedMessages'],
+ }),
+ lastMessage: many2one('mail.message', {
+ compute: '_computeLastMessage',
+ dependencies: ['orderedMessages'],
+ }),
+ messagesCheckboxes: attr({
+ related: 'messages.hasCheckbox',
+ }),
+ /**
+ * List of messages linked to this cache.
+ */
+ messages: many2many('mail.message', {
+ compute: '_computeMessages',
+ dependencies: [
+ 'fetchedMessages',
+ 'threadMessages',
+ ],
+ }),
+ /**
+ * IsEmpty trait of all messages.
+ * Serves as compute dependency.
+ */
+ messagesAreEmpty: attr({
+ related: 'messages.isEmpty'
+ }),
+ /**
+ * List of non empty messages linked to this cache.
+ */
+ nonEmptyMessages: many2many('mail.message', {
+ compute: '_computeNonEmptyMessages',
+ dependencies: [
+ 'messages',
+ 'messagesAreEmpty',
+ ],
+ }),
+ /**
+ * Not a real field, used to trigger its compute method when one of the
+ * dependencies changes.
+ */
+ onChangeMarkAllAsRead: attr({
+ compute: '_onChangeMarkAllAsRead',
+ dependencies: [
+ 'isLoaded',
+ 'isLoading',
+ 'isMarkAllAsReadRequested',
+ 'thread',
+ 'threadIsTemporary',
+ 'threadMainCache',
+ 'threadModel',
+ 'threadViews',
+ ],
+ }),
+ /**
+ * Loads initial messages from `this`.
+ * This is not a "real" field, its compute function is used to trigger
+ * the load of messages at the right time.
+ */
+ onHasToLoadMessagesChanged: attr({
+ compute: '_onHasToLoadMessagesChanged',
+ dependencies: [
+ 'hasToLoadMessages',
+ ],
+ }),
+ /**
+ * Not a real field, used to trigger `_onMessagesChanged` when one of
+ * the dependencies changes.
+ */
+ onMessagesChanged: attr({
+ compute: '_onMessagesChanged',
+ dependencies: [
+ 'messages',
+ 'thread',
+ 'threadMainCache',
+ ],
+ }),
+ /**
+ * Ordered list of messages that have been fetched by this cache.
+ *
+ * This DOES NOT necessarily includes all messages linked to this thread
+ * cache (@see orderedMessages field for that). @see fetchedMessages
+ * field for deeper explanation about "fetched" messages.
+ */
+ orderedFetchedMessages: many2many('mail.message', {
+ compute: '_computeOrderedFetchedMessages',
+ dependencies: ['fetchedMessages'],
+ }),
+ /**
+ * Ordered list of messages linked to this cache.
+ */
+ orderedMessages: many2many('mail.message', {
+ compute: '_computeOrderedMessages',
+ dependencies: ['messages'],
+ }),
+ stringifiedDomain: attr({
+ default: '[]',
+ }),
+ thread: many2one('mail.thread', {
+ inverse: 'caches',
+ }),
+ /**
+ * Serves as compute dependency.
+ */
+ threadIsTemporary: attr({
+ related: 'thread.isTemporary',
+ }),
+ /**
+ * Serves as compute dependency.
+ */
+ threadMainCache: many2one('mail.thread_cache', {
+ related: 'thread.mainCache',
+ }),
+ threadMessages: many2many('mail.message', {
+ related: 'thread.messages',
+ }),
+ /**
+ * Serves as compute dependency.
+ */
+ threadModel: attr({
+ related: 'thread.model',
+ }),
+ /**
+ * States the 'mail.thread_view' that are currently displaying `this`.
+ */
+ threadViews: one2many('mail.thread_view', {
+ inverse: 'threadCache',
+ }),
+ uncheckedMessages: many2many('mail.message', {
+ compute: '_computeUncheckedMessages',
+ dependencies: [
+ 'checkedMessages',
+ 'messagesCheckboxes',
+ 'messages',
+ ],
+ }),
+ };
+
+ ThreadCache.modelName = 'mail.thread_cache';
+
+ return ThreadCache;
+}
+
+registerNewModel('mail.thread_cache', factory);
+
+});
diff --git a/addons/mail/static/src/models/thread_partner_seen_info/thread_partner_seen_info.js b/addons/mail/static/src/models/thread_partner_seen_info/thread_partner_seen_info.js
new file mode 100644
index 00000000..8fd3b95a
--- /dev/null
+++ b/addons/mail/static/src/models/thread_partner_seen_info/thread_partner_seen_info.js
@@ -0,0 +1,109 @@
+odoo.define('mail/static/src/models/thread_partner_seen_info/thread_partner_seen_info.js', function (require) {
+'use strict';
+
+const { registerNewModel } = require('mail/static/src/model/model_core.js');
+const { attr, many2one } = require('mail/static/src/model/model_field.js');
+
+function factory(dependencies) {
+
+ class ThreadPartnerSeenInfo extends dependencies['mail.model'] {
+
+ //----------------------------------------------------------------------
+ // Private
+ //----------------------------------------------------------------------
+
+ /**
+ * @override
+ */
+ static _createRecordLocalId(data) {
+ const { channelId, partnerId } = data;
+ return `${this.modelName}_${channelId}_${partnerId}`;
+ }
+
+ /**
+ * @private
+ * @returns {mail.partner|undefined}
+ */
+ _computePartner() {
+ return [['insert', { id: this.partnerId }]];
+ }
+
+ /**
+ * @private
+ * @returns {mail.thread|undefined}
+ */
+ _computeThread() {
+ return [['insert', {
+ id: this.channelId,
+ model: 'mail.channel',
+ }]];
+ }
+
+ }
+
+ ThreadPartnerSeenInfo.modelName = 'mail.thread_partner_seen_info';
+
+ ThreadPartnerSeenInfo.fields = {
+ /**
+ * The id of channel this seen info is related to.
+ *
+ * Should write on this field to set relation between the channel and
+ * this seen info, not on `thread`.
+ *
+ * Reason for not setting the relation directly is the necessity to
+ * uniquely identify a seen info based on channel and partner from data.
+ * Relational data are list of commands, which is problematic to deduce
+ * identifying records.
+ *
+ * TODO: task-2322536 (normalize relational data) & task-2323665
+ * (required fields) should improve and let us just use the relational
+ * fields.
+ */
+ channelId: attr(),
+ lastFetchedMessage: many2one('mail.message'),
+ lastSeenMessage: many2one('mail.message'),
+ /**
+ * Partner that this seen info is related to.
+ *
+ * Should not write on this field to update relation, and instead
+ * should write on @see partnerId field.
+ */
+ partner: many2one('mail.partner', {
+ compute: '_computePartner',
+ dependencies: ['partnerId'],
+ }),
+ /**
+ * The id of partner this seen info is related to.
+ *
+ * Should write on this field to set relation between the partner and
+ * this seen info, not on `partner`.
+ *
+ * Reason for not setting the relation directly is the necessity to
+ * uniquely identify a seen info based on channel and partner from data.
+ * Relational data are list of commands, which is problematic to deduce
+ * identifying records.
+ *
+ * TODO: task-2322536 (normalize relational data) & task-2323665
+ * (required fields) should improve and let us just use the relational
+ * fields.
+ */
+ partnerId: attr(),
+ /**
+ * Thread (channel) that this seen info is related to.
+ *
+ * Should not write on this field to update relation, and instead
+ * should write on @see channelId field.
+ */
+ thread: many2one('mail.thread', {
+ compute: '_computeThread',
+ dependencies: ['channelId'],
+ inverse: 'partnerSeenInfos',
+ }),
+ };
+
+ return ThreadPartnerSeenInfo;
+}
+
+registerNewModel('mail.thread_partner_seen_info', factory);
+
+});
diff --git a/addons/mail/static/src/models/thread_view/thread_view.js b/addons/mail/static/src/models/thread_view/thread_view.js
new file mode 100644
index 00000000..a7ccf0c7
--- /dev/null
+++ b/addons/mail/static/src/models/thread_view/thread_view.js
@@ -0,0 +1,441 @@
+odoo.define('mail/static/src/models/thread_view/thread_view.js', function (require) {
+'use strict';
+
+const { registerNewModel } = require('mail/static/src/model/model_core.js');
+const { RecordDeletedError } = require('mail/static/src/model/model_errors.js');
+const { attr, many2many, many2one, one2one } = require('mail/static/src/model/model_field.js');
+const { clear } = require('mail/static/src/model/model_field_command.js');
+
+function factory(dependencies) {
+
+ class ThreadView extends dependencies['mail.model'] {
+
+ /**
+ * @override
+ */
+ _willDelete() {
+ this.env.browser.clearTimeout(this._loaderTimeout);
+ return super._willDelete(...arguments);
+ }
+
+ //----------------------------------------------------------------------
+ // Public
+ //----------------------------------------------------------------------
+
+ /**
+ * This function register a hint for the component related to this
+ * record. Hints are information on changes around this viewer that
+ * make require adjustment on the component. For instance, if this
+ * ThreadView initiated a thread cache load and it now has become
+ * loaded, then it may need to auto-scroll to last message.
+ *
+ * @param {string} hintType name of the hint. Used to determine what's
+ * the broad type of adjustement the component has to do.
+ * @param {any} [hintData] data of the hint. Used to fine-tune
+ * adjustments on the component.
+ */
+ addComponentHint(hintType, hintData) {
+ const hint = { data: hintData, type: hintType };
+ this.update({
+ componentHintList: this.componentHintList.concat([hint]),
+ });
+ }
+
+ /**
+ * @param {Object} hint
+ */
+ markComponentHintProcessed(hint) {
+ this.update({
+ componentHintList: this.componentHintList.filter(h => h !== hint),
+ });
+ this.env.messagingBus.trigger('o-thread-view-hint-processed', {
+ hint,
+ threadViewer: this.threadViewer,
+ });
+ }
+
+ /**
+ * @param {mail.message} message
+ */
+ handleVisibleMessage(message) {
+ if (!this.lastVisibleMessage || this.lastVisibleMessage.id < message.id) {
+ this.update({ lastVisibleMessage: [['link', message]] });
+ }
+ }
+
+ //----------------------------------------------------------------------
+ // Private
+ //----------------------------------------------------------------------
+
+ /**
+ * @private
+ * @returns {mail.messaging}
+ */
+ _computeMessaging() {
+ return [['link', this.env.messaging]];
+ }
+
+ /**
+ * @private
+ * @returns {string[]}
+ */
+ _computeTextInputSendShortcuts() {
+ if (!this.thread) {
+ return;
+ }
+ const isMailingList = this.thread.model === 'mail.channel' && this.thread.mass_mailing;
+ // Actually in mobile there is a send button, so we need there 'enter' to allow new line.
+ // Hence, we want to use a different shortcut 'ctrl/meta enter' to send for small screen
+ // size with a non-mailing channel.
+ // here send will be done on clicking the button or using the 'ctrl/meta enter' shortcut.
+ if (this.env.messaging.device.isMobile || isMailingList) {
+ return ['ctrl-enter', 'meta-enter'];
+ }
+ return ['enter'];
+ }
+
+ /**
+ * @private
+ * @returns {integer|undefined}
+ */
+ _computeThreadCacheInitialScrollHeight() {
+ if (!this.threadCache) {
+ return clear();
+ }
+ const threadCacheInitialScrollHeight = this.threadCacheInitialScrollHeights[this.threadCache.localId];
+ if (threadCacheInitialScrollHeight !== undefined) {
+ return threadCacheInitialScrollHeight;
+ }
+ return clear();
+ }
+
+ /**
+ * @private
+ * @returns {integer|undefined}
+ */
+ _computeThreadCacheInitialScrollPosition() {
+ if (!this.threadCache) {
+ return clear();
+ }
+ const threadCacheInitialScrollPosition = this.threadCacheInitialScrollPositions[this.threadCache.localId];
+ if (threadCacheInitialScrollPosition !== undefined) {
+ return threadCacheInitialScrollPosition;
+ }
+ return clear();
+ }
+
+ /**
+ * Not a real field, used to trigger `thread.markAsSeen` when one of
+ * the dependencies changes.
+ *
+ * @private
+ * @returns {boolean}
+ */
+ _computeThreadShouldBeSetAsSeen() {
+ if (!this.thread) {
+ return;
+ }
+ if (!this.thread.lastNonTransientMessage) {
+ return;
+ }
+ if (!this.lastVisibleMessage) {
+ return;
+ }
+ if (this.lastVisibleMessage !== this.lastMessage) {
+ return;
+ }
+ if (!this.hasComposerFocus) {
+ // FIXME condition should not be on "composer is focused" but "threadView is active"
+ // See task-2277543
+ return;
+ }
+ this.thread.markAsSeen(this.thread.lastNonTransientMessage).catch(e => {
+ // prevent crash when executing compute during destroy
+ if (!(e instanceof RecordDeletedError)) {
+ throw e;
+ }
+ });
+ }
+
+ /**
+ * @private
+ */
+ _onThreadCacheChanged() {
+ // clear obsolete hints
+ this.update({ componentHintList: clear() });
+ this.addComponentHint('change-of-thread-cache');
+ if (this.threadCache) {
+ this.threadCache.update({
+ isCacheRefreshRequested: true,
+ isMarkAllAsReadRequested: true,
+ });
+ }
+ this.update({ lastVisibleMessage: [['unlink']] });
+ }
+
+ /**
+ * @private
+ */
+ _onThreadCacheIsLoadingChanged() {
+ if (this.threadCache && this.threadCache.isLoading) {
+ if (!this.isLoading && !this.isPreparingLoading) {
+ this.update({ isPreparingLoading: true });
+ this.async(() =>
+ new Promise(resolve => {
+ this._loaderTimeout = this.env.browser.setTimeout(resolve, 400);
+ }
+ )).then(() => {
+ const isLoading = this.threadCache
+ ? this.threadCache.isLoading
+ : false;
+ this.update({ isLoading, isPreparingLoading: false });
+ });
+ }
+ return;
+ }
+ this.env.browser.clearTimeout(this._loaderTimeout);
+ this.update({ isLoading: false, isPreparingLoading: false });
+ }
+ }
+
+ ThreadView.fields = {
+ checkedMessages: many2many('mail.message', {
+ related: 'threadCache.checkedMessages',
+ }),
+ /**
+ * List of component hints. Hints contain information that help
+ * components make UI/UX decisions based on their UI state.
+ * For instance, on receiving new messages and the last message
+ * is visible, it should auto-scroll to this new last message.
+ *
+ * Format of a component hint:
+ *
+ * {
+ * type: {string} the name of the component hint. Useful
+ * for components to dispatch behaviour
+ * based on its type.
+ * data: {Object} data related to the component hint.
+ * For instance, if hint suggests to scroll
+ * to a certain message, data may contain
+ * message id.
+ * }
+ */
+ componentHintList: attr({
+ default: [],
+ }),
+ composer: many2one('mail.composer', {
+ related: 'thread.composer',
+ }),
+ /**
+ * Serves as compute dependency.
+ */
+ device: one2one('mail.device', {
+ related: 'messaging.device',
+ }),
+ /**
+ * Serves as compute dependency.
+ */
+ deviceIsMobile: attr({
+ related: 'device.isMobile',
+ }),
+ hasComposerFocus: attr({
+ related: 'composer.hasFocus',
+ }),
+ /**
+ * States whether `this.threadCache` is currently loading messages.
+ *
+ * This field is related to `this.threadCache.isLoading` but with a
+ * delay on its update to avoid flickering on the UI.
+ *
+ * It is computed through `_onThreadCacheIsLoadingChanged` and it should
+ * otherwise be considered read-only.
+ */
+ isLoading: attr({
+ default: false,
+ }),
+ /**
+ * States whether `this` is aware of `this.threadCache` currently
+ * loading messages, but `this` is not yet ready to display that loading
+ * on the UI.
+ *
+ * This field is computed through `_onThreadCacheIsLoadingChanged` and
+ * it should otherwise be considered read-only.
+ *
+ * @see `this.isLoading`
+ */
+ isPreparingLoading: attr({
+ default: false,
+ }),
+ /**
+ * Determines whether `this` should automatically scroll on receiving
+ * a new message. Detection of new message is done through the component
+ * hint `message-received`.
+ */
+ hasAutoScrollOnMessageReceived: attr({
+ default: true,
+ }),
+ /**
+ * Last message in the context of the currently displayed thread cache.
+ */
+ lastMessage: many2one('mail.message', {
+ related: 'thread.lastMessage',
+ }),
+ /**
+ * Serves as compute dependency.
+ */
+ lastNonTransientMessage: many2one('mail.message', {
+ related: 'thread.lastNonTransientMessage',
+ }),
+ /**
+ * Most recent message in this ThreadView that has been shown to the
+ * current partner in the currently displayed thread cache.
+ */
+ lastVisibleMessage: many2one('mail.message'),
+ messages: many2many('mail.message', {
+ related: 'threadCache.messages',
+ }),
+ /**
+ * Serves as compute dependency.
+ */
+ messaging: many2one('mail.messaging', {
+ compute: '_computeMessaging',
+ }),
+ nonEmptyMessages: many2many('mail.message', {
+ related: 'threadCache.nonEmptyMessages',
+ }),
+ /**
+ * Not a real field, used to trigger `_onThreadCacheChanged` when one of
+ * the dependencies changes.
+ */
+ onThreadCacheChanged: attr({
+ compute: '_onThreadCacheChanged',
+ dependencies: [
+ 'threadCache'
+ ],
+ }),
+ /**
+ * Not a real field, used to trigger `_onThreadCacheIsLoadingChanged`
+ * when one of the dependencies changes.
+ *
+ * @see `this.isLoading`
+ */
+ onThreadCacheIsLoadingChanged: attr({
+ compute: '_onThreadCacheIsLoadingChanged',
+ dependencies: [
+ 'threadCache',
+ 'threadCacheIsLoading',
+ ],
+ }),
+ /**
+ * Determines the domain to apply when fetching messages for `this.thread`.
+ */
+ stringifiedDomain: attr({
+ related: 'threadViewer.stringifiedDomain',
+ }),
+ /**
+ * Determines the keyboard shortcuts that are available to send a message
+ * from the composer of this thread viewer.
+ */
+ textInputSendShortcuts: attr({
+ compute: '_computeTextInputSendShortcuts',
+ dependencies: [
+ 'device',
+ 'deviceIsMobile',
+ 'thread',
+ 'threadMassMailing',
+ 'threadModel',
+ ],
+ }),
+ /**
+ * Determines the `mail.thread` currently displayed by `this`.
+ */
+ thread: many2one('mail.thread', {
+ inverse: 'threadViews',
+ related: 'threadViewer.thread',
+ }),
+ /**
+ * States the `mail.thread_cache` currently displayed by `this`.
+ */
+ threadCache: many2one('mail.thread_cache', {
+ inverse: 'threadViews',
+ related: 'threadViewer.threadCache',
+ }),
+ threadCacheInitialScrollHeight: attr({
+ compute: '_computeThreadCacheInitialScrollHeight',
+ dependencies: [
+ 'threadCache',
+ 'threadCacheInitialScrollHeights',
+ ],
+ }),
+ threadCacheInitialScrollPosition: attr({
+ compute: '_computeThreadCacheInitialScrollPosition',
+ dependencies: [
+ 'threadCache',
+ 'threadCacheInitialScrollPositions',
+ ],
+ }),
+ /**
+ * Serves as compute dependency.
+ */
+ threadCacheIsLoading: attr({
+ related: 'threadCache.isLoading',
+ }),
+ /**
+ * List of saved initial scroll heights of thread caches.
+ */
+ threadCacheInitialScrollHeights: attr({
+ default: {},
+ related: 'threadViewer.threadCacheInitialScrollHeights',
+ }),
+ /**
+ * List of saved initial scroll positions of thread caches.
+ */
+ threadCacheInitialScrollPositions: attr({
+ default: {},
+ related: 'threadViewer.threadCacheInitialScrollPositions',
+ }),
+ /**
+ * Serves as compute dependency.
+ */
+ threadMassMailing: attr({
+ related: 'thread.mass_mailing',
+ }),
+ /**
+ * Serves as compute dependency.
+ */
+ threadModel: attr({
+ related: 'thread.model',
+ }),
+ /**
+ * Not a real field, used to trigger `thread.markAsSeen` when one of
+ * the dependencies changes.
+ */
+ threadShouldBeSetAsSeen: attr({
+ compute: '_computeThreadShouldBeSetAsSeen',
+ dependencies: [
+ 'hasComposerFocus',
+ 'lastMessage',
+ 'lastNonTransientMessage',
+ 'lastVisibleMessage',
+ 'threadCache',
+ ],
+ }),
+ /**
+ * Determines the `mail.thread_viewer` currently managing `this`.
+ */
+ threadViewer: one2one('mail.thread_viewer', {
+ inverse: 'threadView',
+ }),
+ uncheckedMessages: many2many('mail.message', {
+ related: 'threadCache.uncheckedMessages',
+ }),
+ };
+
+ ThreadView.modelName = 'mail.thread_view';
+
+ return ThreadView;
+}
+
+registerNewModel('mail.thread_view', factory);
+
+});
diff --git a/addons/mail/static/src/models/thread_view/thread_viewer.js b/addons/mail/static/src/models/thread_view/thread_viewer.js
new file mode 100644
index 00000000..c78022d4
--- /dev/null
+++ b/addons/mail/static/src/models/thread_view/thread_viewer.js
@@ -0,0 +1,296 @@
+odoo.define('mail/static/src/models/thread_viewer/thread_viewer.js', function (require) {
+'use strict';
+
+const { registerNewModel } = require('mail/static/src/model/model_core.js');
+const { attr, many2one, one2one } = require('mail/static/src/model/model_field.js');
+
+function factory(dependencies) {
+
+ class ThreadViewer extends dependencies['mail.model'] {
+
+ //----------------------------------------------------------------------
+ // Public
+ //----------------------------------------------------------------------
+
+ /**
+ * @param {integer} scrollHeight
+ * @param {mail.thread_cache} threadCache
+ */
+ saveThreadCacheScrollHeightAsInitial(scrollHeight, threadCache) {
+ threadCache = threadCache || this.threadCache;
+ if (!threadCache) {
+ return;
+ }
+ if (this.chatter) {
+ // Initial scroll height is disabled for chatter because it is
+ // too complex to handle correctly and less important
+ // functionally.
+ return;
+ }
+ this.update({
+ threadCacheInitialScrollHeights: Object.assign({}, this.threadCacheInitialScrollHeights, {
+ [threadCache.localId]: scrollHeight,
+ }),
+ });
+ }
+
+ /**
+ * @param {integer} scrollTop
+ * @param {mail.thread_cache} threadCache
+ */
+ saveThreadCacheScrollPositionsAsInitial(scrollTop, threadCache) {
+ threadCache = threadCache || this.threadCache;
+ if (!threadCache) {
+ return;
+ }
+ if (this.chatter) {
+ // Initial scroll position is disabled for chatter because it is
+ // too complex to handle correctly and less important
+ // functionally.
+ return;
+ }
+ this.update({
+ threadCacheInitialScrollPositions: Object.assign({}, this.threadCacheInitialScrollPositions, {
+ [threadCache.localId]: scrollTop,
+ }),
+ });
+ }
+
+ //----------------------------------------------------------------------
+ // Private
+ //----------------------------------------------------------------------
+
+ /**
+ * @private
+ * @returns {boolean}
+ */
+ _computeHasThreadView() {
+ if (this.chatter) {
+ return this.chatter.hasThreadView;
+ }
+ if (this.chatWindow) {
+ return this.chatWindow.hasThreadView;
+ }
+ if (this.discuss) {
+ return this.discuss.hasThreadView;
+ }
+ return this.hasThreadView;
+ }
+
+ /**
+ * @private
+ * @returns {string}
+ */
+ _computeStringifiedDomain() {
+ if (this.chatter) {
+ return '[]';
+ }
+ if (this.chatWindow) {
+ return '[]';
+ }
+ if (this.discuss) {
+ return this.discuss.stringifiedDomain;
+ }
+ return this.stringifiedDomain;
+ }
+
+ /**
+ * @private
+ * @returns {mail.thread|undefined}
+ */
+ _computeThread() {
+ if (this.chatter) {
+ if (!this.chatter.thread) {
+ return [['unlink']];
+ }
+ return [['link', this.chatter.thread]];
+ }
+ if (this.chatWindow) {
+ if (!this.chatWindow.thread) {
+ return [['unlink']];
+ }
+ return [['link', this.chatWindow.thread]];
+ }
+ if (this.discuss) {
+ if (!this.discuss.thread) {
+ return [['unlink']];
+ }
+ return [['link', this.discuss.thread]];
+ }
+ return [];
+ }
+
+ /**
+ * @private
+ * @returns {mail.thread_cache|undefined}
+ */
+ _computeThreadCache() {
+ if (!this.thread) {
+ return [['unlink']];
+ }
+ return [['link', this.thread.cache(this.stringifiedDomain)]];
+ }
+
+ /**
+ * @private
+ * @returns {mail.thread_viewer|undefined}
+ */
+ _computeThreadView() {
+ if (!this.hasThreadView) {
+ return [['unlink']];
+ }
+ if (this.threadView) {
+ return [];
+ }
+ return [['create']];
+ }
+
+ }
+
+ ThreadViewer.fields = {
+ /**
+ * States the `mail.chatter` managing `this`. This field is computed
+ * through the inverse relation and should be considered read-only.
+ */
+ chatter: one2one('mail.chatter', {
+ inverse: 'threadViewer',
+ }),
+ /**
+ * Serves as compute dependency.
+ */
+ chatterHasThreadView: attr({
+ related: 'chatter.hasThreadView',
+ }),
+ /**
+ * Serves as compute dependency.
+ */
+ chatterThread: many2one('mail.thread', {
+ related: 'chatter.thread',
+ }),
+ /**
+ * States the `mail.chat_window` managing `this`. This field is computed
+ * through the inverse relation and should be considered read-only.
+ */
+ chatWindow: one2one('mail.chat_window', {
+ inverse: 'threadViewer',
+ }),
+ /**
+ * Serves as compute dependency.
+ */
+ chatWindowHasThreadView: attr({
+ related: 'chatWindow.hasThreadView',
+ }),
+ /**
+ * Serves as compute dependency.
+ */
+ chatWindowThread: many2one('mail.thread', {
+ related: 'chatWindow.thread',
+ }),
+ /**
+ * States the `mail.discuss` managing `this`. This field is computed
+ * through the inverse relation and should be considered read-only.
+ */
+ discuss: one2one('mail.discuss', {
+ inverse: 'threadViewer',
+ }),
+ /**
+ * Serves as compute dependency.
+ */
+ discussHasThreadView: attr({
+ related: 'discuss.hasThreadView',
+ }),
+ /**
+ * Serves as compute dependency.
+ */
+ discussStringifiedDomain: attr({
+ related: 'discuss.stringifiedDomain',
+ }),
+ /**
+ * Serves as compute dependency.
+ */
+ discussThread: many2one('mail.thread', {
+ related: 'discuss.thread',
+ }),
+ /**
+ * Determines whether `this.thread` should be displayed.
+ */
+ hasThreadView: attr({
+ compute: '_computeHasThreadView',
+ default: false,
+ dependencies: [
+ 'chatterHasThreadView',
+ 'chatWindowHasThreadView',
+ 'discussHasThreadView',
+ ],
+ }),
+ /**
+ * Determines the domain to apply when fetching messages for `this.thread`.
+ */
+ stringifiedDomain: attr({
+ compute: '_computeStringifiedDomain',
+ default: '[]',
+ dependencies: [
+ 'discussStringifiedDomain',
+ ],
+ }),
+ /**
+ * Determines the `mail.thread` that should be displayed by `this`.
+ */
+ thread: many2one('mail.thread', {
+ compute: '_computeThread',
+ dependencies: [
+ 'chatterThread',
+ 'chatWindowThread',
+ 'discussThread',
+ ],
+ }),
+ /**
+ * States the `mail.thread_cache` that should be displayed by `this`.
+ */
+ threadCache: many2one('mail.thread_cache', {
+ compute: '_computeThreadCache',
+ dependencies: [
+ 'stringifiedDomain',
+ 'thread',
+ ],
+ }),
+ /**
+ * Determines the initial scroll height of thread caches, which is the
+ * scroll height at the time the last scroll position was saved.
+ * Useful to only restore scroll position when the corresponding height
+ * is available, otherwise the restore makes no sense.
+ */
+ threadCacheInitialScrollHeights: attr({
+ default: {},
+ }),
+ /**
+ * Determines the initial scroll positions of thread caches.
+ * Useful to restore scroll position on changing back to this
+ * thread cache. Note that this is only applied when opening
+ * the thread cache, because scroll position may change fast so
+ * save is already throttled.
+ */
+ threadCacheInitialScrollPositions: attr({
+ default: {},
+ }),
+ /**
+ * States the `mail.thread_view` currently displayed and managed by `this`.
+ */
+ threadView: one2one('mail.thread_view', {
+ compute: '_computeThreadView',
+ dependencies: [
+ 'hasThreadView',
+ ],
+ inverse: 'threadViewer',
+ isCausal: true,
+ }),
+ };
+
+ ThreadViewer.modelName = 'mail.thread_viewer';
+
+ return ThreadViewer;
+}
+
+registerNewModel('mail.thread_viewer', factory);
+
+});
diff --git a/addons/mail/static/src/models/user/user.js b/addons/mail/static/src/models/user/user.js
new file mode 100644
index 00000000..721b586f
--- /dev/null
+++ b/addons/mail/static/src/models/user/user.js
@@ -0,0 +1,254 @@
+odoo.define('mail/static/src/models/user/user.js', function (require) {
+'use strict';
+
+const { registerNewModel } = require('mail/static/src/model/model_core.js');
+const { attr, one2one } = require('mail/static/src/model/model_field.js');
+
+function factory(dependencies) {
+
+ class User extends dependencies['mail.model'] {
+
+ /**
+ * @override
+ */
+ _willDelete() {
+ if (this.env.messaging) {
+ if (this === this.env.messaging.currentUser) {
+ this.env.messaging.update({ currentUser: [['unlink']] });
+ }
+ }
+ return super._willDelete(...arguments);
+ }
+
+ //----------------------------------------------------------------------
+ // Public
+ //----------------------------------------------------------------------
+
+ /**
+ * @static
+ * @param {Object} data
+ * @returns {Object}
+ */
+ static convertData(data) {
+ const data2 = {};
+ if ('id' in data) {
+ data2.id = data.id;
+ }
+ if ('partner_id' in data) {
+ if (!data.partner_id) {
+ data2.partner = [['unlink']];
+ } else {
+ const partnerNameGet = data['partner_id'];
+ const partnerData = {
+ display_name: partnerNameGet[1],
+ id: partnerNameGet[0],
+ };
+ data2.partner = [['insert', partnerData]];
+ }
+ }
+ return data2;
+ }
+
+ /**
+ * Performs the `read` RPC on `res.users`.
+ *
+ * @static
+ * @param {Object} param0
+ * @param {Object} param0.context
+ * @param {string[]} param0.fields
+ * @param {integer[]} param0.ids
+ */
+ static async performRpcRead({ context, fields, ids }) {
+ const usersData = await this.env.services.rpc({
+ model: 'res.users',
+ method: 'read',
+ args: [ids],
+ kwargs: {
+ context,
+ fields,
+ },
+ }, { shadow: true });
+ return this.env.models['mail.user'].insert(usersData.map(userData =>
+ this.env.models['mail.user'].convertData(userData)
+ ));
+ }
+
+ /**
+ * Fetches the partner of this user.
+ */
+ async fetchPartner() {
+ return this.env.models['mail.user'].performRpcRead({
+ ids: [this.id],
+ fields: ['partner_id'],
+ context: { active_test: false },
+ });
+ }
+
+ /**
+ * Gets the chat between this user and the current user.
+ *
+ * If a chat is not appropriate, a notification is displayed instead.
+ *
+ * @returns {mail.thread|undefined}
+ */
+ async getChat() {
+ if (!this.partner) {
+ await this.async(() => this.fetchPartner());
+ }
+ if (!this.partner) {
+ // This user has been deleted from the server or never existed:
+ // - Validity of id is not verified at insert.
+ // - There is no bus notification in case of user delete from
+ // another tab or by another user.
+ this.env.services['notification'].notify({
+ message: this.env._t("You can only chat with existing users."),
+ type: 'warning',
+ });
+ return;
+ }
+ // in other cases a chat would be valid, find it or try to create it
+ let chat = this.env.models['mail.thread'].find(thread =>
+ thread.channel_type === 'chat' &&
+ thread.correspondent === this.partner &&
+ thread.model === 'mail.channel' &&
+ thread.public === 'private'
+ );
+ if (!chat ||!chat.isPinned) {
+ // if chat is not pinned then it has to be pinned client-side
+ // and server-side, which is a side effect of following rpc
+ chat = await this.async(() =>
+ this.env.models['mail.thread'].performRpcCreateChat({
+ partnerIds: [this.partner.id],
+ })
+ );
+ }
+ if (!chat) {
+ this.env.services['notification'].notify({
+ message: this.env._t("An unexpected error occurred during the creation of the chat."),
+ type: 'warning',
+ });
+ return;
+ }
+ return chat;
+ }
+
+ /**
+ * Opens a chat between this user and the current user and returns it.
+ *
+ * If a chat is not appropriate, a notification is displayed instead.
+ *
+ * @param {Object} [options] forwarded to @see `mail.thread:open()`
+ * @returns {mail.thread|undefined}
+ */
+ async openChat(options) {
+ const chat = await this.async(() => this.getChat());
+ if (!chat) {
+ return;
+ }
+ await this.async(() => chat.open(options));
+ return chat;
+ }
+
+ /**
+ * Opens the most appropriate view that is a profile for this user.
+ * Because user is a rather technical model to allow login, it's the
+ * partner profile that contains the most useful information.
+ *
+ * @override
+ */
+ async openProfile() {
+ if (!this.partner) {
+ await this.async(() => this.fetchPartner());
+ }
+ if (!this.partner) {
+ // This user has been deleted from the server or never existed:
+ // - Validity of id is not verified at insert.
+ // - There is no bus notification in case of user delete from
+ // another tab or by another user.
+ this.env.services['notification'].notify({
+ message: this.env._t("You can only open the profile of existing users."),
+ type: 'warning',
+ });
+ return;
+ }
+ return this.partner.openProfile();
+ }
+
+ //----------------------------------------------------------------------
+ // Private
+ //----------------------------------------------------------------------
+
+ /**
+ * @override
+ */
+ static _createRecordLocalId(data) {
+ return `${this.modelName}_${data.id}`;
+ }
+
+ /**
+ * @private
+ * @returns {string|undefined}
+ */
+ _computeDisplayName() {
+ return this.display_name || this.partner && this.partner.display_name;
+ }
+
+ /**
+ * @private
+ * @returns {string|undefined}
+ */
+ _computeNameOrDisplayName() {
+ return this.partner && this.partner.nameOrDisplayName || this.display_name;
+ }
+ }
+
+ User.fields = {
+ id: attr(),
+ /**
+ * Determines whether this user is an internal user. An internal user is
+ * a member of the group `base.group_user`. This is the inverse of the
+ * `share` field in python.
+ */
+ isInternalUser: attr(),
+ display_name: attr({
+ compute: '_computeDisplayName',
+ dependencies: [
+ 'display_name',
+ 'partnerDisplayName',
+ ],
+ }),
+ model: attr({
+ default: 'res.user',
+ }),
+ nameOrDisplayName: attr({
+ compute: '_computeNameOrDisplayName',
+ dependencies: [
+ 'display_name',
+ 'partnerNameOrDisplayName',
+ ]
+ }),
+ partner: one2one('mail.partner', {
+ inverse: 'user',
+ }),
+ /**
+ * Serves as compute dependency.
+ */
+ partnerDisplayName: attr({
+ related: 'partner.display_name',
+ }),
+ /**
+ * Serves as compute dependency.
+ */
+ partnerNameOrDisplayName: attr({
+ related: 'partner.nameOrDisplayName',
+ }),
+ };
+
+ User.modelName = 'mail.user';
+
+ return User;
+}
+
+registerNewModel('mail.user', factory);
+
+});