[MOV] im_livechat: move Thread outside of public_livechat.js

Task-2212347

closes odoo/odoo#87597

Signed-off-by: Alexandre Kühn (aku) <aku@odoo.com>
This commit is contained in:
Nemael
2022-03-30 19:27:00 +02:00
parent 327208ca22
commit e5e5396db9
2 changed files with 685 additions and 686 deletions
@@ -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 = $('<span>', {
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 = $('<a>', {
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;
});
@@ -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 = $('<span>', {
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 = $('<a>', {
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;
});