', {
- class: 'o_mail_read_more',
- href: '#',
- text: READ_MORE,
- }).insertBefore(group[0]);
-
- // Toggle All next nodes
- var isReadMore = true;
- $readMore.click(function (e) {
- e.preventDefault();
- isReadMore = !isReadMore;
- _.each(group, function ($child) {
- $child.hide();
- $child.toggle(!isReadMore);
- });
- $readMore.text(isReadMore ? READ_MORE : READ_LESS);
- });
- });
- },
- /**
- * @private
- * @param {MouseEvent} ev
- */
- _onDeleteAttachment: function (ev) {
- ev.stopPropagation();
- var $target = $(ev.currentTarget);
- this.trigger_up('delete_attachment', {
- attachmentId: $target.data('id'),
- attachmentName: $target.data('name')
- });
- },
- /**
- * @private
- * @param {Object} options
- * @param {integer} [options.channelID]
- * @param {string} options.model
- * @param {integer} options.id
- */
- _redirect: _.debounce(function (options) {
- if ('channelID' in options) {
- this.trigger('redirect_to_channel', options.channelID);
- } else {
- this.trigger('redirect', options.model, options.id);
- }
- }, 500, true),
- /**
- * Render the popover when mouse-hovering on the notification icon of a
- * message in the thread.
- * There is at most one such popover at any given time.
- *
- * @private
- * @param {mail.model.AbstractMessage[]} messages list of messages in the
- * rendered thread, for which popover on mouseover interaction is
- * permitted.
- */
- _renderMessageNotificationPopover(messages) {
- if (this._messageMailPopover) {
- this._messageMailPopover.popover('hide');
- }
- if (!this.$('.o_thread_tooltip').length) {
- return;
- }
- this._messageMailPopover = this.$('.o_thread_tooltip').popover({
- html: true,
- boundary: 'viewport',
- placement: 'auto',
- trigger: 'hover',
- offset: '0, 1',
- content: function () {
- var messageID = $(this).data('message-id');
- var message = _.find(messages, function (message) {
- return message.getID() === messageID;
- });
- return QWeb.render('mail.widget.Thread.Message.MailTooltip', {
- notifications: message.getNotifications(),
- });
- },
- });
- },
- /**
- * Render the popover when mouse hovering on the seen icon of a message
- * in the thread. Only seen icons in non-squashed message have popover,
- * because squashed messages hides this icon on message mouseover.
- *
- * @private
- * @param {mail.model.AbstractThread} thread with thread seen mixin,
- * @see {mail.model.ThreadSeenMixin}
- * @param {mail.model.Message[]} messages list of messages in the
- * rendered thread.
- */
- _renderMessageSeenPopover: function (thread, messages) {
- var self = this;
- this._destroyOpenSeenPopoverIDs();
- if (this._messageSeenPopover) {
- this._messageSeenPopover.popover('hide');
- }
- if (!this.$('.o_thread_message_core .o_mail_thread_message_seen_icon').length) {
- return;
- }
- this._messageSeenPopover = this.$('.o_thread_message_core .o_mail_thread_message_seen_icon').popover({
- html: true,
- boundary: 'viewport',
- placement: 'auto',
- trigger: 'hover',
- offset: '0, 1',
- content: function () {
- var $this = $(this);
- self._openedSeenPopoverIDs.push($this.attr('aria-describedby'));
- var messageID = $this.data('message-id');
- var message = _.find(messages, function (message) {
- return message.getID() === messageID;
- });
- return QWeb.render('mail.widget.Thread.Message.SeenIconPopoverContent', {
- thread: thread,
- message: message,
- });
- },
- });
- },
- /**
- * @private
- */
- _updateTimestamps: function () {
- var isAtBottom = this.isAtBottom();
- this.$('.o_mail_timestamp').each(function () {
- var date = $(this).data('date');
- $(this).html(mailUtils.timeFromNow(date));
- });
- if (isAtBottom && !this.isAtBottom()) {
- this.scrollToBottom();
- }
- },
-
- //--------------------------------------------------------------------------
- // Handlers
- //--------------------------------------------------------------------------
-
- /**
- * @private
- * @param {MouseEvent} event
- */
- _onAttachmentDownload: function (event) {
- event.stopPropagation();
- },
- /**
- * @private
- * @param {MouseEvent} event
- */
- _onAttachmentView: function (event) {
- event.stopPropagation();
- var activeAttachmentID = $(event.currentTarget).data('id');
- if (activeAttachmentID) {
- var attachmentViewer = new DocumentViewer(this, this.attachments, activeAttachmentID);
- attachmentViewer.appendTo($('body'));
- }
- },
- /**
- * @private
- * @param {MouseEvent} ev
- */
- _onChangeModerationCheckbox: function (ev) {
- this.trigger_up('update_moderation_buttons');
- },
- /**
- * @private
- */
- _onClick: function () {
- if (this._selectedMessageID) {
- this.unselectMessage();
- this.trigger('unselect_message');
- }
- },
- /**
- * @private
- * @param {MouseEvent} ev
- */
- _onClickMailExpand: function (ev) {
- ev.preventDefault();
- },
- /**
- * @private
- * @param {MouseEvent} ev
- */
- _onClickMessage: function (ev) {
- $(ev.currentTarget).toggleClass('o_thread_selected_message');
- },
- /**
- * @private
- * @param {MouseEvent} ev
- */
- _onClickMessageNeedaction: function (ev) {
- var messageID = $(ev.currentTarget).data('message-id');
- this.trigger('mark_as_read', messageID);
- },
- /**
- * @private
- * @param {MouseEvent} ev
- */
- _onClickMessageNotificationError(ev) {
- const messageID = $(ev.currentTarget).data('message-id');
- this.do_action('mail.mail_resend_message_action', {
- additional_context: {
- mail_message_to_resend: messageID,
- }
- });
- },
- /**
- * @private
- * @param {MouseEvent} ev
- */
- _onClickMessageReply: function (ev) {
- this._selectedMessageID = $(ev.currentTarget).data('message-id');
- this.$('.o_thread_message').removeClass('o_thread_selected_message');
- this.$('.o_thread_message[data-message-id="' + this._selectedMessageID + '"]')
- .addClass('o_thread_selected_message');
- this.trigger('select_message', this._selectedMessageID);
- ev.stopPropagation();
- },
- /**
- * @private
- * @param {MouseEvent} ev
- */
- _onClickMessageStar: function (ev) {
- var messageID = $(ev.currentTarget).data('message-id');
- this.trigger('toggle_star_status', messageID);
- },
- /**
- * @private
- * @param {MouseEvent} ev
- */
- _onClickMessageModeration: function (ev) {
- var $button = $(ev.currentTarget);
- var messageID = $button.data('message-id');
- var decision = $button.data('decision');
- this.trigger_up('message_moderation', {
- messageID: messageID,
- decision: decision,
- });
- },
- /**
- * @private
- * @param {MouseEvent} ev
- */
- _onClickRedirect: function (ev) {
- // ignore inherited branding
- if ($(ev.target).data('oe-field') !== undefined) {
- return;
- }
- var id = $(ev.target).data('oe-id');
- if (id) {
- ev.preventDefault();
- var model = $(ev.target).data('oe-model');
- var options;
- if (model && (model !== 'mail.channel')) {
- options = {
- model: model,
- id: id
- };
- } else {
- options = { channelID: id };
- }
- this._redirect(options);
- }
- },
- /**
- * @private
- */
- _onClickShowMore: function () {
- this.trigger('load_more_messages');
- },
-});
-
-ThreadWidget.ORDER = ORDER;
-
-return ThreadWidget;
-
-});
diff --git a/addons/mail/static/src/js/thread_windows/abstract_thread_window.js b/addons/mail/static/src/js/thread_windows/abstract_thread_window.js
deleted file mode 100644
index 7e39f9c7e2e..00000000000
--- a/addons/mail/static/src/js/thread_windows/abstract_thread_window.js
+++ /dev/null
@@ -1,519 +0,0 @@
-odoo.define('mail.AbstractThreadWindow', function (require) {
-"use strict";
-
-var ThreadWidget = require('mail.widget.Thread');
-
-var config = require('web.config');
-var core = require('web.core');
-var Widget = require('web.Widget');
-
-var QWeb = core.qweb;
-var _t = core._t;
-
-/**
- * This is an abstract widget for rendering thread windows.
- *
- * It contains logic that are shared between mail.ThreadWindow and
- * im_livechat.WebsiteLivechatWindow.
- *
- * The reason for having two different implementation of thread windows is
- * that mail.ThreadWindow makes use of mail.Manager, which is used in the
- * backend, while im_livechat.WebsiteLivechatWindow must work without this
- * mail service.
- */
-var AbstractThreadWindow = Widget.extend({
- template: 'mail.AbstractThreadWindow',
- custom_events: {
- escape_pressed: '_onEscapePressed',
- document_viewer_closed: '_onDocumentViewerClose',
- },
- events: {
- 'click .o_thread_window_close': '_onClickClose',
- 'click .o_thread_window_title': '_onClickFold',
- 'click .o_composer_text_field': '_onComposerClick',
- 'click .o_mail_thread': '_onThreadWindowClicked',
- 'keydown .o_composer_text_field': '_onKeydown',
- 'keypress .o_composer_text_field': '_onKeypress',
- },
- FOLD_ANIMATION_DURATION: 200, // duration in ms for (un)fold transition
- HEIGHT_OPEN: '400px', // height in px of thread window when open
- HEIGHT_FOLDED: '34px', // height, in px, of thread window when folded
- /**
- * Children of this class must make use of `thread`, which is an object that
- * represent the thread that is linked to this thread window.
- *
- * If no thread is provided, this will represent the "blank" thread window.
- *
- * @abstract
- * @param {Widget} parent
- * @param {mail.model.AbstractThread} [thread=null] the thread that this
- * thread window is linked to. If not set, it is the "blank" thread
- * window.
- * @param {Object} [options={}]
- * @param {mail.model.AbstractThread} [options.thread]
- */
- init: function (parent, thread, options) {
- this._super(parent);
-
- this.options = _.defaults(options || {}, {
- autofocus: true,
- displayStars: true,
- displayReplyIcons: false,
- displayNotificationIcons: false,
- placeholder: _t("Say something"),
- });
-
- this._hidden = false;
- this._thread = thread || null;
-
- this._debouncedOnScroll = _.debounce(this._onScroll.bind(this), 100);
-
- if (!this.hasThread()) {
- // internal fold state of thread window without any thread
- this._folded = false;
- }
- },
- start: function () {
- var self = this;
- this.$input = this.$('.o_composer_text_field');
- this.$header = this.$('.o_thread_window_header');
- var options = {
- displayMarkAsRead: false,
- displayStars: this.options.displayStars,
- };
- if (this._thread && this._thread._type === 'document_thread') {
- options.displayDocumentLinks = false;
- }
- this._threadWidget = new ThreadWidget(this, options);
-
- // animate the (un)folding of thread windows
- this.$el.css({transition: 'height ' + this.FOLD_ANIMATION_DURATION + 'ms linear'});
- if (this.isFolded()) {
- this.$el.css('height', this.HEIGHT_FOLDED);
- } else if (this.options.autofocus) {
- this._focusInput();
- }
- if (!config.device.isMobile) {
- var margin_dir = _t.database.parameters.direction === "rtl" ? "margin-left" : "margin-right";
- this.$el.css(margin_dir, $.position.scrollbarWidth());
- }
- var def = this._threadWidget.replace(this.$('.o_thread_window_content')).then(function () {
- self._threadWidget.$el.on('scroll', self, self._debouncedOnScroll);
- });
- return Promise.all([this._super(), def]);
- },
- /**
- * @override
- */
- do_hide: function () {
- this._hidden = true;
- this._super.apply(this, arguments);
- },
- /**
- * @override
- */
- do_show: function () {
- this._hidden = false;
- this._super.apply(this, arguments);
- },
- /**
- * @override
- */
- do_toggle: function (display) {
- this._hidden = _.isBoolean(display) ? !display : !this._hidden;
- this._super.apply(this, arguments);
- },
-
- //--------------------------------------------------------------------------
- // Public
- //--------------------------------------------------------------------------
-
- /**
- * Close this window
- *
- * @abstract
- */
- close: function () {},
- /**
- * Get the ID of the thread window, which is equivalent to the ID of the
- * thread related to this window
- *
- * @returns {integer|string}
- */
- getID: function () {
- return this._getThreadID();
- },
- /**
- * @returns {mail.model.Thread|undefined}
- */
- getThread: function () {
- if (!this.hasThread()) {
- return undefined;
- }
- return this._thread;
- },
- /**
- * Get the status of the thread, such as the im status of a DM chat
- * ('online', 'offline', etc.). If this window has no thread, returns
- * `undefined`.
- *
- * @returns {string|undefined}
- */
- getThreadStatus: function () {
- if (!this.hasThread()) {
- return undefined;
- }
- return this._thread.getStatus();
- },
- /**
- * Get the title of the thread window, which usually contains the name of
- * the thread.
- *
- * @returns {string}
- */
- getTitle: function () {
- if (!this.hasThread()) {
- return _t("Undefined");
- }
- return this._thread.getTitle();
- },
- /**
- * Get the unread counter of the related thread. If there are no thread
- * linked to this window, returns 0.
- *
- * @returns {integer}
- */
- getUnreadCounter: function () {
- if (!this.hasThread()) {
- return 0;
- }
- return this._thread.getUnreadCounter();
- },
- /**
- * States whether this thread window is related to a thread or not.
- *
- * This is useful in order to provide specific behaviour for thread windows
- * without any thread, e.g. let them open a thread from this "blank" thread
- * window.
- *
- * @returns {boolean}
- */
- hasThread: function () {
- return !! this._thread;
- },
- /**
- * Tells whether the bottom of the thread in the thread window is visible
- * or not.
- *
- * @returns {boolean}
- */
- isAtBottom: function () {
- return this._threadWidget.isAtBottom();
- },
- /**
- * State whether the related thread is folded or not. If there are no
- * thread related to this window, it means this is the "blank" thread
- * window, therefore we use the internal folded state.
- *
- * @returns {boolean}
- */
- isFolded: function () {
- if (!this.hasThread()) {
- return this._folded;
- }
- return this._thread.isFolded();
- },
- /**
- * States whether the current environment is in mobile or not. This is
- * useful in order to customize the template rendering for mobile view.
- *
- * @returns {boolean}
- */
- isMobile: function () {
- return config.device.isMobile;
- },
- /**
- * States whether the thread window is hidden or not.
- *
- * @returns {boolean}
- */
- isHidden: function () {
- return this._hidden;
- },
- /**
- * States whether the input of the thread window should be displayed or not.
- * By default, any thread window with a thread needs a composer.
- *
- * @returns {boolean}
- */
- needsComposer: function () {
- return this.hasThread();
- },
- /**
- * Render the thread window
- */
- render: function () {
- this.renderHeader();
- if (this.hasThread()) {
- this._threadWidget.render(this._thread, { displayLoadMore: false });
- }
- },
- /**
- * Render the header of this thread window.
- * This is useful when some information on the header have be updated such
- * as the status or the title of the thread that have changed.
- *
- * @private
- */
- renderHeader: function () {
- var options = this._getHeaderRenderingOptions();
- this.$header.html(
- QWeb.render('mail.AbstractThreadWindow.HeaderContent', options));
- },
- /**
- * Scroll to the bottom of the thread in the thread window
- */
- scrollToBottom: function () {
- this._threadWidget.scrollToBottom();
- },
- /**
- * Toggle the fold state of this thread window. Also update the fold state
- * of the thread model. If the boolean parameter `folded` is provided, it
- * folds/unfolds the window when it is set/unset.
- *
- * @param {boolean} [folded] if not a boolean, toggle the fold state.
- * Otherwise, fold/unfold the window if set/unset.
- */
- toggleFold: function (folded) {
- if (!_.isBoolean(folded)) {
- folded = !this.isFolded();
- }
- this._updateThreadFoldState(folded);
- },
- /**
- * Update the visual state of the window so that it matched the internal
- * fold state. This is useful in case the related thread has its fold state
- * that has been changed.
- */
- updateVisualFoldState: function () {
- if (!this.isFolded()) {
- this._threadWidget.scrollToBottom();
- if (this.options.autofocus) {
- this._focusInput();
- }
- }
- var height = this.isFolded() ? this.HEIGHT_FOLDED : this.HEIGHT_OPEN;
- this.$el.css({height: height});
- },
-
- //--------------------------------------------------------------------------
- // Private
- //--------------------------------------------------------------------------
-
- /**
- * Set the focus on the composer of the thread window. This operation is
- * ignored in mobile context.
- *
- * @private
- * Set the focus on the input of the window
- */
- _focusInput: function () {
- if (
- config.device.touch &&
- config.device.size_class <= config.device.SIZES.SM
- ) {
- return;
- }
- this.$input.focus();
- },
- /**
- * Returns the options used by the rendering of the window's header
- *
- * @private
- * @returns {Object}
- */
- _getHeaderRenderingOptions: function () {
- return {
- status: this.getThreadStatus(),
- thread: this.getThread(),
- title: this.getTitle(),
- unreadCounter: this.getUnreadCounter(),
- widget: this,
- };
- },
- /**
- * Get the ID of the related thread.
- * If this window is not related to a thread, it means this is the "blank"
- * thread window, therefore it returns "_blank" as its ID.
- *
- * @private
- * @returns {integer|string} the threadID, or '_blank' for the window that
- * is not related to any thread.
- */
- _getThreadID: function () {
- if (!this.hasThread()) {
- return '_blank';
- }
- return this._thread.getID();
- },
- /**
- * Tells whether there is focus on this thread. Note that a thread that has
- * the focus means the input has focus.
- *
- * @private
- * @returns {boolean}
- */
- _hasFocus: function () {
- return this.$input.is(':focus');
- },
- /**
- * Post a message on this thread window, and auto-scroll to the bottom of
- * the thread.
- *
- * @private
- * @param {Object} messageData
- */
- _postMessage: function (messageData) {
- var self = this;
- if (!this.hasThread()) {
- return;
- }
- this._thread.postMessage(messageData)
- .then(function () {
- self._threadWidget.scrollToBottom();
- });
- },
- /**
- * Update the fold state of the thread.
- *
- * This function is called when toggling the fold state of this window.
- * If there is no thread linked to this window, it means this is the
- * "blank" thread window, therefore we use the internal state 'folded'
- *
- * @private
- * @param {boolean} folded
- */
- _updateThreadFoldState: function (folded) {
- if (this.hasThread()) {
- this._thread.fold(folded);
- } else {
- this._folded = folded;
- this.updateVisualFoldState();
- }
- },
-
- //--------------------------------------------------------------------------
- // Handlers
- //--------------------------------------------------------------------------
-
- /**
- * Close the thread window.
- * Mark the thread as read if the thread window was open.
- *
- * @private
- * @param {MouseEvent} ev
- */
- _onClickClose: function (ev) {
- ev.stopPropagation();
- ev.preventDefault();
- if (
- this.hasThread() &&
- this._thread.getUnreadCounter() > 0 &&
- !this.isFolded()
- ) {
- this._thread.markAsRead();
- }
- this.close();
- },
- /**
- * Fold/unfold the thread window.
- * Also mark the thread as read.
- *
- * @private
- */
- _onClickFold: function () {
- if (!config.device.isMobile) {
- this.toggleFold();
- }
- },
- /**
- * Called when the composer is clicked -> forces focus on input even if
- * jquery's blockUI is enabled.
- *
- * @private
- * @param {Event} ev
- */
- _onComposerClick: function (ev) {
- if ($(ev.target).closest('a, button').length) {
- return;
- }
- this._focusInput();
- },
- /**
- * @private
- */
- _onDocumentViewerClose: function () {
- this._focusInput();
- },
- /**
- * @private
- */
- _onEscapePressed: function () {
- if (!this.isFolded()) {
- this.close();
- }
- },
- /**
- * Called when typing something on the composer of this thread window.
- *
- * @private
- * @param {KeyboardEvent} ev
- */
- _onKeydown: function (ev) {
- ev.stopPropagation(); // to prevent jquery's blockUI to cancel event
- // ENTER key (avoid requiring jquery ui for external livechat)
- if (ev.which === 13) {
- var content = _.str.trim(this.$input.val());
- var messageData = {
- content: content,
- attachment_ids: [],
- partner_ids: [],
- };
- this.$input.val('');
- if (content) {
- this._postMessage(messageData);
- }
- }
- },
- /**
- * @private
- * @param {KeyboardEvent} ev
- */
- _onKeypress: function (ev) {
- ev.stopPropagation(); // to prevent jquery's blockUI to cancel event
- },
- /**
- * @private
- */
- _onScroll: function () {
- if (this.hasThread() && this.isAtBottom()) {
- this._thread.markAsRead();
- }
- },
- /**
- * When a thread window is clicked on, we want to give the focus to the main
- * input. An exception is made when the user is selecting something.
- *
- * @private
- */
- _onThreadWindowClicked: function () {
- var selectObj = window.getSelection();
- if (selectObj.anchorOffset === selectObj.focusOffset) {
- this.$input.focus();
- }
- },
-});
-
-return AbstractThreadWindow;
-
-});
diff --git a/addons/mail/static/src/js/thread_windows/thread_window.js b/addons/mail/static/src/js/thread_windows/thread_window.js
deleted file mode 100644
index 7280f704fa3..00000000000
--- a/addons/mail/static/src/js/thread_windows/thread_window.js
+++ /dev/null
@@ -1,403 +0,0 @@
-odoo.define('mail.ThreadWindow', function (require) {
-"use strict";
-
-var AbstractThreadWindow = require('mail.AbstractThreadWindow');
-var BasicComposer = require('mail.composer.Basic');
-var ExtendedComposer = require('mail.composer.Extended');
-
-var config = require('web.config');
-var core = require('web.core');
-
-var QWeb = core.qweb;
-var _t = core._t;
-
-/**
- * This is the main widget for rendering small windows for mail.model.Thread.
- * Almost all instances of this class are linked to a thread. The sole
- * exception is the "blank" thread window. This window let us open another
- * thread window, using this "blank" thread window.
- */
-var ThreadWindow = AbstractThreadWindow.extend({
- template: 'mail.ThreadWindow',
- custom_events: AbstractThreadWindow.prototype.custom_events,
- events: _.extend({}, AbstractThreadWindow.prototype.events, {
- 'click .o_mail_thread': '_onThreadWindowFocus',
- 'click .o_thread_composer': '_onThreadWindowFocus',
- 'click .o_thread_window_expand': '_onClickExpand',
- }),
- /**
- * Version of thread window that supports {mail.model.Thread}
- *
- * @override
- * @param {mail.Manager} parent
- * @param {mail.model.Thread} [thread = null] if not set, this is a "blank"
- * thread window. It lets us open a DM chat by providing the name of a
- * user.
- * @param {Object} [options={}]
- * @param {boolean} [options.passively=false]
- */
- init: function (parent, thread, options) {
- this._super.apply(this, arguments);
-
- // don't automatically mark unread messages as seen when at the bottom
- // of the thread
- this._passive = this.options.passively || false;
-
- if (!this.hasThread()) {
- // remembered partner ID of "blank" thread window in order to be
- // replaced with newly opened DM chat window
- this.directPartnerID = null;
- }
- },
-
- /**
- * @override
- */
- start: function () {
- var self = this;
-
- var superDef = this._super().then(this._listenThreadWidget.bind(this));
-
- this.call('mail_service', 'getMailBus')
- .on('update_typing_partners', this, this._onUpdateTypingPartners)
- .on('update_channel', this, this._onUpdateChannel);
-
- var composerDef;
- if (!this.hasThread()) {
- this._startWithoutThread();
- } else if (this.needsComposer()) {
- var composer;
- var composerParams = {
- mentionPartnersRestricted: this._thread.getType() !== 'document_thread',
- thread: this._thread,
- };
- if (
- config.device.isMobile &&
- this._thread.isMassMailing()
- ) {
- composer = new ExtendedComposer(this, composerParams);
- } else {
- composer = new BasicComposer(this, _.extend({ isMini: true }, composerParams));
- }
- composer.on('post_message', this, this._postMessage);
- composer.once('input_focused', this, function () {
- var commands = this._thread.getCommands();
- var partners = this._thread.getMentionPartnerSuggestions();
- composer.mentionSetCommands(commands);
- composer.mentionSetPrefetchedPartners(partners);
- });
- composerDef = composer.replace(this.$('.o_thread_composer'));
- composerDef.then(function () {
- self.$input = self.$('.o_composer_text_field');
- });
- }
- return Promise.all([superDef, composerDef]);
- },
-
- //--------------------------------------------------------------------------
- // Public
- //--------------------------------------------------------------------------
-
- /**
- * @override
- */
- close: function () {
- if (this.hasThread()) {
- this._thread.close();
- } else {
- this.trigger_up('close_blank_thread_window');
- }
- },
- /**
- * Set the focus on the input of this thread window
- */
- focusInput: function () {
- this._focusInput();
- },
- /**
- * Overrides so that if this thread window is not linked to any thread
- * (= "blank" thread window), displays "New message" as its title.
- *
- * @override
- * @returns {string}
- */
- getTitle: function () {
- if (!this.hasThread()) {
- return _t("New message");
- }
- return this._super.apply(this, arguments);
- },
- /**
- * Tell whether the thread window is passive or not. A passive thread window
- * does not auto-mark the thread as read when scrolling at the bottom.
- *
- * @returns {boolean}
- */
- isPassive: function () {
- return this._passive;
- },
- /**
- * Turn the thread window in active mode, so that when the bottom of the
- * thread is visible, it is automatically marked as read.
- */
- removePassive: function () {
- this._passive = false;
- },
- renderOutOfOffice: function () {
- var $outOfOffice = this.$('.o_out_of_office');
- const thread = this.getThread();
- if (!thread.hasOutOfOffice()) {
- if ($outOfOffice.length) {
- $outOfOffice.remove();
- }
- return;
- }
- const $newOutOfOffice = $(QWeb.render('mail.thread.OutOfOffice', { thread }));
- if ($outOfOffice.length) {
- $outOfOffice.replaceWith($newOutOfOffice);
- } else {
- $newOutOfOffice.insertAfter(this.$('.o_thread_window_header'));
- }
- },
- /**
- * Update this thread window
- *
- * @param {Object} options
- * @param {boolean} [options.keepBottom=false] if set, this thread window
- * should scroll to the bottom if it was at the bottom before update
- * @param {boolean} [options.passively=false] if set, this thread window
- * becomes passive, so that it is marked as read only when the focus is
- * on it. Ignore this option if the focus is already on the thread window.
- */
- update: function (options) {
- var self = this;
- if (options.passively && !this._hasFocus()) {
- this._setPassive();
- }
- var bottomVisible = !this.isFolded() &&
- !this.isHidden() &&
- this.isAtBottom();
- if (bottomVisible && !this.isPassive()) {
- this._thread.markAsRead();
- }
- this._thread.fetchMessages().then(function () {
- self.render();
- if (bottomVisible && options.keepBottom) {
- self.scrollToBottom();
- }
- });
- },
-
- //--------------------------------------------------------------------------
- // Private
- //--------------------------------------------------------------------------
-
- /**
- * @override
- * @private
- */
- _getHeaderRenderingOptions: function () {
- var options = this._super.apply(this, arguments);
- if (this._thread.getType() === 'document_thread') {
- options.expandTitle = _t("Open document");
- } else {
- options.expandTitle = _t("Open in Discuss");
- }
- return options;
- },
- /**
- * Listen on thread widget events
- *
- * @private
- */
- _listenThreadWidget: function () {
- this._threadWidget
- .on('redirect', this, this._onRedirect)
- .on('redirect_to_channel', this, this._onRedirectToChannel)
- .on('toggle_star_status', this, this._onToggleStarStatus);
- },
- /**
- * Open this thread window.
- * This private method exists only for the purpose of providing a callback
- * function on redirect to a partner.
- *
- * @private
- */
- _open: function (channelID) {
- this.call('mail_service', 'openThreadWindow', channelID || this.getID());
- },
- /**
- * Set the thread window in passive mode, so that new received message will
- * keep the thread window as unread until there is focus on the thread
- * window.
- *
- * @private
- */
- _setPassive: function () {
- this._passive = true;
- },
- /**
- * @private
- */
- _startWithoutThread: function () {
- var self = this;
- this.$el.addClass('o_thread_less');
- this.$('.o_thread_search_input input')
- .autocomplete({
- autoFocus: true,
- source: function (request, response) {
- self.call('mail_service', 'searchPartner', request.term, 10)
- .then(response);
- },
- select: function (event, ui) {
- // remember partner ID so that we can replace this window
- // with new DM chat window
- var partnerID = ui.item.id;
- self.directPartnerID = partnerID;
- self.call('mail_service', 'openDMChatWindowFromBlankThreadWindow', partnerID);
- },
- })
- .focus();
- },
-
- //--------------------------------------------------------------------------
- // Handlers
- //--------------------------------------------------------------------------
-
- /**
- * Expand the thread, which will open the thread in discuss.
- *
- * If there is no thread linked to this window, it means this is the "blank"
- * thread window, therefore it opens discuss with the default thread (which
- * should be the mailbox 'inbox')
- *
- * @private
- * @param {MouseEvent} ev
- */
- _onClickExpand: _.debounce(function (ev) {
- var self = this;
- ev.preventDefault();
- if (this.hasThread() && this._thread.getType() === 'document_thread') {
- this.do_action({
- type: 'ir.actions.act_window',
- res_model: this._thread.getDocumentModel(),
- views: [[false, 'form']],
- res_id: this._thread.getDocumentID(),
- });
- } else {
- this.do_action('mail.action_discuss', {
- clear_breadcrumbs: false,
- active_id: this.hasThread() ? this._getThreadID() : undefined,
- on_reverse_breadcrumb: function () {
- var mailBus = self.call('mail_service', 'getMailBus');
- if (mailBus) {
- mailBus.trigger('discuss_open', false);
- }
- },
- });
- }
- }, 1000, true),
- /**
- * @override
- * @private
- * @param {KeyboardEvent} ev
- *
- * Override _onKeydown to only prevent jquery's blockUI to cancel event,
- * but without sending the message on ENTER keydown as this is handled by
- * the BasicComposer
- */
- _onKeydown: function (ev) {
- ev.stopPropagation();
- },
- /**
- * callback function receives the threadID linked to resModel and resID
- * Note: this callback function is only called for 'res.partner'
- *
- * @private
- * @param {string} resModel
- * @param {integer} resID
- */
- _onRedirect: function (resModel, resID) {
- var callback = this._open.bind(this);
- this.call('mail_service', 'redirect', resModel, resID, callback);
- },
- /**
- * @private
- * @param {integer} channelID
- */
- _onRedirectToChannel: function (channelID) {
- var self = this;
- var thread = this.call('mail_service', 'getThread', channelID);
- if (!thread) {
- this.call('mail_service', 'joinChannel', channelID)
- .then(function (channelID) {
- var channel = self.call('mail_service', 'getThread', channelID);
- channel.detach();
- });
- } else {
- this.toggleFold(false);
- }
- },
- /**
- * Override it so that passive thread windows do not mark thread as read
- * on scroll
- *
- * @override
- * @private
- */
- _onScroll: function () {
- if (this.isPassive()) {
- return;
- }
- return this._super.apply(this, arguments);
- },
- /**
- * Called when focusing the thread window (click on thread or composer)
- *
- * @private
- */
- _onThreadWindowFocus: function () {
- if (this.isPassive()) {
- this.removePassive();
- this._thread.markAsRead();
- }
- },
- /**
- * @private
- * @param {integer} messageID
- */
- _onToggleStarStatus: function (messageID) {
- var message = this.call('mail_service', 'getMessage', messageID);
- message.toggleStarStatus();
- },
- /**
- * @private
- * @param {integer|string} threadID
- */
- _onUpdateTypingPartners: function (threadID) {
- if (!this.hasThread()) {
- return;
- }
- if (this._thread.getID() !== threadID) {
- return;
- }
- this.renderHeader();
- },
- /**
- * @private
- * @param {integer} channelID
- */
- _onUpdateChannel: function (channelID) {
- if (!this.hasThread()) {
- return;
- }
- if (this._thread.getID() !== channelID) {
- return;
- }
- this.render();
- },
-});
-
-return ThreadWindow;
-
-});
diff --git a/addons/mail/static/src/js/tools/debug_manager.js b/addons/mail/static/src/js/tools/debug_manager.js
index 3a4c1d3c372..798765eb364 100644
--- a/addons/mail/static/src/js/tools/debug_manager.js
+++ b/addons/mail/static/src/js/tools/debug_manager.js
@@ -11,7 +11,6 @@ var _t = core._t;
*/
DebugManager.include({
getMailMessages: function () {
- var self = this;
var selectedIDs = this._controller.getSelectedIds();
if (!selectedIDs.length) {
console.warn(_t("No message available"));
diff --git a/addons/mail/static/src/js/tours/mail.js b/addons/mail/static/src/js/tours/mail.js
index e63647540a0..b37ed70751f 100644
--- a/addons/mail/static/src/js/tours/mail.js
+++ b/addons/mail/static/src/js/tours/mail.js
@@ -7,26 +7,26 @@ var tour = require('web_tour.tour');
var _t = core._t;
tour.register('mail_tour', {
- url: "/web#action=mail.discuss",
+ url: "/web#action=mail.widgets.discuss",
}, [{
- trigger: '.o_mail_discuss .o_mail_discuss_sidebar .o_add[data-type="multi_user_channel"]',
+ trigger: '.o_DiscussSidebar_groupChannel .o_DiscussSidebar_groupHeaderItemAdd',
content: _t("Channels make it easy to organize information across different topics and groups.
Try to create your first channel (e.g. sales, marketing, product XYZ, after work party, etc).
"),
position: 'bottom',
}, {
- trigger: '.o_mail_discuss .o_mail_discuss_sidebar .o_mail_add_thread[data-type="multi_user_channel"]',
+ trigger: '.o_DiscussSidebar_itemNewInput',
content: _t("Create a channel here.
"),
position: 'bottom',
auto: true,
run: function (actions) {
var t = new Date().getTime();
- actions.text("SomeChannel_" + t, this.$anchor.find("input"));
+ actions.text("SomeChannel_" + t, this.$anchor);
},
}, {
- trigger: ".ui-autocomplete .ui-menu-item > a:contains(Private)",
+ trigger: ".ui-autocomplete .ui-menu-item > a:contains(Create):has(.fa-lock)",
content: _t(" Create a private channel.
"),
position: 'bottom',
}, {
- trigger: '.o_mail_discuss .o_composer_text_field',
+ trigger: '.o_Discuss_thread .o_ComposerTextInput_textarea',
content: _t("Write a message to the members of the channel here.
You can notify someone with '@' or link another channel with '#'. Start your message with '/' to get the list of possible commands.
"),
position: "top",
width: 350,
@@ -35,19 +35,19 @@ tour.register('mail_tour', {
actions.text("SomeText_" + t, this.$anchor);
},
}, {
- trigger: '.o_mail_discuss .o_thread_composer .o_composer_button_send',
+ trigger: '.o_Discuss_thread .o_Composer_buttonSend',
content: _t("Post your message on the thread"),
position: "top",
}, {
- trigger: '.o_mail_discuss .o_mail_thread .o_thread_message_star',
+ trigger: '.o_Discuss_thread .o_Message_commandStar',
content: _t("Messages can be starred to remind you to check back later."),
position: "bottom",
}, {
- trigger: '.o_mail_discuss .o_mail_discuss_item[data-thread-id="mailbox_starred"]',
+ trigger: '.o_DiscussSidebarItem.o-starred-box',
content: _t("Once a message has been starred, you can come back and review it at any time here."),
position: "bottom",
}, {
- trigger: '.o_mail_discuss .o_mail_discuss_sidebar .o_add[data-type="dm_chat"]',
+ trigger: '.o_DiscussSidebar_groupChat .o_DiscussSidebar_groupHeaderItemAdd',
content: _t("Chat with coworkers in real-time using direct messages.
You might need to invite users from the Settings app first.
"),
position: 'bottom',
}]);
diff --git a/addons/mail/static/src/js/utils.js b/addons/mail/static/src/js/utils.js
index d4b7d8e7875..d54f5932619 100644
--- a/addons/mail/static/src/js/utils.js
+++ b/addons/mail/static/src/js/utils.js
@@ -5,6 +5,15 @@ var core = require('web.core');
var _t = core._t;
+/**
+ * WARNING: this is not enough to unescape potential XSS contained in htmlString, transformFunction
+ * should handle it or it should be handled after/before calling parseAndTransform. So if the result
+ * of this function is used in a t-raw, be very careful.
+ *
+ * @param {string} htmlString
+ * @param {function} transformFunction
+ * @returns {string}
+ */
function parseAndTransform(htmlString, transformFunction) {
var openToken = "OPEN" + Date.now();
var string = htmlString.replace(/</g, openToken);
@@ -17,6 +26,7 @@ function parseAndTransform(htmlString, transformFunction) {
return _parseAndTransform(children, transformFunction)
.replace(new RegExp(openToken, "g"), "<");
}
+
/**
* @param {Node[]} nodes
* @param {function} transformFunction with:
@@ -127,6 +137,23 @@ function parseEmail(text) {
}
}
+/**
+ * Returns an escaped conversion of a content.
+ *
+ * @param {string} content
+ * @returns {string}
+ */
+function escapeAndCompactTextContent(content) {
+ //Removing unwanted extra spaces from message
+ let value = _.escape(content).trim();
+ value = value.replace(/(\r|\n){2,}/g, '
');
+ value = value.replace(/(\r|\n)/g, '
');
+
+ // prevent html space collapsing
+ value = value.replace(/ /g, ' ').replace(/([^>]) ([^<])/g, '$1 $2');
+ return value;
+}
+
// Replaces textarea text into html text (add , )
// TDE note : should be done server-side, in Python -> use mail.compose.message ?
function getTextToHTML(text) {
@@ -142,14 +169,6 @@ function timeFromNow(date) {
return date.fromNow();
}
-function o_clearTimeout(id) {
- return clearTimeout(id);
-}
-
-function o_setTimeout(func, delay) {
- return setTimeout(func, delay);
-}
-
return {
addLink: addLink,
getTextToHTML: getTextToHTML,
@@ -160,8 +179,7 @@ return {
parseEmail: parseEmail,
stripHTML: stripHTML,
timeFromNow: timeFromNow,
- clearTimeout: o_clearTimeout,
- setTimeout: o_setTimeout,
+ escapeAndCompactTextContent,
};
});
diff --git a/addons/mail/static/src/model/model_core.js b/addons/mail/static/src/model/model_core.js
new file mode 100644
index 00000000000..0198e47ee7d
--- /dev/null
+++ b/addons/mail/static/src/model/model_core.js
@@ -0,0 +1,125 @@
+odoo.define('mail/static/src/model/model_core.js', function (require) {
+'use strict';
+
+/**
+ * Module that contains registry for adding new models or patching models.
+ * Useful for model manager in order to generate model classes.
+ *
+ * This code is not in model manager because other JS modules should populate
+ * a registry, and it's difficult to ensure availability of the model manager
+ * when these JS modules are deployed.
+ */
+
+const registry = {};
+
+//------------------------------------------------------------------------------
+// Private
+//------------------------------------------------------------------------------
+
+/**
+ * @private
+ * @param {string} modelName
+ * @returns {Object}
+ */
+function _getEntryFromModelName(modelName) {
+ if (!registry[modelName]) {
+ registry[modelName] = {
+ dependencies: [],
+ factory: undefined,
+ name: modelName,
+ patches: [],
+ };
+ }
+ return registry[modelName];
+}
+
+/**
+ * @private
+ * @param {string} modelName
+ * @param {string} patchName
+ * @param {Object} patch
+ * @param {Object} [param3={}]
+ * @param {string} [param3.type='instance'] 'instance', 'class' or 'field'
+ */
+function _registerPatchModel(modelName, patchName, patch, { type = 'instance' } = {}) {
+ const entry = _getEntryFromModelName(modelName);
+ Object.assign(entry, {
+ patches: (entry.patches || []).concat([{
+ name: patchName,
+ patch,
+ type,
+ }]),
+ });
+}
+
+//------------------------------------------------------------------------------
+// Public
+//------------------------------------------------------------------------------
+
+/**
+ * Register a patch for static methods in model.
+ *
+ * @param {string} modelName
+ * @param {string} patchName
+ * @param {Object} patch
+ */
+function registerClassPatchModel(modelName, patchName, patch) {
+ _registerPatchModel(modelName, patchName, patch, { type: 'class' });
+}
+
+/**
+ * Register a patch for fields in model.
+ *
+ * @param {string} modelName
+ * @param {string} patchName
+ * @param {Object} patch
+ */
+function registerFieldPatchModel(modelName, patchName, patch) {
+ _registerPatchModel(modelName, patchName, patch, { type: 'field' });
+}
+
+/**
+ * Register a patch for instance methods in model.
+ *
+ * @param {string} modelName
+ * @param {string} patchName
+ * @param {Object} patch
+ */
+function registerInstancePatchModel(modelName, patchName, patch) {
+ _registerPatchModel(modelName, patchName, patch, { type: 'instance' });
+}
+
+/**
+ * @param {string} name
+ * @param {function} factory
+ * @param {string[]} [dependencies=[]]
+ */
+function registerNewModel(name, factory, dependencies = []) {
+ const entry = _getEntryFromModelName(name);
+ let entryDependencies = [...dependencies];
+ if (name !== 'mail.model') {
+ entryDependencies = [...new Set(entryDependencies.concat(['mail.model']))];
+ }
+ if (entry.factory) {
+ throw new Error(`Model "${name}" has already been registered!`);
+ }
+ Object.assign(entry, {
+ dependencies: entryDependencies,
+ factory,
+ name,
+ });
+}
+
+//------------------------------------------------------------------------------
+// Export
+//------------------------------------------------------------------------------
+
+return {
+ registerClassPatchModel,
+ registerFieldPatchModel,
+ registerInstancePatchModel,
+ registerNewModel,
+ registry,
+};
+
+});
diff --git a/addons/mail/static/src/model/model_errors.js b/addons/mail/static/src/model/model_errors.js
new file mode 100644
index 00000000000..17ecc96013c
--- /dev/null
+++ b/addons/mail/static/src/model/model_errors.js
@@ -0,0 +1,22 @@
+odoo.define('mail/static/src/model/model_errors.js', function (require) {
+'use strict';
+
+class RecordDeletedError extends Error {
+
+ /**
+ * @override
+ * @param {string} recordLocalId local id of record that has been deleted
+ * @param {...any} args
+ */
+ constructor(recordLocalId, ...args) {
+ super(...args);
+ this.recordLocalId = recordLocalId;
+ this.name = 'RecordDeletedError';
+ }
+}
+
+return {
+ RecordDeletedError,
+};
+
+});
diff --git a/addons/mail/static/src/model/model_field.js b/addons/mail/static/src/model/model_field.js
new file mode 100644
index 00000000000..589eee5c782
--- /dev/null
+++ b/addons/mail/static/src/model/model_field.js
@@ -0,0 +1,988 @@
+odoo.define('mail/static/src/model/model_field.js', function (require) {
+'use strict';
+
+/**
+ * Class whose instances represent field on a model.
+ * These field definitions are generated from declared fields in static prop
+ * `fields` on the model.
+ */
+class ModelField {
+
+ //--------------------------------------------------------------------------
+ // Public
+ //--------------------------------------------------------------------------
+
+ constructor({
+ autocreate = false,
+ compute,
+ default: def,
+ dependencies = [],
+ dependents = [],
+ fieldName,
+ fieldType,
+ hashes: extraHashes = [],
+ inverse,
+ isCausal = false,
+ modelManager,
+ related,
+ relationType,
+ to,
+ } = {}) {
+ const id = _.uniqueId('field_');
+ /**
+ * This prop only makes sense for fields of type "relation". If set,
+ * it automatically creates a new record for this field on creation of
+ * record, and auto-links with this record.
+ */
+ this.autocreate = autocreate;
+ /**
+ * If set, this field acts as a computed field, and this prop
+ * contains the name of the instance method that computes the value
+ * for this field. This compute method is called on creation of record
+ * and whenever some of its dependencies change. @see dependencies
+ */
+ this.compute = compute;
+ /**
+ * Default value for this field. Used on creation of this field, to
+ * set a value by default.
+ */
+ this.default = def;
+ /**
+ * List of field on current record that this field depends on for its
+ * `compute` method. Useful to determine whether this field should be
+ * registered for recomputation when some record fields have changed.
+ * This list must be declared in model definition, or compute method
+ * is only computed once.
+ */
+ this.dependencies = dependencies;
+ /**
+ * List of fields that are dependent of this field. They should never
+ * be declared, and are automatically generated while processing
+ * declared fields. This is populated by compute `dependencies` and
+ * `related`.
+ */
+ this.dependents = dependents;
+ /**
+ * Name of the field in the definition of fields on model.
+ */
+ this.fieldName = fieldName;
+ /**
+ * Type of this field. 2 types of fields are currently supported:
+ *
+ * 1. 'attribute': fields that store primitive values like integers,
+ * booleans, strings, objects, array, etc.
+ *
+ * 2. 'relation': fields that relate to some other records.
+ */
+ this.fieldType = fieldType;
+ /**
+ * List of hashes registered on this field definition. Technical
+ * prop that is specifically used in processing of dependent
+ * fields, useful to clearly identify which fields of a relation are
+ * dependents and must be registered for computed. Indeed, not all
+ * related records may have a field that depends on changed field,
+ * especially when dependency is defined on sub-model on a relation in
+ * a super-model.
+ *
+ * To illustrate the purpose of this hash, suppose following definition
+ * of models and fields:
+ *
+ * - 3 models (A, B, C) and 3 fields (x, y, z)
+ * - A.fields: { x: one2one(C, inverse: x') }
+ * - B extends A
+ * - B.fields: { z: related(x.y) }
+ * - C.fields: { y: attribute }
+ *
+ * Visually:
+ * x'
+ * <-----------
+ * A -----------> C { y }
+ * ^ x
+ * |
+ * | (extends)
+ * |
+ * B { z = x.y }
+ *
+ * If z has a dependency on x.y, it means y has a dependent on x'.z.
+ * Note that field z exists on B but not on all A. To determine which
+ * kinds of records in relation x' are dependent on y, y is aware of an
+ * hash on this dependent, and any dependents who has this hash in list
+ * of hashes are actual dependents.
+ */
+ this.hashes = extraHashes.concat([id]);
+ /**
+ * Identification for this field definition. Useful to map a dependent
+ * from a dependency. Indeed, declared field definitions use
+ * 'dependencies' but technical process need inverse as 'dependents'.
+ * Dependencies just need name of fields, but dependents cannot just
+ * rely on inverse field names because these dependents are a subset.
+ */
+ this.id = id;
+ /**
+ * This prop only makes sense in a relational field. This contains
+ * the name of the field name in the inverse relation. This may not
+ * be defined in declared field definitions, but processed relational
+ * field definitions always have inverses.
+ */
+ this.inverse = inverse;
+ /**
+ * This prop only makes sense in a relational field. If set, when this
+ * relation is removed, the related record is automatically deleted.
+ */
+ this.isCausal = isCausal;
+ /**
+ * Reference to the model manager.
+ */
+ this.modelManager = modelManager;
+ /**
+ * If set, this field acts as a related field, and this prop contains
+ * a string that references the related field. It should have the
+ * following format: '.', where
+ * is a relational field name on this model or a parent
+ * model (note: could itself be computed or related), and
+ * is the name of field on the records that are
+ * related to current record from this relation. When there are more
+ * than one record in the relation, it maps all related fields per
+ * record in relation.
+ *
+ * FIXME: currently flatten map due to bug, improvement is planned
+ * see Task-id 2261221
+ */
+ this.related = related;
+ /**
+ * This prop only makes sense in a relational field. Determine which
+ * type of relation there is between current record and other records.
+ * 4 types of relation are supported: 'one2one', 'one2many', 'many2one'
+ * and 'many2many'.
+ */
+ this.relationType = relationType;
+ /**
+ * This prop only makes sense in a relational field. Determine which
+ * model name this relation refers to.
+ */
+ this.to = to;
+ }
+
+ /**
+ * Define an attribute field.
+ *
+ * @param {Object} [options]
+ * @returns {Object}
+ */
+ static attr(options) {
+ return Object.assign({ fieldType: 'attribute' }, options);
+ }
+
+ /**
+ * Define a many2many field.
+ *
+ * @param {string} modelName
+ * @param {Object} [options]
+ * @returns {Object}
+ */
+ static many2many(modelName, options) {
+ return ModelField._relation(modelName, Object.assign({}, options, { relationType: 'many2many' }));
+ }
+
+ /**
+ * Define a many2one field.
+ *
+ * @param {string} modelName
+ * @param {Object} [options]
+ * @returns {Object}
+ */
+ static many2one(modelName, options) {
+ return ModelField._relation(modelName, Object.assign({}, options, { relationType: 'many2one' }));
+ }
+
+ /**
+ * Define a one2many field.
+ *
+ * @param {string} modelName
+ * @param {Object} [options]
+ * @returns {Object}
+ */
+ static one2many(modelName, options) {
+ return ModelField._relation(modelName, Object.assign({}, options, { relationType: 'one2many' }));
+ }
+
+ /**
+ * Define a one2one field.
+ *
+ * @param {string} modelName
+ * @param {Object} [options]
+ * @returns {Object}
+ */
+ static one2one(modelName, options) {
+ return ModelField._relation(modelName, Object.assign({}, options, { relationType: 'one2one' }));
+ }
+
+ /**
+ * Combine current field definition with provided field definition and
+ * return the combined field definition. Useful to track list of hashes of
+ * a given field, which is necessary for the working of dependent fields
+ * (computed and related fields).
+ *
+ * @param {ModelField} field
+ * @returns {ModelField}
+ */
+ combine(field) {
+ return new ModelField(Object.assign({}, this, {
+ dependencies: this.dependencies.concat(field.dependencies),
+ hashes: this.hashes.concat(field.hashes),
+ }));
+ }
+
+ /**
+ * Perform computation of this field, which is either a computed or related
+ * field.
+ *
+ * @param {mail.model} record
+ */
+ doCompute(record) {
+ if (this.compute) {
+ this.set(record, record[this.compute]());
+ return;
+ }
+ if (this.related) {
+ this.set(record, this._computeRelated(record));
+ return;
+ }
+ throw new Error("No compute method defined on this field definition");
+ }
+
+ /**
+ * Get the env with messaging.
+ *
+ * @returns {mail/static/src/env/env.js}
+ */
+ get env() {
+ return this.modelManager.env;
+ }
+
+ /**
+ * Get the value associated to this field. Relations must convert record
+ * local ids to records.
+ *
+ * @param {mail.model} record
+ * @returns {any}
+ */
+ get(record) {
+ if (this.fieldType === 'attribute') {
+ return this.read(record);
+ }
+ if (this.fieldType === 'relation') {
+ const OtherModel = this.env.models[this.to];
+ if (['one2one', 'many2one'].includes(this.relationType)) {
+ return OtherModel.get(this.read(record));
+ }
+ return this.read(record)
+ .map(localId => OtherModel.get(localId))
+ /**
+ * FIXME: Stored relation may still contain
+ * outdated records.
+ */
+ .filter(record => !!record);
+ }
+ throw new Error(`cannot get field with unsupported type ${this.fieldType}.`);
+ }
+
+ /**
+ * Get the raw value associated to this field. For relations, this means
+ * the local id or list of local ids of records in this relational field.
+ *
+ * @param {mail.model} record
+ * @returns {any}
+ */
+ read(record) {
+ return record.__state[this.fieldName];
+ }
+
+ /**
+ * Set a value on this field. The format of the value comes from business
+ * code.
+ *
+ * @param {mail.model} record
+ * @param {any} newVal
+ */
+ set(record, newVal) {
+ if (this.fieldType === 'attribute') {
+ this.write(record, newVal);
+ }
+ if (this.fieldType === 'relation') {
+ for (const val of newVal) {
+ switch (val[0]) {
+ case 'create':
+ this._setRelationCreate(record, val[1]);
+ break;
+ case 'insert':
+ this._setRelationInsert(record, val[1]);
+ break;
+ case 'insert-and-replace':
+ this._setRelationInsertAndReplace(record, val[1]);
+ break;
+ case 'link':
+ this._setRelationLink(record, val[1]);
+ break;
+ case 'replace':
+ // TODO IMP replace should not unlink-all (task-2270780)
+ this._setRelationUnlink(record, null);
+ this._setRelationLink(record, val[1]);
+ break;
+ case 'unlink':
+ this._setRelationUnlink(record, val[1]);
+ break;
+ case 'unlink-all':
+ this._setRelationUnlink(record, null);
+ break;
+ }
+ }
+ }
+ }
+
+ /**
+ * Set a value in state associated to this field. Value corresponds exactly
+ * that what is stored on this field, like local id or list of local ids
+ * for a relational field. If the value changes, dependent fields are
+ * automatically registered for (re-)computation.
+ *
+ * @param {mail.model} record
+ * @param {any} newVal
+ * @param {Object} [param2={}]
+ * @param {Object} [param2.registerDependents=true] If set, write
+ * on this field with changed value registers dependent fields for compute.
+ * Of course, we almost always want to register them, so that they reflect
+ * the value with their dependencies. Disabling this feature prevents
+ * useless potentially heavy computation, like when setting default values.
+ */
+ write(record, newVal, { registerDependents = true } = {}) {
+ if (this.read(record) === newVal) {
+ return;
+ }
+ const prevStringified = JSON.stringify(this.read(record));
+ record.__state[this.fieldName] = newVal;
+ const newStringified = JSON.stringify(this.read(record));
+ if (this._containsRecords(newVal)) {
+ throw new Error("Forbidden write operation with records in the __state!!");
+ }
+ if (newStringified === prevStringified) {
+ // value unchanged, don't need to compute dependent fields
+ return;
+ }
+ if (!registerDependents) {
+ return;
+ }
+
+ // flag all dependent fields for compute
+ for (const dependent of this.dependents) {
+ const [hash, currentFieldName, relatedFieldName] = dependent.split(
+ this.modelManager.DEPENDENT_INNER_SEPARATOR
+ );
+ if (relatedFieldName) {
+ const Model = record.constructor;
+ const relationField = Model.fields[currentFieldName];
+ if (['one2many', 'many2many'].includes(relationField.relationType)) {
+ for (const otherRecord of record[currentFieldName]) {
+ const OtherModel = otherRecord.constructor;
+ const field = OtherModel.fields[relatedFieldName];
+ if (field && field.hashes.includes(hash)) {
+ this.modelManager.registerToComputeField(otherRecord, field);
+ }
+ }
+ } else {
+ const otherRecord = record[currentFieldName];
+ if (!otherRecord) {
+ continue;
+ }
+ const OtherModel = otherRecord.constructor;
+ const field = OtherModel.fields[relatedFieldName];
+ if (field && field.hashes.includes(hash)) {
+ this.modelManager.registerToComputeField(otherRecord, field);
+ }
+ }
+ } else {
+ const Model = record.constructor;
+ const field = Model.fields[currentFieldName];
+ if (field && field.hashes.includes(hash)) {
+ this.modelManager.registerToComputeField(record, field);
+ }
+ }
+ }
+ }
+
+ //--------------------------------------------------------------------------
+ // Private
+ //--------------------------------------------------------------------------
+
+ /**
+ * @private
+ * @param {string} modelName
+ * @param {Object} [options]
+ */
+ static _relation(modelName, options) {
+ return Object.assign({
+ fieldType: 'relation',
+ to: modelName,
+ }, options);
+ }
+
+ /**
+ * Compute method when this field is related.
+ *
+ * @private
+ * @param {mail.model} record
+ */
+ _computeRelated(record) {
+ const [relationName, relatedFieldName] = this.related.split('.');
+ const Model = record.constructor;
+ const relationField = Model.fields[relationName];
+ if (['one2many', 'many2many'].includes(relationField.relationType)) {
+ const newVal = [];
+ for (const otherRecord of record[relationName]) {
+ const OtherModel = otherRecord.constructor;
+ const otherField = OtherModel.fields[relatedFieldName];
+ const otherValue = otherField.get(otherRecord);
+ if (otherValue) {
+ if (otherValue instanceof Array) {
+ // avoid nested array if otherField is x2many too
+ // TODO IMP task-2261221
+ for (const v of otherValue) {
+ newVal.push(v);
+ }
+ } else {
+ newVal.push(otherValue);
+ }
+ }
+ }
+ if (this.fieldType === 'relation') {
+ return [['replace', newVal]];
+ }
+ return newVal;
+ }
+ const otherRecord = record[relationName];
+ if (otherRecord) {
+ const OtherModel = otherRecord.constructor;
+ const otherField = OtherModel.fields[relatedFieldName];
+ const newVal = otherField.get(otherRecord);
+ if (this.fieldType === 'relation') {
+ if (newVal) {
+ return [['replace', newVal]];
+ } else {
+ return [['unlink-all']];
+ }
+ }
+ return newVal;
+ }
+ if (this.fieldType === 'relation') {
+ return [];
+ }
+ }
+
+ /**
+ * Determines whether the provided value contains some records. Useful to
+ * prevent writing records directly in state of this field, which should be
+ * treated as buggy design. Indeed, state of field should only contain
+ * either a primitive type or a simple datastructure containing itself
+ * simple datastructures too.
+ *
+ * @private
+ * @param {any} val
+ * @returns {boolean}
+ */
+ _containsRecords(val) {
+ if (!val) {
+ return false;
+ }
+ if (val instanceof this.env.models['mail.model']) {
+ return true;
+ }
+ if (!(val instanceof Array)) {
+ return false;
+ }
+ if (val.length > 0 && val[0] instanceof this.env.models['mail.model']) {
+ return true;
+ }
+ return false;
+ }
+
+ /**
+ * Converts given value to expected format for x2many processing, which is
+ * an array of localId.
+ *
+ * @private
+ * @param {string|mail.model|[]} newValue
+ * @returns {string[]}
+ */
+ _setRelationConvertX2ManyValue(newValue) {
+ if (newValue instanceof Array) {
+ return newValue.map(v => this._setRelationConvertX2OneValue(v));
+ }
+ return [this._setRelationConvertX2OneValue(newValue)];
+ }
+
+ /**
+ * Converts given value to expected format for x2one processing, which is
+ * a localId.
+ *
+ * @private
+ * @param {string|mail.model} newValue
+ * @returns {string}
+ */
+ _setRelationConvertX2OneValue(newValue) {
+ return newValue instanceof this.env.models['mail.model'] ? newValue.localId : newValue;
+ }
+
+ /**
+ * Set on this relational field in 'create' mode. Basically data provided
+ * during set on this relational field contain data to create new records,
+ * which themselves must be linked to record of this field by means of
+ * this field.
+ *
+ * @private
+ * @param {mail.model} record
+ * @param {Object|Object[]} data
+ */
+ _setRelationCreate(record, data) {
+ const OtherModel = this.env.models[this.to];
+ let other;
+ if (['one2one', 'many2one'].includes(this.relationType)) {
+ other = OtherModel.create(data);
+ } else {
+ if (data instanceof Array) {
+ other = data.map(d => OtherModel.create(d));
+ } else {
+ other = OtherModel.create(data);
+ }
+ }
+ this._setRelationLink(record, other);
+ }
+
+ /**
+ * Set on this relational field in 'insert' mode. Basically data provided
+ * during set on this relational field contain data to insert records,
+ * which themselves must be linked to record of this field by means of
+ * this field.
+ *
+ * @private
+ * @param {mail.model} record
+ * @param {Object|Object[]} data
+ */
+ _setRelationInsert(record, data) {
+ const OtherModel = this.env.models[this.to];
+ let other;
+ if (['one2one', 'many2one'].includes(this.relationType)) {
+ other = OtherModel.insert(data);
+ } else {
+ if (data instanceof Array) {
+ other = data.map(d => OtherModel.insert(d));
+ } else {
+ other = OtherModel.insert(data);
+ }
+ }
+ this._setRelationLink(record, other);
+ }
+
+ /**
+ * Set on this relational field in 'insert-and-repalce' mode. Basically
+ * data provided during set on this relational field contain data to insert
+ * records, which themselves must replace value on this field.
+ *
+ * @private
+ * @param {mail.model} record
+ * @param {Object|Object[]} data
+ */
+ _setRelationInsertAndReplace(record, data) {
+ // unlink must be done before insert:
+ // because unlink might trigger delete due to causality and new data
+ // shouldn't be deleted just after being inserted
+ // TODO IMP insert-and-replace should not unlink-all (task-2270780)
+ this._setRelationUnlink(record, null);
+ const OtherModel = this.env.models[this.to];
+ let other;
+ if (['one2one', 'many2one'].includes(this.relationType)) {
+ other = OtherModel.insert(data);
+ } else {
+ if (data instanceof Array) {
+ other = data.map(d => OtherModel.insert(d));
+ } else {
+ other = OtherModel.insert(data);
+ }
+ }
+ this._setRelationLink(record, other);
+ }
+
+ /**
+ * Set a 'link' operation on this relational field.
+ *
+ * @private
+ * @param {string|string[]|mail.model|mail.model[]} newValue
+ */
+ _setRelationLink(record, newValue) {
+ const Model = record.constructor;
+ if (!Model.get(record)) {
+ // current record may be deleted due to causality
+ return;
+ }
+ switch (this.relationType) {
+ case 'many2many':
+ this._setRelationLinkMany2Many(record, newValue);
+ break;
+ case 'many2one':
+ this._setRelationLinkMany2One(record, newValue);
+ break;
+ case 'one2many':
+ this._setRelationLinkOne2Many(record, newValue);
+ break;
+ case 'one2one':
+ this._setRelationLinkOne2One(record, newValue);
+ break;
+ }
+ }
+
+ /**
+ * Handling of a `set` 'link' of a many2many relational field.
+ *
+ * @private
+ * @param {mail.model} record
+ * @param {string|mail.model|[]} newValue
+ */
+ _setRelationLinkMany2Many(record, newValue) {
+ // convert newValue to array of localId
+ const newLocalIds = this._setRelationConvertX2ManyValue(newValue);
+ const OtherModel = this.env.models[this.to];
+
+ for (const newLocalId of newLocalIds) {
+ // read in loop to catch potential changes from previous iteration
+ const prevLocalIds = this.read(record);
+
+ // other record already linked, avoid linking twice
+ if (prevLocalIds.includes(newLocalId)) {
+ continue;
+ }
+
+ const newOtherRecord = OtherModel.get(newLocalId);
+ // other record may be deleted due to causality, avoid linking
+ // deleted records
+ if (!newOtherRecord) {
+ continue;
+ }
+
+ // link other record to current record
+ this.write(record, prevLocalIds.concat([newLocalId]));
+
+ // link current record to other record
+ newOtherRecord.update({
+ [this.inverse]: [['link', record]],
+ });
+ }
+ }
+
+ /**
+ * Handling of a `set` 'link' of a many2one relational field.
+ *
+ * @private
+ * @param {mail.model} record
+ * @param {string|mail.model} newValue
+ */
+ _setRelationLinkMany2One(record, newValue) {
+ // convert newValue to localId
+ const newLocalId = this._setRelationConvertX2OneValue(newValue);
+ const OtherModel = this.env.models[this.to];
+ const prevLocalId = this.read(record);
+
+ // other record already linked, avoid linking twice
+ if (prevLocalId === newLocalId) {
+ return;
+ }
+
+ // unlink previous other record from current record
+ this.write(record, undefined);
+
+ const prevOtherRecord = OtherModel.get(prevLocalId);
+ // there may be no previous other record or the previous other record
+ // may be deleted due to causality
+ if (prevOtherRecord) {
+ // unlink current record from previous other record
+ prevOtherRecord.update({
+ [this.inverse]: [['unlink', record]],
+ });
+ }
+
+ const newOtherRecord = OtherModel.get(newLocalId);
+ // other record may be deleted due to causality, avoid linking
+ // deleted records
+ if (!newOtherRecord) {
+ return;
+ }
+
+ // link other record to current records
+ this.write(record, newLocalId);
+
+ // link current record to other record
+ newOtherRecord.update({
+ [this.inverse]: [['link', record]],
+ });
+ }
+
+ /**
+ * Handling of a `set` 'link' of an one2many relational field.
+ *
+ * @private
+ * @param {mail.model} record
+ * @param {string|mail.model|[]} newValue
+ */
+ _setRelationLinkOne2Many(record, newValue) {
+ // convert newValue to array of localId
+ const newLocalIds = this._setRelationConvertX2ManyValue(newValue);
+ const OtherModel = this.env.models[this.to];
+
+ for (const newLocalId of newLocalIds) {
+ // read in loop to catch potential changes from previous iteration
+ const prevLocalIds = this.read(record);
+
+ // other record already linked, avoid linking twice
+ if (prevLocalIds.includes(newLocalId)) {
+ continue;
+ }
+
+ const newOtherRecord = OtherModel.get(newLocalId);
+ // other record may be deleted due to causality, avoid linking
+ // deleted records
+ if (!newOtherRecord) {
+ continue;
+ }
+
+ // link other record to current record
+ this.write(record, prevLocalIds.concat([newLocalId]));
+
+ // link current record to other record
+ newOtherRecord.update({
+ [this.inverse]: [['link', record]],
+ });
+ }
+ }
+
+ /**
+ * Handling of a `set` 'link' of an one2one relational field.
+ *
+ * @private
+ * @param {mail.model} record
+ * @param {string|mail.model} value
+ */
+ _setRelationLinkOne2One(record, newValue) {
+ // convert newValue to localId
+ const newLocalId = this._setRelationConvertX2OneValue(newValue);
+ const prevLocalId = this.read(record);
+ const OtherModel = this.env.models[this.to];
+
+ // other record already linked, avoid linking twice
+ if (prevLocalId === newLocalId) {
+ return;
+ }
+
+ // unlink previous other record from current record
+ this.write(record, undefined);
+
+ const prevOtherRecord = OtherModel.get(prevLocalId);
+ // there may be no previous other record or the previous other record
+ // may be deleted due to causality
+ if (prevOtherRecord) {
+ // unlink current record from previous other record
+ prevOtherRecord.update({
+ [this.inverse]: [['unlink', record]],
+ });
+ // apply causality
+ if (this.isCausal) {
+ prevOtherRecord.delete();
+ }
+ }
+
+ const newOtherRecord = OtherModel.get(newLocalId);
+ // other record may be deleted due to causality, avoid linking deleted
+ // records
+ if (!newOtherRecord) {
+ return;
+ }
+
+ // link other record to current record
+ this.write(record, newLocalId);
+
+ // link current record to other record
+ newOtherRecord.update({
+ [this.inverse]: [['link', record]],
+ });
+ }
+
+ /**
+ * Set an 'unlink' operation on this relational field.
+ *
+ * @private
+ * @param {mail.model} record
+ * @param {string|string[]|mail.model|mail.model[]|null} newValue
+ */
+ _setRelationUnlink(record, newValue) {
+ const Model = record.constructor;
+ if (!Model.get(record)) {
+ // current record may be deleted due to causality
+ return;
+ }
+ switch (this.relationType) {
+ case 'many2many':
+ this._setRelationUnlinkMany2Many(record, newValue);
+ break;
+ case 'many2one':
+ this._setRelationUnlinkMany2One(record);
+ break;
+ case 'one2many':
+ this._setRelationUnlinkOne2Many(record, newValue);
+ break;
+ case 'one2one':
+ this._setRelationUnlinkOne2One(record);
+ break;
+ }
+ }
+
+ /**
+ * Handling of a `set` 'unlink' of a many2many relational field.
+ *
+ * @private
+ * @param {mail.model} record
+ * @param {string|mail.model|[]|null} newValue
+ */
+ _setRelationUnlinkMany2Many(record, newValue) {
+ // convert newValue to array of localId, null is considered unlink all
+ const otherLocalIds = newValue === null
+ ? [...this.read(record)]
+ : this._setRelationConvertX2ManyValue(newValue);
+ const OtherModel = this.env.models[this.to];
+
+ for (const otherLocalId of otherLocalIds) {
+ // read in loop to catch potential changes from previous iteration
+ const prevLocalIds = this.read(record);
+
+ // other record already unlinked, avoid useless processing
+ if (!prevLocalIds.includes(otherLocalId)) {
+ continue;
+ }
+
+ // unlink other record from current record
+ this.write(record, prevLocalIds.filter(
+ localId => localId !== otherLocalId
+ ));
+
+ const otherRecord = OtherModel.get(otherLocalId);
+ // other record may be deleted due to causality, avoid useless
+ // processing
+ if (otherRecord) {
+ // unlink current record from other record
+ otherRecord.update({
+ [this.inverse]: [['unlink', record]],
+ });
+ }
+ }
+ }
+
+ /**
+ * Handling of a `set` 'unlink' of a many2one relational field.
+ *
+ * @private
+ * @param {mail.model} record
+ */
+ _setRelationUnlinkMany2One(record) {
+ const otherLocalId = this.read(record);
+ const OtherModel = this.env.models[this.to];
+
+ // other record already unlinked, avoid useless processing
+ if (!otherLocalId) {
+ return;
+ }
+
+ // unlink other record from current record
+ this.write(record, undefined);
+
+ const otherRecord = OtherModel.get(otherLocalId);
+ // other record may be deleted due to causality, avoid useless
+ // processing
+ if (otherRecord) {
+ // unlink current record from other record
+ otherRecord.update({
+ [this.inverse]: [['unlink', record]],
+ });
+ }
+ }
+
+ /**
+ * Handling of a `set` 'unlink' of an one2many relational field.
+ *
+ * @private
+ * @param {mail.model} record
+ * @param {string|mail.model|[]|null} newValue
+ * if null, unlink all items in the relation of provided record.
+ */
+ _setRelationUnlinkOne2Many(record, newValue) {
+ // convert newValue to array of localId, null is considered unlink all
+ const otherLocalIds = newValue === null
+ ? [...this.read(record)]
+ : this._setRelationConvertX2ManyValue(newValue);
+ const OtherModel = this.env.models[this.to];
+
+ for (const otherLocalId of otherLocalIds) {
+ // read in loop to catch potential changes from previous iteration
+ const prevLocalIds = this.read(record);
+
+ // other record already unlinked, avoid useless processing
+ if (!prevLocalIds.includes(otherLocalId)) {
+ continue;
+ }
+
+ // unlink other record from current record
+ this.write(record, prevLocalIds.filter(
+ localId => localId !== otherLocalId
+ ));
+
+ const otherRecord = OtherModel.get(otherLocalId);
+ // other record may be deleted due to causality, avoid useless
+ // processing
+ if (otherRecord) {
+ // unlink current record from other record
+ otherRecord.update({
+ [this.inverse]: [['unlink', record]],
+ });
+ }
+ }
+ }
+
+ /**
+ * Handling of a `set` 'unlink' of an one2one relational field.
+ *
+ * @private
+ * @param {mail.model} record
+ */
+ _setRelationUnlinkOne2One(record) {
+ const otherLocalId = this.read(record);
+ const OtherModel = this.env.models[this.to];
+
+ // other record already unlinked, avoid useless processing
+ if (!otherLocalId) {
+ return;
+ }
+
+ // unlink other record from current record
+ this.write(record, undefined);
+
+ const otherRecord = OtherModel.get(otherLocalId);
+ // other record may be deleted due to causality, avoid useless
+ // processing
+ if (otherRecord) {
+ // unlink current record from other record
+ otherRecord.update({
+ [this.inverse]: [['unlink', record]],
+ });
+ }
+ }
+
+}
+
+return ModelField;
+
+});
diff --git a/addons/mail/static/src/model/model_manager.js b/addons/mail/static/src/model/model_manager.js
new file mode 100644
index 00000000000..37ce2d2808c
--- /dev/null
+++ b/addons/mail/static/src/model/model_manager.js
@@ -0,0 +1,947 @@
+odoo.define('mail/static/src/model/model_manager.js', function (require) {
+'use strict';
+
+const { registry } = require('mail/static/src/model/model_core.js');
+const ModelField = require('mail/static/src/model/model_field.js');
+const { patchClassMethods, patchInstanceMethods } = require('mail/static/src/utils/utils.js');
+
+/**
+ * Inner separator used between 2 bits of information in string that is used to
+ * identify record and field to be computed during an update cycle.
+ */
+const COMPUTE_RECORD_FIELD_INNER_SEPARATOR = "--||--||--";
+/**
+ * Inner separator used between bits of information in string that is used to
+ * identify a dependent of a field. Useful to determine which record and field
+ * to register for compute during this update cycle.
+ */
+const DEPENDENT_INNER_SEPARATOR = "--//--//--";
+
+/**
+ * Object that manage models and records, notably their update cycle: whenever
+ * some records are requested for update (either with model static method
+ * `create()` or record method `update()`), this object processes them with
+ * direct field & and computed field updates.
+ */
+class ModelManager {
+
+ //--------------------------------------------------------------------------
+ // Public
+ //--------------------------------------------------------------------------
+
+ constructor(env) {
+ /**
+ * Inner separator used inside string to represent dependents.
+ * Set as public attribute so that it can be used by model field.
+ */
+ this.DEPENDENT_INNER_SEPARATOR = DEPENDENT_INNER_SEPARATOR;
+
+ /**
+ * The messaging env. It is passed on start(), due to the way mocking
+ * of OWL env in tests work...
+ */
+ this.env = undefined;
+
+ /**
+ * Whether this is currently handling an "update after" on a record.
+ * Useful to determine if we should process computed/related fields.
+ */
+ this._isHandlingToUpdateAfters = false;
+ /**
+ * Determine whether an update cycle is currently in progress.
+ * Useful to determine whether an update should initiate an update
+ * cycle or not. An update cycle basically prioritizes processing
+ * of all direct updates (i.e. explicit from `data`) before
+ * processing computes.
+ */
+ this._isInUpdateCycle = false;
+ /**
+ * Contains all records. key is local id, while value is the record.
+ */
+ this._records = {};
+ /**
+ * Fields flagged to call compute during an update cycle.
+ * For instance, when a field with dependents got update, dependent
+ * fields should update themselves by invoking compute at end of
+ * update cycle. Key is of format
+ * , and
+ * determine record and field to be computed. Keys are strings because
+ * it must contain only one occurrence of pair record/field, and we want
+ * O(1) reads/writes.
+ */
+ this._toComputeFields = new Map();
+ /**
+ * List of "update after" on records that have been registered.
+ * These are processed after any explicit update and computed/related
+ * fields.
+ */
+ this._toUpdateAfters = [];
+ }
+
+ /**
+ * Called when all JS modules that register or patch models have been
+ * done. This launches generation of models.
+ */
+ start(env) {
+ this.env = env;
+ /**
+ * Generate the models.
+ */
+ Object.assign(this.env.models, this._generateModels());
+ }
+
+ //--------------------------------------------------------------------------
+ // Public
+ //--------------------------------------------------------------------------
+
+ /**
+ * Returns all records of provided model that match provided criteria.
+ *
+ * @param {mail.model} Model class
+ * @param {function} [filterFunc]
+ * @returns {mail.model[]} records matching criteria.
+ */
+ all(Model, filterFunc) {
+ const allRecords = Object.values(this._records)
+ .filter(e => e instanceof Model);
+ if (filterFunc) {
+ return allRecords.filter(filterFunc);
+ }
+ return allRecords;
+ }
+
+ /**
+ * Register a record that has been created, and manage update of records
+ * from this record creation.
+ *
+ * @param {mail.model} Model class
+ * @param {Object} [data={}]
+ * @returns {mail.model} newly created record
+ */
+ create(Model, data = {}) {
+ const record = new Model({ valid: true });
+ Object.defineProperty(record, 'env', { get: () => Model.env });
+ record.localId = record._createRecordLocalId(data);
+
+ // Make state, which contain field values of record that have to
+ // be observed in store.
+ this.env.store.state[record.localId] = {};
+ record.__state = this.env.store.state[record.localId];
+
+ // Make proxified record, so that access to field redirects
+ // to field getter.
+ const proxifiedRecord = this._makeProxifiedRecord(record);
+ this._records[record.localId] = proxifiedRecord;
+ proxifiedRecord.init();
+ this._makeDefaults(proxifiedRecord);
+
+ const data2 = Object.assign({}, data);
+ for (const field of Object.values(Model.fields)) {
+ if (field.fieldType !== 'relation') {
+ continue;
+ }
+ if (!field.autocreate) {
+ continue;
+ }
+ data2[field.fieldName] = [['create']];
+ }
+
+ for (const field of Object.values(Model.fields)) {
+ if (field.compute || field.related) {
+ // new record should always invoke computed fields.
+ this.registerToComputeField(record, field);
+ }
+ }
+
+ this.update(proxifiedRecord, data2);
+
+ return proxifiedRecord;
+ }
+
+ /**
+ * Delete the record. After this operation, it's as if this record never
+ * existed. Note that relation are removed, which may delete more relations
+ * if some of them are causal.
+ *
+ * @param {mail.model} record
+ */
+ delete(record) {
+ const Model = record.constructor;
+ if (!this.get(Model, record)) {
+ // Record has already been deleted.
+ // (e.g. unlinking one of its reverse relation was causal)
+ return;
+ }
+ const data = {};
+ const recordRelations = Object.values(Model.fields)
+ .filter(field => field.fieldType === 'relation');
+ for (const relation of recordRelations) {
+ if (relation.isCausal) {
+ switch (relation.relationType) {
+ case 'one2one':
+ case 'many2one':
+ if (record[relation.fieldName]) {
+ record[relation.fieldName].delete();
+ }
+ break;
+ case 'one2many':
+ case 'many2many':
+ for (const relatedRecord of record[relation.fieldName]) {
+ relatedRecord.delete();
+ }
+ break;
+ }
+ }
+ data[relation.fieldName] = [['unlink-all']];
+ }
+ record.update(data);
+ delete this._records[record.localId];
+ delete this.env.store.state[record.localId];
+ }
+
+ /**
+ * Delete all records.
+ */
+ deleteAll() {
+ for (const record of Object.values(this._records)) {
+ record.delete();
+ }
+ }
+
+ /**
+ * Get the record of provided model that has provided
+ * criteria, if it exists.
+ *
+ * @param {mail.model} Model class
+ * @param {function} findFunc
+ * @returns {mail.model|undefined} the record of model matching criteria, if
+ * exists.
+ */
+ find(Model, findFunc) {
+ return this.all(Model).find(findFunc);
+ }
+
+ /**
+ * This method returns the record of provided model that matches provided
+ * record/local id. Useful to convert a local id to a record, and also to
+ * determine whether the record is still "alive" (i.e. not deleted). Note
+ * that even if there's a record in the system having provided local id, if
+ * the resulting record is not an instance of this model, this getter
+ * assumes the record does not exist.
+ *
+ * @param {mail.model} Model class
+ * @param {string|mail.model|undefined} recordOrLocalId
+ * @returns {mail.model|undefined} record, if exists
+ */
+ get(Model, recordOrLocalId) {
+ if (recordOrLocalId === undefined) {
+ return undefined;
+ }
+ const record = this._records[
+ recordOrLocalId instanceof this.env.models['mail.model']
+ ? recordOrLocalId.localId
+ : recordOrLocalId
+ ];
+ if (!(record instanceof Model)) {
+ return;
+ }
+ return record;
+ }
+
+ /**
+ * This method creates a record or updates one of provided Model, based on
+ * provided data. This method assumes that records are uniquely identifiable
+ * per "unique find" criteria from data on Model.
+ *
+ * @param {mail.model} Model class
+ * @param {Object} data
+ * @returns {mail.model} created or updated record.
+ */
+ insert(Model, data) {
+ let record = Model.find(Model._findFunctionFromData(data));
+ if (!record) {
+ record = Model.create(data);
+ } else {
+ record.update(data);
+ }
+ return record;
+ }
+
+ /**
+ * Process an update on provided record with provided data. Updating
+ * a record consists of applying direct updates first (i.e. explicit
+ * ones from `data`) and then indirect ones (i.e. compute/related fields
+ * and "after updates").
+ *
+ * @param {mail.model} record
+ * @param {Object} data
+ */
+ update(record, data) {
+ if (!this._isInUpdateCycle) {
+ this._isInUpdateCycle = true;
+ this._updateDirect(record, data);
+ while (
+ this._toComputeFields.size > 0 ||
+ this._toUpdateAfters.length > 0
+ ) {
+ if (this._toComputeFields.size > 0) {
+ this._updateComputes();
+ } else {
+ this._isHandlingToUpdateAfters = true;
+ // process one update after
+ const [recordToUpdate, previous] = this._toUpdateAfters.pop();
+ const RecordToUpdateModel = recordToUpdate.constructor;
+ if (this.get(RecordToUpdateModel, recordToUpdate)) {
+ recordToUpdate._updateAfter(previous);
+ }
+ this._isHandlingToUpdateAfters = false;
+ }
+ }
+ this._toComputeFields.clear();
+ this._isInUpdateCycle = false;
+ } else {
+ this._updateDirect(record, data);
+ if (this._isHandlingToUpdateAfters) {
+ this._updateComputes();
+ }
+ }
+ }
+
+ /**
+ * Register a pair record/field for the compute step of the update cycle in
+ * progress.
+ *
+ * @param {mail.model} record
+ * @param {ModelField} field
+ */
+ registerToComputeField(record, field) {
+ const entry = [record.localId, field.fieldName].join(COMPUTE_RECORD_FIELD_INNER_SEPARATOR);
+ this._toComputeFields.set(entry, true);
+ }
+
+ //--------------------------------------------------------------------------
+ // Private
+ //--------------------------------------------------------------------------
+
+ /**
+ * @private
+ * @param {mail.model} Model class
+ * @param {Object} patch
+ */
+ _applyModelPatchFields(Model, patch) {
+ for (const [fieldName, field] of Object.entries(patch)) {
+ if (!Model.fields[fieldName]) {
+ Model.fields[fieldName] = field;
+ } else {
+ Object.assign(Model.fields[fieldName].dependencies, field.dependencies);
+ }
+ }
+ }
+
+ /**
+ * @private
+ * @param {Object} Models
+ * @throws {Error} in case some declared fields are not correct.
+ */
+ _checkDeclaredFieldsOnModels(Models) {
+ for (const Model of Object.values(Models)) {
+ for (const fieldName in Model.fields) {
+ const field = Model.fields[fieldName];
+ // 0. Get parented declared fields
+ const parentedMatchingFields = [];
+ let TargetModel = Model.__proto__;
+ while (Models[TargetModel.modelName]) {
+ if (TargetModel.fields) {
+ const matchingField = TargetModel.fields[fieldName];
+ if (matchingField) {
+ parentedMatchingFields.push(matchingField);
+ }
+ }
+ TargetModel = TargetModel.__proto__;
+ }
+ // 1. Field type is required.
+ if (!(['attribute', 'relation'].includes(field.fieldType))) {
+ throw new Error(`Field "${Model.modelName}/${fieldName}" has unsupported type ${field.fieldType}.`);
+ }
+ // 2. Invalid keys based on field type.
+ if (field.fieldType === 'attribute') {
+ const invalidKeys = Object.keys(field).filter(key =>
+ ![
+ 'autocreate',
+ 'compute',
+ 'default',
+ 'dependencies',
+ 'fieldType',
+ 'related',
+ ].includes(key)
+ );
+ if (invalidKeys.length > 0) {
+ throw new Error(`Field "${Model.modelName}/${fieldName}" contains some invalid keys: "${invalidKeys.join(", ")}".`);
+ }
+ }
+ if (field.fieldType === 'relation') {
+ const invalidKeys = Object.keys(field).filter(key =>
+ ![
+ 'autocreate',
+ 'compute',
+ 'dependencies',
+ 'fieldType',
+ 'inverse',
+ 'isCausal',
+ 'related',
+ 'relationType',
+ 'to',
+ ].includes(key)
+ );
+ if (invalidKeys.length > 0) {
+ throw new Error(`Field "${Model.modelName}/${fieldName}" contains some invalid keys: "${invalidKeys.join(", ")}".`);
+ }
+ if (!Models[field.to]) {
+ throw new Error(`Relational field "${Model.modelName}/${fieldName}" targets to unknown model name "${field.to}".`);
+ }
+ if (field.isCausal && !(['one2many', 'one2one'].includes(field.relationType))) {
+ throw new Error(`Relational field "${Model.modelName}/${fieldName}" has "isCausal" true with a relation of type "${field.relationType}" but "isCausal" is only supported for "one2many" and "one2one".`);
+ }
+ }
+ // 3. Computed field.
+ if (field.compute && !(typeof field.compute === 'string')) {
+ throw new Error(`Field "${Model.modelName}/${fieldName}" property "compute" must be a string (instance method name).`);
+ }
+ if (field.compute && !(Model.prototype[field.compute])) {
+ throw new Error(`Field "${Model.modelName}/${fieldName}" property "compute" does not refer to an instance method of this Model.`);
+ }
+ if (
+ field.dependencies &&
+ (!field.compute && !parentedMatchingFields.some(field => field.compute))
+ ) {
+ throw new Error(`Field "${Model.modelName}/${fieldName} contains dependendencies but no compute method in itself or parented matching fields (dependencies only make sense for compute fields)."`);
+ }
+ if (
+ (field.compute || parentedMatchingFields.some(field => field.compute)) &&
+ (field.dependencies || parentedMatchingFields.some(field => field.dependencies))
+ ) {
+ if (!(field.dependencies instanceof Array)) {
+ throw new Error(`Compute field "${Model.modelName}/${fieldName}" dependencies must be an array of field names.`);
+ }
+ const unknownDependencies = field.dependencies.every(dependency => !(Model.fields[dependency]));
+ if (unknownDependencies.length > 0) {
+ throw new Error(`Compute field "${Model.modelName}/${fieldName}" contains some unknown dependencies: "${unknownDependencies.join(", ")}".`);
+ }
+ }
+ // 4. Related field.
+ if (field.compute && field.related) {
+ throw new Error(`Field "${Model.modelName}/${fieldName}" cannot be a related and compute field at the same time.`);
+ }
+ if (field.related) {
+ if (!(typeof field.related === 'string')) {
+ throw new Error(`Field "${Model.modelName}/${fieldName}" property "related" has invalid format.`);
+ }
+ const [relationName, relatedFieldName, other] = field.related.split('.');
+ if (!relationName || !relatedFieldName || other) {
+ throw new Error(`Field "${Model.modelName}/${fieldName}" property "related" has invalid format.`);
+ }
+ // find relation on self or parents.
+ let relatedRelation;
+ let TargetModel = Model;
+ while (Models[TargetModel.modelName] && !relatedRelation) {
+ if (TargetModel.fields) {
+ relatedRelation = TargetModel.fields[relationName];
+ }
+ TargetModel = TargetModel.__proto__;
+ }
+ if (!relatedRelation) {
+ throw new Error(`Related field "${Model.modelName}/${fieldName}" relates to unknown relation name "${relationName}".`);
+ }
+ if (relatedRelation.fieldType !== 'relation') {
+ throw new Error(`Related field "${Model.modelName}/${fieldName}" relates to non-relational field "${relationName}".`);
+ }
+ // Assuming related relation is valid...
+ // find field name on related model or any parents.
+ const RelatedModel = Models[relatedRelation.to];
+ let relatedField;
+ TargetModel = RelatedModel;
+ while (Models[TargetModel.modelName] && !relatedField) {
+ if (TargetModel.fields) {
+ relatedField = TargetModel.fields[relatedFieldName];
+ }
+ TargetModel = TargetModel.__proto__;
+ }
+ if (!relatedField) {
+ throw new Error(`Related field "${Model.modelName}/${fieldName}" relates to unknown related model field "${relatedFieldName}".`);
+ }
+ if (relatedField.fieldType !== field.fieldType) {
+ throw new Error(`Related field "${Model.modelName}/${fieldName}" has mismatch type with its related model field.`);
+ }
+ if (
+ relatedField.fieldType === 'relation' &&
+ relatedField.to !== field.to
+ ) {
+ throw new Error(`Related field "${Model.modelName}/${fieldName}" has mismatch target model name with its related model field.`);
+ }
+ }
+ }
+ }
+ }
+
+ /**
+ * @private
+ * @param {Object} Models
+ * @throws {Error} in case some fields are not correct.
+ */
+ _checkProcessedFieldsOnModels(Models) {
+ for (const Model of Object.values(Models)) {
+ for (const fieldName in Model.fields) {
+ const field = Model.fields[fieldName];
+ if (!(['attribute', 'relation'].includes(field.fieldType))) {
+ throw new Error(`Field "${Model.modelName}/${fieldName}" has unsupported type ${field.fieldType}.`);
+ }
+ if (field.compute && field.related) {
+ throw new Error(`Field "${Model.modelName}/${fieldName}" cannot be a related and compute field at the same time.`);
+ }
+ if (field.fieldType === 'attribute') {
+ continue;
+ }
+ if (!field.relationType) {
+ throw new Error(
+ `Field "${Model.modelName}/${fieldName}" must define a relation type in "relationType".`
+ );
+ }
+ if (!(['one2one', 'one2many', 'many2one', 'many2many'].includes(field.relationType))) {
+ throw new Error(
+ `Field "${Model.modelName}/${fieldName}" has invalid relation type "${field.relationType}".`
+ );
+ }
+ if (!field.inverse) {
+ throw new Error(
+ `Field "${
+ Model.modelName
+ }/${
+ fieldName
+ }" must define an inverse relation name in "inverse".`
+ );
+ }
+ if (!field.to) {
+ throw new Error(
+ `Relation "${
+ Model.modelNames
+ }/${
+ fieldName
+ }" must define a model name in "to" (1st positional parameter of relation field helpers).`
+ );
+ }
+ const RelatedModel = Models[field.to];
+ if (!RelatedModel) {
+ throw new Error(
+ `Model name of relation "${Model.modelName}/${fieldName}" does not exist.`
+ );
+ }
+ const inverseField = RelatedModel.fields[field.inverse];
+ if (!inverseField) {
+ throw new Error(
+ `Relation "${
+ Model.modelName
+ }/${
+ fieldName
+ }" has no inverse field "${RelatedModel.modelName}/${field.inverse}".`
+ );
+ }
+ if (inverseField.inverse !== fieldName) {
+ throw new Error(
+ `Inverse field name of relation "${
+ Model.modelName
+ }/${
+ fieldName
+ }" does not match with field name of relation "${
+ RelatedModel.modelName
+ }/${
+ inverseField.inverse
+ }".`
+ );
+ }
+ const allSelfAndParentNames = [];
+ let TargetModel = Model;
+ while (TargetModel) {
+ allSelfAndParentNames.push(TargetModel.modelName);
+ TargetModel = TargetModel.__proto__;
+ }
+ if (!allSelfAndParentNames.includes(inverseField.to)) {
+ throw new Error(
+ `Relation "${
+ Model.modelName
+ }/${
+ fieldName
+ }" has inverse relation "${
+ RelatedModel.modelName
+ }/${
+ field.inverse
+ }" misconfigured (currently "${
+ inverseField.to
+ }", should instead refer to this model or parented models: ${
+ allSelfAndParentNames.map(name => `"${name}"`).join(', ')
+ }?)`
+ );
+ }
+ if (
+ (field.relationType === 'many2many' && inverseField.relationType !== 'many2many') ||
+ (field.relationType === 'one2one' && inverseField.relationType !== 'one2one') ||
+ (field.relationType === 'one2many' && inverseField.relationType !== 'many2one') ||
+ (field.relationType === 'many2one' && inverseField.relationType !== 'one2many')
+ ) {
+ throw new Error(
+ `Mismatch relations types "${
+ Model.modelName
+ }/${
+ fieldName
+ }" (${
+ field.relationType
+ }) and "${
+ RelatedModel.modelName
+ }/${
+ field.inverse
+ }" (${
+ inverseField.relationType
+ }).`
+ );
+ }
+ }
+ }
+ }
+
+ /**
+ * @private
+ * @returns {Object}
+ * @throws {Error} in case it cannot generate models.
+ */
+ _generateModels() {
+ const allNames = Object.keys(registry);
+ const Models = {};
+ const generatedNames = [];
+ let toGenerateNames = [...allNames];
+ while (toGenerateNames.length > 0) {
+ const generatable = toGenerateNames.map(name => registry[name]).find(entry => {
+ let isGenerateable = true;
+ for (const dependencyName of entry.dependencies) {
+ if (!generatedNames.includes(dependencyName)) {
+ isGenerateable = false;
+ }
+ }
+ return isGenerateable;
+ });
+ if (!generatable) {
+ throw new Error(`Cannot generate following Model: ${toGenerateNames.join(', ')}`);
+ }
+ // Make environment accessible from Model.
+ const Model = generatable.factory(Models);
+ Object.defineProperty(Model, 'env', { get: () => this.env });
+ for (const patch of generatable.patches) {
+ switch (patch.type) {
+ case 'class':
+ patchClassMethods(Model, patch.name, patch.patch);
+ break;
+ case 'instance':
+ patchInstanceMethods(Model, patch.name, patch.patch);
+ break;
+ case 'field':
+ this._applyModelPatchFields(Model, patch.patch);
+ break;
+ }
+ }
+ if (!Model.hasOwnProperty('modelName')) {
+ throw new Error(`Missing static property "modelName" on Model class "${Model.name}".`);
+ }
+ if (generatedNames.includes(Model.modelName)) {
+ throw new Error(`Duplicate model name "${Model.modelName}" shared on 2 distinct Model classes.`);
+ }
+ Models[Model.modelName] = Model;
+ generatedNames.push(Model.modelName);
+ toGenerateNames = toGenerateNames.filter(name => name !== Model.modelName);
+ }
+ /**
+ * Check that declared model fields are correct.
+ */
+ this._checkDeclaredFieldsOnModels(Models);
+ /**
+ * Process declared model fields definitions, so that these field
+ * definitions are much easier to use in the system. For instance, all
+ * relational field definitions have an inverse, or fields track all their
+ * dependents.
+ */
+ this._processDeclaredFieldsOnModels(Models);
+ /**
+ * Check that all model fields are correct, notably one relation
+ * should have matching reversed relation.
+ */
+ this._checkProcessedFieldsOnModels(Models);
+ return Models;
+ }
+
+ /**
+ * Make default values of its fields for newly created record.
+ *
+ * @private
+ * @param {mail.model} record
+ */
+ _makeDefaults(record) {
+ const Model = record.constructor;
+ for (const field of Object.values(Model.fields)) {
+ if (field.fieldType === 'attribute') {
+ field.write(record, field.default, { registerDependents: false });
+ }
+ if (field.fieldType === 'relation') {
+ if (['one2many', 'many2many'].includes(field.relationType)) {
+ // Ensure X2many relations are arrays by defaults.
+ field.write(record, [], { registerDependents: false });
+ } else {
+ field.write(record, undefined, { registerDependents: false });
+ }
+ }
+ }
+ }
+
+ /**
+ * @private
+ * @param {mail.model} Model class
+ * @param {ModelField} field
+ * @returns {ModelField}
+ */
+ _makeInverseRelationField(Model, field) {
+ const relFunc =
+ field.relationType === 'many2many' ? ModelField.many2many
+ : field.relationType === 'many2one' ? ModelField.one2many
+ : field.relationType === 'one2many' ? ModelField.many2one
+ : field.relationType === 'one2one' ? ModelField.one2one
+ : undefined;
+ if (!relFunc) {
+ throw new Error(`Cannot compute inverse Relation of "${Model.modelName}/${field.fieldName}".`);
+ }
+ const inverseField = new ModelField(Object.assign(
+ {},
+ relFunc(Model.modelName, { inverse: field.fieldName }),
+ {
+ fieldName: `_inverse_${Model.modelName}/${field.fieldName}`,
+ modelManager: this,
+ }
+ ));
+ return inverseField;
+ }
+
+ /**
+ * Wrap record that has just been created in a proxy. Proxy is useful for
+ * auto-getting records when accessing relational fields.
+ *
+ * @private
+ * @param {mail.model} record
+ * @return {Proxy} proxified record
+ */
+ _makeProxifiedRecord(record) {
+ const proxifiedRecord = new Proxy(record, {
+ get: (target, k) => {
+ if (k === 'constructor') {
+ return target[k];
+ }
+ const field = target.constructor.fields[k];
+ if (!field) {
+ // No crash, we allow these reads due to patch()
+ // implementation details that read on `this._super` even
+ // if not set before-hand.
+ return target[k];
+ }
+ return field.get(proxifiedRecord);
+ },
+ set: (target, k, newVal) => {
+ if (target.constructor.fields[k]) {
+ throw new Error("Forbidden to write on record field without .update()!!");
+ } else {
+ // No crash, we allow these writes due to following concerns:
+ // - patch() implementation details that write on `this._super`
+ // - record listeners that need setting on this with `.bind(this)`
+ target[k] = newVal;
+ }
+ return true;
+ },
+ });
+ return proxifiedRecord;
+ }
+
+ /**
+ * This function processes definition of declared fields in provided models.
+ * Basically, models have fields declared in static prop `fields`, and this
+ * function processes and modifies them in place so that they are fully
+ * configured. For instance, model relations need bi-directional mapping, but
+ * inverse relation may be omitted in declared field: this function auto-fill
+ * this inverse relation.
+ *
+ * @private
+ * @param {Object} Models
+ */
+ _processDeclaredFieldsOnModels(Models) {
+ /**
+ * 1. Prepare fields.
+ */
+ for (const Model of Object.values(Models)) {
+ if (!Model.hasOwnProperty('fields')) {
+ Model.fields = {};
+ }
+ Model.inverseRelations = [];
+ // Make fields aware of their field name.
+ for (const [fieldName, fieldData] of Object.entries(Model.fields)) {
+ Model.fields[fieldName] = new ModelField(Object.assign({}, fieldData, {
+ fieldName,
+ modelManager: this,
+ }));
+ }
+ }
+ /**
+ * 2. Auto-generate definitions of undeclared inverse relations.
+ */
+ for (const Model of Object.values(Models)) {
+ for (const field of Object.values(Model.fields)) {
+ if (field.fieldType !== 'relation') {
+ continue;
+ }
+ if (field.inverse) {
+ continue;
+ }
+ const RelatedModel = Models[field.to];
+ const inverseField = this._makeInverseRelationField(Model, field);
+ field.inverse = inverseField.fieldName;
+ RelatedModel.fields[inverseField.fieldName] = inverseField;
+ }
+ }
+ /**
+ * 3. Generate dependents and inverse-relates on fields.
+ * Field definitions are not yet combined, so registration of `dependents`
+ * may have to walk structural hierarchy of models in order to find
+ * the appropriate field. Also, while dependencies are defined just with
+ * field names, dependents require an additional data called a "hash"
+ * (= field id), which is a way to identify dependents in an inverse
+ * relation. This is necessary because dependents are a subset of an inverse
+ * relation.
+ */
+ for (const Model of Object.values(Models)) {
+ for (const field of Object.values(Model.fields)) {
+ for (const dependencyFieldName of field.dependencies) {
+ let TargetModel = Model;
+ let dependencyField = TargetModel.fields[dependencyFieldName];
+ while (!dependencyField) {
+ TargetModel = TargetModel.__proto__;
+ dependencyField = TargetModel.fields[dependencyFieldName];
+ }
+ const dependent = [field.id, field.fieldName].join(DEPENDENT_INNER_SEPARATOR);
+ dependencyField.dependents = [
+ ...new Set(dependencyField.dependents.concat([dependent]))
+ ];
+ }
+ if (field.related) {
+ const [relationName, relatedFieldName] = field.related.split('.');
+ let TargetModel = Model;
+ let relationField = TargetModel.fields[relationName];
+ while (!relationField) {
+ TargetModel = TargetModel.__proto__;
+ relationField = TargetModel.fields[relationName];
+ }
+ const relationFieldDependent = [
+ field.id,
+ field.fieldName,
+ ].join(DEPENDENT_INNER_SEPARATOR);
+ relationField.dependents = [
+ ...new Set(relationField.dependents.concat([relationFieldDependent]))
+ ];
+ const OtherModel = Models[relationField.to];
+ let OtherTargetModel = OtherModel;
+ let relatedField = OtherTargetModel.fields[relatedFieldName];
+ while (!relatedField) {
+ OtherTargetModel = OtherTargetModel.__proto__;
+ relatedField = OtherTargetModel.fields[relatedFieldName];
+ }
+ const relatedFieldDependent = [
+ field.id,
+ relationField.inverse,
+ field.fieldName,
+ ].join(DEPENDENT_INNER_SEPARATOR);
+ relatedField.dependents = [
+ ...new Set(
+ relatedField.dependents.concat([relatedFieldDependent])
+ )
+ ];
+ }
+ }
+ }
+ /**
+ * 4. Extend definition of fields of a model with the definition of
+ * fields of its parents. Field definitions on self has precedence over
+ * parented fields.
+ */
+ for (const Model of Object.values(Models)) {
+ Model.__combinedFields = {};
+ for (const field of Object.values(Model.fields)) {
+ Model.__combinedFields[field.fieldName] = field;
+ }
+ let TargetModel = Model.__proto__;
+ while (TargetModel && TargetModel.fields) {
+ for (const targetField of Object.values(TargetModel.fields)) {
+ const field = Model.__combinedFields[targetField.fieldName];
+ if (field) {
+ Model.__combinedFields[targetField.fieldName] = field.combine(targetField);
+ } else {
+ Model.__combinedFields[targetField.fieldName] = targetField;
+ }
+ }
+ TargetModel = TargetModel.__proto__;
+ }
+ }
+ for (const Model of Object.values(Models)) {
+ Model.fields = Model.__combinedFields;
+ delete Model.__combinedFields;
+ }
+ }
+
+ /**
+ * Process registered computed fields in the current update cycle.
+ *
+ * @private
+ */
+ _updateComputes() {
+ while (this._toComputeFields.size > 0) {
+ // process one compute field
+ const key = this._toComputeFields.keys().next().value;
+ const [recordLocalId, fieldName] = key.split(COMPUTE_RECORD_FIELD_INNER_SEPARATOR);
+ this._toComputeFields.delete(key);
+ const record = this.env.models['mail.model'].get(recordLocalId);
+ if (record) {
+ const Model = record.constructor;
+ const field = Model.fields[fieldName];
+ field.doCompute(record);
+ }
+ }
+ }
+
+ /**
+ * Process a direct update on given record with provided data.
+ *
+ * @private
+ * @param {mail.model} record
+ * @param {Object} data
+ */
+ _updateDirect(record, data) {
+ const existing = this._toUpdateAfters.find(entry => entry[0] === record);
+ if (!existing) {
+ // queue updateAfter before calling field.set to ensure previous
+ // contains the value at the start of update cycle
+ this._toUpdateAfters.push([record, record._updateBefore()]);
+ }
+ for (const [k, v] of Object.entries(data)) {
+ const Model = record.constructor;
+ const field = Model.fields[k];
+ if (!field) {
+ throw new Error(`Cannot create/update record with data unrelated to a field. (model: "${Model.modelName}", non-field attempted update: "${k}")`);
+ }
+ field.set(record, v);
+ }
+ }
+
+}
+
+return ModelManager;
+
+});
diff --git a/addons/mail/static/src/models/activity/activity.js b/addons/mail/static/src/models/activity/activity.js
new file mode 100644
index 00000000000..12c40bd8bc5
--- /dev/null
+++ b/addons/mail/static/src/models/activity/activity.js
@@ -0,0 +1,289 @@
+odoo.define('mail/static/src/models/activity/activity/js', function (require) {
+'use strict';
+
+const { registerNewModel } = require('mail/static/src/model/model_core.js');
+const { attr, many2many, many2one } = require('mail/static/src/model/model_field.js');
+
+function factory(dependencies) {
+
+ class Activity extends dependencies['mail.model'] {
+
+
+ //----------------------------------------------------------------------
+ // Public
+ //----------------------------------------------------------------------
+
+ /**
+ * Delete the record from database and locally.
+ */
+ async deleteServerRecord() {
+ await this.async(() => this.env.services.rpc({
+ model: 'mail.activity',
+ method: 'unlink',
+ args: [[this.id]],
+ }));
+ this.delete();
+ }
+
+ //----------------------------------------------------------------------
+ // Public
+ //----------------------------------------------------------------------
+
+ /**
+ * @static
+ * @param {Object} data
+ * @return {Object}
+ */
+ static convertData(data) {
+ const data2 = {};
+ if ('activity_category' in data) {
+ data2.category = data.activity_category;
+ }
+ if ('can_write' in data) {
+ data2.canWrite = data.can_write;
+ }
+ if ('create_data' in data) {
+ data2.dateCreate = data.create_date;
+ }
+ if ('date_deadline' in data) {
+ data2.dateDeadline = data.date_deadline;
+ }
+ if ('force_next' in data) {
+ data2.force_next = data.force_next;
+ }
+ if ('icon' in data) {
+ data2.icon = data.icon;
+ }
+ if ('id' in data) {
+ data2.id = data.id;
+ }
+ if ('note' in data) {
+ data2.note = data.note;
+ }
+ if ('res_id' in data) {
+ data2.res_id = data.res_id;
+ }
+ if ('res_model' in data) {
+ data2.res_model = data.res_model;
+ }
+ if ('state' in data) {
+ data2.state = data.state;
+ }
+ if ('summary' in data) {
+ data2.summary = data.summary;
+ }
+
+ // relation
+ if ('activity_type_id' in data) {
+ if (!data.activity_type_id) {
+ data2.type = [['unlink-all']];
+ } else {
+ data2.type = [
+ ['insert', {
+ displayName: data.activity_type_id[1],
+ id: data.activity_type_id[0],
+ }],
+ ];
+ }
+ }
+ if ('create_uid' in data) {
+ if (!data.create_uid) {
+ data2.creator = [['unlink-all']];
+ } else {
+ data2.creator = [
+ ['insert', {
+ id: data.create_uid[0],
+ partnerDisplayName: data.create_uid[1],
+ }],
+ ];
+ }
+ }
+ if ('mail_template_ids' in data) {
+ data2.mailTemplates = [['insert', data.mail_template_ids]];
+ }
+ if ('user_id' in data) {
+ if (!data.user_id) {
+ data2.assignee = [['unlink-all']];
+ } else {
+ data2.assignee = [
+ ['insert', {
+ id: data.user_id[0],
+ partnerDisplayName: data.user_id[1],
+ }],
+ ];
+ }
+ }
+
+ return data2;
+ }
+
+ /**
+ * Opens (legacy) form view dialog to edit current activity and updates
+ * the activity when dialog is closed.
+ */
+ edit() {
+ const action = {
+ type: 'ir.actions.act_window',
+ name: this.env._t("Schedule Activity"),
+ res_model: 'mail.activity',
+ view_mode: 'form',
+ views: [[false, 'form']],
+ target: 'new',
+ context: {
+ default_res_id: this.res_id,
+ default_res_model: this.res_model,
+ },
+ res_id: this.id,
+ };
+ this.env.bus.trigger('do-action', {
+ action,
+ options: { on_close: () => this.fetchAndUpdate() },
+ });
+ }
+
+ async fetchAndUpdate() {
+ const [data] = await this.async(() => this.env.services.rpc({
+ model: 'mail.activity',
+ method: 'activity_format',
+ args: [this.id],
+ }));
+ this.update(this.constructor.convertData(data));
+ if (this.chatter) {
+ this.chatter.refresh();
+ }
+ }
+
+ /**
+ * @param {Object} param0
+ * @param {mail.attachment[]} [param0.attachments=[]]
+ * @param {string|boolean} [param0.feedback=false]
+ */
+ async markAsDone({ attachments = [], feedback = false }) {
+ const attachmentIds = attachments.map(attachment => attachment.id);
+ await this.async(() => this.env.services.rpc({
+ model: 'mail.activity',
+ method: 'action_feedback',
+ args: [[this.id]],
+ kwargs: {
+ attachment_ids: attachmentIds,
+ feedback,
+ },
+ context: this.chatter ? this.chatter.context : {},
+ }));
+ if (this.chatter) {
+ this.chatter.refresh();
+ }
+ this.delete();
+ }
+
+ /**
+ * @param {Object} param0
+ * @param {string} param0.feedback
+ * @returns {Object}
+ */
+ async markAsDoneAndScheduleNext({ feedback }) {
+ const action = await this.async(() => this.env.services.rpc({
+ model: 'mail.activity',
+ method: 'action_feedback_schedule_next',
+ args: [[this.id]],
+ kwargs: { feedback },
+ }));
+ const chatter = this.chatter;
+ if (chatter) {
+ this.chatter.refresh();
+ }
+ this.delete();
+ this.env.bus.trigger('do-action', {
+ action,
+ options: {
+ on_close: () => {
+ if (chatter) {
+ chatter.refreshActivities();
+ }
+ },
+ },
+ });
+ }
+
+ //----------------------------------------------------------------------
+ // Private
+ //----------------------------------------------------------------------
+
+ /**
+ * @private
+ * @returns {boolean}
+ */
+ _computeIsCurrentPartnerAssignee() {
+ if (!this.assigneePartner || !this.messagingCurrentPartner) {
+ return false;
+ }
+ return this.assigneePartner === this.messagingCurrentPartner;
+ }
+
+ /**
+ * @private
+ * @returns {mail.messaging}
+ */
+ _computeMessaging() {
+ return [['link', this.env.messaging]];
+ }
+ }
+
+ Activity.fields = {
+ assignee: many2one('mail.user'),
+ assigneePartner: many2one('mail.partner', {
+ related: 'assignee.partner',
+ }),
+ attachments: many2many('mail.attachment', {
+ inverse: 'activities',
+ }),
+ canWrite: attr({
+ default: false,
+ }),
+ category: attr(),
+ chatter: many2one('mail.chatter', {
+ inverse: 'activities',
+ }),
+ creator: many2one('mail.user'),
+ dateCreate: attr(),
+ dateDeadline: attr(),
+ force_next: attr({
+ default: false,
+ }),
+ icon: attr(),
+ id: attr(),
+ isCurrentPartnerAssignee: attr({
+ compute: '_computeIsCurrentPartnerAssignee',
+ default: false,
+ dependencies: [
+ 'assigneePartner',
+ 'messagingCurrentPartner',
+ ],
+ }),
+ mailTemplates: many2many('mail.mail_template', {
+ inverse: 'activities',
+ }),
+ messaging: many2one('mail.messaging', {
+ compute: '_computeMessaging',
+ }),
+ messagingCurrentPartner: many2one('mail.partner', {
+ related: 'messaging.currentPartner',
+ }),
+ note: attr(),
+ res_id: attr(),
+ res_model: attr(),
+ state: attr(),
+ summary: attr(),
+ type: many2one('mail.activity_type', {
+ inverse: 'activities',
+ }),
+ };
+
+ Activity.modelName = 'mail.activity';
+
+ return Activity;
+}
+
+registerNewModel('mail.activity', factory);
+
+});
diff --git a/addons/mail/static/src/models/activity_type/activity_type.js b/addons/mail/static/src/models/activity_type/activity_type.js
new file mode 100644
index 00000000000..7af2ae74095
--- /dev/null
+++ b/addons/mail/static/src/models/activity_type/activity_type.js
@@ -0,0 +1,26 @@
+odoo.define('mail/static/src/models/activity_type/activity_type.js', function (require) {
+'use strict';
+
+const { registerNewModel } = require('mail/static/src/model/model_core.js');
+const { attr, one2many } = require('mail/static/src/model/model_field.js');
+
+function factory(dependencies) {
+
+ class ActivityType extends dependencies['mail.model'] {}
+
+ ActivityType.fields = {
+ activities: one2many('mail.activity', {
+ inverse: 'type',
+ }),
+ displayName: attr(),
+ id: attr(),
+ };
+
+ ActivityType.modelName = 'mail.activity_type';
+
+ return ActivityType;
+}
+
+registerNewModel('mail.activity_type', factory);
+
+});
diff --git a/addons/mail/static/src/models/attachment/attachment.js b/addons/mail/static/src/models/attachment/attachment.js
new file mode 100644
index 00000000000..0454890401c
--- /dev/null
+++ b/addons/mail/static/src/models/attachment/attachment.js
@@ -0,0 +1,342 @@
+odoo.define('mail/static/src/models/attachment/attachment.js', function (require) {
+'use strict';
+
+const { registerNewModel } = require('mail/static/src/model/model_core.js');
+const { attr, many2many, many2one } = require('mail/static/src/model/model_field.js');
+
+function factory(dependencies) {
+
+ let nextTemporaryId = -1;
+ function getAttachmentNextTemporaryId() {
+ const id = nextTemporaryId;
+ nextTemporaryId -= 1;
+ return id;
+ }
+ class Attachment extends dependencies['mail.model'] {
+
+ //----------------------------------------------------------------------
+ // Public
+ //----------------------------------------------------------------------
+
+ /**
+ * @static
+ * @param {Object} data
+ * @return {Object}
+ */
+ static convertData(data) {
+ const data2 = {};
+ if ('filename' in data) {
+ data2.filename = data.filename;
+ }
+ if ('id' in data) {
+ data2.id = data.id;
+ }
+ if ('mimetype' in data) {
+ data2.mimetype = data.mimetype;
+ }
+ if ('name' in data) {
+ data2.name = data.name;
+ }
+
+ // relation
+ if ('res_id' in data && 'res_model' in data) {
+ data2.originThread = [['insert', {
+ id: data.res_id,
+ model: data.res_model,
+ }]];
+ }
+
+ return data2;
+ }
+
+ /**
+ * View provided attachment(s), with given attachment initially. Prompts
+ * the attachment viewer.
+ *
+ * @param {Object} param0
+ * @param {mail.attachment} [param0.attachment]
+ * @param {mail.attachment>[]} param0.attachments
+ * @returns {string|undefined} unique id of open dialog, if open
+ */
+ static view({ attachment, attachments }) {
+ if (!attachments || attachments.length === 0) {
+ return;
+ }
+ if (!attachment) {
+ attachment = attachments[0];
+ }
+ if (!attachments.includes(attachment)) {
+ return;
+ }
+ this.env.messaging.dialogManager.open('mail.attachment_viewer', {
+ attachment: [['replace', attachment]],
+ attachments: [['replace', attachments]],
+ });
+ }
+
+ /**
+ * Remove this attachment globally.
+ */
+ async remove() {
+ await this.async(() => this.env.services.rpc({
+ model: 'ir.attachment',
+ method: 'unlink',
+ args: [this.id],
+ }, { shadow: true }));
+ this.delete();
+ }
+
+ //----------------------------------------------------------------------
+ // Private
+ //----------------------------------------------------------------------
+
+ /**
+ * @private
+ * @returns {mail.composer[]}
+ */
+ _computeComposers() {
+ if (this.isTemporary) {
+ return [];
+ }
+ const relatedTemporaryAttachment = this.env.models['mail.attachment']
+ .find(attachment =>
+ attachment.filename === this.filename &&
+ attachment.isTemporary
+ );
+ if (relatedTemporaryAttachment) {
+ const composers = relatedTemporaryAttachment.composers;
+ relatedTemporaryAttachment.delete();
+ return [['replace', composers]];
+ }
+ return [];
+ }
+
+ /**
+ * @private
+ * @returns {string}
+ */
+ _computeDefaultSource() {
+ if (this.fileType === 'image') {
+ return `/web/image/${this.id}?unique=1&signature=${this.checksum}&model=ir.attachment`;
+ }
+ if (this.fileType === 'application/pdf') {
+ return `/web/static/lib/pdfjs/web/viewer.html?file=/web/content/${this.id}?model%3Dir.attachment`;
+ }
+ if (this.fileType && this.fileType.includes('text')) {
+ return `/web/content/${this.id}?model%3Dir.attachment`;
+ }
+ if (this.fileType === 'youtu') {
+ const urlArr = this.url.split('/');
+ let token = urlArr[urlArr.length - 1];
+ if (token.includes('watch')) {
+ token = token.split('v=')[1];
+ const amp = token.indexOf('&');
+ if (amp !== -1) {
+ token = token.substring(0, amp);
+ }
+ }
+ return `https://www.youtube.com/embed/${token}`;
+ }
+ if (this.fileType === 'video') {
+ return `/web/image/${this.id}?model=ir.attachment`;
+ }
+ return undefined;
+ }
+
+ /**
+ * @private
+ * @returns {string}
+ */
+ _computeDisplayName() {
+ return this.name || this.filename;
+ }
+
+ /**
+ * @private
+ * @returns {string|undefined}
+ */
+ _computeExtension() {
+ return this.filename && this.filename.split('.').pop();
+ }
+
+ /**
+ * @private
+ * @returns {string|undefined}
+ */
+ _computeFileType() {
+ if (this.type === 'url' && !this.url) {
+ return undefined;
+ } else if (!this.mimetype) {
+ return undefined;
+ }
+ const match = this.type === 'url'
+ ? this.url.match('(youtu|.png|.jpg|.gif)')
+ : this.mimetype.match('(image|video|application/pdf|text)');
+ if (!match) {
+ return undefined;
+ }
+ if (match[1].match('(.png|.jpg|.gif)')) {
+ return 'image';
+ }
+ return match[1];
+ }
+
+ /**
+ * @private
+ * @returns {integer}
+ */
+ _computeId() {
+ if (this.isTemporary && (this.id === undefined || this.id > 0)) {
+ return getAttachmentNextTemporaryId();
+ }
+ return this.id;
+ }
+
+ /**
+ * @private
+ * @returns {boolean}
+ */
+ _computeIsLinkedToComposer() {
+ return this.composers.length > 0;
+ }
+
+ /**
+ * @private
+ * @returns {boolean}
+ */
+ _computeIsTextFile() {
+ if (!this.fileType) {
+ return false;
+ }
+ return this.fileType.includes('text');
+ }
+
+ /**
+ * @private
+ * @returns {boolean}
+ */
+ _computeIsViewable() {
+ return (
+ this.mediaType === 'image' ||
+ this.mediaType === 'video' ||
+ this.mimetype === 'application/pdf' ||
+ this.isTextFile
+ );
+ }
+
+ /**
+ * @private
+ * @returns {string}
+ */
+ _computeMediaType() {
+ return this.mimetype && this.mimetype.split('/').shift();
+ }
+
+ /**
+ * @override
+ */
+ _createRecordLocalId(data) {
+ const { id, isTemporary = false } = data;
+ const Attachment = this.env.models['mail.attachment'];
+ if (isTemporary) {
+ return `${Attachment.modelName}_${nextTemporaryId}`;
+ }
+ return `${Attachment.modelName}_${id}`;
+ }
+
+ }
+
+ Attachment.fields = {
+ activities: many2many('mail.activity', {
+ inverse: 'attachments',
+ }),
+ attachmentViewer: many2many('mail.attachment_viewer', {
+ inverse: 'attachments',
+ }),
+ checkSum: attr(),
+ composers: many2many('mail.composer', {
+ compute: '_computeComposers',
+ inverse: 'attachments',
+ }),
+ defaultSource: attr({
+ compute: '_computeDefaultSource',
+ dependencies: [
+ 'checkSum',
+ 'fileType',
+ 'id',
+ 'url',
+ ],
+ }),
+ displayName: attr({
+ compute: '_computeDisplayName',
+ dependencies: [
+ 'filename',
+ 'name',
+ ],
+ }),
+ extension: attr({
+ compute: '_computeExtension',
+ dependencies: ['filename'],
+ }),
+ filename: attr(),
+ fileType: attr({
+ compute: '_computeFileType',
+ dependencies: [
+ 'mimetype',
+ 'type',
+ 'url',
+ ],
+ }),
+ id: attr({
+ compute: '_computeId',
+ dependencies: ['isTemporary'],
+ }),
+ isLinkedToComposer: attr({
+ compute: '_computeIsLinkedToComposer',
+ dependencies: ['composers'],
+ }),
+ isTemporary: attr({
+ default: false,
+ }),
+ isTextFile: attr({
+ compute: '_computeIsTextFile',
+ dependencies: ['fileType'],
+ }),
+ isViewable: attr({
+ compute: '_computeIsViewable',
+ dependencies: [
+ 'mediaType',
+ 'isTextFile',
+ 'mimetype',
+ ],
+ }),
+ mediaType: attr({
+ compute: '_computeMediaType',
+ dependencies: ['mimetype'],
+ }),
+ messages: many2many('mail.message', {
+ inverse: 'attachments',
+ }),
+ mimetype: attr({
+ default: '',
+ }),
+ name: attr(),
+ originThread: many2one('mail.thread', {
+ inverse: 'originThreadAttachments',
+ }),
+ size: attr(),
+ threads: many2many('mail.thread', {
+ inverse: 'attachments',
+ }),
+ type: attr(),
+ url: attr(),
+ };
+
+ Attachment.modelName = 'mail.attachment';
+
+ return Attachment;
+}
+
+registerNewModel('mail.attachment', factory);
+
+});
diff --git a/addons/mail/static/src/models/attachment/attachment_tests.js b/addons/mail/static/src/models/attachment/attachment_tests.js
new file mode 100644
index 00000000000..b2761f0d99d
--- /dev/null
+++ b/addons/mail/static/src/models/attachment/attachment_tests.js
@@ -0,0 +1,156 @@
+odoo.define('mail/static/src/models/attachment/attachment_tests.js', function (require) {
+'use strict';
+
+const {
+ afterEach: utilsAfterEach,
+ beforeEach: utilsBeforeEach,
+ start: utilsStart,
+} = require('mail/static/src/utils/test_utils.js');
+
+QUnit.module('mail', {}, function () {
+QUnit.module('models', {}, function () {
+QUnit.module('attachment', {}, function () {
+QUnit.module('attachment_tests.js', {
+ beforeEach() {
+ utilsBeforeEach(this);
+
+ this.start = async params => {
+ if (this.widget) {
+ this.widget.destroy();
+ }
+ let { env, widget } = await utilsStart(Object.assign({}, params, {
+ data: this.data,
+ }));
+ this.env = env;
+ this.widget = widget;
+ };
+ },
+ afterEach() {
+ utilsAfterEach(this);
+ this.env = undefined;
+ if (this.widget) {
+ this.widget.destroy();
+ this.widget = undefined;
+ }
+ },
+});
+
+QUnit.test('create (txt)', async function (assert) {
+ assert.expect(9);
+
+ await this.start();
+ assert.notOk(this.env.models['mail.attachment'].find(attachment => attachment.id === 750));
+
+ const attachment = this.env.models['mail.attachment'].create({
+ filename: "test.txt",
+ id: 750,
+ mimetype: 'text/plain',
+ name: "test.txt",
+ });
+ assert.ok(attachment);
+ assert.ok(this.env.models['mail.attachment'].find(attachment => attachment.id === 750));
+ assert.strictEqual(this.env.models['mail.attachment'].find(attachment => attachment.id === 750), attachment);
+ assert.strictEqual(attachment.filename, "test.txt");
+ assert.strictEqual(attachment.id, 750);
+ assert.notOk(attachment.isTemporary);
+ assert.strictEqual(attachment.mimetype, 'text/plain');
+ assert.strictEqual(attachment.name, "test.txt");
+});
+
+QUnit.test('displayName', async function (assert) {
+ assert.expect(5);
+
+ await this.start();
+ assert.notOk(this.env.models['mail.attachment'].find(attachment => attachment.id === 750));
+
+ const attachment = this.env.models['mail.attachment'].create({
+ filename: "test.txt",
+ id: 750,
+ mimetype: 'text/plain',
+ name: "test.txt",
+ });
+ assert.ok(attachment);
+ assert.ok(this.env.models['mail.attachment'].find(attachment => attachment.id === 750));
+ assert.strictEqual(attachment, this.env.models['mail.attachment'].find(attachment => attachment.id === 750));
+ assert.strictEqual(attachment.displayName, "test.txt");
+});
+
+QUnit.test('extension', async function (assert) {
+ assert.expect(5);
+
+ await this.start();
+ assert.notOk(this.env.models['mail.attachment'].find(attachment => attachment.id === 750));
+
+ const attachment = this.env.models['mail.attachment'].create({
+ filename: "test.txt",
+ id: 750,
+ mimetype: 'text/plain',
+ name: "test.txt",
+ });
+ assert.ok(attachment);
+ assert.ok(this.env.models['mail.attachment'].find(attachment => attachment.id === 750));
+ assert.strictEqual(attachment, this.env.models['mail.attachment'].find(attachment => attachment.id === 750));
+ assert.strictEqual(attachment.extension, 'txt');
+});
+
+QUnit.test('fileType', async function (assert) {
+ assert.expect(5);
+
+ await this.start();
+ assert.notOk(this.env.models['mail.attachment'].find(attachment => attachment.id === 750));
+
+ const attachment = this.env.models['mail.attachment'].create({
+ filename: "test.txt",
+ id: 750,
+ mimetype: 'text/plain',
+ name: "test.txt",
+ });
+ assert.ok(attachment);
+ assert.ok(this.env.models['mail.attachment'].find(attachment => attachment.id === 750));
+ assert.strictEqual(attachment, this.env.models['mail.attachment'].find(attachment =>
+ attachment.id === 750)
+ );
+ assert.strictEqual(attachment.fileType, 'text');
+});
+
+QUnit.test('isTextFile', async function (assert) {
+ assert.expect(5);
+
+ await this.start();
+ assert.notOk(this.env.models['mail.attachment'].find(attachment => attachment.id === 750));
+
+ const attachment = this.env.models['mail.attachment'].create({
+ filename: "test.txt",
+ id: 750,
+ mimetype: 'text/plain',
+ name: "test.txt",
+ });
+ assert.ok(attachment);
+ assert.ok(this.env.models['mail.attachment'].find(attachment => attachment.id === 750));
+ assert.strictEqual(attachment, this.env.models['mail.attachment'].find(attachment => attachment.id === 750));
+ assert.ok(attachment.isTextFile);
+});
+
+QUnit.test('isViewable', async function (assert) {
+ assert.expect(5);
+
+ await this.start();
+ assert.notOk(this.env.models['mail.attachment'].find(attachment => attachment.id === 750));
+
+ const attachment = this.env.models['mail.attachment'].create({
+ filename: "test.txt",
+ id: 750,
+ mimetype: 'text/plain',
+ name: "test.txt",
+ });
+ assert.ok(attachment);
+ assert.ok(this.env.models['mail.attachment'].find(attachment => attachment.id === 750));
+ assert.strictEqual(attachment, this.env.models['mail.attachment'].find(attachment => attachment.id === 750));
+ assert.ok(attachment.isViewable);
+});
+
+});
+});
+});
+
+});
diff --git a/addons/mail/static/src/models/attachment_viewer/attachment_viewer.js b/addons/mail/static/src/models/attachment_viewer/attachment_viewer.js
new file mode 100644
index 00000000000..7f9da0c7a8f
--- /dev/null
+++ b/addons/mail/static/src/models/attachment_viewer/attachment_viewer.js
@@ -0,0 +1,44 @@
+odoo.define('mail/static/src/models/attachment_viewer/attachment_viewer.js', function (require) {
+'use strict';
+
+const { registerNewModel } = require('mail/static/src/model/model_core.js');
+const { attr, many2many, many2one } = require('mail/static/src/model/model_field.js');
+
+function factory(dependencies) {
+
+ class AttachmentViewer extends dependencies['mail.model'] {}
+
+ AttachmentViewer.fields = {
+ /**
+ * Angle of the image. Changes when the user rotates it.
+ */
+ angle: attr({
+ default: 0,
+ }),
+ attachment: many2one('mail.attachment'),
+ attachments: many2many('mail.attachment', {
+ inverse: 'attachmentViewer',
+ }),
+ /**
+ * Determine whether the image is loading or not. Useful to diplay
+ * a spinner when loading image initially.
+ */
+ isImageLoading: attr({
+ default: false,
+ }),
+ /**
+ * Scale size of the image. Changes when user zooms in/out.
+ */
+ scale: attr({
+ default: 1,
+ }),
+ };
+
+ AttachmentViewer.modelName = 'mail.attachment_viewer';
+
+ return AttachmentViewer;
+}
+
+registerNewModel('mail.attachment_viewer', factory);
+
+});
diff --git a/addons/mail/static/src/models/chat_window/chat_window.js b/addons/mail/static/src/models/chat_window/chat_window.js
new file mode 100644
index 00000000000..e08fc9029b2
--- /dev/null
+++ b/addons/mail/static/src/models/chat_window/chat_window.js
@@ -0,0 +1,322 @@
+odoo.define('mail/static/src/models/chat_window/chat_window.js', function (require) {
+'use strict';
+
+const { registerNewModel } = require('mail/static/src/model/model_core.js');
+const { attr, many2one, one2many, one2one } = require('mail/static/src/model/model_field.js');
+
+function factory(dependencies) {
+
+ class ChatWindow extends dependencies['mail.model'] {
+
+ //----------------------------------------------------------------------
+ // Public
+ //----------------------------------------------------------------------
+
+ /**
+ * Close this chat window.
+ */
+ close() {
+ const thread = this.thread;
+ this.delete();
+ if (thread) {
+ thread.update({ pendingFoldState: 'closed' });
+ }
+ }
+
+ expand() {
+ if (this.thread) {
+ this.thread.openExpanded();
+ }
+ }
+
+ /**
+ * Programmatically auto-focus an existing chat window.
+ */
+ focus() {
+ this.update({ isDoFocus: true });
+ }
+
+ focusNextVisibleUnfoldedChatWindow() {
+ const nextVisibleUnfoldedChatWindow = this._getNextVisibleUnfoldedChatWindow();
+ if (nextVisibleUnfoldedChatWindow) {
+ nextVisibleUnfoldedChatWindow.focus();
+ }
+ }
+
+ focusPreviousVisibleUnfoldedChatWindow() {
+ const previousVisibleUnfoldedChatWindow =
+ this._getNextVisibleUnfoldedChatWindow({ reverse: true });
+ if (previousVisibleUnfoldedChatWindow) {
+ previousVisibleUnfoldedChatWindow.focus();
+ }
+ }
+
+ fold() {
+ if (this.thread) {
+ this.thread.update({ pendingFoldState: 'folded' });
+ } else {
+ this.update({ _isFolded: true });
+ }
+ }
+
+ /**
+ * Assume that this chat window was hidden before-hand.
+ */
+ makeVisible() {
+ const lastVisible = this.manager.lastVisible;
+ this.manager.swap(this, lastVisible);
+ }
+
+ /**
+ * Shift this chat window to the left on screen.
+ */
+ shiftLeft() {
+ this.manager.shiftLeft(this);
+ }
+
+ /**
+ * Shift this chat window to the right on screen.
+ */
+ shiftRight() {
+ this.manager.shiftRight(this);
+ }
+
+ unfold() {
+ if (this.thread) {
+ this.thread.update({ pendingFoldState: 'open' });
+ } else {
+ this.update({ _isFolded: false });
+ }
+ }
+
+ //----------------------------------------------------------------------
+ // Private
+ //----------------------------------------------------------------------
+
+ /**
+ * @private
+ * @returns {boolean}
+ */
+ _computeHasShiftLeft() {
+ if (!this.manager) {
+ return false;
+ }
+ const allVisible = this.manager.allOrderedVisible;
+ const index = allVisible.findIndex(visible => visible === this);
+ if (index === -1) {
+ return false;
+ }
+ return index < allVisible.length - 1;
+ }
+
+ /**
+ * @private
+ * @returns {boolean}
+ */
+ _computeHasShiftRight() {
+ if (!this.manager) {
+ return false;
+ }
+ const index = this.manager.allOrderedVisible.findIndex(visible => visible === this);
+ if (index === -1) {
+ return false;
+ }
+ return index > 0;
+ }
+
+ /**
+ * @private
+ * @returns {boolean}
+ */
+ _computeIsFolded() {
+ const thread = this.thread;
+ if (thread) {
+ return thread.foldState === 'folded';
+ }
+ return this._isFolded;
+ }
+
+ /**
+ * @private
+ * @returns {string}
+ */
+ _computeName() {
+ if (this.thread) {
+ return this.thread.displayName;
+ }
+ return this.env._t("New message");
+ }
+
+ /**
+ * @private
+ * @returns {integer}
+ */
+ _computeVisibleOffset() {
+ if (!this.manager) {
+ return 0;
+ }
+ const visible = this.manager.visual.visible;
+ const index = visible.findIndex(visible => visible.chatWindowLocalId === this.localId);
+ if (index === -1) {
+ return 0;
+ }
+ return visible[index].offset;
+ }
+
+ /**
+ * Cycles to the next possible visible and unfolded chat window starting
+ * from the `currentChatWindow`, following the natural order based on the
+ * current text direction, and with the possibility to `reverse` based on
+ * the given parameter.
+ *
+ * @private
+ * @param {Object} [param0={}]
+ * @param {boolean} [param0.reverse=false]
+ * @returns {mail.chat_window|undefined}
+ */
+ _getNextVisibleUnfoldedChatWindow({ reverse = false } = {}) {
+ const orderedVisible = this.manager.allOrderedVisible;
+ /**
+ * Return index of next visible chat window of a given visible chat
+ * window index. The direction of "next" chat window depends on
+ * `reverse` option.
+ *
+ * @param {integer} index
+ * @returns {integer}
+ */
+ const _getNextIndex = index => {
+ const directionOffset = reverse ? -1 : 1;
+ let nextIndex = index + directionOffset;
+ if (nextIndex > orderedVisible.length - 1) {
+ nextIndex = 0;
+ }
+ if (nextIndex < 0) {
+ nextIndex = orderedVisible.length - 1;
+ }
+ return nextIndex;
+ };
+
+ const currentIndex = orderedVisible.findIndex(visible => visible === this);
+ let nextIndex = _getNextIndex(currentIndex);
+ let nextToFocus = orderedVisible[nextIndex];
+ while (nextToFocus.isFolded) {
+ nextIndex = _getNextIndex(nextIndex);
+ nextToFocus = orderedVisible[nextIndex];
+ }
+ return nextToFocus;
+ }
+
+ /**
+ * @override
+ */
+ _updateAfter(previous) {
+ // thread
+ if (previous.thread && this.thread !== previous.thread) {
+ this.update({ threadInitialScrollTop: undefined });
+ }
+ }
+
+ /**
+ * @override
+ */
+ _updateBefore() {
+ return {
+ thread: this.thread,
+ };
+ }
+
+ }
+
+ ChatWindow.fields = {
+ /**
+ * Determine whether the chat window is folded or not, when not
+ * linked to a thread.
+ * Note: this value only make sense for chat window not linked
+ * to a thread. State of chat window of a thread is entirely
+ * based on thread.foldState. @see isFolded .
+ */
+ _isFolded: attr({
+ default: false,
+ }),
+ hasShiftLeft: attr({
+ compute: '_computeHasShiftLeft',
+ dependencies: ['managerAllOrderedVisible'],
+ default: false,
+ }),
+ hasShiftRight: attr({
+ compute: '_computeHasShiftRight',
+ dependencies: ['managerAllOrderedVisible'],
+ default: false,
+ }),
+ /**
+ * Determine whether the chat window should be programmatically
+ * focused by observed component of chat window. Those components
+ * are responsible to unmark this record afterwards, otherwise
+ * any re-render will programmatically set focus again!
+ */
+ isDoFocus: attr({
+ default: false,
+ }),
+ /**
+ * Determine whether the chat window is focused or not. Useful for
+ * visual clue.
+ */
+ isFocused: attr({
+ default: false,
+ }),
+ isFolded: attr({
+ compute: '_computeIsFolded',
+ dependencies: [
+ 'thread',
+ 'threadFoldState',
+ '_isFolded',
+ ],
+ default: false,
+ }),
+ manager: many2one('mail.chat_window_manager', {
+ inverse: 'chatWindows',
+ }),
+ managerAllOrderedVisible: one2many('mail.chat_window', {
+ related: 'manager.allOrderedVisible',
+ }),
+ managerVisual: attr({
+ related: 'manager.visual',
+ }),
+ name: attr({
+ compute: '_computeName',
+ dependencies: [
+ 'thread',
+ 'threadDisplayName',
+ ],
+ }),
+ /**
+ * If set, this is the scroll top position of the thread of this
+ * chat window to put initially on mount.
+ */
+ threadInitialScrollTop: attr(),
+ thread: one2one('mail.thread', {
+ related: 'threadViewer.thread',
+ }),
+ threadDisplayName: attr({
+ related: 'thread.displayName',
+ }),
+ threadFoldState: attr({
+ related: 'thread.foldState',
+ }),
+ threadViewer: one2one('mail.thread_viewer', {
+ inverse: 'chatWindow',
+ }),
+ visibleOffset: attr({
+ compute: '_computeVisibleOffset',
+ dependencies: ['managerVisual'],
+ }),
+ };
+
+ ChatWindow.modelName = 'mail.chat_window';
+
+ return ChatWindow;
+}
+
+registerNewModel('mail.chat_window', factory);
+
+});
diff --git a/addons/mail/static/src/models/chat_window_manager/chat_window_manager.js b/addons/mail/static/src/models/chat_window_manager/chat_window_manager.js
new file mode 100644
index 00000000000..3b054f83a30
--- /dev/null
+++ b/addons/mail/static/src/models/chat_window_manager/chat_window_manager.js
@@ -0,0 +1,461 @@
+odoo.define('mail/static/src/models/chat_window_manager/chat_window_manager.js', function (require) {
+'use strict';
+
+const { registerNewModel } = require('mail/static/src/model/model_core.js');
+const { attr, many2one, one2many, one2one } = require('mail/static/src/model/model_field.js');
+
+function factory(dependencies) {
+
+ const BASE_VISUAL = {
+ /**
+ * Amount of visible slots available for chat windows.
+ */
+ availableVisibleSlots: 0,
+ /**
+ * Data related to the hidden menu.
+ */
+ hidden: {
+ /**
+ * List of hidden docked chat windows. Useful to compute counter.
+ * Chat windows are ordered by their `chatWindows` order.
+ */
+ chatWindowLocalIds: [],
+ /**
+ * Whether hidden menu is visible or not
+ */
+ isVisible: false,
+ /**
+ * Offset of hidden menu starting point from the starting point
+ * of chat window manager. Makes only sense if it is visible.
+ */
+ offset: 0,
+ },
+ /**
+ * Data related to visible chat windows. Index determine order of
+ * docked chat windows.
+ *
+ * Value:
+ *
+ * {
+ * chatWindowLocalId,
+ * offset,
+ * }
+ *
+ * Offset is offset of starting point of docked chat window from
+ * starting point of dock chat window manager. Docked chat windows
+ * are ordered by their `chatWindows` order
+ */
+ visible: [],
+ };
+
+
+ class ChatWindowManager extends dependencies['mail.model'] {
+
+ //----------------------------------------------------------------------
+ // Public
+ //----------------------------------------------------------------------
+
+ /**
+ * Close all chat windows.
+ *
+ */
+ closeAll() {
+ const chatWindows = this.allOrdered;
+ for (const chatWindow of chatWindows) {
+ chatWindow.close();
+ }
+ }
+
+ closeHiddenMenu() {
+ this.update({ isHiddenMenuOpen: false });
+ }
+
+ openHiddenMenu() {
+ this.update({ isHiddenMenuOpen: true });
+ }
+
+ openNewMessage() {
+ let newMessageChatWindow = this.newMessageChatWindow;
+ if (!newMessageChatWindow) {
+ newMessageChatWindow = this.env.models['mail.chat_window'].create({
+ manager: [['link', this]],
+ });
+ }
+ newMessageChatWindow.makeVisible();
+ newMessageChatWindow.unfold();
+ newMessageChatWindow.focus();
+ }
+
+ /**
+ * @param {mail.thread} thread
+ * @param {Object} [param1={}]
+ * @param {string} [param1.mode]
+ */
+ openThread(thread, { mode } = {}) {
+ let chatWindow = this.env.models['mail.chat_window'].find(chatWindow =>
+ chatWindow.thread === thread
+ );
+ if (!chatWindow) {
+ chatWindow = this.env.models['mail.chat_window'].create({
+ manager: [['link', this]],
+ threadViewer: [['create', {
+ thread: [['link', thread]],
+ }]],
+ });
+ }
+ if (mode === 'last_visible' && !chatWindow.isVisible) {
+ chatWindow.makeVisible();
+ chatWindow.unfold();
+ chatWindow.focus();
+ }
+ if (mode === 'from_new_message') {
+ if (!this.newMessageChatWindow) {
+ throw new Error('Cannot open thread in chat window in mode "from_new_message" without any new message chat window');
+ }
+ this.swap(chatWindow, this.newMessageChatWindow);
+ this.newMessageChatWindow.close();
+ chatWindow.makeVisible();
+ chatWindow.unfold();
+ chatWindow.focus();
+ }
+ }
+
+ /**
+ * Shift provided chat window to the left on screen.
+ *
+ * @param {mail.chat_window} chatWindow
+ */
+ shiftLeft(chatWindow) {
+ const chatWindows = this.allOrdered;
+ const index = chatWindows.findIndex(cw => cw === chatWindow);
+ if (index === chatWindows.length - 1) {
+ // already left-most
+ return;
+ }
+ const otherChatWindow = chatWindows[index + 1];
+ const _newOrdered = [...this._ordered];
+ _newOrdered[index] = otherChatWindow.localId;
+ _newOrdered[index + 1] = chatWindow.localId;
+ this.update({ _ordered: _newOrdered });
+ chatWindow.focus();
+ }
+
+ /**
+ * Shift provided chat window to the right on screen.
+ *
+ * @param {mail.chat_window} chatWindow
+ */
+ shiftRight(chatWindow) {
+ const chatWindows = this.allOrdered;
+ const index = chatWindows.findIndex(cw => cw === chatWindow);
+ if (index === 0) {
+ // already right-most
+ return;
+ }
+ const otherChatWindow = chatWindows[index - 1];
+ const _newOrdered = [...this._ordered];
+ _newOrdered[index] = otherChatWindow.localId;
+ _newOrdered[index - 1] = chatWindow.localId;
+ this.update({ _ordered: _newOrdered });
+ chatWindow.focus();
+ }
+
+ /**
+ * @param {mail.chat_window} chatWindow1
+ * @param {mail.chat_window} chatWindow2
+ */
+ swap(chatWindow1, chatWindow2) {
+ const ordered = this.allOrdered;
+ const index1 = ordered.findIndex(chatWindow => chatWindow === chatWindow1);
+ const index2 = ordered.findIndex(chatWindow => chatWindow === chatWindow2);
+ if (index1 === -1 || index2 === -1) {
+ return;
+ }
+ const _newOrdered = [...this._ordered];
+ _newOrdered[index1] = chatWindow2.localId;
+ _newOrdered[index2] = chatWindow1.localId;
+ this.update({ _ordered: _newOrdered });
+ }
+
+ //----------------------------------------------------------------------
+ // Private
+ //----------------------------------------------------------------------
+
+ /**
+ * @private
+ * @returns {string[]}
+ */
+ _compute_ordered() {
+ // remove unlinked chatWindows
+ const _ordered = this._ordered.filter(chatWindowLocalId =>
+ this.chatWindows.includes(this.env.models['mail.chat_window'].get(chatWindowLocalId))
+ );
+ // add linked chatWindows
+ for (const chatWindow of this.chatWindows) {
+ if (!_ordered.includes(chatWindow.localId)) {
+ _ordered.push(chatWindow.localId);
+ }
+ }
+ return _ordered;
+ }
+
+ /**
+ * // FIXME: dependent on implementation that uses arbitrary order in relations!!
+ *
+ * @private
+ * @returns {mail.chat_window}
+ */
+ _computeAllOrdered() {
+ return [['replace', this._ordered.map(chatWindowLocalId =>
+ this.env.models['mail.chat_window'].get(chatWindowLocalId)
+ )]];
+ }
+
+ /**
+ * @private
+ * @returns {mail.chat_window[]}
+ */
+ _computeAllOrderedHidden() {
+ return [['replace', this.visual.hidden.chatWindowLocalIds.map(chatWindowLocalId =>
+ this.env.models['mail.chat_window'].get(chatWindowLocalId)
+ )]];
+ }
+
+ /**
+ * @private
+ * @returns {mail.chat_window[]}
+ */
+ _computeAllOrderedVisible() {
+ return [['replace', this.visual.visible.map(({ chatWindowLocalId }) =>
+ this.env.models['mail.chat_window'].get(chatWindowLocalId)
+ )]];
+ }
+
+ /**
+ * @private
+ * @returns {boolean}
+ */
+ _computeHasHiddenChatWindows() {
+ return this.allOrderedHidden.length > 0;
+ }
+
+ /**
+ * @private
+ * @returns {boolean}
+ */
+ _computeHasVisibleChatWindows() {
+ return this.allOrderedVisible.length > 0;
+ }
+
+ /**
+ * @private
+ * @returns {mail.chat_window|undefined}
+ */
+ _computeLastVisible() {
+ const { length: l, [l - 1]: lastVisible } = this.allOrderedVisible;
+ if (!lastVisible) {
+ return [['unlink-all']];
+ }
+ return [['replace', lastVisible]];
+ }
+
+ /**
+ * @private
+ * @returns {mail.chat_window|undefined}
+ */
+ _computeNewMessageChatWindow() {
+ const chatWindow = this.allOrdered.find(chatWindow => !chatWindow.thread);
+ if (!chatWindow) {
+ return [['unlink']];
+ }
+ return [['replace', chatWindow]];
+ }
+
+ /**
+ * @private
+ * @returns {integer}
+ */
+ _computeUnreadHiddenConversationAmount() {
+ const allHiddenWithThread = this.allOrderedHidden.filter(
+ chatWindow => chatWindow.thread
+ );
+ let amount = 0;
+ for (const chatWindow of allHiddenWithThread) {
+ if (chatWindow.thread.message_unread_counter > 0) {
+ amount++;
+ }
+ }
+ return amount;
+ }
+
+ /**
+ * @private
+ * @returns {Object}
+ */
+ _computeVisual() {
+ let visual = JSON.parse(JSON.stringify(BASE_VISUAL));
+ if (!this.env.messaging) {
+ return visual;
+ }
+ const device = this.env.messaging.device;
+ const discuss = this.env.messaging.discuss;
+ const BETWEEN_GAP_WIDTH = 5;
+ const CHAT_WINDOW_WIDTH = 325;
+ const END_GAP_WIDTH = device.isMobile ? 0 : 10;
+ const GLOBAL_WINDOW_WIDTH = device.globalWindowInnerWidth;
+ const HIDDEN_MENU_WIDTH = 200; // max width, including width of dropup list items
+ const START_GAP_WIDTH = device.isMobile ? 0 : 10;
+ const chatWindows = this.allOrdered;
+ if (!device.isMobile && discuss.isOpen) {
+ return visual;
+ }
+ if (!chatWindows.length) {
+ return visual;
+ }
+ const relativeGlobalWindowWidth = GLOBAL_WINDOW_WIDTH - START_GAP_WIDTH - END_GAP_WIDTH;
+ let maxAmountWithoutHidden = Math.floor(
+ relativeGlobalWindowWidth / (CHAT_WINDOW_WIDTH + BETWEEN_GAP_WIDTH));
+ let maxAmountWithHidden = Math.floor(
+ (relativeGlobalWindowWidth - HIDDEN_MENU_WIDTH - BETWEEN_GAP_WIDTH) /
+ (CHAT_WINDOW_WIDTH + BETWEEN_GAP_WIDTH));
+ if (device.isMobile) {
+ maxAmountWithoutHidden = 1;
+ maxAmountWithHidden = 1;
+ }
+ if (chatWindows.length <= maxAmountWithoutHidden) {
+ // all visible
+ for (let i = 0; i < chatWindows.length; i++) {
+ const chatWindowLocalId = chatWindows[i].localId;
+ const offset = START_GAP_WIDTH + i * (CHAT_WINDOW_WIDTH + BETWEEN_GAP_WIDTH);
+ visual.visible.push({ chatWindowLocalId, offset });
+ }
+ visual.availableVisibleSlots = maxAmountWithoutHidden;
+ } else if (maxAmountWithHidden > 0) {
+ // some visible, some hidden
+ for (let i = 0; i < maxAmountWithHidden; i++) {
+ const chatWindowLocalId = chatWindows[i].localId;
+ const offset = START_GAP_WIDTH + i * (CHAT_WINDOW_WIDTH + BETWEEN_GAP_WIDTH);
+ visual.visible.push({ chatWindowLocalId, offset });
+ }
+ if (chatWindows.length > maxAmountWithHidden) {
+ visual.hidden.isVisible = !device.isMobile;
+ visual.hidden.offset = visual.visible[maxAmountWithHidden - 1].offset
+ + CHAT_WINDOW_WIDTH + BETWEEN_GAP_WIDTH;
+ }
+ for (let j = maxAmountWithHidden; j < chatWindows.length; j++) {
+ visual.hidden.chatWindowLocalIds.push(chatWindows[j].localId);
+ }
+ visual.availableVisibleSlots = maxAmountWithHidden;
+ } else {
+ // all hidden
+ visual.hidden.isVisible = !device.isMobile;
+ visual.hidden.offset = START_GAP_WIDTH;
+ visual.hidden.chatWindowLocalIds.concat(chatWindows.map(chatWindow => chatWindow.localId));
+ console.warn('cannot display any visible chat windows (screen is too small)');
+ visual.availableVisibleSlots = 0;
+ }
+ return visual;
+ }
+
+ }
+
+ ChatWindowManager.fields = {
+ /**
+ * List of ordered chat windows (list of local ids)
+ */
+ _ordered: attr({
+ compute: '_compute_ordered',
+ default: [],
+ dependencies: [
+ 'chatWindows',
+ ],
+ }),
+ // FIXME: dependent on implementation that uses arbitrary order in relations!!
+ allOrdered: one2many('mail.chat_window', {
+ compute: '_computeAllOrdered',
+ dependencies: [
+ '_ordered',
+ ],
+ }),
+ allOrderedThread: one2many('mail.thread', {
+ related: 'allOrdered.thread',
+ }),
+ allOrderedHidden: one2many('mail.chat_window', {
+ compute: '_computeAllOrderedHidden',
+ dependencies: ['visual'],
+ }),
+ allOrderedHiddenThread: one2many('mail.thread', {
+ related: 'allOrderedHidden.thread',
+ }),
+ allOrderedHiddenThreadMessageUnreadCounter: attr({
+ related: 'allOrderedHiddenThread.message_unread_counter',
+ }),
+ allOrderedVisible: one2many('mail.chat_window', {
+ compute: '_computeAllOrderedVisible',
+ dependencies: ['visual'],
+ }),
+ chatWindows: one2many('mail.chat_window', {
+ inverse: 'manager',
+ isCausal: true,
+ }),
+ device: one2one('mail.device', {
+ related: 'messaging.device',
+ }),
+ deviceGlobalWindowInnerWidth: attr({
+ related: 'device.globalWindowInnerWidth',
+ }),
+ deviceIsMobile: attr({
+ related: 'device.isMobile',
+ }),
+ discuss: one2one('mail.discuss', {
+ related: 'messaging.discuss',
+ }),
+ discussIsOpen: attr({
+ related: 'discuss.isOpen',
+ }),
+ hasHiddenChatWindows: attr({
+ compute: '_computeHasHiddenChatWindows',
+ dependencies: ['allOrderedHidden'],
+ }),
+ hasVisibleChatWindows: attr({
+ compute: '_computeHasVisibleChatWindows',
+ dependencies: ['allOrderedVisible'],
+ }),
+ isHiddenMenuOpen: attr({
+ default: false,
+ }),
+ lastVisible: many2one('mail.chat_window', {
+ compute: '_computeLastVisible',
+ dependencies: ['allOrderedVisible'],
+ }),
+ messaging: one2one('mail.messaging', {
+ inverse: 'chatWindowManager',
+ }),
+ newMessageChatWindow: one2one('mail.chat_window', {
+ compute: '_computeNewMessageChatWindow',
+ dependencies: ['allOrderedThread'],
+ }),
+ unreadHiddenConversationAmount: attr({
+ compute: '_computeUnreadHiddenConversationAmount',
+ dependencies: ['allOrderedHiddenThreadMessageUnreadCounter'],
+ }),
+ visual: attr({
+ compute: '_computeVisual',
+ default: BASE_VISUAL,
+ dependencies: [
+ 'allOrdered',
+ 'deviceGlobalWindowInnerWidth',
+ 'deviceIsMobile',
+ 'discussIsOpen',
+ ],
+ }),
+ };
+
+ ChatWindowManager.modelName = 'mail.chat_window_manager';
+
+ return ChatWindowManager;
+}
+
+registerNewModel('mail.chat_window_manager', factory);
+
+});
diff --git a/addons/mail/static/src/models/chatter/chatter.js b/addons/mail/static/src/models/chatter/chatter.js
new file mode 100644
index 00000000000..fc50dc4d3ef
--- /dev/null
+++ b/addons/mail/static/src/models/chatter/chatter.js
@@ -0,0 +1,326 @@
+odoo.define('mail/static/src/models/chatter/chatter.js', function (require) {
+'use strict';
+
+const { registerNewModel } = require('mail/static/src/model/model_core.js');
+const { attr, one2many, one2one } = require('mail/static/src/model/model_field.js');
+
+function factory(dependencies) {
+
+ const getThreadNextTemporaryId = (function () {
+ let tmpId = 0;
+ return () => {
+ tmpId -= 1;
+ return tmpId;
+ };
+ })();
+
+ const getMessageNextTemporaryId = (function () {
+ let tmpId = 0;
+ return () => {
+ tmpId -= 1;
+ return tmpId;
+ };
+ })();
+
+ class Chatter extends dependencies['mail.model'] {
+
+ /**
+ * @override
+ */
+ delete() {
+ this._stopAttachmentsLoading();
+ super.delete();
+ }
+
+ //----------------------------------------------------------------------
+ // Public
+ //----------------------------------------------------------------------
+
+ async refresh() {
+ const thread = this.thread;
+ if (!thread || thread.isTemporary) {
+ return;
+ }
+ thread.loadNewMessages();
+ if (!this._isPreparingAttachmentsLoading && !this.isShowingAttachmentsLoading) {
+ this._prepareAttachmentsLoading();
+ }
+ await thread.fetchAttachments();
+ this._stopAttachmentsLoading();
+ }
+
+ async refreshActivities() {
+ // A bit "extreme", may be improved
+ const [{ activity_ids: newActivityIds }] = await this.async(() => this.env.services.rpc({
+ model: this.thread.model,
+ method: 'read',
+ args: [this.thread.id, ['activity_ids']]
+ }));
+ const activitiesData = await this.async(() => this.env.services.rpc({
+ model: 'mail.activity',
+ method: 'activity_format',
+ args: [newActivityIds]
+ }));
+ const activities = [];
+ for (const activityData of activitiesData) {
+ const activity = this.env.models['mail.activity'].insert(
+ this.env.models['mail.activity'].convertData(activityData)
+ );
+ activities.push(activity);
+ }
+ this.update({ activities: [['replace', activities]] });
+ }
+
+ showLogNote() {
+ this.update({
+ isComposerLog: true,
+ isComposerVisible: true,
+ });
+ }
+
+ showSendMessage() {
+ this.update({
+ isComposerLog: false,
+ isComposerVisible: true,
+ });
+ }
+
+ toggleActivityBoxVisibility() {
+ this.update({ isActivityBoxVisible: !this.isActivityBoxVisible });
+ }
+
+ //----------------------------------------------------------------------
+ // Private
+ //----------------------------------------------------------------------
+
+ /**
+ * @private
+ * @returns {mail.activity[]}
+ */
+ _computeFutureActivities() {
+ return [['replace', this.activities.filter(activity => activity.state === 'planned')]];
+ }
+
+ /**
+ * @private
+ * @returns {boolean}
+ */
+ _computeIsDisabled() {
+ return !this.threadId;
+ }
+
+ /**
+ * @private
+ * @returns {mail.activity[]}
+ */
+ _computeOverdueActivities() {
+ return [['replace', this.activities.filter(activity => activity.state === 'overdue')]];
+ }
+
+ /**
+ * @private
+ * @returns {mail.activity[]}
+ */
+ _computeTodayActivities() {
+ return [['replace', this.activities.filter(activity => activity.state === 'today')]];
+ }
+
+ /**
+ * @private
+ */
+ _prepareAttachmentsLoading() {
+ this._isPreparingAttachmentsLoading = true;
+ this._attachmentsLoaderTimeout = this.env.browser.setTimeout(() => {
+ this.update({isShowingAttachmentsLoading: true});
+ this._isPreparingAttachmentsLoading = false;
+ }, this.env.loadingBaseDelayDuration);
+ }
+
+ /**
+ * @private
+ */
+ _stopAttachmentsLoading() {
+ this.env.browser.clearTimeout(this._attachmentsLoaderTimeout);
+ this._attachmentsLoaderTimeout = null;
+ this.update({isShowingAttachmentsLoading: false});
+ this._isPreparingAttachmentsLoading = false;
+ }
+
+ /**
+ * @override
+ */
+ _updateAfter(previous) {
+ // thread
+ if (
+ this.threadModel !== previous.threadModel ||
+ this.threadId !== previous.threadId
+ ) {
+ // change of thread
+ this._updateRelationThread();
+ if (previous.thread && previous.thread.isTemporary) {
+ // AKU FIXME: make dedicated models for "temporary" threads,
+ // so that it auto-handles causality of messages for deletion
+ // automatically
+ const oldMainThreadCache = previous.thread.mainCache;
+ const message = oldMainThreadCache.messages[0];
+ message.delete();
+ previous.thread.delete();
+ }
+ }
+
+ if (previous.activityIds.join(',') !== this.activityIds.join(',')) {
+ this.refreshActivities();
+ }
+ if (
+ previous.followerIds.join(',') !== this.followerIds.join(',') &&
+ !this.thread.isTemporary
+ ) {
+ this.thread.refreshFollowers();
+ }
+ if (
+ previous.thread !== this.thread ||
+ (this.thread && this.messageIds.join(',') !== previous.messageIds.join(','))
+ ) {
+ this.refresh();
+ }
+ }
+
+ /**
+ * @override
+ */
+ _updateBefore() {
+ return {
+ activityIds: this.activityIds,
+ followerIds: this.followerIds,
+ messageIds: this.messageIds,
+ threadModel: this.threadModel,
+ threadId: this.threadId,
+ thread: this.thread,
+ };
+ }
+
+ /**
+ * @private
+ */
+ _updateRelationThread() {
+ if (!this.threadId) {
+ if (this.thread && this.thread.isTemporary) {
+ return;
+ }
+ const nextId = getThreadNextTemporaryId();
+ const thread = this.env.models['mail.thread'].create({
+ areAttachmentsLoaded: true,
+ id: nextId,
+ isTemporary: true,
+ model: this.threadModel,
+ });
+ const currentPartner = this.env.messaging.currentPartner;
+ const message = this.env.models['mail.message'].create({
+ author: [['link', currentPartner]],
+ body: this.env._t("Creating a new record..."),
+ id: getMessageNextTemporaryId(),
+ isTemporary: true,
+ });
+ this.threadViewer.update({ thread: [['link', thread]] });
+ for (const cache of thread.caches) {
+ cache.update({ messages: [['link', message]] });
+ }
+ } else {
+ // thread id and model
+ const thread = this.env.models['mail.thread'].insert({
+ id: this.threadId,
+ model: this.threadModel,
+ });
+ this.threadViewer.update({ thread: [['link', thread]] });
+ }
+ }
+
+ }
+
+ Chatter.fields = {
+ activities: one2many('mail.activity', {
+ inverse: 'chatter',
+ }),
+ activityIds: attr({
+ default: [],
+ }),
+ activitiesState: attr({
+ related: 'activities.state',
+ }),
+ context: attr({
+ default: {},
+ }),
+ followerIds: attr({
+ default: [],
+ }),
+ futureActivities: one2many('mail.activity', {
+ compute: '_computeFutureActivities',
+ dependencies: ['activitiesState'],
+ }),
+ hasActivities: attr({
+ default: true,
+ }),
+ hasExternalBorder: attr({
+ default: true,
+ }),
+ hasFollowers: attr({
+ default: true,
+ }),
+ hasThread: attr({
+ default: true,
+ }),
+ hasTopbarCloseButton: attr({
+ default: false,
+ }),
+ isActivityBoxVisible: attr({
+ default: true,
+ }),
+ isAttachmentBoxVisible: attr({
+ default: false,
+ }),
+ isComposerLog: attr({
+ default: true,
+ }),
+ isComposerVisible: attr({
+ default: false,
+ }),
+ isDisabled: attr({
+ compute: '_computeIsDisabled',
+ default: false,
+ dependencies: ['threadId'],
+ }),
+ isShowingAttachmentsLoading: attr({
+ default: false,
+ }),
+ messageIds: attr({
+ default: [],
+ }),
+ overdueActivities: one2many('mail.activity', {
+ compute: '_computeOverdueActivities',
+ dependencies: ['activitiesState'],
+ }),
+ thread: one2one('mail.thread', {
+ related: 'threadViewer.thread',
+ }),
+ threadAttachmentCount: attr({
+ default: 0,
+ }),
+ threadId: attr(),
+ threadModel: attr(),
+ threadViewer: one2one('mail.thread_viewer', {
+ autocreate: true,
+ }),
+ todayActivities: one2many('mail.activity', {
+ compute: '_computeTodayActivities',
+ dependencies: ['activitiesState'],
+ }),
+ };
+
+ Chatter.modelName = 'mail.chatter';
+
+ return Chatter;
+}
+
+registerNewModel('mail.chatter', factory);
+
+});
diff --git a/addons/mail/static/src/models/composer/composer.js b/addons/mail/static/src/models/composer/composer.js
new file mode 100644
index 00000000000..343e0d372c0
--- /dev/null
+++ b/addons/mail/static/src/models/composer/composer.js
@@ -0,0 +1,593 @@
+odoo.define('mail/static/src/models/composer/composer.js', function (require) {
+'use strict';
+
+const emojis = require('mail.emojis');
+const { registerNewModel } = require('mail/static/src/model/model_core.js');
+const { attr, many2many, many2one, one2one } = require('mail/static/src/model/model_field.js');
+
+const {
+ addLink,
+ escapeAndCompactTextContent,
+ parseAndTransform,
+} = require('mail.utils');
+
+function factory(dependencies) {
+
+ class Composer extends dependencies['mail.model'] {
+
+ //----------------------------------------------------------------------
+ // Public
+ //----------------------------------------------------------------------
+
+ closeMentionSuggestions() {
+ this.update({
+ activeSuggestedPartner: [['unlink']],
+ extraSuggestedPartners: [['unlink-all']],
+ mainSuggestedPartners: [['unlink-all']],
+ });
+ }
+
+ discard() {
+ const discuss = this.env.messaging.discuss;
+ const thread = this.thread;
+ if (
+ !discuss.isOpen ||
+ discuss.thread !== thread ||
+ !discuss.isReplyingToMessage
+ ) {
+ return;
+ }
+ discuss.clearReplyingToMessage();
+ }
+
+ focus() {
+ this.update({ isDoFocus: true });
+ }
+
+ /**
+ * Inserts text content in text input based on selection.
+ *
+ * @param {string} content
+ */
+ insertIntoTextInput(content) {
+ const partA = this.textInputContent.slice(0, this.textInputCursorStart);
+ const partB = this.textInputContent.slice(
+ this.textInputCursorEnd,
+ this.textInputContent.length
+ );
+ this.update({
+ textInputContent: partA + content + partB,
+ textInputCursorStart: this.textInputCursorStart + content.length,
+ textInputCursorEnd: this.textInputCursorStart + content.length,
+ });
+ }
+
+ /**
+ * @param {mail.partner} partner
+ */
+ insertMentionedPartner(partner) {
+ const cursorPosition = this.textInputCursorStart;
+ const textLeft = this.textInputContent.substring(
+ 0,
+ this.textInputContent.substring(0, cursorPosition).lastIndexOf('@') + 1
+ );
+ const textRight = this.textInputContent.substring(
+ cursorPosition,
+ this.textInputContent.length
+ );
+ const partnerName = partner.name.replace(/ /g, '\u00a0');
+ this.update({
+ mentionedPartners: [['link', partner]],
+ textInputContent: textLeft + partnerName + ' ' + textRight,
+ textInputCursorEnd: textLeft.length + partnerName.length + 1,
+ textInputCursorStart: textLeft.length + partnerName.length + 1,
+ });
+ }
+
+ /**
+ * Post a message in provided composer's thread with given data.
+ *
+ * @param {Object} data
+ * @param {string[]} data.attachments
+ * @param {*[]} data.canned_response_ids
+ * @param {integer[]} data.channel_ids
+ * @param {boolean} [data.isLog=false]
+ * @param {string} data.subject
+ * @param {integer} [data.subtype_id]
+ * @param {string} [data.subtype_xmlid='mail.mt_comment']
+ * @param {Object} [options]
+ * @param {integer} options.res_id
+ * @param {string} options.res_model
+ */
+ async postMessage(data, options) {
+ const thread = this.thread;
+ this.thread.unregisterCurrentPartnerIsTyping({ immediateNotify: true });
+ if (thread.model === 'mail.box') {
+ const { res_id, res_model } = options;
+ const otherThread = this.env.models['mail.thread'].find(thread =>
+ thread.id === res_id &&
+ thread.model === res_model
+ );
+ return otherThread.composer.postMessage(Object.assign({}, data));
+ }
+ const {
+ canned_response_ids,
+ channel_ids = [],
+ context,
+ isLog = false,
+ subject,
+ subtype_id,
+ // subtype_xmlid='mail.mt_comment',
+ } = data;
+ const escapedAndCompactContent = escapeAndCompactTextContent(this.textInputContent);
+ let body = escapedAndCompactContent.replace(/ /g, ' ').trim();
+ // This message will be received from the mail composer as html content
+ // subtype but the urls will not be linkified. If the mail composer
+ // takes the responsibility to linkify the urls we end up with double
+ // linkification a bit everywhere. Ideally we want to keep the content
+ // as text internally and only make html enrichment at display time but
+ // the current design makes this quite hard to do.
+ body = this._generateMentionsLinks(body);
+ body = parseAndTransform(body, addLink);
+ body = this._generateEmojisOnHtml(body);
+ let postData = {
+ attachment_ids: this.attachments.map(attachment => attachment.id),
+ body,
+ partner_ids: this.mentionedPartners.map(partner => partner.id),
+ message_type: 'comment',
+ };
+ let messageId;
+ if (thread.model === 'mail.channel') {
+ const command = this._getCommandFromText(body);
+ Object.assign(postData, {
+ command,
+ subtype_xmlid: 'mail.mt_comment'
+ });
+ messageId = await this.async(() => this.env.services.rpc({
+ model: 'mail.channel',
+ method: command ? 'execute_command' : 'message_post',
+ args: [thread.id],
+ kwargs: postData
+ }));
+ } else {
+ Object.assign(postData, {
+ channel_ids: channel_ids.map(channelId => [4, channelId, false]),
+ canned_response_ids
+ });
+ if (subject) {
+ postData.subject = subject;
+ }
+ Object.assign(postData, {
+ context,
+ subtype_id,
+ subtype_xmlid: isLog ? 'mail.mt_note' : 'mail.mt_comment',
+ });
+ messageId = await this.async(() => this.env.services.rpc({
+ model: thread.model,
+ method: 'message_post',
+ args: [thread.id],
+ kwargs: postData
+ }));
+ const [messageData] = await this.async(() => this.env.services.rpc({
+ model: 'mail.message',
+ method: 'message_format',
+ args: [[messageId]]
+ }));
+ this.env.models['mail.message'].insert(Object.assign(
+ {},
+ this.env.models['mail.message'].convertData(messageData),
+ {
+ originThread: [['insert', {
+ id: thread.id,
+ model: thread.model,
+ }]],
+ })
+ );
+ thread.loadNewMessages();
+ }
+ for (const threadViewer of this.thread.viewers) {
+ threadViewer.addComponentHint('current-partner-just-posted-message', messageId);
+ }
+ this._reset();
+ }
+
+ /**
+ * Called when current partner is inserting some input in composer.
+ * Useful to notify current partner is currently typing something in the
+ * composer of this thread to all other members.
+ */
+ handleCurrentPartnerIsTyping() {
+ if (!this.thread) {
+ return;
+ }
+ if (this.thread.typingMembers.includes(this.env.messaging.currentPartner)) {
+ this.thread.refreshCurrentPartnerIsTyping();
+ } else {
+ this.thread.registerCurrentPartnerIsTyping();
+ }
+ }
+
+ setFirstSuggestedPartnerActive() {
+ if (!this.allSuggestedPartners[0]) {
+ return;
+ }
+ this.update({
+ activeSuggestedPartner: [['link', this.allSuggestedPartners[0]]],
+ });
+ }
+
+ setLastSuggestedPartnerActive() {
+ if (this.allSuggestedPartners.length === 0) {
+ return;
+ }
+ this.update({
+ activeSuggestedPartner: [[
+ 'link',
+ this.allSuggestedPartners[this.allSuggestedPartners.length - 1]
+ ]],
+ });
+ }
+
+ setNextSuggestedPartnerActive() {
+ if (this.allSuggestedPartners.length === 0) {
+ return;
+ }
+ const activeSuggestedPartnerIndex = this.allSuggestedPartners.findIndex(
+ suggestedPartner => suggestedPartner === this.activeSuggestedPartner
+ );
+ if (activeSuggestedPartnerIndex !== this.allSuggestedPartners.length - 1) {
+ this.update({
+ activeSuggestedPartner: [[
+ 'link',
+ this.allSuggestedPartners[activeSuggestedPartnerIndex + 1]
+ ]],
+ });
+ } else {
+ this.update({
+ activeSuggestedPartner: [['link', this.allSuggestedPartners[0]]],
+ });
+ }
+ }
+
+ setPreviousSuggestedPartnerActive() {
+ if (this.allSuggestedPartners.length === 0) {
+ return;
+ }
+ const activeSuggestedPartnerIndex = this.allSuggestedPartners.findIndex(
+ suggestedPartner => suggestedPartner === this.activeSuggestedPartner
+ );
+ if (activeSuggestedPartnerIndex === -1) {
+ this.update({
+ activeSuggestedPartner: [['link', this.allSuggestedPartners[0]]]
+ });
+ } else if (activeSuggestedPartnerIndex !== 0) {
+ this.update({
+ activeSuggestedPartner: [[
+ 'link',
+ this.allSuggestedPartners[activeSuggestedPartnerIndex - 1]
+ ]],
+ });
+ } else {
+ this.update({
+ activeSuggestedPartner: [[
+ 'link',
+ this.allSuggestedPartners[this.allSuggestedPartners.length - 1]
+ ]],
+ });
+ }
+ }
+
+ //----------------------------------------------------------------------
+ // Private
+ //----------------------------------------------------------------------
+
+ /**
+ * @private
+ * @returns {mail.partner[]}
+ */
+ _computeAllSuggestedPartners() {
+ return [['replace', this.mainSuggestedPartners.concat(this.extraSuggestedPartners)]];
+ }
+
+ /**
+ * @private
+ * @returns {boolean}
+ */
+ _computeCanPostMessage() {
+ return this.textInputContent || this.attachments.length !== 0;
+ }
+
+ /**
+ * Ensure extraSuggestedPartners does not contain any partner already
+ * present in mainSuggestedPartners. This is necessary for the
+ * consistency of suggestion list because allSuggestedPartners is the
+ * union of both but without duplicates (because it is a relational
+ * field).
+ *
+ * @private
+ * @returns {mail.partner[]}
+ */
+ _computeExtraSuggestedPartners() {
+ return [['unlink', this.mainSuggestedPartners]];
+ }
+
+ /**
+ * @private
+ * @return {boolean}
+ */
+ _computeHasSuggestedPartners() {
+ return this.allSuggestedPartners.length > 0;
+ }
+
+ /**
+ * Detects if mentioned partners are still in the composer text input content
+ * and removes them if not.
+ *
+ * @private
+ * @returns {mail.partner[]}
+ */
+ _computeMentionedPartners() {
+ const inputMentions = this.textInputContent.match(
+ new RegExp("@[^ ]+(?= | |$)", 'g')
+ ) || [];
+ const unmentionedPartners = [];
+ for (const partner of this.mentionedPartners) {
+ let inputMention = inputMentions.find(item => {
+ return item === ("@" + partner.name).replace(/ /g, '\u00a0');
+ });
+ if (!inputMention) {
+ unmentionedPartners.push(partner);
+ }
+ }
+ return [['unlink', unmentionedPartners]];
+ }
+
+ /**
+ * Detects if mentions suggestions should be displayed when user is typing
+ * and searches partners based on user's research.
+ *
+ * @private
+ */
+ _detectDelimiter() {
+ const mentionKeyword = this._validateMentionKeyword('@', false);
+ if (mentionKeyword !== false) {
+ this._getSuggestedPartners(mentionKeyword);
+ } else {
+ this.closeMentionSuggestions();
+ }
+ }
+
+ /**
+ * @private
+ * @param {string} htmlString
+ * @returns {string}
+ */
+ _generateEmojisOnHtml(htmlString) {
+ for (const emoji of emojis) {
+ for (const source of emoji.sources) {
+ const escapedSource = String(source).replace(
+ /([.*+?=^!:${}()|[\]/\\])/g,
+ '\\$1');
+ const regexp = new RegExp(
+ '(\\s|^)(' + escapedSource + ')(?=\\s|$)',
+ 'g');
+ htmlString = htmlString.replace(regexp, '$1' + emoji.unicode);
+ }
+ }
+ return htmlString;
+ }
+
+ /**
+ *
+ * Generates the html link related to the mentioned partner
+ *
+ * @private
+ * @param {string} body
+ * @returns {string}
+ */
+ _generateMentionsLinks(body) {
+ if (this.mentionedPartners.length === 0) {
+ return body;
+ }
+ const inputMentions = body.match(new RegExp("@"+'[^ ]+(?= | |$)', 'g'));
+ const substrings = [];
+ let startIndex = 0;
+ for (const i in inputMentions) {
+ const match = inputMentions[i];
+ const matchName = owl.utils.escape(match.substring(1).replace(new RegExp('\u00a0', 'g'), ' '));
+ const endIndex = body.indexOf(match, startIndex) + match.length;
+ const partner = this.mentionedPartners.find(partner =>
+ partner.name === matchName
+ );
+ let mentionLink = "@" + matchName;
+ if (partner) {
+ const baseHREF = this.env.session.url('/web');
+ const href = `href='${baseHREF}#model=res.partner&id=${partner.id}'`;
+ const attClass = `class='o_mail_redirect'`;
+ const dataOeId = `data-oe-id='${partner.id}'`;
+ const dataOeModel = `data-oe-model='res.partner'`;
+ const target = `target='_blank'`;
+ mentionLink = `@${matchName}`;
+ }
+ substrings.push(body.substring(startIndex, body.indexOf(match, startIndex)));
+ substrings.push(mentionLink);
+ startIndex = endIndex;
+ }
+ substrings.push(body.substring(startIndex, body.length));
+ return substrings.join('');
+ }
+
+ /**
+ * @private
+ * @param {string} content html content
+ * @returns {string|undefined} command, if any in the content
+ */
+ _getCommandFromText(content) {
+ if (content.startsWith('/')) {
+ return content.substring(1).split(/\s/)[0];
+ }
+ return undefined;
+ }
+
+ /**
+ * @private
+ * @param {string} mentionKeyword
+ */
+ async _getSuggestedPartners(mentionKeyword) {
+ const mentions = await this.async(() => this.env.services.rpc(
+ {
+ model: 'res.partner',
+ method: 'get_mention_suggestions',
+ kwargs: {
+ limit: 8,
+ search: mentionKeyword,
+ },
+ },
+ { shadow: true }
+ ));
+
+ const mainSuggestedPartners = mentions[0];
+ const extraSuggestedPartners = mentions[1];
+ this.update({
+ extraSuggestedPartners: [[
+ 'insert-and-replace',
+ extraSuggestedPartners.map(data =>
+ this.env.models['mail.partner'].convertData(data)
+ )
+ ]],
+ mainSuggestedPartners: [[
+ 'insert-and-replace',
+ mainSuggestedPartners.map(data =>
+ this.env.models['mail.partner'].convertData(data))
+ ]],
+ });
+
+ if (this.allSuggestedPartners[0]) {
+ this.update({
+ activeSuggestedPartner: [['link', this.allSuggestedPartners[0]]],
+ });
+ } else {
+ this.update({
+ activeSuggestedPartner: [['unlink']],
+ });
+ }
+ }
+
+ /**
+ * @private
+ */
+ _reset() {
+ this.closeMentionSuggestions();
+ this.update({
+ attachments: [['unlink-all']],
+ mentionedPartners: [['unlink-all']],
+ textInputContent: '',
+ textInputCursorStart: 0,
+ textInputCursorEnd: 0,
+ });
+ }
+
+ /**
+ * Validates user's current typing as a correct mention keyword
+ * in order to trigger mentions suggestions display.
+ * Returns the mention keyword without the delimiter if it has been validated
+ * and false if not
+ *
+ * @private
+ * @param {string} delimiter
+ * @param {boolean} beginningOnly
+ * @returns {string|boolean}
+ */
+ _validateMentionKeyword(delimiter, beginningOnly) {
+ const leftString = this.textInputContent.substring(0, this.textInputCursorStart);
+
+ // use position before delimiter because there should be whitespaces
+ // or line feed/carriage return before the delimiter
+ const beforeDelimiterPosition = leftString.lastIndexOf(delimiter) - 1;
+ if (beginningOnly && beforeDelimiterPosition > 0) {
+ return false;
+ }
+ let searchStr = this.textInputContent.substring(
+ beforeDelimiterPosition,
+ this.textInputCursorStart
+ );
+ // regex string start with delimiter or whitespace then delimiter
+ const pattern = "^"+delimiter+"|^\\s"+delimiter;
+ const regexStart = new RegExp(pattern, 'g');
+ // trim any left whitespaces or the left line feed/ carriage return
+ // at the beginning of the string
+ searchStr = searchStr.replace(/^\s\s*|^[\n\r]/g, '');
+ if (regexStart.test(searchStr) && searchStr.length) {
+ searchStr = searchStr.replace(pattern, '');
+ return !searchStr.includes(' ') && !/[\r\n]/.test(searchStr)
+ ? searchStr.replace(delimiter, '')
+ : false;
+ }
+ return false;
+ }
+ }
+
+ Composer.fields = {
+ activeSuggestedPartner: many2one('mail.partner'),
+ allSuggestedPartners: many2many('mail.partner', {
+ compute: '_computeAllSuggestedPartners',
+ dependencies: [
+ 'extraSuggestedPartners',
+ 'mainSuggestedPartners',
+ ],
+ }),
+ attachments: many2many('mail.attachment', {
+ inverse: 'composers',
+ }),
+ canPostMessage: attr({
+ compute: '_computeCanPostMessage',
+ dependencies: [
+ 'attachments',
+ 'textInputContent',
+ ],
+ default: false,
+ }),
+ extraSuggestedPartners: many2many('mail.partner', {
+ compute: '_computeExtraSuggestedPartners',
+ dependencies: [
+ 'extraSuggestedPartners',
+ 'mainSuggestedPartners',
+ ],
+ }),
+ hasSuggestedPartners: attr({
+ compute: '_computeHasSuggestedPartners',
+ dependencies: [
+ 'allSuggestedPartners',
+ ],
+ default: false,
+ }),
+ isDoFocus: attr({
+ default: false,
+ }),
+ mainSuggestedPartners: many2many('mail.partner'),
+ mentionedPartners: many2many('mail.partner', {
+ compute: '_computeMentionedPartners',
+ dependencies: ['textInputContent'],
+ }),
+ textInputContent: attr({
+ default: "",
+ }),
+ textInputCursorStart: attr({
+ default: 0,
+ }),
+ textInputCursorEnd: attr({
+ default: 0,
+ }),
+ thread: one2one('mail.thread', {
+ inverse: 'composer',
+ }),
+ };
+
+ Composer.modelName = 'mail.composer';
+
+ return Composer;
+}
+
+registerNewModel('mail.composer', factory);
+
+});
diff --git a/addons/mail/static/src/models/country/country.js b/addons/mail/static/src/models/country/country.js
new file mode 100644
index 00000000000..8fe6b282a31
--- /dev/null
+++ b/addons/mail/static/src/models/country/country.js
@@ -0,0 +1,23 @@
+odoo.define('mail/static/src/models/country/country.js', function (require) {
+'use strict';
+
+const { registerNewModel } = require('mail/static/src/model/model_core.js');
+const { attr } = require('mail/static/src/model/model_field.js');
+
+function factory(dependencies) {
+
+ class Country extends dependencies['mail.model'] {}
+
+ Country.fields = {
+ id: attr(),
+ name: attr(),
+ };
+
+ Country.modelName = 'mail.country';
+
+ return Country;
+}
+
+registerNewModel('mail.country', factory);
+
+});
diff --git a/addons/mail/static/src/models/device/device.js b/addons/mail/static/src/models/device/device.js
new file mode 100644
index 00000000000..d3aba92aac4
--- /dev/null
+++ b/addons/mail/static/src/models/device/device.js
@@ -0,0 +1,71 @@
+odoo.define('mail/static/src/models/device/device.js', function (require) {
+'use strict';
+
+const { registerNewModel } = require('mail/static/src/model/model_core.js');
+const { attr } = require('mail/static/src/model/model_field.js');
+
+function factory(dependencies) {
+
+ class Device extends dependencies['mail.model'] {
+
+ /**
+ * @override
+ */
+ static create() {
+ const record = super.create();
+ record._refresh();
+ record._onResize = _.debounce(() => record._refresh(), 100);
+ return record;
+ }
+
+ /**
+ * @override
+ */
+ delete() {
+ window.removeEventListener('resize', this._onResize);
+ super.delete();
+ }
+
+ //----------------------------------------------------------------------
+ // Public
+ //----------------------------------------------------------------------
+
+ /**
+ * Called when messaging is started.
+ */
+ start() {
+ // TODO FIXME Not using this.env.browser because it's proxified, and
+ // addEventListener does not work on proxified window. task-2234596
+ window.addEventListener('resize', this._onResize);
+ }
+
+ //----------------------------------------------------------------------
+ // Private
+ //----------------------------------------------------------------------
+
+ /**
+ * @private
+ */
+ _refresh() {
+ this.update({
+ globalWindowInnerHeight: this.env.browser.innerHeight,
+ globalWindowInnerWidth: this.env.browser.innerWidth,
+ isMobile: this.env.device.isMobile,
+ });
+ }
+ }
+
+ Device.fields = {
+ globalWindowInnerHeight: attr(),
+ globalWindowInnerWidth: attr(),
+ isMobile: attr(),
+ };
+
+ Device.modelName = 'mail.device';
+
+ return Device;
+}
+
+registerNewModel('mail.device', factory);
+
+});
diff --git a/addons/mail/static/src/models/dialog/dialog.js b/addons/mail/static/src/models/dialog/dialog.js
new file mode 100644
index 00000000000..d4b0bcddfb5
--- /dev/null
+++ b/addons/mail/static/src/models/dialog/dialog.js
@@ -0,0 +1,52 @@
+odoo.define('mail/static/src/models/dialog/dialog.js', function (require) {
+'use strict';
+
+const { registerNewModel } = require('mail/static/src/model/model_core.js');
+const { many2one, one2one } = require('mail/static/src/model/model_field.js');
+
+function factory(dependencies) {
+
+ class Dialog extends dependencies['mail.model'] {
+
+ /**
+ * @override
+ */
+ delete() {
+ if (this.manager) {
+ this.manager.unregister(this);
+ }
+ super.delete();
+ }
+
+ //----------------------------------------------------------------------
+ // Public
+ //----------------------------------------------------------------------
+
+ close() {
+ this.delete();
+ }
+
+ }
+
+ Dialog.fields = {
+ manager: many2one('mail.dialog_manager', {
+ inverse: 'dialogs',
+ }),
+ /**
+ * Content of dialog that is directly linked to a record that models
+ * a UI component, such as AttachmentViewer. These records must be
+ * created from @see `mail.dialog_manager:open()`.
+ */
+ record: one2one('mail.model', {
+ isCausal: true,
+ }),
+ };
+
+ Dialog.modelName = 'mail.dialog';
+
+ return Dialog;
+}
+
+registerNewModel('mail.dialog', factory);
+
+});
diff --git a/addons/mail/static/src/models/dialog_manager/dialog_manager.js b/addons/mail/static/src/models/dialog_manager/dialog_manager.js
new file mode 100644
index 00000000000..024172f7cdf
--- /dev/null
+++ b/addons/mail/static/src/models/dialog_manager/dialog_manager.js
@@ -0,0 +1,99 @@
+odoo.define('mail/static/src/models/dialog_manager/dialog_manager.js', function (require) {
+'use strict';
+
+const { registerNewModel } = require('mail/static/src/model/model_core.js');
+const { attr, one2many } = require('mail/static/src/model/model_field.js');
+
+function factory(dependencies) {
+
+ class DialogManager extends dependencies['mail.model'] {
+
+ //----------------------------------------------------------------------
+ // Public
+ //----------------------------------------------------------------------
+
+ /**
+ * @param {mail.dialog} dialog
+ */
+ close(dialog) {
+ this.unregister(dialog);
+ dialog.delete();
+ }
+
+ /**
+ * @param {string} modelName
+ * @param {Object} [recordData]
+ */
+ open(modelName, recordData) {
+ const dialog = this.env.models['mail.dialog'].create({
+ manager: [['link', this]],
+ });
+ if (!modelName) {
+ throw new Error("Dialog should have a link to a model");
+ }
+ const Model = this.env.models[modelName];
+ if (!Model) {
+ throw new Error(`No model exists with name ${modelName}`);
+ }
+ const record = Model.create(recordData);
+ dialog.update({ record: [['link', record]] });
+ this.update({ _ordered: this._ordered.concat([dialog.localId]) });
+ return dialog;
+ }
+
+ /**
+ * @param {mail.dialog} dialog
+ */
+ unregister(dialog) {
+ if (!this.allOrdered.includes(dialog)) {
+ return;
+ }
+ this.update({
+ _ordered: this._ordered.filter(
+ dialogLocalId => dialogLocalId !== dialog.localId
+ ),
+ dialogs: [['unlink', dialog]],
+ });
+ }
+
+ //----------------------------------------------------------------------
+ // Private
+ //----------------------------------------------------------------------
+
+ /**
+ * FIXME: dependent on implementation that uses arbitrary order in relations!!
+ *
+ * @private
+ * @returns {mail.dialog}
+ */
+ _computeAllOrdered() {
+ return [['replace', this._ordered.map(dialogLocalId =>
+ this.env.models['mail.dialog'].get(dialogLocalId)
+ )]];
+ }
+ }
+
+ DialogManager.fields = {
+ _ordered: attr({ default: [] }),
+ // FIXME: dependent on implementation that uses arbitrary order in relations!!
+ allOrdered: one2many('mail.dialog', {
+ compute: '_computeAllOrdered',
+ dependencies: [
+ '_ordered',
+ 'dialogs',
+ ],
+ }),
+ dialogs: one2many('mail.dialog', {
+ inverse: 'manager',
+ isCausal: true,
+ }),
+ };
+
+ DialogManager.modelName = 'mail.dialog_manager';
+
+ return DialogManager;
+}
+
+registerNewModel('mail.dialog_manager', factory);
+
+});
diff --git a/addons/mail/static/src/models/discuss/discuss.js b/addons/mail/static/src/models/discuss/discuss.js
new file mode 100644
index 00000000000..c5d6c3ecf91
--- /dev/null
+++ b/addons/mail/static/src/models/discuss/discuss.js
@@ -0,0 +1,402 @@
+odoo.define('mail/static/src/models/discuss.discuss.js', function (require) {
+'use strict';
+
+const { registerNewModel } = require('mail/static/src/model/model_core.js');
+const { attr, many2one, one2many, one2one } = require('mail/static/src/model/model_field.js');
+
+function factory(dependencies) {
+
+ class Discuss extends dependencies['mail.model'] {
+
+ //----------------------------------------------------------------------
+ // Public
+ //----------------------------------------------------------------------
+
+ /**
+ * @param {mail.thread} thread
+ */
+ cancelThreadRenaming(thread) {
+ this.update({ renamingThreads: [['unlink', thread]] });
+ }
+
+ clearIsAddingItem() {
+ this.update({
+ addingChannelValue: "",
+ isAddingChannel: false,
+ isAddingChat: false,
+ });
+ }
+
+ clearReplyingToMessage() {
+ this.update({ replyingToMessage: [['unlink-all']] });
+ }
+
+ /**
+ * Close the discuss app. Should reset its internal state.
+ */
+ close() {
+ this.update({ isOpen: false });
+ }
+
+ /**
+ * @param {Event} ev
+ * @param {Object} ui
+ * @param {Object} ui.item
+ * @param {integer} ui.item.id
+ */
+ handleAddChannelAutocompleteSelect(ev, ui) {
+ if (ui.item.special) {
+ this.env.models['mail.thread'].createChannel({
+ autoselect: true,
+ name: this.addingChannelValue,
+ public: ui.item.special,
+ type: 'channel',
+ });
+ } else {
+ this.env.models['mail.thread'].joinChannel(ui.item.id, { autoselect: true });
+ }
+ this.clearIsAddingItem();
+ }
+
+ /**
+ * @param {Object} req
+ * @param {string} req.term
+ * @param {function} res
+ */
+ async handleAddChannelAutocompleteSource(req, res) {
+ const value = req.term;
+ const escapedValue = owl.utils.escape(value);
+ this.update({ addingChannelValue: value });
+ const result = await this.async(() => this.env.services.rpc({
+ model: 'mail.channel',
+ method: 'channel_search_to_join',
+ args: [value],
+ }));
+ const items = result.map(data => {
+ let escapedName = owl.utils.escape(data.name);
+ return Object.assign(data, {
+ label: escapedName,
+ value: escapedName
+ });
+ });
+ // XDU FIXME could use a component but be careful with owl's
+ // renderToString https://github.com/odoo/owl/issues/708
+ items.push({
+ label: _.str.sprintf(
+ `${this.env._t('Create %s')}`,
+ `${escapedValue}`,
+ ),
+ escapedValue,
+ special: 'public'
+ }, {
+ label: _.str.sprintf(
+ `${this.env._t('Create %s')}`,
+ `${escapedValue}`,
+ ),
+ escapedValue,
+ special: 'private'
+ });
+ res(items);
+ }
+
+ /**
+ * @param {Event} ev
+ * @param {Object} ui
+ * @param {Object} ui.item
+ * @param {integer} ui.item.id
+ */
+ handleAddChatAutocompleteSelect(ev, ui) {
+ const partnerId = ui.item.id;
+ const partner = this.env.models['mail.partner'].find(partner =>
+ partner.id === partnerId
+ );
+ const chat = partner.correspondentThreads.find(thread => thread.channel_type === 'chat');
+ if (chat) {
+ this.threadViewer.update({ thread: [['link', chat]] });
+ } else {
+ this.env.models['mail.thread'].createChannel({
+ autoselect: true,
+ partnerId,
+ type: 'chat',
+ });
+ }
+ this.clearIsAddingItem();
+ }
+
+ /**
+ * @param {Object} req
+ * @param {string} req.term
+ * @param {function} res
+ */
+ handleAddChatAutocompleteSource(req, res) {
+ const value = owl.utils.escape(req.term);
+ this.env.models['mail.partner'].imSearch({
+ callback: partners => {
+ const suggestions = partners.map(partner => {
+ return {
+ id: partner.id,
+ value: partner.nameOrDisplayName,
+ label: partner.nameOrDisplayName,
+ };
+ });
+ res(_.sortBy(suggestions, 'label'));
+ },
+ keyword: value,
+ limit: 10,
+ });
+ }
+
+ /**
+ * Open thread from init active id. `initActiveId` is used to refer to
+ * a thread that we may not have full data yet, such as when messaging
+ * is not yet initialized.
+ */
+ openInitThread() {
+ const [model, id] = this.initActiveId.split('_');
+ const thread = this.env.models['mail.thread'].find(thread =>
+ thread.id === (model !== 'mail.box' ? Number(id) : id) &&
+ thread.model === model
+ );
+ if (!thread) {
+ return;
+ }
+ this.threadViewer.update({
+ stringifiedDomain: '[]',
+ thread: [['link', thread]],
+ });
+ thread.open({ resetDiscussDomain: true });
+ }
+
+ /**
+ * @param {mail.thread} thread
+ * @param {string} newName
+ */
+ async renameThread(thread, newName) {
+ await this.async(() => thread.rename(newName));
+ this.update({ renamingThreads: [['unlink', thread]] });
+ }
+
+ /**
+ * @param {mail.thread} thread
+ */
+ setThreadRenaming(thread) {
+ this.update({ renamingThreads: [['link', thread ]] });
+ }
+
+ /**
+ * @param {mail.thread} thread
+ * @returns {string}
+ */
+ threadToActiveId(thread) {
+ return `${thread.model}_${thread.id}`;
+ }
+
+ //----------------------------------------------------------------------
+ // Private
+ //----------------------------------------------------------------------
+
+ /**
+ * @private
+ * @returns {string|undefined}
+ */
+ _computeActiveId() {
+ if (!this.thread) {
+ return undefined;
+ }
+ return this.threadToActiveId(this.thread);
+ }
+
+ /**
+ * @private
+ * @returns {string}
+ */
+ _computeAddingChannelValue() {
+ if (!this.isOpen) {
+ return "";
+ }
+ return this.addingChannelValue;
+ }
+
+ /**
+ * @private
+ * @returns {string}
+ */
+ _computeInitActiveId() {
+ if (!this.isOpen) {
+ return this.defaultInitActiveId;
+ }
+ if (this.thread) {
+ return this.threadToActiveId(this.thread);
+ }
+ return this.initActiveId;
+ }
+
+ /**
+ * @private
+ * @returns {boolean}
+ */
+ _computeIsAddingChannel() {
+ if (!this.isOpen) {
+ return false;
+ }
+ return this.isAddingChannel;
+ }
+
+ /**
+ * @private
+ * @returns {boolean}
+ */
+ _computeIsAddingChat() {
+ if (!this.isOpen) {
+ return false;
+ }
+ return this.isAddingChat;
+ }
+
+ /**
+ * @private
+ * @returns {boolean}
+ */
+ _computeIsReplyingToMessage() {
+ return !!this.replyingToMessage;
+ }
+
+ /**
+ * @private
+ * @returns {mail.message|undefined}
+ */
+ _computeReplyingToMessage() {
+ if (!this.isOpen) {
+ return [['unlink-all']];
+ }
+ return [];
+ }
+
+ }
+
+ Discuss.fields = {
+ activeId: attr({
+ compute: '_computeActiveId',
+ dependencies: [
+ 'thread',
+ 'threadId',
+ 'threadModel',
+ ],
+ }),
+ /**
+ * Active mobile navbar tab, either 'mailbox', 'chat', or 'channel'.
+ */
+ activeMobileNavbarTabId: attr({
+ default: 'mailbox',
+ }),
+ /**
+ * Value that is used to create a channel from the sidebar.
+ */
+ addingChannelValue: attr({
+ compute: '_computeAddingChannelValue',
+ default: "",
+ dependencies: ['isOpen'],
+ }),
+ defaultInitActiveId: attr({
+ default: 'mail.box_inbox',
+ }),
+ /**
+ * Determine if the moderation discard dialog is displayed.
+ */
+ hasModerationDiscardDialog: attr({
+ default: false,
+ }),
+ /**
+ * Determine if the moderation reject dialog is displayed.
+ */
+ hasModerationRejectDialog: attr({
+ default: false,
+ }),
+ /**
+ * Formatted init thread on opening discuss for the first time,
+ * when no active thread is defined. Useful to set a thread to
+ * open without knowing its local id in advance.
+ * format: _
+ */
+ initActiveId: attr({
+ compute: '_computeInitActiveId',
+ default: 'mail.box_inbox',
+ dependencies: [
+ 'isOpen',
+ 'thread',
+ 'threadId',
+ 'threadModel',
+ ],
+ }),
+ /**
+ * Determine whether current user is currently adding a channel from
+ * the sidebar.
+ */
+ isAddingChannel: attr({
+ compute: '_computeIsAddingChannel',
+ default: false,
+ dependencies: ['isOpen'],
+ }),
+ /**
+ * Determine whether current user is currently adding a chat from
+ * the sidebar.
+ */
+ isAddingChat: attr({
+ compute: '_computeIsAddingChat',
+ default: false,
+ dependencies: ['isOpen'],
+ }),
+ /**
+ * Whether the discuss app is open or not. Useful to determine
+ * whether the discuss or chat window logic should be applied.
+ */
+ isOpen: attr({
+ default: false,
+ }),
+ isReplyingToMessage: attr({
+ compute: '_computeIsReplyingToMessage',
+ default: false,
+ dependencies: ['replyingToMessage'],
+ }),
+ /**
+ * The menu_id of discuss app, received on mail/init_messaging and
+ * used to open discuss from elsewhere.
+ */
+ menu_id: attr({
+ default: null,
+ }),
+ renamingThreads: one2many('mail.thread'),
+ replyingToMessage: one2one('mail.message', {
+ compute: '_computeReplyingToMessage',
+ dependencies: ['isOpen'],
+ }),
+ /**
+ * Quick search input value in the discuss sidebar (desktop). Useful
+ * to filter channels and chats based on this input content.
+ */
+ sidebarQuickSearchValue: attr({
+ default: "",
+ }),
+ thread: many2one('mail.thread', {
+ related: 'threadViewer.thread',
+ }),
+ threadId: attr({
+ related: 'thread.id',
+ }),
+ threadModel: attr({
+ related: 'thread.model',
+ }),
+ threadViewer: one2one('mail.thread_viewer', {
+ autocreate: true,
+ isCausal: true,
+ }),
+ };
+
+ Discuss.modelName = 'mail.discuss';
+
+ return Discuss;
+}
+
+registerNewModel('mail.discuss', factory);
+
+});
diff --git a/addons/mail/static/src/models/follower/follower.js b/addons/mail/static/src/models/follower/follower.js
new file mode 100644
index 00000000000..78667ed755c
--- /dev/null
+++ b/addons/mail/static/src/models/follower/follower.js
@@ -0,0 +1,265 @@
+odoo.define('mail/static/src/models/follower.follower.js', function (require) {
+'use strict';
+
+const { registerNewModel } = require('mail/static/src/model/model_core.js');
+const { attr, many2many, many2one } = require('mail/static/src/model/model_field.js');
+
+function factory(dependencies) {
+
+ class Follower extends dependencies['mail.model'] {
+
+ //----------------------------------------------------------------------
+ // Public
+ //----------------------------------------------------------------------
+
+ /**
+ * @static
+ * @param {Object} data
+ * @returns {Object}
+ */
+ static convertData(data) {
+ const data2 = {};
+ if ('channel_id' in data) {
+ if (!data.channel_id) {
+ data2.channel = [['unlink-all']];
+ } else {
+ const channelData = {
+ id: data.channel_id,
+ model: 'mail.channel',
+ name: data.name,
+ };
+ data2.channel = [['insert', channelData]];
+ }
+ }
+ if ('id' in data) {
+ data2.id = data.id;
+ }
+ if ('is_active' in data) {
+ data2.isActive = data.is_active;
+ }
+ if ('is_editable' in data) {
+ data2.isEditable = data.is_editable;
+ }
+ if ('partner_id' in data) {
+ if (!data.partner_id) {
+ data2.partner = [['unlink-all']];
+ } else {
+ const partnerData = {
+ email: data.email,
+ id: data.partner_id,
+ name: data.name,
+ };
+ data2.partner = [['insert', partnerData]];
+ }
+ }
+ return data2;
+ }
+
+ /**
+ * Close subtypes dialog
+ */
+ closeSubtypes() {
+ this.env.messaging.dialogManager.close(this._subtypesListDialog);
+ this._subtypesListDialog = undefined;
+ }
+
+ /**
+ * Remove this follower from its related thread.
+ */
+ async remove() {
+ const args = [[this.followedThread.id]];
+ if (this.partner) {
+ args.push([this.partner.id]);
+ } else {
+ args.push([this.channel.id]);
+ }
+ await this.async(() => this.env.services.rpc({
+ model: this.followedThread.model,
+ method: 'message_unsubscribe',
+ args
+ }));
+ this.delete();
+ }
+
+ /**
+ * @param {mail.follower_subtype} subtype
+ */
+ selectSubtype(subtype) {
+ if (!this.selectedSubtypes.includes(subtype)) {
+ this.update({ selectedSubtypes: [['link', subtype]] });
+ }
+ }
+
+ /**
+ * Show (editable) list of subtypes of this follower.
+ */
+ async showSubtypes() {
+ const subtypesData = await this.async(() => this.env.services.rpc({
+ route: '/mail/read_subscription_data',
+ params: { follower_id: this.id },
+ }));
+ this.update({ subtypes: [['unlink-all']] });
+ for (const data of subtypesData) {
+ const subtype = this.env.models['mail.follower_subtype'].insert(
+ this.env.models['mail.follower_subtype'].convertData(data)
+ );
+ this.update({ subtypes: [['link', subtype]] });
+ if (data.followed) {
+ this.update({ selectedSubtypes: [['link', subtype]] });
+ } else {
+ this.update({ selectedSubtypes: [['unlink', subtype]] });
+ }
+ }
+ this._subtypesListDialog = this.env.messaging.dialogManager.open('mail.follower_subtype_list', {
+ follower: [['replace', this]],
+ });
+ }
+
+ /**
+ * @param {mail.follower_subtype} subtype
+ */
+ unselectSubtype(subtype) {
+ if (this.selectedSubtypes.includes(subtype)) {
+ this.update({ selectedSubtypes: [['unlink', subtype]] });
+ }
+ }
+
+ /**
+ * Update server-side subscription of subtypes of this follower.
+ */
+ async updateSubtypes() {
+ if (this.selectedSubtypes.length === 0) {
+ this.remove();
+ } else {
+ const kwargs = {
+ subtype_ids: this.selectedSubtypes.map(subtype => subtype.id),
+ };
+ if (this.partner) {
+ kwargs.partner_ids = [this.partner.id];
+ } else {
+ kwargs.channel_ids = [this.channel.id];
+ }
+ await this.async(() => this.env.services.rpc({
+ model: this.followedThread.model,
+ method: 'message_subscribe',
+ args: [[this.followedThread.id]],
+ kwargs,
+ }));
+ }
+ this.closeSubtypes();
+ }
+
+ //----------------------------------------------------------------------
+ // Private
+ //----------------------------------------------------------------------
+
+ /**
+ * @private
+ * @returns {string}
+ */
+ _computeName() {
+ if (this.channel) {
+ return this.channel.name;
+ }
+ if (this.partner) {
+ return this.partner.name;
+ }
+ return '';
+ }
+
+ /**
+ * @private
+ * @returns {integer}
+ */
+ _computeResId() {
+ if (this.partner) {
+ return this.partner.id;
+ }
+ if (this.channel) {
+ return this.channel.id;
+ }
+ return 0;
+ }
+
+ /**
+ * @private
+ * @returns {string}
+ */
+ _computeResModel() {
+ if (this.partner) {
+ return this.partner.model;
+ }
+ if (this.channel) {
+ return this.channel.model;
+ }
+ return '';
+ }
+
+ }
+
+ Follower.fields = {
+ resId: attr({
+ compute: '_computeResId',
+ default: 0,
+ dependencies: [
+ 'channelId',
+ 'partnerId',
+ ],
+ }),
+ channel: many2one('mail.thread'),
+ channelId: attr({
+ related: 'channel.id',
+ }),
+ channelModel: attr({
+ related: 'channel.model',
+ }),
+ channelName: attr({
+ related: 'channel.name',
+ }),
+ followedThread: many2one('mail.thread', {
+ inverse: 'followers',
+ }),
+ id: attr(),
+ isActive: attr({
+ default: true,
+ }),
+ isEditable: attr({
+ default: false,
+ }),
+ name: attr({
+ compute: '_computeName',
+ dependencies: [
+ 'channelName',
+ 'partnerName',
+ ],
+ }),
+ partner: many2one('mail.partner'),
+ partnerId: attr({
+ related: 'partner.id',
+ }),
+ partnerModel: attr({
+ related: 'partner.model',
+ }),
+ partnerName: attr({
+ related: 'partner.name',
+ }),
+ resModel: attr({
+ compute: '_computeResModel',
+ default: '',
+ dependencies: [
+ 'channelModel',
+ 'partnerModel',
+ ],
+ }),
+ selectedSubtypes: many2many('mail.follower_subtype'),
+ subtypes: many2many('mail.follower_subtype'),
+ };
+
+ Follower.modelName = 'mail.follower';
+
+ return Follower;
+}
+
+registerNewModel('mail.follower', factory);
+
+});
diff --git a/addons/mail/static/src/models/follower_subtype/follower_subtype.js b/addons/mail/static/src/models/follower_subtype/follower_subtype.js
new file mode 100644
index 00000000000..37e65129c61
--- /dev/null
+++ b/addons/mail/static/src/models/follower_subtype/follower_subtype.js
@@ -0,0 +1,71 @@
+odoo.define('mail/static/src/models/follower_subtype/follower_subtype.js', function (require) {
+'use strict';
+
+const { registerNewModel } = require('mail/static/src/model/model_core.js');
+const { attr } = require('mail/static/src/model/model_field.js');
+
+function factory(dependencies) {
+
+ class FollowerSubtype extends dependencies['mail.model'] {
+
+ //----------------------------------------------------------------------
+ // Public
+ //----------------------------------------------------------------------
+
+ /**
+ * @static
+ * @param {Object} data
+ * @returns {Object}
+ */
+ static convertData(data) {
+ const data2 = {};
+ if ('default' in data) {
+ data2.isDefault = data.default;
+ }
+ if ('id' in data) {
+ data2.id = data.id;
+ }
+ if ('internal' in data) {
+ data2.isInternal = data.internal;
+ }
+ if ('name' in data) {
+ data2.name = data.name;
+ }
+ if ('parent_model' in data) {
+ data2.parentModel = data.parent_model;
+ }
+ if ('res_model' in data) {
+ data2.resModel = data.res_model;
+ }
+ if ('sequence' in data) {
+ data2.sequence = data.sequence;
+ }
+ return data2;
+ }
+
+ }
+
+ FollowerSubtype.fields = {
+ id: attr(),
+ isDefault: attr({
+ default: false,
+ }),
+ isInternal: attr({
+ default: false,
+ }),
+ name: attr(),
+ // AKU FIXME: use relation instead
+ parentModel: attr(),
+ // AKU FIXME: use relation instead
+ resModel: attr(),
+ sequence: attr(),
+ };
+
+ FollowerSubtype.modelName = 'mail.follower_subtype';
+
+ return FollowerSubtype;
+}
+
+registerNewModel('mail.follower_subtype', factory);
+
+});
diff --git a/addons/mail/static/src/models/follower_subtype_list/follower_subtype_list.js b/addons/mail/static/src/models/follower_subtype_list/follower_subtype_list.js
new file mode 100644
index 00000000000..9d67cedb97d
--- /dev/null
+++ b/addons/mail/static/src/models/follower_subtype_list/follower_subtype_list.js
@@ -0,0 +1,22 @@
+odoo.define('mail/static/src/models/follower_subtype_list/follower_subtype_list.js', function (require) {
+'use strict';
+
+const { registerNewModel } = require('mail/static/src/model/model_core.js');
+const { many2one } = require('mail/static/src/model/model_field.js');
+
+function factory(dependencies) {
+
+ class FollowerSubtypeList extends dependencies['mail.model'] {}
+
+ FollowerSubtypeList.fields = {
+ follower: many2one('mail.follower'),
+ };
+
+ FollowerSubtypeList.modelName = 'mail.follower_subtype_list';
+
+ return FollowerSubtypeList;
+}
+
+registerNewModel('mail.follower_subtype_list', factory);
+
+});
diff --git a/addons/mail/static/src/models/locale/locale.js b/addons/mail/static/src/models/locale/locale.js
new file mode 100644
index 00000000000..4007dbc796c
--- /dev/null
+++ b/addons/mail/static/src/models/locale/locale.js
@@ -0,0 +1,38 @@
+odoo.define('mail/static/src/models/locale/locale.js', function (require) {
+'use strict';
+
+const { registerNewModel } = require('mail/static/src/model/model_core.js');
+const { attr } = require('mail/static/src/model/model_field.js');
+
+function factory(dependencies) {
+
+ class Locale extends dependencies['mail.model'] {
+
+ //----------------------------------------------------------------------
+ // Private
+ //----------------------------------------------------------------------
+
+ /**
+ * @private
+ * @returns {string}
+ */
+ _computeTextDirection() {
+ return this.env._t.database.parameters.direction;
+ }
+
+ }
+
+ Locale.fields = {
+ textDirection: attr({
+ compute: '_computeTextDirection',
+ }),
+ };
+
+ Locale.modelName = 'mail.locale';
+
+ return Locale;
+}
+
+registerNewModel('mail.locale', factory);
+
+});
diff --git a/addons/mail/static/src/models/mail_template/mail_template.js b/addons/mail/static/src/models/mail_template/mail_template.js
new file mode 100644
index 00000000000..69d2b4c18b1
--- /dev/null
+++ b/addons/mail/static/src/models/mail_template/mail_template.js
@@ -0,0 +1,76 @@
+odoo.define('mail/static/src/models/mail_template/mail_template.js', function (require) {
+'use strict';
+
+const { registerNewModel } = require('mail/static/src/model/model_core.js');
+const { attr, many2many } = require('mail/static/src/model/model_field.js');
+
+function factory(dependencies) {
+
+ class MailTemplate extends dependencies['mail.model'] {
+
+ //----------------------------------------------------------------------
+ // Public
+ //----------------------------------------------------------------------
+
+ /**
+ * @param {mail.activity} activity
+ */
+ preview(activity) {
+ const action = {
+ name: this.env._t("Compose Email"),
+ type: 'ir.actions.act_window',
+ res_model: 'mail.compose.message',
+ views: [[false, 'form']],
+ target: 'new',
+ context: {
+ default_res_id: activity.res_id,
+ default_model: activity.res_model,
+ default_use_template: true,
+ default_template_id: this.id,
+ force_email: true,
+ },
+ };
+ this.env.bus.trigger('do-action', {
+ action,
+ options: {
+ on_close: () => {
+ if (activity.chatter) {
+ activity.chatter.refresh();
+ }
+ },
+ },
+ });
+ }
+
+ /**
+ * @param {mail.activity} activity
+ */
+ async send(activity) {
+ await this.async(() => this.env.services.rpc({
+ model: activity.res_model,
+ method: 'activity_send_mail',
+ args: [[activity.res_id], this.id],
+ }));
+ if (activity.chatter) {
+ activity.chatter.refresh();
+ }
+ }
+
+ }
+
+ MailTemplate.fields = {
+ activities: many2many('mail.activity', {
+ inverse: 'mailTemplates',
+ }),
+ id: attr(),
+ name: attr(),
+ };
+
+ MailTemplate.modelName = 'mail.mail_template';
+
+ return MailTemplate;
+}
+
+registerNewModel('mail.mail_template', factory);
+
+});
diff --git a/addons/mail/static/src/models/message/message.js b/addons/mail/static/src/models/message/message.js
new file mode 100644
index 00000000000..f09d063b473
--- /dev/null
+++ b/addons/mail/static/src/models/message/message.js
@@ -0,0 +1,587 @@
+odoo.define('mail/static/src/models/message/message.js', function (require) {
+'use strict';
+
+const emojis = require('mail.emojis');
+const { registerNewModel } = require('mail/static/src/model/model_core.js');
+const { attr, many2many, many2one, one2many } = require('mail/static/src/model/model_field.js');
+const { addLink, parseAndTransform } = require('mail.utils');
+
+const { str_to_datetime } = require('web.time');
+
+function factory(dependencies) {
+
+ class Message extends dependencies['mail.model'] {
+
+ //----------------------------------------------------------------------
+ // Public
+ //----------------------------------------------------------------------
+
+ /**
+ * @static
+ * @param {mail.thread} thread
+ * @param {string} threadStringifiedDomain
+ */
+ static checkAll(thread, threadStringifiedDomain) {
+ const threadCache = thread.cache(threadStringifiedDomain);
+ threadCache.update({ checkedMessages: [['link', threadCache.messages]] });
+ }
+
+ /**
+ * @static
+ * @param {Object} data
+ * @return {Object}
+ */
+ static convertData(data) {
+ const data2 = {
+ threadCaches: [],
+ };
+ if ('body' in data) {
+ data2.body = data.body;
+ }
+ if ('date' in data && data.date) {
+ data2.date = moment(str_to_datetime(data.date));
+ }
+ if ('email_from' in data) {
+ data2.email_from = data.email_from;
+ }
+ if ('id' in data) {
+ data2.id = data.id;
+ }
+ if ('is_discussion' in data) {
+ data2.is_discussion = data.is_discussion;
+ }
+ if ('is_note' in data) {
+ data2.is_note = data.is_note;
+ }
+ if ('is_notification' in data) {
+ data2.is_notification = data.is_notification;
+ }
+ if ('message_type' in data) {
+ data2.message_type = data.message_type;
+ }
+ if ('moderation_status' in data) {
+ data2.moderation_status = data.moderation_status;
+ const moderationMailbox = this.env.messaging.moderation;
+ if (moderationMailbox && data.moderation_status !== 'pending') {
+ data2.threadCaches.push(['unlink', moderationMailbox.mainCache]);
+ }
+ }
+ if ('subject' in data) {
+ data2.subject = data.subject;
+ }
+ if ('subtype_description' in data) {
+ data2.subtype_description = data.subtype_description;
+ }
+ if ('subtype_id' in data) {
+ data2.subtype_id = data.subtype_id;
+ }
+ if ('tracking_value_ids' in data) {
+ data2.tracking_value_ids = data.tracking_value_ids;
+ }
+
+ // relations
+ if ('attachment_ids' in data) {
+ if (!data.attachment_ids) {
+ data2.attachments = [['unlink-all']];
+ } else {
+ data2.attachments = [
+ ['insert-and-replace', data.attachment_ids.map(attachmentData =>
+ this.env.models['mail.attachment'].convertData(attachmentData)
+ )],
+ ];
+ }
+ }
+ if ('author_id' in data) {
+ if (!data.author_id) {
+ data2.author = [['unlink-all']];
+ } else if (data.author_id[0] !== 0) {
+ // partner id 0 is a hack of message_format to refer to an
+ // author non-related to a partner. display_name equals
+ // email_from, so this is omitted due to being redundant.
+ data2.author = [
+ ['insert', {
+ display_name: data.author_id[1],
+ id: data.author_id[0],
+ }],
+ ];
+ }
+ }
+ if ('channel_ids' in data && data.channel_ids) {
+ // AKU FIXME: side-effect of calling convert...
+ const channelList = [];
+ for (const channelId of data.channel_ids) {
+ const channel = this.env.models['mail.thread'].insert({
+ id: channelId,
+ model: 'mail.channel',
+ });
+ channelList.push(channel);
+ }
+ data2.threadCaches.push(['replace', channelList.map(channel => channel.mainCache)]);
+ }
+ if ('history_partner_ids' in data) {
+ const history = this.env.messaging.history;
+ if (data.history_partner_ids.includes(this.env.messaging.currentPartner.id)) {
+ data2.threadCaches.push(['link', history.mainCache]);
+ } else {
+ data2.threadCaches.push(['unlink', history.mainCache]);
+ }
+ }
+ if ('model' in data && 'res_id' in data && data.model && data.res_id) {
+ const originThreadData = {
+ id: data.res_id,
+ model: data.model,
+ };
+ if ('record_name' in data && data.record_name) {
+ originThreadData.name = data.record_name;
+ }
+ if ('res_model_name' in data && data.res_model_name) {
+ originThreadData.model_name = data.res_model_name;
+ }
+ if ('module_icon' in data) {
+ originThreadData.moduleIcon = data.module_icon;
+ }
+ data2.originThread = [['insert', originThreadData]];
+ }
+ if ('needaction_partner_ids' in data) {
+ const inbox = this.env.messaging.inbox;
+ if (data.needaction_partner_ids.includes(this.env.messaging.currentPartner.id)) {
+ data2.threadCaches.push(['link', inbox.mainCache]);
+ } else {
+ data2.threadCaches.push(['unlink', inbox.mainCache]);
+ }
+ }
+ if ('notifications' in data) {
+ data2.notifications = [['insert', data.notifications.map(notificationData =>
+ this.env.models['mail.notification'].convertData(notificationData)
+ )]];
+ }
+ if ('starred_partner_ids' in data) {
+ const starred = this.env.messaging.starred;
+ if (data.starred_partner_ids.includes(this.env.messaging.currentPartner.id)) {
+ data2.threadCaches.push(['link', starred.mainCache]);
+ } else {
+ data2.threadCaches.push(['unlink', starred.mainCache]);
+ }
+ }
+
+ return data2;
+ }
+
+ /**
+ * Mark all messages of current user with given domain as read.
+ *
+ * @static
+ * @param {Array[]} domain
+ */
+ static async markAllAsRead(domain) {
+ await this.env.services.rpc({
+ model: 'mail.message',
+ method: 'mark_all_as_read',
+ kwargs: { domain },
+ });
+ }
+
+ /**
+ * Mark provided messages as read. Messages that have been marked as
+ * read are acknowledged by server with response as longpolling
+ * notification of following format:
+ *
+ * [[dbname, 'res.partner', partnerId], { type: 'mark_as_read' }]
+ *
+ * @see mail.messaging_notification_handler:_handleNotificationPartnerMarkAsRead()
+ *
+ * @static
+ * @param {mail.message[]} messages
+ */
+ static async markAsRead(messages) {
+ await this.env.services.rpc({
+ model: 'mail.message',
+ method: 'set_message_done',
+ args: [messages.map(message => message.id)]
+ });
+ }
+
+ /**
+ * Applies the moderation `decision` on the provided messages.
+ *
+ * @static
+ * @param {mail.message[]} messages
+ * @param {string} decision: 'accept', 'allow', ban', 'discard', or 'reject'
+ * @param {Object|undefined} [kwargs] optional data to pass on
+ * message moderation. This is provided when rejecting the messages
+ * for which title and comment give reason(s) for reject.
+ * @param {string} [kwargs.title]
+ * @param {string} [kwargs.comment]
+ */
+ static async moderate(messages, decision, kwargs) {
+ const messageIds = messages.map(message => message.id);
+ await this.env.services.rpc({
+ model: 'mail.message',
+ method: 'moderate',
+ args: [messageIds, decision],
+ kwargs: kwargs,
+ });
+ }
+
+ /**
+ * @static
+ * @param {mail.thread} thread
+ * @param {string} threadStringifiedDomain
+ */
+ static uncheckAll(thread, threadStringifiedDomain) {
+ const threadCache = thread.cache(threadStringifiedDomain);
+ threadCache.update({ checkedMessages: [['unlink', threadCache.messages]] });
+ }
+
+ /**
+ * Unstar all starred messages of current user.
+ */
+ static async unstarAll() {
+ await this.env.services.rpc({
+ model: 'mail.message',
+ method: 'unstar_all',
+ });
+ }
+
+ /**
+ * @param {mail.thread} thread
+ * @param {string} threadStringifiedDomain
+ * @returns {boolean}
+ */
+ isChecked(thread, threadStringifiedDomain) {
+ // aku todo
+ const relatedCheckedThreadCache = this.checkedThreadCaches.find(
+ threadCache => (
+ threadCache.thread === thread &&
+ threadCache.stringifiedDomain === threadStringifiedDomain
+ )
+ );
+ return !!relatedCheckedThreadCache;
+ }
+
+ /**
+ * Mark this message as read, so that it no longer appears in current
+ * partner Inbox.
+ */
+ async markAsRead() {
+ await this.async(() => this.env.services.rpc({
+ model: 'mail.message',
+ method: 'set_message_done',
+ args: [[this.id]]
+ }));
+ }
+
+ /**
+ * Applies the moderation `decision` on this message.
+ *
+ * @param {string} decision: 'accept', 'allow', ban', 'discard', or 'reject'
+ * @param {Object|undefined} [kwargs] optional data to pass on
+ * message moderation. This is provided when rejecting the messages
+ * for which title and comment give reason(s) for reject.
+ * @param {string} [kwargs.title]
+ * @param {string} [kwargs.comment]
+ */
+ async moderate(decision, kwargs) {
+ await this.async(() => this.constructor.moderate([this], decision, kwargs));
+ }
+
+ /**
+ * Opens the view that allows to resend the message in case of failure.
+ */
+ openResendAction() {
+ this.env.bus.trigger('do-action', {
+ action: 'mail.mail_resend_message_action',
+ options: {
+ additional_context: {
+ mail_message_to_resend: this.id,
+ },
+ },
+ });
+ }
+
+ /**
+ * Action to initiate reply to given message.
+ */
+ replyTo() {
+ const discuss = this.env.messaging.discuss;
+ if (!discuss.isOpen) {
+ return;
+ }
+ if (discuss.replyingToMessage === this) {
+ discuss.clearReplyingToMessage();
+ } else {
+ discuss.update({ replyingToMessage: [['link', this]] });
+ }
+ }
+
+ /**
+ * Toggle check state of this message in the context of the provided
+ * thread and its stringifiedDomain.
+ *
+ * @param {mail.thread} thread
+ * @param {string} threadStringifiedDomain
+ */
+ toggleCheck(thread, threadStringifiedDomain) {
+ const threadCache = thread.cache(threadStringifiedDomain);
+ if (threadCache.checkedMessages.includes(this)) {
+ threadCache.update({ checkedMessages: [['unlink', this]] });
+ } else {
+ threadCache.update({ checkedMessages: [['link', this]] });
+ }
+ }
+
+ /**
+ * Toggle the starred status of the provided message.
+ */
+ async toggleStar() {
+ await this.async(() => this.env.services.rpc({
+ model: 'mail.message',
+ method: 'toggle_message_starred',
+ args: [[this.id]]
+ }));
+ }
+
+ //----------------------------------------------------------------------
+ // Private
+ //----------------------------------------------------------------------
+
+ /**
+ * @private
+ * @returns {mail.thread[]}
+ */
+ _computeAllThreads() {
+ const threads = this.threadCaches.map(cache => cache.thread);
+ let allThreads = threads;
+ if (this.originThread) {
+ allThreads = allThreads.concat([this.originThread]);
+ }
+ return [['replace', [...new Set(allThreads)]]];
+ }
+
+ /**
+ * @returns {boolean}
+ */
+ _computeFailureNotifications() {
+ return [['replace', this.notifications.filter(notifications =>
+ ['exception', 'bounce'].includes(notifications.notification_status)
+ )]];
+ }
+
+ /**
+ * @private
+ * @returns {boolean}
+ */
+ _computeHasCheckbox() {
+ return this.isModeratedByCurrentPartner;
+ }
+
+ /**
+ * @private
+ * @returns {boolean}
+ */
+ _computeIsCurrentPartnerAuthor() {
+ return (
+ this.author &&
+ this.messagingCurrentPartner &&
+ this.messagingCurrentPartner === this.author
+ );
+ }
+
+ /**
+ * @private
+ * @returns {boolean}
+ */
+ _computeIsModeratedByCurrentPartner() {
+ return (
+ this.moderation_status === 'pending_moderation' &&
+ this.originThread &&
+ this.originThread.isModeratedByCurrentPartner
+ );
+ }
+
+ /**
+ * @private
+ * @returns {boolean}
+ */
+ _computeIsNeedaction() {
+ const inbox = this.env.messaging.inbox;
+ if (!inbox) {
+ return false;
+ }
+ return this.allThreads.includes(inbox);
+ }
+
+ /**
+ * @private
+ * @returns {mail.messaging}
+ */
+ _computeMessaging() {
+ return [['link', this.env.messaging]];
+ }
+
+ /**
+ * @private
+ * @returns {string}
+ */
+ _computePrettyBody() {
+ let prettyBody;
+ for (const emoji of emojis) {
+ const { unicode } = emoji;
+ const regexp = new RegExp(
+ `(?:^|\\s|<[a-z]*>)(${unicode})(?=\\s|$|[a-z]*>)`,
+ "g"
+ );
+ const originalBody = this.body;
+ prettyBody = this.body.replace(
+ regexp,
+ ` ${unicode} `
+ );
+ // Idiot-proof limit. If the user had the amazing idea of
+ // copy-pasting thousands of emojis, the image rendering can lead
+ // to memory overflow errors on some browsers (e.g. Chrome). Set an
+ // arbitrary limit to 200 from which we simply don't replace them
+ // (anyway, they are already replaced by the unicode counterpart).
+ if (_.str.count(prettyBody, "o_mail_emoji") > 200) {
+ prettyBody = originalBody;
+ }
+ }
+ // add anchor tags to urls
+ return parseAndTransform(prettyBody, addLink);
+ }
+
+ /**
+ * @override
+ */
+ _createRecordLocalId(data) {
+ const Message = this.env.models['mail.message'];
+ return `${Message.modelName}_${data.id}`;
+ }
+
+ }
+
+ Message.fields = {
+ allThreads: many2many('mail.thread', {
+ compute: '_computeAllThreads',
+ dependencies: [
+ 'originThread',
+ 'threadCachesThread',
+ ],
+ }),
+ attachments: many2many('mail.attachment', {
+ inverse: 'messages',
+ }),
+ author: many2one('mail.partner', {
+ inverse: 'messagesAsAuthor',
+ }),
+ body: attr({
+ default: "",
+ }),
+ checkedThreadCaches: many2many('mail.thread_cache', {
+ inverse: 'checkedMessages',
+ }),
+ date: attr({
+ default: moment(),
+ }),
+ email_from: attr(),
+ failureNotifications: one2many('mail.notification', {
+ compute: '_computeFailureNotifications',
+ dependencies: ['notificationsStatus'],
+ }),
+ hasCheckbox: attr({
+ compute: '_computeHasCheckbox',
+ default: false,
+ dependencies: ['isModeratedByCurrentPartner'],
+ }),
+ id: attr(),
+ isCurrentPartnerAuthor: attr({
+ compute: '_computeIsCurrentPartnerAuthor',
+ default: false,
+ dependencies: [
+ 'author',
+ 'messagingCurrentPartner',
+ ],
+ }),
+ isModeratedByCurrentPartner: attr({
+ compute: '_computeIsModeratedByCurrentPartner',
+ default: false,
+ dependencies: [
+ 'moderation_status',
+ 'originThread',
+ 'originThreadIsModeratedByCurrentPartner',
+ ],
+ }),
+ isNeedaction: attr({
+ compute: '_computeIsNeedaction',
+ dependencies: [
+ 'allThreads',
+ 'messagingInbox',
+ ],
+ default: false,
+ }),
+ isTemporary: attr({
+ default: false,
+ }),
+ isTransient: attr({
+ default: false,
+ }),
+ is_discussion: attr({
+ default: false,
+ }),
+ is_note: attr({
+ default: false,
+ }),
+ is_notification: attr({
+ default: false,
+ }),
+ message_type: attr(),
+ messaging: many2one('mail.messaging', {
+ compute: '_computeMessaging',
+ }),
+ messagingCurrentPartner: many2one('mail.partner', {
+ related: 'messaging.currentPartner',
+ }),
+ messagingInbox: many2one('mail.thread', {
+ related: 'messaging.inbox',
+ }),
+ moderation_status: attr(),
+ notifications: one2many('mail.notification', {
+ inverse: 'message',
+ isCausal: true,
+ }),
+ notificationsStatus: attr({
+ default: [],
+ related: 'notifications.notification_status',
+ }),
+ originThread: many2one('mail.thread', {
+ inverse: 'originThreadMessages',
+ }),
+ originThreadIsModeratedByCurrentPartner: attr({
+ default: false,
+ related: 'originThread.isModeratedByCurrentPartner',
+ }),
+ prettyBody: attr({
+ compute: '_computePrettyBody',
+ dependencies: ['body'],
+ }),
+ subject: attr(),
+ subtype_description: attr(),
+ subtype_id: attr(),
+ threadCaches: many2many('mail.thread_cache', {
+ inverse: 'messages',
+ }),
+ threadCachesThread: many2many('mail.thread', {
+ related: 'threadCaches.thread',
+ }),
+ tracking_value_ids: attr({
+ default: [],
+ }),
+ };
+
+ Message.modelName = 'mail.message';
+
+ return Message;
+}
+
+registerNewModel('mail.message', factory);
+
+});
diff --git a/addons/mail/static/src/models/message/message_tests.js b/addons/mail/static/src/models/message/message_tests.js
new file mode 100644
index 00000000000..fefb495a3ba
--- /dev/null
+++ b/addons/mail/static/src/models/message/message_tests.js
@@ -0,0 +1,132 @@
+odoo.define('mail/static/src/models/message/message_tests.js', function (require) {
+'use strict';
+
+const {
+ afterEach: utilsAfterEach,
+ beforeEach: utilsBeforeEach,
+ start: utilsStart,
+} = require('mail/static/src/utils/test_utils.js');
+
+const { str_to_datetime } = require('web.time');
+
+QUnit.module('mail', {}, function () {
+QUnit.module('models', {}, function () {
+QUnit.module('message', {}, function () {
+QUnit.module('message_tests.js', {
+ beforeEach() {
+ utilsBeforeEach(this);
+
+ this.start = async params => {
+ if (this.widget) {
+ this.widget.destroy();
+ }
+ let { env, widget } = await utilsStart(Object.assign({}, params, {
+ data: this.data,
+ }));
+ this.env = env;
+ this.widget = widget;
+ };
+ },
+ afterEach() {
+ utilsAfterEach(this);
+ this.env = undefined;
+ if (this.widget) {
+ this.widget.destroy();
+ this.widget = undefined;
+ }
+ },
+});
+
+QUnit.test('create', async function (assert) {
+ assert.expect(31);
+
+ await this.start();
+ assert.notOk(this.env.models['mail.partner'].find(partner => partner.id === 5));
+ assert.notOk(this.env.models['mail.thread'].find(thread =>
+ thread.id === 100 &&
+ thread.model === 'mail.channel'
+ ));
+ assert.notOk(this.env.models['mail.attachment'].find(attachment => attachment.id === 750));
+ assert.notOk(this.env.models['mail.message'].find(message => message.id === 4000));
+
+ const thread = this.env.models['mail.thread'].create({
+ id: 100,
+ model: 'mail.channel',
+ name: "General",
+ });
+ const message = this.env.models['mail.message'].create({
+ attachments: [['insert-and-replace', {
+ filename: "test.txt",
+ id: 750,
+ mimetype: 'text/plain',
+ name: "test.txt",
+ }]],
+ author: [['insert', { id: 5, display_name: "Demo" }]],
+ body: "Test
",
+ date: moment(str_to_datetime("2019-05-05 10:00:00")),
+ id: 4000,
+ originThread: [['link', thread]],
+ threadCaches: [['link', [
+ thread.mainCache,
+ this.env.messaging.inbox.mainCache,
+ this.env.messaging.starred.mainCache,
+ ]]],
+ });
+
+ assert.ok(this.env.models['mail.partner'].find(partner => partner.id === 5));
+ assert.ok(this.env.models['mail.thread'].find(thread =>
+ thread.id === 100 &&
+ thread.model === 'mail.channel'
+ ));
+ assert.ok(this.env.models['mail.attachment'].find(attachment => attachment.id === 750));
+ assert.ok(this.env.models['mail.message'].find(message => message.id === 4000));
+
+ assert.ok(message);
+ assert.strictEqual(this.env.models['mail.message'].find(message => message.id === 4000), message);
+ assert.strictEqual(message.body, "Test
");
+ assert.ok(message.date instanceof moment);
+ assert.strictEqual(
+ moment(message.date).utc().format('YYYY-MM-DD hh:mm:ss'),
+ "2019-05-05 10:00:00"
+ );
+ assert.strictEqual(message.id, 4000);
+ assert.strictEqual(message.originThread, this.env.models['mail.thread'].find(thread =>
+ thread.id === 100 &&
+ thread.model === 'mail.channel'
+ ));
+ assert.ok(
+ message.allThreads.includes(this.env.models['mail.thread'].find(thread =>
+ thread.id === 100 &&
+ thread.model === 'mail.channel'
+ ))
+ );
+ // from partnerId being in needaction_partner_ids
+ assert.ok(message.allThreads.includes(this.env.messaging.inbox));
+ // from partnerId being in starred_partner_ids
+ assert.ok(message.allThreads.includes(this.env.messaging.starred));
+ const attachment = this.env.models['mail.attachment'].find(attachment => attachment.id === 750);
+ assert.ok(attachment);
+ assert.strictEqual(attachment.filename, "test.txt");
+ assert.strictEqual(attachment.id, 750);
+ assert.notOk(attachment.isTemporary);
+ assert.strictEqual(attachment.mimetype, 'text/plain');
+ assert.strictEqual(attachment.name, "test.txt");
+ const channel = this.env.models['mail.thread'].find(thread =>
+ thread.id === 100 &&
+ thread.model === 'mail.channel'
+ );
+ assert.ok(channel);
+ assert.strictEqual(channel.model, 'mail.channel');
+ assert.strictEqual(channel.id, 100);
+ assert.strictEqual(channel.name, "General");
+ const partner = this.env.models['mail.partner'].find(partner => partner.id === 5);
+ assert.ok(partner);
+ assert.strictEqual(partner.display_name, "Demo");
+ assert.strictEqual(partner.id, 5);
+});
+
+});
+});
+});
+
+});
diff --git a/addons/mail/static/src/models/message_seen_indicator/message_seen_indicator.js b/addons/mail/static/src/models/message_seen_indicator/message_seen_indicator.js
new file mode 100644
index 00000000000..9ad56eb8da0
--- /dev/null
+++ b/addons/mail/static/src/models/message_seen_indicator/message_seen_indicator.js
@@ -0,0 +1,286 @@
+odoo.define('mail/static/src/models/message_seen_indicator/message_seen_indicator.js', function (require) {
+'use strict';
+
+const { registerNewModel } = require('mail/static/src/model/model_core.js');
+const { attr, many2many, many2one, one2many } = require('mail/static/src/model/model_field.js');
+
+function factory(dependencies) {
+
+ class MessageSeenIndicator extends dependencies['mail.model'] {
+
+ //----------------------------------------------------------------------
+ // Public
+ //----------------------------------------------------------------------
+
+ /**
+ * @static
+ * @param {mail.message|integer} message
+ * @param {mail.thread|integer} thread
+ * @returns {string|undefined}
+ */
+ static computeId(message, thread) {
+ if (message && thread) {
+ const messageId = Number.isInteger(message) ? message : message.id;
+ const threadId = Number.isInteger(thread) ? thread : thread.id;
+ return [messageId, threadId].join('-');
+ }
+ return undefined;
+ }
+
+ /**
+ * @static
+ * @param {mail.thread} [channel] the concerned thread
+ */
+ static recomputeFetchedValues(channel = undefined) {
+ const indicatorFindFunction = channel ? localIndicator => localIndicator.thread === channel : undefined;
+ const indicators = this.env.models['mail.message_seen_indicator'].all(indicatorFindFunction);
+ for (const indicator of indicators) {
+ indicator.update({
+ hasEveryoneFetched: indicator._computeHasEveryoneFetched(),
+ hasSomeoneFetched: indicator._computeHasSomeoneFetched(),
+ partnersThatHaveFetched: indicator._computePartnersThatHaveFetched(),
+ });
+ }
+ }
+
+ /**
+ * @static
+ * @param {mail.thread} [channel] the concerned thread
+ */
+ static recomputeSeenValues(channel = undefined) {
+ const indicatorFindFunction = channel ? localIndicator => localIndicator.thread === channel : undefined;
+ const indicators = this.env.models['mail.message_seen_indicator'].all(indicatorFindFunction);
+ for (const indicator of indicators) {
+ indicator.update({
+ hasEveryoneSeen: indicator._computeHasEveryoneSeen(),
+ hasSomeoneFetched: indicator._computeHasSomeoneFetched(),
+ hasSomeoneSeen: indicator._computeHasSomeoneSeen(),
+ isMessagePreviousToLastCurrentPartnerMessageSeenByEveryone:
+ indicator._computeIsMessagePreviousToLastCurrentPartnerMessageSeenByEveryone(),
+ partnersThatHaveFetched: indicator._computePartnersThatHaveFetched(),
+ partnersThatHaveSeen: indicator._computePartnersThatHaveSeen(),
+ });
+ }
+ }
+
+ //----------------------------------------------------------------------
+ // Private
+ //----------------------------------------------------------------------
+
+ /**
+ * Manually called as not always called when necessary
+ *
+ * @private
+ * @returns {boolean}
+ * @see computeFetchedValues
+ * @see computeSeenValues
+ */
+ _computeHasEveryoneFetched() {
+ if (!this.message || !this.thread || !this.thread.partnerSeenInfos) {
+ return false;
+ }
+ const otherPartnerSeenInfosDidNotFetch =
+ this.thread.partnerSeenInfos.filter(partnerSeenInfo =>
+ partnerSeenInfo.partner !== this.message.author &&
+ (
+ !partnerSeenInfo.lastFetchedMessage ||
+ partnerSeenInfo.lastFetchedMessage.id < this.message.id
+ )
+ );
+ return otherPartnerSeenInfosDidNotFetch.length === 0;
+ }
+
+ /**
+ * Manually called as not always called when necessary
+ *
+ * @private
+ * @returns {boolean}
+ * @see computeSeenValues
+ */
+ _computeHasEveryoneSeen() {
+ if (!this.message || !this.thread || !this.thread.partnerSeenInfos) {
+ return false;
+ }
+ const otherPartnerSeenInfosDidNotSee =
+ this.thread.partnerSeenInfos.filter(partnerSeenInfo =>
+ partnerSeenInfo.partner !== this.message.author &&
+ (
+ !partnerSeenInfo.lastSeenMessage ||
+ partnerSeenInfo.lastSeenMessage.id < this.message.id
+ )
+ );
+ return otherPartnerSeenInfosDidNotSee.length === 0;
+ }
+
+ /**
+ * Manually called as not always called when necessary
+ *
+ * @private
+ * @returns {boolean}
+ * @see computeFetchedValues
+ * @see computeSeenValues
+ */
+ _computeHasSomeoneFetched() {
+ if (!this.message || !this.thread || !this.thread.partnerSeenInfos) {
+ return false;
+ }
+ const otherPartnerSeenInfosFetched =
+ this.thread.partnerSeenInfos.filter(partnerSeenInfo =>
+ partnerSeenInfo.partner !== this.message.author &&
+ partnerSeenInfo.lastFetchedMessage &&
+ partnerSeenInfo.lastFetchedMessage.id >= this.message.id
+ );
+ return otherPartnerSeenInfosFetched.length > 0;
+ }
+
+ /**
+ * Manually called as not always called when necessary
+ *
+ * @private
+ * @returns {boolean}
+ * @see computeSeenValues
+ */
+ _computeHasSomeoneSeen() {
+ if (!this.message || !this.thread || !this.thread.partnerSeenInfos) {
+ return false;
+ }
+ const otherPartnerSeenInfosSeen =
+ this.thread.partnerSeenInfos.filter(partnerSeenInfo =>
+ partnerSeenInfo.partner !== this.message.author &&
+ partnerSeenInfo.lastSeenMessage &&
+ partnerSeenInfo.lastSeenMessage.id >= this.message.id
+ );
+ return otherPartnerSeenInfosSeen.length > 0;
+ }
+
+ /**
+ * Manually called as not always called when necessary
+ *
+ * @private
+ * @returns {boolean}
+ * @see computeSeenValues
+ */
+ _computeIsMessagePreviousToLastCurrentPartnerMessageSeenByEveryone() {
+ if (
+ !this.message ||
+ !this.thread ||
+ !this.thread.lastCurrentPartnerMessageSeenByEveryone
+ ) {
+ return false;
+ }
+ return this.message.id < this.thread.lastCurrentPartnerMessageSeenByEveryone.id;
+ }
+
+ /**
+ * Manually called as not always called when necessary
+ *
+ * @private
+ * @returns {mail.partner[]}
+ * @see computeFetchedValues
+ * @see computeSeenValues
+ */
+ _computePartnersThatHaveFetched() {
+ if (!this.message || !this.thread || !this.thread.partnerSeenInfos) {
+ return [['unlink-all']];
+ }
+ const otherPartnersThatHaveFetched = this.thread.partnerSeenInfos
+ .filter(partnerSeenInfo =>
+ partnerSeenInfo.partner !== this.message.author &&
+ partnerSeenInfo.lastFetchedMessage &&
+ partnerSeenInfo.lastFetchedMessage.id >= this.message.id
+ )
+ .map(partnerSeenInfo => partnerSeenInfo.partner);
+ if (otherPartnersThatHaveFetched.length === 0) {
+ return [['unlink-all']];
+ }
+ return [['replace', otherPartnersThatHaveFetched]];
+ }
+
+ /**
+ * Manually called as not always called when necessary
+ *
+ * @private
+ * @returns {mail.partner[]}
+ * @see computeSeenValues
+ */
+ _computePartnersThatHaveSeen() {
+ if (!this.message || !this.thread || !this.thread.partnerSeenInfos) {
+ return [['unlink-all']];
+ }
+ const otherPartnersThatHaveSeen = this.thread.partnerSeenInfos
+ .filter(partnerSeenInfo =>
+ partnerSeenInfo.partner !== this.message.author &&
+ partnerSeenInfo.lastSeenMessage &&
+ partnerSeenInfo.lastSeenMessage.id >= this.message.id)
+ .map(partnerSeenInfo => partnerSeenInfo.partner);
+ if (otherPartnersThatHaveSeen.length === 0) {
+ return [['unlink-all']];
+ }
+ return [['replace', otherPartnersThatHaveSeen]];
+ }
+ }
+
+ MessageSeenIndicator.modelName = 'mail.message_seen_indicator';
+
+ MessageSeenIndicator.fields = {
+ hasEveryoneFetched: attr({
+ compute: '_computeHasEveryoneFetched',
+ default: false,
+ dependencies: ['messageAuthor', 'messageId', 'threadPartnerSeenInfos'],
+ }),
+ hasEveryoneSeen: attr({
+ compute: '_computeHasEveryoneSeen',
+ default: false,
+ dependencies: ['messageAuthor', 'messageId', 'threadPartnerSeenInfos'],
+ }),
+ hasSomeoneFetched: attr({
+ compute: '_computeHasSomeoneFetched',
+ default: false,
+ dependencies: ['messageAuthor', 'messageId', 'threadPartnerSeenInfos'],
+ }),
+ hasSomeoneSeen: attr({
+ compute: '_computeHasSomeoneSeen',
+ default: false,
+ dependencies: ['messageAuthor', 'messageId', 'threadPartnerSeenInfos'],
+ }),
+ id: attr(),
+ isMessagePreviousToLastCurrentPartnerMessageSeenByEveryone: attr({
+ compute: '_computeIsMessagePreviousToLastCurrentPartnerMessageSeenByEveryone',
+ default: false,
+ dependencies: [
+ 'messageId',
+ 'threadLastCurrentPartnerMessageSeenByEveryone',
+ ],
+ }),
+ message: many2one('mail.message'),
+ messageAuthor: many2one('mail.partner', {
+ related: 'message.author',
+ }),
+ messageId: attr({
+ related: 'message.id',
+ }),
+ partnersThatHaveFetched: many2many('mail.partner', {
+ compute: '_computePartnersThatHaveFetched',
+ dependencies: ['messageAuthor', 'messageId', 'threadPartnerSeenInfos'],
+ }),
+ partnersThatHaveSeen: many2many('mail.partner', {
+ compute: '_computePartnersThatHaveSeen',
+ dependencies: ['messageAuthor', 'messageId', 'threadPartnerSeenInfos'],
+ }),
+ thread: many2one('mail.thread', {
+ inverse: 'messageSeenIndicators'
+ }),
+ threadPartnerSeenInfos: one2many('mail.thread_partner_seen_info', {
+ related: 'thread.partnerSeenInfos',
+ }),
+ threadLastCurrentPartnerMessageSeenByEveryone: many2one('mail.message', {
+ related: 'thread.lastCurrentPartnerMessageSeenByEveryone',
+ }),
+ };
+
+ return MessageSeenIndicator;
+}
+
+registerNewModel('mail.message_seen_indicator', factory);
+
+});
diff --git a/addons/mail/static/src/models/messaging/messaging.js b/addons/mail/static/src/models/messaging/messaging.js
new file mode 100644
index 00000000000..4cd4c22a82f
--- /dev/null
+++ b/addons/mail/static/src/models/messaging/messaging.js
@@ -0,0 +1,216 @@
+odoo.define('mail/static/src/models/messaging/messaging.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 Messaging extends dependencies['mail.model'] {
+
+ /**
+ * @override
+ */
+ delete() {
+ this.env.services['bus_service'].off('window_focus', null, this._handleGlobalWindowFocus);
+ super.delete();
+ }
+
+ //----------------------------------------------------------------------
+ // Public
+ //----------------------------------------------------------------------
+
+ /**
+ * Open the form view of the record with provided id and model.
+ *
+ * @param {Object} param0
+ * @param {integer} param0.id
+ * @param {string} param0.model
+ */
+ openDocument({ id, model }) {
+ this.env.bus.trigger('do-action', {
+ action: {
+ type: 'ir.actions.act_window',
+ res_model: model,
+ views: [[false, 'form']],
+ res_id: id,
+ },
+ });
+ this.messagingMenu.close();
+ }
+
+ /**
+ * Handles redirection to a model and id. Try to handle it in the context
+ * of messaging (e.g. open chat if this is a user), otherwise fallback to
+ * opening form view of record.
+ *
+ * @param {Object} param0
+ * @param {integer} param0.id
+ * @param {string} param0.model
+ * FIXME needs to be tested and maybe refactored (see task-2244279)
+ */
+ async redirect({ id, model }) {
+ if (model === 'mail.channel') {
+ const channel = this.env.models['mail.thread'].find(thread =>
+ thread.id === id &&
+ thread.model === 'mail.channel'
+ );
+ if (!channel || !channel.isPinned) {
+ this.env.models['mail.thread'].joinChannel(id, { autoselect: true });
+ return;
+ }
+ channel.open();
+ } else if (model === 'res.partner') {
+ if (id === this.currentPartner.id) {
+ this.openDocument({
+ model: 'res.partner',
+ id,
+ });
+ return;
+ }
+ const partner = this.env.models['mail.partner'].insert({ id });
+ if (!partner.user) {
+ await this.async(() => partner.checkIsUser());
+ }
+ if (!partner.user) {
+ // partner is not a user, open document instead
+ this.openDocument({
+ model: 'res.partner',
+ id: partner.id,
+ });
+ return;
+ }
+ const chat = partner.correspondentThreads.find(thread =>
+ thread.channel_type === 'chat'
+ );
+ if (!chat) {
+ this.env.models['mail.thread'].createChannel({
+ autoselect: true,
+ partnerId: id,
+ type: 'chat',
+ });
+ return;
+ }
+ chat.open();
+ } else {
+ this.openDocument({
+ model: 'res.partner',
+ id,
+ });
+ }
+ }
+
+ /**
+ * Start messaging and related records.
+ */
+ async start() {
+ this._handleGlobalWindowFocus = this._handleGlobalWindowFocus.bind(this);
+ this.env.services['bus_service'].on('window_focus', null, this._handleGlobalWindowFocus);
+ await this.async(() => this.initializer.start());
+ this.notificationHandler.start();
+ this.update({ isInitialized: true });
+ }
+
+ //----------------------------------------------------------------------
+ // Private
+ //----------------------------------------------------------------------
+
+ /**
+ * @private
+ */
+ _handleGlobalWindowFocus() {
+ this.update({ outOfFocusUnreadMessageCounter: 0 });
+ this.env.bus.trigger('set_title_part', {
+ part: '_chat',
+ });
+ }
+
+ }
+
+ Messaging.fields = {
+ attachmentViewer: one2one('mail.attachment_viewer', {
+ autocreate: true,
+ isCausal: true,
+ }),
+ cannedResponses: attr({
+ default: {},
+ }),
+ chatWindowManager: one2one('mail.chat_window_manager', {
+ autocreate: true,
+ inverse: 'messaging',
+ isCausal: true,
+ }),
+ commands: attr({
+ default: {},
+ }),
+ currentPartner: one2one('mail.partner'),
+ currentUser: one2one('mail.user'),
+ device: one2one('mail.device', {
+ autocreate: true,
+ isCausal: true,
+ }),
+ dialogManager: one2one('mail.dialog_manager', {
+ autocreate: true,
+ isCausal: true,
+ }),
+ discuss: one2one('mail.discuss', {
+ autocreate: true,
+ isCausal: true,
+ }),
+ /**
+ * Mailbox History.
+ */
+ history: one2one('mail.thread'),
+ /**
+ * Mailbox Inbox.
+ */
+ inbox: one2one('mail.thread'),
+ initializer: one2one('mail.messaging_initializer', {
+ autocreate: true,
+ inverse: 'messaging',
+ isCausal: true,
+ }),
+ isInitialized: attr({
+ default: false,
+ }),
+ locale: one2one('mail.locale', {
+ autocreate: true,
+ isCausal: true,
+ }),
+ messagingMenu: one2one('mail.messaging_menu', {
+ autocreate: true,
+ inverse: 'messaging',
+ isCausal: true,
+ }),
+ /**
+ * Mailbox Moderation.
+ */
+ moderation: one2one('mail.thread'),
+ notificationGroupManager: one2one('mail.notification_group_manager', {
+ autocreate: true,
+ isCausal: true,
+ }),
+ notificationHandler: one2one('mail.messaging_notification_handler', {
+ autocreate: true,
+ inverse: 'messaging',
+ isCausal: true,
+ }),
+ outOfFocusUnreadMessageCounter: attr({
+ default: 0,
+ }),
+ partnerRoot: many2one('mail.partner'),
+ publicPartner: many2one('mail.partner'),
+ /**
+ * Mailbox Starred.
+ */
+ starred: one2one('mail.thread'),
+ };
+
+ Messaging.modelName = 'mail.messaging';
+
+ return Messaging;
+}
+
+registerNewModel('mail.messaging', factory);
+
+});
diff --git a/addons/mail/static/src/models/messaging/messaging_tests.js b/addons/mail/static/src/models/messaging/messaging_tests.js
new file mode 100644
index 00000000000..11a7b1c5779
--- /dev/null
+++ b/addons/mail/static/src/models/messaging/messaging_tests.js
@@ -0,0 +1,66 @@
+odoo.define('mail/static/src/models/messaging/messaging_tests.js', function (require) {
+'use strict';
+
+const {
+ afterEach: utilsAfterEach,
+ beforeEach: utilsBeforeEach,
+ start: utilsStart,
+} = require('mail/static/src/utils/test_utils.js');
+
+QUnit.module('mail', {}, function () {
+QUnit.module('models', {}, function () {
+QUnit.module('messaging', {}, function () {
+QUnit.module('messaging_tests.js', {
+ beforeEach() {
+ utilsBeforeEach(this);
+
+ this.start = async params => {
+ if (this.widget) {
+ this.widget.destroy();
+ }
+ const { env, widget } = await utilsStart(Object.assign({}, params, {
+ data: this.data,
+ }));
+ this.env = env;
+ this.widget = widget;
+ };
+ },
+ afterEach() {
+ utilsAfterEach(this);
+ this.env = undefined;
+ if (this.widget) {
+ this.widget.destroy();
+ this.widget = undefined;
+ }
+ },
+});
+
+QUnit.test('currentPartner initialized from session', async function (assert) {
+ assert.expect(5);
+
+ await this.start();
+
+ assert.ok(this.env.messaging.currentPartner);
+ assert.strictEqual(
+ this.env.messaging.currentPartner.id,
+ this.env.session.partner_id
+ );
+ assert.strictEqual(
+ this.env.messaging.currentPartner.name,
+ this.env.session.name
+ );
+ assert.strictEqual(
+ this.env.messaging.currentPartner.display_name,
+ this.env.session.partner_display_name
+ );
+ assert.strictEqual(
+ this.env.messaging.currentPartner.user.id,
+ this.env.session.uid
+ );
+});
+
+});
+});
+});
+
+});
diff --git a/addons/mail/static/src/models/messaging_initializer/messaging_initializer.js b/addons/mail/static/src/models/messaging_initializer/messaging_initializer.js
new file mode 100644
index 00000000000..3cee3777d3a
--- /dev/null
+++ b/addons/mail/static/src/models/messaging_initializer/messaging_initializer.js
@@ -0,0 +1,292 @@
+odoo.define('mail/static/src/models/messaging_initializer/messaging_initializer.js', function (require) {
+'use strict';
+
+const { registerNewModel } = require('mail/static/src/model/model_core.js');
+const { one2one } = require('mail/static/src/model/model_field.js');
+
+function factory(dependencies) {
+
+ class MessagingInitializer extends dependencies['mail.model'] {
+
+ //----------------------------------------------------------------------
+ // Public
+ //----------------------------------------------------------------------
+
+ /**
+ * Fetch messaging data initially to populate the store specifically for
+ * the current user. This includes pinned channels for instance.
+ */
+ async start() {
+ await this.async(() => this.env.session.is_bound);
+
+ this.messaging.update({
+ history: [['create', {
+ id: 'history',
+ isPinned: true,
+ model: 'mail.box',
+ name: this.env._t("History"),
+ }]],
+ inbox: [['create', {
+ id: 'inbox',
+ isPinned: true,
+ model: 'mail.box',
+ name: this.env._t("Inbox"),
+ }]],
+ starred: [['create', {
+ id: 'starred',
+ isPinned: true,
+ model: 'mail.box',
+ name: this.env._t("Starred"),
+ }]],
+ });
+
+ const device = this.messaging.device;
+ device.start();
+ const context = Object.assign({
+ isMobile: device.isMobile,
+ }, this.env.session.user_context);
+ const discuss = this.messaging.discuss;
+ const data = await this.async(() => this.env.services.rpc({
+ route: '/mail/init_messaging',
+ params: { context: context }
+ }));
+ this._init(data);
+ if (discuss.isOpen) {
+ discuss.openInitThread();
+ }
+ if (this.env.autofetchPartnerImStatus) {
+ this.env.models['mail.partner'].startLoopFetchImStatus();
+ }
+ }
+
+ //----------------------------------------------------------------------
+ // Private
+ //----------------------------------------------------------------------
+
+ /**
+ * @private
+ * @param {Object} param0
+ * @param {Object} param0.channel_slots
+ * @param {Array} [param0.commands=[]]
+ * @param {boolean} [param0.is_moderator=false]
+ * @param {Object} [param0.mail_failures={}]
+ * @param {Object[]} [param0.mention_partner_suggestions=[]]
+ * @param {Object[]} [param0.moderation_channel_ids=[]]
+ * @param {integer} [param0.moderation_counter=0]
+ * @param {integer} [param0.needaction_inbox_counter=0]
+ * @param {Array} param0.partner_root
+ * @param {Object[]} [param0.shortcodes=[]]
+ * @param {integer} [param0.starred_counter=0]
+ */
+ _init({
+ channel_slots,
+ commands = [],
+ is_moderator = false,
+ mail_failures = {},
+ mention_partner_suggestions = [],
+ menu_id,
+ moderation_channel_ids = [],
+ moderation_counter = 0,
+ needaction_inbox_counter = 0,
+ partner_root,
+ public_partner,
+ shortcodes = [],
+ starred_counter = 0
+ }) {
+ const discuss = this.messaging.discuss;
+ this._initPartners({
+ moderation_channel_ids,
+ partner_root,
+ public_partner,
+ });
+ this._initChannels(channel_slots);
+ this._initCommands(commands);
+ this._initMailboxes({
+ is_moderator,
+ moderation_counter,
+ needaction_inbox_counter,
+ starred_counter,
+ });
+ this._initMailFailures(mail_failures);
+ this._initCannedResponses(shortcodes);
+ this._initMentionPartnerSuggestions(mention_partner_suggestions);
+ discuss.update({ menu_id });
+ }
+
+ /**
+ * @private
+ * @param {Object[]} shortcodes
+ */
+ _initCannedResponses(shortcodes) {
+ const messaging = this.messaging;
+ const cannedResponses = shortcodes
+ .map(s => {
+ const { id, source, substitution } = s;
+ return { id, source, substitution };
+ })
+ .reduce((obj, cr) => {
+ obj[cr.id] = cr;
+ return obj;
+ }, {});
+ messaging.update({ cannedResponses });
+ }
+
+ /**
+ * @private
+ * @param {Object} [param0={}]
+ * @param {Object[]} [param0.channel_channel=[]]
+ * @param {Object[]} [param0.channel_direct_message=[]]
+ * @param {Object[]} [param0.channel_private_group=[]]
+ */
+ _initChannels({
+ channel_channel = [],
+ channel_direct_message = [],
+ channel_private_group = [],
+ } = {}) {
+ for (const data of channel_channel.concat(channel_direct_message, channel_private_group)) {
+ this.env.models['mail.thread'].insert(Object.assign(
+ {},
+ this.env.models['mail.thread'].convertData(data),
+ { isPinned: true }
+ ));
+ }
+ }
+
+ /**
+ * @private
+ * @param {Object[]} commandsData
+ */
+ _initCommands(commandsData) {
+ const messaging = this.messaging;
+ const commands = commandsData
+ .map(command => {
+ return Object.assign({
+ id: command.name,
+ }, command);
+ })
+ .reduce((obj, command) => {
+ obj[command.id] = command;
+ return obj;
+ }, {});
+ messaging.update({ commands });
+ }
+
+ /**
+ * @private
+ * @param {Object} param0
+ * @param {boolean} param0.is_moderator
+ * @param {integer} param0.moderation_counter
+ * @param {integer} param0.needaction_inbox_counter
+ * @param {integer} param0.starred_counter
+ */
+ _initMailboxes({
+ is_moderator,
+ moderation_counter,
+ needaction_inbox_counter,
+ starred_counter,
+ }) {
+ this.env.messaging.inbox.update({ counter: needaction_inbox_counter });
+ this.env.messaging.starred.update({ counter: starred_counter });
+ if (is_moderator) {
+ this.messaging.update({
+ moderation: [['create', {
+ counter: moderation_counter,
+ id: 'moderation',
+ isPinned: true,
+ model: 'mail.box',
+ name: this.env._t("Moderation"),
+ }]],
+ });
+ }
+ }
+
+ /**
+ * @private
+ * @param {Object} mailFailuresData
+ */
+ _initMailFailures(mailFailuresData) {
+ for (const messageData of mailFailuresData) {
+ const message = this.env.models['mail.message'].insert(
+ this.env.models['mail.message'].convertData(messageData)
+ );
+ // implicit: failures are sent by the server at initialization
+ // only if the current partner is author of the message
+ if (!message.author && this.messaging.currentPartner) {
+ message.update({ author: [['link', this.messaging.currentPartner]] });
+ }
+ }
+ this.messaging.notificationGroupManager.computeGroups();
+ // manually force recompute of counter (after computing the groups)
+ this.messaging.messagingMenu.update();
+ }
+
+ /**
+ * @private
+ * @param {Object[]} mentionPartnerSuggestionsData
+ */
+ _initMentionPartnerSuggestions(mentionPartnerSuggestionsData) {
+ for (const suggestions of mentionPartnerSuggestionsData) {
+ for (const suggestion of suggestions) {
+ const { email, id, name } = suggestion;
+ this.env.models['mail.partner'].insert({ email, id, name });
+ }
+ }
+ }
+
+ /**
+ * @private
+ * @param {Array} param0 partner root name get
+ * @param {integer} param0[0] partner root id
+ * @param {string} param0[1] partner root display_name
+ */
+ _initPartners({
+ moderation_channel_ids = [],
+ partner_root: {
+ active: partnerRootIsActive,
+ display_name: partnerRootDisplayName,
+ id: partnerRootId,
+ },
+ public_partner: {
+ active: publicPartnerIsActive,
+ display_name: publicPartnerDisplayName,
+ id: publicPartnerId,
+ },
+ }) {
+ this.messaging.update({
+ currentPartner: [['insert', {
+ display_name: this.env.session.partner_display_name,
+ id: this.env.session.partner_id,
+ moderatedChannelIds: moderation_channel_ids,
+ name: this.env.session.name,
+ user: [['insert', { id: this.env.session.uid }]],
+ }]],
+ currentUser: [['insert', { id: this.env.session.uid }]],
+ partnerRoot: [['insert', {
+ active: partnerRootIsActive,
+ display_name: partnerRootDisplayName,
+ id: partnerRootId,
+ }]],
+ publicPartner: [['insert', {
+ active: publicPartnerIsActive,
+ display_name: publicPartnerDisplayName,
+ id: publicPartnerId,
+ }]],
+ });
+ }
+
+ }
+
+ MessagingInitializer.fields = {
+ messaging: one2one('mail.messaging', {
+ inverse: 'initializer',
+ }),
+ };
+
+ MessagingInitializer.modelName = 'mail.messaging_initializer';
+
+ return MessagingInitializer;
+}
+
+registerNewModel('mail.messaging_initializer', factory);
+
+});
diff --git a/addons/mail/static/src/models/messaging_menu/messaging_menu.js b/addons/mail/static/src/models/messaging_menu/messaging_menu.js
new file mode 100644
index 00000000000..c8e52733ff7
--- /dev/null
+++ b/addons/mail/static/src/models/messaging_menu/messaging_menu.js
@@ -0,0 +1,131 @@
+odoo.define('mail/static/src/models/messaging_menu/messaging_menu.js', function (require) {
+'use strict';
+
+const { registerNewModel } = require('mail/static/src/model/model_core.js');
+const { attr, one2one } = require('mail/static/src/model/model_field.js');
+
+function factory(dependencies) {
+
+ class MessagingMenu extends dependencies['mail.model'] {
+
+ //----------------------------------------------------------------------
+ // Public
+ //----------------------------------------------------------------------
+
+ /**
+ * Close the messaging menu. Should reset its internal state.
+ */
+ close() {
+ this.update({
+ activeTabId: 'all',
+ isMobileNewMessageToggled: false,
+ isOpen: false,
+ });
+ }
+
+ /**
+ * Toggle the visibility of the messaging menu "new message" input in
+ * mobile.
+ */
+ toggleMobileNewMessage() {
+ this.update({ isMobileNewMessageToggled: !this.isMobileNewMessageToggled });
+ }
+
+ /**
+ * Toggle whether the messaging menu is open or not.
+ */
+ async toggleOpen() {
+ if (!this.isOpen) {
+ const inbox = this.env.messaging.inbox;
+ if (!inbox.mainCache.isLoaded && !inbox.mainCache.isLoading) {
+ // populate some needaction messages on threads.
+ // FIXME: await necessary due to bug in tests without it
+ // see task-id 2275999
+ await this.async(() => inbox.mainCache.loadMessages());
+ }
+ }
+ this.update({ isOpen: !this.isOpen });
+ }
+
+ //----------------------------------------------------------------------
+ // Private
+ //----------------------------------------------------------------------
+
+ /**
+ * @private
+ * @returns {integer}
+ */
+ _updateCounter() {
+ if (!this.env.messaging) {
+ return 0;
+ }
+ const inboxMailbox = this.env.messaging.inbox;
+ const unreadChannels = this.env.models['mail.thread'].all(thread =>
+ thread.message_unread_counter > 0 &&
+ thread.model === 'mail.channel'
+ );
+ let counter = unreadChannels.length;
+ if (inboxMailbox) {
+ counter += inboxMailbox.counter;
+ }
+ if (!this.messaging) {
+ // compute after delete
+ return counter;
+ }
+ if (this.messaging.notificationGroupManager) {
+ counter += this.messaging.notificationGroupManager.groups.reduce(
+ (total, group) => total + group.notifications.length,
+ 0
+ );
+ }
+ return counter;
+ }
+
+ /**
+ * @override
+ */
+ _updateAfter(previous) {
+ const counter = this._updateCounter();
+ if (this.counter !== counter) {
+ this.update({ counter });
+ }
+ }
+
+ }
+
+ MessagingMenu.fields = {
+ /**
+ * Tab selected in the messaging menu.
+ * Either 'all', 'chat' or 'channel'.
+ */
+ activeTabId: attr({
+ default: 'all',
+ }),
+ counter: attr({
+ default: 0,
+ }),
+ /**
+ * Determine whether the mobile new message input is visible or not.
+ */
+ isMobileNewMessageToggled: attr({
+ default: false,
+ }),
+ /**
+ * Determine whether the messaging menu dropdown is open or not.
+ */
+ isOpen: attr({
+ default: false,
+ }),
+ messaging: one2one('mail.messaging', {
+ inverse: 'messagingMenu',
+ }),
+ };
+
+ MessagingMenu.modelName = 'mail.messaging_menu';
+
+ return MessagingMenu;
+}
+
+registerNewModel('mail.messaging_menu', factory);
+
+});
diff --git a/addons/mail/static/src/models/messaging_notification_handler/messaging_notification_handler.js b/addons/mail/static/src/models/messaging_notification_handler/messaging_notification_handler.js
new file mode 100644
index 00000000000..e779b5a20ff
--- /dev/null
+++ b/addons/mail/static/src/models/messaging_notification_handler/messaging_notification_handler.js
@@ -0,0 +1,744 @@
+odoo.define('mail/static/src/models/messaging_notification_handler/messaging_notification_handler.js', function (require) {
+'use strict';
+
+const { registerNewModel } = require('mail/static/src/model/model_core.js');
+const { one2one } = require('mail/static/src/model/model_field.js');
+
+const PREVIEW_MSG_MAX_SIZE = 350; // optimal for native English speakers
+
+function factory(dependencies) {
+
+ class MessagingNotificationHandler extends dependencies['mail.model'] {
+
+ /**
+ * @override
+ */
+ delete() {
+ this.env.services['bus_service'].off('notification');
+ this.env.services['bus_service'].stopPolling();
+ super.delete();
+ }
+
+ //----------------------------------------------------------------------
+ // Public
+ //----------------------------------------------------------------------
+
+ /**
+ * Fetch messaging data initially to populate the store specifically for
+ * the current users. This includes pinned channels for instance.
+ */
+ start() {
+ this.env.services.bus_service.onNotification(null, notifs => this._handleNotifications(notifs));
+ this.env.services.bus_service.startPolling();
+ }
+
+ //----------------------------------------------------------------------
+ // Private
+ //----------------------------------------------------------------------
+
+ /**
+ * @private
+ * @param {Object[]} notifications
+ * @returns {Object[]}
+ */
+ _filterNotificationsOnUnsubscribe(notifications) {
+ const unsubscribedNotif = notifications.find(notif =>
+ notif[1].info === 'unsubscribe');
+ if (unsubscribedNotif) {
+ notifications = notifications.filter(notif =>
+ notif[0][1] !== 'mail.channel' ||
+ notif[0][2] !== unsubscribedNotif[1].id
+ );
+ }
+ return notifications;
+ }
+
+ /**
+ * @private
+ * @param {Object[]} notifications
+ * @param {Array} notifications[i][0] meta-data of the notification.
+ * @param {string} notifications[i][0][0] name of database this
+ * notification comes from.
+ * @param {string} notifications[i][0][1] type of notification.
+ * @param {integer} notifications[i][0][2] usually id of related type
+ * of notification. For instance, with `mail.channel`, this is the id
+ * of the channel.
+ * @param {Object} notifications[i][1] payload of the notification
+ */
+ async _handleNotifications(notifications) {
+ const filteredNotifications = this._filterNotificationsOnUnsubscribe(notifications);
+ const proms = filteredNotifications.map(notification => {
+ const [[, model, id], data] = notification;
+ switch (model) {
+ case 'ir.needaction':
+ return this._handleNotificationNeedaction(data);
+ case 'mail.channel':
+ return this._handleNotificationChannel(Object.assign({
+ channelId: id,
+ }, data));
+ case 'res.partner':
+ return this._handleNotificationPartner(Object.assign({}, data));
+ default:
+ console.warn(`mail.messaging_notification_handler: Unhandled notification "${model}"`);
+ return;
+ }
+ });
+ await this.async(() => Promise.all(proms));
+ }
+
+ /**
+ * @private
+ * @param {Object} data
+ * @param {integer} data.channelId
+ * @param {string} [data.info]
+ * @param {boolean} [data.is_typing]
+ * @param {integer} [data.last_message_id]
+ * @param {integer} [data.partner_id]
+ */
+ async _handleNotificationChannel(data) {
+ const {
+ channelId,
+ id,
+ info,
+ is_typing,
+ last_message_id,
+ partner_id,
+ } = data;
+ switch (info) {
+ case 'channel_fetched':
+ return this._handleNotificationChannelFetched({
+ id,
+ channelId,
+ last_message_id,
+ partner_id,
+ });
+ case 'channel_seen':
+ return this._handleNotificationChannelSeen({
+ id,
+ channelId,
+ last_message_id,
+ partner_id,
+ });
+ case 'typing_status':
+ return this._handleNotificationChannelTypingStatus({
+ channelId,
+ is_typing,
+ partner_id,
+ });
+ default:
+ return this._handleNotificationChannelMessage(data);
+ }
+ }
+
+ /**
+ * @private
+ * @param {Object} param0
+ * @param {integer} param0.channelId
+ * @param {integer} param0.id
+ * @param {integer} param0.last_message_id
+ * @param {integer} param0.partner_id
+ */
+ async _handleNotificationChannelFetched({
+ id,
+ channelId,
+ last_message_id,
+ partner_id,
+ }) {
+ const channel = this.env.models['mail.thread'].find(thread =>
+ thread.id === channelId && thread.model === 'mail.channel'
+ );
+ if (!channel) {
+ // for example seen from another browser, the current one has no
+ // knowledge of the channel
+ return;
+ }
+ channel.update({
+ partnerSeenInfos: [['insert',
+ {
+ lastFetchedMessage: [['insert', {id: last_message_id}]],
+ id,
+ partner: [['insert', {id: partner_id}]],
+ }
+ ]],
+ messageSeenIndicators: [['insert',
+ {
+ id: this.env.models['mail.message_seen_indicator'].computeId(last_message_id, channel.id),
+ message: [['insert', {id: last_message_id}]],
+ }
+ ]],
+ });
+ // FIXME force the computing of message values (cf task-2261221)
+ this.env.models['mail.message_seen_indicator'].recomputeFetchedValues(channel);
+ }
+
+ /**
+ * @private
+ * @param {Object} param0
+ * @param {integer} param0.channelId
+ * @param {...Object} param0.data
+ * @param {Array} [param0.data.author_id]
+ * @param {integer} param0.data.author_id[0]
+ * @param {integer[]} param0.data.channel_ids
+ */
+ async _handleNotificationChannelMessage(param0) {
+ const channelId = param0.channelId;
+ const convertedData = this.env.models['mail.message'].convertData(param0);
+
+ const oldMessage = this.env.models['mail.message'].find(message =>
+ message.id === convertedData.id
+ );
+ // locally save old value, as insert would overwrite them
+ const oldMessageModerationStatus = (
+ oldMessage && oldMessage.moderation_status
+ );
+ const oldMessageWasModeratedByCurrentPartner = (
+ oldMessage && oldMessage.isModeratedByCurrentPartner
+ );
+
+ const message = this.env.models['mail.message'].insert(convertedData);
+
+ // join the corresponding channel if necessary
+ const channel = this.env.models['mail.thread'].find(thread =>
+ thread.id === channelId &&
+ thread.model === 'mail.channel'
+ );
+ if (!channel || !channel.isPinned) {
+ this.env.models['mail.thread'].joinChannel(channelId);
+ }
+
+ // If the message was already known: nothing else should be done,
+ // except if it was pending moderation by the current partner, then
+ // decrement the moderation counter.
+ if (oldMessage) {
+ if (
+ oldMessageModerationStatus === 'pending_moderation' &&
+ message.moderation_status !== 'pending_moderation' &&
+ oldMessageWasModeratedByCurrentPartner
+ ) {
+ const moderation = this.env.messaging.moderation;
+ moderation.update({ counter: moderation.counter - 1 });
+ }
+ return;
+ }
+
+ // If the current partner is author, do nothing else.
+ if (message.author === this.env.messaging.currentPartner) {
+ return;
+ }
+
+ // If the channel wasn't known, joining the channel already updated
+ // the counter.
+ if (!channel) {
+ return;
+ }
+
+ // Message from mailing channel should not make a notification in
+ // Odoo for users with notification "Handled by Email".
+ // Channel has been marked as read server-side in this case, so
+ // it should not display a notification by incrementing the
+ // unread counter.
+ if (
+ channel &&
+ channel.mass_mailing &&
+ this.env.session.notification_type === 'email'
+ ) {
+ return;
+ }
+
+ // In all other cases: update counter and notify if out of focus.
+ const isOdooFocused = this.env.services['bus_service'].isOdooFocused();
+ if (!isOdooFocused) {
+ this._notifyNewChannelMessageWhileOutOfFocus({ channel, message });
+ }
+ if (channel.model === 'mail.channel') {
+ channel.markAsFetched();
+ }
+ channel.update({ message_unread_counter: channel.message_unread_counter + 1 });
+ // manually force recompute of counter
+ this.messaging.messagingMenu.update();
+ }
+
+ /**
+ * Called when a channel has been seen, and the server responses with the
+ * last message seen. Useful in order to track last message seen.
+ *
+ * @private
+ * @param {Object} param0
+ * @param {integer} param0.channelId
+ * @param {integer} param0.id
+ * @param {integer} param0.last_message_id
+ * @param {integer} param0.partner_id
+ */
+ async _handleNotificationChannelSeen({
+ channelId,
+ id,
+ last_message_id,
+ partner_id,
+ }) {
+ const channel = this.env.models['mail.thread'].find(thread =>
+ thread.id === channelId && thread.model === 'mail.channel'
+ );
+ if (!channel) {
+ // for example seen from another browser, the current one has no
+ // knowledge of the channel
+ return;
+ }
+ const lastMessage = this.env.models['mail.message'].insert({id: last_message_id});
+ const updateData = {
+ partnerSeenInfos: [['insert',
+ {
+ id,
+ lastFetchedMessage: [['link', lastMessage]],
+ lastSeenMessage: [['link', lastMessage]],
+ partner: [['insert', {id: partner_id}]],
+ }],
+ ],
+ messageSeenIndicators: [['insert',
+ {
+ id: this.env.models['mail.message_seen_indicator'].computeId(last_message_id, channel.id),
+ message: [['link', lastMessage]],
+ }
+ ]],
+ };
+ if (this.env.messaging.currentPartner.id === partner_id) {
+ Object.assign(updateData, {
+ message_unread_counter: 0,
+ seen_message_id: last_message_id,
+ });
+ }
+ channel.update(updateData);
+ // FIXME force the computing of thread values (cf task-2261221)
+ this.env.models['mail.thread'].computeLastCurrentPartnerMessageSeenByEveryone(channel);
+ // FIXME force the computing of message values (cf task-2261221)
+ this.env.models['mail.message_seen_indicator'].recomputeSeenValues(channel);
+ // manually force recompute of counter
+ this.messaging.messagingMenu.update();
+ }
+
+ /**
+ * @private
+ * @param {Object} param0
+ * @param {integer} param0.channelId
+ * @param {boolean} param0.is_typing
+ * @param {integer} param0.partner_id
+ */
+ _handleNotificationChannelTypingStatus({
+ channelId,
+ is_typing,
+ partner_id,
+ }) {
+ const channel = this.env.models['mail.thread'].insert({
+ id: channelId,
+ model: 'mail.channel',
+ });
+ const partner = this.env.models['mail.partner'].insert({ id: partner_id });
+ if (partner === this.env.messaging.currentPartner) {
+ // Ignore management of current partner is typing notification.
+ return;
+ }
+ if (is_typing) {
+ if (channel.typingMembers.includes(partner)) {
+ channel.refreshOtherMemberTypingMember(partner);
+ } else {
+ channel.registerOtherMemberTypingMember(partner);
+ }
+ } else {
+ if (!channel.typingMembers.includes(partner)) {
+ // Ignore no longer typing notifications of members that
+ // are not registered as typing something.
+ return;
+ }
+ channel.unregisterOtherMemberTypingMember(partner);
+ }
+ }
+
+ /**
+ * @private
+ * @param {Object} data
+ */
+ _handleNotificationNeedaction(data) {
+ const message = this.env.models['mail.message'].insert(
+ this.env.models['mail.message'].convertData(data)
+ );
+ const inboxMailbox = this.env.messaging.inbox;
+ inboxMailbox.update({ counter: inboxMailbox.counter + 1 });
+ for (const thread of message.allThreads) {
+ if (
+ thread.channel_type === 'channel' &&
+ message.allThreads.includes(inboxMailbox)
+ ) {
+ thread.update({ message_needaction_counter: thread.message_needaction_counter + 1 });
+ }
+ const mainThreadCache = thread.mainCache;
+ mainThreadCache.update({ messages: [['link', message]] });
+ }
+ // manually force recompute of counter
+ this.messaging.messagingMenu.update();
+ }
+
+ /**
+ * @private
+ * @param {Object} data
+ * @param {string} [data.info]
+ * @param {string} [data.type]
+ */
+ async _handleNotificationPartner(data) {
+ const {
+ info,
+ type,
+ } = data;
+ if (type === 'activity_updated') {
+ this.env.bus.trigger('activity_updated', data);
+ } else if (type === 'author') {
+ return this._handleNotificationPartnerAuthor(data);
+ } else if (type === 'deletion') {
+ return this._handleNotificationPartnerDeletion(data);
+ } else if (type === 'message_notification_update') {
+ return this._handleNotificationPartnerMessageNotificationUpdate(data.elements);
+ } else if (type === 'mark_as_read') {
+ return this._handleNotificationPartnerMarkAsRead(data);
+ } else if (type === 'moderator') {
+ return this._handleNotificationPartnerModerator(data);
+ } else if (type === 'simple_notification') {
+ const escapedTitle = owl.utils.escape(data.title);
+ const escapedMessage = owl.utils.escape(data.message);
+ this.env.services['notification'].notify({
+ message: escapedMessage,
+ sticky: data.sticky,
+ title: escapedTitle,
+ type: data.warning ? 'warning' : 'danger',
+ });
+ } else if (type === 'toggle_star') {
+ return this._handleNotificationPartnerToggleStar(data);
+ } else if (info === 'transient_message') {
+ return this._handleNotificationPartnerTransientMessage(data);
+ } else if (info === 'unsubscribe') {
+ return this._handleNotificationPartnerUnsubscribe(data.id);
+ } else if (type === 'user_connection') {
+ return this._handleNotificationPartnerUserConnection(data);
+ } else {
+ return this._handleNotificationPartnerChannel(data);
+ }
+ }
+
+ /**
+ * @private
+ * @param {Object} data
+ * @param {Object} data.message
+ */
+ _handleNotificationPartnerAuthor(data) {
+ this.env.models['mail.message'].insert(
+ this.env.models['mail.message'].convertData(data.message)
+ );
+ }
+
+ /**
+ * @private
+ * @param {Object} data
+ * @param {string} data.channel_type
+ * @param {integer} data.id
+ * @param {string} [data.info]
+ * @param {boolean} data.is_minimized
+ * @param {string} data.name
+ * @param {string} data.state
+ * @param {string} data.uuid
+ */
+ _handleNotificationPartnerChannel(data) {
+ const convertedData = this.env.models['mail.thread'].convertData(data);
+
+ let channel = this.env.models['mail.thread'].find(thread =>
+ thread.id === convertedData.id &&
+ thread.model === 'mail.channel'
+ );
+ const wasCurrentPartnerMember = (
+ channel &&
+ channel.members.includes(this.env.messaging.currentPartner)
+ );
+
+ channel = this.env.models['mail.thread'].insert(Object.assign(
+ { isPinned: true },
+ convertedData,
+ ));
+
+ if (
+ channel.channel_type === 'channel' &&
+ data.info !== 'creation' &&
+ !wasCurrentPartnerMember
+ ) {
+ this.env.services['notification'].notify({
+ message: _.str.sprintf(
+ this.env._t("You have been invited to: %s"),
+ owl.utils.escape(channel.name)
+ ),
+ title: this.env._t("Invitation"),
+ type: 'warning',
+ });
+ }
+ // a new thread with unread messages could have been added
+ // manually force recompute of counter
+ this.messaging.messagingMenu.update();
+ }
+
+ /**
+ * @private
+ * @param {Object} param0
+ * @param {integer[]} param0.messag_ids
+ */
+ _handleNotificationPartnerDeletion({ message_ids }) {
+ const moderationMailbox = this.env.messaging.moderation;
+ for (const id of message_ids) {
+ const message = this.env.models['mail.message'].find(message => message.id === id);
+ if (message) {
+ if (
+ message.moderation_status === 'pending_moderation' &&
+ message.originThread.isModeratedByCurrentPartner
+ ) {
+ moderationMailbox.update({ counter: moderationMailbox.counter - 1 });
+ }
+ message.delete();
+ }
+ }
+ // deleting message might have deleted notifications, force recompute
+ this.messaging.notificationGroupManager.computeGroups();
+ // manually force recompute of counter (after computing the groups)
+ this.messaging.messagingMenu.update();
+ }
+
+ /**
+ * @private
+ * @param {Object} data
+ */
+ _handleNotificationPartnerMessageNotificationUpdate(data) {
+ for (const messageData of data) {
+ const message = this.env.models['mail.message'].insert(
+ this.env.models['mail.message'].convertData(messageData)
+ );
+ // implicit: failures are sent by the server as notification
+ // only if the current partner is author of the message
+ if (!message.author && this.messaging.currentPartner) {
+ message.update({ author: [['link', this.messaging.currentPartner]] });
+ }
+ }
+ this.messaging.notificationGroupManager.computeGroups();
+ // manually force recompute of counter (after computing the groups)
+ this.messaging.messagingMenu.update();
+ }
+
+ /**
+ * @private
+ * @param {Object} param0
+ * @param {integer[]} [param0.channel_ids
+ * @param {integer[]} [param0.message_ids=[]]
+ */
+ _handleNotificationPartnerMarkAsRead({ channel_ids, message_ids = [] }) {
+ const inboxMailbox = this.env.messaging.inbox;
+ const historyMailbox = this.env.messaging.history;
+ const mainHistoryThreadCache = historyMailbox.mainCache;
+
+ // 1. move messages from inbox to history
+ for (const cache of inboxMailbox.caches) {
+ const messages = message_ids
+ .map(id => this.env.models['mail.message'].find(message => message.id === id))
+ .filter(message => !!message);
+ cache.update({ messages: [['unlink', messages]] });
+ // TODO FIXME cannot update other caches of history since the
+ // messages in them depend on the search domain
+ mainHistoryThreadCache.update({ messages: [['link', messages]] });
+ }
+
+ // 2. remove "needaction" from channels
+ let channels;
+ if (channel_ids) {
+ channels = channel_ids
+ .map(id => this.env.models['mail.thread'].find(thread =>
+ thread.id === id &&
+ thread.model === 'mail.channel'
+ ))
+ .filter(thread => !!thread);
+ } else {
+ // flux specific: channel_ids unset means "mark all as read"
+ channels = this.env.models['mail.thread'].all(thread =>
+ thread.model === 'mail.channel'
+ );
+ }
+ for (const channel of channels) {
+ channel.update({ message_needaction_counter: 0 });
+ }
+ inboxMailbox.update({ counter: inboxMailbox.counter - message_ids.length });
+
+ // manually force recompute of counter
+ this.messaging.messagingMenu.update();
+ }
+
+ /**
+ * @private
+ * @param {Object} param0
+ * @param {Object} param0.message
+ */
+ _handleNotificationPartnerModerator({ message: data }) {
+ this.env.models['mail.message'].insert(
+ this.env.models['mail.message'].convertData(data)
+ );
+ const moderationMailbox = this.env.messaging.moderation;
+ if (moderationMailbox) {
+ moderationMailbox.update({ counter: moderationMailbox.counter + 1 });
+ }
+ // manually force recompute of counter
+ this.messaging.messagingMenu.update();
+ }
+
+ /**
+ * @private
+ * @param {Object} param0
+ * @param {integer[]} param0.message_ids
+ * @param {boolean} param0.starred
+ */
+ _handleNotificationPartnerToggleStar({ message_ids = [], starred }) {
+ const starredMailbox = this.env.messaging.starred;
+ for (const messageId of message_ids) {
+ const message = this.env.models['mail.message'].find(message =>
+ message.id === messageId
+ );
+ if (!message) {
+ continue;
+ }
+ if (starred) {
+ message.update({ threadCaches: [['link', starredMailbox.mainCache]] });
+ starredMailbox.update({ counter: starredMailbox.counter + 1 });
+ } else {
+ for (const cache of starredMailbox.caches) {
+ cache.update({ messages: [['unlink', message]] });
+ }
+ starredMailbox.update({ counter: starredMailbox.counter - 1 });
+ }
+ }
+ }
+
+ /**
+ * On receiving a transient message, i.e. a message which does not come
+ * from a member of the channel. Usually a log message, such as one
+ * generated from a command with ('/').
+ *
+ * @private
+ * @param {Object} data
+ */
+ _handleNotificationPartnerTransientMessage(data) {
+ const convertedData = this.env.models['mail.message'].convertData(data);
+ const messageIds = this.env.models['mail.message'].all().map(message => message.id);
+ const partnerRoot = this.env.messaging.partnerRoot;
+ this.env.models['mail.message'].create(Object.assign(convertedData, {
+ author: [['link', partnerRoot]],
+ id: (messageIds ? Math.max(...messageIds) : 0) + 0.01,
+ isTransient: true,
+ }));
+ }
+
+ /**
+ * @private
+ * @param {integer} channelId
+ */
+ _handleNotificationPartnerUnsubscribe(channelId) {
+ const channel = this.env.models['mail.thread'].find(thread =>
+ thread.id === channelId &&
+ thread.model === 'mail.channel'
+ );
+ if (!channel) {
+ return;
+ }
+ let message;
+ if (channel.correspondent) {
+ const correspondent = channel.correspondent;
+ message = _.str.sprintf(
+ this.env._t("You unpinned your conversation with %s."),
+ owl.utils.escape(correspondent.name)
+ );
+ } else {
+ message = _.str.sprintf(
+ this.env._t("You unsubscribed from %s."),
+ owl.utils.escape(channel.name)
+ );
+ }
+ this.env.services['notification'].notify({
+ message,
+ title: this.env._t("Unsubscribed"),
+ type: 'warning',
+ });
+ channel.update({ isPinned: false });
+ }
+
+ /**
+ * @private
+ * @param {Object} param0
+ * @param {string} param0.message
+ * @param {integer} param0.partner_id
+ * @param {string} param0.title
+ */
+ _handleNotificationPartnerUserConnection({ message, partner_id, title }) {
+ this.env.services['bus_service'].sendNotification(title, message);
+ const partner = this.env.models['mail.partner'].insert({
+ id: partner_id,
+ });
+ partner.openChat();
+ }
+
+ /**
+ * @private
+ * @param {Object} param0
+ * @param {mail.thread} param0.channel
+ * @param {mail.message} param0.message
+ */
+ _notifyNewChannelMessageWhileOutOfFocus({ channel, message }) {
+ const author = message.author;
+ const messaging = this.env.messaging;
+ let notificationTitle;
+ if (!author) {
+ notificationTitle = this.env._t("New message");
+ } else {
+ const authorName = author.nameOrDisplayName;
+ if (channel.channel_type === 'channel') {
+ // hack: notification template does not support OWL components,
+ // so we simply use their template to make HTML as if it comes
+ // from component
+ const channelIcon = this.env.qweb.renderToString('mail.ThreadIcon', {
+ env: this.env,
+ thread: channel,
+ });
+ const channelName = owl.utils.escape(channel.displayName);
+ const channelNameWithIcon = channelIcon + channelName;
+ notificationTitle = _.str.sprintf(
+ this.env._t("%s from %s"),
+ owl.utils.escape(authorName),
+ channelNameWithIcon
+ );
+ } else {
+ notificationTitle = owl.utils.escape(authorName);
+ }
+ }
+ const notificationContent = message.prettyBody.substr(0, PREVIEW_MSG_MAX_SIZE);
+ this.env.services['bus_service'].sendNotification(notificationTitle, notificationContent);
+ messaging.update({ outOfFocusUnreadMessageCounter: messaging.outOfFocusUnreadMessageCounter + 1 });
+ const titlePattern = messaging.outOfFocusUnreadMessageCounter === 1
+ ? this.env._t("%d Message")
+ : this.env._t("%d Messages");
+ this.env.bus.trigger('set_title_part', {
+ part: '_chat',
+ title: _.str.sprintf(titlePattern, messaging.outOfFocusUnreadMessageCounter),
+ });
+ }
+
+ }
+
+ MessagingNotificationHandler.fields = {
+ messaging: one2one('mail.messaging', {
+ inverse: 'notificationHandler',
+ }),
+ };
+
+ MessagingNotificationHandler.modelName = 'mail.messaging_notification_handler';
+
+ return MessagingNotificationHandler;
+}
+
+registerNewModel('mail.messaging_notification_handler', factory);
+
+});
diff --git a/addons/mail/static/src/models/model/model.js b/addons/mail/static/src/models/model/model.js
new file mode 100644
index 00000000000..15647acaf49
--- /dev/null
+++ b/addons/mail/static/src/models/model/model.js
@@ -0,0 +1,245 @@
+odoo.define('mail/static/src/models/Model', function (require) {
+'use strict';
+
+const { registerNewModel } = require('mail/static/src/model/model_core.js');
+const { RecordDeletedError } = require('mail/static/src/model/model_errors.js');
+
+/**
+ * This function generates a class that represent a model. Instances of such
+ * model (or inherited models) represent logical objects used in whole
+ * application. They could represent server record (e.g. Thread, Message) or
+ * UI elements (e.g. MessagingMenu, ChatWindow). These instances are called
+ * "records", while the classes are called "models".
+ */
+function factory() {
+
+ class Model {
+
+ /**
+ * @param {Object} [param0={}]
+ * @param {boolean} [param0.valid=false] if set, this constructor is
+ * called by static method `create()`. This should always be the case.
+ * @throws {Error} in case constructor is called in an invalid way, i.e.
+ * by instantiating the record manually with `new` instead of from
+ * static method `create()`.
+ */
+ constructor({ valid = false } = {}) {
+ if (!valid) {
+ throw new Error("Record must always be instantiated from static method 'create()'");
+ }
+ }
+
+ /**
+ * Called when the record is being created, but not yet processed
+ * its create value on the fields. This method is handy to define purely
+ * technical property on this record, like handling of timers. This
+ * method acts like the constructor, but has a very important difference:
+ * the `this` is the proxified record, so evaluation of field values
+ * on get/set work correctly.
+ */
+ init() {}
+
+ //--------------------------------------------------------------------------
+ // Public
+ //--------------------------------------------------------------------------
+
+ /**
+ * Returns all records of this model that match provided criteria.
+ *
+ * @static
+ * @param {function} [filterFunc]
+ * @returns {mail.model[]}
+ */
+ static all(filterFunc) {
+ return this.env.modelManager.all(this, filterFunc);
+ }
+
+ /**
+ * This method is used to create new records of this model
+ * with provided data. This is the only way to create them:
+ * instantiation must never been done with keyword `new` outside of this
+ * function, otherwise the record will not be registered.
+ *
+ * @static
+ * @param {Object} [data] data object with initial data, including relations.
+ * @returns {mail.model} newly created record
+ */
+ static create(data) {
+ return this.env.modelManager.create(this, data);
+ }
+
+ /**
+ * Get the record that has provided criteria, if it exists.
+ *
+ * @static
+ * @param {function} findFunc
+ * @returns {mail.model|undefined}
+ */
+ static find(findFunc) {
+ return this.env.modelManager.find(this, findFunc);
+ }
+
+ /**
+ * This method returns the record of this model that matches provided
+ * local id. Useful to convert a local id to a record. Note that even
+ * if there's a record in the system having provided local id, if the
+ * resulting record is not an instance of this model, this getter
+ * assumes the record does not exist.
+ *
+ * @static
+ * @param {string|mail.model|undefined} recordOrLocalId
+ * @returns {mail.model|undefined}
+ */
+ static get(recordOrLocalId) {
+ return this.env.modelManager.get(this, recordOrLocalId);
+ }
+
+ /**
+ * This method creates a record or updates one, depending
+ * on provided data.
+ *
+ * @static
+ * @param {Object} data
+ * @returns {mail.model} created or updated record.
+ */
+ static insert(data) {
+ return this.env.modelManager.insert(this, data);
+ }
+
+ /**
+ * Perform an async function and wait until it is done. If the record
+ * is deleted, it raises a RecordDeletedError.
+ *
+ * @param {function} func an async function
+ * @throws {RecordDeletedError} in case the current record is not alive
+ * at the end of async function call, whether it's resolved or
+ * rejected.
+ * @throws {any} forwards any error in case the current record is still
+ * alive at the end of rejected async function call.
+ * @returns {any} result of resolved async function.
+ */
+ async async(func) {
+ return new Promise((resolve, reject) => {
+ Promise.resolve(func()).then(result => {
+ if (this.constructor.get(this)) {
+ resolve(result);
+ } else {
+ reject(new RecordDeletedError(this.localId));
+ }
+ }).catch(error => {
+ if (this.constructor.get(this)) {
+ reject(error);
+ } else {
+ reject(new RecordDeletedError(this.localId));
+ }
+ });
+ });
+ }
+
+ /**
+ * This method deletes this record.
+ */
+ delete() {
+ this.env.modelManager.delete(this);
+ }
+
+ /**
+ * Update this record with provided data.
+ *
+ * @param {Object} [data={}]
+ */
+ update(data = {}) {
+ this.env.modelManager.update(this, data);
+ }
+
+ //--------------------------------------------------------------------------
+ // Private
+ //--------------------------------------------------------------------------
+
+ /**
+ * @static
+ * @private
+ * @param {Object} data
+ * @param {any} data.id
+ * @return {function}
+ */
+ static _findFunctionFromData(data) {
+ return record => record.id === data.id;
+ }
+
+ /**
+ * This method generates a local id for this record that is
+ * being created at the moment.
+ *
+ * This function helps customizing the local id to ease mapping a local
+ * id to its record for the developer that reads the local id. For
+ * instance, the local id of a thread cache could combine the thread
+ * and stringified domain in its local id, which is much easier to
+ * track relations and records in the system instead of arbitrary
+ * number to differenciate them.
+ *
+ * @private
+ * @param {Object} data
+ * @returns {string}
+ */
+ _createRecordLocalId(data) {
+ return _.uniqueId(`${this.constructor.modelName}_`);
+ }
+
+ /**
+ * This function is called when this record has been explicitly updated
+ * with `.update()` or static method `.create()`, at the end of an
+ * record update cycle. This is a backward-compatible behaviour that
+ * is deprecated: you should use computed fields instead.
+ *
+ * @deprecated
+ * @abstract
+ * @private
+ * @param {Object} previous contains data that have been stored by
+ * `_updateBefore()`. Useful to make extra update decisions based on
+ * previous data.
+ */
+ _updateAfter(previous) {}
+
+ /**
+ * This function is called just at the beginning of an explicit update
+ * on this function, with `.update()` or static method `.create()`. This
+ * is useful to remember previous values of fields in `_updateAfter`.
+ * This is a backward-compatible behaviour that is deprecated: you
+ * should use computed fields instead.
+ *
+ * @deprecated
+ * @abstract
+ * @private
+ * @param {Object} data
+ * @returns {Object}
+ */
+ _updateBefore() {
+ return {};
+ }
+
+ }
+
+ /**
+ * Models should define fields in static prop or getter `fields`.
+ * It contains an object with name of field as key and value are objects
+ * that define the field. There are some helpers to ease the making of these
+ * objects, @see `mail/static/src/model/model_field.js`
+ *
+ * Note: fields of super-class are automatically inherited, therefore a
+ * sub-class should (re-)define fields without copying ancestors' fields.
+ */
+ Model.fields = {};
+
+ /**
+ * Name of the model. Important to refer to appropriate model class
+ * like in relational fields. Name of model classes must be unique.
+ */
+ Model.modelName = 'mail.model';
+
+ return Model;
+}
+
+registerNewModel('mail.model', factory);
+
+});
diff --git a/addons/mail/static/src/models/notification/notification.js b/addons/mail/static/src/models/notification/notification.js
new file mode 100644
index 00000000000..95e8a013876
--- /dev/null
+++ b/addons/mail/static/src/models/notification/notification.js
@@ -0,0 +1,69 @@
+odoo.define('mail/static/src/models/notification/notification.js', function (require) {
+'use strict';
+
+const { registerNewModel } = require('mail/static/src/model/model_core.js');
+const { attr, many2one } = require('mail/static/src/model/model_field.js');
+
+function factory(dependencies) {
+
+ class Notification extends dependencies['mail.model'] {
+
+ //----------------------------------------------------------------------
+ // Public
+ //----------------------------------------------------------------------
+
+ /**
+ * @static
+ * @param {Object} data
+ * @return {Object}
+ */
+ static convertData(data) {
+ const data2 = {};
+ if ('failure_type' in data) {
+ data2.failure_type = data.failure_type;
+ }
+ if ('id' in data) {
+ data2.id = data.id;
+ }
+ if ('notification_status' in data) {
+ data2.notification_status = data.notification_status;
+ }
+ if ('notification_type' in data) {
+ data2.notification_type = data.notification_type;
+ }
+ if ('partner_id' in data) {
+ if (!data.partner_id) {
+ data2.partner = [['unlink-all']];
+ } else {
+ data2.partner = [
+ ['insert', {
+ display_name: data.partner_id[1],
+ id: data.partner_id[0],
+ }],
+ ];
+ }
+ }
+ return data2;
+ }
+
+ }
+
+ Notification.fields = {
+ failure_type: attr(),
+ id: attr(),
+ message: many2one('mail.message', {
+ inverse: 'notifications',
+ }),
+ notification_status: attr(),
+ notification_type: attr(),
+ partner: many2one('mail.partner'),
+ };
+
+ Notification.modelName = 'mail.notification';
+
+ return Notification;
+}
+
+registerNewModel('mail.notification', factory);
+
+});
diff --git a/addons/mail/static/src/models/notification_group/notification_group.js b/addons/mail/static/src/models/notification_group/notification_group.js
new file mode 100644
index 00000000000..9fdbefc4979
--- /dev/null
+++ b/addons/mail/static/src/models/notification_group/notification_group.js
@@ -0,0 +1,115 @@
+odoo.define('mail/static/src/models/notification_group/notification_group.js', function (require) {
+'use strict';
+
+const { registerNewModel } = require('mail/static/src/model/model_core.js');
+const { attr, one2many } = require('mail/static/src/model/model_field.js');
+
+function factory(dependencies) {
+
+ class NotificationGroup extends dependencies['mail.model'] {
+
+ //----------------------------------------------------------------------
+ // Public
+ //----------------------------------------------------------------------
+
+ /**
+ * Opens the view that allows to cancel all notifications of the group.
+ */
+ openCancelAction() {
+ const device = this.env.messaging.device;
+ if (this.notification_type === 'email') {
+ this.env.bus.trigger('do-action', {
+ action: 'mail.mail_resend_cancel_action',
+ options: {
+ additional_context: {
+ default_model: this.res_model,
+ unread_counter: this.notifications.length,
+ },
+ },
+ });
+ }
+ if (!device.isMobile) {
+ this.env.messaging.messagingMenu.close();
+ }
+ }
+
+ /**
+ * Opens the view that displays either the single record of the group or
+ * all the records in the group.
+ */
+ openDocuments() {
+ if (this.res_id) {
+ if (this.res_model === 'mail.channel') {
+ const channel = this.env.models['mail.thread'].insert({
+ id: this.res_id,
+ model: this.res_model,
+ });
+ channel.open();
+ } else {
+ this.env.messaging.openDocument({
+ id: this.res_id,
+ model: this.res_model,
+ });
+ }
+ } else {
+ this._openDocuments();
+ }
+ }
+
+ //----------------------------------------------------------------------
+ // Private
+ //----------------------------------------------------------------------
+
+ /**
+ * @override
+ */
+ _createRecordLocalId(data) {
+ const NotificationGroup = this.env.models['mail.notification_group'];
+ return `${NotificationGroup.modelName}_${data.id}`;
+ }
+
+ /**
+ * Opens the view that displays all the records of the group.
+ *
+ * @private
+ */
+ _openDocuments() {
+ const device = this.env.messaging.device;
+ if (this.notification_type === 'email') {
+ this.env.bus.trigger('do-action', {
+ action: {
+ name: this.env._t("Mail Failures"),
+ type: 'ir.actions.act_window',
+ view_mode: 'kanban,list,form',
+ views: [[false, 'kanban'], [false, 'list'], [false, 'form']],
+ target: 'current',
+ res_model: this.res_model,
+ domain: [['message_has_error', '=', true]],
+ },
+ });
+ }
+ if (!device.isMobile) {
+ this.env.messaging.messagingMenu.close();
+ }
+ }
+
+ }
+
+ NotificationGroup.fields = {
+ date: attr(),
+ id: attr(),
+ notification_type: attr(),
+ notifications: one2many('mail.notification'),
+ res_id: attr(),
+ res_model: attr(),
+ res_model_name: attr(),
+ };
+
+ NotificationGroup.modelName = 'mail.notification_group';
+
+ return NotificationGroup;
+}
+
+registerNewModel('mail.notification_group', factory);
+
+});
diff --git a/addons/mail/static/src/models/notification_group_manager/notification_group_manager.js b/addons/mail/static/src/models/notification_group_manager/notification_group_manager.js
new file mode 100644
index 00000000000..be7bef8352a
--- /dev/null
+++ b/addons/mail/static/src/models/notification_group_manager/notification_group_manager.js
@@ -0,0 +1,76 @@
+odoo.define('mail/static/src/models/notification_group_manager/notification_group_manager.js', function (require) {
+'use strict';
+
+const { registerNewModel } = require('mail/static/src/model/model_core.js');
+const { one2many } = require('mail/static/src/model/model_field.js');
+
+function factory(dependencies) {
+
+ class NotificationGroupManager extends dependencies['mail.model'] {
+
+ //----------------------------------------------------------------------
+ // Public
+ //----------------------------------------------------------------------
+
+ computeGroups() {
+ for (const group of this.groups) {
+ group.delete();
+ }
+ const groups = [];
+ this.env.messaging.currentPartner.failureNotifications.forEach(notification => {
+ const thread = notification.message.originThread;
+ // Notifications are grouped by model and notification_type.
+ // Except for channel where they are also grouped by id because
+ // we want to open the actual channel in discuss or chat window
+ // and not its kanban/list/form view.
+ const channelId = thread.model === 'mail.channel' ? thread.id : null;
+ const id = `${thread.model}/${channelId}/${notification.notification_type}`;
+ const group = this.env.models['mail.notification_group'].insert({
+ id,
+ notification_type: notification.notification_type,
+ res_model: thread.model,
+ res_model_name: thread.model_name,
+ });
+ group.update({ notifications: [['link', notification]] });
+ // keep res_id only if all notifications are for the same record
+ // set null if multiple records are present in the group
+ let res_id = group.res_id;
+ if (group.res_id === undefined) {
+ res_id = thread.id;
+ } else if (group.res_id !== thread.id) {
+ res_id = null;
+ }
+ // keep only the most recent date from all notification messages
+ let date = group.date;
+ if (!date) {
+ date = notification.message.date;
+ } else {
+ date = moment.max(group.date, notification.message.date);
+ }
+ group.update({
+ date,
+ res_id,
+ });
+ // avoid linking the same group twice when adding a notification
+ // to an existing group
+ if (!groups.includes(group)) {
+ groups.push(group);
+ }
+ });
+ this.update({ groups: [['link', groups]] });
+ }
+
+ }
+
+ NotificationGroupManager.fields = {
+ groups: one2many('mail.notification_group'),
+ };
+
+ NotificationGroupManager.modelName = 'mail.notification_group_manager';
+
+ return NotificationGroupManager;
+}
+
+registerNewModel('mail.notification_group_manager', factory);
+
+});
diff --git a/addons/mail/static/src/models/partner/partner.js b/addons/mail/static/src/models/partner/partner.js
new file mode 100644
index 00000000000..3bf38a60b95
--- /dev/null
+++ b/addons/mail/static/src/models/partner/partner.js
@@ -0,0 +1,273 @@
+odoo.define('mail/static/src/models/partner/partner.js', function (require) {
+'use strict';
+
+const { registerNewModel } = require('mail/static/src/model/model_core.js');
+const { attr, many2many, many2one, one2many, one2one } = require('mail/static/src/model/model_field.js');
+
+const utils = require('web.utils');
+
+function factory(dependencies) {
+
+ let nextPublicId = -1;
+
+ class Partner extends dependencies['mail.model'] {
+
+ //----------------------------------------------------------------------
+ // Public
+ //----------------------------------------------------------------------
+
+ /**
+ * @static
+ * @private
+ * @param {Object} data
+ * @return {Object}
+ */
+ static convertData(data) {
+ const data2 = {};
+ if ('country' in data) {
+ if (!data.country) {
+ data2.country = [['unlink-all']];
+ } else {
+ data2.country = [['insert', {
+ id: data.country[0],
+ name: data.country[1],
+ }]];
+ }
+ }
+ if ('display_name' in data) {
+ data2.display_name = data.display_name;
+ }
+ if ('email' in data) {
+ data2.email = data.email;
+ }
+ if ('id' in data) {
+ data2.id = data.id;
+ }
+ if ('im_status' in data) {
+ data2.im_status = data.im_status;
+ }
+ if ('name' in data) {
+ data2.name = data.name;
+ }
+
+ // relation
+ if ('user_id' in data) {
+ if (!data.user_id) {
+ data2.user = [['unlink-all']];
+ } else {
+ data2.user = [
+ ['insert', {
+ id: data.user_id[0],
+ partnerDisplayName: data.user_id[1],
+ }],
+ ];
+ }
+ }
+
+ return data2;
+ }
+
+ static getNextPublicId() {
+ const id = nextPublicId;
+ nextPublicId -= 1;
+ return id;
+ }
+
+ /**
+ * Search for partners matching `keyword`.
+ *
+ * @static
+ * @param {Object} param0
+ * @param {function} param0.callback
+ * @param {string} param0.keyword
+ * @param {integer} [param0.limit=10]
+ */
+ static async imSearch({ callback, keyword, limit = 10 }) {
+ // prefetched partners
+ let partners = [];
+ const searchRegexp = new RegExp(
+ _.str.escapeRegExp(utils.unaccent(keyword)),
+ 'i'
+ );
+ const currentPartner = this.env.messaging.currentPartner;
+ for (const partner of this.all(partner => partner.active)) {
+ if (partners.length < limit) {
+ if (
+ partner !== currentPartner &&
+ searchRegexp.test(partner.name)
+ ) {
+ partners.push(partner);
+ }
+ }
+ }
+ if (!partners.length) {
+ const partnersData = await this.env.services.rpc(
+ {
+ model: 'res.partner',
+ method: 'im_search',
+ args: [keyword, limit]
+ },
+ { shadow: true }
+ );
+ for (const data of partnersData) {
+ const partner = this.insert(data);
+ partners.push(partner);
+ }
+ }
+ callback(partners);
+ }
+
+ /**
+ * @static
+ */
+ static async startLoopFetchImStatus() {
+ await this._fetchImStatus();
+ this._loopFetchImStatus();
+ }
+
+ async checkIsUser() {
+ const userIds = await this.async(() => this.env.services.rpc({
+ model: 'res.users',
+ method: 'search',
+ args: [[['partner_id', '=', this.id]]],
+ }));
+ if (userIds.length) {
+ this.update({ user: [['insert', { id: userIds[0] }]] });
+ }
+ }
+
+ /**
+ * Opens an existing or new chat.
+ */
+ openChat() {
+ const chat = this.correspondentThreads.find(thread => thread.channel_type === 'chat');
+ if (chat) {
+ chat.open();
+ } else {
+ this.env.models['mail.thread'].createChannel({
+ autoselect: true,
+ partnerId: this.id,
+ type: 'chat',
+ });
+ }
+ }
+
+ //----------------------------------------------------------------------
+ // Private
+ //----------------------------------------------------------------------
+
+ /**
+ * @static
+ * @private
+ */
+ static async _fetchImStatus() {
+ let toFetchPartnersLocalIds = [];
+ let partnerIdToLocalId = {};
+ const toFetchPartners = this.all(partner => partner.im_status !== null);
+ for (const partner of toFetchPartners) {
+ toFetchPartnersLocalIds.push(partner.localId);
+ partnerIdToLocalId[partner.id] = partner.localId;
+ }
+ if (!toFetchPartnersLocalIds.length) {
+ return;
+ }
+ const dataList = await this.env.services.rpc({
+ route: '/longpolling/im_status',
+ params: {
+ partner_ids: toFetchPartnersLocalIds.map(partnerLocalId =>
+ this.get(partnerLocalId).id
+ ),
+ },
+ }, { shadow: true });
+ for (const { id, im_status } of dataList) {
+ this.insert({ id, im_status });
+ delete partnerIdToLocalId[id];
+ }
+ // partners with no im_status => set null
+ for (const noImStatusPartnerLocalId of Object.values(partnerIdToLocalId)) {
+ const partner = this.get(noImStatusPartnerLocalId);
+ if (partner) {
+ partner.update({ im_status: null });
+ }
+ }
+ }
+
+ /**
+ * @static
+ * @private
+ */
+ static _loopFetchImStatus() {
+ setTimeout(async () => {
+ await this._fetchImStatus();
+ this._loopFetchImStatus();
+ }, 50 * 1000);
+ }
+
+ /**
+ * @private
+ * @returns {string}
+ */
+ _computeNameOrDisplayName() {
+ return this.name || this.display_name;
+ }
+
+ /**
+ * @override
+ */
+ _createRecordLocalId(data) {
+ const Partner = this.env.models['mail.partner'];
+ return `${Partner.modelName}_${data.id}`;
+ }
+
+ }
+
+ Partner.fields = {
+ active: attr({
+ default: true,
+ }),
+ correspondentThreads: one2many('mail.thread', {
+ inverse: 'correspondent',
+ }),
+ country: many2one('mail.country'),
+ display_name: attr({
+ default: "",
+ }),
+ email: attr(),
+ failureNotifications: one2many('mail.notification', {
+ related: 'messagesAsAuthor.failureNotifications',
+ }),
+ id: attr(),
+ im_status: attr(),
+ memberThreads: many2many('mail.thread', {
+ inverse: 'members',
+ }),
+ messagesAsAuthor: one2many('mail.message', {
+ inverse: 'author',
+ }),
+ model: attr({
+ default: 'res.partner',
+ }),
+ moderatedChannelIds: attr({
+ default: [],
+ }),
+ name: attr(),
+ nameOrDisplayName: attr({
+ compute: '_computeNameOrDisplayName',
+ dependencies: [
+ 'display_name',
+ 'name',
+ ],
+ }),
+ user: one2one('mail.user', {
+ inverse: 'partner',
+ }),
+ };
+
+ Partner.modelName = 'mail.partner';
+
+ return Partner;
+}
+
+registerNewModel('mail.partner', factory);
+
+});
diff --git a/addons/mail/static/src/models/thread/thread.js b/addons/mail/static/src/models/thread/thread.js
new file mode 100644
index 00000000000..16f52ab861f
--- /dev/null
+++ b/addons/mail/static/src/models/thread/thread.js
@@ -0,0 +1,1370 @@
+odoo.define('mail/static/src/models/thread/thread.js', function (require) {
+'use strict';
+
+const { registerNewModel } = require('mail/static/src/model/model_core.js');
+const { attr, many2many, many2one, one2many, one2one } = require('mail/static/src/model/model_field.js');
+const throttle = require('mail/static/src/utils/throttle/throttle.js');
+const Timer = require('mail/static/src/utils/timer/timer.js');
+
+function factory(dependencies) {
+
+ class Thread extends dependencies['mail.model'] {
+
+ /**
+ * @override
+ */
+ init(...args) {
+ super.init(...args);
+ /**
+ * Timer of current partner that was currently typing something, but
+ * there is no change on the input for 5 seconds. This is used
+ * in order to automatically notify other members that current
+ * partner has stopped typing something, due to making no changes
+ * on the composer for some time.
+ */
+ this._currentPartnerInactiveTypingTimer = new Timer(
+ this.env,
+ () => this.async(() => this._onCurrentPartnerInactiveTypingTimeout()),
+ 5 * 1000
+ );
+ /**
+ * Last 'is_typing' status of current partner that has been notified
+ * to other members. Useful to prevent spamming typing notifications
+ * to other members if it hasn't changed. An exception is the
+ * current partner long typing scenario where current partner has
+ * to re-send the same typing notification from time to time, so
+ * that other members do not assume he/she is no longer typing
+ * something from not receiving any typing notifications for a
+ * very long time.
+ *
+ * Supported values: true/false/undefined.
+ * undefined makes only sense initially and during current partner
+ * long typing timeout flow.
+ */
+ this._currentPartnerLastNotifiedIsTyping = undefined;
+ /**
+ * Timer of current partner that is typing a very long text. When
+ * the other members do not receive any typing notification for a
+ * long time, they must assume that the related partner is no longer
+ * typing something (e.g. they have closed the browser tab).
+ * This is a timer to let other members know that current partner
+ * is still typing something, so that they should not assume he/she
+ * has stopped typing something.
+ */
+ this._currentPartnerLongTypingTimer = new Timer(
+ this.env,
+ () => this.async(() => this._onCurrentPartnerLongTypingTimeout()),
+ 50 * 1000
+ );
+ /**
+ * Determines whether the next request to notify current partner
+ * typing status should always result to making RPC, regardless of
+ * whether last notified current partner typing status is the same.
+ * Most of the time we do not want to notify if value hasn't
+ * changed, exception being the long typing scenario of current
+ * partner.
+ */
+ this._forceNotifyNextCurrentPartnerTypingStatus = false;
+ /**
+ * Registry of timers of partners currently typing in the thread,
+ * excluding current partner. This is useful in order to
+ * automatically unregister typing members when not receive any
+ * typing notification after a long time. Timers are internally
+ * indexed by partner records as key. The current partner is
+ * ignored in this registry of timers.
+ *
+ * @see registerOtherMemberTypingMember
+ * @see unregisterOtherMemberTypingMember
+ */
+ this._otherMembersLongTypingTimers = new Map();
+
+ /**
+ * Clearable and cancellable throttled version of the
+ * `_notifyCurrentPartnerTypingStatus` method.
+ * This is useful when the current partner posts a message and
+ * types something else afterwards: it must notify immediately that
+ * he/she is typing something, instead of waiting for the throttle
+ * internal timer.
+ *
+ * @see _notifyCurrentPartnerTypingStatus
+ */
+ this._throttleNotifyCurrentPartnerTypingStatus = throttle(
+ this.env,
+ ({ isTyping }) => this.async(() => this._notifyCurrentPartnerTypingStatus({ isTyping })),
+ 2.5 * 1000
+ );
+ }
+
+ /**
+ * @override
+ */
+ delete(...args) {
+ this._currentPartnerInactiveTypingTimer.clear();
+ this._currentPartnerLongTypingTimer.clear();
+ this._throttleNotifyCurrentPartnerTypingStatus.clear();
+ for (const timer of this._otherMembersLongTypingTimers.values()) {
+ timer.clear();
+ }
+ super.delete(...args);
+ }
+
+ //----------------------------------------------------------------------
+ // Public
+ //----------------------------------------------------------------------
+
+ /**
+ * @static
+ * @param {mail.thread} [thread] the concerned thread
+ */
+ static computeLastCurrentPartnerMessageSeenByEveryone(thread = undefined) {
+ const threads = thread ? [thread] : this.env.models['mail.thread'].all();
+ threads.map(localThread => {
+ localThread.update({
+ lastCurrentPartnerMessageSeenByEveryone: localThread._computeLastCurrentPartnerMessageSeenByEveryone(),
+ });
+ });
+ }
+
+ /**
+ * @static
+ * @param {Object} data
+ * @return {Object}
+ */
+ static convertData(data) {
+ const data2 = {};
+ if ('channel_type' in data) {
+ data2.channel_type = data.channel_type;
+ data2.model = 'mail.channel';
+ }
+ if ('create_uid' in data) {
+ data2.creator = [['insert', { id: data.create_uid }]];
+ }
+ if ('custom_channel_name' in data) {
+ data2.custom_channel_name = data.custom_channel_name;
+ }
+ if ('group_based_subscription' in data) {
+ data2.group_based_subscription = data.group_based_subscription;
+ }
+ if ('id' in data) {
+ data2.id = data.id;
+ }
+ if ('is_minimized' in data && 'state' in data) {
+ data2.serverFoldState = data.is_minimized ? data.state : 'closed';
+ }
+ if ('is_moderator' in data) {
+ data2.is_moderator = data.is_moderator;
+ }
+ if ('mass_mailing' in data) {
+ data2.mass_mailing = data.mass_mailing;
+ }
+ if ('moderation' in data) {
+ data2.moderation = data.moderation;
+ }
+ if ('message_needaction_counter' in data) {
+ data2.message_needaction_counter = data.message_needaction_counter;
+ }
+ if ('message_unread_counter' in data) {
+ data2.message_unread_counter = data.message_unread_counter;
+ }
+ if ('name' in data) {
+ data2.name = data.name;
+ }
+ if ('public' in data) {
+ data2.public = data.public;
+ }
+ if ('seen_message_id' in data) {
+ data2.seen_message_id = data.seen_message_id;
+ }
+ if ('uuid' in data) {
+ data2.uuid = data.uuid;
+ }
+
+ // relation
+ if ('direct_partner' in data) {
+ if (!data.direct_partner) {
+ data2.correspondent = [['unlink-all']];
+ } else {
+ data2.correspondent = [
+ ['insert', this.env.models['mail.partner'].convertData(data.direct_partner[0])],
+ ];
+ }
+ }
+ if ('members' in data) {
+ if (!data.members) {
+ data2.members = [['unlink-all']];
+ } else {
+ data2.members = [
+ ['insert-and-replace', data.members.map(memberData =>
+ this.env.models['mail.partner'].convertData(memberData)
+ )],
+ ];
+ }
+ }
+ if ('seen_partners_info' in data) {
+ if (!data.seen_partners_info) {
+ data2.partnerSeenInfos = [['unlink-all']];
+ } else {
+ data2.partnerSeenInfos = [
+ ['insert-and-replace',
+ data.seen_partners_info.map(
+ ({ fetched_message_id, id, partner_id, seen_message_id}) => {
+ return {
+ id,
+ lastFetchedMessage: [seen_message_id ? ['insert', {id: seen_message_id}] : ['unlink-all']],
+ lastSeenMessage: [fetched_message_id ? ['insert', {id: fetched_message_id}] : ['unlink-all']],
+ partner: [['insert', {id: partner_id}]],
+ };
+ })
+ ]
+ ];
+ if (data.id || this.id) {
+ const messageIds = data.seen_partners_info.reduce((currentSet, { fetched_message_id, seen_message_id}) => {
+ if (fetched_message_id) {
+ currentSet.add(fetched_message_id);
+ }
+ if (seen_message_id) {
+ currentSet.add(seen_message_id);
+ }
+ return currentSet;
+ }, new Set());
+ if (messageIds.size > 0) {
+ data2.messageSeenIndicators = [
+ ['insert',
+ [...messageIds].map(messageId => {
+ return {
+ id: this.env.models['mail.message_seen_indicator'].computeId(messageId, data.id || this.id),
+ message: [['insert', {id: messageId}]],
+ };
+ })
+ ]
+ ];
+ }
+ }
+ }
+ }
+
+ return data2;
+ }
+
+ /**
+ * Create a channel, which is a special kind of thread on model
+ * 'mail.channel' with multiple members.
+ *
+ * @static
+ * @param {Object} param0
+ * @param {boolean} [param0.autoselect=false] if set, when channel
+ * has been created, it auto-open the channel. This opens in discuss
+ * or chat window, depending on whether discuss is open or not.
+ * @param {string} [param0.autoselectChatWindowMode]
+ * @param {string} param0.name
+ * @param {integer} [param0.partnerId]
+ * @param {string} [param0.public]
+ * @param {string} param0.type
+ */
+ static async createChannel({
+ autoselect = false,
+ autoselectChatWindowMode,
+ name,
+ partnerId,
+ public: publicStatus,
+ type,
+ }) {
+ const device = this.env.messaging.device;
+ // TODO FIX: potential duplicate chat task-2276490
+ const data = await this.env.services.rpc({
+ model: 'mail.channel',
+ method: type === 'chat' ? 'channel_get' : 'channel_create',
+ args: type === 'chat' ? [[partnerId]] : [name, publicStatus],
+ kwargs: {
+ context: Object.assign({}, this.env.session.user_content, {
+ isMobile: device.isMobile,
+ }),
+ }
+ });
+ const thread = this.insert(Object.assign(
+ { isPinned: true },
+ this.convertData(data)
+ ));
+ if (autoselect) {
+ thread.open({ chatWindowMode: autoselectChatWindowMode });
+ }
+ }
+
+ /**
+ * Join a channel. This channel may not yet exists in the store.
+ *
+ * @static
+ * @param {integer} channelId
+ * @param {Object} [param1={}]
+ * @param {boolean} [param1.autoselect=false]
+ */
+ static async joinChannel(channelId, { autoselect = false } = {}) {
+ const channel = this.find(thread =>
+ thread.id === channelId &&
+ thread.model === 'mail.channel'
+ );
+ if (channel && channel.isPinned) {
+ return;
+ }
+ const data = await this.env.services.rpc({
+ model: 'mail.channel',
+ method: 'channel_join_and_get_info',
+ args: [[channelId]]
+ });
+ const thread = this.insert(Object.assign(
+ { isPinned: true },
+ this.convertData(data)
+ ));
+ if (autoselect) {
+ thread.open({ resetDiscussDomain: true });
+ }
+ }
+
+ /**
+ * Load the previews of the specified threads. Basically, it fetches the
+ * last messages, since they are used to display inline content of them.
+ *
+ * @static
+ * @param {mail.thread[]} threads
+ */
+ static async loadPreviews(threads) {
+ const channelIds = threads.reduce((list, thread) => {
+ if (thread.model === 'mail.channel') {
+ return list.concat(thread.id);
+ }
+ return list;
+ }, []);
+ const messagePreviews = await this.env.services.rpc({
+ model: 'mail.channel',
+ method: 'channel_fetch_preview',
+ args: [channelIds],
+ }, { shadow: true });
+ for (const preview of messagePreviews) {
+ const messageData = preview.last_message;
+ if (messageData) {
+ this.env.models['mail.message'].insert(
+ this.env.models['mail.message'].convertData(messageData)
+ );
+ }
+ }
+ }
+
+ /**
+ * @static
+ */
+ static openNewMessage() {
+ const discuss = this.env.messaging.discuss;
+ if (discuss.isOpen) {
+ discuss.openNewMessage();
+ } else {
+ this.env.messaging.chatWindowManager.openNewMessage();
+ }
+ }
+
+ /**
+ * @param {string} [stringifiedDomain='[]']
+ * @returns {mail.thread_cache}
+ */
+ cache(stringifiedDomain = '[]') {
+ let cache = this.caches.find(cache => cache.stringifiedDomain === stringifiedDomain);
+ if (!cache) {
+ cache = this.env.models['mail.thread_cache'].create({
+ stringifiedDomain,
+ thread: [['link', this]],
+ });
+ }
+ return cache;
+ }
+
+ /**
+ * Fetch attachments linked to a record. Useful for populating the store
+ * with these attachments, which are used by attachment box in the chatter.
+ */
+ async fetchAttachments() {
+ const attachmentsData = await this.async(() => this.env.services.rpc({
+ model: 'ir.attachment',
+ method: 'search_read',
+ domain: [
+ ['res_id', '=', this.id],
+ ['res_model', '=', this.model],
+ ],
+ fields: ['id', 'name', 'mimetype'],
+ orderBy: [{ name: 'id', asc: false }],
+ }));
+ for (const attachmentData of attachmentsData) {
+ this.env.models['mail.attachment'].insert(Object.assign({
+ originThread: [['link', this]],
+ }, this.env.models['mail.attachment'].convertData(attachmentData)));
+ }
+ this.update({ areAttachmentsLoaded: true });
+ }
+
+ /**
+ * Add current user to provided thread's followers.
+ */
+ async follow() {
+ await this.async(() => this.env.services.rpc({
+ model: this.model,
+ method: 'message_subscribe',
+ args: [[this.id]],
+ kwargs: {
+ partner_ids: [this.env.messaging.currentPartner.id],
+ context: {}, // FIXME empty context to be overridden in session.js with 'allowed_company_ids' task-2243187
+ },
+ }));
+ this.refreshFollowers();
+ }
+
+ /**
+ * Load new messages on the main cache of this thread.
+ */
+ loadNewMessages() {
+ this.mainCache.loadNewMessages();
+ }
+
+ /**
+ * Mark the specified conversation as fetched.
+ */
+ async markAsFetched() {
+ await this.async(() => this.env.services.rpc({
+ model: 'mail.channel',
+ method: 'channel_fetched',
+ args: [[this.id]],
+ }, { shadow: true }));
+ }
+
+ /**
+ * Mark the specified conversation as read/seen.
+ */
+ async markAsSeen() {
+ if (this.message_unread_counter === 0) {
+ return;
+ }
+ if (this.model === 'mail.channel') {
+ const seen_message_id = await this.async(() => this.env.services.rpc({
+ model: 'mail.channel',
+ method: 'channel_seen',
+ args: [[this.id]]
+ }, { shadow: true }));
+ this.update({ seen_message_id });
+ }
+ this.update({ message_unread_counter: 0 });
+ }
+
+ /**
+ * Mark all needaction messages of this thread as read.
+ */
+ async markNeedactionMessagesAsRead() {
+ await this.async(() =>
+ this.env.models['mail.message'].markAsRead(this.needactionMessages)
+ );
+ }
+
+ /**
+ * Notify server the fold state of this thread. Useful for cross-tab
+ * and cross-device chat window state synchronization.
+ *
+ * Only makes sense if pendingFoldState is set to the desired value.
+ */
+ async notifyFoldStateToServer() {
+ await this.async(() => this.env.services.rpc({
+ model: 'mail.channel',
+ method: 'channel_fold',
+ kwargs: {
+ uuid: this.uuid,
+ state: this.pendingFoldState,
+ }
+ }, { shadow: true }));
+ }
+
+ /**
+ * Open provided thread, either in discuss app or as a chat window.
+ *
+ * @param {Object} param0
+ * @param {string} [param0.chatWindowMode='last_visible']
+ * @param {boolean} [param0.resetDiscussDomain=false]
+ */
+ open({ chatWindowMode = 'last_visible', resetDiscussDomain = false } = {}) {
+ const device = this.env.messaging.device;
+ const discuss = this.env.messaging.discuss;
+ const messagingMenu = this.env.messaging.messagingMenu;
+ if (!['mail.box', 'mail.channel'].includes(this.model)) {
+ this.env.messaging.openDocument({
+ id: this.id,
+ model: this.model,
+ });
+ }
+ if (
+ (!device.isMobile && discuss.isOpen) ||
+ (device.isMobile && this.model === 'mail.box')
+ ) {
+ if (resetDiscussDomain) {
+ discuss.threadViewer.update({ stringifiedDomain: '[]' });
+ }
+ discuss.threadViewer.update({ thread: [['link', this]] });
+ } else {
+ this.env.messaging.chatWindowManager.openThread(this, { mode: chatWindowMode });
+ }
+ if (!device.isMobile) {
+ messagingMenu.close();
+ }
+ }
+
+ /**
+ * Open this thread in an expanded way, that is not in a chat window.
+ */
+ openExpanded() {
+ const discuss = this.env.messaging.discuss;
+ if (['mail.channel', 'mail.box'].includes(this.model)) {
+ this.env.bus.trigger('do-action', {
+ action: 'mail.action_new_discuss',
+ options: {
+ clear_breadcrumbs: false,
+ active_id: discuss.threadToActiveId(this),
+ on_reverse_breadcrumb: () => discuss.close(),
+ },
+ });
+ } else {
+ this.env.bus.trigger('do-action', {
+ action: {
+ type: 'ir.actions.act_window',
+ res_model: this.model,
+ views: [[false, 'form']],
+ res_id: this.id,
+ },
+ });
+ }
+ }
+
+ /**
+ * Open a dialog to add channels as followers.
+ */
+ promptAddChannelFollower() {
+ this._promptAddFollower({ mail_invite_follower_channel_only: true });
+ }
+
+ /**
+ * Open a dialog to add partners as followers.
+ */
+ promptAddPartnerFollower() {
+ this._promptAddFollower({ mail_invite_follower_channel_only: false });
+ }
+
+ /**
+ * Refresh followers information from server.
+ */
+ async refreshFollowers() {
+ // FIXME Do that with only one RPC (see task-2243180)
+ const [{ message_follower_ids: followerIds }] = await this.async(() => this.env.services.rpc({
+ model: this.model,
+ method: 'read',
+ args: [this.id, ['message_follower_ids']],
+ }));
+ if (followerIds && followerIds.length > 0) {
+ const { followers } = await this.async(() => this.env.services.rpc({
+ route: '/mail/read_followers',
+ params: {
+ follower_ids: followerIds,
+ context: {}, // FIXME empty context to be overridden in session.js with 'allowed_company_ids' task-2243187
+ }
+ }));
+ this.update({
+ followers: [['insert-and-replace', followers.map(data =>
+ this.env.models['mail.follower'].convertData(data))
+ ]],
+ });
+ } else {
+ this.update({
+ followers: [['unlink-all']],
+ });
+ }
+ }
+
+ /**
+ * Refresh the typing status of the current partner.
+ */
+ refreshCurrentPartnerIsTyping() {
+ this._currentPartnerInactiveTypingTimer.reset();
+ }
+
+ /**
+ * Called to refresh a registered other member partner that is typing
+ * something.
+ *
+ * @param {mail.partner} partner
+ */
+ refreshOtherMemberTypingMember(partner) {
+ this._otherMembersLongTypingTimers.get(partner).reset();
+ }
+
+ /**
+ * Called when current partner is inserting some input in composer.
+ * Useful to notify current partner is currently typing something in the
+ * composer of this thread to all other members.
+ */
+ async registerCurrentPartnerIsTyping() {
+ // Handling of typing timers.
+ this._currentPartnerInactiveTypingTimer.start();
+ this._currentPartnerLongTypingTimer.start();
+ // Manage typing member relation.
+ const currentPartner = this.env.messaging.currentPartner;
+ const newOrderedTypingMemberLocalIds = this.orderedTypingMemberLocalIds
+ .filter(localId => localId !== currentPartner.localId);
+ newOrderedTypingMemberLocalIds.push(currentPartner.localId);
+ this.update({
+ orderedTypingMemberLocalIds: newOrderedTypingMemberLocalIds,
+ typingMembers: [['link', currentPartner]],
+ });
+ // Notify typing status to other members.
+ await this._throttleNotifyCurrentPartnerTypingStatus({ isTyping: true });
+ }
+
+ /**
+ * Called to register a new other member partner that is typing
+ * something.
+ *
+ * @param {mail.partner} partner
+ */
+ registerOtherMemberTypingMember(partner) {
+ const timer = new Timer(
+ this.env,
+ () => this.async(() => this._onOtherMemberLongTypingTimeout(partner)),
+ 60 * 1000
+ );
+ this._otherMembersLongTypingTimers.set(partner, timer);
+ timer.start();
+ const newOrderedTypingMemberLocalIds = this.orderedTypingMemberLocalIds
+ .filter(localId => localId !== partner.localId);
+ newOrderedTypingMemberLocalIds.push(partner.localId);
+ this.update({
+ orderedTypingMemberLocalIds: newOrderedTypingMemberLocalIds,
+ typingMembers: [['link', partner]],
+ });
+ }
+
+ /**
+ * Rename the given thread with provided new name.
+ *
+ * @param {string} newName
+ */
+ async rename(newName) {
+ if (this.channel_type === 'chat') {
+ await this.async(() => this.env.services.rpc({
+ model: 'mail.channel',
+ method: 'channel_set_custom_name',
+ args: [this.id],
+ kwargs: {
+ name: newName,
+ },
+ }));
+ }
+ this.update({ custom_channel_name: newName });
+ }
+
+ /**
+ * Unfollow current partner from this thread.
+ */
+ async unfollow() {
+ const currentPartnerFollower = this.followers.find(
+ follower => follower.partner === this.env.messaging.currentPartner
+ );
+ await this.async(() => currentPartnerFollower.remove());
+ }
+
+ /**
+ * Called when current partner has explicitly stopped inserting some
+ * input in composer. Useful to notify current partner has currently
+ * stopped typing something in the composer of this thread to all other
+ * members.
+ *
+ * @param {Object} [param0={}]
+ * @param {boolean} [param0.immediateNotify=false] if set, is typing
+ * status of current partner is immediately notified and doesn't
+ * consume throttling at all.
+ */
+ async unregisterCurrentPartnerIsTyping({ immediateNotify = false } = {}) {
+ // Handling of typing timers.
+ this._currentPartnerInactiveTypingTimer.clear();
+ this._currentPartnerLongTypingTimer.clear();
+ // Manage typing member relation.
+ const currentPartner = this.env.messaging.currentPartner;
+ const newOrderedTypingMemberLocalIds = this.orderedTypingMemberLocalIds
+ .filter(localId => localId !== currentPartner.localId);
+ this.update({
+ orderedTypingMemberLocalIds: newOrderedTypingMemberLocalIds,
+ typingMembers: [['unlink', currentPartner]],
+ });
+ // Notify typing status to other members.
+ if (immediateNotify) {
+ this._throttleNotifyCurrentPartnerTypingStatus.clear();
+ }
+ await this.async(
+ () => this._throttleNotifyCurrentPartnerTypingStatus({ isTyping: false })
+ );
+ }
+
+ /**
+ * Called to unregister an other member partner that is no longer typing
+ * something.
+ *
+ * @param {mail.partner} partner
+ */
+ unregisterOtherMemberTypingMember(partner) {
+ this._otherMembersLongTypingTimers.get(partner).clear();
+ this._otherMembersLongTypingTimers.delete(partner);
+ const newOrderedTypingMemberLocalIds = this.orderedTypingMemberLocalIds
+ .filter(localId => localId !== partner.localId);
+ this.update({
+ orderedTypingMemberLocalIds: newOrderedTypingMemberLocalIds,
+ typingMembers: [['unlink', partner]],
+ });
+ }
+
+ /**
+ * Unsubscribe current user from provided channel.
+ */
+ async unsubscribe() {
+ if (this.channel_type === 'mail.channel') {
+ return this.async(() => this.env.services.rpc({
+ model: 'mail.channel',
+ method: 'action_unfollow',
+ args: [[this.id]]
+ }));
+ }
+ return this.async(() => this.env.services.rpc({
+ model: 'mail.channel',
+ method: 'channel_pin',
+ args: [this.uuid, false]
+ }));
+ }
+
+ //----------------------------------------------------------------------
+ // Private
+ //----------------------------------------------------------------------
+
+ /**
+ * @override
+ */
+ static _findFunctionFromData(data) {
+ return record => record.id === data.id && record.model === data.model;
+ }
+
+ /**
+ * @private
+ * @returns {mail.attachment[]}
+ */
+ _computeAllAttachments() {
+ const allAttachments = [...new Set(this.originThreadAttachments.concat(this.attachments))]
+ .sort((a1, a2) => a1.id < a2.id ? 1 : -1);
+ return [['replace', allAttachments]];
+ }
+
+ /**
+ * @private
+ * @returns {mail.chat_window[]}
+ */
+ _computeChatWindows() {
+ const chatWindowViewers = this.viewers.filter(viewer => !!viewer.chatWindow);
+ return [['replace', chatWindowViewers.map(viewer => viewer.chatWindow)]];
+ }
+
+ /**
+ * @private
+ * @returns {string}
+ */
+ _computeDisplayName() {
+ if (this.channel_type === 'chat' && this.correspondent) {
+ return this.custom_channel_name || this.correspondent.nameOrDisplayName;
+ }
+ return this.name;
+ }
+
+ /**
+ * @private
+ * @returns {string}
+ */
+ _computeFoldState() {
+ return this.pendingFoldState || this.serverFoldState;
+ }
+
+ /**
+ * @private
+ * @returns {boolean}
+ */
+ _computeIsCurrentPartnerFollowing() {
+ return this.followers.some(follower =>
+ follower.partner && follower.partner === this.env.messaging.currentPartner
+ );
+ }
+
+ /**
+ * @private
+ * @returns {boolean}
+ */
+ _computeIsModeratedByCurrentPartner() {
+ if (this.model !== 'mail.channel') {
+ return false;
+ }
+ if (!this.messaging) {
+ return false;
+ }
+ if (!this.messaging.currentPartner) {
+ return false;
+ }
+ return this.messaging.currentPartner.moderatedChannelIds.includes(this.id);
+ }
+
+ /**
+ * @private
+ * @returns {mail.message}
+ */
+ _computeLastCurrentPartnerMessageSeenByEveryone() {
+ if (!this.partnerSeenInfos || !this.mainCacheOrderedMessages) {
+ return [['unlink-all']];
+ }
+ const otherPartnerSeenInfos =
+ this.partnerSeenInfos.filter(partnerSeenInfo =>
+ partnerSeenInfo.partner !== this.messagingCurrentPartner);
+ if (otherPartnerSeenInfos.length === 0) {
+ return [['unlink-all']];
+ }
+
+ const otherPartnersLastSeenMessageIds =
+ otherPartnerSeenInfos.map(partnerSeenInfo =>
+ partnerSeenInfo.lastSeenMessage ? partnerSeenInfo.lastSeenMessage.id : 0
+ );
+ if(otherPartnersLastSeenMessageIds.length === 0) {
+ return [['unlink-all']];
+ }
+ const lastMessageSeenByAllId = Math.min(
+ ...otherPartnersLastSeenMessageIds
+ );
+ const currentPartnerOrderedSeenMessages =
+ this.mainCacheOrderedMessages.filter(message =>
+ message.author === this.messagingCurrentPartner &&
+ message.id <= lastMessageSeenByAllId);
+
+ if (
+ !currentPartnerOrderedSeenMessages ||
+ currentPartnerOrderedSeenMessages.length === 0
+ ) {
+ return [['unlink-all']];
+ }
+ return [['replace', currentPartnerOrderedSeenMessages.slice().pop()]];
+ }
+
+ /**
+ * @private
+ * @returns {mail.message|undefined}
+ */
+ _computeLastNeedactionMessage() {
+ const orderedNeedactionMessages = this.needactionMessages.sort(
+ (m1, m2) => m1.id < m2.id ? -1 : 1
+ );
+ const {
+ length: l,
+ [l - 1]: lastNeedactionMessage,
+ } = orderedNeedactionMessages;
+ return [['replace', lastNeedactionMessage]];
+ }
+
+ /**
+ * @private
+ * @returns {mail.thread_cache}
+ */
+ _computeMainCache() {
+ return [['replace', this.cache('[]')]];
+ }
+
+ /**
+ * @private
+ * @returns {mail.message[]}
+ */
+ _computeMessages() {
+ const allMessages = this.mainCacheMessages.concat(this.originThreadMessages);
+ return [['replace', allMessages]];
+ }
+
+ /**
+ * @private
+ * @returns {mail.messaging}
+ */
+ _computeMessaging() {
+ return [['link', this.env.messaging]];
+ }
+
+ /**
+ * @private
+ * @returns {mail.message[]}
+ */
+ _computeNeedactionMessages() {
+ return [['replace', this.messages.filter(message => message.isNeedaction)]];
+ }
+
+ /**
+ * @private
+ * @returns {mail.partner[]}
+ */
+ _computeOrderedOtherTypingMembers() {
+ return [[
+ 'replace',
+ this.orderedTypingMembers.filter(
+ member => member !== this.env.messaging.currentPartner
+ ),
+ ]];
+ }
+
+ /**
+ * @private
+ * @returns {mail.partner[]}
+ */
+ _computeOrderedTypingMembers() {
+ return [[
+ 'replace',
+ this.orderedTypingMemberLocalIds
+ .map(localId => this.env.models['mail.partner'].get(localId))
+ .filter(member => !!member),
+ ]];
+ }
+
+ /**
+ * @private
+ * @returns {string}
+ */
+ _computeTypingStatusText() {
+ if (this.orderedOtherTypingMembers.length === 0) {
+ return this.constructor.fields.typingStatusText.default;
+ }
+ if (this.orderedOtherTypingMembers.length === 1) {
+ return _.str.sprintf(
+ this.env._t("%s is typing..."),
+ this.orderedOtherTypingMembers[0].nameOrDisplayName
+ );
+ }
+ if (this.orderedOtherTypingMembers.length === 2) {
+ return _.str.sprintf(
+ this.env._t("%s and %s are typing..."),
+ this.orderedOtherTypingMembers[0].nameOrDisplayName,
+ this.orderedOtherTypingMembers[1].nameOrDisplayName
+ );
+ }
+ return _.str.sprintf(
+ this.env._t("%s, %s and more are typing..."),
+ this.orderedOtherTypingMembers[0].nameOrDisplayName,
+ this.orderedOtherTypingMembers[1].nameOrDisplayName
+ );
+ }
+
+ /**
+ * @override
+ */
+ _createRecordLocalId(data) {
+ const { channel_type, id, isTemporary = false, model } = data;
+ let threadModel = model;
+ if (!threadModel && channel_type) {
+ threadModel = 'mail.channel';
+ }
+ const Thread = this.env.models['mail.thread'];
+ if (isTemporary) {
+ return `${Thread.modelName}_${id}`;
+ }
+ return `${Thread.modelName}_${threadModel}_${id}`;
+ }
+
+ /**
+ * @private
+ * @param {Object} param0
+ * @param {boolean} param0.isTyping
+ */
+ async _notifyCurrentPartnerTypingStatus({ isTyping }) {
+ if (
+ this._forceNotifyNextCurrentPartnerTypingStatus ||
+ isTyping !== this._currentPartnerLastNotifiedIsTyping
+ ) {
+ if (this.model === 'mail.channel') {
+ await this.async(() => this.env.services.rpc({
+ model: 'mail.channel',
+ method: 'notify_typing',
+ args: [this.id],
+ kwargs: { is_typing: isTyping },
+ }, { shadow: true }));
+ }
+ if (isTyping && this._currentPartnerLongTypingTimer.isRunning) {
+ this._currentPartnerLongTypingTimer.reset();
+ }
+ }
+ this._forceNotifyNextCurrentPartnerTypingStatus = false;
+ this._currentPartnerLastNotifiedIsTyping = isTyping;
+ }
+
+ /**
+ * @private
+ * @param {Object} [param0={}]
+ * @param {boolean} [param0.mail_invite_follower_channel_only=false]
+ */
+ _promptAddFollower({ mail_invite_follower_channel_only = false } = {}) {
+ const action = {
+ type: 'ir.actions.act_window',
+ res_model: 'mail.wizard.invite',
+ view_mode: 'form',
+ views: [[false, 'form']],
+ name: this.env._t("Invite Follower"),
+ target: 'new',
+ context: {
+ default_res_model: this.model,
+ default_res_id: this.id,
+ mail_invite_follower_channel_only,
+ },
+ };
+ this.env.bus.trigger('do-action', {
+ action,
+ options: {
+ on_close: () => this.refreshFollowers(),
+ },
+ });
+ }
+
+ /**
+ * @override
+ */
+ _updateAfter(previous) {
+ if (this.model !== 'mail.channel') {
+ // fold state only makes sense on channels
+ return;
+ }
+ if (
+ this.pendingFoldState &&
+ previous.pendingFoldState !== this.pendingFoldState
+ ) {
+ this.notifyFoldStateToServer();
+ }
+ if (
+ this.serverFoldState === this.pendingFoldState
+ ) {
+ this.update({ pendingFoldState: undefined });
+ }
+
+ // TODO FIXME prevent to open/close a channel on mobile when you
+ // open/close it on desktop (task-2267593)
+
+ // chat window
+ if (previous.foldState === this.foldState) {
+ // avoid updating chatWindows when not changing foldState
+ // important to avoid issues when thread is in progress of being
+ // opened, because the foldState is updated only at the end of
+ // the process
+ return;
+ }
+ if (this.foldState !== 'closed' && this.chatWindows.length === 0) {
+ // condition to avoid crash during destroy
+ if (this.env.messaging.chatWindowManager) {
+ this.env.messaging.chatWindowManager.openThread(this);
+ }
+ }
+ if (this.foldState === 'closed' && this.chatWindows.length > 0) {
+ for (const chatWindow of this.chatWindows) {
+ chatWindow.close();
+ }
+ }
+ }
+
+ /**
+ * @override
+ */
+ _updateBefore() {
+ return {
+ foldState: this.foldState,
+ pendingFoldState: this.pendingFoldState,
+ };
+ }
+
+ //----------------------------------------------------------------------
+ // Handlers
+ //----------------------------------------------------------------------
+
+ /**
+ * @private
+ */
+ async _onCurrentPartnerInactiveTypingTimeout() {
+ await this.async(() => this.unregisterCurrentPartnerIsTyping());
+ }
+
+ /**
+ * Called when current partner has been typing for a very long time.
+ * Immediately notify other members that he/she is still typing.
+ *
+ * @private
+ */
+ async _onCurrentPartnerLongTypingTimeout() {
+ this._forceNotifyNextCurrentPartnerTypingStatus = true;
+ this._throttleNotifyCurrentPartnerTypingStatus.clear();
+ await this.async(
+ () => this._throttleNotifyCurrentPartnerTypingStatus({ isTyping: true })
+ );
+ }
+
+ /**
+ * @private
+ * @param {mail.partner} partner
+ */
+ async _onOtherMemberLongTypingTimeout(partner) {
+ if (!this.typingMembers.includes(partner)) {
+ this._otherMembersLongTypingTimers.delete(partner);
+ return;
+ }
+ this.unregisterOtherMemberTypingMember(partner);
+ }
+ }
+
+ Thread.fields = {
+ allAttachments: many2many('mail.attachment', {
+ compute: '_computeAllAttachments',
+ dependencies: [
+ 'attachments',
+ 'originThreadAttachments',
+ ],
+ }),
+ areAttachmentsLoaded: attr({
+ default: false,
+ }),
+ attachments: many2many('mail.attachment', {
+ inverse: 'threads',
+ }),
+ caches: one2many('mail.thread_cache', {
+ inverse: 'thread',
+ isCausal: true,
+ }),
+ channel_type: attr(),
+ chatWindows: one2many('mail.chat_window', {
+ compute: '_computeChatWindows',
+ dependencies: ['viewersChatWindow'],
+ }),
+ composer: one2one('mail.composer', {
+ autocreate: true,
+ inverse: 'thread',
+ isCausal: true,
+ }),
+ correspondent: many2one('mail.partner', {
+ inverse: 'correspondentThreads',
+ }),
+ correspondentNameOrDisplayName: attr({
+ related: 'correspondent.nameOrDisplayName',
+ }),
+ counter: attr({
+ default: 0,
+ }),
+ creator: many2one('mail.user'),
+ custom_channel_name: attr(),
+ displayName: attr({
+ compute: '_computeDisplayName',
+ dependencies: [
+ 'channel_type',
+ 'correspondent',
+ 'correspondentNameOrDisplayName',
+ 'custom_channel_name',
+ 'name',
+ ],
+ }),
+ /**
+ * Determine the fold state of the channel on the web client.
+ *
+ * If there is a pending fold state change, it is immediately applied on
+ * the interface to avoid a feeling of unresponsiveness. Otherwise the
+ * last known fold state of the server is used.
+ *
+ * This field must be considered read only.
+ */
+ foldState: attr({
+ compute: '_computeFoldState',
+ dependencies: [
+ 'pendingFoldState',
+ 'serverFoldState',
+ ],
+ }),
+ followersPartner: many2many('mail.partner', {
+ related: 'followers.partner',
+ }),
+ followers: one2many('mail.follower', {
+ inverse: 'followedThread',
+ }),
+ group_based_subscription: attr({
+ default: false,
+ }),
+ id: attr(),
+ isCurrentPartnerFollowing: attr({
+ compute: '_computeIsCurrentPartnerFollowing',
+ default: false,
+ dependencies: [
+ 'followersPartner',
+ 'messagingCurrentPartner',
+ ],
+ }),
+ isModeratedByCurrentPartner: attr({
+ compute: '_computeIsModeratedByCurrentPartner',
+ dependencies: [
+ 'model',
+ 'messagingCurrentPartner',
+ ],
+ }),
+ isPinned: attr({
+ default: false,
+ }),
+ isTemporary: attr({
+ default: false,
+ }),
+ is_moderator: attr({
+ default: false,
+ }),
+ lastCurrentPartnerMessageSeenByEveryone: many2one('mail.message', {
+ compute: '_computeLastCurrentPartnerMessageSeenByEveryone',
+ dependencies: [
+ 'partnerSeenInfos',
+ 'mainCacheOrderedMessages',
+ 'messagingCurrentPartner',
+ ],
+ }),
+ lastMessage: many2one('mail.message', {
+ related: 'mainCache.lastMessage',
+ }),
+ lastNeedactionMessage: many2one('mail.message', {
+ compute: '_computeLastNeedactionMessage',
+ dependencies: ['needactionMessages'],
+ }),
+ mainCache: one2one('mail.thread_cache', {
+ compute: '_computeMainCache',
+ dependencies: ['caches'],
+ }),
+ mainCacheMessages: many2many('mail.message', {
+ related: 'mainCache.messages',
+ }),
+ mainCacheOrderedMessages: many2many('mail.message', {
+ related: 'mainCache.orderedMessages',
+ }),
+ mass_mailing: attr({
+ default: false,
+ }),
+ members: many2many('mail.partner', {
+ inverse: 'memberThreads',
+ }),
+ message_needaction_counter: attr({
+ default: 0,
+ }),
+ message_unread_counter: attr({
+ default: 0,
+ }),
+ messages: many2many('mail.message', {
+ compute: '_computeMessages',
+ dependencies: [
+ 'mainCacheMessages',
+ 'originThreadMessages',
+ ],
+ }),
+ messageSeenIndicators: one2many('mail.message_seen_indicator', {
+ inverse: 'thread',
+ isCausal: true,
+ }),
+ messaging: many2one('mail.messaging', {
+ compute: '_computeMessaging',
+ }),
+ messagingCurrentPartner: many2one('mail.partner', {
+ related: 'messaging.currentPartner',
+ }),
+ model: attr(),
+ model_name: attr(),
+ moderation: attr({
+ default: false,
+ }),
+ moduleIcon: attr(),
+ name: attr(),
+ needactionMessages: many2many('mail.message', {
+ compute: '_computeNeedactionMessages',
+ dependencies: ['messages'],
+ }),
+ /**
+ * Ordered typing members on this thread, excluding the current partner.
+ */
+ orderedOtherTypingMembers: many2many('mail.partner', {
+ compute: '_computeOrderedOtherTypingMembers',
+ dependencies: ['orderedTypingMembers'],
+ }),
+ /**
+ * Ordered typing members on this thread. Lower index means this member
+ * is currently typing for the longest time. This list includes current
+ * partner as typer.
+ */
+ orderedTypingMembers: many2many('mail.partner', {
+ compute: '_computeOrderedTypingMembers',
+ dependencies: [
+ 'orderedTypingMemberLocalIds',
+ 'typingMembers',
+ ],
+ }),
+ /**
+ * Technical attribute to manage ordered list of typing members.
+ */
+ orderedTypingMemberLocalIds: attr({
+ default: [],
+ }),
+ originThreadAttachments: one2many('mail.attachment', {
+ inverse: 'originThread',
+ }),
+ originThreadMessages: one2many('mail.message', {
+ inverse: 'originThread',
+ }),
+ partnerSeenInfos: one2many('mail.thread_partner_seen_info', {
+ inverse: 'thread',
+ isCausal: true,
+ }),
+ /**
+ * Determine if there is a pending fold state change, which is a change
+ * of fold state requested by the client but not yet confirmed by the
+ * server.
+ *
+ * This field can be updated to immediately change the fold state on the
+ * interface and to notify the server of the new state.
+ */
+ pendingFoldState: attr(),
+ public: attr(),
+ seen_message_id: attr(),
+ /**
+ * Determine the last fold state known by the server, which is the fold
+ * state displayed after initialization or when the last pending
+ * fold state change was confirmed by the server.
+ *
+ * This field should be considered read only in most situations. Only
+ * the code handling fold state change from the server should typically
+ * update it.
+ */
+ serverFoldState: attr({
+ default: 'closed',
+ }),
+ /**
+ * Members that are currently typing something in the composer of this
+ * thread, including current partner.
+ */
+ typingMembers: many2many('mail.partner'),
+ /**
+ * Text that represents the status on this thread about typing members.
+ */
+ typingStatusText: attr({
+ compute: '_computeTypingStatusText',
+ default: '',
+ dependencies: ['orderedOtherTypingMembers'],
+ }),
+ uuid: attr(),
+ viewers: one2many('mail.thread_viewer', {
+ inverse: 'thread',
+ }),
+ viewersChatWindow: many2many('mail.chat_window', {
+ related: 'viewers.chatWindow',
+ }),
+ };
+
+ Thread.modelName = 'mail.thread';
+
+ return Thread;
+}
+
+registerNewModel('mail.thread', factory);
+
+});
diff --git a/addons/mail/static/src/models/thread/thread_tests.js b/addons/mail/static/src/models/thread/thread_tests.js
new file mode 100644
index 00000000000..02b3966ecde
--- /dev/null
+++ b/addons/mail/static/src/models/thread/thread_tests.js
@@ -0,0 +1,162 @@
+odoo.define('mail/static/src/models/thread/thread_tests.js', function (require) {
+'use strict';
+
+const {
+ afterEach: utilsAfterEach,
+ beforeEach: utilsBeforeEach,
+ start: utilsStart,
+} = require('mail/static/src/utils/test_utils.js');
+
+QUnit.module('mail', {}, function () {
+QUnit.module('models', {}, function () {
+QUnit.module('thread', {}, function () {
+QUnit.module('thread_tests.js', {
+ beforeEach() {
+ utilsBeforeEach(this);
+
+ this.start = async params => {
+ if (this.widget) {
+ this.widget.destroy();
+ }
+ let { env, widget } = await utilsStart(Object.assign({}, params, {
+ data: this.data,
+ }));
+ this.env = env;
+ this.widget = widget;
+ };
+ },
+ afterEach() {
+ utilsAfterEach(this);
+ this.env = undefined;
+ if (this.widget) {
+ this.widget.destroy();
+ this.widget = undefined;
+ }
+ },
+});
+
+QUnit.test('inbox & starred mailboxes', async function (assert) {
+ assert.expect(10);
+
+ await this.start();
+ const mailboxInbox = this.env.messaging.inbox;
+ const mailboxStarred = this.env.messaging.starred;
+ assert.ok(mailboxInbox, "should have mailbox inbox");
+ assert.ok(mailboxStarred, "should have mailbox starred");
+ assert.strictEqual(mailboxInbox.model, 'mail.box');
+ assert.strictEqual(mailboxInbox.counter, 0);
+ assert.strictEqual(mailboxInbox.id, 'inbox');
+ assert.strictEqual(mailboxInbox.name, "Inbox"); // language-dependent
+ assert.strictEqual(mailboxStarred.model, 'mail.box');
+ assert.strictEqual(mailboxStarred.counter, 0);
+ assert.strictEqual(mailboxStarred.id, 'starred');
+ assert.strictEqual(mailboxStarred.name, "Starred"); // language-dependent
+});
+
+QUnit.test('create (channel)', async function (assert) {
+ assert.expect(23);
+
+ await this.start();
+ assert.notOk(this.env.models['mail.partner'].find(partner => partner.id === 9));
+ assert.notOk(this.env.models['mail.partner'].find(partner => partner.id === 10));
+ assert.notOk(this.env.models['mail.thread'].find(thread =>
+ thread.id === 100 &&
+ thread.model === 'mail.channel'
+ ));
+
+ const thread = this.env.models['mail.thread'].create({
+ channel_type: 'channel',
+ id: 100,
+ members: [['insert', [{
+ email: "john@example.com",
+ id: 9,
+ name: "John",
+ }, {
+ email: "fred@example.com",
+ id: 10,
+ name: "Fred",
+ }]]],
+ message_needaction_counter: 6,
+ message_unread_counter: 5,
+ model: 'mail.channel',
+ name: "General",
+ public: 'public',
+ });
+ assert.ok(thread);
+ assert.ok(this.env.models['mail.partner'].find(partner => partner.id === 9));
+ assert.ok(this.env.models['mail.partner'].find(partner => partner.id === 10));
+ assert.ok(this.env.models['mail.thread'].find(thread =>
+ thread.id === 100 &&
+ thread.model === 'mail.channel'
+ ));
+ const partner9 = this.env.models['mail.partner'].find(partner => partner.id === 9);
+ const partner10 = this.env.models['mail.partner'].find(partner => partner.id === 10);
+ assert.strictEqual(thread, this.env.models['mail.thread'].find(thread =>
+ thread.id === 100 &&
+ thread.model === 'mail.channel'
+ ));
+ assert.strictEqual(thread.model, 'mail.channel');
+ assert.strictEqual(thread.channel_type, 'channel');
+ assert.strictEqual(thread.id, 100);
+ assert.ok(thread.members.includes(partner9));
+ assert.ok(thread.members.includes(partner10));
+ assert.strictEqual(thread.message_needaction_counter, 6);
+ assert.strictEqual(thread.message_unread_counter, 5);
+ assert.strictEqual(thread.name, "General");
+ assert.strictEqual(thread.public, 'public');
+ assert.strictEqual(partner9.email, "john@example.com");
+ assert.strictEqual(partner9.id, 9);
+ assert.strictEqual(partner9.name, "John");
+ assert.strictEqual(partner10.email, "fred@example.com");
+ assert.strictEqual(partner10.id, 10);
+ assert.strictEqual(partner10.name, "Fred");
+});
+
+QUnit.test('create (chat)', async function (assert) {
+ assert.expect(15);
+
+ await this.start();
+ assert.notOk(this.env.models['mail.partner'].find(partner => partner.id === 5));
+ assert.notOk(this.env.models['mail.thread'].find(thread =>
+ thread.id === 200 &&
+ thread.model === 'mail.channel'
+ ));
+
+ const channel = this.env.models['mail.thread'].create({
+ channel_type: 'chat',
+ correspondent: [['insert', {
+ email: "demo@example.com",
+ id: 5,
+ im_status: 'online',
+ name: "Demo",
+ }]],
+ id: 200,
+ model: 'mail.channel',
+ });
+ assert.ok(channel);
+ assert.ok(this.env.models['mail.thread'].find(thread =>
+ thread.id === 200 &&
+ thread.model === 'mail.channel'
+ ));
+ assert.ok(this.env.models['mail.partner'].find(partner => partner.id === 5));
+ const partner = this.env.models['mail.partner'].find(partner => partner.id === 5);
+ assert.strictEqual(channel, this.env.models['mail.thread'].find(thread =>
+ thread.id === 200 &&
+ thread.model === 'mail.channel'
+ ));
+ assert.strictEqual(channel.model, 'mail.channel');
+ assert.strictEqual(channel.channel_type, 'chat');
+ assert.strictEqual(channel.id, 200);
+ assert.ok(channel.correspondent);
+ assert.strictEqual(partner, channel.correspondent);
+ assert.strictEqual(partner.email, "demo@example.com");
+ assert.strictEqual(partner.id, 5);
+ assert.strictEqual(partner.im_status, 'online');
+ assert.strictEqual(partner.name, "Demo");
+});
+
+});
+});
+});
+
+});
diff --git a/addons/mail/static/src/models/thread_cache/thread_cache.js b/addons/mail/static/src/models/thread_cache/thread_cache.js
new file mode 100644
index 00000000000..edd991c693c
--- /dev/null
+++ b/addons/mail/static/src/models/thread_cache/thread_cache.js
@@ -0,0 +1,263 @@
+odoo.define('mail/static/src/models/thread_cache/thread_cache.js', function (require) {
+'use strict';
+
+const { registerNewModel } = require('mail/static/src/model/model_core.js');
+const { attr, many2many, many2one } = require('mail/static/src/model/model_field.js');
+
+const MESSAGE_FETCH_LIMIT = 30;
+
+function factory(dependencies) {
+
+ class ThreadCache extends dependencies['mail.model'] {
+
+ //----------------------------------------------------------------------
+ // Public
+ //----------------------------------------------------------------------
+
+ /**
+ * Load this thread cache, by fetching the most recent messages in this
+ * conversation.
+ */
+ async loadMessages() {
+ if (this.isLoaded && this.isLoading) {
+ return;
+ }
+ const searchDomain = JSON.parse(this.stringifiedDomain);
+ let domain = searchDomain.length ? searchDomain : [];
+ domain = this._extendMessageDomain(domain);
+ this.update({ isLoading: true });
+ let messagesData = [];
+ if (!this.thread.isTemporary) {
+ messagesData = await this.async(() => this.env.services.rpc({
+ model: 'mail.message',
+ method: 'message_fetch',
+ args: [domain],
+ kwargs: this._getFetchMessagesKwargs(),
+ }, { shadow: true }));
+ }
+ this._handleMessagesLoaded(messagesData);
+ }
+
+ async loadMoreMessages() {
+ const searchDomain = JSON.parse(this.stringifiedDomain);
+ let domain = searchDomain.length ? searchDomain : [];
+ domain = this._extendMessageDomain(domain);
+ if (this.isAllHistoryLoaded && this.isLoadingMore) {
+ return;
+ }
+ this.update({ isLoadingMore: true });
+ const minMessageId = Math.min(
+ ...this.messages.map(message => message.id)
+ );
+ domain = [['id', '<', minMessageId]].concat(domain);
+ const messagesData = await this.async(() => this.env.services.rpc({
+ model: 'mail.message',
+ method: 'message_fetch',
+ args: [domain],
+ kwargs: this._getFetchMessagesKwargs(),
+ }, { shadow: true }));
+ for (const viewer of this.thread.viewers) {
+ viewer.addComponentHint('more-messages-loaded');
+ }
+ this._handleMessagesLoaded(messagesData);
+ }
+
+ async loadNewMessages() {
+ if (this.isLoading) {
+ return;
+ }
+ if (!this.isLoaded) {
+ await this.async(() => this.loadMessages());
+ return;
+ }
+ const messageIds = this.messages.map(message => message.id);
+ const searchDomain = JSON.parse(this.stringifiedDomain);
+ let domain = searchDomain.length ? searchDomain : [];
+ domain = this._extendMessageDomain(domain);
+ if (messageIds.length > 0) {
+ const lastMessageId = Math.max(...messageIds);
+ domain = [['id', '>', lastMessageId]].concat(domain);
+ }
+ this.update({ isLoading: true });
+ const messageFetchKwargs = this._getFetchMessagesKwargs();
+ messageFetchKwargs.limit = false;
+ const messagesData = await this.async(() => this.env.services.rpc({
+ model: 'mail.message',
+ method: 'message_fetch',
+ args: [domain],
+ kwargs: messageFetchKwargs,
+ }, { shadow: true }));
+ this._handleMessagesLoaded(messagesData);
+ }
+
+ //----------------------------------------------------------------------
+ // Private
+ //----------------------------------------------------------------------
+
+ /**
+ * @private
+ * @returns {mail.message|undefined}
+ */
+ _computeLastMessage() {
+ const { length: l, [l - 1]: lastMessage } = this.orderedMessages;
+ if (!lastMessage) {
+ return [['unlink-all']];
+ }
+ return [['replace', lastMessage]];
+ }
+
+ /**
+ * @private
+ * @returns {mail.message[]}
+ */
+ _computeOrderedMessages() {
+ return [['replace', this.messages.sort((m1, m2) => m1.id < m2.id ? -1 : 1)]];
+ }
+
+ /**
+ * @private
+ * @returns {mail.message[]}
+ */
+ _computeUncheckedMessages() {
+ return [['replace', this.messages.filter(
+ message => message.hasCheckbox && !this.checkedMessages.includes(message)
+ )]];
+ }
+
+ /**
+ * @override
+ */
+ _createRecordLocalId(data) {
+ const {
+ stringifiedDomain = '[]',
+ thread: [[commandInsert, thread]],
+ } = data;
+ const ThreadCache = this.env.models['mail.thread_cache'];
+ return `${ThreadCache.modelName}_[${thread.localId}]_<${stringifiedDomain}>`;
+ }
+
+ /**
+ * @private
+ * @param {Array} domain
+ * @returns {Array}
+ */
+ _extendMessageDomain(domain) {
+ const thread = this.thread;
+ if (thread.model === 'mail.channel') {
+ return domain.concat([['channel_ids', 'in', [thread.id]]]);
+ } else if (thread === this.env.messaging.inbox) {
+ return domain.concat([['needaction', '=', true]]);
+ } else if (thread === this.env.messaging.starred) {
+ return domain.concat([['starred', '=', true]]);
+ } else if (thread === this.env.messaging.history) {
+ return domain.concat([['needaction', '=', false]]);
+ } else if (thread === this.env.messaging.moderation) {
+ return domain.concat([['need_moderation', '=', true]]);
+ } else {
+ return domain.concat([['model', '=', thread.model], ['res_id', '=', thread.id]]);
+ }
+ }
+
+ /**
+ * @private
+ * @returns {Object}
+ */
+ _getFetchMessagesKwargs() {
+ const thread = this.thread;
+ let kwargs = {
+ limit: MESSAGE_FETCH_LIMIT,
+ context: this.env.session.user_context,
+ };
+ if (thread.moderation) {
+ // thread is a channel
+ kwargs.moderated_channel_ids = [thread.id];
+ }
+ return kwargs;
+ }
+
+ /**
+ * @private
+ * @param {Object[]} messageData
+ */
+ _handleMessagesLoaded(messagesData) {
+ this.update({
+ isAllHistoryLoaded: messagesData.length < MESSAGE_FETCH_LIMIT,
+ isLoaded: true,
+ isLoading: false,
+ isLoadingMore: false,
+ messages: [['insert', messagesData.map(data =>
+ this.env.models['mail.message'].convertData(data))
+ ]],
+ });
+ if (!this.thread) {
+ return;
+ }
+ this.thread.update({
+ messageSeenIndicators: [['insert', messagesData.map(messageData => {
+ return {
+ id: this.env.models['mail.message_seen_indicator'].computeId(messageData.id, this.thread.id),
+ message: [['insert', { id: messageData.id }]],
+ };
+ })]],
+ });
+ for (const viewer of this.thread.viewers) {
+ viewer.handleThreadCacheLoaded(this);
+ }
+ }
+
+ }
+
+ ThreadCache.fields = {
+ checkedMessages: many2many('mail.message', {
+ inverse: 'checkedThreadCaches',
+ }),
+ isAllHistoryLoaded: attr({
+ default: false,
+ }),
+ isLoaded: attr({
+ default: false,
+ }),
+ isLoading: attr({
+ default: false,
+ }),
+ isLoadingMore: attr({
+ default: false,
+ }),
+ lastMessage: many2one('mail.message', {
+ compute: '_computeLastMessage',
+ dependencies: ['orderedMessages'],
+ }),
+ messagesCheckboxes: attr({
+ related: 'messages.hasCheckbox',
+ }),
+ messages: many2many('mail.message', {
+ inverse: 'threadCaches',
+ }),
+ orderedMessages: many2many('mail.message', {
+ compute: '_computeOrderedMessages',
+ dependencies: ['messages'],
+ }),
+ stringifiedDomain: attr({
+ default: '[]',
+ }),
+ thread: many2one('mail.thread', {
+ inverse: 'caches',
+ }),
+ uncheckedMessages: many2many('mail.message', {
+ compute: '_computeUncheckedMessages',
+ dependencies: [
+ 'checkedMessages',
+ 'messagesCheckboxes',
+ 'messages',
+ ],
+ }),
+ };
+
+ ThreadCache.modelName = 'mail.thread_cache';
+
+ return ThreadCache;
+}
+
+registerNewModel('mail.thread_cache', factory);
+
+});
diff --git a/addons/mail/static/src/models/thread_partner_seen_info/thread_partner_seen_info.js b/addons/mail/static/src/models/thread_partner_seen_info/thread_partner_seen_info.js
new file mode 100644
index 00000000000..6ea0148d6d0
--- /dev/null
+++ b/addons/mail/static/src/models/thread_partner_seen_info/thread_partner_seen_info.js
@@ -0,0 +1,28 @@
+odoo.define('mail/static/src/models/thread_partner_seen_info/thread_partner_seen_info.js', function (require) {
+'use strict';
+
+const { registerNewModel } = require('mail/static/src/model/model_core.js');
+const { attr, many2one } = require('mail/static/src/model/model_field.js');
+
+function factory(dependencies) {
+
+ class ThreadPartnerSeenInfo extends dependencies['mail.model'] {}
+
+ ThreadPartnerSeenInfo.modelName = 'mail.thread_partner_seen_info';
+
+ ThreadPartnerSeenInfo.fields = {
+ id: attr(),
+ lastFetchedMessage: many2one('mail.message'),
+ lastSeenMessage: many2one('mail.message'),
+ partner: many2one('mail.partner'),
+ thread: many2one('mail.thread', {
+ inverse: 'partnerSeenInfos',
+ }),
+ };
+
+ return ThreadPartnerSeenInfo;
+}
+
+registerNewModel('mail.thread_partner_seen_info', factory);
+
+});
diff --git a/addons/mail/static/src/models/thread_viewer/thread_viewer.js b/addons/mail/static/src/models/thread_viewer/thread_viewer.js
new file mode 100644
index 00000000000..4da88fd9513
--- /dev/null
+++ b/addons/mail/static/src/models/thread_viewer/thread_viewer.js
@@ -0,0 +1,270 @@
+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, many2many, many2one, one2one } = require('mail/static/src/model/model_field.js');
+
+function factory(dependencies) {
+
+ class ThreadViewer extends dependencies['mail.model'] {
+
+ /**
+ * @override
+ */
+ delete() {
+ this._stopLoading();
+ super.delete();
+ }
+
+ //----------------------------------------------------------------------
+ // 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
+ * thread viewer 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 = this._makeComponentHint(hintType, hintData);
+ this.update({
+ componentHintList: this.componentHintList.concat([hint]),
+ });
+ }
+
+ /**
+ * @param {mail.thread_cache} threadCache
+ */
+ handleThreadCacheLoaded(threadCache) {
+ if (threadCache !== this.threadCache) {
+ return;
+ }
+ this._stopLoading();
+ }
+
+ /**
+ * @param {Object} hint
+ */
+ markComponentHintProcessed(hint) {
+ let filterFun;
+ switch (hint.type) {
+ case 'current-partner-just-posted-message':
+ filterFun = h => h.type !== hint.type && h.messageId !== hint.messageId;
+ break;
+ default:
+ filterFun = h => h.type !== hint.type;
+ break;
+ }
+ this.update({
+ componentHintList: this.componentHintList.filter(filterFun),
+ });
+ }
+
+ /**
+ * @param {string} scrollTop
+ */
+ saveThreadCacheScrollPositionsAsInitial(scrollTop) {
+ if (!this.threadCache) {
+ return;
+ }
+ this.update({
+ threadCacheInitialScrollPositions: Object.assign({}, this.threadCacheInitialScrollPositions, {
+ [this.threadCache.localId]: scrollTop,
+ }),
+ });
+ }
+
+ //----------------------------------------------------------------------
+ // Private
+ //----------------------------------------------------------------------
+
+ /**
+ * @private
+ * @returns {mail.thread_cache|undefined}
+ */
+ _computeThreadCache() {
+ if (!this.thread) {
+ return [];
+ }
+ return [['replace', this.thread.cache(this.stringifiedDomain)]];
+ }
+
+ /**
+ * @private
+ * @returns {integer|undefined}
+ */
+ _computeThreadCacheInitialPosition() {
+ if (!this.threadCache) {
+ return undefined;
+ }
+ return this.threadCacheInitialScrollPositions[this.threadCache.localId];
+ }
+
+ /**
+ * @private
+ */
+ _prepareLoading() {
+ this._isPreparingLoading = true;
+ this._loaderTimeout = setTimeout(() => {
+ this.isShowingLoading = true;
+ this._isPreparingLoading = false;
+ }, 400);
+ }
+
+ /**
+ * @private
+ */
+ _stopLoading() {
+ clearTimeout(this._loaderTimeout);
+ this._loaderTimeout = null;
+ this.isShowingLoading = false;
+ this._isPreparingLoading = false;
+ }
+
+ /**
+ * @private
+ * @param {string} hintType
+ * @param {any} hintData
+ * @returns {Object}
+ */
+ _makeComponentHint(hintType, hintData) {
+ let hint;
+ switch (hintType) {
+ case 'change-of-thread-cache':
+ hint = { type: hintType };
+ break;
+ case 'current-partner-just-posted-message':
+ hint = {
+ messageId: hintData,
+ type: hintType,
+ };
+ break;
+ case 'more-messages-loaded':
+ hint = { type: hintType };
+ break;
+ default:
+ throw new Error(`Undefined component hint "${hintType}" for ThreadViewer`);
+ }
+ return hint;
+ }
+
+ /**
+ * @override
+ */
+ _updateAfter(previous) {
+ if (this.thread && this.thread !== previous.thread) {
+ this._stopLoading();
+ if (!this.threadCache.isLoaded && !this.threadCache.isLoading) {
+ this.threadCache.loadMessages();
+ }
+ }
+ if (this.threadCache !== previous.threadCache) {
+ this._stopLoading();
+ this.addComponentHint('change-of-thread-cache');
+ }
+
+ if (
+ this.thread && this.threadCache.isLoading &&
+ !this.isShowingLoading && !this._isPreparingLoading
+ ) {
+ this._prepareLoading();
+ }
+ }
+
+ /**
+ * @override
+ */
+ _updateBefore() {
+ return {
+ thread: this.thread,
+ threadCache: this.threadCache,
+ };
+ }
+
+ }
+
+ ThreadViewer.fields = {
+ chatWindow: one2one('mail.chat_window', {
+ inverse: 'threadViewer',
+ }),
+ 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: [],
+ }),
+ messages: many2many('mail.message', {
+ related: 'threadCache.messages',
+ }),
+ stringifiedDomain: attr({
+ default: '[]',
+ }),
+ thread: many2one('mail.thread', {
+ inverse: 'viewers',
+ }),
+ threadCache: many2one('mail.thread_cache', {
+ compute: '_computeThreadCache',
+ dependencies: [
+ 'stringifiedDomain',
+ 'thread',
+ 'threadCaches',
+ ],
+ }),
+ threadCacheInitialPosition: attr({
+ compute: '_computeThreadCacheInitialPosition',
+ dependencies: [
+ 'threadCache',
+ 'threadCacheInitialScrollPositions',
+ ],
+ }),
+ /**
+ * List of saved 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: {},
+ }),
+ threadCaches: many2many('mail.thread_cache', {
+ related: 'thread.caches',
+ }),
+ uncheckedMessages: many2many('mail.message', {
+ related: 'threadCache.uncheckedMessages',
+ }),
+ };
+
+ ThreadViewer.modelName = 'mail.thread_viewer';
+
+ return ThreadViewer;
+}
+
+registerNewModel('mail.thread_viewer', factory);
+
+});
diff --git a/addons/mail/static/src/models/user/user.js b/addons/mail/static/src/models/user/user.js
new file mode 100644
index 00000000000..2e6206c79f5
--- /dev/null
+++ b/addons/mail/static/src/models/user/user.js
@@ -0,0 +1,78 @@
+odoo.define('mail/static/src/models/user/user.js', function (require) {
+'use strict';
+
+const { registerNewModel } = require('mail/static/src/model/model_core.js');
+const { attr, one2one } = require('mail/static/src/model/model_field.js');
+
+function factory(dependencies) {
+
+ class User extends dependencies['mail.model'] {
+
+ /**
+ * @override
+ */
+ delete() {
+ if (this.env.messaging) {
+ if (this === this.env.messaging.currentUser) {
+ this.env.messaging.update({ currentUser: [['unlink-all']] });
+ }
+ }
+ super.delete();
+ }
+
+ //----------------------------------------------------------------------
+ // Public
+ //----------------------------------------------------------------------
+
+ /**
+ * @returns {string}
+ */
+ nameOrDisplayName() {
+ const partner = this.partner;
+ if (!partner) {
+ return this.partnerDisplayName;
+ }
+ return partner.nameOrDisplayName;
+ }
+
+ //----------------------------------------------------------------------
+ // Private
+ //----------------------------------------------------------------------
+
+ /**
+ * @override
+ */
+ _createRecordLocalId(data) {
+ return `${this.env.models['mail.user'].modelName}_${data.id}`;
+ }
+
+ /**
+ * @override
+ */
+ _updateAfter(previous) {
+ if (this.partnerDisplayName && this.partner) {
+ this.partner.update({ display_name: this.partnerDisplayName });
+ }
+ }
+
+ }
+
+ User.fields = {
+ id: attr(),
+ model: attr({
+ default: 'res.user',
+ }),
+ partner: one2one('mail.partner', {
+ inverse: 'user',
+ }),
+ partnerDisplayName: attr(),
+ };
+
+ User.modelName = 'mail.user';
+
+ return User;
+}
+
+registerNewModel('mail.user', factory);
+
+});
diff --git a/addons/mail/static/src/scss/abstract_thread_window.scss b/addons/mail/static/src/scss/abstract_thread_window.scss
deleted file mode 100644
index 3a7f028f2f2..00000000000
--- a/addons/mail/static/src/scss/abstract_thread_window.scss
+++ /dev/null
@@ -1,194 +0,0 @@
-$o-mail-thread-window-zindex: $zindex-modal + 1 !default;
-
-.o_thread_window {
- direction: ltr;
- display: flex;
- flex-flow: column nowrap;
- position: fixed;
- width: $o-mail-thread-window-width;
- max-width: 100%;
- height: 400px;
- max-height: 100%;
- font-size: 12px;
- background-color: $o-mail-thread-window-bg;
- border-radius: 6px 6px 0 0;
- z-index: $o-mail-thread-window-zindex;
- box-shadow: -5px -5px 10px rgba(black, 0.18);
-
- @include media-breakpoint-down(sm) {
- width: 100%;
- height: 100%!important;
- box-shadow: none;
- &.o_folded {
- display: none;
- }
- }
-
- @media print {
- display: none;
- }
-
- .o_thread_window_header {
- align-items: center;
- display: flex;
- flex: 0 0 auto;
- color: white;
- padding: $o-mail-chatter-gap*0.5 $o-mail-chatter-gap;
- border-radius: 3px 3px 0 0;
- border-bottom: 1px solid gray('300');
- background-color: $o-brand-odoo;
- padding: 8px;
-
- @include media-breakpoint-down(sm) {
- align-items: center;
- height: $o-mail-chat-header-height;
- padding: 0;
- border-radius: 0px;
- .o_thread_window_title {
- font-size: 16px;
- margin-left: 10px;
- }
-
- .o_thread_window_close {
- $o-close-font-size: 17px;
- padding: (($o-mail-chat-header-height - $o-close-font-size) / 2);
- font-size: $o-close-font-size;
- color: white;
- }
- }
- .o_thread_window_avatar {
- margin: -6px 6px -6px 0;
- position: relative;
-
- img {
- height: 25px;
- width: 25px;
- border-radius: 50%;
- }
- span {
- bottom: -4px;
- right: -2px;
- position: absolute;
-
- .fa-circle-o {
- display: none;
- }
- }
- }
-
- .o_thread_window_title {
- cursor: pointer;
- flex: 1 1 auto;
- @include o-text-overflow;
-
- .o_mail_thread_typing_icon {
- padding-left: 2px;
-
- .o_mail_thread_typing_icon_dot {
- background: gray('300');
- }
- }
- }
-
- .o_thread_window_buttons {
- flex: 0 0 auto;
- .o_thread_window_close, .o_thread_window_expand {
- color: white;
- padding: 3px;
- margin-left: 5px;
- @include o-hover-opacity(0.7, 1);
- }
- }
-
- }
-
- .o_mail_thread {
- flex: 1 1 100%;
- overflow: auto;
- -webkit-overflow-scrolling: touch; // smooth scrolling in iOS app (Safari)
-
- .o_thread_date_separator {
- margin: 0px 0px 15px 0px;
- .o_thread_date {
- background-color: $o-mail-thread-window-bg;
- }
- }
- .o_thread_message {
- padding: 4px 5px;
- .o_thread_message_sidebar {
- margin-right: 5px;
- }
- .o_attachment {
- @include media-breakpoint-up(md) {
- width: percentage(1/3);
- }
- }
- }
- }
-
- .o_thread_composer input {
- width: 100%;
- }
-}
-
-.o_thread_window_dropdown {
- width: auto;
- height: 28px;
- color: white;
- background-color: gray('900');
- cursor: pointer;
- box-shadow: none;
-
- @include media-breakpoint-down(sm) {
- display: none;
- }
-
- .o_thread_window_header {
- border-radius: 0;
- }
-
- .o_thread_window_dropdown_toggler {
- padding: 5px;
-
- .o_total_unread_counter {
- @include o-position-absolute(-10px, 0, auto, auto);
- background-color: $o-brand-primary;
- padding: 0 2px;
- font-size: smaller;
- }
- }
- &.show .o_thread_window_dropdown_toggler .o_total_unread_counter, .o_thread_window_expand {
- display: none;
- }
-
- > ul {
- max-width: $o-mail-thread-window-width;
- padding: 0;
-
- > li.o_thread_window_header {
- font-size: 12px;
- padding: 3px 5px;
- &~li.o_thread_window_header {
- border-top: 1px solid white;
- }
- &:hover {
- background-color: darken($o-brand-odoo, 10%);
- }
- }
- }
-}
-
-.o_ui_blocked .o_thread_window {
- // We cannot put the z-index of thread windows directly to be greater than
- // blockUI's as ui-autocomplete dropdowns (which are below blockUI) would
- // appear under the thread windows (and ui-autocomplete is used to choose the
- // person you want to chat with). So we only raise the z-index value when
- // the ui is really blocked (in that case, the ui-autocomplete dropdowns
- // will disappear under the thread windows but this is not really an issue as
- // there should not be any at that time).
- z-index: 1101; // blockUI's z-index is 1100
-}
-
-.o_no_thread_window .o_thread_window {
- display: none;
-}
diff --git a/addons/mail/static/src/scss/attachment_box.scss b/addons/mail/static/src/scss/attachment_box.scss
deleted file mode 100644
index 7008fcce61e..00000000000
--- a/addons/mail/static/src/scss/attachment_box.scss
+++ /dev/null
@@ -1,34 +0,0 @@
-.oe_chatter {
-
- .o_mail_chatter_attachments {
- min-height: 50px;
-
- .o_chatter_attachment {
- padding: $o-attachment-margin;
-
- input.o_input_file {
- position: absolute;
- top: 1px;
- opacity: 0;
- pointer-events: none;
- }
-
- .o_border_dashed {
- position: relative;
- margin-top: 5px;
- margin-bottom: 5px;
- border-bottom: 1px dashed gray('300');
- text-align: center;
-
- .o_attach_title {
- position: relative;
- top: 10px;
- margin: 0 auto;
- padding: 0 10px;
- font-weight: bold;
- background: white;
- }
- }
- }
- }
-}
diff --git a/addons/mail/static/src/scss/chatter.scss b/addons/mail/static/src/scss/chatter.scss
deleted file mode 100644
index 6784121da58..00000000000
--- a/addons/mail/static/src/scss/chatter.scss
+++ /dev/null
@@ -1,35 +0,0 @@
-.o_chatter {
- .o_spinner {
- text-align: center;
- font-size: large;
- }
-
- .o_chatter_topbar {
- display: flex;
- border-bottom: $nav-tabs-border-width solid transparent;
-
- .btn {
- @extend .text-nowrap;
- margin-bottom: -$nav-tabs-border-width;
- padding: $nav-link-padding-y $nav-link-padding-x;
- border-radius: $border-radius-sm $border-radius-sm 0 0;
- }
-
- > .o_topbar_right_area {
- display: flex;
- margin-left: auto;
- justify-content: flex-end;
- }
- }
-
- &.o_chatter_composer_active .o_chatter_topbar {
- border-bottom-color: gray('300');
-
- .btn.o_active {
- color: $o-brand-odoo;
- background-color: theme-color('light');
- border: $nav-tabs-border-width solid gray('300');
- border-bottom-color: transparent;
- }
- }
-}
diff --git a/addons/mail/static/src/scss/composer.scss b/addons/mail/static/src/scss/composer.scss
index ad3338fb794..b478c0b0941 100644
--- a/addons/mail/static/src/scss/composer.scss
+++ b/addons/mail/static/src/scss/composer.scss
@@ -12,274 +12,6 @@
local(Symbola);
}
-.o_thread_composer {
- flex: 0 0 auto;
- display: flex;
- border-top: none;
- background: theme-color('light');
-
- .o_composer, .o_composer_subject {
- position: relative;
- background: white;
-
- textarea, input {
- border: none;
- }
- }
-
- .o_composer_subject {
- display: flex;
- padding-bottom: 5px;
-
- > input {
- flex: 1 1 auto;
- padding: 5px;
- margin-right: 30px;
- }
- }
-
- .o_composer {
- .o_composer_input > textarea {
- width: 100%;
- resize: none;
- }
- .o_composer_mention_dropdown .o_mention_proposition {
- @include o-text-overflow;
- width: 100%;
- padding: 3px 15px;
- .o_mention_name {
- font-weight: 500;
- }
- .o_mention_info {
- font-style: italic;
- color: gray;
- }
- .fa {
- font-size: 0.8em;
- }
- }
- textarea {
- resize: none;
- }
- .btn.fa {
- @include o-hover-opacity($default-opacity: 0.8, $hover-opacity: 1);
- border: none;
- }
- .o_composer_button_full_composer {
- @include o-position-absolute(auto, 0);
- }
-
- .o_mail_emoji_container {
- padding: 0 $o-mail-chatter-gap $o-mail-chatter-gap 0;
- border: 1px solid gray('300');
- border-width: 1px 0 0 0;
- background-color: theme-color('light');
-
- .o_mail_emoji {
- margin: $o-mail-chatter-gap 0 0 $o-mail-chatter-gap;
- cursor: pointer; // only in the popover
- min-width: 22px;
- min-height: 22px;
- }
- }
- }
-
- // Both inline and not-inline design. Mini-composer disabled.
- &:not(.o_chat_mini_composer) {
- padding: $o-mail-thread-avatar-size*0.5;
- padding-left: $grid-gutter-width*0.5 + $o-mail-thread-avatar-size + $o-mail-chatter-gap;
- border: 1px solid gray('300');
- border-top: none;
-
- .o_chatter_avatar {
- @include o-position-absolute(0, $left: -$o-mail-thread-avatar-size - $o-mail-chatter-gap);
- }
-
- .o_chatter_composer_info, .o_composer_suggested_partners {
- flex: 0 0 100%;
- margin-bottom: $o-mail-chatter-gap*0.5;
- }
-
- .o_composer_container {
- position: relative;
- }
-
- .o_composer {
- border-radius: 2px;
- textarea {
- padding-top: $o-mail-chatter-gap !important;
- padding-right: $grid-gutter-width; // avoid overlaps betwenn textarea and o_composer_button_full_composer
- }
-
- .o_mail_emoji_container {
- margin: 0 -$o-mail-chatter-gap;
- }
-
- @include media-breakpoint-down(sm) {
- .o_composer_button_send {
- color: $o-brand-primary;
- }
- }
- }
-
- .o_composer, .o_composer_subject {
- border: 1px solid gray('300');
-
- textarea, input {
- padding: 0 0 $o-mail-chatter-gap;
- border: none;
- }
- }
- .o_composer_subject {
- border-bottom: 0;
- border-radius: 2px 2px 0 0;
- }
-
- .o_chatter_composer_tools {
- border-top: 1px solid gray('300');
- }
-
- &:not(.o_chat_inline_composer) {
- flex-flow: row wrap;
-
- .o_composer_container {
- max-width: 100%;
- }
- .o_composer_container, .o_composer_button_send {
- flex: 1 0 100%;
- }
- .o_composer, .o_composer_subject {
- padding: 0 $o-mail-chatter-gap 0;
- }
- .o_composer_send {
- margin-top: $o-mail-chatter-gap;
-
- > .o_composer_button_discard:not(:hover) {
- background: theme-color('light');
- border-color: transparent;
- }
- }
- }
-
- &.o_chat_inline_composer {
- .o_composer_container {
- flex: 1 1 auto;
- }
- .o_composer {
- padding: $o-mail-chatter-gap $o-mail-chatter-gap 0 $o-mail-chatter-gap;
- border-radius: 2px 0 0 2px;
- border-right-width: 0;
-
- .o_mail_emoji_container {
- text-align: right;
- border-right-width: 1px;
- }
- }
- .o_composer_input {
- display: flex;
- flex-flow: row wrap;
-
- textarea {
- padding-top: 0px !important;
- &:disabled {
- position: absolute;
- }
- }
- }
- .o_composer_text_field {
- flex: 1 1 50%;
- margin: 0;
- padding: 0;
- }
- .o_chatter_composer_tools {
- flex: 0 1 auto;
- }
- .o_composer_attachments_list {
- flex: 1 0 100%;
- }
-
- .o_composer_thread_typing {
- font-size: 0.9em;
- color: gray('600');
- }
-
- .o_chatter_composer_tools {
- border: none;
- border-left: 1px solid gray('300');
- padding-left: $o-mail-chatter-gap*0.5;
- transform: translateY(-$o-mail-chatter-gap*0.5);
- }
-
- .o_composer_button_send {
- border-radius: 0 3px 3px 0;
- // Make the button appear as the same size as the subject input
- padding: 10px $input-btn-padding-x;
- border: 1px solid darken($o-brand-primary, 5%);
- border-left-width: 0;
- }
- }
- }
-
- // Mini-composer enabled
- &.o_chat_mini_composer {
- .o_composer_container {
- border-top: 1px solid gray('300');
- width: 100%;
- }
- .o_composer {
- .o_mail_emoji_container {
- @include o-position-absolute(auto, 0, 100%, 0);
- border-bottom-width: 1px;
- max-height: calc(100vh - 100%);
- overflow-y: auto;
- }
- }
- .o_composer_input {
- display: flex;
- flex-flow: row nowrap;
-
- textarea.o_composer_text_field {
- flex: 1 1 auto;
- }
-
- .o_chatter_composer_tools {
- flex: 0 0 auto;
- }
- @include media-breakpoint-down(sm) {
- .o_composer_button_send {
- display: inline-block;
- }
- }
- }
- .o_composer_send .o_composer_button_send, .o_chatter_avatar {
- display: none !important;
- }
- .o_attachment {
- width: 50%;
- }
- }
-
- .o_file_drop_zone_container {
- display: flex;
- position: absolute;
- left: 0px;
- bottom: 0px;
- height: 100%;
- width: 100%;
- color: $o-brand-primary;
- background: rgba(255, 255, 255, 0.9);
- border: 2px dashed $o-brand-primary;
- z-index: 1;
- align-items: center;
- justify-content: center;
- }
-}
-
-.o_composer_attachments_list:not(:empty) {
- margin-top: $o-mail-chatter-gap;
- text-align: justify;
-}
-
// Emoji
.o_mail_emoji {
display: inline-block;
@@ -291,80 +23,6 @@
font-size: 100%;
}
-// Attachment Icons (common for chat thread and chat composer)
-.o_attachments_list, .o_attachments_previews {
- margin: 0 (-$o-attachment-margin) 0;
- display: flex;
- flex-flow: row wrap;
-}
-
- .o_attachment_wrap {
- padding-right: 0px;
- }
-
-.o_attachments_previews {
- > .o_attachment {
- width: 50%;
-
- @include media-breakpoint-up(md) {
- width: percentage(1/3);
- }
- @include media-breakpoint-up(lg) {
- width: percentage(1/4);
- }
- @include media-breakpoint-up(xl) {
- width: percentage(1/6);
- }
-
- .o_attachment_wrap {
- padding: 0;
- border-radius: 2px;
- border: 1px solid gray('400');
- }
-
- .o_image_box {
- overflow: hidden;
- padding-top: 75%;
-
- .o_attachment_image {
- @include o-position-absolute(0, 0, 0, 0);
- background-size: cover;
- background-position: center;
- transform: scale(1.05);
- transition: all 0.3s ease 0s;
- }
-
- &:hover .o_attachment_image {
- transform: scale(1);
- }
- }
-
- .o_image_overlay {
- @include o-position-absolute($top: 0, $left: 0, $right:0, $bottom:0);
- @include o-hover-opacity($default-opacity: 0, $hover-opacity: 1);
- background-image: linear-gradient(180deg, rgba(black, 0.2), rgba(black, 0.9));
- transition: all 0.3s ease 0s;
- padding: 10px;
-
- .o_attachment_title {
- @include o-position-absolute($bottom: 7px);
- width: 80%;
- @include o-text-overflow(inline-block);
- font-size: $font-size-sm;
- }
-
- .o_attachment_delete_cross {
- color: white;
- }
-
- .o_attachment_download {
- @include o-position-absolute($right: 7px, $bottom:7px);
- }
- }
- }
-}
-
-
@mixin o-viewer-black-btn {
background-color: rgba(black, 0.4);
color: rgba(theme-color('light'), 0.7);
diff --git a/addons/mail/static/src/scss/discuss.scss b/addons/mail/static/src/scss/discuss.scss
index 81b7460325b..22e5e71c677 100644
--- a/addons/mail/static/src/scss/discuss.scss
+++ b/addons/mail/static/src/scss/discuss.scss
@@ -2,258 +2,10 @@
// Layout
// ------------------------------------------------------------------
-.o_mail_discuss {
- @include o-position-absolute(0, 0, 0, 0);
- height: 100%;
- overflow: hidden;
-
- .o_mail_discuss_loading {
- display: flex;
- flex: 1 1 auto;
- align-items: center;
- justify-content: center;
- min-height: 100%;
-
- > i {
- margin-right: 5px;
- }
- }
-
- .o_mail_discuss_sidebar {
- width: $o-mail-chat-sidebar-width;
- height: 100%;
- float: left;
- overflow: auto;
- padding: $o-horizontal-padding 0;
-
- @include media-breakpoint-up(xl) {
- width: $o-mail-chat-sidebar-width + 50px;
- }
-
- .o_discuss_sidebar_quick_search {
- display: flex;
- justify-content: center;
-
- > input {
- border-radius: 15px;
- flex-basis: 80%;
- }
- }
-
- .o_mail_sidebar_title {
- display: flex;
- padding: 6px $o-horizontal-padding 0 $o-horizontal-padding;
- align-items: center;
- justify-content: space-between;
- h4 {
- text-transform: uppercase;
- margin-bottom: 5px;
- }
- .o_add {
- padding: 6px;
- }
- .o_mail_open_channels {
- cursor: pointer;
- }
- }
- .o_mail_discuss_item {
- display: flex;
- align-items: center;
- width: 100%;
- padding: 2px $o-horizontal-padding 2px ($o-horizontal-padding + 12px); // compensate icon
-
- .o_thread_name {
- flex: 1 1 auto;
- @include o-text-overflow;
- }
- .o_mail_thread_typing_icon_dot {
- background: gray('600');;
- }
- .o_mail_sidebar_needaction, .o_mail_partner_unpin, .o_mail_channel_settings {
- flex: 0 0 auto;
- }
- .o_mail_sidebar_needaction {
- margin: 0 0 0 5px;
- }
- .o_mail_partner_unpin, .o_mail_channel_settings {
- padding: 2px 6px;
- }
- i.fa-envelope-o {
- margin-left: 3px;
- }
-
- &.o_mail_discuss_title_main {
- padding: $input-btn-padding-y $o-horizontal-padding;
- margin-bottom: 2px;
- }
- }
-
- .o_mail_discuss_item + .o_mail_sidebar_title {
- margin-top: $input-btn-padding-y;
- }
-
- .o_mail_add_thread {
- width: 100%;
- padding: 5px ($o-horizontal-padding + 23px); // compensate icon
- position: relative;
-
- > input {
- width: 100%;
- padding: 2px 5px;
- }
- > span {
- @include o-position-absolute(7px, $left: 27px);
- }
- }
- }
-
- .o_mail_discuss_content {
- display: flex;
- flex-flow: column nowrap;
- height: 100%;
- margin-left: $o-mail-chat-sidebar-width;
- position: relative;
- overflow: auto;
-
- @include media-breakpoint-up(xl) {
- margin-left: $o-mail-chat-sidebar-width + 50px;
- }
-
- .o_mail_thread {
- position: relative;
- flex: 1 0 0;
- overflow: auto;
- -webkit-overflow-scrolling: touch; // smooth scrolling in iOS app (Safari)
- }
-
- .o_mail_snackbar {
- @include o-position-absolute(15px, 15px);
- padding-top: 10px;
- padding-bottom: 10px;
- cursor: pointer;
- background-color: darken(gray('700'), 8%);
- color: white;
- }
- }
-}
-
// ------------------------------------------------------------------
// Style
// ------------------------------------------------------------------
-.o_mail_discuss {
- .o_mail_discuss_sidebar {
- background-color: gray('900');
- color: gray('300');
-
- .o_discuss_sidebar_quick_search {
-
- > input:focus {
- &::placeholder {
- color: transparent;
- }
- }
-
- > input {
- outline: none;
- background-color: gray('800');
- border: gray('600');
- text-align: center;
- color: gray('400');
- }
- }
-
- .o_mail_discuss_item {
- cursor: pointer;
-
- &:hover {
- background-color: darken(gray('900'),2%);
-
- .o_mail_partner_unpin, .o_mail_channel_settings {
- display: inline-block;
- }
- }
-
- &.o_active {
- background-color: gray('900');
- box-shadow: inset 3px 0 0 $o-brand-primary;
- }
-
- .o_mail_hash {
- opacity: 0.6
- }
-
- .o_mail_partner_unpin, .o_mail_channel_settings {
- display: none;
- }
- &.o_unread_message {
- color: white;
- font-weight: bold;
- }
-
- > .badge {
- color: white;
- background-color: $o-brand-primary;
- }
-
- &.o_mail_discuss_title_main {
- font-size: $font-size-base;
-
- &.o_mail_mailbox_title_starred > .badge {
- background-color: gray('600');
- border-color: gray('600');
- }
- }
- }
-
- > hr {
- border-color: gray('700');
- }
-
- .o_mail_add_thread {
- display: none;
- > input {
- color: black;
- }
- > span {
- color: gray;
- }
- }
-
- .o_mail_sidebar_title {
- h4 {
- margin: 0;
- font-size: $font-size-sm;
- text-transform: uppercase;
- color: gray('600');
- &.o_mail_open_channels:hover {
- color: white;
- }
- }
- .o_add {
- cursor: pointer;
- @include o-hover-opacity($o-mail-sidebar-icon-opacity, 1);
- @include o-hover-text-color(gray('600'), white);
- }
- }
- }
-
- .o_mail_discuss_content {
- background-color: white;
- }
-
- &.o_mail_selection_mode {
- .o_thread_message {
- opacity: 0.5;
- &.o_thread_selected_message {
- padding: 4px $o-horizontal-padding;
- opacity: 1;
- background-color: lightgray;
- }
- }
- }
-}
-
.o_mail_user_status {
font-size: 1em;
position: relative;
@@ -270,66 +22,6 @@
}
}
-
-// ------------------------------------------------------------------
-// Mobile
-// ------------------------------------------------------------------
-@include media-breakpoint-down(sm) {
- .o_mail_discuss {
- display: flex;
- flex-flow: column nowrap;
- background: white;
-
- .o_mail_discuss_mobile_mailboxes_buttons {
- display: flex;
- flex: 0 0 auto;
- z-index: 2;
- box-shadow: 0 2px 4px gray('400');
-
- .o_mailbox_inbox_item {
- flex: 1 1 auto;
- width: 50%;
- padding: 2%;
-
- &.btn-secondary {
- border-bottom: 1px solid gray('400')
- }
- }
- }
-
- .o_mail_discuss_content {
- flex: 1 1 auto;
- min-height: 0;
- margin-left: 0;
- background-color: transparent;
- }
-
- .o_mail_discuss_tab_pane {
- .o_mail_add_thread {
- display: none;
- position: relative;
- padding: 5px 0px;
- margin: 10px;
- border: 1px solid #afafaf;
- border-radius: 4px;
- > span {
- @include o-position-absolute($top: 10px, $left: 10px);
- }
- > input {
- border: none;
- padding: 5px 8px;
- padding-left: 20px;
- }
- }
- }
- .o_mail_mobile_tabs {
- flex: 0 0 auto;
- z-index: 1;
- background-color: white;
- }
- }
-}
-
// ------------------------------------------------------------------
// Thread preview: shared between discuss (mobile) and systray
// ------------------------------------------------------------------
diff --git a/addons/mail/static/src/scss/followers.scss b/addons/mail/static/src/scss/followers.scss
deleted file mode 100644
index 031affb6e52..00000000000
--- a/addons/mail/static/src/scss/followers.scss
+++ /dev/null
@@ -1,92 +0,0 @@
-.o_followers {
- display: flex;
- justify-content: flex-end;
- order: 1;
-
- &.o_field_widget {
- margin-bottom: initial;
- }
-
- > .o_followers_title_box {
- order: 1;
- }
-
- .o_followers_follow_button {
- color: $body-color;
- font-weight: bold;
- text-align: right;
-
- .o_follow, .o_followers_unfollow, .o_followers_following {
- display: none;
- }
-
- // when following, show 'following'
- &.o_followers_following .o_followers_following {
- display: initial;
- }
-
- // when following and hovering, show 'unfollow'
- &.o_followers_following:hover {
- .o_followers_following {
- display: none;
- }
-
- .o_followers_unfollow {
- display: initial;
- }
- }
-
- // when not following show 'follow'
- &.o_followers_notfollow .o_follow {
- display: initial;
- }
-
- .fa {
- &.fa-times {
- color: theme-color('warning');
- }
-
- &.fa-check {
- color: theme-color('success');
- }
- }
- }
-
- .o_followers_list {
- width: 20vh;
- max-height: 50vh;
- overflow: auto;
- }
-
- .o_followers_title_box .dropdown-item.o_partner {
- padding: 0;
- display: flex;
- align-items: center;
-
- .o_mail_redirect {
- margin-right: auto;
- color: $dropdown-link-color;
-
- img {
- width: $o-mail-partner-avatar-size;
- height: $o-mail-partner-avatar-size;
- object-fit: cover;
- }
- }
-
- .btn {
- padding: 0 $grid-gutter-width*0.25;
- }
-
- &.o_inactive {
- .o_mail_redirect {
- color: $gray-500;
- font-style: italic;
-
- img {
- opacity: 0.25;
- }
- }
- }
- }
-}
diff --git a/addons/mail/static/src/scss/out_of_office.scss b/addons/mail/static/src/scss/out_of_office.scss
deleted file mode 100644
index bcdb3a182f7..00000000000
--- a/addons/mail/static/src/scss/out_of_office.scss
+++ /dev/null
@@ -1,8 +0,0 @@
-.o_out_of_office {
- background-color: #9dd0e8;
- padding:8px;
-
- .o_out_of_office_info {
- font-weight: bold;
- }
-}
diff --git a/addons/mail/static/src/scss/thread.scss b/addons/mail/static/src/scss/thread.scss
index 2fc55c7d637..17c0ae72655 100644
--- a/addons/mail/static/src/scss/thread.scss
+++ b/addons/mail/static/src/scss/thread.scss
@@ -1,32 +1,4 @@
-.o_mail_thread_loading {
- display: flex;
- align-items: center;
- justify-content: center;
-}
-
-.o_mail_thread_loading_icon {
- margin-right: 5px;
-}
-
-.o_mail_thread, .o_mail_activity {
- .o_thread_show_more {
- text-align: center;
- }
-
- .o_mail_thread_content {
- display: flex;
- flex-direction: column;
- min-height: 100%;
- }
-
- .o_thread_bottom_free_space {
- height: 15px;
- }
-
- .o_thread_tooltip_container {
- display: inline;
- position: relative;
- }
+.o_mail_activity {
.o_thread_date_separator {
margin-top: 15px;
@@ -48,30 +20,11 @@
}
}
- .o_thread_new_messages_separator {
- margin-bottom: 15px;
- border-bottom: solid lighten($o-brand-odoo, 15%) 1px;
- text-align: right;
- .o_thread_separator_label {
- position: relative;
- top: 8px;
- padding: 0 10px;
- background: white;
- color: lighten($o-brand-odoo, 15%);
- font-size: smaller;
- }
- }
-
.o_thread_message {
display: flex;
padding: 4px $o-horizontal-padding;
margin-bottom: 0px;
- &.o_mail_not_discussion {
- background-color: rgba(gray('300'), 0.5);
- border-bottom: 1px solid gray('400');
- }
-
.o_thread_message_sidebar {
flex: 0 0 $o-mail-thread-avatar-size;
margin-right: 10px;
@@ -106,37 +59,13 @@
height: $o-mail-thread-avatar-size;
object-fit: cover;
}
- .o_thread_message_side_date {
- display: none;
- margin-left: -5px;
- }
- .o_thread_message_star {
- display: none;
- margin-right: -5px;
- }
-
- .o_thread_message_side_date {
- opacity: 0;
- }
-
- .o_mail_thread_message_seen_icon:not(.o_all_seen) {
- opacity: $o-mail-thread-icon-opacity*0.5;
- }
}
.o_thread_icon {
cursor: pointer;
opacity: 0;
- &.fa-star {
- opacity: $o-mail-thread-icon-opacity;
- color: gold;
- }
}
- &:hover, &.o_thread_selected_message {
- .o_thread_message_side_date {
- display: inline-block;
- opacity: $o-mail-thread-side-date-opacity;
- }
+ &:hover {
.o_thread_icon {
display: inline-block;
opacity: $o-mail-thread-icon-opacity;
@@ -144,11 +73,6 @@
opacity: 1;
}
}
- .o_thread_message_sidebar {
- .o_mail_thread_message_seen_icon {
- display: none;
- }
- }
}
.o_mail_redirect {
@@ -210,29 +134,9 @@
}
}
- .o_thread_message_star, .o_thread_message_needaction, .o_thread_message_reply, .o_thread_message_notification {
+ .o_thread_message_needaction, .o_thread_message_reply {
padding: 4px;
}
-
- .o_thread_message_notification {
- color: grey;
- &.o_thread_message_notification_error {
- color: red;
- opacity: 1;
- cursor: pointer;
- }
- }
-
- .o_attachments_list, .o_attachments_previews {
- &:last-child {
- margin-bottom: $grid-gutter-width;
- }
- }
-
- .o_thread_tooltip_container {
- display: inline;
- position: relative;
- }
}
}
.o_thread_title {
@@ -268,68 +172,3 @@
opacity: $o-mail-thread-icon-opacity;
}
}
-
-// ------------------------------------------------------------------
-// Thread typing icon: shared between discuss and chat windows
-// ------------------------------------------------------------------
-
-.o_mail_thread_typing_icon {
- position: relative;
- text-align: center;
- margin-left: auto;
- margin-right: auto;
-
- .o_mail_thread_typing_icon_dot {
- display: inline-block;
- width: 3px;
- height: 3px;
- border-radius: 50%;
- background: gray('800');
- animation: o_mail_thread_typing_icon_dot 1.5s linear infinite;
-
- &:nth-child(2) {
- animation-delay: -1.35s;
- }
-
- &:nth-child(3) {
- animation-delay: -1.2s;
- }
- }
-}
-
-@keyframes o_mail_thread_typing_icon_dot {
- 0%, 40%, 100% {
- transform: initial;
- }
- 20% {
- transform: translateY(-5px);
- }
-}
-
-// ------------------------------------------------------------------
-// Thread seen icon: shared between discuss and chat windows
-// ------------------------------------------------------------------
-
-.o_mail_thread_message_seen_icon {
- position: relative;
- opacity: 0.6;
-
- &.o_all_seen {
- color: $o-enterprise-color;
- }
-
- &:hover {
- cursor: pointer;
- opacity: 0.8;
- }
-
- .fa-check:nth-child(1) {
- padding-left: 3px;
- }
-
- .fa-check:nth-child(2) {
- position: absolute;
- top: -1px;
- left: 0px;
- }
-}
diff --git a/addons/mail/static/src/scss/thread_window.scss b/addons/mail/static/src/scss/thread_window.scss
deleted file mode 100644
index abc8ff2846d..00000000000
--- a/addons/mail/static/src/scss/thread_window.scss
+++ /dev/null
@@ -1,63 +0,0 @@
-.o_thread_window {
- &.o_thread_less {
- .o_thread_search_input {
- display: flex;
- align-items: baseline;
- padding: $o-mail-chatter-gap*0.5 $o-mail-chatter-gap;
-
- > span {
- flex: 0 0 auto;
- }
-
- > input {
- margin-left: 5px;
- padding: $o-mail-chatter-gap*0.5;
- flex: 1 1 100%;
- }
- }
-
- .o_mail_thread {
- background-color: $o-webclient-background-color;
- }
-
- .o_thread_composer {
- display: none;
- }
- }
-
- .o_thread_composer {
- .o_file_drop_zone_container {
- top: 35px;
- }
- .o_composer {
- .o_composer_input {
- width: 80%;
- > textarea {
- padding: 10px;
- font-size: 13px;
- }
- @include media-breakpoint-down(sm) {
- width: 70%;
- > textarea {
- padding: 15px 10px;
- }
- }
- }
- .o_chatter_composer_tools {
- @include o-position-absolute($right: 0px, $top: 0px);
- button {
- padding: 10px;
- @include media-breakpoint-down(sm) {
- padding: 13px;
- }
- &.btn.fa {
- font-size: 1.3em;
- }
- }
- }
- }
- .o_composer_attachments_list {
- margin: 0px;
- }
- }
-}
diff --git a/addons/mail/static/src/services/chat_window_service/chat_window_service.js b/addons/mail/static/src/services/chat_window_service/chat_window_service.js
new file mode 100644
index 00000000000..8a11c20257c
--- /dev/null
+++ b/addons/mail/static/src/services/chat_window_service/chat_window_service.js
@@ -0,0 +1,104 @@
+odoo.define('mail/static/src/services/chat_window_service/chat_window_service.js', function (require) {
+'use strict';
+
+const components = {
+ ChatWindowManager: require('mail/static/src/components/chat_window_manager/chat_window_manager.js'),
+};
+
+const AbstractService = require('web.AbstractService');
+const { bus, serviceRegistry } = require('web.core');
+
+const ChatWindowService = AbstractService.extend({
+ /**
+ * @override {web.AbstractService}
+ */
+ start() {
+ this._super(...arguments);
+ this._webClientReady = false;
+ this._listenHomeMenu();
+ },
+ /**
+ * @private
+ */
+ destroy() {
+ if (this.component) {
+ this.component.destroy();
+ this.component = undefined;
+ }
+ },
+
+ //--------------------------------------------------------------------------
+ // Private
+ //--------------------------------------------------------------------------
+
+ /**
+ * @private
+ * @returns {Node}
+ */
+ _getParentNode() {
+ return document.querySelector('body');
+ },
+ /**
+ * @private
+ */
+ _listenHomeMenu() {
+ bus.on('hide_home_menu', this, this._onHideHomeMenu.bind(this));
+ bus.on('show_home_menu', this, this._onShowHomeMenu.bind(this));
+ bus.on('web_client_ready', this, this._onWebClientReady.bind(this));
+ },
+ /**
+ * @private
+ */
+ async _mount() {
+ if (this.component) {
+ this.component.destroy();
+ this.component = undefined;
+ }
+ const ChatWindowManagerComponent = components.ChatWindowManager;
+ this.component = new ChatWindowManagerComponent(null);
+ const parentNode = this._getParentNode();
+ await this.component.mount(parentNode);
+ },
+
+ //--------------------------------------------------------------------------
+ // Handlers
+ //--------------------------------------------------------------------------
+
+ /**
+ * @private
+ */
+ async _onHideHomeMenu() {
+ if (!this._webClientReady) {
+ return;
+ }
+ if (document.querySelector('.o_ChatWindowManager')) {
+ return;
+ }
+ await this._mount();
+ },
+ /**
+ * @private
+ */
+ async _onShowHomeMenu() {
+ if (!this._webClientReady) {
+ return;
+ }
+ if (document.querySelector('.o_ChatWindowManager')) {
+ return;
+ }
+ await this._mount();
+ },
+ /**
+ * @private
+ */
+ async _onWebClientReady() {
+ await this._mount();
+ this._webClientReady = true;
+ },
+});
+
+serviceRegistry.add('chat_window', ChatWindowService);
+
+return ChatWindowService;
+
+});
diff --git a/addons/mail/static/src/services/dialog_service/dialog_service.js b/addons/mail/static/src/services/dialog_service/dialog_service.js
new file mode 100644
index 00000000000..88762a2992d
--- /dev/null
+++ b/addons/mail/static/src/services/dialog_service/dialog_service.js
@@ -0,0 +1,101 @@
+odoo.define('mail/static/src/services/dialog_service/dialog_service.js', function (require) {
+'use strict';
+
+const components = {
+ DialogManager: require('mail/static/src/components/dialog_manager/dialog_manager.js'),
+};
+
+const AbstractService = require('web.AbstractService');
+const { bus, serviceRegistry } = require('web.core');
+
+const DialogService = AbstractService.extend({
+ /**
+ * @override {web.AbstractService}
+ */
+ start() {
+ this._super(...arguments);
+ this._webClientReady = false;
+ this._listenHomeMenu();
+ },
+ /**
+ * @private
+ */
+ destroy() {
+ if (this.component) {
+ this.component.destroy();
+ this.component = undefined;
+ }
+ },
+
+ //--------------------------------------------------------------------------
+ // Private
+ //--------------------------------------------------------------------------
+
+ /**
+ * @private
+ * @returns {Node}
+ */
+ _getParentNode() {
+ return document.querySelector('body');
+ },
+ /**
+ * @private
+ */
+ _listenHomeMenu() {
+ bus.on('hide_home_menu', this, this._onHideHomeMenu.bind(this));
+ bus.on('show_home_menu', this, this._onShowHomeMenu.bind(this));
+ bus.on('web_client_ready', this, this._onWebClientReady.bind(this));
+ },
+ /**
+ * @private
+ */
+ async _mount() {
+ if (this.component) {
+ this.component.destroy();
+ this.component = undefined;
+ }
+ const DialogManagerComponent = components.DialogManager;
+ this.component = new DialogManagerComponent(null);
+ const parentNode = this._getParentNode();
+ await this.component.mount(parentNode);
+ },
+
+ //--------------------------------------------------------------------------
+ // Handlers
+ //--------------------------------------------------------------------------
+
+ /**
+ * @private
+ */
+ async _onHideHomeMenu() {
+ if (!this._webClientReady) {
+ return;
+ }
+ if (document.querySelector('.o_DialogManager')) {
+ return;
+ }
+ await this._mount();
+ },
+ async _onShowHomeMenu() {
+ if (!this._webClientReady) {
+ return;
+ }
+ if (document.querySelector('.o_DialogManager')) {
+ return;
+ }
+ await this._mount();
+ },
+ /**
+ * @private
+ */
+ async _onWebClientReady() {
+ await this._mount();
+ this._webClientReady = true;
+ }
+});
+
+serviceRegistry.add('dialog', DialogService);
+
+return DialogService;
+
+});
diff --git a/addons/mail/static/src/utils/deferred/deferred.js b/addons/mail/static/src/utils/deferred/deferred.js
new file mode 100644
index 00000000000..f96696fb992
--- /dev/null
+++ b/addons/mail/static/src/utils/deferred/deferred.js
@@ -0,0 +1,21 @@
+odoo.define('mail/static/src/utils/deferred/deferred.js', function (require) {
+'use strict';
+
+/**
+ * @returns {Deferred}
+ */
+function makeDeferred() {
+ let resolve;
+ let reject;
+ const prom = new Promise(function (res, rej) {
+ resolve = res.bind(this);
+ reject = rej.bind(this);
+ });
+ prom.resolve = (...args) => resolve(...args);
+ prom.reject = (...args) => reject(...args);
+ return prom;
+}
+
+return { makeDeferred };
+
+});
diff --git a/addons/mail/static/src/utils/test_utils.js b/addons/mail/static/src/utils/test_utils.js
new file mode 100644
index 00000000000..e8e0c6dbc10
--- /dev/null
+++ b/addons/mail/static/src/utils/test_utils.js
@@ -0,0 +1,860 @@
+odoo.define('mail/static/src/utils/test_utils.js', function (require) {
+'use strict';
+
+const BusService = require('bus.BusService');
+
+const {
+ addMessagingToEnv,
+ addTimeControlToEnv,
+} = require('mail/static/src/env/test_env.js');
+const ChatWindowService = require('mail/static/src/services/chat_window_service/chat_window_service.js');
+const DialogService = require('mail/static/src/services/dialog_service/dialog_service.js');
+const { nextTick } = require('mail/static/src/utils/utils.js');
+const DiscussWidget = require('mail/static/src/widgets/discuss/discuss.js');
+const MessagingMenuWidget = require('mail/static/src/widgets/messaging_menu/messaging_menu.js');
+
+const AbstractStorageService = require('web.AbstractStorageService');
+const NotificationService = require('web.NotificationService');
+const RamStorage = require('web.RamStorage');
+const {
+ createActionManager,
+ createView,
+ makeTestPromise,
+ mock: {
+ addMockEnvironment,
+ patch: legacyPatch,
+ unpatch: legacyUnpatch,
+ },
+} = require('web.test_utils');
+const Widget = require('web.Widget');
+
+const { Component } = owl;
+
+//------------------------------------------------------------------------------
+// Private
+//------------------------------------------------------------------------------
+
+/**
+ * Create a fake object 'dataTransfer', linked to some files,
+ * which is passed to drag and drop events.
+ *
+ * @param {Object[]} files
+ * @returns {Object}
+ */
+function _createFakeDataTransfer(files) {
+ return {
+ dropEffect: 'all',
+ effectAllowed: 'all',
+ files,
+ items: [],
+ types: ['Files'],
+ };
+}
+
+/**
+ * @private
+ * @param {Object} callbacks
+ * @param {function[]} callbacks.init
+ * @param {function[]} callbacks.mount
+ * @param {function[]} callbacks.destroy
+ * @param {function[]} callbacks.return
+ * @returns {Object} update callbacks
+ */
+function _useChatWindow(callbacks) {
+ const {
+ mount: prevMount,
+ destroy: prevDestroy,
+ } = callbacks;
+ return Object.assign({}, callbacks, {
+ mount: prevMount.concat(() => {
+ // trigger mounting of chat window manager
+ Component.env.services['chat_window']._onWebClientReady();
+ }),
+ destroy: prevDestroy.concat(() => {
+ Component.env.services['chat_window'].destroy();
+ }),
+ });
+}
+
+/**
+ * @private
+ * @param {Object} callbacks
+ * @param {function[]} callbacks.init
+ * @param {function[]} callbacks.mount
+ * @param {function[]} callbacks.destroy
+ * @param {function[]} callbacks.return
+ * @returns {Object} update callbacks
+ */
+function _useDialog(callbacks) {
+ const {
+ mount: prevMount,
+ destroy: prevDestroy,
+ } = callbacks;
+ return Object.assign({}, callbacks, {
+ mount: prevMount.concat(() => {
+ // trigger mounting of dialog manager
+ Component.env.services['dialog']._onWebClientReady();
+ }),
+ destroy: prevDestroy.concat(() => {
+ Component.env.services['dialog'].destroy();
+ }),
+ });
+}
+
+/**
+ * @private
+ * @param {Object} callbacks
+ * @param {function[]} callbacks.init
+ * @param {function[]} callbacks.mount
+ * @param {function[]} callbacks.destroy
+ * @param {function[]} callbacks.return
+ * @return {Object} update callbacks
+ */
+function _useDiscuss(callbacks) {
+ const {
+ init: prevInit,
+ mount: prevMount,
+ return: prevReturn,
+ } = callbacks;
+ let discussWidget;
+ const state = {
+ autoOpenDiscuss: false,
+ discussData: {},
+ };
+ return Object.assign({}, callbacks, {
+ init: prevInit.concat(params => {
+ const {
+ autoOpenDiscuss = state.autoOpenDiscuss,
+ discuss: discussData = state.discussData
+ } = params;
+ Object.assign(state, { autoOpenDiscuss, discussData });
+ delete params.autoOpenDiscuss;
+ delete params.discuss;
+ }),
+ mount: prevMount.concat(async params => {
+ const { selector, widget } = params;
+ DiscussWidget.prototype._pushStateActionManager = () => {};
+ discussWidget = new DiscussWidget(widget, state.discussData);
+ await discussWidget.appendTo($(selector));
+ if (state.autoOpenDiscuss) {
+ discussWidget.on_attach_callback();
+ }
+ }),
+ return: prevReturn.concat(result => {
+ Object.assign(result, { discussWidget });
+ }),
+ });
+}
+
+/**
+ * @private
+ * @param {Object} callbacks
+ * @param {function[]} callbacks.init
+ * @param {function[]} callbacks.mount
+ * @param {function[]} callbacks.destroy
+ * @param {function[]} callbacks.return
+ * @returns {Object} update callbacks
+ */
+function _useMessagingMenu(callbacks) {
+ const {
+ mount: prevMount,
+ return: prevReturn,
+ } = callbacks;
+ let messagingMenuWidget;
+ return Object.assign({}, callbacks, {
+ mount: prevMount.concat(async ({ selector, widget }) => {
+ messagingMenuWidget = new MessagingMenuWidget(widget, {});
+ await messagingMenuWidget.appendTo($(selector));
+ messagingMenuWidget.on_attach_callback();
+ }),
+ return: prevReturn.concat(result => {
+ Object.assign(result, { messagingMenuWidget });
+ }),
+ });
+}
+
+//------------------------------------------------------------------------------
+// Public: rendering timers
+//------------------------------------------------------------------------------
+
+/**
+ * Returns a promise resolved at the next animation frame.
+ *
+ * @returns {Promise}
+ */
+function nextAnimationFrame() {
+ const requestAnimationFrame = owl.Component.scheduler.requestAnimationFrame;
+ return new Promise(function (resolve) {
+ setTimeout(() => requestAnimationFrame(() => resolve()));
+ });
+}
+
+/**
+ * Returns a promise resolved the next time OWL stops rendering.
+ *
+ * @param {function} func function which, when called, is
+ * expected to trigger OWL render(s).
+ * @param {number} [timeoutDelay=5000] in ms
+ * @returns {Promise}
+ */
+const afterNextRender = (function () {
+ const stop = owl.Component.scheduler.stop;
+ const stopPromises = [];
+
+ owl.Component.scheduler.stop = function () {
+ const wasRunning = this.isRunning;
+ stop.call(this);
+ if (wasRunning) {
+ while (stopPromises.length) {
+ stopPromises.pop().resolve();
+ }
+ }
+ };
+
+ async function afterNextRender(func, timeoutDelay = 5000) {
+ // Define the potential errors outside of the promise to get a proper
+ // trace if they happen.
+ const startError = new Error("Timeout: the render didn't start.");
+ const stopError = new Error("Timeout: the render didn't stop.");
+ // Set up the timeout to reject if no render happens.
+ let timeoutNoRender;
+ const timeoutProm = new Promise((resolve, reject) => {
+ timeoutNoRender = setTimeout(() => {
+ let error = startError;
+ if (owl.Component.scheduler.isRunning) {
+ error = stopError;
+ }
+ console.error(error);
+ reject(error);
+ }, timeoutDelay);
+ });
+ // Set up the promise to resolve if a render happens.
+ const prom = makeTestPromise();
+ stopPromises.push(prom);
+ // Start the function expected to trigger a render after the promise
+ // has been registered to not miss any potential render.
+ const funcRes = func();
+ // Make them race (first to resolve/reject wins).
+ await Promise.race([prom, timeoutProm]);
+ clearTimeout(timeoutNoRender);
+ // Wait the end of the function to ensure all potential effects are
+ // taken into account during the following verification step.
+ await funcRes;
+ // Wait one more frame to make sure no new render has been queued.
+ await nextAnimationFrame();
+ if (owl.Component.scheduler.isRunning) {
+ await afterNextRender(() => {}, timeoutDelay);
+ }
+ }
+
+ return afterNextRender;
+})();
+
+
+//------------------------------------------------------------------------------
+// Public: test lifecycle
+//------------------------------------------------------------------------------
+
+function beforeEach(self) {
+ const data = {
+ initMessaging: {
+ channel_slots: {},
+ commands: [],
+ is_moderator: false,
+ mail_failures: [],
+ mention_partner_suggestions: [],
+ menu_id: false,
+ moderation_counter: 0,
+ moderation_channel_ids: [],
+ needaction_inbox_counter: 0,
+ partner_root: {
+ active: false,
+ display_name: "OdooBot",
+ id: 2,
+ },
+ public_partner: {
+ active: false,
+ display_name: "Public user",
+ id: 4,
+ },
+ shortcodes: [],
+ starred_counter: 0,
+ },
+ 'ir.attachment': {
+ fields: {
+ name: { type: 'char', string: "attachment name", required: true },
+ res_model: { type: 'char', string: "res model" },
+ res_id: { type: 'integer', string: "res id" },
+ url: { type: 'char', string: 'url' },
+ type: { type: 'selection', selection: [['url', "URL"], ['binary', "BINARY"]] },
+ mimetype: { type: 'char', string: "mimetype" },
+ },
+ },
+ 'mail.activity': {
+ fields: {
+ can_write: {
+ type: 'boolean',
+ },
+ icon: {
+ type: 'string',
+ },
+ id: {
+ type: 'integer',
+ },
+ res_id: {
+ type: 'integer',
+ },
+ res_model: {
+ type: 'string',
+ },
+ },
+ },
+ 'mail.channel': {
+ fields: {
+ channel_type: {
+ string: "Channel Type",
+ type: "selection",
+ },
+ id: {
+ string: "Id",
+ type: 'integer',
+ },
+ message_unread_counter: {
+ string: "# unread messages",
+ type: 'integer',
+ },
+ name: {
+ string: "Name",
+ type: "char",
+ required: true,
+ },
+ },
+ },
+ 'mail.followers': {
+ fields: {
+ channel_id: {
+ type: 'integer',
+ },
+ email: {
+ type: 'string',
+ },
+ id: {
+ type: 'integer',
+ },
+ is_active: {
+ type: 'boolean',
+ },
+ is_editable: {
+ type: 'boolean',
+ },
+ name: {
+ type: 'string',
+ },
+ partner_id: {
+ type: 'integer',
+ },
+ },
+ },
+ 'mail.message': {
+ fields: {
+ attachment_ids: {
+ string: "Attachments",
+ type: 'many2many',
+ relation: 'ir.attachment',
+ default: [],
+ },
+ author_id: {
+ string: "Author",
+ relation: 'res.partner',
+ },
+ body: {
+ string: "Contents",
+ type: 'html',
+ },
+ channel_ids: {
+ string: "Channels",
+ type: 'many2many',
+ relation: 'mail.channel',
+ },
+ date: {
+ string: "Date",
+ type: 'datetime',
+ },
+ history_partner_ids: {
+ string: "Partners with History",
+ type: 'many2many',
+ relation: 'res.partner',
+ },
+ id: {
+ string: "Id",
+ type: 'integer',
+ },
+ is_discussion: {
+ string: "Discussion",
+ type: 'boolean',
+ },
+ is_note: {
+ string: "Note",
+ type: 'boolean',
+ },
+ is_notification: {
+ string: "Notification",
+ type: 'boolean',
+ },
+ is_starred: {
+ string: "Starred",
+ type: 'boolean',
+ },
+ message_type: {
+ string: "Type",
+ type: 'selection',
+ },
+ model: {
+ string: "Related Document model",
+ type: 'char',
+ },
+ needaction: {
+ string: "Need Action",
+ type: 'boolean',
+ },
+ needaction_partner_ids: {
+ string: "Partners with Need Action",
+ type: 'many2many',
+ relation: 'res.partner',
+ },
+ record_name: {
+ string: "Name",
+ type: 'string',
+ },
+ res_id: {
+ string: "Related Document ID",
+ type: 'integer',
+ },
+ starred: {
+ string: "Starred",
+ type: 'boolean',
+ },
+ starred_partner_ids: {
+ string: "Favorited By",
+ type: 'many2many',
+ relation: 'res.partner',
+ },
+ },
+ },
+ 'mail.notification': {
+ fields: {
+ is_read: {
+ string: "Is Read",
+ type: 'boolean',
+ },
+ mail_message_id: {
+ string: "Message",
+ type: 'many2one',
+ relation: 'mail.message',
+ },
+ res_partner_id: {
+ string: "Needaction Recipient",
+ type: 'many2one',
+ relation: 'res.partner',
+ },
+ },
+ },
+ 'res.partner': {
+ fields: {
+ display_name: { string: "Displayed name", type: "char" },
+ im_status: {
+ string: "status",
+ type: 'char',
+ },
+ message_follower_ids: {
+ relation: 'follower',
+ string: "Followers",
+ type: "one2many",
+ },
+ },
+ records: [],
+ },
+ 'res.users': {
+ fields: {
+ partner_id: {
+ string: "Related partners",
+ type: 'many2one',
+ relation: 'res.partner',
+ },
+ },
+ },
+ };
+
+ const originals = {
+ '_.debounce': _.debounce,
+ '_.throttle': _.throttle,
+ 'window.fetch': window.fetch,
+ };
+
+ (function patch() {
+ // patch _.debounce and _.throttle to be fast and synchronous
+ _.debounce = _.identity;
+ _.throttle = _.identity;
+ let uploadedAttachmentsCount = 1;
+ window.fetch = async function (route, form) {
+ const formData = form.body;
+ return {
+ async text() {
+ const ufiles = formData.getAll('ufile');
+ const files = ufiles.map(ufile => JSON.stringify({
+ filename: ufile.name,
+ id: uploadedAttachmentsCount,
+ mimetype: ufile.type,
+ name: ufile.name,
+ }));
+ const callback = formData.get('callback');
+ uploadedAttachmentsCount++;
+ return `
+ `;
+ }
+ };
+ };
+ })();
+
+ function unpatch() {
+ _.debounce = originals['_.debounce'];
+ _.throttle = originals['_.throttle'];
+ window.fetch = originals['window.fetch'];
+ }
+
+ Object.assign(self, { data, unpatch });
+
+ return {
+ data,
+ unpatch,
+ };
+}
+
+function afterEach(self) {
+ self.unpatch();
+}
+
+/**
+ * Main function used to make a mocked environment with mocked messaging env.
+ *
+ * @param {Object} [param0={}]
+ * @param {string} [param0.arch] makes only sense when `param0.hasView` is set:
+ * the arch to use in createView.
+ * @param {Object} [param0.archs]
+ * @param {boolean} [param0.autoOpenDiscuss=false] makes only sense when
+ * `param0.hasDiscuss` is set: determine whether mounted discuss should be
+ * open initially.
+ * @param {boolean} [param0.debug=false]
+ * @param {Object} [param0.data] makes only sense when `param0.hasView` is set:
+ * the data to use in createView.
+ * @param {Object} [param0.discuss={}] makes only sense when `param0.hasDiscuss`
+ * is set: provide data that is passed to discuss widget (= client action) as
+ * 2nd positional argument.
+ * @param {Object} [param0.env={}]
+ * @param {function} [param0.mockRPC]
+ * @param {boolean} [param0.hasActionManager=false] if set, use
+ * createActionManager.
+ * @param {boolean} [param0.hasChatWindow=false] if set, mount chat window
+ * service.
+ * @param {boolean} [param0.hasDiscuss=false] if set, mount discuss app.
+ * @param {boolean} [param0.hasMessagingMenu=false] if set, mount messaging
+ * menu.
+ * @param {boolean} [param0.hasTimeControl=false] if set, all flow of time
+ * with `env.browser.setTimeout` are fully controlled by test itself.
+ * @see addTimeControlToEnv that adds `advanceTime` function in
+ * `env.testUtils`.
+ * @param {boolean} [param0.hasView=false] if set, use createView to create a
+ * view instead of a generic widget.
+ * @param {string} [param0.model] makes only sense when `param0.hasView` is set:
+ * the model to use in createView.
+ * @param {integer} [param0.res_id] makes only sense when `param0.hasView` is set:
+ * the res_id to use in createView.
+ * @param {Object} [param0.services]
+ * @param {Object} [param0.session]
+ * @param {Object} [param0.View] makes only sense when `param0.hasView` is set:
+ * the View class to use in createView.
+ * @param {Object} [param0.viewOptions] makes only sense when `param0.hasView`
+ * is set: the view options to use in createView.
+ * @param {boolean} [param0.waitUntilMessagingInitialized=true]
+ * @param {...Object} [param0.kwargs]
+ * @returns {Object}
+ */
+async function start(param0 = {}) {
+ let callbacks = {
+ init: [],
+ mount: [],
+ destroy: [],
+ return: [],
+ };
+ const {
+ hasActionManager = false,
+ hasChatWindow = false,
+ hasDialog = false,
+ hasDiscuss = false,
+ hasMessagingMenu = false,
+ hasTimeControl = false,
+ hasView = false,
+ waitUntilMessagingInitialized = true,
+ } = param0;
+ delete param0.hasActionManager;
+ delete param0.hasChatWindow;
+ delete param0.hasDiscuss;
+ delete param0.hasMessagingMenu;
+ delete param0.hasTimeControl;
+ delete param0.hasView;
+ if (hasChatWindow) {
+ callbacks = _useChatWindow(callbacks);
+ }
+ if (hasDialog) {
+ callbacks = _useDialog(callbacks);
+ }
+ if (hasDiscuss) {
+ callbacks = _useDiscuss(callbacks);
+ }
+ if (hasMessagingMenu) {
+ callbacks = _useMessagingMenu(callbacks);
+ }
+ const {
+ init: initCallbacks,
+ mount: mountCallbacks,
+ destroy: destroyCallbacks,
+ return: returnCallbacks,
+ } = callbacks;
+ const { debug = false } = param0;
+ initCallbacks.forEach(callback => callback(param0));
+
+ let env = addMessagingToEnv(param0.env, { debug });
+ if (hasTimeControl) {
+ env = addTimeControlToEnv(env);
+ }
+
+ const services = Object.assign({}, {
+ bus_service: BusService.extend({
+ _beep() {}, // Do nothing
+ _poll() {}, // Do nothing
+ _registerWindowUnload() {}, // Do nothing
+ isOdooFocused() {
+ return true;
+ },
+ updateOption() {},
+ }),
+ chat_window: ChatWindowService.extend({
+ _getParentNode() {
+ return document.querySelector(debug ? 'body' : '#qunit-fixture');
+ },
+ _listenHomeMenu: () => {},
+ }),
+ dialog: DialogService.extend({
+ _getParentNode() {
+ return document.querySelector(debug ? 'body' : '#qunit-fixture');
+ },
+ _listenHomeMenu: () => {},
+ }),
+ local_storage: AbstractStorageService.extend({ storage: new RamStorage() }),
+ notification: NotificationService.extend(),
+ }, param0.services);
+
+ const kwargs = Object.assign({}, param0, {
+ archs: Object.assign({}, {
+ 'mail.message,false,search': ''
+ }, param0.archs),
+ debug: param0.debug || false,
+ services: Object.assign({}, services, param0.services),
+ }, { env });
+ let widget;
+ let mockServer; // only in basic mode
+ let testEnv;
+ const selector = debug ? 'body' : '#qunit-fixture';
+ if (hasView) {
+ widget = await createView(kwargs);
+ legacyPatch(widget, {
+ destroy() {
+ this._super(...arguments);
+ destroyCallbacks.forEach(callback => callback({ widget }));
+ legacyUnpatch(widget);
+ if (testEnv) {
+ testEnv.destroyMessaging();
+ }
+ }
+ });
+ } else if (hasActionManager) {
+ widget = await createActionManager(kwargs);
+ legacyPatch(widget, {
+ destroy() {
+ this._super(...arguments);
+ destroyCallbacks.forEach(callback => callback({ widget }));
+ legacyUnpatch(widget);
+ if (testEnv) {
+ testEnv.destroyMessaging();
+ }
+ }
+ });
+ } else {
+ const Parent = Widget.extend({ do_push_state() {} });
+ const parent = new Parent();
+ mockServer = await addMockEnvironment(parent, kwargs);
+ widget = new Widget(parent);
+ await widget.appendTo($(selector));
+ Object.assign(widget, {
+ destroy() {
+ delete widget.destroy;
+ destroyCallbacks.forEach(callback => callback({ widget }));
+ parent.destroy();
+ if (testEnv) {
+ testEnv.destroyMessaging();
+ }
+ },
+ });
+ }
+
+ testEnv = Component.env;
+
+ /**
+ * Components cannot use web.bus, because they cannot use
+ * EventDispatcherMixin, and webclient cannot easily access env.
+ * Communication between webclient and components by core.bus
+ * (usable by webclient) and messagingBus (usable by components), which
+ * the messaging service acts as mediator since it can easily use both
+ * kinds of buses.
+ */
+ testEnv.bus.on(
+ 'hide_home_menu',
+ null,
+ () => testEnv.messagingBus.trigger('hide_home_menu')
+ );
+ testEnv.bus.on(
+ 'show_home_menu',
+ null,
+ () => testEnv.messagingBus.trigger('show_home_menu')
+ );
+ testEnv.bus.on(
+ 'will_hide_home_menu',
+ null,
+ () => testEnv.messagingBus.trigger('will_hide_home_menu')
+ );
+ testEnv.bus.on(
+ 'will_show_home_menu',
+ null,
+ () => testEnv.messagingBus.trigger('will_show_home_menu')
+ );
+ testEnv.modelManager.start(testEnv);
+ /**
+ * Create the messaging singleton record.
+ */
+ testEnv.messaging = testEnv.models['mail.messaging'].create();
+ testEnv.messaging.start().then(() =>
+ testEnv.messagingInitializedDeferred.resolve()
+ );
+
+ const result = { env: testEnv, mockServer, widget };
+
+ if (waitUntilMessagingInitialized) {
+ // env key only accessible after MessagingService has started
+ await testEnv.messagingInitializedDeferred;
+ }
+
+ if (mountCallbacks.length > 0) {
+ await afterNextRender(async () => {
+ await Promise.all(mountCallbacks.map(callback => callback({ selector, widget })));
+ });
+ }
+ returnCallbacks.forEach(callback => callback(result));
+ return result;
+}
+
+//------------------------------------------------------------------------------
+// Public: file utilities
+//------------------------------------------------------------------------------
+
+/**
+ * Drag some files over a DOM element
+ *
+ * @param {DOM.Element} el
+ * @param {Object[]} file must have been create beforehand
+ * @see testUtils.file.createFile
+ */
+function dragenterFiles(el, files) {
+ const ev = new Event('dragenter', { bubbles: true });
+ Object.defineProperty(ev, 'dataTransfer', {
+ value: _createFakeDataTransfer(files),
+ });
+ el.dispatchEvent(ev);
+}
+
+/**
+ * Drop some files on a DOM element
+ *
+ * @param {DOM.Element} el
+ * @param {Object[]} files must have been created beforehand
+ * @see testUtils.file.createFile
+ */
+function dropFiles(el, files) {
+ const ev = new Event('drop', { bubbles: true });
+ Object.defineProperty(ev, 'dataTransfer', {
+ value: _createFakeDataTransfer(files),
+ });
+ el.dispatchEvent(ev);
+}
+
+/**
+ * Set files in a file input
+ *
+ * @param {DOM.Element} el
+ * @param {Object[]} files must have been created beforehand
+ * @see testUtils.file.createFile
+ */
+function inputFiles(el, files) {
+ const dataTransfer = new window.DataTransfer();
+ for (const file of files) {
+ dataTransfer.items.add(file);
+ }
+ el.files = dataTransfer.files;
+ /**
+ * Changing files programatically is not supposed to trigger the event but
+ * it does in Chrome versions before 73 (which is on runbot), so in that
+ * case there is no need to make a manual dispatch, because it would lead to
+ * the files being added twice.
+ */
+ const versionRaw = navigator.userAgent.match(/Chrom(e|ium)\/([0-9]+)\./);
+ const chromeVersion = versionRaw ? parseInt(versionRaw[2], 10) : false;
+ if (!chromeVersion || chromeVersion >= 73) {
+ el.dispatchEvent(new Event('change'));
+ }
+}
+
+/**
+ * Paste some files on a DOM element
+ *
+ * @param {DOM.Element} el
+ * @param {Object[]} files must have been created beforehand
+ * @see testUtils.file.createFile
+ */
+function pasteFiles(el, files) {
+ const ev = new Event('paste', { bubbles: true });
+ Object.defineProperty(ev, 'clipboardData', {
+ value: _createFakeDataTransfer(files),
+ });
+ el.dispatchEvent(ev);
+}
+
+//------------------------------------------------------------------------------
+// Export
+//------------------------------------------------------------------------------
+
+return {
+ afterEach,
+ afterNextRender,
+ beforeEach,
+ dragenterFiles,
+ dropFiles,
+ inputFiles,
+ nextAnimationFrame,
+ nextTick,
+ pasteFiles,
+ start,
+};
+
+});
diff --git a/addons/mail/static/src/utils/throttle/throttle.js b/addons/mail/static/src/utils/throttle/throttle.js
new file mode 100644
index 00000000000..6b9ff00844a
--- /dev/null
+++ b/addons/mail/static/src/utils/throttle/throttle.js
@@ -0,0 +1,382 @@
+odoo.define('mail/static/src/utils/throttle/throttle.js', function (require) {
+'use strict';
+
+const { makeDeferred } = require('mail/static/src/utils/deferred/deferred.js');
+
+/**
+ * This module define an utility function that enables throttling calls on a
+ * provided function. Such throttled calls can be canceled, flushed and/or
+ * cleared:
+ *
+ * - cancel: Canceling a throttle function call means that if a function call is
+ * pending invocation, cancel removes this pending call invocation. It however
+ * preserves the internal timer of the cooling down phase of this throttle
+ * function, meaning that any following throttle function call will be pending
+ * and has to wait for the remaining time of the cooling down phase before
+ * being invoked.
+ *
+ * - flush: Flushing a throttle function call means that if a function call is
+ * pending invocation, flush immediately terminates the cooling down phase and
+ * the pending function call is immediately invoked. Flush also works without
+ * any pending function call: it just terminates the cooling down phase, so
+ * that a following function call is guaranteed to be immediately called.
+ *
+ * - clear: Clearing a throttle function combines canceling and flushing
+ * together.
+ */
+
+//------------------------------------------------------------------------------
+// Errors
+//------------------------------------------------------------------------------
+
+/**
+ * List of internal and external Throttle errors.
+ * Internal errors are prefixed with `_`.
+ */
+
+ /**
+ * Error when throttle function has been canceled with `.cancel()`. Used to
+ * let the caller know of throttle function that the call has been canceled,
+ * which means the inner function will not be called. Usually caller should
+ * just accept it and kindly treat this error as a polite warning.
+ */
+class ThrottleCanceledError extends Error {
+ /**
+ * @override
+ */
+ constructor(throttleId, ...args) {
+ super(...args);
+ this.name = 'ThrottleCanceledError';
+ this.throttleId = throttleId;
+ }
+}
+/**
+ * Error when throttle function has been reinvoked again. Used to let know
+ * caller of throttle function that the call has been canceled and replaced with
+ * another one, which means the (potentially) following inner function will be
+ * in the context of another call. Same as for `ThrottleCanceledError`, usually
+ * caller should just accept it and kindly treat this error as a polite
+ * warning.
+ */
+class ThrottleReinvokedError extends Error {
+ /**
+ * @override
+ */
+ constructor(throttleId, ...args) {
+ super(...args);
+ this.name = 'ThrottleReinvokedError';
+ this.throttleId = throttleId;
+ }
+}
+/**
+ * Error when throttle function has been flushed with `.flush()`. Used
+ * internally to immediately invoke pending inner functions, since a flush means
+ * the termination of cooling down phase.
+ *
+ * @private
+ */
+class _ThrottleFlushedError extends Error {
+ /**
+ * @override
+ */
+ constructor(throttleId, ...args) {
+ super(...args);
+ this.name = '_ThrottleFlushedError';
+ this.throttleId = throttleId;
+ }
+}
+
+//------------------------------------------------------------------------------
+// Private
+//------------------------------------------------------------------------------
+
+/**
+ * This class models the behaviour of the cancelable, flushable and clearable
+ * throttle version of a provided function. See definitions at the top of this
+ * file.
+ */
+class Throttle {
+
+ /**
+ * @param {Object} env the OWL env
+ * @param {function} func provided function for making throttled version.
+ * @param {integer} duration duration of the 'cool down' phase, i.e.
+ * the minimum duration between the most recent function call that has
+ * been made and the following function call (of course, assuming no flush
+ * in-between).
+ */
+ constructor(env, func, duration) {
+ /**
+ * Reference to the OWL envirionment. Useful to fine-tune control of
+ * time flow in tests.
+ * @see mail/static/src/utils/test_utils.js:start.hasTimeControl
+ */
+ this.env = env;
+ /**
+ * Unique id of this throttle function. Useful for the ThrottleError
+ * management, in order to determine whether these errors come from
+ * this throttle or from another one (e.g. inner function makes use of
+ * another throttle).
+ */
+ this.id = _.uniqueId('throttle_');
+ /**
+ * Deferred of current cooling down phase in progress. Defined only when
+ * there is a cooling down phase in progress. Resolved when cooling down
+ * phase terminates from timeout, and rejected if flushed.
+ *
+ * @see _ThrottleFlushedError for rejection of this deferred.
+ */
+ this._coolingDownDeferred = undefined;
+ /**
+ * Duration, in milliseconds, of the cool down phase.
+ */
+ this._duration = duration;
+ /**
+ * Inner function to be invoked and throttled.
+ */
+ this._function = func;
+ /**
+ * Determines whether the throttle function is currently in cool down
+ * phase. Cool down phase happens just after inner function has been
+ * invoked, and during this time any following function call are pending
+ * and will be invoked only after the end of the cool down phase (except
+ * if canceled).
+ */
+ this._isCoolingDown = false;
+ /**
+ * Deferred of a currently pending invocation to inner function. Defined
+ * only during a cooling down phase and just after when throttle
+ * function has been called during this cooling down phase. It is kept
+ * until cooling down phase ends (either from timeout or flushed
+ * throttle) or until throttle is canceled (i.e. removes pending invoke
+ * while keeping cooling down phase live on).
+ */
+ this._pendingInvokeDeferred = undefined;
+ }
+
+ //--------------------------------------------------------------------------
+ // Public
+ //--------------------------------------------------------------------------
+
+ /**
+ * Cancel any buffered function call while keeping the cooldown phase
+ * running.
+ */
+ cancel() {
+ if (!this._isCoolingDown) {
+ return;
+ }
+ if (!this._pendingInvokeDeferred) {
+ return;
+ }
+ this._pendingInvokeDeferred.reject(new ThrottleCanceledError(this.id));
+ }
+
+ /**
+ * Clear any buffered function call and immediately terminates any cooling
+ * down phase in progress.
+ */
+ clear() {
+ this.cancel();
+ this.flush();
+ }
+
+ /**
+ * Called when there is a call to the function. This function is throttled,
+ * so the time it is called depends on whether the "cooldown stage" occurs
+ * or not:
+ *
+ * - no cooldown stage: function is called immediately, and it starts
+ * the cooldown stage when successful.
+ * - in cooldown stage: function is called when the cooldown stage has
+ * ended from timeout.
+ *
+ * Note that after the cooldown stage, only the last attempted function
+ * call will be considered.
+ *
+ * @param {...any} args
+ * @throws {ThrottleReinvokedError|ThrottleCanceledError}
+ * @returns {any} result of called function, if it's called.
+ */
+ async do(...args) {
+ if (!this._isCoolingDown) {
+ return this._invokeFunction(...args);
+ }
+ if (this._pendingInvokeDeferred) {
+ this._pendingInvokeDeferred.reject(new ThrottleReinvokedError(this.id));
+ }
+ try {
+ this._pendingInvokeDeferred = makeDeferred();
+ await Promise.race([this._coolingDownDeferred, this._pendingInvokeDeferred]);
+ } catch (error) {
+ if (
+ !(error instanceof _ThrottleFlushedError) ||
+ error.throttleId !== this.id
+ ) {
+ throw error;
+ }
+ } finally {
+ this._pendingInvokeDeferred = undefined;
+ }
+ return this._invokeFunction(...args);
+ }
+
+ /**
+ * Flush the internal throttle timer, so that the following function call
+ * is immediate. For instance, if there is a cooldown stage, it is aborted.
+ */
+ flush() {
+ if (!this._isCoolingDown) {
+ return;
+ }
+ const coolingDownDeferred = this._coolingDownDeferred;
+ this._coolingDownDeferred = undefined;
+ this._isCoolingDown = false;
+ coolingDownDeferred.reject(new _ThrottleFlushedError(this.id));
+ }
+
+ //--------------------------------------------------------------------------
+ // Private
+ //--------------------------------------------------------------------------
+
+ /**
+ * Invoke the inner function of this throttle and starts cooling down phase
+ * immediately after.
+ *
+ * @private
+ * @param {...any} args
+ */
+ _invokeFunction(...args) {
+ const res = this._function(...args);
+ this._startCoolingDown();
+ return res;
+ }
+
+ /**
+ * Called just when the inner function is being called. Starts the cooling
+ * down phase, which turn any call to this throttle function as pending
+ * inner function calls. This will be called after the end of cooling down
+ * phase (except if canceled).
+ */
+ async _startCoolingDown() {
+ if (this._coolingDownDeferred) {
+ throw new Error("Cannot start cooling down if there's already a cooling down in progress.");
+ }
+ // Keep local reference of cooling down deferred, because the one stored
+ // on `this` could be overwritten by another call to this throttle.
+ const coolingDownDeferred = makeDeferred();
+ this._coolingDownDeferred = coolingDownDeferred;
+ this._isCoolingDown = true;
+ const cooldownTimeoutId = this.env.browser.setTimeout(
+ () => coolingDownDeferred.resolve(),
+ this._duration
+ );
+ let unexpectedError;
+ try {
+ await coolingDownDeferred;
+ } catch (error) {
+ if (
+ !(error instanceof _ThrottleFlushedError) ||
+ error.throttleId !== this.id
+ ) {
+ // This branching should never happen.
+ // Still defined in case of programming error.
+ unexpectedError = error;
+ }
+ } finally {
+ this.env.browser.clearTimeout(cooldownTimeoutId);
+ this._coolingDownDeferred = undefined;
+ this._isCoolingDown = false;
+ }
+ if (unexpectedError) {
+ throw unexpectedError;
+ }
+ }
+
+}
+
+//------------------------------------------------------------------------------
+// Public
+//------------------------------------------------------------------------------
+
+/**
+ * A function that creates a cancelable, flushable and clearable throttle
+ * version of a provided function. See definitions at the top of this file.
+ *
+ * This throttle mechanism allows calling a function at most once during a
+ * certain period:
+ *
+ * - When a function call is made, it enters a 'cooldown' phase, in which any
+ * attempt to call the function is buffered until the cooldown phase ends.
+ * - At most 1 function call can be buffered during the cooldown phase, and the
+ * latest one in this phase will be considered at its end.
+ * - When a cooldown phase ends, any buffered function call will be performed
+ * and another cooldown phase will follow up.
+ *
+ * @param {Object} env the OWL env
+ * @param {function} func the function to throttle.
+ * @param {integer} duration duration, in milliseconds, of the cooling down
+ * phase of the throttling.
+ * @param {Object} [param2={}]
+ * @param {boolean} [param2.silentCancelationErrors=true] if unset, caller
+ * of throttle function will observe some errors that come from current
+ * throttle call that has been canceled, such as when throttle function has
+ * been explicitly canceled with `.cancel()` or when another new throttle call
+ * has been registered.
+ * @see ThrottleCanceledError for when a call has been canceled from explicit
+ * call.
+ * @see ThrottleReinvokedError for when a call has been canceled from another
+ * new throttle call has been registered.
+ * @returns {function} the cancelable, flushable and clearable throttle version
+ * of the provided function.
+ */
+function throttle(
+ env,
+ func,
+ duration,
+ { silentCancelationErrors = true } = {}
+) {
+ const throttleObj = new Throttle(env, func, duration);
+ const callable = async (...args) => {
+ try {
+ // await is important, otherwise errors are not intercepted.
+ return await throttleObj.do(...args);
+ } catch (error) {
+ const isSelfReinvokedError = (
+ error instanceof ThrottleReinvokedError &&
+ error.throttleId === throttleObj.id
+ );
+ const isSelfCanceledError = (
+ error instanceof ThrottleCanceledError &&
+ error.throttleId === throttleObj.id
+ );
+
+ if (silentCancelationErrors && (isSelfReinvokedError || isSelfCanceledError)) {
+ // Silently ignore cancelation errors.
+ // Promise is indefinitely pending for async functions.
+ return new Promise(() => {});
+ } else {
+ throw error;
+ }
+ }
+ };
+ Object.assign(callable, {
+ cancel: () => throttleObj.cancel(),
+ clear: () => throttleObj.clear(),
+ flush: () => throttleObj.flush(),
+ });
+ return callable;
+}
+
+/**
+ * Make external throttle errors accessible from throttle function.
+ */
+Object.assign(throttle, {
+ ThrottleReinvokedError,
+ ThrottleCanceledError,
+});
+
+
+return throttle;
+
+});
diff --git a/addons/mail/static/src/utils/throttle/throttle_tests.js b/addons/mail/static/src/utils/throttle/throttle_tests.js
new file mode 100644
index 00000000000..ecd3710d55c
--- /dev/null
+++ b/addons/mail/static/src/utils/throttle/throttle_tests.js
@@ -0,0 +1,410 @@
+odoo.define('mail/static/src/utils/throttle/throttle_tests.js', function (require) {
+'use strict';
+
+const {
+ afterEach: utilsAfterEach,
+ beforeEach: utilsBeforeEach,
+ start,
+} = require('mail/static/src/utils/test_utils.js');
+const throttle = require('mail/static/src/utils/throttle/throttle.js');
+const { nextTick } = require('mail/static/src/utils/utils.js');
+
+const { ThrottleReinvokedError, ThrottleCanceledError } = throttle;
+
+QUnit.module('mail', {}, function () {
+QUnit.module('utils', {}, function () {
+QUnit.module('throttle', {}, function () {
+QUnit.module('throttle_tests.js', {
+ beforeEach() {
+ utilsBeforeEach(this);
+ this.throttles = [];
+
+ this.start = async (...args) => {
+ const { env, widget } = await start(...args);
+ this.env = env;
+ this.widget = widget;
+ };
+ },
+ afterEach() {
+ utilsAfterEach(this);
+ // Important: tests should cleanly intercept cancelation errors that
+ // may result from this teardown.
+ this.widget.destroy();
+ for (const t of this.throttles) {
+ t.clear();
+ }
+ },
+});
+
+QUnit.test('single call', async function (assert) {
+ assert.expect(6);
+
+ await this.start({
+ hasTimeControl: true,
+ });
+
+ let hasInvokedFunc = false;
+ const throttledFunc = throttle(
+ this.env,
+ () => {
+ hasInvokedFunc = true;
+ return 'func_result';
+ },
+ 0
+ );
+ this.throttles.push(throttledFunc);
+
+ assert.notOk(
+ hasInvokedFunc,
+ "func should not have been invoked on immediate throttle initialization"
+ );
+
+ await this.env.testUtils.advanceTime(0);
+ assert.notOk(
+ hasInvokedFunc,
+ "func should not have been invoked from throttle initialization after 0ms"
+ );
+
+ throttledFunc().then(res => {
+ assert.step('throttle_observed_invoke');
+ assert.strictEqual(
+ res,
+ 'func_result',
+ "throttle call return should forward result of inner func"
+ );
+ });
+ await nextTick();
+ assert.ok(
+ hasInvokedFunc,
+ "func should have been immediately invoked on first throttle call"
+ );
+ assert.verifySteps(
+ ['throttle_observed_invoke'],
+ "throttle should have observed invoked on first throttle call"
+ );
+});
+
+QUnit.test('2nd (throttled) call', async function (assert) {
+ assert.expect(8);
+
+ await this.start({
+ hasTimeControl: true,
+ });
+
+ let funcCalledAmount = 0;
+ const throttledFunc = throttle(
+ this.env,
+ () => {
+ funcCalledAmount++;
+ return `func_result_${funcCalledAmount}`;
+ },
+ 1000
+ );
+ this.throttles.push(throttledFunc);
+
+ throttledFunc().then(result => {
+ assert.step('throttle_observed_invoke_1');
+ assert.strictEqual(
+ result,
+ 'func_result_1',
+ "throttle call return should forward result of inner func 1"
+ );
+ });
+ await nextTick();
+ assert.verifySteps(
+ ['throttle_observed_invoke_1'],
+ "inner function of throttle should have been invoked on 1st call (immediate return)"
+ );
+
+ throttledFunc().then(res => {
+ assert.step('throttle_observed_invoke_2');
+ assert.strictEqual(
+ res,
+ 'func_result_2',
+ "throttle call return should forward result of inner func 2"
+ );
+ });
+ await nextTick();
+ assert.verifySteps(
+ [],
+ "inner function of throttle should not have been immediately invoked after 2nd call immediately after 1st call (throttled with 1s internal clock)"
+ );
+
+ await this.env.testUtils.advanceTime(999);
+ assert.verifySteps(
+ [],
+ "inner function of throttle should not have been invoked after 999ms of 2nd call (throttled with 1s internal clock)"
+ );
+
+ await this.env.testUtils.advanceTime(1);
+ assert.verifySteps(
+ ['throttle_observed_invoke_2'],
+ "inner function of throttle should not have been invoked after 1s of 2nd call (throttled with 1s internal clock)"
+ );
+});
+
+QUnit.test('throttled call reinvocation', async function (assert) {
+ assert.expect(11);
+
+ await this.start({
+ hasTimeControl: true,
+ });
+
+ let funcCalledAmount = 0;
+ const throttledFunc = throttle(
+ this.env,
+ () => {
+ funcCalledAmount++;
+ return `func_result_${funcCalledAmount}`;
+ },
+ 1000,
+ { silentCancelationErrors: false }
+ );
+ this.throttles.push(throttledFunc);
+
+ throttledFunc().then(result => {
+ assert.step('throttle_observed_invoke_1');
+ assert.strictEqual(
+ result,
+ 'func_result_1',
+ "throttle call return should forward result of inner func 1"
+ );
+ });
+ await nextTick();
+ assert.verifySteps(
+ ['throttle_observed_invoke_1'],
+ "inner function of throttle should have been invoked on 1st call (immediate return)"
+ );
+
+ throttledFunc()
+ .then(() => {
+ throw new Error("2nd throttle call should not be resolved (should have been canceled by reinvocation)");
+ })
+ .catch(error => {
+ assert.ok(
+ error instanceof ThrottleReinvokedError,
+ "Should generate a Throttle reinvoked error (from another throttle function call)"
+ );
+ assert.step('throttle_reinvoked_1');
+ });
+ await nextTick();
+ assert.verifySteps(
+ [],
+ "inner function of throttle should not have been immediately invoked after 2nd call immediately after 1st call (throttled with 1s internal clock)"
+ );
+
+ await this.env.testUtils.advanceTime(999);
+ assert.verifySteps(
+ [],
+ "inner function of throttle should not have been invoked after 999ms of 2nd call (throttled with 1s internal clock)"
+ );
+
+ throttledFunc()
+ .then(result => {
+ assert.step('throttle_observed_invoke_2');
+ assert.strictEqual(
+ result,
+ 'func_result_2',
+ "throttle call return should forward result of inner func 2"
+ );
+ });
+ await nextTick();
+ assert.verifySteps(
+ ['throttle_reinvoked_1'],
+ "2nd throttle call should have been canceled from 3rd throttle call (reinvoked before cooling down phase has ended)"
+ );
+
+ await this.env.testUtils.advanceTime(1);
+ assert.verifySteps(
+ ['throttle_observed_invoke_2'],
+ "inner function of throttle should have been invoked after 1s of 1st call (throttled with 1s internal clock, 3rd throttle call re-use timer of 2nd throttle call)"
+ );
+});
+
+QUnit.test('flush throttled call', async function (assert) {
+ assert.expect(9);
+
+ await this.start({
+ hasTimeControl: true,
+ });
+
+ const throttledFunc = throttle(
+ this.env,
+ () => {},
+ 1000,
+ );
+ this.throttles.push(throttledFunc);
+
+ throttledFunc().then(() => assert.step('throttle_observed_invoke_1'));
+ await nextTick();
+ assert.verifySteps(
+ ['throttle_observed_invoke_1'],
+ "inner function of throttle should have been invoked on 1st call (immediate return)"
+ );
+
+ throttledFunc().then(() => assert.step('throttle_observed_invoke_2'));
+ await nextTick();
+ assert.verifySteps(
+ [],
+ "inner function of throttle should not have been immediately invoked after 2nd call immediately after 1st call (throttled with 1s internal clock)"
+ );
+
+ await this.env.testUtils.advanceTime(10);
+ assert.verifySteps(
+ [],
+ "inner function of throttle should not have been invoked after 10ms of 2nd call (throttled with 1s internal clock)"
+ );
+
+ throttledFunc.flush();
+ await nextTick();
+ assert.verifySteps(
+ ['throttle_observed_invoke_2'],
+ "inner function of throttle should have been invoked from 2nd call after flush"
+ );
+
+ throttledFunc().then(() => assert.step('throttle_observed_invoke_3'));
+ await nextTick();
+ await this.env.testUtils.advanceTime(999);
+ assert.verifySteps(
+ [],
+ "inner function of throttle should not have been invoked after 999ms of 3rd call (throttled with 1s internal clock)"
+ );
+
+ await this.env.testUtils.advanceTime(1);
+ assert.verifySteps(
+ ['throttle_observed_invoke_3'],
+ "inner function of throttle should not have been invoked after 999ms of 3rd call (throttled with 1s internal clock)"
+ );
+});
+
+QUnit.test('cancel throttled call', async function (assert) {
+ assert.expect(10);
+
+ await this.start({
+ hasTimeControl: true,
+ });
+
+ const throttledFunc = throttle(
+ this.env,
+ () => {},
+ 1000,
+ { silentCancelationErrors: false }
+ );
+ this.throttles.push(throttledFunc);
+
+ throttledFunc().then(() => assert.step('throttle_observed_invoke_1'));
+ await nextTick();
+ assert.verifySteps(
+ ['throttle_observed_invoke_1'],
+ "inner function of throttle should have been invoked on 1st call (immediate return)"
+ );
+
+ throttledFunc()
+ .then(() => {
+ throw new Error("2nd throttle call should not be resolved (should have been canceled)");
+ })
+ .catch(error => {
+ assert.ok(
+ error instanceof ThrottleCanceledError,
+ "Should generate a Throttle canceled error (from `.cancel()`)"
+ );
+ assert.step('throttle_canceled');
+ });
+ await nextTick();
+ assert.verifySteps(
+ [],
+ "inner function of throttle should not have been immediately invoked after 2nd call immediately after 1st call (throttled with 1s internal clock)"
+ );
+
+ await this.env.testUtils.advanceTime(500);
+ assert.verifySteps(
+ [],
+ "inner function of throttle should not have been invoked after 500ms of 2nd call (throttled with 1s internal clock)"
+ );
+
+ throttledFunc.cancel();
+ await nextTick();
+ assert.verifySteps(
+ ['throttle_canceled'],
+ "2nd throttle function call should have been canceled"
+ );
+
+ throttledFunc().then(() => assert.step('throttle_observed_invoke_3'));
+ await nextTick();
+ assert.verifySteps(
+ [],
+ "3rd throttle function call should not have invoked inner function yet (cancel reuses inner clock of throttle)"
+ );
+
+ await this.env.testUtils.advanceTime(500);
+ assert.verifySteps(
+ ['throttle_observed_invoke_3'],
+ "3rd throttle function call should have invoke inner function after 500ms (cancel reuses inner clock of throttle which was at 500ms in, throttle set at 1ms)"
+ );
+});
+
+QUnit.test('clear throttled call', async function (assert) {
+ assert.expect(9);
+
+ await this.start({
+ hasTimeControl: true,
+ });
+
+ const throttledFunc = throttle(
+ this.env,
+ () => {},
+ 1000,
+ { silentCancelationErrors: false }
+ );
+ this.throttles.push(throttledFunc);
+
+ throttledFunc().then(() => assert.step('throttle_observed_invoke_1'));
+ await nextTick();
+ assert.verifySteps(
+ ['throttle_observed_invoke_1'],
+ "inner function of throttle should have been invoked on 1st call (immediate return)"
+ );
+
+ throttledFunc()
+ .then(() => {
+ throw new Error("2nd throttle call should not be resolved (should have been canceled from clear)");
+ })
+ .catch(error => {
+ assert.ok(
+ error instanceof ThrottleCanceledError,
+ "Should generate a Throttle canceled error (from `.clear()`)"
+ );
+ assert.step('throttle_canceled');
+ });
+ await nextTick();
+ assert.verifySteps(
+ [],
+ "inner function of throttle should not have been immediately invoked after 2nd call immediately after 1st call (throttled with 1s internal clock)"
+ );
+
+ await this.env.testUtils.advanceTime(500);
+ assert.verifySteps(
+ [],
+ "inner function of throttle should not have been invoked after 500ms of 2nd call (throttled with 1s internal clock)"
+ );
+
+ throttledFunc.clear();
+ await nextTick();
+ assert.verifySteps(
+ ['throttle_canceled'],
+ "2nd throttle function call should have been canceled (from `.clear()`)"
+ );
+
+ throttledFunc().then(() => assert.step('throttle_observed_invoke_3'));
+ await nextTick();
+ assert.verifySteps(
+ ['throttle_observed_invoke_3'],
+ "3rd throttle function call should have invoke inner function immediately (`.clear()` flushes throttle)"
+ );
+});
+
+});
+});
+});
+
+});
diff --git a/addons/mail/static/src/utils/timer/timer.js b/addons/mail/static/src/utils/timer/timer.js
new file mode 100644
index 00000000000..56d7f58e39e
--- /dev/null
+++ b/addons/mail/static/src/utils/timer/timer.js
@@ -0,0 +1,165 @@
+odoo.define('mail/static/src/utils/timer/timer.js', function (require) {
+'use strict';
+
+const { makeDeferred } = require('mail/static/src/utils/deferred/deferred.js');
+
+//------------------------------------------------------------------------------
+// Errors
+//------------------------------------------------------------------------------
+
+/**
+ * List of Timer errors.
+ */
+
+ /**
+ * Error when timer has been cleared with `.clear()` or `.reset()`. Used to
+ * let know caller of timer that the countdown has been aborted, which
+ * means the inner function will not be called. Usually caller should just
+ * accept it and kindly treated this error as a polite warning.
+ */
+ class TimerClearedError extends Error {
+ /**
+ * @override
+ */
+ constructor(timerId, ...args) {
+ super(...args);
+ this.name = 'TimerClearedError';
+ this.timerId = timerId;
+ }
+}
+
+//------------------------------------------------------------------------------
+// Private
+//------------------------------------------------------------------------------
+
+/**
+ * This class creates a timer which, when times out, calls a function.
+ * Note that the timer is not started on initialization (@see start method).
+ */
+class Timer {
+
+ /**
+ * @param {Object} env the OWL env
+ * @param {function} onTimeout
+ * @param {integer} duration
+ * @param {Object} [param3={}]
+ * @param {boolean} [param3.silentCancelationErrors=true] if unset, caller
+ * of timer will observe some errors that come from current timer calls
+ * that has been cleared with `.clear()` or `.reset()`.
+ * @see TimerClearedError for when timer has been aborted from `.clear()`
+ * or `.reset()`.
+ */
+ constructor(env, onTimeout, duration, { silentCancelationErrors = true } = {}) {
+ this.env = env;
+ /**
+ * Determine whether the timer has a pending timeout.
+ */
+ this.isRunning = false;
+ /**
+ * Duration, in milliseconds, until timer times out and calls the
+ * timeout function.
+ */
+ this._duration = duration;
+ /**
+ * Determine whether the caller of timer `.start()` and `.reset()`
+ * should observe cancelation errors from `.clear()` or `.reset()`.
+ */
+ this._hasSilentCancelationErrors = silentCancelationErrors;
+ /**
+ * The function that is called when the timer times out.
+ */
+ this._onTimeout = onTimeout;
+ /**
+ * Deferred of a currently pending invocation to inner function on
+ * timeout.
+ */
+ this._timeoutDeferred = undefined;
+ /**
+ * Internal reference of `setTimeout()` that is used to invoke function
+ * when timer times out. Useful to clear it when timer is cleared/reset.
+ */
+ this._timeoutId = undefined;
+ }
+
+ //--------------------------------------------------------------------------
+ // Public
+ //--------------------------------------------------------------------------
+
+ /**
+ * Clear the timer, which basically sets the state of timer as if it was
+ * just instantiated, without being started. This function makes sense only
+ * when this timer is running.
+ */
+ clear() {
+ this.env.browser.clearTimeout(this._timeoutId);
+ this.isRunning = false;
+ if (!this._timeoutDeferred) {
+ return;
+ }
+ this._timeoutDeferred.reject(new TimerClearedError(this.id));
+ }
+
+ /**
+ * Reset the timer, i.e. the pending timeout is refreshed with initial
+ * duration. This function makes sense only when this timer is running.
+ */
+ async reset() {
+ this.clear();
+ await this.start();
+ }
+
+ /**
+ * Starts the timer, i.e. after a certain duration, it times out and calls
+ * a function back. This function makes sense only when this timer is not
+ * yet running.
+ *
+ * @throws {Error} in case the timer is already running.
+ */
+ async start() {
+ if (this.isRunning) {
+ throw new Error("Cannot start a timer that is currently running.");
+ }
+ this.isRunning = true;
+ const timeoutDeferred = makeDeferred();
+ this._timeoutDeferred = timeoutDeferred;
+ const timeoutId = this.env.browser.setTimeout(
+ () => {
+ this.isRunning = false;
+ timeoutDeferred.resolve(this._onTimeout());
+ },
+ this._duration
+ );
+ this._timeoutId = timeoutId;
+ let result;
+ try {
+ result = await timeoutDeferred;
+ } catch (error) {
+ if (
+ !this._hasSilentCancelationErrors ||
+ !(error instanceof TimerClearedError) ||
+ error.timerId !== this.id
+ ) {
+ // This branching should never happens.
+ // Still defined in case of programming error.
+ throw error;
+ }
+ } finally {
+ this.env.browser.clearTimeout(timeoutId);
+ this._timeoutDeferred = undefined;
+ this.isRunning = false;
+ }
+ return result;
+ }
+
+}
+
+/**
+ * Make external timer errors accessible from timer class.
+ */
+Object.assign(Timer, {
+ TimerClearedError,
+});
+
+return Timer;
+
+});
diff --git a/addons/mail/static/src/utils/timer/timer_tests.js b/addons/mail/static/src/utils/timer/timer_tests.js
new file mode 100644
index 00000000000..9b464ac9396
--- /dev/null
+++ b/addons/mail/static/src/utils/timer/timer_tests.js
@@ -0,0 +1,431 @@
+odoo.define('mail/static/src/utils/timer/timer_tests.js', function (require) {
+'use strict';
+
+const {
+ afterEach: utilsAfterEach,
+ beforeEach: utilsBeforeEach,
+ start,
+} = require('mail/static/src/utils/test_utils.js');
+const Timer = require('mail/static/src/utils/timer/timer.js');
+const { nextTick } = require('mail/static/src/utils/test_utils.js');
+
+const { TimerClearedError } = Timer;
+
+QUnit.module('mail', {}, function () {
+QUnit.module('utils', {}, function () {
+QUnit.module('timer', {}, function () {
+QUnit.module('timer_tests.js', {
+ beforeEach() {
+ utilsBeforeEach(this);
+ this.timers = [];
+
+ this.start = async (...args) => {
+ const { env, widget } = await start(...args);
+ this.env = env;
+ this.widget = widget;
+ };
+ },
+ afterEach() {
+ utilsAfterEach(this);
+ // Important: tests should cleanly intercept cancelation errors that
+ // may result from this teardown.
+ this.widget.destroy();
+ for (const timer of this.timers) {
+ timer.clear();
+ }
+ },
+});
+
+QUnit.test('timer does not timeout on initialization', async function (assert) {
+ assert.expect(3);
+
+ await this.start({
+ hasTimeControl: true,
+ });
+
+ let hasTimedOut = false;
+ this.timers.push(
+ new Timer(
+ this.env,
+ () => hasTimedOut = true,
+ 0
+ )
+ );
+
+ assert.notOk(
+ hasTimedOut,
+ "timer should not have timed out on immediate initialization"
+ );
+
+ await this.env.testUtils.advanceTime(0);
+ assert.notOk(
+ hasTimedOut,
+ "timer should not have timed out from initialization after 0ms"
+ );
+
+ await this.env.testUtils.advanceTime(1000 * 1000);
+ assert.notOk(
+ hasTimedOut,
+ "timer should not have timed out from initialization after 1000s"
+ );
+});
+
+QUnit.test('timer start (duration: 0ms)', async function (assert) {
+ assert.expect(2);
+
+ await this.start({
+ hasTimeControl: true,
+ });
+
+ let hasTimedOut = false;
+ this.timers.push(
+ new Timer(
+ this.env,
+ () => hasTimedOut = true,
+ 0
+ )
+ );
+
+ this.timers[0].start();
+ assert.notOk(
+ hasTimedOut,
+ "timer should not have timed out immediately after start"
+ );
+
+ await this.env.testUtils.advanceTime(0);
+ assert.ok(
+ hasTimedOut,
+ "timer should have timed out on start after 0ms"
+ );
+});
+
+QUnit.test('timer start observe termination (duration: 0ms)', async function (assert) {
+ assert.expect(6);
+
+ await this.start({
+ hasTimeControl: true,
+ });
+
+ let hasTimedOut = false;
+ this.timers.push(
+ new Timer(
+ this.env,
+ () => {
+ hasTimedOut = true;
+ return 'timeout_result';
+ },
+ 0
+ )
+ );
+
+ this.timers[0].start()
+ .then(result => {
+ assert.strictEqual(
+ result,
+ 'timeout_result',
+ "value returned by start should be value returned by function on timeout"
+ );
+ assert.step('timeout');
+ });
+ await nextTick();
+ assert.notOk(
+ hasTimedOut,
+ "timer should not have timed out immediately after start"
+ );
+ assert.verifySteps(
+ [],
+ "timer.start() should not have yet observed timeout"
+ );
+
+ await this.env.testUtils.advanceTime(0);
+ assert.ok(
+ hasTimedOut,
+ "timer should have timed out on start after 0ms"
+ );
+ assert.verifySteps(
+ ['timeout'],
+ "timer.start() should have observed timeout after 0ms"
+ );
+});
+
+QUnit.test('timer start (duration: 1000s)', async function (assert) {
+ assert.expect(5);
+
+ await this.start({
+ hasTimeControl: true,
+ });
+
+ let hasTimedOut = false;
+ this.timers.push(
+ new Timer(
+ this.env,
+ () => hasTimedOut = true,
+ 1000 * 1000
+ )
+ );
+
+ this.timers[0].start();
+ assert.notOk(
+ hasTimedOut,
+ "timer should not have timed out immediately after start"
+ );
+
+ await this.env.testUtils.advanceTime(0);
+ assert.notOk(
+ hasTimedOut,
+ "timer should not have timed out on start after 0ms"
+ );
+
+ await this.env.testUtils.advanceTime(1000);
+ assert.notOk(
+ hasTimedOut,
+ "timer should not have timed out on start after 1000ms"
+ );
+
+ await this.env.testUtils.advanceTime(998 * 1000 + 999);
+ assert.notOk(
+ hasTimedOut,
+ "timer should not have timed out on start after 9999ms"
+ );
+
+ await this.env.testUtils.advanceTime(1);
+ assert.ok(
+ hasTimedOut,
+ "timer should have timed out on start after 10s"
+ );
+});
+
+QUnit.test('[no cancelation intercept] timer start then immediate clear (duration: 0ms)', async function (assert) {
+ assert.expect(4);
+
+ await this.start({
+ hasTimeControl: true,
+ });
+
+ let hasTimedOut = false;
+ this.timers.push(
+ new Timer(
+ this.env,
+ () => hasTimedOut = true,
+ 0
+ )
+ );
+
+ this.timers[0].start();
+ assert.notOk(
+ hasTimedOut,
+ "timer should not have timed out immediately after start"
+ );
+
+ this.timers[0].clear();
+ assert.notOk(
+ hasTimedOut,
+ "timer should not have timed out immediately after start and clear"
+ );
+
+ await this.env.testUtils.advanceTime(0);
+ assert.notOk(
+ hasTimedOut,
+ "timer should not have timed out after 0ms of clear"
+ );
+
+ await this.env.testUtils.advanceTime(1000);
+ assert.notOk(
+ hasTimedOut,
+ "timer should not have timed out after 1s of clear"
+ );
+});
+
+QUnit.test('[no cancelation intercept] timer start then clear before timeout (duration: 1000ms)', async function (assert) {
+ assert.expect(4);
+
+ await this.start({
+ hasTimeControl: true,
+ });
+
+ let hasTimedOut = false;
+ this.timers.push(
+ new Timer(
+ this.env,
+ () => hasTimedOut = true,
+ 1000
+ )
+ );
+
+ this.timers[0].start();
+ assert.notOk(
+ hasTimedOut,
+ "timer should not have timed out immediately after start"
+ );
+
+ await this.env.testUtils.advanceTime(999);
+ assert.notOk(
+ hasTimedOut,
+ "timer should not have timed out immediately after 999ms of start"
+ );
+
+ this.timers[0].clear();
+ await this.env.testUtils.advanceTime(1);
+ assert.notOk(
+ hasTimedOut,
+ "timer should not have timed out after 1ms of clear that happens 999ms after start (globally 1s await)"
+ );
+
+ await this.env.testUtils.advanceTime(1000);
+ assert.notOk(
+ hasTimedOut,
+ "timer should not have timed out after 1001ms after clear (timer fully cleared)"
+ );
+});
+
+QUnit.test('[no cancelation intercept] timer start then reset before timeout (duration: 1000ms)', async function (assert) {
+ assert.expect(5);
+
+ await this.start({
+ hasTimeControl: true,
+ });
+
+ let hasTimedOut = false;
+ this.timers.push(
+ new Timer(
+ this.env,
+ () => hasTimedOut = true,
+ 1000
+ )
+ );
+
+ this.timers[0].start();
+ assert.notOk(
+ hasTimedOut,
+ "timer should not have timed out immediately after start"
+ );
+
+ await this.env.testUtils.advanceTime(999);
+ assert.notOk(
+ hasTimedOut,
+ "timer should not have timed out after 999ms of start"
+ );
+
+ this.timers[0].reset();
+ await this.env.testUtils.advanceTime(1);
+ assert.notOk(
+ hasTimedOut,
+ "timer should not have timed out after 1ms of reset which happens 999ms after start"
+ );
+
+ await this.env.testUtils.advanceTime(998);
+ assert.notOk(
+ hasTimedOut,
+ "timer should not have timed out after 999ms of reset"
+ );
+
+ await this.env.testUtils.advanceTime(1);
+ assert.ok(
+ hasTimedOut,
+ "timer should not have timed out after 1s of reset"
+ );
+});
+
+QUnit.test('[with cancelation intercept] timer start then immediate clear (duration: 0ms)', async function (assert) {
+ assert.expect(5);
+
+ await this.start({
+ hasTimeControl: true,
+ });
+
+ let hasTimedOut = false;
+ this.timers.push(
+ new Timer(
+ this.env,
+ () => hasTimedOut = true,
+ 0,
+ { silentCancelationErrors: false }
+ )
+ );
+
+ this.timers[0].start()
+ .then(() => {
+ throw new Error("timer.start() should not be resolved (should have been canceled by clear)");
+ })
+ .catch(error => {
+ assert.ok(
+ error instanceof TimerClearedError,
+ "Should generate a Timer cleared error (from `.clear()`)"
+ );
+ assert.step('timer_cleared');
+ });
+ assert.notOk(
+ hasTimedOut,
+ "timer should not have timed out immediately after start"
+ );
+ await nextTick();
+ assert.verifySteps([], "should not have observed cleared timer (timer not yet cleared)");
+
+ this.timers[0].clear();
+ await nextTick();
+ assert.verifySteps(
+ ['timer_cleared'],
+ "timer.start() should have observed it has been cleared"
+ );
+});
+
+QUnit.test('[with cancelation intercept] timer start then immediate reset (duration: 0ms)', async function (assert) {
+ assert.expect(9);
+
+ await this.start({
+ hasTimeControl: true,
+ });
+
+ let hasTimedOut = false;
+ this.timers.push(
+ new Timer(
+ this.env,
+ () => hasTimedOut = true,
+ 0,
+ { silentCancelationErrors: false }
+ )
+ );
+
+ this.timers[0].start()
+ .then(() => {
+ throw new Error("timer.start() should not observe a timeout");
+ })
+ .catch(error => {
+ assert.ok(error instanceof TimerClearedError, "Should generate a Timer cleared error (from `.reset()`)");
+ assert.step('timer_cleared');
+ });
+ assert.notOk(
+ hasTimedOut,
+ "timer should not have timed out immediately after start"
+ );
+ await nextTick();
+ assert.verifySteps([], "should not have observed cleared timer (timer not yet cleared)");
+
+ this.timers[0].reset()
+ .then(() => assert.step('timer_reset_timeout'));
+ await nextTick();
+ assert.verifySteps(
+ ['timer_cleared'],
+ "timer.start() should have observed it has been cleared"
+ );
+ assert.notOk(
+ hasTimedOut,
+ "timer should not have timed out immediately after reset"
+ );
+
+ await this.env.testUtils.advanceTime(0);
+ assert.ok(
+ hasTimedOut,
+ "timer should have timed out after reset timeout"
+ );
+ assert.verifySteps(
+ ['timer_reset_timeout'],
+ "timer.reset() should have observed it has timed out"
+ );
+});
+
+});
+});
+});
+
+});
diff --git a/addons/mail/static/src/utils/utils.js b/addons/mail/static/src/utils/utils.js
new file mode 100644
index 00000000000..da0855437f7
--- /dev/null
+++ b/addons/mail/static/src/utils/utils.js
@@ -0,0 +1,127 @@
+odoo.define('mail/static/src/utils/utils.js', function (require) {
+'use strict';
+
+const { delay } = require('web.concurrency');
+const {
+ patch: webUtilsPatch,
+ unpatch: webUtilsUnpatch,
+} = require('web.utils');
+
+//------------------------------------------------------------------------------
+// Public
+//------------------------------------------------------------------------------
+
+const classPatchMap = new WeakMap();
+
+/**
+ * Wait a task tick, so that anything in micro-task queue that can be processed
+ * is processed.
+ */
+async function nextTick() {
+ await delay(0);
+}
+
+/**
+ * Inspired by web.utils:patch utility function
+ *
+ * @param {Class} Class
+ * @param {string} patchName
+ * @param {Object} patch
+ * @returns {function} unpatch function
+ */
+function patchClassMethods(Class, patchName, patch) {
+ let metadata = classPatchMap.get(Class);
+ if (!metadata) {
+ metadata = {
+ origMethods: {},
+ patches: {},
+ current: []
+ };
+ classPatchMap.set(Class, metadata);
+ }
+ if (metadata.patches[patchName]) {
+ throw new Error(`Patch [${patchName}] already exists`);
+ }
+ metadata.patches[patchName] = patch;
+ applyPatch(Class, patch);
+ metadata.current.push(patchName);
+
+ function applyPatch(Class, patch) {
+ Object.keys(patch).forEach(function (methodName) {
+ const method = patch[methodName];
+ if (typeof method === "function") {
+ const original = Class[methodName];
+ if (!(methodName in metadata.origMethods)) {
+ metadata.origMethods[methodName] = original;
+ }
+ Class[methodName] = function (...args) {
+ const previousSuper = this._super;
+ this._super = original;
+ const res = method.call(this, ...args);
+ this._super = previousSuper;
+ return res;
+ };
+ }
+ });
+ }
+
+ return () => unpatchClassMethods.bind(Class, patchName);
+}
+
+/**
+ * @param {Class} Class
+ * @param {string} patchName
+ * @param {Object} patch
+ * @returns {function} unpatch function
+ */
+function patchInstanceMethods(Class, patchName, patch) {
+ return webUtilsPatch(Class, patchName, patch);
+}
+
+/**
+ * Inspired by web.utils:unpatch utility function
+ *
+ * @param {Class} Class
+ * @param {string} patchName
+ */
+function unpatchClassMethods(Class, patchName) {
+ let metadata = classPatchMap.get(Class);
+ if (!metadata) {
+ return;
+ }
+ classPatchMap.delete(Class);
+
+ // reset to original
+ for (let k in metadata.origMethods) {
+ Class[k] = metadata.origMethods[k];
+ }
+
+ // apply other patches
+ for (let name of metadata.current) {
+ if (name !== patchName) {
+ patchClassMethods(Class, name, metadata.patches[name]);
+ }
+ }
+}
+
+/**
+ * @param {Class} Class
+ * @param {string} patchName
+ */
+function unpatchInstanceMethods(Class, patchName) {
+ return webUtilsUnpatch(Class, patchName);
+}
+
+//------------------------------------------------------------------------------
+// Export
+//------------------------------------------------------------------------------
+
+return {
+ nextTick,
+ patchClassMethods,
+ patchInstanceMethods,
+ unpatchClassMethods,
+ unpatchInstanceMethods,
+};
+
+});
diff --git a/addons/mail/static/src/variables.scss b/addons/mail/static/src/variables.scss
new file mode 100644
index 00000000000..8d8caab19d6
--- /dev/null
+++ b/addons/mail/static/src/variables.scss
@@ -0,0 +1,14 @@
+// -----------------------------------------------------------------------------
+// Variables
+// -----------------------------------------------------------------------------
+
+$o-mail-thread-window-zindex: $zindex-modal + 1 !default;
+
+$o-mail-chat-window-header-height: 36px !default;
+$o-mail-chat-window-header-height-mobile: 46px !default;
+$o-mail-discuss-sidebar-active-indicator-margin-right: 10px !default;
+$o-mail-discuss-sidebar-active-indicator-width: 3px !default;
+$o-mail-discuss-sidebar-scrollbar-width: 15px !default;
+
+$o-mail-moderation-accept-color: theme-color('success') !default;
+$o-mail-moderation-reject-color: theme-color('danger') !default;
diff --git a/addons/mail/static/src/widgets/common.xml b/addons/mail/static/src/widgets/common.xml
new file mode 100644
index 00000000000..9106f96f4ea
--- /dev/null
+++ b/addons/mail/static/src/widgets/common.xml
@@ -0,0 +1,25 @@
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
diff --git a/addons/mail/static/src/widgets/discuss/discuss.js b/addons/mail/static/src/widgets/discuss/discuss.js
new file mode 100644
index 00000000000..6f55834895f
--- /dev/null
+++ b/addons/mail/static/src/widgets/discuss/discuss.js
@@ -0,0 +1,383 @@
+odoo.define('mail/static/src/widgets/discuss/discuss.js', function (require) {
+'use strict';
+
+const components = {
+ Discuss: require('mail/static/src/components/discuss/discuss.js'),
+};
+const InvitePartnerDialog = require('mail/static/src/widgets/discuss_invite_partner_dialog/discuss_invite_partner_dialog.js');
+
+const AbstractAction = require('web.AbstractAction');
+const { action_registry, qweb } = require('web.core');
+
+const { Component } = owl;
+
+const DiscussWidget = AbstractAction.extend({
+ template: 'mail.widgets.Discuss',
+ hasControlPanel: true,
+ loadControlPanel: true,
+ withSearchBar: true,
+ searchMenuTypes: ['filter', 'favorite'],
+ /**
+ * @override {web.AbstractAction}
+ * @param {web.ActionManager} parent
+ * @param {Object} action
+ * @param {Object} [action.context]
+ * @param {string} [action.context.active_id]
+ * @param {Object} [action.params]
+ * @param {string} [action.params.default_active_id]
+ * @param {Object} [options={}]
+ */
+ init(parent, action, options={}) {
+ this._super(...arguments);
+
+ // render buttons in control panel
+ this.$buttons = $(qweb.render('mail.widgets.Discuss.DiscussControlButtons'));
+ this.$buttons.find('button').css({ display: 'inline-block' });
+ this.$buttons.on('click', '.o_invite', ev => this._onClickInvite(ev));
+ this.$buttons.on('click', '.o_widget_Discuss_controlPanelButtonMarkAllRead',
+ ev => this._onClickMarkAllAsRead(ev)
+ );
+ this.$buttons.on('click', '.o_mobile_new_channel', ev => this._onClickMobileNewChannel(ev));
+ this.$buttons.on('click', '.o_mobile_new_message', ev => this._onClickMobileNewMessage(ev));
+ this.$buttons.on('click', '.o_unstar_all', ev => this._onClickUnstarAll(ev));
+ this.$buttons.on('click', '.o_widget_Discuss_controlPanelButtonSelectAll', ev => this._onClickSelectAll(ev));
+ this.$buttons.on('click', '.o_widget_Discuss_controlPanelButtonUnselectAll', ev => this._onClickUnselectAll(ev));
+ this.$buttons.on('click', '.o_widget_Discuss_controlPanelButtonModeration.o-accept', ev => this._onClickModerationAccept(ev));
+ this.$buttons.on('click', '.o_widget_Discuss_controlPanelButtonModeration.o-discard', ev => this._onClickModerationDiscard(ev));
+ this.$buttons.on('click', '.o_widget_Discuss_controlPanelButtonModeration.o-reject', ev => this._onClickModerationReject(ev));
+
+ // control panel attributes
+ this.action = action;
+ this.actionManager = parent;
+ this.controlPanelModelConfig.modelName = 'mail.message';
+ this.discuss = undefined;
+ this.options = options;
+
+ this.component = undefined;
+
+ this._lastPushStateActiveThread = null;
+ },
+ /**
+ * @override
+ */
+ async willStart() {
+ await this._super(...arguments);
+ this.env = Component.env;
+ const initActiveId = this.options.active_id ||
+ (this.action.context && this.action.context.active_id) ||
+ (this.action.params && this.action.params.default_active_id) ||
+ 'mail.box_inbox';
+ this.discuss = this.env.messaging.discuss;
+ this.discuss.update({ initActiveId });
+ },
+ /**
+ * @override {web.AbstractAction}
+ */
+ destroy() {
+ if (this.component) {
+ this.component.destroy();
+ this.component = undefined;
+ }
+ if (this.$buttons) {
+ this.$buttons.off().remove();
+ }
+ this._super(...arguments);
+ },
+ /**
+ * @override {web.AbstractAction}
+ */
+ on_attach_callback() {
+ this._super(...arguments);
+ if (this.component) {
+ // prevent twice call to on_attach_callback (FIXME)
+ return;
+ }
+ const DiscussComponent = components.Discuss;
+ this.component = new DiscussComponent();
+ this._pushStateActionManagerEventListener = ev => {
+ ev.stopPropagation();
+ if (this._lastPushStateActiveThread === this.discuss.thread) {
+ return;
+ }
+ this._pushStateActionManager();
+ this._lastPushStateActiveThread = this.discuss.thread;
+ };
+ this._showRainbowManEventListener = ev => {
+ ev.stopPropagation();
+ this._showRainbowMan();
+ };
+ this._updateControlPanelEventListener = ev => {
+ ev.stopPropagation();
+ this._updateControlPanel();
+ };
+
+ this.el.addEventListener(
+ 'o-push-state-action-manager',
+ this._pushStateActionManagerEventListener
+ );
+ this.el.addEventListener(
+ 'o-show-rainbow-man',
+ this._showRainbowManEventListener
+ );
+ this.el.addEventListener(
+ 'o-update-control-panel',
+ this._updateControlPanelEventListener
+ );
+ return this.component.mount(this.el);
+ },
+ /**
+ * @override {web.AbstractAction}
+ */
+ on_detach_callback() {
+ this._super(...arguments);
+ if (this.component) {
+ this.component.destroy();
+ }
+ this.component = undefined;
+ this.el.removeEventListener(
+ 'o-push-state-action-manager',
+ this._pushStateActionManagerEventListener
+ );
+ this.el.removeEventListener(
+ 'o-show-rainbow-man',
+ this._showRainbowManEventListener
+ );
+ this.el.removeEventListener(
+ 'o-update-control-panel',
+ this._updateControlPanelEventListener
+ );
+ },
+
+ //--------------------------------------------------------------------------
+ // Private
+ //--------------------------------------------------------------------------
+
+ /**
+ * @private
+ */
+ _pushStateActionManager() {
+ this.actionManager.do_push_state({
+ action: this.action.id,
+ active_id: this.discuss.activeId,
+ });
+ },
+ /**
+ * @private
+ */
+ _showRainbowMan() {
+ this.trigger_up('show_effect', {
+ message: this.env._t("Congratulations, your inbox is empty!"),
+ type: 'rainbow_man',
+ });
+ },
+ /**
+ * @private
+ */
+ _updateControlPanel() {
+ // Invite
+ if (
+ this.discuss.thread &&
+ this.discuss.thread.channel_type === 'channel'
+ ) {
+ this.$buttons.find('.o_invite').removeClass('o_hidden');
+ } else {
+ this.$buttons.find('.o_invite').addClass('o_hidden');
+ }
+ // Mark All Read
+ if (
+ this.discuss.thread &&
+ this.discuss.thread === this.env.messaging.inbox
+ ) {
+ this.$buttons
+ .find('.o_widget_Discuss_controlPanelButtonMarkAllRead')
+ .removeClass('o_hidden')
+ .prop('disabled', this.discuss.threadViewer.messages.length === 0);
+ } else {
+ this.$buttons
+ .find('.o_widget_Discuss_controlPanelButtonMarkAllRead')
+ .addClass('o_hidden');
+ }
+ // Unstar All
+ if (
+ this.discuss.thread &&
+ this.discuss.thread === this.env.messaging.starred
+ ) {
+ this.$buttons
+ .find('.o_unstar_all')
+ .removeClass('o_hidden')
+ .prop('disabled', this.discuss.threadViewer.messages.length === 0);
+ } else {
+ this.$buttons
+ .find('.o_unstar_all')
+ .addClass('o_hidden');
+ }
+ // Mobile: Add channel
+ if (
+ this.env.messaging.device.isMobile &&
+ this.discuss.activeMobileNavbarTabId === 'channel'
+ ) {
+ this.$buttons
+ .find('.o_mobile_new_channel')
+ .removeClass('o_hidden');
+ } else {
+ this.$buttons
+ .find('.o_mobile_new_channel')
+ .addClass('o_hidden');
+ }
+ // Mobile: Add message
+ if (
+ this.env.messaging.device.isMobile &&
+ this.discuss.activeMobileNavbarTabId === 'chat'
+ ) {
+ this.$buttons
+ .find('.o_mobile_new_message')
+ .removeClass('o_hidden');
+ } else {
+ this.$buttons
+ .find('.o_mobile_new_message')
+ .addClass('o_hidden');
+ }
+ // Select All & Unselect All
+ const $selectAll = this.$buttons.find('.o_widget_Discuss_controlPanelButtonSelectAll');
+ const $unselectAll = this.$buttons.find('.o_widget_Discuss_controlPanelButtonUnselectAll');
+
+ if (
+ this.discuss.threadViewer.checkedMessages.length > 0 ||
+ this.discuss.threadViewer.uncheckedMessages.length > 0
+ ) {
+ $selectAll.removeClass('o_hidden');
+ $selectAll.toggleClass('disabled', this.discuss.threadViewer.uncheckedMessages.length === 0);
+ $unselectAll.removeClass('o_hidden');
+ $unselectAll.toggleClass('disabled', this.discuss.threadViewer.checkedMessages.length === 0);
+ } else {
+ $selectAll.addClass('o_hidden');
+ $selectAll.addClass('disabled');
+ $unselectAll.addClass('o_hidden');
+ $unselectAll.addClass('disabled');
+ }
+
+ // Moderation Actions
+ const $moderationButtons = this.$buttons.find('.o_widget_Discuss_controlPanelButtonModeration');
+ if (
+ this.discuss.threadViewer.checkedMessages.length > 0 &&
+ this.discuss.threadViewer.checkedMessages.filter(
+ message => !message.isModeratedByCurrentPartner
+ ).length === 0
+ ) {
+ $moderationButtons.removeClass('o_hidden');
+ } else {
+ $moderationButtons.addClass('o_hidden');
+ }
+
+ let title;
+ if (this.env.messaging.device.isMobile || !this.discuss.thread) {
+ title = this.env._t("Discuss");
+ } else {
+ const prefix =
+ this.discuss.thread.channel_type === 'channel' &&
+ this.discuss.thread.public !== 'private'
+ ? '#'
+ : '';
+ title = `${prefix}${this.discuss.thread.displayName}`;
+ }
+
+ this.updateControlPanel({
+ cp_content: {
+ $buttons: this.$buttons,
+ },
+ title,
+ });
+ },
+
+ //--------------------------------------------------------------------------
+ // Handlers
+ //--------------------------------------------------------------------------
+
+ /**
+ * @private
+ */
+ _onClickInvite() {
+ new InvitePartnerDialog(this, {
+ activeThreadLocalId: this.discuss.thread.localId,
+ messagingEnv: this.env,
+ }).open();
+ },
+ /**
+ * @private
+ */
+ _onClickMarkAllAsRead() {
+ this.env.models['mail.message'].markAllAsRead({ domain: this.domain });
+ },
+ /**
+ * @private
+ */
+ _onClickMobileNewChannel() {
+ this.discuss.update({ isAddingChannel: true });
+ },
+ /**
+ * @private
+ */
+ _onClickMobileNewMessage() {
+ this.discuss.update({ isAddingChat: true });
+ },
+ /**
+ * @private
+ */
+ _onClickModerationAccept() {
+ this.env.models['mail.message'].moderate(
+ this.discuss.threadViewer.checkedMessages,
+ 'accept'
+ );
+ },
+ /**
+ * @private
+ */
+ _onClickModerationDiscard() {
+ this.discuss.update({ hasModerationDiscardDialog: true });
+ },
+ /**
+ * @private
+ */
+ _onClickModerationReject() {
+ this.discuss.update({ hasModerationRejectDialog: true });
+ },
+ /**
+ * @private
+ */
+ _onClickSelectAll() {
+ this.env.models['mail.message'].checkAll(
+ this.discuss.thread,
+ this.discuss.threadStringifiedDomain
+ );
+ },
+ /**
+ * @private
+ */
+ _onClickUnselectAll() {
+ this.env.models['mail.message'].uncheckAll(
+ this.discuss.thread,
+ this.discuss.threadStringifiedDomain
+ );
+ },
+ /**
+ * @private
+ */
+ _onClickUnstarAll() {
+ this.env.models['mail.message'].unstarAll();
+ },
+ /**
+ * @private
+ * @param {Object} searchQuery
+ */
+ _onSearch: function (searchQuery) {
+ this.discuss.threadViewer.update({
+ stringifiedDomain: JSON.stringify(searchQuery.domain),
+ });
+ },
+});
+
+action_registry.add('mail.widgets.discuss', DiscussWidget);
+
+return DiscussWidget;
+
+});
diff --git a/addons/mail/static/src/widgets/discuss/discuss.scss b/addons/mail/static/src/widgets/discuss/discuss.scss
new file mode 100644
index 00000000000..2bed9b7c373
--- /dev/null
+++ b/addons/mail/static/src/widgets/discuss/discuss.scss
@@ -0,0 +1,36 @@
+// ------------------------------------------------------------------
+// Layout
+// ------------------------------------------------------------------
+
+.o_widget_Discuss {
+ display: flex;
+ flex: 0 0 100%;
+ flex-flow: column;
+ height: 100%;
+}
+
+// ------------------------------------------------------------------
+// Style
+// ------------------------------------------------------------------
+
+.o_widget_Discuss {
+
+ .o_control_panel {
+ border-bottom: 0; // cancel default border, so that we only apply it on top of discuss content
+ }
+
+ .o_Discuss_content {
+ border-top: 1px solid darken($o-control-panel-background-color, 20%);
+ }
+
+ .o_Discuss.o-mobile {
+
+ &:not(.o-adding-item) {
+ border-top: 1px solid darken($o-control-panel-background-color, 20%);
+ }
+
+ &.o-adding-item .o_Discuss_mobileAddItemHeader {
+ border-bottom: 1px solid darken($o-control-panel-background-color, 20%);
+ }
+ }
+}
diff --git a/addons/mail/static/src/widgets/discuss/discuss.xml b/addons/mail/static/src/widgets/discuss/discuss.xml
new file mode 100644
index 00000000000..f8e6de37b8e
--- /dev/null
+++ b/addons/mail/static/src/widgets/discuss/discuss.xml
@@ -0,0 +1,27 @@
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
diff --git a/addons/mail/static/src/widgets/discuss_invite_partner_dialog/discuss_invite_partner_dialog.js b/addons/mail/static/src/widgets/discuss_invite_partner_dialog/discuss_invite_partner_dialog.js
new file mode 100644
index 00000000000..e94d58e752c
--- /dev/null
+++ b/addons/mail/static/src/widgets/discuss_invite_partner_dialog/discuss_invite_partner_dialog.js
@@ -0,0 +1,125 @@
+odoo.define('mail/static/src/widgets/discuss_invite_partner_dialog/discuss_invite_partner_dialog.js', function (require) {
+'use strict';
+
+const core = require('web.core');
+const Dialog = require('web.Dialog');
+
+const _lt = core._lt;
+const QWeb = core.qweb;
+
+/**
+ * Widget : Invite People to Channel Dialog
+ *
+ * Popup containing a 'many2many_tags' custom input to select multiple partners.
+ * Searches user according to the input, and triggers event when selection is
+ * validated.
+ */
+const PartnerInviteDialog = Dialog.extend({
+ dialog_title: _lt("Invite people"),
+ template: 'mail.widgets.DiscussInvitePartnerDialog',
+ /**
+ * @override {web.Dialog}
+ * @param {mail/static/src/widgets/discuss/discuss.js} parent
+ * @param {Object} param1
+ * @param {string} param1.activeThreadLocalId
+ * @param {Object} param1.messagingEnv
+ * @param {Object} param1.messagingEnv.store
+ */
+ init(parent, { activeThreadLocalId, messagingEnv }) {
+ const env = messagingEnv;
+ const channel = env.models['mail.thread'].get(activeThreadLocalId);
+ this.channelId = channel.id;
+ this.env = env;
+ this._super(parent, {
+ title: _.str.sprintf(this.env._t("Invite people to #%s"), channel.displayName),
+ size: 'medium',
+ buttons: [{
+ text: this.env._t("Invite"),
+ close: true,
+ classes: 'btn-primary',
+ click: ev => this._invite(ev),
+ }],
+ });
+ },
+ /**
+ * @override {web.Dialog}
+ * @returns {Promise}
+ */
+ start() {
+ this.$input = this.$('.o_input');
+ this.$input.select2({
+ width: '100%',
+ allowClear: true,
+ multiple: true,
+ formatResult: item => {
+ let status;
+ // TODO FIXME fix this, why do we even have an old widget here
+ if (item.id === 'odoobot') {
+ status = 'bot';
+ } else {
+ const partner = this.env.models['mail.partner'].find(partner =>
+ partner.id === item.id
+ );
+ status = partner.im_status;
+ }
+ const $status = QWeb.render('mail.widgets.UserStatus', { status });
+ return $('').text(item.text).prepend($status);
+ },
+ query: query => {
+ this.env.models['mail.partner'].imSearch({
+ callback: partners => {
+ let results = partners.map(partner => {
+ return {
+ id: partner.id,
+ label: partner.nameOrDisplayName,
+ text: partner.nameOrDisplayName,
+ value: partner.nameOrDisplayName,
+ };
+ });
+ results = _.sortBy(results, 'label');
+ query.callback({ results });
+ },
+ keyword: query.term,
+ limit: 20,
+ });
+ }
+ });
+ return this._super(...arguments);
+ },
+
+ //--------------------------------------------------------------------------
+ // Private
+ //--------------------------------------------------------------------------
+
+ /**
+ * @private
+ */
+ async _invite() {
+ const data = this.$input.select2('data');
+ if (data.length === 0) {
+ return;
+ }
+ await this._rpc({
+ model: 'mail.channel',
+ method: 'channel_invite',
+ args: [this.channelId],
+ kwargs: {
+ partner_ids: _.pluck(data, 'id')
+ },
+ });
+ const names = _.escape(_.pluck(data, 'text').join(', '));
+ const notification = _.str.sprintf(
+ this.env._t("You added %s to the conversation."),
+ names
+ );
+ this.env.services['notification'].notify({
+ message: notification,
+ title: this.env._t("New people"),
+ type: 'warning',
+ });
+ },
+});
+
+return PartnerInviteDialog;
+
+});
diff --git a/addons/mail/static/src/widgets/discuss_invite_partner_dialog/discuss_invite_partner_dialog.xml b/addons/mail/static/src/widgets/discuss_invite_partner_dialog/discuss_invite_partner_dialog.xml
new file mode 100644
index 00000000000..82553476e21
--- /dev/null
+++ b/addons/mail/static/src/widgets/discuss_invite_partner_dialog/discuss_invite_partner_dialog.xml
@@ -0,0 +1,13 @@
+
+
+
+
+
+
+
+
+
+
+
diff --git a/addons/mail/static/src/widgets/form_renderer/form_renderer.js b/addons/mail/static/src/widgets/form_renderer/form_renderer.js
new file mode 100644
index 00000000000..df9c8b90d25
--- /dev/null
+++ b/addons/mail/static/src/widgets/form_renderer/form_renderer.js
@@ -0,0 +1,199 @@
+odoo.define('mail/static/src/widgets/form_renderer/form_renderer.js', function (require) {
+"use strict";
+
+const components = {
+ ChatterContainer: require('mail/static/src/components/chatter_container/chatter_container.js'),
+};
+
+const FormRenderer = require('web.FormRenderer');
+const { ComponentWrapper } = require('web.OwlCompatibility');
+
+class ChatterContainerWrapperComponent extends ComponentWrapper {}
+
+/**
+ * Include the FormRenderer to instantiate the chatter area containing (a
+ * subset of) the mail widgets (mail_thread, mail_followers and mail_activity).
+ */
+FormRenderer.include({
+ /**
+ * @override
+ */
+ init(parent, state, params) {
+ this._super(...arguments);
+ this.chatterFields = params.chatterFields;
+ this.mailFields = params.mailFields;
+ this._chatterContainerComponent = undefined;
+ /**
+ * The target of chatter, if chatter has to be appended to the DOM.
+ * This is set when arch contains `div.oe_chatter`.
+ */
+ this._chatterContainerTarget = undefined;
+ // Do not load chatter in form view dialogs
+ this._isFromFormViewDialog = params.isFromFormViewDialog;
+ },
+ /**
+ * @override
+ */
+ destroy() {
+ this._super(...arguments);
+ this._chatterContainerComponent = undefined;
+ this.off('o_attachments_changed', this);
+ this.off('o_chatter_rendered', this);
+ this.off('o_message_posted', this);
+ },
+
+ //--------------------------------------------------------------------------
+ // Private
+ //--------------------------------------------------------------------------
+
+ /**
+ * Returns whether the form renderer has a chatter to display or not.
+ * This is based on arch, which should have `div.oe_chatter`.
+ *
+ * @private
+ * @returns {boolean}
+ */
+ _hasChatter() {
+ return !!this._chatterContainerTarget;
+ },
+ /**
+ * @private
+ */
+ _makeChatterContainerComponent() {
+ const props = this._makeChatterContainerProps();
+ this._chatterContainerComponent = new ChatterContainerWrapperComponent(
+ this,
+ components.ChatterContainer,
+ props
+ );
+ // Not in custom_events because other modules may remove this listener
+ // while attempting to extend them.
+ this.on('o_chatter_rendered', this, ev => this._onChatterRendered(ev));
+ if (this.chatterFields.hasRecordReloadOnMessagePosted) {
+ this.on('o_message_posted', this, ev => this.trigger_up('reload'));
+ }
+ if (this.chatterFields.hasRecordReloadOnAttachmentsChanged) {
+ this.on('o_attachments_changed', this, ev => this.trigger_up('reload'));
+ }
+ },
+ /**
+ * @private
+ * @returns {Object}
+ */
+ _makeChatterContainerProps() {
+ const context = this.record ? this.record.getContext() : {};
+ const activityIds = this.state.data.activity_ids
+ ? this.state.data.activity_ids.res_ids
+ : [];
+ const followerIds = this.state.data.message_follower_ids
+ ? this.state.data.message_follower_ids.res_ids
+ : [];
+ const messageIds = this.state.data.message_ids
+ ? this.state.data.message_ids.res_ids
+ : [];
+ const threadAttachmentCount = this.state.data.message_attachment_count || 0;
+ return {
+ activityIds,
+ context,
+ followerIds,
+ hasActivities: this.chatterFields.hasActivityIds,
+ hasFollowers: this.chatterFields.hasMessageFollowerIds,
+ hasThread: this.chatterFields.hasMessageIds,
+ isAttachmentBoxVisible: this.chatterFields.isActivityBoxVisible,
+ messageIds,
+ threadAttachmentCount,
+ threadId: this.state.res_id,
+ threadModel: this.state.model,
+ };
+ },
+ /**
+ * Create the DOM element that will contain the chatter. This is made in
+ * a separate method so it can be overridden (like in mail_enterprise for
+ * example).
+ *
+ * @private
+ * @returns {jQuery.Element}
+ */
+ _makeChatterContainerTarget() {
+ const $el = $('');
+ this._chatterContainerTarget = $el[0];
+ return $el;
+ },
+ /**
+ * Mount the chatter
+ *
+ * Force re-mounting chatter component in DOM. This is necessary
+ * because each time `_renderView` is called, it puts old content
+ * in a fragment.
+ *
+ * @private
+ */
+ async _mountChatterContainerComponent() {
+ try {
+ await this._chatterContainerComponent.mount(this._chatterContainerTarget);
+ } catch (error) {
+ if (error.message !== "Mounting operation cancelled") {
+ throw error;
+ }
+ }
+ },
+ /**
+ * @override
+ */
+ _renderNode(node) {
+ if (
+ !this._isFromFormViewDialog &&
+ node.tag === 'div' &&
+ node.attrs.class === 'oe_chatter'
+ ) {
+ return this._makeChatterContainerTarget();
+ }
+ return this._super(...arguments);
+ },
+ /**
+ * Overrides the function to render the chatter once the form view is
+ * rendered.
+ *
+ * @override
+ */
+ async _renderView() {
+ await this._super(...arguments);
+ if (this._hasChatter()) {
+ if (!this._chatterContainerComponent) {
+ this._makeChatterContainerComponent();
+ } else {
+ await this._updateChatterContainerComponent();
+ }
+ await this._mountChatterContainerComponent();
+ }
+ },
+ /**
+ * @private
+ */
+ async _updateChatterContainerComponent() {
+ const props = this._makeChatterContainerProps();
+ try {
+ await this._chatterContainerComponent.update(props);
+ } catch (error) {
+ if (error.message !== "Mounting operation cancelled") {
+ throw error;
+ }
+ }
+ },
+
+ //--------------------------------------------------------------------------
+ // Handlers
+ //--------------------------------------------------------------------------
+
+ /**
+ * @abstract
+ * @private
+ * @param {OdooEvent} ev
+ * @param {Object} ev.data
+ * @param {mail.attachment[]} ev.data.attachments
+ * @param {mail.thread} ev.data.thread
+ */
+ _onChatterRendered(ev) {},
+});
+
+});
diff --git a/addons/mail/static/src/widgets/form_renderer/form_renderer.scss b/addons/mail/static/src/widgets/form_renderer/form_renderer.scss
new file mode 100644
index 00000000000..3092055b645
--- /dev/null
+++ b/addons/mail/static/src/widgets/form_renderer/form_renderer.scss
@@ -0,0 +1,17 @@
+// ------------------------------------------------------------------
+// Layout
+// ------------------------------------------------------------------
+
+.o_FormRenderer_chatterContainer {
+ display: flex;
+ flex: 1 1 auto;
+ margin: 0 auto;
+ max-width: $o-form-view-sheet-max-width;
+ padding: map-get($spacers, 3) map-get($spacers, 3) map-get($spacers, 5);
+ width: 100%;
+}
+
+// FIX to hide chatter in dialogs when they are opened from an action returned by python code
+.modal .modal-dialog .o_form_view .o_FormRenderer_chatterContainer {
+ display: none;
+}
diff --git a/addons/mail/static/src/widgets/form_renderer/form_renderer_tests.js b/addons/mail/static/src/widgets/form_renderer/form_renderer_tests.js
new file mode 100644
index 00000000000..7c3ecea8ab9
--- /dev/null
+++ b/addons/mail/static/src/widgets/form_renderer/form_renderer_tests.js
@@ -0,0 +1,455 @@
+odoo.define('mail/static/src/widgets/form_renderer/form_renderer_tests.js', function (require) {
+"use strict";
+
+const {
+ afterEach: utilsAfterEach,
+ afterNextRender,
+ beforeEach: utilsBeforeEach,
+ start,
+} = require('mail/static/src/utils/test_utils.js');
+
+const FormView = require('web.FormView');
+
+QUnit.module('mail', {}, function () {
+QUnit.module('widgets', {}, function () {
+QUnit.module('form_renderer', {}, function () {
+QUnit.module('form_renderer_tests.js', {
+ beforeEach() {
+ utilsBeforeEach(this);
+ this.data['res.partner'].fields = {
+ activity_ids: {
+ string: "Activities",
+ type: 'one2many',
+ relation: 'mail.activity',
+ },
+ message_attachment_count: {
+ string: 'Attachment count',
+ type: 'integer',
+ },
+ message_follower_ids: {
+ string: "Followers",
+ type: 'one2many',
+ relation: 'mail.followers',
+ },
+ message_ids: {
+ string: "Messages",
+ type: 'one2many',
+ relation: 'mail.message',
+ },
+ name: {
+ string: "Name",
+ type: 'char',
+ },
+ };
+ this.underscoreDebounce = _.debounce;
+ this.underscoreThrottle = _.throttle;
+ _.debounce = _.identity;
+ _.throttle = _.identity;
+ // FIXME archs could be removed once task-2248306 is done
+ this.createView = async (viewParams, ...args) => {
+ await afterNextRender(async () => {
+ const viewArgs = Object.assign({
+ archs: {
+ 'mail.activity,false,list': '',
+ 'mail.followers,false,list': '',
+ 'mail.message,false,list': '',
+ }},
+ viewParams,
+ );
+ const { widget } = await start(viewArgs, ...args);
+ this.view = widget;
+ });
+ };
+ },
+ afterEach() {
+ _.debounce = this.underscoreDebounce;
+ _.throttle = this.underscoreThrottle;
+ if (this.view) {
+ this.view.destroy();
+ }
+ utilsAfterEach(this);
+ },
+});
+
+QUnit.test('basic chatter rendering', async function (assert) {
+ assert.expect(1);
+
+ this.data['res.partner'].records = [{
+ id: 2,
+ display_name: "second partner",
+ }];
+ await this.createView({
+ data: this.data,
+ hasView: true,
+ // View params
+ View: FormView,
+ model: 'res.partner',
+ arch: `
+
+ `,
+ res_id: 2,
+ });
+
+ assert.strictEqual(
+ document.querySelectorAll(`.o_Chatter`).length,
+ 1,
+ "there should be a chatter"
+ );
+});
+
+QUnit.test('basic chatter rendering without followers', async function (assert) {
+ assert.expect(6);
+
+ this.data['res.partner'].records = [{
+ activity_ids: [],
+ id: 2,
+ display_name: "second partner",
+ message_ids: [],
+ message_follower_ids: [],
+ }];
+ await this.createView({
+ data: this.data,
+ hasView: true,
+ // View params
+ View: FormView,
+ model: 'res.partner',
+ arch: `
+
+ `,
+ res_id: 2,
+ });
+
+ assert.containsOnce(
+ document.body,
+ '.o_Chatter',
+ "there should be a chatter"
+ );
+ assert.containsOnce(
+ document.body,
+ '.o_ChatterTopbar',
+ "there should be a chatter topbar"
+ );
+ assert.containsOnce(
+ document.body,
+ '.o_ChatterTopbar_buttonAttachments',
+ "there should be an attachment button"
+ );
+ assert.containsOnce(
+ document.body,
+ '.o_ChatterTopbar_buttonScheduleActivity',
+ "there should be a schedule activity button"
+ );
+ assert.containsNone(
+ document.body,
+ '.o_FollowerListMenu',
+ "there should be no followers menu"
+ );
+ assert.containsOnce(
+ document.body,
+ '.o_Chatter_thread',
+ "there should be a thread"
+ );
+});
+
+QUnit.test('basic chatter rendering without activities', async function (assert) {
+ assert.expect(6);
+
+ this.data['res.partner'].records = [{
+ activity_ids: [],
+ id: 2,
+ display_name: "second partner",
+ message_ids: [],
+ message_follower_ids: [],
+ }];
+ await this.createView({
+ data: this.data,
+ hasView: true,
+ // View params
+ View: FormView,
+ model: 'res.partner',
+ arch: `
+
+ `,
+ res_id: 2,
+ });
+
+ assert.containsOnce(
+ document.body,
+ '.o_Chatter',
+ "there should be a chatter"
+ );
+ assert.containsOnce(
+ document.body,
+ '.o_ChatterTopbar',
+ "there should be a chatter topbar"
+ );
+ assert.containsOnce(
+ document.body,
+ '.o_ChatterTopbar_buttonAttachments',
+ "there should be an attachment button"
+ );
+ assert.containsNone(
+ document.body,
+ '.o_ChatterTopbar_buttonScheduleActivity',
+ "there should be a schedule activity button"
+ );
+ assert.containsOnce(
+ document.body,
+ '.o_FollowerListMenu',
+ "there should be a followers menu"
+ );
+ assert.containsOnce(
+ document.body,
+ '.o_Chatter_thread',
+ "there should be a thread"
+ );
+});
+
+QUnit.test('basic chatter rendering without messages', async function (assert) {
+ assert.expect(6);
+
+ this.data['res.partner'].records = [{
+ activity_ids: [],
+ id: 2,
+ display_name: "second partner",
+ message_ids: [],
+ message_follower_ids: [],
+ }];
+ await this.createView({
+ data: this.data,
+ hasView: true,
+ // View params
+ View: FormView,
+ model: 'res.partner',
+ arch: `
+
+ `,
+ res_id: 2,
+ });
+
+ assert.containsOnce(
+ document.body,
+ '.o_Chatter',
+ "there should be a chatter"
+ );
+ assert.containsOnce(
+ document.body,
+ '.o_ChatterTopbar',
+ "there should be a chatter topbar"
+ );
+ assert.containsOnce(
+ document.body,
+ '.o_ChatterTopbar_buttonAttachments',
+ "there should be an attachment button"
+ );
+ assert.containsOnce(
+ document.body,
+ '.o_ChatterTopbar_buttonScheduleActivity',
+ "there should be a schedule activity button"
+ );
+ assert.containsOnce(
+ document.body,
+ '.o_FollowerListMenu',
+ "there should be a followers menu"
+ );
+ assert.containsNone(
+ document.body,
+ '.o_Chatter_thread',
+ "there should be a thread"
+ );
+});
+
+QUnit.test('chatter updating', async function (assert) {
+ assert.expect(3);
+
+ this.data['ir.attachment'].records = [{
+ id: 1,
+ mimetype: 'image/png',
+ name: 'filename.jpg',
+ res_id: 7,
+ res_model: 'partner',
+ type: 'url',
+ }, {
+ id: 2,
+ mimetype: "application/x-msdos-program",
+ name: "file2.txt",
+ res_id: 7,
+ res_model: 'partner',
+ type: 'binary',
+ }, {
+ id: 3,
+ mimetype: "application/x-msdos-program",
+ name: "file3.txt",
+ res_id: 5,
+ res_model: 'partner',
+ type: 'binary',
+ }];
+ this.data['mail.message'].records = [{
+ id: 1000,
+ body: "test 1
",
+ author_id: [100, "Someone"],
+ model: 'res.partner',
+ res_id: 2,
+ moderation_status: 'accepted',
+ }];
+ this.data['res.partner'].records = [{
+ id: 1,
+ display_name: "first partner",
+ message_ids: [],
+ }, {
+ id: 2,
+ display_name: "second partner",
+ message_ids: [],
+ }];
+ await this.createView({
+ data: this.data,
+ hasView: true,
+ // View params
+ View: FormView,
+ model: 'res.partner',
+ res_id: 1,
+ viewOptions: {
+ ids: [1, 2],
+ index: 0,
+ },
+ arch: `
+
+ `,
+ });
+ assert.containsOnce(
+ document.body,
+ '.o_Chatter',
+ "there should be a chatter"
+ );
+ assert.containsNone(
+ document.body,
+ '.o_Message',
+ "there should be no message"
+ );
+
+ await afterNextRender(() => {
+ document.querySelector('.o_pager_next').click();
+ });
+ assert.containsOnce(
+ document.body,
+ '.o_Message',
+ "there should be a message"
+ );
+});
+
+QUnit.test('chatter should become enabled when creation done', async function (assert) {
+ assert.expect(10);
+
+ await this.createView({
+ data: this.data,
+ hasView: true,
+ // View params
+ View: FormView,
+ model: 'res.partner',
+ arch: `
+
+ `,
+ viewOptions: {
+ mode: 'edit',
+ },
+ });
+ assert.containsOnce(
+ document.body,
+ '.o_Chatter',
+ "there should be a chatter"
+ );
+ assert.containsOnce(
+ document.body,
+ '.o_ChatterTopbar_buttonSendMessage',
+ "there should be a send message button"
+ );
+ assert.containsOnce(
+ document.body,
+ '.o_ChatterTopbar_buttonLogNote',
+ "there should be a log note button"
+ );
+ assert.containsOnce(
+ document.body,
+ '.o_ChatterTopbar_buttonLogNote',
+ "there should be an attachments button"
+ );
+ assert.ok(
+ document.querySelector(`.o_ChatterTopbar_buttonSendMessage`).disabled,
+ "send message button should be disabled"
+ );
+ assert.ok(
+ document.querySelector(`.o_ChatterTopbar_buttonLogNote`).disabled,
+ "log note button should be disabled"
+ );
+ assert.ok(
+ document.querySelector(`.o_ChatterTopbar_buttonAttachments`).disabled,
+ "attachments button should be disabled"
+ );
+
+ document.querySelectorAll('.o_field_char')[0].focus();
+ document.execCommand('insertText', false, "hello");
+ await afterNextRender(() => {
+ document.querySelector('.o_form_button_save').click();
+ });
+ assert.notOk(
+ document.querySelector(`.o_ChatterTopbar_buttonSendMessage`).disabled,
+ "send message button should now be enabled"
+ );
+ assert.notOk(
+ document.querySelector(`.o_ChatterTopbar_buttonLogNote`).disabled,
+ "log note button should now be enabled"
+ );
+ assert.notOk(
+ document.querySelector(`.o_ChatterTopbar_buttonAttachments`).disabled,
+ "attachments button should now be enabled"
+ );
+});
+
+});
+});
+});
+
+});
diff --git a/addons/mail/static/src/widgets/messaging_menu/messaging_menu.js b/addons/mail/static/src/widgets/messaging_menu/messaging_menu.js
new file mode 100644
index 00000000000..edfef630605
--- /dev/null
+++ b/addons/mail/static/src/widgets/messaging_menu/messaging_menu.js
@@ -0,0 +1,56 @@
+odoo.define('mail/static/src/widgets/messaging_menu/messaging_menu.js', function (require) {
+'use strict';
+
+const components = {
+ MessagingMenu: require('mail/static/src/components/messaging_menu/messaging_menu.js'),
+};
+
+const SystrayMenu = require('web.SystrayMenu');
+const Widget = require('web.Widget');
+
+/**
+ * Odoo Widget, necessary to instantiate component.
+ */
+const MessagingMenu = Widget.extend({
+ template: 'mail.widgets.MessagingMenu',
+ /**
+ * @override
+ */
+ init() {
+ this._super(...arguments);
+ this.component = undefined;
+ },
+ /**
+ * @override
+ */
+ destroy() {
+ if (this.component) {
+ this.component.destroy();
+ }
+ this._super(...arguments);
+ },
+ async on_attach_callback() {
+ const MessagingMenuComponent = components.MessagingMenu;
+ this.component = new MessagingMenuComponent(null);
+ await this.component.mount(this.el);
+ // unwrap
+ this.el.parentNode.insertBefore(this.component.el, this.el);
+ this.el.parentNode.removeChild(this.el);
+ },
+});
+
+// Systray menu items display order matches order in the list
+// lower index comes first, and display is from right to left.
+// For messagin menu, it should come before activity menu, if any
+// otherwise, it is the next systray item.
+const activityMenuIndex = SystrayMenu.Items.findIndex(SystrayMenuItem =>
+ SystrayMenuItem.prototype.name === 'activity_menu');
+if (activityMenuIndex > 0) {
+ SystrayMenu.Items.splice(activityMenuIndex, 0, MessagingMenu);
+} else {
+ SystrayMenu.Items.push(MessagingMenu);
+}
+
+return MessagingMenu;
+
+});
diff --git a/addons/mail/static/src/widgets/messaging_menu/messaging_menu.xml b/addons/mail/static/src/widgets/messaging_menu/messaging_menu.xml
new file mode 100644
index 00000000000..308c1f31134
--- /dev/null
+++ b/addons/mail/static/src/widgets/messaging_menu/messaging_menu.xml
@@ -0,0 +1,8 @@
+
+
+
+
+
+
+
+
diff --git a/addons/mail/static/src/xml/abstract_thread_window.xml b/addons/mail/static/src/xml/abstract_thread_window.xml
deleted file mode 100644
index ae29a21b5ba..00000000000
--- a/addons/mail/static/src/xml/abstract_thread_window.xml
+++ /dev/null
@@ -1,85 +0,0 @@
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
![]()
-
-
-
-
- ()
-
-
-
-
-
-
-
-
diff --git a/addons/mail/static/src/xml/chatter.xml b/addons/mail/static/src/xml/chatter.xml
deleted file mode 100644
index 1f36287cdf8..00000000000
--- a/addons/mail/static/src/xml/chatter.xml
+++ /dev/null
@@ -1,152 +0,0 @@
-
-
-
-
-
-
-
-
- Attachments
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
- /web/binary/upload_attachment
-
-
-
- Add Attachments
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
- To:
- Followers of
-
-
- ""
-
-
- this document
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
- Attachments
-
-
-
-
-
-
-
-
-
diff --git a/addons/mail/static/src/xml/composer.xml b/addons/mail/static/src/xml/composer.xml
index 071a8023e83..49c0a3bf9b4 100644
--- a/addons/mail/static/src/xml/composer.xml
+++ b/addons/mail/static/src/xml/composer.xml
@@ -1,54 +1,7 @@
-
-
-
Drag Files Here
-
-
-
-
-
-
-
-
-
Drag Files Here
-
-
-
-
-
-
-
-
-
-
-
-
-
- /web/binary/upload_attachment
-
-
-
-
-
-
-
-
-
-
-
-
-
+
@@ -56,48 +9,6 @@
-
-
-
-
-
-
-
-
-
-
-
-
- ()
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
- /
-
-
-
-
diff --git a/addons/mail/static/src/xml/discuss.xml b/addons/mail/static/src/xml/discuss.xml
deleted file mode 100644
index 98788e303ff..00000000000
--- a/addons/mail/static/src/xml/discuss.xml
+++ /dev/null
@@ -1,352 +0,0 @@
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
- Inbox
-
-
-
-
- Starred
-
-
-
-
- History
-
-
- Moderation Queue
-
-
-
-
-
-
-
-
-
- Channels
-
-
-
-
- Add a channel
-
-
-
-
- Direct Messages
-
-
-
-
- User name
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
- #
-
-
-
-
-
-
-
-
-
-
-
-
- #
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
- Message sent in "".
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
- Inbox
-
-
-
- Chat
-
-
-
- Channels
-
-
-
-
-
-
-
-
-
- #
- Add a channel
- Open chat
-
-
-
-
-
-
-
-
-
-
-
-
-
![Preview]()
-
-
-
-
-
-
-
-
-
-
- ()
-
-
-
-
-
-
-
- You:
-
-
- :
-
-
-
-
-
-
-
-
-
-
diff --git a/addons/mail/static/src/xml/followers.xml b/addons/mail/static/src/xml/followers.xml
deleted file mode 100644
index d1f6ebd63a4..00000000000
--- a/addons/mail/static/src/xml/followers.xml
+++ /dev/null
@@ -1,95 +0,0 @@
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
- Add Followers
- Add Channels
-
-
-
-
-
-
-
-
-
-
- Be careful with channels following internal notifications
-
-
-
-
diff --git a/addons/mail/static/src/xml/out_of_office.xml b/addons/mail/static/src/xml/out_of_office.xml
deleted file mode 100644
index 19fb2c341c6..00000000000
--- a/addons/mail/static/src/xml/out_of_office.xml
+++ /dev/null
@@ -1,9 +0,0 @@
-
-
-
-
-
diff --git a/addons/mail/static/src/xml/systray.xml b/addons/mail/static/src/xml/systray.xml
index b10ef51a429..b9612e13a27 100644
--- a/addons/mail/static/src/xml/systray.xml
+++ b/addons/mail/static/src/xml/systray.xml
@@ -1,64 +1,6 @@
-
-
-
-
-
-
-
-
-
-
-
-
-
-
- No conversation yet...
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
No matches found
-
No message matches your search. Try to change your search filters.
-
-
-
-
-
-
- Please wait...
-
-
@@ -126,513 +99,4 @@