summaryrefslogtreecommitdiff
path: root/addons/mail/static/src
diff options
context:
space:
mode:
authorstephanchrst <stephanchrst@gmail.com>2022-05-10 21:51:50 +0700
committerstephanchrst <stephanchrst@gmail.com>2022-05-10 21:51:50 +0700
commit3751379f1e9a4c215fb6eb898b4ccc67659b9ace (patch)
treea44932296ef4a9b71d5f010906253d8c53727726 /addons/mail/static/src
parent0a15094050bfde69a06d6eff798e9a8ddf2b8c21 (diff)
initial commit 2
Diffstat (limited to 'addons/mail/static/src')
-rw-r--r--addons/mail/static/src/audio/ting.mp3bin0 -> 10866 bytes
-rw-r--r--addons/mail/static/src/audio/ting.oggbin0 -> 14654 bytes
-rw-r--r--addons/mail/static/src/bugfix/bugfix.js191
-rw-r--r--addons/mail/static/src/bugfix/bugfix.scss6
-rw-r--r--addons/mail/static/src/bugfix/bugfix.xml11
-rw-r--r--addons/mail/static/src/bugfix/bugfix_tests.js18
-rw-r--r--addons/mail/static/src/component_hooks/use_drag_visible_dropzone/use_drag_visible_dropzone.js93
-rw-r--r--addons/mail/static/src/component_hooks/use_refs/use_refs.js21
-rw-r--r--addons/mail/static/src/component_hooks/use_store/use_store.js126
-rw-r--r--addons/mail/static/src/component_hooks/use_store/use_store_tests.js189
-rw-r--r--addons/mail/static/src/components/activity/activity.js199
-rw-r--r--addons/mail/static/src/components/activity/activity.scss186
-rw-r--r--addons/mail/static/src/components/activity/activity.xml153
-rw-r--r--addons/mail/static/src/components/activity/activity_tests.js1157
-rw-r--r--addons/mail/static/src/components/activity_box/activity_box.js64
-rw-r--r--addons/mail/static/src/components/activity_box/activity_box.scss45
-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.js122
-rw-r--r--addons/mail/static/src/components/activity_mark_done_popover/activity_mark_done_popover.scss20
-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.js297
-rw-r--r--addons/mail/static/src/components/attachment/attachment.js204
-rw-r--r--addons/mail/static/src/components/attachment/attachment.scss204
-rw-r--r--addons/mail/static/src/components/attachment/attachment.xml115
-rw-r--r--addons/mail/static/src/components/attachment/attachment_tests.js762
-rw-r--r--addons/mail/static/src/components/attachment_box/attachment_box.js124
-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.js337
-rw-r--r--addons/mail/static/src/components/attachment_delete_confirm_dialog/attachment_delete_confirm_dialog.js92
-rw-r--r--addons/mail/static/src/components/attachment_delete_confirm_dialog/attachment_delete_confirm_dialog.xml12
-rw-r--r--addons/mail/static/src/components/attachment_list/attachment_list.js119
-rw-r--r--addons/mail/static/src/components/attachment_list/attachment_list.scss29
-rw-r--r--addons/mail/static/src/components/attachment_list/attachment_list.xml39
-rw-r--r--addons/mail/static/src/components/attachment_viewer/attachment_viewer.js598
-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.js174
-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.js363
-rw-r--r--addons/mail/static/src/components/chat_window/chat_window.scss93
-rw-r--r--addons/mail/static/src/components/chat_window/chat_window.xml54
-rw-r--r--addons/mail/static/src/components/chat_window_header/chat_window_header.js118
-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.xml56
-rw-r--r--addons/mail/static/src/components/chat_window_hidden_menu/chat_window_hidden_menu.js141
-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.js51
-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.xml23
-rw-r--r--addons/mail/static/src/components/chat_window_manager/chat_window_manager_tests.js2423
-rw-r--r--addons/mail/static/src/components/chatter/chatter.js150
-rw-r--r--addons/mail/static/src/components/chatter/chatter.scss42
-rw-r--r--addons/mail/static/src/components/chatter/chatter.xml56
-rw-r--r--addons/mail/static/src/components/chatter/chatter_suggested_recipient_tests.js420
-rw-r--r--addons/mail/static/src/components/chatter/chatter_tests.js469
-rw-r--r--addons/mail/static/src/components/chatter_container/chatter_container.js139
-rw-r--r--addons/mail/static/src/components/chatter_container/chatter_container.scss25
-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.js137
-rw-r--r--addons/mail/static/src/components/chatter_topbar/chatter_topbar.scss106
-rw-r--r--addons/mail/static/src/components/chatter_topbar/chatter_topbar.xml74
-rw-r--r--addons/mail/static/src/components/chatter_topbar/chatter_topbar_tests.js730
-rw-r--r--addons/mail/static/src/components/composer/composer.js444
-rw-r--r--addons/mail/static/src/components/composer/composer.scss273
-rw-r--r--addons/mail/static/src/components/composer/composer.xml179
-rw-r--r--addons/mail/static/src/components/composer/composer_tests.js2153
-rw-r--r--addons/mail/static/src/components/composer_suggested_recipient/composer_suggested_recipient.js158
-rw-r--r--addons/mail/static/src/components/composer_suggested_recipient/composer_suggested_recipient.scss5
-rw-r--r--addons/mail/static/src/components/composer_suggested_recipient/composer_suggested_recipient.xml41
-rw-r--r--addons/mail/static/src/components/composer_suggested_recipient_list/composer_suggested_recipient_list.js77
-rw-r--r--addons/mail/static/src/components/composer_suggested_recipient_list/composer_suggested_recipient_list.scss3
-rw-r--r--addons/mail/static/src/components/composer_suggested_recipient_list/composer_suggested_recipient_list.xml26
-rw-r--r--addons/mail/static/src/components/composer_suggestion/composer_suggestion.js143
-rw-r--r--addons/mail/static/src/components/composer_suggestion/composer_suggestion.scss43
-rw-r--r--addons/mail/static/src/components/composer_suggestion/composer_suggestion.xml33
-rw-r--r--addons/mail/static/src/components/composer_suggestion/composer_suggestion_canned_response_tests.js154
-rw-r--r--addons/mail/static/src/components/composer_suggestion/composer_suggestion_channel_tests.js144
-rw-r--r--addons/mail/static/src/components/composer_suggestion/composer_suggestion_command_tests.js151
-rw-r--r--addons/mail/static/src/components/composer_suggestion/composer_suggestion_partner_tests.js160
-rw-r--r--addons/mail/static/src/components/composer_suggestion_list/composer_suggestion_list.js73
-rw-r--r--addons/mail/static/src/components/composer_suggestion_list/composer_suggestion_list.scss27
-rw-r--r--addons/mail/static/src/components/composer_suggestion_list/composer_suggestion_list.xml32
-rw-r--r--addons/mail/static/src/components/composer_text_input/composer_text_input.js419
-rw-r--r--addons/mail/static/src/components/composer_text_input/composer_text_input.scss40
-rw-r--r--addons/mail/static/src/components/composer_text_input/composer_text_input.xml24
-rw-r--r--addons/mail/static/src/components/dialog/dialog.js119
-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.js69
-rw-r--r--addons/mail/static/src/components/dialog_manager/dialog_manager.xml17
-rw-r--r--addons/mail/static/src/components/dialog_manager/dialog_manager_tests.js82
-rw-r--r--addons/mail/static/src/components/discuss/discuss.js313
-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/tests/discuss_domain_tests.js408
-rw-r--r--addons/mail/static/src/components/discuss/tests/discuss_inbox_tests.js725
-rw-r--r--addons/mail/static/src/components/discuss/tests/discuss_moderation_tests.js1180
-rw-r--r--addons/mail/static/src/components/discuss/tests/discuss_pinned_tests.js238
-rw-r--r--addons/mail/static/src/components/discuss/tests/discuss_sidebar_tests.js163
-rw-r--r--addons/mail/static/src/components/discuss/tests/discuss_tests.js4447
-rw-r--r--addons/mail/static/src/components/discuss_mobile_mailbox_selection/discuss_mobile_mailbox_selection.js95
-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_mobile_mailbox_selection/discuss_mobile_mailbox_selection_tests.js130
-rw-r--r--addons/mail/static/src/components/discuss_sidebar/discuss_sidebar.js308
-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.xml81
-rw-r--r--addons/mail/static/src/components/discuss_sidebar_item/discuss_sidebar_item.js220
-rw-r--r--addons/mail/static/src/components/discuss_sidebar_item/discuss_sidebar_item.scss109
-rw-r--r--addons/mail/static/src/components/discuss_sidebar_item/discuss_sidebar_item.xml63
-rw-r--r--addons/mail/static/src/components/drop_zone/drop_zone.js139
-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.js91
-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.js78
-rw-r--r--addons/mail/static/src/components/emojis_popover/emojis_popover.scss22
-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.js241
-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.js94
-rw-r--r--addons/mail/static/src/components/follow_button/follow_button.js93
-rw-r--r--addons/mail/static/src/components/follow_button/follow_button.scss27
-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.js278
-rw-r--r--addons/mail/static/src/components/follower/follower.js80
-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.js380
-rw-r--r--addons/mail/static/src/components/follower_list_menu/follower_list_menu.js154
-rw-r--r--addons/mail/static/src/components/follower_list_menu/follower_list_menu.scss17
-rw-r--r--addons/mail/static/src/components/follower_list_menu/follower_list_menu.xml38
-rw-r--r--addons/mail/static/src/components/follower_list_menu/follower_list_menu_tests.js424
-rw-r--r--addons/mail/static/src/components/follower_subtype/follower_subtype.js71
-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.js233
-rw-r--r--addons/mail/static/src/components/follower_subtype_list/follower_subtype_list.js89
-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.js81
-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.js680
-rw-r--r--addons/mail/static/src/components/message/message.scss381
-rw-r--r--addons/mail/static/src/components/message/message.xml210
-rw-r--r--addons/mail/static/src/components/message/message_tests.js1580
-rw-r--r--addons/mail/static/src/components/message_author_prefix/message_author_prefix.js67
-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.js600
-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.xml103
-rw-r--r--addons/mail/static/src/components/message_seen_indicator/message_seen_indicator.js136
-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.js294
-rw-r--r--addons/mail/static/src/components/messaging_menu/messaging_menu.js234
-rw-r--r--addons/mail/static/src/components/messaging_menu/messaging_menu.scss143
-rw-r--r--addons/mail/static/src/components/messaging_menu/messaging_menu.xml83
-rw-r--r--addons/mail/static/src/components/messaging_menu/messaging_menu_tests.js1039
-rw-r--r--addons/mail/static/src/components/mobile_messaging_navbar/mobile_messaging_navbar.js61
-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.js94
-rw-r--r--addons/mail/static/src/components/moderation_ban_dialog/moderation_ban_dialog.xml23
-rw-r--r--addons/mail/static/src/components/moderation_discard_dialog/moderation_discard_dialog.js109
-rw-r--r--addons/mail/static/src/components/moderation_discard_dialog/moderation_discard_dialog.xml13
-rw-r--r--addons/mail/static/src/components/moderation_reject_dialog/moderation_reject_dialog.js104
-rw-r--r--addons/mail/static/src/components/moderation_reject_dialog/moderation_reject_dialog.xml13
-rw-r--r--addons/mail/static/src/components/notification_alert/notification_alert.js54
-rw-r--r--addons/mail/static/src/components/notification_alert/notification_alert.xml14
-rw-r--r--addons/mail/static/src/components/notification_group/notification_group.js93
-rw-r--r--addons/mail/static/src/components/notification_group/notification_group.scss93
-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.js226
-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.xml47
-rw-r--r--addons/mail/static/src/components/notification_list/notification_list_item.scss179
-rw-r--r--addons/mail/static/src/components/notification_list/notification_list_notification_group_tests.js546
-rw-r--r--addons/mail/static/src/components/notification_list/notification_list_tests.js162
-rw-r--r--addons/mail/static/src/components/notification_popover/notification_popover.js95
-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/notification_request/notification_request.js94
-rw-r--r--addons/mail/static/src/components/notification_request/notification_request.scss77
-rw-r--r--addons/mail/static/src/components/notification_request/notification_request.xml31
-rw-r--r--addons/mail/static/src/components/partner_im_status_icon/partner_im_status_icon.js74
-rw-r--r--addons/mail/static/src/components/partner_im_status_icon/partner_im_status_icon.scss59
-rw-r--r--addons/mail/static/src/components/partner_im_status_icon/partner_im_status_icon.xml38
-rw-r--r--addons/mail/static/src/components/partner_im_status_icon/partner_im_status_icon_tests.js145
-rw-r--r--addons/mail/static/src/components/thread_icon/thread_icon.js64
-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.js118
-rw-r--r--addons/mail/static/src/components/thread_needaction_preview/thread_needaction_preview.js151
-rw-r--r--addons/mail/static/src/components/thread_needaction_preview/thread_needaction_preview.scss108
-rw-r--r--addons/mail/static/src/components/thread_needaction_preview/thread_needaction_preview.xml58
-rw-r--r--addons/mail/static/src/components/thread_needaction_preview/thread_needaction_preview_tests.js457
-rw-r--r--addons/mail/static/src/components/thread_preview/thread_preview.js130
-rw-r--r--addons/mail/static/src/components/thread_preview/thread_preview.scss117
-rw-r--r--addons/mail/static/src/components/thread_preview/thread_preview.xml63
-rw-r--r--addons/mail/static/src/components/thread_preview/thread_preview_tests.js114
-rw-r--r--addons/mail/static/src/components/thread_textual_typing_status/thread_textual_typing_status.js52
-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.js367
-rw-r--r--addons/mail/static/src/components/thread_typing_icon/thread_typing_icon.js41
-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_view/thread_view.js222
-rw-r--r--addons/mail/static/src/components/thread_view/thread_view.scss38
-rw-r--r--addons/mail/static/src/components/thread_view/thread_view.xml50
-rw-r--r--addons/mail/static/src/components/thread_view/thread_view_tests.js1809
-rw-r--r--addons/mail/static/src/env/test_env.js148
-rw-r--r--addons/mail/static/src/img/_al.pngbin0 -> 1017 bytes
-rw-r--r--addons/mail/static/src/img/_pinky.pngbin0 -> 7725 bytes
-rw-r--r--addons/mail/static/src/img/attachment.pngbin0 -> 1142 bytes
-rw-r--r--addons/mail/static/src/img/checklist.pngbin0 -> 976 bytes
-rw-r--r--addons/mail/static/src/img/email_icon.pngbin0 -> 924 bytes
-rw-r--r--addons/mail/static/src/img/email_template.pngbin0 -> 2898 bytes
-rw-r--r--addons/mail/static/src/img/email_template_save.pngbin0 -> 3100 bytes
-rw-r--r--addons/mail/static/src/img/formatting.pngbin0 -> 3466 bytes
-rw-r--r--addons/mail/static/src/img/groupdefault.pngbin0 -> 2461 bytes
-rw-r--r--addons/mail/static/src/img/odoo_o.pngbin0 -> 556 bytes
-rw-r--r--addons/mail/static/src/img/odoobot.pngbin0 -> 2215 bytes
-rw-r--r--addons/mail/static/src/img/odoobot_transparent.pngbin0 -> 4487 bytes
-rw-r--r--addons/mail/static/src/img/smiley/avatar.jpgbin0 -> 6462 bytes
-rw-r--r--addons/mail/static/src/img/smiley/green.pngbin0 -> 1239 bytes
-rw-r--r--addons/mail/static/src/img/smiley/mailfailure.jpgbin0 -> 10498 bytes
-rw-r--r--addons/mail/static/src/img/smiley/yellow.pngbin0 -> 1235 bytes
-rw-r--r--addons/mail/static/src/js/activity.js868
-rw-r--r--addons/mail/static/src/js/basic_view.js68
-rw-r--r--addons/mail/static/src/js/core/translation.js28
-rw-r--r--addons/mail/static/src/js/custom_filter_item.js21
-rw-r--r--addons/mail/static/src/js/document_viewer.js396
-rw-r--r--addons/mail/static/src/js/emojis.js155
-rw-r--r--addons/mail/static/src/js/emojis_mixin.js91
-rw-r--r--addons/mail/static/src/js/field_char.js56
-rw-r--r--addons/mail/static/src/js/field_char_emojis.js18
-rw-r--r--addons/mail/static/src/js/field_emojis_common.js136
-rw-r--r--addons/mail/static/src/js/field_text_emojis.js18
-rw-r--r--addons/mail/static/src/js/main.js126
-rw-r--r--addons/mail/static/src/js/many2many_tags_email.js135
-rw-r--r--addons/mail/static/src/js/many2one_avatar_user.js68
-rw-r--r--addons/mail/static/src/js/systray/systray_activity_menu.js202
-rw-r--r--addons/mail/static/src/js/tools/debug_manager.js33
-rw-r--r--addons/mail/static/src/js/tours/mail.js59
-rw-r--r--addons/mail/static/src/js/utils.js187
-rw-r--r--addons/mail/static/src/js/views/activity/activity_cell.js42
-rw-r--r--addons/mail/static/src/js/views/activity/activity_controller.js124
-rw-r--r--addons/mail/static/src/js/views/activity/activity_model.js124
-rw-r--r--addons/mail/static/src/js/views/activity/activity_record.js62
-rw-r--r--addons/mail/static/src/js/views/activity/activity_renderer.js210
-rw-r--r--addons/mail/static/src/js/views/activity/activity_view.js53
-rw-r--r--addons/mail/static/src/model/model_core.js125
-rw-r--r--addons/mail/static/src/model/model_errors.js22
-rw-r--r--addons/mail/static/src/model/model_field.js820
-rw-r--r--addons/mail/static/src/model/model_field_command.js73
-rw-r--r--addons/mail/static/src/model/model_manager.js1098
-rw-r--r--addons/mail/static/src/models/activity/activity.js355
-rw-r--r--addons/mail/static/src/models/activity_type/activity_type.js39
-rw-r--r--addons/mail/static/src/models/attachment/attachment.js439
-rw-r--r--addons/mail/static/src/models/attachment/attachment_tests.js144
-rw-r--r--addons/mail/static/src/models/attachment_viewer/attachment_viewer.js59
-rw-r--r--addons/mail/static/src/models/canned_response/canned_response.js107
-rw-r--r--addons/mail/static/src/models/channel_command/channel_command.js130
-rw-r--r--addons/mail/static/src/models/chat_window/chat_window.js480
-rw-r--r--addons/mail/static/src/models/chat_window_manager/chat_window_manager.js487
-rw-r--r--addons/mail/static/src/models/chatter/chatter.js334
-rw-r--r--addons/mail/static/src/models/composer/composer.js1435
-rw-r--r--addons/mail/static/src/models/country/country.js55
-rw-r--r--addons/mail/static/src/models/device/device.js71
-rw-r--r--addons/mail/static/src/models/dialog/dialog.js32
-rw-r--r--addons/mail/static/src/models/dialog_manager/dialog_manager.js52
-rw-r--r--addons/mail/static/src/models/discuss/discuss.js568
-rw-r--r--addons/mail/static/src/models/follower/follower.js293
-rw-r--r--addons/mail/static/src/models/follower_subtype/follower_subtype.js82
-rw-r--r--addons/mail/static/src/models/follower_subtype_list/follower_subtype_list.js22
-rw-r--r--addons/mail/static/src/models/locale/locale.js52
-rw-r--r--addons/mail/static/src/models/mail_template/mail_template.js83
-rw-r--r--addons/mail/static/src/models/message/message.js817
-rw-r--r--addons/mail/static/src/models/message/message_tests.js187
-rw-r--r--addons/mail/static/src/models/message_seen_indicator/message_seen_indicator.js358
-rw-r--r--addons/mail/static/src/models/messaging/messaging.js253
-rw-r--r--addons/mail/static/src/models/messaging/messaging_tests.js126
-rw-r--r--addons/mail/static/src/models/messaging_initializer/messaging_initializer.js304
-rw-r--r--addons/mail/static/src/models/messaging_menu/messaging_menu.js154
-rw-r--r--addons/mail/static/src/models/messaging_notification_handler/messaging_notification_handler.js795
-rw-r--r--addons/mail/static/src/models/model/model.js291
-rw-r--r--addons/mail/static/src/models/notification/notification.js80
-rw-r--r--addons/mail/static/src/models/notification_group/notification_group.js126
-rw-r--r--addons/mail/static/src/models/notification_group_manager/notification_group_manager.js77
-rw-r--r--addons/mail/static/src/models/partner/partner.js527
-rw-r--r--addons/mail/static/src/models/suggested_recipient_info/suggested_recipient_info.js116
-rw-r--r--addons/mail/static/src/models/thread/thread.js2324
-rw-r--r--addons/mail/static/src/models/thread/thread_tests.js150
-rw-r--r--addons/mail/static/src/models/thread_cache/thread_cache.js617
-rw-r--r--addons/mail/static/src/models/thread_partner_seen_info/thread_partner_seen_info.js109
-rw-r--r--addons/mail/static/src/models/thread_view/thread_view.js441
-rw-r--r--addons/mail/static/src/models/thread_view/thread_viewer.js296
-rw-r--r--addons/mail/static/src/models/user/user.js254
-rw-r--r--addons/mail/static/src/scss/activity_view.scss132
-rw-r--r--addons/mail/static/src/scss/composer.scss161
-rw-r--r--addons/mail/static/src/scss/discuss.scss191
-rw-r--r--addons/mail/static/src/scss/emojis.scss67
-rw-r--r--addons/mail/static/src/scss/kanban_view.scss64
-rw-r--r--addons/mail/static/src/scss/mail_activity.scss242
-rw-r--r--addons/mail/static/src/scss/many2one_avatar_user.scss6
-rw-r--r--addons/mail/static/src/scss/systray.scss137
-rw-r--r--addons/mail/static/src/scss/thread.scss173
-rw-r--r--addons/mail/static/src/scss/variables.scss19
-rw-r--r--addons/mail/static/src/services/chat_window_service/chat_window_service.js104
-rw-r--r--addons/mail/static/src/services/dialog_service/dialog_service.js101
-rw-r--r--addons/mail/static/src/utils/deferred/deferred.js21
-rw-r--r--addons/mail/static/src/utils/test_utils.js767
-rw-r--r--addons/mail/static/src/utils/throttle/throttle.js382
-rw-r--r--addons/mail/static/src/utils/throttle/throttle_tests.js407
-rw-r--r--addons/mail/static/src/utils/timer/timer.js165
-rw-r--r--addons/mail/static/src/utils/timer/timer_tests.js427
-rw-r--r--addons/mail/static/src/utils/utils.js193
-rw-r--r--addons/mail/static/src/variables.scss16
-rw-r--r--addons/mail/static/src/widgets/common.xml25
-rw-r--r--addons/mail/static/src/widgets/discuss/discuss.js397
-rw-r--r--addons/mail/static/src/widgets/discuss/discuss.scss36
-rw-r--r--addons/mail/static/src/widgets/discuss/discuss.xml27
-rw-r--r--addons/mail/static/src/widgets/discuss_invite_partner_dialog/discuss_invite_partner_dialog.js124
-rw-r--r--addons/mail/static/src/widgets/discuss_invite_partner_dialog/discuss_invite_partner_dialog.xml13
-rw-r--r--addons/mail/static/src/widgets/form_renderer/form_renderer.js188
-rw-r--r--addons/mail/static/src/widgets/form_renderer/form_renderer.scss17
-rw-r--r--addons/mail/static/src/widgets/form_renderer/form_renderer_tests.js982
-rw-r--r--addons/mail/static/src/widgets/messaging_menu/messaging_menu.js56
-rw-r--r--addons/mail/static/src/widgets/messaging_menu/messaging_menu.xml8
-rw-r--r--addons/mail/static/src/widgets/notification_alert/notification_alert.js45
-rw-r--r--addons/mail/static/src/widgets/notification_alert/notification_alert_tests.js103
-rw-r--r--addons/mail/static/src/xml/activity.xml130
-rw-r--r--addons/mail/static/src/xml/activity_view.xml93
-rw-r--r--addons/mail/static/src/xml/composer.xml20
-rw-r--r--addons/mail/static/src/xml/many2one_avatar_user.xml12
-rw-r--r--addons/mail/static/src/xml/systray.xml61
-rw-r--r--addons/mail/static/src/xml/text_emojis.xml18
-rw-r--r--addons/mail/static/src/xml/thread.xml102
-rw-r--r--addons/mail/static/src/xml/web_kanban_activity.xml114
346 files changed, 67238 insertions, 0 deletions
diff --git a/addons/mail/static/src/audio/ting.mp3 b/addons/mail/static/src/audio/ting.mp3
new file mode 100644
index 00000000..fd29bf59
--- /dev/null
+++ b/addons/mail/static/src/audio/ting.mp3
Binary files differ
diff --git a/addons/mail/static/src/audio/ting.ogg b/addons/mail/static/src/audio/ting.ogg
new file mode 100644
index 00000000..cc8c0f01
--- /dev/null
+++ b/addons/mail/static/src/audio/ting.ogg
Binary files differ
diff --git a/addons/mail/static/src/bugfix/bugfix.js b/addons/mail/static/src/bugfix/bugfix.js
new file mode 100644
index 00000000..20800dc7
--- /dev/null
+++ b/addons/mail/static/src/bugfix/bugfix.js
@@ -0,0 +1,191 @@
+/**
+ * This file allows introducing new JS modules without contaminating other files.
+ * This is useful when bug fixing requires adding such JS modules in stable
+ * versions of Odoo. Any module that is defined in this file should be isolated
+ * in its own file in master.
+ */
+odoo.define('mail/static/src/bugfix/bugfix.js', function (require) {
+'use strict';
+
+});
+
+// Should be moved to its own file in master.
+odoo.define('mail/static/src/component_hooks/use_rendered_values/use_rendered_values.js', function (require) {
+'use strict';
+
+const { Component } = owl;
+const { onMounted, onPatched } = owl.hooks;
+
+/**
+ * This hooks provides support for accessing the values returned by the given
+ * selector at the time of the last render. The values will be updated after
+ * every mount/patch.
+ *
+ * @param {function} selector function that will be executed at the time of the
+ * render and of which the result will be stored for future reference.
+ * @returns {function} function to call to retrieve the last rendered values.
+ */
+function useRenderedValues(selector) {
+ const component = Component.current;
+ let renderedValues;
+ let patchedValues;
+
+ const __render = component.__render.bind(component);
+ component.__render = function () {
+ renderedValues = selector();
+ return __render(...arguments);
+ };
+ onMounted(onUpdate);
+ onPatched(onUpdate);
+ function onUpdate() {
+ patchedValues = renderedValues;
+ }
+ return () => patchedValues;
+}
+
+return useRenderedValues;
+
+});
+
+// Should be moved to its own file in master.
+odoo.define('mail/static/src/component_hooks/use_update/use_update.js', function (require) {
+'use strict';
+
+const { Component } = owl;
+const { onMounted, onPatched } = owl.hooks;
+
+const executionQueue = [];
+
+function executeNextInQueue() {
+ if (executionQueue.length === 0) {
+ return;
+ }
+ const { component, func } = executionQueue.shift();
+ if (component.__owl__.status !== 5 /* DESTROYED */) {
+ func();
+ }
+ executeNextInQueue();
+}
+
+/**
+ * @param {Object} param0
+ * @param {Component} param0.component
+ * @param {function} param0.func
+ * @param {integer} param0.priority
+ */
+async function addFunctionToQueue({ component, func, priority }) {
+ const index = executionQueue.findIndex(item => item.priority > priority);
+ const item = { component, func, priority };
+ if (index === -1) {
+ executionQueue.push(item);
+ } else {
+ executionQueue.splice(index, 0, item);
+ }
+ // Timeout to allow all components to register their function before
+ // executing any of them, to respect all priorities.
+ await new Promise(resolve => setTimeout(resolve));
+ executeNextInQueue();
+}
+
+/**
+ * This hook provides support for executing code after update (render or patch).
+ *
+ * @param {Object} param0
+ * @param {function} param0.func the function to execute after the update.
+ * @param {integer} [param0.priority] determines the execution order of the function
+ * among the update function of other components. Lower priority is executed
+ * first. If no priority is given, the function is executed immediately.
+ * This param is deprecated because desynchronized update is causing issue if
+ * there is a new render planned in the meantime (models data become obsolete
+ * in the update method).
+ */
+function useUpdate({ func, priority }) {
+ const component = Component.current;
+ onMounted(onUpdate);
+ onPatched(onUpdate);
+ function onUpdate() {
+ if (priority === undefined) {
+ func();
+ return;
+ }
+ addFunctionToQueue({ component, func, priority });
+ }
+}
+
+return useUpdate;
+
+});
+
+// Should be moved to its own file in master.
+odoo.define('mail/static/src/component_hooks/use_should_update_based_on_props/use_should_update_based_on_props.js', function (require) {
+'use strict';
+
+const { Component } = owl;
+
+/**
+ * Compares `a` and `b` up to the given `compareDepth`.
+ *
+ * @param {any} a
+ * @param {any} b
+ * @param {Object|integer} compareDepth
+ * @returns {boolean}
+ */
+function isEqual(a, b, compareDepth) {
+ const keys = Object.keys(a);
+ if (Object.keys(b).length !== keys.length) {
+ return false;
+ }
+ for (const key of keys) {
+ // the depth can be given either as a number (for all keys) or as
+ // an object (for each key)
+ let depth;
+ if (typeof compareDepth === 'number') {
+ depth = compareDepth;
+ } else {
+ depth = compareDepth[key] || 0;
+ }
+ if (depth === 0 && a[key] !== b[key]) {
+ return false;
+ }
+ if (depth !== 0) {
+ let nextDepth;
+ if (typeof depth === 'number') {
+ nextDepth = depth - 1;
+ } else {
+ nextDepth = depth;
+ }
+ if (!isEqual(a[key], b[key], nextDepth)) {
+ return false;
+ }
+ }
+ }
+ return true;
+}
+
+/**
+ * This hook overrides the `shouldUpdate` method to ensure the component is only
+ * updated if its props actually changed. This is especially useful to use on
+ * components for which an extra render costs proportionally a lot more than
+ * comparing props.
+ *
+ * @param {Object} [param0={}]
+ * @param {Object} [param0.compareDepth={}] allows to specify the comparison
+ * depth to use for each prop. Default is shallow compare (depth = 0).
+ */
+function useShouldUpdateBasedOnProps({ compareDepth = {} } = {}) {
+ const component = Component.current;
+ component.shouldUpdate = nextProps => {
+ const allNewProps = Object.assign({}, nextProps);
+ const defaultProps = component.constructor.defaultProps;
+ for (const key in defaultProps) {
+ if (allNewProps[key] === undefined) {
+ allNewProps[key] = defaultProps[key];
+ }
+ }
+ return !isEqual(component.props, allNewProps, compareDepth);
+ };
+}
+
+return useShouldUpdateBasedOnProps;
+
+});
diff --git a/addons/mail/static/src/bugfix/bugfix.scss b/addons/mail/static/src/bugfix/bugfix.scss
new file mode 100644
index 00000000..c4272e52
--- /dev/null
+++ b/addons/mail/static/src/bugfix/bugfix.scss
@@ -0,0 +1,6 @@
+/**
+* This file allows introducing new styles without contaminating other files.
+* This is useful when bug fixing requires adding new components for instance in
+* stable versions of Odoo. Any style that is defined in this file should be isolated
+* in its own file in master.
+*/
diff --git a/addons/mail/static/src/bugfix/bugfix.xml b/addons/mail/static/src/bugfix/bugfix.xml
new file mode 100644
index 00000000..c17906f7
--- /dev/null
+++ b/addons/mail/static/src/bugfix/bugfix.xml
@@ -0,0 +1,11 @@
+<?xml version="1.0" encoding="UTF-8"?>
+<templates xml:space="preserve">
+
+<!--
+ This file allows introducing new static templates without contaminating other files.
+ This is useful when bug fixing requires adding new components for instance in stable
+ versions of Odoo. Any template that is defined in this file should be isolated
+ in its own file in master.
+-->
+
+</templates>
diff --git a/addons/mail/static/src/bugfix/bugfix_tests.js b/addons/mail/static/src/bugfix/bugfix_tests.js
new file mode 100644
index 00000000..a5ecddde
--- /dev/null
+++ b/addons/mail/static/src/bugfix/bugfix_tests.js
@@ -0,0 +1,18 @@
+odoo.define('mail/static/src/bugfix/bugfix_tests.js', function (require) {
+'use strict';
+
+/**
+ * This file allows introducing new QUnit test modules without contaminating
+ * other test files. This is useful when bug fixing requires adding new
+ * components for instance in stable versions of Odoo. Any test that is defined
+ * in this file should be isolated in its own file in master.
+ */
+QUnit.module('mail', {}, function () {
+QUnit.module('bugfix', {}, function () {
+QUnit.module('bugfix_tests.js', {
+
+});
+});
+});
+
+});
diff --git a/addons/mail/static/src/component_hooks/use_drag_visible_dropzone/use_drag_visible_dropzone.js b/addons/mail/static/src/component_hooks/use_drag_visible_dropzone/use_drag_visible_dropzone.js
new file mode 100644
index 00000000..f96eb265
--- /dev/null
+++ b/addons/mail/static/src/component_hooks/use_drag_visible_dropzone/use_drag_visible_dropzone.js
@@ -0,0 +1,93 @@
+odoo.define('mail/static/src/component_hooks/use_drag_visible_dropzone/use_drag_visible_dropzone.js', function (require) {
+'use strict';
+
+const { useState, onMounted, onWillUnmount } = owl.hooks;
+
+/**
+ * This hook handle the visibility of the dropzone based on drag & drop events.
+ * It needs a ref to a dropzone, so you need to specify a t-ref="dropzone" in
+ * the template of your component.
+ *
+ * @returns {Object}
+ */
+function useDragVisibleDropZone() {
+ /**
+ * Determine whether the drop zone should be visible or not.
+ * Note that this is an observed value, and primitive types such as
+ * boolean cannot be observed, hence this is an object with boolean
+ * value accessible from `.value`
+ */
+ const isVisible = useState({ value: false });
+
+ /**
+ * Counts how many drag enter/leave happened globally. This is the only
+ * way to know if a file has been dragged out of the browser window.
+ */
+ let dragCount = 0;
+
+ // COMPONENTS HOOKS
+ onMounted(() => {
+ document.addEventListener('dragenter', _onDragenterListener, true);
+ document.addEventListener('dragleave', _onDragleaveListener, true);
+ document.addEventListener('drop', _onDropListener, true);
+
+ // Thoses Events prevent the browser to open or download the file if
+ // it's dropped outside of the dropzone
+ window.addEventListener('dragover', ev => ev.preventDefault());
+ window.addEventListener('drop', ev => ev.preventDefault());
+ });
+
+ onWillUnmount(() => {
+ document.removeEventListener('dragenter', _onDragenterListener, true);
+ document.removeEventListener('dragleave', _onDragleaveListener, true);
+ document.removeEventListener('drop', _onDropListener, true);
+
+ window.removeEventListener('dragover', ev => ev.preventDefault());
+ window.removeEventListener('drop', ev => ev.preventDefault());
+ });
+
+ //--------------------------------------------------------------------------
+ // Handlers
+ //--------------------------------------------------------------------------
+
+ /**
+ * Shows the dropzone when entering the browser window, to let the user know
+ * where he can drop its file.
+ * Avoids changing state when entering inner dropzones.
+ *
+ * @private
+ * @param {DragEvent} ev
+ */
+ function _onDragenterListener(ev) {
+ if (dragCount === 0) {
+ isVisible.value = true;
+ }
+ dragCount++;
+ }
+
+ /**
+ * @private
+ * @param {DragEvent} ev
+ */
+ function _onDragleaveListener(ev) {
+ dragCount--;
+ if (dragCount === 0) {
+ isVisible.value = false;
+ }
+ }
+
+ /**
+ * @private
+ * @param {DragEvent} ev
+ */
+ function _onDropListener(ev) {
+ dragCount = 0;
+ isVisible.value = false;
+ }
+
+ return isVisible;
+}
+
+return useDragVisibleDropZone;
+
+});
diff --git a/addons/mail/static/src/component_hooks/use_refs/use_refs.js b/addons/mail/static/src/component_hooks/use_refs/use_refs.js
new file mode 100644
index 00000000..6c84f195
--- /dev/null
+++ b/addons/mail/static/src/component_hooks/use_refs/use_refs.js
@@ -0,0 +1,21 @@
+odoo.define('mail/static/src/component_hooks/use_refs/use_refs.js', function (require) {
+'use strict';
+
+const { Component } = owl;
+
+/**
+ * This hook provides support for dynamic-refs.
+ *
+ * @returns {function} returns object whose keys are t-ref values of active refs.
+ * and values are refs.
+ */
+function useRefs() {
+ const component = Component.current;
+ return function () {
+ return component.__owl__.refs || {};
+ };
+}
+
+return useRefs;
+
+});
diff --git a/addons/mail/static/src/component_hooks/use_store/use_store.js b/addons/mail/static/src/component_hooks/use_store/use_store.js
new file mode 100644
index 00000000..7ff5ea3a
--- /dev/null
+++ b/addons/mail/static/src/component_hooks/use_store/use_store.js
@@ -0,0 +1,126 @@
+odoo.define('mail/static/src/component_hooks/use_store/use_store.js', function (require) {
+'use strict';
+
+/**
+ * Similar to owl.hooks.useStore but to decide if a new render has to be done it
+ * compares the keys on the result, with an optional level of depth for each
+ * key, given as options `compareDepth`.
+ *
+ * It assumes that the result of the selector is always an object (or array).
+ *
+ * @param {function} selector function passed as selector of original `useStore`
+ * with 1st parameter extended as store state. @see owl.hooks.useStore
+ * @param {object} [options={}] @see owl.hooks.useStore
+ * @param {number|object} [options.compareDepth=0] the comparison depth, either
+ * as number (applies to all keys) or as an object (depth for specific keys)
+ * @returns {Proxy} @see owl.hooks.useStore
+ */
+function useStore(selector, options = {}) {
+ const store = options.store || owl.Component.current.env.store;
+ const hashFn = store.observer.revNumber.bind(store.observer);
+ const isEqual = options.isEqual || ((a, b) => a === b);
+
+ /**
+ * Returns a function comparing whether two values are the same, which is just
+ * calling `isEqual` on primitive values and objects, but which also works for
+ * Owl Proxy in a temporal way: the current result of hashFn is compared to the
+ * previous result of hashFn (from the last time the function was called).
+ *
+ * It means that when this function is given Proxy the first call will always
+ * return false, and consecutive calls will not lead to the same result:
+ * it returns true the first time after a change happended inside the Proxy, and
+ * then always returns false until a new change is made.
+ *
+ * @returns {function} function taking two arguments, and comparing them as
+ * explained above.
+ */
+ function proxyComparator() {
+ /**
+ * It is important to locally save the old `revNumber` of each resulting
+ * value because when the "old" and "new" values are the same proxy it is
+ * impossible to compare them based on their current value (since it was
+ * updated in "both" due to the fact it is a proxy in the first place).
+ *
+ * And if the values are not proxy, `revNumber` will be 0 and the `isEqual`
+ * will be used to compare them.
+ */
+ let oldRevNumber;
+
+ function compare(a, b) {
+ let ok = true;
+ const newRevNumber = hashFn(b);
+ if (a === b && newRevNumber > 0) {
+ ok = oldRevNumber === newRevNumber;
+ } else {
+ ok = isEqual(a, b);
+ }
+ oldRevNumber = newRevNumber;
+ return ok;
+ }
+
+ return compare;
+ }
+
+ /**
+ * @see proxyComparator, but instead of comparing the given values, it compares
+ * their respective keys, with `compareDepth` level of depth.
+ * 0 = compare key, 1 = also compare subkeys, ...
+ *
+ * This assumes the given values are objects or arrays.
+ *
+ * @param {number|object} compareDepth the comparison depth, either as number
+ * (applies to all keys) or as an object (depth for specific keys)
+ * @returns {function}
+ */
+ function proxyComparatorDeep(compareDepth = 0) {
+ const comparator = proxyComparator();
+ const comparators = {};
+
+ function compare(a, b) {
+ // If a and b are (the same) proxy, it is already managing the depth
+ // by itself, and a simple comparator can be used.
+ if (a === b && hashFn(b) > 0) {
+ return comparator(a, b);
+ }
+ let ok = true;
+ const newKeys = Object.keys(b);
+ if (!a || (Object.keys(a).length !== newKeys.length)) {
+ ok = false;
+ }
+ for (const key of newKeys) {
+ // the depth can be given either as a number (for all keys) or as
+ // an object (for each key)
+ let depth;
+ if (typeof compareDepth === 'number') {
+ depth = compareDepth;
+ } else {
+ depth = compareDepth[key] || 0;
+ }
+ if (!(key in comparators)) {
+ if (depth > 0) {
+ comparators[key] = proxyComparatorDeep(depth - 1);
+ } else {
+ comparators[key] = proxyComparator();
+ }
+ }
+ // It is important to not break too early, the comparator has to
+ // be called for every key to remember their current states.
+ if (!comparators[key](a ? a[key] : a, b[key])) {
+ ok = false;
+ }
+ }
+ return ok;
+ }
+
+ return compare;
+ }
+
+ const extendedSelector = (state, props) => selector(props);
+ return owl.hooks.useStore(extendedSelector, Object.assign({}, options, {
+ isEqual: proxyComparatorDeep(options.compareDepth),
+ }));
+}
+
+return useStore;
+
+});
diff --git a/addons/mail/static/src/component_hooks/use_store/use_store_tests.js b/addons/mail/static/src/component_hooks/use_store/use_store_tests.js
new file mode 100644
index 00000000..f1a4b054
--- /dev/null
+++ b/addons/mail/static/src/component_hooks/use_store/use_store_tests.js
@@ -0,0 +1,189 @@
+odoo.define('mail/static/src/component_hooks/use_store/use_store_tests.js', function (require) {
+'use strict';
+
+const useStore = require('mail/static/src/component_hooks/use_store/use_store.js');
+const {
+ afterNextRender,
+ nextAnimationFrame,
+} = require('mail/static/src/utils/test_utils.js');
+
+const { Component, QWeb, Store } = owl;
+const { onPatched, useGetters } = owl.hooks;
+const { xml } = owl.tags;
+
+QUnit.module('mail', {}, function () {
+QUnit.module('component_hooks', {}, function () {
+QUnit.module('use_store', {}, function () {
+QUnit.module('use_store_tests.js', {
+ beforeEach() {
+ const qweb = new QWeb();
+ this.env = { qweb };
+ },
+ afterEach() {
+ this.env = undefined;
+ this.store = undefined;
+ },
+});
+
+
+QUnit.test("compare keys, no depth, primitives", async function (assert) {
+ assert.expect(8);
+ this.store = new Store({
+ env: this.env,
+ getters: {
+ get({ state }, key) {
+ return state[key];
+ },
+ },
+ state: {
+ obj: {
+ subObj1: 'a',
+ subObj2: 'b',
+ use1: true,
+ },
+ },
+ });
+ this.env.store = this.store;
+ let count = 0;
+ class MyComponent extends Component {
+ constructor() {
+ super(...arguments);
+ this.storeGetters = useGetters();
+ this.storeProps = useStore(props => {
+ const obj = this.storeGetters.get('obj');
+ return {
+ res: obj.use1 ? obj.subObj1 : obj.subObj2,
+ };
+ });
+ onPatched(() => {
+ count++;
+ });
+ }
+ }
+ Object.assign(MyComponent, {
+ env: this.env,
+ props: {},
+ template: xml`<div t-esc="storeProps.res"/>`,
+ });
+
+ const fixture = document.querySelector('#qunit-fixture');
+
+ const myComponent = new MyComponent();
+ await myComponent.mount(fixture);
+ assert.strictEqual(count, 0,
+ 'should not detect an update initially');
+ assert.strictEqual(fixture.textContent, 'a',
+ 'should display the content of subObj1');
+
+ await afterNextRender(() => {
+ this.store.state.obj.use1 = false;
+ });
+ assert.strictEqual(count, 1,
+ 'should detect an update because the selector is returning a different value (was subObj1, now is subObj2)');
+ assert.strictEqual(fixture.textContent, 'b',
+ 'should display the content of subObj2');
+
+ this.store.state.obj.subObj2 = 'b';
+ // there must be no render here
+ await nextAnimationFrame();
+ assert.strictEqual(count, 1,
+ 'should not detect an update because the same primitive value was assigned (subObj2 was already "b")');
+ assert.strictEqual(fixture.textContent, 'b',
+ 'should still display the content of subObj2');
+
+ await afterNextRender(() => {
+ this.store.state.obj.subObj2 = 'd';
+ });
+ assert.strictEqual(count, 2,
+ 'should detect an update because the selector is returning a different value for subObj2');
+ assert.strictEqual(fixture.textContent, 'd',
+ 'should display the new content of subObj2');
+
+ myComponent.destroy();
+});
+
+QUnit.test("compare keys, depth 1, proxy", async function (assert) {
+ assert.expect(8);
+ this.store = new Store({
+ env: this.env,
+ getters: {
+ get({ state }, key) {
+ return state[key];
+ },
+ },
+ state: {
+ obj: {
+ subObj1: { a: 'a' },
+ subObj2: { a: 'b' },
+ use1: true,
+ },
+ },
+ });
+ this.env.store = this.store;
+ let count = 0;
+ class MyComponent extends Component {
+ constructor() {
+ super(...arguments);
+ this.storeGetters = useGetters();
+ this.storeProps = useStore(props => {
+ const obj = this.storeGetters.get('obj');
+ return {
+ array: [obj.use1 ? obj.subObj1 : obj.subObj2],
+ };
+ }, {
+ compareDepth: {
+ array: 1,
+ },
+ });
+ onPatched(() => {
+ count++;
+ });
+ }
+ }
+ Object.assign(MyComponent, {
+ env: this.env,
+ props: {},
+ template: xml`<div t-esc="storeProps.array[0].a"/>`,
+ });
+
+ const fixture = document.querySelector('#qunit-fixture');
+
+ const myComponent = new MyComponent();
+ await myComponent.mount(fixture);
+ assert.strictEqual(count, 0,
+ 'should not detect an update initially');
+ assert.strictEqual(fixture.textContent, 'a',
+ 'should display the content of subObj1');
+
+ await afterNextRender(() => {
+ this.store.state.obj.use1 = false;
+ });
+ assert.strictEqual(count, 1,
+ 'should detect an update because the selector is returning a different value (was subObj1, now is subObj2)');
+ assert.strictEqual(fixture.textContent, 'b',
+ 'should display the content of subObj2');
+
+ this.store.state.obj.subObj1.a = 'c';
+ // there must be no render here
+ await nextAnimationFrame();
+ assert.strictEqual(count, 1,
+ 'should not detect an update because subObj1 was changed but only subObj2 is selected');
+ assert.strictEqual(fixture.textContent, 'b',
+ 'should still display the content of subObj2');
+
+ await afterNextRender(() => {
+ this.store.state.obj.subObj2.a = 'd';
+ });
+ assert.strictEqual(count, 2,
+ 'should detect an update because the value of subObj2 changed');
+ assert.strictEqual(fixture.textContent, 'd',
+ 'should display the new content of subObj2');
+
+ myComponent.destroy();
+});
+
+});
+});
+});
+
+});
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 00000000..1ee7ecf3
--- /dev/null
+++ b/addons/mail/static/src/components/activity/activity.js
@@ -0,0 +1,199 @@
+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 useShouldUpdateBasedOnProps = require('mail/static/src/component_hooks/use_should_update_based_on_props/use_should_update_based_on_props.js');
+const useStore = require('mail/static/src/component_hooks/use_store/use_store.js');
+
+const {
+ 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);
+ useShouldUpdateBasedOnProps();
+ 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
+ */
+ _onClick(ev) {
+ if (
+ ev.target.tagName === 'A' &&
+ ev.target.dataset.oeId &&
+ ev.target.dataset.oeModel
+ ) {
+ this.env.messaging.openProfile({
+ id: Number(ev.target.dataset.oeId),
+ model: ev.target.dataset.oeModel,
+ });
+ // avoid following dummy href
+ ev.preventDefault();
+ }
+ }
+
+ /**
+ * @private
+ * @param {MouseEvent} ev
+ */
+ async _onClickCancel(ev) {
+ ev.preventDefault();
+ await this.activity.deleteServerRecord();
+ this.trigger('reload', { keepChanges: true });
+ }
+
+ /**
+ * @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 00000000..64a0ceac
--- /dev/null
+++ b/addons/mail/static/src/components/activity/activity.scss
@@ -0,0 +1,186 @@
+// ------------------------------------------------------------------
+// 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_note p {
+ margin-bottom: 0;
+}
+
+.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);
+}
+
+// From python template
+.o_mail_note_title {
+ margin-top: map-get($spacers, 2);
+}
+
+.o_mail_note_title + div p {
+ margin-bottom: 0;
+}
+
+// ------------------------------------------------------------------
+// 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 00000000..e5e9832c
--- /dev/null
+++ b/addons/mail/static/src/components/activity/activity.xml
@@ -0,0 +1,153 @@
+<?xml version="1.0" encoding="UTF-8"?>
+<templates xml:space="preserve">
+
+ <t t-name="mail.Activity" owl="1">
+ <div class="o_Activity" t-on-click="_onClick">
+ <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>
+
+ <t t-if="state.areDetailsVisible">
+ <div class="o_Activity_details">
+ <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>
+ </t>
+
+ <t t-if="activity.note">
+ <div class="o_Activity_note">
+ <t t-raw="activity.note"/>
+ </div>
+ </t>
+
+ <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.thread.id"
+ uploadModel="activity.thread.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 00000000..1c260f07
--- /dev/null
+++ b/addons/mail/static/src/components/activity/activity_tests.js
@@ -0,0 +1,1157 @@
+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,
+ afterNextRender,
+ beforeEach,
+ createRootComponent,
+ start,
+} = 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() {
+ beforeEach(this);
+
+ this.createActivityComponent = async function (activity) {
+ await createRootComponent(this, components.Activity, {
+ props: { activityLocalId: activity.localId },
+ target: this.widget.el,
+ });
+ };
+
+ this.start = async params => {
+ const { env, widget } = await start(Object.assign({}, params, {
+ data: this.data,
+ }));
+ this.env = env;
+ this.widget = widget;
+ };
+ },
+ afterEach() {
+ afterEach(this);
+ },
+});
+
+QUnit.test('activity simplest layout', async function (assert) {
+ assert.expect(12);
+
+ await this.start();
+ const activity = this.env.models['mail.activity'].create({
+ id: 12,
+ thread: [['insert', { id: 42, 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_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({
+ id: 12,
+ note: 'There is no good or bad note',
+ thread: [['insert', { id: 42, 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_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),
+ id: 12,
+ state: 'planned',
+ thread: [['insert', { id: 42, 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_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),
+ id: 12,
+ state: 'planned',
+ thread: [['insert', { id: 42, 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_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),
+ id: 12,
+ state: 'today',
+ thread: [['insert', { id: 42, 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_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),
+ id: 12,
+ state: 'overdue',
+ thread: [['insert', { id: 42, 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_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),
+ id: 12,
+ state: 'overdue',
+ thread: [['insert', { id: 42, 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_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({
+ id: 12,
+ summary: 'test summary',
+ thread: [['insert', { id: 42, 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_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({
+ id: 12,
+ thread: [['insert', { id: 42, model: 'res.partner' }]],
+ 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, display_name: "Admin" }]],
+ dateCreate: date_to_str(today),
+ dateDeadline: date_to_str(tomorrow),
+ id: 12,
+ state: 'planned',
+ thread: [['insert', { id: 42, model: 'res.partner' }]],
+ 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, display_name: "Pauvre pomme" }]],
+ creator: [['insert', { id: 1, display_name: "Admin" }]],
+ dateCreate: date_to_str(today),
+ dateDeadline: date_to_str(tomorrow),
+ id: 12,
+ state: 'planned',
+ thread: [['insert', { id: 42, model: 'res.partner' }]],
+ 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({
+ id: 12,
+ mailTemplates: [['insert', { id: 1, name: "Dummy mail template" }]],
+ thread: [['insert', { id: 42, 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_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({
+ id: 12,
+ mailTemplates: [['insert', {
+ id: 1,
+ name: "Dummy mail template",
+ }]],
+ thread: [['insert', { id: 42, 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({
+ id: 12,
+ mailTemplates: [['insert', {
+ id: 1,
+ name: "Dummy mail template",
+ }]],
+ thread: [['insert', { id: 42, 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_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',
+ id: 12,
+ thread: [['insert', { id: 42, 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_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',
+ id: 12,
+ thread: [['insert', { id: 42, 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_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 mark as done popover should focus feedback input on open [REQUIRE FOCUS]', 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: 'not_upload_file',
+ id: 12,
+ thread: [['insert', { id: 42, model: 'res.partner' }]],
+ });
+ await this.createActivityComponent(activity);
+
+ assert.containsOnce(
+ document.body,
+ '.o_Activity',
+ "should have activity component"
+ );
+ assert.containsOnce(
+ document.body,
+ '.o_Activity_markDoneButton',
+ "should have activity Mark as Done button"
+ );
+
+ await afterNextRender(() => {
+ document.querySelector('.o_Activity_markDoneButton').click();
+ });
+ assert.strictEqual(
+ document.querySelector('.o_ActivityMarkDonePopover_feedback'),
+ document.activeElement,
+ "the popover textarea should have the focus"
+ );
+});
+
+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" }]],
+ thread: [['insert', { id: 42, 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.push({
+ 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'].insert(
+ 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",
+ }]],
+ thread: [['insert', { id: 42, 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>
+ `,
+ });
+ await createRootComponent(this, ParentComponent, {
+ props: { activityLocalId: activity.localId },
+ target: 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"
+ );
+});
+
+QUnit.test('activity mark done popover close on ESCAPE', async function (assert) {
+ // This test is not in activity_mark_done_popover_tests.js as it requires the activity mark done
+ // component to have a parent in order to allow testing interactions the popover.
+ assert.expect(2);
+
+ await this.start();
+ const activity = this.env.models['mail.activity'].create({
+ canWrite: true,
+ category: 'not_upload_file',
+ id: 12,
+ thread: [['insert', { id: 42, model: 'res.partner' }]],
+ });
+
+ await this.createActivityComponent(activity);
+ await afterNextRender(() => {
+ document.querySelector('.o_Activity_markDoneButton').click();
+ });
+ assert.containsOnce(
+ document.body,
+ '.o_ActivityMarkDonePopover',
+ "Popover component should be present"
+ );
+
+ await afterNextRender(() => {
+ const ev = new window.KeyboardEvent('keydown', { bubbles: true, key: "Escape" });
+ document.querySelector(`.o_ActivityMarkDonePopover`).dispatchEvent(ev);
+ });
+ assert.containsNone(
+ document.body,
+ '.o_ActivityMarkDonePopover',
+ "ESCAPE pressed should have closed the mark done popover"
+ );
+});
+
+QUnit.test('activity mark done popover click on discard', async function (assert) {
+ // This test is not in activity_mark_done_popover_tests.js as it requires the activity mark done
+ // component to have a parent in order to allow testing interactions the popover.
+ assert.expect(3);
+
+ await this.start();
+ const activity = this.env.models['mail.activity'].create({
+ canWrite: true,
+ category: 'not_upload_file',
+ id: 12,
+ thread: [['insert', { id: 42, model: 'res.partner' }]],
+ });
+ await this.createActivityComponent(activity);
+ await afterNextRender(() => {
+ document.querySelector('.o_Activity_markDoneButton').click();
+ });
+ 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"
+ );
+ await afterNextRender(() =>
+ document.querySelector('.o_ActivityMarkDonePopover_discardButton').click()
+ );
+ assert.containsNone(
+ document.body,
+ '.o_ActivityMarkDonePopover',
+ "Discard button clicked should have closed the mark done popover"
+ );
+});
+
+QUnit.test('data-oe-id & data-oe-model link redirection on click', async function (assert) {
+ assert.expect(7);
+
+ const bus = new Bus();
+ bus.on('do-action', null, payload => {
+ assert.strictEqual(
+ payload.action.type,
+ 'ir.actions.act_window',
+ "action should open view"
+ );
+ assert.strictEqual(
+ payload.action.res_model,
+ 'some.model',
+ "action should open view on 'some.model' model"
+ );
+ assert.strictEqual(
+ payload.action.res_id,
+ 250,
+ "action should open view on 250"
+ );
+ assert.step('do-action:openFormView_some.model_250');
+ });
+ await this.start({ env: { bus } });
+ const activity = this.env.models['mail.activity'].create({
+ canWrite: true,
+ category: 'not_upload_file',
+ id: 12,
+ note: `<p><a href="#" data-oe-id="250" data-oe-model="some.model">some.model_250</a></p>`,
+ thread: [['insert', { id: 42, model: 'res.partner' }]],
+ });
+ await this.createActivityComponent(activity);
+ assert.containsOnce(
+ document.body,
+ '.o_Activity_note',
+ "activity should have a note"
+ );
+ assert.containsOnce(
+ document.querySelector('.o_Activity_note'),
+ 'a',
+ "activity note should have a link"
+ );
+
+ document.querySelector(`.o_Activity_note a`).click();
+ assert.verifySteps(
+ ['do-action:openFormView_some.model_250'],
+ "should have open form view on related record after click on link"
+ );
+});
+
+});
+});
+});
+
+});
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 00000000..ca191694
--- /dev/null
+++ b/addons/mail/static/src/components/activity_box/activity_box.js
@@ -0,0 +1,64 @@
+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 useShouldUpdateBasedOnProps = require('mail/static/src/component_hooks/use_should_update_based_on_props/use_should_update_based_on_props.js');
+const useStore = require('mail/static/src/component_hooks/use_store/use_store.js');
+
+const { Component } = owl;
+
+class ActivityBox extends Component {
+
+ /**
+ * @override
+ */
+ constructor(...args) {
+ super(...args);
+ useShouldUpdateBasedOnProps();
+ useStore(props => {
+ const chatter = this.env.models['mail.chatter'].get(props.chatterLocalId);
+ const thread = chatter && chatter.thread;
+ return {
+ chatter: chatter ? chatter.__state : undefined,
+ thread: thread && thread.__state,
+ };
+ });
+ }
+
+ //--------------------------------------------------------------------------
+ // 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 00000000..64e99347
--- /dev/null
+++ b/addons/mail/static/src/components/activity_box/activity_box.scss
@@ -0,0 +1,45 @@
+// ------------------------------------------------------------------
+// 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_titleBadges {
+ margin-inline-end: map-get($spacers, 3);
+}
+
+.o_ActivityBox_titleLine {
+ flex: 1 1 auto;
+ width: auto;
+}
+
+.o_ActivityBox_titleText {
+ margin: 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 00000000..900b5634
--- /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 and chatter.thread">
+ <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-down' : 'fa-caret-right'"/>
+ Planned activities
+ </span>
+ <t t-if="!chatter.isActivityBoxVisible">
+ <span class="o_ActivityBox_titleBadges">
+ <t t-if="chatter.thread.overdueActivities.length > 0">
+ <span class="o_ActivityBox_titleBadge badge rounded-circle badge-danger">
+ <t t-esc="chatter.thread.overdueActivities.length"/>
+ </span>
+ </t>
+ <t t-if="chatter.thread.todayActivities.length > 0">
+ <span class="o_ActivityBox_titleBadge badge rounded-circle badge-warning">
+ <t t-esc="chatter.thread.todayActivities.length"/>
+ </span>
+ </t>
+ <t t-if="chatter.thread.futureActivities.length > 0">
+ <span class="o_ActivityBox_titleBadge badge rounded-circle badge-success">
+ <t t-esc="chatter.thread.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.thread.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 00000000..de1ea5ce
--- /dev/null
+++ b/addons/mail/static/src/components/activity_mark_done_popover/activity_mark_done_popover.js
@@ -0,0 +1,122 @@
+odoo.define('mail/static/src/components/activity_mark_done_popover/activity_mark_done_popover.js', function (require) {
+'use strict';
+
+const useShouldUpdateBasedOnProps = require('mail/static/src/component_hooks/use_should_update_based_on_props/use_should_update_based_on_props.js');
+const useStore = require('mail/static/src/component_hooks/use_store/use_store.js');
+
+const { Component } = owl;
+const { useRef } = owl.hooks;
+
+class ActivityMarkDonePopover extends Component {
+
+ /**
+ * @override
+ */
+ constructor(...args) {
+ super(...args);
+ useShouldUpdateBasedOnProps();
+ useStore(props => {
+ const activity = this.env.models['mail.activity'].get(props.activityLocalId);
+ return {
+ activity: activity ? activity.__state : undefined,
+ };
+ });
+ this._feedbackTextareaRef = useRef('feedbackTextarea');
+ }
+
+ //--------------------------------------------------------------------------
+ // Public
+ //--------------------------------------------------------------------------
+
+ mounted() {
+ this._feedbackTextareaRef.el.focus();
+ if (this.activity.feedbackBackup) {
+ this._feedbackTextareaRef.el.value = this.activity.feedbackBackup;
+ }
+ }
+
+ /**
+ * @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");
+ }
+
+ //--------------------------------------------------------------------------
+ // Private
+ //--------------------------------------------------------------------------
+
+ /**
+ * @private
+ */
+ _close() {
+ this.trigger('o-popover-close');
+ }
+
+ //--------------------------------------------------------------------------
+ // Handlers
+ //--------------------------------------------------------------------------
+
+ /**
+ * @private
+ */
+ _onBlur() {
+ this.activity.update({
+ feedbackBackup: this._feedbackTextareaRef.el.value,
+ });
+ }
+
+ /**
+ * @private
+ */
+ _onClickDiscard() {
+ this._close();
+ }
+
+ /**
+ * @private
+ */
+ async _onClickDone() {
+ await this.activity.markAsDone({
+ feedback: this._feedbackTextareaRef.el.value,
+ });
+ this.trigger('reload', { keepChanges: true });
+ }
+
+ /**
+ * @private
+ */
+ _onClickDoneAndScheduleNext() {
+ this.activity.markAsDoneAndScheduleNext({
+ feedback: this._feedbackTextareaRef.el.value,
+ });
+ }
+
+ /**
+ * @private
+ */
+ _onKeydown(ev) {
+ if (ev.key === 'Escape') {
+ this._close();
+ }
+ }
+
+}
+
+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 00000000..3479ffc3
--- /dev/null
+++ b/addons/mail/static/src/components/activity_mark_done_popover/activity_mark_done_popover.scss
@@ -0,0 +1,20 @@
+// ------------------------------------------------------------------
+// Layout
+// ------------------------------------------------------------------
+
+.o_ActivityMarkDonePopover {
+ min-height: 100px;
+}
+
+.o_ActivityMarkDonePopover_buttons {
+ margin-top: map-get($spacers, 2);
+}
+
+.o_ActivityMarkDonePopover_doneButton {
+ margin: map-get($spacers, 0) 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 00000000..357ab59b
--- /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-on-keydown="_onKeydown">
+ <t t-if="activity">
+ <textarea class="form-control o_ActivityMarkDonePopover_feedback" rows="3" placeholder="Write Feedback" t-on-blur="_onBlur" 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>
+ </t>
+ <button type="button" class="o_ActivityMarkDonePopover_discardButton btn btn-sm btn-link" t-on-click="_onClickDiscard">
+ Discard
+ </button>
+ </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 00000000..0c019b2b
--- /dev/null
+++ b/addons/mail/static/src/components/activity_mark_done_popover/activity_mark_done_popover_tests.js
@@ -0,0 +1,297 @@
+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,
+ afterNextRender,
+ beforeEach,
+ createRootComponent,
+ start,
+} = require('mail/static/src/utils/test_utils.js');
+
+const Bus = require('web.Bus');
+
+QUnit.module('mail', {}, function () {
+QUnit.module('components', {}, function () {
+QUnit.module('activity_mark_done_popover', {}, function () {
+QUnit.module('activity_mark_done_popover_tests.js', {
+ beforeEach() {
+ beforeEach(this);
+
+ this.createActivityMarkDonePopoverComponent = async activity => {
+ await createRootComponent(this, components.ActivityMarkDonePopover, {
+ props: { activityLocalId: activity.localId },
+ target: this.widget.el,
+ });
+ };
+
+ this.start = async params => {
+ const { env, widget } = await start(Object.assign({}, params, {
+ data: this.data,
+ }));
+ this.env = env;
+ this.widget = widget;
+ };
+ },
+ afterEach() {
+ afterEach(this);
+ },
+});
+
+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,
+ thread: [['insert', { id: 42, model: 'res.partner' }]],
+ });
+ 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,
+ thread: [['insert', { id: 42, model: 'res.partner' }]],
+ });
+ 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.containsOnce(
+ document.body,
+ '.o_ActivityMarkDonePopover_discardButton',
+ "Popover component should contain the discard button"
+ );
+});
+
+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,
+ thread: [['insert', { id: 42, model: 'res.partner' }]],
+ });
+ 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,
+ thread: [['insert', { id: 42, model: 'res.partner' }]],
+ });
+ 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);
+
+ const bus = new Bus();
+ bus.on('do-action', null, payload => {
+ assert.step('activity_action');
+ throw new Error("The do-action event should not be triggered when the route doesn't return an action");
+ });
+ 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 false;
+ }
+ 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);
+ },
+ env: { bus },
+ });
+ const activity = this.env.models['mail.activity'].create({
+ canWrite: true,
+ category: 'not_upload_file',
+ id: 12,
+ thread: [['insert', { id: 42, model: 'res.partner' }]],
+ });
+ await this.createActivityMarkDonePopoverComponent(activity);
+
+ let feedbackTextarea = document.querySelector('.o_ActivityMarkDonePopover_feedback');
+ feedbackTextarea.focus();
+ document.execCommand('insertText', false, 'This task is done');
+ await afterNextRender(() => {
+ document.querySelector('.o_ActivityMarkDonePopover_doneScheduleNextButton').click();
+ });
+ assert.verifySteps(
+ ['action_feedback_schedule_next'],
+ "Mark done and schedule next button should call the right rpc and not trigger an action"
+ );
+});
+
+QUnit.test('[technical] activity mark done & schedule next with new action', async function (assert) {
+ assert.expect(3);
+
+ const bus = new Bus();
+ bus.on('do-action', null, payload => {
+ assert.step('activity_action');
+ assert.deepEqual(
+ payload.action,
+ { type: 'ir.actions.act_window' },
+ "The content of the action should be correct"
+ );
+ });
+ await this.start({
+ async mockRPC(route, args) {
+ if (route === '/web/dataset/call_kw/mail.activity/action_feedback_schedule_next') {
+ return { type: 'ir.actions.act_window' };
+ }
+ return this._super(...arguments);
+ },
+ env: { bus },
+ });
+ const activity = this.env.models['mail.activity'].create({
+ canWrite: true,
+ category: 'not_upload_file',
+ id: 12,
+ thread: [['insert', { id: 42, model: 'res.partner' }]],
+ });
+ await this.createActivityMarkDonePopoverComponent(activity);
+
+ await afterNextRender(() => {
+ document.querySelector('.o_ActivityMarkDonePopover_doneScheduleNextButton').click();
+ });
+ assert.verifySteps(
+ ['activity_action'],
+ "The action returned by the route should be executed"
+ );
+});
+
+});
+});
+});
+
+});
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 00000000..a4b7b136
--- /dev/null
+++ b/addons/mail/static/src/components/attachment/attachment.js
@@ -0,0 +1,204 @@
+odoo.define('mail/static/src/components/attachment/attachment.js', function (require) {
+'use strict';
+
+const useShouldUpdateBasedOnProps = require('mail/static/src/component_hooks/use_should_update_based_on_props/use_should_update_based_on_props.js');
+const useStore = require('mail/static/src/component_hooks/use_store/use_store.js');
+
+const components = {
+ AttachmentDeleteConfirmDialog: require('mail/static/src/components/attachment_delete_confirm_dialog/attachment_delete_confirm_dialog.js'),
+};
+
+const { Component, useState } = owl;
+
+class Attachment extends Component {
+
+ /**
+ * @override
+ */
+ constructor(...args) {
+ super(...args);
+ useShouldUpdateBasedOnProps({
+ compareDepth: {
+ attachmentLocalIds: 1,
+ },
+ });
+ useStore(props => {
+ const attachment = this.env.models['mail.attachment'].get(props.attachmentLocalId);
+ return {
+ attachment: attachment ? attachment.__state : undefined,
+ };
+ });
+ this.state = useState({
+ hasDeleteConfirmDialog: false,
+ });
+ }
+
+ //--------------------------------------------------------------------------
+ // 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 '';
+ }
+ if (this.env.isQUnitTest) {
+ // background-image:url is hardly mockable, and attachments in
+ // QUnit tests do not actually exist in DB, so style should not
+ // be fetched at all.
+ return '';
+ }
+ let size;
+ if (this.detailsMode === 'card') {
+ size = '38x38';
+ } else {
+ // The size of background-image depends on the props.imageSize
+ // to sync with width and height of `.o_Attachment_image`.
+ if (this.props.imageSize === "large") {
+ size = '400x400';
+ } else if (this.props.imageSize === "medium") {
+ size = '200x200';
+ } else if (this.props.imageSize === "small") {
+ size = '100x100';
+ }
+ }
+ // background-size set to override value from `o_image` which makes small image stretched
+ return `background-image:url(/web/image/${this.attachment.id}/${size}); background-size: auto;`;
+ }
+
+ //--------------------------------------------------------------------------
+ // 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();
+ if (!this.attachment) {
+ return;
+ }
+ if (this.attachment.isLinkedToComposer) {
+ this.attachment.remove();
+ this.trigger('o-attachment-removed', { attachmentLocalId: this.props.attachmentLocalId });
+ } else {
+ this.state.hasDeleteConfirmDialog = true;
+ }
+ }
+
+ /**
+ * @private
+ */
+ _onDeleteConfirmDialogClosed() {
+ this.state.hasDeleteConfirmDialog = false;
+ }
+}
+
+Object.assign(Attachment, {
+ components,
+ 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 00000000..583e5703
--- /dev/null
+++ b/addons/mail/static/src/components/attachment/attachment.scss
@@ -0,0 +1,204 @@
+// ------------------------------------------------------------------
+// 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 {
+ display: flex;
+ flex-flow: column;
+ justify-content: center;
+ min-width: 0; /* This allows the text ellipsis in the flex element */
+ /* prevent hover delete button & attachment image to be too close to the text */
+ padding-left : map-get($spacers, 1);
+ padding-right : map-get($spacers, 1);
+}
+
+.o_Attachment_filename {
+ overflow: hidden;
+ text-overflow: ellipsis;
+ white-space: nowrap;
+}
+
+.o_Attachment_image {
+ flex-shrink: 0;
+ margin: 3px;
+
+ &.o-details-overlay {
+ position: relative;
+ // small, medium and large size styles should be sync with
+ // the size of the background-image and `.o_Attachment_image`.
+ &.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 00000000..938ff894
--- /dev/null
+++ b/addons/mail/static/src/components/attachment/attachment.xml
@@ -0,0 +1,115 @@
+<?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': attachment and 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" 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': 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">
+ <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 t-if="state.hasDeleteConfirmDialog">
+ <AttachmentDeleteConfirmDialog
+ attachmentLocalId="props.attachmentLocalId"
+ t-on-dialog-closed="_onDeleteConfirmDialogClosed"
+ />
+ </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 00000000..eaeb267d
--- /dev/null
+++ b/addons/mail/static/src/components/attachment/attachment_tests.js
@@ -0,0 +1,762 @@
+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,
+ afterNextRender,
+ beforeEach,
+ createRootComponent,
+ start,
+} = 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() {
+ beforeEach(this);
+
+ this.createAttachmentComponent = async (attachment, otherProps) => {
+ const props = Object.assign({ attachmentLocalId: attachment.localId }, otherProps);
+ await createRootComponent(this, components.Attachment, {
+ props,
+ target: this.widget.el,
+ });
+ };
+
+ this.start = async params => {
+ const { env, widget } = await start(Object.assign({}, params, {
+ data: this.data,
+ }));
+ this.env = env;
+ this.widget = widget;
+ };
+ },
+ afterEach() {
+ afterEach(this);
+ },
+});
+
+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'].findFromIdentifyingData({ 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('/200x200'),
+ "should fetch image with 200x200 pixels ratio");
+ assert.step('fetch_image');
+ }
+ 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();
+ 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"
+ );
+});
+
+QUnit.test('view attachment', async function (assert) {
+ assert.expect(3);
+
+ await this.start({
+ hasDialog: true,
+ });
+ const attachment = this.env.models['mail.attachment'].create({
+ filename: "test.png",
+ id: 750,
+ mimetype: 'image/png',
+ name: "test.png",
+ });
+
+ await this.createAttachmentComponent(attachment, {
+ detailsMode: 'hover',
+ isDownloadable: false,
+ isEditable: false,
+ });
+ assert.containsOnce(
+ document.body,
+ '.o_Attachment_image',
+ "attachment should have an image part"
+ );
+ await afterNextRender(() => document.querySelector('.o_Attachment_image').click());
+ assert.containsOnce(
+ document.body,
+ '.o_Dialog',
+ 'a dialog should have been opened once attachment image is clicked',
+ );
+ assert.containsOnce(
+ document.body,
+ '.o_AttachmentViewer',
+ 'an attachment viewer should have been opened once attachment image is clicked',
+ );
+});
+
+QUnit.test('close attachment viewer', async function (assert) {
+ assert.expect(3);
+
+ await this.start({ hasDialog: true });
+ const attachment = this.env.models['mail.attachment'].create({
+ filename: "test.png",
+ id: 750,
+ mimetype: 'image/png',
+ name: "test.png",
+ });
+
+ await this.createAttachmentComponent(attachment, {
+ detailsMode: 'hover',
+ isDownloadable: false,
+ isEditable: false,
+ });
+ assert.containsOnce(
+ document.body,
+ '.o_Attachment_image',
+ "attachment should have an image part"
+ );
+
+ await afterNextRender(() => document.querySelector('.o_Attachment_image').click());
+ assert.containsOnce(
+ document.body,
+ '.o_AttachmentViewer',
+ "an attachment viewer should have been opened once attachment image is clicked",
+ );
+
+ await afterNextRender(() =>
+ document.querySelector('.o_AttachmentViewer_headerItemButtonClose').click()
+ );
+ assert.containsNone(
+ document.body,
+ '.o_Dialog',
+ "attachment viewer should be closed after clicking on close button"
+ );
+});
+
+QUnit.test('clicking on the delete attachment button multiple times should do the rpc only once', async function (assert) {
+ assert.expect(2);
+ await this.start({
+ async mockRPC(route, args) {
+ if (args.method === "unlink" && args.model === "ir.attachment") {
+ assert.step('attachment_unlink');
+ 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: 'hover',
+ });
+ await afterNextRender(() => {
+ document.querySelector('.o_Attachment_actionUnlink').click();
+ });
+
+ await afterNextRender(() => {
+ document.querySelector('.o_AttachmentDeleteConfirmDialog_confirmButton').click();
+ document.querySelector('.o_AttachmentDeleteConfirmDialog_confirmButton').click();
+ document.querySelector('.o_AttachmentDeleteConfirmDialog_confirmButton').click();
+ });
+ assert.verifySteps(
+ ['attachment_unlink'],
+ "The unlink method must be called once"
+ );
+});
+
+QUnit.test('[technical] does not crash when the viewer is closed before image load', async function (assert) {
+ /**
+ * When images are displayed using `src` attribute for the 1st time, it fetches the resource.
+ * In this case, images are actually displayed (fully fetched and rendered on screen) when
+ * `<image>` intercepts `load` event.
+ *
+ * Current code needs to be aware of load state of image, to display spinner when loading
+ * and actual image when loaded. This test asserts no crash from mishandling image becoming
+ * loaded from being viewed for 1st time, but viewer being closed while image is loading.
+ */
+ assert.expect(1);
+
+ await this.start({ hasDialog: true });
+ const attachment = this.env.models['mail.attachment'].create({
+ filename: "test.png",
+ id: 750,
+ mimetype: 'image/png',
+ name: "test.png",
+ });
+ await this.createAttachmentComponent(attachment);
+ await afterNextRender(() => document.querySelector('.o_Attachment_image').click());
+ const imageEl = document.querySelector('.o_AttachmentViewer_viewImage');
+ await afterNextRender(() =>
+ document.querySelector('.o_AttachmentViewer_headerItemButtonClose').click()
+ );
+ // Simulate image becoming loaded.
+ let successfulLoad;
+ try {
+ imageEl.dispatchEvent(new Event('load', { bubbles: true }));
+ successfulLoad = true;
+ } catch (err) {
+ successfulLoad = false;
+ } finally {
+ assert.ok(successfulLoad, 'should not crash when the image is loaded');
+ }
+});
+
+QUnit.test('plain text file is viewable', async function (assert) {
+ assert.expect(1);
+
+ 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,
+ });
+ assert.hasClass(
+ document.querySelector('.o_Attachment'),
+ 'o-viewable',
+ "should be viewable",
+ );
+});
+
+QUnit.test('HTML file is viewable', async function (assert) {
+ assert.expect(1);
+
+ await this.start();
+ const attachment = this.env.models['mail.attachment'].create({
+ filename: "test.html",
+ id: 750,
+ mimetype: 'text/html',
+ name: "test.html",
+ });
+ await this.createAttachmentComponent(attachment, {
+ detailsMode: 'card',
+ isDownloadable: false,
+ isEditable: false,
+ });
+ assert.hasClass(
+ document.querySelector('.o_Attachment'),
+ 'o-viewable',
+ "should be viewable",
+ );
+});
+
+QUnit.test('ODT file is not viewable', async function (assert) {
+ assert.expect(1);
+
+ await this.start();
+ const attachment = this.env.models['mail.attachment'].create({
+ filename: "test.odt",
+ id: 750,
+ mimetype: 'application/vnd.oasis.opendocument.text',
+ name: "test.odt",
+ });
+ await this.createAttachmentComponent(attachment, {
+ detailsMode: 'card',
+ isDownloadable: false,
+ isEditable: false,
+ });
+ assert.doesNotHaveClass(
+ document.querySelector('.o_Attachment'),
+ 'o-viewable',
+ "should not be viewable",
+ );
+});
+
+QUnit.test('DOCX file is not viewable', async function (assert) {
+ assert.expect(1);
+
+ await this.start();
+ const attachment = this.env.models['mail.attachment'].create({
+ filename: "test.docx",
+ id: 750,
+ mimetype: 'application/vnd.openxmlformats-officedocument.wordprocessingml.document',
+ name: "test.docx",
+ });
+ await this.createAttachmentComponent(attachment, {
+ detailsMode: 'card',
+ isDownloadable: false,
+ isEditable: false,
+ });
+ assert.doesNotHaveClass(
+ document.querySelector('.o_Attachment'),
+ 'o-viewable',
+ "should not be viewable",
+ );
+});
+
+});
+});
+});
+
+});
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 00000000..5bfe7c06
--- /dev/null
+++ b/addons/mail/static/src/components/attachment_box/attachment_box.js
@@ -0,0 +1,124 @@
+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 useShouldUpdateBasedOnProps = require('mail/static/src/component_hooks/use_should_update_based_on_props/use_should_update_based_on_props.js');
+const useStore = require('mail/static/src/component_hooks/use_store/use_store.js');
+
+const { Component } = owl;
+const { useRef } = owl.hooks;
+
+class AttachmentBox extends Component {
+
+ /**
+ * @override
+ */
+ constructor(...args) {
+ super(...args);
+ this.isDropZoneVisible = useDragVisibleDropZone();
+ useShouldUpdateBasedOnProps();
+ useStore(props => {
+ const thread = this.env.models['mail.thread'].get(props.threadLocalId);
+ return {
+ thread,
+ threadAllAttachments: thread ? thread.allAttachments : [],
+ threadId: thread && thread.id,
+ threadModel: thread && thread.model,
+ };
+ }, {
+ compareDepth: {
+ threadAllAttachments: 1,
+ },
+ });
+ /**
+ * 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 00000000..d51cca9c
--- /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 00000000..9cd3e713
--- /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 00000000..142eb804
--- /dev/null
+++ b/addons/mail/static/src/components/attachment_box/attachment_box_tests.js
@@ -0,0 +1,337 @@
+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,
+ afterNextRender,
+ beforeEach,
+ createRootComponent,
+ dragenterFiles,
+ dropFiles,
+ start,
+} = 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() {
+ beforeEach(this);
+
+ this.createAttachmentBoxComponent = async (thread, otherProps) => {
+ const props = Object.assign({ threadLocalId: thread.localId }, otherProps);
+ await createRootComponent(this, components.AttachmentBox, {
+ props,
+ target: this.widget.el,
+ });
+ };
+
+ this.start = async params => {
+ const { env, widget } = await start(Object.assign({}, params, {
+ data: this.data,
+ }));
+ this.env = env;
+ this.widget = widget;
+ };
+ },
+ afterEach() {
+ afterEach(this);
+ },
+});
+
+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);
+
+ this.data['ir.attachment'].records.push(
+ {
+ mimetype: 'text/plain',
+ name: 'Blah.txt',
+ res_id: 100,
+ res_model: 'res.partner',
+ },
+ {
+ mimetype: 'text/plain',
+ name: 'Blu.txt',
+ res_id: 100,
+ res_model: 'res.partner',
+ }
+ );
+ await this.start({
+ async mockRPC(route, args) {
+ if (route.includes('ir.attachment/search_read')) {
+ assert.step('ir.attachment/search_read');
+ }
+ 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();
+ 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"
+ );
+});
+
+QUnit.test('view attachments', async function (assert) {
+ assert.expect(7);
+
+ await this.start({
+ hasDialog: true,
+ });
+ const thread = this.env.models['mail.thread'].create({
+ attachments: [
+ ['insert', {
+ id: 143,
+ mimetype: 'text/plain',
+ name: 'Blah.txt'
+ }],
+ ['insert', {
+ id: 144,
+ mimetype: 'text/plain',
+ name: 'Blu.txt'
+ }]
+ ],
+ id: 100,
+ model: 'res.partner',
+ });
+ const firstAttachment = this.env.models['mail.attachment'].findFromIdentifyingData({ id: 143 });
+ await this.createAttachmentBoxComponent(thread);
+
+ await afterNextRender(() =>
+ document.querySelector(`
+ .o_Attachment[data-attachment-local-id="${firstAttachment.localId}"]
+ .o_Attachment_image
+ `).click()
+ );
+ assert.containsOnce(
+ document.body,
+ '.o_Dialog',
+ "a dialog should have been opened once attachment image is clicked",
+ );
+ assert.containsOnce(
+ document.body,
+ '.o_AttachmentViewer',
+ "an attachment viewer should have been opened once attachment image is clicked",
+ );
+ assert.strictEqual(
+ document.querySelector('.o_AttachmentViewer_name').textContent,
+ 'Blah.txt',
+ "attachment viewer iframe should point to clicked attachment",
+ );
+ assert.containsOnce(
+ document.body,
+ '.o_AttachmentViewer_buttonNavigationNext',
+ "attachment viewer should allow to see next attachment",
+ );
+
+ await afterNextRender(() =>
+ document.querySelector('.o_AttachmentViewer_buttonNavigationNext').click()
+ );
+ assert.strictEqual(
+ document.querySelector('.o_AttachmentViewer_name').textContent,
+ 'Blu.txt',
+ "attachment viewer iframe should point to next attachment of attachment box",
+ );
+ assert.containsOnce(
+ document.body,
+ '.o_AttachmentViewer_buttonNavigationNext',
+ "attachment viewer should allow to see next attachment",
+ );
+
+ await afterNextRender(() =>
+ document.querySelector('.o_AttachmentViewer_buttonNavigationNext').click()
+ );
+ assert.strictEqual(
+ document.querySelector('.o_AttachmentViewer_name').textContent,
+ 'Blah.txt',
+ "attachment viewer iframe should point anew to first attachment",
+ );
+});
+
+QUnit.test('remove attachment should ask for confirmation', async function (assert) {
+ assert.expect(5);
+
+ await this.start();
+ const thread = this.env.models['mail.thread'].create({
+ attachments: [
+ ['insert', {
+ id: 143,
+ mimetype: 'text/plain',
+ name: 'Blah.txt'
+ }],
+ ],
+ id: 100,
+ model: 'res.partner',
+ });
+ await this.createAttachmentBoxComponent(thread);
+ assert.containsOnce(
+ document.body,
+ '.o_Attachment',
+ "should have an attachment",
+ );
+ assert.containsOnce(
+ document.body,
+ '.o_Attachment_asideItemUnlink',
+ "attachment should have a delete button"
+ );
+
+ await afterNextRender(() => document.querySelector('.o_Attachment_asideItemUnlink').click());
+ assert.containsOnce(
+ document.body,
+ '.o_AttachmentDeleteConfirmDialog',
+ "A confirmation dialog should have been opened"
+ );
+ assert.strictEqual(
+ document.querySelector('.o_AttachmentDeleteConfirmDialog_mainText').textContent,
+ `Do you really want to delete "Blah.txt"?`,
+ "Confirmation dialog should contain the attachment delete confirmation text"
+ );
+
+ // Confirm the deletion
+ await afterNextRender(() => document.querySelector('.o_AttachmentDeleteConfirmDialog_confirmButton').click());
+ assert.containsNone(
+ document.body,
+ '.o_Attachment',
+ "should no longer have an attachment",
+ );
+});
+
+});
+});
+});
+
+});
diff --git a/addons/mail/static/src/components/attachment_delete_confirm_dialog/attachment_delete_confirm_dialog.js b/addons/mail/static/src/components/attachment_delete_confirm_dialog/attachment_delete_confirm_dialog.js
new file mode 100644
index 00000000..ab7e155a
--- /dev/null
+++ b/addons/mail/static/src/components/attachment_delete_confirm_dialog/attachment_delete_confirm_dialog.js
@@ -0,0 +1,92 @@
+odoo.define('mail/static/src/components/attachment_delete_confirm_dialog/attachment_delete_confirm_dialog.js', function (require) {
+'use strict';
+
+const useShouldUpdateBasedOnProps = require('mail/static/src/component_hooks/use_should_update_based_on_props/use_should_update_based_on_props.js');
+const useStore = require('mail/static/src/component_hooks/use_store/use_store.js');
+
+const components = {
+ Dialog: require('web.OwlDialog'),
+};
+
+const { Component } = owl;
+const { useRef } = owl.hooks;
+
+class AttachmentDeleteConfirmDialog extends Component {
+
+ /**
+ * @override
+ */
+ constructor(...args) {
+ super(...args);
+ useShouldUpdateBasedOnProps();
+ useStore(props => {
+ const attachment = this.env.models['mail.attachment'].get(props.attachmentLocalId);
+ return {
+ attachment: attachment ? attachment.__state : undefined,
+ };
+ });
+ // to manually trigger the dialog close event
+ this._dialogRef = useRef('dialog');
+ }
+
+ //--------------------------------------------------------------------------
+ // Public
+ //--------------------------------------------------------------------------
+
+ /**
+ * @returns {mail.attachment}
+ */
+ get attachment() {
+ return this.env.models['mail.attachment'].get(this.props.attachmentLocalId);
+ }
+
+ /**
+ * @returns {string}
+ */
+ getBody() {
+ return _.str.sprintf(
+ this.env._t(`Do you really want to delete "%s"?`),
+ owl.utils.escape(this.attachment.displayName)
+ );
+ }
+
+ /**
+ * @returns {string}
+ */
+ getTitle() {
+ return this.env._t("Confirmation");
+ }
+
+ //--------------------------------------------------------------------------
+ // Handlers
+ //--------------------------------------------------------------------------
+
+ /**
+ * @private
+ */
+ _onClickCancel() {
+ this._dialogRef.comp._close();
+ }
+
+ /**
+ * @private
+ */
+ _onClickOk() {
+ this._dialogRef.comp._close();
+ this.attachment.remove();
+ this.trigger('o-attachment-removed', { attachmentLocalId: this.props.attachmentLocalId });
+ }
+
+}
+
+Object.assign(AttachmentDeleteConfirmDialog, {
+ components,
+ props: {
+ attachmentLocalId: String,
+ },
+ template: 'mail.AttachmentDeleteConfirmDialog',
+});
+
+return AttachmentDeleteConfirmDialog;
+
+});
diff --git a/addons/mail/static/src/components/attachment_delete_confirm_dialog/attachment_delete_confirm_dialog.xml b/addons/mail/static/src/components/attachment_delete_confirm_dialog/attachment_delete_confirm_dialog.xml
new file mode 100644
index 00000000..6b466a9b
--- /dev/null
+++ b/addons/mail/static/src/components/attachment_delete_confirm_dialog/attachment_delete_confirm_dialog.xml
@@ -0,0 +1,12 @@
+<?xml version="1.0" encoding="UTF-8"?>
+<templates xml:space="preserve">
+ <t t-name="mail.AttachmentDeleteConfirmDialog" owl="1">
+ <Dialog contentClass="'o_AttachmentDeleteConfirmDialog'" title="getTitle()" size="'medium'" t-ref="dialog">
+ <p class="o_AttachmentDeleteConfirmDialog_mainText" t-esc="getBody()"/>
+ <t t-set-slot="buttons">
+ <button class="o_AttachmentDeleteConfirmDialog_confirmButton btn btn-primary" t-on-click="_onClickOk">Ok</button>
+ <button class="o_AttachmentDeleteConfirmDialog_cancelButton btn btn-secondary" t-on-click="_onClickCancel">Cancel</button>
+ </t>
+ </Dialog>
+ </t>
+</templates>
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 00000000..d8658ac8
--- /dev/null
+++ b/addons/mail/static/src/components/attachment_list/attachment_list.js
@@ -0,0 +1,119 @@
+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 useShouldUpdateBasedOnProps = require('mail/static/src/component_hooks/use_should_update_based_on_props/use_should_update_based_on_props.js');
+const useStore = require('mail/static/src/component_hooks/use_store/use_store.js');
+
+const { Component } = owl;
+
+class AttachmentList extends Component {
+
+ /**
+ * @override
+ */
+ constructor(...args) {
+ super(...args);
+ useShouldUpdateBasedOnProps({
+ compareDepth: {
+ attachmentLocalIds: 1,
+ },
+ });
+ useStore(props => {
+ const attachments = this.env.models['mail.attachment'].all().filter(attachment =>
+ props.attachmentLocalIds.includes(attachment.localId)
+ );
+ return {
+ attachments: attachments
+ ? attachments.map(attachment => attachment.__state)
+ : undefined,
+ };
+ }, {
+ compareDepth: {
+ attachments: 1,
+ },
+ });
+ }
+
+ //--------------------------------------------------------------------------
+ // Public
+ //--------------------------------------------------------------------------
+
+ /**
+ * @returns {mail.attachment[]}
+ */
+ get attachments() {
+ return this.env.models['mail.attachment'].all().filter(attachment =>
+ this.props.attachmentLocalIds.includes(attachment.localId)
+ );
+ }
+
+ /**
+ * @returns {mail.attachment[]}
+ */
+ get imageAttachments() {
+ return this.attachments.filter(attachment => attachment.fileType === 'image');
+ }
+
+ /**
+ * @returns {mail.attachment[]}
+ */
+ get nonImageAttachments() {
+ return this.attachments.filter(attachment => attachment.fileType !== 'image');
+ }
+
+ /**
+ * @returns {mail.attachment[]}
+ */
+ get viewableAttachments() {
+ return this.attachments.filter(attachment => attachment.isViewable);
+ }
+
+}
+
+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 00000000..dfe281ae
--- /dev/null
+++ b/addons/mail/static/src/components/attachment_list/attachment_list.scss
@@ -0,0 +1,29 @@
+// ------------------------------------------------------------------
+// Layout
+// ------------------------------------------------------------------
+
+.o_AttachmentList {
+ display: flex;
+ flex-flow: column;
+ justify-content: flex-start;
+}
+
+/* Avoid overflow of long attachment text */
+.o_AttachmentList_attachment {
+ margin-bottom: map-get($spacers, 1);
+ margin-top: map-get($spacers, 1);
+ margin-inline-end: map-get($spacers, 1);
+ margin-inline-start: map-get($spacers, 0);
+ max-width: 100%;
+}
+
+.o_AttachmentList_partialList {
+ display: flex;
+ flex: 1;
+ flex-flow: wrap;
+}
+
+.o_AttachmentList_partialListNonImages {
+ margin: map-get($spacers, 1);
+ justify-content: flex-start;
+}
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 00000000..39499285
--- /dev/null
+++ b/addons/mail/static/src/components/attachment_list/attachment_list.xml
@@ -0,0 +1,39 @@
+<?xml version="1.0" encoding="UTF-8"?>
+<templates xml:space="preserve">
+
+ <t t-name="mail.AttachmentList" owl="1">
+ <div class="o_AttachmentList">
+ <div class="o_AttachmentList_partialList o_AttachmentList_partialListImages">
+ <t t-foreach="imageAttachments" t-as="attachment" t-key="attachment.localId">
+ <Attachment
+ class="o_AttachmentList_attachment o_AttachmentList_imageAttachment"
+ attachmentLocalId="attachment.localId"
+ attachmentLocalIds="viewableAttachments.map(attachment => attachment.localId)"
+ detailsMode="props.attachmentsDetailsMode"
+ imageSize="props.attachmentsImageSize"
+ isDownloadable="props.areAttachmentsDownloadable"
+ isEditable="props.areAttachmentsEditable"
+ showExtension="props.showAttachmentsExtensions"
+ showFilename="props.showAttachmentsFilenames"
+ />
+ </t>
+ </div>
+ <div class="o_AttachmentList_partialList o_AttachmentList_partialListNonImages">
+ <t t-foreach="nonImageAttachments" t-as="attachment" t-key="attachment.localId">
+ <Attachment
+ class="o_AttachmentList_attachment o_AttachmentList_nonImageAttachment"
+ attachmentLocalId="attachment.localId"
+ attachmentLocalIds="viewableAttachments.map(attachment => attachment.localId)"
+ detailsMode="'card'"
+ imageSize="props.attachmentsImageSize"
+ isDownloadable="props.areAttachmentsDownloadable"
+ isEditable="props.areAttachmentsEditable"
+ showExtension="props.showAttachmentsExtensions"
+ showFilename="props.showAttachmentsFilenames"
+ />
+ </t>
+ </div>
+ </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 00000000..30755fd9
--- /dev/null
+++ b/addons/mail/static/src/components/attachment_viewer/attachment_viewer.js
@@ -0,0 +1,598 @@
+odoo.define('mail/static/src/components/attachment_viewer/attachment_viewer.js', function (require) {
+'use strict';
+
+const useRefs = require('mail/static/src/component_hooks/use_refs/use_refs.js');
+const useShouldUpdateBasedOnProps = require('mail/static/src/component_hooks/use_should_update_based_on_props/use_should_update_based_on_props.js');
+const useStore = require('mail/static/src/component_hooks/use_store/use_store.js');
+
+const { 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;
+ useShouldUpdateBasedOnProps();
+ useStore(props => {
+ const attachmentViewer = this.env.models['mail.attachment_viewer'].get(props.localId);
+ return {
+ attachment: attachmentViewer && attachmentViewer.attachment
+ ? attachmentViewer.attachment.__state
+ : undefined,
+ attachments: attachmentViewer
+ ? attachmentViewer.attachments.map(attachment => attachment.__state)
+ : [],
+ attachmentViewer: attachmentViewer ? attachmentViewer.__state : undefined,
+ };
+ });
+ /**
+ * Used to ensure that the ref is always up to date, which seems to be needed if the element
+ * has a t-key, which was added to force the rendering of a new element when the src of the image changes.
+ * This was made to remove the display of the previous image as soon as the src changes.
+ */
+ this._getRefs = useRefs();
+ /**
+ * 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 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 };
+ this._onClickGlobal = this._onClickGlobal.bind(this);
+ }
+
+ mounted() {
+ this.el.focus();
+ this._handleImageLoad();
+ document.addEventListener('click', this._onClickGlobal);
+ }
+
+ /**
+ * When a new image is displayed, show a spinner until it is loaded.
+ */
+ patched() {
+ this._handleImageLoad();
+ }
+
+ willUnmount() {
+ document.removeEventListener('click', this._onClickGlobal);
+ }
+
+ //--------------------------------------------------------------------------
+ // Public
+ //--------------------------------------------------------------------------
+
+ /**
+ * @returns {mail.attachment_viewer}
+ */
+ get attachmentViewer() {
+ return this.env.models['mail.attachment_viewer'].get(this.props.localId);
+ }
+
+ /**
+ * 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.close();
+ }
+
+ /**
+ * 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 || !this.attachmentViewer.attachment) {
+ return;
+ }
+ const refs = this._getRefs();
+ const image = refs[`image_${this.attachmentViewer.attachment.id}`];
+ if (
+ this.attachmentViewer.attachment.fileType === 'image' &&
+ (!image || !image.complete)
+ ) {
+ this.attachmentViewer.update({ isImageLoading: true });
+ }
+ }
+
+ /**
+ * 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 refs = this._getRefs();
+ const image = refs[`image_${this.attachmentViewer.attachment.id}`];
+ const tx = image.offsetWidth * attachmentViewer.scale > this._zoomerRef.el.offsetWidth
+ ? this._translate.x + this._translate.dx
+ : 0;
+ const ty = image.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._zoomOut({ scroll: true });
+ } else {
+ this._zoomIn({ scroll: true });
+ }
+ }
+
+}
+
+Object.assign(AttachmentViewer, {
+ props: {
+ localId: String,
+ },
+ 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 00000000..54f00c1a
--- /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 00000000..8791bd09
--- /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">
+ <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-on-load="_onLoadImage" t-att-src="attachmentViewer.attachment.defaultSource" t-att-style="imageStyle" draggable="false" alt="Viewer" t-key="'image_' + attachmentViewer.attachment.id" t-ref="image_{{ attachmentViewer.attachment.id }}"/>
+ </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="fa fa-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 00000000..c6e268e5
--- /dev/null
+++ b/addons/mail/static/src/components/autocomplete_input/autocomplete_input.js
@@ -0,0 +1,174 @@
+odoo.define('mail/static/src/components/autocomplete_input/autocomplete_input.js', function (require) {
+'use strict';
+
+const useShouldUpdateBasedOnProps = require('mail/static/src/component_hooks/use_should_update_based_on_props/use_should_update_based_on_props.js');
+
+const { Component } = owl;
+
+class AutocompleteInput extends Component {
+
+ constructor(...args) {
+ super(...args);
+ useShouldUpdateBasedOnProps();
+ }
+
+ mounted() {
+ if (this.props.isFocusOnMount) {
+ this.el.focus();
+ }
+
+ let args = {
+ autoFocus: true,
+ 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 };
+ }
+
+ const autoCompleteElem = $(this.el).autocomplete(args);
+ // Resize the autocomplete dropdown options to handle the long strings
+ // By setting the width of dropdown based on the width of the input element.
+ autoCompleteElem.data("ui-autocomplete")._resizeMenu = function () {
+ const ul = this.menu.element;
+ ul.outerWidth(this.element.outerWidth());
+ };
+ }
+
+ 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) {
+ 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 00000000..ffa1bc89
--- /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 00000000..f9271523
--- /dev/null
+++ b/addons/mail/static/src/components/chat_window/chat_window.js
@@ -0,0 +1,363 @@
+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'),
+ ThreadView: require('mail/static/src/components/thread_view/thread_view.js'),
+};
+const useShouldUpdateBasedOnProps = require('mail/static/src/component_hooks/use_should_update_based_on_props/use_should_update_based_on_props.js');
+const useStore = require('mail/static/src/component_hooks/use_store/use_store.js');
+const useUpdate = require('mail/static/src/component_hooks/use_update/use_update.js');
+const { isEventHandled } = require('mail/static/src/utils/utils.js');
+
+const patchMixin = require('web.patchMixin');
+
+const { Component } = owl;
+const { useRef } = owl.hooks;
+
+class ChatWindow extends Component {
+
+ /**
+ * @override
+ */
+ constructor(...args) {
+ super(...args);
+ useShouldUpdateBasedOnProps();
+ useStore(props => {
+ const chatWindow = this.env.models['mail.chat_window'].get(props.chatWindowLocalId);
+ const thread = chatWindow ? chatWindow.thread : undefined;
+ return {
+ chatWindow,
+ chatWindowHasNewMessageForm: chatWindow && chatWindow.hasNewMessageForm,
+ chatWindowIsDoFocus: chatWindow && chatWindow.isDoFocus,
+ chatWindowIsFocused: chatWindow && chatWindow.isFocused,
+ chatWindowIsFolded: chatWindow && chatWindow.isFolded,
+ chatWindowThreadView: chatWindow && chatWindow.threadView,
+ chatWindowVisibleIndex: chatWindow && chatWindow.visibleIndex,
+ chatWindowVisibleOffset: chatWindow && chatWindow.visibleOffset,
+ isDeviceMobile: this.env.messaging.device.isMobile,
+ localeTextDirection: this.env.messaging.locale.textDirection,
+ thread,
+ threadMassMailing: thread && thread.mass_mailing,
+ threadModel: thread && thread.model,
+ };
+ });
+ useUpdate({ func: () => this._update() });
+ /**
+ * 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');
+ this._onWillHideHomeMenu = this._onWillHideHomeMenu.bind(this);
+ this._onWillShowHomeMenu = this._onWillShowHomeMenu.bind(this);
+ // the following are passed as props to children
+ this._onAutocompleteSelect = this._onAutocompleteSelect.bind(this);
+ this._onAutocompleteSource = this._onAutocompleteSource.bind(this);
+ this._constructor(...args);
+ }
+
+ /**
+ * Allows patching constructor.
+ */
+ _constructor() {}
+
+ mounted() {
+ this.env.messagingBus.on('will_hide_home_menu', this, this._onWillHideHomeMenu);
+ this.env.messagingBus.on('will_show_home_menu', this, this._onWillShowHomeMenu);
+ }
+
+ willUnmount() {
+ this.env.messagingBus.off('will_hide_home_menu', this, this._onWillHideHomeMenu);
+ this.env.messagingBus.off('will_show_home_menu', this, this._onWillShowHomeMenu);
+ }
+
+ //--------------------------------------------------------------------------
+ // 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._inputRef.comp) {
+ this._inputRef.comp.focus();
+ }
+ if (this._threadRef.comp) {
+ 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() {
+ if (
+ !this._threadRef.comp ||
+ !this.chatWindow.threadViewer ||
+ !this.chatWindow.threadViewer.threadView
+ ) {
+ return;
+ }
+ if (this.chatWindow.threadViewer.threadView.componentHintList.length > 0) {
+ // the current scroll position is likely incorrect due to the
+ // presence of hints to adjust it
+ return;
+ }
+ this.chatWindow.threadViewer.saveThreadCacheScrollHeightAsInitial(
+ this._threadRef.comp.getScrollHeight()
+ );
+ this.chatWindow.threadViewer.saveThreadCacheScrollPositionsAsInitial(
+ 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
+ */
+ async _onAutocompleteSelect(ev, ui) {
+ const chat = await this.env.messaging.getChat({ partnerId: ui.item.id });
+ if (!chat) {
+ return;
+ }
+ this.env.messaging.chatWindowManager.openThread(chat, {
+ makeActive: true,
+ replaceNewMessage: true,
+ });
+ }
+
+ /**
+ * 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,
+ });
+ }
+
+ /**
+ * 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._saveThreadScrollTop();
+ this.chatWindow.fold();
+ }
+ }
+
+ /**
+ * Called when an element in the thread becomes focused.
+ *
+ * @private
+ * @param {FocusEvent} ev
+ */
+ _onFocusinThread(ev) {
+ ev.stopPropagation();
+ if (!this.chatWindow) {
+ // prevent crash on destroy
+ return;
+ }
+ this.chatWindow.update({ isFocused: true });
+ }
+
+ /**
+ * Focus out the chat window.
+ *
+ * @private
+ */
+ _onFocusout() {
+ if (!this.chatWindow) {
+ // ignore focus out due to record being deleted
+ return;
+ }
+ this.chatWindow.update({ isFocused: false });
+ }
+
+ /**
+ * @private
+ * @param {KeyboardEvent} ev
+ */
+ _onKeydown(ev) {
+ if (!this.chatWindow) {
+ // prevent crash during delete
+ return;
+ }
+ switch (ev.key) {
+ case 'Tab':
+ ev.preventDefault();
+ if (ev.shiftKey) {
+ this.chatWindow.focusPreviousVisibleUnfoldedChatWindow();
+ } else {
+ this.chatWindow.focusNextVisibleUnfoldedChatWindow();
+ }
+ break;
+ case 'Escape':
+ if (isEventHandled(ev, 'ComposerTextInput.closeSuggestions')) {
+ break;
+ }
+ if (isEventHandled(ev, 'Composer.closeEmojisPopover')) {
+ break;
+ }
+ ev.preventDefault();
+ this.chatWindow.focusNextVisibleUnfoldedChatWindow();
+ 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() {
+ 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() {
+ 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 patchMixin(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 00000000..7b61cd3b
--- /dev/null
+++ b/addons/mail/static/src/components/chat_window/chat_window.scss
@@ -0,0 +1,93 @@
+// ------------------------------------------------------------------
+// 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_ThreadView_messageList {
+ font-size: 1rem;
+}
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 00000000..ad4a1096
--- /dev/null
+++ b/addons/mail/static/src/components/chat_window/chat_window.xml
@@ -0,0 +1,54 @@
+<?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-data-visible-index="chatWindow ? chatWindow.visibleIndex : undefined"
+ 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-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.threadView">
+ <ThreadView
+ class="o_ChatWindow_thread"
+ composerAttachmentsDetailsMode="'card'"
+ hasComposer="chatWindow.thread.model !== 'mail.box' and (!chatWindow.thread.mass_mailing or env.messaging.device.isMobile)"
+ hasComposerCurrentPartnerAvatar="false"
+ hasComposerSendButton="env.messaging.device.isMobile"
+ hasSquashCloseMessages="chatWindow.thread.model !== 'mail.box'"
+ threadViewLocalId="chatWindow.threadView.localId"
+ t-on-focusin="_onFocusinThread"
+ t-ref="thread"
+ />
+ </t>
+ <t t-if="chatWindow.hasNewMessageForm">
+ <div class="o_ChatWindow_newMessageForm">
+ <span class="o_ChatWindow_newMessageFormLabel">
+ To:
+ </span>
+ <AutocompleteInput
+ class="o_ChatWindow_newMessageFormInput"
+ 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 00000000..ea560ca2
--- /dev/null
+++ b/addons/mail/static/src/components/chat_window_header/chat_window_header.js
@@ -0,0 +1,118 @@
+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 useShouldUpdateBasedOnProps = require('mail/static/src/component_hooks/use_should_update_based_on_props/use_should_update_based_on_props.js');
+const useStore = require('mail/static/src/component_hooks/use_store/use_store.js');
+
+const { Component } = owl;
+
+class ChatWindowHeader extends Component {
+
+ /**
+ * @override
+ */
+ constructor(...args) {
+ super(...args);
+ useShouldUpdateBasedOnProps();
+ useStore(props => {
+ const chatWindow = this.env.models['mail.chat_window'].get(props.chatWindowLocalId);
+ const thread = chatWindow && chatWindow.thread;
+ return {
+ chatWindow,
+ chatWindowHasShiftLeft: chatWindow && chatWindow.hasShiftLeft,
+ chatWindowHasShiftRight: chatWindow && chatWindow.hasShiftRight,
+ chatWindowName: chatWindow && chatWindow.name,
+ isDeviceMobile: this.env.messaging.device.isMobile,
+ thread,
+ threadLocalMessageUnreadCounter: thread && thread.localMessageUnreadCounter,
+ threadMassMailing: thread && thread.mass_mailing,
+ threadModel: thread && thread.model,
+ };
+ });
+ }
+
+ //--------------------------------------------------------------------------
+ // 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();
+ if (!this.chatWindow) {
+ return;
+ }
+ 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 00000000..c5c23634
--- /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 00000000..b922da15
--- /dev/null
+++ b/addons/mail/static/src/components/chat_window_header/chat_window_header.xml
@@ -0,0 +1,56 @@
+<?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 and chatWindow.thread.model === 'mail.channel'">
+ <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.mass_mailing">
+ <i class="fa fa-envelope-o" title="Messages are sent by email" role="img"/>
+ </t>
+ <t t-if="chatWindow.thread and chatWindow.thread.localMessageUnreadCounter > 0">
+ <div class="o_ChatWindowHeader_counter o_ChatWindowHeader_item">
+ (<t t-esc="chatWindow.thread.localMessageUnreadCounter"/>)
+ </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 00000000..926ca083
--- /dev/null
+++ b/addons/mail/static/src/components/chat_window_hidden_menu/chat_window_hidden_menu.js
@@ -0,0 +1,141 @@
+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 useShouldUpdateBasedOnProps = require('mail/static/src/component_hooks/use_should_update_based_on_props/use_should_update_based_on_props.js');
+const useStore = require('mail/static/src/component_hooks/use_store/use_store.js');
+
+const { 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.makeActive();
+ 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 00000000..119d6184
--- /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 00000000..943fc71d
--- /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 00000000..a0c49a96
--- /dev/null
+++ b/addons/mail/static/src/components/chat_window_manager/chat_window_manager.js
@@ -0,0 +1,51 @@
+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 useShouldUpdateBasedOnProps = require('mail/static/src/component_hooks/use_should_update_based_on_props/use_should_update_based_on_props.js');
+const useStore = require('mail/static/src/component_hooks/use_store/use_store.js');
+
+const { Component } = owl;
+
+class ChatWindowManager extends Component {
+
+ /**
+ * @override
+ */
+ constructor(...args) {
+ super(...args);
+ useShouldUpdateBasedOnProps();
+ useStore(props => {
+ const chatWindowManager = this.env.messaging && this.env.messaging.chatWindowManager;
+ const allOrderedVisible = chatWindowManager
+ ? chatWindowManager.allOrderedVisible
+ : [];
+ return {
+ allOrderedVisible,
+ allOrderedVisibleThread: allOrderedVisible.map(chatWindow => chatWindow.thread),
+ chatWindowManager,
+ chatWindowManagerHasHiddenChatWindows: chatWindowManager && chatWindowManager.hasHiddenChatWindows,
+ isMessagingInitialized: this.env.isMessagingInitialized(),
+ };
+ }, {
+ compareDepth: {
+ allOrderedVisible: 1,
+ allOrderedVisibleThread: 1,
+ },
+ });
+ }
+
+}
+
+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 00000000..1e8abf54
--- /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 00000000..8e2bd6bf
--- /dev/null
+++ b/addons/mail/static/src/components/chat_window_manager/chat_window_manager.xml
@@ -0,0 +1,23 @@
+<?xml version="1.0" encoding="UTF-8"?>
+<templates xml:space="preserve">
+
+ <t t-name="mail.ChatWindowManager" owl="1">
+ <div class="o_ChatWindowManager">
+ <t t-if="env.isMessagingInitialized()">
+ <!-- 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>
+ </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 00000000..ce82f2bb
--- /dev/null
+++ b/addons/mail/static/src/components/chat_window_manager/chat_window_manager_tests.js
@@ -0,0 +1,2423 @@
+odoo.define('mail/static/src/components/chat_window_manager/chat_window_manager_tests.js', function (require) {
+'use strict';
+
+const { makeDeferred } = require('mail/static/src/utils/deferred/deferred.js');
+const {
+ afterEach,
+ afterNextRender,
+ beforeEach,
+ nextAnimationFrame,
+ start,
+} = require('mail/static/src/utils/test_utils.js');
+
+const {
+ file: { createFile, inputFiles },
+ 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() {
+ beforeEach(this);
+
+ this.start = async params => {
+ const { afterEvent, env, widget } = await start(Object.assign(
+ { hasChatWindow: true, hasMessagingMenu: true },
+ params,
+ { data: this.data }
+ ));
+ this.debug = params && params.debug;
+ this.afterEvent = afterEvent;
+ this.env = env;
+ this.widget = widget;
+ };
+
+ /**
+ * Simulates the external behaviours & DOM changes implied by hiding home menu.
+ * Needed to assert validity of tests at technical level (actual code of home menu could not
+ * be used in these tests).
+ */
+ this.hideHomeMenu = async () => {
+ await this.env.bus.trigger('will_hide_home_menu');
+ await this.env.bus.trigger('hide_home_menu');
+ };
+
+ /**
+ * Simulates the external behaviours & DOM changes implied by showing home menu.
+ * Needed to assert validity of tests at technical level (actual code of home menu could not
+ * be used in these tests).
+ */
+ this.showHomeMenu = async () => {
+ await this.env.bus.trigger('will_show_home_menu');
+ const $frag = document.createDocumentFragment();
+ // in real condition, chat window will be removed and put in a fragment then
+ // reinserted into DOM
+ const selector = this.debug ? 'body' : '#qunit-fixture';
+ $(selector).contents().appendTo($frag);
+ await this.env.bus.trigger('show_home_menu');
+ $(selector).append($frag);
+ };
+ },
+ afterEach() {
+ afterEach(this);
+ },
+});
+
+QUnit.test('[technical] messaging not created', async function (assert) {
+ /**
+ * Creation of messaging in env is async due to generation of models being
+ * async. Generation of models is async because it requires parsing of all
+ * JS modules that contain pieces of model definitions.
+ *
+ * Time of having no messaging is very short, almost imperceptible by user
+ * on UI, but the display should not crash during this critical time period.
+ */
+ assert.expect(2);
+
+ const messagingBeforeCreationDeferred = makeDeferred();
+ await this.start({
+ messagingBeforeCreationDeferred,
+ waitUntilMessagingCondition: 'none',
+ });
+ assert.containsOnce(
+ document.body,
+ '.o_ChatWindowManager',
+ "should have chat window manager even when messaging is not yet created"
+ );
+
+ // simulate messaging being created
+ messagingBeforeCreationDeferred.resolve();
+ await nextAnimationFrame();
+
+ assert.containsOnce(
+ document.body,
+ '.o_ChatWindowManager',
+ "should still contain chat window manager after messaging has been created"
+ );
+});
+
+QUnit.test('initial mount', async function (assert) {
+ assert.expect(1);
+
+ await this.start();
+ assert.containsOnce(
+ document.body,
+ '.o_ChatWindowManager',
+ "should have chat window manager"
+ );
+});
+
+QUnit.test('chat window new message: basic rendering', async function (assert) {
+ assert.expect(10);
+
+ await this.start();
+ 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();
+ 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();
+ 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(6);
+
+ await this.start();
+ await afterNextRender(() => document.querySelector(`.o_MessagingMenu_toggler`).click());
+ await afterNextRender(() =>
+ document.querySelector(`.o_MessagingMenu_newMessageButton`).click()
+ );
+ assert.doesNotHaveClass(
+ document.querySelector(`.o_ChatWindow`),
+ 'o-folded',
+ "chat window should not be folded by default"
+ );
+ assert.containsOnce(
+ document.body,
+ '.o_ChatWindow_newMessageForm',
+ "chat window should have new message form"
+ );
+
+ await afterNextRender(() => document.querySelector(`.o_ChatWindow_header`).click());
+ assert.hasClass(
+ document.querySelector(`.o_ChatWindow`),
+ 'o-folded',
+ "chat window should become folded"
+ );
+ assert.containsNone(
+ document.body,
+ '.o_ChatWindow_newMessageForm',
+ "chat window should not have new message form"
+ );
+
+ await afterNextRender(() => document.querySelector(`.o_ChatWindow_header`).click());
+ assert.doesNotHaveClass(
+ document.querySelector(`.o_ChatWindow`),
+ 'o-folded',
+ "chat window should become unfolded"
+ );
+ assert.containsOnce(
+ document.body,
+ '.o_ChatWindow_newMessageForm',
+ "chat window should have new message form"
+ );
+});
+
+QUnit.test('open chat from "new message" chat window should open chat in place of this "new message" chat window', async function (assert) {
+ /**
+ * InnerWith 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 3 visible chat windows:
+ * 10 + 325 + 5 + 325 + 5 + 325 + 10 = 1000 < 1920
+ */
+ assert.expect(11);
+
+ this.data['res.partner'].records.push({ id: 131, name: "Partner 131" });
+ this.data['res.users'].records.push({ partner_id: 131 });
+ this.data['mail.channel'].records.push(
+ { is_minimized: true },
+ { is_minimized: true },
+ );
+ const imSearchDef = makeDeferred();
+ await this.start({
+ env: {
+ browser: {
+ innerWidth: 1920,
+ },
+ },
+ async mockRPC(route, args) {
+ const res = await this._super(...arguments);
+ if (args.method === 'im_search') {
+ imSearchDef.resolve();
+ }
+ return res;
+ }
+ });
+ assert.containsN(
+ document.body,
+ '.o_ChatWindow',
+ 2,
+ "should have 2 chat windows initially"
+ );
+ assert.containsNone(
+ document.body,
+ '.o_ChatWindow.o-new-message',
+ "should not have any 'new message' chat window initially"
+ );
+
+ // open "new message" chat window
+ await afterNextRender(() =>
+ document.querySelector(`.o_MessagingMenu_toggler`).click()
+ );
+ await afterNextRender(() =>
+ document.querySelector(`.o_MessagingMenu_newMessageButton`).click()
+ );
+ assert.containsOnce(
+ document.body,
+ '.o_ChatWindow.o-new-message',
+ "should have 'new message' chat window after clicking 'new message' in messaging menu"
+ );
+ assert.containsN(
+ document.body,
+ '.o_ChatWindow',
+ 3,
+ "should have 3 chat window after opening 'new message' chat window",
+ );
+ assert.containsOnce(
+ document.body,
+ '.o_ChatWindow_newMessageFormInput',
+ "'new message' chat window should have new message form input"
+ );
+ assert.hasClass(
+ document.querySelector('.o_ChatWindow[data-visible-index="2"]'),
+ 'o-new-message',
+ "'new message' chat window should be the last chat window initially",
+ );
+
+ await afterNextRender(() =>
+ document.querySelector('.o_ChatWindow[data-visible-index="2"] .o_ChatWindowHeader_commandShiftRight').click()
+ );
+ assert.hasClass(
+ document.querySelector('.o_ChatWindow[data-visible-index="1"]'),
+ 'o-new-message',
+ "'new message' chat window should have moved to the middle after clicking shift previous",
+ );
+
+ // search for a user in "new message" autocomplete
+ document.execCommand('insertText', false, "131");
+ document.querySelector(`.o_ChatWindow_newMessageFormInput`)
+ .dispatchEvent(new window.KeyboardEvent('keydown'));
+ document.querySelector(`.o_ChatWindow_newMessageFormInput`)
+ .dispatchEvent(new window.KeyboardEvent('keyup'));
+ // Wait for search RPC to be resolved. The following await lines are
+ // necessary because autocomplete is an external lib therefore it is not
+ // possible to use `afterNextRender`.
+ await imSearchDef;
+ await nextAnimationFrame();
+ const link = document.querySelector('.ui-autocomplete .ui-menu-item a');
+ assert.ok(
+ link,
+ "should have autocomplete suggestion after typing on 'new message' input"
+ );
+ assert.strictEqual(
+ link.textContent,
+ "Partner 131",
+ "autocomplete suggestion should target the partner matching search term"
+ );
+
+ await afterNextRender(() => link.click());
+ assert.containsNone(
+ document.body,
+ '.o_ChatWindow.o-new-message',
+ "should have removed the 'new message' chat window after selecting a partner"
+ );
+ assert.strictEqual(
+ document.querySelector('.o_ChatWindow[data-visible-index="1"] .o_ChatWindowHeader_name').textContent,
+ "Partner 131",
+ "chat window with selected partner should be opened in position where 'new message' chat window was, which is in the middle"
+ );
+});
+
+QUnit.test('new message chat window should close on selecting the user if chat with the user is already open', async function (assert) {
+ assert.expect(2);
+
+ this.data['res.partner'].records.push({ id: 131, name: "Partner 131"});
+ this.data['res.users'].records.push({ id: 12, partner_id: 131 });
+ this.data['mail.channel'].records.push({
+ channel_type: "chat",
+ id: 20,
+ is_minimized: true,
+ members: [this.data.currentPartnerId, 131],
+ name: "Partner 131",
+ public: 'private',
+ state: 'open',
+ });
+ const imSearchDef = makeDeferred();
+ await this.start({
+ async mockRPC(route, args) {
+ const res = await this._super(...arguments);
+ if (args.method === 'im_search') {
+ imSearchDef.resolve();
+ }
+ return res;
+ },
+ });
+
+ // open "new message" chat window
+ await afterNextRender(() => document.querySelector(`.o_MessagingMenu_toggler`).click());
+ await afterNextRender(() => document.querySelector(`.o_MessagingMenu_newMessageButton`).click());
+
+ // search for a user in "new message" autocomplete
+ document.execCommand('insertText', false, "131");
+ document.querySelector(`.o_ChatWindow_newMessageFormInput`)
+ .dispatchEvent(new window.KeyboardEvent('keydown'));
+ document.querySelector(`.o_ChatWindow_newMessageFormInput`)
+ .dispatchEvent(new window.KeyboardEvent('keyup'));
+ // Wait for search RPC to be resolved. The following await lines are
+ // necessary because autocomplete is an external lib therefore it is not
+ // possible to use `afterNextRender`.
+ await imSearchDef;
+ await nextAnimationFrame();
+ const link = document.querySelector('.ui-autocomplete .ui-menu-item a');
+
+ await afterNextRender(() => link.click());
+ assert.containsNone(
+ document.body,
+ '.o_ChatWindow_newMessageFormInput',
+ "'new message' chat window should not be there"
+ );
+ assert.containsOnce(
+ document.body,
+ '.o_ChatWindow',
+ "should have only one chat window after selecting user whose chat is already open",
+ );
+});
+
+QUnit.test('new message autocomplete should automatically select first result', async function (assert) {
+ assert.expect(1);
+
+ this.data['res.partner'].records.push({ id: 131, name: "Partner 131" });
+ this.data['res.users'].records.push({ partner_id: 131 });
+ const imSearchDef = makeDeferred();
+ await this.start({
+ async mockRPC(route, args) {
+ const res = await this._super(...arguments);
+ if (args.method === 'im_search') {
+ imSearchDef.resolve();
+ }
+ return res;
+ },
+ });
+
+ // open "new message" chat window
+ await afterNextRender(() =>
+ document.querySelector(`.o_MessagingMenu_toggler`).click()
+ );
+ await afterNextRender(() =>
+ document.querySelector(`.o_MessagingMenu_newMessageButton`).click()
+ );
+
+ // search for a user in "new message" autocomplete
+ document.execCommand('insertText', false, "131");
+ document.querySelector(`.o_ChatWindow_newMessageFormInput`)
+ .dispatchEvent(new window.KeyboardEvent('keydown'));
+ document.querySelector(`.o_ChatWindow_newMessageFormInput`)
+ .dispatchEvent(new window.KeyboardEvent('keyup'));
+ // Wait for search RPC to be resolved. The following await lines are
+ // necessary because autocomplete is an external lib therefore it is not
+ // possible to use `afterNextRender`.
+ await imSearchDef;
+ await nextAnimationFrame();
+ assert.hasClass(
+ document.querySelector('.ui-autocomplete .ui-menu-item a'),
+ 'ui-state-active',
+ "first autocomplete result should be automatically selected",
+ );
+});
+
+QUnit.test('chat window: basic rendering', async function (assert) {
+ assert.expect(11);
+
+ // channel that is expected to be found in the messaging menu
+ // with random unique id and name that will be asserted during the test
+ this.data['mail.channel'].records.push({ id: 20, name: "General" });
+ await this.start();
+
+ 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'].findFromIdentifyingData({
+ id: 20,
+ 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_ThreadView'),
+ "thread part should use component ThreadView"
+ );
+});
+
+QUnit.test('chat window: fold', async function (assert) {
+ assert.expect(9);
+
+ // channel that is expected to be found in the messaging menu
+ // with random UUID, will be asserted during the test
+ this.data['mail.channel'].records.push({ uuid: 'channel-uuid' });
+ await this.start({
+ mockRPC(route, args) {
+ if (args.method === 'channel_fold') {
+ assert.step(`rpc:${args.method}/${args.kwargs.state}`);
+ }
+ 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.containsOnce(
+ document.body,
+ '.o_ChatWindow_thread',
+ "chat window should have a thread"
+ );
+ assert.verifySteps(
+ ['rpc:channel_fold/open'],
+ "should sync fold state 'open' with server after opening chat window"
+ );
+
+ // Fold chat window
+ await afterNextRender(() => document.querySelector(`.o_ChatWindow_header`).click());
+ assert.verifySteps(
+ ['rpc:channel_fold/folded'],
+ "should sync fold state 'folded' with server after folding chat window"
+ );
+ assert.containsNone(
+ document.body,
+ '.o_ChatWindow_thread',
+ "chat window should not have any thread"
+ );
+
+ // Unfold chat window
+ await afterNextRender(() => document.querySelector(`.o_ChatWindow_header`).click());
+ assert.verifySteps(
+ ['rpc:channel_fold/open'],
+ "should sync fold state 'open' with server after unfolding chat window"
+ );
+ assert.containsOnce(
+ document.body,
+ '.o_ChatWindow_thread',
+ "chat window should have a thread"
+ );
+});
+
+QUnit.test('chat window: open / close', async function (assert) {
+ assert.expect(10);
+
+ // channel that is expected to be found in the messaging menu
+ // with random UUID, will be asserted during the test
+ this.data['mail.channel'].records.push({ uuid: 'channel-uuid' });
+ await this.start({
+ mockRPC(route, args) {
+ if (args.method === 'channel_fold') {
+ assert.step(`rpc:channel_fold/${args.kwargs.state}`);
+ }
+ return this._super(...arguments);
+ },
+ });
+ assert.containsNone(
+ document.body,
+ '.o_ChatWindow',
+ "should not have a chat window initially"
+ );
+ await afterNextRender(() => document.querySelector(`.o_MessagingMenu_toggler`).click());
+ await afterNextRender(() =>
+ document.querySelector(`.o_MessagingMenu_dropdownMenu .o_NotificationList_preview`).click()
+ );
+ assert.containsOnce(
+ document.body,
+ '.o_ChatWindow',
+ "should have a chat window after clicking on thread preview"
+ );
+ assert.verifySteps(
+ ['rpc:channel_fold/open'],
+ "should sync fold state 'open' with server after opening chat window"
+ );
+
+ // Close chat window
+ await afterNextRender(() => document.querySelector(`.o_ChatWindowHeader_commandClose`).click());
+ assert.containsNone(
+ document.body,
+ '.o_ChatWindow',
+ "should not have a chat window after closing it"
+ );
+ assert.verifySteps(
+ ['rpc:channel_fold/closed'],
+ "should sync fold state 'closed' with server after closing chat window"
+ );
+
+ // Reopen chat window
+ await afterNextRender(() => document.querySelector(`.o_MessagingMenu_toggler`).click());
+ await afterNextRender(() =>
+ document.querySelector(`.o_MessagingMenu_dropdownMenu .o_NotificationList_preview`).click()
+ );
+ assert.containsOnce(
+ document.body,
+ '.o_ChatWindow',
+ "should have a chat window again after clicking on thread preview again"
+ );
+ assert.verifySteps(
+ ['rpc:channel_fold/open'],
+ "should sync fold state 'open' with server after opening chat window again"
+ );
+});
+
+QUnit.test('Mobile: opening a chat window should not update channel state on the server', async function (assert) {
+ assert.expect(2);
+
+ this.data['mail.channel'].records.push({
+ id: 20,
+ state: 'closed',
+ });
+ await this.start({
+ env: {
+ device: {
+ isMobile: true,
+ },
+ },
+ });
+ await afterNextRender(() => document.querySelector(`.o_MessagingMenu_toggler`).click());
+ await afterNextRender(() => document.querySelector(`.o_NotificationList_preview`).click());
+ assert.containsOnce(
+ document.body,
+ '.o_ChatWindow',
+ "should have a chat window after clicking on thread preview"
+ );
+ const channels = await this.env.services.rpc({
+ model: 'mail.channel',
+ method: 'read',
+ args: [20],
+ }, { shadow: true });
+ assert.strictEqual(
+ channels[0].state,
+ 'closed',
+ 'opening a chat window in mobile should not update channel state on the server',
+ );
+});
+
+QUnit.test('Mobile: closing a chat window should not update channel state on the server', async function (assert) {
+ assert.expect(3);
+
+ this.data['mail.channel'].records.push({
+ id: 20,
+ state: 'open',
+ });
+ await this.start({
+ env: {
+ device: {
+ isMobile: true,
+ },
+ },
+ });
+ await afterNextRender(() => document.querySelector(`.o_MessagingMenu_toggler`).click());
+ await afterNextRender(() => document.querySelector(`.o_NotificationList_preview`).click());
+ assert.containsOnce(
+ document.body,
+ '.o_ChatWindow',
+ "should have a chat window after clicking on thread preview"
+ );
+ // Close chat window
+ await afterNextRender(() => document.querySelector(`.o_ChatWindowHeader_commandClose`).click());
+ assert.containsNone(
+ document.body,
+ '.o_ChatWindow',
+ "should not have a chat window after closing it"
+ );
+ const channels = await this.env.services.rpc({
+ model: 'mail.channel',
+ method: 'read',
+ args: [20],
+ }, { shadow: true });
+ assert.strictEqual(
+ channels[0].state,
+ 'open',
+ 'closing the chat window should not update channel state on the server',
+ );
+});
+
+QUnit.test("Mobile: chat window shouldn't open automatically after receiving a new message", async function (assert) {
+ assert.expect(1);
+
+ this.data['res.partner'].records.push({ id: 10, name: "Demo" });
+ this.data['res.users'].records.push({
+ id: 42,
+ partner_id: 10,
+ });
+ this.data['mail.channel'].records = [
+ {
+ channel_type: "chat",
+ id: 10,
+ members: [this.data.currentPartnerId, 10],
+ uuid: 'channel-10-uuid',
+ },
+ ];
+ await this.start({
+ env: {
+ device: {
+ isMobile: true,
+ },
+ },
+ });
+
+ // simulate receiving a message
+ await afterNextRender(() => this.env.services.rpc({
+ route: '/mail/chat_post',
+ params: {
+ context: {
+ mockedUserId: 42,
+ },
+ message_content: "hu",
+ uuid: 'channel-10-uuid',
+ },
+ }));
+ assert.containsNone(
+ document.body,
+ '.o_ChatWindow',
+ "On mobile, the chat window shouldn't open automatically after receiving a new message"
+ );
+});
+
+QUnit.test('chat window: close on ESCAPE', async function (assert) {
+ assert.expect(10);
+
+ // expected partner to be found by mention during the test
+ this.data['res.partner'].records.push({ name: "TestPartner" });
+ // a chat window with thread is expected to be initially open for this test
+ this.data['mail.channel'].records.push({ is_minimized: true });
+ await this.start({
+ mockRPC(route, args) {
+ if (args.method === 'channel_fold') {
+ assert.step(`rpc:channel_fold/${args.kwargs.state}`);
+ }
+ return this._super(...arguments);
+ },
+ });
+ assert.containsOnce(
+ document.body,
+ '.o_ChatWindow',
+ "chat window should be opened initially"
+ );
+
+ await afterNextRender(() =>
+ document.querySelector(`.o_Composer_buttonEmojis`).click()
+ );
+ assert.containsOnce(
+ document.body,
+ '.o_EmojisPopover',
+ "emojis popover should be opened after click on emojis button"
+ );
+
+ await afterNextRender(() => {
+ const ev = new window.KeyboardEvent('keydown', { bubbles: true, key: "Escape" });
+ document.querySelector(`.o_Composer_buttonEmojis`).dispatchEvent(ev);
+ });
+ assert.containsNone(
+ document.body,
+ '.o_EmojisPopover',
+ "emojis popover should be closed after pressing escape on emojis button"
+ );
+ assert.containsOnce(
+ document.body,
+ '.o_ChatWindow',
+ "chat window should still be opened after pressing escape on emojis button"
+ );
+
+ 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_ComposerSuggestionList_list'),
+ 'show',
+ "should display mention suggestions on typing '@'"
+ );
+
+ await afterNextRender(() => {
+ const ev = new window.KeyboardEvent('keydown', { bubbles: true, key: "Escape" });
+ document.querySelector(`.o_ComposerTextInput_textarea`).dispatchEvent(ev);
+ });
+ assert.containsNone(
+ document.body,
+ '.o_ComposerSuggestionList_list',
+ "mention suggestion should be closed after pressing escape on mention suggestion"
+ );
+ assert.containsOnce(
+ document.body,
+ '.o_ChatWindow',
+ "chat window should still be opened after pressing escape on mention suggestion"
+ );
+
+ await afterNextRender(() => {
+ const ev = new window.KeyboardEvent('keydown', { bubbles: true, key: "Escape" });
+ document.querySelector(`.o_ComposerTextInput_textarea`).dispatchEvent(ev);
+ });
+ assert.containsNone(
+ document.body,
+ '.o_ChatWindow',
+ "chat window should be closed after pressing escape if there was no other priority escape handler"
+ );
+ assert.verifySteps(['rpc:channel_fold/closed']);
+});
+
+QUnit.test('focus next visible chat window when closing current chat window with ESCAPE', 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(4);
+
+ // 2 chat windows with thread are expected to be initially open for this test
+ this.data['mail.channel'].records.push(
+ { is_minimized: true, state: 'open' },
+ { is_minimized: true, state: 'open' }
+ );
+ await this.start({
+ env: {
+ browser: {
+ innerWidth: 1920,
+ },
+ },
+ });
+ assert.containsN(
+ document.body,
+ '.o_ChatWindow .o_ComposerTextInput_textarea',
+ 2,
+ "2 chat windows should be present initially"
+ );
+ assert.containsNone(
+ document.body,
+ '.o_ChatWindow.o-folded',
+ "both chat windows should be open"
+ );
+
+ await afterNextRender(() => {
+ const ev = new window.KeyboardEvent('keydown', { bubbles: true, key: 'Escape' });
+ document.querySelector('.o_ComposerTextInput_textarea').dispatchEvent(ev);
+ });
+ assert.containsOnce(
+ document.body,
+ '.o_ChatWindow',
+ "only one chat window should remain after pressing escape on first chat window"
+ );
+ assert.hasClass(
+ document.querySelector('.o_ChatWindow'),
+ 'o-focused',
+ "next visible chat window should be focused after pressing escape on first chat window"
+ );
+});
+
+QUnit.test('[technical] chat window: composer state conservation on toggle home menu', async function (assert) {
+ // technical as show/hide home menu simulation are involved and home menu implementation
+ // have side-effects on DOM that may make chat window components not work
+ assert.expect(7);
+
+ // channel that is expected to be found in the messaging menu
+ // with random unique id that is needed to link messages
+ this.data['mail.channel'].records.push({ id: 20 });
+ await this.start();
+ await afterNextRender(() => document.querySelector(`.o_MessagingMenu_toggler`).click());
+ await afterNextRender(() =>
+ document.querySelector(`.o_MessagingMenu_dropdownMenu .o_NotificationList_preview`).click()
+ );
+ // Set content of the composer of the chat window
+ await afterNextRender(() => {
+ document.querySelector(`.o_ComposerTextInput_textarea`).focus();
+ document.execCommand('insertText', false, 'XDU for the win !');
+ });
+ assert.containsNone(
+ document.body,
+ '.o_Composer .o_Attachment',
+ "composer should have no attachment initially"
+ );
+ // 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_ComposerTextInput_textarea`).value,
+ "XDU for the win !",
+ "chat window composer initial text input should contain 'XDU for the win !'"
+ );
+ assert.containsN(
+ document.body,
+ '.o_Composer .o_Attachment',
+ 2,
+ "composer should have 2 total attachments after adding 2 attachments"
+ );
+
+ await this.hideHomeMenu();
+ assert.strictEqual(
+ document.querySelector(`.o_ComposerTextInput_textarea`).value,
+ "XDU for the win !",
+ "Chat window composer should still have the same input after hiding home menu"
+ );
+ assert.containsN(
+ document.body,
+ '.o_Composer .o_Attachment',
+ 2,
+ "Chat window composer should have 2 attachments after hiding home menu"
+ );
+
+ // Show home menu
+ await this.showHomeMenu();
+ assert.strictEqual(
+ document.querySelector(`.o_ComposerTextInput_textarea`).value,
+ "XDU for the win !",
+ "chat window composer should still have the same input showing home menu"
+ );
+ assert.containsN(
+ document.body,
+ '.o_Composer .o_Attachment',
+ 2,
+ "Chat window composer should have 2 attachments showing home menu"
+ );
+});
+
+QUnit.test('[technical] chat window: scroll conservation on toggle home menu', async function (assert) {
+ // technical as show/hide home menu simulation are involved and home menu implementation
+ // have side-effects on DOM that may make chat window components not work
+ assert.expect(2);
+
+ this.data['mail.channel'].records.push({ id: 20 });
+ for (let i = 0; i < 10; i++) {
+ this.data['mail.message'].records.push({
+ body: "not empty",
+ channel_ids: [20],
+ });
+ }
+ await this.start();
+ await afterNextRender(() => document.querySelector(`.o_MessagingMenu_toggler`).click());
+ await this.afterEvent({
+ eventName: 'o-component-message-list-scrolled',
+ func: () => document.querySelector('.o_NotificationList_preview').click(),
+ message: "should wait until channel 20 scrolled to its last message after opening it from the messaging menu",
+ predicate: ({ scrollTop, thread }) => {
+ const messageList = document.querySelector('.o_ThreadView_messageList');
+ return (
+ thread &&
+ thread.model === 'mail.channel' &&
+ thread.id === 20 &&
+ scrollTop === messageList.scrollHeight - messageList.clientHeight
+ );
+ },
+ });
+ // Set a scroll position to chat window
+ await this.afterEvent({
+ eventName: 'o-component-message-list-scrolled',
+ func: () => {
+ document.querySelector(`.o_ThreadView_messageList`).scrollTop = 142;
+ },
+ message: "should wait until channel 20 scrolled to 142 after setting this value manually",
+ predicate: ({ scrollTop, thread }) => {
+ return (
+ thread &&
+ thread.model === 'mail.channel' &&
+ thread.id === 20 &&
+ scrollTop === 142
+ );
+ },
+ });
+ await afterNextRender(() => this.hideHomeMenu());
+ assert.strictEqual(
+ document.querySelector(`.o_ThreadView_messageList`).scrollTop,
+ 142,
+ "chat window scrollTop should still be the same after home menu is hidden"
+ );
+
+ await this.afterEvent({
+ eventName: 'o-component-message-list-scrolled',
+ func: () => this.showHomeMenu(),
+ message: "should wait until channel 20 restored its scroll to 142 after showing the home menu",
+ predicate: ({ scrollTop, thread }) => {
+ return (
+ thread &&
+ thread.model === 'mail.channel' &&
+ thread.id === 20 &&
+ scrollTop === 142
+ );
+ },
+ });
+ assert.strictEqual(
+ document.querySelector(`.o_ThreadView_messageList`).scrollTop,
+ 142,
+ "chat window scrollTop should still be the same after home menu is shown"
+ );
+});
+
+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);
+
+ // 2 channels are expected to be found in the messaging menu, each with a
+ // random unique id that will be referenced in the test
+ this.data['mail.channel'].records.push({ id: 10 }, { id: 20 });
+ await this.start({
+ env: {
+ browser: {
+ innerWidth: 1920, // enough to fit at least 2 chat windows
+ },
+ },
+ });
+ 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'].findFromIdentifyingData({
+ id: 10,
+ 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'].findFromIdentifyingData({
+ id: 10,
+ 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'].findFromIdentifyingData({
+ id: 10,
+ 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'].findFromIdentifyingData({
+ id: 20,
+ 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'].findFromIdentifyingData({
+ id: 20,
+ 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'].findFromIdentifyingData({
+ id: 10,
+ 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'].findFromIdentifyingData({
+ id: 20,
+ 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'].findFromIdentifyingData({
+ id: 10,
+ 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);
+
+ // 2 channels are expected to be found in the messaging menu
+ // only their existence matters, data are irrelevant
+ this.data['mail.channel'].records.push({}, {});
+ 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 2 folded chat windows: check shift operations are available', 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
+ * - global width: 900px
+ *
+ * 2 visible chat windows + hidden menu:
+ * 10 + 325 + 5 + 325 + 10 = 675 < 900
+ */
+ assert.expect(13);
+
+ this.data['res.partner'].records.push({ id: 7, name: "Demo" });
+ const channel = {
+ channel_type: "channel",
+ is_minimized: true,
+ is_pinned: true,
+ state: 'folded',
+ };
+ const chat = {
+ channel_type: "chat",
+ is_minimized: true,
+ is_pinned: true,
+ members: [this.data.currentPartnerId, 7],
+ state: 'folded',
+ };
+ this.data['mail.channel'].records.push(channel, chat);
+ await this.start({
+ env: {
+ browser: {
+ innerWidth: 900,
+ },
+ },
+ });
+
+ assert.containsN(
+ document.body,
+ '.o_ChatWindow',
+ 2,
+ "should have opened 2 chat windows initially"
+ );
+ assert.hasClass(
+ document.querySelector('.o_ChatWindow[data-visible-index="0"]'),
+ 'o-folded',
+ "first chat window should be folded"
+ );
+ assert.hasClass(
+ document.querySelector('.o_ChatWindow[data-visible-index="1"]'),
+ 'o-folded',
+ "second chat window should be folded"
+ );
+ assert.containsOnce(
+ document.body,
+ '.o_ChatWindow .o_ChatWindowHeader_commandShiftLeft',
+ "there should be only one chat window allowed to shift left even if folded"
+ );
+ assert.containsOnce(
+ document.body,
+ '.o_ChatWindow .o_ChatWindowHeader_commandShiftRight',
+ "there should be only one chat window allowed to shift right even if folded"
+ );
+
+ const initialFirstChatWindowThreadLocalId =
+ document.querySelector('.o_ChatWindow[data-visible-index="0"]').dataset.threadLocalId;
+ const initialSecondChatWindowThreadLocalId =
+ document.querySelector('.o_ChatWindow[data-visible-index="1"]').dataset.threadLocalId;
+ await afterNextRender(() =>
+ document.querySelector('.o_ChatWindowHeader_commandShiftLeft').click()
+ );
+ assert.strictEqual(
+ document.querySelector('.o_ChatWindow[data-visible-index="0"]').dataset.threadLocalId,
+ initialSecondChatWindowThreadLocalId,
+ "First chat window should be second after it has been shift left"
+ );
+ assert.strictEqual(
+ document.querySelector('.o_ChatWindow[data-visible-index="1"]').dataset.threadLocalId,
+ initialFirstChatWindowThreadLocalId,
+ "Second chat window should be first after the first has been shifted left"
+ );
+
+ await afterNextRender(() =>
+ document.querySelector('.o_ChatWindowHeader_commandShiftLeft').click()
+ );
+ assert.strictEqual(
+ document.querySelector('.o_ChatWindow[data-visible-index="0"]').dataset.threadLocalId,
+ initialFirstChatWindowThreadLocalId,
+ "First chat window should be back at first place"
+ );
+ assert.strictEqual(
+ document.querySelector('.o_ChatWindow[data-visible-index="1"]').dataset.threadLocalId,
+ initialSecondChatWindowThreadLocalId,
+ "Second chat window should be back at second place"
+ );
+
+ await afterNextRender(() =>
+ document.querySelector('.o_ChatWindowHeader_commandShiftRight').click()
+ );
+ assert.strictEqual(
+ document.querySelector('.o_ChatWindow[data-visible-index="0"]').dataset.threadLocalId,
+ initialSecondChatWindowThreadLocalId,
+ "First chat window should be second after it has been shift right"
+ );
+ assert.strictEqual(
+ document.querySelector('.o_ChatWindow[data-visible-index="1"]').dataset.threadLocalId,
+ initialFirstChatWindowThreadLocalId,
+ "Second chat window should be first after the first has been shifted right"
+ );
+
+ await afterNextRender(() =>
+ document.querySelector('.o_ChatWindowHeader_commandShiftRight').click()
+ );
+ assert.strictEqual(
+ document.querySelector('.o_ChatWindow[data-visible-index="0"]').dataset.threadLocalId,
+ initialFirstChatWindowThreadLocalId,
+ "First chat window should be back at first place"
+ );
+ assert.strictEqual(
+ document.querySelector('.o_ChatWindow[data-visible-index="1"]').dataset.threadLocalId,
+ initialSecondChatWindowThreadLocalId,
+ "Second chat window should be back at second place"
+ );
+});
+
+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);
+
+ // 3 channels are expected to be found in the messaging menu, each with a
+ // random unique id that will be referenced in the test
+ this.data['mail.channel'].records.push({ id: 1 }, { id: 2 }, { id: 3 });
+ await this.start({
+ env: {
+ browser: {
+ innerWidth: 900, // enough to fit 2 chat windows but not 3
+ },
+ },
+ });
+
+ // 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'].findFromIdentifyingData({
+ id: 1,
+ 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'].findFromIdentifyingData({
+ id: 2,
+ 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'].findFromIdentifyingData({
+ id: 3,
+ 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'].findFromIdentifyingData({
+ id: 1,
+ 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'].findFromIdentifyingData({
+ id: 3,
+ 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'].findFromIdentifyingData({
+ id: 3,
+ 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);
+
+ // 2 channels are expected to be found in the messaging menu
+ // with random unique id and name that will be asserted during the test
+ this.data['mail.channel'].records.push(
+ { id: 1, name: "channel1" },
+ { 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'].findFromIdentifyingData({
+ id: 1,
+ 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'].findFromIdentifyingData({
+ id: 2,
+ 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)"
+ );
+});
+
+QUnit.test('chat window: TAB cycle with 3 open chat windows [REQUIRE FOCUS]', async function (assert) {
+ /**
+ * InnerWith 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 3 visible chat windows:
+ * 10 + 325 + 5 + 325 + 5 + 325 + 10 = 1000 < 1920
+ */
+ assert.expect(6);
+
+ this.data['mail.channel'].records.push(
+ {
+ is_minimized: true,
+ is_pinned: true,
+ state: 'open',
+ },
+ {
+ is_minimized: true,
+ is_pinned: true,
+ state: 'open',
+ },
+ {
+ is_minimized: true,
+ is_pinned: true,
+ state: 'open',
+ }
+ );
+ await this.start({
+ env: {
+ browser: {
+ innerWidth: 1920,
+ },
+ },
+ });
+ assert.containsN(
+ document.body,
+ '.o_ChatWindow .o_ComposerTextInput_textarea',
+ 3,
+ "initialy, 3 chat windows should be present"
+ );
+ assert.containsNone(
+ document.body,
+ '.o_ChatWindow.o-folded',
+ "all 3 chat windows should be open"
+ );
+
+ await afterNextRender(() => {
+ document.querySelector(".o_ChatWindow[data-visible-index='2'] .o_ComposerTextInput_textarea").focus();
+ });
+ assert.strictEqual(
+ document.querySelector(".o_ChatWindow[data-visible-index='2'] .o_ComposerTextInput_textarea"),
+ document.activeElement,
+ "The chatWindow with visible-index 2 should have the focus"
+ );
+
+ await afterNextRender(() =>
+ triggerEvent(
+ document.querySelector(".o_ChatWindow[data-visible-index='2'] .o_ComposerTextInput_textarea"),
+ 'keydown',
+ { key: 'Tab' },
+ )
+ );
+ assert.strictEqual(
+ document.querySelector(".o_ChatWindow[data-visible-index='1'] .o_ComposerTextInput_textarea"),
+ document.activeElement,
+ "after pressing tab on the chatWindow with visible-index 2, the chatWindow with visible-index 1 should have focus"
+ );
+
+ await afterNextRender(() =>
+ triggerEvent(
+ document.querySelector(".o_ChatWindow[data-visible-index='1'] .o_ComposerTextInput_textarea"),
+ 'keydown',
+ { key: 'Tab' },
+ )
+ );
+ assert.strictEqual(
+ document.querySelector(".o_ChatWindow[data-visible-index='0'] .o_ComposerTextInput_textarea"),
+ document.activeElement,
+ "after pressing tab on the chat window with visible-index 1, the chatWindow with visible-index 0 should have focus"
+ );
+
+ await afterNextRender(() =>
+ triggerEvent(
+ document.querySelector(".o_ChatWindow[data-visible-index='0'] .o_ComposerTextInput_textarea"),
+ 'keydown',
+ { key: 'Tab' },
+ )
+ );
+ assert.strictEqual(
+ document.querySelector(".o_ChatWindow[data-visible-index='2'] .o_ComposerTextInput_textarea"),
+ document.activeElement,
+ "the chatWindow with visible-index 2 should have the focus after pressing tab on the chatWindow with visible-index 0"
+ );
+});
+
+QUnit.test('chat window with a thread: keep scroll position in message list on folded', async function (assert) {
+ assert.expect(3);
+
+ // channel that is expected to be found in the messaging menu
+ // with a random unique id, needed to link messages
+ this.data['mail.channel'].records.push({ id: 20 });
+ for (let i = 0; i < 10; i++) {
+ this.data['mail.message'].records.push({
+ body: "not empty",
+ channel_ids: [20],
+ });
+ }
+ await this.start();
+ await afterNextRender(() => document.querySelector(`.o_MessagingMenu_toggler`).click());
+ await this.afterEvent({
+ eventName: 'o-component-message-list-scrolled',
+ func: () => document.querySelector('.o_NotificationList_preview').click(),
+ message: "should wait until channel 20 scrolled to its last message after opening it from the messaging menu",
+ predicate: ({ scrollTop, thread }) => {
+ const messageList = document.querySelector('.o_ThreadView_messageList');
+ return (
+ thread &&
+ thread.model === 'mail.channel' &&
+ thread.id === 20 &&
+ scrollTop === messageList.scrollHeight - messageList.clientHeight
+ );
+ },
+ });
+ // Set a scroll position to chat window
+ await this.afterEvent({
+ eventName: 'o-component-message-list-scrolled',
+ func: () => {
+ document.querySelector(`.o_ThreadView_messageList`).scrollTop = 142;
+ },
+ message: "should wait until channel 20 scrolled to 142 after setting this value manually",
+ predicate: ({ scrollTop, thread }) => {
+ return (
+ thread &&
+ thread.model === 'mail.channel' &&
+ thread.id === 20 &&
+ scrollTop === 142
+ );
+ },
+ });
+ assert.strictEqual(
+ document.querySelector(`.o_ThreadView_messageList`).scrollTop,
+ 142,
+ "verify chat window initial scrollTop"
+ );
+
+ // fold chat window
+ await afterNextRender(() => document.querySelector('.o_ChatWindow_header').click());
+ assert.containsNone(
+ document.body,
+ ".o_ThreadView",
+ "chat window should be folded so no ThreadView should be present"
+ );
+
+ // unfold chat window
+ await afterNextRender(() => this.afterEvent({
+ eventName: 'o-component-message-list-scrolled',
+ func: () => document.querySelector('.o_ChatWindow_header').click(),
+ message: "should wait until channel 20 restored its scroll position to 142",
+ predicate: ({ scrollTop, thread }) => {
+ return (
+ thread &&
+ thread.model === 'mail.channel' &&
+ thread.id === 20 &&
+ scrollTop === 142
+
+ );
+ },
+ }));
+ assert.strictEqual(
+ document.querySelector(`.o_ThreadView_messageList`).scrollTop,
+ 142,
+ "chat window scrollTop should still be the same when chat window is unfolded"
+ );
+});
+
+QUnit.test('chat window should scroll to the newly posted message just after posting it', async function (assert) {
+ assert.expect(1);
+
+ this.data['mail.channel'].records.push({
+ id: 20,
+ is_minimized: true,
+ state: 'open',
+ });
+ for (let i = 0; i < 10; i++) {
+ this.data['mail.message'].records.push({
+ body: "not empty",
+ channel_ids: [20],
+ });
+ }
+ await this.start();
+
+ // Set content of the composer of the chat window
+ await afterNextRender(() => {
+ document.querySelector('.o_ComposerTextInput_textarea').focus();
+ document.execCommand('insertText', false, 'WOLOLO');
+ });
+ // Send a new message in the chatwindow to trigger the scroll
+ await afterNextRender(() =>
+ triggerEvent(
+ document.querySelector('.o_ChatWindow .o_ComposerTextInput_textarea'),
+ 'keydown',
+ { key: 'Enter' },
+ )
+ );
+ const messageList = document.querySelector('.o_MessageList');
+ assert.strictEqual(
+ messageList.scrollHeight - messageList.scrollTop,
+ messageList.clientHeight,
+ "chat window should scroll to the newly posted message just after posting it"
+ );
+});
+
+QUnit.test('chat window: post message on non-mailing channel with "CTRL-Enter" keyboard shortcut for small screen size', async function (assert) {
+ assert.expect(1);
+
+ this.data['mail.channel'].records.push({
+ id: 20,
+ is_minimized: true,
+ mass_mailing: false,
+ });
+ await this.start({
+ env: {
+ device: {
+ isMobile: true, // here isMobile is used for the small screen size, not actually for the mobile devices
+ },
+ },
+ });
+
+ await afterNextRender(() => document.querySelector(`.o_MessagingMenu_toggler`).click());
+ await afterNextRender(() =>
+ document.querySelector(`.o_MessagingMenu_dropdownMenu .o_NotificationList_preview`).click()
+ );
+ // insert some HTML in editable
+ await afterNextRender(() => {
+ document.querySelector(`.o_ComposerTextInput_textarea`).focus();
+ document.execCommand('insertText', false, "Test");
+ });
+ await afterNextRender(() => {
+ const kevt = new window.KeyboardEvent('keydown', { ctrlKey: true, key: "Enter" });
+ document.querySelector('.o_ComposerTextInput_textarea').dispatchEvent(kevt);
+ });
+ assert.containsOnce(
+ document.body,
+ '.o_Message',
+ "should now have single message in channel after posting message from pressing 'CTRL-Enter' in text input of composer for small screen"
+ );
+});
+
+QUnit.test('[technical] chat window: composer state conservation on toggle home menu when folded', async function (assert) {
+ // technical as show/hide home menu simulation are involved and home menu implementation
+ // have side-effects on DOM that may make chat window components not work
+ assert.expect(6);
+
+ // channel that is expected to be found in the messaging menu
+ // only its existence matters, data are irrelevant
+ this.data['mail.channel'].records.push({});
+ await this.start();
+ await afterNextRender(() => document.querySelector(`.o_MessagingMenu_toggler`).click());
+ await afterNextRender(() =>
+ document.querySelector(`.o_MessagingMenu_dropdownMenu .o_NotificationList_preview`).click()
+ );
+ // Set content of the composer of the chat window
+ await afterNextRender(() => {
+ document.querySelector(`.o_ComposerTextInput_textarea`).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_ComposerTextInput_textarea`).value,
+ "XDU for the win !",
+ "verify chat window composer initial html input"
+ );
+ assert.containsN(
+ document.body,
+ '.o_Composer .o_Attachment',
+ 2,
+ "verify chat window composer initial attachment count"
+ );
+
+ // fold chat window
+ await afterNextRender(() => document.querySelector('.o_ChatWindow_header').click());
+ await this.hideHomeMenu();
+ // unfold chat window
+ await afterNextRender(() => document.querySelector('.o_ChatWindow_header').click());
+ assert.strictEqual(
+ document.querySelector(`.o_ComposerTextInput_textarea`).value,
+ "XDU for the win !",
+ "Chat window composer should still have the same input after hiding home menu"
+ );
+ assert.containsN(
+ document.body,
+ '.o_Composer .o_Attachment',
+ 2,
+ "Chat window composer should have 2 attachments after hiding home menu"
+ );
+
+ // fold chat window
+ await afterNextRender(() => document.querySelector('.o_ChatWindow_header').click());
+ await this.showHomeMenu();
+ // unfold chat window
+ await afterNextRender(() => document.querySelector('.o_ChatWindow_header').click());
+ assert.strictEqual(
+ document.querySelector(`.o_ComposerTextInput_textarea`).value,
+ "XDU for the win !",
+ "chat window composer should still have the same input after showing home menu"
+ );
+ assert.containsN(
+ document.body,
+ '.o_Composer .o_Attachment',
+ 2,
+ "Chat window composer should have 2 attachments after showing home menu"
+ );
+});
+
+QUnit.test('[technical] chat window with a thread: keep scroll position in message list on toggle home menu when folded', async function (assert) {
+ // technical as show/hide home menu simulation are involved and home menu implementation
+ // have side-effects on DOM that may make chat window components not work
+ assert.expect(2);
+
+ // channel that is expected to be found in the messaging menu
+ // with random unique id, needed to link messages
+ this.data['mail.channel'].records.push({ id: 20 });
+ for (let i = 0; i < 10; i++) {
+ this.data['mail.message'].records.push({
+ body: "not empty",
+ channel_ids: [20],
+ });
+ }
+ await this.start();
+ await afterNextRender(() => document.querySelector(`.o_MessagingMenu_toggler`).click());
+ await this.afterEvent({
+ eventName: 'o-component-message-list-scrolled',
+ func: () => document.querySelector('.o_NotificationList_preview').click(),
+ message: "should wait until channel 20 scrolled to its last message after opening it from the messaging menu",
+ predicate: ({ scrollTop, thread }) => {
+ const messageList = document.querySelector('.o_ThreadView_messageList');
+ return (
+ thread &&
+ thread.model === 'mail.channel' &&
+ thread.id === 20 &&
+ scrollTop === messageList.scrollHeight - messageList.clientHeight
+ );
+ },
+ });
+ // Set a scroll position to chat window
+ await this.afterEvent({
+ eventName: 'o-component-message-list-scrolled',
+ func: () => document.querySelector(`.o_ThreadView_messageList`).scrollTop = 142,
+ message: "should wait until channel 20 scrolled to 142 after setting this value manually",
+ predicate: ({ scrollTop, thread }) => {
+ return (
+ thread &&
+ thread.model === 'mail.channel' &&
+ thread.id === 20 &&
+ scrollTop === 142
+ );
+ },
+ });
+ // fold chat window
+ await afterNextRender(() => document.querySelector('.o_ChatWindow_header').click());
+ await this.hideHomeMenu();
+ // unfold chat window
+ await this.afterEvent({
+ eventName: 'o-component-message-list-scrolled',
+ func: () => document.querySelector('.o_ChatWindow_header').click(),
+ message: "should wait until channel 20 restored its scroll to 142 after unfolding it",
+ predicate: ({ scrollTop, thread }) => {
+ return (
+ thread &&
+ thread.model === 'mail.channel' &&
+ thread.id === 20 &&
+ scrollTop === 142
+ );
+ },
+ });
+ assert.strictEqual(
+ document.querySelector(`.o_ThreadView_messageList`).scrollTop,
+ 142,
+ "chat window scrollTop should still be the same after home menu is hidden"
+ );
+
+ // fold chat window
+ await afterNextRender(() => document.querySelector('.o_ChatWindow_header').click());
+ // Show home menu
+ await this.showHomeMenu();
+ // unfold chat window
+ await this.afterEvent({
+ eventName: 'o-component-message-list-scrolled',
+ func: () => document.querySelector('.o_ChatWindow_header').click(),
+ message: "should wait until channel 20 restored its scroll position to the last saved value (142)",
+ predicate: ({ scrollTop, thread }) => {
+ return (
+ thread &&
+ thread.model === 'mail.channel' &&
+ thread.id === 20 &&
+ scrollTop === 142
+ );
+ },
+ });
+ assert.strictEqual(
+ document.querySelector(`.o_ThreadView_messageList`).scrollTop,
+ 142,
+ "chat window scrollTop should still be the same after home menu is shown"
+ );
+});
+
+QUnit.test('chat window does not fetch messages if hidden', 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(14);
+
+ // 3 channels are expected to be found in the messaging menu, each with a
+ // random unique id that will be referenced in the test
+ this.data['mail.channel'].records = [
+ {
+ id: 10,
+ is_minimized: true,
+ name: "Channel #10",
+ state: 'open',
+ },
+ {
+ id: 11,
+ is_minimized: true,
+ name: "Channel #11",
+ state: 'open',
+ },
+ {
+ id: 12,
+ is_minimized: true,
+ name: "Channel #12",
+ state: 'open',
+ },
+ ];
+ await this.start({
+ env: {
+ browser: {
+ innerWidth: 900,
+ },
+ },
+ mockRPC(route, args) {
+ if (args.method === 'message_fetch') {
+ // domain should be like [['channel_id', 'in', [X]]] with X the channel id
+ const channel_ids = args.kwargs.domain[0][2];
+ assert.strictEqual(channel_ids.length, 1, "messages should be fetched channel per channel");
+ assert.step(`rpc:message_fetch:${channel_ids[0]}`);
+ }
+ return this._super(...arguments);
+ },
+ });
+
+ assert.containsN(
+ document.body,
+ '.o_ChatWindow',
+ 2,
+ "2 chat windows should be visible"
+ );
+ assert.containsNone(
+ document.body,
+ `.o_ChatWindow[data-thread-local-id="${
+ this.env.models['mail.thread'].findFromIdentifyingData({
+ id: 12,
+ model: 'mail.channel',
+ }).localId
+ }"]`,
+ "chat window for Channel #12 should be hidden"
+ );
+ assert.containsOnce(
+ document.body,
+ '.o_ChatWindowHiddenMenu',
+ "chat window hidden menu should be displayed"
+ );
+ assert.verifySteps(
+ ['rpc:message_fetch:10', 'rpc:message_fetch:11'],
+ "messages should be fetched for the two visible chat windows"
+ );
+
+ await afterNextRender(() =>
+ document.querySelector('.o_ChatWindowHiddenMenu_dropdownToggle').click()
+ );
+ assert.containsOnce(
+ document.body,
+ '.o_ChatWindowHiddenMenu_chatWindowHeader',
+ "1 hidden chat window should be listed in hidden menu"
+ );
+
+ await afterNextRender(() =>
+ document.querySelector('.o_ChatWindowHiddenMenu_chatWindowHeader').click()
+ );
+ assert.containsN(
+ document.body,
+ '.o_ChatWindow',
+ 2,
+ "2 chat windows should still be visible"
+ );
+ assert.containsOnce(
+ document.body,
+ `.o_ChatWindow[data-thread-local-id="${
+ this.env.models['mail.thread'].findFromIdentifyingData({
+ id: 12,
+ model: 'mail.channel',
+ }).localId
+ }"]`,
+ "chat window for Channel #12 should now be visible"
+ );
+ assert.verifySteps(
+ ['rpc:message_fetch:12'],
+ "messages should now be fetched for Channel #12"
+ );
+});
+
+QUnit.test('new message separator is shown in a chat window of a chat on receiving new message if there is a history of conversation', async function (assert) {
+ assert.expect(3);
+
+ this.data['res.partner'].records.push({ id: 10, name: "Demo" });
+ this.data['res.users'].records.push({
+ id: 42,
+ name: "Foreigner user",
+ partner_id: 10,
+ });
+ this.data['mail.channel'].records = [
+ {
+ channel_type: "chat",
+ id: 10,
+ is_minimized: true,
+ is_pinned: false,
+ members: [this.data.currentPartnerId, 10],
+ uuid: 'channel-10-uuid',
+ },
+ ];
+ this.data['mail.message'].records.push({
+ body: "not empty",
+ channel_ids: [10],
+ model: 'mail.channel',
+ res_id: 10,
+ });
+ await this.start();
+
+ // simulate receiving a message
+ await afterNextRender(async () => this.env.services.rpc({
+ route: '/mail/chat_post',
+ params: {
+ context: {
+ mockedUserId: 42,
+ },
+ message_content: "hu",
+ uuid: 'channel-10-uuid',
+ },
+ }));
+ assert.containsOnce(
+ document.body,
+ '.o_ChatWindow',
+ "a chat window should be visible after receiving a new message from a chat"
+ );
+ assert.containsN(
+ document.body,
+ '.o_Message',
+ 2,
+ "chat window should have 2 messages"
+ );
+ assert.containsOnce(
+ document.body,
+ '.o_MessageList_separatorNewMessages',
+ "should display 'new messages' separator in the conversation, from reception of new messages"
+ );
+});
+
+QUnit.test('new message separator is not shown in a chat window of a chat on receiving new message if there is no history of conversation', async function (assert) {
+ assert.expect(1);
+
+ this.data['res.partner'].records.push({ id: 10, name: "Demo" });
+ this.data['res.users'].records.push({
+ id: 42,
+ name: "Foreigner user",
+ partner_id: 10,
+ });
+ this.data['mail.channel'].records = [{
+ channel_type: "chat",
+ id: 10,
+ members: [this.data.currentPartnerId, 10],
+ uuid: 'channel-10-uuid',
+ }];
+ await this.start();
+
+ // simulate receiving a message
+ await afterNextRender(async () => this.env.services.rpc({
+ route: '/mail/chat_post',
+ params: {
+ context: {
+ mockedUserId: 42,
+ },
+ message_content: "hu",
+ uuid: 'channel-10-uuid',
+ },
+ }));
+ assert.containsNone(
+ document.body,
+ '.o_MessageList_separatorNewMessages',
+ "should not display 'new messages' separator in the conversation of a chat on receiving new message if there is no history of conversation"
+ );
+});
+
+QUnit.test('focusing a chat window of a chat should make new message separator disappear [REQUIRE FOCUS]', async function (assert) {
+ assert.expect(2);
+
+ this.data['res.partner'].records.push({ id: 10, name: "Demo" });
+ this.data['res.users'].records.push({
+ id: 42,
+ name: "Foreigner user",
+ partner_id: 10,
+ });
+ this.data['mail.channel'].records.push(
+ {
+ channel_type: "chat",
+ id: 10,
+ is_minimized: true,
+ is_pinned: false,
+ members: [this.data.currentPartnerId, 10],
+ message_unread_counter: 0,
+ uuid: 'channel-10-uuid',
+ },
+ );
+ this.data['mail.message'].records.push({
+ body: "not empty",
+ channel_ids: [10],
+ model: 'mail.channel',
+ res_id: 10,
+ });
+ await this.start();
+
+ // simulate receiving a message
+ await afterNextRender(() => this.env.services.rpc({
+ route: '/mail/chat_post',
+ params: {
+ context: {
+ mockedUserId: 42,
+ },
+ message_content: "hu",
+ uuid: 'channel-10-uuid',
+ },
+ }));
+ assert.containsOnce(
+ document.body,
+ '.o_MessageList_separatorNewMessages',
+ "should display 'new messages' separator in the conversation, from reception of new messages"
+ );
+
+ await afterNextRender(() => this.afterEvent({
+ eventName: 'o-thread-last-seen-by-current-partner-message-id-changed',
+ func: () => document.querySelector('.o_ComposerTextInput_textarea').focus(),
+ message: "should wait until last seen by current partner message id changed",
+ predicate: ({ thread }) => {
+ return (
+ thread.id === 10 &&
+ thread.model === 'mail.channel'
+ );
+ },
+ }));
+ assert.containsNone(
+ document.body,
+ '.o_MessageList_separatorNewMessages',
+ "new message separator should no longer be shown, after focus on composer text input of chat window"
+ );
+});
+
+QUnit.test('chat window should remain folded when new message is received', async function (assert) {
+ assert.expect(1);
+
+ this.data['res.partner'].records.push({ id: 10, name: "Demo" });
+ this.data['res.users'].records.push({
+ id: 42,
+ name: "Foreigner user",
+ partner_id: 10,
+ });
+ this.data['mail.channel'].records = [
+ {
+ channel_type: "chat",
+ id: 10,
+ is_minimized: true,
+ is_pinned: false,
+ members: [this.data.currentPartnerId, 10],
+ state: 'folded',
+ uuid: 'channel-10-uuid',
+ },
+ ];
+
+ await this.start();
+ // simulate receiving a new message
+ await afterNextRender(async () => this.env.services.rpc({
+ route: '/mail/chat_post',
+ params: {
+ context: {
+ mockedUserId: 42,
+ },
+ message_content: "New Message 2",
+ uuid: 'channel-10-uuid',
+ },
+ }));
+ assert.hasClass(
+ document.querySelector(`.o_ChatWindow`),
+ 'o-folded',
+ "chat window should remain folded"
+ );
+});
+
+});
+});
+});
+
+});
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 00000000..3f6ca7dc
--- /dev/null
+++ b/addons/mail/static/src/components/chatter/chatter.js
@@ -0,0 +1,150 @@
+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'),
+ ThreadView: require('mail/static/src/components/thread_view/thread_view.js'),
+};
+const useShouldUpdateBasedOnProps = require('mail/static/src/component_hooks/use_should_update_based_on_props/use_should_update_based_on_props.js');
+const useStore = require('mail/static/src/component_hooks/use_store/use_store.js');
+const useUpdate = require('mail/static/src/component_hooks/use_update/use_update.js');
+
+const { Component } = owl;
+const { useRef } = owl.hooks;
+
+class Chatter extends Component {
+
+ /**
+ * @override
+ */
+ constructor(...args) {
+ super(...args);
+ useShouldUpdateBasedOnProps();
+ 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,
+ composer: thread && thread.composer,
+ thread,
+ threadActivitiesLength: thread && thread.activities.length,
+ };
+ }, {
+ compareDepth: {
+ attachments: 1,
+ },
+ });
+ useUpdate({ func: () => this._update() });
+ /**
+ * Reference of the composer. Useful to focus it.
+ */
+ this._composerRef = useRef('composer');
+ /**
+ * Reference of the scroll Panel (Real scroll element). Useful to pass the Scroll element to
+ * child component to handle proper scrollable element.
+ */
+ this._scrollPanelRef = useRef('scrollPanel');
+ /**
+ * Reference of the message list. Useful to trigger the scroll event on it.
+ */
+ this._threadRef = useRef('thread');
+ this.getScrollableElement = this.getScrollableElement.bind(this);
+ }
+
+ //--------------------------------------------------------------------------
+ // Public
+ //--------------------------------------------------------------------------
+
+ /**
+ * @returns {mail.chatter}
+ */
+ get chatter() {
+ return this.env.models['mail.chatter'].get(this.props.chatterLocalId);
+ }
+
+ /**
+ * @returns {Element|undefined} Scrollable Element
+ */
+ getScrollableElement() {
+ if (!this._scrollPanelRef.el) {
+ return;
+ }
+ return this._scrollPanelRef.el;
+ }
+
+ //--------------------------------------------------------------------------
+ // Private
+ //--------------------------------------------------------------------------
+
+ /**
+ * @private
+ */
+ _notifyRendered() {
+ this.trigger('o-chatter-rendered', {
+ attachments: this.chatter.thread.allAttachments,
+ thread: this.chatter.thread.localId,
+ });
+ }
+
+ /**
+ * @private
+ */
+ _update() {
+ if (!this.chatter) {
+ return;
+ }
+ if (this.chatter.thread) {
+ this._notifyRendered();
+ }
+ if (this.chatter.isDoFocus) {
+ this.chatter.update({ isDoFocus: false });
+ const composer = this._composerRef.comp;
+ if (composer) {
+ composer.focus();
+ }
+ }
+ }
+
+ //--------------------------------------------------------------------------
+ // Handlers
+ //--------------------------------------------------------------------------
+
+ /**
+ * @private
+ */
+ _onComposerMessagePosted() {
+ this.chatter.update({ isComposerVisible: false });
+ }
+
+ /**
+ * @private
+ * @param {MouseEvent} ev
+ */
+ _onScrollPanelScroll(ev) {
+ if (!this._threadRef.comp) {
+ return;
+ }
+ this._threadRef.comp.onScroll(ev);
+ }
+
+}
+
+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 00000000..d722e03b
--- /dev/null
+++ b/addons/mail/static/src/components/chatter/chatter.scss
@@ -0,0 +1,42 @@
+// ------------------------------------------------------------------
+// Layout
+// ------------------------------------------------------------------
+
+.o_Chatter {
+ position: relative;
+ display: flex;
+ flex: 1 1 auto;
+ flex-direction: column;
+ width: map-get($sizes, 100);
+}
+
+.o_Chatter_composer {
+ border-bottom: $border-width solid;
+
+ &.o-bordered {
+ border-left: $border-width solid;
+ border-right: $border-width solid;
+ }
+}
+
+.o_Chatter_scrollPanel {
+ overflow-y: auto;
+}
+
+// ------------------------------------------------------------------
+// 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 00000000..d9cf20b4
--- /dev/null
+++ b/addons/mail/static/src/components/chatter/chatter.xml
@@ -0,0 +1,56 @@
+<?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">
+ <div class="o_Chatter_fixedPanel">
+ <ChatterTopbar
+ class="o_Chatter_topbar"
+ chatterLocalId="chatter.localId"
+ />
+ <t t-if="chatter.threadView and chatter.isComposerVisible">
+ <Composer
+ class="o_Chatter_composer"
+ t-att-class="{ 'o-bordered': chatter.hasExternalBorder }"
+ composerLocalId="chatter.thread.composer.localId"
+ hasFollowers="true"
+ hasMentionSuggestionsBelowPosition="true"
+ isCompact="false"
+ isExpandable="true"
+ textInputSendShortcuts="['ctrl-enter', 'meta-enter']"
+ t-on-o-message-posted="_onComposerMessagePosted"
+ t-ref="composer"
+ />
+ </t>
+ </div>
+ <div class="o_Chatter_scrollPanel" t-on-scroll="_onScrollPanelScroll" t-ref="scrollPanel">
+ <t t-if="chatter.isAttachmentBoxVisible">
+ <AttachmentBox
+ class="o_Chatter_attachmentBox"
+ threadLocalId="chatter.thread.localId"
+ />
+ </t>
+ <t t-if="chatter.thread and chatter.hasActivities and chatter.thread.activities.length > 0">
+ <ActivityBox
+ class="o_Chatter_activityBox"
+ chatterLocalId="chatter.localId"
+ />
+ </t>
+ <t t-if="chatter.threadView">
+ <ThreadView
+ class="o_Chatter_thread"
+ getScrollableElement="getScrollableElement"
+ hasComposer="false"
+ hasScrollAdjust="chatter.hasMessageListScrollAdjust"
+ order="'desc'"
+ threadViewLocalId="chatter.threadView.localId"
+ t-ref="thread"
+ />
+ </t>
+ </div>
+ </t>
+ </div>
+ </t>
+
+</templates>
diff --git a/addons/mail/static/src/components/chatter/chatter_suggested_recipient_tests.js b/addons/mail/static/src/components/chatter/chatter_suggested_recipient_tests.js
new file mode 100644
index 00000000..9cb57d83
--- /dev/null
+++ b/addons/mail/static/src/components/chatter/chatter_suggested_recipient_tests.js
@@ -0,0 +1,420 @@
+odoo.define('mail/static/src/components/chatter/chatter_suggested_recipient_tests', function (require) {
+'use strict';
+
+const components = {
+ Chatter: require('mail/static/src/components/chatter/chatter.js'),
+ Composer: require('mail/static/src/components/composer/composer.js'),
+};
+const {
+ afterEach,
+ afterNextRender,
+ beforeEach,
+ createRootComponent,
+ start,
+} = require('mail/static/src/utils/test_utils.js');
+
+QUnit.module('mail', {}, function () {
+QUnit.module('components', {}, function () {
+QUnit.module('chatter', {}, function () {
+QUnit.module('chatter_suggested_recipients_tests.js', {
+ beforeEach() {
+ beforeEach(this);
+
+ this.createChatterComponent = async ({ chatter }, otherProps) => {
+ const props = Object.assign({ chatterLocalId: chatter.localId }, otherProps);
+ await createRootComponent(this, components.Chatter, {
+ props,
+ target: this.widget.el,
+ });
+ };
+
+ this.start = async params => {
+ const { env, widget } = await start(Object.assign({}, params, {
+ data: this.data,
+ }));
+ this.env = env;
+ this.widget = widget;
+ };
+ },
+ afterEach() {
+ afterEach(this);
+ },
+});
+
+QUnit.test("suggest recipient on 'Send message' composer", async function (assert) {
+ assert.expect(1);
+
+ this.data['res.partner'].records.push({
+ display_name: "John Jane",
+ email: "john@jane.be",
+ id: 100,
+ });
+ this.data['res.fake'].records.push({
+ id: 10,
+ email_cc: "john@test.be",
+ partner_ids: [100],
+ });
+ await this.start ();
+ const chatter = this.env.models['mail.chatter'].create({
+ threadId: 10,
+ threadModel: 'res.fake',
+ });
+ await this.createChatterComponent({ chatter });
+ await afterNextRender(() =>
+ document.querySelector(`.o_ChatterTopbar_buttonSendMessage`).click()
+ );
+ assert.containsOnce(
+ document.body,
+ '.o_ComposerSuggestedRecipientList',
+ "Should display a list of suggested recipients after opening the composer from 'Send message' button"
+ );
+});
+
+QUnit.test("with 3 or less suggested recipients: no 'show more' button", async function (assert) {
+ assert.expect(1);
+
+ this.data['res.partner'].records.push({
+ display_name: "John Jane",
+ email: "john@jane.be",
+ id: 100,
+ });
+ this.data['res.fake'].records.push({
+ id: 10,
+ email_cc: "john@test.be",
+ partner_ids: [100],
+ });
+ await this.start ();
+ const chatter = this.env.models['mail.chatter'].create({
+ threadId: 10,
+ threadModel: 'res.fake',
+ });
+ await this.createChatterComponent({ chatter });
+ await afterNextRender(() =>
+ document.querySelector(`.o_ChatterTopbar_buttonSendMessage`).click()
+ );
+ assert.containsNone(
+ document.body,
+ '.o_ComposerSuggestedRecipientList_showMore',
+ "should not display 'show more' button with 3 or less suggested recipients"
+ );
+});
+
+QUnit.test("display reason for suggested recipient on mouse over", async function (assert) {
+ assert.expect(1);
+
+ this.data['res.partner'].records.push({
+ display_name: "John Jane",
+ email: "john@jane.be",
+ id: 100,
+ });
+ this.data['res.fake'].records.push({
+ id: 10,
+ partner_ids: [100],
+ });
+ await this.start();
+ const chatter = this.env.models['mail.chatter'].create({
+ threadId: 10,
+ threadModel: 'res.fake',
+ });
+ await this.createChatterComponent({ chatter });
+ await afterNextRender(() =>
+ document.querySelector(`.o_ChatterTopbar_buttonSendMessage`).click()
+ );
+ const partnerTitle = document.querySelector('.o_ComposerSuggestedRecipient[data-partner-id="100"]').getAttribute('title');
+ assert.strictEqual(
+ partnerTitle,
+ "Add as recipient and follower (reason: Email partner)",
+ "must display reason for suggested recipient on mouse over",
+ );
+});
+
+QUnit.test("suggested recipient without partner are unchecked by default", async function (assert) {
+ assert.expect(1);
+
+ this.data['res.fake'].records.push({
+ id: 10,
+ email_cc: "john@test.be",
+ });
+ await this.start();
+ const chatter = this.env.models['mail.chatter'].create({
+ threadId: 10,
+ threadModel: 'res.fake',
+ });
+ await this.createChatterComponent({ chatter });
+ await afterNextRender(() =>
+ document.querySelector(`.o_ChatterTopbar_buttonSendMessage`).click()
+ );
+ const checkboxUnchecked = document.querySelector('.o_ComposerSuggestedRecipient:not([data-partner-id]) input[type=checkbox]');
+ assert.notOk(
+ checkboxUnchecked.checked,
+ "suggested recipient without partner must be unchecked by default",
+ );
+});
+
+QUnit.test("suggested recipient with partner are checked by default", async function (assert) {
+ assert.expect(1);
+
+ this.data['res.partner'].records.push({
+ display_name: "John Jane",
+ email: "john@jane.be",
+ id: 100,
+ });
+ this.data['res.fake'].records.push({
+ id: 10,
+ partner_ids: [100],
+ });
+ await this.start();
+ const chatter = this.env.models['mail.chatter'].create({
+ threadId: 10,
+ threadModel: 'res.fake',
+ });
+ await this.createChatterComponent({ chatter });
+ await afterNextRender(() =>
+ document.querySelector(`.o_ChatterTopbar_buttonSendMessage`).click()
+ );
+ const checkboxChecked = document.querySelector('.o_ComposerSuggestedRecipient[data-partner-id="100"] input[type=checkbox]');
+ assert.ok(
+ checkboxChecked.checked,
+ "suggested recipient with partner must be checked by default",
+ );
+});
+
+QUnit.test("more than 3 suggested recipients: display only 3 and 'show more' button", async function (assert) {
+ assert.expect(1);
+
+ this.data['res.partner'].records.push({
+ display_name: "John Jane",
+ email: "john@jane.be",
+ id: 100,
+ });
+ this.data['res.partner'].records.push({
+ display_name: "Jack Jone",
+ email: "jack@jone.be",
+ id: 1000,
+ });
+ this.data['res.partner'].records.push({
+ display_name: "jolly Roger",
+ email: "Roger@skullflag.com",
+ id: 1001,
+ });
+ this.data['res.partner'].records.push({
+ display_name: "jack sparrow",
+ email: "jsparrow@blackpearl.bb",
+ id: 1002,
+ });
+ this.data['res.fake'].records.push({
+ id: 10,
+ partner_ids: [100, 1000, 1001, 1002],
+ });
+ await this.start ();
+ const chatter = this.env.models['mail.chatter'].create({
+ threadId: 10,
+ threadModel: 'res.fake',
+ });
+ await this.createChatterComponent({ chatter });
+
+ await afterNextRender(() =>
+ document.querySelector(`.o_ChatterTopbar_buttonSendMessage`).click()
+ );
+ assert.containsOnce(
+ document.body,
+ '.o_ComposerSuggestedRecipientList_showMore',
+ "more than 3 suggested recipients display 'show more' button"
+ );
+});
+
+QUnit.test("more than 3 suggested recipients: show all of them on click 'show more' button", async function (assert) {
+ assert.expect(1);
+
+ this.data['res.partner'].records.push({
+ display_name: "John Jane",
+ email: "john@jane.be",
+ id: 100,
+ });
+ this.data['res.partner'].records.push({
+ display_name: "Jack Jone",
+ email: "jack@jone.be",
+ id: 1000,
+ });
+ this.data['res.partner'].records.push({
+ display_name: "jolly Roger",
+ email: "Roger@skullflag.com",
+ id: 1001,
+ });
+ this.data['res.partner'].records.push({
+ display_name: "jack sparrow",
+ email: "jsparrow@blackpearl.bb",
+ id: 1002,
+ });
+ this.data['res.fake'].records.push({
+ id: 10,
+ partner_ids: [100, 1000, 1001, 1002],
+ });
+ await this.start ();
+ const chatter = this.env.models['mail.chatter'].create({
+ threadId: 10,
+ threadModel: 'res.fake',
+ });
+ await this.createChatterComponent({ chatter });
+
+ await afterNextRender(() =>
+ document.querySelector(`.o_ChatterTopbar_buttonSendMessage`).click()
+ );
+ await afterNextRender(() =>
+ document.querySelector(`.o_ComposerSuggestedRecipientList_showMore`).click()
+ );
+ assert.containsN(
+ document.body,
+ '.o_ComposerSuggestedRecipient',
+ 4,
+ "more than 3 suggested recipients: show all of them on click 'show more' button"
+ );
+});
+
+QUnit.test("more than 3 suggested recipients -> click 'show more' -> 'show less' button", async function (assert) {
+ assert.expect(1);
+
+ this.data['res.partner'].records.push({
+ display_name: "John Jane",
+ email: "john@jane.be",
+ id: 100,
+ });
+ this.data['res.partner'].records.push({
+ display_name: "Jack Jone",
+ email: "jack@jone.be",
+ id: 1000,
+ });
+ this.data['res.partner'].records.push({
+ display_name: "jolly Roger",
+ email: "Roger@skullflag.com",
+ id: 1001,
+ });
+ this.data['res.partner'].records.push({
+ display_name: "jack sparrow",
+ email: "jsparrow@blackpearl.bb",
+ id: 1002,
+ });
+ this.data['res.fake'].records.push({
+ id: 10,
+ partner_ids: [100, 1000, 1001, 1002],
+ });
+ await this.start ();
+ const chatter = this.env.models['mail.chatter'].create({
+ threadId: 10,
+ threadModel: 'res.fake',
+ });
+ await this.createChatterComponent({ chatter });
+
+ await afterNextRender(() =>
+ document.querySelector(`.o_ChatterTopbar_buttonSendMessage`).click()
+ );
+ await afterNextRender(() =>
+ document.querySelector(`.o_ComposerSuggestedRecipientList_showMore`).click()
+ );
+ assert.containsOnce(
+ document.body,
+ '.o_ComposerSuggestedRecipientList_showLess',
+ "more than 3 suggested recipients -> click 'show more' -> 'show less' button"
+ );
+});
+
+QUnit.test("suggested recipients list display 3 suggested recipient and 'show more' button when 'show less' button is clicked", async function (assert) {
+ assert.expect(2);
+
+ this.data['res.partner'].records.push({
+ display_name: "John Jane",
+ email: "john@jane.be",
+ id: 100,
+ });
+ this.data['res.partner'].records.push({
+ display_name: "Jack Jone",
+ email: "jack@jone.be",
+ id: 1000,
+ });
+ this.data['res.partner'].records.push({
+ display_name: "jolly Roger",
+ email: "Roger@skullflag.com",
+ id: 1001,
+ });
+ this.data['res.partner'].records.push({
+ display_name: "jack sparrow",
+ email: "jsparrow@blackpearl.bb",
+ id: 1002,
+ });
+ this.data['res.fake'].records.push({
+ id: 10,
+ partner_ids: [100, 1000, 1001, 1002],
+ });
+ await this.start ();
+ const chatter = this.env.models['mail.chatter'].create({
+ threadId: 10,
+ threadModel: 'res.fake',
+ });
+ await this.createChatterComponent({ chatter });
+
+ await afterNextRender(() =>
+ document.querySelector(`.o_ChatterTopbar_buttonSendMessage`).click()
+ );
+ await afterNextRender(() =>
+ document.querySelector(`.o_ComposerSuggestedRecipientList_showMore`).click()
+ );
+ await afterNextRender(() =>
+ document.querySelector(`.o_ComposerSuggestedRecipientList_showLess`).click()
+ );
+ assert.containsN(
+ document.body,
+ '.o_ComposerSuggestedRecipient',
+ 3,
+ "suggested recipient list should display 3 suggested recipients after clicking on 'show less'."
+ );
+ assert.containsOnce(
+ document.body,
+ '.o_ComposerSuggestedRecipientList_showMore',
+ "suggested recipient list should containt a 'show More' button after clicking on 'show less'."
+ );
+});
+
+QUnit.test("suggested recipients should not be notified when posting an internal note", async function (assert) {
+ assert.expect(1);
+
+ this.data['res.partner'].records.push({
+ display_name: "John Jane",
+ email: "john@jane.be",
+ id: 100,
+ });
+ this.data['res.fake'].records.push({
+ id: 10,
+ partner_ids: [100],
+ });
+ await this.start({
+ async mockRPC(route, args) {
+ if (args.model === 'res.fake' && args.method === 'message_post') {
+ assert.strictEqual(
+ args.kwargs.partner_ids.length,
+ 0,
+ "message_post should not contain suggested recipients when posting an internal note"
+ );
+ }
+ return this._super(...arguments);
+ },
+ });
+ const chatter = this.env.models['mail.chatter'].create({
+ threadId: 10,
+ threadModel: 'res.fake',
+ });
+ await this.createChatterComponent({ chatter });
+ await afterNextRender(() =>
+ document.querySelector(`.o_ChatterTopbar_buttonLogNote`).click()
+ );
+ document.querySelector('.o_ComposerTextInput_textarea').focus();
+ await afterNextRender(() => document.execCommand('insertText', false, "Dummy Message"));
+ await afterNextRender(() => {
+ document.querySelector('.o_Composer_buttonSend').click();
+ });
+});
+
+});
+});
+});
+
+});
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 00000000..15163e85
--- /dev/null
+++ b/addons/mail/static/src/components/chatter/chatter_tests.js
@@ -0,0 +1,469 @@
+odoo.define('mail/static/src/components/chatter/chatter_tests', function (require) {
+'use strict';
+
+const components = {
+ Chatter: require('mail/static/src/components/chatter/chatter.js'),
+ Composer: require('mail/static/src/components/composer/composer.js'),
+};
+const {
+ afterEach,
+ afterNextRender,
+ beforeEach,
+ createRootComponent,
+ nextAnimationFrame,
+ start,
+} = 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() {
+ beforeEach(this);
+
+ this.createChatterComponent = async ({ chatter }, otherProps) => {
+ const props = Object.assign({ chatterLocalId: chatter.localId }, otherProps);
+ await createRootComponent(this, components.Chatter, {
+ props,
+ target: this.widget.el,
+ });
+ };
+
+ this.createComposerComponent = async (composer, otherProps) => {
+ const props = Object.assign({ composerLocalId: composer.localId }, otherProps);
+ await createRootComponent(this, components.Composer, {
+ props,
+ target: this.widget.el,
+ });
+ };
+
+ this.start = async params => {
+ const { env, widget } = await start(Object.assign({}, params, {
+ data: this.data,
+ }));
+ this.env = env;
+ this.widget = widget;
+ };
+ },
+ afterEach() {
+ afterEach(this);
+ },
+});
+
+QUnit.test('base rendering when chatter has no attachment', async function (assert) {
+ assert.expect(6);
+
+ this.data['res.partner'].records.push({ id: 100 });
+ for (let i = 0; i < 60; i++) {
+ this.data['mail.message'].records.push({
+ body: "not empty",
+ model: 'res.partner',
+ res_id: 100,
+ });
+ }
+ 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'].findFromIdentifyingData({
+ id: 100,
+ 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(8);
+
+ 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"
+ );
+ assert.containsNone(
+ document.body,
+ '.o_MessageList_loadMore',
+ "should not have the 'load more' button"
+ );
+});
+
+QUnit.test('base rendering when chatter has attachments', async function (assert) {
+ assert.expect(3);
+
+ this.data['res.partner'].records.push({ id: 100 });
+ this.data['ir.attachment'].records.push(
+ {
+ mimetype: 'text/plain',
+ name: 'Blah.txt',
+ res_id: 100,
+ res_model: 'res.partner',
+ },
+ {
+ mimetype: 'text/plain',
+ name: 'Blu.txt',
+ res_id: 100,
+ res_model: 'res.partner',
+ }
+ );
+ 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"
+ );
+});
+
+QUnit.test('show attachment box', async function (assert) {
+ assert.expect(6);
+
+ this.data['res.partner'].records.push({ id: 100 });
+ this.data['ir.attachment'].records.push(
+ {
+ mimetype: 'text/plain',
+ name: 'Blah.txt',
+ res_id: 100,
+ res_model: 'res.partner',
+ },
+ {
+ mimetype: 'text/plain',
+ name: 'Blu.txt',
+ res_id: 100,
+ res_model: 'res.partner',
+ }
+ );
+ 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_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 [REQUIRE FOCUS]', async function (assert) {
+ assert.expect(10);
+
+ this.data['res.partner'].records.push({ id: 100 });
+ 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"
+ );
+ assert.hasClass(
+ document.querySelector('.o_Chatter_composer'),
+ 'o-focused',
+ "composer 'send message' in chatter should have focus just after being displayed"
+ );
+
+ await afterNextRender(() =>
+ document.querySelector(`.o_ChatterTopbar_buttonLogNote`).click()
+ );
+ assert.strictEqual(
+ document.querySelectorAll(`.o_Chatter_composer`).length,
+ 1,
+ "should still have a composer"
+ );
+ assert.hasClass(
+ document.querySelector('.o_Chatter_composer'),
+ 'o-focused',
+ "composer 'log note' in chatter should have focus just after being displayed"
+ );
+
+ 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"
+ );
+});
+
+QUnit.test('should not display user notification messages in chatter', async function (assert) {
+ assert.expect(1);
+
+ this.data['res.partner'].records.push({ id: 100 });
+ this.data['mail.message'].records.push({
+ id: 102,
+ message_type: 'user_notification',
+ model: 'res.partner',
+ res_id: 100,
+ });
+ await this.start();
+ const chatter = this.env.models['mail.chatter'].create({
+ threadId: 100,
+ threadModel: 'res.partner',
+ });
+ await this.createChatterComponent({ chatter });
+
+ assert.containsNone(
+ document.body,
+ '.o_Message',
+ "should display no messages"
+ );
+});
+
+QUnit.test('post message with "CTRL-Enter" keyboard shortcut', async function (assert) {
+ assert.expect(2);
+
+ this.data['res.partner'].records.push({ id: 100 });
+ await this.start();
+ const chatter = this.env.models['mail.chatter'].create({
+ threadId: 100,
+ threadModel: 'res.partner',
+ });
+ await this.createChatterComponent({ chatter });
+ assert.containsNone(
+ document.body,
+ '.o_Message',
+ "should not have any message initially in chatter"
+ );
+
+ await afterNextRender(() =>
+ document.querySelector('.o_ChatterTopbar_buttonSendMessage').click()
+ );
+ await afterNextRender(() => {
+ document.querySelector(`.o_ComposerTextInput_textarea`).focus();
+ document.execCommand('insertText', false, "Test");
+ });
+ await afterNextRender(() => {
+ const kevt = new window.KeyboardEvent('keydown', { ctrlKey: true, key: "Enter" });
+ document.querySelector('.o_ComposerTextInput_textarea').dispatchEvent(kevt);
+ });
+ assert.containsOnce(
+ document.body,
+ '.o_Message',
+ "should now have single message in chatter after posting message from pressing 'CTRL-Enter' in text input of composer"
+ );
+});
+
+QUnit.test('post message with "META-Enter" keyboard shortcut', async function (assert) {
+ assert.expect(2);
+
+ this.data['res.partner'].records.push({ id: 100 });
+ await this.start();
+ const chatter = this.env.models['mail.chatter'].create({
+ threadId: 100,
+ threadModel: 'res.partner',
+ });
+ await this.createChatterComponent({ chatter });
+ assert.containsNone(
+ document.body,
+ '.o_Message',
+ "should not have any message initially in chatter"
+ );
+
+ await afterNextRender(() =>
+ document.querySelector('.o_ChatterTopbar_buttonSendMessage').click()
+ );
+ await afterNextRender(() => {
+ document.querySelector(`.o_ComposerTextInput_textarea`).focus();
+ document.execCommand('insertText', false, "Test");
+ });
+ await afterNextRender(() => {
+ const kevt = new window.KeyboardEvent('keydown', { key: "Enter", metaKey: true });
+ document.querySelector('.o_ComposerTextInput_textarea').dispatchEvent(kevt);
+ });
+ assert.containsOnce(
+ document.body,
+ '.o_Message',
+ "should now have single message in channel after posting message from pressing 'META-Enter' in text input of composer"
+ );
+});
+
+QUnit.test('do not post message with "Enter" keyboard shortcut', async function (assert) {
+ // Note that test doesn't assert Enter makes a newline, because this
+ // default browser cannot be simulated with just dispatching
+ // programmatically crafted events...
+ assert.expect(2);
+
+ this.data['res.partner'].records.push({ id: 100 });
+ await this.start();
+ const chatter = this.env.models['mail.chatter'].create({
+ threadId: 100,
+ threadModel: 'res.partner',
+ });
+ await this.createChatterComponent({ chatter });
+ assert.containsNone(
+ document.body,
+ '.o_Message',
+ "should not have any message initially in chatter"
+ );
+
+ await afterNextRender(() =>
+ document.querySelector('.o_ChatterTopbar_buttonSendMessage').click()
+ );
+ await afterNextRender(() => {
+ document.querySelector(`.o_ComposerTextInput_textarea`).focus();
+ document.execCommand('insertText', false, "Test");
+ });
+ const kevt = new window.KeyboardEvent('keydown', { key: "Enter" });
+ document.querySelector('.o_ComposerTextInput_textarea').dispatchEvent(kevt);
+ await nextAnimationFrame();
+ assert.containsNone(
+ document.body,
+ '.o_Message',
+ "should still not have any message in mailing channel after pressing 'Enter' in text input of composer"
+ );
+});
+
+});
+});
+});
+
+});
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 00000000..2b186e62
--- /dev/null
+++ b/addons/mail/static/src/components/chatter_container/chatter_container.js
@@ -0,0 +1,139 @@
+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 useShouldUpdateBasedOnProps = require('mail/static/src/component_hooks/use_should_update_based_on_props/use_should_update_based_on_props.js');
+const useStore = require('mail/static/src/component_hooks/use_store/use_store.js');
+const useUpdate = require('mail/static/src/component_hooks/use_update/use_update.js');
+const { clear } = require('mail/static/src/model/model_field_command.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;
+ useShouldUpdateBasedOnProps();
+ useStore(props => {
+ const isMessagingInitialized = this.env.isMessagingInitialized();
+ // Delay creation of chatter record until messaging is initialized.
+ // Ideally should observe models directly to detect change instead
+ // of using `useStore`.
+ if (!this._wasMessagingInitialized && isMessagingInitialized) {
+ this._wasMessagingInitialized = true;
+ this._insertFromProps(props);
+ }
+ return { chatter: this.chatter };
+ });
+ useUpdate({ func: () => this._update() });
+ }
+
+ /**
+ * @override
+ */
+ willUpdateProps(nextProps) {
+ if (this.env.isMessagingInitialized()) {
+ this._insertFromProps(nextProps);
+ }
+ return super.willUpdateProps(...arguments);
+ }
+
+ /**
+ * @override
+ */
+ destroy() {
+ super.destroy();
+ if (this.chatter) {
+ this.chatter.delete();
+ }
+ }
+
+ //--------------------------------------------------------------------------
+ // Private
+ //--------------------------------------------------------------------------
+
+ /**
+ * @private
+ */
+ _insertFromProps(props) {
+ const values = Object.assign({}, props);
+ if (values.threadId === undefined) {
+ values.threadId = clear();
+ }
+ if (!this.chatter) {
+ this.chatter = this.env.models['mail.chatter'].create(values);
+ } else {
+ this.chatter.update(values);
+ }
+ }
+
+ /**
+ * @private
+ */
+ _update() {
+ if (this.chatter) {
+ this.chatter.refresh();
+ }
+ }
+
+}
+
+Object.assign(ChatterContainer, {
+ components,
+ props: {
+ hasActivities: {
+ type: Boolean,
+ optional: true,
+ },
+ hasExternalBorder: {
+ type: Boolean,
+ optional: true,
+ },
+ hasFollowers: {
+ type: Boolean,
+ optional: true,
+ },
+ hasMessageList: {
+ type: Boolean,
+ optional: true,
+ },
+ hasMessageListScrollAdjust: {
+ type: Boolean,
+ optional: true,
+ },
+ hasTopbarCloseButton: {
+ type: Boolean,
+ optional: true,
+ },
+ isAttachmentBoxVisibleInitially: {
+ type: Boolean,
+ optional: true,
+ },
+ threadId: {
+ type: Number,
+ optional: true,
+ },
+ threadModel: String,
+ },
+ 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 00000000..8cd51580
--- /dev/null
+++ b/addons/mail/static/src/components/chatter_container/chatter_container.scss
@@ -0,0 +1,25 @@
+// ------------------------------------------------------------------
+// Layout
+// ------------------------------------------------------------------
+
+.o_ChatterContainer {
+ display: flex;
+ flex: 1 1 auto;
+ width: map-get($sizes, 100);
+}
+
+.o_ChatterContainer_noChatter {
+ flex: 1 1 auto;
+ display: flex;
+ align-items: center;
+ justify-content: center;
+}
+
+.o_ChatterContainer_noChatterIcon {
+ margin-right: map-get($spacers, 2);
+}
+
+// ------------------------------------------------------------------
+// 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 00000000..c1d8d220
--- /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="">
+ <div class="o_ChatterContainer_noChatter"><i class="o_ChatterContainer_noChatterIcon fa fa-spinner fa-spin"/>Please wait...</div>
+ </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 00000000..41d2a461
--- /dev/null
+++ b/addons/mail/static/src/components/chatter_topbar/chatter_topbar.js
@@ -0,0 +1,137 @@
+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 useShouldUpdateBasedOnProps = require('mail/static/src/component_hooks/use_should_update_based_on_props/use_should_update_based_on_props.js');
+const useStore = require('mail/static/src/component_hooks/use_store/use_store.js');
+
+const { Component } = owl;
+
+class ChatterTopbar extends Component {
+
+ /**
+ * @override
+ */
+ constructor(...args) {
+ super(...args);
+ useShouldUpdateBasedOnProps();
+ 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,
+ composerIsLog: chatter && chatter.composer && chatter.composer.isLog,
+ 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.composer) {
+ return;
+ }
+ if (this.chatter.isComposerVisible && this.chatter.composer.isLog) {
+ 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.thread.id,
+ default_res_model: this.chatter.thread.model,
+ },
+ res_id: false,
+ };
+ return this.env.bus.trigger('do-action', {
+ action,
+ options: {
+ on_close: () => {
+ this.trigger('reload', { keepChanges: true });
+ },
+ },
+ });
+ }
+
+ /**
+ * @private
+ * @param {MouseEvent} ev
+ */
+ _onClickSendMessage(ev) {
+ if (!this.chatter.composer) {
+ return;
+ }
+ if (this.chatter.isComposerVisible && !this.chatter.composer.isLog) {
+ 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 00000000..062e5219
--- /dev/null
+++ b/addons/mail/static/src/components/chatter_topbar/chatter_topbar.scss
@@ -0,0 +1,106 @@
+// ------------------------------------------------------------------
+// Layout
+// ------------------------------------------------------------------
+
+.o_ChatterTopbar {
+ display: flex;
+ flex-direction: row;
+ justify-content: space-between;
+ // We need the +1 to handle the status bar border-bottom.
+ // The var is called `$o-statusbar-height`, but is used on button, therefore
+ // doesn't include the border-bottom.
+ // We use min-height to allow multiples buttons lines on mobile.
+ min-height: $o-statusbar-height + 1;
+}
+
+.o_ChatterTopbar_actions {
+ border-bottom: $border-width solid;
+ display: flex;
+ flex: 1;
+ flex-direction: row;
+ flex-wrap: wrap-reverse; // reverse to ensure send buttons are directly above composer
+}
+
+.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 {
+ border-color: transparent;
+
+ &.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 00000000..5d4029e6
--- /dev/null
+++ b/addons/mail/static/src/components/chatter_topbar/chatter_topbar.xml
@@ -0,0 +1,74 @@
+<?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.threadView">
+ <button class="btn btn-link o_ChatterTopbar_button o_ChatterTopbar_buttonSendMessage"
+ type="button"
+ t-att-class="{
+ 'o-active': chatter.isComposerVisible and chatter.composer and !chatter.composer.isLog,
+ '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.composer and chatter.composer.isLog,
+ '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>
+ <div class="o-autogrow"/>
+ <div class="o_ChatterTopbar_rightSection">
+ <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>
+ <t t-if="chatter.hasFollowers and chatter.thread">
+ <t t-if="chatter.thread.channel_type !== 'chat'">
+ <FollowButton
+ class="o_ChatterTopbar_button o_ChatterTopbar_followButton"
+ isDisabled="chatter.isDisabled"
+ threadLocalId="chatter.thread.localId"
+ />
+ </t>
+ <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 00000000..3063b389
--- /dev/null
+++ b/addons/mail/static/src/components/chatter_topbar/chatter_topbar_tests.js
@@ -0,0 +1,730 @@
+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,
+ afterNextRender,
+ beforeEach,
+ createRootComponent,
+ start,
+} = 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() {
+ beforeEach(this);
+
+ this.createChatterTopbarComponent = async (chatter, otherProps) => {
+ const props = Object.assign({ chatterLocalId: chatter.localId }, otherProps);
+ await createRootComponent(this, components.ChatterTopBar, {
+ props,
+ target: this.widget.el,
+ });
+ };
+
+ this.start = async params => {
+ const { env, widget } = await start(Object.assign({}, params, {
+ data: this.data,
+ }));
+ this.env = env;
+ this.widget = widget;
+ };
+ },
+ afterEach() {
+ afterEach(this);
+ },
+});
+
+QUnit.test('base rendering', async function (assert) {
+ assert.expect(8);
+
+ this.data['res.partner'].records.push({ id: 100 });
+ 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);
+
+ this.data['res.partner'].records.push({ id: 100 });
+ await this.start({
+ hasTimeControl: true,
+ loadingBaseDelayDuration: 100,
+ async mockRPC(route) {
+ if (route.includes('ir.attachment/search_read')) {
+ await makeTestPromise(); // 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);
+
+ this.data['res.partner'].records.push({ id: 100 });
+ await this.start({
+ async mockRPC(route) {
+ if (route.includes('ir.attachment/search_read')) {
+ await makeTestPromise(); // 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);
+
+ this.data['res.partner'].records.push({ id: 100 });
+ const attachmentPromise = makeTestPromise();
+ await this.start({
+ async mockRPC(route) {
+ const _super = this._super.bind(this, ...arguments); // limitation of class.js
+ if (route.includes('ir.attachment/search_read')) {
+ await attachmentPromise;
+ }
+ return _super();
+ },
+ });
+ 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);
+
+ this.data['res.partner'].records.push({ id: 100 });
+ 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);
+
+ this.data['res.partner'].records.push({ id: 100 });
+ this.data['ir.attachment'].records.push(
+ {
+ mimetype: 'text/plain',
+ name: 'Blah.txt',
+ res_id: 100,
+ res_model: 'res.partner',
+ },
+ {
+ mimetype: 'text/plain',
+ name: 'Blu.txt',
+ res_id: 100,
+ res_model: 'res.partner',
+ }
+ );
+ 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,
+ '2',
+ 'attachment counter should contain "2"'
+ );
+});
+
+QUnit.test('composer state conserved when clicking on another topbar button', async function (assert) {
+ assert.expect(8);
+
+ this.data['res.partner'].records.push({ id: 100 });
+ 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.push({
+ id: 100,
+ message_follower_ids: [1, 2],
+ });
+ this.data['mail.followers'].records.push(
+ {
+ // 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,
+ res_id: 100,
+ res_model: 'res.partner',
+ }, {
+ // 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,
+ res_id: 100,
+ res_model: 'res.partner',
+ },
+ );
+ 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.push({
+ id: 100,
+ message_follower_ids: [1, 2],
+ });
+ await this.start();
+ this.data['mail.followers'].records.push(
+ {
+ 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,
+ res_id: 100,
+ res_model: 'res.partner',
+ }, {
+ 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,
+ res_id: 100,
+ res_model: 'res.partner',
+ },
+ );
+ 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'"
+ );
+});
+
+QUnit.test('log note/send message switching', async function (assert) {
+ assert.expect(8);
+
+ this.data['res.partner'].records.push({ id: 100 });
+ 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_buttonSendMessage',
+ "should have a 'Send Message' button"
+ );
+ assert.doesNotHaveClass(
+ document.querySelector('.o_ChatterTopbar_buttonSendMessage'),
+ 'o-active',
+ "'Send Message' button should not be active"
+ );
+ assert.containsOnce(
+ document.body,
+ '.o_ChatterTopbar_buttonLogNote',
+ "should have a 'Log Note' button"
+ );
+ assert.doesNotHaveClass(
+ document.querySelector('.o_ChatterTopbar_buttonLogNote'),
+ 'o-active',
+ "'Log Note' button should not be active"
+ );
+
+ await afterNextRender(() =>
+ document.querySelector(`.o_ChatterTopbar_buttonSendMessage`).click()
+ );
+ assert.hasClass(
+ document.querySelector('.o_ChatterTopbar_buttonSendMessage'),
+ 'o-active',
+ "'Send Message' button should be active"
+ );
+ assert.doesNotHaveClass(
+ document.querySelector('.o_ChatterTopbar_buttonLogNote'),
+ 'o-active',
+ "'Log Note' button should not be active"
+ );
+
+ await afterNextRender(() =>
+ document.querySelector(`.o_ChatterTopbar_buttonLogNote`).click()
+ );
+ assert.doesNotHaveClass(
+ document.querySelector('.o_ChatterTopbar_buttonSendMessage'),
+ 'o-active',
+ "'Send Message' button should not be active"
+ );
+ assert.hasClass(
+ document.querySelector('.o_ChatterTopbar_buttonLogNote'),
+ 'o-active',
+ "'Log Note' button should be active"
+ );
+});
+
+QUnit.test('log note toggling', async function (assert) {
+ assert.expect(4);
+
+ this.data['res.partner'].records.push({ id: 100 });
+ 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_buttonLogNote',
+ "should have a 'Log Note' button"
+ );
+ assert.doesNotHaveClass(
+ document.querySelector('.o_ChatterTopbar_buttonLogNote'),
+ 'o-active',
+ "'Log Note' button should not be active"
+ );
+
+ await afterNextRender(() =>
+ document.querySelector(`.o_ChatterTopbar_buttonLogNote`).click()
+ );
+ assert.hasClass(
+ document.querySelector('.o_ChatterTopbar_buttonLogNote'),
+ 'o-active',
+ "'Log Note' button should be active"
+ );
+
+ await afterNextRender(() =>
+ document.querySelector(`.o_ChatterTopbar_buttonLogNote`).click()
+ );
+ assert.doesNotHaveClass(
+ document.querySelector('.o_ChatterTopbar_buttonLogNote'),
+ 'o-active',
+ "'Log Note' button should not be active"
+ );
+});
+
+QUnit.test('send message toggling', async function (assert) {
+ assert.expect(4);
+
+ this.data['res.partner'].records.push({ id: 100 });
+ 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_buttonSendMessage',
+ "should have a 'Send Message' button"
+ );
+ assert.doesNotHaveClass(
+ document.querySelector('.o_ChatterTopbar_buttonSendMessage'),
+ 'o-active',
+ "'Send Message' button should not be active"
+ );
+
+ await afterNextRender(() =>
+ document.querySelector(`.o_ChatterTopbar_buttonSendMessage`).click()
+ );
+ assert.hasClass(
+ document.querySelector('.o_ChatterTopbar_buttonSendMessage'),
+ 'o-active',
+ "'Send Message' button should be active"
+ );
+
+ await afterNextRender(() =>
+ document.querySelector(`.o_ChatterTopbar_buttonSendMessage`).click()
+ );
+ assert.doesNotHaveClass(
+ document.querySelector('.o_ChatterTopbar_buttonSendMessage'),
+ 'o-active',
+ "'Send Message' button should not be active"
+ );
+});
+
+});
+});
+});
+
+});
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 00000000..31654a4f
--- /dev/null
+++ b/addons/mail/static/src/components/composer/composer.js
@@ -0,0 +1,444 @@
+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'),
+ ComposerSuggestedRecipientList: require('mail/static/src/components/composer_suggested_recipient_list/composer_suggested_recipient_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 useShouldUpdateBasedOnProps = require('mail/static/src/component_hooks/use_should_update_based_on_props/use_should_update_based_on_props.js');
+const useStore = require('mail/static/src/component_hooks/use_store/use_store.js');
+const useUpdate = require('mail/static/src/component_hooks/use_update/use_update.js');
+const {
+ isEventHandled,
+ markEventHandled,
+} = require('mail/static/src/utils/utils.js');
+
+const { Component } = owl;
+const { useRef } = owl.hooks;
+
+class Composer extends Component {
+
+ /**
+ * @override
+ */
+ constructor(...args) {
+ super(...args);
+ this.isDropZoneVisible = useDragVisibleDropZone();
+ useShouldUpdateBasedOnProps({
+ compareDepth: {
+ textInputSendShortcuts: 1,
+ },
+ });
+ useStore(props => {
+ const composer = this.env.models['mail.composer'].get(props.composerLocalId);
+ const thread = composer && composer.thread;
+ return {
+ composer,
+ composerAttachments: composer ? composer.attachments : [],
+ composerCanPostMessage: composer && composer.canPostMessage,
+ composerHasFocus: composer && composer.hasFocus,
+ composerIsLog: composer && composer.isLog,
+ composerSubjectContent: composer && composer.subjectContent,
+ isDeviceMobile: this.env.messaging.device.isMobile,
+ thread,
+ threadChannelType: thread && thread.channel_type, // for livechat override
+ threadDisplayName: thread && thread.displayName,
+ threadMassMailing: thread && thread.mass_mailing,
+ threadModel: thread && thread.model,
+ threadName: thread && thread.name,
+ };
+ }, {
+ compareDepth: {
+ composerAttachments: 1,
+ },
+ });
+ useUpdate({ func: () => this._update() });
+ /**
+ * Reference of the emoji popover. Useful to include emoji popover as
+ * contained "inside" the composer.
+ */
+ 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');
+ /**
+ * Reference of the subject input. Useful to set content.
+ */
+ this._subjectRef = useRef('subject');
+ this._onClickCaptureGlobal = this._onClickCaptureGlobal.bind(this);
+ }
+
+ mounted() {
+ document.addEventListener('click', this._onClickCaptureGlobal, true);
+ }
+
+ willUnmount() {
+ document.removeEventListener('click', this._onClickCaptureGlobal, true);
+ }
+
+ //--------------------------------------------------------------------------
+ // Public
+ //--------------------------------------------------------------------------
+
+ /**
+ * @returns {mail.composer}
+ */
+ get composer() {
+ return this.env.models['mail.composer'].get(this.props.composerLocalId);
+ }
+
+ /**
+ * Returns whether the given node is self or a children of self, including
+ * the emoji popover.
+ *
+ * @param {Node} node
+ * @returns {boolean}
+ */
+ contains(node) {
+ // emoji popover is outside but should be considered inside
+ const emojisPopover = this._emojisPopoverRef.comp;
+ if (emojisPopover && emojisPopover.contains(node)) {
+ return true;
+ }
+ return this.el.contains(node);
+ }
+
+ /**
+ * Get the current partner image URL.
+ *
+ * @returns {string}
+ */
+ get currentPartnerAvatar() {
+ const avatar = this.env.messaging.currentUser
+ ? this.env.session.url('/web/image', {
+ field: 'image_128',
+ id: this.env.messaging.currentUser.id,
+ 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._textInputRef.comp.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.composer.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.
+ *
+ * Posting of the message could be aborted if it cannot be posted like if there are attachments
+ * currently uploading or if there is no text content and no attachments.
+ *
+ * @private
+ */
+ async _postMessage() {
+ if (!this.composer.canPostMessage) {
+ if (this.composer.hasUploadingAttachment) {
+ this.env.services['notification'].notify({
+ message: this.env._t("Please wait while the file is uploading."),
+ type: 'warning',
+ });
+ }
+ return;
+ }
+ await this.composer.postMessage();
+ // TODO: we might need to remove trigger and use the store to wait for the post rpc to be done
+ // task-2252858
+ this.trigger('o-message-posted');
+ }
+
+ /**
+ * @private
+ */
+ _update() {
+ if (this.props.isDoFocus) {
+ this.focus();
+ }
+ if (!this.composer) {
+ return;
+ }
+ if (this._subjectRef.el) {
+ this._subjectRef.el.value = this.composer.subjectContent;
+ }
+ }
+
+ //--------------------------------------------------------------------------
+ // Handlers
+ //--------------------------------------------------------------------------
+
+ /**
+ * Called when clicking on attachment button.
+ *
+ * @private
+ */
+ _onClickAddAttachment() {
+ this._fileUploaderRef.comp.openBrowserFileUploader();
+ if (!this.env.device.isMobile) {
+ this.focus();
+ }
+ }
+
+ /**
+ * Discards the composer when clicking away.
+ *
+ * @private
+ * @param {MouseEvent} ev
+ */
+ _onClickCaptureGlobal(ev) {
+ if (this.contains(ev.target)) {
+ return;
+ }
+ this.composer.discard();
+ }
+
+ /**
+ * Called when clicking on "expand" button.
+ *
+ * @private
+ */
+ _onClickFullComposer() {
+ this.composer.openFullComposer();
+ }
+
+ /**
+ * Called when clicking on "discard" button.
+ *
+ * @private
+ * @param {MouseEvent} ev
+ */
+ _onClickDiscard(ev) {
+ this.composer.discard();
+ }
+
+ /**
+ * Called when clicking on "send" button.
+ *
+ * @private
+ */
+ _onClickSend() {
+ this._postMessage();
+ this.focus();
+ }
+
+ /**
+ * @private
+ */
+ _onComposerSuggestionClicked() {
+ this.focus();
+ }
+
+ /**
+ * @private
+ */
+ _onComposerTextInputSendShortcut() {
+ 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);
+ if (!this.env.device.isMobile) {
+ this.focus();
+ }
+ }
+
+ /**
+ * @private
+ */
+ _onInputSubject() {
+ this.composer.update({ subjectContent: this._subjectRef.el.value });
+ }
+
+ /**
+ * @private
+ * @param {KeyboardEvent} ev
+ */
+ _onKeydown(ev) {
+ if (ev.key === 'Escape') {
+ if (isEventHandled(ev, 'ComposerTextInput.closeSuggestions')) {
+ return;
+ }
+ if (isEventHandled(ev, 'Composer.closeEmojisPopover')) {
+ return;
+ }
+ ev.preventDefault();
+ this.composer.discard();
+ }
+ }
+
+ /**
+ * @private
+ * @param {KeyboardEvent} ev
+ */
+ _onKeydownEmojiButton(ev) {
+ if (ev.key === 'Escape') {
+ if (this._emojisPopoverRef.comp) {
+ this._emojisPopoverRef.comp.close();
+ this.focus();
+ markEventHandled(ev, 'Composer.closeEmojisPopover');
+ }
+ }
+ }
+
+ /**
+ * @private
+ * @param {CustomEvent} ev
+ */
+ async _onPasteTextInput(ev) {
+ if (!ev.clipboardData || !ev.clipboardData.files) {
+ return;
+ }
+ await this._fileUploaderRef.comp.uploadFiles(ev.clipboardData.files);
+ }
+
+}
+
+Object.assign(Composer, {
+ components,
+ defaultProps: {
+ hasCurrentPartnerAvatar: true,
+ hasDiscardButton: false,
+ hasFollowers: false,
+ hasSendButton: true,
+ hasThreadName: false,
+ hasThreadTyping: false,
+ isCompact: true,
+ isDoFocus: false,
+ isExpandable: false,
+ },
+ props: {
+ attachmentsDetailsMode: {
+ type: String,
+ optional: true,
+ },
+ composerLocalId: String,
+ hasCurrentPartnerAvatar: Boolean,
+ hasDiscardButton: Boolean,
+ hasFollowers: Boolean,
+ hasMentionSuggestionsBelowPosition: {
+ type: Boolean,
+ optional: true,
+ },
+ hasSendButton: Boolean,
+ hasThreadName: Boolean,
+ hasThreadTyping: Boolean,
+ /**
+ * Determines whether this should become focused.
+ */
+ isDoFocus: Boolean,
+ showAttachmentsExtensions: {
+ type: Boolean,
+ optional: true,
+ },
+ showAttachmentsFilenames: {
+ type: Boolean,
+ optional: true,
+ },
+ isCompact: Boolean,
+ isExpandable: Boolean,
+ /**
+ * If set, keyboard shortcuts from text input to send message.
+ * If not set, will use default values from `ComposerTextInput`.
+ */
+ textInputSendShortcuts: {
+ type: Array,
+ element: String,
+ optional: true,
+ },
+ },
+ 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 00000000..df695cce
--- /dev/null
+++ b/addons/mail/static/src/components/composer/composer.scss
@@ -0,0 +1,273 @@
+// ------------------------------------------------------------------
+// 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, 3) map-get($spacers, 3) map-get($spacers, 4) 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-is-compact {
+ max-height: 100px;
+ }
+
+ &:not(.o-composer-is-compact) {
+ overflow-y: auto;
+ max-height: 300px;
+ }
+}
+
+.o_Composer_buttons {
+ display: flex;
+ align-items: stretch;
+ align-self: stretch;
+ flex: 0 0 auto;
+ min-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_Composer_coreFooter {
+ grid-area: core-footer;
+ overflow-x: hidden;
+
+ &: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-is-compact {
+ padding-left: map-get($spacers, 2);
+ padding-right: map-get($spacers, 2);
+ }
+}
+
+.o_Composer_sidebarMain {
+ grid-area: sidebar-main;
+ justify-self: center;
+}
+
+.o_Composer_subject {
+ border-top: $border-width solid $border-color;
+ border-right: $border-width solid $border-color;
+ border-left: $border-width solid $border-color;
+ border-radius: $o-mail-rounded-rectangle-border-radius-sm $o-mail-rounded-rectangle-border-radius-sm 0 0;
+}
+
+.o_Composer_subjectInput {
+ display: flex;
+ flex: 1;
+ padding: map-get($spacers, 2) map-get($spacers, 3);
+ border: 0;
+}
+
+.o_Composer_textInput {
+ flex: 1 1 auto;
+ align-self: stretch;
+
+ &:not(.o-composer-is-compact) {
+ border: 0;
+ 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 {
+ // keep a margin between the buttons to prevent their focus shadow from overlapping
+ margin-left: map-get($spacers, 1);
+ margin-right: map-get($spacers, 1);
+}
+
+.o_Composer_toolButtons {
+ display: flex;
+ padding-top: map-get($spacers, 1);
+ padding-bottom: map-get($spacers, 1);
+
+ &:not(.o-composer-is-compact) {
+ flex-direction: row;
+ justify-content: space-between;
+ flex: 100%;
+ }
+}
+
+.o_Composer_toolButtonSeparator {
+ flex: 0 0 auto;
+ margin-top: map-get($spacers, 2);
+ margin-bottom: map-get($spacers, 2);
+}
+
+// ------------------------------------------------------------------
+// Style
+// ------------------------------------------------------------------
+
+// TODO FIXME o-open on the button should be enough.
+// Style of button when popover is "open" comes from web.Popover, and we can't
+// define a modifier on .o_Composer_button due to not being aware of Popover's
+// state in context of template. https://github.com/odoo/owl/issues/693
+.o_is_open .o_Composer_toolButton {
+ background-color: gray('200');
+}
+
+.o_Composer {
+ background-color: lighten(gray('300'), 7%);
+}
+
+.o_Composer_actionButton.o-last.o-has-current-partner-avatar.o-composer-is-compact {
+ border-radius: 0 $o-mail-rounded-rectangle-border-radius-lg $o-mail-rounded-rectangle-border-radius-lg 0;
+}
+
+.o_Composer_button.o-composer-is-compact {
+ border-left: none; // overrides bootstrap button style
+
+ :last-child {
+ border-radius: 0 3px 3px 0;
+ }
+}
+
+.o_Composer_buttonDiscard {
+ border: 1px solid lighten(gray('400'), 5%);
+}
+
+.o_Composer_buttons {
+ border: 0;
+}
+
+.o_Composer_coreMain:not(.o-composer-is-compact) {
+ background: white;
+ border: 1px solid lighten(gray('400'), 5%);
+
+ // textarea should be all rounded but only when there is no subject field above
+ &:not(.o-composer-is-extended) {
+ border-radius: $o-mail-rounded-rectangle-border-radius-lg;
+ }
+}
+
+.o_Composer_currentPartnerAvatar {
+ object-fit: cover;
+}
+
+.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;
+ border-radius: $o-mail-rounded-rectangle-border-radius-lg;
+ }
+
+ &.o-has-current-partner-avatar.o-composer-is-compact {
+ border-radius: $o-mail-rounded-rectangle-border-radius-lg 0 0 $o-mail-rounded-rectangle-border-radius-lg;
+ }
+}
+
+.o_Composer_toolButton {
+ border: 0; // overrides bootstrap btn
+ background-color: white; // overrides bootstrap btn-light
+ color: gray('600'); // overrides bootstrap btn-light
+ border-radius: 50%;
+
+ &.o-open {
+ background-color: gray('200');
+ }
+}
+
+.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 00000000..cc7038d3
--- /dev/null
+++ b/addons/mail/static/src/components/composer/composer.xml
@@ -0,0 +1,179 @@
+<?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-focused': composer and composer.hasFocus,
+ 'o-has-current-partner-avatar': props.hasCurrentPartnerAvatar,
+ 'o-has-footer': hasFooter,
+ 'o-has-header': hasHeader,
+ 'o-is-compact': props.isCompact,
+ }"
+ t-on-keydown="_onKeydown"
+ >
+ <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 !composer.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 and composer.thread.name">
+ &#32;&quot;<t t-esc="composer.thread.name"/>&quot;
+ </t>
+ <t t-else="">
+ this document
+ </t>
+ </b>
+ </small>
+ <ComposerSuggestedRecipientList
+ threadLocalId="composer.thread.localId"
+ />
+ </t>
+ </div>
+ </t>
+ <t t-if="composer.thread and composer.thread.model === 'mail.channel' and composer.thread.mass_mailing">
+ <div class="o_Composer_subject">
+ <input class="o_Composer_subjectInput" type="text" placeholder="Subject" t-on-input="_onInputSubject" t-ref="subject"/>
+ </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,
+ 'o-composer-is-extended': composer.thread and composer.thread.mass_mailing,
+ }"
+ >
+ <TextInput
+ class="o_Composer_textInput"
+ t-att-class="{
+ 'o-composer-is-compact': props.isCompact,
+ 'o_Composer_textInput-mobile': env.messaging.device.isMobile,
+ 'o-has-current-partner-avatar': props.hasCurrentPartnerAvatar,
+ }"
+ composerLocalId="composer.localId"
+ hasMentionSuggestionsBelowPosition="props.hasMentionSuggestionsBelowPosition"
+ isCompact="props.isCompact"
+ sendShortcuts="props.textInputSendShortcuts"
+ t-on-o-composer-suggestion-clicked="_onComposerSuggestionClicked"
+ t-on-o-composer-text-input-send-shortcut="_onComposerTextInputSendShortcut"
+ t-on-paste="_onPasteTextInput"
+ t-key="composer.localId"
+ 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" t-att-class="{ 'o-composer-is-compact': props.isCompact }">
+ <Popover position="'top'" t-on-o-emoji-selection="_onEmojiSelection">
+ <!-- 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-light"
+ t-att-class="{
+ 'o-open': false and state.displayed,
+ 'o-mobile': env.messaging.device.isMobile,
+ }"
+ t-on-keydown="_onKeydownEmojiButton"
+ >
+ <i class="fa fa-smile-o"/>
+ </button>
+ <t t-set="opened">
+ <EmojisPopover t-ref="emojisPopover"/>
+ </t>
+ </Popover>
+ <button class="o_Composer_button o_Composer_buttonAttachment o_Composer_toolButton btn btn-light 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-light 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"
+ t-att-class="{ 'o-composer-is-compact': props.isCompact }"
+ areAttachmentsEditable="true"
+ attachmentsDetailsMode="props.attachmentsDetailsMode"
+ attachmentsImageSize="'small'"
+ 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-last': env.messaging.device.isMobile or !props.hasDiscardButton,
+ 'o-composer-is-compact': props.isCompact,
+ 'o-has-current-partner-avatar': props.hasCurrentPartnerAvatar,
+ }"
+ t-att-disabled="!composer.canPostMessage ? 'disabled' : ''"
+ type="button"
+ t-on-click="_onClickSend"
+ >
+ <t t-if="!env.messaging.device.isMobile"><t t-if="composer.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-last o_Composer_button o_Composer_buttonDiscard btn btn-secondary" t-att-class="{ 'o-composer-is-compact': props.isCompact, 'o-has-current-partner-avatar': props.hasCurrentPartnerAvatar }" 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 00000000..a4ff5978
--- /dev/null
+++ b/addons/mail/static/src/components/composer/composer_tests.js
@@ -0,0 +1,2153 @@
+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,
+ afterNextRender,
+ beforeEach,
+ createRootComponent,
+ dragenterFiles,
+ dropFiles,
+ nextAnimationFrame,
+ pasteFiles,
+ start,
+} = require('mail/static/src/utils/test_utils.js');
+
+const {
+ file: {
+ createFile,
+ inputFiles,
+ },
+ makeTestPromise,
+} = require('web.test_utils');
+
+QUnit.module('mail', {}, function () {
+QUnit.module('components', {}, function () {
+QUnit.module('composer', {}, function () {
+QUnit.module('composer_tests.js', {
+ beforeEach() {
+ beforeEach(this);
+
+ this.createComposerComponent = async (composer, otherProps) => {
+ const props = Object.assign({ composerLocalId: composer.localId }, otherProps);
+ await createRootComponent(this, components.Composer, {
+ props,
+ target: this.widget.el,
+ });
+ };
+
+ this.start = async params => {
+ const { afterEvent, env, widget } = await start(Object.assign({}, params, {
+ data: this.data,
+ }));
+ this.afterEvent = afterEvent;
+ this.env = env;
+ this.widget = widget;
+ };
+ },
+ afterEach() {
+ afterEach(this);
+ },
+});
+
+QUnit.test('composer text input: basic rendering when posting a message', async function (assert) {
+ assert.expect(5);
+
+ await this.start();
+ const thread = this.env.models['mail.thread'].create({
+ composer: [['create', { isLog: false }]],
+ id: 20,
+ model: 'res.partner',
+ });
+ await this.createComposerComponent(thread.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,
+ "Send a message to followers...",
+ "should have 'Send a message to followers...' as placeholder composer text input"
+ );
+});
+
+QUnit.test('composer text input: basic rendering when logging note', async function (assert) {
+ assert.expect(5);
+
+ await this.start();
+ const thread = this.env.models['mail.thread'].create({
+ composer: [['create', { isLog: true }]],
+ id: 20,
+ model: 'res.partner',
+ });
+ await this.createComposerComponent(thread.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,
+ "Log an internal note...",
+ "should have 'Log an internal note...' as placeholder in composer text input if composer is log"
+ );
+});
+
+QUnit.test('composer text input: basic rendering when linked thread is a mail.channel', async function (assert) {
+ assert.expect(5);
+
+ await this.start();
+ const thread = this.env.models['mail.thread'].create({
+ id: 20,
+ model: 'mail.channel',
+ });
+ await this.createComposerComponent(thread.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 'Write something...' as placeholder in composer text input if composer is for a 'mail.channel'"
+ );
+});
+
+QUnit.test('mailing channel composer: basic rendering', async function (assert) {
+ assert.expect(2);
+
+ // channel that is expected to be rendered, with proper mass_mailing
+ // value and a random unique id that will be referenced in the test
+ this.data['mail.channel'].records.push({ id: 20, mass_mailing: true });
+ await this.start();
+ const thread = this.env.models['mail.thread'].findFromIdentifyingData({
+ id: 20,
+ model: 'mail.channel',
+ });
+ await this.createComposerComponent(thread.composer);
+ assert.containsOnce(
+ document.body,
+ '.o_ComposerTextInput',
+ "Composer should have a text input"
+ );
+ assert.containsOnce(
+ document.body,
+ '.o_Composer_subjectInput',
+ "Composer should have a subject 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 canned response suggestions on typing ":"', async function (assert) {
+ assert.expect(2);
+
+ this.data['mail.shortcode'].records.push({
+ id: 11,
+ source: "hello",
+ substitution: "Hello! How are you?",
+ });
+
+ await this.start();
+ const composer = this.env.models['mail.composer'].create();
+ await this.createComposerComponent(composer);
+
+ assert.containsNone(
+ document.body,
+ '.o_ComposerSuggestionList_list',
+ "Canned responses 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_ComposerSuggestionList_list'),
+ 'show',
+ "should display canned response suggestions on typing ':'"
+ );
+});
+
+QUnit.test('use a canned response', async function (assert) {
+ assert.expect(4);
+
+ this.data['mail.shortcode'].records.push({
+ id: 11,
+ source: "hello",
+ substitution: "Hello! How are you?",
+ });
+
+ await this.start();
+ const composer = this.env.models['mail.composer'].create();
+ await this.createComposerComponent(composer);
+
+ assert.containsNone(
+ document.body,
+ '.o_ComposerSuggestionList_list',
+ "canned response 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, ":");
+ 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_ComposerSuggestion',
+ "should have a canned response suggestion"
+ );
+ await afterNextRender(() =>
+ document.querySelector('.o_ComposerSuggestion').click()
+ );
+ assert.strictEqual(
+ document.querySelector(`.o_ComposerTextInput_textarea`).value.replace(/\s/, " "),
+ "Hello! How are you? ",
+ "text content of composer should have canned response + additional whitespace afterwards"
+ );
+});
+
+QUnit.test('use a canned response some text', async function (assert) {
+ assert.expect(5);
+
+ this.data['mail.shortcode'].records.push({
+ id: 11,
+ source: "hello",
+ substitution: "Hello! How are you?",
+ });
+
+ await this.start();
+ const composer = this.env.models['mail.composer'].create();
+ await this.createComposerComponent(composer);
+
+ assert.containsNone(
+ document.body,
+ '.o_ComposerSuggestion',
+ "canned response 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, ":");
+ 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_ComposerSuggestion',
+ "should have a canned response suggestion"
+ );
+ await afterNextRender(() =>
+ document.querySelector('.o_ComposerSuggestion').click()
+ );
+ assert.strictEqual(
+ document.querySelector(`.o_ComposerTextInput_textarea`).value.replace(/\s/, " "),
+ "bluhbluh Hello! How are you? ",
+ "text content of composer should have previous content + canned response substitution + additional whitespace afterwards"
+ );
+});
+
+QUnit.test('add an emoji after a canned response', async function (assert) {
+ assert.expect(5);
+
+ this.data['mail.shortcode'].records.push({
+ id: 11,
+ source: "hello",
+ substitution: "Hello! How are you?",
+ });
+
+ await this.start();
+ const composer = this.env.models['mail.composer'].create();
+ await this.createComposerComponent(composer);
+
+ assert.containsNone(
+ document.body,
+ '.o_ComposerSuggestion',
+ "canned response 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, ":");
+ 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_ComposerSuggestion',
+ "should have a canned response suggestion"
+ );
+ await afterNextRender(() =>
+ document.querySelector('.o_ComposerSuggestion').click()
+ );
+ assert.strictEqual(
+ document.querySelector(`.o_ComposerTextInput_textarea`).value.replace(/\s/, " "),
+ "Hello! How are you? ",
+ "text content of composer should have previous content + canned response substitution + 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/, " "),
+ "Hello! How are you? 😊",
+ "text content of composer should have previous canned response substitution and selected emoji just after"
+ );
+ // ensure popover is closed
+ await nextAnimationFrame();
+});
+
+QUnit.test('display channel mention suggestions on typing "#"', async function (assert) {
+ assert.expect(2);
+
+ this.data['mail.channel'].records.push({
+ id: 7,
+ name: "General",
+ public: "groups",
+ });
+ await this.start();
+ const composer = this.env.models['mail.composer'].create();
+ await this.createComposerComponent(composer);
+
+ assert.containsNone(
+ document.body,
+ '.o_ComposerSuggestionList_list',
+ "channel 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_ComposerSuggestionList_list'),
+ 'show',
+ "should display channel mention suggestions on typing '#'"
+ );
+});
+
+QUnit.test('mention a channel', async function (assert) {
+ assert.expect(4);
+
+ this.data['mail.channel'].records.push({
+ id: 7,
+ name: "General",
+ public: "groups",
+ });
+ await this.start();
+ const composer = this.env.models['mail.composer'].create();
+ await this.createComposerComponent(composer);
+
+ assert.containsNone(
+ document.body,
+ '.o_ComposerSuggestionList_list',
+ "channel 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, "#");
+ 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_ComposerSuggestion',
+ "should have a channel mention suggestion"
+ );
+ await afterNextRender(() =>
+ document.querySelector('.o_ComposerSuggestion').click()
+ );
+ assert.strictEqual(
+ document.querySelector(`.o_ComposerTextInput_textarea`).value.replace(/\s/, " "),
+ "#General ",
+ "text content of composer should have mentioned channel + additional whitespace afterwards"
+ );
+});
+
+QUnit.test('mention a channel after some text', async function (assert) {
+ assert.expect(5);
+
+ this.data['mail.channel'].records.push({
+ id: 7,
+ name: "General",
+ public: "groups",
+ });
+ await this.start();
+ const composer = this.env.models['mail.composer'].create();
+ await this.createComposerComponent(composer);
+
+ assert.containsNone(
+ document.body,
+ '.o_ComposerSuggestion',
+ "channel 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, "#");
+ 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_ComposerSuggestion',
+ "should have a channel mention suggestion"
+ );
+ await afterNextRender(() =>
+ document.querySelector('.o_ComposerSuggestion').click()
+ );
+ assert.strictEqual(
+ document.querySelector(`.o_ComposerTextInput_textarea`).value.replace(/\s/, " "),
+ "bluhbluh #General ",
+ "text content of composer should have previous content + mentioned channel + additional whitespace afterwards"
+ );
+});
+
+QUnit.test('add an emoji after a channel mention', async function (assert) {
+ assert.expect(5);
+
+ this.data['mail.channel'].records.push({
+ id: 7,
+ name: "General",
+ public: "groups",
+ });
+ await this.start();
+ const composer = this.env.models['mail.composer'].create();
+ await this.createComposerComponent(composer);
+
+ assert.containsNone(
+ document.body,
+ '.o_ComposerSuggestion',
+ "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, "#");
+ 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_ComposerSuggestion',
+ "should have a channel mention suggestion"
+ );
+ await afterNextRender(() =>
+ document.querySelector('.o_ComposerSuggestion').click()
+ );
+ assert.strictEqual(
+ document.querySelector(`.o_ComposerTextInput_textarea`).value.replace(/\s/, " "),
+ "#General ",
+ "text content of composer should have previous content + mentioned channel + 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/, " "),
+ "#General 😊",
+ "text content of composer should have previous channel mention and selected emoji just after"
+ );
+ // ensure popover is closed
+ await nextAnimationFrame();
+});
+
+QUnit.test('display command suggestions on typing "/"', async function (assert) {
+ assert.expect(2);
+
+ this.data['mail.channel'].records.push({ channel_type: 'channel', id: 20 });
+ this.data['mail.channel_command'].records.push(
+ {
+ channel_types: ['channel'],
+ help: "List users in the current channel",
+ name: "who",
+ },
+ );
+ await this.start();
+ const thread = this.env.models['mail.thread'].findFromIdentifyingData({
+ id: 20,
+ model: 'mail.channel',
+ });
+ await this.createComposerComponent(thread.composer);
+
+ assert.containsNone(
+ document.body,
+ '.o_ComposerSuggestionList_list',
+ "command 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_ComposerSuggestionList_list'),
+ 'show',
+ "should display command suggestions on typing '/'"
+ );
+});
+
+QUnit.test('do not send typing notification on typing "/" command', async function (assert) {
+ assert.expect(1);
+
+ this.data['mail.channel'].records.push({ id: 20 });
+ this.data['mail.channel_command'].records.push({
+ channel_types: ['channel'],
+ help: "List users in the current channel",
+ name: "who",
+ });
+ await this.start({
+ async mockRPC(route, args) {
+ if (args.method === 'notify_typing') {
+ assert.step(`notify_typing:${args.kwargs.is_typing}`);
+ }
+ return this._super(...arguments);
+ },
+ });
+ const thread = this.env.models['mail.thread'].findFromIdentifyingData({
+ id: 20,
+ model: 'mail.channel',
+ });
+ await this.createComposerComponent(thread.composer, { hasThreadTyping: true });
+
+ 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.verifySteps([], "No rpc done");
+});
+
+QUnit.test('do not send typing notification on typing after selecting suggestion from "/" command', async function (assert) {
+ assert.expect(1);
+
+ this.data['mail.channel'].records.push({ id: 20 });
+ this.data['mail.channel_command'].records.push({
+ channel_types: ['channel'],
+ help: "List users in the current channel",
+ name: "who",
+ });
+ await this.start({
+ async mockRPC(route, args) {
+ if (args.method === 'notify_typing') {
+ assert.step(`notify_typing:${args.kwargs.is_typing}`);
+ }
+ return this._super(...arguments);
+ },
+ });
+ const thread = this.env.models['mail.thread'].findFromIdentifyingData({
+ id: 20,
+ model: 'mail.channel',
+ });
+ await this.createComposerComponent(thread.composer, { hasThreadTyping: true });
+
+ 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'));
+ });
+ await afterNextRender(() =>
+ document.querySelector('.o_ComposerSuggestion').click()
+ );
+ await afterNextRender(() => {
+ document.querySelector(`.o_ComposerTextInput_textarea`).focus();
+ document.execCommand('insertText', false, " is user?");
+ document.querySelector(`.o_ComposerTextInput_textarea`)
+ .dispatchEvent(new window.KeyboardEvent('keydown'));
+ document.querySelector(`.o_ComposerTextInput_textarea`)
+ .dispatchEvent(new window.KeyboardEvent('keyup'));
+ });
+ assert.verifySteps([], "No rpc done");
+});
+
+QUnit.test('use a command for a specific channel type', async function (assert) {
+ assert.expect(4);
+
+ this.data['mail.channel'].records.push({ channel_type: 'channel', id: 20 });
+ this.data['mail.channel_command'].records.push(
+ {
+ channel_types: ['channel'],
+ help: "List users in the current channel",
+ name: "who",
+ },
+ );
+ await this.start();
+ const thread = this.env.models['mail.thread'].findFromIdentifyingData({
+ id: 20,
+ model: 'mail.channel',
+ });
+ await this.createComposerComponent(thread.composer);
+
+ assert.containsNone(
+ document.body,
+ '.o_ComposerSuggestionList_list',
+ "command 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, "/");
+ 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_ComposerSuggestion',
+ "should have a command suggestion"
+ );
+ await afterNextRender(() =>
+ document.querySelector('.o_ComposerSuggestion').click()
+ );
+ assert.strictEqual(
+ document.querySelector(`.o_ComposerTextInput_textarea`).value.replace(/\s/, " "),
+ "/who ",
+ "text content of composer should have used command + additional whitespace afterwards"
+ );
+});
+
+QUnit.test("channel with no commands should not prompt any command suggestions on typing /", async function (assert) {
+ assert.expect(1);
+
+ this.data['mail.channel'].records.push({ channel_type: 'chat', id: 20 });
+ this.data['mail.channel_command'].records.push(
+ {
+ channel_types: ['channel'],
+ help: "bla bla bla",
+ name: "who",
+ },
+ );
+ await this.start();
+ const thread = this.env.models['mail.thread'].findFromIdentifyingData({
+ id: 20,
+ model: 'mail.channel',
+ });
+ await this.createComposerComponent(thread.composer);
+ await afterNextRender(() => {
+ document.querySelector('.o_ComposerTextInput_textarea').focus();
+ document.execCommand('insertText', false, "/");
+ const composer_text_input = document.querySelector('.o_ComposerTextInput_textarea');
+ composer_text_input.dispatchEvent(new window.KeyboardEvent('keydown'));
+ composer_text_input.dispatchEvent(new window.KeyboardEvent('keyup'));
+ });
+ assert.containsNone(
+ document.body,
+ '.o_ComposerSuggestion',
+ "should not prompt (command) suggestion after typing / (reason: no channel commands in chat channels)"
+ );
+});
+
+QUnit.test('command suggestion should only open if command is the first character', async function (assert) {
+ assert.expect(4);
+
+ this.data['mail.channel'].records.push({ channel_type: 'channel', id: 20 });
+ this.data['mail.channel_command'].records.push(
+ {
+ channel_types: ['channel'],
+ help: "List users in the current channel",
+ name: "who",
+ },
+ );
+ await this.start();
+ const thread = this.env.models['mail.thread'].findFromIdentifyingData({
+ id: 20,
+ model: 'mail.channel',
+ });
+ await this.createComposerComponent(thread.composer);
+ assert.containsNone(
+ document.body,
+ '.o_ComposerSuggestion',
+ "command 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, "/");
+ document.querySelector(`.o_ComposerTextInput_textarea`)
+ .dispatchEvent(new window.KeyboardEvent('keydown'));
+ document.querySelector(`.o_ComposerTextInput_textarea`)
+ .dispatchEvent(new window.KeyboardEvent('keyup'));
+ });
+ assert.containsNone(
+ document.body,
+ '.o_ComposerSuggestion',
+ "should not have a command suggestion"
+ );
+});
+
+QUnit.test('add an emoji after a command', async function (assert) {
+ assert.expect(5);
+
+ this.data['mail.channel'].records.push({ channel_type: 'channel', id: 20 });
+ this.data['mail.channel_command'].records.push(
+ {
+ channel_types: ['channel'],
+ help: "List users in the current channel",
+ name: "who",
+ },
+ );
+ await this.start();
+ const thread = this.env.models['mail.thread'].findFromIdentifyingData({
+ id: 20,
+ model: 'mail.channel',
+ });
+ await this.createComposerComponent(thread.composer);
+
+ assert.containsNone(
+ document.body,
+ '.o_ComposerSuggestion',
+ "command 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, "/");
+ 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_ComposerSuggestion',
+ "should have a command suggestion"
+ );
+ await afterNextRender(() =>
+ document.querySelector('.o_ComposerSuggestion').click()
+ );
+ assert.strictEqual(
+ document.querySelector(`.o_ComposerTextInput_textarea`).value.replace(/\s/, " "),
+ "/who ",
+ "text content of composer should have previous content + used command + 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/, " "),
+ "/who 😊",
+ "text content of composer should have previous command and selected emoji just after"
+ );
+ // ensure popover is closed
+ await nextAnimationFrame();
+});
+
+QUnit.test('display partner mention suggestions on typing "@"', async function (assert) {
+ assert.expect(3);
+
+ this.data['res.partner'].records.push({
+ id: 11,
+ email: "testpartner@odoo.com",
+ name: "TestPartner",
+ });
+ this.data['res.partner'].records.push({
+ id: 12,
+ email: "testpartner2@odoo.com",
+ name: "TestPartner2",
+ });
+ this.data['res.users'].records.push({
+ partner_id: 11,
+ });
+
+ await this.start();
+ const composer = this.env.models['mail.composer'].create();
+ await this.createComposerComponent(composer);
+
+ assert.containsNone(
+ document.body,
+ '.o_ComposerSuggestionList_list',
+ "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_ComposerSuggestionList_list'),
+ 'show',
+ "should display mention suggestions on typing '@'"
+ );
+ assert.containsOnce(
+ document.body,
+ '.dropdown-divider',
+ "should have a separator"
+ );
+});
+
+QUnit.test('mention a partner', async function (assert) {
+ assert.expect(4);
+
+ this.data['res.partner'].records.push({
+ email: "testpartner@odoo.com",
+ name: "TestPartner",
+ });
+ await this.start();
+ const composer = this.env.models['mail.composer'].create();
+ await this.createComposerComponent(composer);
+
+ assert.containsNone(
+ document.body,
+ '.o_ComposerSuggestionList_list',
+ "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, "@");
+ document.querySelector(`.o_ComposerTextInput_textarea`)
+ .dispatchEvent(new window.KeyboardEvent('keydown'));
+ document.querySelector(`.o_ComposerTextInput_textarea`)
+ .dispatchEvent(new window.KeyboardEvent('keyup'));
+ document.execCommand('insertText', false, "T");
+ document.querySelector(`.o_ComposerTextInput_textarea`)
+ .dispatchEvent(new window.KeyboardEvent('keydown'));
+ document.querySelector(`.o_ComposerTextInput_textarea`)
+ .dispatchEvent(new window.KeyboardEvent('keyup'));
+ document.execCommand('insertText', false, "e");
+ 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_ComposerSuggestion',
+ "should have a mention suggestion"
+ );
+ await afterNextRender(() =>
+ document.querySelector('.o_ComposerSuggestion').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);
+
+ this.data['res.partner'].records.push({
+ email: "testpartner@odoo.com",
+ name: "TestPartner",
+ });
+ await this.start();
+ const composer = this.env.models['mail.composer'].create();
+ await this.createComposerComponent(composer);
+
+ assert.containsNone(
+ document.body,
+ '.o_ComposerSuggestion',
+ "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.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'));
+ document.execCommand('insertText', false, "T");
+ document.querySelector(`.o_ComposerTextInput_textarea`)
+ .dispatchEvent(new window.KeyboardEvent('keydown'));
+ document.querySelector(`.o_ComposerTextInput_textarea`)
+ .dispatchEvent(new window.KeyboardEvent('keyup'));
+ document.execCommand('insertText', false, "e");
+ 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_ComposerSuggestion',
+ "should have a mention suggestion"
+ );
+ await afterNextRender(() =>
+ document.querySelector('.o_ComposerSuggestion').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);
+
+ this.data['res.partner'].records.push({
+ email: "testpartner@odoo.com",
+ name: "TestPartner",
+ });
+ await this.start();
+ const composer = this.env.models['mail.composer'].create();
+ await this.createComposerComponent(composer);
+
+ assert.containsNone(
+ document.body,
+ '.o_ComposerSuggestion',
+ "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, "@");
+ document.querySelector(`.o_ComposerTextInput_textarea`)
+ .dispatchEvent(new window.KeyboardEvent('keydown'));
+ document.querySelector(`.o_ComposerTextInput_textarea`)
+ .dispatchEvent(new window.KeyboardEvent('keyup'));
+ document.execCommand('insertText', false, "T");
+ document.querySelector(`.o_ComposerTextInput_textarea`)
+ .dispatchEvent(new window.KeyboardEvent('keydown'));
+ document.querySelector(`.o_ComposerTextInput_textarea`)
+ .dispatchEvent(new window.KeyboardEvent('keyup'));
+ document.execCommand('insertText', false, "e");
+ 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_ComposerSuggestion',
+ "should have a mention suggestion"
+ );
+ await afterNextRender(() =>
+ document.querySelector('.o_ComposerSuggestion').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('send message when enter is pressed while holding ctrl key (this shortcut is available)', async function (assert) {
+ // Note that test doesn't assert ENTER makes no newline, because this
+ // default browser cannot be simulated with just dispatching
+ // programmatically crafted events...
+ assert.expect(5);
+
+ this.data['mail.channel'].records.push({ id: 20 });
+ await this.start({
+ async mockRPC(route, args) {
+ if (args.method === 'message_post') {
+ assert.step('message_post');
+ }
+ return this._super(...arguments);
+ },
+ });
+ const thread = this.env.models['mail.thread'].findFromIdentifyingData({
+ id: 20,
+ model: 'mail.channel',
+ });
+ await this.createComposerComponent(thread.composer, {
+ textInputSendShortcuts: ['ctrl-enter'],
+ });
+ // 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"
+ );
+
+ await afterNextRender(() => {
+ const enterEvent = new window.KeyboardEvent('keydown', { key: 'Enter' });
+ document.querySelector(`.o_ComposerTextInput_textarea`)
+ .dispatchEvent(enterEvent);
+ });
+ assert.strictEqual(
+ document.querySelector(`.o_ComposerTextInput_textarea`).value,
+ "test message",
+ "should have inserted text content in editable as message has not been posted"
+ );
+
+ // Send message with ctrl+enter
+ await afterNextRender(() =>
+ document.querySelector(`.o_ComposerTextInput_textarea`)
+ .dispatchEvent(new window.KeyboardEvent('keydown', { ctrlKey: true, key: 'Enter' }))
+ );
+ assert.verifySteps(['message_post']);
+ assert.strictEqual(
+ document.querySelector(`.o_ComposerTextInput_textarea`).value,
+ "",
+ "should have no content in composer input as message has been posted"
+ );
+});
+
+QUnit.test('send message when enter is pressed while holding meta key (this shortcut is available)', async function (assert) {
+ // Note that test doesn't assert ENTER makes no newline, because this
+ // default browser cannot be simulated with just dispatching
+ // programmatically crafted events...
+ assert.expect(5);
+
+ this.data['mail.channel'].records.push({ id: 20 });
+ await this.start({
+ async mockRPC(route, args) {
+ if (args.method === 'message_post') {
+ assert.step('message_post');
+ }
+ return this._super(...arguments);
+ },
+ });
+ const thread = this.env.models['mail.thread'].findFromIdentifyingData({
+ id: 20,
+ model: 'mail.channel',
+ });
+ await this.createComposerComponent(thread.composer, {
+ textInputSendShortcuts: ['meta-enter'],
+ });
+ // 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"
+ );
+
+ await afterNextRender(() => {
+ const enterEvent = new window.KeyboardEvent('keydown', { key: 'Enter' });
+ document.querySelector(`.o_ComposerTextInput_textarea`)
+ .dispatchEvent(enterEvent);
+ });
+ assert.strictEqual(
+ document.querySelector(`.o_ComposerTextInput_textarea`).value,
+ "test message",
+ "should have inserted text content in editable as message has not been posted"
+ );
+
+ // Send message with meta+enter
+ await afterNextRender(() =>
+ document.querySelector(`.o_ComposerTextInput_textarea`)
+ .dispatchEvent(new window.KeyboardEvent('keydown', { key: 'Enter', metaKey: true }))
+ );
+ assert.verifySteps(['message_post']);
+ assert.strictEqual(
+ document.querySelector(`.o_ComposerTextInput_textarea`).value,
+ "",
+ "should have no content in composer input as message has been posted"
+ );
+});
+
+QUnit.test('composer text input cleared on message post', async function (assert) {
+ assert.expect(4);
+
+ // channel that is expected to be rendered
+ // with a random unique id that will be referenced in the test
+ this.data['mail.channel'].records.push({ id: 20 });
+ await this.start({
+ async mockRPC(route, args) {
+ if (args.method === 'message_post') {
+ assert.step('message_post');
+ }
+ return this._super(...arguments);
+ },
+ });
+ const thread = this.env.models['mail.thread'].findFromIdentifyingData({
+ id: 20,
+ model: 'mail.channel',
+ });
+ await this.createComposerComponent(thread.composer);
+ // Type message
+ await afterNextRender(() => {
+ 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_Composer_buttonSend').click()
+ );
+ 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 inputs cleared on message post in composer of a mailing channel', async function (assert) {
+ assert.expect(10);
+
+ // channel that is expected to be rendered, with proper mass_mailing
+ // value and a random unique id that will be referenced in the test
+ this.data['mail.channel'].records.push({ id: 20, mass_mailing: true });
+ await this.start({
+ async mockRPC(route, args) {
+ if (args.method === 'message_post') {
+ assert.step('message_post');
+ assert.ok(
+ 'body' in args.kwargs,
+ "body should be posted with the message"
+ );
+ assert.strictEqual(
+ args.kwargs.body,
+ "test message",
+ "posted body should be the one typed in text input"
+ );
+ assert.ok(
+ 'subject' in args.kwargs,
+ "subject should be posted with the message"
+ );
+ assert.strictEqual(
+ args.kwargs.subject,
+ "test subject",
+ "posted subject should be the one typed in subject input"
+ );
+ }
+ return this._super(...arguments);
+ },
+ });
+ const thread = this.env.models['mail.thread'].findFromIdentifyingData({
+ id: 20,
+ model: 'mail.channel',
+ });
+ await this.createComposerComponent(thread.composer);
+ // Type message
+ await afterNextRender(() => {
+ 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"
+ );
+
+ await afterNextRender(() => {
+ document.querySelector(`.o_Composer_subjectInput`).focus();
+ document.execCommand('insertText', false, "test subject");
+ });
+ assert.strictEqual(
+ document.querySelector(`.o_Composer_subjectInput`).value,
+ "test subject",
+ "should have inserted text content in input"
+ );
+
+ // Send message
+ await afterNextRender(() =>
+ document.querySelector('.o_Composer_buttonSend').click()
+ );
+ 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.querySelector(`.o_Composer_subjectInput`).value,
+ "",
+ "should have no content in composer subject input after posting message"
+ );
+});
+
+QUnit.test('composer with thread typing notification status', async function (assert) {
+ assert.expect(2);
+
+ // channel that is expected to be rendered
+ // with a random unique id that will be referenced in the test
+ this.data['mail.channel'].records.push({ id: 20 });
+ await this.start();
+ const thread = this.env.models['mail.thread'].findFromIdentifyingData({
+ id: 20,
+ 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);
+
+ // channel that is expected to be rendered
+ // with a random unique id that will be referenced in the test
+ this.data['mail.channel'].records.push({ id: 20 });
+ await this.start({
+ async mockRPC(route, args) {
+ if (args.method === 'notify_typing') {
+ assert.step(`notify_typing:${args.kwargs.is_typing}`);
+ }
+ return this._super(...arguments);
+ },
+ });
+ const thread = this.env.models['mail.thread'].findFromIdentifyingData({
+ id: 20,
+ 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);
+
+ // channel that is expected to be rendered
+ // with a random unique id that will be referenced in the test
+ this.data['mail.channel'].records.push({ id: 20 });
+ await this.start({
+ hasTimeControl: true,
+ async mockRPC(route, args) {
+ if (args.method === 'notify_typing') {
+ assert.step(`notify_typing:${args.kwargs.is_typing}`);
+ }
+ return this._super(...arguments);
+ },
+ });
+ const thread = this.env.models['mail.thread'].findFromIdentifyingData({
+ id: 20,
+ 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);
+
+ // channel that is expected to be rendered
+ // with a random unique id that will be referenced in the test
+ this.data['mail.channel'].records.push({ id: 20 });
+ await this.start({
+ hasTimeControl: true,
+ async mockRPC(route, args) {
+ if (args.method === 'notify_typing') {
+ assert.step(`notify_typing:${args.kwargs.is_typing}`);
+ }
+ return this._super(...arguments);
+ },
+ });
+ const thread = this.env.models['mail.thread'].findFromIdentifyingData({
+ id: 20,
+ 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);
+
+ // channel that is expected to be rendered
+ // with a random unique id that will be referenced in the test
+ this.data['mail.channel'].records.push({ id: 20 });
+ await this.start({
+ hasTimeControl: true,
+ async mockRPC(route, args) {
+ if (args.method === 'notify_typing') {
+ assert.step(`notify_typing:${args.kwargs.is_typing}`);
+ }
+ return this._super(...arguments);
+ },
+ });
+ const thread = this.env.models['mail.thread'].findFromIdentifyingData({
+ id: 20,
+ 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"
+ );
+});
+
+QUnit.test('composer: send button is disabled if attachment upload is not finished', async function (assert) {
+ assert.expect(8);
+
+ const attachmentUploadedPromise = makeTestPromise();
+ await this.start({
+ async mockFetch(resource, init) {
+ const res = this._super(...arguments);
+ if (resource === '/web/binary/upload_attachment') {
+ await attachmentUploadedPromise;
+ }
+ return res;
+ }
+ });
+ const composer = this.env.models['mail.composer'].create();
+ await this.createComposerComponent(composer);
+ const file = await createFile({
+ content: 'hello, world',
+ contentType: 'text/plain',
+ name: 'text.txt',
+ });
+ await afterNextRender(() =>
+ inputFiles(
+ document.querySelector('.o_FileUploader_input'),
+ [file]
+ )
+ );
+ assert.containsOnce(
+ document.body,
+ '.o_Attachment',
+ "should have an attachment after a file has been input"
+ );
+ assert.containsOnce(
+ document.body,
+ '.o_Attachment.o-temporary',
+ "attachment displayed is being uploaded"
+ );
+ assert.containsOnce(
+ document.body,
+ '.o_Composer_buttonSend',
+ "composer send button should be displayed"
+ );
+ assert.ok(
+ !!document.querySelector('.o_Composer_buttonSend').attributes.disabled,
+ "composer send button should be disabled as attachment is not yet uploaded"
+ );
+
+ // simulates attachment finishes uploading
+ await afterNextRender(() => attachmentUploadedPromise.resolve());
+ assert.containsOnce(
+ document.body,
+ '.o_Attachment',
+ "should have only one attachment"
+ );
+ assert.containsNone(
+ document.body,
+ '.o_Attachment.o-temporary',
+ "attachment displayed should be uploaded"
+ );
+ assert.containsOnce(
+ document.body,
+ '.o_Composer_buttonSend',
+ "composer send button should still be present"
+ );
+ assert.ok(
+ !document.querySelector('.o_Composer_buttonSend').attributes.disabled,
+ "composer send button should be enabled as attachment is now uploaded"
+ );
+});
+
+QUnit.test('warning on send with shortcut when attempting to post message with still-uploading attachments', async function (assert) {
+ assert.expect(7);
+
+ await this.start({
+ async mockFetch(resource, init) {
+ const res = this._super(...arguments);
+ if (resource === '/web/binary/upload_attachment') {
+ // simulates attachment is never finished uploading
+ await new Promise(() => {});
+ }
+ return res;
+ },
+ services: {
+ notification: {
+ notify(params) {
+ assert.strictEqual(
+ params.message,
+ "Please wait while the file is uploading.",
+ "notification content should be about the uploading file"
+ );
+ assert.strictEqual(
+ params.type,
+ 'warning',
+ "notification should be a warning"
+ );
+ assert.step('notification');
+ }
+ }
+ },
+ });
+ const thread = this.env.models['mail.thread'].create({
+ composer: [['create', { isLog: false }]],
+ id: 20,
+ model: 'res.partner',
+ });
+ await this.createComposerComponent(thread.composer, {
+ textInputSendShortcuts: ['enter'],
+ });
+ const file = await createFile({
+ content: 'hello, world',
+ contentType: 'text/plain',
+ name: 'text.txt',
+ });
+ await afterNextRender(() =>
+ inputFiles(
+ document.querySelector('.o_FileUploader_input'),
+ [file]
+ )
+ );
+ assert.containsOnce(
+ document.body,
+ '.o_Attachment',
+ "should have only one attachment"
+ );
+ assert.containsOnce(
+ document.body,
+ '.o_Attachment.o-temporary',
+ "attachment displayed is being uploaded"
+ );
+ assert.containsOnce(
+ document.body,
+ '.o_Composer_buttonSend',
+ "composer send button should be displayed"
+ );
+
+ // Try to send message
+ document
+ .querySelector(`.o_ComposerTextInput_textarea`)
+ .dispatchEvent(new window.KeyboardEvent('keydown', { key: 'Enter' }));
+ assert.verifySteps(
+ ['notification'],
+ "should have triggered a notification for inability to post message at the moment (some attachments are still being uploaded)"
+ );
+});
+
+QUnit.test('remove an attachment from composer does not need any confirmation', async function (assert) {
+ assert.expect(3);
+
+ await this.start();
+ const composer = this.env.models['mail.composer'].create();
+ await this.createComposerComponent(composer);
+ const file = await createFile({
+ content: 'hello, world',
+ contentType: 'text/plain',
+ name: 'text.txt',
+ });
+ await afterNextRender(() =>
+ inputFiles(
+ document.querySelector('.o_FileUploader_input'),
+ [file]
+ )
+ );
+ assert.containsOnce(
+ document.body,
+ '.o_Composer_attachmentList',
+ "should have an attachment list"
+ );
+ assert.containsOnce(
+ document.body,
+ '.o_Composer .o_Attachment',
+ "should have only one attachment"
+ );
+
+ await afterNextRender(() =>
+ document.querySelector('.o_Attachment_asideItemUnlink').click()
+ );
+ assert.containsNone(
+ document.body,
+ '.o_Composer .o_Attachment',
+ "should not have any attachment left after unlinking the only one"
+ );
+});
+
+QUnit.test('remove an uploading attachment', async function (assert) {
+ assert.expect(4);
+
+ await this.start({
+ async mockFetch(resource, init) {
+ const res = this._super(...arguments);
+ if (resource === '/web/binary/upload_attachment') {
+ // simulates uploading indefinitely
+ await new Promise(() => {});
+ }
+ return res;
+ }
+ });
+ const composer = this.env.models['mail.composer'].create();
+ await this.createComposerComponent(composer);
+ const file = await createFile({
+ content: 'hello, world',
+ contentType: 'text/plain',
+ name: 'text.txt',
+ });
+ await afterNextRender(() =>
+ inputFiles(
+ document.querySelector('.o_FileUploader_input'),
+ [file]
+ )
+ );
+ assert.containsOnce(
+ document.body,
+ '.o_Composer_attachmentList',
+ "should have an attachment list"
+ );
+ assert.containsOnce(
+ document.body,
+ '.o_Composer .o_Attachment',
+ "should have only one attachment"
+ );
+ assert.containsOnce(
+ document.body,
+ '.o_Composer .o_Attachment.o-temporary',
+ "should have an uploading attachment"
+ );
+
+ await afterNextRender(() =>
+ document.querySelector('.o_Attachment_asideItemUnlink').click());
+ assert.containsNone(
+ document.body,
+ '.o_Composer .o_Attachment',
+ "should not have any attachment left after unlinking temporary one"
+ );
+});
+
+QUnit.test('remove an uploading attachment aborts upload', async function (assert) {
+ assert.expect(1);
+
+ await this.start({
+ async mockFetch(resource, init) {
+ const res = this._super(...arguments);
+ if (resource === '/web/binary/upload_attachment') {
+ // simulates uploading indefinitely
+ await new Promise(() => {});
+ }
+ return res;
+ }
+ });
+ const composer = this.env.models['mail.composer'].create();
+ await this.createComposerComponent(composer);
+ const file = await createFile({
+ content: 'hello, world',
+ contentType: 'text/plain',
+ name: 'text.txt',
+ });
+ await afterNextRender(() =>
+ inputFiles(
+ document.querySelector('.o_FileUploader_input'),
+ [file]
+ )
+ );
+ assert.containsOnce(
+ document.body,
+ '.o_Attachment',
+ "should contain an attachment"
+ );
+ const attachmentLocalId = document.querySelector('.o_Attachment').dataset.attachmentLocalId;
+
+ await this.afterEvent({
+ eventName: 'o-attachment-upload-abort',
+ func: () => {
+ document.querySelector('.o_Attachment_asideItemUnlink').click();
+ },
+ message: "attachment upload request should have been aborted",
+ predicate: ({ attachment }) => {
+ return attachment.localId === attachmentLocalId;
+ },
+ });
+});
+
+QUnit.test("basic rendering when sending a message to the followers and thread doesn't have a name", async function (assert) {
+ assert.expect(1);
+
+ await this.start();
+ const thread = this.env.models['mail.thread'].create({
+ composer: [['create', { isLog: false }]],
+ id: 20,
+ model: 'res.partner',
+ });
+ await this.createComposerComponent(thread.composer, { hasFollowers: true });
+ assert.strictEqual(
+ document.querySelector('.o_Composer_followers').textContent.replace(/\s+/g, ''),
+ "To:Followersofthisdocument",
+ "Composer should display \"To: Followers of this document\" if the thread as no name."
+ );
+});
+
+QUnit.test('send message only once when button send is clicked twice quickly', async function (assert) {
+ assert.expect(2);
+
+ this.data['mail.channel'].records.push({ id: 20 });
+ await this.start({
+ async mockRPC(route, args) {
+ if (args.method === 'message_post') {
+ assert.step('message_post');
+ }
+ return this._super(...arguments);
+ },
+ });
+ const thread = this.env.models['mail.thread'].findFromIdentifyingData({
+ id: 20,
+ model: 'mail.channel',
+ });
+ await this.createComposerComponent(thread.composer);
+ // Type message
+ await afterNextRender(() => {
+ document.querySelector(`.o_ComposerTextInput_textarea`).focus();
+ document.execCommand('insertText', false, "test message");
+ });
+
+ await afterNextRender(() => {
+ document.querySelector(`.o_Composer_buttonSend`).click();
+ document.querySelector(`.o_Composer_buttonSend`).click();
+ });
+ assert.verifySteps(
+ ['message_post'],
+ "The message has been posted only once"
+ );
+});
+
+QUnit.test('send message only once when enter is pressed twice quickly', async function (assert) {
+ assert.expect(2);
+
+ this.data['mail.channel'].records.push({ id: 20 });
+ await this.start({
+ async mockRPC(route, args) {
+ if (args.method === 'message_post') {
+ assert.step('message_post');
+ }
+ return this._super(...arguments);
+ },
+ });
+ const thread = this.env.models['mail.thread'].findFromIdentifyingData({
+ id: 20,
+ model: 'mail.channel',
+ });
+ await this.createComposerComponent(thread.composer, {
+ textInputSendShortcuts: ['enter'],
+ });
+ // Type message
+ await afterNextRender(() => {
+ document.querySelector(`.o_ComposerTextInput_textarea`).focus();
+ document.execCommand('insertText', false, "test message");
+ });
+ await afterNextRender(() => {
+ const enterEvent = new window.KeyboardEvent('keydown', { key: 'Enter' });
+ document.querySelector(`.o_ComposerTextInput_textarea`)
+ .dispatchEvent(enterEvent);
+ document.querySelector(`.o_ComposerTextInput_textarea`)
+ .dispatchEvent(enterEvent);
+ });
+ assert.verifySteps(
+ ['message_post'],
+ "The message has been posted only once"
+ );
+});
+
+QUnit.test('[technical] does not crash when an attachment is removed before its upload starts', async function (assert) {
+ // Uploading multiple files uploads attachments one at a time, this test
+ // ensures that there is no crash when an attachment is destroyed before its
+ // upload started.
+ assert.expect(1);
+
+ // Promise to block attachment uploading
+ const uploadPromise = makeTestPromise();
+ await this.start({
+ async mockFetch(resource) {
+ const _super = this._super.bind(this, ...arguments);
+ if (resource === '/web/binary/upload_attachment') {
+ await uploadPromise;
+ }
+ return _super();
+ },
+ });
+ const composer = this.env.models['mail.composer'].create();
+ await this.createComposerComponent(composer);
+ const file1 = await createFile({
+ name: 'text1.txt',
+ content: 'hello, world',
+ contentType: 'text/plain',
+ });
+ const file2 = await createFile({
+ name: 'text2.txt',
+ content: 'hello, world',
+ contentType: 'text/plain',
+ });
+ await afterNextRender(() =>
+ inputFiles(
+ document.querySelector('.o_FileUploader_input'),
+ [file1, file2]
+ )
+ );
+ await afterNextRender(() => {
+ Array.from(document.querySelectorAll('div'))
+ .find(el => el.textContent === 'text2.txt')
+ .closest('.o_Attachment')
+ .querySelector('.o_Attachment_asideItemUnlink')
+ .click();
+ }
+ );
+ // Simulates the completion of the upload of the first attachment
+ uploadPromise.resolve();
+ assert.containsOnce(
+ document.body,
+ '.o_Attachment:contains("text1.txt")',
+ "should only have the first attachment after cancelling the second attachment"
+ );
+});
+
+});
+});
+});
+
+});
diff --git a/addons/mail/static/src/components/composer_suggested_recipient/composer_suggested_recipient.js b/addons/mail/static/src/components/composer_suggested_recipient/composer_suggested_recipient.js
new file mode 100644
index 00000000..efedf662
--- /dev/null
+++ b/addons/mail/static/src/components/composer_suggested_recipient/composer_suggested_recipient.js
@@ -0,0 +1,158 @@
+odoo.define('mail/static/src/components/composer_suggested_recipient/composer_suggested_recipient.js', function (require) {
+'use strict';
+
+const useShouldUpdateBasedOnProps = require('mail/static/src/component_hooks/use_should_update_based_on_props/use_should_update_based_on_props.js');
+const useStore = require('mail/static/src/component_hooks/use_store/use_store.js');
+const useUpdate = require('mail/static/src/component_hooks/use_update/use_update.js');
+
+const { FormViewDialog } = require('web.view_dialogs');
+const { ComponentAdapter } = require('web.OwlCompatibility');
+
+const { Component } = owl;
+const { useRef } = owl.hooks;
+
+class FormViewDialogComponentAdapter extends ComponentAdapter {
+
+ renderWidget() {
+ // Ensure the dialog is properly reconstructed. Without this line, it is
+ // impossible to open the dialog again after having it closed a first
+ // time, because the DOM of the dialog has disappeared.
+ return this.willStart();
+ }
+
+}
+
+const components = {
+ FormViewDialogComponentAdapter,
+};
+
+class ComposerSuggestedRecipient extends Component {
+
+ constructor(...args) {
+ super(...args);
+ this.id = _.uniqueId('o_ComposerSuggestedRecipient_');
+ useShouldUpdateBasedOnProps();
+ useStore(props => {
+ const suggestedRecipientInfo = this.env.models['mail.suggested_recipient_info'].get(props.suggestedRecipientLocalId);
+ const partner = suggestedRecipientInfo && suggestedRecipientInfo.partner;
+ return {
+ partner: partner && partner.__state,
+ suggestedRecipientInfo: suggestedRecipientInfo && suggestedRecipientInfo.__state,
+ };
+ });
+ useUpdate({ func: () => this._update() });
+ /**
+ * Form view dialog class. Useful to reference it in the template.
+ */
+ this.FormViewDialog = FormViewDialog;
+ /**
+ * Reference of the checkbox. Useful to know whether it was checked or
+ * not, to properly update the corresponding state in the record or to
+ * prompt the user with the partner creation dialog.
+ */
+ this._checkboxRef = useRef('checkbox');
+ /**
+ * Reference of the partner creation dialog. Useful to open it, for
+ * compatibility with old code.
+ */
+ this._dialogRef = useRef('dialog');
+ /**
+ * Whether the dialog is currently open. `_dialogRef` cannot be trusted
+ * to know if the dialog is open due to manually calling `open` and
+ * potential out of sync with component adapter.
+ */
+ this._isDialogOpen = false;
+ this._onDialogSaved = this._onDialogSaved.bind(this);
+ }
+
+ //--------------------------------------------------------------------------
+ // Public
+ //--------------------------------------------------------------------------
+
+ /**
+ * @returns {string|undefined}
+ */
+ get ADD_AS_RECIPIENT_AND_FOLLOWER_REASON() {
+ if (!this.suggestedRecipientInfo) {
+ return undefined;
+ }
+ return this.env._t(_.str.sprintf(
+ "Add as recipient and follower (reason: %s)",
+ this.suggestedRecipientInfo.reason
+ ));
+ }
+
+ /**
+ * @returns {string}
+ */
+ get PLEASE_COMPLETE_CUSTOMER_S_INFORMATION() {
+ return this.env._t("Please complete customer's information");
+ }
+
+ /**
+ * @returns {mail.suggested_recipient_info}
+ */
+ get suggestedRecipientInfo() {
+ return this.env.models['mail.suggested_recipient_info'].get(this.props.suggestedRecipientInfoLocalId);
+ }
+
+ //--------------------------------------------------------------------------
+ // Private
+ //--------------------------------------------------------------------------
+
+ /**
+ * @private
+ */
+ _update() {
+ if (this._checkboxRef.el && this.suggestedRecipientInfo) {
+ this._checkboxRef.el.checked = this.suggestedRecipientInfo.isSelected;
+ }
+ }
+
+ //--------------------------------------------------------------------------
+ // Handler
+ //--------------------------------------------------------------------------
+
+ /**
+ * @private
+ */
+ _onChangeCheckbox() {
+ const isChecked = this._checkboxRef.el.checked;
+ this.suggestedRecipientInfo.update({ isSelected: isChecked });
+ if (!this.suggestedRecipientInfo.partner) {
+ // Recipients must always be partners. On selecting a suggested
+ // recipient that does not have a partner, the partner creation form
+ // should be opened.
+ if (isChecked && this._dialogRef && !this._isDialogOpen) {
+ this._isDialogOpen = true;
+ this._dialogRef.comp.widget.on('closed', this, () => {
+ this._isDialogOpen = false;
+ });
+ this._dialogRef.comp.widget.open();
+ }
+ }
+ }
+
+ /**
+ * @private
+ */
+ _onDialogSaved() {
+ const thread = this.suggestedRecipientInfo && this.suggestedRecipientInfo.thread;
+ if (!thread) {
+ return;
+ }
+ thread.fetchAndUpdateSuggestedRecipients();
+ }
+}
+
+Object.assign(ComposerSuggestedRecipient, {
+ components,
+ props: {
+ suggestedRecipientInfoLocalId: String,
+ },
+ template: 'mail.ComposerSuggestedRecipient',
+});
+
+return ComposerSuggestedRecipient;
+
+});
diff --git a/addons/mail/static/src/components/composer_suggested_recipient/composer_suggested_recipient.scss b/addons/mail/static/src/components/composer_suggested_recipient/composer_suggested_recipient.scss
new file mode 100644
index 00000000..c8b6b5ad
--- /dev/null
+++ b/addons/mail/static/src/components/composer_suggested_recipient/composer_suggested_recipient.scss
@@ -0,0 +1,5 @@
+// Dirty fix: clear modal-body padding, otherwise it create space inside the
+// suggested_recipient list.
+.o_ComposerSuggestedRecipient .modal-body {
+ padding: 0;
+}
diff --git a/addons/mail/static/src/components/composer_suggested_recipient/composer_suggested_recipient.xml b/addons/mail/static/src/components/composer_suggested_recipient/composer_suggested_recipient.xml
new file mode 100644
index 00000000..4e754359
--- /dev/null
+++ b/addons/mail/static/src/components/composer_suggested_recipient/composer_suggested_recipient.xml
@@ -0,0 +1,41 @@
+<?xml version="1.0" encoding="UTF-8"?>
+<templates xml:space="preserve">
+ <t t-name="mail.ComposerSuggestedRecipient" owl="1">
+ <div class="o_ComposerSuggestedRecipient" t-att-data-partner-id="suggestedRecipientInfo.partner and suggestedRecipientInfo.partner.id ? suggestedRecipientInfo.partner.id : false" t-att-title="ADD_AS_RECIPIENT_AND_FOLLOWER_REASON">
+ <t t-if="suggestedRecipientInfo">
+ <div class="custom-control custom-checkbox">
+ <input t-attf-id="{{ id }}_checkbox" class="custom-control-input" type="checkbox" t-att-checked="suggestedRecipientInfo.isSelected ? 'checked' : undefined" t-on-change="_onChangeCheckbox" t-ref="checkbox" />
+ <label class="custom-control-label" t-attf-for="{{ id }}_checkbox">
+ <t t-if="suggestedRecipientInfo.name">
+ <t t-esc="suggestedRecipientInfo.name"/>
+ </t>
+ <t t-if="suggestedRecipientInfo.email">
+ (<t t-esc="suggestedRecipientInfo.email"/>)
+ </t>
+ </label>
+ </div>
+ <t t-if="!suggestedRecipientInfo.partner">
+ <FormViewDialogComponentAdapter
+ Component="FormViewDialog"
+ params="{
+ context: {
+ active_id: suggestedRecipientInfo.thread.id,
+ active_model: 'mail.compose.message',
+ default_email: suggestedRecipientInfo.email,
+ default_name: suggestedRecipientInfo.name,
+ force_email: true,
+ ref: 'compound_context',
+ },
+ disable_multiple_selection: true,
+ on_saved: _onDialogSaved,
+ res_id: false,
+ res_model: 'res.partner',
+ title: PLEASE_COMPLETE_CUSTOMER_S_INFORMATION,
+ }"
+ t-ref="dialog"
+ />
+ </t>
+ </t>
+ </div>
+ </t>
+</templates>
diff --git a/addons/mail/static/src/components/composer_suggested_recipient_list/composer_suggested_recipient_list.js b/addons/mail/static/src/components/composer_suggested_recipient_list/composer_suggested_recipient_list.js
new file mode 100644
index 00000000..61da098c
--- /dev/null
+++ b/addons/mail/static/src/components/composer_suggested_recipient_list/composer_suggested_recipient_list.js
@@ -0,0 +1,77 @@
+odoo.define('mail/static/src/components/composer_suggested_recipient_list/composer_suggested_recipient_list.js', function (require) {
+'use strict';
+
+const useShouldUpdateBasedOnProps = require('mail/static/src/component_hooks/use_should_update_based_on_props/use_should_update_based_on_props.js');
+const useStore = require('mail/static/src/component_hooks/use_store/use_store.js');
+
+const { Component } = owl;
+const { useState } = owl.hooks;
+
+const components = {
+ ComposerSuggestedRecipient: require('mail/static/src/components/composer_suggested_recipient/composer_suggested_recipient.js'),
+};
+
+class ComposerSuggestedRecipientList extends Component {
+
+ /**
+ * @override
+ */
+ constructor(...args) {
+ super(...args);
+ useShouldUpdateBasedOnProps();
+ this.state = useState({
+ hasShowMoreButton: false,
+ });
+ useStore(props => {
+ const thread = this.env.models['mail.thread'].get(props.threadLocalId);
+ return {
+ threadSuggestedRecipientInfoList: thread ? thread.suggestedRecipientInfoList : [],
+ };
+ }, {
+ compareDepth: {
+ threadSuggestedRecipientInfoList: 1,
+ },
+ });
+ }
+
+ //--------------------------------------------------------------------------
+ // Public
+ //--------------------------------------------------------------------------
+
+ /**
+ * @returns {mail.thread}
+ */
+ get thread() {
+ return this.env.models['mail.thread'].get(this.props.threadLocalId);
+ }
+
+ //--------------------------------------------------------------------------
+ // Handlers
+ //--------------------------------------------------------------------------
+
+ /**
+ * @private
+ */
+ _onClickShowLess(ev) {
+ this.state.hasShowMoreButton = false;
+ }
+
+ /**
+ * @private
+ */
+ _onClickShowMore(ev) {
+ this.state.hasShowMoreButton = true;
+ }
+
+}
+
+Object.assign(ComposerSuggestedRecipientList, {
+ components,
+ props: {
+ threadLocalId: String,
+ },
+ template: 'mail.ComposerSuggestedRecipientList',
+});
+
+return ComposerSuggestedRecipientList;
+});
diff --git a/addons/mail/static/src/components/composer_suggested_recipient_list/composer_suggested_recipient_list.scss b/addons/mail/static/src/components/composer_suggested_recipient_list/composer_suggested_recipient_list.scss
new file mode 100644
index 00000000..4e1b4dd7
--- /dev/null
+++ b/addons/mail/static/src/components/composer_suggested_recipient_list/composer_suggested_recipient_list.scss
@@ -0,0 +1,3 @@
+.o_ComposerSuggestedRecipientList {
+ margin-bottom: map-get($spacers, 2);
+}
diff --git a/addons/mail/static/src/components/composer_suggested_recipient_list/composer_suggested_recipient_list.xml b/addons/mail/static/src/components/composer_suggested_recipient_list/composer_suggested_recipient_list.xml
new file mode 100644
index 00000000..c78570db
--- /dev/null
+++ b/addons/mail/static/src/components/composer_suggested_recipient_list/composer_suggested_recipient_list.xml
@@ -0,0 +1,26 @@
+<?xml version="1.0" encoding="UTF-8"?>
+<templates xml:space="preserve">
+ <t t-name="mail.ComposerSuggestedRecipientList" owl="1">
+ <div class="o_ComposerSuggestedRecipientList">
+ <t t-if="thread">
+ <t t-foreach="state.hasShowMoreButton ? thread.suggestedRecipientInfoList : thread.suggestedRecipientInfoList.slice(0,3)" t-as="recipientInfo" t-key="recipientInfo.localId">
+ <ComposerSuggestedRecipient
+ suggestedRecipientInfoLocalId="recipientInfo.localId"
+ />
+ </t>
+ <t t-if="thread.suggestedRecipientInfoList.length > 3">
+ <t t-if="!state.hasShowMoreButton" >
+ <button class="o_ComposerSuggestedRecipientList_showMore btn btn-sm btn-link" t-on-click="_onClickShowMore">
+ Show more
+ </button>
+ </t>
+ <t t-else="">
+ <button class="o_ComposerSuggestedRecipientList_showLess btn btn-sm btn-link" t-on-click="_onClickShowLess">
+ Show less
+ </button>
+ </t>
+ </t>
+ </t>
+ </div>
+ </t>
+</templates>
diff --git a/addons/mail/static/src/components/composer_suggestion/composer_suggestion.js b/addons/mail/static/src/components/composer_suggestion/composer_suggestion.js
new file mode 100644
index 00000000..da54ab54
--- /dev/null
+++ b/addons/mail/static/src/components/composer_suggestion/composer_suggestion.js
@@ -0,0 +1,143 @@
+odoo.define('mail/static/src/components/composer_suggestion/composer_suggestion.js', function (require) {
+'use strict';
+
+const useShouldUpdateBasedOnProps = require('mail/static/src/component_hooks/use_should_update_based_on_props/use_should_update_based_on_props.js');
+const useStore = require('mail/static/src/component_hooks/use_store/use_store.js');
+const useUpdate = require('mail/static/src/component_hooks/use_update/use_update.js');
+
+const components = {
+ PartnerImStatusIcon: require('mail/static/src/components/partner_im_status_icon/partner_im_status_icon.js'),
+};
+
+const { Component } = owl;
+
+class ComposerSuggestion extends Component {
+
+ /**
+ * @override
+ */
+ constructor(...args) {
+ super(...args);
+ useShouldUpdateBasedOnProps();
+ useStore(props => {
+ const composer = this.env.models['mail.composer'].get(this.props.composerLocalId);
+ const record = this.env.models[props.modelName].get(props.recordLocalId);
+ return {
+ composerHasToScrollToActiveSuggestion: composer && composer.hasToScrollToActiveSuggestion,
+ record: record ? record.__state : undefined,
+ };
+ });
+ useUpdate({ func: () => this._update() });
+ }
+
+ //--------------------------------------------------------------------------
+ // Public
+ //--------------------------------------------------------------------------
+
+ /**
+ * @returns {mail.composer}
+ */
+ get composer() {
+ return this.env.models['mail.composer'].get(this.props.composerLocalId);
+ }
+
+ get isCannedResponse() {
+ return this.props.modelName === "mail.canned_response";
+ }
+
+ get isChannel() {
+ return this.props.modelName === "mail.thread";
+ }
+
+ get isCommand() {
+ return this.props.modelName === "mail.channel_command";
+ }
+
+ get isPartner() {
+ return this.props.modelName === "mail.partner";
+ }
+
+ get record() {
+ return this.env.models[this.props.modelName].get(this.props.recordLocalId);
+ }
+
+ /**
+ * Returns a descriptive title for this suggestion. Useful to be able to
+ * read both parts when they are overflowing the UI.
+ *
+ * @returns {string}
+ */
+ title() {
+ if (this.isCannedResponse) {
+ return _.str.sprintf("%s: %s", this.record.source, this.record.substitution);
+ }
+ if (this.isChannel) {
+ return this.record.name;
+ }
+ if (this.isCommand) {
+ return _.str.sprintf("%s: %s", this.record.name, this.record.help);
+ }
+ if (this.isPartner) {
+ if (this.record.email) {
+ return _.str.sprintf("%s (%s)", this.record.nameOrDisplayName, this.record.email);
+ }
+ return this.record.nameOrDisplayName;
+ }
+ return "";
+ }
+
+ //--------------------------------------------------------------------------
+ // Private
+ //--------------------------------------------------------------------------
+
+ /**
+ * @private
+ */
+ _update() {
+ if (
+ this.composer &&
+ this.composer.hasToScrollToActiveSuggestion &&
+ this.props.isActive
+ ) {
+ this.el.scrollIntoView({
+ block: 'center',
+ });
+ this.composer.update({ hasToScrollToActiveSuggestion: false });
+ }
+ }
+
+ //--------------------------------------------------------------------------
+ // Handlers
+ //--------------------------------------------------------------------------
+
+ /**
+ * @private
+ * @param {Event} ev
+ */
+ _onClick(ev) {
+ ev.preventDefault();
+ this.composer.update({ activeSuggestedRecord: [['link', this.record]] });
+ this.composer.insertSuggestion();
+ this.composer.closeSuggestions();
+ this.trigger('o-composer-suggestion-clicked');
+ }
+
+}
+
+Object.assign(ComposerSuggestion, {
+ components,
+ defaultProps: {
+ isActive: false,
+ },
+ props: {
+ composerLocalId: String,
+ isActive: Boolean,
+ modelName: String,
+ recordLocalId: String,
+ },
+ template: 'mail.ComposerSuggestion',
+});
+
+return ComposerSuggestion;
+
+});
diff --git a/addons/mail/static/src/components/composer_suggestion/composer_suggestion.scss b/addons/mail/static/src/components/composer_suggestion/composer_suggestion.scss
new file mode 100644
index 00000000..4083c149
--- /dev/null
+++ b/addons/mail/static/src/components/composer_suggestion/composer_suggestion.scss
@@ -0,0 +1,43 @@
+// ------------------------------------------------------------------
+// Layout
+// ------------------------------------------------------------------
+
+.o_ComposerSuggestion {
+ display: flex;
+ width: map-get($sizes, 100);
+ padding: map-get($spacers, 2) map-get($spacers, 4);
+}
+
+.o_ComposerSuggestion_part1 {
+ // avoid shrinking part 1 because it is more important than part 2
+ // because no shrink, ensure it cannot overflow with a max-width
+ flex: 0 0 auto;
+ max-width: 100%;
+ overflow: hidden;
+ padding-inline-end: map-get($spacers, 2);
+ text-overflow: ellipsis;
+}
+
+.o_ComposerSuggestion_part2 {
+ // shrink part 2 to properly ensure it cannot overflow
+ flex: 0 1 auto;
+ overflow: hidden;
+ text-overflow: ellipsis;
+}
+
+.o_ComposerSuggestion_partnerImStatusIcon {
+ flex: 0 0 auto;
+}
+
+// ------------------------------------------------------------------
+// Style
+// ------------------------------------------------------------------
+
+.o_ComposerSuggestion_part1 {
+ font-weight: $font-weight-bold;
+}
+
+.o_ComposerSuggestion_part2 {
+ font-style: italic;
+ color: $gray-600;
+}
diff --git a/addons/mail/static/src/components/composer_suggestion/composer_suggestion.xml b/addons/mail/static/src/components/composer_suggestion/composer_suggestion.xml
new file mode 100644
index 00000000..787b5aed
--- /dev/null
+++ b/addons/mail/static/src/components/composer_suggestion/composer_suggestion.xml
@@ -0,0 +1,33 @@
+<?xml version="1.0" encoding="UTF-8"?>
+<templates xml:space="preserve">
+
+ <t t-name="mail.ComposerSuggestion" owl="1">
+ <a class="o_ComposerSuggestion dropdown-item" t-att-class="{ 'active': props.isActive }" href="#" t-att-title="title()" role="menuitem" t-on-click="_onClick">
+ <t t-if="record">
+ <t t-if="isCannedResponse">
+ <span class="o_ComposerSuggestion_part1"><t t-esc="record.source"/></span>
+ <span class="o_ComposerSuggestion_part2"><t t-esc="record.substitution"/></span>
+ </t>
+ <t t-if="isChannel">
+ <span class="o_ComposerSuggestion_part1"><t t-esc="record.name"/></span>
+ </t>
+ <t t-if="isCommand">
+ <span class="o_ComposerSuggestion_part1"><t t-esc="record.name"/></span>
+ <span class="o_ComposerSuggestion_part2"><t t-esc="record.help"/></span>
+ </t>
+ <t t-if="isPartner">
+ <PartnerImStatusIcon
+ class="o_ComposerSuggestion_partnerImStatusIcon"
+ hasBackground="false"
+ partnerLocalId="record.localId"
+ />
+ <span class="o_ComposerSuggestion_part1"><t t-esc="record.nameOrDisplayName"/></span>
+ <t t-if="record.email">
+ <span class="o_ComposerSuggestion_part2">(<t t-esc="record.email"/>)</span>
+ </t>
+ </t>
+ </t>
+ </a>
+ </t>
+
+</templates>
diff --git a/addons/mail/static/src/components/composer_suggestion/composer_suggestion_canned_response_tests.js b/addons/mail/static/src/components/composer_suggestion/composer_suggestion_canned_response_tests.js
new file mode 100644
index 00000000..0e0f8685
--- /dev/null
+++ b/addons/mail/static/src/components/composer_suggestion/composer_suggestion_canned_response_tests.js
@@ -0,0 +1,154 @@
+odoo.define('mail/static/src/components/composer_suggestion/composer_suggestion_canned_response_tests.js', function (require) {
+'use strict';
+
+const components = {
+ ComposerSuggestion: require('mail/static/src/components/composer_suggestion/composer_suggestion.js'),
+};
+const {
+ afterEach,
+ beforeEach,
+ createRootComponent,
+ start,
+} = require('mail/static/src/utils/test_utils.js');
+
+QUnit.module('mail', {}, function () {
+QUnit.module('components', {}, function () {
+QUnit.module('composer_suggestion', {}, function () {
+QUnit.module('composer_suggestion_canned_response_tests.js', {
+ beforeEach() {
+ beforeEach(this);
+
+ this.createComposerSuggestion = async props => {
+ await createRootComponent(this, components.ComposerSuggestion, {
+ props,
+ target: this.widget.el,
+ });
+ };
+
+ this.start = async params => {
+ const { env, widget } = await start(Object.assign({}, params, {
+ data: this.data,
+ }));
+ this.env = env;
+ this.widget = widget;
+ };
+ },
+ afterEach() {
+ afterEach(this);
+ },
+});
+
+QUnit.test('canned response suggestion displayed', async function (assert) {
+ assert.expect(1);
+
+ this.data['mail.channel'].records.push({ id: 20 });
+ await this.start();
+ const thread = this.env.models['mail.thread'].findFromIdentifyingData({
+ id: 20,
+ model: 'mail.channel',
+ });
+ const cannedResponse = this.env.models['mail.canned_response'].create({
+ id: 7,
+ source: 'hello',
+ substitution: "Hello, how are you?",
+ });
+ await this.createComposerSuggestion({
+ composerLocalId: thread.composer.localId,
+ isActive: true,
+ modelName: 'mail.canned_response',
+ recordLocalId: cannedResponse.localId,
+ });
+
+ assert.containsOnce(
+ document.body,
+ `.o_ComposerSuggestion`,
+ "Canned response suggestion should be present"
+ );
+});
+
+QUnit.test('canned response suggestion correct data', async function (assert) {
+ assert.expect(5);
+
+ this.data['mail.channel'].records.push({ id: 20 });
+ await this.start();
+ const thread = this.env.models['mail.thread'].findFromIdentifyingData({
+ id: 20,
+ model: 'mail.channel',
+ });
+ const cannedResponse = this.env.models['mail.canned_response'].create({
+ id: 7,
+ source: 'hello',
+ substitution: "Hello, how are you?",
+ });
+ await this.createComposerSuggestion({
+ composerLocalId: thread.composer.localId,
+ isActive: true,
+ modelName: 'mail.canned_response',
+ recordLocalId: cannedResponse.localId,
+ });
+
+ assert.containsOnce(
+ document.body,
+ '.o_ComposerSuggestion',
+ "Canned response suggestion should be present"
+ );
+ assert.containsOnce(
+ document.body,
+ '.o_ComposerSuggestion_part1',
+ "Canned response source should be present"
+ );
+ assert.strictEqual(
+ document.querySelector(`.o_ComposerSuggestion_part1`).textContent,
+ "hello",
+ "Canned response source should be displayed"
+ );
+ assert.containsOnce(
+ document.body,
+ '.o_ComposerSuggestion_part2',
+ "Canned response substitution should be present"
+ );
+ assert.strictEqual(
+ document.querySelector(`.o_ComposerSuggestion_part2`).textContent,
+ "Hello, how are you?",
+ "Canned response substitution should be displayed"
+ );
+});
+
+QUnit.test('canned response suggestion active', async function (assert) {
+ assert.expect(2);
+
+ this.data['mail.channel'].records.push({ id: 20 });
+ await this.start();
+ const thread = this.env.models['mail.thread'].findFromIdentifyingData({
+ id: 20,
+ model: 'mail.channel',
+ });
+ const cannedResponse = this.env.models['mail.canned_response'].create({
+ id: 7,
+ source: 'hello',
+ substitution: "Hello, how are you?",
+ });
+ await this.createComposerSuggestion({
+ composerLocalId: thread.composer.localId,
+ isActive: true,
+ modelName: 'mail.canned_response',
+ recordLocalId: cannedResponse.localId,
+ });
+
+ assert.containsOnce(
+ document.body,
+ '.o_ComposerSuggestion',
+ "Canned response suggestion should be displayed"
+ );
+ assert.hasClass(
+ document.querySelector('.o_ComposerSuggestion'),
+ 'active',
+ "should be active initially"
+ );
+});
+
+});
+});
+});
+
+});
diff --git a/addons/mail/static/src/components/composer_suggestion/composer_suggestion_channel_tests.js b/addons/mail/static/src/components/composer_suggestion/composer_suggestion_channel_tests.js
new file mode 100644
index 00000000..7a211483
--- /dev/null
+++ b/addons/mail/static/src/components/composer_suggestion/composer_suggestion_channel_tests.js
@@ -0,0 +1,144 @@
+odoo.define('mail/static/src/components/composer_suggestion/composer_suggestion_channel_tests.js', function (require) {
+'use strict';
+
+const components = {
+ ComposerSuggestion: require('mail/static/src/components/composer_suggestion/composer_suggestion.js'),
+};
+const {
+ afterEach,
+ beforeEach,
+ createRootComponent,
+ start,
+} = require('mail/static/src/utils/test_utils.js');
+
+QUnit.module('mail', {}, function () {
+QUnit.module('components', {}, function () {
+QUnit.module('composer_suggestion', {}, function () {
+QUnit.module('composer_suggestion_channel_tests.js', {
+ beforeEach() {
+ beforeEach(this);
+
+ this.createComposerSuggestion = async props => {
+ await createRootComponent(this, components.ComposerSuggestion, {
+ props,
+ target: this.widget.el,
+ });
+ };
+
+ this.start = async params => {
+ const { env, widget } = await start(Object.assign({}, params, {
+ data: this.data,
+ }));
+ this.env = env;
+ this.widget = widget;
+ };
+ },
+ afterEach() {
+ afterEach(this);
+ },
+});
+
+QUnit.test('channel mention suggestion displayed', async function (assert) {
+ assert.expect(1);
+
+ this.data['mail.channel'].records.push({ id: 20 });
+ await this.start();
+ const thread = this.env.models['mail.thread'].findFromIdentifyingData({
+ id: 20,
+ model: 'mail.channel',
+ });
+ const channel = this.env.models['mail.thread'].create({
+ id: 7,
+ name: "General",
+ model: 'mail.channel',
+ });
+ await this.createComposerSuggestion({
+ composerLocalId: thread.composer.localId,
+ isActive: true,
+ modelName: 'mail.thread',
+ recordLocalId: channel.localId,
+ });
+
+ assert.containsOnce(
+ document.body,
+ `.o_ComposerSuggestion`,
+ "Channel mention suggestion should be present"
+ );
+});
+
+QUnit.test('channel mention suggestion correct data', async function (assert) {
+ assert.expect(3);
+
+ this.data['mail.channel'].records.push({ id: 20 });
+ await this.start();
+ const thread = this.env.models['mail.thread'].findFromIdentifyingData({
+ id: 20,
+ model: 'mail.channel',
+ });
+ const channel = this.env.models['mail.thread'].create({
+ id: 7,
+ name: "General",
+ model: 'mail.channel',
+ });
+ await this.createComposerSuggestion({
+ composerLocalId: thread.composer.localId,
+ isActive: true,
+ modelName: 'mail.thread',
+ recordLocalId: channel.localId,
+ });
+
+ assert.containsOnce(
+ document.body,
+ '.o_ComposerSuggestion',
+ "Channel mention suggestion should be present"
+ );
+ assert.containsOnce(
+ document.body,
+ '.o_ComposerSuggestion_part1',
+ "Channel name should be present"
+ );
+ assert.strictEqual(
+ document.querySelector(`.o_ComposerSuggestion_part1`).textContent,
+ "General",
+ "Channel name should be displayed"
+ );
+});
+
+QUnit.test('channel mention suggestion active', async function (assert) {
+ assert.expect(2);
+
+ this.data['mail.channel'].records.push({ id: 20 });
+ await this.start();
+ const thread = this.env.models['mail.thread'].findFromIdentifyingData({
+ id: 20,
+ model: 'mail.channel',
+ });
+ const channel = this.env.models['mail.thread'].create({
+ id: 7,
+ name: "General",
+ model: 'mail.channel',
+ });
+ await this.createComposerSuggestion({
+ composerLocalId: thread.composer.localId,
+ isActive: true,
+ modelName: 'mail.thread',
+ recordLocalId: channel.localId,
+ });
+
+ assert.containsOnce(
+ document.body,
+ '.o_ComposerSuggestion',
+ "Channel mention suggestion should be displayed"
+ );
+ assert.hasClass(
+ document.querySelector('.o_ComposerSuggestion'),
+ 'active',
+ "should be active initially"
+ );
+});
+
+});
+});
+});
+
+});
diff --git a/addons/mail/static/src/components/composer_suggestion/composer_suggestion_command_tests.js b/addons/mail/static/src/components/composer_suggestion/composer_suggestion_command_tests.js
new file mode 100644
index 00000000..8bbb3d45
--- /dev/null
+++ b/addons/mail/static/src/components/composer_suggestion/composer_suggestion_command_tests.js
@@ -0,0 +1,151 @@
+odoo.define('mail/static/src/components/composer_suggestion/composer_suggestion_command_tests.js', function (require) {
+'use strict';
+
+const components = {
+ ComposerSuggestion: require('mail/static/src/components/composer_suggestion/composer_suggestion.js'),
+};
+const {
+ afterEach,
+ beforeEach,
+ createRootComponent,
+ start,
+} = require('mail/static/src/utils/test_utils.js');
+
+QUnit.module('mail', {}, function () {
+QUnit.module('components', {}, function () {
+QUnit.module('composer_suggestion', {}, function () {
+QUnit.module('composer_suggestion_command_tests.js', {
+ beforeEach() {
+ beforeEach(this);
+
+ this.createComposerSuggestion = async props => {
+ await createRootComponent(this, components.ComposerSuggestion, {
+ props,
+ target: this.widget.el,
+ });
+ };
+
+ this.start = async params => {
+ const { env, widget } = await start(Object.assign({}, params, {
+ data: this.data,
+ }));
+ this.env = env;
+ this.widget = widget;
+ };
+ },
+ afterEach() {
+ afterEach(this);
+ },
+});
+
+QUnit.test('command suggestion displayed', async function (assert) {
+ assert.expect(1);
+
+ this.data['mail.channel'].records.push({ id: 20 });
+ await this.start();
+ const thread = this.env.models['mail.thread'].findFromIdentifyingData({
+ id: 20,
+ model: 'mail.channel',
+ });
+ const command = this.env.models['mail.channel_command'].create({
+ name: 'whois',
+ help: "Displays who it is",
+ });
+ await this.createComposerSuggestion({
+ composerLocalId: thread.composer.localId,
+ isActive: true,
+ modelName: 'mail.channel_command',
+ recordLocalId: command.localId,
+ });
+
+ assert.containsOnce(
+ document.body,
+ `.o_ComposerSuggestion`,
+ "Command suggestion should be present"
+ );
+});
+
+QUnit.test('command suggestion correct data', async function (assert) {
+ assert.expect(5);
+
+ this.data['mail.channel'].records.push({ id: 20 });
+ await this.start();
+ const thread = this.env.models['mail.thread'].findFromIdentifyingData({
+ id: 20,
+ model: 'mail.channel',
+ });
+ const command = this.env.models['mail.channel_command'].create({
+ name: 'whois',
+ help: "Displays who it is",
+ });
+ await this.createComposerSuggestion({
+ composerLocalId: thread.composer.localId,
+ isActive: true,
+ modelName: 'mail.channel_command',
+ recordLocalId: command.localId,
+ });
+
+ assert.containsOnce(
+ document.body,
+ '.o_ComposerSuggestion',
+ "Command suggestion should be present"
+ );
+ assert.containsOnce(
+ document.body,
+ '.o_ComposerSuggestion_part1',
+ "Command name should be present"
+ );
+ assert.strictEqual(
+ document.querySelector(`.o_ComposerSuggestion_part1`).textContent,
+ "whois",
+ "Command name should be displayed"
+ );
+ assert.containsOnce(
+ document.body,
+ '.o_ComposerSuggestion_part2',
+ "Command help should be present"
+ );
+ assert.strictEqual(
+ document.querySelector(`.o_ComposerSuggestion_part2`).textContent,
+ "Displays who it is",
+ "Command help should be displayed"
+ );
+});
+
+QUnit.test('command suggestion active', async function (assert) {
+ assert.expect(2);
+
+ this.data['mail.channel'].records.push({ id: 20 });
+ await this.start();
+ const thread = this.env.models['mail.thread'].findFromIdentifyingData({
+ id: 20,
+ model: 'mail.channel',
+ });
+ const command = this.env.models['mail.channel_command'].create({
+ name: 'whois',
+ help: "Displays who it is",
+ });
+ await this.createComposerSuggestion({
+ composerLocalId: thread.composer.localId,
+ isActive: true,
+ modelName: 'mail.channel_command',
+ recordLocalId: command.localId,
+ });
+
+ assert.containsOnce(
+ document.body,
+ '.o_ComposerSuggestion',
+ "Command suggestion should be displayed"
+ );
+ assert.hasClass(
+ document.querySelector('.o_ComposerSuggestion'),
+ 'active',
+ "should be active initially"
+ );
+});
+
+});
+});
+});
+
+});
diff --git a/addons/mail/static/src/components/composer_suggestion/composer_suggestion_partner_tests.js b/addons/mail/static/src/components/composer_suggestion/composer_suggestion_partner_tests.js
new file mode 100644
index 00000000..548fd6d7
--- /dev/null
+++ b/addons/mail/static/src/components/composer_suggestion/composer_suggestion_partner_tests.js
@@ -0,0 +1,160 @@
+odoo.define('mail/static/src/components/composer_suggestion/composer_suggestion_partner_tests.js', function (require) {
+'use strict';
+
+const components = {
+ ComposerSuggestion: require('mail/static/src/components/composer_suggestion/composer_suggestion.js'),
+};
+const {
+ afterEach,
+ beforeEach,
+ createRootComponent,
+ start,
+} = require('mail/static/src/utils/test_utils.js');
+
+QUnit.module('mail', {}, function () {
+QUnit.module('components', {}, function () {
+QUnit.module('composer_suggestion', {}, function () {
+QUnit.module('composer_suggestion_partner_tests.js', {
+ beforeEach() {
+ beforeEach(this);
+
+ this.createComposerSuggestion = async props => {
+ await createRootComponent(this, components.ComposerSuggestion, {
+ props,
+ target: this.widget.el,
+ });
+ };
+
+ this.start = async params => {
+ const { env, widget } = await start(Object.assign({}, params, {
+ data: this.data,
+ }));
+ this.env = env;
+ this.widget = widget;
+ };
+ },
+ afterEach() {
+ afterEach(this);
+ },
+});
+
+QUnit.test('partner mention suggestion displayed', async function (assert) {
+ assert.expect(1);
+
+ this.data['mail.channel'].records.push({ id: 20 });
+ await this.start();
+ const thread = this.env.models['mail.thread'].findFromIdentifyingData({
+ id: 20,
+ model: 'mail.channel',
+ });
+ const partner = this.env.models['mail.partner'].create({
+ id: 7,
+ im_status: 'online',
+ name: "Demo User",
+ });
+ await this.createComposerSuggestion({
+ composerLocalId: thread.composer.localId,
+ isActive: true,
+ modelName: 'mail.partner',
+ recordLocalId: partner.localId,
+ });
+
+ assert.containsOnce(
+ document.body,
+ `.o_ComposerSuggestion`,
+ "Partner mention suggestion should be present"
+ );
+});
+
+QUnit.test('partner mention suggestion correct data', async function (assert) {
+ assert.expect(6);
+
+ this.data['mail.channel'].records.push({ id: 20 });
+ await this.start();
+ const thread = this.env.models['mail.thread'].findFromIdentifyingData({
+ id: 20,
+ model: 'mail.channel',
+ });
+ const partner = this.env.models['mail.partner'].create({
+ email: "demo_user@odoo.com",
+ id: 7,
+ im_status: 'online',
+ name: "Demo User",
+ });
+ await this.createComposerSuggestion({
+ composerLocalId: thread.composer.localId,
+ isActive: true,
+ modelName: 'mail.partner',
+ recordLocalId: partner.localId,
+ });
+
+ assert.containsOnce(
+ document.body,
+ '.o_ComposerSuggestion',
+ "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_ComposerSuggestion_part1',
+ "Partner's name should be present"
+ );
+ assert.strictEqual(
+ document.querySelector(`.o_ComposerSuggestion_part1`).textContent,
+ "Demo User",
+ "Partner's name should be displayed"
+ );
+ assert.containsOnce(
+ document.body,
+ '.o_ComposerSuggestion_part2',
+ "Partner's email should be present"
+ );
+ assert.strictEqual(
+ document.querySelector(`.o_ComposerSuggestion_part2`).textContent,
+ "(demo_user@odoo.com)",
+ "Partner's email should be displayed"
+ );
+});
+
+QUnit.test('partner mention suggestion active', async function (assert) {
+ assert.expect(2);
+
+ this.data['mail.channel'].records.push({ id: 20 });
+ await this.start();
+ const thread = this.env.models['mail.thread'].findFromIdentifyingData({
+ id: 20,
+ model: 'mail.channel',
+ });
+ const partner = this.env.models['mail.partner'].create({
+ id: 7,
+ im_status: 'online',
+ name: "Demo User",
+ });
+ await this.createComposerSuggestion({
+ composerLocalId: thread.composer.localId,
+ isActive: true,
+ modelName: 'mail.partner',
+ recordLocalId: partner.localId,
+ });
+
+ assert.containsOnce(
+ document.body,
+ '.o_ComposerSuggestion',
+ "Partner mention suggestion should be displayed"
+ );
+ assert.hasClass(
+ document.querySelector('.o_ComposerSuggestion'),
+ 'active',
+ "should be active initially"
+ );
+});
+
+});
+});
+});
+
+});
diff --git a/addons/mail/static/src/components/composer_suggestion_list/composer_suggestion_list.js b/addons/mail/static/src/components/composer_suggestion_list/composer_suggestion_list.js
new file mode 100644
index 00000000..23f08399
--- /dev/null
+++ b/addons/mail/static/src/components/composer_suggestion_list/composer_suggestion_list.js
@@ -0,0 +1,73 @@
+odoo.define('mail/static/src/components/composer_suggestion_list/composer_suggestion_list.js', function (require) {
+'use strict';
+
+const components = {
+ ComposerSuggestion: require('mail/static/src/components/composer_suggestion/composer_suggestion.js'),
+};
+const useShouldUpdateBasedOnProps = require('mail/static/src/component_hooks/use_should_update_based_on_props/use_should_update_based_on_props.js');
+const useStore = require('mail/static/src/component_hooks/use_store/use_store.js');
+
+const { Component } = owl;
+
+class ComposerSuggestionList extends Component {
+
+ /**
+ * @override
+ */
+ constructor(...args) {
+ super(...args);
+ useShouldUpdateBasedOnProps();
+ useStore(props => {
+ const composer = this.env.models['mail.composer'].get(props.composerLocalId);
+ const activeSuggestedRecord = composer
+ ? composer.activeSuggestedRecord
+ : undefined;
+ const extraSuggestedRecords = composer
+ ? composer.extraSuggestedRecords
+ : [];
+ const mainSuggestedRecords = composer
+ ? composer.mainSuggestedRecords
+ : [];
+ return {
+ activeSuggestedRecord,
+ composer,
+ composerSuggestionModelName: composer && composer.suggestionModelName,
+ extraSuggestedRecords,
+ mainSuggestedRecords,
+ };
+ }, {
+ compareDepth: {
+ extraSuggestedRecords: 1,
+ mainSuggestedRecords: 1,
+ },
+ });
+ }
+
+ //--------------------------------------------------------------------------
+ // Public
+ //--------------------------------------------------------------------------
+
+ /**
+ * @returns {mail.composer}
+ */
+ get composer() {
+ return this.env.models['mail.composer'].get(this.props.composerLocalId);
+ }
+
+}
+
+Object.assign(ComposerSuggestionList, {
+ components,
+ defaultProps: {
+ isBelow: false,
+ },
+ props: {
+ composerLocalId: String,
+ isBelow: Boolean,
+ },
+ template: 'mail.ComposerSuggestionList',
+});
+
+return ComposerSuggestionList;
+
+});
diff --git a/addons/mail/static/src/components/composer_suggestion_list/composer_suggestion_list.scss b/addons/mail/static/src/components/composer_suggestion_list/composer_suggestion_list.scss
new file mode 100644
index 00000000..fa8d375e
--- /dev/null
+++ b/addons/mail/static/src/components/composer_suggestion_list/composer_suggestion_list.scss
@@ -0,0 +1,27 @@
+// ------------------------------------------------------------------
+// Layout
+// ------------------------------------------------------------------
+
+.o_ComposerSuggestionList {
+ position: absolute;
+ // prevent suggestion items from overflowing
+ width: 100%;
+
+ &.o-lowPosition {
+ bottom: 0;
+ }
+}
+
+.o_ComposerSuggestionList_drop {
+ // prevent suggestion items from overflowing
+ width: 100%;
+}
+
+.o_ComposerSuggestionList_list {
+ // prevent suggestion items from overflowing
+ width: 100%;
+ // prevent from overflowing chat window, must be smaller than its height
+ // minus the max height taken by composer and attachment list
+ max-height: 150px;
+ overflow: auto;
+}
diff --git a/addons/mail/static/src/components/composer_suggestion_list/composer_suggestion_list.xml b/addons/mail/static/src/components/composer_suggestion_list/composer_suggestion_list.xml
new file mode 100644
index 00000000..9747109a
--- /dev/null
+++ b/addons/mail/static/src/components/composer_suggestion_list/composer_suggestion_list.xml
@@ -0,0 +1,32 @@
+<?xml version="1.0" encoding="UTF-8"?>
+<templates xml:space="preserve">
+
+ <t t-name="mail.ComposerSuggestionList" owl="1">
+ <div class="o_ComposerSuggestionList" t-att-class="{ 'o-lowPosition': props.isBelow }">
+ <div class="o_ComposerSuggestionList_drop" t-att-class="{ 'dropdown': props.isBelow, 'dropup': !props.isBelow }">
+ <div class="o_ComposerSuggestionList_list dropdown-menu show">
+ <t t-foreach="composer.mainSuggestedRecords" t-as="record" t-key="record.localId">
+ <ComposerSuggestion
+ composerLocalId="props.composerLocalId"
+ isActive="record === composer.activeSuggestedRecord"
+ modelName="composer.suggestionModelName"
+ recordLocalId="record.localId"
+ />
+ </t>
+ <t t-if="composer.mainSuggestedRecords.length > 0 and composer.extraSuggestedRecords.length > 0">
+ <div role="separator" class="dropdown-divider"/>
+ </t>
+ <t t-foreach="composer.extraSuggestedRecords" t-as="record" t-key="record.localId">
+ <ComposerSuggestion
+ composerLocalId="props.composerLocalId"
+ isActive="record === composer.activeSuggestedRecord"
+ modelName="composer.suggestionModelName"
+ recordLocalId="record.localId"
+ />
+ </t>
+ </div>
+ </div>
+ </div>
+ </t>
+
+</templates>
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 00000000..2bdd34da
--- /dev/null
+++ b/addons/mail/static/src/components/composer_text_input/composer_text_input.js
@@ -0,0 +1,419 @@
+odoo.define('mail/static/src/components/composer_text_input/composer_text_input.js', function (require) {
+'use strict';
+
+const useShouldUpdateBasedOnProps = require('mail/static/src/component_hooks/use_should_update_based_on_props/use_should_update_based_on_props.js');
+const useStore = require('mail/static/src/component_hooks/use_store/use_store.js');
+const useUpdate = require('mail/static/src/component_hooks/use_update/use_update.js');
+
+const components = {
+ ComposerSuggestionList: require('mail/static/src/components/composer_suggestion_list/composer_suggestion_list.js'),
+};
+const { markEventHandled } = require('mail/static/src/utils/utils.js');
+
+const { Component } = owl;
+const { useRef } = owl.hooks;
+
+class ComposerTextInput extends Component {
+
+ /**
+ * @override
+ */
+ constructor(...args) {
+ super(...args);
+ useShouldUpdateBasedOnProps({
+ compareDepth: {
+ sendShortcuts: 1,
+ },
+ });
+ useStore(props => {
+ const composer = this.env.models['mail.composer'].get(props.composerLocalId);
+ const thread = composer && composer.thread;
+ return {
+ composerHasFocus: composer && composer.hasFocus,
+ composerHasSuggestions: composer && composer.hasSuggestions,
+ composerIsLog: composer && composer.isLog,
+ composerTextInputContent: composer && composer.textInputContent,
+ composerTextInputCursorEnd: composer && composer.textInputCursorEnd,
+ composerTextInputCursorStart: composer && composer.textInputCursorStart,
+ composerTextInputSelectionDirection: composer && composer.textInputSelectionDirection,
+ isDeviceMobile: this.env.messaging.device.isMobile,
+ threadModel: thread && thread.model,
+ };
+ });
+ /**
+ * Updates the composer text input content when composer is mounted
+ * as textarea content can't be changed from the DOM.
+ */
+ useUpdate({ func: () => this._update() });
+ /**
+ * 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');
+ /**
+ * This is the invisible textarea used to compute the composer height
+ * based on the text content. We need it to downsize the textarea
+ * properly without flicker.
+ */
+ this._mirroredTextareaRef = useRef('mirroredTextarea');
+ }
+
+ //--------------------------------------------------------------------------
+ // Public
+ //--------------------------------------------------------------------------
+
+ /**
+ * @returns {mail.composer}
+ */
+ get composer() {
+ return this.env.models['mail.composer'].get(this.props.composerLocalId);
+ }
+
+ /**
+ * @returns {string}
+ */
+ get textareaPlaceholder() {
+ if (!this.composer) {
+ return "";
+ }
+ if (this.composer.thread && this.composer.thread.model !== 'mail.channel') {
+ if (this.composer.isLog) {
+ return this.env._t("Log an internal note...");
+ }
+ return this.env._t("Send a message to followers...");
+ }
+ return this.env._t("Write something...");
+ }
+
+ focus() {
+ this._textareaRef.el.focus();
+ }
+
+ focusout() {
+ this.saveStateInStore();
+ this._textareaRef.el.blur();
+ }
+
+ /**
+ * Saves the composer text input state in store
+ */
+ saveStateInStore() {
+ this.composer.update({
+ textInputContent: this._getContent(),
+ textInputCursorEnd: this._getSelectionEnd(),
+ textInputCursorStart: this._getSelectionStart(),
+ textInputSelectionDirection: this._textareaRef.el.selectionDirection,
+ });
+ }
+
+ //--------------------------------------------------------------------------
+ // 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() {
+ if (!this.composer) {
+ return;
+ }
+ if (this.composer.isLastStateChangeProgrammatic) {
+ this._textareaRef.el.value = this.composer.textInputContent;
+ if (this.composer.hasFocus) {
+ this._textareaRef.el.setSelectionRange(
+ this.composer.textInputCursorStart,
+ this.composer.textInputCursorEnd,
+ this.composer.textInputSelectionDirection,
+ );
+ }
+ this.composer.update({ isLastStateChangeProgrammatic: false });
+ }
+ this._updateHeight();
+ }
+
+ /**
+ * Updates the textarea height.
+ *
+ * @private
+ */
+ _updateHeight() {
+ this._mirroredTextareaRef.el.value = this.composer.textInputContent;
+ this._textareaRef.el.style.height = (this._mirroredTextareaRef.el.scrollHeight) + "px";
+ }
+
+ //--------------------------------------------------------------------------
+ // Handlers
+ //--------------------------------------------------------------------------
+
+ /**
+ * @private
+ */
+ _onClickTextarea() {
+ // clicking might change the cursor position
+ this.saveStateInStore();
+ }
+
+ /**
+ * @private
+ */
+ _onFocusinTextarea() {
+ this.composer.focus();
+ this.trigger('o-focusin-composer');
+ }
+
+ /**
+ * @private
+ */
+ _onFocusoutTextarea() {
+ this.saveStateInStore();
+ this.composer.update({ hasFocus: false });
+ }
+
+ /**
+ * @private
+ */
+ _onInputTextarea() {
+ this.saveStateInStore();
+ if (this._textareaLastInputValue !== this._textareaRef.el.value) {
+ this.composer.handleCurrentPartnerIsTyping();
+ }
+ this._textareaLastInputValue = this._textareaRef.el.value;
+ this._updateHeight();
+ }
+
+ /**
+ * @private
+ * @param {KeyboardEvent} ev
+ */
+ _onKeydownTextarea(ev) {
+ switch (ev.key) {
+ case 'Escape':
+ if (this.composer.hasSuggestions) {
+ ev.preventDefault();
+ this.composer.closeSuggestions();
+ markEventHandled(ev, 'ComposerTextInput.closeSuggestions');
+ }
+ break;
+ // 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.hasSuggestions) {
+ // 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.hasSuggestions) {
+ ev.preventDefault();
+ return;
+ }
+ if (
+ this.props.sendShortcuts.includes('ctrl-enter') &&
+ !ev.altKey &&
+ ev.ctrlKey &&
+ !ev.metaKey &&
+ !ev.shiftKey
+ ) {
+ this.trigger('o-composer-text-input-send-shortcut');
+ ev.preventDefault();
+ return;
+ }
+ if (
+ this.props.sendShortcuts.includes('enter') &&
+ !ev.altKey &&
+ !ev.ctrlKey &&
+ !ev.metaKey &&
+ !ev.shiftKey
+ ) {
+ this.trigger('o-composer-text-input-send-shortcut');
+ ev.preventDefault();
+ return;
+ }
+ if (
+ this.props.sendShortcuts.includes('meta-enter') &&
+ !ev.altKey &&
+ !ev.ctrlKey &&
+ ev.metaKey &&
+ !ev.shiftKey
+ ) {
+ this.trigger('o-composer-text-input-send-shortcut');
+ ev.preventDefault();
+ return;
+ }
+ }
+
+ /**
+ * Key events management is performed in a Keyup to avoid intempestive RPC calls
+ *
+ * @private
+ * @param {KeyboardEvent} ev
+ */
+ _onKeyupTextarea(ev) {
+ switch (ev.key) {
+ case 'Escape':
+ // already handled in _onKeydownTextarea, break to avoid default
+ break;
+ // ENTER, HOME, END, UP, DOWN, PAGE UP, PAGE DOWN, TAB: check if navigation in mention suggestions
+ case 'Enter':
+ if (this.composer.hasSuggestions) {
+ this.composer.insertSuggestion();
+ this.composer.closeSuggestions();
+ this.focus();
+ }
+ break;
+ case 'ArrowUp':
+ case 'PageUp':
+ if (this.composer.hasSuggestions) {
+ this.composer.setPreviousSuggestionActive();
+ this.composer.update({ hasToScrollToActiveSuggestion: true });
+ }
+ break;
+ case 'ArrowDown':
+ case 'PageDown':
+ if (this.composer.hasSuggestions) {
+ this.composer.setNextSuggestionActive();
+ this.composer.update({ hasToScrollToActiveSuggestion: true });
+ }
+ break;
+ case 'Home':
+ if (this.composer.hasSuggestions) {
+ this.composer.setFirstSuggestionActive();
+ this.composer.update({ hasToScrollToActiveSuggestion: true });
+ }
+ break;
+ case 'End':
+ if (this.composer.hasSuggestions) {
+ this.composer.setLastSuggestionActive();
+ this.composer.update({ hasToScrollToActiveSuggestion: true });
+ }
+ break;
+ case 'Tab':
+ if (this.composer.hasSuggestions) {
+ if (ev.shiftKey) {
+ this.composer.setPreviousSuggestionActive();
+ this.composer.update({ hasToScrollToActiveSuggestion: true });
+ } else {
+ this.composer.setNextSuggestionActive();
+ this.composer.update({ hasToScrollToActiveSuggestion: true });
+ }
+ }
+ break;
+ case 'Alt':
+ case 'AltGraph':
+ case 'CapsLock':
+ case 'Control':
+ case 'Fn':
+ case 'FnLock':
+ case 'Hyper':
+ case 'Meta':
+ case 'NumLock':
+ case 'ScrollLock':
+ case 'Shift':
+ case 'ShiftSuper':
+ case 'Symbol':
+ case 'SymbolLock':
+ // prevent modifier keys from resetting the suggestion state
+ break;
+ // Otherwise, check if a mention is typed
+ default:
+ this.saveStateInStore();
+ }
+ }
+
+}
+
+Object.assign(ComposerTextInput, {
+ components,
+ defaultProps: {
+ hasMentionSuggestionsBelowPosition: false,
+ sendShortcuts: [],
+ },
+ props: {
+ composerLocalId: String,
+ hasMentionSuggestionsBelowPosition: Boolean,
+ isCompact: Boolean,
+ /**
+ * Keyboard shortcuts from text input to send message.
+ */
+ sendShortcuts: {
+ type: Array,
+ element: String,
+ validate: prop => {
+ for (const shortcut of prop) {
+ if (!['ctrl-enter', 'enter', 'meta-enter'].includes(shortcut)) {
+ return false;
+ }
+ }
+ return true;
+ },
+ },
+ },
+ 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 00000000..b9119a71
--- /dev/null
+++ b/addons/mail/static/src/components/composer_text_input/composer_text_input.scss
@@ -0,0 +1,40 @@
+// ------------------------------------------------------------------
+// Layout
+// ------------------------------------------------------------------
+
+.o_ComposerTextInput {
+ min-width: 0;
+ position: relative;
+}
+
+.o_ComposerTextInput_mirroredTextarea {
+ height: 0;
+ position: absolute;
+ opacity: 0;
+ overflow: hidden;
+ top: -10000px;
+}
+
+.o_ComposerTextInput_textareaStyle {
+ padding: 10px;
+ resize: none;
+ border-radius: $o-mail-rounded-rectangle-border-radius-lg;
+ border: none;
+ overflow: auto;
+
+ &.o-composer-is-compact {
+ // When composer is compact, textarea should not be rounded on the right as
+ // buttons are glued to it
+ border-top-right-radius: 0;
+ border-bottom-right-radius: 0;
+ // Chat window height should be taken into account to choose this value
+ // ideally this should be less than the third of chat window height
+ max-height: 100px;
+ }
+
+ &:not(.o-composer-is-compact) {
+ // Don't allow the input to take the whole height when it's not compact
+ // (like in chatter for example) but allow it to take some more place
+ max-height: 400px;
+ }
+}
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 00000000..a14fdee8
--- /dev/null
+++ b/addons/mail/static/src/components/composer_text_input/composer_text_input.xml
@@ -0,0 +1,24 @@
+<?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.hasSuggestions">
+ <ComposerSuggestionList
+ composerLocalId="props.composerLocalId"
+ isBelow="props.hasMentionSuggestionsBelowPosition"
+ />
+ </t>
+ <textarea class="o_ComposerTextInput_textarea o_ComposerTextInput_textareaStyle" t-att-class="{ 'o-composer-is-compact': props.isCompact }" t-esc="composer.textInputContent" t-att-placeholder="textareaPlaceholder" t-on-click="_onClickTextarea" t-on-focusin="_onFocusinTextarea" t-on-focusout="_onFocusoutTextarea" t-on-keydown="_onKeydownTextarea" t-on-keyup="_onKeyupTextarea" t-on-input="_onInputTextarea" t-ref="textarea"/>
+ <!--
+ This is an invisible textarea used to compute the composer
+ height based on the text content. We need it to downsize
+ the textarea properly without flicker.
+ -->
+ <textarea class="o_ComposerTextInput_mirroredTextarea o_ComposerTextInput_textareaStyle" t-att-class="{ 'o-composer-is-compact': props.isCompact }" t-esc="composer.textInputContent" t-ref="mirroredTextarea" disabled="1"/>
+ </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 00000000..65902c9c
--- /dev/null
+++ b/addons/mail/static/src/components/dialog/dialog.js
@@ -0,0 +1,119 @@
+odoo.define('mail/static/src/components/dialog/dialog.js', function (require) {
+'use strict';
+
+const useShouldUpdateBasedOnProps = require('mail/static/src/component_hooks/use_should_update_based_on_props/use_should_update_based_on_props.js');
+const useStore = require('mail/static/src/component_hooks/use_store/use_store.js');
+
+const patchMixin = require('web.patchMixin');
+
+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);
+ useShouldUpdateBasedOnProps();
+ useStore(props => {
+ const dialog = this.env.models['mail.dialog'].get(props.dialogLocalId);
+ return {
+ dialog: dialog ? dialog.__state : undefined,
+ };
+ });
+ this._constructor();
+ }
+
+ /**
+ * Allows patching constructor.
+ */
+ _constructor() {}
+
+ 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 && 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 &&
+ this._componentRef.comp.isCloseable &&
+ !this._componentRef.comp.isCloseable()
+ ) {
+ return;
+ }
+ this.dialog.delete();
+ }
+
+ /**
+ * @private
+ * @param {KeyboardEvent} ev
+ */
+ _onKeydownDocument(ev) {
+ if (ev.key === 'Escape') {
+ this.dialog.delete();
+ }
+ }
+
+}
+
+Object.assign(Dialog, {
+ props: {
+ dialogLocalId: String,
+ },
+ template: 'mail.Dialog',
+});
+
+return patchMixin(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 00000000..fa17dac0
--- /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: $zindex-modal;
+}
+
+// ------------------------------------------------------------------
+// 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 00000000..3c953dec
--- /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 00000000..69b64a27
--- /dev/null
+++ b/addons/mail/static/src/components/dialog_manager/dialog_manager.js
@@ -0,0 +1,69 @@
+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 useShouldUpdateBasedOnProps = require('mail/static/src/component_hooks/use_should_update_based_on_props/use_should_update_based_on_props.js');
+const useStore = require('mail/static/src/component_hooks/use_store/use_store.js');
+
+const { Component } = owl;
+
+class DialogManager extends Component {
+
+ /**
+ * @override
+ */
+ constructor(...args) {
+ super(...args);
+ useShouldUpdateBasedOnProps();
+ useStore(props => {
+ const dialogManager = this.env.messaging && this.env.messaging.dialogManager;
+ return {
+ dialogManager: dialogManager ? dialogManager.__state : undefined,
+ };
+ });
+ }
+
+ mounted() {
+ this._checkDialogOpen();
+ }
+
+ patched() {
+ this._checkDialogOpen();
+ }
+
+ //--------------------------------------------------------------------------
+ // Private
+ //--------------------------------------------------------------------------
+
+ /**
+ * @private
+ */
+ _checkDialogOpen() {
+ if (!this.env.messaging) {
+ /**
+ * Messaging not created, which means essential models like
+ * dialog manager are not ready, so open status of dialog in DOM
+ * is omitted during this (short) period of time.
+ */
+ return;
+ }
+ 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 00000000..035e543e
--- /dev/null
+++ b/addons/mail/static/src/components/dialog_manager/dialog_manager.xml
@@ -0,0 +1,17 @@
+<?xml version="1.0" encoding="UTF-8"?>
+<templates xml:space="preserve">
+
+ <t t-name="mail.DialogManager" owl="1">
+ <div class="o_DialogManager">
+ <t t-if="env.messaging">
+ <t t-foreach="env.messaging.dialogManager.dialogs" t-as="dialog" t-key="dialog.localId">
+ <Dialog
+ class="o_DialogManager_dialog"
+ dialogLocalId="dialog.localId"
+ />
+ </t>
+ </t>
+ </div>
+ </t>
+
+</templates>
diff --git a/addons/mail/static/src/components/dialog_manager/dialog_manager_tests.js b/addons/mail/static/src/components/dialog_manager/dialog_manager_tests.js
new file mode 100644
index 00000000..f377ec17
--- /dev/null
+++ b/addons/mail/static/src/components/dialog_manager/dialog_manager_tests.js
@@ -0,0 +1,82 @@
+odoo.define('mail/static/src/components/dialog_manager/dialog_manager_tests.js', function (require) {
+'use strict';
+
+const { makeDeferred } = require('mail/static/src/utils/deferred/deferred.js');
+const {
+ afterEach,
+ beforeEach,
+ nextAnimationFrame,
+ start,
+} = require('mail/static/src/utils/test_utils.js');
+
+QUnit.module('mail', {}, function () {
+QUnit.module('components', {}, function () {
+QUnit.module('dialog_manager', {}, function () {
+QUnit.module('dialog_manager_tests.js', {
+ beforeEach() {
+ beforeEach(this);
+
+ this.start = async params => {
+ const { env, widget } = await start(Object.assign(
+ { hasDialog: true },
+ params,
+ { data: this.data }
+ ));
+ this.env = env;
+ this.widget = widget;
+ };
+ },
+ afterEach() {
+ afterEach(this);
+ },
+});
+
+QUnit.test('[technical] messaging not created', async function (assert) {
+ /**
+ * Creation of messaging in env is async due to generation of models being
+ * async. Generation of models is async because it requires parsing of all
+ * JS modules that contain pieces of model definitions.
+ *
+ * Time of having no messaging is very short, almost imperceptible by user
+ * on UI, but the display should not crash during this critical time period.
+ */
+ assert.expect(2);
+
+ const messagingBeforeCreationDeferred = makeDeferred();
+ await this.start({
+ messagingBeforeCreationDeferred,
+ waitUntilMessagingCondition: 'none',
+ });
+ assert.containsOnce(
+ document.body,
+ '.o_DialogManager',
+ "should have dialog manager even when messaging is not yet created"
+ );
+
+ // simulate messaging being created
+ messagingBeforeCreationDeferred.resolve();
+ await nextAnimationFrame();
+
+ assert.containsOnce(
+ document.body,
+ '.o_DialogManager',
+ "should still contain dialog manager after messaging has been created"
+ );
+});
+
+QUnit.test('initial mount', async function (assert) {
+ assert.expect(1);
+
+ await this.start();
+ assert.containsOnce(
+ document.body,
+ '.o_DialogManager',
+ "should have dialog manager"
+ );
+});
+
+});
+});
+});
+
+});
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 00000000..068fd88d
--- /dev/null
+++ b/addons/mail/static/src/components/discuss/discuss.js
@@ -0,0 +1,313 @@
+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'),
+ ThreadView: require('mail/static/src/components/thread_view/thread_view.js'),
+};
+const useShouldUpdateBasedOnProps = require('mail/static/src/component_hooks/use_should_update_based_on_props/use_should_update_based_on_props.js');
+const useStore = require('mail/static/src/component_hooks/use_store/use_store.js');
+
+const patchMixin = require('web.patchMixin');
+
+const { Component } = owl;
+const { useRef } = owl.hooks;
+
+class Discuss extends Component {
+
+ /**
+ * @override
+ */
+ constructor(...args) {
+ super(...args);
+ useShouldUpdateBasedOnProps();
+ useStore((...args) => this._useStoreSelector(...args), {
+ compareDepth: {
+ checkedMessages: 1,
+ uncheckedMessages: 1,
+ },
+ });
+ this._updateLocalStoreProps();
+ /**
+ * Reference of the composer. Useful to focus it.
+ */
+ this._composerRef = useRef('composer');
+ /**
+ * Reference of the ThreadView. Useful to focus it.
+ */
+ this._threadViewRef = useRef('threadView');
+ // 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.isMessagingInitialized()) {
+ 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.discuss.threadView &&
+ this._lastThreadCache === this.discuss.threadView.threadCache.localId &&
+ this._lastThreadCounter > 0 && this.discuss.thread.counter === 0
+ ) {
+ this.trigger('o-show-rainbow-man');
+ }
+ this._activeThreadCache = this.discuss.threadView && this.discuss.threadView.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.threadView &&
+ this.discuss.threadView.threadCache &&
+ this.discuss.threadView.threadCache.localId
+ );
+ /**
+ * Locally tracked store props `threadCounter`.
+ * Useful to display the rainbow man on inbox.
+ */
+ this._lastThreadCounter = (
+ this.discuss.thread &&
+ this.discuss.thread.counter
+ );
+ }
+
+ /**
+ * Returns data selected from the store.
+ *
+ * @private
+ * @param {Object} props
+ * @returns {Object}
+ */
+ _useStoreSelector(props) {
+ const discuss = this.env.messaging && this.env.messaging.discuss;
+ const thread = discuss && discuss.thread;
+ const threadView = discuss && discuss.threadView;
+ const replyingToMessage = discuss && discuss.replyingToMessage;
+ const replyingToMessageOriginThread = replyingToMessage && replyingToMessage.originThread;
+ const checkedMessages = threadView ? threadView.checkedMessages : [];
+ return {
+ checkedMessages,
+ checkedMessagesIsModeratedByCurrentPartner: checkedMessages && checkedMessages.some(message => message.isModeratedByCurrentPartner), // for widget
+ discuss,
+ discussActiveId: discuss && discuss.activeId, // for widget
+ discussActiveMobileNavbarTabId: discuss && discuss.activeMobileNavbarTabId,
+ discussHasModerationDiscardDialog: discuss && discuss.hasModerationDiscardDialog,
+ discussHasModerationRejectDialog: discuss && discuss.hasModerationRejectDialog,
+ discussIsAddingChannel: discuss && discuss.isAddingChannel,
+ discussIsAddingChat: discuss && discuss.isAddingChat,
+ discussIsDoFocus: discuss && discuss.isDoFocus,
+ discussReplyingToMessageOriginThreadComposer: replyingToMessageOriginThread && replyingToMessageOriginThread.composer,
+ inbox: this.env.messaging.inbox,
+ isDeviceMobile: this.env.messaging && this.env.messaging.device.isMobile,
+ isMessagingInitialized: this.env.isMessagingInitialized(),
+ replyingToMessage,
+ starred: this.env.messaging.starred, // for widget
+ thread,
+ threadCache: threadView && threadView.threadCache,
+ threadChannelType: thread && thread.channel_type, // for widget
+ threadDisplayName: thread && thread.displayName, // for widget
+ threadCounter: thread && thread.counter,
+ threadModel: thread && thread.model,
+ threadPublic: thread && thread.public, // for widget
+ threadView,
+ threadViewMessagesLength: threadView && threadView.messages.length, // for widget
+ uncheckedMessages: threadView ? threadView.uncheckedMessages : [], // for widget
+ };
+ }
+
+ //--------------------------------------------------------------------------
+ // Handlers
+ //--------------------------------------------------------------------------
+
+ /**
+ * @private
+ */
+ _onDialogClosedModerationDiscard() {
+ this.discuss.update({ hasModerationDiscardDialog: false });
+ }
+
+ /**
+ * @private
+ */
+ _onDialogClosedModerationReject() {
+ this.discuss.update({ hasModerationRejectDialog: false });
+ }
+
+ /**
+ * @private
+ * @param {CustomEvent} ev
+ */
+ _onFocusinComposer(ev) {
+ this.discuss.update({ isDoFocus: 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
+ */
+ _onThreadRendered(ev) {
+ this.trigger('o-update-control-panel');
+ }
+
+}
+
+Object.assign(Discuss, {
+ components,
+ props: {},
+ template: 'mail.Discuss',
+});
+
+return patchMixin(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 00000000..5cddf101
--- /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 00000000..ad9171a5
--- /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 ? discuss.isAddingChannel or discuss.isAddingChat : false,
+ 'o-mobile': env.messaging ? env.messaging.device.isMobile : false,
+ }"
+ >
+ <t t-if="!env.isMessagingInitialized()">
+ <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-if="discuss.hasModerationDiscardDialog">
+ <ModerationDiscardDialog messageLocalIds="discuss.threadView.checkedMessages.map(message => message.localId)" t-on-dialog-closed="_onDialogClosedModerationDiscard"/>
+ </t>
+ <t t-if="discuss.hasModerationRejectDialog">
+ <ModerationRejectDialog messageLocalIds="discuss.threadView.checkedMessages.map(message => message.localId)" t-on-dialog-closed="_onDialogClosedModerationReject"/>
+ </t>
+ </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="discuss.threadView">
+ <ThreadView
+ 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'"
+ haveMessagesMarkAsReadIcon="discuss.thread === env.messaging.inbox"
+ haveMessagesReplyIcon="discuss.thread === env.messaging.inbox"
+ isDoFocus="discuss.isDoFocus"
+ selectedMessageLocalId="discuss.replyingToMessage and discuss.replyingToMessage.localId"
+ threadViewLocalId="discuss.threadView.localId"
+ t-on-o-focusin-composer="_onFocusinComposer"
+ t-on-o-rendered="_onThreadRendered"
+ t-ref="threadView"
+ />
+ </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>
+ <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"
+ isDoFocus="discuss.isDoFocus"
+ textInputSendShortcuts="['ctrl-enter', 'meta-enter']"
+ t-on-o-focusin-composer="_onFocusinComposer"
+ t-on-o-message-posted="_onReplyingToMessageMessagePosted"
+ t-ref="composer"
+ />
+ </t>
+ </t>
+
+</templates>
diff --git a/addons/mail/static/src/components/discuss/tests/discuss_domain_tests.js b/addons/mail/static/src/components/discuss/tests/discuss_domain_tests.js
new file mode 100644
index 00000000..26431207
--- /dev/null
+++ b/addons/mail/static/src/components/discuss/tests/discuss_domain_tests.js
@@ -0,0 +1,408 @@
+odoo.define('mail/static/src/components/discuss/tests/discuss_domain_tests.js', function (require) {
+'use strict';
+
+const {
+ afterEach,
+ afterNextRender,
+ beforeEach,
+ start,
+} = require('mail/static/src/utils/test_utils.js');
+
+QUnit.module('mail', {}, function () {
+QUnit.module('components', {}, function () {
+QUnit.module('discuss', {}, function () {
+QUnit.module('discuss_domain_tests.js', {
+ beforeEach() {
+ beforeEach(this);
+
+ this.start = async params => {
+ const { afterEvent, env, widget } = await start(Object.assign({}, params, {
+ autoOpenDiscuss: true,
+ data: this.data,
+ hasDiscuss: true,
+ }));
+ this.afterEvent = afterEvent;
+ this.env = env;
+ this.widget = widget;
+ };
+ },
+ afterEach() {
+ afterEach(this);
+ },
+});
+
+QUnit.test('discuss should filter messages based on given domain', async function (assert) {
+ assert.expect(2);
+
+ this.data['mail.message'].records.push({
+ body: "test",
+ needaction: true,
+ needaction_partner_ids: [this.data.currentPartnerId],
+ }, {
+ body: "not empty",
+ needaction: true,
+ needaction_partner_ids: [this.data.currentPartnerId],
+ });
+ await this.start();
+ assert.containsN(
+ document.body,
+ '.o_Message',
+ 2,
+ "should have 2 messages in Inbox initially"
+ );
+
+ await this.afterEvent({
+ eventName: 'o-thread-view-hint-processed',
+ func: () => {
+ // simulate control panel search
+ this.env.messaging.discuss.update({
+ stringifiedDomain: JSON.stringify([['body', 'ilike', 'test']]),
+ });
+ },
+ message: "should wait until search filter is applied",
+ predicate: ({ hint, threadViewer }) => {
+ return (
+ hint.type === 'messages-loaded' &&
+ hint.data.fetchedMessages.length === 1 &&
+ threadViewer.thread.model === 'mail.box' &&
+ threadViewer.thread.id === 'inbox'
+ );
+ },
+ });
+ assert.containsOnce(
+ document.body,
+ '.o_Message',
+ "should only have the 1 message containing 'test' remaining after doing a search"
+ );
+});
+
+QUnit.test('discuss should keep filter domain on changing thread', async function (assert) {
+ assert.expect(3);
+
+ this.data['mail.channel'].records.push({ id: 20 });
+ this.data['mail.message'].records.push({
+ body: "test",
+ channel_ids: [20],
+ }, {
+ body: "not empty",
+ channel_ids: [20],
+ });
+ await this.start();
+ const channel = this.env.models['mail.thread'].findFromIdentifyingData({
+ id: 20,
+ model: 'mail.channel',
+ });
+ assert.containsNone(
+ document.body,
+ '.o_Message',
+ "should have no message in Inbox initially"
+ );
+
+ await this.afterEvent({
+ eventName: 'o-thread-view-hint-processed',
+ func: () => {
+ // simulate control panel search
+ this.env.messaging.discuss.update({
+ stringifiedDomain: JSON.stringify([['body', 'ilike', 'test']]),
+ });
+ },
+ message: "should wait until search filter is applied",
+ predicate: ({ hint, threadViewer }) => {
+ return (
+ hint.type === 'messages-loaded' &&
+ threadViewer.thread.model === 'mail.box' &&
+ threadViewer.thread.id === 'inbox'
+ );
+ },
+ });
+ assert.containsNone(
+ document.body,
+ '.o_Message',
+ "should have still no message in Inbox after doing a search"
+ );
+
+ await this.afterEvent({
+ eventName: 'o-thread-view-hint-processed',
+ func: () => {
+ document.querySelector(`
+ .o_DiscussSidebar_item[data-thread-local-id="${channel.localId}"]
+ `).click();
+ },
+ message: "should wait until channel 20 is loaded after clicking on it",
+ predicate: ({ hint, threadViewer }) => {
+ return (
+ hint.type === 'messages-loaded' &&
+ threadViewer.thread.model === 'mail.channel' &&
+ threadViewer.thread.id === 20
+ );
+ },
+ });
+ assert.containsOnce(
+ document.body,
+ '.o_Message',
+ "should only have the 1 message containing 'test' in channel 20 (due to the domain still applied on changing thread)"
+ );
+});
+
+QUnit.test('discuss should refresh filtered thread on receiving new message', async function (assert) {
+ assert.expect(2);
+
+ this.data['mail.channel'].records.push({ id: 20 });
+ await this.start();
+ const channel = this.env.models['mail.thread'].findFromIdentifyingData({
+ id: 20,
+ model: 'mail.channel',
+ });
+ await this.afterEvent({
+ eventName: 'o-thread-view-hint-processed',
+ func: () => {
+ document.querySelector(`
+ .o_DiscussSidebar_item[data-thread-local-id="${channel.localId}"]
+ `).click();
+ },
+ message: "should wait until channel 20 is loaded after clicking on it",
+ predicate: ({ hint, threadViewer }) => {
+ return (
+ hint.type === 'messages-loaded' &&
+ threadViewer.thread.model === 'mail.channel' &&
+ threadViewer.thread.id === 20
+ );
+ },
+ });
+ await this.afterEvent({
+ eventName: 'o-thread-view-hint-processed',
+ func: () => {
+ // simulate control panel search
+ this.env.messaging.discuss.update({
+ stringifiedDomain: JSON.stringify([['body', 'ilike', 'test']]),
+ });
+ },
+ message: "should wait until search filter is applied",
+ predicate: ({ hint, threadViewer }) => {
+ return (
+ hint.type === 'messages-loaded' &&
+ threadViewer.thread.model === 'mail.channel' &&
+ threadViewer.thread.id === 20
+ );
+ },
+ });
+ assert.containsNone(
+ document.body,
+ '.o_Message',
+ "should have initially no message in channel 20 matching the search 'test'"
+ );
+
+ // simulate receiving a message
+ await this.afterEvent({
+ eventName: 'o-thread-view-hint-processed',
+ func: () => this.env.services.rpc({
+ route: '/mail/chat_post',
+ params: {
+ message_content: "test",
+ uuid: channel.uuid,
+ },
+ }),
+ message: "should wait until channel 20 refreshed its filtered message list",
+ predicate: data => {
+ return (
+ data.threadViewer.thread.model === 'mail.channel' &&
+ data.threadViewer.thread.id === 20 &&
+ data.hint.type === 'messages-loaded'
+ );
+ },
+ });
+ assert.containsOnce(
+ document.body,
+ '.o_Message',
+ "should only have the 1 message containing 'test' in channel 20 after just receiving it"
+ );
+});
+
+QUnit.test('discuss should refresh filtered thread on changing thread', async function (assert) {
+ assert.expect(4);
+
+ this.data['mail.channel'].records.push({ id: 20 }, { id: 21 });
+ await this.start();
+ const channel20 = this.env.models['mail.thread'].findFromIdentifyingData({
+ id: 20,
+ model: 'mail.channel',
+ });
+ const channel21 = this.env.models['mail.thread'].findFromIdentifyingData({
+ id: 21,
+ model: 'mail.channel',
+ });
+ await this.afterEvent({
+ eventName: 'o-thread-view-hint-processed',
+ func: () => {
+ document.querySelector(`
+ .o_DiscussSidebar_item[data-thread-local-id="${channel20.localId}"]
+ `).click();
+ },
+ message: "should wait until channel 20 is loaded after clicking on it",
+ predicate: ({ hint, threadViewer }) => {
+ return (
+ hint.type === 'messages-loaded' &&
+ threadViewer.thread.model === 'mail.channel' &&
+ threadViewer.thread.id === 20
+ );
+ },
+ });
+ await this.afterEvent({
+ eventName: 'o-thread-view-hint-processed',
+ func: () => {
+ // simulate control panel search
+ this.env.messaging.discuss.update({
+ stringifiedDomain: JSON.stringify([['body', 'ilike', 'test']]),
+ });
+ },
+ message: "should wait until search filter is applied",
+ predicate: ({ hint, threadViewer }) => {
+ return (
+ hint.type === 'messages-loaded' &&
+ threadViewer.thread.model === 'mail.channel' &&
+ threadViewer.thread.id === 20
+ );
+ },
+ });
+ assert.containsNone(
+ document.body,
+ '.o_Message',
+ "should have initially no message in channel 20 matching the search 'test'"
+ );
+
+ await this.afterEvent({
+ eventName: 'o-thread-view-hint-processed',
+ func: () => {
+ document.querySelector(`
+ .o_DiscussSidebar_item[data-thread-local-id="${channel21.localId}"]
+ `).click();
+ },
+ message: "should wait until channel 21 is loaded after clicking on it",
+ predicate: ({ hint, threadViewer }) => {
+ return (
+ hint.type === 'messages-loaded' &&
+ threadViewer.thread.model === 'mail.channel' &&
+ threadViewer.thread.id === 21
+ );
+ },
+ });
+ assert.containsNone(
+ document.body,
+ '.o_Message',
+ "should have no message in channel 21 matching the search 'test'"
+ );
+ // simulate receiving a message on channel 20 while channel 21 is displayed
+ await this.env.services.rpc({
+ route: '/mail/chat_post',
+ params: {
+ message_content: "test",
+ uuid: channel20.uuid,
+ },
+ });
+ assert.containsNone(
+ document.body,
+ '.o_Message',
+ "should still have no message in channel 21 matching the search 'test' after receiving a message on channel 20"
+ );
+
+ await this.afterEvent({
+ eventName: 'o-thread-view-hint-processed',
+ func: () => {
+ document.querySelector(`
+ .o_DiscussSidebar_item[data-thread-local-id="${channel20.localId}"]
+ `).click();
+ },
+ message: "should wait until channel 20 is loaded with the new message after clicking on it",
+ predicate: ({ hint, threadViewer }) => {
+ return (
+ hint.type === 'messages-loaded' &&
+ threadViewer.thread.model === 'mail.channel' &&
+ threadViewer.thread.id === 20 &&
+ threadViewer.threadCache.fetchedMessages.length === 1
+ );
+ },
+ });
+ assert.containsOnce(
+ document.body,
+ '.o_Message',
+ "should now have the 1 message containing 'test' in channel 20 when displaying it, after having received the message while the channel was not visible"
+ );
+});
+
+QUnit.test('select all and unselect all buttons should work on filtered thread', async function (assert) {
+ assert.expect(4);
+
+ this.data['mail.channel'].records.push({
+ id: 20,
+ is_moderator: true,
+ moderation: true,
+ name: "general",
+ });
+ this.data['mail.message'].records.push({
+ body: "<p>test</p>",
+ model: 'mail.channel',
+ moderation_status: 'pending_moderation',
+ res_id: 20,
+ });
+ await this.start();
+ await this.afterEvent({
+ eventName: 'o-thread-view-hint-processed',
+ func: () => {
+ document.querySelector(`
+ .o_DiscussSidebar_item[data-thread-local-id="${this.env.messaging.moderation.localId}"]
+ `).click();
+ },
+ message: "should wait until moderation box is loaded after clicking on it",
+ predicate: ({ hint, threadViewer }) => {
+ return (
+ hint.type === 'messages-loaded' &&
+ threadViewer.thread.model === 'mail.box' &&
+ threadViewer.thread.id === 'moderation'
+ );
+ },
+ });
+ await this.afterEvent({
+ eventName: 'o-thread-view-hint-processed',
+ func: () => {
+ // simulate control panel search
+ this.env.messaging.discuss.update({
+ stringifiedDomain: JSON.stringify([['body', 'ilike', 'test']]),
+ });
+ },
+ message: "should wait until search filter is applied",
+ predicate: ({ hint, threadViewer }) => {
+ return (
+ hint.type === 'messages-loaded' &&
+ threadViewer.thread.model === 'mail.box' &&
+ threadViewer.thread.id === 'moderation'
+ );
+ },
+ });
+ assert.containsOnce(
+ document.body,
+ '.o_Message',
+ "should only have the 1 message containing 'test' in moderation box"
+ );
+ assert.notOk(
+ document.querySelector('.o_Message_checkbox').checked,
+ "the moderation checkbox should not be checked initially"
+ );
+
+ await afterNextRender(() => document.querySelector('.o_widget_Discuss_controlPanelButtonSelectAll').click());
+ assert.ok(
+ document.querySelector('.o_Message_checkbox').checked,
+ "the moderation checkbox should be checked after clicking on 'select all'"
+ );
+
+ await afterNextRender(() => document.querySelector('.o_widget_Discuss_controlPanelButtonUnselectAll').click());
+ assert.notOk(
+ document.querySelector('.o_Message_checkbox').checked,
+ "the moderation checkbox should be unchecked after clicking on 'unselect all'"
+ );
+});
+
+});
+});
+});
+
+});
diff --git a/addons/mail/static/src/components/discuss/tests/discuss_inbox_tests.js b/addons/mail/static/src/components/discuss/tests/discuss_inbox_tests.js
new file mode 100644
index 00000000..63b524eb
--- /dev/null
+++ b/addons/mail/static/src/components/discuss/tests/discuss_inbox_tests.js
@@ -0,0 +1,725 @@
+odoo.define('mail/static/src/components/discuss/tests/discuss_inbox_tests.js', function (require) {
+'use strict';
+
+const {
+ afterEach,
+ afterNextRender,
+ beforeEach,
+ nextAnimationFrame,
+ start,
+} = require('mail/static/src/utils/test_utils.js');
+
+const Bus = require('web.Bus');
+
+QUnit.module('mail', {}, function () {
+QUnit.module('components', {}, function () {
+QUnit.module('discuss', {}, function () {
+QUnit.module('discuss_inbox_tests.js', {
+ beforeEach() {
+ beforeEach(this);
+
+ this.start = async params => {
+ const { env, widget } = await start(Object.assign({}, params, {
+ autoOpenDiscuss: true,
+ data: this.data,
+ hasDiscuss: true,
+ }));
+ this.env = env;
+ this.widget = widget;
+ };
+ },
+ afterEach() {
+ afterEach(this);
+ },
+});
+
+QUnit.test('reply: discard on pressing escape', async function (assert) {
+ assert.expect(9);
+
+ // partner expected to be found by mention
+ this.data['res.partner'].records.push({
+ email: "testpartnert@odoo.com",
+ id: 11,
+ name: "TestPartner",
+ });
+ // message expected to be found in inbox
+ this.data['mail.message'].records.push({
+ body: "not empty",
+ model: 'res.partner',
+ needaction: true,
+ needaction_partner_ids: [this.data.currentPartnerId],
+ res_id: 20,
+ });
+ await this.start();
+ assert.containsOnce(
+ document.body,
+ '.o_Message',
+ "should display a single message"
+ );
+
+ await afterNextRender(() =>
+ document.querySelector('.o_Message_commandReply').click()
+ );
+ assert.containsOnce(
+ document.body,
+ '.o_Composer',
+ "should have composer after clicking on reply to message"
+ );
+
+ await afterNextRender(() =>
+ document.querySelector(`.o_Composer_buttonEmojis`).click()
+ );
+ assert.containsOnce(
+ document.body,
+ '.o_EmojisPopover',
+ "emojis popover should be opened after click on emojis button"
+ );
+
+ await afterNextRender(() => {
+ const ev = new window.KeyboardEvent('keydown', { bubbles: true, key: "Escape" });
+ document.querySelector(`.o_Composer_buttonEmojis`).dispatchEvent(ev);
+ });
+ assert.containsNone(
+ document.body,
+ '.o_EmojisPopover',
+ "emojis popover should be closed after pressing escape on emojis button"
+ );
+ assert.containsOnce(
+ document.body,
+ '.o_Composer',
+ "reply composer should still be opened after pressing escape on emojis button"
+ );
+
+ 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'));
+ document.execCommand('insertText', false, "T");
+ document.querySelector(`.o_ComposerTextInput_textarea`)
+ .dispatchEvent(new window.KeyboardEvent('keydown'));
+ document.querySelector(`.o_ComposerTextInput_textarea`)
+ .dispatchEvent(new window.KeyboardEvent('keyup'));
+ document.execCommand('insertText', false, "e");
+ 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_ComposerSuggestion',
+ "mention suggestion should be opened after typing @"
+ );
+
+ await afterNextRender(() => {
+ const ev = new window.KeyboardEvent('keydown', { bubbles: true, key: "Escape" });
+ document.querySelector(`.o_ComposerTextInput_textarea`).dispatchEvent(ev);
+ });
+ assert.containsNone(
+ document.body,
+ '.o_ComposerSuggestion',
+ "mention suggestion should be closed after pressing escape on mention suggestion"
+ );
+ assert.containsOnce(
+ document.body,
+ '.o_Composer',
+ "reply composer should still be opened after pressing escape on mention suggestion"
+ );
+
+ await afterNextRender(() => {
+ const ev = new window.KeyboardEvent('keydown', { bubbles: true, key: "Escape" });
+ document.querySelector(`.o_ComposerTextInput_textarea`).dispatchEvent(ev);
+ });
+ assert.containsNone(
+ document.body,
+ '.o_Composer',
+ "reply composer should be closed after pressing escape if there was no other priority escape handler"
+ );
+});
+
+QUnit.test('reply: discard on discard button click', async function (assert) {
+ assert.expect(4);
+
+ this.data['mail.message'].records.push({
+ body: "not empty",
+ model: 'res.partner',
+ needaction: true,
+ needaction_partner_ids: [this.data.currentPartnerId],
+ res_id: 20,
+ });
+ await this.start();
+ assert.containsOnce(
+ document.body,
+ '.o_Message',
+ "should display a single message"
+ );
+
+ await afterNextRender(() =>
+ document.querySelector('.o_Message_commandReply').click()
+ );
+ assert.containsOnce(
+ document.body,
+ '.o_Composer',
+ "should have composer after clicking on reply to message"
+ );
+ assert.containsOnce(
+ document.body,
+ '.o_Composer_buttonDiscard',
+ "composer should have a discard button"
+ );
+
+ await afterNextRender(() =>
+ document.querySelector(`.o_Composer_buttonDiscard`).click()
+ );
+ assert.containsNone(
+ document.body,
+ '.o_Composer',
+ "reply composer should be closed after clicking on discard"
+ );
+});
+
+QUnit.test('reply: discard on reply button toggle', async function (assert) {
+ assert.expect(3);
+
+ this.data['mail.message'].records.push({
+ body: "not empty",
+ model: 'res.partner',
+ needaction: true,
+ needaction_partner_ids: [this.data.currentPartnerId],
+ res_id: 20,
+ });
+ await this.start();
+ assert.containsOnce(
+ document.body,
+ '.o_Message',
+ "should display a single message"
+ );
+
+ await afterNextRender(() =>
+ document.querySelector('.o_Message_commandReply').click()
+ );
+ assert.containsOnce(
+ document.body,
+ '.o_Composer',
+ "should have composer after clicking on reply to message"
+ );
+
+ await afterNextRender(() =>
+ document.querySelector(`.o_Message_commandReply`).click()
+ );
+ assert.containsNone(
+ document.body,
+ '.o_Composer',
+ "reply composer should be closed after clicking on reply button again"
+ );
+});
+
+QUnit.test('reply: discard on click away', async function (assert) {
+ assert.expect(7);
+
+ this.data['mail.message'].records.push({
+ body: "not empty",
+ model: 'res.partner',
+ needaction: true,
+ needaction_partner_ids: [this.data.currentPartnerId],
+ res_id: 20,
+ });
+ await this.start();
+ assert.containsOnce(
+ document.body,
+ '.o_Message',
+ "should display a single message"
+ );
+
+ await afterNextRender(() =>
+ document.querySelector('.o_Message_commandReply').click()
+ );
+ assert.containsOnce(
+ document.body,
+ '.o_Composer',
+ "should have composer after clicking on reply to message"
+ );
+
+ document.querySelector(`.o_ComposerTextInput_textarea`).click();
+ await nextAnimationFrame(); // wait just in case, but nothing is supposed to happen
+ assert.containsOnce(
+ document.body,
+ '.o_Composer',
+ "reply composer should still be there after clicking inside itself"
+ );
+
+ await afterNextRender(() =>
+ document.querySelector(`.o_Composer_buttonEmojis`).click()
+ );
+ assert.containsOnce(
+ document.body,
+ '.o_EmojisPopover',
+ "emojis popover should be opened after clicking on emojis button"
+ );
+
+ await afterNextRender(() => {
+ document.querySelector(`.o_EmojisPopover_emoji`).click();
+ });
+ assert.containsNone(
+ document.body,
+ '.o_EmojisPopover',
+ "emojis popover should be closed after selecting an emoji"
+ );
+ assert.containsOnce(
+ document.body,
+ '.o_Composer',
+ "reply composer should still be there after selecting an emoji (even though it is technically a click away, it should be considered inside)"
+ );
+
+ await afterNextRender(() =>
+ document.querySelector(`.o_Message`).click()
+ );
+ assert.containsNone(
+ document.body,
+ '.o_Composer',
+ "reply composer should be closed after clicking away"
+ );
+});
+
+QUnit.test('"reply to" composer should log note if message replied to is a note', async function (assert) {
+ assert.expect(6);
+
+ this.data['mail.message'].records.push({
+ body: "not empty",
+ is_discussion: false,
+ model: 'res.partner',
+ needaction: true,
+ needaction_partner_ids: [this.data.currentPartnerId],
+ res_id: 20,
+ });
+ await this.start({
+ async mockRPC(route, args) {
+ if (args.method === 'message_post') {
+ assert.step('message_post');
+ assert.strictEqual(
+ args.kwargs.message_type,
+ "comment",
+ "should set message type as 'comment'"
+ );
+ assert.strictEqual(
+ args.kwargs.subtype_xmlid,
+ "mail.mt_note",
+ "should set subtype_xmlid as 'note'"
+ );
+ }
+ return this._super(...arguments);
+ },
+ });
+ assert.containsOnce(
+ document.body,
+ '.o_Message',
+ "should display a single message"
+ );
+
+ await afterNextRender(() =>
+ document.querySelector('.o_Message_commandReply').click()
+ );
+ assert.strictEqual(
+ document.querySelector('.o_Composer_buttonSend').textContent.trim(),
+ "Log",
+ "Send button text should be 'Log'"
+ );
+
+ await afterNextRender(() =>
+ document.execCommand('insertText', false, "Test")
+ );
+ await afterNextRender(() =>
+ document.querySelector('.o_Composer_buttonSend').click()
+ );
+ assert.verifySteps(['message_post']);
+});
+
+QUnit.test('"reply to" composer should send message if message replied to is not a note', async function (assert) {
+ assert.expect(6);
+
+ this.data['mail.message'].records.push({
+ body: "not empty",
+ is_discussion: true,
+ model: 'res.partner',
+ needaction: true,
+ needaction_partner_ids: [this.data.currentPartnerId],
+ res_id: 20,
+ });
+ await this.start({
+ async mockRPC(route, args) {
+ if (args.method === 'message_post') {
+ assert.step('message_post');
+ 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'"
+ );
+ }
+ return this._super(...arguments);
+ },
+ });
+ assert.containsOnce(
+ document.body,
+ '.o_Message',
+ "should display a single message"
+ );
+
+ await afterNextRender(() =>
+ document.querySelector('.o_Message_commandReply').click()
+ );
+ assert.strictEqual(
+ document.querySelector('.o_Composer_buttonSend').textContent.trim(),
+ "Send",
+ "Send button text should be 'Send'"
+ );
+
+ await afterNextRender(() =>
+ document.execCommand('insertText', false, "Test")
+ );
+ await afterNextRender(() =>
+ document.querySelector('.o_Composer_buttonSend').click()
+ );
+ assert.verifySteps(['message_post']);
+});
+
+QUnit.test('error notifications should not be shown in Inbox', async function (assert) {
+ assert.expect(3);
+
+ this.data['mail.message'].records.push({
+ body: "not empty",
+ id: 100,
+ model: 'mail.channel',
+ needaction: true,
+ needaction_partner_ids: [this.data.currentPartnerId],
+ res_id: 20,
+ });
+ this.data['mail.notification'].records.push({
+ mail_message_id: 100, // id of related message
+ res_partner_id: this.data.currentPartnerId, // must be for current partner
+ notification_status: 'exception',
+ notification_type: 'email',
+ });
+ await this.start();
+ assert.containsOnce(
+ document.body,
+ '.o_Message',
+ "should display a single message"
+ );
+ assert.containsOnce(
+ document.body,
+ '.o_Message_originThreadLink',
+ "should display origin thread link"
+ );
+ assert.containsNone(
+ document.body,
+ '.o_Message_notificationIcon',
+ "should not display any notification icon in Inbox"
+ );
+});
+
+QUnit.test('show subject of message in Inbox', async function (assert) {
+ assert.expect(3);
+
+ this.data['mail.message'].records.push({
+ body: "not empty",
+ model: 'mail.channel', // random existing model
+ needaction: true, // message_fetch domain
+ needaction_partner_ids: [this.data.currentPartnerId], // not needed, for consistency
+ subject: "Salutations, voyageur", // will be asserted in the test
+ });
+ await this.start();
+ assert.containsOnce(
+ document.body,
+ '.o_Message',
+ "should display a single message"
+ );
+ assert.containsOnce(
+ document.body,
+ '.o_Message_subject',
+ "should display subject of the message"
+ );
+ assert.strictEqual(
+ document.querySelector('.o_Message_subject').textContent,
+ "Subject: Salutations, voyageur",
+ "Subject of the message should be 'Salutations, voyageur'"
+ );
+});
+
+QUnit.test('show subject of message in history', async function (assert) {
+ assert.expect(3);
+
+ this.data['mail.message'].records.push({
+ body: "not empty",
+ history_partner_ids: [3], // not needed, for consistency
+ model: 'mail.channel', // random existing model
+ subject: "Salutations, voyageur", // will be asserted in the test
+ });
+ await this.start({
+ discuss: {
+ params: {
+ default_active_id: 'mail.box_history',
+ },
+ },
+ });
+ assert.containsOnce(
+ document.body,
+ '.o_Message',
+ "should display a single message"
+ );
+ assert.containsOnce(
+ document.body,
+ '.o_Message_subject',
+ "should display subject of the message"
+ );
+ assert.strictEqual(
+ document.querySelector('.o_Message_subject').textContent,
+ "Subject: Salutations, voyageur",
+ "Subject of the message should be 'Salutations, voyageur'"
+ );
+});
+
+QUnit.test('click on (non-channel/non-partner) origin thread link should redirect to form view', async function (assert) {
+ assert.expect(9);
+
+ const bus = new Bus();
+ bus.on('do-action', null, payload => {
+ // Callback of doing an action (action manager).
+ // Expected to be called on click on origin thread link,
+ // which redirects to form view of record related to origin thread
+ assert.step('do-action');
+ assert.strictEqual(
+ payload.action.type,
+ 'ir.actions.act_window',
+ "action should open a view"
+ );
+ assert.deepEqual(
+ payload.action.views,
+ [[false, 'form']],
+ "action should open form view"
+ );
+ assert.strictEqual(
+ payload.action.res_model,
+ 'some.model',
+ "action should open view with model 'some.model' (model of message origin thread)"
+ );
+ assert.strictEqual(
+ payload.action.res_id,
+ 10,
+ "action should open view with id 10 (id of message origin thread)"
+ );
+ });
+ this.data['some.model'] = { fields: {}, records: [{ id: 10 }] };
+ this.data['mail.message'].records.push({
+ body: "not empty",
+ model: 'some.model',
+ needaction: true,
+ needaction_partner_ids: [this.data.currentPartnerId],
+ record_name: "Some record",
+ res_id: 10,
+ });
+ await this.start({
+ env: {
+ bus,
+ },
+ });
+ assert.containsOnce(
+ document.body,
+ '.o_Message',
+ "should display a single message"
+ );
+ assert.containsOnce(
+ document.body,
+ '.o_Message_originThreadLink',
+ "should display origin thread link"
+ );
+ assert.strictEqual(
+ document.querySelector('.o_Message_originThreadLink').textContent,
+ "Some record",
+ "origin thread link should display record name"
+ );
+
+ document.querySelector('.o_Message_originThreadLink').click();
+ assert.verifySteps(['do-action'], "should have made an action on click on origin thread (to open form view)");
+});
+
+QUnit.test('subject should not be shown when subject is the same as the thread name', async function (assert) {
+ assert.expect(1);
+
+ this.data['mail.message'].records.push({
+ body: "not empty",
+ channel_ids: [100],
+ model: 'mail.channel',
+ res_id: 100,
+ needaction: true,
+ subject: "Salutations, voyageur",
+ });
+ this.data['mail.channel'].records.push({
+ id: 100,
+ name: "Salutations, voyageur",
+ });
+ await this.start();
+
+ assert.containsNone(
+ document.body,
+ '.o_Message_subject',
+ "subject should not be shown when subject is the same as the thread name"
+ );
+});
+
+QUnit.test('subject should not be shown when subject is the same as the thread name and both have the same prefix', async function (assert) {
+ assert.expect(1);
+
+ this.data['mail.message'].records.push({
+ body: "not empty",
+ channel_ids: [100],
+ model: 'mail.channel',
+ res_id: 100,
+ needaction: true,
+ subject: "Re: Salutations, voyageur",
+ });
+ this.data['mail.channel'].records.push({
+ id: 100,
+ name: "Re: Salutations, voyageur",
+ });
+ await this.start();
+
+ assert.containsNone(
+ document.body,
+ '.o_Message_subject',
+ "subject should not be shown when subject is the same as the thread name and both have the same prefix"
+ );
+});
+
+QUnit.test('subject should not be shown when subject differs from thread name only by the "Re:" prefix', async function (assert) {
+ assert.expect(1);
+
+ this.data['mail.message'].records.push({
+ body: "not empty",
+ channel_ids: [100],
+ model: 'mail.channel',
+ res_id: 100,
+ needaction: true,
+ subject: "Re: Salutations, voyageur",
+ });
+ this.data['mail.channel'].records.push({
+ id: 100,
+ name: "Salutations, voyageur",
+ });
+ await this.start();
+
+ assert.containsNone(
+ document.body,
+ '.o_Message_subject',
+ "should not display subject when subject differs from thread name only by the 'Re:' prefix"
+ );
+});
+
+QUnit.test('subject should not be shown when subject differs from thread name only by the "Fw:" and "Re:" prefix', async function (assert) {
+ assert.expect(1);
+
+ this.data['mail.message'].records.push({
+ body: "not empty",
+ channel_ids: [100],
+ model: 'mail.channel',
+ res_id: 100,
+ needaction: true,
+ subject: "Fw: Re: Salutations, voyageur",
+ });
+ this.data['mail.channel'].records.push({
+ id: 100,
+ name: "Salutations, voyageur",
+ });
+ await this.start();
+
+ assert.containsNone(
+ document.body,
+ '.o_Message_subject',
+ "should not display subject when subject differs from thread name only by the 'Fw:' and Re:' prefix"
+ );
+});
+
+QUnit.test('subject should be shown when the thread name has an extra prefix compared to subject', async function (assert) {
+ assert.expect(1);
+
+ this.data['mail.message'].records.push({
+ body: "not empty",
+ channel_ids: [100],
+ model: 'mail.channel',
+ res_id: 100,
+ needaction: true,
+ subject: "Salutations, voyageur",
+ });
+ this.data['mail.channel'].records.push({
+ id: 100,
+ name: "Re: Salutations, voyageur",
+ });
+ await this.start();
+
+ assert.containsOnce(
+ document.body,
+ '.o_Message_subject',
+ "subject should be shown when the thread name has an extra prefix compared to subject"
+ );
+});
+
+QUnit.test('subject should not be shown when subject differs from thread name only by the "fw:" prefix and both contain another common prefix', async function (assert) {
+ assert.expect(1);
+
+ this.data['mail.message'].records.push({
+ body: "not empty",
+ channel_ids: [100],
+ model: 'mail.channel',
+ res_id: 100,
+ needaction: true,
+ subject: "fw: re: Salutations, voyageur",
+ });
+ this.data['mail.channel'].records.push({
+ id: 100,
+ name: "Re: Salutations, voyageur",
+ });
+ await this.start();
+
+ assert.containsNone(
+ document.body,
+ '.o_Message_subject',
+ "subject should not be shown when subject differs from thread name only by the 'fw:' prefix and both contain another common prefix"
+ );
+});
+
+QUnit.test('subject should not be shown when subject differs from thread name only by the "Re: Re:" prefix', async function (assert) {
+ assert.expect(1);
+
+ this.data['mail.message'].records.push({
+ body: "not empty",
+ channel_ids: [100],
+ model: 'mail.channel',
+ res_id: 100,
+ needaction: true,
+ subject: "Re: Re: Salutations, voyageur",
+ });
+ this.data['mail.channel'].records.push({
+ id: 100,
+ name: "Salutations, voyageur",
+ });
+ await this.start();
+
+ assert.containsNone(
+ document.body,
+ '.o_Message_subject',
+ "should not display subject when subject differs from thread name only by the 'Re: Re:'' prefix"
+ );
+});
+
+});
+});
+});
+
+});
diff --git a/addons/mail/static/src/components/discuss/tests/discuss_moderation_tests.js b/addons/mail/static/src/components/discuss/tests/discuss_moderation_tests.js
new file mode 100644
index 00000000..c4c53cb5
--- /dev/null
+++ b/addons/mail/static/src/components/discuss/tests/discuss_moderation_tests.js
@@ -0,0 +1,1180 @@
+odoo.define('mail/static/src/components/discuss/tests/discuss_moderation_tests.js', function (require) {
+'use strict';
+
+const {
+ afterEach,
+ afterNextRender,
+ beforeEach,
+ start,
+} = require('mail/static/src/utils/test_utils.js');
+
+QUnit.module('mail', {}, function () {
+QUnit.module('components', {}, function () {
+QUnit.module('discuss', {}, function () {
+QUnit.module('discuss_moderation_tests.js', {
+ beforeEach() {
+ beforeEach(this);
+
+ this.start = async params => {
+ const { env, widget } = await start(Object.assign({}, params, {
+ autoOpenDiscuss: true,
+ data: this.data,
+ hasDiscuss: true,
+ }));
+ this.env = env;
+ this.widget = widget;
+ };
+ },
+ afterEach() {
+ afterEach(this);
+ },
+});
+
+QUnit.test('as moderator, moderated channel with pending moderation message', async function (assert) {
+ assert.expect(37);
+
+ this.data['mail.channel'].records.push({
+ id: 20, // random unique id, will be used to link message and will be referenced in the test
+ is_moderator: true, // current user is expected to be moderator of channel
+ moderation: true, // for consistency, but not used in the scope of this test
+ name: "general", // random name, will be asserted in the test
+ });
+ this.data['mail.message'].records.push({
+ body: "<p>test</p>", // random body, will be asserted in the test
+ model: 'mail.channel', // expected value to link message to channel
+ moderation_status: 'pending_moderation', // message is expected to be pending moderation
+ res_id: 20, // id of the channel
+ });
+ await this.start();
+
+ assert.ok(
+ document.querySelector(`
+ .o_DiscussSidebar_item[data-thread-local-id="${
+ this.env.messaging.moderation.localId
+ }"]
+ `),
+ "should display the moderation box in the sidebar"
+ );
+ 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'].findFromIdentifyingData({
+ id: 20,
+ 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('as moderator, accept pending moderation message', async function (assert) {
+ assert.expect(12);
+
+ this.data['mail.channel'].records.push({
+ id: 20, // random unique id, will be used to link message and will be referenced in the test
+ is_moderator: true, // current user is expected to be moderator of channel
+ moderation: true, // for consistency, but not used in the scope of this test
+ name: "general", // random name, will be asserted in the test
+ });
+ this.data['mail.message'].records.push({
+ body: "<p>test</p>", // random body, will be asserted in the test
+ id: 100, // random unique id, will be asserted during the test
+ model: 'mail.channel', // expected value to link message to channel
+ moderation_status: 'pending_moderation', // message is expected to be pending moderation
+ res_id: 20, // id of the channel
+ });
+ 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'].findFromIdentifyingData({ id: 100 }).localId
+ }"]
+ `),
+ "should display the message to moderate"
+ );
+ const acceptButton = document.querySelector(`
+ .o_Message[data-message-local-id="${
+ this.env.models['mail.message'].findFromIdentifyingData({ 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'].findFromIdentifyingData({
+ id: 20,
+ 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'].findFromIdentifyingData({ 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('as moderator, reject pending moderation message (reject with explanation)', async function (assert) {
+ assert.expect(23);
+
+ this.data['mail.channel'].records.push({
+ id: 20, // random unique id, will be used to link message and will be referenced in the test
+ is_moderator: true, // current user is expected to be moderator of channel
+ moderation: true, // for consistency, but not used in the scope of this test
+ name: "general", // random name, will be asserted in the test
+ });
+ this.data['mail.message'].records.push({
+ body: "<p>test</p>", // random body, will be asserted in the test
+ id: 100, // random unique id, will be asserted during the test
+ model: 'mail.channel', // expected value to link message to channel
+ moderation_status: 'pending_moderation', // message is expected to be pending moderation
+ res_id: 20, // id of the channel
+ });
+ 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'].findFromIdentifyingData({ 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,
+ "Send explanation to author",
+ "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'].findFromIdentifyingData({
+ id: 20,
+ 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('as moderator, discard pending moderation message (reject without explanation)', async function (assert) {
+ assert.expect(16);
+
+ this.data['mail.channel'].records.push({
+ id: 20, // random unique id, will be used to link message and will be referenced in the test
+ is_moderator: true, // current user is expected to be moderator of channel
+ moderation: true, // for consistency, but not used in the scope of this test
+ name: "general", // random name, will be asserted in the test
+ });
+ this.data['mail.message'].records.push({
+ body: "<p>test</p>", // random body, will be asserted in the test
+ id: 100, // random unique id, will be asserted during the test
+ model: 'mail.channel', // expected value to link message to channel
+ moderation_status: 'pending_moderation', // message is expected to be pending moderation
+ res_id: 20, // id of the channel
+ });
+ 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'].findFromIdentifyingData({ 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,
+ "Confirmation",
+ "dialog should have correct title"
+ );
+ assert.strictEqual(
+ dialog.textContent,
+ "Confirmation×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'].findFromIdentifyingData({
+ id: 20,
+ 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('as author, send message in moderated channel', async function (assert) {
+ assert.expect(4);
+
+ this.data['mail.channel'].records.push({
+ id: 20, // random unique id, will be used to link message and will be referenced in the test
+ moderation: true, // channel must be moderated to test the feature
+ name: "general", // random name, will be asserted in the test
+ });
+ await this.start();
+ const channel = document.querySelector(`
+ .o_DiscussSidebar_item[data-thread-local-id="${
+ this.env.models['mail.thread'].findFromIdentifyingData({
+ id: 20,
+ model: 'mail.channel',
+ }).localId
+ }"]
+ `);
+ assert.ok(
+ channel,
+ "should display the general channel"
+ );
+
+ // go to channel 'general'
+ 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_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('as author, sent message accepted in moderated channel', async function (assert) {
+ assert.expect(5);
+
+ this.data['mail.channel'].records.push({
+ id: 20, // random unique id, will be used to link message and will be referenced in the test
+ moderation: true, // for consistency, but not used in the scope of this test
+ name: "general", // random name, will be asserted in the test
+ });
+ this.data['mail.message'].records.push({
+ body: "not empty",
+ id: 100, // random unique id, will be referenced in the test
+ model: 'mail.channel', // expected value to link message to channel
+ moderation_status: 'pending_moderation', // message is expected to be pending
+ res_id: 20, // id of the channel
+ });
+ await this.start();
+
+ const channel = document.querySelector(`
+ .o_DiscussSidebar_item[data-thread-local-id="${
+ this.env.models['mail.thread'].findFromIdentifyingData({
+ id: 20,
+ 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'].findFromIdentifyingData({ 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 = {
+ id: 100,
+ moderation_status: 'accepted',
+ };
+ 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'].findFromIdentifyingData({ 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('as author, sent message rejected in moderated channel', async function (assert) {
+ assert.expect(4);
+
+ this.data['mail.channel'].records.push({
+ id: 20, // random unique id, will be used to link message and will be referenced in the test
+ moderation: true, // for consistency, but not used in the scope of this test
+ name: "general", // random name, will be asserted in the test
+ });
+ this.data['mail.message'].records.push({
+ body: "not empty",
+ id: 100, // random unique id, will be referenced in the test
+ model: 'mail.channel', // expected value to link message to channel
+ moderation_status: 'pending_moderation', // message is expected to be pending
+ res_id: 20, // id of the channel
+ });
+ await this.start();
+
+ const channel = document.querySelector(`
+ .o_DiscussSidebar_item[data-thread-local-id="${
+ this.env.models['mail.thread'].findFromIdentifyingData({
+ id: 20,
+ 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'].findFromIdentifyingData({ 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', this.env.messaging.currentPartner.id], 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"
+ );
+});
+
+QUnit.test('as moderator, pending moderation message accessibility', async function (assert) {
+ // pending moderation message should appear in moderation box and in origin thread
+ assert.expect(3);
+
+ this.data['mail.channel'].records.push({
+ id: 20, // random unique id, will be used to link message and will be referenced in the test
+ is_moderator: true, // current user is expected to be moderator of channel
+ moderation: true, // channel must be moderated to test the feature
+ });
+ this.data['mail.message'].records.push({
+ body: "not empty",
+ id: 100, // random unique id, will be referenced in the test
+ model: 'mail.channel', // expected value to link message to channel
+ moderation_status: 'pending_moderation', // message is expected to be pending
+ res_id: 20, // id of the channel
+ });
+ await this.start();
+
+ const thread = this.env.models['mail.thread'].findFromIdentifyingData({ id: 20, model: 'mail.channel' });
+ assert.ok(
+ document.querySelector(`
+ .o_DiscussSidebar_item[data-thread-local-id="${
+ this.env.messaging.moderation.localId
+ }"]
+ `),
+ "should display the moderation box in the sidebar"
+ );
+
+ await afterNextRender(() =>
+ document.querySelector(`
+ .o_DiscussSidebar_item[data-thread-local-id="${thread.localId}"]
+ `).click()
+ );
+ const message = this.env.models['mail.message'].findFromIdentifyingData({ id: 100 });
+ assert.containsOnce(
+ document.body,
+ `.o_Message[data-message-local-id="${message.localId}"]`,
+ "the pending moderation message should be in the channel"
+ );
+
+ await afterNextRender(() =>
+ document.querySelector(`
+ .o_DiscussSidebar_item[data-thread-local-id="${
+ this.env.messaging.moderation.localId
+ }"]
+ `).click()
+ );
+ assert.containsOnce(
+ document.body,
+ `.o_Message[data-message-local-id="${message.localId}"]`,
+ "the pending moderation message should be in moderation box"
+ );
+});
+
+QUnit.test('as author, pending moderation message should appear in origin thread', async function (assert) {
+ assert.expect(1);
+
+ this.data['mail.channel'].records.push({
+ id: 20, // random unique id, will be used to link message and will be referenced in the test
+ moderation: true, // channel must be moderated to test the feature
+ });
+ this.data['mail.message'].records.push({
+ author_id: this.data.currentPartnerId, // test as author of message
+ body: "not empty",
+ id: 100, // random unique id, will be referenced in the test
+ model: 'mail.channel', // expected value to link message to channel
+ moderation_status: 'pending_moderation', // message is expected to be pending
+ res_id: 20, // id of the channel
+ });
+ await this.start();
+ const thread = this.env.models['mail.thread'].findFromIdentifyingData({ id: 20, model: 'mail.channel' });
+
+ await afterNextRender(() =>
+ document.querySelector(`
+ .o_DiscussSidebar_item[data-thread-local-id="${thread.localId}"]
+ `).click()
+ );
+ const message = this.env.models['mail.message'].findFromIdentifyingData({ id: 100 });
+ assert.containsOnce(
+ document.body,
+ `.o_Message[data-message-local-id="${message.localId}"]`,
+ "the pending moderation message should be in the channel"
+ );
+});
+
+QUnit.test('as moderator, new pending moderation message posted by someone else', async function (assert) {
+ // the message should appear in origin thread and moderation box if I moderate it
+ assert.expect(3);
+
+ this.data['mail.channel'].records.push({
+ id: 20, // random unique id, will be used to link message and will be referenced in the test
+ is_moderator: true, // current user is expected to be moderator of channel
+ moderation: true, // channel must be moderated to test the feature
+ });
+ await this.start();
+ const thread = this.env.models['mail.thread'].findFromIdentifyingData({ id: 20, model: 'mail.channel' });
+
+ await afterNextRender(() =>
+ document.querySelector(`
+ .o_DiscussSidebar_item[data-thread-local-id="${thread.localId}"]
+ `).click()
+ );
+ assert.containsNone(
+ document.body,
+ `.o_Message`,
+ "should have no message in the channel initially"
+ );
+
+ // simulate receiving the message
+ const messageData = {
+ author_id: [10, 'john doe'], // random id, different than current partner
+ body: "not empty",
+ channel_ids: [], // server do NOT return channel_id of the message if pending moderation
+ id: 1, // random unique id
+ model: 'mail.channel', // expected value to link message to channel
+ moderation_status: 'pending_moderation', // message is expected to be pending
+ res_id: 20, // id of the channel
+ };
+ await afterNextRender(() => {
+ const notifications = [[
+ ['my-db', 'res.partner', this.env.messaging.currentPartner.id],
+ { type: 'moderator', message: messageData },
+ ]];
+ this.widget.call('bus_service', 'trigger', 'notification', notifications);
+ });
+ const message = this.env.models['mail.message'].findFromIdentifyingData({ id: 1 });
+ assert.containsOnce(
+ document.body,
+ `.o_Message[data-message-local-id="${message.localId}"]`,
+ "the pending moderation message should be in the channel"
+ );
+
+ await afterNextRender(() =>
+ document.querySelector(`
+ .o_DiscussSidebar_item[data-thread-local-id="${
+ this.env.messaging.moderation.localId
+ }"]
+ `).click()
+ );
+ assert.containsOnce(
+ document.body,
+ `.o_Message[data-message-local-id="${message.localId}"]`,
+ "the pending moderation message should be in moderation box"
+ );
+});
+
+QUnit.test('accept multiple moderation messages', async function (assert) {
+ assert.expect(5);
+
+ this.data['mail.channel'].records.push({
+ id: 20, // random unique id, will be used to link message and will be referenced in the test
+ is_moderator: true, // current user is expected to be moderator of channel
+ moderation: true, // channel must be moderated to test the feature
+ });
+ this.data['mail.message'].records.push(
+ {
+ body: "not empty",
+ model: 'mail.channel',
+ moderation_status: 'pending_moderation',
+ res_id: 20,
+ },
+ {
+ body: "not empty",
+ model: 'mail.channel',
+ moderation_status: 'pending_moderation',
+ res_id: 20,
+ },
+ {
+ body: "not empty",
+ model: 'mail.channel',
+ moderation_status: 'pending_moderation',
+ res_id: 20,
+ }
+ );
+
+ await this.start({
+ discuss: {
+ params: {
+ default_active_id: 'mail.box_moderation',
+ },
+ },
+ });
+
+ assert.containsN(
+ document.body,
+ '.o_Message',
+ 3,
+ "should initially display 3 messages"
+ );
+
+ await afterNextRender(() => {
+ document.querySelectorAll('.o_Message_checkbox')[0].click();
+ document.querySelectorAll('.o_Message_checkbox')[1].click();
+ });
+ assert.containsN(
+ document.body,
+ '.o_Message_checkbox:checked',
+ 2,
+ "2 messages should have been checked after clicking on their respective checkbox"
+ );
+ assert.doesNotHaveClass(
+ document.querySelector('.o_widget_Discuss_controlPanelButtonModeration.o-accept'),
+ 'o_hidden',
+ "global accept button should be displayed as two messages are selected"
+ );
+
+ await afterNextRender(() =>
+ document.querySelector('.o_widget_Discuss_controlPanelButtonModeration.o-accept').click()
+ );
+ assert.containsN(
+ document.body,
+ '.o_Message',
+ 1,
+ "should display 1 message as the 2 others have been accepted"
+ );
+ assert.hasClass(
+ document.querySelector('.o_widget_Discuss_controlPanelButtonModeration.o-accept'),
+ 'o_hidden',
+ "global accept button should no longer be displayed as messages have been unselected"
+ );
+});
+
+QUnit.test('accept multiple moderation messages after having accepted other messages', async function (assert) {
+ assert.expect(5);
+
+ this.data['mail.channel'].records.push({
+ id: 20, // random unique id, will be used to link message and will be referenced in the test
+ is_moderator: true, // current user is expected to be moderator of channel
+ moderation: true, // channel must be moderated to test the feature
+ });
+ this.data['mail.message'].records.push(
+ {
+ body: "not empty",
+ model: 'mail.channel',
+ moderation_status: 'pending_moderation',
+ res_id: 20,
+ },
+ {
+ body: "not empty",
+ model: 'mail.channel',
+ moderation_status: 'pending_moderation',
+ res_id: 20,
+ },
+ {
+ body: "not empty",
+ model: 'mail.channel',
+ moderation_status: 'pending_moderation',
+ res_id: 20,
+ }
+ );
+ await this.start({
+ discuss: {
+ params: {
+ default_active_id: 'mail.box_moderation',
+ },
+ },
+ });
+ assert.containsN(
+ document.body,
+ '.o_Message',
+ 3,
+ "should initially display 3 messages"
+ );
+
+ await afterNextRender(() => {
+ document.querySelectorAll('.o_Message_checkbox')[0].click();
+ });
+ await afterNextRender(() =>
+ document.querySelector('.o_widget_Discuss_controlPanelButtonModeration.o-accept').click()
+ );
+ await afterNextRender(() => document.querySelectorAll('.o_Message_checkbox')[0].click());
+ assert.containsOnce(
+ document.body,
+ '.o_Message_checkbox:checked',
+ "a message should have been checked after clicking on its checkbox"
+ );
+ assert.doesNotHaveClass(
+ document.querySelector('.o_widget_Discuss_controlPanelButtonModeration.o-accept'),
+ 'o_hidden',
+ "global accept button should be displayed as a message is selected"
+ );
+
+ await afterNextRender(() =>
+ document.querySelector('.o_widget_Discuss_controlPanelButtonModeration.o-accept').click()
+ );
+ assert.containsOnce(
+ document.body,
+ '.o_Message',
+ "should display only one message left after the two others has been accepted"
+ );
+ assert.hasClass(
+ document.querySelector('.o_widget_Discuss_controlPanelButtonModeration.o-accept'),
+ 'o_hidden',
+ "global accept button should no longer be displayed as message has been unselected"
+ );
+});
+
+});
+});
+});
+
+});
diff --git a/addons/mail/static/src/components/discuss/tests/discuss_pinned_tests.js b/addons/mail/static/src/components/discuss/tests/discuss_pinned_tests.js
new file mode 100644
index 00000000..a24ce411
--- /dev/null
+++ b/addons/mail/static/src/components/discuss/tests/discuss_pinned_tests.js
@@ -0,0 +1,238 @@
+odoo.define('mail/static/src/components/discuss/tests/discuss_pinned_tests.js', function (require) {
+'use strict';
+
+const {
+ afterEach,
+ afterNextRender,
+ beforeEach,
+ start,
+} = require('mail/static/src/utils/test_utils.js');
+
+QUnit.module('mail', {}, function () {
+QUnit.module('components', {}, function () {
+QUnit.module('discuss', {}, function () {
+QUnit.module('discuss_pinned_tests.js', {
+ beforeEach() {
+ beforeEach(this);
+
+ this.start = async params => {
+ const { env, widget } = await start(Object.assign({}, params, {
+ autoOpenDiscuss: true,
+ data: this.data,
+ hasDiscuss: true,
+ }));
+ this.env = env;
+ this.widget = widget;
+ };
+ },
+ afterEach() {
+ afterEach(this);
+ },
+});
+
+QUnit.test('sidebar: pinned channel 1: init with one pinned channel', async function (assert) {
+ assert.expect(2);
+
+ // channel that is expected to be found in the sidebar
+ // with a random unique id that will be referenced in the test
+ this.data['mail.channel'].records.push({ id: 20 });
+ await this.start();
+ assert.containsOnce(
+ document.body,
+ `.o_Discuss_thread[data-thread-local-id="${this.env.messaging.inbox.localId}"]`,
+ "The Inbox is opened in discuss"
+ );
+ assert.containsOnce(
+ document.body,
+ `.o_DiscussSidebarItem[data-thread-local-id="${
+ this.env.models['mail.thread'].findFromIdentifyingData({
+ id: 20,
+ model: 'mail.channel',
+ }).localId
+ }"]`,
+ "should have the only channel of which user is member in discuss sidebar"
+ );
+});
+
+QUnit.test('sidebar: pinned channel 2: open pinned channel', async function (assert) {
+ assert.expect(1);
+
+ // channel that is expected to be found in the sidebar
+ // with a random unique id that will be referenced in the test
+ this.data['mail.channel'].records.push({ id: 20 });
+ await this.start();
+
+ const threadGeneral = this.env.models['mail.thread'].findFromIdentifyingData({
+ id: 20,
+ model: 'mail.channel',
+ });
+ await afterNextRender(() =>
+ document.querySelector(`.o_DiscussSidebarItem[data-thread-local-id="${
+ threadGeneral.localId
+ }"]`).click()
+ );
+ assert.containsOnce(
+ document.body,
+ `.o_Discuss_thread[data-thread-local-id="${threadGeneral.localId}"]`,
+ "The channel #General is displayed in discuss"
+ );
+});
+
+QUnit.test('sidebar: pinned channel 3: open pinned channel and unpin it', async function (assert) {
+ assert.expect(8);
+
+ // channel that is expected to be found in the sidebar
+ // with a random unique id that will be referenced in the test
+ this.data['mail.channel'].records.push({
+ id: 20,
+ is_minimized: true,
+ state: 'open',
+ });
+ await this.start({
+ async mockRPC(route, args) {
+ if (args.method === 'execute_command') {
+ assert.step('execute_command');
+ assert.deepEqual(args.args[0], [20],
+ "The right id is sent to the server to remove"
+ );
+ assert.strictEqual(args.kwargs.command, 'leave',
+ "The right command is sent to the server"
+ );
+ }
+ if (args.method === 'channel_fold') {
+ assert.step('channel_fold');
+ }
+ return this._super(...arguments);
+ },
+ });
+
+ const threadGeneral = this.env.models['mail.thread'].findFromIdentifyingData({
+ id: 20,
+ model: 'mail.channel',
+ });
+ await afterNextRender(() =>
+ document.querySelector(`.o_DiscussSidebarItem[data-thread-local-id="${
+ threadGeneral.localId
+ }"]`).click()
+ );
+ assert.verifySteps([], "neither channel_fold nor execute_command are called yet");
+ await afterNextRender(() =>
+ document.querySelector('.o_DiscussSidebarItem_commandLeave').click()
+ );
+ assert.verifySteps(
+ [
+ 'channel_fold',
+ 'execute_command'
+ ],
+ "both channel_fold and execute_command have been called when unpinning a channel"
+ );
+ assert.containsNone(
+ document.body,
+ `.o_DiscussSidebarItem[data-thread-local-id="${threadGeneral.localId}"]`,
+ "The channel must have been removed from discuss sidebar"
+ );
+ assert.containsOnce(
+ document.body,
+ '.o_Discuss_noThread',
+ "should have no thread opened in discuss"
+ );
+});
+
+QUnit.test('sidebar: unpin channel from bus', async function (assert) {
+ assert.expect(5);
+
+ // channel that is expected to be found in the sidebar
+ // with a random unique id that will be referenced in the test
+ this.data['mail.channel'].records.push({ id: 20 });
+ await this.start();
+ const threadGeneral = this.env.models['mail.thread'].findFromIdentifyingData({
+ id: 20,
+ model: 'mail.channel',
+ });
+
+ assert.containsOnce(
+ document.body,
+ `.o_Discuss_thread[data-thread-local-id="${this.env.messaging.inbox.localId}"]`,
+ "The Inbox is opened in discuss"
+ );
+ assert.containsOnce(
+ document.body,
+ `.o_DiscussSidebarItem[data-thread-local-id="${threadGeneral.localId}"]`,
+ "1 channel is present in discuss sidebar and it is 'general'"
+ );
+
+ await afterNextRender(() =>
+ document.querySelector(`.o_DiscussSidebarItem[data-thread-local-id="${
+ threadGeneral.localId
+ }"]`).click()
+ );
+ assert.containsOnce(
+ document.body,
+ `.o_Discuss_thread[data-thread-local-id="${threadGeneral.localId}"]`,
+ "The channel #General is opened in discuss"
+ );
+
+ // Simulate receiving a leave channel notification
+ // (e.g. from user interaction from another device or browser tab)
+ await afterNextRender(() => {
+ const notif = [
+ ["dbName", 'res.partner', this.env.messaging.currentPartner.id],
+ {
+ channel_type: 'channel',
+ id: 20,
+ info: 'unsubscribe',
+ name: "General",
+ public: 'public',
+ state: 'open',
+ }
+ ];
+ this.env.services.bus_service.trigger('notification', [notif]);
+ });
+ assert.containsOnce(
+ document.body,
+ '.o_Discuss_noThread',
+ "should have no thread opened in discuss"
+ );
+ assert.containsNone(
+ document.body,
+ `.o_DiscussSidebarItem[data-thread-local-id="${threadGeneral.localId}"]`,
+ "The channel must have been removed from discuss sidebar"
+ );
+});
+
+QUnit.test('[technical] sidebar: channel group_based_subscription: mandatorily pinned', async function (assert) {
+ assert.expect(2);
+
+ // FIXME: The following is admittedly odd.
+ // Fixing it should entail a deeper reflexion on the group_based_subscription
+ // and is_pinned functionalities, especially in python.
+ // task-2284357
+
+ // channel that is expected to be found in the sidebar
+ this.data['mail.channel'].records.push({
+ group_based_subscription: true, // expected value for this test
+ id: 20, // random unique id, will be referenced in the test
+ is_pinned: false, // expected value for this test
+ });
+ await this.start();
+ const threadGeneral = this.env.models['mail.thread'].findFromIdentifyingData({
+ id: 20,
+ model: 'mail.channel',
+ });
+ assert.containsOnce(
+ document.body,
+ `.o_DiscussSidebarItem[data-thread-local-id="${threadGeneral.localId}"]`,
+ "The channel #General is in discuss sidebar"
+ );
+ assert.containsNone(
+ document.body,
+ 'o_DiscussSidebarItem_commandLeave',
+ "The group_based_subscription channel is not unpinnable"
+ );
+});
+
+});
+});
+});
+
+});
diff --git a/addons/mail/static/src/components/discuss/tests/discuss_sidebar_tests.js b/addons/mail/static/src/components/discuss/tests/discuss_sidebar_tests.js
new file mode 100644
index 00000000..34c884eb
--- /dev/null
+++ b/addons/mail/static/src/components/discuss/tests/discuss_sidebar_tests.js
@@ -0,0 +1,163 @@
+odoo.define('mail/static/src/components/discuss/tests/discuss_sidebar_tests.js', function (require) {
+'use strict';
+
+const { makeDeferred } = require('mail/static/src/utils/deferred/deferred.js');
+const {
+ afterEach,
+ afterNextRender,
+ beforeEach,
+ nextAnimationFrame,
+ start,
+} = require('mail/static/src/utils/test_utils.js');
+
+QUnit.module('mail', {}, function () {
+QUnit.module('components', {}, function () {
+QUnit.module('discuss', {}, function () {
+QUnit.module('discuss_sidebar_tests.js', {
+ beforeEach() {
+ beforeEach(this);
+
+ this.start = async params => {
+ const { env, widget } = await start(Object.assign({}, params, {
+ autoOpenDiscuss: true,
+ data: this.data,
+ hasDiscuss: true,
+ }));
+ this.env = env;
+ this.widget = widget;
+ };
+ },
+ afterEach() {
+ afterEach(this);
+ },
+});
+
+QUnit.test('sidebar find shows channels matching search term', async function (assert) {
+ assert.expect(3);
+
+ this.data['mail.channel'].records.push({
+ channel_partner_ids: [],
+ channel_type: 'channel',
+ id: 20,
+ members: [],
+ name: 'test',
+ public: 'public',
+ });
+ const searchReadDef = makeDeferred();
+ await this.start({
+ async mockRPC(route, args) {
+ const res = await this._super(...arguments);
+ if (args.method === 'search_read') {
+ searchReadDef.resolve();
+ }
+ return res;
+ },
+ });
+ await afterNextRender(() =>
+ document.querySelector(`.o_DiscussSidebar_groupHeaderItemAdd`).click()
+ );
+ document.querySelector(`.o_DiscussSidebar_itemNew`).focus();
+ document.execCommand('insertText', false, "test");
+ document.querySelector(`.o_DiscussSidebar_itemNew`)
+ .dispatchEvent(new window.KeyboardEvent('keydown'));
+ document.querySelector(`.o_DiscussSidebar_itemNew`)
+ .dispatchEvent(new window.KeyboardEvent('keyup'));
+
+ await searchReadDef;
+ await nextAnimationFrame(); // ensures search_read rpc is rendered.
+ const results = document.querySelectorAll('.ui-autocomplete .ui-menu-item a');
+ assert.ok(
+ results,
+ "should have autocomplete suggestion after typing on 'find or create channel' input"
+ );
+ assert.strictEqual(
+ results.length,
+ // When searching for a single existing channel, the results list will have at least 3 lines:
+ // One for the existing channel itself
+ // One for creating a public channel with the search term
+ // One for creating a private channel with the search term
+ 3
+ );
+ assert.strictEqual(
+ results[0].textContent,
+ "test",
+ "autocomplete suggestion should target the channel matching search term"
+ );
+});
+
+QUnit.test('sidebar find shows channels matching search term even when user is member', async function (assert) {
+ assert.expect(3);
+
+ this.data['mail.channel'].records.push({
+ channel_partner_ids: [this.data.currentPartnerId],
+ channel_type: 'channel',
+ id: 20,
+ members: [this.data.currentPartnerId],
+ name: 'test',
+ public: 'public',
+ });
+ const searchReadDef = makeDeferred();
+ await this.start({
+ async mockRPC(route, args) {
+ const res = await this._super(...arguments);
+ if (args.method === 'search_read') {
+ searchReadDef.resolve();
+ }
+ return res;
+ },
+ });
+ await afterNextRender(() =>
+ document.querySelector(`.o_DiscussSidebar_groupHeaderItemAdd`).click()
+ );
+ document.querySelector(`.o_DiscussSidebar_itemNew`).focus();
+ document.execCommand('insertText', false, "test");
+ document.querySelector(`.o_DiscussSidebar_itemNew`)
+ .dispatchEvent(new window.KeyboardEvent('keydown'));
+ document.querySelector(`.o_DiscussSidebar_itemNew`)
+ .dispatchEvent(new window.KeyboardEvent('keyup'));
+
+ await searchReadDef;
+ await nextAnimationFrame();
+ const results = document.querySelectorAll('.ui-autocomplete .ui-menu-item a');
+ assert.ok(
+ results,
+ "should have autocomplete suggestion after typing on 'find or create channel' input"
+ );
+ assert.strictEqual(
+ results.length,
+ // When searching for a single existing channel, the results list will have at least 3 lines:
+ // One for the existing channel itself
+ // One for creating a public channel with the search term
+ // One for creating a private channel with the search term
+ 3
+ );
+ assert.strictEqual(
+ results[0].textContent,
+ "test",
+ "autocomplete suggestion should target the channel matching search term even if user is member"
+ );
+});
+
+QUnit.test('sidebar channels should be ordered case insensitive alphabetically', async function (assert) {
+ assert.expect(1);
+
+ this.data['mail.channel'].records.push(
+ { id: 19, name: "Xyz" },
+ { id: 20, name: "abc" },
+ { id: 21, name: "Abc" },
+ { id: 22, name: "Xyz" }
+ );
+ await this.start();
+ const results = document.querySelectorAll('.o_DiscussSidebar_groupChannel .o_DiscussSidebarItem_name');
+ assert.deepEqual(
+ [results[0].textContent, results[1].textContent, results[2].textContent, results[3].textContent],
+ ["abc", "Abc", "Xyz", "Xyz"],
+ "Channel name should be in case insensitive alphabetical order"
+ );
+});
+
+});
+});
+});
+
+});
diff --git a/addons/mail/static/src/components/discuss/tests/discuss_tests.js b/addons/mail/static/src/components/discuss/tests/discuss_tests.js
new file mode 100644
index 00000000..dc2005e5
--- /dev/null
+++ b/addons/mail/static/src/components/discuss/tests/discuss_tests.js
@@ -0,0 +1,4447 @@
+odoo.define('mail/static/src/components/discuss/tests/discuss_tests.js', function (require) {
+'use strict';
+
+const BusService = require('bus.BusService');
+
+const {
+ afterEach,
+ afterNextRender,
+ beforeEach,
+ nextAnimationFrame,
+ start,
+} = require('mail/static/src/utils/test_utils.js');
+
+const Bus = require('web.Bus');
+const { makeTestPromise, file: { createFile, inputFiles } } = require('web.test_utils');
+
+const {
+ applyFilter,
+ toggleAddCustomFilter,
+ toggleFilterMenu,
+} = require('web.test_utils_control_panel');
+
+QUnit.module('mail', {}, function () {
+QUnit.module('components', {}, function () {
+QUnit.module('discuss', {}, function () {
+QUnit.module('discuss_tests.js', {
+ beforeEach() {
+ beforeEach(this);
+
+ this.start = async params => {
+ const { afterEvent, env, widget } = await start(Object.assign({}, params, {
+ autoOpenDiscuss: true,
+ data: this.data,
+ hasDiscuss: true,
+ }));
+ this.afterEvent = afterEvent;
+ this.env = env;
+ this.widget = widget;
+ };
+ },
+ afterEach() {
+ afterEach(this);
+ },
+});
+
+QUnit.test('messaging not initialized', async function (assert) {
+ assert.expect(1);
+
+ await this.start({
+ async mockRPC(route) {
+ const _super = this._super.bind(this, ...arguments); // limitation of class.js
+ if (route === '/mail/init_messaging') {
+ await makeTestPromise(); // simulate messaging never initialized
+ }
+ return _super();
+ },
+ waitUntilMessagingCondition: 'created',
+ });
+ 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();
+ },
+ waitUntilMessagingCondition: 'created',
+ });
+ 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_ThreadView'),
+ "thread section should use ThreadView 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);
+
+ // notification expected to be counted at init_messaging
+ this.data['mail.notification'].records.push({ res_partner_id: this.data.currentPartnerId });
+ 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,
+ "1",
+ "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);
+
+ // channel expected to be found in the sidebar,
+ // with a random unique id and name that will be referenced in the test
+ this.data['mail.channel'].records.push({ 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'].findFromIdentifyingData({
+ id: 20,
+ 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_ThreadView_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);
+
+ // channel expected to be found in the sidebar
+ // with a random unique id that will be used to link message
+ this.data['mail.channel'].records.push({ id: 20 });
+ // expected needaction message
+ this.data['mail.message'].records.push({
+ body: "not empty",
+ channel_ids: [20], // link message to channel
+ id: 100, // random unique id, useful to link notification
+ });
+ // expected needaction notification
+ this.data['mail.notification'].records.push({
+ mail_message_id: 100, // id of related message
+ res_partner_id: this.data.currentPartnerId, // must be for current partner
+ });
+ 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,
+ "1",
+ "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: mailing channel', async function (assert) {
+ assert.expect(1);
+
+ // channel that is expected to be in the sidebar, with proper mass_mailing value
+ this.data['mail.channel'].records.push({ mass_mailing: true });
+ await this.start();
+ assert.containsOnce(
+ document.querySelector(`.o_DiscussSidebar_groupChannel .o_DiscussSidebar_item`),
+ '.fa.fa-envelope-o',
+ "should have an icon to indicate that the channel is a mailing channel"
+ );
+});
+
+QUnit.test('sidebar: public/private channel rendering', async function (assert) {
+ assert.expect(5);
+
+ // channels that are expected to be found in the sidebar (one public, one private)
+ // with random unique id and name that will be referenced in the test
+ this.data['mail.channel'].records.push(
+ { id: 100, name: "channel1", public: 'public', },
+ { 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'].findFromIdentifyingData({
+ id: 100,
+ 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'].findFromIdentifyingData({
+ id: 101,
+ 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'].findFromIdentifyingData({
+ id: 100,
+ model: 'mail.channel'
+ }).localId
+ }"]
+ `);
+ const channel2 = document.querySelector(`
+ .o_DiscussSidebar_groupChannel
+ .o_DiscussSidebar_item[data-thread-local-id="${
+ this.env.models['mail.thread'].findFromIdentifyingData({
+ id: 101,
+ 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);
+
+ // expected correspondent, with a random unique id that will be used to link
+ // partner to chat and a random name that will be asserted in the test
+ this.data['res.partner'].records.push({ id: 17, name: "Demo" });
+ // chat expected to be found in the sidebar
+ this.data['mail.channel'].records.push({
+ channel_type: 'chat', // testing a chat is the goal of the test
+ id: 10, // random unique id, will be referenced in the test
+ members: [this.data.currentPartnerId, 17], // expected partners
+ public: 'private', // expected value for testing a chat
+ });
+ 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'].findFromIdentifyingData({
+ id: 10,
+ model: 'mail.channel'
+ }).localId,
+ "should have chat with Id 10"
+ );
+ 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);
+
+ // chat expected to be found in the sidebar
+ this.data['mail.channel'].records.push({
+ channel_type: 'chat', // testing a chat is the goal of the test
+ id: 10, // random unique id, will be referenced in the test
+ message_unread_counter: 100,
+ public: 'private', // expected value for testing a chat
+ });
+ 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);
+
+ // expected correspondent, with a random unique id that will be used to link
+ // partner to chat, and various im_status values to assert
+ this.data['res.partner'].records.push(
+ { id: 101, im_status: 'offline', name: "Partner1" },
+ { id: 102, im_status: 'online', name: "Partner2" },
+ { id: 103, im_status: 'away', name: "Partner3" }
+ );
+ // chats expected to be found in the sidebar
+ this.data['mail.channel'].records.push(
+ {
+ channel_type: 'chat', // testing a chat is the goal of the test
+ id: 11, // random unique id, will be referenced in the test
+ members: [this.data.currentPartnerId, 101], // expected partners
+ public: 'private', // expected value for testing a chat
+ },
+ {
+ channel_type: 'chat', // testing a chat is the goal of the test
+ id: 12, // random unique id, will be referenced in the test
+ members: [this.data.currentPartnerId, 102], // expected partners
+ public: 'private', // expected value for testing a chat
+ },
+ {
+ channel_type: 'chat', // testing a chat is the goal of the test
+ id: 13, // random unique id, will be referenced in the test
+ members: [this.data.currentPartnerId, 103], // expected partners
+ public: 'private', // expected value for testing a chat
+ }
+ );
+ 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'].findFromIdentifyingData({
+ id: 11,
+ 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'].findFromIdentifyingData({
+ id: 12,
+ 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'].findFromIdentifyingData({
+ id: 13,
+ 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'].findFromIdentifyingData({
+ id: 11,
+ model: 'mail.channel',
+ }).localId
+ }"]
+ `);
+ const chat2 = document.querySelector(`
+ .o_DiscussSidebar_groupChat
+ .o_DiscussSidebar_item[data-thread-local-id="${
+ this.env.models['mail.thread'].findFromIdentifyingData({
+ id: 12,
+ model: 'mail.channel',
+ }).localId
+ }"]
+ `);
+ const chat3 = document.querySelector(`
+ .o_DiscussSidebar_groupChat
+ .o_DiscussSidebar_item[data-thread-local-id="${
+ this.env.models['mail.thread'].findFromIdentifyingData({
+ id: 13,
+ 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);
+
+ // expected correspondent, with a random unique id that will be used to link
+ // partner to chat, and a random name not used in the scope of this test but set for consistency
+ this.data['res.partner'].records.push({ id: 101, name: "Marc Demo" });
+ // chat expected to be found in the sidebar
+ this.data['mail.channel'].records.push({
+ channel_type: 'chat', // testing a chat is the goal of the test
+ custom_channel_name: "Marc", // testing a custom name is the goal of the test
+ members: [this.data.currentPartnerId, 101], // expected partners
+ public: 'private', // expected value for testing a chat
+ });
+ 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);
+
+ // expected correspondent, with a random unique id that will be used to link
+ // partner to chat, and a random name not used in the scope of this test but set for consistency
+ this.data['res.partner'].records.push({ id: 101, name: "Marc Demo" });
+ // chat expected to be found in the sidebar
+ this.data['mail.channel'].records.push({
+ channel_type: 'chat', // testing a chat is the goal of the test
+ custom_channel_name: "Marc", // testing a custom name is the goal of the test
+ members: [this.data.currentPartnerId, 101], // expected partners
+ public: 'private', // expected value for testing a chat
+ });
+ 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"
+ );
+ 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);
+
+ // channel expected to be found in the sidebar,
+ // with a random unique id that will be referenced in the test
+ this.data['mail.channel'].records.push({ id: 20 });
+ 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'].findFromIdentifyingData({
+ id: 20,
+ model: 'mail.channel',
+ }).localId
+ }"]
+ `).length,
+ 1,
+ "should have channel 20 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_ThreadView_messageList .o_MessageList_empty
+ `).length,
+ 1,
+ "should have empty thread in inbox"
+ );
+ assert.strictEqual(
+ document.querySelector(`
+ .o_Discuss_thread .o_ThreadView_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_ThreadView_messageList .o_MessageList_empty
+ `).length,
+ 1,
+ "should have empty thread in starred"
+ );
+ assert.strictEqual(
+ document.querySelector(`
+ .o_Discuss_thread .o_ThreadView_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_ThreadView_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'].findFromIdentifyingData({
+ id: 20,
+ model: 'mail.channel',
+ }).localId
+ }"]
+ `).click()
+ );
+ assert.ok(
+ document.querySelector(`
+ .o_DiscussSidebar_item[data-thread-local-id="${
+ this.env.models['mail.thread'].findFromIdentifyingData({
+ id: 20,
+ model: 'mail.channel',
+ }).localId
+ }"]
+ `).classList.contains('o-active'),
+ "channel 20 should be active thread"
+ );
+ assert.strictEqual(
+ document.querySelectorAll(`
+ .o_Discuss_thread .o_ThreadView_messageList .o_MessageList_empty
+ `).length,
+ 1,
+ "should have empty thread in starred"
+ );
+ assert.strictEqual(
+ document.querySelector(`
+ .o_Discuss_thread .o_ThreadView_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(4);
+
+ 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.deepEqual(
+ args.kwargs.domain,
+ [["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(7);
+
+ // channel expected to be rendered, with a random unique id that will be referenced in the test
+ this.data['mail.channel'].records.push({ id: 20 });
+ this.data['mail.message'].records.push({
+ body: "not empty",
+ channel_ids: [20],
+ date: "2019-04-20 10:00:00",
+ id: 100,
+ model: 'mail.channel',
+ res_id: 20,
+ });
+ 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.deepEqual(
+ args.kwargs.domain,
+ [["channel_ids", "in", [20]]],
+ "should fetch messages from channel"
+ );
+ }
+ return this._super(...arguments);
+ },
+ });
+ assert.strictEqual(
+ document.querySelectorAll(`.o_Discuss_thread .o_ThreadView_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'].findFromIdentifyingData({ id: 100 }).localId
+ }"]
+ `).length,
+ 1,
+ "should have message with Id 100"
+ );
+});
+
+QUnit.test('open channel from active_id as channel id', async function (assert) {
+ assert.expect(1);
+
+ this.data['mail.channel'].records.push({ id: 20 });
+ await this.start({
+ discuss: {
+ context: {
+ active_id: 20,
+ },
+ }
+ });
+ assert.containsOnce(
+ document.body,
+ `
+ .o_Discuss_thread[data-thread-local-id="${
+ this.env.models['mail.thread'].findFromIdentifyingData({ id: 20, model: 'mail.channel' }).localId
+ }"]
+ `,
+ "should have channel with ID 20 open in Discuss when providing active_id 20"
+ );
+});
+
+QUnit.test('basic rendering of message', async function (assert) {
+ // AKU TODO: should be in message-only tests
+ assert.expect(13);
+
+ // channel expected to be rendered, with a random unique id that will be referenced in the test
+ this.data['mail.channel'].records.push({ id: 20 });
+ // partner to be set as author, with a random unique id that will be used to
+ // link message and a random name that will be asserted in the test
+ this.data['res.partner'].records.push({ id: 11, name: "Demo" });
+ this.data['mail.message'].records.push({
+ author_id: 11,
+ body: "<p>body</p>",
+ channel_ids: [20],
+ date: "2019-04-20 10:00:00",
+ id: 100,
+ model: 'mail.channel',
+ res_id: 20,
+ });
+ await this.start({
+ discuss: {
+ params: {
+ default_active_id: 'mail.channel_20',
+ },
+ },
+ });
+ const message = document.querySelector(`
+ .o_Discuss_thread
+ .o_ThreadView_messageList
+ .o_MessageList_message[data-message-local-id="${
+ this.env.models['mail.message'].findFromIdentifyingData({ 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);
+
+ // channel expected to be rendered, with a random unique id that will be referenced in the test
+ this.data['mail.channel'].records.push({ id: 20 });
+ // partner to be set as author, with a random unique id that will be used to link message
+ this.data['res.partner'].records.push({ id: 11 });
+ this.data['mail.message'].records.push(
+ {
+ author_id: 11, // must be same author as other message
+ body: "<p>body1</p>", // random body, set for consistency
+ channel_ids: [20], // to link message to channel
+ date: "2019-04-20 10:00:00", // date must be within 1 min from other message
+ id: 100, // random unique id, will be referenced in the test
+ message_type: 'comment', // must be a squash-able type-
+ model: 'mail.channel', // to link message to channel
+ res_id: 20, // id of related channel
+ },
+ {
+ author_id: 11, // must be same author as other message
+ body: "<p>body2</p>", // random body, will be asserted in the test
+ channel_ids: [20], // to link message to channel
+ date: "2019-04-20 10:00:30", // date must be within 1 min from other message
+ id: 101, // random unique id, will be referenced in the test
+ message_type: 'comment', // must be a squash-able type
+ model: 'mail.channel', // to link message to channel
+ res_id: 20, // id of related channel
+ }
+ );
+ await this.start({
+ discuss: {
+ params: {
+ default_active_id: 'mail.channel_20',
+ },
+ },
+ });
+ assert.strictEqual(
+ document.querySelectorAll(`
+ .o_Discuss_thread .o_ThreadView_messageList .o_MessageList_message
+ `).length,
+ 2,
+ "should have 2 messages"
+ );
+ const message1 = document.querySelector(`
+ .o_Discuss_thread
+ .o_ThreadView_messageList
+ .o_MessageList_message[data-message-local-id="${
+ this.env.models['mail.message'].findFromIdentifyingData({ id: 100 }).localId
+ }"]
+ `);
+ const message2 = document.querySelector(`
+ .o_Discuss_thread
+ .o_ThreadView_messageList
+ .o_MessageList_message[data-message-local-id="${
+ this.env.models['mail.message'].findFromIdentifyingData({ 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);
+
+ // partner to be set as author, with a random unique id that will be used to link message
+ this.data['res.partner'].records.push({ id: 11 });
+ this.data['mail.message'].records.push(
+ {
+ author_id: 11, // must be same author as other message
+ body: "<p>body1</p>", // random body, set for consistency
+ channel_ids: [20], // to link message to channel
+ date: "2019-04-20 10:00:00", // date must be within 1 min from other message
+ id: 100, // random unique id, will be referenced in the test
+ message_type: 'comment', // must be a squash-able type-
+ model: 'mail.channel', // to link message to channel
+ needaction: true, // necessary for message_fetch domain
+ needaction_partner_ids: [this.data.currentPartnerId], // for consistency
+ res_id: 20, // id of related channel
+ },
+ {
+ author_id: 11, // must be same author as other message
+ body: "<p>body2</p>", // random body, will be asserted in the test
+ channel_ids: [20], // to link message to channel
+ date: "2019-04-20 10:00:30", // date must be within 1 min from other message
+ id: 101, // random unique id, will be referenced in the test
+ message_type: 'comment', // must be a squash-able type
+ model: 'mail.channel', // to link message to channel
+ needaction: true, // necessary for message_fetch domain
+ needaction_partner_ids: [this.data.currentPartnerId], // for consistency
+ res_id: 20, // id of related channel
+ }
+ );
+ await this.start();
+ assert.strictEqual(
+ document.querySelectorAll(`
+ .o_Discuss_thread .o_ThreadView_messageList .o_MessageList_message
+ `).length,
+ 2,
+ "should have 2 messages"
+ );
+ const message1 = document.querySelector(`
+ .o_Discuss_thread
+ .o_ThreadView_messageList
+ .o_MessageList_message[data-message-local-id="${
+ this.env.models['mail.message'].findFromIdentifyingData({ id: 100 }).localId
+ }"]
+ `);
+ const message2 = document.querySelector(`
+ .o_Discuss_thread
+ .o_ThreadView_messageList
+ .o_MessageList_message[data-message-local-id="${
+ this.env.models['mail.message'].findFromIdentifyingData({ 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);
+
+ // channel expected to be rendered, with a random unique id that will be referenced in the test
+ this.data['mail.channel'].records.push({ id: 20 });
+ // partner to be set as author, with a random unique id that will be used to link message
+ this.data['res.partner'].records.push({ id: 11 });
+ for (let i = 28; i >= 0; i--) {
+ this.data['mail.message'].records.push({
+ body: "not empty",
+ channel_ids: [20],
+ date: "2019-04-20 10:00:00",
+ model: 'mail.channel',
+ res_id: 20,
+ });
+ }
+ 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");
+ }
+ return this._super(...arguments);
+ },
+ });
+ assert.strictEqual(
+ document.querySelectorAll(`
+ .o_Discuss_thread .o_ThreadView_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_ThreadView_messageList .o_MessageList_separatorLabelDate
+ `).textContent,
+ "April 20, 2019",
+ "should display date day of messages"
+ );
+ assert.strictEqual(
+ document.querySelectorAll(`
+ .o_Discuss_thread .o_ThreadView_messageList .o_MessageList_message
+ `).length,
+ 29,
+ "should have 29 messages"
+ );
+ assert.strictEqual(
+ document.querySelectorAll(`
+ .o_Discuss_thread .o_ThreadView_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(6);
+
+ // channel expected to be rendered, with a random unique id that will be referenced in the test
+ this.data['mail.channel'].records.push({ id: 20 });
+ // partner to be set as author, with a random unique id that will be used to link message
+ this.data['res.partner'].records.push({ id: 11 });
+ for (let i = 0; i < 40; i++) {
+ this.data['mail.message'].records.push({
+ body: "not empty",
+ channel_ids: [20],
+ date: "2019-04-20 10:00:00",
+ model: 'mail.channel',
+ res_id: 20,
+ });
+ }
+ await this.start({
+ discuss: {
+ params: {
+ default_active_id: 'mail.channel_20',
+ },
+ },
+ });
+ assert.strictEqual(
+ document.querySelectorAll(`
+ .o_Discuss_thread .o_ThreadView_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_ThreadView_messageList .o_MessageList_separatorLabelDate
+ `).textContent,
+ "April 20, 2019",
+ "should display date day of messages"
+ );
+ assert.strictEqual(
+ document.querySelectorAll(`
+ .o_Discuss_thread .o_ThreadView_messageList .o_MessageList_message
+ `).length,
+ 30,
+ "should have 30 messages"
+ );
+ assert.strictEqual(
+ document.querySelectorAll(`
+ .o_Discuss_thread .o_ThreadView_messageList .o_MessageList_loadMore
+ `).length,
+ 1,
+ "should have load more link"
+ );
+
+ await afterNextRender(() =>
+ document.querySelector(`
+ .o_Discuss_thread .o_ThreadView_messageList .o_MessageList_loadMore
+ `).click()
+ );
+ assert.strictEqual(
+ document.querySelectorAll(`
+ .o_Discuss_thread .o_ThreadView_messageList .o_MessageList_message
+ `).length,
+ 40,
+ "should have 40 messages"
+ );
+ assert.strictEqual(
+ document.querySelectorAll(`
+ .o_Discuss_thread .o_ThreadView_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);
+
+ // channel expected to be rendered, with a random unique id that will be referenced in the test
+ this.data['mail.channel'].records.push({ id: 20 });
+ for (let i = 1; i <= 25; i++) {
+ this.data['mail.message'].records.push({
+ body: "not empty",
+ channel_ids: [20],
+ model: 'mail.channel',
+ res_id: 20,
+ });
+ }
+ await this.start({
+ discuss: {
+ params: {
+ default_active_id: 'mail.channel_20',
+ },
+ },
+ waitUntilEvent: {
+ eventName: 'o-component-message-list-scrolled',
+ message: "should wait until channel 20 scrolled to its last message initially",
+ predicate: ({ scrollTop, thread }) => {
+ const messageList = document.querySelector('.o_ThreadView_messageList');
+ return (
+ thread &&
+ thread.model === 'mail.channel' &&
+ thread.id === 20 &&
+ scrollTop === messageList.scrollHeight - messageList.clientHeight
+ );
+ },
+ },
+ });
+ assert.strictEqual(
+ document.querySelectorAll(`
+ .o_Discuss_thread .o_ThreadView_messageList .o_MessageList_message
+ `).length,
+ 25,
+ "should have 25 messages"
+ );
+ const messageList = document.querySelector(`.o_Discuss_thread .o_ThreadView_messageList`);
+ assert.strictEqual(
+ messageList.scrollTop,
+ messageList.scrollHeight - messageList.clientHeight,
+ "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);
+
+ this.data['mail.channel'].records.push({ id: 20 });
+ for (let i = 0; i < 40; i++) {
+ this.data['mail.message'].records.push({
+ body: "not empty",
+ channel_ids: [20],
+ model: 'mail.channel',
+ res_id: 20,
+ });
+ }
+ await this.start({
+ discuss: {
+ params: {
+ default_active_id: 'mail.channel_20',
+ },
+ },
+ waitUntilEvent: {
+ eventName: 'o-component-message-list-scrolled',
+ message: "should wait until channel 20 scrolled to its last message initially",
+ predicate: ({ scrollTop, thread }) => {
+ const messageList = document.querySelector(`.o_Discuss_thread .o_ThreadView_messageList`);
+ return (
+ thread &&
+ thread.model === 'mail.channel' &&
+ thread.id === 20 &&
+ scrollTop === messageList.scrollHeight - messageList.clientHeight
+ );
+ },
+ },
+ });
+ assert.strictEqual(
+ document.querySelectorAll(`
+ .o_Discuss_thread .o_ThreadView_messageList .o_MessageList_message
+ `).length,
+ 30,
+ "should have 30 messages"
+ );
+
+ await this.afterEvent({
+ eventName: 'o-thread-view-hint-processed',
+ func: () => document.querySelector('.o_ThreadView_messageList').scrollTop = 0,
+ message: "should wait until channel 20 loaded more messages after scrolling to top",
+ predicate: ({ hint, threadViewer }) => {
+ return (
+ hint.type === 'more-messages-loaded' &&
+ threadViewer.thread.model === 'mail.channel' &&
+ threadViewer.thread.id === 20
+ );
+ },
+ });
+ assert.strictEqual(
+ document.querySelectorAll(`
+ .o_Discuss_thread .o_ThreadView_messageList .o_MessageList_message
+ `).length,
+ 40,
+ "should have 40 messages"
+ );
+ assert.strictEqual(
+ document.querySelectorAll(`
+ .o_Dsiscuss_thread .o_ThreadView_messageList .o_MessageList_loadMore
+ `).length,
+ 0,
+ "should not longer have load more link (all messages loaded)"
+ );
+});
+
+QUnit.test('new messages separator [REQUIRE FOCUS]', 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(6);
+
+ // Needed partner & user to allow simulation of message reception
+ this.data['res.partner'].records.push({
+ id: 11,
+ name: "Foreigner partner",
+ });
+ this.data['res.users'].records.push({
+ id: 42,
+ name: "Foreigner user",
+ partner_id: 11,
+ });
+ // channel expected to be rendered, with a random unique id that will be
+ // referenced in the test and the seen_message_id value set to last message
+ this.data['mail.channel'].records.push({
+ id: 20,
+ seen_message_id: 125,
+ uuid: 'randomuuid',
+ });
+ for (let i = 1; i <= 25; i++) {
+ this.data['mail.message'].records.push({
+ body: "not empty",
+ channel_ids: [20],
+ id: 100 + i, // for setting proper value for seen_message_id
+ model: 'mail.channel',
+ res_id: 20,
+ });
+ }
+ await this.start({
+ discuss: {
+ params: {
+ default_active_id: 'mail.channel_20',
+ },
+ },
+ waitUntilEvent: {
+ eventName: 'o-component-message-list-scrolled',
+ message: "should wait until channel 20 scrolled to its last message initially",
+ predicate: ({ scrollTop, thread }) => {
+ const messageList = document.querySelector(`.o_Discuss_thread .o_ThreadView_messageList`);
+ return (
+ thread &&
+ thread.model === 'mail.channel' &&
+ thread.id === 20 &&
+ scrollTop === messageList.scrollHeight - messageList.clientHeight
+ );
+ },
+ },
+ });
+ assert.containsN(
+ document.body,
+ '.o_MessageList_message',
+ 25,
+ "should have 25 messages"
+ );
+ assert.containsNone(
+ document.body,
+ '.o_MessageList_separatorNewMessages',
+ "should not display 'new messages' separator"
+ );
+ // scroll to top
+ await this.afterEvent({
+ eventName: 'o-component-message-list-scrolled',
+ func: () => {
+ document.querySelector(`.o_Discuss_thread .o_ThreadView_messageList`).scrollTop = 0;
+ },
+ message: "should wait until channel scrolled to top",
+ predicate: ({ scrollTop, thread }) => {
+ return (
+ thread &&
+ thread.model === 'mail.channel' &&
+ thread.id === 20 &&
+ scrollTop === 0
+ );
+ },
+ });
+ // composer is focused by default, we remove that focus
+ document.querySelector('.o_ComposerTextInput_textarea').blur();
+ // simulate receiving a message
+ await afterNextRender(async () => this.env.services.rpc({
+ route: '/mail/chat_post',
+ params: {
+ context: {
+ mockedUserId: 42,
+ },
+ message_content: "hu",
+ uuid: 'randomuuid',
+ },
+ }));
+
+ assert.containsN(
+ document.body,
+ '.o_MessageList_message',
+ 26,
+ "should have 26 messages"
+ );
+ assert.containsOnce(
+ document.body,
+ '.o_MessageList_separatorNewMessages',
+ "should display 'new messages' separator"
+ );
+ await this.afterEvent({
+ eventName: 'o-component-message-list-scrolled',
+ func: () => {
+ const messageList = document.querySelector(`.o_Discuss_thread .o_ThreadView_messageList`);
+ messageList.scrollTop = messageList.scrollHeight - messageList.clientHeight;
+ },
+ message: "should wait until channel scrolled to bottom",
+ predicate: ({ scrollTop, thread }) => {
+ const messageList = document.querySelector(`.o_Discuss_thread .o_ThreadView_messageList`);
+ return (
+ thread &&
+ thread.model === 'mail.channel' &&
+ thread.id === 20 &&
+ scrollTop === messageList.scrollHeight - messageList.clientHeight
+ );
+ },
+ });
+ assert.containsOnce(
+ document.body,
+ '.o_MessageList_separatorNewMessages',
+ "should still display 'new messages' separator as composer is not focused"
+ );
+
+ await afterNextRender(() =>
+ document.querySelector('.o_ComposerTextInput_textarea').focus()
+ );
+ assert.containsNone(
+ document.body,
+ '.o_MessageList_separatorNewMessages',
+ "should no longer display 'new messages' separator (message seen)"
+ );
+});
+
+QUnit.test('restore thread scroll position', async function (assert) {
+ assert.expect(6);
+ // channels expected to be rendered, with random unique id that will be referenced in the test
+ this.data['mail.channel'].records.push(
+ {
+ id: 11,
+ },
+ {
+ id: 12,
+ },
+ );
+ for (let i = 1; i <= 25; i++) {
+ this.data['mail.message'].records.push({
+ body: "not empty",
+ channel_ids: [11],
+ model: 'mail.channel',
+ res_id: 11,
+ });
+ }
+ for (let i = 1; i <= 24; i++) {
+ this.data['mail.message'].records.push({
+ body: "not empty",
+ channel_ids: [12],
+ model: 'mail.channel',
+ res_id: 12,
+ });
+ }
+ await this.start({
+ discuss: {
+ params: {
+ default_active_id: 'mail.channel_11',
+ },
+ },
+ waitUntilEvent: {
+ eventName: 'o-component-message-list-scrolled',
+ message: "should wait until channel 11 scrolled to its last message",
+ predicate: ({ thread }) => {
+ return thread && thread.model === 'mail.channel' && thread.id === 11;
+ },
+ },
+ });
+ assert.strictEqual(
+ document.querySelectorAll(`
+ .o_Discuss_thread .o_ThreadView_messageList .o_MessageList_message
+ `).length,
+ 25,
+ "should have 25 messages in channel 11"
+ );
+ const initialMessageList = document.querySelector(`
+ .o_Discuss_thread
+ .o_ThreadView_messageList
+ `);
+ assert.strictEqual(
+ initialMessageList.scrollTop,
+ initialMessageList.scrollHeight - initialMessageList.clientHeight,
+ "should have scrolled to bottom of channel 11 initially"
+ );
+
+ await this.afterEvent({
+ eventName: 'o-component-message-list-scrolled',
+ func: () => document.querySelector(`.o_Discuss_thread .o_ThreadView_messageList`).scrollTop = 0,
+ message: "should wait until channel 11 changed its scroll position to top",
+ predicate: ({ thread }) => {
+ return thread && thread.model === 'mail.channel' && thread.id === 11;
+ },
+ });
+ assert.strictEqual(
+ document.querySelector(`.o_Discuss_thread .o_ThreadView_messageList`).scrollTop,
+ 0,
+ "should have scrolled to top of channel 11",
+ );
+
+ // Ensure scrollIntoView of channel 12 has enough time to complete before
+ // going back to channel 11. Await is needed to prevent the scrollIntoView
+ // initially planned for channel 12 to actually apply on channel 11.
+ // task-2333535
+ await this.afterEvent({
+ eventName: 'o-component-message-list-scrolled',
+ func: () => {
+ // select channel 12
+ document.querySelector(`
+ .o_DiscussSidebar_groupChannel
+ .o_DiscussSidebar_item[data-thread-local-id="${
+ this.env.models['mail.thread'].findFromIdentifyingData({
+ id: 12,
+ model: 'mail.channel',
+ }).localId
+ }"]
+ `).click();
+ },
+ message: "should wait until channel 12 scrolled to its last message",
+ predicate: ({ scrollTop, thread }) => {
+ const messageList = document.querySelector('.o_ThreadView_messageList');
+ return (
+ thread &&
+ thread.model === 'mail.channel' &&
+ thread.id === 12 &&
+ scrollTop === messageList.scrollHeight - messageList.clientHeight
+ );
+ },
+ });
+ assert.strictEqual(
+ document.querySelectorAll(`
+ .o_Discuss_thread .o_ThreadView_messageList .o_MessageList_message
+ `).length,
+ 24,
+ "should have 24 messages in channel 12"
+ );
+
+ await this.afterEvent({
+ eventName: 'o-component-message-list-scrolled',
+ func: () => {
+ // select channel 11
+ document.querySelector(`
+ .o_DiscussSidebar_groupChannel
+ .o_DiscussSidebar_item[data-thread-local-id="${
+ this.env.models['mail.thread'].findFromIdentifyingData({
+ id: 11,
+ model: 'mail.channel',
+ }).localId
+ }"]
+ `).click();
+ },
+ message: "should wait until channel 11 restored its scroll position",
+ predicate: ({ scrollTop, thread }) => {
+ return (
+ thread &&
+ thread.model === 'mail.channel' &&
+ thread.id === 11 &&
+ scrollTop === 0
+ );
+ },
+ });
+ assert.strictEqual(
+ document.querySelector(`.o_Discuss_thread .o_ThreadView_messageList`).scrollTop,
+ 0,
+ "should have recovered scroll position of channel 11 (scroll to top)"
+ );
+
+ await this.afterEvent({
+ eventName: 'o-component-message-list-scrolled',
+ func: () => {
+ // select channel 12
+ document.querySelector(`
+ .o_DiscussSidebar_groupChannel
+ .o_DiscussSidebar_item[data-thread-local-id="${
+ this.env.models['mail.thread'].findFromIdentifyingData({
+ id: 12,
+ model: 'mail.channel',
+ }).localId
+ }"]
+ `).click();
+ },
+ message: "should wait until channel 12 recovered its scroll position (to bottom)",
+ predicate: ({ scrollTop, thread }) => {
+ const messageList = document.querySelector('.o_ThreadView_messageList');
+ return (
+ thread &&
+ thread.model === 'mail.channel' &&
+ thread.id === 12 &&
+ scrollTop === messageList.scrollHeight - messageList.clientHeight
+ );
+ },
+ });
+ const messageList = document.querySelector('.o_ThreadView_messageList');
+ assert.strictEqual(
+ messageList.scrollTop,
+ messageList.scrollHeight - messageList.clientHeight,
+ "should have recovered scroll position of channel 12 (scroll to bottom)"
+ );
+});
+
+QUnit.test('message origin redirect to channel', async function (assert) {
+ assert.expect(15);
+
+ // channels expected to be rendered, with random unique id that will be referenced in the test
+ this.data['mail.channel'].records.push({ id: 11 }, { id: 12 });
+ this.data['mail.message'].records.push(
+ {
+ body: "not empty",
+ channel_ids: [11, 12],
+ id: 100,
+ model: 'mail.channel',
+ record_name: "channel11",
+ res_id: 11,
+ },
+ {
+ body: "not empty",
+ channel_ids: [11, 12],
+ id: 101,
+ model: 'mail.channel',
+ record_name: "channel12",
+ res_id: 12,
+ }
+ );
+ await this.start({
+ discuss: {
+ params: {
+ default_active_id: 'mail.channel_11',
+ },
+ },
+ });
+ 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'].findFromIdentifyingData({ 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'].findFromIdentifyingData({ 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'].findFromIdentifyingData({ id: 100 }).localId
+ }"]
+ .o_Message_originThread
+ `).length,
+ 0,
+ "message1 should not have origin part in channel11 (same origin as channel)"
+ );
+ assert.strictEqual(
+ document.querySelectorAll(`
+ .o_Discuss_thread
+ .o_Message[data-message-local-id="${
+ this.env.models['mail.message'].findFromIdentifyingData({ id: 101 }).localId
+ }"]
+ .o_Message_originThread
+ `).length,
+ 1,
+ "message2 should have origin part (origin is channel12 !== channel11)"
+ );
+ assert.strictEqual(
+ document.querySelector(`
+ .o_Discuss_thread
+ .o_Message[data-message-local-id="${
+ this.env.models['mail.message'].findFromIdentifyingData({ id: 101 }).localId
+ }"]
+ .o_Message_originThread
+ `).textContent.trim(),
+ "(from #channel12)",
+ "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'].findFromIdentifyingData({ id: 101 }).localId
+ }"]
+ .o_Message_originThreadLink
+ `).length,
+ 1,
+ "message2 should have link to redirect to origin"
+ );
+
+ // click on origin link of message2 (= channel12)
+ await afterNextRender(() =>
+ document.querySelector(`
+ .o_Discuss_thread
+ .o_Message[data-message-local-id="${
+ this.env.models['mail.message'].findFromIdentifyingData({ 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'].findFromIdentifyingData({
+ id: 12,
+ model: 'mail.channel',
+ }).localId
+ }"]
+ .o_DiscussSidebarItem_activeIndicator
+ `).classList.contains('o-item-active'),
+ "channel12 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'].findFromIdentifyingData({ 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'].findFromIdentifyingData({ 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'].findFromIdentifyingData({ id: 100 }).localId
+ }"]
+ .o_Message_originThread
+ `).length,
+ 1,
+ "message1 should have origin thread part (= channel11 !== channel12)"
+ );
+ assert.strictEqual(
+ document.querySelectorAll(`
+ .o_Discuss_thread
+ .o_Message[data-message-local-id="${
+ this.env.models['mail.message'].findFromIdentifyingData({ id: 101 }).localId
+ }"]
+ .o_Message_originThread
+ `).length,
+ 0,
+ "message2 should not have origin thread part in channel12 (same as current channel)"
+ );
+ assert.strictEqual(
+ document.querySelector(`
+ .o_Discuss_thread
+ .o_Message[data-message-local-id="${
+ this.env.models['mail.message'].findFromIdentifyingData({ id: 100 }).localId
+ }"]
+ .o_Message_originThread
+ `).textContent.trim(),
+ "(from #channel11)",
+ "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'].findFromIdentifyingData({ 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(7);
+
+ // expected correspondent, with a random unique id that will be used to link
+ // partner to chat and a random name that will be asserted in the test
+ this.data['res.partner'].records.push({ id: 7, name: "Demo" });
+ this.data['res.users'].records.push({ partner_id: 7 });
+ this.data['mail.channel'].records.push(
+ // channel expected to be found in the sidebar
+ {
+ id: 1, // random unique id, will be referenced in the test
+ name: "General", // random name, will be asserted in the test
+ },
+ // chat expected to be found in the sidebar
+ {
+ channel_type: 'chat', // testing a chat is the goal of the test
+ id: 10, // random unique id, will be referenced in the test
+ members: [this.data.currentPartnerId, 7], // expected partners
+ public: 'private', // expected value for testing a chat
+ }
+ );
+ this.data['mail.message'].records.push(
+ {
+ author_id: 7,
+ body: "not empty",
+ channel_ids: [1],
+ id: 100,
+ model: 'mail.channel',
+ res_id: 1,
+ }
+ );
+ await this.start({
+ discuss: {
+ params: {
+ default_active_id: 'mail.channel_1',
+ },
+ },
+ });
+ assert.ok(
+ document.querySelector(`
+ .o_DiscussSidebar_groupChannel
+ .o_DiscussSidebar_item[data-thread-local-id="${
+ this.env.models['mail.thread'].findFromIdentifyingData({
+ id: 1,
+ 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'].findFromIdentifyingData({
+ id: 10,
+ 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,
+ 1,
+ "should have 1 message"
+ );
+ const msg1 = document.querySelector(`
+ .o_Discuss_thread
+ .o_Message[data-message-local-id="${
+ this.env.models['mail.message'].findFromIdentifyingData({ id: 100 }).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"
+ );
+
+ 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'].findFromIdentifyingData({
+ id: 1,
+ 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'].findFromIdentifyingData({
+ id: 10,
+ 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);
+
+ for (let id = 1; id <= 20; id++) {
+ this.data['mail.channel'].records.push({ id, name: `channel${id}` });
+ }
+ 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'].findFromIdentifyingData({
+ id: 12,
+ 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);
+
+ // channel expected to be found in the sidebar
+ // with a random unique id and name that will be referenced in the test
+ this.data['mail.channel'].records.push({ 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'].findFromIdentifyingData({
+ id: 20,
+ 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);
+
+ // channel expected to be found in the sidebar,
+ // with a random unique id that will be referenced in the test
+ this.data['mail.channel'].records.push({ id: 20 });
+ this.data['mail.message'].records.push(
+ // first expected message
+ {
+ body: "not empty",
+ channel_ids: [20], // link message to channel
+ id: 100, // random unique id, useful to link notification
+ model: 'mail.channel',
+ // needaction needs to be set here for message_fetch domain, because
+ // mocked models don't have computed fields
+ needaction: true,
+ res_id: 20,
+ },
+ // second expected message
+ {
+ body: "not empty",
+ channel_ids: [20], // link message to channel
+ id: 101, // random unique id, useful to link notification
+ model: 'mail.channel',
+ // needaction needs to be set here for message_fetch domain, because
+ // mocked models don't have computed fields
+ needaction: true,
+ res_id: 20,
+ }
+ );
+ this.data['mail.notification'].records.push(
+ // notification to have first message in inbox
+ {
+ mail_message_id: 100, // id of related message
+ res_partner_id: this.data.currentPartnerId, // must be for current partner
+ },
+ // notification to have second message in inbox
+ {
+ mail_message_id: 101, // id of related message
+ res_partner_id: this.data.currentPartnerId, // must be for current partner
+ }
+ );
+ await this.start();
+ 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'].findFromIdentifyingData({
+ id: 20,
+ 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'].findFromIdentifyingData({
+ id: 20,
+ 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);
+
+ // messages expected to be starred
+ this.data['mail.message'].records.push(
+ { body: "not empty", starred_partner_ids: [this.data.currentPartnerId] },
+ { body: "not empty", starred_partner_ids: [this.data.currentPartnerId] }
+ );
+ await this.start({
+ discuss: {
+ params: {
+ default_active_id: 'mail.box_starred',
+ },
+ },
+ });
+ 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);
+
+ // channel expected to be initially rendered
+ // with a random unique id, will be referenced in the test
+ this.data['mail.channel'].records.push({ id: 20 });
+ this.data['mail.message'].records.push({
+ body: "not empty",
+ channel_ids: [20],
+ id: 100,
+ model: 'mail.channel',
+ res_id: 20,
+ });
+ await this.start({
+ discuss: {
+ params: {
+ default_active_id: 'mail.channel_20',
+ },
+ },
+ async mockRPC(route, args) {
+ 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"
+ );
+ }
+ 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);
+
+ // channels expected to be found in the sidebar,
+ // with random unique id and name that will be referenced in the test
+ this.data['mail.channel'].records.push(
+ { id: 20, name: "General" },
+ { id: 21, name: "Special" }
+ );
+ await this.start({
+ discuss: {
+ params: {
+ default_active_id: 'mail.channel_20',
+ },
+ },
+ });
+ // 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);
+
+ // channels expected to be found in the sidebar
+ // with random unique id and name that will be referenced in the test
+ this.data['mail.channel'].records.push(
+ { id: 20, name: "General" },
+ { id: 21, name: "Special" }
+ );
+ await this.start({
+ discuss: {
+ params: {
+ default_active_id: 'mail.channel_20',
+ },
+ },
+ });
+ 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'].findFromIdentifyingData({ 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'].findFromIdentifyingData({ 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'].findFromIdentifyingData({ 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'].findFromIdentifyingData({ id: 4 }).localId,
+ "should have attachment with id 4 as 3rd attachment"
+ );
+});
+
+QUnit.test('post a simple message', async function (assert) {
+ assert.expect(15);
+
+ // channel expected to be found in the sidebar
+ // with a random unique id that will be referenced in the test
+ this.data['mail.channel'].records.push({ id: 20 });
+ let postedMessageId;
+ await this.start({
+ discuss: {
+ params: {
+ default_active_id: 'mail.channel_20',
+ },
+ },
+ async mockRPC(route, args) {
+ const res = await this._super(...arguments);
+ 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'"
+ );
+ postedMessageId = res;
+ }
+ return res;
+ },
+ });
+ 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
+ await afterNextRender(() => {
+ 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_Composer_buttonSend').click()
+ );
+ 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'].findFromIdentifyingData({ id: postedMessageId }).localId,
+ "new message in thread should be linked to newly created message from message post"
+ );
+ assert.strictEqual(
+ message.querySelector(`:scope .o_Message_authorName`).textContent,
+ "Mitchell Admin",
+ "new message in thread should be from current partner name"
+ );
+ assert.strictEqual(
+ message.querySelector(`:scope .o_Message_content`).textContent,
+ "Test",
+ "new message in thread should have content typed from composer text input"
+ );
+});
+
+QUnit.test('post message on non-mailing channel with "Enter" keyboard shortcut', async function (assert) {
+ assert.expect(2);
+
+ // channel expected to be found in the sidebar
+ // with a random unique id that will be referenced in the test
+ this.data['mail.channel'].records.push({ id: 20, mass_mailing: false });
+ await this.start({
+ discuss: {
+ params: {
+ default_active_id: 'mail.channel_20',
+ },
+ },
+ });
+ assert.containsNone(
+ document.body,
+ '.o_Message',
+ "should not have any message initially in channel"
+ );
+
+ // insert some HTML in editable
+ await afterNextRender(() => {
+ document.querySelector(`.o_ComposerTextInput_textarea`).focus();
+ document.execCommand('insertText', false, "Test");
+ });
+ await afterNextRender(() => {
+ const kevt = new window.KeyboardEvent('keydown', { key: "Enter" });
+ document.querySelector('.o_ComposerTextInput_textarea').dispatchEvent(kevt);
+ });
+ assert.containsOnce(
+ document.body,
+ '.o_Message',
+ "should now have single message in channel after posting message from pressing 'Enter' in text input of composer"
+ );
+});
+
+QUnit.test('do not post message on non-mailing channel with "SHIFT-Enter" keyboard shortcut', async function (assert) {
+ // Note that test doesn't assert SHIFT-Enter makes a newline, because this
+ // default browser cannot be simulated with just dispatching
+ // programmatically crafted events...
+ assert.expect(2);
+
+ // channel expected to be found in the sidebar
+ // with a random unique id that will be referenced in the test
+ this.data['mail.channel'].records.push({ id: 20, mass_mailing: true });
+ await this.start({
+ discuss: {
+ params: {
+ default_active_id: 'mail.channel_20',
+ },
+ },
+ });
+ assert.containsNone(
+ document.body,
+ '.o_Message',
+ "should not have any message initially in channel"
+ );
+
+ // insert some HTML in editable
+ await afterNextRender(() => {
+ document.querySelector(`.o_ComposerTextInput_textarea`).focus();
+ document.execCommand('insertText', false, "Test");
+ });
+ const kevt = new window.KeyboardEvent('keydown', { key: "Enter", shiftKey: true });
+ document.querySelector('.o_ComposerTextInput_textarea').dispatchEvent(kevt);
+ await nextAnimationFrame();
+ assert.containsNone(
+ document.body,
+ '.o_Message',
+ "should still not have any message in channel after pressing 'Shift-Enter' in text input of composer"
+ );
+});
+
+QUnit.test('post message on mailing channel with "CTRL-Enter" keyboard shortcut', async function (assert) {
+ assert.expect(2);
+
+ // channel expected to be found in the sidebar
+ // with a random unique id that will be referenced in the test
+ this.data['mail.channel'].records.push({ id: 20, mass_mailing: true });
+ await this.start({
+ discuss: {
+ params: {
+ default_active_id: 'mail.channel_20',
+ },
+ },
+ });
+ assert.containsNone(
+ document.body,
+ '.o_Message',
+ "should not have any message initially in channel"
+ );
+
+ // insert some HTML in editable
+ await afterNextRender(() => {
+ document.querySelector(`.o_ComposerTextInput_textarea`).focus();
+ document.execCommand('insertText', false, "Test");
+ });
+ await afterNextRender(() => {
+ const kevt = new window.KeyboardEvent('keydown', { ctrlKey: true, key: "Enter" });
+ document.querySelector('.o_ComposerTextInput_textarea').dispatchEvent(kevt);
+ });
+ assert.containsOnce(
+ document.body,
+ '.o_Message',
+ "should now have single message in channel after posting message from pressing 'CTRL-Enter' in text input of composer"
+ );
+});
+
+QUnit.test('post message on mailing channel with "META-Enter" keyboard shortcut', async function (assert) {
+ assert.expect(2);
+
+ // channel expected to be found in the sidebar
+ // with a random unique id that will be referenced in the test
+ this.data['mail.channel'].records.push({ id: 20, mass_mailing: true });
+ await this.start({
+ discuss: {
+ params: {
+ default_active_id: 'mail.channel_20',
+ },
+ },
+ });
+ assert.containsNone(
+ document.body,
+ '.o_Message',
+ "should not have any message initially in channel"
+ );
+
+ // insert some HTML in editable
+ await afterNextRender(() => {
+ document.querySelector(`.o_ComposerTextInput_textarea`).focus();
+ document.execCommand('insertText', false, "Test");
+ });
+ await afterNextRender(() => {
+ const kevt = new window.KeyboardEvent('keydown', { key: "Enter", metaKey: true });
+ document.querySelector('.o_ComposerTextInput_textarea').dispatchEvent(kevt);
+ });
+ assert.containsOnce(
+ document.body,
+ '.o_Message',
+ "should now have single message in channel after posting message from pressing 'META-Enter' in text input of composer"
+ );
+});
+
+QUnit.test('do not post message on mailing channel with "Enter" keyboard shortcut', async function (assert) {
+ // Note that test doesn't assert Enter makes a newline, because this
+ // default browser cannot be simulated with just dispatching
+ // programmatically crafted events...
+ assert.expect(2);
+
+ // channel expected to be found in the sidebar
+ // with a random unique id that will be referenced in the test
+ this.data['mail.channel'].records.push({ id: 20, mass_mailing: true });
+ await this.start({
+ discuss: {
+ params: {
+ default_active_id: 'mail.channel_20',
+ },
+ },
+ });
+ assert.containsNone(
+ document.body,
+ '.o_Message',
+ "should not have any message initially in mailing channel"
+ );
+
+ // insert some HTML in editable
+ await afterNextRender(() => {
+ document.querySelector(`.o_ComposerTextInput_textarea`).focus();
+ document.execCommand('insertText', false, "Test");
+ });
+ const kevt = new window.KeyboardEvent('keydown', { key: "Enter" });
+ document.querySelector('.o_ComposerTextInput_textarea').dispatchEvent(kevt);
+ await nextAnimationFrame();
+ assert.containsNone(
+ document.body,
+ '.o_Message',
+ "should still not have any message in mailing channel after pressing 'Enter' in text input of composer"
+ );
+});
+
+QUnit.test('rendering of inbox message', async function (assert) {
+ // AKU TODO: kinda message specific test
+ assert.expect(7);
+
+ this.data['mail.message'].records.push({
+ body: "not empty",
+ model: 'res.partner', // random existing model
+ needaction: true, // for message_fetch domain
+ needaction_partner_ids: [this.data.currentPartnerId], // for consistency
+ record_name: 'Refactoring', // random name, will be asserted in the test
+ res_id: 20, // random related id
+ });
+ await this.start();
+ 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 [REQUIRE FOCUS]', async function (assert) {
+ assert.expect(3);
+
+ // channel expected to be found in the sidebar, with the expected message_unread_counter
+ // and a random unique id that will be referenced in the test
+ this.data['mail.channel'].records.push({ id: 10, message_unread_counter: 1 });
+ this.data['mail.message'].records.push({
+ id: 12,
+ body: "not empty",
+ channel_ids: [10],
+ model: 'mail.channel',
+ res_id: 10,
+ });
+ await this.start();
+ assert.containsOnce(
+ document.body,
+ `.o_DiscussSidebar_item[data-thread-local-id="${
+ this.env.models['mail.thread'].findFromIdentifyingData({
+ id: 10,
+ model: 'mail.channel',
+ }).localId
+ }"]`,
+ "should have discuss sidebar item with the channel"
+ );
+ assert.hasClass(
+ document.querySelector(`
+ .o_DiscussSidebar_item[data-thread-local-id="${
+ this.env.models['mail.thread'].findFromIdentifyingData({
+ id: 10,
+ model: 'mail.channel',
+ }).localId
+ }"]
+ `),
+ 'o-unread',
+ "sidebar item of channel ID 10 should be unread"
+ );
+
+ await afterNextRender(() => this.afterEvent({
+ eventName: 'o-thread-last-seen-by-current-partner-message-id-changed',
+ func: () => {
+ document.querySelector(`
+ .o_DiscussSidebar_item[data-thread-local-id="${
+ this.env.models['mail.thread'].findFromIdentifyingData({
+ id: 10,
+ model: 'mail.channel',
+ }).localId
+ }"]
+ `).click();
+ },
+ message: "should wait until last seen by current partner message id changed",
+ predicate: ({ thread }) => {
+ return (
+ thread.id === 10 &&
+ thread.model === 'mail.channel' &&
+ thread.lastSeenByCurrentPartnerMessageId === 12
+ );
+ },
+ }));
+ assert.doesNotHaveClass(
+ document.querySelector(`
+ .o_DiscussSidebar_item[data-thread-local-id="${
+ this.env.models['mail.thread'].findFromIdentifyingData({
+ id: 10,
+ model: 'mail.channel',
+ }).localId
+ }"]
+ `),
+ '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();
+ 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 = {
+ body: "not empty",
+ id: 100,
+ needaction_partner_ids: [3],
+ model: 'res.partner',
+ 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'].findFromIdentifyingData({ id: 100 }).localId,
+ "should display newly received needaction message"
+ );
+
+ // simulate receiving another new needaction message
+ await afterNextRender(() => {
+ const data2 = {
+ body: "not empty",
+ id: 101,
+ needaction_partner_ids: [3],
+ model: 'res.partner',
+ 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'].findFromIdentifyingData({ 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'].findFromIdentifyingData({ id: 101 }).localId
+ }"]
+ `),
+ "should display 2nd needaction message"
+ );
+});
+
+QUnit.test('reply to message from inbox (message linked to document)', async function (assert) {
+ assert.expect(19);
+
+ // message that is expected to be found in Inbox
+ this.data['mail.message'].records.push({
+ body: "<p>Test</p>",
+ date: "2019-04-20 11:00:00",
+ id: 100, // random unique id, will be used to link notification to message
+ message_type: 'comment',
+ // needaction needs to be set here for message_fetch domain, because
+ // mocked models don't have computed fields
+ needaction: true,
+ model: 'res.partner',
+ record_name: 'Refactoring',
+ res_id: 20,
+ });
+ // notification to have message in Inbox
+ this.data['mail.notification'].records.push({
+ mail_message_id: 100, // id of related message
+ res_partner_id: this.data.currentPartnerId, // must be for current partner
+ });
+ await this.start({
+ async mockRPC(route, args) {
+ if (args.method === 'message_post') {
+ assert.step('message_post');
+ assert.strictEqual(
+ args.model,
+ 'res.partner',
+ "should post message to record with model 'res.partner'"
+ );
+ 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'"
+ );
+ }
+ 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'].findFromIdentifyingData({ 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")
+ );
+ await afterNextRender(() =>
+ document.querySelector('.o_Composer_buttonSend').click()
+ );
+ 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'].findFromIdentifyingData({ 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.test('load recent messages from thread (already loaded some old messages)', async function (assert) {
+ assert.expect(6);
+
+ // channel expected to be found in the sidebar,
+ // with a random unique id that will be referenced in the test
+ this.data['mail.channel'].records.push({ id: 20 });
+ for (let i = 0; i < 50; i++) {
+ this.data['mail.message'].records.push({
+ body: "not empty",
+ channel_ids: [20], // id of related channel
+ id: 100 + i, // random unique id, will be referenced in the test
+ model: 'mail.channel', // expected value to link message to channel
+ // needaction needs to be set here for message_fetch domain, because
+ // mocked models don't have computed fields
+ needaction: i === 0,
+ // the goal is to have only the first (oldest) message in Inbox
+ needaction_partner_ids: i === 0 ? [this.data.currentPartnerId] : [],
+ res_id: 20, // id of related channel
+ });
+ }
+ await this.start();
+ assert.strictEqual(
+ document.querySelectorAll('.o_Message').length,
+ 1,
+ "Inbox should have a single message initially"
+ );
+ assert.strictEqual(
+ document.querySelector('.o_Message').dataset.messageLocalId,
+ this.env.models['mail.message'].findFromIdentifyingData({ id: 100 }).localId,
+ "the only message initially should be the one marked as 'needaction'"
+ );
+
+ await this.afterEvent({
+ eventName: 'o-component-message-list-scrolled',
+ func: () => {
+ document.querySelector(`
+ .o_DiscussSidebar_item[data-thread-local-id="${
+ this.env.models['mail.thread'].findFromIdentifyingData({
+ id: 20,
+ model: 'mail.channel',
+ }).localId
+ }"]
+ `).click();
+ },
+ message: "should wait until channel scrolled to bottom after opening it from the discuss sidebar",
+ predicate: ({ scrollTop, thread }) => {
+ const messageList = document.querySelector('.o_ThreadView_messageList');
+ return (
+ thread &&
+ thread.model === 'mail.channel' &&
+ thread.id === 20 &&
+ scrollTop === messageList.scrollHeight - messageList.clientHeight
+ );
+ },
+ });
+ assert.strictEqual(
+ document.querySelectorAll('.o_Message').length,
+ 31,
+ `should display 31 messages inside the channel after clicking on it (the previously known
+ message from Inbox and the 30 most recent messages that have been fetched)`
+ );
+ assert.strictEqual(
+ document.querySelectorAll(`
+ .o_Message[data-message-local-id="${
+ this.env.models['mail.message'].findFromIdentifyingData({ id: 100 }).localId
+ }"]
+ `).length,
+ 1,
+ "should display the message from Inbox inside the channel too"
+ );
+
+ await this.afterEvent({
+ eventName: 'o-thread-view-hint-processed',
+ func: () => document.querySelector('.o_Discuss_thread .o_ThreadView_messageList').scrollTop = 0,
+ message: "should wait until channel 20 loaded more messages after scrolling to top",
+ predicate: ({ hint, threadViewer }) => {
+ return (
+ hint.type === 'more-messages-loaded' &&
+ threadViewer.thread.model === 'mail.channel' &&
+ threadViewer.thread.id === 20
+ );
+ },
+ });
+ assert.strictEqual(
+ document.querySelectorAll('.o_Message').length,
+ 50,
+ "should display 50 messages inside the channel after scrolling to load more (all messages fetched)"
+ );
+ assert.strictEqual(
+ document.querySelectorAll(`
+ .o_Message[data-message-local-id="${
+ this.env.models['mail.message'].findFromIdentifyingData({ id: 100 }).localId
+ }"]
+ `).length,
+ 1,
+ "should still display the message from Inbox inside the channel too"
+ );
+});
+
+QUnit.test('messages marked as read move to "History" mailbox', async function (assert) {
+ assert.expect(10);
+
+ // channel expected to be found in the sidebar
+ // with a random unique id that will be referenced in the test
+ this.data['mail.channel'].records.push({ id: 20 });
+ // expected messages
+ this.data['mail.message'].records.push(
+ {
+ body: "not empty",
+ id: 100, // random unique id, useful to link notification
+ model: 'mail.channel', // value to link message to channel
+ // needaction needs to be set here for message_fetch domain, because
+ // mocked models don't have computed fields
+ needaction: true,
+ res_id: 20, // id of related channel
+ },
+ {
+ body: "not empty",
+ id: 101, // random unique id, useful to link notification
+ model: 'mail.channel', // value to link message to channel
+ // needaction needs to be set here for message_fetch domain, because
+ // mocked models don't have computed fields
+ needaction: true,
+ res_id: 20, // id of related channel
+ }
+ );
+ this.data['mail.notification'].records.push(
+ // notification to have first message in inbox
+ {
+ mail_message_id: 100, // id of related message
+ res_partner_id: this.data.currentPartnerId, // must be for current partner
+ },
+ // notification to have second message in inbox
+ {
+ mail_message_id: 101, // id of related message
+ res_partner_id: this.data.currentPartnerId, // must be for current partner
+ }
+ );
+ await this.start({
+ discuss: {
+ params: {
+ default_active_id: 'mail.box_history',
+ },
+ },
+ });
+ 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('mark a single message as read should only move this message to "History" mailbox', async function (assert) {
+ assert.expect(9);
+
+ this.data['mail.message'].records.push(
+ {
+ body: "not empty",
+ id: 1,
+ needaction: true,
+ needaction_partner_ids: [this.data.currentPartnerId],
+ },
+ {
+ body: "not empty",
+ id: 2,
+ needaction: true,
+ needaction_partner_ids: [this.data.currentPartnerId],
+ }
+ );
+ this.data['mail.notification'].records.push(
+ {
+ mail_message_id: 1,
+ res_partner_id: this.data.currentPartnerId,
+ },
+ {
+ mail_message_id: 2,
+ res_partner_id: this.data.currentPartnerId,
+ }
+ );
+ await this.start({
+ discuss: {
+ params: {
+ default_active_id: 'mail.box_history',
+ },
+ },
+ });
+ assert.hasClass(
+ document.querySelector(`
+ .o_DiscussSidebar_item[data-thread-local-id="${
+ this.env.messaging.history.localId
+ }"]
+ `),
+ 'o-active',
+ "history mailbox should initially be the active thread"
+ );
+ assert.containsOnce(
+ document.body,
+ '.o_MessageList_empty',
+ "history mailbox should initially be empty"
+ );
+
+ await afterNextRender(() =>
+ document.querySelector(`
+ .o_DiscussSidebar_item[data-thread-local-id="${
+ this.env.messaging.inbox.localId
+ }"]
+ `).click()
+ );
+ assert.hasClass(
+ document.querySelector(`
+ .o_DiscussSidebar_item[data-thread-local-id="${
+ this.env.messaging.inbox.localId
+ }"]
+ `),
+ 'o-active',
+ "inbox mailbox should be active thread after clicking on it"
+ );
+ assert.containsN(
+ document.body,
+ '.o_Message',
+ 2,
+ "inbox mailbox should have 2 messages"
+ );
+
+ await afterNextRender(() =>
+ document.querySelector(`
+ .o_Message[data-message-local-id="${
+ this.env.models['mail.message'].findFromIdentifyingData({ id: 1 }).localId
+ }"] .o_Message_commandMarkAsRead
+ `).click()
+ );
+ assert.containsOnce(
+ document.body,
+ '.o_Message',
+ "inbox mailbox should have one less message after clicking mark as read"
+ );
+ assert.containsOnce(
+ document.body,
+ `.o_Message[data-message-local-id="${
+ this.env.models['mail.message'].findFromIdentifyingData({ id: 2 }).localId
+ }"]`,
+ "message still in inbox should be the one not marked as read"
+ );
+
+ await afterNextRender(() =>
+ document.querySelector(`
+ .o_DiscussSidebar_item[data-thread-local-id="${
+ this.env.messaging.history.localId
+ }"]
+ `).click()
+ );
+ assert.hasClass(
+ document.querySelector(`
+ .o_DiscussSidebar_item[data-thread-local-id="${
+ this.env.messaging.history.localId
+ }"]
+ `),
+ 'o-active',
+ "history mailbox should be active after clicking on it"
+ );
+ assert.containsOnce(
+ document.body,
+ '.o_Message',
+ "history mailbox should have only 1 message after mark as read"
+ );
+ assert.containsOnce(
+ document.body,
+ `.o_Message[data-message-local-id="${
+ this.env.models['mail.message'].findFromIdentifyingData({ id: 1 }).localId
+ }"]`,
+ "message moved in history should be the one marked as read"
+ );
+});
+
+QUnit.test('all messages in "Inbox" in "History" after marked all as read', async function (assert) {
+ assert.expect(2);
+
+ const messageOffset = 200;
+ for (let id = messageOffset; id < messageOffset + 40; id++) {
+ // message expected to be found in Inbox
+ this.data['mail.message'].records.push({
+ body: "not empty",
+ id, // will be used to link notification to message
+ // needaction needs to be set here for message_fetch domain, because
+ // mocked models don't have computed fields
+ needaction: true,
+ });
+ // notification to have message in Inbox
+ this.data['mail.notification'].records.push({
+ mail_message_id: id, // id of related message
+ res_partner_id: this.data.currentPartnerId, // must be for current partner
+ });
+
+ }
+ await this.start({
+ waitUntilEvent: {
+ eventName: 'o-component-message-list-scrolled',
+ message: "should wait until inbox scrolled to its last message initially",
+ predicate: ({ orderedMessages, scrollTop, thread }) => {
+ const messageList = document.querySelector(`.o_Discuss_thread .o_ThreadView_messageList`);
+ return (
+ thread &&
+ thread.model === 'mail.box' &&
+ thread.id === 'inbox' &&
+ orderedMessages.length === 30 &&
+ scrollTop === messageList.scrollHeight - messageList.clientHeight
+ );
+ },
+ },
+ });
+
+ await afterNextRender(async () => {
+ const markAllReadButton = document.querySelector('.o_widget_Discuss_controlPanelButtonMarkAllRead');
+ markAllReadButton.click();
+ });
+ assert.containsNone(
+ document.body,
+ '.o_Message',
+ "there should no message in Inbox anymore"
+ );
+
+ await this.afterEvent({
+ eventName: 'o-component-message-list-scrolled',
+ func: () => {
+ document.querySelector(`
+ .o_DiscussSidebarItem[data-thread-local-id="${
+ this.env.messaging.history.localId
+ }"]
+ `).click();
+ },
+ message: "should wait until history scrolled to its last message after opening it from the discuss sidebar",
+ predicate: ({ orderedMessages, scrollTop, thread }) => {
+ const messageList = document.querySelector('.o_MessageList');
+ return (
+ thread &&
+ thread.model === 'mail.box' &&
+ thread.id === 'history' &&
+ orderedMessages.length === 30 &&
+ scrollTop === messageList.scrollHeight - messageList.clientHeight
+ );
+ },
+ });
+
+ // simulate a scroll to top to load more messages
+ await this.afterEvent({
+ eventName: 'o-thread-view-hint-processed',
+ func: () => document.querySelector('.o_MessageList').scrollTop = 0,
+ message: "should wait until mailbox history loaded more messages after scrolling to top",
+ predicate: ({ hint, threadViewer }) => {
+ return (
+ hint.type === 'more-messages-loaded' &&
+ threadViewer.thread.model === 'mail.box' &&
+ threadViewer.thread.id === 'history'
+ );
+ },
+ });
+ assert.containsN(
+ document.body,
+ '.o_Message',
+ 40,
+ "there should be 40 messages in History"
+ );
+});
+
+QUnit.test('receive new chat message: out of odoo focus (notification, channel)', async function (assert) {
+ assert.expect(4);
+
+ // channel expected to be found in the sidebar
+ // with a random unique id that will be referenced in the test
+ this.data['mail.channel'].records.push({ id: 20, channel_type: 'chat' });
+ 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 = {
+ channel_ids: [20],
+ id: 126,
+ model: 'mail.channel',
+ 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 chat message: out of odoo focus (notification, chat)', async function (assert) {
+ assert.expect(4);
+
+ // chat expected to be found in the sidebar with the proper channel_type
+ // and a random unique id that will be referenced in the test
+ this.data['mail.channel'].records.push({ channel_type: "chat", id: 10 });
+ 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 = {
+ channel_ids: [10],
+ id: 126,
+ model: 'mail.channel',
+ 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 chat messages: out of odoo focus (tab title)', async function (assert) {
+ assert.expect(12);
+
+ let step = 0;
+ // channel and chat expected to be found in the sidebar
+ // with random unique id and name that will be referenced in the test
+ this.data['mail.channel'].records.push(
+ { channel_type: 'chat', id: 20, public: 'private' },
+ { channel_type: 'chat', id: 10, public: 'private' },
+ );
+ 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 chat 20 with odoo focused
+ await afterNextRender(() => {
+ const messageData1 = {
+ channel_ids: [20],
+ id: 126,
+ model: 'mail.channel',
+ 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 10 with odoo focused
+ await afterNextRender(() => {
+ const messageData2 = {
+ channel_ids: [10],
+ id: 127,
+ model: 'mail.channel',
+ 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 10 with odoo focused
+ await afterNextRender(() => {
+ const messageData3 = {
+ channel_ids: [10],
+ id: 128,
+ model: 'mail.channel',
+ res_id: 10,
+ };
+ const notifications3 = [[['my-db', 'mail.channel', 10], 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);
+
+ // expected correspondent, with a random unique id that will be used to link
+ // partner to chat and a random name that will be asserted in the test
+ this.data['res.partner'].records.push({ id: 7, name: "Demo User" });
+ this.data['mail.channel'].records.push(
+ // channel expected to be found in the sidebar
+ {
+ id: 20, // random unique id, will be referenced in the test
+ name: "General", // random name, will be asserted in the test
+ },
+ // chat expected to be found in the sidebar
+ {
+ channel_type: 'chat', // testing a chat is the goal of the test
+ id: 10, // random unique id, will be referenced in the test
+ members: [this.data.currentPartnerId, 7], // expected partners
+ public: 'private', // expected value for testing a chat
+ }
+ );
+ 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('mark channel as seen if last message is visible when switching channels when the previous channel had a more recent last message than the current channel [REQUIRE FOCUS]', async function (assert) {
+ assert.expect(1);
+
+ this.data['mail.channel'].records.push(
+ { id: 10, message_unread_counter: 1, name: 'Bla' },
+ { id: 11, message_unread_counter: 1, name: 'Blu' },
+ );
+ this.data['mail.message'].records.push({
+ body: 'oldest message',
+ channel_ids: [10],
+ id: 10,
+ }, {
+ body: 'newest message',
+ channel_ids: [11],
+ id: 11,
+ });
+ await this.start({
+ discuss: {
+ context: {
+ active_id: 'mail.channel_11',
+ },
+ },
+ waitUntilEvent: {
+ eventName: 'o-thread-view-hint-processed',
+ message: "should wait until channel 11 loaded its messages initially",
+ predicate: ({ hint, threadViewer }) => {
+ return (
+ threadViewer.thread.model === 'mail.channel' &&
+ threadViewer.thread.id === 11 &&
+ hint.type === 'messages-loaded'
+ );
+ },
+ },
+ });
+ await afterNextRender(() => this.afterEvent({
+ eventName: 'o-thread-last-seen-by-current-partner-message-id-changed',
+ func: () => {
+ document.querySelector(`
+ .o_DiscussSidebar_item[data-thread-local-id="${
+ this.env.models['mail.thread'].findFromIdentifyingData({
+ id: 10,
+ model: 'mail.channel',
+ }).localId
+ }"]
+ `).click();
+ },
+ message: "should wait until last seen by current partner message id changed",
+ predicate: ({ thread }) => {
+ return (
+ thread.id === 10 &&
+ thread.model === 'mail.channel' &&
+ thread.lastSeenByCurrentPartnerMessageId === 10
+ );
+ },
+ }));
+ assert.doesNotHaveClass(
+ document.querySelector(`
+ .o_DiscussSidebar_item[data-thread-local-id="${
+ this.env.models['mail.thread'].findFromIdentifyingData({
+ id: 10,
+ model: 'mail.channel',
+ }).localId
+ }"]
+ `),
+ 'o-unread',
+ "sidebar item of channel ID 10 should no longer be unread"
+ );
+});
+
+QUnit.test('add custom filter should filter messages accordingly to selected filter', async function (assert) {
+ assert.expect(4);
+
+ this.data['mail.channel'].records.push({
+ id: 20,
+ name: "General"
+ });
+ await this.start({
+ async mockRPC(route, args) {
+ if (args.method === 'message_fetch') {
+ const domainsAsStr = args.kwargs.domain.map(domain => domain.join(''));
+ assert.step(`message_fetch:${domainsAsStr.join(',')}`);
+ }
+ return this._super(...arguments);
+ },
+ });
+ assert.verifySteps(['message_fetch:needaction=true'], "A message_fetch request should have been done for needaction messages as inbox is selected by default");
+
+ // Open filter menu of control panel and select a custom filter (id = 0, the only one available)
+ await toggleFilterMenu(document.body);
+ await toggleAddCustomFilter(document.body);
+ await applyFilter(document.body);
+ assert.verifySteps(['message_fetch:id=0,needaction=true'], "A message_fetch request should have been done for selected filter & domain of current thread (inbox)");
+});
+
+});
+});
+});
+
+});
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 00000000..b78faa67
--- /dev/null
+++ b/addons/mail/static/src/components/discuss_mobile_mailbox_selection/discuss_mobile_mailbox_selection.js
@@ -0,0 +1,95 @@
+odoo.define('mail/static/src/components/discuss_mobile_mailbox_selection/discuss_mobile_mailbox_selection.js', function (require) {
+'use strict';
+
+const useShouldUpdateBasedOnProps = require('mail/static/src/component_hooks/use_should_update_based_on_props/use_should_update_based_on_props.js');
+const useStore = require('mail/static/src/component_hooks/use_store/use_store.js');
+
+const { Component } = owl;
+
+class DiscussMobileMailboxSelection extends Component {
+
+ /**
+ * @override
+ */
+ constructor(...args) {
+ super(...args);
+ useShouldUpdateBasedOnProps();
+ 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 { mailboxLocalId } = ev.currentTarget.dataset;
+ const mailbox = this.env.models['mail.thread'].get(mailboxLocalId);
+ if (!mailbox) {
+ return;
+ }
+ mailbox.open();
+ }
+
+}
+
+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 00000000..b620e2f1
--- /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 00000000..e996a203
--- /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_mobile_mailbox_selection/discuss_mobile_mailbox_selection_tests.js b/addons/mail/static/src/components/discuss_mobile_mailbox_selection/discuss_mobile_mailbox_selection_tests.js
new file mode 100644
index 00000000..0d145c13
--- /dev/null
+++ b/addons/mail/static/src/components/discuss_mobile_mailbox_selection/discuss_mobile_mailbox_selection_tests.js
@@ -0,0 +1,130 @@
+odoo.define('mail/static/src/components/discuss_mobile_mailbox_selection/discuss_mobile_mailbox_selection_tests.js', function (require) {
+'use strict';
+
+const {
+ afterEach,
+ afterNextRender,
+ beforeEach,
+ start,
+} = require('mail/static/src/utils/test_utils.js');
+
+QUnit.module('mail', {}, function () {
+QUnit.module('components', {}, function () {
+QUnit.module('discuss_mobile_mailbox_selection', {}, function () {
+QUnit.module('discuss_mobile_mailbox_selection_tests.js', {
+ beforeEach() {
+ beforeEach(this);
+
+ this.start = async params => {
+ const { env, widget } = await start(Object.assign(
+ {
+ autoOpenDiscuss: true,
+ data: this.data,
+ env: {
+ browser: {
+ innerHeight: 640,
+ innerWidth: 360,
+ },
+ device: {
+ isMobile: true,
+ },
+ },
+ hasDiscuss: true,
+ },
+ params,
+ ));
+ this.env = env;
+ this.widget = widget;
+ };
+ },
+ afterEach() {
+ afterEach(this);
+ },
+});
+
+QUnit.test('select another mailbox', async function (assert) {
+ assert.expect(7);
+
+ await this.start();
+ assert.containsOnce(
+ document.body,
+ '.o_Discuss',
+ "should display discuss initially"
+ );
+ assert.hasClass(
+ document.querySelector('.o_Discuss'),
+ 'o-mobile',
+ "discuss should be opened in mobile mode"
+ );
+ assert.containsOnce(
+ document.body,
+ '.o_Discuss_thread',
+ "discuss should display a thread initially"
+ );
+ assert.strictEqual(
+ document.querySelector('.o_Discuss_thread').dataset.threadLocalId,
+ this.env.messaging.inbox.localId,
+ "inbox mailbox should be opened initially"
+ );
+ assert.containsOnce(
+ document.body,
+ `.o_DiscussMobileMailboxSelection_button[
+ data-mailbox-local-id="${this.env.messaging.starred.localId}"
+ ]`,
+ "should have a button to open starred mailbox"
+ );
+
+ await afterNextRender(() =>
+ document.querySelector(`.o_DiscussMobileMailboxSelection_button[
+ data-mailbox-local-id="${this.env.messaging.starred.localId}"]
+ `).click()
+ );
+ assert.containsOnce(
+ document.body,
+ '.o_Discuss_thread',
+ "discuss should still have a thread after clicking on starred mailbox"
+ );
+ assert.strictEqual(
+ document.querySelector('.o_Discuss_thread').dataset.threadLocalId,
+ this.env.messaging.starred.localId,
+ "starred mailbox should be opened after clicking on it"
+ );
+});
+
+QUnit.test('auto-select "Inbox" when discuss had channel as active thread', async function (assert) {
+ assert.expect(3);
+
+ this.data['mail.channel'].records.push({ id: 20 });
+ await this.start({
+ discuss: {
+ context: {
+ active_id: 20,
+ },
+ }
+ });
+ assert.hasClass(
+ document.querySelector('.o_MobileMessagingNavbar_tab[data-tab-id="channel"]'),
+ 'o-active',
+ "'channel' tab should be active initially when loading discuss with channel id as active_id"
+ );
+
+ await afterNextRender(() => document.querySelector('.o_MobileMessagingNavbar_tab[data-tab-id="mailbox"]').click());
+ assert.hasClass(
+ document.querySelector('.o_MobileMessagingNavbar_tab[data-tab-id="mailbox"]'),
+ 'o-active',
+ "'mailbox' tab should be selected after click on mailbox tab"
+ );
+ assert.hasClass(
+ document.querySelector(`.o_DiscussMobileMailboxSelection_button[data-mailbox-local-id="${
+ this.env.messaging.inbox.localId
+ }"]`),
+ 'o-active',
+ "'Inbox' mailbox should be auto-selected after click on mailbox tab"
+ );
+});
+
+});
+});
+});
+
+});
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 00000000..d12d0353
--- /dev/null
+++ b/addons/mail/static/src/components/discuss_sidebar/discuss_sidebar.js
@@ -0,0 +1,308 @@
+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 useShouldUpdateBasedOnProps = require('mail/static/src/component_hooks/use_should_update_based_on_props/use_should_update_based_on_props.js');
+const useStore = require('mail/static/src/component_hooks/use_store/use_store.js');
+const useUpdate = require('mail/static/src/component_hooks/use_update/use_update.js');
+
+const { Component } = owl;
+const { useRef } = owl.hooks;
+
+class DiscussSidebar extends Component {
+
+ /**
+ * @override
+ */
+ constructor(...args) {
+ super(...args);
+ useShouldUpdateBasedOnProps();
+ useStore(
+ (...args) => this._useStoreSelector(...args),
+ { compareDepth: this._useStoreCompareDepth() }
+ );
+ useUpdate({ func: () => this._update() });
+ /**
+ * 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);
+ }
+
+ //--------------------------------------------------------------------------
+ // Public
+ //--------------------------------------------------------------------------
+
+ /**
+ * @returns {mail.discuss}
+ */
+ get discuss() {
+ return this.env.messaging && this.env.messaging.discuss;
+ }
+
+ /**
+ * @returns {string}
+ */
+ get FIND_OR_CREATE_CHANNEL() {
+ return this.env._t("Find or create a channel...");
+ }
+
+ /**
+ * @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) => {
+ if (c1.displayName && !c2.displayName) {
+ return -1;
+ } else if (!c1.displayName && c2.displayName) {
+ return 1;
+ } else if (c1.displayName && c2.displayName && c1.displayName !== c2.displayName) {
+ return c1.displayName.toLowerCase() < c2.displayName.toLowerCase() ? -1 : 1;
+ } else {
+ return c1.id - c2.id;
+ }
+ });
+ 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.discuss) {
+ return;
+ }
+ if (this._quickSearchInputRef.el) {
+ this._quickSearchInputRef.el.value = this.discuss.sidebarQuickSearchValue;
+ }
+ }
+
+ /**
+ * @private
+ * @returns {Object}
+ */
+ _useStoreCompareDepth() {
+ return {
+ allOrderedAndPinnedChats: 1,
+ allOrderedAndPinnedMailboxes: 1,
+ allOrderedAndPinnedMultiUserChannels: 1,
+ };
+ }
+
+ /**
+ * @private
+ * @param {Object} props
+ * @returns {Object}
+ */
+ _useStoreSelector(props) {
+ const discuss = this.env.messaging.discuss;
+ return {
+ allOrderedAndPinnedChats: this.quickSearchPinnedAndOrderedChats,
+ allOrderedAndPinnedMailboxes: this.orderedMailboxes,
+ allOrderedAndPinnedMultiUserChannels: this.quickSearchOrderedAndPinnedMultiUserChannels,
+ allPinnedChannelAmount:
+ this.env.models['mail.thread']
+ .all(thread =>
+ thread.isPinned &&
+ thread.model === 'mail.channel'
+ ).length,
+ discussIsAddingChannel: discuss && discuss.isAddingChannel,
+ discussIsAddingChat: discuss && discuss.isAddingChat,
+ discussSidebarQuickSearchValue: discuss && discuss.sidebarQuickSearchValue,
+ };
+ }
+
+ //--------------------------------------------------------------------------
+ // 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 00000000..3e49cddf
--- /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 00000000..4f9c10e5
--- /dev/null
+++ b/addons/mail/static/src/components/discuss_sidebar/discuss_sidebar.xml
@@ -0,0 +1,81 @@
+<?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"
+ customClass="'o_DiscussSidebar_newChannelAutocompleteSuggestions'"
+ isFocusOnMount="true"
+ isHtml="true"
+ placeholder="FIND_OR_CREATE_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 00000000..0226035c
--- /dev/null
+++ b/addons/mail/static/src/components/discuss_sidebar_item/discuss_sidebar_item.js
@@ -0,0 +1,220 @@
+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 useShouldUpdateBasedOnProps = require('mail/static/src/component_hooks/use_should_update_based_on_props/use_should_update_based_on_props.js');
+const useStore = require('mail/static/src/component_hooks/use_store/use_store.js');
+const { isEventHandled } = require('mail/static/src/utils/utils.js');
+
+const Dialog = require('web.Dialog');
+
+const { Component } = owl;
+
+class DiscussSidebarItem extends Component {
+
+ /**
+ * @override
+ */
+ constructor(...args) {
+ super(...args);
+ useShouldUpdateBasedOnProps();
+ useStore(props => {
+ const discuss = this.env.messaging.discuss;
+ const thread = this.env.models['mail.thread'].get(props.threadLocalId);
+ const correspondent = thread ? thread.correspondent : undefined;
+ return {
+ correspondentName: correspondent && correspondent.name,
+ discussIsRenamingThread: discuss && discuss.renamingThreads.includes(thread),
+ isDiscussThread: discuss && discuss.thread === thread,
+ starred: this.env.messaging.starred,
+ thread,
+ threadChannelType: thread && thread.channel_type,
+ threadCounter: thread && thread.counter,
+ threadDisplayName: thread && thread.displayName,
+ threadGroupBasedSubscription: thread && thread.group_based_subscription,
+ threadLocalMessageUnreadCounter: thread && thread.localMessageUnreadCounter,
+ threadMassMailing: thread && thread.mass_mailing,
+ threadMessageNeedactionCounter: thread && thread.message_needaction_counter,
+ threadModel: thread && thread.model,
+ };
+ });
+ }
+
+ //--------------------------------------------------------------------------
+ // 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.localMessageUnreadCounter;
+ }
+ 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) {
+ if (isEventHandled(ev, 'EditableText.click')) {
+ return;
+ }
+ this.thread.open();
+ }
+
+ /**
+ * 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 00000000..aebc4b9a
--- /dev/null
+++ b/addons/mail/static/src/components/discuss_sidebar_item/discuss_sidebar_item.scss
@@ -0,0 +1,109 @@
+// ------------------------------------------------------------------
+// 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;
+ flex: 0 0 auto;
+}
+
+.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;
+ white-space: nowrap;
+
+ &.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 00000000..74aace21
--- /dev/null
+++ b/addons/mail/static/src/components/discuss_sidebar_item/discuss_sidebar_item.xml
@@ -0,0 +1,63 @@
+<?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.localMessageUnreadCounter > 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 ? thread.correspondent.name : thread.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.localMessageUnreadCounter > 0 }">
+ <t t-esc="thread.displayName"/>
+ </div>
+ <t t-if="thread.mass_mailing">
+ <i class="fa fa-envelope-o" title="Messages are sent by email" role="img"/>
+ </t>
+ </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.localMessageUnreadCounter">
+ <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 00000000..dcbb7019
--- /dev/null
+++ b/addons/mail/static/src/components/drop_zone/drop_zone.js
@@ -0,0 +1,139 @@
+odoo.define('mail/static/src/components/drop_zone/drop_zone.js', function (require) {
+'use strict';
+
+const useShouldUpdateBasedOnProps = require('mail/static/src/component_hooks/use_should_update_based_on_props/use_should_update_based_on_props.js');
+
+const { Component, useState } = owl;
+
+class DropZone extends Component {
+
+ /**
+ * @override
+ */
+ constructor(...args) {
+ super(...args);
+ useShouldUpdateBasedOnProps();
+ 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 00000000..202e4ceb
--- /dev/null
+++ b/addons/mail/static/src/components/drop_zone/drop_zone.scss
@@ -0,0 +1,29 @@
+// ------------------------------------------------------------------
+// Layout
+// ------------------------------------------------------------------
+
+.o_DropZone {
+ display: flex;
+ position: absolute;
+ top: 0;
+ left: 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 00000000..b3db940f
--- /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 00000000..be7e7ddc
--- /dev/null
+++ b/addons/mail/static/src/components/editable_text/editable_text.js
@@ -0,0 +1,91 @@
+odoo.define('mail/static/src/components/editable_text/editable_text.js', function (require) {
+'use strict';
+
+const { markEventHandled } = require('mail/static/src/utils/utils.js');
+const useShouldUpdateBasedOnProps = require('mail/static/src/component_hooks/use_should_update_based_on_props/use_should_update_based_on_props.js');
+
+const { Component } = owl;
+
+class EditableText extends Component {
+
+ constructor(...args) {
+ super(...args);
+ useShouldUpdateBasedOnProps();
+ }
+
+ 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) {
+ markEventHandled(ev, 'EditableText.click');
+ 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 00000000..5e3aa52a
--- /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 00000000..a312eed4
--- /dev/null
+++ b/addons/mail/static/src/components/emojis_popover/emojis_popover.js
@@ -0,0 +1,78 @@
+odoo.define('mail/static/src/components/emojis_popover/emojis_popover.js', function (require) {
+'use strict';
+
+const emojis = require('mail.emojis');
+const useShouldUpdateBasedOnProps = require('mail/static/src/component_hooks/use_should_update_based_on_props/use_should_update_based_on_props.js');
+const useUpdate = require('mail/static/src/component_hooks/use_update/use_update.js');
+
+const { Component } = owl;
+
+class EmojisPopover extends Component {
+
+ /**
+ * @param {...any} args
+ */
+ constructor(...args) {
+ super(...args);
+ this.emojis = emojis;
+ useShouldUpdateBasedOnProps();
+ useUpdate({ func: () => this._update() });
+ }
+
+ //--------------------------------------------------------------------------
+ // Private
+ //--------------------------------------------------------------------------
+
+ /**
+ * @private
+ */
+ _update() {
+ this.trigger('o-popover-compute');
+ }
+
+ //--------------------------------------------------------------------------
+ // Public
+ //--------------------------------------------------------------------------
+
+ close() {
+ this.trigger('o-popover-close');
+ }
+
+ /**
+ * Returns whether the given node is self or a children of self.
+ *
+ * @param {Node} node
+ * @returns {boolean}
+ */
+ contains(node) {
+ if (!this.el) {
+ return false;
+ }
+ return this.el.contains(node);
+ }
+
+ //--------------------------------------------------------------------------
+ // Handlers
+ //--------------------------------------------------------------------------
+
+ /**
+ * @private
+ * @param {MouseEvent} ev
+ */
+ _onClickEmoji(ev) {
+ this.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 00000000..3a4559ae
--- /dev/null
+++ b/addons/mail/static/src/components/emojis_popover/emojis_popover.scss
@@ -0,0 +1,22 @@
+// ------------------------------------------------------------------
+// Layout
+// ------------------------------------------------------------------
+
+.o_EmojisPopover {
+ display: flex;
+ flex-flow: row wrap;
+ max-width: 200px;
+}
+
+.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 00000000..cac840bb
--- /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 00000000..4e57eadd
--- /dev/null
+++ b/addons/mail/static/src/components/file_uploader/file_uploader.js
@@ -0,0 +1,241 @@
+odoo.define('mail/static/src/components/file_uploader/file_uploader.js', function (require) {
+'use strict';
+
+const useShouldUpdateBasedOnProps = require('mail/static/src/component_hooks/use_should_update_based_on_props/use_should_update_based_on_props.js');
+
+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);
+ useShouldUpdateBasedOnProps({
+ compareDepth: {
+ attachmentLocalIds: 1,
+ newAttachmentExtraData: 3,
+ },
+ });
+ }
+
+ 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
+ //--------------------------------------------------------------------------
+
+ /**
+ * @deprecated
+ * @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.env.models['mail.attachment'].create(
+ Object.assign(
+ {
+ filename: file.name,
+ isTemporary: true,
+ name: file.name
+ },
+ this.props.newAttachmentExtraData
+ ),
+ );
+ }
+ }
+ /**
+ * @private
+ * @param {FileList|Array} files
+ * @returns {Promise}
+ */
+ async _performUpload(files) {
+ for (const file of files) {
+ const uploadingAttachment = this.env.models['mail.attachment'].find(attachment =>
+ attachment.isTemporary &&
+ attachment.filename === file.name
+ );
+ if (!uploadingAttachment) {
+ // Uploading attachment no longer exists.
+ // This happens when an uploading attachment is being deleted by user.
+ continue;
+ }
+ try {
+ const response = await this.env.browser.fetch('/web/binary/upload_attachment', {
+ method: 'POST',
+ body: this._createFormData(file),
+ signal: uploadingAttachment.uploadingAbortController.signal,
+ });
+ let html = await response.text();
+ const template = document.createElement('template');
+ template.innerHTML = html.trim();
+ window.eval(template.content.firstChild.textContent);
+ } catch (e) {
+ if (e.name !== 'AbortError') {
+ throw e;
+ }
+ }
+ }
+ }
+
+ /**
+ * @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.env.models['mail.attachment'].insert(
+ Object.assign(
+ {
+ filename,
+ id,
+ mimetype,
+ name,
+ size,
+ },
+ this.props.newAttachmentExtraData
+ ),
+ );
+ 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 00000000..32792313
--- /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 00000000..bf144037
--- /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 00000000..4bf528f1
--- /dev/null
+++ b/addons/mail/static/src/components/file_uploader/file_uploader_tests.js
@@ -0,0 +1,94 @@
+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,
+ beforeEach,
+ createRootComponent,
+ nextAnimationFrame,
+ start,
+} = require('mail/static/src/utils/test_utils.js');
+
+const {
+ file: {
+ createFile,
+ inputFiles,
+ },
+} = require('web.test_utils');
+
+QUnit.module('mail', {}, function () {
+QUnit.module('components', {}, function () {
+QUnit.module('file_uploader', {}, function () {
+QUnit.module('file_uploader_tests.js', {
+ beforeEach() {
+ beforeEach(this);
+ this.components = [];
+
+ this.createFileUploaderComponent = async otherProps => {
+ const props = Object.assign({ attachmentLocalIds: [] }, otherProps);
+ return createRootComponent(this, components.FileUploader, {
+ props,
+ target: this.widget.el,
+ });
+ };
+
+ this.start = async params => {
+ const { env, widget } = await start(Object.assign({}, params, {
+ data: this.data,
+ }));
+ this.env = env;
+ this.widget = widget;
+ };
+ },
+ afterEach() {
+ afterEach(this);
+ },
+});
+
+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 00000000..3c1808cb
--- /dev/null
+++ b/addons/mail/static/src/components/follow_button/follow_button.js
@@ -0,0 +1,93 @@
+odoo.define('mail/static/src/components/follow_button/follow_button.js', function (require) {
+'use strict';
+
+const useShouldUpdateBasedOnProps = require('mail/static/src/component_hooks/use_should_update_based_on_props/use_should_update_based_on_props.js');
+const useStore = require('mail/static/src/component_hooks/use_store/use_store.js');
+
+const { Component } = owl;
+const { useState } = owl.hooks;
+
+class FollowButton extends Component {
+ /**
+ * @override
+ */
+ constructor(...args) {
+ super(...args);
+ useShouldUpdateBasedOnProps();
+ 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 {
+ threadIsCurrentPartnerFollowing: thread && thread.isCurrentPartnerFollowing,
+ };
+ });
+ }
+
+ //--------------------------------------------------------------------------
+ // 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 00000000..36fb60e7
--- /dev/null
+++ b/addons/mail/static/src/components/follow_button/follow_button.scss
@@ -0,0 +1,27 @@
+// ------------------------------------------------------------------
+// Layout
+// ------------------------------------------------------------------
+
+.o_FollowButton {
+ display: flex;
+}
+
+// ------------------------------------------------------------------
+// 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 00000000..00fc8d65
--- /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 00000000..0c4553c6
--- /dev/null
+++ b/addons/mail/static/src/components/follow_button/follow_button_tests.js
@@ -0,0 +1,278 @@
+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,
+ afterNextRender,
+ beforeEach,
+ createRootComponent,
+ start,
+} = 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() {
+ beforeEach(this);
+
+ this.createFollowButtonComponent = async (thread, otherProps = {}) => {
+ const props = Object.assign({ threadLocalId: thread.localId }, otherProps);
+ await createRootComponent(this, components.FollowButton, {
+ props,
+ target: this.widget.el,
+ });
+ };
+
+ this.start = async params => {
+ const { env, widget } = await start(Object.assign({}, params, {
+ data: this.data,
+ }));
+ this.env = env;
+ this.widget = widget;
+ };
+ },
+ afterEach() {
+ afterEach(this);
+ },
+});
+
+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);
+
+ this.data['res.partner'].records.push({ id: 100, message_follower_ids: [1] });
+ this.data['mail.followers'].records.push({
+ id: 1,
+ is_active: true,
+ is_editable: true,
+ partner_id: this.data.currentPartnerId,
+ res_id: 100,
+ res_model: 'res.partner',
+ });
+ await this.start();
+ 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(7);
+
+ this.data['res.partner'].records.push({ id: 100, message_follower_ids: [1] });
+ this.data['mail.followers'].records.push({
+ id: 1,
+ is_active: true,
+ is_editable: true,
+ partner_id: this.data.currentPartnerId,
+ res_id: 100,
+ res_model: 'res.partner',
+ });
+ await this.start({
+ async mockRPC(route, args) {
+ if (route.includes('message_subscribe')) {
+ assert.step('rpc:message_subscribe');
+ } else if (route.includes('mail/read_followers')) {
+ assert.step('rpc:mail/read_followers');
+ }
+ 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:mail/read_followers',
+ ]);
+ 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);
+
+ this.data['res.partner'].records.push({ id: 100, message_follower_ids: [1] });
+ this.data['mail.followers'].records.push({
+ id: 1,
+ is_active: true,
+ is_editable: true,
+ partner_id: this.data.currentPartnerId,
+ res_id: 100,
+ res_model: 'res.partner',
+ });
+ await this.start({
+ async mockRPC(route, args) {
+ if (route.includes('message_unsubscribe')) {
+ assert.step('rpc:message_unsubscribe');
+ }
+ 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 00000000..bafcd88a
--- /dev/null
+++ b/addons/mail/static/src/components/follower/follower.js
@@ -0,0 +1,80 @@
+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 useShouldUpdateBasedOnProps = require('mail/static/src/component_hooks/use_should_update_based_on_props/use_should_update_based_on_props.js');
+const useStore = require('mail/static/src/component_hooks/use_store/use_store.js');
+
+const { Component } = owl;
+
+class Follower extends Component {
+
+ /**
+ * @override
+ */
+ constructor(...args) {
+ super(...args);
+ useShouldUpdateBasedOnProps();
+ 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.follower.openProfile();
+ }
+
+ /**
+ * @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 00000000..509a119f
--- /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 00000000..5cdc89d7
--- /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 or follower.displayName"/>
+ </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 00000000..28058fc9
--- /dev/null
+++ b/addons/mail/static/src/components/follower/follower_tests.js
@@ -0,0 +1,380 @@
+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 { makeDeferred } = require('mail/static/src/utils/deferred/deferred.js');
+const {
+ afterEach,
+ afterNextRender,
+ beforeEach,
+ createRootComponent,
+ start,
+} = 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() {
+ beforeEach(this);
+
+ this.createFollowerComponent = async (follower) => {
+ await createRootComponent(this, components.Follower, {
+ props: { followerLocalId: follower.localId },
+ target: this.widget.el,
+ });
+ };
+
+ this.start = async params => {
+ const { env, widget } = await start(Object.assign({}, params, {
+ data: this.data,
+ }));
+ this.env = env;
+ this.widget = widget;
+ };
+ },
+ afterEach() {
+ afterEach(this);
+ },
+});
+
+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',
+ });
+ 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();
+ 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,
+ 10,
+ "The redirect action should redirect to the right res id (10)"
+ );
+ 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'"
+ );
+ });
+ this.data['res.partner'].records.push({ id: 100 });
+ this.data['mail.channel'].records.push({ id: 10 });
+ await this.start({
+ env: { bus },
+ });
+ 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: 10, 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 openFormDef = makeDeferred();
+ 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'"
+ );
+ openFormDef.resolve();
+ });
+ this.data['res.partner'].records.push({ id: 100 });
+ await this.start({
+ env: { bus },
+ });
+ 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.messaging.currentPartner.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();
+ await openFormDef;
+ 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);
+
+ this.data['res.partner'].records.push({ id: 100, message_follower_ids: [2] });
+ this.data['mail.followers'].records.push({
+ id: 2,
+ is_active: true,
+ is_editable: true,
+ partner_id: this.data.currentPartnerId,
+ res_id: 100,
+ res_model: 'res.partner',
+ });
+ await this.start({
+ hasDialog: true,
+ async mockRPC(route, args) {
+ if (route.includes('/mail/read_subscription_data')) {
+ assert.step('fetch_subtypes');
+ }
+ return this._super(...arguments);
+ },
+ });
+ const thread = this.env.models['mail.thread'].create({
+ id: 100,
+ model: 'res.partner',
+ });
+ await thread.refreshFollowers();
+ await this.createFollowerComponent(thread.followers[0]);
+ 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"
+ );
+});
+
+QUnit.test('edit follower and close subtype dialog', async function (assert) {
+ assert.expect(6);
+
+ this.data['res.partner'].records.push({ id: 100 });
+ await this.start({
+ hasDialog: true,
+ async mockRPC(route, args) {
+ 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.messaging.currentPartner.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',
+ "dialog allowing to edit follower subtypes should have been created"
+ );
+
+ await afterNextRender(
+ () => document.querySelector('.o_FollowerSubtypeList_closeButton').click()
+ );
+ assert.containsNone(
+ document.body,
+ '.o_DialogManager_dialog',
+ "follower subtype dialog should be closed after clicking on close button"
+ );
+});
+
+});
+});
+});
+
+});
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 00000000..996ef1f5
--- /dev/null
+++ b/addons/mail/static/src/components/follower_list_menu/follower_list_menu.js
@@ -0,0 +1,154 @@
+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 useShouldUpdateBasedOnProps = require('mail/static/src/component_hooks/use_should_update_based_on_props/use_should_update_based_on_props.js');
+const useStore = require('mail/static/src/component_hooks/use_store/use_store.js');
+
+const { Component } = owl;
+const { useRef, useState } = owl.hooks;
+
+class FollowerListMenu extends Component {
+ /**
+ * @override
+ */
+ constructor(...args) {
+ super(...args);
+ useShouldUpdateBasedOnProps();
+ 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,
+ threadChannelType: thread && thread.channel_type,
+ };
+ }, {
+ 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 00000000..6e82134a
--- /dev/null
+++ b/addons/mail/static/src/components/follower_list_menu/follower_list_menu.scss
@@ -0,0 +1,17 @@
+// ------------------------------------------------------------------
+// Layout
+// ------------------------------------------------------------------
+
+.o_FollowerListMenu {
+ position: relative;
+}
+
+.o_FollowerListMenu_dropdown {
+ display: flex;
+ flex-flow: column;
+ 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 00000000..86b7f3a6
--- /dev/null
+++ b/addons/mail/static/src/components/follower_list_menu/follower_list_menu.xml
@@ -0,0 +1,38 @@
+<?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">
+ <t t-if="thread.channel_type !== 'chat'">
+ <a class="o_FollowerListMenu_addFollowersButton dropdown-item" href="#" role="menuitem" t-on-click="_onClickAddFollowers">
+ Add Followers
+ </a>
+ </t>
+ <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 00000000..cf6fcf24
--- /dev/null
+++ b/addons/mail/static/src/components/follower_list_menu/follower_list_menu_tests.js
@@ -0,0 +1,424 @@
+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,
+ afterNextRender,
+ beforeEach,
+ createRootComponent,
+ start,
+} = 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() {
+ beforeEach(this);
+
+ this.createFollowerListMenuComponent = async (thread, otherProps = {}) => {
+ const props = Object.assign({ threadLocalId: thread.localId }, otherProps);
+ await createRootComponent(this, components.FollowerListMenu, {
+ props,
+ target: this.widget.el,
+ });
+ };
+
+ this.start = async params => {
+ const { env, widget } = await start(Object.assign({}, params, {
+ data: this.data,
+ }));
+ this.env = env;
+ this.widget = widget;
+ };
+ },
+ afterEach() {
+ afterEach(this);
+ },
+});
+
+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(16);
+
+ 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'"
+ );
+ const partner = this.data['res.partner'].records.find(
+ partner => partner.id === payload.action.context.default_res_id
+ );
+ partner.message_follower_ids.push(1);
+ payload.options.on_close();
+ });
+ this.data['res.partner'].records.push({ id: 100 });
+ this.data['mail.followers'].records.push({
+ partner_id: 42,
+ email: "bla@bla.bla",
+ id: 1,
+ is_active: true,
+ is_editable: true,
+ name: "François Perusse",
+ res_id: 100,
+ res_model: 'res.partner',
+ });
+ await this.start({
+ env: { bus },
+ });
+ 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',
+ ]);
+ 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(16);
+
+ 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'"
+ );
+ const partner = this.data['res.partner'].records.find(
+ partner => partner.id === payload.action.context.default_res_id
+ );
+ partner.message_follower_ids.push(1);
+ payload.options.on_close();
+ });
+ this.data['res.partner'].records.push({ id: 100 });
+ this.data['mail.followers'].records.push({
+ channel_id: 42,
+ email: "bla@bla.bla",
+ id: 1,
+ is_active: true,
+ is_editable: true,
+ name: "Supa channel",
+ res_id: 100,
+ res_model: 'res.partner',
+ });
+ await this.start({
+ env: { bus },
+ });
+ 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',
+ ]);
+ 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('message_unsubscribe')) {
+ assert.step('message_unsubscribe');
+ assert.deepEqual(
+ args.args,
+ [[100], [self.env.messaging.currentPartner.id], []],
+ "message_unsubscribe should be called with right argument"
+ );
+ }
+ 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.messaging.currentPartner.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 longer 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 00000000..ae3ba321
--- /dev/null
+++ b/addons/mail/static/src/components/follower_subtype/follower_subtype.js
@@ -0,0 +1,71 @@
+odoo.define('mail/static/src/components/follower_subtype/follower_subtype.js', function (require) {
+'use strict';
+
+const useShouldUpdateBasedOnProps = require('mail/static/src/component_hooks/use_should_update_based_on_props/use_should_update_based_on_props.js');
+const useStore = require('mail/static/src/component_hooks/use_store/use_store.js');
+
+const { Component } = owl;
+
+class FollowerSubtype extends Component {
+
+ /**
+ * @override
+ */
+ constructor(...args) {
+ super(...args);
+ useShouldUpdateBasedOnProps();
+ 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 00000000..3be0ad46
--- /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 00000000..b2380009
--- /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 00000000..7c802a7a
--- /dev/null
+++ b/addons/mail/static/src/components/follower_subtype/follower_subtype_tests.js
@@ -0,0 +1,233 @@
+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,
+ afterNextRender,
+ beforeEach,
+ createRootComponent,
+ start,
+} = 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() {
+ beforeEach(this);
+
+ this.createFollowerSubtypeComponent = async ({ follower, followerSubtype }) => {
+ const props = {
+ followerLocalId: follower.localId,
+ followerSubtypeLocalId: followerSubtype.localId,
+ };
+ await createRootComponent(this, components.FollowerSubtype, {
+ props,
+ target: this.widget.el,
+ });
+ };
+
+ this.start = async params => {
+ const { env, widget } = await start(Object.assign({}, params, {
+ data: this.data,
+ }));
+ this.env = env;
+ this.widget = widget;
+ };
+ },
+ afterEach() {
+ afterEach(this);
+ },
+});
+
+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 00000000..d5cca5b5
--- /dev/null
+++ b/addons/mail/static/src/components/follower_subtype_list/follower_subtype_list.js
@@ -0,0 +1,89 @@
+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 useShouldUpdateBasedOnProps = require('mail/static/src/component_hooks/use_should_update_based_on_props/use_should_update_based_on_props.js');
+const useStore = require('mail/static/src/component_hooks/use_store/use_store.js');
+
+const { Component, QWeb } = owl;
+
+class FollowerSubtypeList extends Component {
+
+ /**
+ * @override
+ */
+ constructor(...args) {
+ super(...args);
+ useShouldUpdateBasedOnProps();
+ 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 00000000..82aef9fc
--- /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 00000000..ad477d9d
--- /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="o_FollowerSubtypeList_closeButton 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 00000000..32c334be
--- /dev/null
+++ b/addons/mail/static/src/components/mail_template/mail_template.js
@@ -0,0 +1,81 @@
+odoo.define('mail/static/src/components/mail_template/mail_template.js', function (require) {
+'use strict';
+
+const useShouldUpdateBasedOnProps = require('mail/static/src/component_hooks/use_should_update_based_on_props/use_should_update_based_on_props.js');
+const useStore = require('mail/static/src/component_hooks/use_store/use_store.js');
+
+const { Component } = owl;
+
+class MailTemplate extends Component {
+
+ /**
+ * @override
+ */
+ constructor(...args) {
+ super(...args);
+ useShouldUpdateBasedOnProps();
+ 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 00000000..7800ab62
--- /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 00000000..48f7c050
--- /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 00000000..a357c024
--- /dev/null
+++ b/addons/mail/static/src/components/message/message.js
@@ -0,0 +1,680 @@
+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 useShouldUpdateBasedOnProps = require('mail/static/src/component_hooks/use_should_update_based_on_props/use_should_update_based_on_props.js');
+const useStore = require('mail/static/src/component_hooks/use_store/use_store.js');
+const useUpdate = require('mail/static/src/component_hooks/use_update/use_update.js');
+
+const { _lt } = require('web.core');
+const { format } = require('web.field_utils');
+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");
+const { isEventHandled, markEventHandled } = require('mail/static/src/utils/utils.js');
+
+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,
+ });
+ useShouldUpdateBasedOnProps();
+ 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 threadView = this.env.models['mail.thread_view'].get(props.threadViewLocalId);
+ const thread = threadView ? threadView.thread : undefined;
+ return {
+ attachments: message
+ ? message.attachments.map(attachment => attachment.__state)
+ : [],
+ author,
+ authorAvatarUrl: author && author.avatarUrl,
+ authorImStatus: author && author.im_status,
+ authorNameOrDisplayName: author && author.nameOrDisplayName,
+ correspondent: thread && thread.correspondent,
+ hasMessageCheckbox: message ? message.hasCheckbox : false,
+ isDeviceMobile: this.env.messaging.device.isMobile,
+ isMessageChecked: message && threadView
+ ? message.isChecked(thread, threadView.stringifiedDomain)
+ : false,
+ message: message ? message.__state : undefined,
+ notifications: message ? message.notifications.map(notif => notif.__state) : [],
+ originThread,
+ originThreadModel: originThread && originThread.model,
+ originThreadName: originThread && originThread.name,
+ originThreadUrl: originThread && originThread.url,
+ partnerRoot,
+ thread,
+ threadHasSeenIndicators: thread && thread.hasSeenIndicators,
+ threadMassMailing: thread && thread.mass_mailing,
+ };
+ }, {
+ compareDepth: {
+ attachments: 1,
+ notifications: 1,
+ },
+ });
+ useUpdate({ func: () => this._update() });
+ /**
+ * The intent of the reply button depends on the last rendered state.
+ */
+ this._wasSelected;
+ /**
+ * Value of the last rendered prettyBody. Useful to compare to new value
+ * to decide if it has to be updated.
+ */
+ this._lastPrettyBody;
+ /**
+ * Reference to element containing the prettyBody. Useful to be able to
+ * replace prettyBody with new value in JS (which is faster than t-raw).
+ */
+ this._prettyBodyRef = useRef('prettyBody');
+ /**
+ * 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() {}
+
+ 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 this.message.author.avatarUrl;
+ } 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());
+ }
+
+ /**
+ * Determines whether author open chat feature is enabled on message.
+ *
+ * @returns {boolean}
+ */
+ get hasAuthorOpenChat() {
+ if (!this.message.author) {
+ return false;
+ }
+ if (
+ this.threadView &&
+ this.threadView.thread &&
+ this.threadView.thread.correspondent === this.message.author
+ ) {
+ return false;
+ }
+ return true;
+ }
+
+ /**
+ * 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
+ );
+ }
+
+ /**
+ * @returns {mail.message}
+ */
+ get message() {
+ return this.env.models['mail.message'].get(this.props.messageLocalId);
+ }
+ /**
+ * @returns {string}
+ */
+ get OPEN_CHAT() {
+ return this.env._t("Open chat");
+ }
+
+ /**
+ * 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_view}
+ */
+ get threadView() {
+ return this.env.models['mail.thread_view'].get(this.props.threadViewLocalId);
+ }
+
+ /**
+ * @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);
+ /**
+ * Maps tracked field type to a JS formatter. Tracking values are
+ * not always stored in the same field type as their origin type.
+ * Field types that are not listed here are not supported by
+ * tracking in Python. Also see `create_tracking_values` in Python.
+ */
+ switch (value.field_type) {
+ case 'boolean':
+ value.old_value = format.boolean(value.old_value, undefined, { forceString: true });
+ value.new_value = format.boolean(value.new_value, undefined, { forceString: true });
+ break;
+ /**
+ * many2one formatter exists but is expecting id/name_get or data
+ * object but only the target record name is known in this context.
+ *
+ * Selection formatter exists but requires knowing all
+ * possibilities and they are not given in this context.
+ */
+ case 'char':
+ case 'many2one':
+ case 'selection':
+ value.old_value = format.char(value.old_value);
+ value.new_value = format.char(value.new_value);
+ break;
+ case 'date':
+ if (value.old_value) {
+ value.old_value = moment.utc(value.old_value);
+ }
+ if (value.new_value) {
+ value.new_value = moment.utc(value.new_value);
+ }
+ value.old_value = format.date(value.old_value);
+ value.new_value = format.date(value.new_value);
+ break;
+ case 'datetime':
+ if (value.old_value) {
+ value.old_value = moment.utc(value.old_value);
+ }
+ if (value.new_value) {
+ value.new_value = moment.utc(value.new_value);
+ }
+ value.old_value = format.datetime(value.old_value);
+ value.new_value = format.datetime(value.new_value);
+ break;
+ case 'float':
+ value.old_value = format.float(value.old_value);
+ value.new_value = format.float(value.new_value);
+ break;
+ case 'integer':
+ value.old_value = format.integer(value.old_value);
+ value.new_value = format.integer(value.new_value);
+ break;
+ case 'monetary':
+ value.old_value = format.monetary(value.old_value, undefined, { forceString: true });
+ value.new_value = format.monetary(value.new_value, undefined, { forceString: true });
+ break;
+ case 'text':
+ value.old_value = format.text(value.old_value);
+ value.new_value = format.text(value.new_value);
+ break;
+ }
+ 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'.
+ *
+ * FIXME This method should be rewritten (task-2308951)
+ *
+ * @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_readMoreLess',
+ 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.message) {
+ return;
+ }
+ if (this._prettyBodyRef.el && this.message.prettyBody !== this._lastPrettyBody) {
+ this._prettyBodyRef.el.innerHTML = this.message.prettyBody;
+ this._lastPrettyBody = this.message.prettyBody;
+ }
+ // Remove all readmore before if any before reinsert them with _insertReadMoreLess.
+ // This is needed because _insertReadMoreLess is working with direct DOM mutations
+ // which are not sync with Owl.
+ if (this._contentRef.el) {
+ for (const el of [...this._contentRef.el.querySelectorAll(':scope .o_Message_readMoreLess')]) {
+ el.remove();
+ }
+ this._insertReadMoreLess($(this._contentRef.el));
+ this.env.messagingBus.trigger('o-component-message-read-more-less-inserted', {
+ message: this.message,
+ });
+ }
+ this._wasSelected = this.props.isSelected;
+ this.message.refreshDateFromNow();
+ clearInterval(this._intervalId);
+ this._intervalId = setInterval(() => {
+ this.message.refreshDateFromNow();
+ }, 60 * 1000);
+ }
+
+ //--------------------------------------------------------------------------
+ // Handlers
+ //--------------------------------------------------------------------------
+
+ /**
+ * @private
+ */
+ _onChangeCheckbox() {
+ this.message.toggleCheck(this.threadView.thread, this.threadView.stringifiedDomain);
+ }
+
+ /**
+ * @private
+ * @param {MouseEvent} ev
+ */
+ _onClick(ev) {
+ if (ev.target.closest('.o_channel_redirect')) {
+ this.env.messaging.openProfile({
+ id: Number(ev.target.dataset.oeId),
+ model: 'mail.channel',
+ });
+ // avoid following dummy href
+ ev.preventDefault();
+ return;
+ }
+ if (ev.target.tagName === 'A') {
+ if (ev.target.dataset.oeId && ev.target.dataset.oeModel) {
+ this.env.messaging.openProfile({
+ id: Number(ev.target.dataset.oeId),
+ model: ev.target.dataset.oeModel,
+ });
+ // avoid following dummy href
+ ev.preventDefault();
+ }
+ return;
+ }
+ if (
+ !isEventHandled(ev, 'Message.ClickAuthorAvatar') &&
+ !isEventHandled(ev, 'Message.ClickAuthorName') &&
+ !isEventHandled(ev, 'Message.ClickFailure')
+ ) {
+ this.state.isClicked = !this.state.isClicked;
+ }
+ }
+
+ /**
+ * @private
+ * @param {MouseEvent} ev
+ */
+ _onClickAuthorAvatar(ev) {
+ markEventHandled(ev, 'Message.ClickAuthorAvatar');
+ if (!this.hasAuthorOpenChat) {
+ return;
+ }
+ this.message.author.openChat();
+ }
+
+ /**
+ * @private
+ * @param {MouseEvent} ev
+ */
+ _onClickAuthorName(ev) {
+ markEventHandled(ev, 'Message.ClickAuthorName');
+ if (!this.message.author) {
+ return;
+ }
+ this.message.author.openProfile();
+ }
+
+ /**
+ * @private
+ * @param {MouseEvent} ev
+ */
+ _onClickFailure(ev) {
+ markEventHandled(ev, 'Message.ClickFailure');
+ 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) {
+ // avoid following dummy href
+ ev.preventDefault();
+ this.message.originThread.open();
+ }
+
+ /**
+ * @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) {
+ // Use this._wasSelected because this.props.isSelected might be changed
+ // by a global capture click handler (for example the one from Composer)
+ // before the current handler is executed. Indeed because it does a
+ // toggle it needs to take into account the value before the click.
+ if (this._wasSelected) {
+ this.env.messaging.discuss.clearReplyingToMessage();
+ } else {
+ 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: {
+ hasCheckbox: false,
+ hasMarkAsReadIcon: false,
+ hasReplyIcon: false,
+ isSelected: false,
+ isSquashed: false,
+ },
+ props: {
+ attachmentsDetailsMode: {
+ type: String,
+ optional: true,
+ validate: prop => ['auto', 'card', 'hover', 'none'].includes(prop),
+ },
+ hasCheckbox: Boolean,
+ hasMarkAsReadIcon: Boolean,
+ hasReplyIcon: Boolean,
+ isSelected: Boolean,
+ isSquashed: Boolean,
+ messageLocalId: String,
+ threadViewLocalId: {
+ 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 00000000..16d9c790
--- /dev/null
+++ b/addons/mail/static/src/components/message/message.scss
@@ -0,0 +1,381 @@
+// ------------------------------------------------------------------
+// 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 {
+ word-wrap: break-word;
+ word-break: break-word;
+
+ *:not(li):not(li div) {
+ // Message content can contain arbitrary HTML that might overflow and break
+ // the style without this rule.
+ // Lists are ignored because otherwise bullet style become hidden from overflow.
+ // It's acceptable not to manage overflow of these tags for the moment.
+ // It also excludes all div in li because 1st leaf and div child of list overflow
+ // may impact the bullet point (at least it does on Safari).
+ max-width: 100%;
+ overflow-x: auto;
+ }
+
+ img {
+ max-width: 100%;
+ height: auto;
+ }
+}
+
+.o_Message_core {
+ min-width: 0; // allows this flex child to shrink more than its content
+ margin-inline-end: map-get($spacers, 3);
+}
+
+.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_prettyBody {
+
+ > p:last-of-type {
+ margin-bottom: 0;
+ }
+
+}
+
+.o_Message_readMoreLess {
+ display: block;
+}
+
+.o_Message_seenIndicator {
+ margin-inline-end: map-get($spacers, 1);
+}
+
+.o_Message_sidebar {
+ flex: 0 0 $o-mail-message-sidebar-width;
+ max-width: $o-mail-message-sidebar-width;
+ 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, 2);
+}
+
+.o_Message_trackingValue {
+ display: flex;
+ align-items: center;
+ flex-wrap: wrap;
+}
+
+.o_Message_trackingValueItem {
+ margin-inline-end: map-get($spacers, 1);
+}
+
+// ------------------------------------------------------------------
+// Style
+// ------------------------------------------------------------------
+
+.o_Message {
+ background-color: white;
+
+ &:hover, &.o-clicked {
+
+ .o_Message_commands {
+ opacity: 1;
+ }
+
+ .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) {
+ &: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) {
+ opacity: 0;
+}
+
+.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_notificationIconClickable {
+ color: gray('600');
+ cursor: pointer;
+
+ &.o-error {
+ color: $red;
+ }
+}
+
+.o_Message_sidebarCommands {
+ display: none;
+}
+
+.o_Message_sidebarItem.o-message-squashed {
+ display: none;
+}
+
+.o_Message_subject {
+ font-style: italic;
+}
+
+// Used to hide buttons on rating emails in chatter
+// FIXME: should use a better approach for not having such buttons
+// in chatter of such messages, but keep having them in emails.
+.o_Message_content [summary~="o_mail_notification"] {
+ 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 00000000..32687ea6
--- /dev/null
+++ b/addons/mail/static/src/components/message/message.xml
@@ -0,0 +1,210 @@
+<?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 message.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: hasAuthorOpenChat, o_redirect: hasAuthorOpenChat }" t-att-src="avatar" t-on-click="_onClickAuthorAvatar" t-att-title="hasAuthorOpenChat ? OPEN_CHAT : ''" alt="Avatar"/>
+ <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,
+ }"
+ hasOpenChat="hasAuthorOpenChat"
+ 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': message.isStarred,
+ 'fa-star-o': !message.isStarred,
+ 'o-message-selected': props.isSelected,
+ 'o-message-starred': message.isStarred,
+ 'o-mobile': env.messaging.device.isMobile,
+ }" t-on-click="_onClickStar"
+ />
+ </t>
+ </div>
+ <t t-if="message.isCurrentPartnerAuthor and threadView and threadView.thread and threadView.thread.hasSeenIndicators">
+ <MessageSeenIndicator class="o_Message_seenIndicator o-message-squashed" messageLocalId="message.localId" threadLocalId="threadView.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 o_Message_authorRedirect o_redirect" t-on-click="_onClickAuthorName" title="Open profile">
+ <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="message.dateFromNow"/>
+ </div>
+ <t t-if="message.isCurrentPartnerAuthor and threadView and threadView.thread and threadView.thread.hasSeenIndicators">
+ <MessageSeenIndicator class="o_Message_seenIndicator" messageLocalId="message.localId" threadLocalId="threadView.thread.localId"/>
+ </t>
+ <t t-if="threadView and message.originThread and message.originThread !== threadView.thread">
+ <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" t-att-href="message.originThread.url" t-on-click="_onClickOriginThread"><t t-if="message.originThread.name">#<t t-esc="message.originThread.name"/></t><t t-else="">channel</t></a>)
+ </t>
+ <t t-else="">
+ on <a class="o_Message_originThreadLink" t-att-href="message.originThread.url" t-on-click="_onClickOriginThread"><t t-if="message.originThread.name"><t t-esc="message.originThread.name"/></t><t t-else="">document</t></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="threadView and message.originThread and message.originThread === threadView.thread and message.notifications.length > 0">
+ <t t-if="message.failureNotifications.length > 0">
+ <span class="o_Message_notificationIconClickable 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_notificationIconClickable">
+ <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': message.isStarred,
+ 'fa-star-o': !message.isStarred,
+ 'o-message-selected': props.isSelected,
+ 'o-message-starred': message.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="threadView and props.hasCheckbox and message.hasCheckbox">
+ <input class="o_Message_checkbox" type="checkbox" t-att-checked="message.isChecked(threadView.thread, threadView.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">
+ <div class="o_Message_prettyBody" t-ref="prettyBody"/><!-- message.prettyBody is inserted here from _update() -->
+ <t t-if="message.subtype_description and !message.isBodyEqualSubtypeDescription">
+ <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_trackingValueFieldName o_Message_trackingValueItem" t-esc="value.changed_field"/>
+ <t t-if="value.old_value">
+ <div class="o_Message_trackingValueOldValue o_Message_trackingValueItem" t-esc="value.old_value"/>
+ </t>
+ <div class="o_Message_trackingValueSeparator o_Message_trackingValueItem fa fa-long-arrow-right" title="Changed" role="img"/>
+ <t t-if="value.new_value">
+ <div class="o_Message_trackingValueNewValue o_Message_trackingValueItem" t-esc="value.new_value"/>
+ </t>
+ </div>
+ </li>
+ </t>
+ </ul>
+ </t>
+ </div>
+ <t t-if="message.subject and !message.isSubjectSimilarToOriginThreadName and threadView and threadView.thread and (threadView.thread.mass_mailing or [env.messaging.inbox, env.messaging.history].includes(threadView.thread))">
+ <p class="o_Message_subject">Subject: <t t-esc="message.subject"/></p>
+ </t>
+ <t t-if="message.attachments and message.attachments.length > 0">
+ <div class="o_Message_footer">
+ <AttachmentList
+ class="o_Message_attachmentList"
+ areAttachmentsDownloadable="true"
+ areAttachmentsEditable="message.author === env.messaging.currentPartner"
+ attachmentLocalIds="message.attachments.map(attachment => attachment.localId)"
+ attachmentsDetailsMode="props.attachmentsDetailsMode"
+ />
+ </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 00000000..67fa9b96
--- /dev/null
+++ b/addons/mail/static/src/components/message/message_tests.js
@@ -0,0 +1,1580 @@
+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 { makeDeferred } = require('mail/static/src/utils/deferred/deferred.js');
+const {
+ afterEach,
+ afterNextRender,
+ beforeEach,
+ createRootComponent,
+ nextAnimationFrame,
+ start,
+} = 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() {
+ beforeEach(this);
+
+ this.createMessageComponent = async (message, otherProps) => {
+ const props = Object.assign({ messageLocalId: message.localId }, otherProps);
+ await createRootComponent(this, components.Message, {
+ props,
+ target: this.widget.el,
+ });
+ };
+
+ this.start = async params => {
+ const { env, widget } = await start(Object.assign({}, params, {
+ data: this.data,
+ }));
+ this.env = env;
+ this.widget = widget;
+ };
+ },
+ afterEach() {
+ afterEach(this);
+ },
+});
+
+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'].findFromIdentifyingData({ 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_prettyBody`).innerHTML,
+ "<p>Test</p>",
+ "message should display the correct content"
+ );
+});
+
+QUnit.test('moderation: as author, moderated channel with pending moderation message', 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]],
+ });
+ await this.createMessageComponent(message);
+
+ assert.strictEqual(
+ document.querySelectorAll(`.o_Message_moderationPending.o-author`).length,
+ 1,
+ "should have the message pending moderation"
+ );
+});
+
+QUnit.test('moderation: as moderator, moderated channel with pending moderation message', async function (assert) {
+ assert.expect(9);
+
+ await this.start();
+ const thread = this.env.models['mail.thread'].create({
+ id: 20,
+ model: 'mail.channel',
+ moderators: [['link', this.env.messaging.currentPartner]],
+ });
+ 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]],
+ });
+ 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 threadViewer = this.env.models['mail.thread_viewer'].create({
+ hasThreadView: true,
+ thread: [['create', {
+ id: 11,
+ model: 'mail.channel',
+ }]],
+ });
+ 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" }]],
+ }]],
+ originThread: [['link', threadViewer.thread]],
+ });
+ await this.createMessageComponent(message, {
+ threadViewLocalId: threadViewer.threadView.localId
+ });
+
+ assert.containsOnce(
+ document.body,
+ '.o_Message',
+ "should display a message component"
+ );
+ assert.containsOnce(
+ document.body,
+ '.o_Message_notificationIconClickable',
+ "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_notificationIconClickable').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 openResendActionDef = makeDeferred();
+ 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"
+ );
+ openResendActionDef.resolve();
+ });
+
+ await this.start({ env: { bus } });
+ const threadViewer = this.env.models['mail.thread_viewer'].create({
+ hasThreadView: true,
+ thread: [['create', {
+ id: 11,
+ model: 'mail.channel',
+ }]],
+ });
+ const message = this.env.models['mail.message'].create({
+ id: 10,
+ message_type: 'email',
+ notifications: [['insert', {
+ id: 11,
+ notification_status: 'exception',
+ notification_type: 'email',
+ }]],
+ originThread: [['link', threadViewer.thread]],
+ });
+ await this.createMessageComponent(message, {
+ threadViewLocalId: threadViewer.threadView.localId
+ });
+
+ assert.containsOnce(
+ document.body,
+ '.o_Message',
+ "should display a message component"
+ );
+ assert.containsOnce(
+ document.body,
+ '.o_Message_notificationIconClickable',
+ "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"
+ );
+ document.querySelector('.o_Message_notificationIconClickable').click();
+ await openResendActionDef;
+ 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'].insert({
+ id: this.env.messaging.currentPartner.id,
+ display_name: "Demo User",
+ });
+ const thread = this.env.models['mail.thread'].create({
+ channel_type: 'chat',
+ id: 11,
+ members: [
+ [['link', currentPartner]],
+ [['insert', { id: 11, display_name: "Recipient" }]]
+ ],
+ model: 'mail.channel',
+ });
+ const threadViewer = this.env.models['mail.thread_viewer'].create({
+ hasThreadView: true,
+ thread: [['link', thread]],
+ });
+ const message = this.env.models['mail.message'].create({
+ author: [['link', currentPartner]],
+ body: "<p>Test</p>",
+ id: 100,
+ originThread: [['link', thread]],
+ });
+
+ await this.createMessageComponent(message, {
+ threadViewLocalId: threadViewer.threadView.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'].insert({
+ id: this.env.messaging.currentPartner.id,
+ display_name: "Demo User",
+ });
+ const thread = this.env.models['mail.thread'].create({
+ channel_type: 'chat',
+ id: 11,
+ members: [
+ [['link', currentPartner]],
+ [['insert', { id: 11, display_name: "Recipient" }]]
+ ],
+ model: 'mail.channel',
+ });
+ const threadViewer = this.env.models['mail.thread_viewer'].create({
+ hasThreadView: true,
+ thread: [['link', thread]],
+ });
+ const message = this.env.models['mail.message'].create({
+ author: [['link', currentPartner]],
+ body: "<p>Test</p>",
+ id: 100,
+ originThread: [['link', thread]],
+ });
+ await this.createMessageComponent(message, {
+ threadViewLocalId: threadViewer.threadView.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'].insert({
+ id: this.env.messaging.currentPartner.id,
+ display_name: "Demo User",
+ });
+ const thread = this.env.models['mail.thread'].create({
+ channel_type: 'chat',
+ id: 11,
+ members: [
+ [['link', currentPartner]],
+ [['insert', { id: 11, display_name: "Recipient" }]]
+ ],
+ model: 'mail.channel',
+ });
+ const threadViewer = this.env.models['mail.thread_viewer'].create({
+ hasThreadView: true,
+ thread: [['link', thread]],
+ });
+ const message = this.env.models['mail.message'].create({
+ author: [['link', currentPartner]],
+ body: "<p>Test</p>",
+ id: 100,
+ originThread: [['link', thread]],
+ });
+ await this.createMessageComponent(message, {
+ threadViewLocalId: threadViewer.threadView.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({
+ channel_type: 'chat',
+ id: 11,
+ partnerSeenInfos: [['create', [
+ {
+ channelId: 11,
+ lastFetchedMessage: [['insert', { id: 100 }]],
+ partnerId: this.env.messaging.currentPartner.id,
+ },
+ {
+ channelId: 11,
+ lastFetchedMessage: [['insert', { id: 100 }]],
+ partnerId: author.id,
+ },
+ ]]],
+ model: 'mail.channel',
+ });
+ const threadViewer = this.env.models['mail.thread_viewer'].create({
+ hasThreadView: true,
+ thread: [['link', thread]],
+ });
+ const message = this.env.models['mail.message'].insert({
+ author: [['link', author]],
+ body: "<p>Test</p>",
+ id: 100,
+ originThread: [['link', thread]],
+ });
+ await this.createMessageComponent(message, { threadViewLocalId: threadViewer.threadView.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'].insert({
+ id: this.env.messaging.currentPartner.id,
+ display_name: "Demo User",
+ });
+ const thread = this.env.models['mail.thread'].create({
+ channel_type: 'chat',
+ id: 11,
+ messageSeenIndicators: [['insert', {
+ channelId: 11,
+ messageId: 99,
+ }]],
+ model: 'mail.channel',
+ });
+ const threadViewer = this.env.models['mail.thread_viewer'].create({
+ hasThreadView: true,
+ thread: [['link', thread]],
+ });
+ const lastSeenMessage = this.env.models['mail.message'].create({
+ author: [['link', currentPartner]],
+ body: "<p>You already saw me</p>",
+ id: 100,
+ originThread: [['link', thread]],
+ });
+ const message = this.env.models['mail.message'].insert({
+ author: [['link', currentPartner]],
+ body: "<p>Test</p>",
+ id: 99,
+ originThread: [['link', thread]],
+ });
+ thread.update({
+ partnerSeenInfos: [['create', [
+ {
+ channelId: 11,
+ lastSeenMessage: [['link', lastSeenMessage]],
+ partnerId: this.env.messaging.currentPartner.id,
+ },
+ {
+ channelId: 11,
+ lastSeenMessage: [['link', lastSeenMessage]],
+ partnerId: 100,
+ },
+ ]]],
+ });
+ await this.createMessageComponent(message, {
+ threadViewLocalId: threadViewer.threadView.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'].insert({
+ id: this.env.messaging.currentPartner.id,
+ display_name: "Demo User"
+ });
+ const thread = this.env.models['mail.thread'].create({
+ channel_type: 'chat',
+ id: 11,
+ partnerSeenInfos: [['create', [
+ {
+ channelId: 11,
+ lastSeenMessage: [['insert', { id: 100 }]],
+ partnerId: this.env.messaging.currentPartner.id,
+ },
+ {
+ channelId: 11,
+ lastFetchedMessage: [['insert', { id: 100 }]],
+ lastSeenMessage: [['insert', { id: 99 }]],
+ partnerId: 100,
+ },
+ ]]],
+ messageSeenIndicators: [['insert', {
+ channelId: 11,
+ messageId: 100,
+ }]],
+ model: 'mail.channel',
+ });
+ const threadViewer = this.env.models['mail.thread_viewer'].create({
+ hasThreadView: true,
+ thread: [['link', thread]],
+ });
+ const message = this.env.models['mail.message'].insert({
+ author: [['link', currentPartner]],
+ body: "<p>Test</p>",
+ id: 100,
+ originThread: [['link', thread]],
+ });
+ await this.createMessageComponent(message, {
+ threadViewLocalId: threadViewer.threadView.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',
+ 1,
+ "message component should have one check (V) because the message was fetched by everyone but no other member than author has seen the message"
+ );
+});
+
+QUnit.test('allow attachment delete on authored message', async function (assert) {
+ assert.expect(5);
+
+ await this.start();
+ const message = this.env.models['mail.message'].create({
+ attachments: [['insert-and-replace', {
+ filename: "BLAH.jpg",
+ id: 10,
+ name: "BLAH",
+ }]],
+ author: [['link', this.env.messaging.currentPartner]],
+ body: "<p>Test</p>",
+ id: 100,
+ });
+ await this.createMessageComponent(message);
+
+ assert.containsOnce(
+ document.body,
+ '.o_Attachment',
+ "should have an attachment",
+ );
+ assert.containsOnce(
+ document.body,
+ '.o_Attachment_asideItemUnlink',
+ "should have delete attachment button"
+ );
+
+ await afterNextRender(() => document.querySelector('.o_Attachment_asideItemUnlink').click());
+ assert.containsOnce(
+ document.body,
+ '.o_AttachmentDeleteConfirmDialog',
+ "An attachment delete confirmation dialog should have been opened"
+ );
+ assert.strictEqual(
+ document.querySelector('.o_AttachmentDeleteConfirmDialog_mainText').textContent,
+ `Do you really want to delete "BLAH"?`,
+ "Confirmation dialog should contain the attachment delete confirmation text"
+ );
+
+ await afterNextRender(() =>
+ document.querySelector('.o_AttachmentDeleteConfirmDialog_confirmButton').click()
+ );
+ assert.containsNone(
+ document.body,
+ '.o_Attachment',
+ "should no longer have an attachment",
+ );
+});
+
+QUnit.test('prevent attachment delete on non-authored message', async function (assert) {
+ assert.expect(2);
+
+ 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: 11, display_name: "Guy" }]],
+ body: "<p>Test</p>",
+ id: 100,
+ });
+ await this.createMessageComponent(message);
+
+ assert.containsOnce(
+ document.body,
+ '.o_Attachment',
+ "should have an attachment",
+ );
+ assert.containsNone(
+ document.body,
+ '.o_Attachment_asideItemUnlink',
+ "delete attachment button should not be printed"
+ );
+});
+
+QUnit.test('subtype description should be displayed if it is different than body', async function (assert) {
+ assert.expect(2);
+
+ await this.start();
+ const message = this.env.models['mail.message'].create({
+ body: "<p>Hello</p>",
+ id: 100,
+ subtype_description: 'Bonjour',
+ });
+ await this.createMessageComponent(message);
+ assert.containsOnce(
+ document.body,
+ '.o_Message_content',
+ "message should have content"
+ );
+ assert.strictEqual(
+ document.querySelector(`.o_Message_content`).textContent,
+ "HelloBonjour",
+ "message content should display both body and subtype description when they are different"
+ );
+});
+
+QUnit.test('subtype description should not be displayed if it is similar to body', async function (assert) {
+ assert.expect(2);
+
+ await this.start();
+ const message = this.env.models['mail.message'].create({
+ body: "<p>Hello</p>",
+ id: 100,
+ subtype_description: 'hello',
+ });
+ await this.createMessageComponent(message);
+ assert.containsOnce(
+ document.body,
+ '.o_Message_content',
+ "message should have content"
+ );
+ assert.strictEqual(
+ document.querySelector(`.o_Message_content`).textContent,
+ "Hello",
+ "message content should display only body when subtype description is similar"
+ );
+});
+
+QUnit.test('data-oe-id & data-oe-model link redirection on click', async function (assert) {
+ assert.expect(7);
+
+ const bus = new Bus();
+ bus.on('do-action', null, payload => {
+ assert.strictEqual(
+ payload.action.type,
+ 'ir.actions.act_window',
+ "action should open view"
+ );
+ assert.strictEqual(
+ payload.action.res_model,
+ 'some.model',
+ "action should open view on 'some.model' model"
+ );
+ assert.strictEqual(
+ payload.action.res_id,
+ 250,
+ "action should open view on 250"
+ );
+ assert.step('do-action:openFormView_some.model_250');
+ });
+ await this.start({ env: { bus } });
+ const message = this.env.models['mail.message'].create({
+ body: `<p><a href="#" data-oe-id="250" data-oe-model="some.model">some.model_250</a></p>`,
+ id: 100,
+ });
+ await this.createMessageComponent(message);
+ assert.containsOnce(
+ document.body,
+ '.o_Message_content',
+ "message should have content"
+ );
+ assert.containsOnce(
+ document.querySelector('.o_Message_content'),
+ 'a',
+ "message content should have a link"
+ );
+
+ document.querySelector(`.o_Message_content a`).click();
+ assert.verifySteps(
+ ['do-action:openFormView_some.model_250'],
+ "should have open form view on related record after click on link"
+ );
+});
+
+QUnit.test('chat with author should be opened after clicking on his avatar', async function (assert) {
+ assert.expect(4);
+
+ this.data['res.partner'].records.push({ id: 10 });
+ this.data['res.users'].records.push({ partner_id: 10 });
+ await this.start({
+ hasChatWindow: true,
+ });
+ const message = this.env.models['mail.message'].create({
+ author: [['insert', { id: 10 }]],
+ id: 10,
+ });
+ await this.createMessageComponent(message);
+ assert.containsOnce(
+ document.body,
+ '.o_Message_authorAvatar',
+ "message should have the author avatar"
+ );
+ assert.hasClass(
+ document.querySelector('.o_Message_authorAvatar'),
+ 'o_redirect',
+ "author avatar should have the redirect style"
+ );
+
+ await afterNextRender(() =>
+ document.querySelector('.o_Message_authorAvatar').click()
+ );
+ assert.containsOnce(
+ document.body,
+ '.o_ChatWindow_thread',
+ "chat window with thread should be opened after clicking on author avatar"
+ );
+ assert.strictEqual(
+ document.querySelector('.o_ChatWindow_thread').dataset.correspondentId,
+ message.author.id.toString(),
+ "chat with author should be opened after clicking on his avatar"
+ );
+});
+
+QUnit.test('chat with author should be opened after clicking on his im status icon', async function (assert) {
+ assert.expect(4);
+
+ this.data['res.partner'].records.push({ id: 10 });
+ this.data['res.users'].records.push({ partner_id: 10 });
+ await this.start({
+ hasChatWindow: true,
+ });
+ const message = this.env.models['mail.message'].create({
+ author: [['insert', { id: 10, im_status: 'online' }]],
+ id: 10,
+ });
+ await this.createMessageComponent(message);
+ assert.containsOnce(
+ document.body,
+ '.o_Message_partnerImStatusIcon',
+ "message should have the author im status icon"
+ );
+ assert.hasClass(
+ document.querySelector('.o_Message_partnerImStatusIcon'),
+ 'o-has-open-chat',
+ "author im status icon should have the open chat style"
+ );
+
+ await afterNextRender(() =>
+ document.querySelector('.o_Message_partnerImStatusIcon').click()
+ );
+ assert.containsOnce(
+ document.body,
+ '.o_ChatWindow_thread',
+ "chat window with thread should be opened after clicking on author im status icon"
+ );
+ assert.strictEqual(
+ document.querySelector('.o_ChatWindow_thread').dataset.correspondentId,
+ message.author.id.toString(),
+ "chat with author should be opened after clicking on his im status icon"
+ );
+});
+
+QUnit.test('open chat with author on avatar click should be disabled when currently chatting with the author', async function (assert) {
+ assert.expect(3);
+
+ this.data['mail.channel'].records.push({
+ channel_type: 'chat',
+ members: [this.data.currentPartnerId, 10],
+ public: 'private',
+ });
+ this.data['res.partner'].records.push({ id: 10 });
+ this.data['res.users'].records.push({ partner_id: 10 });
+ await this.start({
+ hasChatWindow: true,
+ });
+ const correspondent = this.env.models['mail.partner'].insert({ id: 10 });
+ const message = this.env.models['mail.message'].create({
+ author: [['link', correspondent]],
+ id: 10,
+ });
+ const thread = await correspondent.getChat();
+ const threadViewer = this.env.models['mail.thread_viewer'].create({
+ hasThreadView: true,
+ thread: [['link', thread]],
+ });
+ await this.createMessageComponent(message, {
+ threadViewLocalId: threadViewer.threadView.localId,
+ });
+ assert.containsOnce(
+ document.body,
+ '.o_Message_authorAvatar',
+ "message should have the author avatar"
+ );
+ assert.doesNotHaveClass(
+ document.querySelector('.o_Message_authorAvatar'),
+ 'o_redirect',
+ "author avatar should not have the redirect style"
+ );
+
+ document.querySelector('.o_Message_authorAvatar').click();
+ await nextAnimationFrame();
+ assert.containsNone(
+ document.body,
+ '.o_ChatWindow',
+ "should have no thread opened after clicking on author avatar when currently chatting with the author"
+ );
+});
+
+QUnit.test('basic rendering of tracking value (float type)', async function (assert) {
+ assert.expect(8);
+
+ await this.start();
+ const message = this.env.models['mail.message'].create({
+ id: 11,
+ tracking_value_ids: [{
+ changed_field: "Total",
+ field_type: "float",
+ id: 6,
+ new_value: 45.67,
+ old_value: 12.3,
+ }],
+ });
+ await this.createMessageComponent(message);
+ assert.containsOnce(
+ document.body,
+ '.o_Message_trackingValue',
+ "should display a tracking value"
+ );
+ assert.containsOnce(
+ document.body,
+ '.o_Message_trackingValueFieldName',
+ "should display the name of the tracked field"
+ );
+ assert.strictEqual(
+ document.querySelector('.o_Message_trackingValueFieldName').textContent,
+ "Total:",
+ "should display the correct tracked field name (Total)",
+ );
+ assert.containsOnce(
+ document.body,
+ '.o_Message_trackingValueOldValue',
+ "should display the old value"
+ );
+ assert.strictEqual(
+ document.querySelector('.o_Message_trackingValueOldValue').textContent,
+ "12.30",
+ "should display the correct old value (12.30)",
+ );
+ assert.containsOnce(
+ document.body,
+ '.o_Message_trackingValueSeparator',
+ "should display the separator"
+ );
+ assert.containsOnce(
+ document.body,
+ '.o_Message_trackingValueNewValue',
+ "should display the new value"
+ );
+ assert.strictEqual(
+ document.querySelector('.o_Message_trackingValueNewValue').textContent,
+ "45.67",
+ "should display the correct new value (45.67)",
+ );
+});
+
+QUnit.test('rendering of tracked field of type integer: from non-0 to 0', async function (assert) {
+ assert.expect(1);
+
+ await this.start();
+ const message = this.env.models['mail.message'].create({
+ id: 11,
+ tracking_value_ids: [{
+ changed_field: "Total",
+ field_type: "integer",
+ id: 6,
+ new_value: 0,
+ old_value: 1,
+ }],
+ });
+ await this.createMessageComponent(message);
+ assert.strictEqual(
+ document.querySelector('.o_Message_trackingValue').textContent,
+ "Total:10",
+ "should display the correct content of tracked field of type integer: from non-0 to 0 (Total: 1 -> 0)"
+ );
+});
+
+QUnit.test('rendering of tracked field of type integer: from 0 to non-0', async function (assert) {
+ assert.expect(1);
+
+ await this.start();
+ const message = this.env.models['mail.message'].create({
+ id: 11,
+ tracking_value_ids: [{
+ changed_field: "Total",
+ field_type: "integer",
+ id: 6,
+ new_value: 1,
+ old_value: 0,
+ }],
+ });
+ await this.createMessageComponent(message);
+ assert.strictEqual(
+ document.querySelector('.o_Message_trackingValue').textContent,
+ "Total:01",
+ "should display the correct content of tracked field of type integer: from 0 to non-0 (Total: 0 -> 1)"
+ );
+});
+
+QUnit.test('rendering of tracked field of type float: from non-0 to 0', async function (assert) {
+ assert.expect(1);
+
+ await this.start();
+ const message = this.env.models['mail.message'].create({
+ id: 11,
+ tracking_value_ids: [{
+ changed_field: "Total",
+ field_type: "float",
+ id: 6,
+ new_value: 0,
+ old_value: 1,
+ }],
+ });
+ await this.createMessageComponent(message);
+ assert.strictEqual(
+ document.querySelector('.o_Message_trackingValue').textContent,
+ "Total:1.000.00",
+ "should display the correct content of tracked field of type float: from non-0 to 0 (Total: 1.00 -> 0.00)"
+ );
+});
+
+QUnit.test('rendering of tracked field of type float: from 0 to non-0', async function (assert) {
+ assert.expect(1);
+
+ await this.start();
+ const message = this.env.models['mail.message'].create({
+ id: 11,
+ tracking_value_ids: [{
+ changed_field: "Total",
+ field_type: "float",
+ id: 6,
+ new_value: 1,
+ old_value: 0,
+ }],
+ });
+ await this.createMessageComponent(message);
+ assert.strictEqual(
+ document.querySelector('.o_Message_trackingValue').textContent,
+ "Total:0.001.00",
+ "should display the correct content of tracked field of type float: from 0 to non-0 (Total: 0.00 -> 1.00)"
+ );
+});
+
+QUnit.test('rendering of tracked field of type monetary: from non-0 to 0', async function (assert) {
+ assert.expect(1);
+
+ await this.start();
+ const message = this.env.models['mail.message'].create({
+ id: 11,
+ tracking_value_ids: [{
+ changed_field: "Total",
+ field_type: "monetary",
+ id: 6,
+ new_value: 0,
+ old_value: 1,
+ }],
+ });
+ await this.createMessageComponent(message);
+ assert.strictEqual(
+ document.querySelector('.o_Message_trackingValue').textContent,
+ "Total:1.000.00",
+ "should display the correct content of tracked field of type monetary: from non-0 to 0 (Total: 1.00 -> 0.00)"
+ );
+});
+
+QUnit.test('rendering of tracked field of type monetary: from 0 to non-0', async function (assert) {
+ assert.expect(1);
+
+ await this.start();
+ const message = this.env.models['mail.message'].create({
+ id: 11,
+ tracking_value_ids: [{
+ changed_field: "Total",
+ field_type: "monetary",
+ id: 6,
+ new_value: 1,
+ old_value: 0,
+ }],
+ });
+ await this.createMessageComponent(message);
+ assert.strictEqual(
+ document.querySelector('.o_Message_trackingValue').textContent,
+ "Total:0.001.00",
+ "should display the correct content of tracked field of type monetary: from 0 to non-0 (Total: 0.00 -> 1.00)"
+ );
+});
+
+QUnit.test('rendering of tracked field of type boolean: from true to false', async function (assert) {
+ assert.expect(1);
+
+ await this.start();
+ const message = this.env.models['mail.message'].create({
+ id: 11,
+ tracking_value_ids: [{
+ changed_field: "Is Ready",
+ field_type: "boolean",
+ id: 6,
+ new_value: false,
+ old_value: true,
+ }],
+ });
+ await this.createMessageComponent(message);
+ assert.strictEqual(
+ document.querySelector('.o_Message_trackingValue').textContent,
+ "Is Ready:TrueFalse",
+ "should display the correct content of tracked field of type boolean: from true to false (Is Ready: True -> False)"
+ );
+});
+
+QUnit.test('rendering of tracked field of type boolean: from false to true', async function (assert) {
+ assert.expect(1);
+
+ await this.start();
+ const message = this.env.models['mail.message'].create({
+ id: 11,
+ tracking_value_ids: [{
+ changed_field: "Is Ready",
+ field_type: "boolean",
+ id: 6,
+ new_value: true,
+ old_value: false,
+ }],
+ });
+ await this.createMessageComponent(message);
+ assert.strictEqual(
+ document.querySelector('.o_Message_trackingValue').textContent,
+ "Is Ready:FalseTrue",
+ "should display the correct content of tracked field of type boolean: from false to true (Is Ready: False -> True)"
+ );
+});
+
+QUnit.test('rendering of tracked field of type char: from a string to empty string', async function (assert) {
+ assert.expect(1);
+
+ await this.start();
+ const message = this.env.models['mail.message'].create({
+ id: 11,
+ tracking_value_ids: [{
+ changed_field: "Name",
+ field_type: "char",
+ id: 6,
+ new_value: "",
+ old_value: "Marc",
+ }],
+ });
+ await this.createMessageComponent(message);
+ assert.strictEqual(
+ document.querySelector('.o_Message_trackingValue').textContent,
+ "Name:Marc",
+ "should display the correct content of tracked field of type char: from a string to empty string (Name: Marc ->)"
+ );
+});
+
+QUnit.test('rendering of tracked field of type char: from empty string to a string', async function (assert) {
+ assert.expect(1);
+
+ await this.start();
+ const message = this.env.models['mail.message'].create({
+ id: 11,
+ tracking_value_ids: [{
+ changed_field: "Name",
+ field_type: "char",
+ id: 6,
+ new_value: "Marc",
+ old_value: "",
+ }],
+ });
+ await this.createMessageComponent(message);
+ assert.strictEqual(
+ document.querySelector('.o_Message_trackingValue').textContent,
+ "Name:Marc",
+ "should display the correct content of tracked field of type char: from empty string to a string (Name: -> Marc)"
+ );
+});
+
+QUnit.test('rendering of tracked field of type date: from no date to a set date', async function (assert) {
+ assert.expect(1);
+
+ await this.start();
+ const message = this.env.models['mail.message'].create({
+ id: 11,
+ tracking_value_ids: [{
+ changed_field: "Deadline",
+ field_type: "date",
+ id: 6,
+ new_value: "2018-12-14",
+ old_value: false,
+ }],
+ });
+ await this.createMessageComponent(message);
+ assert.strictEqual(
+ document.querySelector('.o_Message_trackingValue').textContent,
+ "Deadline:12/14/2018",
+ "should display the correct content of tracked field of type date: from no date to a set date (Deadline: -> 12/14/2018)"
+ );
+});
+
+QUnit.test('rendering of tracked field of type date: from a set date to no date', async function (assert) {
+ assert.expect(1);
+
+ await this.start();
+ const message = this.env.models['mail.message'].create({
+ id: 11,
+ tracking_value_ids: [{
+ changed_field: "Deadline",
+ field_type: "date",
+ id: 6,
+ new_value: false,
+ old_value: "2018-12-14",
+ }],
+ });
+ await this.createMessageComponent(message);
+ assert.strictEqual(
+ document.querySelector('.o_Message_trackingValue').textContent,
+ "Deadline:12/14/2018",
+ "should display the correct content of tracked field of type date: from a set date to no date (Deadline: 12/14/2018 ->)"
+ );
+});
+
+QUnit.test('rendering of tracked field of type datetime: from no date and time to a set date and time', async function (assert) {
+ assert.expect(1);
+
+ await this.start();
+ const message = this.env.models['mail.message'].create({
+ id: 11,
+ tracking_value_ids: [{
+ changed_field: "Deadline",
+ field_type: "datetime",
+ id: 6,
+ new_value: "2018-12-14 13:42:28",
+ old_value: false,
+ }],
+ });
+ await this.createMessageComponent(message);
+ assert.strictEqual(
+ document.querySelector('.o_Message_trackingValue').textContent,
+ "Deadline:12/14/2018 13:42:28",
+ "should display the correct content of tracked field of type datetime: from no date and time to a set date and time (Deadline: -> 12/14/2018 13:42:28)"
+ );
+});
+
+QUnit.test('rendering of tracked field of type datetime: from a set date and time to no date and time', async function (assert) {
+ assert.expect(1);
+
+ await this.start();
+ const message = this.env.models['mail.message'].create({
+ id: 11,
+ tracking_value_ids: [{
+ changed_field: "Deadline",
+ field_type: "datetime",
+ id: 6,
+ new_value: false,
+ old_value: "2018-12-14 13:42:28",
+ }],
+ });
+ await this.createMessageComponent(message);
+ assert.strictEqual(
+ document.querySelector('.o_Message_trackingValue').textContent,
+ "Deadline:12/14/2018 13:42:28",
+ "should display the correct content of tracked field of type datetime: from a set date and time to no date and time (Deadline: 12/14/2018 13:42:28 ->)"
+ );
+});
+
+QUnit.test('rendering of tracked field of type text: from some text to empty', async function (assert) {
+ assert.expect(1);
+
+ await this.start();
+ const message = this.env.models['mail.message'].create({
+ id: 11,
+ tracking_value_ids: [{
+ changed_field: "Name",
+ field_type: "text",
+ id: 6,
+ new_value: "",
+ old_value: "Marc",
+ }],
+ });
+ await this.createMessageComponent(message);
+ assert.strictEqual(
+ document.querySelector('.o_Message_trackingValue').textContent,
+ "Name:Marc",
+ "should display the correct content of tracked field of type text: from some text to empty (Name: Marc ->)"
+ );
+});
+
+QUnit.test('rendering of tracked field of type text: from empty to some text', async function (assert) {
+ assert.expect(1);
+
+ await this.start();
+ const message = this.env.models['mail.message'].create({
+ id: 11,
+ tracking_value_ids: [{
+ changed_field: "Name",
+ field_type: "text",
+ id: 6,
+ new_value: "Marc",
+ old_value: "",
+ }],
+ });
+ await this.createMessageComponent(message);
+ assert.strictEqual(
+ document.querySelector('.o_Message_trackingValue').textContent,
+ "Name:Marc",
+ "should display the correct content of tracked field of type text: from empty to some text (Name: -> Marc)"
+ );
+});
+
+QUnit.test('rendering of tracked field of type selection: from a selection to no selection', async function (assert) {
+ assert.expect(1);
+
+ await this.start();
+ const message = this.env.models['mail.message'].create({
+ id: 11,
+ tracking_value_ids: [{
+ changed_field: "State",
+ field_type: "selection",
+ id: 6,
+ new_value: "",
+ old_value: "ok",
+ }],
+ });
+ await this.createMessageComponent(message);
+ assert.strictEqual(
+ document.querySelector('.o_Message_trackingValue').textContent,
+ "State:ok",
+ "should display the correct content of tracked field of type selection: from a selection to no selection (State: ok ->)"
+ );
+});
+
+QUnit.test('rendering of tracked field of type selection: from no selection to a selection', async function (assert) {
+ assert.expect(1);
+
+ await this.start();
+ const message = this.env.models['mail.message'].create({
+ id: 11,
+ tracking_value_ids: [{
+ changed_field: "State",
+ field_type: "selection",
+ id: 6,
+ new_value: "ok",
+ old_value: "",
+ }],
+ });
+ await this.createMessageComponent(message);
+ assert.strictEqual(
+ document.querySelector('.o_Message_trackingValue').textContent,
+ "State:ok",
+ "should display the correct content of tracked field of type selection: from no selection to a selection (State: -> ok)"
+ );
+});
+
+QUnit.test('rendering of tracked field of type many2one: from having a related record to no related record', async function (assert) {
+ assert.expect(1);
+
+ await this.start();
+ const message = this.env.models['mail.message'].create({
+ id: 11,
+ tracking_value_ids: [{
+ changed_field: "Author",
+ field_type: "many2one",
+ id: 6,
+ new_value: "",
+ old_value: "Marc",
+ }],
+ });
+ await this.createMessageComponent(message);
+ assert.strictEqual(
+ document.querySelector('.o_Message_trackingValue').textContent,
+ "Author:Marc",
+ "should display the correct content of tracked field of type many2one: from having a related record to no related record (Author: Marc ->)"
+ );
+});
+
+QUnit.test('rendering of tracked field of type many2one: from no related record to having a related record', async function (assert) {
+ assert.expect(1);
+
+ await this.start();
+ const message = this.env.models['mail.message'].create({
+ id: 11,
+ tracking_value_ids: [{
+ changed_field: "Author",
+ field_type: "many2one",
+ id: 6,
+ new_value: "Marc",
+ old_value: "",
+ }],
+ });
+ await this.createMessageComponent(message);
+ assert.strictEqual(
+ document.querySelector('.o_Message_trackingValue').textContent,
+ "Author:Marc",
+ "should display the correct content of tracked field of type many2one: from no related record to having a related record (Author: -> Marc)"
+ );
+});
+
+QUnit.test('message should not be considered as "clicked" after clicking on its author name', async function (assert) {
+ assert.expect(1);
+
+ 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);
+ document.querySelector(`.o_Message_authorName`).click();
+ await nextAnimationFrame();
+ assert.doesNotHaveClass(
+ document.querySelector(`.o_Message`),
+ 'o-clicked',
+ "message should not be considered as 'clicked' after clicking on its author name"
+ );
+});
+
+QUnit.test('message should not be considered as "clicked" after clicking on its author avatar', async function (assert) {
+ assert.expect(1);
+
+ 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);
+ document.querySelector(`.o_Message_authorAvatar`).click();
+ await nextAnimationFrame();
+ assert.doesNotHaveClass(
+ document.querySelector(`.o_Message`),
+ 'o-clicked',
+ "message should not be considered as 'clicked' after clicking on its author avatar"
+ );
+});
+
+QUnit.test('message should not be considered as "clicked" after clicking on notification failure icon', async function (assert) {
+ assert.expect(1);
+
+ await this.start();
+ const threadViewer = this.env.models['mail.thread_viewer'].create({
+ hasThreadView: true,
+ thread: [['create', {
+ id: 11,
+ model: 'mail.channel',
+ }]],
+ });
+ const message = this.env.models['mail.message'].create({
+ id: 10,
+ message_type: 'email',
+ notifications: [['insert', {
+ id: 11,
+ notification_status: 'exception',
+ notification_type: 'email',
+ }]],
+ originThread: [['link', threadViewer.thread]],
+ });
+ await this.createMessageComponent(message, {
+ threadViewLocalId: threadViewer.threadView.localId
+ });
+ document.querySelector('.o_Message_notificationIconClickable.o-error').click();
+ await nextAnimationFrame();
+ assert.doesNotHaveClass(
+ document.querySelector(`.o_Message`),
+ 'o-clicked',
+ "message should not be considered as 'clicked' after clicking on notification failure icon"
+ );
+});
+
+});
+});
+});
+
+});
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 00000000..21fb18a5
--- /dev/null
+++ b/addons/mail/static/src/components/message_author_prefix/message_author_prefix.js
@@ -0,0 +1,67 @@
+odoo.define('mail/static/src/components/message_author_prefix/message_author_prefix.js', function (require) {
+'use strict';
+
+const useShouldUpdateBasedOnProps = require('mail/static/src/component_hooks/use_should_update_based_on_props/use_should_update_based_on_props.js');
+const useStore = require('mail/static/src/component_hooks/use_store/use_store.js');
+
+const { Component } = owl;
+
+class MessageAuthorPrefix extends Component {
+
+ /**
+ * @override
+ */
+ constructor(...args) {
+ super(...args);
+ useShouldUpdateBasedOnProps();
+ 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 00000000..362eaeb5
--- /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 00000000..eddc2b01
--- /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 message.author !== thread.correspondent">
+ <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 00000000..245fd335
--- /dev/null
+++ b/addons/mail/static/src/components/message_list/message_list.js
@@ -0,0 +1,600 @@
+odoo.define('mail/static/src/components/message_list/message_list.js', function (require) {
+'use strict';
+
+const components = {
+ Message: require('mail/static/src/components/message/message.js'),
+};
+const useRefs = require('mail/static/src/component_hooks/use_refs/use_refs.js');
+const useRenderedValues = require('mail/static/src/component_hooks/use_rendered_values/use_rendered_values.js');
+const useShouldUpdateBasedOnProps = require('mail/static/src/component_hooks/use_should_update_based_on_props/use_should_update_based_on_props.js');
+const useStore = require('mail/static/src/component_hooks/use_store/use_store.js');
+const useUpdate = require('mail/static/src/component_hooks/use_update/use_update.js');
+
+const { Component } = owl;
+const { useRef } = owl.hooks;
+
+class MessageList extends Component {
+
+ /**
+ * @override
+ */
+ constructor(...args) {
+ super(...args);
+ useShouldUpdateBasedOnProps();
+ useStore(props => {
+ const threadView = this.env.models['mail.thread_view'].get(props.threadViewLocalId);
+ const thread = threadView ? threadView.thread : undefined;
+ const threadCache = threadView ? threadView.threadCache : undefined;
+ return {
+ isDeviceMobile: this.env.messaging.device.isMobile,
+ thread,
+ threadCache,
+ threadCacheIsAllHistoryLoaded: threadCache && threadCache.isAllHistoryLoaded,
+ threadCacheIsLoaded: threadCache && threadCache.isLoaded,
+ threadCacheIsLoadingMore: threadCache && threadCache.isLoadingMore,
+ threadCacheLastMessage: threadCache && threadCache.lastMessage,
+ threadCacheOrderedMessages: threadCache ? threadCache.orderedMessages : [],
+ threadIsTemporary: thread && thread.isTemporary,
+ threadMainCache: thread && thread.mainCache,
+ threadMessageAfterNewMessageSeparator: thread && thread.messageAfterNewMessageSeparator,
+ threadViewComponentHintList: threadView ? threadView.componentHintList : [],
+ threadViewNonEmptyMessagesLength: threadView && threadView.nonEmptyMessages.length,
+ };
+ }, {
+ compareDepth: {
+ threadCacheOrderedMessages: 1,
+ threadViewComponentHintList: 1,
+ },
+ });
+ this._getRefs = useRefs();
+ /**
+ * States whether there was at least one programmatic scroll since the
+ * last scroll event was handled (which is particularly async due to
+ * throttled behavior).
+ * Useful to avoid loading more messages or to incorrectly disabling the
+ * auto-scroll feature when the scroll was not made by the user.
+ */
+ this._isLastScrollProgrammatic = false;
+ /**
+ * Reference of the "load more" item. Useful to trigger load more
+ * on scroll when it becomes visible.
+ */
+ this._loadMoreRef = useRef('loadMore');
+ /**
+ * Snapshot computed during willPatch, which is used by patched.
+ */
+ this._willPatchSnapshot = undefined;
+ this._onScrollThrottled = _.throttle(this._onScrollThrottled.bind(this), 100);
+ /**
+ * State used by the component at the time of the render. Useful to
+ * properly handle async code.
+ */
+ this._lastRenderedValues = useRenderedValues(() => {
+ const threadView = this.threadView;
+ const thread = threadView && threadView.thread;
+ const threadCache = threadView && threadView.threadCache;
+ return {
+ componentHintList: threadView ? [...threadView.componentHintList] : [],
+ hasAutoScrollOnMessageReceived: threadView && threadView.hasAutoScrollOnMessageReceived,
+ hasScrollAdjust: this.props.hasScrollAdjust,
+ mainCache: thread && thread.mainCache,
+ order: this.props.order,
+ orderedMessages: threadCache ? [...threadCache.orderedMessages] : [],
+ thread,
+ threadCache,
+ threadCacheInitialScrollHeight: threadView && threadView.threadCacheInitialScrollHeight,
+ threadCacheInitialScrollPosition: threadView && threadView.threadCacheInitialScrollPosition,
+ threadView,
+ threadViewer: threadView && threadView.threadViewer,
+ };
+ });
+ // useUpdate must be defined after useRenderedValues to guarantee proper
+ // call order
+ useUpdate({ func: () => this._update() });
+ }
+
+ willPatch() {
+ const lastMessageRef = this.lastMessageRef;
+ this._willPatchSnapshot = {
+ isLastMessageVisible:
+ lastMessageRef &&
+ lastMessageRef.isBottomVisible({ offset: 10 }),
+ scrollHeight: this._getScrollableElement().scrollHeight,
+ scrollTop: this._getScrollableElement().scrollTop,
+ };
+ }
+
+ //--------------------------------------------------------------------------
+ // Public
+ //--------------------------------------------------------------------------
+
+ /**
+ * Update the scroll position of the message list.
+ * This is not done in patched/mounted hooks because scroll position is
+ * dependent on UI globally. To illustrate, imagine following UI:
+ *
+ * +----------+ < viewport top = scrollable top
+ * | message |
+ * | list |
+ * | |
+ * +----------+ < scrolltop = viewport bottom = scrollable bottom
+ *
+ * Now if a composer is mounted just below the message list, it is shrinked
+ * and scrolltop is altered as a result:
+ *
+ * +----------+ < viewport top = scrollable top
+ * | message |
+ * | list | < scrolltop = viewport bottom <-+
+ * | | |-- dist = composer height
+ * +----------+ < scrollable bottom <-+
+ * +----------+
+ * | composer |
+ * +----------+
+ *
+ * Because of this, the scroll position must be changed when whole UI
+ * is rendered. To make this simpler, this is done when <ThreadView/>
+ * component is patched. This is acceptable when <ThreadView/> has a
+ * fixed height, which is the case for the moment. task-2358066
+ */
+ adjustFromComponentHints() {
+ const { componentHintList, threadView } = this._lastRenderedValues();
+ for (const hint of componentHintList) {
+ switch (hint.type) {
+ case 'change-of-thread-cache':
+ case 'home-menu-hidden':
+ case 'home-menu-shown':
+ // thread just became visible, the goal is to restore its
+ // saved position if it exists or scroll to the end
+ this._adjustScrollFromModel();
+ break;
+ case 'message-received':
+ case 'messages-loaded':
+ case 'new-messages-loaded':
+ // messages have been added at the end, either scroll to the
+ // end or keep the current position
+ this._adjustScrollForExtraMessagesAtTheEnd();
+ break;
+ case 'more-messages-loaded':
+ // messages have been added at the start, keep the current
+ // position
+ this._adjustScrollForExtraMessagesAtTheStart();
+ break;
+ }
+ if (threadView && threadView.exists()) {
+ threadView.markComponentHintProcessed(hint);
+ }
+ }
+ this._willPatchSnapshot = undefined;
+ }
+
+ /**
+ * @param {mail.message} message
+ * @returns {string}
+ */
+ getDateDay(message) {
+ const date = message.date.format('YYYY-MM-DD');
+ if (date === moment().format('YYYY-MM-DD')) {
+ return this.env._t("Today");
+ } else if (
+ date === moment()
+ .subtract(1, 'days')
+ .format('YYYY-MM-DD')
+ ) {
+ return this.env._t("Yesterday");
+ }
+ return message.date.format('LL');
+ }
+
+ /**
+ * @returns {integer}
+ */
+ getScrollHeight() {
+ return this._getScrollableElement().scrollHeight;
+ }
+
+ /**
+ * @returns {integer}
+ */
+ getScrollTop() {
+ return this._getScrollableElement().scrollTop;
+ }
+
+ /**
+ * @returns {mail/static/src/components/message/message.js|undefined}
+ */
+ get mostRecentMessageRef() {
+ const { order } = this._lastRenderedValues();
+ if (order === 'desc') {
+ return this.messageRefs[0];
+ }
+ const { length: l, [l - 1]: mostRecentMessageRef } = this.messageRefs;
+ return mostRecentMessageRef;
+ }
+
+ /**
+ * @param {integer} messageId
+ * @returns {mail/static/src/components/message/message.js|undefined}
+ */
+ messageRefFromId(messageId) {
+ return this.messageRefs.find(ref => ref.message.id === messageId);
+ }
+
+ /**
+ * Get list of sub-components Message, ordered based on prop `order`
+ * (ASC/DESC).
+ *
+ * The asynchronous nature of OWL rendering pipeline may reveal disparity
+ * between knowledgeable state of store between components. Use this getter
+ * with extreme caution!
+ *
+ * Let's illustrate the disparity with a small example:
+ *
+ * - Suppose this component is aware of ordered (record) messages with
+ * following IDs: [1, 2, 3, 4, 5], and each (sub-component) messages map
+ * each of these records.
+ * - Now let's assume a change in store that translate to ordered (record)
+ * messages with following IDs: [2, 3, 4, 5, 6].
+ * - Because store changes trigger component re-rendering by their "depth"
+ * (i.e. from parents to children), this component may be aware of
+ * [2, 3, 4, 5, 6] but not yet sub-components, so that some (component)
+ * messages should be destroyed but aren't yet (the ref with message ID 1)
+ * and some do not exist yet (no ref with message ID 6).
+ *
+ * @returns {mail/static/src/components/message/message.js[]}
+ */
+ get messageRefs() {
+ const { order } = this._lastRenderedValues();
+ const refs = this._getRefs();
+ const ascOrderedMessageRefs = Object.entries(refs)
+ .filter(([refId, ref]) => (
+ // Message refs have message local id as ref id, and message
+ // local ids contain name of model 'mail.message'.
+ refId.includes(this.env.models['mail.message'].modelName) &&
+ // Component that should be destroyed but haven't just yet.
+ ref.message
+ )
+ )
+ .map(([refId, ref]) => ref)
+ .sort((ref1, ref2) => (ref1.message.id < ref2.message.id ? -1 : 1));
+ if (order === 'desc') {
+ return ascOrderedMessageRefs.reverse();
+ }
+ return ascOrderedMessageRefs;
+ }
+
+ /**
+ * @returns {mail.message[]}
+ */
+ get orderedMessages() {
+ const threadCache = this.threadView.threadCache;
+ if (this.props.order === 'desc') {
+ return [...threadCache.orderedMessages].reverse();
+ }
+ return threadCache.orderedMessages;
+ }
+
+ /**
+ * @param {integer} value
+ */
+ setScrollTop(value) {
+ if (this._getScrollableElement().scrollTop === value) {
+ return;
+ }
+ this._isLastScrollProgrammatic = true;
+ this._getScrollableElement().scrollTop = value;
+ }
+
+ /**
+ * @param {mail.message} prevMessage
+ * @param {mail.message} message
+ * @returns {boolean}
+ */
+ shouldMessageBeSquashed(prevMessage, message) {
+ if (!this.props.hasSquashCloseMessages) {
+ return false;
+ }
+ if (Math.abs(message.date.diff(prevMessage.date)) > 60000) {
+ // more than 1 min. elasped
+ return false;
+ }
+ if (prevMessage.message_type !== 'comment' || message.message_type !== 'comment') {
+ return false;
+ }
+ if (prevMessage.author !== message.author) {
+ // from a different author
+ return false;
+ }
+ if (prevMessage.originThread !== message.originThread) {
+ return false;
+ }
+ if (
+ prevMessage.moderation_status === 'pending_moderation' ||
+ message.moderation_status === 'pending_moderation'
+ ) {
+ return false;
+ }
+ if (
+ prevMessage.notifications.length > 0 ||
+ message.notifications.length > 0
+ ) {
+ // visual about notifications is restricted to non-squashed messages
+ return false;
+ }
+ const prevOriginThread = prevMessage.originThread;
+ const originThread = message.originThread;
+ if (
+ prevOriginThread &&
+ originThread &&
+ prevOriginThread.model === originThread.model &&
+ originThread.model !== 'mail.channel' &&
+ prevOriginThread.id !== originThread.id
+ ) {
+ // messages linked to different document thread
+ return false;
+ }
+ return true;
+ }
+
+ /**
+ * @returns {mail.thread_view}
+ */
+ get threadView() {
+ return this.env.models['mail.thread_view'].get(this.props.threadViewLocalId);
+ }
+
+ //--------------------------------------------------------------------------
+ // Private
+ //--------------------------------------------------------------------------
+
+ /**
+ * @private
+ */
+ _adjustScrollForExtraMessagesAtTheEnd() {
+ const {
+ hasAutoScrollOnMessageReceived,
+ hasScrollAdjust,
+ order,
+ } = this._lastRenderedValues();
+ if (!this._getScrollableElement() || !hasScrollAdjust) {
+ return;
+ }
+ if (!hasAutoScrollOnMessageReceived) {
+ if (order === 'desc' && this._willPatchSnapshot) {
+ const { scrollHeight, scrollTop } = this._willPatchSnapshot;
+ this.setScrollTop(this._getScrollableElement().scrollHeight - scrollHeight + scrollTop);
+ }
+ return;
+ }
+ this._scrollToEnd();
+ }
+
+ /**
+ * @private
+ */
+ _adjustScrollForExtraMessagesAtTheStart() {
+ const {
+ hasScrollAdjust,
+ order,
+ } = this._lastRenderedValues();
+ if (
+ !this._getScrollableElement() ||
+ !hasScrollAdjust ||
+ !this._willPatchSnapshot ||
+ order === 'desc'
+ ) {
+ return;
+ }
+ const { scrollHeight, scrollTop } = this._willPatchSnapshot;
+ this.setScrollTop(this._getScrollableElement().scrollHeight - scrollHeight + scrollTop);
+ }
+
+ /**
+ * @private
+ */
+ _adjustScrollFromModel() {
+ const {
+ hasScrollAdjust,
+ threadCacheInitialScrollHeight,
+ threadCacheInitialScrollPosition,
+ } = this._lastRenderedValues();
+ if (!this._getScrollableElement() || !hasScrollAdjust) {
+ return;
+ }
+ if (
+ threadCacheInitialScrollPosition !== undefined &&
+ this._getScrollableElement().scrollHeight === threadCacheInitialScrollHeight
+ ) {
+ this.setScrollTop(threadCacheInitialScrollPosition);
+ return;
+ }
+ this._scrollToEnd();
+ return;
+ }
+
+ /**
+ * @private
+ */
+ _checkMostRecentMessageIsVisible() {
+ const {
+ mainCache,
+ threadCache,
+ threadView,
+ } = this._lastRenderedValues();
+ if (!threadView || !threadView.exists()) {
+ return;
+ }
+ const lastMessageIsVisible =
+ threadCache &&
+ this.mostRecentMessageRef &&
+ threadCache === mainCache &&
+ this.mostRecentMessageRef.isPartiallyVisible();
+ if (lastMessageIsVisible) {
+ threadView.handleVisibleMessage(this.mostRecentMessageRef.message);
+ }
+ }
+
+ /**
+ * @private
+ * @returns {Element|undefined} Scrollable Element
+ */
+ _getScrollableElement() {
+ if (this.props.getScrollableElement) {
+ return this.props.getScrollableElement();
+ } else {
+ return this.el;
+ }
+ }
+
+ /**
+ * @private
+ * @returns {boolean}
+ */
+ _isLoadMoreVisible() {
+ const loadMore = this._loadMoreRef.el;
+ if (!loadMore) {
+ return false;
+ }
+ const loadMoreRect = loadMore.getBoundingClientRect();
+ const elRect = this._getScrollableElement().getBoundingClientRect();
+ const isInvisible = loadMoreRect.top > elRect.bottom || loadMoreRect.bottom < elRect.top;
+ return !isInvisible;
+ }
+
+ /**
+ * @private
+ */
+ _loadMore() {
+ const { threadCache } = this._lastRenderedValues();
+ if (!threadCache || !threadCache.exists()) {
+ return;
+ }
+ threadCache.loadMoreMessages();
+ }
+
+ /**
+ * Scrolls to the end of the list.
+ *
+ * @private
+ */
+ _scrollToEnd() {
+ const { order } = this._lastRenderedValues();
+ this.setScrollTop(order === 'asc' ? this._getScrollableElement().scrollHeight - this._getScrollableElement().clientHeight : 0);
+ }
+
+ /**
+ * @private
+ */
+ _update() {
+ this._checkMostRecentMessageIsVisible();
+ this.adjustFromComponentHints();
+ }
+
+ //--------------------------------------------------------------------------
+ // Handlers
+ //--------------------------------------------------------------------------
+
+ /**
+ * @private
+ * @param {MouseEvent} ev
+ */
+ _onClickLoadMore(ev) {
+ ev.preventDefault();
+ this._loadMore();
+ }
+
+ /**
+ * @private
+ * @param {ScrollEvent} ev
+ */
+ onScroll(ev) {
+ this._onScrollThrottled(ev);
+ }
+
+ /**
+ * @private
+ * @param {ScrollEvent} ev
+ */
+ _onScrollThrottled(ev) {
+ const {
+ order,
+ orderedMessages,
+ thread,
+ threadCache,
+ threadView,
+ threadViewer,
+ } = this._lastRenderedValues();
+ if (!this._getScrollableElement()) {
+ // could be unmounted in the meantime (due to throttled behavior)
+ return;
+ }
+ const scrollTop = this._getScrollableElement().scrollTop;
+ this.env.messagingBus.trigger('o-component-message-list-scrolled', {
+ orderedMessages,
+ scrollTop,
+ thread,
+ threadViewer,
+ });
+ if (!this._isLastScrollProgrammatic && threadView && threadView.exists()) {
+ // Margin to compensate for inaccurate scrolling to bottom and height
+ // flicker due height change of composer area.
+ const margin = 30;
+ // Automatically scroll to new received messages only when the list is
+ // currently fully scrolled.
+ const hasAutoScrollOnMessageReceived = (order === 'asc')
+ ? scrollTop >= this._getScrollableElement().scrollHeight - this._getScrollableElement().clientHeight - margin
+ : scrollTop <= margin;
+ threadView.update({ hasAutoScrollOnMessageReceived });
+ }
+ if (threadViewer && threadViewer.exists()) {
+ threadViewer.saveThreadCacheScrollHeightAsInitial(this._getScrollableElement().scrollHeight, threadCache);
+ threadViewer.saveThreadCacheScrollPositionsAsInitial(scrollTop, threadCache);
+ }
+ if (!this._isLastScrollProgrammatic && this._isLoadMoreVisible()) {
+ this._loadMore();
+ }
+ this._checkMostRecentMessageIsVisible();
+ this._isLastScrollProgrammatic = false;
+ }
+
+}
+
+Object.assign(MessageList, {
+ components,
+ defaultProps: {
+ hasMessageCheckbox: false,
+ hasScrollAdjust: true,
+ hasSquashCloseMessages: false,
+ haveMessagesMarkAsReadIcon: false,
+ haveMessagesReplyIcon: false,
+ order: 'asc',
+ },
+ props: {
+ hasMessageCheckbox: Boolean,
+ hasSquashCloseMessages: Boolean,
+ haveMessagesMarkAsReadIcon: Boolean,
+ haveMessagesReplyIcon: Boolean,
+ hasScrollAdjust: Boolean,
+ /**
+ * Function returns the exact scrollable element from the parent
+ * to manage proper scroll heights which affects the load more messages.
+ */
+ getScrollableElement: {
+ type: Function,
+ optional: true,
+ },
+ order: {
+ type: String,
+ validate: prop => ['asc', 'desc'].includes(prop),
+ },
+ selectedMessageLocalId: {
+ type: String,
+ optional: true,
+ },
+ threadViewLocalId: String,
+ },
+ template: 'mail.MessageList',
+});
+
+return MessageList;
+
+});
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 00000000..cb06adda
--- /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 00000000..c0aff715
--- /dev/null
+++ b/addons/mail/static/src/components/message_list/message_list.xml
@@ -0,0 +1,103 @@
+<?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': threadView and threadView.messages.length === 0, 'o-has-message-selection': props.selectedMessageLocalId }" t-on-scroll="onScroll">
+ <t t-if="threadView">
+ <!-- No result messages -->
+ <t t-if="threadView.nonEmptyMessages.length === 0">
+ <div class="o_MessageList_empty o_MessageList_item">
+ <t t-if="threadView.thread === env.messaging.inbox">
+ <div class="o_MessageList_emptyTitle">
+ Congratulations, your inbox is empty
+ </div>
+ New messages appear here.
+ </t>
+ <t t-elif="threadView.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="threadView.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="threadView.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 orderedMessages.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-foreach="orderedMessages" t-as="message" t-key="message.localId">
+ <t t-if="message === threadView.thread.messageAfterNewMessageSeparator">
+ <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-if="!message.isEmpty">
+ <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,
+ }"
+ hasMarkAsReadIcon="props.haveMessagesMarkAsReadIcon"
+ hasCheckbox="props.hasMessageCheckbox"
+ hasReplyIcon="props.haveMessagesReplyIcon"
+ isSelected="props.selectedMessageLocalId === message.localId"
+ isSquashed="isMessageSquashed"
+ messageLocalId="message.localId"
+ threadViewLocalId="threadView.localId"
+ t-ref="{{ message.localId }}"
+ />
+ <t t-set="prev_message" t-value="message"/>
+ </t>
+ </t>
+ <!-- LOADING (if order desc)-->
+ <t t-if="props.order === 'desc' and orderedMessages.length > 0">
+ <t t-call="mail.MessageList.loadMore"/>
+ </t>
+ </t>
+ </div>
+ </t>
+
+ <t t-name="mail.MessageList.loadMore" owl="1">
+ <t t-if="threadView.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="!threadView.threadCache.isAllHistoryLoaded and !threadView.thread.isTemporary">
+ <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 00000000..ed555b0c
--- /dev/null
+++ b/addons/mail/static/src/components/message_seen_indicator/message_seen_indicator.js
@@ -0,0 +1,136 @@
+odoo.define('mail/static/src/components/message_seen_indicator/message_seen_indicator.js', function (require) {
+'use strict';
+
+const useShouldUpdateBasedOnProps = require('mail/static/src/component_hooks/use_should_update_based_on_props/use_should_update_based_on_props.js');
+const useStore = require('mail/static/src/component_hooks/use_store/use_store.js');
+
+const { Component } = owl;
+
+class MessageSeenIndicator extends Component {
+
+ /**
+ * @override
+ */
+ constructor(...args) {
+ super(...args);
+ useShouldUpdateBasedOnProps();
+ useStore(props => {
+ const message = this.env.models['mail.message'].get(props.messageLocalId);
+ const thread = this.env.models['mail.thread'].get(props.threadLocalId);
+ const messageSeenIndicator = thread && thread.model === 'mail.channel'
+ ? this.env.models['mail.message_seen_indicator'].findFromIdentifyingData({
+ channelId: thread.id,
+ messageId: message.id,
+ })
+ : undefined;
+ 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() {
+ if (!this.thread || this.thread.model !== 'mail.channel') {
+ return undefined;
+ }
+ return this.env.models['mail.message_seen_indicator'].findFromIdentifyingData({
+ channelId: this.thread.id,
+ messageId: this.message.id,
+ });
+ }
+
+ /**
+ * @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 00000000..3a9d566e
--- /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 00000000..e905afaa
--- /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 00000000..fb9c6b8b
--- /dev/null
+++ b/addons/mail/static/src/components/message_seen_indicator/message_seen_indicator_tests.js
@@ -0,0 +1,294 @@
+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,
+ beforeEach,
+ createRootComponent,
+ start,
+} = 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() {
+ beforeEach(this);
+
+ this.createMessageSeenIndicatorComponent = async ({ message, thread }, otherProps) => {
+ const props = Object.assign(
+ { messageLocalId: message.localId, threadLocalId: thread.localId },
+ otherProps
+ );
+ await createRootComponent(this, components.MessageSendIndicator, {
+ props,
+ target: this.widget.el,
+ });
+ };
+
+ this.start = async params => {
+ const { env, widget } = await start(Object.assign({}, params, {
+ data: this.data,
+ }));
+ this.env = env;
+ this.widget = widget;
+ };
+ },
+ afterEach() {
+ afterEach(this);
+ },
+});
+
+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', [
+ {
+ channelId: 1000,
+ lastFetchedMessage: [['insert', { id: 100 }]],
+ partnerId: 10,
+ },
+ {
+ channelId: 1000,
+ partnerId: 100,
+ },
+ ]]],
+ messageSeenIndicators: [['insert', {
+ channelId: 1000,
+ messageId: 100,
+ }]],
+ });
+ const message = this.env.models['mail.message'].insert({
+ author: [['insert', { id: this.env.messaging.currentPartner.id, display_name: "Demo User" }]],
+ body: "<p>Test</p>",
+ id: 100,
+ originThread: [['link', thread]],
+ });
+ 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', [
+ {
+ channelId: 1000,
+ lastFetchedMessage: [['insert', { id: 100 }]],
+ partnerId: 10,
+ },
+ {
+ channelId: 1000,
+ lastFetchedMessage: [['insert', { id: 99 }]],
+ partnerId: 100,
+ },
+ ]]],
+ messageSeenIndicators: [['insert', {
+ channelId: 1000,
+ messageId: 100,
+ }]],
+ });
+ const message = this.env.models['mail.message'].insert({
+ author: [['insert', { id: this.env.messaging.currentPartner.id, display_name: "Demo User" }]],
+ body: "<p>Test</p>",
+ id: 100,
+ originThread: [['link', thread]],
+ });
+ 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', [
+ {
+ channelId: 1000,
+ lastFetchedMessage: [['insert', { id: 100 }]],
+ lastSeenMessage: [['insert', { id: 100 }]],
+ partnerId: 10,
+ },
+ {
+ channelId: 1000,
+ lastFetchedMessage: [['insert', { id: 99 }]],
+ partnerId: 100,
+ },
+ ]]],
+ messageSeenIndicators: [['insert', {
+ channelId: 1000,
+ messageId: 100,
+ }]],
+ });
+ const message = this.env.models['mail.message'].insert({
+ author: [['insert', { id: this.env.messaging.currentPartner.id, display_name: "Demo User" }]],
+ body: "<p>Test</p>",
+ id: 100,
+ originThread: [['link', thread]],
+ });
+ 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', [
+ {
+ channelId: 1000,
+ lastFetchedMessage: [['insert', { id: 100 }]],
+ lastSeenMessage: [['insert', { id: 100 }]],
+ partnerId: 10,
+ },
+ {
+ channelId: 1000,
+ partnerId: 100,
+ },
+ ]]],
+ messageSeenIndicators: [['insert', {
+ channelId: 1000,
+ messageId: 100,
+ }]],
+ });
+ const message = this.env.models['mail.message'].insert({
+ author: [['insert', { id: this.env.messaging.currentPartner.id, display_name: "Demo User" }]],
+ body: "<p>Test</p>",
+ id: 100,
+ originThread: [['link', thread]],
+ });
+ 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', [
+ {
+ channelId: 1000,
+ lastFetchedMessage: [['insert', { id: 100 }]],
+ lastSeenMessage: [['insert', { id: 100 }]],
+ partnerId: 10,
+ },
+ {
+ channelId: 1000,
+ lastFetchedMessage: [['insert', { id: 100 }]],
+ lastSeenMessage: [['insert', { id: 100 }]],
+ partnerId: 100,
+ },
+ ]]],
+ messageSeenIndicators: [['insert', {
+ channelId: 1000,
+ messageId: 100,
+ }]],
+ });
+ const message = this.env.models['mail.message'].insert({
+ author: [['insert', { id: this.env.messaging.currentPartner.id, display_name: "Demo User" }]],
+ body: "<p>Test</p>",
+ id: 100,
+ originThread: [['link', thread]],
+ });
+ 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 00000000..9eb7fd71
--- /dev/null
+++ b/addons/mail/static/src/components/messaging_menu/messaging_menu.js
@@ -0,0 +1,234 @@
+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 useShouldUpdateBasedOnProps = require('mail/static/src/component_hooks/use_should_update_based_on_props/use_should_update_based_on_props.js');
+const useStore = require('mail/static/src/component_hooks/use_store/use_store.js');
+
+const patchMixin = require('web.patchMixin');
+
+const { Component } = owl;
+
+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_');
+ useShouldUpdateBasedOnProps();
+ useStore(props => {
+ return {
+ isDeviceMobile: this.env.messaging && this.env.messaging.device.isMobile,
+ isDiscussOpen: this.env.messaging && this.env.messaging.discuss.isOpen,
+ isMessagingInitialized: this.env.isMessagingInitialized(),
+ messagingMenu: this.env.messaging && this.env.messaging.messagingMenu.__state,
+ };
+ });
+
+ // bind since passed as props
+ this._onMobileNewMessageInputSelect = this._onMobileNewMessageInputSelect.bind(this);
+ this._onMobileNewMessageInputSource = this._onMobileNewMessageInputSource.bind(this);
+ this._onClickCaptureGlobal = this._onClickCaptureGlobal.bind(this);
+ this._constructor(...args);
+ }
+
+ /**
+ * Allows patching constructor.
+ */
+ _constructor() {}
+
+ 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 && 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"),
+ }];
+ }
+
+ //--------------------------------------------------------------------------
+ // Handlers
+ //--------------------------------------------------------------------------
+
+ /**
+ * Closes the menu when clicking outside, if appropriate.
+ *
+ * @private
+ * @param {MouseEvent} ev
+ */
+ _onClickCaptureGlobal(ev) {
+ if (!this.env.messaging) {
+ /**
+ * Messaging not created, which means essential models like
+ * messaging menu are not ready, so user interactions are omitted
+ * during this (short) period of time.
+ */
+ return;
+ }
+ // ignore click inside the menu
+ if (this.el.contains(ev.target)) {
+ return;
+ }
+ // in all other cases: close the messaging menu when clicking outside
+ this.messagingMenu.close();
+ }
+
+ /**
+ * @private
+ * @param {MouseEvent} ev
+ */
+ _onClickDesktopTabButton(ev) {
+ this.messagingMenu.update({ activeTabId: ev.currentTarget.dataset.tabId });
+ }
+
+ /**
+ * @private
+ * @param {MouseEvent} ev
+ */
+ _onClickNewMessage(ev) {
+ if (!this.env.messaging.device.isMobile) {
+ this.env.messaging.chatWindowManager.openNewMessage();
+ this.messagingMenu.close();
+ } else {
+ this.messagingMenu.toggleMobileNewMessage();
+ }
+ }
+
+ /**
+ * @private
+ * @param {MouseEvent} ev
+ */
+ _onClickToggler(ev) {
+ // avoid following dummy href
+ ev.preventDefault();
+ if (!this.env.messaging) {
+ /**
+ * Messaging not created, which means essential models like
+ * messaging menu are not ready, so user interactions are omitted
+ * during this (short) period of time.
+ */
+ return;
+ }
+ 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) {
+ this.env.messaging.openChat({ partnerId: ui.item.id });
+ }
+
+ /**
+ * @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 });
+ }
+
+}
+
+Object.assign(MessagingMenu, {
+ components,
+ props: {},
+ template: 'mail.MessagingMenu',
+});
+
+return patchMixin(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 00000000..e578218a
--- /dev/null
+++ b/addons/mail/static/src/components/messaging_menu/messaging_menu.scss
@@ -0,0 +1,143 @@
+// ------------------------------------------------------------------
+// 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;
+ /**
+ * Override from bootstrap .dropdown-menu to fix top alignment with other
+ * systray menu.
+ */
+ margin-top: map-get($spacers, 0);
+
+ &.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;
+ flex-shrink: 0; // Forces Safari to not shrink below fit content
+ }
+
+ &.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.o-is-open {
+ background-color: rgba(black, 0.1);
+}
+
+.o_MessagingMenu_counter {
+ background-color: $o-enterprise-primary-color;
+}
+
+.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');
+ }
+ }
+}
+
+.o_MessagingMenu_toggler.o-no-notification {
+ @include o-mail-systray-no-notification-style();
+}
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 00000000..fc779231
--- /dev/null
+++ b/addons/mail/static/src/components/messaging_menu/messaging_menu.xml
@@ -0,0 +1,83 @@
+<?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="{ 'o-is-open': messagingMenu ? messagingMenu.isOpen : false, 'o-mobile': env.messaging ? env.messaging.device.isMobile : false }">
+ <a class="o_MessagingMenu_toggler" t-att-class="{ 'o-no-notification': messagingMenu ? !messagingMenu.counter : false }" href="#" title="Conversations" role="button" t-att-aria-expanded="messagingMenu and messagingMenu.isOpen ? 'true' : 'false'" aria-haspopup="true" t-on-click="_onClickToggler">
+ <i class="o_MessagingMenu_icon fa fa-comments" role="img" aria-label="Messages"/>
+ <t t-if="!env.isMessagingInitialized()">
+ <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 t-if="messagingMenu and messagingMenu.isOpen">
+ <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>
+ </div>
+ <NotificationList
+ class="o_MessagingMenu_notificationList"
+ t-att-class="{ 'o-mobile': env.messaging.device.isMobile }"
+ filter="messagingMenu.activeTabId"
+ />
+ <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>
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 00000000..d049ab7a
--- /dev/null
+++ b/addons/mail/static/src/components/messaging_menu/messaging_menu_tests.js
@@ -0,0 +1,1039 @@
+odoo.define('mail/static/src/components/messaging_menu/messaging_menu_tests.js', function (require) {
+'use strict';
+
+const {
+ afterEach,
+ afterNextRender,
+ beforeEach,
+ nextAnimationFrame,
+ start,
+} = 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() {
+ beforeEach(this);
+
+ this.start = async params => {
+ let { discussWidget, env, widget } = await start(Object.assign({}, params, {
+ data: this.data,
+ hasMessagingMenu: true,
+ }));
+ this.discussWidget = discussWidget;
+ this.env = env;
+ this.widget = widget;
+ };
+ },
+ afterEach() {
+ afterEach(this);
+ },
+});
+
+QUnit.test('[technical] messaging not created then becomes created', async function (assert) {
+ /**
+ * Creation of messaging in env is async due to generation of models being
+ * async. Generation of models is async because it requires parsing of all
+ * JS modules that contain pieces of model definitions.
+ *
+ * Time of having no messaging is very short, almost imperceptible by user
+ * on UI, but the display should not crash during this critical time period.
+ */
+ assert.expect(2);
+
+ const messagingBeforeCreationDeferred = makeTestPromise();
+ await this.start({
+ messagingBeforeCreationDeferred,
+ waitUntilMessagingCondition: 'none',
+ });
+ assert.containsOnce(
+ document.body,
+ '.o_MessagingMenu',
+ "should have messaging menu even when messaging is not yet created"
+ );
+
+ // simulate messaging becoming created
+ messagingBeforeCreationDeferred.resolve();
+ await nextAnimationFrame();
+ assert.containsOnce(
+ document.body,
+ '.o_MessagingMenu',
+ "should still contain messaging menu after messaging has been created"
+ );
+});
+
+QUnit.test('[technical] no crash on attempting opening messaging menu when messaging not created', async function (assert) {
+ /**
+ * Creation of messaging in env is async due to generation of models being
+ * async. Generation of models is async because it requires parsing of all
+ * JS modules that contain pieces of model definitions.
+ *
+ * Time of having no messaging is very short, almost imperceptible by user
+ * on UI, but the display should not crash during this critical time period.
+ *
+ * Messaging menu is not expected to be open on click because state of
+ * messaging menu requires messaging being created.
+ */
+ assert.expect(2);
+
+ await this.start({
+ messagingBeforeCreationDeferred: new Promise(() => {}), // keep messaging not created
+ waitUntilMessagingCondition: 'none',
+ });
+ assert.containsOnce(
+ document.body,
+ '.o_MessagingMenu',
+ "should have messaging menu even when messaging is not yet created"
+ );
+
+ let error;
+ try {
+ document.querySelector('.o_MessagingMenu_toggler').click();
+ await nextAnimationFrame();
+ } catch (err) {
+ error = err;
+ }
+ assert.notOk(
+ !!error,
+ "Should not crash on attempt to open messaging menu when messaging not created"
+ );
+ if (error) {
+ throw error;
+ }
+});
+
+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);
+ },
+ waitUntilMessagingCondition: 'created',
+ });
+ 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();
+ },
+ waitUntilMessagingCondition: 'created',
+ });
+ 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();
+ 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'),
+ "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.hasClass(
+ document.querySelector('.o_MessagingMenu'),
+ "o-is-open",
+ "should mark messaging menu as opened"
+ );
+ 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"
+ );
+
+ await afterNextRender(() => document.querySelector(`.o_MessagingMenu_toggler`).click());
+ assert.doesNotHaveClass(
+ document.querySelector('.o_MessagingMenu'),
+ "o-is-open",
+ "should mark messaging menu as closed"
+ );
+});
+
+QUnit.test('counter is taking into account failure notification', async function (assert) {
+ assert.expect(2);
+
+ this.data['mail.channel'].records.push({
+ id: 31,
+ seen_message_id: 11,
+ });
+ // message that is expected to have a failure
+ this.data['mail.message'].records.push({
+ id: 11, // random unique id, will be used to link failure to message
+ model: 'mail.channel', // expected value to link message to channel
+ res_id: 31, // id of a random channel
+ });
+ // failure that is expected to be used in the test
+ this.data['mail.notification'].records.push({
+ mail_message_id: 11, // id of the related message
+ notification_status: 'exception', // necessary value to have a failure
+ });
+ 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();
+
+ 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,
+ });
+
+ 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,
+ });
+
+ 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);
+
+ this.data['res.partner'].records.push({
+ id: 7, // random unique id, to link message author
+ name: "Demo", // random name, will be asserted in the test
+ });
+ // channel that is expected to be found in the test
+ this.data['mail.channel'].records.push({
+ id: 20, // random unique id, will be used to link message to channel
+ name: "General", // random name, will be asserted in the test
+ });
+ // message that is expected to be displayed in the test
+ this.data['mail.message'].records.push({
+ author_id: 7, // not current partner, will be asserted in the test
+ body: "<p>test</p>", // random body, will be asserted in the test
+ channel_ids: [20], // id of related channel
+ model: 'mail.channel', // necessary to link message to channel
+ res_id: 20, // id of related channel
+ });
+ await this.start();
+
+ 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);
+
+ // chat and channel expected to be found in the menu
+ this.data['mail.channel'].records.push(
+ { channel_type: "chat", id: 10 },
+ { id: 20 },
+ );
+ this.data['mail.message'].records.push(
+ {
+ channel_ids: [10], // id of related channel
+ model: 'mail.channel', // to link message to channel
+ res_id: 10, // id of related channel
+ },
+ {
+ channel_ids: [20], // id of related channel
+ model: 'mail.channel', // to link message to channel
+ res_id: 20, // id of related channel
+ },
+ );
+ await this.start();
+
+ 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'].findFromIdentifyingData({
+ id: 10,
+ 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'].findFromIdentifyingData({
+ id: 20,
+ 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'].findFromIdentifyingData({
+ id: 10,
+ 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'].findFromIdentifyingData({
+ id: 20,
+ 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'].findFromIdentifyingData({
+ id: 10,
+ 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'].findFromIdentifyingData({
+ id: 20,
+ 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'].findFromIdentifyingData({
+ id: 10,
+ 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'].findFromIdentifyingData({
+ id: 20,
+ model: 'mail.channel',
+ }).localId
+ }"]
+ `).length,
+ 1,
+ "should have preview of channel"
+ );
+});
+
+QUnit.test('open chat window from preview', async function (assert) {
+ assert.expect(1);
+
+ // channel expected to be found in the menu, only its existence matters, data are irrelevant
+ this.data['mail.channel'].records.push({});
+ await this.start({
+ hasChatWindow: true,
+ });
+
+ 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);
+
+ this.data['mail.channel'].records.push({ id: 11 });
+ this.data['mail.message'].records.push({
+ body: "<p><em>&shoulnotberaised</em><script>throw new Error('CodeInjectionError');</script></p>",
+ channel_ids: [11],
+ });
+ 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, ""),
+ "You:&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"
+ );
+});
+
+QUnit.test('no code injection in message body preview from sanitized message', async function (assert) {
+ assert.expect(5);
+
+ this.data['mail.channel'].records.push({ id: 11 });
+ this.data['mail.message'].records.push({
+ body: "<p>&lt;em&gt;&shoulnotberaised&lt;/em&gt;&lt;script&gt;throw new Error('CodeInjectionError');&lt;/script&gt;</p>",
+ channel_ids: [11],
+ });
+ 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, ""),
+ "You:<em>&shoulnotberaised</em><script>thrownewError('CodeInjectionError');</script>",
+ "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"
+ );
+});
+
+QUnit.test('<br/> tags in message body preview are transformed in spaces', async function (assert) {
+ assert.expect(4);
+
+ this.data['mail.channel'].records.push({ id: 11 });
+ this.data['mail.message'].records.push({
+ body: "<p>a<br/>b<br>c<br />d<br ></p>",
+ channel_ids: [11],
+ });
+ 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,
+ "You: a b c d",
+ "should display correct last message inline content with brs replaced by spaces"
+ );
+});
+
+QUnit.test('rendering with OdooBot has a request (default)', async function (assert) {
+ assert.expect(4);
+
+ await this.start({
+ env: {
+ browser: {
+ Notification: {
+ permission: 'default',
+ },
+ },
+ },
+ });
+
+ assert.ok(
+ document.querySelector('.o_MessagingMenu_counter'),
+ "should display a notification counter next to the messaging menu for OdooBot request"
+ );
+ assert.strictEqual(
+ document.querySelector('.o_MessagingMenu_counter').textContent,
+ "1",
+ "should display a counter of '1' next to the messaging menu"
+ );
+
+ await afterNextRender(() =>
+ document.querySelector('.o_MessagingMenu_toggler').click()
+ );
+ assert.containsOnce(
+ document.body,
+ '.o_NotificationRequest',
+ "should display a notification in the messaging menu"
+ );
+ assert.strictEqual(
+ document.querySelector('.o_NotificationRequest_name').textContent.trim(),
+ 'OdooBot has a request',
+ "notification should display that OdooBot has a request"
+ );
+});
+
+QUnit.test('rendering without OdooBot has a request (denied)', async function (assert) {
+ assert.expect(2);
+
+ await this.start({
+ env: {
+ browser: {
+ Notification: {
+ permission: 'denied',
+ },
+ },
+ },
+ });
+
+ assert.containsNone(
+ document.body,
+ '.o_MessagingMenu_counter',
+ "should not display a notification counter next to the messaging menu"
+ );
+
+ await afterNextRender(() =>
+ document.querySelector('.o_MessagingMenu_toggler').click()
+ );
+ assert.containsNone(
+ document.body,
+ '.o_NotificationRequest',
+ "should display no notification in the messaging menu"
+ );
+});
+
+QUnit.test('rendering without OdooBot has a request (accepted)', async function (assert) {
+ assert.expect(2);
+
+ await this.start({
+ env: {
+ browser: {
+ Notification: {
+ permission: 'granted',
+ },
+ },
+ },
+ });
+
+ assert.containsNone(
+ document.body,
+ '.o_MessagingMenu_counter',
+ "should not display a notification counter next to the messaging menu"
+ );
+
+ await afterNextRender(() =>
+ document.querySelector('.o_MessagingMenu_toggler').click()
+ );
+ assert.containsNone(
+ document.body,
+ '.o_NotificationRequest',
+ "should display no notification in the messaging menu"
+ );
+});
+
+QUnit.test('respond to notification prompt (denied)', async function (assert) {
+ assert.expect(3);
+
+ await this.start({
+ env: {
+ browser: {
+ Notification: {
+ permission: 'default',
+ async requestPermission() {
+ this.permission = 'denied';
+ return this.permission;
+ },
+ },
+ },
+ },
+ });
+
+ await afterNextRender(() =>
+ document.querySelector('.o_MessagingMenu_toggler').click()
+ );
+ await afterNextRender(() =>
+ document.querySelector('.o_NotificationRequest').click()
+ );
+ assert.containsOnce(
+ document.body,
+ '.toast .o_notification_content',
+ "should display a toast notification with the deny confirmation"
+ );
+ assert.containsNone(
+ document.body,
+ '.o_MessagingMenu_counter',
+ "should not display a notification counter next to the messaging menu"
+ );
+
+ await afterNextRender(() =>
+ document.querySelector('.o_MessagingMenu_toggler').click()
+ );
+ assert.containsNone(
+ document.body,
+ '.o_NotificationRequest',
+ "should display no notification in the messaging menu"
+ );
+});
+
+});
+});
+});
+
+});
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 00000000..45b53e87
--- /dev/null
+++ b/addons/mail/static/src/components/mobile_messaging_navbar/mobile_messaging_navbar.js
@@ -0,0 +1,61 @@
+odoo.define('mail/static/src/components/mobile_messaging_navbar/mobile_messaging_navbar.js', function (require) {
+'use strict';
+
+const useShouldUpdateBasedOnProps = require('mail/static/src/component_hooks/use_should_update_based_on_props/use_should_update_based_on_props.js');
+
+const { Component } = owl;
+
+class MobileMessagingNavbar extends Component {
+
+ constructor(...args) {
+ super(...args);
+ useShouldUpdateBasedOnProps({
+ compareDepth: {
+ tabs: 2,
+ },
+ });
+ }
+
+ //--------------------------------------------------------------------------
+ // 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 00000000..df0611f9
--- /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 00000000..d60611bf
--- /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 00000000..c96bd902
--- /dev/null
+++ b/addons/mail/static/src/components/moderation_ban_dialog/moderation_ban_dialog.js
@@ -0,0 +1,94 @@
+odoo.define('mail/static/src/components/moderation_ban_dialog/moderation_ban_dialog.js', function (require) {
+'use strict';
+
+const useShouldUpdateBasedOnProps = require('mail/static/src/component_hooks/use_should_update_based_on_props/use_should_update_based_on_props.js');
+const useStore = require('mail/static/src/component_hooks/use_store/use_store.js');
+
+const components = {
+ Dialog: require('web.OwlDialog'),
+};
+
+const { Component } = owl;
+const { useRef } = owl.hooks;
+
+class ModerationBanDialog extends Component {
+
+ /**
+ * @override
+ */
+ constructor(...args) {
+ super(...args);
+ useShouldUpdateBasedOnProps({
+ compareDepth: {
+ messageLocalIds: 1,
+ },
+ });
+ 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));
+ }
+
+ /**
+ * @returns {string}
+ */
+ get CONFIRMATION() {
+ return this.env._t("Confirmation");
+ }
+
+ //--------------------------------------------------------------------------
+ // 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,
+ 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 00000000..1e29f731
--- /dev/null
+++ b/addons/mail/static/src/components/moderation_ban_dialog/moderation_ban_dialog.xml
@@ -0,0 +1,23 @@
+<?xml version="1.0" encoding="UTF-8"?>
+<templates xml:space="preserve">
+ <t t-name="mail.ModerationBanDialog" owl="1">
+ <Dialog contentClass="'o_ModerationBanDialog'" title="CONFIRMATION" 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>
+ </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 00000000..4c444683
--- /dev/null
+++ b/addons/mail/static/src/components/moderation_discard_dialog/moderation_discard_dialog.js
@@ -0,0 +1,109 @@
+odoo.define('mail/static/src/components/moderation_discard_dialog/moderation_discard_dialog.js', function (require) {
+'use strict';
+
+const useShouldUpdateBasedOnProps = require('mail/static/src/component_hooks/use_should_update_based_on_props/use_should_update_based_on_props.js');
+const useStore = require('mail/static/src/component_hooks/use_store/use_store.js');
+
+const components = {
+ Dialog: require('web.OwlDialog'),
+};
+
+const { Component } = owl;
+const { useRef } = owl.hooks;
+
+class ModerationDiscardDialog extends Component {
+
+ /**
+ * @override
+ */
+ constructor(...args) {
+ super(...args);
+ useShouldUpdateBasedOnProps({
+ compareDepth: {
+ messageLocalIds: 1,
+ },
+ });
+ 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}
+ */
+ getBody() {
+ 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)
+ );
+ }
+
+ /**
+ * @returns {string}
+ */
+ getTitle() {
+ return this.env._t("Confirmation");
+ }
+
+ //--------------------------------------------------------------------------
+ // 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,
+ 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 00000000..58dbc14d
--- /dev/null
+++ b/addons/mail/static/src/components/moderation_discard_dialog/moderation_discard_dialog.xml
@@ -0,0 +1,13 @@
+<?xml version="1.0" encoding="UTF-8"?>
+<templates xml:space="preserve">
+ <t t-name="mail.ModerationDiscardDialog" owl="1">
+ <Dialog contentClass="'o_ModerationDiscardDialog'" title="getTitle()" size="'medium'" t-ref="dialog">
+ <p t-esc="getBody()"/>
+ <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>
+ </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 00000000..44b82bc6
--- /dev/null
+++ b/addons/mail/static/src/components/moderation_reject_dialog/moderation_reject_dialog.js
@@ -0,0 +1,104 @@
+odoo.define('mail/static/src/components/moderation_reject_dialog/moderation_reject_dialog.js', function (require) {
+'use strict';
+
+const useShouldUpdateBasedOnProps = require('mail/static/src/component_hooks/use_should_update_based_on_props/use_should_update_based_on_props.js');
+const useStore = require('mail/static/src/component_hooks/use_store/use_store.js');
+
+const components = {
+ Dialog: require('web.OwlDialog'),
+};
+
+const { Component, useState } = owl;
+const { useRef } = owl.hooks;
+
+class ModerationRejectDialog extends Component {
+
+ /**
+ * @override
+ */
+ constructor(...args) {
+ super(...args);
+ useShouldUpdateBasedOnProps({
+ compareDepth: {
+ messageLocalIds: 1,
+ },
+ });
+ 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)
+ );
+ }
+
+ /**
+ * @returns {string}
+ */
+ get SEND_EXPLANATION_TO_AUTHOR() {
+ return this.env._t("Send explanation to author");
+ }
+
+ //--------------------------------------------------------------------------
+ // 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,
+ 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 00000000..182ffc7e
--- /dev/null
+++ b/addons/mail/static/src/components/moderation_reject_dialog/moderation_reject_dialog.xml
@@ -0,0 +1,13 @@
+<?xml version="1.0" encoding="UTF-8"?>
+<templates xml:space="preserve">
+ <t t-name="mail.ModerationRejectDialog" owl="1">
+ <Dialog contentClass="'o_ModerationRejectDialog'" title="SEND_EXPLANATION_TO_AUTHOR" 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>
+ </t>
+</templates>
diff --git a/addons/mail/static/src/components/notification_alert/notification_alert.js b/addons/mail/static/src/components/notification_alert/notification_alert.js
new file mode 100644
index 00000000..7ef9e3b1
--- /dev/null
+++ b/addons/mail/static/src/components/notification_alert/notification_alert.js
@@ -0,0 +1,54 @@
+odoo.define('mail/static/src/components/notification_alert/notification_alert.js', function (require) {
+'use strict';
+
+const useShouldUpdateBasedOnProps = require('mail/static/src/component_hooks/use_should_update_based_on_props/use_should_update_based_on_props.js');
+const useStore = require('mail/static/src/component_hooks/use_store/use_store.js');
+
+const { Component } = owl;
+
+class NotificationAlert extends Component {
+
+ /**
+ * @override
+ */
+ constructor(...args) {
+ super(...args);
+ useShouldUpdateBasedOnProps();
+ useStore(props => {
+ const isMessagingInitialized = this.env.isMessagingInitialized();
+ return {
+ isMessagingInitialized,
+ isNotificationBlocked: this.isNotificationBlocked,
+ };
+ });
+ }
+
+ //--------------------------------------------------------------------------
+ // Public
+ //--------------------------------------------------------------------------
+
+ /**
+ * @returns {boolean}
+ */
+ get isNotificationBlocked() {
+ if (!this.env.isMessagingInitialized()) {
+ return false;
+ }
+ const windowNotification = this.env.browser.Notification;
+ return (
+ windowNotification &&
+ windowNotification.permission !== "granted" &&
+ !this.env.messaging.isNotificationPermissionDefault()
+ );
+ }
+
+}
+
+Object.assign(NotificationAlert, {
+ props: {},
+ template: 'mail.NotificationAlert',
+});
+
+return NotificationAlert;
+
+});
diff --git a/addons/mail/static/src/components/notification_alert/notification_alert.xml b/addons/mail/static/src/components/notification_alert/notification_alert.xml
new file mode 100644
index 00000000..3d80da13
--- /dev/null
+++ b/addons/mail/static/src/components/notification_alert/notification_alert.xml
@@ -0,0 +1,14 @@
+<?xml version="1.0" encoding="UTF-8"?>
+<templates xml:space="preserve">
+
+ <t t-name="mail.NotificationAlert" owl="1">
+ <div class="o_NotificationAlert">
+ <t t-if="env.isMessagingInitialized()">
+ <center t-if="isNotificationBlocked" class="o_notification_alert alert alert-primary">
+ Odoo Push notifications have been blocked. Go to your browser settings to allow them.
+ </center>
+ </t>
+ </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 00000000..17936986
--- /dev/null
+++ b/addons/mail/static/src/components/notification_group/notification_group.js
@@ -0,0 +1,93 @@
+odoo.define('mail/static/src/components/notification_group/notification_group.js', function (require) {
+'use strict';
+
+const useShouldUpdateBasedOnProps = require('mail/static/src/component_hooks/use_should_update_based_on_props/use_should_update_based_on_props.js');
+const useStore = require('mail/static/src/component_hooks/use_store/use_store.js');
+
+const { Component } = owl;
+const { useRef } = owl.hooks;
+
+class NotificationGroup extends Component {
+
+ /**
+ * @override
+ */
+ constructor(...args) {
+ super(...args);
+ useShouldUpdateBasedOnProps();
+ 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();
+ if (!this.env.messaging.device.isMobile) {
+ this.env.messaging.messagingMenu.close();
+ }
+ }
+
+ /**
+ * @private
+ * @param {MouseEvent} ev
+ */
+ _onClickMarkAsRead(ev) {
+ this.group.openCancelAction();
+ if (!this.env.messaging.device.isMobile) {
+ this.env.messaging.messagingMenu.close();
+ }
+ }
+
+}
+
+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 00000000..88a67002
--- /dev/null
+++ b/addons/mail/static/src/components/notification_group/notification_group.scss
@@ -0,0 +1,93 @@
+// ------------------------------------------------------------------
+// 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_image {
+ @include o-mail-notification-list-item-image-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 00000000..c2f3dceb
--- /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 00000000..33737ba4
--- /dev/null
+++ b/addons/mail/static/src/components/notification_list/notification_list.js
@@ -0,0 +1,226 @@
+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'),
+ NotificationRequest: require('mail/static/src/components/notification_request/notification_request.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 useShouldUpdateBasedOnProps = require('mail/static/src/component_hooks/use_should_update_based_on_props/use_should_update_based_on_props.js');
+const useStore = require('mail/static/src/component_hooks/use_store/use_store.js');
+
+const { Component } = owl;
+
+class NotificationList extends Component {
+
+ /**
+ * @override
+ */
+ constructor(...args) {
+ super(...args);
+ useShouldUpdateBasedOnProps();
+ 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 && notification.thread.exists())
+ .map(notification => 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.needactionMessagesAsOriginThread.length > 0)
+ .sort((t1, t2) => {
+ if (t1.needactionMessagesAsOriginThread.length > 0 && t2.needactionMessagesAsOriginThread.length === 0) {
+ return -1;
+ }
+ if (t1.needactionMessagesAsOriginThread.length === 0 && t2.needactionMessagesAsOriginThread.length > 0) {
+ return 1;
+ }
+ if (t1.lastNeedactionMessageAsOriginThread && t2.lastNeedactionMessageAsOriginThread) {
+ return t1.lastNeedactionMessageAsOriginThread.date.isBefore(t2.lastNeedactionMessageAsOriginThread.date) ? 1 : -1;
+ }
+ if (t1.lastNeedactionMessageAsOriginThread) {
+ return -1;
+ }
+ if (t2.lastNeedactionMessageAsOriginThread) {
+ 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.localMessageUnreadCounter > 0 && t2.localMessageUnreadCounter === 0) {
+ return -1;
+ }
+ if (t1.localMessageUnreadCounter === 0 && t2.localMessageUnreadCounter > 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);
+ }
+ // native notification request
+ if (props.filter === 'all' && this.env.messaging.isNotificationPermissionDefault()) {
+ notifications.unshift({
+ type: 'odoobotRequest',
+ uniqueId: 'odoobotRequest',
+ });
+ }
+ 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.isChatChannel &&
+ 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 00000000..18e31149
--- /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 00000000..e3bfbf38
--- /dev/null
+++ b/addons/mail/static/src/components/notification_list/notification_list.xml
@@ -0,0 +1,47 @@
+<?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 t-if="notification.type === 'odoobotRequest'">
+ <NotificationRequest
+ class="o_NotificationList_notificationRequest"
+ t-att-class="{ 'o-mobile': env.messaging.device.isMobile }"
+ />
+ </t>
+ <t 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 00000000..af98a9fb
--- /dev/null
+++ b/addons/mail/static/src/components/notification_list/notification_list_item.scss
@@ -0,0 +1,179 @@
+// -----------------------------------------------------------------------------
+// Layout
+// -----------------------------------------------------------------------------
+
+@mixin o-mail-notification-list-item-layout {
+ display: flex;
+ flex: 0 0 auto; // Without this, Safari shrinks parent regardless of child content
+ 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-image-style {
+ object-fit: cover;
+}
+
+@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 00000000..223ce363
--- /dev/null
+++ b/addons/mail/static/src/components/notification_list/notification_list_notification_group_tests.js
@@ -0,0 +1,546 @@
+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,
+ afterNextRender,
+ beforeEach,
+ createRootComponent,
+ start,
+} = 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() {
+ beforeEach(this);
+
+ /**
+ * @param {Object} param0
+ * @param {string} [param0.filter='all']
+ */
+ this.createNotificationListComponent = async ({ filter = 'all' } = {}) => {
+ await createRootComponent(this, components.NotificationList, {
+ props: { filter },
+ target: this.widget.el,
+ });
+ };
+
+ this.start = async params => {
+ const { env, widget } = await start(Object.assign({}, params, {
+ data: this.data,
+ }));
+ this.env = env;
+ this.widget = widget;
+ };
+ },
+ afterEach() {
+ afterEach(this);
+ },
+});
+
+QUnit.test('notification group basic layout', async function (assert) {
+ assert.expect(10);
+
+ // message that is expected to have a failure
+ this.data['mail.message'].records.push({
+ id: 11, // random unique id, will be used to link failure to message
+ message_type: 'email', // message must be email (goal of the test)
+ model: 'mail.channel', // expected value to link message to channel
+ res_id: 31, // id of a random channel
+ res_model_name: "Channel", // random res model name, will be asserted in the test
+ });
+ // failure that is expected to be used in the test
+ this.data['mail.notification'].records.push({
+ mail_message_id: 11, // id of the related message
+ notification_status: 'exception', // necessary value to have a failure
+ notification_type: 'email', // expected failure type for email message
+ });
+ 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);
+
+ // message that is expected to have a failure
+ this.data['mail.message'].records.push({
+ id: 11, // random unique id, will be used to link failure to message
+ message_type: 'email', // message must be email (goal of the test)
+ model: 'mail.channel', // expected value to link message to channel
+ res_id: 31, // id of a random channel
+ res_model_name: "Channel", // random res model name, will be asserted in the test
+ });
+ // failure that is expected to be used in the test
+ this.data['mail.notification'].records.push({
+ mail_message_id: 11, // id of the related message
+ notification_status: 'exception', // necessary value to have a failure
+ notification_type: 'email', // expected failure type for email message
+ });
+ 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(5);
+
+ this.data['mail.message'].records.push(
+ // first message that is expected to have a failure
+ {
+ id: 11, // random unique id, will be used to link failure to message
+ message_type: 'email', // message must be email (goal of the test)
+ model: 'res.partner', // same model as second message (and not `mail.channel`)
+ res_id: 31, // same res_id as second message
+ res_model_name: "Partner", // random related model name
+ },
+ // second message that is expected to have a failure
+ {
+ id: 12, // random unique id, will be used to link failure to message
+ message_type: 'email', // message must be email (goal of the test)
+ model: 'res.partner', // same model as first message (and not `mail.channel`)
+ res_id: 31, // same res_id as first message
+ res_model_name: "Partner", // same related model name for consistency
+ }
+ );
+ this.data['mail.notification'].records.push(
+ // first failure that is expected to be used in the test
+ {
+ mail_message_id: 11, // id of the related first message
+ notification_status: 'exception', // one possible value to have a failure
+ notification_type: 'email', // expected failure type for email message
+ },
+ // second failure that is expected to be used in the test
+ {
+ mail_message_id: 12, // id of the related second message
+ notification_status: 'bounce', // other possible value to have a failure
+ notification_type: 'email', // expected failure type for email message
+ }
+ );
+ await this.start({ hasChatWindow: true });
+ 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"
+ );
+ assert.containsNone(
+ document.body,
+ '.o_ChatWindow',
+ "should have no chat window initially"
+ );
+
+ await afterNextRender(() =>
+ document.querySelector('.o_NotificationGroup').click()
+ );
+ assert.containsOnce(
+ document.body,
+ '.o_ChatWindow',
+ "should have opened the thread in a chat window after clicking on it"
+ );
+});
+
+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['mail.message'].records.push(
+ // first message that is expected to have a failure
+ {
+ id: 11, // random unique id, will be used to link failure to message
+ message_type: 'email', // message must be email (goal of the test)
+ model: 'res.partner', // same model as second message (and not `mail.channel`)
+ res_id: 31, // different res_id from second message
+ res_model_name: "Partner", // random related model name
+ },
+ // second message that is expected to have a failure
+ {
+ id: 12, // random unique id, will be used to link failure to message
+ message_type: 'email', // message must be email (goal of the test)
+ model: 'res.partner', // same model as first message (and not `mail.channel`)
+ res_id: 32, // different res_id from first message
+ res_model_name: "Partner", // same related model name for consistency
+ }
+ );
+ this.data['mail.notification'].records.push(
+ // first failure that is expected to be used in the test
+ {
+ mail_message_id: 11, // id of the related first message
+ notification_status: 'exception', // one possible value to have a failure
+ notification_type: 'email', // expected failure type for email message
+ },
+ // second failure that is expected to be used in the test
+ {
+ mail_message_id: 12, // id of the related second message
+ notification_status: 'bounce', // other possible value to have a failure
+ notification_type: 'email', // expected failure type for email message
+ }
+ );
+ 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['mail.channel'].records.push({ id: 31 }, { id: 32 });
+ this.data['mail.message'].records.push(
+ // first message that is expected to have a failure
+ {
+ id: 11, // random unique id, will be used to link failure to message
+ message_type: 'email', // message must be email (goal of the test)
+ model: 'mail.channel', // testing a channel is the goal of the test
+ res_id: 31, // different res_id from second message
+ res_model_name: "Channel", // random related model name
+ },
+ // second message that is expected to have a failure
+ {
+ id: 12, // random unique id, will be used to link failure to message
+ message_type: 'email', // message must be email (goal of the test)
+ model: 'mail.channel', // testing a channel is the goal of the test
+ res_id: 32, // different res_id from first message
+ res_model_name: "Channel", // same related model name for consistency
+ }
+ );
+ this.data['mail.notification'].records.push(
+ // first failure that is expected to be used in the test
+ {
+ mail_message_id: 11, // id of the related first message
+ notification_status: 'exception', // one possible value to have a failure
+ notification_type: 'email', // expected failure type for email message
+ },
+ // second failure that is expected to be used in the test
+ {
+ mail_message_id: 12, // id of the related second message
+ notification_status: 'bounce', // other possible value to have a failure
+ notification_type: 'email', // expected failure type for email message
+ }
+ );
+ 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['mail.message'].records.push(
+ // first message that is expected to have a failure
+ {
+ date: moment.utc().format("YYYY-MM-DD HH:mm:ss"), // random date
+ id: 11, // random unique id, will be used to link failure to message
+ message_type: 'email', // message must be email (goal of the test)
+ model: 'res.partner', // different model from second message
+ res_id: 31, // random unique id, useful to link failure to message
+ res_model_name: "Partner", // random related model name
+ },
+ // second message that is expected to have a failure
+ {
+ // random date, later than first message
+ date: moment.utc().add(1, 'days').format("YYYY-MM-DD HH:mm:ss"),
+ id: 12, // random unique id, will be used to link failure to message
+ message_type: 'email', // message must be email (goal of the test)
+ model: 'res.company', // different model from first message
+ res_id: 32, // random unique id, useful to link failure to message
+ res_model_name: "Company", // random related model name
+ }
+ );
+ this.data['mail.notification'].records.push(
+ // first failure that is expected to be used in the test
+ {
+ mail_message_id: 11, // id of the related first message
+ notification_status: 'exception', // one possible value to have a failure
+ notification_type: 'email', // expected failure type for email message
+ },
+ // second failure that is expected to be used in the test
+ {
+ mail_message_id: 12, // id of the related second message
+ notification_status: 'bounce', // other possible value to have a failure
+ notification_type: 'email', // expected failure type for email message
+ }
+ );
+ 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['mail.message'].records.push(
+ // message that is expected to have a notification
+ {
+ id: 11, // random unique id, will be used to link failure to message
+ message_type: 'email', // message must be email (goal of the test)
+ model: 'res.partner', // random model
+ res_id: 31, // random unique id, useful to link failure to message
+ }
+ );
+ this.data['mail.notification'].records.push(
+ // notification that is expected to be used in the test
+ {
+ mail_message_id: 11, // id of the related first message
+ notification_status: 'ready', // non-failure status
+ notification_type: 'email', // expected notification type for email message
+ },
+ );
+ 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 00000000..24df5b22
--- /dev/null
+++ b/addons/mail/static/src/components/notification_list/notification_list_tests.js
@@ -0,0 +1,162 @@
+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,
+ afterNextRender,
+ beforeEach,
+ createRootComponent,
+ start,
+} = 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() {
+ beforeEach(this);
+
+ /**
+ * @param {Object} param0
+ * @param {string} [param0.filter='all']
+ */
+ this.createNotificationListComponent = async ({ filter = 'all' }) => {
+ await createRootComponent(this, components.NotificationList, {
+ props: { filter },
+ target: this.widget.el,
+ });
+ };
+
+ this.start = async params => {
+ const { env, widget } = await start(Object.assign({}, params, {
+ data: this.data,
+ }));
+ this.env = env;
+ this.widget = widget;
+ };
+ },
+ afterEach() {
+ afterEach(this);
+ },
+});
+
+QUnit.test('marked as read thread notifications are ordered by last message date', async function (assert) {
+ assert.expect(3);
+
+ this.data['mail.channel'].records.push(
+ { id: 100, name: "Channel 2019" },
+ { id: 200, name: "Channel 2020" }
+ );
+ this.data['mail.message'].records.push(
+ {
+ channel_ids: [100],
+ date: "2019-01-01 00:00:00",
+ id: 42,
+ model: 'mail.channel',
+ res_id: 100,
+ },
+ {
+ channel_ids: [200],
+ date: "2020-01-01 00:00:00",
+ id: 43,
+ model: 'mail.channel',
+ 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.push(
+ { id: 100, name: "Channel 2019" },
+ { id: 200, name: "Channel 2020" }
+ );
+ this.data['mail.message'].records.push(
+ {
+ channel_ids: [100],
+ date: "2019-01-01 00:00:00",
+ id: 42,
+ model: 'mail.channel',
+ res_id: 100,
+ },
+ {
+ channel_ids: [200],
+ date: "2020-01-01 00:00:00",
+ id: 43,
+ model: 'mail.channel',
+ 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 00000000..6be3647e
--- /dev/null
+++ b/addons/mail/static/src/components/notification_popover/notification_popover.js
@@ -0,0 +1,95 @@
+odoo.define('mail/static/src/components/notification_popover/notification_popover.js', function (require) {
+'use strict';
+
+const { Component } = owl;
+const useShouldUpdateBasedOnProps = require('mail/static/src/component_hooks/use_should_update_based_on_props/use_should_update_based_on_props.js');
+const useStore = require('mail/static/src/component_hooks/use_store/use_store.js');
+
+class NotificationPopover extends Component {
+
+ /**
+ * @override
+ */
+ constructor(...args) {
+ super(...args);
+ useShouldUpdateBasedOnProps({
+ compareDepth: {
+ notificationLocalIds: 1,
+ },
+ });
+ 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 00000000..06b4201c
--- /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 00000000..cf5aa027
--- /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/notification_request/notification_request.js b/addons/mail/static/src/components/notification_request/notification_request.js
new file mode 100644
index 00000000..54dcbbd4
--- /dev/null
+++ b/addons/mail/static/src/components/notification_request/notification_request.js
@@ -0,0 +1,94 @@
+odoo.define('mail/static/src/components/notification_request/notification_request.js', function (require) {
+'use strict';
+
+const components = {
+ PartnerImStatusIcon: require('mail/static/src/components/partner_im_status_icon/partner_im_status_icon.js'),
+};
+const useShouldUpdateBasedOnProps = require('mail/static/src/component_hooks/use_should_update_based_on_props/use_should_update_based_on_props.js');
+const useStore = require('mail/static/src/component_hooks/use_store/use_store.js');
+
+const { Component } = owl;
+
+class NotificationRequest extends Component {
+
+ /**
+ * @override
+ */
+ constructor(...args) {
+ super(...args);
+ useShouldUpdateBasedOnProps();
+ useStore(props => {
+ return {
+ isDeviceMobile: this.env.messaging.device.isMobile,
+ partnerRoot: this.env.messaging.partnerRoot
+ ? this.env.messaging.partnerRoot.__state
+ : undefined,
+ };
+ });
+ }
+
+ //--------------------------------------------------------------------------
+ // Public
+ //--------------------------------------------------------------------------
+
+ /**
+ * @returns {string}
+ */
+ getHeaderText() {
+ return _.str.sprintf(
+ this.env._t("%s has a request"),
+ this.env.messaging.partnerRoot.nameOrDisplayName
+ );
+ }
+
+ //--------------------------------------------------------------------------
+ // Private
+ //--------------------------------------------------------------------------
+
+ /**
+ * Handle the response of the user when prompted whether push notifications
+ * are granted or denied.
+ *
+ * @private
+ * @param {string} value
+ */
+ _handleResponseNotificationPermission(value) {
+ // manually force recompute because the permission is not in the store
+ this.env.messaging.messagingMenu.update();
+ if (value !== 'granted') {
+ this.env.services['bus_service'].sendNotification(
+ this.env._t("Permission denied"),
+ this.env._t("Odoo will not have the permission to send native notifications on this device.")
+ );
+ }
+ }
+
+ //--------------------------------------------------------------------------
+ // Handlers
+ //--------------------------------------------------------------------------
+
+ /**
+ * @private
+ */
+ _onClick() {
+ const windowNotification = this.env.browser.Notification;
+ const def = windowNotification && windowNotification.requestPermission();
+ if (def) {
+ def.then(this._handleResponseNotificationPermission.bind(this));
+ }
+ if (!this.env.messaging.device.isMobile) {
+ this.env.messaging.messagingMenu.close();
+ }
+ }
+
+}
+
+Object.assign(NotificationRequest, {
+ components,
+ props: {},
+ template: 'mail.NotificationRequest',
+});
+
+return NotificationRequest;
+
+});
diff --git a/addons/mail/static/src/components/notification_request/notification_request.scss b/addons/mail/static/src/components/notification_request/notification_request.scss
new file mode 100644
index 00000000..e2fcb81d
--- /dev/null
+++ b/addons/mail/static/src/components/notification_request/notification_request.scss
@@ -0,0 +1,77 @@
+// ------------------------------------------------------------------
+// Layout
+// ------------------------------------------------------------------
+
+.o_NotificationRequest {
+ @include o-mail-notification-list-item-layout();
+}
+
+.o_NotificationRequest_content {
+ @include o-mail-notification-list-item-content-layout();
+}
+
+.o_NotificationRequest_core {
+ @include o-mail-notification-list-item-core-layout();
+}
+
+.o_NotificationRequest_coreItem {
+ @include o-mail-notification-list-item-core-item-layout();
+}
+
+.o_NotificationRequest_header {
+ @include o-mail-notification-list-item-header-layout();
+}
+
+.o_NotificationRequest_image {
+ @include o-mail-notification-list-item-image-layout();
+}
+
+.o_NotificationRequest_imageContainer {
+ @include o-mail-notification-list-item-image-container-layout();
+}
+
+.o_NotificationRequest_inlineText {
+ @include o-mail-notification-list-item-inline-text-layout();
+}
+
+.o_NotificationRequest_name {
+ @include o-mail-notification-list-item-name-layout();
+}
+
+.o_NotificationRequest_partnerImStatusIcon {
+ @include o-mail-notification-list-item-partner-im-status-icon-layout();
+}
+
+.o_NotificationRequest_sidebar {
+ @include o-mail-notification-list-item-sidebar-layout();
+}
+
+// ------------------------------------------------------------------
+// Style
+// ------------------------------------------------------------------
+
+.o_NotificationRequest {
+ @include o-mail-notification-list-item-style();
+
+ &:hover {
+ .o_NotificationRequest_partnerImStatusIcon {
+ @include o-mail-notification-list-item-hover-partner-im-status-icon-style();
+ }
+ }
+}
+
+.o_NotificationRequest_core {
+ @include o-mail-notification-list-item-core-style();
+}
+
+.o_NotificationRequest_image {
+ @include o-mail-notification-list-item-image-style();
+}
+
+.o_NotificationRequest_name {
+ @include o-mail-notification-list-item-bold-style();
+}
+
+.o_NotificationRequest_partnerImStatusIcon {
+ @include o-mail-notification-list-item-partner-im-status-icon-style();
+}
diff --git a/addons/mail/static/src/components/notification_request/notification_request.xml b/addons/mail/static/src/components/notification_request/notification_request.xml
new file mode 100644
index 00000000..f59c671a
--- /dev/null
+++ b/addons/mail/static/src/components/notification_request/notification_request.xml
@@ -0,0 +1,31 @@
+<?xml version="1.0" encoding="UTF-8"?>
+<templates xml:space="preserve">
+
+ <t t-name="mail.NotificationRequest" owl="1">
+ <div class="o_NotificationRequest" t-on-click="_onClick">
+ <div class="o_NotificationRequest_sidebar">
+ <div class="o_NotificationRequest_imageContainer o_NotificationRequest_sidebarItem">
+ <img class="o_NotificationRequest_image rounded-circle" src="/mail/static/src/img/odoobot.png" alt="Avatar of OdooBot"/>
+ <PartnerImStatusIcon
+ class="o_NotificationRequest_partnerImStatusIcon"
+ t-att-class="{ 'o-mobile': env.messaging.device.isMobile }"
+ partnerLocalId="env.messaging.partnerRoot.localId"
+ />
+ </div>
+ </div>
+ <div class="o_NotificationRequest_content">
+ <div class="o_NotificationRequest_header">
+ <span class="o_NotificationRequest_name" t-att-class="{ 'o-mobile': env.messaging.device.isMobile }">
+ <t t-esc="getHeaderText()"/>
+ </span>
+ </div>
+ <div class="o_NotificationRequest_core">
+ <span class="o_NotificationRequest_coreItem o_NotificationRequest_inlineText">
+ Enable desktop notifications to chat.
+ </span>
+ </div>
+ </div>
+ </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 00000000..e4af9da6
--- /dev/null
+++ b/addons/mail/static/src/components/partner_im_status_icon/partner_im_status_icon.js
@@ -0,0 +1,74 @@
+odoo.define('mail/static/src/components/partner_im_status_icon/partner_im_status_icon.js', function (require) {
+'use strict';
+
+const useShouldUpdateBasedOnProps = require('mail/static/src/component_hooks/use_should_update_based_on_props/use_should_update_based_on_props.js');
+const useStore = require('mail/static/src/component_hooks/use_store/use_store.js');
+
+const { Component } = owl;
+
+class PartnerImStatusIcon extends Component {
+
+ /**
+ * @override
+ */
+ constructor(...args) {
+ super(...args);
+ useShouldUpdateBasedOnProps();
+ useStore(props => {
+ const partner = this.env.models['mail.partner'].get(props.partnerLocalId);
+ return {
+ partner,
+ partnerImStatus: partner && partner.im_status,
+ partnerRoot: this.env.messaging.partnerRoot,
+ };
+ });
+ }
+
+ //--------------------------------------------------------------------------
+ // Public
+ //--------------------------------------------------------------------------
+
+ /**
+ * @returns {mail.partner}
+ */
+ get partner() {
+ return this.env.models['mail.partner'].get(this.props.partnerLocalId);
+ }
+
+ //--------------------------------------------------------------------------
+ // Handlers
+ //--------------------------------------------------------------------------
+
+ /**
+ * @private
+ * @param {MouseEvent} ev
+ */
+ _onClick(ev) {
+ if (!this.props.hasOpenChat) {
+ return;
+ }
+ this.partner.openChat();
+ }
+
+}
+
+Object.assign(PartnerImStatusIcon, {
+ defaultProps: {
+ hasBackground: true,
+ hasOpenChat: false,
+ },
+ props: {
+ partnerLocalId: String,
+ hasBackground: Boolean,
+ /**
+ * Determines whether a click on `this` should open a chat with
+ * `this.partner`.
+ */
+ hasOpenChat: 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 00000000..608c281a
--- /dev/null
+++ b/addons/mail/static/src/components/partner_im_status_icon/partner_im_status_icon.scss
@@ -0,0 +1,59 @@
+// ------------------------------------------------------------------
+// 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 {
+ &.o-has-open-chat {
+ cursor: pointer;
+ }
+}
+
+.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 00000000..ca20a547
--- /dev/null
+++ b/addons/mail/static/src/components/partner_im_status_icon/partner_im_status_icon.xml
@@ -0,0 +1,38 @@
+<?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-has-open-chat': props.hasOpenChat,
+ 'o-offline': partner and partner.im_status === 'offline',
+ 'o-online': partner and partner.im_status === 'online',
+ }"
+ t-on-click="_onClick"
+ 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 00000000..1a68a5e0
--- /dev/null
+++ b/addons/mail/static/src/components/partner_im_status_icon/partner_im_status_icon_tests.js
@@ -0,0 +1,145 @@
+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,
+ afterNextRender,
+ beforeEach,
+ createRootComponent,
+ start,
+} = 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() {
+ beforeEach(this);
+
+ this.createPartnerImStatusIcon = async partner => {
+ await createRootComponent(this, components.PartnerImStatusIcon, {
+ props: { partnerLocalId: partner.localId },
+ target: this.widget.el
+ });
+ };
+
+ this.start = async params => {
+ const { env, widget } = await start(Object.assign({}, params, {
+ data: this.data,
+ }));
+ this.env = env;
+ this.widget = widget;
+ };
+ },
+ afterEach() {
+ afterEach(this);
+ },
+});
+
+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/thread_icon/thread_icon.js b/addons/mail/static/src/components/thread_icon/thread_icon.js
new file mode 100644
index 00000000..71017ec0
--- /dev/null
+++ b/addons/mail/static/src/components/thread_icon/thread_icon.js
@@ -0,0 +1,64 @@
+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 useShouldUpdateBasedOnProps = require('mail/static/src/component_hooks/use_should_update_based_on_props/use_should_update_based_on_props.js');
+const useStore = require('mail/static/src/component_hooks/use_store/use_store.js');
+
+const { Component } = owl;
+
+class ThreadIcon extends Component {
+
+ /**
+ * @override
+ */
+ constructor(...args) {
+ super(...args);
+ useShouldUpdateBasedOnProps();
+ useStore(props => {
+ const thread = this.env.models['mail.thread'].get(props.threadLocalId);
+ const correspondent = thread ? thread.correspondent : undefined;
+ return {
+ correspondent,
+ correspondentImStatus: correspondent && correspondent.im_status,
+ history: this.env.messaging.history,
+ inbox: this.env.messaging.inbox,
+ moderation: this.env.messaging.moderation,
+ partnerRoot: this.env.messaging.partnerRoot,
+ starred: this.env.messaging.starred,
+ thread,
+ threadChannelType: thread && thread.channel_type,
+ threadModel: thread && thread.model,
+ threadOrderedOtherTypingMembersLength: thread && thread.orderedOtherTypingMembers.length,
+ threadPublic: thread && thread.public,
+ threadTypingStatusText: thread && thread.typingStatusText,
+ };
+ });
+ }
+
+ //--------------------------------------------------------------------------
+ // 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 00000000..3824ec44
--- /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;
+ flex: 0 0 auto;
+}
+
+.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 00000000..95c4694a
--- /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 00000000..d233d6f8
--- /dev/null
+++ b/addons/mail/static/src/components/thread_icon/thread_icon_tests.js
@@ -0,0 +1,118 @@
+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,
+ afterNextRender,
+ beforeEach,
+ createRootComponent,
+ start,
+} = 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() {
+ beforeEach(this);
+
+ this.createThreadIcon = async thread => {
+ await createRootComponent(this, components.ThreadIcon, {
+ props: { threadLocalId: thread.localId },
+ target: this.widget.el
+ });
+ };
+
+ this.start = async params => {
+ const { env, widget } = await start(Object.assign({}, params, {
+ data: this.data,
+ }));
+ this.env = env;
+ this.widget = widget;
+ };
+
+ },
+ afterEach() {
+ afterEach(this);
+ },
+});
+
+QUnit.test('chat: correspondent is typing', async function (assert) {
+ assert.expect(5);
+
+ this.data['res.partner'].records.push({
+ id: 17,
+ im_status: 'online',
+ name: 'Demo',
+ });
+ this.data['mail.channel'].records.push({
+ channel_type: 'chat',
+ id: 20,
+ members: [this.data.currentPartnerId, 17],
+ });
+ await this.start();
+ const thread = this.env.models['mail.thread'].findFromIdentifyingData({
+ id: 20,
+ 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',
+ is_typing: true,
+ partner_id: 17,
+ partner_name: "Demo",
+ };
+ 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',
+ is_typing: false,
+ partner_id: 17,
+ partner_name: "Demo",
+ };
+ 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 00000000..b70c8f6b
--- /dev/null
+++ b/addons/mail/static/src/components/thread_needaction_preview/thread_needaction_preview.js
@@ -0,0 +1,151 @@
+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 useShouldUpdateBasedOnProps = require('mail/static/src/component_hooks/use_should_update_based_on_props/use_should_update_based_on_props.js');
+const useStore = require('mail/static/src/component_hooks/use_store/use_store.js');
+const mailUtils = require('mail.utils');
+
+const { Component } = owl;
+const { useRef } = owl.hooks;
+
+class ThreadNeedactionPreview extends Component {
+
+ /**
+ * @override
+ */
+ constructor(...args) {
+ super(...args);
+ useShouldUpdateBasedOnProps();
+ useStore(props => {
+ const thread = this.env.models['mail.thread'].get(props.threadLocalId);
+ const mainThreadCache = thread ? thread.mainCache : undefined;
+ let lastNeedactionMessageAsOriginThreadAuthor;
+ let lastNeedactionMessageAsOriginThread;
+ let threadCorrespondent;
+ if (thread) {
+ lastNeedactionMessageAsOriginThread = mainThreadCache.lastNeedactionMessageAsOriginThread;
+ threadCorrespondent = thread.correspondent;
+ }
+ if (lastNeedactionMessageAsOriginThread) {
+ lastNeedactionMessageAsOriginThreadAuthor = lastNeedactionMessageAsOriginThread.author;
+ }
+ return {
+ isDeviceMobile: this.env.messaging.device.isMobile,
+ lastNeedactionMessageAsOriginThread: lastNeedactionMessageAsOriginThread ? lastNeedactionMessageAsOriginThread.__state : undefined,
+ lastNeedactionMessageAsOriginThreadAuthor: lastNeedactionMessageAsOriginThreadAuthor
+ ? lastNeedactionMessageAsOriginThreadAuthor.__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 this.thread.correspondent.avatarUrl;
+ }
+ 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);
+ }
+
+ /**
+ * Get inline content of the last message of this conversation.
+ *
+ * @returns {string}
+ */
+ get inlineLastNeedactionMessageAsOriginThreadBody() {
+ if (!this.thread.lastNeedactionMessageAsOriginThread) {
+ return '';
+ }
+ return mailUtils.htmlToTextContentInline(this.thread.lastNeedactionMessageAsOriginThread.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.thread.open();
+ if (!this.env.messaging.device.isMobile) {
+ this.env.messaging.messagingMenu.close();
+ }
+ }
+
+ /**
+ * @private
+ * @param {MouseEvent} ev
+ */
+ _onClickMarkAsRead(ev) {
+ this.env.models['mail.message'].markAllAsRead([
+ ['model', '=', this.thread.model],
+ ['res_id', '=', this.thread.id],
+ ]);
+ }
+
+}
+
+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 00000000..5de87f8b
--- /dev/null
+++ b/addons/mail/static/src/components/thread_needaction_preview/thread_needaction_preview.scss
@@ -0,0 +1,108 @@
+// ------------------------------------------------------------------
+// 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_image {
+ @include o-mail-notification-list-item-image-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 00000000..3fd33224
--- /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.needactionMessagesAsOriginThread.length"/>)
+ </span>
+ <span class="o-autogrow"/>
+ <t t-if="thread.lastNeedactionMessageAsOriginThread">
+ <span class="o_ThreadNeedactionPreview_date">
+ <t t-esc="thread.lastNeedactionMessageAsOriginThread.date.fromNow()"/>
+ </span>
+ </t>
+ </div>
+ <div class="o_ThreadNeedactionPreview_core">
+ <span class="o_ThreadNeedactionPreview_coreItem o_ThreadNeedactionPreview_inlineText" t-att-class="{ 'o-empty': inlineLastNeedactionMessageAsOriginThreadBody.length === 0 }">
+ <t t-if="thread.lastNeedactionMessageAsOriginThread and thread.lastNeedactionMessageAsOriginThread.author">
+ <MessageAuthorPrefix
+ messageLocalId="thread.lastNeedactionMessageAsOriginThread.localId"
+ threadLocalId="thread.localId"
+ />
+ </t>
+ <t t-esc="inlineLastNeedactionMessageAsOriginThreadBody"/>
+ </span>
+ <span class="o-autogrow"/>
+ <span class="o_ThreadNeedactionPreview_coreItem o_ThreadNeedactionPreview_markAsRead fa fa-check" title="Mark as Read" t-on-click="_onClickMarkAsRead" t-ref="markAsRead"/>
+ </div>
+ </div>
+ </t>
+ </div>
+ </t>
+
+</templates>
diff --git a/addons/mail/static/src/components/thread_needaction_preview/thread_needaction_preview_tests.js b/addons/mail/static/src/components/thread_needaction_preview/thread_needaction_preview_tests.js
new file mode 100644
index 00000000..ca1fe22c
--- /dev/null
+++ b/addons/mail/static/src/components/thread_needaction_preview/thread_needaction_preview_tests.js
@@ -0,0 +1,457 @@
+odoo.define('mail/static/src/components/thread_needaction_preview/thread_needaction_preview_tests.js', function (require) {
+'use strict';
+
+const components = {
+ ThreadNeedactionPreview: require('mail/static/src/components/thread_needaction_preview/thread_needaction_preview.js'),
+};
+
+const {
+ afterEach,
+ afterNextRender,
+ beforeEach,
+ createRootComponent,
+ start,
+} = require('mail/static/src/utils/test_utils.js');
+
+const Bus = require('web.Bus');
+
+QUnit.module('mail', {}, function () {
+QUnit.module('components', {}, function () {
+QUnit.module('thread_needaction_preview', {}, function () {
+QUnit.module('thread_needaction_preview_tests.js', {
+ beforeEach() {
+ beforeEach(this);
+
+ this.createThreadNeedactionPreviewComponent = async props => {
+ await createRootComponent(this, components.ThreadNeedactionPreview, {
+ props,
+ target: this.widget.el
+ });
+ };
+
+ this.start = async params => {
+ const { afterEvent, env, widget } = await start(Object.assign({}, params, {
+ data: this.data,
+ }));
+ this.afterEvent = afterEvent;
+ this.env = env;
+ this.widget = widget;
+ };
+ },
+ afterEach() {
+ afterEach(this);
+ },
+});
+
+QUnit.test('mark as read', async function (assert) {
+ assert.expect(5);
+
+ this.data['mail.message'].records.push({
+ id: 21,
+ model: 'res.partner',
+ needaction: true,
+ needaction_partner_ids: [this.data.currentPartnerId],
+ res_id: 11,
+ });
+ this.data['mail.notification'].records.push({
+ mail_message_id: 21,
+ notification_status: 'sent',
+ notification_type: 'inbox',
+ res_partner_id: this.data.currentPartnerId,
+ });
+ await this.start({
+ hasChatWindow: true,
+ hasMessagingMenu: true,
+ async mockRPC(route, args) {
+ if (route.includes('mark_all_as_read')) {
+ assert.step('mark_all_as_read');
+ assert.deepEqual(
+ args.kwargs.domain,
+ [
+ ['model', '=', 'res.partner'],
+ ['res_id', '=', 11],
+ ],
+ "should mark all as read the correct thread"
+ );
+ }
+ return this._super(...arguments);
+ },
+ });
+ await afterNextRender(() => this.afterEvent({
+ eventName: 'o-thread-cache-loaded-messages',
+ func: () => document.querySelector('.o_MessagingMenu_toggler').click(),
+ message: "should wait until inbox loaded initial needaction messages",
+ predicate: ({ threadCache }) => {
+ return threadCache.thread.model === 'mail.box' && threadCache.thread.id === 'inbox';
+ },
+ }));
+ assert.containsOnce(
+ document.body,
+ '.o_ThreadNeedactionPreview_markAsRead',
+ "should have 1 mark as read button"
+ );
+
+ await afterNextRender(() =>
+ document.querySelector('.o_ThreadNeedactionPreview_markAsRead').click()
+ );
+ assert.verifySteps(
+ ['mark_all_as_read'],
+ "should have marked the thread as read"
+ );
+ assert.containsNone(
+ document.body,
+ '.o_ChatWindow',
+ "should not have opened the thread"
+ );
+});
+
+QUnit.test('click on preview should mark as read and open the thread', async function (assert) {
+ assert.expect(6);
+
+ this.data['mail.message'].records.push({
+ id: 21,
+ model: 'res.partner',
+ needaction: true,
+ needaction_partner_ids: [this.data.currentPartnerId],
+ res_id: 11,
+ });
+ this.data['mail.notification'].records.push({
+ mail_message_id: 21,
+ notification_status: 'sent',
+ notification_type: 'inbox',
+ res_partner_id: this.data.currentPartnerId,
+ });
+ await this.start({
+ hasChatWindow: true,
+ hasMessagingMenu: true,
+ async mockRPC(route, args) {
+ if (route.includes('mark_all_as_read')) {
+ assert.step('mark_all_as_read');
+ assert.deepEqual(
+ args.kwargs.domain,
+ [
+ ['model', '=', 'res.partner'],
+ ['res_id', '=', 11],
+ ],
+ "should mark all as read the correct thread"
+ );
+ }
+ return this._super(...arguments);
+ },
+ });
+ await afterNextRender(() => this.afterEvent({
+ eventName: 'o-thread-cache-loaded-messages',
+ func: () => document.querySelector('.o_MessagingMenu_toggler').click(),
+ message: "should wait until inbox loaded initial needaction messages",
+ predicate: ({ threadCache }) => {
+ return threadCache.thread.model === 'mail.box' && threadCache.thread.id === 'inbox';
+ },
+ }));
+ assert.containsOnce(
+ document.body,
+ '.o_ThreadNeedactionPreview',
+ "should have a preview initially"
+ );
+ assert.containsNone(
+ document.body,
+ '.o_ChatWindow',
+ "should have no chat window initially"
+ );
+
+ await afterNextRender(() =>
+ document.querySelector('.o_ThreadNeedactionPreview').click()
+ );
+ assert.verifySteps(
+ ['mark_all_as_read'],
+ "should have marked the message as read on clicking on the preview"
+ );
+ assert.containsOnce(
+ document.body,
+ '.o_ChatWindow',
+ "should have opened the thread on clicking on the preview"
+ );
+});
+
+QUnit.test('click on expand from chat window should close the chat window and open the form view', async function (assert) {
+ assert.expect(8);
+
+ const bus = new Bus();
+ bus.on('do-action', null, payload => {
+ assert.step('do_action');
+ assert.strictEqual(
+ payload.action.res_id,
+ 11,
+ "should redirect to the id of the thread"
+ );
+ assert.strictEqual(
+ payload.action.res_model,
+ 'res.partner',
+ "should redirect to the model of the thread"
+ );
+ });
+ this.data['mail.message'].records.push({
+ id: 21,
+ model: 'res.partner',
+ needaction: true,
+ needaction_partner_ids: [this.data.currentPartnerId],
+ res_id: 11,
+ });
+ this.data['mail.notification'].records.push({
+ mail_message_id: 21,
+ notification_status: 'sent',
+ notification_type: 'inbox',
+ res_partner_id: this.data.currentPartnerId,
+ });
+ await this.start({
+ env: { bus },
+ hasChatWindow: true,
+ hasMessagingMenu: true,
+ });
+ await afterNextRender(() => this.afterEvent({
+ eventName: 'o-thread-cache-loaded-messages',
+ func: () => document.querySelector('.o_MessagingMenu_toggler').click(),
+ message: "should wait until inbox loaded initial needaction messages",
+ predicate: ({ threadCache }) => {
+ return threadCache.thread.model === 'mail.box' && threadCache.thread.id === 'inbox';
+ },
+ }));
+ assert.containsOnce(
+ document.body,
+ '.o_ThreadNeedactionPreview',
+ "should have a preview initially"
+ );
+ await afterNextRender(() =>
+ document.querySelector('.o_ThreadNeedactionPreview').click()
+ );
+ assert.containsOnce(
+ document.body,
+ '.o_ChatWindow',
+ "should have opened the thread on clicking on the preview"
+ );
+ assert.containsOnce(
+ document.body,
+ '.o_ChatWindowHeader_commandExpand',
+ "should have an expand button"
+ );
+
+ await afterNextRender(() =>
+ document.querySelector('.o_ChatWindowHeader_commandExpand').click()
+ );
+ assert.containsNone(
+ document.body,
+ '.o_ChatWindow',
+ "should have closed the chat window on clicking expand"
+ );
+ assert.verifySteps(
+ ['do_action'],
+ "should have done an action to open the form view"
+ );
+});
+
+QUnit.test('[technical] opening a non-channel chat window should not call channel_fold', async function (assert) {
+ // channel_fold should not be called when opening non-channels in chat
+ // window, because there is no server sync of fold state for them.
+ assert.expect(3);
+
+ this.data['mail.message'].records.push({
+ id: 21,
+ model: 'res.partner',
+ needaction: true,
+ needaction_partner_ids: [this.data.currentPartnerId],
+ res_id: 11,
+ });
+ this.data['mail.notification'].records.push({
+ mail_message_id: 21,
+ notification_status: 'sent',
+ notification_type: 'inbox',
+ res_partner_id: this.data.currentPartnerId,
+ });
+ await this.start({
+ hasChatWindow: true,
+ hasMessagingMenu: true,
+ async mockRPC(route, args) {
+ if (route.includes('channel_fold')) {
+ const message = "should not call channel_fold when opening a non-channel chat window";
+ assert.step(message);
+ console.error(message);
+ throw Error(message);
+ }
+ return this._super(...arguments);
+ },
+ });
+ await afterNextRender(() => this.afterEvent({
+ eventName: 'o-thread-cache-loaded-messages',
+ func: () => document.querySelector('.o_MessagingMenu_toggler').click(),
+ message: "should wait until inbox loaded initial needaction messages",
+ predicate: ({ threadCache }) => {
+ return threadCache.thread.model === 'mail.box' && threadCache.thread.id === 'inbox';
+ },
+ }));
+ assert.containsOnce(
+ document.body,
+ '.o_ThreadNeedactionPreview',
+ "should have a preview initially"
+ );
+ assert.containsNone(
+ document.body,
+ '.o_ChatWindow',
+ "should have no chat window initially"
+ );
+
+ await afterNextRender(() =>
+ document.querySelector('.o_ThreadNeedactionPreview').click()
+ );
+ assert.containsOnce(
+ document.body,
+ '.o_ChatWindow',
+ "should have opened the chat window on clicking on the preview"
+ );
+});
+
+QUnit.test('preview should display last needaction message preview even if there is a more recent message that is not needaction in the thread', async function (assert) {
+ assert.expect(2);
+
+ this.data['res.partner'].records.push({
+ id: 11,
+ name: "Stranger",
+ });
+ this.data['mail.message'].records.push({
+ author_id: 11,
+ body: "I am the oldest but needaction",
+ id: 21,
+ model: 'res.partner',
+ needaction: true,
+ needaction_partner_ids: [this.data.currentPartnerId],
+ res_id: 11,
+ });
+ this.data['mail.message'].records.push({
+ author_id: this.data.currentPartnerId,
+ body: "I am more recent",
+ id: 22,
+ model: 'res.partner',
+ res_id: 11,
+ });
+ this.data['mail.notification'].records.push({
+ mail_message_id: 21,
+ notification_status: 'sent',
+ notification_type: 'inbox',
+ res_partner_id: this.data.currentPartnerId,
+ });
+ await this.start({
+ hasChatWindow: true,
+ hasMessagingMenu: true,
+ });
+ await afterNextRender(() => this.afterEvent({
+ eventName: 'o-thread-cache-loaded-messages',
+ func: () => document.querySelector('.o_MessagingMenu_toggler').click(),
+ message: "should wait until inbox loaded initial needaction messages",
+ predicate: ({ threadCache }) => {
+ return threadCache.thread.model === 'mail.box' && threadCache.thread.id === 'inbox';
+ },
+ }));
+ assert.containsOnce(
+ document.body,
+ '.o_ThreadNeedactionPreview_inlineText',
+ "should have a preview from the last message"
+ );
+ assert.strictEqual(
+ document.querySelector('.o_ThreadNeedactionPreview_inlineText').textContent,
+ 'Stranger: I am the oldest but needaction',
+ "the displayed message should be the one that needs action even if there is a more recent message that is not needaction on the thread"
+ );
+});
+
+QUnit.test('needaction preview should only show on its origin thread', async function (assert) {
+ assert.expect(2);
+
+ this.data['mail.channel'].records.push({ id: 12 });
+ this.data['mail.message'].records.push({
+ channel_ids: [12],
+ id: 21,
+ model: 'res.partner',
+ needaction: true,
+ needaction_partner_ids: [this.data.currentPartnerId],
+ res_id: 11,
+ });
+ this.data['mail.notification'].records.push({
+ mail_message_id: 21,
+ notification_status: 'sent',
+ notification_type: 'inbox',
+ res_partner_id: this.data.currentPartnerId,
+ });
+ await this.start({ hasMessagingMenu: true });
+ await afterNextRender(() => this.afterEvent({
+ eventName: 'o-thread-cache-loaded-messages',
+ func: () => document.querySelector('.o_MessagingMenu_toggler').click(),
+ message: "should wait until inbox loaded initial needaction messages",
+ predicate: ({ threadCache }) => {
+ return threadCache.thread.model === 'mail.box' && threadCache.thread.id === 'inbox';
+ },
+ }));
+ assert.containsOnce(
+ document.body,
+ '.o_ThreadNeedactionPreview',
+ "should have only one preview"
+ );
+ const thread = this.env.models['mail.thread'].findFromIdentifyingData({
+ id: 11,
+ model: 'res.partner',
+ });
+ assert.containsOnce(
+ document.body,
+ `.o_ThreadNeedactionPreview[data-thread-local-id="${thread.localId}"]`,
+ "preview should be on the origin thread"
+ );
+});
+
+QUnit.test('chat window header should not have unread counter for non-channel thread', async function (assert) {
+ assert.expect(2);
+
+ this.data['res.partner'].records.push({ id: 11 });
+ this.data['mail.message'].records.push({
+ author_id: 11,
+ body: 'not empty',
+ id: 21,
+ model: 'res.partner',
+ needaction: true,
+ needaction_partner_ids: [this.data.currentPartnerId],
+ res_id: 11,
+ });
+ this.data['mail.notification'].records.push({
+ mail_message_id: 21,
+ notification_status: 'sent',
+ notification_type: 'inbox',
+ res_partner_id: this.data.currentPartnerId,
+ });
+ await this.start({
+ hasChatWindow: true,
+ hasMessagingMenu: true,
+ });
+ await afterNextRender(() => this.afterEvent({
+ eventName: 'o-thread-cache-loaded-messages',
+ func: () => document.querySelector('.o_MessagingMenu_toggler').click(),
+ message: "should wait until inbox loaded initial needaction messages",
+ predicate: ({ threadCache }) => {
+ return threadCache.thread.model === 'mail.box' && threadCache.thread.id === 'inbox';
+ },
+ }));
+ await afterNextRender(() =>
+ document.querySelector('.o_ThreadNeedactionPreview').click()
+ );
+ assert.containsOnce(
+ document.body,
+ '.o_ChatWindow',
+ "should have opened the chat window on clicking on the preview"
+ );
+ assert.containsNone(
+ document.body,
+ '.o_ChatWindowHeader_counter',
+ "chat window header should not have unread counter for non-channel thread"
+ );
+});
+
+});
+});
+});
+
+});
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 00000000..94df29e0
--- /dev/null
+++ b/addons/mail/static/src/components/thread_preview/thread_preview.js
@@ -0,0 +1,130 @@
+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 useShouldUpdateBasedOnProps = require('mail/static/src/component_hooks/use_should_update_based_on_props/use_should_update_based_on_props.js');
+const useStore = require('mail/static/src/component_hooks/use_store/use_store.js');
+const mailUtils = require('mail.utils');
+
+const { Component } = owl;
+const { useRef } = owl.hooks;
+
+class ThreadPreview extends Component {
+
+ /**
+ * @override
+ */
+ constructor(...args) {
+ super(...args);
+ useShouldUpdateBasedOnProps();
+ useStore(props => {
+ const thread = this.env.models['mail.thread'].get(props.threadLocalId);
+ let lastMessageAuthor;
+ let lastMessage;
+ if (thread) {
+ const orderedMessages = thread.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 this.thread.correspondent.avatarUrl;
+ }
+ 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.thread.open();
+ if (!this.env.messaging.device.isMobile) {
+ this.env.messaging.messagingMenu.close();
+ }
+ }
+
+ /**
+ * @private
+ * @param {MouseEvent} ev
+ */
+ _onClickMarkAsRead(ev) {
+ if (this.thread.lastNonTransientMessage) {
+ this.thread.markAsSeen(this.thread.lastNonTransientMessage);
+ }
+ }
+
+}
+
+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 00000000..772d63e2
--- /dev/null
+++ b/addons/mail/static/src/components/thread_preview/thread_preview.scss
@@ -0,0 +1,117 @@
+// ------------------------------------------------------------------
+// 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_image {
+ @include o-mail-notification-list-item-image-style();
+}
+
+.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 00000000..8a4baf3d
--- /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.localMessageUnreadCounter === 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.localMessageUnreadCounter === 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.localMessageUnreadCounter === 0 }">
+ <t t-esc="thread.displayName"/>
+ </span>
+ <t t-if="thread.localMessageUnreadCounter > 0">
+ <span class="o_ThreadPreview_counter" t-att-class="{ 'o-muted': thread.localMessageUnreadCounter === 0 }">
+ (<t t-esc="thread.localMessageUnreadCounter"/>)
+ </span>
+ </t>
+ <span class="o-autogrow"/>
+ <t t-if="thread.lastMessage">
+ <span class="o_ThreadPreview_date" t-att-class="{ 'o-muted': thread.localMessageUnreadCounter === 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-esc="inlineLastMessageBody"/>
+ </span>
+ <span class="o-autogrow"/>
+ <t t-if="thread.localMessageUnreadCounter > 0">
+ <span class="o_ThreadPreview_coreItem o_ThreadPreview_markAsRead fa fa-check" title="Mark as Read" t-on-click="_onClickMarkAsRead" t-ref="markAsRead"/>
+ </t>
+ </div>
+ </div>
+ </t>
+ </div>
+ </t>
+
+</templates>
diff --git a/addons/mail/static/src/components/thread_preview/thread_preview_tests.js b/addons/mail/static/src/components/thread_preview/thread_preview_tests.js
new file mode 100644
index 00000000..981abf6b
--- /dev/null
+++ b/addons/mail/static/src/components/thread_preview/thread_preview_tests.js
@@ -0,0 +1,114 @@
+odoo.define('mail/static/src/components/thread_preview/thread_preview_tests.js', function (require) {
+'use strict';
+
+const components = {
+ ThreadPreview: require('mail/static/src/components/thread_preview/thread_preview.js'),
+};
+
+const {
+ afterEach,
+ afterNextRender,
+ beforeEach,
+ createRootComponent,
+ start,
+} = require('mail/static/src/utils/test_utils.js');
+
+QUnit.module('mail', {}, function () {
+QUnit.module('components', {}, function () {
+QUnit.module('thread_preview', {}, function () {
+QUnit.module('thread_preview_tests.js', {
+ beforeEach() {
+ beforeEach(this);
+
+ this.createThreadPreviewComponent = async props => {
+ await createRootComponent(this, components.ThreadPreview, {
+ props,
+ target: this.widget.el,
+ });
+ };
+
+ this.start = async params => {
+ const { env, widget } = await start(Object.assign({}, params, {
+ data: this.data,
+ }));
+ this.env = env;
+ this.widget = widget;
+ };
+ },
+ afterEach() {
+ afterEach(this);
+ },
+});
+
+QUnit.test('mark as read', async function (assert) {
+ assert.expect(8);
+ this.data['mail.channel'].records.push({
+ id: 11,
+ message_unread_counter: 1,
+ });
+ this.data['mail.message'].records.push({
+ channel_ids: [11],
+ id: 100,
+ model: 'mail.channel',
+ res_id: 11,
+ });
+
+ await this.start({
+ hasChatWindow: true,
+ async mockRPC(route, args) {
+ if (route.includes('channel_seen')) {
+ assert.step('channel_seen');
+ }
+ return this._super(...arguments);
+ },
+ });
+ const thread = this.env.models['mail.thread'].findFromIdentifyingData({
+ id: 11,
+ model: 'mail.channel',
+ });
+ await this.createThreadPreviewComponent({ threadLocalId: thread.localId });
+ assert.containsOnce(
+ document.body,
+ '.o_ThreadPreview_markAsRead',
+ "should have the mark as read button"
+ );
+ assert.containsOnce(
+ document.body,
+ '.o_ThreadPreview_counter',
+ "should have an unread counter"
+ );
+
+ await afterNextRender(() =>
+ document.querySelector('.o_ThreadPreview_markAsRead').click()
+ );
+ assert.verifySteps(
+ ['channel_seen'],
+ "should have marked the thread as seen"
+ );
+ assert.hasClass(
+ document.querySelector('.o_ThreadPreview'),
+ 'o-muted',
+ "should be muted once marked as read"
+ );
+ assert.containsNone(
+ document.body,
+ '.o_ThreadPreview_markAsRead',
+ "should no longer have the mark as read button"
+ );
+ assert.containsNone(
+ document.body,
+ '.o_ThreadPreview_counter',
+ "should no longer have an unread counter"
+ );
+ assert.containsNone(
+ document.body,
+ '.o_ChatWindow',
+ "should not have opened the thread"
+ );
+});
+
+});
+});
+});
+
+});
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 00000000..f053abc7
--- /dev/null
+++ b/addons/mail/static/src/components/thread_textual_typing_status/thread_textual_typing_status.js
@@ -0,0 +1,52 @@
+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 useShouldUpdateBasedOnProps = require('mail/static/src/component_hooks/use_should_update_based_on_props/use_should_update_based_on_props.js');
+const useStore = require('mail/static/src/component_hooks/use_store/use_store.js');
+
+const { Component } = owl;
+
+class ThreadTextualTypingStatus extends Component {
+
+ /**
+ * @override
+ */
+ constructor(...args) {
+ super(...args);
+ useShouldUpdateBasedOnProps();
+ useStore(props => {
+ const thread = this.env.models['mail.thread'].get(props.threadLocalId);
+ return {
+ threadOrderedOtherTypingMembersLength: thread && thread.orderedOtherTypingMembersLength,
+ threadTypingStatusText: thread && thread.typingStatusText,
+ };
+ });
+ }
+
+ //--------------------------------------------------------------------------
+ // 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 00000000..4cb9e1cf
--- /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 00000000..722d0738
--- /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 00000000..284ca788
--- /dev/null
+++ b/addons/mail/static/src/components/thread_textual_typing_status/thread_textual_typing_status_tests.js
@@ -0,0 +1,367 @@
+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,
+ afterNextRender,
+ beforeEach,
+ createRootComponent,
+ nextAnimationFrame,
+ start,
+} = 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() {
+ beforeEach(this);
+
+ this.createThreadTextualTypingStatusComponent = async thread => {
+ await createRootComponent(this, components.ThreadTextualTypingStatus, {
+ props: { threadLocalId: thread.localId },
+ target: this.widget.el,
+ });
+ };
+
+ this.start = async params => {
+ const { env, widget } = await start(Object.assign({}, params, {
+ data: this.data,
+ }));
+ this.env = env;
+ this.widget = widget;
+ };
+ },
+ async afterEach() {
+ afterEach(this);
+ },
+});
+
+QUnit.test('receive other member typing status "is typing"', async function (assert) {
+ assert.expect(2);
+
+ this.data['res.partner'].records.push({ id: 17, name: 'Demo' });
+ this.data['mail.channel'].records.push({
+ id: 20,
+ members: [this.data.currentPartnerId, 17],
+ });
+ await this.start();
+ const thread = this.env.models['mail.thread'].findFromIdentifyingData({
+ id: 20,
+ 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',
+ is_typing: true,
+ partner_id: 17,
+ partner_name: "Demo",
+ };
+ 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);
+
+ this.data['res.partner'].records.push({ id: 17, name: 'Demo' });
+ this.data['mail.channel'].records.push({
+ id: 20,
+ members: [this.data.currentPartnerId, 17],
+ });
+ await this.start();
+ const thread = this.env.models['mail.thread'].findFromIdentifyingData({
+ id: 20,
+ 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',
+ is_typing: true,
+ partner_id: 17,
+ partner_name: "Demo",
+ };
+ 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',
+ is_typing: false,
+ partner_id: 17,
+ partner_name: "Demo",
+ };
+ 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);
+
+ this.data['res.partner'].records.push({ id: 17, name: 'Demo' });
+ this.data['mail.channel'].records.push({
+ id: 20,
+ members: [this.data.currentPartnerId, 17],
+ });
+ await this.start({
+ hasTimeControl: true,
+ });
+ const thread = this.env.models['mail.thread'].findFromIdentifyingData({
+ id: 20,
+ 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',
+ is_typing: true,
+ partner_id: 17,
+ partner_name: "Demo",
+ };
+ 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);
+
+ this.data['res.partner'].records.push({ id: 17, name: 'Demo' });
+ this.data['mail.channel'].records.push({
+ id: 20,
+ members: [this.data.currentPartnerId, 17],
+ });
+ await this.start({
+ hasTimeControl: true,
+ });
+ const thread = this.env.models['mail.thread'].findFromIdentifyingData({
+ id: 20,
+ 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',
+ is_typing: true,
+ partner_id: 17,
+ partner_name: "Demo",
+ };
+ 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',
+ is_typing: true,
+ partner_id: 17,
+ partner_name: "Demo",
+ };
+ 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);
+
+ this.data['res.partner'].records.push(
+ { id: 10, name: 'Other10' },
+ { id: 11, name: 'Other11' },
+ { id: 12, name: 'Other12' }
+ );
+ this.data['mail.channel'].records.push({
+ id: 20,
+ members: [this.data.currentPartnerId, 10, 11, 12],
+ });
+ await this.start();
+ const thread = this.env.models['mail.thread'].findFromIdentifyingData({
+ id: 20,
+ 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',
+ is_typing: true,
+ partner_id: 10,
+ partner_name: "Other10",
+ };
+ 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',
+ is_typing: true,
+ partner_id: 11,
+ partner_name: "Other11",
+ };
+ 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',
+ is_typing: true,
+ partner_id: 12,
+ partner_name: "Other12",
+ };
+ 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',
+ is_typing: false,
+ partner_id: 10,
+ partner_name: "Other10",
+ };
+ 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',
+ is_typing: true,
+ partner_id: 10,
+ partner_name: "Other10",
+ };
+ 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 00000000..4c94a749
--- /dev/null
+++ b/addons/mail/static/src/components/thread_typing_icon/thread_typing_icon.js
@@ -0,0 +1,41 @@
+odoo.define('mail/static/src/components/thread_typing_icon/thread_typing_icon.js', function (require) {
+'use strict';
+
+const useShouldUpdateBasedOnProps = require('mail/static/src/component_hooks/use_should_update_based_on_props/use_should_update_based_on_props.js');
+
+const { Component } = owl;
+
+class ThreadTypingIcon extends Component {
+
+ constructor(...args) {
+ super(...args);
+ useShouldUpdateBasedOnProps();
+ }
+
+}
+
+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 00000000..ac3c5b2f
--- /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 00000000..1bdb4ada
--- /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_view/thread_view.js b/addons/mail/static/src/components/thread_view/thread_view.js
new file mode 100644
index 00000000..2399fd16
--- /dev/null
+++ b/addons/mail/static/src/components/thread_view/thread_view.js
@@ -0,0 +1,222 @@
+odoo.define('mail/static/src/components/thread_view/thread_view.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 useShouldUpdateBasedOnProps = require('mail/static/src/component_hooks/use_should_update_based_on_props/use_should_update_based_on_props.js');
+const useStore = require('mail/static/src/component_hooks/use_store/use_store.js');
+const useUpdate = require('mail/static/src/component_hooks/use_update/use_update.js');
+
+const { Component } = owl;
+const { useRef } = owl.hooks;
+
+class ThreadView extends Component {
+
+ /**
+ * @param {...any} args
+ */
+ constructor(...args) {
+ super(...args);
+ useShouldUpdateBasedOnProps();
+ useStore((...args) => this._useStoreSelector(...args), {
+ compareDepth: {
+ threadTextInputSendShortcuts: 1,
+ },
+ });
+ useUpdate({ func: () => this._update() });
+ /**
+ * 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');
+ }
+
+ //--------------------------------------------------------------------------
+ // 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 height in the message list.
+ *
+ * @returns {integer|undefined}
+ */
+ getScrollHeight() {
+ if (!this._messageListRef.comp) {
+ return undefined;
+ }
+ return this._messageListRef.comp.getScrollHeight();
+ }
+
+ /**
+ * Get the scroll position in the message list.
+ *
+ * @returns {integer|undefined}
+ */
+ getScrollTop() {
+ if (!this._messageListRef.comp) {
+ return undefined;
+ }
+ return this._messageListRef.comp.getScrollTop();
+ }
+
+ /**
+ * @private
+ * @param {MouseEvent} ev
+ */
+ onScroll(ev) {
+ if (!this._messageListRef.comp) {
+ return;
+ }
+ this._messageListRef.comp.onScroll(ev);
+ }
+
+ /**
+ * @returns {mail.thread_view}
+ */
+ get threadView() {
+ return this.env.models['mail.thread_view'].get(this.props.threadViewLocalId);
+ }
+
+ //--------------------------------------------------------------------------
+ // Private
+ //--------------------------------------------------------------------------
+
+ /**
+ * Called when thread component is mounted or patched.
+ *
+ * @private
+ */
+ _update() {
+ this.trigger('o-rendered');
+ }
+
+ /**
+ * Returns data selected from the store.
+ *
+ * @private
+ * @param {Object} props
+ * @returns {Object}
+ */
+ _useStoreSelector(props) {
+ const threadView = this.env.models['mail.thread_view'].get(props.threadViewLocalId);
+ const thread = threadView ? threadView.thread : undefined;
+ const threadCache = threadView ? threadView.threadCache : undefined;
+ const correspondent = thread && thread.correspondent;
+ return {
+ composer: thread && thread.composer,
+ correspondentId: correspondent && correspondent.id,
+ isDeviceMobile: this.env.messaging.device.isMobile,
+ thread,
+ threadCacheIsLoaded: threadCache && threadCache.isLoaded,
+ threadIsTemporary: thread && thread.isTemporary,
+ threadMassMailing: thread && thread.mass_mailing,
+ threadModel: thread && thread.model,
+ threadTextInputSendShortcuts: thread && thread.textInputSendShortcuts || [],
+ threadView,
+ threadViewIsLoading: threadView && threadView.isLoading,
+ };
+ }
+
+}
+
+Object.assign(ThreadView, {
+ components,
+ defaultProps: {
+ composerAttachmentsDetailsMode: 'auto',
+ hasComposer: false,
+ hasMessageCheckbox: false,
+ hasSquashCloseMessages: false,
+ haveMessagesMarkAsReadIcon: false,
+ haveMessagesReplyIcon: false,
+ isDoFocus: 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,
+ hasScrollAdjust: {
+ type: Boolean,
+ optional: true,
+ },
+ hasSquashCloseMessages: Boolean,
+ haveMessagesMarkAsReadIcon: Boolean,
+ haveMessagesReplyIcon: Boolean,
+ /**
+ * Determines whether this should become focused.
+ */
+ isDoFocus: Boolean,
+ order: {
+ type: String,
+ validate: prop => ['asc', 'desc'].includes(prop),
+ },
+ selectedMessageLocalId: {
+ type: String,
+ optional: true,
+ },
+ /**
+ * Function returns the exact scrollable element from the parent
+ * to manage proper scroll heights which affects the load more messages.
+ */
+ getScrollableElement: {
+ type: Function,
+ optional: true,
+ },
+ showComposerAttachmentsExtensions: Boolean,
+ showComposerAttachmentsFilenames: Boolean,
+ threadViewLocalId: String,
+ },
+ template: 'mail.ThreadView',
+});
+
+return ThreadView;
+
+});
diff --git a/addons/mail/static/src/components/thread_view/thread_view.scss b/addons/mail/static/src/components/thread_view/thread_view.scss
new file mode 100644
index 00000000..0db54501
--- /dev/null
+++ b/addons/mail/static/src/components/thread_view/thread_view.scss
@@ -0,0 +1,38 @@
+// ------------------------------------------------------------------
+// Layout
+// ------------------------------------------------------------------
+
+.o_ThreadView {
+ display: flex;
+ position: relative;
+ flex-flow: column;
+ overflow: auto;
+}
+
+.o_ThreadView_composer {
+ flex: 0 0 auto;
+}
+
+.o_ThreadView_loading {
+ display: flex;
+ align-self: center;
+ flex: 1 1 auto;
+ align-items: center;
+}
+
+.o_ThreadView_messageList {
+ flex: 1 1 auto;
+}
+
+// ------------------------------------------------------------------
+// Style
+// ------------------------------------------------------------------
+
+.o_ThreadView {
+ background-color: gray('100');
+
+}
+
+.o_ThreadView_loadingIcon {
+ margin-right: 3px;
+}
diff --git a/addons/mail/static/src/components/thread_view/thread_view.xml b/addons/mail/static/src/components/thread_view/thread_view.xml
new file mode 100644
index 00000000..8f06bf39
--- /dev/null
+++ b/addons/mail/static/src/components/thread_view/thread_view.xml
@@ -0,0 +1,50 @@
+<?xml version="1.0" encoding="UTF-8"?>
+<templates xml:space="preserve">
+
+ <t t-name="mail.ThreadView" owl="1">
+ <div class="o_ThreadView" t-att-data-correspondent-id="threadView and threadView.thread and threadView.thread.correspondent and threadView.thread.correspondent.id" t-att-data-thread-local-id="threadView and threadView.thread and threadView.thread.localId">
+ <t t-if="threadView">
+ <t t-if="threadView.isLoading and !threadView.threadCache.isLoaded" name="loadingCondition">
+ <div class="o_ThreadView_loading">
+ <span><i class="o_ThreadView_loadingIcon fa fa-spinner fa-spin" title="Loading..." role="img"/>Loading...</span>
+ </div>
+ </t>
+ <t t-elif="threadView.threadCache.isLoaded or threadView.thread.isTemporary">
+ <MessageList
+ class="o_ThreadView_messageList"
+ getScrollableElement= "props.getScrollableElement"
+ hasMessageCheckbox="props.hasMessageCheckbox"
+ hasScrollAdjust="props.hasScrollAdjust"
+ hasSquashCloseMessages="props.hasSquashCloseMessages"
+ haveMessagesMarkAsReadIcon="props.haveMessagesMarkAsReadIcon"
+ haveMessagesReplyIcon="props.haveMessagesReplyIcon"
+ order="props.order"
+ selectedMessageLocalId="props.selectedMessageLocalId"
+ threadViewLocalId="threadView.localId"
+ t-ref="messageList"
+ />
+ </t>
+ <t t-elif="props.hasComposer">
+ <div class="o-autogrow"/>
+ </t>
+ <t t-if="props.hasComposer">
+ <Composer
+ class="o_ThreadView_composer"
+ attachmentsDetailsMode="props.composerAttachmentsDetailsMode"
+ composerLocalId="threadView.thread.composer.localId"
+ hasCurrentPartnerAvatar="props.hasComposerCurrentPartnerAvatar"
+ hasSendButton="props.hasComposerSendButton"
+ hasThreadTyping="props.hasComposerThreadTyping"
+ isCompact="(threadView.thread.model === 'mail.channel' and threadView.thread.mass_mailing) ? false : undefined"
+ isDoFocus="props.isDoFocus"
+ showAttachmentsExtensions="props.showComposerAttachmentsExtensions"
+ showAttachmentsFilenames="props.showComposerAttachmentsFilenames"
+ textInputSendShortcuts="threadView.textInputSendShortcuts"
+ t-ref="composer"
+ />
+ </t>
+ </t>
+ </div>
+ </t>
+
+</templates>
diff --git a/addons/mail/static/src/components/thread_view/thread_view_tests.js b/addons/mail/static/src/components/thread_view/thread_view_tests.js
new file mode 100644
index 00000000..58a05989
--- /dev/null
+++ b/addons/mail/static/src/components/thread_view/thread_view_tests.js
@@ -0,0 +1,1809 @@
+odoo.define('mail/static/src/components/thread_view/thread_view_tests.js', function (require) {
+'use strict';
+
+const components = {
+ ThreadView: require('mail/static/src/components/thread_view/thread_view.js'),
+};
+const {
+ afterEach,
+ afterNextRender,
+ beforeEach,
+ createRootComponent,
+ dragenterFiles,
+ start,
+} = require('mail/static/src/utils/test_utils.js');
+
+QUnit.module('mail', {}, function () {
+QUnit.module('components', {}, function () {
+QUnit.module('thread_view', {}, function () {
+QUnit.module('thread_view_tests.js', {
+ beforeEach() {
+ beforeEach(this);
+
+ /**
+ * @param {mail.thread_view} threadView
+ * @param {Object} [otherProps={}]
+ * @param {Object} [param2={}]
+ * @param {boolean} [param2.isFixedSize=false]
+ */
+ this.createThreadViewComponent = async (threadView, otherProps = {}, { isFixedSize = false } = {}) => {
+ 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;
+ }
+ const props = Object.assign({ threadViewLocalId: threadView.localId }, otherProps);
+ await createRootComponent(this, components.ThreadView, { props, target });
+ };
+
+ this.start = async params => {
+ const { afterEvent, env, widget } = await start(Object.assign({}, params, {
+ data: this.data,
+ }));
+ this.afterEvent = afterEvent;
+ this.env = env;
+ this.widget = widget;
+ };
+ },
+ afterEach() {
+ afterEach(this);
+ },
+});
+
+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({
+ hasThreadView: true,
+ thread: [['link', thread]],
+ });
+ await this.createThreadViewComponent(threadViewer.threadView, { hasComposer: true });
+ await afterNextRender(() =>
+ dragenterFiles(document.querySelector('.o_ThreadView'))
+ );
+ 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(5);
+
+ for (let i = 0; i <= 60; i++) {
+ this.data['mail.message'].records.push({
+ body: "not empty",
+ channel_ids: [100],
+ model: 'mail.channel',
+ res_id: 100,
+ });
+ }
+ 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({
+ hasThreadView: true,
+ thread: [['link', thread]],
+ });
+ await this.afterEvent({
+ eventName: 'o-thread-view-hint-processed',
+ func: () => this.createThreadViewComponent(threadViewer.threadView, { order: 'desc' }, { isFixedSize: true }),
+ message: "should wait until channel 100 loaded initial messages",
+ predicate: ({ hint, threadViewer }) => {
+ return (
+ hint.type === 'messages-loaded' &&
+ threadViewer.thread.model === 'mail.channel' &&
+ threadViewer.thread.id === 100
+ );
+ },
+ });
+ 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 this.afterEvent({
+ eventName: 'o-thread-view-hint-processed',
+ func: () => {
+ const messageList = document.querySelector('.o_ThreadView_messageList');
+ messageList.scrollTop = messageList.scrollHeight - messageList.clientHeight;
+ },
+ message: "should wait until channel 100 loaded more messages after scrolling to bottom",
+ predicate: ({ hint, threadViewer }) => {
+ return (
+ hint.type === 'more-messages-loaded' &&
+ threadViewer.thread.model === 'mail.channel' &&
+ threadViewer.thread.id === 100
+ );
+ },
+ });
+ assert.strictEqual(
+ document.querySelectorAll(`.o_Message`).length,
+ 60,
+ "should have 60 messages after scrolled to bottom"
+ );
+
+ await afterNextRender(() => {
+ document.querySelector(`.o_ThreadView_messageList`).scrollTop = 0;
+ });
+ assert.strictEqual(
+ document.querySelectorAll(`.o_Message`).length,
+ 60,
+ "scrolling to top should not trigger any message fetching"
+ );
+});
+
+QUnit.test('message list asc order', async function (assert) {
+ assert.expect(5);
+
+ for (let i = 0; i <= 60; i++) {
+ this.data['mail.message'].records.push({
+ body: "not empty",
+ channel_ids: [100],
+ model: 'mail.channel',
+ res_id: 100,
+ });
+ }
+ 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({
+ hasThreadView: true,
+ thread: [['link', thread]],
+ });
+ await this.afterEvent({
+ eventName: 'o-thread-view-hint-processed',
+ func: () => this.createThreadViewComponent(threadViewer.threadView, { order: 'asc' }, { isFixedSize: true }),
+ message: "should wait until channel 100 loaded initial messages",
+ predicate: ({ hint, threadViewer }) => {
+ return (
+ hint.type === 'messages-loaded' &&
+ threadViewer.thread.model === 'mail.channel' &&
+ threadViewer.thread.id === 100
+ );
+ },
+ });
+ 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 this.afterEvent({
+ eventName: 'o-thread-view-hint-processed',
+ func: () => document.querySelector(`.o_ThreadView_messageList`).scrollTop = 0,
+ message: "should wait until channel 100 loaded more messages after scrolling to top",
+ predicate: ({ hint, threadViewer }) => {
+ return (
+ hint.type === 'more-messages-loaded' &&
+ threadViewer.thread.model === 'mail.channel' &&
+ threadViewer.thread.id === 100
+ );
+ },
+ });
+ assert.strictEqual(
+ document.querySelectorAll(`.o_Message`).length,
+ 60,
+ "should have 60 messages after scrolled to top"
+ );
+
+ // scroll to bottom
+ await afterNextRender(() => {
+ document.querySelector(`.o_ThreadView_messageList`).scrollTop =
+ document.querySelector(`.o_ThreadView_messageList`).scrollHeight;
+ });
+ assert.strictEqual(
+ document.querySelectorAll(`.o_Message`).length,
+ 60,
+ "scrolling to bottom should not trigger any message fetching"
+ );
+});
+
+QUnit.test('mark channel as fetched when a new message is loaded and as seen when focusing composer [REQUIRE FOCUS]', async function (assert) {
+ assert.expect(8);
+
+ this.data['res.partner'].records.push({
+ email: "fred@example.com",
+ id: 10,
+ name: "Fred",
+ });
+ this.data['res.users'].records.push({
+ id: 10,
+ partner_id: 10,
+ });
+ this.data['mail.channel'].records.push({
+ channel_type: 'chat',
+ id: 100,
+ is_pinned: true,
+ members: [this.data.currentPartnerId, 10],
+ });
+ 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_fetch');
+ } 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');
+ }
+ return this._super(...arguments);
+ }
+ });
+ const thread = this.env.models['mail.thread'].findFromIdentifyingData({
+ id: 100,
+ model: 'mail.channel',
+ });
+ const threadViewer = this.env.models['mail.thread_viewer'].create({
+ hasThreadView: true,
+ thread: [['link', thread]],
+ });
+ await this.createThreadViewComponent(threadViewer.threadView, { hasComposer: true });
+ await afterNextRender(async () => this.env.services.rpc({
+ route: '/mail/chat_post',
+ params: {
+ context: {
+ mockedUserId: 10,
+ },
+ message_content: "new message",
+ uuid: thread.uuid,
+ },
+ }));
+ assert.verifySteps(
+ ['rpc:channel_fetch'],
+ "Channel should have been fetched but not seen yet"
+ );
+
+ await afterNextRender(() => this.afterEvent({
+ eventName: 'o-thread-last-seen-by-current-partner-message-id-changed',
+ func: () => document.querySelector('.o_ComposerTextInput_textarea').focus(),
+ message: "should wait until last seen by current partner message id changed after focusing the thread",
+ predicate: ({ thread }) => {
+ return (
+ thread.id === 100 &&
+ thread.model === 'mail.channel'
+ );
+ },
+ }));
+ assert.verifySteps(
+ ['rpc:channel_seen'],
+ "Channel should have been marked as seen after threadView got the focus"
+ );
+});
+
+QUnit.test('mark channel as fetched and seen when a new message is loaded if composer is focused [REQUIRE FOCUS]', async function (assert) {
+ assert.expect(4);
+
+ this.data['res.partner'].records.push({
+ id: 10,
+ });
+ this.data['res.users'].records.push({
+ id: 10,
+ partner_id: 10,
+ });
+ this.data['mail.channel'].records.push({
+ id: 100,
+ });
+ await this.start({
+ mockRPC(route, args) {
+ if (args.method === 'channel_fetched' && args.args[0] === 100) {
+ throw new Error("'channel_fetched' RPC must not be called for created channel as message is directly seen");
+ } 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_seen is called on the right channel model'
+ );
+ assert.step('rpc:channel_seen');
+ }
+ return this._super(...arguments);
+ }
+ });
+ const thread = this.env.models['mail.thread'].findFromIdentifyingData({
+ id: 100,
+ model: 'mail.channel',
+ });
+ const threadViewer = this.env.models['mail.thread_viewer'].create({
+ hasThreadView: true,
+ thread: [['link', thread]],
+ });
+ await this.createThreadViewComponent(threadViewer.threadView, { hasComposer: true });
+ document.querySelector('.o_ComposerTextInput_textarea').focus();
+ // simulate receiving a message
+ await this.afterEvent({
+ eventName: 'o-thread-last-seen-by-current-partner-message-id-changed',
+ func: () => this.env.services.rpc({
+ route: '/mail/chat_post',
+ params: {
+ context: {
+ mockedUserId: 10,
+ },
+ message_content: "<p>fdsfsd</p>",
+ uuid: thread.uuid,
+ },
+ }),
+ message: "should wait until last seen by current partner message id changed after receiving a message while thread is focused",
+ predicate: ({ thread }) => {
+ return (
+ thread.id === 100 &&
+ thread.model === 'mail.channel'
+ );
+ },
+ });
+ assert.verifySteps(
+ ['rpc:channel_seen'],
+ "Channel should have been mark as seen directly"
+ );
+});
+
+QUnit.test('show message subject if thread is mailing channel', async function (assert) {
+ assert.expect(3);
+
+ this.data['mail.message'].records.push({
+ body: "not empty",
+ channel_ids: [100],
+ model: 'mail.channel',
+ res_id: 100,
+ subject: "Salutations, voyageur",
+ });
+ await this.start();
+ const thread = this.env.models['mail.thread'].create({
+ channel_type: 'channel',
+ id: 100,
+ mass_mailing: true,
+ model: 'mail.channel',
+ name: "General",
+ public: 'public',
+ });
+ const threadViewer = this.env.models['mail.thread_viewer'].create({
+ hasThreadView: true,
+ thread: [['link', thread]],
+ });
+ await this.createThreadViewComponent(threadViewer.threadView);
+
+ assert.containsOnce(
+ document.body,
+ '.o_Message',
+ "should display a single message"
+ );
+ assert.containsOnce(
+ document.body,
+ '.o_Message_subject',
+ "should display subject of the message"
+ );
+ assert.strictEqual(
+ document.querySelector('.o_Message_subject').textContent,
+ "Subject: Salutations, voyageur",
+ "Subject of the message should be 'Salutations, voyageur'"
+ );
+});
+
+QUnit.test('[technical] new messages separator on posting message', async function (assert) {
+ // technical as we need to remove focus from text input to avoid `channel_seen` call
+ assert.expect(4);
+
+ this.data['mail.channel'].records = [{
+ channel_type: 'channel',
+ id: 20,
+ is_pinned: true,
+ message_unread_counter: 0,
+ seen_message_id: 10,
+ name: "General",
+ }];
+ this.data['mail.message'].records.push({
+ body: "first message",
+ channel_ids: [20],
+ id: 10,
+ });
+ await this.start();
+ const thread = this.env.models['mail.thread'].findFromIdentifyingData({
+ id: 20,
+ model: 'mail.channel'
+ });
+ const threadViewer = this.env.models['mail.thread_viewer'].create({
+ hasThreadView: true,
+ thread: [['link', thread]],
+ });
+ await this.createThreadViewComponent(threadViewer.threadView, { hasComposer: true });
+
+ assert.containsOnce(
+ document.body,
+ '.o_Message',
+ "should display one message in thread initially"
+ );
+ assert.containsNone(
+ document.body,
+ '.o_MessageList_separatorNewMessages',
+ "should not display 'new messages' separator"
+ );
+
+ document.querySelector('.o_ComposerTextInput_textarea').focus();
+ await afterNextRender(() => document.execCommand('insertText', false, "hey !"));
+ await afterNextRender(() => {
+ // need to remove focus from text area to avoid channel_seen
+ document.querySelector('.o_Composer_buttonSend').focus();
+ document.querySelector('.o_Composer_buttonSend').click();
+
+ });
+ assert.containsN(
+ document.body,
+ '.o_Message',
+ 2,
+ "should display 2 messages (initial & newly posted), after posting a message"
+ );
+ assert.containsNone(
+ document.body,
+ '.o_MessageList_separatorNewMessages',
+ "still no separator shown when current partner posted a message"
+ );
+});
+
+QUnit.test('new messages separator on receiving new message [REQUIRE FOCUS]', async function (assert) {
+ assert.expect(6);
+
+ this.data['res.partner'].records.push({
+ id: 11,
+ name: "Foreigner partner",
+ });
+ this.data['res.users'].records.push({
+ id: 42,
+ name: "Foreigner user",
+ partner_id: 11,
+ });
+ this.data['mail.channel'].records.push({
+ channel_type: 'channel',
+ id: 20,
+ is_pinned: true,
+ message_unread_counter: 0,
+ name: "General",
+ seen_message_id: 1,
+ uuid: 'randomuuid',
+ });
+ this.data['mail.message'].records.push({
+ body: "blah",
+ channel_ids: [20],
+ id: 1,
+ });
+ await this.start();
+ const thread = this.env.models['mail.thread'].findFromIdentifyingData({
+ id: 20,
+ model: 'mail.channel'
+ });
+ const threadViewer = this.env.models['mail.thread_viewer'].create({
+ hasThreadView: true,
+ thread: [['link', thread]],
+ });
+ await this.createThreadViewComponent(threadViewer.threadView, { hasComposer: true });
+
+ assert.containsOnce(
+ document.body,
+ '.o_MessageList_message',
+ "should have an initial message"
+ );
+ assert.containsNone(
+ document.body,
+ '.o_MessageList_separatorNewMessages',
+ "should not display 'new messages' separator"
+ );
+
+ document.querySelector('.o_ComposerTextInput_textarea').blur();
+ // simulate receiving a message
+ await this.afterEvent({
+ eventName: 'o-thread-view-hint-processed',
+ func: () => this.env.services.rpc({
+ route: '/mail/chat_post',
+ params: {
+ context: {
+ mockedUserId: 42,
+ },
+ message_content: "hu",
+ uuid: thread.uuid,
+ },
+ }),
+ message: "should wait until new message is received",
+ predicate: ({ hint, threadViewer }) => {
+ return (
+ threadViewer.thread.id === 20 &&
+ threadViewer.thread.model === 'mail.channel' &&
+ hint.type === 'message-received'
+ );
+ },
+ });
+ assert.containsN(
+ document.body,
+ '.o_Message',
+ 2,
+ "should now have 2 messages after receiving a new message"
+ );
+ assert.containsOnce(
+ document.body,
+ '.o_MessageList_separatorNewMessages',
+ "'new messages' separator should be shown"
+ );
+
+ assert.containsOnce(
+ document.body,
+ `.o_MessageList_separatorNewMessages ~ .o_Message[data-message-local-id="${
+ this.env.models['mail.message'].findFromIdentifyingData({ id: 2 }).localId
+ }"]`,
+ "'new messages' separator should be shown above new message received"
+ );
+
+ await afterNextRender(() => this.afterEvent({
+ eventName: 'o-thread-last-seen-by-current-partner-message-id-changed',
+ func: () => document.querySelector('.o_ComposerTextInput_textarea').focus(),
+ message: "should wait until last seen by current partner message id changed after focusing the thread",
+ predicate: ({ thread }) => {
+ return (
+ thread.id === 20 &&
+ thread.model === 'mail.channel'
+ );
+ },
+ }));
+ assert.containsNone(
+ document.body,
+ '.o_MessageList_separatorNewMessages',
+ "'new messages' separator should no longer be shown as last message has been seen"
+ );
+});
+
+QUnit.test('new messages separator on posting message', async function (assert) {
+ assert.expect(4);
+
+ this.data['mail.channel'].records = [{
+ channel_type: 'channel',
+ id: 20,
+ is_pinned: true,
+ message_unread_counter: 0,
+ name: "General",
+ }];
+ await this.start();
+ const thread = this.env.models['mail.thread'].findFromIdentifyingData({
+ id: 20,
+ model: 'mail.channel'
+ });
+ const threadViewer = this.env.models['mail.thread_viewer'].create({
+ hasThreadView: true,
+ thread: [['link', thread]],
+ });
+ await this.createThreadViewComponent(threadViewer.threadView, { hasComposer: true });
+
+ assert.containsNone(
+ document.body,
+ '.o_MessageList_message',
+ "should have no messages"
+ );
+ assert.containsNone(
+ document.body,
+ '.o_MessageList_separatorNewMessages',
+ "should not display 'new messages' separator"
+ );
+
+ document.querySelector('.o_ComposerTextInput_textarea').focus();
+ await afterNextRender(() => document.execCommand('insertText', false, "hey !"));
+ await afterNextRender(() =>
+ document.querySelector('.o_Composer_buttonSend').click()
+ );
+ assert.containsOnce(
+ document.body,
+ '.o_Message',
+ "should have the message current partner just posted"
+ );
+ assert.containsNone(
+ document.body,
+ '.o_MessageList_separatorNewMessages',
+ "still no separator shown when current partner posted a message"
+ );
+});
+
+QUnit.test('basic rendering of canceled notification', async function (assert) {
+ assert.expect(8);
+
+ this.data['mail.channel'].records.push({ id: 11 });
+ this.data['res.partner'].records.push({ id: 12, name: "Someone" });
+ this.data['mail.message'].records.push({
+ body: "not empty",
+ channel_ids: [11],
+ id: 10,
+ message_type: 'email',
+ model: 'mail.channel',
+ notification_ids: [11],
+ res_id: 11,
+ });
+ this.data['mail.notification'].records.push({
+ failure_type: 'SMTP',
+ id: 11,
+ mail_message_id: 10,
+ notification_status: 'canceled',
+ notification_type: 'email',
+ res_partner_id: 12,
+ });
+ await this.start();
+ const threadViewer = await this.env.models['mail.thread_viewer'].create({
+ hasThreadView: true,
+ thread: [['insert', {
+ id: 11,
+ model: 'mail.channel',
+ }]],
+ });
+ await this.afterEvent({
+ eventName: 'o-thread-view-hint-processed',
+ func: () => {
+ this.createThreadViewComponent(threadViewer.threadView);
+ },
+ message: "thread become loaded with messages",
+ predicate: ({ hint, threadViewer }) => {
+ return (
+ hint.type === 'messages-loaded' &&
+ threadViewer.thread.model === 'mail.channel' &&
+ threadViewer.thread.id === 11
+ );
+ },
+ });
+
+ assert.containsOnce(
+ document.body,
+ '.o_Message_notificationIconClickable',
+ "should display the notification icon container on the message"
+ );
+ assert.containsOnce(
+ document.body,
+ '.o_Message_notificationIcon',
+ "should display the notification icon on the message"
+ );
+ assert.hasClass(
+ document.querySelector('.o_Message_notificationIcon'),
+ 'fa-envelope-o',
+ "notification icon shown on the message should represent email"
+ );
+
+ await afterNextRender(() => {
+ document.querySelector('.o_Message_notificationIconClickable').click();
+ });
+ assert.containsOnce(
+ document.body,
+ '.o_NotificationPopover',
+ "notification popover should be opened after notification has been clicked"
+ );
+ assert.containsOnce(
+ document.body,
+ '.o_NotificationPopover_notificationIcon',
+ "an icon should be shown in notification popover"
+ );
+ assert.containsOnce(
+ document.body,
+ '.o_NotificationPopover_notificationIcon.fa.fa-trash-o',
+ "the icon shown in notification popover should be the canceled icon"
+ );
+ assert.containsOnce(
+ document.body,
+ '.o_NotificationPopover_notificationPartnerName',
+ "partner name should be shown in notification popover"
+ );
+ assert.strictEqual(
+ document.querySelector('.o_NotificationPopover_notificationPartnerName').textContent.trim(),
+ "Someone",
+ "partner name shown in notification popover should be the one concerned by the notification"
+ );
+});
+
+QUnit.test('should scroll to bottom on receiving new message if the list is initially scrolled to bottom (asc order)', async function (assert) {
+ assert.expect(2);
+
+ // Needed partner & user to allow simulation of message reception
+ this.data['res.partner'].records.push({
+ id: 11,
+ name: "Foreigner partner",
+ });
+ this.data['res.users'].records.push({
+ id: 42,
+ name: "Foreigner user",
+ partner_id: 11,
+ });
+ this.data['mail.channel'].records.push({ id: 20 });
+ for (let i = 0; i <= 10; i++) {
+ this.data['mail.message'].records.push({
+ body: "not empty",
+ channel_ids: [20],
+ });
+ }
+ await this.start();
+ const thread = this.env.models['mail.thread'].findFromIdentifyingData({
+ id: 20,
+ model: 'mail.channel'
+ });
+ const threadViewer = this.env.models['mail.thread_viewer'].create({
+ hasThreadView: true,
+ thread: [['link', thread]],
+ });
+ await this.afterEvent({
+ eventName: 'o-component-message-list-scrolled',
+ func: () => this.createThreadViewComponent(
+ threadViewer.threadView,
+ { order: 'asc' },
+ { isFixedSize: true },
+ ),
+ message: "should wait until channel 20 scrolled initially",
+ predicate: data => threadViewer === data.threadViewer,
+ });
+ const initialMessageList = document.querySelector('.o_ThreadView_messageList');
+ assert.strictEqual(
+ initialMessageList.scrollTop,
+ initialMessageList.scrollHeight - initialMessageList.clientHeight,
+ "should have scrolled to bottom of channel 20 initially"
+ );
+
+ // simulate receiving a message
+ await this.afterEvent({
+ eventName: 'o-component-message-list-scrolled',
+ func: () =>
+ this.env.services.rpc({
+ route: '/mail/chat_post',
+ params: {
+ context: {
+ mockedUserId: 42,
+ },
+ message_content: "hello",
+ uuid: thread.uuid,
+ },
+ }),
+ message: "should wait until channel 20 scrolled after receiving a message",
+ predicate: data => threadViewer === data.threadViewer,
+ });
+ const messageList = document.querySelector('.o_ThreadView_messageList');
+ assert.strictEqual(
+ messageList.scrollTop,
+ messageList.scrollHeight - messageList.clientHeight,
+ "should scroll to bottom on receiving new message because the list is initially scrolled to bottom"
+ );
+});
+
+QUnit.test('should not scroll on receiving new message if the list is initially scrolled anywhere else than bottom (asc order)', async function (assert) {
+ assert.expect(3);
+
+ // Needed partner & user to allow simulation of message reception
+ this.data['res.partner'].records.push({
+ id: 11,
+ name: "Foreigner partner",
+ });
+ this.data['res.users'].records.push({
+ id: 42,
+ name: "Foreigner user",
+ partner_id: 11,
+ });
+ this.data['mail.channel'].records.push({ id: 20 });
+ for (let i = 0; i <= 10; i++) {
+ this.data['mail.message'].records.push({
+ body: "not empty",
+ channel_ids: [20],
+ });
+ }
+ await this.start();
+ const thread = this.env.models['mail.thread'].findFromIdentifyingData({
+ id: 20,
+ model: 'mail.channel'
+ });
+ const threadViewer = this.env.models['mail.thread_viewer'].create({
+ hasThreadView: true,
+ thread: [['link', thread]],
+ });
+ await this.afterEvent({
+ eventName: 'o-component-message-list-scrolled',
+ func: () => this.createThreadViewComponent(
+ threadViewer.threadView,
+ { order: 'asc' },
+ { isFixedSize: true },
+ ),
+ message: "should wait until channel 20 scrolled initially",
+ predicate: data => threadViewer === data.threadViewer,
+ });
+ const initialMessageList = document.querySelector('.o_ThreadView_messageList');
+ assert.strictEqual(
+ initialMessageList.scrollTop,
+ initialMessageList.scrollHeight - initialMessageList.clientHeight,
+ "should have scrolled to bottom of channel 20 initially"
+ );
+
+ await this.afterEvent({
+ eventName: 'o-component-message-list-scrolled',
+ func: () => initialMessageList.scrollTop = 0,
+ message: "should wait until channel 20 processed manual scroll",
+ predicate: data => threadViewer === data.threadViewer,
+ });
+ assert.strictEqual(
+ initialMessageList.scrollTop,
+ 0,
+ "should have scrolled to the top of channel 20 manually"
+ );
+
+ // simulate receiving a message
+ await this.afterEvent({
+ eventName: 'o-thread-view-hint-processed',
+ func: () =>
+ this.env.services.rpc({
+ route: '/mail/chat_post',
+ params: {
+ context: {
+ mockedUserId: 42,
+ },
+ message_content: "hello",
+ uuid: thread.uuid,
+ },
+ }),
+ message: "should wait until channel 20 processed new message hint",
+ predicate: data => threadViewer === data.threadViewer && data.hint.type === 'message-received',
+ });
+ assert.strictEqual(
+ document.querySelector('.o_ThreadView_messageList').scrollTop,
+ 0,
+ "should not scroll on receiving new message because the list is initially scrolled anywhere else than bottom"
+ );
+});
+
+QUnit.test("delete all attachments of message without content should no longer display the message", async function (assert) {
+ assert.expect(2);
+
+ this.data['ir.attachment'].records.push({
+ id: 143,
+ mimetype: 'text/plain',
+ name: "Blah.txt",
+ });
+ this.data['mail.channel'].records.push({ id: 11 });
+ this.data['mail.message'].records.push(
+ {
+ attachment_ids: [143],
+ channel_ids: [11],
+ id: 101,
+ }
+ );
+ await this.start();
+ const threadViewer = this.env.models['mail.thread_viewer'].create({
+ hasThreadView: true,
+ thread: [['insert', { id: 11, model: 'mail.channel' }]],
+ });
+ // wait for messages of the thread to be loaded
+ await this.afterEvent({
+ eventName: 'o-thread-view-hint-processed',
+ func: () => {
+ this.createThreadViewComponent(threadViewer.threadView);
+ },
+ message: "thread become loaded with messages",
+ predicate: ({ hint, threadViewer }) => {
+ return (
+ hint.type === 'messages-loaded' &&
+ threadViewer.thread.model === 'mail.channel' &&
+ threadViewer.thread.id === 11
+ );
+ },
+ });
+ assert.containsOnce(
+ document.body,
+ '.o_Message',
+ "there should be 1 message displayed initially"
+ );
+
+ await afterNextRender(() => {
+ document.querySelector(`.o_Attachment[data-attachment-local-id="${
+ this.env.models['mail.attachment'].findFromIdentifyingData({ id: 143 }).localId
+ }"] .o_Attachment_asideItemUnlink`).click();
+ });
+ await afterNextRender(() =>
+ document.querySelector('.o_AttachmentDeleteConfirmDialog_confirmButton').click()
+ );
+ assert.containsNone(
+ document.body,
+ '.o_Message',
+ "message should no longer be displayed after removing all its attachments (empty content)"
+ );
+});
+
+QUnit.test('delete all attachments of a message with some text content should still keep it displayed', async function (assert) {
+ assert.expect(2);
+
+ this.data['ir.attachment'].records.push({
+ id: 143,
+ mimetype: 'text/plain',
+ name: "Blah.txt",
+ });
+ this.data['mail.channel'].records.push({ id: 11 });
+ this.data['mail.message'].records.push(
+ {
+ attachment_ids: [143],
+ body: "Some content",
+ channel_ids: [11],
+ id: 101,
+ },
+ );
+ await this.start();
+ const threadViewer = this.env.models['mail.thread_viewer'].create({
+ hasThreadView: true,
+ thread: [['insert', { id: 11, model: 'mail.channel' }]],
+ });
+ // wait for messages of the thread to be loaded
+ await this.afterEvent({
+ eventName: 'o-thread-view-hint-processed',
+ func: () => {
+ this.createThreadViewComponent(threadViewer.threadView);
+ },
+ message: "thread become loaded with messages",
+ predicate: ({ hint, threadViewer }) => {
+ return (
+ hint.type === 'messages-loaded' &&
+ threadViewer.thread.model === 'mail.channel' &&
+ threadViewer.thread.id === 11
+ );
+ },
+ });
+ assert.containsOnce(
+ document.body,
+ '.o_Message',
+ "there should be 1 message displayed initially"
+ );
+
+ await afterNextRender(() => {
+ document.querySelector(`.o_Attachment[data-attachment-local-id="${
+ this.env.models['mail.attachment'].findFromIdentifyingData({ id: 143 }).localId
+ }"] .o_Attachment_asideItemUnlink`).click();
+ });
+ await afterNextRender(() =>
+ document.querySelector('.o_AttachmentDeleteConfirmDialog_confirmButton').click()
+ );
+ assert.containsOnce(
+ document.body,
+ '.o_Message',
+ "message should still be displayed after removing its attachments (non-empty content)"
+ );
+});
+
+QUnit.test('delete all attachments of a message with tracking fields should still keep it displayed', async function (assert) {
+ assert.expect(2);
+
+ this.data['ir.attachment'].records.push({
+ id: 143,
+ mimetype: 'text/plain',
+ name: "Blah.txt",
+ });
+ this.data['mail.channel'].records.push({ id: 11 });
+ this.data['mail.message'].records.push(
+ {
+ attachment_ids: [143],
+ channel_ids: [11],
+ id: 101,
+ tracking_value_ids: [6]
+ },
+ );
+ this.data['mail.tracking.value'].records.push({
+ changed_field: "Name",
+ field_type: "char",
+ id: 6,
+ new_value: "New name",
+ old_value: "Old name",
+ });
+ await this.start();
+ const threadViewer = this.env.models['mail.thread_viewer'].create({
+ hasThreadView: true,
+ thread: [['insert', { id: 11, model: 'mail.channel' }]],
+ });
+ // wait for messages of the thread to be loaded
+ await this.afterEvent({
+ eventName: 'o-thread-view-hint-processed',
+ func: () => {
+ this.createThreadViewComponent(threadViewer.threadView);
+ },
+ message: "thread become loaded with messages",
+ predicate: ({ hint, threadViewer }) => {
+ return (
+ hint.type === 'messages-loaded' &&
+ threadViewer.thread.model === 'mail.channel' &&
+ threadViewer.thread.id === 11
+ );
+ },
+ });
+ assert.containsOnce(
+ document.body,
+ '.o_Message',
+ "there should be 1 message displayed initially"
+ );
+
+ await afterNextRender(() => {
+ document.querySelector(`.o_Attachment[data-attachment-local-id="${
+ this.env.models['mail.attachment'].findFromIdentifyingData({ id: 143 }).localId
+ }"] .o_Attachment_asideItemUnlink`).click();
+ });
+ await afterNextRender(() =>
+ document.querySelector('.o_AttachmentDeleteConfirmDialog_confirmButton').click()
+ );
+ assert.containsOnce(
+ document.body,
+ '.o_Message',
+ "message should still be displayed after removing its attachments (non-empty content)"
+ );
+});
+
+QUnit.test('Post a message containing an email address followed by a mention on another line', async function (assert) {
+ assert.expect(1);
+
+ this.data['mail.channel'].records.push({ id: 11 });
+ this.data['res.partner'].records.push({
+ id: 25,
+ email: "testpartner@odoo.com",
+ name: "TestPartner",
+ });
+ await this.start();
+ const thread = this.env.models['mail.thread'].findFromIdentifyingData({
+ id: 11,
+ model: 'mail.channel',
+ });
+ const threadViewer = this.env.models['mail.thread_viewer'].create({
+ hasThreadView: true,
+ thread: [['link', thread]],
+ });
+ await this.createThreadViewComponent(threadViewer.threadView, { hasComposer: true });
+ document.querySelector('.o_ComposerTextInput_textarea').focus();
+ await afterNextRender(() => document.execCommand('insertText', false, "email@odoo.com\n"));
+ await afterNextRender(() => {
+ ["@", "T", "e"].forEach((char)=>{
+ document.execCommand('insertText', false, char);
+ document.querySelector(`.o_ComposerTextInput_textarea`)
+ .dispatchEvent(new window.KeyboardEvent('keydown'));
+ document.querySelector(`.o_ComposerTextInput_textarea`)
+ .dispatchEvent(new window.KeyboardEvent('keyup'));
+ });
+ });
+ await afterNextRender(() =>
+ document.querySelector('.o_ComposerSuggestion').click()
+ );
+ await afterNextRender(() => {
+ document.querySelector('.o_Composer_buttonSend').click();
+ });
+ assert.containsOnce(
+ document.querySelector(`.o_Message_content`),
+ `.o_mail_redirect[data-oe-id="25"][data-oe-model="res.partner"]:contains("@TestPartner")`,
+ "Conversation should have a message that has been posted, which contains partner mention"
+ );
+});
+
+QUnit.test(`Mention a partner with special character (e.g. apostrophe ')`, async function (assert) {
+ assert.expect(1);
+
+ this.data['mail.channel'].records.push({ id: 11 });
+ this.data['res.partner'].records.push({
+ id: 1952,
+ email: "usatyi@example.com",
+ name: "Pynya's spokesman",
+ });
+ await this.start();
+ const thread = this.env.models['mail.thread'].findFromIdentifyingData({
+ id: 11,
+ model: 'mail.channel',
+ });
+ const threadViewer = this.env.models['mail.thread_viewer'].create({
+ hasThreadView: true,
+ thread: [['link', thread]],
+ });
+ await this.createThreadViewComponent(threadViewer.threadView, { hasComposer: true });
+ document.querySelector('.o_ComposerTextInput_textarea').focus();
+ await afterNextRender(() => {
+ ["@", "P", "y", "n"].forEach((char)=>{
+ document.execCommand('insertText', false, char);
+ document.querySelector(`.o_ComposerTextInput_textarea`)
+ .dispatchEvent(new window.KeyboardEvent('keydown'));
+ document.querySelector(`.o_ComposerTextInput_textarea`)
+ .dispatchEvent(new window.KeyboardEvent('keyup'));
+ });
+ });
+ await afterNextRender(() =>
+ document.querySelector('.o_ComposerSuggestion').click()
+ );
+ await afterNextRender(() => {
+ document.querySelector('.o_Composer_buttonSend').click();
+ });
+ assert.containsOnce(
+ document.querySelector(`.o_Message_content`),
+ `.o_mail_redirect[data-oe-id="1952"][data-oe-model="res.partner"]:contains("@Pynya's spokesman")`,
+ "Conversation should have a message that has been posted, which contains partner mention"
+ );
+});
+
+QUnit.test('mention 2 different partners that have the same name', async function (assert) {
+ assert.expect(3);
+
+ this.data['mail.channel'].records.push({ id: 11 });
+ this.data['res.partner'].records.push(
+ {
+ id: 25,
+ email: "partner1@example.com",
+ name: "TestPartner",
+ }, {
+ id: 26,
+ email: "partner2@example.com",
+ name: "TestPartner",
+ },
+ );
+ await this.start();
+ const thread = this.env.models['mail.thread'].findFromIdentifyingData({
+ id: 11,
+ model: 'mail.channel',
+ });
+ const threadViewer = this.env.models['mail.thread_viewer'].create({
+ hasThreadView: true,
+ thread: [['link', thread]],
+ });
+ await this.createThreadViewComponent(threadViewer.threadView, { hasComposer: true });
+ document.querySelector('.o_ComposerTextInput_textarea').focus();
+ await afterNextRender(() => {
+ ["@", "T", "e"].forEach((char)=>{
+ document.execCommand('insertText', false, char);
+ document.querySelector(`.o_ComposerTextInput_textarea`)
+ .dispatchEvent(new window.KeyboardEvent('keydown'));
+ document.querySelector(`.o_ComposerTextInput_textarea`)
+ .dispatchEvent(new window.KeyboardEvent('keyup'));
+ });
+ });
+ await afterNextRender(() => document.querySelectorAll('.o_ComposerSuggestion')[0].click());
+ await afterNextRender(() => {
+ ["@", "T", "e"].forEach((char)=>{
+ document.execCommand('insertText', false, char);
+ document.querySelector(`.o_ComposerTextInput_textarea`)
+ .dispatchEvent(new window.KeyboardEvent('keydown'));
+ document.querySelector(`.o_ComposerTextInput_textarea`)
+ .dispatchEvent(new window.KeyboardEvent('keyup'));
+ });
+ });
+ await afterNextRender(() => document.querySelectorAll('.o_ComposerSuggestion')[1].click());
+ await afterNextRender(() => document.querySelector('.o_Composer_buttonSend').click());
+ assert.containsOnce(document.body, '.o_Message_content', 'should have one message after posting it');
+ assert.containsOnce(
+ document.querySelector(`.o_Message_content`),
+ `.o_mail_redirect[data-oe-id="25"][data-oe-model="res.partner"]:contains("@TestPartner")`,
+ "message should contain the first partner mention"
+ );
+ assert.containsOnce(
+ document.querySelector(`.o_Message_content`),
+ `.o_mail_redirect[data-oe-id="26"][data-oe-model="res.partner"]:contains("@TestPartner")`,
+ "message should also contain the second partner mention"
+ );
+});
+
+QUnit.test('mention a channel with space in the name', async function (assert) {
+ assert.expect(2);
+
+ this.data['mail.channel'].records.push({
+ id: 7,
+ name: "General good boy",
+ });
+ await this.start();
+ const thread = this.env.models['mail.thread'].findFromIdentifyingData({
+ id: 7,
+ model: 'mail.channel',
+ });
+ const threadViewer = this.env.models['mail.thread_viewer'].create({
+ hasThreadView: true,
+ thread: [['link', thread]],
+ });
+ await this.createThreadViewComponent(threadViewer.threadView, { hasComposer: true });
+
+ 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'));
+ });
+ await afterNextRender(() =>
+ document.querySelector('.o_ComposerSuggestion').click()
+ );
+ await afterNextRender(() => {
+ document.querySelector('.o_Composer_buttonSend').click();
+ });
+ assert.containsOnce(
+ document.querySelector('.o_Message_content'),
+ '.o_channel_redirect',
+ "message must contain a link to the mentioned channel"
+ );
+ assert.strictEqual(
+ document.querySelector('.o_channel_redirect').textContent,
+ '#General good boy',
+ "link to the channel must contains # + the channel name"
+ );
+});
+
+QUnit.test('mention a channel with "&" in the name', async function (assert) {
+ assert.expect(2);
+
+ this.data['mail.channel'].records.push({
+ id: 7,
+ name: "General & good",
+ });
+ await this.start();
+ const thread = this.env.models['mail.thread'].findFromIdentifyingData({
+ id: 7,
+ model: 'mail.channel',
+ });
+ const threadViewer = this.env.models['mail.thread_viewer'].create({
+ hasThreadView: true,
+ thread: [['link', thread]],
+ });
+ await this.createThreadViewComponent(threadViewer.threadView, { hasComposer: true });
+
+ 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'));
+ });
+ await afterNextRender(() =>
+ document.querySelector('.o_ComposerSuggestion').click()
+ );
+ await afterNextRender(() => {
+ document.querySelector('.o_Composer_buttonSend').click();
+ });
+ assert.containsOnce(
+ document.querySelector('.o_Message_content'),
+ '.o_channel_redirect',
+ "message should contain a link to the mentioned channel"
+ );
+ assert.strictEqual(
+ document.querySelector('.o_channel_redirect').textContent,
+ '#General & good',
+ "link to the channel must contains # + the channel name"
+ );
+});
+
+QUnit.test('mention a channel on a second line when the first line contains #', async function (assert) {
+ assert.expect(2);
+
+ this.data['mail.channel'].records.push({
+ id: 7,
+ name: "General good",
+ });
+ await this.start();
+ const thread = this.env.models['mail.thread'].findFromIdentifyingData({
+ id: 7,
+ model: 'mail.channel',
+ });
+ const threadViewer = this.env.models['mail.thread_viewer'].create({
+ hasThreadView: true,
+ thread: [['link', thread]],
+ });
+ await this.createThreadViewComponent(threadViewer.threadView, { hasComposer: true });
+
+ await afterNextRender(() => {
+ document.querySelector(`.o_ComposerTextInput_textarea`).focus();
+ document.execCommand('insertText', false, "#blabla\n#");
+ document.querySelector(`.o_ComposerTextInput_textarea`)
+ .dispatchEvent(new window.KeyboardEvent('keydown'));
+ document.querySelector(`.o_ComposerTextInput_textarea`)
+ .dispatchEvent(new window.KeyboardEvent('keyup'));
+ });
+ await afterNextRender(() => {
+ document.querySelector('.o_ComposerSuggestion').click();
+ });
+ await afterNextRender(() => {
+ document.querySelector('.o_Composer_buttonSend').click();
+ });
+ assert.containsOnce(
+ document.querySelector('.o_Message_content'),
+ '.o_channel_redirect',
+ "message should contain a link to the mentioned channel"
+ );
+ assert.strictEqual(
+ document.querySelector('.o_channel_redirect').textContent,
+ '#General good',
+ "link to the channel must contains # + the channel name"
+ );
+});
+
+QUnit.test('mention a channel when replacing the space after the mention by another char', async function (assert) {
+ assert.expect(2);
+
+ this.data['mail.channel'].records.push({
+ id: 7,
+ name: "General good",
+ });
+ await this.start();
+ const thread = this.env.models['mail.thread'].findFromIdentifyingData({
+ id: 7,
+ model: 'mail.channel',
+ });
+ const threadViewer = this.env.models['mail.thread_viewer'].create({
+ hasThreadView: true,
+ thread: [['link', thread]],
+ });
+ await this.createThreadViewComponent(threadViewer.threadView, { hasComposer: true });
+
+ 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'));
+ });
+ await afterNextRender(() => {
+ document.querySelector('.o_ComposerSuggestion').click();
+ });
+ await afterNextRender(() => {
+ const text = document.querySelector(`.o_ComposerTextInput_textarea`).value;
+ document.querySelector(`.o_ComposerTextInput_textarea`).value = text.slice(0, -1);
+ document.execCommand('insertText', false, ", test");
+ document.querySelector(`.o_ComposerTextInput_textarea`)
+ .dispatchEvent(new window.KeyboardEvent('keydown'));
+ document.querySelector(`.o_ComposerTextInput_textarea`)
+ .dispatchEvent(new window.KeyboardEvent('keyup'));
+ });
+ await afterNextRender(() => {
+ document.querySelector('.o_Composer_buttonSend').click();
+ });
+ assert.containsOnce(
+ document.querySelector('.o_Message_content'),
+ '.o_channel_redirect',
+ "message should contain a link to the mentioned channel"
+ );
+ assert.strictEqual(
+ document.querySelector('.o_channel_redirect').textContent,
+ '#General good',
+ "link to the channel must contains # + the channel name"
+ );
+});
+
+QUnit.test('mention 2 different channels that have the same name', async function (assert) {
+ assert.expect(3);
+
+ this.data['mail.channel'].records.push(
+ {
+ id: 11,
+ name: "my channel",
+ public: 'public', // mentioning another channel is possible only from a public channel
+ },
+ {
+ id: 12,
+ name: "my channel",
+ },
+ );
+ await this.start();
+ const thread = this.env.models['mail.thread'].findFromIdentifyingData({
+ id: 11,
+ model: 'mail.channel',
+ });
+ const threadViewer = this.env.models['mail.thread_viewer'].create({
+ hasThreadView: true,
+ thread: [['link', thread]],
+ });
+ await this.createThreadViewComponent(threadViewer.threadView, { hasComposer: true });
+ document.querySelector('.o_ComposerTextInput_textarea').focus();
+ await afterNextRender(() => {
+ ["#", "m", "y"].forEach((char)=>{
+ document.execCommand('insertText', false, char);
+ document.querySelector(`.o_ComposerTextInput_textarea`)
+ .dispatchEvent(new window.KeyboardEvent('keydown'));
+ document.querySelector(`.o_ComposerTextInput_textarea`)
+ .dispatchEvent(new window.KeyboardEvent('keyup'));
+ });
+ });
+ await afterNextRender(() => document.querySelectorAll('.o_ComposerSuggestion')[0].click());
+ await afterNextRender(() => {
+ ["#", "m", "y"].forEach((char)=>{
+ document.execCommand('insertText', false, char);
+ document.querySelector(`.o_ComposerTextInput_textarea`)
+ .dispatchEvent(new window.KeyboardEvent('keydown'));
+ document.querySelector(`.o_ComposerTextInput_textarea`)
+ .dispatchEvent(new window.KeyboardEvent('keyup'));
+ });
+ });
+ await afterNextRender(() => document.querySelectorAll('.o_ComposerSuggestion')[1].click());
+ await afterNextRender(() => document.querySelector('.o_Composer_buttonSend').click());
+ assert.containsOnce(document.body, '.o_Message_content', 'should have one message after posting it');
+ assert.containsOnce(
+ document.querySelector(`.o_Message_content`),
+ `.o_channel_redirect[data-oe-id="11"][data-oe-model="mail.channel"]:contains("#my channel")`,
+ "message should contain the first channel mention"
+ );
+ assert.containsOnce(
+ document.querySelector(`.o_Message_content`),
+ `.o_channel_redirect[data-oe-id="12"][data-oe-model="mail.channel"]:contains("#my channel")`,
+ "message should also contain the second channel mention"
+ );
+});
+
+QUnit.test('show empty placeholder when thread contains no message', async function (assert) {
+ assert.expect(2);
+
+ this.data['mail.channel'].records.push({ id: 11 });
+ await this.start();
+ const threadViewer = await this.env.models['mail.thread_viewer'].create({
+ hasThreadView: true,
+ thread: [['insert', {
+ id: 11,
+ model: 'mail.channel',
+ }]],
+ });
+ await this.afterEvent({
+ eventName: 'o-thread-view-hint-processed',
+ func: () => {
+ this.createThreadViewComponent(threadViewer.threadView);
+ },
+ message: "should wait until thread becomes loaded with messages",
+ predicate: ({ hint, threadViewer }) => {
+ return (
+ hint.type === 'messages-loaded' &&
+ threadViewer.thread.model === 'mail.channel' &&
+ threadViewer.thread.id === 11
+ );
+ },
+ });
+ assert.containsOnce(
+ document.body,
+ '.o_MessageList_empty',
+ "message list empty placeholder should be shown as thread does not contain any messages"
+ );
+ assert.containsNone(
+ document.body,
+ '.o_Message',
+ "no message should be shown as thread does not contain any"
+ );
+});
+
+QUnit.test('show empty placeholder when thread contains only empty messages', async function (assert) {
+ assert.expect(2);
+
+ this.data['mail.channel'].records.push({ id: 11 });
+ this.data['mail.message'].records.push(
+ {
+ channel_ids: [11],
+ id: 101,
+ },
+ );
+ await this.start();
+ const threadViewer = await this.env.models['mail.thread_viewer'].create({
+ hasThreadView: true,
+ thread: [['insert', {
+ id: 11,
+ model: 'mail.channel',
+ }]],
+ });
+ await this.afterEvent({
+ eventName: 'o-thread-view-hint-processed',
+ func: () => {
+ this.createThreadViewComponent(threadViewer.threadView);
+ },
+ message: "thread become loaded with messages",
+ predicate: ({ hint, threadViewer }) => {
+ return (
+ hint.type === 'messages-loaded' &&
+ threadViewer.thread.model === 'mail.channel' &&
+ threadViewer.thread.id === 11
+ );
+ },
+ });
+ assert.containsOnce(
+ document.body,
+ '.o_MessageList_empty',
+ "message list empty placeholder should be shown as thread contain only empty messages"
+ );
+ assert.containsNone(
+ document.body,
+ '.o_Message',
+ "no message should be shown as thread contains only empty ones"
+ );
+});
+
+QUnit.test('message with subtype should be displayed (and not considered as empty)', async function (assert) {
+ assert.expect(2);
+
+ this.data['mail.channel'].records.push({ id: 11 });
+ this.data['mail.message.subtype'].records.push({
+ description: "Task created",
+ id: 10,
+ });
+ this.data['mail.message'].records.push(
+ {
+ channel_ids: [11],
+ id: 101,
+ subtype_id: 10,
+ },
+ );
+ await this.start();
+ const threadViewer = await this.env.models['mail.thread_viewer'].create({
+ hasThreadView: true,
+ thread: [['insert', {
+ id: 11,
+ model: 'mail.channel',
+ }]],
+ });
+ await this.afterEvent({
+ eventName: 'o-thread-view-hint-processed',
+ func: () => {
+ this.createThreadViewComponent(threadViewer.threadView);
+ },
+ message: "should wait until thread becomes loaded with messages",
+ predicate: ({ hint, threadViewer }) => {
+ return (
+ hint.type === 'messages-loaded' &&
+ threadViewer.thread.model === 'mail.channel' &&
+ threadViewer.thread.id === 11
+ );
+ },
+ });
+ assert.containsOnce(
+ document.body,
+ '.o_Message',
+ "should display 1 message (message with subtype description 'task created')"
+ );
+ assert.strictEqual(
+ document.body.querySelector('.o_Message_content').textContent,
+ "Task created",
+ "message should have 'Task created' (from its subtype description)"
+ );
+});
+
+QUnit.test('[technical] message list with a full page of empty messages should show load more if there are other messages', async function (assert) {
+ // Technical assumptions :
+ // - message_fetch fetching exactly 30 messages,
+ // - empty messages not being displayed
+ // - auto-load more being triggered on scroll, not automatically when the 30 first messages are empty
+ assert.expect(2);
+
+ this.data['mail.channel'].records.push({
+ id: 11,
+ });
+ for (let i = 0; i <= 30; i++) {
+ this.data['mail.message'].records.push({
+ body: "not empty",
+ channel_ids: [11],
+ });
+ }
+ for (let i = 0; i <= 30; i++) {
+ this.data['mail.message'].records.push({
+ channel_ids: [11],
+ });
+ }
+ await this.start();
+ const threadViewer = this.env.models['mail.thread_viewer'].create({
+ hasThreadView: true,
+ thread: [['insert', {
+ id: 11,
+ model: 'mail.channel',
+ }]],
+ });
+ await this.afterEvent({
+ eventName: 'o-thread-view-hint-processed',
+ func: () => {
+ this.createThreadViewComponent(threadViewer.threadView, { order: 'asc' }, { isFixedSize: true });
+ },
+ message: "should wait until thread becomes loaded with messages",
+ predicate: ({ hint, threadViewer }) => {
+ return (
+ hint.type === 'messages-loaded' &&
+ threadViewer.thread.model === 'mail.channel' &&
+ threadViewer.thread.id === 11
+ );
+ },
+ });
+ assert.containsNone(
+ document.body,
+ '.o_Message',
+ "No message should be shown as all 30 first messages are empty"
+ );
+ assert.containsOnce(
+ document.body,
+ '.o_MessageList_loadMore',
+ "Load more button should be shown as there are more messages to show"
+ );
+});
+
+QUnit.test('first unseen message should be directly preceded by the new message separator if there is a transient message just before it while composer is not focused [REQUIRE FOCUS]', async function (assert) {
+ // The goal of removing the focus is to ensure the thread is not marked as seen automatically.
+ // Indeed that would trigger channel_seen no matter what, which is already covered by other tests.
+ // The goal of this test is to cover the conditions specific to transient messages,
+ // and the conditions from focus would otherwise shadow them.
+ assert.expect(3);
+
+ this.data['mail.channel_command'].records.push({ name: 'who' });
+ // Needed partner & user to allow simulation of message reception
+ this.data['res.partner'].records.push({
+ id: 11,
+ name: "Foreigner partner",
+ });
+ this.data['res.users'].records.push({
+ id: 42,
+ name: "Foreigner user",
+ partner_id: 11,
+ });
+ this.data['mail.channel'].records = [{
+ channel_type: 'channel',
+ id: 20,
+ is_pinned: true,
+ name: "General",
+ uuid: 'channel20uuid',
+ }];
+ await this.start();
+ const thread = this.env.models['mail.thread'].findFromIdentifyingData({
+ id: 20,
+ model: 'mail.channel'
+ });
+ const threadViewer = this.env.models['mail.thread_viewer'].create({
+ hasThreadView: true,
+ thread: [['link', thread]],
+ });
+ await this.createThreadViewComponent(threadViewer.threadView, { hasComposer: true });
+ // send a command that leads to receiving a transient message
+ document.querySelector('.o_ComposerTextInput_textarea').focus();
+ await afterNextRender(() => document.execCommand('insertText', false, "/who"));
+ await afterNextRender(() => {
+ document.querySelector('.o_Composer_buttonSend').click();
+ });
+
+ // composer is focused by default, we remove that focus
+ document.querySelector('.o_ComposerTextInput_textarea').blur();
+ // simulate receiving a message
+ await afterNextRender(() => this.env.services.rpc({
+ route: '/mail/chat_post',
+ params: {
+ context: {
+ mockedUserId: 42,
+ },
+ message_content: "test",
+ uuid: 'channel20uuid',
+ },
+ }));
+ assert.containsN(
+ document.body,
+ '.o_Message',
+ 2,
+ "should display 2 messages (the transient & the received message), after posting a command"
+ );
+ assert.containsOnce(
+ document.body,
+ '.o_MessageList_separatorNewMessages',
+ "separator should be shown as a message has been received"
+ );
+ assert.containsOnce(
+ document.body,
+ `.o_Message[data-message-local-id="${
+ this.env.models['mail.message'].find(m => m.isTransient).localId
+ }"] + .o_MessageList_separatorNewMessages`,
+ "separator should be shown just after transient message"
+ );
+});
+
+QUnit.test('composer should be focused automatically after clicking on the send button [REQUIRE FOCUS]', async function (assert) {
+ assert.expect(1);
+
+ this.data['mail.channel'].records.push({id: 20,});
+ await this.start();
+ const thread = this.env.models['mail.thread'].findFromIdentifyingData({
+ id: 20,
+ model: 'mail.channel'
+ });
+ const threadViewer = this.env.models['mail.thread_viewer'].create({
+ hasThreadView: true,
+ thread: [['link', thread]],
+ });
+ await this.createThreadViewComponent(threadViewer.threadView, { hasComposer: true });
+ document.querySelector('.o_ComposerTextInput_textarea').focus();
+ await afterNextRender(() => document.execCommand('insertText', false, "Dummy Message"));
+ await afterNextRender(() => {
+ document.querySelector('.o_Composer_buttonSend').click();
+ });
+ assert.hasClass(
+ document.querySelector('.o_Composer'),
+ 'o-focused',
+ "composer should be focused automatically after clicking on the send button"
+ );
+});
+
+});
+});
+});
+
+});
diff --git a/addons/mail/static/src/env/test_env.js b/addons/mail/static/src/env/test_env.js
new file mode 100644
index 00000000..048bfc8d
--- /dev/null
+++ b/addons/mail/static/src/env/test_env.js
@@ -0,0 +1,148 @@
+odoo.define('mail/static/src/env/test_env.js', function (require) {
+'use strict';
+
+const { makeDeferred } = require('mail/static/src/utils/deferred/deferred.js');
+const { nextTick } = require('mail/static/src/utils/utils.js');
+
+const { Store } = owl;
+const { EventBus } = owl.core;
+
+/**
+ * @param {Object} [providedEnv={}]
+ * @returns {Object}
+ */
+function addMessagingToEnv(providedEnv = {}) {
+ const env = Object.assign(providedEnv);
+
+ /**
+ * Messaging store
+ */
+ const store = new Store({
+ env,
+ state: {
+ messagingRevNumber: 0,
+ },
+ });
+
+ /**
+ * Registry of models.
+ */
+ env.models = {};
+ /**
+ * Environment keys used in messaging.
+ */
+ Object.assign(env, {
+ autofetchPartnerImStatus: false,
+ browser: Object.assign({
+ innerHeight: 1080,
+ innerWidth: 1920,
+ Notification: Object.assign({
+ permission: 'denied',
+ async requestPermission() {
+ return this.permission;
+ },
+ }, (env.browser && env.browser.Notification) || {}),
+ }, env.browser),
+ destroyMessaging() {
+ if (env.modelManager) {
+ env.modelManager.deleteAll();
+ env.messaging = undefined;
+ }
+ },
+ disableAnimation: true,
+ isMessagingInitialized() {
+ if (!this.messaging) {
+ return false;
+ }
+ return this.messaging.isInitialized;
+ },
+ /**
+ * States whether the environment is in QUnit test or not.
+ *
+ * Useful to prevent some behaviour in QUnit tests, like applying
+ * style of attachment that uses url.
+ */
+ isQUnitTest: true,
+ loadingBaseDelayDuration: providedEnv.loadingBaseDelayDuration || 0,
+ messaging: undefined,
+ messagingCreatedPromise: makeDeferred(),
+ messagingInitializedDeferred: makeDeferred(),
+ messagingBus: new EventBus(),
+ modelManager: undefined,
+ store,
+ });
+
+ return env;
+}
+
+/**
+ * @param {Object} [providedEnv={}]
+ * @returns {Object}
+ */
+function addTimeControlToEnv(providedEnv = {}) {
+
+ let env = Object.assign({}, providedEnv);
+
+ if (!env.browser) {
+ env.browser = {};
+ }
+ // list of timeout ids that have timed out.
+ let timedOutIds = [];
+ // key: timeoutId, value: func + remaining duration
+ const timeouts = new Map();
+ Object.assign(env.browser, {
+ clearTimeout: id => {
+ timeouts.delete(id);
+ timedOutIds = timedOutIds.filter(i => i !== id);
+ },
+ setTimeout: (func, duration) => {
+ const timeoutId = _.uniqueId('timeout_');
+ const timeout = {
+ id: timeoutId,
+ isTimedOut: false,
+ func,
+ duration,
+ };
+ timeouts.set(timeoutId, timeout);
+ if (duration === 0) {
+ timedOutIds.push(timeoutId);
+ timeout.isTimedOut = true;
+ }
+ return timeoutId;
+ },
+ });
+ if (!env.testUtils) {
+ env.testUtils = {};
+ }
+ Object.assign(env.testUtils, {
+ advanceTime: async duration => {
+ await nextTick();
+ for (const id of timeouts.keys()) {
+ const timeout = timeouts.get(id);
+ if (timeout.isTimedOut) {
+ continue;
+ }
+ timeout.duration = Math.max(timeout.duration - duration, 0);
+ if (timeout.duration === 0) {
+ timedOutIds.push(id);
+ }
+ }
+ while (timedOutIds.length > 0) {
+ const id = timedOutIds.shift();
+ const timeout = timeouts.get(id);
+ timeouts.delete(id);
+ timeout.func();
+ await nextTick();
+ }
+ await nextTick();
+ },
+ });
+ return env;
+}
+
+return {
+ addMessagingToEnv,
+ addTimeControlToEnv,
+};
+
+});
diff --git a/addons/mail/static/src/img/_al.png b/addons/mail/static/src/img/_al.png
new file mode 100644
index 00000000..f6843ddf
--- /dev/null
+++ b/addons/mail/static/src/img/_al.png
Binary files differ
diff --git a/addons/mail/static/src/img/_pinky.png b/addons/mail/static/src/img/_pinky.png
new file mode 100644
index 00000000..69c18a32
--- /dev/null
+++ b/addons/mail/static/src/img/_pinky.png
Binary files differ
diff --git a/addons/mail/static/src/img/attachment.png b/addons/mail/static/src/img/attachment.png
new file mode 100644
index 00000000..5cc0c332
--- /dev/null
+++ b/addons/mail/static/src/img/attachment.png
Binary files differ
diff --git a/addons/mail/static/src/img/checklist.png b/addons/mail/static/src/img/checklist.png
new file mode 100644
index 00000000..d252606f
--- /dev/null
+++ b/addons/mail/static/src/img/checklist.png
Binary files differ
diff --git a/addons/mail/static/src/img/email_icon.png b/addons/mail/static/src/img/email_icon.png
new file mode 100644
index 00000000..78c131c0
--- /dev/null
+++ b/addons/mail/static/src/img/email_icon.png
Binary files differ
diff --git a/addons/mail/static/src/img/email_template.png b/addons/mail/static/src/img/email_template.png
new file mode 100644
index 00000000..d679492b
--- /dev/null
+++ b/addons/mail/static/src/img/email_template.png
Binary files differ
diff --git a/addons/mail/static/src/img/email_template_save.png b/addons/mail/static/src/img/email_template_save.png
new file mode 100644
index 00000000..bf29f9ab
--- /dev/null
+++ b/addons/mail/static/src/img/email_template_save.png
Binary files differ
diff --git a/addons/mail/static/src/img/formatting.png b/addons/mail/static/src/img/formatting.png
new file mode 100644
index 00000000..cf45fdf1
--- /dev/null
+++ b/addons/mail/static/src/img/formatting.png
Binary files differ
diff --git a/addons/mail/static/src/img/groupdefault.png b/addons/mail/static/src/img/groupdefault.png
new file mode 100644
index 00000000..5d628e99
--- /dev/null
+++ b/addons/mail/static/src/img/groupdefault.png
Binary files differ
diff --git a/addons/mail/static/src/img/odoo_o.png b/addons/mail/static/src/img/odoo_o.png
new file mode 100644
index 00000000..d1839f6d
--- /dev/null
+++ b/addons/mail/static/src/img/odoo_o.png
Binary files differ
diff --git a/addons/mail/static/src/img/odoobot.png b/addons/mail/static/src/img/odoobot.png
new file mode 100644
index 00000000..b1921736
--- /dev/null
+++ b/addons/mail/static/src/img/odoobot.png
Binary files differ
diff --git a/addons/mail/static/src/img/odoobot_transparent.png b/addons/mail/static/src/img/odoobot_transparent.png
new file mode 100644
index 00000000..e8c5a4da
--- /dev/null
+++ b/addons/mail/static/src/img/odoobot_transparent.png
Binary files differ
diff --git a/addons/mail/static/src/img/smiley/avatar.jpg b/addons/mail/static/src/img/smiley/avatar.jpg
new file mode 100644
index 00000000..71687940
--- /dev/null
+++ b/addons/mail/static/src/img/smiley/avatar.jpg
Binary files differ
diff --git a/addons/mail/static/src/img/smiley/green.png b/addons/mail/static/src/img/smiley/green.png
new file mode 100644
index 00000000..0700a5ef
--- /dev/null
+++ b/addons/mail/static/src/img/smiley/green.png
Binary files differ
diff --git a/addons/mail/static/src/img/smiley/mailfailure.jpg b/addons/mail/static/src/img/smiley/mailfailure.jpg
new file mode 100644
index 00000000..6f0ec91a
--- /dev/null
+++ b/addons/mail/static/src/img/smiley/mailfailure.jpg
Binary files differ
diff --git a/addons/mail/static/src/img/smiley/yellow.png b/addons/mail/static/src/img/smiley/yellow.png
new file mode 100644
index 00000000..726570e5
--- /dev/null
+++ b/addons/mail/static/src/img/smiley/yellow.png
Binary files differ
diff --git a/addons/mail/static/src/js/activity.js b/addons/mail/static/src/js/activity.js
new file mode 100644
index 00000000..ae9d914f
--- /dev/null
+++ b/addons/mail/static/src/js/activity.js
@@ -0,0 +1,868 @@
+odoo.define('mail.Activity', function (require) {
+"use strict";
+
+var mailUtils = require('mail.utils');
+
+var AbstractField = require('web.AbstractField');
+var BasicModel = require('web.BasicModel');
+var config = require('web.config');
+var core = require('web.core');
+var field_registry = require('web.field_registry');
+var session = require('web.session');
+var framework = require('web.framework');
+var time = require('web.time');
+
+var QWeb = core.qweb;
+var _t = core._t;
+const _lt = core._lt;
+
+/**
+ * Fetches activities and postprocesses them.
+ *
+ * This standalone function performs an RPC, but to do so, it needs an instance
+ * of a widget that implements the _rpc() function.
+ *
+ * @todo i'm not very proud of the widget instance given in arguments, we should
+ * probably try to do it a better way in the future.
+ *
+ * @param {Widget} self a widget instance that can perform RPCs
+ * @param {Array} ids the ids of activities to read
+ * @return {Promise<Array>} resolved with the activities
+ */
+function _readActivities(self, ids) {
+ if (!ids.length) {
+ return Promise.resolve([]);
+ }
+ var context = self.getSession().user_context;
+ if (self.record && !_.isEmpty(self.record.getContext())) {
+ context = self.record.getContext();
+ }
+ return self._rpc({
+ model: 'mail.activity',
+ method: 'activity_format',
+ args: [ids],
+ context: context,
+ }).then(function (activities) {
+ // convert create_date and date_deadline to moments
+ _.each(activities, function (activity) {
+ activity.create_date = moment(time.auto_str_to_date(activity.create_date));
+ activity.date_deadline = moment(time.auto_str_to_date(activity.date_deadline));
+ });
+ // sort activities by due date
+ activities = _.sortBy(activities, 'date_deadline');
+ return activities;
+ });
+}
+
+BasicModel.include({
+ //--------------------------------------------------------------------------
+ // Private
+ //--------------------------------------------------------------------------
+
+ /**
+ * Fetches the activities displayed by the activity field widget in form
+ * views.
+ *
+ * @private
+ * @param {Object} record - an element from the localData
+ * @param {string} fieldName
+ * @return {Promise<Array>} resolved with the activities
+ */
+ _fetchSpecialActivity: function (record, fieldName) {
+ var localID = (record._changes && fieldName in record._changes) ?
+ record._changes[fieldName] :
+ record.data[fieldName];
+ return _readActivities(this, this.localData[localID].res_ids);
+ },
+});
+
+/**
+ * Set the 'label_delay' entry in activity data according to the deadline date
+ *
+ * @param {Array} activities list of activity Object
+ * @return {Array} : list of modified activity Object
+ */
+var setDelayLabel = function (activities) {
+ var today = moment().startOf('day');
+ _.each(activities, function (activity) {
+ var toDisplay = '';
+ var diff = activity.date_deadline.diff(today, 'days', true); // true means no rounding
+ if (diff === 0) {
+ toDisplay = _t("Today");
+ } else {
+ if (diff < 0) { // overdue
+ if (diff === -1) {
+ toDisplay = _t("Yesterday");
+ } else {
+ toDisplay = _.str.sprintf(_t("%d days overdue"), Math.abs(diff));
+ }
+ } else { // due
+ if (diff === 1) {
+ toDisplay = _t("Tomorrow");
+ } else {
+ toDisplay = _.str.sprintf(_t("Due in %d days"), Math.abs(diff));
+ }
+ }
+ }
+ activity.label_delay = toDisplay;
+ });
+ return activities;
+};
+
+/**
+ * Set the file upload identifier for 'upload_file' type activities
+ *
+ * @param {Array} activities list of activity Object
+ * @return {Array} : list of modified activity Object
+ */
+var setFileUploadID = function (activities) {
+ _.each(activities, function (activity) {
+ if (activity.activity_category === 'upload_file') {
+ activity.fileuploadID = _.uniqueId('o_fileupload');
+ }
+ });
+ return activities;
+};
+
+var BasicActivity = AbstractField.extend({
+ events: {
+ 'click .o_edit_activity': '_onEditActivity',
+ 'change input.o_input_file': '_onFileChanged',
+ 'click .o_mark_as_done': '_onMarkActivityDone',
+ 'click .o_mark_as_done_upload_file': '_onMarkActivityDoneUploadFile',
+ 'click .o_activity_template_preview': '_onPreviewMailTemplate',
+ 'click .o_schedule_activity': '_onScheduleActivity',
+ 'click .o_activity_template_send': '_onSendMailTemplate',
+ 'click .o_unlink_activity': '_onUnlinkActivity',
+ },
+ init: function () {
+ this._super.apply(this, arguments);
+ this._draftFeedback = {};
+ },
+
+ //------------------------------------------------------------
+ // Public
+ //------------------------------------------------------------
+
+ /**
+ * @param {integer} previousActivityTypeID
+ * @return {Promise}
+ */
+ scheduleActivity: function () {
+ var callback = this._reload.bind(this, { activity: true, thread: true });
+ return this._openActivityForm(false, callback);
+ },
+
+ //------------------------------------------------------------
+ // Private
+ //------------------------------------------------------------
+
+ /**
+ * Send a feedback and reload page in order to mark activity as done
+ *
+ * @private
+ * @param {Object} params
+ * @param {integer} params.activityID
+ * @param {integer[]} params.attachmentIds
+ * @param {string} params.feedback
+ *
+ * @return {$.Promise}
+ */
+ _markActivityDone: function (params) {
+ var activityID = params.activityID;
+ var feedback = params.feedback || false;
+ var attachmentIds = params.attachmentIds || [];
+
+ return this._sendActivityFeedback(activityID, feedback, attachmentIds)
+ .then(this._reload.bind(this, { activity: true, thread: true }));
+ },
+ /**
+ * Send a feedback and proposes to schedule next activity
+ * previousActivityTypeID will be given to new activity to propose activity
+ * type based on recommended next activity
+ *
+ * @private
+ * @param {Object} params
+ * @param {integer} params.activityID
+ * @param {string} params.feedback
+ */
+ _markActivityDoneAndScheduleNext: function (params) {
+ var activityID = params.activityID;
+ var feedback = params.feedback;
+ var self = this;
+ this._rpc({
+ model: 'mail.activity',
+ method: 'action_feedback_schedule_next',
+ args: [[activityID]],
+ kwargs: {feedback: feedback},
+ context: this.record.getContext(),
+ }).then(
+ function (rslt_action) {
+ if (rslt_action) {
+ self.do_action(rslt_action, {
+ on_close: function () {
+ self.trigger_up('reload', { keepChanges: true });
+ },
+ });
+ } else {
+ self.trigger_up('reload', { keepChanges: true });
+ }
+ }
+ );
+ },
+ /**
+ * @private
+ * @param {integer} id
+ * @param {function} callback
+ * @return {Promise}
+ */
+ _openActivityForm: function (id, callback) {
+ var action = {
+ type: 'ir.actions.act_window',
+ name: _t("Schedule Activity"),
+ res_model: 'mail.activity',
+ view_mode: 'form',
+ views: [[false, 'form']],
+ target: 'new',
+ context: {
+ default_res_id: this.res_id,
+ default_res_model: this.model,
+ },
+ res_id: id || false,
+ };
+ return this.do_action(action, { on_close: callback });
+ },
+ /**
+ * @private
+ * @param {integer} activityID
+ * @param {string} feedback
+ * @param {integer[]} attachmentIds
+ * @return {Promise}
+ */
+ _sendActivityFeedback: function (activityID, feedback, attachmentIds) {
+ return this._rpc({
+ model: 'mail.activity',
+ method: 'action_feedback',
+ args: [[activityID]],
+ kwargs: {
+ feedback: feedback,
+ attachment_ids: attachmentIds || [],
+ },
+ context: this.record.getContext(),
+ });
+ },
+
+ //------------------------------------------------------------
+ // Handlers
+ //------------------------------------------------------------
+
+ /**
+ * @private
+ * @param {Object[]} activities
+ */
+ _bindOnUploadAction: function (activities) {
+ var self = this;
+ _.each(activities, function (activity) {
+ if (activity.fileuploadID) {
+ $(window).on(activity.fileuploadID, function () {
+ framework.unblockUI();
+ // find the button clicked and display the feedback popup on it
+ var files = Array.prototype.slice.call(arguments, 1);
+ self._markActivityDone({
+ activityID: activity.id,
+ attachmentIds: _.pluck(files, 'id')
+ }).then(function () {
+ self.trigger_up('reload', { keepChanges: true });
+ });
+ });
+ }
+ });
+ },
+ /** Binds a focusout handler on a bootstrap popover
+ * Useful to do some operations on the popover's HTML,
+ * like keeping the user's input for the feedback
+ * @param {JQuery} $popover_el: the element on which
+ * the popover() method has been called
+ */
+ _bindPopoverFocusout: function ($popover_el) {
+ var self = this;
+ // Retrieve the actual popover's HTML
+ var $popover = $($popover_el.data("bs.popover").tip);
+ var activityID = $popover_el.data('activity-id');
+ $popover.off('focusout');
+ $popover.focusout(function (e) {
+ // outside click of popover hide the popover
+ // e.relatedTarget is the element receiving the focus
+ if (!$popover.is(e.relatedTarget) && !$popover.find(e.relatedTarget).length) {
+ self._draftFeedback[activityID] = $popover.find('#activity_feedback').val();
+ $popover.popover('hide');
+ }
+ });
+ },
+
+ /**
+ * @private
+ * @param {MouseEvent} ev
+ * @returns {Promise}
+ */
+ _onEditActivity: function (ev) {
+ ev.preventDefault();
+ var activityID = $(ev.currentTarget).data('activity-id');
+ return this._openActivityForm(activityID, this._reload.bind(this, { activity: true, thread: true }));
+ },
+ /**
+ * @private
+ * @param {FormEvent} ev
+ */
+ _onFileChanged: function (ev) {
+ ev.stopPropagation();
+ ev.preventDefault();
+ var $form = $(ev.currentTarget).closest('form');
+ $form.submit();
+ framework.blockUI();
+ },
+ /**
+ * Called when marking an activity as done
+ *
+ * It lets the current user write a feedback in a popup menu.
+ * After writing the feedback and confirm mark as done
+ * is sent, it marks this activity as done for good with the feedback linked
+ * to it.
+ *
+ * @private
+ * @param {MouseEvent} ev
+ */
+ _onMarkActivityDone: function (ev) {
+ ev.stopPropagation();
+ ev.preventDefault();
+ var self = this;
+ var $markDoneBtn = $(ev.currentTarget);
+ var activityID = $markDoneBtn.data('activity-id');
+ var previousActivityTypeID = $markDoneBtn.data('previous-activity-type-id') || false;
+ var forceNextActivity = $markDoneBtn.data('force-next-activity');
+
+ if ($markDoneBtn.data('toggle') === 'collapse') {
+ var $actLi = $markDoneBtn.parents('.o_log_activity');
+ var $panel = self.$('#o_activity_form_' + activityID);
+
+ if (!$panel.data('bs.collapse')) {
+ var $form = $(QWeb.render('mail.activity_feedback_form', {
+ previous_activity_type_id: previousActivityTypeID,
+ force_next: forceNextActivity
+ }));
+ $panel.append($form);
+ self._onMarkActivityDoneActions($markDoneBtn, $form, activityID);
+
+ // Close and reset any other open panels
+ _.each($panel.siblings('.o_activity_form'), function (el) {
+ if ($(el).data('bs.collapse')) {
+ $(el).empty().collapse('dispose').removeClass('show');
+ }
+ });
+
+ // Scroll to selected activity
+ $markDoneBtn.parents('.o_activity_log_container').scrollTo($actLi.position().top, 100);
+ }
+
+ // Empty and reset panel on close
+ $panel.on('hidden.bs.collapse', function () {
+ if ($panel.data('bs.collapse')) {
+ $actLi.removeClass('o_activity_selected');
+ $panel.collapse('dispose');
+ $panel.empty();
+ }
+ });
+
+ this.$('.o_activity_selected').removeClass('o_activity_selected');
+ $actLi.toggleClass('o_activity_selected');
+ $panel.collapse('toggle');
+
+ } else if (!$markDoneBtn.data('bs.popover')) {
+ $markDoneBtn.popover({
+ template: $(Popover.Default.template).addClass('o_mail_activity_feedback')[0].outerHTML, // Ugly but cannot find another way
+ container: $markDoneBtn,
+ title: _t("Feedback"),
+ html: true,
+ trigger: 'manual',
+ placement: 'right', // FIXME: this should work, maybe a bug in the popper lib
+ content: function () {
+ var $popover = $(QWeb.render('mail.activity_feedback_form', {
+ previous_activity_type_id: previousActivityTypeID,
+ force_next: forceNextActivity
+ }));
+ self._onMarkActivityDoneActions($markDoneBtn, $popover, activityID);
+ return $popover;
+ },
+ }).on('shown.bs.popover', function () {
+ var $popover = $($(this).data("bs.popover").tip);
+ $(".o_mail_activity_feedback.popover").not($popover).popover("hide");
+ $popover.addClass('o_mail_activity_feedback').attr('tabindex', 0);
+ $popover.find('#activity_feedback').focus();
+ self._bindPopoverFocusout($(this));
+ }).popover('show');
+ } else {
+ var popover = $markDoneBtn.data('bs.popover');
+ if ($('#' + popover.tip.id).length === 0) {
+ popover.show();
+ }
+ }
+ },
+ /**
+ * Bind all necessary actions to the 'mark as done' form
+ *
+ * @private
+ * @param {Object} $form
+ * @param {integer} activityID
+ */
+ _onMarkActivityDoneActions: function ($btn, $form, activityID) {
+ var self = this;
+ $form.find('#activity_feedback').val(self._draftFeedback[activityID]);
+ $form.on('click', '.o_activity_popover_done', function (ev) {
+ ev.stopPropagation();
+ self._markActivityDone({
+ activityID: activityID,
+ feedback: $form.find('#activity_feedback').val(),
+ });
+ });
+ $form.on('click', '.o_activity_popover_done_next', function (ev) {
+ ev.stopPropagation();
+ self._markActivityDoneAndScheduleNext({
+ activityID: activityID,
+ feedback: $form.find('#activity_feedback').val(),
+ });
+ });
+ $form.on('click', '.o_activity_popover_discard', function (ev) {
+ ev.stopPropagation();
+ if ($btn.data('bs.popover')) {
+ $btn.popover('hide');
+ } else if ($btn.data('toggle') === 'collapse') {
+ self.$('#o_activity_form_' + activityID).collapse('hide');
+ }
+ });
+ },
+ /**
+ * @private
+ * @param {MouseEvent} ev
+ */
+ _onMarkActivityDoneUploadFile: function (ev) {
+ ev.preventDefault();
+ ev.stopPropagation();
+ var fileuploadID = $(ev.currentTarget).data('fileupload-id');
+ var $input = this.$("[target='" + fileuploadID + "'] > input.o_input_file");
+ $input.click();
+ },
+ /**
+ * @private
+ * @param {MouseEvent} ev
+ * @returns {Promise}
+ */
+ _onPreviewMailTemplate: function (ev) {
+ ev.stopPropagation();
+ ev.preventDefault();
+ var self = this;
+ var templateID = $(ev.currentTarget).data('template-id');
+ var action = {
+ name: _t('Compose Email'),
+ type: 'ir.actions.act_window',
+ res_model: 'mail.compose.message',
+ views: [[false, 'form']],
+ target: 'new',
+ context: {
+ default_res_id: this.res_id,
+ default_model: this.model,
+ default_use_template: true,
+ default_template_id: templateID,
+ force_email: true,
+ },
+ };
+ return this.do_action(action, { on_close: function () {
+ self.trigger_up('reload', { keepChanges: true });
+ } });
+ },
+ /**
+ * @private
+ * @param {MouseEvent} ev
+ * @returns {Promise}
+ */
+ _onSendMailTemplate: function (ev) {
+ ev.stopPropagation();
+ ev.preventDefault();
+ var templateID = $(ev.currentTarget).data('template-id');
+ return this._rpc({
+ model: this.model,
+ method: 'activity_send_mail',
+ args: [[this.res_id], templateID],
+ })
+ .then(this._reload.bind(this, {activity: true, thread: true, followers: true}));
+ },
+ /**
+ * @private
+ * @param {MouseEvent} ev
+ * @returns {Promise}
+ */
+ _onScheduleActivity: function (ev) {
+ ev.preventDefault();
+ return this._openActivityForm(false, this._reload.bind(this));
+ },
+
+ /**
+ * @private
+ * @param {MouseEvent} ev
+ * @param {Object} options
+ * @returns {Promise}
+ */
+ _onUnlinkActivity: function (ev, options) {
+ ev.preventDefault();
+ var activityID = $(ev.currentTarget).data('activity-id');
+ options = _.defaults(options || {}, {
+ model: 'mail.activity',
+ args: [[activityID]],
+ });
+ return this._rpc({
+ model: options.model,
+ method: 'unlink',
+ args: options.args,
+ })
+ .then(this._reload.bind(this, {activity: true}));
+ },
+ /**
+ * Unbind event triggered when a file is uploaded.
+ *
+ * @private
+ * @param {Array} activities: list of activity to unbind
+ */
+ _unbindOnUploadAction: function (activities) {
+ _.each(activities, function (activity) {
+ if (activity.fileuploadID) {
+ $(window).off(activity.fileuploadID);
+ }
+ });
+ },
+});
+
+// -----------------------------------------------------------------------------
+// Activities Widget for Form views ('mail_activity' widget)
+// -----------------------------------------------------------------------------
+// FIXME seems to still be needed in some cases like systray
+var Activity = BasicActivity.extend({
+ className: 'o_mail_activity',
+ events: _.extend({}, BasicActivity.prototype.events, {
+ 'click a': '_onClickRedirect',
+ }),
+ specialData: '_fetchSpecialActivity',
+ /**
+ * @override
+ */
+ init: function () {
+ this._super.apply(this, arguments);
+ this._activities = this.record.specialData[this.name];
+ },
+ /**
+ * @override
+ */
+ destroy: function () {
+ this._unbindOnUploadAction();
+ return this._super.apply(this, arguments);
+ },
+
+ //------------------------------------------------------------
+ // Private
+ //------------------------------------------------------------
+ /**
+ * @private
+ * @param {Object} fieldsToReload
+ */
+ _reload: function (fieldsToReload) {
+ this.trigger_up('reload_mail_fields', fieldsToReload);
+ },
+ /**
+ * @override
+ * @private
+ */
+ _render: function () {
+ _.each(this._activities, function (activity) {
+ var note = mailUtils.parseAndTransform(activity.note || '', mailUtils.inline);
+ var is_blank = (/^\s*$/).test(note);
+ if (!is_blank) {
+ activity.note = mailUtils.parseAndTransform(activity.note, mailUtils.addLink);
+ } else {
+ activity.note = '';
+ }
+ });
+ var activities = setFileUploadID(setDelayLabel(this._activities));
+ if (activities.length) {
+ var nbActivities = _.countBy(activities, 'state');
+ this.$el.html(QWeb.render('mail.activity_items', {
+ uid: session.uid,
+ activities: activities,
+ nbPlannedActivities: nbActivities.planned,
+ nbTodayActivities: nbActivities.today,
+ nbOverdueActivities: nbActivities.overdue,
+ dateFormat: time.getLangDateFormat(),
+ datetimeFormat: time.getLangDatetimeFormat(),
+ session: session,
+ widget: this,
+ }));
+ this._bindOnUploadAction(this._activities);
+ } else {
+ this._unbindOnUploadAction(this._activities);
+ this.$el.empty();
+ }
+ },
+ /**
+ * @override
+ * @private
+ * @param {Object} record
+ */
+ _reset: function (record) {
+ this._super.apply(this, arguments);
+ this._activities = this.record.specialData[this.name];
+ // the mail widgets being persistent, one need to update the res_id on reset
+ this.res_id = record.res_id;
+ },
+
+ //------------------------------------------------------------
+ // Handlers
+ //------------------------------------------------------------
+
+ /**
+ * @private
+ * @param {MouseEvent} ev
+ */
+ _onClickRedirect: function (ev) {
+ var id = $(ev.currentTarget).data('oe-id');
+ if (id) {
+ ev.preventDefault();
+ var model = $(ev.currentTarget).data('oe-model');
+ this.trigger_up('redirect', {
+ res_id: id,
+ res_model: model,
+ });
+ }
+ },
+
+});
+
+// -----------------------------------------------------------------------------
+// Activities Widget for Kanban views ('kanban_activity' widget)
+// -----------------------------------------------------------------------------
+var KanbanActivity = BasicActivity.extend({
+ template: 'mail.KanbanActivity',
+ events: _.extend({}, BasicActivity.prototype.events, {
+ 'show.bs.dropdown': '_onDropdownShow',
+ }),
+ fieldDependencies: _.extend({}, BasicActivity.prototype.fieldDependencies, {
+ activity_exception_decoration: {type: 'selection'},
+ activity_exception_icon: {type: 'char'},
+ activity_state: {type: 'selection'},
+ }),
+
+ /**
+ * @override
+ */
+ init: function (parent, name, record) {
+ this._super.apply(this, arguments);
+ var selection = {};
+ _.each(record.fields.activity_state.selection, function (value) {
+ selection[value[0]] = value[1];
+ });
+ this.selection = selection;
+ this._setState(record);
+ },
+ /**
+ * @override
+ */
+ destroy: function () {
+ this._unbindOnUploadAction();
+ return this._super.apply(this, arguments);
+ },
+ //------------------------------------------------------------
+ // Private
+ //------------------------------------------------------------
+
+ /**
+ * @private
+ */
+ _reload: function () {
+ this.trigger_up('reload', { db_id: this.record_id, keepChanges: true });
+ },
+ /**
+ * @override
+ * @private
+ */
+ _render: function () {
+ // span classes need to be updated manually because the template cannot
+ // be re-rendered eaasily (because of the dropdown state)
+ const spanClasses = ['fa', 'fa-lg', 'fa-fw'];
+ spanClasses.push('o_activity_color_' + (this.activityState || 'default'));
+ if (this.recordData.activity_exception_decoration) {
+ spanClasses.push('text-' + this.recordData.activity_exception_decoration);
+ spanClasses.push(this.recordData.activity_exception_icon);
+ } else {
+ spanClasses.push('fa-clock-o');
+ }
+ this.$('.o_activity_btn > span').removeClass().addClass(spanClasses.join(' '));
+
+ if (this.$el.hasClass('show')) {
+ // note: this part of the rendering might be asynchronous
+ this._renderDropdown();
+ }
+ },
+ /**
+ * @private
+ */
+ _renderDropdown: function () {
+ var self = this;
+ this.$('.o_activity')
+ .toggleClass('dropdown-menu-right', config.device.isMobile)
+ .html(QWeb.render('mail.KanbanActivityLoading'));
+ return _readActivities(this, this.value.res_ids).then(function (activities) {
+ activities = setFileUploadID(activities);
+ self.$('.o_activity').html(QWeb.render('mail.KanbanActivityDropdown', {
+ selection: self.selection,
+ records: _.groupBy(setDelayLabel(activities), 'state'),
+ session: session,
+ widget: self,
+ }));
+ self._bindOnUploadAction(activities);
+ });
+ },
+ /**
+ * @override
+ * @private
+ * @param {Object} record
+ */
+ _reset: function (record) {
+ this._super.apply(this, arguments);
+ this._setState(record);
+ },
+ /**
+ * @private
+ * @param {Object} record
+ */
+ _setState: function (record) {
+ this.record_id = record.id;
+ this.activityState = this.recordData.activity_state;
+ },
+
+ //------------------------------------------------------------
+ // Handlers
+ //------------------------------------------------------------
+
+ /**
+ * @private
+ */
+ _onDropdownShow: function () {
+ this._renderDropdown();
+ },
+});
+
+// -----------------------------------------------------------------------------
+// Activities Widget for List views ('list_activity' widget)
+// -----------------------------------------------------------------------------
+const ListActivity = KanbanActivity.extend({
+ template: 'mail.ListActivity',
+ events: Object.assign({}, KanbanActivity.prototype.events, {
+ 'click .dropdown-menu.o_activity': '_onDropdownClicked',
+ }),
+ fieldDependencies: _.extend({}, KanbanActivity.prototype.fieldDependencies, {
+ activity_summary: {type: 'char'},
+ activity_type_id: {type: 'many2one', relation: 'mail.activity.type'},
+ activity_type_icon: {type: 'char'},
+ }),
+ label: _lt('Next Activity'),
+
+ //--------------------------------------------------------------------------
+ // Private
+ //--------------------------------------------------------------------------
+
+ /**
+ * @override
+ * @private
+ */
+ _render: async function () {
+ await this._super(...arguments);
+ // set the 'special_click' prop on the activity icon to prevent from
+ // opening the record when the user clicks on it (as it opens the
+ // activity dropdown instead)
+ this.$('.o_activity_btn > span').prop('special_click', true);
+ if (this.value.count) {
+ let text;
+ if (this.recordData.activity_exception_decoration) {
+ text = _t('Warning');
+ } else {
+ text = this.recordData.activity_summary ||
+ this.recordData.activity_type_id.data.display_name;
+ }
+ this.$('.o_activity_summary').text(text);
+ }
+ if (this.recordData.activity_type_icon) {
+ this.el.querySelector('.o_activity_btn > span').classList.replace('fa-clock-o', this.recordData.activity_type_icon);
+ }
+ },
+
+ //--------------------------------------------------------------------------
+ // Handlers
+ //--------------------------------------------------------------------------
+
+ /**
+ * As we are in a list view, we don't want clicks inside the activity
+ * dropdown to open the record in a form view.
+ *
+ * @private
+ * @param {MouseEvent} ev
+ */
+ _onDropdownClicked: function (ev) {
+ ev.stopPropagation();
+ },
+});
+
+// -----------------------------------------------------------------------------
+// Activity Exception Widget to display Exception icon ('activity_exception' widget)
+// -----------------------------------------------------------------------------
+
+var ActivityException = AbstractField.extend({
+ noLabel: true,
+ fieldDependencies: _.extend({}, AbstractField.prototype.fieldDependencies, {
+ activity_exception_icon: {type: 'char'}
+ }),
+
+ //------------------------------------------------------------
+ // Private
+ //------------------------------------------------------------
+
+ /**
+ * There is no edit mode for this widget, the icon is always readonly.
+ *
+ * @override
+ * @private
+ */
+ _renderEdit: function () {
+ return this._renderReadonly();
+ },
+
+ /**
+ * Displays the exception icon if there is one.
+ *
+ * @override
+ * @private
+ */
+ _renderReadonly: function () {
+ this.$el.empty();
+ if (this.value) {
+ this.$el.attr({
+ title: _t('This record has an exception activity.'),
+ class: "pull-right mt-1 text-" + this.value + " fa " + this.recordData.activity_exception_icon
+ });
+ }
+ }
+});
+
+field_registry
+ .add('kanban_activity', KanbanActivity)
+ .add('list_activity', ListActivity)
+ .add('activity_exception', ActivityException);
+
+return Activity;
+
+});
diff --git a/addons/mail/static/src/js/basic_view.js b/addons/mail/static/src/js/basic_view.js
new file mode 100644
index 00000000..4c5c9a12
--- /dev/null
+++ b/addons/mail/static/src/js/basic_view.js
@@ -0,0 +1,68 @@
+odoo.define('mail.BasicView', function (require) {
+"use strict";
+
+const BasicView = require('web.BasicView');
+
+const mailWidgets = ['kanban_activity'];
+
+BasicView.include({
+ init: function () {
+ this._super.apply(this, arguments);
+ const post_refresh = this._getFieldOption('message_ids', 'post_refresh', false);
+ const followers_post_refresh = this._getFieldOption('message_follower_ids', 'post_refresh', false);
+ this.chatterFields = {
+ hasActivityIds: this._hasField('activity_ids'),
+ hasMessageFollowerIds: this._hasField('message_follower_ids'),
+ hasMessageIds: this._hasField('message_ids'),
+ hasRecordReloadOnAttachmentsChanged: post_refresh === 'always',
+ hasRecordReloadOnMessagePosted: !!post_refresh,
+ hasRecordReloadOnFollowersUpdate: !!followers_post_refresh,
+ isAttachmentBoxVisibleInitially: (
+ this._getFieldOption('message_ids', 'open_attachments', false) ||
+ this._getFieldOption('message_follower_ids', 'open_attachments', false)
+ ),
+ };
+ const fieldsInfo = this.fieldsInfo[this.viewType];
+ this.rendererParams.chatterFields = this.chatterFields;
+
+ // LEGACY for widget kanban_activity
+ this.mailFields = {};
+ for (const fieldName in fieldsInfo) {
+ const fieldInfo = fieldsInfo[fieldName];
+ if (_.contains(mailWidgets, fieldInfo.widget)) {
+ this.mailFields[fieldInfo.widget] = fieldName;
+ fieldInfo.__no_fetch = true;
+ }
+ }
+ this.rendererParams.activeActions = this.controllerParams.activeActions;
+ this.rendererParams.mailFields = this.mailFields;
+ },
+ /**
+ * Gets the option value of a field if present.
+ *
+ * @private
+ * @param {string} fieldName the desired field name
+ * @param {string} optionName the desired option name
+ * @param {*} defaultValue the default value if option or field is not found.
+ * @returns {*}
+ */
+ _getFieldOption(fieldName, optionName, defaultValue) {
+ const field = this.fieldsInfo[this.viewType][fieldName];
+ if (field && field.options && field.options[optionName] !== undefined) {
+ return field.options[optionName];
+ }
+ return defaultValue;
+ },
+ /**
+ * Checks whether the view has a given field.
+ *
+ * @private
+ * @param {string} fieldName the desired field name
+ * @returns {boolean}
+ */
+ _hasField(fieldName) {
+ return !!this.fieldsInfo[this.viewType][fieldName];
+ },
+});
+
+});
diff --git a/addons/mail/static/src/js/core/translation.js b/addons/mail/static/src/js/core/translation.js
new file mode 100644
index 00000000..faecafaf
--- /dev/null
+++ b/addons/mail/static/src/js/core/translation.js
@@ -0,0 +1,28 @@
+odoo.define('mail/static/src/js/core/translation.js', function (require) {
+'use strict';
+
+const { TranslationDataBase } = require('web.translation');
+
+const { Component } = owl;
+
+TranslationDataBase.include({
+ /**
+ * @override
+ */
+ set_bundle() {
+ const res = this._super(...arguments);
+ if (Component.env.messaging) {
+ // Update messaging locale whenever the translation bundle changes.
+ // In particular if messaging is created before the end of the
+ // `load_translations` RPC, the default values have to be
+ // updated by the received ones.
+ Component.env.messaging.locale.update({
+ language: this.parameters.code,
+ textDirection: this.parameters.direction,
+ });
+ }
+ return res;
+ },
+});
+
+});
diff --git a/addons/mail/static/src/js/custom_filter_item.js b/addons/mail/static/src/js/custom_filter_item.js
new file mode 100644
index 00000000..abe15eda
--- /dev/null
+++ b/addons/mail/static/src/js/custom_filter_item.js
@@ -0,0 +1,21 @@
+odoo.define('mail.CustomFilterItem', function (require) {
+ "use strict";
+
+ const CustomFilterItem = require('web.CustomFilterItem');
+
+ CustomFilterItem.patch('mail.CustomFilterItem', T => class extends T {
+
+ /**
+ * With the `mail` module installed, we want to filter out some of the
+ * available fields in 'Add custom filter' menu (@see CustomFilterItem).
+ * @override
+ */
+ _validateField(field) {
+ return super._validateField(...arguments) &&
+ field.relation !== 'mail.message' &&
+ field.name !== 'message_ids';
+ }
+ });
+
+ return CustomFilterItem;
+});
diff --git a/addons/mail/static/src/js/document_viewer.js b/addons/mail/static/src/js/document_viewer.js
new file mode 100644
index 00000000..b46aea30
--- /dev/null
+++ b/addons/mail/static/src/js/document_viewer.js
@@ -0,0 +1,396 @@
+odoo.define('mail.DocumentViewer', function (require) {
+"use strict";
+
+var core = require('web.core');
+var Widget = require('web.Widget');
+
+var QWeb = core.qweb;
+
+var SCROLL_ZOOM_STEP = 0.1;
+var ZOOM_STEP = 0.5;
+
+/**
+ * This widget is deprecated, and should instead use AttachmentViewer component.
+ * @see `mail/static/src/components/attachment_viewer/attachment_viewer.js`
+ * TODO: remove this widget when it's not longer used
+ *
+ * @deprecated
+ */
+var DocumentViewer = Widget.extend({
+ template: "DocumentViewer",
+ events: {
+ 'click .o_download_btn': '_onDownload',
+ 'click .o_viewer_img': '_onImageClicked',
+ 'click .o_viewer_video': '_onVideoClicked',
+ 'click .move_next': '_onNext',
+ 'click .move_previous': '_onPrevious',
+ 'click .o_rotate': '_onRotate',
+ 'click .o_zoom_in': '_onZoomIn',
+ 'click .o_zoom_out': '_onZoomOut',
+ 'click .o_zoom_reset': '_onZoomReset',
+ 'click .o_close_btn, .o_viewer_img_wrapper': '_onClose',
+ 'click .o_print_btn': '_onPrint',
+ 'DOMMouseScroll .o_viewer_content': '_onScroll', // Firefox
+ 'mousewheel .o_viewer_content': '_onScroll', // Chrome, Safari, IE
+ 'keydown': '_onKeydown',
+ 'keyup': '_onKeyUp',
+ 'mousedown .o_viewer_img': '_onStartDrag',
+ 'mousemove .o_viewer_content': '_onDrag',
+ 'mouseup .o_viewer_content': '_onEndDrag'
+ },
+ /**
+ * The documentViewer takes an array of objects describing attachments in
+ * argument, and the ID of an active attachment (the one to display first).
+ * Documents that are not of type image or video are filtered out.
+ *
+ * @override
+ * @param {Array<Object>} attachments list of attachments
+ * @param {integer} activeAttachmentID
+ */
+ init: function (parent, attachments, activeAttachmentID) {
+ this._super.apply(this, arguments);
+ this.attachment = _.filter(attachments, function (attachment) {
+ var match = attachment.type === 'url' ? attachment.url.match("(youtu|.png|.jpg|.gif)") : attachment.mimetype.match("(image|video|application/pdf|text)");
+ if (match) {
+ attachment.fileType = match[1];
+ if (match[1].match("(.png|.jpg|.gif)")) {
+ attachment.fileType = 'image';
+ }
+ if (match[1] === 'youtu') {
+ var youtube_array = attachment.url.split('/');
+ var youtube_token = youtube_array[youtube_array.length-1];
+ if (youtube_token.indexOf('watch') !== -1) {
+ youtube_token = youtube_token.split('v=')[1];
+ var amp = youtube_token.indexOf('&')
+ if (amp !== -1){
+ youtube_token = youtube_token.substring(0, amp);
+ }
+ }
+ attachment.youtube = youtube_token;
+ }
+ return true;
+ }
+ });
+ this.activeAttachment = _.findWhere(attachments, {id: activeAttachmentID});
+ this.modelName = 'ir.attachment';
+ this._reset();
+ },
+ /**
+ * Open a modal displaying the active attachment
+ * @override
+ */
+ start: function () {
+ this.$el.modal('show');
+ this.$el.on('hidden.bs.modal', _.bind(this._onDestroy, this));
+ this.$('.o_viewer_img').on("load", _.bind(this._onImageLoaded, this));
+ this.$('[data-toggle="tooltip"]').tooltip({delay: 0});
+ return this._super.apply(this, arguments);
+ },
+ /**
+ * @override
+ */
+ destroy: function () {
+ if (this.isDestroyed()) {
+ return;
+ }
+ this.$el.modal('hide');
+ this.$el.remove();
+ this._super.apply(this, arguments);
+ },
+
+ //--------------------------------------------------------------------------
+ // Private
+ //---------------------------------------------------------------------------
+
+ /**
+ * @private
+ */
+ _next: function () {
+ var index = _.findIndex(this.attachment, this.activeAttachment);
+ index = (index + 1) % this.attachment.length;
+ this.activeAttachment = this.attachment[index];
+ this._updateContent();
+ },
+ /**
+ * @private
+ */
+ _previous: function () {
+ var index = _.findIndex(this.attachment, this.activeAttachment);
+ index = index === 0 ? this.attachment.length - 1 : index - 1;
+ this.activeAttachment = this.attachment[index];
+ this._updateContent();
+ },
+ /**
+ * @private
+ */
+ _reset: function () {
+ this.scale = 1;
+ this.dragStartX = this.dragstopX = 0;
+ this.dragStartY = this.dragstopY = 0;
+ },
+ /**
+ * Render the active attachment
+ *
+ * @private
+ */
+ _updateContent: function () {
+ this.$('.o_viewer_content').html(QWeb.render('DocumentViewer.Content', {
+ widget: this
+ }));
+ this.$('.o_viewer_img').on("load", _.bind(this._onImageLoaded, this));
+ this.$('[data-toggle="tooltip"]').tooltip({delay: 0});
+ this._reset();
+ },
+ /**
+ * Get CSS transform property based on scale and angle
+ *
+ * @private
+ * @param {float} scale
+ * @param {float} angle
+ */
+ _getTransform: function(scale, angle) {
+ return 'scale3d(' + scale + ', ' + scale + ', 1) rotate(' + angle + 'deg)';
+ },
+ /**
+ * Rotate image clockwise by provided angle
+ *
+ * @private
+ * @param {float} angle
+ */
+ _rotate: function (angle) {
+ this._reset();
+ var new_angle = (this.angle || 0) + angle;
+ this.$('.o_viewer_img').css('transform', this._getTransform(this.scale, new_angle));
+ this.$('.o_viewer_img').css('max-width', new_angle % 180 !== 0 ? $(document).height() : '100%');
+ this.$('.o_viewer_img').css('max-height', new_angle % 180 !== 0 ? $(document).width() : '100%');
+ this.angle = new_angle;
+ },
+ /**
+ * Zoom in/out image by provided scale
+ *
+ * @private
+ * @param {integer} scale
+ */
+ _zoom: function (scale) {
+ if (scale > 0.5) {
+ this.$('.o_viewer_img').css('transform', this._getTransform(scale, this.angle || 0));
+ this.scale = scale;
+ }
+ this.$('.o_zoom_reset').add('.o_zoom_out').toggleClass('disabled', scale === 1);
+ },
+
+ //--------------------------------------------------------------------------
+ // Handlers
+ //--------------------------------------------------------------------------
+
+ /**
+ * @private
+ * @param {MouseEvent} e
+ */
+ _onClose: function (e) {
+ e.preventDefault();
+ this.destroy();
+ },
+ /**
+ * When popup close complete destroyed modal even DOM footprint too
+ *
+ * @private
+ */
+ _onDestroy: function () {
+ this.destroy();
+ },
+ /**
+ * @private
+ * @param {MouseEvent} e
+ */
+ _onDownload: function (e) {
+ e.preventDefault();
+ window.location = '/web/content/' + this.modelName + '/' + this.activeAttachment.id + '/' + 'datas' + '?download=true';
+ },
+ /**
+ * @private
+ * @param {MouseEvent} e
+ */
+ _onDrag: function (e) {
+ e.preventDefault();
+ if (this.enableDrag) {
+ var $image = this.$('.o_viewer_img');
+ var $zoomer = this.$('.o_viewer_zoomer');
+ var top = $image.prop('offsetHeight') * this.scale > $zoomer.height() ? e.clientY - this.dragStartY : 0;
+ var left = $image.prop('offsetWidth') * this.scale > $zoomer.width() ? e.clientX - this.dragStartX : 0;
+ $zoomer.css("transform", "translate3d("+ left +"px, " + top + "px, 0)");
+ $image.css('cursor', 'move');
+ }
+ },
+ /**
+ * @private
+ * @param {MouseEvent} e
+ */
+ _onEndDrag: function (e) {
+ e.preventDefault();
+ if (this.enableDrag) {
+ this.enableDrag = false;
+ this.dragstopX = e.clientX - this.dragStartX;
+ this.dragstopY = e.clientY - this.dragStartY;
+ this.$('.o_viewer_img').css('cursor', '');
+ }
+ },
+ /**
+ * On click of image do not close modal so stop event propagation
+ *
+ * @private
+ * @param {MouseEvent} e
+ */
+ _onImageClicked: function (e) {
+ e.stopPropagation();
+ },
+ /**
+ * Remove loading indicator when image loaded
+ * @private
+ */
+ _onImageLoaded: function () {
+ this.$('.o_loading_img').hide();
+ },
+ /**
+ * Move next previous attachment on keyboard right left key
+ *
+ * @private
+ * @param {KeyEvent} e
+ */
+ _onKeydown: function (e){
+ switch (e.which) {
+ case $.ui.keyCode.RIGHT:
+ e.preventDefault();
+ this._next();
+ break;
+ case $.ui.keyCode.LEFT:
+ e.preventDefault();
+ this._previous();
+ break;
+ }
+ },
+ /**
+ * Close popup on ESCAPE keyup
+ *
+ * @private
+ * @param {KeyEvent} e
+ */
+ _onKeyUp: function (e) {
+ switch (e.which) {
+ case $.ui.keyCode.ESCAPE:
+ e.preventDefault();
+ this._onClose(e);
+ break;
+ }
+ },
+ /**
+ * @private
+ * @param {MouseEvent} e
+ */
+ _onNext: function (e) {
+ e.preventDefault();
+ this._next();
+ },
+ /**
+ * @private
+ * @param {MouseEvent} e
+ */
+ _onPrevious: function (e) {
+ e.preventDefault();
+ this._previous();
+ },
+ /**
+ * @private
+ * @param {MouseEvent} e
+ */
+ _onPrint: function (e) {
+ e.preventDefault();
+ var src = this.$('.o_viewer_img').prop('src');
+ var script = QWeb.render('PrintImage', {
+ src: src
+ });
+ var printWindow = window.open('about:blank', "_new");
+ printWindow.document.open();
+ printWindow.document.write(script);
+ printWindow.document.close();
+ },
+ /**
+ * Zoom image on scroll
+ *
+ * @private
+ * @param {MouseEvent} e
+ */
+ _onScroll: function (e) {
+ var scale;
+ if (e.originalEvent.wheelDelta > 0 || e.originalEvent.detail < 0) {
+ scale = this.scale + SCROLL_ZOOM_STEP;
+ this._zoom(scale);
+ } else {
+ scale = this.scale - SCROLL_ZOOM_STEP;
+ this._zoom(scale);
+ }
+ },
+ /**
+ * @private
+ * @param {MouseEvent} e
+ */
+ _onStartDrag: function (e) {
+ e.preventDefault();
+ this.enableDrag = true;
+ this.dragStartX = e.clientX - (this.dragstopX || 0);
+ this.dragStartY = e.clientY - (this.dragstopY || 0);
+ },
+ /**
+ * On click of video do not close modal so stop event propagation
+ * and provide play/pause the video instead of quitting it
+ *
+ * @private
+ * @param {MouseEvent} e
+ */
+ _onVideoClicked: function (e) {
+ e.stopPropagation();
+ var videoElement = e.target;
+ if (videoElement.paused) {
+ videoElement.play();
+ } else {
+ videoElement.pause();
+ }
+ },
+ /**
+ * @private
+ * @param {MouseEvent} e
+ */
+ _onRotate: function (e) {
+ e.preventDefault();
+ this._rotate(90);
+ },
+ /**
+ * @private
+ * @param {MouseEvent} e
+ */
+ _onZoomIn: function (e) {
+ e.preventDefault();
+ var scale = this.scale + ZOOM_STEP;
+ this._zoom(scale);
+ },
+ /**
+ * @private
+ * @param {MouseEvent} e
+ */
+ _onZoomOut: function (e) {
+ e.preventDefault();
+ var scale = this.scale - ZOOM_STEP;
+ this._zoom(scale);
+ },
+ /**
+ * @private
+ * @param {MouseEvent} e
+ */
+ _onZoomReset: function (e) {
+ e.preventDefault();
+ this.$('.o_viewer_zoomer').css("transform", "");
+ this._zoom(1);
+ },
+});
+return DocumentViewer;
+});
diff --git a/addons/mail/static/src/js/emojis.js b/addons/mail/static/src/js/emojis.js
new file mode 100644
index 00000000..139bcdae
--- /dev/null
+++ b/addons/mail/static/src/js/emojis.js
@@ -0,0 +1,155 @@
+odoo.define('mail.emojis', function (require) {
+"use strict";
+
+/**
+ * This module exports the list of all available emojis on the client side.
+ * An emoji object has the following properties:
+ *
+ * - {string[]} sources: the character representations of the emoji
+ * - {string} unicode: the unicode representation of the emoji
+ * - {string} description: the description of the emoji
+ */
+
+/**
+ * This data represent all the available emojis that are supported on the web
+ * client:
+ *
+ * - key: this is the source representation of an emoji, i.e. its "character"
+ * representation. This is a string that can be easily typed by the
+ * user and then translated to its unicode representation (see value)
+ * - value: this is the unicode representation of an emoji, i.e. its "true"
+ * representation in the system.
+ */
+var data = {
+ ":)": "😊",
+ ":-)": "😊", // alternative (alt.)
+ "=)": "😊", // alt.
+ ":]": "😊", // alt.
+ ":D": "😃",
+ ":-D": "😃", // alt.
+ "=D": "😃", // alt.
+ "xD": "😆",
+ "XD": "😆", // alt.
+ "x'D": "😂",
+ ";)": "😉",
+ ";-)": "😉", // alt.
+ "B)": "😎",
+ "8)": "😎", // alt.
+ "B-)": "😎", // alt.
+ "8-)": "😎", // alt.
+ ";p": "😜",
+ ";P": "😜", // alt.
+ ":p": "😋",
+ ":P": "😋", // alt.
+ ":-p": "😋", // alt.
+ ":-P": "😋", // alt.
+ "=P": "😋", // alt.
+ "xp": "😝",
+ "xP": "😝", // alt.
+ "o_o": "😳",
+ ":|": "😐",
+ ":-|": "😐", // alt.
+ ":/": "😕", // alt.
+ ":-/": "😕", // alt.
+ ":(": "😞",
+ ":@": "😱",
+ ":O": "😲",
+ ":-O": "😲", // alt.
+ ":o": "😲", // alt.
+ ":-o": "😲", // alt.
+ ":'o": "😨",
+ "3:(": "😠",
+ ">:(": "😠", // alt.
+ "3:": "😠", // alt.
+ "3:)": "😈",
+ ">:)": "😈", // alt.
+ ":*": "😘",
+ ":-*": "😘", // alt.
+ "o:)": "😇",
+ ":'(": "😢",
+ ":'-(": "😭",
+ ":\"(": "😭", // alt.
+ "<3": "❤️",
+ "&lt;3": "❤️",
+ ":heart": "❤️", // alt.
+ "</3": "💔",
+ "&lt;/3": "💔",
+ ":heart_eyes": "😍",
+ ":turban": "👳",
+ ":+1": "👍",
+ ":-1": "👎",
+ ":ok": "👌",
+ ":poop": "💩",
+ ":no_see": "🙈",
+ ":no_hear": "🙉",
+ ":no_speak": "🙊",
+ ":bug": "🐞",
+ ":kitten": "😺",
+ ":bear": "🐻",
+ ":snail": "🐌",
+ ":boar": "🐗",
+ ":clover": "🍀",
+ ":sunflower": "🌹",
+ ":fire": "🔥",
+ ":sun": "☀️",
+ ":partly_sunny:": "⛅️",
+ ":rainbow": "🌈",
+ ":cloud": "☁️",
+ ":zap": "⚡️",
+ ":star": "⭐️",
+ ":cookie": "🍪",
+ ":pizza": "🍕",
+ ":hamburger": "🍔",
+ ":fries": "🍟",
+ ":cake": "🎂",
+ ":cake_part": "🍰",
+ ":coffee": "☕️",
+ ":banana": "🍌",
+ ":sushi": "🍣",
+ ":rice_ball": "🍙",
+ ":beer": "🍺",
+ ":wine": "🍷",
+ ":cocktail": "🍸",
+ ":tropical": "🍹",
+ ":beers": "🍻",
+ ":ghost": "👻",
+ ":skull": "💀",
+ ":et": "👽",
+ ":alien": "👽", // alt.
+ ":party": "🎉",
+ ":trophy": "🏆",
+ ":key": "🔑",
+ ":pin": "📌",
+ ":postal_horn": "📯",
+ ":music": "🎵",
+ ":trumpet": "🎺",
+ ":guitar": "🎸",
+ ":run": "🏃",
+ ":bike": "🚲",
+ ":soccer": "⚽️",
+ ":football": "🏈",
+ ":8ball": "🎱",
+ ":clapper": "🎬",
+ ":microphone": "🎤",
+ ":cheese": "🧀",
+};
+
+// list of emojis in a dictionary, indexed by emoji unicode
+var emojiDict = {};
+_.each(data, function (unicode, source) {
+ if (!emojiDict[unicode]) {
+ emojiDict[unicode] = {
+ sources: [source],
+ unicode: unicode,
+ description: source,
+ };
+ } else {
+ emojiDict[unicode].sources.push(source);
+ }
+});
+
+var emojis = _.values(emojiDict);
+
+return emojis;
+
+});
diff --git a/addons/mail/static/src/js/emojis_mixin.js b/addons/mail/static/src/js/emojis_mixin.js
new file mode 100644
index 00000000..aa535d1d
--- /dev/null
+++ b/addons/mail/static/src/js/emojis_mixin.js
@@ -0,0 +1,91 @@
+odoo.define('mail.emoji_mixin', function (require) {
+"use strict";
+
+var emojis = require('mail.emojis');
+
+/**
+ * This mixin gathers a few methods that are used to handle emojis.
+ *
+ * It's used to:
+ *
+ * - handle the click on an emoji from a dropdown panel and add it to the related textarea/input
+ * - format text and wrap the emojis around <span class="o_mail_emoji"> to make them look nicer
+ *
+ * Methods are based on the collections of emojis available in mail.emojis
+ *
+ */
+return {
+ //--------------------------------------------------------------------------
+ // Handlers
+ //--------------------------------------------------------------------------
+
+ /**
+ * This method should be bound to a click event on an emoji.
+ * (used in text element's emojis dropdown list)
+ *
+ * It assumes that a ``_getTargetTextElement`` method is defined that will return the related
+ * textarea/input element in which the emoji will be inserted.
+ *
+ * @param {MouseEvent} ev
+ */
+ _onEmojiClick: function (ev) {
+ var unicode = ev.currentTarget.textContent.trim();
+ var textInput = this._getTargetTextElement($(ev.currentTarget))[0];
+ var selectionStart = textInput.selectionStart;
+
+ textInput.value = textInput.value.slice(0, selectionStart) + unicode + textInput.value.slice(selectionStart);
+ textInput.focus();
+ textInput.setSelectionRange(selectionStart + unicode.length, selectionStart + unicode.length);
+ },
+
+ //--------------------------------------------------------------------------
+ // Private
+ //--------------------------------------------------------------------------
+
+ /**
+ * This method is used to wrap emojis in a text message with <span class="o_mail_emoji">
+ * As this returns html to be used in a 't-raw' argument, it first makes sure that the
+ * passed text message is html escaped for safety reasons.
+ *
+ * @param {String} message a text message to format
+ */
+ _formatText: function (message) {
+ message = this._htmlEscape(message);
+ message = this._wrapEmojis(message);
+ message = message.replace(/(?:\r\n|\r|\n)/g, '<br>');
+
+ return message;
+ },
+
+ /**
+ * Adapted from qweb2.js#html_escape to avoid formatting '&'
+ *
+ * @param {String} s
+ * @private
+ */
+ _htmlEscape: function (s) {
+ if (s == null) {
+ return '';
+ }
+ return String(s).replace(/</g, '&lt;').replace(/>/g, '&gt;');
+ },
+
+ /**
+ * Will use the mail.emojis library to wrap emojis unicode around a span with a special font
+ * that will make them look nicer (colored, ...).
+ *
+ * @param {String} message
+ */
+ _wrapEmojis: function (message) {
+ emojis.forEach(function (emoji) {
+ message = message.replace(
+ new RegExp(emoji.unicode.replace(/[.*+?^${}()|[\]\\]/g, '\\$&'), 'g'),
+ '<span class="o_mail_emoji">' + emoji.unicode + '</span>'
+ );
+ });
+
+ return message;
+ }
+};
+
+});
diff --git a/addons/mail/static/src/js/field_char.js b/addons/mail/static/src/js/field_char.js
new file mode 100644
index 00000000..1a1b90ec
--- /dev/null
+++ b/addons/mail/static/src/js/field_char.js
@@ -0,0 +1,56 @@
+odoo.define('sms.onchange_in_keyup', function (require) {
+"use strict";
+
+var FieldChar = require('web.basic_fields').FieldChar;
+FieldChar.include({
+
+ //--------------------------------------------------------------------------
+ // Public
+ //-------------------------------------------------------------------------
+
+ /**
+ * Support a key-based onchange in text field. In order to avoid too much
+ * rpc to the server _triggerOnchange is throttled (once every second max)
+ *
+ */
+ init: function () {
+ this._super.apply(this, arguments);
+ this._triggerOnchange = _.throttle(this._triggerOnchange, 1000, {leading: false});
+ },
+
+
+ //--------------------------------------------------------------------------
+ // Handlers
+ //--------------------------------------------------------------------------
+
+ /**
+ * Trigger the 'change' event at key down. It allows to trigger an onchange
+ * while typing which may be interesting in some cases. Otherwise onchange
+ * is triggered only on blur.
+ *
+ * @override
+ * @private
+ */
+ _onKeydown: function () {
+ this._super.apply(this, arguments);
+ if (this.nodeOptions.onchange_on_keydown) {
+ this._triggerOnchange();
+ }
+ },
+
+ //--------------------------------------------------------------------------
+ // Private
+ //--------------------------------------------------------------------------
+
+ /**
+ * Triggers the 'change' event to refresh the value. Throttled at init to
+ * avoid spaming server.
+ *
+ * @private
+ */
+ _triggerOnchange: function () {
+ this.$input.trigger('change');
+ },
+});
+
+});
diff --git a/addons/mail/static/src/js/field_char_emojis.js b/addons/mail/static/src/js/field_char_emojis.js
new file mode 100644
index 00000000..012a68c2
--- /dev/null
+++ b/addons/mail/static/src/js/field_char_emojis.js
@@ -0,0 +1,18 @@
+odoo.define('mail.field_char_emojis', function (require) {
+"use strict";
+
+var basicFields = require('web.basic_fields');
+var registry = require('web.field_registry');
+var FieldEmojiCommon = require('mail.field_emojis_common');
+var MailEmojisMixin = require('mail.emoji_mixin');
+
+/**
+ * Extension of the FieldChar that will add emojis support
+ */
+var FieldCharEmojis = basicFields.FieldChar.extend(MailEmojisMixin, FieldEmojiCommon);
+
+registry.add('char_emojis', FieldCharEmojis);
+
+return FieldCharEmojis;
+
+});
diff --git a/addons/mail/static/src/js/field_emojis_common.js b/addons/mail/static/src/js/field_emojis_common.js
new file mode 100644
index 00000000..79215ac7
--- /dev/null
+++ b/addons/mail/static/src/js/field_emojis_common.js
@@ -0,0 +1,136 @@
+odoo.define('mail.field_emojis_common', function (require) {
+"use strict";
+
+var basicFields = require('web.basic_fields');
+var core = require('web.core');
+var emojis = require('mail.emojis');
+var MailEmojisMixin = require('mail.emoji_mixin');
+var _onEmojiClickMixin = MailEmojisMixin._onEmojiClick;
+var QWeb = core.qweb;
+
+/*
+ * Common code for FieldTextEmojis and FieldCharEmojis
+ */
+var FieldEmojiCommon = {
+ /**
+ * @override
+ * @private
+ */
+ init: function () {
+ this._super.apply(this, arguments);
+ this._triggerOnchange = _.throttle(this._triggerOnchange, 1000, {leading: false});
+ this.emojis = emojis;
+ },
+
+ /**
+ * @override
+ */
+ on_attach_callback: function () {
+ this._attachEmojisDropdown();
+ },
+
+ //--------------------------------------------------------------------------
+ // Handlers
+ //--------------------------------------------------------------------------
+
+ /**
+ * @override
+ * @private
+ */
+ _render: function () {
+ this._super.apply(this, arguments);
+
+ if (this.mode !== 'edit') {
+ this.$el.html(this._formatText(this.$el.text()));
+ }
+ },
+
+ /**
+ * Overridden because we need to add the Emoji to the input AND trigger
+ * the 'change' event to refresh the value.
+ *
+ * @override
+ * @private
+ */
+ _onEmojiClick: function () {
+ _onEmojiClickMixin.apply(this, arguments);
+ this._isDirty = true;
+ this.$input.trigger('change');
+ },
+
+ /**
+ *
+ * By default, the 'change' event is only triggered when the text element is blurred.
+ *
+ * We override this method because we want to update the value while
+ * the user is typing his message (and not only on blur).
+ *
+ * @override
+ * @private
+ */
+ _onKeydown: function () {
+ this._super.apply(this, arguments);
+ if (this.nodeOptions.onchange_on_keydown) {
+ this._triggerOnchange();
+ }
+ },
+
+ //--------------------------------------------------------------------------
+ // Private
+ //--------------------------------------------------------------------------
+
+ /**
+ * Used by MailEmojisMixin, check its document for more info.
+ *
+ * @private
+ */
+ _getTargetTextElement() {
+ return this.$el;
+ },
+
+ /**
+ * Triggers the 'change' event to refresh the value.
+ * This method is throttled to run at most once every second.
+ * (to avoid spamming the server while the user is typing his message)
+ *
+ * @private
+ */
+ _triggerOnchange: function () {
+ this.$input.trigger('change');
+ },
+
+ /**
+ * This will add an emoji button that shows the emojis selection dropdown.
+ *
+ * Should be used inside 'on_attach_callback' because we need the element to be attached to the form first.
+ * That's because the $emojisIcon element needs to be rendered outside of this $el
+ * (which is an text element, that can't 'contain' any other elements).
+ *
+ * @private
+ */
+ _attachEmojisDropdown: function () {
+ if (!this.$emojisIcon) {
+ this.$emojisIcon = $(QWeb.render('mail.EmojisDropdown', {widget: this}));
+ this.$emojisIcon.find('.o_mail_emoji').on('click', this._onEmojiClick.bind(this));
+
+ if (this.$el.filter('span.o_field_translate').length) {
+ // multi-languages activated, place the button on the left of the translation button
+ this.$emojisIcon.addClass('o_mail_emojis_dropdown_translation');
+ }
+ if (this.$el.filter('textarea').length) {
+ this.$emojisIcon.addClass('o_mail_emojis_dropdown_textarea');
+ }
+ this.$el.last().after(this.$emojisIcon);
+ }
+
+ if (this.mode === 'edit') {
+ this.$emojisIcon.show();
+ } else {
+ this.$emojisIcon.hide();
+ }
+ }
+};
+
+return FieldEmojiCommon;
+
+});
diff --git a/addons/mail/static/src/js/field_text_emojis.js b/addons/mail/static/src/js/field_text_emojis.js
new file mode 100644
index 00000000..8ed14ecf
--- /dev/null
+++ b/addons/mail/static/src/js/field_text_emojis.js
@@ -0,0 +1,18 @@
+odoo.define('mail.field_text_emojis', function (require) {
+"use strict";
+
+var basicFields = require('web.basic_fields');
+var registry = require('web.field_registry');
+var FieldEmojiCommon = require('mail.field_emojis_common');
+var MailEmojisMixin = require('mail.emoji_mixin');
+
+/**
+ * Extension of the FieldText that will add emojis support
+ */
+var FieldTextEmojis = basicFields.FieldText.extend(MailEmojisMixin, FieldEmojiCommon);
+
+registry.add('text_emojis', FieldTextEmojis);
+
+return FieldTextEmojis;
+
+});
diff --git a/addons/mail/static/src/js/main.js b/addons/mail/static/src/js/main.js
new file mode 100644
index 00000000..d19a8edb
--- /dev/null
+++ b/addons/mail/static/src/js/main.js
@@ -0,0 +1,126 @@
+odoo.define('mail/static/src/js/main.js', function (require) {
+'use strict';
+
+const ModelManager = require('mail/static/src/model/model_manager.js');
+
+const env = require('web.commonEnv');
+
+const { Store } = owl;
+const { EventBus } = owl.core;
+
+async function createMessaging() {
+ await new Promise(resolve => {
+ /**
+ * Called when all JS resources are loaded. This is useful in order
+ * to do some processing after other JS files have been parsed, for
+ * example new models or patched models that are coming from
+ * other modules, because some of those patches might need to be
+ * applied before messaging initialization.
+ */
+ window.addEventListener('load', resolve);
+ });
+ /**
+ * All JS resources are loaded, but not necessarily processed.
+ * We assume no messaging-related modules return any Promise,
+ * therefore they should be processed *at most* asynchronously at
+ * "Promise time".
+ */
+ await new Promise(resolve => setTimeout(resolve));
+ /**
+ * Some models require session data, like locale text direction (depends on
+ * fully loaded translation).
+ */
+ await env.session.is_bound;
+
+ env.modelManager.start();
+ /**
+ * Create the messaging singleton record.
+ */
+ env.messaging = env.models['mail.messaging'].create();
+}
+
+/**
+ * Messaging store
+ */
+const store = new Store({
+ env,
+ state: {
+ messagingRevNumber: 0,
+ },
+});
+
+/**
+ * Registry of models.
+ */
+env.models = {};
+/**
+ * Environment keys used in messaging.
+ */
+Object.assign(env, {
+ autofetchPartnerImStatus: true,
+ destroyMessaging() {
+ if (env.modelManager) {
+ env.modelManager.deleteAll();
+ env.messaging = undefined;
+ }
+ },
+ disableAnimation: false,
+ isMessagingInitialized() {
+ if (!this.messaging) {
+ return false;
+ }
+ return this.messaging.isInitialized;
+ },
+ /**
+ * States whether the environment is in QUnit test or not.
+ *
+ * Useful to prevent some behaviour in QUnit tests, like applying
+ * style of attachment that uses url.
+ */
+ isQUnitTest: false,
+ loadingBaseDelayDuration: 400,
+ messaging: undefined,
+ messagingBus: new EventBus(),
+ /**
+ * Promise which becomes resolved when messaging is created.
+ *
+ * Useful for discuss widget to know when messaging is created, because this
+ * is an essential condition to make it work.
+ */
+ messagingCreatedPromise: createMessaging(),
+ modelManager: new ModelManager(env),
+ store,
+});
+
+/**
+ * Components cannot use web.bus, because they cannot use
+ * EventDispatcherMixin, and webclient cannot easily access env.
+ * Communication between webclient and components by core.bus
+ * (usable by webclient) and messagingBus (usable by components), which
+ * the messaging service acts as mediator since it can easily use both
+ * kinds of buses.
+ */
+env.bus.on(
+ 'hide_home_menu',
+ null,
+ () => env.messagingBus.trigger('hide_home_menu')
+);
+env.bus.on(
+ 'show_home_menu',
+ null,
+ () => env.messagingBus.trigger('show_home_menu')
+);
+env.bus.on(
+ 'will_hide_home_menu',
+ null,
+ () => env.messagingBus.trigger('will_hide_home_menu')
+);
+env.bus.on(
+ 'will_show_home_menu',
+ null,
+ () => env.messagingBus.trigger('will_show_home_menu')
+);
+
+env.messagingCreatedPromise.then(() => env.messaging.start());
+
+});
diff --git a/addons/mail/static/src/js/many2many_tags_email.js b/addons/mail/static/src/js/many2many_tags_email.js
new file mode 100644
index 00000000..6648ef50
--- /dev/null
+++ b/addons/mail/static/src/js/many2many_tags_email.js
@@ -0,0 +1,135 @@
+odoo.define('mail.many2manytags', function (require) {
+"use strict";
+
+var BasicModel = require('web.BasicModel');
+var core = require('web.core');
+var form_common = require('web.view_dialogs');
+var field_registry = require('web.field_registry');
+var relational_fields = require('web.relational_fields');
+
+var M2MTags = relational_fields.FieldMany2ManyTags;
+var _t = core._t;
+
+BasicModel.include({
+
+ //--------------------------------------------------------------------------
+ // Private
+ //--------------------------------------------------------------------------
+
+ /**
+ * @private
+ * @param {Object} record - an element from the localData
+ * @param {string} fieldName
+ * @return {Promise<Object>} the promise is resolved with the
+ * invalidPartnerIds
+ */
+ _setInvalidMany2ManyTagsEmail: function (record, fieldName) {
+ var self = this;
+ var localID = (record._changes && fieldName in record._changes) ?
+ record._changes[fieldName] :
+ record.data[fieldName];
+ var list = this._applyX2ManyOperations(this.localData[localID]);
+ var invalidPartnerIds = [];
+ _.each(list.data, function (id) {
+ var record = self.localData[id];
+ if (!record.data.email) {
+ invalidPartnerIds.push(record);
+ }
+ });
+ var def;
+ if (invalidPartnerIds.length) {
+ // remove invalid partners
+ var changes = {operation: 'DELETE', ids: _.pluck(invalidPartnerIds, 'id')};
+ def = this._applyX2ManyChange(record, fieldName, changes);
+ }
+ return Promise.resolve(def).then(function () {
+ return {
+ invalidPartnerIds: _.pluck(invalidPartnerIds, 'res_id'),
+ };
+ });
+ },
+});
+
+var FieldMany2ManyTagsEmail = M2MTags.extend({
+ tag_template: "FieldMany2ManyTagsEmail",
+ fieldsToFetch: _.extend({}, M2MTags.prototype.fieldsToFetch, {
+ email: {type: 'char'},
+ }),
+ specialData: "_setInvalidMany2ManyTagsEmail",
+
+ //--------------------------------------------------------------------------
+ // Private
+ //--------------------------------------------------------------------------
+
+ /**
+ * Open a popup for each invalid partners (without email) to fill the email.
+ *
+ * @private
+ * @returns {Promise}
+ */
+ _checkEmailPopup: function () {
+ var self = this;
+
+ var popupDefs = [];
+ var validPartners = [];
+
+ // propose the user to correct invalid partners
+ _.each(this.record.specialData[this.name].invalidPartnerIds, function (resID) {
+ var def = new Promise(function (resolve, reject) {
+ var pop = new form_common.FormViewDialog(self, {
+ res_model: self.field.relation,
+ res_id: resID,
+ context: self.record.context,
+ title: "",
+ on_saved: function (record) {
+ if (record.data.email) {
+ validPartners.push(record.res_id);
+ }
+ },
+ }).open();
+ pop.on('closed', self, function () {
+ resolve();
+ });
+ });
+ popupDefs.push(def);
+ });
+ return Promise.all(popupDefs).then(function() {
+ // All popups have been processed for the given ids
+ // It is now time to set the final value with valid partners ids.
+ validPartners = _.uniq(validPartners);
+ if (validPartners.length) {
+ var values = _.map(validPartners, function (id) {
+ return {id: id};
+ });
+ self._setValue({
+ operation: 'ADD_M2M',
+ ids: values,
+ });
+ }
+ });
+ },
+ /**
+ * Override to check if all many2many values have an email set before
+ * rendering the widget.
+ *
+ * @override
+ * @private
+ */
+ _render: function () {
+ var self = this;
+ var _super = this._super.bind(this);
+ return new Promise(function (resolve, reject) {
+ if (self.record.specialData[self.name].invalidPartnerIds.length) {
+ resolve(self._checkEmailPopup());
+ } else {
+ resolve();
+ }
+ }).then(function () {
+ return _super.apply(self, arguments);
+ });
+ },
+});
+
+field_registry.add('many2many_tags_email', FieldMany2ManyTagsEmail);
+
+});
diff --git a/addons/mail/static/src/js/many2one_avatar_user.js b/addons/mail/static/src/js/many2one_avatar_user.js
new file mode 100644
index 00000000..6a5b5270
--- /dev/null
+++ b/addons/mail/static/src/js/many2one_avatar_user.js
@@ -0,0 +1,68 @@
+odoo.define('mail.Many2OneAvatarUser', function (require) {
+ "use strict";
+
+ // This module defines an extension of the Many2OneAvatar widget, which is
+ // integrated with the messaging system. The Many2OneAvatarUser is designed
+ // to display people, and when the avatar of those people is clicked, it
+ // opens a DM chat window with the corresponding user.
+ //
+ // This widget is supported on many2one fields pointing to 'res.users'.
+ //
+ // Usage:
+ // <field name="user_id" widget="many2one_avatar_user"/>
+ //
+ // The widget is designed to be extended, to support many2one fields pointing
+ // to other models than 'res.users'.
+
+ const fieldRegistry = require('web.field_registry');
+ const { Many2OneAvatar } = require('web.relational_fields');
+
+ const { Component } = owl;
+
+ const Many2OneAvatarUser = Many2OneAvatar.extend({
+ events: Object.assign({}, Many2OneAvatar.prototype.events, {
+ 'click .o_m2o_avatar': '_onAvatarClicked',
+ }),
+ // This widget is only supported on many2ones pointing to 'res.users'
+ supportedModels: ['res.users'],
+
+ init() {
+ this._super(...arguments);
+ if (!this.supportedModels.includes(this.field.relation)) {
+ throw new Error(`This widget is only supported on many2one fields pointing to ${JSON.stringify(this.supportedModels)}`);
+ }
+ if (this.mode === 'readonly') {
+ this.className += ' o_clickable_m2o_avatar';
+ }
+ },
+
+ //----------------------------------------------------------------------
+ // Handlers
+ //----------------------------------------------------------------------
+
+ /**
+ * When the avatar is clicked, open a DM chat window with the
+ * corresponding user.
+ *
+ * @private
+ * @param {MouseEvent} ev
+ */
+ async _onAvatarClicked(ev) {
+ ev.stopPropagation(); // in list view, prevent from opening the record
+ const env = Component.env;
+ await env.messaging.openChat({ userId: this.value.res_id });
+ }
+ });
+
+ const KanbanMany2OneAvatarUser = Many2OneAvatarUser.extend({
+ _template: 'mail.KanbanMany2OneAvatarUser',
+ });
+
+ fieldRegistry.add('many2one_avatar_user', Many2OneAvatarUser);
+ fieldRegistry.add('kanban.many2one_avatar_user', KanbanMany2OneAvatarUser);
+
+ return {
+ Many2OneAvatarUser,
+ KanbanMany2OneAvatarUser,
+ };
+});
diff --git a/addons/mail/static/src/js/systray/systray_activity_menu.js b/addons/mail/static/src/js/systray/systray_activity_menu.js
new file mode 100644
index 00000000..a7299599
--- /dev/null
+++ b/addons/mail/static/src/js/systray/systray_activity_menu.js
@@ -0,0 +1,202 @@
+odoo.define('mail.systray.ActivityMenu', function (require) {
+"use strict";
+
+var core = require('web.core');
+var session = require('web.session');
+var SystrayMenu = require('web.SystrayMenu');
+var Widget = require('web.Widget');
+var Time = require('web.time');
+var QWeb = core.qweb;
+
+const { Component } = owl;
+
+/**
+ * Menu item appended in the systray part of the navbar, redirects to the next
+ * activities of all app
+ */
+var ActivityMenu = Widget.extend({
+ name: 'activity_menu',
+ template:'mail.systray.ActivityMenu',
+ events: {
+ 'click .o_mail_activity_action': '_onActivityActionClick',
+ 'click .o_mail_preview': '_onActivityFilterClick',
+ 'show.bs.dropdown': '_onActivityMenuShow',
+ 'hide.bs.dropdown': '_onActivityMenuHide',
+ },
+ start: function () {
+ this._$activitiesPreview = this.$('.o_mail_systray_dropdown_items');
+ Component.env.bus.on('activity_updated', this, this._updateCounter);
+ this._updateCounter();
+ this._updateActivityPreview();
+ return this._super();
+ },
+ //--------------------------------------------------
+ // Private
+ //--------------------------------------------------
+ /**
+ * Make RPC and get current user's activity details
+ * @private
+ */
+ _getActivityData: function () {
+ var self = this;
+
+ return self._rpc({
+ model: 'res.users',
+ method: 'systray_get_activities',
+ args: [],
+ kwargs: {context: session.user_context},
+ }).then(function (data) {
+ self._activities = data;
+ self.activityCounter = _.reduce(data, function (total_count, p_data) { return total_count + p_data.total_count || 0; }, 0);
+ self.$('.o_notification_counter').text(self.activityCounter);
+ self.$el.toggleClass('o_no_notification', !self.activityCounter);
+ });
+ },
+ /**
+ * Get particular model view to redirect on click of activity scheduled on that model.
+ * @private
+ * @param {string} model
+ */
+ _getActivityModelViewID: function (model) {
+ return this._rpc({
+ model: model,
+ method: 'get_activity_view_id'
+ });
+ },
+ /**
+ * Return views to display when coming from systray depending on the model.
+ *
+ * @private
+ * @param {string} model
+ * @returns {Array[]} output the list of views to display.
+ */
+ _getViewsList(model) {
+ return [[false, 'kanban'], [false, 'list'], [false, 'form']];
+ },
+ /**
+ * Update(render) activity system tray view on activity updation.
+ * @private
+ */
+ _updateActivityPreview: function () {
+ var self = this;
+ self._getActivityData().then(function (){
+ self._$activitiesPreview.html(QWeb.render('mail.systray.ActivityMenu.Previews', {
+ widget: self,
+ Time: Time
+ }));
+ });
+ },
+ /**
+ * update counter based on activity status(created or Done)
+ * @private
+ * @param {Object} [data] key, value to decide activity created or deleted
+ * @param {String} [data.type] notification type
+ * @param {Boolean} [data.activity_deleted] when activity deleted
+ * @param {Boolean} [data.activity_created] when activity created
+ */
+ _updateCounter: function (data) {
+ if (data) {
+ if (data.activity_created) {
+ this.activityCounter ++;
+ }
+ if (data.activity_deleted && this.activityCounter > 0) {
+ this.activityCounter --;
+ }
+ this.$('.o_notification_counter').text(this.activityCounter);
+ this.$el.toggleClass('o_no_notification', !this.activityCounter);
+ }
+ },
+
+ //------------------------------------------------------------
+ // Handlers
+ //------------------------------------------------------------
+
+ /**
+ * Redirect to specific action given its xml id or to the activity
+ * view of the current model if no xml id is provided
+ *
+ * @private
+ * @param {MouseEvent} ev
+ */
+ _onActivityActionClick: function (ev) {
+ ev.stopPropagation();
+ this.$('.dropdown-toggle').dropdown('toggle');
+ var targetAction = $(ev.currentTarget);
+ var actionXmlid = targetAction.data('action_xmlid');
+ if (actionXmlid) {
+ this.do_action(actionXmlid);
+ } else {
+ var domain = [['activity_ids.user_id', '=', session.uid]]
+ if (targetAction.data('domain')) {
+ domain = domain.concat(targetAction.data('domain'))
+ }
+
+ this.do_action({
+ type: 'ir.actions.act_window',
+ name: targetAction.data('model_name'),
+ views: [[false, 'activity'], [false, 'kanban'], [false, 'list'], [false, 'form']],
+ view_mode: 'activity',
+ res_model: targetAction.data('res_model'),
+ domain: domain,
+ }, {
+ clear_breadcrumbs: true,
+ });
+ }
+ },
+
+ /**
+ * Redirect to particular model view
+ * @private
+ * @param {MouseEvent} event
+ */
+ _onActivityFilterClick: function (event) {
+ // fetch the data from the button otherwise fetch the ones from the parent (.o_mail_preview).
+ var data = _.extend({}, $(event.currentTarget).data(), $(event.target).data());
+ var context = {};
+ if (data.filter === 'my') {
+ context['search_default_activities_overdue'] = 1;
+ context['search_default_activities_today'] = 1;
+ } else {
+ context['search_default_activities_' + data.filter] = 1;
+ }
+ // Necessary because activity_ids of mail.activity.mixin has auto_join
+ // So, duplicates are faking the count and "Load more" doesn't show up
+ context['force_search_count'] = 1;
+
+ var domain = [['activity_ids.user_id', '=', session.uid]]
+ if (data.domain) {
+ domain = domain.concat(data.domain)
+ }
+
+ this.do_action({
+ type: 'ir.actions.act_window',
+ name: data.model_name,
+ res_model: data.res_model,
+ views: this._getViewsList(data.res_model),
+ search_view_id: [false],
+ domain: domain,
+ context:context,
+ }, {
+ clear_breadcrumbs: true,
+ });
+ },
+ /**
+ * @private
+ */
+ _onActivityMenuShow: function () {
+ document.body.classList.add('modal-open');
+ this._updateActivityPreview();
+ },
+ /**
+ * @private
+ */
+ _onActivityMenuHide: function () {
+ document.body.classList.remove('modal-open');
+ },
+});
+
+SystrayMenu.Items.push(ActivityMenu);
+
+return ActivityMenu;
+
+});
diff --git a/addons/mail/static/src/js/tools/debug_manager.js b/addons/mail/static/src/js/tools/debug_manager.js
new file mode 100644
index 00000000..798765eb
--- /dev/null
+++ b/addons/mail/static/src/js/tools/debug_manager.js
@@ -0,0 +1,33 @@
+odoo.define('mail.DebugManager.Backend', function (require) {
+"use strict";
+
+var core = require('web.core');
+var DebugManager = require('web.DebugManager.Backend');
+
+var _t = core._t;
+/**
+ * adds a new method available for the debug manager, called by the "Manage Messages" button.
+ *
+ */
+DebugManager.include({
+ getMailMessages: function () {
+ var selectedIDs = this._controller.getSelectedIds();
+ if (!selectedIDs.length) {
+ console.warn(_t("No message available"));
+ return;
+ }
+ this.do_action({
+ res_model: 'mail.message',
+ name: _t('Manage Messages'),
+ views: [[false, 'list'], [false, 'form']],
+ type: 'ir.actions.act_window',
+ domain: [['res_id', '=', selectedIDs[0]], ['model', '=', this._controller.modelName]],
+ context: {
+ default_res_model: this._controller.modelName,
+ default_res_id: selectedIDs[0],
+ },
+ });
+ },
+});
+
+});
diff --git a/addons/mail/static/src/js/tours/mail.js b/addons/mail/static/src/js/tours/mail.js
new file mode 100644
index 00000000..8870abc6
--- /dev/null
+++ b/addons/mail/static/src/js/tours/mail.js
@@ -0,0 +1,59 @@
+odoo.define('mail.tour', function (require) {
+"use strict";
+
+var core = require('web.core');
+var tour = require('web_tour.tour');
+
+var _t = core._t;
+
+tour.register('mail_tour', {
+ url: "/web#action=mail.widgets.discuss",
+ sequence: 80,
+}, [{
+ trigger: '.o_DiscussSidebar_groupChannel .o_DiscussSidebar_groupHeaderItemAdd',
+ content: _t("<p>Channels make it easy to organize information across different topics and groups.</p> <p>Try to <b>create your first channel</b> (e.g. sales, marketing, product XYZ, after work party, etc).</p>"),
+ position: 'bottom',
+}, {
+ trigger: '.o_DiscussSidebar_itemNewInput',
+ content: _t("<p>Create a channel here.</p>"),
+ position: 'bottom',
+ auto: true,
+ run: function (actions) {
+ var t = new Date().getTime();
+ actions.text("SomeChannel_" + t, this.$anchor);
+ },
+}, {
+ trigger: ".o_DiscussSidebar_newChannelAutocompleteSuggestions",
+ content: _t("<p>Create a public or private channel.</p>"),
+ position: 'right',
+ run() {
+ this.$consumeEventAnchors.find('li:first').click();
+ },
+}, {
+ trigger: '.o_Discuss_thread .o_ComposerTextInput_textarea',
+ content: _t("<p><b>Write a message</b> to the members of the channel here.</p> <p>You can notify someone with <i>'@'</i> or link another channel with <i>'#'</i>. Start your message with <i>'/'</i> to get the list of possible commands.</p>"),
+ position: "top",
+ width: 350,
+ run: function (actions) {
+ var t = new Date().getTime();
+ actions.text("SomeText_" + t, this.$anchor);
+ },
+}, {
+ trigger: '.o_Discuss_thread .o_Composer_buttonSend',
+ content: _t("Post your message on the thread"),
+ position: "top",
+}, {
+ trigger: '.o_Discuss_thread .o_Message_commandStar',
+ content: _t("Messages can be <b>starred</b> to remind you to check back later."),
+ position: "bottom",
+}, {
+ trigger: '.o_DiscussSidebarItem.o-starred-box',
+ content: _t("Once a message has been starred, you can come back and review it at any time here."),
+ position: "bottom",
+}, {
+ trigger: '.o_DiscussSidebar_groupChat .o_DiscussSidebar_groupHeaderItemAdd',
+ content: _t("<p><b>Chat with coworkers</b> in real-time using direct messages.</p><p><i>You might need to invite users from the Settings app first.</i></p>"),
+ position: 'bottom',
+}]);
+
+});
diff --git a/addons/mail/static/src/js/utils.js b/addons/mail/static/src/js/utils.js
new file mode 100644
index 00000000..d267623d
--- /dev/null
+++ b/addons/mail/static/src/js/utils.js
@@ -0,0 +1,187 @@
+odoo.define('mail.utils', function (require) {
+"use strict";
+
+var core = require('web.core');
+
+var _t = core._t;
+
+/**
+ * WARNING: this is not enough to unescape potential XSS contained in htmlString, transformFunction
+ * should handle it or it should be handled after/before calling parseAndTransform. So if the result
+ * of this function is used in a t-raw, be very careful.
+ *
+ * @param {string} htmlString
+ * @param {function} transformFunction
+ * @returns {string}
+ */
+function parseAndTransform(htmlString, transformFunction) {
+ var openToken = "OPEN" + Date.now();
+ var string = htmlString.replace(/&lt;/g, openToken);
+ var children;
+ try {
+ children = $('<div>').html(string).contents();
+ } catch (e) {
+ children = $('<div>').html('<pre>' + string + '</pre>').contents();
+ }
+ return _parseAndTransform(children, transformFunction)
+ .replace(new RegExp(openToken, "g"), "&lt;");
+}
+
+/**
+ * @param {Node[]} nodes
+ * @param {function} transformFunction with:
+ * param node
+ * param function
+ * return string
+ * @return {string}
+ */
+function _parseAndTransform(nodes, transformFunction) {
+ return _.map(nodes, function (node) {
+ return transformFunction(node, function () {
+ return _parseAndTransform(node.childNodes, transformFunction);
+ });
+ }).join("");
+}
+
+// Suggested URL Javascript regex of http://stackoverflow.com/questions/3809401/what-is-a-good-regular-expression-to-match-a-url
+// Adapted to make http(s):// not required if (and only if) www. is given. So `should.notmatch` does not match.
+// And further extended to include Latin-1 Supplement, Latin Extended-A, Latin Extended-B and Latin Extended Additional.
+var urlRegexp = /\b(?:https?:\/\/\d{1,3}(?:\.\d{1,3}){3}|(?:https?:\/\/|(?:www\.))[-a-z0-9@:%._+~#=\u00C0-\u024F\u1E00-\u1EFF]{2,256}\.[a-z]{2,13})\b(?:[-a-z0-9@:%_+.~#?&'$//=;\u00C0-\u024F\u1E00-\u1EFF]*)/gi;
+/**
+ * @param {string} text
+ * @param {Object} [attrs={}]
+ * @return {string} linkified text
+ */
+function linkify(text, attrs) {
+ attrs = attrs || {};
+ if (attrs.target === undefined) {
+ attrs.target = '_blank';
+ }
+ if (attrs.target === '_blank') {
+ attrs.rel = 'noreferrer noopener';
+ }
+ attrs = _.map(attrs, function (value, key) {
+ return key + '="' + _.escape(value) + '"';
+ }).join(' ');
+ return text.replace(urlRegexp, function (url) {
+ var href = (!/^https?:\/\//i.test(url)) ? "http://" + url : url;
+ return '<a ' + attrs + ' href="' + href + '">' + url + '</a>';
+ });
+}
+
+function addLink(node, transformChildren) {
+ if (node.nodeType === 3) { // text node
+ const linkified = linkify(node.data);
+ if (linkified !== node.data) {
+ const div = document.createElement('div');
+ div.innerHTML = linkified;
+ for (const childNode of [...div.childNodes]) {
+ node.parentNode.insertBefore(childNode, node);
+ }
+ node.parentNode.removeChild(node);
+ return linkified;
+ }
+ return node.textContent;
+ }
+ if (node.tagName === "A") return node.outerHTML;
+ transformChildren();
+ return node.outerHTML;
+}
+
+/**
+ * @param {string} htmlString
+ * @return {string}
+ */
+function htmlToTextContentInline(htmlString) {
+ const fragment = document.createDocumentFragment();
+ const div = document.createElement('div');
+ fragment.appendChild(div);
+ htmlString = htmlString.replace(/<br\s*\/?>/gi,' ');
+ try {
+ div.innerHTML = htmlString;
+ } catch (e) {
+ div.innerHTML = `<pre>${htmlString}</pre>`;
+ }
+ return div
+ .textContent
+ .trim()
+ .replace(/[\n\r]/g, '')
+ .replace(/\s\s+/g, ' ');
+}
+
+function stripHTML(node, transformChildren) {
+ if (node.nodeType === 3) return node.data; // text node
+ if (node.tagName === "BR") return "\n";
+ return transformChildren();
+}
+
+function inline(node, transform_children) {
+ if (node.nodeType === 3) return node.data;
+ if (node.nodeType === 8) return "";
+ if (node.tagName === "BR") return " ";
+ if (node.tagName.match(/^(A|P|DIV|PRE|BLOCKQUOTE)$/)) return transform_children();
+ node.innerHTML = transform_children();
+ return node.outerHTML;
+}
+
+// Parses text to find email: Tagada <address@mail.fr> -> [Tagada, address@mail.fr] or False
+function parseEmail(text) {
+ if (text){
+ var result = text.match(/(.*)<(.*@.*)>/);
+ if (result) {
+ return [_.str.trim(result[1]), _.str.trim(result[2])];
+ }
+ result = text.match(/(.*@.*)/);
+ if (result) {
+ return [_.str.trim(result[1]), _.str.trim(result[1])];
+ }
+ return [text, false];
+ }
+}
+
+/**
+ * Returns an escaped conversion of a content.
+ *
+ * @param {string} content
+ * @returns {string}
+ */
+function escapeAndCompactTextContent(content) {
+ //Removing unwanted extra spaces from message
+ let value = owl.utils.escape(content).trim();
+ value = value.replace(/(\r|\n){2,}/g, '<br/><br/>');
+ value = value.replace(/(\r|\n)/g, '<br/>');
+
+ // prevent html space collapsing
+ value = value.replace(/ /g, '&nbsp;').replace(/([^>])&nbsp;([^<])/g, '$1 $2');
+ return value;
+}
+
+// Replaces textarea text into html text (add <p>, <a>)
+// TDE note : should be done server-side, in Python -> use mail.compose.message ?
+function getTextToHTML(text) {
+ return text
+ .replace(/((?:https?|ftp):\/\/[\S]+)/g,'<a href="$1">$1</a> ')
+ .replace(/[\n\r]/g,'<br/>');
+}
+
+function timeFromNow(date) {
+ if (moment().diff(date, 'seconds') < 45) {
+ return _t("now");
+ }
+ return date.fromNow();
+}
+
+return {
+ addLink: addLink,
+ getTextToHTML: getTextToHTML,
+ htmlToTextContentInline,
+ inline: inline,
+ linkify: linkify,
+ parseAndTransform: parseAndTransform,
+ parseEmail: parseEmail,
+ stripHTML: stripHTML,
+ timeFromNow: timeFromNow,
+ escapeAndCompactTextContent,
+};
+
+});
diff --git a/addons/mail/static/src/js/views/activity/activity_cell.js b/addons/mail/static/src/js/views/activity/activity_cell.js
new file mode 100644
index 00000000..d1cdca94
--- /dev/null
+++ b/addons/mail/static/src/js/views/activity/activity_cell.js
@@ -0,0 +1,42 @@
+odoo.define("mail.ActivityCell", function (require) {
+ "use strict";
+
+ require("mail.Activity");
+ const field_registry = require('web.field_registry');
+
+ const KanbanActivity = field_registry.get('kanban_activity');
+
+ const ActivityCell = KanbanActivity.extend({
+ /**
+ * @override
+ * @private
+ */
+ _render() {
+ // replace clock by closest deadline
+ const $date = $('<div class="o_closest_deadline">');
+ const date = new Date(this.record.data.closest_deadline);
+ // To remove year only if current year
+ if (moment().year() === moment(date).year()) {
+ $date.text(date.toLocaleDateString(moment().locale(), {
+ day: 'numeric', month: 'short'
+ }));
+ } else {
+ $date.text(moment(date).format('ll'));
+ }
+ this.$('a').html($date);
+ if (this.record.data.activity_ids.res_ids.length > 1) {
+ this.$('a').append($('<span>', {
+ class: 'badge badge-light badge-pill border-0 ' + this.record.data.activity_state,
+ text: this.record.data.activity_ids.res_ids.length,
+ }));
+ }
+ if (this.$el.hasClass('show')) {
+ // note: this part of the rendering might be asynchronous
+ this._renderDropdown();
+ }
+ }
+ });
+
+ return ActivityCell;
+
+});
diff --git a/addons/mail/static/src/js/views/activity/activity_controller.js b/addons/mail/static/src/js/views/activity/activity_controller.js
new file mode 100644
index 00000000..106c5ee9
--- /dev/null
+++ b/addons/mail/static/src/js/views/activity/activity_controller.js
@@ -0,0 +1,124 @@
+odoo.define('mail.ActivityController', function (require) {
+"use strict";
+
+require('mail.Activity');
+var BasicController = require('web.BasicController');
+var core = require('web.core');
+var field_registry = require('web.field_registry');
+var ViewDialogs = require('web.view_dialogs');
+
+var KanbanActivity = field_registry.get('kanban_activity');
+var _t = core._t;
+
+var ActivityController = BasicController.extend({
+ custom_events: _.extend({}, BasicController.prototype.custom_events, {
+ empty_cell_clicked: '_onEmptyCell',
+ send_mail_template: '_onSendMailTemplate',
+ schedule_activity: '_onScheduleActivity',
+ }),
+
+ //--------------------------------------------------------------------------
+ // Public
+ //--------------------------------------------------------------------------
+
+ /**
+ * @override
+ * @param parent
+ * @param model
+ * @param renderer
+ * @param {Object} params
+ * @param {String} params.title The title used in schedule activity dialog
+ */
+ init: function (parent, model, renderer, params) {
+ this._super.apply(this, arguments);
+ this.title = params.title;
+ },
+
+ //--------------------------------------------------------------------------
+ // Private
+ //--------------------------------------------------------------------------
+
+ /**
+ * Overridden to remove the pager as it makes no sense in this view.
+ *
+ * @override
+ */
+ _getPagingInfo: function () {
+ return null;
+ },
+
+ //--------------------------------------------------------------------------
+ // Handlers
+ //--------------------------------------------------------------------------
+
+ /**
+ * @private
+ */
+ _onScheduleActivity: function () {
+ var self = this;
+ var state = this.model.get(this.handle);
+ new ViewDialogs.SelectCreateDialog(this, {
+ res_model: state.model,
+ domain: this.model.originalDomain,
+ title: _.str.sprintf(_t("Search: %s"), this.title),
+ no_create: !this.activeActions.create,
+ disable_multiple_selection: true,
+ context: state.context,
+ on_selected: function (record) {
+ var fakeRecord = state.getKanbanActivityData({}, record[0]);
+ var widget = new KanbanActivity(self, 'activity_ids', fakeRecord, {});
+ widget.scheduleActivity();
+ },
+ }).open();
+ },
+ /**
+ * @private
+ * @param {OdooEvent} ev
+ */
+ _onEmptyCell: function (ev) {
+ var state = this.model.get(this.handle);
+ this.do_action({
+ type: 'ir.actions.act_window',
+ res_model: 'mail.activity',
+ view_mode: 'form',
+ view_type: 'form',
+ views: [[false, 'form']],
+ target: 'new',
+ context: {
+ default_res_id: ev.data.resId,
+ default_res_model: state.model,
+ default_activity_type_id: ev.data.activityTypeId,
+ },
+ res_id: false,
+ }, {
+ on_close: this.reload.bind(this),
+ });
+ },
+ /**
+ * @private
+ * @param {CustomEvent} ev
+ */
+ _onSendMailTemplate: function (ev) {
+ var templateID = ev.data.templateID;
+ var activityTypeID = ev.data.activityTypeID;
+ var state = this.model.get(this.handle);
+ var groupedActivities = state.grouped_activities;
+ var resIDS = [];
+ Object.keys(groupedActivities).forEach(function (resID) {
+ var activityByType = groupedActivities[resID];
+ var activity = activityByType[activityTypeID];
+ if (activity) {
+ resIDS.push(parseInt(resID));
+ }
+ });
+ this._rpc({
+ model: this.model.modelName,
+ method: 'activity_send_mail',
+ args: [resIDS, templateID],
+ });
+ },
+});
+
+return ActivityController;
+
+});
diff --git a/addons/mail/static/src/js/views/activity/activity_model.js b/addons/mail/static/src/js/views/activity/activity_model.js
new file mode 100644
index 00000000..cfb9e36a
--- /dev/null
+++ b/addons/mail/static/src/js/views/activity/activity_model.js
@@ -0,0 +1,124 @@
+odoo.define('mail.ActivityModel', function (require) {
+'use strict';
+
+const BasicModel = require('web.BasicModel');
+const session = require('web.session');
+
+const ActivityModel = BasicModel.extend({
+
+ //--------------------------------------------------------------------------
+ // Public
+ //--------------------------------------------------------------------------
+ /**
+ * Add the following (activity specific) keys when performing a `get` on the
+ * main list datapoint:
+ * - activity_types
+ * - activity_res_ids
+ * - grouped_activities
+ *
+ * @override
+ */
+ __get: function () {
+ var result = this._super.apply(this, arguments);
+ if (result && result.model === this.modelName && result.type === 'list') {
+ _.extend(result, this.additionalData, {getKanbanActivityData: this.getKanbanActivityData});
+ }
+ return result;
+ },
+ /**
+ * @param {Object} activityGroup
+ * @param {integer} resId
+ * @returns {Object}
+ */
+ getKanbanActivityData(activityGroup, resId) {
+ return {
+ data: {
+ activity_ids: {
+ model: 'mail.activity',
+ res_ids: activityGroup.ids,
+ },
+ activity_state: activityGroup.state,
+ closest_deadline: activityGroup.o_closest_deadline,
+ },
+ fields: {
+ activity_ids: {},
+ activity_state: {
+ selection: [
+ ['overdue', "Overdue"],
+ ['today', "Today"],
+ ['planned', "Planned"],
+ ],
+ },
+ },
+ fieldsInfo: {},
+ model: this.model,
+ type: 'record',
+ res_id: resId,
+ getContext: function () {
+ return {};
+ },
+ };
+ },
+ /**
+ * @override
+ * @param {Array[]} params.domain
+ */
+ __load: function (params) {
+ this.originalDomain = _.extend([], params.domain);
+ params.domain.push(['activity_ids', '!=', false]);
+ this.domain = params.domain;
+ this.modelName = params.modelName;
+ params.groupedBy = [];
+ var def = this._super.apply(this, arguments);
+ return Promise.all([def, this._fetchData()]).then(function (result) {
+ return result[0];
+ });
+ },
+ /**
+ * @override
+ * @param {Array[]} [params.domain]
+ */
+ __reload: function (handle, params) {
+ if (params && 'domain' in params) {
+ this.originalDomain = _.extend([], params.domain);
+ params.domain.push(['activity_ids', '!=', false]);
+ this.domain = params.domain;
+ }
+ if (params && 'groupBy' in params) {
+ params.groupBy = [];
+ }
+ var def = this._super.apply(this, arguments);
+ return Promise.all([def, this._fetchData()]).then(function (result) {
+ return result[0];
+ });
+ },
+
+ //--------------------------------------------------------------------------
+ // Private
+ //--------------------------------------------------------------------------
+
+ /**
+ * Fetch activity data.
+ *
+ * @private
+ * @returns {Promise}
+ */
+ _fetchData: function () {
+ var self = this;
+ return this._rpc({
+ model: "mail.activity",
+ method: 'get_activity_data',
+ kwargs: {
+ res_model: this.modelName,
+ domain: this.domain,
+ context: session.user_context,
+ }
+ }).then(function (result) {
+ self.additionalData = result;
+ });
+ },
+});
+
+return ActivityModel;
+
+});
diff --git a/addons/mail/static/src/js/views/activity/activity_record.js b/addons/mail/static/src/js/views/activity/activity_record.js
new file mode 100644
index 00000000..98da9dca
--- /dev/null
+++ b/addons/mail/static/src/js/views/activity/activity_record.js
@@ -0,0 +1,62 @@
+odoo.define('mail.ActivityRecord', function (require) {
+"use strict";
+
+var KanbanRecord = require('web.KanbanRecord');
+
+var ActivityRecord = KanbanRecord.extend({
+ /**
+ * @override
+ */
+ init: function (parent, state) {
+ this._super.apply(this,arguments);
+
+ this.fieldsInfo = state.fieldsInfo.activity;
+ },
+
+ //--------------------------------------------------------------------------
+ // Private
+ //--------------------------------------------------------------------------
+
+ /**
+ * @override
+ * @private
+ */
+ _render: function () {
+ this.defs = [];
+ this._replaceElement(this.qweb.render('activity-box', this.qweb_context));
+ this.$el.on('click', this._onGlobalClick.bind(this));
+ this.$el.addClass('o_activity_record');
+ this._processFields();
+ this._setupColor();
+ return Promise.all(this.defs);
+ },
+ /**
+ * @override
+ * @private
+ */
+ _setFieldDisplay: function ($el, fieldName) {
+ this._super.apply(this, arguments);
+
+ // attribute muted
+ if (this.fieldsInfo[fieldName].muted) {
+ $el.addClass('text-muted');
+ }
+ },
+ /**
+ * @override
+ * @private
+ */
+ _setState: function () {
+ this._super.apply(this, arguments);
+
+ // activity has a different qweb context
+ this.qweb_context = {
+ activity_image: this._getImageURL.bind(this),
+ record: this.record,
+ user_context: this.getSession().user_context,
+ widget: this,
+ };
+ },
+});
+return ActivityRecord;
+});
diff --git a/addons/mail/static/src/js/views/activity/activity_renderer.js b/addons/mail/static/src/js/views/activity/activity_renderer.js
new file mode 100644
index 00000000..e62d416e
--- /dev/null
+++ b/addons/mail/static/src/js/views/activity/activity_renderer.js
@@ -0,0 +1,210 @@
+odoo.define('mail.ActivityRenderer', function (require) {
+"use strict";
+
+const AbstractRendererOwl = require('web.AbstractRendererOwl');
+const ActivityCell = require('mail.ActivityCell');
+const ActivityRecord = require('mail.ActivityRecord');
+const { ComponentAdapter } = require('web.OwlCompatibility');
+const core = require('web.core');
+const KanbanColumnProgressBar = require('web.KanbanColumnProgressBar');
+const patchMixin = require('web.patchMixin');
+const QWeb = require('web.QWeb');
+const session = require('web.session');
+const utils = require('web.utils');
+
+const _t = core._t;
+
+const { useState } = owl.hooks;
+
+/**
+ * Owl Component Adapter for ActivityRecord which is KanbanRecord (Odoo Widget)
+ * TODO: Remove this adapter when ActivityRecord is a Component
+ */
+class ActivityRecordAdapter extends ComponentAdapter {
+ renderWidget() {
+ _.invoke(_.pluck(this.widget.subWidgets, '$el'), 'detach');
+ this.widget._render();
+ }
+
+ updateWidget(nextProps) {
+ const state = nextProps.widgetArgs[0];
+ this.widget._setState(state);
+ }
+}
+
+/**
+ * Owl Component Adapter for ActivityCell which is BasicActivity (AbstractField)
+ * TODO: Remove this adapter when ActivityCell is a Component
+ */
+class ActivityCellAdapter extends ComponentAdapter {
+ renderWidget() {
+ this.widget._render();
+ }
+
+ updateWidget(nextProps) {
+ const record = nextProps.widgetArgs[1];
+ this.widget._reset(record);
+ }
+}
+
+/**
+ * Owl Component Adapter for KanbanColumnProgressBar (Odoo Widget)
+ * TODO: Remove this adapter when KanbanColumnProgressBar is a Component
+ */
+class KanbanColumnProgressBarAdapter extends ComponentAdapter {
+ renderWidget() {
+ this.widget._render();
+ }
+
+ updateWidget(nextProps) {
+ const options = nextProps.widgetArgs[0];
+ const columnState = nextProps.widgetArgs[1];
+
+ const columnId = options.columnID;
+ const nextActiveFilter = options.progressBarStates[columnId].activeFilter;
+ this.widget.activeFilter = nextActiveFilter ? this.widget.activeFilter : false;
+ this.widget.columnState = columnState;
+ this.widget.computeCounters();
+ }
+
+ _trigger_up(ev) {
+ // KanbanColumnProgressBar triggers 3 events before being mounted
+ // but we don't need to listen to them in our case.
+ if (this.el) {
+ super._trigger_up(ev);
+ }
+ }
+}
+
+class ActivityRenderer extends AbstractRendererOwl {
+ constructor(parent, props) {
+ super(...arguments);
+ this.qweb = new QWeb(this.env.isDebug(), {_s: session.origin});
+ this.qweb.add_template(utils.json_node_to_xml(props.templates));
+ this.activeFilter = useState({
+ state: null,
+ activityTypeId: null,
+ resIds: []
+ });
+ this.widgetComponents = {
+ ActivityRecord,
+ ActivityCell,
+ KanbanColumnProgressBar,
+ };
+ }
+
+ //--------------------------------------------------------------------------
+ // Public
+ //--------------------------------------------------------------------------
+
+ /**
+ * Gets all activity resIds in the view.
+ *
+ * @returns filtered resIds first then the rest.
+ */
+ get activityResIds() {
+ const copiedActivityResIds = Array.from(this.props.activity_res_ids)
+ return copiedActivityResIds.sort((a, b) => this.activeFilter.resIds.includes(a) ? -1 : 0);
+ }
+
+ /**
+ * Gets all existing activity type ids.
+ */
+ get activityTypeIds() {
+ const activities = Object.values(this.props.grouped_activities);
+ const activityIds = activities.flatMap(Object.keys);
+ const uniqueIds = Array.from(new Set(activityIds));
+ return uniqueIds.map(Number);
+ }
+
+ getProgressBarOptions(typeId) {
+ return {
+ columnID: typeId,
+ progressBarStates: {
+ [typeId]: {
+ activeFilter: this.activeFilter.activityTypeId === typeId,
+ },
+ },
+ };
+ }
+
+ getProgressBarColumnState(typeId) {
+ const counts = { planned: 0, today: 0, overdue: 0 };
+ for (let activities of Object.values(this.props.grouped_activities)) {
+ if (typeId in activities) {
+ counts[activities[typeId].state] += 1;
+ }
+ }
+ return {
+ count: Object.values(counts).reduce((x, y) => x + y),
+ fields: {
+ activity_state: {
+ type: 'selection',
+ selection: [
+ ['planned', _t('Planned')],
+ ['today', _t('Today')],
+ ['overdue', _t('Overdue')],
+ ],
+ },
+ },
+ progressBarValues: {
+ field: 'activity_state',
+ colors: { planned: 'success', today: 'warning', overdue: 'danger' },
+ counts: counts,
+ },
+ };
+ }
+
+ //--------------------------------------------------------------------------
+ // Handlers
+ //--------------------------------------------------------------------------
+ /**
+ * @private
+ * @param {MouseEvent} ev
+ */
+ _onEmptyCellClicked(ev) {
+ this.trigger('empty_cell_clicked', {
+ resId: parseInt(ev.currentTarget.dataset.resId, 10),
+ activityTypeId: parseInt(ev.currentTarget.dataset.activityTypeId, 10),
+ });
+ }
+ /**
+ * @private
+ * @param {MouseEvent} ev
+ */
+ _onSendMailTemplateClicked(ev) {
+ this.trigger('send_mail_template', {
+ activityTypeID: parseInt(ev.currentTarget.dataset.activityTypeId, 10),
+ templateID: parseInt(ev.currentTarget.dataset.templateId, 10),
+ });
+ }
+ /**
+ * @private
+ * @param {CustomEvent} ev
+ */
+ _onSetProgressBarState(ev) {
+ if (ev.detail.values.activeFilter) {
+ this.activeFilter.state = ev.detail.values.activeFilter;
+ this.activeFilter.activityTypeId = ev.detail.columnID;
+ this.activeFilter.resIds = Object.entries(this.props.grouped_activities)
+ .filter(([, resIds]) => ev.detail.columnID in resIds &&
+ resIds[ev.detail.columnID].state === ev.detail.values.activeFilter)
+ .map(([key]) => parseInt(key));
+ } else {
+ this.activeFilter.state = null;
+ this.activeFilter.activityTypeId = null;
+ this.activeFilter.resIds = [];
+ }
+ }
+}
+
+ActivityRenderer.components = {
+ ActivityRecordAdapter,
+ ActivityCellAdapter,
+ KanbanColumnProgressBarAdapter,
+};
+ActivityRenderer.template = 'mail.ActivityRenderer';
+
+return patchMixin(ActivityRenderer);
+
+});
diff --git a/addons/mail/static/src/js/views/activity/activity_view.js b/addons/mail/static/src/js/views/activity/activity_view.js
new file mode 100644
index 00000000..e2e3eded
--- /dev/null
+++ b/addons/mail/static/src/js/views/activity/activity_view.js
@@ -0,0 +1,53 @@
+odoo.define('mail.ActivityView', function (require) {
+"use strict";
+
+const ActivityController = require('mail.ActivityController');
+const ActivityModel = require('mail.ActivityModel');
+const ActivityRenderer = require('mail.ActivityRenderer');
+const BasicView = require('web.BasicView');
+const core = require('web.core');
+const RendererWrapper = require('web.RendererWrapper');
+const view_registry = require('web.view_registry');
+
+const _lt = core._lt;
+
+const ActivityView = BasicView.extend({
+ accesskey: "a",
+ display_name: _lt('Activity'),
+ icon: 'fa-clock-o',
+ config: _.extend({}, BasicView.prototype.config, {
+ Controller: ActivityController,
+ Model: ActivityModel,
+ Renderer: ActivityRenderer,
+ }),
+ viewType: 'activity',
+ searchMenuTypes: ['filter', 'favorite'],
+
+ /**
+ * @override
+ */
+ init: function () {
+ this._super.apply(this, arguments);
+
+ this.loadParams.type = 'list';
+ // limit makes no sense in this view as we display all records having activities
+ this.loadParams.limit = false;
+
+ this.rendererParams.templates = _.findWhere(this.arch.children, { 'tag': 'templates' });
+ this.controllerParams.title = this.arch.attrs.string;
+ },
+ /**
+ *
+ * @override
+ */
+ getRenderer(parent, state) {
+ state = Object.assign({}, state, this.rendererParams);
+ return new RendererWrapper(null, this.config.Renderer, state);
+ },
+});
+
+view_registry.add('activity', ActivityView);
+
+return ActivityView;
+
+});
diff --git a/addons/mail/static/src/model/model_core.js b/addons/mail/static/src/model/model_core.js
new file mode 100644
index 00000000..0198e47e
--- /dev/null
+++ b/addons/mail/static/src/model/model_core.js
@@ -0,0 +1,125 @@
+odoo.define('mail/static/src/model/model_core.js', function (require) {
+'use strict';
+
+/**
+ * Module that contains registry for adding new models or patching models.
+ * Useful for model manager in order to generate model classes.
+ *
+ * This code is not in model manager because other JS modules should populate
+ * a registry, and it's difficult to ensure availability of the model manager
+ * when these JS modules are deployed.
+ */
+
+const registry = {};
+
+//------------------------------------------------------------------------------
+// Private
+//------------------------------------------------------------------------------
+
+/**
+ * @private
+ * @param {string} modelName
+ * @returns {Object}
+ */
+function _getEntryFromModelName(modelName) {
+ if (!registry[modelName]) {
+ registry[modelName] = {
+ dependencies: [],
+ factory: undefined,
+ name: modelName,
+ patches: [],
+ };
+ }
+ return registry[modelName];
+}
+
+/**
+ * @private
+ * @param {string} modelName
+ * @param {string} patchName
+ * @param {Object} patch
+ * @param {Object} [param3={}]
+ * @param {string} [param3.type='instance'] 'instance', 'class' or 'field'
+ */
+function _registerPatchModel(modelName, patchName, patch, { type = 'instance' } = {}) {
+ const entry = _getEntryFromModelName(modelName);
+ Object.assign(entry, {
+ patches: (entry.patches || []).concat([{
+ name: patchName,
+ patch,
+ type,
+ }]),
+ });
+}
+
+//------------------------------------------------------------------------------
+// Public
+//------------------------------------------------------------------------------
+
+/**
+ * Register a patch for static methods in model.
+ *
+ * @param {string} modelName
+ * @param {string} patchName
+ * @param {Object} patch
+ */
+function registerClassPatchModel(modelName, patchName, patch) {
+ _registerPatchModel(modelName, patchName, patch, { type: 'class' });
+}
+
+/**
+ * Register a patch for fields in model.
+ *
+ * @param {string} modelName
+ * @param {string} patchName
+ * @param {Object} patch
+ */
+function registerFieldPatchModel(modelName, patchName, patch) {
+ _registerPatchModel(modelName, patchName, patch, { type: 'field' });
+}
+
+/**
+ * Register a patch for instance methods in model.
+ *
+ * @param {string} modelName
+ * @param {string} patchName
+ * @param {Object} patch
+ */
+function registerInstancePatchModel(modelName, patchName, patch) {
+ _registerPatchModel(modelName, patchName, patch, { type: 'instance' });
+}
+
+/**
+ * @param {string} name
+ * @param {function} factory
+ * @param {string[]} [dependencies=[]]
+ */
+function registerNewModel(name, factory, dependencies = []) {
+ const entry = _getEntryFromModelName(name);
+ let entryDependencies = [...dependencies];
+ if (name !== 'mail.model') {
+ entryDependencies = [...new Set(entryDependencies.concat(['mail.model']))];
+ }
+ if (entry.factory) {
+ throw new Error(`Model "${name}" has already been registered!`);
+ }
+ Object.assign(entry, {
+ dependencies: entryDependencies,
+ factory,
+ name,
+ });
+}
+
+//------------------------------------------------------------------------------
+// Export
+//------------------------------------------------------------------------------
+
+return {
+ registerClassPatchModel,
+ registerFieldPatchModel,
+ registerInstancePatchModel,
+ registerNewModel,
+ registry,
+};
+
+});
diff --git a/addons/mail/static/src/model/model_errors.js b/addons/mail/static/src/model/model_errors.js
new file mode 100644
index 00000000..17ecc960
--- /dev/null
+++ b/addons/mail/static/src/model/model_errors.js
@@ -0,0 +1,22 @@
+odoo.define('mail/static/src/model/model_errors.js', function (require) {
+'use strict';
+
+class RecordDeletedError extends Error {
+
+ /**
+ * @override
+ * @param {string} recordLocalId local id of record that has been deleted
+ * @param {...any} args
+ */
+ constructor(recordLocalId, ...args) {
+ super(...args);
+ this.recordLocalId = recordLocalId;
+ this.name = 'RecordDeletedError';
+ }
+}
+
+return {
+ RecordDeletedError,
+};
+
+});
diff --git a/addons/mail/static/src/model/model_field.js b/addons/mail/static/src/model/model_field.js
new file mode 100644
index 00000000..d0c461ea
--- /dev/null
+++ b/addons/mail/static/src/model/model_field.js
@@ -0,0 +1,820 @@
+odoo.define('mail/static/src/model/model_field.js', function (require) {
+'use strict';
+
+const { clear, FieldCommand } = require('mail/static/src/model/model_field_command.js');
+
+/**
+ * Class whose instances represent field on a model.
+ * These field definitions are generated from declared fields in static prop
+ * `fields` on the model.
+ */
+class ModelField {
+
+ //--------------------------------------------------------------------------
+ // Public
+ //--------------------------------------------------------------------------
+
+ constructor({
+ compute,
+ default: def,
+ dependencies = [],
+ dependents = [],
+ env,
+ fieldName,
+ fieldType,
+ hashes: extraHashes = [],
+ inverse,
+ isCausal = false,
+ related,
+ relationType,
+ to,
+ } = {}) {
+ const id = _.uniqueId('field_');
+ /**
+ * If set, this field acts as a computed field, and this prop
+ * contains the name of the instance method that computes the value
+ * for this field. This compute method is called on creation of record
+ * and whenever some of its dependencies change. @see dependencies
+ */
+ this.compute = compute;
+ /**
+ * Default value for this field. Used on creation of this field, to
+ * set a value by default.
+ */
+ this.default = def;
+ /**
+ * List of field on current record that this field depends on for its
+ * `compute` method. Useful to determine whether this field should be
+ * registered for recomputation when some record fields have changed.
+ * This list must be declared in model definition, or compute method
+ * is only computed once.
+ */
+ this.dependencies = dependencies;
+ /**
+ * List of fields that are dependent of this field. They should never
+ * be declared, and are automatically generated while processing
+ * declared fields. This is populated by compute `dependencies` and
+ * `related`.
+ */
+ this.dependents = dependents;
+ /**
+ * The messaging env.
+ */
+ this.env = env;
+ /**
+ * Name of the field in the definition of fields on model.
+ */
+ this.fieldName = fieldName;
+ /**
+ * Type of this field. 2 types of fields are currently supported:
+ *
+ * 1. 'attribute': fields that store primitive values like integers,
+ * booleans, strings, objects, array, etc.
+ *
+ * 2. 'relation': fields that relate to some other records.
+ */
+ this.fieldType = fieldType;
+ /**
+ * List of hashes registered on this field definition. Technical
+ * prop that is specifically used in processing of dependent
+ * fields, useful to clearly identify which fields of a relation are
+ * dependents and must be registered for computed. Indeed, not all
+ * related records may have a field that depends on changed field,
+ * especially when dependency is defined on sub-model on a relation in
+ * a super-model.
+ *
+ * To illustrate the purpose of this hash, suppose following definition
+ * of models and fields:
+ *
+ * - 3 models (A, B, C) and 3 fields (x, y, z)
+ * - A.fields: { x: one2one(C, inverse: x') }
+ * - B extends A
+ * - B.fields: { z: related(x.y) }
+ * - C.fields: { y: attribute }
+ *
+ * Visually:
+ * x'
+ * <-----------
+ * A -----------> C { y }
+ * ^ x
+ * |
+ * | (extends)
+ * |
+ * B { z = x.y }
+ *
+ * If z has a dependency on x.y, it means y has a dependent on x'.z.
+ * Note that field z exists on B but not on all A. To determine which
+ * kinds of records in relation x' are dependent on y, y is aware of an
+ * hash on this dependent, and any dependents who has this hash in list
+ * of hashes are actual dependents.
+ */
+ this.hashes = extraHashes.concat([id]);
+ /**
+ * Identification for this field definition. Useful to map a dependent
+ * from a dependency. Indeed, declared field definitions use
+ * 'dependencies' but technical process need inverse as 'dependents'.
+ * Dependencies just need name of fields, but dependents cannot just
+ * rely on inverse field names because these dependents are a subset.
+ */
+ this.id = id;
+ /**
+ * This prop only makes sense in a relational field. This contains
+ * the name of the field name in the inverse relation. This may not
+ * be defined in declared field definitions, but processed relational
+ * field definitions always have inverses.
+ */
+ this.inverse = inverse;
+ /**
+ * This prop only makes sense in a relational field. If set, when this
+ * relation is removed, the related record is automatically deleted.
+ */
+ this.isCausal = isCausal;
+ /**
+ * If set, this field acts as a related field, and this prop contains
+ * a string that references the related field. It should have the
+ * following format: '<relationName>.<relatedFieldName>', where
+ * <relationName> is a relational field name on this model or a parent
+ * model (note: could itself be computed or related), and
+ * <relatedFieldName> is the name of field on the records that are
+ * related to current record from this relation. When there are more
+ * than one record in the relation, it maps all related fields per
+ * record in relation.
+ *
+ * FIXME: currently flatten map due to bug, improvement is planned
+ * see Task-id 2261221
+ */
+ this.related = related;
+ /**
+ * This prop only makes sense in a relational field. Determine which
+ * type of relation there is between current record and other records.
+ * 4 types of relation are supported: 'one2one', 'one2many', 'many2one'
+ * and 'many2many'.
+ */
+ this.relationType = relationType;
+ /**
+ * This prop only makes sense in a relational field. Determine which
+ * model name this relation refers to.
+ */
+ this.to = to;
+
+ if (!this.default && this.fieldType === 'relation') {
+ // default value for relational fields is the empty command
+ this.default = [];
+ }
+ }
+
+ /**
+ * Define an attribute field.
+ *
+ * @param {Object} [options]
+ * @returns {Object}
+ */
+ static attr(options) {
+ return Object.assign({ fieldType: 'attribute' }, options);
+ }
+
+ /**
+ * Define a many2many field.
+ *
+ * @param {string} modelName
+ * @param {Object} [options]
+ * @returns {Object}
+ */
+ static many2many(modelName, options) {
+ return ModelField._relation(modelName, Object.assign({}, options, { relationType: 'many2many' }));
+ }
+
+ /**
+ * Define a many2one field.
+ *
+ * @param {string} modelName
+ * @param {Object} [options]
+ * @returns {Object}
+ */
+ static many2one(modelName, options) {
+ return ModelField._relation(modelName, Object.assign({}, options, { relationType: 'many2one' }));
+ }
+
+ /**
+ * Define a one2many field.
+ *
+ * @param {string} modelName
+ * @param {Object} [options]
+ * @returns {Object}
+ */
+ static one2many(modelName, options) {
+ return ModelField._relation(modelName, Object.assign({}, options, { relationType: 'one2many' }));
+ }
+
+ /**
+ * Define a one2one field.
+ *
+ * @param {string} modelName
+ * @param {Object} [options]
+ * @returns {Object}
+ */
+ static one2one(modelName, options) {
+ return ModelField._relation(modelName, Object.assign({}, options, { relationType: 'one2one' }));
+ }
+
+ /**
+ * Clears the value of this field on the given record. It consists of
+ * setting this to its default value. In particular, using `clear` is the
+ * only way to write `undefined` on a field, as long as `undefined` is its
+ * default value. Relational fields are always unlinked before the default
+ * is applied.
+ *
+ * @param {mail.model} record
+ * @param {options} [options]
+ * @returns {boolean} whether the value changed for the current field
+ */
+ clear(record, options) {
+ let hasChanged = false;
+ if (this.fieldType === 'relation') {
+ if (this.parseAndExecuteCommands(record, [['unlink-all']], options)) {
+ hasChanged = true;
+ }
+ }
+ if (this.parseAndExecuteCommands(record, this.default, options)) {
+ hasChanged = true;
+ }
+ return hasChanged;
+ }
+
+ /**
+ * Combine current field definition with provided field definition and
+ * return the combined field definition. Useful to track list of hashes of
+ * a given field, which is necessary for the working of dependent fields
+ * (computed and related fields).
+ *
+ * @param {ModelField} field
+ * @returns {ModelField}
+ */
+ combine(field) {
+ return new ModelField(Object.assign({}, this, {
+ dependencies: this.dependencies.concat(field.dependencies),
+ hashes: this.hashes.concat(field.hashes),
+ }));
+ }
+
+ /**
+ * Compute method when this field is related.
+ *
+ * @private
+ * @param {mail.model} record
+ */
+ computeRelated(record) {
+ const [relationName, relatedFieldName] = this.related.split('.');
+ const Model = record.constructor;
+ const relationField = Model.__fieldMap[relationName];
+ if (['one2many', 'many2many'].includes(relationField.relationType)) {
+ const newVal = [];
+ for (const otherRecord of record[relationName]) {
+ const OtherModel = otherRecord.constructor;
+ const otherField = OtherModel.__fieldMap[relatedFieldName];
+ const otherValue = otherField.get(otherRecord);
+ if (otherValue) {
+ if (otherValue instanceof Array) {
+ // avoid nested array if otherField is x2many too
+ // TODO IMP task-2261221
+ for (const v of otherValue) {
+ newVal.push(v);
+ }
+ } else {
+ newVal.push(otherValue);
+ }
+ }
+ }
+ if (this.fieldType === 'relation') {
+ return [['replace', newVal]];
+ }
+ return newVal;
+ }
+ const otherRecord = record[relationName];
+ if (otherRecord) {
+ const OtherModel = otherRecord.constructor;
+ const otherField = OtherModel.__fieldMap[relatedFieldName];
+ const newVal = otherField.get(otherRecord);
+ if (newVal === undefined) {
+ return clear();
+ }
+ if (this.fieldType === 'relation') {
+ return [['replace', newVal]];
+ }
+ return newVal;
+ }
+ return clear();
+ }
+
+ /**
+ * Get the value associated to this field. Relations must convert record
+ * local ids to records.
+ *
+ * @param {mail.model} record
+ * @returns {any}
+ */
+ get(record) {
+ if (this.fieldType === 'attribute') {
+ return this.read(record);
+ }
+ if (this.fieldType === 'relation') {
+ if (['one2one', 'many2one'].includes(this.relationType)) {
+ return this.read(record);
+ }
+ return [...this.read(record)];
+ }
+ throw new Error(`cannot get field with unsupported type ${this.fieldType}.`);
+ }
+
+ /**
+ * Parses newVal for command(s) and executes them.
+ *
+ * @param {mail.model} record
+ * @param {any} newVal
+ * @param {Object} [options]
+ * @returns {boolean} whether the value changed for the current field
+ */
+ parseAndExecuteCommands(record, newVal, options) {
+ if (newVal instanceof FieldCommand) {
+ // single command given
+ return newVal.execute(this, record, options);
+ }
+ if (newVal instanceof Array && newVal[0] instanceof FieldCommand) {
+ // multi command given
+ let hasChanged = false;
+ for (const command of newVal) {
+ if (command.execute(this, record, options)) {
+ hasChanged = true;
+ }
+ }
+ return hasChanged;
+ }
+ // not a command
+ return this.set(record, newVal, options);
+ }
+
+ /**
+ * Get the raw value associated to this field. For relations, this means
+ * the local id or list of local ids of records in this relational field.
+ *
+ * @param {mail.model} record
+ * @returns {any}
+ */
+ read(record) {
+ return record.__values[this.fieldName];
+ }
+
+ /**
+ * Set a value on this field. The format of the value comes from business
+ * code.
+ *
+ * @param {mail.model} record
+ * @param {any} newVal
+ * @param {Object} [options]
+ * @param {boolean} [options.hasToUpdateInverse] whether updating the
+ * current field should also update its inverse field. Only applies to
+ * relational fields. Typically set to false only during the process of
+ * updating the inverse field itself, to avoid unnecessary recursion.
+ * @returns {boolean} whether the value changed for the current field
+ */
+ set(record, newVal, options) {
+ const currentValue = this.read(record);
+ if (this.fieldType === 'attribute') {
+ if (currentValue === newVal) {
+ return false;
+ }
+ record.__values[this.fieldName] = newVal;
+ return true;
+ }
+ if (this.fieldType === 'relation') {
+ let hasChanged = false;
+ for (const val of newVal) {
+ switch (val[0]) {
+ case 'create':
+ if (this._setRelationCreate(record, val[1], options)) {
+ hasChanged = true;
+ }
+ break;
+ case 'insert':
+ if (this._setRelationInsert(record, val[1], options)) {
+ hasChanged = true;
+ }
+ break;
+ case 'insert-and-replace':
+ if (this._setRelationInsertAndReplace(record, val[1], options)) {
+ hasChanged = true;
+ }
+ break;
+ case 'link':
+ if (this._setRelationLink(record, val[1], options)) {
+ hasChanged = true;
+ }
+ break;
+ case 'replace':
+ if (this._setRelationReplace(record, val[1], options)) {
+ hasChanged = true;
+ }
+ break;
+ case 'unlink':
+ if (this._setRelationUnlink(record, val[1], options)) {
+ hasChanged = true;
+ }
+ break;
+ case 'unlink-all':
+ if (this._setRelationUnlink(record, currentValue, options)) {
+ hasChanged = true;
+ }
+ break;
+ }
+ }
+ return hasChanged;
+ }
+ }
+
+ //--------------------------------------------------------------------------
+ // Private
+ //--------------------------------------------------------------------------
+
+ /**
+ * @private
+ * @param {string} modelName
+ * @param {Object} [options]
+ */
+ static _relation(modelName, options) {
+ return Object.assign({
+ fieldType: 'relation',
+ to: modelName,
+ }, options);
+ }
+
+ /**
+ * Converts given value to expected format for x2many processing, which is
+ * an iterable of records.
+ *
+ * @private
+ * @param {mail.model|mail.model[]} newValue
+ * @param {Object} [param1={}]
+ * @param {boolean} [param1.hasToVerify=true] whether the value has to be
+ * verified @see `_verifyRelationalValue`
+ * @returns {mail.model[]}
+ */
+ _convertX2ManyValue(newValue, { hasToVerify = true } = {}) {
+ if (typeof newValue[Symbol.iterator] === 'function') {
+ if (hasToVerify) {
+ for (const value of newValue) {
+ this._verifyRelationalValue(value);
+ }
+ }
+ return newValue;
+ }
+ if (hasToVerify) {
+ this._verifyRelationalValue(newValue);
+ }
+ return [newValue];
+ }
+
+ /**
+ * Set on this relational field in 'create' mode. Basically data provided
+ * during set on this relational field contain data to create new records,
+ * which themselves must be linked to record of this field by means of
+ * this field.
+ *
+ * @private
+ * @param {mail.model} record
+ * @param {Object|Object[]} data
+ * @param {Object} [options]
+ * @returns {boolean} whether the value changed for the current field
+ */
+ _setRelationCreate(record, data, options) {
+ const OtherModel = this.env.models[this.to];
+ const other = this.env.modelManager._create(OtherModel, data);
+ return this._setRelationLink(record, other, options);
+ }
+
+ /**
+ * Set on this relational field in 'insert' mode. Basically data provided
+ * during set on this relational field contain data to insert records,
+ * which themselves must be linked to record of this field by means of
+ * this field.
+ *
+ * @private
+ * @param {mail.model} record
+ * @param {Object|Object[]} data
+ * @param {Object} [options]
+ * @returns {boolean} whether the value changed for the current field
+ */
+ _setRelationInsert(record, data, options) {
+ const OtherModel = this.env.models[this.to];
+ const other = this.env.modelManager._insert(OtherModel, data);
+ return this._setRelationLink(record, other, options);
+ }
+
+ /**
+ * Set on this relational field in 'insert-and-repalce' mode. Basically
+ * data provided during set on this relational field contain data to insert
+ * records, which themselves must replace value on this field.
+ *
+ * @private
+ * @param {mail.model} record
+ * @param {Object|Object[]} data
+ * @param {Object} [options]
+ * @returns {boolean} whether the value changed for the current field
+ */
+ _setRelationInsertAndReplace(record, data, options) {
+ const OtherModel = this.env.models[this.to];
+ const newValue = this.env.modelManager._insert(OtherModel, data);
+ return this._setRelationReplace(record, newValue, options);
+ }
+
+ /**
+ * Set a 'link' operation on this relational field.
+ *
+ * @private
+ * @param {mail.model|mail.model[]} newValue
+ * @param {Object} [options]
+ * @returns {boolean} whether the value changed for the current field
+ */
+ _setRelationLink(record, newValue, options) {
+ switch (this.relationType) {
+ case 'many2many':
+ case 'one2many':
+ return this._setRelationLinkX2Many(record, newValue, options);
+ case 'many2one':
+ case 'one2one':
+ return this._setRelationLinkX2One(record, newValue, options);
+ }
+ }
+
+ /**
+ * Handling of a `set` 'link' of a x2many relational field.
+ *
+ * @private
+ * @param {mail.model} record
+ * @param {mail.model|mail.model[]} newValue
+ * @param {Object} [param2={}]
+ * @param {boolean} [param2.hasToUpdateInverse=true] whether updating the
+ * current field should also update its inverse field. Typically set to
+ * false only during the process of updating the inverse field itself, to
+ * avoid unnecessary recursion.
+ * @returns {boolean} whether the value changed for the current field
+ */
+ _setRelationLinkX2Many(record, newValue, { hasToUpdateInverse = true } = {}) {
+ const recordsToLink = this._convertX2ManyValue(newValue);
+ const otherRecords = this.read(record);
+
+ let hasChanged = false;
+ for (const recordToLink of recordsToLink) {
+ // other record already linked, avoid linking twice
+ if (otherRecords.has(recordToLink)) {
+ continue;
+ }
+ hasChanged = true;
+ // link other records to current record
+ otherRecords.add(recordToLink);
+ // link current record to other records
+ if (hasToUpdateInverse) {
+ this.env.modelManager._update(
+ recordToLink,
+ { [this.inverse]: [['link', record]] },
+ { hasToUpdateInverse: false }
+ );
+ }
+ }
+ return hasChanged;
+ }
+
+ /**
+ * Handling of a `set` 'link' of an x2one relational field.
+ *
+ * @private
+ * @param {mail.model} record
+ * @param {mail.model} recordToLink
+ * @param {Object} [param2={}]
+ * @param {boolean} [param2.hasToUpdateInverse=true] whether updating the
+ * current field should also update its inverse field. Typically set to
+ * false only during the process of updating the inverse field itself, to
+ * avoid unnecessary recursion.
+ * @returns {boolean} whether the value changed for the current field
+ */
+ _setRelationLinkX2One(record, recordToLink, { hasToUpdateInverse = true } = {}) {
+ this._verifyRelationalValue(recordToLink);
+ const prevOtherRecord = this.read(record);
+ // other record already linked, avoid linking twice
+ if (prevOtherRecord === recordToLink) {
+ return false;
+ }
+ // unlink to properly update previous inverse before linking new value
+ this._setRelationUnlinkX2One(record, { hasToUpdateInverse });
+ // link other record to current record
+ record.__values[this.fieldName] = recordToLink;
+ // link current record to other record
+ if (hasToUpdateInverse) {
+ this.env.modelManager._update(
+ recordToLink,
+ { [this.inverse]: [['link', record]] },
+ { hasToUpdateInverse: false }
+ );
+ }
+ return true;
+ }
+
+ /**
+ * Set a 'replace' operation on this relational field.
+ *
+ * @private
+ * @param {mail.model} record
+ * @param {mail.model|mail.model[]} newValue
+ * @param {Object} [options]
+ * @returns {boolean} whether the value changed for the current field
+ */
+ _setRelationReplace(record, newValue, options) {
+ if (['one2one', 'many2one'].includes(this.relationType)) {
+ // for x2one replace is just link
+ return this._setRelationLinkX2One(record, newValue, options);
+ }
+
+ // for x2many: smart process to avoid unnecessary unlink/link
+ let hasChanged = false;
+ let hasToReorder = false;
+ const otherRecordsSet = this.read(record);
+ const otherRecordsList = [...otherRecordsSet];
+ const recordsToReplaceList = [...this._convertX2ManyValue(newValue)];
+ const recordsToReplaceSet = new Set(recordsToReplaceList);
+
+ // records to link
+ const recordsToLink = [];
+ for (let i = 0; i < recordsToReplaceList.length; i++) {
+ const recordToReplace = recordsToReplaceList[i];
+ if (!otherRecordsSet.has(recordToReplace)) {
+ recordsToLink.push(recordToReplace);
+ }
+ if (otherRecordsList[i] !== recordToReplace) {
+ hasToReorder = true;
+ }
+ }
+ if (this._setRelationLinkX2Many(record, recordsToLink, options)) {
+ hasChanged = true;
+ }
+
+ // records to unlink
+ const recordsToUnlink = [];
+ for (let i = 0; i < otherRecordsList.length; i++) {
+ const otherRecord = otherRecordsList[i];
+ if (!recordsToReplaceSet.has(otherRecord)) {
+ recordsToUnlink.push(otherRecord);
+ }
+ if (recordsToReplaceList[i] !== otherRecord) {
+ hasToReorder = true;
+ }
+ }
+ if (this._setRelationUnlinkX2Many(record, recordsToUnlink, options)) {
+ hasChanged = true;
+ }
+
+ // reorder result
+ if (hasToReorder) {
+ otherRecordsSet.clear();
+ for (const record of recordsToReplaceList) {
+ otherRecordsSet.add(record);
+ }
+ hasChanged = true;
+ }
+ return hasChanged;
+ }
+
+ /**
+ * Set an 'unlink' operation on this relational field.
+ *
+ * @private
+ * @param {mail.model} record
+ * @param {mail.model|mail.model[]} newValue
+ * @param {Object} [options]
+ * @returns {boolean} whether the value changed for the current field
+ */
+ _setRelationUnlink(record, newValue, options) {
+ switch (this.relationType) {
+ case 'many2many':
+ case 'one2many':
+ return this._setRelationUnlinkX2Many(record, newValue, options);
+ case 'many2one':
+ case 'one2one':
+ return this._setRelationUnlinkX2One(record, options);
+ }
+ }
+
+ /**
+ * Handling of a `set` 'unlink' of a x2many relational field.
+ *
+ * @private
+ * @param {mail.model} record
+ * @param {mail.model|mail.model[]} newValue
+ * @param {Object} [param2={}]
+ * @param {boolean} [param2.hasToUpdateInverse=true] whether updating the
+ * current field should also update its inverse field. Typically set to
+ * false only during the process of updating the inverse field itself, to
+ * avoid unnecessary recursion.
+ * @returns {boolean} whether the value changed for the current field
+ */
+ _setRelationUnlinkX2Many(record, newValue, { hasToUpdateInverse = true } = {}) {
+ const recordsToUnlink = this._convertX2ManyValue(
+ newValue,
+ { hasToVerify: false }
+ );
+ const otherRecords = this.read(record);
+
+ let hasChanged = false;
+ for (const recordToUnlink of recordsToUnlink) {
+ // unlink other record from current record
+ const wasLinked = otherRecords.delete(recordToUnlink);
+ if (!wasLinked) {
+ continue;
+ }
+ hasChanged = true;
+ // unlink current record from other records
+ if (hasToUpdateInverse) {
+ if (!recordToUnlink.exists()) {
+ // This case should never happen ideally, but the current
+ // way of handling related relational fields make it so that
+ // deleted records are not always reflected immediately in
+ // these related fields.
+ continue;
+ }
+ // apply causality
+ if (this.isCausal) {
+ this.env.modelManager._delete(recordToUnlink);
+ } else {
+ this.env.modelManager._update(
+ recordToUnlink,
+ { [this.inverse]: [['unlink', record]] },
+ { hasToUpdateInverse: false }
+ );
+ }
+ }
+ }
+ return hasChanged;
+ }
+
+ /**
+ * Handling of a `set` 'unlink' of a x2one relational field.
+ *
+ * @private
+ * @param {mail.model} record
+ * @param {Object} [param1={}]
+ * @param {boolean} [param1.hasToUpdateInverse=true] whether updating the
+ * current field should also update its inverse field. Typically set to
+ * false only during the process of updating the inverse field itself, to
+ * avoid unnecessary recursion.
+ * @returns {boolean} whether the value changed for the current field
+ */
+ _setRelationUnlinkX2One(record, { hasToUpdateInverse = true } = {}) {
+ const otherRecord = this.read(record);
+ // other record already unlinked, avoid useless processing
+ if (!otherRecord) {
+ return false;
+ }
+ // unlink other record from current record
+ record.__values[this.fieldName] = undefined;
+ // unlink current record from other record
+ if (hasToUpdateInverse) {
+ if (!otherRecord.exists()) {
+ // This case should never happen ideally, but the current
+ // way of handling related relational fields make it so that
+ // deleted records are not always reflected immediately in
+ // these related fields.
+ return;
+ }
+ // apply causality
+ if (this.isCausal) {
+ this.env.modelManager._delete(otherRecord);
+ } else {
+ this.env.modelManager._update(
+ otherRecord,
+ { [this.inverse]: [['unlink', record]] },
+ { hasToUpdateInverse: false }
+ );
+ }
+ }
+ return true;
+ }
+
+ /**
+ * Verifies the given relational value makes sense for the current field.
+ * In particular the given value must be a record, it must be non-deleted,
+ * and it must originates from relational `to` model (or its subclasses).
+ *
+ * @private
+ * @param {mail.model} record
+ * @throws {Error} if record does not satisfy related model
+ */
+ _verifyRelationalValue(record) {
+ const OtherModel = this.env.models[this.to];
+ if (!OtherModel.get(record.localId, { isCheckingInheritance: true })) {
+ throw Error(`Record ${record.localId} is not valid for relational field ${this.fieldName}.`);
+ }
+ }
+
+}
+
+return ModelField;
+
+});
diff --git a/addons/mail/static/src/model/model_field_command.js b/addons/mail/static/src/model/model_field_command.js
new file mode 100644
index 00000000..f4e59a95
--- /dev/null
+++ b/addons/mail/static/src/model/model_field_command.js
@@ -0,0 +1,73 @@
+odoo.define('mail/static/src/model/model_field_command.js', function (require) {
+'use strict';
+
+/**
+ * Allows field update to detect if the value it received is a command to
+ * execute (in which was it will be an instance of this class) or an actual
+ * value to set (in all other cases).
+ */
+class FieldCommand {
+ /**
+ * @constructor
+ * @param {function} func function to call when executing this command.
+ * The function should ALWAYS return a boolean value
+ * to indicate whether the value changed.
+ */
+ constructor(func) {
+ this.func = func;
+ }
+
+ /**
+ * @param {ModelField} field
+ * @param {mail.model} record
+ * @param {options} [options]
+ * @returns {boolean} whether the value changed for the current field
+ */
+ execute(field, record, options) {
+ return this.func(field, record, options);
+ }
+}
+
+/**
+ * Returns a clear command to give to the model manager at create/update.
+ */
+function clear() {
+ return new FieldCommand((field, record, options) =>
+ field.clear(record, options)
+ );
+}
+
+/**
+ * Returns a decrement command to give to the model manager at create/update.
+ *
+ * @param {number} [amount=1]
+ */
+function decrement(amount = 1) {
+ return new FieldCommand((field, record, options) => {
+ const oldValue = field.get(record);
+ return field.set(record, oldValue - amount, options);
+ });
+}
+
+/**
+ * Returns a increment command to give to the model manager at create/update.
+ *
+ * @param {number} [amount=1]
+ */
+function increment(amount = 1) {
+ return new FieldCommand((field, record, options) => {
+ const oldValue = field.get(record);
+ return field.set(record, oldValue + amount, options);
+ });
+}
+
+return {
+ // class
+ FieldCommand,
+ // shortcuts
+ clear,
+ decrement,
+ increment,
+};
+
+});
diff --git a/addons/mail/static/src/model/model_manager.js b/addons/mail/static/src/model/model_manager.js
new file mode 100644
index 00000000..6ffcd8d1
--- /dev/null
+++ b/addons/mail/static/src/model/model_manager.js
@@ -0,0 +1,1098 @@
+odoo.define('mail/static/src/model/model_manager.js', function (require) {
+'use strict';
+
+const { registry } = require('mail/static/src/model/model_core.js');
+const ModelField = require('mail/static/src/model/model_field.js');
+const { patchClassMethods, patchInstanceMethods } = require('mail/static/src/utils/utils.js');
+
+/**
+ * Inner separator used between bits of information in string that is used to
+ * identify a dependent of a field. Useful to determine which record and field
+ * to register for compute during this update cycle.
+ */
+const DEPENDENT_INNER_SEPARATOR = "--//--//--";
+
+/**
+ * Object that manage models and records, notably their update cycle: whenever
+ * some records are requested for update (either with model static method
+ * `create()` or record method `update()`), this object processes them with
+ * direct field & and computed field updates.
+ */
+class ModelManager {
+
+ //--------------------------------------------------------------------------
+ // Public
+ //--------------------------------------------------------------------------
+
+ constructor(env) {
+ /**
+ * Inner separator used inside string to represent dependents.
+ * Set as public attribute so that it can be used by model field.
+ */
+ this.DEPENDENT_INNER_SEPARATOR = DEPENDENT_INNER_SEPARATOR;
+ /**
+ * The messaging env.
+ */
+ this.env = env;
+
+ //----------------------------------------------------------------------
+ // Various variables that are necessary to handle an update cycle. The
+ // goal of having an update cycle is to delay the execution of computes,
+ // life-cycle hooks and potential UI re-renders until the last possible
+ // moment, for performance reasons.
+ //----------------------------------------------------------------------
+
+ /**
+ * Set of records that have been created during the current update
+ * cycle. Useful to trigger `_created()` hook methods.
+ */
+ this._createdRecords = new Set();
+ /**
+ * Tracks whether something has changed during the current update cycle.
+ * Useful to notify components (through the store) that some records
+ * have been changed.
+ */
+ this._hasAnyChangeDuringCycle = false;
+ /**
+ * Set of records that have been updated during the current update
+ * cycle. Useful to allow observers (typically components) to detect
+ * whether specific records have been changed.
+ */
+ this._updatedRecords = new Set();
+ /**
+ * Fields flagged to call compute during an update cycle.
+ * For instance, when a field with dependents got update, dependent
+ * fields should update themselves by invoking compute at end of
+ * update cycle. Key is of format
+ * <record-local-id><DEPENDENT_INNER_SEPARATOR><fieldName>, and
+ * determine record and field to be computed. Keys are strings because
+ * it must contain only one occurrence of pair record/field, and we want
+ * O(1) reads/writes.
+ */
+ this._toComputeFields = new Map();
+ /**
+ * Map of "update after" on records that have been registered.
+ * These are processed after any explicit update and computed/related
+ * fields.
+ */
+ this._toUpdateAfters = new Map();
+ }
+
+ /**
+ * Called when all JS modules that register or patch models have been
+ * done. This launches generation of models.
+ */
+ start() {
+ /**
+ * Generate the models.
+ */
+ Object.assign(this.env.models, this._generateModels());
+ }
+
+ //--------------------------------------------------------------------------
+ // Public
+ //--------------------------------------------------------------------------
+
+ /**
+ * Returns all records of provided model that match provided criteria.
+ *
+ * @param {mail.model} Model class
+ * @param {function} [filterFunc]
+ * @returns {mail.model[]} records matching criteria.
+ */
+ all(Model, filterFunc) {
+ const allRecords = Object.values(Model.__records);
+ if (filterFunc) {
+ return allRecords.filter(filterFunc);
+ }
+ return allRecords;
+ }
+
+ /**
+ * Register a record that has been created, and manage update of records
+ * from this record creation.
+ *
+ * @param {mail.model} Model class
+ * @param {Object|Object[]} [data={}]
+ * If data is an iterable, multiple records will be created.
+ * @returns {mail.model|mail.model[]} newly created record(s)
+ */
+ create(Model, data = {}) {
+ const res = this._create(Model, data);
+ this._flushUpdateCycle();
+ return res;
+ }
+
+ /**
+ * Delete the record. After this operation, it's as if this record never
+ * existed. Note that relation are removed, which may delete more relations
+ * if some of them are causal.
+ *
+ * @param {mail.model} record
+ */
+ delete(record) {
+ this._delete(record);
+ this._flushUpdateCycle();
+ }
+
+ /**
+ * Delete all records.
+ */
+ deleteAll() {
+ for (const Model of Object.values(this.env.models)) {
+ for (const record of Object.values(Model.__records)) {
+ this._delete(record);
+ }
+ }
+ this._flushUpdateCycle();
+ }
+
+ /**
+ * Returns whether the given record still exists.
+ *
+ * @param {mail.model} Model class
+ * @param {mail.model} record
+ * @returns {boolean}
+ */
+ exists(Model, record) {
+ return Model.__records[record.localId] ? true : false;
+ }
+
+ /**
+ * Get the record of provided model that has provided
+ * criteria, if it exists.
+ *
+ * @param {mail.model} Model class
+ * @param {function} findFunc
+ * @returns {mail.model|undefined} the record of model matching criteria, if
+ * exists.
+ */
+ find(Model, findFunc) {
+ return this.all(Model).find(findFunc);
+ }
+
+ /**
+ * Gets the unique record of provided model that matches the given
+ * identifying data, if it exists.
+ * @see `_createRecordLocalId` for criteria of identification.
+ *
+ * @param {mail.model} Model class
+ * @param {Object} data
+ * @returns {mail.model|undefined}
+ */
+ findFromIdentifyingData(Model, data) {
+ const localId = Model._createRecordLocalId(data);
+ return Model.get(localId);
+ }
+
+ /**
+ * This method returns the record of provided model that matches provided
+ * local id. Useful to convert a local id to a record.
+ * Note that even if there's a record in the system having provided local
+ * id, if the resulting record is not an instance of this model, this getter
+ * assumes the record does not exist.
+ *
+ * @param {mail.model} Model class
+ * @param {string} localId
+ * @param {Object} param2
+ * @param {boolean} [param2.isCheckingInheritance=false]
+ * @returns {mail.model|undefined} record, if exists
+ */
+ get(Model, localId, { isCheckingInheritance = false } = {}) {
+ if (!localId) {
+ return;
+ }
+ const record = Model.__records[localId];
+ if (record) {
+ return record;
+ }
+ if (!isCheckingInheritance) {
+ return;
+ }
+ // support for inherited models (eg. relation targeting `mail.model`)
+ for (const SubModel of Object.values(this.env.models)) {
+ if (!(SubModel.prototype instanceof Model)) {
+ continue;
+ }
+ const record = SubModel.__records[localId];
+ if (record) {
+ return record;
+ }
+ }
+ return;
+ }
+
+ /**
+ * This method creates a record or updates one of provided Model, based on
+ * provided data. This method assumes that records are uniquely identifiable
+ * per "unique find" criteria from data on Model.
+ *
+ * @param {mail.model} Model class
+ * @param {Object|Object[]} data
+ * If data is an iterable, multiple records will be created/updated.
+ * @returns {mail.model|mail.model[]} created or updated record(s).
+ */
+ insert(Model, data) {
+ const res = this._insert(Model, data);
+ this._flushUpdateCycle();
+ return res;
+ }
+
+ /**
+ * Process an update on provided record with provided data. Updating
+ * a record consists of applying direct updates first (i.e. explicit
+ * ones from `data`) and then indirect ones (i.e. compute/related fields
+ * and "after updates").
+ *
+ * @param {mail.model} record
+ * @param {Object} data
+ * @returns {boolean} whether any value changed for the current record
+ */
+ update(record, data) {
+ const res = this._update(record, data);
+ this._flushUpdateCycle();
+ return res;
+ }
+
+ //--------------------------------------------------------------------------
+ // Private
+ //--------------------------------------------------------------------------
+
+ /**
+ * @private
+ * @param {mail.model} Model class
+ * @param {Object} patch
+ */
+ _applyModelPatchFields(Model, patch) {
+ for (const [fieldName, field] of Object.entries(patch)) {
+ if (!Model.fields[fieldName]) {
+ Model.fields[fieldName] = field;
+ } else {
+ Object.assign(Model.fields[fieldName].dependencies, field.dependencies);
+ }
+ }
+ }
+
+ /**
+ * @private
+ * @param {Object} Models
+ * @throws {Error} in case some declared fields are not correct.
+ */
+ _checkDeclaredFieldsOnModels(Models) {
+ for (const Model of Object.values(Models)) {
+ for (const fieldName in Model.fields) {
+ const field = Model.fields[fieldName];
+ // 0. Get parented declared fields
+ const parentedMatchingFields = [];
+ let TargetModel = Model.__proto__;
+ while (Models[TargetModel.modelName]) {
+ if (TargetModel.fields) {
+ const matchingField = TargetModel.fields[fieldName];
+ if (matchingField) {
+ parentedMatchingFields.push(matchingField);
+ }
+ }
+ TargetModel = TargetModel.__proto__;
+ }
+ // 1. Field type is required.
+ if (!(['attribute', 'relation'].includes(field.fieldType))) {
+ throw new Error(`Field "${Model.modelName}/${fieldName}" has unsupported type ${field.fieldType}.`);
+ }
+ // 2. Invalid keys based on field type.
+ if (field.fieldType === 'attribute') {
+ const invalidKeys = Object.keys(field).filter(key =>
+ ![
+ 'compute',
+ 'default',
+ 'dependencies',
+ 'fieldType',
+ 'related',
+ ].includes(key)
+ );
+ if (invalidKeys.length > 0) {
+ throw new Error(`Field "${Model.modelName}/${fieldName}" contains some invalid keys: "${invalidKeys.join(", ")}".`);
+ }
+ }
+ if (field.fieldType === 'relation') {
+ const invalidKeys = Object.keys(field).filter(key =>
+ ![
+ 'compute',
+ 'default',
+ 'dependencies',
+ 'fieldType',
+ 'inverse',
+ 'isCausal',
+ 'related',
+ 'relationType',
+ 'to',
+ ].includes(key)
+ );
+ if (invalidKeys.length > 0) {
+ throw new Error(`Field "${Model.modelName}/${fieldName}" contains some invalid keys: "${invalidKeys.join(", ")}".`);
+ }
+ if (!Models[field.to]) {
+ throw new Error(`Relational field "${Model.modelName}/${fieldName}" targets to unknown model name "${field.to}".`);
+ }
+ if (field.isCausal && !(['one2many', 'one2one'].includes(field.relationType))) {
+ throw new Error(`Relational field "${Model.modelName}/${fieldName}" has "isCausal" true with a relation of type "${field.relationType}" but "isCausal" is only supported for "one2many" and "one2one".`);
+ }
+ }
+ // 3. Computed field.
+ if (field.compute && !(typeof field.compute === 'string')) {
+ throw new Error(`Field "${Model.modelName}/${fieldName}" property "compute" must be a string (instance method name).`);
+ }
+ if (field.compute && !(Model.prototype[field.compute])) {
+ throw new Error(`Field "${Model.modelName}/${fieldName}" property "compute" does not refer to an instance method of this Model.`);
+ }
+ if (
+ field.dependencies &&
+ (!field.compute && !parentedMatchingFields.some(field => field.compute))
+ ) {
+ throw new Error(`Field "${Model.modelName}/${fieldName} contains dependendencies but no compute method in itself or parented matching fields (dependencies only make sense for compute fields)."`);
+ }
+ if (
+ (field.compute || parentedMatchingFields.some(field => field.compute)) &&
+ (field.dependencies || parentedMatchingFields.some(field => field.dependencies))
+ ) {
+ if (!(field.dependencies instanceof Array)) {
+ throw new Error(`Compute field "${Model.modelName}/${fieldName}" dependencies must be an array of field names.`);
+ }
+ const unknownDependencies = field.dependencies.every(dependency => !(Model.fields[dependency]));
+ if (unknownDependencies.length > 0) {
+ throw new Error(`Compute field "${Model.modelName}/${fieldName}" contains some unknown dependencies: "${unknownDependencies.join(", ")}".`);
+ }
+ }
+ // 4. Related field.
+ if (field.compute && field.related) {
+ throw new Error(`Field "${Model.modelName}/${fieldName}" cannot be a related and compute field at the same time.`);
+ }
+ if (field.related) {
+ if (!(typeof field.related === 'string')) {
+ throw new Error(`Field "${Model.modelName}/${fieldName}" property "related" has invalid format.`);
+ }
+ const [relationName, relatedFieldName, other] = field.related.split('.');
+ if (!relationName || !relatedFieldName || other) {
+ throw new Error(`Field "${Model.modelName}/${fieldName}" property "related" has invalid format.`);
+ }
+ // find relation on self or parents.
+ let relatedRelation;
+ let TargetModel = Model;
+ while (Models[TargetModel.modelName] && !relatedRelation) {
+ if (TargetModel.fields) {
+ relatedRelation = TargetModel.fields[relationName];
+ }
+ TargetModel = TargetModel.__proto__;
+ }
+ if (!relatedRelation) {
+ throw new Error(`Related field "${Model.modelName}/${fieldName}" relates to unknown relation name "${relationName}".`);
+ }
+ if (relatedRelation.fieldType !== 'relation') {
+ throw new Error(`Related field "${Model.modelName}/${fieldName}" relates to non-relational field "${relationName}".`);
+ }
+ // Assuming related relation is valid...
+ // find field name on related model or any parents.
+ const RelatedModel = Models[relatedRelation.to];
+ let relatedField;
+ TargetModel = RelatedModel;
+ while (Models[TargetModel.modelName] && !relatedField) {
+ if (TargetModel.fields) {
+ relatedField = TargetModel.fields[relatedFieldName];
+ }
+ TargetModel = TargetModel.__proto__;
+ }
+ if (!relatedField) {
+ throw new Error(`Related field "${Model.modelName}/${fieldName}" relates to unknown related model field "${relatedFieldName}".`);
+ }
+ if (relatedField.fieldType !== field.fieldType) {
+ throw new Error(`Related field "${Model.modelName}/${fieldName}" has mismatch type with its related model field.`);
+ }
+ if (
+ relatedField.fieldType === 'relation' &&
+ relatedField.to !== field.to
+ ) {
+ throw new Error(`Related field "${Model.modelName}/${fieldName}" has mismatch target model name with its related model field.`);
+ }
+ }
+ }
+ }
+ }
+
+ /**
+ * @private
+ * @param {Object} Models
+ * @throws {Error} in case some fields are not correct.
+ */
+ _checkProcessedFieldsOnModels(Models) {
+ for (const Model of Object.values(Models)) {
+ for (const fieldName in Model.fields) {
+ const field = Model.fields[fieldName];
+ if (!(['attribute', 'relation'].includes(field.fieldType))) {
+ throw new Error(`Field "${Model.modelName}/${fieldName}" has unsupported type ${field.fieldType}.`);
+ }
+ if (field.compute && field.related) {
+ throw new Error(`Field "${Model.modelName}/${fieldName}" cannot be a related and compute field at the same time.`);
+ }
+ if (field.fieldType === 'attribute') {
+ continue;
+ }
+ if (!field.relationType) {
+ throw new Error(
+ `Field "${Model.modelName}/${fieldName}" must define a relation type in "relationType".`
+ );
+ }
+ if (!(['one2one', 'one2many', 'many2one', 'many2many'].includes(field.relationType))) {
+ throw new Error(
+ `Field "${Model.modelName}/${fieldName}" has invalid relation type "${field.relationType}".`
+ );
+ }
+ if (!field.inverse) {
+ throw new Error(
+ `Field "${
+ Model.modelName
+ }/${
+ fieldName
+ }" must define an inverse relation name in "inverse".`
+ );
+ }
+ if (!field.to) {
+ throw new Error(
+ `Relation "${
+ Model.modelNames
+ }/${
+ fieldName
+ }" must define a model name in "to" (1st positional parameter of relation field helpers).`
+ );
+ }
+ const RelatedModel = Models[field.to];
+ if (!RelatedModel) {
+ throw new Error(
+ `Model name of relation "${Model.modelName}/${fieldName}" does not exist.`
+ );
+ }
+ const inverseField = RelatedModel.fields[field.inverse];
+ if (!inverseField) {
+ throw new Error(
+ `Relation "${
+ Model.modelName
+ }/${
+ fieldName
+ }" has no inverse field "${RelatedModel.modelName}/${field.inverse}".`
+ );
+ }
+ if (inverseField.inverse !== fieldName) {
+ throw new Error(
+ `Inverse field name of relation "${
+ Model.modelName
+ }/${
+ fieldName
+ }" does not match with field name of relation "${
+ RelatedModel.modelName
+ }/${
+ inverseField.inverse
+ }".`
+ );
+ }
+ const allSelfAndParentNames = [];
+ let TargetModel = Model;
+ while (TargetModel) {
+ allSelfAndParentNames.push(TargetModel.modelName);
+ TargetModel = TargetModel.__proto__;
+ }
+ if (!allSelfAndParentNames.includes(inverseField.to)) {
+ throw new Error(
+ `Relation "${
+ Model.modelName
+ }/${
+ fieldName
+ }" has inverse relation "${
+ RelatedModel.modelName
+ }/${
+ field.inverse
+ }" misconfigured (currently "${
+ inverseField.to
+ }", should instead refer to this model or parented models: ${
+ allSelfAndParentNames.map(name => `"${name}"`).join(', ')
+ }?)`
+ );
+ }
+ if (
+ (field.relationType === 'many2many' && inverseField.relationType !== 'many2many') ||
+ (field.relationType === 'one2one' && inverseField.relationType !== 'one2one') ||
+ (field.relationType === 'one2many' && inverseField.relationType !== 'many2one') ||
+ (field.relationType === 'many2one' && inverseField.relationType !== 'one2many')
+ ) {
+ throw new Error(
+ `Mismatch relations types "${
+ Model.modelName
+ }/${
+ fieldName
+ }" (${
+ field.relationType
+ }) and "${
+ RelatedModel.modelName
+ }/${
+ field.inverse
+ }" (${
+ inverseField.relationType
+ }).`
+ );
+ }
+ }
+ }
+ }
+
+ /**
+ * @private
+ * @param {mail.model} Model class
+ * @param {Object|Object[]} [data={}]
+ * @returns {mail.model|mail.model[]}
+ */
+ _create(Model, data = {}) {
+ const isMulti = typeof data[Symbol.iterator] === 'function';
+ const dataList = isMulti ? data : [data];
+ const records = [];
+ for (const data of dataList) {
+ /**
+ * 1. Ensure the record can be created: localId must be unique.
+ */
+ const localId = Model._createRecordLocalId(data);
+ if (Model.get(localId)) {
+ throw Error(`A record already exists for model "${Model.modelName}" with localId "${localId}".`);
+ }
+ /**
+ * 2. Prepare record state. Assign various keys and values that are
+ * expected to be found on every record.
+ */
+ const record = new Model({ valid: true });
+ Object.assign(record, {
+ // The messaging env.
+ env: this.env,
+ // The unique record identifier.
+ localId,
+ // Field values of record.
+ __values: {},
+ // revNumber of record for detecting changes in useStore.
+ __state: 0,
+ });
+ // Ensure X2many relations are Set initially (other fields can stay undefined).
+ for (const field of Model.__fieldList) {
+ if (field.fieldType === 'relation') {
+ if (['one2many', 'many2many'].includes(field.relationType)) {
+ record.__values[field.fieldName] = new Set();
+ }
+ }
+ }
+ /**
+ * 3. Register record and invoke the life-cycle hook `_willCreate.`
+ * After this step the record is in a functioning state and it is
+ * considered existing.
+ */
+ Model.__records[record.localId] = record;
+ record._willCreate();
+ /**
+ * 4. Write provided data, default data, and register computes.
+ */
+ const data2 = {};
+ for (const field of Model.__fieldList) {
+ // `undefined` should have the same effect as not passing the field
+ if (data[field.fieldName] !== undefined) {
+ data2[field.fieldName] = data[field.fieldName];
+ } else {
+ data2[field.fieldName] = field.default;
+ }
+ if (field.compute || field.related) {
+ // new record should always invoke computed fields.
+ this._registerToComputeField(record, field);
+ }
+ }
+ this._update(record, data2);
+ /**
+ * 5. Register post processing operation that are to be delayed at
+ * the end of the update cycle.
+ */
+ this._createdRecords.add(record);
+ this._hasAnyChangeDuringCycle = true;
+
+ records.push(record);
+ }
+ return isMulti ? records : records[0];
+ }
+
+ /**
+ * @private
+ * @param {mail.model} record
+ */
+ _delete(record) {
+ const Model = record.constructor;
+ if (!record.exists()) {
+ throw Error(`Cannot delete already deleted record ${record.localId}.`);
+ }
+ record._willDelete();
+ for (const field of Model.__fieldList) {
+ if (field.fieldType === 'relation') {
+ // ensure inverses are properly unlinked
+ field.parseAndExecuteCommands(record, [['unlink-all']]);
+ }
+ }
+ this._hasAnyChangeDuringCycle = true;
+ // TODO ideally deleting the record should be done at the top of the
+ // method, and it shouldn't be needed to manually remove
+ // _toComputeFields and _toUpdateAfters, but it is not possible until
+ // related are also properly unlinked during `set`
+ this._createdRecords.delete(record);
+ this._toComputeFields.delete(record);
+ this._toUpdateAfters.delete(record);
+ delete Model.__records[record.localId];
+ }
+
+ /**
+ * Terminates an update cycle by executing its pending operations: execute
+ * computed fields, execute life-cycle hooks, update rev numbers.
+ *
+ * @private
+ */
+ _flushUpdateCycle(func) {
+ // Execution of computes
+ while (this._toComputeFields.size > 0) {
+ for (const [record, fields] of this._toComputeFields) {
+ // delete at every step to avoid recursion, indeed doCompute
+ // might trigger an update cycle itself
+ this._toComputeFields.delete(record);
+ if (!record.exists()) {
+ throw Error(`Cannot execute computes for already deleted record ${record.localId}.`);
+ }
+ while (fields.size > 0) {
+ for (const field of fields) {
+ // delete at every step to avoid recursion
+ fields.delete(field);
+ if (field.compute) {
+ this._update(record, { [field.fieldName]: record[field.compute]() });
+ continue;
+ }
+ if (field.related) {
+ this._update(record, { [field.fieldName]: field.computeRelated(record) });
+ continue;
+ }
+ throw new Error("No compute method defined on this field definition");
+ }
+ }
+ }
+ }
+
+ // Execution of _updateAfter
+ while (this._toUpdateAfters.size > 0) {
+ for (const [record, previous] of this._toUpdateAfters) {
+ // delete at every step to avoid recursion, indeed _updateAfter
+ // might trigger an update cycle itself
+ this._toUpdateAfters.delete(record);
+ if (!record.exists()) {
+ throw Error(`Cannot _updateAfter for already deleted record ${record.localId}.`);
+ }
+ record._updateAfter(previous);
+ }
+ }
+
+ // Execution of _created
+ while (this._createdRecords.size > 0) {
+ for (const record of this._createdRecords) {
+ // delete at every step to avoid recursion, indeed _created
+ // might trigger an update cycle itself
+ this._createdRecords.delete(record);
+ if (!record.exists()) {
+ throw Error(`Cannot call _created for already deleted record ${record.localId}.`);
+ }
+ record._created();
+ }
+ }
+
+ // Increment record rev number (for useStore comparison)
+ for (const record of this._updatedRecords) {
+ record.__state++;
+ }
+ this._updatedRecords.clear();
+
+ // Trigger at most one useStore call per update cycle
+ if (this._hasAnyChangeDuringCycle) {
+ this.env.store.state.messagingRevNumber++;
+ this._hasAnyChangeDuringCycle = false;
+ }
+ }
+
+ /**
+ * @private
+ * @returns {Object}
+ * @throws {Error} in case it cannot generate models.
+ */
+ _generateModels() {
+ const allNames = Object.keys(registry);
+ const Models = {};
+ const generatedNames = [];
+ let toGenerateNames = [...allNames];
+ while (toGenerateNames.length > 0) {
+ const generatable = toGenerateNames.map(name => registry[name]).find(entry => {
+ let isGenerateable = true;
+ for (const dependencyName of entry.dependencies) {
+ if (!generatedNames.includes(dependencyName)) {
+ isGenerateable = false;
+ }
+ }
+ return isGenerateable;
+ });
+ if (!generatable) {
+ throw new Error(`Cannot generate following Model: ${toGenerateNames.join(', ')}`);
+ }
+ // Make environment accessible from Model.
+ const Model = generatable.factory(Models);
+ Model.env = this.env;
+ /**
+ * Contains all records. key is local id, while value is the record.
+ */
+ Model.__records = {};
+ for (const patch of generatable.patches) {
+ switch (patch.type) {
+ case 'class':
+ patchClassMethods(Model, patch.name, patch.patch);
+ break;
+ case 'instance':
+ patchInstanceMethods(Model, patch.name, patch.patch);
+ break;
+ case 'field':
+ this._applyModelPatchFields(Model, patch.patch);
+ break;
+ }
+ }
+ if (!Object.prototype.hasOwnProperty.call(Model, 'modelName')) {
+ throw new Error(`Missing static property "modelName" on Model class "${Model.name}".`);
+ }
+ if (generatedNames.includes(Model.modelName)) {
+ throw new Error(`Duplicate model name "${Model.modelName}" shared on 2 distinct Model classes.`);
+ }
+ Models[Model.modelName] = Model;
+ generatedNames.push(Model.modelName);
+ toGenerateNames = toGenerateNames.filter(name => name !== Model.modelName);
+ }
+ /**
+ * Check that declared model fields are correct.
+ */
+ this._checkDeclaredFieldsOnModels(Models);
+ /**
+ * Process declared model fields definitions, so that these field
+ * definitions are much easier to use in the system. For instance, all
+ * relational field definitions have an inverse, or fields track all their
+ * dependents.
+ */
+ this._processDeclaredFieldsOnModels(Models);
+ /**
+ * Check that all model fields are correct, notably one relation
+ * should have matching reversed relation.
+ */
+ this._checkProcessedFieldsOnModels(Models);
+ return Models;
+ }
+
+ /**
+ * @private
+ * @param {mail.model}
+ * @param {Object|Object[]} data
+ * @returns {mail.model|mail.model[]}
+ */
+ _insert(Model, data) {
+ const isMulti = typeof data[Symbol.iterator] === 'function';
+ const dataList = isMulti ? data : [data];
+ const records = [];
+ for (const data of dataList) {
+ let record = Model.findFromIdentifyingData(data);
+ if (!record) {
+ record = this._create(Model, data);
+ } else {
+ this._update(record, data);
+ }
+ records.push(record);
+ }
+ return isMulti ? records : records[0];
+ }
+
+ /**
+ * @private
+ * @param {mail.model} Model class
+ * @param {ModelField} field
+ * @returns {ModelField}
+ */
+ _makeInverseRelationField(Model, field) {
+ const relFunc =
+ field.relationType === 'many2many' ? ModelField.many2many
+ : field.relationType === 'many2one' ? ModelField.one2many
+ : field.relationType === 'one2many' ? ModelField.many2one
+ : field.relationType === 'one2one' ? ModelField.one2one
+ : undefined;
+ if (!relFunc) {
+ throw new Error(`Cannot compute inverse Relation of "${Model.modelName}/${field.fieldName}".`);
+ }
+ const inverseField = new ModelField(Object.assign(
+ {},
+ relFunc(Model.modelName, { inverse: field.fieldName }),
+ {
+ env: this.env,
+ fieldName: `_inverse_${Model.modelName}/${field.fieldName}`,
+ modelManager: this,
+ }
+ ));
+ return inverseField;
+ }
+
+ /**
+ * This function processes definition of declared fields in provided models.
+ * Basically, models have fields declared in static prop `fields`, and this
+ * function processes and modifies them in place so that they are fully
+ * configured. For instance, model relations need bi-directional mapping, but
+ * inverse relation may be omitted in declared field: this function auto-fill
+ * this inverse relation.
+ *
+ * @private
+ * @param {Object} Models
+ */
+ _processDeclaredFieldsOnModels(Models) {
+ /**
+ * 1. Prepare fields.
+ */
+ for (const Model of Object.values(Models)) {
+ if (!Object.prototype.hasOwnProperty.call(Model, 'fields')) {
+ Model.fields = {};
+ }
+ Model.inverseRelations = [];
+ // Make fields aware of their field name.
+ for (const [fieldName, fieldData] of Object.entries(Model.fields)) {
+ Model.fields[fieldName] = new ModelField(Object.assign({}, fieldData, {
+ env: this.env,
+ fieldName,
+ modelManager: this,
+ }));
+ }
+ }
+ /**
+ * 2. Auto-generate definitions of undeclared inverse relations.
+ */
+ for (const Model of Object.values(Models)) {
+ for (const field of Object.values(Model.fields)) {
+ if (field.fieldType !== 'relation') {
+ continue;
+ }
+ if (field.inverse) {
+ continue;
+ }
+ const RelatedModel = Models[field.to];
+ const inverseField = this._makeInverseRelationField(Model, field);
+ field.inverse = inverseField.fieldName;
+ RelatedModel.fields[inverseField.fieldName] = inverseField;
+ }
+ }
+ /**
+ * 3. Generate dependents and inverse-relates on fields.
+ * Field definitions are not yet combined, so registration of `dependents`
+ * may have to walk structural hierarchy of models in order to find
+ * the appropriate field. Also, while dependencies are defined just with
+ * field names, dependents require an additional data called a "hash"
+ * (= field id), which is a way to identify dependents in an inverse
+ * relation. This is necessary because dependents are a subset of an inverse
+ * relation.
+ */
+ for (const Model of Object.values(Models)) {
+ for (const field of Object.values(Model.fields)) {
+ for (const dependencyFieldName of field.dependencies) {
+ let TargetModel = Model;
+ let dependencyField = TargetModel.fields[dependencyFieldName];
+ while (!dependencyField) {
+ TargetModel = TargetModel.__proto__;
+ dependencyField = TargetModel.fields[dependencyFieldName];
+ }
+ const dependent = [field.id, field.fieldName].join(DEPENDENT_INNER_SEPARATOR);
+ dependencyField.dependents = [
+ ...new Set(dependencyField.dependents.concat([dependent]))
+ ];
+ }
+ if (field.related) {
+ const [relationName, relatedFieldName] = field.related.split('.');
+ let TargetModel = Model;
+ let relationField = TargetModel.fields[relationName];
+ while (!relationField) {
+ TargetModel = TargetModel.__proto__;
+ relationField = TargetModel.fields[relationName];
+ }
+ const relationFieldDependent = [
+ field.id,
+ field.fieldName,
+ ].join(DEPENDENT_INNER_SEPARATOR);
+ relationField.dependents = [
+ ...new Set(relationField.dependents.concat([relationFieldDependent]))
+ ];
+ const OtherModel = Models[relationField.to];
+ let OtherTargetModel = OtherModel;
+ let relatedField = OtherTargetModel.fields[relatedFieldName];
+ while (!relatedField) {
+ OtherTargetModel = OtherTargetModel.__proto__;
+ relatedField = OtherTargetModel.fields[relatedFieldName];
+ }
+ const relatedFieldDependent = [
+ field.id,
+ relationField.inverse,
+ field.fieldName,
+ ].join(DEPENDENT_INNER_SEPARATOR);
+ relatedField.dependents = [
+ ...new Set(
+ relatedField.dependents.concat([relatedFieldDependent])
+ )
+ ];
+ }
+ }
+ }
+ /**
+ * 4. Extend definition of fields of a model with the definition of
+ * fields of its parents. Field definitions on self has precedence over
+ * parented fields.
+ */
+ for (const Model of Object.values(Models)) {
+ Model.__combinedFields = {};
+ for (const field of Object.values(Model.fields)) {
+ Model.__combinedFields[field.fieldName] = field;
+ }
+ let TargetModel = Model.__proto__;
+ while (TargetModel && TargetModel.fields) {
+ for (const targetField of Object.values(TargetModel.fields)) {
+ const field = Model.__combinedFields[targetField.fieldName];
+ if (field) {
+ Model.__combinedFields[targetField.fieldName] = field.combine(targetField);
+ } else {
+ Model.__combinedFields[targetField.fieldName] = targetField;
+ }
+ }
+ TargetModel = TargetModel.__proto__;
+ }
+ }
+ /**
+ * 5. Register final fields and make field accessors, to redirects field
+ * access to field getter and to prevent field from being written
+ * without calling update (which is necessary to process update cycle).
+ */
+ for (const Model of Object.values(Models)) {
+ // Object with fieldName/field as key/value pair, for quick access.
+ Model.__fieldMap = Model.__combinedFields;
+ // List of all fields, for iterating.
+ Model.__fieldList = Object.values(Model.__fieldMap);
+ // Add field accessors.
+ for (const field of Model.__fieldList) {
+ Object.defineProperty(Model.prototype, field.fieldName, {
+ get() {
+ return field.get(this); // this is bound to record
+ },
+ });
+ }
+ delete Model.__combinedFields;
+ }
+ }
+
+ /**
+ * Registers compute of dependents for the given field, if applicable.
+ *
+ * @private
+ * @param {mail.model} record
+ * @param {ModelField} field
+ */
+ _registerComputeOfDependents(record, field) {
+ const Model = record.constructor;
+ for (const dependent of field.dependents) {
+ const [hash, fieldName1, fieldName2] = dependent.split(
+ this.DEPENDENT_INNER_SEPARATOR
+ );
+ const field1 = Model.__fieldMap[fieldName1];
+ if (fieldName2) {
+ // "fieldName1.fieldName2" -> dependent is on another record
+ if (['one2many', 'many2many'].includes(field1.relationType)) {
+ for (const otherRecord of record[fieldName1]) {
+ const OtherModel = otherRecord.constructor;
+ const field2 = OtherModel.__fieldMap[fieldName2];
+ if (field2 && field2.hashes.includes(hash)) {
+ this._registerToComputeField(otherRecord, field2);
+ }
+ }
+ } else {
+ const otherRecord = record[fieldName1];
+ if (!otherRecord) {
+ continue;
+ }
+ const OtherModel = otherRecord.constructor;
+ const field2 = OtherModel.__fieldMap[fieldName2];
+ if (field2 && field2.hashes.includes(hash)) {
+ this._registerToComputeField(otherRecord, field2);
+ }
+ }
+ } else {
+ // "fieldName1" only -> dependent is on current record
+ if (field1 && field1.hashes.includes(hash)) {
+ this._registerToComputeField(record, field1);
+ }
+ }
+ }
+ }
+
+ /**
+ * Register a pair record/field for the compute step of the update cycle in
+ * progress.
+ *
+ * @private
+ * @param {mail.model} record
+ * @param {ModelField} field
+ */
+ _registerToComputeField(record, field) {
+ if (!this._toComputeFields.has(record)) {
+ this._toComputeFields.set(record, new Set());
+ }
+ this._toComputeFields.get(record).add(field);
+ }
+
+ /**
+ * @private
+ * @param {mail.model} record
+ * @param {Object} data
+ * @param {Object} [options]
+ * @returns {boolean} whether any value changed for the current record
+ */
+ _update(record, data, options) {
+ if (!record.exists()) {
+ throw Error(`Cannot update already deleted record ${record.localId}.`);
+ }
+ if (!this._toUpdateAfters.has(record)) {
+ // queue updateAfter before calling field.set to ensure previous
+ // contains the value at the start of update cycle
+ this._toUpdateAfters.set(record, record._updateBefore());
+ }
+ const Model = record.constructor;
+ let hasChanged = false;
+ for (const fieldName of Object.keys(data)) {
+ if (data[fieldName] === undefined) {
+ // `undefined` should have the same effect as not passing the field
+ continue;
+ }
+ const field = Model.__fieldMap[fieldName];
+ if (!field) {
+ throw new Error(`Cannot create/update record with data unrelated to a field. (model: "${Model.modelName}", non-field attempted update: "${fieldName}")`);
+ }
+ const newVal = data[fieldName];
+ if (!field.parseAndExecuteCommands(record, newVal, options)) {
+ continue;
+ }
+ hasChanged = true;
+ // flag all dependent fields for compute
+ this._registerComputeOfDependents(record, field);
+ }
+ if (hasChanged) {
+ this._updatedRecords.add(record);
+ this._hasAnyChangeDuringCycle = true;
+ }
+ return hasChanged;
+ }
+
+}
+
+return ModelManager;
+
+});
diff --git a/addons/mail/static/src/models/activity/activity.js b/addons/mail/static/src/models/activity/activity.js
new file mode 100644
index 00000000..f2023aac
--- /dev/null
+++ b/addons/mail/static/src/models/activity/activity.js
@@ -0,0 +1,355 @@
+odoo.define('mail/static/src/models/activity/activity/js', function (require) {
+'use strict';
+
+const { registerNewModel } = require('mail/static/src/model/model_core.js');
+const { attr, many2many, many2one } = require('mail/static/src/model/model_field.js');
+const { clear } = require('mail/static/src/model/model_field_command.js');
+
+function factory(dependencies) {
+
+ class Activity extends dependencies['mail.model'] {
+
+
+ //----------------------------------------------------------------------
+ // Public
+ //----------------------------------------------------------------------
+
+ /**
+ * Delete the record from database and locally.
+ */
+ async deleteServerRecord() {
+ await this.async(() => this.env.services.rpc({
+ model: 'mail.activity',
+ method: 'unlink',
+ args: [[this.id]],
+ }));
+ this.delete();
+ }
+
+ //----------------------------------------------------------------------
+ // Public
+ //----------------------------------------------------------------------
+
+ /**
+ * @static
+ * @param {Object} data
+ * @return {Object}
+ */
+ static convertData(data) {
+ const data2 = {};
+ if ('activity_category' in data) {
+ data2.category = data.activity_category;
+ }
+ if ('can_write' in data) {
+ data2.canWrite = data.can_write;
+ }
+ if ('create_date' in data) {
+ data2.dateCreate = data.create_date;
+ }
+ if ('date_deadline' in data) {
+ data2.dateDeadline = data.date_deadline;
+ }
+ if ('force_next' in data) {
+ data2.force_next = data.force_next;
+ }
+ if ('icon' in data) {
+ data2.icon = data.icon;
+ }
+ if ('id' in data) {
+ data2.id = data.id;
+ }
+ if ('note' in data) {
+ data2.note = data.note;
+ }
+ if ('state' in data) {
+ data2.state = data.state;
+ }
+ if ('summary' in data) {
+ data2.summary = data.summary;
+ }
+
+ // relation
+ if ('activity_type_id' in data) {
+ if (!data.activity_type_id) {
+ data2.type = [['unlink-all']];
+ } else {
+ data2.type = [
+ ['insert', {
+ displayName: data.activity_type_id[1],
+ id: data.activity_type_id[0],
+ }],
+ ];
+ }
+ }
+ if ('create_uid' in data) {
+ if (!data.create_uid) {
+ data2.creator = [['unlink-all']];
+ } else {
+ data2.creator = [
+ ['insert', {
+ id: data.create_uid[0],
+ display_name: data.create_uid[1],
+ }],
+ ];
+ }
+ }
+ if ('mail_template_ids' in data) {
+ data2.mailTemplates = [['insert', data.mail_template_ids]];
+ }
+ if ('res_id' in data && 'res_model' in data) {
+ data2.thread = [['insert', {
+ id: data.res_id,
+ model: data.res_model,
+ }]];
+ }
+ if ('user_id' in data) {
+ if (!data.user_id) {
+ data2.assignee = [['unlink-all']];
+ } else {
+ data2.assignee = [
+ ['insert', {
+ id: data.user_id[0],
+ display_name: data.user_id[1],
+ }],
+ ];
+ }
+ }
+ if ('request_partner_id' in data) {
+ if (!data.request_partner_id) {
+ data2.requestingPartner = [['unlink']];
+ } else {
+ data2.requestingPartner = [
+ ['insert', {
+ id: data.request_partner_id[0],
+ display_name: data.request_partner_id[1],
+ }],
+ ];
+ }
+ }
+
+ return data2;
+ }
+
+ /**
+ * Opens (legacy) form view dialog to edit current activity and updates
+ * the activity when dialog is closed.
+ */
+ edit() {
+ const action = {
+ type: 'ir.actions.act_window',
+ name: this.env._t("Schedule Activity"),
+ res_model: 'mail.activity',
+ view_mode: 'form',
+ views: [[false, 'form']],
+ target: 'new',
+ context: {
+ default_res_id: this.thread.id,
+ default_res_model: this.thread.model,
+ },
+ res_id: this.id,
+ };
+ this.env.bus.trigger('do-action', {
+ action,
+ options: { on_close: () => this.fetchAndUpdate() },
+ });
+ }
+
+ async fetchAndUpdate() {
+ const [data] = await this.async(() => this.env.services.rpc({
+ model: 'mail.activity',
+ method: 'activity_format',
+ args: [this.id],
+ }, { shadow: true }));
+ let shouldDelete = false;
+ if (data) {
+ this.update(this.constructor.convertData(data));
+ } else {
+ shouldDelete = true;
+ }
+ this.thread.refreshActivities();
+ this.thread.refresh();
+ if (shouldDelete) {
+ this.delete();
+ }
+ }
+
+ /**
+ * @param {Object} param0
+ * @param {mail.attachment[]} [param0.attachments=[]]
+ * @param {string|boolean} [param0.feedback=false]
+ */
+ async markAsDone({ attachments = [], feedback = false }) {
+ const attachmentIds = attachments.map(attachment => attachment.id);
+ await this.async(() => this.env.services.rpc({
+ model: 'mail.activity',
+ method: 'action_feedback',
+ args: [[this.id]],
+ kwargs: {
+ attachment_ids: attachmentIds,
+ feedback,
+ },
+ }));
+ this.thread.refresh();
+ this.delete();
+ }
+
+ /**
+ * @param {Object} param0
+ * @param {string} param0.feedback
+ * @returns {Object}
+ */
+ async markAsDoneAndScheduleNext({ feedback }) {
+ const action = await this.async(() => this.env.services.rpc({
+ model: 'mail.activity',
+ method: 'action_feedback_schedule_next',
+ args: [[this.id]],
+ kwargs: { feedback },
+ }));
+ this.thread.refresh();
+ const thread = this.thread;
+ this.delete();
+ if (!action) {
+ thread.refreshActivities();
+ return;
+ }
+ this.env.bus.trigger('do-action', {
+ action,
+ options: {
+ on_close: () => {
+ thread.refreshActivities();
+ },
+ },
+ });
+ }
+
+ //----------------------------------------------------------------------
+ // Private
+ //----------------------------------------------------------------------
+
+ /**
+ * @override
+ */
+ static _createRecordLocalId(data) {
+ return `${this.modelName}_${data.id}`;
+ }
+
+ /**
+ * @private
+ * @returns {boolean}
+ */
+ _computeIsCurrentPartnerAssignee() {
+ if (!this.assigneePartner || !this.messagingCurrentPartner) {
+ return false;
+ }
+ return this.assigneePartner === this.messagingCurrentPartner;
+ }
+
+ /**
+ * @private
+ * @returns {mail.messaging}
+ */
+ _computeMessaging() {
+ return [['link', this.env.messaging]];
+ }
+
+ /**
+ * Wysiwyg editor put `<p><br></p>` even without a note on the activity.
+ * This compute replaces this almost empty value by an actual empty
+ * value, to reduce the size the empty note takes on the UI.
+ *
+ * @private
+ * @returns {string|undefined}
+ */
+ _computeNote() {
+ if (this.note === '<p><br></p>') {
+ return clear();
+ }
+ return this.note;
+ }
+ }
+
+ Activity.fields = {
+ assignee: many2one('mail.user'),
+ assigneePartner: many2one('mail.partner', {
+ related: 'assignee.partner',
+ }),
+ attachments: many2many('mail.attachment', {
+ inverse: 'activities',
+ }),
+ canWrite: attr({
+ default: false,
+ }),
+ category: attr(),
+ creator: many2one('mail.user'),
+ dateCreate: attr(),
+ dateDeadline: attr(),
+ /**
+ * Backup of the feedback content of an activity to be marked as done in the popover.
+ * Feature-specific to restoring the feedback content when component is re-mounted.
+ * In all other cases, this field value should not be trusted.
+ */
+ feedbackBackup: attr(),
+ force_next: attr({
+ default: false,
+ }),
+ icon: attr(),
+ id: attr(),
+ isCurrentPartnerAssignee: attr({
+ compute: '_computeIsCurrentPartnerAssignee',
+ default: false,
+ dependencies: [
+ 'assigneePartner',
+ 'messagingCurrentPartner',
+ ],
+ }),
+ mailTemplates: many2many('mail.mail_template', {
+ inverse: 'activities',
+ }),
+ messaging: many2one('mail.messaging', {
+ compute: '_computeMessaging',
+ }),
+ messagingCurrentPartner: many2one('mail.partner', {
+ related: 'messaging.currentPartner',
+ }),
+ /**
+ * This value is meant to be returned by the server
+ * (and has been sanitized before stored into db).
+ * Do not use this value in a 't-raw' if the activity has been created
+ * directly from user input and not from server data as it's not escaped.
+ */
+ note: attr({
+ compute: '_computeNote',
+ dependencies: [
+ 'note',
+ ],
+ }),
+ /**
+ * Determines that an activity is linked to a requesting partner or not.
+ * It will be used notably in website slides to know who triggered the
+ * "request access" activity.
+ * Also, be useful when the assigned user is different from the
+ * "source" or "requesting" partner.
+ */
+ requestingPartner: many2one('mail.partner'),
+ state: attr(),
+ summary: attr(),
+ /**
+ * Determines to which "thread" (using `mail.activity.mixin` on the
+ * server) `this` belongs to.
+ */
+ thread: many2one('mail.thread', {
+ inverse: 'activities',
+ }),
+ type: many2one('mail.activity_type', {
+ inverse: 'activities',
+ }),
+ };
+
+ Activity.modelName = 'mail.activity';
+
+ return Activity;
+}
+
+registerNewModel('mail.activity', factory);
+
+});
diff --git a/addons/mail/static/src/models/activity_type/activity_type.js b/addons/mail/static/src/models/activity_type/activity_type.js
new file mode 100644
index 00000000..f8a621a8
--- /dev/null
+++ b/addons/mail/static/src/models/activity_type/activity_type.js
@@ -0,0 +1,39 @@
+odoo.define('mail/static/src/models/activity_type/activity_type.js', function (require) {
+'use strict';
+
+const { registerNewModel } = require('mail/static/src/model/model_core.js');
+const { attr, one2many } = require('mail/static/src/model/model_field.js');
+
+function factory(dependencies) {
+
+ class ActivityType extends dependencies['mail.model'] {
+
+ //----------------------------------------------------------------------
+ // Private
+ //----------------------------------------------------------------------
+
+ /**
+ * @override
+ */
+ static _createRecordLocalId(data) {
+ return `${this.modelName}_${data.id}`;
+ }
+
+ }
+
+ ActivityType.fields = {
+ activities: one2many('mail.activity', {
+ inverse: 'type',
+ }),
+ displayName: attr(),
+ id: attr(),
+ };
+
+ ActivityType.modelName = 'mail.activity_type';
+
+ return ActivityType;
+}
+
+registerNewModel('mail.activity_type', factory);
+
+});
diff --git a/addons/mail/static/src/models/attachment/attachment.js b/addons/mail/static/src/models/attachment/attachment.js
new file mode 100644
index 00000000..a49b0a87
--- /dev/null
+++ b/addons/mail/static/src/models/attachment/attachment.js
@@ -0,0 +1,439 @@
+odoo.define('mail/static/src/models/attachment/attachment.js', function (require) {
+'use strict';
+
+const { registerNewModel } = require('mail/static/src/model/model_core.js');
+const { attr, many2many, many2one } = require('mail/static/src/model/model_field.js');
+const { clear } = require('mail/static/src/model/model_field_command.js');
+
+function factory(dependencies) {
+
+ let nextTemporaryId = -1;
+ function getAttachmentNextTemporaryId() {
+ const id = nextTemporaryId;
+ nextTemporaryId -= 1;
+ return id;
+ }
+ class Attachment extends dependencies['mail.model'] {
+
+ //----------------------------------------------------------------------
+ // Public
+ //----------------------------------------------------------------------
+
+ /**
+ * @static
+ * @param {Object} data
+ * @return {Object}
+ */
+ static convertData(data) {
+ const data2 = {};
+ if ('filename' in data) {
+ data2.filename = data.filename;
+ }
+ if ('id' in data) {
+ data2.id = data.id;
+ }
+ if ('mimetype' in data) {
+ data2.mimetype = data.mimetype;
+ }
+ if ('name' in data) {
+ data2.name = data.name;
+ }
+
+ // relation
+ if ('res_id' in data && 'res_model' in data) {
+ data2.originThread = [['insert', {
+ id: data.res_id,
+ model: data.res_model,
+ }]];
+ }
+
+ return data2;
+ }
+
+ /**
+ * @override
+ */
+ static create(data) {
+ const isMulti = typeof data[Symbol.iterator] === 'function';
+ const dataList = isMulti ? data : [data];
+ for (const data of dataList) {
+ if (!data.id) {
+ data.id = getAttachmentNextTemporaryId();
+ }
+ }
+ return super.create(...arguments);
+ }
+
+ /**
+ * View provided attachment(s), with given attachment initially. Prompts
+ * the attachment viewer.
+ *
+ * @static
+ * @param {Object} param0
+ * @param {mail.attachment} [param0.attachment]
+ * @param {mail.attachments[]} param0.attachments
+ * @returns {string|undefined} unique id of open dialog, if open
+ */
+ static view({ attachment, attachments }) {
+ const hasOtherAttachments = attachments && attachments.length > 0;
+ if (!attachment && !hasOtherAttachments) {
+ return;
+ }
+ if (!attachment && hasOtherAttachments) {
+ attachment = attachments[0];
+ } else if (attachment && !hasOtherAttachments) {
+ attachments = [attachment];
+ }
+ if (!attachments.includes(attachment)) {
+ return;
+ }
+ this.env.messaging.dialogManager.open('mail.attachment_viewer', {
+ attachment: [['link', attachment]],
+ attachments: [['replace', attachments]],
+ });
+ }
+
+ /**
+ * Remove this attachment globally.
+ */
+ async remove() {
+ if (this.isUnlinkPending) {
+ return;
+ }
+ if (!this.isTemporary) {
+ this.update({ isUnlinkPending: true });
+ try {
+ await this.async(() => this.env.services.rpc({
+ model: 'ir.attachment',
+ method: 'unlink',
+ args: [this.id],
+ }, { shadow: true }));
+ } finally {
+ this.update({ isUnlinkPending: false });
+ }
+ } else if (this.uploadingAbortController) {
+ this.uploadingAbortController.abort();
+ }
+ this.delete();
+ }
+
+ //----------------------------------------------------------------------
+ // Private
+ //----------------------------------------------------------------------
+
+ /**
+ * @override
+ */
+ static _createRecordLocalId(data) {
+ return `${this.modelName}_${data.id}`;
+ }
+
+ /**
+ * @private
+ * @returns {mail.composer[]}
+ */
+ _computeComposers() {
+ if (this.isTemporary) {
+ return [];
+ }
+ const relatedTemporaryAttachment = this.env.models['mail.attachment']
+ .find(attachment =>
+ attachment.filename === this.filename &&
+ attachment.isTemporary
+ );
+ if (relatedTemporaryAttachment) {
+ const composers = relatedTemporaryAttachment.composers;
+ relatedTemporaryAttachment.delete();
+ return [['replace', composers]];
+ }
+ return [];
+ }
+
+ /**
+ * @private
+ * @returns {string|undefined}
+ */
+ _computeDefaultSource() {
+ if (this.fileType === 'image') {
+ return `/web/image/${this.id}?unique=1&amp;signature=${this.checksum}&amp;model=ir.attachment`;
+ }
+ if (this.fileType === 'application/pdf') {
+ return `/web/static/lib/pdfjs/web/viewer.html?file=/web/content/${this.id}?model%3Dir.attachment`;
+ }
+ if (this.fileType && this.fileType.includes('text')) {
+ return `/web/content/${this.id}?model%3Dir.attachment`;
+ }
+ if (this.fileType === 'youtu') {
+ const urlArr = this.url.split('/');
+ let token = urlArr[urlArr.length - 1];
+ if (token.includes('watch')) {
+ token = token.split('v=')[1];
+ const amp = token.indexOf('&');
+ if (amp !== -1) {
+ token = token.substring(0, amp);
+ }
+ }
+ return `https://www.youtube.com/embed/${token}`;
+ }
+ if (this.fileType === 'video') {
+ return `/web/content/${this.id}?model=ir.attachment`;
+ }
+ return clear();
+ }
+
+ /**
+ * @private
+ * @returns {string|undefined}
+ */
+ _computeDisplayName() {
+ const displayName = this.name || this.filename;
+ if (displayName) {
+ return displayName;
+ }
+ return clear();
+ }
+
+ /**
+ * @private
+ * @returns {string|undefined}
+ */
+ _computeExtension() {
+ const extension = this.filename && this.filename.split('.').pop();
+ if (extension) {
+ return extension;
+ }
+ return clear();
+ }
+
+ /**
+ * @private
+ * @returns {string|undefined}
+ */
+ _computeFileType() {
+ if (this.type === 'url' && !this.url) {
+ return clear();
+ } else if (!this.mimetype) {
+ return clear();
+ }
+ switch (this.mimetype) {
+ case 'application/pdf':
+ return 'application/pdf';
+ case 'image/bmp':
+ case 'image/gif':
+ case 'image/jpeg':
+ case 'image/png':
+ case 'image/svg+xml':
+ case 'image/tiff':
+ case 'image/x-icon':
+ return 'image';
+ case 'application/javascript':
+ case 'application/json':
+ case 'text/css':
+ case 'text/html':
+ case 'text/plain':
+ return 'text';
+ case 'audio/mpeg':
+ case 'video/x-matroska':
+ case 'video/mp4':
+ case 'video/webm':
+ return 'video';
+ }
+ if (!this.url) {
+ return clear();
+ }
+ if (this.url.match('(.png|.jpg|.gif)')) {
+ return 'image';
+ }
+ if (this.url.includes('youtu')) {
+ return 'youtu';
+ }
+ return clear();
+ }
+
+ /**
+ * @private
+ * @returns {boolean}
+ */
+ _computeIsLinkedToComposer() {
+ return this.composers.length > 0;
+ }
+
+ /**
+ * @private
+ * @returns {boolean}
+ */
+ _computeIsTextFile() {
+ if (!this.fileType) {
+ return false;
+ }
+ return this.fileType === 'text';
+ }
+
+ /**
+ * @private
+ * @returns {boolean}
+ */
+ _computeIsViewable() {
+ switch (this.mimetype) {
+ case 'application/javascript':
+ case 'application/json':
+ case 'application/pdf':
+ case 'audio/mpeg':
+ case 'image/bmp':
+ case 'image/gif':
+ case 'image/jpeg':
+ case 'image/png':
+ case 'image/svg+xml':
+ case 'image/tiff':
+ case 'image/x-icon':
+ case 'text/css':
+ case 'text/html':
+ case 'text/plain':
+ case 'video/x-matroska':
+ case 'video/mp4':
+ case 'video/webm':
+ return true;
+ default:
+ return false;
+ }
+ }
+
+ /**
+ * @deprecated
+ * @private
+ * @returns {string}
+ */
+ _computeMediaType() {
+ return this.mimetype && this.mimetype.split('/').shift();
+ }
+
+ /**
+ * @private
+ * @returns {AbortController|undefined}
+ */
+ _computeUploadingAbortController() {
+ if (this.isTemporary) {
+ if (!this.uploadingAbortController) {
+ const abortController = new AbortController();
+ abortController.signal.onabort = () => {
+ this.env.messagingBus.trigger('o-attachment-upload-abort', {
+ attachment: this
+ });
+ };
+ return abortController;
+ }
+ return this.uploadingAbortController;
+ }
+ return undefined;
+ }
+ }
+
+ Attachment.fields = {
+ activities: many2many('mail.activity', {
+ inverse: 'attachments',
+ }),
+ attachmentViewer: many2many('mail.attachment_viewer', {
+ inverse: 'attachments',
+ }),
+ checkSum: attr(),
+ composers: many2many('mail.composer', {
+ compute: '_computeComposers',
+ inverse: 'attachments',
+ }),
+ defaultSource: attr({
+ compute: '_computeDefaultSource',
+ dependencies: [
+ 'checkSum',
+ 'fileType',
+ 'id',
+ 'url',
+ ],
+ }),
+ displayName: attr({
+ compute: '_computeDisplayName',
+ dependencies: [
+ 'filename',
+ 'name',
+ ],
+ }),
+ extension: attr({
+ compute: '_computeExtension',
+ dependencies: ['filename'],
+ }),
+ filename: attr(),
+ fileType: attr({
+ compute: '_computeFileType',
+ dependencies: [
+ 'mimetype',
+ 'type',
+ 'url',
+ ],
+ }),
+ id: attr(),
+ isLinkedToComposer: attr({
+ compute: '_computeIsLinkedToComposer',
+ dependencies: ['composers'],
+ }),
+ isTemporary: attr({
+ default: false,
+ }),
+ isTextFile: attr({
+ compute: '_computeIsTextFile',
+ dependencies: ['fileType'],
+ }),
+ /**
+ * True if an unlink RPC is pending, used to prevent multiple unlink attempts.
+ */
+ isUnlinkPending: attr({
+ default: false,
+ }),
+ isViewable: attr({
+ compute: '_computeIsViewable',
+ dependencies: [
+ 'mimetype',
+ ],
+ }),
+ /**
+ * @deprecated
+ */
+ mediaType: attr({
+ compute: '_computeMediaType',
+ dependencies: ['mimetype'],
+ }),
+ messages: many2many('mail.message', {
+ inverse: 'attachments',
+ }),
+ mimetype: attr({
+ default: '',
+ }),
+ name: attr(),
+ originThread: many2one('mail.thread', {
+ inverse: 'originThreadAttachments',
+ }),
+ size: attr(),
+ threads: many2many('mail.thread', {
+ inverse: 'attachments',
+ }),
+ type: attr(),
+ /**
+ * Abort Controller linked to the uploading process of this attachment.
+ * Useful in order to cancel the in-progress uploading of this attachment.
+ */
+ uploadingAbortController: attr({
+ compute: '_computeUploadingAbortController',
+ dependencies: [
+ 'isTemporary',
+ 'uploadingAbortController',
+ ],
+ }),
+ url: attr(),
+ };
+
+ Attachment.modelName = 'mail.attachment';
+
+ return Attachment;
+}
+
+registerNewModel('mail.attachment', factory);
+
+});
diff --git a/addons/mail/static/src/models/attachment/attachment_tests.js b/addons/mail/static/src/models/attachment/attachment_tests.js
new file mode 100644
index 00000000..5c09dcae
--- /dev/null
+++ b/addons/mail/static/src/models/attachment/attachment_tests.js
@@ -0,0 +1,144 @@
+odoo.define('mail/static/src/models/attachment/attachment_tests.js', function (require) {
+'use strict';
+
+const { afterEach, beforeEach, start } = require('mail/static/src/utils/test_utils.js');
+
+QUnit.module('mail', {}, function () {
+QUnit.module('models', {}, function () {
+QUnit.module('attachment', {}, function () {
+QUnit.module('attachment_tests.js', {
+ beforeEach() {
+ beforeEach(this);
+
+ this.start = async params => {
+ const { env, widget } = await start(Object.assign({}, params, {
+ data: this.data,
+ }));
+ this.env = env;
+ this.widget = widget;
+ };
+ },
+ afterEach() {
+ afterEach(this);
+ },
+});
+
+QUnit.test('create (txt)', async function (assert) {
+ assert.expect(9);
+
+ await this.start();
+ assert.notOk(this.env.models['mail.attachment'].findFromIdentifyingData({ id: 750 }));
+
+ const attachment = this.env.models['mail.attachment'].create({
+ filename: "test.txt",
+ id: 750,
+ mimetype: 'text/plain',
+ name: "test.txt",
+ });
+ assert.ok(attachment);
+ assert.ok(this.env.models['mail.attachment'].findFromIdentifyingData({ id: 750 }));
+ assert.strictEqual(this.env.models['mail.attachment'].findFromIdentifyingData({ id: 750 }), attachment);
+ assert.strictEqual(attachment.filename, "test.txt");
+ assert.strictEqual(attachment.id, 750);
+ assert.notOk(attachment.isTemporary);
+ assert.strictEqual(attachment.mimetype, 'text/plain');
+ assert.strictEqual(attachment.name, "test.txt");
+});
+
+QUnit.test('displayName', async function (assert) {
+ assert.expect(5);
+
+ await this.start();
+ assert.notOk(this.env.models['mail.attachment'].findFromIdentifyingData({ id: 750 }));
+
+ const attachment = this.env.models['mail.attachment'].create({
+ filename: "test.txt",
+ id: 750,
+ mimetype: 'text/plain',
+ name: "test.txt",
+ });
+ assert.ok(attachment);
+ assert.ok(this.env.models['mail.attachment'].findFromIdentifyingData({ id: 750 }));
+ assert.strictEqual(attachment, this.env.models['mail.attachment'].findFromIdentifyingData({ id: 750 }));
+ assert.strictEqual(attachment.displayName, "test.txt");
+});
+
+QUnit.test('extension', async function (assert) {
+ assert.expect(5);
+
+ await this.start();
+ assert.notOk(this.env.models['mail.attachment'].findFromIdentifyingData({ id: 750 }));
+
+ const attachment = this.env.models['mail.attachment'].create({
+ filename: "test.txt",
+ id: 750,
+ mimetype: 'text/plain',
+ name: "test.txt",
+ });
+ assert.ok(attachment);
+ assert.ok(this.env.models['mail.attachment'].findFromIdentifyingData({ id: 750 }));
+ assert.strictEqual(attachment, this.env.models['mail.attachment'].findFromIdentifyingData({ id: 750 }));
+ assert.strictEqual(attachment.extension, 'txt');
+});
+
+QUnit.test('fileType', async function (assert) {
+ assert.expect(5);
+
+ await this.start();
+ assert.notOk(this.env.models['mail.attachment'].findFromIdentifyingData({ id: 750 }));
+
+ const attachment = this.env.models['mail.attachment'].create({
+ filename: "test.txt",
+ id: 750,
+ mimetype: 'text/plain',
+ name: "test.txt",
+ });
+ assert.ok(attachment);
+ assert.ok(this.env.models['mail.attachment'].findFromIdentifyingData({ id: 750 }));
+ assert.strictEqual(attachment, this.env.models['mail.attachment'].findFromIdentifyingData({
+ id: 750,
+ }));
+ assert.strictEqual(attachment.fileType, 'text');
+});
+
+QUnit.test('isTextFile', async function (assert) {
+ assert.expect(5);
+
+ await this.start();
+ assert.notOk(this.env.models['mail.attachment'].findFromIdentifyingData({ id: 750 }));
+
+ const attachment = this.env.models['mail.attachment'].create({
+ filename: "test.txt",
+ id: 750,
+ mimetype: 'text/plain',
+ name: "test.txt",
+ });
+ assert.ok(attachment);
+ assert.ok(this.env.models['mail.attachment'].findFromIdentifyingData({ id: 750 }));
+ assert.strictEqual(attachment, this.env.models['mail.attachment'].findFromIdentifyingData({ id: 750 }));
+ assert.ok(attachment.isTextFile);
+});
+
+QUnit.test('isViewable', async function (assert) {
+ assert.expect(5);
+
+ await this.start();
+ assert.notOk(this.env.models['mail.attachment'].findFromIdentifyingData({ id: 750 }));
+
+ const attachment = this.env.models['mail.attachment'].create({
+ filename: "test.txt",
+ id: 750,
+ mimetype: 'text/plain',
+ name: "test.txt",
+ });
+ assert.ok(attachment);
+ assert.ok(this.env.models['mail.attachment'].findFromIdentifyingData({ id: 750 }));
+ assert.strictEqual(attachment, this.env.models['mail.attachment'].findFromIdentifyingData({ id: 750 }));
+ assert.ok(attachment.isViewable);
+});
+
+});
+});
+});
+
+});
diff --git a/addons/mail/static/src/models/attachment_viewer/attachment_viewer.js b/addons/mail/static/src/models/attachment_viewer/attachment_viewer.js
new file mode 100644
index 00000000..8a96946c
--- /dev/null
+++ b/addons/mail/static/src/models/attachment_viewer/attachment_viewer.js
@@ -0,0 +1,59 @@
+odoo.define('mail/static/src/models/attachment_viewer/attachment_viewer.js', function (require) {
+'use strict';
+
+const { registerNewModel } = require('mail/static/src/model/model_core.js');
+const { attr, many2many, many2one } = require('mail/static/src/model/model_field.js');
+
+function factory(dependencies) {
+
+ class AttachmentViewer extends dependencies['mail.model'] {
+
+ //----------------------------------------------------------------------
+ // Public
+ //----------------------------------------------------------------------
+
+ /**
+ * Close the attachment viewer by closing its linked dialog.
+ */
+ close() {
+ const dialog = this.env.models['mail.dialog'].find(dialog => dialog.record === this);
+ if (dialog) {
+ dialog.delete();
+ }
+ }
+ }
+
+ AttachmentViewer.fields = {
+ /**
+ * Angle of the image. Changes when the user rotates it.
+ */
+ angle: attr({
+ default: 0,
+ }),
+ attachment: many2one('mail.attachment'),
+ attachments: many2many('mail.attachment', {
+ inverse: 'attachmentViewer',
+ }),
+ /**
+ * Determine whether the image is loading or not. Useful to diplay
+ * a spinner when loading image initially.
+ */
+ isImageLoading: attr({
+ default: false,
+ }),
+ /**
+ * Scale size of the image. Changes when user zooms in/out.
+ */
+ scale: attr({
+ default: 1,
+ }),
+ };
+
+ AttachmentViewer.modelName = 'mail.attachment_viewer';
+
+ return AttachmentViewer;
+}
+
+registerNewModel('mail.attachment_viewer', factory);
+
+});
diff --git a/addons/mail/static/src/models/canned_response/canned_response.js b/addons/mail/static/src/models/canned_response/canned_response.js
new file mode 100644
index 00000000..41e917d2
--- /dev/null
+++ b/addons/mail/static/src/models/canned_response/canned_response.js
@@ -0,0 +1,107 @@
+odoo.define('mail/static/src/models/canned_response/canned_response.js', function (require) {
+'use strict';
+
+const { registerNewModel } = require('mail/static/src/model/model_core.js');
+const { attr } = require('mail/static/src/model/model_field.js');
+const { cleanSearchTerm } = require('mail/static/src/utils/utils.js');
+
+function factory(dependencies) {
+
+ class CannedResponse extends dependencies['mail.model'] {
+
+ /**
+ * Fetches canned responses matching the given search term to extend the
+ * JS knowledge and to update the suggestion list accordingly.
+ *
+ * In practice all canned responses are already fetched at init so this
+ * method does nothing.
+ *
+ * @static
+ * @param {string} searchTerm
+ * @param {Object} [options={}]
+ * @param {mail.thread} [options.thread] prioritize and/or restrict
+ * result in the context of given thread
+ */
+ static fetchSuggestions(searchTerm, { thread } = {}) {}
+
+ /**
+ * Returns a sort function to determine the order of display of canned
+ * responses in the suggestion list.
+ *
+ * @static
+ * @param {string} searchTerm
+ * @param {Object} [options={}]
+ * @param {mail.thread} [options.thread] prioritize result in the
+ * context of given thread
+ * @returns {function}
+ */
+ static getSuggestionSortFunction(searchTerm, { thread } = {}) {
+ const cleanedSearchTerm = cleanSearchTerm(searchTerm);
+ return (a, b) => {
+ const cleanedAName = cleanSearchTerm(a.source || '');
+ const cleanedBName = cleanSearchTerm(b.source || '');
+ if (cleanedAName.startsWith(cleanedSearchTerm) && !cleanedBName.startsWith(cleanedSearchTerm)) {
+ return -1;
+ }
+ if (!cleanedAName.startsWith(cleanedSearchTerm) && cleanedBName.startsWith(cleanedSearchTerm)) {
+ return 1;
+ }
+ if (cleanedAName < cleanedBName) {
+ return -1;
+ }
+ if (cleanedAName > cleanedBName) {
+ return 1;
+ }
+ return a.id - b.id;
+ };
+ }
+
+ /*
+ * Returns canned responses that match the given search term.
+ *
+ * @static
+ * @param {string} searchTerm
+ * @param {Object} [options={}]
+ * @param {mail.thread} [options.thread] prioritize and/or restrict
+ * result in the context of given thread
+ * @returns {[mail.canned_response[], mail.canned_response[]]}
+ */
+ static searchSuggestions(searchTerm, { thread } = {}) {
+ const cleanedSearchTerm = cleanSearchTerm(searchTerm);
+ return [this.env.messaging.cannedResponses.filter(cannedResponse =>
+ cleanSearchTerm(cannedResponse.source).includes(cleanedSearchTerm)
+ )];
+ }
+
+ /**
+ * Returns the text that identifies this canned response in a mention.
+ *
+ * @returns {string}
+ */
+ getMentionText() {
+ return this.substitution;
+ }
+
+ }
+
+ CannedResponse.fields = {
+ id: attr(),
+ /**
+ * The keyword to use a specific canned response.
+ */
+ source: attr(),
+ /**
+ * The canned response itself which will replace the keyword previously
+ * entered.
+ */
+ substitution: attr(),
+ };
+
+ CannedResponse.modelName = 'mail.canned_response';
+
+ return CannedResponse;
+}
+
+registerNewModel('mail.canned_response', factory);
+
+});
diff --git a/addons/mail/static/src/models/channel_command/channel_command.js b/addons/mail/static/src/models/channel_command/channel_command.js
new file mode 100644
index 00000000..728acdb9
--- /dev/null
+++ b/addons/mail/static/src/models/channel_command/channel_command.js
@@ -0,0 +1,130 @@
+odoo.define('mail/static/src/models/channel_command/channel_command.js', function (require) {
+'use strict';
+
+const { registerNewModel } = require('mail/static/src/model/model_core.js');
+const { attr } = require('mail/static/src/model/model_field.js');
+const { cleanSearchTerm } = require('mail/static/src/utils/utils.js');
+
+function factory(dependencies) {
+
+ class ChannelCommand extends dependencies['mail.model'] {
+
+ /**
+ * Fetches channel commands matching the given search term to extend the
+ * JS knowledge and to update the suggestion list accordingly.
+ *
+ * In practice all channel commands are already fetched at init so this
+ * method does nothing.
+ *
+ * @static
+ * @param {string} searchTerm
+ * @param {Object} [options={}]
+ * @param {mail.thread} [options.thread] prioritize and/or restrict
+ * result in the context of given thread
+ */
+ static fetchSuggestions(searchTerm, { thread } = {}) {}
+
+ /**
+ * Returns a sort function to determine the order of display of channel
+ * commands in the suggestion list.
+ *
+ * @static
+ * @param {string} searchTerm
+ * @param {Object} [options={}]
+ * @param {mail.thread} [options.thread] prioritize result in the
+ * context of given thread
+ * @returns {function}
+ */
+ static getSuggestionSortFunction(searchTerm, { thread } = {}) {
+ const cleanedSearchTerm = cleanSearchTerm(searchTerm);
+ return (a, b) => {
+ const isATypeSpecific = a.channel_types;
+ const isBTypeSpecific = b.channel_types;
+ if (isATypeSpecific && !isBTypeSpecific) {
+ return -1;
+ }
+ if (!isATypeSpecific && isBTypeSpecific) {
+ return 1;
+ }
+ const cleanedAName = cleanSearchTerm(a.name || '');
+ const cleanedBName = cleanSearchTerm(b.name || '');
+ if (cleanedAName.startsWith(cleanedSearchTerm) && !cleanedBName.startsWith(cleanedSearchTerm)) {
+ return -1;
+ }
+ if (!cleanedAName.startsWith(cleanedSearchTerm) && cleanedBName.startsWith(cleanedSearchTerm)) {
+ return 1;
+ }
+ if (cleanedAName < cleanedBName) {
+ return -1;
+ }
+ if (cleanedAName > cleanedBName) {
+ return 1;
+ }
+ return a.id - b.id;
+ };
+ }
+
+ /**
+ * Returns channel commands that match the given search term.
+ *
+ * @static
+ * @param {string} searchTerm
+ * @param {Object} [options={}]
+ * @param {mail.thread} [options.thread] prioritize and/or restrict
+ * result in the context of given thread
+ * @returns {[mail.channel_command[], mail.channel_command[]]}
+ */
+ static searchSuggestions(searchTerm, { thread } = {}) {
+ if (thread.model !== 'mail.channel') {
+ // channel commands are channel specific
+ return [[]];
+ }
+ const cleanedSearchTerm = cleanSearchTerm(searchTerm);
+ return [this.env.messaging.commands.filter(command => {
+ if (!cleanSearchTerm(command.name).includes(cleanedSearchTerm)) {
+ return false;
+ }
+ if (command.channel_types) {
+ return command.channel_types.includes(thread.channel_type);
+ }
+ return true;
+ })];
+ }
+
+ /**
+ * Returns the text that identifies this channel command in a mention.
+ *
+ * @returns {string}
+ */
+ getMentionText() {
+ return this.name;
+ }
+
+ }
+
+ ChannelCommand.fields = {
+ /**
+ * Determines on which channel types `this` is available.
+ * Type of the channel (e.g. 'chat', 'channel' or 'groups')
+ * This field should contain an array when filtering is desired.
+ * Otherwise, it should be undefined when all types are allowed.
+ */
+ channel_types: attr(),
+ /**
+ * The command that will be executed.
+ */
+ help: attr(),
+ /**
+ * The keyword to use a specific command.
+ */
+ name: attr(),
+ };
+
+ ChannelCommand.modelName = 'mail.channel_command';
+
+ return ChannelCommand;
+}
+
+registerNewModel('mail.channel_command', factory);
+
+});
diff --git a/addons/mail/static/src/models/chat_window/chat_window.js b/addons/mail/static/src/models/chat_window/chat_window.js
new file mode 100644
index 00000000..49e22742
--- /dev/null
+++ b/addons/mail/static/src/models/chat_window/chat_window.js
@@ -0,0 +1,480 @@
+odoo.define('mail/static/src/models/chat_window/chat_window.js', function (require) {
+'use strict';
+
+const { registerNewModel } = require('mail/static/src/model/model_core.js');
+const { attr, many2one, one2many, one2one } = require('mail/static/src/model/model_field.js');
+const { clear } = require('mail/static/src/model/model_field_command.js');
+
+function factory(dependencies) {
+
+ class ChatWindow extends dependencies['mail.model'] {
+
+ /**
+ * @override
+ */
+ _created() {
+ const res = super._created(...arguments);
+ this._onShowHomeMenu.bind(this);
+ this._onHideHomeMenu.bind(this);
+
+ this.env.messagingBus.on('hide_home_menu', this, this._onHideHomeMenu);
+ this.env.messagingBus.on('show_home_menu', this, this._onShowHomeMenu);
+ return res;
+ }
+
+ /**
+ * @override
+ */
+ _willDelete() {
+ this.env.messagingBus.off('hide_home_menu', this, this._onHideHomeMenu);
+ this.env.messagingBus.off('show_home_menu', this, this._onShowHomeMenu);
+ return super._willDelete(...arguments);
+ }
+
+ //----------------------------------------------------------------------
+ // Public
+ //----------------------------------------------------------------------
+
+ /**
+ * Close this chat window.
+ *
+ * @param {Object} [param0={}]
+ * @param {boolean} [param0.notifyServer]
+ */
+ close({ notifyServer } = {}) {
+ if (notifyServer === undefined) {
+ notifyServer = !this.env.messaging.device.isMobile;
+ }
+ const thread = this.thread;
+ this.delete();
+ // Flux specific: 'closed' fold state should only be saved on the
+ // server when manually closing the chat window. Delete at destroy
+ // or sync from server value for example should not save the value.
+ if (thread && notifyServer) {
+ thread.notifyFoldStateToServer('closed');
+ }
+ if (this.env.device.isMobile && !this.env.messaging.discuss.isOpen) {
+ // If we are in mobile and discuss is not open, it means the
+ // chat window was opened from the messaging menu. In that
+ // case it should be re-opened to simulate it was always
+ // there in the background.
+ this.env.messaging.messagingMenu.update({ isOpen: true });
+ }
+ }
+
+ expand() {
+ if (this.thread) {
+ this.thread.open({ expanded: true });
+ }
+ }
+
+ /**
+ * Programmatically auto-focus an existing chat window.
+ */
+ focus() {
+ this.update({ isDoFocus: true });
+ }
+
+ focusNextVisibleUnfoldedChatWindow() {
+ const nextVisibleUnfoldedChatWindow = this._getNextVisibleUnfoldedChatWindow();
+ if (nextVisibleUnfoldedChatWindow) {
+ nextVisibleUnfoldedChatWindow.focus();
+ }
+ }
+
+ focusPreviousVisibleUnfoldedChatWindow() {
+ const previousVisibleUnfoldedChatWindow =
+ this._getNextVisibleUnfoldedChatWindow({ reverse: true });
+ if (previousVisibleUnfoldedChatWindow) {
+ previousVisibleUnfoldedChatWindow.focus();
+ }
+ }
+
+ /**
+ * @param {Object} [param0={}]
+ * @param {boolean} [param0.notifyServer]
+ */
+ fold({ notifyServer } = {}) {
+ if (notifyServer === undefined) {
+ notifyServer = !this.env.messaging.device.isMobile;
+ }
+ this.update({ isFolded: true });
+ // Flux specific: manually folding the chat window should save the
+ // new state on the server.
+ if (this.thread && notifyServer) {
+ this.thread.notifyFoldStateToServer('folded');
+ }
+ }
+
+ /**
+ * Makes this chat window active, which consists of making it visible,
+ * unfolding it, and focusing it.
+ *
+ * @param {Object} [options]
+ */
+ makeActive(options) {
+ this.makeVisible();
+ this.unfold(options);
+ this.focus();
+ }
+
+ /**
+ * Makes this chat window visible by swapping it with the last visible
+ * chat window, or do nothing if it is already visible.
+ */
+ makeVisible() {
+ if (this.isVisible) {
+ return;
+ }
+ const lastVisible = this.manager.lastVisible;
+ this.manager.swap(this, lastVisible);
+ }
+
+ /**
+ * Shift this chat window to the left on screen.
+ */
+ shiftLeft() {
+ this.manager.shiftLeft(this);
+ }
+
+ /**
+ * Shift this chat window to the right on screen.
+ */
+ shiftRight() {
+ this.manager.shiftRight(this);
+ }
+
+ /**
+ * @param {Object} [param0={}]
+ * @param {boolean} [param0.notifyServer]
+ */
+ unfold({ notifyServer } = {}) {
+ if (notifyServer === undefined) {
+ notifyServer = !this.env.messaging.device.isMobile;
+ }
+ this.update({ isFolded: false });
+ // Flux specific: manually opening the chat window should save the
+ // new state on the server.
+ if (this.thread && notifyServer) {
+ this.thread.notifyFoldStateToServer('open');
+ }
+ }
+
+ //----------------------------------------------------------------------
+ // Private
+ //----------------------------------------------------------------------
+
+ /**
+ * @private
+ * @returns {boolean}
+ */
+ _computeHasNewMessageForm() {
+ return this.isVisible && !this.isFolded && !this.thread;
+ }
+
+ /**
+ * @private
+ * @returns {boolean}
+ */
+ _computeHasShiftLeft() {
+ if (!this.manager) {
+ return false;
+ }
+ const allVisible = this.manager.allOrderedVisible;
+ const index = allVisible.findIndex(visible => visible === this);
+ if (index === -1) {
+ return false;
+ }
+ return index < allVisible.length - 1;
+ }
+
+ /**
+ * @private
+ * @returns {boolean}
+ */
+ _computeHasShiftRight() {
+ if (!this.manager) {
+ return false;
+ }
+ const index = this.manager.allOrderedVisible.findIndex(visible => visible === this);
+ if (index === -1) {
+ return false;
+ }
+ return index > 0;
+ }
+
+ /**
+ * @private
+ * @returns {boolean}
+ */
+ _computeHasThreadView() {
+ return this.isVisible && !this.isFolded && this.thread;
+ }
+
+ /**
+ * @private
+ * @returns {boolean}
+ */
+ _computeIsFolded() {
+ const thread = this.thread;
+ if (thread) {
+ return thread.foldState === 'folded';
+ }
+ return this.isFolded;
+ }
+
+ /**
+ * @private
+ * @returns {boolean}
+ */
+ _computeIsVisible() {
+ if (!this.manager) {
+ return false;
+ }
+ return this.manager.allOrderedVisible.includes(this);
+ }
+
+ /**
+ * @private
+ * @returns {string}
+ */
+ _computeName() {
+ if (this.thread) {
+ return this.thread.displayName;
+ }
+ return this.env._t("New message");
+ }
+
+ /**
+ * @private
+ * @returns {integer|undefined}
+ */
+ _computeVisibleIndex() {
+ if (!this.manager) {
+ return clear();
+ }
+ const visible = this.manager.visual.visible;
+ const index = visible.findIndex(visible => visible.chatWindowLocalId === this.localId);
+ if (index === -1) {
+ return clear();
+ }
+ return index;
+ }
+
+ /**
+ * @private
+ * @returns {integer}
+ */
+ _computeVisibleOffset() {
+ if (!this.manager) {
+ return 0;
+ }
+ const visible = this.manager.visual.visible;
+ const index = visible.findIndex(visible => visible.chatWindowLocalId === this.localId);
+ if (index === -1) {
+ return 0;
+ }
+ return visible[index].offset;
+ }
+
+ /**
+ * Cycles to the next possible visible and unfolded chat window starting
+ * from the `currentChatWindow`, following the natural order based on the
+ * current text direction, and with the possibility to `reverse` based on
+ * the given parameter.
+ *
+ * @private
+ * @param {Object} [param0={}]
+ * @param {boolean} [param0.reverse=false]
+ * @returns {mail.chat_window|undefined}
+ */
+ _getNextVisibleUnfoldedChatWindow({ reverse = false } = {}) {
+ const orderedVisible = this.manager.allOrderedVisible;
+ /**
+ * Return index of next visible chat window of a given visible chat
+ * window index. The direction of "next" chat window depends on
+ * `reverse` option.
+ *
+ * @param {integer} index
+ * @returns {integer}
+ */
+ const _getNextIndex = index => {
+ const directionOffset = reverse ? 1 : -1;
+ let nextIndex = index + directionOffset;
+ if (nextIndex > orderedVisible.length - 1) {
+ nextIndex = 0;
+ }
+ if (nextIndex < 0) {
+ nextIndex = orderedVisible.length - 1;
+ }
+ return nextIndex;
+ };
+
+ const currentIndex = orderedVisible.findIndex(visible => visible === this);
+ let nextIndex = _getNextIndex(currentIndex);
+ let nextToFocus = orderedVisible[nextIndex];
+ while (nextToFocus.isFolded) {
+ nextIndex = _getNextIndex(nextIndex);
+ nextToFocus = orderedVisible[nextIndex];
+ }
+ return nextToFocus;
+ }
+
+ //----------------------------------------------------------------------
+ // Handlers
+ //----------------------------------------------------------------------
+
+ /**
+ * @private
+ */
+ async _onHideHomeMenu() {
+ if (!this.threadView) {
+ return;
+ }
+ this.threadView.addComponentHint('home-menu-hidden');
+ }
+
+ /**
+ * @private
+ */
+ async _onShowHomeMenu() {
+ if (!this.threadView) {
+ return;
+ }
+ this.threadView.addComponentHint('home-menu-shown');
+ }
+
+ }
+
+ ChatWindow.fields = {
+ /**
+ * Determines whether "new message form" should be displayed.
+ */
+ hasNewMessageForm: attr({
+ compute: '_computeHasNewMessageForm',
+ dependencies: [
+ 'isFolded',
+ 'isVisible',
+ 'thread',
+ ],
+ }),
+ hasShiftLeft: attr({
+ compute: '_computeHasShiftLeft',
+ dependencies: ['managerAllOrderedVisible'],
+ default: false,
+ }),
+ hasShiftRight: attr({
+ compute: '_computeHasShiftRight',
+ dependencies: ['managerAllOrderedVisible'],
+ default: false,
+ }),
+ /**
+ * Determines whether `this.thread` should be displayed.
+ */
+ hasThreadView: attr({
+ compute: '_computeHasThreadView',
+ dependencies: [
+ 'isFolded',
+ 'isVisible',
+ 'thread',
+ ],
+ }),
+ /**
+ * Determine whether the chat window should be programmatically
+ * focused by observed component of chat window. Those components
+ * are responsible to unmark this record afterwards, otherwise
+ * any re-render will programmatically set focus again!
+ */
+ isDoFocus: attr({
+ default: false,
+ }),
+ /**
+ * States whether `this` is focused. Useful for visual clue.
+ */
+ isFocused: attr({
+ default: false,
+ }),
+ /**
+ * Determines whether `this` is folded.
+ */
+ isFolded: attr({
+ default: false,
+ }),
+ /**
+ * States whether `this` is visible or not. Should be considered
+ * read-only. Setting this value manually will not make it visible.
+ * @see `makeVisible`
+ */
+ isVisible: attr({
+ compute: '_computeIsVisible',
+ dependencies: [
+ 'managerAllOrderedVisible',
+ ],
+ }),
+ manager: many2one('mail.chat_window_manager', {
+ inverse: 'chatWindows',
+ }),
+ managerAllOrderedVisible: one2many('mail.chat_window', {
+ related: 'manager.allOrderedVisible',
+ }),
+ managerVisual: attr({
+ related: 'manager.visual',
+ }),
+ name: attr({
+ compute: '_computeName',
+ dependencies: [
+ 'thread',
+ 'threadDisplayName',
+ ],
+ }),
+ /**
+ * Determines the `mail.thread` that should be displayed by `this`.
+ * If no `mail.thread` is linked, `this` is considered "new message".
+ */
+ thread: one2one('mail.thread', {
+ inverse: 'chatWindow',
+ }),
+ threadDisplayName: attr({
+ related: 'thread.displayName',
+ }),
+ /**
+ * States the `mail.thread_view` displaying `this.thread`.
+ */
+ threadView: one2one('mail.thread_view', {
+ related: 'threadViewer.threadView',
+ }),
+ /**
+ * Determines the `mail.thread_viewer` managing the display of `this.thread`.
+ */
+ threadViewer: one2one('mail.thread_viewer', {
+ default: [['create']],
+ inverse: 'chatWindow',
+ isCausal: true,
+ }),
+ /**
+ * This field handle the "order" (index) of the visible chatWindow inside the UI.
+ *
+ * Using LTR, the right-most chat window has index 0, and the number is incrementing from right to left.
+ * Using RTL, the left-most chat window has index 0, and the number is incrementing from left to right.
+ */
+ visibleIndex: attr({
+ compute: '_computeVisibleIndex',
+ dependencies: [
+ 'manager',
+ 'managerVisual',
+ ],
+ }),
+ visibleOffset: attr({
+ compute: '_computeVisibleOffset',
+ dependencies: ['managerVisual'],
+ }),
+ };
+
+ ChatWindow.modelName = 'mail.chat_window';
+
+ return ChatWindow;
+}
+
+registerNewModel('mail.chat_window', factory);
+
+});
diff --git a/addons/mail/static/src/models/chat_window_manager/chat_window_manager.js b/addons/mail/static/src/models/chat_window_manager/chat_window_manager.js
new file mode 100644
index 00000000..fc367fef
--- /dev/null
+++ b/addons/mail/static/src/models/chat_window_manager/chat_window_manager.js
@@ -0,0 +1,487 @@
+odoo.define('mail/static/src/models/chat_window_manager/chat_window_manager.js', function (require) {
+'use strict';
+
+const { registerNewModel } = require('mail/static/src/model/model_core.js');
+const { attr, many2one, one2many, one2one } = require('mail/static/src/model/model_field.js');
+
+function factory(dependencies) {
+
+ const BASE_VISUAL = {
+ /**
+ * Amount of visible slots available for chat windows.
+ */
+ availableVisibleSlots: 0,
+ /**
+ * Data related to the hidden menu.
+ */
+ hidden: {
+ /**
+ * List of hidden docked chat windows. Useful to compute counter.
+ * Chat windows are ordered by their `chatWindows` order.
+ */
+ chatWindowLocalIds: [],
+ /**
+ * Whether hidden menu is visible or not
+ */
+ isVisible: false,
+ /**
+ * Offset of hidden menu starting point from the starting point
+ * of chat window manager. Makes only sense if it is visible.
+ */
+ offset: 0,
+ },
+ /**
+ * Data related to visible chat windows. Index determine order of
+ * docked chat windows.
+ *
+ * Value:
+ *
+ * {
+ * chatWindowLocalId,
+ * offset,
+ * }
+ *
+ * Offset is offset of starting point of docked chat window from
+ * starting point of dock chat window manager. Docked chat windows
+ * are ordered by their `chatWindows` order
+ */
+ visible: [],
+ };
+
+
+ class ChatWindowManager extends dependencies['mail.model'] {
+
+ //----------------------------------------------------------------------
+ // Public
+ //----------------------------------------------------------------------
+
+ /**
+ * Close all chat windows.
+ *
+ */
+ closeAll() {
+ const chatWindows = this.allOrdered;
+ for (const chatWindow of chatWindows) {
+ chatWindow.close();
+ }
+ }
+
+ closeHiddenMenu() {
+ this.update({ isHiddenMenuOpen: false });
+ }
+
+ /**
+ * Closes all chat windows related to the given thread.
+ *
+ * @param {mail.thread} thread
+ * @param {Object} [options]
+ */
+ closeThread(thread, options) {
+ for (const chatWindow of this.chatWindows) {
+ if (chatWindow.thread === thread) {
+ chatWindow.close(options);
+ }
+ }
+ }
+
+ openHiddenMenu() {
+ this.update({ isHiddenMenuOpen: true });
+ }
+
+ openNewMessage() {
+ let newMessageChatWindow = this.newMessageChatWindow;
+ if (!newMessageChatWindow) {
+ newMessageChatWindow = this.env.models['mail.chat_window'].create({
+ manager: [['link', this]],
+ });
+ }
+ newMessageChatWindow.makeActive();
+ }
+
+ /**
+ * @param {mail.thread} thread
+ * @param {Object} [param1={}]
+ * @param {boolean} [param1.isFolded=false]
+ * @param {boolean} [param1.makeActive=false]
+ * @param {boolean} [param1.notifyServer]
+ * @param {boolean} [param1.replaceNewMessage=false]
+ */
+ openThread(thread, {
+ isFolded = false,
+ makeActive = false,
+ notifyServer,
+ replaceNewMessage = false
+ } = {}) {
+ if (notifyServer === undefined) {
+ notifyServer = !this.env.messaging.device.isMobile;
+ }
+ let chatWindow = this.chatWindows.find(chatWindow =>
+ chatWindow.thread === thread
+ );
+ if (!chatWindow) {
+ chatWindow = this.env.models['mail.chat_window'].create({
+ isFolded,
+ manager: [['link', this]],
+ thread: [['link', thread]],
+ });
+ } else {
+ chatWindow.update({ isFolded });
+ }
+ if (replaceNewMessage && this.newMessageChatWindow) {
+ this.swap(chatWindow, this.newMessageChatWindow);
+ this.newMessageChatWindow.close();
+ }
+ if (makeActive) {
+ // avoid double notify at this step, it will already be done at
+ // the end of the current method
+ chatWindow.makeActive({ notifyServer: false });
+ }
+ // Flux specific: notify server of chat window being opened.
+ if (notifyServer) {
+ const foldState = chatWindow.isFolded ? 'folded' : 'open';
+ thread.notifyFoldStateToServer(foldState);
+ }
+ }
+
+ /**
+ * Shift provided chat window to the left on screen.
+ *
+ * @param {mail.chat_window} chatWindow
+ */
+ shiftLeft(chatWindow) {
+ const chatWindows = this.allOrdered;
+ const index = chatWindows.findIndex(cw => cw === chatWindow);
+ if (index === chatWindows.length - 1) {
+ // already left-most
+ return;
+ }
+ const otherChatWindow = chatWindows[index + 1];
+ const _newOrdered = [...this._ordered];
+ _newOrdered[index] = otherChatWindow.localId;
+ _newOrdered[index + 1] = chatWindow.localId;
+ this.update({ _ordered: _newOrdered });
+ chatWindow.focus();
+ }
+
+ /**
+ * Shift provided chat window to the right on screen.
+ *
+ * @param {mail.chat_window} chatWindow
+ */
+ shiftRight(chatWindow) {
+ const chatWindows = this.allOrdered;
+ const index = chatWindows.findIndex(cw => cw === chatWindow);
+ if (index === 0) {
+ // already right-most
+ return;
+ }
+ const otherChatWindow = chatWindows[index - 1];
+ const _newOrdered = [...this._ordered];
+ _newOrdered[index] = otherChatWindow.localId;
+ _newOrdered[index - 1] = chatWindow.localId;
+ this.update({ _ordered: _newOrdered });
+ chatWindow.focus();
+ }
+
+ /**
+ * @param {mail.chat_window} chatWindow1
+ * @param {mail.chat_window} chatWindow2
+ */
+ swap(chatWindow1, chatWindow2) {
+ const ordered = this.allOrdered;
+ const index1 = ordered.findIndex(chatWindow => chatWindow === chatWindow1);
+ const index2 = ordered.findIndex(chatWindow => chatWindow === chatWindow2);
+ if (index1 === -1 || index2 === -1) {
+ return;
+ }
+ const _newOrdered = [...this._ordered];
+ _newOrdered[index1] = chatWindow2.localId;
+ _newOrdered[index2] = chatWindow1.localId;
+ this.update({ _ordered: _newOrdered });
+ }
+
+ //----------------------------------------------------------------------
+ // Private
+ //----------------------------------------------------------------------
+
+ /**
+ * @private
+ * @returns {string[]}
+ */
+ _compute_ordered() {
+ // remove unlinked chatWindows
+ const _ordered = this._ordered.filter(chatWindowLocalId =>
+ this.chatWindows.includes(this.env.models['mail.chat_window'].get(chatWindowLocalId))
+ );
+ // add linked chatWindows
+ for (const chatWindow of this.chatWindows) {
+ if (!_ordered.includes(chatWindow.localId)) {
+ _ordered.push(chatWindow.localId);
+ }
+ }
+ return _ordered;
+ }
+
+ /**
+ * // FIXME: dependent on implementation that uses arbitrary order in relations!!
+ *
+ * @private
+ * @returns {mail.chat_window}
+ */
+ _computeAllOrdered() {
+ return [['replace', this._ordered.map(chatWindowLocalId =>
+ this.env.models['mail.chat_window'].get(chatWindowLocalId)
+ )]];
+ }
+
+ /**
+ * @private
+ * @returns {mail.chat_window[]}
+ */
+ _computeAllOrderedHidden() {
+ return [['replace', this.visual.hidden.chatWindowLocalIds.map(chatWindowLocalId =>
+ this.env.models['mail.chat_window'].get(chatWindowLocalId)
+ )]];
+ }
+
+ /**
+ * @private
+ * @returns {mail.chat_window[]}
+ */
+ _computeAllOrderedVisible() {
+ return [['replace', this.visual.visible.map(({ chatWindowLocalId }) =>
+ this.env.models['mail.chat_window'].get(chatWindowLocalId)
+ )]];
+ }
+
+ /**
+ * @private
+ * @returns {boolean}
+ */
+ _computeHasHiddenChatWindows() {
+ return this.allOrderedHidden.length > 0;
+ }
+
+ /**
+ * @private
+ * @returns {boolean}
+ */
+ _computeHasVisibleChatWindows() {
+ return this.allOrderedVisible.length > 0;
+ }
+
+ /**
+ * @private
+ * @returns {mail.chat_window|undefined}
+ */
+ _computeLastVisible() {
+ const { length: l, [l - 1]: lastVisible } = this.allOrderedVisible;
+ if (!lastVisible) {
+ return [['unlink']];
+ }
+ return [['link', lastVisible]];
+ }
+
+ /**
+ * @private
+ * @returns {mail.chat_window|undefined}
+ */
+ _computeNewMessageChatWindow() {
+ const chatWindow = this.allOrdered.find(chatWindow => !chatWindow.thread);
+ if (!chatWindow) {
+ return [['unlink']];
+ }
+ return [['link', chatWindow]];
+ }
+
+ /**
+ * @private
+ * @returns {integer}
+ */
+ _computeUnreadHiddenConversationAmount() {
+ const allHiddenWithThread = this.allOrderedHidden.filter(
+ chatWindow => chatWindow.thread
+ );
+ let amount = 0;
+ for (const chatWindow of allHiddenWithThread) {
+ if (chatWindow.thread.localMessageUnreadCounter > 0) {
+ amount++;
+ }
+ }
+ return amount;
+ }
+
+ /**
+ * @private
+ * @returns {Object}
+ */
+ _computeVisual() {
+ let visual = JSON.parse(JSON.stringify(BASE_VISUAL));
+ if (!this.env.messaging) {
+ return visual;
+ }
+ const device = this.env.messaging.device;
+ const discuss = this.env.messaging.discuss;
+ const BETWEEN_GAP_WIDTH = 5;
+ const CHAT_WINDOW_WIDTH = 325;
+ const END_GAP_WIDTH = device.isMobile ? 0 : 10;
+ const GLOBAL_WINDOW_WIDTH = device.globalWindowInnerWidth;
+ const HIDDEN_MENU_WIDTH = 200; // max width, including width of dropup list items
+ const START_GAP_WIDTH = device.isMobile ? 0 : 10;
+ const chatWindows = this.allOrdered;
+ if (!device.isMobile && discuss.isOpen) {
+ return visual;
+ }
+ if (!chatWindows.length) {
+ return visual;
+ }
+ const relativeGlobalWindowWidth = GLOBAL_WINDOW_WIDTH - START_GAP_WIDTH - END_GAP_WIDTH;
+ let maxAmountWithoutHidden = Math.floor(
+ relativeGlobalWindowWidth / (CHAT_WINDOW_WIDTH + BETWEEN_GAP_WIDTH));
+ let maxAmountWithHidden = Math.floor(
+ (relativeGlobalWindowWidth - HIDDEN_MENU_WIDTH - BETWEEN_GAP_WIDTH) /
+ (CHAT_WINDOW_WIDTH + BETWEEN_GAP_WIDTH));
+ if (device.isMobile) {
+ maxAmountWithoutHidden = 1;
+ maxAmountWithHidden = 1;
+ }
+ if (chatWindows.length <= maxAmountWithoutHidden) {
+ // all visible
+ for (let i = 0; i < chatWindows.length; i++) {
+ const chatWindowLocalId = chatWindows[i].localId;
+ const offset = START_GAP_WIDTH + i * (CHAT_WINDOW_WIDTH + BETWEEN_GAP_WIDTH);
+ visual.visible.push({ chatWindowLocalId, offset });
+ }
+ visual.availableVisibleSlots = maxAmountWithoutHidden;
+ } else if (maxAmountWithHidden > 0) {
+ // some visible, some hidden
+ for (let i = 0; i < maxAmountWithHidden; i++) {
+ const chatWindowLocalId = chatWindows[i].localId;
+ const offset = START_GAP_WIDTH + i * (CHAT_WINDOW_WIDTH + BETWEEN_GAP_WIDTH);
+ visual.visible.push({ chatWindowLocalId, offset });
+ }
+ if (chatWindows.length > maxAmountWithHidden) {
+ visual.hidden.isVisible = !device.isMobile;
+ visual.hidden.offset = visual.visible[maxAmountWithHidden - 1].offset
+ + CHAT_WINDOW_WIDTH + BETWEEN_GAP_WIDTH;
+ }
+ for (let j = maxAmountWithHidden; j < chatWindows.length; j++) {
+ visual.hidden.chatWindowLocalIds.push(chatWindows[j].localId);
+ }
+ visual.availableVisibleSlots = maxAmountWithHidden;
+ } else {
+ // all hidden
+ visual.hidden.isVisible = !device.isMobile;
+ visual.hidden.offset = START_GAP_WIDTH;
+ visual.hidden.chatWindowLocalIds.concat(chatWindows.map(chatWindow => chatWindow.localId));
+ console.warn('cannot display any visible chat windows (screen is too small)');
+ visual.availableVisibleSlots = 0;
+ }
+ return visual;
+ }
+
+ }
+
+ ChatWindowManager.fields = {
+ /**
+ * List of ordered chat windows (list of local ids)
+ */
+ _ordered: attr({
+ compute: '_compute_ordered',
+ default: [],
+ dependencies: [
+ 'chatWindows',
+ ],
+ }),
+ // FIXME: dependent on implementation that uses arbitrary order in relations!!
+ allOrdered: one2many('mail.chat_window', {
+ compute: '_computeAllOrdered',
+ dependencies: [
+ '_ordered',
+ ],
+ }),
+ allOrderedThread: one2many('mail.thread', {
+ related: 'allOrdered.thread',
+ }),
+ allOrderedHidden: one2many('mail.chat_window', {
+ compute: '_computeAllOrderedHidden',
+ dependencies: ['visual'],
+ }),
+ allOrderedHiddenThread: one2many('mail.thread', {
+ related: 'allOrderedHidden.thread',
+ }),
+ allOrderedHiddenThreadMessageUnreadCounter: attr({
+ related: 'allOrderedHiddenThread.localMessageUnreadCounter',
+ }),
+ allOrderedVisible: one2many('mail.chat_window', {
+ compute: '_computeAllOrderedVisible',
+ dependencies: ['visual'],
+ }),
+ chatWindows: one2many('mail.chat_window', {
+ inverse: 'manager',
+ isCausal: true,
+ }),
+ device: one2one('mail.device', {
+ related: 'messaging.device',
+ }),
+ deviceGlobalWindowInnerWidth: attr({
+ related: 'device.globalWindowInnerWidth',
+ }),
+ deviceIsMobile: attr({
+ related: 'device.isMobile',
+ }),
+ discuss: one2one('mail.discuss', {
+ related: 'messaging.discuss',
+ }),
+ discussIsOpen: attr({
+ related: 'discuss.isOpen',
+ }),
+ hasHiddenChatWindows: attr({
+ compute: '_computeHasHiddenChatWindows',
+ dependencies: ['allOrderedHidden'],
+ }),
+ hasVisibleChatWindows: attr({
+ compute: '_computeHasVisibleChatWindows',
+ dependencies: ['allOrderedVisible'],
+ }),
+ isHiddenMenuOpen: attr({
+ default: false,
+ }),
+ lastVisible: many2one('mail.chat_window', {
+ compute: '_computeLastVisible',
+ dependencies: ['allOrderedVisible'],
+ }),
+ messaging: one2one('mail.messaging', {
+ inverse: 'chatWindowManager',
+ }),
+ newMessageChatWindow: one2one('mail.chat_window', {
+ compute: '_computeNewMessageChatWindow',
+ dependencies: [
+ 'allOrdered',
+ 'allOrderedThread',
+ ],
+ }),
+ unreadHiddenConversationAmount: attr({
+ compute: '_computeUnreadHiddenConversationAmount',
+ dependencies: ['allOrderedHiddenThreadMessageUnreadCounter'],
+ }),
+ visual: attr({
+ compute: '_computeVisual',
+ default: BASE_VISUAL,
+ dependencies: [
+ 'allOrdered',
+ 'deviceGlobalWindowInnerWidth',
+ 'deviceIsMobile',
+ 'discussIsOpen',
+ ],
+ }),
+ };
+
+ ChatWindowManager.modelName = 'mail.chat_window_manager';
+
+ return ChatWindowManager;
+}
+
+registerNewModel('mail.chat_window_manager', factory);
+
+});
diff --git a/addons/mail/static/src/models/chatter/chatter.js b/addons/mail/static/src/models/chatter/chatter.js
new file mode 100644
index 00000000..84f611eb
--- /dev/null
+++ b/addons/mail/static/src/models/chatter/chatter.js
@@ -0,0 +1,334 @@
+odoo.define('mail/static/src/models/chatter/chatter.js', function (require) {
+'use strict';
+
+const { registerNewModel } = require('mail/static/src/model/model_core.js');
+const { attr, many2one, one2one } = require('mail/static/src/model/model_field.js');
+
+function factory(dependencies) {
+
+ const getThreadNextTemporaryId = (function () {
+ let tmpId = 0;
+ return () => {
+ tmpId -= 1;
+ return tmpId;
+ };
+ })();
+
+ const getMessageNextTemporaryId = (function () {
+ let tmpId = 0;
+ return () => {
+ tmpId -= 1;
+ return tmpId;
+ };
+ })();
+
+ class Chatter extends dependencies['mail.model'] {
+
+ /**
+ * @override
+ */
+ _willDelete() {
+ this._stopAttachmentsLoading();
+ return super._willDelete(...arguments);
+ }
+
+ //----------------------------------------------------------------------
+ // Public
+ //----------------------------------------------------------------------
+
+ focus() {
+ this.update({ isDoFocus: true });
+ }
+
+ async refresh() {
+ if (this.hasActivities) {
+ this.thread.refreshActivities();
+ }
+ if (this.hasFollowers) {
+ this.thread.refreshFollowers();
+ this.thread.fetchAndUpdateSuggestedRecipients();
+ }
+ if (this.hasMessageList) {
+ this.thread.refresh();
+ }
+ }
+
+ showLogNote() {
+ this.update({ isComposerVisible: true });
+ this.thread.composer.update({ isLog: true });
+ this.focus();
+ }
+
+ showSendMessage() {
+ this.update({ isComposerVisible: true });
+ this.thread.composer.update({ isLog: false });
+ this.focus();
+ }
+
+ toggleActivityBoxVisibility() {
+ this.update({ isActivityBoxVisible: !this.isActivityBoxVisible });
+ }
+
+ //----------------------------------------------------------------------
+ // Private
+ //----------------------------------------------------------------------
+
+ /**
+ * @private
+ * @returns {boolean}
+ */
+ _computeHasThreadView() {
+ return this.thread && this.hasMessageList;
+ }
+
+ /**
+ * @private
+ * @returns {boolean}
+ */
+ _computeIsDisabled() {
+ return !this.thread || this.thread.isTemporary;
+ }
+
+ /**
+ * @private
+ */
+ _onThreadIdOrThreadModelChanged() {
+ if (this.threadId) {
+ if (this.thread && this.thread.isTemporary) {
+ this.thread.delete();
+ }
+ this.update({
+ isAttachmentBoxVisible: this.isAttachmentBoxVisibleInitially,
+ thread: [['insert', {
+ // If the thread was considered to have the activity
+ // mixin once, it will have it forever.
+ hasActivities: this.hasActivities ? true : undefined,
+ id: this.threadId,
+ model: this.threadModel,
+ }]],
+ });
+ if (this.hasActivities) {
+ this.thread.refreshActivities();
+ }
+ if (this.hasFollowers) {
+ this.thread.refreshFollowers();
+ this.thread.fetchAndUpdateSuggestedRecipients();
+ }
+ if (this.hasMessageList) {
+ this.thread.refresh();
+ }
+ } else if (!this.thread || !this.thread.isTemporary) {
+ const currentPartner = this.env.messaging.currentPartner;
+ const message = this.env.models['mail.message'].create({
+ author: [['link', currentPartner]],
+ body: this.env._t("Creating a new record..."),
+ id: getMessageNextTemporaryId(),
+ isTemporary: true,
+ });
+ const nextId = getThreadNextTemporaryId();
+ this.update({
+ isAttachmentBoxVisible: false,
+ thread: [['insert', {
+ areAttachmentsLoaded: true,
+ id: nextId,
+ isTemporary: true,
+ model: this.threadModel,
+ }]],
+ });
+ for (const cache of this.thread.caches) {
+ cache.update({ messages: [['link', message]] });
+ }
+ }
+ }
+
+ /**
+ * @private
+ */
+ _onThreadIsLoadingAttachmentsChanged() {
+ if (!this.thread || !this.thread.isLoadingAttachments) {
+ this._stopAttachmentsLoading();
+ return;
+ }
+ if (this._isPreparingAttachmentsLoading || this.isShowingAttachmentsLoading) {
+ return;
+ }
+ this._prepareAttachmentsLoading();
+ }
+
+ /**
+ * @private
+ */
+ _prepareAttachmentsLoading() {
+ this._isPreparingAttachmentsLoading = true;
+ this._attachmentsLoaderTimeout = this.env.browser.setTimeout(() => {
+ this.update({ isShowingAttachmentsLoading: true });
+ this._isPreparingAttachmentsLoading = false;
+ }, this.env.loadingBaseDelayDuration);
+ }
+
+ /**
+ * @private
+ */
+ _stopAttachmentsLoading() {
+ this.env.browser.clearTimeout(this._attachmentsLoaderTimeout);
+ this._attachmentsLoaderTimeout = null;
+ this.update({ isShowingAttachmentsLoading: false });
+ this._isPreparingAttachmentsLoading = false;
+ }
+
+ }
+
+ Chatter.fields = {
+ composer: many2one('mail.composer', {
+ related: 'thread.composer',
+ }),
+ context: attr({
+ default: {},
+ }),
+ /**
+ * Determines whether `this` should display an activity box.
+ */
+ hasActivities: attr({
+ default: true,
+ }),
+ hasExternalBorder: attr({
+ default: true,
+ }),
+ /**
+ * Determines whether `this` should display followers menu.
+ */
+ hasFollowers: attr({
+ default: true,
+ }),
+ /**
+ * Determines whether `this` should display a message list.
+ */
+ hasMessageList: attr({
+ default: true,
+ }),
+ /**
+ * Whether the message list should manage its scroll.
+ * In particular, when the chatter is on the form view's side,
+ * then the scroll is managed by the message list.
+ * Also, the message list shoud not manage the scroll if it shares it
+ * with the rest of the page.
+ */
+ hasMessageListScrollAdjust: attr({
+ default: false,
+ }),
+ /**
+ * Determines whether `this.thread` should be displayed.
+ */
+ hasThreadView: attr({
+ compute: '_computeHasThreadView',
+ dependencies: [
+ 'hasMessageList',
+ 'thread',
+ ],
+ }),
+ hasTopbarCloseButton: attr({
+ default: false,
+ }),
+ isActivityBoxVisible: attr({
+ default: true,
+ }),
+ /**
+ * Determiners whether the attachment box is currently visible.
+ */
+ isAttachmentBoxVisible: attr({
+ default: false,
+ }),
+ /**
+ * Determiners whether the attachment box is visible initially.
+ */
+ isAttachmentBoxVisibleInitially: attr({
+ default: false,
+ }),
+ isComposerVisible: attr({
+ default: false,
+ }),
+ isDisabled: attr({
+ compute: '_computeIsDisabled',
+ default: false,
+ dependencies: [
+ 'threadIsTemporary',
+ ],
+ }),
+ /**
+ * Determine whether this chatter should be focused at next render.
+ */
+ isDoFocus: attr({
+ default: false,
+ }),
+ isShowingAttachmentsLoading: attr({
+ default: false,
+ }),
+ /**
+ * Not a real field, used to trigger its compute method when one of the
+ * dependencies changes.
+ */
+ onThreadIdOrThreadModelChanged: attr({
+ compute: '_onThreadIdOrThreadModelChanged',
+ dependencies: [
+ 'threadId',
+ 'threadModel',
+ ],
+ }),
+ /**
+ * Not a real field, used to trigger its compute method when one of the
+ * dependencies changes.
+ */
+ onThreadIsLoadingAttachmentsChanged: attr({
+ compute: '_onThreadIsLoadingAttachmentsChanged',
+ dependencies: [
+ 'threadIsLoadingAttachments',
+ ],
+ }),
+ /**
+ * Determines the `mail.thread` that should be displayed by `this`.
+ */
+ thread: many2one('mail.thread'),
+ /**
+ * Determines the id of the thread that will be displayed by `this`.
+ */
+ threadId: attr(),
+ /**
+ * Serves as compute dependency.
+ */
+ threadIsLoadingAttachments: attr({
+ related: 'thread.isLoadingAttachments',
+ }),
+ /**
+ * Serves as compute dependency.
+ */
+ threadIsTemporary: attr({
+ related: 'thread.isTemporary',
+ }),
+ /**
+ * Determines the model of the thread that will be displayed by `this`.
+ */
+ threadModel: attr(),
+ /**
+ * States the `mail.thread_view` displaying `this.thread`.
+ */
+ threadView: one2one('mail.thread_view', {
+ related: 'threadViewer.threadView',
+ }),
+ /**
+ * Determines the `mail.thread_viewer` managing the display of `this.thread`.
+ */
+ threadViewer: one2one('mail.thread_viewer', {
+ default: [['create']],
+ inverse: 'chatter',
+ isCausal: true,
+ }),
+ };
+
+ Chatter.modelName = 'mail.chatter';
+
+ return Chatter;
+}
+
+registerNewModel('mail.chatter', factory);
+
+});
diff --git a/addons/mail/static/src/models/composer/composer.js b/addons/mail/static/src/models/composer/composer.js
new file mode 100644
index 00000000..d20520e3
--- /dev/null
+++ b/addons/mail/static/src/models/composer/composer.js
@@ -0,0 +1,1435 @@
+odoo.define('mail/static/src/models/composer/composer.js', function (require) {
+'use strict';
+
+const emojis = require('mail.emojis');
+const { registerNewModel } = require('mail/static/src/model/model_core.js');
+const { attr, many2many, many2one, one2one } = require('mail/static/src/model/model_field.js');
+const { clear } = require('mail/static/src/model/model_field_command.js');
+const mailUtils = require('mail.utils');
+
+const {
+ addLink,
+ escapeAndCompactTextContent,
+ parseAndTransform,
+} = require('mail.utils');
+
+function factory(dependencies) {
+
+ class Composer extends dependencies['mail.model'] {
+
+ /**
+ * @override
+ */
+ _willCreate() {
+ const res = super._willCreate(...arguments);
+ /**
+ * Determines whether there is a mention RPC currently in progress.
+ * Useful to queue a new call if there is already one pending.
+ */
+ this._hasMentionRpcInProgress = false;
+ /**
+ * Determines the next function to execute after the current mention
+ * RPC is done, if any.
+ */
+ this._nextMentionRpcFunction = undefined;
+ return res;
+ }
+
+ /**
+ * @override
+ */
+ _willDelete() {
+ // Clears the mention queue on deleting the record to prevent
+ // unnecessary RPC.
+ this._nextMentionRpcFunction = undefined;
+ return super._willDelete(...arguments);
+ }
+
+ //----------------------------------------------------------------------
+ // Public
+ //----------------------------------------------------------------------
+
+ /**
+ * Closes the suggestion list.
+ */
+ closeSuggestions() {
+ this.update({ suggestionDelimiterPosition: clear() });
+ }
+
+ /**
+ * @deprecated what this method used to do is now automatically computed
+ * based on composer state
+ */
+ async detectSuggestionDelimiter() {}
+
+ /**
+ * Hides the composer, which only makes sense if the composer is
+ * currently used as a Discuss Inbox reply composer.
+ */
+ discard() {
+ if (this.discussAsReplying) {
+ this.discussAsReplying.clearReplyingToMessage();
+ }
+ }
+
+ /**
+ * Focus this composer and remove focus from all others.
+ * Focus is a global concern, it makes no sense to have multiple composers focused at the
+ * same time.
+ */
+ focus() {
+ const allComposers = this.env.models['mail.composer'].all();
+ for (const otherComposer of allComposers) {
+ if (otherComposer !== this && otherComposer.hasFocus) {
+ otherComposer.update({ hasFocus: false });
+ }
+ }
+ this.update({ hasFocus: true });
+ }
+
+ /**
+ * Inserts text content in text input based on selection.
+ *
+ * @param {string} content
+ */
+ insertIntoTextInput(content) {
+ const partA = this.textInputContent.slice(0, this.textInputCursorStart);
+ const partB = this.textInputContent.slice(
+ this.textInputCursorEnd,
+ this.textInputContent.length
+ );
+ let suggestionDelimiterPosition = this.suggestionDelimiterPosition;
+ if (
+ suggestionDelimiterPosition !== undefined &&
+ suggestionDelimiterPosition >= this.textInputCursorStart
+ ) {
+ suggestionDelimiterPosition = suggestionDelimiterPosition + content.length;
+ }
+ this.update({
+ isLastStateChangeProgrammatic: true,
+ suggestionDelimiterPosition,
+ textInputContent: partA + content + partB,
+ textInputCursorEnd: this.textInputCursorStart + content.length,
+ textInputCursorStart: this.textInputCursorStart + content.length,
+ });
+ }
+
+ insertSuggestion() {
+ const cursorPosition = this.textInputCursorStart;
+ let textLeft = this.textInputContent.substring(
+ 0,
+ this.suggestionDelimiterPosition + 1
+ );
+ let textRight = this.textInputContent.substring(
+ cursorPosition,
+ this.textInputContent.length
+ );
+ if (this.suggestionDelimiter === ':') {
+ textLeft = this.textInputContent.substring(
+ 0,
+ this.suggestionDelimiterPosition
+ );
+ textRight = this.textInputContent.substring(
+ cursorPosition,
+ this.textInputContent.length
+ );
+ }
+ const recordReplacement = this.activeSuggestedRecord.getMentionText();
+ const updateData = {
+ isLastStateChangeProgrammatic: true,
+ textInputContent: textLeft + recordReplacement + ' ' + textRight,
+ textInputCursorEnd: textLeft.length + recordReplacement.length + 1,
+ textInputCursorStart: textLeft.length + recordReplacement.length + 1,
+ };
+ // Specific cases for channel and partner mentions: the message with
+ // the mention will appear in the target channel, or be notified to
+ // the target partner.
+ switch (this.activeSuggestedRecord.constructor.modelName) {
+ case 'mail.thread':
+ Object.assign(updateData, { mentionedChannels: [['link', this.activeSuggestedRecord]] });
+ break;
+ case 'mail.partner':
+ Object.assign(updateData, { mentionedPartners: [['link', this.activeSuggestedRecord]] });
+ break;
+ }
+ this.update(updateData);
+ }
+
+ /**
+ * @private
+ * @returns {mail.partner[]}
+ */
+ _computeRecipients() {
+ const recipients = [...this.mentionedPartners];
+ if (this.thread && !this.isLog) {
+ for (const recipient of this.thread.suggestedRecipientInfoList) {
+ if (recipient.partner && recipient.isSelected) {
+ recipients.push(recipient.partner);
+ }
+ }
+ }
+ return [['replace', recipients]];
+ }
+
+ /**
+ * Open the full composer modal.
+ */
+ async openFullComposer() {
+ const attachmentIds = this.attachments.map(attachment => attachment.id);
+
+ const context = {
+ default_attachment_ids: attachmentIds,
+ default_body: mailUtils.escapeAndCompactTextContent(this.textInputContent),
+ default_is_log: this.isLog,
+ default_model: this.thread.model,
+ default_partner_ids: this.recipients.map(partner => partner.id),
+ default_res_id: this.thread.id,
+ mail_post_autofollow: true,
+ };
+
+ const action = {
+ type: 'ir.actions.act_window',
+ res_model: 'mail.compose.message',
+ view_mode: 'form',
+ views: [[false, 'form']],
+ target: 'new',
+ context: context,
+ };
+ const options = {
+ on_close: () => {
+ if (!this.exists()) {
+ return;
+ }
+ this._reset();
+ this.thread.loadNewMessages();
+ },
+ };
+ await this.env.bus.trigger('do-action', { action, options });
+ }
+
+ /**
+ * Post a message in provided composer's thread based on current composer fields values.
+ */
+ async postMessage() {
+ const thread = this.thread;
+ this.thread.unregisterCurrentPartnerIsTyping({ immediateNotify: true });
+ const escapedAndCompactContent = escapeAndCompactTextContent(this.textInputContent);
+ let body = escapedAndCompactContent.replace(/&nbsp;/g, ' ').trim();
+ // This message will be received from the mail composer as html content
+ // subtype but the urls will not be linkified. If the mail composer
+ // takes the responsibility to linkify the urls we end up with double
+ // linkification a bit everywhere. Ideally we want to keep the content
+ // as text internally and only make html enrichment at display time but
+ // the current design makes this quite hard to do.
+ body = this._generateMentionsLinks(body);
+ body = parseAndTransform(body, addLink);
+ body = this._generateEmojisOnHtml(body);
+ let postData = {
+ attachment_ids: this.attachments.map(attachment => attachment.id),
+ body,
+ channel_ids: this.mentionedChannels.map(channel => channel.id),
+ message_type: 'comment',
+ partner_ids: this.recipients.map(partner => partner.id),
+ };
+ if (this.subjectContent) {
+ postData.subject = this.subjectContent;
+ }
+ try {
+ let messageId;
+ this.update({ isPostingMessage: true });
+ if (thread.model === 'mail.channel') {
+ const command = this._getCommandFromText(body);
+ Object.assign(postData, {
+ subtype_xmlid: 'mail.mt_comment',
+ });
+ if (command) {
+ messageId = await this.async(() => this.env.models['mail.thread'].performRpcExecuteCommand({
+ channelId: thread.id,
+ command: command.name,
+ postData,
+ }));
+ } else {
+ messageId = await this.async(() =>
+ this.env.models['mail.thread'].performRpcMessagePost({
+ postData,
+ threadId: thread.id,
+ threadModel: thread.model,
+ })
+ );
+ }
+ } else {
+ Object.assign(postData, {
+ subtype_xmlid: this.isLog ? 'mail.mt_note' : 'mail.mt_comment',
+ });
+ if (!this.isLog) {
+ postData.context = {
+ mail_post_autofollow: true,
+ };
+ }
+ messageId = await this.async(() =>
+ this.env.models['mail.thread'].performRpcMessagePost({
+ postData,
+ threadId: thread.id,
+ threadModel: thread.model,
+ })
+ );
+ const [messageData] = await this.async(() => this.env.services.rpc({
+ model: 'mail.message',
+ method: 'message_format',
+ args: [[messageId]],
+ }, { shadow: true }));
+ this.env.models['mail.message'].insert(Object.assign(
+ {},
+ this.env.models['mail.message'].convertData(messageData),
+ {
+ originThread: [['insert', {
+ id: thread.id,
+ model: thread.model,
+ }]],
+ })
+ );
+ thread.loadNewMessages();
+ }
+ for (const threadView of this.thread.threadViews) {
+ // Reset auto scroll to be able to see the newly posted message.
+ threadView.update({ hasAutoScrollOnMessageReceived: true });
+ }
+ thread.refreshFollowers();
+ thread.fetchAndUpdateSuggestedRecipients();
+ this._reset();
+ } finally {
+ this.update({ isPostingMessage: false });
+ }
+ }
+
+ /**
+ * Called when current partner is inserting some input in composer.
+ * Useful to notify current partner is currently typing something in the
+ * composer of this thread to all other members.
+ */
+ handleCurrentPartnerIsTyping() {
+ if (!this.thread) {
+ return;
+ }
+ if (
+ this.suggestionModelName === 'mail.channel_command' ||
+ this._getCommandFromText(this.textInputContent)
+ ) {
+ return;
+ }
+ if (this.thread.typingMembers.includes(this.env.messaging.currentPartner)) {
+ this.thread.refreshCurrentPartnerIsTyping();
+ } else {
+ this.thread.registerCurrentPartnerIsTyping();
+ }
+ }
+
+ /**
+ * Sets the first suggestion as active. Main and extra records are
+ * considered together.
+ */
+ setFirstSuggestionActive() {
+ const suggestedRecords = this.mainSuggestedRecords.concat(this.extraSuggestedRecords);
+ const firstRecord = suggestedRecords[0];
+ this.update({ activeSuggestedRecord: [['link', firstRecord]] });
+ }
+
+ /**
+ * Sets the last suggestion as active. Main and extra records are
+ * considered together.
+ */
+ setLastSuggestionActive() {
+ const suggestedRecords = this.mainSuggestedRecords.concat(this.extraSuggestedRecords);
+ const { length, [length - 1]: lastRecord } = suggestedRecords;
+ this.update({ activeSuggestedRecord: [['link', lastRecord]] });
+ }
+
+ /**
+ * Sets the next suggestion as active. Main and extra records are
+ * considered together.
+ */
+ setNextSuggestionActive() {
+ const suggestedRecords = this.mainSuggestedRecords.concat(this.extraSuggestedRecords);
+ const activeElementIndex = suggestedRecords.findIndex(
+ suggestion => suggestion === this.activeSuggestedRecord
+ );
+ if (activeElementIndex === suggestedRecords.length - 1) {
+ // loop when reaching the end of the list
+ this.setFirstSuggestionActive();
+ return;
+ }
+ const nextRecord = suggestedRecords[activeElementIndex + 1];
+ this.update({ activeSuggestedRecord: [['link', nextRecord]] });
+ }
+
+ /**
+ * Sets the previous suggestion as active. Main and extra records are
+ * considered together.
+ */
+ setPreviousSuggestionActive() {
+ const suggestedRecords = this.mainSuggestedRecords.concat(this.extraSuggestedRecords);
+ const activeElementIndex = suggestedRecords.findIndex(
+ suggestion => suggestion === this.activeSuggestedRecord
+ );
+ if (activeElementIndex === 0) {
+ // loop when reaching the start of the list
+ this.setLastSuggestionActive();
+ return;
+ }
+ const previousRecord = suggestedRecords[activeElementIndex - 1];
+ this.update({ activeSuggestedRecord: [['link', previousRecord]] });
+ }
+
+ //----------------------------------------------------------------------
+ // Private
+ //----------------------------------------------------------------------
+
+ /**
+ * @deprecated
+ * @private
+ * @returns {mail.canned_response}
+ */
+ _computeActiveSuggestedCannedResponse() {
+ if (this.suggestionDelimiter === ':' && this.activeSuggestedRecord) {
+ return [['link', this.activeSuggestedRecord]];
+ }
+ return [['unlink']];
+ }
+
+ /**
+ * @deprecated
+ * @private
+ * @returns {mail.thread}
+ */
+ _computeActiveSuggestedChannel() {
+ if (this.suggestionDelimiter === '#' && this.activeSuggestedRecord) {
+ return [['link', this.activeSuggestedRecord]];
+ }
+ return [['unlink']];
+ }
+
+ /**
+ * @deprecated
+ * @private
+ * @returns {mail.channel_command}
+ */
+ _computeActiveSuggestedChannelCommand() {
+ if (this.suggestionDelimiter === '/' && this.activeSuggestedRecord) {
+ return [['link', this.activeSuggestedRecord]];
+ }
+ return [['unlink']];
+ }
+
+ /**
+ * @deprecated
+ * @private
+ * @returns {mail.partner}
+ */
+ _computeActiveSuggestedPartner() {
+ if (this.suggestionDelimiter === '@' && this.activeSuggestedRecord) {
+ return [['link', this.activeSuggestedRecord]];
+ }
+ return [['unlink']];
+ }
+
+ /**
+ * Clears the active suggested record on closing mentions or adapt it if
+ * the active current record is no longer part of the suggestions.
+ *
+ * @private
+ * @returns {mail.model}
+ */
+ _computeActiveSuggestedRecord() {
+ if (
+ this.mainSuggestedRecords.length === 0 &&
+ this.extraSuggestedRecords.length === 0
+ ) {
+ return [['unlink']];
+ }
+ if (
+ this.mainSuggestedRecords.includes(this.activeSuggestedRecord) ||
+ this.extraSuggestedRecords.includes(this.activeSuggestedRecord)
+ ) {
+ return;
+ }
+ const suggestedRecords = this.mainSuggestedRecords.concat(this.extraSuggestedRecords);
+ const firstRecord = suggestedRecords[0];
+ return [['link', firstRecord]];
+ }
+
+ /**
+ * @deprecated
+ * @private
+ * @returns {string}
+ */
+ _computeActiveSuggestedRecordName() {
+ switch (this.suggestionDelimiter) {
+ case '@':
+ return "activeSuggestedPartner";
+ case ':':
+ return "activeSuggestedCannedResponse";
+ case '/':
+ return "activeSuggestedChannelCommand";
+ case '#':
+ return "activeSuggestedChannel";
+ default:
+ return clear();
+ }
+ }
+
+ /**
+ * @private
+ * @returns {boolean}
+ */
+ _computeCanPostMessage() {
+ if (!this.textInputContent && this.attachments.length === 0) {
+ return false;
+ }
+ return !this.hasUploadingAttachment && !this.isPostingMessage;
+ }
+
+ /**
+ * @deprecated
+ * @private
+ * @returns {mail.partner[]}
+ */
+ _computeExtraSuggestedPartners() {
+ if (this.suggestionDelimiter === '@') {
+ return [['replace', this.extraSuggestedRecords]];
+ }
+ return [['unlink-all']];
+ }
+
+ /**
+ * Clears the extra suggested record on closing mentions, and ensures
+ * the extra list does not contain any element already present in the
+ * main list, which is a requirement for the navigation process.
+ *
+ * @private
+ * @returns {mail.model[]}
+ */
+ _computeExtraSuggestedRecords() {
+ if (this.suggestionDelimiterPosition === undefined) {
+ return [['unlink-all']];
+ }
+ return [['unlink', this.mainSuggestedRecords]];
+ }
+
+ /**
+ * @deprecated
+ * @private
+ * @returns {mail.model[]}
+ */
+ _computeExtraSuggestedRecordsList() {
+ return this.extraSuggestedRecords;
+ }
+
+ /**
+ * @deprecated
+ * @private
+ * @returns {string}
+ */
+ _computeExtraSuggestedRecordsListName() {
+ if (this.suggestionDelimiter === '@') {
+ return "extraSuggestedPartners";
+ }
+ return clear();
+ }
+
+ /**
+ * @private
+ * @return {boolean}
+ */
+ _computeHasSuggestions() {
+ return this.mainSuggestedRecords.length > 0 || this.extraSuggestedRecords.length > 0;
+ }
+
+ /**
+ * @private
+ * @returns {boolean}
+ */
+ _computeHasUploadingAttachment() {
+ return this.attachments.some(attachment => attachment.isTemporary);
+ }
+
+ /**
+ * @deprecated
+ * @private
+ * @returns {mail.model[]}
+ */
+ _computeMainSuggestedPartners() {
+ if (this.suggestionDelimiter === '@') {
+ return [['replace', this.mainSuggestedRecords]];
+ }
+ return [['unlink-all']];
+ }
+
+ /**
+ * Clears the main suggested record on closing mentions.
+ *
+ * @private
+ * @returns {mail.model[]}
+ */
+ _computeMainSuggestedRecords() {
+ if (this.suggestionDelimiterPosition === undefined) {
+ return [['unlink-all']];
+ }
+ }
+
+ /**
+ * @deprecated
+ * @private
+ * @returns {mail.model[]}
+ */
+ _computeMainSuggestedRecordsList() {
+ return this.mainSuggestedRecords;
+ }
+
+ /**
+ * @deprecated
+ * @private
+ * @returns {string}
+ */
+ _computeMainSuggestedRecordsListName() {
+ switch (this.suggestionDelimiter) {
+ case '@':
+ return "mainSuggestedPartners";
+ case ':':
+ return "suggestedCannedResponses";
+ case '/':
+ return "suggestedChannelCommands";
+ case '#':
+ return "suggestedChannels";
+ default:
+ return clear();
+ }
+ }
+
+ /**
+ * Detects if mentioned partners are still in the composer text input content
+ * and removes them if not.
+ *
+ * @private
+ * @returns {mail.partner[]}
+ */
+ _computeMentionedPartners() {
+ const unmentionedPartners = [];
+ // ensure the same mention is not used multiple times if multiple
+ // partners have the same name
+ const namesIndex = {};
+ for (const partner of this.mentionedPartners) {
+ const fromIndex = namesIndex[partner.name] !== undefined
+ ? namesIndex[partner.name] + 1 :
+ 0;
+ const index = this.textInputContent.indexOf(`@${partner.name}`, fromIndex);
+ if (index !== -1) {
+ namesIndex[partner.name] = index;
+ } else {
+ unmentionedPartners.push(partner);
+ }
+ }
+ return [['unlink', unmentionedPartners]];
+ }
+
+ /**
+ * Detects if mentioned channels are still in the composer text input content
+ * and removes them if not.
+ *
+ * @private
+ * @returns {mail.partner[]}
+ */
+ _computeMentionedChannels() {
+ const unmentionedChannels = [];
+ // ensure the same mention is not used multiple times if multiple
+ // channels have the same name
+ const namesIndex = {};
+ for (const channel of this.mentionedChannels) {
+ const fromIndex = namesIndex[channel.name] !== undefined
+ ? namesIndex[channel.name] + 1 :
+ 0;
+ const index = this.textInputContent.indexOf(`#${channel.name}`, fromIndex);
+ if (index !== -1) {
+ namesIndex[channel.name] = index;
+ } else {
+ unmentionedChannels.push(channel);
+ }
+ }
+ return [['unlink', unmentionedChannels]];
+ }
+
+ /**
+ * @deprecated
+ * @private
+ * @returns {mail.canned_response[]}
+ */
+ _computeSuggestedCannedResponses() {
+ if (this.suggestionDelimiter === ':') {
+ return [['replace', this.mainSuggestedRecords]];
+ }
+ return [['unlink-all']];
+ }
+
+ /**
+ * @deprecated
+ * @private
+ * @returns {mail.thread[]}
+ */
+ _computeSuggestedChannels() {
+ if (this.suggestionDelimiter === '#') {
+ return [['replace', this.mainSuggestedRecords]];
+ }
+ return [['unlink-all']];
+ }
+
+ /**
+ * @private
+ * @returns {string}
+ */
+ _computeSuggestionDelimiter() {
+ if (
+ this.suggestionDelimiterPosition === undefined ||
+ this.suggestionDelimiterPosition >= this.textInputContent.length
+ ) {
+ return clear();
+ }
+ return this.textInputContent[this.suggestionDelimiterPosition];
+ }
+
+ /**
+ * @private
+ * @returns {integer}
+ */
+ _computeSuggestionDelimiterPosition() {
+ if (this.textInputCursorStart !== this.textInputCursorEnd) {
+ // avoid interfering with multi-char selection
+ return clear();
+ }
+ const candidatePositions = [];
+ // keep the current delimiter if it is still valid
+ if (
+ this.suggestionDelimiterPosition !== undefined &&
+ this.suggestionDelimiterPosition < this.textInputCursorStart
+ ) {
+ candidatePositions.push(this.suggestionDelimiterPosition);
+ }
+ // consider the char before the current cursor position if the
+ // current delimiter is no longer valid (or if there is none)
+ if (this.textInputCursorStart > 0) {
+ candidatePositions.push(this.textInputCursorStart - 1);
+ }
+ const suggestionDelimiters = ['@', ':', '#', '/'];
+ for (const candidatePosition of candidatePositions) {
+ if (
+ candidatePosition < 0 ||
+ candidatePosition >= this.textInputContent.length
+ ) {
+ continue;
+ }
+ const candidateChar = this.textInputContent[candidatePosition];
+ if (candidateChar === '/' && candidatePosition !== 0) {
+ continue;
+ }
+ if (!suggestionDelimiters.includes(candidateChar)) {
+ continue;
+ }
+ const charBeforeCandidate = this.textInputContent[candidatePosition - 1];
+ if (charBeforeCandidate && !/\s/.test(charBeforeCandidate)) {
+ continue;
+ }
+ return candidatePosition;
+ }
+ return clear();
+ }
+
+ /**
+ * @deprecated
+ * @private
+ * @returns {mail.channel_command[]}
+ */
+ _computeSuggestedChannelCommands() {
+ if (this.suggestionDelimiter === '/') {
+ return [['replace', this.mainSuggestedRecords]];
+ }
+ return [['unlink-all']];
+ }
+
+ /**
+ * @private
+ * @returns {string}
+ */
+ _computeSuggestionModelName() {
+ switch (this.suggestionDelimiter) {
+ case '@':
+ return 'mail.partner';
+ case ':':
+ return 'mail.canned_response';
+ case '/':
+ return 'mail.channel_command';
+ case '#':
+ return 'mail.thread';
+ default:
+ return clear();
+ }
+ }
+
+ /**
+ * @private
+ * @returns {string}
+ */
+ _computeSuggestionSearchTerm() {
+ if (
+ this.suggestionDelimiterPosition === undefined ||
+ this.suggestionDelimiterPosition >= this.textInputCursorStart
+ ) {
+ return clear();
+ }
+ return this.textInputContent.substring(this.suggestionDelimiterPosition + 1, this.textInputCursorStart);
+ }
+
+ /**
+ * Executes the given async function, only when the last function
+ * executed by this method terminates. If there is already a pending
+ * function it is replaced by the new one. This ensures the result of
+ * these function come in the same order as the call order, and it also
+ * allows to skip obsolete intermediate calls.
+ *
+ * @private
+ * @param {function} func
+ */
+ async _executeOrQueueFunction(func) {
+ if (this._hasMentionRpcInProgress) {
+ this._nextMentionRpcFunction = func;
+ return;
+ }
+ this._hasMentionRpcInProgress = true;
+ this._nextMentionRpcFunction = undefined;
+ try {
+ await this.async(func);
+ } finally {
+ this._hasMentionRpcInProgress = false;
+ if (this._nextMentionRpcFunction) {
+ this._executeOrQueueFunction(this._nextMentionRpcFunction);
+ }
+ }
+ }
+
+ /**
+ * @private
+ * @param {string} htmlString
+ * @returns {string}
+ */
+ _generateEmojisOnHtml(htmlString) {
+ for (const emoji of emojis) {
+ for (const source of emoji.sources) {
+ const escapedSource = String(source).replace(
+ /([.*+?=^!:${}()|[\]/\\])/g,
+ '\\$1');
+ const regexp = new RegExp(
+ '(\\s|^)(' + escapedSource + ')(?=\\s|$)',
+ 'g');
+ htmlString = htmlString.replace(regexp, '$1' + emoji.unicode);
+ }
+ }
+ return htmlString;
+ }
+
+ /**
+ *
+ * Generates the html link related to the mentioned partner
+ *
+ * @private
+ * @param {string} body
+ * @returns {string}
+ */
+ _generateMentionsLinks(body) {
+ // List of mention data to insert in the body.
+ // Useful to do the final replace after parsing to avoid using the
+ // same tag twice if two different mentions have the same name.
+ const mentions = [];
+ for (const partner of this.mentionedPartners) {
+ const placeholder = `@-mention-partner-${partner.id}`;
+ const text = `@${owl.utils.escape(partner.name)}`;
+ mentions.push({
+ class: 'o_mail_redirect',
+ id: partner.id,
+ model: 'res.partner',
+ placeholder,
+ text,
+ });
+ body = body.replace(text, placeholder);
+ }
+ for (const channel of this.mentionedChannels) {
+ const placeholder = `#-mention-channel-${channel.id}`;
+ const text = `#${owl.utils.escape(channel.name)}`;
+ mentions.push({
+ class: 'o_channel_redirect',
+ id: channel.id,
+ model: 'mail.channel',
+ placeholder,
+ text,
+ });
+ body = body.replace(text, placeholder);
+ }
+ const baseHREF = this.env.session.url('/web');
+ for (const mention of mentions) {
+ const href = `href='${baseHREF}#model=${mention.model}&id=${mention.id}'`;
+ const attClass = `class='${mention.class}'`;
+ const dataOeId = `data-oe-id='${mention.id}'`;
+ const dataOeModel = `data-oe-model='${mention.model}'`;
+ const target = `target='_blank'`;
+ const link = `<a ${href} ${attClass} ${dataOeId} ${dataOeModel} ${target}>${mention.text}</a>`;
+ body = body.replace(mention.placeholder, link);
+ }
+ return body;
+ }
+
+ /**
+ * @private
+ * @param {string} content html content
+ * @returns {mail.channel_command|undefined} command, if any in the content
+ */
+ _getCommandFromText(content) {
+ if (content.startsWith('/')) {
+ const firstWord = content.substring(1).split(/\s/)[0];
+ return this.env.messaging.commands.find(command => {
+ if (command.name !== firstWord) {
+ return false;
+ }
+ if (command.channel_types) {
+ return command.channel_types.includes(this.thread.channel_type);
+ }
+ return true;
+ });
+ }
+ return undefined;
+ }
+
+ /**
+ * Updates the suggestion state based on the currently saved composer
+ * state (in particular content and cursor position).
+ *
+ * @private
+ */
+ _onChangeUpdateSuggestionList() {
+ // Update the suggestion list immediately for a reactive UX...
+ this._updateSuggestionList();
+ // ...and then update it again after the server returned data.
+ this._executeOrQueueFunction(async () => {
+ if (
+ this.suggestionDelimiterPosition === undefined ||
+ this.suggestionSearchTerm === undefined ||
+ !this.suggestionModelName
+ ) {
+ // ignore obsolete call
+ return;
+ }
+ const Model = this.env.models[this.suggestionModelName];
+ const searchTerm = this.suggestionSearchTerm;
+ await this.async(() => Model.fetchSuggestions(searchTerm, { thread: this.thread }));
+ this._updateSuggestionList();
+ if (
+ this.suggestionSearchTerm &&
+ this.suggestionSearchTerm === searchTerm &&
+ this.suggestionModelName &&
+ this.env.models[this.suggestionModelName] === Model &&
+ !this.hasSuggestions
+ ) {
+ this.closeSuggestions();
+ }
+ });
+ }
+
+ /**
+ * @private
+ */
+ _reset() {
+ this.update({
+ attachments: [['unlink-all']],
+ isLastStateChangeProgrammatic: true,
+ mentionedChannels: [['unlink-all']],
+ mentionedPartners: [['unlink-all']],
+ subjectContent: "",
+ textInputContent: '',
+ textInputCursorEnd: 0,
+ textInputCursorStart: 0,
+ });
+ }
+
+ /**
+ * Updates the current suggestion list. This method should be called
+ * whenever the UI has to be refreshed following change in state.
+ *
+ * This method should ideally be a compute, but its dependencies are
+ * currently too complex to express due to accessing plenty of fields
+ * from all records of dynamic models.
+ *
+ * @private
+ */
+ _updateSuggestionList() {
+ if (
+ this.suggestionDelimiterPosition === undefined ||
+ this.suggestionSearchTerm === undefined ||
+ !this.suggestionModelName
+ ) {
+ return;
+ }
+ const Model = this.env.models[this.suggestionModelName];
+ const [
+ mainSuggestedRecords,
+ extraSuggestedRecords = [],
+ ] = Model.searchSuggestions(this.suggestionSearchTerm, { thread: this.thread });
+ const sortFunction = Model.getSuggestionSortFunction(this.suggestionSearchTerm, { thread: this.thread });
+ mainSuggestedRecords.sort(sortFunction);
+ extraSuggestedRecords.sort(sortFunction);
+ // arbitrary limit to avoid displaying too many elements at once
+ // ideally a load more mechanism should be introduced
+ const limit = 8;
+ mainSuggestedRecords.length = Math.min(mainSuggestedRecords.length, limit);
+ extraSuggestedRecords.length = Math.min(extraSuggestedRecords.length, limit - mainSuggestedRecords.length);
+ this.update({
+ extraSuggestedRecords: [['replace', extraSuggestedRecords]],
+ hasToScrollToActiveSuggestion: true,
+ mainSuggestedRecords: [['replace', mainSuggestedRecords]],
+ });
+ }
+
+ /**
+ * Validates user's current typing as a correct mention keyword in order
+ * to trigger mentions suggestions display.
+ * Returns the mention keyword without the suggestion delimiter if it
+ * has been validated and false if not.
+ *
+ * @deprecated
+ * @private
+ * @param {boolean} beginningOnly
+ * @returns {string|boolean}
+ */
+ _validateMentionKeyword(beginningOnly) {
+ // use position before suggestion delimiter because there should be whitespaces
+ // or line feed/carriage return before the suggestion delimiter
+ const beforeSuggestionDelimiterPosition = this.suggestionDelimiterPosition - 1;
+ if (beginningOnly && beforeSuggestionDelimiterPosition > 0) {
+ return false;
+ }
+ let searchStr = this.textInputContent.substring(
+ beforeSuggestionDelimiterPosition,
+ this.textInputCursorStart
+ );
+ // regex string start with suggestion delimiter or whitespace then suggestion delimiter
+ const pattern = "^" + this.suggestionDelimiter + "|^\\s" + this.suggestionDelimiter;
+ const regexStart = new RegExp(pattern, 'g');
+ // trim any left whitespaces or the left line feed/ carriage return
+ // at the beginning of the string
+ searchStr = searchStr.replace(/^\s\s*|^[\n\r]/g, '');
+ if (regexStart.test(searchStr) && searchStr.length) {
+ searchStr = searchStr.replace(pattern, '');
+ return !searchStr.includes(' ') && !/[\r\n]/.test(searchStr)
+ ? searchStr.replace(this.suggestionDelimiter, '')
+ : false;
+ }
+ return false;
+ }
+ }
+
+ Composer.fields = {
+ /**
+ * Deprecated. Use `activeSuggestedRecord` instead.
+ */
+ activeSuggestedCannedResponse: many2one('mail.canned_response', {
+ compute: '_computeActiveSuggestedCannedResponse',
+ dependencies: [
+ 'activeSuggestedRecord',
+ 'suggestionDelimiter',
+ ],
+ }),
+ /**
+ * Deprecated. Use `activeSuggestedRecord` instead.
+ */
+ activeSuggestedChannel: many2one('mail.thread', {
+ compute: '_computeActiveSuggestedChannel',
+ dependencies: [
+ 'activeSuggestedRecord',
+ 'suggestionDelimiter',
+ ],
+ }),
+ /**
+ * Deprecated. Use `activeSuggestedRecord` instead.
+ */
+ activeSuggestedChannelCommand: many2one('mail.channel_command', {
+ compute: '_computeActiveSuggestedChannelCommand',
+ dependencies: [
+ 'activeSuggestedRecord',
+ 'suggestionDelimiter',
+ ],
+ }),
+ /**
+ * Deprecated. Use `activeSuggestedRecord` instead.
+ */
+ activeSuggestedPartner: many2one('mail.partner', {
+ compute: '_computeActiveSuggestedPartner',
+ dependencies: [
+ 'activeSuggestedRecord',
+ 'suggestionDelimiter',
+ ],
+ }),
+ /**
+ * Determines the suggested record that is currently active. This record
+ * is highlighted in the UI and it will be the selected record if the
+ * suggestion is confirmed by the user.
+ */
+ activeSuggestedRecord: many2one('mail.model', {
+ compute: '_computeActiveSuggestedRecord',
+ dependencies: [
+ 'activeSuggestedRecord',
+ 'extraSuggestedRecords',
+ 'mainSuggestedRecords',
+ ],
+ }),
+ /**
+ * Deprecated, suggestions should be used in a manner that does not
+ * depend on their type. Use `activeSuggestedRecord` directly instead.
+ */
+ activeSuggestedRecordName: attr({
+ compute: '_computeActiveSuggestedRecordName',
+ dependencies: [
+ 'suggestionDelimiter',
+ ],
+ }),
+ attachments: many2many('mail.attachment', {
+ inverse: 'composers',
+ }),
+ /**
+ * This field watches the uploading (= temporary) status of attachments
+ * linked to this composer.
+ *
+ * Useful to determine whether there are some attachments that are being
+ * uploaded.
+ */
+ attachmentsAreTemporary: attr({
+ related: 'attachments.isTemporary',
+ }),
+ canPostMessage: attr({
+ compute: '_computeCanPostMessage',
+ dependencies: [
+ 'attachments',
+ 'hasUploadingAttachment',
+ 'isPostingMessage',
+ 'textInputContent',
+ ],
+ default: false,
+ }),
+ /**
+ * Instance of discuss if this composer is used as the reply composer
+ * from Inbox. This field is computed from the inverse relation and
+ * should be considered read-only.
+ */
+ discussAsReplying: one2one('mail.discuss', {
+ inverse: 'replyingToMessageOriginThreadComposer',
+ }),
+ /**
+ * Deprecated. Use `extraSuggestedRecords` instead.
+ */
+ extraSuggestedPartners: many2many('mail.partner', {
+ compute: '_computeExtraSuggestedPartners',
+ dependencies: [
+ 'extraSuggestedRecords',
+ 'suggestionDelimiter',
+ ],
+ }),
+ /**
+ * Determines the extra records that are currently suggested.
+ * Allows to have different model types of mentions through a dynamic
+ * process. 2 arbitrary lists can be provided and the second is defined
+ * as "extra".
+ */
+ extraSuggestedRecords: many2many('mail.model', {
+ compute: '_computeExtraSuggestedRecords',
+ dependencies: [
+ 'extraSuggestedRecords',
+ 'mainSuggestedRecords',
+ 'suggestionDelimiterPosition',
+ ],
+ }),
+ /**
+ * Deprecated. Use `extraSuggestedRecords` instead.
+ */
+ extraSuggestedRecordsList: attr({
+ compute: '_computeExtraSuggestedRecordsList',
+ dependencies: [
+ 'extraSuggestedRecords',
+ ],
+ }),
+ /**
+ * Deprecated, suggestions should be used in a manner that does not
+ * depend on their type. Use `extraSuggestedRecords` directly instead.
+ */
+ extraSuggestedRecordsListName: attr({
+ compute: '_computeExtraSuggestedRecordsListName',
+ dependencies: [
+ 'suggestionDelimiter',
+ ],
+ }),
+ /**
+ * This field determines whether some attachments linked to this
+ * composer are being uploaded.
+ */
+ hasUploadingAttachment: attr({
+ compute: '_computeHasUploadingAttachment',
+ dependencies: [
+ 'attachments',
+ 'attachmentsAreTemporary',
+ ],
+ }),
+ hasFocus: attr({
+ default: false,
+ }),
+ /**
+ * States whether there is any result currently found for the current
+ * suggestion delimiter and search term, if applicable.
+ */
+ hasSuggestions: attr({
+ compute: '_computeHasSuggestions',
+ dependencies: [
+ 'extraSuggestedRecords',
+ 'mainSuggestedRecords',
+ ],
+ default: false,
+ }),
+ /**
+ * Determines whether the currently active suggestion should be scrolled
+ * into view.
+ */
+ hasToScrollToActiveSuggestion: attr({
+ default: false,
+ }),
+ /**
+ * Determines whether the last change (since the last render) was
+ * programmatic. Useful to avoid restoring the state when its change was
+ * from a user action, in particular to prevent the cursor from jumping
+ * to its previous position after the user clicked on the textarea while
+ * it didn't have the focus anymore.
+ */
+ isLastStateChangeProgrammatic: attr({
+ default: false,
+ }),
+ /**
+ * If true composer will log a note, else a comment will be posted.
+ */
+ isLog: attr({
+ default: false,
+ }),
+ /**
+ * Determines whether a post_message request is currently pending.
+ */
+ isPostingMessage: attr(),
+ /**
+ * Deprecated. Use `mainSuggestedRecords` instead.
+ */
+ mainSuggestedPartners: many2many('mail.partner', {
+ compute: '_computeMainSuggestedPartners',
+ dependencies: [
+ 'mainSuggestedRecords',
+ 'suggestionDelimiter',
+ ],
+ }),
+ /**
+ * Determines the main records that are currently suggested.
+ * Allows to have different model types of mentions through a dynamic
+ * process. 2 arbitrary lists can be provided and the first is defined
+ * as "main".
+ */
+ mainSuggestedRecords: many2many('mail.model', {
+ compute: '_computeMainSuggestedRecords',
+ dependencies: [
+ 'mainSuggestedRecords',
+ 'suggestionDelimiterPosition',
+ ],
+ }),
+ /**
+ * Deprecated. Use `mainSuggestedRecords` instead.
+ */
+ mainSuggestedRecordsList: attr({
+ compute: '_computeMainSuggestedRecordsList',
+ dependencies: [
+ 'mainSuggestedRecords',
+ ],
+ }),
+ /**
+ * Deprecated, suggestions should be used in a manner that does not
+ * depend on their type. Use `mainSuggestedRecords` directly instead.
+ */
+ mainSuggestedRecordsListName: attr({
+ compute: '_computeMainSuggestedRecordsListName',
+ dependencies: [
+ 'suggestionDelimiter',
+ ],
+ }),
+ mentionedChannels: many2many('mail.thread', {
+ compute: '_computeMentionedChannels',
+ dependencies: ['textInputContent'],
+ }),
+ mentionedPartners: many2many('mail.partner', {
+ compute: '_computeMentionedPartners',
+ dependencies: [
+ 'mentionedPartners',
+ 'mentionedPartnersName',
+ 'textInputContent',
+ ],
+ }),
+ /**
+ * Serves as compute dependency.
+ */
+ mentionedPartnersName: attr({
+ related: 'mentionedPartners.name',
+ }),
+ /**
+ * Not a real field, used to trigger `_onChangeUpdateSuggestionList`
+ * when one of the dependencies changes.
+ */
+ onChangeUpdateSuggestionList: attr({
+ compute: '_onChangeUpdateSuggestionList',
+ dependencies: [
+ 'suggestionDelimiterPosition',
+ 'suggestionModelName',
+ 'suggestionSearchTerm',
+ 'thread',
+ ],
+ }),
+ /**
+ * Determines the extra `mail.partner` (on top of existing followers)
+ * that will receive the message being composed by `this`, and that will
+ * also be added as follower of `this.thread`.
+ */
+ recipients: many2many('mail.partner', {
+ compute: '_computeRecipients',
+ dependencies: [
+ 'isLog',
+ 'mentionedPartners',
+ 'threadSuggestedRecipientInfoListIsSelected',
+ // FIXME thread.suggestedRecipientInfoList.partner should be a
+ // dependency, but it is currently impossible to have a related
+ // m2o through a m2m. task-2261221
+ ]
+ }),
+ /**
+ * Serves as compute dependency.
+ */
+ threadSuggestedRecipientInfoList: many2many('mail.suggested_recipient_info', {
+ related: 'thread.suggestedRecipientInfoList',
+ }),
+ /**
+ * Serves as compute dependency.
+ */
+ threadSuggestedRecipientInfoListIsSelected: attr({
+ related: 'threadSuggestedRecipientInfoList.isSelected',
+ }),
+ /**
+ * Composer subject input content.
+ */
+ subjectContent: attr({
+ default: "",
+ }),
+ /**
+ * Deprecated. Use `mainSuggestedRecords` instead.
+ */
+ suggestedCannedResponses: many2many('mail.canned_response', {
+ compute: '_computeSuggestedCannedResponses',
+ dependencies: [
+ 'mainSuggestedRecords',
+ 'suggestionDelimiter',
+ ],
+ }),
+ /**
+ * Deprecated. Use `mainSuggestedRecords` instead.
+ */
+ suggestedChannelCommands: many2many('mail.channel_command', {
+ compute: '_computeSuggestedChannelCommands',
+ dependencies: [
+ 'mainSuggestedRecords',
+ 'suggestionDelimiter',
+ ],
+ }),
+ /**
+ * Deprecated. Use `mainSuggestedRecords` instead.
+ */
+ suggestedChannels: many2many('mail.thread', {
+ compute: '_computeSuggestedChannels',
+ dependencies: [
+ 'mainSuggestedRecords',
+ 'suggestionDelimiter',
+ ],
+ }),
+ /**
+ * States which type of suggestion is currently in progress, if any.
+ * The value of this field contains the magic char that corresponds to
+ * the suggestion currently in progress, and it must be one of these:
+ * canned responses (:), channels (#), commands (/) and partners (@)
+ */
+ suggestionDelimiter: attr({
+ compute: '_computeSuggestionDelimiter',
+ dependencies: [
+ 'suggestionDelimiterPosition',
+ 'textInputContent',
+ ],
+ }),
+ /**
+ * States the position inside textInputContent of the suggestion
+ * delimiter currently in consideration. Useful if the delimiter char
+ * appears multiple times in the content.
+ * Note: the position is 0 based so it's important to compare to
+ * `undefined` when checking for the absence of a value.
+ */
+ suggestionDelimiterPosition: attr({
+ compute: '_computeSuggestionDelimiterPosition',
+ dependencies: [
+ 'textInputContent',
+ 'textInputCursorEnd',
+ 'textInputCursorStart',
+ ],
+ }),
+ /**
+ * States the target model name of the suggestion currently in progress,
+ * if any.
+ */
+ suggestionModelName: attr({
+ compute: '_computeSuggestionModelName',
+ dependencies: [
+ 'suggestionDelimiter',
+ ],
+ }),
+ /**
+ * States the search term to use for suggestions (if any).
+ */
+ suggestionSearchTerm: attr({
+ compute: '_computeSuggestionSearchTerm',
+ dependencies: [
+ 'suggestionDelimiterPosition',
+ 'textInputContent',
+ 'textInputCursorStart',
+ ],
+ }),
+ textInputContent: attr({
+ default: "",
+ }),
+ textInputCursorEnd: attr({
+ default: 0,
+ }),
+ textInputCursorStart: attr({
+ default: 0,
+ }),
+ textInputSelectionDirection: attr({
+ default: "none",
+ }),
+ thread: one2one('mail.thread', {
+ inverse: 'composer',
+ }),
+ };
+
+ Composer.modelName = 'mail.composer';
+
+ return Composer;
+}
+
+registerNewModel('mail.composer', factory);
+
+});
diff --git a/addons/mail/static/src/models/country/country.js b/addons/mail/static/src/models/country/country.js
new file mode 100644
index 00000000..fb3617cf
--- /dev/null
+++ b/addons/mail/static/src/models/country/country.js
@@ -0,0 +1,55 @@
+odoo.define('mail/static/src/models/country/country.js', function (require) {
+'use strict';
+
+const { registerNewModel } = require('mail/static/src/model/model_core.js');
+const { attr } = require('mail/static/src/model/model_field.js');
+const { clear } = require('mail/static/src/model/model_field_command.js');
+
+function factory(dependencies) {
+
+ class Country extends dependencies['mail.model'] {
+
+ //----------------------------------------------------------------------
+ // Private
+ //----------------------------------------------------------------------
+
+ /**
+ * @override
+ */
+ static _createRecordLocalId(data) {
+ return `${this.modelName}_${data.id}`;
+ }
+
+ /**
+ * @private
+ * @returns {string|undefined}
+ */
+ _computeFlagUrl() {
+ if (!this.code) {
+ return clear();
+ }
+ return `/base/static/img/country_flags/${this.code}.png`;
+ }
+
+ }
+
+ Country.fields = {
+ code: attr(),
+ flagUrl: attr({
+ compute: '_computeFlagUrl',
+ dependencies: [
+ 'code',
+ ],
+ }),
+ id: attr(),
+ name: attr(),
+ };
+
+ Country.modelName = 'mail.country';
+
+ return Country;
+}
+
+registerNewModel('mail.country', factory);
+
+});
diff --git a/addons/mail/static/src/models/device/device.js b/addons/mail/static/src/models/device/device.js
new file mode 100644
index 00000000..29e664d3
--- /dev/null
+++ b/addons/mail/static/src/models/device/device.js
@@ -0,0 +1,71 @@
+odoo.define('mail/static/src/models/device/device.js', function (require) {
+'use strict';
+
+const { registerNewModel } = require('mail/static/src/model/model_core.js');
+const { attr } = require('mail/static/src/model/model_field.js');
+
+function factory(dependencies) {
+
+ class Device extends dependencies['mail.model'] {
+
+ /**
+ * @override
+ */
+ _created() {
+ const res = super._created(...arguments);
+ this._refresh();
+ this._onResize = _.debounce(() => this._refresh(), 100);
+ return res;
+ }
+
+ /**
+ * @override
+ */
+ _willDelete() {
+ window.removeEventListener('resize', this._onResize);
+ return super._willDelete(...arguments);
+ }
+
+ //----------------------------------------------------------------------
+ // Public
+ //----------------------------------------------------------------------
+
+ /**
+ * Called when messaging is started.
+ */
+ start() {
+ // TODO FIXME Not using this.env.browser because it's proxified, and
+ // addEventListener does not work on proxified window. task-2234596
+ window.addEventListener('resize', this._onResize);
+ }
+
+ //----------------------------------------------------------------------
+ // Private
+ //----------------------------------------------------------------------
+
+ /**
+ * @private
+ */
+ _refresh() {
+ this.update({
+ globalWindowInnerHeight: this.env.browser.innerHeight,
+ globalWindowInnerWidth: this.env.browser.innerWidth,
+ isMobile: this.env.device.isMobile,
+ });
+ }
+ }
+
+ Device.fields = {
+ globalWindowInnerHeight: attr(),
+ globalWindowInnerWidth: attr(),
+ isMobile: attr(),
+ };
+
+ Device.modelName = 'mail.device';
+
+ return Device;
+}
+
+registerNewModel('mail.device', factory);
+
+});
diff --git a/addons/mail/static/src/models/dialog/dialog.js b/addons/mail/static/src/models/dialog/dialog.js
new file mode 100644
index 00000000..018951fe
--- /dev/null
+++ b/addons/mail/static/src/models/dialog/dialog.js
@@ -0,0 +1,32 @@
+odoo.define('mail/static/src/models/dialog/dialog.js', function (require) {
+'use strict';
+
+const { registerNewModel } = require('mail/static/src/model/model_core.js');
+const { many2one, one2one } = require('mail/static/src/model/model_field.js');
+
+function factory(dependencies) {
+
+ class Dialog extends dependencies['mail.model'] {}
+
+ Dialog.fields = {
+ manager: many2one('mail.dialog_manager', {
+ inverse: 'dialogs',
+ }),
+ /**
+ * Content of dialog that is directly linked to a record that models
+ * a UI component, such as AttachmentViewer. These records must be
+ * created from @see `mail.dialog_manager:open()`.
+ */
+ record: one2one('mail.model', {
+ isCausal: true,
+ }),
+ };
+
+ Dialog.modelName = 'mail.dialog';
+
+ return Dialog;
+}
+
+registerNewModel('mail.dialog', factory);
+
+});
diff --git a/addons/mail/static/src/models/dialog_manager/dialog_manager.js b/addons/mail/static/src/models/dialog_manager/dialog_manager.js
new file mode 100644
index 00000000..4d86e340
--- /dev/null
+++ b/addons/mail/static/src/models/dialog_manager/dialog_manager.js
@@ -0,0 +1,52 @@
+odoo.define('mail/static/src/models/dialog_manager/dialog_manager.js', function (require) {
+'use strict';
+
+const { registerNewModel } = require('mail/static/src/model/model_core.js');
+const { one2many } = require('mail/static/src/model/model_field.js');
+
+function factory(dependencies) {
+
+ class DialogManager extends dependencies['mail.model'] {
+
+ //----------------------------------------------------------------------
+ // Public
+ //----------------------------------------------------------------------
+
+ /**
+ * @param {string} modelName
+ * @param {Object} [recordData]
+ */
+ open(modelName, recordData) {
+ if (!modelName) {
+ throw new Error("Dialog should have a link to a model");
+ }
+ const Model = this.env.models[modelName];
+ if (!Model) {
+ throw new Error(`No model exists with name ${modelName}`);
+ }
+ const record = Model.create(recordData);
+ const dialog = this.env.models['mail.dialog'].create({
+ manager: [['link', this]],
+ record: [['link', record]],
+ });
+ return dialog;
+ }
+
+ }
+
+ DialogManager.fields = {
+ // FIXME: dependent on implementation that uses insert order in relations!!
+ dialogs: one2many('mail.dialog', {
+ inverse: 'manager',
+ isCausal: true,
+ }),
+ };
+
+ DialogManager.modelName = 'mail.dialog_manager';
+
+ return DialogManager;
+}
+
+registerNewModel('mail.dialog_manager', factory);
+
+});
diff --git a/addons/mail/static/src/models/discuss/discuss.js b/addons/mail/static/src/models/discuss/discuss.js
new file mode 100644
index 00000000..513b77fd
--- /dev/null
+++ b/addons/mail/static/src/models/discuss/discuss.js
@@ -0,0 +1,568 @@
+odoo.define('mail/static/src/models/discuss.discuss.js', function (require) {
+'use strict';
+
+const { registerNewModel } = require('mail/static/src/model/model_core.js');
+const { attr, many2one, one2many, one2one } = require('mail/static/src/model/model_field.js');
+const { clear } = require('mail/static/src/model/model_field_command.js');
+
+function factory(dependencies) {
+
+ class Discuss extends dependencies['mail.model'] {
+
+ //----------------------------------------------------------------------
+ // Public
+ //----------------------------------------------------------------------
+
+ /**
+ * @param {mail.thread} thread
+ */
+ cancelThreadRenaming(thread) {
+ this.update({ renamingThreads: [['unlink', thread]] });
+ }
+
+ clearIsAddingItem() {
+ this.update({
+ addingChannelValue: "",
+ isAddingChannel: false,
+ isAddingChat: false,
+ });
+ }
+
+ clearReplyingToMessage() {
+ this.update({ replyingToMessage: [['unlink-all']] });
+ }
+
+ /**
+ * Close the discuss app. Should reset its internal state.
+ */
+ close() {
+ this.update({ isOpen: false });
+ }
+
+ focus() {
+ this.update({ isDoFocus: true });
+ }
+
+ /**
+ * @param {Event} ev
+ * @param {Object} ui
+ * @param {Object} ui.item
+ * @param {integer} ui.item.id
+ */
+ async handleAddChannelAutocompleteSelect(ev, ui) {
+ const name = this.addingChannelValue;
+ this.clearIsAddingItem();
+ if (ui.item.special) {
+ const channel = await this.async(() =>
+ this.env.models['mail.thread'].performRpcCreateChannel({
+ name,
+ privacy: ui.item.special,
+ })
+ );
+ channel.open();
+ } else {
+ const channel = await this.async(() =>
+ this.env.models['mail.thread'].performRpcJoinChannel({
+ channelId: ui.item.id,
+ })
+ );
+ channel.open();
+ }
+ }
+
+ /**
+ * @param {Object} req
+ * @param {string} req.term
+ * @param {function} res
+ */
+ async handleAddChannelAutocompleteSource(req, res) {
+ const value = req.term;
+ const escapedValue = owl.utils.escape(value);
+ this.update({ addingChannelValue: value });
+ const domain = [
+ ['channel_type', '=', 'channel'],
+ ['name', 'ilike', value],
+ ];
+ const fields = ['channel_type', 'name', 'public', 'uuid'];
+ const result = await this.async(() => this.env.services.rpc({
+ model: "mail.channel",
+ method: "search_read",
+ kwargs: {
+ domain,
+ fields,
+ },
+ }));
+ const items = result.map(data => {
+ let escapedName = owl.utils.escape(data.name);
+ return Object.assign(data, {
+ label: escapedName,
+ value: escapedName
+ });
+ });
+ // XDU FIXME could use a component but be careful with owl's
+ // renderToString https://github.com/odoo/owl/issues/708
+ items.push({
+ label: _.str.sprintf(
+ `<strong>${this.env._t('Create %s')}</strong>`,
+ `<em><span class="fa fa-hashtag"/>${escapedValue}</em>`,
+ ),
+ escapedValue,
+ special: 'public'
+ }, {
+ label: _.str.sprintf(
+ `<strong>${this.env._t('Create %s')}</strong>`,
+ `<em><span class="fa fa-lock"/>${escapedValue}</em>`,
+ ),
+ escapedValue,
+ special: 'private'
+ });
+ res(items);
+ }
+
+ /**
+ * @param {Event} ev
+ * @param {Object} ui
+ * @param {Object} ui.item
+ * @param {integer} ui.item.id
+ */
+ handleAddChatAutocompleteSelect(ev, ui) {
+ this.env.messaging.openChat({ partnerId: ui.item.id });
+ this.clearIsAddingItem();
+ }
+
+ /**
+ * @param {Object} req
+ * @param {string} req.term
+ * @param {function} res
+ */
+ handleAddChatAutocompleteSource(req, res) {
+ const value = owl.utils.escape(req.term);
+ this.env.models['mail.partner'].imSearch({
+ callback: partners => {
+ const suggestions = partners.map(partner => {
+ return {
+ id: partner.id,
+ value: partner.nameOrDisplayName,
+ label: partner.nameOrDisplayName,
+ };
+ });
+ res(_.sortBy(suggestions, 'label'));
+ },
+ keyword: value,
+ limit: 10,
+ });
+ }
+
+ /**
+ * Open thread from init active id. `initActiveId` is used to refer to
+ * a thread that we may not have full data yet, such as when messaging
+ * is not yet initialized.
+ */
+ openInitThread() {
+ const [model, id] = typeof this.initActiveId === 'number'
+ ? ['mail.channel', this.initActiveId]
+ : this.initActiveId.split('_');
+ const thread = this.env.models['mail.thread'].findFromIdentifyingData({
+ id: model !== 'mail.box' ? Number(id) : id,
+ model,
+ });
+ if (!thread) {
+ return;
+ }
+ thread.open();
+ if (this.env.messaging.device.isMobile && thread.channel_type) {
+ this.update({ activeMobileNavbarTabId: thread.channel_type });
+ }
+ }
+
+
+ /**
+ * Opens the given thread in Discuss, and opens Discuss if necessary.
+ *
+ * @param {mail.thread} thread
+ */
+ async openThread(thread) {
+ this.update({
+ thread: [['link', thread]],
+ });
+ this.focus();
+ if (!this.isOpen) {
+ this.env.bus.trigger('do-action', {
+ action: 'mail.action_discuss',
+ options: {
+ active_id: this.threadToActiveId(this),
+ clear_breadcrumbs: false,
+ on_reverse_breadcrumb: () => this.close(),
+ },
+ });
+ }
+ }
+
+ /**
+ * @param {mail.thread} thread
+ * @param {string} newName
+ */
+ async renameThread(thread, newName) {
+ await this.async(() => thread.rename(newName));
+ this.update({ renamingThreads: [['unlink', thread]] });
+ }
+
+ /**
+ * Action to initiate reply to given message in Inbox. Assumes that
+ * Discuss and Inbox are already opened.
+ *
+ * @param {mail.message} message
+ */
+ replyToMessage(message) {
+ this.update({ replyingToMessage: [['link', message]] });
+ // avoid to reply to a note by a message and vice-versa.
+ // subject to change later by allowing subtype choice.
+ this.replyingToMessageOriginThreadComposer.update({
+ isLog: !message.is_discussion && !message.is_notification
+ });
+ this.focus();
+ }
+
+ /**
+ * @param {mail.thread} thread
+ */
+ setThreadRenaming(thread) {
+ this.update({ renamingThreads: [['link', thread]] });
+ }
+
+ /**
+ * @param {mail.thread} thread
+ * @returns {string}
+ */
+ threadToActiveId(thread) {
+ return `${thread.model}_${thread.id}`;
+ }
+
+ //----------------------------------------------------------------------
+ // Private
+ //----------------------------------------------------------------------
+
+ /**
+ * @private
+ * @returns {string|undefined}
+ */
+ _computeActiveId() {
+ if (!this.thread) {
+ return clear();
+ }
+ return this.threadToActiveId(this.thread);
+ }
+
+ /**
+ * @private
+ * @returns {string}
+ */
+ _computeAddingChannelValue() {
+ if (!this.isOpen) {
+ return "";
+ }
+ return this.addingChannelValue;
+ }
+
+ /**
+ * @private
+ * @returns {boolean}
+ */
+ _computeHasThreadView() {
+ if (!this.thread || !this.isOpen) {
+ return false;
+ }
+ if (
+ this.env.messaging.device.isMobile &&
+ (
+ this.activeMobileNavbarTabId !== 'mailbox' ||
+ this.thread.model !== 'mail.box'
+ )
+ ) {
+ return false;
+ }
+ return true;
+ }
+
+ /**
+ * @private
+ * @returns {boolean}
+ */
+ _computeIsAddingChannel() {
+ if (!this.isOpen) {
+ return false;
+ }
+ return this.isAddingChannel;
+ }
+
+ /**
+ * @private
+ * @returns {boolean}
+ */
+ _computeIsAddingChat() {
+ if (!this.isOpen) {
+ return false;
+ }
+ return this.isAddingChat;
+ }
+
+ /**
+ * @private
+ * @returns {boolean}
+ */
+ _computeIsReplyingToMessage() {
+ return !!this.replyingToMessage;
+ }
+
+ /**
+ * Ensures the reply feature is disabled if discuss is not open.
+ *
+ * @private
+ * @returns {mail.message|undefined}
+ */
+ _computeReplyingToMessage() {
+ if (!this.isOpen) {
+ return [['unlink-all']];
+ }
+ return [];
+ }
+
+
+ /**
+ * Only pinned threads are allowed in discuss.
+ *
+ * @private
+ * @returns {mail.thread|undefined}
+ */
+ _computeThread() {
+ let thread = this.thread;
+ if (this.env.messaging &&
+ this.env.messaging.inbox &&
+ this.env.messaging.device.isMobile &&
+ this.activeMobileNavbarTabId === 'mailbox' &&
+ this.initActiveId !== 'mail.box_inbox' &&
+ !thread
+ ) {
+ // After loading Discuss from an arbitrary tab other then 'mailbox',
+ // switching to 'mailbox' requires to also set its inner-tab ;
+ // by default the 'inbox'.
+ return [['replace', this.env.messaging.inbox]];
+ }
+ if (!thread || !thread.isPinned) {
+ return [['unlink']];
+ }
+ return [];
+ }
+
+ }
+
+ Discuss.fields = {
+ activeId: attr({
+ compute: '_computeActiveId',
+ dependencies: [
+ 'thread',
+ 'threadId',
+ 'threadModel',
+ ],
+ }),
+ /**
+ * Active mobile navbar tab, either 'mailbox', 'chat', or 'channel'.
+ */
+ activeMobileNavbarTabId: attr({
+ default: 'mailbox',
+ }),
+ /**
+ * Value that is used to create a channel from the sidebar.
+ */
+ addingChannelValue: attr({
+ compute: '_computeAddingChannelValue',
+ default: "",
+ dependencies: ['isOpen'],
+ }),
+ /**
+ * Serves as compute dependency.
+ */
+ device: one2one('mail.device', {
+ related: 'messaging.device',
+ }),
+ /**
+ * Serves as compute dependency.
+ */
+ deviceIsMobile: attr({
+ related: 'device.isMobile',
+ }),
+ /**
+ * Determine if the moderation discard dialog is displayed.
+ */
+ hasModerationDiscardDialog: attr({
+ default: false,
+ }),
+ /**
+ * Determine if the moderation reject dialog is displayed.
+ */
+ hasModerationRejectDialog: attr({
+ default: false,
+ }),
+ /**
+ * Determines whether `this.thread` should be displayed.
+ */
+ hasThreadView: attr({
+ compute: '_computeHasThreadView',
+ dependencies: [
+ 'activeMobileNavbarTabId',
+ 'deviceIsMobile',
+ 'isOpen',
+ 'thread',
+ 'threadModel',
+ ],
+ }),
+ /**
+ * Formatted init thread on opening discuss for the first time,
+ * when no active thread is defined. Useful to set a thread to
+ * open without knowing its local id in advance.
+ * Support two formats:
+ * {string} <threadModel>_<threadId>
+ * {int} <channelId> with default model of 'mail.channel'
+ */
+ initActiveId: attr({
+ default: 'mail.box_inbox',
+ }),
+ /**
+ * Determine whether current user is currently adding a channel from
+ * the sidebar.
+ */
+ isAddingChannel: attr({
+ compute: '_computeIsAddingChannel',
+ default: false,
+ dependencies: ['isOpen'],
+ }),
+ /**
+ * Determine whether current user is currently adding a chat from
+ * the sidebar.
+ */
+ isAddingChat: attr({
+ compute: '_computeIsAddingChat',
+ default: false,
+ dependencies: ['isOpen'],
+ }),
+ /**
+ * Determine whether this discuss should be focused at next render.
+ */
+ isDoFocus: attr({
+ default: false,
+ }),
+ /**
+ * Whether the discuss app is open or not. Useful to determine
+ * whether the discuss or chat window logic should be applied.
+ */
+ isOpen: attr({
+ default: false,
+ }),
+ isReplyingToMessage: attr({
+ compute: '_computeIsReplyingToMessage',
+ default: false,
+ dependencies: ['replyingToMessage'],
+ }),
+ isThreadPinned: attr({
+ related: 'thread.isPinned',
+ }),
+ /**
+ * The menu_id of discuss app, received on mail/init_messaging and
+ * used to open discuss from elsewhere.
+ */
+ menu_id: attr({
+ default: null,
+ }),
+ messaging: one2one('mail.messaging', {
+ inverse: 'discuss',
+ }),
+ messagingInbox: many2one('mail.thread', {
+ related: 'messaging.inbox',
+ }),
+ renamingThreads: one2many('mail.thread'),
+ /**
+ * The message that is currently selected as being replied to in Inbox.
+ * There is only one reply composer shown at a time, which depends on
+ * this selected message.
+ */
+ replyingToMessage: many2one('mail.message', {
+ compute: '_computeReplyingToMessage',
+ dependencies: [
+ 'isOpen',
+ 'replyingToMessage',
+ ],
+ }),
+ /**
+ * The thread concerned by the reply feature in Inbox. It depends on the
+ * message set to be replied, and should be considered read-only.
+ */
+ replyingToMessageOriginThread: many2one('mail.thread', {
+ related: 'replyingToMessage.originThread',
+ }),
+ /**
+ * The composer to display for the reply feature in Inbox. It depends
+ * on the message set to be replied, and should be considered read-only.
+ */
+ replyingToMessageOriginThreadComposer: one2one('mail.composer', {
+ inverse: 'discussAsReplying',
+ related: 'replyingToMessageOriginThread.composer',
+ }),
+ /**
+ * Quick search input value in the discuss sidebar (desktop). Useful
+ * to filter channels and chats based on this input content.
+ */
+ sidebarQuickSearchValue: attr({
+ default: "",
+ }),
+ /**
+ * Determines the domain to apply when fetching messages for `this.thread`.
+ * This value should only be written by the control panel.
+ */
+ stringifiedDomain: attr({
+ default: '[]',
+ }),
+ /**
+ * Determines the `mail.thread` that should be displayed by `this`.
+ */
+ thread: many2one('mail.thread', {
+ compute: '_computeThread',
+ dependencies: [
+ 'activeMobileNavbarTabId',
+ 'deviceIsMobile',
+ 'isThreadPinned',
+ 'messaging',
+ 'messagingInbox',
+ 'thread',
+ 'threadModel',
+ ],
+ }),
+ threadId: attr({
+ related: 'thread.id',
+ }),
+ threadModel: attr({
+ related: 'thread.model',
+ }),
+ /**
+ * States the `mail.thread_view` displaying `this.thread`.
+ */
+ threadView: one2one('mail.thread_view', {
+ related: 'threadViewer.threadView',
+ }),
+ /**
+ * Determines the `mail.thread_viewer` managing the display of `this.thread`.
+ */
+ threadViewer: one2one('mail.thread_viewer', {
+ default: [['create']],
+ inverse: 'discuss',
+ isCausal: true,
+ }),
+ };
+
+ Discuss.modelName = 'mail.discuss';
+
+ return Discuss;
+}
+
+registerNewModel('mail.discuss', factory);
+
+});
diff --git a/addons/mail/static/src/models/follower/follower.js b/addons/mail/static/src/models/follower/follower.js
new file mode 100644
index 00000000..493fe836
--- /dev/null
+++ b/addons/mail/static/src/models/follower/follower.js
@@ -0,0 +1,293 @@
+odoo.define('mail/static/src/models/follower.follower.js', function (require) {
+'use strict';
+
+const { registerNewModel } = require('mail/static/src/model/model_core.js');
+const { attr, many2many, many2one } = require('mail/static/src/model/model_field.js');
+
+function factory(dependencies) {
+
+ class Follower extends dependencies['mail.model'] {
+
+ //----------------------------------------------------------------------
+ // Public
+ //----------------------------------------------------------------------
+
+ /**
+ * @static
+ * @param {Object} data
+ * @returns {Object}
+ */
+ static convertData(data) {
+ const data2 = {};
+ if ('channel_id' in data) {
+ if (!data.channel_id) {
+ data2.channel = [['unlink-all']];
+ } else {
+ const channelData = {
+ id: data.channel_id,
+ model: 'mail.channel',
+ name: data.name,
+ };
+ data2.channel = [['insert', channelData]];
+ }
+ }
+ if ('id' in data) {
+ data2.id = data.id;
+ }
+ if ('is_active' in data) {
+ data2.isActive = data.is_active;
+ }
+ if ('is_editable' in data) {
+ data2.isEditable = data.is_editable;
+ }
+ if ('partner_id' in data) {
+ if (!data.partner_id) {
+ data2.partner = [['unlink-all']];
+ } else {
+ const partnerData = {
+ display_name: data.display_name,
+ email: data.email,
+ id: data.partner_id,
+ name: data.name,
+ };
+ data2.partner = [['insert', partnerData]];
+ }
+ }
+ return data2;
+ }
+
+ /**
+ * Close subtypes dialog
+ */
+ closeSubtypes() {
+ this._subtypesListDialog.delete();
+ this._subtypesListDialog = undefined;
+ }
+
+ /**
+ * Opens the most appropriate view that is a profile for this follower.
+ */
+ async openProfile() {
+ if (this.partner) {
+ return this.partner.openProfile();
+ }
+ return this.channel.openProfile();
+ }
+
+ /**
+ * Remove this follower from its related thread.
+ */
+ async remove() {
+ const partner_ids = [];
+ const channel_ids = [];
+ if (this.partner) {
+ partner_ids.push(this.partner.id);
+ } else {
+ channel_ids.push(this.channel.id);
+ }
+ await this.async(() => this.env.services.rpc({
+ model: this.followedThread.model,
+ method: 'message_unsubscribe',
+ args: [[this.followedThread.id], partner_ids, channel_ids]
+ }));
+ const followedThread = this.followedThread;
+ this.delete();
+ followedThread.fetchAndUpdateSuggestedRecipients();
+ }
+
+ /**
+ * @param {mail.follower_subtype} subtype
+ */
+ selectSubtype(subtype) {
+ if (!this.selectedSubtypes.includes(subtype)) {
+ this.update({ selectedSubtypes: [['link', subtype]] });
+ }
+ }
+
+ /**
+ * Show (editable) list of subtypes of this follower.
+ */
+ async showSubtypes() {
+ const subtypesData = await this.async(() => this.env.services.rpc({
+ route: '/mail/read_subscription_data',
+ params: { follower_id: this.id },
+ }));
+ this.update({ subtypes: [['unlink-all']] });
+ for (const data of subtypesData) {
+ const subtype = this.env.models['mail.follower_subtype'].insert(
+ this.env.models['mail.follower_subtype'].convertData(data)
+ );
+ this.update({ subtypes: [['link', subtype]] });
+ if (data.followed) {
+ this.update({ selectedSubtypes: [['link', subtype]] });
+ } else {
+ this.update({ selectedSubtypes: [['unlink', subtype]] });
+ }
+ }
+ this._subtypesListDialog = this.env.messaging.dialogManager.open('mail.follower_subtype_list', {
+ follower: [['link', this]],
+ });
+ }
+
+ /**
+ * @param {mail.follower_subtype} subtype
+ */
+ unselectSubtype(subtype) {
+ if (this.selectedSubtypes.includes(subtype)) {
+ this.update({ selectedSubtypes: [['unlink', subtype]] });
+ }
+ }
+
+ /**
+ * Update server-side subscription of subtypes of this follower.
+ */
+ async updateSubtypes() {
+ if (this.selectedSubtypes.length === 0) {
+ this.remove();
+ } else {
+ const kwargs = {
+ subtype_ids: this.selectedSubtypes.map(subtype => subtype.id),
+ };
+ if (this.partner) {
+ kwargs.partner_ids = [this.partner.id];
+ } else {
+ kwargs.channel_ids = [this.channel.id];
+ }
+ await this.async(() => this.env.services.rpc({
+ model: this.followedThread.model,
+ method: 'message_subscribe',
+ args: [[this.followedThread.id]],
+ kwargs,
+ }));
+ this.env.services['notification'].notify({
+ type: 'success',
+ message: this.env._t("The subscription preferences were successfully applied."),
+ });
+ }
+ this.closeSubtypes();
+ }
+
+ //----------------------------------------------------------------------
+ // Private
+ //----------------------------------------------------------------------
+
+ /**
+ * @override
+ */
+ static _createRecordLocalId(data) {
+ return `${this.modelName}_${data.id}`;
+ }
+
+ /**
+ * @private
+ * @returns {string}
+ */
+ _computeName() {
+ if (this.channel) {
+ return this.channel.name;
+ }
+ if (this.partner) {
+ return this.partner.name;
+ }
+ return '';
+ }
+
+ /**
+ * @private
+ * @returns {integer}
+ */
+ _computeResId() {
+ if (this.partner) {
+ return this.partner.id;
+ }
+ if (this.channel) {
+ return this.channel.id;
+ }
+ return 0;
+ }
+
+ /**
+ * @private
+ * @returns {string}
+ */
+ _computeResModel() {
+ if (this.partner) {
+ return this.partner.model;
+ }
+ if (this.channel) {
+ return this.channel.model;
+ }
+ return '';
+ }
+
+ }
+
+ Follower.fields = {
+ resId: attr({
+ compute: '_computeResId',
+ default: 0,
+ dependencies: [
+ 'channelId',
+ 'partnerId',
+ ],
+ }),
+ channel: many2one('mail.thread'),
+ channelId: attr({
+ related: 'channel.id',
+ }),
+ channelModel: attr({
+ related: 'channel.model',
+ }),
+ channelName: attr({
+ related: 'channel.name',
+ }),
+ displayName: attr({
+ related: 'partner.display_name'
+ }),
+ followedThread: many2one('mail.thread', {
+ inverse: 'followers',
+ }),
+ id: attr(),
+ isActive: attr({
+ default: true,
+ }),
+ isEditable: attr({
+ default: false,
+ }),
+ name: attr({
+ compute: '_computeName',
+ dependencies: [
+ 'channelName',
+ 'partnerName',
+ ],
+ }),
+ partner: many2one('mail.partner'),
+ partnerId: attr({
+ related: 'partner.id',
+ }),
+ partnerModel: attr({
+ related: 'partner.model',
+ }),
+ partnerName: attr({
+ related: 'partner.name',
+ }),
+ resModel: attr({
+ compute: '_computeResModel',
+ default: '',
+ dependencies: [
+ 'channelModel',
+ 'partnerModel',
+ ],
+ }),
+ selectedSubtypes: many2many('mail.follower_subtype'),
+ subtypes: many2many('mail.follower_subtype'),
+ };
+
+ Follower.modelName = 'mail.follower';
+
+ return Follower;
+}
+
+registerNewModel('mail.follower', factory);
+
+});
diff --git a/addons/mail/static/src/models/follower_subtype/follower_subtype.js b/addons/mail/static/src/models/follower_subtype/follower_subtype.js
new file mode 100644
index 00000000..68c16fce
--- /dev/null
+++ b/addons/mail/static/src/models/follower_subtype/follower_subtype.js
@@ -0,0 +1,82 @@
+odoo.define('mail/static/src/models/follower_subtype/follower_subtype.js', function (require) {
+'use strict';
+
+const { registerNewModel } = require('mail/static/src/model/model_core.js');
+const { attr } = require('mail/static/src/model/model_field.js');
+
+function factory(dependencies) {
+
+ class FollowerSubtype extends dependencies['mail.model'] {
+
+ //----------------------------------------------------------------------
+ // Public
+ //----------------------------------------------------------------------
+
+ /**
+ * @static
+ * @param {Object} data
+ * @returns {Object}
+ */
+ static convertData(data) {
+ const data2 = {};
+ if ('default' in data) {
+ data2.isDefault = data.default;
+ }
+ if ('id' in data) {
+ data2.id = data.id;
+ }
+ if ('internal' in data) {
+ data2.isInternal = data.internal;
+ }
+ if ('name' in data) {
+ data2.name = data.name;
+ }
+ if ('parent_model' in data) {
+ data2.parentModel = data.parent_model;
+ }
+ if ('res_model' in data) {
+ data2.resModel = data.res_model;
+ }
+ if ('sequence' in data) {
+ data2.sequence = data.sequence;
+ }
+ return data2;
+ }
+
+ //----------------------------------------------------------------------
+ // Private
+ //----------------------------------------------------------------------
+
+ /**
+ * @override
+ */
+ static _createRecordLocalId(data) {
+ return `${this.modelName}_${data.id}`;
+ }
+
+ }
+
+ FollowerSubtype.fields = {
+ id: attr(),
+ isDefault: attr({
+ default: false,
+ }),
+ isInternal: attr({
+ default: false,
+ }),
+ name: attr(),
+ // AKU FIXME: use relation instead
+ parentModel: attr(),
+ // AKU FIXME: use relation instead
+ resModel: attr(),
+ sequence: attr(),
+ };
+
+ FollowerSubtype.modelName = 'mail.follower_subtype';
+
+ return FollowerSubtype;
+}
+
+registerNewModel('mail.follower_subtype', factory);
+
+});
diff --git a/addons/mail/static/src/models/follower_subtype_list/follower_subtype_list.js b/addons/mail/static/src/models/follower_subtype_list/follower_subtype_list.js
new file mode 100644
index 00000000..9d67cedb
--- /dev/null
+++ b/addons/mail/static/src/models/follower_subtype_list/follower_subtype_list.js
@@ -0,0 +1,22 @@
+odoo.define('mail/static/src/models/follower_subtype_list/follower_subtype_list.js', function (require) {
+'use strict';
+
+const { registerNewModel } = require('mail/static/src/model/model_core.js');
+const { many2one } = require('mail/static/src/model/model_field.js');
+
+function factory(dependencies) {
+
+ class FollowerSubtypeList extends dependencies['mail.model'] {}
+
+ FollowerSubtypeList.fields = {
+ follower: many2one('mail.follower'),
+ };
+
+ FollowerSubtypeList.modelName = 'mail.follower_subtype_list';
+
+ return FollowerSubtypeList;
+}
+
+registerNewModel('mail.follower_subtype_list', factory);
+
+});
diff --git a/addons/mail/static/src/models/locale/locale.js b/addons/mail/static/src/models/locale/locale.js
new file mode 100644
index 00000000..50167f07
--- /dev/null
+++ b/addons/mail/static/src/models/locale/locale.js
@@ -0,0 +1,52 @@
+odoo.define('mail/static/src/models/locale/locale.js', function (require) {
+'use strict';
+
+const { registerNewModel } = require('mail/static/src/model/model_core.js');
+const { attr } = require('mail/static/src/model/model_field.js');
+
+function factory(dependencies) {
+
+ class Locale extends dependencies['mail.model'] {
+
+ //----------------------------------------------------------------------
+ // Private
+ //----------------------------------------------------------------------
+
+ /**
+ * @private
+ * @returns {string}
+ */
+ _computeLanguage() {
+ return this.env._t.database.parameters.code;
+ }
+
+ /**
+ * @private
+ * @returns {string}
+ */
+ _computeTextDirection() {
+ return this.env._t.database.parameters.direction;
+ }
+
+ }
+
+ Locale.fields = {
+ /**
+ * Language used by interface, formatted like {language ISO 2}_{country ISO 2} (eg: fr_FR).
+ */
+ language: attr({
+ compute: '_computeLanguage',
+ }),
+ textDirection: attr({
+ compute: '_computeTextDirection',
+ }),
+ };
+
+ Locale.modelName = 'mail.locale';
+
+ return Locale;
+}
+
+registerNewModel('mail.locale', factory);
+
+});
diff --git a/addons/mail/static/src/models/mail_template/mail_template.js b/addons/mail/static/src/models/mail_template/mail_template.js
new file mode 100644
index 00000000..3144c314
--- /dev/null
+++ b/addons/mail/static/src/models/mail_template/mail_template.js
@@ -0,0 +1,83 @@
+odoo.define('mail/static/src/models/mail_template/mail_template.js', function (require) {
+'use strict';
+
+const { registerNewModel } = require('mail/static/src/model/model_core.js');
+const { attr, many2many } = require('mail/static/src/model/model_field.js');
+
+function factory(dependencies) {
+
+ class MailTemplate extends dependencies['mail.model'] {
+
+ //----------------------------------------------------------------------
+ // Public
+ //----------------------------------------------------------------------
+
+ /**
+ * @param {mail.activity} activity
+ */
+ preview(activity) {
+ const action = {
+ name: this.env._t("Compose Email"),
+ type: 'ir.actions.act_window',
+ res_model: 'mail.compose.message',
+ views: [[false, 'form']],
+ target: 'new',
+ context: {
+ default_res_id: activity.thread.id,
+ default_model: activity.thread.model,
+ default_use_template: true,
+ default_template_id: this.id,
+ force_email: true,
+ },
+ };
+ this.env.bus.trigger('do-action', {
+ action,
+ options: {
+ on_close: () => {
+ activity.thread.refresh();
+ },
+ },
+ });
+ }
+
+ /**
+ * @param {mail.activity} activity
+ */
+ async send(activity) {
+ await this.async(() => this.env.services.rpc({
+ model: activity.thread.model,
+ method: 'activity_send_mail',
+ args: [[activity.thread.id], this.id],
+ }));
+ activity.thread.refresh();
+ }
+
+ //----------------------------------------------------------------------
+ // Private
+ //----------------------------------------------------------------------
+
+ /**
+ * @override
+ */
+ static _createRecordLocalId(data) {
+ return `${this.modelName}_${data.id}`;
+ }
+
+ }
+
+ MailTemplate.fields = {
+ activities: many2many('mail.activity', {
+ inverse: 'mailTemplates',
+ }),
+ id: attr(),
+ name: attr(),
+ };
+
+ MailTemplate.modelName = 'mail.mail_template';
+
+ return MailTemplate;
+}
+
+registerNewModel('mail.mail_template', factory);
+
+});
diff --git a/addons/mail/static/src/models/message/message.js b/addons/mail/static/src/models/message/message.js
new file mode 100644
index 00000000..f5c45bfa
--- /dev/null
+++ b/addons/mail/static/src/models/message/message.js
@@ -0,0 +1,817 @@
+odoo.define('mail/static/src/models/message/message.js', function (require) {
+'use strict';
+
+const emojis = require('mail.emojis');
+const { registerNewModel } = require('mail/static/src/model/model_core.js');
+const { attr, many2many, many2one, one2many } = require('mail/static/src/model/model_field.js');
+const { clear } = require('mail/static/src/model/model_field_command.js');
+const { addLink, htmlToTextContentInline, parseAndTransform, timeFromNow } = require('mail.utils');
+
+const { str_to_datetime } = require('web.time');
+
+function factory(dependencies) {
+
+ class Message extends dependencies['mail.model'] {
+
+ //----------------------------------------------------------------------
+ // Public
+ //----------------------------------------------------------------------
+
+ /**
+ * @static
+ * @param {mail.thread} thread
+ * @param {string} threadStringifiedDomain
+ */
+ static checkAll(thread, threadStringifiedDomain) {
+ const threadCache = thread.cache(threadStringifiedDomain);
+ threadCache.update({ checkedMessages: [['link', threadCache.messages]] });
+ }
+
+ /**
+ * @static
+ * @param {Object} data
+ * @return {Object}
+ */
+ static convertData(data) {
+ const data2 = {};
+ if ('attachment_ids' in data) {
+ if (!data.attachment_ids) {
+ data2.attachments = [['unlink-all']];
+ } else {
+ data2.attachments = [
+ ['insert-and-replace', data.attachment_ids.map(attachmentData =>
+ this.env.models['mail.attachment'].convertData(attachmentData)
+ )],
+ ];
+ }
+ }
+ if ('author_id' in data) {
+ if (!data.author_id) {
+ data2.author = [['unlink-all']];
+ } else if (data.author_id[0] !== 0) {
+ // partner id 0 is a hack of message_format to refer to an
+ // author non-related to a partner. display_name equals
+ // email_from, so this is omitted due to being redundant.
+ data2.author = [
+ ['insert', {
+ display_name: data.author_id[1],
+ id: data.author_id[0],
+ }],
+ ];
+ }
+ }
+ if ('body' in data) {
+ data2.body = data.body;
+ }
+ if ('channel_ids' in data && data.channel_ids) {
+ const channels = data.channel_ids
+ .map(channelId =>
+ this.env.models['mail.thread'].findFromIdentifyingData({
+ id: channelId,
+ model: 'mail.channel',
+ })
+ ).filter(channel => !!channel);
+ data2.serverChannels = [['replace', channels]];
+ }
+ if ('date' in data && data.date) {
+ data2.date = moment(str_to_datetime(data.date));
+ }
+ if ('email_from' in data) {
+ data2.email_from = data.email_from;
+ }
+ if ('history_partner_ids' in data) {
+ data2.isHistory = data.history_partner_ids.includes(this.env.messaging.currentPartner.id);
+ }
+ if ('id' in data) {
+ data2.id = data.id;
+ }
+ if ('is_discussion' in data) {
+ data2.is_discussion = data.is_discussion;
+ }
+ if ('is_note' in data) {
+ data2.is_note = data.is_note;
+ }
+ if ('is_notification' in data) {
+ data2.is_notification = data.is_notification;
+ }
+ if ('message_type' in data) {
+ data2.message_type = data.message_type;
+ }
+ if ('model' in data && 'res_id' in data && data.model && data.res_id) {
+ const originThreadData = {
+ id: data.res_id,
+ model: data.model,
+ };
+ if ('record_name' in data && data.record_name) {
+ originThreadData.name = data.record_name;
+ }
+ if ('res_model_name' in data && data.res_model_name) {
+ originThreadData.model_name = data.res_model_name;
+ }
+ if ('module_icon' in data) {
+ originThreadData.moduleIcon = data.module_icon;
+ }
+ data2.originThread = [['insert', originThreadData]];
+ }
+ if ('moderation_status' in data) {
+ data2.moderation_status = data.moderation_status;
+ }
+ if ('needaction_partner_ids' in data) {
+ data2.isNeedaction = data.needaction_partner_ids.includes(this.env.messaging.currentPartner.id);
+ }
+ if ('notifications' in data) {
+ data2.notifications = [['insert', data.notifications.map(notificationData =>
+ this.env.models['mail.notification'].convertData(notificationData)
+ )]];
+ }
+ if ('starred_partner_ids' in data) {
+ data2.isStarred = data.starred_partner_ids.includes(this.env.messaging.currentPartner.id);
+ }
+ if ('subject' in data) {
+ data2.subject = data.subject;
+ }
+ if ('subtype_description' in data) {
+ data2.subtype_description = data.subtype_description;
+ }
+ if ('subtype_id' in data) {
+ data2.subtype_id = data.subtype_id;
+ }
+ if ('tracking_value_ids' in data) {
+ data2.tracking_value_ids = data.tracking_value_ids;
+ }
+
+ return data2;
+ }
+
+ /**
+ * Mark all messages of current user with given domain as read.
+ *
+ * @static
+ * @param {Array[]} domain
+ */
+ static async markAllAsRead(domain) {
+ await this.env.services.rpc({
+ model: 'mail.message',
+ method: 'mark_all_as_read',
+ kwargs: { domain },
+ });
+ }
+
+ /**
+ * Mark provided messages as read. Messages that have been marked as
+ * read are acknowledged by server with response as longpolling
+ * notification of following format:
+ *
+ * [[dbname, 'res.partner', partnerId], { type: 'mark_as_read' }]
+ *
+ * @see mail.messaging_notification_handler:_handleNotificationPartnerMarkAsRead()
+ *
+ * @static
+ * @param {mail.message[]} messages
+ */
+ static async markAsRead(messages) {
+ await this.env.services.rpc({
+ model: 'mail.message',
+ method: 'set_message_done',
+ args: [messages.map(message => message.id)]
+ });
+ }
+
+ /**
+ * Applies the moderation `decision` on the provided messages.
+ *
+ * @static
+ * @param {mail.message[]} messages
+ * @param {string} decision: 'accept', 'allow', ban', 'discard', or 'reject'
+ * @param {Object|undefined} [kwargs] optional data to pass on
+ * message moderation. This is provided when rejecting the messages
+ * for which title and comment give reason(s) for reject.
+ * @param {string} [kwargs.title]
+ * @param {string} [kwargs.comment]
+ */
+ static async moderate(messages, decision, kwargs) {
+ const messageIds = messages.map(message => message.id);
+ await this.env.services.rpc({
+ model: 'mail.message',
+ method: 'moderate',
+ args: [messageIds, decision],
+ kwargs: kwargs,
+ });
+ }
+ /**
+ * Performs the `message_fetch` RPC on `mail.message`.
+ *
+ * @static
+ * @param {Array[]} domain
+ * @param {integer} [limit]
+ * @param {integer[]} [moderated_channel_ids]
+ * @param {Object} [context]
+ * @returns {mail.message[]}
+ */
+ static async performRpcMessageFetch(domain, limit, moderated_channel_ids, context) {
+ const messagesData = await this.env.services.rpc({
+ model: 'mail.message',
+ method: 'message_fetch',
+ kwargs: {
+ context,
+ domain,
+ limit,
+ moderated_channel_ids,
+ },
+ }, { shadow: true });
+ const messages = this.env.models['mail.message'].insert(messagesData.map(
+ messageData => this.env.models['mail.message'].convertData(messageData)
+ ));
+ // compute seen indicators (if applicable)
+ for (const message of messages) {
+ for (const thread of message.threads) {
+ if (thread.model !== 'mail.channel' || thread.channel_type === 'channel') {
+ // disabled on non-channel threads and
+ // on `channel` channels for performance reasons
+ continue;
+ }
+ this.env.models['mail.message_seen_indicator'].insert({
+ channelId: thread.id,
+ messageId: message.id,
+ });
+ }
+ }
+ return messages;
+ }
+
+ /**
+ * @static
+ * @param {mail.thread} thread
+ * @param {string} threadStringifiedDomain
+ */
+ static uncheckAll(thread, threadStringifiedDomain) {
+ const threadCache = thread.cache(threadStringifiedDomain);
+ threadCache.update({ checkedMessages: [['unlink', threadCache.messages]] });
+ }
+
+ /**
+ * Unstar all starred messages of current user.
+ */
+ static async unstarAll() {
+ await this.env.services.rpc({
+ model: 'mail.message',
+ method: 'unstar_all',
+ });
+ }
+
+ /**
+ * @param {mail.thread} thread
+ * @param {string} threadStringifiedDomain
+ * @returns {boolean}
+ */
+ isChecked(thread, threadStringifiedDomain) {
+ // aku todo
+ const relatedCheckedThreadCache = this.checkedThreadCaches.find(
+ threadCache => (
+ threadCache.thread === thread &&
+ threadCache.stringifiedDomain === threadStringifiedDomain
+ )
+ );
+ return !!relatedCheckedThreadCache;
+ }
+
+ /**
+ * Mark this message as read, so that it no longer appears in current
+ * partner Inbox.
+ */
+ async markAsRead() {
+ await this.async(() => this.env.services.rpc({
+ model: 'mail.message',
+ method: 'set_message_done',
+ args: [[this.id]]
+ }));
+ }
+
+ /**
+ * Applies the moderation `decision` on this message.
+ *
+ * @param {string} decision: 'accept', 'allow', ban', 'discard', or 'reject'
+ * @param {Object|undefined} [kwargs] optional data to pass on
+ * message moderation. This is provided when rejecting the messages
+ * for which title and comment give reason(s) for reject.
+ * @param {string} [kwargs.title]
+ * @param {string} [kwargs.comment]
+ */
+ async moderate(decision, kwargs) {
+ await this.async(() => this.constructor.moderate([this], decision, kwargs));
+ }
+
+ /**
+ * Opens the view that allows to resend the message in case of failure.
+ */
+ openResendAction() {
+ this.env.bus.trigger('do-action', {
+ action: 'mail.mail_resend_message_action',
+ options: {
+ additional_context: {
+ mail_message_to_resend: this.id,
+ },
+ },
+ });
+ }
+
+ /**
+ * Refreshes the value of `dateFromNow` field to the "current now".
+ */
+ refreshDateFromNow() {
+ this.update({ dateFromNow: this._computeDateFromNow() });
+ }
+
+ /**
+ * Action to initiate reply to current message in Discuss Inbox. Assumes
+ * that Discuss and Inbox are already opened.
+ */
+ replyTo() {
+ this.env.messaging.discuss.replyToMessage(this);
+ }
+
+ /**
+ * Toggle check state of this message in the context of the provided
+ * thread and its stringifiedDomain.
+ *
+ * @param {mail.thread} thread
+ * @param {string} threadStringifiedDomain
+ */
+ toggleCheck(thread, threadStringifiedDomain) {
+ const threadCache = thread.cache(threadStringifiedDomain);
+ if (threadCache.checkedMessages.includes(this)) {
+ threadCache.update({ checkedMessages: [['unlink', this]] });
+ } else {
+ threadCache.update({ checkedMessages: [['link', this]] });
+ }
+ }
+
+ /**
+ * Toggle the starred status of the provided message.
+ */
+ async toggleStar() {
+ await this.async(() => this.env.services.rpc({
+ model: 'mail.message',
+ method: 'toggle_message_starred',
+ args: [[this.id]]
+ }));
+ }
+
+ //----------------------------------------------------------------------
+ // Private
+ //----------------------------------------------------------------------
+
+ /**
+ * @override
+ */
+ static _createRecordLocalId(data) {
+ return `${this.modelName}_${data.id}`;
+ }
+
+ /**
+ * @returns {string}
+ */
+ _computeDateFromNow() {
+ if (!this.date) {
+ return clear();
+ }
+ return timeFromNow(this.date);
+ }
+
+ /**
+ * @returns {boolean}
+ */
+ _computeFailureNotifications() {
+ return [['replace', this.notifications.filter(notifications =>
+ ['exception', 'bounce'].includes(notifications.notification_status)
+ )]];
+ }
+
+ /**
+ * @private
+ * @returns {boolean}
+ */
+ _computeHasCheckbox() {
+ return this.isModeratedByCurrentPartner;
+ }
+
+ /**
+ * @private
+ * @returns {boolean}
+ */
+ _computeIsCurrentPartnerAuthor() {
+ return !!(
+ this.author &&
+ this.messagingCurrentPartner &&
+ this.messagingCurrentPartner === this.author
+ );
+ }
+
+ /**
+ * @private
+ * @returns {boolean}
+ */
+ _computeIsBodyEqualSubtypeDescription() {
+ if (!this.body || !this.subtype_description) {
+ return false;
+ }
+ const inlineBody = htmlToTextContentInline(this.body);
+ return inlineBody.toLowerCase() === this.subtype_description.toLowerCase();
+ }
+
+ /**
+ * The method does not attempt to cover all possible cases of empty
+ * messages, but mostly those that happen with a standard flow. Indeed
+ * it is preferable to be defensive and show an empty message sometimes
+ * instead of hiding a non-empty message.
+ *
+ * The main use case for when a message should become empty is for a
+ * message posted with only an attachment (no body) and then the
+ * attachment is deleted.
+ *
+ * The main use case for being defensive with the check is when
+ * receiving a message that has no textual content but has other
+ * meaningful HTML tags (eg. just an <img/>).
+ *
+ * @private
+ * @returns {boolean}
+ */
+ _computeIsEmpty() {
+ const isBodyEmpty = (
+ !this.body ||
+ [
+ '',
+ '<p></p>',
+ '<p><br></p>',
+ '<p><br/></p>',
+ ].includes(this.body.replace(/\s/g, ''))
+ );
+ return (
+ isBodyEmpty &&
+ this.attachments.length === 0 &&
+ this.tracking_value_ids.length === 0 &&
+ !this.subtype_description
+ );
+ }
+
+ /**
+ * @private
+ * @returns {boolean}
+ */
+ _computeIsModeratedByCurrentPartner() {
+ return (
+ this.moderation_status === 'pending_moderation' &&
+ this.originThread &&
+ this.originThread.isModeratedByCurrentPartner
+ );
+ }
+ /**
+ * @private
+ * @returns {boolean}
+ */
+ _computeIsSubjectSimilarToOriginThreadName() {
+ if (
+ !this.subject ||
+ !this.originThread ||
+ !this.originThread.name
+ ) {
+ return false;
+ }
+ const threadName = this.originThread.name.toLowerCase().trim();
+ const prefixList = ['re:', 'fw:', 'fwd:'];
+ let cleanedSubject = this.subject.toLowerCase();
+ let wasSubjectCleaned = true;
+ while (wasSubjectCleaned) {
+ wasSubjectCleaned = false;
+ if (threadName === cleanedSubject) {
+ return true;
+ }
+ for (const prefix of prefixList) {
+ if (cleanedSubject.startsWith(prefix)) {
+ cleanedSubject = cleanedSubject.replace(prefix, '').trim();
+ wasSubjectCleaned = true;
+ break;
+ }
+ }
+ }
+ return false;
+ }
+
+ /**
+ * @private
+ * @returns {mail.messaging}
+ */
+ _computeMessaging() {
+ return [['link', this.env.messaging]];
+ }
+
+ /**
+ * This value is meant to be based on field body which is
+ * returned by the server (and has been sanitized before stored into db).
+ * Do not use this value in a 't-raw' if the message has been created
+ * directly from user input and not from server data as it's not escaped.
+ *
+ * @private
+ * @returns {string}
+ */
+ _computePrettyBody() {
+ let prettyBody;
+ for (const emoji of emojis) {
+ const { unicode } = emoji;
+ const regexp = new RegExp(
+ `(?:^|\\s|<[a-z]*>)(${unicode})(?=\\s|$|</[a-z]*>)`,
+ "g"
+ );
+ const originalBody = this.body;
+ prettyBody = this.body.replace(
+ regexp,
+ ` <span class="o_mail_emoji">${unicode}</span> `
+ );
+ // Idiot-proof limit. If the user had the amazing idea of
+ // copy-pasting thousands of emojis, the image rendering can lead
+ // to memory overflow errors on some browsers (e.g. Chrome). Set an
+ // arbitrary limit to 200 from which we simply don't replace them
+ // (anyway, they are already replaced by the unicode counterpart).
+ if (_.str.count(prettyBody, "o_mail_emoji") > 200) {
+ prettyBody = originalBody;
+ }
+ }
+ // add anchor tags to urls
+ return parseAndTransform(prettyBody, addLink);
+ }
+
+ /**
+ * @private
+ * @returns {mail.thread[]}
+ */
+ _computeThreads() {
+ const threads = [...this.serverChannels];
+ if (this.isHistory) {
+ threads.push(this.env.messaging.history);
+ }
+ if (this.isNeedaction) {
+ threads.push(this.env.messaging.inbox);
+ }
+ if (this.isStarred) {
+ threads.push(this.env.messaging.starred);
+ }
+ if (this.env.messaging.moderation && this.isModeratedByCurrentPartner) {
+ threads.push(this.env.messaging.moderation);
+ }
+ if (this.originThread) {
+ threads.push(this.originThread);
+ }
+ return [['replace', threads]];
+ }
+
+ }
+
+ Message.fields = {
+ attachments: many2many('mail.attachment', {
+ inverse: 'messages',
+ }),
+ author: many2one('mail.partner', {
+ inverse: 'messagesAsAuthor',
+ }),
+ /**
+ * This value is meant to be returned by the server
+ * (and has been sanitized before stored into db).
+ * Do not use this value in a 't-raw' if the message has been created
+ * directly from user input and not from server data as it's not escaped.
+ */
+ body: attr({
+ default: "",
+ }),
+ checkedThreadCaches: many2many('mail.thread_cache', {
+ inverse: 'checkedMessages',
+ }),
+ date: attr({
+ default: moment(),
+ }),
+ /**
+ * States the time elapsed since date up to now.
+ */
+ dateFromNow: attr({
+ compute: '_computeDateFromNow',
+ dependencies: [
+ 'date',
+ ],
+ }),
+ email_from: attr(),
+ failureNotifications: one2many('mail.notification', {
+ compute: '_computeFailureNotifications',
+ dependencies: ['notificationsStatus'],
+ }),
+ hasCheckbox: attr({
+ compute: '_computeHasCheckbox',
+ default: false,
+ dependencies: ['isModeratedByCurrentPartner'],
+ }),
+ id: attr(),
+ isCurrentPartnerAuthor: attr({
+ compute: '_computeIsCurrentPartnerAuthor',
+ default: false,
+ dependencies: [
+ 'author',
+ 'messagingCurrentPartner',
+ ],
+ }),
+ /**
+ * States whether `body` and `subtype_description` contain similar
+ * values.
+ *
+ * This is necessary to avoid displaying both of them together when they
+ * contain duplicate information. This will especially happen with
+ * messages that are posted automatically at the creation of a record
+ * (messages that serve as tracking messages). They do have hard-coded
+ * "record created" body while being assigned a subtype with a
+ * description that states the same information.
+ *
+ * Fixing newer messages is possible by not assigning them a duplicate
+ * body content, but the check here is still necessary to handle
+ * existing messages.
+ *
+ * Limitations:
+ * - A translated subtype description might not match a non-translatable
+ * body created by a user with a different language.
+ * - Their content might be mostly but not exactly the same.
+ */
+ isBodyEqualSubtypeDescription: attr({
+ compute: '_computeIsBodyEqualSubtypeDescription',
+ default: false,
+ dependencies: [
+ 'body',
+ 'subtype_description',
+ ],
+ }),
+ /**
+ * Determine whether the message has to be considered empty or not.
+ *
+ * An empty message has no text, no attachment and no tracking value.
+ */
+ isEmpty: attr({
+ compute: '_computeIsEmpty',
+ dependencies: [
+ 'attachments',
+ 'body',
+ 'subtype_description',
+ 'tracking_value_ids',
+ ],
+ }),
+ isModeratedByCurrentPartner: attr({
+ compute: '_computeIsModeratedByCurrentPartner',
+ default: false,
+ dependencies: [
+ 'moderation_status',
+ 'originThread',
+ 'originThreadIsModeratedByCurrentPartner',
+ ],
+ }),
+ /**
+ * States whether `originThread.name` and `subject` contain similar
+ * values except it contains the extra prefix at the start
+ * of the subject.
+ *
+ * This is necessary to avoid displaying the subject, if
+ * the subject is same as threadname.
+ */
+ isSubjectSimilarToOriginThreadName: attr({
+ compute: '_computeIsSubjectSimilarToOriginThreadName',
+ dependencies: [
+ 'originThread',
+ 'originThreadName',
+ 'subject',
+ ],
+ }),
+ isTemporary: attr({
+ default: false,
+ }),
+ isTransient: attr({
+ default: false,
+ }),
+ is_discussion: attr({
+ default: false,
+ }),
+ /**
+ * Determine whether the message was a needaction. Useful to make it
+ * present in history mailbox.
+ */
+ isHistory: attr({
+ default: false,
+ }),
+ /**
+ * Determine whether the message is needaction. Useful to make it
+ * present in inbox mailbox and messaging menu.
+ */
+ isNeedaction: attr({
+ default: false,
+ }),
+ is_note: attr({
+ default: false,
+ }),
+ is_notification: attr({
+ default: false,
+ }),
+ /**
+ * Determine whether the message is starred. Useful to make it present
+ * in starred mailbox.
+ */
+ isStarred: attr({
+ default: false,
+ }),
+ message_type: attr(),
+ messaging: many2one('mail.messaging', {
+ compute: '_computeMessaging',
+ }),
+ messagingCurrentPartner: many2one('mail.partner', {
+ related: 'messaging.currentPartner',
+ }),
+ messagingHistory: many2one('mail.thread', {
+ related: 'messaging.history',
+ }),
+ messagingInbox: many2one('mail.thread', {
+ related: 'messaging.inbox',
+ }),
+ messagingModeration: many2one('mail.thread', {
+ related: 'messaging.moderation',
+ }),
+ messagingStarred: many2one('mail.thread', {
+ related: 'messaging.starred',
+ }),
+ moderation_status: attr(),
+ notifications: one2many('mail.notification', {
+ inverse: 'message',
+ isCausal: true,
+ }),
+ notificationsStatus: attr({
+ default: [],
+ related: 'notifications.notification_status',
+ }),
+ /**
+ * Origin thread of this message (if any).
+ */
+ originThread: many2one('mail.thread', {
+ inverse: 'messagesAsOriginThread',
+ }),
+ originThreadIsModeratedByCurrentPartner: attr({
+ default: false,
+ related: 'originThread.isModeratedByCurrentPartner',
+ }),
+ /**
+ * Serves as compute dependency for isSubjectSimilarToOriginThreadName
+ */
+ originThreadName: attr({
+ related: 'originThread.name',
+ }),
+ /**
+ * This value is meant to be based on field body which is
+ * returned by the server (and has been sanitized before stored into db).
+ * Do not use this value in a 't-raw' if the message has been created
+ * directly from user input and not from server data as it's not escaped.
+ */
+ prettyBody: attr({
+ compute: '_computePrettyBody',
+ dependencies: ['body'],
+ }),
+ subject: attr(),
+ subtype_description: attr(),
+ subtype_id: attr(),
+ /**
+ * All threads that this message is linked to. This field is read-only.
+ */
+ threads: many2many('mail.thread', {
+ compute: '_computeThreads',
+ dependencies: [
+ 'isHistory',
+ 'isModeratedByCurrentPartner',
+ 'isNeedaction',
+ 'isStarred',
+ 'messagingHistory',
+ 'messagingInbox',
+ 'messagingModeration',
+ 'messagingStarred',
+ 'originThread',
+ 'serverChannels',
+ ],
+ inverse: 'messages',
+ }),
+ tracking_value_ids: attr({
+ default: [],
+ }),
+ /**
+ * All channels containing this message on the server.
+ * Equivalent of python field `channel_ids`.
+ */
+ serverChannels: many2many('mail.thread', {
+ inverse: 'messagesAsServerChannel',
+ }),
+ };
+
+ Message.modelName = 'mail.message';
+
+ return Message;
+}
+
+registerNewModel('mail.message', factory);
+
+});
diff --git a/addons/mail/static/src/models/message/message_tests.js b/addons/mail/static/src/models/message/message_tests.js
new file mode 100644
index 00000000..054e8204
--- /dev/null
+++ b/addons/mail/static/src/models/message/message_tests.js
@@ -0,0 +1,187 @@
+odoo.define('mail/static/src/models/message/message_tests.js', function (require) {
+'use strict';
+
+const { afterEach, beforeEach, start } = require('mail/static/src/utils/test_utils.js');
+
+const { str_to_datetime } = require('web.time');
+
+QUnit.module('mail', {}, function () {
+QUnit.module('models', {}, function () {
+QUnit.module('message', {}, function () {
+QUnit.module('message_tests.js', {
+ beforeEach() {
+ beforeEach(this);
+
+ this.start = async params => {
+ const { env, widget } = await start(Object.assign({}, params, {
+ data: this.data,
+ }));
+ this.env = env;
+ this.widget = widget;
+ };
+ },
+ afterEach() {
+ afterEach(this);
+ },
+});
+
+QUnit.test('create', async function (assert) {
+ assert.expect(31);
+
+ await this.start();
+ assert.notOk(this.env.models['mail.partner'].findFromIdentifyingData({ id: 5 }));
+ assert.notOk(this.env.models['mail.thread'].findFromIdentifyingData({
+ id: 100,
+ model: 'mail.channel',
+ }));
+ assert.notOk(this.env.models['mail.attachment'].findFromIdentifyingData({ id: 750 }));
+ assert.notOk(this.env.models['mail.message'].findFromIdentifyingData({ id: 4000 }));
+
+ const thread = this.env.models['mail.thread'].create({
+ id: 100,
+ model: 'mail.channel',
+ name: "General",
+ });
+ const message = this.env.models['mail.message'].create({
+ attachments: [['insert-and-replace', {
+ filename: "test.txt",
+ id: 750,
+ mimetype: 'text/plain',
+ name: "test.txt",
+ }]],
+ author: [['insert', { id: 5, display_name: "Demo" }]],
+ body: "<p>Test</p>",
+ date: moment(str_to_datetime("2019-05-05 10:00:00")),
+ id: 4000,
+ isNeedaction: true,
+ isStarred: true,
+ originThread: [['link', thread]],
+ });
+
+ assert.ok(this.env.models['mail.partner'].findFromIdentifyingData({ id: 5 }));
+ assert.ok(this.env.models['mail.thread'].findFromIdentifyingData({
+ id: 100,
+ model: 'mail.channel',
+ }));
+ assert.ok(this.env.models['mail.attachment'].findFromIdentifyingData({ id: 750 }));
+ assert.ok(this.env.models['mail.message'].findFromIdentifyingData({ id: 4000 }));
+
+ assert.ok(message);
+ assert.strictEqual(this.env.models['mail.message'].findFromIdentifyingData({ id: 4000 }), message);
+ assert.strictEqual(message.body, "<p>Test</p>");
+ assert.ok(message.date instanceof moment);
+ assert.strictEqual(
+ moment(message.date).utc().format('YYYY-MM-DD hh:mm:ss'),
+ "2019-05-05 10:00:00"
+ );
+ assert.strictEqual(message.id, 4000);
+ assert.strictEqual(message.originThread, this.env.models['mail.thread'].findFromIdentifyingData({
+ id: 100,
+ model: 'mail.channel',
+ }));
+ assert.ok(
+ message.threads.includes(this.env.models['mail.thread'].findFromIdentifyingData({
+ id: 100,
+ model: 'mail.channel',
+ }))
+ );
+ // from partnerId being in needaction_partner_ids
+ assert.ok(message.threads.includes(this.env.messaging.inbox));
+ // from partnerId being in starred_partner_ids
+ assert.ok(message.threads.includes(this.env.messaging.starred));
+ const attachment = this.env.models['mail.attachment'].findFromIdentifyingData({ id: 750 });
+ assert.ok(attachment);
+ assert.strictEqual(attachment.filename, "test.txt");
+ assert.strictEqual(attachment.id, 750);
+ assert.notOk(attachment.isTemporary);
+ assert.strictEqual(attachment.mimetype, 'text/plain');
+ assert.strictEqual(attachment.name, "test.txt");
+ const channel = this.env.models['mail.thread'].findFromIdentifyingData({
+ id: 100,
+ model: 'mail.channel',
+ });
+ assert.ok(channel);
+ assert.strictEqual(channel.model, 'mail.channel');
+ assert.strictEqual(channel.id, 100);
+ assert.strictEqual(channel.name, "General");
+ const partner = this.env.models['mail.partner'].findFromIdentifyingData({ id: 5 });
+ assert.ok(partner);
+ assert.strictEqual(partner.display_name, "Demo");
+ assert.strictEqual(partner.id, 5);
+});
+
+QUnit.test('message without body should be considered empty', async function (assert) {
+ assert.expect(1);
+ await this.start();
+ const message = this.env.models['mail.message'].create({ id: 11 });
+ assert.ok(message.isEmpty);
+});
+
+QUnit.test('message with body "" should be considered empty', async function (assert) {
+ assert.expect(1);
+ await this.start();
+ const message = this.env.models['mail.message'].create({ body: "", id: 11 });
+ assert.ok(message.isEmpty);
+});
+
+QUnit.test('message with body "<p></p>" should be considered empty', async function (assert) {
+ assert.expect(1);
+ await this.start();
+ const message = this.env.models['mail.message'].create({ body: "<p></p>", id: 11 });
+ assert.ok(message.isEmpty);
+});
+
+QUnit.test('message with body "<p><br></p>" should be considered empty', async function (assert) {
+ assert.expect(1);
+ await this.start();
+ const message = this.env.models['mail.message'].create({ body: "<p><br></p>", id: 11 });
+ assert.ok(message.isEmpty);
+});
+
+QUnit.test('message with body "<p><br/></p>" should be considered empty', async function (assert) {
+ assert.expect(1);
+ await this.start();
+ const message = this.env.models['mail.message'].create({ body: "<p><br/></p>", id: 11 });
+ assert.ok(message.isEmpty);
+});
+
+QUnit.test(String.raw`message with body "<p>\n</p>" should be considered empty`, async function (assert) {
+ assert.expect(1);
+ await this.start();
+ const message = this.env.models['mail.message'].create({ body: "<p>\n</p>", id: 11 });
+ assert.ok(message.isEmpty);
+});
+
+QUnit.test(String.raw`message with body "<p>\r\n\r\n</p>" should be considered empty`, async function (assert) {
+ assert.expect(1);
+ await this.start();
+ const message = this.env.models['mail.message'].create({ body: "<p>\r\n\r\n</p>", id: 11 });
+ assert.ok(message.isEmpty);
+});
+
+QUnit.test('message with body "<p> </p> " should be considered empty', async function (assert) {
+ assert.expect(1);
+ await this.start();
+ const message = this.env.models['mail.message'].create({ body: "<p> </p> ", id: 11 });
+ assert.ok(message.isEmpty);
+});
+
+QUnit.test(`message with body "<img src=''>" should not be considered empty`, async function (assert) {
+ assert.expect(1);
+ await this.start();
+ const message = this.env.models['mail.message'].create({ body: "<img src=''>", id: 11 });
+ assert.notOk(message.isEmpty);
+});
+
+QUnit.test('message with body "test" should not be considered empty', async function (assert) {
+ assert.expect(1);
+ await this.start();
+ const message = this.env.models['mail.message'].create({ body: "test", id: 11 });
+ assert.notOk(message.isEmpty);
+});
+
+});
+});
+});
+
+});
diff --git a/addons/mail/static/src/models/message_seen_indicator/message_seen_indicator.js b/addons/mail/static/src/models/message_seen_indicator/message_seen_indicator.js
new file mode 100644
index 00000000..dd1848aa
--- /dev/null
+++ b/addons/mail/static/src/models/message_seen_indicator/message_seen_indicator.js
@@ -0,0 +1,358 @@
+odoo.define('mail/static/src/models/message_seen_indicator/message_seen_indicator.js', function (require) {
+'use strict';
+
+const { registerNewModel } = require('mail/static/src/model/model_core.js');
+const { attr, many2many, many2one, one2many } = require('mail/static/src/model/model_field.js');
+
+function factory(dependencies) {
+
+ class MessageSeenIndicator extends dependencies['mail.model'] {
+
+ //----------------------------------------------------------------------
+ // Public
+ //----------------------------------------------------------------------
+
+ /**
+ * @static
+ * @param {mail.thread} [channel] the concerned thread
+ */
+ static recomputeFetchedValues(channel = undefined) {
+ const indicatorFindFunction = channel ? localIndicator => localIndicator.thread === channel : undefined;
+ const indicators = this.env.models['mail.message_seen_indicator'].all(indicatorFindFunction);
+ for (const indicator of indicators) {
+ indicator.update({
+ hasEveryoneFetched: indicator._computeHasEveryoneFetched(),
+ hasSomeoneFetched: indicator._computeHasSomeoneFetched(),
+ partnersThatHaveFetched: indicator._computePartnersThatHaveFetched(),
+ });
+ }
+ }
+
+ /**
+ * @static
+ * @param {mail.thread} [channel] the concerned thread
+ */
+ static recomputeSeenValues(channel = undefined) {
+ const indicatorFindFunction = channel ? localIndicator => localIndicator.thread === channel : undefined;
+ const indicators = this.env.models['mail.message_seen_indicator'].all(indicatorFindFunction);
+ for (const indicator of indicators) {
+ indicator.update({
+ hasEveryoneSeen: indicator._computeHasEveryoneSeen(),
+ hasSomeoneFetched: indicator._computeHasSomeoneFetched(),
+ hasSomeoneSeen: indicator._computeHasSomeoneSeen(),
+ isMessagePreviousToLastCurrentPartnerMessageSeenByEveryone:
+ indicator._computeIsMessagePreviousToLastCurrentPartnerMessageSeenByEveryone(),
+ partnersThatHaveFetched: indicator._computePartnersThatHaveFetched(),
+ partnersThatHaveSeen: indicator._computePartnersThatHaveSeen(),
+ });
+ }
+ }
+
+ //----------------------------------------------------------------------
+ // Private
+ //----------------------------------------------------------------------
+
+ /**
+ * @override
+ */
+ static _createRecordLocalId(data) {
+ const { channelId, messageId } = data;
+ return `${this.modelName}_${channelId}_${messageId}`;
+ }
+
+ /**
+ * Manually called as not always called when necessary
+ *
+ * @private
+ * @returns {boolean}
+ * @see computeFetchedValues
+ * @see computeSeenValues
+ */
+ _computeHasEveryoneFetched() {
+ if (!this.message || !this.thread || !this.thread.partnerSeenInfos) {
+ return false;
+ }
+ const otherPartnerSeenInfosDidNotFetch =
+ this.thread.partnerSeenInfos.filter(partnerSeenInfo =>
+ partnerSeenInfo.partner !== this.message.author &&
+ (
+ !partnerSeenInfo.lastFetchedMessage ||
+ partnerSeenInfo.lastFetchedMessage.id < this.message.id
+ )
+ );
+ return otherPartnerSeenInfosDidNotFetch.length === 0;
+ }
+
+ /**
+ * Manually called as not always called when necessary
+ *
+ * @private
+ * @returns {boolean}
+ * @see computeSeenValues
+ */
+ _computeHasEveryoneSeen() {
+ if (!this.message || !this.thread || !this.thread.partnerSeenInfos) {
+ return false;
+ }
+ const otherPartnerSeenInfosDidNotSee =
+ this.thread.partnerSeenInfos.filter(partnerSeenInfo =>
+ partnerSeenInfo.partner !== this.message.author &&
+ (
+ !partnerSeenInfo.lastSeenMessage ||
+ partnerSeenInfo.lastSeenMessage.id < this.message.id
+ )
+ );
+ return otherPartnerSeenInfosDidNotSee.length === 0;
+ }
+
+ /**
+ * Manually called as not always called when necessary
+ *
+ * @private
+ * @returns {boolean}
+ * @see computeFetchedValues
+ * @see computeSeenValues
+ */
+ _computeHasSomeoneFetched() {
+ if (!this.message || !this.thread || !this.thread.partnerSeenInfos) {
+ return false;
+ }
+ const otherPartnerSeenInfosFetched =
+ this.thread.partnerSeenInfos.filter(partnerSeenInfo =>
+ partnerSeenInfo.partner !== this.message.author &&
+ partnerSeenInfo.lastFetchedMessage &&
+ partnerSeenInfo.lastFetchedMessage.id >= this.message.id
+ );
+ return otherPartnerSeenInfosFetched.length > 0;
+ }
+
+ /**
+ * Manually called as not always called when necessary
+ *
+ * @private
+ * @returns {boolean}
+ * @see computeSeenValues
+ */
+ _computeHasSomeoneSeen() {
+ if (!this.message || !this.thread || !this.thread.partnerSeenInfos) {
+ return false;
+ }
+ const otherPartnerSeenInfosSeen =
+ this.thread.partnerSeenInfos.filter(partnerSeenInfo =>
+ partnerSeenInfo.partner !== this.message.author &&
+ partnerSeenInfo.lastSeenMessage &&
+ partnerSeenInfo.lastSeenMessage.id >= this.message.id
+ );
+ return otherPartnerSeenInfosSeen.length > 0;
+ }
+
+ /**
+ * Manually called as not always called when necessary
+ *
+ * @private
+ * @returns {boolean}
+ * @see computeSeenValues
+ */
+ _computeIsMessagePreviousToLastCurrentPartnerMessageSeenByEveryone() {
+ if (
+ !this.message ||
+ !this.thread ||
+ !this.thread.lastCurrentPartnerMessageSeenByEveryone
+ ) {
+ return false;
+ }
+ return this.message.id < this.thread.lastCurrentPartnerMessageSeenByEveryone.id;
+ }
+
+ /**
+ * Manually called as not always called when necessary
+ *
+ * @private
+ * @returns {mail.partner[]}
+ * @see computeFetchedValues
+ * @see computeSeenValues
+ */
+ _computePartnersThatHaveFetched() {
+ if (!this.message || !this.thread || !this.thread.partnerSeenInfos) {
+ return [['unlink-all']];
+ }
+ const otherPartnersThatHaveFetched = this.thread.partnerSeenInfos
+ .filter(partnerSeenInfo =>
+ /**
+ * Relation may not be set yet immediately
+ * @see mail.thread_partner_seen_info:partnerId field
+ * FIXME task-2278551
+ */
+ partnerSeenInfo.partner &&
+ partnerSeenInfo.partner !== this.message.author &&
+ partnerSeenInfo.lastFetchedMessage &&
+ partnerSeenInfo.lastFetchedMessage.id >= this.message.id
+ )
+ .map(partnerSeenInfo => partnerSeenInfo.partner);
+ if (otherPartnersThatHaveFetched.length === 0) {
+ return [['unlink-all']];
+ }
+ return [['replace', otherPartnersThatHaveFetched]];
+ }
+
+ /**
+ * Manually called as not always called when necessary
+ *
+ * @private
+ * @returns {mail.partner[]}
+ * @see computeSeenValues
+ */
+ _computePartnersThatHaveSeen() {
+ if (!this.message || !this.thread || !this.thread.partnerSeenInfos) {
+ return [['unlink-all']];
+ }
+ const otherPartnersThatHaveSeen = this.thread.partnerSeenInfos
+ .filter(partnerSeenInfo =>
+ /**
+ * Relation may not be set yet immediately
+ * @see mail.thread_partner_seen_info:partnerId field
+ * FIXME task-2278551
+ */
+ partnerSeenInfo.partner &&
+ partnerSeenInfo.partner !== this.message.author &&
+ partnerSeenInfo.lastSeenMessage &&
+ partnerSeenInfo.lastSeenMessage.id >= this.message.id)
+ .map(partnerSeenInfo => partnerSeenInfo.partner);
+ if (otherPartnersThatHaveSeen.length === 0) {
+ return [['unlink-all']];
+ }
+ return [['replace', otherPartnersThatHaveSeen]];
+ }
+
+ /**
+ * @private
+ * @returns {mail.message}
+ */
+ _computeMessage() {
+ return [['insert', { id: this.messageId }]];
+ }
+
+ /**
+ * @private
+ * @returns {mail.thread}
+ */
+ _computeThread() {
+ return [['insert', {
+ id: this.channelId,
+ model: 'mail.channel',
+ }]];
+ }
+ }
+
+ MessageSeenIndicator.modelName = 'mail.message_seen_indicator';
+
+ MessageSeenIndicator.fields = {
+ /**
+ * The id of the channel this seen indicator is related to.
+ *
+ * Should write on this field to set relation between the channel and
+ * this seen indicator, not on `thread`.
+ *
+ * Reason for not setting the relation directly is the necessity to
+ * uniquely identify a seen indicator based on channel and message from data.
+ * Relational data are list of commands, which is problematic to deduce
+ * identifying records.
+ *
+ * TODO: task-2322536 (normalize relational data) & task-2323665
+ * (required fields) should improve and let us just use the relational
+ * fields.
+ */
+ channelId: attr(),
+ hasEveryoneFetched: attr({
+ compute: '_computeHasEveryoneFetched',
+ default: false,
+ dependencies: ['messageAuthor', 'messageId', 'threadPartnerSeenInfos'],
+ }),
+ hasEveryoneSeen: attr({
+ compute: '_computeHasEveryoneSeen',
+ default: false,
+ dependencies: ['messageAuthor', 'messageId', 'threadPartnerSeenInfos'],
+ }),
+ hasSomeoneFetched: attr({
+ compute: '_computeHasSomeoneFetched',
+ default: false,
+ dependencies: ['messageAuthor', 'messageId', 'threadPartnerSeenInfos'],
+ }),
+ hasSomeoneSeen: attr({
+ compute: '_computeHasSomeoneSeen',
+ default: false,
+ dependencies: ['messageAuthor', 'messageId', 'threadPartnerSeenInfos'],
+ }),
+ id: attr(),
+ isMessagePreviousToLastCurrentPartnerMessageSeenByEveryone: attr({
+ compute: '_computeIsMessagePreviousToLastCurrentPartnerMessageSeenByEveryone',
+ default: false,
+ dependencies: [
+ 'messageId',
+ 'threadLastCurrentPartnerMessageSeenByEveryone',
+ ],
+ }),
+ /**
+ * The message concerned by this seen indicator.
+ * This is automatically computed based on messageId field.
+ * @see messageId
+ */
+ message: many2one('mail.message', {
+ compute: '_computeMessage',
+ dependencies: [
+ 'messageId',
+ ],
+ }),
+ messageAuthor: many2one('mail.partner', {
+ related: 'message.author',
+ }),
+ /**
+ * The id of the message this seen indicator is related to.
+ *
+ * Should write on this field to set relation between the channel and
+ * this seen indicator, not on `message`.
+ *
+ * Reason for not setting the relation directly is the necessity to
+ * uniquely identify a seen indicator based on channel and message from data.
+ * Relational data are list of commands, which is problematic to deduce
+ * identifying records.
+ *
+ * TODO: task-2322536 (normalize relational data) & task-2323665
+ * (required fields) should improve and let us just use the relational
+ * fields.
+ */
+ messageId: attr(),
+ partnersThatHaveFetched: many2many('mail.partner', {
+ compute: '_computePartnersThatHaveFetched',
+ dependencies: ['messageAuthor', 'messageId', 'threadPartnerSeenInfos'],
+ }),
+ partnersThatHaveSeen: many2many('mail.partner', {
+ compute: '_computePartnersThatHaveSeen',
+ dependencies: ['messageAuthor', 'messageId', 'threadPartnerSeenInfos'],
+ }),
+ /**
+ * The thread concerned by this seen indicator.
+ * This is automatically computed based on channelId field.
+ * @see channelId
+ */
+ thread: many2one('mail.thread', {
+ compute: '_computeThread',
+ dependencies: [
+ 'channelId',
+ ],
+ inverse: 'messageSeenIndicators'
+ }),
+ threadPartnerSeenInfos: one2many('mail.thread_partner_seen_info', {
+ related: 'thread.partnerSeenInfos',
+ }),
+ threadLastCurrentPartnerMessageSeenByEveryone: many2one('mail.message', {
+ related: 'thread.lastCurrentPartnerMessageSeenByEveryone',
+ }),
+ };
+
+ return MessageSeenIndicator;
+}
+
+registerNewModel('mail.message_seen_indicator', factory);
+
+});
diff --git a/addons/mail/static/src/models/messaging/messaging.js b/addons/mail/static/src/models/messaging/messaging.js
new file mode 100644
index 00000000..3544e718
--- /dev/null
+++ b/addons/mail/static/src/models/messaging/messaging.js
@@ -0,0 +1,253 @@
+odoo.define('mail/static/src/models/messaging/messaging.js', function (require) {
+'use strict';
+
+const { registerNewModel } = require('mail/static/src/model/model_core.js');
+const { attr, many2many, many2one, one2many, one2one } = require('mail/static/src/model/model_field.js');
+
+function factory(dependencies) {
+
+ class Messaging extends dependencies['mail.model'] {
+
+ /**
+ * @override
+ */
+ _willDelete() {
+ if (this.env.services['bus_service']) {
+ this.env.services['bus_service'].off('window_focus', null, this._handleGlobalWindowFocus);
+ }
+ return super._willDelete(...arguments);
+ }
+
+ /**
+ * Starts messaging and related records.
+ */
+ async start() {
+ this._handleGlobalWindowFocus = this._handleGlobalWindowFocus.bind(this);
+ this.env.services['bus_service'].on('window_focus', null, this._handleGlobalWindowFocus);
+ await this.async(() => this.initializer.start());
+ this.notificationHandler.start();
+ this.update({ isInitialized: true });
+ }
+
+ //----------------------------------------------------------------------
+ // Public
+ //----------------------------------------------------------------------
+
+ /**
+ * @returns {boolean}
+ */
+ isNotificationPermissionDefault() {
+ const windowNotification = this.env.browser.Notification;
+ return windowNotification
+ ? windowNotification.permission === 'default'
+ : false;
+ }
+
+ /**
+ * Open the form view of the record with provided id and model.
+ * Gets the chat with the provided person and returns it.
+ *
+ * If a chat is not appropriate, a notification is displayed instead.
+ *
+ * @param {Object} param0
+ * @param {integer} [param0.partnerId]
+ * @param {integer} [param0.userId]
+ * @param {Object} [options]
+ * @returns {mail.thread|undefined}
+ */
+ async getChat({ partnerId, userId }) {
+ if (userId) {
+ const user = this.env.models['mail.user'].insert({ id: userId });
+ return user.getChat();
+ }
+ if (partnerId) {
+ const partner = this.env.models['mail.partner'].insert({ id: partnerId });
+ return partner.getChat();
+ }
+ }
+
+ /**
+ * Opens a chat with the provided person and returns it.
+ *
+ * If a chat is not appropriate, a notification is displayed instead.
+ *
+ * @param {Object} person forwarded to @see `getChat()`
+ * @param {Object} [options] forwarded to @see `mail.thread:open()`
+ * @returns {mail.thread|undefined}
+ */
+ async openChat(person, options) {
+ const chat = await this.async(() => this.getChat(person));
+ if (!chat) {
+ return;
+ }
+ await this.async(() => chat.open(options));
+ return chat;
+ }
+
+ /**
+ * Opens the form view of the record with provided id and model.
+ *
+ * @param {Object} param0
+ * @param {integer} param0.id
+ * @param {string} param0.model
+ */
+ async openDocument({ id, model }) {
+ this.env.bus.trigger('do-action', {
+ action: {
+ type: 'ir.actions.act_window',
+ res_model: model,
+ views: [[false, 'form']],
+ res_id: id,
+ },
+ });
+ if (this.env.messaging.device.isMobile) {
+ // messaging menu has a higher z-index than views so it must
+ // be closed to ensure the visibility of the view
+ this.env.messaging.messagingMenu.close();
+ }
+ }
+
+ /**
+ * Opens the most appropriate view that is a profile for provided id and
+ * model.
+ *
+ * @param {Object} param0
+ * @param {integer} param0.id
+ * @param {string} param0.model
+ */
+ async openProfile({ id, model }) {
+ if (model === 'res.partner') {
+ const partner = this.env.models['mail.partner'].insert({ id });
+ return partner.openProfile();
+ }
+ if (model === 'res.users') {
+ const user = this.env.models['mail.user'].insert({ id });
+ return user.openProfile();
+ }
+ if (model === 'mail.channel') {
+ let channel = this.env.models['mail.thread'].findFromIdentifyingData({ id, model: 'mail.channel' });
+ if (!channel) {
+ channel = (await this.async(() =>
+ this.env.models['mail.thread'].performRpcChannelInfo({ ids: [id] })
+ ))[0];
+ }
+ if (!channel) {
+ this.env.services['notification'].notify({
+ message: this.env._t("You can only open the profile of existing channels."),
+ type: 'warning',
+ });
+ return;
+ }
+ return channel.openProfile();
+ }
+ return this.env.messaging.openDocument({ id, model });
+ }
+
+ //----------------------------------------------------------------------
+ // Private
+ //----------------------------------------------------------------------
+
+ /**
+ * @private
+ */
+ _handleGlobalWindowFocus() {
+ this.update({ outOfFocusUnreadMessageCounter: 0 });
+ this.env.bus.trigger('set_title_part', {
+ part: '_chat',
+ });
+ }
+
+ }
+
+ Messaging.fields = {
+ cannedResponses: one2many('mail.canned_response'),
+ chatWindowManager: one2one('mail.chat_window_manager', {
+ default: [['create']],
+ inverse: 'messaging',
+ isCausal: true,
+ }),
+ commands: one2many('mail.channel_command'),
+ currentPartner: one2one('mail.partner'),
+ currentUser: one2one('mail.user'),
+ device: one2one('mail.device', {
+ default: [['create']],
+ isCausal: true,
+ }),
+ dialogManager: one2one('mail.dialog_manager', {
+ default: [['create']],
+ isCausal: true,
+ }),
+ discuss: one2one('mail.discuss', {
+ default: [['create']],
+ inverse: 'messaging',
+ isCausal: true,
+ }),
+ /**
+ * Mailbox History.
+ */
+ history: one2one('mail.thread'),
+ /**
+ * Mailbox Inbox.
+ */
+ inbox: one2one('mail.thread'),
+ initializer: one2one('mail.messaging_initializer', {
+ default: [['create']],
+ inverse: 'messaging',
+ isCausal: true,
+ }),
+ isInitialized: attr({
+ default: false,
+ }),
+ locale: one2one('mail.locale', {
+ default: [['create']],
+ isCausal: true,
+ }),
+ messagingMenu: one2one('mail.messaging_menu', {
+ default: [['create']],
+ inverse: 'messaging',
+ isCausal: true,
+ }),
+ /**
+ * Mailbox Moderation.
+ */
+ moderation: one2one('mail.thread'),
+ notificationGroupManager: one2one('mail.notification_group_manager', {
+ default: [['create']],
+ isCausal: true,
+ }),
+ notificationHandler: one2one('mail.messaging_notification_handler', {
+ default: [['create']],
+ inverse: 'messaging',
+ isCausal: true,
+ }),
+ outOfFocusUnreadMessageCounter: attr({
+ default: 0,
+ }),
+ partnerRoot: many2one('mail.partner'),
+ /**
+ * Determines which partner should be considered the public partner,
+ * which is a special partner notably used in livechat.
+ *
+ * @deprecated in favor of `publicPartners` because in multi-website
+ * setup there might be a different public partner per website.
+ */
+ publicPartner: many2one('mail.partner'),
+ /**
+ * Determines which partners should be considered the public partners,
+ * which are special partners notably used in livechat.
+ */
+ publicPartners: many2many('mail.partner'),
+ /**
+ * Mailbox Starred.
+ */
+ starred: one2one('mail.thread'),
+ };
+
+ Messaging.modelName = 'mail.messaging';
+
+ return Messaging;
+}
+
+registerNewModel('mail.messaging', factory);
+
+});
diff --git a/addons/mail/static/src/models/messaging/messaging_tests.js b/addons/mail/static/src/models/messaging/messaging_tests.js
new file mode 100644
index 00000000..b306fbb1
--- /dev/null
+++ b/addons/mail/static/src/models/messaging/messaging_tests.js
@@ -0,0 +1,126 @@
+odoo.define('mail/static/src/models/messaging/messaging_tests.js', function (require) {
+'use strict';
+
+const { afterEach, beforeEach, start } = require('mail/static/src/utils/test_utils.js');
+
+QUnit.module('mail', {}, function () {
+QUnit.module('models', {}, function () {
+QUnit.module('messaging', {}, function () {
+QUnit.module('messaging_tests.js', {
+ beforeEach() {
+ beforeEach(this);
+
+ this.start = async params => {
+ const { env, widget } = await start(Object.assign({}, params, {
+ data: this.data,
+ }));
+ this.env = env;
+ this.widget = widget;
+ };
+ },
+ afterEach() {
+ afterEach(this);
+ },
+}, function () {
+
+QUnit.test('openChat: display notification for partner without user', async function (assert) {
+ assert.expect(2);
+
+ this.data['res.partner'].records.push({ id: 14 });
+ await this.start();
+
+ await this.env.messaging.openChat({ partnerId: 14 });
+ assert.containsOnce(
+ document.body,
+ '.toast .o_notification_content',
+ "should display a toast notification after failing to open chat"
+ );
+ assert.strictEqual(
+ document.querySelector('.o_notification_content').textContent,
+ "You can only chat with partners that have a dedicated user.",
+ "should display the correct information in the notification"
+ );
+});
+
+QUnit.test('openChat: display notification for wrong user', async function (assert) {
+ assert.expect(2);
+
+ await this.start();
+
+ // user id not in this.data
+ await this.env.messaging.openChat({ userId: 14 });
+ assert.containsOnce(
+ document.body,
+ '.toast .o_notification_content',
+ "should display a toast notification after failing to open chat"
+ );
+ assert.strictEqual(
+ document.querySelector('.o_notification_content').textContent,
+ "You can only chat with existing users.",
+ "should display the correct information in the notification"
+ );
+});
+
+QUnit.test('openChat: open new chat for user', async function (assert) {
+ assert.expect(3);
+
+ this.data['res.partner'].records.push({ id: 14 });
+ this.data['res.users'].records.push({ id: 11, partner_id: 14 });
+ await this.start();
+
+ const existingChat = this.env.models['mail.thread'].find(thread =>
+ thread.channel_type === 'chat' &&
+ thread.correspondent &&
+ thread.correspondent.id === 14 &&
+ thread.model === 'mail.channel' &&
+ thread.public === 'private'
+ );
+ assert.notOk(existingChat, 'a chat should not exist with the target partner initially');
+
+ await this.env.messaging.openChat({ partnerId: 14 });
+ const chat = this.env.models['mail.thread'].find(thread =>
+ thread.channel_type === 'chat' &&
+ thread.correspondent &&
+ thread.correspondent.id === 14 &&
+ thread.model === 'mail.channel' &&
+ thread.public === 'private'
+ );
+ assert.ok(chat, 'a chat should exist with the target partner');
+ assert.strictEqual(chat.threadViews.length, 1, 'the chat should be displayed in a `mail.thread_view`');
+});
+
+QUnit.test('openChat: open existing chat for user', async function (assert) {
+ assert.expect(5);
+
+ this.data['res.partner'].records.push({ id: 14 });
+ this.data['res.users'].records.push({ id: 11, partner_id: 14 });
+ this.data['mail.channel'].records.push({
+ channel_type: "chat",
+ id: 10,
+ members: [this.data.currentPartnerId, 14],
+ public: 'private',
+ });
+ await this.start();
+ const existingChat = this.env.models['mail.thread'].find(thread =>
+ thread.channel_type === 'chat' &&
+ thread.correspondent &&
+ thread.correspondent.id === 14 &&
+ thread.model === 'mail.channel' &&
+ thread.public === 'private'
+ );
+ assert.ok(existingChat, 'a chat should initially exist with the target partner');
+ assert.strictEqual(existingChat.threadViews.length, 0, 'the chat should not be displayed in a `mail.thread_view`');
+
+ await this.env.messaging.openChat({ partnerId: 14 });
+ assert.ok(existingChat, 'a chat should still exist with the target partner');
+ assert.strictEqual(existingChat.id, 10, 'the chat should be the existing chat');
+ assert.strictEqual(existingChat.threadViews.length, 1, 'the chat should now be displayed in a `mail.thread_view`');
+});
+
+});
+
+});
+});
+});
+
+});
diff --git a/addons/mail/static/src/models/messaging_initializer/messaging_initializer.js b/addons/mail/static/src/models/messaging_initializer/messaging_initializer.js
new file mode 100644
index 00000000..97d0d3b1
--- /dev/null
+++ b/addons/mail/static/src/models/messaging_initializer/messaging_initializer.js
@@ -0,0 +1,304 @@
+odoo.define('mail/static/src/models/messaging_initializer/messaging_initializer.js', function (require) {
+'use strict';
+
+const { registerNewModel } = require('mail/static/src/model/model_core.js');
+const { one2one } = require('mail/static/src/model/model_field.js');
+const { executeGracefully } = require('mail/static/src/utils/utils.js');
+
+function factory(dependencies) {
+
+ class MessagingInitializer extends dependencies['mail.model'] {
+
+ //----------------------------------------------------------------------
+ // Public
+ //----------------------------------------------------------------------
+
+ /**
+ * Fetch messaging data initially to populate the store specifically for
+ * the current user. This includes pinned channels for instance.
+ */
+ async start() {
+ this.messaging.update({
+ history: [['create', {
+ id: 'history',
+ isServerPinned: true,
+ model: 'mail.box',
+ name: this.env._t("History"),
+ }]],
+ inbox: [['create', {
+ id: 'inbox',
+ isServerPinned: true,
+ model: 'mail.box',
+ name: this.env._t("Inbox"),
+ }]],
+ moderation: [['create', {
+ id: 'moderation',
+ model: 'mail.box',
+ name: this.env._t("Moderation"),
+ }]],
+ starred: [['create', {
+ id: 'starred',
+ isServerPinned: true,
+ model: 'mail.box',
+ name: this.env._t("Starred"),
+ }]],
+ });
+ const device = this.messaging.device;
+ device.start();
+ const context = Object.assign({
+ isMobile: device.isMobile,
+ }, this.env.session.user_context);
+ const discuss = this.messaging.discuss;
+ const data = await this.async(() => this.env.services.rpc({
+ route: '/mail/init_messaging',
+ params: { context: context }
+ }, { shadow: true }));
+ await this.async(() => this._init(data));
+ if (discuss.isOpen) {
+ discuss.openInitThread();
+ }
+ if (this.env.autofetchPartnerImStatus) {
+ this.env.models['mail.partner'].startLoopFetchImStatus();
+ }
+ }
+
+ //----------------------------------------------------------------------
+ // Private
+ //----------------------------------------------------------------------
+
+ /**
+ * @private
+ * @param {Object} param0
+ * @param {Object} param0.channel_slots
+ * @param {Array} [param0.commands=[]]
+ * @param {Object} param0.current_partner
+ * @param {integer} param0.current_user_id
+ * @param {Object} [param0.mail_failures={}]
+ * @param {Object[]} [param0.mention_partner_suggestions=[]]
+ * @param {Object[]} [param0.moderation_channel_ids=[]]
+ * @param {integer} [param0.moderation_counter=0]
+ * @param {integer} [param0.needaction_inbox_counter=0]
+ * @param {Object} param0.partner_root
+ * @param {Object} param0.public_partner
+ * @param {Object[]} param0.public_partners
+ * @param {Object[]} [param0.shortcodes=[]]
+ * @param {integer} [param0.starred_counter=0]
+ */
+ async _init({
+ channel_slots,
+ commands = [],
+ current_partner,
+ current_user_id,
+ mail_failures = {},
+ mention_partner_suggestions = [],
+ menu_id,
+ moderation_channel_ids = [],
+ moderation_counter = 0,
+ needaction_inbox_counter = 0,
+ partner_root,
+ public_partner,
+ public_partners,
+ shortcodes = [],
+ starred_counter = 0
+ }) {
+ const discuss = this.messaging.discuss;
+ // partners first because the rest of the code relies on them
+ this._initPartners({
+ current_partner,
+ current_user_id,
+ moderation_channel_ids,
+ partner_root,
+ public_partner,
+ public_partners,
+ });
+ // mailboxes after partners and before other initializers that might
+ // manipulate threads or messages
+ this._initMailboxes({
+ moderation_channel_ids,
+ moderation_counter,
+ needaction_inbox_counter,
+ starred_counter,
+ });
+ // various suggestions in no particular order
+ this._initCannedResponses(shortcodes);
+ this._initCommands(commands);
+ this._initMentionPartnerSuggestions(mention_partner_suggestions);
+ // channels when the rest of messaging is ready
+ await this.async(() => this._initChannels(channel_slots));
+ // failures after channels
+ this._initMailFailures(mail_failures);
+ discuss.update({ menu_id });
+ }
+
+ /**
+ * @private
+ * @param {Object[]} cannedResponsesData
+ */
+ _initCannedResponses(cannedResponsesData) {
+ this.messaging.update({
+ cannedResponses: [['insert', cannedResponsesData]],
+ });
+ }
+
+ /**
+ * @private
+ * @param {Object} [param0={}]
+ * @param {Object[]} [param0.channel_channel=[]]
+ * @param {Object[]} [param0.channel_direct_message=[]]
+ * @param {Object[]} [param0.channel_private_group=[]]
+ */
+ async _initChannels({
+ channel_channel = [],
+ channel_direct_message = [],
+ channel_private_group = [],
+ } = {}) {
+ const channelsData = channel_channel.concat(channel_direct_message, channel_private_group);
+ return executeGracefully(channelsData.map(channelData => () => {
+ const convertedData = this.env.models['mail.thread'].convertData(channelData);
+ if (!convertedData.members) {
+ // channel_info does not return all members of channel for
+ // performance reasons, but code is expecting to know at
+ // least if the current partner is member of it.
+ // (e.g. to know when to display "invited" notification)
+ // Current partner can always be assumed to be a member of
+ // channels received at init.
+ convertedData.members = [['link', this.env.messaging.currentPartner]];
+ }
+ const channel = this.env.models['mail.thread'].insert(
+ Object.assign({ model: 'mail.channel' }, convertedData)
+ );
+ // flux specific: channels received at init have to be
+ // considered pinned. task-2284357
+ if (!channel.isPinned) {
+ channel.pin();
+ }
+ }));
+ }
+
+ /**
+ * @private
+ * @param {Object[]} commandsData
+ */
+ _initCommands(commandsData) {
+ this.messaging.update({
+ commands: [['insert', commandsData]],
+ });
+ }
+
+ /**
+ * @private
+ * @param {Object} param0
+ * @param {Object[]} [param0.moderation_channel_ids=[]]
+ * @param {integer} param0.moderation_counter
+ * @param {integer} param0.needaction_inbox_counter
+ * @param {integer} param0.starred_counter
+ */
+ _initMailboxes({
+ moderation_channel_ids,
+ moderation_counter,
+ needaction_inbox_counter,
+ starred_counter,
+ }) {
+ this.env.messaging.inbox.update({ counter: needaction_inbox_counter });
+ this.env.messaging.starred.update({ counter: starred_counter });
+ if (moderation_channel_ids.length > 0) {
+ this.messaging.moderation.update({
+ counter: moderation_counter,
+ isServerPinned: true,
+ });
+ }
+ }
+
+ /**
+ * @private
+ * @param {Object} mailFailuresData
+ */
+ async _initMailFailures(mailFailuresData) {
+ await executeGracefully(mailFailuresData.map(messageData => () => {
+ const message = this.env.models['mail.message'].insert(
+ this.env.models['mail.message'].convertData(messageData)
+ );
+ // implicit: failures are sent by the server at initialization
+ // only if the current partner is author of the message
+ if (!message.author && this.messaging.currentPartner) {
+ message.update({ author: [['link', this.messaging.currentPartner]] });
+ }
+ }));
+ this.messaging.notificationGroupManager.computeGroups();
+ // manually force recompute of counter (after computing the groups)
+ this.messaging.messagingMenu.update();
+ }
+
+ /**
+ * @private
+ * @param {Object[]} mentionPartnerSuggestionsData
+ */
+ async _initMentionPartnerSuggestions(mentionPartnerSuggestionsData) {
+ return executeGracefully(mentionPartnerSuggestionsData.map(suggestions => () => {
+ return executeGracefully(suggestions.map(suggestion => () => {
+ this.env.models['mail.partner'].insert(this.env.models['mail.partner'].convertData(suggestion));
+ }));
+ }));
+ }
+
+ /**
+ * @private
+ * @param {Object} current_partner
+ * @param {integer} current_user_id
+ * @param {integer[]} moderation_channel_ids
+ * @param {Object} partner_root
+ * @param {Object} public_partner
+ * @param {Object[]} [public_partners=[]]
+ */
+ _initPartners({
+ current_partner,
+ current_user_id: currentUserId,
+ moderation_channel_ids = [],
+ partner_root,
+ public_partner,
+ public_partners = [],
+ }) {
+ const publicPartner = this.env.models['mail.partner'].convertData(public_partner);
+ this.messaging.update({
+ currentPartner: [['insert', Object.assign(
+ this.env.models['mail.partner'].convertData(current_partner),
+ {
+ moderatedChannels: [
+ ['insert', moderation_channel_ids.map(id => {
+ return {
+ id,
+ model: 'mail.channel',
+ };
+ })],
+ ],
+ user: [['insert', { id: currentUserId }]],
+ }
+ )]],
+ currentUser: [['insert', { id: currentUserId }]],
+ partnerRoot: [['insert', this.env.models['mail.partner'].convertData(partner_root)]],
+ publicPartner: [['insert', publicPartner]],
+ publicPartners: [
+ ['insert', publicPartner],
+ ['insert', public_partners.map(
+ publicPartner => this.env.models['mail.partner'].convertData(publicPartner))
+ ],
+ ],
+ });
+ }
+
+ }
+
+ MessagingInitializer.fields = {
+ messaging: one2one('mail.messaging', {
+ inverse: 'initializer',
+ }),
+ };
+
+ MessagingInitializer.modelName = 'mail.messaging_initializer';
+
+ return MessagingInitializer;
+}
+
+registerNewModel('mail.messaging_initializer', factory);
+
+});
diff --git a/addons/mail/static/src/models/messaging_menu/messaging_menu.js b/addons/mail/static/src/models/messaging_menu/messaging_menu.js
new file mode 100644
index 00000000..60212930
--- /dev/null
+++ b/addons/mail/static/src/models/messaging_menu/messaging_menu.js
@@ -0,0 +1,154 @@
+odoo.define('mail/static/src/models/messaging_menu/messaging_menu.js', function (require) {
+'use strict';
+
+const { registerNewModel } = require('mail/static/src/model/model_core.js');
+const { attr, one2one } = require('mail/static/src/model/model_field.js');
+
+function factory(dependencies) {
+
+ class MessagingMenu extends dependencies['mail.model'] {
+
+ //----------------------------------------------------------------------
+ // Public
+ //----------------------------------------------------------------------
+
+ /**
+ * Close the messaging menu. Should reset its internal state.
+ */
+ close() {
+ this.update({ isOpen: false });
+ }
+
+ /**
+ * Toggle the visibility of the messaging menu "new message" input in
+ * mobile.
+ */
+ toggleMobileNewMessage() {
+ this.update({ isMobileNewMessageToggled: !this.isMobileNewMessageToggled });
+ }
+
+ /**
+ * Toggle whether the messaging menu is open or not.
+ */
+ toggleOpen() {
+ this.update({ isOpen: !this.isOpen });
+ }
+
+ //----------------------------------------------------------------------
+ // Private
+ //----------------------------------------------------------------------
+
+ /**
+ * @private
+ */
+ _computeInboxMessagesAutoloader() {
+ if (!this.isOpen) {
+ return;
+ }
+ const inbox = this.env.messaging.inbox;
+ if (!inbox || !inbox.mainCache) {
+ return;
+ }
+ // populate some needaction messages on threads.
+ inbox.mainCache.update({ isCacheRefreshRequested: true });
+ }
+
+ /**
+ * @private
+ * @returns {integer}
+ */
+ _updateCounter() {
+ if (!this.env.messaging) {
+ return 0;
+ }
+ const inboxMailbox = this.env.messaging.inbox;
+ const unreadChannels = this.env.models['mail.thread'].all(thread =>
+ thread.localMessageUnreadCounter > 0 &&
+ thread.model === 'mail.channel' &&
+ thread.isPinned
+ );
+ let counter = unreadChannels.length;
+ if (inboxMailbox) {
+ counter += inboxMailbox.counter;
+ }
+ if (this.messaging.notificationGroupManager) {
+ counter += this.messaging.notificationGroupManager.groups.reduce(
+ (total, group) => total + group.notifications.length,
+ 0
+ );
+ }
+ if (this.messaging.isNotificationPermissionDefault()) {
+ counter++;
+ }
+ return counter;
+ }
+
+ /**
+ * @override
+ */
+ _updateAfter(previous) {
+ const counter = this._updateCounter();
+ if (this.counter !== counter) {
+ this.update({ counter });
+ }
+ }
+
+ }
+
+ MessagingMenu.fields = {
+ /**
+ * Tab selected in the messaging menu.
+ * Either 'all', 'chat' or 'channel'.
+ */
+ activeTabId: attr({
+ default: 'all',
+ }),
+ counter: attr({
+ default: 0,
+ }),
+ /**
+ * Dummy field to automatically load messages of inbox when messaging
+ * menu is open.
+ *
+ * Useful because needaction notifications require fetching inbox
+ * messages to work.
+ */
+ inboxMessagesAutoloader: attr({
+ compute: '_computeInboxMessagesAutoloader',
+ dependencies: [
+ 'isOpen',
+ 'messagingInbox',
+ 'messagingInboxMainCache',
+ ],
+ }),
+ /**
+ * Determine whether the mobile new message input is visible or not.
+ */
+ isMobileNewMessageToggled: attr({
+ default: false,
+ }),
+ /**
+ * Determine whether the messaging menu dropdown is open or not.
+ */
+ isOpen: attr({
+ default: false,
+ }),
+ messaging: one2one('mail.messaging', {
+ inverse: 'messagingMenu',
+ }),
+ messagingInbox: one2one('mail.thread', {
+ related: 'messaging.inbox',
+ }),
+ messagingInboxMainCache: one2one('mail.thread_cache', {
+ related: 'messagingInbox.mainCache',
+ }),
+ };
+
+ MessagingMenu.modelName = 'mail.messaging_menu';
+
+ return MessagingMenu;
+}
+
+registerNewModel('mail.messaging_menu', factory);
+
+});
diff --git a/addons/mail/static/src/models/messaging_notification_handler/messaging_notification_handler.js b/addons/mail/static/src/models/messaging_notification_handler/messaging_notification_handler.js
new file mode 100644
index 00000000..a42ede1c
--- /dev/null
+++ b/addons/mail/static/src/models/messaging_notification_handler/messaging_notification_handler.js
@@ -0,0 +1,795 @@
+odoo.define('mail/static/src/models/messaging_notification_handler/messaging_notification_handler.js', function (require) {
+'use strict';
+
+const { registerNewModel } = require('mail/static/src/model/model_core.js');
+const { one2one } = require('mail/static/src/model/model_field.js');
+const { decrement, increment } = require('mail/static/src/model/model_field_command.js');
+const { htmlToTextContentInline } = require('mail.utils');
+
+const PREVIEW_MSG_MAX_SIZE = 350; // optimal for native English speakers
+
+function factory(dependencies) {
+
+ class MessagingNotificationHandler extends dependencies['mail.model'] {
+
+ /**
+ * @override
+ */
+ _willDelete() {
+ if (this.env.services['bus_service']) {
+ this.env.services['bus_service'].off('notification');
+ this.env.services['bus_service'].stopPolling();
+ }
+ return super._willDelete(...arguments);
+ }
+
+ //----------------------------------------------------------------------
+ // Public
+ //----------------------------------------------------------------------
+
+ /**
+ * Fetch messaging data initially to populate the store specifically for
+ * the current users. This includes pinned channels for instance.
+ */
+ start() {
+ this.env.services.bus_service.onNotification(null, notifs => this._handleNotifications(notifs));
+ this.env.services.bus_service.startPolling();
+ }
+
+ //----------------------------------------------------------------------
+ // Private
+ //----------------------------------------------------------------------
+
+ /**
+ * @private
+ * @param {Object[]} notifications
+ * @returns {Object[]}
+ */
+ _filterNotificationsOnUnsubscribe(notifications) {
+ const unsubscribedNotif = notifications.find(notif =>
+ notif[1].info === 'unsubscribe');
+ if (unsubscribedNotif) {
+ notifications = notifications.filter(notif =>
+ notif[0][1] !== 'mail.channel' ||
+ notif[0][2] !== unsubscribedNotif[1].id
+ );
+ }
+ return notifications;
+ }
+
+ /**
+ * @private
+ * @param {Object[]} notifications
+ * @param {Array|string} notifications[i][0] meta-data of the notification.
+ * @param {string} notifications[i][0][0] name of database this
+ * notification comes from.
+ * @param {string} notifications[i][0][1] type of notification.
+ * @param {integer} notifications[i][0][2] usually id of related type
+ * of notification. For instance, with `mail.channel`, this is the id
+ * of the channel.
+ * @param {Object} notifications[i][1] payload of the notification
+ */
+ async _handleNotifications(notifications) {
+ const filteredNotifications = this._filterNotificationsOnUnsubscribe(notifications);
+ const proms = filteredNotifications.map(notification => {
+ const [channel, message] = notification;
+ if (typeof channel === 'string') {
+ // uuid notification, only for (livechat) public handler
+ return;
+ }
+ const [, model, id] = channel;
+ switch (model) {
+ case 'ir.needaction':
+ return this._handleNotificationNeedaction(message);
+ case 'mail.channel':
+ return this._handleNotificationChannel(id, message);
+ case 'res.partner':
+ if (id !== this.env.messaging.currentPartner.id) {
+ // ignore broadcast to other partners
+ return;
+ }
+ return this._handleNotificationPartner(Object.assign({}, message));
+ }
+ });
+ await this.async(() => Promise.all(proms));
+ }
+
+ /**
+ * @private
+ * @param {integer} channelId
+ * @param {Object} data
+ * @param {string} [data.info]
+ * @param {boolean} [data.is_typing]
+ * @param {integer} [data.last_message_id]
+ * @param {integer} [data.partner_id]
+ */
+ async _handleNotificationChannel(channelId, data) {
+ const {
+ info,
+ is_typing,
+ last_message_id,
+ partner_id,
+ partner_name,
+ } = data;
+ switch (info) {
+ case 'channel_fetched':
+ return this._handleNotificationChannelFetched(channelId, {
+ last_message_id,
+ partner_id,
+ });
+ case 'channel_seen':
+ return this._handleNotificationChannelSeen(channelId, {
+ last_message_id,
+ partner_id,
+ });
+ case 'typing_status':
+ return this._handleNotificationChannelTypingStatus(channelId, {
+ is_typing,
+ partner_id,
+ partner_name,
+ });
+ default:
+ return this._handleNotificationChannelMessage(channelId, data);
+ }
+ }
+
+ /**
+ * @private
+ * @param {integer} channelId
+ * @param {Object} param1
+ * @param {integer} param1.last_message_id
+ * @param {integer} param1.partner_id
+ */
+ async _handleNotificationChannelFetched(channelId, {
+ last_message_id,
+ partner_id,
+ }) {
+ const channel = this.env.models['mail.thread'].findFromIdentifyingData({
+ id: channelId,
+ model: 'mail.channel',
+ });
+ if (!channel) {
+ // for example seen from another browser, the current one has no
+ // knowledge of the channel
+ return;
+ }
+ if (channel.channel_type === 'channel') {
+ // disabled on `channel` channels for performance reasons
+ return;
+ }
+ this.env.models['mail.thread_partner_seen_info'].insert({
+ channelId: channel.id,
+ lastFetchedMessage: [['insert', { id: last_message_id }]],
+ partnerId: partner_id,
+ });
+ channel.update({
+ messageSeenIndicators: [['insert',
+ {
+ channelId: channel.id,
+ messageId: last_message_id,
+ }
+ ]],
+ });
+ // FIXME force the computing of message values (cf task-2261221)
+ this.env.models['mail.message_seen_indicator'].recomputeFetchedValues(channel);
+ }
+
+ /**
+ * @private
+ * @param {integer} channelId
+ * @param {Object} messageData
+ */
+ async _handleNotificationChannelMessage(channelId, messageData) {
+ let channel = this.env.models['mail.thread'].findFromIdentifyingData({
+ id: channelId,
+ model: 'mail.channel',
+ });
+ const wasChannelExisting = !!channel;
+ const convertedData = this.env.models['mail.message'].convertData(messageData);
+ const oldMessage = this.env.models['mail.message'].findFromIdentifyingData(convertedData);
+ // locally save old values, as insert would overwrite them
+ const oldMessageModerationStatus = (
+ oldMessage && oldMessage.moderation_status
+ );
+ const oldMessageWasModeratedByCurrentPartner = (
+ oldMessage && oldMessage.isModeratedByCurrentPartner
+ );
+
+ // Fetch missing info from channel before going further. Inserting
+ // a channel with incomplete info can lead to issues. This is in
+ // particular the case with the `uuid` field that is assumed
+ // "required" by the rest of the code and is necessary for some
+ // features such as chat windows.
+ if (!channel) {
+ channel = (await this.async(() =>
+ this.env.models['mail.thread'].performRpcChannelInfo({ ids: [channelId] })
+ ))[0];
+ }
+ if (!channel.isPinned) {
+ channel.pin();
+ }
+
+ const message = this.env.models['mail.message'].insert(convertedData);
+ this._notifyThreadViewsMessageReceived(message);
+
+ // If the message was already known: nothing else should be done,
+ // except if it was pending moderation by the current partner, then
+ // decrement the moderation counter.
+ if (oldMessage) {
+ if (
+ oldMessageModerationStatus === 'pending_moderation' &&
+ message.moderation_status !== 'pending_moderation' &&
+ oldMessageWasModeratedByCurrentPartner
+ ) {
+ const moderation = this.env.messaging.moderation;
+ moderation.update({ counter: decrement() });
+ }
+ return;
+ }
+
+ // If the current partner is author, do nothing else.
+ if (message.author === this.env.messaging.currentPartner) {
+ return;
+ }
+
+ // Message from mailing channel should not make a notification in
+ // Odoo for users with notification "Handled by Email".
+ // Channel has been marked as read server-side in this case, so
+ // it should not display a notification by incrementing the
+ // unread counter.
+ if (
+ channel.mass_mailing &&
+ this.env.session.notification_type === 'email'
+ ) {
+ this._handleNotificationChannelSeen(channelId, {
+ last_message_id: messageData.id,
+ partner_id: this.env.messaging.currentPartner.id,
+ });
+ return;
+ }
+ // In all other cases: update counter and notify if necessary
+
+ // Chat from OdooBot is considered disturbing and should only be
+ // shown on the menu, but no notification and no thread open.
+ const isChatWithOdooBot = (
+ channel.correspondent &&
+ channel.correspondent === this.env.messaging.partnerRoot
+ );
+ if (!isChatWithOdooBot) {
+ const isOdooFocused = this.env.services['bus_service'].isOdooFocused();
+ // Notify if out of focus
+ if (!isOdooFocused && channel.isChatChannel) {
+ this._notifyNewChannelMessageWhileOutOfFocus({
+ channel,
+ message,
+ });
+ }
+ if (channel.model === 'mail.channel' && channel.channel_type !== 'channel') {
+ // disabled on non-channel threads and
+ // on `channel` channels for performance reasons
+ channel.markAsFetched();
+ }
+ // open chat on receiving new message if it was not already opened or folded
+ if (channel.channel_type !== 'channel' && !this.env.messaging.device.isMobile && !channel.chatWindow) {
+ this.env.messaging.chatWindowManager.openThread(channel);
+ }
+ }
+
+ // If the channel wasn't known its correct counter was fetched at
+ // the start of the method, no need update it here.
+ if (!wasChannelExisting) {
+ return;
+ }
+ // manually force recompute of counter
+ this.messaging.messagingMenu.update();
+ }
+
+ /**
+ * Called when a channel has been seen, and the server responds with the
+ * last message seen. Useful in order to track last message seen.
+ *
+ * @private
+ * @param {integer} channelId
+ * @param {Object} param1
+ * @param {integer} param1.last_message_id
+ * @param {integer} param1.partner_id
+ */
+ async _handleNotificationChannelSeen(channelId, {
+ last_message_id,
+ partner_id,
+ }) {
+ const channel = this.env.models['mail.thread'].findFromIdentifyingData({
+ id: channelId,
+ model: 'mail.channel',
+ });
+ if (!channel) {
+ // for example seen from another browser, the current one has no
+ // knowledge of the channel
+ return;
+ }
+ const lastMessage = this.env.models['mail.message'].insert({ id: last_message_id });
+ // restrict computation of seen indicator for "non-channel" channels
+ // for performance reasons
+ const shouldComputeSeenIndicators = channel.channel_type !== 'channel';
+ const updateData = {};
+ if (shouldComputeSeenIndicators) {
+ this.env.models['mail.thread_partner_seen_info'].insert({
+ channelId: channel.id,
+ lastSeenMessage: [['link', lastMessage]],
+ partnerId: partner_id,
+ });
+ Object.assign(updateData, {
+ // FIXME should no longer use computeId (task-2335647)
+ messageSeenIndicators: [['insert',
+ {
+ channelId: channel.id,
+ messageId: lastMessage.id,
+ },
+ ]],
+ });
+ }
+ if (this.env.messaging.currentPartner.id === partner_id) {
+ Object.assign(updateData, {
+ lastSeenByCurrentPartnerMessageId: last_message_id,
+ pendingSeenMessageId: undefined,
+ });
+ }
+ channel.update(updateData);
+ if (shouldComputeSeenIndicators) {
+ // FIXME force the computing of thread values (cf task-2261221)
+ this.env.models['mail.thread'].computeLastCurrentPartnerMessageSeenByEveryone(channel);
+ // FIXME force the computing of message values (cf task-2261221)
+ this.env.models['mail.message_seen_indicator'].recomputeSeenValues(channel);
+ }
+ // manually force recompute of counter
+ this.messaging.messagingMenu.update();
+ }
+
+ /**
+ * @private
+ * @param {integer} channelId
+ * @param {Object} param1
+ * @param {boolean} param1.is_typing
+ * @param {integer} param1.partner_id
+ * @param {string} param1.partner_name
+ */
+ _handleNotificationChannelTypingStatus(channelId, { is_typing, partner_id, partner_name }) {
+ const channel = this.env.models['mail.thread'].findFromIdentifyingData({
+ id: channelId,
+ model: 'mail.channel',
+ });
+ if (!channel) {
+ return;
+ }
+ const partner = this.env.models['mail.partner'].insert({
+ id: partner_id,
+ name: partner_name,
+ });
+ if (partner === this.env.messaging.currentPartner) {
+ // Ignore management of current partner is typing notification.
+ return;
+ }
+ if (is_typing) {
+ if (channel.typingMembers.includes(partner)) {
+ channel.refreshOtherMemberTypingMember(partner);
+ } else {
+ channel.registerOtherMemberTypingMember(partner);
+ }
+ } else {
+ if (!channel.typingMembers.includes(partner)) {
+ // Ignore no longer typing notifications of members that
+ // are not registered as typing something.
+ return;
+ }
+ channel.unregisterOtherMemberTypingMember(partner);
+ }
+ }
+
+ /**
+ * @private
+ * @param {Object} data
+ */
+ _handleNotificationNeedaction(data) {
+ const message = this.env.models['mail.message'].insert(
+ this.env.models['mail.message'].convertData(data)
+ );
+ this.env.messaging.inbox.update({ counter: increment() });
+ const originThread = message.originThread;
+ if (originThread && message.isNeedaction) {
+ originThread.update({ message_needaction_counter: increment() });
+ }
+ // manually force recompute of counter
+ this.messaging.messagingMenu.update();
+ }
+
+ /**
+ * @private
+ * @param {Object} data
+ * @param {string} [data.info]
+ * @param {string} [data.type]
+ */
+ async _handleNotificationPartner(data) {
+ const {
+ info,
+ type,
+ } = data;
+ if (type === 'activity_updated') {
+ this.env.bus.trigger('activity_updated', data);
+ } else if (type === 'author') {
+ return this._handleNotificationPartnerAuthor(data);
+ } else if (info === 'channel_seen') {
+ return this._handleNotificationChannelSeen(data.channel_id, data);
+ } else if (type === 'deletion') {
+ return this._handleNotificationPartnerDeletion(data);
+ } else if (type === 'message_notification_update') {
+ return this._handleNotificationPartnerMessageNotificationUpdate(data.elements);
+ } else if (type === 'mark_as_read') {
+ return this._handleNotificationPartnerMarkAsRead(data);
+ } else if (type === 'moderator') {
+ return this._handleNotificationPartnerModerator(data);
+ } else if (type === 'simple_notification') {
+ const escapedMessage = owl.utils.escape(data.message);
+ this.env.services['notification'].notify({
+ message: escapedMessage,
+ sticky: data.sticky,
+ type: data.warning ? 'warning' : 'danger',
+ });
+ } else if (type === 'toggle_star') {
+ return this._handleNotificationPartnerToggleStar(data);
+ } else if (info === 'transient_message') {
+ return this._handleNotificationPartnerTransientMessage(data);
+ } else if (info === 'unsubscribe') {
+ return this._handleNotificationPartnerUnsubscribe(data.id);
+ } else if (type === 'user_connection') {
+ return this._handleNotificationPartnerUserConnection(data);
+ } else if (!type) {
+ return this._handleNotificationPartnerChannel(data);
+ }
+ }
+
+ /**
+ * @private
+ * @param {Object} data
+ * @param {Object} data.message
+ */
+ _handleNotificationPartnerAuthor(data) {
+ this.env.models['mail.message'].insert(
+ this.env.models['mail.message'].convertData(data.message)
+ );
+ }
+
+ /**
+ * @private
+ * @param {Object} data
+ * @param {string} data.channel_type
+ * @param {integer} data.id
+ * @param {string} [data.info]
+ * @param {boolean} data.is_minimized
+ * @param {string} data.name
+ * @param {string} data.state
+ * @param {string} data.uuid
+ */
+ _handleNotificationPartnerChannel(data) {
+ const convertedData = this.env.models['mail.thread'].convertData(
+ Object.assign({ model: 'mail.channel' }, data)
+ );
+ if (!convertedData.members) {
+ // channel_info does not return all members of channel for
+ // performance reasons, but code is expecting to know at
+ // least if the current partner is member of it.
+ // (e.g. to know when to display "invited" notification)
+ // Current partner can always be assumed to be a member of
+ // channels received through this notification.
+ convertedData.members = [['link', this.env.messaging.currentPartner]];
+ }
+ let channel = this.env.models['mail.thread'].findFromIdentifyingData(convertedData);
+ const wasCurrentPartnerMember = (
+ channel &&
+ channel.members.includes(this.env.messaging.currentPartner)
+ );
+
+ channel = this.env.models['mail.thread'].insert(convertedData);
+ if (
+ channel.channel_type === 'channel' &&
+ data.info !== 'creation' &&
+ !wasCurrentPartnerMember
+ ) {
+ this.env.services['notification'].notify({
+ message: _.str.sprintf(
+ this.env._t("You have been invited to: %s"),
+ owl.utils.escape(channel.name)
+ ),
+ type: 'warning',
+ });
+ }
+ // a new thread with unread messages could have been added
+ // manually force recompute of counter
+ this.messaging.messagingMenu.update();
+ }
+
+ /**
+ * @private
+ * @param {Object} param0
+ * @param {integer[]} param0.messag_ids
+ */
+ _handleNotificationPartnerDeletion({ message_ids }) {
+ const moderationMailbox = this.env.messaging.moderation;
+ for (const id of message_ids) {
+ const message = this.env.models['mail.message'].findFromIdentifyingData({ id });
+ if (message) {
+ if (
+ message.moderation_status === 'pending_moderation' &&
+ message.originThread.isModeratedByCurrentPartner
+ ) {
+ moderationMailbox.update({ counter: decrement() });
+ }
+ message.delete();
+ }
+ }
+ // deleting message might have deleted notifications, force recompute
+ this.messaging.notificationGroupManager.computeGroups();
+ // manually force recompute of counter (after computing the groups)
+ this.messaging.messagingMenu.update();
+ }
+
+ /**
+ * @private
+ * @param {Object} data
+ */
+ _handleNotificationPartnerMessageNotificationUpdate(data) {
+ for (const messageData of data) {
+ const message = this.env.models['mail.message'].insert(
+ this.env.models['mail.message'].convertData(messageData)
+ );
+ // implicit: failures are sent by the server as notification
+ // only if the current partner is author of the message
+ if (!message.author && this.messaging.currentPartner) {
+ message.update({ author: [['link', this.messaging.currentPartner]] });
+ }
+ }
+ this.messaging.notificationGroupManager.computeGroups();
+ // manually force recompute of counter (after computing the groups)
+ this.messaging.messagingMenu.update();
+ }
+
+ /**
+ * @private
+ * @param {Object} param0
+ * @param {integer[]} [param0.channel_ids
+ * @param {integer[]} [param0.message_ids=[]]
+ * @param {integer} [param0.needaction_inbox_counter]
+ */
+ _handleNotificationPartnerMarkAsRead({ channel_ids, message_ids = [], needaction_inbox_counter }) {
+ for (const message_id of message_ids) {
+ // We need to ignore all not yet known messages because we don't want them
+ // to be shown partially as they would be linked directly to mainCache
+ // Furthermore, server should not send back all message_ids marked as read
+ // but something like last read message_id or something like that.
+ // (just imagine you mark 1000 messages as read ... )
+ const message = this.env.models['mail.message'].findFromIdentifyingData({ id: message_id });
+ if (!message) {
+ continue;
+ }
+ // update thread counter
+ const originThread = message.originThread;
+ if (originThread && message.isNeedaction) {
+ originThread.update({ message_needaction_counter: decrement() });
+ }
+ // move messages from Inbox to history
+ message.update({
+ isHistory: true,
+ isNeedaction: false,
+ });
+ }
+ const inbox = this.env.messaging.inbox;
+ if (needaction_inbox_counter !== undefined) {
+ inbox.update({ counter: needaction_inbox_counter });
+ } else {
+ // kept for compatibility in stable
+ inbox.update({ counter: decrement(message_ids.length) });
+ }
+ if (inbox.counter > inbox.mainCache.fetchedMessages.length) {
+ // Force refresh Inbox because depending on what was marked as
+ // read the cache might become empty even though there are more
+ // messages on the server.
+ inbox.mainCache.update({ hasToLoadMessages: true });
+ }
+ // manually force recompute of counter
+ this.messaging.messagingMenu.update();
+ }
+
+ /**
+ * @private
+ * @param {Object} param0
+ * @param {Object} param0.message
+ */
+ _handleNotificationPartnerModerator({ message: data }) {
+ this.env.models['mail.message'].insert(
+ this.env.models['mail.message'].convertData(data)
+ );
+ const moderationMailbox = this.env.messaging.moderation;
+ if (moderationMailbox) {
+ moderationMailbox.update({ counter: increment() });
+ }
+ // manually force recompute of counter
+ this.messaging.messagingMenu.update();
+ }
+
+ /**
+ * @private
+ * @param {Object} param0
+ * @param {integer[]} param0.message_ids
+ * @param {boolean} param0.starred
+ */
+ _handleNotificationPartnerToggleStar({ message_ids = [], starred }) {
+ const starredMailbox = this.env.messaging.starred;
+ for (const messageId of message_ids) {
+ const message = this.env.models['mail.message'].findFromIdentifyingData({
+ id: messageId,
+ });
+ if (!message) {
+ continue;
+ }
+ message.update({ isStarred: starred });
+ starredMailbox.update({
+ counter: starred ? increment() : decrement(),
+ });
+ }
+ }
+
+ /**
+ * On receiving a transient message, i.e. a message which does not come
+ * from a member of the channel. Usually a log message, such as one
+ * generated from a command with ('/').
+ *
+ * @private
+ * @param {Object} data
+ */
+ _handleNotificationPartnerTransientMessage(data) {
+ const convertedData = this.env.models['mail.message'].convertData(data);
+ const lastMessageId = this.env.models['mail.message'].all().reduce(
+ (lastMessageId, message) => Math.max(lastMessageId, message.id),
+ 0
+ );
+ const partnerRoot = this.env.messaging.partnerRoot;
+ const message = this.env.models['mail.message'].create(Object.assign(convertedData, {
+ author: [['link', partnerRoot]],
+ id: lastMessageId + 0.01,
+ isTransient: true,
+ }));
+ this._notifyThreadViewsMessageReceived(message);
+ // manually force recompute of counter
+ this.messaging.messagingMenu.update();
+ }
+
+ /**
+ * @private
+ * @param {integer} channelId
+ */
+ _handleNotificationPartnerUnsubscribe(channelId) {
+ const channel = this.env.models['mail.thread'].findFromIdentifyingData({
+ id: channelId,
+ model: 'mail.channel',
+ });
+ if (!channel) {
+ return;
+ }
+ let message;
+ if (channel.correspondent) {
+ const correspondent = channel.correspondent;
+ message = _.str.sprintf(
+ this.env._t("You unpinned your conversation with <b>%s</b>."),
+ owl.utils.escape(correspondent.name)
+ );
+ } else {
+ message = _.str.sprintf(
+ this.env._t("You unsubscribed from <b>%s</b>."),
+ owl.utils.escape(channel.name)
+ );
+ }
+ // We assume that arriving here the server has effectively
+ // unpinned the channel
+ channel.update({ isServerPinned: false });
+ this.env.services['notification'].notify({
+ message,
+ type: 'warning',
+ });
+ }
+
+ /**
+ * @private
+ * @param {Object} param0
+ * @param {string} param0.message
+ * @param {integer} param0.partner_id
+ * @param {string} param0.title
+ */
+ async _handleNotificationPartnerUserConnection({ message, partner_id, title }) {
+ // If the current user invited a new user, and the new user is
+ // connecting for the first time while the current user is present
+ // then open a chat for the current user with the new user.
+ this.env.services['bus_service'].sendNotification(title, message);
+ const chat = await this.async(() =>
+ this.env.messaging.getChat({ partnerId: partner_id }
+ ));
+ if (!chat || this.env.messaging.device.isMobile) {
+ return;
+ }
+ this.env.messaging.chatWindowManager.openThread(chat);
+ }
+
+ /**
+ * @private
+ * @param {Object} param0
+ * @param {mail.thread} param0.channel
+ * @param {mail.message} param0.message
+ */
+ _notifyNewChannelMessageWhileOutOfFocus({ channel, message }) {
+ const author = message.author;
+ const messaging = this.env.messaging;
+ let notificationTitle;
+ if (!author) {
+ notificationTitle = this.env._t("New message");
+ } else {
+ const authorName = author.nameOrDisplayName;
+ if (channel.channel_type === 'channel') {
+ // hack: notification template does not support OWL components,
+ // so we simply use their template to make HTML as if it comes
+ // from component
+ const channelIcon = this.env.qweb.renderToString('mail.ThreadIcon', {
+ env: this.env,
+ thread: channel,
+ });
+ const channelName = owl.utils.escape(channel.displayName);
+ const channelNameWithIcon = channelIcon + channelName;
+ notificationTitle = _.str.sprintf(
+ this.env._t("%s from %s"),
+ owl.utils.escape(authorName),
+ channelNameWithIcon
+ );
+ } else {
+ notificationTitle = owl.utils.escape(authorName);
+ }
+ }
+ const notificationContent = htmlToTextContentInline(message.body).substr(0, PREVIEW_MSG_MAX_SIZE);
+ this.env.services['bus_service'].sendNotification(notificationTitle, notificationContent);
+ messaging.update({ outOfFocusUnreadMessageCounter: increment() });
+ const titlePattern = messaging.outOfFocusUnreadMessageCounter === 1
+ ? this.env._t("%d Message")
+ : this.env._t("%d Messages");
+ this.env.bus.trigger('set_title_part', {
+ part: '_chat',
+ title: _.str.sprintf(titlePattern, messaging.outOfFocusUnreadMessageCounter),
+ });
+ }
+
+ /**
+ * Notifies threadViews about the given message being just received.
+ * This can allow them adjust their scroll position if applicable.
+ *
+ * @private
+ * @param {mail.message}
+ */
+ _notifyThreadViewsMessageReceived(message) {
+ for (const thread of message.threads) {
+ for (const threadView of thread.threadViews) {
+ threadView.addComponentHint('message-received', { message });
+ }
+ }
+ }
+
+ }
+
+ MessagingNotificationHandler.fields = {
+ messaging: one2one('mail.messaging', {
+ inverse: 'notificationHandler',
+ }),
+ };
+
+ MessagingNotificationHandler.modelName = 'mail.messaging_notification_handler';
+
+ return MessagingNotificationHandler;
+}
+
+registerNewModel('mail.messaging_notification_handler', factory);
+
+});
diff --git a/addons/mail/static/src/models/model/model.js b/addons/mail/static/src/models/model/model.js
new file mode 100644
index 00000000..3696332a
--- /dev/null
+++ b/addons/mail/static/src/models/model/model.js
@@ -0,0 +1,291 @@
+odoo.define('mail/static/src/models/Model', function (require) {
+'use strict';
+
+const { registerNewModel } = require('mail/static/src/model/model_core.js');
+const { RecordDeletedError } = require('mail/static/src/model/model_errors.js');
+
+/**
+ * This function generates a class that represent a model. Instances of such
+ * model (or inherited models) represent logical objects used in whole
+ * application. They could represent server record (e.g. Thread, Message) or
+ * UI elements (e.g. MessagingMenu, ChatWindow). These instances are called
+ * "records", while the classes are called "models".
+ */
+function factory() {
+
+ class Model {
+
+ /**
+ * @param {Object} [param0={}]
+ * @param {boolean} [param0.valid=false] if set, this constructor is
+ * called by static method `create()`. This should always be the case.
+ * @throws {Error} in case constructor is called in an invalid way, i.e.
+ * by instantiating the record manually with `new` instead of from
+ * static method `create()`.
+ */
+ constructor({ valid = false } = {}) {
+ if (!valid) {
+ throw new Error("Record must always be instantiated from static method 'create()'");
+ }
+ }
+
+ /**
+ * This function is called during the create cycle, when the record has
+ * already been created, but its values have not yet been assigned.
+ *
+ * It is usually preferable to override @see `_created`.
+ *
+ * The main use case is to prepare the record for the assignation of its
+ * values, for example if a computed field relies on the record to have
+ * some purely technical property correctly set.
+ *
+ * @abstract
+ * @private
+ */
+ _willCreate() {}
+
+ /**
+ * This function is called after the record has been created, more
+ * precisely at the end of the update cycle (which means all implicit
+ * changes such as computes have been applied too).
+ *
+ * The main use case is to register listeners on the record.
+ *
+ * @abstract
+ * @private
+ */
+ _created() {}
+
+ /**
+ * This function is called when the record is about to be deleted. The
+ * record still has all of its fields values accessible, but for all
+ * intents and purposes the record should already be considered
+ * deleted, which means update shouldn't be called inside this method.
+ *
+ * The main use case is to unregister listeners on the record.
+ *
+ * @abstract
+ * @private
+ */
+ _willDelete() {}
+
+ //----------------------------------------------------------------------
+ // Public
+ //----------------------------------------------------------------------
+
+ /**
+ * Returns all records of this model that match provided criteria.
+ *
+ * @static
+ * @param {function} [filterFunc]
+ * @returns {mail.model[]}
+ */
+ static all(filterFunc) {
+ return this.env.modelManager.all(this, filterFunc);
+ }
+
+ /**
+ * This method is used to create new records of this model
+ * with provided data. This is the only way to create them:
+ * instantiation must never been done with keyword `new` outside of this
+ * function, otherwise the record will not be registered.
+ *
+ * @static
+ * @param {Object|Object[]} [data] data object with initial data, including relations.
+ * If data is an iterable, multiple records will be created.
+ * @returns {mail.model|mail.model[]} newly created record(s)
+ */
+ static create(data) {
+ return this.env.modelManager.create(this, data);
+ }
+
+ /**
+ * Get the record that has provided criteria, if it exists.
+ *
+ * @static
+ * @param {function} findFunc
+ * @returns {mail.model|undefined}
+ */
+ static find(findFunc) {
+ return this.env.modelManager.find(this, findFunc);
+ }
+
+ /**
+ * Gets the unique record that matches the given identifying data, if it
+ * exists.
+ * @see `_createRecordLocalId` for criteria of identification.
+ *
+ * @static
+ * @param {Object} data
+ * @returns {mail.model|undefined}
+ */
+ static findFromIdentifyingData(data) {
+ return this.env.modelManager.findFromIdentifyingData(this, data);
+ }
+
+ /**
+ * This method returns the record of this model that matches provided
+ * local id. Useful to convert a local id to a record. Note that even
+ * if there's a record in the system having provided local id, if the
+ * resulting record is not an instance of this model, this getter
+ * assumes the record does not exist.
+ *
+ * @static
+ * @param {string} localId
+ * @param {Object} param1
+ * @param {boolean} [param1.isCheckingInheritance]
+ * @returns {mail.model|undefined}
+ */
+ static get(localId, { isCheckingInheritance } = {}) {
+ return this.env.modelManager.get(this, localId, { isCheckingInheritance });
+ }
+
+ /**
+ * This method creates a record or updates one, depending
+ * on provided data.
+ *
+ * @static
+ * @param {Object|Object[]} data
+ * If data is an iterable, multiple records will be created/updated.
+ * @returns {mail.model|mail.model[]} created or updated record(s).
+ */
+ static insert(data) {
+ return this.env.modelManager.insert(this, data);
+ }
+
+ /**
+ * Perform an async function and wait until it is done. If the record
+ * is deleted, it raises a RecordDeletedError.
+ *
+ * @param {function} func an async function
+ * @throws {RecordDeletedError} in case the current record is not alive
+ * at the end of async function call, whether it's resolved or
+ * rejected.
+ * @throws {any} forwards any error in case the current record is still
+ * alive at the end of rejected async function call.
+ * @returns {any} result of resolved async function.
+ */
+ async async(func) {
+ return new Promise((resolve, reject) => {
+ Promise.resolve(func()).then(result => {
+ if (this.exists()) {
+ resolve(result);
+ } else {
+ reject(new RecordDeletedError(this.localId));
+ }
+ }).catch(error => {
+ if (this.exists()) {
+ reject(error);
+ } else {
+ reject(new RecordDeletedError(this.localId));
+ }
+ });
+ });
+ }
+
+ /**
+ * This method deletes this record.
+ */
+ delete() {
+ this.env.modelManager.delete(this);
+ }
+
+ /**
+ * Returns whether the current record exists.
+ *
+ * @returns {boolean}
+ */
+ exists() {
+ return this.env.modelManager.exists(this.constructor, this);
+ }
+
+ /**
+ * Update this record with provided data.
+ *
+ * @param {Object} [data={}]
+ */
+ update(data = {}) {
+ this.env.modelManager.update(this, data);
+ }
+
+ //----------------------------------------------------------------------
+ // Private
+ //----------------------------------------------------------------------
+
+ /**
+ * This method generates a local id for this record that is
+ * being created at the moment.
+ *
+ * This function helps customizing the local id to ease mapping a local
+ * id to its record for the developer that reads the local id. For
+ * instance, the local id of a thread cache could combine the thread
+ * and stringified domain in its local id, which is much easier to
+ * track relations and records in the system instead of arbitrary
+ * number to differenciate them.
+ *
+ * @static
+ * @private
+ * @param {Object} data
+ * @returns {string}
+ */
+ static _createRecordLocalId(data) {
+ return _.uniqueId(`${this.modelName}_`);
+ }
+
+ /**
+ * This function is called when this record has been explicitly updated
+ * with `.update()` or static method `.create()`, at the end of an
+ * record update cycle. This is a backward-compatible behaviour that
+ * is deprecated: you should use computed fields instead.
+ *
+ * @deprecated
+ * @abstract
+ * @private
+ * @param {Object} previous contains data that have been stored by
+ * `_updateBefore()`. Useful to make extra update decisions based on
+ * previous data.
+ */
+ _updateAfter(previous) {}
+
+ /**
+ * This function is called just at the beginning of an explicit update
+ * on this function, with `.update()` or static method `.create()`. This
+ * is useful to remember previous values of fields in `_updateAfter`.
+ * This is a backward-compatible behaviour that is deprecated: you
+ * should use computed fields instead.
+ *
+ * @deprecated
+ * @abstract
+ * @private
+ * @param {Object} data
+ * @returns {Object}
+ */
+ _updateBefore() {
+ return {};
+ }
+
+ }
+
+ /**
+ * Models should define fields in static prop or getter `fields`.
+ * It contains an object with name of field as key and value are objects
+ * that define the field. There are some helpers to ease the making of these
+ * objects, @see `mail/static/src/model/model_field.js`
+ *
+ * Note: fields of super-class are automatically inherited, therefore a
+ * sub-class should (re-)define fields without copying ancestors' fields.
+ */
+ Model.fields = {};
+
+ /**
+ * Name of the model. Important to refer to appropriate model class
+ * like in relational fields. Name of model classes must be unique.
+ */
+ Model.modelName = 'mail.model';
+
+ return Model;
+}
+
+registerNewModel('mail.model', factory);
+
+});
diff --git a/addons/mail/static/src/models/notification/notification.js b/addons/mail/static/src/models/notification/notification.js
new file mode 100644
index 00000000..047faee9
--- /dev/null
+++ b/addons/mail/static/src/models/notification/notification.js
@@ -0,0 +1,80 @@
+odoo.define('mail/static/src/models/notification/notification.js', function (require) {
+'use strict';
+
+const { registerNewModel } = require('mail/static/src/model/model_core.js');
+const { attr, many2one } = require('mail/static/src/model/model_field.js');
+
+function factory(dependencies) {
+
+ class Notification extends dependencies['mail.model'] {
+
+ //----------------------------------------------------------------------
+ // Public
+ //----------------------------------------------------------------------
+
+ /**
+ * @static
+ * @param {Object} data
+ * @return {Object}
+ */
+ static convertData(data) {
+ const data2 = {};
+ if ('failure_type' in data) {
+ data2.failure_type = data.failure_type;
+ }
+ if ('id' in data) {
+ data2.id = data.id;
+ }
+ if ('notification_status' in data) {
+ data2.notification_status = data.notification_status;
+ }
+ if ('notification_type' in data) {
+ data2.notification_type = data.notification_type;
+ }
+ if ('res_partner_id' in data) {
+ if (!data.res_partner_id) {
+ data2.partner = [['unlink-all']];
+ } else {
+ data2.partner = [
+ ['insert', {
+ display_name: data.res_partner_id[1],
+ id: data.res_partner_id[0],
+ }],
+ ];
+ }
+ }
+ return data2;
+ }
+
+ //----------------------------------------------------------------------
+ // Private
+ //----------------------------------------------------------------------
+
+ /**
+ * @override
+ */
+ static _createRecordLocalId(data) {
+ return `${this.modelName}_${data.id}`;
+ }
+
+ }
+
+ Notification.fields = {
+ failure_type: attr(),
+ id: attr(),
+ message: many2one('mail.message', {
+ inverse: 'notifications',
+ }),
+ notification_status: attr(),
+ notification_type: attr(),
+ partner: many2one('mail.partner'),
+ };
+
+ Notification.modelName = 'mail.notification';
+
+ return Notification;
+}
+
+registerNewModel('mail.notification', factory);
+
+});
diff --git a/addons/mail/static/src/models/notification_group/notification_group.js b/addons/mail/static/src/models/notification_group/notification_group.js
new file mode 100644
index 00000000..89111a4e
--- /dev/null
+++ b/addons/mail/static/src/models/notification_group/notification_group.js
@@ -0,0 +1,126 @@
+odoo.define('mail/static/src/models/notification_group/notification_group.js', function (require) {
+'use strict';
+
+const { registerNewModel } = require('mail/static/src/model/model_core.js');
+const { attr, many2one, one2many } = require('mail/static/src/model/model_field.js');
+
+function factory(dependencies) {
+
+ class NotificationGroup extends dependencies['mail.model'] {
+
+ //----------------------------------------------------------------------
+ // Public
+ //----------------------------------------------------------------------
+
+ /**
+ * Opens the view that allows to cancel all notifications of the group.
+ */
+ openCancelAction() {
+ if (this.notification_type !== 'email') {
+ return;
+ }
+ this.env.bus.trigger('do-action', {
+ action: 'mail.mail_resend_cancel_action',
+ options: {
+ additional_context: {
+ default_model: this.res_model,
+ unread_counter: this.notifications.length,
+ },
+ },
+ });
+ }
+
+ /**
+ * Opens the view that displays either the single record of the group or
+ * all the records in the group.
+ */
+ openDocuments() {
+ if (this.thread) {
+ this.thread.open();
+ } else {
+ this._openDocuments();
+ }
+ }
+
+ //----------------------------------------------------------------------
+ // Private
+ //----------------------------------------------------------------------
+
+ /**
+ * @private
+ * @returns {mail.thread|undefined}
+ */
+ _computeThread() {
+ if (this.res_id) {
+ return [['insert', {
+ id: this.res_id,
+ model: this.res_model,
+ }]];
+ }
+ return [['unlink']];
+ }
+
+ /**
+ * @override
+ */
+ static _createRecordLocalId(data) {
+ return `${this.modelName}_${data.id}`;
+ }
+
+ /**
+ * Opens the view that displays all the records of the group.
+ *
+ * @private
+ */
+ _openDocuments() {
+ if (this.notification_type !== 'email') {
+ return;
+ }
+ this.env.bus.trigger('do-action', {
+ action: {
+ name: this.env._t("Mail Failures"),
+ type: 'ir.actions.act_window',
+ view_mode: 'kanban,list,form',
+ views: [[false, 'kanban'], [false, 'list'], [false, 'form']],
+ target: 'current',
+ res_model: this.res_model,
+ domain: [['message_has_error', '=', true]],
+ },
+ });
+ if (this.env.messaging.device.isMobile) {
+ // messaging menu has a higher z-index than views so it must
+ // be closed to ensure the visibility of the view
+ this.env.messaging.messagingMenu.close();
+ }
+ }
+
+ }
+
+ NotificationGroup.fields = {
+ date: attr(),
+ id: attr(),
+ notification_type: attr(),
+ notifications: one2many('mail.notification'),
+ res_id: attr(),
+ res_model: attr(),
+ res_model_name: attr(),
+ /**
+ * Related thread when the notification group concerns a single thread.
+ */
+ thread: many2one('mail.thread', {
+ compute: '_computeThread',
+ dependencies: [
+ 'res_id',
+ 'res_model',
+ ],
+ })
+ };
+
+ NotificationGroup.modelName = 'mail.notification_group';
+
+ return NotificationGroup;
+}
+
+registerNewModel('mail.notification_group', factory);
+
+});
diff --git a/addons/mail/static/src/models/notification_group_manager/notification_group_manager.js b/addons/mail/static/src/models/notification_group_manager/notification_group_manager.js
new file mode 100644
index 00000000..9c7c38ef
--- /dev/null
+++ b/addons/mail/static/src/models/notification_group_manager/notification_group_manager.js
@@ -0,0 +1,77 @@
+odoo.define('mail/static/src/models/notification_group_manager/notification_group_manager.js', function (require) {
+'use strict';
+
+const { registerNewModel } = require('mail/static/src/model/model_core.js');
+const { one2many } = require('mail/static/src/model/model_field.js');
+
+function factory(dependencies) {
+
+ class NotificationGroupManager extends dependencies['mail.model'] {
+
+ //----------------------------------------------------------------------
+ // Public
+ //----------------------------------------------------------------------
+
+ computeGroups() {
+ for (const group of this.groups) {
+ group.delete();
+ }
+ const groups = [];
+ // TODO batch insert, better logic task-2258605
+ this.env.messaging.currentPartner.failureNotifications.forEach(notification => {
+ const thread = notification.message.originThread;
+ // Notifications are grouped by model and notification_type.
+ // Except for channel where they are also grouped by id because
+ // we want to open the actual channel in discuss or chat window
+ // and not its kanban/list/form view.
+ const channelId = thread.model === 'mail.channel' ? thread.id : null;
+ const id = `${thread.model}/${channelId}/${notification.notification_type}`;
+ const group = this.env.models['mail.notification_group'].insert({
+ id,
+ notification_type: notification.notification_type,
+ res_model: thread.model,
+ res_model_name: thread.model_name,
+ });
+ group.update({ notifications: [['link', notification]] });
+ // keep res_id only if all notifications are for the same record
+ // set null if multiple records are present in the group
+ let res_id = group.res_id;
+ if (group.res_id === undefined) {
+ res_id = thread.id;
+ } else if (group.res_id !== thread.id) {
+ res_id = null;
+ }
+ // keep only the most recent date from all notification messages
+ let date = group.date;
+ if (!date) {
+ date = notification.message.date;
+ } else {
+ date = moment.max(group.date, notification.message.date);
+ }
+ group.update({
+ date,
+ res_id,
+ });
+ // avoid linking the same group twice when adding a notification
+ // to an existing group
+ if (!groups.includes(group)) {
+ groups.push(group);
+ }
+ });
+ this.update({ groups: [['link', groups]] });
+ }
+
+ }
+
+ NotificationGroupManager.fields = {
+ groups: one2many('mail.notification_group'),
+ };
+
+ NotificationGroupManager.modelName = 'mail.notification_group_manager';
+
+ return NotificationGroupManager;
+}
+
+registerNewModel('mail.notification_group_manager', factory);
+
+});
diff --git a/addons/mail/static/src/models/partner/partner.js b/addons/mail/static/src/models/partner/partner.js
new file mode 100644
index 00000000..4d007fb1
--- /dev/null
+++ b/addons/mail/static/src/models/partner/partner.js
@@ -0,0 +1,527 @@
+odoo.define('mail/static/src/models/partner/partner.js', function (require) {
+'use strict';
+
+const { registerNewModel } = require('mail/static/src/model/model_core.js');
+const { attr, many2many, many2one, one2many, one2one } = require('mail/static/src/model/model_field.js');
+const { cleanSearchTerm } = require('mail/static/src/utils/utils.js');
+
+function factory(dependencies) {
+
+ class Partner extends dependencies['mail.model'] {
+
+ //----------------------------------------------------------------------
+ // Public
+ //----------------------------------------------------------------------
+
+ /**
+ * @static
+ * @private
+ * @param {Object} data
+ * @return {Object}
+ */
+ static convertData(data) {
+ const data2 = {};
+ if ('active' in data) {
+ data2.active = data.active;
+ }
+ if ('country' in data) {
+ if (!data.country) {
+ data2.country = [['unlink-all']];
+ } else {
+ data2.country = [['insert', {
+ id: data.country[0],
+ name: data.country[1],
+ }]];
+ }
+ }
+ if ('display_name' in data) {
+ data2.display_name = data.display_name;
+ }
+ if ('email' in data) {
+ data2.email = data.email;
+ }
+ if ('id' in data) {
+ data2.id = data.id;
+ }
+ if ('im_status' in data) {
+ data2.im_status = data.im_status;
+ }
+ if ('name' in data) {
+ data2.name = data.name;
+ }
+
+ // relation
+ if ('user_id' in data) {
+ if (!data.user_id) {
+ data2.user = [['unlink-all']];
+ } else {
+ let user = {};
+ if (Array.isArray(data.user_id)) {
+ user = {
+ id: data.user_id[0],
+ display_name: data.user_id[1],
+ };
+ } else {
+ user = {
+ id: data.user_id,
+ };
+ }
+ user.isInternalUser = data.is_internal_user;
+ data2.user = [['insert', user]];
+ }
+ }
+
+ return data2;
+ }
+
+ /**
+ * Fetches partners matching the given search term to extend the
+ * JS knowledge and to update the suggestion list accordingly.
+ *
+ * @static
+ * @param {string} searchTerm
+ * @param {Object} [options={}]
+ * @param {mail.thread} [options.thread] prioritize and/or restrict
+ * result in the context of given thread
+ */
+ static async fetchSuggestions(searchTerm, { thread } = {}) {
+ const kwargs = { search: searchTerm };
+ const isNonPublicChannel = thread && thread.model === 'mail.channel' && thread.public !== 'public';
+ if (isNonPublicChannel) {
+ kwargs.channel_id = thread.id;
+ }
+ const [
+ mainSuggestedPartners,
+ extraSuggestedPartners,
+ ] = await this.env.services.rpc(
+ {
+ model: 'res.partner',
+ method: 'get_mention_suggestions',
+ kwargs,
+ },
+ { shadow: true },
+ );
+ const partnersData = mainSuggestedPartners.concat(extraSuggestedPartners);
+ const partners = this.env.models['mail.partner'].insert(partnersData.map(data =>
+ this.env.models['mail.partner'].convertData(data)
+ ));
+ if (isNonPublicChannel) {
+ thread.update({ members: [['link', partners]] });
+ }
+ }
+
+ /**
+ * Search for partners matching `keyword`.
+ *
+ * @static
+ * @param {Object} param0
+ * @param {function} param0.callback
+ * @param {string} param0.keyword
+ * @param {integer} [param0.limit=10]
+ */
+ static async imSearch({ callback, keyword, limit = 10 }) {
+ // prefetched partners
+ let partners = [];
+ const cleanedSearchTerm = cleanSearchTerm(keyword);
+ const currentPartner = this.env.messaging.currentPartner;
+ for (const partner of this.all(partner => partner.active)) {
+ if (partners.length < limit) {
+ if (
+ partner !== currentPartner &&
+ partner.name &&
+ partner.user &&
+ cleanSearchTerm(partner.name).includes(cleanedSearchTerm)
+ ) {
+ partners.push(partner);
+ }
+ }
+ }
+ if (!partners.length) {
+ const partnersData = await this.env.services.rpc(
+ {
+ model: 'res.partner',
+ method: 'im_search',
+ args: [keyword, limit]
+ },
+ { shadow: true }
+ );
+ const newPartners = this.insert(partnersData.map(
+ partnerData => this.convertData(partnerData)
+ ));
+ partners.push(...newPartners);
+ }
+ callback(partners);
+ }
+
+ /**
+ * Returns partners that match the given search term.
+ *
+ * @static
+ * @param {string} searchTerm
+ * @param {Object} [options={}]
+ * @param {mail.thread} [options.thread] prioritize and/or restrict
+ * result in the context of given thread
+ * @returns {[mail.partner[], mail.partner[]]}
+ */
+ static searchSuggestions(searchTerm, { thread } = {}) {
+ let partners;
+ const isNonPublicChannel = thread && thread.model === 'mail.channel' && thread.public !== 'public';
+ if (isNonPublicChannel) {
+ // Only return the channel members when in the context of a
+ // non-public channel. Indeed, the message with the mention
+ // would be notified to the mentioned partner, so this prevents
+ // from inadvertently leaking the private message to the
+ // mentioned partner.
+ partners = thread.members;
+ } else {
+ partners = this.env.models['mail.partner'].all();
+ }
+ const cleanedSearchTerm = cleanSearchTerm(searchTerm);
+ const mainSuggestionList = [];
+ const extraSuggestionList = [];
+ for (const partner of partners) {
+ if (
+ (!partner.active && partner !== this.env.messaging.partnerRoot) ||
+ partner.id <= 0 ||
+ this.env.messaging.publicPartners.includes(partner)
+ ) {
+ // ignore archived partners (except OdooBot), temporary
+ // partners (livechat guests), public partners (technical)
+ continue;
+ }
+ if (
+ (partner.nameOrDisplayName && cleanSearchTerm(partner.nameOrDisplayName).includes(cleanedSearchTerm)) ||
+ (partner.email && cleanSearchTerm(partner.email).includes(cleanedSearchTerm))
+ ) {
+ if (partner.user) {
+ mainSuggestionList.push(partner);
+ } else {
+ extraSuggestionList.push(partner);
+ }
+ }
+ }
+ return [mainSuggestionList, extraSuggestionList];
+ }
+
+ /**
+ * @static
+ */
+ static async startLoopFetchImStatus() {
+ await this._fetchImStatus();
+ this._loopFetchImStatus();
+ }
+
+ /**
+ * Checks whether this partner has a related user and links them if
+ * applicable.
+ */
+ async checkIsUser() {
+ const userIds = await this.async(() => this.env.services.rpc({
+ model: 'res.users',
+ method: 'search',
+ args: [[['partner_id', '=', this.id]]],
+ kwargs: {
+ context: { active_test: false },
+ },
+ }, { shadow: true }));
+ this.update({ hasCheckedUser: true });
+ if (userIds.length > 0) {
+ this.update({ user: [['insert', { id: userIds[0] }]] });
+ }
+ }
+
+ /**
+ * Gets the chat between the user of this partner and the current user.
+ *
+ * If a chat is not appropriate, a notification is displayed instead.
+ *
+ * @returns {mail.thread|undefined}
+ */
+ async getChat() {
+ if (!this.user && !this.hasCheckedUser) {
+ await this.async(() => this.checkIsUser());
+ }
+ // prevent chatting with non-users
+ if (!this.user) {
+ this.env.services['notification'].notify({
+ message: this.env._t("You can only chat with partners that have a dedicated user."),
+ type: 'info',
+ });
+ return;
+ }
+ return this.user.getChat();
+ }
+
+ /**
+ * Returns the text that identifies this partner in a mention.
+ *
+ * @returns {string}
+ */
+ getMentionText() {
+ return this.name;
+ }
+
+ /**
+ * Returns a sort function to determine the order of display of partners
+ * in the suggestion list.
+ *
+ * @static
+ * @param {string} searchTerm
+ * @param {Object} [options={}]
+ * @param {mail.thread} [options.thread] prioritize result in the
+ * context of given thread
+ * @returns {function}
+ */
+ static getSuggestionSortFunction(searchTerm, { thread } = {}) {
+ const cleanedSearchTerm = cleanSearchTerm(searchTerm);
+ return (a, b) => {
+ const isAInternalUser = a.user && a.user.isInternalUser;
+ const isBInternalUser = b.user && b.user.isInternalUser;
+ if (isAInternalUser && !isBInternalUser) {
+ return -1;
+ }
+ if (!isAInternalUser && isBInternalUser) {
+ return 1;
+ }
+ if (thread && thread.model === 'mail.channel') {
+ const isAMember = thread.members.includes(a);
+ const isBMember = thread.members.includes(b);
+ if (isAMember && !isBMember) {
+ return -1;
+ }
+ if (!isAMember && isBMember) {
+ return 1;
+ }
+ }
+ if (thread) {
+ const isAFollower = thread.followersPartner.includes(a);
+ const isBFollower = thread.followersPartner.includes(b);
+ if (isAFollower && !isBFollower) {
+ return -1;
+ }
+ if (!isAFollower && isBFollower) {
+ return 1;
+ }
+ }
+ const cleanedAName = cleanSearchTerm(a.nameOrDisplayName || '');
+ const cleanedBName = cleanSearchTerm(b.nameOrDisplayName || '');
+ if (cleanedAName.startsWith(cleanedSearchTerm) && !cleanedBName.startsWith(cleanedSearchTerm)) {
+ return -1;
+ }
+ if (!cleanedAName.startsWith(cleanedSearchTerm) && cleanedBName.startsWith(cleanedSearchTerm)) {
+ return 1;
+ }
+ if (cleanedAName < cleanedBName) {
+ return -1;
+ }
+ if (cleanedAName > cleanedBName) {
+ return 1;
+ }
+ const cleanedAEmail = cleanSearchTerm(a.email || '');
+ const cleanedBEmail = cleanSearchTerm(b.email || '');
+ if (cleanedAEmail.startsWith(cleanedSearchTerm) && !cleanedAEmail.startsWith(cleanedSearchTerm)) {
+ return -1;
+ }
+ if (!cleanedBEmail.startsWith(cleanedSearchTerm) && cleanedBEmail.startsWith(cleanedSearchTerm)) {
+ return 1;
+ }
+ if (cleanedAEmail < cleanedBEmail) {
+ return -1;
+ }
+ if (cleanedAEmail > cleanedBEmail) {
+ return 1;
+ }
+ return a.id - b.id;
+ };
+ }
+
+ /**
+ * Opens a chat between the user of this partner and the current user
+ * and returns it.
+ *
+ * If a chat is not appropriate, a notification is displayed instead.
+ *
+ * @param {Object} [options] forwarded to @see `mail.thread:open()`
+ * @returns {mail.thread|undefined}
+ */
+ async openChat(options) {
+ const chat = await this.async(() => this.getChat());
+ if (!chat) {
+ return;
+ }
+ await this.async(() => chat.open(options));
+ return chat;
+ }
+
+ /**
+ * Opens the most appropriate view that is a profile for this partner.
+ */
+ async openProfile() {
+ return this.env.messaging.openDocument({
+ id: this.id,
+ model: 'res.partner',
+ });
+ }
+
+ //----------------------------------------------------------------------
+ // Private
+ //----------------------------------------------------------------------
+
+ /**
+ * @private
+ * @returns {string}
+ */
+ _computeAvatarUrl() {
+ return `/web/image/res.partner/${this.id}/image_128`;
+ }
+
+ /**
+ * @override
+ */
+ static _createRecordLocalId(data) {
+ return `${this.modelName}_${data.id}`;
+ }
+
+ /**
+ * @static
+ * @private
+ */
+ static async _fetchImStatus() {
+ const partnerIds = [];
+ for (const partner of this.all()) {
+ if (partner.im_status !== 'im_partner' && partner.id > 0) {
+ partnerIds.push(partner.id);
+ }
+ }
+ if (partnerIds.length === 0) {
+ return;
+ }
+ const dataList = await this.env.services.rpc({
+ route: '/longpolling/im_status',
+ params: {
+ partner_ids: partnerIds,
+ },
+ }, { shadow: true });
+ this.insert(dataList);
+ }
+
+ /**
+ * @static
+ * @private
+ */
+ static _loopFetchImStatus() {
+ setTimeout(async () => {
+ await this._fetchImStatus();
+ this._loopFetchImStatus();
+ }, 50 * 1000);
+ }
+
+ /**
+ * @private
+ * @returns {string|undefined}
+ */
+ _computeDisplayName() {
+ return this.display_name || this.user && this.user.display_name;
+ }
+
+ /**
+ * @private
+ * @returns {mail.messaging}
+ */
+ _computeMessaging() {
+ return [['link', this.env.messaging]];
+ }
+
+ /**
+ * @private
+ * @returns {string|undefined}
+ */
+ _computeNameOrDisplayName() {
+ return this.name || this.display_name;
+ }
+
+ }
+
+ Partner.fields = {
+ active: attr({
+ default: true,
+ }),
+ avatarUrl: attr({
+ compute: '_computeAvatarUrl',
+ dependencies: [
+ 'id',
+ ],
+ }),
+ correspondentThreads: one2many('mail.thread', {
+ inverse: 'correspondent',
+ }),
+ country: many2one('mail.country'),
+ display_name: attr({
+ compute: '_computeDisplayName',
+ default: "",
+ dependencies: [
+ 'display_name',
+ 'userDisplayName',
+ ],
+ }),
+ email: attr(),
+ failureNotifications: one2many('mail.notification', {
+ related: 'messagesAsAuthor.failureNotifications',
+ }),
+ /**
+ * Whether an attempt was already made to fetch the user corresponding
+ * to this partner. This prevents doing the same RPC multiple times.
+ */
+ hasCheckedUser: attr({
+ default: false,
+ }),
+ id: attr(),
+ im_status: attr(),
+ memberThreads: many2many('mail.thread', {
+ inverse: 'members',
+ }),
+ messagesAsAuthor: one2many('mail.message', {
+ inverse: 'author',
+ }),
+ /**
+ * Serves as compute dependency.
+ */
+ messaging: many2one('mail.messaging', {
+ compute: '_computeMessaging',
+ }),
+ model: attr({
+ default: 'res.partner',
+ }),
+ /**
+ * Channels that are moderated by this partner.
+ */
+ moderatedChannels: many2many('mail.thread', {
+ inverse: 'moderators',
+ }),
+ name: attr(),
+ nameOrDisplayName: attr({
+ compute: '_computeNameOrDisplayName',
+ dependencies: [
+ 'display_name',
+ 'name',
+ ],
+ }),
+ user: one2one('mail.user', {
+ inverse: 'partner',
+ }),
+ /**
+ * Serves as compute dependency.
+ */
+ userDisplayName: attr({
+ related: 'user.display_name',
+ }),
+ };
+
+ Partner.modelName = 'mail.partner';
+
+ return Partner;
+}
+
+registerNewModel('mail.partner', factory);
+
+});
diff --git a/addons/mail/static/src/models/suggested_recipient_info/suggested_recipient_info.js b/addons/mail/static/src/models/suggested_recipient_info/suggested_recipient_info.js
new file mode 100644
index 00000000..c8f12856
--- /dev/null
+++ b/addons/mail/static/src/models/suggested_recipient_info/suggested_recipient_info.js
@@ -0,0 +1,116 @@
+odoo.define('mail/static/src/models/suggested_recipient_info/suggested_recipient_info.js', function (require) {
+'use strict';
+
+const { registerNewModel } = require('mail/static/src/model/model_core.js');
+const { attr, many2one } = require('mail/static/src/model/model_field.js');
+
+function factory(dependencies) {
+
+ class SuggestedRecipientInfo extends dependencies['mail.model'] {
+
+ //----------------------------------------------------------------------
+ // private
+ //----------------------------------------------------------------------
+
+ /**
+ * @private
+ * @returns {string}
+ */
+ _computeEmail() {
+ return this.partner && this.partner.email || this.email;
+ }
+
+ /**
+ * Prevents selecting a recipient that does not have a partner.
+ *
+ * @private
+ * @returns {boolean}
+ */
+ _computeIsSelected() {
+ return this.partner ? this.isSelected : false;
+ }
+
+ /**
+ * @private
+ * @returns {string}
+ */
+ _computeName() {
+ return this.partner && this.partner.nameOrDisplayName || this.name;
+ }
+
+ }
+
+ SuggestedRecipientInfo.fields = {
+ /**
+ * Determines the email of `this`. It serves as visual clue when
+ * displaying `this`, and also serves as default partner email when
+ * creating a new partner from `this`.
+ */
+ email: attr({
+ compute: '_computeEmail',
+ dependencies: [
+ 'email',
+ 'partnerEmail',
+ ],
+ }),
+ /**
+ * Determines whether `this` will be added to recipients when posting a
+ * new message on `this.thread`.
+ */
+ isSelected: attr({
+ compute: '_computeIsSelected',
+ default: true,
+ dependencies: [
+ 'isSelected',
+ 'partner',
+ ],
+ }),
+ /**
+ * Determines the name of `this`. It serves as visual clue when
+ * displaying `this`, and also serves as default partner name when
+ * creating a new partner from `this`.
+ */
+ name: attr({
+ compute: '_computeName',
+ dependencies: [
+ 'name',
+ 'partnerNameOrDisplayName',
+ ],
+ }),
+ /**
+ * Determines the optional `mail.partner` associated to `this`.
+ */
+ partner: many2one('mail.partner'),
+ /**
+ * Serves as compute dependency.
+ */
+ partnerEmail: attr({
+ related: 'partner.email'
+ }),
+ /**
+ * Serves as compute dependency.
+ */
+ partnerNameOrDisplayName: attr({
+ related: 'partner.nameOrDisplayName'
+ }),
+ /**
+ * Determines why `this` is a suggestion for `this.thread`. It serves as
+ * visual clue when displaying `this`.
+ */
+ reason: attr(),
+ /**
+ * Determines the `mail.thread` concerned by `this.`
+ */
+ thread: many2one('mail.thread', {
+ inverse: 'suggestedRecipientInfoList',
+ }),
+ };
+
+ SuggestedRecipientInfo.modelName = 'mail.suggested_recipient_info';
+
+ return SuggestedRecipientInfo;
+}
+
+registerNewModel('mail.suggested_recipient_info', factory);
+
+});
diff --git a/addons/mail/static/src/models/thread/thread.js b/addons/mail/static/src/models/thread/thread.js
new file mode 100644
index 00000000..1011eec4
--- /dev/null
+++ b/addons/mail/static/src/models/thread/thread.js
@@ -0,0 +1,2324 @@
+odoo.define('mail/static/src/models/thread/thread.js', function (require) {
+'use strict';
+
+const { registerNewModel } = require('mail/static/src/model/model_core.js');
+const { attr, many2many, many2one, one2many, one2one } = require('mail/static/src/model/model_field.js');
+const { clear } = require('mail/static/src/model/model_field_command.js');
+const throttle = require('mail/static/src/utils/throttle/throttle.js');
+const Timer = require('mail/static/src/utils/timer/timer.js');
+const { cleanSearchTerm } = require('mail/static/src/utils/utils.js');
+const mailUtils = require('mail.utils');
+
+function factory(dependencies) {
+
+ class Thread extends dependencies['mail.model'] {
+
+ /**
+ * @override
+ */
+ _willCreate() {
+ const res = super._willCreate(...arguments);
+ /**
+ * Timer of current partner that was currently typing something, but
+ * there is no change on the input for 5 seconds. This is used
+ * in order to automatically notify other members that current
+ * partner has stopped typing something, due to making no changes
+ * on the composer for some time.
+ */
+ this._currentPartnerInactiveTypingTimer = new Timer(
+ this.env,
+ () => this.async(() => this._onCurrentPartnerInactiveTypingTimeout()),
+ 5 * 1000
+ );
+ /**
+ * Last 'is_typing' status of current partner that has been notified
+ * to other members. Useful to prevent spamming typing notifications
+ * to other members if it hasn't changed. An exception is the
+ * current partner long typing scenario where current partner has
+ * to re-send the same typing notification from time to time, so
+ * that other members do not assume he/she is no longer typing
+ * something from not receiving any typing notifications for a
+ * very long time.
+ *
+ * Supported values: true/false/undefined.
+ * undefined makes only sense initially and during current partner
+ * long typing timeout flow.
+ */
+ this._currentPartnerLastNotifiedIsTyping = undefined;
+ /**
+ * Timer of current partner that is typing a very long text. When
+ * the other members do not receive any typing notification for a
+ * long time, they must assume that the related partner is no longer
+ * typing something (e.g. they have closed the browser tab).
+ * This is a timer to let other members know that current partner
+ * is still typing something, so that they should not assume he/she
+ * has stopped typing something.
+ */
+ this._currentPartnerLongTypingTimer = new Timer(
+ this.env,
+ () => this.async(() => this._onCurrentPartnerLongTypingTimeout()),
+ 50 * 1000
+ );
+ /**
+ * Determines whether the next request to notify current partner
+ * typing status should always result to making RPC, regardless of
+ * whether last notified current partner typing status is the same.
+ * Most of the time we do not want to notify if value hasn't
+ * changed, exception being the long typing scenario of current
+ * partner.
+ */
+ this._forceNotifyNextCurrentPartnerTypingStatus = false;
+ /**
+ * Registry of timers of partners currently typing in the thread,
+ * excluding current partner. This is useful in order to
+ * automatically unregister typing members when not receive any
+ * typing notification after a long time. Timers are internally
+ * indexed by partner records as key. The current partner is
+ * ignored in this registry of timers.
+ *
+ * @see registerOtherMemberTypingMember
+ * @see unregisterOtherMemberTypingMember
+ */
+ this._otherMembersLongTypingTimers = new Map();
+
+ /**
+ * Clearable and cancellable throttled version of the
+ * `_notifyCurrentPartnerTypingStatus` method.
+ * This is useful when the current partner posts a message and
+ * types something else afterwards: it must notify immediately that
+ * he/she is typing something, instead of waiting for the throttle
+ * internal timer.
+ *
+ * @see _notifyCurrentPartnerTypingStatus
+ */
+ this._throttleNotifyCurrentPartnerTypingStatus = throttle(
+ this.env,
+ ({ isTyping }) => this.async(() => this._notifyCurrentPartnerTypingStatus({ isTyping })),
+ 2.5 * 1000
+ );
+ return res;
+ }
+
+ /**
+ * @override
+ */
+ _willDelete() {
+ this._currentPartnerInactiveTypingTimer.clear();
+ this._currentPartnerLongTypingTimer.clear();
+ this._throttleNotifyCurrentPartnerTypingStatus.clear();
+ for (const timer of this._otherMembersLongTypingTimers.values()) {
+ timer.clear();
+ }
+ if (this.isTemporary) {
+ for (const message of this.messages) {
+ message.delete();
+ }
+ }
+ return super._willDelete(...arguments);
+ }
+
+ //----------------------------------------------------------------------
+ // Public
+ //----------------------------------------------------------------------
+
+ /**
+ * @static
+ * @param {mail.thread} [thread] the concerned thread
+ */
+ static computeLastCurrentPartnerMessageSeenByEveryone(thread = undefined) {
+ const threads = thread ? [thread] : this.env.models['mail.thread'].all();
+ threads.map(localThread => {
+ localThread.update({
+ lastCurrentPartnerMessageSeenByEveryone: localThread._computeLastCurrentPartnerMessageSeenByEveryone(),
+ });
+ });
+ }
+
+ /**
+ * @static
+ * @param {Object} data
+ * @return {Object}
+ */
+ static convertData(data) {
+ const data2 = {
+ messagesAsServerChannel: [],
+ };
+ if ('model' in data) {
+ data2.model = data.model;
+ }
+ if ('channel_type' in data) {
+ data2.channel_type = data.channel_type;
+ data2.model = 'mail.channel';
+ }
+ if ('create_uid' in data) {
+ data2.creator = [['insert', { id: data.create_uid }]];
+ }
+ if ('custom_channel_name' in data) {
+ data2.custom_channel_name = data.custom_channel_name;
+ }
+ if ('group_based_subscription' in data) {
+ data2.group_based_subscription = data.group_based_subscription;
+ }
+ if ('id' in data) {
+ data2.id = data.id;
+ }
+ if ('is_minimized' in data && 'state' in data) {
+ data2.serverFoldState = data.is_minimized ? data.state : 'closed';
+ }
+ if ('is_moderator' in data) {
+ data2.is_moderator = data.is_moderator;
+ }
+ if ('is_pinned' in data) {
+ data2.isServerPinned = data.is_pinned;
+ }
+ if ('last_message' in data && data.last_message) {
+ data2.messagesAsServerChannel.push(['insert', { id: data.last_message.id }]);
+ data2.serverLastMessageId = data.last_message.id;
+ }
+ if ('last_message_id' in data && data.last_message_id) {
+ data2.messagesAsServerChannel.push(['insert', { id: data.last_message_id }]);
+ data2.serverLastMessageId = data.last_message_id;
+ }
+ if ('mass_mailing' in data) {
+ data2.mass_mailing = data.mass_mailing;
+ }
+ if ('moderation' in data) {
+ data2.moderation = data.moderation;
+ }
+ if ('message_needaction_counter' in data) {
+ data2.message_needaction_counter = data.message_needaction_counter;
+ }
+ if ('message_unread_counter' in data) {
+ data2.serverMessageUnreadCounter = data.message_unread_counter;
+ }
+ if ('name' in data) {
+ data2.name = data.name;
+ }
+ if ('public' in data) {
+ data2.public = data.public;
+ }
+ if ('seen_message_id' in data) {
+ data2.lastSeenByCurrentPartnerMessageId = data.seen_message_id || 0;
+ }
+ if ('uuid' in data) {
+ data2.uuid = data.uuid;
+ }
+
+ // relations
+ if ('members' in data) {
+ if (!data.members) {
+ data2.members = [['unlink-all']];
+ } else {
+ data2.members = [
+ ['insert-and-replace', data.members.map(memberData =>
+ this.env.models['mail.partner'].convertData(memberData)
+ )],
+ ];
+ }
+ }
+ if ('seen_partners_info' in data) {
+ if (!data.seen_partners_info) {
+ data2.partnerSeenInfos = [['unlink-all']];
+ } else {
+ /*
+ * FIXME: not optimal to write on relation given the fact that the relation
+ * will be (re)computed based on given fields.
+ * (here channelId will compute partnerSeenInfo.thread))
+ * task-2336946
+ */
+ data2.partnerSeenInfos = [
+ ['insert-and-replace',
+ data.seen_partners_info.map(
+ ({ fetched_message_id, partner_id, seen_message_id }) => {
+ return {
+ channelId: data2.id,
+ lastFetchedMessage: [fetched_message_id ? ['insert', { id: fetched_message_id }] : ['unlink-all']],
+ lastSeenMessage: [seen_message_id ? ['insert', { id: seen_message_id }] : ['unlink-all']],
+ partnerId: partner_id,
+ };
+ })
+ ]
+ ];
+ if (data.id || this.id) {
+ const messageIds = data.seen_partners_info.reduce((currentSet, { fetched_message_id, seen_message_id }) => {
+ if (fetched_message_id) {
+ currentSet.add(fetched_message_id);
+ }
+ if (seen_message_id) {
+ currentSet.add(seen_message_id);
+ }
+ return currentSet;
+ }, new Set());
+ if (messageIds.size > 0) {
+ /*
+ * FIXME: not optimal to write on relation given the fact that the relation
+ * will be (re)computed based on given fields.
+ * (here channelId will compute messageSeenIndicator.thread))
+ * task-2336946
+ */
+ data2.messageSeenIndicators = [
+ ['insert',
+ [...messageIds].map(messageId => {
+ return {
+ channelId: data.id || this.id,
+ messageId,
+ };
+ })
+ ]
+ ];
+ }
+ }
+ }
+ }
+
+ return data2;
+ }
+
+ /**
+ * Fetches threads matching the given composer search state to extend
+ * the JS knowledge and to update the suggestion list accordingly.
+ * More specifically only thread of model 'mail.channel' are fetched.
+ *
+ * @static
+ * @param {string} searchTerm
+ * @param {Object} [options={}]
+ * @param {mail.thread} [options.thread] prioritize and/or restrict
+ * result in the context of given thread
+ */
+ static async fetchSuggestions(searchTerm, { thread } = {}) {
+ const channelsData = await this.env.services.rpc(
+ {
+ model: 'mail.channel',
+ method: 'get_mention_suggestions',
+ kwargs: { search: searchTerm },
+ },
+ { shadow: true },
+ );
+ this.env.models['mail.thread'].insert(channelsData.map(channelData =>
+ Object.assign(
+ { model: 'mail.channel' },
+ this.env.models['mail.thread'].convertData(channelData),
+ )
+ ));
+ }
+
+ /**
+ * Returns a sort function to determine the order of display of threads
+ * in the suggestion list.
+ *
+ * @static
+ * @param {string} searchTerm
+ * @param {Object} [options={}]
+ * @param {mail.thread} [options.thread] prioritize result in the
+ * context of given thread
+ * @returns {function}
+ */
+ static getSuggestionSortFunction(searchTerm, { thread } = {}) {
+ const cleanedSearchTerm = cleanSearchTerm(searchTerm);
+ return (a, b) => {
+ const isAPublic = a.model === 'mail.channel' && a.public === 'public';
+ const isBPublic = b.model === 'mail.channel' && b.public === 'public';
+ if (isAPublic && !isBPublic) {
+ return -1;
+ }
+ if (!isAPublic && isBPublic) {
+ return 1;
+ }
+ const isMemberOfA = a.model === 'mail.channel' && a.members.includes(this.env.messaging.currentPartner);
+ const isMemberOfB = b.model === 'mail.channel' && b.members.includes(this.env.messaging.currentPartner);
+ if (isMemberOfA && !isMemberOfB) {
+ return -1;
+ }
+ if (!isMemberOfA && isMemberOfB) {
+ return 1;
+ }
+ const cleanedAName = cleanSearchTerm(a.name || '');
+ const cleanedBName = cleanSearchTerm(b.name || '');
+ if (cleanedAName.startsWith(cleanedSearchTerm) && !cleanedBName.startsWith(cleanedSearchTerm)) {
+ return -1;
+ }
+ if (!cleanedAName.startsWith(cleanedSearchTerm) && cleanedBName.startsWith(cleanedSearchTerm)) {
+ return 1;
+ }
+ if (cleanedAName < cleanedBName) {
+ return -1;
+ }
+ if (cleanedAName > cleanedBName) {
+ return 1;
+ }
+ return a.id - b.id;
+ };
+ }
+
+ /**
+ * Load the previews of the specified threads. Basically, it fetches the
+ * last messages, since they are used to display inline content of them.
+ *
+ * @static
+ * @param {mail.thread[]} threads
+ */
+ static async loadPreviews(threads) {
+ const channelIds = threads.reduce((list, thread) => {
+ if (thread.model === 'mail.channel') {
+ return list.concat(thread.id);
+ }
+ return list;
+ }, []);
+ if (channelIds.length === 0) {
+ return;
+ }
+ const channelPreviews = await this.env.services.rpc({
+ model: 'mail.channel',
+ method: 'channel_fetch_preview',
+ args: [channelIds],
+ }, { shadow: true });
+ this.env.models['mail.message'].insert(channelPreviews.filter(p => p.last_message).map(
+ channelPreview => this.env.models['mail.message'].convertData(channelPreview.last_message)
+ ));
+ }
+
+
+ /**
+ * Performs the `channel_fold` RPC on `mail.channel`.
+ *
+ * @static
+ * @param {string} uuid
+ * @param {string} state
+ */
+ static async performRpcChannelFold(uuid, state) {
+ return this.env.services.rpc({
+ model: 'mail.channel',
+ method: 'channel_fold',
+ kwargs: {
+ state,
+ uuid,
+ }
+ }, { shadow: true });
+ }
+
+ /**
+ * Performs the `channel_info` RPC on `mail.channel`.
+ *
+ * @static
+ * @param {Object} param0
+ * @param {integer[]} param0.ids list of id of channels
+ * @returns {mail.thread[]}
+ */
+ static async performRpcChannelInfo({ ids }) {
+ const channelInfos = await this.env.services.rpc({
+ model: 'mail.channel',
+ method: 'channel_info',
+ args: [ids],
+ }, { shadow: true });
+ const channels = this.env.models['mail.thread'].insert(
+ channelInfos.map(channelInfo => this.env.models['mail.thread'].convertData(channelInfo))
+ );
+ // manually force recompute of counter
+ this.env.messaging.messagingMenu.update();
+ return channels;
+ }
+
+ /**
+ * Performs the `channel_seen` RPC on `mail.channel`.
+ *
+ * @static
+ * @param {Object} param0
+ * @param {integer[]} param0.ids list of id of channels
+ * @param {integer[]} param0.lastMessageId
+ */
+ static async performRpcChannelSeen({ ids, lastMessageId }) {
+ return this.env.services.rpc({
+ model: 'mail.channel',
+ method: 'channel_seen',
+ args: [ids],
+ kwargs: {
+ last_message_id: lastMessageId,
+ },
+ }, { shadow: true });
+ }
+
+ /**
+ * Performs the `channel_pin` RPC on `mail.channel`.
+ *
+ * @static
+ * @param {Object} param0
+ * @param {boolean} [param0.pinned=false]
+ * @param {string} param0.uuid
+ */
+ static async performRpcChannelPin({ pinned = false, uuid }) {
+ return this.env.services.rpc({
+ model: 'mail.channel',
+ method: 'channel_pin',
+ kwargs: {
+ uuid,
+ pinned,
+ },
+ }, { shadow: true });
+ }
+
+ /**
+ * Performs the `channel_create` RPC on `mail.channel`.
+ *
+ * @static
+ * @param {Object} param0
+ * @param {string} param0.name
+ * @param {string} [param0.privacy]
+ * @returns {mail.thread} the created channel
+ */
+ static async performRpcCreateChannel({ name, privacy }) {
+ const device = this.env.messaging.device;
+ const data = await this.env.services.rpc({
+ model: 'mail.channel',
+ method: 'channel_create',
+ args: [name, privacy],
+ kwargs: {
+ context: Object.assign({}, this.env.session.user_content, {
+ // optimize the return value by avoiding useless queries
+ // in non-mobile devices
+ isMobile: device.isMobile,
+ }),
+ },
+ });
+ return this.env.models['mail.thread'].insert(
+ this.env.models['mail.thread'].convertData(data)
+ );
+ }
+
+ /**
+ * Performs the `channel_get` RPC on `mail.channel`.
+ *
+ * `openChat` is preferable in business code because it will avoid the
+ * RPC if the chat already exists.
+ *
+ * @static
+ * @param {Object} param0
+ * @param {integer[]} param0.partnerIds
+ * @param {boolean} [param0.pinForCurrentPartner]
+ * @returns {mail.thread|undefined} the created or existing chat
+ */
+ static async performRpcCreateChat({ partnerIds, pinForCurrentPartner }) {
+ const device = this.env.messaging.device;
+ // TODO FIX: potential duplicate chat task-2276490
+ const data = await this.env.services.rpc({
+ model: 'mail.channel',
+ method: 'channel_get',
+ kwargs: {
+ context: Object.assign({}, this.env.session.user_content, {
+ // optimize the return value by avoiding useless queries
+ // in non-mobile devices
+ isMobile: device.isMobile,
+ }),
+ partners_to: partnerIds,
+ pin: pinForCurrentPartner,
+ },
+ });
+ if (!data) {
+ return;
+ }
+ return this.env.models['mail.thread'].insert(
+ this.env.models['mail.thread'].convertData(data)
+ );
+ }
+
+ /**
+ * Performs the `channel_join_and_get_info` RPC on `mail.channel`.
+ *
+ * @static
+ * @param {Object} param0
+ * @param {integer} param0.channelId
+ * @returns {mail.thread} the channel that was joined
+ */
+ static async performRpcJoinChannel({ channelId }) {
+ const device = this.env.messaging.device;
+ const data = await this.env.services.rpc({
+ model: 'mail.channel',
+ method: 'channel_join_and_get_info',
+ args: [[channelId]],
+ kwargs: {
+ context: Object.assign({}, this.env.session.user_content, {
+ // optimize the return value by avoiding useless queries
+ // in non-mobile devices
+ isMobile: device.isMobile,
+ }),
+ },
+ });
+ return this.env.models['mail.thread'].insert(
+ this.env.models['mail.thread'].convertData(data)
+ );
+ }
+
+ /**
+ * Performs the `execute_command` RPC on `mail.channel`.
+ *
+ * @static
+ * @param {Object} param0
+ * @param {integer} param0.channelId
+ * @param {string} param0.command
+ * @param {Object} [param0.postData={}]
+ */
+ static async performRpcExecuteCommand({ channelId, command, postData = {} }) {
+ return this.env.services.rpc({
+ model: 'mail.channel',
+ method: 'execute_command',
+ args: [[channelId]],
+ kwargs: Object.assign({ command }, postData),
+ });
+ }
+
+ /**
+ * Performs the `message_post` RPC on given threadModel.
+ *
+ * @static
+ * @param {Object} param0
+ * @param {Object} param0.postData
+ * @param {integer} param0.threadId
+ * @param {string} param0.threadModel
+ * @return {integer} the posted message id
+ */
+ static async performRpcMessagePost({ postData, threadId, threadModel }) {
+ return this.env.services.rpc({
+ model: threadModel,
+ method: 'message_post',
+ args: [threadId],
+ kwargs: postData,
+ });
+ }
+
+ /**
+ * Performs RPC on the route `/mail/get_suggested_recipients`.
+ *
+ * @static
+ * @param {Object} param0
+ * @param {string} param0.model
+ * @param {integer[]} param0.res_id
+ */
+ static async performRpcMailGetSuggestedRecipients({ model, res_ids }) {
+ const data = await this.env.services.rpc({
+ route: '/mail/get_suggested_recipients',
+ params: {
+ model,
+ res_ids,
+ },
+ }, { shadow: true });
+ for (const id in data) {
+ const recipientInfoList = data[id].map(recipientInfoData => {
+ const [partner_id, emailInfo, reason] = recipientInfoData;
+ const [name, email] = emailInfo && mailUtils.parseEmail(emailInfo);
+ return {
+ email,
+ name,
+ partner: [partner_id ? ['insert', { id: partner_id }] : ['unlink']],
+ reason,
+ };
+ });
+ this.insert({
+ id: parseInt(id),
+ model,
+ suggestedRecipientInfoList: [['insert-and-replace', recipientInfoList]],
+ });
+ }
+ }
+
+ /*
+ * Returns threads that match the given search term. More specially only
+ * threads of model 'mail.channel' are suggested, and if the context
+ * thread is a private channel, only itself is returned if it matches
+ * the search term.
+ *
+ * @static
+ * @param {string} searchTerm
+ * @param {Object} [options={}]
+ * @param {mail.thread} [options.thread] prioritize and/or restrict
+ * result in the context of given thread
+ * @returns {[mail.threads[], mail.threads[]]}
+ */
+ static searchSuggestions(searchTerm, { thread } = {}) {
+ let threads;
+ if (thread && thread.model === 'mail.channel' && thread.public !== 'public') {
+ // Only return the current channel when in the context of a
+ // non-public channel. Indeed, the message with the mention
+ // would appear in the target channel, so this prevents from
+ // inadvertently leaking the private message into the mentioned
+ // channel.
+ threads = [thread];
+ } else {
+ threads = this.env.models['mail.thread'].all();
+ }
+ const cleanedSearchTerm = cleanSearchTerm(searchTerm);
+ return [threads.filter(thread =>
+ !thread.isTemporary &&
+ thread.model === 'mail.channel' &&
+ thread.channel_type === 'channel' &&
+ thread.name &&
+ cleanSearchTerm(thread.name).includes(cleanedSearchTerm)
+ )];
+ }
+
+ /**
+ * @param {string} [stringifiedDomain='[]']
+ * @returns {mail.thread_cache}
+ */
+ cache(stringifiedDomain = '[]') {
+ return this.env.models['mail.thread_cache'].insert({
+ stringifiedDomain,
+ thread: [['link', this]],
+ });
+ }
+
+ /**
+ * Fetch attachments linked to a record. Useful for populating the store
+ * with these attachments, which are used by attachment box in the chatter.
+ */
+ async fetchAttachments() {
+ const attachmentsData = await this.async(() => this.env.services.rpc({
+ model: 'ir.attachment',
+ method: 'search_read',
+ domain: [
+ ['res_id', '=', this.id],
+ ['res_model', '=', this.model],
+ ],
+ fields: ['id', 'name', 'mimetype'],
+ orderBy: [{ name: 'id', asc: false }],
+ }, { shadow: true }));
+ this.update({
+ originThreadAttachments: [['insert-and-replace',
+ attachmentsData.map(data =>
+ this.env.models['mail.attachment'].convertData(data)
+ )
+ ]],
+ });
+ this.update({ areAttachmentsLoaded: true });
+ }
+
+ /**
+ * Fetches suggested recipients.
+ */
+ async fetchAndUpdateSuggestedRecipients() {
+ if (this.isTemporary) {
+ return;
+ }
+ return this.env.models['mail.thread'].performRpcMailGetSuggestedRecipients({
+ model: this.model,
+ res_ids: [this.id],
+ });
+ }
+
+ /**
+ * Add current user to provided thread's followers.
+ */
+ async follow() {
+ await this.async(() => this.env.services.rpc({
+ model: this.model,
+ method: 'message_subscribe',
+ args: [[this.id]],
+ kwargs: {
+ partner_ids: [this.env.messaging.currentPartner.id],
+ context: {}, // FIXME empty context to be overridden in session.js with 'allowed_company_ids' task-2243187
+ },
+ }));
+ this.refreshFollowers();
+ this.fetchAndUpdateSuggestedRecipients();
+ }
+
+ /**
+ * Returns the text that identifies this thread in a mention.
+ *
+ * @returns {string}
+ */
+ getMentionText() {
+ return this.name;
+ }
+
+ /**
+ * Load new messages on the main cache of this thread.
+ */
+ loadNewMessages() {
+ this.mainCache.loadNewMessages();
+ }
+
+ /**
+ * Mark the specified conversation as fetched.
+ */
+ async markAsFetched() {
+ await this.async(() => this.env.services.rpc({
+ model: 'mail.channel',
+ method: 'channel_fetched',
+ args: [[this.id]],
+ }, { shadow: true }));
+ }
+
+ /**
+ * Mark the specified conversation as read/seen.
+ *
+ * @param {mail.message} message the message to be considered as last seen.
+ */
+ async markAsSeen(message) {
+ if (this.model !== 'mail.channel') {
+ return;
+ }
+ if (this.pendingSeenMessageId && message.id <= this.pendingSeenMessageId) {
+ return;
+ }
+ if (
+ this.lastSeenByCurrentPartnerMessageId &&
+ message.id <= this.lastSeenByCurrentPartnerMessageId
+ ) {
+ return;
+ }
+ this.update({ pendingSeenMessageId: message.id });
+ return this.env.models['mail.thread'].performRpcChannelSeen({
+ ids: [this.id],
+ lastMessageId: message.id,
+ });
+ }
+
+ /**
+ * Marks as read all needaction messages with this thread as origin.
+ */
+ async markNeedactionMessagesAsOriginThreadAsRead() {
+ await this.async(() =>
+ this.env.models['mail.message'].markAsRead(this.needactionMessagesAsOriginThread)
+ );
+ }
+
+ /**
+ * Mark as read all needaction messages of this thread.
+ */
+ async markNeedactionMessagesAsRead() {
+ await this.async(() =>
+ this.env.models['mail.message'].markAsRead(this.needactionMessages)
+ );
+ }
+
+ /**
+ * Notifies the server of new fold state. Useful for initial,
+ * cross-tab, and cross-device chat window state synchronization.
+ *
+ * @param {string} state
+ */
+ async notifyFoldStateToServer(state) {
+ if (this.model !== 'mail.channel') {
+ // Server sync of fold state is only supported for channels.
+ return;
+ }
+ if (!this.uuid) {
+ return;
+ }
+ return this.env.models['mail.thread'].performRpcChannelFold(this.uuid, state);
+ }
+
+ /**
+ * Notify server to leave the current channel. Useful for cross-tab
+ * and cross-device chat window state synchronization.
+ *
+ * Only makes sense if isPendingPinned is set to the desired value.
+ */
+ async notifyPinStateToServer() {
+ if (this.isPendingPinned) {
+ await this.env.models['mail.thread'].performRpcChannelPin({
+ pinned: true,
+ uuid: this.uuid,
+ });
+ } else {
+ this.env.models['mail.thread'].performRpcExecuteCommand({
+ channelId: this.id,
+ command: 'leave',
+ });
+ }
+ }
+
+ /**
+ * Opens this thread either as form view, in discuss app, or as a chat
+ * window. The thread will be opened in an "active" matter, which will
+ * interrupt current user flow.
+ *
+ * @param {Object} [param0]
+ * @param {boolean} [param0.expanded=false]
+ */
+ async open({ expanded = false } = {}) {
+ const discuss = this.env.messaging.discuss;
+ // check if thread must be opened in form view
+ if (!['mail.box', 'mail.channel'].includes(this.model)) {
+ if (expanded || discuss.isOpen) {
+ // Close chat window because having the same thread opened
+ // both in chat window and as main document does not look
+ // good.
+ this.env.messaging.chatWindowManager.closeThread(this);
+ return this.env.messaging.openDocument({
+ id: this.id,
+ model: this.model,
+ });
+ }
+ }
+ // check if thread must be opened in discuss
+ const device = this.env.messaging.device;
+ if (
+ (!device.isMobile && (discuss.isOpen || expanded)) ||
+ this.model === 'mail.box'
+ ) {
+ return discuss.openThread(this);
+ }
+ // thread must be opened in chat window
+ return this.env.messaging.chatWindowManager.openThread(this, {
+ makeActive: true,
+ });
+ }
+
+ /**
+ * Opens the most appropriate view that is a profile for this thread.
+ */
+ async openProfile() {
+ return this.env.messaging.openDocument({
+ id: this.id,
+ model: this.model,
+ });
+ }
+
+ /**
+ * Pin this thread and notify server of the change.
+ */
+ async pin() {
+ this.update({ isPendingPinned: true });
+ await this.notifyPinStateToServer();
+ }
+
+ /**
+ * Open a dialog to add channels as followers.
+ */
+ promptAddChannelFollower() {
+ this._promptAddFollower({ mail_invite_follower_channel_only: true });
+ }
+
+ /**
+ * Open a dialog to add partners as followers.
+ */
+ promptAddPartnerFollower() {
+ this._promptAddFollower({ mail_invite_follower_channel_only: false });
+ }
+
+ async refresh() {
+ if (this.isTemporary) {
+ return;
+ }
+ this.loadNewMessages();
+ this.update({ isLoadingAttachments: true });
+ await this.async(() => this.fetchAttachments());
+ this.update({ isLoadingAttachments: false });
+ }
+
+ async refreshActivities() {
+ if (!this.hasActivities) {
+ return;
+ }
+ if (this.isTemporary) {
+ return;
+ }
+ // A bit "extreme", may be improved
+ const [{ activity_ids: newActivityIds }] = await this.async(() => this.env.services.rpc({
+ model: this.model,
+ method: 'read',
+ args: [this.id, ['activity_ids']]
+ }, { shadow: true }));
+ const activitiesData = await this.async(() => this.env.services.rpc({
+ model: 'mail.activity',
+ method: 'activity_format',
+ args: [newActivityIds]
+ }, { shadow: true }));
+ const activities = this.env.models['mail.activity'].insert(activitiesData.map(
+ activityData => this.env.models['mail.activity'].convertData(activityData)
+ ));
+ this.update({ activities: [['replace', activities]] });
+ }
+
+ /**
+ * Refresh followers information from server.
+ */
+ async refreshFollowers() {
+ if (this.isTemporary) {
+ this.update({ followers: [['unlink-all']] });
+ return;
+ }
+ const { followers } = await this.async(() => this.env.services.rpc({
+ route: '/mail/read_followers',
+ params: {
+ res_id: this.id,
+ res_model: this.model,
+ },
+ }, { shadow: true }));
+ this.update({ areFollowersLoaded: true });
+ if (followers.length > 0) {
+ this.update({
+ followers: [['insert-and-replace', followers.map(data =>
+ this.env.models['mail.follower'].convertData(data))
+ ]],
+ });
+ } else {
+ this.update({
+ followers: [['unlink-all']],
+ });
+ }
+ }
+
+ /**
+ * Refresh the typing status of the current partner.
+ */
+ refreshCurrentPartnerIsTyping() {
+ this._currentPartnerInactiveTypingTimer.reset();
+ }
+
+ /**
+ * Called to refresh a registered other member partner that is typing
+ * something.
+ *
+ * @param {mail.partner} partner
+ */
+ refreshOtherMemberTypingMember(partner) {
+ this._otherMembersLongTypingTimers.get(partner).reset();
+ }
+
+ /**
+ * Called when current partner is inserting some input in composer.
+ * Useful to notify current partner is currently typing something in the
+ * composer of this thread to all other members.
+ */
+ async registerCurrentPartnerIsTyping() {
+ // Handling of typing timers.
+ this._currentPartnerInactiveTypingTimer.start();
+ this._currentPartnerLongTypingTimer.start();
+ // Manage typing member relation.
+ const currentPartner = this.env.messaging.currentPartner;
+ const newOrderedTypingMemberLocalIds = this.orderedTypingMemberLocalIds
+ .filter(localId => localId !== currentPartner.localId);
+ newOrderedTypingMemberLocalIds.push(currentPartner.localId);
+ this.update({
+ orderedTypingMemberLocalIds: newOrderedTypingMemberLocalIds,
+ typingMembers: [['link', currentPartner]],
+ });
+ // Notify typing status to other members.
+ await this._throttleNotifyCurrentPartnerTypingStatus({ isTyping: true });
+ }
+
+ /**
+ * Called to register a new other member partner that is typing
+ * something.
+ *
+ * @param {mail.partner} partner
+ */
+ registerOtherMemberTypingMember(partner) {
+ const timer = new Timer(
+ this.env,
+ () => this.async(() => this._onOtherMemberLongTypingTimeout(partner)),
+ 60 * 1000
+ );
+ this._otherMembersLongTypingTimers.set(partner, timer);
+ timer.start();
+ const newOrderedTypingMemberLocalIds = this.orderedTypingMemberLocalIds
+ .filter(localId => localId !== partner.localId);
+ newOrderedTypingMemberLocalIds.push(partner.localId);
+ this.update({
+ orderedTypingMemberLocalIds: newOrderedTypingMemberLocalIds,
+ typingMembers: [['link', partner]],
+ });
+ }
+
+ /**
+ * Rename the given thread with provided new name.
+ *
+ * @param {string} newName
+ */
+ async rename(newName) {
+ if (this.channel_type === 'chat') {
+ await this.async(() => this.env.services.rpc({
+ model: 'mail.channel',
+ method: 'channel_set_custom_name',
+ args: [this.id],
+ kwargs: {
+ name: newName,
+ },
+ }));
+ }
+ this.update({ custom_channel_name: newName });
+ }
+
+ /**
+ * Unfollow current partner from this thread.
+ */
+ async unfollow() {
+ const currentPartnerFollower = this.followers.find(
+ follower => follower.partner === this.env.messaging.currentPartner
+ );
+ await this.async(() => currentPartnerFollower.remove());
+ }
+
+ /**
+ * Unpin this thread and notify server of the change.
+ */
+ async unpin() {
+ this.update({ isPendingPinned: false });
+ await this.notifyPinStateToServer();
+ }
+
+ /**
+ * Called when current partner has explicitly stopped inserting some
+ * input in composer. Useful to notify current partner has currently
+ * stopped typing something in the composer of this thread to all other
+ * members.
+ *
+ * @param {Object} [param0={}]
+ * @param {boolean} [param0.immediateNotify=false] if set, is typing
+ * status of current partner is immediately notified and doesn't
+ * consume throttling at all.
+ */
+ async unregisterCurrentPartnerIsTyping({ immediateNotify = false } = {}) {
+ // Handling of typing timers.
+ this._currentPartnerInactiveTypingTimer.clear();
+ this._currentPartnerLongTypingTimer.clear();
+ // Manage typing member relation.
+ const currentPartner = this.env.messaging.currentPartner;
+ const newOrderedTypingMemberLocalIds = this.orderedTypingMemberLocalIds
+ .filter(localId => localId !== currentPartner.localId);
+ this.update({
+ orderedTypingMemberLocalIds: newOrderedTypingMemberLocalIds,
+ typingMembers: [['unlink', currentPartner]],
+ });
+ // Notify typing status to other members.
+ if (immediateNotify) {
+ this._throttleNotifyCurrentPartnerTypingStatus.clear();
+ }
+ await this.async(
+ () => this._throttleNotifyCurrentPartnerTypingStatus({ isTyping: false })
+ );
+ }
+
+ /**
+ * Called to unregister an other member partner that is no longer typing
+ * something.
+ *
+ * @param {mail.partner} partner
+ */
+ unregisterOtherMemberTypingMember(partner) {
+ this._otherMembersLongTypingTimers.get(partner).clear();
+ this._otherMembersLongTypingTimers.delete(partner);
+ const newOrderedTypingMemberLocalIds = this.orderedTypingMemberLocalIds
+ .filter(localId => localId !== partner.localId);
+ this.update({
+ orderedTypingMemberLocalIds: newOrderedTypingMemberLocalIds,
+ typingMembers: [['unlink', partner]],
+ });
+ }
+
+ /**
+ * Unsubscribe current user from provided channel.
+ */
+ unsubscribe() {
+ this.env.messaging.chatWindowManager.closeThread(this);
+ this.unpin();
+ }
+
+ //----------------------------------------------------------------------
+ // Private
+ //----------------------------------------------------------------------
+
+ /**
+ * @override
+ */
+ static _createRecordLocalId(data) {
+ const { channel_type, id, model } = data;
+ let threadModel = model;
+ if (!threadModel && channel_type) {
+ threadModel = 'mail.channel';
+ }
+ return `${this.modelName}_${threadModel}_${id}`;
+ }
+
+ /**
+ * @private
+ * @returns {mail.attachment[]}
+ */
+ _computeAllAttachments() {
+ const allAttachments = [...new Set(this.originThreadAttachments.concat(this.attachments))]
+ .sort((a1, a2) => {
+ // "uploading" before "uploaded" attachments.
+ if (!a1.isTemporary && a2.isTemporary) {
+ return 1;
+ }
+ if (a1.isTemporary && !a2.isTemporary) {
+ return -1;
+ }
+ // "most-recent" before "oldest" attachments.
+ return Math.abs(a2.id) - Math.abs(a1.id);
+ });
+ return [['replace', allAttachments]];
+ }
+
+ /**
+ * @private
+ * @returns {mail.partner}
+ */
+ _computeCorrespondent() {
+ if (this.channel_type === 'channel') {
+ return [['unlink']];
+ }
+ const correspondents = this.members.filter(partner =>
+ partner !== this.env.messaging.currentPartner
+ );
+ if (correspondents.length === 1) {
+ // 2 members chat
+ return [['link', correspondents[0]]];
+ }
+ if (this.members.length === 1) {
+ // chat with oneself
+ return [['link', this.members[0]]];
+ }
+ return [['unlink']];
+ }
+
+ /**
+ * @private
+ * @returns {string}
+ */
+ _computeDisplayName() {
+ if (this.channel_type === 'chat' && this.correspondent) {
+ return this.custom_channel_name || this.correspondent.nameOrDisplayName;
+ }
+ return this.name;
+ }
+
+ /**
+ * @private
+ * @returns {mail.activity[]}
+ */
+ _computeFutureActivities() {
+ return [['replace', this.activities.filter(activity => activity.state === 'planned')]];
+ }
+
+ /**
+ * @private
+ * @returns {boolean}
+ */
+ _computeHasSeenIndicators() {
+ if (this.model !== 'mail.channel') {
+ return false;
+ }
+ if (this.mass_mailing) {
+ return false;
+ }
+ return ['chat', 'livechat'].includes(this.channel_type);
+ }
+
+ /**
+ * @private
+ * @returns {boolean}
+ */
+ _computeIsChatChannel() {
+ return this.channel_type === 'chat';
+ }
+
+ /**
+ * @private
+ * @returns {boolean}
+ */
+ _computeIsCurrentPartnerFollowing() {
+ return this.followers.some(follower =>
+ follower.partner && follower.partner === this.env.messaging.currentPartner
+ );
+ }
+
+ /**
+ * @private
+ * @returns {boolean}
+ */
+ _computeIsModeratedByCurrentPartner() {
+ if (!this.messaging) {
+ return false;
+ }
+ if (!this.messaging.currentPartner) {
+ return false;
+ }
+ return this.moderators.includes(this.env.messaging.currentPartner);
+ }
+
+ /**
+ * @private
+ * @returns {boolean}
+ */
+ _computeIsPinned() {
+ return this.isPendingPinned !== undefined ? this.isPendingPinned : this.isServerPinned;
+ }
+
+ /**
+ * @private
+ * @returns {mail.message}
+ */
+ _computeLastCurrentPartnerMessageSeenByEveryone() {
+ const otherPartnerSeenInfos =
+ this.partnerSeenInfos.filter(partnerSeenInfo =>
+ partnerSeenInfo.partner !== this.messagingCurrentPartner);
+ if (otherPartnerSeenInfos.length === 0) {
+ return [['unlink-all']];
+ }
+
+ const otherPartnersLastSeenMessageIds =
+ otherPartnerSeenInfos.map(partnerSeenInfo =>
+ partnerSeenInfo.lastSeenMessage ? partnerSeenInfo.lastSeenMessage.id : 0
+ );
+ if (otherPartnersLastSeenMessageIds.length === 0) {
+ return [['unlink-all']];
+ }
+ const lastMessageSeenByAllId = Math.min(
+ ...otherPartnersLastSeenMessageIds
+ );
+ const currentPartnerOrderedSeenMessages =
+ this.orderedNonTransientMessages.filter(message =>
+ message.author === this.messagingCurrentPartner &&
+ message.id <= lastMessageSeenByAllId);
+
+ if (
+ !currentPartnerOrderedSeenMessages ||
+ currentPartnerOrderedSeenMessages.length === 0
+ ) {
+ return [['unlink-all']];
+ }
+ return [['link', currentPartnerOrderedSeenMessages.slice().pop()]];
+ }
+
+ /**
+ * @private
+ * @returns {mail.message|undefined}
+ */
+ _computeLastMessage() {
+ const {
+ length: l,
+ [l - 1]: lastMessage,
+ } = this.orderedMessages;
+ if (lastMessage) {
+ return [['link', lastMessage]];
+ }
+ return [['unlink']];
+ }
+
+ /**
+ * @private
+ * @returns {mail.message|undefined}
+ */
+ _computeLastNonTransientMessage() {
+ const {
+ length: l,
+ [l - 1]: lastMessage,
+ } = this.orderedNonTransientMessages;
+ if (lastMessage) {
+ return [['link', lastMessage]];
+ }
+ return [['unlink']];
+ }
+
+ /**
+ * Adjusts the last seen message received from the server to consider
+ * the following messages also as read if they are either transient
+ * messages or messages from the current partner.
+ *
+ * @private
+ * @returns {integer}
+ */
+ _computeLastSeenByCurrentPartnerMessageId() {
+ const firstMessage = this.orderedMessages[0];
+ if (
+ firstMessage &&
+ this.lastSeenByCurrentPartnerMessageId &&
+ this.lastSeenByCurrentPartnerMessageId < firstMessage.id
+ ) {
+ // no deduction can be made if there is a gap
+ return this.lastSeenByCurrentPartnerMessageId;
+ }
+ let lastSeenByCurrentPartnerMessageId = this.lastSeenByCurrentPartnerMessageId;
+ for (const message of this.orderedMessages) {
+ if (message.id <= this.lastSeenByCurrentPartnerMessageId) {
+ continue;
+ }
+ if (
+ message.author === this.env.messaging.currentPartner ||
+ message.isTransient
+ ) {
+ lastSeenByCurrentPartnerMessageId = message.id;
+ continue;
+ }
+ return lastSeenByCurrentPartnerMessageId;
+ }
+ return lastSeenByCurrentPartnerMessageId;
+ }
+
+ /**
+ * @private
+ * @returns {mail.message|undefined}
+ */
+ _computeLastNeedactionMessage() {
+ const orderedNeedactionMessages = this.needactionMessages.sort(
+ (m1, m2) => m1.id < m2.id ? -1 : 1
+ );
+ const {
+ length: l,
+ [l - 1]: lastNeedactionMessage,
+ } = orderedNeedactionMessages;
+ if (lastNeedactionMessage) {
+ return [['link', lastNeedactionMessage]];
+ }
+ return [['unlink']];
+ }
+
+ /**
+ * @private
+ * @returns {mail.message|undefined}
+ */
+ _computeLastNeedactionMessageAsOriginThread() {
+ const orderedNeedactionMessagesAsOriginThread = this.needactionMessagesAsOriginThread.sort(
+ (m1, m2) => m1.id < m2.id ? -1 : 1
+ );
+ const {
+ length: l,
+ [l - 1]: lastNeedactionMessageAsOriginThread,
+ } = orderedNeedactionMessagesAsOriginThread;
+ if (lastNeedactionMessageAsOriginThread) {
+ return [['link', lastNeedactionMessageAsOriginThread]];
+ }
+ return [['unlink']];
+ }
+
+ /**
+ * @private
+ * @returns {mail.thread_cache}
+ */
+ _computeMainCache() {
+ return [['link', this.cache()]];
+ }
+
+ /**
+ * @private
+ * @returns {integer}
+ */
+ _computeLocalMessageUnreadCounter() {
+ if (this.model !== 'mail.channel') {
+ // unread counter only makes sense on channels
+ return clear();
+ }
+ // By default trust the server up to the last message it used
+ // because it's not possible to do better.
+ let baseCounter = this.serverMessageUnreadCounter;
+ let countFromId = this.serverLastMessageId;
+ // But if the client knows the last seen message that the server
+ // returned (and by assumption all the messages that come after),
+ // the counter can be computed fully locally, ignoring potentially
+ // obsolete values from the server.
+ const firstMessage = this.orderedMessages[0];
+ if (
+ firstMessage &&
+ this.lastSeenByCurrentPartnerMessageId &&
+ this.lastSeenByCurrentPartnerMessageId >= firstMessage.id
+ ) {
+ baseCounter = 0;
+ countFromId = this.lastSeenByCurrentPartnerMessageId;
+ }
+ // Include all the messages that are known locally but the server
+ // didn't take into account.
+ return this.orderedMessages.reduce((total, message) => {
+ if (message.id <= countFromId) {
+ return total;
+ }
+ return total + 1;
+ }, baseCounter);
+ }
+
+ /**
+ * @private
+ * @returns {mail.messaging}
+ */
+ _computeMessaging() {
+ return [['link', this.env.messaging]];
+ }
+
+ /**
+ * @private
+ * @returns {mail.message[]}
+ */
+ _computeNeedactionMessages() {
+ return [['replace', this.messages.filter(message => message.isNeedaction)]];
+ }
+
+ /**
+ * @private
+ * @returns {mail.message[]}
+ */
+ _computeNeedactionMessagesAsOriginThread() {
+ return [['replace', this.messagesAsOriginThread.filter(message => message.isNeedaction)]];
+ }
+
+ /**
+ * @private
+ * @returns {mail.message|undefined}
+ */
+ _computeMessageAfterNewMessageSeparator() {
+ if (this.model !== 'mail.channel') {
+ return [['unlink']];
+ }
+ if (this.localMessageUnreadCounter === 0) {
+ return [['unlink']];
+ }
+ const index = this.orderedMessages.findIndex(message =>
+ message.id === this.lastSeenByCurrentPartnerMessageId
+ );
+ if (index === -1) {
+ return [['unlink']];
+ }
+ const message = this.orderedMessages[index + 1];
+ if (!message) {
+ return [['unlink']];
+ }
+ return [['link', message]];
+ }
+
+ /**
+ * @private
+ * @returns {mail.message[]}
+ */
+ _computeOrderedMessages() {
+ return [['replace', this.messages.sort((m1, m2) => m1.id < m2.id ? -1 : 1)]];
+ }
+
+ /**
+ * @private
+ * @returns {mail.message[]}
+ */
+ _computeOrderedNonTransientMessages() {
+ return [['replace', this.orderedMessages.filter(m => !m.isTransient)]];
+ }
+
+ /**
+ * @private
+ * @returns {mail.partner[]}
+ */
+ _computeOrderedOtherTypingMembers() {
+ return [[
+ 'replace',
+ this.orderedTypingMembers.filter(
+ member => member !== this.env.messaging.currentPartner
+ ),
+ ]];
+ }
+
+ /**
+ * @private
+ * @returns {mail.partner[]}
+ */
+ _computeOrderedTypingMembers() {
+ return [[
+ 'replace',
+ this.orderedTypingMemberLocalIds
+ .map(localId => this.env.models['mail.partner'].get(localId))
+ .filter(member => !!member),
+ ]];
+ }
+
+ /**
+ * @private
+ * @returns {mail.activity[]}
+ */
+ _computeOverdueActivities() {
+ return [['replace', this.activities.filter(activity => activity.state === 'overdue')]];
+ }
+
+ /**
+ * @private
+ * @returns {mail.activity[]}
+ */
+ _computeTodayActivities() {
+ return [['replace', this.activities.filter(activity => activity.state === 'today')]];
+ }
+
+ /**
+ * @private
+ * @returns {string}
+ */
+ _computeTypingStatusText() {
+ if (this.orderedOtherTypingMembers.length === 0) {
+ return this.constructor.fields.typingStatusText.default;
+ }
+ if (this.orderedOtherTypingMembers.length === 1) {
+ return _.str.sprintf(
+ this.env._t("%s is typing..."),
+ this.orderedOtherTypingMembers[0].nameOrDisplayName
+ );
+ }
+ if (this.orderedOtherTypingMembers.length === 2) {
+ return _.str.sprintf(
+ this.env._t("%s and %s are typing..."),
+ this.orderedOtherTypingMembers[0].nameOrDisplayName,
+ this.orderedOtherTypingMembers[1].nameOrDisplayName
+ );
+ }
+ return _.str.sprintf(
+ this.env._t("%s, %s and more are typing..."),
+ this.orderedOtherTypingMembers[0].nameOrDisplayName,
+ this.orderedOtherTypingMembers[1].nameOrDisplayName
+ );
+ }
+
+ /**
+ * Compute an url string that can be used inside a href attribute
+ *
+ * @private
+ * @returns {string}
+ */
+ _computeUrl() {
+ const baseHref = this.env.session.url('/web');
+ if (this.model === 'mail.channel') {
+ return `${baseHref}#action=mail.action_discuss&active_id=${this.model}_${this.id}`;
+ }
+ return `${baseHref}#model=${this.model}&id=${this.id}`;
+ }
+
+ /**
+ * @private
+ * @param {Object} param0
+ * @param {boolean} param0.isTyping
+ */
+ async _notifyCurrentPartnerTypingStatus({ isTyping }) {
+ if (
+ this._forceNotifyNextCurrentPartnerTypingStatus ||
+ isTyping !== this._currentPartnerLastNotifiedIsTyping
+ ) {
+ if (this.model === 'mail.channel') {
+ await this.async(() => this.env.services.rpc({
+ model: 'mail.channel',
+ method: 'notify_typing',
+ args: [this.id],
+ kwargs: { is_typing: isTyping },
+ }, { shadow: true }));
+ }
+ if (isTyping && this._currentPartnerLongTypingTimer.isRunning) {
+ this._currentPartnerLongTypingTimer.reset();
+ }
+ }
+ this._forceNotifyNextCurrentPartnerTypingStatus = false;
+ this._currentPartnerLastNotifiedIsTyping = isTyping;
+ }
+
+ /**
+ * Cleans followers of current thread. In particular, chats are supposed
+ * to work with "members", not with "followers". This clean up is only
+ * necessary to remove illegitimate followers in stable version, it can
+ * be removed in master after proper migration to clean the database.
+ *
+ * @private
+ */
+ _onChangeFollowersPartner() {
+ if (this.channel_type !== 'chat') {
+ return;
+ }
+ for (const follower of this.followers) {
+ if (follower.partner) {
+ follower.remove();
+ }
+ }
+ }
+
+ /**
+ * @private
+ */
+ _onChangeLastSeenByCurrentPartnerMessageId() {
+ this.env.messagingBus.trigger('o-thread-last-seen-by-current-partner-message-id-changed', {
+ thread: this,
+ });
+ }
+
+ /**
+ * @private
+ */
+ _onChangeThreadViews() {
+ if (this.threadViews.length === 0) {
+ return;
+ }
+ /**
+ * Fetches followers of chats when they are displayed for the first
+ * time. This is necessary to clean the followers.
+ * @see `_onChangeFollowersPartner` for more information.
+ */
+ if (this.channel_type === 'chat' && !this.areFollowersLoaded) {
+ this.refreshFollowers();
+ }
+ }
+
+ /**
+ * Handles change of pinned state coming from the server. Useful to
+ * clear pending state once server acknowledged the change.
+ *
+ * @private
+ * @see isPendingPinned
+ */
+ _onIsServerPinnedChanged() {
+ if (this.isServerPinned === this.isPendingPinned) {
+ this.update({ isPendingPinned: clear() });
+ }
+ }
+
+ /**
+ * Handles change of fold state coming from the server. Useful to
+ * synchronize corresponding chat window.
+ *
+ * @private
+ */
+ _onServerFoldStateChanged() {
+ if (!this.env.messaging.chatWindowManager) {
+ // avoid crash during destroy
+ return;
+ }
+ if (this.env.messaging.device.isMobile) {
+ return;
+ }
+ if (this.serverFoldState === 'closed') {
+ this.env.messaging.chatWindowManager.closeThread(this, {
+ notifyServer: false,
+ });
+ } else {
+ this.env.messaging.chatWindowManager.openThread(this, {
+ isFolded: this.serverFoldState === 'folded',
+ notifyServer: false,
+ });
+ }
+ }
+
+ /**
+ * @private
+ * @param {Object} [param0={}]
+ * @param {boolean} [param0.mail_invite_follower_channel_only=false]
+ */
+ _promptAddFollower({ mail_invite_follower_channel_only = false } = {}) {
+ const self = this;
+ const action = {
+ type: 'ir.actions.act_window',
+ res_model: 'mail.wizard.invite',
+ view_mode: 'form',
+ views: [[false, 'form']],
+ name: this.env._t("Invite Follower"),
+ target: 'new',
+ context: {
+ default_res_model: this.model,
+ default_res_id: this.id,
+ mail_invite_follower_channel_only,
+ },
+ };
+ this.env.bus.trigger('do-action', {
+ action,
+ options: {
+ on_close: async () => {
+ await this.async(() => this.refreshFollowers());
+ this.env.bus.trigger('mail.thread:promptAddFollower-closed');
+ },
+ },
+ });
+ }
+
+ //----------------------------------------------------------------------
+ // Handlers
+ //----------------------------------------------------------------------
+
+ /**
+ * @private
+ */
+ async _onCurrentPartnerInactiveTypingTimeout() {
+ await this.async(() => this.unregisterCurrentPartnerIsTyping());
+ }
+
+ /**
+ * Called when current partner has been typing for a very long time.
+ * Immediately notify other members that he/she is still typing.
+ *
+ * @private
+ */
+ async _onCurrentPartnerLongTypingTimeout() {
+ this._forceNotifyNextCurrentPartnerTypingStatus = true;
+ this._throttleNotifyCurrentPartnerTypingStatus.clear();
+ await this.async(
+ () => this._throttleNotifyCurrentPartnerTypingStatus({ isTyping: true })
+ );
+ }
+
+ /**
+ * @private
+ * @param {mail.partner} partner
+ */
+ async _onOtherMemberLongTypingTimeout(partner) {
+ if (!this.typingMembers.includes(partner)) {
+ this._otherMembersLongTypingTimers.delete(partner);
+ return;
+ }
+ this.unregisterOtherMemberTypingMember(partner);
+ }
+
+ }
+
+ Thread.fields = {
+ /**
+ * Determines the `mail.activity` that belong to `this`, assuming `this`
+ * has activities (@see hasActivities).
+ */
+ activities: one2many('mail.activity', {
+ inverse: 'thread',
+ }),
+ /**
+ * Serves as compute dependency.
+ */
+ activitiesState: attr({
+ related: 'activities.state',
+ }),
+ allAttachments: many2many('mail.attachment', {
+ compute: '_computeAllAttachments',
+ dependencies: [
+ 'attachments',
+ 'originThreadAttachments',
+ ],
+ }),
+ areAttachmentsLoaded: attr({
+ default: false,
+ }),
+ /**
+ * States whether followers have been loaded at least once for this
+ * thread.
+ */
+ areFollowersLoaded: attr({
+ default: false,
+ }),
+ attachments: many2many('mail.attachment', {
+ inverse: 'threads',
+ }),
+ caches: one2many('mail.thread_cache', {
+ inverse: 'thread',
+ isCausal: true,
+ }),
+ channel_type: attr(),
+ /**
+ * States the `mail.chat_window` related to `this`. Serves as compute
+ * dependency. It is computed from the inverse relation and it should
+ * otherwise be considered read-only.
+ */
+ chatWindow: one2one('mail.chat_window', {
+ inverse: 'thread',
+ }),
+ /**
+ * Serves as compute dependency.
+ */
+ chatWindowIsFolded: attr({
+ related: 'chatWindow.isFolded',
+ }),
+ composer: one2one('mail.composer', {
+ default: [['create']],
+ inverse: 'thread',
+ isCausal: true,
+ }),
+ correspondent: many2one('mail.partner', {
+ compute: '_computeCorrespondent',
+ dependencies: [
+ 'channel_type',
+ 'members',
+ 'messagingCurrentPartner',
+ ],
+ inverse: 'correspondentThreads',
+ }),
+ correspondentNameOrDisplayName: attr({
+ related: 'correspondent.nameOrDisplayName',
+ }),
+ counter: attr({
+ default: 0,
+ }),
+ creator: many2one('mail.user'),
+ custom_channel_name: attr(),
+ displayName: attr({
+ compute: '_computeDisplayName',
+ dependencies: [
+ 'channel_type',
+ 'correspondent',
+ 'correspondentNameOrDisplayName',
+ 'custom_channel_name',
+ 'name',
+ ],
+ }),
+ followersPartner: many2many('mail.partner', {
+ related: 'followers.partner',
+ }),
+ followers: one2many('mail.follower', {
+ inverse: 'followedThread',
+ }),
+ /**
+ * States the `mail.activity` that belongs to `this` and that are
+ * planned in the future (due later than today).
+ */
+ futureActivities: one2many('mail.activity', {
+ compute: '_computeFutureActivities',
+ dependencies: ['activitiesState'],
+ }),
+ group_based_subscription: attr({
+ default: false,
+ }),
+ /**
+ * States whether `this` has activities (`mail.activity.mixin` server side).
+ */
+ hasActivities: attr({
+ default: false,
+ }),
+ /**
+ * Determine whether this thread has the seen indicators (V and VV)
+ * enabled or not.
+ */
+ hasSeenIndicators: attr({
+ compute: '_computeHasSeenIndicators',
+ default: false,
+ dependencies: [
+ 'channel_type',
+ 'mass_mailing',
+ 'model',
+ ],
+ }),
+ id: attr(),
+ /**
+ * States whether this thread is a `mail.channel` qualified as chat.
+ *
+ * Useful to list chat channels, like in messaging menu with the filter
+ * 'chat'.
+ */
+ isChatChannel: attr({
+ compute: '_computeIsChatChannel',
+ dependencies: [
+ 'channel_type',
+ ],
+ default: false,
+ }),
+ isCurrentPartnerFollowing: attr({
+ compute: '_computeIsCurrentPartnerFollowing',
+ default: false,
+ dependencies: [
+ 'followersPartner',
+ 'messagingCurrentPartner',
+ ],
+ }),
+ /**
+ * States whether `this` is currently loading attachments.
+ */
+ isLoadingAttachments: attr({
+ default: false,
+ }),
+ isModeratedByCurrentPartner: attr({
+ compute: '_computeIsModeratedByCurrentPartner',
+ dependencies: [
+ 'messagingCurrentPartner',
+ 'moderators',
+ ],
+ }),
+ /**
+ * Determine if there is a pending pin state change, which is a change
+ * of pin state requested by the client but not yet confirmed by the
+ * server.
+ *
+ * This field can be updated to immediately change the pin state on the
+ * interface and to notify the server of the new state.
+ */
+ isPendingPinned: attr(),
+ /**
+ * Boolean that determines whether this thread is pinned
+ * in discuss and present in the messaging menu.
+ */
+ isPinned: attr({
+ compute: '_computeIsPinned',
+ dependencies: [
+ 'isPendingPinned',
+ 'isServerPinned',
+ ],
+ }),
+ /**
+ * Determine the last pin state known by the server, which is the pin
+ * state displayed after initialization or when the last pending
+ * pin state change was confirmed by the server.
+ *
+ * This field should be considered read only in most situations. Only
+ * the code handling pin state change from the server should typically
+ * update it.
+ */
+ isServerPinned: attr({
+ default: false,
+ }),
+ isTemporary: attr({
+ default: false,
+ }),
+ is_moderator: attr({
+ default: false,
+ }),
+ lastCurrentPartnerMessageSeenByEveryone: many2one('mail.message', {
+ compute: '_computeLastCurrentPartnerMessageSeenByEveryone',
+ dependencies: [
+ 'messagingCurrentPartner',
+ 'orderedNonTransientMessages',
+ 'partnerSeenInfos',
+ ],
+ }),
+ /**
+ * Last message of the thread, could be a transient one.
+ */
+ lastMessage: many2one('mail.message', {
+ compute: '_computeLastMessage',
+ dependencies: ['orderedMessages'],
+ }),
+ lastNeedactionMessage: many2one('mail.message', {
+ compute: '_computeLastNeedactionMessage',
+ dependencies: ['needactionMessages'],
+ }),
+ /**
+ * States the last known needaction message having this thread as origin.
+ */
+ lastNeedactionMessageAsOriginThread: many2one('mail.message', {
+ compute: '_computeLastNeedactionMessageAsOriginThread',
+ dependencies: [
+ 'needactionMessagesAsOriginThread',
+ ],
+ }),
+ /**
+ * Last non-transient message.
+ */
+ lastNonTransientMessage: many2one('mail.message', {
+ compute: '_computeLastNonTransientMessage',
+ dependencies: ['orderedNonTransientMessages'],
+ }),
+ /**
+ * Last seen message id of the channel by current partner.
+ *
+ * Also, it needs to be kept as an id because it's considered like a "date" and could stay
+ * even if corresponding message is deleted. It is basically used to know which
+ * messages are before or after it.
+ */
+ lastSeenByCurrentPartnerMessageId: attr({
+ compute: '_computeLastSeenByCurrentPartnerMessageId',
+ default: 0,
+ dependencies: [
+ 'lastSeenByCurrentPartnerMessageId',
+ 'messagingCurrentPartner',
+ 'orderedMessages',
+ 'orderedMessagesIsTransient',
+ // FIXME missing dependency 'orderedMessages.author', (task-2261221)
+ ],
+ }),
+ /**
+ * Local value of message unread counter, that means it is based on initial server value and
+ * updated with interface updates.
+ */
+ localMessageUnreadCounter: attr({
+ compute: '_computeLocalMessageUnreadCounter',
+ dependencies: [
+ 'lastSeenByCurrentPartnerMessageId',
+ 'messagingCurrentPartner',
+ 'orderedMessages',
+ 'serverLastMessageId',
+ 'serverMessageUnreadCounter',
+ ],
+ }),
+ mainCache: one2one('mail.thread_cache', {
+ compute: '_computeMainCache',
+ }),
+ mass_mailing: attr({
+ default: false,
+ }),
+ members: many2many('mail.partner', {
+ inverse: 'memberThreads',
+ }),
+ /**
+ * Determines the message before which the "new message" separator must
+ * be positioned, if any.
+ */
+ messageAfterNewMessageSeparator: many2one('mail.message', {
+ compute: '_computeMessageAfterNewMessageSeparator',
+ dependencies: [
+ 'lastSeenByCurrentPartnerMessageId',
+ 'localMessageUnreadCounter',
+ 'model',
+ 'orderedMessages',
+ ],
+ }),
+ message_needaction_counter: attr({
+ default: 0,
+ }),
+ /**
+ * All messages that this thread is linked to.
+ * Note that this field is automatically computed by inverse
+ * computed field. This field is readonly.
+ */
+ messages: many2many('mail.message', {
+ inverse: 'threads',
+ }),
+ /**
+ * All messages that have been originally posted in this thread.
+ */
+ messagesAsOriginThread: one2many('mail.message', {
+ inverse: 'originThread',
+ }),
+ /**
+ * Serves as compute dependency.
+ */
+ messagesAsOriginThreadIsNeedaction: attr({
+ related: 'messagesAsOriginThread.isNeedaction',
+ }),
+ /**
+ * All messages that are contained on this channel on the server.
+ * Equivalent to the inverse of python field `channel_ids`.
+ */
+ messagesAsServerChannel: many2many('mail.message', {
+ inverse: 'serverChannels',
+ }),
+ /**
+ * Serves as compute dependency.
+ */
+ messagesIsNeedaction: attr({
+ related: 'messages.isNeedaction',
+ }),
+ messageSeenIndicators: one2many('mail.message_seen_indicator', {
+ inverse: 'thread',
+ isCausal: true,
+ }),
+ messaging: many2one('mail.messaging', {
+ compute: '_computeMessaging',
+ }),
+ messagingCurrentPartner: many2one('mail.partner', {
+ related: 'messaging.currentPartner',
+ }),
+ model: attr(),
+ model_name: attr(),
+ moderation: attr({
+ default: false,
+ }),
+ /**
+ * Partners that are moderating this thread (only applies to channels).
+ */
+ moderators: many2many('mail.partner', {
+ inverse: 'moderatedChannels',
+ }),
+ moduleIcon: attr(),
+ name: attr(),
+ needactionMessages: many2many('mail.message', {
+ compute: '_computeNeedactionMessages',
+ dependencies: [
+ 'messages',
+ 'messagesIsNeedaction',
+ ],
+ }),
+ /**
+ * States all known needaction messages having this thread as origin.
+ */
+ needactionMessagesAsOriginThread: many2many('mail.message', {
+ compute: '_computeNeedactionMessagesAsOriginThread',
+ dependencies: [
+ 'messagesAsOriginThread',
+ 'messagesAsOriginThreadIsNeedaction',
+ ],
+ }),
+ /**
+ * Not a real field, used to trigger `_onChangeFollowersPartner` when one of
+ * the dependencies changes.
+ */
+ onChangeFollowersPartner: attr({
+ compute: '_onChangeFollowersPartner',
+ dependencies: [
+ 'followersPartner',
+ ],
+ }),
+ /**
+ * Not a real field, used to trigger `_onChangeLastSeenByCurrentPartnerMessageId` when one of
+ * the dependencies changes.
+ */
+ onChangeLastSeenByCurrentPartnerMessageId: attr({
+ compute: '_onChangeLastSeenByCurrentPartnerMessageId',
+ dependencies: [
+ 'lastSeenByCurrentPartnerMessageId',
+ ],
+ }),
+ /**
+ * Not a real field, used to trigger `_onChangeThreadViews` when one of
+ * the dependencies changes.
+ */
+ onChangeThreadView: attr({
+ compute: '_onChangeThreadViews',
+ dependencies: [
+ 'threadViews',
+ ],
+ }),
+ /**
+ * Not a real field, used to trigger `_onIsServerPinnedChanged` when one of
+ * the dependencies changes.
+ */
+ onIsServerPinnedChanged: attr({
+ compute: '_onIsServerPinnedChanged',
+ dependencies: [
+ 'isServerPinned',
+ ],
+ }),
+ /**
+ * Not a real field, used to trigger `_onServerFoldStateChanged` when one of
+ * the dependencies changes.
+ */
+ onServerFoldStateChanged: attr({
+ compute: '_onServerFoldStateChanged',
+ dependencies: [
+ 'serverFoldState',
+ ],
+ }),
+ /**
+ * All messages ordered like they are displayed.
+ */
+ orderedMessages: many2many('mail.message', {
+ compute: '_computeOrderedMessages',
+ dependencies: ['messages'],
+ }),
+ /**
+ * Serves as compute dependency. (task-2261221)
+ */
+ orderedMessagesIsTransient: attr({
+ related: 'orderedMessages.isTransient',
+ }),
+ /**
+ * All messages ordered like they are displayed. This field does not
+ * contain transient messages which are not "real" records.
+ */
+ orderedNonTransientMessages: many2many('mail.message', {
+ compute: '_computeOrderedNonTransientMessages',
+ dependencies: [
+ 'orderedMessages',
+ 'orderedMessagesIsTransient',
+ ],
+ }),
+ /**
+ * Ordered typing members on this thread, excluding the current partner.
+ */
+ orderedOtherTypingMembers: many2many('mail.partner', {
+ compute: '_computeOrderedOtherTypingMembers',
+ dependencies: ['orderedTypingMembers'],
+ }),
+ /**
+ * Ordered typing members on this thread. Lower index means this member
+ * is currently typing for the longest time. This list includes current
+ * partner as typer.
+ */
+ orderedTypingMembers: many2many('mail.partner', {
+ compute: '_computeOrderedTypingMembers',
+ dependencies: [
+ 'orderedTypingMemberLocalIds',
+ 'typingMembers',
+ ],
+ }),
+ /**
+ * Technical attribute to manage ordered list of typing members.
+ */
+ orderedTypingMemberLocalIds: attr({
+ default: [],
+ }),
+ originThreadAttachments: one2many('mail.attachment', {
+ inverse: 'originThread',
+ }),
+ /**
+ * States the `mail.activity` that belongs to `this` and that are
+ * overdue (due earlier than today).
+ */
+ overdueActivities: one2many('mail.activity', {
+ compute: '_computeOverdueActivities',
+ dependencies: ['activitiesState'],
+ }),
+ partnerSeenInfos: one2many('mail.thread_partner_seen_info', {
+ inverse: 'thread',
+ isCausal: true,
+ }),
+ /**
+ * Determine if there is a pending seen message change, which is a change
+ * of seen message requested by the client but not yet confirmed by the
+ * server.
+ */
+ pendingSeenMessageId: attr(),
+ public: attr(),
+ /**
+ * Determine the last fold state known by the server, which is the fold
+ * state displayed after initialization or when the last pending
+ * fold state change was confirmed by the server.
+ *
+ * This field should be considered read only in most situations. Only
+ * the code handling fold state change from the server should typically
+ * update it.
+ */
+ serverFoldState: attr({
+ default: 'closed',
+ }),
+ /**
+ * Last message id considered by the server.
+ *
+ * Useful to compute localMessageUnreadCounter field.
+ *
+ * @see localMessageUnreadCounter
+ */
+ serverLastMessageId: attr({
+ default: 0,
+ }),
+ /**
+ * Message unread counter coming from server.
+ *
+ * Value of this field is unreliable, due to dynamic nature of
+ * messaging. So likely outdated/unsync with server. Should use
+ * localMessageUnreadCounter instead, which smartly guess the actual
+ * message unread counter at all time.
+ *
+ * @see localMessageUnreadCounter
+ */
+ serverMessageUnreadCounter: attr({
+ default: 0,
+ }),
+ /**
+ * Determines the `mail.suggested_recipient_info` concerning `this`.
+ */
+ suggestedRecipientInfoList: one2many('mail.suggested_recipient_info', {
+ inverse: 'thread',
+ }),
+ threadViews: one2many('mail.thread_view', {
+ inverse: 'thread',
+ }),
+ /**
+ * States the `mail.activity` that belongs to `this` and that are due
+ * specifically today.
+ */
+ todayActivities: one2many('mail.activity', {
+ compute: '_computeTodayActivities',
+ dependencies: ['activitiesState'],
+ }),
+ /**
+ * Members that are currently typing something in the composer of this
+ * thread, including current partner.
+ */
+ typingMembers: many2many('mail.partner'),
+ /**
+ * Text that represents the status on this thread about typing members.
+ */
+ typingStatusText: attr({
+ compute: '_computeTypingStatusText',
+ default: '',
+ dependencies: ['orderedOtherTypingMembers'],
+ }),
+ /**
+ * URL to access to the conversation.
+ */
+ url: attr({
+ compute: '_computeUrl',
+ default: '',
+ dependencies: [
+ 'id',
+ 'model',
+ ]
+ }),
+ uuid: attr(),
+ };
+
+ Thread.modelName = 'mail.thread';
+
+ return Thread;
+}
+
+registerNewModel('mail.thread', factory);
+
+});
diff --git a/addons/mail/static/src/models/thread/thread_tests.js b/addons/mail/static/src/models/thread/thread_tests.js
new file mode 100644
index 00000000..a535cf4e
--- /dev/null
+++ b/addons/mail/static/src/models/thread/thread_tests.js
@@ -0,0 +1,150 @@
+odoo.define('mail/static/src/models/thread/thread_tests.js', function (require) {
+'use strict';
+
+const { afterEach, beforeEach, start } = require('mail/static/src/utils/test_utils.js');
+
+QUnit.module('mail', {}, function () {
+QUnit.module('models', {}, function () {
+QUnit.module('thread', {}, function () {
+QUnit.module('thread_tests.js', {
+ beforeEach() {
+ beforeEach(this);
+
+ this.start = async params => {
+ const { env, widget } = await start(Object.assign({}, params, {
+ data: this.data,
+ }));
+ this.env = env;
+ this.widget = widget;
+ };
+ },
+ afterEach() {
+ afterEach(this);
+ },
+});
+
+QUnit.test('inbox & starred mailboxes', async function (assert) {
+ assert.expect(10);
+
+ await this.start();
+ const mailboxInbox = this.env.messaging.inbox;
+ const mailboxStarred = this.env.messaging.starred;
+ assert.ok(mailboxInbox, "should have mailbox inbox");
+ assert.ok(mailboxStarred, "should have mailbox starred");
+ assert.strictEqual(mailboxInbox.model, 'mail.box');
+ assert.strictEqual(mailboxInbox.counter, 0);
+ assert.strictEqual(mailboxInbox.id, 'inbox');
+ assert.strictEqual(mailboxInbox.name, "Inbox"); // language-dependent
+ assert.strictEqual(mailboxStarred.model, 'mail.box');
+ assert.strictEqual(mailboxStarred.counter, 0);
+ assert.strictEqual(mailboxStarred.id, 'starred');
+ assert.strictEqual(mailboxStarred.name, "Starred"); // language-dependent
+});
+
+QUnit.test('create (channel)', async function (assert) {
+ assert.expect(23);
+
+ await this.start();
+ assert.notOk(this.env.models['mail.partner'].findFromIdentifyingData({ id: 9 }));
+ assert.notOk(this.env.models['mail.partner'].findFromIdentifyingData({ id: 10 }));
+ assert.notOk(this.env.models['mail.thread'].findFromIdentifyingData({
+ id: 100,
+ model: 'mail.channel',
+ }));
+
+ const thread = this.env.models['mail.thread'].create({
+ channel_type: 'channel',
+ id: 100,
+ members: [['insert', [{
+ email: "john@example.com",
+ id: 9,
+ name: "John",
+ }, {
+ email: "fred@example.com",
+ id: 10,
+ name: "Fred",
+ }]]],
+ message_needaction_counter: 6,
+ model: 'mail.channel',
+ name: "General",
+ public: 'public',
+ serverMessageUnreadCounter: 5,
+ });
+ assert.ok(thread);
+ assert.ok(this.env.models['mail.partner'].findFromIdentifyingData({ id: 9 }));
+ assert.ok(this.env.models['mail.partner'].findFromIdentifyingData({ id: 10 }));
+ assert.ok(this.env.models['mail.thread'].findFromIdentifyingData({
+ id: 100,
+ model: 'mail.channel',
+ }));
+ const partner9 = this.env.models['mail.partner'].findFromIdentifyingData({ id: 9 });
+ const partner10 = this.env.models['mail.partner'].findFromIdentifyingData({ id: 10 });
+ assert.strictEqual(thread, this.env.models['mail.thread'].findFromIdentifyingData({
+ id: 100,
+ model: 'mail.channel',
+ }));
+ assert.strictEqual(thread.model, 'mail.channel');
+ assert.strictEqual(thread.channel_type, 'channel');
+ assert.strictEqual(thread.id, 100);
+ assert.ok(thread.members.includes(partner9));
+ assert.ok(thread.members.includes(partner10));
+ assert.strictEqual(thread.message_needaction_counter, 6);
+ assert.strictEqual(thread.name, "General");
+ assert.strictEqual(thread.public, 'public');
+ assert.strictEqual(thread.serverMessageUnreadCounter, 5);
+ assert.strictEqual(partner9.email, "john@example.com");
+ assert.strictEqual(partner9.id, 9);
+ assert.strictEqual(partner9.name, "John");
+ assert.strictEqual(partner10.email, "fred@example.com");
+ assert.strictEqual(partner10.id, 10);
+ assert.strictEqual(partner10.name, "Fred");
+});
+
+QUnit.test('create (chat)', async function (assert) {
+ assert.expect(15);
+
+ await this.start();
+ assert.notOk(this.env.models['mail.partner'].findFromIdentifyingData({ id: 5 }));
+ assert.notOk(this.env.models['mail.thread'].findFromIdentifyingData({
+ id: 200,
+ model: 'mail.channel',
+ }));
+
+ const channel = this.env.models['mail.thread'].create({
+ channel_type: 'chat',
+ id: 200,
+ members: [['insert', {
+ email: "demo@example.com",
+ id: 5,
+ im_status: 'online',
+ name: "Demo",
+ }]],
+ model: 'mail.channel',
+ });
+ assert.ok(channel);
+ assert.ok(this.env.models['mail.thread'].findFromIdentifyingData({
+ id: 200,
+ model: 'mail.channel',
+ }));
+ assert.ok(this.env.models['mail.partner'].findFromIdentifyingData({ id: 5 }));
+ const partner = this.env.models['mail.partner'].findFromIdentifyingData({ id: 5 });
+ assert.strictEqual(channel, this.env.models['mail.thread'].findFromIdentifyingData({
+ id: 200,
+ model: 'mail.channel',
+ }));
+ assert.strictEqual(channel.model, 'mail.channel');
+ assert.strictEqual(channel.channel_type, 'chat');
+ assert.strictEqual(channel.id, 200);
+ assert.ok(channel.correspondent);
+ assert.strictEqual(partner, channel.correspondent);
+ assert.strictEqual(partner.email, "demo@example.com");
+ assert.strictEqual(partner.id, 5);
+ assert.strictEqual(partner.im_status, 'online');
+ assert.strictEqual(partner.name, "Demo");
+});
+
+});
+});
+});
+
+});
diff --git a/addons/mail/static/src/models/thread_cache/thread_cache.js b/addons/mail/static/src/models/thread_cache/thread_cache.js
new file mode 100644
index 00000000..1760a509
--- /dev/null
+++ b/addons/mail/static/src/models/thread_cache/thread_cache.js
@@ -0,0 +1,617 @@
+odoo.define('mail/static/src/models/thread_cache/thread_cache.js', function (require) {
+'use strict';
+
+const { registerNewModel } = require('mail/static/src/model/model_core.js');
+const { attr, many2many, many2one, one2many } = require('mail/static/src/model/model_field.js');
+
+function factory(dependencies) {
+
+ class ThreadCache extends dependencies['mail.model'] {
+
+ //----------------------------------------------------------------------
+ // Public
+ //----------------------------------------------------------------------
+
+ /**
+ * @returns {mail.message[]|undefined}
+ */
+ async loadMoreMessages() {
+ if (this.isAllHistoryLoaded || this.isLoading) {
+ return;
+ }
+ if (!this.isLoaded) {
+ this.update({ isCacheRefreshRequested: true });
+ return;
+ }
+ this.update({ isLoadingMore: true });
+ const messageIds = this.fetchedMessages.map(message => message.id);
+ const limit = 30;
+ const fetchedMessages = await this.async(() => this._loadMessages({
+ extraDomain: [['id', '<', Math.min(...messageIds)]],
+ limit,
+ }));
+ this.update({ isLoadingMore: false });
+ if (fetchedMessages.length < limit) {
+ this.update({ isAllHistoryLoaded: true });
+ }
+ for (const threadView of this.threadViews) {
+ threadView.addComponentHint('more-messages-loaded', { fetchedMessages });
+ }
+ return fetchedMessages;
+ }
+
+ /**
+ * @returns {mail.message[]|undefined}
+ */
+ async loadNewMessages() {
+ if (this.isLoading) {
+ return;
+ }
+ if (!this.isLoaded) {
+ this.update({ isCacheRefreshRequested: true });
+ return;
+ }
+ const messageIds = this.fetchedMessages.map(message => message.id);
+ const fetchedMessages = this._loadMessages({
+ extraDomain: [['id', '>', Math.max(...messageIds)]],
+ limit: false,
+ });
+ for (const threadView of this.threadViews) {
+ threadView.addComponentHint('new-messages-loaded', { fetchedMessages });
+ }
+ return fetchedMessages;
+ }
+
+ //----------------------------------------------------------------------
+ // Private
+ //----------------------------------------------------------------------
+
+ /**
+ * @override
+ */
+ static _createRecordLocalId(data) {
+ const {
+ stringifiedDomain = '[]',
+ thread: [[commandInsert, thread]],
+ } = data;
+ return `${this.modelName}_[${thread.localId}]_<${stringifiedDomain}>`;
+ }
+
+ /**
+ * @private
+ */
+ _computeCheckedMessages() {
+ const messagesWithoutCheckbox = this.checkedMessages.filter(
+ message => !message.hasCheckbox
+ );
+ return [['unlink', messagesWithoutCheckbox]];
+ }
+
+ /**
+ * @private
+ * @returns {mail.message[]}
+ */
+ _computeFetchedMessages() {
+ if (!this.thread) {
+ return [['unlink-all']];
+ }
+ const toUnlinkMessages = [];
+ for (const message of this.fetchedMessages) {
+ if (!this.thread.messages.includes(message)) {
+ toUnlinkMessages.push(message);
+ }
+ }
+ return [['unlink', toUnlinkMessages]];
+ }
+
+ /**
+ * @private
+ * @returns {mail.message|undefined}
+ */
+ _computeLastFetchedMessage() {
+ const {
+ length: l,
+ [l - 1]: lastFetchedMessage,
+ } = this.orderedFetchedMessages;
+ if (!lastFetchedMessage) {
+ return [['unlink']];
+ }
+ return [['link', lastFetchedMessage]];
+ }
+
+ /**
+ * @private
+ * @returns {mail.message|undefined}
+ */
+ _computeLastMessage() {
+ const {
+ length: l,
+ [l - 1]: lastMessage,
+ } = this.orderedMessages;
+ if (!lastMessage) {
+ return [['unlink']];
+ }
+ return [['link', lastMessage]];
+ }
+
+ /**
+ * @private
+ * @returns {mail.message[]}
+ */
+ _computeMessages() {
+ if (!this.thread) {
+ return [['unlink-all']];
+ }
+ let messages = this.fetchedMessages;
+ if (this.stringifiedDomain !== '[]') {
+ return [['replace', messages]];
+ }
+ // main cache: adjust with newer messages
+ let newerMessages;
+ if (!this.lastFetchedMessage) {
+ newerMessages = this.thread.messages;
+ } else {
+ newerMessages = this.thread.messages.filter(message =>
+ message.id > this.lastFetchedMessage.id
+ );
+ }
+ messages = messages.concat(newerMessages);
+ return [['replace', messages]];
+ }
+
+ /**
+ *
+ * @private
+ * @returns {mail.message[]}
+ */
+ _computeNonEmptyMessages() {
+ return [['replace', this.messages.filter(message => !message.isEmpty)]];
+ }
+
+ /**
+ * @private
+ * @returns {mail.message[]}
+ */
+ _computeOrderedFetchedMessages() {
+ return [['replace', this.fetchedMessages.sort((m1, m2) => m1.id < m2.id ? -1 : 1)]];
+ }
+
+ /**
+ * @private
+ * @returns {mail.message[]}
+ */
+ _computeOrderedMessages() {
+ return [['replace', this.messages.sort((m1, m2) => m1.id < m2.id ? -1 : 1)]];
+ }
+
+ /**
+ * @private
+ * @returns {boolean}
+ */
+ _computeHasToLoadMessages() {
+ if (!this.thread) {
+ // happens during destroy or compute executed in wrong order
+ return false;
+ }
+ const wasCacheRefreshRequested = this.isCacheRefreshRequested;
+ // mark hint as processed
+ if (this.isCacheRefreshRequested) {
+ this.update({ isCacheRefreshRequested: false });
+ }
+ if (this.thread.isTemporary) {
+ // temporary threads don't exist on the server
+ return false;
+ }
+ if (!wasCacheRefreshRequested && this.threadViews.length === 0) {
+ // don't load message that won't be used
+ return false;
+ }
+ if (this.isLoading) {
+ // avoid duplicate RPC
+ return false;
+ }
+ if (!wasCacheRefreshRequested && this.isLoaded) {
+ // avoid duplicate RPC
+ return false;
+ }
+ const isMainCache = this.thread.mainCache === this;
+ if (isMainCache && this.isLoaded) {
+ // Ignore request on the main cache if it is already loaded or
+ // loading. Indeed the main cache is automatically sync with
+ // server updates already, so there is never a need to refresh
+ // it past the first time.
+ return false;
+ }
+ return true;
+ }
+
+ /**
+ * @private
+ * @returns {mail.message[]}
+ */
+ _computeUncheckedMessages() {
+ return [['replace', this.messages.filter(
+ message => message.hasCheckbox && !this.checkedMessages.includes(message)
+ )]];
+ }
+
+ /**
+ * @private
+ * @param {Array} domain
+ * @returns {Array}
+ */
+ _extendMessageDomain(domain) {
+ const thread = this.thread;
+ if (thread.model === 'mail.channel') {
+ return domain.concat([['channel_ids', 'in', [thread.id]]]);
+ } else if (thread === this.env.messaging.inbox) {
+ return domain.concat([['needaction', '=', true]]);
+ } else if (thread === this.env.messaging.starred) {
+ return domain.concat([
+ ['starred_partner_ids', 'in', [this.env.messaging.currentPartner.id]],
+ ]);
+ } else if (thread === this.env.messaging.history) {
+ return domain.concat([['needaction', '=', false]]);
+ } else if (thread === this.env.messaging.moderation) {
+ return domain.concat([['moderation_status', '=', 'pending_moderation']]);
+ } else {
+ // Avoid to load user_notification as these messages are not
+ // meant to be shown on chatters.
+ return domain.concat([
+ ['message_type', '!=', 'user_notification'],
+ ['model', '=', thread.model],
+ ['res_id', '=', thread.id],
+ ]);
+ }
+ }
+
+ /**
+ * @private
+ * @param {Object} [param0={}]
+ * @param {Array[]} [param0.extraDomain]
+ * @param {integer} [param0.limit=30]
+ * @returns {mail.message[]}
+ */
+ async _loadMessages({ extraDomain, limit = 30 } = {}) {
+ this.update({ isLoading: true });
+ const searchDomain = JSON.parse(this.stringifiedDomain);
+ let domain = searchDomain.length ? searchDomain : [];
+ domain = this._extendMessageDomain(domain);
+ if (extraDomain) {
+ domain = extraDomain.concat(domain);
+ }
+ const context = this.env.session.user_context;
+ const moderated_channel_ids = this.thread.moderation
+ ? [this.thread.id]
+ : undefined;
+ const messages = await this.async(() =>
+ this.env.models['mail.message'].performRpcMessageFetch(
+ domain,
+ limit,
+ moderated_channel_ids,
+ context,
+ )
+ );
+ this.update({
+ fetchedMessages: [['link', messages]],
+ isLoaded: true,
+ isLoading: false,
+ });
+ if (!extraDomain && messages.length < limit) {
+ this.update({ isAllHistoryLoaded: true });
+ }
+ this.env.messagingBus.trigger('o-thread-cache-loaded-messages', {
+ fetchedMessages: messages,
+ threadCache: this,
+ });
+ return messages;
+ }
+
+ /**
+ * Calls "mark all as read" when this thread becomes displayed in a
+ * view (which is notified by `isMarkAllAsReadRequested` being `true`),
+ * but delays the call until some other conditions are met, such as the
+ * messages being loaded.
+ * The reason to wait until messages are loaded is to avoid a race
+ * condition because "mark all as read" will change the state of the
+ * messages in parallel to fetch reading them.
+ *
+ * @private
+ */
+ _onChangeMarkAllAsRead() {
+ if (
+ !this.isMarkAllAsReadRequested ||
+ !this.thread ||
+ !this.thread.mainCache ||
+ !this.isLoaded ||
+ this.isLoading
+ ) {
+ // wait for change of state before deciding what to do
+ return;
+ }
+ this.update({ isMarkAllAsReadRequested: false });
+ if (
+ this.thread.isTemporary ||
+ this.thread.model === 'mail.box' ||
+ this.thread.mainCache !== this ||
+ this.threadViews.length === 0
+ ) {
+ // ignore the request
+ return;
+ }
+ this.env.models['mail.message'].markAllAsRead([
+ ['model', '=', this.thread.model],
+ ['res_id', '=', this.thread.id],
+ ]);
+ }
+
+ /**
+ * Loads this thread cache, by fetching the most recent messages in this
+ * conversation.
+ *
+ * @private
+ */
+ _onHasToLoadMessagesChanged() {
+ if (!this.hasToLoadMessages) {
+ return;
+ }
+ this._loadMessages().then(fetchedMessages => {
+ for (const threadView of this.threadViews) {
+ threadView.addComponentHint('messages-loaded', { fetchedMessages });
+ }
+ });
+ }
+
+ /**
+ * Handles change of messages on this thread cache. This is useful to
+ * refresh non-main caches that are currently displayed when the main
+ * cache receives updates. This is necessary because only the main cache
+ * is aware of changes in real time.
+ */
+ _onMessagesChanged() {
+ if (!this.thread) {
+ return;
+ }
+ if (this.thread.mainCache !== this) {
+ return;
+ }
+ for (const threadView of this.thread.threadViews) {
+ if (threadView.threadCache) {
+ threadView.threadCache.update({ isCacheRefreshRequested: true });
+ }
+ }
+ }
+
+ }
+
+ ThreadCache.fields = {
+ checkedMessages: many2many('mail.message', {
+ compute: '_computeCheckedMessages',
+ dependencies: [
+ 'checkedMessages',
+ 'messagesCheckboxes',
+ ],
+ inverse: 'checkedThreadCaches',
+ }),
+ /**
+ * List of messages that have been fetched by this cache.
+ *
+ * This DOES NOT necessarily includes all messages linked to this thread
+ * cache (@see messages field for that): it just contains list
+ * of successive messages that have been explicitly fetched by this
+ * cache. For all non-main caches, this corresponds to all messages.
+ * For the main cache, however, messages received from longpolling
+ * should be displayed on main cache but they have not been explicitly
+ * fetched by cache, so they ARE NOT in this list (at least, not until a
+ * fetch on this thread cache contains this message).
+ *
+ * The distinction between messages and fetched messages is important
+ * to manage "holes" in message list, while still allowing to display
+ * new messages on main cache of thread in real-time.
+ */
+ fetchedMessages: many2many('mail.message', {
+ // adjust with messages unlinked from thread
+ compute: '_computeFetchedMessages',
+ dependencies: ['threadMessages'],
+ }),
+ /**
+ * Determines whether `this` should load initial messages. This field is
+ * computed and should be considered read-only.
+ * @see `isCacheRefreshRequested` to request manual refresh of messages.
+ */
+ hasToLoadMessages: attr({
+ compute: '_computeHasToLoadMessages',
+ dependencies: [
+ 'isCacheRefreshRequested',
+ 'isLoaded',
+ 'isLoading',
+ 'thread',
+ 'threadIsTemporary',
+ 'threadMainCache',
+ 'threadViews',
+ ],
+ }),
+ isAllHistoryLoaded: attr({
+ default: false,
+ }),
+ isLoaded: attr({
+ default: false,
+ }),
+ isLoading: attr({
+ default: false,
+ }),
+ isLoadingMore: attr({
+ default: false,
+ }),
+ /**
+ * Determines whether `this` should consider refreshing its messages.
+ * This field is a hint that may or may not lead to an actual refresh.
+ * @see `hasToLoadMessages`
+ */
+ isCacheRefreshRequested: attr({
+ default: false,
+ }),
+ /**
+ * Determines whether this cache should consider calling "mark all as
+ * read" on this thread.
+ *
+ * This field is a hint that may or may not lead to an actual call.
+ * @see `_onChangeMarkAllAsRead`
+ */
+ isMarkAllAsReadRequested: attr({
+ default: false,
+ }),
+ /**
+ * Last message that has been fetched by this thread cache.
+ *
+ * This DOES NOT necessarily mean the last message linked to this thread
+ * cache (@see lastMessage field for that). @see fetchedMessages field
+ * for a deeper explanation about "fetched" messages.
+ */
+ lastFetchedMessage: many2one('mail.message', {
+ compute: '_computeLastFetchedMessage',
+ dependencies: ['orderedFetchedMessages'],
+ }),
+ lastMessage: many2one('mail.message', {
+ compute: '_computeLastMessage',
+ dependencies: ['orderedMessages'],
+ }),
+ messagesCheckboxes: attr({
+ related: 'messages.hasCheckbox',
+ }),
+ /**
+ * List of messages linked to this cache.
+ */
+ messages: many2many('mail.message', {
+ compute: '_computeMessages',
+ dependencies: [
+ 'fetchedMessages',
+ 'threadMessages',
+ ],
+ }),
+ /**
+ * IsEmpty trait of all messages.
+ * Serves as compute dependency.
+ */
+ messagesAreEmpty: attr({
+ related: 'messages.isEmpty'
+ }),
+ /**
+ * List of non empty messages linked to this cache.
+ */
+ nonEmptyMessages: many2many('mail.message', {
+ compute: '_computeNonEmptyMessages',
+ dependencies: [
+ 'messages',
+ 'messagesAreEmpty',
+ ],
+ }),
+ /**
+ * Not a real field, used to trigger its compute method when one of the
+ * dependencies changes.
+ */
+ onChangeMarkAllAsRead: attr({
+ compute: '_onChangeMarkAllAsRead',
+ dependencies: [
+ 'isLoaded',
+ 'isLoading',
+ 'isMarkAllAsReadRequested',
+ 'thread',
+ 'threadIsTemporary',
+ 'threadMainCache',
+ 'threadModel',
+ 'threadViews',
+ ],
+ }),
+ /**
+ * Loads initial messages from `this`.
+ * This is not a "real" field, its compute function is used to trigger
+ * the load of messages at the right time.
+ */
+ onHasToLoadMessagesChanged: attr({
+ compute: '_onHasToLoadMessagesChanged',
+ dependencies: [
+ 'hasToLoadMessages',
+ ],
+ }),
+ /**
+ * Not a real field, used to trigger `_onMessagesChanged` when one of
+ * the dependencies changes.
+ */
+ onMessagesChanged: attr({
+ compute: '_onMessagesChanged',
+ dependencies: [
+ 'messages',
+ 'thread',
+ 'threadMainCache',
+ ],
+ }),
+ /**
+ * Ordered list of messages that have been fetched by this cache.
+ *
+ * This DOES NOT necessarily includes all messages linked to this thread
+ * cache (@see orderedMessages field for that). @see fetchedMessages
+ * field for deeper explanation about "fetched" messages.
+ */
+ orderedFetchedMessages: many2many('mail.message', {
+ compute: '_computeOrderedFetchedMessages',
+ dependencies: ['fetchedMessages'],
+ }),
+ /**
+ * Ordered list of messages linked to this cache.
+ */
+ orderedMessages: many2many('mail.message', {
+ compute: '_computeOrderedMessages',
+ dependencies: ['messages'],
+ }),
+ stringifiedDomain: attr({
+ default: '[]',
+ }),
+ thread: many2one('mail.thread', {
+ inverse: 'caches',
+ }),
+ /**
+ * Serves as compute dependency.
+ */
+ threadIsTemporary: attr({
+ related: 'thread.isTemporary',
+ }),
+ /**
+ * Serves as compute dependency.
+ */
+ threadMainCache: many2one('mail.thread_cache', {
+ related: 'thread.mainCache',
+ }),
+ threadMessages: many2many('mail.message', {
+ related: 'thread.messages',
+ }),
+ /**
+ * Serves as compute dependency.
+ */
+ threadModel: attr({
+ related: 'thread.model',
+ }),
+ /**
+ * States the 'mail.thread_view' that are currently displaying `this`.
+ */
+ threadViews: one2many('mail.thread_view', {
+ inverse: 'threadCache',
+ }),
+ uncheckedMessages: many2many('mail.message', {
+ compute: '_computeUncheckedMessages',
+ dependencies: [
+ 'checkedMessages',
+ 'messagesCheckboxes',
+ 'messages',
+ ],
+ }),
+ };
+
+ ThreadCache.modelName = 'mail.thread_cache';
+
+ return ThreadCache;
+}
+
+registerNewModel('mail.thread_cache', factory);
+
+});
diff --git a/addons/mail/static/src/models/thread_partner_seen_info/thread_partner_seen_info.js b/addons/mail/static/src/models/thread_partner_seen_info/thread_partner_seen_info.js
new file mode 100644
index 00000000..8fd3b95a
--- /dev/null
+++ b/addons/mail/static/src/models/thread_partner_seen_info/thread_partner_seen_info.js
@@ -0,0 +1,109 @@
+odoo.define('mail/static/src/models/thread_partner_seen_info/thread_partner_seen_info.js', function (require) {
+'use strict';
+
+const { registerNewModel } = require('mail/static/src/model/model_core.js');
+const { attr, many2one } = require('mail/static/src/model/model_field.js');
+
+function factory(dependencies) {
+
+ class ThreadPartnerSeenInfo extends dependencies['mail.model'] {
+
+ //----------------------------------------------------------------------
+ // Private
+ //----------------------------------------------------------------------
+
+ /**
+ * @override
+ */
+ static _createRecordLocalId(data) {
+ const { channelId, partnerId } = data;
+ return `${this.modelName}_${channelId}_${partnerId}`;
+ }
+
+ /**
+ * @private
+ * @returns {mail.partner|undefined}
+ */
+ _computePartner() {
+ return [['insert', { id: this.partnerId }]];
+ }
+
+ /**
+ * @private
+ * @returns {mail.thread|undefined}
+ */
+ _computeThread() {
+ return [['insert', {
+ id: this.channelId,
+ model: 'mail.channel',
+ }]];
+ }
+
+ }
+
+ ThreadPartnerSeenInfo.modelName = 'mail.thread_partner_seen_info';
+
+ ThreadPartnerSeenInfo.fields = {
+ /**
+ * The id of channel this seen info is related to.
+ *
+ * Should write on this field to set relation between the channel and
+ * this seen info, not on `thread`.
+ *
+ * Reason for not setting the relation directly is the necessity to
+ * uniquely identify a seen info based on channel and partner from data.
+ * Relational data are list of commands, which is problematic to deduce
+ * identifying records.
+ *
+ * TODO: task-2322536 (normalize relational data) & task-2323665
+ * (required fields) should improve and let us just use the relational
+ * fields.
+ */
+ channelId: attr(),
+ lastFetchedMessage: many2one('mail.message'),
+ lastSeenMessage: many2one('mail.message'),
+ /**
+ * Partner that this seen info is related to.
+ *
+ * Should not write on this field to update relation, and instead
+ * should write on @see partnerId field.
+ */
+ partner: many2one('mail.partner', {
+ compute: '_computePartner',
+ dependencies: ['partnerId'],
+ }),
+ /**
+ * The id of partner this seen info is related to.
+ *
+ * Should write on this field to set relation between the partner and
+ * this seen info, not on `partner`.
+ *
+ * Reason for not setting the relation directly is the necessity to
+ * uniquely identify a seen info based on channel and partner from data.
+ * Relational data are list of commands, which is problematic to deduce
+ * identifying records.
+ *
+ * TODO: task-2322536 (normalize relational data) & task-2323665
+ * (required fields) should improve and let us just use the relational
+ * fields.
+ */
+ partnerId: attr(),
+ /**
+ * Thread (channel) that this seen info is related to.
+ *
+ * Should not write on this field to update relation, and instead
+ * should write on @see channelId field.
+ */
+ thread: many2one('mail.thread', {
+ compute: '_computeThread',
+ dependencies: ['channelId'],
+ inverse: 'partnerSeenInfos',
+ }),
+ };
+
+ return ThreadPartnerSeenInfo;
+}
+
+registerNewModel('mail.thread_partner_seen_info', factory);
+
+});
diff --git a/addons/mail/static/src/models/thread_view/thread_view.js b/addons/mail/static/src/models/thread_view/thread_view.js
new file mode 100644
index 00000000..a7ccf0c7
--- /dev/null
+++ b/addons/mail/static/src/models/thread_view/thread_view.js
@@ -0,0 +1,441 @@
+odoo.define('mail/static/src/models/thread_view/thread_view.js', function (require) {
+'use strict';
+
+const { registerNewModel } = require('mail/static/src/model/model_core.js');
+const { RecordDeletedError } = require('mail/static/src/model/model_errors.js');
+const { attr, many2many, many2one, one2one } = require('mail/static/src/model/model_field.js');
+const { clear } = require('mail/static/src/model/model_field_command.js');
+
+function factory(dependencies) {
+
+ class ThreadView extends dependencies['mail.model'] {
+
+ /**
+ * @override
+ */
+ _willDelete() {
+ this.env.browser.clearTimeout(this._loaderTimeout);
+ return super._willDelete(...arguments);
+ }
+
+ //----------------------------------------------------------------------
+ // Public
+ //----------------------------------------------------------------------
+
+ /**
+ * This function register a hint for the component related to this
+ * record. Hints are information on changes around this viewer that
+ * make require adjustment on the component. For instance, if this
+ * ThreadView initiated a thread cache load and it now has become
+ * loaded, then it may need to auto-scroll to last message.
+ *
+ * @param {string} hintType name of the hint. Used to determine what's
+ * the broad type of adjustement the component has to do.
+ * @param {any} [hintData] data of the hint. Used to fine-tune
+ * adjustments on the component.
+ */
+ addComponentHint(hintType, hintData) {
+ const hint = { data: hintData, type: hintType };
+ this.update({
+ componentHintList: this.componentHintList.concat([hint]),
+ });
+ }
+
+ /**
+ * @param {Object} hint
+ */
+ markComponentHintProcessed(hint) {
+ this.update({
+ componentHintList: this.componentHintList.filter(h => h !== hint),
+ });
+ this.env.messagingBus.trigger('o-thread-view-hint-processed', {
+ hint,
+ threadViewer: this.threadViewer,
+ });
+ }
+
+ /**
+ * @param {mail.message} message
+ */
+ handleVisibleMessage(message) {
+ if (!this.lastVisibleMessage || this.lastVisibleMessage.id < message.id) {
+ this.update({ lastVisibleMessage: [['link', message]] });
+ }
+ }
+
+ //----------------------------------------------------------------------
+ // Private
+ //----------------------------------------------------------------------
+
+ /**
+ * @private
+ * @returns {mail.messaging}
+ */
+ _computeMessaging() {
+ return [['link', this.env.messaging]];
+ }
+
+ /**
+ * @private
+ * @returns {string[]}
+ */
+ _computeTextInputSendShortcuts() {
+ if (!this.thread) {
+ return;
+ }
+ const isMailingList = this.thread.model === 'mail.channel' && this.thread.mass_mailing;
+ // Actually in mobile there is a send button, so we need there 'enter' to allow new line.
+ // Hence, we want to use a different shortcut 'ctrl/meta enter' to send for small screen
+ // size with a non-mailing channel.
+ // here send will be done on clicking the button or using the 'ctrl/meta enter' shortcut.
+ if (this.env.messaging.device.isMobile || isMailingList) {
+ return ['ctrl-enter', 'meta-enter'];
+ }
+ return ['enter'];
+ }
+
+ /**
+ * @private
+ * @returns {integer|undefined}
+ */
+ _computeThreadCacheInitialScrollHeight() {
+ if (!this.threadCache) {
+ return clear();
+ }
+ const threadCacheInitialScrollHeight = this.threadCacheInitialScrollHeights[this.threadCache.localId];
+ if (threadCacheInitialScrollHeight !== undefined) {
+ return threadCacheInitialScrollHeight;
+ }
+ return clear();
+ }
+
+ /**
+ * @private
+ * @returns {integer|undefined}
+ */
+ _computeThreadCacheInitialScrollPosition() {
+ if (!this.threadCache) {
+ return clear();
+ }
+ const threadCacheInitialScrollPosition = this.threadCacheInitialScrollPositions[this.threadCache.localId];
+ if (threadCacheInitialScrollPosition !== undefined) {
+ return threadCacheInitialScrollPosition;
+ }
+ return clear();
+ }
+
+ /**
+ * Not a real field, used to trigger `thread.markAsSeen` when one of
+ * the dependencies changes.
+ *
+ * @private
+ * @returns {boolean}
+ */
+ _computeThreadShouldBeSetAsSeen() {
+ if (!this.thread) {
+ return;
+ }
+ if (!this.thread.lastNonTransientMessage) {
+ return;
+ }
+ if (!this.lastVisibleMessage) {
+ return;
+ }
+ if (this.lastVisibleMessage !== this.lastMessage) {
+ return;
+ }
+ if (!this.hasComposerFocus) {
+ // FIXME condition should not be on "composer is focused" but "threadView is active"
+ // See task-2277543
+ return;
+ }
+ this.thread.markAsSeen(this.thread.lastNonTransientMessage).catch(e => {
+ // prevent crash when executing compute during destroy
+ if (!(e instanceof RecordDeletedError)) {
+ throw e;
+ }
+ });
+ }
+
+ /**
+ * @private
+ */
+ _onThreadCacheChanged() {
+ // clear obsolete hints
+ this.update({ componentHintList: clear() });
+ this.addComponentHint('change-of-thread-cache');
+ if (this.threadCache) {
+ this.threadCache.update({
+ isCacheRefreshRequested: true,
+ isMarkAllAsReadRequested: true,
+ });
+ }
+ this.update({ lastVisibleMessage: [['unlink']] });
+ }
+
+ /**
+ * @private
+ */
+ _onThreadCacheIsLoadingChanged() {
+ if (this.threadCache && this.threadCache.isLoading) {
+ if (!this.isLoading && !this.isPreparingLoading) {
+ this.update({ isPreparingLoading: true });
+ this.async(() =>
+ new Promise(resolve => {
+ this._loaderTimeout = this.env.browser.setTimeout(resolve, 400);
+ }
+ )).then(() => {
+ const isLoading = this.threadCache
+ ? this.threadCache.isLoading
+ : false;
+ this.update({ isLoading, isPreparingLoading: false });
+ });
+ }
+ return;
+ }
+ this.env.browser.clearTimeout(this._loaderTimeout);
+ this.update({ isLoading: false, isPreparingLoading: false });
+ }
+ }
+
+ ThreadView.fields = {
+ checkedMessages: many2many('mail.message', {
+ related: 'threadCache.checkedMessages',
+ }),
+ /**
+ * List of component hints. Hints contain information that help
+ * components make UI/UX decisions based on their UI state.
+ * For instance, on receiving new messages and the last message
+ * is visible, it should auto-scroll to this new last message.
+ *
+ * Format of a component hint:
+ *
+ * {
+ * type: {string} the name of the component hint. Useful
+ * for components to dispatch behaviour
+ * based on its type.
+ * data: {Object} data related to the component hint.
+ * For instance, if hint suggests to scroll
+ * to a certain message, data may contain
+ * message id.
+ * }
+ */
+ componentHintList: attr({
+ default: [],
+ }),
+ composer: many2one('mail.composer', {
+ related: 'thread.composer',
+ }),
+ /**
+ * Serves as compute dependency.
+ */
+ device: one2one('mail.device', {
+ related: 'messaging.device',
+ }),
+ /**
+ * Serves as compute dependency.
+ */
+ deviceIsMobile: attr({
+ related: 'device.isMobile',
+ }),
+ hasComposerFocus: attr({
+ related: 'composer.hasFocus',
+ }),
+ /**
+ * States whether `this.threadCache` is currently loading messages.
+ *
+ * This field is related to `this.threadCache.isLoading` but with a
+ * delay on its update to avoid flickering on the UI.
+ *
+ * It is computed through `_onThreadCacheIsLoadingChanged` and it should
+ * otherwise be considered read-only.
+ */
+ isLoading: attr({
+ default: false,
+ }),
+ /**
+ * States whether `this` is aware of `this.threadCache` currently
+ * loading messages, but `this` is not yet ready to display that loading
+ * on the UI.
+ *
+ * This field is computed through `_onThreadCacheIsLoadingChanged` and
+ * it should otherwise be considered read-only.
+ *
+ * @see `this.isLoading`
+ */
+ isPreparingLoading: attr({
+ default: false,
+ }),
+ /**
+ * Determines whether `this` should automatically scroll on receiving
+ * a new message. Detection of new message is done through the component
+ * hint `message-received`.
+ */
+ hasAutoScrollOnMessageReceived: attr({
+ default: true,
+ }),
+ /**
+ * Last message in the context of the currently displayed thread cache.
+ */
+ lastMessage: many2one('mail.message', {
+ related: 'thread.lastMessage',
+ }),
+ /**
+ * Serves as compute dependency.
+ */
+ lastNonTransientMessage: many2one('mail.message', {
+ related: 'thread.lastNonTransientMessage',
+ }),
+ /**
+ * Most recent message in this ThreadView that has been shown to the
+ * current partner in the currently displayed thread cache.
+ */
+ lastVisibleMessage: many2one('mail.message'),
+ messages: many2many('mail.message', {
+ related: 'threadCache.messages',
+ }),
+ /**
+ * Serves as compute dependency.
+ */
+ messaging: many2one('mail.messaging', {
+ compute: '_computeMessaging',
+ }),
+ nonEmptyMessages: many2many('mail.message', {
+ related: 'threadCache.nonEmptyMessages',
+ }),
+ /**
+ * Not a real field, used to trigger `_onThreadCacheChanged` when one of
+ * the dependencies changes.
+ */
+ onThreadCacheChanged: attr({
+ compute: '_onThreadCacheChanged',
+ dependencies: [
+ 'threadCache'
+ ],
+ }),
+ /**
+ * Not a real field, used to trigger `_onThreadCacheIsLoadingChanged`
+ * when one of the dependencies changes.
+ *
+ * @see `this.isLoading`
+ */
+ onThreadCacheIsLoadingChanged: attr({
+ compute: '_onThreadCacheIsLoadingChanged',
+ dependencies: [
+ 'threadCache',
+ 'threadCacheIsLoading',
+ ],
+ }),
+ /**
+ * Determines the domain to apply when fetching messages for `this.thread`.
+ */
+ stringifiedDomain: attr({
+ related: 'threadViewer.stringifiedDomain',
+ }),
+ /**
+ * Determines the keyboard shortcuts that are available to send a message
+ * from the composer of this thread viewer.
+ */
+ textInputSendShortcuts: attr({
+ compute: '_computeTextInputSendShortcuts',
+ dependencies: [
+ 'device',
+ 'deviceIsMobile',
+ 'thread',
+ 'threadMassMailing',
+ 'threadModel',
+ ],
+ }),
+ /**
+ * Determines the `mail.thread` currently displayed by `this`.
+ */
+ thread: many2one('mail.thread', {
+ inverse: 'threadViews',
+ related: 'threadViewer.thread',
+ }),
+ /**
+ * States the `mail.thread_cache` currently displayed by `this`.
+ */
+ threadCache: many2one('mail.thread_cache', {
+ inverse: 'threadViews',
+ related: 'threadViewer.threadCache',
+ }),
+ threadCacheInitialScrollHeight: attr({
+ compute: '_computeThreadCacheInitialScrollHeight',
+ dependencies: [
+ 'threadCache',
+ 'threadCacheInitialScrollHeights',
+ ],
+ }),
+ threadCacheInitialScrollPosition: attr({
+ compute: '_computeThreadCacheInitialScrollPosition',
+ dependencies: [
+ 'threadCache',
+ 'threadCacheInitialScrollPositions',
+ ],
+ }),
+ /**
+ * Serves as compute dependency.
+ */
+ threadCacheIsLoading: attr({
+ related: 'threadCache.isLoading',
+ }),
+ /**
+ * List of saved initial scroll heights of thread caches.
+ */
+ threadCacheInitialScrollHeights: attr({
+ default: {},
+ related: 'threadViewer.threadCacheInitialScrollHeights',
+ }),
+ /**
+ * List of saved initial scroll positions of thread caches.
+ */
+ threadCacheInitialScrollPositions: attr({
+ default: {},
+ related: 'threadViewer.threadCacheInitialScrollPositions',
+ }),
+ /**
+ * Serves as compute dependency.
+ */
+ threadMassMailing: attr({
+ related: 'thread.mass_mailing',
+ }),
+ /**
+ * Serves as compute dependency.
+ */
+ threadModel: attr({
+ related: 'thread.model',
+ }),
+ /**
+ * Not a real field, used to trigger `thread.markAsSeen` when one of
+ * the dependencies changes.
+ */
+ threadShouldBeSetAsSeen: attr({
+ compute: '_computeThreadShouldBeSetAsSeen',
+ dependencies: [
+ 'hasComposerFocus',
+ 'lastMessage',
+ 'lastNonTransientMessage',
+ 'lastVisibleMessage',
+ 'threadCache',
+ ],
+ }),
+ /**
+ * Determines the `mail.thread_viewer` currently managing `this`.
+ */
+ threadViewer: one2one('mail.thread_viewer', {
+ inverse: 'threadView',
+ }),
+ uncheckedMessages: many2many('mail.message', {
+ related: 'threadCache.uncheckedMessages',
+ }),
+ };
+
+ ThreadView.modelName = 'mail.thread_view';
+
+ return ThreadView;
+}
+
+registerNewModel('mail.thread_view', factory);
+
+});
diff --git a/addons/mail/static/src/models/thread_view/thread_viewer.js b/addons/mail/static/src/models/thread_view/thread_viewer.js
new file mode 100644
index 00000000..c78022d4
--- /dev/null
+++ b/addons/mail/static/src/models/thread_view/thread_viewer.js
@@ -0,0 +1,296 @@
+odoo.define('mail/static/src/models/thread_viewer/thread_viewer.js', function (require) {
+'use strict';
+
+const { registerNewModel } = require('mail/static/src/model/model_core.js');
+const { attr, many2one, one2one } = require('mail/static/src/model/model_field.js');
+
+function factory(dependencies) {
+
+ class ThreadViewer extends dependencies['mail.model'] {
+
+ //----------------------------------------------------------------------
+ // Public
+ //----------------------------------------------------------------------
+
+ /**
+ * @param {integer} scrollHeight
+ * @param {mail.thread_cache} threadCache
+ */
+ saveThreadCacheScrollHeightAsInitial(scrollHeight, threadCache) {
+ threadCache = threadCache || this.threadCache;
+ if (!threadCache) {
+ return;
+ }
+ if (this.chatter) {
+ // Initial scroll height is disabled for chatter because it is
+ // too complex to handle correctly and less important
+ // functionally.
+ return;
+ }
+ this.update({
+ threadCacheInitialScrollHeights: Object.assign({}, this.threadCacheInitialScrollHeights, {
+ [threadCache.localId]: scrollHeight,
+ }),
+ });
+ }
+
+ /**
+ * @param {integer} scrollTop
+ * @param {mail.thread_cache} threadCache
+ */
+ saveThreadCacheScrollPositionsAsInitial(scrollTop, threadCache) {
+ threadCache = threadCache || this.threadCache;
+ if (!threadCache) {
+ return;
+ }
+ if (this.chatter) {
+ // Initial scroll position is disabled for chatter because it is
+ // too complex to handle correctly and less important
+ // functionally.
+ return;
+ }
+ this.update({
+ threadCacheInitialScrollPositions: Object.assign({}, this.threadCacheInitialScrollPositions, {
+ [threadCache.localId]: scrollTop,
+ }),
+ });
+ }
+
+ //----------------------------------------------------------------------
+ // Private
+ //----------------------------------------------------------------------
+
+ /**
+ * @private
+ * @returns {boolean}
+ */
+ _computeHasThreadView() {
+ if (this.chatter) {
+ return this.chatter.hasThreadView;
+ }
+ if (this.chatWindow) {
+ return this.chatWindow.hasThreadView;
+ }
+ if (this.discuss) {
+ return this.discuss.hasThreadView;
+ }
+ return this.hasThreadView;
+ }
+
+ /**
+ * @private
+ * @returns {string}
+ */
+ _computeStringifiedDomain() {
+ if (this.chatter) {
+ return '[]';
+ }
+ if (this.chatWindow) {
+ return '[]';
+ }
+ if (this.discuss) {
+ return this.discuss.stringifiedDomain;
+ }
+ return this.stringifiedDomain;
+ }
+
+ /**
+ * @private
+ * @returns {mail.thread|undefined}
+ */
+ _computeThread() {
+ if (this.chatter) {
+ if (!this.chatter.thread) {
+ return [['unlink']];
+ }
+ return [['link', this.chatter.thread]];
+ }
+ if (this.chatWindow) {
+ if (!this.chatWindow.thread) {
+ return [['unlink']];
+ }
+ return [['link', this.chatWindow.thread]];
+ }
+ if (this.discuss) {
+ if (!this.discuss.thread) {
+ return [['unlink']];
+ }
+ return [['link', this.discuss.thread]];
+ }
+ return [];
+ }
+
+ /**
+ * @private
+ * @returns {mail.thread_cache|undefined}
+ */
+ _computeThreadCache() {
+ if (!this.thread) {
+ return [['unlink']];
+ }
+ return [['link', this.thread.cache(this.stringifiedDomain)]];
+ }
+
+ /**
+ * @private
+ * @returns {mail.thread_viewer|undefined}
+ */
+ _computeThreadView() {
+ if (!this.hasThreadView) {
+ return [['unlink']];
+ }
+ if (this.threadView) {
+ return [];
+ }
+ return [['create']];
+ }
+
+ }
+
+ ThreadViewer.fields = {
+ /**
+ * States the `mail.chatter` managing `this`. This field is computed
+ * through the inverse relation and should be considered read-only.
+ */
+ chatter: one2one('mail.chatter', {
+ inverse: 'threadViewer',
+ }),
+ /**
+ * Serves as compute dependency.
+ */
+ chatterHasThreadView: attr({
+ related: 'chatter.hasThreadView',
+ }),
+ /**
+ * Serves as compute dependency.
+ */
+ chatterThread: many2one('mail.thread', {
+ related: 'chatter.thread',
+ }),
+ /**
+ * States the `mail.chat_window` managing `this`. This field is computed
+ * through the inverse relation and should be considered read-only.
+ */
+ chatWindow: one2one('mail.chat_window', {
+ inverse: 'threadViewer',
+ }),
+ /**
+ * Serves as compute dependency.
+ */
+ chatWindowHasThreadView: attr({
+ related: 'chatWindow.hasThreadView',
+ }),
+ /**
+ * Serves as compute dependency.
+ */
+ chatWindowThread: many2one('mail.thread', {
+ related: 'chatWindow.thread',
+ }),
+ /**
+ * States the `mail.discuss` managing `this`. This field is computed
+ * through the inverse relation and should be considered read-only.
+ */
+ discuss: one2one('mail.discuss', {
+ inverse: 'threadViewer',
+ }),
+ /**
+ * Serves as compute dependency.
+ */
+ discussHasThreadView: attr({
+ related: 'discuss.hasThreadView',
+ }),
+ /**
+ * Serves as compute dependency.
+ */
+ discussStringifiedDomain: attr({
+ related: 'discuss.stringifiedDomain',
+ }),
+ /**
+ * Serves as compute dependency.
+ */
+ discussThread: many2one('mail.thread', {
+ related: 'discuss.thread',
+ }),
+ /**
+ * Determines whether `this.thread` should be displayed.
+ */
+ hasThreadView: attr({
+ compute: '_computeHasThreadView',
+ default: false,
+ dependencies: [
+ 'chatterHasThreadView',
+ 'chatWindowHasThreadView',
+ 'discussHasThreadView',
+ ],
+ }),
+ /**
+ * Determines the domain to apply when fetching messages for `this.thread`.
+ */
+ stringifiedDomain: attr({
+ compute: '_computeStringifiedDomain',
+ default: '[]',
+ dependencies: [
+ 'discussStringifiedDomain',
+ ],
+ }),
+ /**
+ * Determines the `mail.thread` that should be displayed by `this`.
+ */
+ thread: many2one('mail.thread', {
+ compute: '_computeThread',
+ dependencies: [
+ 'chatterThread',
+ 'chatWindowThread',
+ 'discussThread',
+ ],
+ }),
+ /**
+ * States the `mail.thread_cache` that should be displayed by `this`.
+ */
+ threadCache: many2one('mail.thread_cache', {
+ compute: '_computeThreadCache',
+ dependencies: [
+ 'stringifiedDomain',
+ 'thread',
+ ],
+ }),
+ /**
+ * Determines the initial scroll height of thread caches, which is the
+ * scroll height at the time the last scroll position was saved.
+ * Useful to only restore scroll position when the corresponding height
+ * is available, otherwise the restore makes no sense.
+ */
+ threadCacheInitialScrollHeights: attr({
+ default: {},
+ }),
+ /**
+ * Determines the initial scroll positions of thread caches.
+ * Useful to restore scroll position on changing back to this
+ * thread cache. Note that this is only applied when opening
+ * the thread cache, because scroll position may change fast so
+ * save is already throttled.
+ */
+ threadCacheInitialScrollPositions: attr({
+ default: {},
+ }),
+ /**
+ * States the `mail.thread_view` currently displayed and managed by `this`.
+ */
+ threadView: one2one('mail.thread_view', {
+ compute: '_computeThreadView',
+ dependencies: [
+ 'hasThreadView',
+ ],
+ inverse: 'threadViewer',
+ isCausal: true,
+ }),
+ };
+
+ ThreadViewer.modelName = 'mail.thread_viewer';
+
+ return ThreadViewer;
+}
+
+registerNewModel('mail.thread_viewer', factory);
+
+});
diff --git a/addons/mail/static/src/models/user/user.js b/addons/mail/static/src/models/user/user.js
new file mode 100644
index 00000000..721b586f
--- /dev/null
+++ b/addons/mail/static/src/models/user/user.js
@@ -0,0 +1,254 @@
+odoo.define('mail/static/src/models/user/user.js', function (require) {
+'use strict';
+
+const { registerNewModel } = require('mail/static/src/model/model_core.js');
+const { attr, one2one } = require('mail/static/src/model/model_field.js');
+
+function factory(dependencies) {
+
+ class User extends dependencies['mail.model'] {
+
+ /**
+ * @override
+ */
+ _willDelete() {
+ if (this.env.messaging) {
+ if (this === this.env.messaging.currentUser) {
+ this.env.messaging.update({ currentUser: [['unlink']] });
+ }
+ }
+ return super._willDelete(...arguments);
+ }
+
+ //----------------------------------------------------------------------
+ // Public
+ //----------------------------------------------------------------------
+
+ /**
+ * @static
+ * @param {Object} data
+ * @returns {Object}
+ */
+ static convertData(data) {
+ const data2 = {};
+ if ('id' in data) {
+ data2.id = data.id;
+ }
+ if ('partner_id' in data) {
+ if (!data.partner_id) {
+ data2.partner = [['unlink']];
+ } else {
+ const partnerNameGet = data['partner_id'];
+ const partnerData = {
+ display_name: partnerNameGet[1],
+ id: partnerNameGet[0],
+ };
+ data2.partner = [['insert', partnerData]];
+ }
+ }
+ return data2;
+ }
+
+ /**
+ * Performs the `read` RPC on `res.users`.
+ *
+ * @static
+ * @param {Object} param0
+ * @param {Object} param0.context
+ * @param {string[]} param0.fields
+ * @param {integer[]} param0.ids
+ */
+ static async performRpcRead({ context, fields, ids }) {
+ const usersData = await this.env.services.rpc({
+ model: 'res.users',
+ method: 'read',
+ args: [ids],
+ kwargs: {
+ context,
+ fields,
+ },
+ }, { shadow: true });
+ return this.env.models['mail.user'].insert(usersData.map(userData =>
+ this.env.models['mail.user'].convertData(userData)
+ ));
+ }
+
+ /**
+ * Fetches the partner of this user.
+ */
+ async fetchPartner() {
+ return this.env.models['mail.user'].performRpcRead({
+ ids: [this.id],
+ fields: ['partner_id'],
+ context: { active_test: false },
+ });
+ }
+
+ /**
+ * Gets the chat between this user and the current user.
+ *
+ * If a chat is not appropriate, a notification is displayed instead.
+ *
+ * @returns {mail.thread|undefined}
+ */
+ async getChat() {
+ if (!this.partner) {
+ await this.async(() => this.fetchPartner());
+ }
+ if (!this.partner) {
+ // This user has been deleted from the server or never existed:
+ // - Validity of id is not verified at insert.
+ // - There is no bus notification in case of user delete from
+ // another tab or by another user.
+ this.env.services['notification'].notify({
+ message: this.env._t("You can only chat with existing users."),
+ type: 'warning',
+ });
+ return;
+ }
+ // in other cases a chat would be valid, find it or try to create it
+ let chat = this.env.models['mail.thread'].find(thread =>
+ thread.channel_type === 'chat' &&
+ thread.correspondent === this.partner &&
+ thread.model === 'mail.channel' &&
+ thread.public === 'private'
+ );
+ if (!chat ||!chat.isPinned) {
+ // if chat is not pinned then it has to be pinned client-side
+ // and server-side, which is a side effect of following rpc
+ chat = await this.async(() =>
+ this.env.models['mail.thread'].performRpcCreateChat({
+ partnerIds: [this.partner.id],
+ })
+ );
+ }
+ if (!chat) {
+ this.env.services['notification'].notify({
+ message: this.env._t("An unexpected error occurred during the creation of the chat."),
+ type: 'warning',
+ });
+ return;
+ }
+ return chat;
+ }
+
+ /**
+ * Opens a chat between this user and the current user and returns it.
+ *
+ * If a chat is not appropriate, a notification is displayed instead.
+ *
+ * @param {Object} [options] forwarded to @see `mail.thread:open()`
+ * @returns {mail.thread|undefined}
+ */
+ async openChat(options) {
+ const chat = await this.async(() => this.getChat());
+ if (!chat) {
+ return;
+ }
+ await this.async(() => chat.open(options));
+ return chat;
+ }
+
+ /**
+ * Opens the most appropriate view that is a profile for this user.
+ * Because user is a rather technical model to allow login, it's the
+ * partner profile that contains the most useful information.
+ *
+ * @override
+ */
+ async openProfile() {
+ if (!this.partner) {
+ await this.async(() => this.fetchPartner());
+ }
+ if (!this.partner) {
+ // This user has been deleted from the server or never existed:
+ // - Validity of id is not verified at insert.
+ // - There is no bus notification in case of user delete from
+ // another tab or by another user.
+ this.env.services['notification'].notify({
+ message: this.env._t("You can only open the profile of existing users."),
+ type: 'warning',
+ });
+ return;
+ }
+ return this.partner.openProfile();
+ }
+
+ //----------------------------------------------------------------------
+ // Private
+ //----------------------------------------------------------------------
+
+ /**
+ * @override
+ */
+ static _createRecordLocalId(data) {
+ return `${this.modelName}_${data.id}`;
+ }
+
+ /**
+ * @private
+ * @returns {string|undefined}
+ */
+ _computeDisplayName() {
+ return this.display_name || this.partner && this.partner.display_name;
+ }
+
+ /**
+ * @private
+ * @returns {string|undefined}
+ */
+ _computeNameOrDisplayName() {
+ return this.partner && this.partner.nameOrDisplayName || this.display_name;
+ }
+ }
+
+ User.fields = {
+ id: attr(),
+ /**
+ * Determines whether this user is an internal user. An internal user is
+ * a member of the group `base.group_user`. This is the inverse of the
+ * `share` field in python.
+ */
+ isInternalUser: attr(),
+ display_name: attr({
+ compute: '_computeDisplayName',
+ dependencies: [
+ 'display_name',
+ 'partnerDisplayName',
+ ],
+ }),
+ model: attr({
+ default: 'res.user',
+ }),
+ nameOrDisplayName: attr({
+ compute: '_computeNameOrDisplayName',
+ dependencies: [
+ 'display_name',
+ 'partnerNameOrDisplayName',
+ ]
+ }),
+ partner: one2one('mail.partner', {
+ inverse: 'user',
+ }),
+ /**
+ * Serves as compute dependency.
+ */
+ partnerDisplayName: attr({
+ related: 'partner.display_name',
+ }),
+ /**
+ * Serves as compute dependency.
+ */
+ partnerNameOrDisplayName: attr({
+ related: 'partner.nameOrDisplayName',
+ }),
+ };
+
+ User.modelName = 'mail.user';
+
+ return User;
+}
+
+registerNewModel('mail.user', factory);
+
+});
diff --git a/addons/mail/static/src/scss/activity_view.scss b/addons/mail/static/src/scss/activity_view.scss
new file mode 100644
index 00000000..bbb819eb
--- /dev/null
+++ b/addons/mail/static/src/scss/activity_view.scss
@@ -0,0 +1,132 @@
+.o_activity_view {
+ height: 100%;
+ > table {
+ background-color: white;
+ thead > tr > th:first-of-type {
+ min-width: 300px;
+ }
+ tbody > tr > td, tfoot > tr > td {
+ cursor: pointer;
+ }
+ }
+ .o_activity_summary_cell {
+ background-color: #FFF;
+ &.planned {
+ background-color: theme-color('success');
+ }
+ &.overdue {
+ background-color: theme-color('danger');
+ }
+ &.today {
+ background-color: theme-color('warning');
+ }
+ .o_kanban_inline_block {
+ min-height: 42px;
+ }
+ .dropdown-toggle {
+ cursor: pointer;
+ .o_closest_deadline {
+ height: 42px;
+ width: 100%;
+ color: #FFF;
+ text-align: center;
+ line-height: 42px;
+ }
+ }
+ &.o_activity_empty_cell {
+ > i {
+ display: none;
+ }
+ &:hover {
+ background-color: #eee;
+
+ > i {
+ color: gray;
+ display: block;
+ }
+ }
+ }
+ .o_activity_btn > .badge {
+ @include o-position-absolute($bottom: 0, $right: 0);
+
+ &.planned {
+ color: theme-color('success');
+ }
+ &.overdue {
+ color: theme-color('danger');
+ }
+ &.today {
+ color: theme-color('warning');
+ }
+ }
+ }
+
+ // it contains a kanban card representing the record
+ .o_activity_record {
+ display: flex;
+ flex: 1 1 auto;
+ align-items: center;
+ padding: 8px 8px;
+ cursor: pointer;
+
+ > img {
+ width: 32px;
+ max-height: 32px;
+ margin-right: 16px;
+ }
+
+ > div {
+ max-width: 200px;
+
+ .o_text_block {
+ @include o-text-overflow;
+ display: block;
+ }
+ }
+
+ .o_text_bold {
+ font-weight: bold;
+ }
+
+ .o_text_block {
+ display: block;
+ }
+ }
+ .o_activity_filter_planned {
+ background-color: mix(theme-color('success'), $o-webclient-background-color, 5%);
+ }
+ .o_activity_filter_today {
+ background-color: mix(theme-color('warning'), $o-webclient-background-color, 5%);
+ }
+ .o_activity_filter_overdue {
+ background-color: mix(theme-color('danger'), $o-webclient-background-color, 5%);
+ }
+ .o_record_selector {
+ color: $o-enterprise-primary-color;
+ }
+ .o_activity_type_cell {
+ padding:10px;
+ min-width:100px;
+ .fa-ellipsis-v {
+ cursor: pointer;
+ }
+
+ .o_template_element {
+ white-space: nowrap;
+ padding:5px;
+ cursor: pointer;
+ &:hover {
+ color: theme-color('success');
+ }
+ }
+ .o_kanban_counter {
+ margin: 5px 0 0 0;
+ > .o_kanban_counter_progress {
+ width: 100%;
+ > div.active {
+ border: 1px solid;
+ }
+ }
+ }
+ }
+}
diff --git a/addons/mail/static/src/scss/composer.scss b/addons/mail/static/src/scss/composer.scss
new file mode 100644
index 00000000..b478c0b0
--- /dev/null
+++ b/addons/mail/static/src/scss/composer.scss
@@ -0,0 +1,161 @@
+@font-face {
+ font-family: 'emojifont';
+ src: local('Segoe UI'),
+ local('Apple Color Emoji'),
+ local('Android Emoji'),
+ local('Noto Color Emoji'),
+ local('Twitter Color Emoji'),
+ local('Twitter Color'),
+ local('EmojiOne Color'),
+ local('EmojiOne'),
+ local(EmojiSymbols),
+ local(Symbola);
+}
+
+// Emoji
+.o_mail_emoji {
+ display: inline-block;
+ padding: 0;
+ font-size: 1.3rem;
+ font-family: emojifont;
+}
+.o_mail_preview .o_mail_emoji {
+ font-size: 100%;
+}
+
+@mixin o-viewer-black-btn {
+ background-color: rgba(black, 0.4);
+ color: rgba(theme-color('light'), 0.7);
+
+ &:hover {
+ background-color: rgba(black, 0.6);
+ color: white;
+ }
+
+ &.disabled {
+ color: gray('600');
+ background: none;
+ }
+}
+.o_modal_fullscreen {
+ z-index: $o-mail-thread-window-zindex + 1;
+
+ .o_viewer_content {
+ position: relative;
+ width: 100%;
+ height: 100%;
+
+ .o_viewer-header {
+ @include o-position-absolute(0, 0, $left: 0);
+ height: 45px;
+ padding: $grid-gutter-width*0.5;
+ background-color: rgba(black, 0.8);
+ z-index: 1;
+ color: #FFFFFF;
+
+ a {
+ @include o-hover-text-color(rgba(theme-color('light'), 0.6), white);
+ }
+
+ .o_close_btn {
+ @include o-position-absolute(-1px, $grid-gutter-width*0.5);
+ font-size: $h1-font-size;
+ font-weight: 300;
+ }
+
+ .o_image_caption {
+ bottom: 20%;
+ position: absolute;
+ }
+ }
+
+ .o_loading_img {
+ @include o-position-absolute($top: 45%, $right: 0, $left: 0);
+ }
+
+ .o_viewer_img_wrapper {
+ cursor: pointer;
+ position: fixed;
+ width: 100%;
+ height: 100%;
+ background-color: rgba(black, 0.7);
+
+ .o_viewer_zoomer {
+ width: 100%;
+ height: 100%;
+ display: flex;
+ justify-content: center;
+ align-items: center;
+ padding: 45px 0;
+
+ img {
+ image-orientation: from-image; // Only supported in Firefox
+ }
+
+ img, video {
+ cursor :auto;
+ max-width: 100%;
+ max-height: 100%;
+ transition: 0.2s cubic-bezier(0, 0, 0.49, 1.6) 0s, opacity 0.15s;
+ box-shadow: 0 0 40px black;
+ }
+
+ .o_viewer_pdf {
+ width: 80%;
+ height: 100%;
+ border: 0px;
+ box-shadow: 1px 1px 20px 1px #000;
+ }
+
+ @include media-breakpoint-down(sm) {
+ .o_viewer_pdf, .o_viewer_text {
+ width: 100%;
+ }
+ }
+
+ .o_viewer_text {
+ width: 80%;
+ height: 100%;
+ border: 0px;
+ box-shadow: 1px 1px 20px 1px #000;
+ background-color: white;
+ }
+
+ .o_viewer_video {
+ height: 80%;
+ }
+ }
+ }
+
+ .o_viewer_toolbar {
+ @include o-position-absolute($bottom: $grid-gutter-width*0.5);
+ width: 100%;
+ overflow: hidden;
+ justify-content: center;
+ border-radius: 4px;
+
+ > .btn-group {
+ background-color: rgba(black, 0.4);
+ }
+
+ .o_viewer_toolbar_btn {
+ @include o-viewer-black-btn;
+ padding-left: 8px;
+ padding-right: 8px;
+ }
+ }
+ }
+
+ .arrow {
+ @include o-position-absolute(50%, $grid-gutter-width*0.5);
+ border-radius: 100%;
+ padding: 12px 16px 11px 18px;
+ @include o-viewer-black-btn;
+ }
+
+ .arrow-left {
+ left: $grid-gutter-width*0.5;
+ right: auto;
+ padding: 12px 18px 11px 16px;
+ }
+}
diff --git a/addons/mail/static/src/scss/discuss.scss b/addons/mail/static/src/scss/discuss.scss
new file mode 100644
index 00000000..22e5e71c
--- /dev/null
+++ b/addons/mail/static/src/scss/discuss.scss
@@ -0,0 +1,191 @@
+// ------------------------------------------------------------------
+// Layout
+// ------------------------------------------------------------------
+
+// ------------------------------------------------------------------
+// Style
+// ------------------------------------------------------------------
+
+.o_mail_user_status {
+ font-size: 1em;
+ position: relative;
+ &.o_user_online {
+ color: $o-enterprise-primary-color;
+ }
+ &.o_user_idle {
+ color: theme-color('warning');
+ }
+ &.fa-stack {
+ width: 1em;
+ height: 1em;
+ line-height: 1em;
+ }
+}
+
+// ------------------------------------------------------------------
+// Thread preview: shared between discuss (mobile) and systray
+// ------------------------------------------------------------------
+
+.o_mail_preview {
+ display: flex;
+ background-color: theme-color('light');
+ color: $o-main-text-color;
+ cursor: pointer;
+ overflow: hidden;
+ position: relative;
+ &:hover {
+ background-color: gray('300');
+ .o_preview_name {
+ color: $headings-color;
+ }
+ .o_discuss_icon {
+ opacity: 1;
+ }
+ }
+ &:not(:last-child) {
+ border-bottom: 1px solid gray('400');
+ }
+ .o_mail_preview_image {
+ display: flex;
+ align-items: center;
+ flex: 0 0 auto;
+ position: relative;
+ width: 40px;
+ height: 40px;
+ object-fit: cover;
+ > img {
+ max-width: 100%;
+ max-height: 100%;
+ border-radius: 50%;
+ object-fit: cover;
+ }
+ &.o_mail_preview_app > img {
+ border-radius: 2px;
+ }
+ .o_mail_user_status {
+ @include o-position-absolute($bottom: 0px, $right: 0px);
+ }
+ }
+ .o_preview_info {
+ flex: 1 1 100%;
+ overflow: hidden;
+ .o_preview_title {
+ align-items: center;
+ display: flex;
+ .o_preview_name {
+ flex: 0 1 auto;
+ @include o-text-overflow;
+ }
+ .o_mail_activity_action_buttons {
+ display: flex;
+ flex: 1 1 auto;
+ flex-flow: row-reverse wrap;
+ }
+ .o_mail_activity_action {
+ padding-top: 0px;
+ padding-bottom: 0px;
+ padding-right: 0px;
+ }
+ .o_preview_counter {
+ flex: 0 1 auto;
+ }
+ .o_thread_window_expand {
+ margin: 0px 6px;
+ }
+ .o_last_message_date {
+ flex: 0 0 auto;
+ color: $o-main-color-muted;
+ font-weight: 500;
+ }
+ }
+ .o_last_message_preview {
+ width: 94%;
+ max-height: 20px;
+ color: $o-main-color-muted;
+ @include o-text-overflow;
+ }
+ }
+ .o_discuss_icon {
+ opacity: 0;
+ }
+ &.o_preview_unread {
+ background-color: transparent;
+ &:hover {
+ background-color: theme-color('light');
+ }
+ .o_preview_info {
+ .o_preview_title {
+ .o_preview_name, .o_preview_counter {
+ font-weight: 700;
+ }
+ .o_last_message_date {
+ color: $o-brand-primary;
+ }
+ }
+ }
+ }
+ &.o_systray_activity {
+ background-color: transparent;
+ &:hover {
+ background-color: theme-color('light');
+ }
+ }
+}
+
+@include media-breakpoint-down(sm) {
+
+ .o_main_navbar > ul.o_menu_systray > li .dropdown-menu.show {
+ border: none;
+ }
+
+ .o_mail_preview {
+ padding: $o-mail-chatter-mobile-gap;
+
+ .o_preview_info, .o_last_message_date {
+ margin-left: $o-mail-chatter-mobile-gap;
+ }
+
+ .o_preview_name {
+ font-size: 1.1em;
+ }
+
+ .o_last_message_date {
+ font-size: 0.9em;
+ }
+
+ .o_last_message_preview {
+ margin-top: $o-mail-chatter-mobile-gap*0.5;
+ }
+ }
+
+ .o_mail_mobile_tabs {
+ display: flex;
+ box-shadow: 0 0 8px gray('400');
+
+ .o_mail_mobile_tab {
+ display: flex;
+ flex: 1 1 auto;
+ width: 20%;
+ flex-flow: column nowrap;
+ justify-content: space-between;
+ padding: $o-mail-chatter-mobile-gap $o-mail-chatter-mobile-gap*2;
+ box-shadow: 1px 0 0 gray('400');
+ text-align: center;
+
+ > span {
+ display: block;
+ font-weight: 500;
+ font-size: 10px;
+
+ &.fa {
+ padding-bottom: $o-mail-chatter-mobile-gap*2;
+ font-size: 1.3em;
+ }
+ }
+
+ &.active > span {
+ color: $o-brand-primary;
+ }
+ }
+ }
+}
diff --git a/addons/mail/static/src/scss/emojis.scss b/addons/mail/static/src/scss/emojis.scss
new file mode 100644
index 00000000..b2cb71a4
--- /dev/null
+++ b/addons/mail/static/src/scss/emojis.scss
@@ -0,0 +1,67 @@
+// General variable
+$o-mail-emoji-height: 2rem;
+
+.o_mail_add_emoji {
+ float: right;
+ margin-bottom: 1rem;
+ .dropdown-menu {
+ .o_mail_emoji {
+ cursor: pointer;
+ padding: 2px;
+ width: $o-mail-emoji-height;
+ height: $o-mail-emoji-height;
+ @include hover-focus() {
+ background-color: grey('100');
+ }
+ }
+ }
+}
+
+.o_form_view {
+ // Emojis widgets should hide the emoji dropdown button when the field is invisible.
+ // This is necessary because the button is added *after* the main element (and not inside)
+ // (see '_attachEmojisDropdown' for more details)
+ .o_invisible_modifier + .o_mail_add_emoji{
+ display: none !important;
+ }
+}
+
+.o_mail_emojis_dropdown {
+ height: $o-mail-emoji-height;
+ width: 40px;
+ float: right;
+ bottom: 33px;
+ margin-bottom: -$o-mail-emoji-height;
+
+ * {
+ outline: none!important;
+ box-shadow: none!important;
+ }
+
+ .dropdown-toggle:after {
+ display: none;
+ }
+}
+
+.o_mail_emojis_dropdown_translation {
+ // if the button is added to a text field with a button "language"
+ // add margin-right, so the emojis button is placed on the left of the
+ // language button
+ margin-right: 20px;
+}
+
+.o_mail_emojis_dropdown_textarea{
+ bottom: 40px;
+}
+
+
+.o_xxs_form_view {
+ .o_mail_emojis_dropdown {
+ bottom: 50px;
+ }
+ .o_mail_add_emoji {
+ .dropdown-menu {
+ max-width: 320px;
+ }
+ }
+}
diff --git a/addons/mail/static/src/scss/kanban_view.scss b/addons/mail/static/src/scss/kanban_view.scss
new file mode 100644
index 00000000..02e11eb1
--- /dev/null
+++ b/addons/mail/static/src/scss/kanban_view.scss
@@ -0,0 +1,64 @@
+$o-kanban-attachement-image-size: 80px;
+
+.o_kanban_view {
+
+ .o_kanban_record.o_kanban_attachment {
+ padding: 0;
+
+ .o_kanban_image {
+ width: $o-kanban-attachement-image-size;
+
+ + div {
+ padding-left: $o-kanban-attachement-image-size + $o-kanban-inside-hgutter;
+ @include media-breakpoint-down(sm) {
+ padding-left: $o-kanban-attachement-image-size + $o-kanban-inside-hgutter-mobile;
+ }
+ }
+
+ .o_kanban_image_wrapper {
+ min-height: $o-kanban-attachement-image-size;
+ display: flex;
+ align-items: center;
+ justify-content: center;
+ }
+
+ .o_attachment_image {
+ @include size($o-kanban-attachement-image-size);
+ }
+
+ .o_image {
+ @include size($o-kanban-attachement-image-size*0.7);
+ }
+ }
+
+ .o_kanban_details {
+ .o_kanban_details_wrapper {
+ display: flex;
+ flex-direction: column;
+ min-height: $o-kanban-attachement-image-size;
+ padding: $o-kanban-inside-vgutter $o-kanban-inside-hgutter;
+
+ .o_kanban_record_title {
+ margin-bottom: $o-kanban-inside-vgutter*0.5;
+ white-space: nowrap;
+ text-overflow: ellipsis;
+ overflow: hidden;
+ width: 95%;
+ }
+
+ .o_kanban_record_body {
+ flex: 1 1 auto;
+ white-space: nowrap;
+ text-overflow: ellipsis;
+ overflow: hidden;
+ font-size: smaller;
+ }
+
+ .oe_kanban_avatar {
+ border-radius: 4px;
+ border: 1px solid $component-active-color;
+ }
+ }
+ }
+ }
+}
diff --git a/addons/mail/static/src/scss/mail_activity.scss b/addons/mail/static/src/scss/mail_activity.scss
new file mode 100644
index 00000000..2b316232
--- /dev/null
+++ b/addons/mail/static/src/scss/mail_activity.scss
@@ -0,0 +1,242 @@
+/* Common */
+.o_mail_activity {
+ &.o_field_widget {
+ display: block;
+ }
+
+ .o_thread_date_separator.o_border_dashed {
+ border-bottom-style: dashed;
+
+ &[data-toggle="collapse"] {
+ cursor: pointer;
+
+ .o_chatter_planned_activities_summary {
+ display: none;
+ }
+
+ &.collapsed {
+ margin-bottom: 0;
+ transition: margin 0.8s ease 0s;
+
+ .o_chatter_planned_activities_summary {
+ display: inline-block;
+
+ span {
+ padding: 0 5px;
+ border-radius: 100%;
+ font-size: 11px;
+ }
+ }
+
+ i.fa-caret-down:before {
+ content: '\f0da';
+ }
+ }
+ }
+ }
+
+ #o_chatter_planned_activities {
+ .o_thread_message {
+ .o_thread_message_sidebar {
+ .o_avatar_stack {
+ position: relative;
+ text-align: left;
+ margin-bottom: 8px;
+
+ img {
+ width: 31px;
+ height: 31px;
+ object-fit: cover;
+ }
+
+ .o_avatar_icon {
+ @include o-position-absolute($right: -5px, $bottom: -5px);
+ width: 25px;
+ height: 25px;
+ object-fit: cover;
+ padding: 6px 5px;
+ text-align: center;
+ line-height: 1.2;
+ color: white;
+ border-radius: 100%;
+ border: 2px solid white;
+ }
+ }
+ }
+
+ .o_mail_info {
+ .o_activity_summary {
+ @include o-text-overflow;
+ max-width: 290px;
+ vertical-align: middle;
+ }
+ .o_activity_info {
+ vertical-align: baseline;
+ padding: 4px 6px;
+ background: theme-color('light');
+ border-radius: 2px 2px 0 0;
+ @include o-hover-opacity(1, 1);
+
+ &.collapsed {
+ @include o-hover-opacity(0.5, 1);
+ background: transparent;
+ }
+ }
+ }
+
+ .o_thread_message_collapse .dl-horizontal.card {
+ display: inline-block;
+ margin-bottom: 0;
+
+ dt {
+ max-width: 80px;
+ }
+ dd {
+ margin-left: 95px;
+ }
+ }
+
+ .o_thread_message_note {
+ margin: 2px 0 5px;
+ padding: 0px;
+ }
+ .o_thread_message_warning {
+ margin: 2px 0 5px;
+ }
+
+ .o_activity_template_preview,.o_activity_template_send {
+ font-weight: bold;
+ color: $o-brand-primary;
+ cursor: pointer;
+ &:hover:not(.active) {
+ color: darken($o-brand-primary, 15%);
+ border-color: darken($o-brand-primary, 15%);
+ }
+ }
+
+ .o_thread_message_tools {
+ .o_activity_link {
+ padding: 0 $input-btn-padding-x;
+ }
+ .o_activity_done {
+ padding-left: 0;
+ }
+ }
+ }
+ }
+
+ .o_activity_color_default {
+ color: #dddddd;
+ }
+
+ .o_activity_color_planned {
+ color: darken(theme-color('success'), 10%);
+ }
+ .o_activity_color_overdue {
+ color: darken(theme-color('danger'), 10%);
+ }
+ .o_activity_color_today {
+ color: darken(theme-color('warning'), 10%);
+ }
+}
+
+/* Feedback popover (form view) */
+.o_mail_activity_feedback {
+ max-width: 410px;
+ outline: none;
+
+ textarea {
+ min-width: 250px;
+ }
+}
+
+/* list_activity widget */
+.o_list_view {
+ .o_list_table tbody > tr {
+ > td.o_data_cell.o_list_activity_cell {
+ overflow: visible !important; // allow the activity dropdown to overflow
+ .o_mail_activity {
+ display: flex;
+ max-width: 275px;
+ .o_activity_btn {
+ margin-right: 3px;
+ }
+ .o_activity_summary {
+ @include o-text-overflow;
+ }
+ }
+ }
+ }
+}
+
+/* Kanban View */
+.o_kanban_record{
+ .o_kanban_inline_block {
+ display: inline-block;
+ }
+}
+
+.o_kanban_record, .o_view_controller{
+ .o_mail_activity {
+ .o_activity_btn {
+ span.fa {
+ overflow: visible;
+ line-height: 1;
+ vertical-align: middle;
+ }
+ }
+
+ div.o_activity {
+ min-width: 290px;
+ padding: 0px 0px;
+
+ .o_activity_log_container {
+ max-height: 300px;
+ overflow-y: auto;
+ }
+
+ ul.o_activity_log {
+ color: $body-color;
+
+ li {
+ .o_activity_title_entry {
+ display: flex;
+ align-items: baseline;
+ max-width: 275px;
+ .o_activity_summary {
+ @include o-text-overflow;
+ }
+ }
+
+ .o_edit_button {
+ opacity: 0.5;
+ }
+
+ &:hover .o_edit_button{
+ opacity: 1;
+ }
+
+ .o_activity_link_kanban {
+ font-size: 1.5em;
+ @include o-hover-text-color($text-muted, theme-color('success'));
+ @include o-hover-opacity(0.5, 1);
+ }
+
+ &.o_activity_selected {
+ border-bottom: 0;
+ }
+
+ &.o_activity_form > div {
+ padding-top: 0.5em;
+ padding-bottom: 0.7em;
+ }
+ }
+ }
+
+ .o_no_activity {
+ padding: 10px;
+ cursor: initial;
+ }
+ }
+ }
+}
diff --git a/addons/mail/static/src/scss/many2one_avatar_user.scss b/addons/mail/static/src/scss/many2one_avatar_user.scss
new file mode 100644
index 00000000..cd3fdd38
--- /dev/null
+++ b/addons/mail/static/src/scss/many2one_avatar_user.scss
@@ -0,0 +1,6 @@
+.o_field_many2one_avatar.o_clickable_m2o_avatar {
+ .o_m2o_avatar:hover {
+ cursor: pointer;
+ filter: brightness(0.8);
+ }
+}
diff --git a/addons/mail/static/src/scss/systray.scss b/addons/mail/static/src/scss/systray.scss
new file mode 100644
index 00000000..e198424e
--- /dev/null
+++ b/addons/mail/static/src/scss/systray.scss
@@ -0,0 +1,137 @@
+// Systray icon and dropdown
+.o_mail_systray_item {
+ > a {
+ > i {
+ font-size: larger;
+ }
+ }
+ &.o_no_notification > a {
+ @include o-mail-systray-no-notification-style();
+
+ .o_notification_counter {
+ display: none;
+ }
+ }
+ &.show .o_mail_systray_dropdown {
+ display: flex;
+ flex-flow: column nowrap;
+ }
+ .o_notification_counter {
+ margin-top: -0.8rem;
+ margin-right: 0;
+ margin-left: -0.6rem;
+ background: $o-enterprise-primary-color;
+ color: white;
+ vertical-align: super;
+ font-size: 0.7em;
+ }
+ .o_mail_systray_dropdown {
+ direction: ltr;
+ width: 350px;
+ padding: 0;
+
+ .o_spinner {
+ display: flex;
+ align-items: center;
+ justify-content: center;
+ color: $o-main-text-color;
+ height: 50px;
+ }
+
+ .o_mail_systray_dropdown_top {
+ display: flex;
+ flex: 0 0 auto;
+ justify-content: space-between;
+ border-bottom: 1px solid gray('400');
+ box-shadow: 0 0 2px gray('400');
+ .o_filter_button, .o_new_message {
+ padding: 5px;
+ }
+ .o_filter_button {
+ color: $o-main-color-muted;
+ &:hover, &.active {
+ color: $o-brand-primary;
+ }
+ &.active {
+ cursor: default;
+ font-weight: bold;
+ }
+ }
+ }
+
+ .o_mail_systray_dropdown_items {
+ flex: 0 1 auto;
+ max-height: 400px;
+ min-height: 50px;
+ overflow-y: auto;
+
+ @include media-breakpoint-up(md) {
+ .o_mail_preview {
+ min-height: 50px;
+ padding: 5px;
+ .o_mail_preview_image .fa-circle-o {
+ display: none;
+ }
+ .o_preview_info {
+ margin-left: 10px;
+ .o_preview_title {
+ .o_last_message_date {
+ padding-top: 2px;
+ font-size: x-small;
+ margin-left: 10px;
+ }
+ }
+ }
+ }
+ }
+ }
+ .o_activity_filter_button {
+ padding: 2px;
+ }
+ .o_no_activity {
+ cursor: initial;
+ align-items: center;
+ color: grey;
+ opacity: 0.5;
+ padding: 3px;
+ }
+ }
+}
+
+.o_no_thread_window .o_mail_systray_dropdown .o_new_message {
+ display: none; // hide 'new message' button if chat windows are disabled
+}
+
+// Mobile rules
+// Goal: mock the design of Discuss in mobile
+@include media-breakpoint-down(sm) {
+ .o_mail_systray_item {
+ .o_notification_counter {
+ top: 10%;
+ }
+ .o_mail_systray_dropdown {
+ position: relative;
+ .o_mail_systray_dropdown_top {
+ padding: 5px;
+ }
+ .o_mail_systray_mobile_header {
+ padding: 5px;
+ height: 44px;
+ border-bottom: 1px solid #ebebeb;
+ box-shadow: 0 0 2px gray('400');
+ }
+ .o_mail_systray_dropdown_items {
+ max-height: none;
+ padding-bottom: 52px; // leave space for tabs
+ }
+ .o_mail_mobile_tabs {
+ position: fixed;
+ bottom: 0px;
+ left: 0px;
+ right: 0px;
+ background-color: white;
+ color: $o-main-text-color;
+ }
+ }
+ }
+}
diff --git a/addons/mail/static/src/scss/thread.scss b/addons/mail/static/src/scss/thread.scss
new file mode 100644
index 00000000..070adb6d
--- /dev/null
+++ b/addons/mail/static/src/scss/thread.scss
@@ -0,0 +1,173 @@
+.o_mail_activity {
+
+ .o_thread_date_separator {
+ margin-top: 15px;
+ margin-bottom: 30px;
+ @include media-breakpoint-down(sm) {
+ margin-top: 0px;
+ margin-bottom: 15px;
+ }
+ border-bottom: 1px solid gray('400');
+ text-align: center;
+
+ .o_thread_date {
+ position: relative;
+ top: 10px;
+ margin: 0 auto;
+ padding: 0 10px;
+ font-weight: bold;
+ background: white;
+ }
+ }
+
+ .o_thread_message {
+ display: flex;
+ padding: 4px $o-horizontal-padding;
+ margin-bottom: 0px;
+
+ .o_thread_message_sidebar {
+ flex: 0 0 $o-mail-thread-avatar-size;
+ margin-right: 10px;
+ margin-top: 2px;
+ text-align: center;
+ font-size: smaller;
+ .o_thread_message_sidebar_image {
+ position: relative;
+ height: $o-mail-thread-avatar-size;
+
+ .o_updatable_im_status {
+ width: $o-mail-thread-avatar-size;
+ }
+ .o_mail_user_status {
+ position: absolute;
+ bottom: 0;
+ right: 0;
+
+ &.fa-circle-o {
+ display: none;
+ }
+ }
+ }
+
+ @include media-breakpoint-down(sm) {
+ margin-top: 4px;
+ font-size: x-small;
+ }
+
+ .o_thread_message_avatar {
+ width: $o-mail-thread-avatar-size;
+ height: $o-mail-thread-avatar-size;
+ object-fit: cover;
+ }
+ }
+ .o_thread_icon {
+ cursor: pointer;
+ opacity: 0;
+ }
+
+ &:hover {
+ .o_thread_icon {
+ display: inline-block;
+ opacity: $o-mail-thread-icon-opacity;
+ &:hover {
+ opacity: 1;
+ }
+ }
+ }
+
+ .o_mail_redirect {
+ cursor: pointer;
+ }
+
+ .o_thread_message_core {
+ flex: 1 1 auto;
+ min-width: 0;
+ max-width: 100%;
+ word-wrap: break-word;
+ .o_thread_message_content > pre {
+ white-space: pre-wrap;
+ word-break: break-word;
+ }
+
+ .o_mail_note_title {
+ margin-top: 9px;
+ }
+
+ .o_mail_subject {
+ font-style: italic;
+ }
+
+ .o_mail_notification {
+ font-style: italic;
+ color: gray;
+ }
+
+ [summary~=o_mail_notification] { // name conflicts with channel notifications, but is odoo notification buttons to hide in chatter if present
+ display: none;
+ }
+
+ p {
+ margin: 0 0 9px; // Required by the old design to override a general rule on p's
+ &:last-child {
+ margin-bottom: 0;
+ }
+ }
+ a {
+ display: inline-block;
+ word-break: break-all;
+ }
+ :not(.o_image_box) > img {
+ max-width: 100%;
+ height: auto;
+ }
+
+ .o_mail_body_long {
+ display: none;
+ }
+
+ .o_mail_info {
+ margin-bottom: 2px;
+
+ strong {
+ color: $headings-color;
+ }
+ }
+
+ .o_thread_message_needaction, .o_thread_message_reply {
+ padding: 4px;
+ }
+ }
+ }
+ .o_thread_title {
+ margin-top: 20px;
+ margin-bottom: 20px;
+ font-weight: bold;
+ font-size: 125%;
+
+ &.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_mail_no_content {
+ @include o-position-absolute(30%, 0, 0, 0);
+ text-align: center;
+ font-size: 115%;
+ }
+
+ .o_thread_message .o_thread_message_core .o_mail_read_more {
+ display: block;
+ }
+}
+
+.o_web_client .popover .o_thread_tooltip_icon {
+ min-width: 1rem;
+}
+
+.o_web_client.o_touch_device {
+ .o_mail_thread .o_thread_icon {
+ opacity: $o-mail-thread-icon-opacity;
+ }
+}
diff --git a/addons/mail/static/src/scss/variables.scss b/addons/mail/static/src/scss/variables.scss
new file mode 100644
index 00000000..051a761d
--- /dev/null
+++ b/addons/mail/static/src/scss/variables.scss
@@ -0,0 +1,19 @@
+$o-mail-thread-avatar-size: 36px !default;
+$o-mail-thread-icon-opacity: 0.6 !default;
+$o-mail-thread-side-date-opacity: 0.6 !default;
+$o-mail-thread-window-bg: #FAFAFA !default;
+$o-mail-thread-window-width: 325px !default;
+$o-mail-chatter-gap: 10px !default;
+$o-mail-chatter-mobile-gap: 2% !default;
+$o-mail-chat-header-height: 46px !default;
+$o-mail-attachment-image-size: 100px !default;
+$o-mail-sidebar-icon-opacity: 0.7 !default;
+$o-mail-chat-sidebar-width: 250px !default;
+$o-mail-partner-avatar-size: 24px !default;
+// Needed because $border-radius variations are all set to 0 in enterprise.
+$o-mail-rounded-rectangle-border-radius-sm: .2rem !default;
+$o-mail-rounded-rectangle-border-radius-lg: 3 * $o-mail-rounded-rectangle-border-radius-sm !default;
+
+@mixin o-mail-systray-no-notification-style {
+ opacity: 0.5;
+}
diff --git a/addons/mail/static/src/services/chat_window_service/chat_window_service.js b/addons/mail/static/src/services/chat_window_service/chat_window_service.js
new file mode 100644
index 00000000..8a11c202
--- /dev/null
+++ b/addons/mail/static/src/services/chat_window_service/chat_window_service.js
@@ -0,0 +1,104 @@
+odoo.define('mail/static/src/services/chat_window_service/chat_window_service.js', function (require) {
+'use strict';
+
+const components = {
+ ChatWindowManager: require('mail/static/src/components/chat_window_manager/chat_window_manager.js'),
+};
+
+const AbstractService = require('web.AbstractService');
+const { bus, serviceRegistry } = require('web.core');
+
+const ChatWindowService = AbstractService.extend({
+ /**
+ * @override {web.AbstractService}
+ */
+ start() {
+ this._super(...arguments);
+ this._webClientReady = false;
+ this._listenHomeMenu();
+ },
+ /**
+ * @private
+ */
+ destroy() {
+ if (this.component) {
+ this.component.destroy();
+ this.component = undefined;
+ }
+ },
+
+ //--------------------------------------------------------------------------
+ // Private
+ //--------------------------------------------------------------------------
+
+ /**
+ * @private
+ * @returns {Node}
+ */
+ _getParentNode() {
+ return document.querySelector('body');
+ },
+ /**
+ * @private
+ */
+ _listenHomeMenu() {
+ bus.on('hide_home_menu', this, this._onHideHomeMenu.bind(this));
+ bus.on('show_home_menu', this, this._onShowHomeMenu.bind(this));
+ bus.on('web_client_ready', this, this._onWebClientReady.bind(this));
+ },
+ /**
+ * @private
+ */
+ async _mount() {
+ if (this.component) {
+ this.component.destroy();
+ this.component = undefined;
+ }
+ const ChatWindowManagerComponent = components.ChatWindowManager;
+ this.component = new ChatWindowManagerComponent(null);
+ const parentNode = this._getParentNode();
+ await this.component.mount(parentNode);
+ },
+
+ //--------------------------------------------------------------------------
+ // Handlers
+ //--------------------------------------------------------------------------
+
+ /**
+ * @private
+ */
+ async _onHideHomeMenu() {
+ if (!this._webClientReady) {
+ return;
+ }
+ if (document.querySelector('.o_ChatWindowManager')) {
+ return;
+ }
+ await this._mount();
+ },
+ /**
+ * @private
+ */
+ async _onShowHomeMenu() {
+ if (!this._webClientReady) {
+ return;
+ }
+ if (document.querySelector('.o_ChatWindowManager')) {
+ return;
+ }
+ await this._mount();
+ },
+ /**
+ * @private
+ */
+ async _onWebClientReady() {
+ await this._mount();
+ this._webClientReady = true;
+ },
+});
+
+serviceRegistry.add('chat_window', ChatWindowService);
+
+return ChatWindowService;
+
+});
diff --git a/addons/mail/static/src/services/dialog_service/dialog_service.js b/addons/mail/static/src/services/dialog_service/dialog_service.js
new file mode 100644
index 00000000..88762a29
--- /dev/null
+++ b/addons/mail/static/src/services/dialog_service/dialog_service.js
@@ -0,0 +1,101 @@
+odoo.define('mail/static/src/services/dialog_service/dialog_service.js', function (require) {
+'use strict';
+
+const components = {
+ DialogManager: require('mail/static/src/components/dialog_manager/dialog_manager.js'),
+};
+
+const AbstractService = require('web.AbstractService');
+const { bus, serviceRegistry } = require('web.core');
+
+const DialogService = AbstractService.extend({
+ /**
+ * @override {web.AbstractService}
+ */
+ start() {
+ this._super(...arguments);
+ this._webClientReady = false;
+ this._listenHomeMenu();
+ },
+ /**
+ * @private
+ */
+ destroy() {
+ if (this.component) {
+ this.component.destroy();
+ this.component = undefined;
+ }
+ },
+
+ //--------------------------------------------------------------------------
+ // Private
+ //--------------------------------------------------------------------------
+
+ /**
+ * @private
+ * @returns {Node}
+ */
+ _getParentNode() {
+ return document.querySelector('body');
+ },
+ /**
+ * @private
+ */
+ _listenHomeMenu() {
+ bus.on('hide_home_menu', this, this._onHideHomeMenu.bind(this));
+ bus.on('show_home_menu', this, this._onShowHomeMenu.bind(this));
+ bus.on('web_client_ready', this, this._onWebClientReady.bind(this));
+ },
+ /**
+ * @private
+ */
+ async _mount() {
+ if (this.component) {
+ this.component.destroy();
+ this.component = undefined;
+ }
+ const DialogManagerComponent = components.DialogManager;
+ this.component = new DialogManagerComponent(null);
+ const parentNode = this._getParentNode();
+ await this.component.mount(parentNode);
+ },
+
+ //--------------------------------------------------------------------------
+ // Handlers
+ //--------------------------------------------------------------------------
+
+ /**
+ * @private
+ */
+ async _onHideHomeMenu() {
+ if (!this._webClientReady) {
+ return;
+ }
+ if (document.querySelector('.o_DialogManager')) {
+ return;
+ }
+ await this._mount();
+ },
+ async _onShowHomeMenu() {
+ if (!this._webClientReady) {
+ return;
+ }
+ if (document.querySelector('.o_DialogManager')) {
+ return;
+ }
+ await this._mount();
+ },
+ /**
+ * @private
+ */
+ async _onWebClientReady() {
+ await this._mount();
+ this._webClientReady = true;
+ }
+});
+
+serviceRegistry.add('dialog', DialogService);
+
+return DialogService;
+
+});
diff --git a/addons/mail/static/src/utils/deferred/deferred.js b/addons/mail/static/src/utils/deferred/deferred.js
new file mode 100644
index 00000000..f96696fb
--- /dev/null
+++ b/addons/mail/static/src/utils/deferred/deferred.js
@@ -0,0 +1,21 @@
+odoo.define('mail/static/src/utils/deferred/deferred.js', function (require) {
+'use strict';
+
+/**
+ * @returns {Deferred}
+ */
+function makeDeferred() {
+ let resolve;
+ let reject;
+ const prom = new Promise(function (res, rej) {
+ resolve = res.bind(this);
+ reject = rej.bind(this);
+ });
+ prom.resolve = (...args) => resolve(...args);
+ prom.reject = (...args) => reject(...args);
+ return prom;
+}
+
+return { makeDeferred };
+
+});
diff --git a/addons/mail/static/src/utils/test_utils.js b/addons/mail/static/src/utils/test_utils.js
new file mode 100644
index 00000000..be15afe7
--- /dev/null
+++ b/addons/mail/static/src/utils/test_utils.js
@@ -0,0 +1,767 @@
+odoo.define('mail/static/src/utils/test_utils.js', function (require) {
+'use strict';
+
+const BusService = require('bus.BusService');
+
+const {
+ addMessagingToEnv,
+ addTimeControlToEnv,
+} = require('mail/static/src/env/test_env.js');
+const ModelManager = require('mail/static/src/model/model_manager.js');
+const ChatWindowService = require('mail/static/src/services/chat_window_service/chat_window_service.js');
+const DialogService = require('mail/static/src/services/dialog_service/dialog_service.js');
+const { nextTick } = require('mail/static/src/utils/utils.js');
+const DiscussWidget = require('mail/static/src/widgets/discuss/discuss.js');
+const MessagingMenuWidget = require('mail/static/src/widgets/messaging_menu/messaging_menu.js');
+const MockModels = require('mail/static/tests/helpers/mock_models.js');
+
+const AbstractStorageService = require('web.AbstractStorageService');
+const NotificationService = require('web.NotificationService');
+const RamStorage = require('web.RamStorage');
+const {
+ createActionManager,
+ createView,
+ makeTestPromise,
+ mock: {
+ addMockEnvironment,
+ patch: legacyPatch,
+ unpatch: legacyUnpatch,
+ },
+} = require('web.test_utils');
+const Widget = require('web.Widget');
+
+const { Component } = owl;
+
+//------------------------------------------------------------------------------
+// Private
+//------------------------------------------------------------------------------
+
+/**
+ * Create a fake object 'dataTransfer', linked to some files,
+ * which is passed to drag and drop events.
+ *
+ * @param {Object[]} files
+ * @returns {Object}
+ */
+function _createFakeDataTransfer(files) {
+ return {
+ dropEffect: 'all',
+ effectAllowed: 'all',
+ files,
+ items: [],
+ types: ['Files'],
+ };
+}
+
+/**
+ * @private
+ * @param {Object} callbacks
+ * @param {function[]} callbacks.init
+ * @param {function[]} callbacks.mount
+ * @param {function[]} callbacks.destroy
+ * @param {function[]} callbacks.return
+ * @returns {Object} update callbacks
+ */
+function _useChatWindow(callbacks) {
+ const {
+ mount: prevMount,
+ destroy: prevDestroy,
+ } = callbacks;
+ return Object.assign({}, callbacks, {
+ mount: prevMount.concat(async () => {
+ // trigger mounting of chat window manager
+ await Component.env.services['chat_window']._onWebClientReady();
+ }),
+ destroy: prevDestroy.concat(() => {
+ Component.env.services['chat_window'].destroy();
+ }),
+ });
+}
+
+/**
+ * @private
+ * @param {Object} callbacks
+ * @param {function[]} callbacks.init
+ * @param {function[]} callbacks.mount
+ * @param {function[]} callbacks.destroy
+ * @param {function[]} callbacks.return
+ * @returns {Object} update callbacks
+ */
+function _useDialog(callbacks) {
+ const {
+ mount: prevMount,
+ destroy: prevDestroy,
+ } = callbacks;
+ return Object.assign({}, callbacks, {
+ mount: prevMount.concat(async () => {
+ // trigger mounting of dialog manager
+ await Component.env.services['dialog']._onWebClientReady();
+ }),
+ destroy: prevDestroy.concat(() => {
+ Component.env.services['dialog'].destroy();
+ }),
+ });
+}
+
+/**
+ * @private
+ * @param {Object} callbacks
+ * @param {function[]} callbacks.init
+ * @param {function[]} callbacks.mount
+ * @param {function[]} callbacks.destroy
+ * @param {function[]} callbacks.return
+ * @return {Object} update callbacks
+ */
+function _useDiscuss(callbacks) {
+ const {
+ init: prevInit,
+ mount: prevMount,
+ return: prevReturn,
+ } = callbacks;
+ let discussWidget;
+ const state = {
+ autoOpenDiscuss: false,
+ discussData: {},
+ };
+ return Object.assign({}, callbacks, {
+ init: prevInit.concat(params => {
+ const {
+ autoOpenDiscuss = state.autoOpenDiscuss,
+ discuss: discussData = state.discussData
+ } = params;
+ Object.assign(state, { autoOpenDiscuss, discussData });
+ delete params.autoOpenDiscuss;
+ delete params.discuss;
+ }),
+ mount: prevMount.concat(async params => {
+ const { selector, widget } = params;
+ DiscussWidget.prototype._pushStateActionManager = () => {};
+ discussWidget = new DiscussWidget(widget, state.discussData);
+ await discussWidget.appendTo($(selector));
+ if (state.autoOpenDiscuss) {
+ await discussWidget.on_attach_callback();
+ }
+ }),
+ return: prevReturn.concat(result => {
+ Object.assign(result, { discussWidget });
+ }),
+ });
+}
+
+/**
+ * @private
+ * @param {Object} callbacks
+ * @param {function[]} callbacks.init
+ * @param {function[]} callbacks.mount
+ * @param {function[]} callbacks.destroy
+ * @param {function[]} callbacks.return
+ * @returns {Object} update callbacks
+ */
+function _useMessagingMenu(callbacks) {
+ const {
+ mount: prevMount,
+ return: prevReturn,
+ } = callbacks;
+ let messagingMenuWidget;
+ return Object.assign({}, callbacks, {
+ mount: prevMount.concat(async ({ selector, widget }) => {
+ messagingMenuWidget = new MessagingMenuWidget(widget, {});
+ await messagingMenuWidget.appendTo($(selector));
+ await messagingMenuWidget.on_attach_callback();
+ }),
+ return: prevReturn.concat(result => {
+ Object.assign(result, { messagingMenuWidget });
+ }),
+ });
+}
+
+//------------------------------------------------------------------------------
+// Public: rendering timers
+//------------------------------------------------------------------------------
+
+/**
+ * Returns a promise resolved at the next animation frame.
+ *
+ * @returns {Promise}
+ */
+function nextAnimationFrame() {
+ const requestAnimationFrame = owl.Component.scheduler.requestAnimationFrame;
+ return new Promise(function (resolve) {
+ setTimeout(() => requestAnimationFrame(() => resolve()));
+ });
+}
+
+/**
+ * Returns a promise resolved the next time OWL stops rendering.
+ *
+ * @param {function} func function which, when called, is
+ * expected to trigger OWL render(s).
+ * @param {number} [timeoutDelay=5000] in ms
+ * @returns {Promise}
+ */
+const afterNextRender = (function () {
+ const stop = owl.Component.scheduler.stop;
+ const stopPromises = [];
+
+ owl.Component.scheduler.stop = function () {
+ const wasRunning = this.isRunning;
+ stop.call(this);
+ if (wasRunning) {
+ while (stopPromises.length) {
+ stopPromises.pop().resolve();
+ }
+ }
+ };
+
+ async function afterNextRender(func, timeoutDelay = 5000) {
+ // Define the potential errors outside of the promise to get a proper
+ // trace if they happen.
+ const startError = new Error("Timeout: the render didn't start.");
+ const stopError = new Error("Timeout: the render didn't stop.");
+ // Set up the timeout to reject if no render happens.
+ let timeoutNoRender;
+ const timeoutProm = new Promise((resolve, reject) => {
+ timeoutNoRender = setTimeout(() => {
+ let error = startError;
+ if (owl.Component.scheduler.isRunning) {
+ error = stopError;
+ }
+ console.error(error);
+ reject(error);
+ }, timeoutDelay);
+ });
+ // Set up the promise to resolve if a render happens.
+ const prom = makeTestPromise();
+ stopPromises.push(prom);
+ // Start the function expected to trigger a render after the promise
+ // has been registered to not miss any potential render.
+ const funcRes = func();
+ // Make them race (first to resolve/reject wins).
+ await Promise.race([prom, timeoutProm]);
+ clearTimeout(timeoutNoRender);
+ // Wait the end of the function to ensure all potential effects are
+ // taken into account during the following verification step.
+ await funcRes;
+ // Wait one more frame to make sure no new render has been queued.
+ await nextAnimationFrame();
+ if (owl.Component.scheduler.isRunning) {
+ await afterNextRender(() => {}, timeoutDelay);
+ }
+ }
+
+ return afterNextRender;
+})();
+
+
+//------------------------------------------------------------------------------
+// Public: test lifecycle
+//------------------------------------------------------------------------------
+
+function beforeEach(self) {
+ const data = MockModels.generateData();
+
+ data.partnerRootId = 2;
+ data['res.partner'].records.push({
+ active: false,
+ display_name: "OdooBot",
+ id: data.partnerRootId,
+ });
+
+ data.currentPartnerId = 3;
+ data['res.partner'].records.push({
+ display_name: "Your Company, Mitchell Admin",
+ id: data.currentPartnerId,
+ name: "Mitchell Admin",
+ });
+ data.currentUserId = 2;
+ data['res.users'].records.push({
+ display_name: "Your Company, Mitchell Admin",
+ id: data.currentUserId,
+ name: "Mitchell Admin",
+ partner_id: data.currentPartnerId,
+ });
+
+ data.publicPartnerId = 4;
+ data['res.partner'].records.push({
+ active: false,
+ display_name: "Public user",
+ id: data.publicPartnerId,
+ });
+ data.publicUserId = 3;
+ data['res.users'].records.push({
+ active: false,
+ display_name: "Public user",
+ id: data.publicUserId,
+ name: "Public user",
+ partner_id: data.publicPartnerId,
+ });
+
+ const originals = {
+ '_.debounce': _.debounce,
+ '_.throttle': _.throttle,
+ };
+
+ (function patch() {
+ // patch _.debounce and _.throttle to be fast and synchronous
+ _.debounce = _.identity;
+ _.throttle = _.identity;
+ })();
+
+ function unpatch() {
+ _.debounce = originals['_.debounce'];
+ _.throttle = originals['_.throttle'];
+ }
+
+ Object.assign(self, {
+ components: [],
+ data,
+ unpatch,
+ widget: undefined
+ });
+}
+
+function afterEach(self) {
+ if (self.env) {
+ self.env.bus.off('hide_home_menu', null);
+ self.env.bus.off('show_home_menu', null);
+ self.env.bus.off('will_hide_home_menu', null);
+ self.env.bus.off('will_show_home_menu', null);
+ }
+ // The components must be destroyed before the widget, because the
+ // widget might destroy the models before destroying the components,
+ // and the components might still rely on messaging (or other) record(s).
+ while (self.components.length > 0) {
+ const component = self.components.pop();
+ component.destroy();
+ }
+ if (self.widget) {
+ self.widget.destroy();
+ self.widget = undefined;
+ }
+ self.env = undefined;
+ self.unpatch();
+}
+
+/**
+ * Creates and returns a new root Component with the given props and mounts it
+ * on target.
+ * Assumes that self.env is set to the correct value.
+ * Components created this way are automatically registered for clean up after
+ * the test, which will happen when `afterEach` is called.
+ *
+ * @param {Object} self the current QUnit instance
+ * @param {Class} Component the component class to create
+ * @param {Object} param2
+ * @param {Object} [param2.props={}] forwarded to component constructor
+ * @param {DOM.Element} param2.target mount target for the component
+ * @returns {owl.Component} the new component instance
+ */
+async function createRootComponent(self, Component, { props = {}, target }) {
+ Component.env = self.env;
+ const component = new Component(null, props);
+ delete Component.env;
+ self.components.push(component);
+ await afterNextRender(() => component.mount(target));
+ return component;
+}
+
+/**
+ * Main function used to make a mocked environment with mocked messaging env.
+ *
+ * @param {Object} [param0={}]
+ * @param {string} [param0.arch] makes only sense when `param0.hasView` is set:
+ * the arch to use in createView.
+ * @param {Object} [param0.archs]
+ * @param {boolean} [param0.autoOpenDiscuss=false] makes only sense when
+ * `param0.hasDiscuss` is set: determine whether mounted discuss should be
+ * open initially.
+ * @param {boolean} [param0.debug=false]
+ * @param {Object} [param0.data] makes only sense when `param0.hasView` is set:
+ * the data to use in createView.
+ * @param {Object} [param0.discuss={}] makes only sense when `param0.hasDiscuss`
+ * is set: provide data that is passed to discuss widget (= client action) as
+ * 2nd positional argument.
+ * @param {Object} [param0.env={}]
+ * @param {function} [param0.mockFetch]
+ * @param {function} [param0.mockRPC]
+ * @param {boolean} [param0.hasActionManager=false] if set, use
+ * createActionManager.
+ * @param {boolean} [param0.hasChatWindow=false] if set, mount chat window
+ * service.
+ * @param {boolean} [param0.hasDiscuss=false] if set, mount discuss app.
+ * @param {boolean} [param0.hasMessagingMenu=false] if set, mount messaging
+ * menu.
+ * @param {boolean} [param0.hasTimeControl=false] if set, all flow of time
+ * with `env.browser.setTimeout` are fully controlled by test itself.
+ * @see addTimeControlToEnv that adds `advanceTime` function in
+ * `env.testUtils`.
+ * @param {boolean} [param0.hasView=false] if set, use createView to create a
+ * view instead of a generic widget.
+ * @param {Deferred|Promise} [param0.messagingBeforeCreationDeferred=Promise.resolve()]
+ * Deferred that let tests block messaging creation and simulate resolution.
+ * Useful for testing working components when messaging is not yet created.
+ * @param {string} [param0.model] makes only sense when `param0.hasView` is set:
+ * the model to use in createView.
+ * @param {integer} [param0.res_id] makes only sense when `param0.hasView` is set:
+ * the res_id to use in createView.
+ * @param {Object} [param0.services]
+ * @param {Object} [param0.session]
+ * @param {Object} [param0.View] makes only sense when `param0.hasView` is set:
+ * the View class to use in createView.
+ * @param {Object} [param0.viewOptions] makes only sense when `param0.hasView`
+ * is set: the view options to use in createView.
+ * @param {Object} [param0.waitUntilEvent]
+ * @param {String} [param0.waitUntilEvent.eventName]
+ * @param {String} [param0.waitUntilEvent.message]
+ * @param {function} [param0.waitUntilEvent.predicate]
+ * @param {integer} [param0.waitUntilEvent.timeoutDelay]
+ * @param {string} [param0.waitUntilMessagingCondition='initialized'] Determines
+ * the condition of messaging when this function is resolved.
+ * Supported values: ['none', 'created', 'initialized'].
+ * - 'none': the function resolves regardless of whether messaging is created.
+ * - 'created': the function resolves when messaging is created, but
+ * regardless of whether messaging is initialized.
+ * - 'initialized' (default): the function resolves when messaging is
+ * initialized.
+ * To guarantee messaging is not created, test should pass a pending deferred
+ * as param of `messagingBeforeCreationDeferred`. To make sure messaging is
+ * not initialized, test should mock RPC `mail/init_messaging` and block its
+ * resolution.
+ * @param {...Object} [param0.kwargs]
+ * @throws {Error} in case some provided parameters are wrong, such as
+ * `waitUntilMessagingCondition`.
+ * @returns {Object}
+ */
+async function start(param0 = {}) {
+ let callbacks = {
+ init: [],
+ mount: [],
+ destroy: [],
+ return: [],
+ };
+ const {
+ env: providedEnv,
+ hasActionManager = false,
+ hasChatWindow = false,
+ hasDialog = false,
+ hasDiscuss = false,
+ hasMessagingMenu = false,
+ hasTimeControl = false,
+ hasView = false,
+ messagingBeforeCreationDeferred = Promise.resolve(),
+ waitUntilEvent,
+ waitUntilMessagingCondition = 'initialized',
+ } = param0;
+ if (!['none', 'created', 'initialized'].includes(waitUntilMessagingCondition)) {
+ throw Error(`Unknown parameter value ${waitUntilMessagingCondition} for 'waitUntilMessaging'.`);
+ }
+ delete param0.env;
+ delete param0.hasActionManager;
+ delete param0.hasChatWindow;
+ delete param0.hasDiscuss;
+ delete param0.hasMessagingMenu;
+ delete param0.hasTimeControl;
+ delete param0.hasView;
+ if (hasChatWindow) {
+ callbacks = _useChatWindow(callbacks);
+ }
+ if (hasDialog) {
+ callbacks = _useDialog(callbacks);
+ }
+ if (hasDiscuss) {
+ callbacks = _useDiscuss(callbacks);
+ }
+ if (hasMessagingMenu) {
+ callbacks = _useMessagingMenu(callbacks);
+ }
+ const {
+ init: initCallbacks,
+ mount: mountCallbacks,
+ destroy: destroyCallbacks,
+ return: returnCallbacks,
+ } = callbacks;
+ const { debug = false } = param0;
+ initCallbacks.forEach(callback => callback(param0));
+
+ let env = Object.assign(providedEnv || {});
+ env.session = Object.assign(
+ {
+ is_bound: Promise.resolve(),
+ url: s => s,
+ },
+ env.session
+ );
+ env = addMessagingToEnv(env);
+ if (hasTimeControl) {
+ env = addTimeControlToEnv(env);
+ }
+
+ const services = Object.assign({}, {
+ bus_service: BusService.extend({
+ _beep() {}, // Do nothing
+ _poll() {}, // Do nothing
+ _registerWindowUnload() {}, // Do nothing
+ isOdooFocused() {
+ return true;
+ },
+ updateOption() {},
+ }),
+ chat_window: ChatWindowService.extend({
+ _getParentNode() {
+ return document.querySelector(debug ? 'body' : '#qunit-fixture');
+ },
+ _listenHomeMenu: () => {},
+ }),
+ dialog: DialogService.extend({
+ _getParentNode() {
+ return document.querySelector(debug ? 'body' : '#qunit-fixture');
+ },
+ _listenHomeMenu: () => {},
+ }),
+ local_storage: AbstractStorageService.extend({ storage: new RamStorage() }),
+ notification: NotificationService.extend(),
+ }, param0.services);
+
+ const kwargs = Object.assign({}, param0, {
+ archs: Object.assign({}, {
+ 'mail.message,false,search': '<search/>'
+ }, param0.archs),
+ debug: param0.debug || false,
+ services: Object.assign({}, services, param0.services),
+ }, { env });
+ let widget;
+ let mockServer; // only in basic mode
+ let testEnv;
+ const selector = debug ? 'body' : '#qunit-fixture';
+ if (hasView) {
+ widget = await createView(kwargs);
+ legacyPatch(widget, {
+ destroy() {
+ destroyCallbacks.forEach(callback => callback({ widget }));
+ this._super(...arguments);
+ legacyUnpatch(widget);
+ if (testEnv) {
+ testEnv.destroyMessaging();
+ }
+ }
+ });
+ } else if (hasActionManager) {
+ widget = await createActionManager(kwargs);
+ legacyPatch(widget, {
+ destroy() {
+ destroyCallbacks.forEach(callback => callback({ widget }));
+ this._super(...arguments);
+ legacyUnpatch(widget);
+ if (testEnv) {
+ testEnv.destroyMessaging();
+ }
+ }
+ });
+ } else {
+ const Parent = Widget.extend({ do_push_state() {} });
+ const parent = new Parent();
+ mockServer = await addMockEnvironment(parent, kwargs);
+ widget = new Widget(parent);
+ await widget.appendTo($(selector));
+ Object.assign(widget, {
+ destroy() {
+ delete widget.destroy;
+ destroyCallbacks.forEach(callback => callback({ widget }));
+ parent.destroy();
+ if (testEnv) {
+ testEnv.destroyMessaging();
+ }
+ },
+ });
+ }
+
+ testEnv = Component.env;
+
+ /**
+ * Components cannot use web.bus, because they cannot use
+ * EventDispatcherMixin, and webclient cannot easily access env.
+ * Communication between webclient and components by core.bus
+ * (usable by webclient) and messagingBus (usable by components), which
+ * the messaging service acts as mediator since it can easily use both
+ * kinds of buses.
+ */
+ testEnv.bus.on(
+ 'hide_home_menu',
+ null,
+ () => testEnv.messagingBus.trigger('hide_home_menu')
+ );
+ testEnv.bus.on(
+ 'show_home_menu',
+ null,
+ () => testEnv.messagingBus.trigger('show_home_menu')
+ );
+ testEnv.bus.on(
+ 'will_hide_home_menu',
+ null,
+ () => testEnv.messagingBus.trigger('will_hide_home_menu')
+ );
+ testEnv.bus.on(
+ 'will_show_home_menu',
+ null,
+ () => testEnv.messagingBus.trigger('will_show_home_menu')
+ );
+
+ /**
+ * Returns a promise resolved after the expected event is received.
+ *
+ * @param {Object} param0
+ * @param {string} param0.eventName event to wait
+ * @param {function} param0.func function which, when called, is expected to
+ * trigger the event
+ * @param {string} [param0.message] assertion message
+ * @param {function} [param0.predicate] predicate called with event data.
+ * If not provided, only the event name has to match.
+ * @param {number} [param0.timeoutDelay=5000] how long to wait at most in ms
+ * @returns {Promise}
+ */
+ const afterEvent = (async ({ eventName, func, message, predicate, timeoutDelay = 5000 }) => {
+ // Set up the timeout to reject if the event is not triggered.
+ let timeoutNoEvent;
+ const timeoutProm = new Promise((resolve, reject) => {
+ timeoutNoEvent = setTimeout(() => {
+ let error = message
+ ? new Error(message)
+ : new Error(`Timeout: the event ${eventName} was not triggered.`);
+ console.error(error);
+ reject(error);
+ }, timeoutDelay);
+ });
+ // Set up the promise to resolve if the event is triggered.
+ const eventProm = new Promise(resolve => {
+ testEnv.messagingBus.on(eventName, null, data => {
+ if (!predicate || predicate(data)) {
+ resolve();
+ }
+ });
+ });
+ // Start the function expected to trigger the event after the
+ // promise has been registered to not miss any potential event.
+ const funcRes = func();
+ // Make them race (first to resolve/reject wins).
+ await Promise.race([eventProm, timeoutProm]);
+ clearTimeout(timeoutNoEvent);
+ // If the event is triggered before the end of the async function,
+ // ensure the function finishes its job before returning.
+ await funcRes;
+ });
+
+ const result = {
+ afterEvent,
+ env: testEnv,
+ mockServer,
+ widget,
+ };
+
+ const start = async () => {
+ messagingBeforeCreationDeferred.then(async () => {
+ /**
+ * Some models require session data, like locale text direction
+ * (depends on fully loaded translation).
+ */
+ await env.session.is_bound;
+
+ testEnv.modelManager = new ModelManager(testEnv);
+ testEnv.modelManager.start();
+ /**
+ * Create the messaging singleton record.
+ */
+ testEnv.messaging = testEnv.models['mail.messaging'].create();
+ testEnv.messaging.start().then(() =>
+ testEnv.messagingInitializedDeferred.resolve()
+ );
+ testEnv.messagingCreatedPromise.resolve();
+ });
+ if (waitUntilMessagingCondition === 'created') {
+ await testEnv.messagingCreatedPromise;
+ }
+ if (waitUntilMessagingCondition === 'initialized') {
+ await testEnv.messagingInitializedDeferred;
+ }
+
+ if (mountCallbacks.length > 0) {
+ await afterNextRender(async () => {
+ await Promise.all(mountCallbacks.map(callback => callback({ selector, widget })));
+ });
+ }
+ returnCallbacks.forEach(callback => callback(result));
+ };
+ if (waitUntilEvent) {
+ await afterEvent(Object.assign({ func: start }, waitUntilEvent));
+ } else {
+ await start();
+ }
+ return result;
+}
+
+//------------------------------------------------------------------------------
+// Public: file utilities
+//------------------------------------------------------------------------------
+
+/**
+ * Drag some files over a DOM element
+ *
+ * @param {DOM.Element} el
+ * @param {Object[]} file must have been create beforehand
+ * @see testUtils.file.createFile
+ */
+function dragenterFiles(el, files) {
+ const ev = new Event('dragenter', { bubbles: true });
+ Object.defineProperty(ev, 'dataTransfer', {
+ value: _createFakeDataTransfer(files),
+ });
+ el.dispatchEvent(ev);
+}
+
+/**
+ * Drop some files on a DOM element
+ *
+ * @param {DOM.Element} el
+ * @param {Object[]} files must have been created beforehand
+ * @see testUtils.file.createFile
+ */
+function dropFiles(el, files) {
+ const ev = new Event('drop', { bubbles: true });
+ Object.defineProperty(ev, 'dataTransfer', {
+ value: _createFakeDataTransfer(files),
+ });
+ el.dispatchEvent(ev);
+}
+
+/**
+ * Paste some files on a DOM element
+ *
+ * @param {DOM.Element} el
+ * @param {Object[]} files must have been created beforehand
+ * @see testUtils.file.createFile
+ */
+function pasteFiles(el, files) {
+ const ev = new Event('paste', { bubbles: true });
+ Object.defineProperty(ev, 'clipboardData', {
+ value: _createFakeDataTransfer(files),
+ });
+ el.dispatchEvent(ev);
+}
+
+//------------------------------------------------------------------------------
+// Export
+//------------------------------------------------------------------------------
+
+return {
+ afterEach,
+ afterNextRender,
+ beforeEach,
+ createRootComponent,
+ dragenterFiles,
+ dropFiles,
+ nextAnimationFrame,
+ nextTick,
+ pasteFiles,
+ start,
+};
+
+});
diff --git a/addons/mail/static/src/utils/throttle/throttle.js b/addons/mail/static/src/utils/throttle/throttle.js
new file mode 100644
index 00000000..6b9ff008
--- /dev/null
+++ b/addons/mail/static/src/utils/throttle/throttle.js
@@ -0,0 +1,382 @@
+odoo.define('mail/static/src/utils/throttle/throttle.js', function (require) {
+'use strict';
+
+const { makeDeferred } = require('mail/static/src/utils/deferred/deferred.js');
+
+/**
+ * This module define an utility function that enables throttling calls on a
+ * provided function. Such throttled calls can be canceled, flushed and/or
+ * cleared:
+ *
+ * - cancel: Canceling a throttle function call means that if a function call is
+ * pending invocation, cancel removes this pending call invocation. It however
+ * preserves the internal timer of the cooling down phase of this throttle
+ * function, meaning that any following throttle function call will be pending
+ * and has to wait for the remaining time of the cooling down phase before
+ * being invoked.
+ *
+ * - flush: Flushing a throttle function call means that if a function call is
+ * pending invocation, flush immediately terminates the cooling down phase and
+ * the pending function call is immediately invoked. Flush also works without
+ * any pending function call: it just terminates the cooling down phase, so
+ * that a following function call is guaranteed to be immediately called.
+ *
+ * - clear: Clearing a throttle function combines canceling and flushing
+ * together.
+ */
+
+//------------------------------------------------------------------------------
+// Errors
+//------------------------------------------------------------------------------
+
+/**
+ * List of internal and external Throttle errors.
+ * Internal errors are prefixed with `_`.
+ */
+
+ /**
+ * Error when throttle function has been canceled with `.cancel()`. Used to
+ * let the caller know of throttle function that the call has been canceled,
+ * which means the inner function will not be called. Usually caller should
+ * just accept it and kindly treat this error as a polite warning.
+ */
+class ThrottleCanceledError extends Error {
+ /**
+ * @override
+ */
+ constructor(throttleId, ...args) {
+ super(...args);
+ this.name = 'ThrottleCanceledError';
+ this.throttleId = throttleId;
+ }
+}
+/**
+ * Error when throttle function has been reinvoked again. Used to let know
+ * caller of throttle function that the call has been canceled and replaced with
+ * another one, which means the (potentially) following inner function will be
+ * in the context of another call. Same as for `ThrottleCanceledError`, usually
+ * caller should just accept it and kindly treat this error as a polite
+ * warning.
+ */
+class ThrottleReinvokedError extends Error {
+ /**
+ * @override
+ */
+ constructor(throttleId, ...args) {
+ super(...args);
+ this.name = 'ThrottleReinvokedError';
+ this.throttleId = throttleId;
+ }
+}
+/**
+ * Error when throttle function has been flushed with `.flush()`. Used
+ * internally to immediately invoke pending inner functions, since a flush means
+ * the termination of cooling down phase.
+ *
+ * @private
+ */
+class _ThrottleFlushedError extends Error {
+ /**
+ * @override
+ */
+ constructor(throttleId, ...args) {
+ super(...args);
+ this.name = '_ThrottleFlushedError';
+ this.throttleId = throttleId;
+ }
+}
+
+//------------------------------------------------------------------------------
+// Private
+//------------------------------------------------------------------------------
+
+/**
+ * This class models the behaviour of the cancelable, flushable and clearable
+ * throttle version of a provided function. See definitions at the top of this
+ * file.
+ */
+class Throttle {
+
+ /**
+ * @param {Object} env the OWL env
+ * @param {function} func provided function for making throttled version.
+ * @param {integer} duration duration of the 'cool down' phase, i.e.
+ * the minimum duration between the most recent function call that has
+ * been made and the following function call (of course, assuming no flush
+ * in-between).
+ */
+ constructor(env, func, duration) {
+ /**
+ * Reference to the OWL envirionment. Useful to fine-tune control of
+ * time flow in tests.
+ * @see mail/static/src/utils/test_utils.js:start.hasTimeControl
+ */
+ this.env = env;
+ /**
+ * Unique id of this throttle function. Useful for the ThrottleError
+ * management, in order to determine whether these errors come from
+ * this throttle or from another one (e.g. inner function makes use of
+ * another throttle).
+ */
+ this.id = _.uniqueId('throttle_');
+ /**
+ * Deferred of current cooling down phase in progress. Defined only when
+ * there is a cooling down phase in progress. Resolved when cooling down
+ * phase terminates from timeout, and rejected if flushed.
+ *
+ * @see _ThrottleFlushedError for rejection of this deferred.
+ */
+ this._coolingDownDeferred = undefined;
+ /**
+ * Duration, in milliseconds, of the cool down phase.
+ */
+ this._duration = duration;
+ /**
+ * Inner function to be invoked and throttled.
+ */
+ this._function = func;
+ /**
+ * Determines whether the throttle function is currently in cool down
+ * phase. Cool down phase happens just after inner function has been
+ * invoked, and during this time any following function call are pending
+ * and will be invoked only after the end of the cool down phase (except
+ * if canceled).
+ */
+ this._isCoolingDown = false;
+ /**
+ * Deferred of a currently pending invocation to inner function. Defined
+ * only during a cooling down phase and just after when throttle
+ * function has been called during this cooling down phase. It is kept
+ * until cooling down phase ends (either from timeout or flushed
+ * throttle) or until throttle is canceled (i.e. removes pending invoke
+ * while keeping cooling down phase live on).
+ */
+ this._pendingInvokeDeferred = undefined;
+ }
+
+ //--------------------------------------------------------------------------
+ // Public
+ //--------------------------------------------------------------------------
+
+ /**
+ * Cancel any buffered function call while keeping the cooldown phase
+ * running.
+ */
+ cancel() {
+ if (!this._isCoolingDown) {
+ return;
+ }
+ if (!this._pendingInvokeDeferred) {
+ return;
+ }
+ this._pendingInvokeDeferred.reject(new ThrottleCanceledError(this.id));
+ }
+
+ /**
+ * Clear any buffered function call and immediately terminates any cooling
+ * down phase in progress.
+ */
+ clear() {
+ this.cancel();
+ this.flush();
+ }
+
+ /**
+ * Called when there is a call to the function. This function is throttled,
+ * so the time it is called depends on whether the "cooldown stage" occurs
+ * or not:
+ *
+ * - no cooldown stage: function is called immediately, and it starts
+ * the cooldown stage when successful.
+ * - in cooldown stage: function is called when the cooldown stage has
+ * ended from timeout.
+ *
+ * Note that after the cooldown stage, only the last attempted function
+ * call will be considered.
+ *
+ * @param {...any} args
+ * @throws {ThrottleReinvokedError|ThrottleCanceledError}
+ * @returns {any} result of called function, if it's called.
+ */
+ async do(...args) {
+ if (!this._isCoolingDown) {
+ return this._invokeFunction(...args);
+ }
+ if (this._pendingInvokeDeferred) {
+ this._pendingInvokeDeferred.reject(new ThrottleReinvokedError(this.id));
+ }
+ try {
+ this._pendingInvokeDeferred = makeDeferred();
+ await Promise.race([this._coolingDownDeferred, this._pendingInvokeDeferred]);
+ } catch (error) {
+ if (
+ !(error instanceof _ThrottleFlushedError) ||
+ error.throttleId !== this.id
+ ) {
+ throw error;
+ }
+ } finally {
+ this._pendingInvokeDeferred = undefined;
+ }
+ return this._invokeFunction(...args);
+ }
+
+ /**
+ * Flush the internal throttle timer, so that the following function call
+ * is immediate. For instance, if there is a cooldown stage, it is aborted.
+ */
+ flush() {
+ if (!this._isCoolingDown) {
+ return;
+ }
+ const coolingDownDeferred = this._coolingDownDeferred;
+ this._coolingDownDeferred = undefined;
+ this._isCoolingDown = false;
+ coolingDownDeferred.reject(new _ThrottleFlushedError(this.id));
+ }
+
+ //--------------------------------------------------------------------------
+ // Private
+ //--------------------------------------------------------------------------
+
+ /**
+ * Invoke the inner function of this throttle and starts cooling down phase
+ * immediately after.
+ *
+ * @private
+ * @param {...any} args
+ */
+ _invokeFunction(...args) {
+ const res = this._function(...args);
+ this._startCoolingDown();
+ return res;
+ }
+
+ /**
+ * Called just when the inner function is being called. Starts the cooling
+ * down phase, which turn any call to this throttle function as pending
+ * inner function calls. This will be called after the end of cooling down
+ * phase (except if canceled).
+ */
+ async _startCoolingDown() {
+ if (this._coolingDownDeferred) {
+ throw new Error("Cannot start cooling down if there's already a cooling down in progress.");
+ }
+ // Keep local reference of cooling down deferred, because the one stored
+ // on `this` could be overwritten by another call to this throttle.
+ const coolingDownDeferred = makeDeferred();
+ this._coolingDownDeferred = coolingDownDeferred;
+ this._isCoolingDown = true;
+ const cooldownTimeoutId = this.env.browser.setTimeout(
+ () => coolingDownDeferred.resolve(),
+ this._duration
+ );
+ let unexpectedError;
+ try {
+ await coolingDownDeferred;
+ } catch (error) {
+ if (
+ !(error instanceof _ThrottleFlushedError) ||
+ error.throttleId !== this.id
+ ) {
+ // This branching should never happen.
+ // Still defined in case of programming error.
+ unexpectedError = error;
+ }
+ } finally {
+ this.env.browser.clearTimeout(cooldownTimeoutId);
+ this._coolingDownDeferred = undefined;
+ this._isCoolingDown = false;
+ }
+ if (unexpectedError) {
+ throw unexpectedError;
+ }
+ }
+
+}
+
+//------------------------------------------------------------------------------
+// Public
+//------------------------------------------------------------------------------
+
+/**
+ * A function that creates a cancelable, flushable and clearable throttle
+ * version of a provided function. See definitions at the top of this file.
+ *
+ * This throttle mechanism allows calling a function at most once during a
+ * certain period:
+ *
+ * - When a function call is made, it enters a 'cooldown' phase, in which any
+ * attempt to call the function is buffered until the cooldown phase ends.
+ * - At most 1 function call can be buffered during the cooldown phase, and the
+ * latest one in this phase will be considered at its end.
+ * - When a cooldown phase ends, any buffered function call will be performed
+ * and another cooldown phase will follow up.
+ *
+ * @param {Object} env the OWL env
+ * @param {function} func the function to throttle.
+ * @param {integer} duration duration, in milliseconds, of the cooling down
+ * phase of the throttling.
+ * @param {Object} [param2={}]
+ * @param {boolean} [param2.silentCancelationErrors=true] if unset, caller
+ * of throttle function will observe some errors that come from current
+ * throttle call that has been canceled, such as when throttle function has
+ * been explicitly canceled with `.cancel()` or when another new throttle call
+ * has been registered.
+ * @see ThrottleCanceledError for when a call has been canceled from explicit
+ * call.
+ * @see ThrottleReinvokedError for when a call has been canceled from another
+ * new throttle call has been registered.
+ * @returns {function} the cancelable, flushable and clearable throttle version
+ * of the provided function.
+ */
+function throttle(
+ env,
+ func,
+ duration,
+ { silentCancelationErrors = true } = {}
+) {
+ const throttleObj = new Throttle(env, func, duration);
+ const callable = async (...args) => {
+ try {
+ // await is important, otherwise errors are not intercepted.
+ return await throttleObj.do(...args);
+ } catch (error) {
+ const isSelfReinvokedError = (
+ error instanceof ThrottleReinvokedError &&
+ error.throttleId === throttleObj.id
+ );
+ const isSelfCanceledError = (
+ error instanceof ThrottleCanceledError &&
+ error.throttleId === throttleObj.id
+ );
+
+ if (silentCancelationErrors && (isSelfReinvokedError || isSelfCanceledError)) {
+ // Silently ignore cancelation errors.
+ // Promise is indefinitely pending for async functions.
+ return new Promise(() => {});
+ } else {
+ throw error;
+ }
+ }
+ };
+ Object.assign(callable, {
+ cancel: () => throttleObj.cancel(),
+ clear: () => throttleObj.clear(),
+ flush: () => throttleObj.flush(),
+ });
+ return callable;
+}
+
+/**
+ * Make external throttle errors accessible from throttle function.
+ */
+Object.assign(throttle, {
+ ThrottleReinvokedError,
+ ThrottleCanceledError,
+});
+
+
+return throttle;
+
+});
diff --git a/addons/mail/static/src/utils/throttle/throttle_tests.js b/addons/mail/static/src/utils/throttle/throttle_tests.js
new file mode 100644
index 00000000..d3e6ad66
--- /dev/null
+++ b/addons/mail/static/src/utils/throttle/throttle_tests.js
@@ -0,0 +1,407 @@
+odoo.define('mail/static/src/utils/throttle/throttle_tests.js', function (require) {
+'use strict';
+
+const { afterEach, beforeEach, start } = require('mail/static/src/utils/test_utils.js');
+const throttle = require('mail/static/src/utils/throttle/throttle.js');
+const { nextTick } = require('mail/static/src/utils/utils.js');
+
+const { ThrottleReinvokedError, ThrottleCanceledError } = throttle;
+
+QUnit.module('mail', {}, function () {
+QUnit.module('utils', {}, function () {
+QUnit.module('throttle', {}, function () {
+QUnit.module('throttle_tests.js', {
+ beforeEach() {
+ beforeEach(this);
+ this.throttles = [];
+
+ this.start = async params => {
+ const { env, widget } = await start(Object.assign({}, params, {
+ data: this.data,
+ }));
+ this.env = env;
+ this.widget = widget;
+ };
+ },
+ afterEach() {
+ // Important: tests should cleanly intercept cancelation errors that
+ // may result from this teardown.
+ for (const t of this.throttles) {
+ t.clear();
+ }
+ afterEach(this);
+ },
+});
+
+QUnit.test('single call', async function (assert) {
+ assert.expect(6);
+
+ await this.start({
+ hasTimeControl: true,
+ });
+
+ let hasInvokedFunc = false;
+ const throttledFunc = throttle(
+ this.env,
+ () => {
+ hasInvokedFunc = true;
+ return 'func_result';
+ },
+ 0
+ );
+ this.throttles.push(throttledFunc);
+
+ assert.notOk(
+ hasInvokedFunc,
+ "func should not have been invoked on immediate throttle initialization"
+ );
+
+ await this.env.testUtils.advanceTime(0);
+ assert.notOk(
+ hasInvokedFunc,
+ "func should not have been invoked from throttle initialization after 0ms"
+ );
+
+ throttledFunc().then(res => {
+ assert.step('throttle_observed_invoke');
+ assert.strictEqual(
+ res,
+ 'func_result',
+ "throttle call return should forward result of inner func"
+ );
+ });
+ await nextTick();
+ assert.ok(
+ hasInvokedFunc,
+ "func should have been immediately invoked on first throttle call"
+ );
+ assert.verifySteps(
+ ['throttle_observed_invoke'],
+ "throttle should have observed invoked on first throttle call"
+ );
+});
+
+QUnit.test('2nd (throttled) call', async function (assert) {
+ assert.expect(8);
+
+ await this.start({
+ hasTimeControl: true,
+ });
+
+ let funcCalledAmount = 0;
+ const throttledFunc = throttle(
+ this.env,
+ () => {
+ funcCalledAmount++;
+ return `func_result_${funcCalledAmount}`;
+ },
+ 1000
+ );
+ this.throttles.push(throttledFunc);
+
+ throttledFunc().then(result => {
+ assert.step('throttle_observed_invoke_1');
+ assert.strictEqual(
+ result,
+ 'func_result_1',
+ "throttle call return should forward result of inner func 1"
+ );
+ });
+ await nextTick();
+ assert.verifySteps(
+ ['throttle_observed_invoke_1'],
+ "inner function of throttle should have been invoked on 1st call (immediate return)"
+ );
+
+ throttledFunc().then(res => {
+ assert.step('throttle_observed_invoke_2');
+ assert.strictEqual(
+ res,
+ 'func_result_2',
+ "throttle call return should forward result of inner func 2"
+ );
+ });
+ await nextTick();
+ assert.verifySteps(
+ [],
+ "inner function of throttle should not have been immediately invoked after 2nd call immediately after 1st call (throttled with 1s internal clock)"
+ );
+
+ await this.env.testUtils.advanceTime(999);
+ assert.verifySteps(
+ [],
+ "inner function of throttle should not have been invoked after 999ms of 2nd call (throttled with 1s internal clock)"
+ );
+
+ await this.env.testUtils.advanceTime(1);
+ assert.verifySteps(
+ ['throttle_observed_invoke_2'],
+ "inner function of throttle should not have been invoked after 1s of 2nd call (throttled with 1s internal clock)"
+ );
+});
+
+QUnit.test('throttled call reinvocation', async function (assert) {
+ assert.expect(11);
+
+ await this.start({
+ hasTimeControl: true,
+ });
+
+ let funcCalledAmount = 0;
+ const throttledFunc = throttle(
+ this.env,
+ () => {
+ funcCalledAmount++;
+ return `func_result_${funcCalledAmount}`;
+ },
+ 1000,
+ { silentCancelationErrors: false }
+ );
+ this.throttles.push(throttledFunc);
+
+ throttledFunc().then(result => {
+ assert.step('throttle_observed_invoke_1');
+ assert.strictEqual(
+ result,
+ 'func_result_1',
+ "throttle call return should forward result of inner func 1"
+ );
+ });
+ await nextTick();
+ assert.verifySteps(
+ ['throttle_observed_invoke_1'],
+ "inner function of throttle should have been invoked on 1st call (immediate return)"
+ );
+
+ throttledFunc()
+ .then(() => {
+ throw new Error("2nd throttle call should not be resolved (should have been canceled by reinvocation)");
+ })
+ .catch(error => {
+ assert.ok(
+ error instanceof ThrottleReinvokedError,
+ "Should generate a Throttle reinvoked error (from another throttle function call)"
+ );
+ assert.step('throttle_reinvoked_1');
+ });
+ await nextTick();
+ assert.verifySteps(
+ [],
+ "inner function of throttle should not have been immediately invoked after 2nd call immediately after 1st call (throttled with 1s internal clock)"
+ );
+
+ await this.env.testUtils.advanceTime(999);
+ assert.verifySteps(
+ [],
+ "inner function of throttle should not have been invoked after 999ms of 2nd call (throttled with 1s internal clock)"
+ );
+
+ throttledFunc()
+ .then(result => {
+ assert.step('throttle_observed_invoke_2');
+ assert.strictEqual(
+ result,
+ 'func_result_2',
+ "throttle call return should forward result of inner func 2"
+ );
+ });
+ await nextTick();
+ assert.verifySteps(
+ ['throttle_reinvoked_1'],
+ "2nd throttle call should have been canceled from 3rd throttle call (reinvoked before cooling down phase has ended)"
+ );
+
+ await this.env.testUtils.advanceTime(1);
+ assert.verifySteps(
+ ['throttle_observed_invoke_2'],
+ "inner function of throttle should have been invoked after 1s of 1st call (throttled with 1s internal clock, 3rd throttle call re-use timer of 2nd throttle call)"
+ );
+});
+
+QUnit.test('flush throttled call', async function (assert) {
+ assert.expect(9);
+
+ await this.start({
+ hasTimeControl: true,
+ });
+
+ const throttledFunc = throttle(
+ this.env,
+ () => {},
+ 1000,
+ );
+ this.throttles.push(throttledFunc);
+
+ throttledFunc().then(() => assert.step('throttle_observed_invoke_1'));
+ await nextTick();
+ assert.verifySteps(
+ ['throttle_observed_invoke_1'],
+ "inner function of throttle should have been invoked on 1st call (immediate return)"
+ );
+
+ throttledFunc().then(() => assert.step('throttle_observed_invoke_2'));
+ await nextTick();
+ assert.verifySteps(
+ [],
+ "inner function of throttle should not have been immediately invoked after 2nd call immediately after 1st call (throttled with 1s internal clock)"
+ );
+
+ await this.env.testUtils.advanceTime(10);
+ assert.verifySteps(
+ [],
+ "inner function of throttle should not have been invoked after 10ms of 2nd call (throttled with 1s internal clock)"
+ );
+
+ throttledFunc.flush();
+ await nextTick();
+ assert.verifySteps(
+ ['throttle_observed_invoke_2'],
+ "inner function of throttle should have been invoked from 2nd call after flush"
+ );
+
+ throttledFunc().then(() => assert.step('throttle_observed_invoke_3'));
+ await nextTick();
+ await this.env.testUtils.advanceTime(999);
+ assert.verifySteps(
+ [],
+ "inner function of throttle should not have been invoked after 999ms of 3rd call (throttled with 1s internal clock)"
+ );
+
+ await this.env.testUtils.advanceTime(1);
+ assert.verifySteps(
+ ['throttle_observed_invoke_3'],
+ "inner function of throttle should not have been invoked after 999ms of 3rd call (throttled with 1s internal clock)"
+ );
+});
+
+QUnit.test('cancel throttled call', async function (assert) {
+ assert.expect(10);
+
+ await this.start({
+ hasTimeControl: true,
+ });
+
+ const throttledFunc = throttle(
+ this.env,
+ () => {},
+ 1000,
+ { silentCancelationErrors: false }
+ );
+ this.throttles.push(throttledFunc);
+
+ throttledFunc().then(() => assert.step('throttle_observed_invoke_1'));
+ await nextTick();
+ assert.verifySteps(
+ ['throttle_observed_invoke_1'],
+ "inner function of throttle should have been invoked on 1st call (immediate return)"
+ );
+
+ throttledFunc()
+ .then(() => {
+ throw new Error("2nd throttle call should not be resolved (should have been canceled)");
+ })
+ .catch(error => {
+ assert.ok(
+ error instanceof ThrottleCanceledError,
+ "Should generate a Throttle canceled error (from `.cancel()`)"
+ );
+ assert.step('throttle_canceled');
+ });
+ await nextTick();
+ assert.verifySteps(
+ [],
+ "inner function of throttle should not have been immediately invoked after 2nd call immediately after 1st call (throttled with 1s internal clock)"
+ );
+
+ await this.env.testUtils.advanceTime(500);
+ assert.verifySteps(
+ [],
+ "inner function of throttle should not have been invoked after 500ms of 2nd call (throttled with 1s internal clock)"
+ );
+
+ throttledFunc.cancel();
+ await nextTick();
+ assert.verifySteps(
+ ['throttle_canceled'],
+ "2nd throttle function call should have been canceled"
+ );
+
+ throttledFunc().then(() => assert.step('throttle_observed_invoke_3'));
+ await nextTick();
+ assert.verifySteps(
+ [],
+ "3rd throttle function call should not have invoked inner function yet (cancel reuses inner clock of throttle)"
+ );
+
+ await this.env.testUtils.advanceTime(500);
+ assert.verifySteps(
+ ['throttle_observed_invoke_3'],
+ "3rd throttle function call should have invoke inner function after 500ms (cancel reuses inner clock of throttle which was at 500ms in, throttle set at 1ms)"
+ );
+});
+
+QUnit.test('clear throttled call', async function (assert) {
+ assert.expect(9);
+
+ await this.start({
+ hasTimeControl: true,
+ });
+
+ const throttledFunc = throttle(
+ this.env,
+ () => {},
+ 1000,
+ { silentCancelationErrors: false }
+ );
+ this.throttles.push(throttledFunc);
+
+ throttledFunc().then(() => assert.step('throttle_observed_invoke_1'));
+ await nextTick();
+ assert.verifySteps(
+ ['throttle_observed_invoke_1'],
+ "inner function of throttle should have been invoked on 1st call (immediate return)"
+ );
+
+ throttledFunc()
+ .then(() => {
+ throw new Error("2nd throttle call should not be resolved (should have been canceled from clear)");
+ })
+ .catch(error => {
+ assert.ok(
+ error instanceof ThrottleCanceledError,
+ "Should generate a Throttle canceled error (from `.clear()`)"
+ );
+ assert.step('throttle_canceled');
+ });
+ await nextTick();
+ assert.verifySteps(
+ [],
+ "inner function of throttle should not have been immediately invoked after 2nd call immediately after 1st call (throttled with 1s internal clock)"
+ );
+
+ await this.env.testUtils.advanceTime(500);
+ assert.verifySteps(
+ [],
+ "inner function of throttle should not have been invoked after 500ms of 2nd call (throttled with 1s internal clock)"
+ );
+
+ throttledFunc.clear();
+ await nextTick();
+ assert.verifySteps(
+ ['throttle_canceled'],
+ "2nd throttle function call should have been canceled (from `.clear()`)"
+ );
+
+ throttledFunc().then(() => assert.step('throttle_observed_invoke_3'));
+ await nextTick();
+ assert.verifySteps(
+ ['throttle_observed_invoke_3'],
+ "3rd throttle function call should have invoke inner function immediately (`.clear()` flushes throttle)"
+ );
+});
+
+});
+});
+});
+
+});
diff --git a/addons/mail/static/src/utils/timer/timer.js b/addons/mail/static/src/utils/timer/timer.js
new file mode 100644
index 00000000..56d7f58e
--- /dev/null
+++ b/addons/mail/static/src/utils/timer/timer.js
@@ -0,0 +1,165 @@
+odoo.define('mail/static/src/utils/timer/timer.js', function (require) {
+'use strict';
+
+const { makeDeferred } = require('mail/static/src/utils/deferred/deferred.js');
+
+//------------------------------------------------------------------------------
+// Errors
+//------------------------------------------------------------------------------
+
+/**
+ * List of Timer errors.
+ */
+
+ /**
+ * Error when timer has been cleared with `.clear()` or `.reset()`. Used to
+ * let know caller of timer that the countdown has been aborted, which
+ * means the inner function will not be called. Usually caller should just
+ * accept it and kindly treated this error as a polite warning.
+ */
+ class TimerClearedError extends Error {
+ /**
+ * @override
+ */
+ constructor(timerId, ...args) {
+ super(...args);
+ this.name = 'TimerClearedError';
+ this.timerId = timerId;
+ }
+}
+
+//------------------------------------------------------------------------------
+// Private
+//------------------------------------------------------------------------------
+
+/**
+ * This class creates a timer which, when times out, calls a function.
+ * Note that the timer is not started on initialization (@see start method).
+ */
+class Timer {
+
+ /**
+ * @param {Object} env the OWL env
+ * @param {function} onTimeout
+ * @param {integer} duration
+ * @param {Object} [param3={}]
+ * @param {boolean} [param3.silentCancelationErrors=true] if unset, caller
+ * of timer will observe some errors that come from current timer calls
+ * that has been cleared with `.clear()` or `.reset()`.
+ * @see TimerClearedError for when timer has been aborted from `.clear()`
+ * or `.reset()`.
+ */
+ constructor(env, onTimeout, duration, { silentCancelationErrors = true } = {}) {
+ this.env = env;
+ /**
+ * Determine whether the timer has a pending timeout.
+ */
+ this.isRunning = false;
+ /**
+ * Duration, in milliseconds, until timer times out and calls the
+ * timeout function.
+ */
+ this._duration = duration;
+ /**
+ * Determine whether the caller of timer `.start()` and `.reset()`
+ * should observe cancelation errors from `.clear()` or `.reset()`.
+ */
+ this._hasSilentCancelationErrors = silentCancelationErrors;
+ /**
+ * The function that is called when the timer times out.
+ */
+ this._onTimeout = onTimeout;
+ /**
+ * Deferred of a currently pending invocation to inner function on
+ * timeout.
+ */
+ this._timeoutDeferred = undefined;
+ /**
+ * Internal reference of `setTimeout()` that is used to invoke function
+ * when timer times out. Useful to clear it when timer is cleared/reset.
+ */
+ this._timeoutId = undefined;
+ }
+
+ //--------------------------------------------------------------------------
+ // Public
+ //--------------------------------------------------------------------------
+
+ /**
+ * Clear the timer, which basically sets the state of timer as if it was
+ * just instantiated, without being started. This function makes sense only
+ * when this timer is running.
+ */
+ clear() {
+ this.env.browser.clearTimeout(this._timeoutId);
+ this.isRunning = false;
+ if (!this._timeoutDeferred) {
+ return;
+ }
+ this._timeoutDeferred.reject(new TimerClearedError(this.id));
+ }
+
+ /**
+ * Reset the timer, i.e. the pending timeout is refreshed with initial
+ * duration. This function makes sense only when this timer is running.
+ */
+ async reset() {
+ this.clear();
+ await this.start();
+ }
+
+ /**
+ * Starts the timer, i.e. after a certain duration, it times out and calls
+ * a function back. This function makes sense only when this timer is not
+ * yet running.
+ *
+ * @throws {Error} in case the timer is already running.
+ */
+ async start() {
+ if (this.isRunning) {
+ throw new Error("Cannot start a timer that is currently running.");
+ }
+ this.isRunning = true;
+ const timeoutDeferred = makeDeferred();
+ this._timeoutDeferred = timeoutDeferred;
+ const timeoutId = this.env.browser.setTimeout(
+ () => {
+ this.isRunning = false;
+ timeoutDeferred.resolve(this._onTimeout());
+ },
+ this._duration
+ );
+ this._timeoutId = timeoutId;
+ let result;
+ try {
+ result = await timeoutDeferred;
+ } catch (error) {
+ if (
+ !this._hasSilentCancelationErrors ||
+ !(error instanceof TimerClearedError) ||
+ error.timerId !== this.id
+ ) {
+ // This branching should never happens.
+ // Still defined in case of programming error.
+ throw error;
+ }
+ } finally {
+ this.env.browser.clearTimeout(timeoutId);
+ this._timeoutDeferred = undefined;
+ this.isRunning = false;
+ }
+ return result;
+ }
+
+}
+
+/**
+ * Make external timer errors accessible from timer class.
+ */
+Object.assign(Timer, {
+ TimerClearedError,
+});
+
+return Timer;
+
+});
diff --git a/addons/mail/static/src/utils/timer/timer_tests.js b/addons/mail/static/src/utils/timer/timer_tests.js
new file mode 100644
index 00000000..e2d33e91
--- /dev/null
+++ b/addons/mail/static/src/utils/timer/timer_tests.js
@@ -0,0 +1,427 @@
+odoo.define('mail/static/src/utils/timer/timer_tests.js', function (require) {
+'use strict';
+
+const { afterEach, beforeEach, nextTick, start } = require('mail/static/src/utils/test_utils.js');
+const Timer = require('mail/static/src/utils/timer/timer.js');
+
+const { TimerClearedError } = Timer;
+
+QUnit.module('mail', {}, function () {
+QUnit.module('utils', {}, function () {
+QUnit.module('timer', {}, function () {
+QUnit.module('timer_tests.js', {
+ beforeEach() {
+ beforeEach(this);
+ this.timers = [];
+
+ this.start = async (params) => {
+ const { env, widget } = await start(Object.assign({}, params, {
+ data: this.data,
+ }));
+ this.env = env;
+ this.widget = widget;
+ };
+ },
+ afterEach() {
+ // Important: tests should cleanly intercept cancelation errors that
+ // may result from this teardown.
+ for (const timer of this.timers) {
+ timer.clear();
+ }
+ afterEach(this);
+ },
+});
+
+QUnit.test('timer does not timeout on initialization', async function (assert) {
+ assert.expect(3);
+
+ await this.start({
+ hasTimeControl: true,
+ });
+
+ let hasTimedOut = false;
+ this.timers.push(
+ new Timer(
+ this.env,
+ () => hasTimedOut = true,
+ 0
+ )
+ );
+
+ assert.notOk(
+ hasTimedOut,
+ "timer should not have timed out on immediate initialization"
+ );
+
+ await this.env.testUtils.advanceTime(0);
+ assert.notOk(
+ hasTimedOut,
+ "timer should not have timed out from initialization after 0ms"
+ );
+
+ await this.env.testUtils.advanceTime(1000 * 1000);
+ assert.notOk(
+ hasTimedOut,
+ "timer should not have timed out from initialization after 1000s"
+ );
+});
+
+QUnit.test('timer start (duration: 0ms)', async function (assert) {
+ assert.expect(2);
+
+ await this.start({
+ hasTimeControl: true,
+ });
+
+ let hasTimedOut = false;
+ this.timers.push(
+ new Timer(
+ this.env,
+ () => hasTimedOut = true,
+ 0
+ )
+ );
+
+ this.timers[0].start();
+ assert.notOk(
+ hasTimedOut,
+ "timer should not have timed out immediately after start"
+ );
+
+ await this.env.testUtils.advanceTime(0);
+ assert.ok(
+ hasTimedOut,
+ "timer should have timed out on start after 0ms"
+ );
+});
+
+QUnit.test('timer start observe termination (duration: 0ms)', async function (assert) {
+ assert.expect(6);
+
+ await this.start({
+ hasTimeControl: true,
+ });
+
+ let hasTimedOut = false;
+ this.timers.push(
+ new Timer(
+ this.env,
+ () => {
+ hasTimedOut = true;
+ return 'timeout_result';
+ },
+ 0
+ )
+ );
+
+ this.timers[0].start()
+ .then(result => {
+ assert.strictEqual(
+ result,
+ 'timeout_result',
+ "value returned by start should be value returned by function on timeout"
+ );
+ assert.step('timeout');
+ });
+ await nextTick();
+ assert.notOk(
+ hasTimedOut,
+ "timer should not have timed out immediately after start"
+ );
+ assert.verifySteps(
+ [],
+ "timer.start() should not have yet observed timeout"
+ );
+
+ await this.env.testUtils.advanceTime(0);
+ assert.ok(
+ hasTimedOut,
+ "timer should have timed out on start after 0ms"
+ );
+ assert.verifySteps(
+ ['timeout'],
+ "timer.start() should have observed timeout after 0ms"
+ );
+});
+
+QUnit.test('timer start (duration: 1000s)', async function (assert) {
+ assert.expect(5);
+
+ await this.start({
+ hasTimeControl: true,
+ });
+
+ let hasTimedOut = false;
+ this.timers.push(
+ new Timer(
+ this.env,
+ () => hasTimedOut = true,
+ 1000 * 1000
+ )
+ );
+
+ this.timers[0].start();
+ assert.notOk(
+ hasTimedOut,
+ "timer should not have timed out immediately after start"
+ );
+
+ await this.env.testUtils.advanceTime(0);
+ assert.notOk(
+ hasTimedOut,
+ "timer should not have timed out on start after 0ms"
+ );
+
+ await this.env.testUtils.advanceTime(1000);
+ assert.notOk(
+ hasTimedOut,
+ "timer should not have timed out on start after 1000ms"
+ );
+
+ await this.env.testUtils.advanceTime(998 * 1000 + 999);
+ assert.notOk(
+ hasTimedOut,
+ "timer should not have timed out on start after 9999ms"
+ );
+
+ await this.env.testUtils.advanceTime(1);
+ assert.ok(
+ hasTimedOut,
+ "timer should have timed out on start after 10s"
+ );
+});
+
+QUnit.test('[no cancelation intercept] timer start then immediate clear (duration: 0ms)', async function (assert) {
+ assert.expect(4);
+
+ await this.start({
+ hasTimeControl: true,
+ });
+
+ let hasTimedOut = false;
+ this.timers.push(
+ new Timer(
+ this.env,
+ () => hasTimedOut = true,
+ 0
+ )
+ );
+
+ this.timers[0].start();
+ assert.notOk(
+ hasTimedOut,
+ "timer should not have timed out immediately after start"
+ );
+
+ this.timers[0].clear();
+ assert.notOk(
+ hasTimedOut,
+ "timer should not have timed out immediately after start and clear"
+ );
+
+ await this.env.testUtils.advanceTime(0);
+ assert.notOk(
+ hasTimedOut,
+ "timer should not have timed out after 0ms of clear"
+ );
+
+ await this.env.testUtils.advanceTime(1000);
+ assert.notOk(
+ hasTimedOut,
+ "timer should not have timed out after 1s of clear"
+ );
+});
+
+QUnit.test('[no cancelation intercept] timer start then clear before timeout (duration: 1000ms)', async function (assert) {
+ assert.expect(4);
+
+ await this.start({
+ hasTimeControl: true,
+ });
+
+ let hasTimedOut = false;
+ this.timers.push(
+ new Timer(
+ this.env,
+ () => hasTimedOut = true,
+ 1000
+ )
+ );
+
+ this.timers[0].start();
+ assert.notOk(
+ hasTimedOut,
+ "timer should not have timed out immediately after start"
+ );
+
+ await this.env.testUtils.advanceTime(999);
+ assert.notOk(
+ hasTimedOut,
+ "timer should not have timed out immediately after 999ms of start"
+ );
+
+ this.timers[0].clear();
+ await this.env.testUtils.advanceTime(1);
+ assert.notOk(
+ hasTimedOut,
+ "timer should not have timed out after 1ms of clear that happens 999ms after start (globally 1s await)"
+ );
+
+ await this.env.testUtils.advanceTime(1000);
+ assert.notOk(
+ hasTimedOut,
+ "timer should not have timed out after 1001ms after clear (timer fully cleared)"
+ );
+});
+
+QUnit.test('[no cancelation intercept] timer start then reset before timeout (duration: 1000ms)', async function (assert) {
+ assert.expect(5);
+
+ await this.start({
+ hasTimeControl: true,
+ });
+
+ let hasTimedOut = false;
+ this.timers.push(
+ new Timer(
+ this.env,
+ () => hasTimedOut = true,
+ 1000
+ )
+ );
+
+ this.timers[0].start();
+ assert.notOk(
+ hasTimedOut,
+ "timer should not have timed out immediately after start"
+ );
+
+ await this.env.testUtils.advanceTime(999);
+ assert.notOk(
+ hasTimedOut,
+ "timer should not have timed out after 999ms of start"
+ );
+
+ this.timers[0].reset();
+ await this.env.testUtils.advanceTime(1);
+ assert.notOk(
+ hasTimedOut,
+ "timer should not have timed out after 1ms of reset which happens 999ms after start"
+ );
+
+ await this.env.testUtils.advanceTime(998);
+ assert.notOk(
+ hasTimedOut,
+ "timer should not have timed out after 999ms of reset"
+ );
+
+ await this.env.testUtils.advanceTime(1);
+ assert.ok(
+ hasTimedOut,
+ "timer should not have timed out after 1s of reset"
+ );
+});
+
+QUnit.test('[with cancelation intercept] timer start then immediate clear (duration: 0ms)', async function (assert) {
+ assert.expect(5);
+
+ await this.start({
+ hasTimeControl: true,
+ });
+
+ let hasTimedOut = false;
+ this.timers.push(
+ new Timer(
+ this.env,
+ () => hasTimedOut = true,
+ 0,
+ { silentCancelationErrors: false }
+ )
+ );
+
+ this.timers[0].start()
+ .then(() => {
+ throw new Error("timer.start() should not be resolved (should have been canceled by clear)");
+ })
+ .catch(error => {
+ assert.ok(
+ error instanceof TimerClearedError,
+ "Should generate a Timer cleared error (from `.clear()`)"
+ );
+ assert.step('timer_cleared');
+ });
+ assert.notOk(
+ hasTimedOut,
+ "timer should not have timed out immediately after start"
+ );
+ await nextTick();
+ assert.verifySteps([], "should not have observed cleared timer (timer not yet cleared)");
+
+ this.timers[0].clear();
+ await nextTick();
+ assert.verifySteps(
+ ['timer_cleared'],
+ "timer.start() should have observed it has been cleared"
+ );
+});
+
+QUnit.test('[with cancelation intercept] timer start then immediate reset (duration: 0ms)', async function (assert) {
+ assert.expect(9);
+
+ await this.start({
+ hasTimeControl: true,
+ });
+
+ let hasTimedOut = false;
+ this.timers.push(
+ new Timer(
+ this.env,
+ () => hasTimedOut = true,
+ 0,
+ { silentCancelationErrors: false }
+ )
+ );
+
+ this.timers[0].start()
+ .then(() => {
+ throw new Error("timer.start() should not observe a timeout");
+ })
+ .catch(error => {
+ assert.ok(error instanceof TimerClearedError, "Should generate a Timer cleared error (from `.reset()`)");
+ assert.step('timer_cleared');
+ });
+ assert.notOk(
+ hasTimedOut,
+ "timer should not have timed out immediately after start"
+ );
+ await nextTick();
+ assert.verifySteps([], "should not have observed cleared timer (timer not yet cleared)");
+
+ this.timers[0].reset()
+ .then(() => assert.step('timer_reset_timeout'));
+ await nextTick();
+ assert.verifySteps(
+ ['timer_cleared'],
+ "timer.start() should have observed it has been cleared"
+ );
+ assert.notOk(
+ hasTimedOut,
+ "timer should not have timed out immediately after reset"
+ );
+
+ await this.env.testUtils.advanceTime(0);
+ assert.ok(
+ hasTimedOut,
+ "timer should have timed out after reset timeout"
+ );
+ assert.verifySteps(
+ ['timer_reset_timeout'],
+ "timer.reset() should have observed it has timed out"
+ );
+});
+
+});
+});
+});
+
+});
diff --git a/addons/mail/static/src/utils/utils.js b/addons/mail/static/src/utils/utils.js
new file mode 100644
index 00000000..2cfaa531
--- /dev/null
+++ b/addons/mail/static/src/utils/utils.js
@@ -0,0 +1,193 @@
+odoo.define('mail/static/src/utils/utils.js', function (require) {
+'use strict';
+
+const { delay } = require('web.concurrency');
+const {
+ patch: webUtilsPatch,
+ unaccent,
+ unpatch: webUtilsUnpatch,
+} = require('web.utils');
+
+//------------------------------------------------------------------------------
+// Public
+//------------------------------------------------------------------------------
+
+const classPatchMap = new WeakMap();
+const eventHandledWeakMap = new WeakMap();
+
+/**
+ * Returns the given string after cleaning it. The goal of the clean is to give
+ * more convenient results when comparing it to potential search results, on
+ * which the clean should also be called before comparing them.
+ *
+ * @param {string} searchTerm
+ * @returns {string}
+ */
+function cleanSearchTerm(searchTerm) {
+ return unaccent(searchTerm.toLowerCase());
+}
+
+/**
+ * Executes the provided functions in order, but with a potential delay between
+ * them if they take too much time. This is done in order to avoid blocking the
+ * main thread for too long.
+ *
+ * @param {function[]} functions
+ * @param {integer} [maxTimeFrame=100] time (in ms) until a delay is introduced
+ */
+async function executeGracefully(functions, maxTimeFrame = 100) {
+ let startDate = new Date();
+ for (const func of functions) {
+ if (new Date() - startDate > maxTimeFrame) {
+ await new Promise(resolve => setTimeout(resolve));
+ startDate = new Date();
+ }
+ await func();
+ }
+}
+
+/**
+ * Returns whether the given event has been handled with the given markName.
+ *
+ * @param {Event} ev
+ * @param {string} markName
+ * @returns {boolean}
+ */
+function isEventHandled(ev, markName) {
+ if (!eventHandledWeakMap.get(ev)) {
+ return false;
+ }
+ return eventHandledWeakMap.get(ev).includes(markName);
+}
+
+/**
+ * Marks the given event as handled by the given markName. Useful to allow
+ * handlers in the propagation chain to make a decision based on what has
+ * already been done.
+ *
+ * @param {Event} ev
+ * @param {string} markName
+ */
+function markEventHandled(ev, markName) {
+ if (!eventHandledWeakMap.get(ev)) {
+ eventHandledWeakMap.set(ev, []);
+ }
+ eventHandledWeakMap.get(ev).push(markName);
+}
+
+/**
+ * Wait a task tick, so that anything in micro-task queue that can be processed
+ * is processed.
+ */
+async function nextTick() {
+ await delay(0);
+}
+
+/**
+ * Inspired by web.utils:patch utility function
+ *
+ * @param {Class} Class
+ * @param {string} patchName
+ * @param {Object} patch
+ * @returns {function} unpatch function
+ */
+function patchClassMethods(Class, patchName, patch) {
+ let metadata = classPatchMap.get(Class);
+ if (!metadata) {
+ metadata = {
+ origMethods: {},
+ patches: {},
+ current: []
+ };
+ classPatchMap.set(Class, metadata);
+ }
+ if (metadata.patches[patchName]) {
+ throw new Error(`Patch [${patchName}] already exists`);
+ }
+ metadata.patches[patchName] = patch;
+ applyPatch(Class, patch);
+ metadata.current.push(patchName);
+
+ function applyPatch(Class, patch) {
+ Object.keys(patch).forEach(function (methodName) {
+ const method = patch[methodName];
+ if (typeof method === "function") {
+ const original = Class[methodName];
+ if (!(methodName in metadata.origMethods)) {
+ metadata.origMethods[methodName] = original;
+ }
+ Class[methodName] = function (...args) {
+ const previousSuper = this._super;
+ this._super = original;
+ const res = method.call(this, ...args);
+ this._super = previousSuper;
+ return res;
+ };
+ }
+ });
+ }
+
+ return () => unpatchClassMethods.bind(Class, patchName);
+}
+
+/**
+ * @param {Class} Class
+ * @param {string} patchName
+ * @param {Object} patch
+ * @returns {function} unpatch function
+ */
+function patchInstanceMethods(Class, patchName, patch) {
+ return webUtilsPatch(Class, patchName, patch);
+}
+
+/**
+ * Inspired by web.utils:unpatch utility function
+ *
+ * @param {Class} Class
+ * @param {string} patchName
+ */
+function unpatchClassMethods(Class, patchName) {
+ let metadata = classPatchMap.get(Class);
+ if (!metadata) {
+ return;
+ }
+ classPatchMap.delete(Class);
+
+ // reset to original
+ for (let k in metadata.origMethods) {
+ Class[k] = metadata.origMethods[k];
+ }
+
+ // apply other patches
+ for (let name of metadata.current) {
+ if (name !== patchName) {
+ patchClassMethods(Class, name, metadata.patches[name]);
+ }
+ }
+}
+
+/**
+ * @param {Class} Class
+ * @param {string} patchName
+ */
+function unpatchInstanceMethods(Class, patchName) {
+ return webUtilsUnpatch(Class, patchName);
+}
+
+//------------------------------------------------------------------------------
+// Export
+//------------------------------------------------------------------------------
+
+return {
+ cleanSearchTerm,
+ executeGracefully,
+ isEventHandled,
+ markEventHandled,
+ nextTick,
+ patchClassMethods,
+ patchInstanceMethods,
+ unpatchClassMethods,
+ unpatchInstanceMethods,
+};
+
+});
diff --git a/addons/mail/static/src/variables.scss b/addons/mail/static/src/variables.scss
new file mode 100644
index 00000000..e9bae9ab
--- /dev/null
+++ b/addons/mail/static/src/variables.scss
@@ -0,0 +1,16 @@
+// -----------------------------------------------------------------------------
+// Variables
+// -----------------------------------------------------------------------------
+
+$o-mail-thread-window-zindex: $zindex-modal + 1 !default;
+
+$o-mail-chat-window-header-height: 36px !default;
+$o-mail-chat-window-header-height-mobile: 46px !default;
+$o-mail-discuss-sidebar-active-indicator-margin-right: 10px !default;
+$o-mail-discuss-sidebar-active-indicator-width: 3px !default;
+$o-mail-discuss-sidebar-scrollbar-width: 15px !default;
+
+$o-mail-message-sidebar-width: 50px;
+
+$o-mail-moderation-accept-color: theme-color('success') !default;
+$o-mail-moderation-reject-color: theme-color('danger') !default;
diff --git a/addons/mail/static/src/widgets/common.xml b/addons/mail/static/src/widgets/common.xml
new file mode 100644
index 00000000..9106f96f
--- /dev/null
+++ b/addons/mail/static/src/widgets/common.xml
@@ -0,0 +1,25 @@
+<?xml version="1.0" encoding="UTF-8"?>
+<templates xml:space="preserve">
+
+ <!--
+ AKU FIXME: use mail/static/src/components/partner_im_status_icon/partner_im_status_icon.js component instead
+ @param {string} status
+ -->
+ <t t-name="mail.widgets.UserStatus">
+ <span>
+ <t t-if="status == 'online'">
+ <i class="o_mail_user_status o_user_online fa fa-circle" title="Online" role="img" aria-label="User is online"/>
+ </t>
+ <t t-if="status == 'away'">
+ <i class="fa fa-circle o_mail_user_status o_user_idle" title="Idle" role="img" aria-label="User is idle"/>
+ </t>
+ <t t-if="status == 'offline'">
+ <i class="o_mail_user_status fa fa-circle-o" title="Offline" role="img" aria-label="User is offline"/>
+ </t>
+ <t t-if="status == 'bot'">
+ <i class="o_mail_user_status o_user_online fa fa-heart" title="Bot" role="img" aria-label="User is a bot"/>
+ </t>
+ </span>
+ </t>
+
+</templates>
diff --git a/addons/mail/static/src/widgets/discuss/discuss.js b/addons/mail/static/src/widgets/discuss/discuss.js
new file mode 100644
index 00000000..86f3ba32
--- /dev/null
+++ b/addons/mail/static/src/widgets/discuss/discuss.js
@@ -0,0 +1,397 @@
+odoo.define('mail/static/src/widgets/discuss/discuss.js', function (require) {
+'use strict';
+
+const components = {
+ Discuss: require('mail/static/src/components/discuss/discuss.js'),
+};
+const InvitePartnerDialog = require('mail/static/src/widgets/discuss_invite_partner_dialog/discuss_invite_partner_dialog.js');
+
+const AbstractAction = require('web.AbstractAction');
+const { action_registry, qweb } = require('web.core');
+
+const { Component } = owl;
+
+const DiscussWidget = AbstractAction.extend({
+ template: 'mail.widgets.Discuss',
+ hasControlPanel: true,
+ loadControlPanel: true,
+ withSearchBar: true,
+ searchMenuTypes: ['filter', 'favorite'],
+ /**
+ * @override {web.AbstractAction}
+ * @param {web.ActionManager} parent
+ * @param {Object} action
+ * @param {Object} [action.context]
+ * @param {string} [action.context.active_id]
+ * @param {Object} [action.params]
+ * @param {string} [action.params.default_active_id]
+ * @param {Object} [options={}]
+ */
+ init(parent, action, options={}) {
+ this._super(...arguments);
+
+ // render buttons in control panel
+ this.$buttons = $(qweb.render('mail.widgets.Discuss.DiscussControlButtons'));
+ this.$buttons.find('button').css({ display: 'inline-block' });
+ this.$buttons.on('click', '.o_invite', ev => this._onClickInvite(ev));
+ this.$buttons.on('click', '.o_widget_Discuss_controlPanelButtonMarkAllRead',
+ ev => this._onClickMarkAllAsRead(ev)
+ );
+ this.$buttons.on('click', '.o_mobile_new_channel', ev => this._onClickMobileNewChannel(ev));
+ this.$buttons.on('click', '.o_mobile_new_message', ev => this._onClickMobileNewMessage(ev));
+ this.$buttons.on('click', '.o_unstar_all', ev => this._onClickUnstarAll(ev));
+ this.$buttons.on('click', '.o_widget_Discuss_controlPanelButtonSelectAll', ev => this._onClickSelectAll(ev));
+ this.$buttons.on('click', '.o_widget_Discuss_controlPanelButtonUnselectAll', ev => this._onClickUnselectAll(ev));
+ this.$buttons.on('click', '.o_widget_Discuss_controlPanelButtonModeration.o-accept', ev => this._onClickModerationAccept(ev));
+ this.$buttons.on('click', '.o_widget_Discuss_controlPanelButtonModeration.o-discard', ev => this._onClickModerationDiscard(ev));
+ this.$buttons.on('click', '.o_widget_Discuss_controlPanelButtonModeration.o-reject', ev => this._onClickModerationReject(ev));
+
+ // control panel attributes
+ this.action = action;
+ this.actionManager = parent;
+ this.searchModelConfig.modelName = 'mail.message';
+ this.discuss = undefined;
+ this.options = options;
+
+ this.component = undefined;
+
+ this._lastPushStateActiveThread = null;
+ },
+ /**
+ * @override
+ */
+ async willStart() {
+ await this._super(...arguments);
+ this.env = Component.env;
+ await this.env.messagingCreatedPromise;
+ const initActiveId = this.options.active_id ||
+ (this.action.context && this.action.context.active_id) ||
+ (this.action.params && this.action.params.default_active_id) ||
+ 'mail.box_inbox';
+ this.discuss = this.env.messaging.discuss;
+ this.discuss.update({ initActiveId });
+ },
+ /**
+ * @override {web.AbstractAction}
+ */
+ destroy() {
+ if (this.component) {
+ this.component.destroy();
+ this.component = undefined;
+ }
+ if (this.$buttons) {
+ this.$buttons.off().remove();
+ }
+ this._super(...arguments);
+ },
+ /**
+ * @override {web.AbstractAction}
+ */
+ on_attach_callback() {
+ this._super(...arguments);
+ if (this.component) {
+ // prevent twice call to on_attach_callback (FIXME)
+ return;
+ }
+ const DiscussComponent = components.Discuss;
+ this.component = new DiscussComponent();
+ this._pushStateActionManagerEventListener = ev => {
+ ev.stopPropagation();
+ if (this._lastPushStateActiveThread === this.discuss.thread) {
+ return;
+ }
+ this._pushStateActionManager();
+ this._lastPushStateActiveThread = this.discuss.thread;
+ };
+ this._showRainbowManEventListener = ev => {
+ ev.stopPropagation();
+ this._showRainbowMan();
+ };
+ this._updateControlPanelEventListener = ev => {
+ ev.stopPropagation();
+ this._updateControlPanel();
+ };
+
+ this.el.addEventListener(
+ 'o-push-state-action-manager',
+ this._pushStateActionManagerEventListener
+ );
+ this.el.addEventListener(
+ 'o-show-rainbow-man',
+ this._showRainbowManEventListener
+ );
+ this.el.addEventListener(
+ 'o-update-control-panel',
+ this._updateControlPanelEventListener
+ );
+ return this.component.mount(this.el);
+ },
+ /**
+ * @override {web.AbstractAction}
+ */
+ on_detach_callback() {
+ this._super(...arguments);
+ if (this.component) {
+ this.component.destroy();
+ }
+ this.component = undefined;
+ this.el.removeEventListener(
+ 'o-push-state-action-manager',
+ this._pushStateActionManagerEventListener
+ );
+ this.el.removeEventListener(
+ 'o-show-rainbow-man',
+ this._showRainbowManEventListener
+ );
+ this.el.removeEventListener(
+ 'o-update-control-panel',
+ this._updateControlPanelEventListener
+ );
+ },
+
+ //--------------------------------------------------------------------------
+ // Private
+ //--------------------------------------------------------------------------
+
+ /**
+ * @private
+ */
+ _pushStateActionManager() {
+ this.actionManager.do_push_state({
+ action: this.action.id,
+ active_id: this.discuss.activeId,
+ });
+ },
+ /**
+ * @private
+ * @returns {boolean}
+ */
+ _shouldHaveInviteButton() {
+ return (
+ this.discuss.thread &&
+ this.discuss.thread.channel_type === 'channel'
+ );
+ },
+ /**
+ * @private
+ */
+ _showRainbowMan() {
+ this.trigger_up('show_effect', {
+ message: this.env._t("Congratulations, your inbox is empty!"),
+ type: 'rainbow_man',
+ });
+ },
+ /**
+ * @private
+ */
+ _updateControlPanel() {
+ // Invite
+ if (this._shouldHaveInviteButton()) {
+ this.$buttons.find('.o_invite').removeClass('o_hidden');
+ } else {
+ this.$buttons.find('.o_invite').addClass('o_hidden');
+ }
+ // Mark All Read
+ if (
+ this.discuss.threadView &&
+ this.discuss.thread &&
+ this.discuss.thread === this.env.messaging.inbox
+ ) {
+ this.$buttons
+ .find('.o_widget_Discuss_controlPanelButtonMarkAllRead')
+ .removeClass('o_hidden')
+ .prop('disabled', this.discuss.threadView.messages.length === 0);
+ } else {
+ this.$buttons
+ .find('.o_widget_Discuss_controlPanelButtonMarkAllRead')
+ .addClass('o_hidden');
+ }
+ // Unstar All
+ if (
+ this.discuss.threadView &&
+ this.discuss.thread &&
+ this.discuss.thread === this.env.messaging.starred
+ ) {
+ this.$buttons
+ .find('.o_unstar_all')
+ .removeClass('o_hidden')
+ .prop('disabled', this.discuss.threadView.messages.length === 0);
+ } else {
+ this.$buttons
+ .find('.o_unstar_all')
+ .addClass('o_hidden');
+ }
+ // Mobile: Add channel
+ if (
+ this.env.messaging.device.isMobile &&
+ this.discuss.activeMobileNavbarTabId === 'channel'
+ ) {
+ this.$buttons
+ .find('.o_mobile_new_channel')
+ .removeClass('o_hidden');
+ } else {
+ this.$buttons
+ .find('.o_mobile_new_channel')
+ .addClass('o_hidden');
+ }
+ // Mobile: Add message
+ if (
+ this.env.messaging.device.isMobile &&
+ this.discuss.activeMobileNavbarTabId === 'chat'
+ ) {
+ this.$buttons
+ .find('.o_mobile_new_message')
+ .removeClass('o_hidden');
+ } else {
+ this.$buttons
+ .find('.o_mobile_new_message')
+ .addClass('o_hidden');
+ }
+ // Select All & Unselect All
+ const $selectAll = this.$buttons.find('.o_widget_Discuss_controlPanelButtonSelectAll');
+ const $unselectAll = this.$buttons.find('.o_widget_Discuss_controlPanelButtonUnselectAll');
+
+ if (
+ this.discuss.threadView &&
+ (
+ this.discuss.threadView.checkedMessages.length > 0 ||
+ this.discuss.threadView.uncheckedMessages.length > 0
+ )
+ ) {
+ $selectAll.removeClass('o_hidden');
+ $selectAll.toggleClass('disabled', this.discuss.threadView.uncheckedMessages.length === 0);
+ $unselectAll.removeClass('o_hidden');
+ $unselectAll.toggleClass('disabled', this.discuss.threadView.checkedMessages.length === 0);
+ } else {
+ $selectAll.addClass('o_hidden');
+ $selectAll.addClass('disabled');
+ $unselectAll.addClass('o_hidden');
+ $unselectAll.addClass('disabled');
+ }
+
+ // Moderation Actions
+ const $moderationButtons = this.$buttons.find('.o_widget_Discuss_controlPanelButtonModeration');
+ if (
+ this.discuss.threadView &&
+ this.discuss.threadView.checkedMessages.length > 0 &&
+ this.discuss.threadView.checkedMessages.filter(
+ message => !message.isModeratedByCurrentPartner
+ ).length === 0
+ ) {
+ $moderationButtons.removeClass('o_hidden');
+ } else {
+ $moderationButtons.addClass('o_hidden');
+ }
+
+ let title;
+ if (this.env.messaging.device.isMobile || !this.discuss.thread) {
+ title = this.env._t("Discuss");
+ } else {
+ const prefix =
+ this.discuss.thread.channel_type === 'channel' &&
+ this.discuss.thread.public !== 'private'
+ ? '#'
+ : '';
+ title = `${prefix}${this.discuss.thread.displayName}`;
+ }
+
+ this.updateControlPanel({
+ cp_content: {
+ $buttons: this.$buttons,
+ },
+ title,
+ });
+ },
+
+ //--------------------------------------------------------------------------
+ // Handlers
+ //--------------------------------------------------------------------------
+
+ /**
+ * @private
+ */
+ _onClickInvite() {
+ new InvitePartnerDialog(this, {
+ activeThreadLocalId: this.discuss.thread.localId,
+ messagingEnv: this.env,
+ }).open();
+ },
+ /**
+ * @private
+ */
+ _onClickMarkAllAsRead() {
+ this.env.models['mail.message'].markAllAsRead(this.domain);
+ },
+ /**
+ * @private
+ */
+ _onClickMobileNewChannel() {
+ this.discuss.update({ isAddingChannel: true });
+ },
+ /**
+ * @private
+ */
+ _onClickMobileNewMessage() {
+ this.discuss.update({ isAddingChat: true });
+ },
+ /**
+ * @private
+ */
+ _onClickModerationAccept() {
+ this.env.models['mail.message'].moderate(
+ this.discuss.threadView.checkedMessages,
+ 'accept'
+ );
+ },
+ /**
+ * @private
+ */
+ _onClickModerationDiscard() {
+ this.discuss.update({ hasModerationDiscardDialog: true });
+ },
+ /**
+ * @private
+ */
+ _onClickModerationReject() {
+ this.discuss.update({ hasModerationRejectDialog: true });
+ },
+ /**
+ * @private
+ */
+ _onClickSelectAll() {
+ this.env.models['mail.message'].checkAll(
+ this.discuss.thread,
+ this.discuss.stringifiedDomain
+ );
+ },
+ /**
+ * @private
+ */
+ _onClickUnselectAll() {
+ this.env.models['mail.message'].uncheckAll(
+ this.discuss.thread,
+ this.discuss.stringifiedDomain
+ );
+ },
+ /**
+ * @private
+ */
+ _onClickUnstarAll() {
+ this.env.models['mail.message'].unstarAll();
+ },
+ /**
+ * @private
+ * @param {Object} searchQuery
+ */
+ _onSearch: function (searchQuery) {
+ this.discuss.update({
+ stringifiedDomain: JSON.stringify(searchQuery.domain),
+ });
+ },
+});
+
+action_registry.add('mail.widgets.discuss', DiscussWidget);
+
+return DiscussWidget;
+
+});
diff --git a/addons/mail/static/src/widgets/discuss/discuss.scss b/addons/mail/static/src/widgets/discuss/discuss.scss
new file mode 100644
index 00000000..2bed9b7c
--- /dev/null
+++ b/addons/mail/static/src/widgets/discuss/discuss.scss
@@ -0,0 +1,36 @@
+// ------------------------------------------------------------------
+// Layout
+// ------------------------------------------------------------------
+
+.o_widget_Discuss {
+ display: flex;
+ flex: 0 0 100%;
+ flex-flow: column;
+ height: 100%;
+}
+
+// ------------------------------------------------------------------
+// Style
+// ------------------------------------------------------------------
+
+.o_widget_Discuss {
+
+ .o_control_panel {
+ border-bottom: 0; // cancel default border, so that we only apply it on top of discuss content
+ }
+
+ .o_Discuss_content {
+ border-top: 1px solid darken($o-control-panel-background-color, 20%);
+ }
+
+ .o_Discuss.o-mobile {
+
+ &:not(.o-adding-item) {
+ border-top: 1px solid darken($o-control-panel-background-color, 20%);
+ }
+
+ &.o-adding-item .o_Discuss_mobileAddItemHeader {
+ border-bottom: 1px solid darken($o-control-panel-background-color, 20%);
+ }
+ }
+}
diff --git a/addons/mail/static/src/widgets/discuss/discuss.xml b/addons/mail/static/src/widgets/discuss/discuss.xml
new file mode 100644
index 00000000..f8e6de37
--- /dev/null
+++ b/addons/mail/static/src/widgets/discuss/discuss.xml
@@ -0,0 +1,27 @@
+<?xml version="1.0" encoding="UTF-8"?>
+<templates xml:space="preserve">
+
+ <!--
+ @param {mail/static/src/widgets/discuss/discuss.js} widget
+ -->
+ <t t-name="mail.widgets.Discuss">
+ <div class="o_widget_Discuss"/>
+ </t>
+
+ <!-- @param {boolean} isMobile -->
+ <t t-name="mail.widgets.Discuss.DiscussControlButtons">
+ <div>
+ <button class="o_widget_Discuss_controlPanelButtonInvite o_invite o_hidden btn btn-primary" type="button" title="Invite people">Invite</button>
+ <button class="o_widget_Discuss_controlPanelButtonMarkAllRead o_hidden btn btn-secondary" type="button" title="Mark all as read">Mark all read</button>
+ <button class="o_widget_Discuss_controlPanelButtonUnstarAll o_unstar_all o_hidden btn btn-secondary" type="button" title="Unstar all messages">Unstar all</button>
+ <button class="o_widget_Discuss_controlPanelButtonMobileNewMessage o_mobile_new_message o_hidden btn btn-secondary" type="button" title="New Message">New Message</button>
+ <button class="o_widget_Discuss_controlPanelButtonMobileNewChannel o_mobile_new_channel o_hidden btn btn-secondary" title="New Channel" type="button">New Channel</button>
+ <button class="o_widget_Discuss_controlPanelButtonSelectAll btn btn-secondary o_hidden" title="Select all messages">Select All</button>
+ <button class="o_widget_Discuss_controlPanelButtonUnselectAll btn btn-secondary o_hidden" title="Unselect all messages">Unselect All</button>
+ <button class="o_widget_Discuss_controlPanelButtonModeration btn btn-secondary o_hidden o-accept" title="Accept selected messages">Accept</button>
+ <button class="o_widget_Discuss_controlPanelButtonModeration btn btn-secondary o_hidden o-reject" title="Reject selected messages">Reject</button>
+ <button class="o_widget_Discuss_controlPanelButtonModeration btn btn-secondary o_hidden o-discard" title="Discard selected messages">Discard</button>
+ </div>
+ </t>
+
+</templates>
diff --git a/addons/mail/static/src/widgets/discuss_invite_partner_dialog/discuss_invite_partner_dialog.js b/addons/mail/static/src/widgets/discuss_invite_partner_dialog/discuss_invite_partner_dialog.js
new file mode 100644
index 00000000..6d9a051b
--- /dev/null
+++ b/addons/mail/static/src/widgets/discuss_invite_partner_dialog/discuss_invite_partner_dialog.js
@@ -0,0 +1,124 @@
+odoo.define('mail/static/src/widgets/discuss_invite_partner_dialog/discuss_invite_partner_dialog.js', function (require) {
+'use strict';
+
+const core = require('web.core');
+const Dialog = require('web.Dialog');
+
+const _lt = core._lt;
+const QWeb = core.qweb;
+
+/**
+ * Widget : Invite People to Channel Dialog
+ *
+ * Popup containing a 'many2many_tags' custom input to select multiple partners.
+ * Searches user according to the input, and triggers event when selection is
+ * validated.
+ */
+const PartnerInviteDialog = Dialog.extend({
+ dialog_title: _lt("Invite people"),
+ template: 'mail.widgets.DiscussInvitePartnerDialog',
+ /**
+ * @override {web.Dialog}
+ * @param {mail/static/src/widgets/discuss/discuss.js} parent
+ * @param {Object} param1
+ * @param {string} param1.activeThreadLocalId
+ * @param {Object} param1.messagingEnv
+ * @param {Object} param1.messagingEnv.store
+ */
+ init(parent, { activeThreadLocalId, messagingEnv }) {
+ const env = messagingEnv;
+ const channel = env.models['mail.thread'].get(activeThreadLocalId);
+ this.channelId = channel.id;
+ this.env = env;
+ this._super(parent, {
+ title: _.str.sprintf(this.env._t("Invite people to #%s"), owl.utils.escape(channel.displayName)),
+ size: 'medium',
+ buttons: [{
+ text: this.env._t("Invite"),
+ close: true,
+ classes: 'btn-primary',
+ click: ev => this._invite(ev),
+ }],
+ });
+ },
+ /**
+ * @override {web.Dialog}
+ * @returns {Promise}
+ */
+ start() {
+ this.$input = this.$('.o_input');
+ this.$input.select2({
+ width: '100%',
+ allowClear: true,
+ multiple: true,
+ formatResult: item => {
+ let status;
+ // TODO FIXME fix this, why do we even have an old widget here
+ if (item.id === 'odoobot') {
+ status = 'bot';
+ } else {
+ const partner = this.env.models['mail.partner'].findFromIdentifyingData({
+ id: item.id,
+ });
+ status = partner.im_status;
+ }
+ const $status = QWeb.render('mail.widgets.UserStatus', { status });
+ return $('<span>').text(item.text).prepend($status);
+ },
+ query: query => {
+ this.env.models['mail.partner'].imSearch({
+ callback: partners => {
+ let results = partners.map(partner => {
+ return {
+ id: partner.id,
+ label: partner.nameOrDisplayName,
+ text: partner.nameOrDisplayName,
+ value: partner.nameOrDisplayName,
+ };
+ });
+ results = _.sortBy(results, 'label');
+ query.callback({ results });
+ },
+ keyword: query.term,
+ limit: 20,
+ });
+ }
+ });
+ return this._super(...arguments);
+ },
+
+ //--------------------------------------------------------------------------
+ // Private
+ //--------------------------------------------------------------------------
+
+ /**
+ * @private
+ */
+ async _invite() {
+ const data = this.$input.select2('data');
+ if (data.length === 0) {
+ return;
+ }
+ await this._rpc({
+ model: 'mail.channel',
+ method: 'channel_invite',
+ args: [this.channelId],
+ kwargs: {
+ partner_ids: _.pluck(data, 'id')
+ },
+ });
+ const names = _.escape(_.pluck(data, 'text').join(', '));
+ const notification = _.str.sprintf(
+ this.env._t("You added <b>%s</b> to the conversation."),
+ names
+ );
+ this.env.services['notification'].notify({
+ message: notification,
+ type: 'warning',
+ });
+ },
+});
+
+return PartnerInviteDialog;
+
+});
diff --git a/addons/mail/static/src/widgets/discuss_invite_partner_dialog/discuss_invite_partner_dialog.xml b/addons/mail/static/src/widgets/discuss_invite_partner_dialog/discuss_invite_partner_dialog.xml
new file mode 100644
index 00000000..82553476
--- /dev/null
+++ b/addons/mail/static/src/widgets/discuss_invite_partner_dialog/discuss_invite_partner_dialog.xml
@@ -0,0 +1,13 @@
+<?xml version="1.0" encoding="UTF-8"?>
+<templates xml:space="preserve">
+
+ <!--
+ @param {mail/static/src/widgets/discuss_invite_partner_dialog/discuss_invite_partner_dialog.js} widget
+ -->
+ <t t-name="mail.widgets.DiscussInvitePartnerDialog">
+ <div>
+ <input class="o_dialog o_input o_invite_partner" type="text"/>
+ </div>
+ </t>
+
+</templates>
diff --git a/addons/mail/static/src/widgets/form_renderer/form_renderer.js b/addons/mail/static/src/widgets/form_renderer/form_renderer.js
new file mode 100644
index 00000000..cf147656
--- /dev/null
+++ b/addons/mail/static/src/widgets/form_renderer/form_renderer.js
@@ -0,0 +1,188 @@
+odoo.define('mail/static/src/widgets/form_renderer/form_renderer.js', function (require) {
+"use strict";
+
+const components = {
+ ChatterContainer: require('mail/static/src/components/chatter_container/chatter_container.js'),
+};
+
+const FormRenderer = require('web.FormRenderer');
+const { ComponentWrapper } = require('web.OwlCompatibility');
+
+class ChatterContainerWrapperComponent extends ComponentWrapper {}
+
+/**
+ * Include the FormRenderer to instantiate the chatter area containing (a
+ * subset of) the mail widgets (mail_thread, mail_followers and mail_activity).
+ */
+FormRenderer.include({
+ /**
+ * @override
+ */
+ init(parent, state, params) {
+ this._super(...arguments);
+ this.chatterFields = params.chatterFields;
+ this.mailFields = params.mailFields;
+ this._chatterContainerComponent = undefined;
+ /**
+ * The target of chatter, if chatter has to be appended to the DOM.
+ * This is set when arch contains `div.oe_chatter`.
+ */
+ this._chatterContainerTarget = undefined;
+ // Do not load chatter in form view dialogs
+ this._isFromFormViewDialog = params.isFromFormViewDialog;
+ },
+ /**
+ * @override
+ */
+ destroy() {
+ this._super(...arguments);
+ this._chatterContainerComponent = undefined;
+ this.off('o_attachments_changed', this);
+ this.off('o_chatter_rendered', this);
+ this.off('o_message_posted', this);
+ owl.Component.env.bus.off('mail.thread:promptAddFollower-closed', this);
+ },
+
+ //--------------------------------------------------------------------------
+ // Private
+ //--------------------------------------------------------------------------
+
+ /**
+ * Returns whether the form renderer has a chatter to display or not.
+ * This is based on arch, which should have `div.oe_chatter`.
+ *
+ * @private
+ * @returns {boolean}
+ */
+ _hasChatter() {
+ return !!this._chatterContainerTarget;
+ },
+ /**
+ * @private
+ */
+ _makeChatterContainerComponent() {
+ const props = this._makeChatterContainerProps();
+ this._chatterContainerComponent = new ChatterContainerWrapperComponent(
+ this,
+ components.ChatterContainer,
+ props
+ );
+ // Not in custom_events because other modules may remove this listener
+ // while attempting to extend them.
+ this.on('o_chatter_rendered', this, ev => this._onChatterRendered(ev));
+ if (this.chatterFields.hasRecordReloadOnMessagePosted) {
+ this.on('o_message_posted', this, ev => {
+ this.trigger_up('reload', { keepChanges: true });
+ });
+ }
+ if (this.chatterFields.hasRecordReloadOnAttachmentsChanged) {
+ this.on('o_attachments_changed', this, ev => this.trigger_up('reload', { keepChanges: true }));
+ }
+ if (this.chatterFields.hasRecordReloadOnFollowersUpdate) {
+ owl.Component.env.bus.on('mail.thread:promptAddFollower-closed', this, ev => this.trigger_up('reload', { keepChanges: true }));
+ }
+ },
+ /**
+ * @private
+ * @returns {Object}
+ */
+ _makeChatterContainerProps() {
+ return {
+ hasActivities: this.chatterFields.hasActivityIds,
+ hasFollowers: this.chatterFields.hasMessageFollowerIds,
+ hasMessageList: this.chatterFields.hasMessageIds,
+ isAttachmentBoxVisibleInitially: this.chatterFields.isAttachmentBoxVisibleInitially,
+ threadId: this.state.res_id,
+ threadModel: this.state.model,
+ };
+ },
+ /**
+ * Create the DOM element that will contain the chatter. This is made in
+ * a separate method so it can be overridden (like in mail_enterprise for
+ * example).
+ *
+ * @private
+ * @returns {jQuery.Element}
+ */
+ _makeChatterContainerTarget() {
+ const $el = $('<div class="o_FormRenderer_chatterContainer"/>');
+ this._chatterContainerTarget = $el[0];
+ return $el;
+ },
+ /**
+ * Mount the chatter
+ *
+ * Force re-mounting chatter component in DOM. This is necessary
+ * because each time `_renderView` is called, it puts old content
+ * in a fragment.
+ *
+ * @private
+ */
+ async _mountChatterContainerComponent() {
+ try {
+ await this._chatterContainerComponent.mount(this._chatterContainerTarget);
+ } catch (error) {
+ if (error.message !== "Mounting operation cancelled") {
+ throw error;
+ }
+ }
+ },
+ /**
+ * @override
+ */
+ _renderNode(node) {
+ if (node.tag === 'div' && node.attrs.class === 'oe_chatter') {
+ if (this._isFromFormViewDialog) {
+ return $('<div/>');
+ }
+ return this._makeChatterContainerTarget();
+ }
+ return this._super(...arguments);
+ },
+ /**
+ * Overrides the function to render the chatter once the form view is
+ * rendered.
+ *
+ * @override
+ */
+ async __renderView() {
+ await this._super(...arguments);
+ if (this._hasChatter()) {
+ if (!this._chatterContainerComponent) {
+ this._makeChatterContainerComponent();
+ } else {
+ await this._updateChatterContainerComponent();
+ }
+ await this._mountChatterContainerComponent();
+ }
+ },
+ /**
+ * @private
+ */
+ async _updateChatterContainerComponent() {
+ const props = this._makeChatterContainerProps();
+ try {
+ await this._chatterContainerComponent.update(props);
+ } catch (error) {
+ if (error.message !== "Mounting operation cancelled") {
+ throw error;
+ }
+ }
+ },
+
+ //--------------------------------------------------------------------------
+ // Handlers
+ //--------------------------------------------------------------------------
+
+ /**
+ * @abstract
+ * @private
+ * @param {OdooEvent} ev
+ * @param {Object} ev.data
+ * @param {mail.attachment[]} ev.data.attachments
+ * @param {mail.thread} ev.data.thread
+ */
+ _onChatterRendered(ev) {},
+});
+
+});
diff --git a/addons/mail/static/src/widgets/form_renderer/form_renderer.scss b/addons/mail/static/src/widgets/form_renderer/form_renderer.scss
new file mode 100644
index 00000000..3092055b
--- /dev/null
+++ b/addons/mail/static/src/widgets/form_renderer/form_renderer.scss
@@ -0,0 +1,17 @@
+// ------------------------------------------------------------------
+// Layout
+// ------------------------------------------------------------------
+
+.o_FormRenderer_chatterContainer {
+ display: flex;
+ flex: 1 1 auto;
+ margin: 0 auto;
+ max-width: $o-form-view-sheet-max-width;
+ padding: map-get($spacers, 3) map-get($spacers, 3) map-get($spacers, 5);
+ width: 100%;
+}
+
+// FIX to hide chatter in dialogs when they are opened from an action returned by python code
+.modal .modal-dialog .o_form_view .o_FormRenderer_chatterContainer {
+ display: none;
+}
diff --git a/addons/mail/static/src/widgets/form_renderer/form_renderer_tests.js b/addons/mail/static/src/widgets/form_renderer/form_renderer_tests.js
new file mode 100644
index 00000000..90cdb169
--- /dev/null
+++ b/addons/mail/static/src/widgets/form_renderer/form_renderer_tests.js
@@ -0,0 +1,982 @@
+odoo.define('mail/static/src/widgets/form_renderer/form_renderer_tests.js', function (require) {
+"use strict";
+
+const { makeDeferred } = require('mail/static/src/utils/deferred/deferred.js');
+const {
+ afterEach,
+ afterNextRender,
+ beforeEach,
+ nextAnimationFrame,
+ start,
+} = require('mail/static/src/utils/test_utils.js');
+
+const config = require('web.config');
+const FormView = require('web.FormView');
+const {
+ dom: { triggerEvent },
+} = require('web.test_utils');
+
+QUnit.module('mail', {}, function () {
+QUnit.module('widgets', {}, function () {
+QUnit.module('form_renderer', {}, function () {
+QUnit.module('form_renderer_tests.js', {
+ beforeEach() {
+ beforeEach(this);
+
+ // FIXME archs could be removed once task-2248306 is done
+ // The mockServer will try to get the list view
+ // of every relational fields present in the main view.
+ // In the case of mail fields, we don't really need them,
+ // but they still need to be defined.
+ this.createView = async (viewParams, ...args) => {
+ await afterNextRender(async () => {
+ const viewArgs = Object.assign(
+ {
+ archs: {
+ 'mail.activity,false,list': '<tree/>',
+ 'mail.followers,false,list': '<tree/>',
+ 'mail.message,false,list': '<tree/>',
+ },
+ },
+ viewParams,
+ );
+ const { afterEvent, env, widget } = await start(viewArgs, ...args);
+ this.afterEvent = afterEvent;
+ this.env = env;
+ this.widget = widget;
+ });
+ };
+ },
+ afterEach() {
+ afterEach(this);
+ },
+});
+
+QUnit.test('[technical] spinner when messaging is not created', async function (assert) {
+ /**
+ * Creation of messaging in env is async due to generation of models being
+ * async. Generation of models is async because it requires parsing of all
+ * JS modules that contain pieces of model definitions.
+ *
+ * Time of having no messaging is very short, almost imperceptible by user
+ * on UI, but the display should not crash during this critical time period.
+ */
+ assert.expect(3);
+
+ this.data['res.partner'].records.push({
+ display_name: "second partner",
+ id: 12,
+ });
+ await this.createView({
+ data: this.data,
+ hasView: true,
+ messagingBeforeCreationDeferred: makeDeferred(), // block messaging creation
+ waitUntilMessagingCondition: 'none',
+ // View params
+ View: FormView,
+ model: 'res.partner',
+ arch: `
+ <form string="Partners">
+ <sheet>
+ <field name="name"/>
+ </sheet>
+ <div class="oe_chatter"></div>
+ </form>
+ `,
+ res_id: 12,
+ });
+ assert.containsOnce(
+ document.body,
+ '.o_ChatterContainer',
+ "should display chatter container even when messaging is not created yet"
+ );
+ assert.containsOnce(
+ document.body,
+ '.o_ChatterContainer_noChatter',
+ "chatter container should not display any chatter when messaging not created"
+ );
+ assert.strictEqual(
+ document.querySelector('.o_ChatterContainer').textContent,
+ "Please wait...",
+ "chatter container should display spinner when messaging not yet created"
+ );
+});
+
+QUnit.test('[technical] keep spinner on transition from messaging non-created to messaging created (and non-initialized)', async function (assert) {
+ /**
+ * Creation of messaging in env is async due to generation of models being
+ * async. Generation of models is async because it requires parsing of all
+ * JS modules that contain pieces of model definitions.
+ *
+ * Time of having no messaging is very short, almost imperceptible by user
+ * on UI, but the display should not crash during this critical time period.
+ */
+ assert.expect(4);
+
+ const messagingBeforeCreationDeferred = makeDeferred();
+ this.data['res.partner'].records.push({
+ display_name: "second partner",
+ id: 12,
+ });
+ await this.createView({
+ data: this.data,
+ hasView: true,
+ messagingBeforeCreationDeferred,
+ async mockRPC(route, args) {
+ const _super = this._super.bind(this, ...arguments); // limitation of class.js
+ if (route === '/mail/init_messaging') {
+ await new Promise(() => {}); // simulate messaging never initialized
+ }
+ return _super();
+ },
+ waitUntilMessagingCondition: 'none',
+ // View params
+ View: FormView,
+ model: 'res.partner',
+ arch: `
+ <form string="Partners">
+ <sheet>
+ <field name="name"/>
+ </sheet>
+ <div class="oe_chatter"></div>
+ </form>
+ `,
+ res_id: 12,
+ });
+ assert.strictEqual(
+ document.querySelector('.o_ChatterContainer').textContent,
+ "Please wait...",
+ "chatter container should display spinner when messaging not yet created"
+ );
+ assert.containsOnce(
+ document.body,
+ '.o_ChatterContainer_noChatter',
+ "chatter container should not display any chatter when messaging not created"
+ );
+
+ // simulate messaging become created
+ messagingBeforeCreationDeferred.resolve();
+ await nextAnimationFrame();
+ assert.strictEqual(
+ document.querySelector('.o_ChatterContainer').textContent,
+ "Please wait...",
+ "chatter container should still display spinner when messaging is created but not initialized"
+ );
+ assert.containsOnce(
+ document.body,
+ '.o_ChatterContainer_noChatter',
+ "chatter container should still not display any chatter when messaging not initialized"
+ );
+});
+
+QUnit.test('spinner when messaging is created but not initialized', async function (assert) {
+ assert.expect(3);
+
+ this.data['res.partner'].records.push({
+ display_name: "second partner",
+ id: 12,
+ });
+ await this.createView({
+ data: this.data,
+ hasView: true,
+ async mockRPC(route, args) {
+ const _super = this._super.bind(this, ...arguments); // limitation of class.js
+ if (route === '/mail/init_messaging') {
+ await new Promise(() => {}); // simulate messaging never initialized
+ }
+ return _super();
+ },
+ waitUntilMessagingCondition: 'created',
+ // View params
+ View: FormView,
+ model: 'res.partner',
+ arch: `
+ <form string="Partners">
+ <sheet>
+ <field name="name"/>
+ </sheet>
+ <div class="oe_chatter"></div>
+ </form>
+ `,
+ res_id: 12,
+ });
+ assert.containsOnce(
+ document.body,
+ '.o_ChatterContainer',
+ "should display chatter container even when messaging is not fully initialized"
+ );
+ assert.containsOnce(
+ document.body,
+ '.o_ChatterContainer_noChatter',
+ "chatter container should not display any chatter when messaging not initialized"
+ );
+ assert.strictEqual(
+ document.querySelector('.o_ChatterContainer').textContent,
+ "Please wait...",
+ "chatter container should display spinner when messaging not yet initialized"
+ );
+});
+
+QUnit.test('transition non-initialized messaging to initialized messaging: display spinner then chatter', async function (assert) {
+ assert.expect(3);
+
+ const messagingBeforeInitializationDeferred = makeDeferred();
+ this.data['res.partner'].records.push({
+ display_name: "second partner",
+ id: 12,
+ });
+ await this.createView({
+ data: this.data,
+ hasView: true,
+ async mockRPC(route, args) {
+ const _super = this._super.bind(this, ...arguments); // limitation of class.js
+ if (route === '/mail/init_messaging') {
+ await messagingBeforeInitializationDeferred;
+ }
+ return _super();
+ },
+ waitUntilMessagingCondition: 'created',
+ // View params
+ View: FormView,
+ model: 'res.partner',
+ arch: `
+ <form string="Partners">
+ <sheet>
+ <field name="name"/>
+ </sheet>
+ <div class="oe_chatter"></div>
+ </form>
+ `,
+ res_id: 12,
+ });
+ assert.strictEqual(
+ document.querySelector('.o_ChatterContainer').textContent,
+ "Please wait...",
+ "chatter container should display spinner when messaging not yet initialized"
+ );
+ assert.containsOnce(
+ document.body,
+ '.o_ChatterContainer_noChatter',
+ "chatter container should not display any chatter when messaging not initialized"
+ );
+
+ // Simulate messaging becomes initialized
+ await afterNextRender(() => messagingBeforeInitializationDeferred.resolve());
+ assert.containsNone(
+ document.body,
+ '.o_ChatterContainer_noChatter',
+ "chatter container should now display chatter when messaging becomes initialized"
+ );
+});
+
+QUnit.test('basic chatter rendering', async function (assert) {
+ assert.expect(1);
+
+ this.data['res.partner'].records.push({ display_name: "second partner", id: 12, });
+ await this.createView({
+ data: this.data,
+ hasView: true,
+ // View params
+ View: FormView,
+ model: 'res.partner',
+ arch: `
+ <form string="Partners">
+ <sheet>
+ <field name="name"/>
+ </sheet>
+ <div class="oe_chatter"></div>
+ </form>
+ `,
+ res_id: 12,
+ });
+ assert.strictEqual(
+ document.querySelectorAll(`.o_Chatter`).length,
+ 1,
+ "there should be a chatter"
+ );
+});
+
+QUnit.test('basic chatter rendering without followers', async function (assert) {
+ assert.expect(6);
+
+ this.data['res.partner'].records.push({ display_name: "second partner", id: 12 });
+ await this.createView({
+ data: this.data,
+ hasView: true,
+ // View params
+ View: FormView,
+ model: 'res.partner',
+ arch: `
+ <form string="Partners">
+ <sheet>
+ <field name="name"/>
+ </sheet>
+ <div class="oe_chatter">
+ <field name="activity_ids"/>
+ <field name="message_ids"/>
+ </div>
+ </form>
+ `,
+ res_id: 12,
+ });
+ assert.containsOnce(
+ document.body,
+ '.o_Chatter',
+ "there should be a chatter"
+ );
+ assert.containsOnce(
+ document.body,
+ '.o_ChatterTopbar',
+ "there should be a chatter topbar"
+ );
+ assert.containsOnce(
+ document.body,
+ '.o_ChatterTopbar_buttonAttachments',
+ "there should be an attachment button"
+ );
+ assert.containsOnce(
+ document.body,
+ '.o_ChatterTopbar_buttonScheduleActivity',
+ "there should be a schedule activity button"
+ );
+ assert.containsNone(
+ document.body,
+ '.o_FollowerListMenu',
+ "there should be no followers menu"
+ );
+ assert.containsOnce(
+ document.body,
+ '.o_Chatter_thread',
+ "there should be a thread"
+ );
+});
+
+QUnit.test('basic chatter rendering without activities', async function (assert) {
+ assert.expect(6);
+
+ this.data['res.partner'].records.push({ display_name: "second partner", id: 12 });
+ await this.createView({
+ data: this.data,
+ hasView: true,
+ // View params
+ View: FormView,
+ model: 'res.partner',
+ arch: `
+ <form string="Partners">
+ <sheet>
+ <field name="name"/>
+ </sheet>
+ <div class="oe_chatter">
+ <field name="message_follower_ids"/>
+ <field name="message_ids"/>
+ </div>
+ </form>
+ `,
+ res_id: 12,
+ });
+ assert.containsOnce(
+ document.body,
+ '.o_Chatter',
+ "there should be a chatter"
+ );
+ assert.containsOnce(
+ document.body,
+ '.o_ChatterTopbar',
+ "there should be a chatter topbar"
+ );
+ assert.containsOnce(
+ document.body,
+ '.o_ChatterTopbar_buttonAttachments',
+ "there should be an attachment button"
+ );
+ assert.containsNone(
+ document.body,
+ '.o_ChatterTopbar_buttonScheduleActivity',
+ "there should be a schedule activity button"
+ );
+ assert.containsOnce(
+ document.body,
+ '.o_FollowerListMenu',
+ "there should be a followers menu"
+ );
+ assert.containsOnce(
+ document.body,
+ '.o_Chatter_thread',
+ "there should be a thread"
+ );
+});
+
+QUnit.test('basic chatter rendering without messages', async function (assert) {
+ assert.expect(6);
+
+ this.data['res.partner'].records.push({ display_name: "second partner", id: 12 });
+ await this.createView({
+ data: this.data,
+ hasView: true,
+ // View params
+ View: FormView,
+ model: 'res.partner',
+ arch: `
+ <form string="Partners">
+ <sheet>
+ <field name="name"/>
+ </sheet>
+ <div class="oe_chatter">
+ <field name="message_follower_ids"/>
+ <field name="activity_ids"/>
+ </div>
+ </form>
+ `,
+ res_id: 12,
+ });
+ assert.containsOnce(
+ document.body,
+ '.o_Chatter',
+ "there should be a chatter"
+ );
+ assert.containsOnce(
+ document.body,
+ '.o_ChatterTopbar',
+ "there should be a chatter topbar"
+ );
+ assert.containsOnce(
+ document.body,
+ '.o_ChatterTopbar_buttonAttachments',
+ "there should be an attachment button"
+ );
+ assert.containsOnce(
+ document.body,
+ '.o_ChatterTopbar_buttonScheduleActivity',
+ "there should be a schedule activity button"
+ );
+ assert.containsOnce(
+ document.body,
+ '.o_FollowerListMenu',
+ "there should be a followers menu"
+ );
+ assert.containsNone(
+ document.body,
+ '.o_Chatter_thread',
+ "there should be a thread"
+ );
+});
+
+QUnit.test('chatter updating', async function (assert) {
+ assert.expect(1);
+
+ this.data['mail.message'].records.push({ body: "not empty", model: 'res.partner', res_id: 12 });
+ this.data['res.partner'].records.push(
+ { display_name: "first partner", id: 11 },
+ { display_name: "second partner", id: 12 }
+ );
+ await this.createView({
+ data: this.data,
+ hasView: true,
+ // View params
+ View: FormView,
+ model: 'res.partner',
+ res_id: 11,
+ viewOptions: {
+ ids: [11, 12],
+ index: 0,
+ },
+ arch: `
+ <form string="Partners">
+ <sheet>
+ <field name="name"/>
+ </sheet>
+ <div class="oe_chatter">
+ <field name="message_ids"/>
+ </div>
+ </form>
+ `,
+ waitUntilEvent: {
+ eventName: 'o-thread-view-hint-processed',
+ message: "should wait until partner 11 thread loaded messages initially",
+ predicate: ({ hint, threadViewer }) => {
+ return (
+ hint.type === 'messages-loaded' &&
+ threadViewer.thread.model === 'res.partner' &&
+ threadViewer.thread.id === 11
+ );
+ },
+ }
+ });
+
+ await this.afterEvent({
+ eventName: 'o-thread-view-hint-processed',
+ func: () => document.querySelector('.o_pager_next').click(),
+ message: "should wait until partner 12 thread loaded messages after clicking on next",
+ predicate: ({ hint, threadViewer }) => {
+ return (
+ hint.type === 'messages-loaded' &&
+ threadViewer.thread.model === 'res.partner' &&
+ threadViewer.thread.id === 12
+ );
+ },
+ });
+ assert.containsOnce(
+ document.body,
+ '.o_Message',
+ "there should be a message in partner 12 thread"
+ );
+});
+
+QUnit.test('chatter should become enabled when creation done', async function (assert) {
+ assert.expect(10);
+
+ await this.createView({
+ data: this.data,
+ hasView: true,
+ // View params
+ View: FormView,
+ model: 'res.partner',
+ arch: `
+ <form string="Partners">
+ <sheet>
+ <field name="name"/>
+ </sheet>
+ <div class="oe_chatter">
+ <field name="message_ids"/>
+ </div>
+ </form>
+ `,
+ viewOptions: {
+ mode: 'edit',
+ },
+ });
+ assert.containsOnce(
+ document.body,
+ '.o_Chatter',
+ "there should be a chatter"
+ );
+ assert.containsOnce(
+ document.body,
+ '.o_ChatterTopbar_buttonSendMessage',
+ "there should be a send message button"
+ );
+ assert.containsOnce(
+ document.body,
+ '.o_ChatterTopbar_buttonLogNote',
+ "there should be a log note button"
+ );
+ assert.containsOnce(
+ document.body,
+ '.o_ChatterTopbar_buttonLogNote',
+ "there should be an attachments button"
+ );
+ assert.ok(
+ document.querySelector(`.o_ChatterTopbar_buttonSendMessage`).disabled,
+ "send message button should be disabled"
+ );
+ assert.ok(
+ document.querySelector(`.o_ChatterTopbar_buttonLogNote`).disabled,
+ "log note button should be disabled"
+ );
+ assert.ok(
+ document.querySelector(`.o_ChatterTopbar_buttonAttachments`).disabled,
+ "attachments button should be disabled"
+ );
+
+ document.querySelectorAll('.o_field_char')[0].focus();
+ document.execCommand('insertText', false, "hello");
+ await afterNextRender(() => {
+ document.querySelector('.o_form_button_save').click();
+ });
+ assert.notOk(
+ document.querySelector(`.o_ChatterTopbar_buttonSendMessage`).disabled,
+ "send message button should now be enabled"
+ );
+ assert.notOk(
+ document.querySelector(`.o_ChatterTopbar_buttonLogNote`).disabled,
+ "log note button should now be enabled"
+ );
+ assert.notOk(
+ document.querySelector(`.o_ChatterTopbar_buttonAttachments`).disabled,
+ "attachments button should now be enabled"
+ );
+});
+
+QUnit.test('read more/less links are not duplicated when switching from read to edit mode', async function (assert) {
+ assert.expect(5);
+
+ this.data['mail.message'].records.push({
+ author_id: 100,
+ // "data-o-mail-quote" added by server is intended to be compacted in read more/less blocks
+ body: `
+ <div>
+ Dear Joel Willis,<br>
+ Thank you for your enquiry.<br>
+ If you have any questions, please let us know.
+ <br><br>
+ Thank you,<br>
+ <span data-o-mail-quote="1">-- <br data-o-mail-quote="1">
+ System
+ </span>
+ </div>
+ `,
+ id: 1000,
+ model: 'res.partner',
+ res_id: 2,
+ });
+ this.data['res.partner'].records.push({
+ display_name: "Someone",
+ id: 100,
+ });
+ await this.createView({
+ data: this.data,
+ hasView: true,
+ // View params
+ View: FormView,
+ model: 'res.partner',
+ res_id: 2,
+ arch: `
+ <form string="Partners">
+ <sheet>
+ <field name="name"/>
+ </sheet>
+ <div class="oe_chatter">
+ <field name="message_ids"/>
+ </div>
+ </form>
+ `,
+ waitUntilEvent: {
+ eventName: 'o-component-message-read-more-less-inserted',
+ message: "should wait until read more/less is inserted initially",
+ predicate: ({ message }) => message.id === 1000,
+ },
+ });
+ assert.containsOnce(
+ document.body,
+ '.o_Chatter',
+ "there should be a chatter"
+ );
+ assert.containsOnce(
+ document.body,
+ '.o_Message',
+ "there should be a message"
+ );
+ assert.containsOnce(
+ document.body,
+ '.o_Message_readMoreLess',
+ "there should be only one read more"
+ );
+ await afterNextRender(() => this.afterEvent({
+ eventName: 'o-component-message-read-more-less-inserted',
+ func: () => document.querySelector('.o_form_button_edit').click(),
+ message: "should wait until read more/less is inserted after clicking on edit",
+ predicate: ({ message }) => message.id === 1000,
+ }));
+ assert.containsOnce(
+ document.body,
+ '.o_Message_readMoreLess',
+ "there should still be only one read more after switching to edit mode"
+ );
+
+ await afterNextRender(() => this.afterEvent({
+ eventName: 'o-component-message-read-more-less-inserted',
+ func: () => document.querySelector('.o_form_button_cancel').click(),
+ message: "should wait until read more/less is inserted after canceling edit",
+ predicate: ({ message }) => message.id === 1000,
+ }));
+ assert.containsOnce(
+ document.body,
+ '.o_Message_readMoreLess',
+ "there should still be only one read more after switching back to read mode"
+ );
+});
+
+QUnit.test('read more links becomes read less after being clicked', async function (assert) {
+ assert.expect(6);
+
+ this.data['mail.message'].records = [{
+ author_id: 100,
+ // "data-o-mail-quote" added by server is intended to be compacted in read more/less blocks
+ body: `
+ <div>
+ Dear Joel Willis,<br>
+ Thank you for your enquiry.<br>
+ If you have any questions, please let us know.
+ <br><br>
+ Thank you,<br>
+ <span data-o-mail-quote="1">-- <br data-o-mail-quote="1">
+ System
+ </span>
+ </div>
+ `,
+ id: 1000,
+ model: 'res.partner',
+ res_id: 2,
+ }];
+ this.data['res.partner'].records.push({
+ display_name: "Someone",
+ id: 100,
+ });
+ await this.createView({
+ data: this.data,
+ hasView: true,
+ // View params
+ View: FormView,
+ model: 'res.partner',
+ res_id: 2,
+ arch: `
+ <form string="Partners">
+ <sheet>
+ <field name="name"/>
+ </sheet>
+ <div class="oe_chatter">
+ <field name="message_ids"/>
+ </div>
+ </form>
+ `,
+ waitUntilEvent: {
+ eventName: 'o-component-message-read-more-less-inserted',
+ message: "should wait until read more/less is inserted initially",
+ predicate: ({ message }) => message.id === 1000,
+ },
+ });
+ assert.containsOnce(
+ document.body,
+ '.o_Chatter',
+ "there should be a chatter"
+ );
+ assert.containsOnce(
+ document.body,
+ '.o_Message',
+ "there should be a message"
+ );
+ assert.containsOnce(
+ document.body,
+ '.o_Message_readMoreLess',
+ "there should be a read more"
+ );
+ assert.strictEqual(
+ document.querySelector('.o_Message_readMoreLess').textContent,
+ 'read more',
+ "read more/less link should contain 'read more' as text"
+ );
+
+ await afterNextRender(() => this.afterEvent({
+ eventName: 'o-component-message-read-more-less-inserted',
+ func: () => document.querySelector('.o_form_button_edit').click(),
+ message: "should wait until read more/less is inserted after clicking on edit",
+ predicate: ({ message }) => message.id === 1000,
+ }));
+ assert.strictEqual(
+ document.querySelector('.o_Message_readMoreLess').textContent,
+ 'read more',
+ "read more/less link should contain 'read more' as text"
+ );
+
+ document.querySelector('.o_Message_readMoreLess').click();
+ assert.strictEqual(
+ document.querySelector('.o_Message_readMoreLess').textContent,
+ 'read less',
+ "read more/less link should contain 'read less' as text after it has been clicked"
+ );
+});
+
+QUnit.test('Form view not scrolled when switching record', async function (assert) {
+ assert.expect(6);
+
+ this.data['res.partner'].records.push(
+ {
+ id: 11,
+ display_name: "Partner 1",
+ description: [...Array(60).keys()].join('\n'),
+ },
+ {
+ id: 12,
+ display_name: "Partner 2",
+ }
+ );
+
+ const messages = [...Array(60).keys()].map(id => {
+ return {
+ model: 'res.partner',
+ res_id: id % 2 ? 11 : 12,
+ };
+ });
+ this.data['mail.message'].records = messages;
+
+ await this.createView({
+ data: this.data,
+ hasView: true,
+ // View params
+ View: FormView,
+ model: 'res.partner',
+ arch: `
+ <form string="Partners">
+ <sheet>
+ <field name="name"/>
+ <field name="description"/>
+ </sheet>
+ <div class="oe_chatter">
+ <field name="message_ids"/>
+ </div>
+ </form>
+ `,
+ viewOptions: {
+ currentId: 11,
+ ids: [11, 12],
+ },
+ config: {
+ device: { size_class: config.device.SIZES.LG },
+ },
+ env: {
+ device: { size_class: config.device.SIZES.LG },
+ },
+ });
+
+ const controllerContentEl = document.querySelector('.o_content');
+
+ assert.strictEqual(
+ document.querySelector('.breadcrumb-item.active').textContent,
+ 'Partner 1',
+ "Form view should display partner 'Partner 1'"
+ );
+ assert.strictEqual(controllerContentEl.scrollTop, 0,
+ "The top of the form view is visible"
+ );
+
+ await afterNextRender(async () => {
+ controllerContentEl.scrollTop = controllerContentEl.scrollHeight - controllerContentEl.clientHeight;
+ await triggerEvent(
+ document.querySelector('.o_ThreadView_messageList'),
+ 'scroll'
+ );
+ });
+ assert.strictEqual(
+ controllerContentEl.scrollTop,
+ controllerContentEl.scrollHeight - controllerContentEl.clientHeight,
+ "The controller container should be scrolled to its bottom"
+ );
+
+ await afterNextRender(() =>
+ document.querySelector('.o_pager_next').click()
+ );
+ assert.strictEqual(
+ document.querySelector('.breadcrumb-item.active').textContent,
+ 'Partner 2',
+ "The form view should display partner 'Partner 2'"
+ );
+ assert.strictEqual(controllerContentEl.scrollTop, 0,
+ "The top of the form view should be visible when switching record from pager"
+ );
+
+ await afterNextRender(() =>
+ document.querySelector('.o_pager_previous').click()
+ );
+ assert.strictEqual(controllerContentEl.scrollTop, 0,
+ "Form view's scroll position should have been reset when switching back to first record"
+ );
+});
+
+QUnit.test('Attachments that have been unlinked from server should be visually unlinked from record', async function (assert) {
+ // Attachments that have been fetched from a record at certain time and then
+ // removed from the server should be reflected on the UI when the current
+ // partner accesses this record again.
+ assert.expect(2);
+
+ this.data['res.partner'].records.push(
+ { display_name: "Partner1", id: 11 },
+ { display_name: "Partner2", id: 12 }
+ );
+ this.data['ir.attachment'].records.push(
+ {
+ id: 11,
+ mimetype: 'text.txt',
+ res_id: 11,
+ res_model: 'res.partner',
+ },
+ {
+ id: 12,
+ mimetype: 'text.txt',
+ res_id: 11,
+ res_model: 'res.partner',
+ }
+ );
+ await this.createView({
+ data: this.data,
+ hasView: true,
+ // View params
+ View: FormView,
+ model: 'res.partner',
+ res_id: 11,
+ viewOptions: {
+ ids: [11, 12],
+ index: 0,
+ },
+ arch: `
+ <form string="Partners">
+ <sheet>
+ <field name="name"/>
+ </sheet>
+ <div class="oe_chatter">
+ <field name="message_ids"/>
+ </div>
+ </form>
+ `,
+ });
+ assert.strictEqual(
+ document.querySelector('.o_ChatterTopbar_buttonCount').textContent,
+ '2',
+ "Partner1 should have 2 attachments initially"
+ );
+
+ // The attachment links are updated on (re)load,
+ // so using pager is a way to reload the record "Partner1".
+ await afterNextRender(() =>
+ document.querySelector('.o_pager_next').click()
+ );
+ // Simulate unlinking attachment 12 from Partner 1.
+ this.data['ir.attachment'].records.find(a => a.id === 11).res_id = 0;
+ await afterNextRender(() =>
+ document.querySelector('.o_pager_previous').click()
+ );
+ assert.strictEqual(
+ document.querySelector('.o_ChatterTopbar_buttonCount').textContent,
+ '1',
+ "Partner1 should now have 1 attachment after it has been unlinked from server"
+ );
+});
+
+QUnit.test('chatter just contains "creating a new record" message during the creation of a new record after having displayed a chatter for an existing record', async function (assert) {
+ assert.expect(2);
+
+ this.data['res.partner'].records.push({ id: 12 });
+ await this.createView({
+ data: this.data,
+ hasView: true,
+ View: FormView,
+ model: 'res.partner',
+ res_id: 12,
+ arch: `
+ <form>
+ <div class="oe_chatter">
+ <field name="message_ids"/>
+ </div>
+ </form>
+ `,
+ });
+
+ await afterNextRender(() => {
+ document.querySelector('.o_form_button_create').click();
+ });
+ assert.containsOnce(
+ document.body,
+ '.o_Message',
+ "Should have a single message when creating a new record"
+ );
+ assert.strictEqual(
+ document.querySelector('.o_Message_content').textContent,
+ 'Creating a new record...',
+ "the message content should be in accord to the creation of this record"
+ );
+});
+
+});
+});
+});
+
+});
diff --git a/addons/mail/static/src/widgets/messaging_menu/messaging_menu.js b/addons/mail/static/src/widgets/messaging_menu/messaging_menu.js
new file mode 100644
index 00000000..edfef630
--- /dev/null
+++ b/addons/mail/static/src/widgets/messaging_menu/messaging_menu.js
@@ -0,0 +1,56 @@
+odoo.define('mail/static/src/widgets/messaging_menu/messaging_menu.js', function (require) {
+'use strict';
+
+const components = {
+ MessagingMenu: require('mail/static/src/components/messaging_menu/messaging_menu.js'),
+};
+
+const SystrayMenu = require('web.SystrayMenu');
+const Widget = require('web.Widget');
+
+/**
+ * Odoo Widget, necessary to instantiate component.
+ */
+const MessagingMenu = Widget.extend({
+ template: 'mail.widgets.MessagingMenu',
+ /**
+ * @override
+ */
+ init() {
+ this._super(...arguments);
+ this.component = undefined;
+ },
+ /**
+ * @override
+ */
+ destroy() {
+ if (this.component) {
+ this.component.destroy();
+ }
+ this._super(...arguments);
+ },
+ async on_attach_callback() {
+ const MessagingMenuComponent = components.MessagingMenu;
+ this.component = new MessagingMenuComponent(null);
+ await this.component.mount(this.el);
+ // unwrap
+ this.el.parentNode.insertBefore(this.component.el, this.el);
+ this.el.parentNode.removeChild(this.el);
+ },
+});
+
+// Systray menu items display order matches order in the list
+// lower index comes first, and display is from right to left.
+// For messagin menu, it should come before activity menu, if any
+// otherwise, it is the next systray item.
+const activityMenuIndex = SystrayMenu.Items.findIndex(SystrayMenuItem =>
+ SystrayMenuItem.prototype.name === 'activity_menu');
+if (activityMenuIndex > 0) {
+ SystrayMenu.Items.splice(activityMenuIndex, 0, MessagingMenu);
+} else {
+ SystrayMenu.Items.push(MessagingMenu);
+}
+
+return MessagingMenu;
+
+});
diff --git a/addons/mail/static/src/widgets/messaging_menu/messaging_menu.xml b/addons/mail/static/src/widgets/messaging_menu/messaging_menu.xml
new file mode 100644
index 00000000..308c1f31
--- /dev/null
+++ b/addons/mail/static/src/widgets/messaging_menu/messaging_menu.xml
@@ -0,0 +1,8 @@
+<?xml version="1.0" encoding="UTF-8"?>
+<templates xml:space="preserve">
+
+ <t t-name="mail.widgets.MessagingMenu">
+ <li class="o_widget_SystrayMessagingItem"/>
+ </t>
+
+</templates>
diff --git a/addons/mail/static/src/widgets/notification_alert/notification_alert.js b/addons/mail/static/src/widgets/notification_alert/notification_alert.js
new file mode 100644
index 00000000..27055cd7
--- /dev/null
+++ b/addons/mail/static/src/widgets/notification_alert/notification_alert.js
@@ -0,0 +1,45 @@
+odoo.define('mail/static/src/widgets/notification_alert/notification_alert.js', function (require) {
+"use strict";
+
+const components = {
+ NotificationAlert: require('mail/static/src/components/notification_alert/notification_alert.js'),
+};
+
+const { ComponentWrapper, WidgetAdapterMixin } = require('web.OwlCompatibility');
+
+const Widget = require('web.Widget');
+const widgetRegistry = require('web.widget_registry');
+
+class NotificationAlertWrapper extends ComponentWrapper {}
+
+// -----------------------------------------------------------------------------
+// Display Notification alert on user preferences form view
+// -----------------------------------------------------------------------------
+const NotificationAlert = Widget.extend(WidgetAdapterMixin, {
+ /**
+ * @override
+ */
+ init() {
+ this._super(...arguments);
+ this.component = undefined;
+ },
+ /**
+ * @override
+ */
+ async start() {
+ await this._super(...arguments);
+
+ this.component = new NotificationAlertWrapper(
+ this,
+ components.NotificationAlert,
+ {}
+ );
+ await this.component.mount(this.el);
+ },
+});
+
+widgetRegistry.add('notification_alert', NotificationAlert);
+
+return NotificationAlert;
+
+});
diff --git a/addons/mail/static/src/widgets/notification_alert/notification_alert_tests.js b/addons/mail/static/src/widgets/notification_alert/notification_alert_tests.js
new file mode 100644
index 00000000..20297c85
--- /dev/null
+++ b/addons/mail/static/src/widgets/notification_alert/notification_alert_tests.js
@@ -0,0 +1,103 @@
+odoo.define('mail/static/src/widgets/notification_alert/notification_alert_tests.js', function (require) {
+'use strict';
+
+const { afterEach, beforeEach, start } = require('mail/static/src/utils/test_utils.js');
+
+const FormView = require('web.FormView');
+
+QUnit.module('mail', {}, function () {
+QUnit.module('widgets', {}, function () {
+QUnit.module('notification_alert', {}, function () {
+QUnit.module('notification_alert_tests.js', {
+ beforeEach() {
+ beforeEach(this);
+
+ this.start = async params => {
+ let { widget } = await start(Object.assign({
+ data: this.data,
+ hasView: true,
+ // View params
+ View: FormView,
+ model: 'mail.message',
+ arch: `
+ <form>
+ <widget name="notification_alert"/>
+ </form>
+ `,
+ }, params));
+ this.widget = widget;
+ };
+ },
+ afterEach() {
+ afterEach(this);
+ },
+});
+
+QUnit.skip('notification_alert widget: display blocked notification alert', async function (assert) {
+ // FIXME: Test should work, but for some reasons OWL always flags the
+ // component as not mounted, even though it is in the DOM and it's state
+ // is good for rendering... task-227947
+ assert.expect(1);
+
+ await this.start({
+ env: {
+ browser: {
+ Notification: {
+ permission: 'denied',
+ },
+ },
+ },
+ });
+
+ assert.containsOnce(
+ document.body,
+ '.o_notification_alert',
+ "Blocked notification alert should be displayed"
+ );
+});
+
+QUnit.test('notification_alert widget: no notification alert when granted', async function (assert) {
+ assert.expect(1);
+
+ await this.start({
+ env: {
+ browser: {
+ Notification: {
+ permission: 'granted',
+ },
+ },
+ },
+ });
+
+ assert.containsNone(
+ document.body,
+ '.o_notification_alert',
+ "Blocked notification alert should not be displayed"
+ );
+});
+
+QUnit.test('notification_alert widget: no notification alert when default', async function (assert) {
+ assert.expect(1);
+
+ await this.start({
+ env: {
+ browser: {
+ Notification: {
+ permission: 'default',
+ },
+ },
+ },
+ });
+
+ assert.containsNone(
+ document.body,
+ '.o_notification_alert',
+ "Blocked notification alert should not be displayed"
+ );
+});
+
+});
+});
+});
+
+});
diff --git a/addons/mail/static/src/xml/activity.xml b/addons/mail/static/src/xml/activity.xml
new file mode 100644
index 00000000..487eb210
--- /dev/null
+++ b/addons/mail/static/src/xml/activity.xml
@@ -0,0 +1,130 @@
+<?xml version="1.0" encoding="UTF-8"?>
+<templates xml:space="preserve">
+ <t t-name="mail.activity_items">
+ <div class="o_thread_date_separator o_border_dashed" data-toggle="collapse" data-target="#o_chatter_planned_activities">
+ <a role="button" class="o_thread_date btn">
+ <i class="fa fa-fw fa-caret-down"/>
+ Planned activities
+ <small class="o_chatter_planned_activities_summary ml8">
+ <span class="badge rounded-circle badge-danger"><t t-esc="nbOverdueActivities"/></span>
+ <span class="badge rounded-circle badge-warning"><t t-esc="nbTodayActivities"/></span>
+ <span class="badge rounded-circle badge-success"><t t-esc="nbPlannedActivities"/></span>
+ </small>
+ </a>
+ </div>
+ <div id="o_chatter_planned_activities" class="collapse show">
+ <t t-foreach="activities" t-as="activity">
+ <div class="o_thread_message" style="margin-bottom: 10px">
+ <div class="o_thread_message_sidebar">
+ <div class="o_avatar_stack">
+ <img t-attf-src="/web/image#{activity.user_id[0] >= 0 ? ('/res.users/' + activity.user_id[0] + '/image_128') : ''}" class="o_thread_message_avatar rounded-circle mb8" t-att-title="activity.user_id[1]" t-att-alt="activity.user_id[1]"/>
+ <i t-att-class="'o_avatar_icon fa ' + activity.icon + ' bg-' + (activity.state == 'planned'? 'success' : (activity.state == 'today'? 'warning' : 'danger')) + '-full'"
+ t-att-title="activity.activity_type_id[1]"/>
+ </div>
+ </div>
+ <div class="o_thread_message_core">
+ <div class="o_mail_info text-muted">
+ <strong><span t-attf-class="o_activity_date o_activity_color_#{activity.state}"><t t-esc="activity.label_delay" /></span></strong>:
+ <strong t-if="activity.summary" class="o_activity_summary"> &#8220;<t t-esc="activity.summary"/>&#8221;</strong>
+ <strong t-if="!activity.summary"> <t t-esc="activity.activity_type_id[1]" /></strong>
+ <em> for </em>
+ <t t-esc="activity.user_id[1]" />
+ <a class="btn btn-link btn-info text-muted collapsed o_activity_info ml4" role="button" data-toggle="collapse" t-attf-data-target="#o_chatter_activity_info_#{activity.id}">
+ <i class="fa fa-info-circle" role="img" aria-label="Info" title="Info"></i>
+ </a>
+ <div class="o_thread_message_collapse collapse" t-attf-id="o_chatter_activity_info_#{activity.id}">
+ <dl class="dl-horizontal">
+ <dt>Activity type</dt>
+ <dd class="mb8">
+ <t t-esc="activity.activity_type_id[1]"/>
+ </dd>
+ <dt>Created on</dt>
+ <dd class="mb8">
+ <t t-esc="activity.create_date.format(datetimeFormat)"/>
+ by
+ <img t-attf-src="/web/image#{activity.create_uid[0] >= 0 ? ('/res.users/' + activity.create_uid[0] + '/image_128') : ''}"
+ height="18" width="18"
+ class="o_object_fit_cover rounded-circle mr4"
+ t-att-title="activity.create_uid[1]"
+ t-att-alt="activity.create_uid[1]"/>
+ <b><t t-esc="activity.create_uid[1]"/></b>
+ </dd>
+ <dt>Assigned to</dt>
+ <dd class="mb8">
+ <img t-attf-src="/web/image#{activity.user_id[0] >= 0 ? ('/res.users/' + activity.user_id[0] + '/image_128') : ''}" height="18" width="18" class="o_object_fit_cover rounded-circle mr4" t-att-title="activity.user_id[1]" t-att-alt="activity.user_id[1]"/>
+ <b><t t-esc="activity.user_id[1]"/></b>
+ <em>, due on </em><span t-attf-class="o_activity_color_#{activity.state}"><t t-esc="activity.date_deadline.format(dateFormat)"/></span>
+ </dd>
+ </dl>
+ </div>
+ </div>
+ <div t-if="activity.note" t-attf-class="o_thread_message_#{activity.activity_decoration ? activity.activity_decoration : 'note'} #{activity.activity_decoration ? 'alert alert-' + activity.activity_decoration : ''}">
+ <t t-raw="activity.note"/>
+ </div>
+ <t t-if="activity.mail_template_ids &amp;&amp; activity.mail_template_ids.length &gt; 0">
+ <div class="mt16" t-att-data-activity-id="activity.id" t-att-data-previous-activity-type-id="activity.activity_type_id[0]">
+ <t t-foreach="activity.mail_template_ids" t-as="mail_template">
+ <div>
+ <i class="fa fa-envelope-o" aria-label="Mail" title="Mail" role="img"></i>
+ <span t-esc="mail_template.name"/>:
+ <span class="o_activity_template_preview" t-att-data-template-id="mail_template.id">Preview</span>
+ <span class="text-muted">or</span>
+ <span class="o_activity_template_send" t-att-data-template-id="mail_template.id">Send Now</span>
+ </div>
+ </t>
+ </div>
+ </t>
+ <div class="o_thread_message_tools btn-group">
+ <t t-call="mail.activity_thread_message_tools"/>
+ </div>
+ </div>
+ </div>
+ </t>
+ </div>
+ </t>
+ <t t-name="mail.activity_thread_message_tools">
+ <div t-if="activity.can_write" class="o_thread_message_tools btn-group">
+ <span t-if="activity.activity_category !== 'upload_file'" class="o_mark_as_done" data-toggle="popover" t-att-data-activity-id="activity.id" t-att-data-force-next-activity="activity.force_next" t-att-data-previous-activity-type-id="activity.activity_type_id[0]">
+ <a role="button" href="#" class="btn btn-link btn-success text-muted o_activity_link mr8">
+ <i class="fa fa-check"/> Mark Done </a>
+ </span>
+ <span t-if="activity.activity_category === 'upload_file'" class="o_mark_as_done_upload_file" t-att-data-activity-id="activity.id" t-att-data-force-next-activity="activity.force_next" t-att-data-previous-activity-type-id="activity.activity_type_id[0]" t-att-data-fileupload-id="activity.fileuploadID">
+ <a role="button" href="#" class="btn btn-link btn-success text-muted o_activity_link mr8">
+ <i class="fa fa-upload"/> Upload Document </a>
+ </span>
+ <span t-if="activity.activity_category === 'upload_file'" class="d-none">
+ <t t-call="HiddenInputFile">
+ <t t-set="fileupload_id" t-value="activity.fileuploadID"/>
+ <t t-set="fileupload_action" t-translation="off">/web/binary/upload_attachment</t>
+ <input type="hidden" name="model" t-att-value="activity.res_model"/>
+ <input type="hidden" name="id" t-att-value="activity.res_id"/>
+ </t>
+ </span>
+ <a role="button" href="#" class="btn btn-link btn-secondary text-muted o_edit_activity o_activity_link" t-att-data-activity-id="activity.id">
+ <i class="fa fa-pencil"/> Edit
+ </a>
+ <a role="button" href="#" class="btn btn-link btn-danger text-muted o_unlink_activity o_activity_link" t-att-data-activity-id="activity.id">
+ <i class="fa fa-times"/> Cancel
+ </a>
+ </div>
+ </t>
+ <t t-name="mail.activity_feedback_form">
+ <div>
+ <textarea class="form-control" rows="3" id="activity_feedback" placeholder="Write Feedback"/>
+ <div class="mt8">
+ <t t-if="!force_next">
+ <button type="button" class="btn btn-sm btn-primary o_activity_popover_done_next" t-att-data-previous-activity-type-id="previous_activity_type_id">
+ Done &amp; Schedule Next</button>
+ <button type="button" class="btn btn-sm btn-primary o_activity_popover_done">
+ Done</button>
+ <button type="button" class="btn btn-sm btn-link o_activity_popover_discard">
+ Discard</button>
+ </t>
+ <t t-else="">
+ <button type="button" class="btn btn-sm btn-primary o_activity_popover_done_next">
+ Done &amp; Launch Next</button>
+ </t>
+ </div>
+ </div>
+ </t>
+</templates>
diff --git a/addons/mail/static/src/xml/activity_view.xml b/addons/mail/static/src/xml/activity_view.xml
new file mode 100644
index 00000000..c40d6d73
--- /dev/null
+++ b/addons/mail/static/src/xml/activity_view.xml
@@ -0,0 +1,93 @@
+<?xml version="1.0" encoding="UTF-8"?>
+<templates xml:space="preserve">
+
+<t t-name="mail.ActivityViewHeader" owl="1">
+ <thead>
+ <tr>
+ <th></th>
+ <th t-foreach="props.activity_types" t-as="type" t-key="type[0]"
+ class="o_activity_type_cell" t-attf-class="{{ activeFilter.activityTypeId === type[0] ? 'o_activity_filter_' + activeFilter.state : '' }}"
+ t-att-data-activity-type-id="type[0]" t-attf-width="{{100/props.activity_types.length}}%">
+ <div>
+ <span t-esc="type[1]"/>
+ <span t-if="type[2].length > 0" class="dropdown pull-right">
+ <i class="fa fa-ellipsis-v fa-fw" data-toggle="dropdown"/>
+ <div class="dropdown-menu">
+ <t t-foreach="type[2]" t-as="template" t-key="template.id">
+ <div title="This action will send an email."
+ class="o_template_element o_send_mail_template"
+ t-att-data-activity-type-id="type[0]"
+ t-att-data-template-id="template.id"
+ t-on-click="_onSendMailTemplateClicked">
+ <i class="fa fa-envelope fa-fw"/> <t t-esc="template.name"/>
+ </div>
+ </t>
+ </div>
+ </span>
+ </div>
+ <KanbanColumnProgressBarAdapter Component="widgetComponents.KanbanColumnProgressBar"
+ widgetArgs="[getProgressBarOptions(type[0]), getProgressBarColumnState(type[0])]"
+ t-if="activityTypeIds.includes(type[0])"
+ t-on-set-progress-bar-state="_onSetProgressBarState"/>
+ <div t-else="" class="mt24"/>
+ </th>
+ </tr>
+ </thead>
+</t>
+
+<t t-name="mail.ActivityViewBody" owl="1">
+ <tbody>
+ <t t-foreach="activityResIds" t-as="resId" t-key="resId">
+ <t t-call="mail.ActivityViewRow"/>
+ </t>
+ </tbody>
+</t>
+
+<t t-name="mail.ActivityViewRow" owl="1">
+ <tr class="o_data_row" t-att-data-res-id="resId">
+ <t t-set="record" t-value="props.data.find(data => data.res_id === resId)"/>
+ <td t-attf-class="{{ activeFilter.resIds.includes(resId) ? 'o_activity_filter_' + activeFilter.state : '' }}">
+ <ActivityRecordAdapter Component="widgetComponents.ActivityRecord"
+ widgetArgs="[record, { qweb: qweb }]"/>
+ </td>
+ <t t-foreach="props.activity_types" t-as="type" t-key="type[0]">
+ <t t-call="mail.ActivityViewCell"/>
+ </t>
+ </tr>
+</t>
+
+<t t-name="mail.ActivityViewCell" owl="1">
+ <t t-set="activityGroup" t-value="props.grouped_activities[resId] and props.grouped_activities[resId][type[0]] or {count: 0, ids: [], state: false}"/>
+ <td t-if="activityGroup.state" t-att-data-res-id="resId" t-att-data-activity-type-id="type[0]"
+ t-attf-class="o_activity_summary_cell {{activityGroup.state}} {{ activeFilter.resIds.includes(resId) ? 'o_activity_filter_' + activeFilter.state : '' }}">
+ <ActivityCellAdapter Component="widgetComponents.ActivityCell"
+ widgetArgs="['activity_ids', props.getKanbanActivityData(activityGroup, resId)]"/>
+ </td>
+ <td t-else="" t-att-data-res-id="resId" t-att-data-activity-type-id="type[0]"
+ class="o_activity_summary_cell o_activity_empty_cell"
+ t-attf-class="{{ activeFilter.resIds.includes(resId) ? 'o_activity_filter_' + activeFilter.state : '' }}"
+ t-on-click.prevent.stop="_onEmptyCellClicked">
+ <i title="Create" class="text-center fa fa-plus"/>
+ </td>
+</t>
+
+<t t-name="mail.ActivityViewFooter" owl="1">
+ <tfoot>
+ <tr class="o_data_row">
+ <td class="o_record_selector p-3" t-on-click.prevent.stop="trigger('schedule_activity')">
+ <span class="fa fa-plus pr-2"/><span>Schedule activity</span>
+ </td>
+ </tr>
+ </tfoot>
+</t>
+
+<div t-name="mail.ActivityRenderer" class="o_activity_view" owl="1">
+ <t t-if="!props.activity_types.length" t-call="web.NoContentHelper"/>
+ <table t-else="" class="table-bordered mb-5">
+ <t t-call="mail.ActivityViewHeader"/>
+ <t t-call="mail.ActivityViewBody"/>
+ <t t-call="mail.ActivityViewFooter"/>
+ </table>
+</div>
+
+</templates>
diff --git a/addons/mail/static/src/xml/composer.xml b/addons/mail/static/src/xml/composer.xml
new file mode 100644
index 00000000..49c0a3bf
--- /dev/null
+++ b/addons/mail/static/src/xml/composer.xml
@@ -0,0 +1,20 @@
+<?xml version="1.0" encoding="UTF-8"?>
+<templates xml:space="preserve">
+
+ <div t-name="mail.legacy.Composer.emojis" class="o_mail_emoji_container">
+ <t t-foreach="emojis" t-as="emoji">
+ <span t-att-data-emoji="emoji.sources[0]" class="o_mail_emoji" t-att-title="emoji.description" t-att-aria-label="emoji.description">
+ <t t-raw="emoji.unicode"/>
+ </span>
+ </t>
+ </div>
+
+ <t t-name="FieldMany2ManyTagsEmail" t-extend="FieldMany2ManyTag">
+ <t t-jquery="[t-attf-class*=badge]" t-operation="replace">
+ <div t-attf-class="badge badge-pill dropdown o_tag_color_0 #{el.email.indexOf('@') &lt; 0 ? 'o_tag_error' : ''}" t-att-data-color="color" t-att-data-index="el_index" t-att-data-id="el.id" t-attf-title="Tag color: #{colornames[color]}">
+ <span class="o_badge_text" t-att-title="el.email"><t t-esc="el.display_name"/></span>
+ <a t-if="!readonly" href="#" class="fa fa-times o_delete" title="Delete" aria-label="Delete"/>
+ </div>
+ </t>
+ </t>
+</templates>
diff --git a/addons/mail/static/src/xml/many2one_avatar_user.xml b/addons/mail/static/src/xml/many2one_avatar_user.xml
new file mode 100644
index 00000000..3ef845fd
--- /dev/null
+++ b/addons/mail/static/src/xml/many2one_avatar_user.xml
@@ -0,0 +1,12 @@
+<?xml version="1.0" encoding="UTF-8"?>
+<templates xml:space="preserve">
+
+ <!-- MailMany2OneAvatar: do not display the display_name in kanban views -->
+ <t t-name="mail.KanbanMany2OneAvatarUser" t-extend="web.Many2OneAvatar">
+ <t t-jquery="img" t-operation="attributes">
+ <attribute name="t-att-title">value</attribute>
+ </t>
+ <t t-jquery="span" t-operation="replace"/>
+ </t>
+
+</templates>
diff --git a/addons/mail/static/src/xml/systray.xml b/addons/mail/static/src/xml/systray.xml
new file mode 100644
index 00000000..b9612e13
--- /dev/null
+++ b/addons/mail/static/src/xml/systray.xml
@@ -0,0 +1,61 @@
+<?xml version="1.0" encoding="UTF-8"?>
+<templates>
+
+ <!--
+ @param {mail.systray.ActivityMenu} widget
+ @param {Object[]} widget.activities
+ -->
+ <t t-name="mail.systray.ActivityMenu.Previews">
+ <t t-set="activities" t-value="widget._activities"/>
+ <t t-if="_.isEmpty(activities)">
+ <div class="dropdown-item-text text-center o_no_activity">
+ <span>No activities planned.</span>
+ </div>
+ </t>
+ <t t-foreach="activities" t-as="activity">
+ <div class="o_mail_preview o_systray_activity" t-att-data-res_model="activity.model" t-att-data-model_name="activity.name" t-att-data-domain="activity.domain" data-filter='my'>
+ <div t-if="activity.icon" class="o_mail_preview_image o_mail_preview_app">
+ <img t-att-src="activity.icon" alt="Activity"/>
+ </div>
+ <div class="o_preview_info">
+ <div class="o_preview_title">
+ <span class="o_preview_name">
+ <t t-esc="activity.name"/>
+ </span>
+ <div t-if="activity.actions" class="o_mail_activity_action_buttons">
+ <t t-foreach="activity.actions" t-as="action">
+ <button type="button"
+ t-att-title="action.name"
+ t-att-class="'o_mail_activity_action btn btn-link fa ' + action.icon"
+ t-att-data-action_xmlid="action.action_xmlid"
+ t-att-data-res_model="activity.model"
+ t-att-data-model_name="activity.name"
+ t-att-data-domain="activity.domain">
+ </button>
+ </t>
+ </div>
+ </div>
+ <div t-if="activity and activity.type == 'activity'">
+ <button t-if="activity.overdue_count" type="button" class="btn btn-link o_activity_filter_button mr16" t-att-data-res_model="activity.model" t-att-data-model_name="activity.name" data-filter='overdue'><t t-esc="activity.overdue_count"/> Late </button>
+ <span t-if="!activity.overdue_count" class="o_no_activity mr16">0 Late </span>
+ <button t-if="activity.today_count" type="button" class="btn btn-link o_activity_filter_button mr16" t-att-data-res_model="activity.model" t-att-data-model_name="activity.name" data-filter='today'> <t t-esc="activity.today_count"/> Today </button>
+ <span t-if="!activity.today_count" class="o_no_activity mr16">0 Today </span>
+ <button t-if="activity.planned_count" type="button" class="btn btn-link o_activity_filter_button float-right" t-att-data-res_model="activity.model" t-att-data-model_name="activity.name" data-filter='upcoming_all'> <t t-esc="activity.planned_count"/> Future </button>
+ <span t-if="!activity.planned_count" class="o_no_activity float-right">0 Future</span>
+ </div>
+ </div>
+ </div>
+ </t>
+ </t>
+
+ <t t-name="mail.systray.ActivityMenu">
+ <li class="o_mail_systray_item">
+ <a class="dropdown-toggle o-no-caret" data-toggle="dropdown" data-display="static" aria-expanded="false" title="Activities" href="#" role="button">
+ <i class="fa fa-clock-o" role="img" aria-label="Activities"/> <span class="o_notification_counter badge badge-pill"/>
+ </a>
+ <div class="o_mail_systray_dropdown dropdown-menu dropdown-menu-right" role="menu">
+ <div class="o_mail_systray_dropdown_items"/>
+ </div>
+ </li>
+ </t>
+</templates>
diff --git a/addons/mail/static/src/xml/text_emojis.xml b/addons/mail/static/src/xml/text_emojis.xml
new file mode 100644
index 00000000..4f333ba9
--- /dev/null
+++ b/addons/mail/static/src/xml/text_emojis.xml
@@ -0,0 +1,18 @@
+<?xml version="1.0" encoding="UTF-8"?>
+<templates xml:space="preserve">
+ <div t-name="mail.EmojisDropdown" class="o_mail_emojis_dropdown o_mail_add_emoji dropdown position-relative">
+ <button class="btn btn-block dropdown-toggle px-3 py-1"
+ type="button"
+ data-toggle="dropdown"
+ aria-haspopup="true"
+ aria-expanded="false"
+ title="Add an emoji">
+ <i class="fa fa-smile-o"/>
+ </button>
+ <div class="dropdown-menu dropdown-menu-right border-0 p-2" style="width: 406px">
+ <t t-call="mail.legacy.Composer.emojis">
+ <t t-set="emojis" t-value="widget.emojis"></t>
+ </t>
+ </div>
+ </div>
+</templates>
diff --git a/addons/mail/static/src/xml/thread.xml b/addons/mail/static/src/xml/thread.xml
new file mode 100644
index 00000000..5a3205ed
--- /dev/null
+++ b/addons/mail/static/src/xml/thread.xml
@@ -0,0 +1,102 @@
+<?xml version="1.0" encoding="UTF-8"?>
+<templates xml:space="preserve">
+
+ <!--
+ extends the debug mode menu to allow access to the attachment list view of the current record.
+ -->
+ <t t-extend="WebClient.DebugManager.View">
+ <t t-jquery="a[data-action='get_metadata']" t-operation="after">
+ <a role="menuitem" href="#" data-action="getMailMessages" class="dropdown-item">Manage Messages</a>
+ </t>
+ </t>
+ <!--
+ @param {mail.DocumentViewer} widget
+ -->
+ <t t-name="DocumentViewer.Content">
+ <div class="o_viewer_content">
+ <t t-set="model" t-value="widget.modelName"/>
+ <div class="o_viewer-header">
+ <span class="o_image_caption">
+ <i class="fa fa-picture-o mr8" t-if="widget.activeAttachment.fileType == 'image'" role="img" aria-label="Image" title="Image"/>
+ <i class="fa fa-file-text mr8" t-if="widget.activeAttachment.fileType == 'application/pdf'" role="img" aria-label="PDF file" title="PDF file"/>
+ <i class="fa fa-video-camera mr8" t-if="widget.activeAttachment.fileType == 'video'" role="img" aria-label="Video" title="Video"/>
+ <t t-esc="widget.activeAttachment.name"/>
+ <a role="button" href="#" class="o_download_btn ml8 small" data-toggle="tooltip" data-placement="right" title="Download"><i class="fa fa-fw fa-download" role="img" aria-label="Download"/></a>
+ </span>
+ <a role="button" class="o_close_btn float-right" href="#" aria-label="Close" title="Close">×</a>
+ </div>
+ <div class="o_viewer_img_wrapper">
+ <div class="o_viewer_zoomer">
+ <t t-if="widget.activeAttachment.fileType === 'image'">
+ <div class="o_loading_img text-center">
+ <i class="fa fa-circle-o-notch fa-spin text-gray-light fa-3x fa-fw" role="img" aria-label="Loading" title="Loading"/>
+ </div>
+ <t t-set="unique" t-value="widget.activeAttachment.checksum ? widget.activeAttachment.checksum.slice(-8) : ''"/>
+ <img class="o_viewer_img" t-attf-src="/web/image/#{widget.activeAttachment.id}?unique=#{unique}&amp;model=#{model}" alt="Viewer"/>
+ </t>
+ <iframe t-if="widget.activeAttachment.fileType == 'application/pdf'" class="mt32 o_viewer_pdf" t-attf-src="/web/static/lib/pdfjs/web/viewer.html?file=/web/content/#{widget.activeAttachment.id}?model%3D#{model}%26filename%3D#{window.encodeURIComponent(widget.activeAttachment.name)}" />
+ <iframe t-if="(widget.activeAttachment.fileType || '').indexOf('text') !== -1" class="mt32 o_viewer_text" t-attf-src="/web/content/#{widget.activeAttachment.id}?model=#{model}" />
+ <iframe t-if="widget.activeAttachment.fileType == 'youtu'" class="mt32 o_viewer_text" allow="autoplay; encrypted-media" width="560" height="315" t-attf-src="https://www.youtube.com/embed/#{widget.activeAttachment.youtube}"/>
+ <video t-if="widget.activeAttachment.fileType == 'video'" class="o_viewer_video" controls="controls">
+ <source t-attf-src="/web/image/#{widget.activeAttachment.id}?model=#{model}" t-att-data-type="widget.activeAttachment.mimetype"/>
+ </video>
+ </div>
+ </div>
+ <div t-if="widget.activeAttachment.fileType == 'image'" class="o_viewer_toolbar btn-toolbar" role="toolbar">
+ <div class="btn-group" role="group">
+ <a role="button" href="#" class="o_viewer_toolbar_btn btn o_zoom_in" data-toggle="tooltip" title="Zoom In"><i class="fa fa-fw fa-plus" role="img" aria-label="Zoom In"/></a>
+ <a role="button" href="#" class="o_viewer_toolbar_btn btn o_zoom_reset disabled" data-toggle="tooltip" title="Reset Zoom"><i class="fa fa-fw fa-search" role="img" aria-label="Reset Zoom"/></a>
+ <a role="button" href="#" class="o_viewer_toolbar_btn btn o_zoom_out disabled" data-toggle="tooltip" title="Zoom Out"><i class="fa fa-fw fa-minus" role="img" aria-label="Zoom Out"/></a>
+ </div>
+ <div class="btn-group" role="group">
+ <a role="button" href="#" class="o_viewer_toolbar_btn btn o_rotate" data-toggle="tooltip" title="Rotate"><i class="fa fa-fw fa-repeat" role="img" aria-label="Rotate"/></a>
+ </div>
+ <div class="btn-group" role="group">
+ <a role="button" href="#" class="o_viewer_toolbar_btn btn o_print_btn" data-toggle="tooltip" title="Print"><i class="fa fa-fw fa-print" role="img" aria-label="Print"/></a>
+ <a role="button" href="#" class="o_viewer_toolbar_btn btn o_download_btn" data-toggle="tooltip" title="Download"><i class="fa fa-fw fa-download" role="img" aria-label="Download"/></a>
+ </div>
+ </div>
+ </div>
+ </t>
+
+ <!--
+ @param {mail.DocumentViewer} widget
+ -->
+ <t t-name="DocumentViewer">
+ <div class="modal o_modal_fullscreen" tabindex="-1" data-keyboard="false" role="dialog">
+ <t class="o_document_viewer_content_call" t-call="DocumentViewer.Content"/>
+
+ <t t-if="widget.attachment.length !== 1">
+ <a class="arrow arrow-left move_previous" href="#">
+ <span class="fa fa-chevron-left" role="img" aria-label="Previous" title="Previous"/>
+ </a>
+ <a class="arrow arrow-right move_next" href="#">
+ <span class="fa fa-chevron-right" role="img" aria-label="Next" title="Next"/>
+ </a>
+ </t>
+ </div>
+ </t>
+
+ <!--
+ @param {string} src
+ -->
+ <t t-name="PrintImage">
+ <html>
+ <head>
+ <script>
+ function onload_img() {
+ setTimeout('print_img()', 10);
+ }
+ function print_img() {
+ window.print();
+ window.close();
+ }
+ </script>
+ </head>
+ <body onload='onload_img()'>
+ <img t-att-src='src' alt=""/>
+ </body>
+ </html>
+ </t>
+
+</templates>
diff --git a/addons/mail/static/src/xml/web_kanban_activity.xml b/addons/mail/static/src/xml/web_kanban_activity.xml
new file mode 100644
index 00000000..e6d82e37
--- /dev/null
+++ b/addons/mail/static/src/xml/web_kanban_activity.xml
@@ -0,0 +1,114 @@
+<?xml version="1.0" encoding="UTF-8"?>
+<templates xml:space="preserve">
+
+<t t-name="mail.KanbanActivity">
+ <div class="o_kanban_inline_block dropdown o_mail_activity">
+ <a class="dropdown-toggle o-no-caret o_activity_btn" data-toggle="dropdown" role="button">
+ <!-- span classes are generated dynamically (see _render) -->
+ <span t-att-title="widget.selection[widget.activityState]" role="img" t-att-aria-label="widget.selection[widget.activity_state]"/>
+ </a>
+ <div class="dropdown-menu o_activity" role="menu"/>
+ </div>
+</t>
+
+<t t-name="mail.ListActivity" t-extend="mail.KanbanActivity">
+ <t t-jquery=".o_mail_activity" t-operation="append">
+ <span class="o_activity_summary"/>
+ </t>
+</t>
+
+<t t-name="mail.KanbanActivityLoading">
+ <div class="dropdown-item text-center o_no_activity">
+ <span class="fa fa-spinner fa-spin fa-2x" role="img" aria-label="Loading..." title="Loading..."/>
+ </div>
+</t>
+
+<t t-name="mail.KanbanActivityDropdown">
+ <span role="menuitem" t-if="_.isEmpty(records)" class="dropdown-item-text text-center o_no_activity">
+ <i>Schedule activities to help you get things done.</i>
+ </span>
+ <div t-else="" aria-haspopup="true" role="menu" class="o_activity_log_container dropdown-item bg-100 p-0">
+ <ul class="o_activity_log list-group list-group-flush mb-2" role="menu">
+ <t t-foreach="_.keys(records)" t-as="key">
+ <t t-set="logs" t-value="records[key]" />
+ <t t-set="contextual_class" t-value="key == 'planned' ? 'success' : (key == 'today' ? 'warning' : 'danger') "/>
+
+ <li role="menuitem" t-attf-class="o_activity_label list-group-item list-group-item list-group-item-light d-flex justify-content-between align-items-center o_activity_color_{{key}} {{!key_first ? 'mt-2' : ''}}">
+ <strong><t t-esc="selection[key]"/></strong>
+ <span t-attf-class="badge badge-pill badge-{{contextual_class}} border-0 mr-0"><t t-esc="logs.length"/></span>
+ </li>
+ <t t-foreach="logs" t-as="log">
+ <t t-set="edit_class" t-value="'o_edit_activity'"/>
+ <t t-if="log.force_next">
+ <t t-set="edit_class" t-value=""/>
+ </t>
+ <t class="activities_list_group_item">
+ <t t-call="mail.activities-list-group-item"/>
+ </t>
+ <li t-attf-id="o_activity_form_{{log.id}}" class="o_activity_form list-group-item border-top-0 py-0 mb-2 collapse"></li>
+ </t>
+ </t>
+ </ul>
+ </div>
+ <div class="dropdown-divider m-0"/>
+ <div role="menuitem" class="o_schedule_activity dropdown-header p-0 text-center">
+ <button class="btn btn-secondary btn-block p-3">
+ <i class="fa fa-plus fa-fw"></i><strong>Schedule an activity</strong>
+ </button>
+ </div>
+</t>
+
+<t t-name="mail.activities-list-group-item">
+ <li t-attf-class="list-group-item o_log_activity d-flex #{log_last ? 'border-bottom' : ''}" role="menuitem">
+ <div t-attf-class="o_activity_title o_log_activity #{edit_class}" t-att-data-activity-id="log.id">
+ <div t-attf-class="o_activity_title_entry o_mail_activity {{! log.force_next ? 'align-items-center' : 'mb-1'}}">
+ <span t-attf-class="fa #{log.icon ? log.icon : 'fa-bell' } fa-fw mr-2 text-center text-muted" role="img" aria-label="Log" title="Log"/>
+ <strong class="text-dark o_activity_summary"><t t-esc="log.title_action or log.summary or log.activity_type_id[1]"/></strong>
+ <button t-if="! log.force_next and log.can_write" class="btn btn-sm btn-link py-0 o_edit_button"><i class="fa fa-pencil"/></button>
+ </div>
+ <div class="o_activity_title_entry mt-1" t-if="log.state != 'today'">
+ <span class="fa fa-clock-o fa-fw mr-2 text-center text-muted" role="img" aria-label="Deadline" title="Deadline"/>
+ <small t-if="log.user_id[0] !== session.uid and log.mail_template_ids" class="mr-1"><t t-esc="log.user_id[1]"/> -</small>
+ <small t-att-title="log.date_deadline"><t t-esc="log.label_delay" /></small>
+ </div>
+ <t t-if="log.mail_template_ids">
+ <div t-foreach="log.mail_template_ids" t-as="mail_template" class="o_activity_title_entry mt-2" t-att-data-activity-id="log.id" t-att-data-force-next-activity="log.force_next" t-att-data-previous-activity-type-id="log.activity_type_id[0]">
+ <i class="fa fa-envelope-o fa-fw mr-2 text-center text-muted" aria-label="Mail" title="Mail" role="img"></i>
+ <small>
+ <div class="mb-1" t-esc="mail_template.name + ':'"/>
+ <a class="o_activity_template_preview" t-att-data-template-id="mail_template.id" href="#"><b>Preview</b></a>
+ <small>or</small>
+ <a class="o_activity_template_send" t-att-data-template-id="mail_template.id" href="#"><b>Send Now</b></a>
+ </small>
+ </div>
+ </t>
+ </div>
+ <div t-if="log.can_write" class="flex-grow-1 text-right">
+ <t t-if="log.activity_category === 'upload_file'">
+ <a t-att-data-force-next-activity="log.force_next"
+ t-att-data-previous-activity-type-id="log.activity_type_id[0]"
+ t-att-data-activity-id="log.id"
+ class="o_mark_as_done_upload_file o_activity_link o_activity_link_kanban fa fa-upload"
+ title="Upload file" role="img" t-att-data-fileupload-id="log.fileuploadID"/>
+ <span class="d-none">
+ <t t-call="HiddenInputFile">
+ <t t-set="fileupload_id" t-value="log.fileuploadID"/>
+ <t t-set="fileupload_action" t-translation="off">/web/binary/upload_attachment</t>
+ <input type="hidden" name="model" t-att-value="log.res_model"/>
+ <input type="hidden" name="id" t-att-value="log.res_id"/>
+ </t>
+ </span>
+ </t>
+ <t t-else="">
+ <a t-att-data-force-next-activity="log.force_next"
+ t-att-data-previous-activity-type-id="log.activity_type_id[0]"
+ t-att-data-activity-id="log.id"
+ t-attf-href="#o_mark_done_form{{log.id}}"
+ class="o_mark_as_done o_activity_link o_activity_link_kanban fa fa-check-circle"
+ data-toggle="collapse" title="Mark as done" role="img" aria-label="Mark as done"/>
+ </t>
+ </div>
+ </li>
+</t>
+
+</templates>