diff options
| author | stephanchrst <stephanchrst@gmail.com> | 2022-05-10 21:51:50 +0700 |
|---|---|---|
| committer | stephanchrst <stephanchrst@gmail.com> | 2022-05-10 21:51:50 +0700 |
| commit | 3751379f1e9a4c215fb6eb898b4ccc67659b9ace (patch) | |
| tree | a44932296ef4a9b71d5f010906253d8c53727726 /addons/mail/static/src/components/message_list | |
| parent | 0a15094050bfde69a06d6eff798e9a8ddf2b8c21 (diff) | |
initial commit 2
Diffstat (limited to 'addons/mail/static/src/components/message_list')
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> |
