summaryrefslogtreecommitdiff
path: root/addons/mail/static/src/components
diff options
context:
space:
mode:
authorSébastien Theys <seb@odoo.com>2020-06-15 18:12:21 +0000
committerSébastien Theys <seb@odoo.com>2020-06-15 18:12:21 +0000
commit3fea5b2136627c1d7e83b2a5e111952fe3eb10a1 (patch)
tree5eefad07972a87ce4689b83260f981bc87aadad4 /addons/mail/static/src/components
parent0a82848606151ef60eb6523e9c4c161171f214e7 (diff)
[REF] mail, *: refactor messaging with OWL
This commit is a significant rewriting of client-side discuss, chatter, chat window, and messaging menu using OWL. The behavior should be broadly the same, with some slight functional changes here and there. From a technical standpoint, the code of messaging is mainly organized in 2 main groups of modules: - models, which are logical entities that depict the client-side state of messaging as a whole. - components, which are in charge of displaying information from models. This refactoring also introduces new JS guidelines regarding folder structure (/static) and naming rules for JS modules. Community PR: https://github.com/odoo/odoo/pull/39023 Enterprise PR: https://github.com/odoo/enterprise/pull/6249 Task-1914207 This PR is a collaborative work by Alexandre, Julien, Sébastien and Xavier, with the precious help of Lucas to speed it up towards the end. closes odoo/odoo#39023 Related: odoo/enterprise#6249 Signed-off-by: Alexandre Kühn (aku) <aku@odoo.com> Co-authored-by: Alexandre Kühn <aku@odoo.com> Co-authored-by: Julien Giannone <jgi@odoo.com> Co-authored-by: Lucas Perais <lpe@odoo.com> Co-authored-by: Sébastien Theys <seb@odoo.com> Co-authored-by: Xavier Dubuc <xdu@odoo.com>
Diffstat (limited to 'addons/mail/static/src/components')
-rw-r--r--addons/mail/static/src/components/activity/activity.js177
-rw-r--r--addons/mail/static/src/components/activity/activity.scss174
-rw-r--r--addons/mail/static/src/components/activity/activity.xml149
-rw-r--r--addons/mail/static/src/components/activity/activity_tests.js981
-rw-r--r--addons/mail/static/src/components/activity_box/activity_box.js60
-rw-r--r--addons/mail/static/src/components/activity_box/activity_box.scss41
-rw-r--r--addons/mail/static/src/components/activity_box/activity_box.xml45
-rw-r--r--addons/mail/static/src/components/activity_mark_done_popover/activity_mark_done_popover.js83
-rw-r--r--addons/mail/static/src/components/activity_mark_done_popover/activity_mark_done_popover.scss22
-rw-r--r--addons/mail/static/src/components/activity_mark_done_popover/activity_mark_done_popover.xml23
-rw-r--r--addons/mail/static/src/components/activity_mark_done_popover/activity_mark_done_popover_tests.js282
-rw-r--r--addons/mail/static/src/components/attachment/attachment.js162
-rw-r--r--addons/mail/static/src/components/attachment/attachment.scss196
-rw-r--r--addons/mail/static/src/components/attachment/attachment.xml109
-rw-r--r--addons/mail/static/src/components/attachment/attachment_tests.js545
-rw-r--r--addons/mail/static/src/components/attachment_box/attachment_box.js118
-rw-r--r--addons/mail/static/src/components/attachment_box/attachment_box.scss46
-rw-r--r--addons/mail/static/src/components/attachment_box/attachment_box.xml50
-rw-r--r--addons/mail/static/src/components/attachment_box/attachment_box_tests.js228
-rw-r--r--addons/mail/static/src/components/attachment_list/attachment_list.js54
-rw-r--r--addons/mail/static/src/components/attachment_list/attachment_list.scss13
-rw-r--r--addons/mail/static/src/components/attachment_list/attachment_list.xml22
-rw-r--r--addons/mail/static/src/components/attachment_viewer/attachment_viewer.js593
-rw-r--r--addons/mail/static/src/components/attachment_viewer/attachment_viewer.scss198
-rw-r--r--addons/mail/static/src/components/attachment_viewer/attachment_viewer.xml93
-rw-r--r--addons/mail/static/src/components/autocomplete_input/autocomplete_input.js161
-rw-r--r--addons/mail/static/src/components/autocomplete_input/autocomplete_input.xml8
-rw-r--r--addons/mail/static/src/components/chat_window/chat_window.js352
-rw-r--r--addons/mail/static/src/components/chat_window/chat_window.scss101
-rw-r--r--addons/mail/static/src/components/chat_window/chat_window.xml56
-rw-r--r--addons/mail/static/src/components/chat_window_header/chat_window_header.js107
-rw-r--r--addons/mail/static/src/components/chat_window_header/chat_window_header.scss95
-rw-r--r--addons/mail/static/src/components/chat_window_header/chat_window_header.xml53
-rw-r--r--addons/mail/static/src/components/chat_window_hidden_menu/chat_window_hidden_menu.js142
-rw-r--r--addons/mail/static/src/components/chat_window_hidden_menu/chat_window_hidden_menu.scss90
-rw-r--r--addons/mail/static/src/components/chat_window_hidden_menu/chat_window_hidden_menu.xml31
-rw-r--r--addons/mail/static/src/components/chat_window_manager/chat_window_manager.js37
-rw-r--r--addons/mail/static/src/components/chat_window_manager/chat_window_manager.scss16
-rw-r--r--addons/mail/static/src/components/chat_window_manager/chat_window_manager.xml21
-rw-r--r--addons/mail/static/src/components/chat_window_manager/chat_window_manager_tests.js1243
-rw-r--r--addons/mail/static/src/components/chatter/chatter.js103
-rw-r--r--addons/mail/static/src/components/chatter/chatter.scss37
-rw-r--r--addons/mail/static/src/components/chatter/chatter.xml53
-rw-r--r--addons/mail/static/src/components/chatter/chatter_tests.js337
-rw-r--r--addons/mail/static/src/components/chatter_container/chatter_container.js80
-rw-r--r--addons/mail/static/src/components/chatter_container/chatter_container.scss13
-rw-r--r--addons/mail/static/src/components/chatter_container/chatter_container.xml15
-rw-r--r--addons/mail/static/src/components/chatter_topbar/chatter_topbar.js128
-rw-r--r--addons/mail/static/src/components/chatter_topbar/chatter_topbar.scss100
-rw-r--r--addons/mail/static/src/components/chatter_topbar/chatter_topbar.xml72
-rw-r--r--addons/mail/static/src/components/chatter_topbar/chatter_topbar_tests.js590
-rw-r--r--addons/mail/static/src/components/composer/composer.js393
-rw-r--r--addons/mail/static/src/components/composer/composer.scss278
-rw-r--r--addons/mail/static/src/components/composer/composer.xml154
-rw-r--r--addons/mail/static/src/components/composer/composer_tests.js866
-rw-r--r--addons/mail/static/src/components/composer_text_input/composer_text_input.js334
-rw-r--r--addons/mail/static/src/components/composer_text_input/composer_text_input.scss25
-rw-r--r--addons/mail/static/src/components/composer_text_input/composer_text_input.xml44
-rw-r--r--addons/mail/static/src/components/dialog/dialog.js105
-rw-r--r--addons/mail/static/src/components/dialog/dialog.scss23
-rw-r--r--addons/mail/static/src/components/dialog/dialog.xml22
-rw-r--r--addons/mail/static/src/components/dialog_manager/dialog_manager.js58
-rw-r--r--addons/mail/static/src/components/dialog_manager/dialog_manager.xml15
-rw-r--r--addons/mail/static/src/components/discuss/discuss.js277
-rw-r--r--addons/mail/static/src/components/discuss/discuss.scss114
-rw-r--r--addons/mail/static/src/components/discuss/discuss.xml106
-rw-r--r--addons/mail/static/src/components/discuss/discuss_tests.js5523
-rw-r--r--addons/mail/static/src/components/discuss_mobile_mailbox_selection/discuss_mobile_mailbox_selection.js89
-rw-r--r--addons/mail/static/src/components/discuss_mobile_mailbox_selection/discuss_mobile_mailbox_selection.scss26
-rw-r--r--addons/mail/static/src/components/discuss_mobile_mailbox_selection/discuss_mobile_mailbox_selection.xml20
-rw-r--r--addons/mail/static/src/components/discuss_sidebar/discuss_sidebar.js307
-rw-r--r--addons/mail/static/src/components/discuss_sidebar/discuss_sidebar.scss110
-rw-r--r--addons/mail/static/src/components/discuss_sidebar/discuss_sidebar.xml80
-rw-r--r--addons/mail/static/src/components/discuss_sidebar_item/discuss_sidebar_item.js203
-rw-r--r--addons/mail/static/src/components/discuss_sidebar_item/discuss_sidebar_item.scss107
-rw-r--r--addons/mail/static/src/components/discuss_sidebar_item/discuss_sidebar_item.xml60
-rw-r--r--addons/mail/static/src/components/drop_zone/drop_zone.js136
-rw-r--r--addons/mail/static/src/components/drop_zone/drop_zone.scss29
-rw-r--r--addons/mail/static/src/components/drop_zone/drop_zone.xml12
-rw-r--r--addons/mail/static/src/components/editable_text/editable_text.js82
-rw-r--r--addons/mail/static/src/components/editable_text/editable_text.xml8
-rw-r--r--addons/mail/static/src/components/emojis_popover/emojis_popover.js42
-rw-r--r--addons/mail/static/src/components/emojis_popover/emojis_popover.scss21
-rw-r--r--addons/mail/static/src/components/emojis_popover/emojis_popover.xml14
-rw-r--r--addons/mail/static/src/components/file_uploader/file_uploader.js206
-rw-r--r--addons/mail/static/src/components/file_uploader/file_uploader.scss3
-rw-r--r--addons/mail/static/src/components/file_uploader/file_uploader.xml10
-rw-r--r--addons/mail/static/src/components/file_uploader/file_uploader_tests.js104
-rw-r--r--addons/mail/static/src/components/follow_button/follow_button.js91
-rw-r--r--addons/mail/static/src/components/follow_button/follow_button.scss19
-rw-r--r--addons/mail/static/src/components/follow_button/follow_button.xml24
-rw-r--r--addons/mail/static/src/components/follow_button/follow_button_tests.js331
-rw-r--r--addons/mail/static/src/components/follower/follower.js81
-rw-r--r--addons/mail/static/src/components/follower/follower.scss55
-rw-r--r--addons/mail/static/src/components/follower/follower.xml23
-rw-r--r--addons/mail/static/src/components/follower/follower_tests.js354
-rw-r--r--addons/mail/static/src/components/follower_list_menu/follower_list_menu.js152
-rw-r--r--addons/mail/static/src/components/follower_list_menu/follower_list_menu.scss19
-rw-r--r--addons/mail/static/src/components/follower_list_menu/follower_list_menu.xml36
-rw-r--r--addons/mail/static/src/components/follower_list_menu/follower_list_menu_tests.js465
-rw-r--r--addons/mail/static/src/components/follower_subtype/follower_subtype.js69
-rw-r--r--addons/mail/static/src/components/follower_subtype/follower_subtype.scss27
-rw-r--r--addons/mail/static/src/components/follower_subtype/follower_subtype.xml13
-rw-r--r--addons/mail/static/src/components/follower_subtype/follower_subtype_tests.js241
-rw-r--r--addons/mail/static/src/components/follower_subtype_list/follower_subtype_list.js87
-rw-r--r--addons/mail/static/src/components/follower_subtype_list/follower_subtype_list.scss8
-rw-r--r--addons/mail/static/src/components/follower_subtype_list/follower_subtype_list.xml38
-rw-r--r--addons/mail/static/src/components/mail_template/mail_template.js79
-rw-r--r--addons/mail/static/src/components/mail_template/mail_template.scss27
-rw-r--r--addons/mail/static/src/components/mail_template/mail_template.xml29
-rw-r--r--addons/mail/static/src/components/message/message.js621
-rw-r--r--addons/mail/static/src/components/message/message.scss354
-rw-r--r--addons/mail/static/src/components/message/message.xml220
-rw-r--r--addons/mail/static/src/components/message/message_tests.js692
-rw-r--r--addons/mail/static/src/components/message_author_prefix/message_author_prefix.js65
-rw-r--r--addons/mail/static/src/components/message_author_prefix/message_author_prefix.scss11
-rw-r--r--addons/mail/static/src/components/message_author_prefix/message_author_prefix.xml17
-rw-r--r--addons/mail/static/src/components/message_list/message_list.js531
-rw-r--r--addons/mail/static/src/components/message_list/message_list.scss135
-rw-r--r--addons/mail/static/src/components/message_list/message_list.xml107
-rw-r--r--addons/mail/static/src/components/message_seen_indicator/message_seen_indicator.js130
-rw-r--r--addons/mail/static/src/components/message_seen_indicator/message_seen_indicator.scss39
-rw-r--r--addons/mail/static/src/components/message_seen_indicator/message_seen_indicator.xml16
-rw-r--r--addons/mail/static/src/components/message_seen_indicator/message_seen_indicator_tests.js293
-rw-r--r--addons/mail/static/src/components/messaging_menu/messaging_menu.js266
-rw-r--r--addons/mail/static/src/components/messaging_menu/messaging_menu.scss133
-rw-r--r--addons/mail/static/src/components/messaging_menu/messaging_menu.xml89
-rw-r--r--addons/mail/static/src/components/messaging_menu/messaging_menu_tests.js849
-rw-r--r--addons/mail/static/src/components/mobile_messaging_navbar/mobile_messaging_navbar.js50
-rw-r--r--addons/mail/static/src/components/mobile_messaging_navbar/mobile_messaging_navbar.scss43
-rw-r--r--addons/mail/static/src/components/mobile_messaging_navbar/mobile_messaging_navbar.xml17
-rw-r--r--addons/mail/static/src/components/moderation_ban_dialog/moderation_ban_dialog.js79
-rw-r--r--addons/mail/static/src/components/moderation_ban_dialog/moderation_ban_dialog.xml26
-rw-r--r--addons/mail/static/src/components/moderation_discard_dialog/moderation_discard_dialog.js94
-rw-r--r--addons/mail/static/src/components/moderation_discard_dialog/moderation_discard_dialog.xml16
-rw-r--r--addons/mail/static/src/components/moderation_reject_dialog/moderation_reject_dialog.js89
-rw-r--r--addons/mail/static/src/components/moderation_reject_dialog/moderation_reject_dialog.xml16
-rw-r--r--addons/mail/static/src/components/notification_group/notification_group.js85
-rw-r--r--addons/mail/static/src/components/notification_group/notification_group.scss89
-rw-r--r--addons/mail/static/src/components/notification_group/notification_group.xml39
-rw-r--r--addons/mail/static/src/components/notification_list/notification_list.js216
-rw-r--r--addons/mail/static/src/components/notification_list/notification_list.scss37
-rw-r--r--addons/mail/static/src/components/notification_list/notification_list.xml41
-rw-r--r--addons/mail/static/src/components/notification_list/notification_list_item.scss174
-rw-r--r--addons/mail/static/src/components/notification_list/notification_list_notification_group_tests.js563
-rw-r--r--addons/mail/static/src/components/notification_list/notification_list_tests.js210
-rw-r--r--addons/mail/static/src/components/notification_popover/notification_popover.js89
-rw-r--r--addons/mail/static/src/components/notification_popover/notification_popover.scss7
-rw-r--r--addons/mail/static/src/components/notification_popover/notification_popover.xml17
-rw-r--r--addons/mail/static/src/components/partner_im_status_icon/partner_im_status_icon.js52
-rw-r--r--addons/mail/static/src/components/partner_im_status_icon/partner_im_status_icon.scss53
-rw-r--r--addons/mail/static/src/components/partner_im_status_icon/partner_im_status_icon.xml36
-rw-r--r--addons/mail/static/src/components/partner_im_status_icon/partner_im_status_icon_tests.js152
-rw-r--r--addons/mail/static/src/components/partner_mention_suggestion/partner_mention_suggestion.js79
-rw-r--r--addons/mail/static/src/components/partner_mention_suggestion/partner_mention_suggestion.scss26
-rw-r--r--addons/mail/static/src/components/partner_mention_suggestion/partner_mention_suggestion.xml19
-rw-r--r--addons/mail/static/src/components/partner_mention_suggestion/partner_mention_suggestion_tests.js141
-rw-r--r--addons/mail/static/src/components/thread_icon/thread_icon.js54
-rw-r--r--addons/mail/static/src/components/thread_icon/thread_icon.scss26
-rw-r--r--addons/mail/static/src/components/thread_icon/thread_icon.xml58
-rw-r--r--addons/mail/static/src/components/thread_icon/thread_icon_tests.js146
-rw-r--r--addons/mail/static/src/components/thread_needaction_preview/thread_needaction_preview.js131
-rw-r--r--addons/mail/static/src/components/thread_needaction_preview/thread_needaction_preview.scss104
-rw-r--r--addons/mail/static/src/components/thread_needaction_preview/thread_needaction_preview.xml58
-rw-r--r--addons/mail/static/src/components/thread_preview/thread_preview.js124
-rw-r--r--addons/mail/static/src/components/thread_preview/thread_preview.scss113
-rw-r--r--addons/mail/static/src/components/thread_preview/thread_preview.xml63
-rw-r--r--addons/mail/static/src/components/thread_textual_typing_status/thread_textual_typing_status.js49
-rw-r--r--addons/mail/static/src/components/thread_textual_typing_status/thread_textual_typing_status.scss12
-rw-r--r--addons/mail/static/src/components/thread_textual_typing_status/thread_textual_typing_status.xml14
-rw-r--r--addons/mail/static/src/components/thread_textual_typing_status/thread_textual_typing_status_tests.js480
-rw-r--r--addons/mail/static/src/components/thread_typing_icon/thread_typing_icon.js32
-rw-r--r--addons/mail/static/src/components/thread_typing_icon/thread_typing_icon.scss108
-rw-r--r--addons/mail/static/src/components/thread_typing_icon/thread_typing_icon.xml29
-rw-r--r--addons/mail/static/src/components/thread_viewer/thread_viewer.js199
-rw-r--r--addons/mail/static/src/components/thread_viewer/thread_viewer.scss38
-rw-r--r--addons/mail/static/src/components/thread_viewer/thread_viewer.xml44
-rw-r--r--addons/mail/static/src/components/thread_viewer/thread_viewer_tests.js364
178 files changed, 30148 insertions, 0 deletions
diff --git a/addons/mail/static/src/components/activity/activity.js b/addons/mail/static/src/components/activity/activity.js
new file mode 100644
index 00000000000..6c70f876160
--- /dev/null
+++ b/addons/mail/static/src/components/activity/activity.js
@@ -0,0 +1,177 @@
+odoo.define('mail/static/src/components/activity/activity.js', function (require) {
+'use strict';
+
+const components = {
+ ActivityMarkDonePopover: require('mail/static/src/components/activity_mark_done_popover/activity_mark_done_popover.js'),
+ FileUploader: require('mail/static/src/components/file_uploader/file_uploader.js'),
+ MailTemplate: require('mail/static/src/components/mail_template/mail_template.js'),
+};
+const useStore = require('mail/static/src/component_hooks/use_store/use_store.js');
+
+const {
+ auto_str_to_date,
+ getLangDateFormat,
+ getLangDatetimeFormat,
+} = require('web.time');
+
+const { Component, useState } = owl;
+const { useRef } = owl.hooks;
+
+class Activity extends Component {
+
+ /**
+ * @override
+ */
+ constructor(...args) {
+ super(...args);
+ this.state = useState({
+ areDetailsVisible: false,
+ });
+ useStore(props => {
+ const activity = this.env.models['mail.activity'].get(props.activityLocalId);
+ return {
+ activity: activity ? activity.__state : undefined,
+ assigneeNameOrDisplayName: (
+ activity &&
+ activity.assignee &&
+ activity.assignee.nameOrDisplayName()
+ ),
+ };
+ });
+ /**
+ * Reference of the file uploader.
+ * Useful to programmatically prompts the browser file uploader.
+ */
+ this._fileUploaderRef = useRef('fileUploader');
+ }
+
+ //--------------------------------------------------------------------------
+ // Public
+ //--------------------------------------------------------------------------
+
+ /**
+ * @returns {mail.activity}
+ */
+ get activity() {
+ return this.env.models['mail.activity'].get(this.props.activityLocalId);
+ }
+
+ /**
+ * @returns {string}
+ */
+ get assignedUserText() {
+ return _.str.sprintf(this.env._t("for %s"), this.activity.assignee.nameOrDisplayName());
+ }
+
+ /**
+ * @returns {string}
+ */
+ get delayLabel() {
+ const today = moment().startOf('day');
+ const momentDeadlineDate = moment(auto_str_to_date(this.activity.dateDeadline));
+ // true means no rounding
+ const diff = momentDeadlineDate.diff(today, 'days', true);
+ if (diff === 0) {
+ return this.env._t("Today:");
+ } else if (diff === -1) {
+ return this.env._t("Yesterday:");
+ } else if (diff < 0) {
+ return _.str.sprintf(this.env._t("%d days overdue:"), Math.abs(diff));
+ } else if (diff === 1) {
+ return this.env._t("Tomorrow:");
+ } else {
+ return _.str.sprintf(this.env._t("Due in %d days:"), Math.abs(diff));
+ }
+ }
+
+ /**
+ * @returns {string}
+ */
+ get formattedCreateDatetime() {
+ const momentCreateDate = moment(auto_str_to_date(this.activity.dateCreate));
+ const datetimeFormat = getLangDatetimeFormat();
+ return momentCreateDate.format(datetimeFormat);
+ }
+
+ /**
+ * @returns {string}
+ */
+ get formattedDeadlineDate() {
+ const momentDeadlineDate = moment(auto_str_to_date(this.activity.dateDeadline));
+ const datetimeFormat = getLangDateFormat();
+ return momentDeadlineDate.format(datetimeFormat);
+ }
+
+ /**
+ * @returns {string}
+ */
+ get MARK_DONE() {
+ return this.env._t("Mark Done");
+ }
+
+ /**
+ * @returns {string}
+ */
+ get summary() {
+ return _.str.sprintf(this.env._t("“%s”"), this.activity.summary);
+ }
+
+ //--------------------------------------------------------------------------
+ // Handlers
+ //--------------------------------------------------------------------------
+
+ /**
+ * @private
+ * @param {CustomEvent} ev
+ * @param {Object} ev.detail
+ * @param {mail.attachment} ev.detail.attachment
+ */
+ _onAttachmentCreated(ev) {
+ this.activity.markAsDone({ attachments: [ev.detail.attachment] });
+ }
+
+ /**
+ * @private
+ * @param {MouseEvent} ev
+ */
+ _onClickCancel(ev) {
+ ev.preventDefault();
+ this.activity.deleteServerRecord();
+ }
+
+ /**
+ * @private
+ */
+ _onClickDetailsButton() {
+ this.state.areDetailsVisible = !this.state.areDetailsVisible;
+ }
+
+ /**
+ * @private
+ * @param {MouseEvent} ev
+ */
+ _onClickEdit(ev) {
+ this.activity.edit();
+ }
+
+ /**
+ * @private
+ * @param {MouseEvent} ev
+ */
+ _onClickUploadDocument(ev) {
+ this._fileUploaderRef.comp.openBrowserFileUploader();
+ }
+
+}
+
+Object.assign(Activity, {
+ components,
+ props: {
+ activityLocalId: String,
+ },
+ template: 'mail.Activity',
+});
+
+return Activity;
+
+});
diff --git a/addons/mail/static/src/components/activity/activity.scss b/addons/mail/static/src/components/activity/activity.scss
new file mode 100644
index 00000000000..96117afd9ea
--- /dev/null
+++ b/addons/mail/static/src/components/activity/activity.scss
@@ -0,0 +1,174 @@
+// ------------------------------------------------------------------
+// Layout
+// ------------------------------------------------------------------
+
+.o_Activity {
+ display: flex;
+ flex: 0 0 auto;
+ padding: map-get($spacers, 2);
+}
+
+.o_Activity_detailsUserAvatar {
+ margin-inline-end: map-get($spacers, 2);
+ object-fit: cover;
+ height: 18px;
+ width: 18px;
+}
+
+.o_Activity_dueDateText, .o_Activity_summary {
+ margin-inline-end: map-get($spacers, 2);
+}
+
+.o_Activity_iconContainer {
+ @include o-position-absolute($top: auto, $left: auto, $bottom: -5px, $right: -5px);
+ display: flex;
+ align-items: center;
+ justify-content: center;
+ width: 25px;
+ height: 25px;
+ border-width: 2px;
+}
+
+.o_Activity_info {
+ display: flex;
+ align-items: baseline;
+}
+
+.o_Activity_sidebar {
+ display: flex;
+ flex: 0 0 36px;
+ margin-right: map-get($spacers, 3);
+ justify-content: center;
+}
+
+.o_Activity_toolButton {
+ padding-top: map-get($spacers, 0);
+}
+
+.o_Activity_tools {
+ display: flex;
+}
+
+.o_Activity_user {
+ height: 36px;
+ margin-left: map-get($spacers, 2);
+ margin-right: map-get($spacers, 2);
+ position: relative;
+ width: 36px;
+}
+
+.o_Activity_userAvatar {
+ height: map-get($sizes, 100);
+ width: map-get($sizes, 100);
+}
+
+// ------------------------------------------------------------------
+// Style
+// ------------------------------------------------------------------
+
+$o-mail-activity-default-color: gray('300') !default;
+$o-mail-activity-overdue-color: darken(theme-color('danger'), 10%) !default;
+$o-mail-activity-planned-color: darken(theme-color('success'), 10%) !default;
+$o-mail-activity-today-color: darken(theme-color('warning'), 10%) !default;
+
+
+.o_Activity_deadlineDateText {
+ &.o-default {
+ color: $o-mail-activity-default-color;
+ }
+
+ &.o-overdue {
+ color: $o-mail-activity-overdue-color;
+ }
+
+ &.o-planned {
+ color: $o-mail-activity-planned-color;
+ }
+
+ &.o-today {
+ color: $o-mail-activity-today-color;
+ }
+}
+
+.o_Activity_details {
+ color: gray('500');
+}
+
+.o_Activity_detailsCreatorAvatar {
+ margin-inline-start: map-get($spacers, 2);
+}
+
+.o_Activity_detailsUserAvatar {
+ border-radius: 50%;
+}
+
+.o_Activity_dueDateText {
+ font-weight: bolder;
+
+ &.o-default {
+ color: $o-mail-activity-default-color;
+ }
+
+ &.o-overdue {
+ color: $o-mail-activity-overdue-color;
+ }
+
+ &.o-planned {
+ color: $o-mail-activity-planned-color;
+ }
+
+ &.o-today {
+ color: $o-mail-activity-today-color;
+ }
+}
+
+/* Needed specifity to counter default bootstrap style */
+a:not([href]):not([tabindex]).o_Activity_detailsButton {
+ background: transparent;
+ opacity: 0.5;
+ color: gray('500');
+
+ &:hover {
+ opacity: 1;
+ color: gray('600');
+ }
+}
+
+.o_Activity_detailsCreator {
+ font-weight: bold;
+}
+
+.o_Activity_iconContainer {
+ color: white;
+ border-color: white;
+ border-radius: 100%;
+ border-style: solid;
+}
+
+.o_Activity_sidebar {
+ font-size: smaller;
+}
+
+.o_Activity_summary {
+ font-weight: bolder;
+ color: gray('900');
+}
+
+.o_Activity_toolButton {
+ opacity: 0.5;
+ color: gray('500');
+
+ &:hover {
+ opacity: 1;
+ color: gray('600');
+ }
+}
+
+.o_Activity_userAvatar {
+ border-radius: 50%;
+}
+
+.o_Activity_userName {
+ color: gray('500');
+}
+
diff --git a/addons/mail/static/src/components/activity/activity.xml b/addons/mail/static/src/components/activity/activity.xml
new file mode 100644
index 00000000000..22f4f9dddc7
--- /dev/null
+++ b/addons/mail/static/src/components/activity/activity.xml
@@ -0,0 +1,149 @@
+<?xml version="1.0" encoding="UTF-8"?>
+<templates xml:space="preserve">
+
+ <t t-name="mail.Activity" owl="1">
+ <div class="o_Activity">
+ <t t-if="activity">
+ <div class="o_Activity_sidebar">
+ <div class="o_Activity_user">
+ <t t-if="activity.assignee">
+ <img class="o_Activity_userAvatar" t-attf-src="/web/image/res.users/{{ activity.assignee.id }}/image_128" t-att-alt="activity.assignee.nameOrDisplayName()"/>
+ </t>
+ <div class="o_Activity_iconContainer"
+ t-att-class="{
+ 'bg-success-full': activity.state === 'planned',
+ 'bg-warning-full': activity.state === 'today',
+ 'bg-danger-full': activity.state === 'overdue',
+ }"
+ >
+ <i class="o_Activity_icon fa" t-attf-class="{{ activity.icon }}"/>
+ </div>
+ </div>
+ </div>
+ <div class="o_Activity_core">
+ <div class="o_Activity_info">
+ <div class="o_Activity_dueDateText"
+ t-att-class="{
+ 'o-default': activity.state === 'default',
+ 'o-overdue': activity.state === 'overdue',
+ 'o-planned': activity.state === 'planned',
+ 'o-today': activity.state === 'today',
+ }"
+ >
+ <t t-esc="delayLabel"/>
+ </div>
+ <t t-if="activity.summary">
+ <div class="o_Activity_summary">
+ <t t-esc="summary"/>
+ </div>
+ </t>
+ <t t-elif="activity.type">
+ <div class="o_Activity_summary o_Activity_type">
+ <t t-esc="activity.type.displayName"/>
+ </div>
+ </t>
+ <t t-if="activity.assignee">
+ <div class="o_Activity_userName">
+ <t t-esc="assignedUserText"/>
+ </div>
+ </t>
+ <a class="o_Activity_detailsButton btn btn-link" t-on-click="_onClickDetailsButton" role="button">
+ <i class="fa fa-info-circle" role="img" title="Info"/>
+ </a>
+ </div>
+
+ <div class="o_Activity_details" t-if="state.areDetailsVisible">
+ <dl class="dl-horizontal">
+ <t t-if="activity.type">
+ <dt>Activity type</dt>
+ <dd class="o_Activity_type">
+ <t t-esc="activity.type.displayName"/>
+ </dd>
+ </t>
+ <t t-if="activity.creator">
+ <dt>Created</dt>
+ <dd class="o_Activity_detailsCreation">
+ <t t-esc="formattedCreateDatetime"/>
+ <img class="o_Activity_detailsUserAvatar o_Activity_detailsCreatorAvatar" t-attf-src="/web/image/res.users/{{ activity.creator.id }}/image_128" t-att-title="activity.creator.nameOrDisplayName()" t-att-alt="activity.creator.nameOrDisplayName()"/>
+ <span class="o_Activity_detailsCreator">
+ <t t-esc="activity.creator.nameOrDisplayName()"/>
+ </span>
+ </dd>
+ </t>
+ <t t-if="activity.assignee">
+ <dt>Assigned to</dt>
+ <dd class="o_Activity_detailsAssignation">
+ <img class="o_Activity_detailsUserAvatar o_Activity_detailsAssignationUserAvatar" t-attf-src="/web/image/res.users/{{ activity.assignee.id }}/image_128" t-att-title="activity.assignee.nameOrDisplayName()" t-att-alt="activity.assignee.nameOrDisplayName()"/>
+ <t t-esc="activity.assignee.nameOrDisplayName()"/>
+ </dd>
+ </t>
+ <dt>Due on</dt>
+ <dd class="o_Activity_detailsDueDate">
+ <span class="o_Activity_deadlineDateText"
+ t-att-class="{
+ 'o-default': activity.state === 'default',
+ 'o-overdue': activity.state === 'overdue',
+ 'o-planned': activity.state === 'planned',
+ 'o-today': activity.state === 'today',
+ }"
+ >
+ <t t-esc="formattedDeadlineDate"/>
+ </span>
+ </dd>
+ </dl>
+ </div>
+
+ <div class="o_Activity_note" t-if="activity.note">
+ <t t-raw="activity.note"/>
+ </div>
+
+ <t t-if="activity.mailTemplates.length > 0">
+ <div class="o_Activity_mailTemplates">
+ <t t-foreach="activity.mailTemplates" t-as="mailTemplate" t-key="mailTemplate.localId">
+ <MailTemplate
+ class="o_Activity_mailTemplate"
+ activityLocalId="activity.localId"
+ mailTemplateLocalId="mailTemplate.localId"
+ />
+ </t>
+ </div>
+ </t>
+
+ <t t-if="activity.canWrite">
+ <div name="tools" class="o_Activity_tools">
+ <t t-if="activity.category !== 'upload_file'">
+ <Popover position="'right'" title="MARK_DONE">
+ <button class="o_Activity_toolButton o_Activity_markDoneButton btn btn-link" t-att-title="MARK_DONE">
+ <i class="fa fa-check"/> Mark Done
+ </button>
+ <t t-set="opened">
+ <ActivityMarkDonePopover activityLocalId="props.activityLocalId"/>
+ </t>
+ </Popover>
+ </t>
+ <t t-else="">
+ <button class="o_Activity_toolButton o_Activity_uploadButton btn btn-link" t-on-click="_onClickUploadDocument">
+ <i class="fa fa-upload"/> Upload Document
+ </button>
+ <FileUploader
+ attachmentLocalIds="activity.attachments.map(attachment => attachment.localId)"
+ uploadId="activity.res_id"
+ uploadModel="activity.res_model"
+ t-on-o-attachment-created="_onAttachmentCreated"
+ t-ref="fileUploader"
+ />
+ </t>
+ <button class="o_Activity_toolButton o_Activity_editButton btn btn-link" t-on-click="_onClickEdit">
+ <i class="fa fa-pencil"/> Edit
+ </button>
+ <button class="o_Activity_toolButton o_Activity_cancelButton btn btn-link" t-on-click="_onClickCancel" >
+ <i class="fa fa-times"/> Cancel
+ </button>
+ </div>
+ </t>
+ </div>
+ </t>
+ </div>
+ </t>
+
+</templates>
diff --git a/addons/mail/static/src/components/activity/activity_tests.js b/addons/mail/static/src/components/activity/activity_tests.js
new file mode 100644
index 00000000000..714f0d593eb
--- /dev/null
+++ b/addons/mail/static/src/components/activity/activity_tests.js
@@ -0,0 +1,981 @@
+odoo.define('mail/static/src/components/activity/activity_tests.js', function (require) {
+'use strict';
+
+const components = {
+ Activity: require('mail/static/src/components/activity/activity.js'),
+};
+
+const {
+ afterEach: utilsAfterEach,
+ afterNextRender,
+ beforeEach: utilsBeforeEach,
+ start: utilsStart,
+} = require('mail/static/src/utils/test_utils.js');
+const useStore = require('mail/static/src/component_hooks/use_store/use_store.js');
+
+const Bus = require('web.Bus');
+const { date_to_str } = require('web.time');
+
+const { Component, tags: { xml } } = owl;
+
+QUnit.module('mail', {}, function () {
+QUnit.module('components', {}, function () {
+QUnit.module('activity', {}, function () {
+QUnit.module('activity_tests.js', {
+ beforeEach() {
+ utilsBeforeEach(this);
+
+ this.createActivityComponent = async function (activity) {
+ const ActivityComponent = components.Activity;
+ ActivityComponent.env = this.env;
+ this.component = new ActivityComponent(null, {
+ activityLocalId: activity.localId,
+ });
+ await this.component.mount(this.widget.el);
+ };
+
+ this.start = async params => {
+ let { env, widget } = await utilsStart(Object.assign({}, params, {
+ data: this.data,
+ }));
+ this.env = env;
+ this.widget = widget;
+ };
+ },
+ afterEach() {
+ utilsAfterEach(this);
+ if (this.component) {
+ this.component.destroy();
+ }
+ if (this.widget) {
+ this.widget.destroy();
+ }
+ this.env = undefined;
+ },
+});
+
+QUnit.test('activity simplest layout', async function (assert) {
+ assert.expect(12);
+
+ await this.start();
+ const activity = this.env.models['mail.activity'].create();
+ await this.createActivityComponent(activity);
+ assert.strictEqual(
+ document.querySelectorAll('.o_Activity').length,
+ 1,
+ "should have activity component"
+ );
+ assert.strictEqual(
+ document.querySelectorAll('.o_Activity_sidebar').length,
+ 1,
+ "should have activity sidebar"
+ );
+ assert.strictEqual(
+ document.querySelectorAll('.o_Activity_core').length,
+ 1,
+ "should have activity core"
+ );
+ assert.strictEqual(
+ document.querySelectorAll('.o_Activity_user').length,
+ 1,
+ "should have activity user"
+ );
+ assert.strictEqual(
+ document.querySelectorAll('.o_Activity_info').length,
+ 1,
+ "should have activity info"
+ );
+ assert.strictEqual(
+ document.querySelectorAll('.o_Activity_note').length,
+ 0,
+ "should not have activity note"
+ );
+ assert.strictEqual(
+ document.querySelectorAll('.o_Activity_details').length,
+ 0,
+ "should not have activity details"
+ );
+ assert.strictEqual(
+ document.querySelectorAll('.o_Activity_mailTemplates').length,
+ 0,
+ "should not have activity mail templates"
+ );
+ assert.strictEqual(
+ document.querySelectorAll('.o_Activity_editButton').length,
+ 0,
+ "should not have activity Edit button"
+ );
+ assert.strictEqual(
+ document.querySelectorAll('.o_Activity_cancelButton').length,
+ 0,
+ "should not have activity Cancel button"
+ );
+ assert.strictEqual(
+ document.querySelectorAll('.o_Activity_markDoneButton').length,
+ 0,
+ "should not have activity Mark as Done button"
+ );
+ assert.strictEqual(
+ document.querySelectorAll('.o_Activity_uploadButton').length,
+ 0,
+ "should not have activity Upload button"
+ );
+});
+
+QUnit.test('activity with note layout', async function (assert) {
+ assert.expect(3);
+
+ await this.start();
+ const activity = this.env.models['mail.activity'].create({
+ note: 'There is no good or bad note'
+ });
+ await this.createActivityComponent(activity);
+ assert.strictEqual(
+ document.querySelectorAll('.o_Activity').length,
+ 1,
+ "should have activity component"
+ );
+ assert.strictEqual(
+ document.querySelectorAll('.o_Activity_note').length,
+ 1,
+ "should have activity note"
+ );
+ assert.strictEqual(
+ document.querySelector('.o_Activity_note').textContent,
+ "There is no good or bad note",
+ "activity note should be 'There is no good or bad note'"
+ );
+});
+
+QUnit.test('activity info layout when planned after tomorrow', async function (assert) {
+ assert.expect(4);
+
+ await this.start();
+ const today = new Date();
+ const fiveDaysFromNow = new Date();
+ fiveDaysFromNow.setDate(today.getDate() + 5);
+ const activity = this.env.models['mail.activity'].create({
+ dateDeadline: date_to_str(fiveDaysFromNow),
+ state: 'planned',
+ });
+ await this.createActivityComponent(activity);
+ assert.strictEqual(
+ document.querySelectorAll('.o_Activity').length,
+ 1,
+ "should have activity component"
+ );
+ assert.strictEqual(
+ document.querySelectorAll('.o_Activity_dueDateText').length,
+ 1,
+ "should have activity delay"
+ );
+ assert.ok(
+ document.querySelector('.o_Activity_dueDateText').classList.contains('o-planned'),
+ "activity delay should have the right color modifier class (planned)"
+ );
+ assert.strictEqual(
+ document.querySelector('.o_Activity_dueDateText').textContent,
+ "Due in 5 days:",
+ "activity delay should have 'Due in 5 days:' as label"
+ );
+});
+
+QUnit.test('activity info layout when planned tomorrow', async function (assert) {
+ assert.expect(4);
+
+ await this.start();
+ const today = new Date();
+ const tomorrow = new Date();
+ tomorrow.setDate(today.getDate() + 1);
+ const activity = this.env.models['mail.activity'].create({
+ dateDeadline: date_to_str(tomorrow),
+ state: 'planned',
+ });
+ await this.createActivityComponent(activity);
+ assert.strictEqual(
+ document.querySelectorAll('.o_Activity').length,
+ 1,
+ "should have activity component"
+ );
+ assert.strictEqual(
+ document.querySelectorAll('.o_Activity_dueDateText').length,
+ 1,
+ "should have activity delay"
+ );
+ assert.ok(
+ document.querySelector('.o_Activity_dueDateText').classList.contains('o-planned'),
+ "activity delay should have the right color modifier class (planned)"
+ );
+ assert.strictEqual(
+ document.querySelector('.o_Activity_dueDateText').textContent,
+ 'Tomorrow:',
+ "activity delay should have 'Tomorrow:' as label"
+ );
+});
+
+QUnit.test('activity info layout when planned today', async function (assert) {
+ assert.expect(4);
+
+ await this.start();
+ const today = new Date();
+ const activity = this.env.models['mail.activity'].create({
+ dateDeadline: date_to_str(today),
+ state: 'today',
+ });
+ await this.createActivityComponent(activity);
+ assert.strictEqual(
+ document.querySelectorAll('.o_Activity').length,
+ 1,
+ "should have activity component"
+ );
+ assert.strictEqual(
+ document.querySelectorAll('.o_Activity_dueDateText').length,
+ 1,
+ "should have activity delay"
+ );
+ assert.ok(
+ document.querySelector('.o_Activity_dueDateText').classList.contains('o-today'),
+ "activity delay should have the right color modifier class (today)"
+ );
+ assert.strictEqual(
+ document.querySelector('.o_Activity_dueDateText').textContent,
+ "Today:",
+ "activity delay should have 'Today:' as label"
+ );
+});
+
+QUnit.test('activity info layout when planned yesterday', async function (assert) {
+ assert.expect(4);
+
+ await this.start();
+ const today = new Date();
+ const yesterday = new Date();
+ yesterday.setDate(today.getDate() - 1);
+ const activity = this.env.models['mail.activity'].create({
+ dateDeadline: date_to_str(yesterday),
+ state: 'overdue',
+ });
+ await this.createActivityComponent(activity);
+ assert.strictEqual(
+ document.querySelectorAll('.o_Activity').length,
+ 1,
+ "should have activity component"
+ );
+ assert.strictEqual(
+ document.querySelectorAll('.o_Activity_dueDateText').length,
+ 1,
+ "should have activity delay"
+ );
+ assert.ok(
+ document.querySelector('.o_Activity_dueDateText').classList.contains('o-overdue'),
+ "activity delay should have the right color modifier class (overdue)"
+ );
+ assert.strictEqual(
+ document.querySelector('.o_Activity_dueDateText').textContent,
+ "Yesterday:",
+ "activity delay should have 'Yesterday:' as label"
+ );
+});
+
+QUnit.test('activity info layout when planned before yesterday', async function (assert) {
+ assert.expect(4);
+
+ await this.start();
+ const today = new Date();
+ const fiveDaysBeforeNow = new Date();
+ fiveDaysBeforeNow.setDate(today.getDate() - 5);
+ const activity = this.env.models['mail.activity'].create({
+ dateDeadline: date_to_str(fiveDaysBeforeNow),
+ state: 'overdue',
+ });
+ await this.createActivityComponent(activity);
+ assert.strictEqual(
+ document.querySelectorAll('.o_Activity').length,
+ 1,
+ "should have activity component"
+ );
+ assert.strictEqual(
+ document.querySelectorAll('.o_Activity_dueDateText').length,
+ 1,
+ "should have activity delay"
+ );
+ assert.ok(
+ document.querySelector('.o_Activity_dueDateText').classList.contains('o-overdue'),
+ "activity delay should have the right color modifier class (overdue)"
+ );
+ assert.strictEqual(
+ document.querySelector('.o_Activity_dueDateText').textContent,
+ "5 days overdue:",
+ "activity delay should have '5 days overdue:' as label"
+ );
+});
+
+QUnit.test('activity with a summary layout', async function (assert) {
+ assert.expect(4);
+
+ await this.start();
+ const activity = this.env.models['mail.activity'].create({
+ summary: 'test summary',
+ });
+ await this.createActivityComponent(activity);
+ assert.strictEqual(
+ document.querySelectorAll('.o_Activity').length,
+ 1,
+ "should have activity component"
+ );
+ assert.strictEqual(
+ document.querySelectorAll('.o_Activity_summary').length,
+ 1,
+ "should have activity summary"
+ );
+ assert.strictEqual(
+ document.querySelectorAll('.o_Activity_type').length,
+ 0,
+ "should not have the activity type as summary"
+ );
+ assert.strictEqual(
+ document.querySelector('.o_Activity_summary').textContent.trim(),
+ "“test summary”",
+ "should have the specific activity summary in activity summary"
+ );
+});
+
+QUnit.test('activity without summary layout', async function (assert) {
+ assert.expect(5);
+
+ await this.start();
+ const activity = this.env.models['mail.activity'].create({
+ type: [['insert', { id: 1, displayName: "Fake type" }]],
+ });
+ await this.createActivityComponent(activity);
+ assert.strictEqual(
+ document.querySelectorAll('.o_Activity').length,
+ 1,
+ "should have activity component"
+ );
+ assert.strictEqual(
+ document.querySelectorAll('.o_Activity_type').length,
+ 1,
+ "activity details should have an activity type section"
+ );
+ assert.strictEqual(
+ document.querySelector('.o_Activity_type').textContent.trim(),
+ "Fake type",
+ "activity details should have the activity type display name in type section"
+ );
+ assert.strictEqual(
+ document.querySelectorAll('.o_Activity_summary.o_Activity_type').length,
+ 1,
+ "should have activity type as summary"
+ );
+ assert.strictEqual(
+ document.querySelectorAll('.o_Activity_summary:not(.o_Activity_type)').length,
+ 0,
+ "should not have a specific summary"
+ );
+});
+
+QUnit.test('activity details toggle', async function (assert) {
+ assert.expect(5);
+
+ await this.start();
+ const today = new Date();
+ const tomorrow = new Date();
+ tomorrow.setDate(today.getDate() + 1);
+ const activity = this.env.models['mail.activity'].create({
+ creator: [['insert', { id: 1, partnerDisplayName: "Admin" }]],
+ dateCreate: date_to_str(today),
+ dateDeadline: date_to_str(tomorrow),
+ state: 'planned',
+ type: [['insert', { id: 1, displayName: "Fake type" }]],
+ });
+ await this.createActivityComponent(activity);
+ assert.strictEqual(
+ document.querySelectorAll('.o_Activity').length,
+ 1,
+ "should have activity component"
+ );
+ assert.strictEqual(
+ document.querySelectorAll('.o_Activity_details').length,
+ 0,
+ "activity details should not be visible by default"
+ );
+ assert.strictEqual(
+ document.querySelectorAll('.o_Activity_detailsButton').length,
+ 1,
+ "activity should have a details button"
+ );
+
+ await afterNextRender(() =>
+ document.querySelector('.o_Activity_detailsButton').click()
+ );
+ assert.strictEqual(
+ document.querySelectorAll('.o_Activity_details').length,
+ 1,
+ "activity details should be visible after clicking on details button"
+ );
+
+ await afterNextRender(() =>
+ document.querySelector('.o_Activity_detailsButton').click()
+ );
+ assert.strictEqual(
+ document.querySelectorAll('.o_Activity_details').length,
+ 0,
+ "activity details should no longer be visible after clicking again on details button"
+ );
+});
+
+QUnit.test('activity details layout', async function (assert) {
+ assert.expect(11);
+
+ await this.start();
+ const today = new Date();
+ const tomorrow = new Date();
+ tomorrow.setDate(today.getDate() + 1);
+ const activity = this.env.models['mail.activity'].create({
+ assignee: [['insert', { id: 10, partnerDisplayName: "Pauvre pomme" }]],
+ creator: [['insert', { id: 1, partnerDisplayName: "Admin" }]],
+ dateCreate: date_to_str(today),
+ dateDeadline: date_to_str(tomorrow),
+ state: 'planned',
+ type: [['insert', { id: 1, displayName: "Fake type" }]],
+ });
+ await this.createActivityComponent(activity);
+ assert.strictEqual(
+ document.querySelectorAll('.o_Activity').length,
+ 1,
+ "should have activity component"
+ );
+ assert.strictEqual(
+ document.querySelectorAll('.o_Activity_userAvatar').length,
+ 1,
+ "should have activity user avatar"
+ );
+ assert.strictEqual(
+ document.querySelectorAll('.o_Activity_detailsButton').length,
+ 1,
+ "activity should have a details button"
+ );
+
+ await afterNextRender(() =>
+ document.querySelector('.o_Activity_detailsButton').click()
+ );
+ assert.strictEqual(
+ document.querySelectorAll('.o_Activity_details').length,
+ 1,
+ "activity details should be visible after clicking on details button"
+ );
+ assert.strictEqual(
+ document.querySelectorAll('.o_Activity_details .o_Activity_type').length,
+ 1,
+ "activity details should have type"
+ );
+ assert.strictEqual(
+ document.querySelector('.o_Activity_details .o_Activity_type').textContent,
+ "Fake type",
+ "activity details type should be 'Fake type'"
+ );
+ assert.strictEqual(
+ document.querySelectorAll('.o_Activity_detailsCreation').length,
+ 1,
+ "activity details should have creation date "
+ );
+ assert.strictEqual(
+ document.querySelectorAll('.o_Activity_detailsCreator').length,
+ 1,
+ "activity details should have creator"
+ );
+ assert.strictEqual(
+ document.querySelectorAll('.o_Activity_detailsAssignation').length,
+ 1,
+ "activity details should have assignation information"
+ );
+ assert.strictEqual(
+ document.querySelector('.o_Activity_detailsAssignation').textContent.indexOf('Pauvre pomme'),
+ 0,
+ "activity details assignation information should contain creator display name"
+ );
+ assert.strictEqual(
+ document.querySelectorAll('.o_Activity_detailsAssignationUserAvatar').length,
+ 1,
+ "activity details should have user avatar"
+ );
+});
+
+QUnit.test('activity with mail template layout', async function (assert) {
+ assert.expect(8);
+
+ await this.start();
+ const activity = this.env.models['mail.activity'].create({
+ mailTemplates: [['insert', { id: 1, name: "Dummy mail template" }]],
+ });
+ await this.createActivityComponent(activity);
+ assert.strictEqual(
+ document.querySelectorAll('.o_Activity').length,
+ 1,
+ "should have activity component"
+ );
+ assert.strictEqual(
+ document.querySelectorAll('.o_Activity_sidebar').length,
+ 1,
+ "should have activity sidebar"
+ );
+ assert.strictEqual(
+ document.querySelectorAll('.o_Activity_mailTemplates').length,
+ 1,
+ "should have activity mail templates"
+ );
+ assert.strictEqual(
+ document.querySelectorAll('.o_Activity_mailTemplate').length,
+ 1,
+ "should have activity mail template"
+ );
+ assert.strictEqual(
+ document.querySelectorAll('.o_MailTemplate_name').length,
+ 1,
+ "should have activity mail template name"
+ );
+ assert.strictEqual(
+ document.querySelector('.o_MailTemplate_name').textContent,
+ "Dummy mail template",
+ "should have activity mail template name"
+ );
+ assert.strictEqual(
+ document.querySelectorAll('.o_MailTemplate_preview').length,
+ 1,
+ "should have activity mail template name preview button"
+ );
+ assert.strictEqual(
+ document.querySelectorAll('.o_MailTemplate_send').length,
+ 1,
+ "should have activity mail template name send button"
+ );
+});
+
+QUnit.test('activity with mail template: preview mail', async function (assert) {
+ assert.expect(10);
+
+ const bus = new Bus();
+ bus.on('do-action', null, payload => {
+ assert.step('do_action');
+ assert.strictEqual(
+ payload.action.context.default_res_id,
+ 42,
+ 'Action should have the activity res id as default res id in context'
+ );
+ assert.strictEqual(
+ payload.action.context.default_model,
+ 'res.partner',
+ 'Action should have the activity res model as default model in context'
+ );
+ assert.ok(
+ payload.action.context.default_use_template,
+ 'Action should have true as default use_template in context'
+ );
+ assert.strictEqual(
+ payload.action.context.default_template_id,
+ 1,
+ 'Action should have the selected mail template id as default template id in context'
+ );
+ assert.strictEqual(
+ payload.action.type,
+ "ir.actions.act_window",
+ 'Action should be of type "ir.actions.act_window"'
+ );
+ assert.strictEqual(
+ payload.action.res_model,
+ "mail.compose.message",
+ 'Action should have "mail.compose.message" as res_model'
+ );
+ });
+
+ await this.start({ env: { bus } });
+ const activity = this.env.models['mail.activity'].create({
+ mailTemplates: [['insert', {
+ id: 1,
+ name: "Dummy mail template",
+ }]],
+ res_id: 42,
+ res_model: 'res.partner',
+ });
+ await this.createActivityComponent(activity);
+ assert.strictEqual(
+ document.querySelectorAll('.o_Activity').length,
+ 1,
+ "should have activity component"
+ );
+ assert.strictEqual(
+ document.querySelectorAll('.o_MailTemplate_preview').length,
+ 1,
+ "should have activity mail template name preview button"
+ );
+
+ document.querySelector('.o_MailTemplate_preview').click();
+ assert.verifySteps(
+ ['do_action'],
+ "should have called 'compose email' action correctly"
+ );
+});
+
+QUnit.test('activity with mail template: send mail', async function (assert) {
+ assert.expect(7);
+
+ await this.start({
+ async mockRPC(route, args) {
+ if (args.method === 'activity_send_mail') {
+ assert.step('activity_send_mail');
+ assert.strictEqual(args.args[0].length, 1);
+ assert.strictEqual(args.args[0][0], 42);
+ assert.strictEqual(args.args[1], 1);
+ return;
+ } else {
+ return this._super(...arguments);
+ }
+ },
+ });
+ const activity = this.env.models['mail.activity'].create({
+ mailTemplates: [['insert', {
+ id: 1,
+ name: "Dummy mail template",
+ }]],
+ res_id: 42,
+ });
+ await this.createActivityComponent(activity);
+ assert.strictEqual(
+ document.querySelectorAll('.o_Activity').length,
+ 1,
+ "should have activity component"
+ );
+ assert.strictEqual(
+ document.querySelectorAll('.o_MailTemplate_send').length,
+ 1,
+ "should have activity mail template name send button"
+ );
+
+ document.querySelector('.o_MailTemplate_send').click();
+ assert.verifySteps(
+ ['activity_send_mail'],
+ "should have called activity_send_mail rpc"
+ );
+});
+
+QUnit.test('activity upload document is available', async function (assert) {
+ assert.expect(3);
+
+ await this.start();
+ const today = new Date();
+ const tomorrow = new Date();
+ tomorrow.setDate(today.getDate() + 1);
+ const activity = this.env.models['mail.activity'].create({
+ canWrite: true,
+ category: 'upload_file',
+ });
+ await this.createActivityComponent(activity);
+ assert.strictEqual(
+ document.querySelectorAll('.o_Activity').length,
+ 1,
+ "should have activity component"
+ );
+ assert.strictEqual(
+ document.querySelectorAll('.o_Activity_uploadButton').length,
+ 1,
+ "should have activity upload button"
+ );
+ assert.strictEqual(
+ document.querySelectorAll('.o_FileUploader').length,
+ 1,
+ "should have a file uploader"
+ );
+});
+
+QUnit.test('activity click on mark as done', async function (assert) {
+ assert.expect(4);
+
+ await this.start();
+ const today = new Date();
+ const tomorrow = new Date();
+ tomorrow.setDate(today.getDate() + 1);
+ const activity = this.env.models['mail.activity'].create({
+ canWrite: true,
+ category: 'not_upload_file',
+ });
+ await this.createActivityComponent(activity);
+
+ assert.strictEqual(
+ document.querySelectorAll('.o_Activity').length,
+ 1,
+ "should have activity component"
+ );
+ assert.strictEqual(
+ document.querySelectorAll('.o_Activity_markDoneButton').length,
+ 1,
+ "should have activity Mark as Done button"
+ );
+
+ await afterNextRender(() => {
+ document.querySelector('.o_Activity_markDoneButton').click();
+ });
+ assert.strictEqual(
+ document.querySelectorAll('.o_ActivityMarkDonePopover').length,
+ 1,
+ "should have opened the mark done popover"
+ );
+
+ await afterNextRender(() => {
+ document.querySelector('.o_Activity_markDoneButton').click();
+ });
+ assert.strictEqual(
+ document.querySelectorAll('.o_ActivityMarkDonePopover').length,
+ 0,
+ "should have closed the mark done popover"
+ );
+});
+
+QUnit.test('activity click on edit', async function (assert) {
+ assert.expect(9);
+
+ const bus = new Bus();
+ bus.on('do-action', null, payload => {
+ assert.step('do_action');
+ assert.strictEqual(
+ payload.action.context.default_res_id,
+ 42,
+ 'Action should have the activity res id as default res id in context'
+ );
+ assert.strictEqual(
+ payload.action.context.default_res_model,
+ 'res.partner',
+ 'Action should have the activity res model as default res model in context'
+ );
+ assert.strictEqual(
+ payload.action.type,
+ "ir.actions.act_window",
+ 'Action should be of type "ir.actions.act_window"'
+ );
+ assert.strictEqual(
+ payload.action.res_model,
+ "mail.activity",
+ 'Action should have "mail.activity" as res_model'
+ );
+ assert.strictEqual(
+ payload.action.res_id,
+ 12,
+ 'Action should have activity id as res_id'
+ );
+ });
+
+ await this.start({ env: { bus } });
+ const activity = this.env.models['mail.activity'].create({
+ canWrite: true,
+ id: 12,
+ mailTemplates: [['insert', { id: 1, name: "Dummy mail template" }]],
+ res_id: 42,
+ res_model: 'res.partner',
+ });
+ await this.createActivityComponent(activity);
+ assert.strictEqual(
+ document.querySelectorAll('.o_Activity').length,
+ 1,
+ "should have activity component"
+ );
+ assert.strictEqual(
+ document.querySelectorAll('.o_Activity_editButton').length,
+ 1,
+ "should have activity edit button"
+ );
+
+ document.querySelector('.o_Activity_editButton').click();
+ assert.verifySteps(
+ ['do_action'],
+ "should have called 'schedule activity' action correctly"
+ );
+});
+
+QUnit.test('activity edition', async function (assert) {
+ assert.expect(14);
+
+ this.data['mail.activity'].records = [{
+ can_write: true,
+ icon: 'fa-times',
+ id: 12,
+ res_id: 42,
+ res_model: 'res.partner',
+ }];
+
+ const bus = new Bus();
+ bus.on('do-action', null, payload => {
+ assert.step('do_action');
+ assert.strictEqual(
+ payload.action.context.default_res_id,
+ 42,
+ 'Action should have the activity res id as default res id in context'
+ );
+ assert.strictEqual(
+ payload.action.context.default_res_model,
+ 'res.partner',
+ 'Action should have the activity res model as default res model in context'
+ );
+ assert.strictEqual(
+ payload.action.type,
+ 'ir.actions.act_window',
+ 'Action should be of type "ir.actions.act_window"'
+ );
+ assert.strictEqual(
+ payload.action.res_model,
+ 'mail.activity',
+ 'Action should have "mail.activity" as res_model'
+ );
+ assert.strictEqual(
+ payload.action.res_id,
+ 12,
+ 'Action should have activity id as res_id'
+ );
+ this.data['mail.activity'].records[0].icon = 'fa-check';
+ payload.options.on_close();
+ });
+
+ await this.start({ env: { bus } });
+ const activity = this.env.models['mail.activity'].create(
+ this.env.models['mail.activity'].convertData(
+ this.data['mail.activity'].records[0]
+ )
+ );
+ await this.createActivityComponent(activity);
+
+ assert.containsOnce(
+ document.body,
+ '.o_Activity',
+ "should have activity component"
+ );
+ assert.containsOnce(
+ document.body,
+ '.o_Activity_editButton',
+ "should have activity edit button"
+ );
+ assert.containsOnce(
+ document.body,
+ '.o_Activity_icon',
+ "should have activity icon"
+ );
+ assert.containsOnce(
+ document.body,
+ '.o_Activity_icon.fa-times',
+ "should have initial activity icon"
+ );
+ assert.containsNone(
+ document.body,
+ '.o_Activity_icon.fa-check',
+ "should not have new activity icon when not edited yet"
+ );
+
+ await afterNextRender(() => {
+ document.querySelector('.o_Activity_editButton').click();
+ });
+ assert.verifySteps(
+ ['do_action'],
+ "should have called 'schedule activity' action correctly"
+ );
+ assert.containsNone(
+ document.body,
+ '.o_Activity_icon.fa-times',
+ "should no more have initial activity icon once edited"
+ );
+ assert.containsOnce(
+ document.body,
+ '.o_Activity_icon.fa-check',
+ "should now have new activity icon once edited"
+ );
+});
+
+QUnit.test('activity click on cancel', async function (assert) {
+ assert.expect(7);
+
+ await this.start({
+ async mockRPC(route, args) {
+ if (route === '/web/dataset/call_kw/mail.activity/unlink') {
+ assert.step('unlink');
+ assert.strictEqual(args.args[0].length, 1);
+ assert.strictEqual(args.args[0][0], 12);
+ return;
+ } else {
+ return this._super(...arguments);
+ }
+ },
+ });
+ const activity = this.env.models['mail.activity'].create({
+ canWrite: true,
+ id: 12,
+ mailTemplates: [['insert', {
+ id: 1,
+ name: "Dummy mail template",
+ }]],
+ res_id: 42,
+ res_model: 'res.partner',
+ });
+
+ // Create a parent component to surround the Activity component in order to be able
+ // to check that activity component has been destroyed
+ class ParentComponent extends Component {
+ constructor(...args) {
+ super(... args);
+ useStore(props => {
+ const activity = this.env.models['mail.activity'].get(props.activityLocalId);
+ return {
+ activity: activity ? activity.__state : undefined,
+ };
+ });
+ }
+
+ /**
+ * @returns {mail.activity}
+ */
+ get activity() {
+ return this.env.models['mail.activity'].get(this.props.activityLocalId);
+ }
+ }
+ ParentComponent.env = this.env;
+ Object.assign(ParentComponent, {
+ components,
+ props: { activityLocalId: String },
+ template: xml`
+ <div>
+ <p>parent</p>
+ <t t-if="activity">
+ <Activity activityLocalId="activity.localId"/>
+ </t>
+ </div>
+ `,
+ });
+ this.component = new ParentComponent(null, { activityLocalId: activity.localId });
+ await this.component.mount(this.widget.el);
+
+ assert.strictEqual(
+ document.querySelectorAll('.o_Activity').length,
+ 1,
+ "should have activity component"
+ );
+ assert.strictEqual(
+ document.querySelectorAll('.o_Activity_cancelButton').length,
+ 1,
+ "should have activity cancel button"
+ );
+
+ await afterNextRender(() =>
+ document.querySelector('.o_Activity_cancelButton').click()
+ );
+ assert.verifySteps(
+ ['unlink'],
+ "should have called unlink rpc after clicking on cancel"
+ );
+ assert.strictEqual(
+ document.querySelectorAll('.o_Activity').length,
+ 0,
+ "should no longer display activity after clicking on cancel"
+ );
+});
+
+});
+});
+});
+
+});
diff --git a/addons/mail/static/src/components/activity_box/activity_box.js b/addons/mail/static/src/components/activity_box/activity_box.js
new file mode 100644
index 00000000000..bfc624bea19
--- /dev/null
+++ b/addons/mail/static/src/components/activity_box/activity_box.js
@@ -0,0 +1,60 @@
+odoo.define('mail/static/src/components/activity_box/activity_box.js', function (require) {
+'use strict';
+
+const components = {
+ Activity: require('mail/static/src/components/activity/activity.js'),
+};
+const useStore = require('mail/static/src/component_hooks/use_store/use_store.js');
+
+const { Component } = owl;
+
+class ActivityBox extends Component {
+
+ /**
+ * @override
+ */
+ constructor(...args) {
+ super(...args);
+ useStore(props => {
+ const chatter = this.env.models['mail.chatter'].get(props.chatterLocalId);
+ return {
+ chatter: chatter ? chatter.__state : undefined,
+ };
+ });
+ }
+
+ //--------------------------------------------------------------------------
+ // Public
+ //--------------------------------------------------------------------------
+
+ /**
+ * @returns {Chatter}
+ */
+ get chatter() {
+ return this.env.models['mail.chatter'].get(this.props.chatterLocalId);
+ }
+
+ //--------------------------------------------------------------------------
+ // Handlers
+ //--------------------------------------------------------------------------
+
+ /**
+ * @private
+ */
+ _onClickTitle() {
+ this.chatter.toggleActivityBoxVisibility();
+ }
+
+}
+
+Object.assign(ActivityBox, {
+ components,
+ props: {
+ chatterLocalId: String,
+ },
+ template: 'mail.ActivityBox',
+});
+
+return ActivityBox;
+
+});
diff --git a/addons/mail/static/src/components/activity_box/activity_box.scss b/addons/mail/static/src/components/activity_box/activity_box.scss
new file mode 100644
index 00000000000..056f624c04f
--- /dev/null
+++ b/addons/mail/static/src/components/activity_box/activity_box.scss
@@ -0,0 +1,41 @@
+// ------------------------------------------------------------------
+// Layout
+// ------------------------------------------------------------------
+
+.o_ActivityBox_title {
+ display: flex;
+ align-items: center;
+ flex: 0 0 auto;
+ margin-top: map-get($spacers, 4);
+ margin-bottom: map-get($spacers, 4);
+}
+
+.o_ActivityBox_titleBadge {
+ padding: map-get($spacers, 0) map-get($spacers, 2);
+}
+
+.o_ActivityBox_titleLine {
+ flex: 1 1 auto;
+ width: auto;
+}
+
+.o_ActivityBox_titleText {
+ padding: map-get($spacers, 0) map-get($spacers, 3);
+}
+
+// ------------------------------------------------------------------
+// Style
+// ------------------------------------------------------------------
+
+.o_ActivityBox_title {
+ font-weight: bold;
+}
+
+.o_ActivityBox_titleBadge {
+ font-size: 11px;
+}
+
+.o_ActivityBox_titleLine {
+ border-color: gray('400');
+ border-style: dashed;
+}
diff --git a/addons/mail/static/src/components/activity_box/activity_box.xml b/addons/mail/static/src/components/activity_box/activity_box.xml
new file mode 100644
index 00000000000..2538ac9a462
--- /dev/null
+++ b/addons/mail/static/src/components/activity_box/activity_box.xml
@@ -0,0 +1,45 @@
+<?xml version="1.0" encoding="UTF-8"?>
+<templates xml:space="preserve">
+
+ <t t-name="mail.ActivityBox" owl="1">
+ <div class="o_ActivityBox">
+ <t t-if="chatter">
+ <a role="button" class="o_ActivityBox_title btn" t-on-click="_onClickTitle">
+ <hr class="o_ActivityBox_titleLine" />
+ <span class="o_ActivityBox_titleText">
+ <i class="fa fa-fw" t-att-class="chatter.isActivityBoxVisible ? 'fa-caret-right' : 'fa-caret-down'"/>
+ Planned activities
+ </span>
+ <t t-if="!chatter.isActivityBoxVisible">
+ <span class="o_ActivityBox_titleBadges">
+ <t t-if="chatter.overdueActivities.length > 0">
+ <span class="o_ActivityBox_titleBadge badge rounded-circle badge-danger">
+ <t t-esc="chatter.overdueActivities.length"/>
+ </span>
+ </t>
+ <t t-if="chatter.todayActivities.length > 0">
+ <span class="o_ActivityBox_titleBadge badge rounded-circle badge-warning">
+ <t t-esc="chatter.todayActivities.length"/>
+ </span>
+ </t>
+ <t t-if="chatter.futureActivities.length > 0">
+ <span class="o_ActivityBox_titleBadge badge rounded-circle badge-success">
+ <t t-esc="chatter.futureActivities.length"/>
+ </span>
+ </t>
+ </span>
+ </t>
+ <hr class="o_ActivityBox_titleLine" />
+ </a>
+ <t t-if="chatter.isActivityBoxVisible">
+ <div class="o_ActivityList">
+ <t t-foreach="chatter.activities" t-as="activity" t-key="activity.localId">
+ <Activity class="o_ActivityBox_activity" activityLocalId="activity.localId"/>
+ </t>
+ </div>
+ </t>
+ </t>
+ </div>
+ </t>
+
+</templates>
diff --git a/addons/mail/static/src/components/activity_mark_done_popover/activity_mark_done_popover.js b/addons/mail/static/src/components/activity_mark_done_popover/activity_mark_done_popover.js
new file mode 100644
index 00000000000..03e8f2ea54e
--- /dev/null
+++ b/addons/mail/static/src/components/activity_mark_done_popover/activity_mark_done_popover.js
@@ -0,0 +1,83 @@
+odoo.define('mail/static/src/components/activity_mark_done_popover/activity_mark_done_popover.js', function (require) {
+'use strict';
+
+const useStore = require('mail/static/src/component_hooks/use_store/use_store.js');
+
+const { Component } = owl;
+const { useRef } = owl.hooks;
+
+class ActivityMarkDonePopover extends Component {
+
+ /**
+ * @override
+ */
+ constructor(...args) {
+ super(...args);
+ useStore(props => {
+ const activity = this.env.models['mail.activity'].get(props.activityLocalId);
+ return {
+ activity: activity ? activity.__state : undefined,
+ };
+ });
+ this._feedbackTextareaRef = useRef('feedbackTextarea');
+ }
+
+ //--------------------------------------------------------------------------
+ // Public
+ //--------------------------------------------------------------------------
+
+ /**
+ * @returns {mail.activity}
+ */
+ get activity() {
+ return this.env.models['mail.activity'].get(this.props.activityLocalId);
+ }
+
+ /**
+ * @returns {string}
+ */
+ get DONE_AND_SCHEDULE_NEXT() {
+ return this.env._t("Done & Schedule Next");
+ }
+
+ //--------------------------------------------------------------------------
+ // Handlers
+ //--------------------------------------------------------------------------
+
+ /**
+ * @private
+ */
+ _onClickDiscard() {
+ this.trigger('o-popover-close');
+ }
+
+ /**
+ * @private
+ */
+ _onClickDone() {
+ this.activity.markAsDone({
+ feedback: this._feedbackTextareaRef.el.value,
+ });
+ }
+
+ /**
+ * @private
+ */
+ _onClickDoneAndScheduleNext() {
+ this.activity.markAsDoneAndScheduleNext({
+ feedback: this._feedbackTextareaRef.el.value,
+ });
+ }
+
+}
+
+Object.assign(ActivityMarkDonePopover, {
+ props: {
+ activityLocalId: String,
+ },
+ template: 'mail.ActivityMarkDonePopover',
+});
+
+return ActivityMarkDonePopover;
+
+});
diff --git a/addons/mail/static/src/components/activity_mark_done_popover/activity_mark_done_popover.scss b/addons/mail/static/src/components/activity_mark_done_popover/activity_mark_done_popover.scss
new file mode 100644
index 00000000000..1e15fa086c1
--- /dev/null
+++ b/addons/mail/static/src/components/activity_mark_done_popover/activity_mark_done_popover.scss
@@ -0,0 +1,22 @@
+// ------------------------------------------------------------------
+// Layout
+// ------------------------------------------------------------------
+
+.o_ActivityMarkDonePopover {
+ align-content: space-between;
+ display: flex;
+ flex-flow: row wrap;
+ min-height: 100px;
+}
+
+.o_ActivityMarkDonePopover_buttons {
+ display: flex;
+ flex: 1;
+ justify-content: space-between;
+ margin-top: map-get($spacers, 2);
+}
+
+.o_ActivityMarkDonePopover_feedback {
+ min-height: 70px;
+}
+
diff --git a/addons/mail/static/src/components/activity_mark_done_popover/activity_mark_done_popover.xml b/addons/mail/static/src/components/activity_mark_done_popover/activity_mark_done_popover.xml
new file mode 100644
index 00000000000..2cd22e993ec
--- /dev/null
+++ b/addons/mail/static/src/components/activity_mark_done_popover/activity_mark_done_popover.xml
@@ -0,0 +1,23 @@
+<?xml version="1.0" encoding="UTF-8"?>
+<templates xml:space="preserve">
+
+ <t t-name="mail.ActivityMarkDonePopover" owl="1">
+ <div class="o_ActivityMarkDonePopover">
+ <t t-if="activity">
+ <textarea class="form-control o_ActivityMarkDonePopover_feedback" rows="3" placeholder="Write Feedback" t-ref="feedbackTextarea"/>
+ <div class="o_ActivityMarkDonePopover_buttons">
+ <button type="button" class="o_ActivityMarkDonePopover_doneScheduleNextButton btn btn-sm btn-primary" t-on-click="_onClickDoneAndScheduleNext" t-esc="DONE_AND_SCHEDULE_NEXT"/>
+ <t t-if="!activity.force_next">
+ <button type="button" class="o_ActivityMarkDonePopover_doneButton btn btn-sm btn-primary" t-on-click="_onClickDone">
+ Done
+ </button>
+ <button type="button" class="o_ActivityMarkDonePopover_discardButton btn btn-sm btn-link" t-on-click="_onClickDiscard">
+ Discard
+ </button>
+ </t>
+ </div>
+ </t>
+ </div>
+ </t>
+
+</templates>
diff --git a/addons/mail/static/src/components/activity_mark_done_popover/activity_mark_done_popover_tests.js b/addons/mail/static/src/components/activity_mark_done_popover/activity_mark_done_popover_tests.js
new file mode 100644
index 00000000000..0c04e492f24
--- /dev/null
+++ b/addons/mail/static/src/components/activity_mark_done_popover/activity_mark_done_popover_tests.js
@@ -0,0 +1,282 @@
+odoo.define('mail/static/src/components/activity_mark_done_popover/activity_mark_done_popover_tests.js', function (require) {
+'use strict';
+
+const components = {
+ ActivityMarkDonePopover: require('mail/static/src/components/activity_mark_done_popover/activity_mark_done_popover.js'),
+};
+
+const {
+ afterEach: utilsAfterEach,
+ beforeEach: utilsBeforeEach,
+ start: utilsStart,
+} = require('mail/static/src/utils/test_utils.js');
+
+QUnit.module('mail', {}, function () {
+QUnit.module('components', {}, function () {
+QUnit.module('activity_mark_done_popover', {}, function () {
+QUnit.module('activity_mark_done_popover_tests.js', {
+ beforeEach() {
+ utilsBeforeEach(this);
+
+ this.createActivityMarkDonePopoverComponent = async activity => {
+ const ActivityMarkDonePopoverComponent = components.ActivityMarkDonePopover;
+ ActivityMarkDonePopoverComponent.env = this.env;
+ this.component = new ActivityMarkDonePopoverComponent(null, {
+ activityLocalId: activity.localId,
+ });
+ await this.component.mount(this.widget.el);
+ };
+
+ this.start = async params => {
+ let { env, widget } = await utilsStart(Object.assign({}, params, {
+ data: this.data,
+ }));
+ this.env = env;
+ this.widget = widget;
+ };
+ },
+ afterEach() {
+ utilsAfterEach(this);
+ if (this.component) {
+ this.component.destroy();
+ }
+ if (this.widget) {
+ this.widget.destroy();
+ }
+ this.env = undefined;
+ },
+});
+
+QUnit.test('activity mark done popover simplest layout', async function (assert) {
+ assert.expect(6);
+
+ await this.start();
+ const activity = this.env.models['mail.activity'].create({
+ canWrite: true,
+ category: 'not_upload_file',
+ id: 12,
+ });
+ await this.createActivityMarkDonePopoverComponent(activity);
+
+ assert.containsOnce(
+ document.body,
+ '.o_ActivityMarkDonePopover',
+ "Popover component should be present"
+ );
+ assert.containsOnce(
+ document.body,
+ '.o_ActivityMarkDonePopover_feedback',
+ "Popover component should contain the feedback textarea"
+ );
+ assert.containsOnce(
+ document.body,
+ '.o_ActivityMarkDonePopover_buttons',
+ "Popover component should contain the action buttons"
+ );
+ assert.containsOnce(
+ document.body,
+ '.o_ActivityMarkDonePopover_doneScheduleNextButton',
+ "Popover component should contain the done & schedule next button"
+ );
+ assert.containsOnce(
+ document.body,
+ '.o_ActivityMarkDonePopover_doneButton',
+ "Popover component should contain the done button"
+ );
+ assert.containsOnce(
+ document.body,
+ '.o_ActivityMarkDonePopover_discardButton',
+ "Popover component should contain the discard button"
+ );
+});
+
+QUnit.test('activity with force next mark done popover simplest layout', async function (assert) {
+ assert.expect(6);
+
+ await this.start();
+ const activity = this.env.models['mail.activity'].create({
+ canWrite: true,
+ category: 'not_upload_file',
+ force_next: true,
+ id: 12,
+ });
+ await this.createActivityMarkDonePopoverComponent(activity);
+
+ assert.containsOnce(
+ document.body,
+ '.o_ActivityMarkDonePopover',
+ "Popover component should be present"
+ );
+ assert.containsOnce(
+ document.body,
+ '.o_ActivityMarkDonePopover_feedback',
+ "Popover component should contain the feedback textarea"
+ );
+ assert.containsOnce(
+ document.body,
+ '.o_ActivityMarkDonePopover_buttons',
+ "Popover component should contain the action buttons"
+ );
+ assert.containsOnce(
+ document.body,
+ '.o_ActivityMarkDonePopover_doneScheduleNextButton',
+ "Popover component should contain the done & schedule next button"
+ );
+ assert.containsNone(
+ document.body,
+ '.o_ActivityMarkDonePopover_doneButton',
+ "Popover component should NOT contain the done button"
+ );
+ assert.containsNone(
+ document.body,
+ '.o_ActivityMarkDonePopover_discardButton',
+ "Popover component should NOT contain the discard button"
+ );
+});
+
+QUnit.test('activity mark done popover click on discard', async function (assert) {
+ assert.expect(4);
+
+ await this.start();
+ const activity = this.env.models['mail.activity'].create({
+ canWrite: true,
+ category: 'not_upload_file',
+ id: 12,
+ });
+ await this.createActivityMarkDonePopoverComponent(activity);
+ function onPopoverClose(ev) {
+ assert.step('event_triggered');
+ }
+ document.addEventListener('o-popover-close', onPopoverClose);
+
+ assert.containsOnce(
+ document.body,
+ '.o_ActivityMarkDonePopover',
+ "Popover component should be present"
+ );
+ assert.containsOnce(
+ document.body,
+ '.o_ActivityMarkDonePopover_discardButton',
+ "Popover component should contain the discard button"
+ );
+ document.querySelector('.o_ActivityMarkDonePopover_discardButton').click();
+ assert.verifySteps(['event_triggered'], 'Discard clicked should trigger the right event');
+
+ document.removeEventListener('o-popover-close', onPopoverClose);
+});
+
+QUnit.test('activity mark done popover mark done without feedback', async function (assert) {
+ assert.expect(7);
+
+ await this.start({
+ async mockRPC(route, args) {
+ if (route === '/web/dataset/call_kw/mail.activity/action_feedback') {
+ assert.step('action_feedback');
+ assert.strictEqual(args.args.length, 1);
+ assert.strictEqual(args.args[0].length, 1);
+ assert.strictEqual(args.args[0][0], 12);
+ assert.strictEqual(args.kwargs.attachment_ids.length, 0);
+ assert.notOk(args.kwargs.feedback);
+ return;
+ }
+ if (route === '/web/dataset/call_kw/mail.activity/unlink') {
+ // 'unlink' on non-existing record raises a server crash
+ throw new Error("'unlink' RPC on activity must not be called (already unlinked from mark as done)");
+ }
+ return this._super(...arguments);
+ },
+ });
+ const activity = this.env.models['mail.activity'].create({
+ canWrite: true,
+ category: 'not_upload_file',
+ id: 12,
+ });
+ await this.createActivityMarkDonePopoverComponent(activity);
+
+ document.querySelector('.o_ActivityMarkDonePopover_doneButton').click();
+ assert.verifySteps(
+ ['action_feedback'],
+ "Mark done and schedule next button should call the right rpc"
+ );
+});
+
+QUnit.test('activity mark done popover mark done with feedback', async function (assert) {
+ assert.expect(7);
+
+ await this.start({
+ async mockRPC(route, args) {
+ if (route === '/web/dataset/call_kw/mail.activity/action_feedback') {
+ assert.step('action_feedback');
+ assert.strictEqual(args.args.length, 1);
+ assert.strictEqual(args.args[0].length, 1);
+ assert.strictEqual(args.args[0][0], 12);
+ assert.strictEqual(args.kwargs.attachment_ids.length, 0);
+ assert.strictEqual(args.kwargs.feedback, 'This task is done');
+ return;
+ }
+ if (route === '/web/dataset/call_kw/mail.activity/unlink') {
+ // 'unlink' on non-existing record raises a server crash
+ throw new Error("'unlink' RPC on activity must not be called (already unlinked from mark as done)");
+ }
+ return this._super(...arguments);
+ },
+ });
+ const activity = this.env.models['mail.activity'].create({
+ canWrite: true,
+ category: 'not_upload_file',
+ id: 12,
+ });
+ await this.createActivityMarkDonePopoverComponent(activity);
+
+ let feedbackTextarea = document.querySelector('.o_ActivityMarkDonePopover_feedback');
+ feedbackTextarea.focus();
+ document.execCommand('insertText', false, 'This task is done');
+ document.querySelector('.o_ActivityMarkDonePopover_doneButton').click();
+ assert.verifySteps(
+ ['action_feedback'],
+ "Mark done and schedule next button should call the right rpc"
+ );
+});
+
+QUnit.test('activity mark done popover mark done and schedule next', async function (assert) {
+ assert.expect(6);
+
+ await this.start({
+ async mockRPC(route, args) {
+ if (route === '/web/dataset/call_kw/mail.activity/action_feedback_schedule_next') {
+ assert.step('action_feedback_schedule_next');
+ assert.strictEqual(args.args.length, 1);
+ assert.strictEqual(args.args[0].length, 1);
+ assert.strictEqual(args.args[0][0], 12);
+ assert.strictEqual(args.kwargs.feedback, 'This task is done');
+ return;
+ }
+ if (route === '/web/dataset/call_kw/mail.activity/unlink') {
+ // 'unlink' on non-existing record raises a server crash
+ throw new Error("'unlink' RPC on activity must not be called (already unlinked from mark as done)");
+ }
+ return this._super(...arguments);
+ },
+ });
+ const activity = this.env.models['mail.activity'].create({
+ canWrite: true,
+ category: 'not_upload_file',
+ id: 12,
+ });
+ await this.createActivityMarkDonePopoverComponent(activity);
+
+ let feedbackTextarea = document.querySelector('.o_ActivityMarkDonePopover_feedback');
+ feedbackTextarea.focus();
+ document.execCommand('insertText', false, 'This task is done');
+ document.querySelector('.o_ActivityMarkDonePopover_doneScheduleNextButton').click();
+ assert.verifySteps(
+ ['action_feedback_schedule_next'],
+ "Mark done and schedule next button should call the right rpc"
+ );
+});
+
+});
+});
+});
+
+});
diff --git a/addons/mail/static/src/components/attachment/attachment.js b/addons/mail/static/src/components/attachment/attachment.js
new file mode 100644
index 00000000000..76922ed7a3c
--- /dev/null
+++ b/addons/mail/static/src/components/attachment/attachment.js
@@ -0,0 +1,162 @@
+odoo.define('mail/static/src/components/attachment/attachment.js', function (require) {
+'use strict';
+
+const useStore = require('mail/static/src/component_hooks/use_store/use_store.js');
+
+const { Component } = owl;
+
+class Attachment extends Component {
+
+ /**
+ * @override
+ */
+ constructor(...args) {
+ super(...args);
+ useStore(props => {
+ const attachment = this.env.models['mail.attachment'].get(props.attachmentLocalId);
+ return {
+ attachment: attachment ? attachment.__state : undefined,
+ };
+ });
+ }
+
+ //--------------------------------------------------------------------------
+ // Public
+ //--------------------------------------------------------------------------
+
+ /**
+ * @returns {mail.attachment}
+ */
+ get attachment() {
+ return this.env.models['mail.attachment'].get(this.props.attachmentLocalId);
+ }
+
+ /**
+ * Return the url of the attachment. Temporary attachments, a.k.a. uploading
+ * attachments, do not have an url.
+ *
+ * @returns {string}
+ */
+ get attachmentUrl() {
+ if (this.attachment.isTemporary) {
+ return '';
+ }
+ return this.env.session.url('/web/content', {
+ id: this.attachment.id,
+ download: true,
+ });
+ }
+
+ /**
+ * Get the details mode after auto mode is computed
+ *
+ * @returns {string} 'card', 'hover' or 'none'
+ */
+ get detailsMode() {
+ if (this.props.detailsMode !== 'auto') {
+ return this.props.detailsMode;
+ }
+ if (this.attachment.fileType !== 'image') {
+ return 'card';
+ }
+ return 'hover';
+ }
+
+ /**
+ * Get the attachment representation style to be applied
+ *
+ * @returns {string}
+ */
+ get imageStyle() {
+ if (this.attachment.fileType !== 'image') {
+ return '';
+ }
+ let size;
+ if (this.detailsMode === 'card') {
+ size = '38x38';
+ } else {
+ size = '160x160';
+ }
+ return `background-image:url(/web/image/${this.attachment.id}/${size}/?crop=true);`;
+ }
+
+ //--------------------------------------------------------------------------
+ // Handlers
+ //--------------------------------------------------------------------------
+
+ /**
+ * Download the attachment when clicking on donwload icon.
+ *
+ * @private
+ * @param {MouseEvent} ev
+ */
+ _onClickDownload(ev) {
+ ev.stopPropagation();
+ window.location = `/web/content/ir.attachment/${this.attachment.id}/datas?download=true`;
+ }
+
+ /**
+ * Open the attachment viewer when clicking on viewable attachment.
+ *
+ * @private
+ * @param {MouseEvent} ev
+ */
+ _onClickImage(ev) {
+ if (!this.attachment.isViewable) {
+ return;
+ }
+ this.env.models['mail.attachment'].view({
+ attachment: this.attachment,
+ attachments: this.props.attachmentLocalIds.map(
+ attachmentLocalId => this.env.models['mail.attachment'].get(attachmentLocalId)
+ ),
+ });
+ }
+
+ /**
+ * @private
+ * @param {MouseEvent} ev
+ */
+ _onClickUnlink(ev) {
+ ev.stopPropagation();
+ this.attachment.remove();
+ this.trigger('o-attachment-removed');
+ }
+
+}
+
+Object.assign(Attachment, {
+ defaultProps: {
+ attachmentLocalIds: [],
+ detailsMode: 'auto',
+ imageSize: 'medium',
+ isDownloadable: false,
+ isEditable: true,
+ showExtension: true,
+ showFilename: true,
+ },
+ props: {
+ attachmentLocalId: String,
+ attachmentLocalIds: {
+ type: Array,
+ element: String,
+ },
+ detailsMode: {
+ type: String,
+ validate: prop => ['auto', 'card', 'hover', 'none'].includes(prop),
+ },
+ imageSize: {
+ type: String,
+ validate: prop => ['small', 'medium', 'large'].includes(prop),
+ },
+ isDownloadable: Boolean,
+ isEditable: Boolean,
+ showExtension: Boolean,
+ showFilename: Boolean,
+ },
+ template: 'mail.Attachment',
+});
+
+return Attachment;
+
+});
diff --git a/addons/mail/static/src/components/attachment/attachment.scss b/addons/mail/static/src/components/attachment/attachment.scss
new file mode 100644
index 00000000000..aebe44c82ba
--- /dev/null
+++ b/addons/mail/static/src/components/attachment/attachment.scss
@@ -0,0 +1,196 @@
+// ------------------------------------------------------------------
+// Layout
+// ------------------------------------------------------------------
+
+.o_Attachment {
+ display: flex;
+
+ &:hover .o_Attachment_asideItemUnlink.o-pretty {
+ transform: translateX(0);
+ }
+}
+
+.o_Attachment_action {
+ min-width: 20px;
+}
+
+.o_Attachment_actions {
+ justify-content: space-between;
+ display: flex;
+ flex-direction: column;
+}
+
+.o_Attachment_aside {
+ position: relative;
+ overflow: hidden;
+
+ &:not(.o-has-multiple-action) {
+ min-width: 50px;
+ }
+
+ &.o-has-multiple-action {
+ min-width: 30px;
+ display: flex;
+ flex-direction: column;
+ }
+}
+
+.o_Attachment_asideItem {
+ display: flex;
+ width: 100%;
+ height: 100%;
+ align-items: center;
+ justify-content: center;
+}
+
+.o_Attachment_asideItemUnlink.o-pretty {
+ position: absolute;
+ top: 0;
+ transform: translateX(100%);
+}
+
+.o_Attachment_details {
+ min-width: 0; /* This allows the text ellipsis in the flex element */
+}
+
+.o_Attachment_filename {
+ overflow: hidden;
+ text-overflow: ellipsis;
+ white-space: nowrap;
+}
+
+.o_Attachment_image {
+ margin: 3px;
+
+ &.o-details-overlay {
+ position: relative;
+
+ &.o-small {
+ min-width: 100px;
+ min-height: 100px;
+ }
+ &.o-medium {
+ min-width: 200px;
+ min-height: 200px;
+ }
+ &.o-large {
+ min-width: 400px;
+ min-height: 400px;
+ }
+
+ &:hover {
+ .o_Attachment_imageOverlay {
+ opacity: 1;
+ }
+ }
+ }
+}
+
+.o_Attachment_imageOverlay {
+ bottom: 0;
+ display:flex;
+ flex-direction: row;
+ justify-content: flex-end;
+ left: 0;
+ padding: 10px;
+ position: absolute;
+ right: 0;
+ top: 0;
+}
+
+.o_Attachment_imageOverlayDetails {
+ display: flex;
+ flex-direction: column;
+ justify-content: flex-end;
+ margin: 3px;
+ width: 200px;
+}
+
+
+// ------------------------------------------------------------------
+// Style
+// ------------------------------------------------------------------
+
+.o_Attachment {
+ &.o-has-card-details {
+ background-color: gray('300');
+ border-radius: 5px;
+ }
+}
+
+.o_Attachment_action {
+ border-radius: 10px;
+ cursor: pointer;
+ text-align: center;
+
+ &:hover {
+ background: rgba(255, 255, 255, 0.2);
+ }
+}
+
+.o_Attachment_aside {
+ border-radius: 0 5px 5px 0;
+}
+
+.o_Attachment_asideItemDownload {
+ cursor: pointer;
+
+ &:hover {
+ background-color: gray('400');
+ }
+}
+
+.o_Attachment_asideItemUnlink {
+ cursor: pointer;
+
+ &:not(.o-pretty):hover {
+ background-color: gray('400');
+ }
+
+ &.o-pretty {
+ color: white;
+ background-color: $o-brand-primary;
+
+ &:hover {
+ background-color: darken($o-brand-primary, 10%);
+ }
+ }
+
+}
+
+.o_Attachment_asideItemUploaded {
+ color: $o-brand-primary;
+}
+
+.o_Attachment_extension {
+ text-transform: uppercase;
+ font-size: 80%;
+ font-weight: 400;
+}
+
+.o_Attachment_image.o-attachment-viewable {
+ cursor: zoom-in;
+
+ &:not(.o-details-overlay):hover {
+ opacity: 0.7;
+ }
+}
+
+.o_Attachment_imageOverlay {
+ background-image: linear-gradient(180deg, rgba(0, 0, 0, 0.2), rgba(0, 0, 0, 0.9));
+ border-radius: 5px;
+ color: white;
+ opacity: 0;
+}
+
+// ------------------------------------------------------------------
+// Animation
+// ------------------------------------------------------------------
+
+.o_Attachment_asideItemUnlink.o-pretty {
+ transition: transform 0.3s ease 0s;
+}
+
+.o_Attachment_imageOverlay {
+ transition: all 0.3s ease 0s;
+}
diff --git a/addons/mail/static/src/components/attachment/attachment.xml b/addons/mail/static/src/components/attachment/attachment.xml
new file mode 100644
index 00000000000..8af00288b8b
--- /dev/null
+++ b/addons/mail/static/src/components/attachment/attachment.xml
@@ -0,0 +1,109 @@
+<?xml version="1.0" encoding="UTF-8"?>
+<templates xml:space="preserve">
+
+ <t t-name="mail.Attachment" owl="1">
+ <div class="o_Attachment"
+ t-att-class="{
+ 'o-downloadable': props.isDownloadable,
+ 'o-editable': props.isEditable,
+ 'o-has-card-details': detailsMode === 'card',
+ 'o-temporary': attachment and attachment.isTemporary,
+ 'o-viewable': attachment and attachment.isViewable,
+ }" t-att-title="attachment ? attachment.displayName : undefined" t-att-data-attachment-local-id="attachment ? attachment.localId : undefined"
+ >
+ <t t-if="attachment">
+ <!-- Image style-->
+ <!-- o_image from mimetype.scss -->
+ <div class="o_Attachment_image o_image" t-on-click="_onClickImage"
+ t-att-class="{
+ 'o-attachment-viewable': attachment.isViewable,
+ 'o-details-overlay': detailsMode !== 'card',
+ 'o-large': props.imageSize === 'large',
+ 'o-medium': props.imageSize === 'medium',
+ 'o-small': props.imageSize === 'small',
+ }" t-att-href="attachmentUrl" t-att-style="imageStyle" t-att-data-mimetype="attachment.mimetype"
+ >
+ <t t-if="(props.showFilename or props.showExtension) and detailsMode === 'hover'">
+ <div class="o_Attachment_imageOverlay">
+ <div class="o_Attachment_details o_Attachment_imageOverlayDetails">
+ <t t-if="props.showFilename">
+ <div class="o_Attachment_filename">
+ <t t-esc="attachment.displayName"/>
+ </div>
+ </t>
+ <t t-if="props.showExtension">
+ <div class="o_Attachment_extension">
+ <t t-esc="attachment.extension"/>
+ </div>
+ </t>
+ </div>
+ <div class="o_Attachment_actions">
+ <!-- Remove button -->
+ <t t-if="props.isEditable and !attachment.isTemporary" t-key="'unlink'">
+ <div class="o_Attachment_action o_Attachment_actionUnlink"
+ t-att-class="{
+ 'o-pretty': attachment.isLinkedToComposer,
+ }" t-on-click="_onClickUnlink" title="Remove"
+ >
+ <i class="fa fa-times"/>
+ </div>
+ </t>
+ <!-- Download button -->
+ <t t-if="props.isDownloadable and !attachment.isTemporary" t-key="'download'">
+ <div class="o_Attachment_action o_Attachment_actionDownload" t-on-click="_onClickDownload" title="Download">
+ <i class="fa fa-download"/>
+ </div>
+ </t>
+ </div>
+ </div>
+ </t>
+ </div>
+ <!-- Attachment details -->
+ <t t-if="(props.showFilename or props.showExtension) and detailsMode === 'card'">
+ <div class="o_Attachment_details">
+ <t t-if="props.showFilename">
+ <div class="o_Attachment_filename">
+ <t t-esc="attachment.displayName"/>
+ </div>
+ </t>
+ <t t-if="props.showExtension">
+ <div class="o_Attachment_extension">
+ <t t-esc="attachment.extension"/>
+ </div>
+ </t>
+ </div>
+ </t>
+ <!-- Attachment aside -->
+ <t t-if="detailsMode !== 'hover' and (props.isDownloadable or props.isEditable)">
+ <div class="o_Attachment_aside" t-att-class="{ 'o-has-multiple-action': !attachment.isTemporary and props.isDownloadable and props.isEditable }">
+ <!-- Uploading icon -->
+ <t t-if="attachment.isTemporary and attachment.isLinkedToComposer">
+ <div class="o_Attachment_asideItem o_Attachment_asideItemUploading" title="Uploading">
+ <i class="fa fa-spin fa-spinner"/>
+ </div>
+ </t>
+ <!-- Uploaded icon -->
+ <t t-if="!attachment.isTemporary and attachment.isLinkedToComposer">
+ <div class="o_Attachment_asideItem o_Attachment_asideItemUploaded" title="Uploaded">
+ <i class="fa fa-check"/>
+ </div>
+ </t>
+ <!-- Remove button -->
+ <t t-if="props.isEditable and !attachment.isTemporary">
+ <div class="o_Attachment_asideItem o_Attachment_asideItemUnlink" t-att-class="{ 'o-pretty': attachment.isLinkedToComposer }" t-on-click="_onClickUnlink" title="Remove">
+ <i class="fa fa-times"/>
+ </div>
+ </t>
+ <!-- Download button -->
+ <t t-if="props.isDownloadable and !attachment.isTemporary">
+ <div class="o_Attachment_asideItem o_Attachment_asideItemDownload" t-on-click="_onClickDownload" title="Download">
+ <i class="fa fa-download"/>
+ </div>
+ </t>
+ </div>
+ </t>
+ </t>
+ </div>
+ </t>
+
+</templates>
diff --git a/addons/mail/static/src/components/attachment/attachment_tests.js b/addons/mail/static/src/components/attachment/attachment_tests.js
new file mode 100644
index 00000000000..224a80a30e7
--- /dev/null
+++ b/addons/mail/static/src/components/attachment/attachment_tests.js
@@ -0,0 +1,545 @@
+odoo.define('mail/static/src/components/attachment/attachment_tests.js', function (require) {
+'use strict';
+
+const components = {
+ Attachment: require('mail/static/src/components/attachment/attachment.js'),
+};
+const {
+ afterEach: utilsAfterEach,
+ beforeEach: utilsBeforeEach,
+ start: utilsStart,
+} = require('mail/static/src/utils/test_utils.js');
+
+QUnit.module('mail', {}, function () {
+QUnit.module('components', {}, function () {
+QUnit.module('attachment', {}, function () {
+QUnit.module('attachment_tests.js', {
+ beforeEach() {
+ utilsBeforeEach(this);
+
+ this.createAttachmentComponent = async (attachment, otherProps) => {
+ const AttachmentComponent = components.Attachment;
+ AttachmentComponent.env = this.env;
+ this.component = new AttachmentComponent(null, Object.assign({
+ attachmentLocalId: attachment.localId,
+ }, otherProps));
+ await this.component.mount(this.widget.el);
+ };
+
+ 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);
+ if (this.component) {
+ this.component.destroy();
+ }
+ if (this.widget) {
+ this.widget.destroy();
+ }
+ this.env = undefined;
+ delete components.Attachment.env;
+ },
+});
+
+QUnit.test('simplest layout', async function (assert) {
+ assert.expect(8);
+
+ await this.start();
+ const attachment = this.env.models['mail.attachment'].create({
+ filename: "test.txt",
+ id: 750,
+ mimetype: 'text/plain',
+ name: "test.txt",
+ });
+ await this.createAttachmentComponent(attachment, {
+ detailsMode: 'none',
+ isDownloadable: false,
+ isEditable: false,
+ showExtension: false,
+ showFilename: false,
+ });
+ assert.strictEqual(
+ document.querySelectorAll('.o_Attachment').length,
+ 1,
+ "should have attachment component in DOM"
+ );
+ const attachmentEl = document.querySelector('.o_Attachment');
+ assert.strictEqual(
+ attachmentEl.dataset.attachmentLocalId,
+ this.env.models['mail.attachment'].find(attachment => attachment.id === 750).localId,
+ "attachment component should be linked to attachment store model"
+ );
+ assert.strictEqual(
+ attachmentEl.title,
+ "test.txt",
+ "attachment should have filename as title attribute"
+ );
+ assert.strictEqual(
+ attachmentEl.querySelectorAll(`:scope .o_Attachment_image`).length,
+ 1,
+ "attachment should have an image part"
+ );
+ const attachmentImage = document.querySelector(`.o_Attachment_image`);
+ assert.ok(
+ attachmentImage.classList.contains('o_image'),
+ "attachment should have o_image classname (required for mimetype.scss style)"
+ );
+ assert.strictEqual(
+ attachmentImage.dataset.mimetype,
+ 'text/plain',
+ "attachment should have data-mimetype set (required for mimetype.scss style)"
+ );
+ assert.strictEqual(
+ document.querySelectorAll(`.o_Attachment_details`).length,
+ 0,
+ "attachment should not have a details part"
+ );
+ assert.strictEqual(
+ document.querySelectorAll(`.o_Attachment_aside`).length,
+ 0,
+ "attachment should not have an aside part"
+ );
+});
+
+QUnit.test('simplest layout + deletable', async function (assert) {
+ assert.expect(6);
+
+ await this.start({
+ async mockRPC(route, args) {
+ if (route.includes('web/image/750')) {
+ assert.ok(
+ route.includes('/160x160'),
+ "should fetch image with 160x160 pixels ratio");
+ assert.step('fetch_image');
+ return;
+ }
+ return this._super(...arguments);
+ },
+ });
+ const attachment = this.env.models['mail.attachment'].create({
+ filename: "test.txt",
+ id: 750,
+ mimetype: 'text/plain',
+ name: "test.txt",
+ });
+ await this.createAttachmentComponent(attachment, {
+ detailsMode: 'none',
+ isDownloadable: false,
+ isEditable: true,
+ showExtension: false,
+ showFilename: false
+ });
+ assert.strictEqual(
+ document.querySelectorAll('.o_Attachment').length,
+ 1,
+ "should have attachment component in DOM"
+ );
+ assert.strictEqual(
+ document.querySelectorAll(`.o_Attachment_image`).length,
+ 1,
+ "attachment should have an image part"
+ );
+ assert.strictEqual(
+ document.querySelectorAll(`.o_Attachment_details`).length,
+ 0,
+ "attachment should not have a details part"
+ );
+ assert.strictEqual(
+ document.querySelectorAll(`.o_Attachment_aside`).length,
+ 1,
+ "attachment should have an aside part"
+ );
+ assert.strictEqual(
+ document.querySelectorAll(`.o_Attachment_asideItem`).length,
+ 1,
+ "attachment should have only one aside item"
+ );
+ assert.strictEqual(
+ document.querySelectorAll(`.o_Attachment_asideItemUnlink`).length,
+ 1,
+ "attachment should have a delete button"
+ );
+});
+
+QUnit.test('simplest layout + downloadable', async function (assert) {
+ assert.expect(6);
+
+ await this.start();
+ const attachment = this.env.models['mail.attachment'].create({
+ filename: "test.txt",
+ id: 750,
+ mimetype: 'text/plain',
+ name: "test.txt",
+ });
+ await this.createAttachmentComponent(attachment, {
+ detailsMode: 'none',
+ isDownloadable: true,
+ isEditable: false,
+ showExtension: false,
+ showFilename: false
+ });
+ assert.strictEqual(
+ document.querySelectorAll('.o_Attachment').length,
+ 1,
+ "should have attachment component in DOM"
+ );
+ assert.strictEqual(
+ document.querySelectorAll(`.o_Attachment_image`).length,
+ 1,
+ "attachment should have an image part"
+ );
+ assert.strictEqual(
+ document.querySelectorAll(`.o_Attachment_details`).length,
+ 0,
+ "attachment should not have a details part"
+ );
+ assert.strictEqual(
+ document.querySelectorAll(`.o_Attachment_aside`).length,
+ 1,
+ "attachment should have an aside part"
+ );
+ assert.strictEqual(
+ document.querySelectorAll(`.o_Attachment_asideItem`).length,
+ 1,
+ "attachment should have only one aside item"
+ );
+ assert.strictEqual(
+ document.querySelectorAll(`.o_Attachment_asideItemDownload`).length,
+ 1,
+ "attachment should have a download button"
+ );
+});
+
+QUnit.test('simplest layout + deletable + downloadable', async function (assert) {
+ assert.expect(8);
+
+ await this.start();
+ const attachment = this.env.models['mail.attachment'].create({
+ filename: "test.txt",
+ id: 750,
+ mimetype: 'text/plain',
+ name: "test.txt",
+ });
+ await this.createAttachmentComponent(attachment, {
+ detailsMode: 'none',
+ isDownloadable: true,
+ isEditable: true,
+ showExtension: false,
+ showFilename: false
+ });
+ assert.strictEqual(
+ document.querySelectorAll('.o_Attachment').length,
+ 1,
+ "should have attachment component in DOM"
+ );
+ assert.strictEqual(
+ document.querySelectorAll(`.o_Attachment_image`).length,
+ 1,
+ "attachment should have an image part"
+ );
+ assert.strictEqual(
+ document.querySelectorAll(`.o_Attachment_details`).length,
+ 0,
+ "attachment should not have a details part"
+ );
+ assert.strictEqual(
+ document.querySelectorAll(`.o_Attachment_aside`).length,
+ 1,
+ "attachment should have an aside part"
+ );
+ assert.ok(
+ document.querySelector(`.o_Attachment_aside`).classList.contains('o-has-multiple-action'),
+ "attachment aside should contain multiple actions"
+ );
+ assert.strictEqual(
+ document.querySelectorAll(`.o_Attachment_asideItem`).length,
+ 2,
+ "attachment should have only two aside items"
+ );
+ assert.strictEqual(
+ document.querySelectorAll(`.o_Attachment_asideItemDownload`).length,
+ 1,
+ "attachment should have a download button"
+ );
+ assert.strictEqual(
+ document.querySelectorAll(`.o_Attachment_asideItemUnlink`).length,
+ 1,
+ "attachment should have a delete button"
+ );
+});
+
+QUnit.test('layout with card details', async function (assert) {
+ assert.expect(3);
+
+ await this.start();
+ const attachment = this.env.models['mail.attachment'].create({
+ filename: "test.txt",
+ id: 750,
+ mimetype: 'text/plain',
+ name: "test.txt",
+ });
+ await this.createAttachmentComponent(attachment, {
+ detailsMode: 'card',
+ isDownloadable: false,
+ isEditable: false,
+ showExtension: false,
+ showFilename: false
+ });
+ assert.strictEqual(
+ document.querySelectorAll(`.o_Attachment_image`).length,
+ 1,
+ "attachment should have an image part"
+ );
+ assert.strictEqual(
+ document.querySelectorAll(`.o_Attachment_details`).length,
+ 0,
+ "attachment should not have a details part"
+ );
+ assert.strictEqual(
+ document.querySelectorAll(`.o_Attachment_aside`).length,
+ 0,
+ "attachment should not have an aside part"
+ );
+});
+
+QUnit.test('layout with card details and filename', async function (assert) {
+ assert.expect(3);
+
+ await this.start();
+ const attachment = this.env.models['mail.attachment'].create({
+ filename: "test.txt",
+ id: 750,
+ mimetype: 'text/plain',
+ name: "test.txt",
+ });
+ await this.createAttachmentComponent(attachment, {
+ detailsMode: 'card',
+ isDownloadable: false,
+ isEditable: false,
+ showExtension: false,
+ showFilename: true
+ });
+ assert.strictEqual(
+ document.querySelectorAll(`.o_Attachment_details`).length,
+ 1,
+ "attachment should have a details part"
+ );
+ assert.strictEqual(
+ document.querySelectorAll(`.o_Attachment_filename`).length,
+ 1,
+ "attachment should not have its filename shown"
+ );
+ assert.strictEqual(
+ document.querySelectorAll(`.o_Attachment_extension`).length,
+ 0,
+ "attachment should have its extension shown"
+ );
+});
+
+QUnit.test('layout with card details and extension', async function (assert) {
+ assert.expect(3);
+
+ await this.start();
+ const attachment = this.env.models['mail.attachment'].create({
+ filename: "test.txt",
+ id: 750,
+ mimetype: 'text/plain',
+ name: "test.txt",
+ });
+ await this.createAttachmentComponent(attachment, {
+ detailsMode: 'card',
+ isDownloadable: false,
+ isEditable: false,
+ showExtension: true,
+ showFilename: false
+ });
+ assert.strictEqual(
+ document.querySelectorAll(`.o_Attachment_details`).length,
+ 1,
+ "attachment should have a details part"
+ );
+ assert.strictEqual(
+ document.querySelectorAll(`.o_Attachment_filename`).length,
+ 0,
+ "attachment should not have its filename shown"
+ );
+ assert.strictEqual(
+ document.querySelectorAll(`.o_Attachment_extension`).length,
+ 1,
+ "attachment should have its extension shown"
+ );
+});
+
+QUnit.test('layout with card details and filename and extension', async function (assert) {
+ assert.expect(3);
+
+ await this.start();
+ const attachment = this.env.models['mail.attachment'].create({
+ filename: "test.txt",
+ id: 750,
+ mimetype: 'text/plain',
+ name: "test.txt",
+ });
+ await this.createAttachmentComponent(attachment, {
+ detailsMode: 'card',
+ isDownloadable: false,
+ isEditable: false,
+ showExtension: true,
+ showFilename: true
+ });
+ assert.strictEqual(
+ document.querySelectorAll(`.o_Attachment_details`).length,
+ 1,
+ "attachment should have a details part"
+ );
+ assert.strictEqual(
+ document.querySelectorAll(`.o_Attachment_filename`).length,
+ 1,
+ "attachment should have its filename shown"
+ );
+ assert.strictEqual(
+ document.querySelectorAll(`.o_Attachment_extension`).length,
+ 1,
+ "attachment should have its extension shown"
+ );
+});
+
+QUnit.test('simplest layout with hover details and filename and extension', async function (assert) {
+ assert.expect(8);
+
+ await this.start();
+ const attachment = this.env.models['mail.attachment'].create({
+ filename: "test.txt",
+ id: 750,
+ mimetype: 'text/plain',
+ name: "test.txt",
+ });
+ await this.createAttachmentComponent(attachment, {
+ detailsMode: 'hover',
+ isDownloadable: true,
+ isEditable: true,
+ showExtension: true,
+ showFilename: true
+ });
+ assert.strictEqual(
+ document.querySelectorAll(`
+ .o_Attachment_details:not(.o_Attachment_imageOverlayDetails)
+ `).length,
+ 0,
+ "attachment should not have a details part directly"
+ );
+ assert.strictEqual(
+ document.querySelectorAll(`.o_Attachment_imageOverlayDetails`).length,
+ 1,
+ "attachment should have a details part in the overlay"
+ );
+ assert.strictEqual(
+ document.querySelectorAll(`.o_Attachment_image`).length,
+ 1,
+ "attachment should have an image part"
+ );
+ assert.strictEqual(
+ document.querySelectorAll(`.o_Attachment_imageOverlay`).length,
+ 1,
+ "attachment should have an image overlay part"
+ );
+ assert.strictEqual(
+ document.querySelectorAll(`.o_Attachment_filename`).length,
+ 1,
+ "attachment should have its filename shown"
+ );
+ assert.strictEqual(
+ document.querySelectorAll(`.o_Attachment_extension`).length,
+ 1,
+ "attachment should have its extension shown"
+ );
+ assert.strictEqual(
+ document.querySelectorAll(`.o_Attachment_actions`).length,
+ 1,
+ "attachment should have an actions part"
+ );
+ assert.strictEqual(
+ document.querySelectorAll(`.o_Attachment_aside`).length,
+ 0,
+ "attachment should not have an aside element"
+ );
+});
+
+QUnit.test('auto layout with image', async function (assert) {
+ assert.expect(7);
+
+ await this.start({
+ async mockRPC(route, args) {
+ if (route.includes('web/image/750')) {
+ return;
+ }
+ return this._super(...arguments);
+ },
+ });
+ const attachment = this.env.models['mail.attachment'].create({
+ filename: "test.png",
+ id: 750,
+ mimetype: 'image/png',
+ name: "test.png",
+ });
+
+ await this.createAttachmentComponent(attachment, {
+ detailsMode: 'auto',
+ isDownloadable: false,
+ isEditable: false,
+ showExtension: true,
+ showFilename: true
+ });
+ assert.strictEqual(
+ document.querySelectorAll(`
+ .o_Attachment_details:not(.o_Attachment_imageOverlayDetails)
+ `).length,
+ 0,
+ "attachment should not have a details part directly"
+ );
+ assert.strictEqual(
+ document.querySelectorAll(`.o_Attachment_imageOverlayDetails`).length,
+ 1,
+ "attachment should have a details part in the overlay"
+ );
+ assert.strictEqual(
+ document.querySelectorAll(`.o_Attachment_image`).length,
+ 1,
+ "attachment should have an image part"
+ );
+ assert.strictEqual(
+ document.querySelectorAll(`.o_Attachment_imageOverlay`).length,
+ 1,
+ "attachment should have an image overlay part"
+ );
+ assert.strictEqual(
+ document.querySelectorAll(`.o_Attachment_filename`).length,
+ 1,
+ "attachment should have its filename shown"
+ );
+ assert.strictEqual(
+ document.querySelectorAll(`.o_Attachment_extension`).length,
+ 1,
+ "attachment should have its extension shown"
+ );
+ assert.strictEqual(
+ document.querySelectorAll(`.o_Attachment_aside`).length,
+ 0,
+ "attachment should not have an aside element"
+ );
+});
+
+});
+});
+});
+
+});
diff --git a/addons/mail/static/src/components/attachment_box/attachment_box.js b/addons/mail/static/src/components/attachment_box/attachment_box.js
new file mode 100644
index 00000000000..6e0c9d7c55e
--- /dev/null
+++ b/addons/mail/static/src/components/attachment_box/attachment_box.js
@@ -0,0 +1,118 @@
+odoo.define('mail/static/src/components/attachment_box/attachment_box.js', function (require) {
+'use strict';
+
+const components = {
+ AttachmentList: require('mail/static/src/components/attachment_list/attachment_list.js'),
+ DropZone: require('mail/static/src/components/drop_zone/drop_zone.js'),
+ FileUploader: require('mail/static/src/components/file_uploader/file_uploader.js'),
+};
+const useDragVisibleDropZone = require('mail/static/src/component_hooks/use_drag_visible_dropzone/use_drag_visible_dropzone.js');
+const useStore = require('mail/static/src/component_hooks/use_store/use_store.js');
+
+const { Component } = owl;
+const { useRef } = owl.hooks;
+
+class AttachmentBox extends Component {
+
+ /**
+ * @override
+ */
+ constructor(...args) {
+ super(...args);
+ this.isDropZoneVisible = useDragVisibleDropZone();
+ useStore(props => {
+ const thread = this.env.models['mail.thread'].get(props.threadLocalId);
+ return {
+ attachments: thread
+ ? thread.allAttachments.map(attachment => attachment.__state)
+ : [],
+ thread: thread ? thread.__state : undefined,
+ };
+ });
+ /**
+ * Reference of the file uploader.
+ * Useful to programmatically prompts the browser file uploader.
+ */
+ this._fileUploaderRef = useRef('fileUploader');
+ }
+
+ //--------------------------------------------------------------------------
+ // Public
+ //--------------------------------------------------------------------------
+
+ /**
+ * Get an object which is passed to FileUploader component to be used when
+ * creating attachment.
+ *
+ * @returns {Object}
+ */
+ get newAttachmentExtraData() {
+ return {
+ originThread: [['link', this.thread]],
+ };
+ }
+
+ /**
+ * @returns {mail.thread|undefined}
+ */
+ get thread() {
+ return this.env.models['mail.thread'].get(this.props.threadLocalId);
+ }
+
+ //--------------------------------------------------------------------------
+ // Handlers
+ //--------------------------------------------------------------------------
+
+ /**
+ * @private
+ * @param {Event} ev
+ */
+ _onAttachmentCreated(ev) {
+ // FIXME Could be changed by spying attachments count (task-2252858)
+ this.trigger('o-attachments-changed');
+ }
+
+ /**
+ * @private
+ * @param {Event} ev
+ */
+ _onAttachmentRemoved(ev) {
+ // FIXME Could be changed by spying attachments count (task-2252858)
+ this.trigger('o-attachments-changed');
+ }
+
+ /**
+ * @private
+ * @param {Event} ev
+ */
+ _onClickAdd(ev) {
+ ev.preventDefault();
+ ev.stopPropagation();
+ this._fileUploaderRef.comp.openBrowserFileUploader();
+ }
+
+ /**
+ * @private
+ * @param {CustomEvent} ev
+ * @param {Object} ev.detail
+ * @param {FileList} ev.detail.files
+ */
+ async _onDropZoneFilesDropped(ev) {
+ ev.stopPropagation();
+ await this._fileUploaderRef.comp.uploadFiles(ev.detail.files);
+ this.isDropZoneVisible.value = false;
+ }
+
+}
+
+Object.assign(AttachmentBox, {
+ components,
+ props: {
+ threadLocalId: String,
+ },
+ template: 'mail.AttachmentBox',
+});
+
+return AttachmentBox;
+
+});
diff --git a/addons/mail/static/src/components/attachment_box/attachment_box.scss b/addons/mail/static/src/components/attachment_box/attachment_box.scss
new file mode 100644
index 00000000000..d51cca9c31c
--- /dev/null
+++ b/addons/mail/static/src/components/attachment_box/attachment_box.scss
@@ -0,0 +1,46 @@
+// ------------------------------------------------------------------
+// Layout
+// ------------------------------------------------------------------
+
+.o_AttachmentBox {
+ position: relative;
+}
+
+.o_AttachmentBox_buttonAdd {
+ align-self: center;
+}
+
+.o_AttachmentBox_content {
+ display: flex;
+ flex-direction: column;
+}
+
+.o_AttachmentBox_dashedLine {
+ flex-grow: 1;
+}
+
+.o_AttachmentBox_fileInput {
+ display: none;
+}
+
+.o_AttachmentBox_title {
+ display: flex;
+ align-items: center;
+}
+
+.o_AttachmentBox_titleText {
+ padding: map-get($spacers, 3);
+}
+
+// ------------------------------------------------------------------
+// Style
+// ------------------------------------------------------------------
+
+.o_AttachmentBox_dashedLine {
+ border-style: dashed;
+ border-color: gray('300');
+}
+
+.o_AttachmentBox_title {
+ font-weight: bold;
+}
diff --git a/addons/mail/static/src/components/attachment_box/attachment_box.xml b/addons/mail/static/src/components/attachment_box/attachment_box.xml
new file mode 100644
index 00000000000..9cd3e713401
--- /dev/null
+++ b/addons/mail/static/src/components/attachment_box/attachment_box.xml
@@ -0,0 +1,50 @@
+<?xml version="1.0" encoding="UTF-8"?>
+<templates xml:space="preserve">
+
+ <t t-name="mail.AttachmentBox" owl="1">
+ <div class="o_AttachmentBox">
+ <div class="o_AttachmentBox_title">
+ <hr class="o_AttachmentBox_dashedLine"/>
+ <span class="o_AttachmentBox_titleText">
+ Attachments
+ </span>
+ <hr class="o_AttachmentBox_dashedLine"/>
+ </div>
+ <div class="o_AttachmentBox_content">
+ <t t-if="isDropZoneVisible.value">
+ <DropZone
+ class="o_AttachmentBox_dropZone"
+ t-on-o-dropzone-files-dropped="_onDropZoneFilesDropped"
+ t-ref="dropzone"
+ />
+ </t>
+ <t t-if="thread and thread.allAttachments.length > 0">
+ <AttachmentList
+ class="o_attachmentBox_attachmentList"
+ areAttachmentsDownloadable="true"
+ attachmentLocalIds="thread.allAttachments.map(attachment => attachment.localId)"
+ attachmentsDetailsMode="'hover'"
+ attachmentsImageSize="'small'"
+ showAttachmentsFilenames="true"
+ t-on-o-attachment-removed="_onAttachmentRemoved"
+ />
+ </t>
+ <button class="o_AttachmentBox_buttonAdd btn btn-link" type="button" t-on-click="_onClickAdd">
+ <i class="fa fa-plus-square"/>
+ Add attachments
+ </button>
+ </div>
+ <t t-if="thread">
+ <FileUploader
+ attachmentLocalIds="thread.allAttachments.map(attachment => attachment.localId)"
+ newAttachmentExtraData="newAttachmentExtraData"
+ uploadModel="thread.model"
+ uploadId="thread.id"
+ t-on-o-attachment-created="_onAttachmentCreated"
+ t-ref="fileUploader"
+ />
+ </t>
+ </div>
+ </t>
+
+</templates>
diff --git a/addons/mail/static/src/components/attachment_box/attachment_box_tests.js b/addons/mail/static/src/components/attachment_box/attachment_box_tests.js
new file mode 100644
index 00000000000..93690791d10
--- /dev/null
+++ b/addons/mail/static/src/components/attachment_box/attachment_box_tests.js
@@ -0,0 +1,228 @@
+odoo.define('mail/static/src/components/attachment_box/attachment_box_tests.js', function (require) {
+"use strict";
+
+const components = {
+ AttachmentBox: require('mail/static/src/components/attachment_box/attachment_box.js'),
+};
+const {
+ afterEach: utilsAfterEach,
+ afterNextRender,
+ beforeEach: utilsBeforeEach,
+ dragenterFiles,
+ dropFiles,
+ start: utilsStart,
+} = require('mail/static/src/utils/test_utils.js');
+
+const { file: { createFile } } = require('web.test_utils');
+
+QUnit.module('mail', {}, function () {
+QUnit.module('components', {}, function () {
+QUnit.module('attachment_box', {}, function () {
+QUnit.module('attachment_box_tests.js', {
+ beforeEach() {
+ utilsBeforeEach(this);
+
+ this.createAttachmentBoxComponent = async (thread, otherProps) => {
+ const AttachmentBoxComponent = components.AttachmentBox;
+ AttachmentBoxComponent.env = this.env;
+ this.component = new AttachmentBoxComponent(null, Object.assign({
+ threadLocalId: thread.localId,
+ }, otherProps));
+ await this.component.mount(this.widget.el);
+ };
+
+ 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);
+ if (this.component) {
+ this.component.destroy();
+ }
+ if (this.widget) {
+ this.widget.destroy();
+ }
+ delete components.AttachmentBox.env;
+ this.env = undefined;
+ },
+});
+
+QUnit.test('base empty rendering', async function (assert) {
+ assert.expect(4);
+
+ await this.start();
+ const thread = this.env.models['mail.thread'].create({
+ id: 100,
+ model: 'res.partner',
+ });
+ await this.createAttachmentBoxComponent(thread);
+ assert.strictEqual(
+ document.querySelectorAll(`.o_AttachmentBox`).length,
+ 1,
+ "should have an attachment box"
+ );
+ assert.strictEqual(
+ document.querySelectorAll(`.o_AttachmentBox_buttonAdd`).length,
+ 1,
+ "should have a button add"
+ );
+ assert.strictEqual(
+ document.querySelectorAll(`.o_FileUploader_input`).length,
+ 1,
+ "should have a file input"
+ );
+ assert.strictEqual(
+ document.querySelectorAll(`.o_AttachmentBox .o_Attachment`).length,
+ 0,
+ "should not have any attachment"
+ );
+});
+
+QUnit.test('base non-empty rendering', async function (assert) {
+ assert.expect(6);
+
+ await this.start({
+ async mockRPC(route, args) {
+ if (route.includes('ir.attachment/search_read')) {
+ assert.step('ir.attachment/search_read');
+ return [{
+ id: 143,
+ filename: 'Blah.txt',
+ mimetype: 'text/plain',
+ name: 'Blah.txt'
+ }, {
+ id: 144,
+ filename: 'Blu.txt',
+ mimetype: 'text/plain',
+ name: 'Blu.txt'
+ }];
+ }
+ return this._super(...arguments);
+ }
+ });
+ const thread = this.env.models['mail.thread'].create({
+ id: 100,
+ model: 'res.partner',
+ });
+ await thread.fetchAttachments();
+ await this.createAttachmentBoxComponent(thread);
+ assert.verifySteps(
+ ['ir.attachment/search_read'],
+ "should have fetched attachments"
+ );
+ assert.strictEqual(
+ document.querySelectorAll(`.o_AttachmentBox`).length,
+ 1,
+ "should have an attachment box"
+ );
+ assert.strictEqual(
+ document.querySelectorAll(`.o_AttachmentBox_buttonAdd`).length,
+ 1,
+ "should have a button add"
+ );
+ assert.strictEqual(
+ document.querySelectorAll(`.o_FileUploader_input`).length,
+ 1,
+ "should have a file input"
+ );
+ assert.strictEqual(
+ document.querySelectorAll(`.o_attachmentBox_attachmentList`).length,
+ 1,
+ "should have an attachment list"
+ );
+});
+
+QUnit.test('attachment box: drop attachments', async function (assert) {
+ assert.expect(5);
+
+ await this.start({
+ async mockRPC(route, args) {
+ if (route.includes('ir.attachment/search_read')) {
+ return [];
+ }
+ return this._super(...arguments);
+ }
+ });
+ const thread = this.env.models['mail.thread'].create({
+ id: 100,
+ model: 'res.partner',
+ });
+ await thread.fetchAttachments();
+ await this.createAttachmentBoxComponent(thread);
+ const files = [
+ await createFile({
+ content: 'hello, world',
+ contentType: 'text/plain',
+ name: 'text.txt',
+ }),
+ ];
+ assert.strictEqual(
+ document.querySelectorAll('.o_AttachmentBox').length,
+ 1,
+ "should have an attachment box"
+ );
+
+ await afterNextRender(() =>
+ dragenterFiles(document.querySelector('.o_AttachmentBox'))
+ );
+ assert.ok(
+ document.querySelector('.o_AttachmentBox_dropZone'),
+ "should have a drop zone"
+ );
+ assert.strictEqual(
+ document.querySelectorAll(`.o_AttachmentBox .o_Attachment`).length,
+ 0,
+ "should have no attachment before files are dropped"
+ );
+
+ await afterNextRender(() =>
+ dropFiles(
+ document.querySelector('.o_AttachmentBox_dropZone'),
+ files
+ )
+ );
+ assert.strictEqual(
+ document.querySelectorAll(`.o_AttachmentBox .o_Attachment`).length,
+ 1,
+ "should have 1 attachment in the box after files dropped"
+ );
+
+ await afterNextRender(() =>
+ dragenterFiles(document.querySelector('.o_AttachmentBox'))
+ );
+ const file1 = await createFile({
+ content: 'hello, world',
+ contentType: 'text/plain',
+ name: 'text2.txt',
+ });
+ const file2 = await createFile({
+ content: 'hello, world',
+ contentType: 'text/plain',
+ name: 'text3.txt',
+ });
+ await afterNextRender(() =>
+ dropFiles(
+ document.querySelector('.o_AttachmentBox_dropZone'),
+ [file1, file2]
+ )
+ );
+ assert.strictEqual(
+ document.querySelectorAll(`.o_AttachmentBox .o_Attachment`).length,
+ 3,
+ "should have 3 attachments in the box after files dropped"
+ );
+});
+
+});
+});
+});
+
+});
diff --git a/addons/mail/static/src/components/attachment_list/attachment_list.js b/addons/mail/static/src/components/attachment_list/attachment_list.js
new file mode 100644
index 00000000000..d097e5d705d
--- /dev/null
+++ b/addons/mail/static/src/components/attachment_list/attachment_list.js
@@ -0,0 +1,54 @@
+odoo.define('mail/static/src/components/attachment_list/attachment_list.js', function (require) {
+'use strict';
+
+const components = {
+ Attachment: require('mail/static/src/components/attachment/attachment.js'),
+};
+
+const { Component } = owl;
+
+class AttachmentList extends Component {}
+
+Object.assign(AttachmentList, {
+ components,
+ defaultProps: {
+ attachmentLocalIds: [],
+ },
+ props: {
+ areAttachmentsDownloadable: {
+ type: Boolean,
+ optional: true,
+ },
+ areAttachmentsEditable: {
+ type: Boolean,
+ optional: true,
+ },
+ attachmentLocalIds: {
+ type: Array,
+ element: String,
+ },
+ attachmentsDetailsMode: {
+ type: String,
+ optional: true,
+ validate: prop => ['auto', 'card', 'hover', 'none'].includes(prop),
+ },
+ attachmentsImageSize: {
+ type: String,
+ optional: true,
+ validate: prop => ['small', 'medium', 'large'].includes(prop),
+ },
+ showAttachmentsExtensions: {
+ type: Boolean,
+ optional: true,
+ },
+ showAttachmentsFilenames: {
+ type: Boolean,
+ optional: true,
+ },
+ },
+ template: 'mail.AttachmentList',
+});
+
+return AttachmentList;
+
+});
diff --git a/addons/mail/static/src/components/attachment_list/attachment_list.scss b/addons/mail/static/src/components/attachment_list/attachment_list.scss
new file mode 100644
index 00000000000..af4954818e0
--- /dev/null
+++ b/addons/mail/static/src/components/attachment_list/attachment_list.scss
@@ -0,0 +1,13 @@
+// ------------------------------------------------------------------
+// Layout
+// ------------------------------------------------------------------
+
+.o_AttachmentList {
+ display: flex;
+ flex-flow: wrap;
+ justify-content: flex-start;
+}
+
+.o_AttachmentList_attachment {
+ margin: map-get($spacers, 1);
+}
diff --git a/addons/mail/static/src/components/attachment_list/attachment_list.xml b/addons/mail/static/src/components/attachment_list/attachment_list.xml
new file mode 100644
index 00000000000..452419105ff
--- /dev/null
+++ b/addons/mail/static/src/components/attachment_list/attachment_list.xml
@@ -0,0 +1,22 @@
+<?xml version="1.0" encoding="UTF-8"?>
+<templates xml:space="preserve">
+
+ <t t-name="mail.AttachmentList" owl="1">
+ <div class="o_AttachmentList">
+ <t t-foreach="props.attachmentLocalIds" t-as="attachmentLocalId" t-key="attachmentLocalId">
+ <Attachment
+ class="o_AttachmentList_attachment"
+ attachmentLocalId="attachmentLocalId"
+ attachmentLocalIds="props.attachmentLocalIds"
+ detailsMode="props.attachmentsDetailsMode"
+ imageSize="props.attachmentsImageSize"
+ isDownloadable="props.areAttachmentsDownloadable"
+ isEditable="props.areAttachmentsEditable"
+ showExtension="props.showAttachmentsExtensions"
+ showFilename="props.showAttachmentsFilenames"
+ />
+ </t>
+ </div>
+ </t>
+
+</templates>
diff --git a/addons/mail/static/src/components/attachment_viewer/attachment_viewer.js b/addons/mail/static/src/components/attachment_viewer/attachment_viewer.js
new file mode 100644
index 00000000000..54f5f383d3b
--- /dev/null
+++ b/addons/mail/static/src/components/attachment_viewer/attachment_viewer.js
@@ -0,0 +1,593 @@
+odoo.define('mail/static/src/components/attachment_viewer/attachment_viewer.js', function (require) {
+'use strict';
+
+const useStore = require('mail/static/src/component_hooks/use_store/use_store.js');
+
+const { Component, QWeb } = owl;
+const { useRef } = owl.hooks;
+
+const MIN_SCALE = 0.5;
+const SCROLL_ZOOM_STEP = 0.1;
+const ZOOM_STEP = 0.5;
+
+class AttachmentViewer extends Component {
+
+ /**
+ * @override
+ */
+ constructor(...args) {
+ super(...args);
+ this.MIN_SCALE = MIN_SCALE;
+ useStore(props => {
+ const attachmentViewer = this.env.messaging.attachmentViewer;
+ return {
+ attachment: attachmentViewer && attachmentViewer.attachment
+ ? attachmentViewer.attachment.__state
+ : undefined,
+ attachments: attachmentViewer
+ ? attachmentViewer.attachments.map(attachment => attachment.__state)
+ : [],
+ attachmentViewer: attachmentViewer ? attachmentViewer.__state : undefined,
+ };
+ });
+ /**
+ * Determine whether the user is currently dragging the image.
+ * This is useful to determine whether a click outside of the image
+ * should close the attachment viewer or not.
+ */
+ this._isDragging = false;
+ /**
+ * Reference to the image node. Useful in the computation of the zoomer
+ * style (based on user zoom in/out interactions).
+ */
+ this._imageRef = useRef('image');
+ /**
+ * Reference of the zoomer node. Useful to apply translate
+ * transformation on image visualisation.
+ */
+ this._zoomerRef = useRef('zoomer');
+ /**
+ * Tracked translate transformations on image visualisation. This is
+ * not observed with `useStore` because they are used to compute zoomer
+ * style, and this is changed directly on zoomer for performance
+ * reasons (overhead of making vdom is too significant for each mouse
+ * position changes while dragging)
+ */
+ this._translate = { x: 0, y: 0, dx: 0, dy: 0 };
+ /**
+ * Tracked last rendered attachment. Useful to detect a new image is
+ * loading, in order to display spinner until it is fully loaded.
+ */
+ this._renderedAttachment = undefined;
+ this._onClickGlobal = this._onClickGlobal.bind(this);
+ }
+
+ mounted() {
+ this.el.focus();
+ this._handleImageLoad();
+ this._renderedAttachment = this.attachmentViewer.attachment;
+ document.addEventListener('click', this._onClickGlobal);
+ }
+
+ /**
+ * When a new image is displayed, show a spinner until it is loaded.
+ */
+ patched() {
+ this._handleImageLoad();
+ this._renderedAttachment = this.attachmentViewer.attachment;
+ }
+
+ willUnmount() {
+ document.removeEventListener('click', this._onClickGlobal);
+ }
+
+ //--------------------------------------------------------------------------
+ // Public
+ //--------------------------------------------------------------------------
+
+ /**
+ * @returns {mail.attachment_viewer}
+ */
+ get attachmentViewer() {
+ return this.env.messaging.attachmentViewer;
+ }
+
+ /**
+ * Compute the style of the image (scale + rotation).
+ *
+ * @returns {string}
+ */
+ get imageStyle() {
+ const attachmentViewer = this.attachmentViewer;
+ let style = `transform: ` +
+ `scale3d(${attachmentViewer.scale}, ${attachmentViewer.scale}, 1) ` +
+ `rotate(${attachmentViewer.angle}deg);`;
+
+ if (attachmentViewer.angle % 180 !== 0) {
+ style += `` +
+ `max-height: ${window.innerWidth}px; ` +
+ `max-width: ${window.innerHeight}px;`;
+ } else {
+ style += `` +
+ `max-height: 100%; ` +
+ `max-width: 100%;`;
+ }
+ return style;
+ }
+
+ /**
+ * Mandatory method for dialog components.
+ * Prevent closing the dialog when clicking on the mask when the user is
+ * currently dragging the image.
+ *
+ * @returns {boolean}
+ */
+ isCloseable() {
+ return !this._isDragging;
+ }
+
+ //--------------------------------------------------------------------------
+ // Private
+ //--------------------------------------------------------------------------
+
+ /**
+ * Close the dialog with this attachment viewer.
+ *
+ * @private
+ */
+ _close() {
+ this.attachmentViewer.delete();
+ }
+
+ /**
+ * Download the attachment.
+ *
+ * @private
+ */
+ _download() {
+ const id = this.attachmentViewer.attachment.id;
+ this.env.services.navigate(`/web/content/ir.attachment/${id}/datas?download=true`);
+ }
+
+ /**
+ * Determine whether the current image is rendered for the 1st time, and if
+ * that's the case, display a spinner until loaded.
+ *
+ * @private
+ */
+ _handleImageLoad() {
+ if (
+ this.attachmentViewer.attachment.fileType === 'image' &&
+ this._renderedAttachment !== this.attachmentViewer.attachment
+ ) {
+ this.attachmentViewer.update({ isImageLoading: true });
+ this._imageRef.el.addEventListener('load', ev => this._onLoadImage(ev));
+ }
+ }
+
+ /**
+ * Display the previous attachment in the list of attachments.
+ *
+ * @private
+ */
+ _next() {
+ const attachmentViewer = this.attachmentViewer;
+ const index = attachmentViewer.attachments.findIndex(attachment =>
+ attachment === attachmentViewer.attachment
+ );
+ const nextIndex = (index + 1) % attachmentViewer.attachments.length;
+ attachmentViewer.update({
+ attachment: [['link', attachmentViewer.attachments[nextIndex]]],
+ });
+ }
+
+ /**
+ * Display the previous attachment in the list of attachments.
+ *
+ * @private
+ */
+ _previous() {
+ const attachmentViewer = this.attachmentViewer;
+ const index = attachmentViewer.attachments.findIndex(attachment =>
+ attachment === attachmentViewer.attachment
+ );
+ const nextIndex = index === 0
+ ? attachmentViewer.attachments.length - 1
+ : index - 1;
+ attachmentViewer.update({
+ attachment: [['link', attachmentViewer.attachments[nextIndex]]],
+ });
+ }
+
+ /**
+ * Prompt the browser print of this attachment.
+ *
+ * @private
+ */
+ _print() {
+ const printWindow = window.open('about:blank', '_new');
+ printWindow.document.open();
+ printWindow.document.write(`
+ <html>
+ <head>
+ <script>
+ function onloadImage() {
+ setTimeout('printImage()', 10);
+ }
+ function printImage() {
+ window.print();
+ window.close();
+ }
+ </script>
+ </head>
+ <body onload='onloadImage()'>
+ <img src="${this.attachmentViewer.attachment.defaultSource}" alt=""/>
+ </body>
+ </html>`);
+ printWindow.document.close();
+ }
+
+ /**
+ * Rotate the image by 90 degrees to the right.
+ *
+ * @private
+ */
+ _rotate() {
+ this.attachmentViewer.update({ angle: this.attachmentViewer.angle + 90 });
+ }
+
+ /**
+ * Stop dragging interaction of the user.
+ *
+ * @private
+ */
+ _stopDragging() {
+ this._isDragging = false;
+ this._translate.x += this._translate.dx;
+ this._translate.y += this._translate.dy;
+ this._translate.dx = 0;
+ this._translate.dy = 0;
+ this._updateZoomerStyle();
+ }
+
+ /**
+ * Update the style of the zoomer based on translate transformation. Changes
+ * are directly applied on zoomer, instead of triggering re-render and
+ * defining them in the template, for performance reasons.
+ *
+ * @private
+ * @returns {string}
+ */
+ _updateZoomerStyle() {
+ const attachmentViewer = this.attachmentViewer;
+ const tx = this._imageRef.el.offsetWidth * attachmentViewer.scale > this._zoomerRef.el.offsetWidth
+ ? this._translate.x + this._translate.dx
+ : 0;
+ const ty = this._imageRef.el.offsetHeight * attachmentViewer.scale > this._zoomerRef.el.offsetHeight
+ ? this._translate.y + this._translate.dy
+ : 0;
+ if (tx === 0) {
+ this._translate.x = 0;
+ }
+ if (ty === 0) {
+ this._translate.y = 0;
+ }
+ this._zoomerRef.el.style = `transform: ` +
+ `translate(${tx}px, ${ty}px)`;
+ }
+
+ /**
+ * Zoom in the image.
+ *
+ * @private
+ * @param {Object} [param0={}]
+ * @param {boolean} [param0.scroll=false]
+ */
+ _zoomIn({ scroll = false } = {}) {
+ this.attachmentViewer.update({
+ scale: this.attachmentViewer.scale + (scroll ? SCROLL_ZOOM_STEP : ZOOM_STEP),
+ });
+ this._updateZoomerStyle();
+ }
+
+ /**
+ * Zoom out the image.
+ *
+ * @private
+ * @param {Object} [param0={}]
+ * @param {boolean} [param0.scroll=false]
+ */
+ _zoomOut({ scroll = false } = {}) {
+ if (this.attachmentViewer.scale === MIN_SCALE) {
+ return;
+ }
+ const unflooredAdaptedScale = (
+ this.attachmentViewer.scale -
+ (scroll ? SCROLL_ZOOM_STEP : ZOOM_STEP)
+ );
+ this.attachmentViewer.update({
+ scale: Math.max(MIN_SCALE, unflooredAdaptedScale),
+ });
+ this._updateZoomerStyle();
+ }
+
+ /**
+ * Reset the zoom scale of the image.
+ *
+ * @private
+ */
+ _zoomReset() {
+ this.attachmentViewer.update({ scale: 1 });
+ this._updateZoomerStyle();
+ }
+
+ //--------------------------------------------------------------------------
+ // Handlers
+ //--------------------------------------------------------------------------
+
+ /**
+ * Called when clicking on mask of attachment viewer.
+ *
+ * @private
+ * @param {MouseEvent} ev
+ */
+ _onClick(ev) {
+ if (this._isDragging) {
+ return;
+ }
+ // TODO: clicking on the background should probably be handled by the dialog?
+ // task-2092965
+ this._close();
+ }
+
+ /**
+ * Called when clicking on cross icon.
+ *
+ * @private
+ * @param {MouseEvent} ev
+ */
+ _onClickClose(ev) {
+ this._close();
+ }
+
+ /**
+ * Called when clicking on download icon.
+ *
+ * @private
+ * @param {MouseEvent} ev
+ */
+ _onClickDownload(ev) {
+ ev.stopPropagation();
+ this._download();
+ }
+
+ /**
+ * @private
+ * @param {MouseEvent} ev
+ */
+ _onClickGlobal(ev) {
+ if (!this._isDragging) {
+ return;
+ }
+ ev.stopPropagation();
+ this._stopDragging();
+ }
+
+ /**
+ * Called when clicking on the header. Stop propagation of event to prevent
+ * closing the dialog.
+ *
+ * @private
+ * @param {MouseEvent} ev
+ */
+ _onClickHeader(ev) {
+ ev.stopPropagation();
+ }
+
+ /**
+ * Called when clicking on image. Stop propagation of event to prevent
+ * closing the dialog.
+ *
+ * @private
+ * @param {MouseEvent} ev
+ */
+ _onClickImage(ev) {
+ if (this._isDragging) {
+ return;
+ }
+ ev.stopPropagation();
+ }
+
+ /**
+ * Called when clicking on next icon.
+ *
+ * @private
+ * @param {MouseEvent} ev
+ */
+ _onClickNext(ev) {
+ ev.stopPropagation();
+ this._next();
+ }
+
+ /**
+ * Called when clicking on previous icon.
+ *
+ * @private
+ * @param {MouseEvent} ev
+ */
+ _onClickPrevious(ev) {
+ ev.stopPropagation();
+ this._previous();
+ }
+
+ /**
+ * Called when clicking on print icon.
+ *
+ * @private
+ * @param {MouseEvent} ev
+ */
+ _onClickPrint(ev) {
+ ev.stopPropagation();
+ this._print();
+ }
+
+ /**
+ * Called when clicking on rotate icon.
+ *
+ * @private
+ * @param {MouseEvent} ev
+ */
+ _onClickRotate(ev) {
+ ev.stopPropagation();
+ this._rotate();
+ }
+
+ /**
+ * Called when clicking on embed video player. Stop propagation to prevent
+ * closing the dialog.
+ *
+ * @private
+ * @param {MouseEvent} ev
+ */
+ _onClickVideo(ev) {
+ ev.stopPropagation();
+ }
+
+ /**
+ * Called when clicking on zoom in icon.
+ *
+ * @private
+ * @param {MouseEvent} ev
+ */
+ _onClickZoomIn(ev) {
+ ev.stopPropagation();
+ this._zoomIn();
+ }
+
+ /**
+ * Called when clicking on zoom out icon.
+ *
+ * @private
+ * @param {MouseEvent} ev
+ */
+ _onClickZoomOut(ev) {
+ ev.stopPropagation();
+ this._zoomOut();
+ }
+
+ /**
+ * Called when clicking on reset zoom icon.
+ *
+ * @private
+ * @param {MouseEvent} ev
+ */
+ _onClickZoomReset(ev) {
+ ev.stopPropagation();
+ this._zoomReset();
+ }
+
+ /**
+ * @private
+ * @param {KeyboardEvent} ev
+ */
+ _onKeydown(ev) {
+ switch (ev.key) {
+ case 'ArrowRight':
+ this._next();
+ break;
+ case 'ArrowLeft':
+ this._previous();
+ break;
+ case 'Escape':
+ this._close();
+ break;
+ case 'q':
+ this._close();
+ break;
+ case 'r':
+ this._rotate();
+ break;
+ case '+':
+ this._zoomIn();
+ break;
+ case '-':
+ this._zoomOut();
+ break;
+ case '0':
+ this._zoomReset();
+ break;
+ default:
+ return;
+ }
+ ev.stopPropagation();
+ }
+
+ /**
+ * Called when new image has been loaded
+ *
+ * @private
+ * @param {Event} ev
+ */
+ _onLoadImage(ev) {
+ ev.stopPropagation();
+ this.attachmentViewer.update({ isImageLoading: false });
+ }
+
+ /**
+ * @private
+ * @param {DragEvent} ev
+ */
+ _onMousedownImage(ev) {
+ if (this._isDragging) {
+ return;
+ }
+ if (ev.button !== 0) {
+ return;
+ }
+ ev.stopPropagation();
+ this._isDragging = true;
+ this._dragstartX = ev.clientX;
+ this._dragstartY = ev.clientY;
+ }
+
+ /**
+ * @private
+ * @param {DragEvent}
+ */
+ _onMousemoveView(ev) {
+ if (!this._isDragging) {
+ return;
+ }
+ this._translate.dx = ev.clientX - this._dragstartX;
+ this._translate.dy = ev.clientY - this._dragstartY;
+ this._updateZoomerStyle();
+ }
+
+ /**
+ * @private
+ * @param {Event} ev
+ */
+ _onWheelImage(ev) {
+ ev.stopPropagation();
+ if (!this.el) {
+ return;
+ }
+ if (ev.deltaY > 0) {
+ this._zoomIn({ scroll: true });
+ } else {
+ this._zoomOut({ scroll: true });
+ }
+ }
+
+}
+
+Object.assign(AttachmentViewer, {
+ props: {},
+ template: 'mail.AttachmentViewer',
+});
+
+QWeb.registerComponent('AttachmentViewer', AttachmentViewer);
+
+return AttachmentViewer;
+
+});
diff --git a/addons/mail/static/src/components/attachment_viewer/attachment_viewer.scss b/addons/mail/static/src/components/attachment_viewer/attachment_viewer.scss
new file mode 100644
index 00000000000..54f00c1ae5d
--- /dev/null
+++ b/addons/mail/static/src/components/attachment_viewer/attachment_viewer.scss
@@ -0,0 +1,198 @@
+// ------------------------------------------------------------------
+// Layout
+// ------------------------------------------------------------------
+
+.o_AttachmentViewer {
+ display: flex;
+ width: 100%;
+ height: 100%;
+ flex-flow: column;
+ align-items: center;
+ z-index: -1;
+}
+
+.o_AttachmentViewer_buttonNavigation {
+ position: absolute;
+ display: flex;
+ align-items: center;
+ justify-content: center;
+ width: 40px;
+ height: 40px;
+ top: 50%;
+ transform: translateY(-50%);
+}
+
+.o_AttachmentViewer_buttonNavigationNext {
+ right: 15px;
+
+ > .fa {
+ margin: 1px 0 0 1px; // not correctly centered for some reasons
+ }
+}
+
+.o_AttachmentViewer_buttonNavigationPrevious {
+ left: 15px;
+
+ > .fa {
+ margin: 1px 1px 0 0; // not correctly centered for some reasons
+ }
+}
+
+.o_AttachmentViewer_header {
+ display: flex;
+ height: $o-navbar-height;
+ align-items: center;
+ padding: 0 15px;
+ width: 100%;
+}
+
+.o_AttachmentViewer_headerItem {
+ margin: 0 5px;
+
+ &:first-child {
+ margin-left: 0;
+ }
+
+ &:last-child {
+ margin-right: 0;
+ }
+}
+
+.o_AttachmentViewer_loading {
+ position: absolute;
+}
+
+.o_AttachmentViewer_main {
+ position: absolute;
+ top: 0;
+ left: 0;
+ right: 0;
+ bottom: 0;
+ display: flex;
+ align-items: center;
+ justify-content: center;
+ z-index: -1;
+ padding: 45px 0;
+
+ &.o_with_img {
+ overflow: hidden;
+ }
+}
+
+.o_AttachmentViewer_toolbar {
+ position: absolute;
+ bottom: 45px;
+ transform: translateY(100%);
+ display: flex;
+}
+
+.o_AttachmentViewer_toolbarButton {
+ padding: 8px;
+}
+
+.o_AttachmentViewer_viewImage {
+ max-height: 100%;
+ max-width: 100%;
+}
+
+.o_AttachmentViewer_viewIframe {
+ width: 90%;
+ height: 100%;
+}
+
+.o_AttachmentViewer_viewVideo {
+ width: 75%;
+ height: 75%;
+}
+
+.o_AttachmentViewer_zoomer {
+ position: absolute;
+ padding: 45px 0;
+ height: 100%;
+ width: 100%;
+ display: flex;
+ align-items: center;
+ justify-content: center;
+}
+
+// ------------------------------------------------------------------
+// Style
+// ------------------------------------------------------------------
+
+.o_AttachmentViewer {
+ outline: none;
+}
+
+.o_AttachmentViewer_buttonNavigation {
+ color: gray('400');
+ background-color: lighten(black, 15%);
+ border-radius: 100%;
+ cursor: pointer;
+
+ &:hover {
+ color: lighten(gray('400'), 15%);
+ background-color: black;
+ }
+}
+
+.o_AttachmentViewer_header {
+ background-color: rgba(0, 0, 0, 0.7);
+ color: gray('400');
+}
+
+.o_AttachmentViewer_headerItemButton {
+ cursor: pointer;
+
+ &:hover {
+ background-color: rgba(0, 0, 0, 0.8);
+ color: lighten(gray('400'), 15%);
+ }
+}
+
+.o_AttachmentViewer_headerItemButtonClose {
+ cursor: pointer;
+ font-size: 1.3rem;
+}
+
+.o_AttachmentViewer_toolbar {
+ cursor: pointer;
+}
+
+.o_AttachmentViewer_toolbarButton {
+ background-color: lighten(black, 15%);
+
+ &.o_disabled {
+ cursor: not-allowed;
+ filter: brightness(1.3);
+ }
+
+ &:not(.o_disabled) {
+ color: gray('400');
+ cursor: pointer;
+
+ &:hover {
+ background-color: black;
+ color: lighten(gray('400'), 15%);
+ }
+ }
+}
+
+.o_AttachmentViewer_view {
+ background-color: black;
+ box-shadow: 0 0 40px black;
+ outline: none;
+ border: none;
+
+ &.o_text {
+ background-color: white;
+ }
+}
+
+// ------------------------------------------------------------------
+// Animation
+// ------------------------------------------------------------------
+
+.o_AttachmentViewer_viewImage {
+ transition: transform 0.3s ease;
+}
+
diff --git a/addons/mail/static/src/components/attachment_viewer/attachment_viewer.xml b/addons/mail/static/src/components/attachment_viewer/attachment_viewer.xml
new file mode 100644
index 00000000000..af7967b164f
--- /dev/null
+++ b/addons/mail/static/src/components/attachment_viewer/attachment_viewer.xml
@@ -0,0 +1,93 @@
+<?xml version="1.0" encoding="UTF-8"?>
+<templates xml:space="preserve">
+
+ <t t-name="mail.AttachmentViewer" owl="1">
+ <div class="o_AttachmentViewer" t-on-click="_onClick" t-on-keydown="_onKeydown" tabindex="0" t-att-data-dialog="attachmentViewer.dialog.localId">
+ <div class="o_AttachmentViewer_header" t-on-click="_onClickHeader">
+ <t t-if="attachmentViewer.attachment.fileType">
+ <div class="o_AttachmentViewer_headerItem o_AttachmentViewer_icon">
+ <t t-if="attachmentViewer.attachment.fileType === 'image'">
+ <i class="fa fa-picture-o" role="img" title="Image"/>
+ </t>
+ <t t-if="attachmentViewer.attachment.fileType === 'application/pdf'">
+ <i class="fa fa-file-text" role="img" title="PDF file"/>
+ </t>
+ <t t-if="attachmentViewer.attachment.isTextFile">
+ <i class="fa fa-file-text" role="img" title="Text file"/>
+ </t>
+ <t t-if="attachmentViewer.attachment.fileType === 'video'">
+ <i class="fa fa-video-camera" role="img" title="Video"/>
+ </t>
+ </div>
+ </t>
+ <div class="o_AttachmentViewer_headerItem o_AttachmentViewer_name">
+ <t t-esc="attachmentViewer.attachment.displayName"/>
+ </div>
+ <div class="o_AttachmentViewer_buttonDownload o_AttachmentViewer_headerItem o_AttachmentViewer_headerItemButton" t-on-click="_onClickDownload" role="button" title="Download">
+ <i class="fa fa-download fa-fw" role="img"/>
+ </div>
+ <div class="o-autogrow"/>
+ <div class="o_AttachmentViewer_headerItem o_AttachmentViewer_headerItemButton o_AttachmentViewer_headerItemButtonClose" t-on-click="_onClickClose" role="button" title="Close (Esc)" aria-label="Close">
+ <i class="fa fa-fw fa-times" role="img"/>
+ </div>
+ </div>
+ <div class="o_AttachmentViewer_main" t-att-class="{ o_with_img: attachmentViewer.attachment.fileType === 'image' }" t-on-mousemove="_onMousemoveView">
+ <t t-if="attachmentViewer.attachment.fileType === 'image'">
+ <div class="o_AttachmentViewer_zoomer" t-ref="zoomer">
+ <t t-if="attachmentViewer.isImageLoading">
+ <div class="o_AttachmentViewer_loading">
+ <i class="fa fa-3x fa-circle-o-notch fa-fw fa-spin" role="img" title="Loading"/>
+ </div>
+ </t>
+ <img class="o_AttachmentViewer_view o_AttachmentViewer_viewImage" t-on-click="_onClickImage" t-on-mousedown="_onMousedownImage" t-on-wheel="_onWheelImage" t-att-src="attachmentViewer.attachment.defaultSource" t-att-style="imageStyle" draggable="false" alt="Viewer" t-ref="image"/>
+ </div>
+ </t>
+ <t t-if="attachmentViewer.attachment.fileType === 'application/pdf'">
+ <iframe class="o_AttachmentViewer_view o_AttachmentViewer_viewIframe o_AttachmentViewer_viewPdf" t-att-src="attachmentViewer.attachment.defaultSource"/>
+ </t>
+ <t t-if="attachmentViewer.attachment.isTextFile">
+ <iframe class="o_AttachmentViewer_view o_AttachmentViewer_viewIframe o_text" t-att-src="attachmentViewer.attachment.defaultSource"/>
+ </t>
+ <t t-if="attachmentViewer.attachment.fileType === 'youtu'">
+ <iframe allow="autoplay; encrypted-media" class="o_AttachmentViewer_view o_AttachmentViewer_viewIframe o_AttachmentViewer_youtube" t-att-src="attachmentViewer.attachment.defaultSource" height="315" width="560"/>
+ </t>
+ <t t-if="attachmentViewer.attachment.fileType === 'video'">
+ <video class="o_AttachmentViewer_view o_AttachmentViewer_viewVideo" t-on-click="_onClickVideo" controls="controls">
+ <source t-att-data-type="attachmentViewer.attachment.mimetype" t-att-src="attachmentViewer.attachment.defaultSource"/>
+ </video>
+ </t>
+ </div>
+ <t t-if="attachmentViewer.attachment.fileType === 'image'">
+ <div class="o_AttachmentViewer_toolbar" role="toolbar">
+ <div class="o_AttachmentViewer_toolbarButton" t-on-click="_onClickZoomIn" title="Zoom In (+)" role="button">
+ <i class="fa fa-fw fa-plus" role="img"/>
+ </div>
+ <div class="o_AttachmentViewer_toolbarButton" t-att-class="{ o_disabled: attachmentViewer.scale === 1 }" t-on-click="_onClickZoomReset" role="button" title="Reset Zoom (0)">
+ <i class="fa fa-fw fa-search" role="img"/>
+ </div>
+ <div class="o_AttachmentViewer_toolbarButton" t-att-class="{ o_disabled: attachmentViewer.scale === MIN_SCALE }" t-on-click="_onClickZoomOut" title="Zoom Out (-)" role="button">
+ <i class="fafa-fw fa-minus" role="img"/>
+ </div>
+ <div class="o_AttachmentViewer_toolbarButton" t-on-click="_onClickRotate" title="Rotate (r)" role="button">
+ <i class="fa fa-fw fa-repeat" role="img"/>
+ </div>
+ <div class="o_AttachmentViewer_toolbarButton" t-on-click="_onClickPrint" title="Print" role="button">
+ <i class="fa fa-fw fa-print" role="img"/>
+ </div>
+ <div class="o_AttachmentViewer_buttonDownload o_AttachmentViewer_toolbarButton" t-on-click="_onClickDownload" title="Download" role="button">
+ <i class="fa fa-download fa-fw" role="img"/>
+ </div>
+ </div>
+ </t>
+ <t t-if="attachmentViewer.attachments.length > 1">
+ <div class="o_AttachmentViewer_buttonNavigation o_AttachmentViewer_buttonNavigationPrevious" t-on-click="_onClickPrevious" title="Previous (Left-Arrow)" role="button">
+ <span class="fa fa-chevron-left" role="img"/>
+ </div>
+ <div class="o_AttachmentViewer_buttonNavigation o_AttachmentViewer_buttonNavigationNext" t-on-click="_onClickNext" title="Next (Right-Arrow)" role="button">
+ <span class="fa fa-chevron-right" role="img"/>
+ </div>
+ </t>
+ </div>
+ </t>
+
+</templates>
diff --git a/addons/mail/static/src/components/autocomplete_input/autocomplete_input.js b/addons/mail/static/src/components/autocomplete_input/autocomplete_input.js
new file mode 100644
index 00000000000..1e383f7b307
--- /dev/null
+++ b/addons/mail/static/src/components/autocomplete_input/autocomplete_input.js
@@ -0,0 +1,161 @@
+odoo.define('mail/static/src/components/autocomplete_input/autocomplete_input.js', function (require) {
+'use strict';
+
+const { Component } = owl;
+
+class AutocompleteInput extends Component {
+
+ mounted() {
+ if (this.props.isFocusOnMount) {
+ this.el.focus();
+ }
+
+ let args = {
+ select: (ev, ui) => this._onAutocompleteSelect(ev, ui),
+ source: (req, res) => this._onAutocompleteSource(req, res),
+ focus: ev => this._onAutocompleteFocus(ev),
+ html: this.props.isHtml || false,
+ };
+
+ if (this.props.customClass) {
+ args.classes = { 'ui-autocomplete': this.props.customClass };
+ }
+
+ $(this.el).autocomplete(args);
+ }
+
+ willUnmount() {
+ $(this.el).autocomplete('destroy');
+ }
+
+ //--------------------------------------------------------------------------
+ // Public
+ //--------------------------------------------------------------------------
+
+ /**
+ * Returns whether the given node is self or a children of self, including
+ * the suggestion menu.
+ *
+ * @param {Node} node
+ * @returns {boolean}
+ */
+ contains(node) {
+ if (this.el.contains(node)) {
+ return true;
+ }
+ if (!this.props.customClass) {
+ return false;
+ }
+ const element = document.querySelector(`.${this.props.customClass}`);
+ if (!element) {
+ return false;
+ }
+ return element.contains(node);
+ }
+
+ focus() {
+ if (!this.el) {
+ return;
+ }
+ this.el.focus();
+ }
+
+ focusout() {
+ if (!this.el) {
+ return;
+ }
+ this.el.blur();
+ }
+
+ //--------------------------------------------------------------------------
+ // Handlers
+ //--------------------------------------------------------------------------
+
+ /**
+ * @private
+ * @param {FocusEvent} ev
+ */
+ _onAutocompleteFocus(ev) {
+ if (this.props.focus) {
+ this.props.focus(ev);
+ } else {
+ ev.preventDefault();
+ }
+ }
+
+ /**
+ * @private
+ * @param {Event} ev
+ * @param {Object} ui
+ */
+ _onAutocompleteSelect(ev, ui) {
+ ev.stopPropagation();
+ if (this.props.select) {
+ this.props.select(ev, ui);
+ }
+ }
+
+ /**
+ * @private
+ * @param {Object} req
+ * @param {function} res
+ */
+ _onAutocompleteSource(req, res) {
+ if (this.props.source) {
+ this.props.source(req, res);
+ }
+ }
+
+ /**
+ * @private
+ * @param {MouseEvent} ev
+ */
+ _onBlur(ev) {
+ this.trigger('o-hide');
+ }
+
+ /**
+ * @private
+ * @param {MouseEvent} ev
+ */
+ _onKeydown(ev) {
+ if (ev.key === 'Escape') {
+ this.trigger('o-hide');
+ }
+ }
+
+}
+
+Object.assign(AutocompleteInput, {
+ defaultProps: {
+ isFocusOnMount: false,
+ isHtml: false,
+ placeholder: '',
+ },
+ props: {
+ customClass: {
+ type: String,
+ optional: true,
+ },
+ focus: {
+ type: Function,
+ optional: true,
+ },
+ isFocusOnMount: Boolean,
+ isHtml: Boolean,
+ placeholder: String,
+ select: {
+ type: Function,
+ optional: true,
+ },
+ source: {
+ type: Function,
+ optional: true,
+ },
+ },
+ template: 'mail.AutocompleteInput',
+});
+
+return AutocompleteInput;
+
+});
diff --git a/addons/mail/static/src/components/autocomplete_input/autocomplete_input.xml b/addons/mail/static/src/components/autocomplete_input/autocomplete_input.xml
new file mode 100644
index 00000000000..ffa1bc890a4
--- /dev/null
+++ b/addons/mail/static/src/components/autocomplete_input/autocomplete_input.xml
@@ -0,0 +1,8 @@
+<?xml version="1.0" encoding="UTF-8"?>
+<templates xml:space="preserve">
+
+ <t t-name="mail.AutocompleteInput" owl="1">
+ <input class="o_AutocompleteInput" t-on-blur="_onBlur" t-on-keydown="_onKeydown" t-att-placeholder="props.placeholder"/>
+ </t>
+
+</templates>
diff --git a/addons/mail/static/src/components/chat_window/chat_window.js b/addons/mail/static/src/components/chat_window/chat_window.js
new file mode 100644
index 00000000000..8d1851c78fc
--- /dev/null
+++ b/addons/mail/static/src/components/chat_window/chat_window.js
@@ -0,0 +1,352 @@
+odoo.define('mail/static/src/components/chat_window/chat_window.js', function (require) {
+'use strict';
+
+const components = {
+ AutocompleteInput: require('mail/static/src/components/autocomplete_input/autocomplete_input.js'),
+ ChatWindowHeader: require('mail/static/src/components/chat_window_header/chat_window_header.js'),
+ ThreadViewer: require('mail/static/src/components/thread_viewer/thread_viewer.js'),
+};
+const useStore = require('mail/static/src/component_hooks/use_store/use_store.js');
+
+const { Component } = owl;
+const { useRef } = owl.hooks;
+
+class ChatWindow extends Component {
+
+ /**
+ * @override
+ */
+ constructor(...args) {
+ super(...args);
+ useStore(props => {
+ const chatWindow = this.env.models['mail.chat_window'].get(props.chatWindowLocalId);
+ return {
+ chatWindow: chatWindow ? chatWindow.__state : undefined,
+ isDeviceMobile: this.env.messaging.device.isMobile,
+ localeTextDirection: this.env.messaging.locale.textDirection,
+ };
+ });
+ /**
+ * Reference of the header of the chat window.
+ * Useful to prevent click on header from wrongly focusing the window.
+ */
+ this._chatWindowHeaderRef = useRef('header');
+ /**
+ * Reference of the autocomplete input (new_message chat window only).
+ * Useful when focusing this chat window, which consists of focusing
+ * this input.
+ */
+ this._inputRef = useRef('input');
+ /**
+ * Reference of thread in the chat window (chat window with thread
+ * only). Useful when focusing this chat window, which consists of
+ * focusing this thread. Will likely focus the composer of thread, if
+ * it has one!
+ */
+ this._threadRef = useRef('thread');
+ // the following are passed as props to children
+ this._onAutocompleteSelect = this._onAutocompleteSelect.bind(this);
+ this._onAutocompleteSource = this._onAutocompleteSource.bind(this);
+ }
+
+ mounted() {
+ this.env.messagingBus.on('will_hide_home_menu', this, this._onWillHideHomeMenu.bind(this));
+ this.env.messagingBus.on('will_show_home_menu', this, this._onWillShowHomeMenu.bind(this));
+ this._update();
+ }
+
+ patched() {
+ this._update();
+ }
+
+ willUnmount() {
+ this.env.messagingBus.off('will_hide_home_menu', this, this._onWillHideHomeMenu.bind(this));
+ this.env.messagingBus.off('will_show_home_menu', this, this._onWillShowHomeMenu.bind(this));
+ }
+
+ //--------------------------------------------------------------------------
+ // Public
+ //--------------------------------------------------------------------------
+
+ /**
+ * @returns {mail.chat_window}
+ */
+ get chatWindow() {
+ return this.env.models['mail.chat_window'].get(this.props.chatWindowLocalId);
+ }
+
+ /**
+ * Get the content of placeholder for the autocomplete input of
+ * 'new_message' chat window.
+ *
+ * @returns {string}
+ */
+ get newMessageFormInputPlaceholder() {
+ return this.env._t("Search user...");
+ }
+
+ //--------------------------------------------------------------------------
+ // Private
+ //--------------------------------------------------------------------------
+
+ /**
+ * Apply visual position of the chat window.
+ *
+ * @private
+ */
+ _applyVisibleOffset() {
+ const textDirection = this.env.messaging.locale.textDirection;
+ const offsetFrom = textDirection === 'rtl' ? 'left' : 'right';
+ const oppositeFrom = offsetFrom === 'right' ? 'left' : 'right';
+ this.el.style[offsetFrom] = this.chatWindow.visibleOffset + 'px';
+ this.el.style[oppositeFrom] = 'auto';
+ }
+
+ /**
+ * Focus this chat window.
+ *
+ * @private
+ */
+ _focus() {
+ this.chatWindow.update({
+ isDoFocus: false,
+ isFocused: true,
+ });
+ if (!this.chatWindow.thread) {
+ this._inputRef.comp.focus();
+ } else {
+ this._threadRef.comp.focus();
+ }
+ }
+
+ /**
+ * Save the scroll positions of the chat window in the store.
+ * This is useful in order to remount chat windows and keep previous
+ * scroll positions. This is necessary because when toggling on/off
+ * home menu, the chat windows have to be remade from scratch.
+ *
+ * @private
+ */
+ _saveThreadScrollTop() {
+ this.chatWindow.update({
+ threadInitialScrollTop: this._threadRef.comp.getScrollTop(),
+ });
+ }
+
+ /**
+ * @private
+ */
+ _update() {
+ if (!this.chatWindow) {
+ // chat window is being deleted
+ return;
+ }
+ if (this.chatWindow.isDoFocus) {
+ this._focus();
+ }
+ this._applyVisibleOffset();
+ }
+
+ //--------------------------------------------------------------------------
+ // Handlers
+ //--------------------------------------------------------------------------
+
+ /**
+ * Called when selecting an item in the autocomplete input of the
+ * 'new_message' chat window.
+ *
+ * @private
+ * @param {Event} ev
+ * @param {Object} ui
+ * @param {Object} ui.item
+ * @param {integer} ui.item.id
+ */
+ _onAutocompleteSelect(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) {
+ chat.open({ chatWindowMode: 'from_new_message' });
+ } else {
+ this.env.models['mail.thread'].createChannel({
+ autoselect: true,
+ autoselectChatWindowMode: 'from_new_message',
+ partnerId,
+ type: 'chat',
+ });
+ }
+ }
+
+ /**
+ * Called when typing in the autocomplete input of the 'new_message' chat
+ * window.
+ *
+ * @private
+ * @param {Object} req
+ * @param {string} req.term
+ * @param {function} res
+ */
+ _onAutocompleteSource(req, res) {
+ 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: _.escape(req.term),
+ limit: 10,
+ });
+ }
+
+ /**
+ * Handle focus of the chat window based on position of click. The click on
+ * chat window that folds it should NOT set focus on this chat window.
+ *
+ * @private
+ * @param {MouseEvent} ev
+ */
+ _onClick(ev) {
+ const chatWindowHeader = this._chatWindowHeaderRef.el;
+ if (chatWindowHeader && chatWindowHeader.contains(ev.target)) {
+ // handled in _onClickedHeader
+ return;
+ }
+ if (this.chatWindow.isFocused) {
+ return;
+ }
+ this.chatWindow.focus();
+ }
+
+ /**
+ * Called when clicking on header of chat window. Usually folds the chat
+ * window.
+ *
+ * @private
+ * @param {CustomEvent} ev
+ */
+ _onClickedHeader(ev) {
+ ev.stopPropagation();
+ if (this.env.messaging.device.isMobile) {
+ return;
+ }
+ if (this.chatWindow.isFolded) {
+ this.chatWindow.unfold();
+ this.chatWindow.focus();
+ } else {
+ this.chatWindow.fold();
+ }
+ }
+
+ /**
+ * Called when an element in the thread becomes focused.
+ *
+ * @private
+ * @param {FocusEvent} ev
+ */
+ _onFocusinThread(ev) {
+ ev.stopPropagation();
+ this.chatWindow.update({ isFocused: true });
+ }
+
+ /**
+ * Focus out the chat window.
+ *
+ * @private
+ */
+ _onFocusout() {
+ if (this._inputRef.comp) {
+ this._inputRef.comp.focusout();
+ }
+ if (this._threadRef.comp) {
+ this._threadRef.comp.focusout();
+ }
+ if (!this.chatWindow) {
+ // ignore focus out due to record being deleted
+ return;
+ }
+ this.chatWindow.update({ isFocused: false });
+ }
+
+ /**
+ * @private
+ * @param {KeyboardEvent} ev
+ */
+ _onKeydown(ev) {
+ /**
+ * Prevent auto-focus of fuzzy search in the home menu.
+ * Useful in order to allow copy/paste content inside chat window with
+ * CTRL-C & CTRL-V when on the home menu.
+ */
+ ev.stopPropagation();
+ switch (ev.key) {
+ case 'Tab':
+ ev.preventDefault();
+ if (ev.shiftKey) {
+ this.chatWindow.focusPreviousVisibleUnfoldedChatWindow();
+ } else {
+ this.chatWindow.focusNextVisibleUnfoldedChatWindow();
+ }
+ break;
+ case 'Escape':
+ ev.preventDefault();
+ this.chatWindow.close();
+ break;
+ }
+ }
+
+ /**
+ * Save the scroll positions of the chat window in the store.
+ * This is useful in order to remount chat windows and keep previous
+ * scroll positions. This is necessary because when toggling on/off
+ * home menu, the chat windows have to be remade from scratch.
+ *
+ * @private
+ */
+ async _onWillHideHomeMenu() {
+ if (!this.chatWindow.thread) {
+ return;
+ }
+ this._saveThreadScrollTop();
+ }
+
+ /**
+ * Save the scroll positions of the chat window in the store.
+ * This is useful in order to remount chat windows and keep previous
+ * scroll positions. This is necessary because when toggling on/off
+ * home menu, the chat windows have to be remade from scratch.
+ *
+ * @private
+ */
+ async _onWillShowHomeMenu() {
+ if (!this.chatWindow.thread) {
+ return;
+ }
+ this._saveThreadScrollTop();
+ }
+
+}
+
+Object.assign(ChatWindow, {
+ components,
+ defaultProps: {
+ hasCloseAsBackButton: false,
+ isExpandable: false,
+ isFullscreen: false,
+ },
+ props: {
+ chatWindowLocalId: String,
+ hasCloseAsBackButton: Boolean,
+ isExpandable: Boolean,
+ isFullscreen: Boolean,
+ },
+ template: 'mail.ChatWindow',
+});
+
+return ChatWindow;
+
+});
diff --git a/addons/mail/static/src/components/chat_window/chat_window.scss b/addons/mail/static/src/components/chat_window/chat_window.scss
new file mode 100644
index 00000000000..299da88575e
--- /dev/null
+++ b/addons/mail/static/src/components/chat_window/chat_window.scss
@@ -0,0 +1,101 @@
+// ------------------------------------------------------------------
+// Layout
+// ------------------------------------------------------------------
+
+.o_ChatWindow {
+ position: absolute;
+ bottom: 0;
+ display: flex;
+ flex-flow: column;
+
+ &:not(.o-mobile) {
+ max-width: 100%;
+ max-height: 100%;
+ width: 325px;
+
+ &.o-folded {
+ height: $o-mail-chat-window-header-height;
+ }
+
+ &:not(.o-folded) {
+ height: 400px;
+ }
+ }
+
+ &.o-mobile {
+ position: fixed;
+ }
+
+ &.o-fullscreen {
+ height: 100%;
+ width: 100%;
+ }
+}
+
+.o_ChatWindow_header {
+ flex: 0 0 auto;
+}
+
+.o_ChatWindow_newMessageForm {
+ padding: 3px;
+ margin-top: 3px;
+ display: flex;
+ align-items: center;
+}
+
+.o_ChatWindow_newMessageFormInput {
+ flex: 1 1 auto;
+}
+
+.o_ChatWindow_newMessageFormLabel {
+ margin-right: 5px;
+ flex: 0 0 auto;
+}
+
+.o_ChatWindow_thread {
+ flex: 1 1 auto;
+}
+
+// ------------------------------------------------------------------
+// Style
+// ------------------------------------------------------------------
+
+.o_ChatWindow {
+ background-color: $o-mail-thread-window-bg;
+ border-radius: 6px 6px 0 0;
+ box-shadow: -5px -5px 10px rgba(black, 0.09);
+ outline: none;
+
+ &:not(.o-mobile) {
+
+ &.o-focused {
+ box-shadow: -5px -5px 10px rgba(black, 0.18);
+ }
+ }
+
+
+ .o_Composer {
+ border: 0;
+ }
+}
+
+.o_ChatWindow_header {
+ border-radius: 3px 3px 0 0;
+}
+
+.o_ChatWindow_newMessageFormInput {
+ outline: none;
+ border: 1px solid gray('300'); // cancel firefox border on input focus
+}
+
+.o_ChatWindow_thread .o_ThreadViewer_messageList {
+ font-size: 1rem;
+}
+
+// ------------------------------------------------------------------
+// Animation
+// ------------------------------------------------------------------
+
+.o_ChatWindow:not(.o-mobile) {
+ transition: height 0.3s;
+}
diff --git a/addons/mail/static/src/components/chat_window/chat_window.xml b/addons/mail/static/src/components/chat_window/chat_window.xml
new file mode 100644
index 00000000000..c4f6117a25d
--- /dev/null
+++ b/addons/mail/static/src/components/chat_window/chat_window.xml
@@ -0,0 +1,56 @@
+<?xml version="1.0" encoding="UTF-8"?>
+<templates xml:space="preserve">
+
+ <t t-name="mail.ChatWindow" owl="1">
+ <div class="o_ChatWindow" tabindex="0"
+ t-att-class="{
+ 'o-focused': chatWindow and chatWindow.isFocused,
+ 'o-folded': chatWindow and chatWindow.isFolded,
+ 'o-fullscreen': props.isFullscreen,
+ 'o-mobile': env.messaging.device.isMobile,
+ 'o-new-message': chatWindow and !chatWindow.thread,
+ }" t-on-click="_onClick" t-on-keydown="_onKeydown" t-on-focusout="_onFocusout" t-att-data-chat-window-local-id="chatWindow ? chatWindow.localId : undefined" t-att-data-thread-local-id="chatWindow ? (chatWindow.thread ? chatWindow.thread.localId : '') : undefined"
+ >
+ <t t-if="chatWindow">
+ <ChatWindowHeader
+ class="o_ChatWindow_header"
+ chatWindowLocalId="chatWindow.localId"
+ hasCloseAsBackButton="props.hasCloseAsBackButton"
+ isExpandable="props.isExpandable"
+ t-on-o-clicked="_onClickedHeader"
+ t-ref="header"
+ />
+ <t t-if="chatWindow.thread">
+ <ThreadViewer
+ class="o_ChatWindow_thread"
+ composerAttachmentsDetailsMode="'card'"
+ hasComposer="chatWindow.thread.model !== 'mail.box'"
+ hasComposerCurrentPartnerAvatar="false"
+ hasComposerSendButton="env.messaging.device.isMobile"
+ hasSquashCloseMessages="chatWindow.thread.model !== 'mail.box'"
+ haveMessagesAuthorRedirect="chatWindow.thread.channel_type !== 'chat'"
+ threadViewerLocalId="chatWindow.threadViewer.localId"
+ t-on-focusin="_onFocusinThread"
+ t-ref="thread"
+ />
+ </t>
+ <t t-else="">
+ <div class="o_ChatWindow_newMessageForm">
+ <span class="o_ChatWindow_newMessageFormLabel">
+ To:
+ </span>
+ <AutocompleteInput
+ class="o_ChatWindow_newMessageFormInput"
+ isHtml="true"
+ placeholder="newMessageFormInputPlaceholder"
+ select="_onAutocompleteSelect"
+ source="_onAutocompleteSource"
+ t-ref="input"
+ />
+ </div>
+ </t>
+ </t>
+ </div>
+ </t>
+
+</templates>
diff --git a/addons/mail/static/src/components/chat_window_header/chat_window_header.js b/addons/mail/static/src/components/chat_window_header/chat_window_header.js
new file mode 100644
index 00000000000..60294ffc213
--- /dev/null
+++ b/addons/mail/static/src/components/chat_window_header/chat_window_header.js
@@ -0,0 +1,107 @@
+odoo.define('mail/static/src/components/chat_window_header/chat_window_header.js', function (require) {
+'use strict';
+
+const components = {
+ ThreadIcon: require('mail/static/src/components/thread_icon/thread_icon.js'),
+};
+const useStore = require('mail/static/src/component_hooks/use_store/use_store.js');
+
+const { Component } = owl;
+
+class ChatWindowHeader extends Component {
+
+ /**
+ * @override
+ */
+ constructor(...args) {
+ super(...args);
+ useStore(props => {
+ const chatWindow = this.env.models['mail.chat_window'].get(props.chatWindowLocalId);
+ const thread = chatWindow && chatWindow.thread;
+ return {
+ chatWindow: chatWindow ? chatWindow.__state : undefined,
+ isDeviceMobile: this.env.messaging.device.isMobile,
+ thread: thread ? thread.__state : undefined,
+ };
+ });
+ }
+
+ //--------------------------------------------------------------------------
+ // Public
+ //--------------------------------------------------------------------------
+
+ /**
+ * @returns {mail.chat_window}
+ */
+ get chatWindow() {
+ return this.env.models['mail.chat_window'].get(this.props.chatWindowLocalId);
+ }
+
+ //--------------------------------------------------------------------------
+ // Handlers
+ //--------------------------------------------------------------------------
+
+ /**
+ * @private
+ * @param {MouseEvent} ev
+ */
+ _onClick(ev) {
+ const chatWindow = this.chatWindow;
+ this.trigger('o-clicked', { chatWindow });
+ }
+
+ /**
+ * @private
+ * @param {MouseEvent} ev
+ */
+ _onClickClose(ev) {
+ ev.stopPropagation();
+ this.chatWindow.close();
+ }
+
+ /**
+ * @private
+ * @param {MouseEvent} ev
+ */
+ _onClickExpand(ev) {
+ ev.stopPropagation();
+ this.chatWindow.expand();
+ }
+
+ /**
+ * @private
+ * @param {MouseEvent} ev
+ */
+ _onClickShiftLeft(ev) {
+ ev.stopPropagation();
+ this.chatWindow.shiftLeft();
+ }
+
+ /**
+ * @private
+ * @param {MouseEvent} ev
+ */
+ _onClickShiftRight(ev) {
+ ev.stopPropagation();
+ this.chatWindow.shiftRight();
+ }
+
+}
+
+Object.assign(ChatWindowHeader, {
+ components,
+ defaultProps: {
+ hasCloseAsBackButton: false,
+ isExpandable: false,
+ },
+ props: {
+ chatWindowLocalId: String,
+ hasCloseAsBackButton: Boolean,
+ isExpandable: Boolean,
+ },
+ template: 'mail.ChatWindowHeader',
+});
+
+return ChatWindowHeader;
+
+});
diff --git a/addons/mail/static/src/components/chat_window_header/chat_window_header.scss b/addons/mail/static/src/components/chat_window_header/chat_window_header.scss
new file mode 100644
index 00000000000..c5c23634d20
--- /dev/null
+++ b/addons/mail/static/src/components/chat_window_header/chat_window_header.scss
@@ -0,0 +1,95 @@
+// ------------------------------------------------------------------
+// Layout
+// ------------------------------------------------------------------
+
+.o_ChatWindowHeader {
+ display: flex;
+ align-items: center;
+ height: $o-mail-chat-window-header-height;
+
+ &.o-mobile {
+ height: $o-mail-chat-window-header-height-mobile;
+ }
+}
+
+.o_ChatWindowHeader_command {
+ padding: 0 8px;
+ display: flex;
+ height: 100%;
+ align-items: center;
+
+ &:hover {
+ background-color: rgba(0, 0, 0, 0.1);
+ }
+}
+
+.o_ChatWindowHeader_commandBack {
+ margin-right: 5px;
+}
+
+.o_ChatWindowHeader_item {
+ margin: 0 3px;
+
+ &.o_ChatWindowHeader_rightArea {
+ margin-right: 0;
+ }
+
+ &:first-child {
+ margin-left: 10px;
+
+ &.o_ChatWindowHeader_command {
+ margin-left: 0px; // no margin for commands
+ }
+ }
+
+ &.o_ChatWindowHeader_rightArea:last-child .o_ChatWindowHeader_command {
+ margin-right: 0px; // no margin for commands
+ }
+}
+
+.o_ChatWindowHeader_name {
+ max-height: 100%;
+ white-space: nowrap;
+ overflow: hidden;
+ text-overflow: ellipsis;
+}
+
+.o_ChatWindowHeader_rightArea {
+ display: flex;
+ height: 100%;
+ align-items: center;
+}
+
+// ------------------------------------------------------------------
+// Style
+// ------------------------------------------------------------------
+
+.o_ChatWindowHeader {
+ background-color: $o-brand-odoo;
+ color: white;
+ cursor: pointer;
+
+ &:not(.o-mobile) {
+
+ &:hover .o_ChatWindowHeader_command {
+ opacity: 0.7;
+
+ &:hover {
+ opacity: 1;
+ }
+ }
+
+ &:not(:hover) .o_ChatWindowHeader_command {
+ opacity: 0.5;
+ }
+ }
+
+}
+
+.o_ChatWindowHeader_command.o-mobile {
+ font-size: 1.3rem;
+}
+
+.o_ChatWindowHeader_name {
+ user-select: none;
+}
diff --git a/addons/mail/static/src/components/chat_window_header/chat_window_header.xml b/addons/mail/static/src/components/chat_window_header/chat_window_header.xml
new file mode 100644
index 00000000000..0c3261282cc
--- /dev/null
+++ b/addons/mail/static/src/components/chat_window_header/chat_window_header.xml
@@ -0,0 +1,53 @@
+<?xml version="1.0" encoding="UTF-8"?>
+<templates xml:space="preserve">
+
+ <t t-name="mail.ChatWindowHeader" owl="1">
+ <div class="o_ChatWindowHeader" t-att-class="{ 'o-mobile': env.messaging.device.isMobile }" t-on-click="_onClick">
+ <t t-if="chatWindow">
+ <t t-if="props.hasCloseAsBackButton">
+ <div class="o_ChatWindowHeader_command o_ChatWindowHeader_commandBack o_ChatWindowHeader_commandClose" t-att-class="{ 'o-mobile': env.messaging.device.isMobile }" t-on-click="_onClickClose" title="Close conversation">
+ <i class="fa fa-arrow-left"/>
+ </div>
+ </t>
+ <t t-if="chatWindow.thread">
+ <ThreadIcon
+ class="o_ChatWindowHeader_icon o_ChatWindowHeader_item"
+ threadLocalId="chatWindow.thread.localId"
+ />
+ </t>
+ <div class="o_ChatWindowHeader_item o_ChatWindowHeader_name" t-att-title="chatWindow.name">
+ <t t-esc="chatWindow.name"/>
+ </div>
+ <t t-if="chatWindow.thread and chatWindow.thread.message_unread_counter > 0">
+ <div class="o_ChatWindowHeader_counter o_ChatWindowHeader_item">
+ (<t t-esc="chatWindow.thread.message_unread_counter"/>)
+ </div>
+ </t>
+ <div class="o-autogrow"/>
+ <div class="o_ChatWindowHeader_item o_ChatWindowHeader_rightArea">
+ <t t-if="chatWindow.hasShiftLeft">
+ <div class="o_ChatWindowHeader_command o_ChatWindowHeader_commandShiftLeft" t-att-class="{ 'o-mobile': env.messaging.device.isMobile }" t-on-click="_onClickShiftLeft" title="Shift left">
+ <i class="fa fa-angle-left"/>
+ </div>
+ </t>
+ <t t-if="chatWindow.hasShiftRight">
+ <div class="o_ChatWindowHeader_command o_ChatWindowHeader_commandShiftRight" t-att-class="{ 'o-mobile': env.messaging.device.isMobile }" t-on-click="_onClickShiftRight" title="Shift right">
+ <i class="fa fa-angle-right"/>
+ </div>
+ </t>
+ <t t-if="props.isExpandable">
+ <div class="o_ChatWindowHeader_command o_ChatWindowHeader_commandExpand" t-att-class="{ 'o-mobile': env.messaging.device.isMobile }" t-on-click="_onClickExpand" title="Open in Discuss">
+ <i class="fa fa-expand"/>
+ </div>
+ </t>
+ <t t-if="!props.hasCloseAsBackButton">
+ <div class="o_ChatWindowHeader_command o_ChatWindowHeader_commandClose" t-att-class="{ 'o-mobile': env.messaging.device.isMobile }" t-on-click="_onClickClose" title="Close chat window">
+ <i class="fa fa-close"/>
+ </div>
+ </t>
+ </div>
+ </t>
+ </div>
+ </t>
+
+</templates>
diff --git a/addons/mail/static/src/components/chat_window_hidden_menu/chat_window_hidden_menu.js b/addons/mail/static/src/components/chat_window_hidden_menu/chat_window_hidden_menu.js
new file mode 100644
index 00000000000..b598eeee4b4
--- /dev/null
+++ b/addons/mail/static/src/components/chat_window_hidden_menu/chat_window_hidden_menu.js
@@ -0,0 +1,142 @@
+odoo.define('mail/static/src/components/chat_window_hidden_menu/chat_window_hidden_menu.js', function (require) {
+'use strict';
+
+const components = {
+ ChatWindowHeader: require('mail/static/src/components/chat_window_header/chat_window_header.js'),
+};
+const useStore = require('mail/static/src/component_hooks/use_store/use_store.js');
+
+const { Component } = owl;
+const { useRef } = owl.hooks;
+
+class ChatWindowHiddenMenu extends Component {
+
+ /**
+ * @override
+ */
+ constructor(...args) {
+ super(...args);
+ useStore(props => {
+ const chatWindowManager = this.env.messaging.chatWindowManager;
+ const device = this.env.messaging.device;
+ const locale = this.env.messaging.locale;
+ return {
+ chatWindowManager: chatWindowManager ? chatWindowManager.__state : undefined,
+ device: device ? device.__state : undefined,
+ localeTextDirection: locale ? locale.textDirection : undefined,
+ };
+ });
+ this._onClickCaptureGlobal = this._onClickCaptureGlobal.bind(this);
+ /**
+ * Reference of the dropup list. Useful to auto-set max height based on
+ * browser screen height.
+ */
+ this._listRef = useRef('list');
+ /**
+ * The intent of the toggle button depends on the last rendered state.
+ */
+ this._wasMenuOpen;
+ }
+
+ mounted() {
+ this._apply();
+ document.addEventListener('click', this._onClickCaptureGlobal, true);
+ }
+
+ patched() {
+ this._apply();
+ }
+
+ willUnmount() {
+ document.removeEventListener('click', this._onClickCaptureGlobal, true);
+ }
+
+ //--------------------------------------------------------------------------
+ // Private
+ //--------------------------------------------------------------------------
+
+ /**
+ * @private
+ */
+ _apply() {
+ this._applyListHeight();
+ this._applyOffset();
+ this._wasMenuOpen = this.env.messaging.chatWindowManager.isHiddenMenuOpen;
+ }
+
+ /**
+ * @private
+ */
+ _applyListHeight() {
+ const device = this.env.messaging.device;
+ const height = device.globalWindowInnerHeight / 2;
+ this._listRef.el.style['max-height'] = `${height}px`;
+ }
+
+ /**
+ * @private
+ */
+ _applyOffset() {
+ const textDirection = this.env.messaging.locale.textDirection;
+ const offsetFrom = textDirection === 'rtl' ? 'left' : 'right';
+ const oppositeFrom = offsetFrom === 'right' ? 'left' : 'right';
+ const offset = this.env.messaging.chatWindowManager.visual.hidden.offset;
+ this.el.style[offsetFrom] = `${offset}px`;
+ this.el.style[oppositeFrom] = 'auto';
+ }
+
+ //--------------------------------------------------------------------------
+ // Handlers
+ //--------------------------------------------------------------------------
+
+ /**
+ * Closes the menu when clicking outside.
+ * Must be done as capture to avoid stop propagation.
+ *
+ * @private
+ * @param {MouseEvent} ev
+ */
+ _onClickCaptureGlobal(ev) {
+ if (this.el.contains(ev.target)) {
+ return;
+ }
+ this.env.messaging.chatWindowManager.closeHiddenMenu();
+ }
+
+ /**
+ * @private
+ * @param {MouseEvent} ev
+ */
+ _onClickToggle(ev) {
+ if (this._wasMenuOpen) {
+ this.env.messaging.chatWindowManager.closeHiddenMenu();
+ } else {
+ this.env.messaging.chatWindowManager.openHiddenMenu();
+ }
+ }
+
+ /**
+ * @private
+ * @param {CustomEvent} ev
+ * @param {Object} ev.detail
+ * @param {mail.chat_window} ev.detail.chatWindow
+ */
+ _onClickedChatWindow(ev) {
+ const chatWindow = ev.detail.chatWindow;
+ chatWindow.makeVisible();
+ chatWindow.unfold();
+ chatWindow.focus();
+ this.env.messaging.chatWindowManager.closeHiddenMenu();
+ }
+
+}
+
+Object.assign(ChatWindowHiddenMenu, {
+ components,
+ props: {},
+ template: 'mail.ChatWindowHiddenMenu',
+});
+
+return ChatWindowHiddenMenu;
+
+});
diff --git a/addons/mail/static/src/components/chat_window_hidden_menu/chat_window_hidden_menu.scss b/addons/mail/static/src/components/chat_window_hidden_menu/chat_window_hidden_menu.scss
new file mode 100644
index 00000000000..119d61848ab
--- /dev/null
+++ b/addons/mail/static/src/components/chat_window_hidden_menu/chat_window_hidden_menu.scss
@@ -0,0 +1,90 @@
+// ------------------------------------------------------------------
+// Layout
+// ------------------------------------------------------------------
+
+.o_ChatWindowHiddenMenu {
+ position: fixed;
+ bottom: 0;
+ display: flex;
+ width: 50px;
+ height: 28px;
+ align-items: stretch;
+}
+
+.o_ChatWindowHiddenMenu_chatWindowHeader {
+ max-width: 200px;
+}
+
+.o_ChatWindowHiddenMenu_dropdownToggle {
+ display: flex;
+ align-items: center;
+ justify-content: center;
+ flex: 1 1 auto;
+ max-width: 100%;
+}
+
+.o_ChatWindowHiddenMenu_dropdownToggleIcon {
+ margin-right: 1px;
+}
+
+.o_ChatWindowHiddenMenu_dropdownToggleItem {
+ margin: 0 3px;
+}
+
+.o_ChatWindowHiddenMenu_list {
+ overflow: auto;
+ margin: 0;
+ padding: 0;
+}
+
+.o_ChatWindowHiddenMenu_listItem {
+
+ &:not(:last-child) {
+ margin-bottom: 1px;
+ }
+}
+
+.o_ChatWindowHiddenMenu_unreadCounter {
+ position: absolute;
+ right: 0;
+ top: 0;
+ transform: translate(50%, -50%);
+ z-index: 1001; // on top of bootstrap dropup menu
+}
+
+.o_ChatWindowHiddenMenu_windowCounter {
+ overflow: hidden;
+ text-overflow: ellipsis;
+ margin-left: 1px;
+}
+
+// ------------------------------------------------------------------
+// Style
+// ------------------------------------------------------------------
+
+.o_ChatWindowHiddenMenu {
+ background-color: gray('900');
+ border-radius: 6px 6px 0 0;
+ color: white;
+ cursor: pointer;
+}
+
+.o_ChatWindowHiddenMenu_chatWindowHeader {
+ opacity: 0.95;
+
+ &:hover {
+ opacity: 1;
+ }
+}
+
+.o_ChatWindowHiddenMenu_dropdownToggle.show {
+ opacity: 0.5;
+}
+
+.o_ChatWindowHiddenMenu_unreadCounter {
+ background-color: $o-brand-primary;
+}
+
+.o_ChatWindowHiddenMenu_windowCounter {
+ user-select: none;
+}
diff --git a/addons/mail/static/src/components/chat_window_hidden_menu/chat_window_hidden_menu.xml b/addons/mail/static/src/components/chat_window_hidden_menu/chat_window_hidden_menu.xml
new file mode 100644
index 00000000000..943fc71d09d
--- /dev/null
+++ b/addons/mail/static/src/components/chat_window_hidden_menu/chat_window_hidden_menu.xml
@@ -0,0 +1,31 @@
+<?xml version="1.0" encoding="UTF-8"?>
+<templates xml:space="preserve">
+
+ <t t-name="mail.ChatWindowHiddenMenu" owl="1">
+ <div class="dropup o_ChatWindowHiddenMenu">
+ <div class="dropdown-toggle o_ChatWindowHiddenMenu_dropdownToggle" t-att-class="{ show: env.messaging.chatWindowManager.isHiddenMenuOpen }" t-on-click="_onClickToggle">
+ <div class="fa fa-comments-o o_ChatWindowHiddenMenu_dropdownToggleIcon o_ChatWindowHiddenMenu_dropdownToggleItem"/>
+ <div class="o_ChatWindowHiddenMenu_dropdownToggleItem o_ChatWindowHiddenMenu_windowCounter">
+ <t t-esc="env.messaging.chatWindowManager.allOrderedHidden.length"/>
+ </div>
+ </div>
+ <ul class="dropdown-menu dropdown-menu-right o_ChatWindowHiddenMenu_list" t-att-class="{ show: env.messaging.chatWindowManager.isHiddenMenuOpen }" role="menu" t-ref="list">
+ <t t-foreach="env.messaging.chatWindowManager.allOrderedHidden" t-as="chatWindow" t-key="chatWindow.localId">
+ <li class="o_ChatWindowHiddenMenu_listItem" role="menuitem">
+ <ChatWindowHeader
+ class="o_ChatWindowHiddenMenu_chatWindowHeader"
+ chatWindowLocalId="chatWindow.localId"
+ t-on-o-clicked="_onClickedChatWindow"
+ />
+ </li>
+ </t>
+ </ul>
+ <t t-if="env.messaging.chatWindowManager.unreadHiddenConversationAmount > 0">
+ <div class="badge badge-pill o_ChatWindowHiddenMenu_unreadCounter">
+ <t t-esc="env.messaging.chatWindowManager.unreadHiddenConversationAmount"/>
+ </div>
+ </t>
+ </div>
+ </t>
+
+</templates>
diff --git a/addons/mail/static/src/components/chat_window_manager/chat_window_manager.js b/addons/mail/static/src/components/chat_window_manager/chat_window_manager.js
new file mode 100644
index 00000000000..956c6811e68
--- /dev/null
+++ b/addons/mail/static/src/components/chat_window_manager/chat_window_manager.js
@@ -0,0 +1,37 @@
+odoo.define('mail/static/src/components/chat_window_manager/chat_window_manager.js', function (require) {
+'use strict';
+
+const components = {
+ ChatWindow: require('mail/static/src/components/chat_window/chat_window.js'),
+ ChatWindowHiddenMenu: require('mail/static/src/components/chat_window_hidden_menu/chat_window_hidden_menu.js'),
+};
+const useStore = require('mail/static/src/component_hooks/use_store/use_store.js');
+
+const { Component } = owl;
+
+class ChatWindowManager extends Component {
+
+ /**
+ * @override
+ */
+ constructor(...args) {
+ super(...args);
+ useStore(props => {
+ return {
+ chatWindowManagerVisual: this.env.messaging.chatWindowManager.visual,
+ device: this.env.messaging.device,
+ };
+ });
+ }
+
+}
+
+Object.assign(ChatWindowManager, {
+ components,
+ props: {},
+ template: 'mail.ChatWindowManager',
+});
+
+return ChatWindowManager;
+
+});
diff --git a/addons/mail/static/src/components/chat_window_manager/chat_window_manager.scss b/addons/mail/static/src/components/chat_window_manager/chat_window_manager.scss
new file mode 100644
index 00000000000..1e8abf54184
--- /dev/null
+++ b/addons/mail/static/src/components/chat_window_manager/chat_window_manager.scss
@@ -0,0 +1,16 @@
+// ------------------------------------------------------------------
+// Layout
+// ------------------------------------------------------------------
+
+.o_ChatWindowManager {
+ bottom: 0;
+ right: 0;
+ display: flex;
+ flex-direction: row-reverse;
+ z-index: 1000;
+}
+
+// ------------------------------------------------------------------
+// Style
+// ------------------------------------------------------------------
+
diff --git a/addons/mail/static/src/components/chat_window_manager/chat_window_manager.xml b/addons/mail/static/src/components/chat_window_manager/chat_window_manager.xml
new file mode 100644
index 00000000000..3ced7210da1
--- /dev/null
+++ b/addons/mail/static/src/components/chat_window_manager/chat_window_manager.xml
@@ -0,0 +1,21 @@
+<?xml version="1.0" encoding="UTF-8"?>
+<templates xml:space="preserve">
+
+ <t t-name="mail.ChatWindowManager" owl="1">
+ <div class="o_ChatWindowManager">
+ <!-- Note: DOM elements are ordered from left to right -->
+ <t t-if="env.messaging.chatWindowManager.hasHiddenChatWindows">
+ <ChatWindowHiddenMenu class="o_ChatWindowManager_hiddenMenu"/>
+ </t>
+ <t t-foreach="env.messaging.chatWindowManager.allOrderedVisible" t-as="chatWindow" t-key="chatWindow.localId">
+ <ChatWindow
+ chatWindowLocalId="chatWindow.localId"
+ hasCloseAsBackButton="env.messaging.device.isMobile"
+ isExpandable="!env.messaging.device.isMobile and !!chatWindow.thread"
+ isFullscreen="env.messaging.device.isMobile"
+ />
+ </t>
+ </div>
+ </t>
+
+</templates>
diff --git a/addons/mail/static/src/components/chat_window_manager/chat_window_manager_tests.js b/addons/mail/static/src/components/chat_window_manager/chat_window_manager_tests.js
new file mode 100644
index 00000000000..f987a40db46
--- /dev/null
+++ b/addons/mail/static/src/components/chat_window_manager/chat_window_manager_tests.js
@@ -0,0 +1,1243 @@
+odoo.define('mail/static/src/components/chat_window_manager/chat_window_manager_tests.js', function (require) {
+'use strict';
+
+const {
+ afterEach: utilsAfterEach,
+ afterNextRender,
+ beforeEach: utilsBeforeEach,
+ inputFiles,
+ start: utilsStart,
+} = require('mail/static/src/utils/test_utils.js');
+
+const {
+ file: { createFile },
+ dom: { triggerEvent },
+} = require('web.test_utils');
+
+QUnit.module('mail', {}, function () {
+QUnit.module('components', {}, function () {
+QUnit.module('chat_window_manager', {}, function () {
+QUnit.module('chat_window_manager_tests.js', {
+ beforeEach() {
+ utilsBeforeEach(this);
+
+ this.start = async params => {
+ let { env, widget } = await utilsStart(Object.assign({
+ hasChatWindow: true,
+ hasMessagingMenu: true,
+ }, params, {
+ data: this.data,
+ }));
+ this.env = env;
+ this.widget = widget;
+ };
+ },
+ afterEach() {
+ utilsAfterEach(this);
+ if (this.widget) {
+ this.widget.destroy();
+ }
+ },
+});
+
+QUnit.test('initial mount', async function (assert) {
+ assert.expect(1);
+
+ await this.start();
+ assert.strictEqual(document.querySelectorAll('.o_ChatWindowManager').length,
+ 1,
+ "should have chat window manager");
+});
+
+QUnit.test('chat window new message: basic rendering', async function (assert) {
+ assert.expect(10);
+
+ await this.start({
+ async mockRPC(route, args) {
+ if (args.method === 'channel_fetch_preview') {
+ return [];
+ }
+ return this._super(...arguments);
+ },
+ });
+ await afterNextRender(() =>
+ document.querySelector(`.o_MessagingMenu_toggler`).click()
+ );
+ await afterNextRender(() =>
+ document.querySelector(`.o_MessagingMenu_newMessageButton`).click()
+ );
+ assert.strictEqual(
+ document.querySelectorAll(`.o_ChatWindow`).length,
+ 1,
+ "should have open a chat window"
+ );
+ assert.strictEqual(
+ document.querySelectorAll(`.o_ChatWindow_header`).length,
+ 1,
+ "should have a header"
+ );
+ assert.strictEqual(
+ document.querySelectorAll(`.o_ChatWindow_header .o_ChatWindowHeader_name`).length,
+ 1,
+ "should have name part in header"
+ );
+ assert.strictEqual(
+ document.querySelector(`.o_ChatWindow_header .o_ChatWindowHeader_name`).textContent,
+ "New message",
+ "should display 'new message' in the header"
+ );
+ assert.strictEqual(
+ document.querySelectorAll(`.o_ChatWindow_header .o_ChatWindowHeader_command`).length,
+ 1,
+ "should have 1 command in header"
+ );
+ assert.strictEqual(
+ document.querySelectorAll(`.o_ChatWindow_header .o_ChatWindowHeader_commandClose`).length,
+ 1,
+ "should have command to close chat window"
+ );
+ assert.strictEqual(
+ document.querySelectorAll(`.o_ChatWindow_newMessageForm`).length,
+ 1,
+ "should have a new message chat window container"
+ );
+ assert.strictEqual(
+ document.querySelectorAll(`.o_ChatWindow_newMessageFormLabel`).length,
+ 1,
+ "should have a part in selection with label"
+ );
+ assert.strictEqual(
+ document.querySelector(`.o_ChatWindow_newMessageFormLabel`).textContent.trim(),
+ "To:",
+ "should have label 'To:' in selection"
+ );
+ assert.strictEqual(
+ document.querySelectorAll(`.o_ChatWindow_newMessageFormInput`).length,
+ 1,
+ "should have an input in selection"
+ );
+});
+
+QUnit.test('chat window new message: focused on open', async function (assert) {
+ assert.expect(2);
+
+ await this.start({
+ async mockRPC(route, args) {
+ if (args.method === 'channel_fetch_preview') {
+ return [];
+ }
+ return this._super(...arguments);
+ },
+ });
+ await afterNextRender(() =>
+ document.querySelector(`.o_MessagingMenu_toggler`).click()
+ );
+ await afterNextRender(() =>
+ document.querySelector(`.o_MessagingMenu_newMessageButton`).click()
+ );
+ assert.ok(
+ document.querySelector(`.o_ChatWindow`).classList.contains('o-focused'),
+ "chat window should be focused"
+ );
+ assert.ok(
+ document.activeElement,
+ document.querySelector(`.o_ChatWindow_newMessageFormInput`),
+ "chat window focused = selection input focused"
+ );
+});
+
+QUnit.test('chat window new message: close', async function (assert) {
+ assert.expect(1);
+
+ await this.start({
+ async mockRPC(route, args) {
+ if (args.method === 'channel_fetch_preview') {
+ return [];
+ }
+ return this._super(...arguments);
+ },
+ });
+ await afterNextRender(() =>
+ document.querySelector(`.o_MessagingMenu_toggler`).click()
+ );
+ await afterNextRender(() =>
+ document.querySelector(`.o_MessagingMenu_newMessageButton`).click()
+ );
+ await afterNextRender(() =>
+ document.querySelector(`.o_ChatWindow_header .o_ChatWindowHeader_commandClose`).click()
+ );
+ assert.strictEqual(
+ document.querySelectorAll(`.o_ChatWindow`).length,
+ 0,
+ "chat window should be closed"
+ );
+});
+
+QUnit.test('chat window new message: fold', async function (assert) {
+ assert.expect(3);
+
+ await this.start({
+ async mockRPC(route, args) {
+ if (args.method === 'channel_fetch_preview') {
+ return [];
+ }
+ return this._super(...arguments);
+ },
+ });
+ await afterNextRender(() => document.querySelector(`.o_MessagingMenu_toggler`).click());
+ await afterNextRender(() =>
+ document.querySelector(`.o_MessagingMenu_newMessageButton`).click()
+ );
+ assert.notOk(
+ document.querySelector(`.o_ChatWindow`).classList.contains('o-folded'),
+ "chat window should not be folded by default"
+ );
+
+ await afterNextRender(() => document.querySelector(`.o_ChatWindow_header`).click());
+ assert.ok(
+ document.querySelector(`.o_ChatWindow`).classList.contains('o-folded'),
+ "chat window should become folded"
+ );
+
+ await afterNextRender(() => document.querySelector(`.o_ChatWindow_header`).click());
+ assert.notOk(
+ document.querySelector(`.o_ChatWindow`).classList.contains('o-folded'),
+ "chat window should become unfolded"
+ );
+});
+
+QUnit.test('chat window: basic rendering', async function (assert) {
+ assert.expect(11);
+
+ Object.assign(this.data.initMessaging, {
+ channel_slots: {
+ channel_channel: [{
+ channel_type: "channel",
+ id: 20,
+ name: "General",
+ }],
+ },
+ });
+ await this.start({
+ async mockRPC(route, args) {
+ if (args.method === 'channel_fetch_preview') {
+ return [{
+ id: 20,
+ last_message: {
+ author_id: [7, "Demo"],
+ body: "<p>test</p>",
+ channel_ids: [20],
+ id: 100,
+ message_type: 'comment',
+ model: 'mail.channel',
+ res_id: 20,
+ },
+ }];
+ }
+ return this._super(...arguments);
+ },
+ });
+ await afterNextRender(() =>
+ document.querySelector(`.o_MessagingMenu_toggler`).click()
+ );
+ await afterNextRender(() =>
+ document.querySelector(`.o_NotificationList_preview`).click()
+ );
+ assert.strictEqual(
+ document.querySelectorAll(`.o_ChatWindow`).length,
+ 1,
+ "should have open a chat window"
+ );
+ const chatWindow = document.querySelector(`.o_ChatWindow`);
+ assert.strictEqual(
+ chatWindow.dataset.threadLocalId,
+ this.env.models['mail.thread'].find(thread =>
+ thread.id === 20 &&
+ thread.model === 'mail.channel'
+ ).localId,
+ "should have open a chat window of channel"
+ );
+ assert.strictEqual(
+ chatWindow.querySelectorAll(`:scope .o_ChatWindow_header`).length,
+ 1,
+ "should have header part"
+ );
+ const chatWindowHeader = chatWindow.querySelector(`:scope .o_ChatWindow_header`);
+ assert.strictEqual(
+ chatWindowHeader.querySelectorAll(`:scope .o_ThreadIcon`).length,
+ 1,
+ "should have thread icon in header part"
+ );
+ assert.strictEqual(
+ chatWindowHeader.querySelectorAll(`:scope .o_ChatWindowHeader_name`).length,
+ 1,
+ "should have thread name in header part"
+ );
+ assert.strictEqual(
+ chatWindowHeader.querySelector(`:scope .o_ChatWindowHeader_name`).textContent,
+ "General",
+ "should have correct thread name in header part"
+ );
+ assert.strictEqual(
+ chatWindowHeader.querySelectorAll(`:scope .o_ChatWindowHeader_command`).length,
+ 2,
+ "should have 2 commands in header part"
+ );
+ assert.strictEqual(
+ chatWindowHeader.querySelectorAll(`:scope .o_ChatWindowHeader_commandExpand`).length,
+ 1,
+ "should have command to expand thread in discuss"
+ );
+ assert.strictEqual(
+ chatWindowHeader.querySelectorAll(`:scope .o_ChatWindowHeader_commandClose`).length,
+ 1,
+ "should have command to close chat window"
+ );
+ assert.strictEqual(
+ chatWindow.querySelectorAll(`:scope .o_ChatWindow_thread`).length,
+ 1,
+ "should have part to display thread content inside chat window"
+ );
+ assert.ok(
+ chatWindow.querySelector(`:scope .o_ChatWindow_thread`).classList.contains('o_ThreadViewer'),
+ "thread viewer part should use component ThreadViewer"
+ );
+});
+
+QUnit.test('chat window: fold', async function (assert) {
+ assert.expect(24);
+
+ let foldCall = 0;
+ Object.assign(this.data.initMessaging, {
+ channel_slots: {
+ channel_channel: [{
+ channel_type: 'channel',
+ id: 20,
+ is_minimized: false,
+ name: "General",
+ state: 'open',
+ uuid: 'channel-20-uuid',
+ }],
+ },
+ });
+ await this.start({
+ async mockRPC(route, args) {
+ if (args.method === 'channel_fetch_preview') {
+ return [{
+ id: 20,
+ last_message: {
+ author_id: [7, "Demo"],
+ body: "<p>test</p>",
+ channel_ids: [20],
+ id: 100,
+ message_type: 'comment',
+ model: 'mail.channel',
+ res_id: 20,
+ },
+ }];
+ } else if (args.method === 'channel_fold') {
+ assert.step(`rpc:${args.method}/${args.kwargs.state}`);
+ foldCall++;
+ const kwargsKeys = Object.keys(args.kwargs);
+ assert.strictEqual(
+ args.args.length,
+ 0,
+ "channel_fold call have no args"
+ );
+ assert.strictEqual(
+ kwargsKeys.length,
+ 2,
+ "channel_fold call have exactly 2 kwargs"
+ );
+ assert.ok(
+ kwargsKeys.includes('state'),
+ "channel_fold call have 'state' kwargs"
+ );
+ assert.ok(
+ kwargsKeys.includes('uuid'),
+ "channel_fold call have 'uuid' kwargs"
+ );
+ assert.strictEqual(
+ args.kwargs.uuid,
+ 'channel-20-uuid',
+ "channel_fold call uuid is from channel 20"
+ );
+ if (foldCall % 2 === 0) {
+ assert.strictEqual(
+ args.kwargs.state,
+ 'folded',
+ "channel_fold call state is 'folded'"
+ );
+ } else {
+ assert.strictEqual(
+ args.kwargs.state,
+ 'open',
+ "channel_fold call state is 'open'"
+ );
+ }
+ return [];
+ }
+ return this._super(...arguments);
+ },
+ });
+ // Open Thread
+ await afterNextRender(() => document.querySelector(`.o_MessagingMenu_toggler`).click());
+ await afterNextRender(() =>
+ document.querySelector(`.o_MessagingMenu_dropdownMenu .o_NotificationList_preview`).click()
+ );
+ assert.verifySteps(['rpc:channel_fold/open']);
+ // Fold chat window
+ await afterNextRender(() => document.querySelector(`.o_ChatWindow_header`).click());
+ assert.verifySteps(['rpc:channel_fold/folded']);
+ // Unfold chat window
+ await afterNextRender(() => document.querySelector(`.o_ChatWindow_header`).click());
+ assert.verifySteps(['rpc:channel_fold/open']);
+});
+
+QUnit.test('chat window: open / close', async function (assert) {
+ assert.expect(24);
+
+ let foldCall = 0;
+ Object.assign(this.data.initMessaging, {
+ channel_slots: {
+ channel_channel: [{
+ channel_type: 'channel',
+ id: 20,
+ is_minimized: false,
+ name: "General",
+ state: 'open',
+ uuid: 'channel-20-uuid',
+ }],
+ },
+ });
+ await this.start({
+ async mockRPC(route, args) {
+ if (args.method === 'channel_fetch_preview') {
+ return [{
+ id: 20,
+ last_message: {
+ author_id: [7, "Demo"],
+ body: "<p>test</p>",
+ channel_ids: [20],
+ id: 100,
+ message_type: 'comment',
+ model: 'mail.channel',
+ res_id: 20,
+ },
+ }];
+ } else if (args.method === 'channel_fold') {
+ assert.step(`rpc:channel_fold/${args.kwargs.state}`);
+ foldCall++;
+ const kwargsKeys = Object.keys(args.kwargs);
+ assert.strictEqual(
+ args.args.length,
+ 0,
+ "channel_fold call have no args"
+ );
+ assert.strictEqual(
+ kwargsKeys.length,
+ 2,
+ "channel_fold call have exactly 2 kwargs"
+ );
+ assert.ok(
+ kwargsKeys.includes('state'),
+ "channel_fold call have 'state' kwargs"
+ );
+ assert.ok(
+ kwargsKeys.includes('uuid'),
+ "channel_fold call have 'uuid' kwargs"
+ );
+ assert.strictEqual(
+ args.kwargs.uuid,
+ 'channel-20-uuid',
+ "channel_fold call uuid should be correct"
+ );
+ if (foldCall % 2 === 0) {
+ assert.strictEqual(
+ args.kwargs.state,
+ 'closed',
+ "channel_fold call state is 'closed'"
+ );
+ } else {
+ assert.strictEqual(
+ args.kwargs.state,
+ 'open',
+ "channel_fold call state is 'open'"
+ );
+ }
+ return [];
+ }
+ return this._super(...arguments);
+ },
+ });
+ await afterNextRender(() => document.querySelector(`.o_MessagingMenu_toggler`).click());
+ await afterNextRender(() =>
+ document.querySelector(`.o_MessagingMenu_dropdownMenu .o_NotificationList_preview`).click()
+ );
+ assert.verifySteps(['rpc:channel_fold/open']);
+
+ // Close chat window
+ await afterNextRender(() => document.querySelector(`.o_ChatWindowHeader_commandClose`).click());
+ assert.verifySteps(['rpc:channel_fold/closed']);
+
+ // Reopen chat window
+ await afterNextRender(() => document.querySelector(`.o_MessagingMenu_toggler`).click());
+ await afterNextRender(() =>
+ document.querySelector(`.o_MessagingMenu_dropdownMenu .o_NotificationList_preview`).click()
+ );
+ assert.verifySteps(['rpc:channel_fold/open']);
+});
+
+QUnit.test('chat window: close on ESCAPE', async function (assert) {
+ assert.expect(4);
+
+ Object.assign(this.data.initMessaging, {
+ channel_slots: {
+ channel_channel: [{
+ channel_type: 'channel',
+ id: 20,
+ is_minimized: false,
+ name: "General",
+ state: 'open',
+ uuid: 'channel-20-uuid',
+ }],
+ },
+ });
+ await this.start({
+ async mockRPC(route, args) {
+ if (args.method === 'channel_fetch_preview') {
+ return [{
+ id: 20,
+ last_message: {
+ author_id: [7, "Demo"],
+ body: "<p>test</p>",
+ channel_ids: [20],
+ id: 100,
+ message_type: 'comment',
+ model: 'mail.channel',
+ res_id: 20,
+ },
+ }];
+ } else if (args.method === 'channel_fold') {
+ assert.step(`rpc:channel_fold/${args.kwargs.state}`);
+ return [];
+ }
+ return this._super(...arguments);
+ },
+ });
+ await afterNextRender(() => document.querySelector(`.o_MessagingMenu_toggler`).click());
+ await afterNextRender(() =>
+ document.querySelector(`.o_MessagingMenu_dropdownMenu .o_NotificationList_preview`).click()
+ );
+ assert.verifySteps(['rpc:channel_fold/open']);
+
+ await afterNextRender(() => {
+ document.querySelector(`.o_ChatWindow`).focus();
+ const kevt = new window.KeyboardEvent('keydown', { bubbles: true, key: "Escape" });
+ document.querySelector(`.o_ChatWindow`).dispatchEvent(kevt);
+ });
+ assert.verifySteps(['rpc:channel_fold/closed']);
+});
+
+QUnit.test('chat window: state conservation on toggle home menu', async function (assert) {
+ assert.expect(9);
+
+ Object.assign(this.data.initMessaging, {
+ channel_slots: {
+ channel_channel: [{
+ channel_type: 'channel',
+ id: 20,
+ is_minimized: false,
+ name: "General",
+ state: 'open',
+ uuid: 'channel-20-uuid',
+ }],
+ },
+ });
+ await this.start({
+ async mockRPC(route, args) {
+ if (args.method === 'channel_fetch_preview') {
+ return [{
+ id: 20,
+ last_message: {
+ author_id: [7, "Demo"],
+ body: "<p>test</p>",
+ channel_ids: [20],
+ id: 100,
+ message_type: 'comment',
+ model: 'mail.channel',
+ res_id: 20,
+ },
+ }];
+ } else if (args.method === 'channel_fold') {
+ return;
+ } else if (args.method === 'message_fetch') {
+ return [...Array(20).keys()].map(i => {
+ return {
+ author_id: [11, "Demo"],
+ body: "<p>body</p>",
+ channel_ids: [20],
+ date: "2019-04-20 10:00:00",
+ id: i + 10,
+ message_type: 'comment',
+ model: 'mail.channel',
+ record_name: 'General',
+ res_id: i + 1,
+ };
+ });
+ }
+ return this._super(...arguments);
+ },
+ });
+ await afterNextRender(() => document.querySelector(`.o_MessagingMenu_toggler`).click());
+ await afterNextRender(() =>
+ document.querySelector(`.o_MessagingMenu_dropdownMenu .o_NotificationList_preview`).click()
+ );
+ // Set a scroll position to chat window
+ document.querySelector(`.o_ThreadViewer_messageList`).scrollTop = 142;
+ // Set html content of the composer of the chat window
+ let composerTextInputTextArea = document.querySelector(`.o_ComposerTextInput_textarea`);
+ composerTextInputTextArea.focus();
+ document.execCommand('insertText', false, 'XDU for the win !');
+ // Set attachments of the composer
+ const files = [
+ await createFile({
+ name: 'text state conservation on toggle home menu.txt',
+ content: 'hello, world',
+ contentType: 'text/plain',
+ }),
+ await createFile({
+ name: 'text2 state conservation on toggle home menu.txt',
+ content: 'hello, xdu is da best man',
+ contentType: 'text/plain',
+ })
+ ];
+ await afterNextRender(() =>
+ inputFiles(
+ document.querySelector('.o_FileUploader_input'),
+ files
+ )
+ );
+ assert.strictEqual(
+ document.querySelector(`.o_ThreadViewer_messageList`).scrollTop,
+ 142,
+ "verify chat window initial scrollTop"
+ );
+ composerTextInputTextArea = document.querySelector(`.o_ComposerTextInput_textarea`);
+ assert.strictEqual(
+ composerTextInputTextArea.value,
+ "XDU for the win !",
+ "verifif chat window composer initial html input"
+ );
+ assert.strictEqual(
+ document.querySelectorAll(`.o_Composer .o_Attachment`).length,
+ 2,
+ "verify chat window composer initial attachment count"
+ );
+ // Hide home menu
+ await this.env.bus.trigger('will_hide_home_menu');
+ await this.env.bus.trigger('hide_home_menu');
+ assert.strictEqual(
+ document.querySelector(`.o_ThreadViewer_messageList`).scrollTop,
+ 142,
+ "chat window scrollTop should still be the same (1)"
+ );
+ composerTextInputTextArea = document.querySelector(`.o_ComposerTextInput_textarea`);
+ assert.strictEqual(
+ composerTextInputTextArea.value,
+ "XDU for the win !",
+ "Chat window composer should still have the same html input (1)"
+ );
+ assert.strictEqual(
+ document.querySelectorAll(`.o_Composer .o_Attachment`).length,
+ 2,
+ "Chat window composer should have 2 attachments (1)"
+ );
+
+ // Show home menu
+ await this.env.bus.trigger('will_show_home_menu');
+ await this.env.bus.trigger('show_home_menu');
+ assert.strictEqual(
+ document.querySelector(`.o_ThreadViewer_messageList`).scrollTop,
+ 142,
+ "chat window scrollTop should still be the same (2)"
+ );
+ composerTextInputTextArea = document.querySelector(`.o_ComposerTextInput_textarea`);
+ assert.strictEqual(
+ composerTextInputTextArea.value,
+ "XDU for the win !",
+ "chat window composer should still have the same html input (2)"
+ );
+ assert.strictEqual(
+ document.querySelectorAll(`.o_Composer .o_Attachment`).length,
+ 2,
+ "Chat window composer should have 2 attachments (2)"
+ );
+});
+
+QUnit.test('open 2 different chat windows: enough screen width [REQUIRE FOCUS]', async function (assert) {
+ /**
+ * computation uses following info:
+ * ([mocked] global window width: @see `mail/static/src/utils/test_utils.js:start()` method)
+ * (others: @see mail/static/src/models/chat_window_manager/chat_window_manager.js:visual)
+ *
+ * - chat window width: 325px
+ * - start/end/between gap width: 10px/10px/5px
+ * - hidden menu width: 200px
+ * - global width: 1920px
+ *
+ * Enough space for 2 visible chat windows:
+ * 10 + 325 + 5 + 325 + 10 = 670 < 1920
+ */
+ assert.expect(8);
+
+ Object.assign(this.data.initMessaging, {
+ channel_slots: {
+ channel_channel: [{
+ channel_type: "channel",
+ id: 20,
+ name: "General",
+ }],
+ channel_direct_message: [{
+ channel_type: "chat",
+ direct_partner: [{
+ id: 7,
+ name: "Demo",
+ }],
+ id: 10,
+ }],
+ },
+ });
+ await this.start({
+ env: {
+ browser: {
+ innerHeight: 1080,
+ innerWidth: 1920,
+ },
+ },
+ async mockRPC(route, args) {
+ if (args.method === 'channel_fetch_preview') {
+ return [{
+ id: 20,
+ last_message: {
+ author_id: [7, "Demo"],
+ body: "<p>test</p>",
+ channel_ids: [20],
+ id: 100,
+ message_type: 'comment',
+ model: 'mail.channel',
+ res_id: 20,
+ },
+ }, {
+ id: 10,
+ last_message: {
+ author_id: [7, "Demo"],
+ body: "<p>test2</p>",
+ channel_ids: [10],
+ id: 101,
+ message_type: 'comment',
+ model: 'mail.channel',
+ res_id: 10,
+ },
+ }];
+ }
+ return this._super(...arguments);
+ },
+ });
+ await afterNextRender(() => document.querySelector(`.o_MessagingMenu_toggler`).click());
+ await afterNextRender(() =>
+ document.querySelector(`
+ .o_MessagingMenu_dropdownMenu
+ .o_NotificationList_preview[data-thread-local-id="${
+ this.env.models['mail.thread'].find(thread =>
+ thread.id === 10 &&
+ thread.model === 'mail.channel'
+ ).localId
+ }"]
+ `).click()
+ );
+ assert.strictEqual(
+ document.querySelectorAll(`.o_ChatWindow`).length,
+ 1,
+ "should have open a chat window"
+ );
+ assert.strictEqual(
+ document.querySelectorAll(`
+ .o_ChatWindow[data-thread-local-id="${
+ this.env.models['mail.thread'].find(thread =>
+ thread.id === 10 &&
+ thread.model === 'mail.channel'
+ ).localId
+ }"]
+ `).length,
+ 1,
+ "chat window of chat should be open"
+ );
+ assert.ok(
+ document.querySelector(`
+ .o_ChatWindow[data-thread-local-id="${
+ this.env.models['mail.thread'].find(thread =>
+ thread.id === 10 &&
+ thread.model === 'mail.channel'
+ ).localId
+ }"]
+ `).classList.contains('o-focused'),
+ "chat window of chat should have focus"
+ );
+
+ await afterNextRender(() => document.querySelector(`.o_MessagingMenu_toggler`).click());
+ await afterNextRender(() =>
+ document.querySelector(`
+ .o_MessagingMenu_dropdownMenu
+ .o_NotificationList_preview[data-thread-local-id="${
+ this.env.models['mail.thread'].find(thread =>
+ thread.id === 20 &&
+ thread.model === 'mail.channel'
+ ).localId
+ }"]
+ `).click()
+ );
+ assert.strictEqual(
+ document.querySelectorAll(`.o_ChatWindow`).length,
+ 2,
+ "should have open a new chat window"
+ );
+ assert.strictEqual(
+ document.querySelectorAll(`
+ .o_ChatWindow[data-thread-local-id="${
+ this.env.models['mail.thread'].find(thread =>
+ thread.id === 20 &&
+ thread.model === 'mail.channel'
+ ).localId
+ }"]
+ `).length,
+ 1,
+ "chat window of channel should be open"
+ );
+ assert.strictEqual(
+ document.querySelectorAll(`
+ .o_ChatWindow[data-thread-local-id="${
+ this.env.models['mail.thread'].find(thread =>
+ thread.id === 10 &&
+ thread.model === 'mail.channel'
+ ).localId
+ }"]
+ `).length,
+ 1,
+ "chat window of chat should still be open"
+ );
+ assert.ok(
+ document.querySelector(`
+ .o_ChatWindow[data-thread-local-id="${
+ this.env.models['mail.thread'].find(thread =>
+ thread.id === 20 &&
+ thread.model === 'mail.channel'
+ ).localId
+ }"]
+ `).classList.contains('o-focused'),
+ "chat window of channel should have focus"
+ );
+ assert.notOk(
+ document.querySelector(`
+ .o_ChatWindow[data-thread-local-id="${
+ this.env.models['mail.thread'].find(thread =>
+ thread.id === 10 &&
+ thread.model === 'mail.channel'
+ ).localId
+ }"]
+ `).classList.contains('o-focused'),
+ "chat window of chat should no longer have focus"
+ );
+});
+
+QUnit.test('open 2 chat windows: check shift operations are available', async function (assert) {
+ assert.expect(9);
+
+ const channel = {
+ channel_type: "channel",
+ id: 20,
+ name: "General",
+ };
+ const chat = {
+ channel_type: "chat",
+ direct_partner: [{
+ id: 7,
+ name: "Demo",
+ }],
+ id: 10,
+ };
+ this.data['mail.channel'].records = [channel, chat];
+ Object.assign(this.data.initMessaging, {
+ channel_slots: {
+ channel_channel: [channel],
+ channel_direct_message: [chat],
+ },
+ });
+ await this.start();
+ await afterNextRender(() => {
+ document.querySelector('.o_MessagingMenu_toggler').click();
+ });
+ await afterNextRender(() => {
+ document.querySelectorAll('.o_MessagingMenu_dropdownMenu .o_NotificationList_preview')[0].click();
+ });
+ await afterNextRender(() => {
+ document.querySelector('.o_MessagingMenu_toggler').click();
+ });
+ await afterNextRender(() => {
+ document.querySelectorAll('.o_MessagingMenu_dropdownMenu .o_NotificationList_preview')[1].click();
+ });
+
+ assert.containsN(
+ document.body,
+ '.o_ChatWindow',
+ 2,
+ "should have opened 2 chat windows"
+ );
+ assert.containsOnce(
+ document.querySelectorAll('.o_ChatWindow')[0],
+ '.o_ChatWindowHeader_commandShiftLeft',
+ "first chat window should be allowed to shift left"
+ );
+ assert.containsNone(
+ document.querySelectorAll('.o_ChatWindow')[0],
+ '.o_ChatWindowHeader_commandShiftRight',
+ "first chat window should not be allowed to shift right"
+ );
+ assert.containsNone(
+ document.querySelectorAll('.o_ChatWindow')[1],
+ '.o_ChatWindowHeader_commandShiftLeft',
+ "second chat window should not be allowed to shift left"
+ );
+ assert.containsOnce(
+ document.querySelectorAll('.o_ChatWindow')[1],
+ '.o_ChatWindowHeader_commandShiftRight',
+ "second chat window should be allowed to shift right"
+ );
+
+ const initialFirstChatWindowThreadLocalId =
+ document.querySelectorAll('.o_ChatWindow')[0].dataset.threadLocalId;
+ const initialSecondChatWindowThreadLocalId =
+ document.querySelectorAll('.o_ChatWindow')[1].dataset.threadLocalId;
+ await afterNextRender(() => {
+ document.querySelectorAll('.o_ChatWindow')[0]
+ .querySelector(':scope .o_ChatWindowHeader_commandShiftLeft')
+ .click();
+ });
+ assert.strictEqual(
+ document.querySelectorAll('.o_ChatWindow')[0].dataset.threadLocalId,
+ initialSecondChatWindowThreadLocalId,
+ "First chat window should be second after it has been shift left"
+ );
+ assert.strictEqual(
+ document.querySelectorAll('.o_ChatWindow')[1].dataset.threadLocalId,
+ initialFirstChatWindowThreadLocalId,
+ "Second chat window should be first after the first has been shifted left"
+ );
+
+ await afterNextRender(() => {
+ document.querySelectorAll('.o_ChatWindow')[1]
+ .querySelector(':scope .o_ChatWindowHeader_commandShiftRight')
+ .click();
+ });
+ assert.strictEqual(
+ document.querySelectorAll('.o_ChatWindow')[0].dataset.threadLocalId,
+ initialFirstChatWindowThreadLocalId,
+ "First chat window should be back at first place after being shifted left then right"
+ );
+ assert.strictEqual(
+ document.querySelectorAll('.o_ChatWindow')[1].dataset.threadLocalId,
+ initialSecondChatWindowThreadLocalId,
+ "Second chat window should be back at second place after first one has been shifted left then right"
+ );
+});
+
+QUnit.test('open 3 different chat windows: not enough screen width', async function (assert) {
+ /**
+ * computation uses following info:
+ * ([mocked] global window width: 900px)
+ * (others: @see `mail/static/src/models/chat_window_manager/chat_window_manager.js:visual`)
+ *
+ * - chat window width: 325px
+ * - start/end/between gap width: 10px/10px/5px
+ * - hidden menu width: 200px
+ * - global width: 1080px
+ *
+ * Enough space for 2 visible chat windows, and one hidden chat window:
+ * 3 visible chat windows:
+ * 10 + 325 + 5 + 325 + 5 + 325 + 10 = 1000 < 900
+ * 2 visible chat windows + hidden menu:
+ * 10 + 325 + 5 + 325 + 10 + 200 + 5 = 875 < 900
+ */
+ assert.expect(12);
+
+ Object.assign(this.data.initMessaging, {
+ channel_slots: {
+ channel_channel: [{
+ channel_type: "channel",
+ id: 1,
+ name: "channel1",
+ }, {
+ channel_type: "channel",
+ id: 2,
+ name: "channel2",
+ }, {
+ channel_type: "channel",
+ id: 3,
+ name: "channel3",
+ }],
+ },
+ });
+ await this.start({
+ env: {
+ browser: {
+ innerHeight: 900,
+ innerWidth: 900,
+ },
+ },
+ async mockRPC(route, args) {
+ if (args.method === 'channel_fetch_preview') {
+ return [];
+ }
+ return this._super(...arguments);
+ },
+ });
+
+ // open, from systray menu, chat windows of channels with Id 1, 2, then 3
+ await afterNextRender(() =>
+ document.querySelector(`.o_MessagingMenu_toggler`).click()
+ );
+ await afterNextRender(() =>
+ document.querySelector(`
+ .o_MessagingMenu_dropdownMenu
+ .o_NotificationList_preview[data-thread-local-id="${
+ this.env.models['mail.thread'].find(thread =>
+ thread.id === 1 &&
+ thread.model === 'mail.channel'
+ ).localId
+ }"]
+ `).click()
+ );
+ assert.strictEqual(
+ document.querySelectorAll(`.o_ChatWindow`).length,
+ 1,
+ "should have open 1 visible chat window"
+ );
+ assert.strictEqual(
+ document.querySelectorAll(`.o_ChatWindowManager_hiddenMenu`).length,
+ 0,
+ "should not have hidden menu"
+ );
+ assert.strictEqual(
+ document.querySelectorAll(`.o_MessagingMenu_dropdownMenu`).length,
+ 0,
+ "messaging menu should be hidden"
+ );
+
+ await afterNextRender(() =>
+ document.querySelector(`.o_MessagingMenu_toggler`).click()
+ );
+ await afterNextRender(() =>
+ document.querySelector(`
+ .o_MessagingMenu_dropdownMenu
+ .o_NotificationList_preview[data-thread-local-id="${
+ this.env.models['mail.thread'].find(thread =>
+ thread.id === 2 &&
+ thread.model === 'mail.channel'
+ ).localId
+ }"]
+ `).click()
+ );
+ assert.strictEqual(
+ document.querySelectorAll(`.o_ChatWindow`).length,
+ 2,
+ "should have open 2 visible chat windows"
+ );
+ assert.strictEqual(
+ document.querySelectorAll(`.o_ChatWindowManager_hiddenMenu`).length,
+ 0,
+ "should not have hidden menu"
+ );
+ assert.strictEqual(
+ document.querySelectorAll(`.o_MessagingMenu_dropdownMenu`).length,
+ 0,
+ "messaging menu should be hidden"
+ );
+
+ await afterNextRender(() =>
+ document.querySelector(`.o_MessagingMenu_toggler`).click()
+ );
+ await afterNextRender(() =>
+ document.querySelector(`
+ .o_MessagingMenu_dropdownMenu
+ .o_NotificationList_preview[data-thread-local-id="${
+ this.env.models['mail.thread'].find(thread =>
+ thread.id === 3 &&
+ thread.model === 'mail.channel'
+ ).localId
+ }"]
+ `).click()
+ );
+ assert.strictEqual(
+ document.querySelectorAll(`.o_ChatWindow`).length,
+ 2,
+ "should have open 2 visible chat windows"
+ );
+ assert.strictEqual(
+ document.querySelectorAll(`.o_ChatWindowManager_hiddenMenu`).length,
+ 1,
+ "should have hidden menu"
+ );
+ assert.strictEqual(
+ document.querySelectorAll(`.o_MessagingMenu_dropdownMenu`).length,
+ 0,
+ "messaging menu should be hidden"
+ );
+ assert.strictEqual(
+ document.querySelectorAll(`
+ .o_ChatWindow[data-thread-local-id="${
+ this.env.models['mail.thread'].find(thread =>
+ thread.id === 1 &&
+ thread.model === 'mail.channel'
+ ).localId
+ }"]
+ `).length,
+ 1,
+ "chat window of channel 1 should be open"
+ );
+ assert.strictEqual(
+ document.querySelectorAll(`
+ .o_ChatWindow[data-thread-local-id="${
+ this.env.models['mail.thread'].find(thread =>
+ thread.id === 3 &&
+ thread.model === 'mail.channel'
+ ).localId
+ }"]
+ `).length,
+ 1,
+ "chat window of channel 3 should be open"
+ );
+ assert.ok(
+ document.querySelector(`
+ .o_ChatWindow[data-thread-local-id="${
+ this.env.models['mail.thread'].find(thread =>
+ thread.id === 3 &&
+ thread.model === 'mail.channel'
+ ).localId
+ }"]
+ `).classList.contains('o-focused'),
+ "chat window of channel 3 should have focus"
+ );
+});
+
+QUnit.test('chat window: switch on TAB', async function (assert) {
+ assert.expect(10);
+
+ Object.assign(this.data.initMessaging, {
+ channel_slots: {
+ channel_channel: [{
+ channel_type: "channel",
+ id: 1,
+ name: "channel1",
+ }, {
+ channel_type: "channel",
+ id: 2,
+ name: "channel2",
+ }],
+ },
+ });
+ await this.start();
+
+ await afterNextRender(() =>
+ document.querySelector(`.o_MessagingMenu_toggler`).click()
+ );
+ await afterNextRender(() =>
+ document.querySelector(`
+ .o_MessagingMenu_dropdownMenu
+ .o_NotificationList_preview[data-thread-local-id="${
+ this.env.models['mail.thread'].find(thread =>
+ thread.id === 1 &&
+ thread.model === 'mail.channel'
+ ).localId
+ }"]`
+ ).click()
+ );
+
+ assert.containsOnce(document.body, '.o_ChatWindow', "Only 1 chatWindow must be opened");
+ const chatWindow = document.querySelector('.o_ChatWindow');
+ assert.strictEqual(
+ chatWindow.querySelector('.o_ChatWindowHeader_name').textContent,
+ 'channel1',
+ "The name of the only chatWindow should be 'channel1' (channel with ID 1)"
+ );
+ assert.strictEqual(
+ chatWindow.querySelector('.o_ComposerTextInput_textarea'),
+ document.activeElement,
+ "The chatWindow composer must have focus"
+ );
+
+ await afterNextRender(() =>
+ triggerEvent(
+ chatWindow.querySelector('.o_ChatWindow .o_ComposerTextInput_textarea'),
+ 'keydown',
+ { key: 'Tab' },
+ )
+ );
+ assert.strictEqual(
+ chatWindow.querySelector('.o_ChatWindow .o_ComposerTextInput_textarea'),
+ document.activeElement,
+ "The chatWindow composer still has focus"
+ );
+
+ await afterNextRender(() =>
+ document.querySelector(`.o_MessagingMenu_toggler`).click()
+ );
+ await afterNextRender(() =>
+ document.querySelector(`
+ .o_MessagingMenu_dropdownMenu
+ .o_NotificationList_preview[data-thread-local-id="${
+ this.env.models['mail.thread'].find(thread =>
+ thread.id === 2 &&
+ thread.model === 'mail.channel'
+ ).localId
+ }"]`
+ ).click()
+ );
+
+ assert.containsN(document.body, '.o_ChatWindow', 2, "2 chatWindows must be opened");
+ const chatWindows = document.querySelectorAll('.o_ChatWindow');
+ assert.strictEqual(
+ chatWindows[0].querySelector('.o_ChatWindowHeader_name').textContent,
+ 'channel1',
+ "The name of the 1st chatWindow should be 'channel1' (channel with ID 1)"
+ );
+ assert.strictEqual(
+ chatWindows[1].querySelector('.o_ChatWindowHeader_name').textContent,
+ 'channel2',
+ "The name of the 2nd chatWindow should be 'channel2' (channel with ID 2)"
+ );
+ assert.strictEqual(
+ chatWindows[1].querySelector('.o_ComposerTextInput_textarea'),
+ document.activeElement,
+ "The 2nd chatWindow composer must have focus (channel with ID 2)"
+ );
+
+ await afterNextRender(() =>
+ triggerEvent(
+ chatWindows[1].querySelector('.o_ComposerTextInput_textarea'),
+ 'keydown',
+ { key: 'Tab' },
+ )
+ );
+ assert.containsN(document.body, '.o_ChatWindow', 2, "2 chatWindows should still be opened");
+ assert.strictEqual(
+ chatWindows[0].querySelector('.o_ComposerTextInput_textarea'),
+ document.activeElement,
+ "The 1st chatWindow composer must have focus (channel with ID 1)"
+ );
+});
+
+});
+});
+});
+
+});
diff --git a/addons/mail/static/src/components/chatter/chatter.js b/addons/mail/static/src/components/chatter/chatter.js
new file mode 100644
index 00000000000..5503cc90d8b
--- /dev/null
+++ b/addons/mail/static/src/components/chatter/chatter.js
@@ -0,0 +1,103 @@
+odoo.define('mail/static/src/components/chatter/chatter.js', function (require) {
+'use strict';
+
+const components = {
+ ActivityBox: require('mail/static/src/components/activity_box/activity_box.js'),
+ AttachmentBox: require('mail/static/src/components/attachment_box/attachment_box.js'),
+ ChatterTopbar: require('mail/static/src/components/chatter_topbar/chatter_topbar.js'),
+ Composer: require('mail/static/src/components/composer/composer.js'),
+ ThreadViewer: require('mail/static/src/components/thread_viewer/thread_viewer.js'),
+};
+const useStore = require('mail/static/src/component_hooks/use_store/use_store.js');
+
+const { Component } = owl;
+const { useRef } = owl.hooks;
+
+class Chatter extends Component {
+
+ /**
+ * @override
+ */
+ constructor(...args) {
+ super(...args);
+ useStore(props => {
+ const chatter = this.env.models['mail.chatter'].get(props.chatterLocalId);
+ const thread = chatter ? chatter.thread : undefined;
+ let attachments = [];
+ if (thread) {
+ attachments = thread.allAttachments;
+ }
+ return {
+ attachments: attachments.map(attachment => attachment.__state),
+ chatter: chatter ? chatter.__state : undefined,
+ thread: thread ? thread.__state : undefined,
+ };
+ }, {
+ compareDepth: {
+ attachments: 1,
+ },
+ });
+ this._threadRef = useRef('thread');
+ }
+
+ mounted() {
+ if (this.chatter.thread) {
+ this._notifyRendered();
+ }
+ }
+
+ patched() {
+ if (this.chatter.thread) {
+ this._notifyRendered();
+ }
+ }
+
+ //--------------------------------------------------------------------------
+ // Public
+ //--------------------------------------------------------------------------
+
+ /**
+ * @returns {mail.chatter}
+ */
+ get chatter() {
+ return this.env.models['mail.chatter'].get(this.props.chatterLocalId);
+ }
+
+ //--------------------------------------------------------------------------
+ // Private
+ //--------------------------------------------------------------------------
+
+ /**
+ * @private
+ */
+ _notifyRendered() {
+ this.trigger('o-chatter-rendered', {
+ attachments: this.chatter.thread.allAttachments,
+ thread: this.chatter.thread.localId,
+ });
+ }
+
+ //--------------------------------------------------------------------------
+ // Handlers
+ //--------------------------------------------------------------------------
+
+ /**
+ * @private
+ */
+ _onComposerMessagePosted() {
+ this.chatter.update({ isComposerVisible: false });
+ }
+
+}
+
+Object.assign(Chatter, {
+ components,
+ props: {
+ chatterLocalId: String,
+ },
+ template: 'mail.Chatter',
+});
+
+return Chatter;
+
+});
diff --git a/addons/mail/static/src/components/chatter/chatter.scss b/addons/mail/static/src/components/chatter/chatter.scss
new file mode 100644
index 00000000000..ef33f676de2
--- /dev/null
+++ b/addons/mail/static/src/components/chatter/chatter.scss
@@ -0,0 +1,37 @@
+// ------------------------------------------------------------------
+// Layout
+// ------------------------------------------------------------------
+
+.o_Chatter {
+ display: flex;
+ flex: 1 1 auto;
+ flex-direction: column;
+ overflow: auto;
+}
+
+.o_Chatter_composer {
+ border-bottom: $border-width solid;
+
+ &.o-bordered {
+ border-left: $border-width solid;
+ border-right: $border-width solid;
+ }
+}
+
+// ------------------------------------------------------------------
+// Style
+// ------------------------------------------------------------------
+
+.o_Chatter {
+ background-color: white;
+ border-color: $border-color;
+}
+
+.o_Chatter_composer {
+ border-bottom-color: $border-color;
+
+ &.o-bordered {
+ border-left-color: $border-color;
+ border-right-color: $border-color;
+ }
+}
diff --git a/addons/mail/static/src/components/chatter/chatter.xml b/addons/mail/static/src/components/chatter/chatter.xml
new file mode 100644
index 00000000000..f630eaf7634
--- /dev/null
+++ b/addons/mail/static/src/components/chatter/chatter.xml
@@ -0,0 +1,53 @@
+<?xml version="1.0" encoding="UTF-8"?>
+<templates xml:space="preserve">
+
+ <t t-name="mail.Chatter" owl="1">
+ <div class="o_Chatter">
+ <t t-if="chatter">
+ <ChatterTopbar
+ class="o_Chatter_topbar"
+ chatterLocalId="chatter.localId"
+ />
+ <t t-if="chatter.hasThread and chatter.thread and chatter.isComposerVisible">
+ <Composer
+ class="o_Chatter_composer"
+ t-att-class="{ 'o-bordered': chatter.hasExternalBorder }"
+ composerLocalId="chatter.thread.composer.localId"
+ hasMentionSuggestionsBelowPosition="true"
+ hasTextInputSendOnEnterEnabled="false"
+ isCompact="false"
+ isExpandable="true"
+ isLog="chatter.isComposerLog"
+ t-on-o-message-posted="_onComposerMessagePosted"
+ t-ref="composer"
+ />
+ </t>
+ <t t-if="chatter.isAttachmentBoxVisible">
+ <AttachmentBox
+ class="o_Chatter_attachmentBox"
+ threadLocalId="chatter.thread.localId"
+ />
+ </t>
+ <t t-if="chatter.thread">
+ <t t-if="chatter.hasActivities and chatter.activities.length > 0">
+ <ActivityBox
+ class="o_Chatter_activityBox"
+ chatterLocalId="chatter.localId"
+ />
+ </t>
+ <t t-if="chatter.hasThread">
+ <ThreadViewer
+ class="o_Chatter_thread"
+ hasComposer="false"
+ haveMessagesAuthorRedirect="true"
+ order="'desc'"
+ threadViewerLocalId="chatter.threadViewer.localId"
+ t-ref="thread"
+ />
+ </t>
+ </t>
+ </t>
+ </div>
+ </t>
+
+</templates>
diff --git a/addons/mail/static/src/components/chatter/chatter_tests.js b/addons/mail/static/src/components/chatter/chatter_tests.js
new file mode 100644
index 00000000000..a27a4036105
--- /dev/null
+++ b/addons/mail/static/src/components/chatter/chatter_tests.js
@@ -0,0 +1,337 @@
+odoo.define('mail/static/src/components/chatter/chatter_tests', function (require) {
+'use strict';
+
+const components = {
+ Chatter: require('mail/static/src/components/chatter/chatter.js'),
+};
+const {
+ afterEach: utilsAfterEach,
+ afterNextRender,
+ beforeEach: utilsBeforeEach,
+ start: utilsStart,
+} = require('mail/static/src/utils/test_utils.js');
+
+QUnit.module('mail', {}, function () {
+QUnit.module('components', {}, function () {
+QUnit.module('chatter', {}, function () {
+QUnit.module('chatter_tests.js', {
+ beforeEach() {
+ utilsBeforeEach(this);
+
+ this.createChatterComponent = async ({ chatter }, otherProps) => {
+ const ChatterComponent = components.Chatter;
+ ChatterComponent.env = this.env;
+ this.component = new ChatterComponent(
+ null,
+ Object.assign({ chatterLocalId: chatter.localId }, otherProps)
+ );
+ await afterNextRender(() => this.component.mount(this.widget.el));
+ };
+
+ 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);
+ if (this.component) {
+ this.component.destroy();
+ }
+ if (this.widget) {
+ this.widget.destroy();
+ }
+ delete components.Chatter.env;
+ this.env = undefined;
+ },
+});
+
+QUnit.test('base rendering when chatter has no attachment', async function (assert) {
+ assert.expect(6);
+
+ const messages = [...Array(60).keys()].map(id => {
+ return {
+ author_id: [10, "Demo User"],
+ body: `<p>Message ${id + 1}</p>`,
+ date: "2019-04-20 10:00:00",
+ id: id + 1,
+ message_type: 'comment',
+ model: 'res.partner',
+ record_name: 'General',
+ res_id: 100,
+ };
+ });
+ this.data['mail.message'].records = messages;
+
+ await this.start();
+ const chatter = this.env.models['mail.chatter'].create({
+ threadId: 100,
+ threadModel: 'res.partner',
+ });
+ await this.createChatterComponent({ chatter });
+ assert.strictEqual(
+ document.querySelectorAll(`.o_Chatter`).length,
+ 1,
+ "should have a chatter"
+ );
+ assert.strictEqual(
+ document.querySelectorAll(`.o_ChatterTopbar`).length,
+ 1,
+ "should have a chatter topbar"
+ );
+ assert.strictEqual(
+ document.querySelectorAll(`.o_Chatter_attachmentBox`).length,
+ 0,
+ "should not have an attachment box in the chatter"
+ );
+ assert.strictEqual(
+ document.querySelectorAll(`.o_Chatter_thread`).length,
+ 1,
+ "should have a thread in the chatter"
+ );
+ assert.strictEqual(
+ document.querySelector(`.o_Chatter_thread`).dataset.threadLocalId,
+ this.env.models['mail.thread'].find(thread =>
+ thread.id === 100 &&
+ thread.model === 'res.partner'
+ ).localId,
+ "thread should have the right thread local id"
+ );
+ assert.strictEqual(
+ document.querySelectorAll(`.o_Message`).length,
+ 30,
+ "the first 30 messages of thread should be loaded"
+ );
+});
+
+QUnit.test('base rendering when chatter has no record', async function (assert) {
+ assert.expect(7);
+
+ await this.start();
+ const chatter = this.env.models['mail.chatter'].create({
+ threadModel: 'res.partner',
+ });
+ await this.createChatterComponent({ chatter });
+ assert.strictEqual(
+ document.querySelectorAll(`.o_Chatter`).length,
+ 1,
+ "should have a chatter"
+ );
+ assert.strictEqual(
+ document.querySelectorAll(`.o_ChatterTopbar`).length,
+ 1,
+ "should have a chatter topbar"
+ );
+ assert.strictEqual(
+ document.querySelectorAll(`.o_Chatter_attachmentBox`).length,
+ 0,
+ "should not have an attachment box in the chatter"
+ );
+ assert.strictEqual(
+ document.querySelectorAll(`.o_Chatter_thread`).length,
+ 1,
+ "should have a thread in the chatter"
+ );
+ assert.ok(
+ chatter.thread.isTemporary,
+ "thread should have a temporary thread linked to chatter"
+ );
+ assert.strictEqual(
+ document.querySelectorAll(`.o_Message`).length,
+ 1,
+ "should have a message"
+ );
+ assert.strictEqual(
+ document.querySelector(`.o_Message_content`).textContent,
+ "Creating a new record...",
+ "should have the 'Creating a new record ...' message"
+ );
+});
+
+QUnit.test('base rendering when chatter has attachments', async function (assert) {
+ assert.expect(3);
+
+ await this.start({
+ async mockRPC(route, args) {
+ if (route.includes('ir.attachment/search_read')) {
+ return [{
+ id: 143,
+ filename: 'Blah.txt',
+ mimetype: 'text/plain',
+ name: 'Blah.txt'
+ }, {
+ id: 144,
+ filename: 'Blu.txt',
+ mimetype: 'text/plain',
+ name: 'Blu.txt'
+ }];
+ }
+ return this._super(...arguments);
+ }
+ });
+ const chatter = this.env.models['mail.chatter'].create({
+ threadId: 100,
+ threadModel: 'res.partner',
+ });
+ await this.createChatterComponent({ chatter });
+ assert.strictEqual(
+ document.querySelectorAll(`.o_Chatter`).length,
+ 1,
+ "should have a chatter"
+ );
+ assert.strictEqual(
+ document.querySelectorAll(`.o_ChatterTopbar`).length,
+ 1,
+ "should have a chatter topbar"
+ );
+ assert.strictEqual(
+ document.querySelectorAll(`.o_Chatter_attachmentBox`).length,
+ 0,
+ "should not have an attachment box in the chatter"
+ );
+});
+
+QUnit.test('show attachment box', async function (assert) {
+ assert.expect(6);
+
+ await this.start({
+ async mockRPC(route, args) {
+ if (route.includes('ir.attachment/search_read')) {
+ return [{
+ id: 143,
+ filename: 'Blah.txt',
+ mimetype: 'text/plain',
+ name: 'Blah.txt'
+ }, {
+ id: 144,
+ filename: 'Blu.txt',
+ mimetype: 'text/plain',
+ name: 'Blu.txt'
+ }];
+ }
+ return this._super(...arguments);
+ }
+ });
+ const chatter = this.env.models['mail.chatter'].create({
+ threadId: 100,
+ threadModel: 'res.partner',
+ });
+ await this.createChatterComponent({ chatter });
+ assert.strictEqual(
+ document.querySelectorAll(`.o_Chatter`).length,
+ 1,
+ "should have a chatter"
+ );
+ assert.strictEqual(
+ document.querySelectorAll(`.o_ChatterTopbar`).length,
+ 1,
+ "should have a chatter topbar"
+ );
+ assert.strictEqual(
+ document.querySelectorAll(`.o_ChatterTopbar_buttonAttachments`).length,
+ 1,
+ "should have an attachments button in chatter topbar"
+ );
+ assert.strictEqual(
+ document.querySelectorAll(`.o_ChatterTopbar_buttonAttachmentsCount`).length,
+ 1,
+ "attachments button should have a counter"
+ );
+ assert.strictEqual(
+ document.querySelectorAll(`.o_Chatter_attachmentBox`).length,
+ 0,
+ "should not have an attachment box in the chatter"
+ );
+
+ await afterNextRender(() =>
+ document.querySelector(`.o_ChatterTopbar_buttonAttachments`).click()
+ );
+ assert.strictEqual(
+ document.querySelectorAll(`.o_Chatter_attachmentBox`).length,
+ 1,
+ "should have an attachment box in the chatter"
+ );
+});
+
+QUnit.test('composer show/hide on log note/send message', async function (assert) {
+ assert.expect(8);
+
+ await this.start();
+ const chatter = this.env.models['mail.chatter'].create({
+ threadId: 100,
+ threadModel: 'res.partner',
+ });
+ await this.createChatterComponent({ chatter });
+ assert.strictEqual(
+ document.querySelectorAll(`.o_ChatterTopbar_buttonSendMessage`).length,
+ 1,
+ "should have a send message button"
+ );
+ assert.strictEqual(
+ document.querySelectorAll(`.o_ChatterTopbar_buttonLogNote`).length,
+ 1,
+ "should have a log note button"
+ );
+ assert.strictEqual(
+ document.querySelectorAll(`.o_Chatter_composer`).length,
+ 0,
+ "should not have a composer"
+ );
+
+ await afterNextRender(() =>
+ document.querySelector(`.o_ChatterTopbar_buttonSendMessage`).click()
+ );
+ assert.strictEqual(
+ document.querySelectorAll(`.o_Chatter_composer`).length,
+ 1,
+ "should have a composer"
+ );
+
+ await afterNextRender(() =>
+ document.querySelector(`.o_ChatterTopbar_buttonLogNote`).click()
+ );
+ assert.strictEqual(
+ document.querySelectorAll(`.o_Chatter_composer`).length,
+ 1,
+ "should still have a composer"
+ );
+
+ await afterNextRender(() =>
+ document.querySelector(`.o_ChatterTopbar_buttonLogNote`).click()
+ );
+ assert.strictEqual(
+ document.querySelectorAll(`.o_Chatter_composer`).length,
+ 0,
+ "should have no composer anymore"
+ );
+
+ await afterNextRender(() =>
+ document.querySelector(`.o_ChatterTopbar_buttonSendMessage`).click()
+ );
+ assert.strictEqual(
+ document.querySelectorAll(`.o_Chatter_composer`).length,
+ 1,
+ "should have a composer"
+ );
+
+ await afterNextRender(() =>
+ document.querySelector(`.o_ChatterTopbar_buttonSendMessage`).click()
+ );
+ assert.strictEqual(
+ document.querySelectorAll(`.o_Chatter_composer`).length,
+ 0,
+ "should have no composer anymore"
+ );
+});
+
+});
+});
+});
+
+});
diff --git a/addons/mail/static/src/components/chatter_container/chatter_container.js b/addons/mail/static/src/components/chatter_container/chatter_container.js
new file mode 100644
index 00000000000..0c5de5208fc
--- /dev/null
+++ b/addons/mail/static/src/components/chatter_container/chatter_container.js
@@ -0,0 +1,80 @@
+odoo.define('mail/static/src/components/chatter_container/chatter_container.js', function (require) {
+'use strict';
+
+const components = {
+ Chatter: require('mail/static/src/components/chatter/chatter.js'),
+};
+const useStore = require('mail/static/src/component_hooks/use_store/use_store.js');
+
+const { Component } = owl;
+
+/**
+ * This component abstracts chatter component to its parent, so that it can be
+ * mounted and receive chatter data even when a chatter component cannot be
+ * created. Indeed, in order to create a chatter component, we must create
+ * a chatter record, the latter requiring messaging to be initialized. The view
+ * may attempt to create a chatter before messaging has been initialized, so
+ * this component delays the mounting of chatter until it becomes initialized.
+ */
+class ChatterContainer extends Component {
+
+ /**
+ * @override
+ */
+ constructor(...args) {
+ super(...args);
+ this.chatter = undefined;
+ this._wasMessagingInitialized = false;
+ useStore(props => {
+ const isMessagingInitialized = this.env.isMessagingInitialized();
+ if (!this._wasMessagingInitialized && isMessagingInitialized) {
+ this._wasMessagingInitialized = true;
+ this.chatter = this.env.models['mail.chatter'].create(props);
+ }
+ return { isMessagingInitialized };
+ });
+ }
+
+ mounted() {
+ this._update();
+ }
+
+ patched() {
+ this._update();
+ }
+
+ destroy() {
+ super.destroy();
+ if (this.chatter) {
+ this.chatter.delete();
+ }
+ }
+
+ //--------------------------------------------------------------------------
+ // Private
+ //--------------------------------------------------------------------------
+
+ /**
+ * @private
+ */
+ _update() {
+ if (this.chatter) {
+ this.chatter.update(this.props);
+ }
+ }
+
+}
+
+Object.assign(ChatterContainer, {
+ components,
+ /**
+ * No props validation because this component simply forwards props to
+ * chatter record as its data.
+ */
+ template: 'mail.ChatterContainer',
+});
+
+
+return ChatterContainer;
+
+});
diff --git a/addons/mail/static/src/components/chatter_container/chatter_container.scss b/addons/mail/static/src/components/chatter_container/chatter_container.scss
new file mode 100644
index 00000000000..99e8d530fb3
--- /dev/null
+++ b/addons/mail/static/src/components/chatter_container/chatter_container.scss
@@ -0,0 +1,13 @@
+// ------------------------------------------------------------------
+// Layout
+// ------------------------------------------------------------------
+
+.o_ChatterContainer {
+ display: flex;
+ flex: 1 1 auto;
+}
+
+// ------------------------------------------------------------------
+// Style
+// ------------------------------------------------------------------
+
diff --git a/addons/mail/static/src/components/chatter_container/chatter_container.xml b/addons/mail/static/src/components/chatter_container/chatter_container.xml
new file mode 100644
index 00000000000..f9ee262e69b
--- /dev/null
+++ b/addons/mail/static/src/components/chatter_container/chatter_container.xml
@@ -0,0 +1,15 @@
+<?xml version="1.0" encoding="UTF-8"?>
+<templates xml:space="preserve">
+
+ <t t-name="mail.ChatterContainer" owl="1">
+ <div class="o_ChatterContainer">
+ <t t-if="chatter">
+ <Chatter chatterLocalId="chatter.localId"/>
+ </t>
+ <t t-else="">
+ Please wait...
+ </t>
+ </div>
+ </t>
+
+</templates>
diff --git a/addons/mail/static/src/components/chatter_topbar/chatter_topbar.js b/addons/mail/static/src/components/chatter_topbar/chatter_topbar.js
new file mode 100644
index 00000000000..c4b088b7709
--- /dev/null
+++ b/addons/mail/static/src/components/chatter_topbar/chatter_topbar.js
@@ -0,0 +1,128 @@
+odoo.define('mail/static/src/components/chatter_topbar/chatter_topbar.js', function (require) {
+'use strict';
+
+const components = {
+ FollowButton: require('mail/static/src/components/follow_button/follow_button.js'),
+ FollowerListMenu: require('mail/static/src/components/follower_list_menu/follower_list_menu.js'),
+};
+const useStore = require('mail/static/src/component_hooks/use_store/use_store.js');
+
+const { Component } = owl;
+
+class ChatterTopbar extends Component {
+
+ /**
+ * @override
+ */
+ constructor(...args) {
+ super(...args);
+ useStore(props => {
+ const chatter = this.env.models['mail.chatter'].get(props.chatterLocalId);
+ const thread = chatter ? chatter.thread : undefined;
+ const threadAttachments = thread ? thread.allAttachments : [];
+ return {
+ areThreadAttachmentsLoaded: thread && thread.areAttachmentsLoaded,
+ chatter: chatter ? chatter.__state : undefined,
+ threadAttachmentsAmount: threadAttachments.length,
+ };
+ });
+ }
+
+ //--------------------------------------------------------------------------
+ // Public
+ //--------------------------------------------------------------------------
+
+ /**
+ * @returns {mail.chatter}
+ */
+ get chatter() {
+ return this.env.models['mail.chatter'].get(this.props.chatterLocalId);
+ }
+
+ //--------------------------------------------------------------------------
+ // Handlers
+ //--------------------------------------------------------------------------
+
+ /**
+ * @private
+ * @param {MouseEvent} ev
+ */
+ _onClickAttachments(ev) {
+ this.chatter.update({
+ isAttachmentBoxVisible: !this.chatter.isAttachmentBoxVisible,
+ });
+ }
+
+ /**
+ * @private
+ * @param {MouseEvent} ev
+ */
+ _onClickClose(ev) {
+ this.trigger('o-close-chatter');
+ }
+
+ /**
+ * @private
+ * @param {MouseEvent} ev
+ */
+ _onClickLogNote(ev) {
+ if (this.chatter.isComposerVisible && this.chatter.isComposerLog) {
+ this.chatter.update({ isComposerVisible: false });
+ } else {
+ this.chatter.showLogNote();
+ }
+ }
+
+ /**
+ * @private
+ * @param {MouseEvent} ev
+ */
+ _onClickScheduleActivity(ev) {
+ 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.chatter.threadId,
+ default_res_model: this.chatter.threadModel,
+ },
+ res_id: false,
+ };
+ return this.env.bus.trigger('do-action', {
+ action,
+ options: {
+ // A bit "extreme", could be improved:
+ // normally only an activity is created (no update nor delete)
+ on_close: () => this.chatter.refreshActivities(),
+ },
+ });
+ }
+
+ /**
+ * @private
+ * @param {MouseEvent} ev
+ */
+ _onClickSendMessage(ev) {
+ if (this.chatter.isComposerVisible && !this.chatter.isComposerLog) {
+ this.chatter.update({ isComposerVisible: false });
+ } else {
+ this.chatter.showSendMessage();
+ }
+ }
+
+}
+
+Object.assign(ChatterTopbar, {
+ components,
+ props: {
+ chatterLocalId: String,
+ },
+ template: 'mail.ChatterTopbar',
+});
+
+return ChatterTopbar;
+
+});
diff --git a/addons/mail/static/src/components/chatter_topbar/chatter_topbar.scss b/addons/mail/static/src/components/chatter_topbar/chatter_topbar.scss
new file mode 100644
index 00000000000..17c5fa5d856
--- /dev/null
+++ b/addons/mail/static/src/components/chatter_topbar/chatter_topbar.scss
@@ -0,0 +1,100 @@
+// ------------------------------------------------------------------
+// Layout
+// ------------------------------------------------------------------
+
+.o_ChatterTopbar {
+ display: flex;
+ flex-direction: row;
+ justify-content: space-between;
+}
+
+.o_ChatterTopbar_actions {
+ display: flex;
+ flex: 1;
+ flex-direction: row;
+ flex-wrap: wrap;
+
+ &.o-has-active-button {
+ border-bottom: $border-width solid;
+ }
+}
+
+.o_ChatterTopbar_button {
+ margin-bottom: -$border-width; /* Needed to allow "overriding" of the bottom border */
+}
+
+.o_ChatterTopbar_buttonAttachmentsCountLoader {
+ margin-left: 2px;
+}
+
+.o_ChatterTopbar_buttonCount {
+ padding-left: 0.25rem;
+}
+
+.o_ChatterTopbar_buttonClose {
+ display: flex;
+ flex-shrink: 0;
+ justify-content: center;
+ align-items: center;
+ width: 34px;
+ height: 34px;
+}
+
+.o_ChatterTopbar_followerListMenu {
+ display: flex;
+}
+
+.o_ChatterTopbar_rightSection {
+ display: flex;
+ flex: 1 0 auto;
+ justify-content: flex-end;
+}
+
+// ------------------------------------------------------------------
+// Style
+// ------------------------------------------------------------------
+
+.o_ChatterTopbar_actions.o-has-active-button {
+ border-color: $border-color;
+}
+
+.o_ChatterTopbar_button {
+ border-radius: 0;
+
+ &:hover {
+ background-color: gray('300');
+ }
+
+ &.o-active {
+ color: $o-brand-odoo;
+ background-color: lighten(gray('300'), 7%);
+ border-right-color: $border-color;
+
+ &:not(:first-of-type),
+ &:first-of-type.o-bordered {
+ border-left-color: $border-color;
+ }
+
+ &.o-bordered {
+ border-top-color: $border-color;
+ }
+
+ &:hover {
+ background-color: gray('300');
+ color: $link-hover-color;
+ }
+ }
+}
+
+.o_ChatterTopbar_buttonClose {
+ border-radius: 0 0 10px 10px;
+ font-size: $font-size-lg;
+ background-color: gray('700');
+ color: gray('100');
+ cursor: pointer;
+
+ &:hover {
+ background-color: gray('600');
+ color: $white;
+ }
+}
diff --git a/addons/mail/static/src/components/chatter_topbar/chatter_topbar.xml b/addons/mail/static/src/components/chatter_topbar/chatter_topbar.xml
new file mode 100644
index 00000000000..67dfe4ea844
--- /dev/null
+++ b/addons/mail/static/src/components/chatter_topbar/chatter_topbar.xml
@@ -0,0 +1,72 @@
+<?xml version="1.0" encoding="UTF-8"?>
+<templates xml:space="preserve">
+
+ <t t-name="mail.ChatterTopbar" owl="1">
+ <div class="o_ChatterTopbar">
+ <t t-if="chatter">
+ <div class="o_ChatterTopbar_actions" t-att-class="{'o-has-active-button': chatter.isComposerVisible }">
+ <t t-if="chatter.hasThread">
+ <button class="btn btn-link o_ChatterTopbar_button o_ChatterTopbar_buttonSendMessage"
+ type="button"
+ t-att-class="{
+ 'o-active': chatter.isComposerVisible and !chatter.isComposerLog,
+ 'o-bordered': chatter.hasExternalBorder,
+ }"
+ t-att-disabled="chatter.isDisabled"
+ t-on-click="_onClickSendMessage"
+ >
+ Send message
+ </button>
+ <button class="btn btn-link o_ChatterTopbar_button o_ChatterTopbar_buttonLogNote"
+ type="button"
+ t-att-class="{
+ 'o-active': chatter.isComposerVisible and chatter.isComposerLog,
+ 'o-bordered': chatter.hasExternalBorder,
+ }"
+ t-att-disabled="chatter.isDisabled"
+ t-on-click="_onClickLogNote"
+ >
+ Log note
+ </button>
+ </t>
+ <t t-if="chatter.hasActivities">
+ <button class="btn btn-link o_ChatterTopbar_button o_ChatterTopbar_buttonScheduleActivity" type="button" t-att-disabled="chatter.isDisabled" t-on-click="_onClickScheduleActivity">
+ <i class="fa fa-clock-o"/>
+ Schedule activity
+ </button>
+ </t>
+ <button class="btn btn-link o_ChatterTopbar_button o_ChatterTopbar_buttonAttachments" type="button" t-att-disabled="chatter.isDisabled" t-on-click="_onClickAttachments">
+ <i class="fa fa-paperclip"/>
+ <t t-if="chatter.isDisabled or !chatter.isShowingAttachmentsLoading">
+ <span class="o_ChatterTopbar_buttonCount o_ChatterTopbar_buttonAttachmentsCount" t-esc="chatter.thread ? chatter.thread.allAttachments.length : 0"/>
+ </t>
+ <t t-else="">
+ <i class="o_ChatterTopbar_buttonAttachmentsCountLoader fa fa-spinner fa-spin" aria-label="Attachment counter loading..."/>
+ </t>
+ </button>
+ <div class="o-autogrow"/>
+ <div class="o_ChatterTopbar_rightSection">
+ <t t-if="chatter.hasFollowers and chatter.thread">
+ <FollowButton
+ class="o_ChatterTopbar_button o_ChatterTopbar_followButton"
+ isDisabled="chatter.isDisabled"
+ threadLocalId="chatter.thread.localId"
+ />
+ <FollowerListMenu
+ class="o_ChatterTopbar_button o_ChatterTopbar_followerListMenu"
+ isDisabled="chatter.isDisabled"
+ threadLocalId="chatter.thread.localId"
+ />
+ </t>
+ </div>
+ </div>
+ <t t-if="chatter.hasTopbarCloseButton">
+ <div class="o_ChatterTopbar_buttonClose" title="Close" t-on-click="_onClickClose">
+ <i class="fa fa-times"/>
+ </div>
+ </t>
+ </t>
+ </div>
+ </t>
+
+</templates>
diff --git a/addons/mail/static/src/components/chatter_topbar/chatter_topbar_tests.js b/addons/mail/static/src/components/chatter_topbar/chatter_topbar_tests.js
new file mode 100644
index 00000000000..c6c38c081b6
--- /dev/null
+++ b/addons/mail/static/src/components/chatter_topbar/chatter_topbar_tests.js
@@ -0,0 +1,590 @@
+odoo.define('mail/static/src/components/chatter_topbar/chatter_topbar_tests.js', function (require) {
+'use strict';
+
+const components = {
+ ChatterTopBar: require('mail/static/src/components/chatter_topbar/chatter_topbar.js'),
+};
+const {
+ afterEach: utilsAfterEach,
+ afterNextRender,
+ beforeEach: utilsBeforeEach,
+ start: utilsStart,
+} = require('mail/static/src/utils/test_utils.js');
+
+const { makeTestPromise } = require('web.test_utils');
+
+QUnit.module('mail', {}, function () {
+QUnit.module('components', {}, function () {
+QUnit.module('chatter_topbar', {}, function () {
+QUnit.module('chatter_topbar_tests.js', {
+ beforeEach() {
+ utilsBeforeEach(this);
+
+ this.createChatterTopbarComponent = async (chatter, otherProps) => {
+ const ChatterTopBarComponent = components.ChatterTopBar;
+ ChatterTopBarComponent.env = this.env;
+ this.component = new ChatterTopBarComponent(
+ null,
+ Object.assign({ chatterLocalId: chatter.localId }, otherProps)
+ );
+ await afterNextRender(() => this.component.mount(this.widget.el));
+ };
+
+ 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);
+ if (this.component) {
+ this.component.destroy();
+ }
+ if (this.widget) {
+ this.widget.destroy();
+ }
+ delete components.ChatterTopBar.env;
+ this.env = undefined;
+ },
+});
+
+QUnit.test('base rendering', async function (assert) {
+ assert.expect(8);
+
+ await this.start();
+ const chatter = this.env.models['mail.chatter'].create({
+ threadId: 100,
+ threadModel: 'res.partner',
+ });
+ await this.createChatterTopbarComponent(chatter);
+
+ assert.strictEqual(
+ document.querySelectorAll(`.o_ChatterTopbar`).length,
+ 1,
+ "should have a chatter topbar"
+ );
+ assert.strictEqual(
+ document.querySelectorAll(`.o_ChatterTopbar_buttonSendMessage`).length,
+ 1,
+ "should have a send message button in chatter menu"
+ );
+ assert.strictEqual(
+ document.querySelectorAll(`.o_ChatterTopbar_buttonLogNote`).length,
+ 1,
+ "should have a log note button in chatter menu"
+ );
+ assert.strictEqual(
+ document.querySelectorAll(`.o_ChatterTopbar_buttonScheduleActivity`).length,
+ 1,
+ "should have a schedule activity button in chatter menu"
+ );
+ assert.strictEqual(
+ document.querySelectorAll(`.o_ChatterTopbar_buttonAttachments`).length,
+ 1,
+ "should have an attachments button in chatter menu"
+ );
+ assert.strictEqual(
+ document.querySelectorAll(`.o_ChatterTopbar_buttonAttachmentsCountLoader`).length,
+ 0,
+ "attachments button should not have a loader"
+ );
+ assert.strictEqual(
+ document.querySelectorAll(`.o_ChatterTopbar_buttonAttachmentsCount`).length,
+ 1,
+ "attachments button should have a counter"
+ );
+ assert.strictEqual(
+ document.querySelectorAll(`.o_ChatterTopbar_followerListMenu`).length,
+ 1,
+ "should have a follower menu"
+ );
+});
+
+QUnit.test('base disabled rendering', async function (assert) {
+ assert.expect(8);
+
+ await this.start();
+ const chatter = this.env.models['mail.chatter'].create({
+ threadModel: 'res.partner',
+ });
+ await this.createChatterTopbarComponent(chatter);
+ assert.strictEqual(
+ document.querySelectorAll(`.o_ChatterTopbar`).length,
+ 1,
+ "should have a chatter topbar"
+ );
+ 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_buttonScheduleActivity`).disabled,
+ "schedule activity should be disabled"
+ );
+ assert.ok(
+ document.querySelector(`.o_ChatterTopbar_buttonAttachments`).disabled,
+ "attachments button should be disabled"
+ );
+ assert.strictEqual(
+ document.querySelectorAll(`.o_ChatterTopbar_buttonAttachmentsCountLoader`).length,
+ 0,
+ "attachments button should not have a loader"
+ );
+ assert.strictEqual(
+ document.querySelectorAll(`.o_ChatterTopbar_buttonAttachmentsCount`).length,
+ 1,
+ "attachments button should have a counter"
+ );
+ assert.strictEqual(
+ document.querySelector(`.o_ChatterTopbar_buttonAttachmentsCount`).textContent,
+ '0',
+ "attachments button counter should be 0"
+ );
+});
+
+QUnit.test('attachment loading is delayed', async function (assert) {
+ assert.expect(4);
+
+ await this.start({
+ hasTimeControl: true,
+ loadingBaseDelayDuration: 100,
+ async mockRPC(route) {
+ if (route.includes('ir.attachment/search_read')) {
+ return new Promise(() => {}); // simulate long loading
+ }
+ return this._super(...arguments);
+ }
+ });
+ const chatter = this.env.models['mail.chatter'].create({
+ threadId: 100,
+ threadModel: 'res.partner',
+ });
+ await this.createChatterTopbarComponent(chatter);
+
+ assert.strictEqual(
+ document.querySelectorAll(`.o_ChatterTopbar`).length,
+ 1,
+ "should have a chatter topbar"
+ );
+ assert.strictEqual(
+ document.querySelectorAll(`.o_ChatterTopbar_buttonAttachments`).length,
+ 1,
+ "should have an attachments button in chatter menu"
+ );
+ assert.strictEqual(
+ document.querySelectorAll(`.o_ChatterTopbar_buttonAttachmentsCountLoader`).length,
+ 0,
+ "attachments button should not have a loader yet"
+ );
+
+ await afterNextRender(async () => this.env.testUtils.advanceTime(100));
+ assert.strictEqual(
+ document.querySelectorAll(`.o_ChatterTopbar_buttonAttachmentsCountLoader`).length,
+ 1,
+ "attachments button should now have a loader"
+ );
+});
+
+QUnit.test('attachment counter while loading attachments', async function (assert) {
+ assert.expect(4);
+
+ await this.start({
+ async mockRPC(route) {
+ if (route.includes('ir.attachment/search_read')) {
+ return new Promise(() => {}); // simulate long loading
+ }
+ return this._super(...arguments);
+ }
+ });
+ const chatter = this.env.models['mail.chatter'].create({
+ threadId: 100,
+ threadModel: 'res.partner',
+ });
+ await this.createChatterTopbarComponent(chatter);
+
+ assert.strictEqual(
+ document.querySelectorAll(`.o_ChatterTopbar`).length,
+ 1,
+ "should have a chatter topbar"
+ );
+ assert.strictEqual(
+ document.querySelectorAll(`.o_ChatterTopbar_buttonAttachments`).length,
+ 1,
+ "should have an attachments button in chatter menu"
+ );
+ assert.strictEqual(
+ document.querySelectorAll(`.o_ChatterTopbar_buttonAttachmentsCountLoader`).length,
+ 1,
+ "attachments button should have a loader"
+ );
+ assert.strictEqual(
+ document.querySelectorAll(`.o_ChatterTopbar_buttonAttachmentsCount`).length,
+ 0,
+ "attachments button should not have a counter"
+ );
+});
+
+QUnit.test('attachment counter transition when attachments become loaded)', async function (assert) {
+ assert.expect(7);
+
+ const attachmentPromise = makeTestPromise();
+ await this.start({
+ async mockRPC(route) {
+ if (route.includes('ir.attachment/search_read')) {
+ await attachmentPromise;
+ return [];
+ }
+ return this._super(...arguments);
+ }
+ });
+ const chatter = this.env.models['mail.chatter'].create({
+ threadId: 100,
+ threadModel: 'res.partner',
+ });
+ await this.createChatterTopbarComponent(chatter);
+
+ assert.strictEqual(
+ document.querySelectorAll(`.o_ChatterTopbar`).length,
+ 1,
+ "should have a chatter topbar"
+ );
+ assert.strictEqual(
+ document.querySelectorAll(`.o_ChatterTopbar_buttonAttachments`).length,
+ 1,
+ "should have an attachments button in chatter menu"
+ );
+ assert.strictEqual(
+ document.querySelectorAll(`.o_ChatterTopbar_buttonAttachmentsCountLoader`).length,
+ 1,
+ "attachments button should have a loader"
+ );
+ assert.strictEqual(
+ document.querySelectorAll(`.o_ChatterTopbar_buttonAttachmentsCount`).length,
+ 0,
+ "attachments button should not have a counter"
+ );
+
+ await afterNextRender(() => attachmentPromise.resolve());
+ assert.strictEqual(
+ document.querySelectorAll(`.o_ChatterTopbar_buttonAttachments`).length,
+ 1,
+ "should have an attachments button in chatter menu"
+ );
+ assert.strictEqual(
+ document.querySelectorAll(`.o_ChatterTopbar_buttonAttachmentsCountLoader`).length,
+ 0,
+ "attachments button should not have a loader"
+ );
+ assert.strictEqual(
+ document.querySelectorAll(`.o_ChatterTopbar_buttonAttachmentsCount`).length,
+ 1,
+ "attachments button should have a counter"
+ );
+});
+
+QUnit.test('attachment counter without attachments', async function (assert) {
+ assert.expect(4);
+
+ await this.start();
+ const chatter = this.env.models['mail.chatter'].create({
+ threadId: 100,
+ threadModel: 'res.partner',
+ });
+ await this.createChatterTopbarComponent(chatter);
+
+ assert.strictEqual(
+ document.querySelectorAll(`.o_ChatterTopbar`).length,
+ 1,
+ "should have a chatter topbar"
+ );
+ assert.strictEqual(
+ document.querySelectorAll(`.o_ChatterTopbar_buttonAttachments`).length,
+ 1,
+ "should have an attachments button in chatter menu"
+ );
+ assert.strictEqual(
+ document.querySelectorAll(`.o_ChatterTopbar_buttonAttachmentsCount`).length,
+ 1,
+ "attachments button should have a counter"
+ );
+ assert.strictEqual(
+ document.querySelector(`.o_ChatterTopbar_buttonAttachmentsCount`).textContent,
+ '0',
+ 'attachment counter should contain "0"'
+ );
+});
+
+QUnit.test('attachment counter with attachments', async function (assert) {
+ assert.expect(4);
+
+ await this.start({
+ async mockRPC(route) {
+ if (route.includes('ir.attachment/search_read')) {
+ return [{
+ id: 143,
+ filename: 'Blah.txt',
+ mimetype: 'text/plain',
+ name: 'Blah.txt'
+ }, {
+ id: 144,
+ filename: 'Blu.txt',
+ mimetype: 'text/plain',
+ name: 'Blu.txt'
+ }];
+ }
+ return this._super(...arguments);
+ }
+ });
+ const chatter = this.env.models['mail.chatter'].create({
+ threadId: 100,
+ threadModel: 'res.partner',
+ });
+ await this.createChatterTopbarComponent(chatter);
+
+ assert.strictEqual(
+ document.querySelectorAll(`.o_ChatterTopbar`).length,
+ 1,
+ "should have a chatter topbar"
+ );
+ assert.strictEqual(
+ document.querySelectorAll(`.o_ChatterTopbar_buttonAttachments`).length,
+ 1,
+ "should have an attachments button in chatter menu"
+ );
+ assert.strictEqual(
+ document.querySelectorAll(`.o_ChatterTopbar_buttonAttachmentsCount`).length,
+ 1,
+ "attachments button should have a counter"
+ );
+ assert.strictEqual(
+ document.querySelector(`.o_ChatterTopbar_buttonAttachmentsCount`).textContent,
+ '2',
+ 'attachment counter should contain "2"'
+ );
+});
+
+QUnit.test('composer state conserved when clicking on another topbar button', async function (assert) {
+ assert.expect(8);
+
+ await this.start();
+ const chatter = this.env.models['mail.chatter'].create({
+ threadId: 100,
+ threadModel: 'res.partner',
+ });
+ await this.createChatterTopbarComponent(chatter);
+
+ assert.containsOnce(
+ document.body,
+ `.o_ChatterTopbar`,
+ "should have a chatter topbar"
+ );
+ assert.containsOnce(
+ document.body,
+ `.o_ChatterTopbar_buttonSendMessage`,
+ "should have a send message button in chatter menu"
+ );
+ assert.containsOnce(
+ document.body,
+ `.o_ChatterTopbar_buttonLogNote`,
+ "should have a log note button in chatter menu"
+ );
+ assert.containsOnce(
+ document.body,
+ `.o_ChatterTopbar_buttonAttachments`,
+ "should have an attachments button in chatter menu"
+ );
+
+ await afterNextRender(() => {
+ document.querySelector(`.o_ChatterTopbar_buttonLogNote`).click();
+ });
+ assert.containsOnce(
+ document.body,
+ `.o_ChatterTopbar_buttonLogNote.o-active`,
+ "log button should now be active"
+ );
+ assert.containsNone(
+ document.body,
+ `.o_ChatterTopbar_buttonSendMessage.o-active`,
+ "send message button should not be active"
+ );
+
+ await afterNextRender(() => {
+ document.querySelector(`.o_ChatterTopbar_buttonAttachments`).click();
+ });
+ assert.containsOnce(
+ document.body,
+ `.o_ChatterTopbar_buttonLogNote.o-active`,
+ "log button should still be active"
+ );
+ assert.containsNone(
+ document.body,
+ `.o_ChatterTopbar_buttonSendMessage.o-active`,
+ "send message button should still be not active"
+ );
+});
+
+QUnit.test('rendering with multiple partner followers', async function (assert) {
+ assert.expect(7);
+
+ await this.start();
+ this.data['res.partner'].records = [{
+ id: 100,
+ message_follower_ids: [1, 2],
+ }];
+ this.data['mail.followers'].records = [
+ {
+ // simulate real return from RPC
+ // (the presence of the key and the falsy value need to be handled correctly)
+ channel_id: false,
+ id: 1,
+ name: "Jean Michang",
+ partner_id: 12,
+ }, {
+ // simulate real return from RPC
+ // (the presence of the key and the falsy value need to be handled correctly)
+ channel_id: false,
+ id: 2,
+ name: "Eden Hazard",
+ partner_id: 11,
+ },
+ ];
+ const chatter = this.env.models['mail.chatter'].create({
+ followerIds: [1, 2],
+ threadId: 100,
+ threadModel: 'res.partner',
+ });
+ await this.createChatterTopbarComponent(chatter);
+
+ assert.containsOnce(
+ document.body,
+ '.o_FollowerListMenu',
+ "should have followers menu component"
+ );
+ assert.containsOnce(
+ document.body,
+ '.o_FollowerListMenu_buttonFollowers',
+ "should have followers button"
+ );
+
+ await afterNextRender(() => {
+ document.querySelector('.o_FollowerListMenu_buttonFollowers').click();
+ });
+ assert.containsOnce(
+ document.body,
+ '.o_FollowerListMenu_dropdown',
+ "followers dropdown should be opened"
+ );
+ assert.containsN(
+ document.body,
+ '.o_Follower',
+ 2,
+ "exactly two followers should be listed"
+ );
+ assert.containsN(
+ document.body,
+ '.o_Follower_name',
+ 2,
+ "exactly two follower names should be listed"
+ );
+ assert.strictEqual(
+ document.querySelectorAll('.o_Follower_name')[0].textContent.trim(),
+ "Jean Michang",
+ "first follower is 'Jean Michang'"
+ );
+ assert.strictEqual(
+ document.querySelectorAll('.o_Follower_name')[1].textContent.trim(),
+ "Eden Hazard",
+ "second follower is 'Eden Hazard'"
+ );
+});
+
+QUnit.test('rendering with multiple channel followers', async function (assert) {
+ assert.expect(7);
+
+ this.data['res.partner'].records = [{
+ id: 100,
+ message_follower_ids: [1, 2],
+ }];
+ await this.start();
+ this.data['mail.followers'].records = [
+ {
+ channel_id: 11,
+ id: 1,
+ name: "channel numero 5",
+ // simulate real return from RPC
+ // (the presence of the key and the falsy value need to be handled correctly)
+ partner_id: false,
+ }, {
+ channel_id: 12,
+ id: 2,
+ name: "channel armstrong",
+ // simulate real return from RPC
+ // (the presence of the key and the falsy value need to be handled correctly)
+ partner_id: false,
+ },
+ ];
+ const chatter = this.env.models['mail.chatter'].create({
+ followerIds: [1, 2],
+ threadId: 100,
+ threadModel: 'res.partner',
+ });
+ await this.createChatterTopbarComponent(chatter);
+
+ assert.containsOnce(
+ document.body,
+ '.o_FollowerListMenu',
+ "should have followers menu component"
+ );
+ assert.containsOnce(
+ document.body,
+ '.o_FollowerListMenu_buttonFollowers',
+ "should have followers button"
+ );
+
+ await afterNextRender(() => {
+ document.querySelector('.o_FollowerListMenu_buttonFollowers').click();
+ });
+ assert.containsOnce(
+ document.body,
+ '.o_FollowerListMenu_dropdown',
+ "followers dropdown should be opened"
+ );
+ assert.containsN(
+ document.body,
+ '.o_Follower',
+ 2,
+ "exactly two followers should be listed"
+ );
+ assert.containsN(
+ document.body,
+ '.o_Follower_name',
+ 2,
+ "exactly two follower names should be listed"
+ );
+ assert.strictEqual(
+ document.querySelectorAll('.o_Follower_name')[0].textContent.trim(),
+ "channel numero 5",
+ "first follower is 'channel numero 5'"
+ );
+ assert.strictEqual(
+ document.querySelectorAll('.o_Follower_name')[1].textContent.trim(),
+ "channel armstrong",
+ "second follower is 'channel armstrong'"
+ );
+});
+
+});
+});
+});
+
+});
diff --git a/addons/mail/static/src/components/composer/composer.js b/addons/mail/static/src/components/composer/composer.js
new file mode 100644
index 00000000000..f2135835a4d
--- /dev/null
+++ b/addons/mail/static/src/components/composer/composer.js
@@ -0,0 +1,393 @@
+odoo.define('mail/static/src/components/composer/composer.js', function (require) {
+'use strict';
+
+const components = {
+ AttachmentList: require('mail/static/src/components/attachment_list/attachment_list.js'),
+ DropZone: require('mail/static/src/components/drop_zone/drop_zone.js'),
+ EmojisPopover: require('mail/static/src/components/emojis_popover/emojis_popover.js'),
+ FileUploader: require('mail/static/src/components/file_uploader/file_uploader.js'),
+ TextInput: require('mail/static/src/components/composer_text_input/composer_text_input.js'),
+ ThreadTextualTypingStatus: require('mail/static/src/components/thread_textual_typing_status/thread_textual_typing_status.js'),
+};
+const useDragVisibleDropZone = require('mail/static/src/component_hooks/use_drag_visible_dropzone/use_drag_visible_dropzone.js');
+const useStore = require('mail/static/src/component_hooks/use_store/use_store.js');
+const mailUtils = require('mail.utils');
+
+const { Component } = owl;
+const { useRef } = owl.hooks;
+
+class Composer extends Component {
+
+ /**
+ * @override
+ */
+ constructor(...args) {
+ super(...args);
+ this.isDropZoneVisible = useDragVisibleDropZone();
+ useStore(props => {
+ const composer = this.env.models['mail.composer'].get(props.composerLocalId);
+ return {
+ composer: composer ? composer.__state : undefined,
+ isDeviceMobile: this.env.messaging.device.isMobile,
+ thread: composer && composer.thread
+ ? composer.thread.__state
+ : undefined,
+ };
+ });
+ /**
+ * Reference of the emoji button. Useful to include emoji popover as
+ * click "inside" the composer for the prop `isDiscardOnClickAway`.
+ */
+ this._emojisPopoverRef = useRef('emojisPopover');
+ /**
+ * Reference of the file uploader.
+ * Useful to programmatically prompts the browser file uploader.
+ */
+ this._fileUploaderRef = useRef('fileUploader');
+ /**
+ * Reference of the text input component.
+ */
+ this._textInputRef = useRef('textInput');
+ /**
+ * Tracked focus counter from props. Useful to determine whether it
+ * should auto focus this composer when patched.
+ */
+ this._focusCount = 0;
+ // to focus if the prop changes
+ this._lastComposer = undefined;
+ this._onClickCaptureGlobal = this._onClickCaptureGlobal.bind(this);
+ }
+
+ mounted() {
+ this._lastComposer = this.composer;
+ if (this.props.isFocusOnMount) {
+ this.focus();
+ }
+ document.addEventListener('click', this._onClickCaptureGlobal, true);
+ }
+
+ patched() {
+ // focus when changing composer
+ if (
+ this.props.isFocusOnMount &&
+ this._lastComposer !== this.composer
+ ) {
+ this.focus();
+ }
+ this._lastComposer = this.composer;
+ }
+
+ willUnmount() {
+ document.removeEventListener('click', this._onClickCaptureGlobal, true);
+ }
+
+ //--------------------------------------------------------------------------
+ // Public
+ //--------------------------------------------------------------------------
+
+ /**
+ * @returns {mail.composer}
+ */
+ get composer() {
+ return this.env.models['mail.composer'].get(this.props.composerLocalId);
+ }
+
+ /**
+ * Get the current partner image URL.
+ *
+ * @returns {string}
+ */
+ get currentPartnerAvatar() {
+ const avatar = this.env.session.uid > 0
+ ? this.env.session.url('/web/image', {
+ field: 'image_128',
+ id: this.env.session.uid,
+ model: 'res.users',
+ })
+ : '/web/static/src/img/user_menu_avatar.png';
+ return avatar;
+ }
+
+ /**
+ * Focus the composer.
+ */
+ focus() {
+ if (this.env.messaging.device.isMobile) {
+ this.el.scrollIntoView();
+ }
+ this.composer.focus();
+ }
+
+ /**
+ * Focusout the composer.
+ */
+ focusout() {
+ this._textInputRef.comp.focusout();
+ }
+
+ /**
+ * Determine whether composer should display a footer.
+ *
+ * @returns {boolean}
+ */
+ get hasFooter() {
+ return (
+ this.props.hasThreadTyping ||
+ this.composer.attachments.length > 0 ||
+ !this.props.isCompact
+ );
+ }
+
+ /**
+ * Determine whether the composer should display a header.
+ *
+ * @returns {boolean}
+ */
+ get hasHeader() {
+ return (
+ (this.props.hasThreadName && this.composer.thread) ||
+ (this.props.hasFollowers && !this.props.isLog)
+ );
+ }
+
+ /**
+ * Get an object which is passed to FileUploader component to be used when
+ * creating attachment.
+ *
+ * @returns {Object}
+ */
+ get newAttachmentExtraData() {
+ return {
+ composers: [['replace', this.composer]],
+ };
+ }
+
+ //--------------------------------------------------------------------------
+ // Private
+ //--------------------------------------------------------------------------
+
+ /**
+ * Post a message in the composer on related thread.
+ *
+ * @private
+ */
+ async _postMessage() {
+ // TODO: take suggested recipients into account
+ await this.composer.postMessage({ isLog: this.props.isLog });
+ // TODO: we might need to remove trigger and use the store to wait for the post rpc to be done
+ this.trigger('o-message-posted');
+ }
+
+ //--------------------------------------------------------------------------
+ // Handlers
+ //--------------------------------------------------------------------------
+
+ /**
+ * Called when clicking on attachment button.
+ *
+ * @private
+ */
+ _onClickAddAttachment() {
+ this._fileUploaderRef.comp.openBrowserFileUploader();
+ this.focus();
+ }
+
+ /**
+ * Discards the composer when clicking away from the Inbox reply in discuss.
+ * TODO maybe move this in discuss. task-2092950
+ *
+ * @private
+ * @param {MouseEvent} ev
+ */
+ _onClickCaptureGlobal(ev) {
+ // in discuss app: prevents discarding the composer when clicking away
+ if (!this.props.isDiscardOnClickAway) {
+ return;
+ }
+ // prevents discarding when clicking on self (on discuss and not on discuss)
+ if (this.el.contains(ev.target)) {
+ return;
+ }
+ // emoji popover is outside but should be considered inside
+ if (this._emojisPopoverRef.comp.isInsideEventTarget(ev.target)) {
+ return;
+ }
+ this.composer.discard();
+ }
+
+ /**
+ * Called when clicking on "expand" button.
+ *
+ * @private
+ */
+ async _onClickFullComposer() {
+ const attachmentIds = this.composer.attachments.map(attachment => attachment.res_id);
+
+ const context = {
+ // default_parent_id: this.id,
+ default_body: mailUtils.escapeAndCompactTextContent(this.composer.textInputContent),
+ default_attachment_ids: attachmentIds,
+ // default_partner_ids: partnerIds,
+ default_is_log: this.props.isLog,
+ mail_post_autofollow: true,
+ };
+
+ // if (this.context.default_model && this.context.default_res_id) {
+ // context.default_model = this.context.default_model;
+ // context.default_res_id = this.context.default_res_id;
+ // }
+
+ const action = {
+ type: 'ir.actions.act_window',
+ res_model: 'mail.compose.message',
+ view_mode: 'form',
+ views: [[false, 'form']],
+ target: 'new',
+ context: context,
+ };
+ await this.env.bus.trigger('do-action', { action });
+ this.trigger('o-full-composer-opened');
+ }
+
+ /**
+ * Called when clicking on "discard" button.
+ *
+ * @private
+ * @param {MouseEvent} ev
+ */
+ _onClickDiscard(ev) {
+ this.composer.discard();
+ }
+
+ /**
+ * Called when clicking on "send" button.
+ *
+ * @private
+ */
+ _onClickSend() {
+ if (!this.composer.canPostMessage) {
+ return;
+ }
+ this._postMessage();
+ }
+
+ /**
+ * Called when some files have been dropped in the dropzone.
+ *
+ * @private
+ * @param {CustomEvent} ev
+ * @param {Object} ev.detail
+ * @param {FileList} ev.detail.files
+ */
+ async _onDropZoneFilesDropped(ev) {
+ ev.stopPropagation();
+ await this._fileUploaderRef.comp.uploadFiles(ev.detail.files);
+ this.isDropZoneVisible.value = false;
+ }
+
+ /**
+ * Called when selection an emoji from the emoji popover (from the emoji
+ * button).
+ *
+ * @private
+ * @param {CustomEvent} ev
+ * @param {Object} ev.detail
+ * @param {string} ev.detail.unicode
+ */
+ _onEmojiSelection(ev) {
+ ev.stopPropagation();
+ this._textInputRef.comp.saveStateInStore();
+ this.composer.insertIntoTextInput(ev.detail.unicode);
+ this.composer.focus();
+ }
+
+ /**
+ * @private
+ * @param {CustomEvent} ev
+ */
+ async _onPasteTextInput(ev) {
+ if (!ev.clipboardData || !ev.clipboardData.files) {
+ return;
+ }
+ await this._fileUploaderRef.comp.uploadFiles(ev.clipboardData.files);
+ }
+
+ /**
+ * @private
+ */
+ _onTextInputKeydownEnter() {
+ if (!this.composer.canPostMessage) {
+ return;
+ }
+ this._postMessage();
+ }
+
+}
+
+Object.assign(Composer, {
+ components,
+ defaultProps: {
+ attachmentLocalIds: [],
+ hasCurrentPartnerAvatar: true,
+ hasDiscardButton: false,
+ hasFollowers: false,
+ hasSendButton: true,
+ hasTextInputSendOnEnterEnabled: true,
+ hasThreadName: false,
+ hasThreadTyping: false,
+ isCompact: true,
+ isDiscardOnClickAway: false,
+ isExpandable: false,
+ isFocusOnMount: false,
+ isLog: false,
+ },
+ props: {
+ attachmentLocalIds: {
+ type: Array,
+ element: String,
+ },
+ attachmentsDetailsMode: {
+ type: String,
+ optional: true,
+ },
+ composerLocalId: String,
+ hasCurrentPartnerAvatar: Boolean,
+ hasDiscardButton: Boolean,
+ hasFollowers: Boolean,
+ hasMentionSuggestionsBelowPosition: {
+ type: Boolean,
+ optional: true,
+ },
+ hasSendButton: Boolean,
+ hasTextInputSendOnEnterEnabled: Boolean,
+ hasThreadName: Boolean,
+ hasThreadTyping: Boolean,
+ showAttachmentsExtensions: {
+ type: Boolean,
+ optional: true,
+ },
+ showAttachmentsFilenames: {
+ type: Boolean,
+ optional: true,
+ },
+ initialAttachmentLocalIds: {
+ type: Array,
+ element: String,
+ optional: true,
+ },
+ initialTextInputHtmlContent: {
+ type: String,
+ optional: true,
+ },
+ isCompact: Boolean,
+ isDiscardOnClickAway: Boolean,
+ isExpandable: Boolean,
+ isFocusOnMount: Boolean,
+ isLog: Boolean,
+ },
+ template: 'mail.Composer',
+});
+
+return Composer;
+
+});
diff --git a/addons/mail/static/src/components/composer/composer.scss b/addons/mail/static/src/components/composer/composer.scss
new file mode 100644
index 00000000000..98557ec1ce9
--- /dev/null
+++ b/addons/mail/static/src/components/composer/composer.scss
@@ -0,0 +1,278 @@
+// ------------------------------------------------------------------
+// Layout
+// ------------------------------------------------------------------
+
+.o_Composer {
+ display: grid;
+ grid-template-areas:
+ "sidebar-header core-header"
+ "sidebar-main core-main"
+ "sidebar-footer core-footer";
+ grid-template-columns: auto 1fr;
+ grid-template-rows: auto 1fr auto;
+
+ &.o-has-current-partner-avatar {
+ grid-template-columns: 50px 1fr;
+ padding: map-get($spacers, 0) map-get($spacers, 3) map-get($spacers, 1) map-get($spacers, 1);
+
+ &:not(.o-has-footer) {
+ padding-bottom: 20px;
+ }
+
+ &:not(.o-has-header) {
+ padding-top: 20px;
+ }
+ }
+}
+
+.o_Composer_actionButtons {
+ &.o-composer-is-compact {
+ display: flex;
+ }
+ &:not(.o-composer-is-compact) {
+ margin-top: 10px;
+ }
+}
+
+.o_Composer_attachmentList {
+ flex: 1 1 auto;
+}
+
+.o_Composer_buttons {
+ display: flex;
+ align-items: stretch;
+ flex: 0 0 auto;
+ height: 41px; // match minimal-height of input, including border width
+
+ &:not(.o-composer-is-compact) {
+ border: 0;
+ height: auto;
+ padding: 0 10px;
+ width: 100%;
+ }
+
+ &.o-mobile {
+ height: 51px; // mobile padding
+ }
+}
+
+.o_Composer_coreFooter {
+ grid-area: core-footer;
+
+ &:not(.o-composer-is-compact) {
+ margin-left: 0;
+ }
+}
+
+.o_Composer_coreHeader {
+ grid-area: core-header;
+}
+
+.o_Composer_coreMain {
+ grid-area: core-main;
+ min-width: 0;
+ display: flex;
+ flex-wrap: nowrap;
+ align-items: flex-start;
+ flex: 1 1 auto;
+
+ &:not(.o-composer-is-compact) {
+ flex-direction: column;
+ }
+}
+
+.o_Composer_currentPartnerAvatar {
+ width: 36px;
+ height: 36px;
+}
+
+.o_Composer_followers,
+.o_Composer_suggestedPartners {
+ flex: 0 0 100%;
+ margin-bottom: $o-mail-chatter-gap * 0.5;
+}
+
+.o_Composer_primaryToolButtons {
+ display: flex;
+ align-items: center;
+}
+
+.o_Composer_sidebarMain {
+ grid-area: sidebar-main;
+ justify-self: center;
+}
+
+.o_Composer_textInput {
+ flex: 1 1 auto;
+ align-self: stretch;
+
+ &:not(.o-composer-is-compact) {
+ border: 0;
+ min-height: 40px;
+
+ .note-editable {
+ min-height: 40px;
+ }
+ }
+}
+
+.o_Composer_threadTextualTypingStatus {
+ font-size: $font-size-sm;
+ overflow: hidden;
+ text-overflow: ellipsis;
+
+ &:before {
+ // invisible character so that typing status bar has constant height, regardless of text content.
+ content: "\200b"; /* unicode zero width space character */
+ }
+}
+
+.o_Composer_toolButton {
+ margin: 0 2px;
+
+ &:not(.o-mobile) {
+ margin-top: 2px;
+ margin-bottom: 2px;
+ }
+}
+
+.o_Composer_toolButtons {
+ display: flex;
+
+ &:not(.o-composer-is-compact) {
+ flex-direction: row;
+ justify-content: space-between;
+ flex: 100%;
+ }
+
+ &:not(:last-child) .o_Composer_toolButton:last-child {
+ margin-right: 5px;
+ }
+}
+
+.o_Composer_toolButtonSeparator {
+ flex: 0 0 auto;
+ margin-top: 5px;
+ margin-bottom: 5px;
+ margin-right: 5px;
+}
+
+// ------------------------------------------------------------------
+// Style
+// ------------------------------------------------------------------
+
+.o_Composer {
+ background-color: lighten(gray('300'), 7%);
+
+ &.o-has-current-partner-avatar {
+
+ .o_Composer_actionButtons.o-composer-is-compact > *:last-child {
+ border-radius: 0 10px 10px 0;
+ }
+
+ .o_Composer_textInput {
+ border-radius: 10px 0 0 10px;
+
+ > .note-editor {
+ border-radius: 10px 0 0 10px;
+
+ > .note-editing-area {
+ border-radius: 10px 0 0 10px;
+ }
+ }
+ }
+ }
+}
+
+.o_Composer_button {
+ color: gray('600');
+
+ &.o-open{
+ background-color: gray('200');
+ }
+
+ &.o-composer-is-compact {
+ border-left: none;
+ border-radius: 0 3px 3px 0;
+ }
+}
+
+// TODO FIXME o-open on the button should be enough, https://github.com/odoo/owl/issues/693
+.o_is_open {
+ .o_Composer_button {
+ background-color: gray('200');
+ }
+}
+
+.o_Composer_buttonDiscard {
+ border: 1px solid lighten(gray('400'), 5%);
+}
+
+.o_Composer_buttons {
+ border: 0;
+}
+
+.o_Composer_buttonSend {
+ border: 1px solid darken($o-brand-primary, 5%);
+
+ &:disabled {
+ // enforce this style (override jQuery style)
+ border: 1px solid darken($o-brand-primary, 5%);
+
+ &.o-composer-is-compact {
+ border-left: none;
+ }
+ }
+
+ &:not(:last-child) {
+ border-right: none;
+ }
+}
+
+.o_Composer_coreMain:not(.o-composer-is-compact) {
+ background: white;
+ border-radius: 5px;
+ border: 1px solid lighten(gray('400'), 5%);
+}
+
+.o_Composer_textInput {
+ appearance: none;
+ outline: none;
+ background-color: white;
+ border: 0;
+ border-top: 1px solid lighten(gray('400'), 5%);
+ border-bottom: 1px solid lighten(gray('400'), 5%);
+ border-left: 1px solid lighten(gray('400'), 5%);
+
+ &:not(.o-composer-is-compact) {
+ border: 0;
+ }
+}
+
+.o_Composer_toolButton {
+ border: 0;
+
+ &:hover {
+ // enforce this style (webclient btn-secondary)
+ border: 0;
+ }
+}
+
+.o_Composer_toolButtons {
+ background-color: white;
+ border-top: 1px solid lighten(gray('400'), 5%);
+ border-bottom: 1px solid lighten(gray('400'), 5%);
+
+ &:not(.o-composer-is-compact) {
+ border-bottom: 0;
+ border-radius: initial;
+ }
+
+ &:last-child:not(.o-composer-has-current-partner-avatar) {
+ border-right: 1px solid lighten(gray('400'), 5%);
+ }
+}
+
+.o_Composer_toolButtonSeparator {
+ border-left: 1px solid lighten(gray('400'), 5%);
+}
diff --git a/addons/mail/static/src/components/composer/composer.xml b/addons/mail/static/src/components/composer/composer.xml
new file mode 100644
index 00000000000..055c3837ccf
--- /dev/null
+++ b/addons/mail/static/src/components/composer/composer.xml
@@ -0,0 +1,154 @@
+<?xml version="1.0" encoding="UTF-8"?>
+<templates xml:space="preserve">
+
+ <t t-name="mail.Composer" owl="1">
+ <div class="o_Composer"
+ t-att-class="{
+ 'o-has-current-partner-avatar': props.hasCurrentPartnerAvatar,
+ 'o-has-footer': hasFooter,
+ 'o-has-header': hasHeader,
+ 'o-is-compact': props.isCompact,
+ }"
+ >
+ <t t-if="composer">
+ <t t-if="isDropZoneVisible.value">
+ <DropZone
+ class="o_Composer_dropZone"
+ t-on-o-dropzone-files-dropped="_onDropZoneFilesDropped"
+ t-ref="dropzone"
+ />
+ </t>
+ <FileUploader
+ attachmentLocalIds="composer.attachments.map(attachment => attachment.localId)"
+ newAttachmentExtraData="newAttachmentExtraData"
+ t-ref="fileUploader"
+ />
+ <t t-if="hasHeader">
+ <div class="o_Composer_coreHeader">
+ <t t-if="props.hasThreadName and composer.thread">
+ <span class="o_Composer_threadName">
+ on: <b><t t-esc="composer.thread.displayName"/></b>
+ </span>
+ </t>
+ <t t-if="props.hasFollowers and !props.isLog">
+ <!-- Text for followers -->
+ <small class="o_Composer_followers">
+ <b class="text-muted">To: </b>
+ <em class="text-muted">Followers of </em>
+ <b>
+ <t t-if="composer.thread">
+ &#32;&quot;<t t-esc="composer.thread.name"/>&quot;
+ </t>
+ <t t-else="">
+ this document
+ </t>
+ </b>
+ </small>
+ </t>
+ </div>
+ </t>
+ <t t-if="props.hasCurrentPartnerAvatar">
+ <div class="o_Composer_sidebarMain">
+ <img class="o_Composer_currentPartnerAvatar rounded-circle" t-att-src="currentPartnerAvatar" alt=""/>
+ </div>
+ </t>
+ <div class="o_Composer_coreMain" t-att-class="{ 'o-composer-is-compact': props.isCompact }">
+ <TextInput
+ class="o_Composer_textInput"
+ t-att-class="{
+ 'o-composer-is-compact': props.isCompact,
+ 'o_Composer_textInput-mobile': env.messaging.device.isMobile,
+ }"
+ composerLocalId="composer.localId"
+ hasMentionSuggestionsBelowPosition="props.hasMentionSuggestionsBelowPosition"
+ hasSendOnEnterEnabled="props.hasTextInputSendOnEnterEnabled"
+ t-on-o-keydown-enter="_onTextInputKeydownEnter"
+ t-on-paste="_onPasteTextInput"
+ t-ref="textInput"
+ />
+ <div class="o_Composer_buttons" t-att-class="{ 'o-composer-is-compact': props.isCompact, 'o-mobile': env.messaging.device.isMobile }">
+ <div class="o_Composer_toolButtons"
+ t-att-class="{
+ 'o-composer-has-current-partner-avatar': props.hasCurrentPartnerAvatar,
+ 'o-composer-is-compact': props.isCompact,
+ }">
+ <t t-if="props.isCompact">
+ <div class="o_Composer_toolButtonSeparator"/>
+ </t>
+ <div class="o_Composer_primaryToolButtons">
+ <Popover position="'top'" t-on-o-emoji-selection="_onEmojiSelection" t-ref="emojisPopover">
+ <!-- TODO FIXME o-open not possible to code due to https://github.com/odoo/owl/issues/693 -->
+ <button class="o_Composer_button o_Composer_buttonEmojis o_Composer_toolButton btn btn-secondary"
+ t-att-class="{
+ 'o-open': false and state.displayed,
+ 'o-mobile': env.messaging.device.isMobile,
+ }"
+ >
+ <i class="fa fa-smile-o"/>
+ </button>
+ <t t-set="opened">
+ <EmojisPopover/>
+ </t>
+ </Popover>
+ <button class="o_Composer_button o_Composer_buttonAttachment o_Composer_toolButton btn btn-secondary fa fa-paperclip" t-att-class="{ 'o-mobile': env.messaging.device.isMobile }" title="Add attachment" type="button" t-on-click="_onClickAddAttachment"/>
+ </div>
+ <t t-if="props.isExpandable">
+ <div class="o_Composer_secondaryToolButtons">
+ <button class="btn btn-secondary fa fa-expand o_Composer_button o_Composer_buttonFullComposer o_Composer_toolButton" t-att-class="{ 'o-mobile': env.messaging.device.isMobile }" title="Full composer" type="button" t-on-click="_onClickFullComposer"/>
+ </div>
+ </t>
+ </div>
+ <t t-if="props.isCompact">
+ <t t-call="mail.Composer.actionButtons"/>
+ </t>
+ </div>
+ </div>
+ <t t-if="hasFooter">
+ <div class="o_Composer_coreFooter" t-att-class="{ 'o-composer-is-compact': props.isCompact }">
+ <t t-if="props.hasThreadTyping">
+ <ThreadTextualTypingStatus class="o_Composer_threadTextualTypingStatus" threadLocalId="composer.thread.localId"/>
+ </t>
+ <t t-if="composer.attachments.length > 0">
+ <AttachmentList
+ class="o_Composer_attachmentList"
+ areAttachmentsEditable="true"
+ attachmentsDetailsMode="props.attachmentsDetailsMode"
+ attachmentLocalIds="composer.attachments.map(attachment => attachment.localId)"
+ showAttachmentsExtensions="props.showAttachmentsExtensions"
+ showAttachmentsFilenames="props.showAttachmentsFilenames"
+ />
+ </t>
+ <t t-if="!props.isCompact">
+ <t t-call="mail.Composer.actionButtons"/>
+ </t>
+ </div>
+ </t>
+ </t>
+ </div>
+ </t>
+
+ <t t-name="mail.Composer.actionButtons" owl="1">
+ <div class="o_Composer_actionButtons" t-att-class="{ 'o-composer-is-compact': props.isCompact }">
+ <t t-if="props.hasSendButton">
+ <button class="o_Composer_actionButton o_Composer_button o_Composer_buttonSend btn btn-primary"
+ t-att-class="{
+ 'fa': env.messaging.device.isMobile,
+ 'fa-paper-plane-o': env.messaging.device.isMobile,
+ 'o-composer-is-compact': props.isCompact,
+ }"
+ t-att-disabled="(!composer.textInputContent and composer.attachments.length === 0) ? 'disabled' : ''"
+ type="button"
+ t-on-click="_onClickSend"
+ >
+ <t t-if="!env.messaging.device.isMobile"><t t-if="props.isLog">Log</t><t t-else="">Send</t></t>
+ </button>
+ </t>
+ <t t-if="!env.messaging.device.isMobile and props.hasDiscardButton">
+ <button class="o_Composer_actionButton o_Composer_button o_Composer_buttonDiscard btn btn-secondary" t-att-class="{ 'o-composer-is-compact': props.isCompact }" type="button" t-on-click="_onClickDiscard">
+ Discard
+ </button>
+ </t>
+ </div>
+ </t>
+
+</templates>
diff --git a/addons/mail/static/src/components/composer/composer_tests.js b/addons/mail/static/src/components/composer/composer_tests.js
new file mode 100644
index 00000000000..e506e99a48e
--- /dev/null
+++ b/addons/mail/static/src/components/composer/composer_tests.js
@@ -0,0 +1,866 @@
+odoo.define('mail/static/src/components/composer/composer_tests.js', function (require) {
+'use strict';
+
+const components = {
+ Composer: require('mail/static/src/components/composer/composer.js'),
+};
+const {
+ afterEach: utilsAfterEach,
+ afterNextRender,
+ beforeEach: utilsBeforeEach,
+ dragenterFiles,
+ dropFiles,
+ inputFiles,
+ nextAnimationFrame,
+ pasteFiles,
+ start: utilsStart,
+} = require('mail/static/src/utils/test_utils.js');
+
+const { file: { createFile } } = require('web.test_utils');
+
+QUnit.module('mail', {}, function () {
+QUnit.module('components', {}, function () {
+QUnit.module('composer', {}, function () {
+QUnit.module('composer_tests.js', {
+ beforeEach() {
+ utilsBeforeEach(this);
+
+ this.createComposerComponent = async (composer, otherProps) => {
+ const ComposerComponent = components.Composer;
+ ComposerComponent.env = this.env;
+ this.component = new ComposerComponent(null, Object.assign({
+ composerLocalId: composer.localId,
+ }, otherProps));
+ await afterNextRender(() => this.component.mount(this.widget.el));
+ };
+
+ 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;
+ };
+ },
+ async afterEach() {
+ utilsAfterEach(this);
+ if (this.component) {
+ this.component.destroy();
+ }
+ if (this.widget) {
+ this.widget.destroy();
+ }
+ this.env = undefined;
+ delete components.Composer.env;
+ },
+});
+
+QUnit.test('composer text input: basic rendering', async function (assert) {
+ assert.expect(5);
+
+ await this.start();
+ const composer = this.env.models['mail.composer'].create();
+ await this.createComposerComponent(composer);
+ assert.strictEqual(
+ document.querySelectorAll('.o_Composer').length,
+ 1,
+ "should have composer in discuss thread"
+ );
+ assert.strictEqual(
+ document.querySelectorAll('.o_Composer_textInput').length,
+ 1,
+ "should have text input inside discuss thread composer"
+ );
+ assert.ok(
+ document.querySelector('.o_Composer_textInput').classList.contains('o_ComposerTextInput'),
+ "composer text input of composer should be a ComposerTextIput component"
+ );
+ assert.strictEqual(
+ document.querySelectorAll(`.o_ComposerTextInput_textarea`).length,
+ 1,
+ "should have editable part inside composer text input"
+ );
+ assert.strictEqual(
+ document.querySelector(`.o_ComposerTextInput_textarea`).placeholder,
+ "Write something...",
+ "should have placeholder in note editable of composer text input"
+ );
+});
+
+QUnit.test('add an emoji', async function (assert) {
+ assert.expect(1);
+
+ await this.start();
+ const composer = this.env.models['mail.composer'].create();
+ await this.createComposerComponent(composer);
+ await afterNextRender(() =>
+ document.querySelector('.o_Composer_buttonEmojis').click()
+ );
+ await afterNextRender(() =>
+ document.querySelector('.o_EmojisPopover_emoji[data-unicode="😊"]').click()
+ );
+ assert.strictEqual(
+ document.querySelector(`.o_ComposerTextInput_textarea`).value,
+ "😊",
+ "emoji should be inserted in the composer text input"
+ );
+ // ensure popover is closed
+ await nextAnimationFrame();
+ await nextAnimationFrame();
+ await nextAnimationFrame();
+});
+
+QUnit.test('add an emoji after some text', async function (assert) {
+ assert.expect(2);
+
+ await this.start();
+ const composer = this.env.models['mail.composer'].create();
+ await this.createComposerComponent(composer);
+ await afterNextRender(() => {
+ document.querySelector(`.o_ComposerTextInput_textarea`).focus();
+ document.execCommand('insertText', false, "Blabla");
+ });
+ assert.strictEqual(
+ document.querySelector(`.o_ComposerTextInput_textarea`).value,
+ "Blabla",
+ "composer text input should have text only initially"
+ );
+
+ await afterNextRender(() => document.querySelector('.o_Composer_buttonEmojis').click());
+ await afterNextRender(() =>
+ document.querySelector('.o_EmojisPopover_emoji[data-unicode="😊"]').click()
+ );
+ assert.strictEqual(
+ document.querySelector(`.o_ComposerTextInput_textarea`).value,
+ "Blabla😊",
+ "emoji should be inserted after the text"
+ );
+ // ensure popover is closed
+ await nextAnimationFrame();
+ await nextAnimationFrame();
+ await nextAnimationFrame();
+});
+
+QUnit.test('add emoji replaces (keyboard) text selection', async function (assert) {
+ assert.expect(2);
+
+ await this.start();
+ const composer = this.env.models['mail.composer'].create();
+ await this.createComposerComponent(composer);
+ const composerTextInputTextArea = document.querySelector(`.o_ComposerTextInput_textarea`);
+ await afterNextRender(() => {
+ composerTextInputTextArea.focus();
+ document.execCommand('insertText', false, "Blabla");
+ });
+ assert.strictEqual(
+ composerTextInputTextArea.value,
+ "Blabla",
+ "composer text input should have text only initially"
+ );
+
+ // simulate selection of all the content by keyboard
+ composerTextInputTextArea.setSelectionRange(0, composerTextInputTextArea.value.length);
+
+ // select emoji
+ await afterNextRender(() => document.querySelector('.o_Composer_buttonEmojis').click());
+ await afterNextRender(() =>
+ document.querySelector('.o_EmojisPopover_emoji[data-unicode="😊"]').click()
+ );
+ assert.strictEqual(
+ document.querySelector(`.o_ComposerTextInput_textarea`).value,
+ "😊",
+ "whole text selection should have been replaced by emoji"
+ );
+ // ensure popover is closed
+ await nextAnimationFrame();
+ await nextAnimationFrame();
+ await nextAnimationFrame();
+});
+
+QUnit.test('display partner mention suggestions on typing "@"', async function (assert) {
+ assert.expect(2);
+
+ await this.start({
+ async mockRPC(route, args) {
+ if (args.method === 'get_mention_suggestions') {
+ return [
+ [{
+ email: "testpartnert@odoo.com",
+ id: 11,
+ name: "TestPartner",
+ }],
+ [],
+ ];
+ }
+ return this._super(...arguments);
+ },
+ });
+ const composer = this.env.models['mail.composer'].create();
+ await this.createComposerComponent(composer);
+
+ assert.containsNone(
+ document.body,
+ '.o_ComposerTextInput_mentionDropdownPropositionList',
+ "mention suggestions list should not be present"
+ );
+ await afterNextRender(() => {
+ document.querySelector(`.o_ComposerTextInput_textarea`).focus();
+ document.execCommand('insertText', false, "@");
+ document.querySelector(`.o_ComposerTextInput_textarea`)
+ .dispatchEvent(new window.KeyboardEvent('keydown'));
+ document.querySelector(`.o_ComposerTextInput_textarea`)
+ .dispatchEvent(new window.KeyboardEvent('keyup'));
+ });
+ assert.hasClass(
+ document.querySelector('.o_ComposerTextInput_mentionDropdownPropositionList'),
+ 'show',
+ "should display mention suggestions on typing '@'"
+ );
+});
+
+QUnit.test('mention a partner', async function (assert) {
+ assert.expect(4);
+
+ await this.start({
+ async mockRPC(route, args) {
+ if (args.method === 'get_mention_suggestions') {
+ return [
+ [{
+ email: "testpartnert@odoo.com",
+ id: 11,
+ name: "TestPartner",
+ }],
+ [],
+ ];
+ }
+ return this._super(...arguments);
+ },
+ });
+ const composer = this.env.models['mail.composer'].create();
+ await this.createComposerComponent(composer);
+
+ assert.containsNone(
+ document.body,
+ '.o_ComposerTextInput_mentionDropdownPropositionList',
+ "mention suggestions list should not be present"
+ );
+ assert.strictEqual(
+ document.querySelector(`.o_ComposerTextInput_textarea`).value,
+ "",
+ "text content of composer should be empty initially"
+ );
+ await afterNextRender(() => {
+ document.querySelector(`.o_ComposerTextInput_textarea`).focus();
+ document.execCommand('insertText', false, "@");
+ });
+ await afterNextRender(() => {
+ document.querySelector(`.o_ComposerTextInput_textarea`)
+ .dispatchEvent(new window.KeyboardEvent('keydown'));
+ document.querySelector(`.o_ComposerTextInput_textarea`)
+ .dispatchEvent(new window.KeyboardEvent('keyup'));
+ });
+ assert.containsOnce(
+ document.body,
+ '.o_PartnerMentionSuggestion',
+ "should have a mention suggestion"
+ );
+ await afterNextRender(() =>
+ document.querySelector('.o_PartnerMentionSuggestion').click()
+ );
+ assert.strictEqual(
+ document.querySelector(`.o_ComposerTextInput_textarea`).value.replace(/\s/, " "),
+ "@TestPartner ",
+ "text content of composer should have mentioned partner + additional whitespace afterwards"
+ );
+});
+
+QUnit.test('mention a partner after some text', async function (assert) {
+ assert.expect(5);
+
+ await this.start({
+ async mockRPC(route, args) {
+ if (args.method === 'get_mention_suggestions') {
+ return [
+ [{
+ email: "testpartnert@odoo.com",
+ id: 11,
+ name: "TestPartner",
+ }],
+ [],
+ ];
+ }
+ return this._super(...arguments);
+ },
+ });
+ const composer = this.env.models['mail.composer'].create();
+ await this.createComposerComponent(composer);
+
+ assert.containsNone(
+ document.body,
+ '.o_ComposerTextInput_mentionDropdownPropositionList',
+ "mention suggestions list should not be present"
+ );
+ assert.strictEqual(
+ document.querySelector(`.o_ComposerTextInput_textarea`).value,
+ "",
+ "text content of composer should be empty initially"
+ );
+ document.querySelector(`.o_ComposerTextInput_textarea`).focus();
+ await afterNextRender(() =>
+ document.execCommand('insertText', false, "bluhbluh ")
+ );
+ assert.strictEqual(
+ document.querySelector(`.o_ComposerTextInput_textarea`).value,
+ "bluhbluh ",
+ "text content of composer should have content"
+ );
+ await afterNextRender(() =>
+ document.execCommand('insertText', false, "@")
+ );
+ await afterNextRender(() => {
+ document.querySelector(`.o_ComposerTextInput_textarea`)
+ .dispatchEvent(new window.KeyboardEvent('keydown'));
+ document.querySelector(`.o_ComposerTextInput_textarea`)
+ .dispatchEvent(new window.KeyboardEvent('keyup'));
+ });
+ assert.containsOnce(
+ document.body,
+ '.o_PartnerMentionSuggestion',
+ "should have a mention suggestion"
+ );
+ await afterNextRender(() =>
+ document.querySelector('.o_PartnerMentionSuggestion').click()
+ );
+ assert.strictEqual(
+ document.querySelector(`.o_ComposerTextInput_textarea`).value.replace(/\s/, " "),
+ "bluhbluh @TestPartner ",
+ "text content of composer should have previous content + mentioned partner + additional whitespace afterwards"
+ );
+});
+
+QUnit.test('add an emoji after a partner mention', async function (assert) {
+ assert.expect(5);
+
+ await this.start({
+ async mockRPC(route, args) {
+ if (args.method === 'get_mention_suggestions') {
+ return [
+ [{
+ email: "testpartnert@odoo.com",
+ id: 11,
+ name: "TestPartner",
+ }],
+ [],
+ ];
+ }
+ return this._super(...arguments);
+ },
+ });
+ const composer = this.env.models['mail.composer'].create();
+ await this.createComposerComponent(composer);
+
+ assert.containsNone(
+ document.body,
+ '.o_ComposerTextInput_mentionDropdownPropositionList',
+ "mention suggestions list should not be present"
+ );
+ assert.strictEqual(
+ document.querySelector(`.o_ComposerTextInput_textarea`).value,
+ "",
+ "text content of composer should be empty initially"
+ );
+ await afterNextRender(() => {
+ document.querySelector(`.o_ComposerTextInput_textarea`).focus();
+ document.execCommand('insertText', false, "@");
+ });
+ await afterNextRender(() => {
+ document.querySelector(`.o_ComposerTextInput_textarea`)
+ .dispatchEvent(new window.KeyboardEvent('keydown'));
+ document.querySelector(`.o_ComposerTextInput_textarea`)
+ .dispatchEvent(new window.KeyboardEvent('keyup'));
+ });
+ assert.containsOnce(
+ document.body,
+ '.o_PartnerMentionSuggestion',
+ "should have a mention suggestion"
+ );
+ await afterNextRender(() =>
+ document.querySelector('.o_PartnerMentionSuggestion').click()
+ );
+ assert.strictEqual(
+ document.querySelector(`.o_ComposerTextInput_textarea`).value.replace(/\s/, " "),
+ "@TestPartner ",
+ "text content of composer should have previous content + mentioned partner + additional whitespace afterwards"
+ );
+
+ // select emoji
+ await afterNextRender(() =>
+ document.querySelector('.o_Composer_buttonEmojis').click()
+ );
+ await afterNextRender(() =>
+ document.querySelector('.o_EmojisPopover_emoji[data-unicode="😊"]').click()
+ );
+ assert.strictEqual(
+ document.querySelector(`.o_ComposerTextInput_textarea`).value.replace(/\s/, " "),
+ "@TestPartner 😊",
+ "text content of composer should have previous mention and selected emoji just after"
+ );
+ // ensure popover is closed
+ await nextAnimationFrame();
+});
+
+QUnit.test('composer: add an attachment', async function (assert) {
+ assert.expect(2);
+
+ await this.start();
+ const composer = this.env.models['mail.composer'].create();
+ await this.createComposerComponent(composer, { attachmentsDetailsMode: 'card' });
+ const file = await createFile({
+ content: 'hello, world',
+ contentType: 'text/plain',
+ name: 'text.txt',
+ });
+ await afterNextRender(() =>
+ inputFiles(
+ document.querySelector('.o_FileUploader_input'),
+ [file]
+ )
+ );
+ assert.ok(
+ document.querySelector('.o_Composer_attachmentList'),
+ "should have an attachment list"
+ );
+ assert.ok(
+ document.querySelector(`.o_Composer .o_Attachment`),
+ "should have an attachment"
+ );
+});
+
+QUnit.test('composer: drop attachments', async function (assert) {
+ assert.expect(4);
+
+ await this.start();
+ const composer = this.env.models['mail.composer'].create();
+ await this.createComposerComponent(composer);
+ const files = [
+ await createFile({
+ content: 'hello, world',
+ contentType: 'text/plain',
+ name: 'text.txt',
+ }),
+ await createFile({
+ content: 'hello, worlduh',
+ contentType: 'text/plain',
+ name: 'text2.txt',
+ }),
+ ];
+ await afterNextRender(() => dragenterFiles(document.querySelector('.o_Composer')));
+ assert.ok(
+ document.querySelector('.o_Composer_dropZone'),
+ "should have a drop zone"
+ );
+ assert.strictEqual(
+ document.querySelectorAll(`.o_Composer .o_Attachment`).length,
+ 0,
+ "should have no attachment before files are dropped"
+ );
+
+ await afterNextRender(() =>
+ dropFiles(
+ document.querySelector('.o_Composer_dropZone'),
+ files
+ )
+ );
+ assert.strictEqual(
+ document.querySelectorAll(`.o_Composer .o_Attachment`).length,
+ 2,
+ "should have 2 attachments in the composer after files dropped"
+ );
+
+ await afterNextRender(() => dragenterFiles(document.querySelector('.o_Composer')));
+ await afterNextRender(async () =>
+ dropFiles(
+ document.querySelector('.o_Composer_dropZone'),
+ [
+ await createFile({
+ content: 'hello, world',
+ contentType: 'text/plain',
+ name: 'text3.txt',
+ })
+ ]
+ )
+ );
+ assert.strictEqual(
+ document.querySelectorAll(`.o_Composer .o_Attachment`).length,
+ 3,
+ "should have 3 attachments in the box after files dropped"
+ );
+});
+
+QUnit.test('composer: paste attachments', async function (assert) {
+ assert.expect(2);
+
+ await this.start();
+ const composer = this.env.models['mail.composer'].create();
+ await this.createComposerComponent(composer);
+ const files = [
+ await createFile({
+ content: 'hello, world',
+ contentType: 'text/plain',
+ name: 'text.txt',
+ })
+ ];
+ assert.strictEqual(
+ document.querySelectorAll(`.o_Composer .o_Attachment`).length,
+ 0,
+ "should not have any attachment in the composer before paste"
+ );
+
+ await afterNextRender(() =>
+ pasteFiles(document.querySelector('.o_ComposerTextInput'), files)
+ );
+ assert.strictEqual(
+ document.querySelectorAll(`.o_Composer .o_Attachment`).length,
+ 1,
+ "should have 1 attachment in the composer after paste"
+ );
+});
+
+QUnit.test('composer text input cleared on message post', async function (assert) {
+ assert.expect(4);
+
+ Object.assign(this.data.initMessaging, {
+ channel_slots: {
+ channel_channel: [{
+ channel_type: 'channel',
+ id: 20,
+ name: "General",
+ }],
+ },
+ });
+ await this.start({
+ async mockRPC(route, args) {
+ if (args.method === 'message_post') {
+ assert.step('message_post');
+ return;
+ }
+ return this._super(...arguments);
+ },
+ });
+ const thread = this.env.models['mail.thread'].find(thread =>
+ thread.id === 20 &&
+ thread.model === 'mail.channel'
+ );
+ await this.createComposerComponent(thread.composer);
+ // Type message
+ document.querySelector(`.o_ComposerTextInput_textarea`).focus();
+ document.execCommand('insertText', false, "test message");
+ assert.strictEqual(
+ document.querySelector(`.o_ComposerTextInput_textarea`).value,
+ "test message",
+ "should have inserted text content in editable"
+ );
+
+ // Send message
+ await afterNextRender(() =>
+ document.querySelector(`.o_ComposerTextInput_textarea`)
+ .dispatchEvent(new window.KeyboardEvent('keydown', { key: 'Enter' }))
+ );
+ assert.verifySteps(['message_post']);
+ assert.strictEqual(
+ document.querySelector(`.o_ComposerTextInput_textarea`).value,
+ "",
+ "should have no content in composer input after posting message"
+ );
+});
+
+QUnit.test('composer with thread typing notification status', async function (assert) {
+ assert.expect(2);
+
+ Object.assign(this.data.initMessaging, {
+ channel_slots: {
+ channel_channel: [{
+ channel_type: 'channel',
+ id: 20,
+ name: "General",
+ }],
+ },
+ });
+ await this.start();
+ const thread = this.env.models['mail.thread'].find(thread =>
+ thread.id === 20 &&
+ thread.model === 'mail.channel'
+ );
+ await this.createComposerComponent(thread.composer, { hasThreadTyping: true });
+
+ assert.containsOnce(
+ document.body,
+ '.o_Composer_threadTextualTypingStatus',
+ "Composer should have a thread textual typing status bar"
+ );
+ assert.strictEqual(
+ document.body.querySelector('.o_Composer_threadTextualTypingStatus').textContent,
+ "",
+ "By default, thread textual typing status bar should be empty"
+ );
+});
+
+QUnit.test('current partner notify is typing to other thread members', async function (assert) {
+ assert.expect(2);
+
+ Object.assign(this.data.initMessaging, {
+ channel_slots: {
+ channel_channel: [{
+ channel_type: 'channel',
+ id: 20,
+ members: [{
+ email: 'admin@odoo.com',
+ id: 3,
+ name: 'Admin',
+ }, {
+ email: 'demo@odoo.com',
+ id: 7,
+ name: 'Demo',
+ }],
+ name: "General",
+ }],
+ },
+ });
+ await this.start({
+ env: {
+ session: {
+ name: 'Admin',
+ partner_display_name: 'Your Company, Admin',
+ partner_id: 3,
+ uid: 2,
+ },
+ },
+ async mockRPC(route, args) {
+ if (args.method === 'notify_typing') {
+ assert.step(`notify_typing:${args.kwargs.is_typing}`);
+ return;
+ }
+ return this._super(...arguments);
+ },
+ });
+ const thread = this.env.models['mail.thread'].find(thread =>
+ thread.id === 20 &&
+ thread.model === 'mail.channel'
+ );
+ await this.createComposerComponent(thread.composer, { hasThreadTyping: true });
+
+ document.querySelector(`.o_ComposerTextInput_textarea`).focus();
+ document.execCommand('insertText', false, "a");
+ document.querySelector(`.o_ComposerTextInput_textarea`)
+ .dispatchEvent(new window.KeyboardEvent('keydown', { key: 'a' }));
+
+ assert.verifySteps(
+ ['notify_typing:true'],
+ "should have notified current partner typing status"
+ );
+});
+
+QUnit.test('current partner is typing should not translate on textual typing status', async function (assert) {
+ assert.expect(3);
+
+ Object.assign(this.data.initMessaging, {
+ channel_slots: {
+ channel_channel: [{
+ channel_type: 'channel',
+ id: 20,
+ members: [{
+ email: 'admin@odoo.com',
+ id: 3,
+ name: 'Admin',
+ }, {
+ email: 'demo@odoo.com',
+ id: 7,
+ name: 'Demo',
+ }],
+ name: "General",
+ }],
+ },
+ });
+ await this.start({
+ env: {
+ session: {
+ name: 'Admin',
+ partner_display_name: 'Your Company, Admin',
+ partner_id: 3,
+ uid: 2,
+ },
+ },
+ hasTimeControl: true,
+ async mockRPC(route, args) {
+ if (args.method === 'notify_typing') {
+ assert.step(`notify_typing:${args.kwargs.is_typing}`);
+ return;
+ }
+ return this._super(...arguments);
+ },
+ });
+ const thread = this.env.models['mail.thread'].find(thread =>
+ thread.id === 20 &&
+ thread.model === 'mail.channel'
+ );
+ await this.createComposerComponent(thread.composer, { hasThreadTyping: true });
+
+ document.querySelector(`.o_ComposerTextInput_textarea`).focus();
+ document.execCommand('insertText', false, "a");
+ document.querySelector(`.o_ComposerTextInput_textarea`)
+ .dispatchEvent(new window.KeyboardEvent('keydown', { key: 'a' }));
+
+ assert.verifySteps(
+ ['notify_typing:true'],
+ "should have notified current partner typing status"
+ );
+
+ await nextAnimationFrame();
+ assert.strictEqual(
+ document.body.querySelector('.o_Composer_threadTextualTypingStatus').textContent,
+ "",
+ "Thread textual typing status bar should not display current partner is typing"
+ );
+});
+
+QUnit.test('current partner notify no longer is typing to thread members after 5 seconds inactivity', async function (assert) {
+ assert.expect(4);
+
+ Object.assign(this.data.initMessaging, {
+ channel_slots: {
+ channel_channel: [{
+ channel_type: 'channel',
+ id: 20,
+ members: [{
+ email: 'admin@odoo.com',
+ id: 3,
+ name: 'Admin',
+ }, {
+ email: 'demo@odoo.com',
+ id: 7,
+ name: 'Demo',
+ }],
+ name: "General",
+ }],
+ },
+ });
+ await this.start({
+ env: {
+ session: {
+ name: 'Admin',
+ partner_display_name: 'Your Company, Admin',
+ partner_id: 3,
+ uid: 2,
+ },
+ },
+ hasTimeControl: true,
+ async mockRPC(route, args) {
+ if (args.method === 'notify_typing') {
+ assert.step(`notify_typing:${args.kwargs.is_typing}`);
+ return;
+ }
+ return this._super(...arguments);
+ },
+ });
+ const thread = this.env.models['mail.thread'].find(thread =>
+ thread.id === 20 &&
+ thread.model === 'mail.channel'
+ );
+ await this.createComposerComponent(thread.composer, { hasThreadTyping: true });
+
+ document.querySelector(`.o_ComposerTextInput_textarea`).focus();
+ document.execCommand('insertText', false, "a");
+ document.querySelector(`.o_ComposerTextInput_textarea`)
+ .dispatchEvent(new window.KeyboardEvent('keydown', { key: 'a' }));
+
+ assert.verifySteps(
+ ['notify_typing:true'],
+ "should have notified current partner is typing"
+ );
+
+ await this.env.testUtils.advanceTime(5 * 1000);
+ assert.verifySteps(
+ ['notify_typing:false'],
+ "should have notified current partner no longer is typing (inactive for 5 seconds)"
+ );
+});
+
+QUnit.test('current partner notify is typing again to other members every 50s of long continuous typing', async function (assert) {
+ assert.expect(4);
+
+ Object.assign(this.data.initMessaging, {
+ channel_slots: {
+ channel_channel: [{
+ channel_type: 'channel',
+ id: 20,
+ members: [{
+ email: 'admin@odoo.com',
+ id: 3,
+ name: 'Admin',
+ }, {
+ email: 'demo@odoo.com',
+ id: 7,
+ name: 'Demo',
+ }],
+ name: "General",
+ }],
+ },
+ });
+ await this.start({
+ env: {
+ session: {
+ name: 'Admin',
+ partner_display_name: 'Your Company, Admin',
+ partner_id: 3,
+ uid: 2,
+ },
+ },
+ hasTimeControl: true,
+ async mockRPC(route, args) {
+ if (args.method === 'notify_typing') {
+ assert.step(`notify_typing:${args.kwargs.is_typing}`);
+ return;
+ }
+ return this._super(...arguments);
+ },
+ });
+ const thread = this.env.models['mail.thread'].find(thread =>
+ thread.id === 20 &&
+ thread.model === 'mail.channel'
+ );
+ await this.createComposerComponent(thread.composer, { hasThreadTyping: true });
+
+ document.querySelector(`.o_ComposerTextInput_textarea`).focus();
+ document.execCommand('insertText', false, "a");
+ document.querySelector(`.o_ComposerTextInput_textarea`)
+ .dispatchEvent(new window.KeyboardEvent('keydown', { key: 'a' }));
+ assert.verifySteps(
+ ['notify_typing:true'],
+ "should have notified current partner is typing"
+ );
+
+ // simulate current partner typing a character every 2.5 seconds for 50 seconds straight.
+ let totalTimeElapsed = 0;
+ const elapseTickTime = 2.5 * 1000;
+ while (totalTimeElapsed < 50 * 1000) {
+ document.execCommand('insertText', false, "a");
+ document.querySelector(`.o_ComposerTextInput_textarea`)
+ .dispatchEvent(new window.KeyboardEvent('keydown', { key: 'a' }));
+ totalTimeElapsed += elapseTickTime;
+ await this.env.testUtils.advanceTime(elapseTickTime);
+ }
+
+ assert.verifySteps(
+ ['notify_typing:true'],
+ "should have notified current partner is still typing after 50s of straight typing"
+ );
+});
+
+});
+});
+});
+
+});
diff --git a/addons/mail/static/src/components/composer_text_input/composer_text_input.js b/addons/mail/static/src/components/composer_text_input/composer_text_input.js
new file mode 100644
index 00000000000..198f563d1c7
--- /dev/null
+++ b/addons/mail/static/src/components/composer_text_input/composer_text_input.js
@@ -0,0 +1,334 @@
+odoo.define('mail/static/src/components/composer_text_input/composer_text_input.js', function (require) {
+'use strict';
+
+const useStore = require('mail/static/src/component_hooks/use_store/use_store.js');
+
+const components = {
+ PartnerMentionSuggestion: require('mail/static/src/components/partner_mention_suggestion/partner_mention_suggestion.js'),
+};
+
+const { Component } = owl;
+const { useRef } = owl.hooks;
+
+/**
+ * ComposerInput relies on a minimal HTML editor in order to support mentions.
+ */
+class ComposerTextInput extends Component {
+
+ /**
+ * @override
+ */
+ constructor(...args) {
+ super(...args);
+ useStore(props => {
+ const composer = this.env.models['mail.composer'].get(props.composerLocalId);
+ return {
+ composer: composer ? composer.__state : undefined,
+ isDeviceMobile: this.env.messaging.device.isMobile,
+ };
+ });
+ /**
+ * Last content of textarea from input event. Useful to determine
+ * whether the current partner is typing something.
+ */
+ this._textareaLastInputValue = "";
+ /**
+ * Reference of the textarea. Useful to set height, selection and content.
+ */
+ this._textareaRef = useRef('textarea');
+ }
+
+ /**
+ * Updates the composer text input content when composer is mounted
+ * as textarea content can't be changed from the DOM.
+ */
+ mounted() {
+ this._update();
+ }
+
+ /**
+ * Updates the composer text input content when composer has changed
+ * as textarea content can't be changed from the DOM.
+ */
+ patched() {
+ this._update();
+ }
+
+ //--------------------------------------------------------------------------
+ // Public
+ //--------------------------------------------------------------------------
+
+ /**
+ * @returns {mail.composer}
+ */
+ get composer() {
+ return this.env.models['mail.composer'].get(this.props.composerLocalId);
+ }
+
+ focusout() {
+ this.saveStateInStore();
+ this._textareaRef.el.blur();
+ }
+
+ /**
+ * Saves the composer text input state in store
+ */
+ saveStateInStore() {
+ this.composer.update({
+ textInputContent: this._getContent(),
+ textInputCursorStart: this._getSelectionStart(),
+ textInputCursorEnd: this._getSelectionEnd(),
+ });
+ }
+
+ //--------------------------------------------------------------------------
+ // Private
+ //--------------------------------------------------------------------------
+
+ /**
+ * Returns textarea current content.
+ *
+ * @private
+ * @returns {string}
+ */
+ _getContent() {
+ return this._textareaRef.el.value;
+ }
+
+ /**
+ * Returns selection end position.
+ *
+ * @private
+ * @returns {integer}
+ */
+ _getSelectionEnd() {
+ return this._textareaRef.el.selectionEnd;
+ }
+
+ /**
+ * Returns selection start position.
+ *
+ * @private
+ * @returns {integer}
+ *
+ */
+ _getSelectionStart() {
+ return this._textareaRef.el.selectionStart;
+ }
+
+ /**
+ * Determines whether the textarea is empty or not.
+ *
+ * @private
+ * @returns {boolean}
+ */
+ _isEmpty() {
+ return this._getContent() === "";
+ }
+
+ /**
+ * Updates the content and height of a textarea
+ *
+ * @private
+ */
+ _update() {
+ this._textareaRef.el.value = this.composer.textInputContent;
+ this._textareaRef.el.setSelectionRange(
+ this.composer.textInputCursorStart,
+ this.composer.textInputCursorEnd
+ );
+ this._updateHeight();
+ if (this.composer.isDoFocus) {
+ this._textareaRef.el.focus();
+ this.composer.update({ isDoFocus: false });
+ }
+ }
+
+ /**
+ * Updates the textarea height.
+ *
+ * @private
+ */
+ _updateHeight() {
+ this._textareaRef.el.style.height = "0px";
+ this._textareaRef.el.style.height = (this._textareaRef.el.scrollHeight) + "px";
+ }
+
+ //--------------------------------------------------------------------------
+ // Handlers
+ //--------------------------------------------------------------------------
+
+ /**
+ * @private
+ */
+ _onInputTextarea() {
+ if (this._textareaLastInputValue !== this._textareaRef.el.value) {
+ this.composer.handleCurrentPartnerIsTyping();
+ }
+ this._textareaLastInputValue = this._textareaRef.el.value;
+ this._updateHeight();
+ this.saveStateInStore();
+ }
+
+ /**
+ * @private
+ * @param {KeyboardEvent} ev
+ */
+ _onKeydownTextarea(ev) {
+ switch (ev.key) {
+ // UP, DOWN, TAB: prevent moving cursor if navigation in mention suggestions
+ case 'ArrowUp':
+ case 'PageUp':
+ case 'ArrowDown':
+ case 'PageDown':
+ case 'Home':
+ case 'End':
+ case 'Tab':
+ if (this.composer.hasSuggestedPartners) {
+ // We use preventDefault here to avoid keys native actions but actions are handled in keyUp
+ ev.preventDefault();
+ }
+ break;
+ // ENTER: submit the message only if the dropdown mention proposition is not displayed
+ case 'Enter':
+ this._onKeydownTextareaEnter(ev);
+ break;
+ }
+ }
+
+ /**
+ * @private
+ * @param {KeyboardEvent} ev
+ */
+ _onKeydownTextareaEnter(ev) {
+ if (this.composer.hasSuggestedPartners) {
+ ev.preventDefault();
+ } else {
+ if (!this.props.hasSendOnEnterEnabled) {
+ return;
+ }
+ if (ev.shiftKey) {
+ return;
+ }
+ if (this.env.messaging.device.isMobile) {
+ return;
+ }
+ this.trigger('o-keydown-enter');
+ ev.preventDefault();
+ }
+ }
+
+ /**
+ * Key events management is performed in a Keyup to avoid intempestive RPC calls
+ *
+ * @private
+ * @param {KeyboardEvent} ev
+ */
+ _onKeyupTextarea(ev) {
+ switch (ev.key) {
+ // ESCAPE: close mention suggestions
+ case 'Escape':
+ this._onKeyupTextareaEscape(ev);
+ break;
+ // ENTER, HOME, END, UP, DOWN, PAGE UP, PAGE DOWN, TAB: check if navigation in mention suggestions
+ case 'Enter':
+ if (this.composer.hasSuggestedPartners) {
+ if (this.composer.activeSuggestedPartner) {
+ this.composer.insertMentionedPartner(this.composer.activeSuggestedPartner);
+ this.composer.closeMentionSuggestions();
+ this.composer.focus();
+ }
+ }
+ break;
+ case 'ArrowUp':
+ case 'PageUp':
+ if (this.composer.hasSuggestedPartners) {
+ this.composer.setPreviousSuggestedPartnerActive();
+ }
+ break;
+ case 'ArrowDown':
+ case 'PageDown':
+ if (this.composer.hasSuggestedPartners) {
+ this.composer.setNextSuggestedPartnerActive();
+ }
+ break;
+ case 'Home':
+ if (this.composer.hasSuggestedPartners) {
+ this.composer.setFirstSuggestedPartnerActive();
+ }
+ break;
+ case 'End':
+ if (this.composer.hasSuggestedPartners) {
+ this.composer.setLastSuggestedPartnerActive();
+ }
+ break;
+ case 'Tab':
+ if (this.composer.hasSuggestedPartners) {
+ if (ev.shiftKey) {
+ this.composer.setPreviousSuggestedPartnerActive();
+ } else {
+ this.composer.setNextSuggestedPartnerActive();
+ }
+ }
+ break;
+ // Otherwise, check if a mention is typed
+ default:
+ this.saveStateInStore();
+ this.composer._detectDelimiter();
+ }
+ }
+
+ /**
+ * @private
+ * @param {KeyboardEvent} ev
+ */
+ _onKeyupTextareaEscape(ev) {
+ if (this.composer.hasSuggestedPartners) {
+ this.composer.closeMentionSuggestions();
+ } else {
+ if (!this._isEmpty()) {
+ return;
+ }
+ this.composer.discard();
+ }
+ }
+
+ /**
+ * @private
+ * @param {Event} ev
+ */
+ _onPartnerMentionSuggestionClicked(ev) {
+ this.composer.insertMentionedPartner(ev.detail.partner);
+ this.composer.closeMentionSuggestions();
+ this.composer.focus();
+ }
+
+ /**
+ * @private
+ * @param {MouseEvent} ev
+ */
+ _onPartnerMentionSuggestionMouseOver(ev) {
+ this.composer.update({
+ activeSuggestedPartner: [['link', ev.detail.partner]],
+ });
+ }
+
+}
+
+Object.assign(ComposerTextInput, {
+ components,
+ defaultProps: {
+ hasMentionSuggestionsBelowPosition: false,
+ hasSendOnEnterEnabled: true,
+ },
+ props: {
+ hasMentionSuggestionsBelowPosition: Boolean,
+ hasSendOnEnterEnabled: Boolean,
+ composerLocalId: String,
+ },
+ template: 'mail.ComposerTextInput',
+});
+
+return ComposerTextInput;
+
+});
diff --git a/addons/mail/static/src/components/composer_text_input/composer_text_input.scss b/addons/mail/static/src/components/composer_text_input/composer_text_input.scss
new file mode 100644
index 00000000000..fe3ad16788d
--- /dev/null
+++ b/addons/mail/static/src/components/composer_text_input/composer_text_input.scss
@@ -0,0 +1,25 @@
+// ------------------------------------------------------------------
+// Layout
+// ------------------------------------------------------------------
+
+.o_ComposerTextInput {
+ min-width: 0;
+ position: relative;
+}
+
+.o_ComposerTextInput_mentionDropdown {
+ position: absolute;
+
+ &.o-lowPosition {
+ bottom: 0;
+ }
+}
+
+.o_ComposerTextInput_textarea {
+ padding: 10px;
+ max-height: 200px;
+ resize: none;
+ border-radius: 10px 0 0 10px;
+ border: none;
+ overflow: auto
+}
diff --git a/addons/mail/static/src/components/composer_text_input/composer_text_input.xml b/addons/mail/static/src/components/composer_text_input/composer_text_input.xml
new file mode 100644
index 00000000000..f68b295cb8d
--- /dev/null
+++ b/addons/mail/static/src/components/composer_text_input/composer_text_input.xml
@@ -0,0 +1,44 @@
+<?xml version="1.0" encoding="UTF-8"?>
+<templates xml:space="preserve">
+
+ <t t-name="mail.ComposerTextInput" owl="1">
+ <div class="o_ComposerTextInput">
+ <t t-if="composer">
+ <t t-if="composer.hasSuggestedPartners">
+ <div class="o_ComposerTextInput_mentionDropdown" t-att-class="{ 'o-lowPosition': props.hasMentionSuggestionsBelowPosition }">
+ <div
+ t-att-class="{
+ 'dropdown': props.hasMentionSuggestionsBelowPosition,
+ 'dropup': !props.hasMentionSuggestionsBelowPosition,
+ }"
+ >
+ <div class="o_ComposerTextInput_mentionDropdownPropositionList dropdown-menu show">
+ <t t-foreach="composer.mainSuggestedPartners" t-as="mainSuggestedPartner" t-key="mainSuggestedPartner.localId">
+ <PartnerMentionSuggestion
+ isActive="mainSuggestedPartner === composer.activeSuggestedPartner"
+ partnerLocalId="mainSuggestedPartner.localId"
+ t-on-o-partner-mention-suggestion-clicked="_onPartnerMentionSuggestionClicked"
+ t-on-o-partner-mention-suggestion-mouse-over="_onPartnerMentionSuggestionMouseOver"
+ />
+ </t>
+ <t t-if="composer.mainSuggestedPartners.length > 0 and composer.extraSuggestedPartners.length > 0">
+ <div role="separator" class="dropdown-divider"/>
+ </t>
+ <t t-foreach="composer.extraSuggestedPartners" t-as="extraSuggestedPartner" t-key="extraSuggestedPartner.localId">
+ <PartnerMentionSuggestion
+ isActive="extraSuggestedPartner === composer.activeSuggestedPartner"
+ partnerLocalId="extraSuggestedPartner.localId"
+ t-on-o-partner-mention-suggestion-clicked="_onPartnerMentionSuggestionClicked"
+ t-on-o-partner-mention-suggestion-mouse-over="_onPartnerMentionSuggestionMouseOver"
+ />
+ </t>
+ </div>
+ </div>
+ </div>
+ </t>
+ <textarea class="o_ComposerTextInput_textarea" t-esc="composer.textInputContent" placeholder="Write something..." t-on-keydown="_onKeydownTextarea" t-on-keyup="_onKeyupTextarea" t-on-input="_onInputTextarea" t-ref="textarea"/>
+ </t>
+ </div>
+ </t>
+
+</templates>
diff --git a/addons/mail/static/src/components/dialog/dialog.js b/addons/mail/static/src/components/dialog/dialog.js
new file mode 100644
index 00000000000..dc7ddf88e0a
--- /dev/null
+++ b/addons/mail/static/src/components/dialog/dialog.js
@@ -0,0 +1,105 @@
+odoo.define('mail/static/src/components/dialog/dialog.js', function (require) {
+'use strict';
+
+const useStore = require('mail/static/src/component_hooks/use_store/use_store.js');
+
+const { Component } = owl;
+const { useRef } = owl.hooks;
+
+class Dialog extends Component {
+
+ /**
+ * @param {...any} args
+ */
+ constructor(...args) {
+ super(...args);
+ /**
+ * Reference to the component used inside this dialog.
+ */
+ this._componentRef = useRef('component');
+ this._onClickGlobal = this._onClickGlobal.bind(this);
+ this._onKeydownDocument = this._onKeydownDocument.bind(this);
+ useStore(props => {
+ const dialog = this.env.models['mail.dialog'].get(props.dialogLocalId);
+ return {
+ dialog: dialog ? dialog.__state : undefined,
+ };
+ });
+ }
+
+ mounted() {
+ document.addEventListener('click', this._onClickGlobal, true);
+ document.addEventListener('keydown', this._onKeydownDocument);
+ }
+
+ willUnmount() {
+ document.removeEventListener('click', this._onClickGlobal, true);
+ document.removeEventListener('keydown', this._onKeydownDocument);
+ }
+
+ //--------------------------------------------------------------------------
+ // Public
+ //--------------------------------------------------------------------------
+
+ /**
+ * @returns {mail.dialog}
+ */
+ get dialog() {
+ return this.env.models['mail.dialog'].get(this.props.dialogLocalId);
+ }
+
+ //--------------------------------------------------------------------------
+ // Handlers
+ //--------------------------------------------------------------------------
+
+ /**
+ * Called when clicking on this dialog.
+ *
+ * @private
+ * @param {MouseEvent} ev
+ */
+ _onClick(ev) {
+ ev.stopPropagation();
+ }
+
+ /**
+ * Closes the dialog when clicking outside.
+ * Does not work with attachment viewer because it takes the whole space.
+ *
+ * @private
+ * @param {MouseEvent} ev
+ */
+ _onClickGlobal(ev) {
+ if (this._componentRef.el.contains(ev.target)) {
+ return;
+ }
+ // TODO: this should be child logic (will crash if child doesn't have isCloseable!!)
+ // task-2092965
+ if (this._componentRef.comp.isCloseable && !this._componentRef.comp.isCloseable()) {
+ return;
+ }
+ this.dialog.close();
+ }
+
+ /**
+ * @private
+ * @param {KeyboardEvent} ev
+ */
+ _onKeydownDocument(ev) {
+ if (ev.key === 'Escape') {
+ this.dialog.close();
+ }
+ }
+
+}
+
+Object.assign(Dialog, {
+ props: {
+ dialogLocalId: String,
+ },
+ template: 'mail.Dialog',
+});
+
+return Dialog;
+
+});
diff --git a/addons/mail/static/src/components/dialog/dialog.scss b/addons/mail/static/src/components/dialog/dialog.scss
new file mode 100644
index 00000000000..00d6e24ff48
--- /dev/null
+++ b/addons/mail/static/src/components/dialog/dialog.scss
@@ -0,0 +1,23 @@
+// ------------------------------------------------------------------
+// Layout
+// ------------------------------------------------------------------
+
+.o_Dialog {
+ position: absolute;
+ top: 0;
+ bottom: 0;
+ left: 0;
+ right: 0;
+ display: flex;
+ align-items: center;
+ justify-content: center;
+ z-index: 1;
+}
+
+// ------------------------------------------------------------------
+// Style
+// ------------------------------------------------------------------
+
+.o_Dialog {
+ background-color: rgba(0, 0, 0, 0.7);
+}
diff --git a/addons/mail/static/src/components/dialog/dialog.xml b/addons/mail/static/src/components/dialog/dialog.xml
new file mode 100644
index 00000000000..3c953decbbc
--- /dev/null
+++ b/addons/mail/static/src/components/dialog/dialog.xml
@@ -0,0 +1,22 @@
+<?xml version="1.0" encoding="UTF-8"?>
+<templates xml:space="preserve">
+
+ <t t-name="mail.Dialog" owl="1">
+ <div class="o_Dialog">
+ <t t-if="dialog">
+ <t t-if="dialog.record">
+ <t
+ t-component="{{ dialog.record['constructor'].name }}"
+ class="o_Dialog_component"
+ t-props="{ localId: dialog.record.localId }"
+ t-ref="component"
+ />
+ </t>
+ <t t-else="">
+ <span>Only dialog linked to a record is currently supported!</span>
+ </t>
+ </t>
+ </div>
+ </t>
+
+</templates>
diff --git a/addons/mail/static/src/components/dialog_manager/dialog_manager.js b/addons/mail/static/src/components/dialog_manager/dialog_manager.js
new file mode 100644
index 00000000000..810b7247fe2
--- /dev/null
+++ b/addons/mail/static/src/components/dialog_manager/dialog_manager.js
@@ -0,0 +1,58 @@
+odoo.define('mail/static/src/components/dialog_manager/dialog_manager.js', function (require) {
+'use strict';
+
+const components = {
+ Dialog: require('mail/static/src/components/dialog/dialog.js'),
+};
+const useStore = require('mail/static/src/component_hooks/use_store/use_store.js');
+
+const { Component } = owl;
+
+class DialogManager extends Component {
+
+ /**
+ * @override
+ */
+ constructor(...args) {
+ super(...args);
+ useStore(props => {
+ return {
+ dialogManager: this.env.messaging.dialogManager.__state,
+ };
+ });
+ }
+
+ mounted() {
+ this._checkDialogOpen();
+ }
+
+ patched() {
+ this._checkDialogOpen();
+ }
+
+ //--------------------------------------------------------------------------
+ // Private
+ //--------------------------------------------------------------------------
+
+ /**
+ * @private
+ */
+ _checkDialogOpen() {
+ if (this.env.messaging.dialogManager.dialogs.length > 0) {
+ document.body.classList.add('modal-open');
+ } else {
+ document.body.classList.remove('modal-open');
+ }
+ }
+
+}
+
+Object.assign(DialogManager, {
+ components,
+ props: {},
+ template: 'mail.DialogManager',
+});
+
+return DialogManager;
+
+});
diff --git a/addons/mail/static/src/components/dialog_manager/dialog_manager.xml b/addons/mail/static/src/components/dialog_manager/dialog_manager.xml
new file mode 100644
index 00000000000..994d7d9c9d7
--- /dev/null
+++ b/addons/mail/static/src/components/dialog_manager/dialog_manager.xml
@@ -0,0 +1,15 @@
+<?xml version="1.0" encoding="UTF-8"?>
+<templates xml:space="preserve">
+
+ <t t-name="mail.DialogManager" owl="1">
+ <div class="o_DialogManager">
+ <t t-foreach="env.messaging.dialogManager.allOrdered" t-as="dialog" t-key="dialog.localId">
+ <Dialog
+ class="o_DialogManager_dialog"
+ dialogLocalId="dialog.localId"
+ />
+ </t>
+ </div>
+ </t>
+
+</templates>
diff --git a/addons/mail/static/src/components/discuss/discuss.js b/addons/mail/static/src/components/discuss/discuss.js
new file mode 100644
index 00000000000..271235bccb0
--- /dev/null
+++ b/addons/mail/static/src/components/discuss/discuss.js
@@ -0,0 +1,277 @@
+odoo.define('mail/static/src/components/discuss/discuss.js', function (require) {
+'use strict';
+
+const components = {
+ AutocompleteInput: require('mail/static/src/components/autocomplete_input/autocomplete_input.js'),
+ Composer: require('mail/static/src/components/composer/composer.js'),
+ DiscussMobileMailboxSelection: require('mail/static/src/components/discuss_mobile_mailbox_selection/discuss_mobile_mailbox_selection.js'),
+ DiscussSidebar: require('mail/static/src/components/discuss_sidebar/discuss_sidebar.js'),
+ MobileMessagingNavbar: require('mail/static/src/components/mobile_messaging_navbar/mobile_messaging_navbar.js'),
+ ModerationDiscardDialog: require('mail/static/src/components/moderation_discard_dialog/moderation_discard_dialog.js'),
+ ModerationRejectDialog: require('mail/static/src/components/moderation_reject_dialog/moderation_reject_dialog.js'),
+ NotificationList: require('mail/static/src/components/notification_list/notification_list.js'),
+ ThreadViewer: require('mail/static/src/components/thread_viewer/thread_viewer.js'),
+};
+const useStore = require('mail/static/src/component_hooks/use_store/use_store.js');
+
+const { Component } = owl;
+const { useRef } = owl.hooks;
+
+class Discuss extends Component {
+
+ /**
+ * @override
+ */
+ constructor(...args) {
+ super(...args);
+ useStore(props => {
+ const discuss = this.env.messaging.discuss;
+ return {
+ checkedMessages: discuss.threadViewer.checkedMessages.map(message => message.__state),
+ discuss: discuss.__state,
+ isDeviceMobile: this.env.messaging.device.isMobile,
+ isMessagingInitialized: this.env.messaging.isInitialized,
+ thread: discuss.thread ? discuss.thread.__state : undefined,
+ threadCache: discuss.threadViewer.threadCache
+ ? discuss.threadViewer.threadCache.__state
+ : undefined,
+ uncheckedMessages: discuss.threadViewer.uncheckedMessages.map(message => message.__state),
+ };
+ }, {
+ compareDepth: {
+ checkedMessages: 1,
+ uncheckedMessages: 1,
+ },
+ });
+ this._updateLocalStoreProps();
+ /**
+ * Reference of the thread. Useful to update scroll position correctly
+ * on patch. AKU TODO: this made sense when composer was outside of
+ * thread, but this may no longer be necessary??
+ */
+ this._threadRef = useRef('thread');
+
+ // bind since passed as props
+ this._onMobileAddItemHeaderInputSelect = this._onMobileAddItemHeaderInputSelect.bind(this);
+ this._onMobileAddItemHeaderInputSource = this._onMobileAddItemHeaderInputSource.bind(this);
+ }
+
+ mounted() {
+ this.discuss.update({ isOpen: true });
+ if (this.discuss.thread) {
+ this.trigger('o-push-state-action-manager');
+ } else if (this.env.messaging.isInitialized) {
+ this.discuss.openInitThread();
+ }
+ this._updateLocalStoreProps();
+ }
+
+ patched() {
+ this.trigger('o-update-control-panel');
+ if (this.discuss.thread) {
+ this.trigger('o-push-state-action-manager');
+ }
+ if (
+ this.discuss.thread &&
+ this.discuss.thread === this.env.messaging.inbox &&
+ this._lastThreadCache === this.discuss.threadViewer.threadCache.localId &&
+ this._lastThreadCounter > 0 && this.discuss.thread.counter === 0
+ ) {
+ this.trigger('o-show-rainbow-man');
+ }
+ this._activeThreadCache = this.discuss.threadViewer.threadCache;
+ this._updateLocalStoreProps();
+ }
+
+ willUnmount() {
+ if (this.discuss) {
+ this.discuss.close();
+ }
+ }
+
+ //--------------------------------------------------------------------------
+ // Public
+ //--------------------------------------------------------------------------
+
+ /**
+ * @returns {string}
+ */
+ get addChannelInputPlaceholder() {
+ return this.env._t("Create or search channel...");
+ }
+
+ /**
+ * @returns {string}
+ */
+ get addChatInputPlaceholder() {
+ return this.env._t("Search user...");
+ }
+
+ /**
+ * @returns {mail.discuss}
+ */
+ get discuss() {
+ return this.env.messaging && this.env.messaging.discuss;
+ }
+
+ /**
+ * @returns {Object[]}
+ */
+ mobileNavbarTabs() {
+ return [{
+ icon: 'fa fa-inbox',
+ id: 'mailbox',
+ label: this.env._t("Mailboxes"),
+ }, {
+ icon: 'fa fa-user',
+ id: 'chat',
+ label: this.env._t("Chat"),
+ }, {
+ icon: 'fa fa-users',
+ id: 'channel',
+ label: this.env._t("Channel"),
+ }];
+ }
+
+ //--------------------------------------------------------------------------
+ // Private
+ //--------------------------------------------------------------------------
+
+ /**
+ * @private
+ */
+ _updateLocalStoreProps() {
+ /**
+ * Locally tracked store props `activeThreadCache`.
+ * Useful to set scroll position from last stored one and to display
+ * rainbox man on inbox.
+ */
+ this._lastThreadCache = (
+ this.discuss.threadViewer.threadCache &&
+ this.discuss.threadViewer.threadCache.localId
+ );
+ /**
+ * Locally tracked store props `threadCounter`.
+ * Useful to display the rainbow man on inbox.
+ */
+ this._lastThreadCounter = (
+ this.discuss.thread &&
+ this.discuss.thread.counter
+ );
+ }
+
+ //--------------------------------------------------------------------------
+ // Handlers
+ //--------------------------------------------------------------------------
+
+ /**
+ * @private
+ */
+ _onDialogClosedModerationDiscard() {
+ this.discuss.update({ hasModerationDiscardDialog: false });
+ }
+
+ /**
+ * @private
+ */
+ _onDialogClosedModerationReject() {
+ this.discuss.update({ hasModerationRejectDialog: false });
+ }
+
+ /**
+ * @private
+ * @param {CustomEvent} ev
+ */
+ _onHideMobileAddItemHeader(ev) {
+ ev.stopPropagation();
+ this.discuss.clearIsAddingItem();
+ }
+
+ /**
+ * @private
+ * @param {Event} ev
+ * @param {Object} ui
+ * @param {Object} ui.item
+ * @param {integer} ui.item.id
+ */
+ _onMobileAddItemHeaderInputSelect(ev, ui) {
+ const discuss = this.discuss;
+ if (discuss.isAddingChannel) {
+ discuss.handleAddChannelAutocompleteSelect(ev, ui);
+ } else {
+ discuss.handleAddChatAutocompleteSelect(ev, ui);
+ }
+ }
+
+ /**
+ * @private
+ * @param {Object} req
+ * @param {string} req.term
+ * @param {function} res
+ */
+ _onMobileAddItemHeaderInputSource(req, res) {
+ if (this.discuss.isAddingChannel) {
+ this.discuss.handleAddChannelAutocompleteSource(req, res);
+ } else {
+ this.discuss.handleAddChatAutocompleteSource(req, res);
+ }
+ }
+
+ /**
+ * @private
+ */
+ _onReplyingToMessageMessagePosted() {
+ this.env.services['notification'].notify({
+ message: _.str.sprintf(
+ this.env._t(`Message posted on "%s"`),
+ owl.utils.escape(this.discuss.replyingToMessage.originThread.displayName)
+ ),
+ type: 'warning',
+ });
+ this.discuss.clearReplyingToMessage();
+ }
+
+ /**
+ * @private
+ * @param {CustomEvent} ev
+ * @param {Object} ev.detail
+ * @param {string} ev.detail.tabId
+ */
+ _onSelectMobileNavbarTab(ev) {
+ ev.stopPropagation();
+ if (this.discuss.activeMobileNavbarTabId === ev.detail.tabId) {
+ return;
+ }
+ this.discuss.clearReplyingToMessage();
+ this.discuss.update({ activeMobileNavbarTabId: ev.detail.tabId });
+ }
+
+ /**
+ * @private
+ * @param {CustomEvent} ev
+ * @param {Object} ev.detail
+ * @param {mail.thread} ev.detail.thread
+ */
+ _onSelectThread(ev) {
+ this.discuss.threadViewer.update({ thread: [['link', ev.detail.thread]] });
+ }
+
+ /**
+ * @private
+ * @param {CustomEvent} ev
+ */
+ _onThreadRendered(ev) {
+ this.trigger('o-update-control-panel');
+ }
+
+}
+
+Object.assign(Discuss, {
+ components,
+ props: {},
+ template: 'mail.Discuss',
+});
+
+return Discuss;
+
+});
diff --git a/addons/mail/static/src/components/discuss/discuss.scss b/addons/mail/static/src/components/discuss/discuss.scss
new file mode 100644
index 00000000000..5cddf1017ba
--- /dev/null
+++ b/addons/mail/static/src/components/discuss/discuss.scss
@@ -0,0 +1,114 @@
+// ------------------------------------------------------------------
+// Layout
+// ------------------------------------------------------------------
+
+.o_action_manager {
+ // bug with scrollable inside discuss mobile without this...
+ min-height: 0;
+}
+
+.o-autogrow {
+ flex: 1 1 auto;
+}
+
+.o_Discuss {
+ display: flex;
+ height: 100%;
+ min-height: 0;
+
+ &.o-mobile {
+ flex-flow: column;
+ align-items: center;
+ }
+}
+
+.o_Discuss_chatWindowHeader {
+ width: 100%;
+ flex: 0 0 auto;
+}
+
+.o_Discuss_content {
+ height: 100%;
+ overflow: auto;
+ flex: 1 1 auto;
+ display: flex;
+ flex-flow: column;
+}
+
+.o_Discuss_messagingNotInitialized {
+ flex: 1 1 auto;
+ display: flex;
+ align-items: center;
+ justify-content: center;
+}
+
+.o_Discuss_messagingNotInitializedIcon {
+ margin-right: 3px;
+}
+
+.o_Discuss_mobileAddItemHeader {
+ display: flex;
+ justify-content: center;
+ width: 100%;
+ padding: 0 10px;
+}
+
+.o_Discuss_mobileAddItemHeaderInput {
+ flex: 1 1 auto;
+ margin-bottom: 8px;
+ padding: 8px;
+}
+
+.o_Discuss_mobileMailboxSelection {
+ width: 100%;
+}
+
+.o_Discuss_mobileNavbar {
+ width: 100%;
+}
+
+.o_Discuss_noThread {
+ display: flex;
+ flex: 1 1 auto;
+ width: 100%;
+ align-items: center;
+ justify-content: center;
+}
+
+.o_Discuss_replyingToMessageComposer {
+ width: 100%;
+}
+
+.o_Discuss_sidebar {
+ height: 100%;
+ overflow: auto;
+ padding-top: 10px;
+ flex: 0 0 auto;
+}
+
+.o_Discuss_thread {
+ flex: 1 1 auto;
+
+ &.o-mobile {
+ width: 100%;
+ }
+}
+
+.o_Discuss_notificationList {
+ width: 100%;
+ flex: 1 1 auto;
+}
+// ------------------------------------------------------------------
+// Style
+// ------------------------------------------------------------------
+
+.o_Discuss.o-mobile {
+ background-color: white;
+}
+
+.o_Discuss_mobileAddItemHeaderInput {
+ appearance: none;
+ border: 1px solid gray('400');
+ border-radius: 5px;
+ outline: none;
+}
diff --git a/addons/mail/static/src/components/discuss/discuss.xml b/addons/mail/static/src/components/discuss/discuss.xml
new file mode 100644
index 00000000000..789c7a465c8
--- /dev/null
+++ b/addons/mail/static/src/components/discuss/discuss.xml
@@ -0,0 +1,106 @@
+<?xml version="1.0" encoding="UTF-8"?>
+<templates xml:space="preserve">
+
+ <t t-name="mail.Discuss" owl="1">
+ <div class="o_Discuss"
+ t-att-class="{
+ 'o-adding-item': discuss.isAddingChannel or discuss.isAddingChat,
+ 'o-mobile': env.messaging.device.isMobile,
+ }"
+ >
+ <t t-if="!env.messaging.isInitialized">
+ <div class="o_Discuss_messagingNotInitialized"><i class="o_Discuss_messagingNotInitializedIcon fa fa-spinner fa-spin"/>Please wait...</div>
+ </t>
+ <t t-else="">
+ <t t-if="!env.messaging.device.isMobile">
+ <DiscussSidebar class="o_Discuss_sidebar"/>
+ </t>
+ <t t-if="env.messaging.device.isMobile" t-call="mail.Discuss.content"/>
+ <t t-else="">
+ <div class="o_Discuss_content">
+ <t t-call="mail.Discuss.content"/>
+ </div>
+ </t>
+ </t>
+
+ <t t-if="discuss.hasModerationDiscardDialog">
+ <ModerationDiscardDialog messageLocalIds="discuss.threadViewer.checkedMessages.map(message => message.localId)" t-on-dialog-closed="_onDialogClosedModerationDiscard"/>
+ </t>
+ <t t-if="discuss.hasModerationRejectDialog">
+ <ModerationRejectDialog messageLocalIds="discuss.threadViewer.checkedMessages.map(message => message.localId)" t-on-dialog-closed="_onDialogClosedModerationReject"/>
+ </t>
+ </div>
+ </t>
+
+ <t t-name="mail.Discuss.content" owl="1">
+ <t t-if="env.messaging.device.isMobile and discuss.activeMobileNavbarTabId === 'mailbox'">
+ <DiscussMobileMailboxSelection class="o_Discuss_mobileMailboxSelection"/>
+ </t>
+ <t t-if="env.messaging.device.isMobile and (discuss.isAddingChannel or discuss.isAddingChat)">
+ <div class="o_Discuss_mobileAddItemHeader">
+ <AutocompleteInput
+ class="o_Discuss_mobileAddItemHeaderInput"
+ isFocusOnMount="true"
+ isHtml="discuss.isAddingChannel"
+ placeholder="discuss.isAddingChannel ? addChannelInputPlaceholder : addChatInputPlaceholder"
+ select="_onMobileAddItemHeaderInputSelect"
+ source="_onMobileAddItemHeaderInputSource"
+ t-on-o-hide="_onHideMobileAddItemHeader"
+ />
+ </div>
+ </t>
+ <t t-if="(env.messaging.device.isMobile and discuss.activeMobileNavbarTabId === 'mailbox' and discuss.thread and discuss.thread.model === 'mail.box') or (!env.messaging.device.isMobile and discuss.thread)">
+ <ThreadViewer
+ class="o_Discuss_thread"
+ t-att-class="{ 'o-mobile': env.messaging.device.isMobile }"
+ composerAttachmentsDetailsMode="'card'"
+ hasComposer="discuss.thread.model !== 'mail.box'"
+ hasComposerCurrentPartnerAvatar="!env.messaging.device.isMobile"
+ hasComposerThreadTyping="true"
+ hasMessageCheckbox="true"
+ hasSquashCloseMessages="discuss.thread.model !== 'mail.box'"
+ haveMessagesAuthorRedirect="discuss.thread.channel_type !== 'chat'"
+ haveMessagesMarkAsReadIcon="discuss.thread === env.messaging.inbox"
+ haveMessagesReplyIcon="discuss.thread === env.messaging.inbox"
+ selectedMessageLocalId="
+ discuss.replyingToMessage and
+ discuss.replyingToMessage.localId"
+ threadViewerLocalId="discuss.threadViewer.localId"
+ t-on-o-rendered="_onThreadRendered"
+ />
+ </t>
+ <t t-if="!discuss.thread and (!env.messaging.device.isMobile or discuss.activeMobileNavbarTabId === 'mailbox')">
+ <div class="o_Discuss_noThread">
+ No conversation selected.
+ </div>
+ </t>
+ <t t-if="env.messaging.device.isMobile and discuss.activeMobileNavbarTabId !== 'mailbox'">
+ <NotificationList
+ class="o_Discuss_notificationList"
+ filter="discuss.activeMobileNavbarTabId"
+ t-on-o-select-thread="_onSelectThread"
+ />
+ </t>
+ <t t-if="env.messaging.device.isMobile and !discuss.isReplyingToMessage">
+ <MobileMessagingNavbar
+ class="o_Discuss_mobileNavbar"
+ activeTabId="discuss.activeMobileNavbarTabId"
+ tabs="mobileNavbarTabs()"
+ t-on-o-select-mobile-messaging-navbar-tab="_onSelectMobileNavbarTab"
+ />
+ </t>
+ <t t-if="discuss.isReplyingToMessage">
+ <Composer
+ class="o_Discuss_replyingToMessageComposer"
+ composerLocalId="discuss.replyingToMessage.originThread.composer.localId"
+ hasCurrentPartnerAvatar="!env.messaging.device.isMobile"
+ hasDiscardButton="true"
+ hasThreadName="true"
+ isDiscardOnClickAway="true"
+ isFocusOnMount="true"
+ t-on-o-message-posted="_onReplyingToMessageMessagePosted"
+ />
+ </t>
+ </t>
+
+</templates>
diff --git a/addons/mail/static/src/components/discuss/discuss_tests.js b/addons/mail/static/src/components/discuss/discuss_tests.js
new file mode 100644
index 00000000000..7a37349041f
--- /dev/null
+++ b/addons/mail/static/src/components/discuss/discuss_tests.js
@@ -0,0 +1,5523 @@
+odoo.define('mail/static/src/components/discuss/discuss_tests.js', function (require) {
+'use strict';
+
+const BusService = require('bus.BusService');
+
+const {
+ afterEach: utilsAfterEach,
+ afterNextRender,
+ beforeEach: utilsBeforeEach,
+ inputFiles,
+ start: utilsStart,
+} = require('mail/static/src/utils/test_utils.js');
+
+const Bus = require('web.Bus');
+const { makeTestPromise, file: { createFile } } = require('web.test_utils');
+
+QUnit.module('mail', {}, function () {
+QUnit.module('components', {}, function () {
+QUnit.module('discuss', {}, function () {
+QUnit.module('discuss_tests.js', {
+ beforeEach() {
+ utilsBeforeEach(this);
+
+ this.start = async params => {
+ if (this.widget) {
+ this.widget.destroy();
+ }
+ let { env, widget } = await utilsStart(Object.assign({}, params, {
+ autoOpenDiscuss: true,
+ data: this.data,
+ hasDiscuss: true,
+ }));
+ this.env = env;
+ this.widget = widget;
+ };
+ },
+ afterEach() {
+ utilsAfterEach(this);
+ if (this.widget) {
+ this.widget.destroy();
+ }
+ },
+});
+
+QUnit.test('messaging not initialized', async function (assert) {
+ assert.expect(1);
+
+ await this.start({
+ async mockRPC(route) {
+ if (route === '/mail/init_messaging') {
+ // simulate messaging never initialized
+ return new Promise(resolve => {});
+ }
+ return this._super(...arguments);
+ },
+ waitUntilMessagingInitialized: false,
+ });
+ assert.strictEqual(
+ document.querySelectorAll('.o_Discuss_messagingNotInitialized').length,
+ 1,
+ "should display messaging not initialized"
+ );
+});
+
+QUnit.test('messaging becomes initialized', async function (assert) {
+ assert.expect(2);
+
+ const messagingInitializedProm = makeTestPromise();
+
+ await this.start({
+ async mockRPC(route) {
+ const _super = this._super.bind(this, ...arguments); // limitation of class.js
+ if (route === '/mail/init_messaging') {
+ await messagingInitializedProm;
+ }
+ return _super();
+ },
+ waitUntilMessagingInitialized: false,
+ });
+ assert.strictEqual(
+ document.querySelectorAll('.o_Discuss_messagingNotInitialized').length,
+ 1,
+ "should display messaging not initialized"
+ );
+
+ await afterNextRender(() => messagingInitializedProm.resolve());
+ assert.strictEqual(
+ document.querySelectorAll('.o_Discuss_messagingNotInitialized').length,
+ 0,
+ "should no longer display messaging not initialized"
+ );
+});
+
+QUnit.test('basic rendering', async function (assert) {
+ assert.expect(4);
+
+ await this.start();
+ assert.strictEqual(
+ document.querySelectorAll('.o_Discuss_sidebar').length,
+ 1,
+ "should have a sidebar section"
+ );
+ assert.strictEqual(
+ document.querySelectorAll('.o_Discuss_content').length,
+ 1,
+ "should have content section"
+ );
+ assert.strictEqual(
+ document.querySelectorAll('.o_Discuss_thread').length,
+ 1,
+ "should have thread section inside content"
+ );
+ assert.ok(
+ document.querySelector('.o_Discuss_thread').classList.contains('o_ThreadViewer'),
+ "thread section should use ThreadViewer component"
+ );
+});
+
+QUnit.test('basic rendering: sidebar', async function (assert) {
+ assert.expect(20);
+
+ await this.start();
+ assert.strictEqual(
+ document.querySelectorAll(`.o_DiscussSidebar_group`).length,
+ 3,
+ "should have 3 groups in sidebar"
+ );
+ assert.strictEqual(
+ document.querySelectorAll(`.o_DiscussSidebar_groupMailbox`).length,
+ 1,
+ "should have group 'Mailbox' in sidebar"
+ );
+ assert.strictEqual(
+ document.querySelectorAll(`
+ .o_DiscussSidebar_groupMailbox .o_DiscussSidebar_groupHeader
+ `).length,
+ 0,
+ "mailbox category should not have any header"
+ );
+ assert.strictEqual(
+ document.querySelectorAll(`
+ .o_DiscussSidebar_groupMailbox .o_DiscussSidebar_item
+ `).length,
+ 3,
+ "should have 3 mailbox items"
+ );
+ assert.strictEqual(
+ document.querySelectorAll(`
+ .o_DiscussSidebar_groupMailbox
+ .o_DiscussSidebar_item[data-thread-local-id="${
+ this.env.messaging.inbox.localId
+ }"]
+ `).length,
+ 1,
+ "should have inbox mailbox item"
+ );
+ assert.strictEqual(
+ document.querySelectorAll(`
+ .o_DiscussSidebar_groupMailbox
+ .o_DiscussSidebar_item[data-thread-local-id="${
+ this.env.messaging.starred.localId
+ }"]
+ `).length,
+ 1,
+ "should have starred mailbox item"
+ );
+ assert.strictEqual(
+ document.querySelectorAll(`
+ .o_DiscussSidebar_groupMailbox
+ .o_DiscussSidebar_item[data-thread-local-id="${
+ this.env.messaging.history.localId
+ }"]
+ `).length,
+ 1,
+ "should have history mailbox item"
+ );
+ assert.strictEqual(
+ document.querySelectorAll(`.o_Discuss_sidebar .o_DiscussSidebar_separator`).length,
+ 1,
+ "should have separator (between mailboxes and channels, but that's not tested)"
+ );
+ assert.strictEqual(
+ document.querySelectorAll(`.o_DiscussSidebar_groupChannel`).length,
+ 1,
+ "should have group 'Channel' in sidebar"
+ );
+ assert.strictEqual(
+ document.querySelectorAll(`
+ .o_DiscussSidebar_groupChannel .o_DiscussSidebar_groupHeader
+ `).length,
+ 1,
+ "channel category should have a header"
+ );
+ assert.strictEqual(
+ document.querySelectorAll(`
+ .o_DiscussSidebar_groupChannel .o_DiscussSidebar_groupTitle
+ `).length,
+ 1,
+ "should have title in channel header"
+ );
+ assert.strictEqual(
+ document.querySelector(`
+ .o_DiscussSidebar_groupChannel .o_DiscussSidebar_groupTitle
+ `).textContent.trim(),
+ "Channels"
+ );
+ assert.strictEqual(
+ document.querySelectorAll(`.o_DiscussSidebar_groupChannel .o_DiscussSidebar_list`).length,
+ 1,
+ "channel category should list items"
+ );
+ assert.strictEqual(
+ document.querySelectorAll(`.o_DiscussSidebar_groupChannel .o_DiscussSidebar_item`).length,
+ 0,
+ "channel category should have no item by default"
+ );
+ assert.strictEqual(
+ document.querySelectorAll(`.o_DiscussSidebar_groupChat`).length,
+ 1,
+ "should have group 'Chat' in sidebar"
+ );
+ assert.strictEqual(
+ document.querySelectorAll(`.o_DiscussSidebar_groupChat .o_DiscussSidebar_groupHeader`).length,
+ 1,
+ "channel category should have a header"
+ );
+ assert.strictEqual(
+ document.querySelectorAll(`.o_DiscussSidebar_groupChat .o_DiscussSidebar_groupTitle`).length,
+ 1,
+ "should have title in chat header"
+ );
+ assert.strictEqual(
+ document.querySelector(`
+ .o_DiscussSidebar_groupChat .o_DiscussSidebar_groupTitle
+ `).textContent.trim(),
+ "Direct Messages"
+ );
+ assert.strictEqual(
+ document.querySelectorAll(`.o_DiscussSidebar_groupChat .o_DiscussSidebar_list`).length,
+ 1,
+ "chat category should list items"
+ );
+ assert.strictEqual(
+ document.querySelectorAll(`.o_DiscussSidebar_groupChat .o_DiscussSidebar_item`).length,
+ 0,
+ "chat category should have no item by default"
+ );
+});
+
+QUnit.test('sidebar: basic mailbox rendering', async function (assert) {
+ assert.expect(6);
+
+ await this.start();
+ const inbox = document.querySelector(`
+ .o_DiscussSidebar_groupMailbox
+ .o_DiscussSidebar_item[data-thread-local-id="${
+ this.env.messaging.inbox.localId
+ }"]
+ `);
+ assert.strictEqual(
+ inbox.querySelectorAll(`:scope .o_DiscussSidebarItem_activeIndicator`).length,
+ 1,
+ "mailbox should have active indicator"
+ );
+ assert.strictEqual(
+ inbox.querySelectorAll(`:scope .o_ThreadIcon`).length,
+ 1,
+ "mailbox should have an icon"
+ );
+ assert.strictEqual(
+ inbox.querySelectorAll(`:scope .o_ThreadIcon_mailboxInbox`).length,
+ 1,
+ "inbox should have 'inbox' icon"
+ );
+ assert.strictEqual(
+ inbox.querySelectorAll(`:scope .o_DiscussSidebarItem_name`).length,
+ 1,
+ "mailbox should have a name"
+ );
+ assert.strictEqual(
+ inbox.querySelector(`:scope .o_DiscussSidebarItem_name`).textContent,
+ "Inbox",
+ "inbox should have name 'Inbox'"
+ );
+ assert.strictEqual(
+ document.querySelectorAll(`
+ .o_DiscussSidebar_item[data-thread-local-id="${
+ this.env.messaging.inbox.localId
+ }"]
+ .o_DiscussSidebarItem_counter
+ `).length,
+ 0,
+ "should have no counter when equal to 0 (default value)"
+ );
+});
+
+QUnit.test('sidebar: default active inbox', async function (assert) {
+ assert.expect(1);
+
+ await this.start();
+ const inbox = document.querySelector(`
+ .o_DiscussSidebar_groupMailbox
+ .o_DiscussSidebar_item[data-thread-local-id="${
+ this.env.messaging.inbox.localId
+ }"]
+ `);
+ assert.ok(
+ inbox.querySelector(`
+ :scope .o_DiscussSidebarItem_activeIndicator
+ `).classList.contains('o-item-active'),
+ "inbox should be active by default"
+ );
+});
+
+QUnit.test('sidebar: change item', async function (assert) {
+ assert.expect(4);
+
+ await this.start();
+ assert.ok(
+ document.querySelector(`
+ .o_DiscussSidebar_item[data-thread-local-id="${
+ this.env.messaging.inbox.localId
+ }"]
+ .o_DiscussSidebarItem_activeIndicator
+ `).classList.contains('o-item-active'),
+ "inbox should be active by default"
+ );
+ assert.notOk(
+ document.querySelector(`
+ .o_DiscussSidebar_item[data-thread-local-id="${
+ this.env.messaging.starred.localId
+ }"]
+ .o_DiscussSidebarItem_activeIndicator
+ `).classList.contains('o-item-active'),
+ "starred should be inactive by default"
+ );
+
+ await afterNextRender(() =>
+ document.querySelector(`
+ .o_DiscussSidebar_item[data-thread-local-id="${
+ this.env.messaging.starred.localId
+ }"]
+ `).click()
+ );
+ assert.notOk(
+ document.querySelector(`
+ .o_DiscussSidebar_item[data-thread-local-id="${
+ this.env.messaging.inbox.localId
+ }"]
+ .o_DiscussSidebarItem_activeIndicator
+ `).classList.contains('o-item-active'),
+ "inbox mailbox should become inactive"
+ );
+ assert.ok(
+ document.querySelector(`
+ .o_DiscussSidebar_item[data-thread-local-id="${
+ this.env.messaging.starred.localId
+ }"]
+ .o_DiscussSidebarItem_activeIndicator
+ `).classList.contains('o-item-active'),
+ "starred mailbox should become active");
+});
+
+QUnit.test('sidebar: inbox with counter', async function (assert) {
+ assert.expect(2);
+
+ Object.assign(this.data.initMessaging, {
+ needaction_inbox_counter: 100,
+ });
+ await this.start();
+ assert.strictEqual(
+ document.querySelectorAll(`
+ .o_DiscussSidebar_item[data-thread-local-id="${
+ this.env.messaging.inbox.localId
+ }"]
+ .o_DiscussSidebarItem_counter
+ `).length,
+ 1,
+ "should display a counter (= have a counter when different from 0)"
+ );
+ assert.strictEqual(
+ document.querySelector(`
+ .o_DiscussSidebar_item[data-thread-local-id="${
+ this.env.messaging.inbox.localId
+ }"]
+ .o_DiscussSidebarItem_counter
+ `).textContent,
+ "100",
+ "should have counter value"
+ );
+});
+
+QUnit.test('sidebar: add channel', async function (assert) {
+ assert.expect(3);
+
+ await this.start();
+ assert.strictEqual(
+ document.querySelectorAll(`
+ .o_DiscussSidebar_groupChannel
+ .o_DiscussSidebar_groupHeaderItemAdd
+ `).length,
+ 1,
+ "should be able to add channel from header"
+ );
+ assert.strictEqual(
+ document.querySelector(`
+ .o_DiscussSidebar_groupChannel
+ .o_DiscussSidebar_groupHeaderItemAdd
+ `).title,
+ "Add or join a channel");
+
+ await afterNextRender(() =>
+ document.querySelector(`
+ .o_DiscussSidebar_groupChannel .o_DiscussSidebar_groupHeaderItemAdd
+ `).click()
+ );
+ assert.strictEqual(
+ document.querySelectorAll(`.o_DiscussSidebar_groupChannel .o_DiscussSidebar_itemNew`).length,
+ 1,
+ "should have item to add a new channel"
+ );
+});
+
+QUnit.test('sidebar: basic channel rendering', async function (assert) {
+ assert.expect(14);
+
+ Object.assign(this.data.initMessaging, {
+ channel_slots: {
+ channel_channel: [{
+ channel_type: "channel",
+ id: 20,
+ name: "General",
+ }],
+ },
+ });
+ await this.start();
+ assert.strictEqual(
+ document.querySelectorAll(`.o_DiscussSidebar_groupChannel .o_DiscussSidebar_item`).length,
+ 1,
+ "should have one channel item");
+ let channel = document.querySelector(`
+ .o_DiscussSidebar_groupChannel
+ .o_DiscussSidebar_item
+ `);
+ assert.strictEqual(
+ channel.dataset.threadLocalId,
+ this.env.models['mail.thread'].find(thread =>
+ thread.id === 20 &&
+ thread.model === 'mail.channel'
+ ).localId,
+ "should have channel with Id 20"
+ );
+ assert.strictEqual(
+ channel.querySelectorAll(`:scope .o_DiscussSidebarItem_activeIndicator`).length,
+ 1,
+ "should have active indicator"
+ );
+ assert.strictEqual(
+ channel.querySelectorAll(`:scope .o_DiscussSidebarItem_activeIndicator.o-item-active`).length,
+ 0,
+ "should not be active by default"
+ );
+ assert.strictEqual(
+ channel.querySelectorAll(`:scope .o_ThreadIcon`).length,
+ 1,
+ "should have an icon"
+ );
+ assert.strictEqual(
+ channel.querySelectorAll(`:scope .o_DiscussSidebarItem_name`).length,
+ 1,
+ "should have a name"
+ );
+ assert.strictEqual(
+ channel.querySelector(`:scope .o_DiscussSidebarItem_name`).textContent,
+ "General",
+ "should have name value"
+ );
+ assert.strictEqual(
+ channel.querySelectorAll(`:scope .o_DiscussSidebarItem_commands`).length,
+ 1,
+ "should have commands"
+ );
+ assert.strictEqual(
+ channel.querySelectorAll(`:scope .o_DiscussSidebarItem_command`).length,
+ 2,
+ "should have 2 commands"
+ );
+ assert.strictEqual(
+ channel.querySelectorAll(`:scope .o_DiscussSidebarItem_commandSettings`).length,
+ 1,
+ "should have 'settings' command"
+ );
+ assert.strictEqual(
+ channel.querySelectorAll(`:scope .o_DiscussSidebarItem_commandLeave`).length,
+ 1,
+ "should have 'leave' command"
+ );
+ assert.strictEqual(
+ channel.querySelectorAll(`:scope .o_DiscussSidebarItem_counter`).length,
+ 0,
+ "should have a counter when equals 0 (default value)"
+ );
+
+ await afterNextRender(() =>
+ document.querySelector(`.o_DiscussSidebar_groupChannel .o_DiscussSidebar_item`).click()
+ );
+ channel = document.querySelector(`.o_DiscussSidebar_groupChannel .o_DiscussSidebar_item`);
+ assert.strictEqual(
+ channel.querySelectorAll(`:scope .o_DiscussSidebarItem_activeIndicator.o-item-active`).length,
+ 1,
+ "channel should become active"
+ );
+ assert.strictEqual(
+ document.querySelectorAll(`.o_Discuss_thread .o_ThreadViewer_composer`).length,
+ 1,
+ "should have composer section inside thread content (can post message in channel)"
+ );
+});
+
+QUnit.test('sidebar: channel rendering with needaction counter', async function (assert) {
+ assert.expect(5);
+
+ Object.assign(this.data.initMessaging, {
+ channel_slots: {
+ channel_channel: [{
+ channel_type: "channel",
+ id: 20,
+ name: "General",
+ message_needaction_counter: 10,
+ }],
+ },
+ });
+ await this.start();
+ const channel = document.querySelector(`.o_DiscussSidebar_groupChannel .o_DiscussSidebar_item`);
+ assert.strictEqual(
+ channel.querySelectorAll(`:scope .o_DiscussSidebarItem_counter`).length,
+ 1,
+ "should have a counter when different from 0"
+ );
+ assert.strictEqual(
+ channel.querySelector(`:scope .o_DiscussSidebarItem_counter`).textContent,
+ "10",
+ "should have counter value"
+ );
+ assert.strictEqual(
+ channel.querySelectorAll(`:scope .o_DiscussSidebarItem_command`).length,
+ 1,
+ "should have single command"
+ );
+ assert.strictEqual(
+ channel.querySelectorAll(`:scope .o_DiscussSidebarItem_commandSettings`).length,
+ 1,
+ "should have 'settings' command"
+ );
+ assert.strictEqual(
+ channel.querySelectorAll(`:scope .o_DiscussSidebarItem_commandLeave`).length,
+ 0,
+ "should not have 'leave' command"
+ );
+});
+
+QUnit.test('sidebar: public/private channel rendering', async function (assert) {
+ assert.expect(5);
+
+ Object.assign(this.data.initMessaging, {
+ channel_slots: {
+ channel_channel: [{
+ channel_type: "channel",
+ id: 100,
+ name: "channel1",
+ public: 'public',
+ }],
+ channel_private_group: [{
+ channel_type: "channel",
+ id: 101,
+ name: "channel2",
+ public: 'private',
+ }],
+ },
+ });
+ await this.start();
+ assert.strictEqual(
+ document.querySelectorAll(`.o_DiscussSidebar_groupChannel .o_DiscussSidebar_item`).length,
+ 2,
+ "should have 2 channel items"
+ );
+ assert.strictEqual(
+ document.querySelectorAll(`
+ .o_DiscussSidebar_groupChannel
+ .o_DiscussSidebar_item[data-thread-local-id="${
+ this.env.models['mail.thread'].find(thread =>
+ thread.id === 100 &&
+ thread.model === 'mail.channel'
+ ).localId
+ }"]
+ `).length,
+ 1,
+ "should have channel1 (Id 100)"
+ );
+ assert.strictEqual(
+ document.querySelectorAll(`
+ .o_DiscussSidebar_groupChannel
+ .o_DiscussSidebar_item[data-thread-local-id="${
+ this.env.models['mail.thread'].find(thread =>
+ thread.id === 101 &&
+ thread.model === 'mail.channel'
+ ).localId
+ }"]
+ `).length,
+ 1,
+ "should have channel2 (Id 101)"
+ );
+ const channel1 = document.querySelector(`
+ .o_DiscussSidebar_groupChannel
+ .o_DiscussSidebar_item[data-thread-local-id="${
+ this.env.models['mail.thread'].find(thread =>
+ thread.id === 100 &&
+ thread.model === 'mail.channel'
+ ).localId
+ }"]
+ `);
+ const channel2 = document.querySelector(`
+ .o_DiscussSidebar_groupChannel
+ .o_DiscussSidebar_item[data-thread-local-id="${
+ this.env.models['mail.thread'].find(thread =>
+ thread.id === 101 &&
+ thread.model === 'mail.channel'
+ ).localId
+ }"]
+ `);
+ assert.strictEqual(
+ channel1.querySelectorAll(`:scope .o_ThreadIcon_channelPublic`).length,
+ 1,
+ "channel1 (public) has hashtag icon"
+ );
+ assert.strictEqual(
+ channel2.querySelectorAll(`:scope .o_ThreadIcon_channelPrivate`).length,
+ 1,
+ "channel2 (private) has lock icon"
+ );
+});
+
+QUnit.test('sidebar: basic chat rendering', async function (assert) {
+ assert.expect(11);
+
+ Object.assign(this.data.initMessaging, {
+ channel_slots: {
+ channel_direct_message: [{
+ channel_type: "chat",
+ direct_partner: [{
+ id: 7,
+ name: "Demo",
+ }],
+ id: 10,
+ }],
+ },
+ });
+ await this.start();
+ assert.strictEqual(
+ document.querySelectorAll(`.o_DiscussSidebar_groupChat .o_DiscussSidebar_item`).length,
+ 1,
+ "should have one chat item"
+ );
+ const chat = document.querySelector(`.o_DiscussSidebar_groupChat .o_DiscussSidebar_item`);
+ assert.strictEqual(
+ chat.dataset.threadLocalId,
+ this.env.models['mail.thread'].find(thread =>
+ thread.id === 10 &&
+ thread.model === 'mail.channel'
+ ).localId,
+ "should have chat with Id 20"
+ );
+ assert.strictEqual(
+ chat.querySelectorAll(`:scope .o_DiscussSidebarItem_activeIndicator`).length,
+ 1,
+ "should have active indicator"
+ );
+ assert.strictEqual(
+ chat.querySelectorAll(`:scope .o_ThreadIcon`).length,
+ 1,
+ "should have an icon"
+ );
+ assert.strictEqual(
+ chat.querySelectorAll(`:scope .o_DiscussSidebarItem_name`).length,
+ 1,
+ "should have a name"
+ );
+ assert.strictEqual(
+ chat.querySelector(`:scope .o_DiscussSidebarItem_name`).textContent,
+ "Demo",
+ "should have correspondent name as name"
+ );
+ assert.strictEqual(
+ chat.querySelectorAll(`:scope .o_DiscussSidebarItem_commands`).length,
+ 1,
+ "should have commands"
+ );
+ assert.strictEqual(
+ chat.querySelectorAll(`:scope .o_DiscussSidebarItem_command`).length,
+ 2,
+ "should have 2 commands"
+ );
+ assert.strictEqual(
+ chat.querySelectorAll(`:scope .o_DiscussSidebarItem_commandRename`).length,
+ 1,
+ "should have 'rename' command"
+ );
+ assert.strictEqual(
+ chat.querySelectorAll(`:scope .o_DiscussSidebarItem_commandUnpin`).length,
+ 1,
+ "should have 'unpin' command"
+ );
+ assert.strictEqual(
+ chat.querySelectorAll(`:scope .o_DiscussSidebarItem_counter`).length,
+ 0,
+ "should have a counter when equals 0 (default value)"
+ );
+});
+
+QUnit.test('sidebar: chat rendering with unread counter', async function (assert) {
+ assert.expect(5);
+
+ Object.assign(this.data.initMessaging, {
+ channel_slots: {
+ channel_direct_message: [{
+ channel_type: "chat",
+ direct_partner: [{
+ id: 7,
+ name: "Demo",
+ }],
+ id: 10,
+ message_unread_counter: 100,
+ }],
+ },
+ });
+ await this.start();
+ const chat = document.querySelector(`.o_DiscussSidebar_groupChat .o_DiscussSidebar_item`);
+ assert.strictEqual(
+ chat.querySelectorAll(`:scope .o_DiscussSidebarItem_counter`).length,
+ 1,
+ "should have a counter when different from 0"
+ );
+ assert.strictEqual(
+ chat.querySelector(`:scope .o_DiscussSidebarItem_counter`).textContent,
+ "100",
+ "should have counter value"
+ );
+ assert.strictEqual(
+ chat.querySelectorAll(`:scope .o_DiscussSidebarItem_command`).length,
+ 1,
+ "should have single command"
+ );
+ assert.strictEqual(
+ chat.querySelectorAll(`:scope .o_DiscussSidebarItem_commandRename`).length,
+ 1,
+ "should have 'rename' command"
+ );
+ assert.strictEqual(
+ chat.querySelectorAll(`:scope .o_DiscussSidebarItem_commandUnpin`).length,
+ 0,
+ "should not have 'unpin' command"
+ );
+});
+
+QUnit.test('sidebar: chat im_status rendering', async function (assert) {
+ assert.expect(7);
+
+ Object.assign(this.data.initMessaging, {
+ channel_slots: {
+ channel_direct_message: [{
+ channel_type: "chat",
+ direct_partner: [{
+ id: 101,
+ im_status: 'offline',
+ name: "Partner1",
+ }],
+ id: 11,
+ }, {
+ channel_type: "chat",
+ direct_partner: [{
+ id: 102,
+ im_status: 'online',
+ name: "Partner2",
+ }],
+ id: 12,
+ }, {
+ channel_type: "chat",
+ direct_partner: [{
+ id: 103,
+ im_status: 'away',
+ name: "Partner3",
+ }],
+ id: 13,
+ }],
+ },
+ });
+ await this.start();
+ assert.strictEqual(
+ document.querySelectorAll(`.o_DiscussSidebar_groupChat .o_DiscussSidebar_item`).length,
+ 3,
+ "should have 3 chat items"
+ );
+ assert.strictEqual(
+ document.querySelectorAll(`
+ .o_DiscussSidebar_groupChat
+ .o_DiscussSidebar_item[data-thread-local-id="${
+ this.env.models['mail.thread'].find(thread =>
+ thread.id === 11 &&
+ thread.model === 'mail.channel'
+ ).localId
+ }"]
+ `).length,
+ 1,
+ "should have Partner1 (Id 11)"
+ );
+ assert.strictEqual(
+ document.querySelectorAll(`
+ .o_DiscussSidebar_groupChat
+ .o_DiscussSidebar_item[data-thread-local-id="${
+ this.env.models['mail.thread'].find(thread =>
+ thread.id === 12 &&
+ thread.model === 'mail.channel'
+ ).localId
+ }"]
+ `).length,
+ 1,
+ "should have Partner2 (Id 12)"
+ );
+ assert.strictEqual(
+ document.querySelectorAll(`
+ .o_DiscussSidebar_groupChat
+ .o_DiscussSidebar_item[data-thread-local-id="${
+ this.env.models['mail.thread'].find(thread =>
+ thread.id === 13 &&
+ thread.model === 'mail.channel'
+ ).localId
+ }"]
+ `).length,
+ 1,
+ "should have Partner3 (Id 13)"
+ );
+ const chat1 = document.querySelector(`
+ .o_DiscussSidebar_groupChat
+ .o_DiscussSidebar_item[data-thread-local-id="${
+ this.env.models['mail.thread'].find(thread =>
+ thread.id === 11 &&
+ thread.model === 'mail.channel'
+ ).localId
+ }"]
+ `);
+ const chat2 = document.querySelector(`
+ .o_DiscussSidebar_groupChat
+ .o_DiscussSidebar_item[data-thread-local-id="${
+ this.env.models['mail.thread'].find(thread =>
+ thread.id === 12 &&
+ thread.model === 'mail.channel'
+ ).localId
+ }"]
+ `);
+ const chat3 = document.querySelector(`
+ .o_DiscussSidebar_groupChat
+ .o_DiscussSidebar_item[data-thread-local-id="${
+ this.env.models['mail.thread'].find(thread =>
+ thread.id === 13 &&
+ thread.model === 'mail.channel'
+ ).localId
+ }"]
+ `);
+ assert.strictEqual(
+ chat1.querySelectorAll(`:scope .o_ThreadIcon_offline`).length,
+ 1,
+ "chat1 should have offline icon"
+ );
+ assert.strictEqual(
+ chat2.querySelectorAll(`:scope .o_ThreadIcon_online`).length,
+ 1,
+ "chat2 should have online icon"
+ );
+ assert.strictEqual(
+ chat3.querySelectorAll(`:scope .o_ThreadIcon_away`).length,
+ 1,
+ "chat3 should have away icon"
+ );
+});
+
+QUnit.test('sidebar: chat custom name', async function (assert) {
+ assert.expect(1);
+
+ Object.assign(this.data.initMessaging, {
+ channel_slots: {
+ channel_direct_message: [{
+ channel_type: "chat",
+ custom_channel_name: "Marc",
+ direct_partner: [{
+ id: 7,
+ name: "Marc Demo",
+ }],
+ id: 10,
+ }],
+ },
+ });
+ await this.start();
+ const chat = document.querySelector(`.o_DiscussSidebar_groupChat .o_DiscussSidebar_item`);
+ assert.strictEqual(
+ chat.querySelector(`:scope .o_DiscussSidebarItem_name`).textContent,
+ "Marc",
+ "chat should have custom name as name"
+ );
+});
+
+QUnit.test('sidebar: rename chat', async function (assert) {
+ assert.expect(8);
+
+ Object.assign(this.data.initMessaging, {
+ channel_slots: {
+ channel_direct_message: [{
+ custom_channel_name: "Marc",
+ channel_type: "chat",
+ direct_partner: [{
+ id: 7,
+ name: "Marc Demo",
+ }],
+ id: 10,
+ }],
+ },
+ });
+ await this.start({
+ async mockRPC(route, args) {
+ if (args.method === 'channel_set_custom_name') {
+ return;
+ }
+ return this._super(...arguments);
+ },
+ });
+ const chat = document.querySelector(`.o_DiscussSidebar_groupChat .o_DiscussSidebar_item`);
+ assert.strictEqual(
+ chat.querySelector(`:scope .o_DiscussSidebarItem_name`).textContent,
+ "Marc",
+ "chat should have custom name as name"
+ );
+ assert.notOk(
+ chat.querySelector(`:scope .o_DiscussSidebarItem_name`).classList.contains('o-editable'),
+ "chat name should not be editable"
+ );
+
+ await afterNextRender(() =>
+ chat.querySelector(`:scope .o_DiscussSidebarItem_commandRename`).click()
+ );
+ assert.ok(
+ chat.querySelector(`:scope .o_DiscussSidebarItem_name`).classList.contains('o-editable'),
+ "chat should have editable name"
+ );
+ assert.strictEqual(
+ chat.querySelectorAll(`:scope .o_DiscussSidebarItem_nameInput`).length,
+ 1,
+ "chat should have editable name input"
+ );
+ assert.strictEqual(
+ chat.querySelector(`:scope .o_DiscussSidebarItem_nameInput`).value,
+ "Marc",
+ "editable name input should have custom chat name as value by default"
+ );
+ assert.strictEqual(
+ chat.querySelector(`:scope .o_DiscussSidebarItem_nameInput`).placeholder,
+ "Marc Demo",
+ "editable name input should have partner name as placeholder"
+ );
+
+ await afterNextRender(() => {
+ chat.querySelector(`:scope .o_DiscussSidebarItem_nameInput`).value = "Demo";
+ const kevt = new window.KeyboardEvent('keydown', { key: "Enter" });
+ chat.querySelector(`:scope .o_DiscussSidebarItem_nameInput`).dispatchEvent(kevt);
+ });
+ assert.notOk(
+ chat.querySelector(`:scope .o_DiscussSidebarItem_name`).classList.contains('o-editable'),
+ "chat should no longer show editable name"
+ );
+ assert.strictEqual(
+ chat.querySelector(`:scope .o_DiscussSidebarItem_name`).textContent,
+ "Demo",
+ "chat should have renamed name as name"
+ );
+});
+
+QUnit.test('default thread rendering', async function (assert) {
+ assert.expect(16);
+
+ Object.assign(this.data.initMessaging, {
+ channel_slots: {
+ channel_channel: [{
+ channel_type: "channel",
+ id: 20,
+ name: "General",
+ }],
+ },
+ });
+ await this.start();
+ assert.strictEqual(
+ document.querySelectorAll(`
+ .o_DiscussSidebar_item[data-thread-local-id="${
+ this.env.messaging.inbox.localId
+ }"]
+ `).length,
+ 1,
+ "should have inbox mailbox in the sidebar"
+ );
+ assert.strictEqual(
+ document.querySelectorAll(`
+ .o_DiscussSidebar_item[data-thread-local-id="${
+ this.env.messaging.starred.localId
+ }"]
+ `).length,
+ 1,
+ "should have starred mailbox in the sidebar"
+ );
+ assert.strictEqual(
+ document.querySelectorAll(`
+ .o_DiscussSidebar_item[data-thread-local-id="${
+ this.env.messaging.history.localId
+ }"]
+ `).length,
+ 1,
+ "should have history mailbox in the sidebar"
+ );
+ assert.strictEqual(
+ document.querySelectorAll(`
+ .o_DiscussSidebar_item[data-thread-local-id="${
+ this.env.models['mail.thread'].find(thread =>
+ thread.id === 20 &&
+ thread.model === 'mail.channel'
+ ).localId
+ }"]
+ `).length,
+ 1,
+ "should have 'general' channel in the sidebar"
+ );
+ assert.ok(
+ document.querySelector(`
+ .o_DiscussSidebar_item[data-thread-local-id="${
+ this.env.messaging.inbox.localId
+ }"]
+ `).classList.contains('o-active'),
+ "inbox mailbox should be active thread"
+ );
+ assert.strictEqual(
+ document.querySelectorAll(`
+ .o_Discuss_thread .o_ThreadViewer_messageList .o_MessageList_empty
+ `).length,
+ 1,
+ "should have empty thread in inbox"
+ );
+ assert.strictEqual(
+ document.querySelector(`
+ .o_Discuss_thread .o_ThreadViewer_messageList .o_MessageList_empty
+ `).textContent.trim(),
+ "Congratulations, your inbox is empty New messages appear here."
+ );
+
+ await afterNextRender(() =>
+ document.querySelector(`
+ .o_DiscussSidebar_item[data-thread-local-id="${
+ this.env.messaging.starred.localId
+ }"]
+ `).click()
+ );
+ assert.ok(
+ document.querySelector(`
+ .o_DiscussSidebar_item[data-thread-local-id="${
+ this.env.messaging.starred.localId
+ }"]
+ `).classList.contains('o-active'),
+ "starred mailbox should be active thread"
+ );
+ assert.strictEqual(
+ document.querySelectorAll(`
+ .o_Discuss_thread .o_ThreadViewer_messageList .o_MessageList_empty
+ `).length,
+ 1,
+ "should have empty thread in starred"
+ );
+ assert.strictEqual(
+ document.querySelector(`
+ .o_Discuss_thread .o_ThreadViewer_messageList .o_MessageList_empty
+ `).textContent.trim(),
+ "No starred messages You can mark any message as 'starred', and it shows up in this mailbox."
+ );
+
+ await afterNextRender(() =>
+ document.querySelector(`
+ .o_DiscussSidebar_item[data-thread-local-id="${
+ this.env.messaging.history.localId
+ }"]
+ `).click()
+ );
+ assert.ok(
+ document.querySelector(`
+ .o_DiscussSidebar_item[data-thread-local-id="${
+ this.env.messaging.history.localId
+ }"]
+ `).classList.contains('o-active'),
+ "history mailbox should be active thread"
+ );
+ assert.strictEqual(
+ document.querySelectorAll(`
+ .o_Discuss_thread .o_ThreadViewer_messageList .o_MessageList_empty
+ `).length,
+ 1,
+ "should have empty thread in starred"
+ );
+ assert.strictEqual(
+ document.querySelector(`.o_Discuss_thread .o_MessageList_empty`).textContent.trim(),
+ "No history messages Messages marked as read will appear in the history."
+ );
+
+ await afterNextRender(() =>
+ document.querySelector(`
+ .o_DiscussSidebar_item[data-thread-local-id="${
+ this.env.models['mail.thread'].find(thread =>
+ thread.id === 20 &&
+ thread.model === 'mail.channel'
+ ).localId
+ }"]
+ `).click()
+ );
+ assert.ok(
+ document.querySelector(`
+ .o_DiscussSidebar_item[data-thread-local-id="${
+ this.env.models['mail.thread'].find(thread =>
+ thread.id === 20 &&
+ thread.model === 'mail.channel'
+ ).localId
+ }"]
+ `).classList.contains('o-active'),
+ "channel 'general' should be active thread"
+ );
+ assert.strictEqual(
+ document.querySelectorAll(`
+ .o_Discuss_thread .o_ThreadViewer_messageList .o_MessageList_empty
+ `).length,
+ 1,
+ "should have empty thread in starred"
+ );
+ assert.strictEqual(
+ document.querySelector(`
+ .o_Discuss_thread .o_ThreadViewer_messageList .o_MessageList_empty
+ `).textContent.trim(),
+ "There are no messages in this conversation."
+ );
+});
+
+QUnit.test('initially load messages from inbox', async function (assert) {
+ assert.expect(5);
+
+ await this.start({
+ async mockRPC(route, args) {
+ if (args.method === 'message_fetch') {
+ assert.step('message_fetch');
+ assert.strictEqual(
+ args.kwargs.limit,
+ 30,
+ "should fetch up to 30 messages"
+ );
+ assert.strictEqual(
+ args.args.length,
+ 1,
+ "should have a single item in args"
+ );
+ assert.deepEqual(
+ args.args[0],
+ [["needaction", "=", true]],
+ "should fetch needaction messages"
+ );
+ }
+ return this._super(...arguments);
+ },
+ });
+ assert.verifySteps(['message_fetch']);
+});
+
+QUnit.test('default select thread in discuss params', async function (assert) {
+ assert.expect(1);
+
+ await this.start({
+ discuss: {
+ params: {
+ default_active_id: 'mail.box_starred',
+ },
+ }
+ });
+ assert.ok(
+ document.querySelector(`
+ .o_DiscussSidebar_item[data-thread-local-id="${
+ this.env.messaging.starred.localId
+ }"]
+ .o_DiscussSidebarItem_activeIndicator
+ `).classList.contains('o-item-active'),
+ "starred mailbox should become active"
+ );
+});
+
+QUnit.test('auto-select thread in discuss context', async function (assert) {
+ assert.expect(1);
+
+ await this.start({
+ discuss: {
+ context: {
+ active_id: 'mail.box_starred',
+ },
+ },
+ });
+ assert.ok(
+ document.querySelector(`
+ .o_DiscussSidebar_item[data-thread-local-id="${
+ this.env.messaging.starred.localId
+ }"]
+ .o_DiscussSidebarItem_activeIndicator
+ `).classList.contains('o-item-active'),
+ "starred mailbox should become active"
+ );
+});
+
+QUnit.test('load single message from channel initially', async function (assert) {
+ assert.expect(8);
+
+ Object.assign(this.data.initMessaging, {
+ channel_slots: {
+ channel_channel: [{
+ channel_type: "channel",
+ id: 20,
+ name: "General",
+ }],
+ },
+ });
+ await this.start({
+ discuss: {
+ params: {
+ default_active_id: 'mail.channel_20',
+ },
+ },
+ async mockRPC(route, args) {
+ if (args.method === 'message_fetch') {
+ assert.strictEqual(
+ args.kwargs.limit,
+ 30,
+ "should fetch up to 30 messages"
+ );
+ assert.strictEqual(
+ args.args.length,
+ 1,
+ "should have a single item in args"
+ );
+ assert.deepEqual(
+ args.args[0],
+ [["channel_ids", "in", [20]]],
+ "should fetch messages from channel"
+ );
+ return [{
+ author_id: [11, "Demo"],
+ body: "<p>body</p>",
+ channel_ids: [20],
+ date: "2019-04-20 10:00:00",
+ id: 100,
+ message_type: 'comment',
+ model: 'mail.channel',
+ record_name: 'General',
+ res_id: 20,
+ }];
+ }
+ return this._super(...arguments);
+ },
+ });
+ assert.strictEqual(
+ document.querySelectorAll(`.o_Discuss_thread .o_ThreadViewer_messageList`).length,
+ 1,
+ "should have list of messages"
+ );
+ assert.strictEqual(
+ document.querySelectorAll(`.o_Discuss_thread .o_MessageList_separatorDate`).length,
+ 1,
+ "should have a single date separator" // to check: may be client timezone dependent
+ );
+ assert.strictEqual(
+ document.querySelector(`.o_Discuss_thread .o_MessageList_separatorLabelDate`).textContent,
+ "April 20, 2019",
+ "should display date day of messages"
+ );
+ assert.strictEqual(
+ document.querySelectorAll(`.o_Discuss_thread .o_MessageList_message`).length,
+ 1,
+ "should have a single message"
+ );
+ assert.strictEqual(
+ document.querySelectorAll(`
+ .o_Discuss_thread
+ .o_MessageList_message[data-message-local-id="${
+ this.env.models['mail.message'].find(message => message.id === 100).localId
+ }"]
+ `).length,
+ 1,
+ "should have message with Id 100"
+ );
+});
+
+QUnit.test('basic rendering of message', async function (assert) {
+ // AKU TODO: should be in message-only tests
+ assert.expect(13);
+
+ Object.assign(this.data.initMessaging, {
+ channel_slots: {
+ channel_channel: [{
+ channel_type: "channel",
+ id: 20,
+ name: "General",
+ }],
+ },
+ });
+ await this.start({
+ discuss: {
+ params: {
+ default_active_id: 'mail.channel_20',
+ },
+ },
+ async mockRPC(route, args) {
+ if (args.method === 'message_fetch') {
+ return [{
+ author_id: [11, "Demo"],
+ body: "<p>body</p>",
+ channel_ids: [20],
+ date: "2019-04-20 10:00:00",
+ id: 100,
+ message_type: 'comment',
+ model: 'mail.channel',
+ record_name: 'General',
+ res_id: 20,
+ }];
+ }
+ return this._super(...arguments);
+ },
+ });
+ const message = document.querySelector(`
+ .o_Discuss_thread
+ .o_ThreadViewer_messageList
+ .o_MessageList_message[data-message-local-id="${
+ this.env.models['mail.message'].find(message => message.id === 100).localId
+ }"]
+ `);
+ assert.strictEqual(
+ message.querySelectorAll(`:scope .o_Message_sidebar`).length,
+ 1,
+ "should have message sidebar of message"
+ );
+ assert.strictEqual(
+ message.querySelectorAll(`:scope .o_Message_authorAvatar`).length,
+ 1,
+ "should have author avatar in sidebar of message"
+ );
+ assert.strictEqual(
+ message.querySelector(`:scope .o_Message_authorAvatar`).dataset.src,
+ "/web/image/res.partner/11/image_128",
+ "should have url of message in author avatar sidebar"
+ );
+ assert.strictEqual(
+ message.querySelectorAll(`:scope .o_Message_core`).length,
+ 1,
+ "should have core part of message"
+ );
+ assert.strictEqual(
+ message.querySelectorAll(`:scope .o_Message_header`).length,
+ 1,
+ "should have header in core part of message"
+ );
+ assert.strictEqual(
+ message.querySelectorAll(`:scope .o_Message_authorName`).length,
+ 1,
+ "should have author name in header of message"
+ );
+ assert.strictEqual(
+ message.querySelector(`:scope .o_Message_authorName`).textContent,
+ "Demo",
+ "should have textually author name in header of message"
+ );
+ assert.strictEqual(
+ message.querySelectorAll(`:scope .o_Message_header .o_Message_date`).length,
+ 1,
+ "should have date in header of message"
+ );
+ assert.strictEqual(
+ message.querySelectorAll(`:scope .o_Message_header .o_Message_commands`).length,
+ 1,
+ "should have commands in header of message"
+ );
+ assert.strictEqual(
+ message.querySelectorAll(`:scope .o_Message_header .o_Message_command`).length,
+ 1,
+ "should have a single command in header of message"
+ );
+ assert.strictEqual(
+ message.querySelectorAll(`:scope .o_Message_commandStar`).length,
+ 1,
+ "should have command to star message"
+ );
+ assert.strictEqual(
+ message.querySelectorAll(`:scope .o_Message_content`).length,
+ 1,
+ "should have content in core part of message"
+ );
+ assert.strictEqual(
+ message.querySelector(`:scope .o_Message_content`).textContent.trim(),
+ "body",
+ "should have body of message in content part of message"
+ );
+});
+
+QUnit.test('basic rendering of squashed message', async function (assert) {
+ // messages are squashed when "close", e.g. less than 1 minute has elapsed
+ // from messages of same author and same thread. Note that this should
+ // be working in non-mailboxes
+ // AKU TODO: should be message and/or message list-only tests
+ assert.expect(12);
+
+ Object.assign(this.data.initMessaging, {
+ channel_slots: {
+ channel_channel: [{
+ channel_type: "channel",
+ id: 20,
+ name: "General",
+ }],
+ },
+ });
+ await this.start({
+ discuss: {
+ params: {
+ default_active_id: 'mail.channel_20',
+ },
+ },
+ async mockRPC(route, args) {
+ if (args.method === 'message_fetch') {
+ return [{
+ author_id: [11, "Demo"],
+ body: "<p>body1</p>",
+ channel_ids: [20],
+ date: "2019-04-20 10:00:00",
+ id: 100,
+ message_type: 'comment',
+ model: 'mail.channel',
+ record_name: 'General',
+ res_id: 20,
+ }, {
+ author_id: [11, "Demo"],
+ body: "<p>body2</p>",
+ channel_ids: [20],
+ date: "2019-04-20 10:00:30",
+ id: 101,
+ message_type: 'comment',
+ model: 'mail.channel',
+ record_name: 'General',
+ res_id: 20,
+ }];
+ }
+ return this._super(...arguments);
+ },
+ });
+ assert.strictEqual(
+ document.querySelectorAll(`
+ .o_Discuss_thread .o_ThreadViewer_messageList .o_MessageList_message
+ `).length,
+ 2,
+ "should have 2 messages"
+ );
+ const message1 = document.querySelector(`
+ .o_Discuss_thread
+ .o_ThreadViewer_messageList
+ .o_MessageList_message[data-message-local-id="${
+ this.env.models['mail.message'].find(message => message.id === 100).localId
+ }"]
+ `);
+ const message2 = document.querySelector(`
+ .o_Discuss_thread
+ .o_ThreadViewer_messageList
+ .o_MessageList_message[data-message-local-id="${
+ this.env.models['mail.message'].find(message => message.id === 101).localId
+ }"]
+ `);
+ assert.notOk(
+ message1.classList.contains('o-squashed'),
+ "message 1 should not be squashed"
+ );
+ assert.notOk(
+ message1.querySelector(`:scope .o_Message_sidebar`).classList.contains('o-message-squashed'),
+ "message 1 should not have squashed sidebar"
+ );
+ assert.ok(
+ message2.classList.contains('o-squashed'),
+ "message 2 should be squashed"
+ );
+ assert.ok(
+ message2.querySelector(`:scope .o_Message_sidebar`).classList.contains('o-message-squashed'),
+ "message 2 should have squashed sidebar"
+ );
+ assert.strictEqual(
+ message2.querySelectorAll(`:scope .o_Message_sidebar .o_Message_date`).length,
+ 1,
+ "message 2 should have date in sidebar"
+ );
+ assert.strictEqual(
+ message2.querySelectorAll(`:scope .o_Message_sidebar .o_Message_commands`).length,
+ 1,
+ "message 2 should have some commands in sidebar"
+ );
+ assert.strictEqual(
+ message2.querySelectorAll(`:scope .o_Message_sidebar .o_Message_commandStar`).length,
+ 1,
+ "message 2 should have star command in sidebar"
+ );
+ assert.strictEqual(
+ message2.querySelectorAll(`:scope .o_Message_core`).length,
+ 1,
+ "message 2 should have core part"
+ );
+ assert.strictEqual(
+ message2.querySelectorAll(`:scope .o_Message_header`).length,
+ 0,
+ "message 2 should have a header in core part"
+ );
+ assert.strictEqual(
+ message2.querySelectorAll(`:scope .o_Message_content`).length,
+ 1,
+ "message 2 should have some content in core part"
+ );
+ assert.strictEqual(
+ message2.querySelector(`:scope .o_Message_content`).textContent.trim(),
+ "body2",
+ "message 2 should have body in content part"
+ );
+});
+
+QUnit.test('inbox messages are never squashed', async function (assert) {
+ assert.expect(3);
+
+ await this.start({
+ async mockRPC(route, args) {
+ if (args.method === 'message_fetch') {
+ // fetching messages from inbox
+ return [{
+ author_id: [11, "Demo"],
+ body: "<p>body1</p>",
+ channel_ids: [20],
+ date: "2019-04-20 10:00:00",
+ id: 100,
+ message_type: 'comment',
+ model: 'mail.channel',
+ needaction: true,
+ needaction_partner_ids: [3],
+ record_name: 'General',
+ res_id: 20,
+ }, {
+ author_id: [11, "Demo"],
+ body: "<p>body2</p>",
+ channel_ids: [20],
+ date: "2019-04-20 10:00:30",
+ id: 101,
+ message_type: 'comment',
+ model: 'mail.channel',
+ needaction: true,
+ needaction_partner_ids: [3],
+ record_name: 'General',
+ res_id: 20,
+ }];
+ }
+ return this._super(...arguments);
+ },
+ });
+ assert.strictEqual(
+ document.querySelectorAll(`
+ .o_Discuss_thread .o_ThreadViewer_messageList .o_MessageList_message
+ `).length,
+ 2,
+ "should have 2 messages"
+ );
+ const message1 = document.querySelector(`
+ .o_Discuss_thread
+ .o_ThreadViewer_messageList
+ .o_MessageList_message[data-message-local-id="${
+ this.env.models['mail.message'].find(message => message.id === 100).localId
+ }"]
+ `);
+ const message2 = document.querySelector(`
+ .o_Discuss_thread
+ .o_ThreadViewer_messageList
+ .o_MessageList_message[data-message-local-id="${
+ this.env.models['mail.message'].find(message => message.id === 101).localId
+ }"]
+ `);
+ assert.notOk(
+ message1.classList.contains('o-squashed'),
+ "message 1 should not be squashed"
+ );
+ assert.notOk(
+ message2.classList.contains('o-squashed'),
+ "message 2 should not be squashed"
+ );
+});
+
+QUnit.test('load all messages from channel initially, less than fetch limit (29 < 30)', async function (assert) {
+ // AKU TODO: thread specific test
+ assert.expect(5);
+
+ Object.assign(this.data.initMessaging, {
+ channel_slots: {
+ channel_channel: [{
+ channel_type: "channel",
+ id: 20,
+ name: "General",
+ }],
+ },
+ });
+ await this.start({
+ discuss: {
+ params: {
+ default_active_id: 'mail.channel_20',
+ },
+ },
+ async mockRPC(route, args) {
+ if (args.method === 'message_fetch') {
+ assert.strictEqual(args.kwargs.limit, 30, "should fetch up to 30 messages");
+ let messagesData = [];
+ // 29 messages
+ for (let i = 28; i >= 0; i--) {
+ messagesData.push({
+ author_id: [10 + i, `User${i}`],
+ body: `<p>body${i}</p>`,
+ channel_ids: [20],
+ date: "2019-04-20 10:00:00",
+ id: 100 + i,
+ message_type: 'comment',
+ model: 'mail.channel',
+ record_name: 'General',
+ res_id: 20,
+ });
+ }
+ return messagesData;
+ }
+ return this._super(...arguments);
+ },
+ });
+ assert.strictEqual(
+ document.querySelectorAll(`
+ .o_Discuss_thread .o_ThreadViewer_messageList .o_MessageList_separatorDate
+ `).length,
+ 1,
+ "should have a single date separator" // to check: may be client timezone dependent
+ );
+ assert.strictEqual(
+ document.querySelector(`
+ .o_Discuss_thread .o_ThreadViewer_messageList .o_MessageList_separatorLabelDate
+ `).textContent,
+ "April 20, 2019",
+ "should display date day of messages"
+ );
+ assert.strictEqual(
+ document.querySelectorAll(`
+ .o_Discuss_thread .o_ThreadViewer_messageList .o_MessageList_message
+ `).length,
+ 29,
+ "should have 29 messages"
+ );
+ assert.strictEqual(
+ document.querySelectorAll(`
+ .o_Discuss_thread .o_ThreadViewer_messageList .o_MessageList_loadMore
+ `).length,
+ 0,
+ "should not have load more link"
+ );
+});
+
+QUnit.test('load more messages from channel', async function (assert) {
+ // AKU: thread specific test
+ assert.expect(8);
+
+ let step = 0;
+ Object.assign(this.data.initMessaging, {
+ channel_slots: {
+ channel_channel: [{
+ channel_type: "channel",
+ id: 20,
+ name: "General",
+ }],
+ },
+ });
+ await this.start({
+ discuss: {
+ params: {
+ default_active_id: 'mail.channel_20',
+ },
+ },
+ async mockRPC(route, args) {
+ if (args.method === 'message_fetch') {
+ step++;
+ if (step === 1) {
+ // fetching messages from channel (initial load)
+ assert.strictEqual(
+ args.kwargs.limit,
+ 30,
+ "should fetch up to 30 messages"
+ );
+ let messagesData = [];
+ // 30 messages
+ for (let i = 39; i >= 10; i--) {
+ messagesData.push({
+ author_id: [10 + i, `User${i}`],
+ body: `<p>body${i}</p>`,
+ channel_ids: [20],
+ date: "2019-04-20 10:00:00",
+ id: 100 + i,
+ message_type: 'comment',
+ model: 'mail.channel',
+ record_name: 'General',
+ res_id: 20,
+ });
+ }
+ return messagesData;
+ }
+ if (step === 2) {
+ // fetching more messages from channel (load more)
+ assert.strictEqual(
+ args.kwargs.limit,
+ 30,
+ "should fetch up to 30 messages"
+ );
+ let messagesData = [];
+ // 10 messages
+ for (let i = 9; i >= 0; i--) {
+ messagesData.push({
+ author_id: [10 + i, `User${i}`],
+ body: `<p>body${i}</p>`,
+ channel_ids: [20],
+ date: "2019-04-20 10:00:00",
+ id: 100 + i,
+ message_type: 'comment',
+ model: 'mail.channel',
+ record_name: 'General',
+ res_id: 20,
+ });
+ }
+ return messagesData;
+ }
+ }
+ return this._super(...arguments);
+ },
+ });
+ assert.strictEqual(
+ document.querySelectorAll(`
+ .o_Discuss_thread .o_ThreadViewer_messageList .o_MessageList_separatorDate
+ `).length,
+ 1,
+ "should have a single date separator" // to check: may be client timezone dependent
+ );
+ assert.strictEqual(
+ document.querySelector(`
+ .o_Discuss_thread .o_ThreadViewer_messageList .o_MessageList_separatorLabelDate
+ `).textContent,
+ "April 20, 2019",
+ "should display date day of messages"
+ );
+ assert.strictEqual(
+ document.querySelectorAll(`
+ .o_Discuss_thread .o_ThreadViewer_messageList .o_MessageList_message
+ `).length,
+ 30,
+ "should have 30 messages"
+ );
+ assert.strictEqual(
+ document.querySelectorAll(`
+ .o_Discuss_thread .o_ThreadViewer_messageList .o_MessageList_loadMore
+ `).length,
+ 1,
+ "should have load more link"
+ );
+
+ await afterNextRender(() =>
+ document.querySelector(`
+ .o_Discuss_thread .o_ThreadViewer_messageList .o_MessageList_loadMore
+ `).click()
+ );
+ assert.strictEqual(
+ document.querySelectorAll(`
+ .o_Discuss_thread .o_ThreadViewer_messageList .o_MessageList_message
+ `).length,
+ 40,
+ "should have 40 messages"
+ );
+ assert.strictEqual(
+ document.querySelectorAll(`
+ .o_Discuss_thread .o_ThreadViewer_messageList .o_MessageList_loadMore
+ `).length,
+ 0,
+ "should not longer have load more link (all messages loaded)"
+ );
+});
+
+QUnit.test('auto-scroll to bottom of thread', async function (assert) {
+ // AKU TODO: thread specific test
+ assert.expect(2);
+
+ Object.assign(this.data.initMessaging, {
+ channel_slots: {
+ channel_channel: [{
+ channel_type: "channel",
+ id: 20,
+ name: "General",
+ }],
+ },
+ });
+ await this.start({
+ discuss: {
+ params: {
+ default_active_id: 'mail.channel_20',
+ },
+ },
+ async mockRPC(route, args) {
+ if (args.method === 'message_fetch') {
+ let messagesData = [];
+ // 25 messages
+ for (let i = 1; i <= 25; i++) {
+ messagesData.push({
+ author_id: [10 + i, `User${i}`],
+ body: `<p>body${i}</p>`,
+ channel_ids: [20],
+ date: "2019-04-20 10:00:00",
+ id: 100 + i,
+ message_type: 'comment',
+ model: 'mail.channel',
+ record_name: 'General',
+ res_id: 20,
+ });
+ }
+ return messagesData;
+ }
+ return this._super(...arguments);
+ },
+ });
+ assert.strictEqual(
+ document.querySelectorAll(`
+ .o_Discuss_thread .o_ThreadViewer_messageList .o_MessageList_message
+ `).length,
+ 25,
+ "should have 25 messages"
+ );
+ const messageList = document.querySelector(`.o_Discuss_thread .o_ThreadViewer_messageList`);
+ assert.strictEqual(
+ messageList.scrollTop + messageList.clientHeight,
+ messageList.scrollHeight,
+ "should have scrolled to bottom of thread"
+ );
+});
+
+QUnit.test('load more messages from channel (auto-load on scroll)', async function (assert) {
+ // AKU TODO: thread specific test
+ assert.expect(3);
+
+ let step = 0;
+ Object.assign(this.data.initMessaging, {
+ channel_slots: {
+ channel_channel: [{
+ channel_type: "channel",
+ id: 20,
+ name: "General",
+ }],
+ },
+ });
+ await this.start({
+ discuss: {
+ params: {
+ default_active_id: 'mail.channel_20',
+ },
+ },
+ async mockRPC(route, args) {
+ if (args.method === 'message_fetch') {
+ step++;
+ if (step === 1) {
+ // fetching messages from channel (initial load)
+ let messagesData = [];
+ // 30 messages
+ for (let i = 39; i >= 10; i--) {
+ messagesData.push({
+ author_id: [10 + i, `User${i}`],
+ body: `<p>body${i}</p>`,
+ channel_ids: [20],
+ date: "2019-04-20 10:00:00",
+ id: 100 + i,
+ message_type: 'comment',
+ model: 'mail.channel',
+ record_name: 'General',
+ res_id: 20,
+ });
+ }
+ return messagesData;
+ }
+ if (step === 2) {
+ // fetching more messages from channel (load more)
+ let messagesData = [];
+ // 10 messages
+ for (let i = 9; i >= 0; i--) {
+ messagesData.push({
+ author_id: [10 + i, `User${i}`],
+ body: `<p>body${i}</p>`,
+ channel_ids: [20],
+ date: "2019-04-20 10:00:00",
+ id: 100 + i,
+ message_type: 'comment',
+ model: 'mail.channel',
+ record_name: 'General',
+ res_id: 20,
+ });
+ }
+ return messagesData;
+ }
+ }
+ return this._super(...arguments);
+ },
+ });
+ assert.strictEqual(
+ document.querySelectorAll(`
+ .o_Discuss_thread .o_ThreadViewer_messageList .o_MessageList_message
+ `).length,
+ 30,
+ "should have 30 messages"
+ );
+
+ await afterNextRender(() => {
+ document.querySelector(`.o_Discuss_thread .o_ThreadViewer_messageList`).scrollTop = 0;
+ });
+ assert.strictEqual(
+ document.querySelectorAll(`
+ .o_Discuss_thread .o_ThreadViewer_messageList .o_MessageList_message
+ `).length,
+ 40,
+ "should have 40 messages"
+ );
+ assert.strictEqual(
+ document.querySelectorAll(`
+ .o_Dsiscuss_thread .o_ThreadViewer_messageList .o_MessageList_loadMore
+ `).length,
+ 0,
+ "should not longer have load more link (all messages loaded)"
+ );
+});
+
+QUnit.test('new messages separator', async function (assert) {
+ // this test requires several messages so that the last message is not
+ // visible. This is necessary in order to display 'new messages' and not
+ // remove from DOM right away from seeing last message.
+ // AKU TODO: thread specific test
+ assert.expect(5);
+
+ let step = 0;
+ Object.assign(this.data.initMessaging, {
+ channel_slots: {
+ channel_channel: [{
+ channel_type: "channel",
+ id: 20,
+ message_unread_counter: 0,
+ name: "General",
+ seen_message_id: 125,
+ }],
+ },
+ });
+ await this.start({
+ discuss: {
+ params: {
+ default_active_id: 'mail.channel_20',
+ },
+ },
+ async mockRPC(route, args) {
+ if (args.method === 'message_fetch') {
+ step++;
+ if (step === 1) {
+ // fetching messages from channel (initial load)
+ let messagesData = [];
+ // 25 messages
+ for (let i = 1; i <= 25; i++) {
+ messagesData.push({
+ author_id: [10 + i, `User${i}`],
+ body: `<p>body${i}</p>`,
+ channel_ids: [20],
+ date: "2019-04-20 10:00:00",
+ id: 100 + i,
+ message_type: 'comment',
+ model: 'mail.channel',
+ record_name: 'General',
+ res_id: 20,
+ });
+ }
+ return messagesData;
+ }
+ if (step === 2) {
+ throw new Error("should not fetch more messages");
+ }
+ }
+ return this._super(...arguments);
+ },
+ });
+ assert.strictEqual(
+ document.querySelectorAll(`
+ .o_Discuss_thread .o_ThreadViewer_messageList .o_MessageList_message
+ `).length,
+ 25,
+ "should have 25 messages"
+ );
+ assert.strictEqual(
+ document.querySelectorAll(`
+ .o_Discuss_thread .o_ThreadViewer_messageList .o_MessageList_separatorNewMessages
+ `).length,
+ 0,
+ "should not display 'new messages' separator"
+ );
+
+ document.querySelector(`.o_Discuss_thread .o_ThreadViewer_messageList`).scrollTop = 0;
+ // simulate receiving a new message
+ const data = {
+ author_id: [36, "User26"],
+ body: "<p>boddy26</p>",
+ channel_ids: [20],
+ date: "2019-04-20 10:00:00",
+ id: 126,
+ message_type: 'comment',
+ model: 'mail.channel',
+ record_name: 'General',
+ res_id: 20,
+ };
+ await afterNextRender(() => {
+ const notifications = [[['my-db', 'mail.channel', 20], data]];
+ this.widget.call('bus_service', 'trigger', 'notification', notifications);
+ });
+ assert.strictEqual(
+ document.querySelectorAll(`
+ .o_Discuss_thread .o_ThreadViewer_messageList .o_MessageList_message
+ `).length,
+ 26,
+ "should have 26 messages"
+ );
+ assert.strictEqual(
+ document.querySelectorAll(`
+ .o_Discuss_thread .o_ThreadViewer_messageList .o_MessageList_separatorNewMessages
+ `).length,
+ 1,
+ "should display 'new messages' separator"
+ );
+
+ // scroll to bottom
+ await afterNextRender(() => {
+ document.querySelector(`.o_Discuss_thread .o_ThreadViewer_messageList`).scrollTop =
+ document.querySelector(`.o_Discuss_thread .o_ThreadViewer_messageList`).scrollHeight;
+ });
+ assert.strictEqual(
+ document.querySelectorAll(`
+ .o_Discuss_thread .o_ThreadViewer_messageList .o_MessageList_separatorNewMessages
+ `).length,
+ 0,
+ "should no longer display 'new messages' separator (message seen)"
+ );
+});
+
+QUnit.test('restore thread scroll position', async function (assert) {
+ assert.expect(4);
+
+ let step = 0;
+ Object.assign(this.data.initMessaging, {
+ channel_slots: {
+ channel_channel: [{
+ channel_type: "channel",
+ id: 1,
+ name: "channel1",
+ }, {
+ channel_type: "channel",
+ id: 2,
+ name: "channel2",
+ }],
+ },
+ });
+ await this.start({
+ discuss: {
+ params: {
+ default_active_id: 'mail.channel_1',
+ },
+ },
+ async mockRPC(route, args) {
+ if (args.method === 'message_fetch') {
+ step++;
+ if (step === 1) {
+ // fetching messages from channel1 (initial load)
+ let messagesData = [];
+ // 25 messages
+ for (let i = 1; i <= 25; i++) {
+ messagesData.push({
+ author_id: [10 + i, `User${i}`],
+ body: `<p>body${i}</p>`,
+ channel_ids: [1],
+ date: "2019-04-20 10:00:00",
+ id: 100 + i,
+ message_type: 'comment',
+ model: 'mail.channel',
+ record_name: 'channel1',
+ res_id: 1,
+ });
+ }
+ return messagesData;
+ }
+ if (step === 2) {
+ // fetching messages from channel2 (initial load)
+ let messagesData = [];
+ // 25 messages
+ for (let i = 1; i <= 25; i++) {
+ messagesData.push({
+ author_id: [10 + i, `User${i}`],
+ body: `<p>body${i}</p>`,
+ channel_ids: [2],
+ date: "2019-04-20 10:00:00",
+ id: 200 + i,
+ message_type: 'comment',
+ model: 'mail.channel',
+ record_name: 'channel2',
+ res_id: 2,
+ });
+ }
+ return messagesData;
+ }
+ }
+ return this._super(...arguments);
+ },
+ });
+ assert.strictEqual(
+ document.querySelectorAll(`
+ .o_Discuss_thread .o_ThreadViewer_messageList .o_MessageList_message
+ `).length,
+ 25,
+ "should have 25 messages"
+ );
+
+ // scroll to top of channel1
+ await afterNextRender(() => {
+ document.querySelector(`.o_Discuss_thread .o_ThreadViewer_messageList`).scrollTop = 0;
+ });
+ assert.strictEqual(
+ document.querySelector(`.o_Discuss_thread .o_ThreadViewer_messageList`).scrollTop,
+ 0,
+ "should have scrolled to top of thread"
+ );
+
+ // select channel2
+ await afterNextRender(() =>
+ document.querySelector(`
+ .o_DiscussSidebar_groupChannel
+ .o_DiscussSidebar_item[data-thread-local-id="${
+ this.env.models['mail.thread'].find(thread =>
+ thread.id === 2 &&
+ thread.model === 'mail.channel'
+ ).localId
+ }"]
+ `).click()
+ );
+ // select channel1
+ await afterNextRender(() =>
+ document.querySelector(`
+ .o_DiscussSidebar_groupChannel
+ .o_DiscussSidebar_item[data-thread-local-id="${
+ this.env.models['mail.thread'].find(thread =>
+ thread.id === 1 &&
+ thread.model === 'mail.channel'
+ ).localId
+ }"]
+ `).click()
+ );
+ assert.strictEqual(
+ document.querySelector(`.o_Discuss_thread .o_ThreadViewer_messageList`).scrollTop,
+ 0,
+ "should have recovered scroll position of channel1 (scroll to top)"
+ );
+
+ // select channel2
+ await afterNextRender(() =>
+ document.querySelector(`
+ .o_DiscussSidebar_groupChannel
+ .o_DiscussSidebar_item[data-thread-local-id="${
+ this.env.models['mail.thread'].find(thread =>
+ thread.id === 2 &&
+ thread.model === 'mail.channel'
+ ).localId
+ }"]
+ `).click()
+ );
+ const messageList = document.querySelector(`
+ .o_Discuss_thread
+ .o_ThreadViewer_messageList
+ `);
+ assert.strictEqual(
+ messageList.scrollTop + messageList.clientHeight,
+ messageList.scrollHeight,
+ "should have recovered scroll position of channel2 (scroll to bottom)"
+ );
+});
+
+QUnit.test('message origin redirect to channel', async function (assert) {
+ assert.expect(15);
+
+ let step = 0;
+ Object.assign(this.data.initMessaging, {
+ channel_slots: {
+ channel_channel: [{
+ channel_type: 'channel',
+ id: 1,
+ name: "channel1",
+ }, {
+ channel_type: 'channel',
+ id: 2,
+ name: "channel2",
+ }],
+ },
+ });
+ let messagesData = [{
+ author_id: [10, "User1"],
+ body: `<p>message1</p>`,
+ channel_ids: [1, 2],
+ date: "2019-04-20 10:00:00",
+ id: 100,
+ message_type: 'comment',
+ model: 'mail.channel',
+ record_name: "channel1",
+ res_id: 1,
+ }, {
+ author_id: [11, "User2"],
+ body: `<p>message2</p>`,
+ channel_ids: [1, 2],
+ date: "2019-04-20 10:00:00",
+ id: 101,
+ message_type: 'comment',
+ model: 'mail.channel',
+ record_name: "channel2",
+ res_id: 2,
+ }];
+ await this.start({
+ discuss: {
+ params: {
+ default_active_id: 'mail.channel_1',
+ },
+ },
+ async mockRPC(route, args) {
+ if (args.method === 'message_fetch') {
+ step++;
+ if (step === 1) {
+ // fetching messages from channel1 (initial load)
+ return messagesData;
+ }
+ if (step === 2) {
+ // fetching messages from channel2 (initial load)
+ return messagesData;
+ }
+ }
+ return this._super(...arguments);
+ },
+ });
+ assert.strictEqual(
+ document.querySelectorAll('.o_Discuss_thread .o_Message').length,
+ 2,
+ "should have 2 messages"
+ );
+ assert.strictEqual(
+ document.querySelectorAll(`
+ .o_Discuss_thread
+ .o_Message[data-message-local-id="${
+ this.env.models['mail.message'].find(message => message.id === 100).localId
+ }"]
+ `).length,
+ 1,
+ "should have message1 (Id 100)"
+ );
+ assert.strictEqual(
+ document.querySelectorAll(`
+ .o_Discuss_thread
+ .o_Message[data-message-local-id="${
+ this.env.models['mail.message'].find(message => message.id === 101).localId
+ }"]
+ `).length,
+ 1,
+ "should have message2 (Id 101)"
+ );
+ assert.strictEqual(
+ document.querySelectorAll(`
+ .o_Discuss_thread
+ .o_Message[data-message-local-id="${
+ this.env.models['mail.message'].find(message => message.id === 100).localId
+ }"]
+ .o_Message_originThread
+ `).length,
+ 0,
+ "message1 should not have origin part in channel1 (same origin as channel)"
+ );
+ assert.strictEqual(
+ document.querySelectorAll(`
+ .o_Discuss_thread
+ .o_Message[data-message-local-id="${
+ this.env.models['mail.message'].find(message => message.id === 101).localId
+ }"]
+ .o_Message_originThread
+ `).length,
+ 1,
+ "message2 should have origin part (origin is channel2 !== channel1)"
+ );
+ assert.strictEqual(
+ document.querySelector(`
+ .o_Discuss_thread
+ .o_Message[data-message-local-id="${
+ this.env.models['mail.message'].find(message => message.id === 101).localId
+ }"]
+ .o_Message_originThread
+ `).textContent.trim(),
+ "(from #channel2)",
+ "message2 should display name of origin channel"
+ );
+ assert.strictEqual(
+ document.querySelectorAll(`
+ .o_Discuss_thread
+ .o_Message[data-message-local-id="${
+ this.env.models['mail.message'].find(message => message.id === 101).localId
+ }"]
+ .o_Message_originThreadLink
+ `).length,
+ 1,
+ "message2 should have link to redirect to origin"
+ );
+
+ // click on origin link of message2 (= channel2)
+ await afterNextRender(() =>
+ document.querySelector(`
+ .o_Discuss_thread
+ .o_Message[data-message-local-id="${
+ this.env.models['mail.message'].find(message => message.id === 101).localId
+ }"]
+ .o_Message_originThreadLink
+ `).click()
+ );
+ assert.ok(
+ document.querySelector(`
+ .o_DiscussSidebar_groupChannel
+ .o_DiscussSidebar_item[data-thread-local-id="${
+ this.env.models['mail.thread'].find(thread =>
+ thread.id === 2 &&
+ thread.model === 'mail.channel'
+ ).localId
+ }"]
+ .o_DiscussSidebarItem_activeIndicator
+ `).classList.contains('o-item-active'),
+ "channel2 should be active channel on redirect from discuss app"
+ );
+ assert.strictEqual(
+ document.querySelectorAll(`.o_Discuss_thread .o_Message`).length,
+ 2,
+ "should have 2 messages"
+ );
+ assert.strictEqual(
+ document.querySelectorAll(`
+ .o_Discuss_thread
+ .o_Message[data-message-local-id="${
+ this.env.models['mail.message'].find(message => message.id === 100).localId
+ }"]
+ `).length,
+ 1,
+ "should have message1 (Id 100)"
+ );
+ assert.strictEqual(
+ document.querySelectorAll(`
+ .o_Discuss_thread
+ .o_Message[data-message-local-id="${
+ this.env.models['mail.message'].find(message => message.id === 101).localId
+ }"]
+ `).length,
+ 1,
+ "should have message2 (Id 101)"
+ );
+ assert.strictEqual(
+ document.querySelectorAll(`
+ .o_Discuss_thread
+ .o_Message[data-message-local-id="${
+ this.env.models['mail.message'].find(message => message.id === 100).localId
+ }"]
+ .o_Message_originThread
+ `).length,
+ 1,
+ "message1 should have origin thread part (= channel1 !== channel2)"
+ );
+ assert.strictEqual(
+ document.querySelectorAll(`
+ .o_Discuss_thread
+ .o_Message[data-message-local-id="${
+ this.env.models['mail.message'].find(message => message.id === 101).localId
+ }"]
+ .o_Message_originThread
+ `).length,
+ 0,
+ "message2 should not have origin thread part in channel2 (same as current channel)"
+ );
+ assert.strictEqual(
+ document.querySelector(`
+ .o_Discuss_thread
+ .o_Message[data-message-local-id="${
+ this.env.models['mail.message'].find(message => message.id === 100).localId
+ }"]
+ .o_Message_originThread
+ `).textContent.trim(),
+ "(from #channel1)",
+ "message1 should display name of origin channel"
+ );
+ assert.strictEqual(
+ document.querySelectorAll(`
+ .o_Discuss_thread
+ .o_Message[data-message-local-id="${
+ this.env.models['mail.message'].find(message => message.id === 100).localId
+ }"]
+ .o_Message_originThreadLink
+ `).length,
+ 1,
+ "message1 should have link to redirect to origin channel"
+ );
+});
+
+QUnit.test('redirect to author (open chat)', async function (assert) {
+ assert.expect(9);
+
+ let step = 0;
+ Object.assign(this.data.initMessaging, {
+ channel_slots: {
+ channel_channel: [{
+ channel_type: "channel",
+ id: 1,
+ name: "General",
+ }],
+ channel_direct_message: [{
+ channel_type: "chat",
+ direct_partner: [{
+ id: 7,
+ name: "Demo",
+ }],
+ id: 10,
+ }],
+ },
+ });
+ await this.start({
+ discuss: {
+ params: {
+ default_active_id: 'mail.channel_1',
+ },
+ },
+ async mockRPC(route, args) {
+ if (args.method === 'message_fetch') {
+ step++;
+ if (step === 1) {
+ // fetching messages from General (initial load)
+ return [{
+ author_id: [7, "Demo"],
+ body: `<p>message1</p>`,
+ channel_ids: [1],
+ date: "2019-04-20 10:00:00",
+ id: 100,
+ message_type: 'comment',
+ model: 'mail.channel',
+ record_name: "General",
+ res_id: 1,
+ }, {
+ author_id: [3, "Me"],
+ body: `<p>message2</p>`,
+ channel_ids: [1],
+ date: "2019-04-20 10:00:00",
+ id: 101,
+ message_type: 'comment',
+ model: 'mail.channel',
+ record_name: "General",
+ res_id: 1,
+ }];
+ }
+ if (step === 2) {
+ // fetching messages from DM (initial load)
+ return [];
+ }
+ }
+ if (args.model === 'res.users' && args.method === 'search') {
+ return [2];
+ }
+ return this._super(...arguments);
+ },
+ });
+ assert.ok(
+ document.querySelector(`
+ .o_DiscussSidebar_groupChannel
+ .o_DiscussSidebar_item[data-thread-local-id="${
+ this.env.models['mail.thread'].find(thread =>
+ thread.id === 1 &&
+ thread.model === 'mail.channel'
+ ).localId
+ }"]
+ .o_DiscussSidebarItem_activeIndicator
+ `).classList.contains('o-item-active'),
+ "channel 'General' should be active"
+ );
+ assert.notOk(
+ document.querySelector(`
+ .o_DiscussSidebar_groupChat
+ .o_DiscussSidebar_item[data-thread-local-id="${
+ this.env.models['mail.thread'].find(thread =>
+ thread.id === 10 &&
+ thread.model === 'mail.channel'
+ ).localId
+ }"]
+ .o_DiscussSidebarItem_activeIndicator
+ `).classList.contains('o-item-active'),
+ "Chat 'Demo' should not be active"
+ );
+ assert.strictEqual(
+ document.querySelectorAll(`.o_Discuss_thread .o_Message`).length,
+ 2,
+ "should have 2 messages"
+ );
+ const msg1 = document.querySelector(`
+ .o_Discuss_thread
+ .o_Message[data-message-local-id="${
+ this.env.models['mail.message'].find(message => message.id === 100).localId
+ }"]
+ `);
+ const msg2 = document.querySelector(`
+ .o_Discuss_thread
+ .o_Message[data-message-local-id="${
+ this.env.models['mail.message'].find(message => message.id === 101).localId
+ }"]
+ `);
+ assert.strictEqual(
+ msg1.querySelectorAll(`:scope .o_Message_authorAvatar`).length,
+ 1,
+ "message1 should have author image"
+ );
+ assert.ok(
+ msg1.querySelector(`:scope .o_Message_authorAvatar`).classList.contains('o_redirect'),
+ "message1 should have redirect to author"
+ );
+ assert.strictEqual(
+ msg2.querySelectorAll(`:scope .o_Message_authorAvatar`).length,
+ 1,
+ "message2 should have author image"
+ );
+ assert.notOk(
+ msg2.querySelector(`:scope .o_Message_authorAvatar`).classList.contains('o_redirect'),
+ "message2 should not have redirect to author (self-author)"
+ );
+
+ await afterNextRender(() =>
+ msg1.querySelector(`:scope .o_Message_authorAvatar`).click()
+ );
+ assert.notOk(
+ document.querySelector(`
+ .o_DiscussSidebar_groupChannel
+ .o_DiscussSidebar_item[data-thread-local-id="${
+ this.env.models['mail.thread'].find(thread =>
+ thread.id === 1 &&
+ thread.model === 'mail.channel'
+ ).localId
+ }"]
+ .o_DiscussSidebarItem_activeIndicator
+ `).classList.contains('o-item-active'),
+ "channel 'General' should become inactive after author redirection"
+ );
+ assert.ok(
+ document.querySelector(`
+ .o_DiscussSidebar_groupChat
+ .o_DiscussSidebar_item[data-thread-local-id="${
+ this.env.models['mail.thread'].find(thread =>
+ thread.id === 10 &&
+ thread.model === 'mail.channel'
+ ).localId
+ }"]
+ .o_DiscussSidebarItem_activeIndicator
+ `).classList.contains('o-item-active'),
+ "chat 'Demo' should become active after author redirection"
+ );
+});
+
+QUnit.test('sidebar quick search', async function (assert) {
+ // feature enables at 20 or more channels
+ assert.expect(6);
+
+ let channelsData = [];
+ for (let id = 1; id <= 20; id++) {
+ channelsData.push({
+ channel_type: 'channel',
+ id,
+ name: `channel${id}`,
+ });
+ }
+ Object.assign(this.data.initMessaging, {
+ channel_slots: {
+ channel_channel: channelsData,
+ },
+ });
+ await this.start();
+ assert.strictEqual(
+ document.querySelectorAll(`.o_DiscussSidebar_groupChannel .o_DiscussSidebar_item`).length,
+ 20,
+ "should have 20 channel items"
+ );
+ assert.strictEqual(
+ document.querySelectorAll(`.o_Discuss_sidebar input.o_DiscussSidebar_quickSearch`).length,
+ 1,
+ "should have quick search in sidebar"
+ );
+
+ const quickSearch = document.querySelector(`
+ .o_Discuss_sidebar input.o_DiscussSidebar_quickSearch
+ `);
+ await afterNextRender(() => {
+ quickSearch.value = "1";
+ const kevt1 = new window.KeyboardEvent('input');
+ quickSearch.dispatchEvent(kevt1);
+ });
+ assert.strictEqual(
+ document.querySelectorAll(`.o_DiscussSidebar_groupChannel .o_DiscussSidebar_item`).length,
+ 11,
+ "should have filtered to 11 channel items"
+ );
+
+ await afterNextRender(() => {
+ quickSearch.value = "12";
+ const kevt2 = new window.KeyboardEvent('input');
+ quickSearch.dispatchEvent(kevt2);
+ });
+ assert.strictEqual(
+ document.querySelectorAll(`.o_DiscussSidebar_groupChannel .o_DiscussSidebar_item`).length,
+ 1,
+ "should have filtered to a single channel item"
+ );
+ assert.strictEqual(
+ document.querySelector(`
+ .o_DiscussSidebar_groupChannel .o_DiscussSidebar_item
+ `).dataset.threadLocalId,
+ this.env.models['mail.thread'].find(thread =>
+ thread.id === 12 &&
+ thread.model === 'mail.channel'
+ ).localId,
+ "should have filtered to a single channel item with Id 12"
+ );
+
+ await afterNextRender(() => {
+ quickSearch.value = "123";
+ const kevt3 = new window.KeyboardEvent('input');
+ quickSearch.dispatchEvent(kevt3);
+ });
+ assert.strictEqual(
+ document.querySelectorAll(`.o_DiscussSidebar_groupChannel .o_DiscussSidebar_item`).length,
+ 0,
+ "should have filtered to no channel item"
+ );
+});
+
+QUnit.test('basic control panel rendering', async function (assert) {
+ assert.expect(8);
+
+ Object.assign(this.data.initMessaging, {
+ channel_slots: {
+ channel_channel: [{
+ channel_type: "channel",
+ id: 20,
+ name: "General",
+ }],
+ },
+ });
+ await this.start();
+ assert.strictEqual(
+ document.querySelector(`
+ .o_widget_Discuss .o_control_panel .breadcrumb
+ `).textContent,
+ "Inbox",
+ "display inbox in the breadcrumb"
+ );
+ const markAllReadButton = document.querySelector(`.o_widget_Discuss_controlPanelButtonMarkAllRead`);
+ assert.isVisible(
+ markAllReadButton,
+ "should have visible button 'Mark all read' in the control panel of inbox"
+ );
+ assert.ok(
+ markAllReadButton.disabled,
+ "should have disabled button 'Mark all read' in the control panel of inbox (no messages)"
+ );
+
+ await afterNextRender(() =>
+ document.querySelector(`
+ .o_DiscussSidebar_item[data-thread-local-id="${
+ this.env.messaging.starred.localId
+ }"]
+ `).click()
+ );
+ assert.strictEqual(
+ document.querySelector(`
+ .o_widget_Discuss .o_control_panel .breadcrumb
+ `).textContent,
+ "Starred",
+ "display starred in the breadcrumb"
+ );
+ const unstarAllButton = document.querySelector(`.o_widget_Discuss_controlPanelButtonUnstarAll`);
+ assert.isVisible(
+ unstarAllButton,
+ "should have visible button 'Unstar all' in the control panel of starred"
+ );
+ assert.ok(
+ unstarAllButton.disabled,
+ "should have disabled button 'Unstar all' in the control panel of starred (no messages)"
+ );
+
+ await afterNextRender(() =>
+ document.querySelector(`
+ .o_DiscussSidebar_item[data-thread-local-id="${
+ this.env.models['mail.thread'].find(thread =>
+ thread.id === 20 &&
+ thread.model === 'mail.channel'
+ ).localId
+ }"]
+ `).click()
+ );
+ assert.strictEqual(
+ document.querySelector(`
+ .o_widget_Discuss .o_control_panel .breadcrumb
+ `).textContent,
+ "#General",
+ "display general in the breadcrumb"
+ );
+ const inviteButton = document.querySelector(`.o_widget_Discuss_controlPanelButtonInvite`);
+ assert.isVisible(
+ inviteButton,
+ "should have visible button 'Invite' in the control panel of channel"
+ );
+});
+
+QUnit.test('inbox: mark all messages as read', async function (assert) {
+ assert.expect(8);
+
+ const self = this;
+ Object.assign(this.data.initMessaging, {
+ needaction_inbox_counter: 2,
+ channel_slots: {
+ channel_channel: [{
+ channel_type: "channel",
+ id: 20,
+ message_needaction_counter: 2,
+ name: "General",
+ }],
+ },
+ });
+ await this.start({
+ async mockRPC(route, args) {
+ if (args.method === 'message_fetch') {
+ return [{
+ author_id: [7, "Demo"],
+ body: `<p>message1</p>`,
+ channel_ids: [20],
+ date: "2019-04-20 10:00:00",
+ id: 100,
+ message_type: 'comment',
+ model: 'mail.channel',
+ needaction: true,
+ needaction_partner_ids: [3],
+ record_name: "General",
+ res_id: 20,
+ }, {
+ author_id: [8, "Other"],
+ body: `<p>message2</p>`,
+ channel_ids: [20],
+ date: "2019-04-20 10:00:00",
+ id: 101,
+ message_type: 'comment',
+ model: 'mail.channel',
+ needaction: true,
+ needaction_partner_ids: [3],
+ record_name: "General",
+ res_id: 20,
+ }];
+ }
+ if (args.method === 'mark_all_as_read') {
+ // simulate mark as read notification
+ const data = {
+ message_ids: [100, 101],
+ type: 'mark_as_read',
+ };
+ const notifications = [[['my-db', 'res.partner'], data]];
+ self.widget.call('bus_service', 'trigger', 'notification', notifications);
+ return;
+
+ }
+ return this._super(...arguments);
+ },
+ });
+ assert.strictEqual(
+ document.querySelector(`
+ .o_DiscussSidebar_item[data-thread-local-id="${
+ this.env.messaging.inbox.localId
+ }"]
+ .o_DiscussSidebarItem_counter
+ `).textContent,
+ "2",
+ "inbox should have counter of 2"
+ );
+ assert.strictEqual(
+ document.querySelector(`
+ .o_DiscussSidebar_item[data-thread-local-id="${
+ this.env.models['mail.thread'].find(thread =>
+ thread.id === 20 &&
+ thread.model === 'mail.channel'
+ ).localId
+ }"]
+ .o_DiscussSidebarItem_counter
+ `).textContent,
+ "2",
+ "channel should have counter of 2"
+ );
+ assert.strictEqual(
+ document.querySelectorAll(`.o_Discuss .o_Message`).length,
+ 2,
+ "should have 2 messages in inbox"
+ );
+ let markAllReadButton = document.querySelector(`.o_widget_Discuss_controlPanelButtonMarkAllRead`);
+ assert.notOk(
+ markAllReadButton.disabled,
+ "should have enabled button 'Mark all read' in the control panel of inbox (has messages)"
+ );
+
+ await afterNextRender(() => markAllReadButton.click());
+ assert.strictEqual(
+ document.querySelectorAll(`
+ .o_DiscussSidebar_item[data-thread-local-id="${
+ this.env.messaging.inbox.localId
+ }"]
+ .o_DiscussSidebarItem_counter
+ `).length,
+ 0,
+ "inbox should display no counter (= 0)"
+ );
+ assert.strictEqual(
+ document.querySelectorAll(`
+ .o_DiscussSidebar_item[data-thread-local-id="${
+ this.env.models['mail.thread'].find(thread =>
+ thread.id === 20 &&
+ thread.model === 'mail.channel'
+ ).localId
+ }"]
+ .o_DiscussSidebarItem_counter
+ `).length,
+ 0,
+ "channel should display no counter (= 0)"
+ );
+ assert.strictEqual(
+ document.querySelectorAll(`.o_Discuss .o_Message`).length,
+ 0,
+ "should have no message in inbox"
+ );
+ markAllReadButton = document.querySelector(`.o_widget_Discuss_controlPanelButtonMarkAllRead`);
+ assert.ok(
+ markAllReadButton.disabled,
+ "should have disabled button 'Mark all read' in the control panel of inbox (no messages)"
+ );
+});
+
+QUnit.test('starred: unstar all', async function (assert) {
+ assert.expect(6);
+
+ const self = this;
+ Object.assign(this.data.initMessaging, { starred_counter: 2 });
+ await this.start({
+ discuss: {
+ params: {
+ default_active_id: 'mail.box_starred',
+ },
+ },
+ async mockRPC(route, args) {
+ if (args.method === 'message_fetch') {
+ return [{
+ author_id: [7, "Demo"],
+ body: `<p>message1</p>`,
+ channel_ids: [20],
+ date: "2019-04-20 10:00:00",
+ id: 100,
+ message_type: 'comment',
+ model: 'mail.channel',
+ record_name: "General",
+ res_id: 20,
+ starred: true,
+ starred_partner_ids: [3],
+ }, {
+ author_id: [8, "Other"],
+ body: `<p>message2</p>`,
+ channel_ids: [20],
+ date: "2019-04-20 10:00:00",
+ id: 101,
+ message_type: 'comment',
+ model: 'mail.channel',
+ record_name: "General",
+ res_id: 20,
+ starred: true,
+ starred_partner_ids: [3],
+ }];
+ }
+ if (args.method === 'unstar_all') {
+ // simulate toggle_star notification
+ const data = {
+ message_ids: [100, 101],
+ starred: false,
+ type: 'toggle_star',
+ };
+ const notifications = [[['my-db', 'res.partner'], data]];
+ self.widget.call('bus_service', 'trigger', 'notification', notifications);
+ return;
+
+ }
+ return this._super(...arguments);
+ },
+ });
+ assert.strictEqual(
+ document.querySelector(`
+ .o_DiscussSidebar_item[data-thread-local-id="${
+ this.env.messaging.starred.localId
+ }"]
+ .o_DiscussSidebarItem_counter
+ `).textContent,
+ "2",
+ "starred should have counter of 2"
+ );
+ assert.strictEqual(
+ document.querySelectorAll(`.o_Discuss .o_Message`).length,
+ 2,
+ "should have 2 messages in starred"
+ );
+ let unstarAllButton = document.querySelector(`.o_widget_Discuss_controlPanelButtonUnstarAll`);
+ assert.notOk(
+ unstarAllButton.disabled,
+ "should have enabled button 'Unstar all' in the control panel of starred (has messages)"
+ );
+
+ await afterNextRender(() => unstarAllButton.click());
+ assert.strictEqual(
+ document.querySelectorAll(`
+ .o_DiscussSidebar_item[data-thread-local-id="${
+ this.env.messaging.starred.localId
+ }"]
+ .o_DiscussSidebarItem_counter
+ `).length,
+ 0,
+ "starred should display no counter (= 0)"
+ );
+ assert.strictEqual(
+ document.querySelectorAll(`.o_Discuss .o_Message`).length,
+ 0,
+ "should have no message in starred"
+ );
+ unstarAllButton = document.querySelector(`.o_widget_Discuss_controlPanelButtonUnstarAll`);
+ assert.ok(
+ unstarAllButton.disabled,
+ "should have disabled button 'Unstar all' in the control panel of starred (no messages)"
+ );
+});
+
+QUnit.test('toggle_star message', async function (assert) {
+ assert.expect(16);
+
+ const self = this;
+ Object.assign(this.data.initMessaging, {
+ channel_slots: {
+ channel_channel: [{
+ channel_type: "channel",
+ id: 20,
+ name: "General",
+ }],
+ },
+ });
+ let messageData = {
+ author_id: [11, "Demo"],
+ body: "<p>body</p>",
+ channel_ids: [20],
+ date: "2019-04-20 10:00:00",
+ id: 100,
+ message_type: 'comment',
+ model: 'mail.channel',
+ record_name: 'General',
+ res_id: 20,
+ starred: false,
+ starred_partner_ids: [],
+ };
+
+ await this.start({
+ discuss: {
+ params: {
+ default_active_id: 'mail.channel_20',
+ },
+ },
+ async mockRPC(route, args) {
+ if (args.method === 'message_fetch') {
+ return [messageData];
+ }
+ if (args.method === 'toggle_message_starred') {
+ assert.step('rpc:toggle_message_starred');
+ assert.strictEqual(
+ args.args[0][0],
+ 100,
+ "should have message Id in args"
+ );
+ // simulate toggle_star notification
+ messageData.starred = !messageData.starred;
+ const data = {
+ message_ids: [100],
+ starred: messageData.starred,
+ type: 'toggle_star',
+ };
+ const notifications = [[['my-db', 'res.partner'], data]];
+ self.widget.call('bus_service', 'trigger', 'notification', notifications);
+ return;
+ }
+ return this._super(...arguments);
+ },
+ });
+ assert.strictEqual(
+ document.querySelectorAll(`
+ .o_DiscussSidebar_item[data-thread-local-id="${
+ this.env.messaging.starred.localId
+ }"]
+ .o_DiscussSidebarItem_counter
+ `).length,
+ 0,
+ "starred should display no counter (= 0)"
+ );
+ assert.strictEqual(
+ document.querySelectorAll(`.o_Discuss .o_Message`).length,
+ 1,
+ "should have 1 message in channel"
+ );
+ let message = document.querySelector(`.o_Discuss .o_Message`);
+ assert.notOk(
+ message.classList.contains('o-starred'),
+ "message should not be starred"
+ );
+ assert.strictEqual(
+ message.querySelectorAll(`:scope .o_Message_commandStar`).length,
+ 1,
+ "message should have star command"
+ );
+
+ await afterNextRender(() => message.querySelector(`:scope .o_Message_commandStar`).click());
+ assert.verifySteps(['rpc:toggle_message_starred']);
+ assert.strictEqual(
+ document.querySelector(`
+ .o_DiscussSidebar_item[data-thread-local-id="${
+ this.env.messaging.starred.localId
+ }"]
+ .o_DiscussSidebarItem_counter
+ `).textContent,
+ "1",
+ "starred should display a counter of 1"
+ );
+ assert.strictEqual(
+ document.querySelectorAll(`.o_Discuss .o_Message`).length,
+ 1,
+ "should have kept 1 message in channel"
+ );
+ message = document.querySelector(`.o_Discuss .o_Message`);
+ assert.ok(
+ message.classList.contains('o-starred'),
+ "message should be starred"
+ );
+
+ await afterNextRender(() => message.querySelector(`:scope .o_Message_commandStar`).click());
+ assert.verifySteps(['rpc:toggle_message_starred']);
+ assert.strictEqual(
+ document.querySelectorAll(`
+ .o_DiscussSidebar_item[data-thread-local-id="${
+ this.env.messaging.starred.localId
+ }"]
+ .o_DiscussSidebarItem_counter
+ `).length,
+ 0,
+ "starred should no longer display a counter (= 0)"
+ );
+ assert.strictEqual(
+ document.querySelectorAll(`.o_Discuss .o_Message`).length,
+ 1,
+ "should still have 1 message in channel"
+ );
+ message = document.querySelector(`.o_Discuss .o_Message`);
+ assert.notOk(
+ message.classList.contains('o-starred'),
+ "message should no longer be starred"
+ );
+});
+
+QUnit.test('composer state: text save and restore', async function (assert) {
+ assert.expect(2);
+
+ Object.assign(this.data.initMessaging, {
+ channel_slots: {
+ channel_channel: [{
+ channel_type: 'channel',
+ id: 20,
+ name: "General",
+ }, {
+ channel_type: 'channel',
+ id: 21,
+ name: "Special",
+ }],
+ },
+ });
+ await this.start({
+ discuss: {
+ params: {
+ default_active_id: 'mail.channel_20',
+ },
+ },
+ async mockRPC(route, args) {
+ if (args.method === 'message_fetch') {
+ return [];
+ }
+ return this._super(...arguments);
+ },
+ });
+ // Write text in composer for #general
+ await afterNextRender(() => {
+ document.querySelector(`.o_ComposerTextInput_textarea`).focus();
+ document.execCommand('insertText', false, "A message");
+ document.querySelector(`.o_ComposerTextInput_textarea`)
+ .dispatchEvent(new window.KeyboardEvent('input'));
+ });
+ await afterNextRender(() =>
+ document.querySelector(`.o_DiscussSidebarItem[data-thread-name="Special"]`).click()
+ );
+ await afterNextRender(() => {
+ document.querySelector(`.o_ComposerTextInput_textarea`).focus();
+ document.execCommand('insertText', false, "An other message");
+ document.querySelector(`.o_ComposerTextInput_textarea`)
+ .dispatchEvent(new window.KeyboardEvent('input'));
+ });
+ // Switch back to #general
+ await afterNextRender(() =>
+ document.querySelector(`.o_DiscussSidebarItem[data-thread-name="General"]`).click()
+ );
+ assert.strictEqual(
+ document.querySelector(`.o_ComposerTextInput_textarea`).value,
+ "A message",
+ "should restore the input text"
+ );
+
+ await afterNextRender(() =>
+ document.querySelector(`.o_DiscussSidebarItem[data-thread-name="Special"]`).click()
+ );
+ assert.strictEqual(
+ document.querySelector(`.o_ComposerTextInput_textarea`).value,
+ "An other message",
+ "should restore the input text"
+ );
+});
+
+QUnit.test('composer state: attachments save and restore', async function (assert) {
+ assert.expect(6);
+
+ Object.assign(this.data.initMessaging, {
+ channel_slots: {
+ channel_channel: [{
+ channel_type: 'channel',
+ id: 20,
+ name: "General",
+ }, {
+ channel_type: 'channel',
+ id: 21,
+ name: "Special",
+ }],
+ },
+ });
+ await this.start({
+ discuss: {
+ params: {
+ default_active_id: 'mail.channel_20',
+ },
+ },
+ async mockRPC(route, args) {
+ if (args.method === 'message_fetch') {
+ return [];
+ }
+ return this._super(...arguments);
+ },
+ });
+ const channels = document.querySelectorAll(`
+ .o_DiscussSidebar_groupChannel .o_DiscussSidebar_item
+ `);
+ // Add attachment in a message for #general
+ await afterNextRender(async () => {
+ const file = await createFile({
+ content: 'hello, world',
+ contentType: 'text/plain',
+ name: 'text.txt',
+ });
+ inputFiles(
+ document.querySelector('.o_FileUploader_input'),
+ [file]
+ );
+ });
+ // Switch to #special
+ await afterNextRender(() => channels[1].click());
+ // Add attachments in a message for #special
+ const files = [
+ await createFile({
+ content: 'hello2, world',
+ contentType: 'text/plain',
+ name: 'text2.txt',
+ }),
+ await createFile({
+ content: 'hello3, world',
+ contentType: 'text/plain',
+ name: 'text3.txt',
+ }),
+ await createFile({
+ content: 'hello4, world',
+ contentType: 'text/plain',
+ name: 'text4.txt',
+ }),
+ ];
+ await afterNextRender(() =>
+ inputFiles(
+ document.querySelector('.o_FileUploader_input'),
+ files
+ )
+ );
+ // Switch back to #general
+ await afterNextRender(() => channels[0].click());
+ // Check attachment is reloaded
+ assert.strictEqual(
+ document.querySelectorAll(`.o_Composer .o_Attachment`).length,
+ 1,
+ "should have 1 attachment in the composer"
+ );
+ assert.strictEqual(
+ document.querySelector(`.o_Composer .o_Attachment`).dataset.attachmentLocalId,
+ this.env.models['mail.attachment'].find(attachment => attachment.id === 1).localId,
+ "should have correct 1st attachment in the composer"
+ );
+
+ // Switch back to #special
+ await afterNextRender(() => channels[1].click());
+ // Check attachments are reloaded
+ assert.strictEqual(
+ document.querySelectorAll(`.o_Composer .o_Attachment`).length,
+ 3,
+ "should have 3 attachments in the composer"
+ );
+ assert.strictEqual(
+ document.querySelectorAll(`.o_Composer .o_Attachment`)[0].dataset.attachmentLocalId,
+ this.env.models['mail.attachment'].find(attachment => attachment.id === 2).localId,
+ "should have attachment with id 2 as 1st attachment"
+ );
+ assert.strictEqual(
+ document.querySelectorAll(`.o_Composer .o_Attachment`)[1].dataset.attachmentLocalId,
+ this.env.models['mail.attachment'].find(attachment => attachment.id === 3).localId,
+ "should have attachment with id 3 as 2nd attachment"
+ );
+ assert.strictEqual(
+ document.querySelectorAll(`.o_Composer .o_Attachment`)[2].dataset.attachmentLocalId,
+ this.env.models['mail.attachment'].find(attachment => attachment.id === 4).localId,
+ "should have attachment with id 4 as 3rd attachment"
+ );
+});
+
+QUnit.test('post a simple message', async function (assert) {
+ assert.expect(15);
+
+ const self = this;
+ Object.assign(this.data.initMessaging, {
+ channel_slots: {
+ channel_channel: [{
+ channel_type: "channel",
+ id: 20,
+ name: "General",
+ }],
+ },
+ });
+ let messagesData = [];
+ await this.start({
+ discuss: {
+ params: {
+ default_active_id: 'mail.channel_20',
+ },
+ },
+ async mockRPC(route, args) {
+ if (args.method === 'message_fetch') {
+ return messagesData;
+ }
+ if (args.method === 'message_post') {
+ assert.step('message_post');
+ assert.strictEqual(
+ args.args[0],
+ 20,
+ "should post message to channel Id 20"
+ );
+ assert.strictEqual(
+ args.kwargs.body,
+ "Test",
+ "should post with provided content in composer input"
+ );
+ assert.strictEqual(
+ args.kwargs.message_type,
+ "comment",
+ "should set message type as 'comment'"
+ );
+ assert.strictEqual(
+ args.kwargs.subtype_xmlid,
+ "mail.mt_comment",
+ "should set subtype_xmlid as 'comment'"
+ );
+ // simulate receiving a new message
+ const data = {
+ author_id: [3, "Admin"],
+ body: args.kwargs.body,
+ channel_ids: [20],
+ date: "2019-04-20 11:00:00",
+ id: 101,
+ message_type: args.kwargs.message_type,
+ model: 'mail.channel',
+ subtype_xmlid: args.kwargs.subtype_xmlid,
+ record_name: 'General',
+ res_id: 20,
+ };
+ const notifications = [
+ [['my-db', 'mail.channel', 20], data]
+ ];
+ messagesData.push(data);
+ self.widget.call('bus_service', 'trigger', 'notification', notifications);
+ return;
+ }
+ return this._super(...arguments);
+ },
+ });
+ assert.strictEqual(
+ document.querySelectorAll(`.o_MessageList_empty`).length,
+ 1,
+ "should display thread with no message initially"
+ );
+ assert.strictEqual(
+ document.querySelectorAll(`.o_Message`).length,
+ 0,
+ "should display no message initially"
+ );
+ assert.strictEqual(
+ document.querySelector(`.o_ComposerTextInput_textarea`).value,
+ "",
+ "should have empty content initially"
+ );
+
+ // insert some HTML in editable
+ document.querySelector(`.o_ComposerTextInput_textarea`).focus();
+ document.execCommand('insertText', false, "Test");
+ assert.strictEqual(
+ document.querySelector(`.o_ComposerTextInput_textarea`).value,
+ "Test",
+ "should have inserted text in editable"
+ );
+
+ await afterNextRender(() =>
+ document.querySelector(`.o_ComposerTextInput_textarea`)
+ .dispatchEvent(new window.KeyboardEvent('keydown', { key: 'Enter' }))
+ );
+ assert.verifySteps(['message_post']);
+ assert.strictEqual(
+ document.querySelector(`.o_ComposerTextInput_textarea`).value,
+ "",
+ "should have no content in composer input after posting message"
+ );
+ assert.strictEqual(
+ document.querySelectorAll(`.o_Message`).length,
+ 1,
+ "should display a message after posting message"
+ );
+ const message = document.querySelector(`.o_Message`);
+ assert.strictEqual(
+ message.dataset.messageLocalId,
+ this.env.models['mail.message'].find(message => message.id === 101).localId,
+ "new message in thread should be linked to newly created message from message post"
+ );
+ assert.strictEqual(
+ message.querySelector(`:scope .o_Message_authorName`).textContent,
+ "Admin",
+ "new message in thread should be from Admin"
+ );
+ assert.strictEqual(
+ message.querySelector(`:scope .o_Message_content`).textContent,
+ "Test",
+ "new message in thread should have content typed from composer text input"
+ );
+});
+
+QUnit.test('rendering of inbox message', async function (assert) {
+ // AKU TODO: kinda message specific test
+ assert.expect(7);
+
+ await this.start({
+ async mockRPC(route, args) {
+ if (args.method === 'message_fetch') {
+ return [{
+ author_id: [11, "Demo"],
+ body: "<p>body</p>",
+ date: "2019-04-20 10:00:00",
+ id: 100,
+ message_type: 'comment',
+ model: 'project.task',
+ needaction_partner_ids: [44],
+ record_name: 'Refactoring',
+ res_id: 20,
+ }];
+ }
+ return this._super(...arguments);
+ },
+ });
+ assert.strictEqual(
+ document.querySelectorAll('.o_Message').length,
+ 1,
+ "should display a message"
+ );
+ const message = document.querySelector('.o_Message');
+ assert.strictEqual(
+ message.querySelectorAll(`:scope .o_Message_originThread`).length,
+ 1,
+ "should display origin thread of message"
+ );
+ assert.strictEqual(
+ message.querySelector(`:scope .o_Message_originThread`).textContent,
+ " on Refactoring",
+ "should display origin thread name"
+ );
+ assert.strictEqual(
+ message.querySelectorAll(`:scope .o_Message_command`).length,
+ 3,
+ "should display 3 commands"
+ );
+ assert.strictEqual(
+ message.querySelectorAll(`:scope .o_Message_commandStar`).length,
+ 1,
+ "should display star command"
+ );
+ assert.strictEqual(
+ message.querySelectorAll(`:scope .o_Message_commandReply`).length,
+ 1,
+ "should display reply command"
+ );
+ assert.strictEqual(
+ message.querySelectorAll(`:scope .o_Message_commandMarkAsRead`).length,
+ 1,
+ "should display mark as read command"
+ );
+});
+
+QUnit.test('mark channel as seen on last message visible', async function (assert) {
+ assert.expect(3);
+
+ let step = 0;
+ Object.assign(this.data.initMessaging, {
+ channel_slots: {
+ channel_channel: [{
+ channel_type: 'channel',
+ id: 10,
+ message_unread_counter: 1,
+ name: "General",
+ }],
+ },
+ });
+ await this.start({
+ async mockRPC(route, args) {
+ if (args.method === 'message_fetch') {
+ step++;
+ if (step === 1) {
+ return [];
+ }
+ if (step === 2) {
+ return [{
+ author_id: [11, "Demo"],
+ body: "<p>body</p>",
+ date: "2019-04-20 10:00:00",
+ id: 100,
+ message_type: 'comment',
+ model: 'mail.channel',
+ record_name: "General",
+ res_id: 10,
+ }];
+ }
+ throw new Error("should not call 'message_fetch' more than twice");
+ }
+ return this._super(...arguments);
+ },
+ });
+ assert.strictEqual(
+ document.querySelectorAll(`
+ .o_DiscussSidebar_item[data-thread-local-id="${
+ this.env.models['mail.thread'].find(thread =>
+ thread.id === 10 &&
+ thread.model === 'mail.channel'
+ ).localId
+ }"]
+ `).length,
+ 1,
+ "should have discuss sidebar item with the channel"
+ );
+ assert.ok(
+ document.querySelector(`
+ .o_DiscussSidebar_item[data-thread-local-id="${
+ this.env.models['mail.thread'].find(thread =>
+ thread.id === 10 &&
+ thread.model === 'mail.channel'
+ ).localId
+ }"]
+ `).classList.contains('o-unread'),
+ "sidebar item of channel ID 10 should be unread"
+ );
+
+ await afterNextRender(() =>
+ document.querySelector(`
+ .o_DiscussSidebar_item[data-thread-local-id="${
+ this.env.models['mail.thread'].find(thread =>
+ thread.id === 10 &&
+ thread.model === 'mail.channel'
+ ).localId
+ }"]
+ `).click()
+ );
+ assert.notOk(
+ document.querySelector(`
+ .o_DiscussSidebar_item[data-thread-local-id="${
+ this.env.models['mail.thread'].find(thread =>
+ thread.id === 10 &&
+ thread.model === 'mail.channel'
+ ).localId
+ }"]
+ `).classList.contains('o-unread'),
+ "sidebar item of channel ID 10 should not longer be unread"
+ );
+});
+
+QUnit.test('receive new needaction messages', async function (assert) {
+ assert.expect(12);
+
+ await this.start({
+ async mockRPC(route, args) {
+ if (args.method === 'message_fetch') {
+ return [];
+ }
+ return this._super(...arguments);
+ },
+ });
+ assert.ok(
+ document.querySelector(`
+ .o_DiscussSidebar_item[data-thread-local-id="${
+ this.env.messaging.inbox.localId
+ }"]
+ `),
+ "should have inbox in sidebar"
+ );
+ assert.ok(
+ document.querySelector(`
+ .o_DiscussSidebar_item[data-thread-local-id="${
+ this.env.messaging.inbox.localId
+ }"]
+ `).classList.contains('o-active'),
+ "inbox should be current discuss thread"
+ );
+ assert.notOk(
+ document.querySelector(`
+ .o_DiscussSidebar_item[data-thread-local-id="${
+ this.env.messaging.inbox.localId
+ }"]
+ .o_DiscussSidebarItem_counter
+ `),
+ "inbox item in sidebar should not have any counter"
+ );
+ assert.strictEqual(
+ document.querySelectorAll(`.o_Discuss_thread .o_Message`).length,
+ 0,
+ "should have no messages in inbox initially"
+ );
+
+ // simulate receiving a new needaction message
+ await afterNextRender(() => {
+ const data = {
+ author_id: [7, "Demo"],
+ body: "<p>Test</p>",
+ date: "2019-04-20 11:00:00",
+ id: 100,
+ message_type: 'comment',
+ needaction_partner_ids: [3],
+ model: 'project.task',
+ record_name: 'Refactoring',
+ res_id: 20,
+ };
+ const notifications = [[['my-db', 'ir.needaction', 3], data]];
+ this.widget.call('bus_service', 'trigger', 'notification', notifications);
+ });
+ assert.ok(
+ document.querySelector(`
+ .o_DiscussSidebar_item[data-thread-local-id="${
+ this.env.messaging.inbox.localId
+ }"]
+ .o_DiscussSidebarItem_counter
+ `),
+ "inbox item in sidebar should now have counter"
+ );
+ assert.strictEqual(
+ document.querySelector(`
+ .o_DiscussSidebar_item[data-thread-local-id="${
+ this.env.messaging.inbox.localId
+ }"]
+ .o_DiscussSidebarItem_counter
+ `).textContent,
+ '1',
+ "inbox item in sidebar should have counter of '1'"
+ );
+ assert.strictEqual(
+ document.querySelectorAll(`.o_Discuss_thread .o_Message`).length,
+ 1,
+ "should have one message in inbox"
+ );
+ assert.strictEqual(
+ document.querySelector(`.o_Discuss_thread .o_Message`).dataset.messageLocalId,
+ this.env.models['mail.message'].find(message => message.id === 100).localId,
+ "should display newly received needaction message"
+ );
+
+ // simulate receiving another new needaction message
+ await afterNextRender(() => {
+ const data2 = {
+ author_id: [7, "Demo"],
+ body: "<p>Test2</p>",
+ date: "2019-04-20 11:00:00",
+ id: 101,
+ message_type: 'comment',
+ needaction_partner_ids: [3],
+ model: 'project.task',
+ record_name: 'Refactoring',
+ res_id: 20,
+ };
+ const notifications2 = [[['my-db', 'ir.needaction', 3], data2]];
+ this.widget.call('bus_service', 'trigger', 'notification', notifications2);
+ });
+ assert.strictEqual(
+ document.querySelector(`
+ .o_DiscussSidebar_item[data-thread-local-id="${
+ this.env.messaging.inbox.localId
+ }"]
+ .o_DiscussSidebarItem_counter
+ `).textContent,
+ '2',
+ "inbox item in sidebar should have counter of '2'"
+ );
+ assert.strictEqual(
+ document.querySelectorAll(`.o_Discuss_thread .o_Message`).length,
+ 2,
+ "should have 2 messages in inbox"
+ );
+ assert.ok(
+ document.querySelector(`
+ .o_Discuss_thread
+ .o_Message[data-message-local-id="${
+ this.env.models['mail.message'].find(message => message.id === 100).localId
+ }"]
+ `),
+ "should still display 1st needaction message"
+ );
+ assert.ok(
+ document.querySelector(`
+ .o_Discuss_thread
+ .o_Message[data-message-local-id="${
+ this.env.models['mail.message'].find(message => message.id === 101).localId
+ }"]
+ `),
+ "should display 2nd needaction message"
+ );
+});
+
+QUnit.test('reply to message from inbox (message linked to document)', async function (assert) {
+ assert.expect(20);
+
+ Object.assign(this.data.initMessaging, { needaction_inbox_counter: 1 });
+
+ let messagesData = [];
+ await this.start({
+ async mockRPC(route, args) {
+ if (args.method === 'message_fetch') {
+ return [{
+ author_id: [7, "Demo"],
+ body: "<p>Test</p>",
+ date: "2019-04-20 11:00:00",
+ id: 100,
+ message_type: 'comment',
+ needaction_partner_ids: [3],
+ model: 'project.task',
+ record_name: 'Refactoring',
+ res_id: 20,
+ }];
+ }
+ if (args.method === 'message_post') {
+ assert.step('message_post');
+ assert.strictEqual(
+ args.model,
+ 'project.task',
+ "should post message to record with model 'project.task'"
+ );
+ assert.strictEqual(
+ args.args[0],
+ 20,
+ "should post message to record with Id 20"
+ );
+ assert.strictEqual(
+ args.kwargs.body,
+ "Test",
+ "should post with provided content in composer input"
+ );
+ assert.strictEqual(
+ args.kwargs.message_type,
+ "comment",
+ "should set message type as 'comment'"
+ );
+ assert.strictEqual(
+ args.kwargs.subtype_xmlid,
+ "mail.mt_comment",
+ "should set subtype_xmlid as 'comment'"
+ );
+ messagesData.push({
+ author_id: [3, "Admin"],
+ body: args.kwargs.body,
+ date: "2019-04-20 11:00:00",
+ id: 101,
+ message_type: args.kwargs.message_type,
+ model: args.model,
+ subtype_xmlid: args.kwargs.subtype_xmlid,
+ record_name: 'Refactoring',
+ res_id: 20,
+ });
+ return;
+ }
+ if (args.method === 'message_format') {
+ return messagesData;
+ }
+ return this._super(...arguments);
+ },
+ });
+ assert.strictEqual(
+ document.querySelectorAll('.o_Message').length,
+ 1,
+ "should display a single message"
+ );
+ assert.strictEqual(
+ document.querySelector('.o_Message').dataset.messageLocalId,
+ this.env.models['mail.message'].find(message => message.id === 100).localId,
+ "should display message with ID 100"
+ );
+ assert.strictEqual(
+ document.querySelector('.o_Message_originThread').textContent,
+ " on Refactoring",
+ "should display message originates from record 'Refactoring'"
+ );
+
+ await afterNextRender(() =>
+ document.querySelector('.o_Message_commandReply').click()
+ );
+ assert.ok(
+ document.querySelector('.o_Message').classList.contains('o-selected'),
+ "message should be selected after clicking on reply icon"
+ );
+ assert.ok(
+ document.querySelector('.o_Composer'),
+ "should have composer after clicking on reply to message"
+ );
+ assert.strictEqual(
+ document.querySelector(`.o_Composer_threadName`).textContent,
+ " on: Refactoring",
+ "composer should display origin thread name of message"
+ );
+ assert.strictEqual(
+ document.activeElement,
+ document.querySelector(`.o_ComposerTextInput_textarea`),
+ "composer text input should be auto-focus"
+ );
+
+ await afterNextRender(() => {
+ document.execCommand('insertText', false, "Test");
+ document.querySelector(`.o_ComposerTextInput_textarea`)
+ .dispatchEvent(new window.KeyboardEvent('keydown', { key: 'Enter' }));
+ });
+ assert.verifySteps(['message_post']);
+ assert.notOk(
+ document.querySelector('.o_Composer'),
+ "should no longer have composer after posting reply to message"
+ );
+ assert.strictEqual(
+ document.querySelectorAll('.o_Message').length,
+ 1,
+ "should still display a single message after posting reply"
+ );
+ assert.strictEqual(
+ document.querySelector('.o_Message').dataset.messageLocalId,
+ this.env.models['mail.message'].find(message => message.id === 100).localId,
+ "should still display message with ID 100 after posting reply"
+ );
+ assert.notOk(
+ document.querySelector('.o_Message').classList.contains('o-selected'),
+ "message should not longer be selected after posting reply"
+ );
+ assert.ok(
+ document.querySelector('.o_notification'),
+ "should display a notification after posting reply"
+ );
+ assert.strictEqual(
+ document.querySelector('.o_notification_content').textContent,
+ "Message posted on \"Refactoring\"",
+ "notification should tell that message has been posted to the record 'Refactoring'"
+ );
+});
+
+QUnit.skip('load recent messages from thread (already loaded some old messages)', async function (assert) {
+ // TODO FIXME should be restored as part of task-2171881 (robust "load more" with "holes")
+ // Load old message that has been needaction, when opening this thread for
+ // the first time, it should load messages that are more recent.
+ assert.expect(17);
+
+ Object.assign(this.data.initMessaging, {
+ channel_slots: {
+ channel_channel: [{
+ channel_type: 'channel',
+ id: 20,
+ name: "General",
+ }],
+ },
+ needaction_inbox_counter: 1,
+ });
+ let step = 0;
+ let mailMessages = {};
+ for (let i = 0; i < 50; i++) {
+ mailMessages[100 + i] = {
+ author_id: [7, "Demo"],
+ body: `<p>Test${i}</p>`,
+ date: `2019-04-20 11:00:${i < 10 ? '0' + i : i}`,
+ id: 100 + i,
+ message_type: 'comment',
+ needaction_partner_ids: i === 0 ? [3] : [],
+ model: 'mail.channel',
+ record_name: "General",
+ res_id: 20,
+ };
+ }
+ await this.start({
+ async mockRPC(route, args) {
+ if (args.method === 'message_fetch') {
+ step++;
+ if (step === 1) {
+ assert.step('message_fetch:load_inbox');
+ assert.deepEqual(
+ args.args[0],
+ [['needaction', '=', true]],
+ "should fetch needaction messages from inbox"
+ );
+ const needactionMessages = Object
+ .values(mailMessages)
+ .filter(mailMessage =>
+ mailMessage.needaction_partner_ids.length > 0
+ );
+ return needactionMessages;
+ }
+ if (step === 2) {
+ assert.step('message_fetch:load_channel_20');
+ assert.deepEqual(
+ args.args[0],
+ [['channel_ids', 'in', [20]]],
+ "should fetch messages from channel ID 20"
+ );
+ assert.strictEqual(
+ args.kwargs.limit,
+ 30,
+ "should limit fetch to 30 messages (load)"
+ );
+ const moreRecentMessages = Object
+ .values(mailMessages)
+ .sort((mailMsg1, mailMsg2) => mailMsg1.id > mailMsg2.id ? -1 : 1)
+ .splice(0, 30);
+ return moreRecentMessages;
+ }
+ if (step === 3) {
+ assert.step('message_fetch:load_more_channel_20');
+ assert.deepEqual(
+ args.args[0],
+ // loaded 30 messages, so 150 - 30 = 120 = ID of last message fetched
+ // => load more messages starting from this ID
+ [['id', '<', 120], ['channel_ids', 'in', [20]]],
+ "should fetch more messages from 30 more recent messages"
+ );
+ assert.strictEqual(
+ args.kwargs.limit,
+ 30,
+ "should limit fetch to 30 messages (load more)"
+ );
+ const loadMoreMessages = Object
+ .values(mailMessages)
+ .filter(mailMessage => mailMessage.id < 120)
+ .sort((mailMsg1, mailMsg2) => mailMsg1.id > mailMsg2.id ? -1 : 1)
+ .splice(0, 30);
+ return loadMoreMessages;
+ }
+ throw new Error("should not fetch more than twice");
+ }
+ return this._super(...arguments);
+ },
+ });
+ assert.verifySteps(
+ ['message_fetch:load_inbox'],
+ "should initially have fetched messages from inbox"
+ );
+ assert.strictEqual(
+ document.querySelectorAll('.o_Message').length,
+ 1,
+ "should fetch a single message from inbox"
+ );
+ assert.strictEqual(
+ document.querySelector('.o_Message').dataset.messageLocalId,
+ this.env.models['mail.message'].find(message => message.id === 100).localId,
+ "should have fetched 1st message of channel 'General' as needaction from inbox"
+ );
+
+ await afterNextRender(() =>
+ document.querySelector(`
+ .o_DiscussSidebar_item[data-thread-local-id="${
+ this.env.models['mail.thread'].find(thread =>
+ thread.id === 20 &&
+ thread.model === 'mail.channel'
+ ).localId
+ }"]
+ `).click()
+ );
+ assert.verifySteps(
+ ['message_fetch:load_channel_20'],
+ "should initially have fetched messages from channel 'General' (channel 20)"
+ );
+ assert.strictEqual(
+ document.querySelectorAll('.o_Message').length,
+ 31,
+ "should display 30 messages from channel 'General' (only fetched ones)"
+ );
+ assert.strictEqual(
+ document.querySelectorAll(`
+ .o_Message[data-message-local-id="${
+ this.env.models['mail.message'].find(message => message.id === 100).localId
+ }"]
+ `).length,
+ 1,
+ "should not display 1st message of 'General' as needaction from inbox"
+ );
+
+ await afterNextRender(() => {
+ document.querySelector(`.o_Discuss_thread .o_ThreadViewer_messageList`).scrollTop = 0;
+ });
+ assert.verifySteps(
+ ['message_fetch:load_more_channel_20'],
+ "should have fetched more messages from channel 'General' (channel 20)"
+ );
+ assert.strictEqual(
+ document.querySelectorAll('.o_Message').length,
+ 50,
+ "should display 50 messages from channel 'General' (all fetched)"
+ );
+ assert.strictEqual(
+ document.querySelectorAll(`
+ .o_Message[data-message-local-id="${
+ this.env.models['mail.message'].find(message => message.id === 100).localId
+ }"]
+ `).length,
+ 1,
+ "should include 1st message of 'General' as needaction from inbox in the fetched more messages"
+ );
+});
+
+QUnit.test('messages marked as read move to "History" mailbox', async function (assert) {
+ assert.expect(10);
+
+ const self = this;
+ this.data['mail.message'].records = [{
+ author_id: [5, 'Demo User'],
+ body: '<p>test 1</p>',
+ id: 1,
+ needaction: true,
+ needaction_partner_ids: [3],
+ }, {
+ author_id: [6, 'Test User'],
+ body: '<p>test 2</p>',
+ id: 2,
+ needaction: true,
+ needaction_partner_ids: [3],
+ }];
+ this.data['mail.notification'].records = [{
+ id: 50,
+ is_read: false,
+ mail_message_id: 1,
+ res_partner_id: 3,
+ }, {
+ id: 51,
+ is_read: false,
+ mail_message_id: 1,
+ res_partner_id: 3,
+ }];
+ this.data.initMessaging.needaction_inbox_counter = 2;
+ await this.start({
+ discuss: {
+ params: {
+ default_active_id: 'mail.box_history',
+ },
+ },
+ async mockRPC(route, args) {
+ if (args.method === 'mark_all_as_read') {
+ for (const message of this.data['mail.message'].records) {
+ message.history_partner_ids = [3];
+ message.needaction_partner_ids = [];
+ }
+ const notificationData = {
+ type: 'mark_as_read',
+ message_ids: [1, 2],
+ };
+ const notification = [[false, 'res.partner', 3], notificationData];
+ self.widget.call('bus_service', 'trigger', 'notification', [notification]);
+ return 3;
+ }
+ return this._super(...arguments);
+ },
+ });
+ assert.ok(
+ document.querySelector(`
+ .o_DiscussSidebar_item[data-thread-local-id="${
+ this.env.messaging.history.localId
+ }"]
+ `).classList.contains('o-active'),
+ "History mailbox should be active thread"
+ );
+ assert.strictEqual(
+ document.querySelectorAll(`.o_Discuss_thread .o_MessageList_empty`).length,
+ 1,
+ "should have empty thread in history"
+ );
+
+ await afterNextRender(() =>
+ document.querySelector(`
+ .o_DiscussSidebar_item[data-thread-local-id="${
+ this.env.messaging.inbox.localId
+ }"]
+ `).click()
+ );
+ assert.ok(
+ document.querySelector(`
+ .o_DiscussSidebar_item[data-thread-local-id="${
+ this.env.messaging.inbox.localId
+ }"]
+ `).classList.contains('o-active'),
+ "Inbox mailbox should be active thread"
+ );
+ assert.strictEqual(
+ document.querySelectorAll(`.o_Discuss_thread .o_MessageList_empty`).length,
+ 0,
+ "Inbox mailbox should not be empty"
+ );
+ assert.strictEqual(
+ document.querySelectorAll(`.o_Discuss_thread .o_MessageList_message`).length,
+ 2,
+ "Inbox mailbox should have 2 messages"
+ );
+
+ await afterNextRender(() =>
+ document.querySelector('.o_widget_Discuss_controlPanelButtonMarkAllRead').click()
+ );
+ assert.ok(
+ document.querySelector(`
+ .o_DiscussSidebar_item[data-thread-local-id="${
+ this.env.messaging.inbox.localId
+ }"]
+ `).classList.contains('o-active'),
+ "Inbox mailbox should still be active after mark as read"
+ );
+ assert.strictEqual(
+ document.querySelectorAll(`.o_Discuss_thread .o_MessageList_empty`).length,
+ 1,
+ "Inbox mailbox should now be empty after mark as read"
+ );
+
+ await afterNextRender(() =>
+ document.querySelector(`
+ .o_DiscussSidebar_item[data-thread-local-id="${
+ this.env.messaging.history.localId
+ }"]
+ `).click()
+ );
+ assert.ok(
+ document.querySelector(`
+ .o_DiscussSidebar_item[data-thread-local-id="${
+ this.env.messaging.history.localId
+ }"]
+ `).classList.contains('o-active'),
+ "History mailbox should be active"
+ );
+ assert.strictEqual(
+ document.querySelectorAll(`.o_Discuss_thread .o_MessageList_empty`).length,
+ 0,
+ "History mailbox should not be empty after mark as read"
+ );
+ assert.strictEqual(
+ document.querySelectorAll(`.o_Discuss_thread .o_MessageList_message`).length,
+ 2,
+ "History mailbox should have 2 messages"
+ );
+});
+
+QUnit.test('all messages in "Inbox" in "History" after marked all as read', async function (assert) {
+ assert.expect(10);
+
+ const self = this;
+ const messagesData = [];
+ const messageOffset = 200;
+ const partnerOffset = 100;
+ for (let i = messageOffset; i < messageOffset + 40; i++) {
+ messagesData.push({
+ author_id: [partnerOffset + i, 'User ' + (partnerOffset + i)],
+ body: '<p>test ' + i + '</p>',
+ id: i,
+ needaction: true,
+ needaction_partner_ids: [3],
+ });
+ }
+
+ this.data['mail.message'].records = messagesData;
+
+ let messageFetchCount = 0;
+ const initDef = makeTestPromise();
+ const markAllReadDef = makeTestPromise();
+ const clickHistoryDef = makeTestPromise();
+ const loadMoreDef = makeTestPromise();
+
+ await this.start({
+ async mockRPC(route, args) {
+ if (args.method === 'mark_all_as_read') {
+ const messageIDs = [];
+ for (let i = 0; i < messagesData.length; i++) {
+ this.data['mail.message'].records[i].history_partner_ids = [3];
+ this.data['mail.message'].records[i].needaction_partner_ids = [];
+ this.data['mail.message'].records[i].needaction = false;
+ messageIDs.push(messageOffset + i);
+ }
+ const notificationData = {
+ type: 'mark_as_read',
+ message_ids: messageIDs,
+ };
+ const notification = [[false, 'res.partner', 3], notificationData];
+ self.widget.call('bus_service', 'trigger', 'notification', [notification]);
+ markAllReadDef.resolve();
+ return 3;
+ }
+ if (args.method === 'message_fetch') {
+ // 1st message_fetch: 'Inbox' initially
+ // 2nd message_fetch: 'History' initially
+ // 3rd message_fetch: 'History' load more
+ assert.step(args.method);
+
+ messageFetchCount++;
+ if (messageFetchCount === 1) {
+ initDef.resolve();
+ }
+ if (messageFetchCount === 2) {
+ clickHistoryDef.resolve();
+ }
+ if (messageFetchCount === 3) {
+ loadMoreDef.resolve();
+ }
+ }
+ return this._super(...arguments);
+ },
+ });
+
+ await initDef;
+ assert.verifySteps(
+ ['message_fetch'],
+ "should fetch messages once for needaction messages (Inbox)"
+ );
+ assert.containsN(
+ document.body,
+ '.o_Message',
+ 30,
+ "there should be 30 messages that are loaded in Inbox"
+ );
+
+ await afterNextRender(async () => {
+ const markAllReadButton = document.querySelector('.o_widget_Discuss_controlPanelButtonMarkAllRead');
+ markAllReadButton.click();
+ await markAllReadDef;
+ });
+ assert.containsNone(
+ document.body,
+ '.o_Message',
+ "there should no message in inbox anymore"
+ );
+
+ await afterNextRender(async () => {
+ document.querySelector(`
+ .o_DiscussSidebarItem[data-thread-local-id="${
+ this.env.messaging.history.localId
+ }"]
+ `).click();
+ await clickHistoryDef;
+ });
+ assert.verifySteps(
+ ['message_fetch'],
+ "should fetch messages once for history"
+ );
+ assert.containsN(
+ document.body,
+ '.o_Message',
+ 30,
+ "there should be 30 messages in History"
+ );
+
+ // simulate a scroll to top to load more messages
+ await afterNextRender(async () => {
+ document.querySelector('.o_MessageList').scrollTop = 0;
+ await loadMoreDef;
+ });
+ assert.verifySteps(
+ ['message_fetch'],
+ "should fetch more messages in history for loadMore"
+ );
+ assert.containsN(
+ document.body,
+ '.o_Message',
+ 40,
+ "there should be 40 messages in History"
+ );
+});
+
+QUnit.test('receive new channel message: out of odoo focus (notification, channel)', async function (assert) {
+ assert.expect(4);
+
+ Object.assign(this.data.initMessaging, {
+ channel_slots: {
+ channel_channel: [{
+ channel_type: "channel",
+ id: 20,
+ message_unread_counter: 0,
+ name: "General",
+ }],
+ },
+ });
+ const bus = new Bus();
+ bus.on('set_title_part', null, payload => {
+ assert.step('set_title_part');
+ assert.strictEqual(payload.part, '_chat');
+ assert.strictEqual(payload.title, "1 Message");
+ });
+
+ await this.start({
+ env: { bus },
+ services: {
+ bus_service: BusService.extend({
+ _beep() {}, // Do nothing
+ _poll() {}, // Do nothing
+ _registerWindowUnload() {}, // Do nothing
+ isOdooFocused: () => false,
+ updateOption() {},
+ }),
+ },
+ });
+
+ // simulate receiving a new message with odoo focused
+ await afterNextRender(() => {
+ const messageData = {
+ author_id: [7, "Demo User"],
+ body: "<p>Test</p>",
+ channel_ids: [20],
+ date: "2019-04-20 10:00:00",
+ id: 126,
+ message_type: 'comment',
+ model: 'mail.channel',
+ record_name: 'General',
+ res_id: 20,
+ };
+ const notifications = [[['my-db', 'mail.channel', 20], messageData]];
+ this.widget.call('bus_service', 'trigger', 'notification', notifications);
+ });
+ assert.verifySteps(['set_title_part']);
+});
+
+QUnit.test('receive new channel message: out of odoo focus (notification, chat)', async function (assert) {
+ assert.expect(4);
+
+ Object.assign(this.data.initMessaging, {
+ channel_slots: {
+ channel_direct_message: [{
+ channel_type: "chat",
+ direct_partner: [{
+ id: 7,
+ name: "Demo User",
+ }],
+ id: 10,
+ message_unread_counter: 0,
+ }],
+ },
+ });
+ const bus = new Bus();
+ bus.on('set_title_part', null, payload => {
+ assert.step('set_title_part');
+ assert.strictEqual(payload.part, '_chat');
+ assert.strictEqual(payload.title, "1 Message");
+ });
+
+ await this.start({
+ env: { bus },
+ services: {
+ bus_service: BusService.extend({
+ _beep() {}, // Do nothing
+ _poll() {}, // Do nothing
+ _registerWindowUnload() {}, // Do nothing
+ isOdooFocused: () => false,
+ updateOption() {},
+ }),
+ },
+ });
+
+ // simulate receiving a new message with odoo focused
+ await afterNextRender(() => {
+ const messageData = {
+ author_id: [7, "Demo User"],
+ body: "<p>Test</p>",
+ channel_ids: [10],
+ date: "2019-04-20 10:00:00",
+ id: 126,
+ message_type: 'comment',
+ model: 'mail.channel',
+ record_name: 'General',
+ res_id: 10,
+ };
+ const notifications = [[['my-db', 'mail.channel', 10], messageData]];
+ this.widget.call('bus_service', 'trigger', 'notification', notifications);
+ });
+ assert.verifySteps(['set_title_part']);
+});
+
+QUnit.test('receive new channel messages: out of odoo focus (tab title)', async function (assert) {
+ assert.expect(12);
+
+ let step = 0;
+ Object.assign(this.data.initMessaging, {
+ channel_slots: {
+ channel_channel: [{
+ channel_type: "channel",
+ id: 20,
+ message_unread_counter: 0,
+ name: "General",
+ }],
+ channel_direct_message: [{
+ channel_type: "chat",
+ direct_partner: [{
+ id: 7,
+ name: "Demo User",
+ }],
+ id: 10,
+ message_unread_counter: 0,
+ }],
+ },
+ });
+ const bus = new Bus();
+ bus.on('set_title_part', null, payload => {
+ step++;
+ assert.step('set_title_part');
+ assert.strictEqual(payload.part, '_chat');
+ if (step === 1) {
+ assert.strictEqual(payload.title, "1 Message");
+ }
+ if (step === 2) {
+ assert.strictEqual(payload.title, "2 Messages");
+ }
+ if (step === 3) {
+ assert.strictEqual(payload.title, "3 Messages");
+ }
+ });
+
+ await this.start({
+ env: { bus },
+ services: {
+ bus_service: BusService.extend({
+ _beep() {}, // Do nothing
+ _poll() {}, // Do nothing
+ _registerWindowUnload() {}, // Do nothing
+ isOdooFocused: () => false,
+ updateOption() {},
+ }),
+ },
+ });
+
+ // simulate receiving a new message in general with odoo focused
+ await afterNextRender(() => {
+ const messageData1 = {
+ author_id: [7, "Demo User"],
+ body: "<p>Test1</p>",
+ channel_ids: [20],
+ date: "2019-04-20 10:00:00",
+ id: 126,
+ message_type: 'comment',
+ model: 'mail.channel',
+ record_name: 'General',
+ res_id: 20,
+ };
+ const notifications1 = [[['my-db', 'mail.channel', 20], messageData1]];
+ this.widget.call('bus_service', 'trigger', 'notification', notifications1);
+ });
+ assert.verifySteps(['set_title_part']);
+
+ // simulate receiving a new message in chat with odoo focused
+ await afterNextRender(() => {
+ const messageData2 = {
+ author_id: [7, "Demo User"],
+ body: "<p>Test2</p>",
+ channel_ids: [10],
+ date: "2019-04-20 10:00:00",
+ id: 127,
+ message_type: 'comment',
+ model: 'mail.channel',
+ record_name: 'General',
+ res_id: 10,
+ };
+ const notifications2 = [[['my-db', 'mail.channel', 10], messageData2]];
+ this.widget.call('bus_service', 'trigger', 'notification', notifications2);
+ });
+ assert.verifySteps(['set_title_part']);
+
+ // simulate receiving another new message in chat with odoo focused
+ await afterNextRender(() => {
+ const messageData3 = {
+ author_id: [7, "Demo User"],
+ body: "<p>Test3</p>",
+ channel_ids: [10],
+ date: "2019-04-20 10:00:00",
+ id: 128,
+ message_type: 'comment',
+ model: 'mail.channel',
+ record_name: 'General',
+ res_id: 10,
+ };
+ const notifications3 = [[['my-db', 'mail.channel', 20], messageData3]];
+ this.widget.call('bus_service', 'trigger', 'notification', notifications3);
+ });
+ assert.verifySteps(['set_title_part']);
+});
+
+QUnit.test('auto-focus composer on opening thread', async function (assert) {
+ assert.expect(14);
+
+ Object.assign(this.data.initMessaging, {
+ channel_slots: {
+ channel_channel: [{
+ channel_type: "channel",
+ id: 20,
+ message_unread_counter: 0,
+ name: "General",
+ }],
+ channel_direct_message: [{
+ channel_type: "chat",
+ direct_partner: [{
+ id: 7,
+ name: "Demo User",
+ }],
+ id: 10,
+ message_unread_counter: 0,
+ }],
+ },
+ });
+ await this.start();
+ assert.strictEqual(
+ document.querySelectorAll(`
+ .o_DiscussSidebar_item[data-thread-name="Inbox"]
+ `).length,
+ 1,
+ "should have mailbox 'Inbox' in the sidebar"
+ );
+ assert.ok(
+ document.querySelector(`
+ .o_DiscussSidebar_item[data-thread-name="Inbox"]
+ `).classList.contains('o-active'),
+ "mailbox 'Inbox' should be active initially"
+ );
+ assert.strictEqual(
+ document.querySelectorAll(`
+ .o_DiscussSidebar_item[data-thread-name="General"]
+ `).length,
+ 1,
+ "should have channel 'General' in the sidebar"
+ );
+ assert.notOk(
+ document.querySelector(`
+ .o_DiscussSidebar_item[data-thread-name="General"]
+ `).classList.contains('o-active'),
+ "channel 'General' should not be active initially"
+ );
+ assert.strictEqual(
+ document.querySelectorAll(`
+ .o_DiscussSidebar_item[data-thread-name="Demo User"]
+ `).length,
+ 1,
+ "should have chat 'Demo User' in the sidebar"
+ );
+ assert.notOk(
+ document.querySelector(`
+ .o_DiscussSidebar_item[data-thread-name="Demo User"]
+ `).classList.contains('o-active'),
+ "chat 'Demo User' should not be active initially"
+ );
+ assert.strictEqual(
+ document.querySelectorAll(`.o_Composer`).length,
+ 0,
+ "there should be no composer when active thread of discuss is mailbox 'Inbox'"
+ );
+
+ await afterNextRender(() =>
+ document.querySelector(`.o_DiscussSidebar_item[data-thread-name="General"]`).click()
+ );
+ assert.ok(
+ document.querySelector(`
+ .o_DiscussSidebar_item[data-thread-name="General"]
+ `).classList.contains('o-active'),
+ "channel 'General' should become active after selecting it from the sidebar"
+ );
+ assert.strictEqual(
+ document.querySelectorAll(`.o_Composer`).length,
+ 1,
+ "there should be a composer when active thread of discuss is channel 'General'"
+ );
+ assert.strictEqual(
+ document.activeElement,
+ document.querySelector(`.o_ComposerTextInput_textarea`),
+ "composer of channel 'General' should be automatically focused on opening"
+ );
+
+ document.querySelector(`.o_ComposerTextInput_textarea`).blur();
+ assert.notOk(
+ document.activeElement === document.querySelector(`.o_ComposerTextInput_textarea`),
+ "composer of channel 'General' should no longer focused on click away"
+ );
+
+ await afterNextRender(() =>
+ document.querySelector(`.o_DiscussSidebar_item[data-thread-name="Demo User"]`).click()
+ );
+ assert.ok(
+ document.querySelector(`
+ .o_DiscussSidebar_item[data-thread-name="Demo User"]
+ `).classList.contains('o-active'),
+ "chat 'Demo User' should become active after selecting it from the sidebar"
+ );
+ assert.strictEqual(
+ document.querySelectorAll(`.o_Composer`).length,
+ 1,
+ "there should be a composer when active thread of discuss is chat 'Demo User'"
+ );
+ assert.strictEqual(
+ document.activeElement,
+ document.querySelector(`.o_ComposerTextInput_textarea`),
+ "composer of chat 'Demo User' should be automatically focused on opening"
+ );
+});
+
+QUnit.test('moderation: moderated channel with pending moderation message', async function (assert) {
+ assert.expect(37);
+
+ Object.assign(this.data.initMessaging, {
+ channel_slots: {
+ channel_channel: [{
+ id: 20,
+ channel_type: "channel",
+ name: "general",
+ moderation: true,
+ }],
+ },
+ is_moderator: true,
+ moderation_counter: 1,
+ moderation_channel_ids: [20],
+ });
+ this.data['mail.message'].records = [{
+ author_id: [2, "Someone"],
+ body: "<p>test</p>",
+ channel_ids: [20],
+ id: 100,
+ model: 'mail.channel',
+ moderation_status: 'pending_moderation',
+ need_moderation: true,
+ res_id: 20,
+ }];
+
+ await this.start();
+
+ assert.ok(
+ document.querySelector(`
+ .o_DiscussSidebar_item[data-thread-local-id="${
+ this.env.messaging.moderation.localId
+ }"]
+ `),
+ "should display the moderation box"
+ );
+ const mailboxCounter = document.querySelector(`
+ .o_DiscussSidebar_item[data-thread-local-id="${
+ this.env.messaging.moderation.localId
+ }"]
+ .o_DiscussSidebarItem_counter
+ `);
+ assert.ok(
+ mailboxCounter,
+ "there should be a counter next to the moderation mailbox in the sidebar"
+ );
+ assert.strictEqual(
+ mailboxCounter.textContent.trim(),
+ "1",
+ "the mailbox counter of the moderation mailbox should display '1'"
+ );
+
+ // 1. go to moderation mailbox
+ await afterNextRender(() =>
+ document.querySelector(`
+ .o_DiscussSidebar_item[data-thread-local-id="${
+ this.env.messaging.moderation.localId
+ }"]
+ `).click()
+ );
+ // check message
+ assert.containsOnce(
+ document.body,
+ '.o_Message',
+ "should be only one message in moderation box"
+ );
+ assert.strictEqual(
+ document.querySelector('.o_Message_content').textContent,
+ "test",
+ "this message pending moderation should have the correct content"
+ );
+ assert.containsOnce(
+ document.body,
+ '.o_Message_originThreadLink',
+ "thee message should have one origin"
+ );
+ assert.strictEqual(
+ document.querySelector('.o_Message_originThreadLink').textContent,
+ "#general",
+ "the message pending moderation should have correct origin as its linked document"
+ );
+ assert.containsOnce(
+ document.body,
+ '.o_Message_checkbox',
+ "there should be a moderation checkbox next to the message"
+ );
+ assert.notOk(
+ document.querySelector('.o_Message_checkbox').checked,
+ "the moderation checkbox should be unchecked by default"
+ );
+ // check select all (enabled) / unselect all (disabled) buttons
+ assert.containsOnce(
+ document.body,
+ '.o_widget_Discuss_controlPanelButtonSelectAll',
+ "there should be a 'Select All' button in the control panel"
+ );
+ assert.doesNotHaveClass(
+ document.querySelector('.o_widget_Discuss_controlPanelButtonSelectAll'),
+ 'disabled',
+ "the 'Select All' button should not be disabled"
+ );
+ assert.containsOnce(
+ document.body,
+ '.o_widget_Discuss_controlPanelButtonUnselectAll',
+ "there should be a 'Unselect All' button in the control panel"
+ );
+ assert.hasClass(
+ document.querySelector('.o_widget_Discuss_controlPanelButtonUnselectAll'),
+ 'disabled',
+ "the 'Unselect All' button should be disabled"
+ );
+ // check moderate all buttons (invisible)
+ assert.containsN(
+ document.body,
+ '.o_widget_Discuss_controlPanelButtonModeration',
+ 3,
+ "there should be 3 buttons to moderate selected messages in the control panel"
+ );
+ assert.containsOnce(
+ document.body,
+ '.o_widget_Discuss_controlPanelButtonModeration.o-accept',
+ "there should one moderate button to accept messages pending moderation"
+ );
+ assert.isNotVisible(
+ document.querySelector('.o_widget_Discuss_controlPanelButtonModeration.o-accept'),
+ "the moderate button 'Accept' should be invisible by default"
+ );
+ assert.containsOnce(
+ document.body,
+ '.o_widget_Discuss_controlPanelButtonModeration.o-reject',
+ "there should one moderate button to reject messages pending moderation"
+ );
+ assert.isNotVisible(
+ document.querySelector('.o_widget_Discuss_controlPanelButtonModeration.o-reject'),
+ "the moderate button 'Reject' should be invisible by default"
+ );
+ assert.containsOnce(
+ document.body,
+ '.o_widget_Discuss_controlPanelButtonModeration.o-discard',
+ "there should one moderate button to discard messages pending moderation"
+ );
+ assert.isNotVisible(
+ document.querySelector('.o_widget_Discuss_controlPanelButtonModeration.o-discard'),
+ "the moderate button 'Discard' should be invisible by default"
+ );
+
+ // click on message moderation checkbox
+ await afterNextRender(() => document.querySelector('.o_Message_checkbox').click());
+ assert.ok(
+ document.querySelector('.o_Message_checkbox').checked,
+ "the moderation checkbox should become checked after click"
+ );
+ // check select all (disabled) / unselect all buttons (enabled)
+ assert.hasClass(
+ document.querySelector('.o_widget_Discuss_controlPanelButtonSelectAll'),
+ 'disabled',
+ "the 'Select All' button should be disabled"
+ );
+ assert.doesNotHaveClass(
+ document.querySelector('.o_widget_Discuss_controlPanelButtonUnselectAll'),
+ 'disabled',
+ "the 'Unselect All' button should not be disabled"
+ );
+ // check moderate all buttons updated (visible)
+ assert.isVisible(
+ document.querySelector('.o_widget_Discuss_controlPanelButtonModeration.o-accept'),
+ "the moderate button 'Accept' should be visible"
+ );
+ assert.isVisible(
+ document.querySelector('.o_widget_Discuss_controlPanelButtonModeration.o-reject'),
+ "the moderate button 'Reject' should be visible"
+ );
+ assert.isVisible(
+ document.querySelector('.o_widget_Discuss_controlPanelButtonModeration.o-discard'),
+ "the moderate button 'Discard' should be visible"
+ );
+
+ // test select buttons
+ await afterNextRender(() =>
+ document.querySelector('.o_widget_Discuss_controlPanelButtonUnselectAll').click()
+ );
+ assert.notOk(
+ document.querySelector('.o_Message_checkbox').checked,
+ "the moderation checkbox should become unchecked after click"
+ );
+
+ await afterNextRender(() =>
+ document.querySelector('.o_widget_Discuss_controlPanelButtonSelectAll').click()
+ );
+ assert.ok(
+ document.querySelector('.o_Message_checkbox').checked,
+ "the moderation checkbox should become checked again after click"
+ );
+
+ // 2. go to channel 'general'
+ await afterNextRender(() =>
+ document.querySelector(`
+ .o_DiscussSidebar_item[data-thread-local-id="${
+ this.env.models['mail.thread'].find(thread =>
+ thread.id === 20 &&
+ thread.model === 'mail.channel'
+ ).localId
+ }"]
+ `).click()
+ );
+ // check correct message
+ assert.containsOnce(
+ document.body,
+ '.o_Message',
+ "should be only one message in general channel"
+ );
+ assert.containsOnce(
+ document.body,
+ '.o_Message_checkbox',
+ "there should be a moderation checkbox next to the message"
+ );
+ assert.notOk(
+ document.querySelector('.o_Message_checkbox').checked,
+ "the moderation checkbox should not be checked here"
+ );
+ await afterNextRender(() => document.querySelector('.o_Message_checkbox').click());
+ // Don't test moderation actions visibility, since it is similar to moderation box.
+
+ // 3. test discard button
+ await afterNextRender(() =>
+ document.querySelector('.o_widget_Discuss_controlPanelButtonModeration.o-discard').click()
+ );
+ assert.containsOnce(
+ document.body,
+ '.o_ModerationDiscardDialog',
+ "discard dialog should be open"
+ );
+ // the dialog will be tested separately
+ await afterNextRender(() =>
+ document.querySelector('.o_ModerationDiscardDialog .o-cancel').click()
+ );
+ assert.containsNone(
+ document.body,
+ '.o_ModerationDiscardDialog',
+ "discard dialog should be closed"
+ );
+
+ // 4. test reject button
+ await afterNextRender(() =>
+ document.querySelector(`
+ .o_widget_Discuss_controlPanelButtonModeration.o-reject
+ `).click()
+ );
+ assert.containsOnce(
+ document.body,
+ '.o_ModerationRejectDialog',
+ "reject dialog should be open"
+ );
+ // the dialog will be tested separately
+ await afterNextRender(() =>
+ document.querySelector('.o_ModerationRejectDialog .o-cancel').click()
+ );
+ assert.containsNone(
+ document.body,
+ '.o_ModerationRejectDialog',
+ "reject dialog should be closed"
+ );
+
+ // 5. test accept button
+ await afterNextRender(() =>
+ document.querySelector('.o_widget_Discuss_controlPanelButtonModeration.o-accept').click()
+ );
+ assert.containsOnce(
+ document.body,
+ '.o_Message',
+ "should still be only one message in general channel"
+ );
+ assert.containsNone(
+ document.body,
+ '.o_Message_checkbox',
+ "there should not be a moderation checkbox next to the message"
+ );
+});
+
+QUnit.test('moderation: accept pending moderation message', async function (assert) {
+ assert.expect(12);
+
+ Object.assign(this.data.initMessaging, {
+ channel_slots: {
+ channel_channel: [{
+ id: 20,
+ channel_type: "channel",
+ name: "general",
+ moderation: true,
+ }],
+ },
+ is_moderator: true,
+ moderation_counter: 1,
+ moderation_channel_ids: [20],
+ });
+ this.data['mail.message'].records = [{
+ author_id: [2, "Someone"],
+ body: "<p>test</p>",
+ id: 100,
+ model: 'mail.channel',
+ moderation_status: 'pending_moderation',
+ need_moderation: true,
+ res_id: 20,
+ }];
+
+ await this.start({
+ async mockRPC(route, args) {
+ if (args.method === 'moderate') {
+ assert.step('moderate');
+ const messageIDs = args.args[0];
+ const decision = args.args[1];
+ assert.strictEqual(
+ messageIDs.length,
+ 1,
+ "should moderate one message"
+ );
+ assert.strictEqual(
+ messageIDs[0],
+ 100,
+ "should moderate message with ID 100"
+ );
+ assert.strictEqual(
+ decision,
+ 'accept',
+ "should accept the message"
+ );
+ }
+ return this._super(...arguments);
+ },
+ });
+
+ // 1. go to moderation box
+ const moderationBox = document.querySelector(`
+ .o_DiscussSidebar_item[data-thread-local-id="${
+ this.env.messaging.moderation.localId
+ }"]
+ `);
+ assert.ok(
+ moderationBox,
+ "should display the moderation box"
+ );
+
+ await afterNextRender(() => moderationBox.click());
+ assert.ok(
+ document.querySelector(`
+ .o_Message[data-message-local-id="${
+ this.env.models['mail.message'].find(message => message.id === 100).localId
+ }"]
+ `),
+ "should display the message to moderate"
+ );
+ const acceptButton = document.querySelector(`
+ .o_Message[data-message-local-id="${
+ this.env.models['mail.message'].find(message => message.id === 100).localId
+ }"]
+ .o_Message_moderationAction.o-accept
+ `);
+ assert.ok(acceptButton, "should display the accept button");
+
+ await afterNextRender(() => acceptButton.click());
+ assert.verifySteps(['moderate']);
+ assert.containsOnce(
+ document.body,
+ '.o_MessageList_emptyTitle',
+ "should now have no message displayed in moderation box"
+ );
+
+ // 2. go to channel 'general'
+ const channel = document.querySelector(`
+ .o_DiscussSidebar_item[data-thread-local-id="${
+ this.env.models['mail.thread'].find(thread =>
+ thread.id === 20 &&
+ thread.model === 'mail.channel'
+ ).localId
+ }"]
+ `);
+ assert.ok(
+ channel,
+ "should display the general channel"
+ );
+
+ await afterNextRender(() => channel.click());
+ const message = document.querySelector(`
+ .o_Message[data-message-local-id="${
+ this.env.models['mail.message'].find(message => message.id === 100).localId
+ }"]
+ `);
+ assert.ok(
+ message,
+ "should display the accepted message"
+ );
+ assert.containsNone(
+ message,
+ '.o_Message_moderationPending',
+ "the message should not be pending moderation"
+ );
+});
+
+QUnit.test('moderation: reject pending moderation message (reject with explanation)', async function (assert) {
+ assert.expect(23);
+
+ Object.assign(this.data.initMessaging, {
+ channel_slots: {
+ channel_channel: [{
+ id: 20,
+ channel_type: "channel",
+ name: "general",
+ moderation: true,
+ }],
+ },
+ is_moderator: true,
+ moderation_counter: 1,
+ moderation_channel_ids: [20],
+ });
+ this.data['mail.message'].records = [{
+ author_id: [2, "Someone"],
+ body: "<p>test</p>",
+ id: 100,
+ model: 'mail.channel',
+ moderation_status: 'pending_moderation',
+ need_moderation: true,
+ res_id: 20,
+ }];
+
+ await this.start({
+ async mockRPC(route, args) {
+ if (args.method === 'moderate') {
+ assert.step('moderate');
+ const messageIDs = args.args[0];
+ const decision = args.args[1];
+ const kwargs = args.kwargs;
+ assert.strictEqual(
+ messageIDs.length,
+ 1,
+ "should moderate one message"
+ );
+ assert.strictEqual(
+ messageIDs[0],
+ 100,
+ "should moderate message with ID 100"
+ );
+ assert.strictEqual(
+ decision,
+ 'reject',
+ "should reject the message"
+ );
+ assert.strictEqual(
+ kwargs.title,
+ "Message Rejected",
+ "should have correct reject message title"
+ );
+ assert.strictEqual(
+ kwargs.comment,
+ "Your message was rejected by moderator.",
+ "should have correct reject message body / comment"
+ );
+ }
+ return this._super(...arguments);
+ },
+ });
+
+ // 1. go to moderation box
+ const moderationBox = document.querySelector(`
+ .o_DiscussSidebar_item[data-thread-local-id="${
+ this.env.messaging.moderation.localId
+ }"]
+ `);
+ assert.ok(
+ moderationBox,
+ "should display the moderation box"
+ );
+
+ await afterNextRender(() => moderationBox.click());
+ const pendingMessage = document.querySelector(`
+ .o_Message[data-message-local-id="${
+ this.env.models['mail.message'].find(message => message.id === 100).localId
+ }"]
+ `);
+ assert.ok(
+ pendingMessage,
+ "should display the message to moderate"
+ );
+ const rejectButton = pendingMessage.querySelector(':scope .o_Message_moderationAction.o-reject');
+ assert.ok(
+ rejectButton,
+ "should display the reject button"
+ );
+
+ await afterNextRender(() => rejectButton.click());
+ const dialog = document.querySelector('.o_ModerationRejectDialog');
+ assert.ok(
+ dialog,
+ "a dialog should be prompt to the moderator on click reject"
+ );
+ assert.strictEqual(
+ dialog.querySelector('.modal-title').textContent,
+ // TODO FIXME, this should be a proper title "Send explanation to author"
+ // see https://github.com/odoo/owl/issues/670
+ "[object Object]",
+ "dialog should have correct title"
+ );
+
+ const messageTitle = dialog.querySelector(':scope .o_ModerationRejectDialog_title');
+ assert.ok(
+ messageTitle,
+ "should have a title for rejecting"
+ );
+ assert.hasAttrValue(
+ messageTitle,
+ 'placeholder',
+ "Subject",
+ "title for reject reason should have correct placeholder"
+ );
+ assert.strictEqual(
+ messageTitle.value,
+ "Message Rejected",
+ "title for reject reason should have correct default value"
+ );
+
+ const messageComment = dialog.querySelector(':scope .o_ModerationRejectDialog_comment');
+ assert.ok(
+ messageComment,
+ "should have a comment for rejecting"
+ );
+ assert.hasAttrValue(
+ messageComment,
+ 'placeholder',
+ "Mail Body",
+ "comment for reject reason should have correct placeholder"
+ );
+ assert.strictEqual(
+ messageComment.value,
+ "Your message was rejected by moderator.",
+ "comment for reject reason should have correct default text content"
+ );
+ const confirmReject = dialog.querySelector(':scope .o-reject');
+ assert.ok(
+ confirmReject,
+ "should have reject button"
+ );
+ assert.strictEqual(
+ confirmReject.textContent,
+ "Reject"
+ );
+
+ await afterNextRender(() => confirmReject.click());
+ assert.verifySteps(['moderate']);
+ assert.containsOnce(
+ document.body,
+ '.o_MessageList_emptyTitle',
+ "should now have no message displayed in moderation box"
+ );
+
+ // 2. go to channel 'general'
+ const channel = document.querySelector(`
+ .o_DiscussSidebar_item[data-thread-local-id="${
+ this.env.models['mail.thread'].find(thread =>
+ thread.id === 20 &&
+ thread.model === 'mail.channel'
+ ).localId
+ }"]
+ `);
+ assert.ok(
+ channel,
+ 'should display the general channel'
+ );
+
+ await afterNextRender(() => channel.click());
+ assert.containsNone(
+ document.body,
+ '.o_Message',
+ "should now have no message in channel"
+ );
+});
+
+QUnit.test('moderation: discard pending moderation message (reject without explanation)', async function (assert) {
+ assert.expect(16);
+
+ Object.assign(this.data.initMessaging, {
+ channel_slots: {
+ channel_channel: [{
+ id: 20,
+ channel_type: "channel",
+ name: "general",
+ moderation: true,
+ }],
+ },
+ is_moderator: true,
+ moderation_counter: 1,
+ moderation_channel_ids: [20],
+ });
+ this.data['mail.message'].records = [{
+ author_id: [2, "Someone"],
+ body: "<p>test</p>",
+ id: 100,
+ model: 'mail.channel',
+ moderation_status: 'pending_moderation',
+ need_moderation: true,
+ res_id: 20,
+ }];
+
+ await this.start({
+ async mockRPC(route, args) {
+ if (args.method === 'moderate') {
+ assert.step('moderate');
+ const messageIDs = args.args[0];
+ const decision = args.args[1];
+ assert.strictEqual(messageIDs.length, 1, "should moderate one message");
+ assert.strictEqual(messageIDs[0], 100, "should moderate message with ID 100");
+ assert.strictEqual(decision, 'discard', "should discard the message");
+ }
+ return this._super(...arguments);
+ },
+ });
+
+ // 1. go to moderation box
+ const moderationBox = document.querySelector(`
+ .o_DiscussSidebar_item[data-thread-local-id="${
+ this.env.messaging.moderation.localId
+ }"]
+ `);
+ assert.ok(
+ moderationBox,
+ "should display the moderation box"
+ );
+
+ await afterNextRender(() => moderationBox.click());
+ const pendingMessage = document.querySelector(`
+ .o_Message[data-message-local-id="${
+ this.env.models['mail.message'].find(message => message.id === 100).localId
+ }"]
+ `);
+ assert.ok(
+ pendingMessage,
+ "should display the message to moderate"
+ );
+
+ const discardButton = pendingMessage.querySelector(`
+ :scope .o_Message_moderationAction.o-discard
+ `);
+ assert.ok(
+ discardButton,
+ "should display the discard button"
+ );
+
+ await afterNextRender(() => discardButton.click());
+ const dialog = document.querySelector('.o_ModerationDiscardDialog');
+ assert.ok(
+ dialog,
+ "a dialog should be prompt to the moderator on click discard"
+ );
+ assert.strictEqual(
+ dialog.querySelector('.modal-title').textContent,
+ // TODO FIXME, this should be a proper title "Confirmation"
+ // see https://github.com/odoo/owl/issues/670
+ "[object Object]",
+ "dialog should have correct title"
+ );
+ assert.strictEqual(
+ dialog.textContent,
+ "[object Object]×You are going to discard 1 message.Do you confirm the action?DiscardCancel",
+ "should warn the user on discard action"
+ );
+
+ const confirmDiscard = dialog.querySelector(':scope .o-discard');
+ assert.ok(
+ confirmDiscard,
+ "should have discard button"
+ );
+ assert.strictEqual(
+ confirmDiscard.textContent,
+ "Discard"
+ );
+
+ await afterNextRender(() => confirmDiscard.click());
+ assert.verifySteps(['moderate']);
+ assert.containsOnce(
+ document.body,
+ '.o_MessageList_emptyTitle',
+ "should now have no message displayed in moderation box"
+ );
+
+ // 2. go to channel 'general'
+ const channel = document.querySelector(`
+ .o_DiscussSidebar_item[data-thread-local-id="${
+ this.env.models['mail.thread'].find(thread =>
+ thread.id === 20 &&
+ thread.model === 'mail.channel'
+ ).localId
+ }"]
+ `);
+ assert.ok(
+ channel,
+ "should display the general channel"
+ );
+
+ await afterNextRender(() => channel.click());
+ assert.containsNone(
+ document.body,
+ '.o_Message',
+ "should now have no message in channel"
+ );
+});
+
+QUnit.test('moderation: send message in moderated channel', async function (assert) {
+ assert.expect(4);
+
+ const self = this;
+ Object.assign(this.data.initMessaging, {
+ channel_slots: {
+ channel_channel: [{
+ id: 20,
+ channel_type: "channel",
+ name: "general",
+ moderation: true,
+ }],
+ },
+ });
+
+ await this.start({
+ async mockRPC(route, args) {
+ if (args.method === 'message_post') {
+ const message = {
+ id: 100,
+ author_id: [13, 'Someone'],
+ body: args.kwargs.body,
+ channel_ids: [20],
+ message_type: args.kwargs.message_type,
+ model: 'mail.channel',
+ moderation_status: 'pending_moderation',
+ res_id: 20,
+ };
+ const notificationData = {
+ type: 'author',
+ message: message,
+ };
+ const notification = [[false, 'res.partner', 13], notificationData];
+ self.widget.call('bus_service', 'trigger', 'notification', [notification]);
+
+ return message.id;
+ }
+ return this._super(...arguments);
+ },
+ });
+
+ // go to channel 'general'
+ const channel = document.querySelector(`
+ .o_DiscussSidebar_item[data-thread-local-id="${
+ this.env.models['mail.thread'].find(thread =>
+ thread.id === 20 &&
+ thread.model === 'mail.channel'
+ ).localId
+ }"]
+ `);
+ assert.ok(
+ channel,
+ "should display the general channel"
+ );
+
+ await afterNextRender(() => channel.click());
+ assert.containsNone(
+ document.body,
+ '.o_Message',
+ "should have no message in channel"
+ );
+
+ // post a message
+ await afterNextRender(() => {
+ const textInput = document.querySelector('.o_ComposerTextInput_textarea');
+ textInput.focus();
+ document.execCommand('insertText', false, "Some Text");
+ });
+ await afterNextRender(() => document.querySelector('.o_Composer_buttonSend').click());
+ const messagePending = document.querySelector(`
+ .o_Message[data-message-local-id="${
+ this.env.models['mail.message'].find(message => message.id === 100).localId
+ }"]
+ .o_Message_moderationPending
+ `);
+ assert.ok(
+ messagePending,
+ "should display the pending message with pending info"
+ );
+ assert.hasClass(
+ messagePending,
+ 'o-author',
+ "the message should be pending moderation as author"
+ );
+});
+
+QUnit.test('moderation: sent message accepted in moderated channel', async function (assert) {
+ assert.expect(5);
+
+ Object.assign(this.data.initMessaging, {
+ channel_slots: {
+ channel_channel: [{
+ id: 20,
+ channel_type: "channel",
+ name: "general",
+ moderation: true,
+ }],
+ },
+ });
+ this.data['mail.message'].records = [{
+ author_id: [13, "Someone"],
+ body: "<p>test</p>",
+ id: 100,
+ model: 'mail.channel',
+ moderation_status: 'pending_moderation',
+ need_moderation: true,
+ res_id: 20,
+ }];
+
+ await this.start();
+
+ const channel = document.querySelector(`
+ .o_DiscussSidebar_item[data-thread-local-id="${
+ this.env.models['mail.thread'].find(thread =>
+ thread.id === 20 &&
+ thread.model === 'mail.channel'
+ ).localId
+ }"]
+ `);
+ assert.ok(
+ channel,
+ "should display the general channel"
+ );
+
+ await afterNextRender(() => channel.click());
+ const messagePending = document.querySelector(`
+ .o_Message[data-message-local-id="${
+ this.env.models['mail.message'].find(message => message.id === 100).localId
+ }"]
+ .o_Message_moderationPending
+ `);
+ assert.ok(
+ messagePending,
+ "should display the pending message with pending info"
+ );
+ assert.hasClass(
+ messagePending,
+ 'o-author',
+ "the message should be pending moderation as author"
+ );
+
+ // simulate accepted message
+ await afterNextRender(() => {
+ const messageData = {
+ author_id: [13, "Someone"],
+ body: "<p>test</p>",
+ channel_ids: [20],
+ id: 100,
+ model: 'mail.channel',
+ moderation_status: 'accepted',
+ res_id: 20,
+ };
+ const notification = [[false, 'mail.channel', 20], messageData];
+ this.widget.call('bus_service', 'trigger', 'notification', [notification]);
+ });
+
+ // check message is accepted
+ const message = document.querySelector(`
+ .o_Message[data-message-local-id="${
+ this.env.models['mail.message'].find(message => message.id === 100).localId
+ }"]
+ `);
+ assert.ok(
+ message,
+ "should still display the message"
+ );
+ assert.containsNone(
+ message,
+ '.o_Message_moderationPending',
+ "the message should not be in pending moderation anymore"
+ );
+});
+
+QUnit.test('moderation: sent message rejected in moderated channel', async function (assert) {
+ assert.expect(4);
+
+ Object.assign(this.data.initMessaging, {
+ channel_slots: {
+ channel_channel: [{
+ id: 20,
+ channel_type: "channel",
+ name: "general",
+ moderation: true,
+ }],
+ },
+ });
+ this.data['mail.message'].records = [{
+ author_id: [13, "Someone"],
+ body: "<p>test</p>",
+ id: 100,
+ model: 'mail.channel',
+ moderation_status: 'pending_moderation',
+ need_moderation: true,
+ res_id: 20,
+ }];
+
+ await this.start();
+
+ const channel = document.querySelector(`
+ .o_DiscussSidebar_item[data-thread-local-id="${
+ this.env.models['mail.thread'].find(thread =>
+ thread.id === 20 &&
+ thread.model === 'mail.channel'
+ ).localId
+ }"]
+ `);
+ assert.ok(
+ channel,
+ "should display the general channel"
+ );
+
+ await afterNextRender(() => channel.click());
+ const messagePending = document.querySelector(`
+ .o_Message[data-message-local-id="${
+ this.env.models['mail.message'].find(message => message.id === 100).localId
+ }"]
+ .o_Message_moderationPending
+ `);
+ assert.ok(
+ messagePending,
+ "should display the pending message with pending info"
+ );
+ assert.hasClass(
+ messagePending,
+ 'o-author',
+ "the message should be pending moderation as author"
+ );
+
+ // simulate reject from moderator
+ await afterNextRender(() => {
+ const notifData = {
+ type: 'deletion',
+ message_ids: [100],
+ };
+ const notification = [[false, 'res.partner', 13], notifData];
+ this.widget.call('bus_service', 'trigger', 'notification', [notification]);
+ });
+ // check no message
+ assert.containsNone(
+ document.body,
+ '.o_Message',
+ "message should be removed from channel after reject"
+ );
+});
+
+});
+});
+});
+
+});
diff --git a/addons/mail/static/src/components/discuss_mobile_mailbox_selection/discuss_mobile_mailbox_selection.js b/addons/mail/static/src/components/discuss_mobile_mailbox_selection/discuss_mobile_mailbox_selection.js
new file mode 100644
index 00000000000..258d7770066
--- /dev/null
+++ b/addons/mail/static/src/components/discuss_mobile_mailbox_selection/discuss_mobile_mailbox_selection.js
@@ -0,0 +1,89 @@
+odoo.define('mail/static/src/components/discuss_mobile_mailbox_selection/discuss_mobile_mailbox_selection.js', function (require) {
+'use strict';
+
+const useStore = require('mail/static/src/component_hooks/use_store/use_store.js');
+
+const { Component } = owl;
+
+class DiscussMobileMailboxSelection extends Component {
+
+ /**
+ * @override
+ */
+ constructor(...args) {
+ super(...args);
+ useStore(props => {
+ return {
+ allOrderedAndPinnedMailboxes: this.orderedMailboxes.map(mailbox => mailbox.__state),
+ discussThread: this.env.messaging.discuss.thread
+ ? this.env.messaging.discuss.thread.__state
+ : undefined,
+ };
+ }, {
+ compareDepth: {
+ allOrderedAndPinnedMailboxes: 1,
+ },
+ });
+ }
+
+ //--------------------------------------------------------------------------
+ // Public
+ //--------------------------------------------------------------------------
+
+ /**
+ * @returns {mail.thread[]}
+ */
+ get orderedMailboxes() {
+ return this.env.models['mail.thread']
+ .all(thread => thread.isPinned && thread.model === 'mail.box')
+ .sort((mailbox1, mailbox2) => {
+ if (mailbox1 === this.env.messaging.inbox) {
+ return -1;
+ }
+ if (mailbox2 === this.env.messaging.inbox) {
+ return 1;
+ }
+ if (mailbox1 === this.env.messaging.starred) {
+ return -1;
+ }
+ if (mailbox2 === this.env.messaging.starred) {
+ return 1;
+ }
+ const mailbox1Name = mailbox1.displayName;
+ const mailbox2Name = mailbox2.displayName;
+ mailbox1Name < mailbox2Name ? -1 : 1;
+ });
+ }
+
+ /**
+ * @returns {mail.discuss}
+ */
+ get discuss() {
+ return this.env.messaging && this.env.messaging.discuss;
+ }
+
+ //--------------------------------------------------------------------------
+ // Handlers
+ //--------------------------------------------------------------------------
+
+ /**
+ * Called when clicking on a mailbox selection item.
+ *
+ * @private
+ * @param {MouseEvent} ev
+ */
+ _onClick(ev) {
+ const { mailbox } = ev.currentTarget.dataset;
+ this.discuss.threadViewer.update({ thread: [['link', mailbox]] });
+ }
+
+}
+
+Object.assign(DiscussMobileMailboxSelection, {
+ props: {},
+ template: 'mail.DiscussMobileMailboxSelection',
+});
+
+return DiscussMobileMailboxSelection;
+
+});
diff --git a/addons/mail/static/src/components/discuss_mobile_mailbox_selection/discuss_mobile_mailbox_selection.scss b/addons/mail/static/src/components/discuss_mobile_mailbox_selection/discuss_mobile_mailbox_selection.scss
new file mode 100644
index 00000000000..b620e2f1328
--- /dev/null
+++ b/addons/mail/static/src/components/discuss_mobile_mailbox_selection/discuss_mobile_mailbox_selection.scss
@@ -0,0 +1,26 @@
+// ------------------------------------------------------------------
+// Layout
+// ------------------------------------------------------------------
+
+.o_DiscussMobileMailboxSelection {
+ display: flex;
+ flex: 0 0 auto;
+}
+
+.o_DiscussMobileMailboxSelection_button {
+ flex: 1 1 0;
+ padding: 8px;
+ z-index: 1;
+
+ &.o-active {
+ z-index: 2;
+ }
+}
+
+// ------------------------------------------------------------------
+// Style
+// ------------------------------------------------------------------
+
+.o_DiscussMobileMailboxSelection_button {
+ box-shadow: 0 2px 4px gray('400');
+}
diff --git a/addons/mail/static/src/components/discuss_mobile_mailbox_selection/discuss_mobile_mailbox_selection.xml b/addons/mail/static/src/components/discuss_mobile_mailbox_selection/discuss_mobile_mailbox_selection.xml
new file mode 100644
index 00000000000..e996a203966
--- /dev/null
+++ b/addons/mail/static/src/components/discuss_mobile_mailbox_selection/discuss_mobile_mailbox_selection.xml
@@ -0,0 +1,20 @@
+<?xml version="1.0" encoding="UTF-8"?>
+<templates xml:space="preserve">
+
+ <t t-name="mail.DiscussMobileMailboxSelection" owl="1">
+ <div class="o_DiscussMobileMailboxSelection">
+ <t t-foreach="orderedMailboxes" t-as="mailbox" t-key="mailbox.localId">
+ <button class="o_DiscussMobileMailboxSelection_button btn"
+ t-att-class="{
+ 'btn-primary': discuss.thread === mailbox,
+ 'btn-secondary': discuss.thread !== mailbox,
+ 'o-active': discuss.thread === mailbox,
+ }" t-on-click="_onClick" t-att-data-mailbox-local-id="mailbox.localId" type="button"
+ >
+ <t t-esc="mailbox.name"/>
+ </button>
+ </t>
+ </div>
+ </t>
+
+</templates>
diff --git a/addons/mail/static/src/components/discuss_sidebar/discuss_sidebar.js b/addons/mail/static/src/components/discuss_sidebar/discuss_sidebar.js
new file mode 100644
index 00000000000..11737fa04dc
--- /dev/null
+++ b/addons/mail/static/src/components/discuss_sidebar/discuss_sidebar.js
@@ -0,0 +1,307 @@
+odoo.define('mail/static/src/components/discuss_sidebar/discuss_sidebar.js', function (require) {
+'use strict';
+
+const components = {
+ AutocompleteInput: require('mail/static/src/components/autocomplete_input/autocomplete_input.js'),
+ DiscussSidebarItem: require('mail/static/src/components/discuss_sidebar_item/discuss_sidebar_item.js'),
+};
+const useStore = require('mail/static/src/component_hooks/use_store/use_store.js');
+
+const { Component } = owl;
+const { useRef } = owl.hooks;
+
+class DiscussSidebar extends Component {
+
+ /**
+ * @override
+ */
+ constructor(...args) {
+ super(...args);
+ useStore(
+ (...args) => this._useStoreSelector(...args),
+ { compareDepth: () => this._useStoreCompareDepth() }
+ );
+
+ /**
+ * Reference of the quick search input. Useful to filter channels and
+ * chats based on this input content.
+ */
+ this._quickSearchInputRef = useRef('quickSearchInput');
+
+ // bind since passed as props
+ this._onAddChannelAutocompleteSelect = this._onAddChannelAutocompleteSelect.bind(this);
+ this._onAddChannelAutocompleteSource = this._onAddChannelAutocompleteSource.bind(this);
+ this._onAddChatAutocompleteSelect = this._onAddChatAutocompleteSelect.bind(this);
+ this._onAddChatAutocompleteSource = this._onAddChatAutocompleteSource.bind(this);
+ }
+
+ mounted() {
+ this._update();
+ }
+
+ patched() {
+ this._update();
+ }
+
+ //--------------------------------------------------------------------------
+ // Public
+ //--------------------------------------------------------------------------
+
+ /**
+ * @returns {mail.discuss}
+ */
+ get discuss() {
+ return this.env.messaging && this.env.messaging.discuss;
+ }
+
+ /**
+ * @returns {mail.thread[]}
+ */
+ get orderedMailboxes() {
+ return this.env.models['mail.thread']
+ .all(thread => thread.isPinned && thread.model === 'mail.box')
+ .sort((mailbox1, mailbox2) => {
+ if (mailbox1 === this.env.messaging.inbox) {
+ return -1;
+ }
+ if (mailbox2 === this.env.messaging.inbox) {
+ return 1;
+ }
+ if (mailbox1 === this.env.messaging.starred) {
+ return -1;
+ }
+ if (mailbox2 === this.env.messaging.starred) {
+ return 1;
+ }
+ const mailbox1Name = mailbox1.displayName;
+ const mailbox2Name = mailbox2.displayName;
+ mailbox1Name < mailbox2Name ? -1 : 1;
+ });
+ }
+
+ /**
+ * Return the list of chats that match the quick search value input.
+ *
+ * @returns {mail.thread[]}
+ */
+ get quickSearchPinnedAndOrderedChats() {
+ const allOrderedAndPinnedChats = this.env.models['mail.thread']
+ .all(thread =>
+ thread.channel_type === 'chat' &&
+ thread.isPinned &&
+ thread.model === 'mail.channel'
+ )
+ .sort((c1, c2) => c1.displayName < c2.displayName ? -1 : 1);
+ if (!this.discuss.sidebarQuickSearchValue) {
+ return allOrderedAndPinnedChats;
+ }
+ const qsVal = this.discuss.sidebarQuickSearchValue.toLowerCase();
+ return allOrderedAndPinnedChats.filter(chat => {
+ const nameVal = chat.displayName.toLowerCase();
+ return nameVal.includes(qsVal);
+ });
+ }
+
+ /**
+ * Return the list of channels that match the quick search value input.
+ *
+ * @returns {mail.thread[]}
+ */
+ get quickSearchOrderedAndPinnedMultiUserChannels() {
+ const allOrderedAndPinnedMultiUserChannels = this.env.models['mail.thread']
+ .all(thread =>
+ thread.channel_type === 'channel' &&
+ thread.isPinned &&
+ thread.model === 'mail.channel'
+ )
+ .sort((c1, c2) => c1.displayName < c2.displayName ? -1 : 1);
+ if (!this.discuss.sidebarQuickSearchValue) {
+ return allOrderedAndPinnedMultiUserChannels;
+ }
+ const qsVal = this.discuss.sidebarQuickSearchValue.toLowerCase();
+ return allOrderedAndPinnedMultiUserChannels.filter(channel => {
+ const nameVal = channel.displayName.toLowerCase();
+ return nameVal.includes(qsVal);
+ });
+ }
+
+ //--------------------------------------------------------------------------
+ // Private
+ //--------------------------------------------------------------------------
+
+ /**
+ * @private
+ */
+ _update() {
+ if (this._quickSearchInputRef.el) {
+ this._quickSearchInputRef.el.value = this.discuss.sidebarQuickSearchValue;
+ }
+ }
+
+ /**
+ * @private
+ * @returns {Object}
+ */
+ _useStoreCompareDepth() {
+ return {
+ allOrderedAndPinnedChats: 1,
+ allOrderedAndPinnedMailboxes: 1,
+ allOrderedAndPinnedMultiUserChannels: 1,
+ allPinnedChannelAmount: 1,
+ };
+ }
+
+ /**
+ * @private
+ * @param {Object} props
+ * @returns {Object}
+ */
+ _useStoreSelector(props) {
+ return {
+ allOrderedAndPinnedChats:
+ this.env.models['mail.thread']
+ .all(thread =>
+ thread.channel_type === 'chat' &&
+ thread.isPinned &&
+ thread.model === 'mail.channel'
+ )
+ .sort((c1, c2) => c1.displayName < c2.displayName ? -1 : 1)
+ .map(chat => chat.__state),
+ allOrderedAndPinnedMailboxes: this.orderedMailboxes.map(mailbox => mailbox.__state),
+ allOrderedAndPinnedMultiUserChannels:
+ this.env.models['mail.thread']
+ .all(thread =>
+ thread.channel_type === 'channel' &&
+ thread.isPinned &&
+ thread.model === 'mail.channel'
+ )
+ .sort((c1, c2) => c1.displayName < c2.displayName ? -1 : 1)
+ .map(channel => channel.__state),
+ allPinnedChannelAmount:
+ this.env.models['mail.thread']
+ .all(thread =>
+ thread.isPinned &&
+ thread.model === 'mail.channel'
+ ).length,
+ discuss: this.env.messaging.discuss.__state,
+ };
+ }
+
+ //--------------------------------------------------------------------------
+ // Handlers
+ //--------------------------------------------------------------------------
+
+ /**
+ * @private
+ * @param {Event} ev
+ * @param {Object} ui
+ * @param {Object} ui.item
+ * @param {integer} ui.item.id
+ */
+ _onAddChannelAutocompleteSelect(ev, ui) {
+ this.discuss.handleAddChannelAutocompleteSelect(ev, ui);
+ }
+
+ /**
+ * @private
+ * @param {Object} req
+ * @param {string} req.term
+ * @param {function} res
+ */
+ _onAddChannelAutocompleteSource(req, res) {
+ this.discuss.handleAddChannelAutocompleteSource(req, res);
+ }
+
+ /**
+ * @private
+ * @param {Event} ev
+ * @param {Object} ui
+ * @param {Object} ui.item
+ * @param {integer} ui.item.id
+ */
+ _onAddChatAutocompleteSelect(ev, ui) {
+ this.discuss.handleAddChatAutocompleteSelect(ev, ui);
+ }
+
+ /**
+ * @private
+ * @param {Object} req
+ * @param {string} req.term
+ * @param {function} res
+ */
+ _onAddChatAutocompleteSource(req, res) {
+ this.discuss.handleAddChatAutocompleteSource(req, res);
+ }
+
+ /**
+ * Called when clicking on add channel icon.
+ *
+ * @private
+ * @param {MouseEvent} ev
+ */
+ _onClickChannelAdd(ev) {
+ ev.stopPropagation();
+ this.discuss.update({ isAddingChannel: true });
+ }
+
+ /**
+ * Called when clicking on channel title.
+ *
+ * @private
+ * @param {MouseEvent} ev
+ */
+ _onClickChannelTitle(ev) {
+ ev.stopPropagation();
+ return this.env.bus.trigger('do-action', {
+ action: {
+ name: this.env._t("Public Channels"),
+ type: 'ir.actions.act_window',
+ res_model: 'mail.channel',
+ views: [[false, 'kanban'], [false, 'form']],
+ domain: [['public', '!=', 'private']]
+ },
+ });
+ }
+
+ /**
+ * Called when clicking on add chat icon.
+ *
+ * @private
+ * @param {MouseEvent} ev
+ */
+ _onClickChatAdd(ev) {
+ ev.stopPropagation();
+ this.discuss.update({ isAddingChat: true });
+ }
+
+ /**
+ * @private
+ * @param {CustomEvent} ev
+ */
+ _onHideAddingItem(ev) {
+ ev.stopPropagation();
+ this.discuss.clearIsAddingItem();
+ }
+
+ /**
+ * @private
+ * @param {KeyboardEvent} ev
+ */
+ _onInputQuickSearch(ev) {
+ ev.stopPropagation();
+ this.discuss.update({
+ sidebarQuickSearchValue: this._quickSearchInputRef.el.value,
+ });
+ }
+
+}
+
+Object.assign(DiscussSidebar, {
+ components,
+ props: {},
+ template: 'mail.DiscussSidebar',
+});
+
+return DiscussSidebar;
+
+});
diff --git a/addons/mail/static/src/components/discuss_sidebar/discuss_sidebar.scss b/addons/mail/static/src/components/discuss_sidebar/discuss_sidebar.scss
new file mode 100644
index 00000000000..3e49cddf7dd
--- /dev/null
+++ b/addons/mail/static/src/components/discuss_sidebar/discuss_sidebar.scss
@@ -0,0 +1,110 @@
+// ------------------------------------------------------------------
+// Layout
+// ------------------------------------------------------------------
+
+.o_DiscussSidebar {
+ display: flex;
+ flex-flow: column;
+ width: $o-mail-chat-sidebar-width;
+
+ @include media-breakpoint-up(xl) {
+ width: $o-mail-chat-sidebar-width + 50px;
+ }
+}
+
+.o_DiscussSidebar_group {
+ display: flex;
+ flex-flow: column;
+ flex: 0 0 auto;
+}
+
+.o_DiscussSidebar_groupHeader {
+ display: flex;
+ align-items: center;
+ margin: 5px 0;
+}
+
+.o_DiscussSidebar_groupHeaderItem {
+ margin-left: 3px;
+ margin-right: 3px;
+
+ &:first-child {
+ margin-left: $o-mail-discuss-sidebar-active-indicator-margin-right;
+ }
+
+ &:last-child {
+ margin-right: $o-mail-discuss-sidebar-scrollbar-width;
+ }
+}
+
+.o_DiscussSidebar_itemNew {
+ display: flex;
+ justify-content: center;
+}
+
+.o_DiscussSidebar_itemNewInput {
+ flex: 1 1 auto;
+ margin-left: $o-mail-discuss-sidebar-active-indicator-margin-right + 3px;
+ margin-right: $o-mail-discuss-sidebar-scrollbar-width;
+}
+
+.o_DiscussSidebar_quickSearch {
+ border-radius: 10px;
+ margin: 0 $o-mail-discuss-sidebar-scrollbar-width 10px;
+ padding: 3px 10px;
+}
+
+.o_DiscussSidebar_separator {
+ width: 100%;
+}
+
+// ------------------------------------------------------------------
+// Style
+// ------------------------------------------------------------------
+
+.o_DiscussSidebar {
+ background-color: gray('900');
+ color: gray('300');
+}
+
+.o_DiscussSidebar_groupHeader {
+ font-size: $font-size-sm;
+ text-transform: uppercase;
+ font-weight: bolder;
+}
+
+.o_DiscussSidebar_groupHeaderItemAdd {
+ cursor: pointer;
+
+ &:not(:hover) {
+ color: gray('600');
+ }
+}
+
+.o_DiscussSidebar_groupTitle {
+
+ &:not(.o-clickable) {
+ color: gray('600');
+ }
+
+ &.o-clickable {
+ cursor: pointer;
+
+ &:not(:hover) {
+ color: gray('600');
+ }
+ }
+}
+
+.o_DiscussSidebar_itemNewInput {
+ outline: none;
+}
+
+.o_DiscussSidebar_quickSearch {
+ border: none;
+ outline: none;
+}
+
+.o_DiscussSidebar_separator {
+ background-color: gray('600');
+}
diff --git a/addons/mail/static/src/components/discuss_sidebar/discuss_sidebar.xml b/addons/mail/static/src/components/discuss_sidebar/discuss_sidebar.xml
new file mode 100644
index 00000000000..ac4ac02bd46
--- /dev/null
+++ b/addons/mail/static/src/components/discuss_sidebar/discuss_sidebar.xml
@@ -0,0 +1,80 @@
+<?xml version="1.0" encoding="UTF-8"?>
+<templates xml:space="preserve">
+
+ <t t-name="mail.DiscussSidebar" owl="1">
+ <div name="root" class="o_DiscussSidebar">
+ <div class="o_DiscussSidebar_group o_DiscussSidebar_groupMailbox">
+ <t t-foreach="orderedMailboxes" t-as="mailbox" t-key="mailbox.localId">
+ <DiscussSidebarItem
+ class="o_DiscussSidebar_item"
+ threadLocalId="mailbox.localId"
+ />
+ </t>
+ </div>
+ <hr class="o_DiscussSidebar_separator"/>
+ <t t-if="env.models['mail.thread'].all(thread => thread.isPinned and thread.model === 'mail.channel').length > 19">
+ <input class="o_DiscussSidebar_quickSearch" t-on-input="_onInputQuickSearch" placeholder="Quick search..." t-ref="quickSearchInput" t-esc="discuss.sidebarQuickSearchValue"/>
+ </t>
+ <div class="o_DiscussSidebar_group o_DiscussSidebar_groupChannel">
+ <div class="o_DiscussSidebar_groupHeader">
+ <div class="o_DiscussSidebar_groupHeaderItem o_DiscussSidebar_groupTitle o-clickable" t-on-click="_onClickChannelTitle">
+ Channels
+ </div>
+ <div class="o-autogrow"/>
+ <div class="o_DiscussSidebar_groupHeaderItem o_DiscussSidebar_groupHeaderItemAdd fa fa-plus" t-on-click="_onClickChannelAdd" title="Add or join a channel"/>
+ </div>
+ <div class="o_DiscussSidebar_list">
+ <t t-if="discuss.isAddingChannel">
+ <div class="o_DiscussSidebar_item o_DiscussSidebar_itemNew">
+ <AutocompleteInput
+ class="o_DiscussSidebar_itemNewInput"
+ isFocusOnMount="true"
+ isHtml="true"
+ placeholder="'Find or create a channel...'"
+ select="_onAddChannelAutocompleteSelect"
+ source="_onAddChannelAutocompleteSource"
+ t-on-o-hide="_onHideAddingItem"
+ />
+ </div>
+ </t>
+ <t t-foreach="quickSearchOrderedAndPinnedMultiUserChannels" t-as="channel" t-key="channel.localId">
+ <DiscussSidebarItem
+ class="o_DiscussSidebar_item"
+ threadLocalId="channel.localId"
+ />
+ </t>
+ </div>
+ </div>
+ <div class="o_DiscussSidebar_group o_DiscussSidebar_groupChat">
+ <div class="o_DiscussSidebar_groupHeader">
+ <div class="o_DiscussSidebar_groupHeaderItem o_DiscussSidebar_groupTitle">
+ Direct Messages
+ </div>
+ <div class="o-autogrow"/>
+ <div class="o_DiscussSidebar_groupHeaderItem o_DiscussSidebar_groupHeaderItemAdd fa fa-plus" t-on-click="_onClickChatAdd" title="Start a conversation"/>
+ </div>
+ <div class="o_DiscussSidebar_list">
+ <t t-if="discuss.isAddingChat">
+ <div class="o_DiscussSidebar_item o_DiscussSidebar_itemNew">
+ <AutocompleteInput
+ class="o_DiscussSidebar_itemNewInput"
+ isFocusOnMount="true"
+ placeholder="'Find or start a conversation...'"
+ select="_onAddChatAutocompleteSelect"
+ source="_onAddChatAutocompleteSource"
+ t-on-o-hide="_onHideAddingItem"
+ />
+ </div>
+ </t>
+ <t t-foreach="quickSearchPinnedAndOrderedChats" t-as="chat" t-key="chat.localId">
+ <DiscussSidebarItem
+ class="o_DiscussSidebar_item"
+ threadLocalId="chat.localId"
+ />
+ </t>
+ </div>
+ </div>
+ </div>
+ </t>
+
+</templates>
diff --git a/addons/mail/static/src/components/discuss_sidebar_item/discuss_sidebar_item.js b/addons/mail/static/src/components/discuss_sidebar_item/discuss_sidebar_item.js
new file mode 100644
index 00000000000..db22f9f2116
--- /dev/null
+++ b/addons/mail/static/src/components/discuss_sidebar_item/discuss_sidebar_item.js
@@ -0,0 +1,203 @@
+odoo.define('mail/static/src/components/discuss_sidebar_item/discuss_sidebar_item.js', function (require) {
+'use strict';
+
+const components = {
+ EditableText: require('mail/static/src/components/editable_text/editable_text.js'),
+ ThreadIcon: require('mail/static/src/components/thread_icon/thread_icon.js'),
+};
+const useStore = require('mail/static/src/component_hooks/use_store/use_store.js');
+
+const Dialog = require('web.Dialog');
+
+const { Component } = owl;
+
+class DiscussSidebarItem extends Component {
+
+ /**
+ * @override
+ */
+ constructor(...args) {
+ super(...args);
+ useStore(props => {
+ const thread = this.env.models['mail.thread'].get(props.threadLocalId);
+ const correspondent = thread ? thread.correspondent : undefined;
+ return {
+ correspondent: correspondent ? correspondent.__state : undefined,
+ discuss: this.env.messaging.discuss.__state,
+ thread: thread ? thread.__state : undefined,
+ };
+ });
+ }
+
+ //--------------------------------------------------------------------------
+ // Public
+ //--------------------------------------------------------------------------
+
+ /**
+ * Get the counter of this discuss item, which is based on the thread type.
+ *
+ * @returns {integer}
+ */
+ get counter() {
+ if (this.thread.model === 'mail.box') {
+ return this.thread.counter;
+ } else if (this.thread.channel_type === 'channel') {
+ return this.thread.message_needaction_counter;
+ } else if (this.thread.channel_type === 'chat') {
+ return this.thread.message_unread_counter;
+ }
+ return 0;
+ }
+
+ /**
+ * @returns {mail.discuss}
+ */
+ get discuss() {
+ return this.env.messaging && this.env.messaging.discuss;
+ }
+
+ /**
+ * @returns {boolean}
+ */
+ hasUnpin() {
+ return this.thread.channel_type === 'chat';
+ }
+
+ /**
+ * @returns {mail.thread}
+ */
+ get thread() {
+ return this.env.models['mail.thread'].get(this.props.threadLocalId);
+ }
+
+ //--------------------------------------------------------------------------
+ // Private
+ //--------------------------------------------------------------------------
+
+ /**
+ * @private
+ * @returns {Promise}
+ */
+ _askAdminConfirmation() {
+ return new Promise(resolve => {
+ Dialog.confirm(this,
+ this.env._t("You are the administrator of this channel. Are you sure you want to leave?"),
+ {
+ buttons: [
+ {
+ text: this.env._t("Leave"),
+ classes: 'btn-primary',
+ close: true,
+ click: resolve
+ },
+ {
+ text: this.env._t("Discard"),
+ close: true
+ }
+ ]
+ }
+ );
+ });
+ }
+
+ //--------------------------------------------------------------------------
+ // Handlers
+ //--------------------------------------------------------------------------
+
+ /**
+ * @private
+ * @param {Event} ev
+ */
+ _onCancelRenaming(ev) {
+ this.discuss.cancelThreadRenaming(this.thread);
+ }
+
+ /**
+ * @private
+ * @param {MouseEvent} ev
+ */
+ _onClick(ev) {
+ this.discuss.threadViewer.update({ thread: [['link', this.thread]] });
+ }
+
+ /**
+ * Stop propagation to prevent selecting this item.
+ *
+ * @private
+ * @param {CustomEvent} ev
+ */
+ _onClickedEditableText(ev) {
+ ev.stopPropagation();
+ }
+
+ /**
+ * @private
+ * @param {MouseEvent} ev
+ */
+ async _onClickLeave(ev) {
+ ev.stopPropagation();
+ if (this.thread.creator === this.env.messaging.currentUser) {
+ await this._askAdminConfirmation();
+ }
+ this.thread.unsubscribe();
+ }
+
+ /**
+ * @private
+ * @param {MouseEvent} ev
+ */
+ _onClickRename(ev) {
+ ev.stopPropagation();
+ this.discuss.setThreadRenaming(this.thread);
+ }
+
+ /**
+ * @private
+ * @param {MouseEvent} ev
+ */
+ _onClickSettings(ev) {
+ ev.stopPropagation();
+ return this.env.bus.trigger('do-action', {
+ action: {
+ type: 'ir.actions.act_window',
+ res_model: this.thread.model,
+ res_id: this.thread.id,
+ views: [[false, 'form']],
+ target: 'current'
+ },
+ });
+ }
+
+ /**
+ * @private
+ * @param {MouseEvent} ev
+ */
+ _onClickUnpin(ev) {
+ ev.stopPropagation();
+ this.thread.unsubscribe();
+ }
+
+ /**
+ * @private
+ * @param {CustomEvent} ev
+ * @param {Object} ev.detail
+ * @param {string} ev.detail.newName
+ */
+ _onValidateEditableText(ev) {
+ ev.stopPropagation();
+ this.discuss.renameThread(this.thread, ev.detail.newName);
+ }
+
+}
+
+Object.assign(DiscussSidebarItem, {
+ components,
+ props: {
+ threadLocalId: String,
+ },
+ template: 'mail.DiscussSidebarItem',
+});
+
+return DiscussSidebarItem;
+
+});
diff --git a/addons/mail/static/src/components/discuss_sidebar_item/discuss_sidebar_item.scss b/addons/mail/static/src/components/discuss_sidebar_item/discuss_sidebar_item.scss
new file mode 100644
index 00000000000..649297585f9
--- /dev/null
+++ b/addons/mail/static/src/components/discuss_sidebar_item/discuss_sidebar_item.scss
@@ -0,0 +1,107 @@
+// ------------------------------------------------------------------
+// Layout
+// ------------------------------------------------------------------
+
+.o_DiscussSidebarItem {
+ display: flex;
+ align-items: center;
+ padding: map-get($spacers, 1) 0;
+
+ &:hover .o_DiscussSidebarItem_commands {
+ display: flex;
+ }
+}
+
+.o_DiscussSidebarItem_activeIndicator {
+ width: $o-mail-discuss-sidebar-active-indicator-width;
+ align-self: stretch;
+}
+
+.o_DiscussSidebarItem_command {
+ margin-left: 3px;
+ margin-right: 3px;
+
+ &:first-child {
+ margin-left: 0px;
+ }
+
+ &:last-child {
+ margin-right: 0px;
+ }
+}
+
+.o_DiscussSidebarItem_commands {
+ display: none;
+}
+
+.o_DiscussSidebarItem_item {
+ margin-left: 3px;
+ margin-right: 3px;
+
+ &:first-child {
+ margin-left: 0px;
+ margin-right: $o-mail-discuss-sidebar-active-indicator-margin-right;
+ }
+
+ &:last-child {
+ margin-right: $o-mail-discuss-sidebar-scrollbar-width;
+ }
+}
+
+.o_DiscussSidebarItem_name {
+ overflow: hidden;
+ text-overflow: ellipsis;
+
+ &.o-editable {
+ margin-left: $o-mail-discuss-sidebar-active-indicator-width + $o-mail-discuss-sidebar-active-indicator-margin-right;
+ margin-right: $o-mail-discuss-sidebar-scrollbar-width;
+ }
+}
+
+.o_DiscussSidebarItem_nameInput {
+ width: 100%;
+}
+
+// ------------------------------------------------------------------
+// Style
+// ------------------------------------------------------------------
+
+.o_DiscussSidebarItem {
+ cursor: pointer;
+
+ &:hover {
+ background-color: darken(gray('900'), 5%);
+ }
+
+ &.o-starred-box {
+ .o_DiscussSidebarItem_counter {
+ border-color: gray('600');
+ background-color: gray('600');
+ }
+ }
+}
+
+.o_DiscussSidebarItem_activeIndicator.o-item-active {
+ background-color: $o-brand-primary;
+}
+
+.o_DiscussSidebarItem_command:not(:hover) {
+ color: gray('600');
+}
+
+.o_DiscussSidebarItem_counter {
+ background-color: $o-brand-primary;
+}
+
+.o_DiscussSidebarItem_name {
+
+ &.o-item-unread {
+ font-weight: bold;
+ }
+}
+
+.o_DiscussSidebarItem_nameInput {
+ outline: none;
+ border: none;
+ border-radius: 2px;
+}
diff --git a/addons/mail/static/src/components/discuss_sidebar_item/discuss_sidebar_item.xml b/addons/mail/static/src/components/discuss_sidebar_item/discuss_sidebar_item.xml
new file mode 100644
index 00000000000..2db165b4c34
--- /dev/null
+++ b/addons/mail/static/src/components/discuss_sidebar_item/discuss_sidebar_item.xml
@@ -0,0 +1,60 @@
+<?xml version="1.0" encoding="UTF-8"?>
+<templates xml:space="preserve">
+
+ <t t-name="mail.DiscussSidebarItem" owl="1">
+ <div class="o_DiscussSidebarItem"
+ t-att-class="{
+ 'o-active': thread and discuss.thread === thread,
+ 'o-starred-box': thread and thread === env.messaging.starred,
+ 'o-unread': thread and thread.message_unread_counter > 0,
+ }" t-on-click="_onClick" t-att-data-thread-local-id="thread ? thread.localId : undefined" t-att-data-thread-name="thread ? thread.displayName : undefined"
+ >
+ <t t-if="thread">
+ <div class=" o_DiscussSidebarItem_activeIndicator o_DiscussSidebarItem_item" t-att-class="{ 'o-item-active': discuss.thread === thread }"/>
+ <ThreadIcon class="o_DiscussSidebarItem_item" threadLocalId="thread.localId"/>
+ <t t-if="thread.channel_type === 'chat' and discuss.renamingThreads.includes(thread)">
+ <div class="o_DiscussSidebarItem_item o_DiscussSidebarItem_name o-editable">
+ <EditableText
+ class="o_DiscussSidebarItem_nameInput"
+ placeholder="thread.correspondent.name"
+ value="thread.displayName"
+ t-on-o-cancel="_onCancelRenaming"
+ t-on-o-clicked="_onClickedEditableText"
+ t-on-o-validate="_onValidateEditableText"
+ />
+ </div>
+ </t>
+ <t t-else="">
+ <div class="o_DiscussSidebarItem_item o_DiscussSidebarItem_name" t-att-class="{ 'o-item-unread': thread.message_unread_counter > 0 }">
+ <t t-esc="thread.displayName"/>
+ </div>
+ </t>
+ <div class="o-autogrow o_DiscussSidebarItem_item"/>
+ <t t-if="thread.model !== 'mail.box'">
+ <div class="o_DiscussSidebarItem_commands o_DiscussSidebarItem_item">
+ <t t-if="thread.channel_type === 'channel'">
+ <div class="fa fa-cog o_DiscussSidebarItem_command o_DiscussSidebarItem_commandSettings" t-on-click="_onClickSettings" title="Channel settings" role="img"/>
+ <t t-if="!thread.message_needaction_counter and !thread.group_based_subscription">
+ <div class="o_DiscussSidebarItem_command o_DiscussSidebarItem_commandLeave fa fa-times" t-on-click="_onClickLeave" title="Leave this channel" role="img"/>
+ </t>
+ </t>
+ <t t-if="thread.channel_type === 'chat'">
+ <div class="o_DiscussSidebarItem_command o_DiscussSidebarItem_commandRename fa fa-cog" t-on-click="_onClickRename" title="Rename conversation" role="img"/>
+ </t>
+ <t t-if="hasUnpin()">
+ <t t-if="!thread.message_unread_counter">
+ <div class="fa fa-times o_DiscussSidebarItem_command o_DiscussSidebarItem_commandUnpin" t-on-click="_onClickUnpin" title="Unpin conversation" role="img"/>
+ </t>
+ </t>
+ </div>
+ </t>
+ <t t-if="counter > 0">
+ <div class="o_DiscussSidebarItem_counter o_DiscussSidebarItem_item badge badge-pill">
+ <t t-esc="counter"/>
+ </div>
+ </t>
+ </t>
+ </div>
+ </t>
+
+</templates>
diff --git a/addons/mail/static/src/components/drop_zone/drop_zone.js b/addons/mail/static/src/components/drop_zone/drop_zone.js
new file mode 100644
index 00000000000..2eb4dc8ef74
--- /dev/null
+++ b/addons/mail/static/src/components/drop_zone/drop_zone.js
@@ -0,0 +1,136 @@
+odoo.define('mail/static/src/components/drop_zone/drop_zone.js', function (require) {
+'use strict';
+
+const { Component, useState } = owl;
+
+class DropZone extends Component {
+
+ /**
+ * @override
+ */
+ constructor(...args) {
+ super(...args);
+ this.state = useState({
+ /**
+ * Determine whether the user is dragging files over the dropzone.
+ * Useful to provide visual feedback in that case.
+ */
+ isDraggingInside: false,
+ });
+ /**
+ * Counts how many drag enter/leave happened on self and children. This
+ * ensures the drop effect stays active when dragging over a child.
+ */
+ this._dragCount = 0;
+ }
+
+ //--------------------------------------------------------------------------
+ // Public
+ //--------------------------------------------------------------------------
+
+ /**
+ * Returns whether the given node is self or a children of self.
+ *
+ * @param {Node} node
+ * @returns {boolean}
+ */
+ contains(node) {
+ return this.el.contains(node);
+ }
+
+ //--------------------------------------------------------------------------
+ // Private
+ //--------------------------------------------------------------------------
+
+ /**
+ * Making sure that dragging content is external files.
+ * Ignoring other content dragging like text.
+ *
+ * @private
+ * @param {DataTransfer} dataTransfer
+ * @returns {boolean}
+ */
+ _isDragSourceExternalFile(dataTransfer) {
+ const dragDataType = dataTransfer.types;
+ if (dragDataType.constructor === window.DOMStringList) {
+ return dragDataType.contains('Files');
+ }
+ if (dragDataType.constructor === Array) {
+ return dragDataType.includes('Files');
+ }
+ return false;
+ }
+
+ //--------------------------------------------------------------------------
+ // Handlers
+ //--------------------------------------------------------------------------
+
+ /**
+ * Shows a visual drop effect when dragging inside the dropzone.
+ *
+ * @private
+ * @param {DragEvent} ev
+ */
+ _onDragenter(ev) {
+ ev.preventDefault();
+ if (this._dragCount === 0) {
+ this.state.isDraggingInside = true;
+ }
+ this._dragCount++;
+ }
+
+ /**
+ * Hides the visual drop effect when dragging outside the dropzone.
+ *
+ * @private
+ * @param {DragEvent} ev
+ */
+ _onDragleave(ev) {
+ this._dragCount--;
+ if (this._dragCount === 0) {
+ this.state.isDraggingInside = false;
+ }
+ }
+
+ /**
+ * Prevents default (from the template) in order to receive the drop event.
+ * The drop effect cursor works only when set on dragover.
+ *
+ * @private
+ * @param {DragEvent} ev
+ */
+ _onDragover(ev) {
+ ev.preventDefault();
+ ev.dataTransfer.dropEffect = 'copy';
+ }
+
+ /**
+ * Triggers the `o-dropzone-files-dropped` event when new files are dropped
+ * on the dropzone, and then removes the visual drop effect.
+ *
+ * The parents should handle this event to process the files as they wish,
+ * such as uploading them.
+ *
+ * @private
+ * @param {DragEvent} ev
+ */
+ _onDrop(ev) {
+ ev.preventDefault();
+ if (this._isDragSourceExternalFile(ev.dataTransfer)) {
+ this.trigger('o-dropzone-files-dropped', {
+ files: ev.dataTransfer.files,
+ });
+ }
+ this.state.isDraggingInside = false;
+ }
+
+}
+
+Object.assign(DropZone, {
+ props: {},
+ template: 'mail.DropZone',
+});
+
+return DropZone;
+
+});
diff --git a/addons/mail/static/src/components/drop_zone/drop_zone.scss b/addons/mail/static/src/components/drop_zone/drop_zone.scss
new file mode 100644
index 00000000000..e1a901cb12e
--- /dev/null
+++ b/addons/mail/static/src/components/drop_zone/drop_zone.scss
@@ -0,0 +1,29 @@
+// ------------------------------------------------------------------
+// Layout
+// ------------------------------------------------------------------
+
+.o_DropZone {
+ display: flex;
+ position: absolute;
+ left: 0;
+ bottom: 0;
+ height: 100%;
+ width: 100%;
+ z-index: 1;
+ align-items: center;
+ justify-content: center;
+}
+
+// ------------------------------------------------------------------
+// Style
+// ------------------------------------------------------------------
+
+.o_DropZone {
+ color: $o-enterprise-primary-color;
+ background: rgba(255, 255, 255, 0.9);
+ border: 2px dashed $o-enterprise-primary-color;
+
+ &.o-dragging-inside {
+ border-width: 5px;
+ }
+}
diff --git a/addons/mail/static/src/components/drop_zone/drop_zone.xml b/addons/mail/static/src/components/drop_zone/drop_zone.xml
new file mode 100644
index 00000000000..b3db940fde9
--- /dev/null
+++ b/addons/mail/static/src/components/drop_zone/drop_zone.xml
@@ -0,0 +1,12 @@
+<?xml version="1.0" encoding="UTF-8"?>
+<templates xml:space="preserve">
+
+ <t t-name="mail.DropZone" owl="1">
+ <div class="o_DropZone" t-att-class="{ 'o-dragging-inside': state.isDraggingInside }" t-on-dragenter="_onDragenter" t-on-dragleave="_onDragleave" t-on-dragover="_onDragover" t-on-drop="_onDrop">
+ <h4>
+ Drag Files Here <i class="fa fa-download"/>
+ </h4>
+ </div>
+ </t>
+
+</templates>
diff --git a/addons/mail/static/src/components/editable_text/editable_text.js b/addons/mail/static/src/components/editable_text/editable_text.js
new file mode 100644
index 00000000000..4a4b926741e
--- /dev/null
+++ b/addons/mail/static/src/components/editable_text/editable_text.js
@@ -0,0 +1,82 @@
+odoo.define('mail/static/src/components/editable_text/editable_text.js', function (require) {
+'use strict';
+
+const { Component } = owl;
+
+class EditableText extends Component {
+
+ mounted() {
+ this.el.focus();
+ this.el.setSelectionRange(0, (this.el.value && this.el.value.length) || 0);
+ }
+
+ willUnmount() {
+ this.trigger('o-cancel');
+ }
+
+ //--------------------------------------------------------------------------
+ // Handlers
+ //--------------------------------------------------------------------------
+
+ /**
+ * @private
+ * @param {MouseEvent} ev
+ */
+ _onBlur(ev) {
+ this.trigger('o-cancel');
+ }
+
+ /**
+ * @private
+ * @param {MouseEvent} ev
+ */
+ _onClick(ev) {
+ this.trigger('o-clicked');
+ }
+
+ /**
+ * @private
+ * @param {KeyboardEvent} ev
+ */
+ _onKeydown(ev) {
+ switch (ev.key) {
+ case 'Enter':
+ this._onKeydownEnter(ev);
+ break;
+ case 'Escape':
+ this.trigger('o-cancel');
+ break;
+ }
+ }
+
+ /**
+ * @private
+ * @param {KeyboardEvent} ev
+ */
+ _onKeydownEnter(ev) {
+ const value = this.el.value;
+ const newName = value || this.props.placeholder;
+ if (this.props.value !== newName) {
+ this.trigger('o-validate', { newName });
+ } else {
+ this.trigger('o-cancel');
+ }
+ }
+
+}
+
+Object.assign(EditableText, {
+ defaultProps: {
+ placeholder: "",
+ value: "",
+ },
+ props: {
+ placeholder: String,
+ value: String,
+ },
+ template: 'mail.EditableText',
+});
+
+return EditableText;
+
+});
diff --git a/addons/mail/static/src/components/editable_text/editable_text.xml b/addons/mail/static/src/components/editable_text/editable_text.xml
new file mode 100644
index 00000000000..5e3aa52abe6
--- /dev/null
+++ b/addons/mail/static/src/components/editable_text/editable_text.xml
@@ -0,0 +1,8 @@
+<?xml version="1.0" encoding="UTF-8"?>
+<templates xml:space="preserve">
+
+ <t t-name="mail.EditableText" owl="1">
+ <input class="o_EditableText" t-att-value="props.value" t-on-blur="_onBlur" t-on-click="_onClick" t-on-keydown="_onKeydown" t-att-placeholder="props.placeholder"/>
+ </t>
+
+</templates>
diff --git a/addons/mail/static/src/components/emojis_popover/emojis_popover.js b/addons/mail/static/src/components/emojis_popover/emojis_popover.js
new file mode 100644
index 00000000000..2e8ee6c6ae8
--- /dev/null
+++ b/addons/mail/static/src/components/emojis_popover/emojis_popover.js
@@ -0,0 +1,42 @@
+odoo.define('mail/static/src/components/emojis_popover/emojis_popover.js', function (require) {
+'use strict';
+
+const emojis = require('mail.emojis');
+
+const { Component } = owl;
+
+class EmojisPopover extends Component {
+
+ /**
+ * @param {...any} args
+ */
+ constructor(...args) {
+ super(...args);
+ this.emojis = emojis;
+ }
+
+ //--------------------------------------------------------------------------
+ // Handlers
+ //--------------------------------------------------------------------------
+
+ /**
+ * @private
+ * @param {MouseEvent} ev
+ */
+ _onClickEmoji(ev) {
+ this.trigger('o-popover-close');
+ this.trigger('o-emoji-selection', {
+ unicode: ev.currentTarget.dataset.unicode,
+ });
+ }
+
+}
+
+Object.assign(EmojisPopover, {
+ props: {},
+ template: 'mail.EmojisPopover',
+});
+
+return EmojisPopover;
+
+});
diff --git a/addons/mail/static/src/components/emojis_popover/emojis_popover.scss b/addons/mail/static/src/components/emojis_popover/emojis_popover.scss
new file mode 100644
index 00000000000..e49edff659d
--- /dev/null
+++ b/addons/mail/static/src/components/emojis_popover/emojis_popover.scss
@@ -0,0 +1,21 @@
+// ------------------------------------------------------------------
+// Layout
+// ------------------------------------------------------------------
+
+.o_EmojisPopover {
+ display: flex;
+ flex-flow: row wrap;
+}
+
+.o_EmojisPopover_emoji {
+ font-size: 1.1em;
+ margin: 3px;
+}
+
+// ------------------------------------------------------------------
+// Style
+// ------------------------------------------------------------------
+
+.o_EmojisPopover_emoji {
+ cursor: pointer;
+}
diff --git a/addons/mail/static/src/components/emojis_popover/emojis_popover.xml b/addons/mail/static/src/components/emojis_popover/emojis_popover.xml
new file mode 100644
index 00000000000..cac840bbedc
--- /dev/null
+++ b/addons/mail/static/src/components/emojis_popover/emojis_popover.xml
@@ -0,0 +1,14 @@
+<?xml version="1.0" encoding="UTF-8"?>
+<templates xml:space="preserve">
+
+ <t t-name="mail.EmojisPopover" owl="1">
+ <div class="o_EmojisPopover">
+ <t t-foreach="emojis" t-as="emoji" t-key="emoji.unicode">
+ <span class="o_EmojisPopover_emoji" t-on-click="_onClickEmoji" t-att-title="emoji.description" t-att-data-source="emoji.sources[0]" t-att-data-unicode="emoji.unicode">
+ <t t-esc="emoji.unicode"/>
+ </span>
+ </t>
+ </div>
+ </t>
+
+</templates>
diff --git a/addons/mail/static/src/components/file_uploader/file_uploader.js b/addons/mail/static/src/components/file_uploader/file_uploader.js
new file mode 100644
index 00000000000..732eb312126
--- /dev/null
+++ b/addons/mail/static/src/components/file_uploader/file_uploader.js
@@ -0,0 +1,206 @@
+odoo.define('mail/static/src/components/file_uploader/file_uploader.js', function (require) {
+'use strict';
+
+const core = require('web.core');
+
+const { Component } = owl;
+const { useRef } = owl.hooks;
+
+class FileUploader extends Component {
+
+ /**
+ * @override
+ */
+ constructor(...args) {
+ super(...args);
+ this._fileInputRef = useRef('fileInput');
+ this._fileUploadId = _.uniqueId('o_FileUploader_fileupload');
+ this._onAttachmentUploaded = this._onAttachmentUploaded.bind(this);
+ }
+
+ mounted() {
+ $(window).on(this._fileUploadId, this._onAttachmentUploaded);
+ }
+
+ willUnmount() {
+ $(window).off(this._fileUploadId);
+ }
+
+ //--------------------------------------------------------------------------
+ // Public
+ //--------------------------------------------------------------------------
+
+ /**
+ * @param {FileList|Array} files
+ * @returns {Promise}
+ */
+ async uploadFiles(files) {
+ await this._unlinkExistingAttachments(files);
+ this._createTemporaryAttachments(files);
+ await this._performUpload(files);
+ this._fileInputRef.el.value = '';
+ }
+
+ openBrowserFileUploader() {
+ this._fileInputRef.el.click();
+ }
+
+ //--------------------------------------------------------------------------
+ // Private
+ //--------------------------------------------------------------------------
+
+ /**
+ * @private
+ * @param {Object} fileData
+ * @returns {mail.attachment}
+ */
+ _createAttachment(fileData) {
+ return this.env.models['mail.attachment'].create(Object.assign(
+ {},
+ fileData,
+ this.props.newAttachmentExtraData
+ ));
+ }
+
+ /**
+ * @private
+ * @param {File} file
+ * @returns {FormData}
+ */
+ _createFormData(file) {
+ let formData = new window.FormData();
+ formData.append('callback', this._fileUploadId);
+ formData.append('csrf_token', core.csrf_token);
+ formData.append('id', this.props.uploadId);
+ formData.append('model', this.props.uploadModel);
+ formData.append('ufile', file, file.name);
+ return formData;
+ }
+
+ /**
+ * @private
+ * @param {FileList|Array} files
+ */
+ _createTemporaryAttachments(files) {
+ for (const file of files) {
+ this._createAttachment({
+ filename: file.name,
+ isTemporary: true,
+ name: file.name
+ });
+ }
+ }
+ /**
+ * @private
+ * @param {FileList|Array} files
+ * @returns {Promise}
+ */
+ async _performUpload(files) {
+ for (const file of files) {
+ const response = await window.fetch('/web/binary/upload_attachment', {
+ method: 'POST',
+ body: this._createFormData(file),
+ });
+ let html = await response.text();
+ const template = document.createElement('template');
+ template.innerHTML = html.trim();
+ window.eval(template.content.firstChild.textContent);
+ }
+ }
+
+ /**
+ * @private
+ * @param {FileList|Array} files
+ * @returns {Promise}
+ */
+ async _unlinkExistingAttachments(files) {
+ for (const file of files) {
+ const attachment = this.props.attachmentLocalIds
+ .map(attachmentLocalId => this.env.models['mail.attachment'].get(attachmentLocalId))
+ .find(attachment => attachment.name === file.name && attachment.size === file.size);
+ // if the files already exits, delete the file before upload
+ if (attachment) {
+ attachment.remove();
+ }
+ }
+ }
+
+ //--------------------------------------------------------------------------
+ // Handlers
+ //--------------------------------------------------------------------------
+
+ /**
+ * @private
+ * @param {jQuery.Event} ev
+ * @param {...Object} filesData
+ */
+ async _onAttachmentUploaded(ev, ...filesData) {
+ for (const fileData of filesData) {
+ const { error, filename, id, mimetype, name, size } = fileData;
+ if (error || !id) {
+ this.env.services['notification'].notify({
+ type: 'danger',
+ message: owl.utils.escape(error),
+ });
+ const relatedTemporaryAttachments = this.env.models['mail.attachment']
+ .find(attachment =>
+ attachment.filename === filename &&
+ attachment.isTemporary
+ );
+ for (const attachment of relatedTemporaryAttachments) {
+ attachment.delete();
+ }
+ return;
+ }
+ // FIXME : needed to avoid problems on uploading
+ // Without this the useStore selector of component could be not called
+ // E.g. in attachment_box_tests.js
+ await new Promise(resolve => setTimeout(resolve));
+ const attachment = this._createAttachment({
+ filename,
+ id,
+ mimetype,
+ name,
+ size,
+ });
+ this.trigger('o-attachment-created', { attachment });
+ }
+ }
+
+ /**
+ * Called when there are changes in the file input.
+ *
+ * @private
+ * @param {Event} ev
+ * @param {EventTarget} ev.target
+ * @param {FileList|Array} ev.target.files
+ */
+ async _onChangeAttachment(ev) {
+ await this.uploadFiles(ev.target.files);
+ }
+
+}
+
+Object.assign(FileUploader, {
+ defaultProps: {
+ uploadId: 0,
+ uploadModel: 'mail.compose.message'
+ },
+ props: {
+ attachmentLocalIds: {
+ type: Array,
+ element: String,
+ },
+ newAttachmentExtraData: {
+ type: Object,
+ optional: true,
+ },
+ uploadId: Number,
+ uploadModel: String,
+ },
+ template: 'mail.FileUploader',
+});
+
+return FileUploader;
+
+});
diff --git a/addons/mail/static/src/components/file_uploader/file_uploader.scss b/addons/mail/static/src/components/file_uploader/file_uploader.scss
new file mode 100644
index 00000000000..32792313ed8
--- /dev/null
+++ b/addons/mail/static/src/components/file_uploader/file_uploader.scss
@@ -0,0 +1,3 @@
+.o_FileUploader_input {
+ display: none !important;
+}
diff --git a/addons/mail/static/src/components/file_uploader/file_uploader.xml b/addons/mail/static/src/components/file_uploader/file_uploader.xml
new file mode 100644
index 00000000000..bf144037511
--- /dev/null
+++ b/addons/mail/static/src/components/file_uploader/file_uploader.xml
@@ -0,0 +1,10 @@
+<?xml version="1.0" encoding="UTF-8"?>
+<templates xml:space="preserve">
+
+ <t t-name="mail.FileUploader" owl="1">
+ <div class="o_FileUploader">
+ <input class="o_FileUploader_input" t-on-change="_onChangeAttachment" multiple="true" type="file" t-ref="fileInput" t-key="'fileInput'"/>
+ </div>
+ </t>
+
+</templates>
diff --git a/addons/mail/static/src/components/file_uploader/file_uploader_tests.js b/addons/mail/static/src/components/file_uploader/file_uploader_tests.js
new file mode 100644
index 00000000000..a5831a89efb
--- /dev/null
+++ b/addons/mail/static/src/components/file_uploader/file_uploader_tests.js
@@ -0,0 +1,104 @@
+odoo.define('mail/static/src/components/file_uploader/file_uploader_tests.js', function (require) {
+"use strict";
+
+const components = {
+ FileUploader: require('mail/static/src/components/file_uploader/file_uploader.js'),
+};
+const {
+ afterEach: utilsAfterEach,
+ beforeEach: utilsBeforeEach,
+ inputFiles,
+ nextAnimationFrame,
+ start: utilsStart,
+} = require('mail/static/src/utils/test_utils.js');
+
+const { file: { createFile } } = require('web.test_utils');
+
+QUnit.module('mail', {}, function () {
+QUnit.module('components', {}, function () {
+QUnit.module('file_uploader', {}, function () {
+QUnit.module('file_uploader_tests.js', {
+ beforeEach() {
+ utilsBeforeEach(this);
+ this.components = [];
+
+ this.createFileUploaderComponent = async props => {
+ const FileUploaderComponent = components.FileUploader;
+ FileUploaderComponent.env = this.env;
+ const fileUploader = new FileUploaderComponent(
+ null,
+ Object.assign({ attachmentLocalIds: [] }, props)
+ );
+ await fileUploader.mount(this.widget.el);
+ this.components.push(fileUploader);
+ return fileUploader;
+ };
+
+ 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);
+ for (const fileUploader of this.components) {
+ fileUploader.destroy();
+ }
+ if (this.widget) {
+ this.widget.destroy();
+ }
+ delete components.FileUploader.env;
+ this.env = undefined;
+ },
+});
+
+QUnit.test('no conflicts between file uploaders', async function (assert) {
+ assert.expect(2);
+
+ await this.start();
+ const fileUploader1 = await this.createFileUploaderComponent();
+ const fileUploader2 = await this.createFileUploaderComponent();
+ const file1 = await createFile({
+ name: 'text1.txt',
+ content: 'hello, world',
+ contentType: 'text/plain',
+ });
+ inputFiles(
+ fileUploader1.el.querySelector('.o_FileUploader_input'),
+ [file1]
+ );
+ await nextAnimationFrame(); // we can't use afterNextRender as fileInput are display:none
+ assert.strictEqual(
+ this.env.models['mail.attachment'].all().length,
+ 1,
+ 'Uploaded file should be the only attachment created'
+ );
+
+ const file2 = await createFile({
+ name: 'text2.txt',
+ content: 'hello, world',
+ contentType: 'text/plain',
+ });
+ inputFiles(
+ fileUploader2.el.querySelector('.o_FileUploader_input'),
+ [file2]
+ );
+ await nextAnimationFrame();
+ assert.strictEqual(
+ this.env.models['mail.attachment'].all().length,
+ 2,
+ 'Uploaded file should be the only attachment added'
+ );
+});
+
+});
+});
+});
+
+});
diff --git a/addons/mail/static/src/components/follow_button/follow_button.js b/addons/mail/static/src/components/follow_button/follow_button.js
new file mode 100644
index 00000000000..b1639567c14
--- /dev/null
+++ b/addons/mail/static/src/components/follow_button/follow_button.js
@@ -0,0 +1,91 @@
+odoo.define('mail/static/src/components/follow_button/follow_button.js', function (require) {
+'use strict';
+
+const useStore = require('mail/static/src/component_hooks/use_store/use_store.js');
+
+const { Component } = owl;
+const { useState } = owl.hooks;
+
+class FollowButton extends Component {
+ /**
+ * @override
+ */
+ constructor(...args) {
+ super(...args);
+ this.state = useState({
+ /**
+ * Determine whether the unfollow button is highlighted or not.
+ */
+ isUnfollowButtonHighlighted: false,
+ });
+ useStore(props => {
+ const thread = this.env.models['mail.thread'].get(props.threadLocalId);
+ return {
+ thread: thread ? thread.__state : undefined,
+ };
+ });
+ }
+
+ //--------------------------------------------------------------------------
+ // Public
+ //--------------------------------------------------------------------------
+
+ /**
+ * @return {mail.thread}
+ */
+ get thread() {
+ return this.env.models['mail.thread'].get(this.props.threadLocalId);
+ }
+
+ //--------------------------------------------------------------------------
+ // Handlers
+ //--------------------------------------------------------------------------
+
+ /**
+ * @private
+ * @param {MouseEvent} ev
+ */
+ _onClickFollow(ev) {
+ this.thread.follow();
+ }
+
+ /**
+ * @private
+ * @param {MouseEvent} ev
+ */
+ _onClickUnfollow(ev) {
+ this.thread.unfollow();
+ }
+
+ /**
+ * @private
+ * @param {MouseEvent} ev
+ */
+ _onMouseLeaveUnfollow(ev) {
+ this.state.isUnfollowButtonHighlighted = false;
+ }
+
+ /**
+ * @private
+ * @param {MouseEvent} ev
+ */
+ _onMouseEnterUnfollow(ev) {
+ this.state.isUnfollowButtonHighlighted = true;
+ }
+
+}
+
+Object.assign(FollowButton, {
+ defaultProps: {
+ isDisabled: false,
+ },
+ props: {
+ isDisabled: Boolean,
+ threadLocalId: String,
+ },
+ template: 'mail.FollowButton',
+});
+
+return FollowButton;
+
+});
diff --git a/addons/mail/static/src/components/follow_button/follow_button.scss b/addons/mail/static/src/components/follow_button/follow_button.scss
new file mode 100644
index 00000000000..431de0e8577
--- /dev/null
+++ b/addons/mail/static/src/components/follow_button/follow_button.scss
@@ -0,0 +1,19 @@
+// ------------------------------------------------------------------
+// Style
+// ------------------------------------------------------------------
+
+.o_FollowButton_follow {
+ color: gray('600');
+}
+
+.o_FollowButton_unfollow {
+ color: gray('600');
+
+ &.o-following {
+ color: $green;
+ }
+
+ &.o-unfollow {
+ color: $orange;
+ }
+}
diff --git a/addons/mail/static/src/components/follow_button/follow_button.xml b/addons/mail/static/src/components/follow_button/follow_button.xml
new file mode 100644
index 00000000000..00fc8d65310
--- /dev/null
+++ b/addons/mail/static/src/components/follow_button/follow_button.xml
@@ -0,0 +1,24 @@
+<?xml version="1.0" encoding="UTF-8"?>
+<templates xml:space="preserve">
+
+ <t t-name="mail.FollowButton" owl="1">
+ <div class="o_FollowButton">
+ <t t-if="thread.isCurrentPartnerFollowing">
+ <button class="o_FollowButton_unfollow btn btn-link" t-att-class="{ 'o-following': !state.isUnfollowButtonHighlighted, 'o-unfollow': state.isUnfollowButtonHighlighted }" t-att-disabled="props.isDisabled" t-on-click="_onClickUnfollow" t-on-mouseenter="_onMouseEnterUnfollow" t-on-mouseleave="_onMouseLeaveUnfollow">
+ <t t-if="state.isUnfollowButtonHighlighted">
+ <i class="fa fa-times"/> Unfollow
+ </t>
+ <t t-else="">
+ <i class="fa fa-check"/> Following
+ </t>
+ </button>
+ </t>
+ <t t-else="">
+ <button class="o_FollowButton_follow btn btn-link" t-att-disabled="props.isDisabled" t-on-click="_onClickFollow">
+ Follow
+ </button>
+ </t>
+ </div>
+ </t>
+
+</templates>
diff --git a/addons/mail/static/src/components/follow_button/follow_button_tests.js b/addons/mail/static/src/components/follow_button/follow_button_tests.js
new file mode 100644
index 00000000000..a32cdb76e9d
--- /dev/null
+++ b/addons/mail/static/src/components/follow_button/follow_button_tests.js
@@ -0,0 +1,331 @@
+odoo.define('mail/static/src/components/follow_button/follow_button_tests.js', function (require) {
+'use strict';
+
+const components = {
+ FollowButton: require('mail/static/src/components/follow_button/follow_button.js'),
+};
+const {
+ afterEach: utilsAfterEach,
+ afterNextRender,
+ beforeEach: utilsBeforeEach,
+ start: utilsStart,
+} = require('mail/static/src/utils/test_utils.js');
+
+QUnit.module('mail', {}, function () {
+QUnit.module('components', {}, function () {
+QUnit.module('follow_button', {}, function () {
+QUnit.module('follow_button_tests.js', {
+ beforeEach() {
+ utilsBeforeEach(this);
+
+ this.createFollowButtonComponent = async (thread, otherProps = {}) => {
+ const FollowButtonComponent = components.FollowButton;
+ FollowButtonComponent.env = this.env;
+ this.component = new FollowButtonComponent(null,
+ Object.assign(otherProps, { threadLocalId: thread.localId })
+ );
+ await afterNextRender(() => this.component.mount(this.widget.el));
+ };
+
+ this.start = async params => {
+ let { env, widget } = await utilsStart(Object.assign({}, params, {
+ data: this.data,
+ }));
+ this.env = env;
+ this.widget = widget;
+ };
+ },
+ afterEach() {
+ utilsAfterEach(this);
+ if (this.component) {
+ this.component.destroy();
+ this.component = undefined;
+ }
+ if (this.widget) {
+ this.widget.destroy();
+ this.widget = undefined;
+ }
+ this.env = undefined;
+ delete components.FollowButton.env;
+ },
+});
+
+QUnit.test('base rendering not editable', async function (assert) {
+ assert.expect(3);
+
+ await this.start();
+ const thread = this.env.models['mail.thread'].create({
+ id: 100,
+ model: 'res.partner',
+ });
+ await this.createFollowButtonComponent(thread, { isDisabled: true });
+ assert.containsOnce(
+ document.body,
+ '.o_FollowButton',
+ "should have follow button component"
+ );
+ assert.containsOnce(
+ document.body,
+ '.o_FollowButton_follow',
+ "should have 'Follow' button"
+ );
+ assert.ok(
+ document.querySelector('.o_FollowButton_follow').disabled,
+ "'Follow' button should be disabled"
+ );
+});
+
+QUnit.test('base rendering editable', async function (assert) {
+ assert.expect(3);
+
+ await this.start();
+ const thread = this.env.models['mail.thread'].create({
+ id: 100,
+ model: 'res.partner',
+ });
+ await this.createFollowButtonComponent(thread);
+ assert.containsOnce(
+ document.body,
+ '.o_FollowButton',
+ "should have follow button component"
+ );
+ assert.containsOnce(
+ document.body,
+ '.o_FollowButton_follow',
+ "should have 'Follow' button"
+ );
+ assert.notOk(
+ document.querySelector('.o_FollowButton_follow').disabled,
+ "'Follow' button should be disabled"
+ );
+});
+
+QUnit.test('hover following button', async function (assert) {
+ assert.expect(8);
+ const self = this;
+
+ await this.start({
+ async mockRPC(route, args) {
+ if (route.includes('web/image/')) {
+ return;
+ } else if (route.includes('res.partner/read')) {
+ return [{
+ id: 100,
+ message_follower_ids: [1],
+ }];
+ } else if (route.includes('message_subscribe')) {
+ return;
+ } else if (route.includes('message_unsubscribe')) {
+ return;
+ } else if (route.includes('mail/read_followers')) {
+ return {
+ followers: [{
+ partner_id: self.env.session.partner_id,
+ email: "bla@bla.bla",
+ id: 1,
+ is_active: true,
+ is_editable: true,
+ name: "François Perusse",
+ }]
+ };
+ }
+ return this._super(...arguments);
+ },
+ });
+ const thread = this.env.models['mail.thread'].create({
+ id: 100,
+ model: 'res.partner',
+ });
+ thread.follow();
+ await this.createFollowButtonComponent(thread);
+ assert.containsOnce(
+ document.body,
+ '.o_FollowButton',
+ "should have follow button component"
+ );
+ assert.containsOnce(
+ document.body,
+ '.o_FollowButton_unfollow',
+ "should have 'Unfollow' button"
+ );
+ assert.strictEqual(
+ document.querySelector('.o_FollowButton_unfollow').textContent.trim(),
+ 'Following',
+ "'unfollow' button should display 'Following' as text when not hovered"
+ );
+ assert.containsNone(
+ document.querySelector('.o_FollowButton_unfollow'),
+ '.fa-times',
+ "'unfollow' button should not contain a cross icon when not hovered"
+ );
+ assert.containsOnce(
+ document.querySelector('.o_FollowButton_unfollow'),
+ '.fa-check',
+ "'unfollow' button should contain a check icon when not hovered"
+ );
+
+ await afterNextRender(() => {
+ document
+ .querySelector('.o_FollowButton_unfollow')
+ .dispatchEvent(new window.MouseEvent('mouseenter'));
+ }
+ );
+ assert.strictEqual(
+ document.querySelector('.o_FollowButton_unfollow').textContent.trim(),
+ 'Unfollow',
+ "'unfollow' button should display 'Unfollow' as text when hovered"
+ );
+ assert.containsOnce(
+ document.querySelector('.o_FollowButton_unfollow'),
+ '.fa-times',
+ "'unfollow' button should contain a cross icon when hovered"
+ );
+ assert.containsNone(
+ document.querySelector('.o_FollowButton_unfollow'),
+ '.fa-check',
+ "'unfollow' button should not contain a check icon when hovered"
+ );
+});
+
+QUnit.test('click on "follow" button', async function (assert) {
+ assert.expect(8);
+
+ await this.start({
+ async mockRPC(route, args) {
+ if (route.includes('web/image/')) {
+ return;
+ } else if (route.includes('res.partner/read')) {
+ assert.step('rpc:read_follower_ids');
+ return [{
+ id: 100,
+ message_follower_ids: [1],
+ }];
+ } else if (route.includes('message_subscribe')) {
+ assert.step('rpc:message_subscribe');
+ return;
+ } else if (route.includes('mail/read_followers')) {
+ assert.step('rpc:read_followers_details');
+ return {
+ followers: [{
+ partner_id: 3,
+ email: "bla@bla.bla",
+ id: 1,
+ is_active: true,
+ is_editable: true,
+ name: "François Perusse",
+ }]
+ };
+ }
+ return this._super(...arguments);
+ },
+ });
+ const thread = this.env.models['mail.thread'].create({
+ id: 100,
+ model: 'res.partner',
+ });
+ await this.createFollowButtonComponent(thread);
+ assert.containsOnce(
+ document.body,
+ '.o_FollowButton',
+ "should have follow button component"
+ );
+ assert.containsOnce(
+ document.body,
+ '.o_FollowButton_follow',
+ "should have button follow"
+ );
+
+ await afterNextRender(() => {
+ document.querySelector('.o_FollowButton_follow').click();
+ });
+ assert.verifySteps([
+ 'rpc:message_subscribe',
+ 'rpc:read_follower_ids',
+ 'rpc:read_followers_details',
+ ]);
+ assert.containsNone(
+ document.body,
+ '.o_FollowButton_follow',
+ "should not have follow button after clicked on follow"
+ );
+ assert.containsOnce(
+ document.body,
+ '.o_FollowButton_unfollow',
+ "should have unfollow button after clicked on follow"
+ );
+});
+
+QUnit.test('click on "unfollow" button', async function (assert) {
+ assert.expect(7);
+ const self = this;
+
+ await this.start({
+ async mockRPC(route, args) {
+ if (route.includes('web/image/')) {
+ return;
+ } else if (route.includes('res.partner/read')) {
+ return [{
+ id: 100,
+ message_follower_ids: [1],
+ }];
+ } else if (route.includes('message_subscribe')) {
+ return;
+ } else if (route.includes('message_unsubscribe')) {
+ assert.step('rpc:message_unsubscribe');
+ return;
+ } else if (route.includes('mail/read_followers')) {
+ return {
+ followers: [{
+ partner_id: self.env.session.partner_id,
+ email: "bla@bla.bla",
+ id: 1,
+ is_active: true,
+ is_editable: true,
+ name: "François Perusse",
+ }]
+ };
+ }
+ return this._super(...arguments);
+ },
+ });
+ const thread = this.env.models['mail.thread'].create({
+ id: 100,
+ model: 'res.partner',
+ });
+ thread.follow();
+ await this.createFollowButtonComponent(thread);
+ assert.containsOnce(
+ document.body,
+ '.o_FollowButton',
+ "should have follow button component"
+ );
+ assert.containsNone(
+ document.body,
+ '.o_FollowButton_follow',
+ "should not have button follow"
+ );
+ assert.containsOnce(
+ document.body,
+ '.o_FollowButton_unfollow',
+ "should have button unfollow"
+ );
+
+ await afterNextRender(() => document.querySelector('.o_FollowButton_unfollow').click());
+ assert.verifySteps(['rpc:message_unsubscribe']);
+ assert.containsOnce(
+ document.body,
+ '.o_FollowButton_follow',
+ "should have follow button after clicked on unfollow"
+ );
+ assert.containsNone(
+ document.body,
+ '.o_FollowButton_unfollow',
+ "should not have unfollow button after clicked on unfollow"
+ );
+});
+
+});
+});
+});
+
+});
diff --git a/addons/mail/static/src/components/follower/follower.js b/addons/mail/static/src/components/follower/follower.js
new file mode 100644
index 00000000000..f446c098914
--- /dev/null
+++ b/addons/mail/static/src/components/follower/follower.js
@@ -0,0 +1,81 @@
+odoo.define('mail/static/src/components/follower/follower.js', function (require) {
+'use strict';
+
+const components = {
+ FollowerSubtypeList: require('mail/static/src/components/follower_subtype_list/follower_subtype_list.js'),
+};
+const useStore = require('mail/static/src/component_hooks/use_store/use_store.js');
+
+const { Component } = owl;
+
+class Follower extends Component {
+
+ /**
+ * @override
+ */
+ constructor(...args) {
+ super(...args);
+ useStore(props => {
+ const follower = this.env.models['mail.follower'].get(props.followerLocalId);
+ return [follower ? follower.__state : undefined];
+ });
+ }
+
+ //--------------------------------------------------------------------------
+ // Public
+ //--------------------------------------------------------------------------
+
+ /**
+ * @returns {mail.follower}
+ */
+ get follower() {
+ return this.env.models['mail.follower'].get(this.props.followerLocalId);
+ }
+
+ //--------------------------------------------------------------------------
+ // Handlers
+ //--------------------------------------------------------------------------
+
+ /**
+ * @private
+ * @param {MouseEvent} ev
+ */
+ _onClickDetails(ev) {
+ ev.preventDefault();
+ ev.stopPropagation();
+ this.env.messaging.openDocument({
+ id: this.follower.resId,
+ model: this.follower.resModel,
+ });
+ }
+
+ /**
+ * @private
+ * @param {MouseEvent} ev
+ */
+ _onClickEdit(ev) {
+ ev.preventDefault();
+ this.follower.showSubtypes();
+ }
+
+ /**
+ * @private
+ * @param {MouseEvent} ev
+ */
+ _onClickRemove(ev) {
+ this.follower.remove();
+ }
+
+}
+
+Object.assign(Follower, {
+ components,
+ props: {
+ followerLocalId: String,
+ },
+ template: 'mail.Follower',
+});
+
+return Follower;
+
+});
diff --git a/addons/mail/static/src/components/follower/follower.scss b/addons/mail/static/src/components/follower/follower.scss
new file mode 100644
index 00000000000..509a119fb61
--- /dev/null
+++ b/addons/mail/static/src/components/follower/follower.scss
@@ -0,0 +1,55 @@
+// ------------------------------------------------------------------
+// Layout
+// ------------------------------------------------------------------
+
+.o_Follower {
+ display: flex;
+ flex-flow: row;
+ justify-content: space-between;
+ padding: map-get($spacers, 0);
+}
+
+.o_Follower_avatar {
+ width: 24px;
+ height: 24px;
+ margin-inline-end: map-get($spacers, 2);
+}
+
+.o_Follower_details {
+ align-items: center;
+ display: flex;
+ flex: 1;
+ padding-left: map-get($spacers, 3);
+ padding-right: map-get($spacers, 3);
+}
+
+// ------------------------------------------------------------------
+// Style
+// ------------------------------------------------------------------
+
+.o_Follower_avatar {
+ border-radius: 50%;
+}
+
+.o_Follower_button {
+ border-radius: 0;
+
+ &:hover {
+ background: gray('400');
+ color: $black;
+ }
+}
+
+.o_Follower_details {
+ color: gray('700');
+
+ &:hover {
+ background: gray('400');
+ color: $black;
+ }
+
+ &.o-inactive {
+ opacity: 0.25;
+ font-style: italic;
+ }
+}
diff --git a/addons/mail/static/src/components/follower/follower.xml b/addons/mail/static/src/components/follower/follower.xml
new file mode 100644
index 00000000000..26a26ece3f9
--- /dev/null
+++ b/addons/mail/static/src/components/follower/follower.xml
@@ -0,0 +1,23 @@
+<?xml version="1.0" encoding="UTF-8"?>
+<templates xml:space="preserve">
+
+ <t t-name="mail.Follower" owl="1">
+ <div class="o_Follower">
+ <t t-if="follower">
+ <a class="o_Follower_details" t-att-class="{ 'o-inactive': !follower.isActive }" href="#" t-on-click="_onClickDetails">
+ <img class="o_Follower_avatar" t-attf-src="/web/image/{{ follower.resModel }}/{{ follower.resId }}/image_128" alt="Avatar"/>
+ <span class="o_Follower_name" t-esc="follower.name"/>
+ </a>
+ <t t-if="follower.isEditable">
+ <button class="btn btn-icon o_Follower_button o_Follower_editButton" title="Edit subscription" t-on-click="_onClickEdit">
+ <i class="fa fa-pencil"/>
+ </button>
+ <button class="btn btn-icon o_Follower_button o_Follower_removeButton" title="Remove this follower" t-on-click="_onClickRemove">
+ <i class="fa fa-remove"/>
+ </button>
+ </t>
+ </t>
+ </div>
+ </t>
+
+</templates>
diff --git a/addons/mail/static/src/components/follower/follower_tests.js b/addons/mail/static/src/components/follower/follower_tests.js
new file mode 100644
index 00000000000..87e09dda2df
--- /dev/null
+++ b/addons/mail/static/src/components/follower/follower_tests.js
@@ -0,0 +1,354 @@
+odoo.define('mail/static/src/components/follower/follower_tests.js', function (require) {
+'use strict';
+
+const components = {
+ Follower: require('mail/static/src/components/follower/follower.js'),
+};
+const {
+ afterEach: utilsAfterEach,
+ afterNextRender,
+ beforeEach: utilsBeforeEach,
+ start: utilsStart,
+} = require('mail/static/src/utils/test_utils.js');
+
+const Bus = require('web.Bus');
+
+QUnit.module('mail', {}, function () {
+QUnit.module('components', {}, function () {
+QUnit.module('follower', {}, function () {
+QUnit.module('follower_tests.js', {
+ beforeEach() {
+ utilsBeforeEach(this);
+
+ this.createFollowerComponent = async (follower) => {
+ const FollowerComponent = components.Follower;
+ FollowerComponent.env = this.env;
+ this.component = new FollowerComponent(null, { followerLocalId: follower.localId });
+ await this.component.mount(this.widget.el);
+ };
+
+ this.start = async params => {
+ let { env, widget } = await utilsStart(Object.assign({}, params, {
+ data: this.data,
+ }));
+ this.env = env;
+ this.widget = widget;
+ };
+ },
+ afterEach() {
+ utilsAfterEach(this);
+ if (this.component) {
+ this.component.destroy();
+ this.component = undefined;
+ }
+ if (this.widget) {
+ this.widget.destroy();
+ this.widget = undefined;
+ }
+ this.env = undefined;
+ delete components.Follower.env;
+ },
+});
+
+QUnit.test('base rendering not editable', async function (assert) {
+ assert.expect(5);
+
+ await this.start({
+ async mockRPC(route, args) {
+ if (route === '/web/image/mail.channel/1/image_128') {
+ return;
+ }
+ return this._super(...arguments);
+ },
+ });
+
+ const thread = this.env.models['mail.thread'].create({
+ id: 100,
+ model: 'res.partner',
+ });
+ const follower = await this.env.models['mail.follower'].create({
+ channel: [['insert', { id: 1, model: 'mail.channel', name: "François Perusse" }]],
+ followedThread: [['link', thread]],
+ id: 2,
+ isActive: true,
+ isEditable: false,
+ });
+ await this.createFollowerComponent(follower);
+ assert.containsOnce(
+ document.body,
+ '.o_Follower',
+ "should have follower component"
+ );
+ assert.containsOnce(
+ document.body,
+ '.o_Follower_details',
+ "should display a details part"
+ );
+ assert.containsOnce(
+ document.body,
+ '.o_Follower_avatar',
+ "should display the avatar of the follower"
+ );
+ assert.containsOnce(
+ document.body,
+ '.o_Follower_name',
+ "should display the name of the follower"
+ );
+ assert.containsNone(
+ document.body,
+ '.o_Follower_button',
+ "should have no button as follower is not editable"
+ );
+});
+
+QUnit.test('base rendering editable', async function (assert) {
+ assert.expect(6);
+
+ await this.start({
+ async mockRPC(route, args) {
+ if (route === 'web/image/mail.channel/1/image_128') {
+ return;
+ }
+ return this._super(...arguments);
+ },
+ });
+ const thread = this.env.models['mail.thread'].create({
+ id: 100,
+ model: 'res.partner',
+ });
+ const follower = await this.env.models['mail.follower'].create({
+ channel: [['insert', { id: 1, model: 'mail.channel', name: "François Perusse" }]],
+ followedThread: [['link', thread]],
+ id: 2,
+ isActive: true,
+ isEditable: true,
+ });
+ await this.createFollowerComponent(follower);
+ assert.containsOnce(
+ document.body,
+ '.o_Follower',
+ "should have follower component"
+ );
+ assert.containsOnce(
+ document.body,
+ '.o_Follower_details',
+ "should display a details part"
+ );
+ assert.containsOnce(
+ document.body,
+ '.o_Follower_avatar',
+ "should display the avatar of the follower"
+ );
+ assert.containsOnce(
+ document.body,
+ '.o_Follower_name',
+ "should display the name of the follower"
+ );
+ assert.containsOnce(
+ document.body,
+ '.o_Follower_editButton',
+ "should have an edit button"
+ );
+ assert.containsOnce(
+ document.body,
+ '.o_Follower_removeButton',
+ "should have a remove button"
+ );
+});
+
+QUnit.test('click on channel follower details', async function (assert) {
+ assert.expect(7);
+
+ const bus = new Bus();
+ bus.on('do-action', null, payload => {
+ assert.step('do_action');
+ assert.strictEqual(
+ payload.action.res_id,
+ 1,
+ "The redirect action should redirect to the right res id (1)"
+ );
+ assert.strictEqual(
+ payload.action.res_model,
+ 'mail.channel',
+ "The redirect action should redirect to the right res model (mail.channel)"
+ );
+ assert.strictEqual(
+ payload.action.type,
+ "ir.actions.act_window",
+ "The redirect action should be of type 'ir.actions.act_window'"
+ );
+ });
+
+ await this.start({
+ env: { bus },
+ async mockRPC(route, args) {
+ if (route === 'web/image/mail.channel/1/image_128') {
+ return;
+ }
+ return this._super(...arguments);
+ },
+ });
+ const thread = this.env.models['mail.thread'].create({
+ id: 100,
+ model: 'res.partner',
+ });
+ const follower = await this.env.models['mail.follower'].create({
+ channel: [['insert', { id: 1, model: 'mail.channel', name: "channel" }]],
+ followedThread: [['link', thread]],
+ id: 2,
+ isActive: true,
+ isEditable: true,
+ });
+ await this.createFollowerComponent(follower);
+ assert.containsOnce(
+ document.body,
+ '.o_Follower',
+ "should have follower component"
+ );
+ assert.containsOnce(
+ document.body,
+ '.o_Follower_details',
+ "should display a details part"
+ );
+
+ document.querySelector('.o_Follower_details').click();
+ assert.verifySteps(
+ ['do_action'],
+ "clicking on channel should redirect to channel form view"
+ );
+});
+
+QUnit.test('click on partner follower details', async function (assert) {
+ assert.expect(7);
+
+ const bus = new Bus();
+ bus.on('do-action', null, payload => {
+ assert.step('do_action');
+ assert.strictEqual(
+ payload.action.res_id,
+ 3,
+ "The redirect action should redirect to the right res id (3)"
+ );
+ assert.strictEqual(
+ payload.action.res_model,
+ 'res.partner',
+ "The redirect action should redirect to the right res model (res.partner)"
+ );
+ assert.strictEqual(
+ payload.action.type,
+ "ir.actions.act_window",
+ "The redirect action should be of type 'ir.actions.act_window'"
+ );
+ });
+
+ await this.start({
+ env: { bus },
+ async mockRPC(route, args) {
+ if (route === 'web/image/res.partner/3/image_128') {
+ return;
+ }
+ return this._super(...arguments);
+ },
+ });
+ const thread = this.env.models['mail.thread'].create({
+ id: 100,
+ model: 'res.partner',
+ });
+ const follower = await this.env.models['mail.follower'].create({
+ followedThread: [['link', thread]],
+ id: 2,
+ isActive: true,
+ isEditable: true,
+ partner: [['insert', {
+ email: "bla@bla.bla",
+ id: this.env.session.partner_id,
+ name: "François Perusse",
+ }]],
+ });
+ await this.createFollowerComponent(follower);
+ assert.containsOnce(
+ document.body,
+ '.o_Follower',
+ "should have follower component"
+ );
+ assert.containsOnce(
+ document.body,
+ '.o_Follower_details',
+ "should display a details part"
+ );
+
+ document.querySelector('.o_Follower_details').click();
+ assert.verifySteps(
+ ['do_action'],
+ "clicking on follower should redirect to partner form view"
+ );
+});
+
+QUnit.test('click on edit follower', async function (assert) {
+ assert.expect(5);
+
+ await this.start({
+ hasDialog: true,
+ async mockRPC(route, args) {
+ if (route === 'web/image/res.partner/3/image_128') {
+ return;
+ }
+ if (route.includes('/mail/read_subscription_data')) {
+ assert.step('fetch_subtypes');
+ return [{
+ default: true,
+ followed: true,
+ internal: false,
+ id: 1,
+ name: "Dummy test",
+ res_model: 'res.partner'
+ }];
+ }
+ return this._super(...arguments);
+ },
+ });
+ const thread = this.env.models['mail.thread'].create({
+ id: 100,
+ model: 'res.partner',
+ });
+ const follower = await this.env.models['mail.follower'].create({
+ followedThread: [['link', thread]],
+ id: 2,
+ isActive: true,
+ isEditable: true,
+ partner: [['insert', {
+ email: "bla@bla.bla",
+ id: this.env.session.partner_id,
+ name: "François Perusse",
+ }]],
+ });
+ await this.createFollowerComponent(follower);
+ assert.containsOnce(
+ document.body,
+ '.o_Follower',
+ "should have follower component"
+ );
+ assert.containsOnce(
+ document.body,
+ '.o_Follower_editButton',
+ "should display an edit button"
+ );
+
+ await afterNextRender(() => document.querySelector('.o_Follower_editButton').click());
+ assert.verifySteps(
+ ['fetch_subtypes'],
+ "clicking on edit follower should fetch subtypes"
+ );
+
+ assert.containsOnce(
+ document.body,
+ '.o_FollowerSubtypeList',
+ "A dialog allowing to edit follower subtypes should have been created"
+ );
+});
+
+});
+});
+});
+
+});
diff --git a/addons/mail/static/src/components/follower_list_menu/follower_list_menu.js b/addons/mail/static/src/components/follower_list_menu/follower_list_menu.js
new file mode 100644
index 00000000000..54eac6e284c
--- /dev/null
+++ b/addons/mail/static/src/components/follower_list_menu/follower_list_menu.js
@@ -0,0 +1,152 @@
+odoo.define('mail/static/src/components/follower_list_menu/follower_list_menu.js', function (require) {
+'use strict';
+
+const components = {
+ Follower: require('mail/static/src/components/follower/follower.js'),
+};
+const useStore = require('mail/static/src/component_hooks/use_store/use_store.js');
+
+const { Component } = owl;
+const { useRef, useState } = owl.hooks;
+
+class FollowerListMenu extends Component {
+ /**
+ * @override
+ */
+ constructor(...args) {
+ super(...args);
+ this.state = useState({
+ /**
+ * Determine whether the dropdown is open or not.
+ */
+ isDropdownOpen: false,
+ });
+ useStore(props => {
+ const thread = this.env.models['mail.thread'].get(props.threadLocalId);
+ const followers = thread ? thread.followers : [];
+ return {
+ followers: followers.map(follower => follower.__state),
+ thread: thread ? thread.__state : undefined,
+ };
+ }, {
+ compareDepth: {
+ followers: 1,
+ },
+ });
+ this._dropdownRef = useRef('dropdown');
+ this._onClickCaptureGlobal = this._onClickCaptureGlobal.bind(this);
+ }
+
+ mounted() {
+ document.addEventListener('click', this._onClickCaptureGlobal, true);
+ }
+
+ willUnmount() {
+ document.removeEventListener('click', this._onClickCaptureGlobal, true);
+ }
+
+ //--------------------------------------------------------------------------
+ // Public
+ //--------------------------------------------------------------------------
+
+ /**
+ * @return {mail.thread}
+ */
+ get thread() {
+ return this.env.models['mail.thread'].get(this.props.threadLocalId);
+ }
+
+ //--------------------------------------------------------------------------
+ // Private
+ //--------------------------------------------------------------------------
+
+ /**
+ * @private
+ */
+ _hide() {
+ this.state.isDropdownOpen = false;
+ }
+
+ /**
+ * @private
+ * @param {KeyboardEvent} ev
+ */
+ _onKeydown(ev) {
+ ev.stopPropagation();
+ switch (ev.key) {
+ case 'Escape':
+ ev.preventDefault();
+ this._hide();
+ break;
+ }
+ }
+
+ //--------------------------------------------------------------------------
+ // Handlers
+ //--------------------------------------------------------------------------
+
+ /**
+ * @private
+ * @param {MouseEvent} ev
+ */
+ _onClickAddChannels(ev) {
+ ev.preventDefault();
+ this._hide();
+ this.thread.promptAddChannelFollower();
+ }
+
+ /**
+ * @private
+ * @param {MouseEvent} ev
+ */
+ _onClickAddFollowers(ev) {
+ ev.preventDefault();
+ this._hide();
+ this.thread.promptAddPartnerFollower();
+ }
+
+ /**
+ * Close the dropdown when clicking outside of it.
+ *
+ * @private
+ * @param {MouseEvent} ev
+ */
+ _onClickCaptureGlobal(ev) {
+ // since dropdown is conditionally shown based on state, dropdownRef can be null
+ if (this._dropdownRef.el && !this._dropdownRef.el.contains(ev.target)) {
+ this._hide();
+ }
+ }
+
+ /**
+ * @private
+ * @param {MouseEvent} ev
+ */
+ _onClickFollowersButton(ev) {
+ this.state.isDropdownOpen = !this.state.isDropdownOpen;
+ }
+
+ /**
+ * @private
+ * @param {MouseEvent} ev
+ */
+ _onClickFollower(ev) {
+ this._hide();
+ }
+}
+
+Object.assign(FollowerListMenu, {
+ components,
+ defaultProps: {
+ isDisabled: false,
+ },
+ props: {
+ isDisabled: Boolean,
+ threadLocalId: String,
+ },
+ template: 'mail.FollowerListMenu',
+});
+
+return FollowerListMenu;
+
+});
diff --git a/addons/mail/static/src/components/follower_list_menu/follower_list_menu.scss b/addons/mail/static/src/components/follower_list_menu/follower_list_menu.scss
new file mode 100644
index 00000000000..2fa52819c59
--- /dev/null
+++ b/addons/mail/static/src/components/follower_list_menu/follower_list_menu.scss
@@ -0,0 +1,19 @@
+// ------------------------------------------------------------------
+// Layout
+// ------------------------------------------------------------------
+
+.o_FollowerListMenu {
+ position: relative;
+}
+
+.o_FollowerListMenu_dropdown {
+ display: flex;
+ flex-flow: column;
+ padding-top: map-get($spacers, 0);
+ padding-bottom: map-get($spacers, 0);
+ overflow-y: auto;
+}
+
+.o_FollowerListMenu_followers {
+ display: flex;
+}
diff --git a/addons/mail/static/src/components/follower_list_menu/follower_list_menu.xml b/addons/mail/static/src/components/follower_list_menu/follower_list_menu.xml
new file mode 100644
index 00000000000..5bf6c129dda
--- /dev/null
+++ b/addons/mail/static/src/components/follower_list_menu/follower_list_menu.xml
@@ -0,0 +1,36 @@
+<?xml version="1.0" encoding="UTF-8"?>
+<templates xml:space="preserve">
+
+ <t t-name="mail.FollowerListMenu" owl="1">
+ <div class="o_FollowerListMenu" t-on-keydown="_onKeydown">
+ <div class="o_FollowerListMenu_followers" t-ref="dropdown">
+ <button class="o_FollowerListMenu_buttonFollowers btn btn-link" t-att-disabled="props.isDisabled" t-on-click="_onClickFollowersButton" title="Show Followers">
+ <i class="fa fa-user"/>
+ <span class="o_FollowerListMenu_buttonFollowersCount pl-1" t-esc="thread.followers.length"/>
+ </button>
+
+ <t t-if="state.isDropdownOpen">
+ <div class="o_FollowerListMenu_dropdown dropdown-menu dropdown-menu-right" role="menu">
+ <a class="o_FollowerListMenu_addFollowersButton dropdown-item" href="#" role="menuitem" t-on-click="_onClickAddFollowers">
+ Add Followers
+ </a>
+ <a class="o_FollowerListMenu_addChannelsButton dropdown-item" href="#" role="menuitem" t-on-click="_onClickAddChannels">
+ Add Channels
+ </a>
+ <t t-if="thread.followers.length > 0">
+ <div role="separator" class="dropdown-divider"/>
+ <t t-foreach="thread.followers" t-as="follower" t-key="follower.localId">
+ <Follower
+ class="o_FollowerMenu_follower dropdown-item"
+ followerLocalId="follower.localId"
+ t-on-click="_onClickFollower"
+ />
+ </t>
+ </t>
+ </div>
+ </t>
+ </div>
+ </div>
+ </t>
+
+</templates>
diff --git a/addons/mail/static/src/components/follower_list_menu/follower_list_menu_tests.js b/addons/mail/static/src/components/follower_list_menu/follower_list_menu_tests.js
new file mode 100644
index 00000000000..b67c87398ed
--- /dev/null
+++ b/addons/mail/static/src/components/follower_list_menu/follower_list_menu_tests.js
@@ -0,0 +1,465 @@
+odoo.define('mail/static/src/components/follower_list_menu/follower_list_menu_tests.js', function (require) {
+'use strict';
+
+const components = {
+ FollowerListMenu: require('mail/static/src/components/follower_list_menu/follower_list_menu.js'),
+};
+const {
+ afterEach: utilsAfterEach,
+ afterNextRender,
+ beforeEach: utilsBeforeEach,
+ start: utilsStart,
+} = require('mail/static/src/utils/test_utils.js');
+
+const Bus = require('web.Bus');
+
+QUnit.module('mail', {}, function () {
+QUnit.module('components', {}, function () {
+QUnit.module('follower_list_menu', {}, function () {
+QUnit.module('follower_list_menu_tests.js', {
+ beforeEach() {
+ utilsBeforeEach(this);
+
+ this.createFollowerListMenuComponent = async (thread, otherProps = {}) => {
+ const FollowerListMenuComponent = components.FollowerListMenu;
+ FollowerListMenuComponent.env = this.env;
+ this.component = new FollowerListMenuComponent(null,
+ Object.assign(otherProps, { threadLocalId: thread.localId })
+ );
+ await this.component.mount(this.widget.el);
+ };
+
+ this.start = async params => {
+ let { env, widget } = await utilsStart(Object.assign({}, params, {
+ data: this.data,
+ }));
+ this.env = env;
+ this.widget = widget;
+ };
+ },
+ afterEach() {
+ utilsAfterEach(this);
+ if (this.component) {
+ this.component.destroy();
+ this.component = undefined;
+ }
+ if (this.widget) {
+ this.widget.destroy();
+ this.widget = undefined;
+ }
+ this.env = undefined;
+ delete components.FollowerListMenu.env;
+ },
+});
+
+QUnit.test('base rendering not editable', async function (assert) {
+ assert.expect(5);
+
+ await this.start();
+ const thread = this.env.models['mail.thread'].create({
+ id: 100,
+ model: 'res.partner',
+ });
+ await this.createFollowerListMenuComponent(thread, { isDisabled: true });
+ assert.containsOnce(
+ document.body,
+ '.o_FollowerListMenu',
+ "should have followers menu component"
+ );
+ assert.containsOnce(
+ document.body,
+ '.o_FollowerListMenu_buttonFollowers',
+ "should have followers button"
+ );
+ assert.ok(
+ document.querySelector('.o_FollowerListMenu_buttonFollowers').disabled,
+ "followers button should be disabled"
+ );
+ assert.containsNone(
+ document.body,
+ '.o_FollowerListMenu_dropdown',
+ "followers dropdown should not be opened"
+ );
+
+ document.querySelector('.o_FollowerListMenu_buttonFollowers').click();
+ assert.containsNone(
+ document.body,
+ '.o_FollowerListMenu_dropdown',
+ "followers dropdown should still be closed as button is disabled"
+ );
+});
+
+QUnit.test('base rendering editable', async function (assert) {
+ assert.expect(5);
+
+ await this.start();
+ const thread = this.env.models['mail.thread'].create({
+ id: 100,
+ model: 'res.partner',
+ });
+ await this.createFollowerListMenuComponent(thread);
+
+ assert.containsOnce(
+ document.body,
+ '.o_FollowerListMenu',
+ "should have followers menu component"
+ );
+ assert.containsOnce(
+ document.body,
+ '.o_FollowerListMenu_buttonFollowers',
+ "should have followers button"
+ );
+ assert.notOk(
+ document.querySelector('.o_FollowerListMenu_buttonFollowers').disabled,
+ "followers button should not be disabled"
+ );
+ assert.containsNone(
+ document.body,
+ '.o_FollowerListMenu_dropdown',
+ "followers dropdown should not be opened"
+ );
+
+ await afterNextRender(() => {
+ document.querySelector('.o_FollowerListMenu_buttonFollowers').click();
+ });
+ assert.containsOnce(
+ document.body,
+ '.o_FollowerListMenu_dropdown',
+ "followers dropdown should be opened"
+ );
+});
+
+QUnit.test('click on "add followers" button', async function (assert) {
+ assert.expect(18);
+
+ const followerIds = [];
+ const bus = new Bus();
+ bus.on('do-action', null, payload => {
+ assert.step('action:open_view');
+ assert.strictEqual(
+ payload.action.context.default_res_model,
+ 'res.partner',
+ "'The 'add followers' action should contain thread model in context'"
+ );
+ assert.notOk(
+ payload.action.context.mail_invite_follower_channel_only,
+ "The 'add followers' action should not be restricted to channels only"
+ );
+ assert.strictEqual(
+ payload.action.context.default_res_id,
+ 100,
+ "The 'add followers' action should contain thread id in context"
+ );
+ assert.strictEqual(
+ payload.action.res_model,
+ 'mail.wizard.invite',
+ "The 'add followers' action should be a wizard invite of mail module"
+ );
+ assert.strictEqual(
+ payload.action.type,
+ "ir.actions.act_window",
+ "The 'add followers' action should be of type 'ir.actions.act_window'"
+ );
+ followerIds.push(1);
+ payload.options.on_close();
+ });
+
+ await this.start({
+ env: { bus },
+ async mockRPC(route, args) {
+ if (route.includes('web/image/')) {
+ return;
+ } else if (route.includes('res.partner/read')) {
+ assert.step('rpc:read_follower_ids');
+ return [{
+ id: 100,
+ message_follower_ids: followerIds,
+ }];
+ } else if (route.includes('mail/read_followers')) {
+ assert.step('rpc:read_followers_details');
+ return {
+ followers: [{
+ partner_id: 42,
+ email: "bla@bla.bla",
+ id: 1,
+ is_active: true,
+ is_editable: true,
+ name: "François Perusse",
+ }]
+ };
+ }
+ return this._super(...arguments);
+ },
+ });
+ const thread = this.env.models['mail.thread'].create({
+ id: 100,
+ model: 'res.partner',
+ });
+ await this.createFollowerListMenuComponent(thread);
+
+ assert.containsOnce(
+ document.body,
+ '.o_FollowerListMenu',
+ "should have followers menu component"
+ );
+ assert.containsOnce(
+ document.body,
+ '.o_FollowerListMenu_buttonFollowers',
+ "should have followers button"
+ );
+ assert.strictEqual(
+ document.querySelector('.o_FollowerListMenu_buttonFollowersCount').textContent,
+ "0",
+ "Followers counter should be equal to 0"
+ );
+
+ await afterNextRender(() => {
+ document.querySelector('.o_FollowerListMenu_buttonFollowers').click();
+ });
+ assert.containsOnce(
+ document.body,
+ '.o_FollowerListMenu_dropdown',
+ "followers dropdown should be opened"
+ );
+ assert.containsOnce(
+ document.body,
+ '.o_FollowerListMenu_addFollowersButton',
+ "followers dropdown should contain a 'Add followers' button"
+ );
+
+ await afterNextRender(() => {
+ document.querySelector('.o_FollowerListMenu_addFollowersButton').click();
+ });
+ assert.containsNone(
+ document.body,
+ '.o_FollowerListMenu_dropdown',
+ "followers dropdown should be closed after click on 'Add followers'"
+ );
+ assert.verifySteps([
+ 'action:open_view',
+ 'rpc:read_follower_ids',
+ 'rpc:read_followers_details',
+ ]);
+ assert.strictEqual(
+ document.querySelector('.o_FollowerListMenu_buttonFollowersCount').textContent,
+ "1",
+ "Followers counter should now be equal to 1"
+ );
+
+ await afterNextRender(() => {
+ document.querySelector('.o_FollowerListMenu_buttonFollowers').click();
+ });
+ assert.containsOnce(
+ document.body,
+ '.o_FollowerMenu_follower',
+ "Follower list should be refreshed and contain a follower"
+ );
+ assert.strictEqual(
+ document.querySelector('.o_Follower_name').textContent,
+ "François Perusse",
+ "Follower added in follower list should be the one added"
+ );
+});
+
+QUnit.test('click on "add channels" button', async function (assert) {
+ assert.expect(18);
+
+ const followerIds = [];
+ const bus = new Bus();
+ bus.on('do-action', null, payload => {
+ assert.step('action:open_view');
+ assert.strictEqual(
+ payload.action.context.default_res_model,
+ 'res.partner',
+ "'The 'add channels' action should contain thread model in context'"
+ );
+ assert.ok(
+ payload.action.context.mail_invite_follower_channel_only,
+ "The 'add channels' action should be restricted to channels only"
+ );
+ assert.strictEqual(
+ payload.action.context.default_res_id,
+ 100,
+ "The 'add channels' action should contain thread id in context"
+ );
+ assert.strictEqual(
+ payload.action.res_model,
+ 'mail.wizard.invite',
+ "The 'add channels' action should be a wizard invite of mail module"
+ );
+ assert.strictEqual(
+ payload.action.type,
+ "ir.actions.act_window",
+ "The 'add channels' action should be of type 'ir.actions.act_window'"
+ );
+ followerIds.push(1);
+ payload.options.on_close();
+ });
+ await this.start({
+ env: { bus },
+ async mockRPC(route, args) {
+ if (route.includes('web/image/')) {
+ return;
+ } else if (route.includes('res.partner/read')) {
+ assert.step('rpc:read_follower_ids');
+ return [{
+ id: 100,
+ message_follower_ids: followerIds,
+ }];
+ } else if (route.includes('mail/read_followers')) {
+ assert.step('rpc:read_followers_details');
+ return {
+ followers: [{
+ channel_id: 42,
+ email: "bla@bla.bla",
+ id: 1,
+ is_active: true,
+ is_editable: true,
+ name: "Supa channel",
+ }]
+ };
+ }
+ return this._super(...arguments);
+ },
+ });
+ const thread = this.env.models['mail.thread'].create({
+ id: 100,
+ model: 'res.partner',
+ });
+ await this.createFollowerListMenuComponent(thread);
+
+ assert.containsOnce(
+ document.body,
+ '.o_FollowerListMenu',
+ "should have followers menu component"
+ );
+ assert.strictEqual(
+ document.querySelector('.o_FollowerListMenu_buttonFollowersCount').textContent,
+ "0",
+ "Followers counter should be equal to 0"
+ );
+ assert.containsOnce(
+ document.body,
+ '.o_FollowerListMenu_buttonFollowers',
+ "should have followers button"
+ );
+
+ await afterNextRender(() => {
+ document.querySelector('.o_FollowerListMenu_buttonFollowers').click();
+ });
+ assert.containsOnce(
+ document.body,
+ '.o_FollowerListMenu_dropdown',
+ "followers dropdown should be opened"
+ );
+ assert.containsOnce(
+ document.body,
+ '.o_FollowerListMenu_addChannelsButton',
+ "followers dropdown should contain a 'Add channels' button"
+ );
+
+ await afterNextRender(() => {
+ document.querySelector('.o_FollowerListMenu_addChannelsButton').click();
+ });
+ assert.containsNone(
+ document.body,
+ '.o_FollowerListMenu_dropdown',
+ "followers dropdown should be closed after click on 'add channels'"
+ );
+ assert.verifySteps([
+ 'action:open_view',
+ 'rpc:read_follower_ids',
+ 'rpc:read_followers_details',
+ ]);
+ assert.strictEqual(
+ document.querySelector('.o_FollowerListMenu_buttonFollowersCount').textContent,
+ "1",
+ "Followers counter should now be equal to 1"
+ );
+
+ await afterNextRender(() => {
+ document.querySelector('.o_FollowerListMenu_buttonFollowers').click();
+ });
+ assert.containsOnce(
+ document.body,
+ '.o_FollowerMenu_follower',
+ "Follower list should be refreshed and contain a follower"
+ );
+ assert.strictEqual(
+ document.querySelector('.o_Follower_name').textContent,
+ "Supa channel",
+ "Follower added in follower list should be the one added"
+ );
+});
+
+QUnit.test('click on remove follower', async function (assert) {
+ assert.expect(6);
+
+ const self = this;
+ await this.start({
+ async mockRPC(route, args) {
+ if (route.includes('web/image/')) {
+ return;
+ } else if (route.includes('message_unsubscribe')) {
+ assert.step('message_unsubscribe');
+ // FIXME "integration" test, should be replaced ... maybe by a tour.
+ assert.deepEqual(
+ args.args,
+ [[100],[self.env.session.partner_id]],
+ "message unsubscribe should be called with right argument"
+ );
+ return;
+ }
+ return this._super(...arguments);
+ },
+ });
+ const thread = this.env.models['mail.thread'].create({
+ id: 100,
+ model: 'res.partner',
+ });
+ await this.env.models['mail.follower'].create({
+ followedThread: [['link', thread]],
+ id: 2,
+ isActive: true,
+ isEditable: true,
+ partner: [['insert', {
+ email: "bla@bla.bla",
+ id: this.env.session.partner_id,
+ name: "François Perusse",
+ }]],
+ });
+ await this.createFollowerListMenuComponent(thread);
+
+ await afterNextRender(() => {
+ document.querySelector('.o_FollowerListMenu_buttonFollowers').click();
+ });
+ assert.containsOnce(
+ document.body,
+ '.o_Follower',
+ "should have follower component"
+ );
+ assert.containsOnce(
+ document.body,
+ '.o_Follower_removeButton',
+ "should display a remove button"
+ );
+
+ await afterNextRender(() => {
+ document.querySelector('.o_Follower_removeButton').click();
+ });
+ assert.verifySteps(
+ ['message_unsubscribe'],
+ "clicking on remove button should call 'message_unsubscribe' route"
+ );
+ assert.containsNone(
+ document.body,
+ '.o_Follower',
+ "should no more have follower component"
+ );
+});
+
+});
+});
+});
+
+});
diff --git a/addons/mail/static/src/components/follower_subtype/follower_subtype.js b/addons/mail/static/src/components/follower_subtype/follower_subtype.js
new file mode 100644
index 00000000000..8ff47ada34f
--- /dev/null
+++ b/addons/mail/static/src/components/follower_subtype/follower_subtype.js
@@ -0,0 +1,69 @@
+odoo.define('mail/static/src/components/follower_subtype/follower_subtype.js', function (require) {
+'use strict';
+
+const useStore = require('mail/static/src/component_hooks/use_store/use_store.js');
+
+const { Component } = owl;
+
+class FollowerSubtype extends Component {
+
+ /**
+ * @override
+ */
+ constructor(...args) {
+ super(...args);
+ useStore(props => {
+ const followerSubtype = this.env.models['mail.follower_subtype'].get(props.followerSubtypeLocalId);
+ return [followerSubtype ? followerSubtype.__state : undefined];
+ });
+ }
+
+ //--------------------------------------------------------------------------
+ // Public
+ //--------------------------------------------------------------------------
+
+ /**
+ * @returns {mail.follower|undefined}
+ */
+ get follower() {
+ return this.env.models['mail.follower'].get(this.props.followerLocalId);
+ }
+
+ /**
+ * @returns {mail.follower_subtype}
+ */
+ get followerSubtype() {
+ return this.env.models['mail.follower_subtype'].get(this.props.followerSubtypeLocalId);
+ }
+
+ //--------------------------------------------------------------------------
+ // Handlers
+ //--------------------------------------------------------------------------
+
+ /**
+ * Called when clicking on cancel button.
+ *
+ * @private
+ * @param {Event} ev
+ */
+ _onChangeCheckbox(ev) {
+ if (ev.target.checked) {
+ this.follower.selectSubtype(this.followerSubtype);
+ } else {
+ this.follower.unselectSubtype(this.followerSubtype);
+ }
+ }
+
+}
+
+Object.assign(FollowerSubtype, {
+ props: {
+ followerLocalId: String,
+ followerSubtypeLocalId: String,
+ },
+ template: 'mail.FollowerSubtype',
+});
+
+return FollowerSubtype;
+
+});
diff --git a/addons/mail/static/src/components/follower_subtype/follower_subtype.scss b/addons/mail/static/src/components/follower_subtype/follower_subtype.scss
new file mode 100644
index 00000000000..3be0ad4656e
--- /dev/null
+++ b/addons/mail/static/src/components/follower_subtype/follower_subtype.scss
@@ -0,0 +1,27 @@
+// ------------------------------------------------------------------
+// Layout
+// ------------------------------------------------------------------
+
+.o_FollowerSubtype_checkbox {
+ margin-inline-end: map-get($spacers, 2);
+}
+
+.o_FollowerSubtype_label {
+ display: flex;
+ flex: 1;
+ flex-direction: row;
+ align-items: center;
+ margin-bottom: map-get($spacers, 0);
+ padding: map-get($spacers, 2);
+}
+
+// ------------------------------------------------------------------
+// Style
+// ------------------------------------------------------------------
+
+.o_FollowerSubtype_label {
+ cursor: pointer;
+ &:hover {
+ background-color: gray('200');
+ }
+}
diff --git a/addons/mail/static/src/components/follower_subtype/follower_subtype.xml b/addons/mail/static/src/components/follower_subtype/follower_subtype.xml
new file mode 100644
index 00000000000..b23800099d6
--- /dev/null
+++ b/addons/mail/static/src/components/follower_subtype/follower_subtype.xml
@@ -0,0 +1,13 @@
+<?xml version="1.0" encoding="UTF-8"?>
+<templates xml:space="preserve">
+
+ <t t-name="mail.FollowerSubtype" owl="1">
+ <div class="o_FollowerSubtype">
+ <label class="o_FollowerSubtype_label">
+ <input class="o_FollowerSubtype_checkbox" type="checkbox" t-att-checked="follower.selectedSubtypes.includes(followerSubtype) ? 'checked': ''" t-on-change="_onChangeCheckbox"/>
+ <t t-esc="followerSubtype.name"/>
+ </label>
+ </div>
+ </t>
+
+</templates>
diff --git a/addons/mail/static/src/components/follower_subtype/follower_subtype_tests.js b/addons/mail/static/src/components/follower_subtype/follower_subtype_tests.js
new file mode 100644
index 00000000000..ac5baa13ec3
--- /dev/null
+++ b/addons/mail/static/src/components/follower_subtype/follower_subtype_tests.js
@@ -0,0 +1,241 @@
+odoo.define('mail/static/src/components/follower_subtype/follower_subtype_tests.js', function (require) {
+'use strict';
+
+const components = {
+ FollowerSubtype: require('mail/static/src/components/follower_subtype/follower_subtype.js'),
+};
+const {
+ afterEach: utilsAfterEach,
+ afterNextRender,
+ beforeEach: utilsBeforeEach,
+ start: utilsStart,
+} = require('mail/static/src/utils/test_utils.js');
+
+QUnit.module('mail', {}, function () {
+QUnit.module('components', {}, function () {
+QUnit.module('follower_subtype', {}, function () {
+QUnit.module('follower_subtype_tests.js', {
+ beforeEach() {
+ utilsBeforeEach(this);
+
+ this.createFollowerSubtypeComponent = async ({ follower, followerSubtype }) => {
+ const FollowerSubtypeComponent = components.FollowerSubtype;
+ FollowerSubtypeComponent.env = this.env;
+ this.component = new FollowerSubtypeComponent(null, {
+ followerLocalId: follower.localId,
+ followerSubtypeLocalId: followerSubtype.localId,
+ });
+ await this.component.mount(this.widget.el);
+ };
+
+ this.start = async params => {
+ let { env, widget } = await utilsStart(Object.assign({}, params, {
+ data: this.data,
+ }));
+ this.env = env;
+ this.widget = widget;
+ };
+ },
+ afterEach() {
+ utilsAfterEach(this);
+ if (this.component) {
+ this.component.destroy();
+ this.component = undefined;
+ }
+ if (this.widget) {
+ this.widget.destroy();
+ this.widget = undefined;
+ }
+ this.env = undefined;
+ delete components.FollowerSubtype.env;
+ }
+});
+
+QUnit.test('simplest layout of a followed subtype', async function (assert) {
+ assert.expect(5);
+
+ await this.start();
+
+ const thread = this.env.models['mail.thread'].create({
+ id: 100,
+ model: 'res.partner',
+ });
+ const follower = this.env.models['mail.follower'].create({
+ channel: [['insert', {
+ id: 1,
+ model: 'mail.channel',
+ name: "François Perusse",
+ }]],
+ followedThread: [['link', thread]],
+ id: 2,
+ isActive: true,
+ isEditable: true,
+ });
+ const followerSubtype = this.env.models['mail.follower_subtype'].create({
+ id: 1,
+ isDefault: true,
+ isInternal: false,
+ name: "Dummy test",
+ resModel: 'res.partner'
+ });
+ follower.update({
+ selectedSubtypes: [['link', followerSubtype]],
+ subtypes: [['link', followerSubtype]],
+ });
+ await this.createFollowerSubtypeComponent({
+ follower,
+ followerSubtype,
+ });
+ assert.containsOnce(
+ document.body,
+ '.o_FollowerSubtype',
+ "should have follower subtype component"
+ );
+ assert.containsOnce(
+ document.body,
+ '.o_FollowerSubtype_label',
+ "should have a label"
+ );
+ assert.containsOnce(
+ document.body,
+ '.o_FollowerSubtype_checkbox',
+ "should have a checkbox"
+ );
+ assert.strictEqual(
+ document.querySelector('.o_FollowerSubtype_label').textContent,
+ "Dummy test",
+ "should have the name of the subtype as label"
+ );
+ assert.ok(
+ document.querySelector('.o_FollowerSubtype_checkbox').checked,
+ "checkbox should be checked as follower subtype is followed"
+ );
+});
+
+QUnit.test('simplest layout of a not followed subtype', async function (assert) {
+ assert.expect(5);
+
+ await this.start();
+
+ const thread = this.env.models['mail.thread'].create({
+ id: 100,
+ model: 'res.partner',
+ });
+ const follower = this.env.models['mail.follower'].create({
+ channel: [['insert', {
+ id: 1,
+ model: 'mail.channel',
+ name: "François Perusse",
+ }]],
+ followedThread: [['link', thread]],
+ id: 2,
+ isActive: true,
+ isEditable: true,
+ });
+ const followerSubtype = this.env.models['mail.follower_subtype'].create({
+ id: 1,
+ isDefault: true,
+ isInternal: false,
+ name: "Dummy test",
+ resModel: 'res.partner'
+ });
+ follower.update({ subtypes: [['link', followerSubtype]] });
+ await this.createFollowerSubtypeComponent({
+ follower,
+ followerSubtype,
+ });
+ assert.containsOnce(
+ document.body,
+ '.o_FollowerSubtype',
+ "should have follower subtype component"
+ );
+ assert.containsOnce(
+ document.body,
+ '.o_FollowerSubtype_label',
+ "should have a label"
+ );
+ assert.containsOnce(
+ document.body,
+ '.o_FollowerSubtype_checkbox',
+ "should have a checkbox"
+ );
+ assert.strictEqual(
+ document.querySelector('.o_FollowerSubtype_label').textContent,
+ "Dummy test",
+ "should have the name of the subtype as label"
+ );
+ assert.notOk(
+ document.querySelector('.o_FollowerSubtype_checkbox').checked,
+ "checkbox should not be checked as follower subtype is not followed"
+ );
+});
+
+QUnit.test('toggle follower subtype checkbox', async function (assert) {
+ assert.expect(5);
+
+ await this.start();
+
+ const thread = this.env.models['mail.thread'].create({
+ id: 100,
+ model: 'res.partner',
+ });
+ const follower = this.env.models['mail.follower'].create({
+ channel: [['insert', {
+ id: 1,
+ model: 'mail.channel',
+ name: "François Perusse",
+ }]],
+ followedThread: [['link', thread]],
+ id: 2,
+ isActive: true,
+ isEditable: true,
+ });
+ const followerSubtype = this.env.models['mail.follower_subtype'].create({
+ id: 1,
+ isDefault: true,
+ isInternal: false,
+ name: "Dummy test",
+ resModel: 'res.partner'
+ });
+ follower.update({ subtypes: [['link', followerSubtype]] });
+ await this.createFollowerSubtypeComponent({
+ follower,
+ followerSubtype,
+ });
+ assert.containsOnce(
+ document.body,
+ '.o_FollowerSubtype',
+ "should have follower subtype component"
+ );
+ assert.containsOnce(
+ document.body,
+ '.o_FollowerSubtype_checkbox',
+ "should have a checkbox"
+ );
+ assert.notOk(
+ document.querySelector('.o_FollowerSubtype_checkbox').checked,
+ "checkbox should not be checked as follower subtype is not followed"
+ );
+
+ await afterNextRender(() =>
+ document.querySelector('.o_FollowerSubtype_checkbox').click()
+ );
+ assert.ok(
+ document.querySelector('.o_FollowerSubtype_checkbox').checked,
+ "checkbox should now be checked"
+ );
+
+ await afterNextRender(() =>
+ document.querySelector('.o_FollowerSubtype_checkbox').click()
+ );
+ assert.notOk(
+ document.querySelector('.o_FollowerSubtype_checkbox').checked,
+ "checkbox should be no more checked"
+ );
+});
+
+});
+});
+});
+
+});
diff --git a/addons/mail/static/src/components/follower_subtype_list/follower_subtype_list.js b/addons/mail/static/src/components/follower_subtype_list/follower_subtype_list.js
new file mode 100644
index 00000000000..851382861ea
--- /dev/null
+++ b/addons/mail/static/src/components/follower_subtype_list/follower_subtype_list.js
@@ -0,0 +1,87 @@
+odoo.define('mail/static/src/components/follower_subtype_list/follower_subtype_list.js', function (require) {
+'use strict';
+
+const components = {
+ FollowerSubtype: require('mail/static/src/components/follower_subtype/follower_subtype.js'),
+};
+const useStore = require('mail/static/src/component_hooks/use_store/use_store.js');
+
+const { Component, QWeb } = owl;
+
+class FollowerSubtypeList extends Component {
+
+ /**
+ * @override
+ */
+ constructor(...args) {
+ super(...args);
+ useStore(props => {
+ const followerSubtypeList = this.env.models['mail.follower_subtype_list'].get(props.localId);
+ const follower = followerSubtypeList
+ ? followerSubtypeList.follower
+ : undefined;
+ const followerSubtypes = follower ? follower.subtypes : [];
+ return {
+ follower: follower ? follower.__state : undefined,
+ followerSubtypeList: followerSubtypeList
+ ? followerSubtypeList.__state
+ : undefined,
+ followerSubtypes: followerSubtypes.map(subtype => subtype.__state),
+ };
+ }, {
+ compareDepth: {
+ followerSubtypes: 1,
+ },
+ });
+ }
+
+ //--------------------------------------------------------------------------
+ // Public
+ //--------------------------------------------------------------------------
+
+ /**
+ * @returns {mail.follower_subtype_list}
+ */
+ get followerSubtypeList() {
+ return this.env.models['mail.follower_subtype_list'].get(this.props.localId);
+ }
+
+ //--------------------------------------------------------------------------
+ // Handlers
+ //--------------------------------------------------------------------------
+
+ /**
+ * Called when clicking on cancel button.
+ *
+ * @private
+ * @param {MouseEvent} ev
+ */
+ _onClickCancel(ev) {
+ this.followerSubtypeList.follower.closeSubtypes();
+ }
+
+ /**
+ * Called when clicking on apply button.
+ *
+ * @private
+ * @param {MouseEvent} ev
+ */
+ _onClickApply(ev) {
+ this.followerSubtypeList.follower.updateSubtypes();
+ }
+
+}
+
+Object.assign(FollowerSubtypeList, {
+ components,
+ props: {
+ localId: String,
+ },
+ template: 'mail.FollowerSubtypeList',
+});
+
+QWeb.registerComponent('FollowerSubtypeList', FollowerSubtypeList);
+
+return FollowerSubtypeList;
+
+});
diff --git a/addons/mail/static/src/components/follower_subtype_list/follower_subtype_list.scss b/addons/mail/static/src/components/follower_subtype_list/follower_subtype_list.scss
new file mode 100644
index 00000000000..82aef9fc974
--- /dev/null
+++ b/addons/mail/static/src/components/follower_subtype_list/follower_subtype_list.scss
@@ -0,0 +1,8 @@
+// ------------------------------------------------------------------
+// Layout
+// ------------------------------------------------------------------
+
+.o_FollowerSubtypeList_subtypes {
+ display: flex;
+ flex-flow: column;
+}
diff --git a/addons/mail/static/src/components/follower_subtype_list/follower_subtype_list.xml b/addons/mail/static/src/components/follower_subtype_list/follower_subtype_list.xml
new file mode 100644
index 00000000000..9ab73328a9e
--- /dev/null
+++ b/addons/mail/static/src/components/follower_subtype_list/follower_subtype_list.xml
@@ -0,0 +1,38 @@
+<?xml version="1.0" encoding="UTF-8"?>
+<templates xml:space="preserve">
+
+ <t t-name="mail.FollowerSubtypeList" owl="1">
+ <div class="o_FollowerSubtypeList modal-dialog">
+ <t t-if="followerSubtypeList">
+ <div class="modal-content">
+ <header class="modal-header">
+ <h4 class="modal-title">
+ Edit Subscription of <t t-esc="followerSubtypeList.follower.name"/>
+ </h4>
+ <i class="close fa fa-times" aria-label="Close" t-on-click="_onClickCancel"/>
+ </header>
+ <main class="modal-body">
+ <div class="o_FollowerSubtypeList_subtypes">
+ <t t-foreach="followerSubtypeList.follower.subtypes" t-as="subtype" t-key="subtype.id">
+ <FollowerSubtype
+ class="o_FollowerSubtypeList_subtype"
+ followerLocalId="followerSubtypeList.follower.localId"
+ followerSubtypeLocalId="subtype.localId"
+ />
+ </t>
+ </div>
+ </main>
+ <div class="modal-footer">
+ <button class="o-apply btn btn-primary" t-on-click="_onClickApply">
+ Apply
+ </button>
+ <button class="o-cancel btn btn-secondary" t-on-click="_onClickCancel">
+ Cancel
+ </button>
+ </div>
+ </div>
+ </t>
+ </div>
+ </t>
+
+</templates>
diff --git a/addons/mail/static/src/components/mail_template/mail_template.js b/addons/mail/static/src/components/mail_template/mail_template.js
new file mode 100644
index 00000000000..45dc8c344c6
--- /dev/null
+++ b/addons/mail/static/src/components/mail_template/mail_template.js
@@ -0,0 +1,79 @@
+odoo.define('mail/static/src/components/mail_template/mail_template.js', function (require) {
+'use strict';
+
+const useStore = require('mail/static/src/component_hooks/use_store/use_store.js');
+
+const { Component } = owl;
+
+class MailTemplate extends Component {
+
+ /**
+ * @override
+ */
+ constructor(...args) {
+ super(...args);
+ useStore(props => {
+ const activity = this.env.models['mail.activity'].get(props.activityLocalId);
+ const mailTemplate = this.env.models['mail.mail_template'].get(props.mailTemplateLocalId);
+ return {
+ activity: activity ? activity.__state : undefined,
+ mailTemplate: mailTemplate ? mailTemplate.__state : undefined,
+ };
+ });
+ }
+
+ //--------------------------------------------------------------------------
+ // Public
+ //--------------------------------------------------------------------------
+
+ /**
+ * @returns {mail.activity}
+ */
+ get activity() {
+ return this.env.models['mail.activity'].get(this.props.activityLocalId);
+ }
+
+ /**
+ * @returns {mail.mail_template}
+ */
+ get mailTemplate() {
+ return this.env.models['mail.mail_template'].get(this.props.mailTemplateLocalId);
+ }
+
+ //--------------------------------------------------------------------------
+ // Handlers
+ //--------------------------------------------------------------------------
+
+ /**
+ * @private
+ * @param {MouseEvent} ev
+ */
+ _onClickPreview(ev) {
+ ev.stopPropagation();
+ ev.preventDefault();
+ this.mailTemplate.preview(this.activity);
+ }
+
+ /**
+ * @private
+ * @param {MouseEvent} ev
+ */
+ _onClickSend(ev) {
+ ev.stopPropagation();
+ ev.preventDefault();
+ this.mailTemplate.send(this.activity);
+ }
+
+}
+
+Object.assign(MailTemplate, {
+ props: {
+ activityLocalId: String,
+ mailTemplateLocalId: String,
+ },
+ template: 'mail.MailTemplate',
+});
+
+return MailTemplate;
+
+});
diff --git a/addons/mail/static/src/components/mail_template/mail_template.scss b/addons/mail/static/src/components/mail_template/mail_template.scss
new file mode 100644
index 00000000000..7800ab62bc5
--- /dev/null
+++ b/addons/mail/static/src/components/mail_template/mail_template.scss
@@ -0,0 +1,27 @@
+// ------------------------------------------------------------------
+// Layout
+// ------------------------------------------------------------------
+
+.o_MailTemplate {
+ display: flex;
+ flex: 0 0 auto;
+ align-items: center;
+}
+
+.o_MailTemplate_button {
+ padding-top: map-get($spacers, 0);
+ padding-bottom: map-get($spacers, 0);
+}
+
+.o_MailTemplate_name {
+ margin-inline-start: map-get($spacers, 2);
+}
+
+// ------------------------------------------------------------------
+// Style
+// ------------------------------------------------------------------
+
+.o_MailTemplate_text {
+ color: gray('500');
+ font-style: italic;
+}
diff --git a/addons/mail/static/src/components/mail_template/mail_template.xml b/addons/mail/static/src/components/mail_template/mail_template.xml
new file mode 100644
index 00000000000..48f7c050fcc
--- /dev/null
+++ b/addons/mail/static/src/components/mail_template/mail_template.xml
@@ -0,0 +1,29 @@
+<?xml version="1.0" encoding="UTF-8"?>
+<templates xml:space="preserve">
+
+ <t t-name="mail.MailTemplate" owl="1">
+ <div class="o_MailTemplate">
+ <t t-if="mailTemplate">
+ <i class="fa fa-envelope-o" title="Mail" role="img"/>
+ <span class="o_MailTemplate_name" t-esc="mailTemplate.name"/>
+ <span>:</span>
+ <button
+ class="o_MailTemplate_button o_MailTemplate_preview btn btn-link"
+ t-att-data-mail-template-id="mailTemplate.id"
+ t-on-click="_onClickPreview"
+ >
+ Preview
+ </button>
+ <span class="o_MailTemplate_text">or</span>
+ <button
+ class="o_MailTemplate_button o_MailTemplate_send btn btn-link"
+ t-att-data-mail-template-id="mailTemplate.id"
+ t-on-click="_onClickSend"
+ >
+ Send Now
+ </button>
+ </t>
+ </div>
+ </t>
+
+</templates>
diff --git a/addons/mail/static/src/components/message/message.js b/addons/mail/static/src/components/message/message.js
new file mode 100644
index 00000000000..f4940a1e566
--- /dev/null
+++ b/addons/mail/static/src/components/message/message.js
@@ -0,0 +1,621 @@
+odoo.define('mail/static/src/components/message/message.js', function (require) {
+'use strict';
+
+const components = {
+ AttachmentList: require('mail/static/src/components/attachment_list/attachment_list.js'),
+ MessageSeenIndicator: require('mail/static/src/components/message_seen_indicator/message_seen_indicator.js'),
+ ModerationBanDialog: require('mail/static/src/components/moderation_ban_dialog/moderation_ban_dialog.js'),
+ ModerationDiscardDialog: require('mail/static/src/components/moderation_discard_dialog/moderation_discard_dialog.js'),
+ ModerationRejectDialog: require('mail/static/src/components/moderation_reject_dialog/moderation_reject_dialog.js'),
+ NotificationPopover: require('mail/static/src/components/notification_popover/notification_popover.js'),
+ PartnerImStatusIcon: require('mail/static/src/components/partner_im_status_icon/partner_im_status_icon.js'),
+};
+const useStore = require('mail/static/src/component_hooks/use_store/use_store.js');
+const { timeFromNow } = require('mail.utils');
+
+const { _lt } = require('web.core');
+const { getLangDatetimeFormat } = require('web.time');
+
+const { Component, useState } = owl;
+const { useRef } = owl.hooks;
+
+const READ_MORE = _lt("read more");
+const READ_LESS = _lt("read less");
+
+class Message extends Component {
+
+ /**
+ * @override
+ */
+ constructor(...args) {
+ super(...args);
+ this.state = useState({
+ // Determine if the moderation ban dialog is displayed.
+ hasModerationBanDialog: false,
+ // Determine if the moderation discard dialog is displayed.
+ hasModerationDiscardDialog: false,
+ // Determine if the moderation reject dialog is displayed.
+ hasModerationRejectDialog: false,
+ /**
+ * Determine whether the message is clicked. When message is in
+ * clicked state, it keeps displaying the commands.
+ */
+ isClicked: false,
+ /**
+ * Time elapsed from message datetime to current datetime.
+ */
+ timeElapsed: null,
+ });
+ useStore(props => {
+ const message = this.env.models['mail.message'].get(props.messageLocalId);
+ const author = message ? message.author : undefined;
+ const partnerRoot = this.env.messaging.partnerRoot;
+ const originThread = message ? message.originThread : undefined;
+ const threadViewer = this.env.models['mail.thread_viewer'].get(props.threadViewerLocalId);
+ const thread = threadViewer ? threadViewer.thread : undefined;
+ const threadStringifiedDomain = threadViewer
+ ? threadViewer.stringifiedDomain
+ : undefined;
+ return {
+ attachments: message
+ ? message.attachments.map(attachment => attachment.__state)
+ : undefined,
+ author: author ? author.__state : undefined,
+ hasMessageCheckbox: message ? message.hasCheckbox : false,
+ isDeviceMobile: this.env.messaging.device.isMobile,
+ isMessageChecked: message && threadViewer
+ ? message.isChecked(thread, threadStringifiedDomain)
+ : false,
+ message: message ? message.__state : undefined,
+ notifications: message ? message.notifications.map(notif => notif.__state) : [],
+ originThread: originThread ? originThread.__state : undefined,
+ partnerRoot: partnerRoot ? partnerRoot.__state : undefined,
+ thread: thread ? thread.__state : undefined,
+ threadViewer: threadViewer ? threadViewer.__state : undefined,
+ };
+ }, {
+ compareDepth: {
+ notifications: 1,
+ },
+ });
+ /**
+ * Reference to the content of the message.
+ */
+ this._contentRef = useRef('content');
+ /**
+ * To get checkbox state.
+ */
+ this._checkboxRef = useRef('checkbox');
+ /**
+ * Id of setInterval used to auto-update time elapsed of message at
+ * regular time.
+ */
+ this._intervalId = undefined;
+ this._constructor();
+ }
+
+ /**
+ * Allows patching constructor.
+ */
+ _constructor() {}
+
+ mounted() {
+ this._insertReadMoreLess($(this._contentRef.el));
+ this._update();
+ }
+
+ patched() {
+ this._update();
+ }
+
+ willUnmount() {
+ clearInterval(this._intervalId);
+ }
+
+ //--------------------------------------------------------------------------
+ // Public
+ //--------------------------------------------------------------------------
+
+ /**
+ * @returns {string}
+ */
+ get avatar() {
+ if (
+ this.message.author &&
+ this.message.author === this.env.messaging.partnerRoot
+ ) {
+ return '/mail/static/src/img/odoobot.png';
+ } else if (this.message.author) {
+ // TODO FIXME for public user this might not be accessible. task-2223236
+ // we should probably use the correspondig attachment id + access token
+ // or create a dedicated route to get message image, checking the access right of the message
+ return `/web/image/res.partner/${this.message.author.id}/image_128`;
+ } else if (this.message.message_type === 'email') {
+ return '/mail/static/src/img/email_icon.png';
+ }
+ return '/mail/static/src/img/smiley/avatar.jpg';
+ }
+
+ /**
+ * Get the date time of the message at current user locale time.
+ *
+ * @returns {string}
+ */
+ get datetime() {
+ return this.message.date.format(getLangDatetimeFormat());
+ }
+
+ /**
+ * Determine whether author redirect feature is enabled on message.
+ * Click on message author should redirect to author.
+ *
+ * @returns {boolean}
+ */
+ get hasAuthorRedirect() {
+ if (!this.props.hasAuthorRedirect) {
+ return false;
+ }
+ if (!this.message.author) {
+ return false;
+ }
+ if (this.message.author === this.env.messaging.currentPartner) {
+ return false;
+ }
+ return true;
+ }
+
+ /**
+ * Determine whether the message origin thread is the same as the context
+ * of displaying this message. In other word, if the enclosing thread
+ * component of this message component is linked to the origin thread of
+ * this message, then the origin is the same.
+ *
+ * @returns {boolean}
+ */
+ get hasDifferentOriginThread() {
+ if (!this.threadViewer) {
+ return false;
+ }
+ if (!this.message.originThread) {
+ return false;
+ }
+ return this.message.originThread !== this.threadViewer.thread;
+ }
+
+ /**
+ * @returns {mail.attachment[]}
+ */
+ get imageAttachments() {
+ return this.message.attachments.filter(attachment => attachment.fileType === 'image');
+ }
+
+ /**
+ * Tell whether the bottom of this message is visible or not.
+ *
+ * @param {Object} param0
+ * @param {integer} [offset=0]
+ * @returns {boolean}
+ */
+ isBottomVisible({ offset=0 } = {}) {
+ if (!this.el) {
+ return false;
+ }
+ const elRect = this.el.getBoundingClientRect();
+ if (!this.el.parentNode) {
+ return false;
+ }
+ const parentRect = this.el.parentNode.getBoundingClientRect();
+ // bottom with (double) 10px offset
+ return (
+ elRect.bottom < parentRect.bottom + offset &&
+ parentRect.top < elRect.bottom + offset
+ );
+ }
+
+ /**
+ * Tell whether the message is partially visible on browser window or not.
+ *
+ * @returns {boolean}
+ */
+ isPartiallyVisible() {
+ const elRect = this.el.getBoundingClientRect();
+ if (!this.el.parentNode) {
+ return false;
+ }
+ const parentRect = this.el.parentNode.getBoundingClientRect();
+ // intersection with 5px offset
+ return (
+ elRect.top < parentRect.bottom + 5 &&
+ parentRect.top < elRect.bottom + 5
+ );
+ }
+
+ /**
+ * Determine whether the message is starred.
+ *
+ * @returns {boolean}
+ */
+ get isStarred() {
+ return this.message.allThreads.includes(this.env.messaging.starred);
+ }
+
+ /**
+ * @returns {mail.message}
+ */
+ get message() {
+ return this.env.models['mail.message'].get(this.props.messageLocalId);
+ }
+
+ /**
+ * @returns {mail.attachment[]}
+ */
+ get nonImageAttachments() {
+ return this.message.attachments.filter(attachment => attachment.fileType !== 'image');
+ }
+
+ /**
+ * Make this message viewable in its enclosing scroll environment (usually
+ * message list).
+ *
+ * @param {Object} [param0={}]
+ * @param {string} [param0.behavior='auto']
+ * @param {string} [param0.block='end']
+ * @returns {Promise}
+ */
+ async scrollIntoView({ behavior='auto', block='end' }={}) {
+ this.el.scrollIntoView({
+ behavior,
+ block,
+ inline: 'nearest',
+ });
+ if (behavior === 'smooth') {
+ return new Promise(resolve => setTimeout(resolve, 500));
+ } else {
+ return Promise.resolve();
+ }
+ }
+
+ /**
+ * Get the shorttime format of the message date.
+ *
+ * @returns {string}
+ */
+ get shortTime() {
+ return this.message.date.format('hh:mm');
+ }
+
+ /**
+ * @returns {mail.thread_viewer}
+ */
+ get threadViewer() {
+ return this.env.models['mail.thread_viewer'].get(this.props.threadViewerLocalId);
+ }
+
+ /**
+ * @returns {Object}
+ */
+ get trackingValues() {
+ return this.message.tracking_value_ids.map(trackingValue => {
+ const value = Object.assign({}, trackingValue);
+ value.changed_field = _.str.sprintf(this.env._t("%s:"), value.changed_field);
+ if (value.field_type === 'datetime') {
+ if (value.old_value) {
+ value.old_value =
+ moment.utc(value.old_value).local().format('LLL');
+ }
+ if (value.new_value) {
+ value.new_value =
+ moment.utc(value.new_value).local().format('LLL');
+ }
+ } else if (value.field_type === 'date') {
+ if (value.old_value) {
+ value.old_value =
+ moment(value.old_value).local().format('LL');
+ }
+ if (value.new_value) {
+ value.new_value =
+ moment(value.new_value).local().format('LL');
+ }
+ }
+ return value;
+ });
+ }
+
+ //--------------------------------------------------------------------------
+ // Private
+ //--------------------------------------------------------------------------
+
+ /**
+ * Modifies the message to add the 'read more/read less' functionality
+ * All element nodes with 'data-o-mail-quote' attribute are concerned.
+ * All text nodes after a ``#stopSpelling`` element are concerned.
+ * Those text nodes need to be wrapped in a span (toggle functionality).
+ * All consecutive elements are joined in one 'read more/read less'.
+ *
+ * @private
+ * @param {jQuery} $element
+ */
+ _insertReadMoreLess($element) {
+ const groups = [];
+ let readMoreNodes;
+
+ // nodeType 1: element_node
+ // nodeType 3: text_node
+ const $children = $element.contents()
+ .filter((index, content) =>
+ content.nodeType === 1 || (content.nodeType === 3 && content.nodeValue.trim())
+ );
+
+ for (const child of $children) {
+ let $child = $(child);
+
+ // Hide Text nodes if "stopSpelling"
+ if (
+ child.nodeType === 3 &&
+ $child.prevAll('[id*="stopSpelling"]').length > 0
+ ) {
+ // Convert Text nodes to Element nodes
+ $child = $('<span>', {
+ text: child.textContent,
+ 'data-o-mail-quote': '1',
+ });
+ child.parentNode.replaceChild($child[0], child);
+ }
+
+ // Create array for each 'read more' with nodes to toggle
+ if (
+ $child.attr('data-o-mail-quote') ||
+ (
+ $child.get(0).nodeName === 'BR' &&
+ $child.prev('[data-o-mail-quote="1"]').length > 0
+ )
+ ) {
+ if (!readMoreNodes) {
+ readMoreNodes = [];
+ groups.push(readMoreNodes);
+ }
+ $child.hide();
+ readMoreNodes.push($child);
+ } else {
+ readMoreNodes = undefined;
+ this._insertReadMoreLess($child);
+ }
+ }
+
+ for (const group of groups) {
+ // Insert link just before the first node
+ const $readMoreLess = $('<a>', {
+ class: 'o_Message_readMore',
+ href: '#',
+ text: READ_MORE,
+ }).insertBefore(group[0]);
+
+ // Toggle All next nodes
+ let isReadMore = true;
+ $readMoreLess.click(e => {
+ e.preventDefault();
+ isReadMore = !isReadMore;
+ for (const $child of group) {
+ $child.hide();
+ $child.toggle(!isReadMore);
+ }
+ $readMoreLess.text(isReadMore ? READ_MORE : READ_LESS);
+ });
+ }
+ }
+
+ /**
+ * @private
+ */
+ _update() {
+ if (!this.state.timeElapsed) {
+ this.state.timeElapsed = timeFromNow(this.message.date);
+ }
+ clearInterval(this._intervalId);
+ this._intervalId = setInterval(() => {
+ this.state.timeElapsed = timeFromNow(this.message.date);
+ }, 60 * 1000);
+ }
+
+ //--------------------------------------------------------------------------
+ // Handlers
+ //--------------------------------------------------------------------------
+
+ /**
+ * @private
+ */
+ _onChangeCheckbox() {
+ this.message.toggleCheck(this.threadViewer.thread, this.threadViewer.stringifiedDomain);
+ }
+
+ /**
+ * @private
+ * @param {MouseEvent} ev
+ */
+ _onClick(ev) {
+ if (ev.target.closest('.o_mention')) {
+ this.env.messaging.redirect({
+ id: Number(ev.target.dataset.oeId),
+ model: ev.target.dataset.oeModel,
+ });
+ ev.preventDefault();
+ return;
+ }
+ if (ev.target.closest('.o_mail_redirect')) {
+ this.env.messaging.redirect({
+ id: Number(ev.target.dataset.oeId),
+ model: ev.target.dataset.oeModel,
+ });
+ ev.preventDefault();
+ return;
+ }
+ ev.stopPropagation();
+ this.state.isClicked = !this.state.isClicked;
+ }
+
+ /**
+ * @private
+ * @param {MouseEvent} ev
+ */
+ _onClickAuthor(ev) {
+ if (!this.hasAuthorRedirect) {
+ return;
+ }
+ if (!this.message.author) {
+ return;
+ }
+ this.env.messaging.redirect({
+ id: this.message.author.id,
+ model: this.message.author.model,
+ });
+ }
+
+ /**
+ * @private
+ * @param {MouseEvent} ev
+ */
+ _onClickFailure(ev) {
+ this.message.openResendAction();
+ }
+
+ /**
+ * @private
+ * @param {MouseEvent} ev
+ */
+ _onClickModerationAccept(ev) {
+ ev.preventDefault();
+ this.message.moderate('accept');
+ }
+
+ /**
+ * @private
+ * @param {MouseEvent} ev
+ */
+ _onClickModerationAllow(ev) {
+ ev.preventDefault();
+ this.message.moderate('allow');
+ }
+
+ /**
+ * @private
+ * @param {MouseEvent} ev
+ */
+ _onClickModerationBan(ev) {
+ ev.preventDefault();
+ this.state.hasModerationBanDialog = true;
+ }
+
+ /**
+ * @private
+ * @param {MouseEvent} ev
+ */
+ _onClickModerationDiscard(ev) {
+ ev.preventDefault();
+ this.state.hasModerationDiscardDialog = true;
+ }
+
+ /**
+ * @private
+ * @param {MouseEvent} ev
+ */
+ _onClickModerationReject(ev) {
+ ev.preventDefault();
+ this.state.hasModerationRejectDialog = true;
+ }
+
+ /**
+ * @private
+ * @param {MouseEvent} ev
+ */
+ _onClickOriginThread(ev) {
+ ev.preventDefault();
+ this.env.messaging.redirect({
+ id: this.message.originThread.id,
+ model: this.message.originThread.model,
+ });
+ }
+
+ /**
+ * @private
+ * @param {MouseEvent} ev
+ */
+ _onClickStar(ev) {
+ ev.stopPropagation();
+ this.message.toggleStar();
+ }
+
+ /**
+ * @private
+ * @param {MouseEvent} ev
+ */
+ _onClickMarkAsRead(ev) {
+ ev.stopPropagation();
+ this.message.markAsRead();
+ }
+
+ /**
+ * @private
+ * @param {MouseEvent} ev
+ */
+ _onClickReply(ev) {
+ ev.stopPropagation();
+ this.message.replyTo();
+ }
+
+ /**
+ * @private
+ */
+ _onDialogClosedModerationBan() {
+ this.state.hasModerationBanDialog = false;
+ }
+
+ /**
+ * @private
+ */
+ _onDialogClosedModerationDiscard() {
+ this.state.hasModerationDiscardDialog = false;
+ }
+
+ /**
+ * @private
+ */
+ _onDialogClosedModerationReject() {
+ this.state.hasModerationRejectDialog = false;
+ }
+
+}
+
+Object.assign(Message, {
+ components,
+ defaultProps: {
+ hasAuthorRedirect: false,
+ hasCheckbox: false,
+ hasMarkAsReadIcon: false,
+ hasReplyIcon: false,
+ isSelected: false,
+ isSquashed: false,
+ },
+ props: {
+ attachmentsDetailsMode: {
+ type: String,
+ optional: true,
+ validate: prop => ['auto', 'card', 'hover', 'none'].includes(prop),
+ },
+ hasAuthorRedirect: Boolean,
+ hasCheckbox: Boolean,
+ hasMarkAsReadIcon: Boolean,
+ hasReplyIcon: Boolean,
+ isSelected: Boolean,
+ isSquashed: Boolean,
+ messageLocalId: String,
+ threadViewerLocalId: {
+ type: String,
+ optional: true,
+ },
+ },
+ template: 'mail.Message',
+});
+
+return Message;
+
+});
diff --git a/addons/mail/static/src/components/message/message.scss b/addons/mail/static/src/components/message/message.scss
new file mode 100644
index 00000000000..fa7b7474558
--- /dev/null
+++ b/addons/mail/static/src/components/message/message.scss
@@ -0,0 +1,354 @@
+// ------------------------------------------------------------------
+// Layout
+// ------------------------------------------------------------------
+
+.o_Message {
+ display: flex;
+ flex: 0 0 auto;
+ padding: map-get($spacers, 2);
+}
+
+.o_Message_authorAvatar {
+ height: 100%;
+ width: 100%;
+ object-fit: cover;
+}
+
+.o_Message_authorAvatarContainer {
+ position: relative;
+ height: 36px;
+ width: 36px;
+}
+
+.o_Message_authorName {
+ margin-inline-end: map-get($spacers, 2);
+}
+
+.o_Message_checkbox {
+ margin-inline-end: map-get($spacers, 2);
+}
+
+.o_Message_commandStar {
+ font-size: 1.3em;
+}
+
+.o_Message_Composer {
+ flex: 1 1 auto;
+}
+
+.o_Message_commands {
+ display: flex;
+ align-items: center;
+}
+
+.o_Message_content {
+ flex: 1 1 auto;
+ min-width: 0;
+ max-width: 100%;
+ word-wrap: break-word;
+ word-break: break-word;
+
+ > p:last-of-type {
+ margin-bottom: 0;
+ }
+}
+
+.o_Message_core {
+ margin-inline-end: map-get($spacers, 3);
+ overflow: auto;
+}
+
+.o_Message_footer {
+ display: flex;
+ flex-direction: column;
+}
+
+.o_Message_header {
+ display: flex;
+ flex-flow: row wrap;
+ align-items: baseline;
+}
+
+.o_Message_headerCommands {
+ margin-inline-end: map-get($spacers, 2);
+ align-self: center;
+
+ .o_Message_headerCommand {
+ padding-left: map-get($spacers, 2);
+ padding-right: map-get($spacers, 2);
+
+ &.o-mobile {
+ padding-left: map-get($spacers, 3);
+ padding-right: map-get($spacers, 3);
+
+ &:first-child {
+ padding-left: map-get($spacers, 2);
+ }
+
+ &:last-child {
+ padding-right: map-get($spacers, 2);
+ }
+ }
+ }
+}
+
+.o_Message_headerDate {
+ margin-inline-end: map-get($spacers, 2);
+ font-size: 0.8em;
+}
+
+.o_Message_moderationAction {
+ margin-inline-end: map-get($spacers, 3);
+}
+
+.o_Message_moderationPending {
+ margin-inline-end: map-get($spacers, 3);
+}
+
+.o_Message_moderationSubHeader {
+ display: flex;
+ flex-flow: row wrap;
+ align-items: center;
+}
+
+.o_Message_originThread {
+ margin-inline-end: map-get($spacers, 2);
+}
+
+.o_Message_partnerImStatusIcon {
+ @include o-position-absolute($bottom: 0, $right: 0);
+ display: flex;
+ align-items: center;
+ justify-content: center;
+}
+
+.o_Message_readMore {
+ display: block;
+}
+
+.o_Message_sidebar {
+ flex: 0 0 50px;
+ max-width: 50px;
+ display: flex;
+ margin-inline-end: map-get($spacers, 2);
+ justify-content: center;
+
+ &.o-message-squashed {
+ align-items: flex-start;
+ }
+}
+
+.o_Message_sidebarItem {
+ margin-left: map-get($spacers, 1);
+ margin-right: map-get($spacers, 1);
+
+ &.o-message-squashed {
+ display: flex;
+ }
+}
+
+.o_Message_trackingValues {
+ margin-top: map-get($spacers, 3);
+}
+
+.o_Message_trackingValue {
+ display: flex;
+ align-items: center;
+}
+
+.o_Message_trackingValueItem {
+ margin-inline-end: map-get($spacers, 1);
+}
+
+// ------------------------------------------------------------------
+// Style
+// ------------------------------------------------------------------
+
+.o_Message {
+ background-color: white;
+
+ &:hover, &.o-clicked {
+
+ .o_Message_command {
+ display: flex;
+ }
+
+ .o_Message_commands {
+ display: flex;
+ }
+
+ .o_Message_sidebarItem.o-message-squashed {
+ display: flex;
+ }
+
+ .o_Message_seenIndicator.o-message-squashed {
+ display: none;
+ }
+ }
+
+ .o_Message_partnerImStatusIcon {
+ color: white;
+ }
+
+ &.o-not-discussion {
+ background-color: lighten(gray('300'), 5%);
+ border-bottom: 1px solid darken(gray('300'), 5%);
+
+ .o_Message_partnerImStatusIcon {
+ color: lighten(gray('300'), 5%);
+ }
+
+ &.o-selected {
+ border-bottom: 1px solid darken(gray('400'), 5%);
+ }
+ }
+
+ &.o-selected {
+ background-color: gray('400');
+
+ .o_Message_partnerImStatusIcon {
+ color: gray('400');
+ }
+ }
+
+ &.o-starred {
+
+ .o_Message_commandStar {
+ display: flex;
+ }
+
+ .o_Message_commands {
+ display: flex;
+ }
+ }
+}
+
+.o_Message_authorName {
+ font-weight: bold;
+}
+
+.o_Message_authorRedirect {
+ cursor: pointer;
+}
+
+.o_Message_command {
+ cursor: pointer;
+ color: gray('400');
+
+ &:not(.o-mobile) {
+ display: none;
+
+ &:hover {
+ filter: brightness(0.8);
+ }
+ }
+
+ &.o-mobile {
+ filter: brightness(0.8);
+
+ &:hover {
+ filter: brightness(0.75);
+ }
+ }
+
+ &.o-message-selected {
+ color: gray('500');
+ }
+}
+
+.o_Message_commandStar {
+
+ &.o-message-starred {
+ color: gold;
+
+ &:hover {
+ filter: brightness(0.9);
+ }
+ }
+}
+
+.o_Message_content .o_mention {
+ color: $o-brand-primary;
+ cursor: pointer;
+
+ &:hover {
+ color: darken($o-brand-primary, 15%);
+ }
+}
+
+.o_Message_date {
+ color: gray('500');
+
+ &.o-message-selected {
+ color: gray('600');
+ }
+}
+
+.o_Message_headerCommands:not(.o-mobile) {
+ display: none;
+}
+
+.o_Message_originThread {
+ font-size: 0.8em;
+ color: gray('500');
+
+ &.o-message-selected {
+ color: gray('600');
+ }
+}
+
+.o_Message_originThreadLink {
+ font-size: 1.25em; // original size
+}
+
+.o_Message_partnerImStatusIcon:not(.o_Message_partnerImStatusIcon-mobile) {
+ font-size: x-small;
+}
+
+.o_Message_moderationAction {
+ font-weight: bold;
+ font-style: italic;
+
+ &.o-accept,
+ &.o-allow {
+ color: $o-mail-moderation-accept-color;
+ @include hover-focus {
+ color: darken($o-mail-moderation-accept-color, $emphasized-link-hover-darken-percentage);
+ }
+ }
+
+ &.o-ban,
+ &.o-discard,
+ &.o-reject {
+ color: $o-mail-moderation-reject-color;
+ @include hover-focus {
+ color: darken($o-mail-moderation-reject-color, $emphasized-link-hover-darken-percentage);
+ }
+ }
+}
+
+.o_Message_moderationPending {
+ font-style: italic;
+
+ &.o-author {
+ color: theme-color('danger');
+ font-weight: bold;
+ }
+}
+
+.o_Message_notificationIconContainer {
+ color: gray('600');
+ cursor: pointer;
+
+ &.o-error {
+ color: $red;
+ }
+}
+
+.o_Message_sidebarCommands {
+ display: none;
+}
+
+.o_Message_sidebarItem.o-message-squashed {
+ display: none;
+}
diff --git a/addons/mail/static/src/components/message/message.xml b/addons/mail/static/src/components/message/message.xml
new file mode 100644
index 00000000000..b55e7a5ccf6
--- /dev/null
+++ b/addons/mail/static/src/components/message/message.xml
@@ -0,0 +1,220 @@
+<?xml version="1.0" encoding="UTF-8"?>
+<templates xml:space="preserve">
+
+ <t t-name="mail.Message" owl="1">
+ <div class="o_Message"
+ t-att-class="{
+ 'o-clicked': state.isClicked,
+ 'o-discussion': message and (message.is_discussion or message.is_notification),
+ 'o-mobile': env.messaging.device.isMobile,
+ 'o-not-discussion': message and !(message.is_discussion or message.is_notification),
+ 'o-notification': message and message.message_type === 'notification',
+ 'o-selected': props.isSelected,
+ 'o-squashed': props.isSquashed,
+ 'o-starred': message and isStarred,
+ }" t-on-click="_onClick" t-att-data-message-local-id="message and message.localId"
+ >
+ <t t-if="message" name="rootCondition">
+ <div class="o_Message_sidebar" t-att-class="{ 'o-message-squashed': props.isSquashed }">
+ <t t-if="!props.isSquashed">
+ <div class="o_Message_authorAvatarContainer o_Message_sidebarItem">
+ <img class="o_Message_authorAvatar rounded-circle" t-att-class="{ o_Message_authorRedirect: hasAuthorRedirect, o_redirect: hasAuthorRedirect }" t-att-src="avatar" t-on-click="_onClickAuthor" t-att-title="hasAuthorRedirect ? 'Open chat' : ''" alt=""/>
+ <t t-if="message.author and message.author.im_status">
+ <PartnerImStatusIcon
+ class="o_Message_partnerImStatusIcon"
+ t-att-class="{
+ 'o-message-not-discussion': !(message.is_discussion or message.is_notification),
+ 'o-message-selected': props.isSelected,
+ 'o_Message_partnerImStatusIcon-mobile': env.messaging.device.isMobile,
+ }"
+ partnerLocalId="message.author.localId"
+ />
+ </t>
+ </div>
+ </t>
+ <t t-else="">
+ <div class="o_Message_date o_Message_sidebarItem o-message-squashed" t-att-class="{ 'o-message-selected': props.isSelected }">
+ <t t-esc="shortTime"/>
+ </div>
+ <div class="o_Message_commands o_Message_sidebarCommands o_Message_sidebarItem o-message-squashed" t-att-class="{ 'o-message-selected': props.isSelected, 'o-mobile': env.messaging.device.isMobile }">
+ <t t-if="message.message_type !== 'notification'">
+ <div class="o_Message_command o_Message_commandStar fa"
+ t-att-class="{
+ 'fa-star': isStarred,
+ 'fa-star-o': !isStarred,
+ 'o-message-selected': props.isSelected,
+ 'o-message-starred': isStarred,
+ 'o-mobile': env.messaging.device.isMobile,
+ }" t-on-click="_onClickStar"
+ />
+ </t>
+ </div>
+ <t t-if="message.isCurrentPartnerAuthor and threadViewer and threadViewer.thread">
+ <MessageSeenIndicator class="o_Message_seenIndicator o-message-squashed" messageLocalId="message.localId" threadLocalId="threadViewer.thread.localId"/>
+ </t>
+ </t>
+ </div>
+ <div class="o_Message_core">
+ <t t-if="!props.isSquashed">
+ <div class="o_Message_header">
+ <t t-if="message.author">
+ <div class="o_Message_authorName" t-att-class="hasAuthorRedirect ? 'o_Message_authorRedirect o_redirect' : ''" t-on-click="_onClickAuthor" t-att-title="hasAuthorRedirect ? 'Open chat' : ''">
+ <t t-esc="message.author.nameOrDisplayName"/>
+ </div>
+ </t>
+ <t t-elif="message.email_from">
+ <a class="o_Message_authorName" t-attf-href="mailto:{{ message.email_from }}?subject=Re: {{ message.subject ? message.subject : '' }}">
+ <t t-esc="message.email_from"/>
+ </a>
+ </t>
+ <t t-else="">
+ <div class="o_Message_authorName">
+ Anonymous
+ </div>
+ </t>
+ <div class="o_Message_date o_Message_headerDate" t-att-class="{ 'o-message-selected': props.isSelected }" t-att-title="datetime">
+ - <t t-esc="state.timeElapsed"/>
+ </div>
+ <t t-if="message.isCurrentPartnerAuthor and threadViewer and threadViewer.thread">
+ <MessageSeenIndicator class="o_Message_seenIndicator" messageLocalId="message.localId" threadLocalId="threadViewer.thread.localId"/>
+ </t>
+ <t t-if="hasDifferentOriginThread">
+ <div class="o_Message_originThread" t-att-class="{ 'o-message-selected': props.isSelected }">
+ <t t-if="message.originThread.model === 'mail.channel'">
+ (from <a class="o_Message_originThreadLink" href="#" t-on-click="_onClickOriginThread">#<t t-esc="message.originThread.name"/></a>)
+ </t>
+ <t t-else="">
+ on <a class="o_Message_originThreadLink" href="#" t-on-click="_onClickOriginThread"><t t-esc="message.originThread.name"/></a>
+ </t>
+ </div>
+ </t>
+ <t t-if="message.moderation_status === 'pending_moderation' and !message.isModeratedByCurrentPartner">
+ <span class="o_Message_moderationPending o-author" title="Your message is pending moderation.">Pending moderation</span>
+ </t>
+ <t t-if="message.notifications.length > 0">
+ <t t-if="message.failureNotifications.length > 0">
+ <span class="o_Message_notificationIconContainer o-error" t-on-click="_onClickFailure">
+ <i name="failureIcon" class="o_Message_notificationIcon fa fa-envelope"/>
+ </span>
+ </t>
+ <t t-else="">
+ <Popover>
+ <span class="o_Message_notificationIconContainer">
+ <i name="notificationIcon" class="o_Message_notificationIcon fa fa-envelope-o"/>
+ </span>
+ <t t-set="opened">
+ <NotificationPopover
+ notificationLocalIds="message.notifications.map(notification => notification.localId)"
+ />
+ </t>
+ </Popover>
+ </t>
+ </t>
+ <div class="o_Message_commands o_Message_headerCommands" t-att-class="{ 'o-mobile': env.messaging.device.isMobile }">
+ <t t-if="!message.isTemporary and ((message.message_type !== 'notification' and message.originThread and message.originThread.model === 'mail.channel') or !message.isTransient) and message.moderation_status !== 'pending_moderation'">
+ <span class="o_Message_command o_Message_commandStar o_Message_headerCommand fa"
+ t-att-class="{
+ 'fa-star': isStarred,
+ 'fa-star-o': !isStarred,
+ 'o-message-selected': props.isSelected,
+ 'o-message-starred': isStarred,
+ 'o-mobile': env.messaging.device.isMobile,
+ }" t-on-click="_onClickStar" title="Mark as Todo"
+ />
+ </t>
+ <t t-if="props.hasReplyIcon">
+ <span class="o_Message_command o_Message_commandReply o_Message_headerCommand fa fa-reply"
+ t-att-class="{
+ 'o-message-selected': props.isSelected,
+ 'o-mobile': env.messaging.device.isMobile,
+ }" t-on-click="_onClickReply" title="Reply"
+ />
+ </t>
+ <t t-if="props.hasMarkAsReadIcon">
+ <span class="o_Message_command o_Message_commandMarkAsRead o_Message_headerCommand fa fa-check"
+ t-att-class="{
+ 'o-message-selected': props.isSelected,
+ 'o-mobile': env.messaging.device.isMobile,
+ }" t-on-click="_onClickMarkAsRead" title="Mark as Read"
+ />
+ </t>
+ </div>
+ </div>
+ <t t-if="message.isModeratedByCurrentPartner">
+ <div class="o_Message_moderationSubHeader">
+ <t t-if="threadViewer and props.hasCheckbox and message.hasCheckbox">
+ <input class="o_Message_checkbox" type="checkbox" t-att-checked="message.isChecked(threadViewer.thread, threadViewer.stringifiedDomain) ? 'checked': ''" t-on-change="_onChangeCheckbox" t-ref="checkbox"/>
+ </t>
+ <span class="o_Message_moderationPending">Pending moderation:</span>
+ <a class="o_Message_moderationAction o-accept" href="#" title="Accept" t-on-click="_onClickModerationAccept">Accept</a>
+ <a class="o_Message_moderationAction o-reject" href="#" title="Remove message with explanation" t-on-click="_onClickModerationReject">Reject</a>
+ <a class="o_Message_moderationAction o-discard" href="#" title="Remove message without explanation" t-on-click="_onClickModerationDiscard">Discard</a>
+ <a class="o_Message_moderationAction o-allow" href="#" title="Add this email address to white list of people" t-on-click="_onClickModerationAllow">Always Allow</a>
+ <a class="o_Message_moderationAction o-ban" href="#" title="Ban this email address" t-on-click="_onClickModerationBan">Ban</a>
+ </div>
+ </t>
+ </t>
+ <div class="o_Message_content" t-ref="content">
+ <t t-raw="message.prettyBody"/>
+ <t t-if="message.subtype_description">
+ <p t-esc="message.subtype_description"/>
+ </t>
+ <t t-if="trackingValues.length > 0">
+ <ul class="o_Message_trackingValues">
+ <t t-foreach="trackingValues" t-as="value" t-key="value.id">
+ <li>
+ <div class="o_Message_trackingValue">
+ <div class="o_Message_trackingValueItem" t-esc="value.changed_field"/>
+ <t t-if="value.old_value">
+ <div class="o_Message_trackingValueItem" t-esc="value.old_value || ((value.field_type !== 'boolean') and '')"/>
+ <t t-if="value.old_value !== value.new_value">
+ <div class="o_Message_trackingValueItem fa fa-long-arrow-right" title="Changed" role="img"/>
+ </t>
+ </t>
+ <t t-if="value.old_value !== value.new_value">
+ <div class="o_Message_trackingValueItem" t-esc="value.new_value || ((value.field_type !== 'boolean') and '')"/>
+ </t>
+ </div>
+ </li>
+ </t>
+ </ul>
+ </t>
+ </div>
+ <t t-if="message.attachments and message.attachments.length > 0">
+ <div class="o_Message_footer">
+ <!-- TODO XDU This does not allow to preview all attachments at once, find somethind else -->
+ <t t-if="imageAttachments and imageAttachments.length > 0">
+ <AttachmentList
+ class="o_Message_attachmentList"
+ areAttachmentsDownloadable="true"
+ areAttachmentsEditable="true"
+ attachmentLocalIds="imageAttachments.map(attachment => attachment.localId)"
+ attachmentsDetailsMode="props.attachmentsDetailsMode"
+ />
+ </t>
+ <t t-if="nonImageAttachments and nonImageAttachments.length > 0">
+ <AttachmentList
+ class="o_Message_attachmentList"
+ areAttachmentsDownloadable="true"
+ areAttachmentsEditable="true"
+ attachmentLocalIds="nonImageAttachments.map(attachment => attachment.localId)"
+ attachmentsDetailsMode="props.attachmentsDetailsMode"
+ />
+ </t>
+ </div>
+ </t>
+ </div>
+ <t t-if="state.hasModerationBanDialog">
+ <ModerationBanDialog messageLocalIds="[message.localId]" t-on-dialog-closed="_onDialogClosedModerationBan"/>
+ </t>
+ <t t-if="state.hasModerationDiscardDialog">
+ <ModerationDiscardDialog messageLocalIds="[message.localId]" t-on-dialog-closed="_onDialogClosedModerationDiscard"/>
+ </t>
+ <t t-if="state.hasModerationRejectDialog">
+ <ModerationRejectDialog messageLocalIds="[message.localId]" t-on-dialog-closed="_onDialogClosedModerationReject"/>
+ </t>
+ </t>
+ </div>
+ </t>
+
+</templates>
diff --git a/addons/mail/static/src/components/message/message_tests.js b/addons/mail/static/src/components/message/message_tests.js
new file mode 100644
index 00000000000..b09d0a181bd
--- /dev/null
+++ b/addons/mail/static/src/components/message/message_tests.js
@@ -0,0 +1,692 @@
+odoo.define('mail/static/src/components/message/message_tests.js', function (require) {
+'use strict';
+
+const components = {
+ Message: require('mail/static/src/components/message/message.js'),
+};
+const {
+ afterEach: utilsAfterEach,
+ afterNextRender,
+ beforeEach: utilsBeforeEach,
+ start: utilsStart,
+} = require('mail/static/src/utils/test_utils.js');
+
+const Bus = require('web.Bus');
+
+QUnit.module('mail', {}, function () {
+QUnit.module('components', {}, function () {
+QUnit.module('message', {}, function () {
+QUnit.module('message_tests.js', {
+ beforeEach() {
+ utilsBeforeEach(this);
+
+ this.createMessageComponent = async (message, otherProps) => {
+ const MessageComponent = components.Message;
+ MessageComponent.env = this.env;
+ this.component = new MessageComponent(null, Object.assign({
+ messageLocalId: message.localId,
+ }, otherProps));
+ await afterNextRender(() => this.component.mount(this.widget.el));
+ };
+
+ 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);
+ if (this.component) {
+ this.component.destroy();
+ }
+ if (this.widget) {
+ this.widget.destroy();
+ }
+ this.env = undefined;
+ delete components.Message.env;
+ },
+});
+
+QUnit.test('basic rendering', async function (assert) {
+ assert.expect(12);
+
+ await this.start();
+ const message = this.env.models['mail.message'].create({
+ author: [['insert', { id: 7, display_name: "Demo User" }]],
+ body: "<p>Test</p>",
+ id: 100,
+ });
+ await this.createMessageComponent(message);
+ assert.strictEqual(
+ document.querySelectorAll('.o_Message').length,
+ 1,
+ "should display a message component"
+ );
+ const messageEl = document.querySelector('.o_Message');
+ assert.strictEqual(
+ messageEl.dataset.messageLocalId,
+ this.env.models['mail.message'].find(message => message.id === 100).localId,
+ "message component should be linked to message store model"
+ );
+ assert.strictEqual(
+ messageEl.querySelectorAll(`:scope .o_Message_sidebar`).length,
+ 1,
+ "message should have a sidebar"
+ );
+ assert.strictEqual(
+ messageEl.querySelectorAll(`:scope .o_Message_sidebar .o_Message_authorAvatar`).length,
+ 1,
+ "message should have author avatar in the sidebar"
+ );
+ assert.strictEqual(
+ messageEl.querySelector(`:scope .o_Message_authorAvatar`).tagName,
+ 'IMG',
+ "message author avatar should be an image"
+ );
+ assert.strictEqual(
+ messageEl.querySelector(`:scope .o_Message_authorAvatar`).dataset.src,
+ '/web/image/res.partner/7/image_128',
+ "message author avatar should GET image of the related partner"
+ );
+ assert.strictEqual(
+ messageEl.querySelectorAll(`:scope .o_Message_authorName`).length,
+ 1,
+ "message should display author name"
+ );
+ assert.strictEqual(
+ messageEl.querySelector(`:scope .o_Message_authorName`).textContent,
+ "Demo User",
+ "message should display correct author name"
+ );
+ assert.strictEqual(
+ messageEl.querySelectorAll(`:scope .o_Message_date`).length,
+ 1,
+ "message should display date"
+ );
+ assert.strictEqual(
+ messageEl.querySelectorAll(`:scope .o_Message_commands`).length,
+ 1,
+ "message should display list of commands"
+ );
+ assert.strictEqual(
+ messageEl.querySelectorAll(`:scope .o_Message_content`).length,
+ 1,
+ "message should display the content"
+ );
+ assert.strictEqual(messageEl.querySelector(`:scope .o_Message_content`).innerHTML,
+ "<p>Test</p>",
+ "message should display the correct content"
+ );
+});
+
+QUnit.test('delete attachment linked to message', async function (assert) {
+ assert.expect(1);
+
+ await this.start();
+ const message = this.env.models['mail.message'].create({
+ attachments: [['insert-and-replace', {
+ filename: "BLAH.jpg",
+ id: 10,
+ name: "BLAH",
+ }]],
+ author: [['insert', { id: 7, display_name: "Demo User" }]],
+ body: "<p>Test</p>",
+ id: 100,
+ });
+ await this.createMessageComponent(message);
+ await afterNextRender(() => document.querySelector('.o_Attachment_asideItemUnlink').click());
+ assert.notOk(this.env.models['mail.attachment'].find(attachment => attachment.id === 10));
+});
+
+QUnit.test('moderation: moderated channel with pending moderation message (author)', async function (assert) {
+ assert.expect(1);
+
+ await this.start();
+ const thread = this.env.models['mail.thread'].create({
+ id: 20,
+ model: 'mail.channel',
+ });
+ const message = this.env.models['mail.message'].create({
+ author: [['insert', { id: 1, display_name: "Admin" }]],
+ body: "<p>Test</p>",
+ id: 100,
+ moderation_status: 'pending_moderation',
+ originThread: [['link', thread]],
+ threadCaches: [['link', thread.mainCache]],
+ });
+ await this.createMessageComponent(message);
+
+ assert.strictEqual(
+ document.querySelectorAll(`.o_Message_moderationPending.o-author`).length,
+ 1,
+ "should have the message pending moderation"
+ );
+});
+
+QUnit.test('moderation: moderated channel with pending moderation message (moderator)', async function (assert) {
+ assert.expect(9);
+
+ Object.assign(this.data.initMessaging, {
+ moderation_channel_ids: [20],
+ });
+ await this.start();
+ const thread = this.env.models['mail.thread'].create({
+ id: 20,
+ model: 'mail.channel',
+ });
+ const message = this.env.models['mail.message'].create({
+ author: [['insert', { id: 7, display_name: "Demo User" }]],
+ body: "<p>Test</p>",
+ id: 100,
+ moderation_status: 'pending_moderation',
+ originThread: [['link', thread]],
+ threadCaches: [['link', thread.mainCache]],
+ });
+ await this.createMessageComponent(message);
+ const messageEl = document.querySelector('.o_Message');
+ assert.ok(messageEl, "should display a message");
+ assert.containsOnce(messageEl, `.o_Message_moderationSubHeader`,
+ "should have the message pending moderation"
+ );
+ assert.containsNone(messageEl, `.o_Message_checkbox`,
+ "should not have the moderation checkbox by default"
+ );
+ assert.containsN(messageEl, '.o_Message_moderationAction', 5,
+ "there should be 5 contextual moderation decisions next to the message"
+ );
+ assert.containsOnce(messageEl, '.o_Message_moderationAction.o-accept',
+ "there should be a contextual moderation decision to accept the message"
+ );
+ assert.containsOnce(messageEl, '.o_Message_moderationAction.o-reject',
+ "there should be a contextual moderation decision to reject the message"
+ );
+ assert.containsOnce(messageEl, '.o_Message_moderationAction.o-discard',
+ "there should be a contextual moderation decision to discard the message"
+ );
+ assert.containsOnce(messageEl, '.o_Message_moderationAction.o-allow',
+ "there should be a contextual moderation decision to allow the user of the message)"
+ );
+ assert.containsOnce(messageEl, '.o_Message_moderationAction.o-ban',
+ "there should be a contextual moderation decision to ban the user of the message"
+ );
+ // The actions are tested as part of discuss tests.
+});
+
+QUnit.test('Notification Sent', async function (assert) {
+ assert.expect(9);
+
+ await this.start();
+ const message = this.env.models['mail.message'].create({
+ id: 10,
+ message_type: 'email',
+ notifications: [['insert', {
+ id: 11,
+ notification_status: 'sent',
+ notification_type: 'email',
+ partner: [['insert', { id: 12, name: "Someone" }]],
+ }]],
+ });
+ await this.createMessageComponent(message);
+
+ assert.containsOnce(
+ document.body,
+ '.o_Message',
+ "should display a message component"
+ );
+ assert.containsOnce(
+ document.body,
+ '.o_Message_notificationIconContainer',
+ "should display the notification icon container"
+ );
+ assert.containsOnce(
+ document.body,
+ '.o_Message_notificationIcon',
+ "should display the notification icon"
+ );
+ assert.hasClass(
+ document.querySelector('.o_Message_notificationIcon'),
+ 'fa-envelope-o',
+ "icon should represent email success"
+ );
+
+ await afterNextRender(() => {
+ document.querySelector('.o_Message_notificationIconContainer').click();
+ });
+ assert.containsOnce(
+ document.body,
+ '.o_NotificationPopover',
+ "notification popover should be open"
+ );
+ assert.containsOnce(
+ document.body,
+ '.o_NotificationPopover_notificationIcon',
+ "popover should have one icon"
+ );
+ assert.hasClass(
+ document.querySelector('.o_NotificationPopover_notificationIcon'),
+ 'fa-check',
+ "popover should have the sent icon"
+ );
+ assert.containsOnce(
+ document.body,
+ '.o_NotificationPopover_notificationPartnerName',
+ "popover should have the partner name"
+ );
+ assert.strictEqual(
+ document.querySelector('.o_NotificationPopover_notificationPartnerName').textContent.trim(),
+ "Someone",
+ "partner name should be correct"
+ );
+});
+
+QUnit.test('Notification Error', async function (assert) {
+ assert.expect(8);
+
+ const bus = new Bus();
+ bus.on('do-action', null, payload => {
+ assert.step('do_action');
+ assert.strictEqual(
+ payload.action,
+ 'mail.mail_resend_message_action',
+ "action should be the one to resend email"
+ );
+ assert.strictEqual(
+ payload.options.additional_context.mail_message_to_resend,
+ 10,
+ "action should have correct message id"
+ );
+ });
+
+ await this.start({ env: { bus } });
+ const message = this.env.models['mail.message'].create({
+ id: 10,
+ message_type: 'email',
+ notifications: [['insert', {
+ id: 11,
+ notification_status: 'exception',
+ notification_type: 'email',
+ }]],
+ });
+ await this.createMessageComponent(message);
+
+ assert.containsOnce(
+ document.body,
+ '.o_Message',
+ "should display a message component"
+ );
+ assert.containsOnce(
+ document.body,
+ '.o_Message_notificationIconContainer',
+ "should display the notification icon container"
+ );
+ assert.containsOnce(
+ document.body,
+ '.o_Message_notificationIcon',
+ "should display the notification icon"
+ );
+ assert.hasClass(
+ document.querySelector('.o_Message_notificationIcon'),
+ 'fa-envelope',
+ "icon should represent email error"
+ );
+
+ await afterNextRender(() => {
+ document.querySelector('.o_Message_notificationIconContainer').click();
+ });
+ assert.verifySteps(
+ ['do_action'],
+ "should do an action to display the resend email dialog"
+ );
+});
+
+QUnit.test("'channel_fetch' notification received is correctly handled", async function (assert) {
+ assert.expect(3);
+
+ await this.start();
+ const currentPartner = this.env.models['mail.partner'].create({
+ id: this.env.session.partner_id,
+ display_name: "Demo User",
+ });
+ const thread = this.env.models['mail.thread'].create({
+ id: 11,
+ members: [
+ [['link', currentPartner]],
+ [['insert', { id: 11, display_name: "Recipient" }]]
+ ],
+ model: 'mail.channel',
+ });
+ const threadViewer = this.env.models['mail.thread_viewer'].create({ thread: [['link', thread]] });
+ const message = this.env.models['mail.message'].create({
+ author: [['link', currentPartner]],
+ body: "<p>Test</p>",
+ id: 100,
+ threadCaches: [['link', thread.mainCache]],
+ });
+
+ await this.createMessageComponent(message, { threadViewerLocalId: threadViewer.localId });
+
+ assert.containsOnce(
+ document.body,
+ '.o_Message',
+ "should display a message component"
+ );
+ assert.containsNone(
+ document.body,
+ '.o_MessageSeenIndicator_icon',
+ "message component should not have any check (V) as message is not yet received"
+ );
+
+ // Simulate received channel fetched notification
+ const notifications = [
+ [['myDB', 'mail.channel', 11], {
+ info: 'channel_fetched',
+ last_message_id: 100,
+ partner_id: 11,
+ }],
+ ];
+ await afterNextRender(() => {
+ this.widget.call('bus_service', 'trigger', 'notification', notifications);
+ });
+
+ assert.containsOnce(
+ document.body,
+ '.o_MessageSeenIndicator_icon',
+ "message seen indicator component should only contain one check (V) as message is just received"
+ );
+});
+
+QUnit.test("'channel_seen' notification received is correctly handled", async function (assert) {
+ assert.expect(3);
+
+ await this.start();
+ const currentPartner = this.env.models['mail.partner'].create({
+ id: this.env.session.partner_id,
+ display_name: "Demo User",
+ });
+ const thread = this.env.models['mail.thread'].create({
+ id: 11,
+ members: [
+ [['link', currentPartner]],
+ [['insert', { id: 11, display_name: "Recipient" }]]
+ ],
+ model: 'mail.channel',
+ });
+ const threadViewer = this.env.models['mail.thread_viewer'].create({ thread: [['link', thread]] });
+ const message = this.env.models['mail.message'].create({
+ author: [['link', currentPartner]],
+ body: "<p>Test</p>",
+ id: 100,
+ threadCaches: [['link', thread.mainCache]],
+ });
+ await this.createMessageComponent(message, { threadViewerLocalId: threadViewer.localId });
+
+ assert.containsOnce(
+ document.body,
+ '.o_Message',
+ "should display a message component"
+ );
+ assert.containsNone(
+ document.body,
+ '.o_MessageSeenIndicator_icon',
+ "message component should not have any check (V) as message is not yet received"
+ );
+
+ // Simulate received channel seen notification
+ const notifications = [
+ [['myDB', 'mail.channel', 11], {
+ info: 'channel_seen',
+ last_message_id: 100,
+ partner_id: 11,
+ }],
+ ];
+ await afterNextRender(() => {
+ this.widget.call('bus_service', 'trigger', 'notification', notifications);
+ });
+ assert.containsN(
+ document.body,
+ '.o_MessageSeenIndicator_icon',
+ 2,
+ "message seen indicator component should contain two checks (V) as message is seen"
+ );
+});
+
+QUnit.test("'channel_fetch' notification then 'channel_seen' received are correctly handled", async function (assert) {
+ assert.expect(4);
+
+ await this.start();
+ const currentPartner = this.env.models['mail.partner'].create({
+ id: this.env.session.partner_id,
+ display_name: "Demo User",
+ });
+ const thread = this.env.models['mail.thread'].create({
+ id: 11,
+ members: [
+ [['link', currentPartner]],
+ [['insert', { id: 11, display_name: "Recipient" }]]
+ ],
+ model: 'mail.channel',
+ });
+ const threadViewer = this.env.models['mail.thread_viewer'].create({ thread: [['link', thread]] });
+ const message = this.env.models['mail.message'].create({
+ author: [['link', currentPartner]],
+ body: "<p>Test</p>",
+ id: 100,
+ threadCaches: [['link', thread.mainCache]],
+ });
+ await this.createMessageComponent(message, { threadViewerLocalId: threadViewer.localId });
+
+ assert.containsOnce(
+ document.body,
+ '.o_Message',
+ "should display a message component"
+ );
+ assert.containsNone(
+ document.body,
+ '.o_MessageSeenIndicator_icon',
+ "message component should not have any check (V) as message is not yet received"
+ );
+
+ // Simulate received channel fetched notification
+ let notifications = [
+ [['myDB', 'mail.channel', 11], {
+ info: 'channel_fetched',
+ last_message_id: 100,
+ partner_id: 11,
+ }],
+ ];
+ await afterNextRender(() => {
+ this.widget.call('bus_service', 'trigger', 'notification', notifications);
+ });
+ assert.containsOnce(
+ document.body,
+ '.o_MessageSeenIndicator_icon',
+ "message seen indicator component should only contain one check (V) as message is just received"
+ );
+
+ // Simulate received channel seen notification
+ notifications = [
+ [['myDB', 'mail.channel', 11], {
+ info: 'channel_seen',
+ last_message_id: 100,
+ partner_id: 11,
+ }],
+ ];
+ await afterNextRender(() => {
+ this.widget.call('bus_service', 'trigger', 'notification', notifications);
+ });
+ assert.containsN(
+ document.body,
+ '.o_MessageSeenIndicator_icon',
+ 2,
+ "message seen indicator component should contain two checks (V) as message is now seen"
+ );
+});
+
+QUnit.test('do not show messaging seen indicator if not authored by me', async function (assert) {
+ assert.expect(2);
+
+ await this.start();
+ const author = this.env.models['mail.partner'].create({
+ id: 100,
+ display_name: "Demo User"
+ });
+ const thread = this.env.models['mail.thread'].create({
+ id: 11,
+ partnerSeenInfos: [['create', [
+ {
+ lastFetchedMessage: [['insert', {id: 100}]],
+ partner: [['insert', {id: this.env.session.partner_id}]],
+ },
+ {
+ lastFetchedMessage: [['insert', {id: 100}]],
+ partner: [['link', author]],
+ },
+ ]]],
+ model: 'mail.channel',
+ });
+ const threadViewer = this.env.models['mail.thread_viewer'].create({ thread: [['link', thread]] });
+ const message = this.env.models['mail.message'].create({
+ author: [['link', author]],
+ body: "<p>Test</p>",
+ id: 100,
+ threadCaches: [['link', thread.mainCache]],
+ });
+ await this.createMessageComponent(message, { threadViewerLocalId: threadViewer.localId });
+
+ assert.containsOnce(
+ document.body,
+ '.o_Message',
+ "should display a message component"
+ );
+ assert.containsNone(
+ document.body,
+ '.o_Message_seenIndicator',
+ "message component should not have any message seen indicator"
+ );
+});
+
+QUnit.test('do not show messaging seen indicator if before last seen by all message', async function (assert) {
+ assert.expect(3);
+
+ await this.start();
+ const currentPartner = this.env.models['mail.partner'].create({
+ id: this.env.session.partner_id,
+ display_name: "Demo User",
+ });
+ const thread = this.env.models['mail.thread'].create({
+ id: 11,
+ messageSeenIndicators: [['insert', {
+ id: this.env.models['mail.message_seen_indicator'].computeId(99, 11),
+ message: [['insert', {id: 99}]],
+ }]],
+ model: 'mail.channel',
+ });
+ const threadViewer = this.env.models['mail.thread_viewer'].create({ thread: [['link', thread]] });
+ const lastSeenMessage = this.env.models['mail.message'].create({
+ author: [['link', currentPartner]],
+ body: "<p>You already saw me</p>",
+ id: 100,
+ threadCaches: [['link', thread.mainCache]],
+ });
+ const message = this.env.models['mail.message'].create({
+ author: [['link', currentPartner]],
+ body: "<p>Test</p>",
+ id: 99,
+ threadCaches: [['link', thread.mainCache]],
+ });
+ thread.update({
+ partnerSeenInfos: [['create', [
+ {
+ lastSeenMessage: [['link', lastSeenMessage]],
+ partner: [['link', currentPartner]],
+ },
+ {
+ lastSeenMessage: [['link', lastSeenMessage]],
+ partner: [['insert', {id: 100}]],
+ },
+ ]]],
+ });
+ await this.createMessageComponent(message, { threadViewerLocalId: threadViewer.localId });
+
+ assert.containsOnce(
+ document.body,
+ '.o_Message',
+ "should display a message component"
+ );
+ assert.containsOnce(
+ document.body,
+ '.o_Message_seenIndicator',
+ "message component should have a message seen indicator"
+ );
+ assert.containsNone(
+ document.body,
+ '.o_MessageSeenIndicator_icon',
+ "message component should not have any check (V)"
+ );
+});
+
+QUnit.test('only show messaging seen indicator if authored by me, after last seen by all message', async function (assert) {
+ assert.expect(3);
+
+ await this.start();
+ const currentPartner = this.env.models['mail.partner'].create({
+ id: this.env.session.partner_id,
+ display_name: "Demo User"
+ });
+ const thread = this.env.models['mail.thread'].create({
+ id: 11,
+ partnerSeenInfos: [['create', [
+ {
+ lastSeenMessage: [['insert', {id: 100}]],
+ partner: [['link', currentPartner]],
+ },
+ {
+ partner: [['insert', {id: 100}]],
+ lastFetchedMessage: [['insert', {id: 100}]],
+ lastSeenMessage: [['insert', {id: 99}]],
+ },
+ ]]],
+ messageSeenIndicators: [['insert', {
+ id: this.env.models['mail.message_seen_indicator'].computeId(100, 11),
+ message: [['insert', {id: 100}]],
+ }]],
+ model: 'mail.channel',
+ });
+ const threadViewer = this.env.models['mail.thread_viewer'].create({ thread: [['link', thread]] });
+ const message = this.env.models['mail.message'].create({
+ author: [['link', currentPartner]],
+ body: "<p>Test</p>",
+ id: 100,
+ threadCaches: [['link', thread.mainCache]],
+ });
+ await this.createMessageComponent(message, { threadViewerLocalId: threadViewer.localId });
+
+ assert.containsOnce(
+ document.body,
+ '.o_Message',
+ "should display a message component"
+ );
+ assert.containsOnce(
+ document.body,
+ '.o_Message_seenIndicator',
+ "message component should have a message seen indicator"
+ );
+ assert.containsN(
+ document.body,
+ '.o_MessageSeenIndicator_icon',
+ 2,
+ "message component should have two checks (V)"
+ );
+});
+
+});
+});
+});
+
+});
diff --git a/addons/mail/static/src/components/message_author_prefix/message_author_prefix.js b/addons/mail/static/src/components/message_author_prefix/message_author_prefix.js
new file mode 100644
index 00000000000..07fb1d054f0
--- /dev/null
+++ b/addons/mail/static/src/components/message_author_prefix/message_author_prefix.js
@@ -0,0 +1,65 @@
+odoo.define('mail/static/src/components/message_author_prefix/message_author_prefix.js', function (require) {
+'use strict';
+
+const useStore = require('mail/static/src/component_hooks/use_store/use_store.js');
+
+const { Component } = owl;
+
+class MessageAuthorPrefix extends Component {
+
+ /**
+ * @override
+ */
+ constructor(...args) {
+ super(...args);
+ useStore(props => {
+ const message = this.env.models['mail.message'].get(props.messageLocalId);
+ const author = message ? message.author : undefined;
+ const thread = props.threadLocalId
+ ? this.env.models['mail.thread'].get(props.threadLocalId)
+ : undefined;
+ return {
+ author: author ? author.__state : undefined,
+ currentPartner: this.env.messaging.currentPartner
+ ? this.env.messaging.currentPartner.__state
+ : undefined,
+ message: message ? message.__state : undefined,
+ thread: thread ? thread.__state : undefined,
+ };
+ });
+ }
+
+ //--------------------------------------------------------------------------
+ // Public
+ //--------------------------------------------------------------------------
+
+ /**
+ * @returns {mail.message}
+ */
+ get message() {
+ return this.env.models['mail.message'].get(this.props.messageLocalId);
+ }
+
+ /**
+ * @returns {mail.thread|undefined}
+ */
+ get thread() {
+ return this.env.models['mail.thread'].get(this.props.threadLocalId);
+ }
+
+}
+
+Object.assign(MessageAuthorPrefix, {
+ props: {
+ messageLocalId: String,
+ threadLocalId: {
+ type: String,
+ optional: true,
+ },
+ },
+ template: 'mail.MessageAuthorPrefix',
+});
+
+return MessageAuthorPrefix;
+
+});
diff --git a/addons/mail/static/src/components/message_author_prefix/message_author_prefix.scss b/addons/mail/static/src/components/message_author_prefix/message_author_prefix.scss
new file mode 100644
index 00000000000..362eaeb5b92
--- /dev/null
+++ b/addons/mail/static/src/components/message_author_prefix/message_author_prefix.scss
@@ -0,0 +1,11 @@
+// ------------------------------------------------------------------
+// Layout
+// ------------------------------------------------------------------
+
+.o_MessageAuthorPrefixIcon {
+ margin-right: 3px;
+}
+
+// ------------------------------------------------------------------
+// Style
+// ------------------------------------------------------------------
diff --git a/addons/mail/static/src/components/message_author_prefix/message_author_prefix.xml b/addons/mail/static/src/components/message_author_prefix/message_author_prefix.xml
new file mode 100644
index 00000000000..de8992e4b8f
--- /dev/null
+++ b/addons/mail/static/src/components/message_author_prefix/message_author_prefix.xml
@@ -0,0 +1,17 @@
+<?xml version="1.0" encoding="UTF-8"?>
+<templates xml:space="preserve">
+
+ <t t-name="mail.MessageAuthorPrefix" owl="1">
+ <span class="o_MessageAuthorPrefix">
+ <t t-if="message">
+ <t t-if="message.author and message.author === env.messaging.currentPartner">
+ <i class="o_MessageAuthorPrefixIcon fa fa-mail-reply"/>You:
+ </t>
+ <t t-elif="thread and thread.channel_type === 'channel'">
+ <t t-esc="message.author.nameOrDisplayName"/>:
+ </t>
+ </t>
+ </span>
+ </t>
+
+</templates>
diff --git a/addons/mail/static/src/components/message_list/message_list.js b/addons/mail/static/src/components/message_list/message_list.js
new file mode 100644
index 00000000000..c948689c6d1
--- /dev/null
+++ b/addons/mail/static/src/components/message_list/message_list.js
@@ -0,0 +1,531 @@
+odoo.define('mail/static/src/components/message_list/message_list.js', function (require) {
+'use strict';
+
+const components = {
+ Message: require('mail/static/src/components/message/message.js'),
+};
+const useRefs = require('mail/static/src/component_hooks/use_refs/use_refs.js');
+const useStore = require('mail/static/src/component_hooks/use_store/use_store.js');
+
+const { Component } = owl;
+const { useRef } = owl.hooks;
+
+class MessageList extends Component {
+
+ /**
+ * @override
+ */
+ constructor(...args) {
+ super(...args);
+ useStore(props => {
+ const threadViewer = this.env.models['mail.thread_viewer'].get(props.threadViewerLocalId);
+ const thread = threadViewer ? threadViewer.thread : undefined;
+ const threadCache = threadViewer ? threadViewer.threadCache : undefined;
+ return {
+ isDeviceMobile: this.env.messaging.device.isMobile,
+ messages: threadCache
+ ? threadCache.orderedMessages.map(message => message.__state)
+ : [],
+ thread: thread ? thread.__state : undefined,
+ threadCache: threadCache ? threadCache.__state : undefined,
+ threadViewer: threadViewer ? threadViewer.__state : undefined,
+ };
+ }, {
+ compareDepth: {
+ messages: 1,
+ },
+ });
+ this._getRefs = useRefs();
+ /**
+ * Determine whether the auto-scroll on load is active or not. This
+ * is useful to disable some times, such as when mounting message list
+ * in ASC order: the initial scroll position is at the top of the
+ * conversation, and most of the time the expected initial scroll
+ * position should be at the bottom of the thread. During this time,
+ * the programmatical scrolling should not trigger auto-load messages
+ * on scroll.
+ */
+ this._isAutoLoadOnScrollActive = true;
+ /**
+ * Reference of the "load more" item. Useful to trigger load more
+ * on scroll when it becomes visible.
+ */
+ this._loadMoreRef = useRef('loadMore');
+ /**
+ * Tracked last thread cache rendered. Useful to determine scroll
+ * position on patch if it is on the same thread cache or not.
+ */
+ this._renderedThreadCache = null;
+ /**
+ * Tracked last selected message. Useful to determine when patch comes
+ * from a message selection on a given thread cache, so that it
+ * auto-scroll to that message.
+ */
+ this._selectedMessage = null;
+ /**
+ * Snapshot computed during willPatch, which is used by patched.
+ */
+ this._willPatchSnapshot = undefined;
+ this._onScroll = _.throttle(this._onScroll.bind(this), 100);
+ }
+
+ mounted() {
+ this._update();
+ }
+
+ willPatch() {
+ const lastMessageRef = this.lastMessageRef;
+ this._willPatchSnapshot = {
+ isLastMessageVisible:
+ lastMessageRef &&
+ lastMessageRef.isBottomVisible({ offset: 10 }),
+ scrollHeight: this.el.scrollHeight,
+ scrollTop: this.el.scrollTop,
+ };
+ }
+
+ patched() {
+ this._update();
+ }
+
+ //--------------------------------------------------------------------------
+ // Public
+ //--------------------------------------------------------------------------
+
+ /**
+ * Update the scroll position of the message list.
+ * This is not done in patched/mounted hooks because scroll position is
+ * dependent on UI globally. To illustrate, imagine following UI:
+ *
+ * +----------+ < viewport top = scrollable top
+ * | message |
+ * | list |
+ * | |
+ * +----------+ < scrolltop = viewport bottom = scrollable bottom
+ *
+ * Now if a composer is mounted just below the message list, it is shrinked
+ * and scrolltop is altered as a result:
+ *
+ * +----------+ < viewport top = scrollable top
+ * | message |
+ * | list | < scrolltop = viewport bottom <-+
+ * | | |-- dist = composer height
+ * +----------+ < scrollable bottom <-+
+ * +----------+
+ * | composer |
+ * +----------+
+ *
+ * Because of this, the scroll position must be changed when whole UI
+ * is rendered. To make this simpler, this is done when <ThreadViewer/>
+ * component is patched. This is acceptable when <ThreadViewer/> has a
+ * fixed height, which is the case for the moment.
+ */
+ async adjustFromComponentHints() {
+ for (const hint of this.threadViewer.componentHintList) {
+ switch (hint.type) {
+ case 'change-of-thread-cache':
+ this._adjustFromChangeOfThreadCache(hint);
+ break;
+ case 'current-partner-just-posted-message':
+ this._adjustFromCurrentPartnerJustPostedMessage(hint);
+ break;
+ case 'more-messages-loaded':
+ this._adjustFromMoreMessagesLoaded(hint);
+ break;
+ default:
+ this.threadViewer.markComponentHintProcessed(hint);
+ break;
+ }
+ }
+ this._willPatchSnapshot = undefined;
+ }
+
+ /**
+ * @param {mail.message} message
+ * @returns {string}
+ */
+ getDateDay(message) {
+ const date = message.date.format('YYYY-MM-DD');
+ if (date === moment().format('YYYY-MM-DD')) {
+ return this.env._t("Today");
+ } else if (
+ date === moment()
+ .subtract(1, 'days')
+ .format('YYYY-MM-DD')
+ ) {
+ return this.env._t("Yesterday");
+ }
+ return message.date.format('LL');
+ }
+
+ /**
+ * @returns {integer}
+ */
+ getScrollTop() {
+ return this.el.scrollTop;
+ }
+
+ /**
+ * @returns {mail/static/src/components/message/message.js|undefined}
+ */
+ get mostRecentMessageRef() {
+ if (this.props.order === 'desc') {
+ return this.messageRefs[0];
+ }
+ const { length: l, [l - 1]: mostRecentMessageRef } = this.messageRefs;
+ return mostRecentMessageRef;
+ }
+
+ /**
+ * @param {integer} messageId
+ * @returns {mail/static/src/components/message/message.js|undefined}
+ */
+ messageRefFromId(messageId) {
+ return this.messageRefs.find(ref => ref.message.id === messageId);
+ }
+
+ /**
+ * Get list of sub-components Message, ordered based on prop `order`
+ * (ASC/DESC).
+ *
+ * The asynchronous nature of OWL rendering pipeline may reveal disparity
+ * between knowledgeable state of store between components. Use this getter
+ * with extreme caution!
+ *
+ * Let's illustrate the disparity with a small example:
+ *
+ * - Suppose this component is aware of ordered (record) messages with
+ * following IDs: [1, 2, 3, 4, 5], and each (sub-component) messages map
+ * each of these records.
+ * - Now let's assume a change in store that translate to ordered (record)
+ * messages with following IDs: [2, 3, 4, 5, 6].
+ * - Because store changes trigger component re-rendering by their "depth"
+ * (i.e. from parents to children), this component may be aware of
+ * [2, 3, 4, 5, 6] but not yet sub-components, so that some (component)
+ * messages should be destroyed but aren't yet (the ref with message ID 1)
+ * and some do not exist yet (no ref with message ID 6).
+ *
+ * @returns {mail/static/src/components/message/message.js[]}
+ */
+ get messageRefs() {
+ const refs = this._getRefs();
+ const ascOrderedMessageRefs = Object.entries(refs)
+ .filter(([refId, ref]) => (
+ // Message refs have message local id as ref id, and message
+ // local ids contain name of model 'mail.message'.
+ refId.includes(this.env.models['mail.message'].modelName) &&
+ // Component that should be destroyed but haven't just yet.
+ ref.message
+ )
+ )
+ .map(([refId, ref]) => ref)
+ .sort((ref1, ref2) => (ref1.message.id < ref2.message.id ? -1 : 1));
+ if (this.props.order === 'desc') {
+ return ascOrderedMessageRefs.reverse();
+ }
+ return ascOrderedMessageRefs;
+ }
+
+ /**
+ * @returns {mail.message[]}
+ */
+ get orderedMessages() {
+ const threadCache = this.threadViewer.threadCache;
+ if (this.props.order === 'desc') {
+ return [...threadCache.orderedMessages].reverse();
+ }
+ return threadCache.orderedMessages;
+ }
+
+ /**
+ * @param {integer} value
+ */
+ async setScrollTop(value) {
+ this._isAutoLoadOnScrollActive = false;
+ this.el.scrollTop = value;
+ await new Promise(resolve => setTimeout(resolve, 0));
+ this._isAutoLoadOnScrollActive = true;
+ }
+
+ /**
+ * @param {mail.message} prevMessage
+ * @param {mail.message} message
+ * @returns {boolean}
+ */
+ shouldMessageBeSquashed(prevMessage, message) {
+ if (!this.props.hasSquashCloseMessages) {
+ return false;
+ }
+ if (Math.abs(message.date.diff(prevMessage.date)) > 60000) {
+ // more than 1 min. elasped
+ return false;
+ }
+ if (prevMessage.message_type !== 'comment' || message.message_type !== 'comment') {
+ return false;
+ }
+ if (prevMessage.author !== message.author) {
+ // from a different author
+ return false;
+ }
+ if (prevMessage.originThread !== message.originThread) {
+ return false;
+ }
+ if (
+ prevMessage.moderation_status === 'pending_moderation' ||
+ message.moderation_status === 'pending_moderation'
+ ) {
+ return false;
+ }
+ if (
+ prevMessage.notifications.length > 0 ||
+ message.notifications.length > 0
+ ) {
+ // visual about notifications is restricted to non-squashed messages
+ return false;
+ }
+ const prevOriginThread = prevMessage.originThread;
+ const originThread = message.originThread;
+ if (
+ prevOriginThread &&
+ originThread &&
+ prevOriginThread.model === originThread.model &&
+ originThread.model !== 'mail.channel' &&
+ prevOriginThread.id !== originThread.id
+ ) {
+ // messages linked to different document thread
+ return false;
+ }
+ return true;
+ }
+
+ /**
+ * @returns {mail.thread_viewer}
+ */
+ get threadViewer() {
+ return this.env.models['mail.thread_viewer'].get(this.props.threadViewerLocalId);
+ }
+
+ //--------------------------------------------------------------------------
+ // Private
+ //--------------------------------------------------------------------------
+
+ /**
+ * @private
+ * @param {Object} hint
+ */
+ async _adjustFromChangeOfThreadCache(hint) {
+ const threadCache = this.threadViewer.threadCache;
+ if (!threadCache.isLoaded) {
+ return;
+ }
+ let isProcessed = false;
+ if (threadCache.messages.length > 0) {
+ if (this.threadViewer.threadCacheInitialPosition !== undefined) {
+ this.el.scrollTop = this.threadViewer.threadCacheInitialPosition;
+ isProcessed = true;
+ } else {
+ const lastMessage = threadCache.lastMessage;
+ if (this.messageRefFromId(lastMessage.id)) {
+ this._scrollToMostRecentMessage();
+ isProcessed = true;
+ }
+ }
+ } else {
+ isProcessed = true;
+ }
+ if (isProcessed) {
+ this.threadViewer.markComponentHintProcessed(hint);
+ }
+ }
+
+ /**
+ * @private
+ * @param {Object} hint
+ * @param {integer} hint.messageId
+ */
+ async _adjustFromCurrentPartnerJustPostedMessage(hint) {
+ const threadCache = this.threadViewer.threadCache;
+ const { messageId } = hint;
+ if (threadCache.isLoaded) {
+ const threadCacheMessageIds = threadCache.messages.map(message => message.id);
+ if (threadCacheMessageIds.includes(messageId) && this.messageRefFromId(messageId)) {
+ await this._scrollToMessage(messageId);
+ this.threadViewer.markComponentHintProcessed(hint);
+ }
+ }
+ }
+
+ /**
+ * @private
+ * @param {Object} hint
+ */
+ _adjustFromMoreMessagesLoaded(hint) {
+ if (!this._willPatchSnapshot) {
+ this.threadViewer.markComponentHintProcessed(hint);
+ return;
+ }
+ const { scrollHeight, scrollTop } = this._willPatchSnapshot;
+ if (this.props.order === 'asc') {
+ this.el.scrollTop = this.el.scrollHeight - scrollHeight + scrollTop;
+ }
+ this.threadViewer.markComponentHintProcessed(hint);
+ }
+
+ /**
+ * @private
+ */
+ _checkThreadMarkAsRead() {
+ const thread = this.threadViewer.thread;
+ const threadCache = this.threadViewer.threadCache;
+ if (!threadCache) {
+ return;
+ }
+ if (threadCache.messages.length === 0) {
+ return;
+ }
+ if (!this.mostRecentMessageRef) {
+ return;
+ }
+ if (
+ threadCache === thread.mainCache &&
+ this.mostRecentMessageRef.isPartiallyVisible()
+ ) {
+ thread.markAsSeen();
+ }
+ }
+
+ /**
+ * @private
+ * @returns {boolean}
+ */
+ _isLoadMoreVisible() {
+ const loadMore = this._loadMoreRef.el;
+ if (!loadMore) {
+ return false;
+ }
+ const loadMoreRect = loadMore.getBoundingClientRect();
+ const elRect = this.el.getBoundingClientRect();
+ const isInvisible = loadMoreRect.top > elRect.bottom || loadMoreRect.bottom < elRect.top;
+ return !isInvisible;
+ }
+
+ /**
+ * @private
+ */
+ _loadMore() {
+ this.threadViewer.threadCache.loadMoreMessages();
+ }
+
+ /**
+ * @private
+ * @returns {Promise}
+ */
+ async _scrollToMostRecentMessage() {
+ if (!this.mostRecentMessageRef) {
+ return;
+ }
+ this._isAutoLoadOnScrollActive = false;
+ await this.mostRecentMessageRef.scrollIntoView();
+ if (!this.el) {
+ this._isAutoLoadOnScrollActive = true;
+ return;
+ }
+ this.el.scrollTop = this.props.order === 'asc'
+ ? this.el.scrollTop + 15
+ : this.el.scrollTop - 15;
+ this._isAutoLoadOnScrollActive = true;
+ }
+
+ /**
+ * @param {integer} messageId
+ */
+ async _scrollToMessage(messageId) {
+ const messageRef = this.messageRefFromId(messageId);
+ if (!messageRef) {
+ return;
+ }
+ this._isAutoLoadOnScrollActive = false;
+ await messageRef.scrollIntoView();
+ if (!this.el) {
+ this._isAutoLoadOnScrollActive = true;
+ return;
+ }
+ this.el.scrollTop = this.props.order === 'asc'
+ ? this.el.scrollTop + 15
+ : this.el.scrollTop - 15;
+ this._isAutoLoadOnScrollActive = true;
+ }
+
+ /**
+ * @private
+ */
+ _update() {
+ this._checkThreadMarkAsRead();
+ }
+
+ //--------------------------------------------------------------------------
+ // Handlers
+ //--------------------------------------------------------------------------
+
+ /**
+ * @private
+ * @param {MouseEvent} ev
+ */
+ _onClickLoadMore(ev) {
+ ev.preventDefault();
+ this._loadMore();
+ }
+
+ /**
+ * @private
+ * @param {ScrollEvent} ev
+ */
+ _onScroll(ev) {
+ if (!this.el) {
+ // could be unmounted in the meantime (due to throttled behavior)
+ return;
+ }
+ this.threadViewer.saveThreadCacheScrollPositionsAsInitial(this.el.scrollTop);
+ if (!this._isAutoLoadOnScrollActive) {
+ return;
+ }
+ if (this._isLoadMoreVisible()) {
+ this._loadMore();
+ }
+ this._checkThreadMarkAsRead();
+ }
+
+}
+
+Object.assign(MessageList, {
+ components,
+ defaultProps: {
+ hasMessageCheckbox: false,
+ hasSquashCloseMessages: false,
+ haveMessagesAuthorRedirect: false,
+ haveMessagesMarkAsReadIcon: false,
+ haveMessagesReplyIcon: false,
+ order: 'asc',
+ },
+ props: {
+ hasMessageCheckbox: Boolean,
+ hasSquashCloseMessages: Boolean,
+ haveMessagesAuthorRedirect: Boolean,
+ haveMessagesMarkAsReadIcon: Boolean,
+ haveMessagesReplyIcon: Boolean,
+ order: {
+ type: String,
+ validate: prop => ['asc', 'desc'].includes(prop),
+ },
+ selectedMessageLocalId: {
+ type: String,
+ optional: true,
+ },
+ threadViewerLocalId: String,
+ },
+ template: 'mail.MessageList',
+});
+
+return MessageList;
+
+});
diff --git a/addons/mail/static/src/components/message_list/message_list.scss b/addons/mail/static/src/components/message_list/message_list.scss
new file mode 100644
index 00000000000..cb06adda261
--- /dev/null
+++ b/addons/mail/static/src/components/message_list/message_list.scss
@@ -0,0 +1,135 @@
+// ------------------------------------------------------------------
+// Layout
+// ------------------------------------------------------------------
+
+.o_MessageList {
+ display: flex;
+ flex-flow: column;
+ overflow: auto;
+
+ &.o-empty {
+ align-items: center;
+ justify-content: center;
+ }
+
+ &:not(.o-empty) {
+ padding-bottom: 15px;
+ }
+}
+
+.o_MessageList_empty {
+ flex: 1 1 auto;
+ height: 100%;
+ width: 100%;
+ align-self: center;
+ display: flex;
+ flex-flow: column;
+ align-items: center;
+ justify-content: center;
+ padding: 20px;
+ line-height: 2.5rem;
+}
+
+.o_MessageList_isLoadingMore {
+ align-self: center;
+}
+
+.o_MessageList_isLoadingMoreIcon {
+ margin-right: 3px;
+}
+
+.o_MessageList_loadMore {
+ align-self: center;
+}
+
+.o_MessageList_separator {
+ display: flex;
+ align-items: center;
+ padding: 0 0;
+ flex: 0 0 auto;
+}
+
+.o_MessageList_separatorDate {
+ padding: 15px 0;
+}
+
+.o_MessageList_separatorLine {
+ flex: 1 1 auto;
+ width: auto;
+}
+
+.o_MessageList_separatorNewMessages {
+ // bug with safari: container does not auto-grow from child size
+ padding: 0 0;
+ margin-right: 15px;
+}
+
+.o_MessageList_separatorLabel {
+ padding: 0 10px;
+}
+
+// ------------------------------------------------------------------
+// Style
+// ------------------------------------------------------------------
+
+.o_MessageList {
+ background-color: white;
+}
+
+.o_MessageList_empty {
+ text-align: center;
+}
+
+.o_MessageList_emptyTitle {
+ font-weight: bold;
+ font-size: 1.3rem;
+
+ &.o-neutral-face-icon:before {
+ @extend %o-nocontent-init-image;
+ @include size(120px, 140px);
+ background: transparent url(/web/static/src/img/neutral_face.svg) no-repeat center;
+ }
+}
+
+.o_MessageList_loadMore {
+ cursor: pointer;
+}
+
+.o_MessageList_message.o-has-message-selection:not(.o-selected) {
+ opacity: 0.5;
+}
+
+.o_MessageList_separator {
+ font-weight: bold;
+}
+
+.o_MessageList_separatorLine {
+ border-color: gray('400');
+}
+
+.o_MessageList_separatorLineNewMessages {
+ border-color: lighten($o-brand-odoo, 15%);
+}
+
+.o_MessageList_separatorNewMessages {
+ color: lighten($o-brand-odoo, 15%);
+
+}
+
+.o_MessageList_separatorLabel {
+ background-color: white;
+}
+
+// ------------------------------------------------------------------
+// Animation
+// ------------------------------------------------------------------
+
+.o_MessageList_separatorNewMessages:not(.o-disable-animation) {
+ &.fade-leave-active {
+ transition: opacity 0.5s;
+ }
+
+ &.fade-leave-to {
+ opacity: 0;
+ }
+}
diff --git a/addons/mail/static/src/components/message_list/message_list.xml b/addons/mail/static/src/components/message_list/message_list.xml
new file mode 100644
index 00000000000..4951bcea951
--- /dev/null
+++ b/addons/mail/static/src/components/message_list/message_list.xml
@@ -0,0 +1,107 @@
+<?xml version="1.0" encoding="UTF-8"?>
+<templates xml:space="preserve">
+
+ <t t-name="mail.MessageList" owl="1">
+ <div class="o_MessageList" t-att-class="{ 'o-empty': threadViewer and threadViewer.messages.length === 0, 'o-has-message-selection': props.selectedMessageLocalId }" t-on-scroll="_onScroll">
+ <t t-if="threadViewer">
+ <!-- No result messages -->
+ <t t-if="threadViewer.messages.length === 0">
+ <div class="o_MessageList_empty o_MessageList_item">
+ <t t-if="threadViewer.thread === env.messaging.inbox">
+ <div class="o_MessageList_emptyTitle">
+ Congratulations, your inbox is empty
+ </div>
+ New messages appear here.
+ </t>
+ <t t-elif="threadViewer.thread === env.messaging.starred">
+ <div class="o_MessageList_emptyTitle">
+ No starred messages
+ </div>
+ You can mark any message as 'starred', and it shows up in this mailbox.
+ </t>
+ <t t-elif="threadViewer.thread === env.messaging.history">
+ <div class="o_MessageList_emptyTitle o-neutral-face-icon">
+ No history messages
+ </div>
+ Messages marked as read will appear in the history.
+ </t>
+ <t t-elif="threadViewer.thread === env.messaging.moderation">
+ <div class="o_MessageList_emptyTitle">
+ You have no messages to moderate.
+ </div>
+ Messages pending moderation appear here.
+ </t>
+ <t t-else="">
+ There are no messages in this conversation.
+ </t>
+ </div>
+ </t>
+ <!-- LOADING (if order asc)-->
+ <t t-if="props.order === 'asc' and threadViewer.messages.length > 0">
+ <t t-call="mail.MessageList.loadMore"/>
+ </t>
+ <!-- MESSAGES -->
+ <t t-set="current_day" t-value="0"/>
+ <t t-set="prev_message" t-value="0"/>
+ <t t-if="threadViewer.thread.model === 'mail.channel' and threadViewer.thread.message_unread_counter > 0 and !threadViewer.thread.seen_message_id">
+ <div class="o_MessageList_separator o_MessageList_separatorNewMessages o_MessageList_item" t-att-class="{ 'o-disable-animation': env.disableAnimation }" t-transition="fade">
+ <hr class="o_MessageList_separatorLine o_MessageList_separatorLineNewMessages"/><span class="o_MessageList_separatorLabel o_MessageList_separatorLabelNewMessages">New messages</span>
+ </div>
+ </t>
+ <t t-foreach="orderedMessages" t-as="message" t-key="message.localId">
+ <t t-set="message_day" t-value="getDateDay(message)"/>
+ <t t-if="current_day !== message_day">
+ <div class="o_MessageList_separator o_MessageList_separatorDate o_MessageList_item">
+ <hr class="o_MessageList_separatorLine"/><span class="o_MessageList_separatorLabel o_MessageList_separatorLabelDate"><t t-esc="message_day"/></span><hr class="o_MessageList_separatorLine"/>
+ <t t-set="current_day" t-value="message_day"/>
+ <t t-set="isMessageSquashed" t-value="false"/>
+ </div>
+ </t>
+ <t t-else="">
+ <t t-set="isMessageSquashed" t-value="shouldMessageBeSquashed(prev_message, message)"/>
+ </t>
+ <Message
+ class="o_MessageList_item o_MessageList_message"
+ t-att-class="{
+ 'o-has-message-selection': props.selectedMessageLocalId,
+ }"
+ hasAuthorRedirect="props.haveMessagesAuthorRedirect"
+ hasMarkAsReadIcon="props.haveMessagesMarkAsReadIcon"
+ hasCheckbox="props.hasMessageCheckbox"
+ hasReplyIcon="props.haveMessagesReplyIcon"
+ isSelected="props.selectedMessageLocalId === message.localId"
+ isSquashed="isMessageSquashed"
+ messageLocalId="message.localId"
+ threadViewerLocalId="threadViewer.localId"
+ t-ref="{{ message.localId }}"
+ />
+ <t t-set="prev_message" t-value="message"/>
+ <t t-if="threadViewer.thread.model === 'mail.channel' and threadViewer.thread.message_unread_counter > 0 and threadViewer.thread.seen_message_id === message.id">
+ <div class="o_MessageList_separator o_MessageList_separatorNewMessages o_MessageList_item" t-att-class="{ 'o-disable-animation': env.disableAnimation }" t-transition="fade">
+ <hr class="o_MessageList_separatorLine o_MessageList_separatorLineNewMessages"/><span class="o_MessageList_separatorLabel o_MessageList_separatorLabelNewMessages">New messages</span>
+ </div>
+ </t>
+ </t>
+ <!-- LOADING (if order desc)-->
+ <t t-if="props.order === 'desc' and threadViewer.messages.length > 0">
+ <t t-call="mail.MessageList.loadMore"/>
+ </t>
+ </t>
+ </div>
+ </t>
+
+ <t t-name="mail.MessageList.loadMore" owl="1">
+ <t t-if="threadViewer.threadCache.isLoadingMore">
+ <div class="o_MessageList_item o_MessageList_isLoadingMore">
+ <i class="o_MessageList_isLoadingMoreIcon fa fa-spin fa-spinner"/>
+ Loading...
+ </div>
+ </t>
+ <t t-elif="!threadViewer.threadCache.isAllHistoryLoaded">
+ <a class="o_MessageList_item o_MessageList_loadMore" href="#" t-on-click="_onClickLoadMore" t-ref="loadMore">
+ Load more
+ </a>
+ </t>
+ </t>
+
+</templates>
diff --git a/addons/mail/static/src/components/message_seen_indicator/message_seen_indicator.js b/addons/mail/static/src/components/message_seen_indicator/message_seen_indicator.js
new file mode 100644
index 00000000000..8c5239c9e2d
--- /dev/null
+++ b/addons/mail/static/src/components/message_seen_indicator/message_seen_indicator.js
@@ -0,0 +1,130 @@
+odoo.define('mail/static/src/components/message_seen_indicator/message_seen_indicator.js', function (require) {
+'use strict';
+
+const useStore = require('mail/static/src/component_hooks/use_store/use_store.js');
+
+const { Component } = owl;
+
+class MessageSeenIndicator extends Component {
+
+ /**
+ * @override
+ */
+ constructor(...args) {
+ super(...args);
+ useStore(props => {
+ const message = this.env.models['mail.message'].get(props.messageLocalId);
+ const thread = this.env.models['mail.thread'].get(props.threadLocalId);
+ const messageSeenIndicator = this.env.models['mail.message_seen_indicator'].find(
+ messageSeenIndicator =>
+ messageSeenIndicator.message === message &&
+ messageSeenIndicator.thread === thread
+ );
+ return {
+ messageSeenIndicator: messageSeenIndicator ? messageSeenIndicator.__state : undefined,
+ };
+ });
+ }
+
+ //--------------------------------------------------------------------------
+ // Public
+ //--------------------------------------------------------------------------
+
+ /**
+ * @returns {string}
+ */
+ get indicatorTitle() {
+ if (!this.messageSeenIndicator) {
+ return '';
+ }
+ if (this.messageSeenIndicator.hasEveryoneSeen) {
+ return this.env._t("Seen by Everyone");
+ }
+ if (this.messageSeenIndicator.hasSomeoneSeen) {
+ const partnersThatHaveSeen = this.messageSeenIndicator.partnersThatHaveSeen.map(
+ partner => partner.name
+ );
+ if (partnersThatHaveSeen.length === 1) {
+ return _.str.sprintf(
+ this.env._t("Seen by %s"),
+ partnersThatHaveSeen[0]
+ );
+ }
+ if (partnersThatHaveSeen.length === 2) {
+ return _.str.sprintf(
+ this.env._t("Seen by %s and %s"),
+ partnersThatHaveSeen[0],
+ partnersThatHaveSeen[1]
+ );
+ }
+ return _.str.sprintf(
+ this.env._t("Seen by %s, %s and more"),
+ partnersThatHaveSeen[0],
+ partnersThatHaveSeen[1]
+ );
+ }
+ if (this.messageSeenIndicator.hasEveryoneFetched) {
+ return this.env._t("Received by Everyone");
+ }
+ if (this.messageSeenIndicator.hasSomeoneFetched) {
+ const partnersThatHaveFetched = this.messageSeenIndicator.partnersThatHaveFetched.map(
+ partner => partner.name
+ );
+ if (partnersThatHaveFetched.length === 1) {
+ return _.str.sprintf(
+ this.env._t("Received by %s"),
+ partnersThatHaveFetched[0]
+ );
+ }
+ if (partnersThatHaveFetched.length === 2) {
+ return _.str.sprintf(
+ this.env._t("Received by %s and %s"),
+ partnersThatHaveFetched[0],
+ partnersThatHaveFetched[1]
+ );
+ }
+ return _.str.sprintf(
+ this.env._t("Received by %s, %s and more"),
+ partnersThatHaveFetched[0],
+ partnersThatHaveFetched[1]
+ );
+ }
+ return '';
+ }
+
+ /**
+ * @returns {mail.message}
+ */
+ get message() {
+ return this.env.models['mail.message'].get(this.props.messageLocalId);
+ }
+
+ /**
+ * @returns {mail.message_seen_indicator}
+ */
+ get messageSeenIndicator() {
+ return this.env.models['mail.message_seen_indicator'].find(messageSeenIndicator =>
+ messageSeenIndicator.message === this.message &&
+ messageSeenIndicator.thread === this.thread
+ );
+ }
+
+ /**
+ * @returns {mail.Thread}
+ */
+ get thread() {
+ return this.env.models['mail.thread'].get(this.props.threadLocalId);
+ }
+}
+
+Object.assign(MessageSeenIndicator, {
+ props: {
+ messageLocalId: String,
+ threadLocalId: String,
+ },
+ template: 'mail.MessageSeenIndicator',
+});
+
+return MessageSeenIndicator;
+
+});
diff --git a/addons/mail/static/src/components/message_seen_indicator/message_seen_indicator.scss b/addons/mail/static/src/components/message_seen_indicator/message_seen_indicator.scss
new file mode 100644
index 00000000000..3a9d566e399
--- /dev/null
+++ b/addons/mail/static/src/components/message_seen_indicator/message_seen_indicator.scss
@@ -0,0 +1,39 @@
+// ------------------------------------------------------------------
+// Layout
+// ------------------------------------------------------------------
+
+.o_MessageSeenIndicator {
+ display: flex;
+ position: relative;
+ flex-wrap: nowrap;
+}
+
+.o_MessageSeenIndicator_icon {
+
+ &.o-first {
+ padding-left: map-get($spacers, 1);
+ }
+
+ &.o-second {
+ position: absolute;
+ top: -1px;
+ left: -1px;
+ }
+}
+
+// ------------------------------------------------------------------
+// Style
+// ------------------------------------------------------------------
+
+.o_MessageSeenIndicator {
+ opacity: 0.6;
+
+ &.o-all-seen {
+ color: $o-brand-odoo;
+ }
+
+ &:hover {
+ cursor: pointer;
+ opacity: 0.8;
+ }
+}
diff --git a/addons/mail/static/src/components/message_seen_indicator/message_seen_indicator.xml b/addons/mail/static/src/components/message_seen_indicator/message_seen_indicator.xml
new file mode 100644
index 00000000000..e905afaa392
--- /dev/null
+++ b/addons/mail/static/src/components/message_seen_indicator/message_seen_indicator.xml
@@ -0,0 +1,16 @@
+<?xml version="1.0" encoding="UTF-8"?>
+<templates xml:space="preserve">
+
+ <t t-name="mail.MessageSeenIndicator" owl="1">
+ <span class="o_MessageSeenIndicator" t-att-class="{ 'o-all-seen': messageSeenIndicator and messageSeenIndicator.hasEveryoneSeen }" t-att-title="indicatorTitle">
+ <t t-if="messageSeenIndicator and !messageSeenIndicator.isMessagePreviousToLastCurrentPartnerMessageSeenByEveryone">
+ <t t-if="messageSeenIndicator.hasSomeoneFetched or messageSeenIndicator.hasSomeoneSeen">
+ <i class="o_MessageSeenIndicator_icon o-first fa fa-check"/>
+ </t>
+ <t t-if="messageSeenIndicator.hasSomeoneSeen">
+ <i class="o_MessageSeenIndicator_icon o-second fa fa-check"/>
+ </t>
+ </t>
+ </span>
+ </t>
+</templates>
diff --git a/addons/mail/static/src/components/message_seen_indicator/message_seen_indicator_tests.js b/addons/mail/static/src/components/message_seen_indicator/message_seen_indicator_tests.js
new file mode 100644
index 00000000000..9184dc0c692
--- /dev/null
+++ b/addons/mail/static/src/components/message_seen_indicator/message_seen_indicator_tests.js
@@ -0,0 +1,293 @@
+odoo.define('mail/static/src/components/message_seen_indicator/message_seen_indicator_tests', function (require) {
+'use strict';
+
+const components = {
+ MessageSendIndicator: require('mail/static/src/components/message_seen_indicator/message_seen_indicator.js'),
+};
+const {
+ afterEach: utilsAfterEach,
+ beforeEach: utilsBeforeEach,
+ start: utilsStart,
+} = require('mail/static/src/utils/test_utils.js');
+
+QUnit.module('mail', {}, function () {
+QUnit.module('components', {}, function () {
+QUnit.module('message_seen_indicator', {}, function () {
+QUnit.module('message_seen_indicator_tests.js', {
+ beforeEach() {
+ utilsBeforeEach(this);
+
+ this.createMessageSeenIndicatorComponent = async ({ message, thread }, otherProps) => {
+ const MessageSeenIndicatorComponent = components.MessageSendIndicator;
+ MessageSeenIndicatorComponent.env = this.env;
+ this.component = new MessageSeenIndicatorComponent(null, Object.assign({
+ messageLocalId: message.localId,
+ threadLocalId: thread.localId,
+ }, otherProps));
+ await this.component.mount(this.widget.el);
+ };
+
+ 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);
+ if (this.component) {
+ this.component.destroy();
+ }
+ if (this.widget) {
+ this.widget.destroy();
+ }
+ this.env = undefined;
+ delete components.MessageSendIndicator.env;
+ },
+});
+
+QUnit.test('rendering when just one has received the message', async function (assert) {
+ assert.expect(3);
+
+ await this.start();
+ const thread = this.env.models['mail.thread'].create({
+ id: 1000,
+ model: 'mail.channel',
+ partnerSeenInfos: [['create', [
+ {
+ partner: [['create', {id: 10}]],
+ lastFetchedMessage: [['insert', {id: 100}]]
+ },
+ {
+ partner: [['create', {id: 100}]],
+ },
+ ]]],
+ messageSeenIndicators: [['insert', {
+ id: this.env.models['mail.message_seen_indicator'].computeId(100, 1000),
+ message: [['insert', {id: 100}]],
+ }]],
+ });
+ const message = this.env.models['mail.message'].create({
+ author: [['insert', { id: this.env.session.partner_id, display_name: "Demo User" }]],
+ body: "<p>Test</p>",
+ id: 100,
+ threadCaches: [['link', thread.mainCache]],
+ });
+ await this.createMessageSeenIndicatorComponent({message, thread});
+ assert.containsOnce(
+ document.body,
+ '.o_MessageSeenIndicator',
+ "should display a message seen indicator component"
+ );
+ assert.doesNotHaveClass(
+ document.querySelector('.o_MessageSeenIndicator'),
+ 'o-all-seen',
+ "indicator component should not be considered as all seen"
+ );
+ assert.containsOnce(
+ document.body,
+ '.o_MessageSeenIndicator_icon',
+ "should display only one seen indicator icon"
+ );
+});
+
+QUnit.test('rendering when everyone have received the message', async function (assert) {
+ assert.expect(3);
+
+ await this.start();
+ const thread = this.env.models['mail.thread'].create({
+ id: 1000,
+ model: 'mail.channel',
+ partnerSeenInfos: [['create', [
+ {
+ partner: [['create', {id: 10}]],
+ lastFetchedMessage: [['insert', {id: 100}]],
+ },
+ {
+ partner: [['create', {id: 100}]],
+ lastFetchedMessage: [['insert', {id: 99}]],
+ },
+ ]]],
+ messageSeenIndicators: [['insert', {
+ id: this.env.models['mail.message_seen_indicator'].computeId(100, 1000),
+ message: [['insert', {id: 100}]],
+ }]],
+ });
+ const message = this.env.models['mail.message'].create({
+ author: [['insert', { id: this.env.session.partner_id, display_name: "Demo User" }]],
+ body: "<p>Test</p>",
+ id: 100,
+ threadCaches: [['link', thread.mainCache]],
+ });
+ await this.createMessageSeenIndicatorComponent({message, thread});
+ assert.containsOnce(
+ document.body,
+ '.o_MessageSeenIndicator',
+ "should display a message seen indicator component"
+ );
+ assert.doesNotHaveClass(
+ document.querySelector('.o_MessageSeenIndicator'),
+ 'o-all-seen',
+ "indicator component should not be considered as all seen"
+ );
+ assert.containsOnce(
+ document.body,
+ '.o_MessageSeenIndicator_icon',
+ "should display only one seen indicator icon"
+ );
+});
+
+QUnit.test('rendering when just one has seen the message', async function (assert) {
+ assert.expect(3);
+
+ await this.start();
+ const thread = this.env.models['mail.thread'].create({
+ id: 1000,
+ model: 'mail.channel',
+ partnerSeenInfos: [['create', [
+ {
+ partner: [['create', {id: 10}]],
+ lastFetchedMessage: [['insert', {id: 100}]],
+ lastSeenMessage: [['insert', {id: 100}]],
+ },
+ {
+ partner: [['create', {id: 100}]],
+ lastFetchedMessage: [['insert', {id: 99}]],
+ },
+ ]]],
+ messageSeenIndicators: [['insert', {
+ id: this.env.models['mail.message_seen_indicator'].computeId(100, 1000),
+ message: [['insert', {id: 100}]],
+ }]],
+ });
+ const message = this.env.models['mail.message'].create({
+ author: [['insert', { id: this.env.session.partner_id, display_name: "Demo User" }]],
+ body: "<p>Test</p>",
+ id: 100,
+ threadCaches: [['link', thread.mainCache]],
+ });
+ await this.createMessageSeenIndicatorComponent({message, thread});
+ assert.containsOnce(
+ document.body,
+ '.o_MessageSeenIndicator',
+ "should display a message seen indicator component"
+ );
+ assert.doesNotHaveClass(
+ document.querySelector('.o_MessageSeenIndicator'),
+ 'o-all-seen',
+ "indicator component should not be considered as all seen"
+ );
+ assert.containsN(
+ document.body,
+ '.o_MessageSeenIndicator_icon',
+ 2,
+ "should display two seen indicator icon"
+ );
+});
+
+QUnit.test('rendering when just one has seen & received the message', async function (assert) {
+ assert.expect(3);
+
+ await this.start();
+ const thread = this.env.models['mail.thread'].create({
+ id: 1000,
+ model: 'mail.channel',
+ partnerSeenInfos: [['create', [
+ {
+ partner: [['create', {id: 10}]],
+ lastFetchedMessage: [['insert', {id: 100}]],
+ lastSeenMessage: [['insert', {id: 100}]],
+ },
+ {
+ partner: [['create', {id: 100}]],
+ },
+ ]]],
+ messageSeenIndicators: [['insert', {
+ id: this.env.models['mail.message_seen_indicator'].computeId(100, 1000),
+ message: [['insert', {id: 100}]],
+ }]],
+ });
+ const message = this.env.models['mail.message'].create({
+ author: [['insert', { id: this.env.session.partner_id, display_name: "Demo User" }]],
+ body: "<p>Test</p>",
+ id: 100,
+ threadCaches: [['link', thread.mainCache]],
+ });
+ await this.createMessageSeenIndicatorComponent({message, thread});
+ assert.containsOnce(
+ document.body,
+ '.o_MessageSeenIndicator',
+ "should display a message seen indicator component"
+ );
+ assert.doesNotHaveClass(
+ document.querySelector('.o_MessageSeenIndicator'),
+ 'o-all-seen',
+ "indicator component should not be considered as all seen"
+ );
+ assert.containsN(
+ document.body,
+ '.o_MessageSeenIndicator_icon',
+ 2,
+ "should display two seen indicator icon"
+ );
+});
+
+QUnit.test('rendering when just everyone has seen the message', async function (assert) {
+ assert.expect(3);
+
+ await this.start();
+ const thread = this.env.models['mail.thread'].create({
+ id: 1000,
+ model: 'mail.channel',
+ partnerSeenInfos: [['create', [
+ {
+ partner: [['create', {id: 10}]],
+ lastFetchedMessage: [['insert', {id: 100}]],
+ lastSeenMessage: [['insert', {id: 100}]],
+ },
+ {
+ partner: [['create', {id: 100}]],
+ lastFetchedMessage: [['insert', {id: 100}]],
+ lastSeenMessage: [['insert', {id: 100}]],
+ },
+ ]]],
+ messageSeenIndicators: [['insert', {
+ id: this.env.models['mail.message_seen_indicator'].computeId(100, 1000),
+ message: [['insert', {id: 100}]],
+ }]],
+ });
+ const message = this.env.models['mail.message'].create({
+ author: [['insert', { id: this.env.session.partner_id, display_name: "Demo User" }]],
+ body: "<p>Test</p>",
+ id: 100,
+ threadCaches: [['link', thread.mainCache]],
+ });
+ await this.createMessageSeenIndicatorComponent({message, thread});
+ assert.containsOnce(
+ document.body,
+ '.o_MessageSeenIndicator',
+ "should display a message seen indicator component"
+ );
+ assert.hasClass(
+ document.querySelector('.o_MessageSeenIndicator'),
+ 'o-all-seen',
+ "indicator component should not considered as all seen"
+ );
+ assert.containsN(
+ document.body,
+ '.o_MessageSeenIndicator_icon',
+ 2,
+ "should display two seen indicator icon"
+ );
+});
+
+});
+});
+});
+
+});
diff --git a/addons/mail/static/src/components/messaging_menu/messaging_menu.js b/addons/mail/static/src/components/messaging_menu/messaging_menu.js
new file mode 100644
index 00000000000..864c7df97e1
--- /dev/null
+++ b/addons/mail/static/src/components/messaging_menu/messaging_menu.js
@@ -0,0 +1,266 @@
+odoo.define('mail/static/src/components/messaging_menu/messaging_menu.js', function (require) {
+'use strict';
+
+const components = {
+ AutocompleteInput: require('mail/static/src/components/autocomplete_input/autocomplete_input.js'),
+ MobileMessagingNavbar: require('mail/static/src/components/mobile_messaging_navbar/mobile_messaging_navbar.js'),
+ NotificationList: require('mail/static/src/components/notification_list/notification_list.js'),
+};
+const useStore = require('mail/static/src/component_hooks/use_store/use_store.js');
+
+const { Component } = owl;
+const { useRef } = owl.hooks;
+
+class MessagingMenu extends Component {
+
+ /**
+ * @override
+ */
+ constructor(...args) {
+ super(...args);
+ /**
+ * global JS generated ID for this component. Useful to provide a
+ * custom class to autocomplete input, so that click in an autocomplete
+ * item is not considered as a click away from messaging menu in mobile.
+ */
+ this.id = _.uniqueId('o_messagingMenu_');
+ useStore((...args) => this._useStoreSelector(...args));
+
+ /**
+ * Reference of the new message input in mobile. Useful to include it
+ * and autocomplete menu as "inside" the messaging menu, to prevent
+ * closing the messaging menu otherwise.
+ */
+ this._mobileNewMessageInputRef = useRef('mobileNewMessageInput');
+
+ // bind since passed as props
+ this._onMobileNewMessageInputSelect = this._onMobileNewMessageInputSelect.bind(this);
+ this._onMobileNewMessageInputSource = this._onMobileNewMessageInputSource.bind(this);
+
+ this._onClickCaptureGlobal = this._onClickCaptureGlobal.bind(this);
+ }
+
+ mounted() {
+ document.addEventListener('click', this._onClickCaptureGlobal, true);
+ }
+
+ willUnmount() {
+ document.removeEventListener('click', this._onClickCaptureGlobal, true);
+ }
+
+ //--------------------------------------------------------------------------
+ // Public
+ //--------------------------------------------------------------------------
+
+ /**
+ * @returns {mail.discuss}
+ */
+ get discuss() {
+ return this.env.messaging && this.env.messaging.discuss;
+ }
+
+ /**
+ * @returns {mail.messaging_menu}
+ */
+ get messagingMenu() {
+ return this.env.messaging.messagingMenu;
+ }
+
+ /**
+ * @returns {string}
+ */
+ get mobileNewMessageInputPlaceholder() {
+ return this.env._t("Search user...");
+ }
+
+ /**
+ * @returns {Object[]}
+ */
+ get tabs() {
+ return [{
+ icon: 'fa fa-envelope',
+ id: 'all',
+ label: this.env._t("All"),
+ }, {
+ icon: 'fa fa-user',
+ id: 'chat',
+ label: this.env._t("Chat"),
+ }, {
+ icon: 'fa fa-users',
+ id: 'channel',
+ label: this.env._t("Channel"),
+ }];
+ }
+
+ //--------------------------------------------------------------------------
+ // Private
+ //--------------------------------------------------------------------------
+
+ /**
+ * @private
+ */
+ _useStoreSelector(props) {
+ return {
+ messagingMenu: this.env.messaging.messagingMenu.__state,
+ isDeviceMobile: this.env.messaging.device.isMobile,
+ isDiscussOpen: this.env.messaging.discuss.isOpen,
+ isMessagingInitialized: this.env.messaging.isInitialized,
+ };
+ }
+
+ //--------------------------------------------------------------------------
+ // Handlers
+ //--------------------------------------------------------------------------
+
+ /**
+ * @private
+ * @param {MouseEvent} ev
+ */
+ _onClickCaptureGlobal(ev) {
+ // in mobile: keeps the messaging menu open in background
+ // TODO: maybe need to move this to a mobile component?
+ // task-2089887
+ if (
+ this.env.messaging.device.isMobile &&
+ this.env.messaging.chatWindowManager.hasVisibleChatWindows
+ ) {
+ return;
+ }
+ // closes the menu when clicking outside
+ if (this.el.contains(ev.target)) {
+ return;
+ }
+ const input = this._mobileNewMessageInputRef.comp;
+ if (input && input.contains(ev.target)) {
+ return;
+ }
+ this.messagingMenu.close();
+ }
+
+ /**
+ * @private
+ * @param {MouseEvent} ev
+ */
+ _onClickDesktopTabButton(ev) {
+ ev.stopPropagation();
+ this.messagingMenu.update({ activeTabId: ev.currentTarget.dataset.tabId });
+ }
+
+ /**
+ * @private
+ * @param {MouseEvent} ev
+ */
+ _onClickNewMessage(ev) {
+ ev.stopPropagation();
+ if (!this.env.messaging.device.isMobile) {
+ this.env.models['mail.thread'].openNewMessage();
+ this.messagingMenu.close();
+ } else {
+ this.messagingMenu.toggleMobileNewMessage();
+ }
+ }
+
+ /**
+ * @private
+ * @param {MouseEvent} ev
+ */
+ _onClickToggler(ev) {
+ ev.stopPropagation();
+ ev.preventDefault();
+ this.messagingMenu.toggleOpen();
+ }
+
+ /**
+ * @private
+ * @param {CustomEvent} ev
+ */
+ _onHideMobileNewMessage(ev) {
+ ev.stopPropagation();
+ this.messagingMenu.toggleMobileNewMessage();
+ }
+
+ /**
+ * @private
+ * @param {Event} ev
+ * @param {Object} ui
+ * @param {Object} ui.item
+ * @param {integer} ui.item.id
+ */
+ _onMobileNewMessageInputSelect(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) {
+ chat.open();
+ } else {
+ this.env.models['mail.thread'].createChannel({
+ autoselect: true,
+ partnerId,
+ type: 'chat',
+ });
+ }
+ if (!this.env.messaging.device.isMobile) {
+ this.messagingMenu.close();
+ }
+ }
+
+ /**
+ * @private
+ * @param {Object} req
+ * @param {string} req.term
+ * @param {function} res
+ */
+ _onMobileNewMessageInputSource(req, res) {
+ const value = _.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,
+ });
+ }
+
+ /**
+ * @private
+ * @param {CustomEvent} ev
+ * @param {Object} ev.detail
+ * @param {string} ev.detail.tabId
+ */
+ _onSelectMobileNavbarTab(ev) {
+ ev.stopPropagation();
+ this.messagingMenu.update({ activeTabId: ev.detail.tabId });
+ }
+
+ /**
+ * @private
+ * @param {CustomEvent} ev
+ * @param {Object} ev.detail
+ * @param {mail.thread} ev.detail.thread
+ */
+ _onSelectThread(ev) {
+ ev.stopPropagation();
+ this.env.models['mail.thread'].get(ev.detail.thread).open();
+ if (!this.env.messaging.device.isMobile) {
+ this.messagingMenu.close();
+ }
+ }
+
+}
+
+Object.assign(MessagingMenu, {
+ components,
+ props: {},
+ template: 'mail.MessagingMenu',
+});
+
+return MessagingMenu;
+
+});
diff --git a/addons/mail/static/src/components/messaging_menu/messaging_menu.scss b/addons/mail/static/src/components/messaging_menu/messaging_menu.scss
new file mode 100644
index 00000000000..776d6af5371
--- /dev/null
+++ b/addons/mail/static/src/components/messaging_menu/messaging_menu.scss
@@ -0,0 +1,133 @@
+// ------------------------------------------------------------------
+// Layout
+// ------------------------------------------------------------------
+
+.o_MessagingMenu_counter {
+ position: relative;
+ transform: translate(-5px, -5px);
+ margin-right: -10px; // "cancel" right padding of systray items
+}
+
+.o_MessagingMenu_dropdownMenu {
+ display: flex;
+ flex-flow: column;
+ padding-top: 0;
+ padding-bottom: 0;
+ overflow-y: auto;
+
+ &.o-messaging-not-initialized {
+ align-items: center;
+ justify-content: center;
+ }
+
+ &:not(.o-mobile) {
+ flex: 0 1 auto;
+ width: 350px;
+ min-height: 50px;
+ max-height: 400px;
+ z-index: 1100; // on top of chat windows
+ }
+
+ &.o-mobile {
+ flex: 1 1 auto;
+ position: fixed;
+ top: $o-mail-chat-window-header-height-mobile;
+ bottom: 0;
+ left: 0;
+ right: 0;
+ width: 100%;
+ margin: 0;
+ max-height: none;
+ }
+}
+
+.o_MessagingMenu_dropdownMenuHeader {
+
+ &:not(.o-mobile) {
+ display: flex;
+ }
+
+ &.o-mobile {
+ display: grid;
+ grid-template-areas:
+ "top"
+ "bottom";
+ grid-template-rows: auto auto;
+ padding: 5px
+ }
+}
+
+.o_MessagingMenu_dropdownLoadingIcon {
+ margin-right: 3px;
+}
+
+.o_MessagingMenu_icon {
+ font-size: larger
+}
+
+.o_MessagingMenu_loading {
+ font-size: small;
+ position: absolute;
+ bottom: 50%;
+ right: 0;
+}
+
+.o_MessagingMenu_newMessageButton.o-mobile {
+ grid-area: top;
+ justify-self: start;
+}
+
+.o_MessagingMenu_mobileNewMessageInput {
+ grid-area: bottom;
+ padding: 8px;
+ margin-top: 10px
+}
+
+.o_MessagingMenu_notificationList.o-mobile {
+ flex: 1 1 auto;
+ overflow-y: auto;
+}
+
+
+// ------------------------------------------------------------------
+// Style
+// ------------------------------------------------------------------
+
+// Make hightlight more consistent, due to messaging menu looking quite similar to discuss app in mobile
+.o_MessagingMenu.show {
+ background-color: rgba(black, 0.1);
+}
+
+.o_MessagingMenu_counter {
+ background-color: $o-brand-primary;
+}
+
+.o_MessagingMenu_dropdownMenu.o-mobile {
+ border: 0;
+}
+
+.o_MessagingMenu_dropdownMenuHeader {
+ border-bottom: 1px solid gray('400');
+ z-index: 1;
+}
+
+.o_MessagingMenu_mobileNewMessageInput {
+ appearance: none;
+ border: 1px solid gray('400');
+ border-radius: 5px;
+ outline: none;
+}
+
+.o_MessagingMenu_tabButton.o-desktop {
+
+ &.o-active {
+ font-weight: bold;
+ }
+
+ &:not(:hover) {
+
+ &:not(.o-active) {
+ color: gray('500');
+ }
+ }
+}
diff --git a/addons/mail/static/src/components/messaging_menu/messaging_menu.xml b/addons/mail/static/src/components/messaging_menu/messaging_menu.xml
new file mode 100644
index 00000000000..77c33e5ce7e
--- /dev/null
+++ b/addons/mail/static/src/components/messaging_menu/messaging_menu.xml
@@ -0,0 +1,89 @@
+<?xml version="1.0" encoding="UTF-8"?>
+<templates xml:space="preserve">
+
+ <t t-name="mail.MessagingMenu" owl="1">
+ <li class="o_MessagingMenu" t-att-class="{ show: messagingMenu.isOpen, 'o-mobile': env.messaging.device.isMobile }">
+ <!-- TODO FIXME restore mobile stuff here (activeMobileThread) -->
+ <t t-if="!env.messaging.device.isMobile or true">
+ <a class="o_MessagingMenu_toggler o-no-caret" t-att-class="{ show: messagingMenu.isOpen }" href="#" t-on-click="_onClickToggler" data-toggle="dropdown" data-display="static" title="Conversations" role="button" aria-expanded="false">
+ <i class="o_MessagingMenu_icon fa fa-comments-o" role="img" aria-label="Messages"/>
+ <t t-if="!env.messaging.isInitialized">
+ <i class="o_MessagingMenu_loading fa fa-spinner fa-spin"/>
+ </t>
+ <t t-elif="messagingMenu.counter > 0">
+ <span class="o_MessagingMenu_counter badge badge-pill">
+ <t t-esc="messagingMenu.counter"/>
+ </span>
+ </t>
+ </a>
+ </t>
+ <!-- TODO FIXME restore mobile stuff here (activeMobileThread) -->
+ <t t-if="messagingMenu.isOpen and (!env.messaging.device.isMobile or true)">
+ <div class="o_MessagingMenu_dropdownMenu dropdown-menu dropdown-menu-right" t-att-class="{ 'o-mobile': env.messaging.device.isMobile, 'o-messaging-not-initialized': !env.messaging.isInitialized }" role="menu">
+ <t t-if="!env.messaging.isInitialized">
+ <span><i class="o_MessagingMenu_dropdownLoadingIcon fa fa-spinner fa-spin"/>Please wait...</span>
+ </t>
+ <t t-else="">
+ <div class="o_MessagingMenu_dropdownMenuHeader" t-att-class="{ 'o-mobile': env.messaging.device.isMobile }">
+ <t t-if="!env.messaging.device.isMobile">
+ <t t-foreach="['all', 'chat', 'channel']" t-as="tabId" t-key="tabId">
+ <button class="o_MessagingMenu_tabButton o-desktop btn btn-link" t-att-class="{ 'o-active': messagingMenu.activeTabId === tabId, }" t-on-click="_onClickDesktopTabButton" type="button" role="tab" t-att-data-tab-id="tabId">
+ <t t-if="tabId === 'all'">All</t>
+ <t t-elif="tabId === 'chat'">Chat</t>
+ <t t-elif="tabId === 'channel'">Channels</t>
+ </button>
+ </t>
+ </t>
+ <t t-if="env.messaging.device.isMobile">
+ <t t-call="mail.MessagingMenu.newMessageButton"/>
+ </t>
+ <div class="o-autogrow"/>
+ <t t-if="!env.messaging.device.isMobile and !discuss.isOpen">
+ <t t-call="mail.MessagingMenu.newMessageButton"/>
+ </t>
+ <t t-if="env.messaging.device.isMobile and messagingMenu.isMobileNewMessageToggled">
+ <AutocompleteInput
+ class="o_MessagingMenu_mobileNewMessageInput"
+ customClass="id + '_mobileNewMessageInputAutocomplete'"
+ isFocusOnMount="true"
+ placeholder="mobileNewMessageInputPlaceholder"
+ select="_onMobileNewMessageInputSelect"
+ source="_onMobileNewMessageInputSource"
+ t-on-o-hide="_onHideMobileNewMessage"
+ t-ref="mobileNewMessageInput"
+ />
+ </t>
+ </div>
+ <NotificationList
+ class="o_MessagingMenu_notificationList"
+ t-att-class="{ 'o-mobile': env.messaging.device.isMobile }"
+ filter="messagingMenu.activeTabId"
+ t-on-o-select-thread="_onSelectThread"
+ />
+ <t t-if="env.messaging.device.isMobile">
+ <MobileMessagingNavbar
+ class="o_MessagingMenu_mobileNavbar"
+ activeTabId="messagingMenu.activeTabId"
+ tabs="tabs"
+ t-on-o-select-mobile-messaging-navbar-tab="_onSelectMobileNavbarTab"
+ />
+ </t>
+ </t>
+ </div>
+ </t>
+ </li>
+ </t>
+
+ <t t-name="mail.MessagingMenu.newMessageButton" owl="1">
+ <button class="o_MessagingMenu_newMessageButton btn"
+ t-att-class="{
+ 'btn-link': !env.messaging.device.isMobile,
+ 'btn-secondary': env.messaging.device.isMobile,
+ 'o-mobile': env.messaging.device.isMobile,
+ }" t-on-click="_onClickNewMessage" type="button"
+ >
+ New message
+ </button>
+ </t>
+
+</templates> \ No newline at end of file
diff --git a/addons/mail/static/src/components/messaging_menu/messaging_menu_tests.js b/addons/mail/static/src/components/messaging_menu/messaging_menu_tests.js
new file mode 100644
index 00000000000..60159360899
--- /dev/null
+++ b/addons/mail/static/src/components/messaging_menu/messaging_menu_tests.js
@@ -0,0 +1,849 @@
+odoo.define('mail/static/src/components/messaging_menu/messaging_menu_tests.js', function (require) {
+'use strict';
+
+const {
+ afterEach: utilsAfterEach,
+ afterNextRender,
+ beforeEach: utilsBeforeEach,
+ start: utilsStart,
+} = require('mail/static/src/utils/test_utils.js');
+
+const { makeTestPromise } = require('web.test_utils');
+
+QUnit.module('mail', {}, function () {
+QUnit.module('components', {}, function () {
+QUnit.module('messaging_menu', {}, function () {
+QUnit.module('messaging_menu_tests.js', {
+ beforeEach() {
+ utilsBeforeEach(this);
+
+ this.start = async params => {
+ if (this.widget) {
+ this.widget.destroy();
+ }
+ let { discussWidget, env, widget } = await utilsStart(Object.assign({}, params, {
+ data: this.data,
+ hasMessagingMenu: true,
+ }));
+ this.discussWidget = discussWidget;
+ this.env = env;
+ this.widget = widget;
+ };
+ },
+ afterEach() {
+ utilsAfterEach(this);
+ if (this.widget) {
+ this.widget.destroy();
+ }
+ },
+});
+
+QUnit.test('messaging not initialized', async function (assert) {
+ assert.expect(2);
+
+ await this.start({
+ async mockRPC(route) {
+ if (route === '/mail/init_messaging') {
+ // simulate messaging never initialized
+ return new Promise(resolve => {});
+ }
+ return this._super(...arguments);
+ },
+ waitUntilMessagingInitialized: false,
+ });
+ assert.strictEqual(
+ document.querySelectorAll('.o_MessagingMenu_loading').length,
+ 1,
+ "should display loading icon on messaging menu when messaging not yet initialized"
+ );
+
+ await afterNextRender(() => document.querySelector(`.o_MessagingMenu_toggler`).click());
+ assert.strictEqual(
+ document.querySelector('.o_MessagingMenu_dropdownMenu').textContent,
+ "Please wait...",
+ "should prompt loading when opening messaging menu"
+ );
+});
+
+QUnit.test('messaging becomes initialized', async function (assert) {
+ assert.expect(2);
+
+ const messagingInitializedProm = makeTestPromise();
+
+ await this.start({
+ async mockRPC(route) {
+ const _super = this._super.bind(this, ...arguments); // limitation of class.js
+ if (route === '/mail/init_messaging') {
+ await messagingInitializedProm;
+ }
+ return _super();
+ },
+ waitUntilMessagingInitialized: false,
+ });
+ await afterNextRender(() => document.querySelector(`.o_MessagingMenu_toggler`).click());
+
+ // simulate messaging becomes initialized
+ await afterNextRender(() => messagingInitializedProm.resolve());
+ assert.strictEqual(
+ document.querySelectorAll('.o_MessagingMenu_loading').length,
+ 0,
+ "should no longer display loading icon on messaging menu when messaging becomes initialized"
+ );
+ assert.notOk(
+ document.querySelector('.o_MessagingMenu_dropdownMenu').textContent.includes("Please wait..."),
+ "should no longer prompt loading when opening messaging menu when messaging becomes initialized"
+ );
+});
+
+QUnit.test('basic rendering', async function (assert) {
+ assert.expect(21);
+
+ await this.start({
+ async mockRPC(route, args) {
+ if (args.method === 'channel_fetch_preview') {
+ return [];
+ }
+ return this._super(...arguments);
+ },
+ });
+ assert.strictEqual(
+ document.querySelectorAll('.o_MessagingMenu').length,
+ 1,
+ "should have messaging menu"
+ );
+ assert.notOk(
+ document.querySelector('.o_MessagingMenu').classList.contains('show'),
+ "should not mark messaging menu item as shown by default"
+ );
+ assert.strictEqual(
+ document.querySelectorAll(`.o_MessagingMenu_toggler`).length,
+ 1,
+ "should have clickable element on messaging menu"
+ );
+ assert.notOk(
+ document.querySelector(`.o_MessagingMenu_toggler`).classList.contains('show'),
+ "should not mark messaging menu clickable item as shown by default"
+ );
+ assert.strictEqual(
+ document.querySelectorAll(`.o_MessagingMenu_icon`).length,
+ 1,
+ "should have icon on clickable element in messaging menu"
+ );
+ assert.ok(
+ document.querySelector(`.o_MessagingMenu_icon`).classList.contains('fa-comments-o'),
+ "should have 'comments' icon on clickable element in messaging menu"
+ );
+ assert.strictEqual(
+ document.querySelectorAll(`.o_MessagingMenu_dropdownMenu`).length,
+ 0,
+ "should not display any messaging menu dropdown by default"
+ );
+
+ await afterNextRender(() => document.querySelector(`.o_MessagingMenu_toggler`).click());
+ assert.ok(
+ document.querySelector('.o_MessagingMenu').classList.contains('show'),
+ "should mark messaging menu item as shown"
+ );
+ assert.ok(
+ document.querySelector(`.o_MessagingMenu_toggler`).classList.contains('show'),
+ "should mark messaging menu clickable item as shown"
+ );
+ assert.strictEqual(
+ document.querySelectorAll(`.o_MessagingMenu_dropdownMenu`).length,
+ 1,
+ "should display messaging menu dropdown after click"
+ );
+ assert.strictEqual(
+ document.querySelectorAll(`.o_MessagingMenu_dropdownMenuHeader`).length,
+ 1,
+ "should have dropdown menu header"
+ );
+ assert.strictEqual(
+ document.querySelectorAll(`
+ .o_MessagingMenu_dropdownMenuHeader
+ .o_MessagingMenu_tabButton
+ `).length,
+ 3,
+ "should have 3 tab buttons to filter items in the header"
+ );
+ assert.strictEqual(
+ document.querySelectorAll(`.o_MessagingMenu_tabButton[data-tab-id="all"]`).length,
+ 1,
+ "1 tab button should be 'All'"
+ );
+ assert.strictEqual(
+ document.querySelectorAll(`.o_MessagingMenu_tabButton[data-tab-id="chat"]`).length,
+ 1,
+ "1 tab button should be 'Chat'"
+ );
+ assert.strictEqual(
+ document.querySelectorAll(`.o_MessagingMenu_tabButton[data-tab-id="channel"]`).length,
+ 1,
+ "1 tab button should be 'Channels'"
+ );
+ assert.ok(
+ document.querySelector(`
+ .o_MessagingMenu_tabButton[data-tab-id="all"]
+ `).classList.contains('o-active'),
+ "'all' tab button should be active"
+ );
+ assert.notOk(
+ document.querySelector(`
+ .o_MessagingMenu_tabButton[data-tab-id="chat"]
+ `).classList.contains('o-active'),
+ "'chat' tab button should not be active"
+ );
+ assert.notOk(
+ document.querySelector(`
+ .o_MessagingMenu_tabButton[data-tab-id="channel"]
+ `).classList.contains('o-active'),
+ "'channel' tab button should not be active"
+ );
+ assert.strictEqual(
+ document.querySelectorAll(`.o_MessagingMenu_newMessageButton`).length,
+ 1,
+ "should have button to make a new message"
+ );
+ assert.strictEqual(
+ document.querySelectorAll(`
+ .o_MessagingMenu_dropdownMenu
+ .o_NotificationList
+ `).length,
+ 1,
+ "should display thread preview list"
+ );
+ assert.strictEqual(
+ document.querySelectorAll(`
+ .o_MessagingMenu_dropdownMenu
+ .o_NotificationList_noConversation
+ `).length,
+ 1,
+ "should display no conversation in thread preview list"
+ );
+});
+
+QUnit.test('counter is taking into account failure notification', async function (assert) {
+ assert.expect(2);
+
+ this.data.initMessaging.mail_failures = [{
+ date: moment.utc().format("YYYY-MM-DD HH:mm:ss"),
+ id: 11,
+ message_type: 'email',
+ model: 'mail.channel',
+ notifications: [{
+ failure_type: 'SMTP',
+ id: 21,
+ notification_status: 'exception',
+ notification_type: 'email',
+ partner_id: [41, "Someone"],
+ }],
+ res_id: 31,
+ res_model_name: "Channel",
+ }];
+ await this.start();
+
+ assert.containsOnce(
+ document.body,
+ '.o_MessagingMenu_counter',
+ "should display a notification counter next to the messaging menu for one notification"
+ );
+ assert.strictEqual(
+ document.querySelector('.o_MessagingMenu_counter').textContent,
+ "1",
+ "should display a counter of '1' next to the messaging menu"
+ );
+});
+
+QUnit.test('switch tab', async function (assert) {
+ assert.expect(15);
+
+ await this.start({
+ async mockRPC(route, args) {
+ if (args.method === 'channel_fetch_preview') {
+ return [];
+ }
+ return this._super(...arguments);
+ },
+ });
+
+ await afterNextRender(() => document.querySelector(`.o_MessagingMenu_toggler`).click());
+ assert.strictEqual(
+ document.querySelectorAll(`.o_MessagingMenu_tabButton[data-tab-id="all"]`).length,
+ 1,
+ "1 tab button should be 'All'"
+ );
+ assert.strictEqual(
+ document.querySelectorAll(`.o_MessagingMenu_tabButton[data-tab-id="chat"]`).length,
+ 1,
+ "1 tab button should be 'Chat'"
+ );
+ assert.strictEqual(
+ document.querySelectorAll(`.o_MessagingMenu_tabButton[data-tab-id="channel"]`).length,
+ 1,
+ "1 tab button should be 'Channels'"
+ );
+ assert.ok(
+ document.querySelector(`
+ .o_MessagingMenu_tabButton[data-tab-id="all"]
+ `).classList.contains('o-active'),
+ "'all' tab button should be active"
+ );
+ assert.notOk(
+ document.querySelector(`
+ .o_MessagingMenu_tabButton[data-tab-id="chat"]
+ `).classList.contains('o-active'),
+ "'chat' tab button should not be active"
+ );
+ assert.notOk(
+ document.querySelector(`
+ .o_MessagingMenu_tabButton[data-tab-id="channel"]
+ `).classList.contains('o-active'),
+ "'channel' tab button should not be active"
+ );
+
+ await afterNextRender(() =>
+ document.querySelector(`.o_MessagingMenu_tabButton[data-tab-id="chat"]`).click()
+ );
+ assert.notOk(
+ document.querySelector(`
+ .o_MessagingMenu_tabButton[data-tab-id="all"]
+ `).classList.contains('o-active'),
+ "'all' tab button should become inactive"
+ );
+ assert.ok(
+ document.querySelector(`
+ .o_MessagingMenu_tabButton[data-tab-id="chat"]
+ `).classList.contains('o-active'),
+ "'chat' tab button should not become active"
+ );
+ assert.notOk(
+ document.querySelector(`
+ .o_MessagingMenu_tabButton[data-tab-id="channel"]
+ `).classList.contains('o-active'),
+ "'channel' tab button should stay inactive"
+ );
+
+ await afterNextRender(() =>
+ document.querySelector(`.o_MessagingMenu_tabButton[data-tab-id="channel"]`).click()
+ );
+ assert.notOk(
+ document.querySelector(`
+ .o_MessagingMenu_tabButton[data-tab-id="all"]
+ `).classList.contains('o-active'),
+ "'all' tab button should stay active"
+ );
+ assert.notOk(
+ document.querySelector(`
+ .o_MessagingMenu_tabButton[data-tab-id="chat"]
+ `).classList.contains('o-active'),
+ "'chat' tab button should become inactive"
+ );
+ assert.ok(
+ document.querySelector(`
+ .o_MessagingMenu_tabButton[data-tab-id="channel"]
+ `).classList.contains('o-active'),
+ "'channel' tab button should become active"
+ );
+
+ await afterNextRender(() =>
+ document.querySelector(`.o_MessagingMenu_tabButton[data-tab-id="all"]`).click()
+ );
+ assert.ok(
+ document.querySelector(`
+ .o_MessagingMenu_tabButton[data-tab-id="all"]
+ `).classList.contains('o-active'),
+ "'all' tab button should become active"
+ );
+ assert.notOk(
+ document.querySelector(`
+ .o_MessagingMenu_tabButton[data-tab-id="chat"]
+ `).classList.contains('o-active'),
+ "'chat' tab button should stay inactive"
+ );
+ assert.notOk(
+ document.querySelector(`
+ .o_MessagingMenu_tabButton[data-tab-id="channel"]
+ `).classList.contains('o-active'),
+ "'channel' tab button should become inactive"
+ );
+});
+
+QUnit.test('new message', async function (assert) {
+ assert.expect(3);
+
+ await this.start({
+ hasChatWindow: true,
+ async mockRPC(route, args) {
+ if (args.method === 'channel_fetch_preview') {
+ return [];
+ }
+ return this._super(...arguments);
+ },
+ });
+
+ await afterNextRender(() =>
+ document.querySelector(`.o_MessagingMenu_toggler`).click()
+ );
+ await afterNextRender(() =>
+ document.querySelector(`.o_MessagingMenu_newMessageButton`).click()
+ );
+
+ assert.strictEqual(
+ document.querySelectorAll(`.o_ChatWindow`).length,
+ 1,
+ "should have open a chat window"
+ );
+ assert.ok(
+ document.querySelector(`.o_ChatWindow`).classList.contains('o-new-message'),
+ "chat window should be for new message"
+ );
+ assert.ok(
+ document.querySelector(`.o_ChatWindow`).classList.contains('o-focused'),
+ "chat window should be focused"
+ );
+});
+
+QUnit.test('no new message when discuss is open', async function (assert) {
+ assert.expect(3);
+
+ await this.start({
+ autoOpenDiscuss: true,
+ hasDiscuss: true,
+ async mockRPC(route, args) {
+ if (args.method === 'channel_fetch_preview') {
+ return [];
+ }
+ return this._super(...arguments);
+ },
+ });
+
+ await afterNextRender(() =>
+ document.querySelector(`.o_MessagingMenu_toggler`).click()
+ );
+ assert.strictEqual(
+ document.querySelectorAll(`.o_MessagingMenu_newMessageButton`).length,
+ 0,
+ "should not have 'new message' when discuss is open"
+ );
+
+ // simulate closing discuss app
+ await afterNextRender(() => this.discussWidget.on_detach_callback());
+ assert.strictEqual(
+ document.querySelectorAll(`.o_MessagingMenu_newMessageButton`).length,
+ 1,
+ "should have 'new message' when discuss is closed"
+ );
+
+ // simulate opening discuss app
+ await afterNextRender(() => this.discussWidget.on_attach_callback());
+ assert.strictEqual(
+ document.querySelectorAll(`.o_MessagingMenu_newMessageButton`).length,
+ 0,
+ "should not have 'new message' when discuss is open again"
+ );
+});
+
+QUnit.test('channel preview: basic rendering', async function (assert) {
+ assert.expect(9);
+
+ Object.assign(this.data.initMessaging, {
+ channel_slots: {
+ channel_channel: [{
+ channel_type: "channel",
+ id: 20,
+ name: "General",
+ }],
+ },
+ });
+ await this.start({
+ async mockRPC(route, args) {
+ if (args.method === 'channel_fetch_preview') {
+ return [{
+ id: 20,
+ last_message: {
+ author_id: [7, "Demo"],
+ body: "<p>test</p>",
+ channel_ids: [20],
+ id: 100,
+ message_type: 'comment',
+ model: 'mail.channel',
+ record_name: "General",
+ res_id: 20,
+ },
+ }];
+ }
+ return this._super(...arguments);
+ },
+ });
+
+ await afterNextRender(() => document.querySelector(`.o_MessagingMenu_toggler`).click());
+ assert.strictEqual(
+ document.querySelectorAll(`
+ .o_MessagingMenu_dropdownMenu .o_ThreadPreview
+ `).length,
+ 1,
+ "should have one preview"
+ );
+ assert.strictEqual(
+ document.querySelectorAll(`
+ .o_MessagingMenu_dropdownMenu
+ .o_ThreadPreview_sidebar
+ `).length,
+ 1,
+ "preview should have a sidebar"
+ );
+ assert.strictEqual(
+ document.querySelectorAll(`
+ .o_MessagingMenu_dropdownMenu
+ .o_ThreadPreview_content
+ `).length,
+ 1,
+ "preview should have some content"
+ );
+ assert.strictEqual(
+ document.querySelectorAll(`
+ .o_MessagingMenu_dropdownMenu
+ .o_ThreadPreview_header
+ `).length,
+ 1,
+ "preview should have header in content"
+ );
+ assert.strictEqual(
+ document.querySelectorAll(`
+ .o_MessagingMenu_dropdownMenu
+ .o_ThreadPreview_header
+ .o_ThreadPreview_name
+ `).length,
+ 1,
+ "preview should have name in header of content"
+ );
+ assert.strictEqual(
+ document.querySelector(`
+ .o_MessagingMenu_dropdownMenu
+ .o_ThreadPreview_name
+ `).textContent,
+ "General", "preview should have name of channel"
+ );
+ assert.strictEqual(
+ document.querySelectorAll(`
+ .o_MessagingMenu_dropdownMenu
+ .o_ThreadPreview_content
+ .o_ThreadPreview_core
+ `).length,
+ 1,
+ "preview should have core in content"
+ );
+ assert.strictEqual(
+ document.querySelectorAll(`
+ .o_MessagingMenu_dropdownMenu
+ .o_ThreadPreview_core
+ .o_ThreadPreview_inlineText
+ `).length,
+ 1,
+ "preview should have inline text in core of content"
+ );
+ assert.strictEqual(
+ document.querySelector(`
+ .o_MessagingMenu_dropdownMenu
+ .o_ThreadPreview_core
+ .o_ThreadPreview_inlineText
+ `).textContent.trim(),
+ "Demo: test",
+ "preview should have message content as inline text of core content"
+ );
+});
+
+QUnit.test('filtered previews', async function (assert) {
+ assert.expect(12);
+
+ Object.assign(this.data.initMessaging, {
+ channel_slots: {
+ channel_channel: [{
+ channel_type: "channel",
+ id: 20,
+ name: "General",
+ }],
+ channel_direct_message: [{
+ channel_type: "chat",
+ direct_partner: [{
+ id: 7,
+ name: "Demo",
+ }],
+ id: 10,
+ }],
+ },
+ });
+ await this.start({
+ async mockRPC(route, args) {
+ if (args.method === 'channel_fetch_preview') {
+ return [{
+ id: 20,
+ last_message: {
+ author_id: [7, "Demo"],
+ body: "<p>test</p>",
+ channel_ids: [20],
+ id: 100,
+ message_type: 'comment',
+ model: 'mail.channel',
+ res_id: 20,
+ },
+ }, {
+ id: 10,
+ last_message: {
+ author_id: [7, "Demo"],
+ body: "<p>test2</p>",
+ channel_ids: [10],
+ id: 101,
+ message_type: 'comment',
+ model: 'mail.channel',
+ res_id: 10,
+ },
+ }];
+ }
+ return this._super(...arguments);
+ },
+ });
+
+ await afterNextRender(() =>
+ document.querySelector(`.o_MessagingMenu_toggler`).click()
+ );
+ assert.strictEqual(
+ document.querySelectorAll(`.o_MessagingMenu_dropdownMenu .o_ThreadPreview`).length,
+ 2,
+ "should have 2 previews"
+ );
+ assert.strictEqual(
+ document.querySelectorAll(`
+ .o_MessagingMenu_dropdownMenu
+ .o_ThreadPreview[data-thread-local-id="${
+ this.env.models['mail.thread'].find(thread =>
+ thread.id === 10 &&
+ thread.model === 'mail.channel'
+ ).localId
+ }"]
+ `).length,
+ 1,
+ "should have preview of chat"
+ );
+ assert.strictEqual(
+ document.querySelectorAll(`
+ .o_MessagingMenu_dropdownMenu
+ .o_ThreadPreview[data-thread-local-id="${
+ this.env.models['mail.thread'].find(thread =>
+ thread.id === 20 &&
+ thread.model === 'mail.channel'
+ ).localId
+ }"]
+ `).length,
+ 1,
+ "should have preview of channel"
+ );
+
+ await afterNextRender(() =>
+ document.querySelector('.o_MessagingMenu_tabButton[data-tab-id="chat"]').click()
+ );
+ assert.strictEqual(
+ document.querySelectorAll(`.o_MessagingMenu_dropdownMenu .o_ThreadPreview`).length,
+ 1,
+ "should have one preview"
+ );
+ assert.strictEqual(
+ document.querySelectorAll(`
+ .o_MessagingMenu_dropdownMenu
+ .o_ThreadPreview[data-thread-local-id="${
+ this.env.models['mail.thread'].find(thread =>
+ thread.id === 10 &&
+ thread.model === 'mail.channel'
+ ).localId
+ }"]
+ `).length,
+ 1,
+ "should have preview of chat"
+ );
+ assert.strictEqual(
+ document.querySelectorAll(`
+ .o_MessagingMenu_dropdownMenu
+ .o_ThreadPreview[data-thread-local-id="${
+ this.env.models['mail.thread'].find(thread =>
+ thread.id === 20 &&
+ thread.model === 'mail.channel'
+ ).localId
+ }"]
+ `).length,
+ 0,
+ "should not have preview of channel"
+ );
+
+ await afterNextRender(() =>
+ document.querySelector('.o_MessagingMenu_tabButton[data-tab-id="channel"]').click()
+ );
+ assert.strictEqual(
+ document.querySelectorAll(`
+ .o_MessagingMenu_dropdownMenu
+ .o_ThreadPreview
+ `).length,
+ 1,
+ "should have one preview"
+ );
+ assert.strictEqual(
+ document.querySelectorAll(`
+ .o_MessagingMenu_dropdownMenu
+ .o_ThreadPreview[data-thread-local-id="${
+ this.env.models['mail.thread'].find(thread =>
+ thread.id === 10 &&
+ thread.model === 'mail.channel'
+ ).localId
+ }"]
+ `).length,
+ 0,
+ "should not have preview of chat"
+ );
+ assert.strictEqual(
+ document.querySelectorAll(`
+ .o_MessagingMenu_dropdownMenu
+ .o_ThreadPreview[data-thread-local-id="${
+ this.env.models['mail.thread'].find(thread =>
+ thread.id === 20 &&
+ thread.model === 'mail.channel'
+ ).localId
+ }"]
+ `).length,
+ 1,
+ "should have preview of channel"
+ );
+
+ await afterNextRender(() =>
+ document.querySelector('.o_MessagingMenu_tabButton[data-tab-id="all"]').click()
+ );
+ assert.strictEqual(
+ document.querySelectorAll(`.o_MessagingMenu_dropdownMenu .o_ThreadPreview`).length,
+ 2,
+ "should have 2 previews"
+ );
+ assert.strictEqual(
+ document.querySelectorAll(`
+ .o_MessagingMenu_dropdownMenu
+ .o_ThreadPreview[data-thread-local-id="${
+ this.env.models['mail.thread'].find(thread =>
+ thread.id === 10 &&
+ thread.model === 'mail.channel'
+ ).localId
+ }"]
+ `).length,
+ 1,
+ "should have preview of chat"
+ );
+ assert.strictEqual(
+ document.querySelectorAll(`
+ .o_MessagingMenu_dropdownMenu
+ .o_ThreadPreview[data-thread-local-id="${
+ this.env.models['mail.thread'].find(thread =>
+ thread.id === 20 &&
+ thread.model === 'mail.channel'
+ ).localId
+ }"]
+ `).length,
+ 1,
+ "should have preview of channel"
+ );
+});
+
+QUnit.test('open chat window from preview', async function (assert) {
+ assert.expect(1);
+
+ Object.assign(this.data.initMessaging, {
+ channel_slots: {
+ channel_channel: [{
+ channel_type: "channel",
+ id: 20,
+ name: "General",
+ }],
+ },
+ });
+ await this.start({
+ hasChatWindow: true,
+ async mockRPC(route, args) {
+ if (args.method === 'channel_fetch_preview') {
+ return [];
+ }
+ return this._super(...arguments);
+ },
+ });
+
+ await afterNextRender(() =>
+ document.querySelector(`.o_MessagingMenu_toggler`).click()
+ );
+ await afterNextRender(() =>
+ document.querySelector(`.o_MessagingMenu_dropdownMenu .o_ThreadPreview`).click()
+ );
+ assert.strictEqual(
+ document.querySelectorAll(`.o_ChatWindow`).length,
+ 1,
+ "should have open a chat window"
+ );
+});
+
+QUnit.test('no code injection in message body preview', async function (assert) {
+ assert.expect(5);
+
+ Object.assign(this.data.initMessaging, {
+ channel_slots: {
+ channel_channel: [{
+ channel_type: "channel",
+ id: 1,
+ name: "General",
+ }],
+ },
+ });
+
+ this.data['mail.channel'].records = [{
+ id: 1,
+ name: "general",
+ channel_type: "channel",
+ channel_message_ids: [1],
+ }];
+ this.data['mail.message'].records = [{
+ id: 1,
+ author_id: [1, 'Georges'],
+ body: "<p><em>&shoulnotberaised</em><script>throw new Error('CodeInjectionError');</script></p>",
+ channel_ids: [1],
+ }];
+
+ await this.start();
+
+ await afterNextRender(() => {
+ document.querySelector(`.o_MessagingMenu_toggler`).click();
+ });
+ assert.containsOnce(
+ document.body,
+ '.o_MessagingMenu_dropdownMenu .o_ThreadPreview',
+ "should display a preview",
+ );
+ assert.containsOnce(
+ document.body,
+ '.o_ThreadPreview_core',
+ "preview should have core in content",
+ );
+ assert.containsOnce(
+ document.body,
+ '.o_ThreadPreview_inlineText',
+ "preview should have inline text in core of content",
+ );
+ assert.strictEqual(
+ document.querySelector('.o_ThreadPreview_inlineText')
+ .textContent.replace(/\s/g, ""),
+ "Georges:&shoulnotberaisedthrownewError('CodeInjectionError');",
+ "should display correct uninjected last message inline content"
+ );
+ assert.containsNone(
+ document.querySelector('.o_ThreadPreview_inlineText'),
+ 'script',
+ "last message inline content should not have any code injection"
+ );
+});
+
+});
+});
+});
+
+});
diff --git a/addons/mail/static/src/components/mobile_messaging_navbar/mobile_messaging_navbar.js b/addons/mail/static/src/components/mobile_messaging_navbar/mobile_messaging_navbar.js
new file mode 100644
index 00000000000..923480b3f73
--- /dev/null
+++ b/addons/mail/static/src/components/mobile_messaging_navbar/mobile_messaging_navbar.js
@@ -0,0 +1,50 @@
+odoo.define('mail/static/src/components/mobile_messaging_navbar/mobile_messaging_navbar.js', function (require) {
+'use strict';
+
+const { Component } = owl;
+
+class MobileMessagingNavbar extends Component {
+
+ //--------------------------------------------------------------------------
+ // Handlers
+ //--------------------------------------------------------------------------
+
+ /**
+ * @private
+ * @param {MouseEvent} ev
+ */
+ _onClick(ev) {
+ this.trigger('o-select-mobile-messaging-navbar-tab', {
+ tabId: ev.currentTarget.dataset.tabId,
+ });
+ }
+
+}
+
+Object.assign(MobileMessagingNavbar, {
+ defaultProps: {
+ tabs: [],
+ },
+ props: {
+ activeTabId: String,
+ tabs: {
+ type: Array,
+ element: {
+ type: Object,
+ shape: {
+ icon: {
+ type: String,
+ optional: true,
+ },
+ id: String,
+ label: String,
+ },
+ },
+ },
+ },
+ template: 'mail.MobileMessagingNavbar',
+});
+
+return MobileMessagingNavbar;
+
+});
diff --git a/addons/mail/static/src/components/mobile_messaging_navbar/mobile_messaging_navbar.scss b/addons/mail/static/src/components/mobile_messaging_navbar/mobile_messaging_navbar.scss
new file mode 100644
index 00000000000..df0611f9115
--- /dev/null
+++ b/addons/mail/static/src/components/mobile_messaging_navbar/mobile_messaging_navbar.scss
@@ -0,0 +1,43 @@
+// ------------------------------------------------------------------
+// Layout
+// ------------------------------------------------------------------
+
+.o_MobileMessagingNavbar {
+ display: flex;
+ flex: 0 0 auto;
+ z-index: 1;
+}
+
+.o_MobileMessagingNavbar_tab {
+ display: flex;
+ flex-flow: column;
+ align-items: center;
+ flex: 1 1 0;
+ padding: 8px;
+}
+
+.o_MobileMessagingNavbar_tabIcon {
+ margin-bottom: 4%;
+ font-size: 1.3em;
+}
+
+.o_MobileMessagingNavbar_tabLabel {
+ font-size: 0.8em;
+}
+
+// ------------------------------------------------------------------
+// Style
+// ------------------------------------------------------------------
+
+.o_MobileMessagingNavbar {
+ background-color: white;
+ box-shadow: 0 0 8px gray('400');
+}
+
+.o_MobileMessagingNavbar_tab {
+ box-shadow: 1px 0 0 gray('400');
+
+ &.o-active {
+ color: $o-brand-primary;
+ }
+}
diff --git a/addons/mail/static/src/components/mobile_messaging_navbar/mobile_messaging_navbar.xml b/addons/mail/static/src/components/mobile_messaging_navbar/mobile_messaging_navbar.xml
new file mode 100644
index 00000000000..d60611bf786
--- /dev/null
+++ b/addons/mail/static/src/components/mobile_messaging_navbar/mobile_messaging_navbar.xml
@@ -0,0 +1,17 @@
+<?xml version="1.0" encoding="UTF-8"?>
+<templates xml:space="preserve">
+
+ <t t-name="mail.MobileMessagingNavbar" owl="1">
+ <div class="o_MobileMessagingNavbar">
+ <t t-foreach="props.tabs" t-as="tab" t-key="tab.id">
+ <div class="o_MobileMessagingNavbar_tab" t-att-class="{ 'o-active': props.activeTabId === tab.id }" t-on-click="_onClick" t-att-data-tab-id="tab.id">
+ <t t-if="tab.icon">
+ <span class="o_MobileMessagingNavbar_tabIcon" t-att-class="tab.icon"/>
+ </t>
+ <span class="o_MobileMessagingNavbar_tabLabel"><t t-esc="tab.label"/></span>
+ </div>
+ </t>
+ </div>
+ </t>
+
+</templates>
diff --git a/addons/mail/static/src/components/moderation_ban_dialog/moderation_ban_dialog.js b/addons/mail/static/src/components/moderation_ban_dialog/moderation_ban_dialog.js
new file mode 100644
index 00000000000..ec260829cf4
--- /dev/null
+++ b/addons/mail/static/src/components/moderation_ban_dialog/moderation_ban_dialog.js
@@ -0,0 +1,79 @@
+odoo.define('mail/static/src/components/moderation_ban_dialog/moderation_ban_dialog.js', function (require) {
+'use strict';
+
+const useStore = require('mail/static/src/component_hooks/use_store/use_store.js');
+
+const Dialog = require('web.OwlDialog');
+
+const { Component } = owl;
+const { useRef } = owl.hooks;
+
+class ModerationBanDialog extends Component {
+
+ /**
+ * @override
+ */
+ constructor(...args) {
+ super(...args);
+ useStore(props => {
+ const messages = props.messageLocalIds.map(localId =>
+ this.env.models['mail.message'].get(localId)
+ );
+ return {
+ messages: messages.map(message => message ? message.__state : undefined),
+ };
+ }, {
+ compareDepth: {
+ messages: 1,
+ },
+ });
+ // to manually trigger the dialog close event
+ this._dialogRef = useRef('dialog');
+ }
+
+ //--------------------------------------------------------------------------
+ // Public
+ //--------------------------------------------------------------------------
+
+ /**
+ * @returns {mail.message[]}
+ */
+ get messages() {
+ return this.props.messageLocalIds.map(localId => this.env.models['mail.message'].get(localId));
+ }
+
+ //--------------------------------------------------------------------------
+ // Handlers
+ //--------------------------------------------------------------------------
+
+ /**
+ * @private
+ */
+ _onClickBan() {
+ this._dialogRef.comp._close();
+ this.env.models['mail.message'].moderate(this.messages, 'ban');
+ }
+
+ /**
+ * @private
+ */
+ _onClickCancel() {
+ this._dialogRef.comp._close();
+ }
+
+}
+
+Object.assign(ModerationBanDialog, {
+ components: { Dialog },
+ props: {
+ messageLocalIds: {
+ type: Array,
+ element: String,
+ },
+ },
+ template: 'mail.ModerationBanDialog',
+});
+
+return ModerationBanDialog;
+
+});
diff --git a/addons/mail/static/src/components/moderation_ban_dialog/moderation_ban_dialog.xml b/addons/mail/static/src/components/moderation_ban_dialog/moderation_ban_dialog.xml
new file mode 100644
index 00000000000..d0bc59c19a6
--- /dev/null
+++ b/addons/mail/static/src/components/moderation_ban_dialog/moderation_ban_dialog.xml
@@ -0,0 +1,26 @@
+<?xml version="1.0" encoding="UTF-8"?>
+<templates xml:space="preserve">
+ <t t-name="mail.ModerationBanDialog" owl="1">
+ <div><!-- TODO FIXME necessary div for useRef, can be removed at next owl update, see https://github.com/odoo/owl/issues/638 -->
+ <t t-set="title">Confirmation</t>
+ <Dialog contentClass="'o_ModerationBanDialog'" title="title" size="'medium'" t-ref="dialog">
+ <t t-if="messages.length === 1">
+ <p>You are going to ban the following user:</p>
+ </t>
+ <t t-else="">
+ <p>You are going to ban the following users:</p>
+ </t>
+ <ul class="my-5">
+ <t t-foreach="messages" t-as="message" t-key="message.localId">
+ <li t-esc="message.email_from"/>
+ </t>
+ </ul>
+ <p>Do you confirm the action?</p>
+ <t t-set-slot="buttons">
+ <button class="o-ban btn btn-primary" t-on-click="_onClickBan">Ban</button>
+ <button class="o-cancel btn btn-secondary" t-on-click="_onClickCancel">Cancel</button>
+ </t>
+ </Dialog>
+ </div>
+ </t>
+</templates>
diff --git a/addons/mail/static/src/components/moderation_discard_dialog/moderation_discard_dialog.js b/addons/mail/static/src/components/moderation_discard_dialog/moderation_discard_dialog.js
new file mode 100644
index 00000000000..597d2ac3262
--- /dev/null
+++ b/addons/mail/static/src/components/moderation_discard_dialog/moderation_discard_dialog.js
@@ -0,0 +1,94 @@
+odoo.define('mail/static/src/components/moderation_discard_dialog/moderation_discard_dialog.js', function (require) {
+'use strict';
+
+const useStore = require('mail/static/src/component_hooks/use_store/use_store.js');
+
+const Dialog = require('web.OwlDialog');
+
+const { Component } = owl;
+const { useRef } = owl.hooks;
+
+class ModerationDiscardDialog extends Component {
+
+ /**
+ * @override
+ */
+ constructor(...args) {
+ super(...args);
+ useStore(props => {
+ const messages = props.messageLocalIds.map(localId =>
+ this.env.models['mail.message'].get(localId)
+ );
+ return {
+ messages: messages.map(message => message ? message.__state : undefined),
+ };
+ }, {
+ compareDepth: {
+ messages: 1,
+ },
+ });
+ // to manually trigger the dialog close event
+ this._dialogRef = useRef('dialog');
+ }
+
+ //--------------------------------------------------------------------------
+ // Public
+ //--------------------------------------------------------------------------
+
+ /**
+ * @returns {string}
+ */
+ getText() {
+ if (this.messages.length === 1) {
+ return this.env._t("You are going to discard 1 message.");
+ }
+ return _.str.sprintf(
+ this.env._t("You are going to discard %s messages."),
+ this.messages.length
+ );
+ }
+
+ /**
+ * @returns {mail.message[]}
+ */
+ get messages() {
+ return this.props.messageLocalIds.map(localId =>
+ this.env.models['mail.message'].get(localId)
+ );
+ }
+
+ //--------------------------------------------------------------------------
+ // Handlers
+ //--------------------------------------------------------------------------
+
+ /**
+ * @private
+ */
+ _onClickCancel() {
+ this._dialogRef.comp._close();
+ }
+
+ /**
+ * @private
+ */
+ _onClickDiscard() {
+ this._dialogRef.comp._close();
+ this.env.models['mail.message'].moderate(this.messages, 'discard');
+ }
+
+}
+
+Object.assign(ModerationDiscardDialog, {
+ components: { Dialog },
+ props: {
+ messageLocalIds: {
+ type: Array,
+ element: String,
+ },
+ },
+ template: 'mail.ModerationDiscardDialog',
+});
+
+return ModerationDiscardDialog;
+
+});
diff --git a/addons/mail/static/src/components/moderation_discard_dialog/moderation_discard_dialog.xml b/addons/mail/static/src/components/moderation_discard_dialog/moderation_discard_dialog.xml
new file mode 100644
index 00000000000..13cafb0f047
--- /dev/null
+++ b/addons/mail/static/src/components/moderation_discard_dialog/moderation_discard_dialog.xml
@@ -0,0 +1,16 @@
+<?xml version="1.0" encoding="UTF-8"?>
+<templates xml:space="preserve">
+ <t t-name="mail.ModerationDiscardDialog" owl="1">
+ <div><!-- TODO FIXME necessary div for useRef, can be removed at next owl update, see https://github.com/odoo/owl/issues/638 -->
+ <t t-set="title">Confirmation</t>
+ <Dialog contentClass="'o_ModerationDiscardDialog'" title="title" size="'medium'" t-ref="dialog">
+ <p t-esc="getText()"/>
+ <p>Do you confirm the action?</p>
+ <t t-set-slot="buttons">
+ <button class="o-discard btn btn-primary" t-on-click="_onClickDiscard">Discard</button>
+ <button class="o-cancel btn btn-secondary" t-on-click="_onClickCancel">Cancel</button>
+ </t>
+ </Dialog>
+ </div>
+ </t>
+</templates>
diff --git a/addons/mail/static/src/components/moderation_reject_dialog/moderation_reject_dialog.js b/addons/mail/static/src/components/moderation_reject_dialog/moderation_reject_dialog.js
new file mode 100644
index 00000000000..95f90e42c7d
--- /dev/null
+++ b/addons/mail/static/src/components/moderation_reject_dialog/moderation_reject_dialog.js
@@ -0,0 +1,89 @@
+odoo.define('mail/static/src/components/moderation_reject_dialog/moderation_reject_dialog.js', function (require) {
+'use strict';
+
+const useStore = require('mail/static/src/component_hooks/use_store/use_store.js');
+
+const Dialog = require('web.OwlDialog');
+
+const { Component, useState } = owl;
+const { useRef } = owl.hooks;
+
+class ModerationRejectDialog extends Component {
+
+ /**
+ * @override
+ */
+ constructor(...args) {
+ super(...args);
+ this.state = useState({
+ title: this.env._t("Message Rejected"),
+ comment: this.env._t("Your message was rejected by moderator."),
+ });
+ useStore(props => {
+ const messages = props.messageLocalIds.map(localId =>
+ this.env.models['mail.message'].get(localId)
+ );
+ return {
+ messages: messages.map(message => message ? message.__state : undefined),
+ };
+ }, {
+ compareDepth: {
+ messages: 1,
+ },
+ });
+ // to manually trigger the dialog close event
+ this._dialogRef = useRef('dialog');
+ }
+
+ //--------------------------------------------------------------------------
+ // Public
+ //--------------------------------------------------------------------------
+
+ /**
+ * @returns {mail.message[]}
+ */
+ get messages() {
+ return this.props.messageLocalIds.map(localId =>
+ this.env.models['mail.message'].get(localId)
+ );
+ }
+
+ //--------------------------------------------------------------------------
+ // Handlers
+ //--------------------------------------------------------------------------
+
+ /**
+ * @private
+ */
+ _onClickCancel() {
+ this._dialogRef.comp._close();
+ }
+
+ /**
+ * @private
+ */
+ _onClickReject() {
+ this._dialogRef.comp._close();
+ const kwargs = {
+ title: this.state.title,
+ comment: this.state.comment,
+ };
+ this.env.models['mail.message'].moderate(this.messages, 'reject', kwargs);
+ }
+
+}
+
+Object.assign(ModerationRejectDialog, {
+ components: { Dialog },
+ props: {
+ messageLocalIds: {
+ type: Array,
+ element: String,
+ },
+ },
+ template: 'mail.ModerationRejectDialog',
+});
+
+return ModerationRejectDialog;
+
+});
diff --git a/addons/mail/static/src/components/moderation_reject_dialog/moderation_reject_dialog.xml b/addons/mail/static/src/components/moderation_reject_dialog/moderation_reject_dialog.xml
new file mode 100644
index 00000000000..a908265d33b
--- /dev/null
+++ b/addons/mail/static/src/components/moderation_reject_dialog/moderation_reject_dialog.xml
@@ -0,0 +1,16 @@
+<?xml version="1.0" encoding="UTF-8"?>
+<templates xml:space="preserve">
+ <t t-name="mail.ModerationRejectDialog" owl="1">
+ <div><!-- TODO FIXME necessary div for useRef, can be removed at next owl update, see https://github.com/odoo/owl/issues/638 -->
+ <t t-set="title">Send explanation to author</t>
+ <Dialog contentClass="'o_ModerationRejectDialog'" title="title" size="'medium'" t-ref="dialog">
+ <input class="o_ModerationRejectDialog_title form-control" type="text" placeholder="Subject" autofocus="autofocus" t-model="state.title"/>
+ <textarea class="o_ModerationRejectDialog_comment form-control mt16" placeholder="Mail Body" t-model="state.comment"/>
+ <t t-set-slot="buttons">
+ <button class="o-reject btn btn-primary" t-on-click="_onClickReject">Reject</button>
+ <button class="o-cancel btn btn-secondary" t-on-click="_onClickCancel">Cancel</button>
+ </t>
+ </Dialog>
+ </div>
+ </t>
+</templates>
diff --git a/addons/mail/static/src/components/notification_group/notification_group.js b/addons/mail/static/src/components/notification_group/notification_group.js
new file mode 100644
index 00000000000..4020a610dc6
--- /dev/null
+++ b/addons/mail/static/src/components/notification_group/notification_group.js
@@ -0,0 +1,85 @@
+odoo.define('mail/static/src/components/notification_group/notification_group.js', function (require) {
+'use strict';
+
+const useStore = require('mail/static/src/component_hooks/use_store/use_store.js');
+
+const { Component } = owl;
+const { useRef } = owl.hooks;
+
+class NotificationGroup extends Component {
+
+ /**
+ * @override
+ */
+ constructor(...args) {
+ super(...args);
+ useStore(props => {
+ const group = this.env.models['mail.notification_group'].get(props.notificationGroupLocalId);
+ return {
+ group: group ? group.__state : undefined,
+ };
+ });
+ /**
+ * Reference of the "mark as read" button. Useful to disable the
+ * top-level click handler when clicking on this specific button.
+ */
+ this._markAsReadRef = useRef('markAsRead');
+ }
+
+ //--------------------------------------------------------------------------
+ // Public
+ //--------------------------------------------------------------------------
+
+ /**
+ * @returns {mail.notification_group}
+ */
+ get group() {
+ return this.env.models['mail.notification_group'].get(this.props.notificationGroupLocalId);
+ }
+
+ /**
+ * @returns {string|undefined}
+ */
+ image() {
+ if (this.group.notification_type === 'email') {
+ return '/mail/static/src/img/smiley/mailfailure.jpg';
+ }
+ }
+
+ //--------------------------------------------------------------------------
+ // Handlers
+ //--------------------------------------------------------------------------
+
+ /**
+ * @private
+ * @param {MouseEvent} ev
+ */
+ _onClick(ev) {
+ const markAsRead = this._markAsReadRef.el;
+ if (markAsRead && markAsRead.contains(ev.target)) {
+ // handled in `_onClickMarkAsRead`
+ return;
+ }
+ this.group.openDocuments();
+ }
+
+ /**
+ * @private
+ * @param {MouseEvent} ev
+ */
+ _onClickMarkAsRead(ev) {
+ this.group.openCancelAction();
+ }
+
+}
+
+Object.assign(NotificationGroup, {
+ props: {
+ notificationGroupLocalId: String,
+ },
+ template: 'mail.NotificationGroup',
+});
+
+return NotificationGroup;
+
+});
diff --git a/addons/mail/static/src/components/notification_group/notification_group.scss b/addons/mail/static/src/components/notification_group/notification_group.scss
new file mode 100644
index 00000000000..0ba937ec127
--- /dev/null
+++ b/addons/mail/static/src/components/notification_group/notification_group.scss
@@ -0,0 +1,89 @@
+// ------------------------------------------------------------------
+// Layout
+// ------------------------------------------------------------------
+
+.o_NotificationGroup {
+ @include o-mail-notification-list-item-layout();
+
+ &:hover .o_NotificationGroup_markAsRead {
+ // TODO also mixin this
+ // task-2258605
+ opacity: 1;
+ }
+}
+
+.o_NotificationGroup_content {
+ @include o-mail-notification-list-item-content-layout();
+}
+
+.o_NotificationGroup_core {
+ @include o-mail-notification-list-item-core-layout();
+}
+
+.o_NotificationGroup_coreItem {
+ @include o-mail-notification-list-item-core-item-layout();
+}
+
+.o_NotificationGroup_counter {
+ @include o-mail-notification-list-item-counter-layout();
+}
+
+.o_NotificationGroup_date {
+ @include o-mail-notification-list-item-date-layout();
+}
+
+.o_NotificationGroup_header {
+ @include o-mail-notification-list-item-header-layout();
+}
+
+.o_NotificationGroup_image {
+ @include o-mail-notification-list-item-image-layout();
+}
+
+.o_NotificationGroup_imageContainer {
+ @include o-mail-notification-list-item-image-container-layout();
+}
+
+.o_NotificationGroup_inlineText {
+ @include o-mail-notification-list-item-inline-text-layout();
+}
+
+.o_NotificationGroup_markAsRead {
+ @include o-mail-notification-list-item-mark-as-read-layout();
+}
+
+.o_NotificationGroup_name {
+ @include o-mail-notification-list-item-name-layout();
+}
+
+.o_NotificationGroup_sidebar {
+ @include o-mail-notification-list-item-sidebar-layout();
+}
+
+// ------------------------------------------------------------------
+// Style
+// ------------------------------------------------------------------
+
+.o_NotificationGroup {
+ @include o-mail-notification-list-item-style();
+}
+
+.o_NotificationGroup_core {
+ @include o-mail-notification-list-item-core-style();
+}
+
+.o_NotificationGroup_counter {
+ @include o-mail-notification-list-item-bold-style();
+}
+
+.o_NotificationGroup_date {
+ @include o-mail-notification-list-item-date-style();
+}
+
+.o_NotificationGroup_markAsRead {
+ @include o-mail-notification-list-item-mark-as-read-style();
+}
+
+.o_NotificationGroup_name {
+ @include o-mail-notification-list-item-bold-style();
+}
diff --git a/addons/mail/static/src/components/notification_group/notification_group.xml b/addons/mail/static/src/components/notification_group/notification_group.xml
new file mode 100644
index 00000000000..c2f3dcebda1
--- /dev/null
+++ b/addons/mail/static/src/components/notification_group/notification_group.xml
@@ -0,0 +1,39 @@
+<?xml version="1.0" encoding="UTF-8"?>
+<templates xml:space="preserve">
+
+ <t t-name="mail.NotificationGroup" owl="1">
+ <div class="o_NotificationGroup" t-on-click="_onClick">
+ <t t-if="group">
+ <div class="o_NotificationGroup_sidebar">
+ <div class="o_NotificationGroup_imageContainer o_NotificationGroup_sidebarItem">
+ <img class="o_NotificationGroup_image rounded-circle" t-att-src="image()" alt="Message delivery failure image"/>
+ </div>
+ </div>
+ <div class="o_NotificationGroup_content">
+ <div class="o_NotificationGroup_header">
+ <span class="o_NotificationGroup_name">
+ <t t-esc="group.res_model_name"/>
+ </span>
+ <span class="o_NotificationGroup_counter">
+ (<t t-esc="group.notifications.length"/>)
+ </span>
+ <span class="o-autogrow"/>
+ <span class="o_NotificationGroup_date">
+ <t t-esc="group.date.fromNow()"/>
+ </span>
+ </div>
+ <div class="o_NotificationGroup_core">
+ <span class="o_NotificationGroup_coreItem o_NotificationGroup_inlineText">
+ <t t-if="group.notification_type === 'email'">
+ An error occurred when sending an email.
+ </t>
+ </span>
+ <span class="o-autogrow"/>
+ <span class="o_NotificationGroup_coreItem o_NotificationGroup_markAsRead fa fa-check" title="Discard message delivery failures" t-on-click="_onClickMarkAsRead" t-ref="markAsRead"/>
+ </div>
+ </div>
+ </t>
+ </div>
+ </t>
+
+</templates>
diff --git a/addons/mail/static/src/components/notification_list/notification_list.js b/addons/mail/static/src/components/notification_list/notification_list.js
new file mode 100644
index 00000000000..5f714afc6d6
--- /dev/null
+++ b/addons/mail/static/src/components/notification_list/notification_list.js
@@ -0,0 +1,216 @@
+odoo.define('mail/static/src/components/notification_list/notification_list.js', function (require) {
+'use strict';
+
+const components = {
+ NotificationGroup: require('mail/static/src/components/notification_group/notification_group.js'),
+ ThreadNeedactionPreview: require('mail/static/src/components/thread_needaction_preview/thread_needaction_preview.js'),
+ ThreadPreview: require('mail/static/src/components/thread_preview/thread_preview.js'),
+};
+const useStore = require('mail/static/src/component_hooks/use_store/use_store.js');
+
+const { Component } = owl;
+
+class NotificationList extends Component {
+
+ /**
+ * @override
+ */
+ constructor(...args) {
+ super(...args);
+ this.storeProps = useStore((...args) => this._useStoreSelector(...args), {
+ compareDepth: {
+ // list + notification object created in useStore
+ notifications: 2,
+ },
+ });
+ }
+
+ mounted() {
+ this._loadPreviews();
+ }
+
+ //--------------------------------------------------------------------------
+ // Public
+ //--------------------------------------------------------------------------
+
+ /**
+ * @returns {Object[]}
+ */
+ get notifications() {
+ const { notifications } = this.storeProps;
+ return notifications;
+ }
+
+ //--------------------------------------------------------------------------
+ // Private
+ //--------------------------------------------------------------------------
+
+ /**
+ * Load previews of given thread. Basically consists of fetching all missing
+ * last messages of each thread.
+ *
+ * @private
+ */
+ async _loadPreviews() {
+ const threads = this.notifications
+ .filter(notification => notification.thread)
+ .map(notification => this.env.models['mail.thread'].get(notification.thread));
+ this.env.models['mail.thread'].loadPreviews(threads);
+ }
+
+ /**
+ * @private
+ * @param {Object} props
+ */
+ _useStoreSelector(props) {
+ const threads = this._useStoreSelectorThreads(props);
+ let threadNeedactionNotifications = [];
+ if (props.filter === 'all') {
+ // threads with needactions
+ threadNeedactionNotifications = this.env.models['mail.thread']
+ .all(t => t.model !== 'mail.box' && t.needactionMessages.length > 0)
+ .sort((t1, t2) => {
+ if (t1.needactionMessages.length > 0 && t2.needactionMessages.length === 0) {
+ return -1;
+ }
+ if (t1.needactionMessages.length === 0 && t2.needactionMessages.length > 0) {
+ return 1;
+ }
+ if (t1.lastNeedactionMessage && t2.lastNeedactionMessage) {
+ return t1.lastNeedactionMessage.date.isBefore(t2.lastNeedactionMessage.date) ? 1 : -1;
+ }
+ if (t1.lastNeedactionMessage) {
+ return -1;
+ }
+ if (t2.lastNeedactionMessage) {
+ return 1;
+ }
+ return t1.id < t2.id ? -1 : 1;
+ })
+ .map(thread => {
+ return {
+ thread,
+ type: 'thread_needaction',
+ uniqueId: thread.localId + '_needaction',
+ };
+ });
+ }
+ // thread notifications
+ const threadNotifications = threads
+ .sort((t1, t2) => {
+ if (t1.message_unread_counter > 0 && t2.message_unread_counter === 0) {
+ return -1;
+ }
+ if (t1.message_unread_counter === 0 && t2.message_unread_counter > 0) {
+ return 1;
+ }
+ if (t1.lastMessage && t2.lastMessage) {
+ return t1.lastMessage.date.isBefore(t2.lastMessage.date) ? 1 : -1;
+ }
+ if (t1.lastMessage) {
+ return -1;
+ }
+ if (t2.lastMessage) {
+ return 1;
+ }
+ return t1.id < t2.id ? -1 : 1;
+ })
+ .map(thread => {
+ return {
+ thread,
+ type: 'thread',
+ uniqueId: thread.localId,
+ };
+ });
+ let notifications = threadNeedactionNotifications.concat(threadNotifications);
+ if (props.filter === 'all') {
+ const notificationGroups = this.env.messaging.notificationGroupManager.groups;
+ notifications = Object.values(notificationGroups)
+ .sort((group1, group2) =>
+ group1.date.isAfter(group2.date) ? -1 : 1
+ ).map(notificationGroup => {
+ return {
+ notificationGroup,
+ uniqueId: notificationGroup.localId,
+ };
+ }).concat(notifications);
+ }
+ return {
+ isDeviceMobile: this.env.messaging.device.isMobile,
+ notifications,
+ };
+ }
+
+ /**
+ * @private
+ * @param {Object} props
+ * @throws {Error} in case `props.filter` is not supported
+ * @returns {mail.thread[]}
+ */
+ _useStoreSelectorThreads(props) {
+ if (props.filter === 'mailbox') {
+ return this.env.models['mail.thread']
+ .all(thread => thread.isPinned && thread.model === 'mail.box')
+ .sort((mailbox1, mailbox2) => {
+ if (mailbox1 === this.env.messaging.inbox) {
+ return -1;
+ }
+ if (mailbox2 === this.env.messaging.inbox) {
+ return 1;
+ }
+ if (mailbox1 === this.env.messaging.starred) {
+ return -1;
+ }
+ if (mailbox2 === this.env.messaging.starred) {
+ return 1;
+ }
+ const mailbox1Name = mailbox1.displayName;
+ const mailbox2Name = mailbox2.displayName;
+ mailbox1Name < mailbox2Name ? -1 : 1;
+ });
+ } else if (props.filter === 'channel') {
+ return this.env.models['mail.thread']
+ .all(thread =>
+ thread.channel_type === 'channel' &&
+ thread.isPinned &&
+ thread.model === 'mail.channel'
+ )
+ .sort((c1, c2) => c1.displayName < c2.displayName ? -1 : 1);
+ } else if (props.filter === 'chat') {
+ return this.env.models['mail.thread']
+ .all(thread =>
+ thread.channel_type === 'chat' &&
+ thread.isPinned &&
+ thread.model === 'mail.channel'
+ )
+ .sort((c1, c2) => c1.displayName < c2.displayName ? -1 : 1);
+ } else if (props.filter === 'all') {
+ // "All" filter is for channels and chats
+ return this.env.models['mail.thread']
+ .all(thread => thread.isPinned && thread.model === 'mail.channel')
+ .sort((c1, c2) => c1.displayName < c2.displayName ? -1 : 1);
+ } else {
+ throw new Error(`Unsupported filter ${props.filter}`);
+ }
+ }
+
+}
+
+Object.assign(NotificationList, {
+ _allowedFilters: ['all', 'mailbox', 'channel', 'chat'],
+ components,
+ defaultProps: {
+ filter: 'all',
+ },
+ props: {
+ filter: {
+ type: String,
+ validate: prop => NotificationList._allowedFilters.includes(prop),
+ },
+ },
+ template: 'mail.NotificationList',
+});
+
+return NotificationList;
+
+});
diff --git a/addons/mail/static/src/components/notification_list/notification_list.scss b/addons/mail/static/src/components/notification_list/notification_list.scss
new file mode 100644
index 00000000000..18e3114952c
--- /dev/null
+++ b/addons/mail/static/src/components/notification_list/notification_list.scss
@@ -0,0 +1,37 @@
+// ------------------------------------------------------------------
+// Layout
+// ------------------------------------------------------------------
+
+ .o_NotificationList {
+ display: flex;
+ flex-flow: column;
+ overflow: auto;
+
+ &.o-empty {
+ justify-content: center;
+ }
+}
+
+.o_NotificationList_noConversation {
+ display: flex;
+ align-items: center;
+ justify-content: center;
+ padding: map-get($spacers, 4) map-get($spacers, 2);
+}
+
+.o_NotificationList_separator {
+ flex: 0 0 auto;
+ width: map-get($sizes, 100);
+}
+
+// ------------------------------------------------------------------
+// Style
+// ------------------------------------------------------------------
+
+.o_NotificationList_separator {
+ border-bottom: $border-width solid $border-color;
+}
+
+.o_NotificationList_noConversation {
+ color: $text-muted;
+}
diff --git a/addons/mail/static/src/components/notification_list/notification_list.xml b/addons/mail/static/src/components/notification_list/notification_list.xml
new file mode 100644
index 00000000000..bcf65da985d
--- /dev/null
+++ b/addons/mail/static/src/components/notification_list/notification_list.xml
@@ -0,0 +1,41 @@
+<?xml version="1.0" encoding="UTF-8"?>
+<templates xml:space="preserve">
+
+ <t t-name="mail.NotificationList" owl="1">
+ <div class="o_NotificationList" t-att-class="{ 'o-empty': notifications.length === 0 }">
+ <t t-if="notifications.length === 0">
+ <div class="o_NotificationList_noConversation">
+ No conversation yet...
+ </div>
+ </t>
+ <t t-else="">
+ <t t-foreach="notifications" t-as="notification" t-key="notification.uniqueId">
+ <t t-if="notification.type === 'thread' and notification.thread">
+ <ThreadPreview
+ class="o_NotificationList_preview"
+ t-att-class="{ 'o-mobile': env.messaging.device.isMobile }"
+ threadLocalId="notification.thread.localId"
+ />
+ </t>
+ <t t-if="notification.type === 'thread_needaction' and notification.thread">
+ <ThreadNeedactionPreview
+ class="o_NotificationList_preview"
+ t-att-class="{ 'o-mobile': env.messaging.device.isMobile }"
+ threadLocalId="notification.thread.localId"
+ />
+ </t>
+ <t t-if="notification.notificationGroup">
+ <NotificationGroup
+ class="o_NotificationList_group"
+ notificationGroupLocalId="notification.notificationGroup.localId"
+ />
+ </t>
+ <t name="separatorCondition" t-if="!notification_last">
+ <div class="o_NotificationList_separator"/>
+ </t>
+ </t>
+ </t>
+ </div>
+ </t>
+
+</templates>
diff --git a/addons/mail/static/src/components/notification_list/notification_list_item.scss b/addons/mail/static/src/components/notification_list/notification_list_item.scss
new file mode 100644
index 00000000000..265e908a894
--- /dev/null
+++ b/addons/mail/static/src/components/notification_list/notification_list_item.scss
@@ -0,0 +1,174 @@
+// -----------------------------------------------------------------------------
+// Layout
+// -----------------------------------------------------------------------------
+
+@mixin o-mail-notification-list-item-layout {
+ display: flex;
+ align-items: center;
+ padding: map-get($spacers, 1);
+
+ &.o-mobile {
+ padding: map-get($spacers, 2);
+ }
+}
+
+@mixin o-mail-notification-list-item-content-layout {
+ display: flex;
+ flex-flow: column;
+ flex: 1 1 auto;
+ align-self: flex-start;
+ min-width: 0; // needed for flex to work correctly
+ margin: map-get($spacers, 2);
+}
+
+@mixin o-mail-notification-list-item-core-layout {
+ display: flex;
+}
+
+@mixin o-mail-notification-list-item-core-item-layout {
+ margin: map-get($spacers, 0) map-get($spacers, 2);
+
+ &:first-child {
+ margin-inline-start: map-get($spacers, 0);
+ }
+
+ &:last-child {
+ margin-inline-end: map-get($spacers, 0);
+ }
+}
+
+@mixin o-mail-notification-list-item-counter-layout() {
+ margin: map-get($spacers, 0) map-get($spacers, 2);
+}
+
+@mixin o-mail-notification-list-item-date-layout() {
+ flex: 0 0 auto;
+}
+
+@mixin o-mail-notification-list-item-header-layout {
+ display: flex;
+ margin-bottom: map-get($spacers, 1);
+}
+
+@mixin o-mail-notification-list-item-image-layout {
+ width: map-get($sizes, 100);
+ height: map-get($sizes, 100);
+}
+
+@mixin o-mail-notification-list-item-image-container-layout {
+ position: relative;
+ width: 40px;
+ height: 40px;
+}
+
+@mixin o-mail-notification-list-item-inline-text-layout {
+ white-space: nowrap;
+ overflow: hidden;
+ text-overflow: ellipsis;
+
+ &.o-empty::before {
+ content: '\00a0'; // keep line-height as if it had content
+ }
+}
+
+@mixin o-mail-notification-list-item-mark-as-read-layout() {
+ display: flex;
+ flex: 0 0 auto;
+}
+
+@mixin o-mail-notification-list-item-name-layout {
+ white-space: nowrap;
+ overflow: hidden;
+ text-overflow: ellipsis;
+
+ &.o-mobile {
+ font-size: 1.1em;
+ }
+}
+
+@mixin o-mail-notification-list-item-partner-im-status-icon-layout {
+ @include o-position-absolute($bottom: 0, $right: 0);
+ display: flex;
+ align-items: center;
+ justify-content: center;
+}
+
+@mixin o-mail-notification-list-item-sidebar-layout {
+ margin: map-get($spacers, 1);
+}
+
+// -----------------------------------------------------------------------------
+// Style
+// -----------------------------------------------------------------------------
+
+$o-mail-notification-list-item-background-color: $white !default;
+$o-mail-notification-list-item-hover-background-color:
+ darken($o-mail-notification-list-item-background-color, 7%) !default;
+
+$o-mail-notification-list-item-muted-background-color: gray('100') !default;
+$o-mail-notification-list-item-muted-hover-background-color:
+ darken($o-mail-notification-list-item-muted-background-color, 7%) !default;
+
+@mixin o-mail-notification-list-item-style {
+ cursor: pointer;
+ user-select: none;
+ background-color: $o-mail-notification-list-item-background-color;
+
+ &:hover {
+ background-color: $o-mail-notification-list-item-hover-background-color;
+ }
+
+ &.o-muted {
+ background-color: $o-mail-notification-list-item-muted-background-color;
+
+ &:hover {
+ background-color: $o-mail-notification-list-item-muted-hover-background-color;
+ }
+ }
+}
+
+@mixin o-mail-notification-list-item-bold-style {
+ font-weight: bold;
+
+ &.o-muted {
+ font-weight: initial;
+ }
+}
+
+@mixin o-mail-notification-list-item-core-style {
+ color: gray('500');
+}
+
+@mixin o-mail-notification-list-item-date-style() {
+ @include o-mail-notification-list-item-bold-style();
+ font-size: x-small;
+ color: $o-brand-primary;
+}
+
+@mixin o-mail-notification-list-item-mark-as-read-style() {
+ opacity: 0;
+
+ &:hover {
+ color: gray('600');
+ }
+}
+
+@mixin o-mail-notification-list-item-hover-partner-im-status-icon-style {
+ color: $o-mail-notification-list-item-hover-background-color;
+}
+
+@mixin o-mail-notification-list-item-muted-hover-partner-im-status-icon-style {
+ color: $o-mail-notification-list-item-muted-hover-background-color;
+}
+
+@mixin o-mail-notification-list-item-partner-im-status-icon-style {
+ color: $o-mail-notification-list-item-background-color;
+
+ &:not(.o-mobile) {
+ font-size: x-small;
+ }
+
+ &.o-muted {
+ color: $o-mail-notification-list-item-muted-background-color;
+ }
+}
diff --git a/addons/mail/static/src/components/notification_list/notification_list_notification_group_tests.js b/addons/mail/static/src/components/notification_list/notification_list_notification_group_tests.js
new file mode 100644
index 00000000000..f76fd920d82
--- /dev/null
+++ b/addons/mail/static/src/components/notification_list/notification_list_notification_group_tests.js
@@ -0,0 +1,563 @@
+odoo.define('mail/static/src/components/notification_list/notification_list_notification_group_tests.js', function (require) {
+'use strict';
+
+const components = {
+ NotificationList: require('mail/static/src/components/notification_list/notification_list.js'),
+};
+
+const {
+ afterEach: utilsAfterEach,
+ afterNextRender,
+ beforeEach: utilsBeforeEach,
+ start: utilsStart,
+} = require('mail/static/src/utils/test_utils.js');
+
+const Bus = require('web.Bus');
+
+QUnit.module('mail', {}, function () {
+QUnit.module('components', {}, function () {
+QUnit.module('notification_list', {}, function () {
+QUnit.module('notification_list_notification_group_tests.js', {
+ beforeEach() {
+ utilsBeforeEach(this);
+
+ /**
+ * @param {Object} param0
+ * @param {string} [param0.filter='all']
+ */
+ this.createNotificationListComponent = async ({ filter = 'all' } = {}) => {
+ const NotificationListComponent = components.NotificationList;
+ NotificationListComponent.env = this.env;
+ this.component = new NotificationListComponent(null, { filter });
+ await afterNextRender(() => this.component.mount(this.widget.el));
+ };
+
+ this.start = async params => {
+ let { env, widget } = await utilsStart(Object.assign({}, params, {
+ data: this.data,
+ }));
+ this.env = env;
+ this.widget = widget;
+ };
+ },
+ afterEach() {
+ utilsAfterEach(this);
+ if (this.component) {
+ this.component.destroy();
+ this.component = undefined;
+ }
+ if (this.widget) {
+ this.widget.destroy();
+ this.widget = undefined;
+ }
+ this.env = undefined;
+ delete components.NotificationList.env;
+ },
+});
+
+QUnit.test('notification group basic layout', async function (assert) {
+ assert.expect(10);
+
+ this.data.initMessaging.mail_failures = [{
+ date: moment.utc().format("YYYY-MM-DD HH:mm:ss"),
+ id: 11,
+ message_type: 'email',
+ model: 'mail.channel',
+ notifications: [{
+ failure_type: 'SMTP',
+ id: 21,
+ notification_status: 'exception',
+ notification_type: 'email',
+ partner_id: [41, "Someone"],
+ }],
+ res_id: 31,
+ res_model_name: "Channel",
+ }];
+ await this.start();
+ await this.createNotificationListComponent();
+
+ assert.containsOnce(
+ document.body,
+ '.o_NotificationGroup',
+ "should have 1 notification group"
+ );
+ assert.containsOnce(
+ document.body,
+ '.o_NotificationGroup_name',
+ "should have 1 group name"
+ );
+ assert.strictEqual(
+ document.querySelector('.o_NotificationGroup_name').textContent,
+ "Channel",
+ "should have model name as group name"
+ );
+ assert.containsOnce(
+ document.body,
+ '.o_NotificationGroup_counter',
+ "should have 1 group counter"
+ );
+ assert.strictEqual(
+ document.querySelector('.o_NotificationGroup_counter').textContent.trim(),
+ "(1)",
+ "should have only 1 notification in the group"
+ );
+ assert.containsOnce(
+ document.body,
+ '.o_NotificationGroup_date',
+ "should have 1 group date"
+ );
+ assert.strictEqual(
+ document.querySelector('.o_NotificationGroup_date').textContent,
+ "a few seconds ago",
+ "should have the group date corresponding to now"
+ );
+ assert.containsOnce(
+ document.body,
+ '.o_NotificationGroup_inlineText',
+ "should have 1 group text"
+ );
+ assert.strictEqual(
+ document.querySelector('.o_NotificationGroup_inlineText').textContent.trim(),
+ "An error occurred when sending an email.",
+ "should have the group text corresponding to email"
+ );
+ assert.containsOnce(
+ document.body,
+ '.o_NotificationGroup_markAsRead',
+ "should have 1 mark as read button"
+ );
+});
+
+QUnit.test('mark as read', async function (assert) {
+ assert.expect(6);
+
+ this.data.initMessaging.mail_failures = [{
+ date: moment.utc().format("YYYY-MM-DD HH:mm:ss"),
+ id: 11,
+ message_type: 'email',
+ model: 'mail.channel',
+ notifications: [{
+ failure_type: 'SMTP',
+ id: 21,
+ notification_status: 'exception',
+ notification_type: 'email',
+ partner_id: [41, "Someone"],
+ }],
+ res_id: 31,
+ res_model_name: "Channel",
+ }];
+ const bus = new Bus();
+ bus.on('do-action', null, payload => {
+ assert.step('do_action');
+ assert.strictEqual(
+ payload.action,
+ 'mail.mail_resend_cancel_action',
+ "action should be the one to cancel email"
+ );
+ assert.strictEqual(
+ payload.options.additional_context.default_model,
+ 'mail.channel',
+ "action should have the group model as default_model"
+ );
+ assert.strictEqual(
+ payload.options.additional_context.unread_counter,
+ 1,
+ "action should have the group notification length as unread_counter"
+ );
+ });
+
+ await this.start({ env: { bus } });
+ await this.createNotificationListComponent();
+
+ assert.containsOnce(
+ document.body,
+ '.o_NotificationGroup_markAsRead',
+ "should have 1 mark as read button"
+ );
+
+ document.querySelector('.o_NotificationGroup_markAsRead').click();
+ assert.verifySteps(
+ ['do_action'],
+ "should do an action to display the cancel email dialog"
+ );
+});
+
+QUnit.test('grouped notifications by document', async function (assert) {
+ // If some failures linked to a document refers to a same document, a single
+ // notification should group all those failures.
+ assert.expect(9);
+
+ this.data.initMessaging.mail_failures = [{
+ date: moment.utc().format("YYYY-MM-DD HH:mm:ss"),
+ id: 11,
+ message_type: 'email',
+ model: 'res.partner', // key element of this test: same model
+ notifications: [{
+ failure_type: 'SMTP',
+ id: 21,
+ notification_status: 'exception',
+ notification_type: 'email',
+ partner_id: [41, "Someone"],
+ }],
+ res_id: 31,
+ res_model_name: "Partner",
+ }, {
+ date: moment.utc().format("YYYY-MM-DD HH:mm:ss"),
+ id: 12,
+ message_type: 'email',
+ model: 'res.partner', // key element of this test: same model
+ notifications: [{
+ failure_type: 'SMTP',
+ id: 22,
+ notification_status: 'bounce',
+ notification_type: 'email',
+ partner_id: [42, "Someone else"],
+ }],
+ res_id: 31,
+ res_model_name: "Partner",
+ }];
+ const bus = new Bus();
+ bus.on('do-action', null, payload => {
+ assert.step('do_action');
+ assert.strictEqual(
+ payload.action.type,
+ 'ir.actions.act_window',
+ "action should have the type act_window"
+ );
+ assert.strictEqual(
+ payload.action.res_model,
+ 'res.partner',
+ "action should have the group model as res_model"
+ );
+ assert.strictEqual(
+ JSON.stringify(payload.action.views),
+ JSON.stringify([[false, 'form']]),
+ "action should have form view"
+ );
+ assert.strictEqual(
+ payload.action.res_id,
+ 31,
+ "action should have the group res_id as res_id"
+ );
+ });
+
+ await this.start({ env: { bus } });
+ await this.createNotificationListComponent();
+
+ assert.containsOnce(
+ document.body,
+ '.o_NotificationGroup',
+ "should have 1 notification group"
+ );
+ assert.containsOnce(
+ document.body,
+ '.o_NotificationGroup_counter',
+ "should have 1 group counter"
+ );
+ assert.strictEqual(
+ document.querySelector('.o_NotificationGroup_counter').textContent.trim(),
+ "(2)",
+ "should have 2 notifications in the group"
+ );
+
+ document.querySelector('.o_NotificationGroup').click();
+ assert.verifySteps(
+ ['do_action'],
+ "should do an action to display the related record"
+ );
+});
+
+QUnit.test('grouped notifications by document model', async function (assert) {
+ // If all failures linked to a document model refers to different documents,
+ // a single notification should group all failures that are linked to this
+ // document model.
+ assert.expect(12);
+
+ this.data.initMessaging.mail_failures = [{
+ date: moment.utc().format("YYYY-MM-DD HH:mm:ss"),
+ id: 11,
+ message_type: 'email',
+ model: 'res.partner', // key element of this test: same model
+ notifications: [{
+ failure_type: 'SMTP',
+ id: 21,
+ notification_status: 'exception',
+ notification_type: 'email',
+ partner_id: [41, "Someone"],
+ }],
+ res_id: 31, // key element of this test: a different res_id
+ res_model_name: "Partner",
+ }, {
+ date: moment.utc().format("YYYY-MM-DD HH:mm:ss"),
+ id: 12,
+ message_type: 'email',
+ model: 'res.partner', // key element of this test: same model
+ notifications: [{
+ failure_type: 'SMTP',
+ id: 22,
+ notification_status: 'bounce',
+ notification_type: 'email',
+ partner_id: [42, "Someone else"],
+ }],
+ res_id: 32, // key element of this test: a different res_id
+ res_model_name: "Partner",
+ }];
+ const bus = new Bus();
+ bus.on('do-action', null, payload => {
+ assert.step('do_action');
+ assert.strictEqual(
+ payload.action.name,
+ "Mail Failures",
+ "action should have 'Mail Failures' as name",
+ );
+ assert.strictEqual(
+ payload.action.type,
+ 'ir.actions.act_window',
+ "action should have the type act_window"
+ );
+ assert.strictEqual(
+ payload.action.view_mode,
+ 'kanban,list,form',
+ "action should have 'kanban,list,form' as view_mode"
+ );
+ assert.strictEqual(
+ JSON.stringify(payload.action.views),
+ JSON.stringify([[false, 'kanban'], [false, 'list'], [false, 'form']]),
+ "action should have correct views"
+ );
+ assert.strictEqual(
+ payload.action.target,
+ 'current',
+ "action should have 'current' as target"
+ );
+ assert.strictEqual(
+ payload.action.res_model,
+ 'res.partner',
+ "action should have the group model as res_model"
+ );
+ assert.strictEqual(
+ JSON.stringify(payload.action.domain),
+ JSON.stringify([['message_has_error', '=', true]]),
+ "action should have 'message_has_error' as domain"
+ );
+ });
+
+ await this.start({ env: { bus } });
+ await this.createNotificationListComponent();
+
+ assert.containsOnce(
+ document.body,
+ '.o_NotificationGroup',
+ "should have 1 notification group"
+ );
+ assert.containsOnce(
+ document.body,
+ '.o_NotificationGroup_counter',
+ "should have 1 group counter"
+ );
+ assert.strictEqual(
+ document.querySelector('.o_NotificationGroup_counter').textContent.trim(),
+ "(2)",
+ "should have 2 notifications in the group"
+ );
+
+ document.querySelector('.o_NotificationGroup').click();
+ assert.verifySteps(
+ ['do_action'],
+ "should do an action to display the related records"
+ );
+});
+
+QUnit.test('different mail.channel are not grouped', async function (assert) {
+ // `mail.channel` is a special case where notifications are not grouped when
+ // they are linked to different channels, even though the model is the same.
+ assert.expect(6);
+
+ this.data.initMessaging.mail_failures = [{
+ date: moment.utc().format("YYYY-MM-DD HH:mm:ss"),
+ id: 11,
+ message_type: 'email',
+ model: 'mail.channel', // key element of this test: `mail.channel`
+ notifications: [{
+ failure_type: 'SMTP',
+ id: 21,
+ notification_status: 'exception',
+ notification_type: 'email',
+ partner_id: [41, "Someone"],
+ }],
+ res_id: 31, // key element of this test: a different res_id
+ res_model_name: "Channel",
+ }, {
+ date: moment.utc().format("YYYY-MM-DD HH:mm:ss"),
+ id: 12,
+ message_type: 'email',
+ model: 'mail.channel', // key element of this test: `mail.channel`
+ notifications: [{
+ failure_type: 'SMTP',
+ id: 22,
+ notification_status: 'bounce',
+ notification_type: 'email',
+ partner_id: [42, "Someone else"],
+ }],
+ res_id: 32, // key element of this test: a different res_id
+ res_model_name: "Channel",
+ }];
+ await this.start({
+ hasChatWindow: true, // needed to assert thread.open
+ });
+ await this.createNotificationListComponent();
+
+ assert.containsN(
+ document.body,
+ '.o_NotificationGroup',
+ 2,
+ "should have 2 notifications group"
+ );
+ const groups = document.querySelectorAll('.o_NotificationGroup');
+ assert.containsOnce(
+ groups[0],
+ '.o_NotificationGroup_counter',
+ "should have 1 group counter in first group"
+ );
+ assert.strictEqual(
+ groups[0].querySelector('.o_NotificationGroup_counter').textContent.trim(),
+ "(1)",
+ "should have 1 notification in first group"
+ );
+ assert.containsOnce(
+ groups[1],
+ '.o_NotificationGroup_counter',
+ "should have 1 group counter in second group"
+ );
+ assert.strictEqual(
+ groups[1].querySelector('.o_NotificationGroup_counter').textContent.trim(),
+ "(1)",
+ "should have 1 notification in second group"
+ );
+
+ await afterNextRender(() => groups[0].click());
+ assert.containsOnce(
+ document.body,
+ '.o_ChatWindow',
+ "should have opened the channel related to the first group in a chat window"
+ );
+});
+
+QUnit.test('multiple grouped notifications by document model, sorted by date desc', async function (assert) {
+ assert.expect(9);
+
+ this.data.initMessaging.mail_failures = [{
+ date: moment.utc().format("YYYY-MM-DD HH:mm:ss"),
+ id: 11,
+ message_type: 'email',
+ model: 'res.partner', // key element of this test: different model
+ notifications: [{
+ failure_type: 'SMTP',
+ id: 21,
+ notification_status: 'exception',
+ notification_type: 'email',
+ partner_id: [41, "Someone"],
+ }],
+ res_id: 31,
+ res_model_name: "Partner",
+ }, {
+ date: moment.utc().add(1, 'days').format("YYYY-MM-DD HH:mm:ss"),
+ id: 12,
+ message_type: 'email',
+ model: 'res.company', // key element of this test: different model
+ notifications: [{
+ failure_type: 'SMTP',
+ id: 22,
+ notification_status: 'bounce',
+ notification_type: 'email',
+ partner_id: [42, "Someone else"],
+ }],
+ res_id: 32,
+ res_model_name: "Company",
+ }];
+ await this.start();
+ await this.createNotificationListComponent();
+
+ assert.containsN(
+ document.body,
+ '.o_NotificationGroup',
+ 2,
+ "should have 2 notifications group"
+ );
+ const groups = document.querySelectorAll('.o_NotificationGroup');
+ assert.containsOnce(
+ groups[0],
+ '.o_NotificationGroup_name',
+ "should have 1 group name in first group"
+ );
+ assert.strictEqual(
+ groups[0].querySelector('.o_NotificationGroup_name').textContent,
+ "Company",
+ "should have first model name as group name"
+ );
+ assert.containsOnce(
+ groups[0],
+ '.o_NotificationGroup_counter',
+ "should have 1 group counter in first group"
+ );
+ assert.strictEqual(
+ groups[0].querySelector('.o_NotificationGroup_counter').textContent.trim(),
+ "(1)",
+ "should have 1 notification in first group"
+ );
+ assert.containsOnce(
+ groups[1],
+ '.o_NotificationGroup_name',
+ "should have 1 group name in second group"
+ );
+ assert.strictEqual(
+ groups[1].querySelector('.o_NotificationGroup_name').textContent,
+ "Partner",
+ "should have second model name as group name"
+ );
+ assert.containsOnce(
+ groups[1],
+ '.o_NotificationGroup_counter',
+ "should have 1 group counter in second group"
+ );
+ assert.strictEqual(
+ groups[1].querySelector('.o_NotificationGroup_counter').textContent.trim(),
+ "(1)",
+ "should have 1 notification in second group"
+ );
+});
+
+QUnit.test('non-failure notifications are ignored', async function (assert) {
+ assert.expect(1);
+
+ this.data.initMessaging.mail_failures = [{
+ date: moment.utc().format("YYYY-MM-DD HH:mm:ss"),
+ id: 11,
+ message_type: 'email',
+ model: 'res.partner',
+ notifications: [{
+ failure_type: false,
+ id: 21,
+ // key element of this test: status is not bounce or exception
+ notification_status: 'ready',
+ notification_type: 'email',
+ partner_id: [41, "Someone"],
+ }],
+ res_id: 31,
+ res_model_name: "Partner",
+ }];
+ await this.start();
+ await this.createNotificationListComponent();
+
+ assert.containsNone(
+ document.body,
+ '.o_NotificationGroup',
+ "should have 0 notification group"
+ );
+});
+
+});
+});
+});
+
+});
diff --git a/addons/mail/static/src/components/notification_list/notification_list_tests.js b/addons/mail/static/src/components/notification_list/notification_list_tests.js
new file mode 100644
index 00000000000..cc71ee2d0f2
--- /dev/null
+++ b/addons/mail/static/src/components/notification_list/notification_list_tests.js
@@ -0,0 +1,210 @@
+odoo.define('mail/static/src/components/notification_list/notification_list_tests.js', function (require) {
+'use strict';
+
+const components = {
+ NotificationList: require('mail/static/src/components/notification_list/notification_list.js'),
+};
+
+const {
+ afterEach: utilsAfterEach,
+ afterNextRender,
+ beforeEach: utilsBeforeEach,
+ start: utilsStart,
+} = require('mail/static/src/utils/test_utils.js');
+
+QUnit.module('mail', {}, function () {
+QUnit.module('components', {}, function () {
+QUnit.module('notification_list', {}, function () {
+QUnit.module('notification_list_tests.js', {
+ beforeEach() {
+ utilsBeforeEach(this);
+
+ /**
+ * @param {Object} param0
+ * @param {string} [param0.filter='all']
+ */
+ this.createNotificationListComponent = async ({ filter = 'all' }) => {
+ const NotificationListComponent = components.NotificationList;
+ NotificationListComponent.env = this.env;
+ this.component = new NotificationListComponent(null, { filter });
+ await afterNextRender(() => this.component.mount(this.widget.el));
+ };
+
+ this.start = async params => {
+ Object.assign(this.data.initMessaging, {
+ channel_slots: {
+ channel_channel: [{
+ channel_type: 'channel',
+ id: 100,
+ name: "Channel 2019",
+ message_unread_counter: 0,
+ }, {
+ channel_type: 'channel',
+ id: 200,
+ name: "Channel 2020",
+ message_unread_counter: 0,
+ }],
+ },
+ });
+ let { env, widget } = await utilsStart(Object.assign({}, params, {
+ data: this.data,
+ }));
+ this.env = env;
+ this.widget = widget;
+ };
+ },
+ afterEach() {
+ utilsAfterEach(this);
+ if (this.component) {
+ this.component.destroy();
+ this.component = undefined;
+ }
+ if (this.widget) {
+ this.widget.destroy();
+ this.widget = undefined;
+ }
+ this.env = undefined;
+ delete components.NotificationList.env;
+ },
+});
+
+QUnit.test('marked as read thread notifications are ordered by last message date', async function (assert) {
+ assert.expect(3);
+
+ this.data['mail.channel'].records = [{
+ channel_type: 'channel',
+ id: 100,
+ message_unread_counter: 0,
+ name: "Channel 2019",
+ }, {
+ channel_type: 'channel',
+ id: 200,
+ message_unread_counter: 0,
+ name: "Channel 2020",
+ }];
+ this.data['mail.message'].records = [{
+ author_id: [10, "Author A"],
+ body: "<p>Message A</p>",
+ channel_ids: [100],
+ date: "2019-01-01 00:00:00",
+ id: 42,
+ message_type: 'comment',
+ model: 'mail.channel',
+ record_name: 'Channel 2019',
+ res_id: 100,
+ }, {
+ author_id: [20, "Author B"],
+ body: "<p>Message B</p>",
+ channel_ids: [200],
+ date: "2020-01-01 00:00:00",
+ id: 43,
+ message_type: 'comment',
+ model: 'mail.channel',
+ record_name: 'Channel 2020',
+ res_id: 200,
+ }];
+ await this.start();
+ await this.createNotificationListComponent({ filter: 'all' });
+ assert.containsN(
+ document.body,
+ '.o_ThreadPreview',
+ 2,
+ "there should be two thread previews"
+ );
+ const threadPreviewElList = document.querySelectorAll('.o_ThreadPreview');
+ assert.strictEqual(
+ threadPreviewElList[0].querySelector(':scope .o_ThreadPreview_name').textContent,
+ 'Channel 2020',
+ "First channel in the list should be the channel of 2020 (more recent last message)"
+ );
+ assert.strictEqual(
+ threadPreviewElList[1].querySelector(':scope .o_ThreadPreview_name').textContent,
+ 'Channel 2019',
+ "Second channel in the list should be the channel of 2019 (least recent last message)"
+ );
+});
+
+QUnit.test('thread notifications are re-ordered on receiving a new message', async function (assert) {
+ assert.expect(4);
+
+ this.data['mail.channel'].records = [{
+ channel_type: 'channel',
+ id: 100,
+ message_unread_counter: 0,
+ name: "Channel 2019",
+ }, {
+ channel_type: 'channel',
+ id: 200,
+ message_unread_counter: 0,
+ name: "Channel 2020",
+ }];
+ this.data['mail.message'].records = [{
+ author_id: [10, "Author A"],
+ body: "<p>Message A</p>",
+ channel_ids: [100],
+ date: "2019-01-01 00:00:00",
+ id: 42,
+ message_type: 'comment',
+ model: 'mail.channel',
+ record_name: 'Channel 2019',
+ res_id: 100,
+ }, {
+ author_id: [20, "Author B"],
+ body: "<p>Message B</p>",
+ channel_ids: [200],
+ date: "2020-01-01 00:00:00",
+ id: 43,
+ message_type: 'comment',
+ model: 'mail.channel',
+ record_name: 'Channel 2020',
+ res_id: 200,
+ }];
+ await this.start();
+ await this.createNotificationListComponent({ filter: 'all' });
+ assert.containsN(
+ document.body,
+ '.o_ThreadPreview',
+ 2,
+ "there should be two thread previews"
+ );
+
+ await afterNextRender(() => {
+ const messageData = {
+ author_id: [7, "Demo User"],
+ body: "<p>New message !</p>",
+ channel_ids: [100],
+ date: "2020-03-23 10:00:00",
+ id: 44,
+ message_type: 'comment',
+ model: 'mail.channel',
+ record_name: 'Channel 2019',
+ res_id: 100,
+ };
+ this.widget.call('bus_service', 'trigger', 'notification', [
+ [['my-db', 'mail.channel', 100], messageData]
+ ]);
+ });
+ assert.containsN(
+ document.body,
+ '.o_ThreadPreview',
+ 2,
+ "there should still be two thread previews"
+ );
+ const threadPreviewElList = document.querySelectorAll('.o_ThreadPreview');
+ assert.strictEqual(
+ threadPreviewElList[0].querySelector(':scope .o_ThreadPreview_name').textContent,
+ 'Channel 2019',
+ "First channel in the list should now be 'Channel 2019'"
+ );
+ assert.strictEqual(
+ threadPreviewElList[1].querySelector(':scope .o_ThreadPreview_name').textContent,
+ 'Channel 2020',
+ "Second channel in the list should now be 'Channel 2020'"
+ );
+});
+
+});
+});
+});
+
+});
diff --git a/addons/mail/static/src/components/notification_popover/notification_popover.js b/addons/mail/static/src/components/notification_popover/notification_popover.js
new file mode 100644
index 00000000000..0f3fce77a58
--- /dev/null
+++ b/addons/mail/static/src/components/notification_popover/notification_popover.js
@@ -0,0 +1,89 @@
+odoo.define('mail/static/src/components/notification_popover/notification_popover.js', function (require) {
+'use strict';
+
+const { Component } = owl;
+const useStore = require('mail/static/src/component_hooks/use_store/use_store.js');
+
+class NotificationPopover extends Component {
+
+ /**
+ * @override
+ */
+ constructor(...args) {
+ super(...args);
+ useStore(props => {
+ const notifications = props.notificationLocalIds.map(
+ notificationLocalId => this.env.models['mail.notification'].get(notificationLocalId)
+ );
+ return {
+ notifications: notifications.map(notification => notification ? notification.__state : undefined),
+ };
+ }, {
+ compareDepth: {
+ notifications: 1,
+ },
+ });
+ }
+
+ /**
+ * @returns {string}
+ */
+ get iconClass() {
+ switch (this.notification.notification_status) {
+ case 'sent':
+ return 'fa fa-check';
+ case 'bounce':
+ return 'fa fa-exclamation';
+ case 'exception':
+ return 'fa fa-exclamation';
+ case 'ready':
+ return 'fa fa-send-o';
+ case 'canceled':
+ return 'fa fa-trash-o';
+ }
+ return '';
+ }
+
+ /**
+ * @returns {string}
+ */
+ get iconTitle() {
+ switch (this.notification.notification_status) {
+ case 'sent':
+ return this.env._t("Sent");
+ case 'bounce':
+ return this.env._t("Bounced");
+ case 'exception':
+ return this.env._t("Error");
+ case 'ready':
+ return this.env._t("Ready");
+ case 'canceled':
+ return this.env._t("Canceled");
+ }
+ return '';
+ }
+
+ /**
+ * @returns {mail.notification[]}
+ */
+ get notifications() {
+ return this.props.notificationLocalIds.map(
+ notificationLocalId => this.env.models['mail.notification'].get(notificationLocalId)
+ );
+ }
+
+}
+
+Object.assign(NotificationPopover, {
+ props: {
+ notificationLocalIds: {
+ type: Array,
+ element: String,
+ },
+ },
+ template: 'mail.NotificationPopover',
+});
+
+return NotificationPopover;
+
+});
diff --git a/addons/mail/static/src/components/notification_popover/notification_popover.scss b/addons/mail/static/src/components/notification_popover/notification_popover.scss
new file mode 100644
index 00000000000..06b4201ca2c
--- /dev/null
+++ b/addons/mail/static/src/components/notification_popover/notification_popover.scss
@@ -0,0 +1,7 @@
+// -----------------------------------------------------------------------------
+// Layout
+// -----------------------------------------------------------------------------
+
+.o_NotificationPopover_notificationIcon {
+ margin-inline-end: map-get($spacers, 2);
+}
diff --git a/addons/mail/static/src/components/notification_popover/notification_popover.xml b/addons/mail/static/src/components/notification_popover/notification_popover.xml
new file mode 100644
index 00000000000..cf5aa027672
--- /dev/null
+++ b/addons/mail/static/src/components/notification_popover/notification_popover.xml
@@ -0,0 +1,17 @@
+<?xml version="1.0" encoding="UTF-8"?>
+<templates xml:space="preserve">
+
+ <t t-name="mail.NotificationPopover" owl="1">
+ <div class="o_NotificationPopover">
+ <t t-foreach="notifications" t-as="notification" t-key="notification.localId">
+ <div class="o_NotificationPopover_notification">
+ <i class="o_NotificationPopover_notificationIcon" t-att-class="iconClass" t-att-title="iconTitle" role="img"/>
+ <t t-if="notification.partner">
+ <span class="o_NotificationPopover_notificationPartnerName" t-esc="notification.partner.nameOrDisplayName"/>
+ </t>
+ </div>
+ </t>
+ </div>
+ </t>
+
+</templates>
diff --git a/addons/mail/static/src/components/partner_im_status_icon/partner_im_status_icon.js b/addons/mail/static/src/components/partner_im_status_icon/partner_im_status_icon.js
new file mode 100644
index 00000000000..7150c2914a9
--- /dev/null
+++ b/addons/mail/static/src/components/partner_im_status_icon/partner_im_status_icon.js
@@ -0,0 +1,52 @@
+odoo.define('mail/static/src/components/partner_im_status_icon/partner_im_status_icon.js', function (require) {
+'use strict';
+
+const useStore = require('mail/static/src/component_hooks/use_store/use_store.js');
+
+const { Component } = owl;
+
+class PartnerImStatusIcon extends Component {
+
+ /**
+ * @override
+ */
+ constructor(...args) {
+ super(...args);
+ useStore(props => {
+ const partner = this.env.models['mail.partner'].get(props.partnerLocalId);
+ return {
+ partner: partner ? partner.__state : undefined,
+ partnerRoot: this.env.messaging.partnerRoot
+ ? this.env.messaging.partnerRoot.__state
+ : undefined,
+ };
+ });
+ }
+
+ //--------------------------------------------------------------------------
+ // Public
+ //--------------------------------------------------------------------------
+
+ /**
+ * @returns {mail.partner}
+ */
+ get partner() {
+ return this.env.models['mail.partner'].get(this.props.partnerLocalId);
+ }
+
+}
+
+Object.assign(PartnerImStatusIcon, {
+ defaultProps: {
+ hasBackground: true
+ },
+ props: {
+ partnerLocalId: String,
+ hasBackground: Boolean,
+ },
+ template: 'mail.PartnerImStatusIcon',
+});
+
+return PartnerImStatusIcon;
+
+});
diff --git a/addons/mail/static/src/components/partner_im_status_icon/partner_im_status_icon.scss b/addons/mail/static/src/components/partner_im_status_icon/partner_im_status_icon.scss
new file mode 100644
index 00000000000..d5791c2f0c6
--- /dev/null
+++ b/addons/mail/static/src/components/partner_im_status_icon/partner_im_status_icon.scss
@@ -0,0 +1,53 @@
+// ------------------------------------------------------------------
+// Layout
+// ------------------------------------------------------------------
+
+.o_PartnerImStatusIcon {
+ display: flex;
+ flex-flow: column;
+
+ width: 1.2em;
+ height: 1.2em;
+ line-height: 1.3em;
+}
+
+.o_PartnerImStatusIcon_outerBackground {
+ transform: scale(1.5);
+}
+
+.o-background {
+ transform: scale(1);
+ margin-inline-end: map-get($spacers, 1);
+ margin-top: 2px;
+}
+
+// ------------------------------------------------------------------
+// Style
+// ------------------------------------------------------------------
+
+.o_PartnerImStatusIcon_innerBackground {
+ color: white;
+}
+
+.o_PartnerImStatusIcon_icon {
+
+ &.o-away {
+ color: theme-color('warning');
+ }
+
+ &.o-bot {
+ color: $o-enterprise-primary-color;
+ }
+
+ &.o-offline {
+ color: gray('700');
+ }
+
+ &.o-online {
+ color: $o-enterprise-primary-color;
+ }
+}
+
+
+
+
diff --git a/addons/mail/static/src/components/partner_im_status_icon/partner_im_status_icon.xml b/addons/mail/static/src/components/partner_im_status_icon/partner_im_status_icon.xml
new file mode 100644
index 00000000000..23879cc6b39
--- /dev/null
+++ b/addons/mail/static/src/components/partner_im_status_icon/partner_im_status_icon.xml
@@ -0,0 +1,36 @@
+<?xml version="1.0" encoding="UTF-8"?>
+<templates xml:space="preserve">
+
+ <t t-name="mail.PartnerImStatusIcon" owl="1">
+ <span class="o_PartnerImStatusIcon fa-stack"
+ t-att-class="{
+ 'o-away': partner and partner.im_status === 'away',
+ 'o-background': !props.hasBackground,
+ 'o-bot': partner and env.messaging.partnerRoot === partner,
+ 'o-offline': partner and partner.im_status === 'offline',
+ 'o-online': partner and partner.im_status === 'online',
+ }"
+ t-att-data-partner-local-id="partner ? partner.localId : undefined"
+ >
+ <t t-if="partner" name="rootCondition">
+ <t t-if="props.hasBackground">
+ <i class="o_PartnerImStatusIcon_outerBackground fa fa-circle fa-stack-1x"/>
+ <i class="o_PartnerImStatusIcon_innerBackground fa fa-circle fa-stack-1x"/>
+ </t>
+ <t t-if="partner.im_status === 'online'">
+ <i class="o_PartnerImStatusIcon_icon o-online fa fa-circle fa-stack-1x" title="Online" role="img" aria-label="User is online"/>
+ </t>
+ <t t-if="partner.im_status === 'away'">
+ <i class="o_PartnerImStatusIcon_icon o-away fa fa-circle fa-stack-1x" title="Idle" role="img" aria-label="User is idle"/>
+ </t>
+ <t t-if="partner.im_status === 'offline'">
+ <i class="o_PartnerImStatusIcon_icon o-offline fa fa-circle-o fa-stack-1x" title="Offline" role="img" aria-label="User is offline"/>
+ </t>
+ <t t-if="partner === env.messaging.partnerRoot">
+ <i class="o_PartnerImStatusIcon_icon o-bot fa fa-heart fa-stack-1x" title="Bot" role="img" aria-label="User is a bot"/>
+ </t>
+ </t>
+ </span>
+ </t>
+
+</templates>
diff --git a/addons/mail/static/src/components/partner_im_status_icon/partner_im_status_icon_tests.js b/addons/mail/static/src/components/partner_im_status_icon/partner_im_status_icon_tests.js
new file mode 100644
index 00000000000..01246ddcdb3
--- /dev/null
+++ b/addons/mail/static/src/components/partner_im_status_icon/partner_im_status_icon_tests.js
@@ -0,0 +1,152 @@
+odoo.define('mail/static/src/components/partner_im_status_icon/partner_im_status_icon_tests.js', function (require) {
+'use strict';
+
+const components = {
+ PartnerImStatusIcon: require('mail/static/src/components/partner_im_status_icon/partner_im_status_icon.js'),
+};
+const {
+ afterEach: utilsAfterEach,
+ afterNextRender,
+ beforeEach: utilsBeforeEach,
+ start: utilsStart,
+} = require('mail/static/src/utils/test_utils.js');
+
+QUnit.module('mail', {}, function () {
+QUnit.module('components', {}, function () {
+QUnit.module('partner_im_status_icon', {}, function () {
+QUnit.module('partner_im_status_icon_tests.js', {
+ beforeEach() {
+ utilsBeforeEach(this);
+
+ this.createPartnerImStatusIcon = async partner => {
+ const PartnerImStatusIconComponent = components.PartnerImStatusIcon;
+ PartnerImStatusIconComponent.env = this.env;
+ this.component = new PartnerImStatusIconComponent(null, { partnerLocalId: partner.localId });
+ await this.component.mount(this.widget.el);
+ };
+
+ this.start = async params => {
+ let { env, widget } = await utilsStart(Object.assign({}, params, {
+ data: this.data,
+ }));
+ this.env = env;
+ this.widget = widget;
+ };
+ },
+ afterEach() {
+ utilsAfterEach(this);
+ if (this.component) {
+ this.component.destroy();
+ }
+ if (this.widget) {
+ this.widget.destroy();
+ }
+ this.env = undefined;
+ delete components.PartnerImStatusIcon.env;
+ },
+});
+
+QUnit.test('initially online', async function (assert) {
+ assert.expect(3);
+
+ await this.start();
+ const partner = this.env.models['mail.partner'].create({
+ id: 7,
+ name: "Demo User",
+ im_status: 'online',
+ });
+ await this.createPartnerImStatusIcon(partner);
+ assert.strictEqual(
+ document.querySelectorAll(`.o_PartnerImStatusIcon`).length,
+ 1,
+ "should have partner IM status icon"
+ );
+ assert.strictEqual(
+ document.querySelector(`.o_PartnerImStatusIcon`).dataset.partnerLocalId,
+ partner.localId,
+ "partner IM status icon should be linked to partner with ID 7"
+ );
+ assert.strictEqual(
+ document.querySelectorAll(`.o_PartnerImStatusIcon.o-online`).length,
+ 1,
+ "partner IM status icon should have online status rendering"
+ );
+});
+
+QUnit.test('initially offline', async function (assert) {
+ assert.expect(1);
+
+ await this.start();
+ const partner = this.env.models['mail.partner'].create({
+ id: 7,
+ name: "Demo User",
+ im_status: 'offline',
+ });
+ await this.createPartnerImStatusIcon(partner);
+ assert.strictEqual(
+ document.querySelectorAll(`.o_PartnerImStatusIcon.o-offline`).length,
+ 1,
+ "partner IM status icon should have offline status rendering"
+ );
+});
+
+QUnit.test('initially away', async function (assert) {
+ assert.expect(1);
+
+ await this.start();
+ const partner = this.env.models['mail.partner'].create({
+ id: 7,
+ name: "Demo User",
+ im_status: 'away',
+ });
+ await this.createPartnerImStatusIcon(partner);
+ assert.strictEqual(
+ document.querySelectorAll(`.o_PartnerImStatusIcon.o-away`).length,
+ 1,
+ "partner IM status icon should have away status rendering"
+ );
+});
+
+QUnit.test('change icon on change partner im_status', async function (assert) {
+ assert.expect(4);
+
+ await this.start();
+ const partner = this.env.models['mail.partner'].create({
+ id: 7,
+ name: "Demo User",
+ im_status: 'online',
+ });
+ await this.createPartnerImStatusIcon(partner);
+ assert.strictEqual(
+ document.querySelectorAll(`.o_PartnerImStatusIcon.o-online`).length,
+ 1,
+ "partner IM status icon should have online status rendering"
+ );
+
+ await afterNextRender(() => partner.update({ im_status: 'offline' }));
+ assert.strictEqual(
+ document.querySelectorAll(`.o_PartnerImStatusIcon.o-offline`).length,
+ 1,
+ "partner IM status icon should have offline status rendering"
+ );
+
+ await afterNextRender(() => partner.update({ im_status: 'away' }));
+ assert.strictEqual(
+ document.querySelectorAll(`.o_PartnerImStatusIcon.o-away`).length,
+ 1,
+ "partner IM status icon should have away status rendering"
+ );
+
+ await afterNextRender(() => partner.update({ im_status: 'online' }));
+ assert.strictEqual(
+ document.querySelectorAll(`.o_PartnerImStatusIcon.o-online`).length,
+ 1,
+ "partner IM status icon should have online status rendering in the end"
+ );
+});
+
+});
+});
+});
+
+});
diff --git a/addons/mail/static/src/components/partner_mention_suggestion/partner_mention_suggestion.js b/addons/mail/static/src/components/partner_mention_suggestion/partner_mention_suggestion.js
new file mode 100644
index 00000000000..7c39f148264
--- /dev/null
+++ b/addons/mail/static/src/components/partner_mention_suggestion/partner_mention_suggestion.js
@@ -0,0 +1,79 @@
+odoo.define('mail/static/src/components/partner_mention_suggestion/partner_mention_suggestion.js', function (require) {
+'use strict';
+
+const useStore = require('mail/static/src/component_hooks/use_store/use_store.js');
+
+const components = {
+ PartnerImStatusIcon: require('mail/static/src/components/partner_im_status_icon/partner_im_status_icon.js'),
+};
+
+const { Component } = owl;
+
+class PartnerMentionSuggestion extends Component {
+
+ /**
+ * @override
+ */
+ constructor(...args) {
+ super(...args);
+ useStore(props => {
+ const partner = this.env.models['mail.partner'].get(props.partnerLocalId);
+ return {
+ partner: partner ? partner.__state : undefined,
+ };
+ });
+ }
+
+ //--------------------------------------------------------------------------
+ // Public
+ //--------------------------------------------------------------------------
+
+ /**
+ * @returns {mail.partner}
+ */
+ get partner() {
+ return this.env.models['mail.partner'].get(this.props.partnerLocalId);
+ }
+
+ //--------------------------------------------------------------------------
+ // Handlers
+ //--------------------------------------------------------------------------
+
+ /**
+ * @private
+ * @param {Event} ev
+ */
+ _onClick(ev) {
+ // avoid following dummy href
+ ev.preventDefault();
+ this.trigger('o-partner-mention-suggestion-clicked', {
+ partner: this.partner,
+ });
+ }
+
+ /**
+ * @private
+ * @param {Event} ev
+ */
+ _onMouseOver(ev) {
+ this.trigger('o-partner-mention-suggestion-mouse-over', {
+ partner: this.partner,
+ });
+ }
+}
+
+Object.assign(PartnerMentionSuggestion, {
+ components,
+ defaultProps: {
+ isActive: false,
+ },
+ props: {
+ isActive: Boolean,
+ partnerLocalId: String,
+ },
+ template: 'mail.PartnerMentionSuggestion',
+});
+
+return PartnerMentionSuggestion;
+
+});
diff --git a/addons/mail/static/src/components/partner_mention_suggestion/partner_mention_suggestion.scss b/addons/mail/static/src/components/partner_mention_suggestion/partner_mention_suggestion.scss
new file mode 100644
index 00000000000..b93d245972c
--- /dev/null
+++ b/addons/mail/static/src/components/partner_mention_suggestion/partner_mention_suggestion.scss
@@ -0,0 +1,26 @@
+// ------------------------------------------------------------------
+// Layout
+// ------------------------------------------------------------------
+
+.o_PartnerMentionSuggestion {
+ display: flex;
+ width: map-get($sizes, 100);
+ padding: map-get($spacers, 2) map-get($spacers, 4);
+}
+
+.o_PartnerMentionSuggestion_name {
+ padding-inline-end: map-get($spacers, 2);
+}
+
+// ------------------------------------------------------------------
+// Style
+// ------------------------------------------------------------------
+
+.o_PartnerMentionSuggestion_email {
+ font-style: italic;
+ color: $gray-600;
+}
+
+.o_PartnerMentionSuggestion_name {
+ font-weight: $font-weight-bold;
+}
diff --git a/addons/mail/static/src/components/partner_mention_suggestion/partner_mention_suggestion.xml b/addons/mail/static/src/components/partner_mention_suggestion/partner_mention_suggestion.xml
new file mode 100644
index 00000000000..a45e991ab19
--- /dev/null
+++ b/addons/mail/static/src/components/partner_mention_suggestion/partner_mention_suggestion.xml
@@ -0,0 +1,19 @@
+<?xml version="1.0" encoding="UTF-8"?>
+<templates xml:space="preserve">
+
+ <t t-name="mail.PartnerMentionSuggestion" owl="1">
+ <a class="o_PartnerMentionSuggestion dropdown-item" t-att-class="{ 'active': props.isActive }" href="#" role="menuitem" t-on-click="_onClick" t-on-mouseover="_onMouseOver">
+ <t t-if="partner">
+ <PartnerImStatusIcon
+ hasBackground="false"
+ partnerLocalId="partner.localId"
+ />
+ <span class="o_PartnerMentionSuggestion_name"><t t-esc="partner.nameOrDisplayName"/></span>
+ <t t-if="partner.email">
+ <span class="o_PartnerMentionSuggestion_email">(<t t-esc="partner.email"/>)</span>
+ </t>
+ </t>
+ </a>
+ </t>
+
+</templates>
diff --git a/addons/mail/static/src/components/partner_mention_suggestion/partner_mention_suggestion_tests.js b/addons/mail/static/src/components/partner_mention_suggestion/partner_mention_suggestion_tests.js
new file mode 100644
index 00000000000..e9b31a264ef
--- /dev/null
+++ b/addons/mail/static/src/components/partner_mention_suggestion/partner_mention_suggestion_tests.js
@@ -0,0 +1,141 @@
+odoo.define('mail/static/src/components/partner_mention_suggestion/partner_mention_suggestion_tests.js', function (require) {
+'use strict';
+
+const components = {
+ PartnerMentionSuggestion: require('mail/static/src/components/partner_mention_suggestion/partner_mention_suggestion.js'),
+};
+const {
+ afterEach: utilsAfterEach,
+ beforeEach: utilsBeforeEach,
+ start: utilsStart,
+} = require('mail/static/src/utils/test_utils.js');
+
+QUnit.module('mail', {}, function () {
+QUnit.module('components', {}, function () {
+QUnit.module('partner_mention_suggestion', {}, function () {
+QUnit.module('partner_mention_suggestion_tests.js', {
+ beforeEach() {
+ utilsBeforeEach(this);
+ this.createPartnerMentionSuggestion = async partner => {
+ const PartnerMentionSuggestionComponent = components.PartnerMentionSuggestion;
+ PartnerMentionSuggestionComponent.env = this.env;
+ this.component = new PartnerMentionSuggestionComponent(
+ null,
+ {
+ isActive: true,
+ partnerLocalId: partner.localId,
+ });
+ await this.component.mount(this.widget.el);
+ };
+ this.start = async params => {
+ let { env, widget } = await utilsStart(Object.assign({}, params, {
+ data: this.data,
+ }));
+ this.env = env;
+ this.widget = widget;
+ };
+ },
+ afterEach() {
+ utilsAfterEach(this);
+ if (this.component) {
+ this.component.destroy();
+ }
+ if (this.widget) {
+ this.widget.destroy();
+ }
+ this.env = undefined;
+ delete components.PartnerMentionSuggestion.env;
+ },
+});
+
+QUnit.test('partner mention suggestion displayed', async function (assert) {
+ assert.expect(1);
+
+ await this.start();
+ const partner = this.env.models['mail.partner'].create({
+ id: 7,
+ im_status: 'online',
+ name: "Demo User",
+ });
+ await this.createPartnerMentionSuggestion(partner);
+
+ assert.containsOnce(
+ document.body,
+ `.o_PartnerMentionSuggestion`,
+ "Partner mention suggestion should be present"
+ );
+});
+
+QUnit.test('partner mention suggestion correct data', async function (assert) {
+ assert.expect(6);
+
+ await this.start();
+ const partner = this.env.models['mail.partner'].create({
+ email: "demo_user@odoo.com",
+ id: 7,
+ im_status: 'online',
+ name: "Demo User",
+ });
+ await this.createPartnerMentionSuggestion(partner);
+
+ assert.containsOnce(
+ document.body,
+ '.o_PartnerMentionSuggestion',
+ "Partner mention suggestion should be present"
+ );
+ assert.strictEqual(
+ document.querySelectorAll(`.o_PartnerImStatusIcon`).length,
+ 1,
+ "Partner's im_status should be displayed"
+ );
+ assert.containsOnce(
+ document.body,
+ '.o_PartnerMentionSuggestion_name',
+ "Partner's name should be present"
+ );
+ assert.strictEqual(
+ document.querySelector(`.o_PartnerMentionSuggestion_name`).textContent,
+ "Demo User",
+ "Partner's name should be displayed"
+ );
+ assert.containsOnce(
+ document.body,
+ '.o_PartnerMentionSuggestion_email',
+ "Partner's email should be present"
+ );
+ assert.strictEqual(
+ document.querySelector(`.o_PartnerMentionSuggestion_email`).textContent,
+ "(demo_user@odoo.com)",
+ "Partner's email should be displayed"
+ );
+});
+
+QUnit.test('partner mention suggestion active', async function (assert) {
+ assert.expect(2);
+
+ await this.start();
+ const partner = this.env.models['mail.partner'].create({
+ email: "demo_user@odoo.com",
+ id: 7,
+ im_status: 'online',
+ name: "Demo User",
+ });
+ await this.createPartnerMentionSuggestion(partner);
+
+ assert.containsOnce(
+ document.body,
+ '.o_PartnerMentionSuggestion',
+ "Partner mention suggestion should be displayed"
+ );
+ assert.hasClass(
+ document.querySelector('.o_PartnerMentionSuggestion'),
+ 'active',
+ "should be active initially"
+ );
+});
+
+});
+});
+});
+
+});
diff --git a/addons/mail/static/src/components/thread_icon/thread_icon.js b/addons/mail/static/src/components/thread_icon/thread_icon.js
new file mode 100644
index 00000000000..0b82d0c9510
--- /dev/null
+++ b/addons/mail/static/src/components/thread_icon/thread_icon.js
@@ -0,0 +1,54 @@
+odoo.define('mail/static/src/components/thread_icon/thread_icon.js', function (require) {
+'use strict';
+
+const components = {
+ ThreadTypingIcon: require('mail/static/src/components/thread_typing_icon/thread_typing_icon.js'),
+};
+const useStore = require('mail/static/src/component_hooks/use_store/use_store.js');
+
+const { Component } = owl;
+
+class ThreadIcon extends Component {
+
+ /**
+ * @override
+ */
+ constructor(...args) {
+ super(...args);
+ useStore(props => {
+ const thread = this.env.models['mail.thread'].get(props.threadLocalId);
+ const correspondent = thread ? thread.correspondent : undefined;
+ return {
+ correspondent: correspondent ? correspondent.__state : undefined,
+ partnerRoot: this.env.messaging.partnerRoot
+ ? this.env.messaging.partnerRoot.__state
+ : undefined,
+ thread: thread ? thread.__state : undefined,
+ };
+ });
+ }
+
+ //--------------------------------------------------------------------------
+ // Public
+ //--------------------------------------------------------------------------
+
+ /**
+ * @returns {mail.thread}
+ */
+ get thread() {
+ return this.env.models['mail.thread'].get(this.props.threadLocalId);
+ }
+
+}
+
+Object.assign(ThreadIcon, {
+ components,
+ props: {
+ threadLocalId: String,
+ },
+ template: 'mail.ThreadIcon',
+});
+
+return ThreadIcon;
+
+});
diff --git a/addons/mail/static/src/components/thread_icon/thread_icon.scss b/addons/mail/static/src/components/thread_icon/thread_icon.scss
new file mode 100644
index 00000000000..f27b48d0a8b
--- /dev/null
+++ b/addons/mail/static/src/components/thread_icon/thread_icon.scss
@@ -0,0 +1,26 @@
+
+// ------------------------------------------------------------------
+// Layout
+// ------------------------------------------------------------------
+
+.o_ThreadIcon {
+ display: flex;
+ width: 13px;
+ justify-content: center;
+}
+
+.o_ThreadIcon_typing {
+ flex: 1 1 auto;
+}
+
+// ------------------------------------------------------------------
+// Style
+// ------------------------------------------------------------------
+
+.o_ThreadIcon_away {
+ color: theme-color('warning');
+}
+
+.o_ThreadIcon_online {
+ color: $o-enterprise-primary-color;
+}
diff --git a/addons/mail/static/src/components/thread_icon/thread_icon.xml b/addons/mail/static/src/components/thread_icon/thread_icon.xml
new file mode 100644
index 00000000000..95c4694a661
--- /dev/null
+++ b/addons/mail/static/src/components/thread_icon/thread_icon.xml
@@ -0,0 +1,58 @@
+<?xml version="1.0" encoding="UTF-8"?>
+<templates xml:space="preserve">
+
+ <t t-name="mail.ThreadIcon" owl="1">
+ <div class="o_ThreadIcon">
+ <t t-if="thread" name="rootCondition">
+ <t t-if="thread.channel_type === 'channel'">
+ <t t-if="thread.public === 'private'">
+ <!-- AKU TODO: channel of type 'groups' should maybe also have lock icon -->
+ <div class="o_ThreadIcon_channelPrivate fa fa-lock" title="Private channel"/>
+ </t>
+ <t t-else="">
+ <div class="o_ThreadIcon_channelPublic fa fa-hashtag" title="Public channel"/>
+ </t>
+ </t>
+ <t t-elif="thread.channel_type === 'chat' and thread.correspondent">
+ <t t-if="thread.orderedOtherTypingMembers.length > 0">
+ <ThreadTypingIcon
+ class="o_ThreadIcon_typing"
+ animation="'pulse'"
+ title="thread.typingStatusText"
+ />
+ </t>
+ <t t-elif="thread.correspondent.im_status === 'online'">
+ <div class="o_ThreadIcon_online fa fa-circle" title="Online"/>
+ </t>
+ <t t-elif="thread.correspondent.im_status === 'offline'">
+ <div class="o_ThreadIcon_offline fa fa-circle-o" title="Offline"/>
+ </t>
+ <t t-elif="thread.correspondent.im_status === 'away'">
+ <div class="o_ThreadIcon_away fa fa-circle" title="Away"/>
+ </t>
+ <t t-elif="thread.correspondent === env.messaging.partnerRoot">
+ <div class="o_ThreadIcon_online fa fa-heart" title="Bot"/>
+ </t>
+ <t t-else="" name="noImStatusCondition">
+ <div class="o_ThreadIcon_noImStatus fa fa-question-circle" title="No IM status available"/>
+ </t>
+ </t>
+ <t t-elif="thread.model === 'mail.box'">
+ <t t-if="thread === env.messaging.inbox">
+ <div class="o_ThreadIcon_mailboxInbox fa fa-inbox"/>
+ </t>
+ <t t-elif="thread === env.messaging.starred">
+ <div class="o_ThreadIcon_mailboxStarred fa fa-star-o"/>
+ </t>
+ <t t-elif="thread === env.messaging.history">
+ <div class="o_ThreadIcon_mailboxHistory fa fa-history"/>
+ </t>
+ <t t-elif="thread === env.messaging.moderation">
+ <div class="o_ThreadIcon_mailboxModeration fa fa-envelope"/>
+ </t>
+ </t>
+ </t>
+ </div>
+ </t>
+
+</templates>
diff --git a/addons/mail/static/src/components/thread_icon/thread_icon_tests.js b/addons/mail/static/src/components/thread_icon/thread_icon_tests.js
new file mode 100644
index 00000000000..c21aef4c393
--- /dev/null
+++ b/addons/mail/static/src/components/thread_icon/thread_icon_tests.js
@@ -0,0 +1,146 @@
+odoo.define('mail/static/src/components/thread_icon/thread_icon_tests.js', function (require) {
+'use strict';
+
+const components = {
+ ThreadIcon: require('mail/static/src/components/thread_icon/thread_icon.js'),
+};
+const {
+ afterEach: utilsAfterEach,
+ afterNextRender,
+ beforeEach: utilsBeforeEach,
+ start: utilsStart,
+} = require('mail/static/src/utils/test_utils.js');
+
+QUnit.module('mail', {}, function () {
+QUnit.module('components', {}, function () {
+QUnit.module('thread_icon', {}, function () {
+QUnit.module('thread_icon_tests.js', {
+ beforeEach() {
+ utilsBeforeEach(this);
+
+ this.createThreadIcon = async thread => {
+ const ThreadIconComponent = components.ThreadIcon;
+ ThreadIconComponent.env = this.env;
+ this.component = new ThreadIconComponent(null, { threadLocalId: thread.localId });
+ await this.component.mount(this.widget.el);
+ };
+
+ this.start = async params => {
+ let { env, widget } = await utilsStart(Object.assign({}, params, {
+ data: this.data,
+ }));
+ this.env = env;
+ this.widget = widget;
+ };
+
+ },
+ afterEach() {
+ utilsAfterEach(this);
+ if (this.component) {
+ this.component.destroy();
+ }
+ if (this.widget) {
+ this.widget.destroy();
+ }
+ this.env = undefined;
+ delete components.ThreadIcon.env;
+ },
+});
+
+QUnit.test('chat: correspondent is typing', async function (assert) {
+ assert.expect(5);
+
+ Object.assign(this.data.initMessaging, {
+ channel_slots: {
+ channel_direct_message: [{
+ channel_type: 'chat',
+ direct_partner: [{
+ email: 'demo@odoo.com',
+ id: 7,
+ im_status: 'online',
+ name: "Demo",
+ }],
+ id: 20,
+ members: [{
+ email: 'admin@odoo.com',
+ id: 3,
+ name: 'Admin',
+ }, {
+ email: 'demo@odoo.com',
+ id: 7,
+ im_status: 'online',
+ name: 'Demo',
+ }],
+ }],
+ },
+ });
+ await this.start({
+ env: {
+ session: {
+ name: 'Admin',
+ partner_display_name: 'Your Company, Admin',
+ partner_id: 3,
+ uid: 2,
+ },
+ },
+ });
+ const thread = this.env.models['mail.thread'].find(thread =>
+ thread.id === 20 &&
+ thread.model === 'mail.channel'
+ );
+ await this.createThreadIcon(thread);
+
+ assert.containsOnce(
+ document.body,
+ '.o_ThreadIcon',
+ "should have thread icon"
+ );
+ assert.containsOnce(
+ document.body,
+ '.o_ThreadIcon_online',
+ "should have thread icon with partner im status icon 'online'"
+ );
+
+ // simulate receive typing notification from demo "is typing"
+ await afterNextRender(() => {
+ const typingData = {
+ info: 'typing_status',
+ partner_id: 7,
+ is_typing: true,
+ };
+ const notification = [[false, 'mail.channel', 20], typingData];
+ this.widget.call('bus_service', 'trigger', 'notification', [notification]);
+ });
+ assert.containsOnce(
+ document.body,
+ '.o_ThreadIcon_typing',
+ "should have thread icon with partner currently typing"
+ );
+ assert.strictEqual(
+ document.querySelector('.o_ThreadIcon_typing').title,
+ "Demo is typing...",
+ "title of icon should tell demo is currently typing"
+ );
+
+ // simulate receive typing notification from demo "no longer is typing"
+ await afterNextRender(() => {
+ const typingData = {
+ info: 'typing_status',
+ partner_id: 7,
+ is_typing: false,
+ };
+ const notification = [[false, 'mail.channel', 20], typingData];
+ this.widget.call('bus_service', 'trigger', 'notification', [notification]);
+ });
+ assert.containsOnce(
+ document.body,
+ '.o_ThreadIcon_online',
+ "should have thread icon with partner im status icon 'online' (no longer typing)"
+ );
+});
+
+});
+});
+});
+
+});
diff --git a/addons/mail/static/src/components/thread_needaction_preview/thread_needaction_preview.js b/addons/mail/static/src/components/thread_needaction_preview/thread_needaction_preview.js
new file mode 100644
index 00000000000..efcd8f5d86e
--- /dev/null
+++ b/addons/mail/static/src/components/thread_needaction_preview/thread_needaction_preview.js
@@ -0,0 +1,131 @@
+odoo.define('mail/static/src/components/thread_needaction_preview/thread_needaction_preview.js', function (require) {
+'use strict';
+
+const components = {
+ MessageAuthorPrefix: require('mail/static/src/components/message_author_prefix/message_author_prefix.js'),
+ PartnerImStatusIcon: require('mail/static/src/components/partner_im_status_icon/partner_im_status_icon.js'),
+};
+const useStore = require('mail/static/src/component_hooks/use_store/use_store.js');
+const mailUtils = require('mail.utils');
+
+const { Component } = owl;
+const { useRef } = owl.hooks;
+
+class ThreadNeedactionPreview extends Component {
+
+ /**
+ * @override
+ */
+ constructor(...args) {
+ super(...args);
+ useStore(props => {
+ const thread = this.env.models['mail.thread'].get(props.threadLocalId);
+ const mainThreadCache = thread ? thread.mainCache : undefined;
+ let lastNeedactionMessageAuthor;
+ let lastNeedactionMessage;
+ let threadCorrespondent;
+ if (thread) {
+ lastNeedactionMessage = mainThreadCache.lastNeedactionMessage;
+ threadCorrespondent = thread.correspondent;
+ }
+ if (lastNeedactionMessage) {
+ lastNeedactionMessageAuthor = lastNeedactionMessage.author;
+ }
+ return {
+ isDeviceMobile: this.env.messaging.device.isMobile,
+ lastNeedactionMessage: lastNeedactionMessage ? lastNeedactionMessage.__state : undefined,
+ lastNeedactionMessageAuthor: lastNeedactionMessageAuthor
+ ? lastNeedactionMessageAuthor.__state
+ : undefined,
+ thread: thread ? thread.__state : undefined,
+ threadCorrespondent: threadCorrespondent
+ ? threadCorrespondent.__state
+ : undefined,
+ };
+ });
+ /**
+ * Reference of the "mark as read" button. Useful to disable the
+ * top-level click handler when clicking on this specific button.
+ */
+ this._markAsReadRef = useRef('markAsRead');
+ }
+
+ //--------------------------------------------------------------------------
+ // Public
+ //--------------------------------------------------------------------------
+
+ /**
+ * Get the image route of the thread.
+ *
+ * @returns {string}
+ */
+ image() {
+ if (this.thread.moduleIcon) {
+ return this.thread.moduleIcon;
+ }
+ if (this.thread.correspondent) {
+ return `/web/image/res.partner/${this.thread.correspondent.id}/image_128`;
+ }
+ if (this.thread.model === 'mail.channel') {
+ return `/web/image/mail.channel/${this.thread.id}/image_128`;
+ }
+ return '/mail/static/src/img/smiley/avatar.jpg';
+ }
+
+ /**
+ * Get inline content of the last message of this conversation.
+ *
+ * @returns {string}
+ */
+ get inlineLastNeedactionMessageBody() {
+ if (!this.thread.lastNeedactionMessage) {
+ return '';
+ }
+ return mailUtils.htmlToTextContentInline(this.thread.lastNeedactionMessage.prettyBody);
+ }
+
+ /**
+ * @returns {mail.thread}
+ */
+ get thread() {
+ return this.env.models['mail.thread'].get(this.props.threadLocalId);
+ }
+
+ //--------------------------------------------------------------------------
+ // Handlers
+ //--------------------------------------------------------------------------
+
+ /**
+ * @private
+ * @param {MouseEvent} ev
+ */
+ _onClick(ev) {
+ const markAsRead = this._markAsReadRef.el;
+ if (markAsRead && markAsRead.contains(ev.target)) {
+ // handled in `_onClickMarkAsRead`
+ return;
+ }
+ this.trigger('o-select-thread', { thread: this.thread });
+ }
+
+ /**
+ * @private
+ * @param {MouseEvent} ev
+ */
+ _onClickMarkAsRead(ev) {
+ this.thread.markNeedactionMessagesAsRead();
+ }
+
+}
+
+Object.assign(ThreadNeedactionPreview, {
+ components,
+ props: {
+ threadLocalId: String,
+ },
+ template: 'mail.ThreadNeedactionPreview',
+});
+
+return ThreadNeedactionPreview;
+
+});
diff --git a/addons/mail/static/src/components/thread_needaction_preview/thread_needaction_preview.scss b/addons/mail/static/src/components/thread_needaction_preview/thread_needaction_preview.scss
new file mode 100644
index 00000000000..08219402ab4
--- /dev/null
+++ b/addons/mail/static/src/components/thread_needaction_preview/thread_needaction_preview.scss
@@ -0,0 +1,104 @@
+// ------------------------------------------------------------------
+// Layout
+// ------------------------------------------------------------------
+
+.o_ThreadNeedactionPreview {
+ @include o-mail-notification-list-item-layout();
+
+ &:hover .o_ThreadNeedactionPreview_markAsRead {
+ opacity: 1;
+ }
+}
+
+.o_ThreadNeedactionPreview_content {
+ @include o-mail-notification-list-item-content-layout();
+}
+
+.o_ThreadNeedactionPreview_core {
+ @include o-mail-notification-list-item-core-layout();
+}
+
+.o_ThreadNeedactionPreview_coreItem {
+ @include o-mail-notification-list-item-core-item-layout();
+}
+
+.o_ThreadNeedactionPreview_counter {
+ @include o-mail-notification-list-item-counter-layout();
+}
+
+.o_ThreadNeedactionPreview_date {
+ @include o-mail-notification-list-item-date-layout();
+}
+
+.o_ThreadNeedactionPreview_header {
+ @include o-mail-notification-list-item-header-layout();
+}
+
+.o_ThreadNeedactionPreview_image {
+ @include o-mail-notification-list-item-image-layout();
+}
+
+.o_ThreadNeedactionPreview_imageContainer {
+ @include o-mail-notification-list-item-image-container-layout();
+}
+
+.o_ThreadNeedactionPreview_inlineText {
+ @include o-mail-notification-list-item-inline-text-layout();
+}
+
+.o_ThreadNeedactionPreview_markAsRead {
+ @include o-mail-notification-list-item-mark-as-read-layout();
+}
+
+.o_ThreadNeedactionPreview_name {
+ @include o-mail-notification-list-item-name-layout();
+}
+
+.o_ThreadNeedactionPreview_partnerImStatusIcon {
+ @include o-mail-notification-list-item-partner-im-status-icon-layout();
+}
+
+.o_ThreadNeedactionPreview_sidebar {
+ @include o-mail-notification-list-item-sidebar-layout();
+}
+
+// ------------------------------------------------------------------
+// Style
+// ------------------------------------------------------------------
+
+.o_ThreadNeedactionPreview {
+ @include o-mail-notification-list-item-style();
+ background-color: rgba($o-brand-primary, 0.1);
+
+ &:hover {
+ background-color: rgba($o-brand-primary, 0.2);
+
+ .o_ThreadNeedactionPreview_partnerImStatusIcon {
+ @include o-mail-notification-list-item-hover-partner-im-status-icon-style();
+ }
+ }
+}
+
+.o_ThreadNeedactionPreview_core {
+ @include o-mail-notification-list-item-core-style();
+}
+
+.o_ThreadNeedactionPreview_counter {
+ @include o-mail-notification-list-item-bold-style();
+}
+
+.o_ThreadNeedactionPreview_date {
+ @include o-mail-notification-list-item-date-style();
+}
+
+.o_ThreadNeedactionPreview_markAsRead {
+ @include o-mail-notification-list-item-mark-as-read-style();
+}
+
+.o_ThreadNeedactionPreview_name {
+ @include o-mail-notification-list-item-bold-style();
+}
+
+.o_ThreadNeedactionPreview_partnerImStatusIcon {
+ @include o-mail-notification-list-item-partner-im-status-icon-style();
+}
diff --git a/addons/mail/static/src/components/thread_needaction_preview/thread_needaction_preview.xml b/addons/mail/static/src/components/thread_needaction_preview/thread_needaction_preview.xml
new file mode 100644
index 00000000000..82612ed233c
--- /dev/null
+++ b/addons/mail/static/src/components/thread_needaction_preview/thread_needaction_preview.xml
@@ -0,0 +1,58 @@
+<?xml version="1.0" encoding="UTF-8"?>
+<templates xml:space="preserve">
+
+ <t t-name="mail.ThreadNeedactionPreview" owl="1">
+ <!--
+ The preview template is used by the discuss in mobile, and by the systray
+ menu in order to show preview of threads.
+ -->
+ <div class="o_ThreadNeedactionPreview" t-on-click="_onClick" t-att-data-thread-local-id="thread ? thread.localId : undefined">
+ <t t-if="thread">
+ <div class="o_ThreadNeedactionPreview_sidebar">
+ <div class="o_ThreadNeedactionPreview_imageContainer o_ThreadNeedactionPreview_sidebarItem">
+ <img class="o_ThreadNeedactionPreview_image" t-att-src="image()" alt="Thread Image"/>
+ <t t-if="thread.correspondent and thread.correspondent.im_status">
+ <PartnerImStatusIcon
+ class="o_ThreadNeedactionPreview_partnerImStatusIcon"
+ t-att-class="{
+ 'o-mobile': env.messaging.device.isMobile,
+ }"
+ partnerLocalId="thread.correspondent.localId"
+ />
+ </t>
+ </div>
+ </div>
+ <div class="o_ThreadNeedactionPreview_content">
+ <div class="o_ThreadNeedactionPreview_header">
+ <span class="o_ThreadNeedactionPreview_name" t-att-class="{ 'o-mobile': env.messaging.device.isMobile }">
+ <t t-esc="thread.displayName"/>
+ </span>
+ <span class="o_ThreadNeedactionPreview_counter">
+ (<t t-esc="thread.needactionMessages.length"/>)
+ </span>
+ <span class="o-autogrow"/>
+ <t t-if="thread.lastNeedactionMessage">
+ <span class="o_ThreadNeedactionPreview_date">
+ <t t-esc="thread.lastNeedactionMessage.date.fromNow()"/>
+ </span>
+ </t>
+ </div>
+ <div class="o_ThreadNeedactionPreview_core">
+ <span class="o_ThreadNeedactionPreview_coreItem o_ThreadNeedactionPreview_inlineText" t-att-class="{ 'o-empty': inlineLastNeedactionMessageBody.length === 0 }">
+ <t t-if="thread.lastMessage and thread.lastMessage.author">
+ <MessageAuthorPrefix
+ messageLocalId="thread.lastMessage.localId"
+ threadLocalId="thread.localId"
+ />
+ </t>
+ <t t-raw="inlineLastNeedactionMessageBody"/>
+ </span>
+ <span class="o-autogrow"/>
+ <span class="o_ThreadNeedactionPreview_coreItem o_ThreadNeedactionPreview_markAsRead fa fa-check" title="Mark as Read" t-on-click="_onClickMarkAsRead"/>
+ </div>
+ </div>
+ </t>
+ </div>
+ </t>
+
+</templates>
diff --git a/addons/mail/static/src/components/thread_preview/thread_preview.js b/addons/mail/static/src/components/thread_preview/thread_preview.js
new file mode 100644
index 00000000000..f2bbfabe3f9
--- /dev/null
+++ b/addons/mail/static/src/components/thread_preview/thread_preview.js
@@ -0,0 +1,124 @@
+odoo.define('mail/static/src/components/thread_preview/thread_preview.js', function (require) {
+'use strict';
+
+const components = {
+ MessageAuthorPrefix: require('mail/static/src/components/message_author_prefix/message_author_prefix.js'),
+ PartnerImStatusIcon: require('mail/static/src/components/partner_im_status_icon/partner_im_status_icon.js'),
+};
+const useStore = require('mail/static/src/component_hooks/use_store/use_store.js');
+const mailUtils = require('mail.utils');
+
+const { Component } = owl;
+const { useRef } = owl.hooks;
+
+class ThreadPreview extends Component {
+
+ /**
+ * @override
+ */
+ constructor(...args) {
+ super(...args);
+ useStore(props => {
+ const thread = this.env.models['mail.thread'].get(props.threadLocalId);
+ const mainThreadCache = thread ? thread.mainCache : undefined;
+ let lastMessageAuthor;
+ let lastMessage;
+ if (thread) {
+ const orderedMessages = mainThreadCache.orderedMessages;
+ lastMessage = orderedMessages[orderedMessages.length - 1];
+ }
+ if (lastMessage) {
+ lastMessageAuthor = lastMessage.author;
+ }
+ return {
+ isDeviceMobile: this.env.messaging.device.isMobile,
+ lastMessage: lastMessage ? lastMessage.__state : undefined,
+ lastMessageAuthor: lastMessageAuthor
+ ? lastMessageAuthor.__state
+ : undefined,
+ thread: thread ? thread.__state : undefined,
+ threadCorrespondent: thread && thread.correspondent
+ ? thread.correspondent.__state
+ : undefined,
+ };
+ });
+ /**
+ * Reference of the "mark as read" button. Useful to disable the
+ * top-level click handler when clicking on this specific button.
+ */
+ this._markAsReadRef = useRef('markAsRead');
+ }
+
+ //--------------------------------------------------------------------------
+ // Public
+ //--------------------------------------------------------------------------
+
+ /**
+ * Get the image route of the thread.
+ *
+ * @returns {string}
+ */
+ image() {
+ if (this.thread.correspondent) {
+ return `/web/image/res.partner/${this.thread.correspondent.id}/image_128`;
+ }
+ return `/web/image/mail.channel/${this.thread.id}/image_128`;
+ }
+
+ /**
+ * Get inline content of the last message of this conversation.
+ *
+ * @returns {string}
+ */
+ get inlineLastMessageBody() {
+ if (!this.thread.lastMessage) {
+ return '';
+ }
+ return mailUtils.htmlToTextContentInline(this.thread.lastMessage.prettyBody);
+ }
+
+ /**
+ * @returns {mail.thread}
+ */
+ get thread() {
+ return this.env.models['mail.thread'].get(this.props.threadLocalId);
+ }
+
+ //--------------------------------------------------------------------------
+ // Handlers
+ //--------------------------------------------------------------------------
+
+ /**
+ * @private
+ * @param {MouseEvent} ev
+ */
+ _onClick(ev) {
+ const markAsRead = this._markAsReadRef.el;
+ if (markAsRead && markAsRead.contains(ev.target)) {
+ // handled in `_onClickMarkAsRead`
+ return;
+ }
+ this.trigger('o-select-thread', { thread: this.thread });
+ }
+
+ /**
+ * @private
+ * @param {MouseEvent} ev
+ */
+ _onClickMarkAsRead(ev) {
+ this.thread.markAsSeen();
+ }
+
+}
+
+Object.assign(ThreadPreview, {
+ components,
+ props: {
+ threadLocalId: String,
+ },
+ template: 'mail.ThreadPreview',
+});
+
+return ThreadPreview;
+
+});
diff --git a/addons/mail/static/src/components/thread_preview/thread_preview.scss b/addons/mail/static/src/components/thread_preview/thread_preview.scss
new file mode 100644
index 00000000000..7e7f8abdf1a
--- /dev/null
+++ b/addons/mail/static/src/components/thread_preview/thread_preview.scss
@@ -0,0 +1,113 @@
+// ------------------------------------------------------------------
+// Layout
+// ------------------------------------------------------------------
+
+.o_ThreadPreview {
+ @include o-mail-notification-list-item-layout();
+
+ &:hover .o_ThreadPreview_markAsRead {
+ opacity: 1;
+ }
+}
+
+.o_ThreadPreview_content {
+ @include o-mail-notification-list-item-content-layout();
+}
+
+.o_ThreadPreview_core {
+ @include o-mail-notification-list-item-core-layout();
+}
+
+.o_ThreadPreview_coreItem {
+ @include o-mail-notification-list-item-core-item-layout();
+}
+
+.o_ThreadPreview_counter {
+ @include o-mail-notification-list-item-counter-layout();
+}
+
+.o_ThreadPreview_date {
+ @include o-mail-notification-list-item-date-layout();
+}
+
+.o_ThreadPreview_header {
+ @include o-mail-notification-list-item-header-layout();
+}
+
+.o_ThreadPreview_image {
+ @include o-mail-notification-list-item-image-layout();
+}
+
+.o_ThreadPreview_imageContainer {
+ @include o-mail-notification-list-item-image-container-layout();
+}
+
+.o_ThreadPreview_inlineText {
+ @include o-mail-notification-list-item-inline-text-layout();
+}
+
+.o_ThreadPreview_markAsRead {
+ @include o-mail-notification-list-item-mark-as-read-layout();
+}
+
+.o_ThreadPreview_name {
+ @include o-mail-notification-list-item-name-layout();
+}
+
+.o_ThreadPreview_partnerImStatusIcon {
+ @include o-mail-notification-list-item-partner-im-status-icon-layout();
+}
+
+.o_ThreadPreview_sidebar {
+ @include o-mail-notification-list-item-sidebar-layout();
+}
+
+// ------------------------------------------------------------------
+// Style
+// ------------------------------------------------------------------
+
+.o_ThreadPreview {
+ @include o-mail-notification-list-item-style();
+
+ &:hover {
+ .o_ThreadPreview_partnerImStatusIcon {
+ @include o-mail-notification-list-item-hover-partner-im-status-icon-style();
+ }
+ }
+
+ &.o-muted {
+ &:hover {
+ .o_ThreadPreview_partnerImStatusIcon {
+ @include o-mail-notification-list-item-muted-hover-partner-im-status-icon-style();
+ }
+ }
+ }
+}
+
+.o_ThreadPreview_core {
+ @include o-mail-notification-list-item-core-style();
+}
+
+.o_ThreadPreview_counter {
+ @include o-mail-notification-list-item-bold-style();
+}
+
+.o_ThreadPreview_date {
+ @include o-mail-notification-list-item-date-style();
+
+ &.o-muted {
+ color: gray('500');
+ }
+}
+
+.o_ThreadPreview_markAsRead {
+ @include o-mail-notification-list-item-mark-as-read-style();
+}
+
+.o_ThreadPreview_name {
+ @include o-mail-notification-list-item-bold-style();
+}
+
+.o_ThreadPreview_partnerImStatusIcon {
+ @include o-mail-notification-list-item-partner-im-status-icon-style();
+}
diff --git a/addons/mail/static/src/components/thread_preview/thread_preview.xml b/addons/mail/static/src/components/thread_preview/thread_preview.xml
new file mode 100644
index 00000000000..ded9c6fadac
--- /dev/null
+++ b/addons/mail/static/src/components/thread_preview/thread_preview.xml
@@ -0,0 +1,63 @@
+<?xml version="1.0" encoding="UTF-8"?>
+<templates xml:space="preserve">
+
+ <t t-name="mail.ThreadPreview" owl="1">
+ <!--
+ The preview template is used by the discuss in mobile, and by the systray
+ menu in order to show preview of threads.
+ -->
+ <div class="o_ThreadPreview" t-att-class="{ 'o-muted': thread and thread.message_unread_counter === 0 }" t-on-click="_onClick" t-att-data-thread-local-id="thread ? thread.localId : undefined">
+ <t t-if="thread">
+ <div class="o_ThreadPreview_sidebar">
+ <div class="o_ThreadPreview_imageContainer o_ThreadPreview_sidebarItem">
+ <img class="o_ThreadPreview_image rounded-circle" t-att-src="image()" alt="Thread Image"/>
+ <t t-if="thread.correspondent and thread.correspondent.im_status">
+ <PartnerImStatusIcon
+ class="o_ThreadPreview_partnerImStatusIcon"
+ t-att-class="{
+ 'o-mobile': env.messaging.device.isMobile,
+ 'o-muted': thread.message_unread_counter === 0,
+ }"
+ partnerLocalId="thread.correspondent.localId"
+ />
+ </t>
+ </div>
+ </div>
+ <div class="o_ThreadPreview_content">
+ <div class="o_ThreadPreview_header">
+ <span class="o_ThreadPreview_name" t-att-class="{ 'o-mobile': env.messaging.device.isMobile, 'o-muted': thread.message_unread_counter === 0 }">
+ <t t-esc="thread.displayName"/>
+ </span>
+ <t t-if="thread.message_unread_counter > 0">
+ <span class="o_ThreadPreview_counter" t-att-class="{ 'o-muted': thread.message_unread_counter === 0 }">
+ (<t t-esc="thread.message_unread_counter"/>)
+ </span>
+ </t>
+ <span class="o-autogrow"/>
+ <t t-if="thread.lastMessage">
+ <span class="o_ThreadPreview_date" t-att-class="{ 'o-muted': thread.message_unread_counter === 0 }">
+ <t t-esc="thread.lastMessage.date.fromNow()"/>
+ </span>
+ </t>
+ </div>
+ <div class="o_ThreadPreview_core">
+ <span class="o_ThreadPreview_coreItem o_ThreadPreview_inlineText" t-att-class="{ 'o-empty': inlineLastMessageBody.length === 0 }">
+ <t t-if="thread.lastMessage and thread.lastMessage.author">
+ <MessageAuthorPrefix
+ messageLocalId="thread.lastMessage.localId"
+ threadLocalId="thread.localId"
+ />
+ </t>
+ <t t-raw="inlineLastMessageBody"/>
+ </span>
+ <span class="o-autogrow"/>
+ <t t-if="thread.message_unread_counter > 0">
+ <span class="o_ThreadPreview_coreItem o_ThreadPreview_markAsRead fa fa-check" title="Mark as Read" t-on-click="_onClickMarkAsRead"/>
+ </t>
+ </div>
+ </div>
+ </t>
+ </div>
+ </t>
+
+</templates>
diff --git a/addons/mail/static/src/components/thread_textual_typing_status/thread_textual_typing_status.js b/addons/mail/static/src/components/thread_textual_typing_status/thread_textual_typing_status.js
new file mode 100644
index 00000000000..94f8c2b95bb
--- /dev/null
+++ b/addons/mail/static/src/components/thread_textual_typing_status/thread_textual_typing_status.js
@@ -0,0 +1,49 @@
+odoo.define('mail/static/src/components/thread_textual_typing_status/thread_textual_typing_status.js', function (require) {
+'use strict';
+
+const components = {
+ ThreadTypingIcon: require('mail/static/src/components/thread_typing_icon/thread_typing_icon.js'),
+};
+const useStore = require('mail/static/src/component_hooks/use_store/use_store.js');
+
+const { Component } = owl;
+
+class ThreadTextualTypingStatus extends Component {
+
+ /**
+ * @override
+ */
+ constructor(...args) {
+ super(...args);
+ useStore(props => {
+ const thread = this.env.models['mail.thread'].get(props.threadLocalId);
+ return {
+ thread: thread ? thread.__state : undefined,
+ };
+ });
+ }
+
+ //--------------------------------------------------------------------------
+ // Public
+ //--------------------------------------------------------------------------
+
+ /**
+ * @returns {mail.thread}
+ */
+ get thread() {
+ return this.env.models['mail.thread'].get(this.props.threadLocalId);
+ }
+
+}
+
+Object.assign(ThreadTextualTypingStatus, {
+ components,
+ props: {
+ threadLocalId: String,
+ },
+ template: 'mail.ThreadTextualTypingStatus',
+});
+
+return ThreadTextualTypingStatus;
+
+});
diff --git a/addons/mail/static/src/components/thread_textual_typing_status/thread_textual_typing_status.scss b/addons/mail/static/src/components/thread_textual_typing_status/thread_textual_typing_status.scss
new file mode 100644
index 00000000000..4cb9e1cfc3f
--- /dev/null
+++ b/addons/mail/static/src/components/thread_textual_typing_status/thread_textual_typing_status.scss
@@ -0,0 +1,12 @@
+
+// ------------------------------------------------------------------
+// Layout
+// ------------------------------------------------------------------
+
+.o_ThreadTextualTypingStatus {
+ display: flex;
+}
+
+.o_ThreadTextualTypingStatus_separator {
+ width: map-get($spacers, 1);
+}
diff --git a/addons/mail/static/src/components/thread_textual_typing_status/thread_textual_typing_status.xml b/addons/mail/static/src/components/thread_textual_typing_status/thread_textual_typing_status.xml
new file mode 100644
index 00000000000..722d07389f8
--- /dev/null
+++ b/addons/mail/static/src/components/thread_textual_typing_status/thread_textual_typing_status.xml
@@ -0,0 +1,14 @@
+<?xml version="1.0" encoding="UTF-8"?>
+<templates xml:space="preserve">
+
+ <t t-name="mail.ThreadTextualTypingStatus" owl="1">
+ <div class="o_ThreadTextualTypingStatus">
+ <t t-if="thread and thread.orderedOtherTypingMembers.length > 0">
+ <ThreadTypingIcon animation="'pulse'" size="'medium'"/>
+ <span class="o_ThreadTextualTypingStatus_separator"/>
+ <span t-esc="thread.typingStatusText"/>
+ </t>
+ </div>
+ </t>
+
+</templates>
diff --git a/addons/mail/static/src/components/thread_textual_typing_status/thread_textual_typing_status_tests.js b/addons/mail/static/src/components/thread_textual_typing_status/thread_textual_typing_status_tests.js
new file mode 100644
index 00000000000..2d4665e4920
--- /dev/null
+++ b/addons/mail/static/src/components/thread_textual_typing_status/thread_textual_typing_status_tests.js
@@ -0,0 +1,480 @@
+odoo.define('mail/static/src/components/thread_textual_typing_status/thread_textual_typing_status_tests.js', function (require) {
+'use strict';
+
+const components = {
+ ThreadTextualTypingStatus: require('mail/static/src/components/thread_textual_typing_status/thread_textual_typing_status.js'),
+};
+const {
+ afterEach: utilsAfterEach,
+ afterNextRender,
+ beforeEach: utilsBeforeEach,
+ nextAnimationFrame,
+ start: utilsStart,
+} = require('mail/static/src/utils/test_utils.js');
+
+QUnit.module('mail', {}, function () {
+QUnit.module('components', {}, function () {
+QUnit.module('thread_textual_typing_status', {}, function () {
+QUnit.module('thread_textual_typing_status_tests.js', {
+ beforeEach() {
+ utilsBeforeEach(this);
+
+ this.createThreadTextualTypingStatusComponent = async thread => {
+ const ThreadTextualTypingStatusComponent = components.ThreadTextualTypingStatus;
+ ThreadTextualTypingStatusComponent.env = this.env;
+ this.component = new ThreadTextualTypingStatusComponent(null, {
+ threadLocalId: thread.localId,
+ });
+ await afterNextRender(() => this.component.mount(this.widget.el));
+ };
+
+ 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;
+ };
+ },
+ async afterEach() {
+ utilsAfterEach(this);
+ if (this.component) {
+ this.component.destroy();
+ }
+ if (this.widget) {
+ this.widget.destroy();
+ }
+ this.env = undefined;
+ delete components.ThreadTextualTypingStatus.env;
+ },
+});
+
+QUnit.test('receive other member typing status "is typing"', async function (assert) {
+ assert.expect(2);
+
+ Object.assign(this.data.initMessaging, {
+ channel_slots: {
+ channel_channel: [{
+ channel_type: 'channel',
+ id: 20,
+ members: [{
+ email: 'admin@odoo.com',
+ id: 3,
+ name: 'Admin',
+ }, {
+ email: 'demo@odoo.com',
+ id: 7,
+ name: 'Demo',
+ }],
+ name: "General",
+ }],
+ },
+ });
+ await this.start({
+ env: {
+ session: {
+ name: 'Admin',
+ partner_display_name: 'Your Company, Admin',
+ partner_id: 3,
+ uid: 2,
+ },
+ },
+ });
+ const thread = this.env.models['mail.thread'].find(thread =>
+ thread.id === 20 &&
+ thread.model === 'mail.channel'
+ );
+ await this.createThreadTextualTypingStatusComponent(thread);
+
+ assert.strictEqual(
+ document.querySelector('.o_ThreadTextualTypingStatus').textContent,
+ "",
+ "Should display no one is currently typing"
+ );
+
+ // simulate receive typing notification from demo
+ await afterNextRender(() => {
+ const typingData = {
+ info: 'typing_status',
+ partner_id: 7,
+ is_typing: true,
+ };
+ const notification = [[false, 'mail.channel', 20], typingData];
+ this.widget.call('bus_service', 'trigger', 'notification', [notification]);
+ });
+ assert.strictEqual(
+ document.querySelector('.o_ThreadTextualTypingStatus').textContent,
+ "Demo is typing...",
+ "Should display that demo user is typing"
+ );
+});
+
+QUnit.test('receive other member typing status "is typing" then "no longer is typing"', async function (assert) {
+ assert.expect(3);
+
+ Object.assign(this.data.initMessaging, {
+ channel_slots: {
+ channel_channel: [{
+ channel_type: 'channel',
+ id: 20,
+ members: [{
+ email: 'admin@odoo.com',
+ id: 3,
+ name: 'Admin',
+ }, {
+ email: 'demo@odoo.com',
+ id: 7,
+ name: 'Demo',
+ }],
+ name: "General",
+ }],
+ },
+ });
+ await this.start({
+ env: {
+ session: {
+ name: 'Admin',
+ partner_display_name: 'Your Company, Admin',
+ partner_id: 3,
+ uid: 2,
+ },
+ },
+ });
+ const thread = this.env.models['mail.thread'].find(thread =>
+ thread.id === 20 &&
+ thread.model === 'mail.channel'
+ );
+ await this.createThreadTextualTypingStatusComponent(thread);
+
+ assert.strictEqual(
+ document.querySelector('.o_ThreadTextualTypingStatus').textContent,
+ "",
+ "Should display no one is currently typing"
+ );
+
+ // simulate receive typing notification from demo "is typing"
+ await afterNextRender(() => {
+ const typingData = {
+ info: 'typing_status',
+ partner_id: 7,
+ is_typing: true,
+ };
+ const notification = [[false, 'mail.channel', 20], typingData];
+ this.widget.call('bus_service', 'trigger', 'notification', [notification]);
+ });
+ assert.strictEqual(
+ document.querySelector('.o_ThreadTextualTypingStatus').textContent,
+ "Demo is typing...",
+ "Should display that demo user is typing"
+ );
+
+ // simulate receive typing notification from demo "is no longer typing"
+ await afterNextRender(() => {
+ const typingData = {
+ info: 'typing_status',
+ partner_id: 7,
+ is_typing: false,
+ };
+ const notification = [[false, 'mail.channel', 20], typingData];
+ this.widget.call('bus_service', 'trigger', 'notification', [notification]);
+ });
+ assert.strictEqual(
+ document.querySelector('.o_ThreadTextualTypingStatus').textContent,
+ "",
+ "Should no longer display that demo user is typing"
+ );
+});
+
+QUnit.test('assume other member typing status becomes "no longer is typing" after 60 seconds without any updated typing status', async function (assert) {
+ assert.expect(3);
+
+ Object.assign(this.data.initMessaging, {
+ channel_slots: {
+ channel_channel: [{
+ channel_type: 'channel',
+ id: 20,
+ members: [{
+ email: 'admin@odoo.com',
+ id: 3,
+ name: 'Admin',
+ }, {
+ email: 'demo@odoo.com',
+ id: 7,
+ name: 'Demo',
+ }],
+ name: "General",
+ }],
+ },
+ });
+ await this.start({
+ env: {
+ session: {
+ name: 'Admin',
+ partner_display_name: 'Your Company, Admin',
+ partner_id: 3,
+ uid: 2,
+ },
+ },
+ hasTimeControl: true,
+ });
+ const thread = this.env.models['mail.thread'].find(thread =>
+ thread.id === 20 &&
+ thread.model === 'mail.channel'
+ );
+ await this.createThreadTextualTypingStatusComponent(thread);
+
+ assert.strictEqual(
+ document.querySelector('.o_ThreadTextualTypingStatus').textContent,
+ "",
+ "Should display no one is currently typing"
+ );
+
+ // simulate receive typing notification from demo "is typing"
+ await afterNextRender(() => {
+ const typingData = {
+ info: 'typing_status',
+ partner_id: 7,
+ is_typing: true,
+ };
+ const notification = [[false, 'mail.channel', 20], typingData];
+ this.widget.call('bus_service', 'trigger', 'notification', [notification]);
+ });
+ assert.strictEqual(
+ document.querySelector('.o_ThreadTextualTypingStatus').textContent,
+ "Demo is typing...",
+ "Should display that demo user is typing"
+ );
+
+ await afterNextRender(() => this.env.testUtils.advanceTime(60 * 1000));
+ assert.strictEqual(
+ document.querySelector('.o_ThreadTextualTypingStatus').textContent,
+ "",
+ "Should no longer display that demo user is typing"
+ );
+});
+
+QUnit.test ('other member typing status "is typing" refreshes 60 seconds timer of assuming no longer typing', async function (assert) {
+ assert.expect(4);
+
+ Object.assign(this.data.initMessaging, {
+ channel_slots: {
+ channel_channel: [{
+ channel_type: 'channel',
+ id: 20,
+ members: [{
+ email: 'admin@odoo.com',
+ id: 3,
+ name: 'Admin',
+ }, {
+ email: 'demo@odoo.com',
+ id: 7,
+ name: 'Demo',
+ }],
+ name: "General",
+ }],
+ },
+ });
+ await this.start({
+ env: {
+ session: {
+ name: 'Admin',
+ partner_display_name: 'Your Company, Admin',
+ partner_id: 3,
+ uid: 2,
+ },
+ },
+ hasTimeControl: true,
+ });
+ const thread = this.env.models['mail.thread'].find(thread =>
+ thread.id === 20 &&
+ thread.model === 'mail.channel'
+ );
+ await this.createThreadTextualTypingStatusComponent(thread);
+
+ assert.strictEqual(
+ document.querySelector('.o_ThreadTextualTypingStatus').textContent,
+ "",
+ "Should display no one is currently typing"
+ );
+
+ // simulate receive typing notification from demo "is typing"
+ await afterNextRender(() => {
+ const typingData = {
+ info: 'typing_status',
+ partner_id: 7,
+ is_typing: true,
+ };
+ const notification = [[false, 'mail.channel', 20], typingData];
+ this.widget.call('bus_service', 'trigger', 'notification', [notification]);
+ });
+ assert.strictEqual(
+ document.querySelector('.o_ThreadTextualTypingStatus').textContent,
+ "Demo is typing...",
+ "Should display that demo user is typing"
+ );
+
+ // simulate receive typing notification from demo "is typing" again after 50s.
+ await this.env.testUtils.advanceTime(50 * 1000);
+ const typingData = {
+ info: 'typing_status',
+ partner_id: 7,
+ is_typing: true,
+ };
+ const notification = [[false, 'mail.channel', 20], typingData];
+ this.widget.call('bus_service', 'trigger', 'notification', [notification]);
+ await this.env.testUtils.advanceTime(50 * 1000);
+ await nextAnimationFrame();
+ assert.strictEqual(
+ document.querySelector('.o_ThreadTextualTypingStatus').textContent,
+ "Demo is typing...",
+ "Should still display that demo user is typing after 100 seconds (refreshed is typing status at 50s => (100 - 50) = 50s < 60s after assuming no-longer typing)"
+ );
+
+ await afterNextRender(() => this.env.testUtils.advanceTime(11 * 1000));
+ assert.strictEqual(
+ document.querySelector('.o_ThreadTextualTypingStatus').textContent,
+ "",
+ "Should no longer display that demo user is typing after 111 seconds (refreshed is typing status at 50s => (111 - 50) = 61s > 60s after assuming no-longer typing)"
+ );
+});
+
+QUnit.test('receive several other members typing status "is typing"', async function (assert) {
+ assert.expect(6);
+
+ Object.assign(this.data.initMessaging, {
+ channel_slots: {
+ channel_channel: [{
+ channel_type: 'channel',
+ id: 20,
+ members: [{
+ email: 'admin@odoo.com',
+ id: 3,
+ name: 'Admin',
+ }, {
+ email: 'other10@odoo.com',
+ id: 10,
+ name: 'Other10',
+ }, {
+ email: 'other11@odoo.com',
+ id: 11,
+ name: 'Other11',
+ }, {
+ email: 'other12@odoo.com',
+ id: 12,
+ name: 'Other12',
+ }],
+ name: "General",
+ }],
+ },
+ });
+ await this.start({
+ env: {
+ session: {
+ name: 'Admin',
+ partner_display_name: 'Your Company, Admin',
+ partner_id: 3,
+ uid: 2,
+ },
+ },
+ });
+ const thread = this.env.models['mail.thread'].find(thread =>
+ thread.id === 20 &&
+ thread.model === 'mail.channel'
+ );
+ await this.createThreadTextualTypingStatusComponent(thread);
+
+ assert.strictEqual(
+ document.querySelector('.o_ThreadTextualTypingStatus').textContent,
+ "",
+ "Should display no one is currently typing"
+ );
+
+ // simulate receive typing notification from other10 (is typing)
+ await afterNextRender(() => {
+ const typingData = {
+ info: 'typing_status',
+ partner_id: 10,
+ is_typing: true,
+ };
+ const notification = [[false, 'mail.channel', 20], typingData];
+ this.widget.call('bus_service', 'trigger', 'notification', [notification]);
+ });
+ assert.strictEqual(
+ document.querySelector('.o_ThreadTextualTypingStatus').textContent,
+ "Other10 is typing...",
+ "Should display that 'Other10' member is typing"
+ );
+
+ // simulate receive typing notification from other11 (is typing)
+ await afterNextRender(() => {
+ const typingData = {
+ info: 'typing_status',
+ partner_id: 11,
+ is_typing: true,
+ };
+ const notification = [[false, 'mail.channel', 20], typingData];
+ this.widget.call('bus_service', 'trigger', 'notification', [notification]);
+ });
+ assert.strictEqual(
+ document.querySelector('.o_ThreadTextualTypingStatus').textContent,
+ "Other10 and Other11 are typing...",
+ "Should display that members 'Other10' and 'Other11' are typing (order: longer typer named first)"
+ );
+
+ // simulate receive typing notification from other12 (is typing)
+ await afterNextRender(() => {
+ const typingData = {
+ info: 'typing_status',
+ partner_id: 12,
+ is_typing: true,
+ };
+ const notification = [[false, 'mail.channel', 20], typingData];
+ this.widget.call('bus_service', 'trigger', 'notification', [notification]);
+ });
+ assert.strictEqual(
+ document.querySelector('.o_ThreadTextualTypingStatus').textContent,
+ "Other10, Other11 and more are typing...",
+ "Should display that members 'Other10', 'Other11' and more (at least 1 extra member) are typing (order: longer typer named first)"
+ );
+
+ // simulate receive typing notification from other10 (no longer is typing)
+ await afterNextRender(() => {
+ const typingData = {
+ info: 'typing_status',
+ partner_id: 10,
+ is_typing: false,
+ };
+ const notification = [[false, 'mail.channel', 20], typingData];
+ this.widget.call('bus_service', 'trigger', 'notification', [notification]);
+ });
+ assert.strictEqual(
+ document.querySelector('.o_ThreadTextualTypingStatus').textContent,
+ "Other11 and Other12 are typing...",
+ "Should display that members 'Other11' and 'Other12' are typing ('Other10' stopped typing)"
+ );
+
+ // simulate receive typing notification from other10 (is typing again)
+ await afterNextRender(() => {
+ const typingData = {
+ info: 'typing_status',
+ partner_id: 10,
+ is_typing: true,
+ };
+ const notification = [[false, 'mail.channel', 20], typingData];
+ this.widget.call('bus_service', 'trigger', 'notification', [notification]);
+ });
+ assert.strictEqual(
+ document.querySelector('.o_ThreadTextualTypingStatus').textContent,
+ "Other11, Other12 and more are typing...",
+ "Should display that members 'Other11' and 'Other12' and more (at least 1 extra member) are typing (order by longer typer, 'Other10' just recently restarted typing)"
+ );
+});
+
+});
+});
+});
+
+});
diff --git a/addons/mail/static/src/components/thread_typing_icon/thread_typing_icon.js b/addons/mail/static/src/components/thread_typing_icon/thread_typing_icon.js
new file mode 100644
index 00000000000..95caee984d4
--- /dev/null
+++ b/addons/mail/static/src/components/thread_typing_icon/thread_typing_icon.js
@@ -0,0 +1,32 @@
+odoo.define('mail/static/src/components/thread_typing_icon/thread_typing_icon.js', function (require) {
+'use strict';
+
+const { Component } = owl;
+
+class ThreadTypingIcon extends Component {}
+
+Object.assign(ThreadTypingIcon, {
+ defaultProps: {
+ animation: 'none',
+ size: 'small',
+ },
+ props: {
+ animation: {
+ type: String,
+ validate: prop => ['bounce', 'none', 'pulse'].includes(prop),
+ },
+ size: {
+ type: String,
+ validate: prop => ['small', 'medium'].includes(prop),
+ },
+ title: {
+ type: String,
+ optional: true,
+ }
+ },
+ template: 'mail.ThreadTypingIcon',
+});
+
+return ThreadTypingIcon;
+
+});
diff --git a/addons/mail/static/src/components/thread_typing_icon/thread_typing_icon.scss b/addons/mail/static/src/components/thread_typing_icon/thread_typing_icon.scss
new file mode 100644
index 00000000000..ac3c5b2fb83
--- /dev/null
+++ b/addons/mail/static/src/components/thread_typing_icon/thread_typing_icon.scss
@@ -0,0 +1,108 @@
+// ------------------------------------------------------------------
+// Variables
+// ------------------------------------------------------------------
+
+$o-thread-typing-icon-size-medium: 5px;
+$o-thread-typing-icon-size-small: 3px;
+
+// ------------------------------------------------------------------
+// Layout
+// ------------------------------------------------------------------
+
+.o_ThreadTypingIcon {
+ display: flex;
+ align-items: center;
+}
+
+.o_ThreadTypingIcon_dot {
+ display: flex;
+ flex: 0 0 auto;
+}
+
+.o_ThreadTypingIcon_separator {
+ min-width: 1px;
+ flex: 1 0 auto;
+}
+
+// ------------------------------------------------------------------
+// Style
+// ------------------------------------------------------------------
+
+.o_ThreadTypingIcon_dot {
+ border-radius: 50%;
+ background: gray('500');
+
+ &.o-sizeMedium {
+ width: $o-thread-typing-icon-size-medium;
+ height: $o-thread-typing-icon-size-medium;
+ }
+
+ &.o-sizeSmall {
+ width: $o-thread-typing-icon-size-small;
+ height: $o-thread-typing-icon-size-small;
+ }
+}
+
+// ------------------------------------------------------------------
+// Animation
+// ------------------------------------------------------------------
+
+.o_ThreadTypingIcon_dot.o-animationBounce {
+
+ // Note: duplicated animation because dependent on size, and current SASS version doesn't support var()
+ &.o-sizeMedium {
+ animation: o_ThreadTypingIcon_dot_animationBounce_sizeMedium_animation 1.5s linear infinite;
+ }
+
+ &.o-sizeSmall {
+ animation: o_ThreadTypingIcon_dot_animationBounce_sizeSmall_animation 1.5s linear infinite;
+ }
+
+ &.o_ThreadTypingIcon_dot2 {
+ animation-delay: -1.35s;
+ }
+
+ &.o_ThreadTypingIcon_dot3 {
+ animation-delay: -1.2s;
+ }
+}
+
+.o_ThreadTypingIcon_dot.o-animationPulse {
+ animation: o_ThreadTypingIcon_dot_animationPulse_animation 1.5s linear infinite;
+
+ &.o_ThreadTypingIcon_dot2 {
+ animation-delay: -1.35s;
+ }
+
+ &.o_ThreadTypingIcon_dot3 {
+ animation-delay: -1.2s;
+ }
+}
+
+@keyframes o_ThreadTypingIcon_dot_animationBounce_sizeMedium_animation {
+ 0%, 40%, 100% {
+ transform: initial;
+ }
+ 20% {
+ transform: translateY(-$o-thread-typing-icon-size-medium);
+ }
+}
+
+@keyframes o_ThreadTypingIcon_dot_animationBounce_sizeSmall_animation {
+ 0%, 40%, 100% {
+ transform: initial;
+ }
+ 20% {
+ transform: translateY(-$o-thread-typing-icon-size-small);
+ }
+}
+
+
+@keyframes o_ThreadTypingIcon_dot_animationPulse_animation {
+ 0%, 40%, 100% {
+ opacity: initial;
+ }
+ 20% {
+ opacity: 25%;
+ }
+}
diff --git a/addons/mail/static/src/components/thread_typing_icon/thread_typing_icon.xml b/addons/mail/static/src/components/thread_typing_icon/thread_typing_icon.xml
new file mode 100644
index 00000000000..1bdb4adaf92
--- /dev/null
+++ b/addons/mail/static/src/components/thread_typing_icon/thread_typing_icon.xml
@@ -0,0 +1,29 @@
+<?xml version="1.0" encoding="UTF-8"?>
+<templates xml:space="preserve">
+
+ <t t-name="mail.ThreadTypingIcon" owl="1">
+ <div class="o_ThreadTypingIcon" t-att-title="props.title">
+ <span class="o_ThreadTypingIcon_dot o_ThreadTypingIcon_dot1" t-att-class="{
+ 'o-animationBounce': props.animation === 'bounce',
+ 'o-animationPulse': props.animation === 'pulse',
+ 'o-sizeMedium': props.size === 'medium',
+ 'o-sizeSmall': props.size === 'small',
+ }"/>
+ <span class="o_ThreadTypingIcon_separator"/>
+ <span class="o_ThreadTypingIcon_dot o_ThreadTypingIcon_dot2" t-att-class="{
+ 'o-animationBounce': props.animation === 'bounce',
+ 'o-animationPulse': props.animation === 'pulse',
+ 'o-sizeMedium': props.size === 'medium',
+ 'o-sizeSmall': props.size === 'small',
+ }"/>
+ <span class="o_ThreadTypingIcon_separator"/>
+ <span class="o_ThreadTypingIcon_dot o_ThreadTypingIcon_dot3" t-att-class="{
+ 'o-animationBounce': props.animation === 'bounce',
+ 'o-animationPulse': props.animation === 'pulse',
+ 'o-sizeMedium': props.size === 'medium',
+ 'o-sizeSmall': props.size === 'small',
+ }"/>
+ </div>
+ </t>
+
+</templates>
diff --git a/addons/mail/static/src/components/thread_viewer/thread_viewer.js b/addons/mail/static/src/components/thread_viewer/thread_viewer.js
new file mode 100644
index 00000000000..98c3c6edfb6
--- /dev/null
+++ b/addons/mail/static/src/components/thread_viewer/thread_viewer.js
@@ -0,0 +1,199 @@
+odoo.define('mail/static/src/components/thread_viewer/thread_viewer.js', function (require) {
+'use strict';
+
+const components = {
+ Composer: require('mail/static/src/components/composer/composer.js'),
+ MessageList: require('mail/static/src/components/message_list/message_list.js'),
+};
+const useStore = require('mail/static/src/component_hooks/use_store/use_store.js');
+
+const { Component } = owl;
+const { useRef } = owl.hooks;
+
+class ThreadViewer extends Component {
+
+ /**
+ * @param {...any} args
+ */
+ constructor(...args) {
+ super(...args);
+ useStore((...args) => this._useStoreSelector(...args));
+ /**
+ * Reference of the composer. Useful to set focus on composer when
+ * thread has the focus.
+ */
+ this._composerRef = useRef('composer');
+ /**
+ * Reference of the message list. Useful to determine scroll positions.
+ */
+ this._messageListRef = useRef('messageList');
+ }
+
+ mounted() {
+ this._update();
+ }
+
+ patched() {
+ this._update();
+ }
+
+ //--------------------------------------------------------------------------
+ // Public
+ //--------------------------------------------------------------------------
+
+ /**
+ * Focus the thread. If it has a composer, focus it.
+ */
+ focus() {
+ if (!this._composerRef.comp) {
+ return;
+ }
+ this._composerRef.comp.focus();
+ }
+
+ /**
+ * Focusout the thread.
+ */
+ focusout() {
+ if (!this._composerRef.comp) {
+ return;
+ }
+ this._composerRef.comp.focusout();
+ }
+
+ /**
+ * Get the scroll position in the message list.
+ *
+ * @returns {integer|undefined}
+ */
+ getScrollTop() {
+ if (!this._messageListRef.comp) {
+ return undefined;
+ }
+ return this._messageListRef.comp.getScrollTop();
+ }
+
+ /**
+ * @returns {mail.thread_viewer}
+ */
+ get threadViewer() {
+ return this.env.models['mail.thread_viewer'].get(this.props.threadViewerLocalId);
+ }
+
+ //--------------------------------------------------------------------------
+ // Private
+ //--------------------------------------------------------------------------
+
+ /**
+ * Called when thread component is mounted or patched.
+ *
+ * @private
+ */
+ async _update() {
+ const messageList = this._messageListRef.comp;
+ this.trigger('o-rendered');
+ /**
+ * Control panel may offset scrolling position of message list due to
+ * height of buttons. To prevent this, control panel re-render is
+ * triggered before message list. Correct way should be to adjust
+ * scroll positions after everything has been rendered, but OWL doesn't
+ * have such an API for the moment.
+ */
+ if (messageList) {
+ messageList.adjustFromComponentHints();
+ }
+ }
+
+ /**
+ * Returns data selected from the store.
+ *
+ * @private
+ * @param {Object} props
+ * @returns {Object}
+ */
+ _useStoreSelector(props) {
+ const threadViewer = this.env.models['mail.thread_viewer'].get(props.threadViewerLocalId);
+ const thread = threadViewer ? threadViewer.thread : undefined;
+ const threadCache = threadViewer ? threadViewer.threadCache : undefined;
+ return {
+ isDeviceMobile: this.env.messaging.device.isMobile,
+ thread: thread ? thread.__state : undefined,
+ threadCache: threadCache ? threadCache.__state : undefined,
+ threadViewer: threadViewer ? threadViewer.__state : undefined,
+ };
+ }
+
+}
+
+Object.assign(ThreadViewer, {
+ components,
+ defaultProps: {
+ composerAttachmentsDetailsMode: 'auto',
+ hasComposer: false,
+ hasMessageCheckbox: false,
+ hasSquashCloseMessages: false,
+ haveMessagesAuthorRedirect: false,
+ haveMessagesMarkAsReadIcon: false,
+ haveMessagesReplyIcon: false,
+ order: 'asc',
+ showComposerAttachmentsExtensions: true,
+ showComposerAttachmentsFilenames: true,
+ },
+ props: {
+ composerAttachmentsDetailsMode: {
+ type: String,
+ validate: prop => ['auto', 'card', 'hover', 'none'].includes(prop),
+ },
+ hasComposer: Boolean,
+ hasComposerCurrentPartnerAvatar: {
+ type: Boolean,
+ optional: true,
+ },
+ hasComposerSendButton: {
+ type: Boolean,
+ optional: true,
+ },
+ /**
+ * If set, determines whether the composer should display status of
+ * members typing on related thread. When this prop is not provided,
+ * it defaults to composer component default value.
+ */
+ hasComposerThreadTyping: {
+ type: Boolean,
+ optional: true,
+ },
+ hasMessageCheckbox: Boolean,
+ hasSquashCloseMessages: Boolean,
+ haveMessagesAuthorRedirect: Boolean,
+ haveMessagesMarkAsReadIcon: Boolean,
+ haveMessagesReplyIcon: Boolean,
+ /**
+ * Set the initial scroll position of message list on mount. Note that
+ * this prop is not directly passed to message list as props because
+ * it may compute scroll top without composer, and then composer may alter
+ * them on mount. To solve this issue, thread handles setting initial scroll
+ * positions, so that this is always done after composer has been mounted.
+ * (child `mounted()` are called before parent `mounted()`)
+ */
+ messageListInitialScrollTop: {
+ type: Number,
+ optional: true
+ },
+ order: {
+ type: String,
+ validate: prop => ['asc', 'desc'].includes(prop),
+ },
+ selectedMessageLocalId: {
+ type: String,
+ optional: true,
+ },
+ showComposerAttachmentsExtensions: Boolean,
+ showComposerAttachmentsFilenames: Boolean,
+ threadViewerLocalId: String,
+ },
+ template: 'mail.ThreadViewer',
+});
+
+return ThreadViewer;
+
+});
diff --git a/addons/mail/static/src/components/thread_viewer/thread_viewer.scss b/addons/mail/static/src/components/thread_viewer/thread_viewer.scss
new file mode 100644
index 00000000000..9b39a493979
--- /dev/null
+++ b/addons/mail/static/src/components/thread_viewer/thread_viewer.scss
@@ -0,0 +1,38 @@
+// ------------------------------------------------------------------
+// Layout
+// ------------------------------------------------------------------
+
+.o_ThreadViewer {
+ display: flex;
+ position: relative;
+ flex-flow: column;
+ overflow: auto;
+}
+
+.o_ThreadViewer_composer {
+ flex: 0 0 auto;
+}
+
+.o_ThreadViewer_loading {
+ display: flex;
+ align-self: center;
+ flex: 1 1 auto;
+ align-items: center;
+}
+
+.o_ThreadViewer_messageList {
+ flex: 1 1 auto;
+}
+
+// ------------------------------------------------------------------
+// Style
+// ------------------------------------------------------------------
+
+.o_ThreadViewer {
+ background-color: gray('100');
+
+}
+
+.o_ThreadViewer_loadingIcon {
+ margin-right: 3px;
+}
diff --git a/addons/mail/static/src/components/thread_viewer/thread_viewer.xml b/addons/mail/static/src/components/thread_viewer/thread_viewer.xml
new file mode 100644
index 00000000000..e292ac4e4ec
--- /dev/null
+++ b/addons/mail/static/src/components/thread_viewer/thread_viewer.xml
@@ -0,0 +1,44 @@
+<?xml version="1.0" encoding="UTF-8"?>
+<templates xml:space="preserve">
+
+ <t t-name="mail.ThreadViewer" owl="1">
+ <div class="o_ThreadViewer" t-att-data-thread-local-id="threadViewer and threadViewer.thread and threadViewer.thread.localId">
+ <t t-if="threadViewer">
+ <t t-if="threadViewer.isShowingLoading" name="loadingCondition">
+ <div class="o_ThreadViewer_loading">
+ <span><i class="o_ThreadViewer_loadingIcon fa fa-spinner fa-spin" title="Loading..." role="img"/>Loading...</span>
+ </div>
+ </t>
+ <t t-elif="threadViewer.threadCache.isLoaded">
+ <MessageList
+ class="o_ThreadViewer_messageList"
+ hasMessageCheckbox="props.hasMessageCheckbox"
+ hasSquashCloseMessages="props.hasSquashCloseMessages"
+ haveMessagesAuthorRedirect="props.haveMessagesAuthorRedirect"
+ haveMessagesMarkAsReadIcon="props.haveMessagesMarkAsReadIcon"
+ haveMessagesReplyIcon="props.haveMessagesReplyIcon"
+ order="props.order"
+ selectedMessageLocalId="props.selectedMessageLocalId"
+ threadViewerLocalId="threadViewer.localId"
+ t-ref="messageList"
+ />
+ </t>
+ <t t-if="props.hasComposer">
+ <Composer
+ class="o_ThreadViewer_composer"
+ attachmentsDetailsMode="props.composerAttachmentsDetailsMode"
+ composerLocalId="threadViewer.thread.composer.localId"
+ hasCurrentPartnerAvatar="props.hasComposerCurrentPartnerAvatar"
+ hasSendButton="props.hasComposerSendButton"
+ hasThreadTyping="props.hasComposerThreadTyping"
+ isFocusOnMount="!env.messaging.device.isMobile"
+ showAttachmentsExtensions="props.showComposerAttachmentsExtensions"
+ showAttachmentsFilenames="props.showComposerAttachmentsFilenames"
+ t-ref="composer"
+ />
+ </t>
+ </t>
+ </div>
+ </t>
+
+</templates>
diff --git a/addons/mail/static/src/components/thread_viewer/thread_viewer_tests.js b/addons/mail/static/src/components/thread_viewer/thread_viewer_tests.js
new file mode 100644
index 00000000000..1c7d24a86e1
--- /dev/null
+++ b/addons/mail/static/src/components/thread_viewer/thread_viewer_tests.js
@@ -0,0 +1,364 @@
+odoo.define('mail/static/src/components/thread_viewer/thread_viewer_tests.js', function (require) {
+'use strict';
+
+const components = {
+ ThreadViewer: require('mail/static/src/components/thread_viewer/thread_viewer.js'),
+};
+const {
+ afterEach: utilsAfterEach,
+ afterNextRender,
+ beforeEach: utilsBeforeEach,
+ dragenterFiles,
+ start: utilsStart,
+} = require('mail/static/src/utils/test_utils.js');
+
+QUnit.module('mail', {}, function () {
+QUnit.module('components', {}, function () {
+QUnit.module('thread_viewer', {}, function () {
+QUnit.module('thread_viewer_tests.js', {
+ beforeEach() {
+ utilsBeforeEach(this);
+
+ /**
+ * @param {mail.thread_viewer} threadViewer
+ * @param {Object} [otherProps={}]
+ * @param {Object} [param2={}]
+ * @param {boolean} [param2.isFixedSize=false]
+ */
+ this.createThreadViewerComponent = async (threadViewer, otherProps = {}, { isFixedSize = false } = {}) => {
+ const ThreadViewerComponent = components.ThreadViewer;
+ ThreadViewerComponent.env = this.env;
+ this.component = new ThreadViewerComponent(
+ null,
+ Object.assign({ threadViewerLocalId: threadViewer.localId }, otherProps)
+ );
+ let target;
+ if (isFixedSize) {
+ // needed to allow scrolling in some tests
+ const div = document.createElement('div');
+ Object.assign(div.style, {
+ display: 'flex',
+ 'flex-flow': 'column',
+ height: '300px',
+ });
+ this.widget.el.append(div);
+ target = div;
+ } else {
+ target = this.widget.el;
+ }
+ await afterNextRender(() => this.component.mount(target));
+ };
+
+ 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);
+ if (this.component) {
+ this.component.destroy();
+ }
+ if (this.widget) {
+ this.widget.destroy();
+ }
+ this.env = undefined;
+ delete components.ThreadViewer.env;
+ },
+});
+
+QUnit.test('dragover files on thread with composer', async function (assert) {
+ assert.expect(1);
+
+ await this.start();
+ 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",
+ },
+ ]]],
+ model: 'mail.channel',
+ name: "General",
+ public: 'public',
+ });
+ const threadViewer = this.env.models['mail.thread_viewer'].create({ thread: [['link', thread]] });
+ await this.createThreadViewerComponent(threadViewer, { hasComposer: true });
+ await afterNextRender(() =>
+ dragenterFiles(document.querySelector('.o_ThreadViewer'))
+ );
+ assert.ok(
+ document.querySelector('.o_Composer_dropZone'),
+ "should have dropzone when dragging file over the thread"
+ );
+});
+
+QUnit.test('message list desc order', async function (assert) {
+ assert.expect(8);
+
+ let lastId = 10000;
+ let amountOfCalls = 0;
+ await this.start({
+ async mockRPC(route, args) {
+ if (args.method === 'message_fetch') {
+ amountOfCalls ++;
+ assert.step(`message_fetch_${amountOfCalls}`);
+ // Just return 30 different messages
+ const messagesData = [...Array(30).keys()].reduce(function (acc, i) {
+ acc.push({
+ author_id: [i + 1, `Author #${i}`],
+ body: `<p>The message</p>`,
+ channel_ids: [20],
+ date: "2019-04-20 10:00:00",
+ id: lastId - i,
+ message_type: 'comment',
+ model: 'mail.channel',
+ record_name: 'General',
+ res_id: 20,
+ });
+ return acc;
+ }, []);
+ lastId -= 30;
+ return messagesData;
+ }
+ return this._super(...arguments);
+ },
+ });
+ 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",
+ },
+ ]]],
+ model: 'mail.channel',
+ name: "General",
+ public: 'public',
+ });
+ const threadViewer = this.env.models['mail.thread_viewer'].create({ thread: [['link', thread]] });
+ await this.createThreadViewerComponent(threadViewer, { order: 'desc' }, { isFixedSize: true });
+ const messageItems = document.querySelectorAll(`.o_MessageList_item`);
+ assert.notOk(
+ messageItems[0].classList.contains("o_MessageList_loadMore"),
+ "load more link should NOT be before messages"
+ );
+ assert.ok(
+ messageItems[messageItems.length - 1].classList.contains("o_MessageList_loadMore"),
+ "load more link should be after messages"
+ );
+ assert.strictEqual(
+ document.querySelectorAll(`.o_Message`).length,
+ 30,
+ "should have 30 messages at the beginning"
+ );
+
+ // scroll to bottom
+ await afterNextRender(() => {
+ document.querySelector(`.o_ThreadViewer_messageList`).scrollTop =
+ document.querySelector(`.o_ThreadViewer_messageList`).scrollHeight;
+ });
+ assert.strictEqual(
+ document.querySelectorAll(`.o_Message`).length,
+ 60,
+ "should have 60 messages after scrolled to bottom"
+ );
+
+ await afterNextRender(() => {
+ document.querySelector(`.o_ThreadViewer_messageList`).scrollTop = 0;
+ });
+ assert.strictEqual(
+ document.querySelectorAll(`.o_Message`).length,
+ 60,
+ "scrolling to top should not trigger any message fetching"
+ );
+ assert.verifySteps(['message_fetch_1', 'message_fetch_2']);
+});
+
+QUnit.test('message list asc order', async function (assert) {
+ assert.expect(8);
+
+ let lastId = 10000;
+ let amountOfCalls = 0;
+ await this.start({
+ async mockRPC(route, args) {
+ if (args.method === 'message_fetch') {
+ amountOfCalls ++;
+ assert.step(`message_fetch_${amountOfCalls}`);
+ // Just return 30 different messages
+ const messagesData = [...Array(30).keys()].reduce(function (acc, i) {
+ acc.push({
+ author_id: [i + 1, `Author #${i}`],
+ body: `<p>The message</p>`,
+ channel_ids: [20],
+ date: "2019-04-20 10:00:00",
+ id: lastId - i,
+ message_type: 'comment',
+ model: 'mail.channel',
+ record_name: 'General',
+ res_id: 20,
+ });
+ return acc;
+ }, []);
+ lastId -= 30;
+ return messagesData;
+ }
+ return this._super(...arguments);
+ },
+ });
+ 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",
+ },
+ ]]],
+ model: 'mail.channel',
+ name: "General",
+ public: 'public',
+ });
+ const threadViewer = this.env.models['mail.thread_viewer'].create({ thread: [['link', thread]] });
+ await this.createThreadViewerComponent(threadViewer, { order: 'asc' }, { isFixedSize: true });
+ const messageItems = document.querySelectorAll(`.o_MessageList_item`);
+ assert.notOk(
+ messageItems[messageItems.length - 1].classList.contains("o_MessageList_loadMore"),
+ "load more link should be before messages"
+ );
+ assert.ok(
+ messageItems[0].classList.contains("o_MessageList_loadMore"),
+ "load more link should NOT be after messages"
+ );
+ assert.strictEqual(
+ document.querySelectorAll(`.o_Message`).length,
+ 30,
+ "should have 30 messages at the beginning"
+ );
+
+ // scroll to top
+ await afterNextRender(() => {
+ document.querySelector(`.o_ThreadViewer_messageList`).scrollTop = 0;
+ });
+ assert.strictEqual(
+ document.querySelectorAll(`.o_Message`).length,
+ 60,
+ "should have 60 messages after scrolled to top"
+ );
+
+ // scroll to bottom
+ await afterNextRender(() => {
+ document.querySelector(`.o_ThreadViewer_messageList`).scrollTop =
+ document.querySelector(`.o_ThreadViewer_messageList`).scrollHeight;
+ });
+ assert.strictEqual(
+ document.querySelectorAll(`.o_Message`).length,
+ 60,
+ "scrolling to bottom should not trigger any message fetching"
+ );
+ assert.verifySteps(['message_fetch_1', 'message_fetch_2']);
+});
+
+QUnit.test('mark channel as fetched when a new message is loaded and as seen when message is visible', async function (assert) {
+ assert.expect(7);
+
+ await this.start({
+ mockRPC(route, args) {
+ if (args.method === 'channel_fetched') {
+ assert.strictEqual(
+ args.args[0][0],
+ 100,
+ 'channel_fetched is called on the right channel id'
+ );
+ assert.strictEqual(
+ args.model,
+ 'mail.channel',
+ 'channel_fetched is called on the right channel model'
+ );
+ assert.step('rpc_channel_fetched:called');
+ } else if (args.method === 'channel_seen') {
+ assert.strictEqual(
+ args.args[0][0],
+ 100,
+ 'channel_seen is called on the right channel id'
+ );
+ assert.strictEqual(
+ args.model,
+ 'mail.channel',
+ 'channel_seeb is called on the right channel model'
+ );
+ assert.step('rpc_channel_seen:called');
+ }
+ return this._super(...arguments);
+ }
+ });
+ const thread = this.env.models['mail.thread'].create({
+ id: 100,
+ isPinned: true, // just to avoid joinChannel to be called
+ members: [['insert', [
+ {
+ email: "john@example.com",
+ id: this.env.session.partner_id,
+ name: "John",
+ },
+ {
+ email: "fred@example.com",
+ id: 10,
+ name: "Fred",
+ },
+ ]]],
+ model: 'mail.channel',
+ });
+ const threadViewer = this.env.models['mail.thread_viewer'].create({ thread: [['link', thread]] });
+ await this.createThreadViewerComponent(threadViewer);
+ const notifications = [
+ [['myDB', 'mail.channel', 100], {
+ channelId: 100,
+ id: 1,
+ body: "<p>fdsfsd</p>",
+ author_id: [10, "Fred"],
+ model: "mail.channel",
+ channel_ids: [100],
+ }]
+ ];
+ await afterNextRender(() => {
+ this.widget.call('bus_service', 'trigger', 'notification', notifications);
+ });
+ assert.verifySteps(
+ ['rpc_channel_fetched:called', 'rpc_channel_seen:called'],
+ "Channel should have been fetched before having been seen"
+ );
+});
+
+});
+});
+});
+
+});