summaryrefslogtreecommitdiff
path: root/addons/mail/static/src/models/thread_view
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/models/thread_view
parent0a15094050bfde69a06d6eff798e9a8ddf2b8c21 (diff)
initial commit 2
Diffstat (limited to 'addons/mail/static/src/models/thread_view')
-rw-r--r--addons/mail/static/src/models/thread_view/thread_view.js441
-rw-r--r--addons/mail/static/src/models/thread_view/thread_viewer.js296
2 files changed, 737 insertions, 0 deletions
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);
+
+});