[IMP] mail: introduce Channel and ChannelMember model

task-2832456

closes odoo/odoo#91918

Signed-off-by: Sébastien Theys (seb) <seb@odoo.com>
This commit is contained in:
Sébastien Theys
2022-06-13 17:58:22 +02:00
committed by Alexandre Kühn
parent 4c190bce3a
commit d9c25be92f
16 changed files with 351 additions and 131 deletions
+17 -5
View File
@@ -1169,12 +1169,24 @@ class Channel(models.Model):
def load_more_members(self, known_member_ids):
self.ensure_one()
partners = self.env['res.partner'].with_context(active_test=False).search_read(
domain=[('id', 'not in', known_member_ids), ('channel_ids', 'in', self.id)],
fields=['id', 'name', 'im_status'],
limit=30
channel_partners = self.env['mail.channel.partner'].search(
domain=[('id', 'not in', known_member_ids), ('channel_id', '=', self.id)],
limit=100
)
return [('insert', partners)]
count = self.env['mail.channel.partner'].search_count(
domain=[('channel_id', '=', self.id)],
)
return {
'channelMembers': [('insert', [{
'id': member.id,
'partner': [('insert', {
'id': member.partner_id.id,
'name': member.partner_id.name,
'im_status': member.partner_id.im_status,
})]
} for member in channel_partners])],
'memberCount': count,
}
def _get_avatar_cache_key(self):
if not self.avatar_128:
@@ -6,17 +6,10 @@ const { Component } = owl;
export class ChannelMember extends Component {
/**
* @returns {Thread}
*/
get channel() {
return this.props.channel;
}
/**
* @returns {ChannelMemberView}
*/
get record() {
get channelMemberView() {
return this.props.record;
}
@@ -24,7 +17,6 @@ export class ChannelMember extends Component {
Object.assign(ChannelMember, {
props: {
channel: Object,
record: Object,
},
template: 'mail.ChannelMember',
@@ -2,23 +2,23 @@
<templates xml:space="preserve">
<t t-name="mail.ChannelMember" owl="1">
<div class="o_ChannelMember d-flex align-items-center mx-3 my-1" t-attf-class="{{ className }}" t-att-data-partner-local-id="record.partner.localId" t-ref="root">
<div class="o_ChannelMember_avatarContainer position-relative flex-shrink-0 o_cursor_pointer" t-on-click="() => channel.onClickMemberAvatar(record.partner)">
<img class="o_ChannelMember_avatar rounded-circle w-100 h-100" t-attf-src="/mail/channel/{{ channel.id }}/partner/{{ record.partner.id }}/avatar_128" alt="Avatar"/>
<div class="o_ChannelMember d-flex align-items-center mx-3 my-1" t-attf-class="{{ className }}" t-att-data-partner-local-id="channelMemberView.channelMember.partner.localId" t-ref="root">
<div class="o_ChannelMember_avatarContainer position-relative flex-shrink-0 o_cursor_pointer" t-on-click="channelMemberView.onClickMemberAvatar">
<img class="o_ChannelMember_avatar rounded-circle w-100 h-100" t-attf-src="/mail/channel/{{ channelMemberView.channelMember.channel.id }}/partner/{{ channelMemberView.channelMember.partner.id }}/avatar_128" alt="Avatar"/>
<t t-if="record.partner.im_status and record.partner.im_status !== 'im_partner'">
<t t-if="channelMemberView.channelMember.partner.im_status and channelMemberView.channelMember.partner.im_status !== 'im_partner'">
<PartnerImStatusIcon
className="'o_ChannelMember_partnerImStatusIcon position-absolute bottom-0 end-0 d-flex align-items-center justify-content-center text-light'"
classNameObj="{
'o-isDeviceSmall': messaging.device.isSmall,
'small': !messaging.device.isSmall,
}"
partner="record.partner"
partner="channelMemberView.channelMember.partner"
/>
</t>
</div>
<span class="o_ChannelMember_name ml-2 flex-column-1 text-truncate o_cursor_pointer" t-on-click="() => channel.onClickMemberName(record.partner)">
<t t-esc="record.partner.nameOrDisplayName"/>
<span class="o_ChannelMember_name ml-2 flex-column-1 text-truncate o_cursor_pointer" t-on-click="channelMemberView.onClickMemberName">
<t t-esc="channelMemberView.channelMember.partner.nameOrDisplayName"/>
</span>
</div>
</t>
@@ -9,7 +9,7 @@ export class ChannelMemberList extends Component {
/**
* @returns {ChannelMemberListView}
*/
get channelMemberListView() {
get channelMemberListView() {
return this.props.record;
}
@@ -16,9 +16,9 @@
<t t-if="channelMemberListView.channel.unknownMemberCount > 1">
<span class="mx-2 mt-2">And <t t-esc="channelMemberListView.channel.unknownMemberCount"/> other members.</span>
</t>
<t t-if="channelMemberListView.channel.unknownMemberCount > 0">
<t t-if="!channelMemberListView.channel.areAllMembersLoaded">
<div class="mx-2 my-1">
<button class="btn btn-secondary" t-on-click="channelMemberListView.channel.onClickLoadMoreMembers">Load more</button>
<button class="o_ChannelMemberList_loadMoreButton btn btn-secondary" t-on-click="channelMemberListView.onClickLoadMoreMembers">Load more</button>
</div>
</t>
</div>
@@ -4,7 +4,7 @@
<t t-name="mail.ChannelMemberListCategory" owl="1">
<h6 class="m-2" t-esc="record.title"/>
<t t-foreach="record.channelMemberViews" t-as="channelMemberView" t-key="channelMemberView.localId">
<ChannelMember channel="record.channel" record="channelMemberView"/>
<ChannelMember record="channelMemberView"/>
</t>
</t>
@@ -3,6 +3,7 @@
import { decrement, increment } from '@mail/model/model_field_command';
import { Listener } from '@mail/model/model_listener';
import { followRelations } from '@mail/model/model_utils';
import { cleanSearchTerm } from '@mail/utils/utils';
/**
* Defines a set containing the relation records of the given field on the given
@@ -79,11 +80,14 @@ export class RelationSet {
}
break;
}
case 'case-insensitive-asc':
if (valA.toLowerCase() === valB.toLowerCase()) {
case 'case-insensitive-asc': {
const cleanedValA = cleanSearchTerm(valA);
const cleanedValB = cleanSearchTerm(valB);
if (cleanedValA === cleanedValB) {
break;
}
return valA.toLowerCase() < valB.toLowerCase() ? -1 : 1;
return cleanedValA < cleanedValB ? -1 : 1;
}
case 'smaller-first':
if (valA === valB) {
break;
+99
View File
@@ -0,0 +1,99 @@
/** @odoo-module **/
import { registerModel } from '@mail/model/model_core';
import { attr, one, many } from '@mail/model/model_field';
import { insertAndReplace } from '@mail/model/model_field_command';
registerModel({
name: 'Channel',
identifyingFields: ['id'],
recordMethods: {
async fetchChannelMembers() {
const channelData = await this.messaging.rpc({
model: 'mail.channel',
method: 'load_more_members',
args: [[this.id]],
kwargs: {
known_member_ids: this.channelMembers.map(channelMember => channelMember.id),
},
});
if (!this.exists()) {
return;
}
this.update(channelData);
},
/**
* @private
* @returns {boolean}
*/
_computeAreAllMembersLoaded() {
return this.memberCount === this.channelMembers.length;
},
/**
* @private
* @returns {FieldCommand}
*/
_computeThread() {
return insertAndReplace({
id: this.id,
model: 'mail.channel',
});
},
/**
* @private
* @returns {integer}
*/
_computeUnknownMemberCount() {
return this.memberCount - this.channelMembers.length;
},
/**
* @private
* @returns {Array[]}
*/
_sortMembers() {
return [
['defined-first', 'name'],
['case-insensitive-asc', 'name'],
];
},
},
fields: {
areAllMembersLoaded: attr({
compute: '_computeAreAllMembersLoaded',
}),
channelMembers: many('ChannelMember', {
inverse: 'channel',
isCausal: true,
}),
id: attr({
readonly: true,
required: true,
}),
memberCount: attr({
related: 'thread.memberCount',
}),
orderedOfflineMembers: many('ChannelMember', {
inverse: 'channelAsOfflineMember',
sort: '_sortMembers',
}),
orderedOnlineMembers: many('ChannelMember', {
inverse: 'channelAsOnlineMember',
sort: '_sortMembers',
}),
thread: one('Thread', {
compute: '_computeThread',
inverse: 'channel',
isCausal: true,
readonly: true,
required: true,
}),
/**
* States how many members are currently unknown on the client side.
* This is the difference between the total number of members of the
* channel as reported in memberCount and those actually in members.
*/
unknownMemberCount: attr({
compute: '_computeUnknownMemberCount',
}),
},
});
@@ -0,0 +1,63 @@
/** @odoo-module **/
import { registerModel } from '@mail/model/model_core';
import { attr, one, many } from '@mail/model/model_field';
import { clear, replace } from '@mail/model/model_field_command';
registerModel({
name: 'ChannelMember',
identifyingFields: ['id'],
recordMethods: {
/**
* @private
* @returns {FieldCommand}
*/
_computeChannelAsOfflineMember() {
return this.partner && !this.partner.isOnline ? replace(this.channel) : clear();
},
/**
* @private
* @returns {FieldCommand}
*/
_computeChannelAsOnlineMember() {
return this.partner && this.partner.isOnline ? replace(this.channel) : clear();
},
/**
* @private
* @returns {string}
*/
_computeName() {
return this.partner.nameOrDisplayName;
},
},
fields: {
channel: one('Channel', {
inverse: 'channelMembers',
readonly: true,
required: true,
}),
channelAsOfflineMember: one('Channel', {
compute: '_computeChannelAsOfflineMember',
inverse: 'orderedOfflineMembers',
}),
channelAsOnlineMember: one('Channel', {
compute: '_computeChannelAsOnlineMember',
inverse: 'orderedOnlineMembers',
}),
channelMemberViews: many('ChannelMemberView', {
inverse: 'channelMember',
isCausal: true,
}),
id: attr({
readonly: true,
required: true,
}),
name: attr({
compute: '_computeName',
}),
partner: one('Partner', {
inverse: 'channelMembers',
readonly: true,
}),
},
});
@@ -26,12 +26,12 @@ registerModel({
* @private
* @returns {FieldCommand}
*/
_computeChannelMemberViews() {
_computeChannelMemberViews() {
if (this.members.length === 0) {
return clear();
}
return insertAndReplace(
this.members.map(member => ({ partner: replace(member) })),
this.members.map(member => ({ channelMember: replace(member) })),
);
},
/**
@@ -72,7 +72,7 @@ registerModel({
},
},
fields: {
channel: one('Thread', {
channel: one('Channel', {
compute: '_computeChannel',
required: true,
}),
@@ -89,7 +89,7 @@ registerModel({
inverse: 'channelMemberListCategoryViewOwner',
isCausal: true,
}),
members: many('Partner', {
members: many('ChannelMember', {
compute: '_computeMembers',
}),
title: attr({
@@ -7,17 +7,28 @@ import { clear, insertAndReplace, replace } from '@mail/model/model_field_comman
registerModel({
name: 'ChannelMemberListView',
identifyingFields: [['chatWindowOwner', 'threadViewOwner']],
lifecycleHooks: {
_created() {
this.channel.fetchChannelMembers();
},
},
recordMethods: {
/**
* Handles click on the "load more members" button.
*/
async onClickLoadMoreMembers() {
this.channel.fetchChannelMembers();
},
/**
* @private
* @returns {FieldCommand}
*/
_computeChannel() {
if (this.chatWindowOwner) {
return replace(this.chatWindowOwner.thread);
return replace(this.chatWindowOwner.thread.channel);
}
if (this.threadViewOwner) {
return replace(this.threadViewOwner.thread);
return replace(this.threadViewOwner.thread.channel);
}
return clear();
},
@@ -43,7 +54,7 @@ registerModel({
},
},
fields: {
channel: one('Thread', {
channel: one('Channel', {
compute: '_computeChannel',
readonly: true,
}),
@@ -5,14 +5,36 @@ import { one } from '@mail/model/model_field';
registerModel({
name: 'ChannelMemberView',
identifyingFields: ['channelMemberListCategoryViewOwner', 'partner'],
identifyingFields: ['channelMemberListCategoryViewOwner', 'channelMember'],
recordMethods: {
/**
* Handles click on the avatar of the channel member in the member list
* of this channel.
*/
onClickMemberAvatar() {
if (!this.channelMember.partner) {
return;
}
this.channelMember.partner.openChat();
},
/**
* Handles click on the name of the channel member in the member list of
* this channel.
*/
onClickMemberName() {
if (!this.channelMember.partner) {
return;
}
this.channelMember.partner.openProfile();
},
},
fields: {
channelMemberListCategoryViewOwner: one('ChannelMemberListCategoryView', {
inverse: 'channelMemberViews',
readonly: true,
required: true,
}),
partner: one('Partner', {
channelMember: one('ChannelMember', {
inverse: 'channelMemberViews',
readonly: true,
required: true,
+1 -1
View File
@@ -376,7 +376,7 @@ registerModel({
inverse: 'partner',
isCausal: true,
}),
channelMemberViews: many('ChannelMemberView', {
channelMembers: many('ChannelMember', {
inverse: 'partner',
isCausal: true,
}),
+13 -91
View File
@@ -797,24 +797,6 @@ registerModel({
pinned: this.isPendingPinned,
});
},
/**
* Handles click on the avatar of the given member in the member list of
* this channel.
*
* @param {Partner} member
*/
onClickMemberAvatar(member) {
member.openChat();
},
/**
* Handles click on the name of the given member in the member list of
* this channel.
*
* @param {Partner} member
*/
onClickMemberName(member) {
member.openProfile();
},
/**
* 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
@@ -1053,6 +1035,13 @@ registerModel({
});
return replace(allAttachments);
},
/**
* @private
* @returns {FieldCommand}
*/
_computeChannel() {
return this.model === 'mail.channel' ? insertAndReplace({ id: this.id }) : clear();
},
/**
* @private
* @returns {FieldCommand}
@@ -1468,20 +1457,6 @@ registerModel({
}
return replace(message);
},
/**
* @private
* @returns {Partner[]}
*/
_computeOrderedOfflineMembers() {
return replace(this._sortMembers(this.members.filter(member => !member.isOnline)));
},
/**
* @private
* @returns {Partner[]}
*/
_computeOrderedOnlineMembers() {
return replace(this._sortMembers(this.members.filter(member => member.isOnline)));
},
/**
* @private
* @returns {Message[]}
@@ -1565,13 +1540,6 @@ registerModel({
this.getMemberName(this.orderedOtherTypingMembers[1])
);
},
/**
* @private
* @returns {integer}
*/
_computeUnknownMemberCount() {
return this.memberCount - this.members.length;
},
/**
* Compute an url string that can be used inside a href attribute
*
@@ -1701,44 +1669,12 @@ registerModel({
['case-insensitive-asc', 'nameOrDisplayName'],
];
},
/**
* @private
* @param {Partner[]} members
* @returns {Partner[]}
*/
_sortMembers(members) {
return [...members].sort((a, b) => {
const cleanedAName = cleanSearchTerm(a.nameOrDisplayName || '');
const cleanedBName = cleanSearchTerm(b.nameOrDisplayName || '');
if (cleanedAName < cleanedBName) {
return -1;
}
if (cleanedAName > cleanedBName) {
return 1;
}
return a.id - b.id;
});
},
/**
* Event handler for clicking thread in discuss app.
*/
async onClick() {
await this.open();
},
/**
* Handles click on the "load more members" button.
*/
async onClickLoadMoreMembers() {
const members = await this.messaging.rpc({
model: 'mail.channel',
method: 'load_more_members',
args: [[this.id]],
kwargs: {
'known_member_ids': this.members.map(partner => partner.id),
},
});
this.update({ members });
},
onCurrentPartnerInactiveTypingTimeout() {
this.unregisterCurrentPartnerIsTyping();
},
@@ -1785,6 +1721,12 @@ registerModel({
readonly: true,
required: true,
}),
channel: one('Channel', {
compute: '_computeChannel',
inverse: 'thread',
isCausal: true,
readonly: true,
}),
channel_type: attr(),
/**
* States the chat window related to this thread (if any).
@@ -2174,18 +2116,6 @@ registerModel({
needactionMessagesAsOriginThread: many('Message', {
compute: '_computeNeedactionMessagesAsOriginThread',
}),
/**
* All offline members ordered like they are displayed.
*/
orderedOfflineMembers: many('Partner', {
compute: '_computeOrderedOfflineMembers',
}),
/**
* All online members ordered like they are displayed.
*/
orderedOnlineMembers: many('Partner', {
compute: '_computeOrderedOnlineMembers',
}),
/**
* All messages ordered like they are displayed.
*/
@@ -2385,14 +2315,6 @@ registerModel({
compute: '_computeTypingStatusText',
default: '',
}),
/**
* States how many members are currently unknown on the client side.
* This is the difference between the total number of members of the
* channel as reported in memberCount and those actually in members.
*/
unknownMemberCount: attr({
compute: '_computeUnknownMemberCount',
}),
/**
* URL to access to the conversation.
*/
@@ -219,6 +219,11 @@ patch(MockServer.prototype, 'mail', {
const ids = args.args[0];
return this._mockMailChannelWriteImage128(ids[0]);
}
if (args.model === 'mail.channel' && args.method === 'load_more_members') {
const [channel_ids] = args.args;
const { known_member_ids } = args.kwargs;
return this._mockMailChannelLoadMoreMembers(channel_ids, known_member_ids);
}
// mail.message methods
if (args.model === 'mail.message' && args.method === 'mark_all_as_read') {
const domain = args.args[0] || args.kwargs.domain;
@@ -1286,6 +1291,35 @@ patch(MockServer.prototype, 'mail', {
}
return messageData;
},
/**
* Simulates `load_more_members` on `mail.channel`.
*
* @private
* @param {integer[]} channel_ids
* @param {integer[]} known_member_ids
*/
_mockMailChannelLoadMoreMembers(channel_ids, known_member_ids) {
const channelPartners = this.pyEnv['mail.channel.partner'].searchRead([
['id', 'not in', known_member_ids],
['channel_id', 'in', channel_ids],
], { limit: 100 });
const memberCount = this.pyEnv['mail.channel.partner'].searchCount([
['channel_id', 'in', channel_ids],
]);
return {
channelMembers: [['insert', channelPartners.map(channelPartner => {
const [partner] = this.pyEnv['res.partner'].searchRead(
[['id', '=', channelPartner.partner_id[0]]],
{ fields: ['id', 'name', 'im_status'] }
);
return {
id: channelPartner.id,
partner: [['insert', partner]],
};
})]],
memberCount,
};
},
/**
* Simulates `notify_typing` on `mail.channel`.
*
@@ -7,7 +7,7 @@ import {
QUnit.module('mail', {}, function () {
QUnit.module('components', {}, function () {
QUnit.module('discuss_tests.js', {
QUnit.module('channel_member_list_tests.js', {
beforeEach() {
this.start = async params => {
return start(Object.assign({}, params, {
@@ -113,7 +113,7 @@ QUnit.test('should have correct members in member list', async function (assert)
}"]`,
"should have 'Demo' in member list ('Demo' is a member)",
);
});
});
QUnit.test('there should be a button to hide member list in the thread view topbar when the member list is visible', async function (assert) {
assert.expect(1);
@@ -143,5 +143,66 @@ QUnit.test('there should be a button to hide member list in the thread view topb
);
});
QUnit.test('should show a button to load more members if they are not all loaded', async function (assert) {
assert.expect(1);
const pyEnv = await startServer();
const channel_last_seen_partner_ids = [[0, 0, { partner_id: pyEnv.currentPartnerId }]];
for (let i = 0; i < 101; i++) {
const resPartnerId = pyEnv['res.partner'].create({ name: "name" + i });
channel_last_seen_partner_ids.push([0, 0, { partner_id: resPartnerId }]);
}
const mailChannelId = pyEnv['mail.channel'].create({
channel_last_seen_partner_ids,
channel_type: 'group',
public: 'private',
});
const { click } = await this.start({
discuss: {
context: {
active_id: `mail.channel_${mailChannelId}`,
},
},
});
await click('.o_ThreadViewTopbar_showMemberListButton');
assert.containsOnce(
document.body,
'.o_ChannelMemberList_loadMoreButton',
"should have a load more button because 100 members were fetched initially and there are 102 members in total",
);
});
QUnit.test('Load more button should load more members', async function (assert) {
assert.expect(1);
const pyEnv = await startServer();
const channel_last_seen_partner_ids = [[0, 0, { partner_id: pyEnv.currentPartnerId }]];
for (let i = 0; i < 101; i++) {
const resPartnerId = pyEnv['res.partner'].create({ name: "name" + i });
channel_last_seen_partner_ids.push([0, 0, { partner_id: resPartnerId }]);
}
const mailChannelId = pyEnv['mail.channel'].create({
channel_last_seen_partner_ids,
channel_type: 'group',
public: 'private',
});
const { click } = await this.start({
discuss: {
context: {
active_id: `mail.channel_${mailChannelId}`,
},
},
});
await click('.o_ThreadViewTopbar_showMemberListButton');
await click('.o_ChannelMemberList_loadMoreButton');
assert.containsN(
document.body,
'.o_ChannelMember',
102,
"should load all the members because 100 members were fetched initially, and load more fetched the remaining 2 members",
);
});
});
});