diff options
Diffstat (limited to 'addons/mail/static/src/js/thread_windows')
| -rw-r--r-- | addons/mail/static/src/js/thread_windows/abstract_thread_window.js | 390 | ||||
| -rw-r--r-- | addons/mail/static/src/js/thread_windows/thread_window.js | 428 |
2 files changed, 818 insertions, 0 deletions
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; + +}); diff --git a/addons/mail/static/src/js/thread_windows/thread_window.js b/addons/mail/static/src/js/thread_windows/thread_window.js new file mode 100644 index 00000000000..c0d7a22ea33 --- /dev/null +++ b/addons/mail/static/src/js/thread_windows/thread_window.js @@ -0,0 +1,428 @@ +odoo.define('mail.ThreadWindow', function (require) { +"use strict"; + +var AbstractThreadWindow = require('mail.AbstractThreadWindow'); +var BasicComposer = require('mail.composer.Basic'); + +var core = require('web.core'); + +var _t = core._t; + +/** + * This is the main widget for rendering small windows for mail.model.Thread. + * Almost all instances of this class are linked to a thread. The sole + * exception is the "blank" thread window. This window let us open another + * thread window, using this "blank" thread window. + */ +var ThreadWindow = AbstractThreadWindow.extend({ + template: 'mail.ThreadWindow', + events: _.extend({}, AbstractThreadWindow.prototype.events, { + 'click .o_thread_window_expand': '_onClickExpand', + }), + /** + * Version of thread window that supports {mail.model.Thread} + * + * @override + * @param {mail.Manager} parent + * @param {mail.model.Thread} [thread = null] if not set, this is a "blank" + * thread window. It lets us open a DM by providing the name of a chat. + * @param {Object} [options={}] + * @param {boolean} [options.passively=false] + */ + init: function (parent, thread, options) { + this._super.apply(this, arguments); + + // don't automatically mark unread messages as seen when at the bottom + // of the thread + this._passive = this.options.passively; + this._thread = thread || null; + + if (!this.hasThread()) { + // internal fold state of thread window without any thread + this._folded = false; + // remembered partner ID of "blank" thread window in order to be + // replaced with newly opened DM window + this.directPartnerID = null; + } + }, + + /** + * @override + */ + start: function () { + var self = this; + + var superDef = this._super().then(this._listenThreadWidget.bind(this)); + + var composerDef; + if (!this.hasThread()) { + this._startWithoutThread(); + } else if (this._needsComposer()) { + var basicComposer = new BasicComposer(this, { + mentionPartnersRestricted: this._thread.getType() !== 'document_thread', + isMini: true + }); + basicComposer.on('post_message', this, this._postMessage); + basicComposer.once('input_focused', this, function () { + var commands = this._thread.getCommands(); + var partners = this._thread.getMentionPartnerSuggestions(); + basicComposer.mentionSetCommands(commands); + basicComposer.mentionSetPrefetchedPartners(partners); + }); + composerDef = basicComposer.replace(this.$('.o_thread_composer')); + composerDef.then(function () { + self.$input = self.$('.o_composer_text_field'); + }); + } + + return $.when(superDef, composerDef); + }, + + //-------------------------------------------------------------------------- + // Public + //-------------------------------------------------------------------------- + + /** + * @override + */ + close: function () { + if (this._hasThread()) { + this._thread.close(); + } + }, + /** + * Set the focus on the input of this thread window + */ + focusInput: function () { + this._focusInput(); + }, + /** + * Get the ID of the thread window, which is equivalent to the ID of the + * thread related to this window + * + * @returns {integer|string} + */ + getID: function () { + return this._getThreadID(); + }, + /** + * Get the status of the thread. If this window has no thread, returns + * 'undefined' + * + * @override + * @returns {string|undefined} + */ + getStatus: function () { + if (!this.hasThread()) { + return undefined; + } + return this._thread.getStatus(); + }, + /** + * Get the title of the thread window, which is equivalent to the name of + * related thread, prefixed with "#" if this is a chat (i.e. DM, backend + * livechat). + * + * If there is no thread linked to this thread window, display title of + * "blank" thread window. + * + * @override + * @returns {string} + */ + getTitle: function () { + if (!this.hasThread()) { + return _t("New message"); + } + return this._thread.getTitle(); + }, + /** + * Get the unread counter of the related thread. If there are no thread + * linked to this window, returns 0. + * + * @override + * @returns {integer} + */ + getUnreadCounter: function () { + if (!this.hasThread()) { + return 0; + } + return this._thread.getUnreadCounter(); + }, + /** + * @override + * @returns {boolean} + */ + hasThread: function () { + return !!this._thread; + }, + /** + * State whether the related thread is folded or not. If there are no + * thread related to this window, it means this is the "blank" thread + * window, therefore we use the internal folded state. + * + * @override + * @returns {boolean} + */ + isFolded: function () { + if (!this.hasThread()) { + return this._folded; + } + return this._thread.isFolded(); + }, + /** + * Tell whether the thread window is passive or not. A passive thread window + * does not auto-mark the thread as read when scrolling at the bottom. + * + * @returns {boolean} + */ + isPassive: function () { + return this._passive; + }, + /** + * Turn the thread window in active mode, so that when the bottom of the + * thread is visible, it is automatically marked as read. + */ + removePassive: function () { + this._passive = false; + }, + /** + * Update the header of this thread window + * This is useful when some information on the header have be updated + * such as the status or the title of the thread that have changed. + */ + updateHeader: function () { + this._renderHeader(); + }, + + //-------------------------------------------------------------------------- + // Private + //-------------------------------------------------------------------------- + + /** + * Get the ID of the thread window, which is equivalent to the ID of the + * related thread. + * + * If this window is not related to a thread, it means this is the "blank" + * thread window, therefore it returns "_blank" as its ID. + * + * @override + * @private + * @returns {integer|string} the threadID, or '_blank' for the window that + * is not related to any thread. + */ + _getThreadID: function () { + if (!this.hasThread()) { + return '_blank'; + } + return this._thread.getID(); + }, + /** + * @override + * @private + */ + _getHeaderRenderingOptions: function () { + var options = this._super.apply(this, arguments); + if (this._thread.getType() === 'document_thread') { + options.expandTitle = _t("Open document"); + } else { + options.expandTitle = _t("Open in Discuss"); + } + return options; + }, + /** + * States whether the window is linked to a thread or not + * + * Useful to detect whether this is the "blank" thread window or not. + * + * @private + * @returns {boolean} + */ + _hasThread: function () { + return !!this._thread; + }, + /** + * Listen on thread widget events + * + * @private + */ + _listenThreadWidget: function () { + this.threadWidget + .on('redirect', this, this._onRedirect) + .on('redirect_to_channel', this, this._onRedirectToChannel) + .on('toggle_star_status', this, this._onToggleStarStatus); + }, + /** + * States whether the input of the thread window should be displayed or not. + * This is based on the type of the thread: + * + * Do not display the input in the following cases: + * + * - no thread related to this window + * - window of a mailbox (temp: let us have mailboxes in window mode) + * - window of a thread with mass mailing + * + * Any other threads show the input in the window. + * + * @private + * @returns {boolean} + */ + _needsComposer: function () { + return this.hasThread() && + (this._thread.getType() !== 'mailbox') && + !this._thread.isMassMailing(); + }, + /** + * Open this thread window. + * This private method exists only for the purpose of providing a callback + * function on redirect to a partner. + * + * @private + */ + _open: function () { + this.call('mail_service', 'openThreadWindow', this.getID()); + }, + /** + * Post a message in the thread related to this window. + * If this window has no thread, do nothing + * + * @override + * @private + * @param {Object} messageData + */ + _postMessage: function (messageData) { + var self = this; + if (!this._hasThread()) { + return; + } + this._thread.postMessage(messageData) + .then(function () { + self.threadWidget.scrollToBottom(); + }); + }, + /** + * @private + */ + _startWithoutThread: function () { + var self = this; + this.$el.addClass('o_thread_less'); + this.$('.o_thread_search_input input') + .autocomplete({ + source: function (request, response) { + self.call('mail_service', 'searchPartner', request.term, 10) + .done(response); + }, + select: function (event, ui) { + // remember partner ID so that we can replace this window + // with new DM window + var partnerID = ui.item.id; + self.directPartnerID = partnerID; + self.call('mail_service', 'openDmWindow', partnerID); + }, + }) + .focus(); + }, + /** + * Update the fold state of the thread + * If there is no thread linked to this window, it means this is the + * "blank" thread window, therefore we use the internal state 'folded' + * + * @override + * @private + * @param {boolean} folded + */ + _updateThreadFoldState: function (folded) { + if (this.hasThread()) { + this._thread.fold(folded); + } else { + this._folded = folded; + } + }, + + //-------------------------------------------------------------------------- + // Handlers + //-------------------------------------------------------------------------- + + /** + * Expand the thread, which will open the thread in discuss. + * + * If there is no thread linked to this window, it means this is the "blank" + * thread window, therefore it opens discuss with the default thread (which + * should be the mailbox 'inbox') + * + * @private + * @param {MouseEvent} ev + */ + _onClickExpand: _.debounce(function (ev) { + var self = this; + ev.preventDefault(); + if (this._thread.getType() === 'document_thread') { + this.do_action({ + type: 'ir.actions.act_window', + res_model: this._thread.getDocumentModel(), + views: [[false, 'form']], + res_id: this._thread.getDocumentID(), + }); + } else { + this.do_action('mail.mail_channel_action_client_chat', { + clear_breadcrumbs: false, + active_id: this.hasThread() ? this._getThreadID() : undefined, + on_reverse_breadcrumb: function () { + self.call('mail_service', 'getMailBus') + .trigger('discuss_open', false); + }, + }); + } + }, 1000, true), + /** + * @override + * @private + * @param {KeyboardEvent} ev + * + * Override _onKeydown to only prevent jquery's blockUI to cancel event, + * but without sending the message on ENTER keydown as this is handled by + * the BasicComposer + */ + _onKeydown: function (ev) { + ev.stopPropagation(); + }, + /** + * callback function receives the threadID linked to resModel and resID + * Note: this callback function is only called for 'res.partner' + * + * @private + * @param {string} resModel + * @param {integer} resID + */ + _onRedirect: function (resModel, resID) { + var callback = this._open.bind(this); + this.call('mail_service', 'redirect', resModel, resID, callback); + }, + /** + * @private + * @param {integer} channelID + */ + _onRedirectToChannel: function (channelID) { + var thread = this.call('mail_service', 'getThread', channelID); + if (!thread) { + this.call('mail_service', 'joinChannel', channelID) + .then(function (channel) { + channel.detach(); + }); + } else { + this.toggleFold(false); + } + }, + /** + * @private + * @param {integer} messageID + */ + _onToggleStarStatus: function (messageID) { + var message = this.call('mail_service', 'getMessage', messageID); + message.toggleStarStatus(); + }, + +}); + +return ThreadWindow; + +}); |
