From 3751379f1e9a4c215fb6eb898b4ccc67659b9ace Mon Sep 17 00:00:00 2001 From: stephanchrst Date: Tue, 10 May 2022 21:51:50 +0700 Subject: initial commit 2 --- .../static/src/models/thread_view/thread_view.js | 441 +++++++++++++++++++++ .../static/src/models/thread_view/thread_viewer.js | 296 ++++++++++++++ 2 files changed, 737 insertions(+) create mode 100644 addons/mail/static/src/models/thread_view/thread_view.js create mode 100644 addons/mail/static/src/models/thread_view/thread_viewer.js (limited to 'addons/mail/static/src/models/thread_view') diff --git a/addons/mail/static/src/models/thread_view/thread_view.js b/addons/mail/static/src/models/thread_view/thread_view.js new file mode 100644 index 00000000..a7ccf0c7 --- /dev/null +++ b/addons/mail/static/src/models/thread_view/thread_view.js @@ -0,0 +1,441 @@ +odoo.define('mail/static/src/models/thread_view/thread_view.js', function (require) { +'use strict'; + +const { registerNewModel } = require('mail/static/src/model/model_core.js'); +const { RecordDeletedError } = require('mail/static/src/model/model_errors.js'); +const { attr, many2many, many2one, one2one } = require('mail/static/src/model/model_field.js'); +const { clear } = require('mail/static/src/model/model_field_command.js'); + +function factory(dependencies) { + + class ThreadView extends dependencies['mail.model'] { + + /** + * @override + */ + _willDelete() { + this.env.browser.clearTimeout(this._loaderTimeout); + return super._willDelete(...arguments); + } + + //---------------------------------------------------------------------- + // Public + //---------------------------------------------------------------------- + + /** + * This function register a hint for the component related to this + * record. Hints are information on changes around this viewer that + * make require adjustment on the component. For instance, if this + * ThreadView initiated a thread cache load and it now has become + * loaded, then it may need to auto-scroll to last message. + * + * @param {string} hintType name of the hint. Used to determine what's + * the broad type of adjustement the component has to do. + * @param {any} [hintData] data of the hint. Used to fine-tune + * adjustments on the component. + */ + addComponentHint(hintType, hintData) { + const hint = { data: hintData, type: hintType }; + this.update({ + componentHintList: this.componentHintList.concat([hint]), + }); + } + + /** + * @param {Object} hint + */ + markComponentHintProcessed(hint) { + this.update({ + componentHintList: this.componentHintList.filter(h => h !== hint), + }); + this.env.messagingBus.trigger('o-thread-view-hint-processed', { + hint, + threadViewer: this.threadViewer, + }); + } + + /** + * @param {mail.message} message + */ + handleVisibleMessage(message) { + if (!this.lastVisibleMessage || this.lastVisibleMessage.id < message.id) { + this.update({ lastVisibleMessage: [['link', message]] }); + } + } + + //---------------------------------------------------------------------- + // Private + //---------------------------------------------------------------------- + + /** + * @private + * @returns {mail.messaging} + */ + _computeMessaging() { + return [['link', this.env.messaging]]; + } + + /** + * @private + * @returns {string[]} + */ + _computeTextInputSendShortcuts() { + if (!this.thread) { + return; + } + const isMailingList = this.thread.model === 'mail.channel' && this.thread.mass_mailing; + // Actually in mobile there is a send button, so we need there 'enter' to allow new line. + // Hence, we want to use a different shortcut 'ctrl/meta enter' to send for small screen + // size with a non-mailing channel. + // here send will be done on clicking the button or using the 'ctrl/meta enter' shortcut. + if (this.env.messaging.device.isMobile || isMailingList) { + return ['ctrl-enter', 'meta-enter']; + } + return ['enter']; + } + + /** + * @private + * @returns {integer|undefined} + */ + _computeThreadCacheInitialScrollHeight() { + if (!this.threadCache) { + return clear(); + } + const threadCacheInitialScrollHeight = this.threadCacheInitialScrollHeights[this.threadCache.localId]; + if (threadCacheInitialScrollHeight !== undefined) { + return threadCacheInitialScrollHeight; + } + return clear(); + } + + /** + * @private + * @returns {integer|undefined} + */ + _computeThreadCacheInitialScrollPosition() { + if (!this.threadCache) { + return clear(); + } + const threadCacheInitialScrollPosition = this.threadCacheInitialScrollPositions[this.threadCache.localId]; + if (threadCacheInitialScrollPosition !== undefined) { + return threadCacheInitialScrollPosition; + } + return clear(); + } + + /** + * Not a real field, used to trigger `thread.markAsSeen` when one of + * the dependencies changes. + * + * @private + * @returns {boolean} + */ + _computeThreadShouldBeSetAsSeen() { + if (!this.thread) { + return; + } + if (!this.thread.lastNonTransientMessage) { + return; + } + if (!this.lastVisibleMessage) { + return; + } + if (this.lastVisibleMessage !== this.lastMessage) { + return; + } + if (!this.hasComposerFocus) { + // FIXME condition should not be on "composer is focused" but "threadView is active" + // See task-2277543 + return; + } + this.thread.markAsSeen(this.thread.lastNonTransientMessage).catch(e => { + // prevent crash when executing compute during destroy + if (!(e instanceof RecordDeletedError)) { + throw e; + } + }); + } + + /** + * @private + */ + _onThreadCacheChanged() { + // clear obsolete hints + this.update({ componentHintList: clear() }); + this.addComponentHint('change-of-thread-cache'); + if (this.threadCache) { + this.threadCache.update({ + isCacheRefreshRequested: true, + isMarkAllAsReadRequested: true, + }); + } + this.update({ lastVisibleMessage: [['unlink']] }); + } + + /** + * @private + */ + _onThreadCacheIsLoadingChanged() { + if (this.threadCache && this.threadCache.isLoading) { + if (!this.isLoading && !this.isPreparingLoading) { + this.update({ isPreparingLoading: true }); + this.async(() => + new Promise(resolve => { + this._loaderTimeout = this.env.browser.setTimeout(resolve, 400); + } + )).then(() => { + const isLoading = this.threadCache + ? this.threadCache.isLoading + : false; + this.update({ isLoading, isPreparingLoading: false }); + }); + } + return; + } + this.env.browser.clearTimeout(this._loaderTimeout); + this.update({ isLoading: false, isPreparingLoading: false }); + } + } + + ThreadView.fields = { + checkedMessages: many2many('mail.message', { + related: 'threadCache.checkedMessages', + }), + /** + * List of component hints. Hints contain information that help + * components make UI/UX decisions based on their UI state. + * For instance, on receiving new messages and the last message + * is visible, it should auto-scroll to this new last message. + * + * Format of a component hint: + * + * { + * type: {string} the name of the component hint. Useful + * for components to dispatch behaviour + * based on its type. + * data: {Object} data related to the component hint. + * For instance, if hint suggests to scroll + * to a certain message, data may contain + * message id. + * } + */ + componentHintList: attr({ + default: [], + }), + composer: many2one('mail.composer', { + related: 'thread.composer', + }), + /** + * Serves as compute dependency. + */ + device: one2one('mail.device', { + related: 'messaging.device', + }), + /** + * Serves as compute dependency. + */ + deviceIsMobile: attr({ + related: 'device.isMobile', + }), + hasComposerFocus: attr({ + related: 'composer.hasFocus', + }), + /** + * States whether `this.threadCache` is currently loading messages. + * + * This field is related to `this.threadCache.isLoading` but with a + * delay on its update to avoid flickering on the UI. + * + * It is computed through `_onThreadCacheIsLoadingChanged` and it should + * otherwise be considered read-only. + */ + isLoading: attr({ + default: false, + }), + /** + * States whether `this` is aware of `this.threadCache` currently + * loading messages, but `this` is not yet ready to display that loading + * on the UI. + * + * This field is computed through `_onThreadCacheIsLoadingChanged` and + * it should otherwise be considered read-only. + * + * @see `this.isLoading` + */ + isPreparingLoading: attr({ + default: false, + }), + /** + * Determines whether `this` should automatically scroll on receiving + * a new message. Detection of new message is done through the component + * hint `message-received`. + */ + hasAutoScrollOnMessageReceived: attr({ + default: true, + }), + /** + * Last message in the context of the currently displayed thread cache. + */ + lastMessage: many2one('mail.message', { + related: 'thread.lastMessage', + }), + /** + * Serves as compute dependency. + */ + lastNonTransientMessage: many2one('mail.message', { + related: 'thread.lastNonTransientMessage', + }), + /** + * Most recent message in this ThreadView that has been shown to the + * current partner in the currently displayed thread cache. + */ + lastVisibleMessage: many2one('mail.message'), + messages: many2many('mail.message', { + related: 'threadCache.messages', + }), + /** + * Serves as compute dependency. + */ + messaging: many2one('mail.messaging', { + compute: '_computeMessaging', + }), + nonEmptyMessages: many2many('mail.message', { + related: 'threadCache.nonEmptyMessages', + }), + /** + * Not a real field, used to trigger `_onThreadCacheChanged` when one of + * the dependencies changes. + */ + onThreadCacheChanged: attr({ + compute: '_onThreadCacheChanged', + dependencies: [ + 'threadCache' + ], + }), + /** + * Not a real field, used to trigger `_onThreadCacheIsLoadingChanged` + * when one of the dependencies changes. + * + * @see `this.isLoading` + */ + onThreadCacheIsLoadingChanged: attr({ + compute: '_onThreadCacheIsLoadingChanged', + dependencies: [ + 'threadCache', + 'threadCacheIsLoading', + ], + }), + /** + * Determines the domain to apply when fetching messages for `this.thread`. + */ + stringifiedDomain: attr({ + related: 'threadViewer.stringifiedDomain', + }), + /** + * Determines the keyboard shortcuts that are available to send a message + * from the composer of this thread viewer. + */ + textInputSendShortcuts: attr({ + compute: '_computeTextInputSendShortcuts', + dependencies: [ + 'device', + 'deviceIsMobile', + 'thread', + 'threadMassMailing', + 'threadModel', + ], + }), + /** + * Determines the `mail.thread` currently displayed by `this`. + */ + thread: many2one('mail.thread', { + inverse: 'threadViews', + related: 'threadViewer.thread', + }), + /** + * States the `mail.thread_cache` currently displayed by `this`. + */ + threadCache: many2one('mail.thread_cache', { + inverse: 'threadViews', + related: 'threadViewer.threadCache', + }), + threadCacheInitialScrollHeight: attr({ + compute: '_computeThreadCacheInitialScrollHeight', + dependencies: [ + 'threadCache', + 'threadCacheInitialScrollHeights', + ], + }), + threadCacheInitialScrollPosition: attr({ + compute: '_computeThreadCacheInitialScrollPosition', + dependencies: [ + 'threadCache', + 'threadCacheInitialScrollPositions', + ], + }), + /** + * Serves as compute dependency. + */ + threadCacheIsLoading: attr({ + related: 'threadCache.isLoading', + }), + /** + * List of saved initial scroll heights of thread caches. + */ + threadCacheInitialScrollHeights: attr({ + default: {}, + related: 'threadViewer.threadCacheInitialScrollHeights', + }), + /** + * List of saved initial scroll positions of thread caches. + */ + threadCacheInitialScrollPositions: attr({ + default: {}, + related: 'threadViewer.threadCacheInitialScrollPositions', + }), + /** + * Serves as compute dependency. + */ + threadMassMailing: attr({ + related: 'thread.mass_mailing', + }), + /** + * Serves as compute dependency. + */ + threadModel: attr({ + related: 'thread.model', + }), + /** + * Not a real field, used to trigger `thread.markAsSeen` when one of + * the dependencies changes. + */ + threadShouldBeSetAsSeen: attr({ + compute: '_computeThreadShouldBeSetAsSeen', + dependencies: [ + 'hasComposerFocus', + 'lastMessage', + 'lastNonTransientMessage', + 'lastVisibleMessage', + 'threadCache', + ], + }), + /** + * Determines the `mail.thread_viewer` currently managing `this`. + */ + threadViewer: one2one('mail.thread_viewer', { + inverse: 'threadView', + }), + uncheckedMessages: many2many('mail.message', { + related: 'threadCache.uncheckedMessages', + }), + }; + + ThreadView.modelName = 'mail.thread_view'; + + return ThreadView; +} + +registerNewModel('mail.thread_view', factory); + +}); diff --git a/addons/mail/static/src/models/thread_view/thread_viewer.js b/addons/mail/static/src/models/thread_view/thread_viewer.js new file mode 100644 index 00000000..c78022d4 --- /dev/null +++ b/addons/mail/static/src/models/thread_view/thread_viewer.js @@ -0,0 +1,296 @@ +odoo.define('mail/static/src/models/thread_viewer/thread_viewer.js', function (require) { +'use strict'; + +const { registerNewModel } = require('mail/static/src/model/model_core.js'); +const { attr, many2one, one2one } = require('mail/static/src/model/model_field.js'); + +function factory(dependencies) { + + class ThreadViewer extends dependencies['mail.model'] { + + //---------------------------------------------------------------------- + // Public + //---------------------------------------------------------------------- + + /** + * @param {integer} scrollHeight + * @param {mail.thread_cache} threadCache + */ + saveThreadCacheScrollHeightAsInitial(scrollHeight, threadCache) { + threadCache = threadCache || this.threadCache; + if (!threadCache) { + return; + } + if (this.chatter) { + // Initial scroll height is disabled for chatter because it is + // too complex to handle correctly and less important + // functionally. + return; + } + this.update({ + threadCacheInitialScrollHeights: Object.assign({}, this.threadCacheInitialScrollHeights, { + [threadCache.localId]: scrollHeight, + }), + }); + } + + /** + * @param {integer} scrollTop + * @param {mail.thread_cache} threadCache + */ + saveThreadCacheScrollPositionsAsInitial(scrollTop, threadCache) { + threadCache = threadCache || this.threadCache; + if (!threadCache) { + return; + } + if (this.chatter) { + // Initial scroll position is disabled for chatter because it is + // too complex to handle correctly and less important + // functionally. + return; + } + this.update({ + threadCacheInitialScrollPositions: Object.assign({}, this.threadCacheInitialScrollPositions, { + [threadCache.localId]: scrollTop, + }), + }); + } + + //---------------------------------------------------------------------- + // Private + //---------------------------------------------------------------------- + + /** + * @private + * @returns {boolean} + */ + _computeHasThreadView() { + if (this.chatter) { + return this.chatter.hasThreadView; + } + if (this.chatWindow) { + return this.chatWindow.hasThreadView; + } + if (this.discuss) { + return this.discuss.hasThreadView; + } + return this.hasThreadView; + } + + /** + * @private + * @returns {string} + */ + _computeStringifiedDomain() { + if (this.chatter) { + return '[]'; + } + if (this.chatWindow) { + return '[]'; + } + if (this.discuss) { + return this.discuss.stringifiedDomain; + } + return this.stringifiedDomain; + } + + /** + * @private + * @returns {mail.thread|undefined} + */ + _computeThread() { + if (this.chatter) { + if (!this.chatter.thread) { + return [['unlink']]; + } + return [['link', this.chatter.thread]]; + } + if (this.chatWindow) { + if (!this.chatWindow.thread) { + return [['unlink']]; + } + return [['link', this.chatWindow.thread]]; + } + if (this.discuss) { + if (!this.discuss.thread) { + return [['unlink']]; + } + return [['link', this.discuss.thread]]; + } + return []; + } + + /** + * @private + * @returns {mail.thread_cache|undefined} + */ + _computeThreadCache() { + if (!this.thread) { + return [['unlink']]; + } + return [['link', this.thread.cache(this.stringifiedDomain)]]; + } + + /** + * @private + * @returns {mail.thread_viewer|undefined} + */ + _computeThreadView() { + if (!this.hasThreadView) { + return [['unlink']]; + } + if (this.threadView) { + return []; + } + return [['create']]; + } + + } + + ThreadViewer.fields = { + /** + * States the `mail.chatter` managing `this`. This field is computed + * through the inverse relation and should be considered read-only. + */ + chatter: one2one('mail.chatter', { + inverse: 'threadViewer', + }), + /** + * Serves as compute dependency. + */ + chatterHasThreadView: attr({ + related: 'chatter.hasThreadView', + }), + /** + * Serves as compute dependency. + */ + chatterThread: many2one('mail.thread', { + related: 'chatter.thread', + }), + /** + * States the `mail.chat_window` managing `this`. This field is computed + * through the inverse relation and should be considered read-only. + */ + chatWindow: one2one('mail.chat_window', { + inverse: 'threadViewer', + }), + /** + * Serves as compute dependency. + */ + chatWindowHasThreadView: attr({ + related: 'chatWindow.hasThreadView', + }), + /** + * Serves as compute dependency. + */ + chatWindowThread: many2one('mail.thread', { + related: 'chatWindow.thread', + }), + /** + * States the `mail.discuss` managing `this`. This field is computed + * through the inverse relation and should be considered read-only. + */ + discuss: one2one('mail.discuss', { + inverse: 'threadViewer', + }), + /** + * Serves as compute dependency. + */ + discussHasThreadView: attr({ + related: 'discuss.hasThreadView', + }), + /** + * Serves as compute dependency. + */ + discussStringifiedDomain: attr({ + related: 'discuss.stringifiedDomain', + }), + /** + * Serves as compute dependency. + */ + discussThread: many2one('mail.thread', { + related: 'discuss.thread', + }), + /** + * Determines whether `this.thread` should be displayed. + */ + hasThreadView: attr({ + compute: '_computeHasThreadView', + default: false, + dependencies: [ + 'chatterHasThreadView', + 'chatWindowHasThreadView', + 'discussHasThreadView', + ], + }), + /** + * Determines the domain to apply when fetching messages for `this.thread`. + */ + stringifiedDomain: attr({ + compute: '_computeStringifiedDomain', + default: '[]', + dependencies: [ + 'discussStringifiedDomain', + ], + }), + /** + * Determines the `mail.thread` that should be displayed by `this`. + */ + thread: many2one('mail.thread', { + compute: '_computeThread', + dependencies: [ + 'chatterThread', + 'chatWindowThread', + 'discussThread', + ], + }), + /** + * States the `mail.thread_cache` that should be displayed by `this`. + */ + threadCache: many2one('mail.thread_cache', { + compute: '_computeThreadCache', + dependencies: [ + 'stringifiedDomain', + 'thread', + ], + }), + /** + * Determines the initial scroll height of thread caches, which is the + * scroll height at the time the last scroll position was saved. + * Useful to only restore scroll position when the corresponding height + * is available, otherwise the restore makes no sense. + */ + threadCacheInitialScrollHeights: attr({ + default: {}, + }), + /** + * Determines the initial scroll positions of thread caches. + * Useful to restore scroll position on changing back to this + * thread cache. Note that this is only applied when opening + * the thread cache, because scroll position may change fast so + * save is already throttled. + */ + threadCacheInitialScrollPositions: attr({ + default: {}, + }), + /** + * States the `mail.thread_view` currently displayed and managed by `this`. + */ + threadView: one2one('mail.thread_view', { + compute: '_computeThreadView', + dependencies: [ + 'hasThreadView', + ], + inverse: 'threadViewer', + isCausal: true, + }), + }; + + ThreadViewer.modelName = 'mail.thread_viewer'; + + return ThreadViewer; +} + +registerNewModel('mail.thread_viewer', factory); + +}); -- cgit v1.2.3