diff options
| author | Lucas Perais (lpe) <lpe@odoo.com> | 2020-06-15 11:07:54 +0000 |
|---|---|---|
| committer | Alexandre Kühn <aku@odoo.com> | 2020-06-26 00:54:54 +0000 |
| commit | 257371bab52b009c027ead19857fbd865f2bc5b4 (patch) | |
| tree | 997978f831cd0879b2b410ec3c79ccb156d7e725 | |
| parent | 5a30919826bd4072bf3e0e2915b9df271d58ee78 (diff) | |
[FIX] mail: discuss: unpin channel fixes
FIX: use the correct API of mail.channel.py to unfollow a channel
i.e.: `execute_command_leave`
FIX: unpinning a channel has immediate effect on the interface
chatWindows are closed, and the inbox is displayed
FIX: unpinning a channel destroys corresponding chatWindows across tabs
It also unpins the channel in other tabs
FIX: python sends is_pinned as part of the channel_info
both at init messaging and in the main bus notification
That way, we can keep track of the synchronization of the pin status
between JS and python
task-2275000
task-2267941
closes odoo/odoo#53703
X-original-commit: d4fcc64acb2fc0c6fd9df505380f665abd8b39c4
Signed-off-by: Alexandre Kühn (aku) <aku@odoo.com>
23 files changed, 576 insertions, 48 deletions
diff --git a/addons/im_livechat/static/src/components/discuss/discuss_tests.js b/addons/im_livechat/static/src/components/discuss/discuss_tests.js index dc65fc8d4d7..cbe87437c7a 100644 --- a/addons/im_livechat/static/src/components/discuss/discuss_tests.js +++ b/addons/im_livechat/static/src/components/discuss/discuss_tests.js @@ -43,6 +43,7 @@ QUnit.test('livechat in the sidebar', async function (assert) { channel_livechat: [{ channel_type: "livechat", id: 1, + is_pinned: true, livechat_visitor: { country: false, id: false, diff --git a/addons/im_livechat/static/src/components/thread_icon/thread_icon_tests.js b/addons/im_livechat/static/src/components/thread_icon/thread_icon_tests.js index 9edd905eabb..c55685ac23e 100644 --- a/addons/im_livechat/static/src/components/thread_icon/thread_icon_tests.js +++ b/addons/im_livechat/static/src/components/thread_icon/thread_icon_tests.js @@ -55,6 +55,7 @@ QUnit.test('livechat: public website visitor is typing', async function (assert) channel_livechat: [{ channel_type: 'livechat', id: 20, + is_pinned: true, livechat_visitor: { country: false, id: false, diff --git a/addons/im_livechat/static/src/components/thread_textual_typing_status/thread_textual_typing_status_tests.js b/addons/im_livechat/static/src/components/thread_textual_typing_status/thread_textual_typing_status_tests.js index 0fdae483eea..e057e156a70 100644 --- a/addons/im_livechat/static/src/components/thread_textual_typing_status/thread_textual_typing_status_tests.js +++ b/addons/im_livechat/static/src/components/thread_textual_typing_status/thread_textual_typing_status_tests.js @@ -59,6 +59,7 @@ QUnit.test('receive visitor typing status "is typing"', async function (assert) channel_livechat: [{ channel_type: 'livechat', id: 20, + is_pinned: true, livechat_visitor: { country: false, id: false, diff --git a/addons/im_livechat/static/src/models/messaging_initializer/messaging_initializer.js b/addons/im_livechat/static/src/models/messaging_initializer/messaging_initializer.js index ab9fc72193c..5f356f8146d 100644 --- a/addons/im_livechat/static/src/models/messaging_initializer/messaging_initializer.js +++ b/addons/im_livechat/static/src/models/messaging_initializer/messaging_initializer.js @@ -17,11 +17,9 @@ registerInstancePatchModel('mail.messaging_initializer', 'im_livechat/static/src this._super(initMessagingData); const { channel_livechat = [] } = initMessagingData; for (const data of channel_livechat) { - this.env.models['mail.thread'].insert(Object.assign( - {}, + this.env.models['mail.thread'].insert( this.env.models['mail.thread'].convertData(data), - { isPinned: true } - )); + ); } }, }); diff --git a/addons/mail/models/mail_channel.py b/addons/mail/models/mail_channel.py index 23f63f4dad3..a7986c67612 100644 --- a/addons/mail/models/mail_channel.py +++ b/addons/mail/models/mail_channel.py @@ -281,6 +281,9 @@ class Channel(models.Model): def _action_unfollow(self, partner): channel_info = self.channel_info('unsubscribe')[0] # must be computed before leaving the channel (access rights) result = self.write({'channel_partner_ids': [(3, partner.id)]}) + # side effect of unsubscribe that wasn't taken into account because + # channel_info is called before actually unpinning the channel + channel_info['is_pinned'] = False self.env['bus.bus'].sendone((self._cr.dbname, 'res.partner', partner.id), channel_info) if not self.email_send: notification = _('<div class="o_mail_notification">left <a href="#" class="o_channel_redirect" data-oe-id="%s">#%s</a></div>') % (self.id, self.name,) @@ -592,6 +595,7 @@ class Channel(models.Model): info['is_minimized'] = partner_channel.is_minimized info['seen_message_id'] = partner_channel.seen_message_id.id info['custom_channel_name'] = partner_channel.custom_channel_name + info['is_pinned'] = partner_channel.is_pinned # add members infos partner_ids = channel_partners.mapped('partner_id').ids diff --git a/addons/mail/static/src/components/chat_window_manager/chat_window_manager_tests.js b/addons/mail/static/src/components/chat_window_manager/chat_window_manager_tests.js index a4723f65201..1c51a669312 100644 --- a/addons/mail/static/src/components/chat_window_manager/chat_window_manager_tests.js +++ b/addons/mail/static/src/components/chat_window_manager/chat_window_manager_tests.js @@ -232,6 +232,7 @@ QUnit.test('chat window: basic rendering', async function (assert) { channel_channel: [{ channel_type: "channel", id: 20, + is_pinned: true, name: "General", }], }, @@ -332,6 +333,7 @@ QUnit.test('chat window: fold', async function (assert) { channel_type: 'channel', id: 20, is_minimized: false, + is_pinned: true, name: "General", state: 'open', uuid: 'channel-20-uuid', @@ -439,6 +441,7 @@ QUnit.test('chat window: open / close', async function (assert) { channel_type: 'channel', id: 20, is_minimized: false, + is_pinned: true, name: "General", state: 'open', uuid: 'channel-20-uuid', @@ -532,6 +535,7 @@ QUnit.test('chat window: close on ESCAPE', async function (assert) { channel_type: 'channel', id: 20, is_minimized: false, + is_pinned: true, name: "General", state: 'open', uuid: 'channel-20-uuid', @@ -583,6 +587,7 @@ QUnit.test('chat window: state conservation on toggle home menu', async function channel_type: 'channel', id: 20, is_minimized: false, + is_pinned: true, name: "General", state: 'open', uuid: 'channel-20-uuid', @@ -731,6 +736,7 @@ QUnit.test('open 2 different chat windows: enough screen width [REQUIRE FOCUS]', channel_channel: [{ channel_type: "channel", id: 20, + is_pinned: true, name: "General", }], channel_direct_message: [{ @@ -740,6 +746,7 @@ QUnit.test('open 2 different chat windows: enough screen width [REQUIRE FOCUS]', name: "Demo", }], id: 10, + is_pinned: true, }], }, }); @@ -891,6 +898,7 @@ QUnit.test('open 2 chat windows: check shift operations are available', async fu const channel = { channel_type: "channel", id: 20, + is_pinned: true, name: "General", }; const chat = { @@ -900,6 +908,7 @@ QUnit.test('open 2 chat windows: check shift operations are available', async fu name: "Demo", }], id: 10, + is_pinned: true, }; this.data['mail.channel'].records = [channel, chat]; Object.assign(this.data.initMessaging, { @@ -1010,14 +1019,17 @@ QUnit.test('open 3 different chat windows: not enough screen width', async funct channel_channel: [{ channel_type: "channel", id: 1, + is_pinned: true, name: "channel1", }, { channel_type: "channel", id: 2, + is_pinned: true, name: "channel2", }, { channel_type: "channel", id: 3, + is_pinned: true, name: "channel3", }], }, @@ -1172,10 +1184,12 @@ QUnit.test('chat window: switch on TAB', async function (assert) { channel_channel: [{ channel_type: "channel", id: 1, + is_pinned: true, name: "channel1", }, { channel_type: "channel", id: 2, + is_pinned: true, name: "channel2", }], }, diff --git a/addons/mail/static/src/components/composer/composer_tests.js b/addons/mail/static/src/components/composer/composer_tests.js index c62242360a6..24bba449019 100644 --- a/addons/mail/static/src/components/composer/composer_tests.js +++ b/addons/mail/static/src/components/composer/composer_tests.js @@ -612,6 +612,7 @@ QUnit.test('composer text input cleared on message post', async function (assert channel_channel: [{ channel_type: 'channel', id: 20, + is_pinned: true, name: "General", }], }, @@ -660,6 +661,7 @@ QUnit.test('composer with thread typing notification status', async function (as channel_channel: [{ channel_type: 'channel', id: 20, + is_pinned: true, name: "General", }], }, @@ -691,6 +693,7 @@ QUnit.test('current partner notify is typing to other thread members', async fun channel_channel: [{ channel_type: 'channel', id: 20, + is_pinned: true, members: [{ email: 'admin@odoo.com', id: 3, @@ -746,6 +749,7 @@ QUnit.test('current partner is typing should not translate on textual typing sta channel_channel: [{ channel_type: 'channel', id: 20, + is_pinned: true, members: [{ email: 'admin@odoo.com', id: 3, @@ -809,6 +813,7 @@ QUnit.test('current partner notify no longer is typing to thread members after 5 channel_channel: [{ channel_type: 'channel', id: 20, + is_pinned: true, members: [{ email: 'admin@odoo.com', id: 3, @@ -871,6 +876,7 @@ QUnit.test('current partner notify is typing again to other members every 50s of channel_channel: [{ channel_type: 'channel', id: 20, + is_pinned: true, members: [{ email: 'admin@odoo.com', id: 3, diff --git a/addons/mail/static/src/components/discuss/tests/discuss_moderation_tests.js b/addons/mail/static/src/components/discuss/tests/discuss_moderation_tests.js index 40a5af69337..adda97827b5 100644 --- a/addons/mail/static/src/components/discuss/tests/discuss_moderation_tests.js +++ b/addons/mail/static/src/components/discuss/tests/discuss_moderation_tests.js @@ -43,6 +43,7 @@ QUnit.test('moderated channel with pending moderation message', async function ( channel_slots: { channel_channel: [{ id: 20, + is_pinned: true, channel_type: "channel", name: "general", moderation: true, @@ -323,6 +324,7 @@ QUnit.test('accept pending moderation message', async function (assert) { channel_slots: { channel_channel: [{ id: 20, + is_pinned: true, channel_type: "channel", name: "general", moderation: true, @@ -442,6 +444,7 @@ QUnit.test('reject pending moderation message (reject with explanation)', async channel_slots: { channel_channel: [{ id: 20, + is_pinned: true, channel_type: "channel", name: "general", moderation: true, @@ -619,6 +622,7 @@ QUnit.test('discard pending moderation message (reject without explanation)', as channel_slots: { channel_channel: [{ id: 20, + is_pinned: true, channel_type: "channel", name: "general", moderation: true, @@ -749,6 +753,7 @@ QUnit.test('send message in moderated channel', async function (assert) { channel_slots: { channel_channel: [{ id: 20, + is_pinned: true, channel_type: "channel", name: "general", moderation: true, @@ -834,6 +839,7 @@ QUnit.test('sent message accepted in moderated channel', async function (assert) channel_slots: { channel_channel: [{ id: 20, + is_pinned: true, channel_type: "channel", name: "general", moderation: true, @@ -921,6 +927,7 @@ QUnit.test('sent message rejected in moderated channel', async function (assert) channel_slots: { channel_channel: [{ id: 20, + is_pinned: true, channel_type: "channel", name: "general", moderation: true, @@ -994,6 +1001,7 @@ QUnit.test('as moderator, pending moderation message accessibility', async funct channel_slots: { channel_channel: [{ id: 20, + is_pinned: true, channel_type: "channel", name: "general", moderation: true, @@ -1059,6 +1067,7 @@ QUnit.test('as author, pending moderation message should appear in origin thread channel_slots: { channel_channel: [{ id: 20, + is_pinned: true, channel_type: "channel", name: "general", moderation: true, @@ -1100,6 +1109,7 @@ QUnit.test('new pending moderation message posted by me', async function (assert channel_slots: { channel_channel: [{ id: 20, + is_pinned: true, channel_type: "channel", name: "general", moderation: true, @@ -1161,6 +1171,7 @@ QUnit.test('new pending moderation message posted by someone else when moderator channel_slots: { channel_channel: [{ id: 20, + is_pinned: true, channel_type: "channel", name: "general", moderation: true, diff --git a/addons/mail/static/src/components/discuss/tests/discuss_pinned_tests.js b/addons/mail/static/src/components/discuss/tests/discuss_pinned_tests.js new file mode 100644 index 00000000000..55bcfd6080e --- /dev/null +++ b/addons/mail/static/src/components/discuss/tests/discuss_pinned_tests.js @@ -0,0 +1,274 @@ +odoo.define('mail/static/src/components/discuss/tests/discuss_pinned_tests.js', function (require) { +'use strict'; + +const { + afterEach: utilsAfterEach, + afterNextRender, + beforeEach: utilsBeforeEach, + start: utilsStart, +} = require('mail/static/src/utils/test_utils.js'); + +QUnit.module('mail', {}, function () { +QUnit.module('components', {}, function () { +QUnit.module('discuss', {}, function () { +QUnit.module('discuss_pinned_tests.js', { + beforeEach() { + utilsBeforeEach(this); + + this.start = async params => { + let { env, widget } = await utilsStart(Object.assign({}, params, { + autoOpenDiscuss: true, + data: this.data, + hasDiscuss: true, + })); + this.env = env; + this.widget = widget; + }; + }, + afterEach() { + if (this.widget) { + this.widget.destroy(); + } + utilsAfterEach(this); + }, +}); + +QUnit.test('sidebar: pinned channel 1: init with one pinned channel', async function (assert) { + assert.expect(2); + + Object.assign(this.data.initMessaging, { + channel_slots: { + channel_channel: [{ + channel_type: "channel", + id: 20, + is_pinned: true, + name: "General", + }], + }, + }); + await this.start(); + assert.containsOnce( + document.body, + `.o_Discuss_thread[data-thread-local-id="${this.env.messaging.inbox.localId}"]`, + "The Inbox is opened in discuss" + ); + assert.containsOnce( + document.body, + `.o_DiscussSidebarItem[data-thread-local-id="${ + this.env.models['mail.thread'].find(thread => + thread.id === 20 && thread.model === 'mail.channel' + ).localId + }"]`, + "1 channel is present in discuss sidebar and it is 'general'" + ); +}); + +QUnit.test('sidebar: pinned channel 2: open pinned channel', async function (assert) { + assert.expect(1); + + Object.assign(this.data.initMessaging, { + channel_slots: { + channel_channel: [{ + channel_type: "channel", + id: 20, + is_pinned: true, + name: "General", + }], + }, + }); + await this.start(); + + const threadGeneral = this.env.models['mail.thread'].find(thread => + thread.id === 20 && thread.model === 'mail.channel' + ); + await afterNextRender(() => + document.querySelector(`.o_DiscussSidebarItem[data-thread-local-id="${ + threadGeneral.localId + }"]`).click() + ); + assert.containsOnce( + document.body, + `.o_Discuss_thread[data-thread-local-id="${threadGeneral.localId}"]`, + "The channel #General is displayed in discuss" + ); +}); + +QUnit.test('sidebar: pinned channel 3: open pinned channel and unpin it', async function (assert) { + assert.expect(8); + + const channel = { + channel_type: "channel", + id: 20, + is_minimized: false, + is_pinned: true, + name: "General", + uuid: 'general', + state: 'closed', + }; + + this.data['mail.channel'].records = [channel]; + Object.assign(this.data.initMessaging, { + channel_slots: { + channel_channel: [channel], + }, + }); + + const self = this; + await this.start({ + async mockRPC(route, args) { + if (args.method === 'execute_command') { + assert.step('execute_command'); + assert.deepEqual(args.args[0], [20], + "The right id is sent to the server to remove" + ); + assert.strictEqual(args.args[1], 'leave', + "The right command is sent to the server" + ); + } + if (args.method === 'channel_fold') { + assert.step('channel_fold'); + } + return this._super(...arguments); + }, + }); + + const threadGeneral = this.env.models['mail.thread'].find(thread => + thread.id === 20 && thread.model === 'mail.channel' + ); + await afterNextRender(() => + document.querySelector(`.o_DiscussSidebarItem[data-thread-local-id="${ + threadGeneral.localId + }"]`).click() + ); + assert.verifySteps([], "neither channel_fold nor execute_command are called yet"); + await afterNextRender(() => + document.querySelector('.o_DiscussSidebarItem_commandLeave').click() + ); + assert.verifySteps( + [ + 'channel_fold', + 'execute_command' + ], + "both channel_fold and execute_command have been called when unpinning a channel" + ); + assert.containsNone( + document.body, + `.o_DiscussSidebarItem[data-thread-local-id="${threadGeneral.localId}"]`, + "The channel must have been removed from discuss sidebar" + ); + assert.containsOnce( + document.body, + `.o_Discuss_thread[data-thread-local-id="${this.env.messaging.inbox.localId}"]`, + "The Inbox is opened in discuss" + ); +}); + +QUnit.test('sidebar: unpin channel from bus', async function (assert) { + assert.expect(5); + + Object.assign(this.data.initMessaging, { + channel_slots: { + channel_channel: [{ + channel_type: "channel", + id: 20, + is_pinned: true, + name: "General", + }], + }, + }); + + await this.start(); + const threadGeneral = this.env.models['mail.thread'].find(thread => + thread.id === 20 && thread.model === 'mail.channel' + ); + + assert.containsOnce( + document.body, + `.o_Discuss_thread[data-thread-local-id="${this.env.messaging.inbox.localId}"]`, + "The Inbox is opened in discuss" + ); + assert.containsOnce( + document.body, + `.o_DiscussSidebarItem[data-thread-local-id="${threadGeneral.localId}"]`, + "1 channel is present in discuss sidebar and it is 'general'" + ); + + await afterNextRender(() => + document.querySelector(`.o_DiscussSidebarItem[data-thread-local-id="${ + threadGeneral.localId + }"]`).click() + ); + assert.containsOnce( + document.body, + `.o_Discuss_thread[data-thread-local-id="${threadGeneral.localId}"]`, + "The channel #General is opened in discuss" + ); + + // Simulate receiving a leave channel notification + // (e.g. from user interaction from another device or browser tab) + await afterNextRender(() => { + const notif = [ + ["dbName", 'res.partner', this.env.messaging.currentPartner.id], + { + channel_type: 'channel', + id: 20, + info: 'unsubscribe', + name: "General", + public: 'public', + state: 'open', + } + ]; + this.env.services.bus_service.trigger('notification', [notif]); + }); + assert.containsOnce( + document.body, + `.o_Discuss_thread[data-thread-local-id="${this.env.messaging.inbox.localId}"]`, + "The Inbox is opened in discuss" + ); + assert.containsNone( + document.body, + `.o_DiscussSidebarItem[data-thread-local-id="${threadGeneral.localId}"]`, + "The channel must have been removed from discuss sidebar" + ); +}); + +QUnit.test('sidebar: channel group_based_subscription: mandatorily pinned', async function (assert) { + assert.expect(2); + + // FIXME: The following is admittedly odd. + // Fixing it should entail a deeper reflexion on the group_based_subscription + // and is_pinned functionalities, especially in python. + // task-2284357 + + Object.assign(this.data.initMessaging, { + channel_slots: { + channel_channel: [{ + channel_type: "channel", + group_based_subscription: true, + id: 20, + is_pinned: false, + name: "General", + }], + }, + }); + await this.start(); + const threadGeneral = this.env.models['mail.thread'].find(thread => + thread.id === 20 && thread.model === 'mail.channel' + ); + assert.containsOnce( + document.body, + `.o_DiscussSidebarItem[data-thread-local-id="${threadGeneral.localId}"]`, + "The channel #General is in discuss sidebar" + ); + assert.containsNone( + document.body, + 'o_DiscussSidebarItem_commandLeave', + "The group_based_subscription channel is not unpinnable" + ); +}); + +}); +}); +}); + +}); diff --git a/addons/mail/static/src/components/discuss/tests/discuss_tests.js b/addons/mail/static/src/components/discuss/tests/discuss_tests.js index 994d75a8935..6b6d4b3d5db 100644 --- a/addons/mail/static/src/components/discuss/tests/discuss_tests.js +++ b/addons/mail/static/src/components/discuss/tests/discuss_tests.js @@ -22,9 +22,6 @@ QUnit.module('discuss_tests.js', { utilsBeforeEach(this); this.start = async params => { - if (this.widget) { - this.widget.destroy(); - } let { env, widget } = await utilsStart(Object.assign({}, params, { autoOpenDiscuss: true, data: this.data, @@ -429,6 +426,7 @@ QUnit.test('sidebar: basic channel rendering', async function (assert) { channel_channel: [{ channel_type: "channel", id: 20, + is_pinned: true, name: "General", }], }, @@ -525,6 +523,7 @@ QUnit.test('sidebar: channel rendering with needaction counter', async function channel_channel: [{ channel_type: "channel", id: 20, + is_pinned: true, name: "General", message_needaction_counter: 10, }], @@ -567,12 +566,14 @@ QUnit.test('sidebar: public/private channel rendering', async function (assert) channel_channel: [{ channel_type: "channel", id: 100, + is_pinned: true, name: "channel1", public: 'public', }], channel_private_group: [{ channel_type: "channel", id: 101, + is_pinned: true, name: "channel2", public: 'private', }], @@ -652,6 +653,7 @@ QUnit.test('sidebar: basic chat rendering', async function (assert) { name: "Demo", }], id: 10, + is_pinned: true, }], }, }); @@ -729,6 +731,7 @@ QUnit.test('sidebar: chat rendering with unread counter', async function (assert name: "Demo", }], id: 10, + is_pinned: true, message_unread_counter: 100, }], }, @@ -775,6 +778,7 @@ QUnit.test('sidebar: chat im_status rendering', async function (assert) { name: "Partner1", }], id: 11, + is_pinned: true, }, { channel_type: "chat", direct_partner: [{ @@ -783,6 +787,7 @@ QUnit.test('sidebar: chat im_status rendering', async function (assert) { name: "Partner2", }], id: 12, + is_pinned: true, }, { channel_type: "chat", direct_partner: [{ @@ -791,6 +796,7 @@ QUnit.test('sidebar: chat im_status rendering', async function (assert) { name: "Partner3", }], id: 13, + is_pinned: true, }], }, }); @@ -896,6 +902,7 @@ QUnit.test('sidebar: chat custom name', async function (assert) { name: "Marc Demo", }], id: 10, + is_pinned: true, }], }, }); @@ -921,6 +928,7 @@ QUnit.test('sidebar: rename chat', async function (assert) { name: "Marc Demo", }], id: 10, + is_pinned: true, }], }, }); @@ -990,6 +998,7 @@ QUnit.test('default thread rendering', async function (assert) { channel_channel: [{ channel_type: "channel", id: 20, + is_pinned: true, name: "General", }], }, @@ -1227,6 +1236,7 @@ QUnit.test('load single message from channel initially', async function (assert) channel_channel: [{ channel_type: "channel", id: 20, + is_pinned: true, name: "General", }], }, @@ -1310,6 +1320,7 @@ QUnit.test('basic rendering of message', async function (assert) { channel_channel: [{ channel_type: "channel", id: 20, + is_pinned: true, name: "General", }], }, @@ -1423,6 +1434,7 @@ QUnit.test('basic rendering of squashed message', async function (assert) { channel_channel: [{ channel_type: "channel", id: 20, + is_pinned: true, name: "General", }], }, @@ -1610,6 +1622,7 @@ QUnit.test('load all messages from channel initially, less than fetch limit (29 channel_channel: [{ channel_type: "channel", id: 20, + is_pinned: true, name: "General", }], }, @@ -1683,6 +1696,7 @@ QUnit.test('load more messages from channel', async function (assert) { channel_channel: [{ channel_type: "channel", id: 20, + is_pinned: true, name: "General", }], }, @@ -1807,6 +1821,7 @@ QUnit.test('auto-scroll to bottom of thread', async function (assert) { channel_channel: [{ channel_type: "channel", id: 20, + is_pinned: true, name: "General", }], }, @@ -1864,6 +1879,7 @@ QUnit.test('load more messages from channel (auto-load on scroll)', async functi channel_channel: [{ channel_type: "channel", id: 20, + is_pinned: true, name: "General", }], }, @@ -1959,6 +1975,7 @@ QUnit.test('new messages separator', async function (assert) { channel_channel: [{ channel_type: "channel", id: 20, + is_pinned: true, message_unread_counter: 0, name: "General", seen_message_id: 125, @@ -2073,10 +2090,12 @@ QUnit.test('restore thread scroll position', async function (assert) { channel_channel: [{ channel_type: "channel", id: 1, + is_pinned: true, name: "channel1", }, { channel_type: "channel", id: 2, + is_pinned: true, name: "channel2", }], }, @@ -2212,10 +2231,12 @@ QUnit.test('message origin redirect to channel', async function (assert) { channel_channel: [{ channel_type: 'channel', id: 1, + is_pinned: true, name: "channel1", }, { channel_type: 'channel', id: 2, + is_pinned: true, name: "channel2", }], }, @@ -2435,6 +2456,7 @@ QUnit.test('redirect to author (open chat)', async function (assert) { channel_channel: [{ channel_type: "channel", id: 1, + is_pinned: true, name: "General", }], channel_direct_message: [{ @@ -2444,6 +2466,7 @@ QUnit.test('redirect to author (open chat)', async function (assert) { name: "Demo", }], id: 10, + is_pinned: true, }], }, }); @@ -2593,6 +2616,7 @@ QUnit.test('sidebar quick search', async function (assert) { channelsData.push({ channel_type: 'channel', id, + is_pinned: true, name: `channel${id}`, }); } @@ -2668,6 +2692,7 @@ QUnit.test('basic control panel rendering', async function (assert) { channel_channel: [{ channel_type: "channel", id: 20, + is_pinned: true, name: "General", }], }, @@ -2748,6 +2773,7 @@ QUnit.test('inbox: mark all messages as read', async function (assert) { channel_channel: [{ channel_type: "channel", id: 20, + is_pinned: true, message_needaction_counter: 2, name: "General", }], @@ -2973,6 +2999,7 @@ QUnit.test('toggle_star message', async function (assert) { channel_channel: [{ channel_type: "channel", id: 20, + is_pinned: true, name: "General", }], }, @@ -3103,10 +3130,12 @@ QUnit.test('composer state: text save and restore', async function (assert) { channel_channel: [{ channel_type: 'channel', id: 20, + is_pinned: true, name: "General", }, { channel_type: 'channel', id: 21, + is_pinned: true, name: "Special", }], }, @@ -3168,10 +3197,12 @@ QUnit.test('composer state: attachments save and restore', async function (asser channel_channel: [{ channel_type: 'channel', id: 20, + is_pinned: true, name: "General", }, { channel_type: 'channel', id: 21, + is_pinned: true, name: "Special", }], }, @@ -3278,6 +3309,7 @@ QUnit.test('post a simple message', async function (assert) { channel_channel: [{ channel_type: "channel", id: 20, + is_pinned: true, name: "General", }], }, @@ -3465,6 +3497,7 @@ QUnit.test('mark channel as seen on last message visible', async function (asser channel_channel: [{ channel_type: 'channel', id: 10, + is_pinned: true, message_unread_counter: 1, name: "General", }], @@ -3828,6 +3861,7 @@ QUnit.test('load recent messages from thread (already loaded some old messages)' channel_channel: [{ channel_type: 'channel', id: 20, + is_pinned: true, name: "General", }], }, @@ -4245,6 +4279,7 @@ QUnit.test('receive new channel message: out of odoo focus (notification, channe channel_channel: [{ channel_type: "channel", id: 20, + is_pinned: true, message_unread_counter: 0, name: "General", }], @@ -4298,9 +4333,11 @@ QUnit.test('receive new channel message: out of odoo focus (notification, chat)' channel_type: "chat", direct_partner: [{ id: 7, + is_pinned: true, name: "Demo User", }], id: 10, + is_pinned: true, message_unread_counter: 0, }], }, @@ -4353,6 +4390,7 @@ QUnit.test('receive new channel messages: out of odoo focus (tab title)', async channel_channel: [{ channel_type: "channel", id: 20, + is_pinned: true, message_unread_counter: 0, name: "General", }], @@ -4363,6 +4401,7 @@ QUnit.test('receive new channel messages: out of odoo focus (tab title)', async name: "Demo User", }], id: 10, + is_pinned: true, message_unread_counter: 0, }], }, @@ -4459,6 +4498,7 @@ QUnit.test('auto-focus composer on opening thread', async function (assert) { channel_channel: [{ channel_type: "channel", id: 20, + is_pinned: true, message_unread_counter: 0, name: "General", }], @@ -4469,6 +4509,7 @@ QUnit.test('auto-focus composer on opening thread', async function (assert) { name: "Demo User", }], id: 10, + is_pinned: true, message_unread_counter: 0, }], }, diff --git a/addons/mail/static/src/components/messaging_menu/messaging_menu_tests.js b/addons/mail/static/src/components/messaging_menu/messaging_menu_tests.js index 4f070d7881c..abf13f541f4 100644 --- a/addons/mail/static/src/components/messaging_menu/messaging_menu_tests.js +++ b/addons/mail/static/src/components/messaging_menu/messaging_menu_tests.js @@ -455,6 +455,7 @@ QUnit.test('channel preview: basic rendering', async function (assert) { channel_channel: [{ channel_type: "channel", id: 20, + is_pinned: true, name: "General", }], }, @@ -565,6 +566,7 @@ QUnit.test('filtered previews', async function (assert) { channel_channel: [{ channel_type: "channel", id: 20, + is_pinned: true, name: "General", }], channel_direct_message: [{ @@ -574,6 +576,7 @@ QUnit.test('filtered previews', async function (assert) { name: "Demo", }], id: 10, + is_pinned: true, }], }, }); @@ -760,6 +763,7 @@ QUnit.test('open chat window from preview', async function (assert) { channel_channel: [{ channel_type: "channel", id: 20, + is_pinned: true, name: "General", }], }, @@ -795,6 +799,7 @@ QUnit.test('no code injection in message body preview', async function (assert) channel_channel: [{ channel_type: "channel", id: 1, + is_pinned: true, name: "General", }], }, diff --git a/addons/mail/static/src/components/notification_list/notification_list_tests.js b/addons/mail/static/src/components/notification_list/notification_list_tests.js index cc71ee2d0f2..4dd03f0e014 100644 --- a/addons/mail/static/src/components/notification_list/notification_list_tests.js +++ b/addons/mail/static/src/components/notification_list/notification_list_tests.js @@ -36,11 +36,13 @@ QUnit.module('notification_list_tests.js', { channel_channel: [{ channel_type: 'channel', id: 100, + is_pinned: true, name: "Channel 2019", message_unread_counter: 0, }, { channel_type: 'channel', id: 200, + is_pinned: true, name: "Channel 2020", message_unread_counter: 0, }], diff --git a/addons/mail/static/src/components/thread_icon/thread_icon_tests.js b/addons/mail/static/src/components/thread_icon/thread_icon_tests.js index c21aef4c393..7bd4076a1b4 100644 --- a/addons/mail/static/src/components/thread_icon/thread_icon_tests.js +++ b/addons/mail/static/src/components/thread_icon/thread_icon_tests.js @@ -61,6 +61,7 @@ QUnit.test('chat: correspondent is typing', async function (assert) { name: "Demo", }], id: 20, + is_pinned: true, members: [{ email: 'admin@odoo.com', id: 3, diff --git a/addons/mail/static/src/components/thread_textual_typing_status/thread_textual_typing_status_tests.js b/addons/mail/static/src/components/thread_textual_typing_status/thread_textual_typing_status_tests.js index 2d4665e4920..fce0363bc94 100644 --- a/addons/mail/static/src/components/thread_textual_typing_status/thread_textual_typing_status_tests.js +++ b/addons/mail/static/src/components/thread_textual_typing_status/thread_textual_typing_status_tests.js @@ -60,6 +60,7 @@ QUnit.test('receive other member typing status "is typing"', async function (ass channel_channel: [{ channel_type: 'channel', id: 20, + is_pinned: true, members: [{ email: 'admin@odoo.com', id: 3, @@ -120,6 +121,7 @@ QUnit.test('receive other member typing status "is typing" then "no longer is ty channel_channel: [{ channel_type: 'channel', id: 20, + is_pinned: true, members: [{ email: 'admin@odoo.com', id: 3, @@ -196,6 +198,7 @@ QUnit.test('assume other member typing status becomes "no longer is typing" afte channel_channel: [{ channel_type: 'channel', id: 20, + is_pinned: true, members: [{ email: 'admin@odoo.com', id: 3, @@ -264,6 +267,7 @@ QUnit.test ('other member typing status "is typing" refreshes 60 seconds timer o channel_channel: [{ channel_type: 'channel', id: 20, + is_pinned: true, members: [{ email: 'admin@odoo.com', id: 3, @@ -349,6 +353,7 @@ QUnit.test('receive several other members typing status "is typing"', async func channel_channel: [{ channel_type: 'channel', id: 20, + is_pinned: true, members: [{ email: 'admin@odoo.com', id: 3, diff --git a/addons/mail/static/src/components/thread_viewer/thread_viewer_tests.js b/addons/mail/static/src/components/thread_viewer/thread_viewer_tests.js index 259e877cb34..6276cf11ca5 100644 --- a/addons/mail/static/src/components/thread_viewer/thread_viewer_tests.js +++ b/addons/mail/static/src/components/thread_viewer/thread_viewer_tests.js @@ -323,7 +323,7 @@ QUnit.test('mark channel as fetched when a new message is loaded and as seen whe // FIXME should not be needed, see task-2277537 composer: [['create']], // avoid initial focus id: 100, - isPinned: true, // just to avoid joinChannel to be called + isServerPinned: true, // just to avoid joinChannel to be called members: [['insert', [ { email: "john@example.com", @@ -393,7 +393,7 @@ QUnit.test('mark channel as fetched and seen when a new message is loaded if com }); const thread = this.env.models['mail.thread'].create({ id: 100, - isPinned: true, // just to avoid joinChannel to be called + isServerPinned: true, // just to avoid joinChannel to be called members: [['insert', [ { email: "john@example.com", diff --git a/addons/mail/static/src/models/discuss/discuss.js b/addons/mail/static/src/models/discuss/discuss.js index c5d6c3ecf91..77a7f0fca9a 100644 --- a/addons/mail/static/src/models/discuss/discuss.js +++ b/addons/mail/static/src/models/discuss/discuss.js @@ -272,6 +272,23 @@ function factory(dependencies) { return []; } + /** + * @private + */ + _onChangeThreadIsPinned() { + let thread = this.thread; + // No thread, or thread is being removed + // so we display discuss the messaging's Inbox. + if ( + (!thread || !thread.isPinned) && + this.messaging + ) { + thread = this.messaging.inbox; + } + if (thread && this.threadViewer && thread !== this.thread) { + this.threadViewer.update({ thread: [['link', thread]] }); + } + } } Discuss.fields = { @@ -358,6 +375,9 @@ function factory(dependencies) { default: false, dependencies: ['replyingToMessage'], }), + isThreadPinned: attr({ + related: 'thread.isPinned', + }), /** * The menu_id of discuss app, received on mail/init_messaging and * used to open discuss from elsewhere. @@ -365,6 +385,20 @@ function factory(dependencies) { menu_id: attr({ default: null, }), + messaging: one2one('mail.messaging', { + inverse: 'discuss', + }), + /** + * When a thread changes, or some properties of it change + * Computes whether we should display it or change it + */ + onChangeThreadIsPinned: attr({ + compute: '_onChangeThreadIsPinned', + dependencies: [ + 'isThreadPinned', + 'thread', + ], + }), renamingThreads: one2many('mail.thread'), replyingToMessage: one2one('mail.message', { compute: '_computeReplyingToMessage', diff --git a/addons/mail/static/src/models/messaging/messaging.js b/addons/mail/static/src/models/messaging/messaging.js index eb81b69d0b9..17e9b1550a4 100644 --- a/addons/mail/static/src/models/messaging/messaging.js +++ b/addons/mail/static/src/models/messaging/messaging.js @@ -151,6 +151,7 @@ function factory(dependencies) { }), discuss: one2one('mail.discuss', { autocreate: true, + inverse: 'messaging', isCausal: true, }), /** diff --git a/addons/mail/static/src/models/messaging_initializer/messaging_initializer.js b/addons/mail/static/src/models/messaging_initializer/messaging_initializer.js index 3cee3777d3a..5bcebff0af5 100644 --- a/addons/mail/static/src/models/messaging_initializer/messaging_initializer.js +++ b/addons/mail/static/src/models/messaging_initializer/messaging_initializer.js @@ -22,19 +22,19 @@ function factory(dependencies) { this.messaging.update({ history: [['create', { id: 'history', - isPinned: true, + isServerPinned: true, model: 'mail.box', name: this.env._t("History"), }]], inbox: [['create', { id: 'inbox', - isPinned: true, + isServerPinned: true, model: 'mail.box', name: this.env._t("Inbox"), }]], starred: [['create', { id: 'starred', - isPinned: true, + isServerPinned: true, model: 'mail.box', name: this.env._t("Starred"), }]], @@ -144,11 +144,9 @@ function factory(dependencies) { channel_private_group = [], } = {}) { for (const data of channel_channel.concat(channel_direct_message, channel_private_group)) { - this.env.models['mail.thread'].insert(Object.assign( - {}, + this.env.models['mail.thread'].insert( this.env.models['mail.thread'].convertData(data), - { isPinned: true } - )); + ); } } @@ -192,7 +190,7 @@ function factory(dependencies) { moderation: [['create', { counter: moderation_counter, id: 'moderation', - isPinned: true, + isServerPinned: true, model: 'mail.box', name: this.env._t("Moderation"), }]], diff --git a/addons/mail/static/src/models/messaging_notification_handler/messaging_notification_handler.js b/addons/mail/static/src/models/messaging_notification_handler/messaging_notification_handler.js index 0514bde027c..f92521f248e 100644 --- a/addons/mail/static/src/models/messaging_notification_handler/messaging_notification_handler.js +++ b/addons/mail/static/src/models/messaging_notification_handler/messaging_notification_handler.js @@ -453,11 +453,7 @@ function factory(dependencies) { channel.members.includes(this.env.messaging.currentPartner) ); - channel = this.env.models['mail.thread'].insert(Object.assign( - { isPinned: true }, - convertedData, - )); - + channel = this.env.models['mail.thread'].insert(convertedData); if ( channel.channel_type === 'channel' && data.info !== 'creation' && @@ -654,12 +650,14 @@ function factory(dependencies) { owl.utils.escape(channel.name) ); } + // We assume that arriving here the server has effectively + // unpinned the channel + channel.update({ isServerPinned: false }); this.env.services['notification'].notify({ message, title: this.env._t("Unsubscribed"), type: 'warning', }); - channel.update({ isPinned: false }); } /** diff --git a/addons/mail/static/src/models/thread/thread.js b/addons/mail/static/src/models/thread/thread.js index 06c41652ff8..2267d170f10 100644 --- a/addons/mail/static/src/models/thread/thread.js +++ b/addons/mail/static/src/models/thread/thread.js @@ -171,6 +171,16 @@ function factory(dependencies) { if ('is_moderator' in data) { data2.is_moderator = data.is_moderator; } + if ('is_pinned' in data) { + data2.isServerPinned = data.is_pinned; + // FIXME: The following is admittedly odd. + // Fixing it should entail a deeper reflexion on the group_based_subscription + // and is_pinned functionalities, especially in python. + // task-2284357 + if ('group_based_subscription' in data && data.group_based_subscription) { + data2.isServerPinned = true; + } + } if ('mass_mailing' in data) { data2.mass_mailing = data.mass_mailing; } @@ -298,10 +308,7 @@ function factory(dependencies) { }), } }); - const thread = this.insert(Object.assign( - { isPinned: true }, - this.convertData(data) - )); + const thread = this.insert(this.convertData(data)); if (autoselect) { thread.open({ chatWindowMode: autoselectChatWindowMode }); } @@ -316,7 +323,7 @@ function factory(dependencies) { * @param {boolean} [param1.autoselect=false] */ static async joinChannel(channelId, { autoselect = false } = {}) { - const channel = this.find(thread => + let channel = this.find(thread => thread.id === channelId && thread.model === 'mail.channel' ); @@ -328,12 +335,17 @@ function factory(dependencies) { method: 'channel_join_and_get_info', args: [[channelId]] }); - const thread = this.insert(Object.assign( - { isPinned: true }, - this.convertData(data) - )); + // 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) { - thread.open({ resetDiscussDomain: true }); + channel.open({ resetDiscussDomain: true }); } } @@ -483,6 +495,20 @@ 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'. + */ + async notifyUnPinToServer() { + return this.async(() => this.env.services.rpc({ + model: 'mail.channel', + method: 'execute_command', + args: [[this.id], 'leave'] + })); + } + + /** * Open provided thread, either in discuss app or as a chat window. * * @param {Object} param0 @@ -728,19 +754,11 @@ function factory(dependencies) { /** * Unsubscribe current user from provided channel. */ - async unsubscribe() { - if (this.channel_type === 'mail.channel') { - return this.async(() => this.env.services.rpc({ - model: 'mail.channel', - method: 'action_unfollow', - args: [[this.id]] - })); - } - return this.async(() => this.env.services.rpc({ - model: 'mail.channel', - method: 'channel_pin', - args: [this.uuid, false] - })); + unsubscribe() { + this.update({ + pendingFoldState: 'closed', + isPendingPinned: false, + }); } //---------------------------------------------------------------------- @@ -821,6 +839,14 @@ function factory(dependencies) { /** * @private + * @returns {boolean} + */ + _computeIsPinned() { + return this.isPendingPinned !== undefined ? this.isPendingPinned : this.isServerPinned; + } + + /** + * @private * @returns {mail.message} */ _computeLastCurrentPartnerMessageSeenByEveryone() { @@ -1059,6 +1085,17 @@ function factory(dependencies) { ) { this.update({ pendingFoldState: undefined }); } + if ( + this.isPendingPinned === false && + previous.isPendingPinned !== this.isPendingPinned + ) { + this.notifyUnPinToServer(); + } + if ( + this.isServerPinned === this.isPendingPinned + ) { + this.update({ isPendingPinned: undefined }); + } // TODO FIXME prevent to open/close a channel on mobile when you // open/close it on desktop (task-2267593) @@ -1090,6 +1127,7 @@ function factory(dependencies) { _updateBefore() { return { foldState: this.foldState, + isPendingPinned: this.isPendingPinned, pendingFoldState: this.pendingFoldState, }; } @@ -1222,7 +1260,36 @@ function factory(dependencies) { 'messagingCurrentPartner', ], }), + /** + * Determine if there is a pending pin state change, which is a change + * of pin state requested by the client but not yet confirmed by the + * server. + * + * This field can be updated to immediately change the pin state on the + * interface and to notify the server of the new state. + */ + isPendingPinned: attr(), + /** + * Boolean that determines whether this thread is pinned + * in discuss and present in the messaging menu. + */ isPinned: attr({ + compute: '_computeIsPinned', + dependencies: [ + 'isPendingPinned', + 'isServerPinned', + ], + }), + /** + * Determine the last pin state known by the server, which is the pin + * state displayed after initialization or when the last pending + * pin state change was confirmed by the server. + * + * This field should be considered read only in most situations. Only + * the code handling pin state change from the server should typically + * update it. + */ + isServerPinned: attr({ default: false, }), isTemporary: attr({ diff --git a/addons/mail/static/src/utils/test_utils.js b/addons/mail/static/src/utils/test_utils.js index 5b7cb029cc3..d5eec350e01 100644 --- a/addons/mail/static/src/utils/test_utils.js +++ b/addons/mail/static/src/utils/test_utils.js @@ -319,6 +319,16 @@ function beforeEach(self) { string: "Id", type: 'integer', }, + is_minimized: { + // In python this belongs to mail.channel.partner. Here for simplicity. + string: "isMinimized", + type: "boolean", + }, + is_pinned: { + // In python this belongs to mail.channel.partner. Here for simplicity. + string: "isPinned", + type: "boolean", + }, message_unread_counter: { string: "# unread messages", type: 'integer', @@ -328,7 +338,18 @@ function beforeEach(self) { type: "char", required: true, }, + state: { + // In python this belongs to mail.channel.partner. Here for simplicity. + string: "FoldState", + type: "char", + }, + uuid: { + string: "UUID", + type: "char", + required: true, + }, }, + records: [], }, 'mail.followers': { fields: { diff --git a/addons/mail/static/tests/helpers/mock_server.js b/addons/mail/static/tests/helpers/mock_server.js index a3f7bb91b63..a87d2853635 100644 --- a/addons/mail/static/tests/helpers/mock_server.js +++ b/addons/mail/static/tests/helpers/mock_server.js @@ -50,7 +50,48 @@ MockServer.include({ return channel; }); }, - + /** + * Simulate the 'channel_fold' route of of mail.channel + * In particular sends a notification on the bus + * + * @private + */ + _mockChannelFold(args) { + const { state, uuid } = args.kwargs; + const channel = this.data['mail.channel'].records.find(c => c.uuid === uuid); + channel.state = state; + // Should use something of the form session.partner_id or env.messaging.currentPartner + // This is a limitation of the architecture of JS tests + const notifConfirmFold = [ + ["dbName", 'res.partner', undefined], + Object.assign({}, channel) + ]; + this._widget.call('bus_service', 'trigger', 'notification', [notifConfirmFold]); + }, + /** + * Simulate the 'execute_command' route of mail.channel + * In particular sends a notification on the bus + * + * @private + */ + _mockExecuteCommand(args) { + const [ids, commandName] = args.args; + const channels = this.data['mail.channel'].records.filter(c => ids.includes(c.id)); + if (commandName === 'leave') { + for (const channel of channels) { + channel.is_pinned = false; + // Should use something of the form session.partner_id or env.messaging.currentPartner + // This is a limitation of the architecture of JS tests + const notifConfirmUnpin = [ + ["dbName", 'res.partner', undefined], + Object.assign({}, channel, { info: 'unsubscribe' }) + ]; + this._widget.call('bus_service', 'trigger', 'notification', [notifConfirmUnpin]); + } + return; + } + throw new Error(`mail/mock_server: the route execute_command doesn't implement the command "${commandName}"`); + }, /** * Simulate the '/mail/read_followers' route * @@ -318,7 +359,7 @@ MockServer.include({ return Promise.resolve(this._mockChannelFetchPreview(args)); } if (args.method === 'channel_fold') { - return; + return this._mockChannelFold(args); } if (args.method === 'channel_minimize') { return Promise.resolve(); @@ -329,6 +370,9 @@ MockServer.include({ if (args.method === 'channel_fetched') { return Promise.resolve(); } + if (args.method === 'execute_command') { + return this._mockExecuteCommand(args); + } if (args.method === 'get_activity_data') { return Promise.resolve(this._mockGetActivityData(args)); } diff --git a/addons/mail/views/assets.xml b/addons/mail/views/assets.xml index 6b4623a5416..a13c036cd8f 100644 --- a/addons/mail/views/assets.xml +++ b/addons/mail/views/assets.xml @@ -245,6 +245,7 @@ <script type="text/javascript" src="/mail/static/src/components/chatter_topbar/chatter_topbar_tests.js"></script> <script type="text/javascript" src="/mail/static/src/components/composer/composer_tests.js"></script> <script type="text/javascript" src="/mail/static/src/components/discuss/tests/discuss_moderation_tests.js"></script> + <script type="text/javascript" src="/mail/static/src/components/discuss/tests/discuss_pinned_tests.js"></script> <script type="text/javascript" src="/mail/static/src/components/discuss/tests/discuss_tests.js"></script> <script type="text/javascript" src="/mail/static/src/components/file_uploader/file_uploader_tests.js"></script> <script type="text/javascript" src="/mail/static/src/components/follow_button/follow_button_tests.js"></script> |
