summaryrefslogtreecommitdiff
path: root/addons/mail/static/src/components/message_list
diff options
context:
space:
mode:
authorstephanchrst <stephanchrst@gmail.com>2022-05-10 21:51:50 +0700
committerstephanchrst <stephanchrst@gmail.com>2022-05-10 21:51:50 +0700
commit3751379f1e9a4c215fb6eb898b4ccc67659b9ace (patch)
treea44932296ef4a9b71d5f010906253d8c53727726 /addons/mail/static/src/components/message_list
parent0a15094050bfde69a06d6eff798e9a8ddf2b8c21 (diff)
initial commit 2
Diffstat (limited to 'addons/mail/static/src/components/message_list')
-rw-r--r--addons/mail/static/src/components/message_list/message_list.js600
-rw-r--r--addons/mail/static/src/components/message_list/message_list.scss135
-rw-r--r--addons/mail/static/src/components/message_list/message_list.xml103
3 files changed, 838 insertions, 0 deletions
diff --git a/addons/mail/static/src/components/message_list/message_list.js b/addons/mail/static/src/components/message_list/message_list.js
new file mode 100644
index 00000000..245fd335
--- /dev/null
+++ b/addons/mail/static/src/components/message_list/message_list.js
@@ -0,0 +1,600 @@
+odoo.define('mail/static/src/components/message_list/message_list.js', function (require) {
+'use strict';
+
+const components = {
+ Message: require('mail/static/src/components/message/message.js'),
+};
+const useRefs = require('mail/static/src/component_hooks/use_refs/use_refs.js');
+const useRenderedValues = require('mail/static/src/component_hooks/use_rendered_values/use_rendered_values.js');
+const useShouldUpdateBasedOnProps = require('mail/static/src/component_hooks/use_should_update_based_on_props/use_should_update_based_on_props.js');
+const useStore = require('mail/static/src/component_hooks/use_store/use_store.js');
+const useUpdate = require('mail/static/src/component_hooks/use_update/use_update.js');
+
+const { Component } = owl;
+const { useRef } = owl.hooks;
+
+class MessageList extends Component {
+
+ /**
+ * @override
+ */
+ constructor(...args) {
+ super(...args);
+ useShouldUpdateBasedOnProps();
+ useStore(props => {
+ const threadView = this.env.models['mail.thread_view'].get(props.threadViewLocalId);
+ const thread = threadView ? threadView.thread : undefined;
+ const threadCache = threadView ? threadView.threadCache : undefined;
+ return {
+ isDeviceMobile: this.env.messaging.device.isMobile,
+ thread,
+ threadCache,
+ threadCacheIsAllHistoryLoaded: threadCache && threadCache.isAllHistoryLoaded,
+ threadCacheIsLoaded: threadCache && threadCache.isLoaded,
+ threadCacheIsLoadingMore: threadCache && threadCache.isLoadingMore,
+ threadCacheLastMessage: threadCache && threadCache.lastMessage,
+ threadCacheOrderedMessages: threadCache ? threadCache.orderedMessages : [],
+ threadIsTemporary: thread && thread.isTemporary,
+ threadMainCache: thread && thread.mainCache,
+ threadMessageAfterNewMessageSeparator: thread && thread.messageAfterNewMessageSeparator,
+ threadViewComponentHintList: threadView ? threadView.componentHintList : [],
+ threadViewNonEmptyMessagesLength: threadView && threadView.nonEmptyMessages.length,
+ };
+ }, {
+ compareDepth: {
+ threadCacheOrderedMessages: 1,
+ threadViewComponentHintList: 1,
+ },
+ });
+ this._getRefs = useRefs();
+ /**
+ * States whether there was at least one programmatic scroll since the
+ * last scroll event was handled (which is particularly async due to
+ * throttled behavior).
+ * Useful to avoid loading more messages or to incorrectly disabling the
+ * auto-scroll feature when the scroll was not made by the user.
+ */
+ this._isLastScrollProgrammatic = false;
+ /**
+ * Reference of the "load more" item. Useful to trigger load more
+ * on scroll when it becomes visible.
+ */
+ this._loadMoreRef = useRef('loadMore');
+ /**
+ * Snapshot computed during willPatch, which is used by patched.
+ */
+ this._willPatchSnapshot = undefined;
+ this._onScrollThrottled = _.throttle(this._onScrollThrottled.bind(this), 100);
+ /**
+ * State used by the component at the time of the render. Useful to
+ * properly handle async code.
+ */
+ this._lastRenderedValues = useRenderedValues(() => {
+ const threadView = this.threadView;
+ const thread = threadView && threadView.thread;
+ const threadCache = threadView && threadView.threadCache;
+ return {
+ componentHintList: threadView ? [...threadView.componentHintList] : [],
+ hasAutoScrollOnMessageReceived: threadView && threadView.hasAutoScrollOnMessageReceived,
+ hasScrollAdjust: this.props.hasScrollAdjust,
+ mainCache: thread && thread.mainCache,
+ order: this.props.order,
+ orderedMessages: threadCache ? [...threadCache.orderedMessages] : [],
+ thread,
+ threadCache,
+ threadCacheInitialScrollHeight: threadView && threadView.threadCacheInitialScrollHeight,
+ threadCacheInitialScrollPosition: threadView && threadView.threadCacheInitialScrollPosition,
+ threadView,
+ threadViewer: threadView && threadView.threadViewer,
+ };
+ });
+ // useUpdate must be defined after useRenderedValues to guarantee proper
+ // call order
+ useUpdate({ func: () => this._update() });
+ }
+
+ willPatch() {
+ const lastMessageRef = this.lastMessageRef;
+ this._willPatchSnapshot = {
+ isLastMessageVisible:
+ lastMessageRef &&
+ lastMessageRef.isBottomVisible({ offset: 10 }),
+ scrollHeight: this._getScrollableElement().scrollHeight,
+ scrollTop: this._getScrollableElement().scrollTop,
+ };
+ }
+
+ //--------------------------------------------------------------------------
+ // Public
+ //--------------------------------------------------------------------------
+
+ /**
+ * Update the scroll position of the message list.
+ * This is not done in patched/mounted hooks because scroll position is
+ * dependent on UI globally. To illustrate, imagine following UI:
+ *
+ * +----------+ < viewport top = scrollable top
+ * | message |
+ * | list |
+ * | |
+ * +----------+ < scrolltop = viewport bottom = scrollable bottom
+ *
+ * Now if a composer is mounted just below the message list, it is shrinked
+ * and scrolltop is altered as a result:
+ *
+ * +----------+ < viewport top = scrollable top
+ * | message |
+ * | list | < scrolltop = viewport bottom <-+
+ * | | |-- dist = composer height
+ * +----------+ < scrollable bottom <-+
+ * +----------+
+ * | composer |
+ * +----------+
+ *
+ * Because of this, the scroll position must be changed when whole UI
+ * is rendered. To make this simpler, this is done when <ThreadView/>
+ * component is patched. This is acceptable when <ThreadView/> has a
+ * fixed height, which is the case for the moment. task-2358066
+ */
+ adjustFromComponentHints() {
+ const { componentHintList, threadView } = this._lastRenderedValues();
+ for (const hint of componentHintList) {
+ switch (hint.type) {
+ case 'change-of-thread-cache':
+ case 'home-menu-hidden':
+ case 'home-menu-shown':
+ // thread just became visible, the goal is to restore its
+ // saved position if it exists or scroll to the end
+ this._adjustScrollFromModel();
+ break;
+ case 'message-received':
+ case 'messages-loaded':
+ case 'new-messages-loaded':
+ // messages have been added at the end, either scroll to the
+ // end or keep the current position
+ this._adjustScrollForExtraMessagesAtTheEnd();
+ break;
+ case 'more-messages-loaded':
+ // messages have been added at the start, keep the current
+ // position
+ this._adjustScrollForExtraMessagesAtTheStart();
+ break;
+ }
+ if (threadView && threadView.exists()) {
+ threadView.markComponentHintProcessed(hint);
+ }
+ }
+ this._willPatchSnapshot = undefined;
+ }
+
+ /**
+ * @param {mail.message} message
+ * @returns {string}
+ */
+ getDateDay(message) {
+ const date = message.date.format('YYYY-MM-DD');
+ if (date === moment().format('YYYY-MM-DD')) {
+ return this.env._t("Today");
+ } else if (
+ date === moment()
+ .subtract(1, 'days')
+ .format('YYYY-MM-DD')
+ ) {
+ return this.env._t("Yesterday");
+ }
+ return message.date.format('LL');
+ }
+
+ /**
+ * @returns {integer}
+ */
+ getScrollHeight() {
+ return this._getScrollableElement().scrollHeight;
+ }
+
+ /**
+ * @returns {integer}
+ */
+ getScrollTop() {
+ return this._getScrollableElement().scrollTop;
+ }
+
+ /**
+ * @returns {mail/static/src/components/message/message.js|undefined}
+ */
+ get mostRecentMessageRef() {
+ const { order } = this._lastRenderedValues();
+ if (order === 'desc') {
+ return this.messageRefs[0];
+ }
+ const { length: l, [l - 1]: mostRecentMessageRef } = this.messageRefs;
+ return mostRecentMessageRef;
+ }
+
+ /**
+ * @param {integer} messageId
+ * @returns {mail/static/src/components/message/message.js|undefined}
+ */
+ messageRefFromId(messageId) {
+ return this.messageRefs.find(ref => ref.message.id === messageId);
+ }
+
+ /**
+ * Get list of sub-components Message, ordered based on prop `order`
+ * (ASC/DESC).
+ *
+ * The asynchronous nature of OWL rendering pipeline may reveal disparity
+ * between knowledgeable state of store between components. Use this getter
+ * with extreme caution!
+ *
+ * Let's illustrate the disparity with a small example:
+ *
+ * - Suppose this component is aware of ordered (record) messages with
+ * following IDs: [1, 2, 3, 4, 5], and each (sub-component) messages map
+ * each of these records.
+ * - Now let's assume a change in store that translate to ordered (record)
+ * messages with following IDs: [2, 3, 4, 5, 6].
+ * - Because store changes trigger component re-rendering by their "depth"
+ * (i.e. from parents to children), this component may be aware of
+ * [2, 3, 4, 5, 6] but not yet sub-components, so that some (component)
+ * messages should be destroyed but aren't yet (the ref with message ID 1)
+ * and some do not exist yet (no ref with message ID 6).
+ *
+ * @returns {mail/static/src/components/message/message.js[]}
+ */
+ get messageRefs() {
+ const { order } = this._lastRenderedValues();
+ const refs = this._getRefs();
+ const ascOrderedMessageRefs = Object.entries(refs)
+ .filter(([refId, ref]) => (
+ // Message refs have message local id as ref id, and message
+ // local ids contain name of model 'mail.message'.
+ refId.includes(this.env.models['mail.message'].modelName) &&
+ // Component that should be destroyed but haven't just yet.
+ ref.message
+ )
+ )
+ .map(([refId, ref]) => ref)
+ .sort((ref1, ref2) => (ref1.message.id < ref2.message.id ? -1 : 1));
+ if (order === 'desc') {
+ return ascOrderedMessageRefs.reverse();
+ }
+ return ascOrderedMessageRefs;
+ }
+
+ /**
+ * @returns {mail.message[]}
+ */
+ get orderedMessages() {
+ const threadCache = this.threadView.threadCache;
+ if (this.props.order === 'desc') {
+ return [...threadCache.orderedMessages].reverse();
+ }
+ return threadCache.orderedMessages;
+ }
+
+ /**
+ * @param {integer} value
+ */
+ setScrollTop(value) {
+ if (this._getScrollableElement().scrollTop === value) {
+ return;
+ }
+ this._isLastScrollProgrammatic = true;
+ this._getScrollableElement().scrollTop = value;
+ }
+
+ /**
+ * @param {mail.message} prevMessage
+ * @param {mail.message} message
+ * @returns {boolean}
+ */
+ shouldMessageBeSquashed(prevMessage, message) {
+ if (!this.props.hasSquashCloseMessages) {
+ return false;
+ }
+ if (Math.abs(message.date.diff(prevMessage.date)) > 60000) {
+ // more than 1 min. elasped
+ return false;
+ }
+ if (prevMessage.message_type !== 'comment' || message.message_type !== 'comment') {
+ return false;
+ }
+ if (prevMessage.author !== message.author) {
+ // from a different author
+ return false;
+ }
+ if (prevMessage.originThread !== message.originThread) {
+ return false;
+ }
+ if (
+ prevMessage.moderation_status === 'pending_moderation' ||
+ message.moderation_status === 'pending_moderation'
+ ) {
+ return false;
+ }
+ if (
+ prevMessage.notifications.length > 0 ||
+ message.notifications.length > 0
+ ) {
+ // visual about notifications is restricted to non-squashed messages
+ return false;
+ }
+ const prevOriginThread = prevMessage.originThread;
+ const originThread = message.originThread;
+ if (
+ prevOriginThread &&
+ originThread &&
+ prevOriginThread.model === originThread.model &&
+ originThread.model !== 'mail.channel' &&
+ prevOriginThread.id !== originThread.id
+ ) {
+ // messages linked to different document thread
+ return false;
+ }
+ return true;
+ }
+
+ /**
+ * @returns {mail.thread_view}
+ */
+ get threadView() {
+ return this.env.models['mail.thread_view'].get(this.props.threadViewLocalId);
+ }
+
+ //--------------------------------------------------------------------------
+ // Private
+ //--------------------------------------------------------------------------
+
+ /**
+ * @private
+ */
+ _adjustScrollForExtraMessagesAtTheEnd() {
+ const {
+ hasAutoScrollOnMessageReceived,
+ hasScrollAdjust,
+ order,
+ } = this._lastRenderedValues();
+ if (!this._getScrollableElement() || !hasScrollAdjust) {
+ return;
+ }
+ if (!hasAutoScrollOnMessageReceived) {
+ if (order === 'desc' && this._willPatchSnapshot) {
+ const { scrollHeight, scrollTop } = this._willPatchSnapshot;
+ this.setScrollTop(this._getScrollableElement().scrollHeight - scrollHeight + scrollTop);
+ }
+ return;
+ }
+ this._scrollToEnd();
+ }
+
+ /**
+ * @private
+ */
+ _adjustScrollForExtraMessagesAtTheStart() {
+ const {
+ hasScrollAdjust,
+ order,
+ } = this._lastRenderedValues();
+ if (
+ !this._getScrollableElement() ||
+ !hasScrollAdjust ||
+ !this._willPatchSnapshot ||
+ order === 'desc'
+ ) {
+ return;
+ }
+ const { scrollHeight, scrollTop } = this._willPatchSnapshot;
+ this.setScrollTop(this._getScrollableElement().scrollHeight - scrollHeight + scrollTop);
+ }
+
+ /**
+ * @private
+ */
+ _adjustScrollFromModel() {
+ const {
+ hasScrollAdjust,
+ threadCacheInitialScrollHeight,
+ threadCacheInitialScrollPosition,
+ } = this._lastRenderedValues();
+ if (!this._getScrollableElement() || !hasScrollAdjust) {
+ return;
+ }
+ if (
+ threadCacheInitialScrollPosition !== undefined &&
+ this._getScrollableElement().scrollHeight === threadCacheInitialScrollHeight
+ ) {
+ this.setScrollTop(threadCacheInitialScrollPosition);
+ return;
+ }
+ this._scrollToEnd();
+ return;
+ }
+
+ /**
+ * @private
+ */
+ _checkMostRecentMessageIsVisible() {
+ const {
+ mainCache,
+ threadCache,
+ threadView,
+ } = this._lastRenderedValues();
+ if (!threadView || !threadView.exists()) {
+ return;
+ }
+ const lastMessageIsVisible =
+ threadCache &&
+ this.mostRecentMessageRef &&
+ threadCache === mainCache &&
+ this.mostRecentMessageRef.isPartiallyVisible();
+ if (lastMessageIsVisible) {
+ threadView.handleVisibleMessage(this.mostRecentMessageRef.message);
+ }
+ }
+
+ /**
+ * @private
+ * @returns {Element|undefined} Scrollable Element
+ */
+ _getScrollableElement() {
+ if (this.props.getScrollableElement) {
+ return this.props.getScrollableElement();
+ } else {
+ return this.el;
+ }
+ }
+
+ /**
+ * @private
+ * @returns {boolean}
+ */
+ _isLoadMoreVisible() {
+ const loadMore = this._loadMoreRef.el;
+ if (!loadMore) {
+ return false;
+ }
+ const loadMoreRect = loadMore.getBoundingClientRect();
+ const elRect = this._getScrollableElement().getBoundingClientRect();
+ const isInvisible = loadMoreRect.top > elRect.bottom || loadMoreRect.bottom < elRect.top;
+ return !isInvisible;
+ }
+
+ /**
+ * @private
+ */
+ _loadMore() {
+ const { threadCache } = this._lastRenderedValues();
+ if (!threadCache || !threadCache.exists()) {
+ return;
+ }
+ threadCache.loadMoreMessages();
+ }
+
+ /**
+ * Scrolls to the end of the list.
+ *
+ * @private
+ */
+ _scrollToEnd() {
+ const { order } = this._lastRenderedValues();
+ this.setScrollTop(order === 'asc' ? this._getScrollableElement().scrollHeight - this._getScrollableElement().clientHeight : 0);
+ }
+
+ /**
+ * @private
+ */
+ _update() {
+ this._checkMostRecentMessageIsVisible();
+ this.adjustFromComponentHints();
+ }
+
+ //--------------------------------------------------------------------------
+ // Handlers
+ //--------------------------------------------------------------------------
+
+ /**
+ * @private
+ * @param {MouseEvent} ev
+ */
+ _onClickLoadMore(ev) {
+ ev.preventDefault();
+ this._loadMore();
+ }
+
+ /**
+ * @private
+ * @param {ScrollEvent} ev
+ */
+ onScroll(ev) {
+ this._onScrollThrottled(ev);
+ }
+
+ /**
+ * @private
+ * @param {ScrollEvent} ev
+ */
+ _onScrollThrottled(ev) {
+ const {
+ order,
+ orderedMessages,
+ thread,
+ threadCache,
+ threadView,
+ threadViewer,
+ } = this._lastRenderedValues();
+ if (!this._getScrollableElement()) {
+ // could be unmounted in the meantime (due to throttled behavior)
+ return;
+ }
+ const scrollTop = this._getScrollableElement().scrollTop;
+ this.env.messagingBus.trigger('o-component-message-list-scrolled', {
+ orderedMessages,
+ scrollTop,
+ thread,
+ threadViewer,
+ });
+ if (!this._isLastScrollProgrammatic && threadView && threadView.exists()) {
+ // Margin to compensate for inaccurate scrolling to bottom and height
+ // flicker due height change of composer area.
+ const margin = 30;
+ // Automatically scroll to new received messages only when the list is
+ // currently fully scrolled.
+ const hasAutoScrollOnMessageReceived = (order === 'asc')
+ ? scrollTop >= this._getScrollableElement().scrollHeight - this._getScrollableElement().clientHeight - margin
+ : scrollTop <= margin;
+ threadView.update({ hasAutoScrollOnMessageReceived });
+ }
+ if (threadViewer && threadViewer.exists()) {
+ threadViewer.saveThreadCacheScrollHeightAsInitial(this._getScrollableElement().scrollHeight, threadCache);
+ threadViewer.saveThreadCacheScrollPositionsAsInitial(scrollTop, threadCache);
+ }
+ if (!this._isLastScrollProgrammatic && this._isLoadMoreVisible()) {
+ this._loadMore();
+ }
+ this._checkMostRecentMessageIsVisible();
+ this._isLastScrollProgrammatic = false;
+ }
+
+}
+
+Object.assign(MessageList, {
+ components,
+ defaultProps: {
+ hasMessageCheckbox: false,
+ hasScrollAdjust: true,
+ hasSquashCloseMessages: false,
+ haveMessagesMarkAsReadIcon: false,
+ haveMessagesReplyIcon: false,
+ order: 'asc',
+ },
+ props: {
+ hasMessageCheckbox: Boolean,
+ hasSquashCloseMessages: Boolean,
+ haveMessagesMarkAsReadIcon: Boolean,
+ haveMessagesReplyIcon: Boolean,
+ hasScrollAdjust: Boolean,
+ /**
+ * Function returns the exact scrollable element from the parent
+ * to manage proper scroll heights which affects the load more messages.
+ */
+ getScrollableElement: {
+ type: Function,
+ optional: true,
+ },
+ order: {
+ type: String,
+ validate: prop => ['asc', 'desc'].includes(prop),
+ },
+ selectedMessageLocalId: {
+ type: String,
+ optional: true,
+ },
+ threadViewLocalId: String,
+ },
+ template: 'mail.MessageList',
+});
+
+return MessageList;
+
+});
diff --git a/addons/mail/static/src/components/message_list/message_list.scss b/addons/mail/static/src/components/message_list/message_list.scss
new file mode 100644
index 00000000..cb06adda
--- /dev/null
+++ b/addons/mail/static/src/components/message_list/message_list.scss
@@ -0,0 +1,135 @@
+// ------------------------------------------------------------------
+// Layout
+// ------------------------------------------------------------------
+
+.o_MessageList {
+ display: flex;
+ flex-flow: column;
+ overflow: auto;
+
+ &.o-empty {
+ align-items: center;
+ justify-content: center;
+ }
+
+ &:not(.o-empty) {
+ padding-bottom: 15px;
+ }
+}
+
+.o_MessageList_empty {
+ flex: 1 1 auto;
+ height: 100%;
+ width: 100%;
+ align-self: center;
+ display: flex;
+ flex-flow: column;
+ align-items: center;
+ justify-content: center;
+ padding: 20px;
+ line-height: 2.5rem;
+}
+
+.o_MessageList_isLoadingMore {
+ align-self: center;
+}
+
+.o_MessageList_isLoadingMoreIcon {
+ margin-right: 3px;
+}
+
+.o_MessageList_loadMore {
+ align-self: center;
+}
+
+.o_MessageList_separator {
+ display: flex;
+ align-items: center;
+ padding: 0 0;
+ flex: 0 0 auto;
+}
+
+.o_MessageList_separatorDate {
+ padding: 15px 0;
+}
+
+.o_MessageList_separatorLine {
+ flex: 1 1 auto;
+ width: auto;
+}
+
+.o_MessageList_separatorNewMessages {
+ // bug with safari: container does not auto-grow from child size
+ padding: 0 0;
+ margin-right: 15px;
+}
+
+.o_MessageList_separatorLabel {
+ padding: 0 10px;
+}
+
+// ------------------------------------------------------------------
+// Style
+// ------------------------------------------------------------------
+
+.o_MessageList {
+ background-color: white;
+}
+
+.o_MessageList_empty {
+ text-align: center;
+}
+
+.o_MessageList_emptyTitle {
+ font-weight: bold;
+ font-size: 1.3rem;
+
+ &.o-neutral-face-icon:before {
+ @extend %o-nocontent-init-image;
+ @include size(120px, 140px);
+ background: transparent url(/web/static/src/img/neutral_face.svg) no-repeat center;
+ }
+}
+
+.o_MessageList_loadMore {
+ cursor: pointer;
+}
+
+.o_MessageList_message.o-has-message-selection:not(.o-selected) {
+ opacity: 0.5;
+}
+
+.o_MessageList_separator {
+ font-weight: bold;
+}
+
+.o_MessageList_separatorLine {
+ border-color: gray('400');
+}
+
+.o_MessageList_separatorLineNewMessages {
+ border-color: lighten($o-brand-odoo, 15%);
+}
+
+.o_MessageList_separatorNewMessages {
+ color: lighten($o-brand-odoo, 15%);
+
+}
+
+.o_MessageList_separatorLabel {
+ background-color: white;
+}
+
+// ------------------------------------------------------------------
+// Animation
+// ------------------------------------------------------------------
+
+.o_MessageList_separatorNewMessages:not(.o-disable-animation) {
+ &.fade-leave-active {
+ transition: opacity 0.5s;
+ }
+
+ &.fade-leave-to {
+ opacity: 0;
+ }
+}
diff --git a/addons/mail/static/src/components/message_list/message_list.xml b/addons/mail/static/src/components/message_list/message_list.xml
new file mode 100644
index 00000000..c0aff715
--- /dev/null
+++ b/addons/mail/static/src/components/message_list/message_list.xml
@@ -0,0 +1,103 @@
+<?xml version="1.0" encoding="UTF-8"?>
+<templates xml:space="preserve">
+
+ <t t-name="mail.MessageList" owl="1">
+ <div class="o_MessageList" t-att-class="{ 'o-empty': threadView and threadView.messages.length === 0, 'o-has-message-selection': props.selectedMessageLocalId }" t-on-scroll="onScroll">
+ <t t-if="threadView">
+ <!-- No result messages -->
+ <t t-if="threadView.nonEmptyMessages.length === 0">
+ <div class="o_MessageList_empty o_MessageList_item">
+ <t t-if="threadView.thread === env.messaging.inbox">
+ <div class="o_MessageList_emptyTitle">
+ Congratulations, your inbox is empty
+ </div>
+ New messages appear here.
+ </t>
+ <t t-elif="threadView.thread === env.messaging.starred">
+ <div class="o_MessageList_emptyTitle">
+ No starred messages
+ </div>
+ You can mark any message as 'starred', and it shows up in this mailbox.
+ </t>
+ <t t-elif="threadView.thread === env.messaging.history">
+ <div class="o_MessageList_emptyTitle o-neutral-face-icon">
+ No history messages
+ </div>
+ Messages marked as read will appear in the history.
+ </t>
+ <t t-elif="threadView.thread === env.messaging.moderation">
+ <div class="o_MessageList_emptyTitle">
+ You have no messages to moderate.
+ </div>
+ Messages pending moderation appear here.
+ </t>
+ <t t-else="">
+ There are no messages in this conversation.
+ </t>
+ </div>
+ </t>
+ <!-- LOADING (if order asc)-->
+ <t t-if="props.order === 'asc' and orderedMessages.length > 0">
+ <t t-call="mail.MessageList.loadMore"/>
+ </t>
+ <!-- MESSAGES -->
+ <t t-set="current_day" t-value="0"/>
+ <t t-set="prev_message" t-value="0"/>
+ <t t-foreach="orderedMessages" t-as="message" t-key="message.localId">
+ <t t-if="message === threadView.thread.messageAfterNewMessageSeparator">
+ <div class="o_MessageList_separator o_MessageList_separatorNewMessages o_MessageList_item" t-att-class="{ 'o-disable-animation': env.disableAnimation }" t-transition="fade">
+ <hr class="o_MessageList_separatorLine o_MessageList_separatorLineNewMessages"/><span class="o_MessageList_separatorLabel o_MessageList_separatorLabelNewMessages">New messages</span>
+ </div>
+ </t>
+ <t t-if="!message.isEmpty">
+ <t t-set="message_day" t-value="getDateDay(message)"/>
+ <t t-if="current_day !== message_day">
+ <div class="o_MessageList_separator o_MessageList_separatorDate o_MessageList_item">
+ <hr class="o_MessageList_separatorLine"/><span class="o_MessageList_separatorLabel o_MessageList_separatorLabelDate"><t t-esc="message_day"/></span><hr class="o_MessageList_separatorLine"/>
+ <t t-set="current_day" t-value="message_day"/>
+ <t t-set="isMessageSquashed" t-value="false"/>
+ </div>
+ </t>
+ <t t-else="">
+ <t t-set="isMessageSquashed" t-value="shouldMessageBeSquashed(prev_message, message)"/>
+ </t>
+ <Message
+ class="o_MessageList_item o_MessageList_message"
+ t-att-class="{
+ 'o-has-message-selection': props.selectedMessageLocalId,
+ }"
+ hasMarkAsReadIcon="props.haveMessagesMarkAsReadIcon"
+ hasCheckbox="props.hasMessageCheckbox"
+ hasReplyIcon="props.haveMessagesReplyIcon"
+ isSelected="props.selectedMessageLocalId === message.localId"
+ isSquashed="isMessageSquashed"
+ messageLocalId="message.localId"
+ threadViewLocalId="threadView.localId"
+ t-ref="{{ message.localId }}"
+ />
+ <t t-set="prev_message" t-value="message"/>
+ </t>
+ </t>
+ <!-- LOADING (if order desc)-->
+ <t t-if="props.order === 'desc' and orderedMessages.length > 0">
+ <t t-call="mail.MessageList.loadMore"/>
+ </t>
+ </t>
+ </div>
+ </t>
+
+ <t t-name="mail.MessageList.loadMore" owl="1">
+ <t t-if="threadView.threadCache.isLoadingMore">
+ <div class="o_MessageList_item o_MessageList_isLoadingMore">
+ <i class="o_MessageList_isLoadingMoreIcon fa fa-spin fa-spinner"/>
+ Loading...
+ </div>
+ </t>
+ <t t-elif="!threadView.threadCache.isAllHistoryLoaded and !threadView.thread.isTemporary">
+ <a class="o_MessageList_item o_MessageList_loadMore" href="#" t-on-click="_onClickLoadMore" t-ref="loadMore">
+ Load more
+ </a>
+ </t>
+ </t>
+
+</templates>