diff options
| author | Sébastien Theys <seb@odoo.com> | 2020-08-28 12:53:38 +0000 |
|---|---|---|
| committer | Sébastien Theys <seb@odoo.com> | 2020-08-28 16:15:15 +0000 |
| commit | 17611f4adc5e950b0a75a2a6615b1da3c956387f (patch) | |
| tree | ede6cbc0bc67c1dceed3b5ca6c1bb3e08389675c /addons | |
| parent | 5ba64925fd846e9a9a4884913e3d1004d05ecdf6 (diff) | |
[FIX] mail, *: fix opens and redirects, adapt messaging menu close
task-2244279
task-2262862
task-2274993
task-2277537
task-2282238
closes odoo/odoo#56745
X-original-commit: 109f202
Related: odoo/enterprise#12761
Signed-off-by: Alexandre Kühn (aku) <aku@odoo.com>
Signed-off-by: Sébastien Theys (seb) <seb@odoo.com>
Diffstat (limited to 'addons')
64 files changed, 1697 insertions, 923 deletions
diff --git a/addons/hr/__manifest__.py b/addons/hr/__manifest__.py index ada77010824..cb2942140ed 100644 --- a/addons/hr/__manifest__.py +++ b/addons/hr/__manifest__.py @@ -46,5 +46,8 @@ 'installable': True, 'application': True, 'auto_install': False, - 'qweb': ['static/src/xml/hr_templates.xml'], + 'qweb': [ + 'static/src/bugfix/bugfix.xml', + 'static/src/xml/hr_templates.xml', + ], } diff --git a/addons/hr/static/src/bugfix/bugfix.js b/addons/hr/static/src/bugfix/bugfix.js new file mode 100644 index 00000000000..0351046fab3 --- /dev/null +++ b/addons/hr/static/src/bugfix/bugfix.js @@ -0,0 +1,10 @@ +/** + * This file allows introducing new JS modules without contaminating other files. + * This is useful when bug fixing requires adding such JS modules in stable + * versions of Odoo. Any module that is defined in this file should be isolated + * in its own file in master. + */ +odoo.define('hr/static/src/bugfix/bugfix.js', function (require) { +'use strict'; + +}); diff --git a/addons/hr/static/src/bugfix/bugfix.scss b/addons/hr/static/src/bugfix/bugfix.scss new file mode 100644 index 00000000000..c4272e527b4 --- /dev/null +++ b/addons/hr/static/src/bugfix/bugfix.scss @@ -0,0 +1,6 @@ +/** +* This file allows introducing new styles without contaminating other files. +* This is useful when bug fixing requires adding new components for instance in +* stable versions of Odoo. Any style that is defined in this file should be isolated +* in its own file in master. +*/ diff --git a/addons/hr/static/src/bugfix/bugfix.xml b/addons/hr/static/src/bugfix/bugfix.xml new file mode 100644 index 00000000000..c17906f75bb --- /dev/null +++ b/addons/hr/static/src/bugfix/bugfix.xml @@ -0,0 +1,11 @@ +<?xml version="1.0" encoding="UTF-8"?> +<templates xml:space="preserve"> + +<!-- + This file allows introducing new static templates without contaminating other files. + This is useful when bug fixing requires adding new components for instance in stable + versions of Odoo. Any template that is defined in this file should be isolated + in its own file in master. +--> + +</templates> diff --git a/addons/hr/static/src/bugfix/bugfix_tests.js b/addons/hr/static/src/bugfix/bugfix_tests.js new file mode 100644 index 00000000000..8b23d37282d --- /dev/null +++ b/addons/hr/static/src/bugfix/bugfix_tests.js @@ -0,0 +1,18 @@ +odoo.define('hr/static/src/bugfix/bugfix_tests.js', function (require) { +'use strict'; + +/** + * This file allows introducing new QUnit test modules without contaminating + * other test files. This is useful when bug fixing requires adding new + * components for instance in stable versions of Odoo. Any test that is defined + * in this file should be isolated in its own file in master. + */ +QUnit.module('hr', {}, function () { +QUnit.module('bugfix', {}, function () { +QUnit.module('bugfix_tests.js', { + +}); +}); +}); + +}); diff --git a/addons/hr/static/src/js/chat.js b/addons/hr/static/src/js/chat.js index b9985e3929f..0489ccceac0 100644 --- a/addons/hr/static/src/js/chat.js +++ b/addons/hr/static/src/js/chat.js @@ -22,11 +22,7 @@ odoo.define('hr.employee_chat', function (require) { var self = this; return this._super.apply(this, arguments).then(function () { var $chat_button = self.$el.find('.o_employee_chat_btn'); - if (self.state.context.uid === self.state.data.user_id.res_id) { // Hide the button for yourself - $chat_button.hide(); - } else { - $chat_button.off('click').on('click', self._onOpenChat.bind(self)); - } + $chat_button.off('click').on('click', self._onOpenChat.bind(self)); }); }, @@ -38,9 +34,8 @@ odoo.define('hr.employee_chat', function (require) { _onOpenChat: function (ev) { ev.preventDefault(); ev.stopImmediatePropagation(); - this.trigger_up('open_chat', { - partner_id: this.state.data.user_partner_id.res_id - }); + const env = Component.env; + env.messaging.openChat({ employeeId: this.state.data.id }); return true; }, }; @@ -48,23 +43,9 @@ odoo.define('hr.employee_chat', function (require) { // USAGE OF CHAT MIXIN IN FORM VIEWS var EmployeeFormRenderer = FormRenderer.extend(ChatMixin); - var EmployeeFormController = FormController.extend({ - custom_events: _.extend({}, FormController.prototype.custom_events, { - open_chat: '_onOpenChat' - }), - - _onOpenChat: function (ev) { - const env = Component.env; - const partner = env.models['mail.partner'].insert({ - id: ev.data.partner_id, - }); - partner.openChat(); - }, - }); - var EmployeeFormView = FormView.extend({ config: _.extend({}, FormView.prototype.config, { - Controller: EmployeeFormController, + Controller: FormController, Renderer: EmployeeFormRenderer }), }); @@ -80,23 +61,9 @@ odoo.define('hr.employee_chat', function (require) { }), }); - var EmployeeKanbanController = KanbanController.extend({ - custom_events: _.extend({}, KanbanController.prototype.custom_events, { - open_chat: '_onOpenChat' - }), - - _onOpenChat: function (ev) { - const env = Component.env; - const partner = env.models['mail.partner'].insert({ - id: ev.data.partner_id, - }); - partner.openChat(); - }, - }); - var EmployeeKanbanView = KanbanView.extend({ config: _.extend({}, KanbanView.prototype.config, { - Controller: EmployeeKanbanController, + Controller: KanbanController, Renderer: EmployeeKanbanRenderer }), }); diff --git a/addons/hr/static/src/js/many2one_avatar_employee.js b/addons/hr/static/src/js/many2one_avatar_employee.js index 5be29801ea9..0d5a245aaa2 100644 --- a/addons/hr/static/src/js/many2one_avatar_employee.js +++ b/addons/hr/static/src/js/many2one_avatar_employee.js @@ -8,48 +8,26 @@ odoo.define('hr.Many2OneAvatarEmployee', function (require) { // Usage: // <field name="employee_id" widget="many2one_avatar_employee"/> - const { _t } = require('web.core'); const fieldRegistry = require('web.field_registry'); const { Many2OneAvatarUser, KanbanMany2OneAvatarUser } = require('mail.Many2OneAvatarUser'); - const session = require('web.session'); + const { Component } = owl; const Many2OneAvatarEmployeeMixin = { - supportedModel: 'hr.employee', - - /** - * Set the field to read on 'hr.employee' to get the partner id. - * - * @override - */ - init() { - this._super(...arguments); - this.partnerField = 'user_partner_id'; - }, + supportedModels: ['hr.employee', 'hr.employee.public'], //---------------------------------------------------------------------- // Private //---------------------------------------------------------------------- /** - * Display a warning if the user clicked on himself, or on an employee - * not associated with any user. - * * @override - * @param {number} [partnerId] the id of the clicked partner */ - _displayWarning(partnerId) { - if (partnerId !== session.partner_id) { - // this is not ourself, so if we get here it means that the - // employee is not associated with any user - this.displayNotification({ - message: _t('You can only chat with employees that have a dedicated user'), - type: 'info', - }); - } else { - this._super(...arguments); - } - }, + async _onAvatarClicked(ev) { + ev.stopPropagation(); // in list view, prevent from opening the record + const env = Component.env; + await env.messaging.openChat({ employeeId: this.value.res_id }); + } }; const Many2OneAvatarEmployee = Many2OneAvatarUser.extend(Many2OneAvatarEmployeeMixin); diff --git a/addons/hr/static/src/models/employee/employee.js b/addons/hr/static/src/models/employee/employee.js new file mode 100644 index 00000000000..0cf877baab6 --- /dev/null +++ b/addons/hr/static/src/models/employee/employee.js @@ -0,0 +1,204 @@ +odoo.define('hr/static/src/models/employee/employee.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 Employee extends dependencies['mail.model'] { + + //---------------------------------------------------------------------- + // Public + //---------------------------------------------------------------------- + + /** + * @static + * @param {Object} data + * @returns {Object} + */ + static convertData(data) { + const data2 = {}; + if ('id' in data) { + data2.id = data.id; + } + if ('user_id' in data) { + data2.hasCheckedUser = true; + if (!data.user_id) { + data2.user = [['unlink']]; + } else { + const partnerNameGet = data['user_partner_id']; + const partnerData = { + display_name: partnerNameGet[1], + id: partnerNameGet[0], + }; + const userNameGet = data['user_id']; + const userData = { + id: userNameGet[0], + partner: [['insert', partnerData]], + partnerDisplayName: userNameGet[1], + }; + data2.user = [['insert', userData]]; + } + } + return data2; + } + + /** + * Performs the `read` RPC on the `hr.employee.public`. + * + * @static + * @param {Object} param0 + * @param {Object} param0.context + * @param {string[]} param0.fields + * @param {integer[]} param0.ids + */ + static async performRpcRead({ context, fields, ids }) { + const employeesData = await this.env.services.rpc({ + model: 'hr.employee.public', + method: 'read', + args: [ids], + kwargs: { + context, + fields, + }, + }); + this.env.models['hr.employee'].insert(employeesData.map(employeeData => + this.env.models['hr.employee'].convertData(employeeData) + )); + } + + /** + * Performs the `search_read` RPC on `hr.employee.public`. + * + * @static + * @param {Object} param0 + * @param {Object} param0.context + * @param {Array[]} param0.domain + * @param {string[]} param0.fields + */ + static async performRpcSearchRead({ context, domain, fields }) { + const employeesData = await this.env.services.rpc({ + model: 'hr.employee.public', + method: 'search_read', + kwargs: { + context, + domain, + fields, + }, + }); + this.env.models['hr.employee'].insert(employeesData.map(employeeData => + this.env.models['hr.employee'].convertData(employeeData) + )); + } + + /** + * Checks whether this employee has a related user and partner and links + * them if applicable. + */ + async checkIsUser() { + return this.env.models['hr.employee'].performRpcRead({ + ids: [this.id], + fields: ['user_id', 'user_partner_id'], + context: { active_test: false }, + }); + } + + /** + * Gets the chat between the user of this employee 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 employees that have a dedicated user."), + type: 'info', + }); + return; + } + return this.user.getChat(); + } + + /** + * Opens a chat between the user of this employee 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 employee. + */ + async openProfile() { + return this.env.messaging.openDocument({ + id: this.id, + model: 'hr.employee.public', + }); + } + + //---------------------------------------------------------------------- + // Private + //---------------------------------------------------------------------- + + /** + * @override + */ + static _createRecordLocalId(data) { + return `${this.modelName}_${data.id}`; + } + + } + + Employee.fields = { + /** + * Whether an attempt was already made to fetch the user corresponding + * to this employee. This prevents doing the same RPC multiple times. + */ + hasCheckedUser: attr({ + default: false, + }), + /** + * Unique identifier for this employee. + */ + id: attr(), + /** + * Partner related to this employee. + */ + partner: one2one('mail.partner', { + inverse: 'employee', + related: 'user.partner', + }), + /** + * User related to this employee. + */ + user: one2one('mail.user', { + inverse: 'employee', + }), + }; + + Employee.modelName = 'hr.employee'; + + return Employee; +} + +registerNewModel('hr.employee', factory); + +}); diff --git a/addons/hr/static/src/models/messaging/messaging.js b/addons/hr/static/src/models/messaging/messaging.js new file mode 100644 index 00000000000..435761a91ef --- /dev/null +++ b/addons/hr/static/src/models/messaging/messaging.js @@ -0,0 +1,36 @@ +odoo.define('hr/static/src/models/messaging/messaging.js', function (require) { +'use strict'; + +const { + registerInstancePatchModel, +} = require('mail/static/src/model/model_core.js'); + +registerInstancePatchModel('mail.messaging', 'hr/static/src/models/messaging/messaging.js', { + //-------------------------------------------------------------------------- + // Public + //-------------------------------------------------------------------------- + + /** + * @override + * @param {integer} [param0.employeeId] + */ + async getChat({ employeeId }) { + if (employeeId) { + const employee = this.env.models['hr.employee'].insert({ id: employeeId }); + return employee.getChat(); + } + return this._super(...arguments); + }, + /** + * @override + */ + async openProfile({ id, model }) { + if (model === 'hr.employee' || model === 'hr.employee.public') { + const employee = this.env.models['hr.employee'].insert({ id }); + return employee.openProfile(); + } + return this._super(...arguments); + }, +}); + +}); diff --git a/addons/hr/static/src/models/partner/partner.js b/addons/hr/static/src/models/partner/partner.js new file mode 100644 index 00000000000..cbbcb987dd2 --- /dev/null +++ b/addons/hr/static/src/models/partner/partner.js @@ -0,0 +1,63 @@ +odoo.define('hr/static/src/models/partner/partner.js', function (require) { +'use strict'; + +const { + registerInstancePatchModel, + registerFieldPatchModel, +} = require('mail/static/src/model/model_core.js'); +const { attr, one2one } = require('mail/static/src/model/model_field.js'); + +registerInstancePatchModel('mail.partner', 'hr/static/src/models/partner/partner.js', { + //-------------------------------------------------------------------------- + // Public + //-------------------------------------------------------------------------- + + /** + * Checks whether this partner has a related employee and links them if + * applicable. + */ + async checkIsEmployee() { + await this.async(() => this.env.models['hr.employee'].performRpcSearchRead({ + context: { active_test: false }, + domain: [['user_partner_id', '=', this.id]], + fields: ['user_id', 'user_partner_id'], + })); + this.update({ hasCheckedEmployee: true }); + }, + /** + * When a partner is an employee, its employee profile contains more useful + * information to know who he is than its partner profile. + * + * @override + */ + async openProfile() { + // limitation of patch, `this._super` becomes unavailable after `await` + const _super = this._super.bind(this, ...arguments); + if (!this.employee && !this.hasCheckedEmployee) { + await this.async(() => this.checkIsEmployee()); + } + if (this.employee) { + return this.employee.openProfile(); + } + return _super(); + }, +}); + +registerFieldPatchModel('mail.partner', 'hr/static/src/models/partner/partner.js', { + /** + * Employee related to this partner. It is computed through + * the inverse relation and should be considered read-only. + */ + employee: one2one('hr.employee', { + inverse: 'partner', + }), + /** + * Whether an attempt was already made to fetch the employee corresponding + * to this partner. This prevents doing the same RPC multiple times. + */ + hasCheckedEmployee: attr({ + default: false, + }), +}); + +}); diff --git a/addons/hr/static/src/models/user/user.js b/addons/hr/static/src/models/user/user.js new file mode 100644 index 00000000000..a0482d50deb --- /dev/null +++ b/addons/hr/static/src/models/user/user.js @@ -0,0 +1,18 @@ +odoo.define('hr/static/src/models/user/user.js', function (require) { +'use strict'; + +const { + registerFieldPatchModel, +} = require('mail/static/src/model/model_core.js'); +const { one2one } = require('mail/static/src/model/model_field.js'); + +registerFieldPatchModel('mail.user', 'hr/static/src/models/user/user.js', { + /** + * Employee related to this user. + */ + employee: one2one('hr.employee', { + inverse: 'user', + }), +}); + +}); diff --git a/addons/hr/static/tests/helpers/mock_models.js b/addons/hr/static/tests/helpers/mock_models.js new file mode 100644 index 00000000000..d4ae7c86035 --- /dev/null +++ b/addons/hr/static/tests/helpers/mock_models.js @@ -0,0 +1,34 @@ +odoo.define('hr/static/tests/helpers/mock_models.js', function (require) { +'use strict'; + +const MockModels = require('mail/static/tests/helpers/mock_models.js'); + +MockModels.patch('hr/static/tests/helpers/mock_models.js', T => + class extends T { + + //---------------------------------------------------------------------- + // Public + //---------------------------------------------------------------------- + + /** + * @override + */ + static generateData() { + const data = super.generateData(...arguments); + Object.assign(data, { + 'hr.employee.public': { + fields: { + display_name: { string: "Name", type: "char" }, + user_id: { string: "User", type: "many2one", relation: 'res.users' }, + user_partner_id: { string: "Partner", type: "many2one", relation: 'res.partner' }, + }, + records: [], + }, + }); + return data; + } + + } +); + +}); diff --git a/addons/hr/static/tests/many2one_avatar_employee_tests.js b/addons/hr/static/tests/many2one_avatar_employee_tests.js index 06ea5a74f07..c1eef47b182 100644 --- a/addons/hr/static/tests/many2one_avatar_employee_tests.js +++ b/addons/hr/static/tests/many2one_avatar_employee_tests.js @@ -1,7 +1,12 @@ odoo.define('hr.Many2OneAvatarEmployeeTests', function (require) { "use strict"; -const { afterEach, beforeEach, start } = require('mail/static/src/utils/test_utils.js'); +const { + afterEach, + afterNextRender, + beforeEach, + start, +} = require('mail/static/src/utils/test_utils.js'); const FormView = require('web.FormView'); const KanbanView = require('web.KanbanView'); @@ -20,7 +25,7 @@ QUnit.module('hr', {}, function () { Object.assign(this.data, { 'foo': { fields: { - employee_id: { string: "Employee", type: 'many2one', relation: 'hr.employee' }, + employee_id: { string: "Employee", type: 'many2one', relation: 'hr.employee.public' }, }, records: [ { id: 1, employee_id: 11 }, @@ -29,26 +34,17 @@ QUnit.module('hr', {}, function () { { id: 4, employee_id: 23 }, ], }, - 'hr.employee': { - fields: { - display_name: { string: "Name", type: "char" }, - user_partner_id: { string: "Partner", type: "many2one", relation: 'res.partner' }, - }, - records: [{ - id: 11, - name: "Mario", - user_partner_id: 11, - }, { - id: 7, - name: "Luigi", - user_partner_id: 12, - }, { - id: 23, - name: "Yoshi", - user_partner_id: 13, - }], - }, }); + this.data['hr.employee.public'].records.push( + { id: 11, name: "Mario", user_id: 11, user_partner_id: 11 }, + { id: 7, name: "Luigi", user_id: 12, user_partner_id: 12 }, + { id: 23, name: "Yoshi", user_id: 13, user_partner_id: 13 } + ); + this.data['res.users'].records.push( + { id: 11, partner_id: 11 }, + { id: 12, partner_id: 12 }, + { id: 13, partner_id: 13 } + ); this.data['res.partner'].records.push( { id: 11, display_name: "Mario" }, { id: 12, display_name: "Luigi" }, @@ -61,9 +57,10 @@ QUnit.module('hr', {}, function () { }); QUnit.test('many2one_avatar_employee widget in list view', async function (assert) { - assert.expect(4); + assert.expect(11); const { widget: list } = await start({ + hasChatWindow: true, hasView: true, View: ListView, model: 'foo', @@ -79,18 +76,59 @@ QUnit.module('hr', {}, function () { assert.strictEqual(list.$('.o_data_cell span').text(), 'MarioLuigiMarioYoshi'); - await dom.click(list.$('.o_data_cell:nth(0) .o_m2o_avatar')); - await dom.click(list.$('.o_data_cell:nth(1) .o_m2o_avatar')); - await dom.click(list.$('.o_data_cell:nth(2) .o_m2o_avatar')); - - - assert.verifySteps([ - 'read hr.employee 11', - // 'call service openDMChatWindow 1', - 'read hr.employee 7', - // 'call service openDMChatWindow 2', - // 'call service openDMChatWindow 1', - ]); + // click on first employee + await afterNextRender(() => + dom.click(list.$('.o_data_cell:nth(0) .o_m2o_avatar')) + ); + assert.verifySteps( + ['read hr.employee.public 11'], + "first employee should have been read to find its partner" + ); + assert.containsOnce( + document.body, + '.o_ChatWindowHeader_name', + 'should have opened chat window' + ); + assert.strictEqual( + document.querySelector('.o_ChatWindowHeader_name').textContent, + "Mario", + 'chat window should be with clicked employee' + ); + + // click on second employee + await afterNextRender(() => + dom.click(list.$('.o_data_cell:nth(1) .o_m2o_avatar') + )); + assert.verifySteps( + ['read hr.employee.public 7'], + "second employee should have been read to find its partner" + ); + assert.containsN( + document.body, + '.o_ChatWindowHeader_name', + 2, + 'should have opened second chat window' + ); + assert.strictEqual( + document.querySelectorAll('.o_ChatWindowHeader_name')[1].textContent, + "Luigi", + 'chat window should be with clicked employee' + ); + + // click on third employee (same as first) + await afterNextRender(() => + dom.click(list.$('.o_data_cell:nth(2) .o_m2o_avatar')) + ); + assert.verifySteps( + [], + "employee should not have been read again because we already know its partner" + ); + assert.containsN( + document.body, + '.o_ChatWindowHeader_name', + 2, + "should still have only 2 chat windows because third is the same partner as first" + ); list.destroy(); }); @@ -117,18 +155,19 @@ QUnit.module('hr', {}, function () { assert.strictEqual(kanban.$('.o_kanban_record').text().trim(), ''); assert.containsN(kanban, '.o_m2o_avatar', 4); - assert.strictEqual(kanban.$('.o_m2o_avatar:nth(0)').data('src'), '/web/image/hr.employee/11/image_128'); - assert.strictEqual(kanban.$('.o_m2o_avatar:nth(1)').data('src'), '/web/image/hr.employee/7/image_128'); - assert.strictEqual(kanban.$('.o_m2o_avatar:nth(2)').data('src'), '/web/image/hr.employee/11/image_128'); - assert.strictEqual(kanban.$('.o_m2o_avatar:nth(3)').data('src'), '/web/image/hr.employee/23/image_128'); + assert.strictEqual(kanban.$('.o_m2o_avatar:nth(0)').data('src'), '/web/image/hr.employee.public/11/image_128'); + assert.strictEqual(kanban.$('.o_m2o_avatar:nth(1)').data('src'), '/web/image/hr.employee.public/7/image_128'); + assert.strictEqual(kanban.$('.o_m2o_avatar:nth(2)').data('src'), '/web/image/hr.employee.public/11/image_128'); + assert.strictEqual(kanban.$('.o_m2o_avatar:nth(3)').data('src'), '/web/image/hr.employee.public/23/image_128'); kanban.destroy(); }); QUnit.test('many2one_avatar_employee: click on an employee not associated with a user', async function (assert) { - assert.expect(5); + assert.expect(6); - this.data['hr.employee'].records[0].user_partner_id = false; + this.data['hr.employee.public'].records[0].user_id = false; + this.data['hr.employee.public'].records[0].user_partner_id = false; const { widget: form } = await start({ hasView: true, View: FormView, @@ -156,50 +195,19 @@ QUnit.module('hr', {}, function () { assert.verifySteps([ 'read foo 1', - 'read hr.employee 11', - 'display notification "You can only chat with employees that have a dedicated user"', + 'read hr.employee.public 11', ]); - form.destroy(); - }); - - QUnit.test('many2one_avatar_employee: click on self', async function (assert) { - assert.expect(5); - - this.data.currentPartnerId = 11; - const { widget: form } = await start({ - hasView: true, - View: FormView, - model: 'foo', - data: this.data, - arch: '<form><field name="employee_id" widget="many2one_avatar_employee"/></form>', - mockRPC(route, args) { - if (args.method === 'read') { - assert.step(`read ${args.model} ${args.args[0]}`); - } - return this._super(...arguments); - }, - session: { - partner_id: this.data.currentPartnerId, - }, - res_id: 1, - }); - - mock.intercept(form, 'call_service', (ev) => { - if (ev.data.service === 'notification') { - assert.step(`display notification "${ev.data.args[0].message}"`); - } - }, true); - - assert.strictEqual(form.$('.o_field_widget[name=employee_id]').text().trim(), 'Mario'); - - await dom.click(form.$('.o_m2o_avatar')); - - assert.verifySteps([ - 'read foo 1', - 'read hr.employee 11', - 'display notification "You cannot chat with yourself"', - ]); + 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 employees that have a dedicated user.", + "should display the correct information in the notification" + ); form.destroy(); }); diff --git a/addons/hr/views/hr_templates.xml b/addons/hr/views/hr_templates.xml index d959b96506b..5d4bcd9da3e 100644 --- a/addons/hr/views/hr_templates.xml +++ b/addons/hr/views/hr_templates.xml @@ -4,15 +4,24 @@ <template id="assets_backend" name="hr assets" inherit_id="web.assets_backend"> <xpath expr="." position="inside"> <link rel="stylesheet" type="text/scss" href="/hr/static/src/scss/hr.scss"/> + <link rel="stylesheet" type="text/scss" href="/hr/static/src/bugfix/bugfix.scss"/> + + <script type="text/javascript" src="/hr/static/src/bugfix/bugfix.js"/> <script type="text/javascript" src="/hr/static/src/js/chat.js"></script> <script type="text/javascript" src="/hr/static/src/js/language.js"></script> <script type="text/javascript" src="/hr/static/src/js/many2one_avatar_employee.js"></script> <script type="text/javascript" src="/hr/static/src/js/standalone_m2o_avatar_employee.js"></script> + <script type="text/javascript" src="/hr/static/src/models/employee/employee.js"/> + <script type="text/javascript" src="/hr/static/src/models/messaging/messaging.js"/> + <script type="text/javascript" src="/hr/static/src/models/partner/partner.js"/> + <script type="text/javascript" src="/hr/static/src/models/user/user.js"/> </xpath> </template> <template id="qunit_suite" name="hr tests" inherit_id="web.qunit_suite_tests"> <xpath expr="." position="inside"> + <script type="text/javascript" src="/hr/static/src/bugfix/bugfix_tests.js"/> + <script type="text/javascript" src="/hr/static/tests/helpers/mock_models.js"/> <script type="text/javascript" src="/hr/static/tests/many2one_avatar_employee_tests.js"></script> <script type="text/javascript" src="/hr/static/tests/standalone_m2o_avatar_employee_tests.js"></script> </xpath> diff --git a/addons/im_livechat/static/src/models/messaging_notification_handler/messaging_notification_handler.js b/addons/im_livechat/static/src/models/messaging_notification_handler/messaging_notification_handler.js index dd9d08b606c..25e71660ebe 100644 --- a/addons/im_livechat/static/src/models/messaging_notification_handler/messaging_notification_handler.js +++ b/addons/im_livechat/static/src/models/messaging_notification_handler/messaging_notification_handler.js @@ -11,12 +11,9 @@ registerInstancePatchModel('mail.messaging_notification_handler', 'im_livechat/s /** * @override - * @param {Object} param0 - * @param {integer} param0.channelId - * @param {integer} param0.partner_id */ - _handleNotificationChannelTypingStatus(data) { - const { channelId, partner_id } = data; + _handleNotificationChannelTypingStatus(channelId, data) { + const { partner_id } = data; const channel = this.env.models['mail.thread'].findFromIdentifyingData({ id: channelId, model: 'mail.channel', @@ -32,7 +29,7 @@ registerInstancePatchModel('mail.messaging_notification_handler', 'im_livechat/s } else { partnerId = partner_id; } - this._super(Object.assign(data, { + this._super(channelId, Object.assign(data, { partner_id: partnerId, })); }, diff --git a/addons/mail/static/src/components/chat_window/chat_window.js b/addons/mail/static/src/components/chat_window/chat_window.js index 4652da4975b..7f9b53d2670 100644 --- a/addons/mail/static/src/components/chat_window/chat_window.js +++ b/addons/mail/static/src/components/chat_window/chat_window.js @@ -167,20 +167,15 @@ class ChatWindow extends Component { * @param {Object} ui.item * @param {integer} ui.item.id */ - _onAutocompleteSelect(ev, ui) { - const partnerId = ui.item.id; - const partner = this.env.models['mail.partner'].find(partner => partner.id === partnerId); - const chat = partner.correspondentThreads.find(thread => thread.channel_type === 'chat'); - if (chat) { - chat.open({ chatWindowMode: 'from_new_message' }); - } else { - this.env.models['mail.thread'].createChannel({ - autoselect: true, - autoselectChatWindowMode: 'from_new_message', - partnerId, - type: 'chat', - }); + async _onAutocompleteSelect(ev, ui) { + const chat = await this.env.messaging.getChat({ partnerId: ui.item.id }); + if (!chat) { + return; } + this.env.messaging.chatWindowManager.openThread(chat, { + makeActive: true, + replaceNewMessage: true, + }); } /** @@ -225,6 +220,12 @@ class ChatWindow extends Component { if (this.chatWindow.isFocused) { return; } + if (isEventHandled(ev, 'Message.authorOpenChat')) { + return; + } + if (isEventHandled(ev, 'Message.authorOpenProfile')) { + return; + } this.chatWindow.focus(); } diff --git a/addons/mail/static/src/components/chat_window/chat_window.xml b/addons/mail/static/src/components/chat_window/chat_window.xml index d711f267ac7..f92a32cf2fe 100644 --- a/addons/mail/static/src/components/chat_window/chat_window.xml +++ b/addons/mail/static/src/components/chat_window/chat_window.xml @@ -29,7 +29,6 @@ hasComposerCurrentPartnerAvatar="false" hasComposerSendButton="env.messaging.device.isMobile" hasSquashCloseMessages="chatWindow.thread.model !== 'mail.box'" - haveMessagesAuthorRedirect="chatWindow.thread.channel_type !== 'chat'" threadViewLocalId="chatWindow.threadView.localId" t-on-focusin="_onFocusinThread" t-ref="thread" diff --git a/addons/mail/static/src/components/chat_window_hidden_menu/chat_window_hidden_menu.js b/addons/mail/static/src/components/chat_window_hidden_menu/chat_window_hidden_menu.js index b598eeee4b4..c6ec0b74e9f 100644 --- a/addons/mail/static/src/components/chat_window_hidden_menu/chat_window_hidden_menu.js +++ b/addons/mail/static/src/components/chat_window_hidden_menu/chat_window_hidden_menu.js @@ -123,9 +123,7 @@ class ChatWindowHiddenMenu extends Component { */ _onClickedChatWindow(ev) { const chatWindow = ev.detail.chatWindow; - chatWindow.makeVisible(); - chatWindow.unfold(); - chatWindow.focus(); + chatWindow.makeActive(); this.env.messaging.chatWindowManager.closeHiddenMenu(); } diff --git a/addons/mail/static/src/components/chat_window_manager/chat_window_manager_tests.js b/addons/mail/static/src/components/chat_window_manager/chat_window_manager_tests.js index 3306dbed905..efba64cecde 100644 --- a/addons/mail/static/src/components/chat_window_manager/chat_window_manager_tests.js +++ b/addons/mail/static/src/components/chat_window_manager/chat_window_manager_tests.js @@ -255,6 +255,128 @@ QUnit.test('chat window new message: fold', async function (assert) { ); }); +QUnit.test('open chat from "new message" chat window should open chat in place of this "new message" chat window', async function (assert) { + /** + * InnerWith computation uses following info: + * ([mocked] global window width: @see `mail/static/src/utils/test_utils.js:start()` method) + * (others: @see mail/static/src/models/chat_window_manager/chat_window_manager.js:visual) + * + * - chat window width: 325px + * - start/end/between gap width: 10px/10px/5px + * - hidden menu width: 200px + * - global width: 1920px + * + * Enough space for 3 visible chat windows: + * 10 + 325 + 5 + 325 + 5 + 325 + 10 = 1000 < 1920 + */ + assert.expect(11); + + this.data['res.partner'].records.push({ id: 131, name: "Partner 131" }); + this.data['res.users'].records.push({ partner_id: 131 }); + this.data['mail.channel'].records.push( + { is_minimized: true }, + { is_minimized: true }, + ); + const imSearchDef = makeDeferred(); + await this.start({ + env: { + browser: { + innerWidth: 1920, + }, + }, + async mockRPC(route, args) { + const res = await this._super(...arguments); + if (args.method === 'im_search') { + imSearchDef.resolve(); + } + return res; + } + }); + assert.containsN( + document.body, + '.o_ChatWindow', + 2, + "should have 2 chat windows initially" + ); + assert.containsNone( + document.body, + '.o_ChatWindow.o-new-message', + "should not have any 'new message' chat window initially" + ); + + // open "new message" chat window + await afterNextRender(() => + document.querySelector(`.o_MessagingMenu_toggler`).click() + ); + await afterNextRender(() => + document.querySelector(`.o_MessagingMenu_newMessageButton`).click() + ); + assert.containsOnce( + document.body, + '.o_ChatWindow.o-new-message', + "should have 'new message' chat window after clicking 'new message' in messaging menu" + ); + assert.containsN( + document.body, + '.o_ChatWindow', + 3, + "should have 3 chat window after opening 'new message' chat window", + ); + assert.containsOnce( + document.body, + '.o_ChatWindow_newMessageFormInput', + "'new message' chat window should have new message form input" + ); + assert.hasClass( + document.querySelector('.o_ChatWindow[data-visible-index="2"]'), + 'o-new-message', + "'new message' chat window should be the last chat window initially", + ); + + await afterNextRender(() => + document.querySelector('.o_ChatWindow[data-visible-index="2"] .o_ChatWindowHeader_commandShiftRight').click() + ); + assert.hasClass( + document.querySelector('.o_ChatWindow[data-visible-index="1"]'), + 'o-new-message', + "'new message' chat window should have moved to the middle after clicking shift previous", + ); + + // search for a user in "new message" autocomplete + document.execCommand('insertText', false, "131"); + document.querySelector(`.o_ChatWindow_newMessageFormInput`) + .dispatchEvent(new window.KeyboardEvent('keydown')); + document.querySelector(`.o_ChatWindow_newMessageFormInput`) + .dispatchEvent(new window.KeyboardEvent('keyup')); + // Wait for search RPC to be resolved. The following await lines are + // necessary because autocomplete is an external lib therefore it is not + // possible to use `afterNextRender`. + await imSearchDef; + await nextAnimationFrame(); + const link = document.querySelector('.ui-autocomplete .ui-menu-item a'); + assert.ok( + link, + "should have autocomplete suggestion after typing on 'new message' input" + ); + assert.strictEqual( + link.textContent, + "Partner 131", + "autocomplete suggestion should target the partner matching search term" + ); + + await afterNextRender(() => link.click()); + assert.containsNone( + document.body, + '.o_ChatWindow.o-new-message', + "should have removed the 'new message' chat window after selecting a partner" + ); + assert.strictEqual( + document.querySelector('.o_ChatWindow[data-visible-index="1"] .o_ChatWindowHeader_name').textContent, + "Partner 131", + "chat window with selected partner should be opened in position where 'new message' chat window was, which is in the middle" + ); +}); + QUnit.test('chat window: basic rendering', async function (assert) { assert.expect(11); @@ -1351,7 +1473,7 @@ QUnit.test('chat window: switch on TAB', async function (assert) { ); }); -QUnit.test('chat window: TAB cycle with 3 open chat windows', async function (assert) { +QUnit.test('chat window: TAB cycle with 3 open chat windows [REQUIRE FOCUS]', async function (assert) { /** * InnerWith computation uses following info: * ([mocked] global window width: @see `mail/static/src/utils/test_utils.js:start()` method) diff --git a/addons/mail/static/src/components/chatter/chatter.js b/addons/mail/static/src/components/chatter/chatter.js index 61fbb78d619..cd1f8f8720b 100644 --- a/addons/mail/static/src/components/chatter/chatter.js +++ b/addons/mail/static/src/components/chatter/chatter.js @@ -37,19 +37,18 @@ class Chatter extends Component { attachments: 1, }, }); - this._threadRef = useRef('thread'); + /** + * Reference of the composer. Useful to focus it. + */ + this._composerRef = useRef('composer'); } mounted() { - if (this.chatter.thread) { - this._notifyRendered(); - } + this._update(); } patched() { - if (this.chatter.thread) { - this._notifyRendered(); - } + this._update(); } //-------------------------------------------------------------------------- @@ -77,6 +76,22 @@ class Chatter extends Component { }); } + /** + * @private + */ + _update() { + if (this.chatter.thread) { + this._notifyRendered(); + } + if (this.chatter.isDoFocus) { + this.chatter.update({ isDoFocus: false }); + const composer = this._composerRef.comp; + if (composer) { + composer.focus(); + } + } + } + //-------------------------------------------------------------------------- // Handlers //-------------------------------------------------------------------------- diff --git a/addons/mail/static/src/components/chatter/chatter.xml b/addons/mail/static/src/components/chatter/chatter.xml index b83c9ad733f..a48396f813c 100644 --- a/addons/mail/static/src/components/chatter/chatter.xml +++ b/addons/mail/static/src/components/chatter/chatter.xml @@ -39,10 +39,8 @@ class="o_Chatter_thread" hasComposer="false" hasScrollAdjust="chatter.hasMessageListScrollAdjust" - haveMessagesAuthorRedirect="true" order="'desc'" threadViewLocalId="chatter.threadView.localId" - t-ref="thread" /> </t> </t> diff --git a/addons/mail/static/src/components/composer/composer.js b/addons/mail/static/src/components/composer/composer.js index afb7d811eae..0b23ee4ec22 100644 --- a/addons/mail/static/src/components/composer/composer.js +++ b/addons/mail/static/src/components/composer/composer.js @@ -121,7 +121,7 @@ class Composer extends Component { if (this.env.messaging.device.isMobile) { this.el.scrollIntoView(); } - this.composer.focus(); + this._textInputRef.comp.focus(); } /** @@ -288,7 +288,7 @@ class Composer extends Component { ev.stopPropagation(); this._textInputRef.comp.saveStateInStore(); this.composer.insertIntoTextInput(ev.detail.unicode); - this.composer.focus(); + this.focus(); } /** @@ -323,7 +323,7 @@ class Composer extends Component { if (ev.key === 'Escape') { if (this._emojisPopoverRef.comp) { this._emojisPopoverRef.comp.close(); - this.composer.focus(); + this.focus(); markEventHandled(ev, 'Composer.closeEmojisPopover'); } } diff --git a/addons/mail/static/src/components/composer_text_input/composer_text_input.js b/addons/mail/static/src/components/composer_text_input/composer_text_input.js index 53616e112e9..6a0943f9ca8 100644 --- a/addons/mail/static/src/components/composer_text_input/composer_text_input.js +++ b/addons/mail/static/src/components/composer_text_input/composer_text_input.js @@ -82,6 +82,10 @@ class ComposerTextInput extends Component { return this.env._t("Write something..."); } + focus() { + this._textareaRef.el.focus(); + } + focusout() { this.saveStateInStore(); this._textareaRef.el.blur(); @@ -155,10 +159,6 @@ class ComposerTextInput extends Component { this.composer.textInputCursorEnd ); this._updateHeight(); - if (this.composer.isDoFocus) { - this._textareaRef.el.focus(); - this.composer.update({ isDoFocus: false }); - } } /** @@ -273,7 +273,7 @@ class ComposerTextInput extends Component { if (this.composer.activeSuggestedPartner) { this.composer.insertMentionedPartner(this.composer.activeSuggestedPartner); this.composer.closeMentionSuggestions(); - this.composer.focus(); + this.focus(); } } break; @@ -322,7 +322,7 @@ class ComposerTextInput extends Component { _onPartnerMentionSuggestionClicked(ev) { this.composer.insertMentionedPartner(ev.detail.partner); this.composer.closeMentionSuggestions(); - this.composer.focus(); + this.focus(); } /** diff --git a/addons/mail/static/src/components/discuss/discuss.js b/addons/mail/static/src/components/discuss/discuss.js index 83aee1e201e..4c8c4a58ab7 100644 --- a/addons/mail/static/src/components/discuss/discuss.js +++ b/addons/mail/static/src/components/discuss/discuss.js @@ -47,12 +47,13 @@ class Discuss extends Component { }); this._updateLocalStoreProps(); /** - * Reference of the thread. Useful to update scroll position correctly - * on patch. AKU TODO: this made sense when composer was outside of - * thread, but this may no longer be necessary?? + * Reference of the composer. Useful to focus it. */ - this._threadRef = useRef('thread'); - + this._composerRef = useRef('composer'); + /** + * Reference of the ThreadView. Useful to focus it. + */ + this._threadViewRef = useRef('threadView'); // bind since passed as props this._onMobileAddItemHeaderInputSelect = this._onMobileAddItemHeaderInputSelect.bind(this); this._onMobileAddItemHeaderInputSource = this._onMobileAddItemHeaderInputSource.bind(this); @@ -66,6 +67,7 @@ class Discuss extends Component { this.discuss.openInitThread(); } this._updateLocalStoreProps(); + this._update(); } patched() { @@ -83,6 +85,7 @@ class Discuss extends Component { } this._activeThreadCache = this.discuss.threadView.threadCache; this._updateLocalStoreProps(); + this._update(); } willUnmount() { @@ -142,6 +145,24 @@ class Discuss extends Component { /** * @private */ + _update() { + if (this.discuss.isDoFocus) { + this.discuss.update({ isDoFocus: false }); + const composer = this._composerRef.comp; + if (composer) { + composer.focus(); + } else { + const threadView = this._threadViewRef.comp; + if (threadView) { + threadView.focus(); + } + } + } + } + + /** + * @private + */ _updateLocalStoreProps() { /** * Locally tracked store props `activeThreadCache`. @@ -251,16 +272,6 @@ class Discuss extends Component { /** * @private * @param {CustomEvent} ev - * @param {Object} ev.detail - * @param {mail.thread} ev.detail.thread - */ - _onSelectThread(ev) { - this.discuss.threadView.update({ thread: [['link', ev.detail.thread]] }); - } - - /** - * @private - * @param {CustomEvent} ev */ _onThreadRendered(ev) { this.trigger('o-update-control-panel'); diff --git a/addons/mail/static/src/components/discuss/discuss.xml b/addons/mail/static/src/components/discuss/discuss.xml index 94234d0fc9b..d88b4796d9c 100644 --- a/addons/mail/static/src/components/discuss/discuss.xml +++ b/addons/mail/static/src/components/discuss/discuss.xml @@ -58,7 +58,6 @@ hasComposerThreadTyping="true" hasMessageCheckbox="true" hasSquashCloseMessages="discuss.thread.model !== 'mail.box'" - haveMessagesAuthorRedirect="discuss.thread.channel_type !== 'chat'" haveMessagesMarkAsReadIcon="discuss.thread === env.messaging.inbox" haveMessagesReplyIcon="discuss.thread === env.messaging.inbox" selectedMessageLocalId=" @@ -66,6 +65,7 @@ discuss.replyingToMessage.localId" threadViewLocalId="discuss.threadView.localId" t-on-o-rendered="_onThreadRendered" + t-ref="threadView" /> </t> <t t-if="!discuss.thread and (!env.messaging.device.isMobile or discuss.activeMobileNavbarTabId === 'mailbox')"> @@ -77,7 +77,6 @@ <NotificationList class="o_Discuss_notificationList" filter="discuss.activeMobileNavbarTabId" - t-on-o-select-thread="_onSelectThread" /> </t> <t t-if="env.messaging.device.isMobile and !discuss.isReplyingToMessage"> @@ -96,6 +95,7 @@ hasDiscardButton="true" hasThreadName="true" t-on-o-message-posted="_onReplyingToMessageMessagePosted" + t-ref="composer" /> </t> </t> diff --git a/addons/mail/static/src/components/discuss/tests/discuss_tests.js b/addons/mail/static/src/components/discuss/tests/discuss_tests.js index 8c437ce5b18..3f9e93d1de8 100644 --- a/addons/mail/static/src/components/discuss/tests/discuss_tests.js +++ b/addons/mail/static/src/components/discuss/tests/discuss_tests.js @@ -2116,7 +2116,7 @@ QUnit.test('message origin redirect to channel', async function (assert) { }); QUnit.test('redirect to author (open chat)', async function (assert) { - assert.expect(9); + assert.expect(7); // expected correspondent, with a random unique id that will be used to link // partner to chat and a random name that will be asserted in the test @@ -2143,13 +2143,6 @@ QUnit.test('redirect to author (open chat)', async function (assert) { id: 100, model: 'mail.channel', res_id: 1, - }, - { - author_id: this.data.currentPartnerId, - channel_ids: [1], - id: 101, - model: 'mail.channel', - res_id: 1, } ); await this.start({ @@ -2187,8 +2180,8 @@ QUnit.test('redirect to author (open chat)', async function (assert) { ); assert.strictEqual( document.querySelectorAll(`.o_Discuss_thread .o_Message`).length, - 2, - "should have 2 messages" + 1, + "should have 1 message" ); const msg1 = document.querySelector(` .o_Discuss_thread @@ -2196,12 +2189,6 @@ QUnit.test('redirect to author (open chat)', async function (assert) { this.env.models['mail.message'].find(message => message.id === 100).localId }"] `); - const msg2 = document.querySelector(` - .o_Discuss_thread - .o_Message[data-message-local-id="${ - this.env.models['mail.message'].find(message => message.id === 101).localId - }"] - `); assert.strictEqual( msg1.querySelectorAll(`:scope .o_Message_authorAvatar`).length, 1, @@ -2211,15 +2198,6 @@ QUnit.test('redirect to author (open chat)', async function (assert) { msg1.querySelector(`:scope .o_Message_authorAvatar`).classList.contains('o_redirect'), "message1 should have redirect to author" ); - assert.strictEqual( - msg2.querySelectorAll(`:scope .o_Message_authorAvatar`).length, - 1, - "message2 should have author image" - ); - assert.notOk( - msg2.querySelector(`:scope .o_Message_authorAvatar`).classList.contains('o_redirect'), - "message2 should not have redirect to author (self-author)" - ); await afterNextRender(() => msg1.querySelector(`:scope .o_Message_authorAvatar`).click() diff --git a/addons/mail/static/src/components/discuss_mobile_mailbox_selection/discuss_mobile_mailbox_selection.js b/addons/mail/static/src/components/discuss_mobile_mailbox_selection/discuss_mobile_mailbox_selection.js index cea920a45f9..c2185a60dc0 100644 --- a/addons/mail/static/src/components/discuss_mobile_mailbox_selection/discuss_mobile_mailbox_selection.js +++ b/addons/mail/static/src/components/discuss_mobile_mailbox_selection/discuss_mobile_mailbox_selection.js @@ -74,7 +74,7 @@ class DiscussMobileMailboxSelection extends Component { */ _onClick(ev) { const { mailbox } = ev.currentTarget.dataset; - this.discuss.threadView.update({ thread: [['link', mailbox]] }); + mailbox.open(); } } diff --git a/addons/mail/static/src/components/discuss_sidebar_item/discuss_sidebar_item.js b/addons/mail/static/src/components/discuss_sidebar_item/discuss_sidebar_item.js index daaa8b44b5d..2b84762c9dd 100644 --- a/addons/mail/static/src/components/discuss_sidebar_item/discuss_sidebar_item.js +++ b/addons/mail/static/src/components/discuss_sidebar_item/discuss_sidebar_item.js @@ -117,7 +117,7 @@ class DiscussSidebarItem extends Component { * @param {MouseEvent} ev */ _onClick(ev) { - this.discuss.threadView.update({ thread: [['link', this.thread]] }); + this.thread.open(); } /** diff --git a/addons/mail/static/src/components/follower/follower.js b/addons/mail/static/src/components/follower/follower.js index f446c098914..dc3e395f03c 100644 --- a/addons/mail/static/src/components/follower/follower.js +++ b/addons/mail/static/src/components/follower/follower.js @@ -43,10 +43,7 @@ class Follower extends Component { _onClickDetails(ev) { ev.preventDefault(); ev.stopPropagation(); - this.env.messaging.openDocument({ - id: this.follower.resId, - model: this.follower.resModel, - }); + this.follower.openProfile(); } /** diff --git a/addons/mail/static/src/components/follower/follower_tests.js b/addons/mail/static/src/components/follower/follower_tests.js index d93cb24b6c2..a742e899e6b 100644 --- a/addons/mail/static/src/components/follower/follower_tests.js +++ b/addons/mail/static/src/components/follower/follower_tests.js @@ -4,6 +4,7 @@ odoo.define('mail/static/src/components/follower/follower_tests.js', function (r const components = { Follower: require('mail/static/src/components/follower/follower.js'), }; +const { makeDeferred } = require('mail/static/src/utils/deferred/deferred.js'); const { afterEach, afterNextRender, @@ -141,8 +142,8 @@ QUnit.test('click on channel follower details', async function (assert) { assert.step('do_action'); assert.strictEqual( payload.action.res_id, - 1, - "The redirect action should redirect to the right res id (1)" + 10, + "The redirect action should redirect to the right res id (10)" ); assert.strictEqual( payload.action.res_model, @@ -155,7 +156,8 @@ QUnit.test('click on channel follower details', async function (assert) { "The redirect action should be of type 'ir.actions.act_window'" ); }); - + this.data['res.partner'].records.push({ id: 100 }); + this.data['mail.channel'].records.push({ id: 10 }); await this.start({ env: { bus }, }); @@ -164,7 +166,7 @@ QUnit.test('click on channel follower details', async function (assert) { model: 'res.partner', }); const follower = await this.env.models['mail.follower'].create({ - channel: [['insert', { id: 1, model: 'mail.channel', name: "channel" }]], + channel: [['insert', { id: 10, model: 'mail.channel', name: "channel" }]], followedThread: [['link', thread]], id: 2, isActive: true, @@ -192,6 +194,7 @@ QUnit.test('click on channel follower details', async function (assert) { QUnit.test('click on partner follower details', async function (assert) { assert.expect(7); + const openFormDef = makeDeferred(); const bus = new Bus(); bus.on('do-action', null, payload => { assert.step('do_action'); @@ -210,8 +213,9 @@ QUnit.test('click on partner follower details', async function (assert) { "ir.actions.act_window", "The redirect action should be of type 'ir.actions.act_window'" ); + openFormDef.resolve(); }); - + this.data['res.partner'].records.push({ id: 100 }); await this.start({ env: { bus }, }); @@ -243,6 +247,7 @@ QUnit.test('click on partner follower details', async function (assert) { ); document.querySelector('.o_Follower_details').click(); + await openFormDef; assert.verifySteps( ['do_action'], "clicking on follower should redirect to partner form view" @@ -302,6 +307,7 @@ QUnit.test('click on edit follower', async function (assert) { QUnit.test('edit follower and close subtype dialog', async function (assert) { assert.expect(6); + this.data['res.partner'].records.push({ id: 100 }); await this.start({ hasDialog: true, async mockRPC(route, args) { diff --git a/addons/mail/static/src/components/message/message.js b/addons/mail/static/src/components/message/message.js index 157f3dc2acd..7d314647bb2 100644 --- a/addons/mail/static/src/components/message/message.js +++ b/addons/mail/static/src/components/message/message.js @@ -11,6 +11,7 @@ const components = { PartnerImStatusIcon: require('mail/static/src/components/partner_im_status_icon/partner_im_status_icon.js'), }; const useStore = require('mail/static/src/component_hooks/use_store/use_store.js'); +const { markEventHandled } = require('mail/static/src/utils/utils.js'); const { timeFromNow } = require('mail.utils'); const { _lt } = require('web.core'); @@ -156,19 +157,18 @@ class Message extends Component { } /** - * Determine whether author redirect feature is enabled on message. - * Click on message author should redirect to author. + * Determines whether author open chat feature is enabled on message. * * @returns {boolean} */ - get hasAuthorRedirect() { - if (!this.props.hasAuthorRedirect) { - return false; - } + get hasAuthorOpenChat() { if (!this.message.author) { return false; } - if (this.message.author === this.env.messaging.currentPartner) { + if ( + this.message.originThread && + this.message.originThread.correspondent === this.message.author + ) { return false; } return true; @@ -237,6 +237,13 @@ class Message extends Component { } /** + * @returns {string} + */ + get OPEN_CHAT() { + return this.env._t("Open chat"); + } + + /** * Make this message viewable in its enclosing scroll environment (usually * message list). * @@ -245,7 +252,7 @@ class Message extends Component { * @param {string} [param0.block='end'] * @returns {Promise} */ - async scrollIntoView({ behavior='auto', block='end' }={}) { + async scrollIntoView({ behavior = 'auto', block = 'end' } = {}) { this.el.scrollIntoView({ behavior, block, @@ -419,23 +426,33 @@ class Message extends Component { * @param {MouseEvent} ev */ _onClick(ev) { + if (ev.target.closest('.o_channel_redirect')) { + this.env.messaging.openProfile({ + id: Number(ev.target.dataset.oeId), + model: 'mail.channel', + }); + // avoid following dummy href + ev.preventDefault(); + return; + } if (ev.target.closest('.o_mention')) { - this.env.messaging.redirect({ + this.env.messaging.openProfile({ id: Number(ev.target.dataset.oeId), model: ev.target.dataset.oeModel, }); + // avoid following dummy href ev.preventDefault(); return; } if (ev.target.closest('.o_mail_redirect')) { - this.env.messaging.redirect({ + this.env.messaging.openProfile({ id: Number(ev.target.dataset.oeId), model: ev.target.dataset.oeModel, }); + // avoid following dummy href ev.preventDefault(); return; } - ev.stopPropagation(); this.state.isClicked = !this.state.isClicked; } @@ -443,17 +460,24 @@ class Message extends Component { * @private * @param {MouseEvent} ev */ - _onClickAuthor(ev) { - if (!this.hasAuthorRedirect) { + _onClickAuthorAvatar(ev) { + if (!this.hasAuthorOpenChat) { return; } + markEventHandled(ev, 'Message.authorOpenChat'); + this.message.author.openChat(); + } + + /** + * @private + * @param {MouseEvent} ev + */ + _onClickAuthorName(ev) { if (!this.message.author) { return; } - this.env.messaging.redirect({ - id: this.message.author.id, - model: this.message.author.model, - }); + markEventHandled(ev, 'Message.authorOpenProfile'); + this.message.author.openProfile(); } /** @@ -514,11 +538,9 @@ class Message extends Component { * @param {MouseEvent} ev */ _onClickOriginThread(ev) { + // avoid following dummy href ev.preventDefault(); - this.env.messaging.redirect({ - id: this.message.originThread.id, - model: this.message.originThread.model, - }); + this.message.originThread.open(); } /** @@ -581,7 +603,6 @@ class Message extends Component { Object.assign(Message, { components, defaultProps: { - hasAuthorRedirect: false, hasCheckbox: false, hasMarkAsReadIcon: false, hasReplyIcon: false, @@ -594,7 +615,6 @@ Object.assign(Message, { optional: true, validate: prop => ['auto', 'card', 'hover', 'none'].includes(prop), }, - hasAuthorRedirect: Boolean, hasCheckbox: Boolean, hasMarkAsReadIcon: Boolean, hasReplyIcon: Boolean, diff --git a/addons/mail/static/src/components/message/message.xml b/addons/mail/static/src/components/message/message.xml index 7739c79337a..b0a41947c88 100644 --- a/addons/mail/static/src/components/message/message.xml +++ b/addons/mail/static/src/components/message/message.xml @@ -18,7 +18,7 @@ <div class="o_Message_sidebar" t-att-class="{ 'o-message-squashed': props.isSquashed }"> <t t-if="!props.isSquashed"> <div class="o_Message_authorAvatarContainer o_Message_sidebarItem"> - <img class="o_Message_authorAvatar rounded-circle" t-att-class="{ o_Message_authorRedirect: hasAuthorRedirect, o_redirect: hasAuthorRedirect }" t-att-src="avatar" t-on-click="_onClickAuthor" t-att-title="hasAuthorRedirect ? 'Open chat' : ''" alt=""/> + <img class="o_Message_authorAvatar rounded-circle" t-att-class="{ o_Message_authorRedirect: hasAuthorOpenChat, o_redirect: hasAuthorOpenChat }" t-att-src="avatar" t-on-click="_onClickAuthorAvatar" t-att-title="hasAuthorOpenChat ? OPEN_CHAT : ''" alt="Avatar"/> <t t-if="message.author and message.author.im_status"> <PartnerImStatusIcon class="o_Message_partnerImStatusIcon" @@ -58,7 +58,7 @@ <t t-if="!props.isSquashed"> <div class="o_Message_header"> <t t-if="message.author"> - <div class="o_Message_authorName" t-att-class="hasAuthorRedirect ? 'o_Message_authorRedirect o_redirect' : ''" t-on-click="_onClickAuthor" t-att-title="hasAuthorRedirect ? 'Open chat' : ''"> + <div class="o_Message_authorName o_Message_authorRedirect o_redirect" t-on-click="_onClickAuthorName" title="Open profile"> <t t-esc="message.author.nameOrDisplayName"/> </div> </t> diff --git a/addons/mail/static/src/components/message_list/message_list.js b/addons/mail/static/src/components/message_list/message_list.js index 0cdf4893305..966bff9a5ba 100644 --- a/addons/mail/static/src/components/message_list/message_list.js +++ b/addons/mail/static/src/components/message_list/message_list.js @@ -141,9 +141,6 @@ class MessageList extends Component { case 'more-messages-loaded': this._adjustFromMoreMessagesLoaded(hint); break; - default: - this.threadView.markComponentHintProcessed(hint); - break; } } this._willPatchSnapshot = undefined; @@ -552,7 +549,6 @@ Object.assign(MessageList, { hasMessageCheckbox: false, hasScrollAdjust: true, hasSquashCloseMessages: false, - haveMessagesAuthorRedirect: false, haveMessagesMarkAsReadIcon: false, haveMessagesReplyIcon: false, order: 'asc', @@ -560,7 +556,6 @@ Object.assign(MessageList, { props: { hasMessageCheckbox: Boolean, hasSquashCloseMessages: Boolean, - haveMessagesAuthorRedirect: Boolean, haveMessagesMarkAsReadIcon: Boolean, haveMessagesReplyIcon: Boolean, hasScrollAdjust: Boolean, diff --git a/addons/mail/static/src/components/message_list/message_list.xml b/addons/mail/static/src/components/message_list/message_list.xml index a678f7a7199..0a87558e839 100644 --- a/addons/mail/static/src/components/message_list/message_list.xml +++ b/addons/mail/static/src/components/message_list/message_list.xml @@ -65,7 +65,6 @@ t-att-class="{ 'o-has-message-selection': props.selectedMessageLocalId, }" - hasAuthorRedirect="props.haveMessagesAuthorRedirect" hasMarkAsReadIcon="props.haveMessagesMarkAsReadIcon" hasCheckbox="props.hasMessageCheckbox" hasReplyIcon="props.haveMessagesReplyIcon" diff --git a/addons/mail/static/src/components/messaging_menu/messaging_menu.js b/addons/mail/static/src/components/messaging_menu/messaging_menu.js index ac4472d8209..4e5ede8f635 100644 --- a/addons/mail/static/src/components/messaging_menu/messaging_menu.js +++ b/addons/mail/static/src/components/messaging_menu/messaging_menu.js @@ -33,13 +33,6 @@ class MessagingMenu extends Component { }; }); - /** - * Reference of the new message input in mobile. Useful to include it - * and autocomplete menu as "inside" the messaging menu, to prevent - * closing the messaging menu otherwise. - */ - this._mobileNewMessageInputRef = useRef('mobileNewMessageInput'); - // bind since passed as props this._onMobileNewMessageInputSelect = this._onMobileNewMessageInputSelect.bind(this); this._onMobileNewMessageInputSource = this._onMobileNewMessageInputSource.bind(this); @@ -121,20 +114,14 @@ class MessagingMenu extends Component { // in mobile: keeps the messaging menu open in background // TODO: maybe need to move this to a mobile component? // task-2089887 - if ( - this.env.messaging.device.isMobile && - this.env.messaging.chatWindowManager.hasVisibleChatWindows - ) { + if (this.env.messaging.device.isMobile) { return; } // ignore click inside the menu if (this.el.contains(ev.target)) { return; } - const input = this._mobileNewMessageInputRef.comp; - if (input && input.contains(ev.target)) { - return; - } + // in all other cases: close the messaging menu when clicking outside this.messagingMenu.close(); } @@ -194,21 +181,7 @@ class MessagingMenu extends Component { * @param {integer} ui.item.id */ _onMobileNewMessageInputSelect(ev, ui) { - const partnerId = ui.item.id; - const partner = this.env.models['mail.partner'].find(partner => partner.id === partnerId); - const chat = partner.correspondentThreads.find(thread => thread.channel_type === 'chat'); - if (chat) { - chat.open(); - } else { - this.env.models['mail.thread'].createChannel({ - autoselect: true, - partnerId, - type: 'chat', - }); - } - if (!this.env.messaging.device.isMobile) { - this.messagingMenu.close(); - } + this.env.messaging.openChat({ partnerId: ui.item.id }); } /** @@ -246,20 +219,6 @@ class MessagingMenu extends Component { this.messagingMenu.update({ activeTabId: ev.detail.tabId }); } - /** - * @private - * @param {CustomEvent} ev - * @param {Object} ev.detail - * @param {mail.thread} ev.detail.thread - */ - _onSelectThread(ev) { - ev.stopPropagation(); - ev.detail.thread.open(); - if (!this.env.messaging.device.isMobile) { - this.messagingMenu.close(); - } - } - } Object.assign(MessagingMenu, { diff --git a/addons/mail/static/src/components/messaging_menu/messaging_menu.xml b/addons/mail/static/src/components/messaging_menu/messaging_menu.xml index b743c7ca92d..fc779231dd8 100644 --- a/addons/mail/static/src/components/messaging_menu/messaging_menu.xml +++ b/addons/mail/static/src/components/messaging_menu/messaging_menu.xml @@ -46,7 +46,6 @@ select="_onMobileNewMessageInputSelect" source="_onMobileNewMessageInputSource" t-on-o-hide="_onHideMobileNewMessage" - t-ref="mobileNewMessageInput" /> </t> </div> @@ -54,7 +53,6 @@ class="o_MessagingMenu_notificationList" t-att-class="{ 'o-mobile': env.messaging.device.isMobile }" filter="messagingMenu.activeTabId" - t-on-o-select-thread="_onSelectThread" /> <t t-if="env.messaging.device.isMobile"> <MobileMessagingNavbar diff --git a/addons/mail/static/src/components/notification_group/notification_group.js b/addons/mail/static/src/components/notification_group/notification_group.js index 4020a610dc6..65cfcea3076 100644 --- a/addons/mail/static/src/components/notification_group/notification_group.js +++ b/addons/mail/static/src/components/notification_group/notification_group.js @@ -61,6 +61,9 @@ class NotificationGroup extends Component { return; } this.group.openDocuments(); + if (!this.env.messaging.device.isMobile) { + this.env.messaging.messagingMenu.close(); + } } /** @@ -69,6 +72,9 @@ class NotificationGroup extends Component { */ _onClickMarkAsRead(ev) { this.group.openCancelAction(); + if (!this.env.messaging.device.isMobile) { + this.env.messaging.messagingMenu.close(); + } } } diff --git a/addons/mail/static/src/components/notification_list/notification_list_notification_group_tests.js b/addons/mail/static/src/components/notification_list/notification_list_notification_group_tests.js index fcab0251524..aa1d36593ad 100644 --- a/addons/mail/static/src/components/notification_list/notification_list_notification_group_tests.js +++ b/addons/mail/static/src/components/notification_list/notification_list_notification_group_tests.js @@ -364,6 +364,7 @@ QUnit.test('different mail.channel are not grouped', async function (assert) { // they are linked to different channels, even though the model is the same. assert.expect(6); + this.data['mail.channel'].records.push({ id: 31 }, { id: 32 }); this.data['mail.message'].records.push( // first message that is expected to have a failure { diff --git a/addons/mail/static/src/components/notification_request/notification_request.js b/addons/mail/static/src/components/notification_request/notification_request.js index cb1b59ccbaa..69fb5beab2f 100644 --- a/addons/mail/static/src/components/notification_request/notification_request.js +++ b/addons/mail/static/src/components/notification_request/notification_request.js @@ -74,9 +74,7 @@ class NotificationRequest extends Component { if (def) { def.then(this._handleResponseNotificationPermission.bind(this)); } - - const device = this.env.messaging.device; - if (!device.isMobile) { + if (!this.env.messaging.device.isMobile) { this.env.messaging.messagingMenu.close(); } } diff --git a/addons/mail/static/src/components/thread_needaction_preview/thread_needaction_preview.js b/addons/mail/static/src/components/thread_needaction_preview/thread_needaction_preview.js index efcd8f5d86e..24486cfc93c 100644 --- a/addons/mail/static/src/components/thread_needaction_preview/thread_needaction_preview.js +++ b/addons/mail/static/src/components/thread_needaction_preview/thread_needaction_preview.js @@ -105,7 +105,10 @@ class ThreadNeedactionPreview extends Component { // handled in `_onClickMarkAsRead` return; } - this.trigger('o-select-thread', { thread: this.thread }); + this.thread.open(); + if (!this.env.messaging.device.isMobile) { + this.env.messaging.messagingMenu.close(); + } } /** diff --git a/addons/mail/static/src/components/thread_preview/thread_preview.js b/addons/mail/static/src/components/thread_preview/thread_preview.js index 614201e821d..6e30005225c 100644 --- a/addons/mail/static/src/components/thread_preview/thread_preview.js +++ b/addons/mail/static/src/components/thread_preview/thread_preview.js @@ -97,7 +97,10 @@ class ThreadPreview extends Component { // handled in `_onClickMarkAsRead` return; } - this.trigger('o-select-thread', { thread: this.thread }); + this.thread.open(); + if (!this.env.messaging.device.isMobile) { + this.env.messaging.messagingMenu.close(); + } } /** diff --git a/addons/mail/static/src/components/thread_view/thread_view.js b/addons/mail/static/src/components/thread_view/thread_view.js index 7b1680f31a8..395fac68474 100644 --- a/addons/mail/static/src/components/thread_view/thread_view.js +++ b/addons/mail/static/src/components/thread_view/thread_view.js @@ -132,7 +132,6 @@ Object.assign(ThreadView, { hasComposer: false, hasMessageCheckbox: false, hasSquashCloseMessages: false, - haveMessagesAuthorRedirect: false, haveMessagesMarkAsReadIcon: false, haveMessagesReplyIcon: false, order: 'asc', @@ -168,7 +167,6 @@ Object.assign(ThreadView, { optional: true, }, hasSquashCloseMessages: Boolean, - haveMessagesAuthorRedirect: Boolean, haveMessagesMarkAsReadIcon: Boolean, haveMessagesReplyIcon: Boolean, order: { diff --git a/addons/mail/static/src/components/thread_view/thread_view.xml b/addons/mail/static/src/components/thread_view/thread_view.xml index 30844135e65..eead567fa1e 100644 --- a/addons/mail/static/src/components/thread_view/thread_view.xml +++ b/addons/mail/static/src/components/thread_view/thread_view.xml @@ -15,7 +15,6 @@ hasMessageCheckbox="props.hasMessageCheckbox" hasScrollAdjust="props.hasScrollAdjust" hasSquashCloseMessages="props.hasSquashCloseMessages" - haveMessagesAuthorRedirect="props.haveMessagesAuthorRedirect" haveMessagesMarkAsReadIcon="props.haveMessagesMarkAsReadIcon" haveMessagesReplyIcon="props.haveMessagesReplyIcon" order="props.order" diff --git a/addons/mail/static/src/components/thread_view/thread_view_tests.js b/addons/mail/static/src/components/thread_view/thread_view_tests.js index 06832473b99..c8f41632ae3 100644 --- a/addons/mail/static/src/components/thread_view/thread_view_tests.js +++ b/addons/mail/static/src/components/thread_view/thread_view_tests.js @@ -304,7 +304,7 @@ QUnit.test('mark channel as fetched when a new message is loaded and as seen whe "Channel should have been fetched but not seen yet" ); - await afterNextRender(() => thread.composer.focus()); + await afterNextRender(() => document.querySelector('.o_ComposerTextInput_textarea').focus()); assert.verifySteps( ['rpc:channel_seen'], "Channel should have been marked as seen after threadView got the focus" @@ -355,6 +355,8 @@ QUnit.test('mark channel as fetched and seen when a new message is loaded if com const threadView = this.env.models['mail.thread_view'].create({ thread: [['link', thread]] }); await this.createThreadViewComponent(threadView, { hasComposer: true }); + + document.querySelector('.o_ComposerTextInput_textarea').focus(); const notifications = [ [['myDB', 'mail.channel', 100], { channelId: 100, diff --git a/addons/mail/static/src/js/many2one_avatar_user.js b/addons/mail/static/src/js/many2one_avatar_user.js index 3a4e04089f5..6a5b527004a 100644 --- a/addons/mail/static/src/js/many2one_avatar_user.js +++ b/addons/mail/static/src/js/many2one_avatar_user.js @@ -4,27 +4,18 @@ odoo.define('mail.Many2OneAvatarUser', function (require) { // This module defines an extension of the Many2OneAvatar widget, which is // integrated with the messaging system. The Many2OneAvatarUser is designed // to display people, and when the avatar of those people is clicked, it - // opens a DM chat window with the corresponding partner (the messaging - // system is based on model 'res.partner'). + // opens a DM chat window with the corresponding user. // - // This widget is supported on many2one fields pointing to 'res.users'. When - // the user is clicked, we fetch the partner id of the given user (field - // 'partner_id'), and we open a chat window with this partner. + // This widget is supported on many2one fields pointing to 'res.users'. // // Usage: // <field name="user_id" widget="many2one_avatar_user"/> // // The widget is designed to be extended, to support many2one fields pointing - // to other models than 'res.users'. Those models must have a many2one field - // pointing to 'res.partner'. Ideally, the many2one should be false if the - // corresponding partner isn't associated with a user, otherwise it will open - // chat windows with partners that won't be able to read your messages and - // reply. + // to other models than 'res.users'. - const { _t } = require('web.core'); const fieldRegistry = require('web.field_registry'); const { Many2OneAvatar } = require('web.relational_fields'); - const session = require('web.session'); const { Component } = owl; @@ -32,71 +23,17 @@ odoo.define('mail.Many2OneAvatarUser', function (require) { events: Object.assign({}, Many2OneAvatar.prototype.events, { 'click .o_m2o_avatar': '_onAvatarClicked', }), - // Maps record ids to promises that resolve with the corresponding partner ids. - // Used as a cache shared between all instances of this widget. - partnerIds: {}, // This widget is only supported on many2ones pointing to 'res.users' - supportedModel: 'res.users', + supportedModels: ['res.users'], init() { this._super(...arguments); - if (this.supportedModel !== this.field.relation) { - throw new Error(`This widget is only supported on many2one fields pointing to ${this.supportedModel}`); + if (!this.supportedModels.includes(this.field.relation)) { + throw new Error(`This widget is only supported on many2one fields pointing to ${JSON.stringify(this.supportedModels)}`); } if (this.mode === 'readonly') { this.className += ' o_clickable_m2o_avatar'; } - this.partnerField = 'partner_id'; // field to read on 'res.users' to get the partner id - }, - - //---------------------------------------------------------------------- - // Private - //---------------------------------------------------------------------- - - /** - * Displays a warning when we can't open a DM chat window with the partner - * corresponding to the clicked record. On model 'res.users', it only - * happens when the user clicked on himself. This can be overridden by - * extensions of this widget, for instance to handle the case where the - * user clicked on a record whose partner isn't associated with any user. - * - * @private - */ - _displayWarning() { - this.displayNotification({ - message: _t('You cannot chat with yourself'), - type: 'info', - }); - }, - /** - * @private - * @param {number} resId - * @returns {string} the key to use in the partnerIds cache - */ - _getCacheKey(resId) { - return `${this.field.relation}_${resId}`; - }, - /** - * For a given record id of the comodel, returns the corresponding - * partner id. - * - * @param {number} resId - * @returns {Promise<integer|false>} - */ - _resIdToPartnerId(resId) { - const key = this._getCacheKey(resId); - if (!this.partnerIds[key]) { - const params = { - method: 'read', - model: this.field.relation, - args: [resId, [this.partnerField]], - }; - this.partnerIds[key] = this._rpc(params).then(recs => { - const partner = recs[0][this.partnerField]; - return partner && partner[0]; - }); - } - return this.partnerIds[key]; }, //---------------------------------------------------------------------- @@ -105,27 +42,15 @@ odoo.define('mail.Many2OneAvatarUser', function (require) { /** * When the avatar is clicked, open a DM chat window with the - * corresponding partner. If the user clicked on himself, open a blank - * thread window, for the sake of consistency. + * corresponding user. * * @private * @param {MouseEvent} ev */ async _onAvatarClicked(ev) { ev.stopPropagation(); // in list view, prevent from opening the record - let partnerId; - if (this.field.relation !== 'res.users' || this.value.res_id !== session.uid) { - partnerId = await this._resIdToPartnerId(this.value.res_id); - } - if (partnerId && partnerId !== session.partner_id) { - const env = Component.env; - const partner = env.models['mail.partner'].insert({ - id: partnerId, - }); - partner.openChat(); - } else { - this._displayWarning(partnerId); - } + const env = Component.env; + await env.messaging.openChat({ userId: this.value.res_id }); } }); diff --git a/addons/mail/static/src/models/chat_window/chat_window.js b/addons/mail/static/src/models/chat_window/chat_window.js index ab1aa214750..a4db3e638de 100644 --- a/addons/mail/static/src/models/chat_window/chat_window.js +++ b/addons/mail/static/src/models/chat_window/chat_window.js @@ -47,7 +47,7 @@ function factory(dependencies) { expand() { if (this.thread) { - this.thread.openExpanded(); + this.thread.open({ expanded: true }); } } @@ -82,9 +82,23 @@ function factory(dependencies) { } /** - * Assume that this chat window was hidden before-hand. + * Makes this chat window active, which consists of making it visible, + * unfolding it, and focusing it. + */ + makeActive() { + this.makeVisible(); + this.unfold(); + 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); } @@ -161,6 +175,17 @@ function factory(dependencies) { /** * @private + * @returns {boolean} + */ + _computeIsVisible() { + if (!this.manager) { + return false; + } + return this.manager.allOrderedVisible.includes(this); + } + + /** + * @private * @returns {string} */ _computeName() { @@ -316,6 +341,17 @@ function factory(dependencies) { ], default: false, }), + /** + * Whether this chat window 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', }), 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 index 7cdf35ba734..8be11eba453 100644 --- 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 @@ -70,6 +70,19 @@ function factory(dependencies) { this.update({ isHiddenMenuOpen: false }); } + /** + * Closes all chat windows related to the given thread. + * + * @param {mail.thread} thread + */ + closeThread(thread) { + for (const chatWindow of this.chatWindows) { + if (chatWindow.thread === thread) { + chatWindow.close(); + } + } + } + openHiddenMenu() { this.update({ isHiddenMenuOpen: true }); } @@ -81,18 +94,17 @@ function factory(dependencies) { manager: [['link', this]], }); } - newMessageChatWindow.makeVisible(); - newMessageChatWindow.unfold(); - newMessageChatWindow.focus(); + newMessageChatWindow.makeActive(); } /** * @param {mail.thread} thread * @param {Object} [param1={}] - * @param {string} [param1.mode] + * @param {boolean} [param1.makeActive=false] + * @param {boolean} [param1.replaceNewMessage=false] */ - openThread(thread, { mode } = {}) { - let chatWindow = this.env.models['mail.chat_window'].find(chatWindow => + openThread(thread, { makeActive = false, replaceNewMessage = false } = {}) { + let chatWindow = this.chatWindows.find(chatWindow => chatWindow.thread === thread ); if (!chatWindow) { @@ -103,20 +115,15 @@ function factory(dependencies) { }]], }); } - if (mode === 'last_visible' && !chatWindow.isVisible) { - chatWindow.makeVisible(); - chatWindow.unfold(); - chatWindow.focus(); + if (thread.foldState === 'closed') { + thread.update({ pendingFoldState: 'open' }); } - if (mode === 'from_new_message') { - if (!this.newMessageChatWindow) { - throw new Error('Cannot open thread in chat window in mode "from_new_message" without any new message chat window'); - } + if (replaceNewMessage && this.newMessageChatWindow) { this.swap(chatWindow, this.newMessageChatWindow); this.newMessageChatWindow.close(); - chatWindow.makeVisible(); - chatWindow.unfold(); - chatWindow.focus(); + } + if (makeActive) { + chatWindow.makeActive(); } } diff --git a/addons/mail/static/src/models/chatter/chatter.js b/addons/mail/static/src/models/chatter/chatter.js index bfd5b8e96bb..836aac94060 100644 --- a/addons/mail/static/src/models/chatter/chatter.js +++ b/addons/mail/static/src/models/chatter/chatter.js @@ -36,6 +36,10 @@ function factory(dependencies) { // Public //---------------------------------------------------------------------- + focus() { + this.update({ isDoFocus: true }); + } + async refresh() { const thread = this.thread; if (!thread || thread.isTemporary) { @@ -76,18 +80,14 @@ function factory(dependencies) { showLogNote() { this.update({ isComposerVisible: true }); - this.thread.composer.update({ - isDoFocus: true, - isLog: true, - }); + this.thread.composer.update({ isLog: true }); + this.focus(); } showSendMessage() { this.update({ isComposerVisible: true }); - this.thread.composer.update({ - isDoFocus: true, - isLog: false, - }); + this.thread.composer.update({ isLog: false }); + this.focus(); } toggleActivityBoxVisibility() { @@ -310,6 +310,12 @@ function factory(dependencies) { default: false, dependencies: ['threadId'], }), + /** + * Determine whether this chatter should be focused at next render. + */ + isDoFocus: attr({ + default: false, + }), isShowingAttachmentsLoading: attr({ default: false, }), diff --git a/addons/mail/static/src/models/composer/composer.js b/addons/mail/static/src/models/composer/composer.js index b04430ddc5e..2be5919204b 100644 --- a/addons/mail/static/src/models/composer/composer.js +++ b/addons/mail/static/src/models/composer/composer.js @@ -38,10 +38,6 @@ function factory(dependencies) { } } - focus() { - this.update({ isDoFocus: true }); - } - /** * Inserts text content in text input based on selection. * @@ -606,9 +602,6 @@ function factory(dependencies) { ], default: false, }), - isDoFocus: attr({ - default: false, - }), /** * If true composer will log a note, else a comment will be posted. */ diff --git a/addons/mail/static/src/models/discuss/discuss.js b/addons/mail/static/src/models/discuss/discuss.js index 10d92315cec..2dae66f811f 100644 --- a/addons/mail/static/src/models/discuss/discuss.js +++ b/addons/mail/static/src/models/discuss/discuss.js @@ -38,24 +38,35 @@ function factory(dependencies) { this.update({ isOpen: false }); } + focus() { + this.update({ isDoFocus: true }); + } + /** * @param {Event} ev * @param {Object} ui * @param {Object} ui.item * @param {integer} ui.item.id */ - handleAddChannelAutocompleteSelect(ev, ui) { + async handleAddChannelAutocompleteSelect(ev, ui) { + const name = this.addingChannelValue; + this.clearIsAddingItem(); if (ui.item.special) { - this.env.models['mail.thread'].createChannel({ - autoselect: true, - name: this.addingChannelValue, - public: ui.item.special, - type: 'channel', - }); + const channel = await this.async(() => + this.env.models['mail.thread'].performRpcCreateChannel({ + name, + privacy: ui.item.special, + }) + ); + channel.open(); } else { - this.env.models['mail.thread'].joinChannel(ui.item.id, { autoselect: true }); + const channel = await this.async(() => + this.env.models['mail.thread'].performRpcJoinChannel({ + channelId: ui.item.id, + }) + ); + channel.open(); } - this.clearIsAddingItem(); } /** @@ -106,20 +117,7 @@ function factory(dependencies) { * @param {integer} ui.item.id */ handleAddChatAutocompleteSelect(ev, ui) { - const partnerId = ui.item.id; - const partner = this.env.models['mail.partner'].find(partner => - partner.id === partnerId - ); - const chat = partner.correspondentThreads.find(thread => thread.channel_type === 'chat'); - if (chat) { - this.threadView.update({ thread: [['link', chat]] }); - } else { - this.env.models['mail.thread'].createChannel({ - autoselect: true, - partnerId, - type: 'chat', - }); - } + this.env.messaging.openChat({ partnerId: ui.item.id }); this.clearIsAddingItem(); } @@ -160,11 +158,31 @@ function factory(dependencies) { if (!thread) { return; } + thread.open(); + } + + + /** + * Opens the given thread in Discuss, and opens Discuss if necessary. + * + * @param {mail.thread} thread + */ + async openThread(thread) { this.threadView.update({ stringifiedDomain: '[]', thread: [['link', thread]], }); - thread.open({ resetDiscussDomain: true }); + 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(), + }, + }); + } } /** @@ -189,7 +207,7 @@ function factory(dependencies) { this.replyingToMessageOriginThreadComposer.update({ isLog: !message.is_discussion && !message.is_notification }); - this.replyingToMessageOriginThreadComposer.focus(); + this.focus(); } /** @@ -382,6 +400,12 @@ function factory(dependencies) { 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. */ diff --git a/addons/mail/static/src/models/follower/follower.js b/addons/mail/static/src/models/follower/follower.js index e5be843d7ce..6fb83649e2e 100644 --- a/addons/mail/static/src/models/follower/follower.js +++ b/addons/mail/static/src/models/follower/follower.js @@ -64,6 +64,16 @@ function factory(dependencies) { } /** + * 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() { diff --git a/addons/mail/static/src/models/messaging/messaging.js b/addons/mail/static/src/models/messaging/messaging.js index c8c286c7db3..c215d2e5774 100644 --- a/addons/mail/static/src/models/messaging/messaging.js +++ b/addons/mail/static/src/models/messaging/messaging.js @@ -16,6 +16,17 @@ function factory(dependencies) { 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 //---------------------------------------------------------------------- @@ -32,12 +43,53 @@ function factory(dependencies) { /** * 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 */ - openDocument({ id, model }) { + async openDocument({ id, model }) { this.env.bus.trigger('do-action', { action: { type: 'ir.actions.act_window', @@ -46,79 +98,47 @@ function factory(dependencies) { res_id: id, }, }); - this.messagingMenu.close(); + 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(); + } } /** - * Handles redirection to a model and id. Try to handle it in the context - * of messaging (e.g. open chat if this is a user), otherwise fallback to - * opening form view of record. + * 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 - * FIXME needs to be tested and maybe refactored (see task-2244279) */ - async redirect({ id, model }) { - if (model === 'mail.channel') { - const channel = this.env.models['mail.thread'].find(thread => - thread.id === id && - thread.model === 'mail.channel' - ); - if (!channel || !channel.isPinned) { - this.env.models['mail.thread'].joinChannel(id, { autoselect: true }); - return; - } - channel.open(); - } else if (model === 'res.partner') { - if (id === this.currentPartner.id) { - this.openDocument({ - model: 'res.partner', - id, - }); - return; - } + async openProfile({ id, model }) { + if (model === 'res.partner') { const partner = this.env.models['mail.partner'].insert({ id }); - if (!partner.user) { - await this.async(() => partner.checkIsUser()); - } - if (!partner.user) { - // partner is not a user, open document instead - this.openDocument({ - model: 'res.partner', - id: partner.id, - }); - return; + 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]; } - const chat = partner.correspondentThreads.find(thread => - thread.channel_type === 'chat' - ); - if (!chat) { - this.env.models['mail.thread'].createChannel({ - autoselect: true, - partnerId: id, - type: 'chat', + if (!channel) { + this.env.services['notification'].notify({ + message: this.env._t("You can only open the profile of existing channels."), + type: 'warning', }); return; } - chat.open(); - } else { - this.openDocument({ - model, - id, - }); + return channel.openProfile(); } - } - - /** - * Start 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 }); + return this.env.messaging.openDocument({ id, model }); } //---------------------------------------------------------------------- diff --git a/addons/mail/static/src/models/messaging/messaging_tests.js b/addons/mail/static/src/models/messaging/messaging_tests.js index a5d57bf79bd..b306fbb17f3 100644 --- a/addons/mail/static/src/models/messaging/messaging_tests.js +++ b/addons/mail/static/src/models/messaging/messaging_tests.js @@ -21,6 +21,102 @@ QUnit.module('messaging_tests.js', { 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_notification_handler/messaging_notification_handler.js b/addons/mail/static/src/models/messaging_notification_handler/messaging_notification_handler.js index d89992d8a4b..29923af1e39 100644 --- 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 @@ -78,9 +78,7 @@ function factory(dependencies) { case 'ir.needaction': return this._handleNotificationNeedaction(message); case 'mail.channel': - return this._handleNotificationChannel(Object.assign({ - channelId: id, - }, message)); + return this._handleNotificationChannel(id, message); case 'res.partner': if (id !== this.env.messaging.currentPartner.id) { // ignore broadcast to other partners @@ -97,16 +95,15 @@ function factory(dependencies) { /** * @private + * @param {integer} channelId * @param {Object} data - * @param {integer} data.channelId * @param {string} [data.info] * @param {boolean} [data.is_typing] * @param {integer} [data.last_message_id] * @param {integer} [data.partner_id] */ - async _handleNotificationChannel(data) { + async _handleNotificationChannel(channelId, data) { const { - channelId, id, info, is_typing, @@ -115,41 +112,37 @@ function factory(dependencies) { } = data; switch (info) { case 'channel_fetched': - return this._handleNotificationChannelFetched({ + return this._handleNotificationChannelFetched(channelId, { id, - channelId, last_message_id, partner_id, }); case 'channel_seen': - return this._handleNotificationChannelSeen({ + return this._handleNotificationChannelSeen(channelId, { id, - channelId, last_message_id, partner_id, }); case 'typing_status': - return this._handleNotificationChannelTypingStatus({ - channelId, + return this._handleNotificationChannelTypingStatus(channelId, { is_typing, partner_id, }); default: - return this._handleNotificationChannelMessage(data); + return this._handleNotificationChannelMessage(channelId, data); } } /** * @private - * @param {Object} param0 - * @param {integer} param0.channelId - * @param {integer} param0.id - * @param {integer} param0.last_message_id - * @param {integer} param0.partner_id + * @param {integer} channelId + * @param {Object} param1 + * @param {integer} param1.id + * @param {integer} param1.last_message_id + * @param {integer} param1.partner_id */ - async _handleNotificationChannelFetched({ + async _handleNotificationChannelFetched(channelId, { id, - channelId, last_message_id, partner_id, }) { @@ -183,21 +176,18 @@ function factory(dependencies) { /** * @private - * @param {Object} param0 - * @param {integer} param0.channelId - * @param {...Object} param0.data - * @param {Array} [param0.data.author_id] - * @param {integer} param0.data.author_id[0] - * @param {integer[]} param0.data.channel_ids + * @param {integer} channelId + * @param {Object} messageData */ - async _handleNotificationChannelMessage(param0) { - const channelId = param0.channelId; - const convertedData = this.env.models['mail.message'].convertData(param0); - - const oldMessage = this.env.models['mail.message'].find(message => - message.id === convertedData.id - ); - // locally save old value, as insert would overwrite them + 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 ); @@ -205,16 +195,21 @@ function factory(dependencies) { oldMessage && oldMessage.isModeratedByCurrentPartner ); - const message = this.env.models['mail.message'].insert(convertedData); - - // join the corresponding channel if necessary - const channel = this.env.models['mail.thread'].findFromIdentifyingData({ - id: channelId, - model: 'mail.channel', - }); - if (!channel || !channel.isPinned) { - this.env.models['mail.thread'].joinChannel(channelId); + // 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.update({ isPendingPinned: true }); + } + + const message = this.env.models['mail.message'].insert(convertedData); // If the message was already known: nothing else should be done, // except if it was pending moderation by the current partner, then @@ -236,34 +231,49 @@ function factory(dependencies) { return; } - // If the channel wasn't known, joining the channel already updated - // the counter. - if (!channel) { - 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 && channel.mass_mailing && this.env.session.notification_type === 'email' ) { return; } + // In all other cases: update counter and notify if necessary - // In all other cases: update counter and notify if out of focus. - const isOdooFocused = this.env.services['bus_service'].isOdooFocused(); - if (!isOdooFocused) { - this._notifyNewChannelMessageWhileOutOfFocus({ channel, message }); - } - if (channel.model === 'mail.channel') { + // 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) { + // Notify if out of focus + const isOdooFocused = this.env.services['bus_service'].isOdooFocused(); + if (!isOdooFocused) { + this._notifyNewChannelMessageWhileOutOfFocus({ + channel, + message, + }); + } channel.markAsFetched(); + // (re)open chat on receiving new message + if (channel.channel_type !== 'channel') { + 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; } - channel.update({ message_unread_counter: channel.message_unread_counter + 1 }); + channel.update({ + message_unread_counter: channel.message_unread_counter + 1, + }); // manually force recompute of counter this.messaging.messagingMenu.update(); } @@ -273,14 +283,13 @@ function factory(dependencies) { * last message seen. Useful in order to track last message seen. * * @private - * @param {Object} param0 - * @param {integer} param0.channelId - * @param {integer} param0.id - * @param {integer} param0.last_message_id - * @param {integer} param0.partner_id + * @param {integer} channelId + * @param {Object} param1 + * @param {integer} param1.id + * @param {integer} param1.last_message_id + * @param {integer} param1.partner_id */ - async _handleNotificationChannelSeen({ - channelId, + async _handleNotificationChannelSeen(channelId, { id, last_message_id, partner_id, @@ -328,16 +337,12 @@ function factory(dependencies) { /** * @private - * @param {Object} param0 - * @param {integer} param0.channelId - * @param {boolean} param0.is_typing - * @param {integer} param0.partner_id + * @param {integer} channelId + * @param {Object} param1 + * @param {boolean} param1.is_typing + * @param {integer} param1.partner_id */ - _handleNotificationChannelTypingStatus({ - channelId, - is_typing, - partner_id, - }) { + _handleNotificationChannelTypingStatus(channelId, { is_typing, partner_id }) { const channel = this.env.models['mail.thread'].findFromIdentifyingData({ id: channelId, model: 'mail.channel', @@ -456,7 +461,9 @@ function factory(dependencies) { * @param {string} data.uuid */ _handleNotificationPartnerChannel(data) { - const convertedData = this.env.models['mail.thread'].convertData(data); + const convertedData = this.env.models['mail.thread'].convertData( + Object.assign({ model: 'mail.channel' }, data) + ); let channel = this.env.models['mail.thread'].findFromIdentifyingData(convertedData); const wasCurrentPartnerMember = ( @@ -686,12 +693,18 @@ function factory(dependencies) { * @param {integer} param0.partner_id * @param {string} param0.title */ - _handleNotificationPartnerUserConnection({ message, partner_id, 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 partner = this.env.models['mail.partner'].insert({ - id: partner_id, - }); - partner.openChat(); + const chat = await this.async(() => + this.env.messaging.getChat({ partnerId: partner_id } + )); + if (!chat) { + return; + } + this.env.messaging.chatWindowManager.openThread(chat); } /** diff --git a/addons/mail/static/src/models/notification_group/notification_group.js b/addons/mail/static/src/models/notification_group/notification_group.js index 415350332cc..89111a4ed74 100644 --- a/addons/mail/static/src/models/notification_group/notification_group.js +++ b/addons/mail/static/src/models/notification_group/notification_group.js @@ -2,7 +2,7 @@ odoo.define('mail/static/src/models/notification_group/notification_group.js', f 'use strict'; const { registerNewModel } = require('mail/static/src/model/model_core.js'); -const { attr, one2many } = require('mail/static/src/model/model_field.js'); +const { attr, many2one, one2many } = require('mail/static/src/model/model_field.js'); function factory(dependencies) { @@ -16,21 +16,18 @@ function factory(dependencies) { * Opens the view that allows to cancel all notifications of the group. */ openCancelAction() { - const device = this.env.messaging.device; - if (this.notification_type === 'email') { - 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, - }, - }, - }); - } - if (!device.isMobile) { - this.env.messaging.messagingMenu.close(); + 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, + }, + }, + }); } /** @@ -38,19 +35,8 @@ function factory(dependencies) { * all the records in the group. */ openDocuments() { - if (this.res_id) { - if (this.res_model === 'mail.channel') { - const channel = this.env.models['mail.thread'].insert({ - id: this.res_id, - model: this.res_model, - }); - channel.open(); - } else { - this.env.messaging.openDocument({ - id: this.res_id, - model: this.res_model, - }); - } + if (this.thread) { + this.thread.open(); } else { this._openDocuments(); } @@ -61,6 +47,20 @@ function factory(dependencies) { //---------------------------------------------------------------------- /** + * @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) { @@ -73,21 +73,23 @@ function factory(dependencies) { * @private */ _openDocuments() { - const device = this.env.messaging.device; - if (this.notification_type === 'email') { - 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.notification_type !== 'email') { + return; } - if (!device.isMobile) { + 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(); } } @@ -102,6 +104,16 @@ function factory(dependencies) { 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'; diff --git a/addons/mail/static/src/models/partner/partner.js b/addons/mail/static/src/models/partner/partner.js index 91b3d0f482b..0b08451efcd 100644 --- a/addons/mail/static/src/models/partner/partner.js +++ b/addons/mail/static/src/models/partner/partner.js @@ -125,31 +125,73 @@ function factory(dependencies) { 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 }, + }, })); - if (userIds.length) { + this.update({ hasCheckedUser: true }); + if (userIds.length > 0) { this.update({ user: [['insert', { id: userIds[0] }]] }); } } /** - * Opens an existing or new chat. + * 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} */ - openChat() { - const chat = this.correspondentThreads.find(thread => thread.channel_type === 'chat'); - if (chat) { - chat.open(); - } else { - this.env.models['mail.thread'].createChannel({ - autoselect: true, - partnerId: this.id, - type: 'chat', + 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(); + } + + /** + * 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', + }); } //---------------------------------------------------------------------- @@ -235,6 +277,13 @@ function factory(dependencies) { 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', { diff --git a/addons/mail/static/src/models/thread/thread.js b/addons/mail/static/src/models/thread/thread.js index d34d295223a..efd33f9a0ce 100644 --- a/addons/mail/static/src/models/thread/thread.js +++ b/addons/mail/static/src/models/thread/thread.js @@ -133,6 +133,9 @@ function factory(dependencies) { */ static convertData(data) { const data2 = {}; + if ('model' in data) { + data2.model = data.model; + } if ('channel_type' in data) { data2.channel_type = data.channel_type; data2.model = 'mail.channel'; @@ -249,103 +252,140 @@ function factory(dependencies) { } /** - * Create a channel, which is a special kind of thread on model - * 'mail.channel' with multiple members. + * 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; + }, []); + 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_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], + }); + 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_create` RPC on `mail.channel`. * * @static * @param {Object} param0 - * @param {boolean} [param0.autoselect=false] if set, when channel - * has been created, it auto-open the channel. This opens in discuss - * or chat window, depending on whether discuss is open or not. - * @param {string} [param0.autoselectChatWindowMode] * @param {string} param0.name - * @param {integer} [param0.partnerId] - * @param {string} [param0.public] - * @param {string} param0.type - */ - static async createChannel({ - autoselect = false, - autoselectChatWindowMode, - name, - partnerId, - public: publicStatus, - type, - }) { + * @param {string} [param0.privacy] + * @returns {mail.thread} the created channel + */ + static async performRpcCreateChannel({ name, privacy }) { const device = this.env.messaging.device; - // TODO FIX: potential duplicate chat task-2276490 const data = await this.env.services.rpc({ model: 'mail.channel', - method: type === 'chat' ? 'channel_get' : 'channel_create', - args: type === 'chat' ? [[partnerId]] : [name, publicStatus], + 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, }), - } + }, }); - const thread = this.insert(this.convertData(data)); - if (autoselect) { - thread.open({ chatWindowMode: autoselectChatWindowMode }); - } + return this.env.models['mail.thread'].insert( + this.env.models['mail.thread'].convertData(data) + ); } /** - * Join a channel. This channel may not yet exists in the store. + * 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 {integer} channelId - * @param {Object} [param1={}] - * @param {boolean} [param1.autoselect=false] - */ - static async joinChannel(channelId, { autoselect = false } = {}) { - let channel = this.find(thread => - thread.id === channelId && - thread.model === 'mail.channel' - ); - if (channel && channel.isPinned) { - return; - } + * @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_join_and_get_info', - args: [[channelId]] + 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, + }, }); - // We just joined the channel because of the previous rpc - // the main assumption here is that we didn't have the channel - // in memory. If we did though, clear the pending state and - // let the server's data be the master - const convertedData = Object.assign( - { isPendingPinned: undefined }, - this.convertData(data), - ); - channel = this.insert(convertedData); - if (autoselect) { - channel.open({ resetDiscussDomain: true }); + if (!data) { + return; } + return this.env.models['mail.thread'].insert( + this.env.models['mail.thread'].convertData(data) + ); } /** - * Load the previews of the specified threads. Basically, it fetches the - * last messages, since they are used to display inline content of them. + * Performs the `channel_join_and_get_info` RPC on `mail.channel`. * * @static - * @param {mail.thread[]} threads + * @param {Object} param0 + * @param {integer} param0.channelId + * @returns {mail.thread} the channel that was joined */ - static async loadPreviews(threads) { - const channelIds = threads.reduce((list, thread) => { - if (thread.model === 'mail.channel') { - return list.concat(thread.id); - } - return list; - }, []); - const channelPreviews = await this.env.services.rpc({ + static async performRpcJoinChannel({ channelId }) { + const device = this.env.messaging.device; + const data = 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) - )); + 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) + ); } /** @@ -453,7 +493,7 @@ function factory(dependencies) { * * Only makes sense if pendingFoldState is set to the desired value. */ - async notifyFoldStateToServer() { + notifyFoldStateToServer() { // method is called from _updateAfter so it cannot be async this.env.services.rpc({ model: 'mail.channel', @@ -469,75 +509,67 @@ function factory(dependencies) { * Notify server to leave the current channel. Useful for cross-tab * and cross-device chat window state synchronization. * - * Only makes sense if pendingServerState is set to 'unpin'. + * Only makes sense if isPendingPinned is set to the desired value. */ - notifyUnPinToServer() { + notifyPinStateToServer() { // method is called from _updateAfter so it cannot be async - this.env.services.rpc({ - model: 'mail.channel', - method: 'execute_command', - args: [[this.id], 'leave'] - }); + if (this.isPendingPinned) { + this.env.services.rpc({ + model: 'mail.channel', + method: 'channel_pin', + kwargs: { + uuid: this.uuid, + pinned: true, + }, + }, { shadow: true }); + } else { + this.env.services.rpc({ + model: 'mail.channel', + method: 'execute_command', + args: [[this.id], 'leave'] + }, { shadow: true }); + } } /** - * Open provided thread, either in discuss app or as a chat window. + * 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 {string} [param0.chatWindowMode='last_visible'] - * @param {boolean} [param0.resetDiscussDomain=false] + * @param {Object} [param0] + * @param {boolean} [param0.expanded=false] */ - open({ chatWindowMode = 'last_visible', resetDiscussDomain = false } = {}) { - const device = this.env.messaging.device; - const discuss = this.env.messaging.discuss; - const messagingMenu = this.env.messaging.messagingMenu; + async open({ expanded = false } = {}) { + // check if thread must be opened in form view if (!['mail.box', 'mail.channel'].includes(this.model)) { - this.env.messaging.openDocument({ + 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; + const discuss = this.env.messaging.discuss; if ( - (!device.isMobile && discuss.isOpen) || - (device.isMobile && this.model === 'mail.box') + (!device.isMobile && (discuss.isOpen || expanded)) || + this.model === 'mail.box' ) { - if (resetDiscussDomain) { - discuss.threadView.update({ stringifiedDomain: '[]' }); - } - discuss.threadView.update({ thread: [['link', this]] }); - } else { - this.env.messaging.chatWindowManager.openThread(this, { mode: chatWindowMode }); - } - if (!device.isMobile) { - messagingMenu.close(); + return discuss.openThread(this); } + // thread must be opened in chat window + return this.env.messaging.chatWindowManager.openThread(this, { + makeActive: true, + }); } /** - * Open this thread in an expanded way, that is not in a chat window. + * Opens the most appropriate view that is a profile for this thread. */ - openExpanded() { - const discuss = this.env.messaging.discuss; - if (['mail.channel', 'mail.box'].includes(this.model)) { - discuss.threadView.update({ thread: [['link', this]] }); - this.env.bus.trigger('do-action', { - action: 'mail.action_discuss', - options: { - clear_breadcrumbs: false, - active_id: discuss.threadToActiveId(this), - on_reverse_breadcrumb: () => discuss.close(), - }, - }); - } else { - this.env.bus.trigger('do-action', { - action: { - type: 'ir.actions.act_window', - res_model: this.model, - views: [[false, 'form']], - res_id: this.id, - }, - }); - } + async openProfile() { + return this.env.messaging.openDocument({ + id: this.id, + model: this.model, + }); } /** @@ -760,15 +792,6 @@ function factory(dependencies) { /** * @private - * @returns {mail.chat_window[]} - */ - _computeChatWindows() { - const chatWindowThreadViews = this.threadViews.filter(threadView => !!threadView.chatWindow); - return [['replace', chatWindowThreadViews.map(threadView => threadView.chatWindow)]]; - } - - /** - * @private * @returns {mail.partner} */ _computeCorrespondent() { @@ -1088,10 +1111,10 @@ function factory(dependencies) { this.update({ pendingFoldState: undefined }); } if ( - this.isPendingPinned === false && + this.isPendingPinned !== undefined && previous.isPendingPinned !== this.isPendingPinned ) { - this.notifyUnPinToServer(); + this.notifyPinStateToServer(); } if ( this.isServerPinned === this.isPendingPinned @@ -1110,16 +1133,14 @@ function factory(dependencies) { // the process return; } - if (this.foldState !== 'closed' && this.chatWindows.length === 0) { - // condition to avoid crash during destroy - if (this.env.messaging.chatWindowManager) { - this.env.messaging.chatWindowManager.openThread(this); - } + if (!this.env.messaging.chatWindowManager) { + // avoid crash during destroy + return; } - if (this.foldState === 'closed' && this.chatWindows.length > 0) { - for (const chatWindow of this.chatWindows) { - chatWindow.close(); - } + if (this.foldState !== 'closed') { + this.env.messaging.chatWindowManager.openThread(this); + } else { + this.env.messaging.chatWindowManager.closeThread(this); } } @@ -1192,18 +1213,8 @@ function factory(dependencies) { isCausal: true, }), channel_type: attr(), - chatWindows: one2many('mail.chat_window', { - compute: '_computeChatWindows', - dependencies: ['threadViewsChatWindow'], - }), composer: one2one('mail.composer', { - /** - * FIXME With this, whenever client is aware of new thread, this will - * (almost) always focus its composer when displayed. This shouldn't be - * the case, instead auto-focus of composer is flow-specific. - * See task-2277537 - */ - default: [['create', { isDoFocus: true }]], + default: [['create']], inverse: 'thread', isCausal: true, }), @@ -1475,9 +1486,6 @@ function factory(dependencies) { threadViews: one2many('mail.thread_view', { inverse: 'thread', }), - threadViewsChatWindow: many2many('mail.chat_window', { - related: 'threadViews.chatWindow', - }), }; Thread.modelName = 'mail.thread'; diff --git a/addons/mail/static/src/models/user/user.js b/addons/mail/static/src/models/user/user.js index b9bc72bf987..18b2c28e1ac 100644 --- a/addons/mail/static/src/models/user/user.js +++ b/addons/mail/static/src/models/user/user.js @@ -25,6 +25,66 @@ function factory(dependencies) { //---------------------------------------------------------------------- /** + * @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, + }, + }); + 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 }, + }); + } + + /** * @returns {string} */ nameOrDisplayName() { @@ -35,6 +95,94 @@ function factory(dependencies) { return partner.nameOrDisplayName; } + /** + * 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 = 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 //---------------------------------------------------------------------- diff --git a/addons/mail/static/src/widgets/notification_alert/notification_alert_tests.js b/addons/mail/static/src/widgets/notification_alert/notification_alert_tests.js index 9338e1e9673..20297c855f1 100644 --- a/addons/mail/static/src/widgets/notification_alert/notification_alert_tests.js +++ b/addons/mail/static/src/widgets/notification_alert/notification_alert_tests.js @@ -36,7 +36,7 @@ QUnit.module('notification_alert_tests.js', { QUnit.skip('notification_alert widget: display blocked notification alert', async function (assert) { // FIXME: Test should work, but for some reasons OWL always flags the // component as not mounted, even though it is in the DOM and it's state - // is good for rendering... + // is good for rendering... task-227947 assert.expect(1); await this.start({ diff --git a/addons/mail/static/tests/many2one_avatar_user_tests.js b/addons/mail/static/tests/many2one_avatar_user_tests.js index a94ada54eb8..7010cc9a7e5 100644 --- a/addons/mail/static/tests/many2one_avatar_user_tests.js +++ b/addons/mail/static/tests/many2one_avatar_user_tests.js @@ -3,7 +3,6 @@ odoo.define('mail.Many2OneAvatarUserTests', function (require) { const { afterEach, beforeEach, start } = require('mail/static/src/utils/test_utils.js'); -const FormView = require('web.FormView'); const KanbanView = require('web.KanbanView'); const ListView = require('web.ListView'); const { Many2OneAvatarUser } = require('mail.Many2OneAvatarUser'); @@ -91,45 +90,6 @@ QUnit.module('mail', {}, function () { list.destroy(); }); - QUnit.test('many2one_avatar_user widget: click on self', async function (assert) { - assert.expect(4); - - const { widget: form } = await start({ - hasView: true, - View: FormView, - model: 'foo', - data: this.data, - arch: '<form><field name="user_id" widget="many2one_avatar_user"/></form>', - mockRPC(route, args) { - if (args.method === 'read') { - assert.step(`read ${args.model} ${args.args[0]}`); - } - return this._super(...arguments); - }, - session: { - uid: 23, - }, - res_id: 4, - }); - - mock.intercept(form, 'call_service', (ev) => { - if (ev.data.service === 'notification') { - assert.step(`display notification "${ev.data.args[0].message}"`); - } - }, true); - - assert.strictEqual(form.$('.o_field_widget[name=user_id]').text().trim(), 'Yoshi'); - - await dom.click(form.$('.o_m2o_avatar')); - - assert.verifySteps([ - 'read foo 4', - 'display notification "You cannot chat with yourself"', - ]); - - form.destroy(); - }); - QUnit.test('many2one_avatar_user widget in kanban view', async function (assert) { assert.expect(6); diff --git a/addons/mail_bot/static/src/models/messaging_initializer/messaging_initializer.js b/addons/mail_bot/static/src/models/messaging_initializer/messaging_initializer.js index 4118b5228ef..9b06fbbaa1c 100644 --- a/addons/mail_bot/static/src/models/messaging_initializer/messaging_initializer.js +++ b/addons/mail_bot/static/src/models/messaging_initializer/messaging_initializer.js @@ -12,9 +12,6 @@ registerInstancePatchModel('mail.messaging_initializer', 'mail_bot/static/src/mo * @private */ async _initializeOdooBot() { - await this.async(() => new Promise(resolve => - this.env.browser.setTimeout(resolve, 2 * 60 * 1000) - )); const data = await this.async(() => this.env.services.rpc({ model: 'mail.channel', method: 'init_odoobot', diff --git a/addons/mail_bot/static/src/models/messaging_initializer/messaging_initializer_tests.js b/addons/mail_bot/static/src/models/messaging_initializer/messaging_initializer_tests.js index 4121cc5a277..bfb97067e90 100644 --- a/addons/mail_bot/static/src/models/messaging_initializer/messaging_initializer_tests.js +++ b/addons/mail_bot/static/src/models/messaging_initializer/messaging_initializer_tests.js @@ -24,10 +24,10 @@ QUnit.module('messaging_initializer_tests.js', { }); -QUnit.test('OdooBot initialized after 2 minutes', async function (assert) { +QUnit.test('OdooBot initialized at init', async function (assert) { // TODO this test should be completed in combination with // implementing _mockMailChannelInitOdooBot task-2300480 - assert.expect(3); + assert.expect(2); await this.start({ env: { @@ -35,7 +35,6 @@ QUnit.test('OdooBot initialized after 2 minutes', async function (assert) { odoobot_initialized: false, }, }, - hasTimeControl: true, async mockRPC(route, args) { if (args.method === 'init_odoobot') { assert.step('init_odoobot'); @@ -44,16 +43,9 @@ QUnit.test('OdooBot initialized after 2 minutes', async function (assert) { }, }); - await this.env.testUtils.advanceTime(119 * 1000); - assert.verifySteps( - [], - "should not have initialized OdooBot after 1 minute 59 seconds" - ); - - await this.env.testUtils.advanceTime(1 * 1000); assert.verifySteps( ['init_odoobot'], - "should have initialized OdooBot after 2 minutes" + "should have initialized OdooBot at init" ); }); diff --git a/addons/sms/static/src/models/notification_group/notification_group.js b/addons/sms/static/src/models/notification_group/notification_group.js index 151d794b86c..9e7c38d2325 100644 --- a/addons/sms/static/src/models/notification_group/notification_group.js +++ b/addons/sms/static/src/models/notification_group/notification_group.js @@ -15,18 +15,18 @@ registerInstancePatchModel('mail.notification_group', 'sms/static/src/models/not * @override */ openCancelAction() { - if (this.notification_type === 'sms') { - this.env.bus.trigger('do-action', { - action: 'sms.sms_cancel_action', - options: { - additional_context: { - default_model: this.res_model, - unread_counter: this.notifications.length, - }, - }, - }); + if (this.notification_type !== 'sms') { + return this._super(...arguments); } - return this._super(...arguments); + this.env.bus.trigger('do-action', { + action: 'sms.sms_cancel_action', + options: { + additional_context: { + default_model: this.res_model, + unread_counter: this.notifications.length, + }, + }, + }); }, //-------------------------------------------------------------------------- @@ -37,20 +37,25 @@ registerInstancePatchModel('mail.notification_group', 'sms/static/src/models/not * @override */ _openDocuments() { - if (this.notification_type === 'sms') { - this.env.bus.trigger('do-action', { - action: { - name: this.env._t("SMS 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_sms_error', '=', true]], - }, - }); + if (this.notification_type !== 'sms') { + return this._super(...arguments); + } + this.env.bus.trigger('do-action', { + action: { + name: this.env._t("SMS 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_sms_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.messagingMenu.close(); } - return this._super(...arguments); }, }); diff --git a/addons/snailmail/static/src/models/notification_group/notification_group.js b/addons/snailmail/static/src/models/notification_group/notification_group.js index c63ebe4c066..3fff33cbb01 100644 --- a/addons/snailmail/static/src/models/notification_group/notification_group.js +++ b/addons/snailmail/static/src/models/notification_group/notification_group.js @@ -15,18 +15,18 @@ registerInstancePatchModel('mail.notification_group', 'snailmail/static/src/mode * @override */ openCancelAction() { - if (this.notification_type === 'snail') { - this.env.bus.trigger('do-action', { - action: 'snailmail.snailmail_letter_cancel_action', - options: { - additional_context: { - default_model: this.res_model, - unread_counter: this.notifications.length, - }, - }, - }); + if (this.notification_type !== 'snail') { + return this._super(...arguments); } - return this._super(...arguments); + this.env.bus.trigger('do-action', { + action: 'snailmail.snailmail_letter_cancel_action', + options: { + additional_context: { + default_model: this.res_model, + unread_counter: this.notifications.length, + }, + }, + }); }, //-------------------------------------------------------------------------- @@ -37,20 +37,25 @@ registerInstancePatchModel('mail.notification_group', 'snailmail/static/src/mode * @override */ _openDocuments() { - if (this.notification_type === 'snail') { - this.env.bus.trigger('do-action', { - action: { - name: this.env._t("Snailmail 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_ids.snailmail_error', '=', true]], - }, - }); + if (this.notification_type !== 'snail') { + return this._super(...arguments); + } + this.env.bus.trigger('do-action', { + action: { + name: this.env._t("Snailmail 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_ids.snailmail_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(); } - return this._super(...arguments); }, }); |
