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 * component is patched. This is acceptable when 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; });