[FIX] mail, *: fix opens and redirects, adapt messaging menu close

task-2244279
task-2262862
task-2274993
task-2277537
task-2282238

closes odoo/odoo#56755

X-original-commit: 17611f4adc5e950b0a75a2a6615b1da3c956387f
Related: odoo/enterprise#12769
Signed-off-by: Alexandre Kühn (aku) <aku@odoo.com>
Signed-off-by: Sébastien Theys (seb) <seb@odoo.com>
This commit is contained in:
Sébastien Theys
2020-08-28 17:34:13 +00:00
parent 86221d1f71
commit 590a088750
64 changed files with 1714 additions and 940 deletions
+4 -1
View File
@@ -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',
],
}
+10
View File
@@ -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';
});
+6
View File
@@ -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.
*/
+11
View File
@@ -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>
@@ -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', {
});
});
});
});
+5 -38
View File
@@ -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
}),
});
@@ -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);
@@ -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);
});
@@ -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);
},
});
});
@@ -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,
}),
});
});
+18
View File
@@ -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',
}),
});
});
@@ -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;
}
}
);
});
@@ -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'));
// 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'
);
assert.verifySteps([
'read hr.employee 11',
// 'call service openDMChatWindow 1',
'read hr.employee 7',
// 'call service openDMChatWindow 2',
// 'call service openDMChatWindow 1',
]);
// 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();
});
+9
View File
@@ -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>
@@ -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,
}));
},
@@ -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();
}
@@ -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"
@@ -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();
}
@@ -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)
@@ -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
//--------------------------------------------------------------------------
@@ -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>
@@ -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');
}
}
@@ -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();
}
/**
@@ -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() {
@@ -139,6 +142,24 @@ class Discuss extends Component {
// Private
//--------------------------------------------------------------------------
/**
* @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
*/
@@ -248,16 +269,6 @@ class Discuss extends Component {
this.discuss.update({ activeMobileNavbarTabId: ev.detail.tabId });
}
/**
* @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
@@ -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>
@@ -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()
@@ -74,7 +74,7 @@ class DiscussMobileMailboxSelection extends Component {
*/
_onClick(ev) {
const { mailbox } = ev.currentTarget.dataset;
this.discuss.threadView.update({ thread: [['link', mailbox]] });
mailbox.open();
}
}
@@ -117,7 +117,7 @@ class DiscussSidebarItem extends Component {
* @param {MouseEvent} ev
*/
_onClick(ev) {
this.discuss.threadView.update({ thread: [['link', this.thread]] });
this.thread.open();
}
/**
@@ -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();
}
/**
@@ -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) {
@@ -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;
@@ -236,6 +236,13 @@ class Message extends Component {
return this.message.attachments.filter(attachment => attachment.fileType !== 'image');
}
/**
* @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,
@@ -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>
@@ -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,
@@ -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"
@@ -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, {
@@ -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
@@ -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();
}
}
}
@@ -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
{
@@ -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();
}
}
@@ -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();
}
}
/**
@@ -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();
}
}
/**
@@ -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: {
@@ -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"
@@ -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,
@@ -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 });
}
});
@@ -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);
}
@@ -159,6 +173,17 @@ function factory(dependencies) {
return this._isFolded;
}
/**
* @private
* @returns {boolean}
*/
_computeIsVisible() {
if (!this.manager) {
return false;
}
return this.manager.allOrderedVisible.includes(this);
}
/**
* @private
* @returns {string}
@@ -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',
}),
@@ -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();
}
}
@@ -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,
}),
@@ -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.
*/
@@ -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) {
if (ui.item.special) {
this.env.models['mail.thread'].createChannel({
autoselect: true,
name: this.addingChannelValue,
public: ui.item.special,
type: 'channel',
});
} else {
this.env.models['mail.thread'].joinChannel(ui.item.id, { autoselect: true });
}
async handleAddChannelAutocompleteSelect(ev, ui) {
const name = this.addingChannelValue;
this.clearIsAddingItem();
if (ui.item.special) {
const channel = await this.async(() =>
this.env.models['mail.thread'].performRpcCreateChannel({
name,
privacy: ui.item.special,
})
);
channel.open();
} else {
const channel = await this.async(() =>
this.env.models['mail.thread'].performRpcJoinChannel({
channelId: ui.item.id,
})
);
channel.open();
}
}
/**
@@ -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();
}
/**
@@ -381,6 +399,12 @@ function factory(dependencies) {
default: false,
dependencies: ['isOpen'],
}),
/**
* Determine whether this discuss should be focused at next render.
*/
isDoFocus: attr({
default: false,
}),
/**
* Whether the discuss app is open or not. Useful to determine
* whether the discuss or chat window logic should be applied.
@@ -63,6 +63,16 @@ function factory(dependencies) {
this._subtypesListDialog = undefined;
}
/**
* Opens the most appropriate view that is a profile for this follower.
*/
async openProfile() {
if (this.partner) {
return this.partner.openProfile();
}
return this.channel.openProfile();
}
/**
* Remove this follower from its related thread.
*/
@@ -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();
}
/**
* 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.
*
* @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;
}
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;
}
const chat = partner.correspondentThreads.find(thread =>
thread.channel_type === 'chat'
);
if (!chat) {
this.env.models['mail.thread'].createChannel({
autoselect: true,
partnerId: id,
type: 'chat',
});
return;
}
chat.open();
} else {
this.openDocument({
model,
id,
});
if (this.env.messaging.device.isMobile) {
// messaging menu has a higher z-index than views so it must
// be closed to ensure the visibility of the view
this.env.messaging.messagingMenu.close();
}
}
/**
* Start messaging and related records.
* Opens the most appropriate view that is a profile for provided id and
* model.
*
* @param {Object} param0
* @param {integer} param0.id
* @param {string} param0.model
*/
async 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 });
async openProfile({ id, model }) {
if (model === 'res.partner') {
const partner = this.env.models['mail.partner'].insert({ id });
return partner.openProfile();
}
if (model === 'res.users') {
const user = this.env.models['mail.user'].insert({ id });
return user.openProfile();
}
if (model === 'mail.channel') {
let channel = this.env.models['mail.thread'].findFromIdentifyingData({ id, model: 'mail.channel' });
if (!channel) {
channel = (await this.async(() =>
this.env.models['mail.thread'].performRpcChannelInfo({ ids: [id] })
))[0];
}
if (!channel) {
this.env.services['notification'].notify({
message: this.env._t("You can only open the profile of existing channels."),
type: 'warning',
});
return;
}
return channel.openProfile();
}
return this.env.messaging.openDocument({ id, model });
}
//----------------------------------------------------------------------
@@ -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`');
});
});
});
@@ -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);
}
}
channel.update({ message_unread_counter: channel.message_unread_counter + 1 });
// 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,
});
// 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);
}
/**
@@ -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 (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,
},
});
}
if (!device.isMobile) {
this.env.messaging.messagingMenu.close();
}
},
});
}
/**
@@ -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();
}
@@ -60,6 +46,20 @@ function factory(dependencies) {
// Private
//----------------------------------------------------------------------
/**
* @private
* @returns {mail.thread|undefined}
*/
_computeThread() {
if (this.res_id) {
return [['insert', {
id: this.res_id,
model: this.res_model,
}]];
}
return [['unlink']];
}
/**
* @override
*/
@@ -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';
@@ -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', {
+169 -161
View File
@@ -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';
@@ -248,82 +251,6 @@ function factory(dependencies) {
return data2;
}
/**
* Create a channel, which is a special kind of thread on model
* 'mail.channel' with multiple members.
*
* @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,
}) {
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],
kwargs: {
context: Object.assign({}, this.env.session.user_content, {
isMobile: device.isMobile,
}),
}
});
const thread = this.insert(this.convertData(data));
if (autoselect) {
thread.open({ chatWindowMode: autoselectChatWindowMode });
}
}
/**
* Join a channel. This channel may not yet exists in the store.
*
* @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;
}
const data = await this.env.services.rpc({
model: 'mail.channel',
method: 'channel_join_and_get_info',
args: [[channelId]]
});
// 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 });
}
}
/**
* Load the previews of the specified threads. Basically, it fetches the
* last messages, since they are used to display inline content of them.
@@ -348,6 +275,119 @@ function factory(dependencies) {
));
}
/**
* 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 {string} param0.name
* @param {string} [param0.privacy]
* @returns {mail.thread} the created channel
*/
static async performRpcCreateChannel({ name, privacy }) {
const device = this.env.messaging.device;
const data = await this.env.services.rpc({
model: 'mail.channel',
method: 'channel_create',
args: [name, privacy],
kwargs: {
context: Object.assign({}, this.env.session.user_content, {
// optimize the return value by avoiding useless queries
// in non-mobile devices
isMobile: device.isMobile,
}),
},
});
return this.env.models['mail.thread'].insert(
this.env.models['mail.thread'].convertData(data)
);
}
/**
* Performs the `channel_get` RPC on `mail.channel`.
*
* `openChat` is preferable in business code because it will avoid the
* RPC if the chat already exists.
*
* @static
* @param {Object} param0
* @param {integer[]} param0.partnerIds
* @param {boolean} [param0.pinForCurrentPartner]
* @returns {mail.thread|undefined} the created or existing chat
*/
static async performRpcCreateChat({ partnerIds, pinForCurrentPartner }) {
const device = this.env.messaging.device;
// TODO FIX: potential duplicate chat task-2276490
const data = await this.env.services.rpc({
model: 'mail.channel',
method: 'channel_get',
kwargs: {
context: Object.assign({}, this.env.session.user_content, {
// optimize the return value by avoiding useless queries
// in non-mobile devices
isMobile: device.isMobile,
}),
partners_to: partnerIds,
pin: pinForCurrentPartner,
},
});
if (!data) {
return;
}
return this.env.models['mail.thread'].insert(
this.env.models['mail.thread'].convertData(data)
);
}
/**
* Performs the `channel_join_and_get_info` RPC on `mail.channel`.
*
* @static
* @param {Object} param0
* @param {integer} param0.channelId
* @returns {mail.thread} the channel that was joined
*/
static async performRpcJoinChannel({ channelId }) {
const device = this.env.messaging.device;
const data = await this.env.services.rpc({
model: 'mail.channel',
method: 'channel_join_and_get_info',
args: [[channelId]],
kwargs: {
context: Object.assign({}, this.env.session.user_content, {
// optimize the return value by avoiding useless queries
// in non-mobile devices
isMobile: device.isMobile,
}),
},
});
return this.env.models['mail.thread'].insert(
this.env.models['mail.thread'].convertData(data)
);
}
/**
* @param {string} [stringifiedDomain='[]']
* @returns {mail.thread_cache}
@@ -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,
});
}
/**
@@ -758,15 +790,6 @@ function factory(dependencies) {
return [['replace', allAttachments]];
}
/**
* @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}
@@ -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';
+148
View File
@@ -24,6 +24,66 @@ function factory(dependencies) {
// Public
//----------------------------------------------------------------------
/**
* @static
* @param {Object} data
* @returns {Object}
*/
static convertData(data) {
const data2 = {};
if ('id' in data) {
data2.id = data.id;
}
if ('partner_id' in data) {
if (!data.partner_id) {
data2.partner = [['unlink']];
} else {
const partnerNameGet = data['partner_id'];
const partnerData = {
display_name: partnerNameGet[1],
id: partnerNameGet[0],
};
data2.partner = [['insert', partnerData]];
}
}
return data2;
}
/**
* Performs the `read` RPC on `res.users`.
*
* @static
* @param {Object} param0
* @param {Object} param0.context
* @param {string[]} param0.fields
* @param {integer[]} param0.ids
*/
static async performRpcRead({ context, fields, ids }) {
const usersData = await this.env.services.rpc({
model: 'res.users',
method: 'read',
args: [ids],
kwargs: {
context,
fields,
},
});
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}
*/
@@ -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
//----------------------------------------------------------------------
@@ -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({
@@ -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);
@@ -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',
@@ -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"
);
});
@@ -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);
},
});
@@ -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);
},
});