diff --git a/addons/im_livechat/static/src/legacy/public_livechat.js b/addons/im_livechat/static/src/legacy/public_livechat.js index fa95e349681..c31a9d4ba78 100644 --- a/addons/im_livechat/static/src/legacy/public_livechat.js +++ b/addons/im_livechat/static/src/legacy/public_livechat.js @@ -634,689 +634,3 @@ return { }); -odoo.define('im_livechat.legacy.mail.widget.Thread', function (require) { -"use strict"; - -var DocumentViewer = require('im_livechat.legacy.mail.DocumentViewer'); -var mailUtils = require('@mail/js/utils'); - -var core = require('web.core'); -var time = require('web.time'); -var Widget = require('web.Widget'); - -var QWeb = core.qweb; -var _lt = core._lt; - -var ORDER = { - ASC: 1, // visually, ascending order of message IDs (from top to bottom) - DESC: -1, // visually, descending order of message IDs (from top to bottom) -}; - -var READ_MORE = _lt("read more"); -var READ_LESS = _lt("read less"); - -/** - * This is a generic widget to render a thread. - * Any thread that extends mail.model.AbstractThread can be used with this - * widget. - */ -var ThreadWidget = Widget.extend({ - className: 'o_mail_thread', - - events: { - 'click a': '_onClickRedirect', - 'click img': '_onClickRedirect', - 'click strong': '_onClickRedirect', - 'click .o_thread_show_more': '_onClickShowMore', - 'click .o_attachment_download': '_onAttachmentDownload', - 'click .o_attachment_view': '_onAttachmentView', - 'click .o_attachment_delete_cross': '_onDeleteAttachment', - 'click .o_thread_message_needaction': '_onClickMessageNeedaction', - 'click .o_thread_message_star': '_onClickMessageStar', - 'click .o_thread_message_reply': '_onClickMessageReply', - 'click .oe_mail_expand': '_onClickMailExpand', - 'click .o_thread_message': '_onClickMessage', - 'click': '_onClick', - 'click .o_thread_message_notification_error': '_onClickMessageNotificationError', - 'click .o_thread_message_moderation': '_onClickMessageModeration', - 'change .moderation_checkbox': '_onChangeModerationCheckbox', - }, - - /** - * @override - * @param {widget} parent - * @param {Object} options - */ - init: function (parent, options) { - this._super.apply(this, arguments); - this.attachments = []; - // options when the thread is enabled (e.g. can send message, - // interact on messages, etc.) - this._enabledOptions = _.defaults(options || {}, { - displayOrder: ORDER.ASC, - displayMarkAsRead: true, - displayModerationCommands: false, - displayStars: true, - displayDocumentLinks: true, - displayAvatars: true, - squashCloseMessages: true, - displayNotificationIcons: true, - displayReplyIcons: false, - loadMoreOnScroll: false, - hasMessageAttachmentDeletable: false, - }); - // options when the thread is disabled - this._disabledOptions = { - displayOrder: this._enabledOptions.displayOrder, - displayMarkAsRead: false, - displayModerationCommands: false, - displayStars: false, - displayDocumentLinks: false, - displayAvatars: this._enabledOptions.displayAvatars, - squashCloseMessages: false, - displayNotificationIcons: false, - displayReplyIcons: false, - loadMoreOnScroll: this._enabledOptions.loadMoreOnScroll, - hasMessageAttachmentDeletable: false, - }; - 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 - * remove it, it stays visible on the page until a page reload. - * - * @override - */ - destroy: function () { - clearInterval(this._updateTimestampsInterval); - if (this._messageMailPopover) { - this._messageMailPopover.popover('hide'); - } - if (this._messageSeenPopover) { - this._messageSeenPopover.popover('hide'); - } - this._destroyOpenSeenPopoverIDs(); - this._super(); - }, - /** - * @param {im_livechat.legacy.mail.model.AbstractThread} thread the thread to render. - * @param {Object} [options] - * @param {integer} [options.displayOrder=ORDER.ASC] order of displaying - * messages in the thread: - * - ORDER.ASC: last message is at the bottom of the thread - * - ORDER.DESC: last message is at the top of the thread - * @param {boolean} [options.displayLoadMore] - * @param {Array} [options.domain=[]] the domain for the messages in the - * thread. - * @param {boolean} [options.isCreateMode] - * @param {boolean} [options.scrollToBottom=false] - * @param {boolean} [options.squashCloseMessages] - */ - render: function (thread, options) { - var self = this; - - var shouldScrollToBottomAfterRendering = false; - if (this._currentThreadID === thread.getID() && this.isAtBottom()) { - shouldScrollToBottomAfterRendering = true; - } - this._currentThreadID = thread.getID(); - - // copy so that reverse do not alter order in the thread object - var messages = _.clone(thread.getMessages({ domain: options.domain || [] })); - - var modeOptions = options.isCreateMode ? this._disabledOptions : - this._enabledOptions; - - // attachments ordered by messages order (increasing ID) - this.attachments = _.uniq(_.flatten(_.map(messages, function (message) { - return message.getAttachments(); - }))); - - options = _.extend({}, modeOptions, options, { - selectedMessageID: this._selectedMessageID, - }); - - // dict where key is message ID, and value is whether it should display - // the author of message or not visually - var displayAuthorMessages = {}; - - // Hide avatar and info of a message if that message and the previous - // one are both comments wrote by the same author at the same minute - // and in the same document (users can now post message in documents - // directly from a channel that follows it) - var prevMessage; - _.each(messages, function (message) { - if ( - // is first message of thread - !prevMessage || - // more than 1 min. elasped - (Math.abs(message.getDate().diff(prevMessage.getDate())) > 60000) || - prevMessage.getType() !== 'comment' || - message.getType() !== 'comment' || - // from a different author - (prevMessage.getAuthorID() !== message.getAuthorID()) || - ( - // messages are linked to a document thread - ( - prevMessage.isLinkedToDocumentThread() && - message.isLinkedToDocumentThread() - ) && - ( - // are from different documents - prevMessage.getDocumentModel() !== message.getDocumentModel() || - prevMessage.getDocumentID() !== message.getDocumentID() - ) - ) - ) { - displayAuthorMessages[message.getID()] = true; - } else { - displayAuthorMessages[message.getID()] = !options.squashCloseMessages; - } - prevMessage = message; - }); - - if (modeOptions.displayOrder === ORDER.DESC) { - messages.reverse(); - } - - this.$el.html(QWeb.render('im_livechat.legacy.mail.widget.Thread', { - thread: thread, - displayAuthorMessages: displayAuthorMessages, - options: options, - ORDER: ORDER, - dateFormat: time.getLangDatetimeFormat(), - })); - - _.each(messages, function (message) { - var $message = self.$('.o_thread_message[data-message-id="' + message.getID() + '"]'); - $message.find('.o_mail_timestamp').data('date', message.getDate()); - - self._insertReadMore($message); - }); - - if (shouldScrollToBottomAfterRendering) { - this.scrollToBottom(); - } - - if (!this._updateTimestampsInterval) { - this.updateTimestampsInterval = setInterval(function () { - self._updateTimestamps(); - }, 1000 * 60); - } - - this._renderMessageNotificationPopover(messages); - if (thread.hasSeenFeature()) { - this._renderMessageSeenPopover(thread, messages); - } - }, - - /** - * Render thread widget when loading, i.e. when messaging is not yet ready. - * @see /mail/init_messaging - */ - renderLoading: function () { - this.$el.html(QWeb.render('im_livechat.legacy.mail.widget.ThreadLoading')); - }, - - //-------------------------------------------------------------------------- - // Public - //-------------------------------------------------------------------------- - - getScrolltop: function () { - return this.$el.scrollTop(); - }, - /** - * State whether the bottom of the thread is visible or not, - * with a tolerance of 5 pixels - * - * @return {boolean} - */ - isAtBottom: function () { - var fullHeight = this.el.scrollHeight; - var topHiddenHeight = this.$el.scrollTop(); - var visibleHeight = this.$el.outerHeight(); - var bottomHiddenHeight = fullHeight - topHiddenHeight - visibleHeight; - return bottomHiddenHeight < 5; - }, - /** - * Removes a message and re-renders the thread - * - * @param {integer} [messageID] the id of the removed message - * @param {mail.model.AbstractThread} thread the thread which contains - * updated list of messages (so it does not contain any message with ID - * `messageID`). - * @param {Object} [options] options for the thread rendering - */ - removeMessageAndRender: function (messageID, thread, options) { - var self = this; - this._currentThreadID = thread.getID(); - return new Promise(function (resolve, reject) { - self.$('.o_thread_message[data-message-id="' + messageID + '"]') - .fadeOut({ - done: function () { - if (self._currentThreadID === thread.getID()) { - self.render(thread, options); - } - resolve(); - }, - duration: 200, - }); - }); - }, - /** - * Scroll to the bottom of the thread - */ - scrollToBottom: function () { - this.$el.scrollTop(this.el.scrollHeight); - }, - /** - * Scrolls the thread to a given message - * - * @param {integer} options.msgID the ID of the message to scroll to - * @param {integer} [options.duration] - * @param {boolean} [options.onlyIfNecessary] - */ - scrollToMessage: function (options) { - var $target = this.$('.o_thread_message[data-message-id="' + options.messageID + '"]'); - if (options.onlyIfNecessary) { - var delta = $target.parent().height() - $target.height(); - var offset = delta < 0 ? - 0 : - delta - ($target.offset().top - $target.offsetParent().offset().top); - offset = - Math.min(offset, 0); - this.$el.scrollTo("+=" + offset + "px", options.duration); - } else if ($target.length) { - this.$el.scrollTo($target); - } - }, - /** - * Scroll to the specific position in pixel - * - * If no position is provided, scroll to the bottom of the thread - * - * @param {integer} [position] distance from top to position in pixels. - * If not provided, scroll to the bottom. - */ - scrollToPosition: function (position) { - if (position) { - this.$el.scrollTop(position); - } else { - this.scrollToBottom(); - } - }, - /** - * Toggle all the moderation checkboxes in the thread - * - * @param {boolean} checked if true, check the boxes, - * otherwise uncheck them. - */ - toggleModerationCheckboxes: function (checked) { - this.$('.moderation_checkbox').prop('checked', checked); - }, - /** - * Unselect the selected message - */ - unselectMessage: function () { - this.$('.o_thread_message').removeClass('o_thread_selected_message'); - this._selectedMessageID = null; - }, - - //-------------------------------------------------------------------------- - // 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. - * All text nodes after a ``#stopSpelling`` element are concerned. - * Those text nodes need to be wrapped in a span (toggle functionality). - * All consecutive elements are joined in one 'read more/read less'. - * - * @private - * @param {jQuery} $element - */ - _insertReadMore: function ($element) { - var self = this; - - var groups = []; - var readMoreNodes; - - // nodeType 1: element_node - // nodeType 3: text_node - var $children = $element.contents() - .filter(function () { - return this.nodeType === 1 || - this.nodeType === 3 && - this.nodeValue.trim(); - }); - - _.each($children, function (child) { - var $child = $(child); - - // Hide Text nodes if "stopSpelling" - if ( - child.nodeType === 3 && - $child.prevAll('[id*="stopSpelling"]').length > 0 - ) { - // Convert Text nodes to Element nodes - $child = $('', { - text: child.textContent, - 'data-o-mail-quote': '1', - }); - child.parentNode.replaceChild($child[0], child); - } - - // Create array for each 'read more' with nodes to toggle - if ( - $child.attr('data-o-mail-quote') || - ( - $child.get(0).nodeName === 'BR' && - $child.prev('[data-o-mail-quote="1"]').length > 0 - ) - ) { - if (!readMoreNodes) { - readMoreNodes = []; - groups.push(readMoreNodes); - } - $child.hide(); - readMoreNodes.push($child); - } else { - readMoreNodes = undefined; - self._insertReadMore($child); - } - }); - - _.each(groups, function (group) { - // Insert link just before the first node - var $readMore = $('', { - class: 'o_mail_read_more', - href: '#', - text: READ_MORE, - }).insertBefore(group[0]); - - // Toggle All next nodes - var isReadMore = true; - $readMore.click(function (e) { - e.preventDefault(); - isReadMore = !isReadMore; - _.each(group, function ($child) { - $child.hide(); - $child.toggle(!isReadMore); - }); - $readMore.text(isReadMore ? READ_MORE : READ_LESS); - }); - }); - }, - /** - * @private - * @param {MouseEvent} ev - */ - _onDeleteAttachment: function (ev) { - ev.stopPropagation(); - var $target = $(ev.currentTarget); - this.trigger_up('delete_attachment', { - attachmentId: $target.data('id'), - attachmentName: $target.data('name') - }); - }, - /** - * @private - * @param {Object} options - * @param {integer} [options.channelID] - * @param {string} options.model - * @param {integer} options.id - */ - _redirect: _.debounce(function (options) { - if ('channelID' in options) { - this.trigger('redirect_to_channel', options.channelID); - } else { - this.trigger('redirect', options.model, options.id); - } - }, 500, true), - /** - * Render the popover when mouse-hovering on the notification icon of a - * message in the thread. - * There is at most one such popover at any given time. - * - * @private - * @param {im_livechat.legacy.mail.model.AbstractMessage[]} messages list of messages in the - * rendered thread, for which popover on mouseover interaction is - * permitted. - */ - _renderMessageNotificationPopover(messages) { - if (this._messageMailPopover) { - this._messageMailPopover.popover('hide'); - } - if (!this.$('.o_thread_tooltip').length) { - return; - } - this._messageMailPopover = this.$('.o_thread_tooltip').popover({ - html: true, - boundary: 'viewport', - placement: 'auto', - trigger: 'hover', - offset: '0, 1', - content: function () { - var messageID = $(this).data('message-id'); - var message = _.find(messages, function (message) { - return message.getID() === messageID; - }); - return QWeb.render('im_livechat.legacy.mail.widget.Thread.Message.MailTooltip', { - notifications: message.getNotifications(), - }); - }, - }); - }, - /** - * 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 {im_livechat.legacy.mail.model.AbstractThread} thread with thread seen mixin, - * @see {im_livechat.legacy.mail.model.ThreadSeenMixin} - * @param {im_livechat.legacy.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('im_livechat.legacy.mail.widget.Thread.Message.SeenIconPopoverContent', { - thread: thread, - message: message, - }); - }, - }); - }, - /** - * @private - */ - _updateTimestamps: function () { - var isAtBottom = this.isAtBottom(); - this.$('.o_mail_timestamp').each(function () { - var date = $(this).data('date'); - $(this).text(mailUtils.timeFromNow(date)); - }); - if (isAtBottom && !this.isAtBottom()) { - this.scrollToBottom(); - } - }, - - //-------------------------------------------------------------------------- - // Handlers - //-------------------------------------------------------------------------- - - /** - * @private - * @param {MouseEvent} event - */ - _onAttachmentDownload: function (event) { - event.stopPropagation(); - }, - /** - * @private - * @param {MouseEvent} event - */ - _onAttachmentView: function (event) { - event.stopPropagation(); - var activeAttachmentID = $(event.currentTarget).data('id'); - if (activeAttachmentID) { - var attachmentViewer = new DocumentViewer(this, this.attachments, activeAttachmentID); - attachmentViewer.appendTo($('body')); - } - }, - /** - * @private - * @param {MouseEvent} ev - */ - _onChangeModerationCheckbox: function (ev) { - this.trigger_up('update_moderation_buttons'); - }, - /** - * @private - */ - _onClick: function () { - if (this._selectedMessageID) { - this.unselectMessage(); - this.trigger('unselect_message'); - } - }, - /** - * @private - * @param {MouseEvent} ev - */ - _onClickMailExpand: function (ev) { - ev.preventDefault(); - }, - /** - * @private - * @param {MouseEvent} ev - */ - _onClickMessage: function (ev) { - $(ev.currentTarget).toggleClass('o_thread_selected_message'); - }, - /** - * @private - * @param {MouseEvent} ev - */ - _onClickMessageNeedaction: function (ev) { - var messageID = $(ev.currentTarget).data('message-id'); - this.trigger('mark_as_read', messageID); - }, - /** - * @private - * @param {MouseEvent} ev - */ - _onClickMessageNotificationError(ev) { - const messageID = $(ev.currentTarget).data('message-id'); - this.do_action('mail.mail_resend_message_action', { - additional_context: { - mail_message_to_resend: messageID, - } - }); - }, - /** - * @private - * @param {MouseEvent} ev - */ - _onClickMessageReply: function (ev) { - this._selectedMessageID = $(ev.currentTarget).data('message-id'); - this.$('.o_thread_message').removeClass('o_thread_selected_message'); - this.$('.o_thread_message[data-message-id="' + this._selectedMessageID + '"]') - .addClass('o_thread_selected_message'); - this.trigger('select_message', this._selectedMessageID); - ev.stopPropagation(); - }, - /** - * @private - * @param {MouseEvent} ev - */ - _onClickMessageStar: function (ev) { - var messageID = $(ev.currentTarget).data('message-id'); - this.trigger('toggle_star_status', messageID); - }, - /** - * @private - * @param {MouseEvent} ev - */ - _onClickMessageModeration: function (ev) { - var $button = $(ev.currentTarget); - var messageID = $button.data('message-id'); - var decision = $button.data('decision'); - this.trigger_up('message_moderation', { - messageID: messageID, - decision: decision, - }); - }, - /** - * @private - * @param {MouseEvent} ev - */ - _onClickRedirect: function (ev) { - // ignore inherited branding - if ($(ev.target).data('oe-field') !== undefined) { - return; - } - var id = $(ev.target).data('oe-id'); - if (id) { - ev.preventDefault(); - var model = $(ev.target).data('oe-model'); - var options; - if (model && (model !== 'mail.channel')) { - options = { - model: model, - id: id - }; - } else { - options = { channelID: id }; - } - this._redirect(options); - } - }, - /** - * @private - */ - _onClickShowMore: function () { - this.trigger('load_more_messages'); - }, -}); - -ThreadWidget.ORDER = ORDER; - -return ThreadWidget; - -}); - diff --git a/addons/im_livechat/static/src/legacy/widgets/thread.js b/addons/im_livechat/static/src/legacy/widgets/thread.js new file mode 100644 index 00000000000..da96e6ebe32 --- /dev/null +++ b/addons/im_livechat/static/src/legacy/widgets/thread.js @@ -0,0 +1,685 @@ +odoo.define('im_livechat.legacy.mail.widget.Thread', function (require) { +"use strict"; + +var DocumentViewer = require('im_livechat.legacy.mail.DocumentViewer'); +var mailUtils = require('@mail/js/utils'); + +var core = require('web.core'); +var time = require('web.time'); +var Widget = require('web.Widget'); + +var QWeb = core.qweb; +var _lt = core._lt; + +var ORDER = { + ASC: 1, // visually, ascending order of message IDs (from top to bottom) + DESC: -1, // visually, descending order of message IDs (from top to bottom) +}; + +var READ_MORE = _lt("read more"); +var READ_LESS = _lt("read less"); + +/** + * This is a generic widget to render a thread. + * Any thread that extends mail.model.AbstractThread can be used with this + * widget. + */ +var ThreadWidget = Widget.extend({ + className: 'o_mail_thread', + + events: { + 'click a': '_onClickRedirect', + 'click img': '_onClickRedirect', + 'click strong': '_onClickRedirect', + 'click .o_thread_show_more': '_onClickShowMore', + 'click .o_attachment_download': '_onAttachmentDownload', + 'click .o_attachment_view': '_onAttachmentView', + 'click .o_attachment_delete_cross': '_onDeleteAttachment', + 'click .o_thread_message_needaction': '_onClickMessageNeedaction', + 'click .o_thread_message_star': '_onClickMessageStar', + 'click .o_thread_message_reply': '_onClickMessageReply', + 'click .oe_mail_expand': '_onClickMailExpand', + 'click .o_thread_message': '_onClickMessage', + 'click': '_onClick', + 'click .o_thread_message_notification_error': '_onClickMessageNotificationError', + 'click .o_thread_message_moderation': '_onClickMessageModeration', + 'change .moderation_checkbox': '_onChangeModerationCheckbox', + }, + + /** + * @override + * @param {widget} parent + * @param {Object} options + */ + init: function (parent, options) { + this._super.apply(this, arguments); + this.attachments = []; + // options when the thread is enabled (e.g. can send message, + // interact on messages, etc.) + this._enabledOptions = _.defaults(options || {}, { + displayOrder: ORDER.ASC, + displayMarkAsRead: true, + displayModerationCommands: false, + displayStars: true, + displayDocumentLinks: true, + displayAvatars: true, + squashCloseMessages: true, + displayNotificationIcons: true, + displayReplyIcons: false, + loadMoreOnScroll: false, + hasMessageAttachmentDeletable: false, + }); + // options when the thread is disabled + this._disabledOptions = { + displayOrder: this._enabledOptions.displayOrder, + displayMarkAsRead: false, + displayModerationCommands: false, + displayStars: false, + displayDocumentLinks: false, + displayAvatars: this._enabledOptions.displayAvatars, + squashCloseMessages: false, + displayNotificationIcons: false, + displayReplyIcons: false, + loadMoreOnScroll: this._enabledOptions.loadMoreOnScroll, + hasMessageAttachmentDeletable: false, + }; + 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 + * remove it, it stays visible on the page until a page reload. + * + * @override + */ + destroy: function () { + clearInterval(this._updateTimestampsInterval); + if (this._messageMailPopover) { + this._messageMailPopover.popover('hide'); + } + if (this._messageSeenPopover) { + this._messageSeenPopover.popover('hide'); + } + this._destroyOpenSeenPopoverIDs(); + this._super(); + }, + /** + * @param {im_livechat.legacy.mail.model.AbstractThread} thread the thread to render. + * @param {Object} [options] + * @param {integer} [options.displayOrder=ORDER.ASC] order of displaying + * messages in the thread: + * - ORDER.ASC: last message is at the bottom of the thread + * - ORDER.DESC: last message is at the top of the thread + * @param {boolean} [options.displayLoadMore] + * @param {Array} [options.domain=[]] the domain for the messages in the + * thread. + * @param {boolean} [options.isCreateMode] + * @param {boolean} [options.scrollToBottom=false] + * @param {boolean} [options.squashCloseMessages] + */ + render: function (thread, options) { + var self = this; + + var shouldScrollToBottomAfterRendering = false; + if (this._currentThreadID === thread.getID() && this.isAtBottom()) { + shouldScrollToBottomAfterRendering = true; + } + this._currentThreadID = thread.getID(); + + // copy so that reverse do not alter order in the thread object + var messages = _.clone(thread.getMessages({ domain: options.domain || [] })); + + var modeOptions = options.isCreateMode ? this._disabledOptions : + this._enabledOptions; + + // attachments ordered by messages order (increasing ID) + this.attachments = _.uniq(_.flatten(_.map(messages, function (message) { + return message.getAttachments(); + }))); + + options = _.extend({}, modeOptions, options, { + selectedMessageID: this._selectedMessageID, + }); + + // dict where key is message ID, and value is whether it should display + // the author of message or not visually + var displayAuthorMessages = {}; + + // Hide avatar and info of a message if that message and the previous + // one are both comments wrote by the same author at the same minute + // and in the same document (users can now post message in documents + // directly from a channel that follows it) + var prevMessage; + _.each(messages, function (message) { + if ( + // is first message of thread + !prevMessage || + // more than 1 min. elasped + (Math.abs(message.getDate().diff(prevMessage.getDate())) > 60000) || + prevMessage.getType() !== 'comment' || + message.getType() !== 'comment' || + // from a different author + (prevMessage.getAuthorID() !== message.getAuthorID()) || + ( + // messages are linked to a document thread + ( + prevMessage.isLinkedToDocumentThread() && + message.isLinkedToDocumentThread() + ) && + ( + // are from different documents + prevMessage.getDocumentModel() !== message.getDocumentModel() || + prevMessage.getDocumentID() !== message.getDocumentID() + ) + ) + ) { + displayAuthorMessages[message.getID()] = true; + } else { + displayAuthorMessages[message.getID()] = !options.squashCloseMessages; + } + prevMessage = message; + }); + + if (modeOptions.displayOrder === ORDER.DESC) { + messages.reverse(); + } + + this.$el.html(QWeb.render('im_livechat.legacy.mail.widget.Thread', { + thread: thread, + displayAuthorMessages: displayAuthorMessages, + options: options, + ORDER: ORDER, + dateFormat: time.getLangDatetimeFormat(), + })); + + _.each(messages, function (message) { + var $message = self.$('.o_thread_message[data-message-id="' + message.getID() + '"]'); + $message.find('.o_mail_timestamp').data('date', message.getDate()); + + self._insertReadMore($message); + }); + + if (shouldScrollToBottomAfterRendering) { + this.scrollToBottom(); + } + + if (!this._updateTimestampsInterval) { + this.updateTimestampsInterval = setInterval(function () { + self._updateTimestamps(); + }, 1000 * 60); + } + + this._renderMessageNotificationPopover(messages); + if (thread.hasSeenFeature()) { + this._renderMessageSeenPopover(thread, messages); + } + }, + + /** + * Render thread widget when loading, i.e. when messaging is not yet ready. + * @see /mail/init_messaging + */ + renderLoading: function () { + this.$el.html(QWeb.render('im_livechat.legacy.mail.widget.ThreadLoading')); + }, + + //-------------------------------------------------------------------------- + // Public + //-------------------------------------------------------------------------- + + getScrolltop: function () { + return this.$el.scrollTop(); + }, + /** + * State whether the bottom of the thread is visible or not, + * with a tolerance of 5 pixels + * + * @return {boolean} + */ + isAtBottom: function () { + var fullHeight = this.el.scrollHeight; + var topHiddenHeight = this.$el.scrollTop(); + var visibleHeight = this.$el.outerHeight(); + var bottomHiddenHeight = fullHeight - topHiddenHeight - visibleHeight; + return bottomHiddenHeight < 5; + }, + /** + * Removes a message and re-renders the thread + * + * @param {integer} [messageID] the id of the removed message + * @param {mail.model.AbstractThread} thread the thread which contains + * updated list of messages (so it does not contain any message with ID + * `messageID`). + * @param {Object} [options] options for the thread rendering + */ + removeMessageAndRender: function (messageID, thread, options) { + var self = this; + this._currentThreadID = thread.getID(); + return new Promise(function (resolve, reject) { + self.$('.o_thread_message[data-message-id="' + messageID + '"]') + .fadeOut({ + done: function () { + if (self._currentThreadID === thread.getID()) { + self.render(thread, options); + } + resolve(); + }, + duration: 200, + }); + }); + }, + /** + * Scroll to the bottom of the thread + */ + scrollToBottom: function () { + this.$el.scrollTop(this.el.scrollHeight); + }, + /** + * Scrolls the thread to a given message + * + * @param {integer} options.msgID the ID of the message to scroll to + * @param {integer} [options.duration] + * @param {boolean} [options.onlyIfNecessary] + */ + scrollToMessage: function (options) { + var $target = this.$('.o_thread_message[data-message-id="' + options.messageID + '"]'); + if (options.onlyIfNecessary) { + var delta = $target.parent().height() - $target.height(); + var offset = delta < 0 ? + 0 : + delta - ($target.offset().top - $target.offsetParent().offset().top); + offset = - Math.min(offset, 0); + this.$el.scrollTo("+=" + offset + "px", options.duration); + } else if ($target.length) { + this.$el.scrollTo($target); + } + }, + /** + * Scroll to the specific position in pixel + * + * If no position is provided, scroll to the bottom of the thread + * + * @param {integer} [position] distance from top to position in pixels. + * If not provided, scroll to the bottom. + */ + scrollToPosition: function (position) { + if (position) { + this.$el.scrollTop(position); + } else { + this.scrollToBottom(); + } + }, + /** + * Toggle all the moderation checkboxes in the thread + * + * @param {boolean} checked if true, check the boxes, + * otherwise uncheck them. + */ + toggleModerationCheckboxes: function (checked) { + this.$('.moderation_checkbox').prop('checked', checked); + }, + /** + * Unselect the selected message + */ + unselectMessage: function () { + this.$('.o_thread_message').removeClass('o_thread_selected_message'); + this._selectedMessageID = null; + }, + + //-------------------------------------------------------------------------- + // 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. + * All text nodes after a ``#stopSpelling`` element are concerned. + * Those text nodes need to be wrapped in a span (toggle functionality). + * All consecutive elements are joined in one 'read more/read less'. + * + * @private + * @param {jQuery} $element + */ + _insertReadMore: function ($element) { + var self = this; + + var groups = []; + var readMoreNodes; + + // nodeType 1: element_node + // nodeType 3: text_node + var $children = $element.contents() + .filter(function () { + return this.nodeType === 1 || + this.nodeType === 3 && + this.nodeValue.trim(); + }); + + _.each($children, function (child) { + var $child = $(child); + + // Hide Text nodes if "stopSpelling" + if ( + child.nodeType === 3 && + $child.prevAll('[id*="stopSpelling"]').length > 0 + ) { + // Convert Text nodes to Element nodes + $child = $('', { + text: child.textContent, + 'data-o-mail-quote': '1', + }); + child.parentNode.replaceChild($child[0], child); + } + + // Create array for each 'read more' with nodes to toggle + if ( + $child.attr('data-o-mail-quote') || + ( + $child.get(0).nodeName === 'BR' && + $child.prev('[data-o-mail-quote="1"]').length > 0 + ) + ) { + if (!readMoreNodes) { + readMoreNodes = []; + groups.push(readMoreNodes); + } + $child.hide(); + readMoreNodes.push($child); + } else { + readMoreNodes = undefined; + self._insertReadMore($child); + } + }); + + _.each(groups, function (group) { + // Insert link just before the first node + var $readMore = $('', { + class: 'o_mail_read_more', + href: '#', + text: READ_MORE, + }).insertBefore(group[0]); + + // Toggle All next nodes + var isReadMore = true; + $readMore.click(function (e) { + e.preventDefault(); + isReadMore = !isReadMore; + _.each(group, function ($child) { + $child.hide(); + $child.toggle(!isReadMore); + }); + $readMore.text(isReadMore ? READ_MORE : READ_LESS); + }); + }); + }, + /** + * @private + * @param {MouseEvent} ev + */ + _onDeleteAttachment: function (ev) { + ev.stopPropagation(); + var $target = $(ev.currentTarget); + this.trigger_up('delete_attachment', { + attachmentId: $target.data('id'), + attachmentName: $target.data('name') + }); + }, + /** + * @private + * @param {Object} options + * @param {integer} [options.channelID] + * @param {string} options.model + * @param {integer} options.id + */ + _redirect: _.debounce(function (options) { + if ('channelID' in options) { + this.trigger('redirect_to_channel', options.channelID); + } else { + this.trigger('redirect', options.model, options.id); + } + }, 500, true), + /** + * Render the popover when mouse-hovering on the notification icon of a + * message in the thread. + * There is at most one such popover at any given time. + * + * @private + * @param {im_livechat.legacy.mail.model.AbstractMessage[]} messages list of messages in the + * rendered thread, for which popover on mouseover interaction is + * permitted. + */ + _renderMessageNotificationPopover(messages) { + if (this._messageMailPopover) { + this._messageMailPopover.popover('hide'); + } + if (!this.$('.o_thread_tooltip').length) { + return; + } + this._messageMailPopover = this.$('.o_thread_tooltip').popover({ + html: true, + boundary: 'viewport', + placement: 'auto', + trigger: 'hover', + offset: '0, 1', + content: function () { + var messageID = $(this).data('message-id'); + var message = _.find(messages, function (message) { + return message.getID() === messageID; + }); + return QWeb.render('im_livechat.legacy.mail.widget.Thread.Message.MailTooltip', { + notifications: message.getNotifications(), + }); + }, + }); + }, + /** + * 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 {im_livechat.legacy.mail.model.AbstractThread} thread with thread seen mixin, + * @see {im_livechat.legacy.mail.model.ThreadSeenMixin} + * @param {im_livechat.legacy.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('im_livechat.legacy.mail.widget.Thread.Message.SeenIconPopoverContent', { + thread: thread, + message: message, + }); + }, + }); + }, + /** + * @private + */ + _updateTimestamps: function () { + var isAtBottom = this.isAtBottom(); + this.$('.o_mail_timestamp').each(function () { + var date = $(this).data('date'); + $(this).text(mailUtils.timeFromNow(date)); + }); + if (isAtBottom && !this.isAtBottom()) { + this.scrollToBottom(); + } + }, + + //-------------------------------------------------------------------------- + // Handlers + //-------------------------------------------------------------------------- + + /** + * @private + * @param {MouseEvent} event + */ + _onAttachmentDownload: function (event) { + event.stopPropagation(); + }, + /** + * @private + * @param {MouseEvent} event + */ + _onAttachmentView: function (event) { + event.stopPropagation(); + var activeAttachmentID = $(event.currentTarget).data('id'); + if (activeAttachmentID) { + var attachmentViewer = new DocumentViewer(this, this.attachments, activeAttachmentID); + attachmentViewer.appendTo($('body')); + } + }, + /** + * @private + * @param {MouseEvent} ev + */ + _onChangeModerationCheckbox: function (ev) { + this.trigger_up('update_moderation_buttons'); + }, + /** + * @private + */ + _onClick: function () { + if (this._selectedMessageID) { + this.unselectMessage(); + this.trigger('unselect_message'); + } + }, + /** + * @private + * @param {MouseEvent} ev + */ + _onClickMailExpand: function (ev) { + ev.preventDefault(); + }, + /** + * @private + * @param {MouseEvent} ev + */ + _onClickMessage: function (ev) { + $(ev.currentTarget).toggleClass('o_thread_selected_message'); + }, + /** + * @private + * @param {MouseEvent} ev + */ + _onClickMessageNeedaction: function (ev) { + var messageID = $(ev.currentTarget).data('message-id'); + this.trigger('mark_as_read', messageID); + }, + /** + * @private + * @param {MouseEvent} ev + */ + _onClickMessageNotificationError(ev) { + const messageID = $(ev.currentTarget).data('message-id'); + this.do_action('mail.mail_resend_message_action', { + additional_context: { + mail_message_to_resend: messageID, + } + }); + }, + /** + * @private + * @param {MouseEvent} ev + */ + _onClickMessageReply: function (ev) { + this._selectedMessageID = $(ev.currentTarget).data('message-id'); + this.$('.o_thread_message').removeClass('o_thread_selected_message'); + this.$('.o_thread_message[data-message-id="' + this._selectedMessageID + '"]') + .addClass('o_thread_selected_message'); + this.trigger('select_message', this._selectedMessageID); + ev.stopPropagation(); + }, + /** + * @private + * @param {MouseEvent} ev + */ + _onClickMessageStar: function (ev) { + var messageID = $(ev.currentTarget).data('message-id'); + this.trigger('toggle_star_status', messageID); + }, + /** + * @private + * @param {MouseEvent} ev + */ + _onClickMessageModeration: function (ev) { + var $button = $(ev.currentTarget); + var messageID = $button.data('message-id'); + var decision = $button.data('decision'); + this.trigger_up('message_moderation', { + messageID: messageID, + decision: decision, + }); + }, + /** + * @private + * @param {MouseEvent} ev + */ + _onClickRedirect: function (ev) { + // ignore inherited branding + if ($(ev.target).data('oe-field') !== undefined) { + return; + } + var id = $(ev.target).data('oe-id'); + if (id) { + ev.preventDefault(); + var model = $(ev.target).data('oe-model'); + var options; + if (model && (model !== 'mail.channel')) { + options = { + model: model, + id: id + }; + } else { + options = { channelID: id }; + } + this._redirect(options); + } + }, + /** + * @private + */ + _onClickShowMore: function () { + this.trigger('load_more_messages'); + }, +}); + +ThreadWidget.ORDER = ORDER; + +return ThreadWidget; + +});