summaryrefslogtreecommitdiff
path: root/addons/mail/static/src/js/thread_windows
diff options
context:
space:
mode:
Diffstat (limited to 'addons/mail/static/src/js/thread_windows')
-rw-r--r--addons/mail/static/src/js/thread_windows/abstract_thread_window.js390
-rw-r--r--addons/mail/static/src/js/thread_windows/thread_window.js428
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;
+
+});