diff options
Diffstat (limited to 'addons/mail/static/src/models')
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&signature=${this.checksum}&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(/ /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); + +}); |
