From cd34f6de727d5b3858420cff3c9d3c5995c4e75c Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Alexandre=20K=C3=BChn?= Date: Tue, 12 Jun 2018 15:46:46 +0200 Subject: [REF] mail: JS mail refactoring ---------------------- Summary ---------------------- The purpose of this commit is to improve the JS code of the `mail` module. It applies the new coding guidelines and makes some changes on the design of some modules, such as the old ChatManager module. Here is a short summary of the changes that have been made: 1. New coding guidelines - snake_case to camelCase - prefix private attributes and methods with '_' - jsdoc on most methods - one class per module 2. Rename/Merge some classes - 'chat manager' becomes 'mail manager' (internal) and 'mail service' (external) - 'chat window manager' is now included in mail manager - 'thread' widget now named 'thread widget' 3. New model abstraction for mail objects: - modules 'mail.model.*' - modeling: 0..1 0..1 * * ThreadWindow <------> Thread <-------> Message / \ / \ Thread With Cache Document Thread / | \ / | \ Mailbox Channel Support Channel | | DM - Thread: the superclass of threads. - ThreadWindow: the window component of a thread. - Message: mail objects representing messages. - Thread With Cache: threads that can be used with search view (Discuss app compatible). - Document Thread: represents the thread part of a chatter. - Mailbox: represents what was previously called 'static' channel, e.g. 'Inbox'. - Channel: mail objects representing channels, including livechat. - DM: special kind of Channel for 1:1 communication in the backend. - Support Channel: special channel for im_support module. This new modeling approach let us easily add features on all threads, such as the possibility to put any thread in a small window. ---------------------- Known issues ---------------------- [Already Present in Master] 1. When the Discuss app is in the background with 'Inbox' as the selected Thread, when clicking on a document thread preview in the messaging menu of the systray, the rainbow man appears. 2. When a document with the chatter is in the background, when receiving an inbox notification from this document thread, the document thread is automatically marked as read, which removes the notification right away. 3. Sometimes, opening a DM window from the "blank" thread window does not work. 4. Reply-to feature on Inbox is not working: no message is sent in the document thread. 5. On the first login of admin user with demo data, the inbox counter is wrong (it displays 6, instead of 3). Explanation after investigation: > On page load, it fetches the correct number of Inbox messages (3), but the server notifies of 3 needaction messages right away, so it wrongly assumes these are new needaction messages. > Not possible for web client to detect that these messages should not increment the Inbox counter while keeping same API. 6. Notifications for new document thread messages only work when the user sets 'handle with Odoo' for the Notification Management in the preferences. > due to notifications on the longpoll bus for document thread messages that come from needaction notifications. > requires server-side changes to send notification on the longpoll bus to mentionned user. [New] 7. When receiving a message on a unjoined channel, thread window flickers ('open' > 'close' > 'open') Explanation after investigation: > JS logic: a) On auto-join, ask server to join the channel and get channel infos. b) The info tells the channel is not detached, but JS code makes decision to detach it, and tells server the channel is now detached. c) From (a), server notifies on longpoll bus the state of channel, which is not detached. The web client thinks that the window state of the channel has been changed somewhere else, and the channel is now closed. d) From (b), server notifies on longpoll bus the state of channel, which is detached. The web client opens the thread window of this channel. > The flicker didn't occur before refactoring because the web client was only updating the model of channel when it receives the longpoll notification. > Server behaviour on 1st longpoll notification is necessary for cross-tab synchronization for channel window state. > New design implies that model and view should be synchronized, hence the issue now. > Solution: remove server-side thread window synchronization and replace with client-side synchronization. ---------------------- Hacks ---------------------- The module `im_livechat` now uses mail objects that are compatible with Message and Window objects: Modeling for messages: AbstractMessage / \ / \ LivechatMessage Message - AbstractMessage: message compatible with the thread widget. - LivechatMessage: message used by im_livechat. - Message: message used with the mail manager. Modeling for thread windows: AbstractThreadWindow / \ / \ LivechatWindow ThreadWindow - AbstractThreadWindow: behaviour share between all types of thread windows. - LivechatWindow: window used by im_livechat. - ThreadWindow: window used with mail_manager. The reason for these hacks are twofold: 1. Use the thread widget in the frontend and livechat external lib bundles. 2. Do not have a dependency with the mail manager in the frontend and external lib bundles. --- .../js/thread_windows/abstract_thread_window.js | 390 +++++++++++++++++++++ 1 file changed, 390 insertions(+) create mode 100644 addons/mail/static/src/js/thread_windows/abstract_thread_window.js (limited to 'addons/mail/static/src/js/thread_windows/abstract_thread_window.js') 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 new file mode 100644 index 00000000000..1d49d89eeb7 --- /dev/null +++ b/addons/mail/static/src/js/thread_windows/abstract_thread_window.js @@ -0,0 +1,390 @@ +odoo.define('mail.AbstractThreadWindow', function (require) { +"use strict"; + +var ThreadWidget = require('mail.widget.Thread'); + +var config = require('web.config'); +var core = require('web.core'); +var Widget = require('web.Widget'); + +var QWeb = core.qweb; +var _t = core._t; + +var HEIGHT_OPEN = '400px'; +var HEIGHT_FOLDED = '34px'; + +/** + * This is an abstract widget for rendering thread windows. + * + * It contains logic that are shared between mail.ThreadWindow and + * mail.LivechatWindow. + * + * The reason for having two different implementation of thread windows is + * that mail.ThreadWindow makes use of mail.Manager, which is used in the + * backend, while mail.LivechatWindow must work without this mail service. + */ +var AbstractThreadWindow = Widget.extend({ + template: 'mail.AbstractThreadWindow', + custom_events: { + escape_pressed: '_onEscapePressed', + document_viewer_closed: '_onDocumentViewerClose', + }, + events: { + 'click .o_thread_window_close': '_onClickClose', + 'click .o_thread_window_title': '_onClickFold', + 'click .o_thread_composer': '_onComposerClick', + 'click .o_mail_thread': '_onThreadWindowClicked', + 'keydown .o_thread_composer': '_onKeydown', + 'keypress .o_thread_composer': '_onKeypress', + }, + /** + * Children of this class must make use of `thread`, which is an object that + * represent the thread that is linked to this thread window. + * + * If no thread is provided, this will represent the "blank" thread window. + * + * @abstract + * @param {Widget} parent + * @param {Object} thread + * @param {Object} [options={}] + */ + init: function (parent, thread, options) { + this._super(parent); + + this.options = _.defaults(options || {}, { + autofocus: true, + displayStars: true, + displayReplyIcons: false, + displayEmailIcons: false, + placeholder: _t("Say something"), + }); + + this._hidden = false; + }, + start: function () { + this.$input = this.$('.o_composer_text_field'); + this.$header = this.$('.o_thread_window_header'); + + this.threadWidget = new ThreadWidget(this, { + threadID: this._getThreadID(), + displayDocumentLinks: false, + displayMarkAsRead: false, + displayStars: this.options.displayStars, + }); + + if (this.isFolded()) { + this.$el.css('height', HEIGHT_FOLDED); + } else if (this.options.autofocus) { + this._focusInput(); + } + if (!config.device.isMobile) { + this.$el.css('margin-right', $.position.scrollbarWidth()); + } + var def = this.threadWidget.replace(this.$('.o_chat_content')); + return $.when(this._super(), def); + }, + /** + * @override + */ + do_hide: function () { + this._hidden = true; + this._super.apply(this, arguments); + }, + /** + * @override + */ + do_show: function () { + this._hidden = false; + this._super.apply(this, arguments); + }, + /** + * @override + */ + do_toggle: function (display) { + this._hidden = _.isBoolean(display) ? !display : !this._hidden; + this._super.apply(this, arguments); + }, + + //-------------------------------------------------------------------------- + // Public + //-------------------------------------------------------------------------- + + /** + * Close this window + * + * @abstract + */ + close: function () {}, + /** + * Get the status of the thread, such as the im status of a DM. + * + * @abstract + * @returns {string} + */ + getStatus: function () {}, + /** + * Get the title of the thread window, which usually contains the name of + * the thread. + * + * @abstract + * @returns {string} + */ + getTitle: function () {}, + /** + * Get the unread counter of the thread linked to this thread window. + * + * @abstract + * @returns {integer} + */ + getUnreadCounter: function () {}, + /** + * States whether this thread window is related to a thread or not. + * + * This is useful in order to provide specific behaviour for thread windows + * without any thread, e.g. let them open a thread from this "blank" thread + * window. + * + * @abstract + * @returns {boolean} + */ + hasThread: function () {}, + /** + * States whether this thread is folded or not. This information is stored + * on the thread model. + * + * @abstract + * @returns {boolean} + */ + isFolded: function () {}, + /** + * States whether the current environment is in mobile or not. This is + * useful in order to customize the template rendering for mobile view. + * + * @returns {boolean} + */ + isMobile: function () { + return config.device.isMobile; + }, + /** + * States whether the thread window is hidden or not. + * + * @returns {boolean} + */ + isHidden: function () { + return this._hidden; + }, + /** + * Render the thread window, using the provided list of messages + * + * TODO: use messages in thread, instead of providing list of messages + * + * @param {mail.model.AbstractMessage[]} messages + */ + render: function (messages) { + this._renderHeader(); + this.threadWidget.render(messages, { displayLoadMore: false }); + }, + /** + * Toggle the fold state of this thread window. Also update the fold state + * of the thread model. + * + * @param {boolean} folded + */ + toggleFold: function (folded) { + if (!_.isBoolean(folded)) { + folded = !this.isFolded(); + } + this._updateThreadFoldState(folded); + }, + /** + * Update the visual state of the window so that it matched the internal + * fold state. This is useful in case the related thread has its fold state + * that has been changed. + */ + updateVisualFoldState: function () { + if (!this.isFolded()) { + this.threadWidget.scrollToBottom(); + this._focusInput(); + } + this._animateFold(); + }, + + //-------------------------------------------------------------------------- + // Private + //-------------------------------------------------------------------------- + + /** + * Called when there is a change of the fold state of the thread window. + * This method animates the change of fold state of this thread window. + * + * @private + */ + _animateFold: function () { + this.$el.animate({ + height: this.isFolded() ? HEIGHT_FOLDED : HEIGHT_OPEN + }, 200); + }, + /** + * Set the focus on the composer of the thread window. This operation is + * ignored in mobile context. + * + * @private + * Set the focus on the input of the window + */ + _focusInput: function () { + if ( + config.device.touch && + config.device.size_class <= config.device.SIZES.SM + ) { + return; + } + this.$input.focus(); + }, + /** + * Returns the options used by the rendering of the window's header + * + * @private + * @returns {Object} + */ + _getHeaderRenderingOptions: function () { + return { + status: this.getStatus(), + title: this.getTitle(), + unreadCounter: this.getUnreadCounter(), + widget: this, + }; + }, + /** + * Get the ID of the related thread. + * + * @abstract + * @private + * @return {integer} + */ + _getThreadID: function () {}, + /** + * Post a message on this thread window + * + * @abstract + * @private + * @param {Object} messageData + */ + _postMessage: function (messageData) {}, + /** + * Update the fold state of the related thread. + * This function is called when toggling the fold state of this window + * + * @abstract + * @private + * @param {boolean} folded + */ + _updateThreadFoldState: function (folded) {}, + /** + * Render the header of this thread window + * + * @private + */ + _renderHeader: function () { + var options = this._getHeaderRenderingOptions(); + this.$header.html( + QWeb.render('mail.AbstractThreadWindowHeaderContent', options)); + }, + + //-------------------------------------------------------------------------- + // Handlers + //-------------------------------------------------------------------------- + + /** + * Close the thread + * + * @private + * @param {MouseEvent} ev + */ + _onClickClose: function (ev) { + ev.stopPropagation(); + ev.preventDefault(); + this.close(); + }, + /** + * Fold/unfold the thread window + * + * @private + */ + _onClickFold: function () { + if (!config.device.isMobile) { + this.toggleFold(); + } + }, + /** + * Called when the composer is clicked -> forces focus on input even if + * jquery's blockUI is enabled. + * + * @private + * @param {Event} ev + */ + _onComposerClick: function (ev) { + if ($(ev.target).closest('a, button').length) { + return; + } + this._focusInput(); + }, + /** + * @private + */ + _onDocumentViewerClose: function () { + this._focusInput(); + }, + /** + * @private + */ + _onEscapePressed: function () { + if (!this.isFolded()) { + this.close(); + } + }, + /** + * Called when typing something on the composer of this thread window. + * + * @private + * @param {KeyboardEvent} ev + */ + _onKeydown: function (ev) { + ev.stopPropagation(); // to prevent jquery's blockUI to cancel event + // ENTER key (avoid requiring jquery ui for external livechat) + if (ev.which === 13) { + var content = _.str.trim(this.$input.val()); + var messageData = { + content: content, + attachment_ids: [], + partner_ids: [], + }; + this.$input.val(''); + if (content) { + this._postMessage(messageData); + } + } + }, + /** + * @private + * @param {KeyboardEvent} ev + */ + _onKeypress: function (ev) { + ev.stopPropagation(); // to prevent jquery's blockUI to cancel event + }, + /** + * When a thread window is clicked on, we want to give the focus to the main + * input. An exception is made when the user is selecting something. + * + * @private + */ + _onThreadWindowClicked: function () { + var selectObj = window.getSelection(); + if (selectObj.anchorOffset === selectObj.focusOffset) { + this.$input.focus(); + } + }, +}); + +return AbstractThreadWindow; + +}); -- cgit v1.2.3