Files
odoo_source/addons/mail/static/src/js/thread_widget.js
T
Alexandre Kühn 611b42409e [FIX] mail: filter messages in thread (Discuss)
Revision on https://github.com/odoo/odoo/commit/ec8c505bc1c9ec21939e9ffef53a9cc6f63f30c7

The commit above changes the way threads are rendered: the template
now uses a thread object, instead of a list of messages.

However, it was not handling messages that have been filtered from
the searchview. As a result, it was not possible to filter messages
at all.

This commit re-adds the possibility to filter messages, by providing
information on the domain of the thread in the template.

Task-ID 1877502
2018-09-12 15:02:22 +02:00

622 lines
21 KiB
JavaScript

odoo.define('mail.widget.Thread', function (require) {
"use strict";
var DocumentViewer = require('mail.DocumentViewer');
var mailUtils = require('mail.utils');
var core = require('web.core');
var time = require('web.time');
var Widget = require('web.Widget');
var QWeb = core.qweb;
var _t = core._t;
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 = _t("read more");
var READ_LESS = _t("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_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_email_exception': '_onClickEmailException',
'click .o_thread_message_email_bounce': '_onClickEmailException',
'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);
// options when the thread is enabled (e.g. can send message,
// interact on messages, etc.)
this._enabledOptions = _.defaults(options || {}, {
displayOrder: ORDER.ASC,
displayMarkAsRead: true,
displayStars: true,
displayDocumentLinks: true,
displayAvatars: true,
squashCloseMessages: true,
displayEmailIcons: true,
displayReplyIcons: false,
loadMoreOnScroll: false,
});
// options when the thread is disabled
this._disabledOptions = {
displayOrder: this._enabledOptions.displayOrder,
displayMarkAsRead: false,
displayStars: false,
displayDocumentLinks: false,
displayAvatars: this._enabledOptions.displayAvatars,
squashCloseMessages: false,
displayEmailIcons: false,
displayReplyIcons: false,
loadMoreOnScroll: this._enabledOptions.loadMoreOnScroll,
};
this._selectedMessageID = null;
this._currentThreadID = null;
this._messageMailPopover = null;
},
/**
* 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');
}
},
/**
* @param {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('mail.widget.Thread', {
thread: thread,
displayAuthorMessages: displayAuthorMessages,
options: options,
ORDER: ORDER,
dateFormat: time.getLangDatetimeFormat(),
}));
// must be after mail.widget.Thread rendering, so that there is the
// DOM element for the 'is typing' notification bar
if (thread.hasTypingNotification()) {
this.renderTypingNotificationBar(thread);
}
_.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._renderMessageMailPopover(messages);
},
//--------------------------------------------------------------------------
// 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;
var done = $.Deferred();
this.$('.o_thread_message[data-message-id="' + messageID + '"]')
.fadeOut({
done: function () {
self.render(thread, options);
done.resolve();
},
duration: 200,
});
return done;
},
/**
* Render the 'is typing...' text on the typing notification bar of the
* thread. This is called when there is a change in the list of users
* typing something on this thread.
*
* @param {mail.model.AbstractThread} thread with ThreadTypingMixin
*/
renderTypingNotificationBar: function (thread) {
if (this._currentThreadID === thread.getID()) {
var shouldScrollToBottomAfterRendering = this.isAtBottom();
// typing notification bar rendering
var $typingBar = this.$('.o_thread_typing_notification_bar');
var text = thread.getTypingMembersToText();
$typingBar.toggleClass('o_hidden', !text); // hide if no text, because of padding
$typingBar.text(text);
if (shouldScrollToBottomAfterRendering) {
this.scrollToBottom();
}
}
},
/**
* 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.messageID 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
//--------------------------------------------------------------------------
/**
* 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 {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 mail icon of a message
* in the thread. There is at most one such popover at any given time.
*
* @private
* @param {mail.model.AbstractMessage[]} messages list of messages in the
* rendered thread, for which popover on mouseover interaction is
* permitted.
*/
_renderMessageMailPopover: function (messages) {
if (this._messageMailPopover) {
this._messageMailPopover.popover('hide');
}
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('mail.widget.Thread.Message.MailTooltip', {
data: message.getCustomerEmailData()
});
},
});
},
/**
* @private
*/
_updateTimestamps: function () {
var isAtBottom = this.isAtBottom();
this.$('.o_mail_timestamp').each(function () {
var date = $(this).data('date');
$(this).html(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
*/
_onClickEmailException: function (ev) {
var 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
*/
_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
*/
_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;
});