From ae5d22fa0766360a43752d6d25dd0c4260dac34f Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Alexandre=20K=C3=BChn?= Date: Wed, 5 Dec 2018 14:34:41 +0000 Subject: [PATCH 01/11] [FIX] bus,mail:tests: do not use deprecated test helpers `testUtils.addMockEnvironment` is deprecated since https://github.com/odoo/odoo/commit/abf32b8b21394491040e5c11c1fea809c1d7c62c We should use `testUtils.mock.addMockEnvironment` instead. --- addons/bus/static/tests/bus_tests.js | 4 ++-- .../static/tests/systray/systray_messaging_menu_tests.js | 8 ++++---- 2 files changed, 6 insertions(+), 6 deletions(-) diff --git a/addons/bus/static/tests/bus_tests.js b/addons/bus/static/tests/bus_tests.js index c8c7eab2bd9..e58fd22e8a8 100644 --- a/addons/bus/static/tests/bus_tests.js +++ b/addons/bus/static/tests/bus_tests.js @@ -223,7 +223,7 @@ QUnit.module('Bus', { var pollDeferredMaster = $.Deferred(); var parentMaster = new Widget(); - testUtils.addMockEnvironment(parentMaster, { + testUtils.mock.addMockEnvironment(parentMaster, { data: {}, services: { bus_service: BusService, @@ -255,7 +255,7 @@ QUnit.module('Bus', { setTimeout(function () { var parentSlave = new Widget(); var pollDeferredSlave = $.Deferred(); - testUtils.addMockEnvironment(parentSlave, { + testUtils.mock.addMockEnvironment(parentSlave, { data: {}, services: { bus_service: BusService, diff --git a/addons/mail/static/tests/systray/systray_messaging_menu_tests.js b/addons/mail/static/tests/systray/systray_messaging_menu_tests.js index 44651a70523..230da3b0a23 100644 --- a/addons/mail/static/tests/systray/systray_messaging_menu_tests.js +++ b/addons/mail/static/tests/systray/systray_messaging_menu_tests.js @@ -592,7 +592,7 @@ QUnit.test("messaging menu widget: channel seen notification", function ( assert this.data.initMessaging.channel_slots.channel_channel[0].message_unread_counter = 1; var messagingMenu = new MessagingMenu(); - testUtils.addMockEnvironment(messagingMenu, { + testUtils.mock.addMockEnvironment(messagingMenu, { services: this.services, data: this.data, }); @@ -633,7 +633,7 @@ QUnit.test("messaging menu widget: preview with no message should be undated", f this.data['mail.message'].records[0].channel_ids = []; var messagingMenu = new MessagingMenu(); - testUtils.addMockEnvironment(messagingMenu, { + testUtils.mock.addMockEnvironment(messagingMenu, { services: this.services, data: this.data, }); @@ -664,7 +664,7 @@ QUnit.test("messaging menu widget: sort dated previews before undated previews", this.data.initMessaging.channel_slots.direct_message = [dm]; var messagingMenu = new MessagingMenu(); - testUtils.addMockEnvironment(messagingMenu, { + testUtils.mock.addMockEnvironment(messagingMenu, { services: this.services, data: this.data, }); @@ -752,7 +752,7 @@ QUnit.test('global counter with channel previews', function (assert) { this.data['mail.message'].records = messages; var messagingMenu = new MessagingMenu(); - testUtils.addMockEnvironment(messagingMenu, { + testUtils.mock.addMockEnvironment(messagingMenu, { services: this.services, data: this.data, session: { From 9117d968e056d7c4e1112079e7e0ea0dd63cf9a5 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Alexandre=20K=C3=BChn?= Date: Wed, 5 Dec 2018 14:34:56 +0000 Subject: [PATCH 02/11] [FIX] mail: get members on channel_info Before this commit, when accessing a channel after a page reload, the list of members in a channel was not known until the composer on this channel has the focus, either in the Discuss app or in a chat window. This was a good-enough condition when information about members was limited to mention suggestions, which requires having the focus on the composer. However, with the 'is typing' features on channels, it requires being aware of the members of the channel at the moment of the rendering in discuss or in a chat window. This commit fixes the issue by providing the list of channel members when retrieving information about the channel. Without this fix, the 'is typing' notifications could sometimes not determine the name(s) and email(s) of user(s) typing something. --- addons/mail/models/mail_channel.py | 38 +++++++++++++------ .../static/src/js/models/threads/channel.js | 6 ++- 2 files changed, 31 insertions(+), 13 deletions(-) diff --git a/addons/mail/models/mail_channel.py b/addons/mail/models/mail_channel.py index 2b060cf1276..778ee09d4c4 100644 --- a/addons/mail/models/mail_channel.py +++ b/addons/mail/models/mail_channel.py @@ -537,10 +537,13 @@ class Channel(models.Model): :rtype : list(dict) """ channel_infos = [] - partner_channels = self.env['mail.channel.partner'] - # find the channel partner state, if logged user - if self.env.user and self.env.user.partner_id: - partner_channels = self.env['mail.channel.partner'].search([('partner_id', '=', self.env.user.partner_id.id), ('channel_id', 'in', self.ids)]) + + # all relations partner_channel on those channels + all_partner_channel = self.env['mail.channel.partner'].search([('channel_id', 'in', self.ids)]) + + # all partner infos on those channels + partner_infos = all_partner_channel.mapped('partner_id').read(['id', 'name', 'email']) + # for each channel, build the information header and include the logged partner information for channel in self: info = { @@ -573,17 +576,28 @@ class Channel(models.Model): if last_message: info['last_message'] = last_message[0].get('last_message') - # add user session state, if available and if user is logged - if partner_channels.ids: - partner_channel = partner_channels.filtered(lambda c: channel.id == c.channel_id.id) - if len(partner_channel) >= 1: - partner_channel = partner_channel[0] - info['state'] = partner_channel.fold_state or 'open' - info['is_minimized'] = partner_channel.is_minimized - info['seen_message_id'] = partner_channel.seen_message_id.id + # listeners of the channel + channel_partners = all_partner_channel.filtered(lambda pc: channel.id == pc.channel_id.id) + + # find the channel partner state, if logged user + partner_channel = self.env['mail.channel.partner'] + if self.env.user and self.env.user.partner_id: + partner_channel = channel_partners.filtered(lambda pc: pc.partner_id.id == self.env.user.partner_id.id) # add needaction and unread counter, since the user is logged info['message_needaction_counter'] = channel.message_needaction_counter info['message_unread_counter'] = channel.message_unread_counter + + # add user session state, if available and if user is logged + if len(partner_channel.ids): + partner_channel = partner_channel[0] + info['state'] = partner_channel.fold_state or 'open' + info['is_minimized'] = partner_channel.is_minimized + info['seen_message_id'] = partner_channel.seen_message_id.id + + # add members infos + partner_ids = channel_partners.mapped('partner_id').ids + info['members'] = [partner_info for partner_info in partner_infos if partner_info['id'] in partner_ids] + channel_infos.append(info) return channel_infos diff --git a/addons/mail/static/src/js/models/threads/channel.js b/addons/mail/static/src/js/models/threads/channel.js index ff90ca8edfb..f6839873ef7 100644 --- a/addons/mail/static/src/js/models/threads/channel.js +++ b/addons/mail/static/src/js/models/threads/channel.js @@ -29,6 +29,10 @@ var Channel = SearchableThread.extend(ThreadTypingMixin, { * @param {boolean} params.data.is_moderator whether the current user is * moderator of this channel. * @param {string} [params.data.last_message_date] date in server-format + * @param {Object[]} [params.data.members=[]] + * @param {integer} [params.data.members[i].id] + * @param {string} [params.data.members[i].name] + * @param {string} [params.data.members[i].email] * @param {integer} [params.data.message_unread_counter] * @param {boolean} [params.data.moderation=false] whether the channel is * moderated or not @@ -61,7 +65,7 @@ var Channel = SearchableThread.extend(ThreadTypingMixin, { this._isModerated = data.moderation; this._isMyselfModerator = data.is_moderator; this._lastMessageDate = undefined; - this._members = []; + this._members = data.members || []; // Deferred that is resolved on fetched members of this channel. this._membersDef = undefined; // number of messages that are 'needaction', which is equivalent to the From 7ad45a04e1a368f132fcc83bbe992ce0e98ed3d2 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Alexandre=20K=C3=BChn?= Date: Wed, 5 Dec 2018 14:35:02 +0000 Subject: [PATCH 03/11] [IMP] im_livechat, mail: improved "is typing" notification With this commit, the UI for "is typing..." notifications has changed. ** before **: In Discuss & chat windows, "is typing..." notification bar at the bottom of the thread, inside the conversation. ** after ** In chat windows, animated '...' next to conversation name. In Discuss, animated '...' next to conversation name in sidebar, and text below the composer. Task-ID 1881001 --- .../im_livechat/static/src/js/im_livechat.js | 2 +- .../static/src/js/composers/basic_composer.js | 21 + addons/mail/static/src/js/discuss.js | 28 +- .../thread_typing_mixin.js | 9 + .../src/js/services/mail_window_manager.js | 20 +- addons/mail/static/src/js/thread_widget.js | 28 -- .../thread_windows/abstract_thread_window.js | 18 +- .../src/js/thread_windows/thread_window.js | 17 +- .../src/scss/abstract_thread_window.scss | 12 +- addons/mail/static/src/scss/composer.scss | 5 + addons/mail/static/src/scss/discuss.scss | 7 +- addons/mail/static/src/scss/thread.scss | 57 ++- .../static/src/xml/abstract_thread_window.xml | 3 + addons/mail/static/src/xml/composer.xml | 8 + addons/mail/static/src/xml/discuss.xml | 4 + addons/mail/static/src/xml/thread.xml | 28 +- addons/mail/static/tests/discuss_tests.js | 4 + .../discuss_typing_notification_tests.js | 450 +++++++++++------- .../typing_thread_window_tests.js | 128 +++++ addons/mail/views/mail_templates.xml | 1 + 20 files changed, 556 insertions(+), 294 deletions(-) create mode 100644 addons/mail/static/tests/thread_window/typing_thread_window_tests.js diff --git a/addons/im_livechat/static/src/js/im_livechat.js b/addons/im_livechat/static/src/js/im_livechat.js index b8941eb4239..1445ba79eae 100644 --- a/addons/im_livechat/static/src/js/im_livechat.js +++ b/addons/im_livechat/static/src/js/im_livechat.js @@ -360,7 +360,7 @@ var LivechatButton = Widget.extend({ */ _onUpdatedTypingPartners: function (ev) { ev.stopPropagation(); - this._chatWindow.renderTypingNotificationBar(); + this._chatWindow.renderHeader(); }, /** * @private diff --git a/addons/mail/static/src/js/composers/basic_composer.js b/addons/mail/static/src/js/composers/basic_composer.js index c8f7d702af4..1a40e3f48fa 100644 --- a/addons/mail/static/src/js/composers/basic_composer.js +++ b/addons/mail/static/src/js/composers/basic_composer.js @@ -123,6 +123,9 @@ var BasicComposer = Widget.extend({ $(window).on(this.fileuploadID, this._onAttachmentLoaded.bind(this)); this.on('change:attachment_ids', this, this._renderAttachments); + this.call('mail_service', 'getMailBus') + .on('update_typing_partners', this, this._onUpdateTypingPartners); + // Mention this._mentionManager.prependTo(this.$('.o_composer')); @@ -793,6 +796,24 @@ var BasicComposer = Widget.extend({ this._mentionManager.detectDelimiter(); } }, + /** + * @private + * @param {integer|string} threadID + */ + _onUpdateTypingPartners: function (threadID) { + if (!this.options.showTyping) { + return; + } + if (!this.options.thread) { + return; + } + if (this.options.thread.getID() !== threadID) { + return; + } + this.$('.o_composer_thread_typing').html(QWeb.render('mail.Composer.ThreadTyping', { + thread: this.options.thread, + })); + }, }); return BasicComposer; diff --git a/addons/mail/static/src/js/discuss.js b/addons/mail/static/src/js/discuss.js index 2cb9fe749d7..dd03f08028c 100644 --- a/addons/mail/static/src/js/discuss.js +++ b/addons/mail/static/src/js/discuss.js @@ -235,12 +235,14 @@ var Discuss = AbstractAction.extend(ControlPanelMixin, { start: function () { var self = this; - this._basicComposer = new BasicComposer(this, - { mentionPartnersRestricted: true } - ); - this._extendedComposer = new ExtendedComposer(this, - { mentionPartnersRestricted: true } - ); + this._basicComposer = new BasicComposer(this, { + mentionPartnersRestricted: true, + showTyping: true, + }); + this._extendedComposer = new ExtendedComposer(this, { + mentionPartnersRestricted: true, + showTyping: true, + }); this._basicComposer .on('post_message', this, this._onPostMessage) .on('input_focused', this, this._onComposerFocused); @@ -1391,19 +1393,9 @@ var Discuss = AbstractAction.extend(ControlPanelMixin, { }, /** * @private - * @param {integer|string} threadID */ - _onTypingPartnersUpdated: function (threadID) { - var self = this; - if (this._thread.getID() !== threadID) { - return; - } - if (this._thread.hasTypingNotification()) { - // call getMentionpartnerSuggestions in order to correctly fetch members - this._thread.getMentionPartnerSuggestions().then(function () { - self._threadWidget.renderTypingNotificationBar(self._thread); - }); - } + _onTypingPartnersUpdated: function () { + this._updateThreads(); }, /** * @private diff --git a/addons/mail/static/src/js/models/threads/thread_typing_mixin/thread_typing_mixin.js b/addons/mail/static/src/js/models/threads/thread_typing_mixin/thread_typing_mixin.js index fb1a3a91cd1..9a5b5abb4cc 100644 --- a/addons/mail/static/src/js/models/threads/thread_typing_mixin/thread_typing_mixin.js +++ b/addons/mail/static/src/js/models/threads/thread_typing_mixin/thread_typing_mixin.js @@ -147,6 +147,15 @@ var ThreadTypingMixin = { hasTypingNotification: function () { return true; }, + /** + * Tells if someone other than current user is typing something on this + * thread. + * + * @returns {boolean} + */ + isSomeoneTyping: function () { + return !(_.isEmpty(this._typingPartnerIDs)); + }, /** * Register someone that is currently typing something in this thread. * If this is the current user that is typing something, don't do anything diff --git a/addons/mail/static/src/js/services/mail_window_manager.js b/addons/mail/static/src/js/services/mail_window_manager.js index 79ea668e55e..64cf6bb88c9 100644 --- a/addons/mail/static/src/js/services/mail_window_manager.js +++ b/addons/mail/static/src/js/services/mail_window_manager.js @@ -329,8 +329,7 @@ MailManager.include({ .on('is_thread_bottom_visible', this, this._onIsThreadBottomVisible) .on('unsubscribe_from_channel', this, this._onUnsubscribeFromChannel) .on('update_thread_unread_counter', this, this._onUpdateThreadUnreadCounter) - .on('update_dm_presence', this, this._onUpdateDmPresence) - .on('update_typing_partners', this, this._onTypingPartnersUpdated); + .on('update_dm_presence', this, this._onUpdateDmPresence); core.bus.on('resize', this, _.debounce(this._repositionThreadWindows.bind(this), 100)); }, @@ -655,23 +654,6 @@ MailManager.include({ _onNewMessage: function (message) { this._updateThreadWindowsFromMessage(message, { keepBottom: true, passively: true }); }, - /** - * @private - * @param {integer|string} threadID - */ - _onTypingPartnersUpdated: function (threadID) { - var threadWindow = this._getThreadWindow(threadID); - if (!threadWindow) { - return; - } - var thread = this.getThread(threadID); - if (thread.isChannel()) { - // call getMentionpartnerSuggestions in order to correctly fetch members - thread.getMentionPartnerSuggestions().then(function () { - threadWindow.renderTypingNotificationBar(); - }); - } - }, /** * Close the thread window when unsusbscribe from a channel. * diff --git a/addons/mail/static/src/js/thread_widget.js b/addons/mail/static/src/js/thread_widget.js index e6da87c6dba..da74028903e 100644 --- a/addons/mail/static/src/js/thread_widget.js +++ b/addons/mail/static/src/js/thread_widget.js @@ -185,12 +185,6 @@ var ThreadWidget = Widget.extend({ dateFormat: time.getLangDatetimeFormat(), })); - // must be after mail.widget.Thread rendering, so that there is the - // DOM element for the 'is typing' notification bar - if (thread.hasTypingNotification()) { - this.renderTypingNotificationBar(thread); - } - _.each(messages, function (message) { var $message = self.$('.o_thread_message[data-message-id="'+ message.getID() +'"]'); $message.find('.o_mail_timestamp').data('date', message.getDate()); @@ -253,28 +247,6 @@ var ThreadWidget = Widget.extend({ }); return done; }, - /** - * Render the 'is typing...' text on the typing notification bar of the - * thread. This is called when there is a change in the list of users - * typing something on this thread. - * - * @param {mail.model.AbstractThread} thread with ThreadTypingMixin - */ - renderTypingNotificationBar: function (thread) { - if (this._currentThreadID === thread.getID()) { - var shouldScrollToBottomAfterRendering = this.isAtBottom(); - - // typing notification bar rendering - var $typingBar = this.$('.o_thread_typing_notification_bar'); - var text = thread.getTypingMembersToText(); - $typingBar.toggleClass('o_hidden', !text); // hide if no text, because of padding - $typingBar.text(text); - - if (shouldScrollToBottomAfterRendering) { - this.scrollToBottom(); - } - } - }, /** * Scroll to the bottom of the thread */ diff --git a/addons/mail/static/src/js/thread_windows/abstract_thread_window.js b/addons/mail/static/src/js/thread_windows/abstract_thread_window.js index f4bf9533ff9..3e4e2e16325 100644 --- a/addons/mail/static/src/js/thread_windows/abstract_thread_window.js +++ b/addons/mail/static/src/js/thread_windows/abstract_thread_window.js @@ -138,6 +138,15 @@ var AbstractThreadWindow = Widget.extend({ getID: function () { return this._getThreadID(); }, + /** + * @returns {mail.model.Thread|undefined} + */ + getThread: function () { + if (!this.hasThread) { + return undefined; + } + return this._thread; + }, /** * Get the status of the thread, such as the im status of a DM chat * ('online', 'offline', etc.). If this window has no thread, returns @@ -256,14 +265,6 @@ var AbstractThreadWindow = Widget.extend({ this.$header.html( QWeb.render('mail.AbstractThreadWindow.HeaderContent', options)); }, - /** - * Render the 'is typing...' notification bar text on the thread in this - * thread window. This is called when there is a change in the list of users - * currently typing something on this thread. - */ - renderTypingNotificationBar: function () { - this._threadWidget.renderTypingNotificationBar(this._thread); - }, /** * Scroll to the bottom of the thread in the thread window */ @@ -339,6 +340,7 @@ var AbstractThreadWindow = Widget.extend({ _getHeaderRenderingOptions: function () { return { status: this.getThreadStatus(), + thread: this.getThread(), title: this.getTitle(), unreadCounter: this.getUnreadCounter(), widget: this, diff --git a/addons/mail/static/src/js/thread_windows/thread_window.js b/addons/mail/static/src/js/thread_windows/thread_window.js index 62468e9ea16..f4b0137c476 100644 --- a/addons/mail/static/src/js/thread_windows/thread_window.js +++ b/addons/mail/static/src/js/thread_windows/thread_window.js @@ -55,6 +55,9 @@ var ThreadWindow = AbstractThreadWindow.extend({ var superDef = this._super().then(this._listenThreadWidget.bind(this)); + this.call('mail_service', 'getMailBus') + .on('update_typing_partners', this, this._onUpdateTypingPartners); + var composerDef; if (!this.hasThread()) { this._startWithoutThread(); @@ -353,7 +356,19 @@ var ThreadWindow = AbstractThreadWindow.extend({ var message = this.call('mail_service', 'getMessage', messageID); message.toggleStarStatus(); }, - + /** + * @private + * @param {integer|string} threadID + */ + _onUpdateTypingPartners: function (threadID) { + if (!this.hasThread()) { + return; + } + if (this._thread.getID() !== threadID) { + return; + } + this.renderHeader(); + }, }); return ThreadWindow; diff --git a/addons/mail/static/src/scss/abstract_thread_window.scss b/addons/mail/static/src/scss/abstract_thread_window.scss index a4ad6d366cc..b148afa111f 100644 --- a/addons/mail/static/src/scss/abstract_thread_window.scss +++ b/addons/mail/static/src/scss/abstract_thread_window.scss @@ -57,6 +57,14 @@ $o-mail-thread-window-zindex: $zindex-modal + 1 !default; cursor: pointer; flex: 1 1 auto; @include o-text-overflow; + + .o_mail_thread_typing_icon { + padding-left: 2px; + + .o_mail_thread_typing_icon_dot { + background: gray('300'); + } + } } .o_thread_window_buttons { @@ -76,10 +84,6 @@ $o-mail-thread-window-zindex: $zindex-modal + 1 !default; overflow: auto; -webkit-overflow-scrolling: touch; // smooth scrolling in iOS app (Safari) - .o_thread_typing_notification_bar { - background-color: rgba($o-mail-thread-window-bg, 0.75); - } - .o_thread_date_separator { margin: 0px 0px 15px 0px; .o_thread_date { diff --git a/addons/mail/static/src/scss/composer.scss b/addons/mail/static/src/scss/composer.scss index e076629f667..f12e195abe9 100644 --- a/addons/mail/static/src/scss/composer.scss +++ b/addons/mail/static/src/scss/composer.scss @@ -184,6 +184,11 @@ flex: 1 0 100%; } + .o_composer_thread_typing { + font-size: 0.9em; + color: gray('600'); + } + .o_chatter_composer_tools { border: none; border-left: 1px solid gray('300'); diff --git a/addons/mail/static/src/scss/discuss.scss b/addons/mail/static/src/scss/discuss.scss index 8d328b028bf..39c30aefa05 100644 --- a/addons/mail/static/src/scss/discuss.scss +++ b/addons/mail/static/src/scss/discuss.scss @@ -44,6 +44,9 @@ flex: 1 1 auto; @include o-text-overflow; } + .o_mail_thread_typing_icon_dot { + background: gray('600');; + } .o_mail_sidebar_needaction, .o_mail_partner_unpin, .o_mail_channel_settings { flex: 0 0 auto; } @@ -129,7 +132,7 @@ background-color: darken(gray('900'),2%); .o_mail_partner_unpin, .o_mail_channel_settings { - opacity: $o-mail-sidebar-icon-opacity; + display: inline-block; } } @@ -143,7 +146,7 @@ } .o_mail_partner_unpin, .o_mail_channel_settings { - @include o-hover-opacity($default-opacity: 0, $hover-opacity: 1); + display: none; } &.o_unread_message { color: white; diff --git a/addons/mail/static/src/scss/thread.scss b/addons/mail/static/src/scss/thread.scss index 81f6862ca9f..a56f54bad78 100644 --- a/addons/mail/static/src/scss/thread.scss +++ b/addons/mail/static/src/scss/thread.scss @@ -13,26 +13,6 @@ height: 15px; } - .o_thread_typing_notification_free_space { - flex-grow: 1, - } - - .o_thread_typing_notification_bar { - flex: 0, 0, 20px; - background-color: rgba($white, 0.75); - padding: 5px; - text-align: center; - color: gray('600'); - - &.o_thread_order_asc { - @include o-position-sticky($bottom: 0px); - } - - &.o_thread_order_desc { - @include o-position-sticky($top: 0px); - } - } - .o_thread_tooltip_container { display: inline; position: relative; @@ -239,3 +219,40 @@ opacity: $o-mail-thread-icon-opacity; } } + +// ------------------------------------------------------------------ +// Thread typing icon: shared between discuss and chat windows +// ------------------------------------------------------------------ + +.o_mail_thread_typing_icon { + position: relative; + text-align: center; + margin-left: auto; + margin-right: auto; + + .o_mail_thread_typing_icon_dot { + display: inline-block; + width: 3px; + height: 3px; + border-radius: 50%; + background: gray('800'); + animation: o_mail_thread_typing_icon_dot 1.5s linear infinite; + + &:nth-child(2) { + animation-delay: -1.35s; + } + + &:nth-child(3) { + animation-delay: -1.2s; + } + } +} + +@keyframes o_mail_thread_typing_icon_dot { + 0%, 40%, 100% { + transform: initial; + } + 20% { + transform: translateY(-5px); + } +} diff --git a/addons/mail/static/src/xml/abstract_thread_window.xml b/addons/mail/static/src/xml/abstract_thread_window.xml index de0418af3df..89c45323c7a 100644 --- a/addons/mail/static/src/xml/abstract_thread_window.xml +++ b/addons/mail/static/src/xml/abstract_thread_window.xml @@ -11,6 +11,7 @@ +
@@ -58,6 +59,7 @@ name of the document. @param {integer} [unreadCounter] the number of unread messages on the thread. + @param {mail.model.Thread|undefined} thread @param {Object} widget @param {function} widget.isMobile function without any param that states whether it should render for desktop or mobile screen. @@ -70,6 +72,7 @@ () + diff --git a/addons/mail/static/src/xml/composer.xml b/addons/mail/static/src/xml/composer.xml index 883c9752035..6147197cd17 100644 --- a/addons/mail/static/src/xml/composer.xml +++ b/addons/mail/static/src/xml/composer.xml @@ -24,6 +24,7 @@
+
@@ -40,6 +41,13 @@
+ + + + +
- + @@ -253,14 +244,10 @@ Chat
-
+
Channels
-
- - Private Channels -
@@ -272,8 +259,8 @@
- # - Add a channel + # + Add a channel Open chat
diff --git a/addons/mail/static/tests/discuss_mobile_tests.js b/addons/mail/static/tests/discuss_mobile_tests.js index 498c3fb42ef..19e4c8bdd7f 100644 --- a/addons/mail/static/tests/discuss_mobile_tests.js +++ b/addons/mail/static/tests/discuss_mobile_tests.js @@ -26,7 +26,7 @@ QUnit.test('mobile basic rendering', function (assert) { // /mail/client_action route is always called when the test suite is // launched), and we must wait for this RPC to be done before starting to // test the interface. This should be refactored to facilitate the testing. - assert.expect(19); + assert.expect(16); var done = assert.async(); createDiscuss({ @@ -35,6 +35,7 @@ QUnit.test('mobile basic rendering', function (assert) { params: {}, data: this.data, services: this.services, + debug: true, }).then(function (discuss) { // test basic rendering in mobile assert.containsOnce(discuss, '.o_mail_discuss_content .o_mail_no_content', @@ -49,7 +50,7 @@ QUnit.test('mobile basic rendering', function (assert) { "should be in inbox"); assert.hasClass($('.o_mail_discuss_button_dm_chat'),'d-none', "should have invisible button 'New Message'"); - assert.hasClass($('.o_mail_discuss_button_public'),'d-none', + assert.hasClass($('.o_mail_discuss_button_multi_user_channel'),'d-none', "should have invisible button 'New Channel'"); // move to 'Chat' tab @@ -62,29 +63,19 @@ QUnit.test('mobile basic rendering', function (assert) { "should have a button to open DM chat in 'Chat' tab"); assert.doesNotHaveClass($('.o_mail_discuss_button_dm_chat'), 'd-none', "should be visible in 'Chat' tab"); - assert.hasClass($('.o_mail_discuss_button_public'),'d-none', + assert.hasClass($('.o_mail_discuss_button_multi_user_channel'),'d-none', "should have invisible button 'New Channel' in 'Chat' tab"); testUtils.dom.click($('.o_mail_discuss_button_dm_chat')); assert.containsOnce(discuss, '.o_mail_add_thread input:visible', "should display the input to add a channel"); // move to 'Channels' tab - testUtils.dom.click(discuss.$('.o_mail_mobile_tab[data-type=public]')); + testUtils.dom.click(discuss.$('.o_mail_mobile_tab[data-type=multi_user_channel]')); assert.hasClass($('.o_mail_discuss_button_dm_chat'),'d-none', "should have invisible button 'New Message' in 'Channels' tab"); - assert.doesNotHaveClass($('.o_mail_discuss_button_public'), 'd-none', + assert.doesNotHaveClass($('.o_mail_discuss_button_multi_user_channel'), 'd-none', "should have visible button 'New Channel' in 'Channels' tab"); - testUtils.dom.click($('.o_mail_discuss_button_public')); - assert.containsOnce(discuss, '.o_mail_add_thread input:visible', - "should display the input to add a channel"); - - // move to Private Channels tab - testUtils.dom.click(discuss.$('.o_mail_mobile_tab[data-type=private]')); - assert.hasClass($('.o_mail_discuss_button_dm_chat'),'d-none', - "should have invisible button 'New Message' in 'Private Channels' tab"); - assert.doesNotHaveClass($('.o_mail_discuss_button_private'), 'd-none', - "should have invisible button 'New Channel' in 'Private Channels' tab"); - testUtils.dom.click($('.o_mail_discuss_button_private')); + testUtils.dom.click($('.o_mail_discuss_button_multi_user_channel')); assert.containsOnce(discuss, '.o_mail_add_thread input:visible', "should display the input to add a channel"); From 64f044761aa52bc4fb87821de415aef5a8fea0d9 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Alexandre=20K=C3=BChn?= Date: Wed, 5 Dec 2018 14:35:09 +0000 Subject: [PATCH 06/11] [IMP] bus, mail, mail_bot: improved native notification With this commit, no push notification is sent to user when to acknowledge that he just has accepted them. Also, on native push notifications, the Odoo Bot icon now has a transparent background. Task-ID 1881001 --- .../bus/static/src/js/services/bus_service.js | 2 +- .../static/src/img/odoobot_transparent.png | Bin 0 -> 4487 bytes .../mail_bot/static/src/js/mailbot_service.js | 32 +++++++++++------- .../static/src/js/systray_messaging_menu.js | 5 +-- .../tests/systray_messaging_menu_tests.js | 8 ++--- 5 files changed, 26 insertions(+), 21 deletions(-) create mode 100644 addons/mail/static/src/img/odoobot_transparent.png diff --git a/addons/bus/static/src/js/services/bus_service.js b/addons/bus/static/src/js/services/bus_service.js index 72ba8c1531b..36eb496270f 100644 --- a/addons/bus/static/src/js/services/bus_service.js +++ b/addons/bus/static/src/js/services/bus_service.js @@ -80,7 +80,7 @@ var BusService = CrossTab.extend(ServicesMixin, { * @param {function} [callback] if given callback will be called when user clicks on notification */ _sendNativeNotification: function (title, content, callback) { - var notification = new Notification(title, {body: content, icon: "/mail/static/src/img/odoobot.png"}); + var notification = new Notification(title, {body: content, icon: "/mail/static/src/img/odoobot_transparent.png"}); notification.onclick = function () { window.focus(); if (this.cancel) { diff --git a/addons/mail/static/src/img/odoobot_transparent.png b/addons/mail/static/src/img/odoobot_transparent.png new file mode 100644 index 0000000000000000000000000000000000000000..e8c5a4dada888ad64758755c83d5da9293036510 GIT binary patch literal 4487 zcmY*dcRU+_nY7E%$zy%oaZ@b{+o%>)mEV(Wg!Is02FGfO8U2}*&ib&yqzV>QA~yCOwhkh`ZqIJf z0Dz1y_||oEK-vI(-CW%fVBf#E{ziar{Xbz47w~Tg(&aBMQ!QPfqKBsg@S(^95m7E# zQXmj0<7w{*)>l&gH~n_^7Z(hPdoM{xf`^1nPv4hTC>_%kHj!yWjC*T&Yv3;7op*B_z(jDPAx!X5ul$sO@;Tel5@ z{+xisL_|UV;l53k`4a`}c)}fSmH+U|ipl(q{C}~3b!0$)#Q&?9e>(j;dfTcjsSM~p z*CtD<*~1S70H_All;jP4aSklW3=OAPWSugZ9U=J%u@|`XhMHm;ZTBA^Hx8>%jS33M zyFBSoXTb0d*;OJ^GIosKRcuYArr}y`7dn=Fu`q?7S*V9w$>UJmp*E!Ula%wZ z%~`0T-SBFsXt+u$*VyF7kK`B-tyH$oB*a43x@w$c2>tU6%JM*R;}(@RQi)BC)n_6z zB_T`AFVF#YPc~tpG>&*EP{?JDzNGUZf)ENwcQW0FOALKqt%a`*id z8b5TLFu(?~cZHw0jNhebH#x)V0A(1MInz3zM5-%Q5^;Z+U`h{c4YaH^3y^)V$6wE^ z##l8CEVORJp}0nA8kIc-68CiS&VC|L3ZIGE9^h#*@tF{TAPRF!(F*4TsB=^aab0}P z(>LCWQy$g@GH<(9(>Mz`C7lL-)>^;lTOE{`7Zwx{(EDszg(w%O*f;mwx;m{Wn-rIaN9g#v4rB-FZ;lSl;luV{v5|R6 zGlh0f<))^}IZu>f`O#bXX$w?Vg!7S-^Ka@m>86Je?$-Zn)--9bBzS!!O+8 z>64(5*RYdH;H!u?dQsAJ_NRQFmWI%EQ39ddOjxVW2ZxqaKN5|PBcFKZKNqF+uJgUC z+-GVhjntERqmXfk4Vs+;#LczX9Os}}T^ep~gO{II{<188{m{@H=I>x$L{u zK=jksdsZTS-8oz6CEyEpT0&IJ(mE8seqHK%T{oFQCDE!wVl^XRz739nN8(TF#s2Ko z=UU?^Ji6&hCPx|@Gwer@LW8l^Jj9yrb|JPE$BoiYpR-&Z6{(1cPN|;YVH!AA=(N|G zFd7U(my$k&^nC8={+G@e#eOD-JsS9MssKfEY>6aVkKvIv>d3r?2PdJERcXV!;U3%* z4{s%scSx{0JIP;(L6KlZRd$HkIO!m=rKy-Cf(K>vl>&_tr+A~#sV47{oK(R4q*+&4TPsoFzo|V3@kkLaNdNr#r;wNwJ&sx z{22X|W9cF|QV=@Ry9N2uyP{V4@=7lH%&{pcJ0P1*`6yLfUQkR6{#q=qHA2yP9eZ)~zGI;tFg~RAxxGER10vO$L zihxB$4@4-wMk;lj>vB{yB8io~$vnTC8}OJrjAq81#6X5wJf!2&VSFbdGH(1~#`V(Q z%j6|k9FVI(`033TffC;rZvWSlPOzf+{>g1-MZrnLgV(NUGC8iBVFoRpy>)@ z;Mc4?xTD*C&NK4*7fu*OgvskGemt%F$NU)cRvj4W9U7SN0Ch$RhIIeuGEGQ}c*lrv zb~HqSQV8AwGgK0BN!|7{DIW*7>#BHWnPOdZZ&>s>i`R3`9t7!ZWPSHbDwxqqURFXu zeVlAL4f)!54N_9i48iQHsGkGU;G9S%vuN5-kycA3%One_r)yIRlLae;<3qZO8wFqU z`6KmWAde#`2-1BSN+dgdR%nYpkZ=$RD3R{ga3LA2;|dzMir z;vVMS+9GP*-ePg(AXeEgK(m_KYEIm(w78E|>1K>u3X(0u=04P)`KpIC!xfL$K2=X| zx;|e<3hBHFi2oX4N0K#^tbiWye@>YP*3pPJ-(zO>S`V8q%6rg0episTrCi66xVX$LZNwE$Q&ba6HHz{SlVUBT2T6 zn6@w7I48E~%Gos1(>kAY`LAO^yvEe<(vItg;eF^&b`r^y5S|wzR|HctQ)xKFf{13; z-N`4DDrtcj=KKJlZC9wj&&(BI`u;|kf-zhr$#yt=~#1{yS%-mXp`5ScF7 zaACu2mJ#?C509b{W9l$ox!$WE0r?Jzf|jX%f=%tuN3%mIku(PHJa~duhyRM<)n|87 z6J~KyBbm|0Q<3AJ+TKAQe}f;YK4x?1c(HkZ$VtQMs!;n-Q;?~Mm!#n^+NWFQIF`5_WMeEFHmCuZ%CNs z2ig{ITt;3j1YTD03%)SyRrHdt%EDxIwdH6_@xngEw|6oZ%SI@3M&2$sRXnc~p0Vf1*0T^}k`wEkuV@v= zgnT(Mp~|IPSCu1#r>Eiap=36t;K&^9E?L}&>To(xg&r<}Et8T`XamTSH>a469L8(k z_}*z;J6~(mZuj094ib%%+-BB_oa?vE96k_TG7o4V!J7JcHb0_u3~u9LnGh(jldgAI zj($()H!H59UOQmB@#`ek1vCMHfqV%p6njnpbFjmL68wDp2i8En2M3W?_~E-N782O7UI%)>)Bc zqm&fIff?9#j)M1}UtYY`nKE3Oh2&^Of7Yjp+8OM3tR7-qg=*DFVPQk*y45BL3g-{) z%?Z|d=b(j#6_5NV&MtcbK4Zx^Ta(OPnKZ6ZjY+(ctOl%zmSmqG)w%wO03(HyH>DHjZ#ZTGk`UXHkFT4tj0s3C&#g}QHO0l_lcHA{YCV+xMqV+Xy)NzWSyg;w?yIbm(ar8G zGOXbwQ>fhTmKYG^>|$8eBF@%-s1IrKW+{yhtqx60NPD~pdMY$=L|8%>Z$5@y9kQ>~ z6%?&oV8?qf#l9OflLf)Z3RSNE4%KooKREyIcXJ5aEkV}}4n?7q2ShK!=g;qm+7oT1 JY6a`C{{iV`RagK3 literal 0 HcmV?d00001 diff --git a/addons/mail_bot/static/src/js/mailbot_service.js b/addons/mail_bot/static/src/js/mailbot_service.js index a75780ab090..8863ea94ba0 100644 --- a/addons/mail_bot/static/src/js/mailbot_service.js +++ b/addons/mail_bot/static/src/js/mailbot_service.js @@ -23,6 +23,11 @@ var MailBotService = AbstractService.extend({ }, 2*60*1000); } }, + + //-------------------------------------------------------------------------- + // Public + //-------------------------------------------------------------------------- + /** * Get the previews related to the OdooBot (conversation not included). * For instance, when there is no conversation with OdooBot and OdooBot has @@ -33,25 +38,28 @@ var MailBotService = AbstractService.extend({ * 'mail.Preview' template. */ getPreviews: function (filter) { - var previews = []; - if (this.hasRequest() && (filter === 'mailbox_inbox' || !filter)) { - previews.push({ - title: _t("OdooBot has a request"), - imageSRC: "/mail/static/src/img/odoobot.png", - status: 'bot', - body: _t("Enable desktop notifications to chat"), - id: 'request_notification', - unreadCounter: 1, - }); + if (!this.isRequestingForNativeNotifications()) { + return []; } + if (filter && filter !== 'mailbox_inbox') { + return []; + } + var previews = [{ + title: _t("OdooBot has a request"), + imageSRC: "/mail/static/src/img/odoobot.png", + status: 'bot', + body: _t("Enable desktop notifications to chat"), + id: 'request_notification', + unreadCounter: 1, + }]; return previews; }, /** - * Tell whether OdooBot has a request or not. + * Tell whether OdooBot is requesting to enable push notifications. * * @returns {boolean} */ - hasRequest: function () { + isRequestingForNativeNotifications: function () { return window.Notification && window.Notification.permission === "default"; }, }); diff --git a/addons/mail_bot/static/src/js/systray_messaging_menu.js b/addons/mail_bot/static/src/js/systray_messaging_menu.js index 05c0ee54f24..f7e64ff751e 100644 --- a/addons/mail_bot/static/src/js/systray_messaging_menu.js +++ b/addons/mail_bot/static/src/js/systray_messaging_menu.js @@ -22,7 +22,7 @@ return MessagingMenu.include({ */ _computeCounter: function () { var counter = this._super.apply(this, arguments); - if (this.call('mailbot_service', 'hasRequest')) { + if (this.call('mailbot_service', 'isRequestingForNativeNotifications')) { counter++; } return counter; @@ -59,9 +59,6 @@ return MessagingMenu.include({ if (value !== 'granted') { this.call('bus_service', 'sendNotification', _t('Permission denied'), _t('Odoo will not have the permission to send native notifications on this device.')); - } else { - this.call('bus_service', 'sendNotification', _t('Yay, push notifications are enabled!'), - _t('Odoo has now the permission to send you native notifications on this device.')); } this._updateCounter(); }, diff --git a/addons/mail_bot/static/tests/systray_messaging_menu_tests.js b/addons/mail_bot/static/tests/systray_messaging_menu_tests.js index c248871c2c9..88f5c161f66 100644 --- a/addons/mail_bot/static/tests/systray_messaging_menu_tests.js +++ b/addons/mail_bot/static/tests/systray_messaging_menu_tests.js @@ -27,7 +27,7 @@ QUnit.module('MessagingMenu', { // Patch mailbot_service so that it does do not do any RPC, and // the detection of push notifications permissions can be simulated in // the test cases. By default, shows 'OdooBot has a request' - this.hasMailbotRequest = true; + this.isMailbotRequesting = true; testUtils.mock.patch(MailBotService, { /** * @override @@ -37,8 +37,8 @@ QUnit.module('MessagingMenu', { * @override * @returns {boolean} */ - hasRequest: function () { - return self.hasMailbotRequest; + isRequestingForNativeNotifications: function () { + return self.isMailbotRequesting; }, }); @@ -82,7 +82,7 @@ QUnit.test('messaging menu widget: rendering with OdooBot has a request', functi QUnit.test('messaging menu widget: rendering without OdooBot has a request', function (assert) { assert.expect(3); - this.hasMailbotRequest = false; + this.isMailbotRequesting = false; var messagingMenu = new MessagingMenu(); testUtils.mock.addMockEnvironment(messagingMenu, { From ef1c342b7c827a4f9822d499beb31f439e967017 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Alexandre=20K=C3=BChn?= Date: Wed, 5 Dec 2018 14:35:11 +0000 Subject: [PATCH 07/11] [IMP] mail: put emoji in composer based on cursor position Before this commit, when selecting an emoji from the composer, the emoji was put at the end of the composer content. With this commit, the selected emoji is now put in the composer based on the position of the cursor. It also focuses the input and set the cursor just after the newly inserted emoji. Task-ID 1881001 --- .../static/src/js/composers/basic_composer.js | 24 +++++++- .../src/js/composers/mention_manager.js | 11 ++-- addons/mail/static/tests/discuss_tests.js | 60 +++++++++++++++++++ 3 files changed, 86 insertions(+), 9 deletions(-) diff --git a/addons/mail/static/src/js/composers/basic_composer.js b/addons/mail/static/src/js/composers/basic_composer.js index 1a40e3f48fa..09c521ad795 100644 --- a/addons/mail/static/src/js/composers/basic_composer.js +++ b/addons/mail/static/src/js/composers/basic_composer.js @@ -164,6 +164,15 @@ var BasicComposer = Widget.extend({ focus: function () { this.$input.focus(); }, + /** + * Get cursor position and selection + * + * @returns {Object} a current cursor position as { start: {integer}, end: {integer} } + */ + getSelectionPositions: function () { + var InputElement = this.$input.get(0); + return InputElement ? dom.getSelectionRange(InputElement) : { start: 0, end: 0 }; + }, /** * Get the state of the composer. * @@ -666,15 +675,24 @@ var BasicComposer = Widget.extend({ } }, /** - * Called when an emoji is clicked -> adds it in the , focuses the - * and closes the emoji panel. + * Called when an emoji is clicked from the emoji panel. + * Emoji is inserted in the composer based on the position of the cursor, + * and it automatically focuses the composer and set the cursor position + * just after the newly inserted emoji. * * @private * @param {Event} ev */ _onEmojiImageClick: function (ev) { - this.$input.val(this.$input.val() + " " + $(ev.currentTarget).data('emoji') + " "); + var cursorPosition = this.getSelectionPositions(); + var inputVal = this.$input.val(); + var leftSubstring = inputVal.substring(0, cursorPosition.start); + var rightSubstring = inputVal.substring(cursorPosition.end); + var newInputVal = [leftSubstring , $(ev.currentTarget).data('emoji'), rightSubstring].join(" "); + var newCursorPosition = newInputVal.length - rightSubstring.length; + this.$input.val(newInputVal); this.$input.focus(); + this.$input[0].setSelectionRange(newCursorPosition, newCursorPosition); this._hideEmojis(); }, /** diff --git a/addons/mail/static/src/js/composers/mention_manager.js b/addons/mail/static/src/js/composers/mention_manager.js index 907263328cc..44506046ec8 100644 --- a/addons/mail/static/src/js/composers/mention_manager.js +++ b/addons/mail/static/src/js/composers/mention_manager.js @@ -77,7 +77,7 @@ var MentionManager = Widget.extend({ detectDelimiter: function () { var self = this; var textVal = this._composer.$input.val(); - var cursorPosition = this._getSelectionPositions().start; + var cursorPosition = this._getComposerSelectionPositions().start; var leftString = textVal.substring(0, cursorPosition); function validateKeyword(delimiter, beginningOnly) { @@ -290,9 +290,8 @@ var MentionManager = Widget.extend({ * @private * @returns a current cursor position */ - _getSelectionPositions: function () { - var InputElement = this._composer.$input.get(0); - return InputElement ? dom.getSelectionRange(InputElement) : { start: 0, end: 0 }; + _getComposerSelectionPositions: function () { + return this._composer.getSelectionPositions(); }, /** * @private @@ -402,14 +401,14 @@ var MentionManager = Widget.extend({ if (this._activeListener.selection.length) { // get mention matches (ordered by index in the text) var matches = this._getMatch(textInput, this._activeListener); - var index = getMentionIndex(matches, this._getSelectionPositions().start); + var index = getMentionIndex(matches, this._getComposerSelectionPositions().start); this._activeListener.selection.splice(index, 0, selectedSuggestion); } else { this._activeListener.selection.push(selectedSuggestion); } // update input text, and reset dropdown - var cursorPosition = this._getSelectionPositions().start; + var cursorPosition = this._getComposerSelectionPositions().start; var textLeft = textInput.substring(0, cursorPosition-(this._mentionWord.length+1)); var textRight = textInput.substring(cursorPosition, textInput.length); var textInputNew = textLeft + substitution + ' ' + textRight; diff --git a/addons/mail/static/tests/discuss_tests.js b/addons/mail/static/tests/discuss_tests.js index ac1df714ea6..c8de7ae5450 100644 --- a/addons/mail/static/tests/discuss_tests.js +++ b/addons/mail/static/tests/discuss_tests.js @@ -1338,5 +1338,65 @@ QUnit.test('quick search channels in the sidebar with more than 20 channels', fu }); }); +QUnit.test('select emoji replaces cursor position', function (assert) { + assert.expect(5); + var done = assert.async(); + + this.data.initMessaging = { + channel_slots: { + channel_channel: [{ + id: 1, + channel_type: "channel", + name: "general", + }], + }, + }; + + createDiscuss({ + id: 1, + context: {}, + params: {}, + data: this.data, + services: this.services, + }) + .then(function (discuss) { + var $general = discuss.$('.o_mail_discuss_sidebar') + .find('.o_mail_discuss_item[data-thread-id=1]'); + assert.strictEqual($general.length, 1, + "should have the channel item with id 1"); + assert.hasAttrValue($general, 'title', 'general', + "should have the title 'general'"); + + // click on general + testUtils.dom.click($general); + + // general uses basic composer, so this is the 1st composer + // (2nd composer is hidden) + var $composer = discuss.$('.o_composer_text_field').first(); + + testUtils.fields.editInput($composer, 'abcdefgh'); + + assert.strictEqual($composer.val(), "abcdefgh"); + + // cursor position: ab|cd|efgh (selecting c and d) + $composer[0].setSelectionRange(2, 4); + + testUtils.dom.click(discuss.$('.o_composer_button_emoji')); + testUtils.dom.click(discuss.$('.o_mail_emoji[data-emoji=":)"]')); + + assert.strictEqual($composer.val(), "ab :) efgh", + "should have inserted emoji "); + + testUtils.dom.click(discuss.$('.o_composer_button_emoji')); + testUtils.dom.click(discuss.$('.o_mail_emoji[data-emoji=":)"]')); + + assert.strictEqual($composer.val(), "ab :) :) efgh", + "should have inserted emoji after previously inserted emoji"); + + discuss.destroy(); + done(); + }); +}); + }); }); From f372ac541f6a90a741253acdfd35cf5f2cc97696 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Alexandre=20K=C3=BChn?= Date: Wed, 5 Dec 2018 14:35:19 +0000 Subject: [PATCH 08/11] [IMP] mail: rename DM conversation This commit adds the possibility to customize the name of a DM conversation. To do so, click on the gear icon when hovering on a DM in the discuss sidebar. This feature intentionally removes the access to the settings of a DM, because the settings view only makes sense for non-DM conversations. Task-ID 1881001 --- addons/mail/models/mail_channel.py | 10 ++ addons/mail/static/src/js/discuss.js | 74 ++++++++++++- .../static/src/js/models/threads/dm_chat.js | 4 +- .../src/js/models/threads/document_thread.js | 11 -- .../static/src/js/models/threads/thread.js | 8 ++ addons/mail/static/src/xml/discuss.xml | 5 + addons/mail/static/tests/discuss_tests.js | 101 ++++++++++++++++++ 7 files changed, 200 insertions(+), 13 deletions(-) diff --git a/addons/mail/models/mail_channel.py b/addons/mail/models/mail_channel.py index 778ee09d4c4..e17b134c38a 100644 --- a/addons/mail/models/mail_channel.py +++ b/addons/mail/models/mail_channel.py @@ -24,6 +24,7 @@ class ChannelPartner(models.Model): _table = 'mail_channel_partner' _rec_name = 'partner_id' + custom_channel_name = fields.Char('Custom channel name') partner_id = fields.Many2one('res.partner', string='Recipient', ondelete='cascade') partner_email = fields.Char('Email', related='partner_id.email', readonly=False) channel_id = fields.Many2one('mail.channel', string='Channel', ondelete='cascade') @@ -593,6 +594,7 @@ class Channel(models.Model): info['state'] = partner_channel.fold_state or 'open' 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 # add members infos partner_ids = channel_partners.mapped('partner_id').ids @@ -743,6 +745,14 @@ class Channel(models.Model): # broadcast the channel header to the added partner self._broadcast(partner_ids) + @api.model + def channel_set_custom_name(self, channel_id, name=False): + domain = [('partner_id', '=', self.env.user.partner_id.id), ('channel_id.id', '=', channel_id)] + channel_partners = self.env['mail.channel.partner'].search(domain, limit=1) + channel_partners.write({ + 'custom_channel_name': name, + }) + @api.multi def notify_typing(self, is_typing, is_website_user=False): """ Broadcast the typing notification to channel members diff --git a/addons/mail/static/src/js/discuss.js b/addons/mail/static/src/js/discuss.js index a3dd72b4923..5a5bc9e106e 100644 --- a/addons/mail/static/src/js/discuss.js +++ b/addons/mail/static/src/js/discuss.js @@ -111,6 +111,72 @@ var PartnerInviteDialog = Dialog.extend({ }, }); +/** + * Widget : Rename Conversation Dialog + */ +var RenameConversationDialog = Dialog.extend({ + dialog_title: _t("Rename conversation"), + template: 'mail.RenameConversationDialog', + /** + * @override + * @param {integer|string} channelID id of the channel + * @param {function} callback to call when successfully renaming + * conversation. + */ + init: function (parent, channelID, callback) { + this._channelID = channelID; + this._callback = callback; + + this._super(parent, { + title: 'Rename conversation', + size: 'medium', + buttons: [{ + text: _t("Rename"), + close: true, + classes: 'btn-primary o_mail_conversation_rename', + click: this._rename.bind(this), + }, { + text: _t("Discard"), + close: true, + }], + }); + }, + /** + * @override + * @returns {$.Promise} + */ + start: function () { + var channel = this.call('mail_service', 'getChannel', this._channelID); + this.$('input').val(channel.getName()); + return this._super.apply(this, arguments); + }, + + //-------------------------------------------------------------------------- + // Private + //-------------------------------------------------------------------------- + + /** + * @private + * @returns {$.Promise} + */ + _rename: function () { + var self = this; + var name = this.$('input').val(); + return this._rpc({ + model: 'mail.channel', + method: 'channel_set_custom_name', + args: [this._channelID], + kwargs: { + name: name, + } + }).then(function (updatedName) { + var channel = self.call('mail_service', 'getThread', self._channelID); + channel.setName(updatedName); + self._callback(); + }); + }, +}); + /** * Widget : Moderator reject message dialog * @@ -1223,7 +1289,13 @@ var Discuss = AbstractAction.extend(ControlPanelMixin, { * @param {MouseEvent} ev */ _onChannelSettingsClicked: function (ev) { - var threadID = $(ev.target).data('thread-id'); + ev.stopPropagation(); + var threadID = $(ev.currentTarget).data('thread-id'); + var thread = this.call('mail_service', 'getThread', threadID); + if (thread.getType() === 'dm_chat') { + new RenameConversationDialog(this, threadID, this._updateThreads.bind(this)).open(); + return; + } this.do_action({ type: 'ir.actions.act_window', res_model: 'mail.channel', diff --git a/addons/mail/static/src/js/models/threads/dm_chat.js b/addons/mail/static/src/js/models/threads/dm_chat.js index 767a6e32edd..5e69dbeff8c 100644 --- a/addons/mail/static/src/js/models/threads/dm_chat.js +++ b/addons/mail/static/src/js/models/threads/dm_chat.js @@ -12,6 +12,8 @@ var DMChat = TwoUserChannel.extend({ * @override * @param {Object} params * @param {Object} params.data + * @param {string|undefined} [params.custom_channel_name] if set, use this + * custom name for this DM * @param {Object[]} params.data.direct_partner * @param {integer} params.data.direct_partner[0].id * @param {string} params.data.direct_partner[0].im_status @@ -23,7 +25,7 @@ var DMChat = TwoUserChannel.extend({ var data = params.data; this._directPartnerID = data.direct_partner[0].id; - this._name = data.direct_partner[0].name; + this._name = data.custom_channel_name || data.direct_partner[0].name; this._status = data.direct_partner[0].im_status; this._type = 'dm_chat'; }, diff --git a/addons/mail/static/src/js/models/threads/document_thread.js b/addons/mail/static/src/js/models/threads/document_thread.js index f90da991960..d902d69765c 100644 --- a/addons/mail/static/src/js/models/threads/document_thread.js +++ b/addons/mail/static/src/js/models/threads/document_thread.js @@ -168,17 +168,6 @@ var DocumentThread = Thread.extend({ this._mustFetchMessageIDs = false; this._messageIDs = messageIDs; }, - /** - * Set the name of this document thread - * - * This is useful if the name of the document related to the document thread - * has changed - * - * @param {string} newName - */ - setName: function (newName) { - this._name = newName; - }, //-------------------------------------------------------------------------- // Private diff --git a/addons/mail/static/src/js/models/threads/thread.js b/addons/mail/static/src/js/models/threads/thread.js index 8c2beb8bbfa..068e69b9c32 100644 --- a/addons/mail/static/src/js/models/threads/thread.js +++ b/addons/mail/static/src/js/models/threads/thread.js @@ -271,6 +271,14 @@ var Thread = AbstractThread.extend(ServicesMixin, { markAsPreviewed: function () { this._previewed = true; }, + /** + * Set the name of this thread + * + * @param {string} newName + */ + setName: function (newName) { + this._name = newName; + }, //-------------------------------------------------------------------------- // Private diff --git a/addons/mail/static/src/xml/discuss.xml b/addons/mail/static/src/xml/discuss.xml index 90084a886bd..98430c81ce1 100644 --- a/addons/mail/static/src/xml/discuss.xml +++ b/addons/mail/static/src/xml/discuss.xml @@ -218,6 +218,11 @@
+ +
+ +
+
diff --git a/addons/mail/static/tests/discuss_tests.js b/addons/mail/static/tests/discuss_tests.js index c8de7ae5450..993cc1581d2 100644 --- a/addons/mail/static/tests/discuss_tests.js +++ b/addons/mail/static/tests/discuss_tests.js @@ -1398,5 +1398,106 @@ QUnit.test('select emoji replaces cursor position', function (assert) { }); }); +QUnit.test('rename DM conversation', function (assert) { + assert.expect(11); + var done = assert.async(); + + this.data.initMessaging = { + channel_slots: { + channel_direct_message: [{ + id: 1, + channel_type: "chat", + create_uid: 3, + direct_partner: [{ + id: 7, + im_status: 'online', + name: 'Demo User', + }], + }], + }, + }; + + createDiscuss({ + id: 1, + context: {}, + params: {}, + data: this.data, + services: this.services, + session: { partner_id: 3 }, + mockRPC: function (route, args) { + if (args.method === 'channel_set_custom_name') { + assert.step(args.method); + assert.strictEqual(args.args[0], 1); + assert.strictEqual(args.kwargs.name, "Demo"); + return $.when("Demo"); + } + return this._super.apply(this, arguments); + }, + }) + .then(function (discuss) { + var $dm = discuss.$('.o_mail_discuss_item[data-thread-id=1]'); + assert.isVisible($dm, "should display DM in the discuss sidebar"); + assert.strictEqual($dm.find('.o_thread_name').text().trim(), "Demo User"); + + // The settings icon is only shown when hovering on sidebar item. + // helpter dom.click needs the element to be visible, hence the + // hack on display of this icon. + discuss.$('.o_mail_channel_settings').css('display', 'block'); + testUtils.dom.click(discuss.$('.o_mail_channel_settings')); + + assert.isVisible($('.modal-dialog')); + assert.strictEqual($('.modal-title').text(), "Rename conversation"); + assert.isVisible($('.o_mail_discuss_rename_channel_input')); + assert.strictEqual($('.o_mail_discuss_rename_channel_input').val(), "Demo User"); + + testUtils.fields.editInput($('.o_mail_discuss_rename_channel_input'), "Demo"); + testUtils.dom.click($('.o_mail_conversation_rename')); + $dm = discuss.$('.o_mail_discuss_item[data-thread-id=1]'); + assert.verifySteps(['channel_set_custom_name']); + assert.strictEqual($dm.find('.o_thread_name').text().trim(), "Demo"); + + discuss.destroy(); + done(); + }); +}); + +QUnit.test('custom-named DM conversation', function (assert) { + assert.expect(2); + var done = assert.async(); + + this.data.initMessaging = { + channel_slots: { + channel_direct_message: [{ + id: 1, + channel_type: "chat", + create_uid: 3, + direct_partner: [{ + id: 7, + im_status: 'online', + name: 'Demo User', + }], + custom_channel_name: 'My Buddy', + }], + }, + }; + + createDiscuss({ + id: 1, + context: {}, + params: {}, + data: this.data, + services: this.services, + session: { partner_id: 3 }, + }) + .then(function (discuss) { + var $dm = discuss.$('.o_mail_discuss_item[data-thread-id=1]'); + assert.isVisible($dm, "should display DM in the discuss sidebar"); + assert.strictEqual($dm.find('.o_thread_name').text().trim(), "My Buddy"); + + discuss.destroy(); + done(); + }); +}); + }); }); From 50dfa865ea32c88d55e7eb0fce6a6ae978c9c3b4 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Alexandre=20K=C3=BChn?= Date: Wed, 5 Dec 2018 14:35:21 +0000 Subject: [PATCH 09/11] [IMP] mail: discard replying to a message in discuss This commit adds a button to discard replying to a message from the Discuss inbox. Task-ID 1881001 --- .../src/js/composers/extended_composer.js | 38 +++++++++++ addons/mail/static/src/js/discuss.js | 11 +++ addons/mail/static/src/scss/composer.scss | 5 ++ addons/mail/static/src/xml/composer.xml | 1 + addons/mail/static/src/xml/thread.xml | 2 +- addons/mail/static/tests/discuss_tests.js | 67 +++++++++++++++++++ 6 files changed, 123 insertions(+), 1 deletion(-) diff --git a/addons/mail/static/src/js/composers/extended_composer.js b/addons/mail/static/src/js/composers/extended_composer.js index f6e8560da8f..917b71fa880 100644 --- a/addons/mail/static/src/js/composers/extended_composer.js +++ b/addons/mail/static/src/js/composers/extended_composer.js @@ -3,7 +3,14 @@ odoo.define('mail.composer.Extended', function (require) { var BasicComposer = require('mail.composer.Basic'); +var core = require('web.core'); + +var QWeb = core.qweb; + var ExtendedComposer = BasicComposer.extend({ + events: _.extend({}, BasicComposer.prototype.events, { + 'click .o_composer_button_discard': '_onClickDiscard', + }), /** * @override */ @@ -67,6 +74,26 @@ var ExtendedComposer = BasicComposer.extend({ setSubject: function (subject) { this.$('.o_composer_subject input').val(subject); }, + /** + * Show the button 'discard' next to the send button. This is useful while + * replying to a message, when in the end we do not want to reply. + */ + showDiscardButton: function () { + var $discard = this.$('.o_composer_button_discard'); + $discard.removeClass('d-none'); + $discard.addClass('d-md-inline-block'); + }, + /** + * Hide the button 'discard' next to the send button. This is useful when + * we were replying to a message from a certain thread, then we discard it + * with some other actions (i.e. switch thread). The discard button only + * makes sense during the selection of a message. + */ + hideDiscardButton: function () { + var $discard = this.$('.o_composer_button_discard'); + $discard.removeClass('d-md-inline-block'); + $discard.addClass('d-none'); + }, //-------------------------------------------------------------------------- // Private @@ -93,6 +120,17 @@ var ExtendedComposer = BasicComposer.extend({ _shouldSend: function () { return false; }, + + //-------------------------------------------------------------------------- + // Handlers + //-------------------------------------------------------------------------- + + /** + * @private + */ + _onClickDiscard: function () { + this.trigger_up('discard_extended_composer'); + }, }); return ExtendedComposer; diff --git a/addons/mail/static/src/js/discuss.js b/addons/mail/static/src/js/discuss.js index 5a5bc9e106e..dc2a1933be7 100644 --- a/addons/mail/static/src/js/discuss.js +++ b/addons/mail/static/src/js/discuss.js @@ -243,6 +243,7 @@ var ModeratorRejectMessageDialog = Dialog.extend({ var Discuss = AbstractAction.extend(ControlPanelMixin, { template: 'mail.discuss', custom_events: { + discard_extended_composer: '_onDiscardExtendedComposer', message_moderation: '_onMessageModeration', search: '_onSearch', update_moderation_buttons: '_onUpdateModerationButtons', @@ -927,6 +928,7 @@ var Discuss = AbstractAction.extend(ControlPanelMixin, { this._selectedMessage = message; var subject = "Re: " + message.getDocumentName(); this._extendedComposer.setSubject(subject); + this._extendedComposer.showDiscardButton(); if (this._thread.getType() !== 'mailbox') { this._basicComposer.do_hide(); @@ -1053,6 +1055,7 @@ var Discuss = AbstractAction.extend(ControlPanelMixin, { _unselectMessage: function () { this._basicComposer.do_toggle(this._thread.getType() !== 'mailbox' && !this._thread.isMassMailing()); this._extendedComposer.do_toggle(this._thread.isMassMailing()); + this._extendedComposer.hideDiscardButton(); if (!config.device.isMobile) { var composer = this._thread.getType() !== 'mailbox' && this._thread.isMassMailing() ? @@ -1314,6 +1317,14 @@ var Discuss = AbstractAction.extend(ControlPanelMixin, { composer.mentionSetCommands(commands); composer.mentionSetPrefetchedPartners(partners); }, + /** + * @private + * @param {OdooEvent} ev + */ + _onDiscardExtendedComposer: function (ev) { + ev.stopPropagation(); + this._unselectMessage(); + }, /** * When clicking on an item in the sidebar * diff --git a/addons/mail/static/src/scss/composer.scss b/addons/mail/static/src/scss/composer.scss index f12e195abe9..bd9307bdd2f 100644 --- a/addons/mail/static/src/scss/composer.scss +++ b/addons/mail/static/src/scss/composer.scss @@ -147,6 +147,11 @@ } .o_composer_send { margin-top: $o-mail-chatter-gap; + + > .o_composer_button_discard:not(:hover) { + background: theme-color('light'); + border-color: transparent; + } } } diff --git a/addons/mail/static/src/xml/composer.xml b/addons/mail/static/src/xml/composer.xml index 6147197cd17..ec95a6d5b61 100644 --- a/addons/mail/static/src/xml/composer.xml +++ b/addons/mail/static/src/xml/composer.xml @@ -28,6 +28,7 @@
+
diff --git a/addons/mail/static/src/xml/thread.xml b/addons/mail/static/src/xml/thread.xml index a1e2d52e2ad..748b9d4edc6 100644 --- a/addons/mail/static/src/xml/thread.xml +++ b/addons/mail/static/src/xml/thread.xml @@ -259,7 +259,7 @@ @param {integer} [options.selectedMessageID] --> -
+
test 1

', + id: 1, + needaction: true, + needaction_partner_ids: [3], + res_id: 100, + model: 'some.document', + record_name: 'SomeDocument', + }]; + this.data.initMessaging = { + needaction_inbox_counter: 1, + }; + + createDiscuss({ + id: 1, + context: {}, + params: {}, + data: this.data, + services: this.services, + session: { partner_id: 3 }, + mockRPC: function (route, args) { + if (args.method === 'message_post') { + assert.step(args.method); + assert.strictEqual(args.model, 'some.document', + "should post message to correct document model"); + assert.strictEqual(args.args[0], 100, + "should post message to correct document ID"); + + self.data['mail.message'].records.push({ + author_id: [3, 'Me'], + body: args.kwargs.body, + id: 2, + res_id: 100, + model: 'some.document', + record_name: 'SomeDocument', + }); + return $.when(2); + } + return this._super.apply(this, arguments); + }, + }) + .then(function (discuss) { + testUtils.dom.click(discuss.$('.o_thread_message_reply')); + assert.containsOnce(discuss, '.o_thread_selected_message', + "should have a message selected"); + + var $composer = discuss.$('.o_thread_composer_extended'); + assert.containsOnce($composer, '.o_composer_button_discard', + "should have button to discard replying to message"); + + testUtils.dom.click($composer.find('.o_composer_button_discard')); + assert.isNotVisible($composer, + "extended composer should become hidden on discard"); + assert.containsNone(discuss, '.o_thread_selected_message', + "should not longer have a message selected"); + + discuss.destroy(); + done(); + }); +}); + QUnit.test('no quick search channels in the sidebar with less than 20 channels', function (assert) { assert.expect(3); var done = assert.async(); From 15bbadb69b1b26404a614736c3cde7980d3c1dfd Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Alexandre=20K=C3=BChn?= Date: Wed, 5 Dec 2018 14:35:26 +0000 Subject: [PATCH 10/11] [REF] im_livechat, mail, website_livechat: clean mail model folder The utility classes `Timer`, `Timers` and `CCThrottleFunction` were only used by the feature 'is typing...', thus all those files were in the same folder. This commit moves the utility classes in a dedicated folder, and introduces a folder for thread mixins, in preparation for future features on threads that will be implemented as mixins. --- .../views/im_livechat_channel_templates.xml | 10 +++++----- .../thread_typing_mixin.js | 0 .../cc_throttle_function.js | 0 .../{threads/thread_typing_mixin => utils}/timer.js | 0 .../{threads/thread_typing_mixin => utils}/timers.js | 0 addons/mail/views/mail_templates.xml | 9 ++++----- addons/website_livechat/views/website_livechat.xml | 10 +++++----- 7 files changed, 14 insertions(+), 15 deletions(-) rename addons/mail/static/src/js/models/threads/{thread_typing_mixin => mixins}/thread_typing_mixin.js (100%) rename addons/mail/static/src/js/models/{threads/thread_typing_mixin => utils}/cc_throttle_function.js (100%) rename addons/mail/static/src/js/models/{threads/thread_typing_mixin => utils}/timer.js (100%) rename addons/mail/static/src/js/models/{threads/thread_typing_mixin => utils}/timers.js (100%) diff --git a/addons/im_livechat/views/im_livechat_channel_templates.xml b/addons/im_livechat/views/im_livechat_channel_templates.xml index f47a5c0a591..a736c33d3f0 100644 --- a/addons/im_livechat/views/im_livechat_channel_templates.xml +++ b/addons/im_livechat/views/im_livechat_channel_templates.xml @@ -166,14 +166,14 @@ - - - - - + + + + + diff --git a/addons/mail/static/src/js/models/threads/thread_typing_mixin/thread_typing_mixin.js b/addons/mail/static/src/js/models/threads/mixins/thread_typing_mixin.js similarity index 100% rename from addons/mail/static/src/js/models/threads/thread_typing_mixin/thread_typing_mixin.js rename to addons/mail/static/src/js/models/threads/mixins/thread_typing_mixin.js diff --git a/addons/mail/static/src/js/models/threads/thread_typing_mixin/cc_throttle_function.js b/addons/mail/static/src/js/models/utils/cc_throttle_function.js similarity index 100% rename from addons/mail/static/src/js/models/threads/thread_typing_mixin/cc_throttle_function.js rename to addons/mail/static/src/js/models/utils/cc_throttle_function.js diff --git a/addons/mail/static/src/js/models/threads/thread_typing_mixin/timer.js b/addons/mail/static/src/js/models/utils/timer.js similarity index 100% rename from addons/mail/static/src/js/models/threads/thread_typing_mixin/timer.js rename to addons/mail/static/src/js/models/utils/timer.js diff --git a/addons/mail/static/src/js/models/threads/thread_typing_mixin/timers.js b/addons/mail/static/src/js/models/utils/timers.js similarity index 100% rename from addons/mail/static/src/js/models/threads/thread_typing_mixin/timers.js rename to addons/mail/static/src/js/models/utils/timers.js diff --git a/addons/mail/views/mail_templates.xml b/addons/mail/views/mail_templates.xml index efe7657e192..b9fe174e470 100644 --- a/addons/mail/views/mail_templates.xml +++ b/addons/mail/views/mail_templates.xml @@ -35,11 +35,10 @@ - - - - - + + + + diff --git a/addons/website_livechat/views/website_livechat.xml b/addons/website_livechat/views/website_livechat.xml index a76f5503029..e5f429fc806 100644 --- a/addons/website_livechat/views/website_livechat.xml +++ b/addons/website_livechat/views/website_livechat.xml @@ -24,14 +24,14 @@ - - - - - + + + + + From 844fb2b51ea7cfa557b19466c0a53dde19433e7a Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Alexandre=20K=C3=BChn?= Date: Wed, 5 Dec 2018 14:35:29 +0000 Subject: [PATCH 11/11] [IMP] mail: channel seen icon indication ('v' and 'vv') This commit adds the seen icon next to a message for all channels except livechat. It tracks received and seen messages for all members of the channel. Icon is either 1 or 2 check(s) ('v' or 'vv'). Those are the different states of this 'seen' icon: ICON: DESCRIPTION: no check .................. no one has received the message. 1 check ................... some members have received the message, no one has seen it. 2 checks (greyed-out) ..... some members have seen the message, some or everyone has received it 2 checks (green) .......... all members have seen the message This icon is not shown for messages preceding the last message seen by everyone, in order to reduce visual noise. Task-ID 1881001 --- addons/mail/models/mail_channel.py | 36 +- addons/mail/static/src/js/discuss.js | 13 +- .../src/js/models/threads/abstract_thread.js | 10 + .../static/src/js/models/threads/channel.js | 63 +- .../threads/mixins/channel_seen_mixin.js | 265 ++++ .../js/services/mail_notification_manager.js | 68 +- addons/mail/static/src/js/thread_widget.js | 59 + .../src/js/thread_windows/thread_window.js | 13 +- addons/mail/static/src/scss/thread.scss | 41 + addons/mail/static/src/xml/thread.xml | 66 + .../tests/discuss_seen_indicator_tests.js | 1132 +++++++++++++++++ .../mail/static/tests/helpers/mock_server.js | 3 + .../systray/systray_messaging_menu_tests.js | 5 +- addons/mail/views/mail_templates.xml | 2 + 14 files changed, 1728 insertions(+), 48 deletions(-) create mode 100644 addons/mail/static/src/js/models/threads/mixins/channel_seen_mixin.js create mode 100644 addons/mail/static/tests/discuss_seen_indicator_tests.js diff --git a/addons/mail/models/mail_channel.py b/addons/mail/models/mail_channel.py index e17b134c38a..f7384a87b8f 100644 --- a/addons/mail/models/mail_channel.py +++ b/addons/mail/models/mail_channel.py @@ -28,6 +28,7 @@ class ChannelPartner(models.Model): partner_id = fields.Many2one('res.partner', string='Recipient', ondelete='cascade') partner_email = fields.Char('Email', related='partner_id.email', readonly=False) channel_id = fields.Many2one('mail.channel', string='Channel', ondelete='cascade') + fetched_message_id = fields.Many2one('mail.message', string='Last Fetched') seen_message_id = fields.Many2one('mail.message', string='Last Seen') fold_state = fields.Selection([('open', 'Open'), ('folded', 'Folded'), ('closed', 'Closed')], string='Conversation Fold State', default='open') is_minimized = fields.Boolean("Conversation is minimized") @@ -599,6 +600,11 @@ class Channel(models.Model): # add members infos partner_ids = channel_partners.mapped('partner_id').ids info['members'] = [partner_info for partner_info in partner_infos if partner_info['id'] in partner_ids] + info['seen_partners_info'] = [{ + 'partner_id': cp.partner_id.id, + 'fetched_message_id': cp.fetched_message_id.id, + 'seen_message_id': cp.seen_message_id.id, + } for cp in channel_partners] channel_infos.append(info) return channel_infos @@ -716,10 +722,36 @@ class Channel(models.Model): self.ensure_one() if self.channel_message_ids.ids: last_message_id = self.channel_message_ids.ids[0] # zero is the index of the last message - self.env['mail.channel.partner'].search([('channel_id', 'in', self.ids), ('partner_id', '=', self.env.user.partner_id.id)]).write({'seen_message_id': last_message_id}) - self.env['bus.bus'].sendone((self._cr.dbname, 'res.partner', self.env.user.partner_id.id), {'info': 'channel_seen', 'id': self.id, 'last_message_id': last_message_id}) + self.env['mail.channel.partner'].search([('channel_id', 'in', self.ids), ('partner_id', '=', self.env.user.partner_id.id)]).write({ + 'seen_message_id': last_message_id, + 'fetched_message_id': last_message_id, + }) + data = { + 'info': 'channel_seen', + 'last_message_id': last_message_id, + 'partner_id': self.env.user.partner_id.id, + } + self.env['bus.bus'].sendmany([[(self._cr.dbname, 'mail.channel', self.id), data]]) return last_message_id + @api.multi + def channel_fetched(self): + """ Broadcast the channel_fetched notification to channel members + """ + self.ensure_one() + if not self.channel_message_ids.ids: + return + last_message_id = self.channel_message_ids.ids[0] # zero is the index of the last message + self.env['mail.channel.partner'].search([('channel_id', 'in', self.ids), ('partner_id', '=', self.env.user.partner_id.id)]).write({ + 'fetched_message_id': last_message_id, + }) + data = { + 'info': 'channel_fetched', + 'last_message_id': last_message_id, + 'partner_id': self.env.user.partner_id.id, + } + self.env['bus.bus'].sendmany([[(self._cr.dbname, 'mail.channel', self.id), data]]) + @api.multi def channel_invite(self, partner_ids): """ Add the given partner_ids to the current channels and broadcast the channel header to them. diff --git a/addons/mail/static/src/js/discuss.js b/addons/mail/static/src/js/discuss.js index dc2a1933be7..6c2d4b58d26 100644 --- a/addons/mail/static/src/js/discuss.js +++ b/addons/mail/static/src/js/discuss.js @@ -1024,7 +1024,8 @@ var Discuss = AbstractAction.extend(ControlPanelMixin, { .on('update_dm_presence', this, this._onUpdateDmPresence) .on('activity_updated', this, this._onActivityUpdated) .on('update_moderation_counter', this, this._onUpdateModerationCounter) - .on('update_typing_partners', this, this._onTypingPartnersUpdated); + .on('update_typing_partners', this, this._onTypingPartnersUpdated) + .on('update_channel', this, this._onUpdateChannel); }, /** * @private @@ -1592,6 +1593,16 @@ var Discuss = AbstractAction.extend(ControlPanelMixin, { _onUnstarAllClicked: function () { this.call('mail_service', 'unstarAll'); }, + /** + * @private + * @param {integer} channelID + */ + _onUpdateChannel: function (channelID) { + if (this._thread.getID() !== channelID) { + return; + } + this._fetchAndRenderThread(); + }, /** * @private */ diff --git a/addons/mail/static/src/js/models/threads/abstract_thread.js b/addons/mail/static/src/js/models/threads/abstract_thread.js index 829f54581da..d6a37967c68 100644 --- a/addons/mail/static/src/js/models/threads/abstract_thread.js +++ b/addons/mail/static/src/js/models/threads/abstract_thread.js @@ -102,6 +102,16 @@ var AbstractThread = Class.extend(Mixins.EventDispatcherMixin, { hasMessages: function () { return !_.isEmpty(this.getMessages()); }, + /** + * States whether this thread is compatible with the 'seen' feature. + * By default, threads do not have thsi feature active. + * @see {mail.model.ThreadSeenMixin} to enable this feature on a thread. + * + * @returns {boolean} + */ + hasSeenFeature: function () { + return false; + }, /** * States whether this thread is compatible with the 'is typing...' feature. * By default, threads do not have this feature active. diff --git a/addons/mail/static/src/js/models/threads/channel.js b/addons/mail/static/src/js/models/threads/channel.js index f6839873ef7..7db3474ebb2 100644 --- a/addons/mail/static/src/js/models/threads/channel.js +++ b/addons/mail/static/src/js/models/threads/channel.js @@ -1,6 +1,7 @@ odoo.define('mail.model.Channel', function (require) { "use strict"; +var ChannelSeenMixin = require('mail.model.ChannelSeenMixin'); var SearchableThread = require('mail.model.SearchableThread'); var ThreadTypingMixin = require('mail.model.ThreadTypingMixin'); var mailUtils = require('mail.utils'); @@ -16,7 +17,7 @@ var time = require('web.time'); * Any piece of code in JS that make use of channels must ideally interact with * such objects, instead of direct data from the server. */ -var Channel = SearchableThread.extend(ThreadTypingMixin, { +var Channel = SearchableThread.extend(ChannelSeenMixin, ThreadTypingMixin, { /** * @override * @param {Object} params @@ -36,6 +37,10 @@ var Channel = SearchableThread.extend(ThreadTypingMixin, { * @param {integer} [params.data.message_unread_counter] * @param {boolean} [params.data.moderation=false] whether the channel is * moderated or not + * @param {Object[]} [params.data.partners_info=[]] + * @param {integer} [params.data.partners_info[i].partner_id] + * @param {integer} [params.data.partners_info[i].fetched_message_id] + * @param {integer} [params.data.partners_info[i].seen_message_id] * @param {string} params.data.state * @param {string} [params.data.uuid] * @param {Object} params.options @@ -45,7 +50,8 @@ var Channel = SearchableThread.extend(ThreadTypingMixin, { init: function (params) { var self = this; this._super.apply(this, arguments); - ThreadTypingMixin.init.call(this, arguments); + ChannelSeenMixin.init.apply(this, arguments); + ThreadTypingMixin.init.apply(this, arguments); var data = params.data; var options = params.options; @@ -72,7 +78,6 @@ var Channel = SearchableThread.extend(ThreadTypingMixin, { // number of messages in this channel that are in inbox. this._needactionCounter = data.message_needaction_counter || 0; this._serverType = data.channel_type; - this._throttleFetchSeen = _.throttle(this._fetchSeen.bind(this), 3000); // unique identifier for this channel, which is required for some rpc this._uuid = data.uuid; @@ -335,23 +340,6 @@ var Channel = SearchableThread.extend(ThreadTypingMixin, { // Private //-------------------------------------------------------------------------- - /** - * @private - * @returns {$.Promise} resolved with ID of last seen message - */ - _fetchSeen: function () { - var self = this; - return this._rpc({ - model: 'mail.channel', - method: 'channel_seen', - args: [[this._id]], - }, { - shadow: true - }).then(function (lastSeenMessageID) { - self._lastSeenMessageID = lastSeenMessageID; - return lastSeenMessageID; - }); - }, /** * Override so that it tells whether the channel is moderated or not. This * is useful in order to display pending moderation messages when the @@ -399,9 +387,23 @@ var Channel = SearchableThread.extend(ThreadTypingMixin, { */ _markAsRead: function () { var superDef = this._super.apply(this, arguments); - var seenDef = this._throttleFetchSeen(); + var seenDef = this._throttleNotifySeen(); return $.when(superDef, seenDef); }, + /** + * @override {mail.model.ThreadSeenMixin} + * @private + * @returns {$.Promise} + */ + _notifyFetched: function () { + return this._rpc({ + model: 'mail.channel', + method: 'channel_fetched', + args: [[this._id]], + }, { + shadow: true + }); + }, /** * @override {mail.model.ThreadTypingMixin} * @private @@ -417,6 +419,25 @@ var Channel = SearchableThread.extend(ThreadTypingMixin, { kwargs: { is_typing: params.typing }, }, { shadow: true }); }, + /** + * @override {mail.model.ThreadSeenMixin} + * @private + * @returns {$.Promise} resolved with ID of last seen message + */ + _notifySeen: function () { + var self = this; + this._cancelThrottledNotifyFetched(); + return this._rpc({ + model: 'mail.channel', + method: 'channel_seen', + args: [[this._id]], + }, { + shadow: true + }).then(function (lastSeenMessageID) { + self._lastSeenMessageID = lastSeenMessageID; + return lastSeenMessageID; + }); + }, /** * Prepare and send a message to the server on this channel. * diff --git a/addons/mail/static/src/js/models/threads/mixins/channel_seen_mixin.js b/addons/mail/static/src/js/models/threads/mixins/channel_seen_mixin.js new file mode 100644 index 00000000000..8a59db70aa2 --- /dev/null +++ b/addons/mail/static/src/js/models/threads/mixins/channel_seen_mixin.js @@ -0,0 +1,265 @@ +odoo.define('mail.model.ChannelSeenMixin', function (require) { +"use strict"; + +var CCThrottleFunction = require('mail.model.CCThrottleFunction'); + +var session = require('web.session'); + +/** + * Mixin for enabling the "seen" feature on channels. + */ +var ChannelSeenMixin = { + /** + * Initialize the internal data for seen feature on channels. + * + * Also listens on some internal events of the channel: + * + * - 'message_added': used to notify channel has been fetched + * + * @param {Object} params + * @param {Object[]} [params.data.partners_info=[]] + * @param {integer} [params.data.seen_partners_info[i].partner_id] + * @param {integer|boolean} [params.data.seen_partners_info[i].fetched_message_id=false] + * @param {integer|boolean} [params.data.seen_partners_info[i].seen_message_id=false] + */ + init: function (params) { + this._seenPartnersInfo = params.data.seen_partners_info || []; + + this._throttleNotifySeen = _.throttle(this._notifySeen.bind(this), 3000); + this._throttleNotifyFetched = CCThrottleFunction({ + duration: 3000, + func: this._notifyFetched.bind(this), + }); + + this.on('message_added', this, this._onSeenMessageAdded); + }, + + //-------------------------------------------------------------------------- + // Public + //-------------------------------------------------------------------------- + + /** + * Get list of members that have fetched the provided message on this + * channel, but not yet seen it. Ignore current user in this list. + * + * @param {mail.model.Message} message + * @returns {Object[]} list of members + */ + getFetchedNotSeenMembers: function (message) { + var otherPartnersInfo = _.filter(this._seenPartnersInfo, function (info) { + return info.partner_id !== session.partner_id; + }); + + // seen partner IDs + var seenPartnersInfo = _.filter(otherPartnersInfo, function (info) { + return info.seen_message_id && info.seen_message_id >= message.getID(); + }); + var seenPartnerIDs = _.map(seenPartnersInfo, function (info) { + return info.partner_id; + }); + + // fetched partner IDs (ignore seen) + var fetchedPartnersInfo = _.filter(otherPartnersInfo, function (info) { + var lastFetchedID = info.fetched_message_id || info.seen_message_id || 0; + return lastFetchedID >= message.getID(); + }); + var fetchedPartnerIDs = _.map(fetchedPartnersInfo, function (info) { + return info.partner_id; + }); + fetchedPartnerIDs = _.reject(fetchedPartnerIDs, function (fetchedPartnerID) { + return _.contains(seenPartnerIDs, fetchedPartnerID); + }); + + // fetched members + var fetchedMembers = _.filter(this._members, function (member) { + return _.contains(fetchedPartnerIDs, member.id); + }); + + return fetchedMembers; + }, + /** + * @returns {integer} + */ + getLastMessageIDSeenByEveryone: function () { + var otherPartnersInfo = _.filter(this._seenPartnersInfo, function (info) { + return info.partner_id !== session.partner_id; + }); + var seenMessageIDs = _.map(otherPartnersInfo, function (info) { + return info.seen_message_id || 0; + }); + if (_.isEmpty(seenMessageIDs)) { + return 0; + } + return _.min(seenMessageIDs); + }, + /** + * Get list of members that have seen the provided message on this channel. + * Ignore current user in this list. + * + * @param {mail.model.Message} message + * @returns {Object[]} list of members + */ + getSeenMembers: function (message) { + var otherPartnersInfo = _.filter(this._seenPartnersInfo, function (info) { + return info.partner_id !== session.partner_id; + }); + + // seen partner IDs + var seenPartnersInfo = _.filter(otherPartnersInfo, function (info) { + return info.seen_message_id >= message.getID(); + }); + var seenPartnerIDs = _.map(seenPartnersInfo, function (info) { + return info.partner_id; + }); + + // seen members + var seenMembers = _.filter(this._members, function (member) { + return _.contains(seenPartnerIDs, member.id); + }); + + return seenMembers; + }, + /** + * @param {mail.model.Message} message + * @returns {boolean} + */ + hasEveryoneFetched: function (message) { + var otherPartnersInfo = _.filter(this._seenPartnersInfo, function (info) { + return info.partner_id !== session.partner_id; + }); + var lastFetchedMessageIDs = _.map(otherPartnersInfo, function (info) { + return info.fetched_message_id || info.seen_message_id || 0; + }); + if (_.isEmpty(lastFetchedMessageIDs)) { + return 0; + } + return message.getID() <= _.min(lastFetchedMessageIDs); + }, + /** + * @param {mail.model.Message} message + * @returns {boolean} + */ + hasEveryoneSeen: function (message) { + var otherPartnersInfo = _.filter(this._seenPartnersInfo, function (info) { + return info.partner_id !== session.partner_id; + }); + var lastSeenMessageIDs = _.map(otherPartnersInfo, function (info) { + return info.seen_message_id || 0; + }); + if (_.isEmpty(lastSeenMessageIDs)) { + return 0; + } + return message.getID() <= _.min(lastSeenMessageIDs); + }, + /** + * @param {mail.model.Message} message + * @returns {boolean} + */ + hasSomeoneFetched: function (message) { + var otherPartnersInfo = _.filter(this._seenPartnersInfo, function (info) { + return info.partner_id !== session.partner_id; + }); + var lastFetchedMessageIDs = _.map(otherPartnersInfo, function (info) { + return info.fetched_message_id || info.seen_message_id || 0; + }); + if (_.isEmpty(lastFetchedMessageIDs)) { + return false; + } + var fetched = _.find(lastFetchedMessageIDs, function (msgID) { + return msgID >= message.getID(); + }); + return fetched; + }, + /** + * @param {mail.model.Message} message + * @returns {boolean} + */ + hasSomeoneSeen: function (message) { + var otherPartnersInfo = _.filter(this._seenPartnersInfo, function (info) { + return info.partner_id !== session.partner_id; + }); + var lastSeenMessageIDs = _.map(otherPartnersInfo, function (info) { + return info.seen_message_id || 0; + }); + if (_.isEmpty(lastSeenMessageIDs)) { + return false; + } + var seen = _.find(lastSeenMessageIDs, function (msgID) { + return msgID >= message.getID(); + }); + return seen; + }, + /** + * @returns {boolean} + */ + hasSeenFeature: function () { + return true; + }, + /** + * @param {Object} data + * @param {string} data.info either 'channel_seen' or 'channel_fetched' + * @param {integer} data.partner_id + * @param {integer} data.last_message_id + */ + updateSeenPartnersInfo: function (data) { + var seenInfo = _.findWhere(this._seenPartnersInfo, { partner_id: data.partner_id }); + if (!seenInfo) { + seenInfo = { + partner_id: data.partner_id, + seen_message_id: 0, + fetched_message_id: 0, + }; + this._seenPartnersInfo.push(seenInfo); + } + if (data.info === 'channel_fetched') { + seenInfo.fetched_message_id = data.last_message_id; + } else if (data.info === 'channel_seen') { + seenInfo.fetched_message_id = data.last_message_id; + seenInfo.seen_message_id = data.last_message_id; + } + this.call('mail_service', 'getMailBus').trigger('update_channel', this.getID()); + }, + + //-------------------------------------------------------------------------- + // Private + //-------------------------------------------------------------------------- + + /** + * @private + */ + _cancelThrottledNotifyFetched: function () { + this._throttleNotifyFetched.cancel(); + this._throttleNotifyFetched.clear(); + }, + /** + * @abstract + * @private + * @returns {$.Promise} + */ + _notifyFetched: function () { + return $.when(); + }, + /** + * @abstract + * @private + * @returns {$.Promise} resolved with ID of last seen message + */ + _notifySeen: function () { + return $.when(); + }, + + //-------------------------------------------------------------------------- + // Handlers + //-------------------------------------------------------------------------- + + /** + * @private + */ + _onSeenMessageAdded: function () { + this._throttleNotifyFetched(); + }, +}; + +return ChannelSeenMixin; + +}); diff --git a/addons/mail/static/src/js/services/mail_notification_manager.js b/addons/mail/static/src/js/services/mail_notification_manager.js index 8b698b3e04e..140266285c7 100644 --- a/addons/mail/static/src/js/services/mail_notification_manager.js +++ b/addons/mail/static/src/js/services/mail_notification_manager.js @@ -56,10 +56,32 @@ MailManager.include({ _handleChannelNotification: function (params) { if (params.data && params.data.info === 'typing_status') { this._handleChannelTypingNotification(params.channelID, params.data); + } else if (params.data && params.data.info === 'channel_fetched') { + this._handleChannelFetchedNotification(params.channelID, params.data); + } else if (params.data && params.data.info === 'channel_seen') { + this._handleChannelSeenNotification(params.channelID, params.data); } else { this._handleChannelMessageNotification(params.data); } }, + /** + * Called when a channel has been fetched, and the server responses with the + * last message fetched. Useful in order to track last message fetched. + * + * @private + * @param {integer} channelID + * @param {Object} data + * @param {string} data.info 'channel_fetched' + * @param {integer} data.last_message_id + * @param {integer} data.partner_id + */ + _handleChannelFetchedNotification: function (channelID, data) { + var channel = this.getChannel(channelID); + if (!channel) { + return; + } + channel.updateSeenPartnersInfo(data); + }, /** * Called when a new or updated message is received on a channel * @@ -88,6 +110,31 @@ MailManager.include({ }); }); }, + /** + * Called when a channel has been seen, and the server responses with the + * last message seen. Useful in order to track last message seen. + * + * @private + * @param {integer} channelID + * @param {Object} data + * @param {string} data.info 'channel_seen' + * @param {integer} data.last_message_id + * @param {integer} data.partner_id + */ + _handleChannelSeenNotification: function (channelID, data) { + var channel = this.getChannel(channelID); + if (!channel) { + return; + } + channel.updateSeenPartnersInfo(data); + if (session.partner_id !== data.partner_id) { + return; + } + channel.setLastSeenMessageID(data.last_message_id); + if (channel.hasUnreadMessages()) { + channel.resetUnreadCounter(); + } + }, /** * Called when someone starts or stops typing a message in a channel * @@ -150,25 +197,6 @@ MailManager.include({ _handlePartnerActivityUpdateNotification: function (data) { this._mailBus.trigger('activity_updated', data); }, - /** - * Called when a channel has been seen, and the server responses with the - * last message seen. Useful in order to track last message seen. - * - * @private - * @param {Object} data - * @param {integer} data.id ID of a channel - * @param {integer} [data.last_message_id] mandatory if 'id' refers to an - * existing channel. - */ - _handlePartnerChannelSeenNotification: function (data) { - var channel = this.getChannel(data.id); - if (channel) { - channel.setLastSeenMessageID(data.last_message_id); - if (channel.hasUnreadMessages()) { - channel.resetUnreadCounter(); - } - } - }, /** * Called when receiving a channel state as a partner notification: * @@ -351,8 +379,6 @@ MailManager.include({ this._handlePartnerMessageAuthorNotification(data); } else if (data.type === 'deletion') { this._handlePartnerMessageDeletionNotification(data); - } else if (data.info === 'channel_seen') { - this._handlePartnerChannelSeenNotification(data); } else if (data.info === 'transient_message') { this._handlePartnerTransientMessageNotification(data); } else if (data.type === 'activity_updated') { diff --git a/addons/mail/static/src/js/thread_widget.js b/addons/mail/static/src/js/thread_widget.js index da74028903e..d812d403007 100644 --- a/addons/mail/static/src/js/thread_widget.js +++ b/addons/mail/static/src/js/thread_widget.js @@ -83,6 +83,9 @@ var ThreadWidget = Widget.extend({ this._selectedMessageID = null; this._currentThreadID = null; this._messageMailPopover = null; + this._messageSeenPopover = null; + // used to track popover IDs to destroy on re-rendering of popovers + this._openedSeenPopoverIDs = []; }, /** * The message mail popover may still be shown at this moment. If we do not @@ -95,6 +98,10 @@ var ThreadWidget = Widget.extend({ if (this._messageMailPopover) { this._messageMailPopover.popover('hide'); } + if (this._messageSeenPopover) { + this._messageSeenPopover.popover('hide'); + } + this._destroyOpenSeenPopoverIDs(); }, /** * @param {mail.model.AbstractThread} thread the thread to render. @@ -203,6 +210,9 @@ var ThreadWidget = Widget.extend({ } this._renderMessageMailPopover(messages); + if (thread.hasSeenFeature()) { + this._renderMessageSeenPopover(thread, messages); + } }, //-------------------------------------------------------------------------- @@ -309,6 +319,15 @@ var ThreadWidget = Widget.extend({ // Private //-------------------------------------------------------------------------- + /** + * @private + */ + _destroyOpenSeenPopoverIDs: function () { + _.each(this._openedSeenPopoverIDs, function (popoverID) { + $('#' + popoverID).remove(); + }); + this._openedSeenPopoverIDs = []; + }, /** * Modifies $element to add the 'read more/read less' functionality * All element nodes with 'data-o-mail-quote' attribute are concerned. @@ -438,6 +457,46 @@ var ThreadWidget = Widget.extend({ }, }); }, + /** + * Render the popover when mouse hovering on the seen icon of a message + * in the thread. Only seen icons in non-squashed message have popover, + * because squashed messages hides this icon on message mouseover. + * + * @private + * @param {mail.model.AbstractThread} thread with thread seen mixin, + * @see {mail.model.ThreadSeenMixin} + * @param {mail.model.Message[]} messages list of messages in the + * rendered thread. + */ + _renderMessageSeenPopover: function (thread, messages) { + var self = this; + this._destroyOpenSeenPopoverIDs(); + if (this._messageSeenPopover) { + this._messageSeenPopover.popover('hide'); + } + if (!this.$('.o_thread_message_core .o_mail_thread_message_seen_icon').length) { + return; + } + this._messageSeenPopover = this.$('.o_thread_message_core .o_mail_thread_message_seen_icon').popover({ + html: true, + boundary: 'viewport', + placement: 'auto', + trigger: 'hover', + offset: '0, 1', + content: function () { + var $this = $(this); + self._openedSeenPopoverIDs.push($this.attr('aria-describedby')); + var messageID = $this.data('message-id'); + var message = _.find(messages, function (message) { + return message.getID() === messageID; + }); + return QWeb.render('mail.widget.Thread.Message.SeenIconPopoverContent', { + thread: thread, + message: message, + }); + }, + }); + }, /** * @private */ diff --git a/addons/mail/static/src/js/thread_windows/thread_window.js b/addons/mail/static/src/js/thread_windows/thread_window.js index f4b0137c476..cd5ffa7db94 100644 --- a/addons/mail/static/src/js/thread_windows/thread_window.js +++ b/addons/mail/static/src/js/thread_windows/thread_window.js @@ -56,7 +56,8 @@ var ThreadWindow = AbstractThreadWindow.extend({ var superDef = this._super().then(this._listenThreadWidget.bind(this)); this.call('mail_service', 'getMailBus') - .on('update_typing_partners', this, this._onUpdateTypingPartners); + .on('update_typing_partners', this, this._onUpdateTypingPartners) + .on('update_channel', this, this._onUpdateChannel); var composerDef; if (!this.hasThread()) { @@ -369,6 +370,16 @@ var ThreadWindow = AbstractThreadWindow.extend({ } this.renderHeader(); }, + /** + * @private + * @param {integer} channelID + */ + _onUpdateChannel: function (channelID) { + if (this._thread.getID() !== channelID) { + return; + } + this.render(); + }, }); return ThreadWindow; diff --git a/addons/mail/static/src/scss/thread.scss b/addons/mail/static/src/scss/thread.scss index a56f54bad78..ba705b1e49d 100644 --- a/addons/mail/static/src/scss/thread.scss +++ b/addons/mail/static/src/scss/thread.scss @@ -78,15 +78,21 @@ max-width: $o-mail-thread-avatar-size; } .o_thread_message_side_date { + display: none; margin-left: -5px; } .o_thread_message_star { + display: none; margin-right: -5px; } .o_thread_message_side_date { opacity: 0; } + + .o_mail_thread_message_seen_icon:not(.o_all_seen) { + opacity: $o-mail-thread-icon-opacity*0.5; + } } .o_thread_icon { cursor: pointer; @@ -99,14 +105,21 @@ &:hover, &.o_thread_selected_message { .o_thread_message_side_date { + display: inline-block; opacity: $o-mail-thread-side-date-opacity; } .o_thread_icon { + display: inline-block; opacity: $o-mail-thread-icon-opacity; &:hover { opacity: 1; } } + .o_thread_message_sidebar { + .o_mail_thread_message_seen_icon { + display: none; + } + } } .o_mail_redirect { @@ -256,3 +269,31 @@ transform: translateY(-5px); } } + +// ------------------------------------------------------------------ +// Thread seen icon: shared between discuss and chat windows +// ------------------------------------------------------------------ + +.o_mail_thread_message_seen_icon { + position: relative; + opacity: 0.6; + + &.o_all_seen { + color: $o-enterprise-primary-color; + } + + &:hover { + cursor: pointer; + opacity: 0.8; + } + + .fa-check:nth-child(1) { + padding-left: 3px; + } + + .fa-check:nth-child(2) { + position: absolute; + top: 1px; + left: 0px; + } +} diff --git a/addons/mail/static/src/xml/thread.xml b/addons/mail/static/src/xml/thread.xml index 748b9d4edc6..fcfe8ba7de2 100644 --- a/addons/mail/static/src/xml/thread.xml +++ b/addons/mail/static/src/xml/thread.xml @@ -281,6 +281,7 @@ +

@@ -319,6 +320,7 @@ + @@ -363,6 +365,70 @@ + + + + + + + + + + + + + + + + +

+ +

Seen by Everyone

+
+ + + + Seen by: +
    +
  • + () +
  • +
+
+ + +

Received by Everyone

+
+ + + + Received by: +
    +
  • + () +
  • +
+
+
+
+
+
+ +