[IMP] mail: add group chat feature

task-2360934

closes odoo/odoo#75633

Signed-off-by: Alexandre Kühn (aku) <aku@odoo.com>
This commit is contained in:
Julien Giannone
2021-08-26 17:25:06 +00:00
committed by Sébastien Theys
parent 806a8db35b
commit 845bb6cc93
15 changed files with 520 additions and 154 deletions
@@ -5,11 +5,11 @@
<div class="o_ChannelInvitationForm d-flex flex-column" t-ref="channelInvitationForm">
<h3 class="mx-3 mt-3 mb-2">Invite people</h3>
<div class="mx-3 my-2">
<input type="text" t-att-value="channelInvitationForm.searchTerm" placeholder="Type the name of a person" t-on-input="channelInvitationForm.onInputSearch" t-ref="searchInput"/>
<input class="o_ChannelInvitationForm_searchInput" type="text" t-att-value="channelInvitationForm.searchTerm" placeholder="Type the name of a person" t-on-input="channelInvitationForm.onInputSearch" t-ref="searchInput"/>
</div>
<div class="d-flex flex-column flex-grow-1 mx-0 py-2 overflow-auto">
<t t-foreach="channelInvitationForm.selectablePartners" t-as="selectablePartner" t-key="selectablePartner.localId">
<div class="o_ChannelInvitationForm_selectablePartner d-flex align-items-center px-3 py-1" t-on-click="channelInvitationForm.onClickSelectablePartner.bind(channelInvitationForm, selectablePartner)">
<div class="o_ChannelInvitationForm_selectablePartner d-flex align-items-center px-3 py-1" t-on-click="channelInvitationForm.onClickSelectablePartner.bind(channelInvitationForm, selectablePartner)" t-att-data-partner-id="selectablePartner.id">
<div class="o_ChannelInvitationForm_selectablePartnerAvatarContainer flex-shrink-0 position-relative">
<img class="o_ChannelInvitationForm_selectablePartnerAvatar w-100 h-100 rounded-circle" t-att-src="selectablePartner.avatarUrl" alt="Avatar"/>
<t t-if="selectablePartner.im_status and selectablePartner.im_status !== 'im_partner'">
@@ -27,7 +27,7 @@
<t t-esc="selectablePartner.nameOrDisplayName"/>
</span>
<div class="o_ChannelInvitationForm_selectablePartnerNameSelectionSeparator flex-grow-1 flex-shrink-0"/>
<input class="flex-shrink-0" type="checkbox" t-att-checked="channelInvitationForm.selectedPartners.includes(selectablePartner) ? 'checked' : undefined" t-on-input="channelInvitationForm.onInputPartnerCheckbox.bind(channelInvitationForm, selectablePartner)" t-ref="selection-status"/>
<input class="o_ChannelInvitationForm_selectablePartnerCheckbox flex-shrink-0" type="checkbox" t-att-checked="channelInvitationForm.selectedPartners.includes(selectablePartner) ? 'checked' : undefined" t-on-input="channelInvitationForm.onInputPartnerCheckbox.bind(channelInvitationForm, selectablePartner)" t-ref="selection-status"/>
</div>
</t>
<t t-if="channelInvitationForm.selectablePartners.length === 0">
@@ -52,7 +52,7 @@
</div>
</t>
<div class="mx-3 mt-2 mb-3">
<button class="btn btn-primary w-100" t-att-disabled="channelInvitationForm.selectedPartners.length === 0" t-on-click="channelInvitationForm.onClickInvite">
<button class="o_ChannelInvitationForm_inviteButton btn btn-primary w-100" t-att-disabled="channelInvitationForm.selectedPartners.length === 0" t-on-click="channelInvitationForm.onClickInvite">
<t t-esc="channelInvitationForm.inviteButtonText"/>
</button>
</div>
@@ -0,0 +1,152 @@
/** @odoo-module **/
import {
afterEach,
afterNextRender,
beforeEach,
start,
} from '@mail/utils/test_utils';
QUnit.module('mail', {}, function () {
QUnit.module('components', {}, function () {
QUnit.module('channel_invitation_form', {}, function () {
QUnit.module('channel_invitation_form_tests.js', {
beforeEach() {
beforeEach(this);
this.start = async params => {
const { env, widget } = await start(Object.assign({}, params, {
autoOpenDiscuss: true,
data: this.data,
hasDiscuss: true,
}));
this.env = env;
this.widget = widget;
};
},
afterEach() {
afterEach(this);
},
});
QUnit.test('should display the channel invitation form after clicking on the invite button of a chat', async function (assert) {
assert.expect(1);
this.data['res.partner'].records.push({
id: 11,
email: "testpartner@odoo.com",
name: "TestPartner",
});
this.data['res.users'].records.push({
partner_id: 11,
});
this.data['mail.channel'].records.push({
channel_type: 'chat',
id: 13,
members: [this.data.currentPartnerId, 11],
public: 'private',
});
await this.start({
discuss: {
context: {
active_id: 13,
},
},
});
await afterNextRender(() => document.querySelector(`.o_ThreadViewTopbar_inviteButton`).click());
assert.containsOnce(
document.body,
'.o_ChannelInvitationForm',
"should display the channel invitation form after clicking on the invite button of a chat"
);
});
QUnit.test('should be able to search for a new user to invite from an existing chat', async function (assert) {
assert.expect(1);
this.data['res.partner'].records.push({
id: 11,
email: "testpartner@odoo.com",
name: "TestPartner",
});
this.data['res.partner'].records.push({
id: 12,
email: "testpartner2@odoo.com",
name: "TestPartner2",
});
this.data['res.users'].records.push({
partner_id: 11,
});
this.data['res.users'].records.push({
partner_id: 12,
});
this.data['mail.channel'].records.push({
channel_type: 'chat',
id: 13,
members: [this.data.currentPartnerId, 11],
public: 'private',
});
await this.start({
discuss: {
context: {
active_id: 13,
},
},
});
await afterNextRender(() => document.querySelector(`.o_ThreadViewTopbar_inviteButton`).click());
await afterNextRender(() => document.execCommand('insertText', false, "TestPartner2"));
assert.strictEqual(
document.querySelector(`.o_ChannelInvitationForm_selectablePartnerName`).textContent,
"TestPartner2",
"should display 'TestPartner2' as it matches search term",
);
});
QUnit.test('should be able to create a new group chat from an existing chat', async function (assert) {
assert.expect(1);
this.data['res.partner'].records.push({
id: 11,
email: "testpartner@odoo.com",
name: "TestPartner",
});
this.data['res.partner'].records.push({
id: 12,
email: "testpartner2@odoo.com",
name: "TestPartner2",
});
this.data['res.users'].records.push({
partner_id: 11,
});
this.data['res.users'].records.push({
partner_id: 12,
});
this.data['mail.channel'].records.push({
channel_type: 'chat',
id: 13,
members: [this.data.currentPartnerId, 11],
public: 'private',
});
await this.start({
discuss: {
context: {
active_id: 13,
},
},
});
await afterNextRender(() => document.querySelector(`.o_ThreadViewTopbar_inviteButton`).click());
await afterNextRender(() => document.execCommand('insertText', false, "TestPartner2"));
document.querySelector(`.o_ChannelInvitationForm_selectablePartnerCheckbox`).click();
await afterNextRender(() => document.querySelector(`.o_ChannelInvitationForm_inviteButton`).click());
await afterNextRender(() => document.querySelector(`.o_ChannelInvitationForm_inviteButton`).click());
assert.strictEqual(
document.querySelector(`.o_ThreadViewTopbar_threadName`).textContent,
'Mitchell Admin, TestPartner, TestPartner2',
"should have created a new group chat with the existing chat members and the selected user",
);
});
});
});
});
@@ -77,8 +77,8 @@ QUnit.test('sidebar: pinned channel 2: open pinned channel', async function (ass
);
});
QUnit.test('sidebar: pinned channel 3: open pinned channel and unpin it', async function (assert) {
assert.expect(7);
QUnit.test('sidebar: pinned channel 3: open channel and leave it', async function (assert) {
assert.expect(6);
// channel that is expected to be found in the sidebar
// with a random unique id that will be referenced in the test
@@ -95,9 +95,6 @@ QUnit.test('sidebar: pinned channel 3: open pinned channel and unpin it', async
"The right id is sent to the server to remove"
);
}
if (args.method === 'channel_fold') {
assert.step('channel_fold');
}
return this._super(...arguments);
},
});
@@ -111,16 +108,15 @@ QUnit.test('sidebar: pinned channel 3: open pinned channel and unpin it', async
threadGeneral.localId
}"]`).click()
);
assert.verifySteps([], "neither channel_fold nor action_unfollow are called yet");
assert.verifySteps([], "action_unfollow is not called yet");
await afterNextRender(() =>
document.querySelector('.o_DiscussSidebarCategoryItem_commandLeave').click()
);
assert.verifySteps(
[
'channel_fold',
'action_unfollow'
],
"both channel_fold and action_unfollow have been called when unpinning a channel"
"action_unfollow has been called when leaving a channel"
);
assert.containsNone(
document.body,
@@ -37,6 +37,9 @@
<div class="o_ThreadIcon_noImStatus fa fa-question-circle" title="No IM status available"/>
</t>
</t>
<t t-elif="thread.channel_type === 'group'">
<div class="o_ThreadIcon fa fa-users" title="Grouped Chat"/>
</t>
<t t-elif="thread.model === 'mail.box'">
<t t-if="thread === messaging.inbox">
<div class="o_ThreadIcon_mailboxInbox fa fa-inbox"/>
@@ -55,6 +55,7 @@
}
.o_ThreadViewTopbar_threadName {
max-width: map-get($sizes, 75);
&:not(.o-isMouseOverThreadName) {
border-color: transparent; // presence of border even if invisible to prevent flicker
@@ -10,7 +10,7 @@
<div class="o_ThreadViewTopbar_title d-flex flex-grow-1 align-self-center align-items-center">
<t t-if="threadViewTopBar.thread">
<t t-if="!threadViewTopBar.isEditingThreadName">
<div class="o_ThreadViewTopbar_threadName flex-shrink-0 mw-75 px-1 text-truncate lead font-weight-bold" t-att-title="threadViewTopBar.thread.displayName" t-on-click="threadViewTopBar.onClickTopbarThreadName" t-on-mouseenter="threadViewTopBar.onMouseEnterTopBarThreadName" t-on-mouseleave="threadViewTopBar.onMouseLeaveTopBarThreadName" t-att-class="{ 'o-isMouseOverThreadName': threadViewTopBar.isMouseOverThreadName }">
<div class="o_ThreadViewTopbar_threadName flex-shrink-0 px-1 text-truncate lead font-weight-bold" t-att-title="threadViewTopBar.thread.displayName" t-on-click="threadViewTopBar.onClickTopbarThreadName" t-on-mouseenter="threadViewTopBar.onMouseEnterTopBarThreadName" t-on-mouseleave="threadViewTopBar.onMouseLeaveTopBarThreadName" t-att-class="{ 'o-isMouseOverThreadName': threadViewTopBar.isMouseOverThreadName }">
<t t-esc="threadViewTopBar.thread.displayName"/>
</div>
</t>
@@ -19,7 +19,7 @@
</t>
</t>
<t t-if="!threadViewTopBar.thread">
<div class="o_ThreadViewTopbar_threadName flex-shrink-0 mw-75 px-1 text-truncate lead font-weight-bold">
<div class="o_ThreadViewTopbar_threadName flex-shrink-0 px-1 text-truncate lead font-weight-bold">
Discuss
</div>
</t>
@@ -34,14 +34,32 @@ function factory(dependencies) {
* @param {MouseEvent} ev
*/
async onClickInvite(ev) {
await this.env.services.rpc(({
model: 'mail.channel',
method: 'add_members',
args: [this.thread.id],
kwargs: {
partner_ids: this.selectedPartners.map(partner => partner.id),
},
}));
if (this.thread.channel_type === 'chat') {
const channelData = await this.env.services.rpc(({
model: 'mail.channel',
method: 'create_group',
kwargs: {
partners_to: [...new Set([
this.messaging.currentPartner.id,
...this.thread.members.map(member => member.id),
...this.selectedPartners.map(partner => partner.id),
])],
},
}));
const channel = this.messaging.models['mail.thread'].insert(
this.messaging.models['mail.thread'].convertData(channelData)
);
channel.open();
} else {
await this.env.services.rpc(({
model: 'mail.channel',
method: 'add_members',
args: [[this.thread.id]],
kwargs: {
partner_ids: this.selectedPartners.map(partner => partner.id),
},
}));
}
this.update({
searchTerm: "",
selectedPartners: unlinkAll(),
@@ -148,9 +166,15 @@ function factory(dependencies) {
* @returns {string}
*/
_computeInviteButtonText() {
if (!this.thread || this.thread.channel_type !== 'channel') {
if (!this.thread) {
return clear();
}
switch (this.thread.channel_type) {
case 'chat':
return this.env._t("Create group chat");
case 'group':
return this.env._t("Invite to group chat");
}
return this.env._t("Invite to Channel");
}
@@ -38,6 +38,7 @@ function factory(dependencies) {
_computeAvatarUrl() {
switch (this.channelType) {
case 'channel':
case 'group':
return `/web/image/mail.channel/${this.channelId}/image_128`;
case 'chat':
return this.channel.correspondent.avatarUrl;
@@ -64,6 +65,7 @@ function factory(dependencies) {
case 'channel':
return this.channel.message_needaction_counter;
case 'chat':
case 'group':
return this.channel.localMessageUnreadCounter;
}
}
@@ -73,9 +75,11 @@ function factory(dependencies) {
* @returns {boolean}
*/
_computeHasLeaveCommand() {
return this.channelType === 'channel' &&
return (
['channel', 'group'].includes(this.channelType) &&
!this.channel.message_needaction_counter &&
!this.channel.group_based_subscription;
!this.channel.group_based_subscription
);
}
/**
@@ -121,6 +125,7 @@ function factory(dependencies) {
switch (this.channelType) {
case 'channel':
case 'chat':
case 'group':
return true;
}
}
@@ -144,10 +149,13 @@ function factory(dependencies) {
*/
async onClickCommandLeave(ev) {
ev.stopPropagation();
if (this.channel.creator === this.messaging.currentUser) {
if (this.channel.channel_type !== 'group' && this.channel.creator === this.messaging.currentUser) {
await this._askAdminConfirmation();
}
this.channel.unsubscribe();
if (this.channel.channel_type === 'group') {
await this._askLeaveGroupConfirmation();
}
this.channel.leave();
}
/**
@@ -201,6 +209,33 @@ function factory(dependencies) {
);
});
}
/**
* @private
* @returns {Promise}
*/
_askLeaveGroupConfirmation() {
return new Promise(resolve => {
Dialog.confirm(this,
this.env._t("You are about to leave this group conversation and will no longer have access to it unless you are invited again. Are you sure you want to continue?"),
{
buttons: [
{
text: this.env._t("Leave"),
classes: 'btn-primary',
close: true,
click: resolve
},
{
text: this.env._t("Discard"),
close: true
}
]
}
);
});
}
}
DiscussSidebarCategoryItem.fields = {
@@ -241,7 +241,7 @@ function factory(dependencies) {
newItemPlaceholderText: this.env._t("Find or start a conversation..."),
serverStateKey: 'is_discuss_sidebar_category_chat_open',
sortComputeMethod: 'last_action',
supportedChannelTypes: ['chat'],
supportedChannelTypes: ['chat', 'group'],
}),
});
}
@@ -1210,6 +1210,9 @@ function factory(dependencies) {
if (this.channel_type === 'chat' && this.correspondent) {
return this.custom_channel_name || this.correspondent.nameOrDisplayName;
}
if (this.channel_type === 'group') {
return this.name || this.members.map(partner => partner.nameOrDisplayName).join(', ');
}
return this.name;
}
@@ -1226,10 +1229,7 @@ function factory(dependencies) {
* @returns {boolean}
*/
_computeHasInviteFeature() {
if (this.model !== 'mail.channel') {
return false;
}
return this.channel_type === 'channel';
return this.model === 'mail.channel';
}
/**
@@ -1248,7 +1248,7 @@ function factory(dependencies) {
* @returns {boolean}
*/
_computeIsChannelDescriptionChangeable() {
return this.model === 'mail.channel' && this.channel_type === 'channel';
return this.model === 'mail.channel' && ['channel', 'group'].includes(this.channel_type);
}
/**
@@ -1258,7 +1258,7 @@ function factory(dependencies) {
_computeIsChannelRenamable() {
return (
this.model === 'mail.channel' &&
['chat', 'channel'].includes(this.channel_type)
['chat', 'channel', 'group'].includes(this.channel_type)
);
}
@@ -1267,7 +1267,7 @@ function factory(dependencies) {
* @returns {boolean}
*/
_computeHasMemberListFeature() {
return this.model === 'mail.channel' && ['channel'].includes(this.channel_type);
return this.model === 'mail.channel' && ['channel', 'group'].includes(this.channel_type);
}
/**
@@ -294,6 +294,9 @@ function factory(dependencies) {
if (newName && this.thread.channel_type === 'channel' && newName !== this.thread.name) {
this.thread.rename(newName);
}
if (this.thread.channel_type === 'group' && newName !== this.thread.name) {
this.thread.rename(newName);
}
}
/**
@@ -305,7 +308,7 @@ function factory(dependencies) {
isEditingThreadDescription: false,
pendingThreadDescription: clear(),
});
if (this.thread.channel_type === 'channel' && newDescription !== this.thread.description) {
if (newDescription !== this.thread.description) {
this.thread.changeDescription(newDescription);
}
}