summaryrefslogtreecommitdiff
path: root/addons/mail/static/src
diff options
context:
space:
mode:
authorSébastien Theys <seb@odoo.com>2020-06-15 18:12:21 +0000
committerSébastien Theys <seb@odoo.com>2020-06-15 18:12:21 +0000
commit3fea5b2136627c1d7e83b2a5e111952fe3eb10a1 (patch)
tree5eefad07972a87ce4689b83260f981bc87aadad4 /addons/mail/static/src
parent0a82848606151ef60eb6523e9c4c161171f214e7 (diff)
[REF] mail, *: refactor messaging with OWL
This commit is a significant rewriting of client-side discuss, chatter, chat window, and messaging menu using OWL. The behavior should be broadly the same, with some slight functional changes here and there. From a technical standpoint, the code of messaging is mainly organized in 2 main groups of modules: - models, which are logical entities that depict the client-side state of messaging as a whole. - components, which are in charge of displaying information from models. This refactoring also introduces new JS guidelines regarding folder structure (/static) and naming rules for JS modules. Community PR: https://github.com/odoo/odoo/pull/39023 Enterprise PR: https://github.com/odoo/enterprise/pull/6249 Task-1914207 This PR is a collaborative work by Alexandre, Julien, Sébastien and Xavier, with the precious help of Lucas to speed it up towards the end. closes odoo/odoo#39023 Related: odoo/enterprise#6249 Signed-off-by: Alexandre Kühn (aku) <aku@odoo.com> Co-authored-by: Alexandre Kühn <aku@odoo.com> Co-authored-by: Julien Giannone <jgi@odoo.com> Co-authored-by: Lucas Perais <lpe@odoo.com> Co-authored-by: Sébastien Theys <seb@odoo.com> Co-authored-by: Xavier Dubuc <xdu@odoo.com>
Diffstat (limited to 'addons/mail/static/src')
-rw-r--r--addons/mail/static/src/bugfix/bugfix.js10
-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.js88
-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.js190
-rw-r--r--addons/mail/static/src/components/activity/activity.js177
-rw-r--r--addons/mail/static/src/components/activity/activity.scss174
-rw-r--r--addons/mail/static/src/components/activity/activity.xml149
-rw-r--r--addons/mail/static/src/components/activity/activity_tests.js981
-rw-r--r--addons/mail/static/src/components/activity_box/activity_box.js60
-rw-r--r--addons/mail/static/src/components/activity_box/activity_box.scss41
-rw-r--r--addons/mail/static/src/components/activity_box/activity_box.xml45
-rw-r--r--addons/mail/static/src/components/activity_mark_done_popover/activity_mark_done_popover.js83
-rw-r--r--addons/mail/static/src/components/activity_mark_done_popover/activity_mark_done_popover.scss22
-rw-r--r--addons/mail/static/src/components/activity_mark_done_popover/activity_mark_done_popover.xml23
-rw-r--r--addons/mail/static/src/components/activity_mark_done_popover/activity_mark_done_popover_tests.js282
-rw-r--r--addons/mail/static/src/components/attachment/attachment.js162
-rw-r--r--addons/mail/static/src/components/attachment/attachment.scss196
-rw-r--r--addons/mail/static/src/components/attachment/attachment.xml109
-rw-r--r--addons/mail/static/src/components/attachment/attachment_tests.js545
-rw-r--r--addons/mail/static/src/components/attachment_box/attachment_box.js118
-rw-r--r--addons/mail/static/src/components/attachment_box/attachment_box.scss46
-rw-r--r--addons/mail/static/src/components/attachment_box/attachment_box.xml50
-rw-r--r--addons/mail/static/src/components/attachment_box/attachment_box_tests.js228
-rw-r--r--addons/mail/static/src/components/attachment_list/attachment_list.js54
-rw-r--r--addons/mail/static/src/components/attachment_list/attachment_list.scss13
-rw-r--r--addons/mail/static/src/components/attachment_list/attachment_list.xml22
-rw-r--r--addons/mail/static/src/components/attachment_viewer/attachment_viewer.js593
-rw-r--r--addons/mail/static/src/components/attachment_viewer/attachment_viewer.scss198
-rw-r--r--addons/mail/static/src/components/attachment_viewer/attachment_viewer.xml93
-rw-r--r--addons/mail/static/src/components/autocomplete_input/autocomplete_input.js161
-rw-r--r--addons/mail/static/src/components/autocomplete_input/autocomplete_input.xml8
-rw-r--r--addons/mail/static/src/components/chat_window/chat_window.js352
-rw-r--r--addons/mail/static/src/components/chat_window/chat_window.scss101
-rw-r--r--addons/mail/static/src/components/chat_window/chat_window.xml56
-rw-r--r--addons/mail/static/src/components/chat_window_header/chat_window_header.js107
-rw-r--r--addons/mail/static/src/components/chat_window_header/chat_window_header.scss95
-rw-r--r--addons/mail/static/src/components/chat_window_header/chat_window_header.xml53
-rw-r--r--addons/mail/static/src/components/chat_window_hidden_menu/chat_window_hidden_menu.js142
-rw-r--r--addons/mail/static/src/components/chat_window_hidden_menu/chat_window_hidden_menu.scss90
-rw-r--r--addons/mail/static/src/components/chat_window_hidden_menu/chat_window_hidden_menu.xml31
-rw-r--r--addons/mail/static/src/components/chat_window_manager/chat_window_manager.js37
-rw-r--r--addons/mail/static/src/components/chat_window_manager/chat_window_manager.scss16
-rw-r--r--addons/mail/static/src/components/chat_window_manager/chat_window_manager.xml21
-rw-r--r--addons/mail/static/src/components/chat_window_manager/chat_window_manager_tests.js1243
-rw-r--r--addons/mail/static/src/components/chatter/chatter.js103
-rw-r--r--addons/mail/static/src/components/chatter/chatter.scss37
-rw-r--r--addons/mail/static/src/components/chatter/chatter.xml53
-rw-r--r--addons/mail/static/src/components/chatter/chatter_tests.js337
-rw-r--r--addons/mail/static/src/components/chatter_container/chatter_container.js80
-rw-r--r--addons/mail/static/src/components/chatter_container/chatter_container.scss13
-rw-r--r--addons/mail/static/src/components/chatter_container/chatter_container.xml15
-rw-r--r--addons/mail/static/src/components/chatter_topbar/chatter_topbar.js128
-rw-r--r--addons/mail/static/src/components/chatter_topbar/chatter_topbar.scss100
-rw-r--r--addons/mail/static/src/components/chatter_topbar/chatter_topbar.xml72
-rw-r--r--addons/mail/static/src/components/chatter_topbar/chatter_topbar_tests.js590
-rw-r--r--addons/mail/static/src/components/composer/composer.js393
-rw-r--r--addons/mail/static/src/components/composer/composer.scss278
-rw-r--r--addons/mail/static/src/components/composer/composer.xml154
-rw-r--r--addons/mail/static/src/components/composer/composer_tests.js866
-rw-r--r--addons/mail/static/src/components/composer_text_input/composer_text_input.js334
-rw-r--r--addons/mail/static/src/components/composer_text_input/composer_text_input.scss25
-rw-r--r--addons/mail/static/src/components/composer_text_input/composer_text_input.xml44
-rw-r--r--addons/mail/static/src/components/dialog/dialog.js105
-rw-r--r--addons/mail/static/src/components/dialog/dialog.scss23
-rw-r--r--addons/mail/static/src/components/dialog/dialog.xml22
-rw-r--r--addons/mail/static/src/components/dialog_manager/dialog_manager.js58
-rw-r--r--addons/mail/static/src/components/dialog_manager/dialog_manager.xml15
-rw-r--r--addons/mail/static/src/components/discuss/discuss.js277
-rw-r--r--addons/mail/static/src/components/discuss/discuss.scss114
-rw-r--r--addons/mail/static/src/components/discuss/discuss.xml106
-rw-r--r--addons/mail/static/src/components/discuss/discuss_tests.js5523
-rw-r--r--addons/mail/static/src/components/discuss_mobile_mailbox_selection/discuss_mobile_mailbox_selection.js89
-rw-r--r--addons/mail/static/src/components/discuss_mobile_mailbox_selection/discuss_mobile_mailbox_selection.scss26
-rw-r--r--addons/mail/static/src/components/discuss_mobile_mailbox_selection/discuss_mobile_mailbox_selection.xml20
-rw-r--r--addons/mail/static/src/components/discuss_sidebar/discuss_sidebar.js307
-rw-r--r--addons/mail/static/src/components/discuss_sidebar/discuss_sidebar.scss110
-rw-r--r--addons/mail/static/src/components/discuss_sidebar/discuss_sidebar.xml80
-rw-r--r--addons/mail/static/src/components/discuss_sidebar_item/discuss_sidebar_item.js203
-rw-r--r--addons/mail/static/src/components/discuss_sidebar_item/discuss_sidebar_item.scss107
-rw-r--r--addons/mail/static/src/components/discuss_sidebar_item/discuss_sidebar_item.xml60
-rw-r--r--addons/mail/static/src/components/drop_zone/drop_zone.js136
-rw-r--r--addons/mail/static/src/components/drop_zone/drop_zone.scss29
-rw-r--r--addons/mail/static/src/components/drop_zone/drop_zone.xml12
-rw-r--r--addons/mail/static/src/components/editable_text/editable_text.js82
-rw-r--r--addons/mail/static/src/components/editable_text/editable_text.xml8
-rw-r--r--addons/mail/static/src/components/emojis_popover/emojis_popover.js42
-rw-r--r--addons/mail/static/src/components/emojis_popover/emojis_popover.scss21
-rw-r--r--addons/mail/static/src/components/emojis_popover/emojis_popover.xml14
-rw-r--r--addons/mail/static/src/components/file_uploader/file_uploader.js206
-rw-r--r--addons/mail/static/src/components/file_uploader/file_uploader.scss3
-rw-r--r--addons/mail/static/src/components/file_uploader/file_uploader.xml10
-rw-r--r--addons/mail/static/src/components/file_uploader/file_uploader_tests.js104
-rw-r--r--addons/mail/static/src/components/follow_button/follow_button.js91
-rw-r--r--addons/mail/static/src/components/follow_button/follow_button.scss19
-rw-r--r--addons/mail/static/src/components/follow_button/follow_button.xml24
-rw-r--r--addons/mail/static/src/components/follow_button/follow_button_tests.js331
-rw-r--r--addons/mail/static/src/components/follower/follower.js81
-rw-r--r--addons/mail/static/src/components/follower/follower.scss55
-rw-r--r--addons/mail/static/src/components/follower/follower.xml23
-rw-r--r--addons/mail/static/src/components/follower/follower_tests.js354
-rw-r--r--addons/mail/static/src/components/follower_list_menu/follower_list_menu.js152
-rw-r--r--addons/mail/static/src/components/follower_list_menu/follower_list_menu.scss19
-rw-r--r--addons/mail/static/src/components/follower_list_menu/follower_list_menu.xml36
-rw-r--r--addons/mail/static/src/components/follower_list_menu/follower_list_menu_tests.js465
-rw-r--r--addons/mail/static/src/components/follower_subtype/follower_subtype.js69
-rw-r--r--addons/mail/static/src/components/follower_subtype/follower_subtype.scss27
-rw-r--r--addons/mail/static/src/components/follower_subtype/follower_subtype.xml13
-rw-r--r--addons/mail/static/src/components/follower_subtype/follower_subtype_tests.js241
-rw-r--r--addons/mail/static/src/components/follower_subtype_list/follower_subtype_list.js87
-rw-r--r--addons/mail/static/src/components/follower_subtype_list/follower_subtype_list.scss8
-rw-r--r--addons/mail/static/src/components/follower_subtype_list/follower_subtype_list.xml38
-rw-r--r--addons/mail/static/src/components/mail_template/mail_template.js79
-rw-r--r--addons/mail/static/src/components/mail_template/mail_template.scss27
-rw-r--r--addons/mail/static/src/components/mail_template/mail_template.xml29
-rw-r--r--addons/mail/static/src/components/message/message.js621
-rw-r--r--addons/mail/static/src/components/message/message.scss354
-rw-r--r--addons/mail/static/src/components/message/message.xml220
-rw-r--r--addons/mail/static/src/components/message/message_tests.js692
-rw-r--r--addons/mail/static/src/components/message_author_prefix/message_author_prefix.js65
-rw-r--r--addons/mail/static/src/components/message_author_prefix/message_author_prefix.scss11
-rw-r--r--addons/mail/static/src/components/message_author_prefix/message_author_prefix.xml17
-rw-r--r--addons/mail/static/src/components/message_list/message_list.js531
-rw-r--r--addons/mail/static/src/components/message_list/message_list.scss135
-rw-r--r--addons/mail/static/src/components/message_list/message_list.xml107
-rw-r--r--addons/mail/static/src/components/message_seen_indicator/message_seen_indicator.js130
-rw-r--r--addons/mail/static/src/components/message_seen_indicator/message_seen_indicator.scss39
-rw-r--r--addons/mail/static/src/components/message_seen_indicator/message_seen_indicator.xml16
-rw-r--r--addons/mail/static/src/components/message_seen_indicator/message_seen_indicator_tests.js293
-rw-r--r--addons/mail/static/src/components/messaging_menu/messaging_menu.js266
-rw-r--r--addons/mail/static/src/components/messaging_menu/messaging_menu.scss133
-rw-r--r--addons/mail/static/src/components/messaging_menu/messaging_menu.xml89
-rw-r--r--addons/mail/static/src/components/messaging_menu/messaging_menu_tests.js849
-rw-r--r--addons/mail/static/src/components/mobile_messaging_navbar/mobile_messaging_navbar.js50
-rw-r--r--addons/mail/static/src/components/mobile_messaging_navbar/mobile_messaging_navbar.scss43
-rw-r--r--addons/mail/static/src/components/mobile_messaging_navbar/mobile_messaging_navbar.xml17
-rw-r--r--addons/mail/static/src/components/moderation_ban_dialog/moderation_ban_dialog.js79
-rw-r--r--addons/mail/static/src/components/moderation_ban_dialog/moderation_ban_dialog.xml26
-rw-r--r--addons/mail/static/src/components/moderation_discard_dialog/moderation_discard_dialog.js94
-rw-r--r--addons/mail/static/src/components/moderation_discard_dialog/moderation_discard_dialog.xml16
-rw-r--r--addons/mail/static/src/components/moderation_reject_dialog/moderation_reject_dialog.js89
-rw-r--r--addons/mail/static/src/components/moderation_reject_dialog/moderation_reject_dialog.xml16
-rw-r--r--addons/mail/static/src/components/notification_group/notification_group.js85
-rw-r--r--addons/mail/static/src/components/notification_group/notification_group.scss89
-rw-r--r--addons/mail/static/src/components/notification_group/notification_group.xml39
-rw-r--r--addons/mail/static/src/components/notification_list/notification_list.js216
-rw-r--r--addons/mail/static/src/components/notification_list/notification_list.scss37
-rw-r--r--addons/mail/static/src/components/notification_list/notification_list.xml41
-rw-r--r--addons/mail/static/src/components/notification_list/notification_list_item.scss174
-rw-r--r--addons/mail/static/src/components/notification_list/notification_list_notification_group_tests.js563
-rw-r--r--addons/mail/static/src/components/notification_list/notification_list_tests.js210
-rw-r--r--addons/mail/static/src/components/notification_popover/notification_popover.js89
-rw-r--r--addons/mail/static/src/components/notification_popover/notification_popover.scss7
-rw-r--r--addons/mail/static/src/components/notification_popover/notification_popover.xml17
-rw-r--r--addons/mail/static/src/components/partner_im_status_icon/partner_im_status_icon.js52
-rw-r--r--addons/mail/static/src/components/partner_im_status_icon/partner_im_status_icon.scss53
-rw-r--r--addons/mail/static/src/components/partner_im_status_icon/partner_im_status_icon.xml36
-rw-r--r--addons/mail/static/src/components/partner_im_status_icon/partner_im_status_icon_tests.js152
-rw-r--r--addons/mail/static/src/components/partner_mention_suggestion/partner_mention_suggestion.js79
-rw-r--r--addons/mail/static/src/components/partner_mention_suggestion/partner_mention_suggestion.scss26
-rw-r--r--addons/mail/static/src/components/partner_mention_suggestion/partner_mention_suggestion.xml19
-rw-r--r--addons/mail/static/src/components/partner_mention_suggestion/partner_mention_suggestion_tests.js141
-rw-r--r--addons/mail/static/src/components/thread_icon/thread_icon.js54
-rw-r--r--addons/mail/static/src/components/thread_icon/thread_icon.scss26
-rw-r--r--addons/mail/static/src/components/thread_icon/thread_icon.xml58
-rw-r--r--addons/mail/static/src/components/thread_icon/thread_icon_tests.js146
-rw-r--r--addons/mail/static/src/components/thread_needaction_preview/thread_needaction_preview.js131
-rw-r--r--addons/mail/static/src/components/thread_needaction_preview/thread_needaction_preview.scss104
-rw-r--r--addons/mail/static/src/components/thread_needaction_preview/thread_needaction_preview.xml58
-rw-r--r--addons/mail/static/src/components/thread_preview/thread_preview.js124
-rw-r--r--addons/mail/static/src/components/thread_preview/thread_preview.scss113
-rw-r--r--addons/mail/static/src/components/thread_preview/thread_preview.xml63
-rw-r--r--addons/mail/static/src/components/thread_textual_typing_status/thread_textual_typing_status.js49
-rw-r--r--addons/mail/static/src/components/thread_textual_typing_status/thread_textual_typing_status.scss12
-rw-r--r--addons/mail/static/src/components/thread_textual_typing_status/thread_textual_typing_status.xml14
-rw-r--r--addons/mail/static/src/components/thread_textual_typing_status/thread_textual_typing_status_tests.js480
-rw-r--r--addons/mail/static/src/components/thread_typing_icon/thread_typing_icon.js32
-rw-r--r--addons/mail/static/src/components/thread_typing_icon/thread_typing_icon.scss108
-rw-r--r--addons/mail/static/src/components/thread_typing_icon/thread_typing_icon.xml29
-rw-r--r--addons/mail/static/src/components/thread_viewer/thread_viewer.js199
-rw-r--r--addons/mail/static/src/components/thread_viewer/thread_viewer.scss38
-rw-r--r--addons/mail/static/src/components/thread_viewer/thread_viewer.xml44
-rw-r--r--addons/mail/static/src/components/thread_viewer/thread_viewer_tests.js364
-rw-r--r--addons/mail/static/src/env/env.js104
-rw-r--r--addons/mail/static/src/env/test_env.js152
-rw-r--r--addons/mail/static/src/js/activity.js3
-rw-r--r--addons/mail/static/src/js/attachment_box.js141
-rw-r--r--addons/mail/static/src/js/basic_model.js33
-rw-r--r--addons/mail/static/src/js/basic_view.js48
-rw-r--r--addons/mail/static/src/js/chatter.js660
-rw-r--r--addons/mail/static/src/js/composers/basic_composer.js864
-rw-r--r--addons/mail/static/src/js/composers/chatter_composer.js331
-rw-r--r--addons/mail/static/src/js/composers/extended_composer.js138
-rw-r--r--addons/mail/static/src/js/composers/mention_manager.js439
-rw-r--r--addons/mail/static/src/js/discuss.js1642
-rw-r--r--addons/mail/static/src/js/document_viewer.js8
-rw-r--r--addons/mail/static/src/js/followers.js461
-rw-r--r--addons/mail/static/src/js/form_controller.js47
-rw-r--r--addons/mail/static/src/js/form_renderer.js77
-rw-r--r--addons/mail/static/src/js/form_view.js26
-rw-r--r--addons/mail/static/src/js/many2one_avatar_user.js7
-rw-r--r--addons/mail/static/src/js/models/mail_failure.js127
-rw-r--r--addons/mail/static/src/js/models/messages/abstract_message.js462
-rw-r--r--addons/mail/static/src/js/models/messages/message.js806
-rw-r--r--addons/mail/static/src/js/models/threads/abstract_thread.js231
-rw-r--r--addons/mail/static/src/js/models/threads/channel.js474
-rw-r--r--addons/mail/static/src/js/models/threads/create_mode_document_thread.js75
-rw-r--r--addons/mail/static/src/js/models/threads/dm_chat.js109
-rw-r--r--addons/mail/static/src/js/models/threads/document_thread.js364
-rw-r--r--addons/mail/static/src/js/models/threads/livechat.js95
-rw-r--r--addons/mail/static/src/js/models/threads/mailbox.js224
-rw-r--r--addons/mail/static/src/js/models/threads/mixins/channel_seen_mixin.js229
-rw-r--r--addons/mail/static/src/js/models/threads/mixins/thread_typing_mixin.js360
-rw-r--r--addons/mail/static/src/js/models/threads/multi_user_channel.js73
-rw-r--r--addons/mail/static/src/js/models/threads/searchable_thread.js244
-rw-r--r--addons/mail/static/src/js/models/threads/thread.js383
-rw-r--r--addons/mail/static/src/js/models/threads/two_user_channel.js32
-rw-r--r--addons/mail/static/src/js/models/utils/cc_throttle_function.js181
-rw-r--r--addons/mail/static/src/js/models/utils/timer.js69
-rw-r--r--addons/mail/static/src/js/models/utils/timers.js82
-rw-r--r--addons/mail/static/src/js/services/mail_document_thread_manager.js260
-rw-r--r--addons/mail/static/src/js/services/mail_manager.js1356
-rw-r--r--addons/mail/static/src/js/services/mail_notification_manager.js597
-rw-r--r--addons/mail/static/src/js/services/mail_service.js17
-rw-r--r--addons/mail/static/src/js/services/mail_status_manager.js232
-rw-r--r--addons/mail/static/src/js/services/mail_window_manager.js780
-rw-r--r--addons/mail/static/src/js/systray/systray_activity_menu.js4
-rw-r--r--addons/mail/static/src/js/systray/systray_messaging_menu.js394
-rw-r--r--addons/mail/static/src/js/thread_field.js265
-rw-r--r--addons/mail/static/src/js/thread_widget.js686
-rw-r--r--addons/mail/static/src/js/thread_windows/abstract_thread_window.js519
-rw-r--r--addons/mail/static/src/js/thread_windows/thread_window.js403
-rw-r--r--addons/mail/static/src/js/tools/debug_manager.js1
-rw-r--r--addons/mail/static/src/js/tours/mail.js20
-rw-r--r--addons/mail/static/src/js/utils.js38
-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.js988
-rw-r--r--addons/mail/static/src/model/model_manager.js947
-rw-r--r--addons/mail/static/src/models/activity/activity.js289
-rw-r--r--addons/mail/static/src/models/activity_type/activity_type.js26
-rw-r--r--addons/mail/static/src/models/attachment/attachment.js342
-rw-r--r--addons/mail/static/src/models/attachment/attachment_tests.js156
-rw-r--r--addons/mail/static/src/models/attachment_viewer/attachment_viewer.js44
-rw-r--r--addons/mail/static/src/models/chat_window/chat_window.js322
-rw-r--r--addons/mail/static/src/models/chat_window_manager/chat_window_manager.js461
-rw-r--r--addons/mail/static/src/models/chatter/chatter.js326
-rw-r--r--addons/mail/static/src/models/composer/composer.js593
-rw-r--r--addons/mail/static/src/models/country/country.js23
-rw-r--r--addons/mail/static/src/models/device/device.js71
-rw-r--r--addons/mail/static/src/models/dialog/dialog.js52
-rw-r--r--addons/mail/static/src/models/dialog_manager/dialog_manager.js99
-rw-r--r--addons/mail/static/src/models/discuss/discuss.js402
-rw-r--r--addons/mail/static/src/models/follower/follower.js265
-rw-r--r--addons/mail/static/src/models/follower_subtype/follower_subtype.js71
-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.js38
-rw-r--r--addons/mail/static/src/models/mail_template/mail_template.js76
-rw-r--r--addons/mail/static/src/models/message/message.js587
-rw-r--r--addons/mail/static/src/models/message/message_tests.js132
-rw-r--r--addons/mail/static/src/models/message_seen_indicator/message_seen_indicator.js286
-rw-r--r--addons/mail/static/src/models/messaging/messaging.js216
-rw-r--r--addons/mail/static/src/models/messaging/messaging_tests.js66
-rw-r--r--addons/mail/static/src/models/messaging_initializer/messaging_initializer.js292
-rw-r--r--addons/mail/static/src/models/messaging_menu/messaging_menu.js131
-rw-r--r--addons/mail/static/src/models/messaging_notification_handler/messaging_notification_handler.js744
-rw-r--r--addons/mail/static/src/models/model/model.js245
-rw-r--r--addons/mail/static/src/models/notification/notification.js69
-rw-r--r--addons/mail/static/src/models/notification_group/notification_group.js115
-rw-r--r--addons/mail/static/src/models/notification_group_manager/notification_group_manager.js76
-rw-r--r--addons/mail/static/src/models/partner/partner.js273
-rw-r--r--addons/mail/static/src/models/thread/thread.js1370
-rw-r--r--addons/mail/static/src/models/thread/thread_tests.js162
-rw-r--r--addons/mail/static/src/models/thread_cache/thread_cache.js263
-rw-r--r--addons/mail/static/src/models/thread_partner_seen_info/thread_partner_seen_info.js28
-rw-r--r--addons/mail/static/src/models/thread_viewer/thread_viewer.js270
-rw-r--r--addons/mail/static/src/models/user/user.js78
-rw-r--r--addons/mail/static/src/scss/abstract_thread_window.scss194
-rw-r--r--addons/mail/static/src/scss/attachment_box.scss34
-rw-r--r--addons/mail/static/src/scss/chatter.scss35
-rw-r--r--addons/mail/static/src/scss/composer.scss342
-rw-r--r--addons/mail/static/src/scss/discuss.scss308
-rw-r--r--addons/mail/static/src/scss/followers.scss92
-rw-r--r--addons/mail/static/src/scss/out_of_office.scss8
-rw-r--r--addons/mail/static/src/scss/thread.scss167
-rw-r--r--addons/mail/static/src/scss/thread_window.scss63
-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.js860
-rw-r--r--addons/mail/static/src/utils/throttle/throttle.js382
-rw-r--r--addons/mail/static/src/utils/throttle/throttle_tests.js410
-rw-r--r--addons/mail/static/src/utils/timer/timer.js165
-rw-r--r--addons/mail/static/src/utils/timer/timer_tests.js431
-rw-r--r--addons/mail/static/src/utils/utils.js127
-rw-r--r--addons/mail/static/src/variables.scss14
-rw-r--r--addons/mail/static/src/widgets/common.xml25
-rw-r--r--addons/mail/static/src/widgets/discuss/discuss.js383
-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.js125
-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.js199
-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.js455
-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/xml/abstract_thread_window.xml85
-rw-r--r--addons/mail/static/src/xml/chatter.xml152
-rw-r--r--addons/mail/static/src/xml/composer.xml91
-rw-r--r--addons/mail/static/src/xml/discuss.xml352
-rw-r--r--addons/mail/static/src/xml/followers.xml95
-rw-r--r--addons/mail/static/src/xml/out_of_office.xml9
-rw-r--r--addons/mail/static/src/xml/systray.xml58
-rw-r--r--addons/mail/static/src/xml/text_emojis.xml2
-rw-r--r--addons/mail/static/src/xml/thread.xml536
-rw-r--r--addons/mail/static/src/xml/thread_window.xml32
320 files changed, 46099 insertions, 17669 deletions
diff --git a/addons/mail/static/src/bugfix/bugfix.js b/addons/mail/static/src/bugfix/bugfix.js
new file mode 100644
index 00000000000..5a7474b5c59
--- /dev/null
+++ b/addons/mail/static/src/bugfix/bugfix.js
@@ -0,0 +1,10 @@
+/**
+ * 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';
+
+});
diff --git a/addons/mail/static/src/bugfix/bugfix.scss b/addons/mail/static/src/bugfix/bugfix.scss
new file mode 100644
index 00000000000..c4272e527b4
--- /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 00000000000..c17906f75bb
--- /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 00000000000..a5ecddded12
--- /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 00000000000..2af2a2ddea3
--- /dev/null
+++ b/addons/mail/static/src/component_hooks/use_drag_visible_dropzone/use_drag_visible_dropzone.js
@@ -0,0 +1,88 @@
+odoo.define('mail/static/src/component_hooks/use_drag_visible_dropzone/use_drag_visible_dropzone.js', function (require) {
+'use strict';
+
+const { useRef, 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 });
+ const dropzoneRef = useRef('dropzone');
+
+ /**
+ * 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);
+ });
+
+ onWillUnmount(() => {
+ document.removeEventListener('dragenter', _onDragenterListener, true);
+ document.removeEventListener('dragleave', _onDragleaveListener, true);
+ document.removeEventListener('drop', _onDropListener, true);
+ });
+
+ //--------------------------------------------------------------------------
+ // 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;
+ if (dropzoneRef.comp && !dropzoneRef.comp.contains(ev.target)) {
+ 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 00000000000..6c84f195222
--- /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 00000000000..7ff5ea3a9da
--- /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 00000000000..19751ee937d
--- /dev/null
+++ b/addons/mail/static/src/component_hooks/use_store/use_store_tests.js
@@ -0,0 +1,190 @@
+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 { 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,
+ };
+ }, {
+ onUpdate: () => {
+ 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,
+ },
+ onUpdate: () => {
+ 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 00000000000..6c70f876160
--- /dev/null
+++ b/addons/mail/static/src/components/activity/activity.js
@@ -0,0 +1,177 @@
+odoo.define('mail/static/src/components/activity/activity.js', function (require) {
+'use strict';
+
+const components = {
+ ActivityMarkDonePopover: require('mail/static/src/components/activity_mark_done_popover/activity_mark_done_popover.js'),
+ FileUploader: require('mail/static/src/components/file_uploader/file_uploader.js'),
+ MailTemplate: require('mail/static/src/components/mail_template/mail_template.js'),
+};
+const useStore = require('mail/static/src/component_hooks/use_store/use_store.js');
+
+const {
+ auto_str_to_date,
+ getLangDateFormat,
+ getLangDatetimeFormat,
+} = require('web.time');
+
+const { Component, useState } = owl;
+const { useRef } = owl.hooks;
+
+class Activity extends Component {
+
+ /**
+ * @override
+ */
+ constructor(...args) {
+ super(...args);
+ this.state = useState({
+ areDetailsVisible: false,
+ });
+ useStore(props => {
+ const activity = this.env.models['mail.activity'].get(props.activityLocalId);
+ return {
+ activity: activity ? activity.__state : undefined,
+ assigneeNameOrDisplayName: (
+ activity &&
+ activity.assignee &&
+ activity.assignee.nameOrDisplayName()
+ ),
+ };
+ });
+ /**
+ * Reference of the file uploader.
+ * Useful to programmatically prompts the browser file uploader.
+ */
+ this._fileUploaderRef = useRef('fileUploader');
+ }
+
+ //--------------------------------------------------------------------------
+ // Public
+ //--------------------------------------------------------------------------
+
+ /**
+ * @returns {mail.activity}
+ */
+ get activity() {
+ return this.env.models['mail.activity'].get(this.props.activityLocalId);
+ }
+
+ /**
+ * @returns {string}
+ */
+ get assignedUserText() {
+ return _.str.sprintf(this.env._t("for %s"), this.activity.assignee.nameOrDisplayName());
+ }
+
+ /**
+ * @returns {string}
+ */
+ get delayLabel() {
+ const today = moment().startOf('day');
+ const momentDeadlineDate = moment(auto_str_to_date(this.activity.dateDeadline));
+ // true means no rounding
+ const diff = momentDeadlineDate.diff(today, 'days', true);
+ if (diff === 0) {
+ return this.env._t("Today:");
+ } else if (diff === -1) {
+ return this.env._t("Yesterday:");
+ } else if (diff < 0) {
+ return _.str.sprintf(this.env._t("%d days overdue:"), Math.abs(diff));
+ } else if (diff === 1) {
+ return this.env._t("Tomorrow:");
+ } else {
+ return _.str.sprintf(this.env._t("Due in %d days:"), Math.abs(diff));
+ }
+ }
+
+ /**
+ * @returns {string}
+ */
+ get formattedCreateDatetime() {
+ const momentCreateDate = moment(auto_str_to_date(this.activity.dateCreate));
+ const datetimeFormat = getLangDatetimeFormat();
+ return momentCreateDate.format(datetimeFormat);
+ }
+
+ /**
+ * @returns {string}
+ */
+ get formattedDeadlineDate() {
+ const momentDeadlineDate = moment(auto_str_to_date(this.activity.dateDeadline));
+ const datetimeFormat = getLangDateFormat();
+ return momentDeadlineDate.format(datetimeFormat);
+ }
+
+ /**
+ * @returns {string}
+ */
+ get MARK_DONE() {
+ return this.env._t("Mark Done");
+ }
+
+ /**
+ * @returns {string}
+ */
+ get summary() {
+ return _.str.sprintf(this.env._t("“%s”"), this.activity.summary);
+ }
+
+ //--------------------------------------------------------------------------
+ // Handlers
+ //--------------------------------------------------------------------------
+
+ /**
+ * @private
+ * @param {CustomEvent} ev
+ * @param {Object} ev.detail
+ * @param {mail.attachment} ev.detail.attachment
+ */
+ _onAttachmentCreated(ev) {
+ this.activity.markAsDone({ attachments: [ev.detail.attachment] });
+ }
+
+ /**
+ * @private
+ * @param {MouseEvent} ev
+ */
+ _onClickCancel(ev) {
+ ev.preventDefault();
+ this.activity.deleteServerRecord();
+ }
+
+ /**
+ * @private
+ */
+ _onClickDetailsButton() {
+ this.state.areDetailsVisible = !this.state.areDetailsVisible;
+ }
+
+ /**
+ * @private
+ * @param {MouseEvent} ev
+ */
+ _onClickEdit(ev) {
+ this.activity.edit();
+ }
+
+ /**
+ * @private
+ * @param {MouseEvent} ev
+ */
+ _onClickUploadDocument(ev) {
+ this._fileUploaderRef.comp.openBrowserFileUploader();
+ }
+
+}
+
+Object.assign(Activity, {
+ components,
+ props: {
+ activityLocalId: String,
+ },
+ template: 'mail.Activity',
+});
+
+return Activity;
+
+});
diff --git a/addons/mail/static/src/components/activity/activity.scss b/addons/mail/static/src/components/activity/activity.scss
new file mode 100644
index 00000000000..96117afd9ea
--- /dev/null
+++ b/addons/mail/static/src/components/activity/activity.scss
@@ -0,0 +1,174 @@
+// ------------------------------------------------------------------
+// Layout
+// ------------------------------------------------------------------
+
+.o_Activity {
+ display: flex;
+ flex: 0 0 auto;
+ padding: map-get($spacers, 2);
+}
+
+.o_Activity_detailsUserAvatar {
+ margin-inline-end: map-get($spacers, 2);
+ object-fit: cover;
+ height: 18px;
+ width: 18px;
+}
+
+.o_Activity_dueDateText, .o_Activity_summary {
+ margin-inline-end: map-get($spacers, 2);
+}
+
+.o_Activity_iconContainer {
+ @include o-position-absolute($top: auto, $left: auto, $bottom: -5px, $right: -5px);
+ display: flex;
+ align-items: center;
+ justify-content: center;
+ width: 25px;
+ height: 25px;
+ border-width: 2px;
+}
+
+.o_Activity_info {
+ display: flex;
+ align-items: baseline;
+}
+
+.o_Activity_sidebar {
+ display: flex;
+ flex: 0 0 36px;
+ margin-right: map-get($spacers, 3);
+ justify-content: center;
+}
+
+.o_Activity_toolButton {
+ padding-top: map-get($spacers, 0);
+}
+
+.o_Activity_tools {
+ display: flex;
+}
+
+.o_Activity_user {
+ height: 36px;
+ margin-left: map-get($spacers, 2);
+ margin-right: map-get($spacers, 2);
+ position: relative;
+ width: 36px;
+}
+
+.o_Activity_userAvatar {
+ height: map-get($sizes, 100);
+ width: map-get($sizes, 100);
+}
+
+// ------------------------------------------------------------------
+// Style
+// ------------------------------------------------------------------
+
+$o-mail-activity-default-color: gray('300') !default;
+$o-mail-activity-overdue-color: darken(theme-color('danger'), 10%) !default;
+$o-mail-activity-planned-color: darken(theme-color('success'), 10%) !default;
+$o-mail-activity-today-color: darken(theme-color('warning'), 10%) !default;
+
+
+.o_Activity_deadlineDateText {
+ &.o-default {
+ color: $o-mail-activity-default-color;
+ }
+
+ &.o-overdue {
+ color: $o-mail-activity-overdue-color;
+ }
+
+ &.o-planned {
+ color: $o-mail-activity-planned-color;
+ }
+
+ &.o-today {
+ color: $o-mail-activity-today-color;
+ }
+}
+
+.o_Activity_details {
+ color: gray('500');
+}
+
+.o_Activity_detailsCreatorAvatar {
+ margin-inline-start: map-get($spacers, 2);
+}
+
+.o_Activity_detailsUserAvatar {
+ border-radius: 50%;
+}
+
+.o_Activity_dueDateText {
+ font-weight: bolder;
+
+ &.o-default {
+ color: $o-mail-activity-default-color;
+ }
+
+ &.o-overdue {
+ color: $o-mail-activity-overdue-color;
+ }
+
+ &.o-planned {
+ color: $o-mail-activity-planned-color;
+ }
+
+ &.o-today {
+ color: $o-mail-activity-today-color;
+ }
+}
+
+/* Needed specifity to counter default bootstrap style */
+a:not([href]):not([tabindex]).o_Activity_detailsButton {
+ background: transparent;
+ opacity: 0.5;
+ color: gray('500');
+
+ &:hover {
+ opacity: 1;
+ color: gray('600');
+ }
+}
+
+.o_Activity_detailsCreator {
+ font-weight: bold;
+}
+
+.o_Activity_iconContainer {
+ color: white;
+ border-color: white;
+ border-radius: 100%;
+ border-style: solid;
+}
+
+.o_Activity_sidebar {
+ font-size: smaller;
+}
+
+.o_Activity_summary {
+ font-weight: bolder;
+ color: gray('900');
+}
+
+.o_Activity_toolButton {
+ opacity: 0.5;
+ color: gray('500');
+
+ &:hover {
+ opacity: 1;
+ color: gray('600');
+ }
+}
+
+.o_Activity_userAvatar {
+ border-radius: 50%;
+}
+
+.o_Activity_userName {
+ color: gray('500');
+}
+
diff --git a/addons/mail/static/src/components/activity/activity.xml b/addons/mail/static/src/components/activity/activity.xml
new file mode 100644
index 00000000000..22f4f9dddc7
--- /dev/null
+++ b/addons/mail/static/src/components/activity/activity.xml
@@ -0,0 +1,149 @@
+<?xml version="1.0" encoding="UTF-8"?>
+<templates xml:space="preserve">
+
+ <t t-name="mail.Activity" owl="1">
+ <div class="o_Activity">
+ <t t-if="activity">
+ <div class="o_Activity_sidebar">
+ <div class="o_Activity_user">
+ <t t-if="activity.assignee">
+ <img class="o_Activity_userAvatar" t-attf-src="/web/image/res.users/{{ activity.assignee.id }}/image_128" t-att-alt="activity.assignee.nameOrDisplayName()"/>
+ </t>
+ <div class="o_Activity_iconContainer"
+ t-att-class="{
+ 'bg-success-full': activity.state === 'planned',
+ 'bg-warning-full': activity.state === 'today',
+ 'bg-danger-full': activity.state === 'overdue',
+ }"
+ >
+ <i class="o_Activity_icon fa" t-attf-class="{{ activity.icon }}"/>
+ </div>
+ </div>
+ </div>
+ <div class="o_Activity_core">
+ <div class="o_Activity_info">
+ <div class="o_Activity_dueDateText"
+ t-att-class="{
+ 'o-default': activity.state === 'default',
+ 'o-overdue': activity.state === 'overdue',
+ 'o-planned': activity.state === 'planned',
+ 'o-today': activity.state === 'today',
+ }"
+ >
+ <t t-esc="delayLabel"/>
+ </div>
+ <t t-if="activity.summary">
+ <div class="o_Activity_summary">
+ <t t-esc="summary"/>
+ </div>
+ </t>
+ <t t-elif="activity.type">
+ <div class="o_Activity_summary o_Activity_type">
+ <t t-esc="activity.type.displayName"/>
+ </div>
+ </t>
+ <t t-if="activity.assignee">
+ <div class="o_Activity_userName">
+ <t t-esc="assignedUserText"/>
+ </div>
+ </t>
+ <a class="o_Activity_detailsButton btn btn-link" t-on-click="_onClickDetailsButton" role="button">
+ <i class="fa fa-info-circle" role="img" title="Info"/>
+ </a>
+ </div>
+
+ <div class="o_Activity_details" t-if="state.areDetailsVisible">
+ <dl class="dl-horizontal">
+ <t t-if="activity.type">
+ <dt>Activity type</dt>
+ <dd class="o_Activity_type">
+ <t t-esc="activity.type.displayName"/>
+ </dd>
+ </t>
+ <t t-if="activity.creator">
+ <dt>Created</dt>
+ <dd class="o_Activity_detailsCreation">
+ <t t-esc="formattedCreateDatetime"/>
+ <img class="o_Activity_detailsUserAvatar o_Activity_detailsCreatorAvatar" t-attf-src="/web/image/res.users/{{ activity.creator.id }}/image_128" t-att-title="activity.creator.nameOrDisplayName()" t-att-alt="activity.creator.nameOrDisplayName()"/>
+ <span class="o_Activity_detailsCreator">
+ <t t-esc="activity.creator.nameOrDisplayName()"/>
+ </span>
+ </dd>
+ </t>
+ <t t-if="activity.assignee">
+ <dt>Assigned to</dt>
+ <dd class="o_Activity_detailsAssignation">
+ <img class="o_Activity_detailsUserAvatar o_Activity_detailsAssignationUserAvatar" t-attf-src="/web/image/res.users/{{ activity.assignee.id }}/image_128" t-att-title="activity.assignee.nameOrDisplayName()" t-att-alt="activity.assignee.nameOrDisplayName()"/>
+ <t t-esc="activity.assignee.nameOrDisplayName()"/>
+ </dd>
+ </t>
+ <dt>Due on</dt>
+ <dd class="o_Activity_detailsDueDate">
+ <span class="o_Activity_deadlineDateText"
+ t-att-class="{
+ 'o-default': activity.state === 'default',
+ 'o-overdue': activity.state === 'overdue',
+ 'o-planned': activity.state === 'planned',
+ 'o-today': activity.state === 'today',
+ }"
+ >
+ <t t-esc="formattedDeadlineDate"/>
+ </span>
+ </dd>
+ </dl>
+ </div>
+
+ <div class="o_Activity_note" t-if="activity.note">
+ <t t-raw="activity.note"/>
+ </div>
+
+ <t t-if="activity.mailTemplates.length > 0">
+ <div class="o_Activity_mailTemplates">
+ <t t-foreach="activity.mailTemplates" t-as="mailTemplate" t-key="mailTemplate.localId">
+ <MailTemplate
+ class="o_Activity_mailTemplate"
+ activityLocalId="activity.localId"
+ mailTemplateLocalId="mailTemplate.localId"
+ />
+ </t>
+ </div>
+ </t>
+
+ <t t-if="activity.canWrite">
+ <div name="tools" class="o_Activity_tools">
+ <t t-if="activity.category !== 'upload_file'">
+ <Popover position="'right'" title="MARK_DONE">
+ <button class="o_Activity_toolButton o_Activity_markDoneButton btn btn-link" t-att-title="MARK_DONE">
+ <i class="fa fa-check"/> Mark Done
+ </button>
+ <t t-set="opened">
+ <ActivityMarkDonePopover activityLocalId="props.activityLocalId"/>
+ </t>
+ </Popover>
+ </t>
+ <t t-else="">
+ <button class="o_Activity_toolButton o_Activity_uploadButton btn btn-link" t-on-click="_onClickUploadDocument">
+ <i class="fa fa-upload"/> Upload Document
+ </button>
+ <FileUploader
+ attachmentLocalIds="activity.attachments.map(attachment => attachment.localId)"
+ uploadId="activity.res_id"
+ uploadModel="activity.res_model"
+ t-on-o-attachment-created="_onAttachmentCreated"
+ t-ref="fileUploader"
+ />
+ </t>
+ <button class="o_Activity_toolButton o_Activity_editButton btn btn-link" t-on-click="_onClickEdit">
+ <i class="fa fa-pencil"/> Edit
+ </button>
+ <button class="o_Activity_toolButton o_Activity_cancelButton btn btn-link" t-on-click="_onClickCancel" >
+ <i class="fa fa-times"/> Cancel
+ </button>
+ </div>
+ </t>
+ </div>
+ </t>
+ </div>
+ </t>
+
+</templates>
diff --git a/addons/mail/static/src/components/activity/activity_tests.js b/addons/mail/static/src/components/activity/activity_tests.js
new file mode 100644
index 00000000000..714f0d593eb
--- /dev/null
+++ b/addons/mail/static/src/components/activity/activity_tests.js
@@ -0,0 +1,981 @@
+odoo.define('mail/static/src/components/activity/activity_tests.js', function (require) {
+'use strict';
+
+const components = {
+ Activity: require('mail/static/src/components/activity/activity.js'),
+};
+
+const {
+ afterEach: utilsAfterEach,
+ afterNextRender,
+ beforeEach: utilsBeforeEach,
+ start: utilsStart,
+} = require('mail/static/src/utils/test_utils.js');
+const useStore = require('mail/static/src/component_hooks/use_store/use_store.js');
+
+const Bus = require('web.Bus');
+const { date_to_str } = require('web.time');
+
+const { Component, tags: { xml } } = owl;
+
+QUnit.module('mail', {}, function () {
+QUnit.module('components', {}, function () {
+QUnit.module('activity', {}, function () {
+QUnit.module('activity_tests.js', {
+ beforeEach() {
+ utilsBeforeEach(this);
+
+ this.createActivityComponent = async function (activity) {
+ const ActivityComponent = components.Activity;
+ ActivityComponent.env = this.env;
+ this.component = new ActivityComponent(null, {
+ activityLocalId: activity.localId,
+ });
+ await this.component.mount(this.widget.el);
+ };
+
+ this.start = async params => {
+ let { env, widget } = await utilsStart(Object.assign({}, params, {
+ data: this.data,
+ }));
+ this.env = env;
+ this.widget = widget;
+ };
+ },
+ afterEach() {
+ utilsAfterEach(this);
+ if (this.component) {
+ this.component.destroy();
+ }
+ if (this.widget) {
+ this.widget.destroy();
+ }
+ this.env = undefined;
+ },
+});
+
+QUnit.test('activity simplest layout', async function (assert) {
+ assert.expect(12);
+
+ await this.start();
+ const activity = this.env.models['mail.activity'].create();
+ await this.createActivityComponent(activity);
+ assert.strictEqual(
+ document.querySelectorAll('.o_Activity').length,
+ 1,
+ "should have activity component"
+ );
+ assert.strictEqual(
+ document.querySelectorAll('.o_Activity_sidebar').length,
+ 1,
+ "should have activity sidebar"
+ );
+ assert.strictEqual(
+ document.querySelectorAll('.o_Activity_core').length,
+ 1,
+ "should have activity core"
+ );
+ assert.strictEqual(
+ document.querySelectorAll('.o_Activity_user').length,
+ 1,
+ "should have activity user"
+ );
+ assert.strictEqual(
+ document.querySelectorAll('.o_Activity_info').length,
+ 1,
+ "should have activity info"
+ );
+ assert.strictEqual(
+ document.querySelectorAll('.o_Activity_note').length,
+ 0,
+ "should not have activity note"
+ );
+ assert.strictEqual(
+ document.querySelectorAll('.o_Activity_details').length,
+ 0,
+ "should not have activity details"
+ );
+ assert.strictEqual(
+ document.querySelectorAll('.o_Activity_mailTemplates').length,
+ 0,
+ "should not have activity mail templates"
+ );
+ assert.strictEqual(
+ document.querySelectorAll('.o_Activity_editButton').length,
+ 0,
+ "should not have activity Edit button"
+ );
+ assert.strictEqual(
+ document.querySelectorAll('.o_Activity_cancelButton').length,
+ 0,
+ "should not have activity Cancel button"
+ );
+ assert.strictEqual(
+ document.querySelectorAll('.o_Activity_markDoneButton').length,
+ 0,
+ "should not have activity Mark as Done button"
+ );
+ assert.strictEqual(
+ document.querySelectorAll('.o_Activity_uploadButton').length,
+ 0,
+ "should not have activity Upload button"
+ );
+});
+
+QUnit.test('activity with note layout', async function (assert) {
+ assert.expect(3);
+
+ await this.start();
+ const activity = this.env.models['mail.activity'].create({
+ note: 'There is no good or bad note'
+ });
+ await this.createActivityComponent(activity);
+ assert.strictEqual(
+ document.querySelectorAll('.o_Activity').length,
+ 1,
+ "should have activity component"
+ );
+ assert.strictEqual(
+ document.querySelectorAll('.o_Activity_note').length,
+ 1,
+ "should have activity note"
+ );
+ assert.strictEqual(
+ document.querySelector('.o_Activity_note').textContent,
+ "There is no good or bad note",
+ "activity note should be 'There is no good or bad note'"
+ );
+});
+
+QUnit.test('activity info layout when planned after tomorrow', async function (assert) {
+ assert.expect(4);
+
+ await this.start();
+ const today = new Date();
+ const fiveDaysFromNow = new Date();
+ fiveDaysFromNow.setDate(today.getDate() + 5);
+ const activity = this.env.models['mail.activity'].create({
+ dateDeadline: date_to_str(fiveDaysFromNow),
+ state: 'planned',
+ });
+ await this.createActivityComponent(activity);
+ assert.strictEqual(
+ document.querySelectorAll('.o_Activity').length,
+ 1,
+ "should have activity component"
+ );
+ assert.strictEqual(
+ document.querySelectorAll('.o_Activity_dueDateText').length,
+ 1,
+ "should have activity delay"
+ );
+ assert.ok(
+ document.querySelector('.o_Activity_dueDateText').classList.contains('o-planned'),
+ "activity delay should have the right color modifier class (planned)"
+ );
+ assert.strictEqual(
+ document.querySelector('.o_Activity_dueDateText').textContent,
+ "Due in 5 days:",
+ "activity delay should have 'Due in 5 days:' as label"
+ );
+});
+
+QUnit.test('activity info layout when planned tomorrow', async function (assert) {
+ assert.expect(4);
+
+ await this.start();
+ const today = new Date();
+ const tomorrow = new Date();
+ tomorrow.setDate(today.getDate() + 1);
+ const activity = this.env.models['mail.activity'].create({
+ dateDeadline: date_to_str(tomorrow),
+ state: 'planned',
+ });
+ await this.createActivityComponent(activity);
+ assert.strictEqual(
+ document.querySelectorAll('.o_Activity').length,
+ 1,
+ "should have activity component"
+ );
+ assert.strictEqual(
+ document.querySelectorAll('.o_Activity_dueDateText').length,
+ 1,
+ "should have activity delay"
+ );
+ assert.ok(
+ document.querySelector('.o_Activity_dueDateText').classList.contains('o-planned'),
+ "activity delay should have the right color modifier class (planned)"
+ );
+ assert.strictEqual(
+ document.querySelector('.o_Activity_dueDateText').textContent,
+ 'Tomorrow:',
+ "activity delay should have 'Tomorrow:' as label"
+ );
+});
+
+QUnit.test('activity info layout when planned today', async function (assert) {
+ assert.expect(4);
+
+ await this.start();
+ const today = new Date();
+ const activity = this.env.models['mail.activity'].create({
+ dateDeadline: date_to_str(today),
+ state: 'today',
+ });
+ await this.createActivityComponent(activity);
+ assert.strictEqual(
+ document.querySelectorAll('.o_Activity').length,
+ 1,
+ "should have activity component"
+ );
+ assert.strictEqual(
+ document.querySelectorAll('.o_Activity_dueDateText').length,
+ 1,
+ "should have activity delay"
+ );
+ assert.ok(
+ document.querySelector('.o_Activity_dueDateText').classList.contains('o-today'),
+ "activity delay should have the right color modifier class (today)"
+ );
+ assert.strictEqual(
+ document.querySelector('.o_Activity_dueDateText').textContent,
+ "Today:",
+ "activity delay should have 'Today:' as label"
+ );
+});
+
+QUnit.test('activity info layout when planned yesterday', async function (assert) {
+ assert.expect(4);
+
+ await this.start();
+ const today = new Date();
+ const yesterday = new Date();
+ yesterday.setDate(today.getDate() - 1);
+ const activity = this.env.models['mail.activity'].create({
+ dateDeadline: date_to_str(yesterday),
+ state: 'overdue',
+ });
+ await this.createActivityComponent(activity);
+ assert.strictEqual(
+ document.querySelectorAll('.o_Activity').length,
+ 1,
+ "should have activity component"
+ );
+ assert.strictEqual(
+ document.querySelectorAll('.o_Activity_dueDateText').length,
+ 1,
+ "should have activity delay"
+ );
+ assert.ok(
+ document.querySelector('.o_Activity_dueDateText').classList.contains('o-overdue'),
+ "activity delay should have the right color modifier class (overdue)"
+ );
+ assert.strictEqual(
+ document.querySelector('.o_Activity_dueDateText').textContent,
+ "Yesterday:",
+ "activity delay should have 'Yesterday:' as label"
+ );
+});
+
+QUnit.test('activity info layout when planned before yesterday', async function (assert) {
+ assert.expect(4);
+
+ await this.start();
+ const today = new Date();
+ const fiveDaysBeforeNow = new Date();
+ fiveDaysBeforeNow.setDate(today.getDate() - 5);
+ const activity = this.env.models['mail.activity'].create({
+ dateDeadline: date_to_str(fiveDaysBeforeNow),
+ state: 'overdue',
+ });
+ await this.createActivityComponent(activity);
+ assert.strictEqual(
+ document.querySelectorAll('.o_Activity').length,
+ 1,
+ "should have activity component"
+ );
+ assert.strictEqual(
+ document.querySelectorAll('.o_Activity_dueDateText').length,
+ 1,
+ "should have activity delay"
+ );
+ assert.ok(
+ document.querySelector('.o_Activity_dueDateText').classList.contains('o-overdue'),
+ "activity delay should have the right color modifier class (overdue)"
+ );
+ assert.strictEqual(
+ document.querySelector('.o_Activity_dueDateText').textContent,
+ "5 days overdue:",
+ "activity delay should have '5 days overdue:' as label"
+ );
+});
+
+QUnit.test('activity with a summary layout', async function (assert) {
+ assert.expect(4);
+
+ await this.start();
+ const activity = this.env.models['mail.activity'].create({
+ summary: 'test summary',
+ });
+ await this.createActivityComponent(activity);
+ assert.strictEqual(
+ document.querySelectorAll('.o_Activity').length,
+ 1,
+ "should have activity component"
+ );
+ assert.strictEqual(
+ document.querySelectorAll('.o_Activity_summary').length,
+ 1,
+ "should have activity summary"
+ );
+ assert.strictEqual(
+ document.querySelectorAll('.o_Activity_type').length,
+ 0,
+ "should not have the activity type as summary"
+ );
+ assert.strictEqual(
+ document.querySelector('.o_Activity_summary').textContent.trim(),
+ "“test summary”",
+ "should have the specific activity summary in activity summary"
+ );
+});
+
+QUnit.test('activity without summary layout', async function (assert) {
+ assert.expect(5);
+
+ await this.start();
+ const activity = this.env.models['mail.activity'].create({
+ type: [['insert', { id: 1, displayName: "Fake type" }]],
+ });
+ await this.createActivityComponent(activity);
+ assert.strictEqual(
+ document.querySelectorAll('.o_Activity').length,
+ 1,
+ "should have activity component"
+ );
+ assert.strictEqual(
+ document.querySelectorAll('.o_Activity_type').length,
+ 1,
+ "activity details should have an activity type section"
+ );
+ assert.strictEqual(
+ document.querySelector('.o_Activity_type').textContent.trim(),
+ "Fake type",
+ "activity details should have the activity type display name in type section"
+ );
+ assert.strictEqual(
+ document.querySelectorAll('.o_Activity_summary.o_Activity_type').length,
+ 1,
+ "should have activity type as summary"
+ );
+ assert.strictEqual(
+ document.querySelectorAll('.o_Activity_summary:not(.o_Activity_type)').length,
+ 0,
+ "should not have a specific summary"
+ );
+});
+
+QUnit.test('activity details toggle', async function (assert) {
+ assert.expect(5);
+
+ await this.start();
+ const today = new Date();
+ const tomorrow = new Date();
+ tomorrow.setDate(today.getDate() + 1);
+ const activity = this.env.models['mail.activity'].create({
+ creator: [['insert', { id: 1, partnerDisplayName: "Admin" }]],
+ dateCreate: date_to_str(today),
+ dateDeadline: date_to_str(tomorrow),
+ state: 'planned',
+ type: [['insert', { id: 1, displayName: "Fake type" }]],
+ });
+ await this.createActivityComponent(activity);
+ assert.strictEqual(
+ document.querySelectorAll('.o_Activity').length,
+ 1,
+ "should have activity component"
+ );
+ assert.strictEqual(
+ document.querySelectorAll('.o_Activity_details').length,
+ 0,
+ "activity details should not be visible by default"
+ );
+ assert.strictEqual(
+ document.querySelectorAll('.o_Activity_detailsButton').length,
+ 1,
+ "activity should have a details button"
+ );
+
+ await afterNextRender(() =>
+ document.querySelector('.o_Activity_detailsButton').click()
+ );
+ assert.strictEqual(
+ document.querySelectorAll('.o_Activity_details').length,
+ 1,
+ "activity details should be visible after clicking on details button"
+ );
+
+ await afterNextRender(() =>
+ document.querySelector('.o_Activity_detailsButton').click()
+ );
+ assert.strictEqual(
+ document.querySelectorAll('.o_Activity_details').length,
+ 0,
+ "activity details should no longer be visible after clicking again on details button"
+ );
+});
+
+QUnit.test('activity details layout', async function (assert) {
+ assert.expect(11);
+
+ await this.start();
+ const today = new Date();
+ const tomorrow = new Date();
+ tomorrow.setDate(today.getDate() + 1);
+ const activity = this.env.models['mail.activity'].create({
+ assignee: [['insert', { id: 10, partnerDisplayName: "Pauvre pomme" }]],
+ creator: [['insert', { id: 1, partnerDisplayName: "Admin" }]],
+ dateCreate: date_to_str(today),
+ dateDeadline: date_to_str(tomorrow),
+ state: 'planned',
+ type: [['insert', { id: 1, displayName: "Fake type" }]],
+ });
+ await this.createActivityComponent(activity);
+ assert.strictEqual(
+ document.querySelectorAll('.o_Activity').length,
+ 1,
+ "should have activity component"
+ );
+ assert.strictEqual(
+ document.querySelectorAll('.o_Activity_userAvatar').length,
+ 1,
+ "should have activity user avatar"
+ );
+ assert.strictEqual(
+ document.querySelectorAll('.o_Activity_detailsButton').length,
+ 1,
+ "activity should have a details button"
+ );
+
+ await afterNextRender(() =>
+ document.querySelector('.o_Activity_detailsButton').click()
+ );
+ assert.strictEqual(
+ document.querySelectorAll('.o_Activity_details').length,
+ 1,
+ "activity details should be visible after clicking on details button"
+ );
+ assert.strictEqual(
+ document.querySelectorAll('.o_Activity_details .o_Activity_type').length,
+ 1,
+ "activity details should have type"
+ );
+ assert.strictEqual(
+ document.querySelector('.o_Activity_details .o_Activity_type').textContent,
+ "Fake type",
+ "activity details type should be 'Fake type'"
+ );
+ assert.strictEqual(
+ document.querySelectorAll('.o_Activity_detailsCreation').length,
+ 1,
+ "activity details should have creation date "
+ );
+ assert.strictEqual(
+ document.querySelectorAll('.o_Activity_detailsCreator').length,
+ 1,
+ "activity details should have creator"
+ );
+ assert.strictEqual(
+ document.querySelectorAll('.o_Activity_detailsAssignation').length,
+ 1,
+ "activity details should have assignation information"
+ );
+ assert.strictEqual(
+ document.querySelector('.o_Activity_detailsAssignation').textContent.indexOf('Pauvre pomme'),
+ 0,
+ "activity details assignation information should contain creator display name"
+ );
+ assert.strictEqual(
+ document.querySelectorAll('.o_Activity_detailsAssignationUserAvatar').length,
+ 1,
+ "activity details should have user avatar"
+ );
+});
+
+QUnit.test('activity with mail template layout', async function (assert) {
+ assert.expect(8);
+
+ await this.start();
+ const activity = this.env.models['mail.activity'].create({
+ mailTemplates: [['insert', { id: 1, name: "Dummy mail template" }]],
+ });
+ await this.createActivityComponent(activity);
+ assert.strictEqual(
+ document.querySelectorAll('.o_Activity').length,
+ 1,
+ "should have activity component"
+ );
+ assert.strictEqual(
+ document.querySelectorAll('.o_Activity_sidebar').length,
+ 1,
+ "should have activity sidebar"
+ );
+ assert.strictEqual(
+ document.querySelectorAll('.o_Activity_mailTemplates').length,
+ 1,
+ "should have activity mail templates"
+ );
+ assert.strictEqual(
+ document.querySelectorAll('.o_Activity_mailTemplate').length,
+ 1,
+ "should have activity mail template"
+ );
+ assert.strictEqual(
+ document.querySelectorAll('.o_MailTemplate_name').length,
+ 1,
+ "should have activity mail template name"
+ );
+ assert.strictEqual(
+ document.querySelector('.o_MailTemplate_name').textContent,
+ "Dummy mail template",
+ "should have activity mail template name"
+ );
+ assert.strictEqual(
+ document.querySelectorAll('.o_MailTemplate_preview').length,
+ 1,
+ "should have activity mail template name preview button"
+ );
+ assert.strictEqual(
+ document.querySelectorAll('.o_MailTemplate_send').length,
+ 1,
+ "should have activity mail template name send button"
+ );
+});
+
+QUnit.test('activity with mail template: preview mail', async function (assert) {
+ assert.expect(10);
+
+ const bus = new Bus();
+ bus.on('do-action', null, payload => {
+ assert.step('do_action');
+ assert.strictEqual(
+ payload.action.context.default_res_id,
+ 42,
+ 'Action should have the activity res id as default res id in context'
+ );
+ assert.strictEqual(
+ payload.action.context.default_model,
+ 'res.partner',
+ 'Action should have the activity res model as default model in context'
+ );
+ assert.ok(
+ payload.action.context.default_use_template,
+ 'Action should have true as default use_template in context'
+ );
+ assert.strictEqual(
+ payload.action.context.default_template_id,
+ 1,
+ 'Action should have the selected mail template id as default template id in context'
+ );
+ assert.strictEqual(
+ payload.action.type,
+ "ir.actions.act_window",
+ 'Action should be of type "ir.actions.act_window"'
+ );
+ assert.strictEqual(
+ payload.action.res_model,
+ "mail.compose.message",
+ 'Action should have "mail.compose.message" as res_model'
+ );
+ });
+
+ await this.start({ env: { bus } });
+ const activity = this.env.models['mail.activity'].create({
+ mailTemplates: [['insert', {
+ id: 1,
+ name: "Dummy mail template",
+ }]],
+ res_id: 42,
+ res_model: 'res.partner',
+ });
+ await this.createActivityComponent(activity);
+ assert.strictEqual(
+ document.querySelectorAll('.o_Activity').length,
+ 1,
+ "should have activity component"
+ );
+ assert.strictEqual(
+ document.querySelectorAll('.o_MailTemplate_preview').length,
+ 1,
+ "should have activity mail template name preview button"
+ );
+
+ document.querySelector('.o_MailTemplate_preview').click();
+ assert.verifySteps(
+ ['do_action'],
+ "should have called 'compose email' action correctly"
+ );
+});
+
+QUnit.test('activity with mail template: send mail', async function (assert) {
+ assert.expect(7);
+
+ await this.start({
+ async mockRPC(route, args) {
+ if (args.method === 'activity_send_mail') {
+ assert.step('activity_send_mail');
+ assert.strictEqual(args.args[0].length, 1);
+ assert.strictEqual(args.args[0][0], 42);
+ assert.strictEqual(args.args[1], 1);
+ return;
+ } else {
+ return this._super(...arguments);
+ }
+ },
+ });
+ const activity = this.env.models['mail.activity'].create({
+ mailTemplates: [['insert', {
+ id: 1,
+ name: "Dummy mail template",
+ }]],
+ res_id: 42,
+ });
+ await this.createActivityComponent(activity);
+ assert.strictEqual(
+ document.querySelectorAll('.o_Activity').length,
+ 1,
+ "should have activity component"
+ );
+ assert.strictEqual(
+ document.querySelectorAll('.o_MailTemplate_send').length,
+ 1,
+ "should have activity mail template name send button"
+ );
+
+ document.querySelector('.o_MailTemplate_send').click();
+ assert.verifySteps(
+ ['activity_send_mail'],
+ "should have called activity_send_mail rpc"
+ );
+});
+
+QUnit.test('activity upload document is available', async function (assert) {
+ assert.expect(3);
+
+ await this.start();
+ const today = new Date();
+ const tomorrow = new Date();
+ tomorrow.setDate(today.getDate() + 1);
+ const activity = this.env.models['mail.activity'].create({
+ canWrite: true,
+ category: 'upload_file',
+ });
+ await this.createActivityComponent(activity);
+ assert.strictEqual(
+ document.querySelectorAll('.o_Activity').length,
+ 1,
+ "should have activity component"
+ );
+ assert.strictEqual(
+ document.querySelectorAll('.o_Activity_uploadButton').length,
+ 1,
+ "should have activity upload button"
+ );
+ assert.strictEqual(
+ document.querySelectorAll('.o_FileUploader').length,
+ 1,
+ "should have a file uploader"
+ );
+});
+
+QUnit.test('activity click on mark as done', async function (assert) {
+ assert.expect(4);
+
+ await this.start();
+ const today = new Date();
+ const tomorrow = new Date();
+ tomorrow.setDate(today.getDate() + 1);
+ const activity = this.env.models['mail.activity'].create({
+ canWrite: true,
+ category: 'not_upload_file',
+ });
+ await this.createActivityComponent(activity);
+
+ assert.strictEqual(
+ document.querySelectorAll('.o_Activity').length,
+ 1,
+ "should have activity component"
+ );
+ assert.strictEqual(
+ document.querySelectorAll('.o_Activity_markDoneButton').length,
+ 1,
+ "should have activity Mark as Done button"
+ );
+
+ await afterNextRender(() => {
+ document.querySelector('.o_Activity_markDoneButton').click();
+ });
+ assert.strictEqual(
+ document.querySelectorAll('.o_ActivityMarkDonePopover').length,
+ 1,
+ "should have opened the mark done popover"
+ );
+
+ await afterNextRender(() => {
+ document.querySelector('.o_Activity_markDoneButton').click();
+ });
+ assert.strictEqual(
+ document.querySelectorAll('.o_ActivityMarkDonePopover').length,
+ 0,
+ "should have closed the mark done popover"
+ );
+});
+
+QUnit.test('activity click on edit', async function (assert) {
+ assert.expect(9);
+
+ const bus = new Bus();
+ bus.on('do-action', null, payload => {
+ assert.step('do_action');
+ assert.strictEqual(
+ payload.action.context.default_res_id,
+ 42,
+ 'Action should have the activity res id as default res id in context'
+ );
+ assert.strictEqual(
+ payload.action.context.default_res_model,
+ 'res.partner',
+ 'Action should have the activity res model as default res model in context'
+ );
+ assert.strictEqual(
+ payload.action.type,
+ "ir.actions.act_window",
+ 'Action should be of type "ir.actions.act_window"'
+ );
+ assert.strictEqual(
+ payload.action.res_model,
+ "mail.activity",
+ 'Action should have "mail.activity" as res_model'
+ );
+ assert.strictEqual(
+ payload.action.res_id,
+ 12,
+ 'Action should have activity id as res_id'
+ );
+ });
+
+ await this.start({ env: { bus } });
+ const activity = this.env.models['mail.activity'].create({
+ canWrite: true,
+ id: 12,
+ mailTemplates: [['insert', { id: 1, name: "Dummy mail template" }]],
+ res_id: 42,
+ res_model: 'res.partner',
+ });
+ await this.createActivityComponent(activity);
+ assert.strictEqual(
+ document.querySelectorAll('.o_Activity').length,
+ 1,
+ "should have activity component"
+ );
+ assert.strictEqual(
+ document.querySelectorAll('.o_Activity_editButton').length,
+ 1,
+ "should have activity edit button"
+ );
+
+ document.querySelector('.o_Activity_editButton').click();
+ assert.verifySteps(
+ ['do_action'],
+ "should have called 'schedule activity' action correctly"
+ );
+});
+
+QUnit.test('activity edition', async function (assert) {
+ assert.expect(14);
+
+ this.data['mail.activity'].records = [{
+ can_write: true,
+ icon: 'fa-times',
+ id: 12,
+ res_id: 42,
+ res_model: 'res.partner',
+ }];
+
+ const bus = new Bus();
+ bus.on('do-action', null, payload => {
+ assert.step('do_action');
+ assert.strictEqual(
+ payload.action.context.default_res_id,
+ 42,
+ 'Action should have the activity res id as default res id in context'
+ );
+ assert.strictEqual(
+ payload.action.context.default_res_model,
+ 'res.partner',
+ 'Action should have the activity res model as default res model in context'
+ );
+ assert.strictEqual(
+ payload.action.type,
+ 'ir.actions.act_window',
+ 'Action should be of type "ir.actions.act_window"'
+ );
+ assert.strictEqual(
+ payload.action.res_model,
+ 'mail.activity',
+ 'Action should have "mail.activity" as res_model'
+ );
+ assert.strictEqual(
+ payload.action.res_id,
+ 12,
+ 'Action should have activity id as res_id'
+ );
+ this.data['mail.activity'].records[0].icon = 'fa-check';
+ payload.options.on_close();
+ });
+
+ await this.start({ env: { bus } });
+ const activity = this.env.models['mail.activity'].create(
+ this.env.models['mail.activity'].convertData(
+ this.data['mail.activity'].records[0]
+ )
+ );
+ await this.createActivityComponent(activity);
+
+ assert.containsOnce(
+ document.body,
+ '.o_Activity',
+ "should have activity component"
+ );
+ assert.containsOnce(
+ document.body,
+ '.o_Activity_editButton',
+ "should have activity edit button"
+ );
+ assert.containsOnce(
+ document.body,
+ '.o_Activity_icon',
+ "should have activity icon"
+ );
+ assert.containsOnce(
+ document.body,
+ '.o_Activity_icon.fa-times',
+ "should have initial activity icon"
+ );
+ assert.containsNone(
+ document.body,
+ '.o_Activity_icon.fa-check',
+ "should not have new activity icon when not edited yet"
+ );
+
+ await afterNextRender(() => {
+ document.querySelector('.o_Activity_editButton').click();
+ });
+ assert.verifySteps(
+ ['do_action'],
+ "should have called 'schedule activity' action correctly"
+ );
+ assert.containsNone(
+ document.body,
+ '.o_Activity_icon.fa-times',
+ "should no more have initial activity icon once edited"
+ );
+ assert.containsOnce(
+ document.body,
+ '.o_Activity_icon.fa-check',
+ "should now have new activity icon once edited"
+ );
+});
+
+QUnit.test('activity click on cancel', async function (assert) {
+ assert.expect(7);
+
+ await this.start({
+ async mockRPC(route, args) {
+ if (route === '/web/dataset/call_kw/mail.activity/unlink') {
+ assert.step('unlink');
+ assert.strictEqual(args.args[0].length, 1);
+ assert.strictEqual(args.args[0][0], 12);
+ return;
+ } else {
+ return this._super(...arguments);
+ }
+ },
+ });
+ const activity = this.env.models['mail.activity'].create({
+ canWrite: true,
+ id: 12,
+ mailTemplates: [['insert', {
+ id: 1,
+ name: "Dummy mail template",
+ }]],
+ res_id: 42,
+ res_model: 'res.partner',
+ });
+
+ // Create a parent component to surround the Activity component in order to be able
+ // to check that activity component has been destroyed
+ class ParentComponent extends Component {
+ constructor(...args) {
+ super(... args);
+ useStore(props => {
+ const activity = this.env.models['mail.activity'].get(props.activityLocalId);
+ return {
+ activity: activity ? activity.__state : undefined,
+ };
+ });
+ }
+
+ /**
+ * @returns {mail.activity}
+ */
+ get activity() {
+ return this.env.models['mail.activity'].get(this.props.activityLocalId);
+ }
+ }
+ ParentComponent.env = this.env;
+ Object.assign(ParentComponent, {
+ components,
+ props: { activityLocalId: String },
+ template: xml`
+ <div>
+ <p>parent</p>
+ <t t-if="activity">
+ <Activity activityLocalId="activity.localId"/>
+ </t>
+ </div>
+ `,
+ });
+ this.component = new ParentComponent(null, { activityLocalId: activity.localId });
+ await this.component.mount(this.widget.el);
+
+ assert.strictEqual(
+ document.querySelectorAll('.o_Activity').length,
+ 1,
+ "should have activity component"
+ );
+ assert.strictEqual(
+ document.querySelectorAll('.o_Activity_cancelButton').length,
+ 1,
+ "should have activity cancel button"
+ );
+
+ await afterNextRender(() =>
+ document.querySelector('.o_Activity_cancelButton').click()
+ );
+ assert.verifySteps(
+ ['unlink'],
+ "should have called unlink rpc after clicking on cancel"
+ );
+ assert.strictEqual(
+ document.querySelectorAll('.o_Activity').length,
+ 0,
+ "should no longer display activity after clicking on cancel"
+ );
+});
+
+});
+});
+});
+
+});
diff --git a/addons/mail/static/src/components/activity_box/activity_box.js b/addons/mail/static/src/components/activity_box/activity_box.js
new file mode 100644
index 00000000000..bfc624bea19
--- /dev/null
+++ b/addons/mail/static/src/components/activity_box/activity_box.js
@@ -0,0 +1,60 @@
+odoo.define('mail/static/src/components/activity_box/activity_box.js', function (require) {
+'use strict';
+
+const components = {
+ Activity: require('mail/static/src/components/activity/activity.js'),
+};
+const useStore = require('mail/static/src/component_hooks/use_store/use_store.js');
+
+const { Component } = owl;
+
+class ActivityBox extends Component {
+
+ /**
+ * @override
+ */
+ constructor(...args) {
+ super(...args);
+ useStore(props => {
+ const chatter = this.env.models['mail.chatter'].get(props.chatterLocalId);
+ return {
+ chatter: chatter ? chatter.__state : undefined,
+ };
+ });
+ }
+
+ //--------------------------------------------------------------------------
+ // Public
+ //--------------------------------------------------------------------------
+
+ /**
+ * @returns {Chatter}
+ */
+ get chatter() {
+ return this.env.models['mail.chatter'].get(this.props.chatterLocalId);
+ }
+
+ //--------------------------------------------------------------------------
+ // Handlers
+ //--------------------------------------------------------------------------
+
+ /**
+ * @private
+ */
+ _onClickTitle() {
+ this.chatter.toggleActivityBoxVisibility();
+ }
+
+}
+
+Object.assign(ActivityBox, {
+ components,
+ props: {
+ chatterLocalId: String,
+ },
+ template: 'mail.ActivityBox',
+});
+
+return ActivityBox;
+
+});
diff --git a/addons/mail/static/src/components/activity_box/activity_box.scss b/addons/mail/static/src/components/activity_box/activity_box.scss
new file mode 100644
index 00000000000..056f624c04f
--- /dev/null
+++ b/addons/mail/static/src/components/activity_box/activity_box.scss
@@ -0,0 +1,41 @@
+// ------------------------------------------------------------------
+// Layout
+// ------------------------------------------------------------------
+
+.o_ActivityBox_title {
+ display: flex;
+ align-items: center;
+ flex: 0 0 auto;
+ margin-top: map-get($spacers, 4);
+ margin-bottom: map-get($spacers, 4);
+}
+
+.o_ActivityBox_titleBadge {
+ padding: map-get($spacers, 0) map-get($spacers, 2);
+}
+
+.o_ActivityBox_titleLine {
+ flex: 1 1 auto;
+ width: auto;
+}
+
+.o_ActivityBox_titleText {
+ padding: map-get($spacers, 0) map-get($spacers, 3);
+}
+
+// ------------------------------------------------------------------
+// Style
+// ------------------------------------------------------------------
+
+.o_ActivityBox_title {
+ font-weight: bold;
+}
+
+.o_ActivityBox_titleBadge {
+ font-size: 11px;
+}
+
+.o_ActivityBox_titleLine {
+ border-color: gray('400');
+ border-style: dashed;
+}
diff --git a/addons/mail/static/src/components/activity_box/activity_box.xml b/addons/mail/static/src/components/activity_box/activity_box.xml
new file mode 100644
index 00000000000..2538ac9a462
--- /dev/null
+++ b/addons/mail/static/src/components/activity_box/activity_box.xml
@@ -0,0 +1,45 @@
+<?xml version="1.0" encoding="UTF-8"?>
+<templates xml:space="preserve">
+
+ <t t-name="mail.ActivityBox" owl="1">
+ <div class="o_ActivityBox">
+ <t t-if="chatter">
+ <a role="button" class="o_ActivityBox_title btn" t-on-click="_onClickTitle">
+ <hr class="o_ActivityBox_titleLine" />
+ <span class="o_ActivityBox_titleText">
+ <i class="fa fa-fw" t-att-class="chatter.isActivityBoxVisible ? 'fa-caret-right' : 'fa-caret-down'"/>
+ Planned activities
+ </span>
+ <t t-if="!chatter.isActivityBoxVisible">
+ <span class="o_ActivityBox_titleBadges">
+ <t t-if="chatter.overdueActivities.length > 0">
+ <span class="o_ActivityBox_titleBadge badge rounded-circle badge-danger">
+ <t t-esc="chatter.overdueActivities.length"/>
+ </span>
+ </t>
+ <t t-if="chatter.todayActivities.length > 0">
+ <span class="o_ActivityBox_titleBadge badge rounded-circle badge-warning">
+ <t t-esc="chatter.todayActivities.length"/>
+ </span>
+ </t>
+ <t t-if="chatter.futureActivities.length > 0">
+ <span class="o_ActivityBox_titleBadge badge rounded-circle badge-success">
+ <t t-esc="chatter.futureActivities.length"/>
+ </span>
+ </t>
+ </span>
+ </t>
+ <hr class="o_ActivityBox_titleLine" />
+ </a>
+ <t t-if="chatter.isActivityBoxVisible">
+ <div class="o_ActivityList">
+ <t t-foreach="chatter.activities" t-as="activity" t-key="activity.localId">
+ <Activity class="o_ActivityBox_activity" activityLocalId="activity.localId"/>
+ </t>
+ </div>
+ </t>
+ </t>
+ </div>
+ </t>
+
+</templates>
diff --git a/addons/mail/static/src/components/activity_mark_done_popover/activity_mark_done_popover.js b/addons/mail/static/src/components/activity_mark_done_popover/activity_mark_done_popover.js
new file mode 100644
index 00000000000..03e8f2ea54e
--- /dev/null
+++ b/addons/mail/static/src/components/activity_mark_done_popover/activity_mark_done_popover.js
@@ -0,0 +1,83 @@
+odoo.define('mail/static/src/components/activity_mark_done_popover/activity_mark_done_popover.js', function (require) {
+'use strict';
+
+const useStore = require('mail/static/src/component_hooks/use_store/use_store.js');
+
+const { Component } = owl;
+const { useRef } = owl.hooks;
+
+class ActivityMarkDonePopover extends Component {
+
+ /**
+ * @override
+ */
+ constructor(...args) {
+ super(...args);
+ useStore(props => {
+ const activity = this.env.models['mail.activity'].get(props.activityLocalId);
+ return {
+ activity: activity ? activity.__state : undefined,
+ };
+ });
+ this._feedbackTextareaRef = useRef('feedbackTextarea');
+ }
+
+ //--------------------------------------------------------------------------
+ // Public
+ //--------------------------------------------------------------------------
+
+ /**
+ * @returns {mail.activity}
+ */
+ get activity() {
+ return this.env.models['mail.activity'].get(this.props.activityLocalId);
+ }
+
+ /**
+ * @returns {string}
+ */
+ get DONE_AND_SCHEDULE_NEXT() {
+ return this.env._t("Done & Schedule Next");
+ }
+
+ //--------------------------------------------------------------------------
+ // Handlers
+ //--------------------------------------------------------------------------
+
+ /**
+ * @private
+ */
+ _onClickDiscard() {
+ this.trigger('o-popover-close');
+ }
+
+ /**
+ * @private
+ */
+ _onClickDone() {
+ this.activity.markAsDone({
+ feedback: this._feedbackTextareaRef.el.value,
+ });
+ }
+
+ /**
+ * @private
+ */
+ _onClickDoneAndScheduleNext() {
+ this.activity.markAsDoneAndScheduleNext({
+ feedback: this._feedbackTextareaRef.el.value,
+ });
+ }
+
+}
+
+Object.assign(ActivityMarkDonePopover, {
+ props: {
+ activityLocalId: String,
+ },
+ template: 'mail.ActivityMarkDonePopover',
+});
+
+return ActivityMarkDonePopover;
+
+});
diff --git a/addons/mail/static/src/components/activity_mark_done_popover/activity_mark_done_popover.scss b/addons/mail/static/src/components/activity_mark_done_popover/activity_mark_done_popover.scss
new file mode 100644
index 00000000000..1e15fa086c1
--- /dev/null
+++ b/addons/mail/static/src/components/activity_mark_done_popover/activity_mark_done_popover.scss
@@ -0,0 +1,22 @@
+// ------------------------------------------------------------------
+// Layout
+// ------------------------------------------------------------------
+
+.o_ActivityMarkDonePopover {
+ align-content: space-between;
+ display: flex;
+ flex-flow: row wrap;
+ min-height: 100px;
+}
+
+.o_ActivityMarkDonePopover_buttons {
+ display: flex;
+ flex: 1;
+ justify-content: space-between;
+ margin-top: map-get($spacers, 2);
+}
+
+.o_ActivityMarkDonePopover_feedback {
+ min-height: 70px;
+}
+
diff --git a/addons/mail/static/src/components/activity_mark_done_popover/activity_mark_done_popover.xml b/addons/mail/static/src/components/activity_mark_done_popover/activity_mark_done_popover.xml
new file mode 100644
index 00000000000..2cd22e993ec
--- /dev/null
+++ b/addons/mail/static/src/components/activity_mark_done_popover/activity_mark_done_popover.xml
@@ -0,0 +1,23 @@
+<?xml version="1.0" encoding="UTF-8"?>
+<templates xml:space="preserve">
+
+ <t t-name="mail.ActivityMarkDonePopover" owl="1">
+ <div class="o_ActivityMarkDonePopover">
+ <t t-if="activity">
+ <textarea class="form-control o_ActivityMarkDonePopover_feedback" rows="3" placeholder="Write Feedback" t-ref="feedbackTextarea"/>
+ <div class="o_ActivityMarkDonePopover_buttons">
+ <button type="button" class="o_ActivityMarkDonePopover_doneScheduleNextButton btn btn-sm btn-primary" t-on-click="_onClickDoneAndScheduleNext" t-esc="DONE_AND_SCHEDULE_NEXT"/>
+ <t t-if="!activity.force_next">
+ <button type="button" class="o_ActivityMarkDonePopover_doneButton btn btn-sm btn-primary" t-on-click="_onClickDone">
+ Done
+ </button>
+ <button type="button" class="o_ActivityMarkDonePopover_discardButton btn btn-sm btn-link" t-on-click="_onClickDiscard">
+ Discard
+ </button>
+ </t>
+ </div>
+ </t>
+ </div>
+ </t>
+
+</templates>
diff --git a/addons/mail/static/src/components/activity_mark_done_popover/activity_mark_done_popover_tests.js b/addons/mail/static/src/components/activity_mark_done_popover/activity_mark_done_popover_tests.js
new file mode 100644
index 00000000000..0c04e492f24
--- /dev/null
+++ b/addons/mail/static/src/components/activity_mark_done_popover/activity_mark_done_popover_tests.js
@@ -0,0 +1,282 @@
+odoo.define('mail/static/src/components/activity_mark_done_popover/activity_mark_done_popover_tests.js', function (require) {
+'use strict';
+
+const components = {
+ ActivityMarkDonePopover: require('mail/static/src/components/activity_mark_done_popover/activity_mark_done_popover.js'),
+};
+
+const {
+ afterEach: utilsAfterEach,
+ beforeEach: utilsBeforeEach,
+ start: utilsStart,
+} = require('mail/static/src/utils/test_utils.js');
+
+QUnit.module('mail', {}, function () {
+QUnit.module('components', {}, function () {
+QUnit.module('activity_mark_done_popover', {}, function () {
+QUnit.module('activity_mark_done_popover_tests.js', {
+ beforeEach() {
+ utilsBeforeEach(this);
+
+ this.createActivityMarkDonePopoverComponent = async activity => {
+ const ActivityMarkDonePopoverComponent = components.ActivityMarkDonePopover;
+ ActivityMarkDonePopoverComponent.env = this.env;
+ this.component = new ActivityMarkDonePopoverComponent(null, {
+ activityLocalId: activity.localId,
+ });
+ await this.component.mount(this.widget.el);
+ };
+
+ this.start = async params => {
+ let { env, widget } = await utilsStart(Object.assign({}, params, {
+ data: this.data,
+ }));
+ this.env = env;
+ this.widget = widget;
+ };
+ },
+ afterEach() {
+ utilsAfterEach(this);
+ if (this.component) {
+ this.component.destroy();
+ }
+ if (this.widget) {
+ this.widget.destroy();
+ }
+ this.env = undefined;
+ },
+});
+
+QUnit.test('activity mark done popover simplest layout', async function (assert) {
+ assert.expect(6);
+
+ await this.start();
+ const activity = this.env.models['mail.activity'].create({
+ canWrite: true,
+ category: 'not_upload_file',
+ id: 12,
+ });
+ await this.createActivityMarkDonePopoverComponent(activity);
+
+ assert.containsOnce(
+ document.body,
+ '.o_ActivityMarkDonePopover',
+ "Popover component should be present"
+ );
+ assert.containsOnce(
+ document.body,
+ '.o_ActivityMarkDonePopover_feedback',
+ "Popover component should contain the feedback textarea"
+ );
+ assert.containsOnce(
+ document.body,
+ '.o_ActivityMarkDonePopover_buttons',
+ "Popover component should contain the action buttons"
+ );
+ assert.containsOnce(
+ document.body,
+ '.o_ActivityMarkDonePopover_doneScheduleNextButton',
+ "Popover component should contain the done & schedule next button"
+ );
+ assert.containsOnce(
+ document.body,
+ '.o_ActivityMarkDonePopover_doneButton',
+ "Popover component should contain the done button"
+ );
+ assert.containsOnce(
+ document.body,
+ '.o_ActivityMarkDonePopover_discardButton',
+ "Popover component should contain the discard button"
+ );
+});
+
+QUnit.test('activity with force next mark done popover simplest layout', async function (assert) {
+ assert.expect(6);
+
+ await this.start();
+ const activity = this.env.models['mail.activity'].create({
+ canWrite: true,
+ category: 'not_upload_file',
+ force_next: true,
+ id: 12,
+ });
+ await this.createActivityMarkDonePopoverComponent(activity);
+
+ assert.containsOnce(
+ document.body,
+ '.o_ActivityMarkDonePopover',
+ "Popover component should be present"
+ );
+ assert.containsOnce(
+ document.body,
+ '.o_ActivityMarkDonePopover_feedback',
+ "Popover component should contain the feedback textarea"
+ );
+ assert.containsOnce(
+ document.body,
+ '.o_ActivityMarkDonePopover_buttons',
+ "Popover component should contain the action buttons"
+ );
+ assert.containsOnce(
+ document.body,
+ '.o_ActivityMarkDonePopover_doneScheduleNextButton',
+ "Popover component should contain the done & schedule next button"
+ );
+ assert.containsNone(
+ document.body,
+ '.o_ActivityMarkDonePopover_doneButton',
+ "Popover component should NOT contain the done button"
+ );
+ assert.containsNone(
+ document.body,
+ '.o_ActivityMarkDonePopover_discardButton',
+ "Popover component should NOT contain the discard button"
+ );
+});
+
+QUnit.test('activity mark done popover click on discard', async function (assert) {
+ assert.expect(4);
+
+ await this.start();
+ const activity = this.env.models['mail.activity'].create({
+ canWrite: true,
+ category: 'not_upload_file',
+ id: 12,
+ });
+ await this.createActivityMarkDonePopoverComponent(activity);
+ function onPopoverClose(ev) {
+ assert.step('event_triggered');
+ }
+ document.addEventListener('o-popover-close', onPopoverClose);
+
+ assert.containsOnce(
+ document.body,
+ '.o_ActivityMarkDonePopover',
+ "Popover component should be present"
+ );
+ assert.containsOnce(
+ document.body,
+ '.o_ActivityMarkDonePopover_discardButton',
+ "Popover component should contain the discard button"
+ );
+ document.querySelector('.o_ActivityMarkDonePopover_discardButton').click();
+ assert.verifySteps(['event_triggered'], 'Discard clicked should trigger the right event');
+
+ document.removeEventListener('o-popover-close', onPopoverClose);
+});
+
+QUnit.test('activity mark done popover mark done without feedback', async function (assert) {
+ assert.expect(7);
+
+ await this.start({
+ async mockRPC(route, args) {
+ if (route === '/web/dataset/call_kw/mail.activity/action_feedback') {
+ assert.step('action_feedback');
+ assert.strictEqual(args.args.length, 1);
+ assert.strictEqual(args.args[0].length, 1);
+ assert.strictEqual(args.args[0][0], 12);
+ assert.strictEqual(args.kwargs.attachment_ids.length, 0);
+ assert.notOk(args.kwargs.feedback);
+ return;
+ }
+ if (route === '/web/dataset/call_kw/mail.activity/unlink') {
+ // 'unlink' on non-existing record raises a server crash
+ throw new Error("'unlink' RPC on activity must not be called (already unlinked from mark as done)");
+ }
+ return this._super(...arguments);
+ },
+ });
+ const activity = this.env.models['mail.activity'].create({
+ canWrite: true,
+ category: 'not_upload_file',
+ id: 12,
+ });
+ await this.createActivityMarkDonePopoverComponent(activity);
+
+ document.querySelector('.o_ActivityMarkDonePopover_doneButton').click();
+ assert.verifySteps(
+ ['action_feedback'],
+ "Mark done and schedule next button should call the right rpc"
+ );
+});
+
+QUnit.test('activity mark done popover mark done with feedback', async function (assert) {
+ assert.expect(7);
+
+ await this.start({
+ async mockRPC(route, args) {
+ if (route === '/web/dataset/call_kw/mail.activity/action_feedback') {
+ assert.step('action_feedback');
+ assert.strictEqual(args.args.length, 1);
+ assert.strictEqual(args.args[0].length, 1);
+ assert.strictEqual(args.args[0][0], 12);
+ assert.strictEqual(args.kwargs.attachment_ids.length, 0);
+ assert.strictEqual(args.kwargs.feedback, 'This task is done');
+ return;
+ }
+ if (route === '/web/dataset/call_kw/mail.activity/unlink') {
+ // 'unlink' on non-existing record raises a server crash
+ throw new Error("'unlink' RPC on activity must not be called (already unlinked from mark as done)");
+ }
+ return this._super(...arguments);
+ },
+ });
+ const activity = this.env.models['mail.activity'].create({
+ canWrite: true,
+ category: 'not_upload_file',
+ id: 12,
+ });
+ await this.createActivityMarkDonePopoverComponent(activity);
+
+ let feedbackTextarea = document.querySelector('.o_ActivityMarkDonePopover_feedback');
+ feedbackTextarea.focus();
+ document.execCommand('insertText', false, 'This task is done');
+ document.querySelector('.o_ActivityMarkDonePopover_doneButton').click();
+ assert.verifySteps(
+ ['action_feedback'],
+ "Mark done and schedule next button should call the right rpc"
+ );
+});
+
+QUnit.test('activity mark done popover mark done and schedule next', async function (assert) {
+ assert.expect(6);
+
+ await this.start({
+ async mockRPC(route, args) {
+ if (route === '/web/dataset/call_kw/mail.activity/action_feedback_schedule_next') {
+ assert.step('action_feedback_schedule_next');
+ assert.strictEqual(args.args.length, 1);
+ assert.strictEqual(args.args[0].length, 1);
+ assert.strictEqual(args.args[0][0], 12);
+ assert.strictEqual(args.kwargs.feedback, 'This task is done');
+ return;
+ }
+ if (route === '/web/dataset/call_kw/mail.activity/unlink') {
+ // 'unlink' on non-existing record raises a server crash
+ throw new Error("'unlink' RPC on activity must not be called (already unlinked from mark as done)");
+ }
+ return this._super(...arguments);
+ },
+ });
+ const activity = this.env.models['mail.activity'].create({
+ canWrite: true,
+ category: 'not_upload_file',
+ id: 12,
+ });
+ await this.createActivityMarkDonePopoverComponent(activity);
+
+ let feedbackTextarea = document.querySelector('.o_ActivityMarkDonePopover_feedback');
+ feedbackTextarea.focus();
+ document.execCommand('insertText', false, 'This task is done');
+ document.querySelector('.o_ActivityMarkDonePopover_doneScheduleNextButton').click();
+ assert.verifySteps(
+ ['action_feedback_schedule_next'],
+ "Mark done and schedule next button should call the right rpc"
+ );
+});
+
+});
+});
+});
+
+});
diff --git a/addons/mail/static/src/components/attachment/attachment.js b/addons/mail/static/src/components/attachment/attachment.js
new file mode 100644
index 00000000000..76922ed7a3c
--- /dev/null
+++ b/addons/mail/static/src/components/attachment/attachment.js
@@ -0,0 +1,162 @@
+odoo.define('mail/static/src/components/attachment/attachment.js', function (require) {
+'use strict';
+
+const useStore = require('mail/static/src/component_hooks/use_store/use_store.js');
+
+const { Component } = owl;
+
+class Attachment extends Component {
+
+ /**
+ * @override
+ */
+ constructor(...args) {
+ super(...args);
+ useStore(props => {
+ const attachment = this.env.models['mail.attachment'].get(props.attachmentLocalId);
+ return {
+ attachment: attachment ? attachment.__state : undefined,
+ };
+ });
+ }
+
+ //--------------------------------------------------------------------------
+ // Public
+ //--------------------------------------------------------------------------
+
+ /**
+ * @returns {mail.attachment}
+ */
+ get attachment() {
+ return this.env.models['mail.attachment'].get(this.props.attachmentLocalId);
+ }
+
+ /**
+ * Return the url of the attachment. Temporary attachments, a.k.a. uploading
+ * attachments, do not have an url.
+ *
+ * @returns {string}
+ */
+ get attachmentUrl() {
+ if (this.attachment.isTemporary) {
+ return '';
+ }
+ return this.env.session.url('/web/content', {
+ id: this.attachment.id,
+ download: true,
+ });
+ }
+
+ /**
+ * Get the details mode after auto mode is computed
+ *
+ * @returns {string} 'card', 'hover' or 'none'
+ */
+ get detailsMode() {
+ if (this.props.detailsMode !== 'auto') {
+ return this.props.detailsMode;
+ }
+ if (this.attachment.fileType !== 'image') {
+ return 'card';
+ }
+ return 'hover';
+ }
+
+ /**
+ * Get the attachment representation style to be applied
+ *
+ * @returns {string}
+ */
+ get imageStyle() {
+ if (this.attachment.fileType !== 'image') {
+ return '';
+ }
+ let size;
+ if (this.detailsMode === 'card') {
+ size = '38x38';
+ } else {
+ size = '160x160';
+ }
+ return `background-image:url(/web/image/${this.attachment.id}/${size}/?crop=true);`;
+ }
+
+ //--------------------------------------------------------------------------
+ // Handlers
+ //--------------------------------------------------------------------------
+
+ /**
+ * Download the attachment when clicking on donwload icon.
+ *
+ * @private
+ * @param {MouseEvent} ev
+ */
+ _onClickDownload(ev) {
+ ev.stopPropagation();
+ window.location = `/web/content/ir.attachment/${this.attachment.id}/datas?download=true`;
+ }
+
+ /**
+ * Open the attachment viewer when clicking on viewable attachment.
+ *
+ * @private
+ * @param {MouseEvent} ev
+ */
+ _onClickImage(ev) {
+ if (!this.attachment.isViewable) {
+ return;
+ }
+ this.env.models['mail.attachment'].view({
+ attachment: this.attachment,
+ attachments: this.props.attachmentLocalIds.map(
+ attachmentLocalId => this.env.models['mail.attachment'].get(attachmentLocalId)
+ ),
+ });
+ }
+
+ /**
+ * @private
+ * @param {MouseEvent} ev
+ */
+ _onClickUnlink(ev) {
+ ev.stopPropagation();
+ this.attachment.remove();
+ this.trigger('o-attachment-removed');
+ }
+
+}
+
+Object.assign(Attachment, {
+ defaultProps: {
+ attachmentLocalIds: [],
+ detailsMode: 'auto',
+ imageSize: 'medium',
+ isDownloadable: false,
+ isEditable: true,
+ showExtension: true,
+ showFilename: true,
+ },
+ props: {
+ attachmentLocalId: String,
+ attachmentLocalIds: {
+ type: Array,
+ element: String,
+ },
+ detailsMode: {
+ type: String,
+ validate: prop => ['auto', 'card', 'hover', 'none'].includes(prop),
+ },
+ imageSize: {
+ type: String,
+ validate: prop => ['small', 'medium', 'large'].includes(prop),
+ },
+ isDownloadable: Boolean,
+ isEditable: Boolean,
+ showExtension: Boolean,
+ showFilename: Boolean,
+ },
+ template: 'mail.Attachment',
+});
+
+return Attachment;
+
+});
diff --git a/addons/mail/static/src/components/attachment/attachment.scss b/addons/mail/static/src/components/attachment/attachment.scss
new file mode 100644
index 00000000000..aebe44c82ba
--- /dev/null
+++ b/addons/mail/static/src/components/attachment/attachment.scss
@@ -0,0 +1,196 @@
+// ------------------------------------------------------------------
+// Layout
+// ------------------------------------------------------------------
+
+.o_Attachment {
+ display: flex;
+
+ &:hover .o_Attachment_asideItemUnlink.o-pretty {
+ transform: translateX(0);
+ }
+}
+
+.o_Attachment_action {
+ min-width: 20px;
+}
+
+.o_Attachment_actions {
+ justify-content: space-between;
+ display: flex;
+ flex-direction: column;
+}
+
+.o_Attachment_aside {
+ position: relative;
+ overflow: hidden;
+
+ &:not(.o-has-multiple-action) {
+ min-width: 50px;
+ }
+
+ &.o-has-multiple-action {
+ min-width: 30px;
+ display: flex;
+ flex-direction: column;
+ }
+}
+
+.o_Attachment_asideItem {
+ display: flex;
+ width: 100%;
+ height: 100%;
+ align-items: center;
+ justify-content: center;
+}
+
+.o_Attachment_asideItemUnlink.o-pretty {
+ position: absolute;
+ top: 0;
+ transform: translateX(100%);
+}
+
+.o_Attachment_details {
+ min-width: 0; /* This allows the text ellipsis in the flex element */
+}
+
+.o_Attachment_filename {
+ overflow: hidden;
+ text-overflow: ellipsis;
+ white-space: nowrap;
+}
+
+.o_Attachment_image {
+ margin: 3px;
+
+ &.o-details-overlay {
+ position: relative;
+
+ &.o-small {
+ min-width: 100px;
+ min-height: 100px;
+ }
+ &.o-medium {
+ min-width: 200px;
+ min-height: 200px;
+ }
+ &.o-large {
+ min-width: 400px;
+ min-height: 400px;
+ }
+
+ &:hover {
+ .o_Attachment_imageOverlay {
+ opacity: 1;
+ }
+ }
+ }
+}
+
+.o_Attachment_imageOverlay {
+ bottom: 0;
+ display:flex;
+ flex-direction: row;
+ justify-content: flex-end;
+ left: 0;
+ padding: 10px;
+ position: absolute;
+ right: 0;
+ top: 0;
+}
+
+.o_Attachment_imageOverlayDetails {
+ display: flex;
+ flex-direction: column;
+ justify-content: flex-end;
+ margin: 3px;
+ width: 200px;
+}
+
+
+// ------------------------------------------------------------------
+// Style
+// ------------------------------------------------------------------
+
+.o_Attachment {
+ &.o-has-card-details {
+ background-color: gray('300');
+ border-radius: 5px;
+ }
+}
+
+.o_Attachment_action {
+ border-radius: 10px;
+ cursor: pointer;
+ text-align: center;
+
+ &:hover {
+ background: rgba(255, 255, 255, 0.2);
+ }
+}
+
+.o_Attachment_aside {
+ border-radius: 0 5px 5px 0;
+}
+
+.o_Attachment_asideItemDownload {
+ cursor: pointer;
+
+ &:hover {
+ background-color: gray('400');
+ }
+}
+
+.o_Attachment_asideItemUnlink {
+ cursor: pointer;
+
+ &:not(.o-pretty):hover {
+ background-color: gray('400');
+ }
+
+ &.o-pretty {
+ color: white;
+ background-color: $o-brand-primary;
+
+ &:hover {
+ background-color: darken($o-brand-primary, 10%);
+ }
+ }
+
+}
+
+.o_Attachment_asideItemUploaded {
+ color: $o-brand-primary;
+}
+
+.o_Attachment_extension {
+ text-transform: uppercase;
+ font-size: 80%;
+ font-weight: 400;
+}
+
+.o_Attachment_image.o-attachment-viewable {
+ cursor: zoom-in;
+
+ &:not(.o-details-overlay):hover {
+ opacity: 0.7;
+ }
+}
+
+.o_Attachment_imageOverlay {
+ background-image: linear-gradient(180deg, rgba(0, 0, 0, 0.2), rgba(0, 0, 0, 0.9));
+ border-radius: 5px;
+ color: white;
+ opacity: 0;
+}
+
+// ------------------------------------------------------------------
+// Animation
+// ------------------------------------------------------------------
+
+.o_Attachment_asideItemUnlink.o-pretty {
+ transition: transform 0.3s ease 0s;
+}
+
+.o_Attachment_imageOverlay {
+ transition: all 0.3s ease 0s;
+}
diff --git a/addons/mail/static/src/components/attachment/attachment.xml b/addons/mail/static/src/components/attachment/attachment.xml
new file mode 100644
index 00000000000..8af00288b8b
--- /dev/null
+++ b/addons/mail/static/src/components/attachment/attachment.xml
@@ -0,0 +1,109 @@
+<?xml version="1.0" encoding="UTF-8"?>
+<templates xml:space="preserve">
+
+ <t t-name="mail.Attachment" owl="1">
+ <div class="o_Attachment"
+ t-att-class="{
+ 'o-downloadable': props.isDownloadable,
+ 'o-editable': props.isEditable,
+ 'o-has-card-details': detailsMode === 'card',
+ 'o-temporary': attachment and attachment.isTemporary,
+ 'o-viewable': attachment and attachment.isViewable,
+ }" t-att-title="attachment ? attachment.displayName : undefined" t-att-data-attachment-local-id="attachment ? attachment.localId : undefined"
+ >
+ <t t-if="attachment">
+ <!-- Image style-->
+ <!-- o_image from mimetype.scss -->
+ <div class="o_Attachment_image o_image" t-on-click="_onClickImage"
+ t-att-class="{
+ 'o-attachment-viewable': attachment.isViewable,
+ 'o-details-overlay': detailsMode !== 'card',
+ 'o-large': props.imageSize === 'large',
+ 'o-medium': props.imageSize === 'medium',
+ 'o-small': props.imageSize === 'small',
+ }" t-att-href="attachmentUrl" t-att-style="imageStyle" t-att-data-mimetype="attachment.mimetype"
+ >
+ <t t-if="(props.showFilename or props.showExtension) and detailsMode === 'hover'">
+ <div class="o_Attachment_imageOverlay">
+ <div class="o_Attachment_details o_Attachment_imageOverlayDetails">
+ <t t-if="props.showFilename">
+ <div class="o_Attachment_filename">
+ <t t-esc="attachment.displayName"/>
+ </div>
+ </t>
+ <t t-if="props.showExtension">
+ <div class="o_Attachment_extension">
+ <t t-esc="attachment.extension"/>
+ </div>
+ </t>
+ </div>
+ <div class="o_Attachment_actions">
+ <!-- Remove button -->
+ <t t-if="props.isEditable and !attachment.isTemporary" t-key="'unlink'">
+ <div class="o_Attachment_action o_Attachment_actionUnlink"
+ t-att-class="{
+ 'o-pretty': attachment.isLinkedToComposer,
+ }" t-on-click="_onClickUnlink" title="Remove"
+ >
+ <i class="fa fa-times"/>
+ </div>
+ </t>
+ <!-- Download button -->
+ <t t-if="props.isDownloadable and !attachment.isTemporary" t-key="'download'">
+ <div class="o_Attachment_action o_Attachment_actionDownload" t-on-click="_onClickDownload" title="Download">
+ <i class="fa fa-download"/>
+ </div>
+ </t>
+ </div>
+ </div>
+ </t>
+ </div>
+ <!-- Attachment details -->
+ <t t-if="(props.showFilename or props.showExtension) and detailsMode === 'card'">
+ <div class="o_Attachment_details">
+ <t t-if="props.showFilename">
+ <div class="o_Attachment_filename">
+ <t t-esc="attachment.displayName"/>
+ </div>
+ </t>
+ <t t-if="props.showExtension">
+ <div class="o_Attachment_extension">
+ <t t-esc="attachment.extension"/>
+ </div>
+ </t>
+ </div>
+ </t>
+ <!-- Attachment aside -->
+ <t t-if="detailsMode !== 'hover' and (props.isDownloadable or props.isEditable)">
+ <div class="o_Attachment_aside" t-att-class="{ 'o-has-multiple-action': !attachment.isTemporary and props.isDownloadable and props.isEditable }">
+ <!-- Uploading icon -->
+ <t t-if="attachment.isTemporary and attachment.isLinkedToComposer">
+ <div class="o_Attachment_asideItem o_Attachment_asideItemUploading" title="Uploading">
+ <i class="fa fa-spin fa-spinner"/>
+ </div>
+ </t>
+ <!-- Uploaded icon -->
+ <t t-if="!attachment.isTemporary and attachment.isLinkedToComposer">
+ <div class="o_Attachment_asideItem o_Attachment_asideItemUploaded" title="Uploaded">
+ <i class="fa fa-check"/>
+ </div>
+ </t>
+ <!-- Remove button -->
+ <t t-if="props.isEditable and !attachment.isTemporary">
+ <div class="o_Attachment_asideItem o_Attachment_asideItemUnlink" t-att-class="{ 'o-pretty': attachment.isLinkedToComposer }" t-on-click="_onClickUnlink" title="Remove">
+ <i class="fa fa-times"/>
+ </div>
+ </t>
+ <!-- Download button -->
+ <t t-if="props.isDownloadable and !attachment.isTemporary">
+ <div class="o_Attachment_asideItem o_Attachment_asideItemDownload" t-on-click="_onClickDownload" title="Download">
+ <i class="fa fa-download"/>
+ </div>
+ </t>
+ </div>
+ </t>
+ </t>
+ </div>
+ </t>
+
+</templates>
diff --git a/addons/mail/static/src/components/attachment/attachment_tests.js b/addons/mail/static/src/components/attachment/attachment_tests.js
new file mode 100644
index 00000000000..224a80a30e7
--- /dev/null
+++ b/addons/mail/static/src/components/attachment/attachment_tests.js
@@ -0,0 +1,545 @@
+odoo.define('mail/static/src/components/attachment/attachment_tests.js', function (require) {
+'use strict';
+
+const components = {
+ Attachment: require('mail/static/src/components/attachment/attachment.js'),
+};
+const {
+ afterEach: utilsAfterEach,
+ beforeEach: utilsBeforeEach,
+ start: utilsStart,
+} = require('mail/static/src/utils/test_utils.js');
+
+QUnit.module('mail', {}, function () {
+QUnit.module('components', {}, function () {
+QUnit.module('attachment', {}, function () {
+QUnit.module('attachment_tests.js', {
+ beforeEach() {
+ utilsBeforeEach(this);
+
+ this.createAttachmentComponent = async (attachment, otherProps) => {
+ const AttachmentComponent = components.Attachment;
+ AttachmentComponent.env = this.env;
+ this.component = new AttachmentComponent(null, Object.assign({
+ attachmentLocalId: attachment.localId,
+ }, otherProps));
+ await this.component.mount(this.widget.el);
+ };
+
+ this.start = async params => {
+ if (this.widget) {
+ this.widget.destroy();
+ }
+ let { env, widget } = await utilsStart(Object.assign({}, params, {
+ data: this.data,
+ }));
+ this.env = env;
+ this.widget = widget;
+ };
+ },
+ afterEach() {
+ utilsAfterEach(this);
+ if (this.component) {
+ this.component.destroy();
+ }
+ if (this.widget) {
+ this.widget.destroy();
+ }
+ this.env = undefined;
+ delete components.Attachment.env;
+ },
+});
+
+QUnit.test('simplest layout', async function (assert) {
+ assert.expect(8);
+
+ await this.start();
+ const attachment = this.env.models['mail.attachment'].create({
+ filename: "test.txt",
+ id: 750,
+ mimetype: 'text/plain',
+ name: "test.txt",
+ });
+ await this.createAttachmentComponent(attachment, {
+ detailsMode: 'none',
+ isDownloadable: false,
+ isEditable: false,
+ showExtension: false,
+ showFilename: false,
+ });
+ assert.strictEqual(
+ document.querySelectorAll('.o_Attachment').length,
+ 1,
+ "should have attachment component in DOM"
+ );
+ const attachmentEl = document.querySelector('.o_Attachment');
+ assert.strictEqual(
+ attachmentEl.dataset.attachmentLocalId,
+ this.env.models['mail.attachment'].find(attachment => attachment.id === 750).localId,
+ "attachment component should be linked to attachment store model"
+ );
+ assert.strictEqual(
+ attachmentEl.title,
+ "test.txt",
+ "attachment should have filename as title attribute"
+ );
+ assert.strictEqual(
+ attachmentEl.querySelectorAll(`:scope .o_Attachment_image`).length,
+ 1,
+ "attachment should have an image part"
+ );
+ const attachmentImage = document.querySelector(`.o_Attachment_image`);
+ assert.ok(
+ attachmentImage.classList.contains('o_image'),
+ "attachment should have o_image classname (required for mimetype.scss style)"
+ );
+ assert.strictEqual(
+ attachmentImage.dataset.mimetype,
+ 'text/plain',
+ "attachment should have data-mimetype set (required for mimetype.scss style)"
+ );
+ assert.strictEqual(
+ document.querySelectorAll(`.o_Attachment_details`).length,
+ 0,
+ "attachment should not have a details part"
+ );
+ assert.strictEqual(
+ document.querySelectorAll(`.o_Attachment_aside`).length,
+ 0,
+ "attachment should not have an aside part"
+ );
+});
+
+QUnit.test('simplest layout + deletable', async function (assert) {
+ assert.expect(6);
+
+ await this.start({
+ async mockRPC(route, args) {
+ if (route.includes('web/image/750')) {
+ assert.ok(
+ route.includes('/160x160'),
+ "should fetch image with 160x160 pixels ratio");
+ assert.step('fetch_image');
+ return;
+ }
+ return this._super(...arguments);
+ },
+ });
+ const attachment = this.env.models['mail.attachment'].create({
+ filename: "test.txt",
+ id: 750,
+ mimetype: 'text/plain',
+ name: "test.txt",
+ });
+ await this.createAttachmentComponent(attachment, {
+ detailsMode: 'none',
+ isDownloadable: false,
+ isEditable: true,
+ showExtension: false,
+ showFilename: false
+ });
+ assert.strictEqual(
+ document.querySelectorAll('.o_Attachment').length,
+ 1,
+ "should have attachment component in DOM"
+ );
+ assert.strictEqual(
+ document.querySelectorAll(`.o_Attachment_image`).length,
+ 1,
+ "attachment should have an image part"
+ );
+ assert.strictEqual(
+ document.querySelectorAll(`.o_Attachment_details`).length,
+ 0,
+ "attachment should not have a details part"
+ );
+ assert.strictEqual(
+ document.querySelectorAll(`.o_Attachment_aside`).length,
+ 1,
+ "attachment should have an aside part"
+ );
+ assert.strictEqual(
+ document.querySelectorAll(`.o_Attachment_asideItem`).length,
+ 1,
+ "attachment should have only one aside item"
+ );
+ assert.strictEqual(
+ document.querySelectorAll(`.o_Attachment_asideItemUnlink`).length,
+ 1,
+ "attachment should have a delete button"
+ );
+});
+
+QUnit.test('simplest layout + downloadable', async function (assert) {
+ assert.expect(6);
+
+ await this.start();
+ const attachment = this.env.models['mail.attachment'].create({
+ filename: "test.txt",
+ id: 750,
+ mimetype: 'text/plain',
+ name: "test.txt",
+ });
+ await this.createAttachmentComponent(attachment, {
+ detailsMode: 'none',
+ isDownloadable: true,
+ isEditable: false,
+ showExtension: false,
+ showFilename: false
+ });
+ assert.strictEqual(
+ document.querySelectorAll('.o_Attachment').length,
+ 1,
+ "should have attachment component in DOM"
+ );
+ assert.strictEqual(
+ document.querySelectorAll(`.o_Attachment_image`).length,
+ 1,
+ "attachment should have an image part"
+ );
+ assert.strictEqual(
+ document.querySelectorAll(`.o_Attachment_details`).length,
+ 0,
+ "attachment should not have a details part"
+ );
+ assert.strictEqual(
+ document.querySelectorAll(`.o_Attachment_aside`).length,
+ 1,
+ "attachment should have an aside part"
+ );
+ assert.strictEqual(
+ document.querySelectorAll(`.o_Attachment_asideItem`).length,
+ 1,
+ "attachment should have only one aside item"
+ );
+ assert.strictEqual(
+ document.querySelectorAll(`.o_Attachment_asideItemDownload`).length,
+ 1,
+ "attachment should have a download button"
+ );
+});
+
+QUnit.test('simplest layout + deletable + downloadable', async function (assert) {
+ assert.expect(8);
+
+ await this.start();
+ const attachment = this.env.models['mail.attachment'].create({
+ filename: "test.txt",
+ id: 750,
+ mimetype: 'text/plain',
+ name: "test.txt",
+ });
+ await this.createAttachmentComponent(attachment, {
+ detailsMode: 'none',
+ isDownloadable: true,
+ isEditable: true,
+ showExtension: false,
+ showFilename: false
+ });
+ assert.strictEqual(
+ document.querySelectorAll('.o_Attachment').length,
+ 1,
+ "should have attachment component in DOM"
+ );
+ assert.strictEqual(
+ document.querySelectorAll(`.o_Attachment_image`).length,
+ 1,
+ "attachment should have an image part"
+ );
+ assert.strictEqual(
+ document.querySelectorAll(`.o_Attachment_details`).length,
+ 0,
+ "attachment should not have a details part"
+ );
+ assert.strictEqual(
+ document.querySelectorAll(`.o_Attachment_aside`).length,
+ 1,
+ "attachment should have an aside part"
+ );
+ assert.ok(
+ document.querySelector(`.o_Attachment_aside`).classList.contains('o-has-multiple-action'),
+ "attachment aside should contain multiple actions"
+ );
+ assert.strictEqual(
+ document.querySelectorAll(`.o_Attachment_asideItem`).length,
+ 2,
+ "attachment should have only two aside items"
+ );
+ assert.strictEqual(
+ document.querySelectorAll(`.o_Attachment_asideItemDownload`).length,
+ 1,
+ "attachment should have a download button"
+ );
+ assert.strictEqual(
+ document.querySelectorAll(`.o_Attachment_asideItemUnlink`).length,
+ 1,
+ "attachment should have a delete button"
+ );
+});
+
+QUnit.test('layout with card details', async function (assert) {
+ assert.expect(3);
+
+ await this.start();
+ const attachment = this.env.models['mail.attachment'].create({
+ filename: "test.txt",
+ id: 750,
+ mimetype: 'text/plain',
+ name: "test.txt",
+ });
+ await this.createAttachmentComponent(attachment, {
+ detailsMode: 'card',
+ isDownloadable: false,
+ isEditable: false,
+ showExtension: false,
+ showFilename: false
+ });
+ assert.strictEqual(
+ document.querySelectorAll(`.o_Attachment_image`).length,
+ 1,
+ "attachment should have an image part"
+ );
+ assert.strictEqual(
+ document.querySelectorAll(`.o_Attachment_details`).length,
+ 0,
+ "attachment should not have a details part"
+ );
+ assert.strictEqual(
+ document.querySelectorAll(`.o_Attachment_aside`).length,
+ 0,
+ "attachment should not have an aside part"
+ );
+});
+
+QUnit.test('layout with card details and filename', async function (assert) {
+ assert.expect(3);
+
+ await this.start();
+ const attachment = this.env.models['mail.attachment'].create({
+ filename: "test.txt",
+ id: 750,
+ mimetype: 'text/plain',
+ name: "test.txt",
+ });
+ await this.createAttachmentComponent(attachment, {
+ detailsMode: 'card',
+ isDownloadable: false,
+ isEditable: false,
+ showExtension: false,
+ showFilename: true
+ });
+ assert.strictEqual(
+ document.querySelectorAll(`.o_Attachment_details`).length,
+ 1,
+ "attachment should have a details part"
+ );
+ assert.strictEqual(
+ document.querySelectorAll(`.o_Attachment_filename`).length,
+ 1,
+ "attachment should not have its filename shown"
+ );
+ assert.strictEqual(
+ document.querySelectorAll(`.o_Attachment_extension`).length,
+ 0,
+ "attachment should have its extension shown"
+ );
+});
+
+QUnit.test('layout with card details and extension', async function (assert) {
+ assert.expect(3);
+
+ await this.start();
+ const attachment = this.env.models['mail.attachment'].create({
+ filename: "test.txt",
+ id: 750,
+ mimetype: 'text/plain',
+ name: "test.txt",
+ });
+ await this.createAttachmentComponent(attachment, {
+ detailsMode: 'card',
+ isDownloadable: false,
+ isEditable: false,
+ showExtension: true,
+ showFilename: false
+ });
+ assert.strictEqual(
+ document.querySelectorAll(`.o_Attachment_details`).length,
+ 1,
+ "attachment should have a details part"
+ );
+ assert.strictEqual(
+ document.querySelectorAll(`.o_Attachment_filename`).length,
+ 0,
+ "attachment should not have its filename shown"
+ );
+ assert.strictEqual(
+ document.querySelectorAll(`.o_Attachment_extension`).length,
+ 1,
+ "attachment should have its extension shown"
+ );
+});
+
+QUnit.test('layout with card details and filename and extension', async function (assert) {
+ assert.expect(3);
+
+ await this.start();
+ const attachment = this.env.models['mail.attachment'].create({
+ filename: "test.txt",
+ id: 750,
+ mimetype: 'text/plain',
+ name: "test.txt",
+ });
+ await this.createAttachmentComponent(attachment, {
+ detailsMode: 'card',
+ isDownloadable: false,
+ isEditable: false,
+ showExtension: true,
+ showFilename: true
+ });
+ assert.strictEqual(
+ document.querySelectorAll(`.o_Attachment_details`).length,
+ 1,
+ "attachment should have a details part"
+ );
+ assert.strictEqual(
+ document.querySelectorAll(`.o_Attachment_filename`).length,
+ 1,
+ "attachment should have its filename shown"
+ );
+ assert.strictEqual(
+ document.querySelectorAll(`.o_Attachment_extension`).length,
+ 1,
+ "attachment should have its extension shown"
+ );
+});
+
+QUnit.test('simplest layout with hover details and filename and extension', async function (assert) {
+ assert.expect(8);
+
+ await this.start();
+ const attachment = this.env.models['mail.attachment'].create({
+ filename: "test.txt",
+ id: 750,
+ mimetype: 'text/plain',
+ name: "test.txt",
+ });
+ await this.createAttachmentComponent(attachment, {
+ detailsMode: 'hover',
+ isDownloadable: true,
+ isEditable: true,
+ showExtension: true,
+ showFilename: true
+ });
+ assert.strictEqual(
+ document.querySelectorAll(`
+ .o_Attachment_details:not(.o_Attachment_imageOverlayDetails)
+ `).length,
+ 0,
+ "attachment should not have a details part directly"
+ );
+ assert.strictEqual(
+ document.querySelectorAll(`.o_Attachment_imageOverlayDetails`).length,
+ 1,
+ "attachment should have a details part in the overlay"
+ );
+ assert.strictEqual(
+ document.querySelectorAll(`.o_Attachment_image`).length,
+ 1,
+ "attachment should have an image part"
+ );
+ assert.strictEqual(
+ document.querySelectorAll(`.o_Attachment_imageOverlay`).length,
+ 1,
+ "attachment should have an image overlay part"
+ );
+ assert.strictEqual(
+ document.querySelectorAll(`.o_Attachment_filename`).length,
+ 1,
+ "attachment should have its filename shown"
+ );
+ assert.strictEqual(
+ document.querySelectorAll(`.o_Attachment_extension`).length,
+ 1,
+ "attachment should have its extension shown"
+ );
+ assert.strictEqual(
+ document.querySelectorAll(`.o_Attachment_actions`).length,
+ 1,
+ "attachment should have an actions part"
+ );
+ assert.strictEqual(
+ document.querySelectorAll(`.o_Attachment_aside`).length,
+ 0,
+ "attachment should not have an aside element"
+ );
+});
+
+QUnit.test('auto layout with image', async function (assert) {
+ assert.expect(7);
+
+ await this.start({
+ async mockRPC(route, args) {
+ if (route.includes('web/image/750')) {
+ return;
+ }
+ return this._super(...arguments);
+ },
+ });
+ const attachment = this.env.models['mail.attachment'].create({
+ filename: "test.png",
+ id: 750,
+ mimetype: 'image/png',
+ name: "test.png",
+ });
+
+ await this.createAttachmentComponent(attachment, {
+ detailsMode: 'auto',
+ isDownloadable: false,
+ isEditable: false,
+ showExtension: true,
+ showFilename: true
+ });
+ assert.strictEqual(
+ document.querySelectorAll(`
+ .o_Attachment_details:not(.o_Attachment_imageOverlayDetails)
+ `).length,
+ 0,
+ "attachment should not have a details part directly"
+ );
+ assert.strictEqual(
+ document.querySelectorAll(`.o_Attachment_imageOverlayDetails`).length,
+ 1,
+ "attachment should have a details part in the overlay"
+ );
+ assert.strictEqual(
+ document.querySelectorAll(`.o_Attachment_image`).length,
+ 1,
+ "attachment should have an image part"
+ );
+ assert.strictEqual(
+ document.querySelectorAll(`.o_Attachment_imageOverlay`).length,
+ 1,
+ "attachment should have an image overlay part"
+ );
+ assert.strictEqual(
+ document.querySelectorAll(`.o_Attachment_filename`).length,
+ 1,
+ "attachment should have its filename shown"
+ );
+ assert.strictEqual(
+ document.querySelectorAll(`.o_Attachment_extension`).length,
+ 1,
+ "attachment should have its extension shown"
+ );
+ assert.strictEqual(
+ document.querySelectorAll(`.o_Attachment_aside`).length,
+ 0,
+ "attachment should not have an aside element"
+ );
+});
+
+});
+});
+});
+
+});
diff --git a/addons/mail/static/src/components/attachment_box/attachment_box.js b/addons/mail/static/src/components/attachment_box/attachment_box.js
new file mode 100644
index 00000000000..6e0c9d7c55e
--- /dev/null
+++ b/addons/mail/static/src/components/attachment_box/attachment_box.js
@@ -0,0 +1,118 @@
+odoo.define('mail/static/src/components/attachment_box/attachment_box.js', function (require) {
+'use strict';
+
+const components = {
+ AttachmentList: require('mail/static/src/components/attachment_list/attachment_list.js'),
+ DropZone: require('mail/static/src/components/drop_zone/drop_zone.js'),
+ FileUploader: require('mail/static/src/components/file_uploader/file_uploader.js'),
+};
+const useDragVisibleDropZone = require('mail/static/src/component_hooks/use_drag_visible_dropzone/use_drag_visible_dropzone.js');
+const useStore = require('mail/static/src/component_hooks/use_store/use_store.js');
+
+const { Component } = owl;
+const { useRef } = owl.hooks;
+
+class AttachmentBox extends Component {
+
+ /**
+ * @override
+ */
+ constructor(...args) {
+ super(...args);
+ this.isDropZoneVisible = useDragVisibleDropZone();
+ useStore(props => {
+ const thread = this.env.models['mail.thread'].get(props.threadLocalId);
+ return {
+ attachments: thread
+ ? thread.allAttachments.map(attachment => attachment.__state)
+ : [],
+ thread: thread ? thread.__state : undefined,
+ };
+ });
+ /**
+ * Reference of the file uploader.
+ * Useful to programmatically prompts the browser file uploader.
+ */
+ this._fileUploaderRef = useRef('fileUploader');
+ }
+
+ //--------------------------------------------------------------------------
+ // Public
+ //--------------------------------------------------------------------------
+
+ /**
+ * Get an object which is passed to FileUploader component to be used when
+ * creating attachment.
+ *
+ * @returns {Object}
+ */
+ get newAttachmentExtraData() {
+ return {
+ originThread: [['link', this.thread]],
+ };
+ }
+
+ /**
+ * @returns {mail.thread|undefined}
+ */
+ get thread() {
+ return this.env.models['mail.thread'].get(this.props.threadLocalId);
+ }
+
+ //--------------------------------------------------------------------------
+ // Handlers
+ //--------------------------------------------------------------------------
+
+ /**
+ * @private
+ * @param {Event} ev
+ */
+ _onAttachmentCreated(ev) {
+ // FIXME Could be changed by spying attachments count (task-2252858)
+ this.trigger('o-attachments-changed');
+ }
+
+ /**
+ * @private
+ * @param {Event} ev
+ */
+ _onAttachmentRemoved(ev) {
+ // FIXME Could be changed by spying attachments count (task-2252858)
+ this.trigger('o-attachments-changed');
+ }
+
+ /**
+ * @private
+ * @param {Event} ev
+ */
+ _onClickAdd(ev) {
+ ev.preventDefault();
+ ev.stopPropagation();
+ this._fileUploaderRef.comp.openBrowserFileUploader();
+ }
+
+ /**
+ * @private
+ * @param {CustomEvent} ev
+ * @param {Object} ev.detail
+ * @param {FileList} ev.detail.files
+ */
+ async _onDropZoneFilesDropped(ev) {
+ ev.stopPropagation();
+ await this._fileUploaderRef.comp.uploadFiles(ev.detail.files);
+ this.isDropZoneVisible.value = false;
+ }
+
+}
+
+Object.assign(AttachmentBox, {
+ components,
+ props: {
+ threadLocalId: String,
+ },
+ template: 'mail.AttachmentBox',
+});
+
+return AttachmentBox;
+
+});
diff --git a/addons/mail/static/src/components/attachment_box/attachment_box.scss b/addons/mail/static/src/components/attachment_box/attachment_box.scss
new file mode 100644
index 00000000000..d51cca9c31c
--- /dev/null
+++ b/addons/mail/static/src/components/attachment_box/attachment_box.scss
@@ -0,0 +1,46 @@
+// ------------------------------------------------------------------
+// Layout
+// ------------------------------------------------------------------
+
+.o_AttachmentBox {
+ position: relative;
+}
+
+.o_AttachmentBox_buttonAdd {
+ align-self: center;
+}
+
+.o_AttachmentBox_content {
+ display: flex;
+ flex-direction: column;
+}
+
+.o_AttachmentBox_dashedLine {
+ flex-grow: 1;
+}
+
+.o_AttachmentBox_fileInput {
+ display: none;
+}
+
+.o_AttachmentBox_title {
+ display: flex;
+ align-items: center;
+}
+
+.o_AttachmentBox_titleText {
+ padding: map-get($spacers, 3);
+}
+
+// ------------------------------------------------------------------
+// Style
+// ------------------------------------------------------------------
+
+.o_AttachmentBox_dashedLine {
+ border-style: dashed;
+ border-color: gray('300');
+}
+
+.o_AttachmentBox_title {
+ font-weight: bold;
+}
diff --git a/addons/mail/static/src/components/attachment_box/attachment_box.xml b/addons/mail/static/src/components/attachment_box/attachment_box.xml
new file mode 100644
index 00000000000..9cd3e713401
--- /dev/null
+++ b/addons/mail/static/src/components/attachment_box/attachment_box.xml
@@ -0,0 +1,50 @@
+<?xml version="1.0" encoding="UTF-8"?>
+<templates xml:space="preserve">
+
+ <t t-name="mail.AttachmentBox" owl="1">
+ <div class="o_AttachmentBox">
+ <div class="o_AttachmentBox_title">
+ <hr class="o_AttachmentBox_dashedLine"/>
+ <span class="o_AttachmentBox_titleText">
+ Attachments
+ </span>
+ <hr class="o_AttachmentBox_dashedLine"/>
+ </div>
+ <div class="o_AttachmentBox_content">
+ <t t-if="isDropZoneVisible.value">
+ <DropZone
+ class="o_AttachmentBox_dropZone"
+ t-on-o-dropzone-files-dropped="_onDropZoneFilesDropped"
+ t-ref="dropzone"
+ />
+ </t>
+ <t t-if="thread and thread.allAttachments.length > 0">
+ <AttachmentList
+ class="o_attachmentBox_attachmentList"
+ areAttachmentsDownloadable="true"
+ attachmentLocalIds="thread.allAttachments.map(attachment => attachment.localId)"
+ attachmentsDetailsMode="'hover'"
+ attachmentsImageSize="'small'"
+ showAttachmentsFilenames="true"
+ t-on-o-attachment-removed="_onAttachmentRemoved"
+ />
+ </t>
+ <button class="o_AttachmentBox_buttonAdd btn btn-link" type="button" t-on-click="_onClickAdd">
+ <i class="fa fa-plus-square"/>
+ Add attachments
+ </button>
+ </div>
+ <t t-if="thread">
+ <FileUploader
+ attachmentLocalIds="thread.allAttachments.map(attachment => attachment.localId)"
+ newAttachmentExtraData="newAttachmentExtraData"
+ uploadModel="thread.model"
+ uploadId="thread.id"
+ t-on-o-attachment-created="_onAttachmentCreated"
+ t-ref="fileUploader"
+ />
+ </t>
+ </div>
+ </t>
+
+</templates>
diff --git a/addons/mail/static/src/components/attachment_box/attachment_box_tests.js b/addons/mail/static/src/components/attachment_box/attachment_box_tests.js
new file mode 100644
index 00000000000..93690791d10
--- /dev/null
+++ b/addons/mail/static/src/components/attachment_box/attachment_box_tests.js
@@ -0,0 +1,228 @@
+odoo.define('mail/static/src/components/attachment_box/attachment_box_tests.js', function (require) {
+"use strict";
+
+const components = {
+ AttachmentBox: require('mail/static/src/components/attachment_box/attachment_box.js'),
+};
+const {
+ afterEach: utilsAfterEach,
+ afterNextRender,
+ beforeEach: utilsBeforeEach,
+ dragenterFiles,
+ dropFiles,
+ start: utilsStart,
+} = require('mail/static/src/utils/test_utils.js');
+
+const { file: { createFile } } = require('web.test_utils');
+
+QUnit.module('mail', {}, function () {
+QUnit.module('components', {}, function () {
+QUnit.module('attachment_box', {}, function () {
+QUnit.module('attachment_box_tests.js', {
+ beforeEach() {
+ utilsBeforeEach(this);
+
+ this.createAttachmentBoxComponent = async (thread, otherProps) => {
+ const AttachmentBoxComponent = components.AttachmentBox;
+ AttachmentBoxComponent.env = this.env;
+ this.component = new AttachmentBoxComponent(null, Object.assign({
+ threadLocalId: thread.localId,
+ }, otherProps));
+ await this.component.mount(this.widget.el);
+ };
+
+ this.start = async params => {
+ if (this.widget) {
+ this.widget.destroy();
+ }
+ let { env, widget } = await utilsStart(Object.assign({}, params, {
+ data: this.data,
+ }));
+ this.env = env;
+ this.widget = widget;
+ };
+ },
+ afterEach() {
+ utilsAfterEach(this);
+ if (this.component) {
+ this.component.destroy();
+ }
+ if (this.widget) {
+ this.widget.destroy();
+ }
+ delete components.AttachmentBox.env;
+ this.env = undefined;
+ },
+});
+
+QUnit.test('base empty rendering', async function (assert) {
+ assert.expect(4);
+
+ await this.start();
+ const thread = this.env.models['mail.thread'].create({
+ id: 100,
+ model: 'res.partner',
+ });
+ await this.createAttachmentBoxComponent(thread);
+ assert.strictEqual(
+ document.querySelectorAll(`.o_AttachmentBox`).length,
+ 1,
+ "should have an attachment box"
+ );
+ assert.strictEqual(
+ document.querySelectorAll(`.o_AttachmentBox_buttonAdd`).length,
+ 1,
+ "should have a button add"
+ );
+ assert.strictEqual(
+ document.querySelectorAll(`.o_FileUploader_input`).length,
+ 1,
+ "should have a file input"
+ );
+ assert.strictEqual(
+ document.querySelectorAll(`.o_AttachmentBox .o_Attachment`).length,
+ 0,
+ "should not have any attachment"
+ );
+});
+
+QUnit.test('base non-empty rendering', async function (assert) {
+ assert.expect(6);
+
+ await this.start({
+ async mockRPC(route, args) {
+ if (route.includes('ir.attachment/search_read')) {
+ assert.step('ir.attachment/search_read');
+ return [{
+ id: 143,
+ filename: 'Blah.txt',
+ mimetype: 'text/plain',
+ name: 'Blah.txt'
+ }, {
+ id: 144,
+ filename: 'Blu.txt',
+ mimetype: 'text/plain',
+ name: 'Blu.txt'
+ }];
+ }
+ return this._super(...arguments);
+ }
+ });
+ const thread = this.env.models['mail.thread'].create({
+ id: 100,
+ model: 'res.partner',
+ });
+ await thread.fetchAttachments();
+ await this.createAttachmentBoxComponent(thread);
+ assert.verifySteps(
+ ['ir.attachment/search_read'],
+ "should have fetched attachments"
+ );
+ assert.strictEqual(
+ document.querySelectorAll(`.o_AttachmentBox`).length,
+ 1,
+ "should have an attachment box"
+ );
+ assert.strictEqual(
+ document.querySelectorAll(`.o_AttachmentBox_buttonAdd`).length,
+ 1,
+ "should have a button add"
+ );
+ assert.strictEqual(
+ document.querySelectorAll(`.o_FileUploader_input`).length,
+ 1,
+ "should have a file input"
+ );
+ assert.strictEqual(
+ document.querySelectorAll(`.o_attachmentBox_attachmentList`).length,
+ 1,
+ "should have an attachment list"
+ );
+});
+
+QUnit.test('attachment box: drop attachments', async function (assert) {
+ assert.expect(5);
+
+ await this.start({
+ async mockRPC(route, args) {
+ if (route.includes('ir.attachment/search_read')) {
+ return [];
+ }
+ return this._super(...arguments);
+ }
+ });
+ const thread = this.env.models['mail.thread'].create({
+ id: 100,
+ model: 'res.partner',
+ });
+ await thread.fetchAttachments();
+ await this.createAttachmentBoxComponent(thread);
+ const files = [
+ await createFile({
+ content: 'hello, world',
+ contentType: 'text/plain',
+ name: 'text.txt',
+ }),
+ ];
+ assert.strictEqual(
+ document.querySelectorAll('.o_AttachmentBox').length,
+ 1,
+ "should have an attachment box"
+ );
+
+ await afterNextRender(() =>
+ dragenterFiles(document.querySelector('.o_AttachmentBox'))
+ );
+ assert.ok(
+ document.querySelector('.o_AttachmentBox_dropZone'),
+ "should have a drop zone"
+ );
+ assert.strictEqual(
+ document.querySelectorAll(`.o_AttachmentBox .o_Attachment`).length,
+ 0,
+ "should have no attachment before files are dropped"
+ );
+
+ await afterNextRender(() =>
+ dropFiles(
+ document.querySelector('.o_AttachmentBox_dropZone'),
+ files
+ )
+ );
+ assert.strictEqual(
+ document.querySelectorAll(`.o_AttachmentBox .o_Attachment`).length,
+ 1,
+ "should have 1 attachment in the box after files dropped"
+ );
+
+ await afterNextRender(() =>
+ dragenterFiles(document.querySelector('.o_AttachmentBox'))
+ );
+ const file1 = await createFile({
+ content: 'hello, world',
+ contentType: 'text/plain',
+ name: 'text2.txt',
+ });
+ const file2 = await createFile({
+ content: 'hello, world',
+ contentType: 'text/plain',
+ name: 'text3.txt',
+ });
+ await afterNextRender(() =>
+ dropFiles(
+ document.querySelector('.o_AttachmentBox_dropZone'),
+ [file1, file2]
+ )
+ );
+ assert.strictEqual(
+ document.querySelectorAll(`.o_AttachmentBox .o_Attachment`).length,
+ 3,
+ "should have 3 attachments in the box after files dropped"
+ );
+});
+
+});
+});
+});
+
+});
diff --git a/addons/mail/static/src/components/attachment_list/attachment_list.js b/addons/mail/static/src/components/attachment_list/attachment_list.js
new file mode 100644
index 00000000000..d097e5d705d
--- /dev/null
+++ b/addons/mail/static/src/components/attachment_list/attachment_list.js
@@ -0,0 +1,54 @@
+odoo.define('mail/static/src/components/attachment_list/attachment_list.js', function (require) {
+'use strict';
+
+const components = {
+ Attachment: require('mail/static/src/components/attachment/attachment.js'),
+};
+
+const { Component } = owl;
+
+class AttachmentList extends Component {}
+
+Object.assign(AttachmentList, {
+ components,
+ defaultProps: {
+ attachmentLocalIds: [],
+ },
+ props: {
+ areAttachmentsDownloadable: {
+ type: Boolean,
+ optional: true,
+ },
+ areAttachmentsEditable: {
+ type: Boolean,
+ optional: true,
+ },
+ attachmentLocalIds: {
+ type: Array,
+ element: String,
+ },
+ attachmentsDetailsMode: {
+ type: String,
+ optional: true,
+ validate: prop => ['auto', 'card', 'hover', 'none'].includes(prop),
+ },
+ attachmentsImageSize: {
+ type: String,
+ optional: true,
+ validate: prop => ['small', 'medium', 'large'].includes(prop),
+ },
+ showAttachmentsExtensions: {
+ type: Boolean,
+ optional: true,
+ },
+ showAttachmentsFilenames: {
+ type: Boolean,
+ optional: true,
+ },
+ },
+ template: 'mail.AttachmentList',
+});
+
+return AttachmentList;
+
+});
diff --git a/addons/mail/static/src/components/attachment_list/attachment_list.scss b/addons/mail/static/src/components/attachment_list/attachment_list.scss
new file mode 100644
index 00000000000..af4954818e0
--- /dev/null
+++ b/addons/mail/static/src/components/attachment_list/attachment_list.scss
@@ -0,0 +1,13 @@
+// ------------------------------------------------------------------
+// Layout
+// ------------------------------------------------------------------
+
+.o_AttachmentList {
+ display: flex;
+ flex-flow: wrap;
+ justify-content: flex-start;
+}
+
+.o_AttachmentList_attachment {
+ margin: map-get($spacers, 1);
+}
diff --git a/addons/mail/static/src/components/attachment_list/attachment_list.xml b/addons/mail/static/src/components/attachment_list/attachment_list.xml
new file mode 100644
index 00000000000..452419105ff
--- /dev/null
+++ b/addons/mail/static/src/components/attachment_list/attachment_list.xml
@@ -0,0 +1,22 @@
+<?xml version="1.0" encoding="UTF-8"?>
+<templates xml:space="preserve">
+
+ <t t-name="mail.AttachmentList" owl="1">
+ <div class="o_AttachmentList">
+ <t t-foreach="props.attachmentLocalIds" t-as="attachmentLocalId" t-key="attachmentLocalId">
+ <Attachment
+ class="o_AttachmentList_attachment"
+ attachmentLocalId="attachmentLocalId"
+ attachmentLocalIds="props.attachmentLocalIds"
+ detailsMode="props.attachmentsDetailsMode"
+ imageSize="props.attachmentsImageSize"
+ isDownloadable="props.areAttachmentsDownloadable"
+ isEditable="props.areAttachmentsEditable"
+ showExtension="props.showAttachmentsExtensions"
+ showFilename="props.showAttachmentsFilenames"
+ />
+ </t>
+ </div>
+ </t>
+
+</templates>
diff --git a/addons/mail/static/src/components/attachment_viewer/attachment_viewer.js b/addons/mail/static/src/components/attachment_viewer/attachment_viewer.js
new file mode 100644
index 00000000000..54f5f383d3b
--- /dev/null
+++ b/addons/mail/static/src/components/attachment_viewer/attachment_viewer.js
@@ -0,0 +1,593 @@
+odoo.define('mail/static/src/components/attachment_viewer/attachment_viewer.js', function (require) {
+'use strict';
+
+const useStore = require('mail/static/src/component_hooks/use_store/use_store.js');
+
+const { Component, QWeb } = owl;
+const { useRef } = owl.hooks;
+
+const MIN_SCALE = 0.5;
+const SCROLL_ZOOM_STEP = 0.1;
+const ZOOM_STEP = 0.5;
+
+class AttachmentViewer extends Component {
+
+ /**
+ * @override
+ */
+ constructor(...args) {
+ super(...args);
+ this.MIN_SCALE = MIN_SCALE;
+ useStore(props => {
+ const attachmentViewer = this.env.messaging.attachmentViewer;
+ return {
+ attachment: attachmentViewer && attachmentViewer.attachment
+ ? attachmentViewer.attachment.__state
+ : undefined,
+ attachments: attachmentViewer
+ ? attachmentViewer.attachments.map(attachment => attachment.__state)
+ : [],
+ attachmentViewer: attachmentViewer ? attachmentViewer.__state : undefined,
+ };
+ });
+ /**
+ * Determine whether the user is currently dragging the image.
+ * This is useful to determine whether a click outside of the image
+ * should close the attachment viewer or not.
+ */
+ this._isDragging = false;
+ /**
+ * Reference to the image node. Useful in the computation of the zoomer
+ * style (based on user zoom in/out interactions).
+ */
+ this._imageRef = useRef('image');
+ /**
+ * Reference of the zoomer node. Useful to apply translate
+ * transformation on image visualisation.
+ */
+ this._zoomerRef = useRef('zoomer');
+ /**
+ * Tracked translate transformations on image visualisation. This is
+ * not observed with `useStore` because they are used to compute zoomer
+ * style, and this is changed directly on zoomer for performance
+ * reasons (overhead of making vdom is too significant for each mouse
+ * position changes while dragging)
+ */
+ this._translate = { x: 0, y: 0, dx: 0, dy: 0 };
+ /**
+ * Tracked last rendered attachment. Useful to detect a new image is
+ * loading, in order to display spinner until it is fully loaded.
+ */
+ this._renderedAttachment = undefined;
+ this._onClickGlobal = this._onClickGlobal.bind(this);
+ }
+
+ mounted() {
+ this.el.focus();
+ this._handleImageLoad();
+ this._renderedAttachment = this.attachmentViewer.attachment;
+ document.addEventListener('click', this._onClickGlobal);
+ }
+
+ /**
+ * When a new image is displayed, show a spinner until it is loaded.
+ */
+ patched() {
+ this._handleImageLoad();
+ this._renderedAttachment = this.attachmentViewer.attachment;
+ }
+
+ willUnmount() {
+ document.removeEventListener('click', this._onClickGlobal);
+ }
+
+ //--------------------------------------------------------------------------
+ // Public
+ //--------------------------------------------------------------------------
+
+ /**
+ * @returns {mail.attachment_viewer}
+ */
+ get attachmentViewer() {
+ return this.env.messaging.attachmentViewer;
+ }
+
+ /**
+ * Compute the style of the image (scale + rotation).
+ *
+ * @returns {string}
+ */
+ get imageStyle() {
+ const attachmentViewer = this.attachmentViewer;
+ let style = `transform: ` +
+ `scale3d(${attachmentViewer.scale}, ${attachmentViewer.scale}, 1) ` +
+ `rotate(${attachmentViewer.angle}deg);`;
+
+ if (attachmentViewer.angle % 180 !== 0) {
+ style += `` +
+ `max-height: ${window.innerWidth}px; ` +
+ `max-width: ${window.innerHeight}px;`;
+ } else {
+ style += `` +
+ `max-height: 100%; ` +
+ `max-width: 100%;`;
+ }
+ return style;
+ }
+
+ /**
+ * Mandatory method for dialog components.
+ * Prevent closing the dialog when clicking on the mask when the user is
+ * currently dragging the image.
+ *
+ * @returns {boolean}
+ */
+ isCloseable() {
+ return !this._isDragging;
+ }
+
+ //--------------------------------------------------------------------------
+ // Private
+ //--------------------------------------------------------------------------
+
+ /**
+ * Close the dialog with this attachment viewer.
+ *
+ * @private
+ */
+ _close() {
+ this.attachmentViewer.delete();
+ }
+
+ /**
+ * Download the attachment.
+ *
+ * @private
+ */
+ _download() {
+ const id = this.attachmentViewer.attachment.id;
+ this.env.services.navigate(`/web/content/ir.attachment/${id}/datas?download=true`);
+ }
+
+ /**
+ * Determine whether the current image is rendered for the 1st time, and if
+ * that's the case, display a spinner until loaded.
+ *
+ * @private
+ */
+ _handleImageLoad() {
+ if (
+ this.attachmentViewer.attachment.fileType === 'image' &&
+ this._renderedAttachment !== this.attachmentViewer.attachment
+ ) {
+ this.attachmentViewer.update({ isImageLoading: true });
+ this._imageRef.el.addEventListener('load', ev => this._onLoadImage(ev));
+ }
+ }
+
+ /**
+ * Display the previous attachment in the list of attachments.
+ *
+ * @private
+ */
+ _next() {
+ const attachmentViewer = this.attachmentViewer;
+ const index = attachmentViewer.attachments.findIndex(attachment =>
+ attachment === attachmentViewer.attachment
+ );
+ const nextIndex = (index + 1) % attachmentViewer.attachments.length;
+ attachmentViewer.update({
+ attachment: [['link', attachmentViewer.attachments[nextIndex]]],
+ });
+ }
+
+ /**
+ * Display the previous attachment in the list of attachments.
+ *
+ * @private
+ */
+ _previous() {
+ const attachmentViewer = this.attachmentViewer;
+ const index = attachmentViewer.attachments.findIndex(attachment =>
+ attachment === attachmentViewer.attachment
+ );
+ const nextIndex = index === 0
+ ? attachmentViewer.attachments.length - 1
+ : index - 1;
+ attachmentViewer.update({
+ attachment: [['link', attachmentViewer.attachments[nextIndex]]],
+ });
+ }
+
+ /**
+ * Prompt the browser print of this attachment.
+ *
+ * @private
+ */
+ _print() {
+ const printWindow = window.open('about:blank', '_new');
+ printWindow.document.open();
+ printWindow.document.write(`
+ <html>
+ <head>
+ <script>
+ function onloadImage() {
+ setTimeout('printImage()', 10);
+ }
+ function printImage() {
+ window.print();
+ window.close();
+ }
+ </script>
+ </head>
+ <body onload='onloadImage()'>
+ <img src="${this.attachmentViewer.attachment.defaultSource}" alt=""/>
+ </body>
+ </html>`);
+ printWindow.document.close();
+ }
+
+ /**
+ * Rotate the image by 90 degrees to the right.
+ *
+ * @private
+ */
+ _rotate() {
+ this.attachmentViewer.update({ angle: this.attachmentViewer.angle + 90 });
+ }
+
+ /**
+ * Stop dragging interaction of the user.
+ *
+ * @private
+ */
+ _stopDragging() {
+ this._isDragging = false;
+ this._translate.x += this._translate.dx;
+ this._translate.y += this._translate.dy;
+ this._translate.dx = 0;
+ this._translate.dy = 0;
+ this._updateZoomerStyle();
+ }
+
+ /**
+ * Update the style of the zoomer based on translate transformation. Changes
+ * are directly applied on zoomer, instead of triggering re-render and
+ * defining them in the template, for performance reasons.
+ *
+ * @private
+ * @returns {string}
+ */
+ _updateZoomerStyle() {
+ const attachmentViewer = this.attachmentViewer;
+ const tx = this._imageRef.el.offsetWidth * attachmentViewer.scale > this._zoomerRef.el.offsetWidth
+ ? this._translate.x + this._translate.dx
+ : 0;
+ const ty = this._imageRef.el.offsetHeight * attachmentViewer.scale > this._zoomerRef.el.offsetHeight
+ ? this._translate.y + this._translate.dy
+ : 0;
+ if (tx === 0) {
+ this._translate.x = 0;
+ }
+ if (ty === 0) {
+ this._translate.y = 0;
+ }
+ this._zoomerRef.el.style = `transform: ` +
+ `translate(${tx}px, ${ty}px)`;
+ }
+
+ /**
+ * Zoom in the image.
+ *
+ * @private
+ * @param {Object} [param0={}]
+ * @param {boolean} [param0.scroll=false]
+ */
+ _zoomIn({ scroll = false } = {}) {
+ this.attachmentViewer.update({
+ scale: this.attachmentViewer.scale + (scroll ? SCROLL_ZOOM_STEP : ZOOM_STEP),
+ });
+ this._updateZoomerStyle();
+ }
+
+ /**
+ * Zoom out the image.
+ *
+ * @private
+ * @param {Object} [param0={}]
+ * @param {boolean} [param0.scroll=false]
+ */
+ _zoomOut({ scroll = false } = {}) {
+ if (this.attachmentViewer.scale === MIN_SCALE) {
+ return;
+ }
+ const unflooredAdaptedScale = (
+ this.attachmentViewer.scale -
+ (scroll ? SCROLL_ZOOM_STEP : ZOOM_STEP)
+ );
+ this.attachmentViewer.update({
+ scale: Math.max(MIN_SCALE, unflooredAdaptedScale),
+ });
+ this._updateZoomerStyle();
+ }
+
+ /**
+ * Reset the zoom scale of the image.
+ *
+ * @private
+ */
+ _zoomReset() {
+ this.attachmentViewer.update({ scale: 1 });
+ this._updateZoomerStyle();
+ }
+
+ //--------------------------------------------------------------------------
+ // Handlers
+ //--------------------------------------------------------------------------
+
+ /**
+ * Called when clicking on mask of attachment viewer.
+ *
+ * @private
+ * @param {MouseEvent} ev
+ */
+ _onClick(ev) {
+ if (this._isDragging) {
+ return;
+ }
+ // TODO: clicking on the background should probably be handled by the dialog?
+ // task-2092965
+ this._close();
+ }
+
+ /**
+ * Called when clicking on cross icon.
+ *
+ * @private
+ * @param {MouseEvent} ev
+ */
+ _onClickClose(ev) {
+ this._close();
+ }
+
+ /**
+ * Called when clicking on download icon.
+ *
+ * @private
+ * @param {MouseEvent} ev
+ */
+ _onClickDownload(ev) {
+ ev.stopPropagation();
+ this._download();
+ }
+
+ /**
+ * @private
+ * @param {MouseEvent} ev
+ */
+ _onClickGlobal(ev) {
+ if (!this._isDragging) {
+ return;
+ }
+ ev.stopPropagation();
+ this._stopDragging();
+ }
+
+ /**
+ * Called when clicking on the header. Stop propagation of event to prevent
+ * closing the dialog.
+ *
+ * @private
+ * @param {MouseEvent} ev
+ */
+ _onClickHeader(ev) {
+ ev.stopPropagation();
+ }
+
+ /**
+ * Called when clicking on image. Stop propagation of event to prevent
+ * closing the dialog.
+ *
+ * @private
+ * @param {MouseEvent} ev
+ */
+ _onClickImage(ev) {
+ if (this._isDragging) {
+ return;
+ }
+ ev.stopPropagation();
+ }
+
+ /**
+ * Called when clicking on next icon.
+ *
+ * @private
+ * @param {MouseEvent} ev
+ */
+ _onClickNext(ev) {
+ ev.stopPropagation();
+ this._next();
+ }
+
+ /**
+ * Called when clicking on previous icon.
+ *
+ * @private
+ * @param {MouseEvent} ev
+ */
+ _onClickPrevious(ev) {
+ ev.stopPropagation();
+ this._previous();
+ }
+
+ /**
+ * Called when clicking on print icon.
+ *
+ * @private
+ * @param {MouseEvent} ev
+ */
+ _onClickPrint(ev) {
+ ev.stopPropagation();
+ this._print();
+ }
+
+ /**
+ * Called when clicking on rotate icon.
+ *
+ * @private
+ * @param {MouseEvent} ev
+ */
+ _onClickRotate(ev) {
+ ev.stopPropagation();
+ this._rotate();
+ }
+
+ /**
+ * Called when clicking on embed video player. Stop propagation to prevent
+ * closing the dialog.
+ *
+ * @private
+ * @param {MouseEvent} ev
+ */
+ _onClickVideo(ev) {
+ ev.stopPropagation();
+ }
+
+ /**
+ * Called when clicking on zoom in icon.
+ *
+ * @private
+ * @param {MouseEvent} ev
+ */
+ _onClickZoomIn(ev) {
+ ev.stopPropagation();
+ this._zoomIn();
+ }
+
+ /**
+ * Called when clicking on zoom out icon.
+ *
+ * @private
+ * @param {MouseEvent} ev
+ */
+ _onClickZoomOut(ev) {
+ ev.stopPropagation();
+ this._zoomOut();
+ }
+
+ /**
+ * Called when clicking on reset zoom icon.
+ *
+ * @private
+ * @param {MouseEvent} ev
+ */
+ _onClickZoomReset(ev) {
+ ev.stopPropagation();
+ this._zoomReset();
+ }
+
+ /**
+ * @private
+ * @param {KeyboardEvent} ev
+ */
+ _onKeydown(ev) {
+ switch (ev.key) {
+ case 'ArrowRight':
+ this._next();
+ break;
+ case 'ArrowLeft':
+ this._previous();
+ break;
+ case 'Escape':
+ this._close();
+ break;
+ case 'q':
+ this._close();
+ break;
+ case 'r':
+ this._rotate();
+ break;
+ case '+':
+ this._zoomIn();
+ break;
+ case '-':
+ this._zoomOut();
+ break;
+ case '0':
+ this._zoomReset();
+ break;
+ default:
+ return;
+ }
+ ev.stopPropagation();
+ }
+
+ /**
+ * Called when new image has been loaded
+ *
+ * @private
+ * @param {Event} ev
+ */
+ _onLoadImage(ev) {
+ ev.stopPropagation();
+ this.attachmentViewer.update({ isImageLoading: false });
+ }
+
+ /**
+ * @private
+ * @param {DragEvent} ev
+ */
+ _onMousedownImage(ev) {
+ if (this._isDragging) {
+ return;
+ }
+ if (ev.button !== 0) {
+ return;
+ }
+ ev.stopPropagation();
+ this._isDragging = true;
+ this._dragstartX = ev.clientX;
+ this._dragstartY = ev.clientY;
+ }
+
+ /**
+ * @private
+ * @param {DragEvent}
+ */
+ _onMousemoveView(ev) {
+ if (!this._isDragging) {
+ return;
+ }
+ this._translate.dx = ev.clientX - this._dragstartX;
+ this._translate.dy = ev.clientY - this._dragstartY;
+ this._updateZoomerStyle();
+ }
+
+ /**
+ * @private
+ * @param {Event} ev
+ */
+ _onWheelImage(ev) {
+ ev.stopPropagation();
+ if (!this.el) {
+ return;
+ }
+ if (ev.deltaY > 0) {
+ this._zoomIn({ scroll: true });
+ } else {
+ this._zoomOut({ scroll: true });
+ }
+ }
+
+}
+
+Object.assign(AttachmentViewer, {
+ props: {},
+ template: 'mail.AttachmentViewer',
+});
+
+QWeb.registerComponent('AttachmentViewer', AttachmentViewer);
+
+return AttachmentViewer;
+
+});
diff --git a/addons/mail/static/src/components/attachment_viewer/attachment_viewer.scss b/addons/mail/static/src/components/attachment_viewer/attachment_viewer.scss
new file mode 100644
index 00000000000..54f00c1ae5d
--- /dev/null
+++ b/addons/mail/static/src/components/attachment_viewer/attachment_viewer.scss
@@ -0,0 +1,198 @@
+// ------------------------------------------------------------------
+// Layout
+// ------------------------------------------------------------------
+
+.o_AttachmentViewer {
+ display: flex;
+ width: 100%;
+ height: 100%;
+ flex-flow: column;
+ align-items: center;
+ z-index: -1;
+}
+
+.o_AttachmentViewer_buttonNavigation {
+ position: absolute;
+ display: flex;
+ align-items: center;
+ justify-content: center;
+ width: 40px;
+ height: 40px;
+ top: 50%;
+ transform: translateY(-50%);
+}
+
+.o_AttachmentViewer_buttonNavigationNext {
+ right: 15px;
+
+ > .fa {
+ margin: 1px 0 0 1px; // not correctly centered for some reasons
+ }
+}
+
+.o_AttachmentViewer_buttonNavigationPrevious {
+ left: 15px;
+
+ > .fa {
+ margin: 1px 1px 0 0; // not correctly centered for some reasons
+ }
+}
+
+.o_AttachmentViewer_header {
+ display: flex;
+ height: $o-navbar-height;
+ align-items: center;
+ padding: 0 15px;
+ width: 100%;
+}
+
+.o_AttachmentViewer_headerItem {
+ margin: 0 5px;
+
+ &:first-child {
+ margin-left: 0;
+ }
+
+ &:last-child {
+ margin-right: 0;
+ }
+}
+
+.o_AttachmentViewer_loading {
+ position: absolute;
+}
+
+.o_AttachmentViewer_main {
+ position: absolute;
+ top: 0;
+ left: 0;
+ right: 0;
+ bottom: 0;
+ display: flex;
+ align-items: center;
+ justify-content: center;
+ z-index: -1;
+ padding: 45px 0;
+
+ &.o_with_img {
+ overflow: hidden;
+ }
+}
+
+.o_AttachmentViewer_toolbar {
+ position: absolute;
+ bottom: 45px;
+ transform: translateY(100%);
+ display: flex;
+}
+
+.o_AttachmentViewer_toolbarButton {
+ padding: 8px;
+}
+
+.o_AttachmentViewer_viewImage {
+ max-height: 100%;
+ max-width: 100%;
+}
+
+.o_AttachmentViewer_viewIframe {
+ width: 90%;
+ height: 100%;
+}
+
+.o_AttachmentViewer_viewVideo {
+ width: 75%;
+ height: 75%;
+}
+
+.o_AttachmentViewer_zoomer {
+ position: absolute;
+ padding: 45px 0;
+ height: 100%;
+ width: 100%;
+ display: flex;
+ align-items: center;
+ justify-content: center;
+}
+
+// ------------------------------------------------------------------
+// Style
+// ------------------------------------------------------------------
+
+.o_AttachmentViewer {
+ outline: none;
+}
+
+.o_AttachmentViewer_buttonNavigation {
+ color: gray('400');
+ background-color: lighten(black, 15%);
+ border-radius: 100%;
+ cursor: pointer;
+
+ &:hover {
+ color: lighten(gray('400'), 15%);
+ background-color: black;
+ }
+}
+
+.o_AttachmentViewer_header {
+ background-color: rgba(0, 0, 0, 0.7);
+ color: gray('400');
+}
+
+.o_AttachmentViewer_headerItemButton {
+ cursor: pointer;
+
+ &:hover {
+ background-color: rgba(0, 0, 0, 0.8);
+ color: lighten(gray('400'), 15%);
+ }
+}
+
+.o_AttachmentViewer_headerItemButtonClose {
+ cursor: pointer;
+ font-size: 1.3rem;
+}
+
+.o_AttachmentViewer_toolbar {
+ cursor: pointer;
+}
+
+.o_AttachmentViewer_toolbarButton {
+ background-color: lighten(black, 15%);
+
+ &.o_disabled {
+ cursor: not-allowed;
+ filter: brightness(1.3);
+ }
+
+ &:not(.o_disabled) {
+ color: gray('400');
+ cursor: pointer;
+
+ &:hover {
+ background-color: black;
+ color: lighten(gray('400'), 15%);
+ }
+ }
+}
+
+.o_AttachmentViewer_view {
+ background-color: black;
+ box-shadow: 0 0 40px black;
+ outline: none;
+ border: none;
+
+ &.o_text {
+ background-color: white;
+ }
+}
+
+// ------------------------------------------------------------------
+// Animation
+// ------------------------------------------------------------------
+
+.o_AttachmentViewer_viewImage {
+ transition: transform 0.3s ease;
+}
+
diff --git a/addons/mail/static/src/components/attachment_viewer/attachment_viewer.xml b/addons/mail/static/src/components/attachment_viewer/attachment_viewer.xml
new file mode 100644
index 00000000000..af7967b164f
--- /dev/null
+++ b/addons/mail/static/src/components/attachment_viewer/attachment_viewer.xml
@@ -0,0 +1,93 @@
+<?xml version="1.0" encoding="UTF-8"?>
+<templates xml:space="preserve">
+
+ <t t-name="mail.AttachmentViewer" owl="1">
+ <div class="o_AttachmentViewer" t-on-click="_onClick" t-on-keydown="_onKeydown" tabindex="0" t-att-data-dialog="attachmentViewer.dialog.localId">
+ <div class="o_AttachmentViewer_header" t-on-click="_onClickHeader">
+ <t t-if="attachmentViewer.attachment.fileType">
+ <div class="o_AttachmentViewer_headerItem o_AttachmentViewer_icon">
+ <t t-if="attachmentViewer.attachment.fileType === 'image'">
+ <i class="fa fa-picture-o" role="img" title="Image"/>
+ </t>
+ <t t-if="attachmentViewer.attachment.fileType === 'application/pdf'">
+ <i class="fa fa-file-text" role="img" title="PDF file"/>
+ </t>
+ <t t-if="attachmentViewer.attachment.isTextFile">
+ <i class="fa fa-file-text" role="img" title="Text file"/>
+ </t>
+ <t t-if="attachmentViewer.attachment.fileType === 'video'">
+ <i class="fa fa-video-camera" role="img" title="Video"/>
+ </t>
+ </div>
+ </t>
+ <div class="o_AttachmentViewer_headerItem o_AttachmentViewer_name">
+ <t t-esc="attachmentViewer.attachment.displayName"/>
+ </div>
+ <div class="o_AttachmentViewer_buttonDownload o_AttachmentViewer_headerItem o_AttachmentViewer_headerItemButton" t-on-click="_onClickDownload" role="button" title="Download">
+ <i class="fa fa-download fa-fw" role="img"/>
+ </div>
+ <div class="o-autogrow"/>
+ <div class="o_AttachmentViewer_headerItem o_AttachmentViewer_headerItemButton o_AttachmentViewer_headerItemButtonClose" t-on-click="_onClickClose" role="button" title="Close (Esc)" aria-label="Close">
+ <i class="fa fa-fw fa-times" role="img"/>
+ </div>
+ </div>
+ <div class="o_AttachmentViewer_main" t-att-class="{ o_with_img: attachmentViewer.attachment.fileType === 'image' }" t-on-mousemove="_onMousemoveView">
+ <t t-if="attachmentViewer.attachment.fileType === 'image'">
+ <div class="o_AttachmentViewer_zoomer" t-ref="zoomer">
+ <t t-if="attachmentViewer.isImageLoading">
+ <div class="o_AttachmentViewer_loading">
+ <i class="fa fa-3x fa-circle-o-notch fa-fw fa-spin" role="img" title="Loading"/>
+ </div>
+ </t>
+ <img class="o_AttachmentViewer_view o_AttachmentViewer_viewImage" t-on-click="_onClickImage" t-on-mousedown="_onMousedownImage" t-on-wheel="_onWheelImage" t-att-src="attachmentViewer.attachment.defaultSource" t-att-style="imageStyle" draggable="false" alt="Viewer" t-ref="image"/>
+ </div>
+ </t>
+ <t t-if="attachmentViewer.attachment.fileType === 'application/pdf'">
+ <iframe class="o_AttachmentViewer_view o_AttachmentViewer_viewIframe o_AttachmentViewer_viewPdf" t-att-src="attachmentViewer.attachment.defaultSource"/>
+ </t>
+ <t t-if="attachmentViewer.attachment.isTextFile">
+ <iframe class="o_AttachmentViewer_view o_AttachmentViewer_viewIframe o_text" t-att-src="attachmentViewer.attachment.defaultSource"/>
+ </t>
+ <t t-if="attachmentViewer.attachment.fileType === 'youtu'">
+ <iframe allow="autoplay; encrypted-media" class="o_AttachmentViewer_view o_AttachmentViewer_viewIframe o_AttachmentViewer_youtube" t-att-src="attachmentViewer.attachment.defaultSource" height="315" width="560"/>
+ </t>
+ <t t-if="attachmentViewer.attachment.fileType === 'video'">
+ <video class="o_AttachmentViewer_view o_AttachmentViewer_viewVideo" t-on-click="_onClickVideo" controls="controls">
+ <source t-att-data-type="attachmentViewer.attachment.mimetype" t-att-src="attachmentViewer.attachment.defaultSource"/>
+ </video>
+ </t>
+ </div>
+ <t t-if="attachmentViewer.attachment.fileType === 'image'">
+ <div class="o_AttachmentViewer_toolbar" role="toolbar">
+ <div class="o_AttachmentViewer_toolbarButton" t-on-click="_onClickZoomIn" title="Zoom In (+)" role="button">
+ <i class="fa fa-fw fa-plus" role="img"/>
+ </div>
+ <div class="o_AttachmentViewer_toolbarButton" t-att-class="{ o_disabled: attachmentViewer.scale === 1 }" t-on-click="_onClickZoomReset" role="button" title="Reset Zoom (0)">
+ <i class="fa fa-fw fa-search" role="img"/>
+ </div>
+ <div class="o_AttachmentViewer_toolbarButton" t-att-class="{ o_disabled: attachmentViewer.scale === MIN_SCALE }" t-on-click="_onClickZoomOut" title="Zoom Out (-)" role="button">
+ <i class="fafa-fw fa-minus" role="img"/>
+ </div>
+ <div class="o_AttachmentViewer_toolbarButton" t-on-click="_onClickRotate" title="Rotate (r)" role="button">
+ <i class="fa fa-fw fa-repeat" role="img"/>
+ </div>
+ <div class="o_AttachmentViewer_toolbarButton" t-on-click="_onClickPrint" title="Print" role="button">
+ <i class="fa fa-fw fa-print" role="img"/>
+ </div>
+ <div class="o_AttachmentViewer_buttonDownload o_AttachmentViewer_toolbarButton" t-on-click="_onClickDownload" title="Download" role="button">
+ <i class="fa fa-download fa-fw" role="img"/>
+ </div>
+ </div>
+ </t>
+ <t t-if="attachmentViewer.attachments.length > 1">
+ <div class="o_AttachmentViewer_buttonNavigation o_AttachmentViewer_buttonNavigationPrevious" t-on-click="_onClickPrevious" title="Previous (Left-Arrow)" role="button">
+ <span class="fa fa-chevron-left" role="img"/>
+ </div>
+ <div class="o_AttachmentViewer_buttonNavigation o_AttachmentViewer_buttonNavigationNext" t-on-click="_onClickNext" title="Next (Right-Arrow)" role="button">
+ <span class="fa fa-chevron-right" role="img"/>
+ </div>
+ </t>
+ </div>
+ </t>
+
+</templates>
diff --git a/addons/mail/static/src/components/autocomplete_input/autocomplete_input.js b/addons/mail/static/src/components/autocomplete_input/autocomplete_input.js
new file mode 100644
index 00000000000..1e383f7b307
--- /dev/null
+++ b/addons/mail/static/src/components/autocomplete_input/autocomplete_input.js
@@ -0,0 +1,161 @@
+odoo.define('mail/static/src/components/autocomplete_input/autocomplete_input.js', function (require) {
+'use strict';
+
+const { Component } = owl;
+
+class AutocompleteInput extends Component {
+
+ mounted() {
+ if (this.props.isFocusOnMount) {
+ this.el.focus();
+ }
+
+ let args = {
+ select: (ev, ui) => this._onAutocompleteSelect(ev, ui),
+ source: (req, res) => this._onAutocompleteSource(req, res),
+ focus: ev => this._onAutocompleteFocus(ev),
+ html: this.props.isHtml || false,
+ };
+
+ if (this.props.customClass) {
+ args.classes = { 'ui-autocomplete': this.props.customClass };
+ }
+
+ $(this.el).autocomplete(args);
+ }
+
+ willUnmount() {
+ $(this.el).autocomplete('destroy');
+ }
+
+ //--------------------------------------------------------------------------
+ // Public
+ //--------------------------------------------------------------------------
+
+ /**
+ * Returns whether the given node is self or a children of self, including
+ * the suggestion menu.
+ *
+ * @param {Node} node
+ * @returns {boolean}
+ */
+ contains(node) {
+ if (this.el.contains(node)) {
+ return true;
+ }
+ if (!this.props.customClass) {
+ return false;
+ }
+ const element = document.querySelector(`.${this.props.customClass}`);
+ if (!element) {
+ return false;
+ }
+ return element.contains(node);
+ }
+
+ focus() {
+ if (!this.el) {
+ return;
+ }
+ this.el.focus();
+ }
+
+ focusout() {
+ if (!this.el) {
+ return;
+ }
+ this.el.blur();
+ }
+
+ //--------------------------------------------------------------------------
+ // Handlers
+ //--------------------------------------------------------------------------
+
+ /**
+ * @private
+ * @param {FocusEvent} ev
+ */
+ _onAutocompleteFocus(ev) {
+ if (this.props.focus) {
+ this.props.focus(ev);
+ } else {
+ ev.preventDefault();
+ }
+ }
+
+ /**
+ * @private
+ * @param {Event} ev
+ * @param {Object} ui
+ */
+ _onAutocompleteSelect(ev, ui) {
+ ev.stopPropagation();
+ if (this.props.select) {
+ this.props.select(ev, ui);
+ }
+ }
+
+ /**
+ * @private
+ * @param {Object} req
+ * @param {function} res
+ */
+ _onAutocompleteSource(req, res) {
+ if (this.props.source) {
+ this.props.source(req, res);
+ }
+ }
+
+ /**
+ * @private
+ * @param {MouseEvent} ev
+ */
+ _onBlur(ev) {
+ this.trigger('o-hide');
+ }
+
+ /**
+ * @private
+ * @param {MouseEvent} ev
+ */
+ _onKeydown(ev) {
+ if (ev.key === 'Escape') {
+ this.trigger('o-hide');
+ }
+ }
+
+}
+
+Object.assign(AutocompleteInput, {
+ defaultProps: {
+ isFocusOnMount: false,
+ isHtml: false,
+ placeholder: '',
+ },
+ props: {
+ customClass: {
+ type: String,
+ optional: true,
+ },
+ focus: {
+ type: Function,
+ optional: true,
+ },
+ isFocusOnMount: Boolean,
+ isHtml: Boolean,
+ placeholder: String,
+ select: {
+ type: Function,
+ optional: true,
+ },
+ source: {
+ type: Function,
+ optional: true,
+ },
+ },
+ template: 'mail.AutocompleteInput',
+});
+
+return AutocompleteInput;
+
+});
diff --git a/addons/mail/static/src/components/autocomplete_input/autocomplete_input.xml b/addons/mail/static/src/components/autocomplete_input/autocomplete_input.xml
new file mode 100644
index 00000000000..ffa1bc890a4
--- /dev/null
+++ b/addons/mail/static/src/components/autocomplete_input/autocomplete_input.xml
@@ -0,0 +1,8 @@
+<?xml version="1.0" encoding="UTF-8"?>
+<templates xml:space="preserve">
+
+ <t t-name="mail.AutocompleteInput" owl="1">
+ <input class="o_AutocompleteInput" t-on-blur="_onBlur" t-on-keydown="_onKeydown" t-att-placeholder="props.placeholder"/>
+ </t>
+
+</templates>
diff --git a/addons/mail/static/src/components/chat_window/chat_window.js b/addons/mail/static/src/components/chat_window/chat_window.js
new file mode 100644
index 00000000000..8d1851c78fc
--- /dev/null
+++ b/addons/mail/static/src/components/chat_window/chat_window.js
@@ -0,0 +1,352 @@
+odoo.define('mail/static/src/components/chat_window/chat_window.js', function (require) {
+'use strict';
+
+const components = {
+ AutocompleteInput: require('mail/static/src/components/autocomplete_input/autocomplete_input.js'),
+ ChatWindowHeader: require('mail/static/src/components/chat_window_header/chat_window_header.js'),
+ ThreadViewer: require('mail/static/src/components/thread_viewer/thread_viewer.js'),
+};
+const useStore = require('mail/static/src/component_hooks/use_store/use_store.js');
+
+const { Component } = owl;
+const { useRef } = owl.hooks;
+
+class ChatWindow extends Component {
+
+ /**
+ * @override
+ */
+ constructor(...args) {
+ super(...args);
+ useStore(props => {
+ const chatWindow = this.env.models['mail.chat_window'].get(props.chatWindowLocalId);
+ return {
+ chatWindow: chatWindow ? chatWindow.__state : undefined,
+ isDeviceMobile: this.env.messaging.device.isMobile,
+ localeTextDirection: this.env.messaging.locale.textDirection,
+ };
+ });
+ /**
+ * Reference of the header of the chat window.
+ * Useful to prevent click on header from wrongly focusing the window.
+ */
+ this._chatWindowHeaderRef = useRef('header');
+ /**
+ * Reference of the autocomplete input (new_message chat window only).
+ * Useful when focusing this chat window, which consists of focusing
+ * this input.
+ */
+ this._inputRef = useRef('input');
+ /**
+ * Reference of thread in the chat window (chat window with thread
+ * only). Useful when focusing this chat window, which consists of
+ * focusing this thread. Will likely focus the composer of thread, if
+ * it has one!
+ */
+ this._threadRef = useRef('thread');
+ // the following are passed as props to children
+ this._onAutocompleteSelect = this._onAutocompleteSelect.bind(this);
+ this._onAutocompleteSource = this._onAutocompleteSource.bind(this);
+ }
+
+ mounted() {
+ this.env.messagingBus.on('will_hide_home_menu', this, this._onWillHideHomeMenu.bind(this));
+ this.env.messagingBus.on('will_show_home_menu', this, this._onWillShowHomeMenu.bind(this));
+ this._update();
+ }
+
+ patched() {
+ this._update();
+ }
+
+ willUnmount() {
+ this.env.messagingBus.off('will_hide_home_menu', this, this._onWillHideHomeMenu.bind(this));
+ this.env.messagingBus.off('will_show_home_menu', this, this._onWillShowHomeMenu.bind(this));
+ }
+
+ //--------------------------------------------------------------------------
+ // Public
+ //--------------------------------------------------------------------------
+
+ /**
+ * @returns {mail.chat_window}
+ */
+ get chatWindow() {
+ return this.env.models['mail.chat_window'].get(this.props.chatWindowLocalId);
+ }
+
+ /**
+ * Get the content of placeholder for the autocomplete input of
+ * 'new_message' chat window.
+ *
+ * @returns {string}
+ */
+ get newMessageFormInputPlaceholder() {
+ return this.env._t("Search user...");
+ }
+
+ //--------------------------------------------------------------------------
+ // Private
+ //--------------------------------------------------------------------------
+
+ /**
+ * Apply visual position of the chat window.
+ *
+ * @private
+ */
+ _applyVisibleOffset() {
+ const textDirection = this.env.messaging.locale.textDirection;
+ const offsetFrom = textDirection === 'rtl' ? 'left' : 'right';
+ const oppositeFrom = offsetFrom === 'right' ? 'left' : 'right';
+ this.el.style[offsetFrom] = this.chatWindow.visibleOffset + 'px';
+ this.el.style[oppositeFrom] = 'auto';
+ }
+
+ /**
+ * Focus this chat window.
+ *
+ * @private
+ */
+ _focus() {
+ this.chatWindow.update({
+ isDoFocus: false,
+ isFocused: true,
+ });
+ if (!this.chatWindow.thread) {
+ this._inputRef.comp.focus();
+ } else {
+ this._threadRef.comp.focus();
+ }
+ }
+
+ /**
+ * Save the scroll positions of the chat window in the store.
+ * This is useful in order to remount chat windows and keep previous
+ * scroll positions. This is necessary because when toggling on/off
+ * home menu, the chat windows have to be remade from scratch.
+ *
+ * @private
+ */
+ _saveThreadScrollTop() {
+ this.chatWindow.update({
+ threadInitialScrollTop: this._threadRef.comp.getScrollTop(),
+ });
+ }
+
+ /**
+ * @private
+ */
+ _update() {
+ if (!this.chatWindow) {
+ // chat window is being deleted
+ return;
+ }
+ if (this.chatWindow.isDoFocus) {
+ this._focus();
+ }
+ this._applyVisibleOffset();
+ }
+
+ //--------------------------------------------------------------------------
+ // Handlers
+ //--------------------------------------------------------------------------
+
+ /**
+ * Called when selecting an item in the autocomplete input of the
+ * 'new_message' chat window.
+ *
+ * @private
+ * @param {Event} ev
+ * @param {Object} ui
+ * @param {Object} ui.item
+ * @param {integer} ui.item.id
+ */
+ _onAutocompleteSelect(ev, ui) {
+ const partnerId = ui.item.id;
+ const partner = this.env.models['mail.partner'].find(partner => partner.id === partnerId);
+ const chat = partner.correspondentThreads.find(thread => thread.channel_type === 'chat');
+ if (chat) {
+ chat.open({ chatWindowMode: 'from_new_message' });
+ } else {
+ this.env.models['mail.thread'].createChannel({
+ autoselect: true,
+ autoselectChatWindowMode: 'from_new_message',
+ partnerId,
+ type: 'chat',
+ });
+ }
+ }
+
+ /**
+ * Called when typing in the autocomplete input of the 'new_message' chat
+ * window.
+ *
+ * @private
+ * @param {Object} req
+ * @param {string} req.term
+ * @param {function} res
+ */
+ _onAutocompleteSource(req, res) {
+ this.env.models['mail.partner'].imSearch({
+ callback: (partners) => {
+ const suggestions = partners.map(partner => {
+ return {
+ id: partner.id,
+ value: partner.nameOrDisplayName,
+ label: partner.nameOrDisplayName,
+ };
+ });
+ res(_.sortBy(suggestions, 'label'));
+ },
+ keyword: _.escape(req.term),
+ limit: 10,
+ });
+ }
+
+ /**
+ * Handle focus of the chat window based on position of click. The click on
+ * chat window that folds it should NOT set focus on this chat window.
+ *
+ * @private
+ * @param {MouseEvent} ev
+ */
+ _onClick(ev) {
+ const chatWindowHeader = this._chatWindowHeaderRef.el;
+ if (chatWindowHeader && chatWindowHeader.contains(ev.target)) {
+ // handled in _onClickedHeader
+ return;
+ }
+ if (this.chatWindow.isFocused) {
+ return;
+ }
+ this.chatWindow.focus();
+ }
+
+ /**
+ * Called when clicking on header of chat window. Usually folds the chat
+ * window.
+ *
+ * @private
+ * @param {CustomEvent} ev
+ */
+ _onClickedHeader(ev) {
+ ev.stopPropagation();
+ if (this.env.messaging.device.isMobile) {
+ return;
+ }
+ if (this.chatWindow.isFolded) {
+ this.chatWindow.unfold();
+ this.chatWindow.focus();
+ } else {
+ this.chatWindow.fold();
+ }
+ }
+
+ /**
+ * Called when an element in the thread becomes focused.
+ *
+ * @private
+ * @param {FocusEvent} ev
+ */
+ _onFocusinThread(ev) {
+ ev.stopPropagation();
+ this.chatWindow.update({ isFocused: true });
+ }
+
+ /**
+ * Focus out the chat window.
+ *
+ * @private
+ */
+ _onFocusout() {
+ if (this._inputRef.comp) {
+ this._inputRef.comp.focusout();
+ }
+ if (this._threadRef.comp) {
+ this._threadRef.comp.focusout();
+ }
+ if (!this.chatWindow) {
+ // ignore focus out due to record being deleted
+ return;
+ }
+ this.chatWindow.update({ isFocused: false });
+ }
+
+ /**
+ * @private
+ * @param {KeyboardEvent} ev
+ */
+ _onKeydown(ev) {
+ /**
+ * Prevent auto-focus of fuzzy search in the home menu.
+ * Useful in order to allow copy/paste content inside chat window with
+ * CTRL-C & CTRL-V when on the home menu.
+ */
+ ev.stopPropagation();
+ switch (ev.key) {
+ case 'Tab':
+ ev.preventDefault();
+ if (ev.shiftKey) {
+ this.chatWindow.focusPreviousVisibleUnfoldedChatWindow();
+ } else {
+ this.chatWindow.focusNextVisibleUnfoldedChatWindow();
+ }
+ break;
+ case 'Escape':
+ ev.preventDefault();
+ this.chatWindow.close();
+ break;
+ }
+ }
+
+ /**
+ * Save the scroll positions of the chat window in the store.
+ * This is useful in order to remount chat windows and keep previous
+ * scroll positions. This is necessary because when toggling on/off
+ * home menu, the chat windows have to be remade from scratch.
+ *
+ * @private
+ */
+ async _onWillHideHomeMenu() {
+ if (!this.chatWindow.thread) {
+ return;
+ }
+ this._saveThreadScrollTop();
+ }
+
+ /**
+ * Save the scroll positions of the chat window in the store.
+ * This is useful in order to remount chat windows and keep previous
+ * scroll positions. This is necessary because when toggling on/off
+ * home menu, the chat windows have to be remade from scratch.
+ *
+ * @private
+ */
+ async _onWillShowHomeMenu() {
+ if (!this.chatWindow.thread) {
+ return;
+ }
+ this._saveThreadScrollTop();
+ }
+
+}
+
+Object.assign(ChatWindow, {
+ components,
+ defaultProps: {
+ hasCloseAsBackButton: false,
+ isExpandable: false,
+ isFullscreen: false,
+ },
+ props: {
+ chatWindowLocalId: String,
+ hasCloseAsBackButton: Boolean,
+ isExpandable: Boolean,
+ isFullscreen: Boolean,
+ },
+ template: 'mail.ChatWindow',
+});
+
+return ChatWindow;
+
+});
diff --git a/addons/mail/static/src/components/chat_window/chat_window.scss b/addons/mail/static/src/components/chat_window/chat_window.scss
new file mode 100644
index 00000000000..299da88575e
--- /dev/null
+++ b/addons/mail/static/src/components/chat_window/chat_window.scss
@@ -0,0 +1,101 @@
+// ------------------------------------------------------------------
+// Layout
+// ------------------------------------------------------------------
+
+.o_ChatWindow {
+ position: absolute;
+ bottom: 0;
+ display: flex;
+ flex-flow: column;
+
+ &:not(.o-mobile) {
+ max-width: 100%;
+ max-height: 100%;
+ width: 325px;
+
+ &.o-folded {
+ height: $o-mail-chat-window-header-height;
+ }
+
+ &:not(.o-folded) {
+ height: 400px;
+ }
+ }
+
+ &.o-mobile {
+ position: fixed;
+ }
+
+ &.o-fullscreen {
+ height: 100%;
+ width: 100%;
+ }
+}
+
+.o_ChatWindow_header {
+ flex: 0 0 auto;
+}
+
+.o_ChatWindow_newMessageForm {
+ padding: 3px;
+ margin-top: 3px;
+ display: flex;
+ align-items: center;
+}
+
+.o_ChatWindow_newMessageFormInput {
+ flex: 1 1 auto;
+}
+
+.o_ChatWindow_newMessageFormLabel {
+ margin-right: 5px;
+ flex: 0 0 auto;
+}
+
+.o_ChatWindow_thread {
+ flex: 1 1 auto;
+}
+
+// ------------------------------------------------------------------
+// Style
+// ------------------------------------------------------------------
+
+.o_ChatWindow {
+ background-color: $o-mail-thread-window-bg;
+ border-radius: 6px 6px 0 0;
+ box-shadow: -5px -5px 10px rgba(black, 0.09);
+ outline: none;
+
+ &:not(.o-mobile) {
+
+ &.o-focused {
+ box-shadow: -5px -5px 10px rgba(black, 0.18);
+ }
+ }
+
+
+ .o_Composer {
+ border: 0;
+ }
+}
+
+.o_ChatWindow_header {
+ border-radius: 3px 3px 0 0;
+}
+
+.o_ChatWindow_newMessageFormInput {
+ outline: none;
+ border: 1px solid gray('300'); // cancel firefox border on input focus
+}
+
+.o_ChatWindow_thread .o_ThreadViewer_messageList {
+ font-size: 1rem;
+}
+
+// ------------------------------------------------------------------
+// Animation
+// ------------------------------------------------------------------
+
+.o_ChatWindow:not(.o-mobile) {
+ transition: height 0.3s;
+}
diff --git a/addons/mail/static/src/components/chat_window/chat_window.xml b/addons/mail/static/src/components/chat_window/chat_window.xml
new file mode 100644
index 00000000000..c4f6117a25d
--- /dev/null
+++ b/addons/mail/static/src/components/chat_window/chat_window.xml
@@ -0,0 +1,56 @@
+<?xml version="1.0" encoding="UTF-8"?>
+<templates xml:space="preserve">
+
+ <t t-name="mail.ChatWindow" owl="1">
+ <div class="o_ChatWindow" tabindex="0"
+ t-att-class="{
+ 'o-focused': chatWindow and chatWindow.isFocused,
+ 'o-folded': chatWindow and chatWindow.isFolded,
+ 'o-fullscreen': props.isFullscreen,
+ 'o-mobile': env.messaging.device.isMobile,
+ 'o-new-message': chatWindow and !chatWindow.thread,
+ }" t-on-click="_onClick" t-on-keydown="_onKeydown" t-on-focusout="_onFocusout" t-att-data-chat-window-local-id="chatWindow ? chatWindow.localId : undefined" t-att-data-thread-local-id="chatWindow ? (chatWindow.thread ? chatWindow.thread.localId : '') : undefined"
+ >
+ <t t-if="chatWindow">
+ <ChatWindowHeader
+ class="o_ChatWindow_header"
+ chatWindowLocalId="chatWindow.localId"
+ hasCloseAsBackButton="props.hasCloseAsBackButton"
+ isExpandable="props.isExpandable"
+ t-on-o-clicked="_onClickedHeader"
+ t-ref="header"
+ />
+ <t t-if="chatWindow.thread">
+ <ThreadViewer
+ class="o_ChatWindow_thread"
+ composerAttachmentsDetailsMode="'card'"
+ hasComposer="chatWindow.thread.model !== 'mail.box'"
+ hasComposerCurrentPartnerAvatar="false"
+ hasComposerSendButton="env.messaging.device.isMobile"
+ hasSquashCloseMessages="chatWindow.thread.model !== 'mail.box'"
+ haveMessagesAuthorRedirect="chatWindow.thread.channel_type !== 'chat'"
+ threadViewerLocalId="chatWindow.threadViewer.localId"
+ t-on-focusin="_onFocusinThread"
+ t-ref="thread"
+ />
+ </t>
+ <t t-else="">
+ <div class="o_ChatWindow_newMessageForm">
+ <span class="o_ChatWindow_newMessageFormLabel">
+ To:
+ </span>
+ <AutocompleteInput
+ class="o_ChatWindow_newMessageFormInput"
+ isHtml="true"
+ placeholder="newMessageFormInputPlaceholder"
+ select="_onAutocompleteSelect"
+ source="_onAutocompleteSource"
+ t-ref="input"
+ />
+ </div>
+ </t>
+ </t>
+ </div>
+ </t>
+
+</templates>
diff --git a/addons/mail/static/src/components/chat_window_header/chat_window_header.js b/addons/mail/static/src/components/chat_window_header/chat_window_header.js
new file mode 100644
index 00000000000..60294ffc213
--- /dev/null
+++ b/addons/mail/static/src/components/chat_window_header/chat_window_header.js
@@ -0,0 +1,107 @@
+odoo.define('mail/static/src/components/chat_window_header/chat_window_header.js', function (require) {
+'use strict';
+
+const components = {
+ ThreadIcon: require('mail/static/src/components/thread_icon/thread_icon.js'),
+};
+const useStore = require('mail/static/src/component_hooks/use_store/use_store.js');
+
+const { Component } = owl;
+
+class ChatWindowHeader extends Component {
+
+ /**
+ * @override
+ */
+ constructor(...args) {
+ super(...args);
+ useStore(props => {
+ const chatWindow = this.env.models['mail.chat_window'].get(props.chatWindowLocalId);
+ const thread = chatWindow && chatWindow.thread;
+ return {
+ chatWindow: chatWindow ? chatWindow.__state : undefined,
+ isDeviceMobile: this.env.messaging.device.isMobile,
+ thread: thread ? thread.__state : undefined,
+ };
+ });
+ }
+
+ //--------------------------------------------------------------------------
+ // Public
+ //--------------------------------------------------------------------------
+
+ /**
+ * @returns {mail.chat_window}
+ */
+ get chatWindow() {
+ return this.env.models['mail.chat_window'].get(this.props.chatWindowLocalId);
+ }
+
+ //--------------------------------------------------------------------------
+ // Handlers
+ //--------------------------------------------------------------------------
+
+ /**
+ * @private
+ * @param {MouseEvent} ev
+ */
+ _onClick(ev) {
+ const chatWindow = this.chatWindow;
+ this.trigger('o-clicked', { chatWindow });
+ }
+
+ /**
+ * @private
+ * @param {MouseEvent} ev
+ */
+ _onClickClose(ev) {
+ ev.stopPropagation();
+ this.chatWindow.close();
+ }
+
+ /**
+ * @private
+ * @param {MouseEvent} ev
+ */
+ _onClickExpand(ev) {
+ ev.stopPropagation();
+ this.chatWindow.expand();
+ }
+
+ /**
+ * @private
+ * @param {MouseEvent} ev
+ */
+ _onClickShiftLeft(ev) {
+ ev.stopPropagation();
+ this.chatWindow.shiftLeft();
+ }
+
+ /**
+ * @private
+ * @param {MouseEvent} ev
+ */
+ _onClickShiftRight(ev) {
+ ev.stopPropagation();
+ this.chatWindow.shiftRight();
+ }
+
+}
+
+Object.assign(ChatWindowHeader, {
+ components,
+ defaultProps: {
+ hasCloseAsBackButton: false,
+ isExpandable: false,
+ },
+ props: {
+ chatWindowLocalId: String,
+ hasCloseAsBackButton: Boolean,
+ isExpandable: Boolean,
+ },
+ template: 'mail.ChatWindowHeader',
+});
+
+return ChatWindowHeader;
+
+});
diff --git a/addons/mail/static/src/components/chat_window_header/chat_window_header.scss b/addons/mail/static/src/components/chat_window_header/chat_window_header.scss
new file mode 100644
index 00000000000..c5c23634d20
--- /dev/null
+++ b/addons/mail/static/src/components/chat_window_header/chat_window_header.scss
@@ -0,0 +1,95 @@
+// ------------------------------------------------------------------
+// Layout
+// ------------------------------------------------------------------
+
+.o_ChatWindowHeader {
+ display: flex;
+ align-items: center;
+ height: $o-mail-chat-window-header-height;
+
+ &.o-mobile {
+ height: $o-mail-chat-window-header-height-mobile;
+ }
+}
+
+.o_ChatWindowHeader_command {
+ padding: 0 8px;
+ display: flex;
+ height: 100%;
+ align-items: center;
+
+ &:hover {
+ background-color: rgba(0, 0, 0, 0.1);
+ }
+}
+
+.o_ChatWindowHeader_commandBack {
+ margin-right: 5px;
+}
+
+.o_ChatWindowHeader_item {
+ margin: 0 3px;
+
+ &.o_ChatWindowHeader_rightArea {
+ margin-right: 0;
+ }
+
+ &:first-child {
+ margin-left: 10px;
+
+ &.o_ChatWindowHeader_command {
+ margin-left: 0px; // no margin for commands
+ }
+ }
+
+ &.o_ChatWindowHeader_rightArea:last-child .o_ChatWindowHeader_command {
+ margin-right: 0px; // no margin for commands
+ }
+}
+
+.o_ChatWindowHeader_name {
+ max-height: 100%;
+ white-space: nowrap;
+ overflow: hidden;
+ text-overflow: ellipsis;
+}
+
+.o_ChatWindowHeader_rightArea {
+ display: flex;
+ height: 100%;
+ align-items: center;
+}
+
+// ------------------------------------------------------------------
+// Style
+// ------------------------------------------------------------------
+
+.o_ChatWindowHeader {
+ background-color: $o-brand-odoo;
+ color: white;
+ cursor: pointer;
+
+ &:not(.o-mobile) {
+
+ &:hover .o_ChatWindowHeader_command {
+ opacity: 0.7;
+
+ &:hover {
+ opacity: 1;
+ }
+ }
+
+ &:not(:hover) .o_ChatWindowHeader_command {
+ opacity: 0.5;
+ }
+ }
+
+}
+
+.o_ChatWindowHeader_command.o-mobile {
+ font-size: 1.3rem;
+}
+
+.o_ChatWindowHeader_name {
+ user-select: none;
+}
diff --git a/addons/mail/static/src/components/chat_window_header/chat_window_header.xml b/addons/mail/static/src/components/chat_window_header/chat_window_header.xml
new file mode 100644
index 00000000000..0c3261282cc
--- /dev/null
+++ b/addons/mail/static/src/components/chat_window_header/chat_window_header.xml
@@ -0,0 +1,53 @@
+<?xml version="1.0" encoding="UTF-8"?>
+<templates xml:space="preserve">
+
+ <t t-name="mail.ChatWindowHeader" owl="1">
+ <div class="o_ChatWindowHeader" t-att-class="{ 'o-mobile': env.messaging.device.isMobile }" t-on-click="_onClick">
+ <t t-if="chatWindow">
+ <t t-if="props.hasCloseAsBackButton">
+ <div class="o_ChatWindowHeader_command o_ChatWindowHeader_commandBack o_ChatWindowHeader_commandClose" t-att-class="{ 'o-mobile': env.messaging.device.isMobile }" t-on-click="_onClickClose" title="Close conversation">
+ <i class="fa fa-arrow-left"/>
+ </div>
+ </t>
+ <t t-if="chatWindow.thread">
+ <ThreadIcon
+ class="o_ChatWindowHeader_icon o_ChatWindowHeader_item"
+ threadLocalId="chatWindow.thread.localId"
+ />
+ </t>
+ <div class="o_ChatWindowHeader_item o_ChatWindowHeader_name" t-att-title="chatWindow.name">
+ <t t-esc="chatWindow.name"/>
+ </div>
+ <t t-if="chatWindow.thread and chatWindow.thread.message_unread_counter > 0">
+ <div class="o_ChatWindowHeader_counter o_ChatWindowHeader_item">
+ (<t t-esc="chatWindow.thread.message_unread_counter"/>)
+ </div>
+ </t>
+ <div class="o-autogrow"/>
+ <div class="o_ChatWindowHeader_item o_ChatWindowHeader_rightArea">
+ <t t-if="chatWindow.hasShiftLeft">
+ <div class="o_ChatWindowHeader_command o_ChatWindowHeader_commandShiftLeft" t-att-class="{ 'o-mobile': env.messaging.device.isMobile }" t-on-click="_onClickShiftLeft" title="Shift left">
+ <i class="fa fa-angle-left"/>
+ </div>
+ </t>
+ <t t-if="chatWindow.hasShiftRight">
+ <div class="o_ChatWindowHeader_command o_ChatWindowHeader_commandShiftRight" t-att-class="{ 'o-mobile': env.messaging.device.isMobile }" t-on-click="_onClickShiftRight" title="Shift right">
+ <i class="fa fa-angle-right"/>
+ </div>
+ </t>
+ <t t-if="props.isExpandable">
+ <div class="o_ChatWindowHeader_command o_ChatWindowHeader_commandExpand" t-att-class="{ 'o-mobile': env.messaging.device.isMobile }" t-on-click="_onClickExpand" title="Open in Discuss">
+ <i class="fa fa-expand"/>
+ </div>
+ </t>
+ <t t-if="!props.hasCloseAsBackButton">
+ <div class="o_ChatWindowHeader_command o_ChatWindowHeader_commandClose" t-att-class="{ 'o-mobile': env.messaging.device.isMobile }" t-on-click="_onClickClose" title="Close chat window">
+ <i class="fa fa-close"/>
+ </div>
+ </t>
+ </div>
+ </t>
+ </div>
+ </t>
+
+</templates>
diff --git a/addons/mail/static/src/components/chat_window_hidden_menu/chat_window_hidden_menu.js b/addons/mail/static/src/components/chat_window_hidden_menu/chat_window_hidden_menu.js
new file mode 100644
index 00000000000..b598eeee4b4
--- /dev/null
+++ b/addons/mail/static/src/components/chat_window_hidden_menu/chat_window_hidden_menu.js
@@ -0,0 +1,142 @@
+odoo.define('mail/static/src/components/chat_window_hidden_menu/chat_window_hidden_menu.js', function (require) {
+'use strict';
+
+const components = {
+ ChatWindowHeader: require('mail/static/src/components/chat_window_header/chat_window_header.js'),
+};
+const useStore = require('mail/static/src/component_hooks/use_store/use_store.js');
+
+const { Component } = owl;
+const { useRef } = owl.hooks;
+
+class ChatWindowHiddenMenu extends Component {
+
+ /**
+ * @override
+ */
+ constructor(...args) {
+ super(...args);
+ useStore(props => {
+ const chatWindowManager = this.env.messaging.chatWindowManager;
+ const device = this.env.messaging.device;
+ const locale = this.env.messaging.locale;
+ return {
+ chatWindowManager: chatWindowManager ? chatWindowManager.__state : undefined,
+ device: device ? device.__state : undefined,
+ localeTextDirection: locale ? locale.textDirection : undefined,
+ };
+ });
+ this._onClickCaptureGlobal = this._onClickCaptureGlobal.bind(this);
+ /**
+ * Reference of the dropup list. Useful to auto-set max height based on
+ * browser screen height.
+ */
+ this._listRef = useRef('list');
+ /**
+ * The intent of the toggle button depends on the last rendered state.
+ */
+ this._wasMenuOpen;
+ }
+
+ mounted() {
+ this._apply();
+ document.addEventListener('click', this._onClickCaptureGlobal, true);
+ }
+
+ patched() {
+ this._apply();
+ }
+
+ willUnmount() {
+ document.removeEventListener('click', this._onClickCaptureGlobal, true);
+ }
+
+ //--------------------------------------------------------------------------
+ // Private
+ //--------------------------------------------------------------------------
+
+ /**
+ * @private
+ */
+ _apply() {
+ this._applyListHeight();
+ this._applyOffset();
+ this._wasMenuOpen = this.env.messaging.chatWindowManager.isHiddenMenuOpen;
+ }
+
+ /**
+ * @private
+ */
+ _applyListHeight() {
+ const device = this.env.messaging.device;
+ const height = device.globalWindowInnerHeight / 2;
+ this._listRef.el.style['max-height'] = `${height}px`;
+ }
+
+ /**
+ * @private
+ */
+ _applyOffset() {
+ const textDirection = this.env.messaging.locale.textDirection;
+ const offsetFrom = textDirection === 'rtl' ? 'left' : 'right';
+ const oppositeFrom = offsetFrom === 'right' ? 'left' : 'right';
+ const offset = this.env.messaging.chatWindowManager.visual.hidden.offset;
+ this.el.style[offsetFrom] = `${offset}px`;
+ this.el.style[oppositeFrom] = 'auto';
+ }
+
+ //--------------------------------------------------------------------------
+ // Handlers
+ //--------------------------------------------------------------------------
+
+ /**
+ * Closes the menu when clicking outside.
+ * Must be done as capture to avoid stop propagation.
+ *
+ * @private
+ * @param {MouseEvent} ev
+ */
+ _onClickCaptureGlobal(ev) {
+ if (this.el.contains(ev.target)) {
+ return;
+ }
+ this.env.messaging.chatWindowManager.closeHiddenMenu();
+ }
+
+ /**
+ * @private
+ * @param {MouseEvent} ev
+ */
+ _onClickToggle(ev) {
+ if (this._wasMenuOpen) {
+ this.env.messaging.chatWindowManager.closeHiddenMenu();
+ } else {
+ this.env.messaging.chatWindowManager.openHiddenMenu();
+ }
+ }
+
+ /**
+ * @private
+ * @param {CustomEvent} ev
+ * @param {Object} ev.detail
+ * @param {mail.chat_window} ev.detail.chatWindow
+ */
+ _onClickedChatWindow(ev) {
+ const chatWindow = ev.detail.chatWindow;
+ chatWindow.makeVisible();
+ chatWindow.unfold();
+ chatWindow.focus();
+ this.env.messaging.chatWindowManager.closeHiddenMenu();
+ }
+
+}
+
+Object.assign(ChatWindowHiddenMenu, {
+ components,
+ props: {},
+ template: 'mail.ChatWindowHiddenMenu',
+});
+
+return ChatWindowHiddenMenu;
+
+});
diff --git a/addons/mail/static/src/components/chat_window_hidden_menu/chat_window_hidden_menu.scss b/addons/mail/static/src/components/chat_window_hidden_menu/chat_window_hidden_menu.scss
new file mode 100644
index 00000000000..119d61848ab
--- /dev/null
+++ b/addons/mail/static/src/components/chat_window_hidden_menu/chat_window_hidden_menu.scss
@@ -0,0 +1,90 @@
+// ------------------------------------------------------------------
+// Layout
+// ------------------------------------------------------------------
+
+.o_ChatWindowHiddenMenu {
+ position: fixed;
+ bottom: 0;
+ display: flex;
+ width: 50px;
+ height: 28px;
+ align-items: stretch;
+}
+
+.o_ChatWindowHiddenMenu_chatWindowHeader {
+ max-width: 200px;
+}
+
+.o_ChatWindowHiddenMenu_dropdownToggle {
+ display: flex;
+ align-items: center;
+ justify-content: center;
+ flex: 1 1 auto;
+ max-width: 100%;
+}
+
+.o_ChatWindowHiddenMenu_dropdownToggleIcon {
+ margin-right: 1px;
+}
+
+.o_ChatWindowHiddenMenu_dropdownToggleItem {
+ margin: 0 3px;
+}
+
+.o_ChatWindowHiddenMenu_list {
+ overflow: auto;
+ margin: 0;
+ padding: 0;
+}
+
+.o_ChatWindowHiddenMenu_listItem {
+
+ &:not(:last-child) {
+ margin-bottom: 1px;
+ }
+}
+
+.o_ChatWindowHiddenMenu_unreadCounter {
+ position: absolute;
+ right: 0;
+ top: 0;
+ transform: translate(50%, -50%);
+ z-index: 1001; // on top of bootstrap dropup menu
+}
+
+.o_ChatWindowHiddenMenu_windowCounter {
+ overflow: hidden;
+ text-overflow: ellipsis;
+ margin-left: 1px;
+}
+
+// ------------------------------------------------------------------
+// Style
+// ------------------------------------------------------------------
+
+.o_ChatWindowHiddenMenu {
+ background-color: gray('900');
+ border-radius: 6px 6px 0 0;
+ color: white;
+ cursor: pointer;
+}
+
+.o_ChatWindowHiddenMenu_chatWindowHeader {
+ opacity: 0.95;
+
+ &:hover {
+ opacity: 1;
+ }
+}
+
+.o_ChatWindowHiddenMenu_dropdownToggle.show {
+ opacity: 0.5;
+}
+
+.o_ChatWindowHiddenMenu_unreadCounter {
+ background-color: $o-brand-primary;
+}
+
+.o_ChatWindowHiddenMenu_windowCounter {
+ user-select: none;
+}
diff --git a/addons/mail/static/src/components/chat_window_hidden_menu/chat_window_hidden_menu.xml b/addons/mail/static/src/components/chat_window_hidden_menu/chat_window_hidden_menu.xml
new file mode 100644
index 00000000000..943fc71d09d
--- /dev/null
+++ b/addons/mail/static/src/components/chat_window_hidden_menu/chat_window_hidden_menu.xml
@@ -0,0 +1,31 @@
+<?xml version="1.0" encoding="UTF-8"?>
+<templates xml:space="preserve">
+
+ <t t-name="mail.ChatWindowHiddenMenu" owl="1">
+ <div class="dropup o_ChatWindowHiddenMenu">
+ <div class="dropdown-toggle o_ChatWindowHiddenMenu_dropdownToggle" t-att-class="{ show: env.messaging.chatWindowManager.isHiddenMenuOpen }" t-on-click="_onClickToggle">
+ <div class="fa fa-comments-o o_ChatWindowHiddenMenu_dropdownToggleIcon o_ChatWindowHiddenMenu_dropdownToggleItem"/>
+ <div class="o_ChatWindowHiddenMenu_dropdownToggleItem o_ChatWindowHiddenMenu_windowCounter">
+ <t t-esc="env.messaging.chatWindowManager.allOrderedHidden.length"/>
+ </div>
+ </div>
+ <ul class="dropdown-menu dropdown-menu-right o_ChatWindowHiddenMenu_list" t-att-class="{ show: env.messaging.chatWindowManager.isHiddenMenuOpen }" role="menu" t-ref="list">
+ <t t-foreach="env.messaging.chatWindowManager.allOrderedHidden" t-as="chatWindow" t-key="chatWindow.localId">
+ <li class="o_ChatWindowHiddenMenu_listItem" role="menuitem">
+ <ChatWindowHeader
+ class="o_ChatWindowHiddenMenu_chatWindowHeader"
+ chatWindowLocalId="chatWindow.localId"
+ t-on-o-clicked="_onClickedChatWindow"
+ />
+ </li>
+ </t>
+ </ul>
+ <t t-if="env.messaging.chatWindowManager.unreadHiddenConversationAmount > 0">
+ <div class="badge badge-pill o_ChatWindowHiddenMenu_unreadCounter">
+ <t t-esc="env.messaging.chatWindowManager.unreadHiddenConversationAmount"/>
+ </div>
+ </t>
+ </div>
+ </t>
+
+</templates>
diff --git a/addons/mail/static/src/components/chat_window_manager/chat_window_manager.js b/addons/mail/static/src/components/chat_window_manager/chat_window_manager.js
new file mode 100644
index 00000000000..956c6811e68
--- /dev/null
+++ b/addons/mail/static/src/components/chat_window_manager/chat_window_manager.js
@@ -0,0 +1,37 @@
+odoo.define('mail/static/src/components/chat_window_manager/chat_window_manager.js', function (require) {
+'use strict';
+
+const components = {
+ ChatWindow: require('mail/static/src/components/chat_window/chat_window.js'),
+ ChatWindowHiddenMenu: require('mail/static/src/components/chat_window_hidden_menu/chat_window_hidden_menu.js'),
+};
+const useStore = require('mail/static/src/component_hooks/use_store/use_store.js');
+
+const { Component } = owl;
+
+class ChatWindowManager extends Component {
+
+ /**
+ * @override
+ */
+ constructor(...args) {
+ super(...args);
+ useStore(props => {
+ return {
+ chatWindowManagerVisual: this.env.messaging.chatWindowManager.visual,
+ device: this.env.messaging.device,
+ };
+ });
+ }
+
+}
+
+Object.assign(ChatWindowManager, {
+ components,
+ props: {},
+ template: 'mail.ChatWindowManager',
+});
+
+return ChatWindowManager;
+
+});
diff --git a/addons/mail/static/src/components/chat_window_manager/chat_window_manager.scss b/addons/mail/static/src/components/chat_window_manager/chat_window_manager.scss
new file mode 100644
index 00000000000..1e8abf54184
--- /dev/null
+++ b/addons/mail/static/src/components/chat_window_manager/chat_window_manager.scss
@@ -0,0 +1,16 @@
+// ------------------------------------------------------------------
+// Layout
+// ------------------------------------------------------------------
+
+.o_ChatWindowManager {
+ bottom: 0;
+ right: 0;
+ display: flex;
+ flex-direction: row-reverse;
+ z-index: 1000;
+}
+
+// ------------------------------------------------------------------
+// Style
+// ------------------------------------------------------------------
+
diff --git a/addons/mail/static/src/components/chat_window_manager/chat_window_manager.xml b/addons/mail/static/src/components/chat_window_manager/chat_window_manager.xml
new file mode 100644
index 00000000000..3ced7210da1
--- /dev/null
+++ b/addons/mail/static/src/components/chat_window_manager/chat_window_manager.xml
@@ -0,0 +1,21 @@
+<?xml version="1.0" encoding="UTF-8"?>
+<templates xml:space="preserve">
+
+ <t t-name="mail.ChatWindowManager" owl="1">
+ <div class="o_ChatWindowManager">
+ <!-- Note: DOM elements are ordered from left to right -->
+ <t t-if="env.messaging.chatWindowManager.hasHiddenChatWindows">
+ <ChatWindowHiddenMenu class="o_ChatWindowManager_hiddenMenu"/>
+ </t>
+ <t t-foreach="env.messaging.chatWindowManager.allOrderedVisible" t-as="chatWindow" t-key="chatWindow.localId">
+ <ChatWindow
+ chatWindowLocalId="chatWindow.localId"
+ hasCloseAsBackButton="env.messaging.device.isMobile"
+ isExpandable="!env.messaging.device.isMobile and !!chatWindow.thread"
+ isFullscreen="env.messaging.device.isMobile"
+ />
+ </t>
+ </div>
+ </t>
+
+</templates>
diff --git a/addons/mail/static/src/components/chat_window_manager/chat_window_manager_tests.js b/addons/mail/static/src/components/chat_window_manager/chat_window_manager_tests.js
new file mode 100644
index 00000000000..f987a40db46
--- /dev/null
+++ b/addons/mail/static/src/components/chat_window_manager/chat_window_manager_tests.js
@@ -0,0 +1,1243 @@
+odoo.define('mail/static/src/components/chat_window_manager/chat_window_manager_tests.js', function (require) {
+'use strict';
+
+const {
+ afterEach: utilsAfterEach,
+ afterNextRender,
+ beforeEach: utilsBeforeEach,
+ inputFiles,
+ start: utilsStart,
+} = require('mail/static/src/utils/test_utils.js');
+
+const {
+ file: { createFile },
+ dom: { triggerEvent },
+} = require('web.test_utils');
+
+QUnit.module('mail', {}, function () {
+QUnit.module('components', {}, function () {
+QUnit.module('chat_window_manager', {}, function () {
+QUnit.module('chat_window_manager_tests.js', {
+ beforeEach() {
+ utilsBeforeEach(this);
+
+ this.start = async params => {
+ let { env, widget } = await utilsStart(Object.assign({
+ hasChatWindow: true,
+ hasMessagingMenu: true,
+ }, params, {
+ data: this.data,
+ }));
+ this.env = env;
+ this.widget = widget;
+ };
+ },
+ afterEach() {
+ utilsAfterEach(this);
+ if (this.widget) {
+ this.widget.destroy();
+ }
+ },
+});
+
+QUnit.test('initial mount', async function (assert) {
+ assert.expect(1);
+
+ await this.start();
+ assert.strictEqual(document.querySelectorAll('.o_ChatWindowManager').length,
+ 1,
+ "should have chat window manager");
+});
+
+QUnit.test('chat window new message: basic rendering', async function (assert) {
+ assert.expect(10);
+
+ await this.start({
+ async mockRPC(route, args) {
+ if (args.method === 'channel_fetch_preview') {
+ return [];
+ }
+ return this._super(...arguments);
+ },
+ });
+ await afterNextRender(() =>
+ document.querySelector(`.o_MessagingMenu_toggler`).click()
+ );
+ await afterNextRender(() =>
+ document.querySelector(`.o_MessagingMenu_newMessageButton`).click()
+ );
+ assert.strictEqual(
+ document.querySelectorAll(`.o_ChatWindow`).length,
+ 1,
+ "should have open a chat window"
+ );
+ assert.strictEqual(
+ document.querySelectorAll(`.o_ChatWindow_header`).length,
+ 1,
+ "should have a header"
+ );
+ assert.strictEqual(
+ document.querySelectorAll(`.o_ChatWindow_header .o_ChatWindowHeader_name`).length,
+ 1,
+ "should have name part in header"
+ );
+ assert.strictEqual(
+ document.querySelector(`.o_ChatWindow_header .o_ChatWindowHeader_name`).textContent,
+ "New message",
+ "should display 'new message' in the header"
+ );
+ assert.strictEqual(
+ document.querySelectorAll(`.o_ChatWindow_header .o_ChatWindowHeader_command`).length,
+ 1,
+ "should have 1 command in header"
+ );
+ assert.strictEqual(
+ document.querySelectorAll(`.o_ChatWindow_header .o_ChatWindowHeader_commandClose`).length,
+ 1,
+ "should have command to close chat window"
+ );
+ assert.strictEqual(
+ document.querySelectorAll(`.o_ChatWindow_newMessageForm`).length,
+ 1,
+ "should have a new message chat window container"
+ );
+ assert.strictEqual(
+ document.querySelectorAll(`.o_ChatWindow_newMessageFormLabel`).length,
+ 1,
+ "should have a part in selection with label"
+ );
+ assert.strictEqual(
+ document.querySelector(`.o_ChatWindow_newMessageFormLabel`).textContent.trim(),
+ "To:",
+ "should have label 'To:' in selection"
+ );
+ assert.strictEqual(
+ document.querySelectorAll(`.o_ChatWindow_newMessageFormInput`).length,
+ 1,
+ "should have an input in selection"
+ );
+});
+
+QUnit.test('chat window new message: focused on open', async function (assert) {
+ assert.expect(2);
+
+ await this.start({
+ async mockRPC(route, args) {
+ if (args.method === 'channel_fetch_preview') {
+ return [];
+ }
+ return this._super(...arguments);
+ },
+ });
+ await afterNextRender(() =>
+ document.querySelector(`.o_MessagingMenu_toggler`).click()
+ );
+ await afterNextRender(() =>
+ document.querySelector(`.o_MessagingMenu_newMessageButton`).click()
+ );
+ assert.ok(
+ document.querySelector(`.o_ChatWindow`).classList.contains('o-focused'),
+ "chat window should be focused"
+ );
+ assert.ok(
+ document.activeElement,
+ document.querySelector(`.o_ChatWindow_newMessageFormInput`),
+ "chat window focused = selection input focused"
+ );
+});
+
+QUnit.test('chat window new message: close', async function (assert) {
+ assert.expect(1);
+
+ await this.start({
+ async mockRPC(route, args) {
+ if (args.method === 'channel_fetch_preview') {
+ return [];
+ }
+ return this._super(...arguments);
+ },
+ });
+ await afterNextRender(() =>
+ document.querySelector(`.o_MessagingMenu_toggler`).click()
+ );
+ await afterNextRender(() =>
+ document.querySelector(`.o_MessagingMenu_newMessageButton`).click()
+ );
+ await afterNextRender(() =>
+ document.querySelector(`.o_ChatWindow_header .o_ChatWindowHeader_commandClose`).click()
+ );
+ assert.strictEqual(
+ document.querySelectorAll(`.o_ChatWindow`).length,
+ 0,
+ "chat window should be closed"
+ );
+});
+
+QUnit.test('chat window new message: fold', async function (assert) {
+ assert.expect(3);
+
+ await this.start({
+ async mockRPC(route, args) {
+ if (args.method === 'channel_fetch_preview') {
+ return [];
+ }
+ return this._super(...arguments);
+ },
+ });
+ await afterNextRender(() => document.querySelector(`.o_MessagingMenu_toggler`).click());
+ await afterNextRender(() =>
+ document.querySelector(`.o_MessagingMenu_newMessageButton`).click()
+ );
+ assert.notOk(
+ document.querySelector(`.o_ChatWindow`).classList.contains('o-folded'),
+ "chat window should not be folded by default"
+ );
+
+ await afterNextRender(() => document.querySelector(`.o_ChatWindow_header`).click());
+ assert.ok(
+ document.querySelector(`.o_ChatWindow`).classList.contains('o-folded'),
+ "chat window should become folded"
+ );
+
+ await afterNextRender(() => document.querySelector(`.o_ChatWindow_header`).click());
+ assert.notOk(
+ document.querySelector(`.o_ChatWindow`).classList.contains('o-folded'),
+ "chat window should become unfolded"
+ );
+});
+
+QUnit.test('chat window: basic rendering', async function (assert) {
+ assert.expect(11);
+
+ Object.assign(this.data.initMessaging, {
+ channel_slots: {
+ channel_channel: [{
+ channel_type: "channel",
+ id: 20,
+ name: "General",
+ }],
+ },
+ });
+ await this.start({
+ async mockRPC(route, args) {
+ if (args.method === 'channel_fetch_preview') {
+ return [{
+ id: 20,
+ last_message: {
+ author_id: [7, "Demo"],
+ body: "<p>test</p>",
+ channel_ids: [20],
+ id: 100,
+ message_type: 'comment',
+ model: 'mail.channel',
+ res_id: 20,
+ },
+ }];
+ }
+ return this._super(...arguments);
+ },
+ });
+ await afterNextRender(() =>
+ document.querySelector(`.o_MessagingMenu_toggler`).click()
+ );
+ await afterNextRender(() =>
+ document.querySelector(`.o_NotificationList_preview`).click()
+ );
+ assert.strictEqual(
+ document.querySelectorAll(`.o_ChatWindow`).length,
+ 1,
+ "should have open a chat window"
+ );
+ const chatWindow = document.querySelector(`.o_ChatWindow`);
+ assert.strictEqual(
+ chatWindow.dataset.threadLocalId,
+ this.env.models['mail.thread'].find(thread =>
+ thread.id === 20 &&
+ thread.model === 'mail.channel'
+ ).localId,
+ "should have open a chat window of channel"
+ );
+ assert.strictEqual(
+ chatWindow.querySelectorAll(`:scope .o_ChatWindow_header`).length,
+ 1,
+ "should have header part"
+ );
+ const chatWindowHeader = chatWindow.querySelector(`:scope .o_ChatWindow_header`);
+ assert.strictEqual(
+ chatWindowHeader.querySelectorAll(`:scope .o_ThreadIcon`).length,
+ 1,
+ "should have thread icon in header part"
+ );
+ assert.strictEqual(
+ chatWindowHeader.querySelectorAll(`:scope .o_ChatWindowHeader_name`).length,
+ 1,
+ "should have thread name in header part"
+ );
+ assert.strictEqual(
+ chatWindowHeader.querySelector(`:scope .o_ChatWindowHeader_name`).textContent,
+ "General",
+ "should have correct thread name in header part"
+ );
+ assert.strictEqual(
+ chatWindowHeader.querySelectorAll(`:scope .o_ChatWindowHeader_command`).length,
+ 2,
+ "should have 2 commands in header part"
+ );
+ assert.strictEqual(
+ chatWindowHeader.querySelectorAll(`:scope .o_ChatWindowHeader_commandExpand`).length,
+ 1,
+ "should have command to expand thread in discuss"
+ );
+ assert.strictEqual(
+ chatWindowHeader.querySelectorAll(`:scope .o_ChatWindowHeader_commandClose`).length,
+ 1,
+ "should have command to close chat window"
+ );
+ assert.strictEqual(
+ chatWindow.querySelectorAll(`:scope .o_ChatWindow_thread`).length,
+ 1,
+ "should have part to display thread content inside chat window"
+ );
+ assert.ok(
+ chatWindow.querySelector(`:scope .o_ChatWindow_thread`).classList.contains('o_ThreadViewer'),
+ "thread viewer part should use component ThreadViewer"
+ );
+});
+
+QUnit.test('chat window: fold', async function (assert) {
+ assert.expect(24);
+
+ let foldCall = 0;
+ Object.assign(this.data.initMessaging, {
+ channel_slots: {
+ channel_channel: [{
+ channel_type: 'channel',
+ id: 20,
+ is_minimized: false,
+ name: "General",
+ state: 'open',
+ uuid: 'channel-20-uuid',
+ }],
+ },
+ });
+ await this.start({
+ async mockRPC(route, args) {
+ if (args.method === 'channel_fetch_preview') {
+ return [{
+ id: 20,
+ last_message: {
+ author_id: [7, "Demo"],
+ body: "<p>test</p>",
+ channel_ids: [20],
+ id: 100,
+ message_type: 'comment',
+ model: 'mail.channel',
+ res_id: 20,
+ },
+ }];
+ } else if (args.method === 'channel_fold') {
+ assert.step(`rpc:${args.method}/${args.kwargs.state}`);
+ foldCall++;
+ const kwargsKeys = Object.keys(args.kwargs);
+ assert.strictEqual(
+ args.args.length,
+ 0,
+ "channel_fold call have no args"
+ );
+ assert.strictEqual(
+ kwargsKeys.length,
+ 2,
+ "channel_fold call have exactly 2 kwargs"
+ );
+ assert.ok(
+ kwargsKeys.includes('state'),
+ "channel_fold call have 'state' kwargs"
+ );
+ assert.ok(
+ kwargsKeys.includes('uuid'),
+ "channel_fold call have 'uuid' kwargs"
+ );
+ assert.strictEqual(
+ args.kwargs.uuid,
+ 'channel-20-uuid',
+ "channel_fold call uuid is from channel 20"
+ );
+ if (foldCall % 2 === 0) {
+ assert.strictEqual(
+ args.kwargs.state,
+ 'folded',
+ "channel_fold call state is 'folded'"
+ );
+ } else {
+ assert.strictEqual(
+ args.kwargs.state,
+ 'open',
+ "channel_fold call state is 'open'"
+ );
+ }
+ return [];
+ }
+ return this._super(...arguments);
+ },
+ });
+ // Open Thread
+ await afterNextRender(() => document.querySelector(`.o_MessagingMenu_toggler`).click());
+ await afterNextRender(() =>
+ document.querySelector(`.o_MessagingMenu_dropdownMenu .o_NotificationList_preview`).click()
+ );
+ assert.verifySteps(['rpc:channel_fold/open']);
+ // Fold chat window
+ await afterNextRender(() => document.querySelector(`.o_ChatWindow_header`).click());
+ assert.verifySteps(['rpc:channel_fold/folded']);
+ // Unfold chat window
+ await afterNextRender(() => document.querySelector(`.o_ChatWindow_header`).click());
+ assert.verifySteps(['rpc:channel_fold/open']);
+});
+
+QUnit.test('chat window: open / close', async function (assert) {
+ assert.expect(24);
+
+ let foldCall = 0;
+ Object.assign(this.data.initMessaging, {
+ channel_slots: {
+ channel_channel: [{
+ channel_type: 'channel',
+ id: 20,
+ is_minimized: false,
+ name: "General",
+ state: 'open',
+ uuid: 'channel-20-uuid',
+ }],
+ },
+ });
+ await this.start({
+ async mockRPC(route, args) {
+ if (args.method === 'channel_fetch_preview') {
+ return [{
+ id: 20,
+ last_message: {
+ author_id: [7, "Demo"],
+ body: "<p>test</p>",
+ channel_ids: [20],
+ id: 100,
+ message_type: 'comment',
+ model: 'mail.channel',
+ res_id: 20,
+ },
+ }];
+ } else if (args.method === 'channel_fold') {
+ assert.step(`rpc:channel_fold/${args.kwargs.state}`);
+ foldCall++;
+ const kwargsKeys = Object.keys(args.kwargs);
+ assert.strictEqual(
+ args.args.length,
+ 0,
+ "channel_fold call have no args"
+ );
+ assert.strictEqual(
+ kwargsKeys.length,
+ 2,
+ "channel_fold call have exactly 2 kwargs"
+ );
+ assert.ok(
+ kwargsKeys.includes('state'),
+ "channel_fold call have 'state' kwargs"
+ );
+ assert.ok(
+ kwargsKeys.includes('uuid'),
+ "channel_fold call have 'uuid' kwargs"
+ );
+ assert.strictEqual(
+ args.kwargs.uuid,
+ 'channel-20-uuid',
+ "channel_fold call uuid should be correct"
+ );
+ if (foldCall % 2 === 0) {
+ assert.strictEqual(
+ args.kwargs.state,
+ 'closed',
+ "channel_fold call state is 'closed'"
+ );
+ } else {
+ assert.strictEqual(
+ args.kwargs.state,
+ 'open',
+ "channel_fold call state is 'open'"
+ );
+ }
+ return [];
+ }
+ return this._super(...arguments);
+ },
+ });
+ await afterNextRender(() => document.querySelector(`.o_MessagingMenu_toggler`).click());
+ await afterNextRender(() =>
+ document.querySelector(`.o_MessagingMenu_dropdownMenu .o_NotificationList_preview`).click()
+ );
+ assert.verifySteps(['rpc:channel_fold/open']);
+
+ // Close chat window
+ await afterNextRender(() => document.querySelector(`.o_ChatWindowHeader_commandClose`).click());
+ assert.verifySteps(['rpc:channel_fold/closed']);
+
+ // Reopen chat window
+ await afterNextRender(() => document.querySelector(`.o_MessagingMenu_toggler`).click());
+ await afterNextRender(() =>
+ document.querySelector(`.o_MessagingMenu_dropdownMenu .o_NotificationList_preview`).click()
+ );
+ assert.verifySteps(['rpc:channel_fold/open']);
+});
+
+QUnit.test('chat window: close on ESCAPE', async function (assert) {
+ assert.expect(4);
+
+ Object.assign(this.data.initMessaging, {
+ channel_slots: {
+ channel_channel: [{
+ channel_type: 'channel',
+ id: 20,
+ is_minimized: false,
+ name: "General",
+ state: 'open',
+ uuid: 'channel-20-uuid',
+ }],
+ },
+ });
+ await this.start({
+ async mockRPC(route, args) {
+ if (args.method === 'channel_fetch_preview') {
+ return [{
+ id: 20,
+ last_message: {
+ author_id: [7, "Demo"],
+ body: "<p>test</p>",
+ channel_ids: [20],
+ id: 100,
+ message_type: 'comment',
+ model: 'mail.channel',
+ res_id: 20,
+ },
+ }];
+ } else if (args.method === 'channel_fold') {
+ assert.step(`rpc:channel_fold/${args.kwargs.state}`);
+ return [];
+ }
+ return this._super(...arguments);
+ },
+ });
+ await afterNextRender(() => document.querySelector(`.o_MessagingMenu_toggler`).click());
+ await afterNextRender(() =>
+ document.querySelector(`.o_MessagingMenu_dropdownMenu .o_NotificationList_preview`).click()
+ );
+ assert.verifySteps(['rpc:channel_fold/open']);
+
+ await afterNextRender(() => {
+ document.querySelector(`.o_ChatWindow`).focus();
+ const kevt = new window.KeyboardEvent('keydown', { bubbles: true, key: "Escape" });
+ document.querySelector(`.o_ChatWindow`).dispatchEvent(kevt);
+ });
+ assert.verifySteps(['rpc:channel_fold/closed']);
+});
+
+QUnit.test('chat window: state conservation on toggle home menu', async function (assert) {
+ assert.expect(9);
+
+ Object.assign(this.data.initMessaging, {
+ channel_slots: {
+ channel_channel: [{
+ channel_type: 'channel',
+ id: 20,
+ is_minimized: false,
+ name: "General",
+ state: 'open',
+ uuid: 'channel-20-uuid',
+ }],
+ },
+ });
+ await this.start({
+ async mockRPC(route, args) {
+ if (args.method === 'channel_fetch_preview') {
+ return [{
+ id: 20,
+ last_message: {
+ author_id: [7, "Demo"],
+ body: "<p>test</p>",
+ channel_ids: [20],
+ id: 100,
+ message_type: 'comment',
+ model: 'mail.channel',
+ res_id: 20,
+ },
+ }];
+ } else if (args.method === 'channel_fold') {
+ return;
+ } else if (args.method === 'message_fetch') {
+ return [...Array(20).keys()].map(i => {
+ return {
+ author_id: [11, "Demo"],
+ body: "<p>body</p>",
+ channel_ids: [20],
+ date: "2019-04-20 10:00:00",
+ id: i + 10,
+ message_type: 'comment',
+ model: 'mail.channel',
+ record_name: 'General',
+ res_id: i + 1,
+ };
+ });
+ }
+ return this._super(...arguments);
+ },
+ });
+ await afterNextRender(() => document.querySelector(`.o_MessagingMenu_toggler`).click());
+ await afterNextRender(() =>
+ document.querySelector(`.o_MessagingMenu_dropdownMenu .o_NotificationList_preview`).click()
+ );
+ // Set a scroll position to chat window
+ document.querySelector(`.o_ThreadViewer_messageList`).scrollTop = 142;
+ // Set html content of the composer of the chat window
+ let composerTextInputTextArea = document.querySelector(`.o_ComposerTextInput_textarea`);
+ composerTextInputTextArea.focus();
+ document.execCommand('insertText', false, 'XDU for the win !');
+ // Set attachments of the composer
+ const files = [
+ await createFile({
+ name: 'text state conservation on toggle home menu.txt',
+ content: 'hello, world',
+ contentType: 'text/plain',
+ }),
+ await createFile({
+ name: 'text2 state conservation on toggle home menu.txt',
+ content: 'hello, xdu is da best man',
+ contentType: 'text/plain',
+ })
+ ];
+ await afterNextRender(() =>
+ inputFiles(
+ document.querySelector('.o_FileUploader_input'),
+ files
+ )
+ );
+ assert.strictEqual(
+ document.querySelector(`.o_ThreadViewer_messageList`).scrollTop,
+ 142,
+ "verify chat window initial scrollTop"
+ );
+ composerTextInputTextArea = document.querySelector(`.o_ComposerTextInput_textarea`);
+ assert.strictEqual(
+ composerTextInputTextArea.value,
+ "XDU for the win !",
+ "verifif chat window composer initial html input"
+ );
+ assert.strictEqual(
+ document.querySelectorAll(`.o_Composer .o_Attachment`).length,
+ 2,
+ "verify chat window composer initial attachment count"
+ );
+ // Hide home menu
+ await this.env.bus.trigger('will_hide_home_menu');
+ await this.env.bus.trigger('hide_home_menu');
+ assert.strictEqual(
+ document.querySelector(`.o_ThreadViewer_messageList`).scrollTop,
+ 142,
+ "chat window scrollTop should still be the same (1)"
+ );
+ composerTextInputTextArea = document.querySelector(`.o_ComposerTextInput_textarea`);
+ assert.strictEqual(
+ composerTextInputTextArea.value,
+ "XDU for the win !",
+ "Chat window composer should still have the same html input (1)"
+ );
+ assert.strictEqual(
+ document.querySelectorAll(`.o_Composer .o_Attachment`).length,
+ 2,
+ "Chat window composer should have 2 attachments (1)"
+ );
+
+ // Show home menu
+ await this.env.bus.trigger('will_show_home_menu');
+ await this.env.bus.trigger('show_home_menu');
+ assert.strictEqual(
+ document.querySelector(`.o_ThreadViewer_messageList`).scrollTop,
+ 142,
+ "chat window scrollTop should still be the same (2)"
+ );
+ composerTextInputTextArea = document.querySelector(`.o_ComposerTextInput_textarea`);
+ assert.strictEqual(
+ composerTextInputTextArea.value,
+ "XDU for the win !",
+ "chat window composer should still have the same html input (2)"
+ );
+ assert.strictEqual(
+ document.querySelectorAll(`.o_Composer .o_Attachment`).length,
+ 2,
+ "Chat window composer should have 2 attachments (2)"
+ );
+});
+
+QUnit.test('open 2 different chat windows: enough screen width [REQUIRE FOCUS]', async function (assert) {
+ /**
+ * computation uses following info:
+ * ([mocked] global window width: @see `mail/static/src/utils/test_utils.js:start()` method)
+ * (others: @see mail/static/src/models/chat_window_manager/chat_window_manager.js:visual)
+ *
+ * - chat window width: 325px
+ * - start/end/between gap width: 10px/10px/5px
+ * - hidden menu width: 200px
+ * - global width: 1920px
+ *
+ * Enough space for 2 visible chat windows:
+ * 10 + 325 + 5 + 325 + 10 = 670 < 1920
+ */
+ assert.expect(8);
+
+ Object.assign(this.data.initMessaging, {
+ channel_slots: {
+ channel_channel: [{
+ channel_type: "channel",
+ id: 20,
+ name: "General",
+ }],
+ channel_direct_message: [{
+ channel_type: "chat",
+ direct_partner: [{
+ id: 7,
+ name: "Demo",
+ }],
+ id: 10,
+ }],
+ },
+ });
+ await this.start({
+ env: {
+ browser: {
+ innerHeight: 1080,
+ innerWidth: 1920,
+ },
+ },
+ async mockRPC(route, args) {
+ if (args.method === 'channel_fetch_preview') {
+ return [{
+ id: 20,
+ last_message: {
+ author_id: [7, "Demo"],
+ body: "<p>test</p>",
+ channel_ids: [20],
+ id: 100,
+ message_type: 'comment',
+ model: 'mail.channel',
+ res_id: 20,
+ },
+ }, {
+ id: 10,
+ last_message: {
+ author_id: [7, "Demo"],
+ body: "<p>test2</p>",
+ channel_ids: [10],
+ id: 101,
+ message_type: 'comment',
+ model: 'mail.channel',
+ res_id: 10,
+ },
+ }];
+ }
+ return this._super(...arguments);
+ },
+ });
+ await afterNextRender(() => document.querySelector(`.o_MessagingMenu_toggler`).click());
+ await afterNextRender(() =>
+ document.querySelector(`
+ .o_MessagingMenu_dropdownMenu
+ .o_NotificationList_preview[data-thread-local-id="${
+ this.env.models['mail.thread'].find(thread =>
+ thread.id === 10 &&
+ thread.model === 'mail.channel'
+ ).localId
+ }"]
+ `).click()
+ );
+ assert.strictEqual(
+ document.querySelectorAll(`.o_ChatWindow`).length,
+ 1,
+ "should have open a chat window"
+ );
+ assert.strictEqual(
+ document.querySelectorAll(`
+ .o_ChatWindow[data-thread-local-id="${
+ this.env.models['mail.thread'].find(thread =>
+ thread.id === 10 &&
+ thread.model === 'mail.channel'
+ ).localId
+ }"]
+ `).length,
+ 1,
+ "chat window of chat should be open"
+ );
+ assert.ok(
+ document.querySelector(`
+ .o_ChatWindow[data-thread-local-id="${
+ this.env.models['mail.thread'].find(thread =>
+ thread.id === 10 &&
+ thread.model === 'mail.channel'
+ ).localId
+ }"]
+ `).classList.contains('o-focused'),
+ "chat window of chat should have focus"
+ );
+
+ await afterNextRender(() => document.querySelector(`.o_MessagingMenu_toggler`).click());
+ await afterNextRender(() =>
+ document.querySelector(`
+ .o_MessagingMenu_dropdownMenu
+ .o_NotificationList_preview[data-thread-local-id="${
+ this.env.models['mail.thread'].find(thread =>
+ thread.id === 20 &&
+ thread.model === 'mail.channel'
+ ).localId
+ }"]
+ `).click()
+ );
+ assert.strictEqual(
+ document.querySelectorAll(`.o_ChatWindow`).length,
+ 2,
+ "should have open a new chat window"
+ );
+ assert.strictEqual(
+ document.querySelectorAll(`
+ .o_ChatWindow[data-thread-local-id="${
+ this.env.models['mail.thread'].find(thread =>
+ thread.id === 20 &&
+ thread.model === 'mail.channel'
+ ).localId
+ }"]
+ `).length,
+ 1,
+ "chat window of channel should be open"
+ );
+ assert.strictEqual(
+ document.querySelectorAll(`
+ .o_ChatWindow[data-thread-local-id="${
+ this.env.models['mail.thread'].find(thread =>
+ thread.id === 10 &&
+ thread.model === 'mail.channel'
+ ).localId
+ }"]
+ `).length,
+ 1,
+ "chat window of chat should still be open"
+ );
+ assert.ok(
+ document.querySelector(`
+ .o_ChatWindow[data-thread-local-id="${
+ this.env.models['mail.thread'].find(thread =>
+ thread.id === 20 &&
+ thread.model === 'mail.channel'
+ ).localId
+ }"]
+ `).classList.contains('o-focused'),
+ "chat window of channel should have focus"
+ );
+ assert.notOk(
+ document.querySelector(`
+ .o_ChatWindow[data-thread-local-id="${
+ this.env.models['mail.thread'].find(thread =>
+ thread.id === 10 &&
+ thread.model === 'mail.channel'
+ ).localId
+ }"]
+ `).classList.contains('o-focused'),
+ "chat window of chat should no longer have focus"
+ );
+});
+
+QUnit.test('open 2 chat windows: check shift operations are available', async function (assert) {
+ assert.expect(9);
+
+ const channel = {
+ channel_type: "channel",
+ id: 20,
+ name: "General",
+ };
+ const chat = {
+ channel_type: "chat",
+ direct_partner: [{
+ id: 7,
+ name: "Demo",
+ }],
+ id: 10,
+ };
+ this.data['mail.channel'].records = [channel, chat];
+ Object.assign(this.data.initMessaging, {
+ channel_slots: {
+ channel_channel: [channel],
+ channel_direct_message: [chat],
+ },
+ });
+ await this.start();
+ await afterNextRender(() => {
+ document.querySelector('.o_MessagingMenu_toggler').click();
+ });
+ await afterNextRender(() => {
+ document.querySelectorAll('.o_MessagingMenu_dropdownMenu .o_NotificationList_preview')[0].click();
+ });
+ await afterNextRender(() => {
+ document.querySelector('.o_MessagingMenu_toggler').click();
+ });
+ await afterNextRender(() => {
+ document.querySelectorAll('.o_MessagingMenu_dropdownMenu .o_NotificationList_preview')[1].click();
+ });
+
+ assert.containsN(
+ document.body,
+ '.o_ChatWindow',
+ 2,
+ "should have opened 2 chat windows"
+ );
+ assert.containsOnce(
+ document.querySelectorAll('.o_ChatWindow')[0],
+ '.o_ChatWindowHeader_commandShiftLeft',
+ "first chat window should be allowed to shift left"
+ );
+ assert.containsNone(
+ document.querySelectorAll('.o_ChatWindow')[0],
+ '.o_ChatWindowHeader_commandShiftRight',
+ "first chat window should not be allowed to shift right"
+ );
+ assert.containsNone(
+ document.querySelectorAll('.o_ChatWindow')[1],
+ '.o_ChatWindowHeader_commandShiftLeft',
+ "second chat window should not be allowed to shift left"
+ );
+ assert.containsOnce(
+ document.querySelectorAll('.o_ChatWindow')[1],
+ '.o_ChatWindowHeader_commandShiftRight',
+ "second chat window should be allowed to shift right"
+ );
+
+ const initialFirstChatWindowThreadLocalId =
+ document.querySelectorAll('.o_ChatWindow')[0].dataset.threadLocalId;
+ const initialSecondChatWindowThreadLocalId =
+ document.querySelectorAll('.o_ChatWindow')[1].dataset.threadLocalId;
+ await afterNextRender(() => {
+ document.querySelectorAll('.o_ChatWindow')[0]
+ .querySelector(':scope .o_ChatWindowHeader_commandShiftLeft')
+ .click();
+ });
+ assert.strictEqual(
+ document.querySelectorAll('.o_ChatWindow')[0].dataset.threadLocalId,
+ initialSecondChatWindowThreadLocalId,
+ "First chat window should be second after it has been shift left"
+ );
+ assert.strictEqual(
+ document.querySelectorAll('.o_ChatWindow')[1].dataset.threadLocalId,
+ initialFirstChatWindowThreadLocalId,
+ "Second chat window should be first after the first has been shifted left"
+ );
+
+ await afterNextRender(() => {
+ document.querySelectorAll('.o_ChatWindow')[1]
+ .querySelector(':scope .o_ChatWindowHeader_commandShiftRight')
+ .click();
+ });
+ assert.strictEqual(
+ document.querySelectorAll('.o_ChatWindow')[0].dataset.threadLocalId,
+ initialFirstChatWindowThreadLocalId,
+ "First chat window should be back at first place after being shifted left then right"
+ );
+ assert.strictEqual(
+ document.querySelectorAll('.o_ChatWindow')[1].dataset.threadLocalId,
+ initialSecondChatWindowThreadLocalId,
+ "Second chat window should be back at second place after first one has been shifted left then right"
+ );
+});
+
+QUnit.test('open 3 different chat windows: not enough screen width', async function (assert) {
+ /**
+ * computation uses following info:
+ * ([mocked] global window width: 900px)
+ * (others: @see `mail/static/src/models/chat_window_manager/chat_window_manager.js:visual`)
+ *
+ * - chat window width: 325px
+ * - start/end/between gap width: 10px/10px/5px
+ * - hidden menu width: 200px
+ * - global width: 1080px
+ *
+ * Enough space for 2 visible chat windows, and one hidden chat window:
+ * 3 visible chat windows:
+ * 10 + 325 + 5 + 325 + 5 + 325 + 10 = 1000 < 900
+ * 2 visible chat windows + hidden menu:
+ * 10 + 325 + 5 + 325 + 10 + 200 + 5 = 875 < 900
+ */
+ assert.expect(12);
+
+ Object.assign(this.data.initMessaging, {
+ channel_slots: {
+ channel_channel: [{
+ channel_type: "channel",
+ id: 1,
+ name: "channel1",
+ }, {
+ channel_type: "channel",
+ id: 2,
+ name: "channel2",
+ }, {
+ channel_type: "channel",
+ id: 3,
+ name: "channel3",
+ }],
+ },
+ });
+ await this.start({
+ env: {
+ browser: {
+ innerHeight: 900,
+ innerWidth: 900,
+ },
+ },
+ async mockRPC(route, args) {
+ if (args.method === 'channel_fetch_preview') {
+ return [];
+ }
+ return this._super(...arguments);
+ },
+ });
+
+ // open, from systray menu, chat windows of channels with Id 1, 2, then 3
+ await afterNextRender(() =>
+ document.querySelector(`.o_MessagingMenu_toggler`).click()
+ );
+ await afterNextRender(() =>
+ document.querySelector(`
+ .o_MessagingMenu_dropdownMenu
+ .o_NotificationList_preview[data-thread-local-id="${
+ this.env.models['mail.thread'].find(thread =>
+ thread.id === 1 &&
+ thread.model === 'mail.channel'
+ ).localId
+ }"]
+ `).click()
+ );
+ assert.strictEqual(
+ document.querySelectorAll(`.o_ChatWindow`).length,
+ 1,
+ "should have open 1 visible chat window"
+ );
+ assert.strictEqual(
+ document.querySelectorAll(`.o_ChatWindowManager_hiddenMenu`).length,
+ 0,
+ "should not have hidden menu"
+ );
+ assert.strictEqual(
+ document.querySelectorAll(`.o_MessagingMenu_dropdownMenu`).length,
+ 0,
+ "messaging menu should be hidden"
+ );
+
+ await afterNextRender(() =>
+ document.querySelector(`.o_MessagingMenu_toggler`).click()
+ );
+ await afterNextRender(() =>
+ document.querySelector(`
+ .o_MessagingMenu_dropdownMenu
+ .o_NotificationList_preview[data-thread-local-id="${
+ this.env.models['mail.thread'].find(thread =>
+ thread.id === 2 &&
+ thread.model === 'mail.channel'
+ ).localId
+ }"]
+ `).click()
+ );
+ assert.strictEqual(
+ document.querySelectorAll(`.o_ChatWindow`).length,
+ 2,
+ "should have open 2 visible chat windows"
+ );
+ assert.strictEqual(
+ document.querySelectorAll(`.o_ChatWindowManager_hiddenMenu`).length,
+ 0,
+ "should not have hidden menu"
+ );
+ assert.strictEqual(
+ document.querySelectorAll(`.o_MessagingMenu_dropdownMenu`).length,
+ 0,
+ "messaging menu should be hidden"
+ );
+
+ await afterNextRender(() =>
+ document.querySelector(`.o_MessagingMenu_toggler`).click()
+ );
+ await afterNextRender(() =>
+ document.querySelector(`
+ .o_MessagingMenu_dropdownMenu
+ .o_NotificationList_preview[data-thread-local-id="${
+ this.env.models['mail.thread'].find(thread =>
+ thread.id === 3 &&
+ thread.model === 'mail.channel'
+ ).localId
+ }"]
+ `).click()
+ );
+ assert.strictEqual(
+ document.querySelectorAll(`.o_ChatWindow`).length,
+ 2,
+ "should have open 2 visible chat windows"
+ );
+ assert.strictEqual(
+ document.querySelectorAll(`.o_ChatWindowManager_hiddenMenu`).length,
+ 1,
+ "should have hidden menu"
+ );
+ assert.strictEqual(
+ document.querySelectorAll(`.o_MessagingMenu_dropdownMenu`).length,
+ 0,
+ "messaging menu should be hidden"
+ );
+ assert.strictEqual(
+ document.querySelectorAll(`
+ .o_ChatWindow[data-thread-local-id="${
+ this.env.models['mail.thread'].find(thread =>
+ thread.id === 1 &&
+ thread.model === 'mail.channel'
+ ).localId
+ }"]
+ `).length,
+ 1,
+ "chat window of channel 1 should be open"
+ );
+ assert.strictEqual(
+ document.querySelectorAll(`
+ .o_ChatWindow[data-thread-local-id="${
+ this.env.models['mail.thread'].find(thread =>
+ thread.id === 3 &&
+ thread.model === 'mail.channel'
+ ).localId
+ }"]
+ `).length,
+ 1,
+ "chat window of channel 3 should be open"
+ );
+ assert.ok(
+ document.querySelector(`
+ .o_ChatWindow[data-thread-local-id="${
+ this.env.models['mail.thread'].find(thread =>
+ thread.id === 3 &&
+ thread.model === 'mail.channel'
+ ).localId
+ }"]
+ `).classList.contains('o-focused'),
+ "chat window of channel 3 should have focus"
+ );
+});
+
+QUnit.test('chat window: switch on TAB', async function (assert) {
+ assert.expect(10);
+
+ Object.assign(this.data.initMessaging, {
+ channel_slots: {
+ channel_channel: [{
+ channel_type: "channel",
+ id: 1,
+ name: "channel1",
+ }, {
+ channel_type: "channel",
+ id: 2,
+ name: "channel2",
+ }],
+ },
+ });
+ await this.start();
+
+ await afterNextRender(() =>
+ document.querySelector(`.o_MessagingMenu_toggler`).click()
+ );
+ await afterNextRender(() =>
+ document.querySelector(`
+ .o_MessagingMenu_dropdownMenu
+ .o_NotificationList_preview[data-thread-local-id="${
+ this.env.models['mail.thread'].find(thread =>
+ thread.id === 1 &&
+ thread.model === 'mail.channel'
+ ).localId
+ }"]`
+ ).click()
+ );
+
+ assert.containsOnce(document.body, '.o_ChatWindow', "Only 1 chatWindow must be opened");
+ const chatWindow = document.querySelector('.o_ChatWindow');
+ assert.strictEqual(
+ chatWindow.querySelector('.o_ChatWindowHeader_name').textContent,
+ 'channel1',
+ "The name of the only chatWindow should be 'channel1' (channel with ID 1)"
+ );
+ assert.strictEqual(
+ chatWindow.querySelector('.o_ComposerTextInput_textarea'),
+ document.activeElement,
+ "The chatWindow composer must have focus"
+ );
+
+ await afterNextRender(() =>
+ triggerEvent(
+ chatWindow.querySelector('.o_ChatWindow .o_ComposerTextInput_textarea'),
+ 'keydown',
+ { key: 'Tab' },
+ )
+ );
+ assert.strictEqual(
+ chatWindow.querySelector('.o_ChatWindow .o_ComposerTextInput_textarea'),
+ document.activeElement,
+ "The chatWindow composer still has focus"
+ );
+
+ await afterNextRender(() =>
+ document.querySelector(`.o_MessagingMenu_toggler`).click()
+ );
+ await afterNextRender(() =>
+ document.querySelector(`
+ .o_MessagingMenu_dropdownMenu
+ .o_NotificationList_preview[data-thread-local-id="${
+ this.env.models['mail.thread'].find(thread =>
+ thread.id === 2 &&
+ thread.model === 'mail.channel'
+ ).localId
+ }"]`
+ ).click()
+ );
+
+ assert.containsN(document.body, '.o_ChatWindow', 2, "2 chatWindows must be opened");
+ const chatWindows = document.querySelectorAll('.o_ChatWindow');
+ assert.strictEqual(
+ chatWindows[0].querySelector('.o_ChatWindowHeader_name').textContent,
+ 'channel1',
+ "The name of the 1st chatWindow should be 'channel1' (channel with ID 1)"
+ );
+ assert.strictEqual(
+ chatWindows[1].querySelector('.o_ChatWindowHeader_name').textContent,
+ 'channel2',
+ "The name of the 2nd chatWindow should be 'channel2' (channel with ID 2)"
+ );
+ assert.strictEqual(
+ chatWindows[1].querySelector('.o_ComposerTextInput_textarea'),
+ document.activeElement,
+ "The 2nd chatWindow composer must have focus (channel with ID 2)"
+ );
+
+ await afterNextRender(() =>
+ triggerEvent(
+ chatWindows[1].querySelector('.o_ComposerTextInput_textarea'),
+ 'keydown',
+ { key: 'Tab' },
+ )
+ );
+ assert.containsN(document.body, '.o_ChatWindow', 2, "2 chatWindows should still be opened");
+ assert.strictEqual(
+ chatWindows[0].querySelector('.o_ComposerTextInput_textarea'),
+ document.activeElement,
+ "The 1st chatWindow composer must have focus (channel with ID 1)"
+ );
+});
+
+});
+});
+});
+
+});
diff --git a/addons/mail/static/src/components/chatter/chatter.js b/addons/mail/static/src/components/chatter/chatter.js
new file mode 100644
index 00000000000..5503cc90d8b
--- /dev/null
+++ b/addons/mail/static/src/components/chatter/chatter.js
@@ -0,0 +1,103 @@
+odoo.define('mail/static/src/components/chatter/chatter.js', function (require) {
+'use strict';
+
+const components = {
+ ActivityBox: require('mail/static/src/components/activity_box/activity_box.js'),
+ AttachmentBox: require('mail/static/src/components/attachment_box/attachment_box.js'),
+ ChatterTopbar: require('mail/static/src/components/chatter_topbar/chatter_topbar.js'),
+ Composer: require('mail/static/src/components/composer/composer.js'),
+ ThreadViewer: require('mail/static/src/components/thread_viewer/thread_viewer.js'),
+};
+const useStore = require('mail/static/src/component_hooks/use_store/use_store.js');
+
+const { Component } = owl;
+const { useRef } = owl.hooks;
+
+class Chatter extends Component {
+
+ /**
+ * @override
+ */
+ constructor(...args) {
+ super(...args);
+ useStore(props => {
+ const chatter = this.env.models['mail.chatter'].get(props.chatterLocalId);
+ const thread = chatter ? chatter.thread : undefined;
+ let attachments = [];
+ if (thread) {
+ attachments = thread.allAttachments;
+ }
+ return {
+ attachments: attachments.map(attachment => attachment.__state),
+ chatter: chatter ? chatter.__state : undefined,
+ thread: thread ? thread.__state : undefined,
+ };
+ }, {
+ compareDepth: {
+ attachments: 1,
+ },
+ });
+ this._threadRef = useRef('thread');
+ }
+
+ mounted() {
+ if (this.chatter.thread) {
+ this._notifyRendered();
+ }
+ }
+
+ patched() {
+ if (this.chatter.thread) {
+ this._notifyRendered();
+ }
+ }
+
+ //--------------------------------------------------------------------------
+ // Public
+ //--------------------------------------------------------------------------
+
+ /**
+ * @returns {mail.chatter}
+ */
+ get chatter() {
+ return this.env.models['mail.chatter'].get(this.props.chatterLocalId);
+ }
+
+ //--------------------------------------------------------------------------
+ // Private
+ //--------------------------------------------------------------------------
+
+ /**
+ * @private
+ */
+ _notifyRendered() {
+ this.trigger('o-chatter-rendered', {
+ attachments: this.chatter.thread.allAttachments,
+ thread: this.chatter.thread.localId,
+ });
+ }
+
+ //--------------------------------------------------------------------------
+ // Handlers
+ //--------------------------------------------------------------------------
+
+ /**
+ * @private
+ */
+ _onComposerMessagePosted() {
+ this.chatter.update({ isComposerVisible: false });
+ }
+
+}
+
+Object.assign(Chatter, {
+ components,
+ props: {
+ chatterLocalId: String,
+ },
+ template: 'mail.Chatter',
+});
+
+return Chatter;
+
+});
diff --git a/addons/mail/static/src/components/chatter/chatter.scss b/addons/mail/static/src/components/chatter/chatter.scss
new file mode 100644
index 00000000000..ef33f676de2
--- /dev/null
+++ b/addons/mail/static/src/components/chatter/chatter.scss
@@ -0,0 +1,37 @@
+// ------------------------------------------------------------------
+// Layout
+// ------------------------------------------------------------------
+
+.o_Chatter {
+ display: flex;
+ flex: 1 1 auto;
+ flex-direction: column;
+ overflow: auto;
+}
+
+.o_Chatter_composer {
+ border-bottom: $border-width solid;
+
+ &.o-bordered {
+ border-left: $border-width solid;
+ border-right: $border-width solid;
+ }
+}
+
+// ------------------------------------------------------------------
+// Style
+// ------------------------------------------------------------------
+
+.o_Chatter {
+ background-color: white;
+ border-color: $border-color;
+}
+
+.o_Chatter_composer {
+ border-bottom-color: $border-color;
+
+ &.o-bordered {
+ border-left-color: $border-color;
+ border-right-color: $border-color;
+ }
+}
diff --git a/addons/mail/static/src/components/chatter/chatter.xml b/addons/mail/static/src/components/chatter/chatter.xml
new file mode 100644
index 00000000000..f630eaf7634
--- /dev/null
+++ b/addons/mail/static/src/components/chatter/chatter.xml
@@ -0,0 +1,53 @@
+<?xml version="1.0" encoding="UTF-8"?>
+<templates xml:space="preserve">
+
+ <t t-name="mail.Chatter" owl="1">
+ <div class="o_Chatter">
+ <t t-if="chatter">
+ <ChatterTopbar
+ class="o_Chatter_topbar"
+ chatterLocalId="chatter.localId"
+ />
+ <t t-if="chatter.hasThread and chatter.thread and chatter.isComposerVisible">
+ <Composer
+ class="o_Chatter_composer"
+ t-att-class="{ 'o-bordered': chatter.hasExternalBorder }"
+ composerLocalId="chatter.thread.composer.localId"
+ hasMentionSuggestionsBelowPosition="true"
+ hasTextInputSendOnEnterEnabled="false"
+ isCompact="false"
+ isExpandable="true"
+ isLog="chatter.isComposerLog"
+ t-on-o-message-posted="_onComposerMessagePosted"
+ t-ref="composer"
+ />
+ </t>
+ <t t-if="chatter.isAttachmentBoxVisible">
+ <AttachmentBox
+ class="o_Chatter_attachmentBox"
+ threadLocalId="chatter.thread.localId"
+ />
+ </t>
+ <t t-if="chatter.thread">
+ <t t-if="chatter.hasActivities and chatter.activities.length > 0">
+ <ActivityBox
+ class="o_Chatter_activityBox"
+ chatterLocalId="chatter.localId"
+ />
+ </t>
+ <t t-if="chatter.hasThread">
+ <ThreadViewer
+ class="o_Chatter_thread"
+ hasComposer="false"
+ haveMessagesAuthorRedirect="true"
+ order="'desc'"
+ threadViewerLocalId="chatter.threadViewer.localId"
+ t-ref="thread"
+ />
+ </t>
+ </t>
+ </t>
+ </div>
+ </t>
+
+</templates>
diff --git a/addons/mail/static/src/components/chatter/chatter_tests.js b/addons/mail/static/src/components/chatter/chatter_tests.js
new file mode 100644
index 00000000000..a27a4036105
--- /dev/null
+++ b/addons/mail/static/src/components/chatter/chatter_tests.js
@@ -0,0 +1,337 @@
+odoo.define('mail/static/src/components/chatter/chatter_tests', function (require) {
+'use strict';
+
+const components = {
+ Chatter: require('mail/static/src/components/chatter/chatter.js'),
+};
+const {
+ afterEach: utilsAfterEach,
+ afterNextRender,
+ beforeEach: utilsBeforeEach,
+ start: utilsStart,
+} = require('mail/static/src/utils/test_utils.js');
+
+QUnit.module('mail', {}, function () {
+QUnit.module('components', {}, function () {
+QUnit.module('chatter', {}, function () {
+QUnit.module('chatter_tests.js', {
+ beforeEach() {
+ utilsBeforeEach(this);
+
+ this.createChatterComponent = async ({ chatter }, otherProps) => {
+ const ChatterComponent = components.Chatter;
+ ChatterComponent.env = this.env;
+ this.component = new ChatterComponent(
+ null,
+ Object.assign({ chatterLocalId: chatter.localId }, otherProps)
+ );
+ await afterNextRender(() => this.component.mount(this.widget.el));
+ };
+
+ this.start = async params => {
+ if (this.widget) {
+ this.widget.destroy();
+ }
+ let { env, widget } = await utilsStart(Object.assign({}, params, {
+ data: this.data,
+ }));
+ this.env = env;
+ this.widget = widget;
+ };
+ },
+ afterEach() {
+ utilsAfterEach(this);
+ if (this.component) {
+ this.component.destroy();
+ }
+ if (this.widget) {
+ this.widget.destroy();
+ }
+ delete components.Chatter.env;
+ this.env = undefined;
+ },
+});
+
+QUnit.test('base rendering when chatter has no attachment', async function (assert) {
+ assert.expect(6);
+
+ const messages = [...Array(60).keys()].map(id => {
+ return {
+ author_id: [10, "Demo User"],
+ body: `<p>Message ${id + 1}</p>`,
+ date: "2019-04-20 10:00:00",
+ id: id + 1,
+ message_type: 'comment',
+ model: 'res.partner',
+ record_name: 'General',
+ res_id: 100,
+ };
+ });
+ this.data['mail.message'].records = messages;
+
+ await this.start();
+ const chatter = this.env.models['mail.chatter'].create({
+ threadId: 100,
+ threadModel: 'res.partner',
+ });
+ await this.createChatterComponent({ chatter });
+ assert.strictEqual(
+ document.querySelectorAll(`.o_Chatter`).length,
+ 1,
+ "should have a chatter"
+ );
+ assert.strictEqual(
+ document.querySelectorAll(`.o_ChatterTopbar`).length,
+ 1,
+ "should have a chatter topbar"
+ );
+ assert.strictEqual(
+ document.querySelectorAll(`.o_Chatter_attachmentBox`).length,
+ 0,
+ "should not have an attachment box in the chatter"
+ );
+ assert.strictEqual(
+ document.querySelectorAll(`.o_Chatter_thread`).length,
+ 1,
+ "should have a thread in the chatter"
+ );
+ assert.strictEqual(
+ document.querySelector(`.o_Chatter_thread`).dataset.threadLocalId,
+ this.env.models['mail.thread'].find(thread =>
+ thread.id === 100 &&
+ thread.model === 'res.partner'
+ ).localId,
+ "thread should have the right thread local id"
+ );
+ assert.strictEqual(
+ document.querySelectorAll(`.o_Message`).length,
+ 30,
+ "the first 30 messages of thread should be loaded"
+ );
+});
+
+QUnit.test('base rendering when chatter has no record', async function (assert) {
+ assert.expect(7);
+
+ await this.start();
+ const chatter = this.env.models['mail.chatter'].create({
+ threadModel: 'res.partner',
+ });
+ await this.createChatterComponent({ chatter });
+ assert.strictEqual(
+ document.querySelectorAll(`.o_Chatter`).length,
+ 1,
+ "should have a chatter"
+ );
+ assert.strictEqual(
+ document.querySelectorAll(`.o_ChatterTopbar`).length,
+ 1,
+ "should have a chatter topbar"
+ );
+ assert.strictEqual(
+ document.querySelectorAll(`.o_Chatter_attachmentBox`).length,
+ 0,
+ "should not have an attachment box in the chatter"
+ );
+ assert.strictEqual(
+ document.querySelectorAll(`.o_Chatter_thread`).length,
+ 1,
+ "should have a thread in the chatter"
+ );
+ assert.ok(
+ chatter.thread.isTemporary,
+ "thread should have a temporary thread linked to chatter"
+ );
+ assert.strictEqual(
+ document.querySelectorAll(`.o_Message`).length,
+ 1,
+ "should have a message"
+ );
+ assert.strictEqual(
+ document.querySelector(`.o_Message_content`).textContent,
+ "Creating a new record...",
+ "should have the 'Creating a new record ...' message"
+ );
+});
+
+QUnit.test('base rendering when chatter has attachments', async function (assert) {
+ assert.expect(3);
+
+ await this.start({
+ async mockRPC(route, args) {
+ if (route.includes('ir.attachment/search_read')) {
+ return [{
+ id: 143,
+ filename: 'Blah.txt',
+ mimetype: 'text/plain',
+ name: 'Blah.txt'
+ }, {
+ id: 144,
+ filename: 'Blu.txt',
+ mimetype: 'text/plain',
+ name: 'Blu.txt'
+ }];
+ }
+ return this._super(...arguments);
+ }
+ });
+ const chatter = this.env.models['mail.chatter'].create({
+ threadId: 100,
+ threadModel: 'res.partner',
+ });
+ await this.createChatterComponent({ chatter });
+ assert.strictEqual(
+ document.querySelectorAll(`.o_Chatter`).length,
+ 1,
+ "should have a chatter"
+ );
+ assert.strictEqual(
+ document.querySelectorAll(`.o_ChatterTopbar`).length,
+ 1,
+ "should have a chatter topbar"
+ );
+ assert.strictEqual(
+ document.querySelectorAll(`.o_Chatter_attachmentBox`).length,
+ 0,
+ "should not have an attachment box in the chatter"
+ );
+});
+
+QUnit.test('show attachment box', async function (assert) {
+ assert.expect(6);
+
+ await this.start({
+ async mockRPC(route, args) {
+ if (route.includes('ir.attachment/search_read')) {
+ return [{
+ id: 143,
+ filename: 'Blah.txt',
+ mimetype: 'text/plain',
+ name: 'Blah.txt'
+ }, {
+ id: 144,
+ filename: 'Blu.txt',
+ mimetype: 'text/plain',
+ name: 'Blu.txt'
+ }];
+ }
+ return this._super(...arguments);
+ }
+ });
+ const chatter = this.env.models['mail.chatter'].create({
+ threadId: 100,
+ threadModel: 'res.partner',
+ });
+ await this.createChatterComponent({ chatter });
+ assert.strictEqual(
+ document.querySelectorAll(`.o_Chatter`).length,
+ 1,
+ "should have a chatter"
+ );
+ assert.strictEqual(
+ document.querySelectorAll(`.o_ChatterTopbar`).length,
+ 1,
+ "should have a chatter topbar"
+ );
+ assert.strictEqual(
+ document.querySelectorAll(`.o_ChatterTopbar_buttonAttachments`).length,
+ 1,
+ "should have an attachments button in chatter topbar"
+ );
+ assert.strictEqual(
+ document.querySelectorAll(`.o_ChatterTopbar_buttonAttachmentsCount`).length,
+ 1,
+ "attachments button should have a counter"
+ );
+ assert.strictEqual(
+ document.querySelectorAll(`.o_Chatter_attachmentBox`).length,
+ 0,
+ "should not have an attachment box in the chatter"
+ );
+
+ await afterNextRender(() =>
+ document.querySelector(`.o_ChatterTopbar_buttonAttachments`).click()
+ );
+ assert.strictEqual(
+ document.querySelectorAll(`.o_Chatter_attachmentBox`).length,
+ 1,
+ "should have an attachment box in the chatter"
+ );
+});
+
+QUnit.test('composer show/hide on log note/send message', async function (assert) {
+ assert.expect(8);
+
+ await this.start();
+ const chatter = this.env.models['mail.chatter'].create({
+ threadId: 100,
+ threadModel: 'res.partner',
+ });
+ await this.createChatterComponent({ chatter });
+ assert.strictEqual(
+ document.querySelectorAll(`.o_ChatterTopbar_buttonSendMessage`).length,
+ 1,
+ "should have a send message button"
+ );
+ assert.strictEqual(
+ document.querySelectorAll(`.o_ChatterTopbar_buttonLogNote`).length,
+ 1,
+ "should have a log note button"
+ );
+ assert.strictEqual(
+ document.querySelectorAll(`.o_Chatter_composer`).length,
+ 0,
+ "should not have a composer"
+ );
+
+ await afterNextRender(() =>
+ document.querySelector(`.o_ChatterTopbar_buttonSendMessage`).click()
+ );
+ assert.strictEqual(
+ document.querySelectorAll(`.o_Chatter_composer`).length,
+ 1,
+ "should have a composer"
+ );
+
+ await afterNextRender(() =>
+ document.querySelector(`.o_ChatterTopbar_buttonLogNote`).click()
+ );
+ assert.strictEqual(
+ document.querySelectorAll(`.o_Chatter_composer`).length,
+ 1,
+ "should still have a composer"
+ );
+
+ await afterNextRender(() =>
+ document.querySelector(`.o_ChatterTopbar_buttonLogNote`).click()
+ );
+ assert.strictEqual(
+ document.querySelectorAll(`.o_Chatter_composer`).length,
+ 0,
+ "should have no composer anymore"
+ );
+
+ await afterNextRender(() =>
+ document.querySelector(`.o_ChatterTopbar_buttonSendMessage`).click()
+ );
+ assert.strictEqual(
+ document.querySelectorAll(`.o_Chatter_composer`).length,
+ 1,
+ "should have a composer"
+ );
+
+ await afterNextRender(() =>
+ document.querySelector(`.o_ChatterTopbar_buttonSendMessage`).click()
+ );
+ assert.strictEqual(
+ document.querySelectorAll(`.o_Chatter_composer`).length,
+ 0,
+ "should have no composer anymore"
+ );
+});
+
+});
+});
+});
+
+});
diff --git a/addons/mail/static/src/components/chatter_container/chatter_container.js b/addons/mail/static/src/components/chatter_container/chatter_container.js
new file mode 100644
index 00000000000..0c5de5208fc
--- /dev/null
+++ b/addons/mail/static/src/components/chatter_container/chatter_container.js
@@ -0,0 +1,80 @@
+odoo.define('mail/static/src/components/chatter_container/chatter_container.js', function (require) {
+'use strict';
+
+const components = {
+ Chatter: require('mail/static/src/components/chatter/chatter.js'),
+};
+const useStore = require('mail/static/src/component_hooks/use_store/use_store.js');
+
+const { Component } = owl;
+
+/**
+ * This component abstracts chatter component to its parent, so that it can be
+ * mounted and receive chatter data even when a chatter component cannot be
+ * created. Indeed, in order to create a chatter component, we must create
+ * a chatter record, the latter requiring messaging to be initialized. The view
+ * may attempt to create a chatter before messaging has been initialized, so
+ * this component delays the mounting of chatter until it becomes initialized.
+ */
+class ChatterContainer extends Component {
+
+ /**
+ * @override
+ */
+ constructor(...args) {
+ super(...args);
+ this.chatter = undefined;
+ this._wasMessagingInitialized = false;
+ useStore(props => {
+ const isMessagingInitialized = this.env.isMessagingInitialized();
+ if (!this._wasMessagingInitialized && isMessagingInitialized) {
+ this._wasMessagingInitialized = true;
+ this.chatter = this.env.models['mail.chatter'].create(props);
+ }
+ return { isMessagingInitialized };
+ });
+ }
+
+ mounted() {
+ this._update();
+ }
+
+ patched() {
+ this._update();
+ }
+
+ destroy() {
+ super.destroy();
+ if (this.chatter) {
+ this.chatter.delete();
+ }
+ }
+
+ //--------------------------------------------------------------------------
+ // Private
+ //--------------------------------------------------------------------------
+
+ /**
+ * @private
+ */
+ _update() {
+ if (this.chatter) {
+ this.chatter.update(this.props);
+ }
+ }
+
+}
+
+Object.assign(ChatterContainer, {
+ components,
+ /**
+ * No props validation because this component simply forwards props to
+ * chatter record as its data.
+ */
+ template: 'mail.ChatterContainer',
+});
+
+
+return ChatterContainer;
+
+});
diff --git a/addons/mail/static/src/components/chatter_container/chatter_container.scss b/addons/mail/static/src/components/chatter_container/chatter_container.scss
new file mode 100644
index 00000000000..99e8d530fb3
--- /dev/null
+++ b/addons/mail/static/src/components/chatter_container/chatter_container.scss
@@ -0,0 +1,13 @@
+// ------------------------------------------------------------------
+// Layout
+// ------------------------------------------------------------------
+
+.o_ChatterContainer {
+ display: flex;
+ flex: 1 1 auto;
+}
+
+// ------------------------------------------------------------------
+// Style
+// ------------------------------------------------------------------
+
diff --git a/addons/mail/static/src/components/chatter_container/chatter_container.xml b/addons/mail/static/src/components/chatter_container/chatter_container.xml
new file mode 100644
index 00000000000..f9ee262e69b
--- /dev/null
+++ b/addons/mail/static/src/components/chatter_container/chatter_container.xml
@@ -0,0 +1,15 @@
+<?xml version="1.0" encoding="UTF-8"?>
+<templates xml:space="preserve">
+
+ <t t-name="mail.ChatterContainer" owl="1">
+ <div class="o_ChatterContainer">
+ <t t-if="chatter">
+ <Chatter chatterLocalId="chatter.localId"/>
+ </t>
+ <t t-else="">
+ Please wait...
+ </t>
+ </div>
+ </t>
+
+</templates>
diff --git a/addons/mail/static/src/components/chatter_topbar/chatter_topbar.js b/addons/mail/static/src/components/chatter_topbar/chatter_topbar.js
new file mode 100644
index 00000000000..c4b088b7709
--- /dev/null
+++ b/addons/mail/static/src/components/chatter_topbar/chatter_topbar.js
@@ -0,0 +1,128 @@
+odoo.define('mail/static/src/components/chatter_topbar/chatter_topbar.js', function (require) {
+'use strict';
+
+const components = {
+ FollowButton: require('mail/static/src/components/follow_button/follow_button.js'),
+ FollowerListMenu: require('mail/static/src/components/follower_list_menu/follower_list_menu.js'),
+};
+const useStore = require('mail/static/src/component_hooks/use_store/use_store.js');
+
+const { Component } = owl;
+
+class ChatterTopbar extends Component {
+
+ /**
+ * @override
+ */
+ constructor(...args) {
+ super(...args);
+ useStore(props => {
+ const chatter = this.env.models['mail.chatter'].get(props.chatterLocalId);
+ const thread = chatter ? chatter.thread : undefined;
+ const threadAttachments = thread ? thread.allAttachments : [];
+ return {
+ areThreadAttachmentsLoaded: thread && thread.areAttachmentsLoaded,
+ chatter: chatter ? chatter.__state : undefined,
+ threadAttachmentsAmount: threadAttachments.length,
+ };
+ });
+ }
+
+ //--------------------------------------------------------------------------
+ // Public
+ //--------------------------------------------------------------------------
+
+ /**
+ * @returns {mail.chatter}
+ */
+ get chatter() {
+ return this.env.models['mail.chatter'].get(this.props.chatterLocalId);
+ }
+
+ //--------------------------------------------------------------------------
+ // Handlers
+ //--------------------------------------------------------------------------
+
+ /**
+ * @private
+ * @param {MouseEvent} ev
+ */
+ _onClickAttachments(ev) {
+ this.chatter.update({
+ isAttachmentBoxVisible: !this.chatter.isAttachmentBoxVisible,
+ });
+ }
+
+ /**
+ * @private
+ * @param {MouseEvent} ev
+ */
+ _onClickClose(ev) {
+ this.trigger('o-close-chatter');
+ }
+
+ /**
+ * @private
+ * @param {MouseEvent} ev
+ */
+ _onClickLogNote(ev) {
+ if (this.chatter.isComposerVisible && this.chatter.isComposerLog) {
+ this.chatter.update({ isComposerVisible: false });
+ } else {
+ this.chatter.showLogNote();
+ }
+ }
+
+ /**
+ * @private
+ * @param {MouseEvent} ev
+ */
+ _onClickScheduleActivity(ev) {
+ const action = {
+ type: 'ir.actions.act_window',
+ name: this.env._t("Schedule Activity"),
+ res_model: 'mail.activity',
+ view_mode: 'form',
+ views: [[false, 'form']],
+ target: 'new',
+ context: {
+ default_res_id: this.chatter.threadId,
+ default_res_model: this.chatter.threadModel,
+ },
+ res_id: false,
+ };
+ return this.env.bus.trigger('do-action', {
+ action,
+ options: {
+ // A bit "extreme", could be improved:
+ // normally only an activity is created (no update nor delete)
+ on_close: () => this.chatter.refreshActivities(),
+ },
+ });
+ }
+
+ /**
+ * @private
+ * @param {MouseEvent} ev
+ */
+ _onClickSendMessage(ev) {
+ if (this.chatter.isComposerVisible && !this.chatter.isComposerLog) {
+ this.chatter.update({ isComposerVisible: false });
+ } else {
+ this.chatter.showSendMessage();
+ }
+ }
+
+}
+
+Object.assign(ChatterTopbar, {
+ components,
+ props: {
+ chatterLocalId: String,
+ },
+ template: 'mail.ChatterTopbar',
+});
+
+return ChatterTopbar;
+
+});
diff --git a/addons/mail/static/src/components/chatter_topbar/chatter_topbar.scss b/addons/mail/static/src/components/chatter_topbar/chatter_topbar.scss
new file mode 100644
index 00000000000..17c5fa5d856
--- /dev/null
+++ b/addons/mail/static/src/components/chatter_topbar/chatter_topbar.scss
@@ -0,0 +1,100 @@
+// ------------------------------------------------------------------
+// Layout
+// ------------------------------------------------------------------
+
+.o_ChatterTopbar {
+ display: flex;
+ flex-direction: row;
+ justify-content: space-between;
+}
+
+.o_ChatterTopbar_actions {
+ display: flex;
+ flex: 1;
+ flex-direction: row;
+ flex-wrap: wrap;
+
+ &.o-has-active-button {
+ border-bottom: $border-width solid;
+ }
+}
+
+.o_ChatterTopbar_button {
+ margin-bottom: -$border-width; /* Needed to allow "overriding" of the bottom border */
+}
+
+.o_ChatterTopbar_buttonAttachmentsCountLoader {
+ margin-left: 2px;
+}
+
+.o_ChatterTopbar_buttonCount {
+ padding-left: 0.25rem;
+}
+
+.o_ChatterTopbar_buttonClose {
+ display: flex;
+ flex-shrink: 0;
+ justify-content: center;
+ align-items: center;
+ width: 34px;
+ height: 34px;
+}
+
+.o_ChatterTopbar_followerListMenu {
+ display: flex;
+}
+
+.o_ChatterTopbar_rightSection {
+ display: flex;
+ flex: 1 0 auto;
+ justify-content: flex-end;
+}
+
+// ------------------------------------------------------------------
+// Style
+// ------------------------------------------------------------------
+
+.o_ChatterTopbar_actions.o-has-active-button {
+ border-color: $border-color;
+}
+
+.o_ChatterTopbar_button {
+ border-radius: 0;
+
+ &:hover {
+ background-color: gray('300');
+ }
+
+ &.o-active {
+ color: $o-brand-odoo;
+ background-color: lighten(gray('300'), 7%);
+ border-right-color: $border-color;
+
+ &:not(:first-of-type),
+ &:first-of-type.o-bordered {
+ border-left-color: $border-color;
+ }
+
+ &.o-bordered {
+ border-top-color: $border-color;
+ }
+
+ &:hover {
+ background-color: gray('300');
+ color: $link-hover-color;
+ }
+ }
+}
+
+.o_ChatterTopbar_buttonClose {
+ border-radius: 0 0 10px 10px;
+ font-size: $font-size-lg;
+ background-color: gray('700');
+ color: gray('100');
+ cursor: pointer;
+
+ &:hover {
+ background-color: gray('600');
+ color: $white;
+ }
+}
diff --git a/addons/mail/static/src/components/chatter_topbar/chatter_topbar.xml b/addons/mail/static/src/components/chatter_topbar/chatter_topbar.xml
new file mode 100644
index 00000000000..67dfe4ea844
--- /dev/null
+++ b/addons/mail/static/src/components/chatter_topbar/chatter_topbar.xml
@@ -0,0 +1,72 @@
+<?xml version="1.0" encoding="UTF-8"?>
+<templates xml:space="preserve">
+
+ <t t-name="mail.ChatterTopbar" owl="1">
+ <div class="o_ChatterTopbar">
+ <t t-if="chatter">
+ <div class="o_ChatterTopbar_actions" t-att-class="{'o-has-active-button': chatter.isComposerVisible }">
+ <t t-if="chatter.hasThread">
+ <button class="btn btn-link o_ChatterTopbar_button o_ChatterTopbar_buttonSendMessage"
+ type="button"
+ t-att-class="{
+ 'o-active': chatter.isComposerVisible and !chatter.isComposerLog,
+ 'o-bordered': chatter.hasExternalBorder,
+ }"
+ t-att-disabled="chatter.isDisabled"
+ t-on-click="_onClickSendMessage"
+ >
+ Send message
+ </button>
+ <button class="btn btn-link o_ChatterTopbar_button o_ChatterTopbar_buttonLogNote"
+ type="button"
+ t-att-class="{
+ 'o-active': chatter.isComposerVisible and chatter.isComposerLog,
+ 'o-bordered': chatter.hasExternalBorder,
+ }"
+ t-att-disabled="chatter.isDisabled"
+ t-on-click="_onClickLogNote"
+ >
+ Log note
+ </button>
+ </t>
+ <t t-if="chatter.hasActivities">
+ <button class="btn btn-link o_ChatterTopbar_button o_ChatterTopbar_buttonScheduleActivity" type="button" t-att-disabled="chatter.isDisabled" t-on-click="_onClickScheduleActivity">
+ <i class="fa fa-clock-o"/>
+ Schedule activity
+ </button>
+ </t>
+ <button class="btn btn-link o_ChatterTopbar_button o_ChatterTopbar_buttonAttachments" type="button" t-att-disabled="chatter.isDisabled" t-on-click="_onClickAttachments">
+ <i class="fa fa-paperclip"/>
+ <t t-if="chatter.isDisabled or !chatter.isShowingAttachmentsLoading">
+ <span class="o_ChatterTopbar_buttonCount o_ChatterTopbar_buttonAttachmentsCount" t-esc="chatter.thread ? chatter.thread.allAttachments.length : 0"/>
+ </t>
+ <t t-else="">
+ <i class="o_ChatterTopbar_buttonAttachmentsCountLoader fa fa-spinner fa-spin" aria-label="Attachment counter loading..."/>
+ </t>
+ </button>
+ <div class="o-autogrow"/>
+ <div class="o_ChatterTopbar_rightSection">
+ <t t-if="chatter.hasFollowers and chatter.thread">
+ <FollowButton
+ class="o_ChatterTopbar_button o_ChatterTopbar_followButton"
+ isDisabled="chatter.isDisabled"
+ threadLocalId="chatter.thread.localId"
+ />
+ <FollowerListMenu
+ class="o_ChatterTopbar_button o_ChatterTopbar_followerListMenu"
+ isDisabled="chatter.isDisabled"
+ threadLocalId="chatter.thread.localId"
+ />
+ </t>
+ </div>
+ </div>
+ <t t-if="chatter.hasTopbarCloseButton">
+ <div class="o_ChatterTopbar_buttonClose" title="Close" t-on-click="_onClickClose">
+ <i class="fa fa-times"/>
+ </div>
+ </t>
+ </t>
+ </div>
+ </t>
+
+</templates>
diff --git a/addons/mail/static/src/components/chatter_topbar/chatter_topbar_tests.js b/addons/mail/static/src/components/chatter_topbar/chatter_topbar_tests.js
new file mode 100644
index 00000000000..c6c38c081b6
--- /dev/null
+++ b/addons/mail/static/src/components/chatter_topbar/chatter_topbar_tests.js
@@ -0,0 +1,590 @@
+odoo.define('mail/static/src/components/chatter_topbar/chatter_topbar_tests.js', function (require) {
+'use strict';
+
+const components = {
+ ChatterTopBar: require('mail/static/src/components/chatter_topbar/chatter_topbar.js'),
+};
+const {
+ afterEach: utilsAfterEach,
+ afterNextRender,
+ beforeEach: utilsBeforeEach,
+ start: utilsStart,
+} = require('mail/static/src/utils/test_utils.js');
+
+const { makeTestPromise } = require('web.test_utils');
+
+QUnit.module('mail', {}, function () {
+QUnit.module('components', {}, function () {
+QUnit.module('chatter_topbar', {}, function () {
+QUnit.module('chatter_topbar_tests.js', {
+ beforeEach() {
+ utilsBeforeEach(this);
+
+ this.createChatterTopbarComponent = async (chatter, otherProps) => {
+ const ChatterTopBarComponent = components.ChatterTopBar;
+ ChatterTopBarComponent.env = this.env;
+ this.component = new ChatterTopBarComponent(
+ null,
+ Object.assign({ chatterLocalId: chatter.localId }, otherProps)
+ );
+ await afterNextRender(() => this.component.mount(this.widget.el));
+ };
+
+ this.start = async params => {
+ if (this.widget) {
+ this.widget.destroy();
+ }
+ let { env, widget } = await utilsStart(Object.assign({}, params, {
+ data: this.data,
+ }));
+ this.env = env;
+ this.widget = widget;
+ };
+ },
+ afterEach() {
+ utilsAfterEach(this);
+ if (this.component) {
+ this.component.destroy();
+ }
+ if (this.widget) {
+ this.widget.destroy();
+ }
+ delete components.ChatterTopBar.env;
+ this.env = undefined;
+ },
+});
+
+QUnit.test('base rendering', async function (assert) {
+ assert.expect(8);
+
+ await this.start();
+ const chatter = this.env.models['mail.chatter'].create({
+ threadId: 100,
+ threadModel: 'res.partner',
+ });
+ await this.createChatterTopbarComponent(chatter);
+
+ assert.strictEqual(
+ document.querySelectorAll(`.o_ChatterTopbar`).length,
+ 1,
+ "should have a chatter topbar"
+ );
+ assert.strictEqual(
+ document.querySelectorAll(`.o_ChatterTopbar_buttonSendMessage`).length,
+ 1,
+ "should have a send message button in chatter menu"
+ );
+ assert.strictEqual(
+ document.querySelectorAll(`.o_ChatterTopbar_buttonLogNote`).length,
+ 1,
+ "should have a log note button in chatter menu"
+ );
+ assert.strictEqual(
+ document.querySelectorAll(`.o_ChatterTopbar_buttonScheduleActivity`).length,
+ 1,
+ "should have a schedule activity button in chatter menu"
+ );
+ assert.strictEqual(
+ document.querySelectorAll(`.o_ChatterTopbar_buttonAttachments`).length,
+ 1,
+ "should have an attachments button in chatter menu"
+ );
+ assert.strictEqual(
+ document.querySelectorAll(`.o_ChatterTopbar_buttonAttachmentsCountLoader`).length,
+ 0,
+ "attachments button should not have a loader"
+ );
+ assert.strictEqual(
+ document.querySelectorAll(`.o_ChatterTopbar_buttonAttachmentsCount`).length,
+ 1,
+ "attachments button should have a counter"
+ );
+ assert.strictEqual(
+ document.querySelectorAll(`.o_ChatterTopbar_followerListMenu`).length,
+ 1,
+ "should have a follower menu"
+ );
+});
+
+QUnit.test('base disabled rendering', async function (assert) {
+ assert.expect(8);
+
+ await this.start();
+ const chatter = this.env.models['mail.chatter'].create({
+ threadModel: 'res.partner',
+ });
+ await this.createChatterTopbarComponent(chatter);
+ assert.strictEqual(
+ document.querySelectorAll(`.o_ChatterTopbar`).length,
+ 1,
+ "should have a chatter topbar"
+ );
+ assert.ok(
+ document.querySelector(`.o_ChatterTopbar_buttonSendMessage`).disabled,
+ "send message button should be disabled"
+ );
+ assert.ok(
+ document.querySelector(`.o_ChatterTopbar_buttonLogNote`).disabled,
+ "log note button should be disabled"
+ );
+ assert.ok(
+ document.querySelector(`.o_ChatterTopbar_buttonScheduleActivity`).disabled,
+ "schedule activity should be disabled"
+ );
+ assert.ok(
+ document.querySelector(`.o_ChatterTopbar_buttonAttachments`).disabled,
+ "attachments button should be disabled"
+ );
+ assert.strictEqual(
+ document.querySelectorAll(`.o_ChatterTopbar_buttonAttachmentsCountLoader`).length,
+ 0,
+ "attachments button should not have a loader"
+ );
+ assert.strictEqual(
+ document.querySelectorAll(`.o_ChatterTopbar_buttonAttachmentsCount`).length,
+ 1,
+ "attachments button should have a counter"
+ );
+ assert.strictEqual(
+ document.querySelector(`.o_ChatterTopbar_buttonAttachmentsCount`).textContent,
+ '0',
+ "attachments button counter should be 0"
+ );
+});
+
+QUnit.test('attachment loading is delayed', async function (assert) {
+ assert.expect(4);
+
+ await this.start({
+ hasTimeControl: true,
+ loadingBaseDelayDuration: 100,
+ async mockRPC(route) {
+ if (route.includes('ir.attachment/search_read')) {
+ return new Promise(() => {}); // simulate long loading
+ }
+ return this._super(...arguments);
+ }
+ });
+ const chatter = this.env.models['mail.chatter'].create({
+ threadId: 100,
+ threadModel: 'res.partner',
+ });
+ await this.createChatterTopbarComponent(chatter);
+
+ assert.strictEqual(
+ document.querySelectorAll(`.o_ChatterTopbar`).length,
+ 1,
+ "should have a chatter topbar"
+ );
+ assert.strictEqual(
+ document.querySelectorAll(`.o_ChatterTopbar_buttonAttachments`).length,
+ 1,
+ "should have an attachments button in chatter menu"
+ );
+ assert.strictEqual(
+ document.querySelectorAll(`.o_ChatterTopbar_buttonAttachmentsCountLoader`).length,
+ 0,
+ "attachments button should not have a loader yet"
+ );
+
+ await afterNextRender(async () => this.env.testUtils.advanceTime(100));
+ assert.strictEqual(
+ document.querySelectorAll(`.o_ChatterTopbar_buttonAttachmentsCountLoader`).length,
+ 1,
+ "attachments button should now have a loader"
+ );
+});
+
+QUnit.test('attachment counter while loading attachments', async function (assert) {
+ assert.expect(4);
+
+ await this.start({
+ async mockRPC(route) {
+ if (route.includes('ir.attachment/search_read')) {
+ return new Promise(() => {}); // simulate long loading
+ }
+ return this._super(...arguments);
+ }
+ });
+ const chatter = this.env.models['mail.chatter'].create({
+ threadId: 100,
+ threadModel: 'res.partner',
+ });
+ await this.createChatterTopbarComponent(chatter);
+
+ assert.strictEqual(
+ document.querySelectorAll(`.o_ChatterTopbar`).length,
+ 1,
+ "should have a chatter topbar"
+ );
+ assert.strictEqual(
+ document.querySelectorAll(`.o_ChatterTopbar_buttonAttachments`).length,
+ 1,
+ "should have an attachments button in chatter menu"
+ );
+ assert.strictEqual(
+ document.querySelectorAll(`.o_ChatterTopbar_buttonAttachmentsCountLoader`).length,
+ 1,
+ "attachments button should have a loader"
+ );
+ assert.strictEqual(
+ document.querySelectorAll(`.o_ChatterTopbar_buttonAttachmentsCount`).length,
+ 0,
+ "attachments button should not have a counter"
+ );
+});
+
+QUnit.test('attachment counter transition when attachments become loaded)', async function (assert) {
+ assert.expect(7);
+
+ const attachmentPromise = makeTestPromise();
+ await this.start({
+ async mockRPC(route) {
+ if (route.includes('ir.attachment/search_read')) {
+ await attachmentPromise;
+ return [];
+ }
+ return this._super(...arguments);
+ }
+ });
+ const chatter = this.env.models['mail.chatter'].create({
+ threadId: 100,
+ threadModel: 'res.partner',
+ });
+ await this.createChatterTopbarComponent(chatter);
+
+ assert.strictEqual(
+ document.querySelectorAll(`.o_ChatterTopbar`).length,
+ 1,
+ "should have a chatter topbar"
+ );
+ assert.strictEqual(
+ document.querySelectorAll(`.o_ChatterTopbar_buttonAttachments`).length,
+ 1,
+ "should have an attachments button in chatter menu"
+ );
+ assert.strictEqual(
+ document.querySelectorAll(`.o_ChatterTopbar_buttonAttachmentsCountLoader`).length,
+ 1,
+ "attachments button should have a loader"
+ );
+ assert.strictEqual(
+ document.querySelectorAll(`.o_ChatterTopbar_buttonAttachmentsCount`).length,
+ 0,
+ "attachments button should not have a counter"
+ );
+
+ await afterNextRender(() => attachmentPromise.resolve());
+ assert.strictEqual(
+ document.querySelectorAll(`.o_ChatterTopbar_buttonAttachments`).length,
+ 1,
+ "should have an attachments button in chatter menu"
+ );
+ assert.strictEqual(
+ document.querySelectorAll(`.o_ChatterTopbar_buttonAttachmentsCountLoader`).length,
+ 0,
+ "attachments button should not have a loader"
+ );
+ assert.strictEqual(
+ document.querySelectorAll(`.o_ChatterTopbar_buttonAttachmentsCount`).length,
+ 1,
+ "attachments button should have a counter"
+ );
+});
+
+QUnit.test('attachment counter without attachments', async function (assert) {
+ assert.expect(4);
+
+ await this.start();
+ const chatter = this.env.models['mail.chatter'].create({
+ threadId: 100,
+ threadModel: 'res.partner',
+ });
+ await this.createChatterTopbarComponent(chatter);
+
+ assert.strictEqual(
+ document.querySelectorAll(`.o_ChatterTopbar`).length,
+ 1,
+ "should have a chatter topbar"
+ );
+ assert.strictEqual(
+ document.querySelectorAll(`.o_ChatterTopbar_buttonAttachments`).length,
+ 1,
+ "should have an attachments button in chatter menu"
+ );
+ assert.strictEqual(
+ document.querySelectorAll(`.o_ChatterTopbar_buttonAttachmentsCount`).length,
+ 1,
+ "attachments button should have a counter"
+ );
+ assert.strictEqual(
+ document.querySelector(`.o_ChatterTopbar_buttonAttachmentsCount`).textContent,
+ '0',
+ 'attachment counter should contain "0"'
+ );
+});
+
+QUnit.test('attachment counter with attachments', async function (assert) {
+ assert.expect(4);
+
+ await this.start({
+ async mockRPC(route) {
+ if (route.includes('ir.attachment/search_read')) {
+ return [{
+ id: 143,
+ filename: 'Blah.txt',
+ mimetype: 'text/plain',
+ name: 'Blah.txt'
+ }, {
+ id: 144,
+ filename: 'Blu.txt',
+ mimetype: 'text/plain',
+ name: 'Blu.txt'
+ }];
+ }
+ return this._super(...arguments);
+ }
+ });
+ const chatter = this.env.models['mail.chatter'].create({
+ threadId: 100,
+ threadModel: 'res.partner',
+ });
+ await this.createChatterTopbarComponent(chatter);
+
+ assert.strictEqual(
+ document.querySelectorAll(`.o_ChatterTopbar`).length,
+ 1,
+ "should have a chatter topbar"
+ );
+ assert.strictEqual(
+ document.querySelectorAll(`.o_ChatterTopbar_buttonAttachments`).length,
+ 1,
+ "should have an attachments button in chatter menu"
+ );
+ assert.strictEqual(
+ document.querySelectorAll(`.o_ChatterTopbar_buttonAttachmentsCount`).length,
+ 1,
+ "attachments button should have a counter"
+ );
+ assert.strictEqual(
+ document.querySelector(`.o_ChatterTopbar_buttonAttachmentsCount`).textContent,
+ '2',
+ 'attachment counter should contain "2"'
+ );
+});
+
+QUnit.test('composer state conserved when clicking on another topbar button', async function (assert) {
+ assert.expect(8);
+
+ await this.start();
+ const chatter = this.env.models['mail.chatter'].create({
+ threadId: 100,
+ threadModel: 'res.partner',
+ });
+ await this.createChatterTopbarComponent(chatter);
+
+ assert.containsOnce(
+ document.body,
+ `.o_ChatterTopbar`,
+ "should have a chatter topbar"
+ );
+ assert.containsOnce(
+ document.body,
+ `.o_ChatterTopbar_buttonSendMessage`,
+ "should have a send message button in chatter menu"
+ );
+ assert.containsOnce(
+ document.body,
+ `.o_ChatterTopbar_buttonLogNote`,
+ "should have a log note button in chatter menu"
+ );
+ assert.containsOnce(
+ document.body,
+ `.o_ChatterTopbar_buttonAttachments`,
+ "should have an attachments button in chatter menu"
+ );
+
+ await afterNextRender(() => {
+ document.querySelector(`.o_ChatterTopbar_buttonLogNote`).click();
+ });
+ assert.containsOnce(
+ document.body,
+ `.o_ChatterTopbar_buttonLogNote.o-active`,
+ "log button should now be active"
+ );
+ assert.containsNone(
+ document.body,
+ `.o_ChatterTopbar_buttonSendMessage.o-active`,
+ "send message button should not be active"
+ );
+
+ await afterNextRender(() => {
+ document.querySelector(`.o_ChatterTopbar_buttonAttachments`).click();
+ });
+ assert.containsOnce(
+ document.body,
+ `.o_ChatterTopbar_buttonLogNote.o-active`,
+ "log button should still be active"
+ );
+ assert.containsNone(
+ document.body,
+ `.o_ChatterTopbar_buttonSendMessage.o-active`,
+ "send message button should still be not active"
+ );
+});
+
+QUnit.test('rendering with multiple partner followers', async function (assert) {
+ assert.expect(7);
+
+ await this.start();
+ this.data['res.partner'].records = [{
+ id: 100,
+ message_follower_ids: [1, 2],
+ }];
+ this.data['mail.followers'].records = [
+ {
+ // simulate real return from RPC
+ // (the presence of the key and the falsy value need to be handled correctly)
+ channel_id: false,
+ id: 1,
+ name: "Jean Michang",
+ partner_id: 12,
+ }, {
+ // simulate real return from RPC
+ // (the presence of the key and the falsy value need to be handled correctly)
+ channel_id: false,
+ id: 2,
+ name: "Eden Hazard",
+ partner_id: 11,
+ },
+ ];
+ const chatter = this.env.models['mail.chatter'].create({
+ followerIds: [1, 2],
+ threadId: 100,
+ threadModel: 'res.partner',
+ });
+ await this.createChatterTopbarComponent(chatter);
+
+ assert.containsOnce(
+ document.body,
+ '.o_FollowerListMenu',
+ "should have followers menu component"
+ );
+ assert.containsOnce(
+ document.body,
+ '.o_FollowerListMenu_buttonFollowers',
+ "should have followers button"
+ );
+
+ await afterNextRender(() => {
+ document.querySelector('.o_FollowerListMenu_buttonFollowers').click();
+ });
+ assert.containsOnce(
+ document.body,
+ '.o_FollowerListMenu_dropdown',
+ "followers dropdown should be opened"
+ );
+ assert.containsN(
+ document.body,
+ '.o_Follower',
+ 2,
+ "exactly two followers should be listed"
+ );
+ assert.containsN(
+ document.body,
+ '.o_Follower_name',
+ 2,
+ "exactly two follower names should be listed"
+ );
+ assert.strictEqual(
+ document.querySelectorAll('.o_Follower_name')[0].textContent.trim(),
+ "Jean Michang",
+ "first follower is 'Jean Michang'"
+ );
+ assert.strictEqual(
+ document.querySelectorAll('.o_Follower_name')[1].textContent.trim(),
+ "Eden Hazard",
+ "second follower is 'Eden Hazard'"
+ );
+});
+
+QUnit.test('rendering with multiple channel followers', async function (assert) {
+ assert.expect(7);
+
+ this.data['res.partner'].records = [{
+ id: 100,
+ message_follower_ids: [1, 2],
+ }];
+ await this.start();
+ this.data['mail.followers'].records = [
+ {
+ channel_id: 11,
+ id: 1,
+ name: "channel numero 5",
+ // simulate real return from RPC
+ // (the presence of the key and the falsy value need to be handled correctly)
+ partner_id: false,
+ }, {
+ channel_id: 12,
+ id: 2,
+ name: "channel armstrong",
+ // simulate real return from RPC
+ // (the presence of the key and the falsy value need to be handled correctly)
+ partner_id: false,
+ },
+ ];
+ const chatter = this.env.models['mail.chatter'].create({
+ followerIds: [1, 2],
+ threadId: 100,
+ threadModel: 'res.partner',
+ });
+ await this.createChatterTopbarComponent(chatter);
+
+ assert.containsOnce(
+ document.body,
+ '.o_FollowerListMenu',
+ "should have followers menu component"
+ );
+ assert.containsOnce(
+ document.body,
+ '.o_FollowerListMenu_buttonFollowers',
+ "should have followers button"
+ );
+
+ await afterNextRender(() => {
+ document.querySelector('.o_FollowerListMenu_buttonFollowers').click();
+ });
+ assert.containsOnce(
+ document.body,
+ '.o_FollowerListMenu_dropdown',
+ "followers dropdown should be opened"
+ );
+ assert.containsN(
+ document.body,
+ '.o_Follower',
+ 2,
+ "exactly two followers should be listed"
+ );
+ assert.containsN(
+ document.body,
+ '.o_Follower_name',
+ 2,
+ "exactly two follower names should be listed"
+ );
+ assert.strictEqual(
+ document.querySelectorAll('.o_Follower_name')[0].textContent.trim(),
+ "channel numero 5",
+ "first follower is 'channel numero 5'"
+ );
+ assert.strictEqual(
+ document.querySelectorAll('.o_Follower_name')[1].textContent.trim(),
+ "channel armstrong",
+ "second follower is 'channel armstrong'"
+ );
+});
+
+});
+});
+});
+
+});
diff --git a/addons/mail/static/src/components/composer/composer.js b/addons/mail/static/src/components/composer/composer.js
new file mode 100644
index 00000000000..f2135835a4d
--- /dev/null
+++ b/addons/mail/static/src/components/composer/composer.js
@@ -0,0 +1,393 @@
+odoo.define('mail/static/src/components/composer/composer.js', function (require) {
+'use strict';
+
+const components = {
+ AttachmentList: require('mail/static/src/components/attachment_list/attachment_list.js'),
+ DropZone: require('mail/static/src/components/drop_zone/drop_zone.js'),
+ EmojisPopover: require('mail/static/src/components/emojis_popover/emojis_popover.js'),
+ FileUploader: require('mail/static/src/components/file_uploader/file_uploader.js'),
+ TextInput: require('mail/static/src/components/composer_text_input/composer_text_input.js'),
+ ThreadTextualTypingStatus: require('mail/static/src/components/thread_textual_typing_status/thread_textual_typing_status.js'),
+};
+const useDragVisibleDropZone = require('mail/static/src/component_hooks/use_drag_visible_dropzone/use_drag_visible_dropzone.js');
+const useStore = require('mail/static/src/component_hooks/use_store/use_store.js');
+const mailUtils = require('mail.utils');
+
+const { Component } = owl;
+const { useRef } = owl.hooks;
+
+class Composer extends Component {
+
+ /**
+ * @override
+ */
+ constructor(...args) {
+ super(...args);
+ this.isDropZoneVisible = useDragVisibleDropZone();
+ useStore(props => {
+ const composer = this.env.models['mail.composer'].get(props.composerLocalId);
+ return {
+ composer: composer ? composer.__state : undefined,
+ isDeviceMobile: this.env.messaging.device.isMobile,
+ thread: composer && composer.thread
+ ? composer.thread.__state
+ : undefined,
+ };
+ });
+ /**
+ * Reference of the emoji button. Useful to include emoji popover as
+ * click "inside" the composer for the prop `isDiscardOnClickAway`.
+ */
+ this._emojisPopoverRef = useRef('emojisPopover');
+ /**
+ * Reference of the file uploader.
+ * Useful to programmatically prompts the browser file uploader.
+ */
+ this._fileUploaderRef = useRef('fileUploader');
+ /**
+ * Reference of the text input component.
+ */
+ this._textInputRef = useRef('textInput');
+ /**
+ * Tracked focus counter from props. Useful to determine whether it
+ * should auto focus this composer when patched.
+ */
+ this._focusCount = 0;
+ // to focus if the prop changes
+ this._lastComposer = undefined;
+ this._onClickCaptureGlobal = this._onClickCaptureGlobal.bind(this);
+ }
+
+ mounted() {
+ this._lastComposer = this.composer;
+ if (this.props.isFocusOnMount) {
+ this.focus();
+ }
+ document.addEventListener('click', this._onClickCaptureGlobal, true);
+ }
+
+ patched() {
+ // focus when changing composer
+ if (
+ this.props.isFocusOnMount &&
+ this._lastComposer !== this.composer
+ ) {
+ this.focus();
+ }
+ this._lastComposer = this.composer;
+ }
+
+ willUnmount() {
+ document.removeEventListener('click', this._onClickCaptureGlobal, true);
+ }
+
+ //--------------------------------------------------------------------------
+ // Public
+ //--------------------------------------------------------------------------
+
+ /**
+ * @returns {mail.composer}
+ */
+ get composer() {
+ return this.env.models['mail.composer'].get(this.props.composerLocalId);
+ }
+
+ /**
+ * Get the current partner image URL.
+ *
+ * @returns {string}
+ */
+ get currentPartnerAvatar() {
+ const avatar = this.env.session.uid > 0
+ ? this.env.session.url('/web/image', {
+ field: 'image_128',
+ id: this.env.session.uid,
+ model: 'res.users',
+ })
+ : '/web/static/src/img/user_menu_avatar.png';
+ return avatar;
+ }
+
+ /**
+ * Focus the composer.
+ */
+ focus() {
+ if (this.env.messaging.device.isMobile) {
+ this.el.scrollIntoView();
+ }
+ this.composer.focus();
+ }
+
+ /**
+ * Focusout the composer.
+ */
+ focusout() {
+ this._textInputRef.comp.focusout();
+ }
+
+ /**
+ * Determine whether composer should display a footer.
+ *
+ * @returns {boolean}
+ */
+ get hasFooter() {
+ return (
+ this.props.hasThreadTyping ||
+ this.composer.attachments.length > 0 ||
+ !this.props.isCompact
+ );
+ }
+
+ /**
+ * Determine whether the composer should display a header.
+ *
+ * @returns {boolean}
+ */
+ get hasHeader() {
+ return (
+ (this.props.hasThreadName && this.composer.thread) ||
+ (this.props.hasFollowers && !this.props.isLog)
+ );
+ }
+
+ /**
+ * Get an object which is passed to FileUploader component to be used when
+ * creating attachment.
+ *
+ * @returns {Object}
+ */
+ get newAttachmentExtraData() {
+ return {
+ composers: [['replace', this.composer]],
+ };
+ }
+
+ //--------------------------------------------------------------------------
+ // Private
+ //--------------------------------------------------------------------------
+
+ /**
+ * Post a message in the composer on related thread.
+ *
+ * @private
+ */
+ async _postMessage() {
+ // TODO: take suggested recipients into account
+ await this.composer.postMessage({ isLog: this.props.isLog });
+ // TODO: we might need to remove trigger and use the store to wait for the post rpc to be done
+ this.trigger('o-message-posted');
+ }
+
+ //--------------------------------------------------------------------------
+ // Handlers
+ //--------------------------------------------------------------------------
+
+ /**
+ * Called when clicking on attachment button.
+ *
+ * @private
+ */
+ _onClickAddAttachment() {
+ this._fileUploaderRef.comp.openBrowserFileUploader();
+ this.focus();
+ }
+
+ /**
+ * Discards the composer when clicking away from the Inbox reply in discuss.
+ * TODO maybe move this in discuss. task-2092950
+ *
+ * @private
+ * @param {MouseEvent} ev
+ */
+ _onClickCaptureGlobal(ev) {
+ // in discuss app: prevents discarding the composer when clicking away
+ if (!this.props.isDiscardOnClickAway) {
+ return;
+ }
+ // prevents discarding when clicking on self (on discuss and not on discuss)
+ if (this.el.contains(ev.target)) {
+ return;
+ }
+ // emoji popover is outside but should be considered inside
+ if (this._emojisPopoverRef.comp.isInsideEventTarget(ev.target)) {
+ return;
+ }
+ this.composer.discard();
+ }
+
+ /**
+ * Called when clicking on "expand" button.
+ *
+ * @private
+ */
+ async _onClickFullComposer() {
+ const attachmentIds = this.composer.attachments.map(attachment => attachment.res_id);
+
+ const context = {
+ // default_parent_id: this.id,
+ default_body: mailUtils.escapeAndCompactTextContent(this.composer.textInputContent),
+ default_attachment_ids: attachmentIds,
+ // default_partner_ids: partnerIds,
+ default_is_log: this.props.isLog,
+ mail_post_autofollow: true,
+ };
+
+ // if (this.context.default_model && this.context.default_res_id) {
+ // context.default_model = this.context.default_model;
+ // context.default_res_id = this.context.default_res_id;
+ // }
+
+ const action = {
+ type: 'ir.actions.act_window',
+ res_model: 'mail.compose.message',
+ view_mode: 'form',
+ views: [[false, 'form']],
+ target: 'new',
+ context: context,
+ };
+ await this.env.bus.trigger('do-action', { action });
+ this.trigger('o-full-composer-opened');
+ }
+
+ /**
+ * Called when clicking on "discard" button.
+ *
+ * @private
+ * @param {MouseEvent} ev
+ */
+ _onClickDiscard(ev) {
+ this.composer.discard();
+ }
+
+ /**
+ * Called when clicking on "send" button.
+ *
+ * @private
+ */
+ _onClickSend() {
+ if (!this.composer.canPostMessage) {
+ return;
+ }
+ this._postMessage();
+ }
+
+ /**
+ * Called when some files have been dropped in the dropzone.
+ *
+ * @private
+ * @param {CustomEvent} ev
+ * @param {Object} ev.detail
+ * @param {FileList} ev.detail.files
+ */
+ async _onDropZoneFilesDropped(ev) {
+ ev.stopPropagation();
+ await this._fileUploaderRef.comp.uploadFiles(ev.detail.files);
+ this.isDropZoneVisible.value = false;
+ }
+
+ /**
+ * Called when selection an emoji from the emoji popover (from the emoji
+ * button).
+ *
+ * @private
+ * @param {CustomEvent} ev
+ * @param {Object} ev.detail
+ * @param {string} ev.detail.unicode
+ */
+ _onEmojiSelection(ev) {
+ ev.stopPropagation();
+ this._textInputRef.comp.saveStateInStore();
+ this.composer.insertIntoTextInput(ev.detail.unicode);
+ this.composer.focus();
+ }
+
+ /**
+ * @private
+ * @param {CustomEvent} ev
+ */
+ async _onPasteTextInput(ev) {
+ if (!ev.clipboardData || !ev.clipboardData.files) {
+ return;
+ }
+ await this._fileUploaderRef.comp.uploadFiles(ev.clipboardData.files);
+ }
+
+ /**
+ * @private
+ */
+ _onTextInputKeydownEnter() {
+ if (!this.composer.canPostMessage) {
+ return;
+ }
+ this._postMessage();
+ }
+
+}
+
+Object.assign(Composer, {
+ components,
+ defaultProps: {
+ attachmentLocalIds: [],
+ hasCurrentPartnerAvatar: true,
+ hasDiscardButton: false,
+ hasFollowers: false,
+ hasSendButton: true,
+ hasTextInputSendOnEnterEnabled: true,
+ hasThreadName: false,
+ hasThreadTyping: false,
+ isCompact: true,
+ isDiscardOnClickAway: false,
+ isExpandable: false,
+ isFocusOnMount: false,
+ isLog: false,
+ },
+ props: {
+ attachmentLocalIds: {
+ type: Array,
+ element: String,
+ },
+ attachmentsDetailsMode: {
+ type: String,
+ optional: true,
+ },
+ composerLocalId: String,
+ hasCurrentPartnerAvatar: Boolean,
+ hasDiscardButton: Boolean,
+ hasFollowers: Boolean,
+ hasMentionSuggestionsBelowPosition: {
+ type: Boolean,
+ optional: true,
+ },
+ hasSendButton: Boolean,
+ hasTextInputSendOnEnterEnabled: Boolean,
+ hasThreadName: Boolean,
+ hasThreadTyping: Boolean,
+ showAttachmentsExtensions: {
+ type: Boolean,
+ optional: true,
+ },
+ showAttachmentsFilenames: {
+ type: Boolean,
+ optional: true,
+ },
+ initialAttachmentLocalIds: {
+ type: Array,
+ element: String,
+ optional: true,
+ },
+ initialTextInputHtmlContent: {
+ type: String,
+ optional: true,
+ },
+ isCompact: Boolean,
+ isDiscardOnClickAway: Boolean,
+ isExpandable: Boolean,
+ isFocusOnMount: Boolean,
+ isLog: Boolean,
+ },
+ template: 'mail.Composer',
+});
+
+return Composer;
+
+});
diff --git a/addons/mail/static/src/components/composer/composer.scss b/addons/mail/static/src/components/composer/composer.scss
new file mode 100644
index 00000000000..98557ec1ce9
--- /dev/null
+++ b/addons/mail/static/src/components/composer/composer.scss
@@ -0,0 +1,278 @@
+// ------------------------------------------------------------------
+// Layout
+// ------------------------------------------------------------------
+
+.o_Composer {
+ display: grid;
+ grid-template-areas:
+ "sidebar-header core-header"
+ "sidebar-main core-main"
+ "sidebar-footer core-footer";
+ grid-template-columns: auto 1fr;
+ grid-template-rows: auto 1fr auto;
+
+ &.o-has-current-partner-avatar {
+ grid-template-columns: 50px 1fr;
+ padding: map-get($spacers, 0) map-get($spacers, 3) map-get($spacers, 1) map-get($spacers, 1);
+
+ &:not(.o-has-footer) {
+ padding-bottom: 20px;
+ }
+
+ &:not(.o-has-header) {
+ padding-top: 20px;
+ }
+ }
+}
+
+.o_Composer_actionButtons {
+ &.o-composer-is-compact {
+ display: flex;
+ }
+ &:not(.o-composer-is-compact) {
+ margin-top: 10px;
+ }
+}
+
+.o_Composer_attachmentList {
+ flex: 1 1 auto;
+}
+
+.o_Composer_buttons {
+ display: flex;
+ align-items: stretch;
+ flex: 0 0 auto;
+ height: 41px; // match minimal-height of input, including border width
+
+ &:not(.o-composer-is-compact) {
+ border: 0;
+ height: auto;
+ padding: 0 10px;
+ width: 100%;
+ }
+
+ &.o-mobile {
+ height: 51px; // mobile padding
+ }
+}
+
+.o_Composer_coreFooter {
+ grid-area: core-footer;
+
+ &:not(.o-composer-is-compact) {
+ margin-left: 0;
+ }
+}
+
+.o_Composer_coreHeader {
+ grid-area: core-header;
+}
+
+.o_Composer_coreMain {
+ grid-area: core-main;
+ min-width: 0;
+ display: flex;
+ flex-wrap: nowrap;
+ align-items: flex-start;
+ flex: 1 1 auto;
+
+ &:not(.o-composer-is-compact) {
+ flex-direction: column;
+ }
+}
+
+.o_Composer_currentPartnerAvatar {
+ width: 36px;
+ height: 36px;
+}
+
+.o_Composer_followers,
+.o_Composer_suggestedPartners {
+ flex: 0 0 100%;
+ margin-bottom: $o-mail-chatter-gap * 0.5;
+}
+
+.o_Composer_primaryToolButtons {
+ display: flex;
+ align-items: center;
+}
+
+.o_Composer_sidebarMain {
+ grid-area: sidebar-main;
+ justify-self: center;
+}
+
+.o_Composer_textInput {
+ flex: 1 1 auto;
+ align-self: stretch;
+
+ &:not(.o-composer-is-compact) {
+ border: 0;
+ min-height: 40px;
+
+ .note-editable {
+ min-height: 40px;
+ }
+ }
+}
+
+.o_Composer_threadTextualTypingStatus {
+ font-size: $font-size-sm;
+ overflow: hidden;
+ text-overflow: ellipsis;
+
+ &:before {
+ // invisible character so that typing status bar has constant height, regardless of text content.
+ content: "\200b"; /* unicode zero width space character */
+ }
+}
+
+.o_Composer_toolButton {
+ margin: 0 2px;
+
+ &:not(.o-mobile) {
+ margin-top: 2px;
+ margin-bottom: 2px;
+ }
+}
+
+.o_Composer_toolButtons {
+ display: flex;
+
+ &:not(.o-composer-is-compact) {
+ flex-direction: row;
+ justify-content: space-between;
+ flex: 100%;
+ }
+
+ &:not(:last-child) .o_Composer_toolButton:last-child {
+ margin-right: 5px;
+ }
+}
+
+.o_Composer_toolButtonSeparator {
+ flex: 0 0 auto;
+ margin-top: 5px;
+ margin-bottom: 5px;
+ margin-right: 5px;
+}
+
+// ------------------------------------------------------------------
+// Style
+// ------------------------------------------------------------------
+
+.o_Composer {
+ background-color: lighten(gray('300'), 7%);
+
+ &.o-has-current-partner-avatar {
+
+ .o_Composer_actionButtons.o-composer-is-compact > *:last-child {
+ border-radius: 0 10px 10px 0;
+ }
+
+ .o_Composer_textInput {
+ border-radius: 10px 0 0 10px;
+
+ > .note-editor {
+ border-radius: 10px 0 0 10px;
+
+ > .note-editing-area {
+ border-radius: 10px 0 0 10px;
+ }
+ }
+ }
+ }
+}
+
+.o_Composer_button {
+ color: gray('600');
+
+ &.o-open{
+ background-color: gray('200');
+ }
+
+ &.o-composer-is-compact {
+ border-left: none;
+ border-radius: 0 3px 3px 0;
+ }
+}
+
+// TODO FIXME o-open on the button should be enough, https://github.com/odoo/owl/issues/693
+.o_is_open {
+ .o_Composer_button {
+ background-color: gray('200');
+ }
+}
+
+.o_Composer_buttonDiscard {
+ border: 1px solid lighten(gray('400'), 5%);
+}
+
+.o_Composer_buttons {
+ border: 0;
+}
+
+.o_Composer_buttonSend {
+ border: 1px solid darken($o-brand-primary, 5%);
+
+ &:disabled {
+ // enforce this style (override jQuery style)
+ border: 1px solid darken($o-brand-primary, 5%);
+
+ &.o-composer-is-compact {
+ border-left: none;
+ }
+ }
+
+ &:not(:last-child) {
+ border-right: none;
+ }
+}
+
+.o_Composer_coreMain:not(.o-composer-is-compact) {
+ background: white;
+ border-radius: 5px;
+ border: 1px solid lighten(gray('400'), 5%);
+}
+
+.o_Composer_textInput {
+ appearance: none;
+ outline: none;
+ background-color: white;
+ border: 0;
+ border-top: 1px solid lighten(gray('400'), 5%);
+ border-bottom: 1px solid lighten(gray('400'), 5%);
+ border-left: 1px solid lighten(gray('400'), 5%);
+
+ &:not(.o-composer-is-compact) {
+ border: 0;
+ }
+}
+
+.o_Composer_toolButton {
+ border: 0;
+
+ &:hover {
+ // enforce this style (webclient btn-secondary)
+ border: 0;
+ }
+}
+
+.o_Composer_toolButtons {
+ background-color: white;
+ border-top: 1px solid lighten(gray('400'), 5%);
+ border-bottom: 1px solid lighten(gray('400'), 5%);
+
+ &:not(.o-composer-is-compact) {
+ border-bottom: 0;
+ border-radius: initial;
+ }
+
+ &:last-child:not(.o-composer-has-current-partner-avatar) {
+ border-right: 1px solid lighten(gray('400'), 5%);
+ }
+}
+
+.o_Composer_toolButtonSeparator {
+ border-left: 1px solid lighten(gray('400'), 5%);
+}
diff --git a/addons/mail/static/src/components/composer/composer.xml b/addons/mail/static/src/components/composer/composer.xml
new file mode 100644
index 00000000000..055c3837ccf
--- /dev/null
+++ b/addons/mail/static/src/components/composer/composer.xml
@@ -0,0 +1,154 @@
+<?xml version="1.0" encoding="UTF-8"?>
+<templates xml:space="preserve">
+
+ <t t-name="mail.Composer" owl="1">
+ <div class="o_Composer"
+ t-att-class="{
+ 'o-has-current-partner-avatar': props.hasCurrentPartnerAvatar,
+ 'o-has-footer': hasFooter,
+ 'o-has-header': hasHeader,
+ 'o-is-compact': props.isCompact,
+ }"
+ >
+ <t t-if="composer">
+ <t t-if="isDropZoneVisible.value">
+ <DropZone
+ class="o_Composer_dropZone"
+ t-on-o-dropzone-files-dropped="_onDropZoneFilesDropped"
+ t-ref="dropzone"
+ />
+ </t>
+ <FileUploader
+ attachmentLocalIds="composer.attachments.map(attachment => attachment.localId)"
+ newAttachmentExtraData="newAttachmentExtraData"
+ t-ref="fileUploader"
+ />
+ <t t-if="hasHeader">
+ <div class="o_Composer_coreHeader">
+ <t t-if="props.hasThreadName and composer.thread">
+ <span class="o_Composer_threadName">
+ on: <b><t t-esc="composer.thread.displayName"/></b>
+ </span>
+ </t>
+ <t t-if="props.hasFollowers and !props.isLog">
+ <!-- Text for followers -->
+ <small class="o_Composer_followers">
+ <b class="text-muted">To: </b>
+ <em class="text-muted">Followers of </em>
+ <b>
+ <t t-if="composer.thread">
+ &#32;&quot;<t t-esc="composer.thread.name"/>&quot;
+ </t>
+ <t t-else="">
+ this document
+ </t>
+ </b>
+ </small>
+ </t>
+ </div>
+ </t>
+ <t t-if="props.hasCurrentPartnerAvatar">
+ <div class="o_Composer_sidebarMain">
+ <img class="o_Composer_currentPartnerAvatar rounded-circle" t-att-src="currentPartnerAvatar" alt=""/>
+ </div>
+ </t>
+ <div class="o_Composer_coreMain" t-att-class="{ 'o-composer-is-compact': props.isCompact }">
+ <TextInput
+ class="o_Composer_textInput"
+ t-att-class="{
+ 'o-composer-is-compact': props.isCompact,
+ 'o_Composer_textInput-mobile': env.messaging.device.isMobile,
+ }"
+ composerLocalId="composer.localId"
+ hasMentionSuggestionsBelowPosition="props.hasMentionSuggestionsBelowPosition"
+ hasSendOnEnterEnabled="props.hasTextInputSendOnEnterEnabled"
+ t-on-o-keydown-enter="_onTextInputKeydownEnter"
+ t-on-paste="_onPasteTextInput"
+ t-ref="textInput"
+ />
+ <div class="o_Composer_buttons" t-att-class="{ 'o-composer-is-compact': props.isCompact, 'o-mobile': env.messaging.device.isMobile }">
+ <div class="o_Composer_toolButtons"
+ t-att-class="{
+ 'o-composer-has-current-partner-avatar': props.hasCurrentPartnerAvatar,
+ 'o-composer-is-compact': props.isCompact,
+ }">
+ <t t-if="props.isCompact">
+ <div class="o_Composer_toolButtonSeparator"/>
+ </t>
+ <div class="o_Composer_primaryToolButtons">
+ <Popover position="'top'" t-on-o-emoji-selection="_onEmojiSelection" t-ref="emojisPopover">
+ <!-- TODO FIXME o-open not possible to code due to https://github.com/odoo/owl/issues/693 -->
+ <button class="o_Composer_button o_Composer_buttonEmojis o_Composer_toolButton btn btn-secondary"
+ t-att-class="{
+ 'o-open': false and state.displayed,
+ 'o-mobile': env.messaging.device.isMobile,
+ }"
+ >
+ <i class="fa fa-smile-o"/>
+ </button>
+ <t t-set="opened">
+ <EmojisPopover/>
+ </t>
+ </Popover>
+ <button class="o_Composer_button o_Composer_buttonAttachment o_Composer_toolButton btn btn-secondary fa fa-paperclip" t-att-class="{ 'o-mobile': env.messaging.device.isMobile }" title="Add attachment" type="button" t-on-click="_onClickAddAttachment"/>
+ </div>
+ <t t-if="props.isExpandable">
+ <div class="o_Composer_secondaryToolButtons">
+ <button class="btn btn-secondary fa fa-expand o_Composer_button o_Composer_buttonFullComposer o_Composer_toolButton" t-att-class="{ 'o-mobile': env.messaging.device.isMobile }" title="Full composer" type="button" t-on-click="_onClickFullComposer"/>
+ </div>
+ </t>
+ </div>
+ <t t-if="props.isCompact">
+ <t t-call="mail.Composer.actionButtons"/>
+ </t>
+ </div>
+ </div>
+ <t t-if="hasFooter">
+ <div class="o_Composer_coreFooter" t-att-class="{ 'o-composer-is-compact': props.isCompact }">
+ <t t-if="props.hasThreadTyping">
+ <ThreadTextualTypingStatus class="o_Composer_threadTextualTypingStatus" threadLocalId="composer.thread.localId"/>
+ </t>
+ <t t-if="composer.attachments.length > 0">
+ <AttachmentList
+ class="o_Composer_attachmentList"
+ areAttachmentsEditable="true"
+ attachmentsDetailsMode="props.attachmentsDetailsMode"
+ attachmentLocalIds="composer.attachments.map(attachment => attachment.localId)"
+ showAttachmentsExtensions="props.showAttachmentsExtensions"
+ showAttachmentsFilenames="props.showAttachmentsFilenames"
+ />
+ </t>
+ <t t-if="!props.isCompact">
+ <t t-call="mail.Composer.actionButtons"/>
+ </t>
+ </div>
+ </t>
+ </t>
+ </div>
+ </t>
+
+ <t t-name="mail.Composer.actionButtons" owl="1">
+ <div class="o_Composer_actionButtons" t-att-class="{ 'o-composer-is-compact': props.isCompact }">
+ <t t-if="props.hasSendButton">
+ <button class="o_Composer_actionButton o_Composer_button o_Composer_buttonSend btn btn-primary"
+ t-att-class="{
+ 'fa': env.messaging.device.isMobile,
+ 'fa-paper-plane-o': env.messaging.device.isMobile,
+ 'o-composer-is-compact': props.isCompact,
+ }"
+ t-att-disabled="(!composer.textInputContent and composer.attachments.length === 0) ? 'disabled' : ''"
+ type="button"
+ t-on-click="_onClickSend"
+ >
+ <t t-if="!env.messaging.device.isMobile"><t t-if="props.isLog">Log</t><t t-else="">Send</t></t>
+ </button>
+ </t>
+ <t t-if="!env.messaging.device.isMobile and props.hasDiscardButton">
+ <button class="o_Composer_actionButton o_Composer_button o_Composer_buttonDiscard btn btn-secondary" t-att-class="{ 'o-composer-is-compact': props.isCompact }" type="button" t-on-click="_onClickDiscard">
+ Discard
+ </button>
+ </t>
+ </div>
+ </t>
+
+</templates>
diff --git a/addons/mail/static/src/components/composer/composer_tests.js b/addons/mail/static/src/components/composer/composer_tests.js
new file mode 100644
index 00000000000..e506e99a48e
--- /dev/null
+++ b/addons/mail/static/src/components/composer/composer_tests.js
@@ -0,0 +1,866 @@
+odoo.define('mail/static/src/components/composer/composer_tests.js', function (require) {
+'use strict';
+
+const components = {
+ Composer: require('mail/static/src/components/composer/composer.js'),
+};
+const {
+ afterEach: utilsAfterEach,
+ afterNextRender,
+ beforeEach: utilsBeforeEach,
+ dragenterFiles,
+ dropFiles,
+ inputFiles,
+ nextAnimationFrame,
+ pasteFiles,
+ start: utilsStart,
+} = require('mail/static/src/utils/test_utils.js');
+
+const { file: { createFile } } = require('web.test_utils');
+
+QUnit.module('mail', {}, function () {
+QUnit.module('components', {}, function () {
+QUnit.module('composer', {}, function () {
+QUnit.module('composer_tests.js', {
+ beforeEach() {
+ utilsBeforeEach(this);
+
+ this.createComposerComponent = async (composer, otherProps) => {
+ const ComposerComponent = components.Composer;
+ ComposerComponent.env = this.env;
+ this.component = new ComposerComponent(null, Object.assign({
+ composerLocalId: composer.localId,
+ }, otherProps));
+ await afterNextRender(() => this.component.mount(this.widget.el));
+ };
+
+ this.start = async params => {
+ if (this.widget) {
+ this.widget.destroy();
+ }
+ let { env, widget } = await utilsStart(Object.assign({}, params, {
+ data: this.data,
+ }));
+ this.env = env;
+ this.widget = widget;
+ };
+ },
+ async afterEach() {
+ utilsAfterEach(this);
+ if (this.component) {
+ this.component.destroy();
+ }
+ if (this.widget) {
+ this.widget.destroy();
+ }
+ this.env = undefined;
+ delete components.Composer.env;
+ },
+});
+
+QUnit.test('composer text input: basic rendering', async function (assert) {
+ assert.expect(5);
+
+ await this.start();
+ const composer = this.env.models['mail.composer'].create();
+ await this.createComposerComponent(composer);
+ assert.strictEqual(
+ document.querySelectorAll('.o_Composer').length,
+ 1,
+ "should have composer in discuss thread"
+ );
+ assert.strictEqual(
+ document.querySelectorAll('.o_Composer_textInput').length,
+ 1,
+ "should have text input inside discuss thread composer"
+ );
+ assert.ok(
+ document.querySelector('.o_Composer_textInput').classList.contains('o_ComposerTextInput'),
+ "composer text input of composer should be a ComposerTextIput component"
+ );
+ assert.strictEqual(
+ document.querySelectorAll(`.o_ComposerTextInput_textarea`).length,
+ 1,
+ "should have editable part inside composer text input"
+ );
+ assert.strictEqual(
+ document.querySelector(`.o_ComposerTextInput_textarea`).placeholder,
+ "Write something...",
+ "should have placeholder in note editable of composer text input"
+ );
+});
+
+QUnit.test('add an emoji', async function (assert) {
+ assert.expect(1);
+
+ await this.start();
+ const composer = this.env.models['mail.composer'].create();
+ await this.createComposerComponent(composer);
+ await afterNextRender(() =>
+ document.querySelector('.o_Composer_buttonEmojis').click()
+ );
+ await afterNextRender(() =>
+ document.querySelector('.o_EmojisPopover_emoji[data-unicode="😊"]').click()
+ );
+ assert.strictEqual(
+ document.querySelector(`.o_ComposerTextInput_textarea`).value,
+ "😊",
+ "emoji should be inserted in the composer text input"
+ );
+ // ensure popover is closed
+ await nextAnimationFrame();
+ await nextAnimationFrame();
+ await nextAnimationFrame();
+});
+
+QUnit.test('add an emoji after some text', async function (assert) {
+ assert.expect(2);
+
+ await this.start();
+ const composer = this.env.models['mail.composer'].create();
+ await this.createComposerComponent(composer);
+ await afterNextRender(() => {
+ document.querySelector(`.o_ComposerTextInput_textarea`).focus();
+ document.execCommand('insertText', false, "Blabla");
+ });
+ assert.strictEqual(
+ document.querySelector(`.o_ComposerTextInput_textarea`).value,
+ "Blabla",
+ "composer text input should have text only initially"
+ );
+
+ await afterNextRender(() => document.querySelector('.o_Composer_buttonEmojis').click());
+ await afterNextRender(() =>
+ document.querySelector('.o_EmojisPopover_emoji[data-unicode="😊"]').click()
+ );
+ assert.strictEqual(
+ document.querySelector(`.o_ComposerTextInput_textarea`).value,
+ "Blabla😊",
+ "emoji should be inserted after the text"
+ );
+ // ensure popover is closed
+ await nextAnimationFrame();
+ await nextAnimationFrame();
+ await nextAnimationFrame();
+});
+
+QUnit.test('add emoji replaces (keyboard) text selection', async function (assert) {
+ assert.expect(2);
+
+ await this.start();
+ const composer = this.env.models['mail.composer'].create();
+ await this.createComposerComponent(composer);
+ const composerTextInputTextArea = document.querySelector(`.o_ComposerTextInput_textarea`);
+ await afterNextRender(() => {
+ composerTextInputTextArea.focus();
+ document.execCommand('insertText', false, "Blabla");
+ });
+ assert.strictEqual(
+ composerTextInputTextArea.value,
+ "Blabla",
+ "composer text input should have text only initially"
+ );
+
+ // simulate selection of all the content by keyboard
+ composerTextInputTextArea.setSelectionRange(0, composerTextInputTextArea.value.length);
+
+ // select emoji
+ await afterNextRender(() => document.querySelector('.o_Composer_buttonEmojis').click());
+ await afterNextRender(() =>
+ document.querySelector('.o_EmojisPopover_emoji[data-unicode="😊"]').click()
+ );
+ assert.strictEqual(
+ document.querySelector(`.o_ComposerTextInput_textarea`).value,
+ "😊",
+ "whole text selection should have been replaced by emoji"
+ );
+ // ensure popover is closed
+ await nextAnimationFrame();
+ await nextAnimationFrame();
+ await nextAnimationFrame();
+});
+
+QUnit.test('display partner mention suggestions on typing "@"', async function (assert) {
+ assert.expect(2);
+
+ await this.start({
+ async mockRPC(route, args) {
+ if (args.method === 'get_mention_suggestions') {
+ return [
+ [{
+ email: "testpartnert@odoo.com",
+ id: 11,
+ name: "TestPartner",
+ }],
+ [],
+ ];
+ }
+ return this._super(...arguments);
+ },
+ });
+ const composer = this.env.models['mail.composer'].create();
+ await this.createComposerComponent(composer);
+
+ assert.containsNone(
+ document.body,
+ '.o_ComposerTextInput_mentionDropdownPropositionList',
+ "mention suggestions list should not be present"
+ );
+ await afterNextRender(() => {
+ document.querySelector(`.o_ComposerTextInput_textarea`).focus();
+ document.execCommand('insertText', false, "@");
+ document.querySelector(`.o_ComposerTextInput_textarea`)
+ .dispatchEvent(new window.KeyboardEvent('keydown'));
+ document.querySelector(`.o_ComposerTextInput_textarea`)
+ .dispatchEvent(new window.KeyboardEvent('keyup'));
+ });
+ assert.hasClass(
+ document.querySelector('.o_ComposerTextInput_mentionDropdownPropositionList'),
+ 'show',
+ "should display mention suggestions on typing '@'"
+ );
+});
+
+QUnit.test('mention a partner', async function (assert) {
+ assert.expect(4);
+
+ await this.start({
+ async mockRPC(route, args) {
+ if (args.method === 'get_mention_suggestions') {
+ return [
+ [{
+ email: "testpartnert@odoo.com",
+ id: 11,
+ name: "TestPartner",
+ }],
+ [],
+ ];
+ }
+ return this._super(...arguments);
+ },
+ });
+ const composer = this.env.models['mail.composer'].create();
+ await this.createComposerComponent(composer);
+
+ assert.containsNone(
+ document.body,
+ '.o_ComposerTextInput_mentionDropdownPropositionList',
+ "mention suggestions list should not be present"
+ );
+ assert.strictEqual(
+ document.querySelector(`.o_ComposerTextInput_textarea`).value,
+ "",
+ "text content of composer should be empty initially"
+ );
+ await afterNextRender(() => {
+ document.querySelector(`.o_ComposerTextInput_textarea`).focus();
+ document.execCommand('insertText', false, "@");
+ });
+ await afterNextRender(() => {
+ document.querySelector(`.o_ComposerTextInput_textarea`)
+ .dispatchEvent(new window.KeyboardEvent('keydown'));
+ document.querySelector(`.o_ComposerTextInput_textarea`)
+ .dispatchEvent(new window.KeyboardEvent('keyup'));
+ });
+ assert.containsOnce(
+ document.body,
+ '.o_PartnerMentionSuggestion',
+ "should have a mention suggestion"
+ );
+ await afterNextRender(() =>
+ document.querySelector('.o_PartnerMentionSuggestion').click()
+ );
+ assert.strictEqual(
+ document.querySelector(`.o_ComposerTextInput_textarea`).value.replace(/\s/, " "),
+ "@TestPartner ",
+ "text content of composer should have mentioned partner + additional whitespace afterwards"
+ );
+});
+
+QUnit.test('mention a partner after some text', async function (assert) {
+ assert.expect(5);
+
+ await this.start({
+ async mockRPC(route, args) {
+ if (args.method === 'get_mention_suggestions') {
+ return [
+ [{
+ email: "testpartnert@odoo.com",
+ id: 11,
+ name: "TestPartner",
+ }],
+ [],
+ ];
+ }
+ return this._super(...arguments);
+ },
+ });
+ const composer = this.env.models['mail.composer'].create();
+ await this.createComposerComponent(composer);
+
+ assert.containsNone(
+ document.body,
+ '.o_ComposerTextInput_mentionDropdownPropositionList',
+ "mention suggestions list should not be present"
+ );
+ assert.strictEqual(
+ document.querySelector(`.o_ComposerTextInput_textarea`).value,
+ "",
+ "text content of composer should be empty initially"
+ );
+ document.querySelector(`.o_ComposerTextInput_textarea`).focus();
+ await afterNextRender(() =>
+ document.execCommand('insertText', false, "bluhbluh ")
+ );
+ assert.strictEqual(
+ document.querySelector(`.o_ComposerTextInput_textarea`).value,
+ "bluhbluh ",
+ "text content of composer should have content"
+ );
+ await afterNextRender(() =>
+ document.execCommand('insertText', false, "@")
+ );
+ await afterNextRender(() => {
+ document.querySelector(`.o_ComposerTextInput_textarea`)
+ .dispatchEvent(new window.KeyboardEvent('keydown'));
+ document.querySelector(`.o_ComposerTextInput_textarea`)
+ .dispatchEvent(new window.KeyboardEvent('keyup'));
+ });
+ assert.containsOnce(
+ document.body,
+ '.o_PartnerMentionSuggestion',
+ "should have a mention suggestion"
+ );
+ await afterNextRender(() =>
+ document.querySelector('.o_PartnerMentionSuggestion').click()
+ );
+ assert.strictEqual(
+ document.querySelector(`.o_ComposerTextInput_textarea`).value.replace(/\s/, " "),
+ "bluhbluh @TestPartner ",
+ "text content of composer should have previous content + mentioned partner + additional whitespace afterwards"
+ );
+});
+
+QUnit.test('add an emoji after a partner mention', async function (assert) {
+ assert.expect(5);
+
+ await this.start({
+ async mockRPC(route, args) {
+ if (args.method === 'get_mention_suggestions') {
+ return [
+ [{
+ email: "testpartnert@odoo.com",
+ id: 11,
+ name: "TestPartner",
+ }],
+ [],
+ ];
+ }
+ return this._super(...arguments);
+ },
+ });
+ const composer = this.env.models['mail.composer'].create();
+ await this.createComposerComponent(composer);
+
+ assert.containsNone(
+ document.body,
+ '.o_ComposerTextInput_mentionDropdownPropositionList',
+ "mention suggestions list should not be present"
+ );
+ assert.strictEqual(
+ document.querySelector(`.o_ComposerTextInput_textarea`).value,
+ "",
+ "text content of composer should be empty initially"
+ );
+ await afterNextRender(() => {
+ document.querySelector(`.o_ComposerTextInput_textarea`).focus();
+ document.execCommand('insertText', false, "@");
+ });
+ await afterNextRender(() => {
+ document.querySelector(`.o_ComposerTextInput_textarea`)
+ .dispatchEvent(new window.KeyboardEvent('keydown'));
+ document.querySelector(`.o_ComposerTextInput_textarea`)
+ .dispatchEvent(new window.KeyboardEvent('keyup'));
+ });
+ assert.containsOnce(
+ document.body,
+ '.o_PartnerMentionSuggestion',
+ "should have a mention suggestion"
+ );
+ await afterNextRender(() =>
+ document.querySelector('.o_PartnerMentionSuggestion').click()
+ );
+ assert.strictEqual(
+ document.querySelector(`.o_ComposerTextInput_textarea`).value.replace(/\s/, " "),
+ "@TestPartner ",
+ "text content of composer should have previous content + mentioned partner + additional whitespace afterwards"
+ );
+
+ // select emoji
+ await afterNextRender(() =>
+ document.querySelector('.o_Composer_buttonEmojis').click()
+ );
+ await afterNextRender(() =>
+ document.querySelector('.o_EmojisPopover_emoji[data-unicode="😊"]').click()
+ );
+ assert.strictEqual(
+ document.querySelector(`.o_ComposerTextInput_textarea`).value.replace(/\s/, " "),
+ "@TestPartner 😊",
+ "text content of composer should have previous mention and selected emoji just after"
+ );
+ // ensure popover is closed
+ await nextAnimationFrame();
+});
+
+QUnit.test('composer: add an attachment', async function (assert) {
+ assert.expect(2);
+
+ await this.start();
+ const composer = this.env.models['mail.composer'].create();
+ await this.createComposerComponent(composer, { attachmentsDetailsMode: 'card' });
+ const file = await createFile({
+ content: 'hello, world',
+ contentType: 'text/plain',
+ name: 'text.txt',
+ });
+ await afterNextRender(() =>
+ inputFiles(
+ document.querySelector('.o_FileUploader_input'),
+ [file]
+ )
+ );
+ assert.ok(
+ document.querySelector('.o_Composer_attachmentList'),
+ "should have an attachment list"
+ );
+ assert.ok(
+ document.querySelector(`.o_Composer .o_Attachment`),
+ "should have an attachment"
+ );
+});
+
+QUnit.test('composer: drop attachments', async function (assert) {
+ assert.expect(4);
+
+ await this.start();
+ const composer = this.env.models['mail.composer'].create();
+ await this.createComposerComponent(composer);
+ const files = [
+ await createFile({
+ content: 'hello, world',
+ contentType: 'text/plain',
+ name: 'text.txt',
+ }),
+ await createFile({
+ content: 'hello, worlduh',
+ contentType: 'text/plain',
+ name: 'text2.txt',
+ }),
+ ];
+ await afterNextRender(() => dragenterFiles(document.querySelector('.o_Composer')));
+ assert.ok(
+ document.querySelector('.o_Composer_dropZone'),
+ "should have a drop zone"
+ );
+ assert.strictEqual(
+ document.querySelectorAll(`.o_Composer .o_Attachment`).length,
+ 0,
+ "should have no attachment before files are dropped"
+ );
+
+ await afterNextRender(() =>
+ dropFiles(
+ document.querySelector('.o_Composer_dropZone'),
+ files
+ )
+ );
+ assert.strictEqual(
+ document.querySelectorAll(`.o_Composer .o_Attachment`).length,
+ 2,
+ "should have 2 attachments in the composer after files dropped"
+ );
+
+ await afterNextRender(() => dragenterFiles(document.querySelector('.o_Composer')));
+ await afterNextRender(async () =>
+ dropFiles(
+ document.querySelector('.o_Composer_dropZone'),
+ [
+ await createFile({
+ content: 'hello, world',
+ contentType: 'text/plain',
+ name: 'text3.txt',
+ })
+ ]
+ )
+ );
+ assert.strictEqual(
+ document.querySelectorAll(`.o_Composer .o_Attachment`).length,
+ 3,
+ "should have 3 attachments in the box after files dropped"
+ );
+});
+
+QUnit.test('composer: paste attachments', async function (assert) {
+ assert.expect(2);
+
+ await this.start();
+ const composer = this.env.models['mail.composer'].create();
+ await this.createComposerComponent(composer);
+ const files = [
+ await createFile({
+ content: 'hello, world',
+ contentType: 'text/plain',
+ name: 'text.txt',
+ })
+ ];
+ assert.strictEqual(
+ document.querySelectorAll(`.o_Composer .o_Attachment`).length,
+ 0,
+ "should not have any attachment in the composer before paste"
+ );
+
+ await afterNextRender(() =>
+ pasteFiles(document.querySelector('.o_ComposerTextInput'), files)
+ );
+ assert.strictEqual(
+ document.querySelectorAll(`.o_Composer .o_Attachment`).length,
+ 1,
+ "should have 1 attachment in the composer after paste"
+ );
+});
+
+QUnit.test('composer text input cleared on message post', async function (assert) {
+ assert.expect(4);
+
+ Object.assign(this.data.initMessaging, {
+ channel_slots: {
+ channel_channel: [{
+ channel_type: 'channel',
+ id: 20,
+ name: "General",
+ }],
+ },
+ });
+ await this.start({
+ async mockRPC(route, args) {
+ if (args.method === 'message_post') {
+ assert.step('message_post');
+ return;
+ }
+ return this._super(...arguments);
+ },
+ });
+ const thread = this.env.models['mail.thread'].find(thread =>
+ thread.id === 20 &&
+ thread.model === 'mail.channel'
+ );
+ await this.createComposerComponent(thread.composer);
+ // Type message
+ document.querySelector(`.o_ComposerTextInput_textarea`).focus();
+ document.execCommand('insertText', false, "test message");
+ assert.strictEqual(
+ document.querySelector(`.o_ComposerTextInput_textarea`).value,
+ "test message",
+ "should have inserted text content in editable"
+ );
+
+ // Send message
+ await afterNextRender(() =>
+ document.querySelector(`.o_ComposerTextInput_textarea`)
+ .dispatchEvent(new window.KeyboardEvent('keydown', { key: 'Enter' }))
+ );
+ assert.verifySteps(['message_post']);
+ assert.strictEqual(
+ document.querySelector(`.o_ComposerTextInput_textarea`).value,
+ "",
+ "should have no content in composer input after posting message"
+ );
+});
+
+QUnit.test('composer with thread typing notification status', async function (assert) {
+ assert.expect(2);
+
+ Object.assign(this.data.initMessaging, {
+ channel_slots: {
+ channel_channel: [{
+ channel_type: 'channel',
+ id: 20,
+ name: "General",
+ }],
+ },
+ });
+ await this.start();
+ const thread = this.env.models['mail.thread'].find(thread =>
+ thread.id === 20 &&
+ thread.model === 'mail.channel'
+ );
+ await this.createComposerComponent(thread.composer, { hasThreadTyping: true });
+
+ assert.containsOnce(
+ document.body,
+ '.o_Composer_threadTextualTypingStatus',
+ "Composer should have a thread textual typing status bar"
+ );
+ assert.strictEqual(
+ document.body.querySelector('.o_Composer_threadTextualTypingStatus').textContent,
+ "",
+ "By default, thread textual typing status bar should be empty"
+ );
+});
+
+QUnit.test('current partner notify is typing to other thread members', async function (assert) {
+ assert.expect(2);
+
+ Object.assign(this.data.initMessaging, {
+ channel_slots: {
+ channel_channel: [{
+ channel_type: 'channel',
+ id: 20,
+ members: [{
+ email: 'admin@odoo.com',
+ id: 3,
+ name: 'Admin',
+ }, {
+ email: 'demo@odoo.com',
+ id: 7,
+ name: 'Demo',
+ }],
+ name: "General",
+ }],
+ },
+ });
+ await this.start({
+ env: {
+ session: {
+ name: 'Admin',
+ partner_display_name: 'Your Company, Admin',
+ partner_id: 3,
+ uid: 2,
+ },
+ },
+ async mockRPC(route, args) {
+ if (args.method === 'notify_typing') {
+ assert.step(`notify_typing:${args.kwargs.is_typing}`);
+ return;
+ }
+ return this._super(...arguments);
+ },
+ });
+ const thread = this.env.models['mail.thread'].find(thread =>
+ thread.id === 20 &&
+ thread.model === 'mail.channel'
+ );
+ await this.createComposerComponent(thread.composer, { hasThreadTyping: true });
+
+ document.querySelector(`.o_ComposerTextInput_textarea`).focus();
+ document.execCommand('insertText', false, "a");
+ document.querySelector(`.o_ComposerTextInput_textarea`)
+ .dispatchEvent(new window.KeyboardEvent('keydown', { key: 'a' }));
+
+ assert.verifySteps(
+ ['notify_typing:true'],
+ "should have notified current partner typing status"
+ );
+});
+
+QUnit.test('current partner is typing should not translate on textual typing status', async function (assert) {
+ assert.expect(3);
+
+ Object.assign(this.data.initMessaging, {
+ channel_slots: {
+ channel_channel: [{
+ channel_type: 'channel',
+ id: 20,
+ members: [{
+ email: 'admin@odoo.com',
+ id: 3,
+ name: 'Admin',
+ }, {
+ email: 'demo@odoo.com',
+ id: 7,
+ name: 'Demo',
+ }],
+ name: "General",
+ }],
+ },
+ });
+ await this.start({
+ env: {
+ session: {
+ name: 'Admin',
+ partner_display_name: 'Your Company, Admin',
+ partner_id: 3,
+ uid: 2,
+ },
+ },
+ hasTimeControl: true,
+ async mockRPC(route, args) {
+ if (args.method === 'notify_typing') {
+ assert.step(`notify_typing:${args.kwargs.is_typing}`);
+ return;
+ }
+ return this._super(...arguments);
+ },
+ });
+ const thread = this.env.models['mail.thread'].find(thread =>
+ thread.id === 20 &&
+ thread.model === 'mail.channel'
+ );
+ await this.createComposerComponent(thread.composer, { hasThreadTyping: true });
+
+ document.querySelector(`.o_ComposerTextInput_textarea`).focus();
+ document.execCommand('insertText', false, "a");
+ document.querySelector(`.o_ComposerTextInput_textarea`)
+ .dispatchEvent(new window.KeyboardEvent('keydown', { key: 'a' }));
+
+ assert.verifySteps(
+ ['notify_typing:true'],
+ "should have notified current partner typing status"
+ );
+
+ await nextAnimationFrame();
+ assert.strictEqual(
+ document.body.querySelector('.o_Composer_threadTextualTypingStatus').textContent,
+ "",
+ "Thread textual typing status bar should not display current partner is typing"
+ );
+});
+
+QUnit.test('current partner notify no longer is typing to thread members after 5 seconds inactivity', async function (assert) {
+ assert.expect(4);
+
+ Object.assign(this.data.initMessaging, {
+ channel_slots: {
+ channel_channel: [{
+ channel_type: 'channel',
+ id: 20,
+ members: [{
+ email: 'admin@odoo.com',
+ id: 3,
+ name: 'Admin',
+ }, {
+ email: 'demo@odoo.com',
+ id: 7,
+ name: 'Demo',
+ }],
+ name: "General",
+ }],
+ },
+ });
+ await this.start({
+ env: {
+ session: {
+ name: 'Admin',
+ partner_display_name: 'Your Company, Admin',
+ partner_id: 3,
+ uid: 2,
+ },
+ },
+ hasTimeControl: true,
+ async mockRPC(route, args) {
+ if (args.method === 'notify_typing') {
+ assert.step(`notify_typing:${args.kwargs.is_typing}`);
+ return;
+ }
+ return this._super(...arguments);
+ },
+ });
+ const thread = this.env.models['mail.thread'].find(thread =>
+ thread.id === 20 &&
+ thread.model === 'mail.channel'
+ );
+ await this.createComposerComponent(thread.composer, { hasThreadTyping: true });
+
+ document.querySelector(`.o_ComposerTextInput_textarea`).focus();
+ document.execCommand('insertText', false, "a");
+ document.querySelector(`.o_ComposerTextInput_textarea`)
+ .dispatchEvent(new window.KeyboardEvent('keydown', { key: 'a' }));
+
+ assert.verifySteps(
+ ['notify_typing:true'],
+ "should have notified current partner is typing"
+ );
+
+ await this.env.testUtils.advanceTime(5 * 1000);
+ assert.verifySteps(
+ ['notify_typing:false'],
+ "should have notified current partner no longer is typing (inactive for 5 seconds)"
+ );
+});
+
+QUnit.test('current partner notify is typing again to other members every 50s of long continuous typing', async function (assert) {
+ assert.expect(4);
+
+ Object.assign(this.data.initMessaging, {
+ channel_slots: {
+ channel_channel: [{
+ channel_type: 'channel',
+ id: 20,
+ members: [{
+ email: 'admin@odoo.com',
+ id: 3,
+ name: 'Admin',
+ }, {
+ email: 'demo@odoo.com',
+ id: 7,
+ name: 'Demo',
+ }],
+ name: "General",
+ }],
+ },
+ });
+ await this.start({
+ env: {
+ session: {
+ name: 'Admin',
+ partner_display_name: 'Your Company, Admin',
+ partner_id: 3,
+ uid: 2,
+ },
+ },
+ hasTimeControl: true,
+ async mockRPC(route, args) {
+ if (args.method === 'notify_typing') {
+ assert.step(`notify_typing:${args.kwargs.is_typing}`);
+ return;
+ }
+ return this._super(...arguments);
+ },
+ });
+ const thread = this.env.models['mail.thread'].find(thread =>
+ thread.id === 20 &&
+ thread.model === 'mail.channel'
+ );
+ await this.createComposerComponent(thread.composer, { hasThreadTyping: true });
+
+ document.querySelector(`.o_ComposerTextInput_textarea`).focus();
+ document.execCommand('insertText', false, "a");
+ document.querySelector(`.o_ComposerTextInput_textarea`)
+ .dispatchEvent(new window.KeyboardEvent('keydown', { key: 'a' }));
+ assert.verifySteps(
+ ['notify_typing:true'],
+ "should have notified current partner is typing"
+ );
+
+ // simulate current partner typing a character every 2.5 seconds for 50 seconds straight.
+ let totalTimeElapsed = 0;
+ const elapseTickTime = 2.5 * 1000;
+ while (totalTimeElapsed < 50 * 1000) {
+ document.execCommand('insertText', false, "a");
+ document.querySelector(`.o_ComposerTextInput_textarea`)
+ .dispatchEvent(new window.KeyboardEvent('keydown', { key: 'a' }));
+ totalTimeElapsed += elapseTickTime;
+ await this.env.testUtils.advanceTime(elapseTickTime);
+ }
+
+ assert.verifySteps(
+ ['notify_typing:true'],
+ "should have notified current partner is still typing after 50s of straight typing"
+ );
+});
+
+});
+});
+});
+
+});
diff --git a/addons/mail/static/src/components/composer_text_input/composer_text_input.js b/addons/mail/static/src/components/composer_text_input/composer_text_input.js
new file mode 100644
index 00000000000..198f563d1c7
--- /dev/null
+++ b/addons/mail/static/src/components/composer_text_input/composer_text_input.js
@@ -0,0 +1,334 @@
+odoo.define('mail/static/src/components/composer_text_input/composer_text_input.js', function (require) {
+'use strict';
+
+const useStore = require('mail/static/src/component_hooks/use_store/use_store.js');
+
+const components = {
+ PartnerMentionSuggestion: require('mail/static/src/components/partner_mention_suggestion/partner_mention_suggestion.js'),
+};
+
+const { Component } = owl;
+const { useRef } = owl.hooks;
+
+/**
+ * ComposerInput relies on a minimal HTML editor in order to support mentions.
+ */
+class ComposerTextInput extends Component {
+
+ /**
+ * @override
+ */
+ constructor(...args) {
+ super(...args);
+ useStore(props => {
+ const composer = this.env.models['mail.composer'].get(props.composerLocalId);
+ return {
+ composer: composer ? composer.__state : undefined,
+ isDeviceMobile: this.env.messaging.device.isMobile,
+ };
+ });
+ /**
+ * Last content of textarea from input event. Useful to determine
+ * whether the current partner is typing something.
+ */
+ this._textareaLastInputValue = "";
+ /**
+ * Reference of the textarea. Useful to set height, selection and content.
+ */
+ this._textareaRef = useRef('textarea');
+ }
+
+ /**
+ * Updates the composer text input content when composer is mounted
+ * as textarea content can't be changed from the DOM.
+ */
+ mounted() {
+ this._update();
+ }
+
+ /**
+ * Updates the composer text input content when composer has changed
+ * as textarea content can't be changed from the DOM.
+ */
+ patched() {
+ this._update();
+ }
+
+ //--------------------------------------------------------------------------
+ // Public
+ //--------------------------------------------------------------------------
+
+ /**
+ * @returns {mail.composer}
+ */
+ get composer() {
+ return this.env.models['mail.composer'].get(this.props.composerLocalId);
+ }
+
+ focusout() {
+ this.saveStateInStore();
+ this._textareaRef.el.blur();
+ }
+
+ /**
+ * Saves the composer text input state in store
+ */
+ saveStateInStore() {
+ this.composer.update({
+ textInputContent: this._getContent(),
+ textInputCursorStart: this._getSelectionStart(),
+ textInputCursorEnd: this._getSelectionEnd(),
+ });
+ }
+
+ //--------------------------------------------------------------------------
+ // Private
+ //--------------------------------------------------------------------------
+
+ /**
+ * Returns textarea current content.
+ *
+ * @private
+ * @returns {string}
+ */
+ _getContent() {
+ return this._textareaRef.el.value;
+ }
+
+ /**
+ * Returns selection end position.
+ *
+ * @private
+ * @returns {integer}
+ */
+ _getSelectionEnd() {
+ return this._textareaRef.el.selectionEnd;
+ }
+
+ /**
+ * Returns selection start position.
+ *
+ * @private
+ * @returns {integer}
+ *
+ */
+ _getSelectionStart() {
+ return this._textareaRef.el.selectionStart;
+ }
+
+ /**
+ * Determines whether the textarea is empty or not.
+ *
+ * @private
+ * @returns {boolean}
+ */
+ _isEmpty() {
+ return this._getContent() === "";
+ }
+
+ /**
+ * Updates the content and height of a textarea
+ *
+ * @private
+ */
+ _update() {
+ this._textareaRef.el.value = this.composer.textInputContent;
+ this._textareaRef.el.setSelectionRange(
+ this.composer.textInputCursorStart,
+ this.composer.textInputCursorEnd
+ );
+ this._updateHeight();
+ if (this.composer.isDoFocus) {
+ this._textareaRef.el.focus();
+ this.composer.update({ isDoFocus: false });
+ }
+ }
+
+ /**
+ * Updates the textarea height.
+ *
+ * @private
+ */
+ _updateHeight() {
+ this._textareaRef.el.style.height = "0px";
+ this._textareaRef.el.style.height = (this._textareaRef.el.scrollHeight) + "px";
+ }
+
+ //--------------------------------------------------------------------------
+ // Handlers
+ //--------------------------------------------------------------------------
+
+ /**
+ * @private
+ */
+ _onInputTextarea() {
+ if (this._textareaLastInputValue !== this._textareaRef.el.value) {
+ this.composer.handleCurrentPartnerIsTyping();
+ }
+ this._textareaLastInputValue = this._textareaRef.el.value;
+ this._updateHeight();
+ this.saveStateInStore();
+ }
+
+ /**
+ * @private
+ * @param {KeyboardEvent} ev
+ */
+ _onKeydownTextarea(ev) {
+ switch (ev.key) {
+ // UP, DOWN, TAB: prevent moving cursor if navigation in mention suggestions
+ case 'ArrowUp':
+ case 'PageUp':
+ case 'ArrowDown':
+ case 'PageDown':
+ case 'Home':
+ case 'End':
+ case 'Tab':
+ if (this.composer.hasSuggestedPartners) {
+ // We use preventDefault here to avoid keys native actions but actions are handled in keyUp
+ ev.preventDefault();
+ }
+ break;
+ // ENTER: submit the message only if the dropdown mention proposition is not displayed
+ case 'Enter':
+ this._onKeydownTextareaEnter(ev);
+ break;
+ }
+ }
+
+ /**
+ * @private
+ * @param {KeyboardEvent} ev
+ */
+ _onKeydownTextareaEnter(ev) {
+ if (this.composer.hasSuggestedPartners) {
+ ev.preventDefault();
+ } else {
+ if (!this.props.hasSendOnEnterEnabled) {
+ return;
+ }
+ if (ev.shiftKey) {
+ return;
+ }
+ if (this.env.messaging.device.isMobile) {
+ return;
+ }
+ this.trigger('o-keydown-enter');
+ ev.preventDefault();
+ }
+ }
+
+ /**
+ * Key events management is performed in a Keyup to avoid intempestive RPC calls
+ *
+ * @private
+ * @param {KeyboardEvent} ev
+ */
+ _onKeyupTextarea(ev) {
+ switch (ev.key) {
+ // ESCAPE: close mention suggestions
+ case 'Escape':
+ this._onKeyupTextareaEscape(ev);
+ break;
+ // ENTER, HOME, END, UP, DOWN, PAGE UP, PAGE DOWN, TAB: check if navigation in mention suggestions
+ case 'Enter':
+ if (this.composer.hasSuggestedPartners) {
+ if (this.composer.activeSuggestedPartner) {
+ this.composer.insertMentionedPartner(this.composer.activeSuggestedPartner);
+ this.composer.closeMentionSuggestions();
+ this.composer.focus();
+ }
+ }
+ break;
+ case 'ArrowUp':
+ case 'PageUp':
+ if (this.composer.hasSuggestedPartners) {
+ this.composer.setPreviousSuggestedPartnerActive();
+ }
+ break;
+ case 'ArrowDown':
+ case 'PageDown':
+ if (this.composer.hasSuggestedPartners) {
+ this.composer.setNextSuggestedPartnerActive();
+ }
+ break;
+ case 'Home':
+ if (this.composer.hasSuggestedPartners) {
+ this.composer.setFirstSuggestedPartnerActive();
+ }
+ break;
+ case 'End':
+ if (this.composer.hasSuggestedPartners) {
+ this.composer.setLastSuggestedPartnerActive();
+ }
+ break;
+ case 'Tab':
+ if (this.composer.hasSuggestedPartners) {
+ if (ev.shiftKey) {
+ this.composer.setPreviousSuggestedPartnerActive();
+ } else {
+ this.composer.setNextSuggestedPartnerActive();
+ }
+ }
+ break;
+ // Otherwise, check if a mention is typed
+ default:
+ this.saveStateInStore();
+ this.composer._detectDelimiter();
+ }
+ }
+
+ /**
+ * @private
+ * @param {KeyboardEvent} ev
+ */
+ _onKeyupTextareaEscape(ev) {
+ if (this.composer.hasSuggestedPartners) {
+ this.composer.closeMentionSuggestions();
+ } else {
+ if (!this._isEmpty()) {
+ return;
+ }
+ this.composer.discard();
+ }
+ }
+
+ /**
+ * @private
+ * @param {Event} ev
+ */
+ _onPartnerMentionSuggestionClicked(ev) {
+ this.composer.insertMentionedPartner(ev.detail.partner);
+ this.composer.closeMentionSuggestions();
+ this.composer.focus();
+ }
+
+ /**
+ * @private
+ * @param {MouseEvent} ev
+ */
+ _onPartnerMentionSuggestionMouseOver(ev) {
+ this.composer.update({
+ activeSuggestedPartner: [['link', ev.detail.partner]],
+ });
+ }
+
+}
+
+Object.assign(ComposerTextInput, {
+ components,
+ defaultProps: {
+ hasMentionSuggestionsBelowPosition: false,
+ hasSendOnEnterEnabled: true,
+ },
+ props: {
+ hasMentionSuggestionsBelowPosition: Boolean,
+ hasSendOnEnterEnabled: Boolean,
+ composerLocalId: String,
+ },
+ template: 'mail.ComposerTextInput',
+});
+
+return ComposerTextInput;
+
+});
diff --git a/addons/mail/static/src/components/composer_text_input/composer_text_input.scss b/addons/mail/static/src/components/composer_text_input/composer_text_input.scss
new file mode 100644
index 00000000000..fe3ad16788d
--- /dev/null
+++ b/addons/mail/static/src/components/composer_text_input/composer_text_input.scss
@@ -0,0 +1,25 @@
+// ------------------------------------------------------------------
+// Layout
+// ------------------------------------------------------------------
+
+.o_ComposerTextInput {
+ min-width: 0;
+ position: relative;
+}
+
+.o_ComposerTextInput_mentionDropdown {
+ position: absolute;
+
+ &.o-lowPosition {
+ bottom: 0;
+ }
+}
+
+.o_ComposerTextInput_textarea {
+ padding: 10px;
+ max-height: 200px;
+ resize: none;
+ border-radius: 10px 0 0 10px;
+ border: none;
+ overflow: auto
+}
diff --git a/addons/mail/static/src/components/composer_text_input/composer_text_input.xml b/addons/mail/static/src/components/composer_text_input/composer_text_input.xml
new file mode 100644
index 00000000000..f68b295cb8d
--- /dev/null
+++ b/addons/mail/static/src/components/composer_text_input/composer_text_input.xml
@@ -0,0 +1,44 @@
+<?xml version="1.0" encoding="UTF-8"?>
+<templates xml:space="preserve">
+
+ <t t-name="mail.ComposerTextInput" owl="1">
+ <div class="o_ComposerTextInput">
+ <t t-if="composer">
+ <t t-if="composer.hasSuggestedPartners">
+ <div class="o_ComposerTextInput_mentionDropdown" t-att-class="{ 'o-lowPosition': props.hasMentionSuggestionsBelowPosition }">
+ <div
+ t-att-class="{
+ 'dropdown': props.hasMentionSuggestionsBelowPosition,
+ 'dropup': !props.hasMentionSuggestionsBelowPosition,
+ }"
+ >
+ <div class="o_ComposerTextInput_mentionDropdownPropositionList dropdown-menu show">
+ <t t-foreach="composer.mainSuggestedPartners" t-as="mainSuggestedPartner" t-key="mainSuggestedPartner.localId">
+ <PartnerMentionSuggestion
+ isActive="mainSuggestedPartner === composer.activeSuggestedPartner"
+ partnerLocalId="mainSuggestedPartner.localId"
+ t-on-o-partner-mention-suggestion-clicked="_onPartnerMentionSuggestionClicked"
+ t-on-o-partner-mention-suggestion-mouse-over="_onPartnerMentionSuggestionMouseOver"
+ />
+ </t>
+ <t t-if="composer.mainSuggestedPartners.length > 0 and composer.extraSuggestedPartners.length > 0">
+ <div role="separator" class="dropdown-divider"/>
+ </t>
+ <t t-foreach="composer.extraSuggestedPartners" t-as="extraSuggestedPartner" t-key="extraSuggestedPartner.localId">
+ <PartnerMentionSuggestion
+ isActive="extraSuggestedPartner === composer.activeSuggestedPartner"
+ partnerLocalId="extraSuggestedPartner.localId"
+ t-on-o-partner-mention-suggestion-clicked="_onPartnerMentionSuggestionClicked"
+ t-on-o-partner-mention-suggestion-mouse-over="_onPartnerMentionSuggestionMouseOver"
+ />
+ </t>
+ </div>
+ </div>
+ </div>
+ </t>
+ <textarea class="o_ComposerTextInput_textarea" t-esc="composer.textInputContent" placeholder="Write something..." t-on-keydown="_onKeydownTextarea" t-on-keyup="_onKeyupTextarea" t-on-input="_onInputTextarea" t-ref="textarea"/>
+ </t>
+ </div>
+ </t>
+
+</templates>
diff --git a/addons/mail/static/src/components/dialog/dialog.js b/addons/mail/static/src/components/dialog/dialog.js
new file mode 100644
index 00000000000..dc7ddf88e0a
--- /dev/null
+++ b/addons/mail/static/src/components/dialog/dialog.js
@@ -0,0 +1,105 @@
+odoo.define('mail/static/src/components/dialog/dialog.js', function (require) {
+'use strict';
+
+const useStore = require('mail/static/src/component_hooks/use_store/use_store.js');
+
+const { Component } = owl;
+const { useRef } = owl.hooks;
+
+class Dialog extends Component {
+
+ /**
+ * @param {...any} args
+ */
+ constructor(...args) {
+ super(...args);
+ /**
+ * Reference to the component used inside this dialog.
+ */
+ this._componentRef = useRef('component');
+ this._onClickGlobal = this._onClickGlobal.bind(this);
+ this._onKeydownDocument = this._onKeydownDocument.bind(this);
+ useStore(props => {
+ const dialog = this.env.models['mail.dialog'].get(props.dialogLocalId);
+ return {
+ dialog: dialog ? dialog.__state : undefined,
+ };
+ });
+ }
+
+ mounted() {
+ document.addEventListener('click', this._onClickGlobal, true);
+ document.addEventListener('keydown', this._onKeydownDocument);
+ }
+
+ willUnmount() {
+ document.removeEventListener('click', this._onClickGlobal, true);
+ document.removeEventListener('keydown', this._onKeydownDocument);
+ }
+
+ //--------------------------------------------------------------------------
+ // Public
+ //--------------------------------------------------------------------------
+
+ /**
+ * @returns {mail.dialog}
+ */
+ get dialog() {
+ return this.env.models['mail.dialog'].get(this.props.dialogLocalId);
+ }
+
+ //--------------------------------------------------------------------------
+ // Handlers
+ //--------------------------------------------------------------------------
+
+ /**
+ * Called when clicking on this dialog.
+ *
+ * @private
+ * @param {MouseEvent} ev
+ */
+ _onClick(ev) {
+ ev.stopPropagation();
+ }
+
+ /**
+ * Closes the dialog when clicking outside.
+ * Does not work with attachment viewer because it takes the whole space.
+ *
+ * @private
+ * @param {MouseEvent} ev
+ */
+ _onClickGlobal(ev) {
+ if (this._componentRef.el.contains(ev.target)) {
+ return;
+ }
+ // TODO: this should be child logic (will crash if child doesn't have isCloseable!!)
+ // task-2092965
+ if (this._componentRef.comp.isCloseable && !this._componentRef.comp.isCloseable()) {
+ return;
+ }
+ this.dialog.close();
+ }
+
+ /**
+ * @private
+ * @param {KeyboardEvent} ev
+ */
+ _onKeydownDocument(ev) {
+ if (ev.key === 'Escape') {
+ this.dialog.close();
+ }
+ }
+
+}
+
+Object.assign(Dialog, {
+ props: {
+ dialogLocalId: String,
+ },
+ template: 'mail.Dialog',
+});
+
+return Dialog;
+
+});
diff --git a/addons/mail/static/src/components/dialog/dialog.scss b/addons/mail/static/src/components/dialog/dialog.scss
new file mode 100644
index 00000000000..00d6e24ff48
--- /dev/null
+++ b/addons/mail/static/src/components/dialog/dialog.scss
@@ -0,0 +1,23 @@
+// ------------------------------------------------------------------
+// Layout
+// ------------------------------------------------------------------
+
+.o_Dialog {
+ position: absolute;
+ top: 0;
+ bottom: 0;
+ left: 0;
+ right: 0;
+ display: flex;
+ align-items: center;
+ justify-content: center;
+ z-index: 1;
+}
+
+// ------------------------------------------------------------------
+// Style
+// ------------------------------------------------------------------
+
+.o_Dialog {
+ background-color: rgba(0, 0, 0, 0.7);
+}
diff --git a/addons/mail/static/src/components/dialog/dialog.xml b/addons/mail/static/src/components/dialog/dialog.xml
new file mode 100644
index 00000000000..3c953decbbc
--- /dev/null
+++ b/addons/mail/static/src/components/dialog/dialog.xml
@@ -0,0 +1,22 @@
+<?xml version="1.0" encoding="UTF-8"?>
+<templates xml:space="preserve">
+
+ <t t-name="mail.Dialog" owl="1">
+ <div class="o_Dialog">
+ <t t-if="dialog">
+ <t t-if="dialog.record">
+ <t
+ t-component="{{ dialog.record['constructor'].name }}"
+ class="o_Dialog_component"
+ t-props="{ localId: dialog.record.localId }"
+ t-ref="component"
+ />
+ </t>
+ <t t-else="">
+ <span>Only dialog linked to a record is currently supported!</span>
+ </t>
+ </t>
+ </div>
+ </t>
+
+</templates>
diff --git a/addons/mail/static/src/components/dialog_manager/dialog_manager.js b/addons/mail/static/src/components/dialog_manager/dialog_manager.js
new file mode 100644
index 00000000000..810b7247fe2
--- /dev/null
+++ b/addons/mail/static/src/components/dialog_manager/dialog_manager.js
@@ -0,0 +1,58 @@
+odoo.define('mail/static/src/components/dialog_manager/dialog_manager.js', function (require) {
+'use strict';
+
+const components = {
+ Dialog: require('mail/static/src/components/dialog/dialog.js'),
+};
+const useStore = require('mail/static/src/component_hooks/use_store/use_store.js');
+
+const { Component } = owl;
+
+class DialogManager extends Component {
+
+ /**
+ * @override
+ */
+ constructor(...args) {
+ super(...args);
+ useStore(props => {
+ return {
+ dialogManager: this.env.messaging.dialogManager.__state,
+ };
+ });
+ }
+
+ mounted() {
+ this._checkDialogOpen();
+ }
+
+ patched() {
+ this._checkDialogOpen();
+ }
+
+ //--------------------------------------------------------------------------
+ // Private
+ //--------------------------------------------------------------------------
+
+ /**
+ * @private
+ */
+ _checkDialogOpen() {
+ if (this.env.messaging.dialogManager.dialogs.length > 0) {
+ document.body.classList.add('modal-open');
+ } else {
+ document.body.classList.remove('modal-open');
+ }
+ }
+
+}
+
+Object.assign(DialogManager, {
+ components,
+ props: {},
+ template: 'mail.DialogManager',
+});
+
+return DialogManager;
+
+});
diff --git a/addons/mail/static/src/components/dialog_manager/dialog_manager.xml b/addons/mail/static/src/components/dialog_manager/dialog_manager.xml
new file mode 100644
index 00000000000..994d7d9c9d7
--- /dev/null
+++ b/addons/mail/static/src/components/dialog_manager/dialog_manager.xml
@@ -0,0 +1,15 @@
+<?xml version="1.0" encoding="UTF-8"?>
+<templates xml:space="preserve">
+
+ <t t-name="mail.DialogManager" owl="1">
+ <div class="o_DialogManager">
+ <t t-foreach="env.messaging.dialogManager.allOrdered" t-as="dialog" t-key="dialog.localId">
+ <Dialog
+ class="o_DialogManager_dialog"
+ dialogLocalId="dialog.localId"
+ />
+ </t>
+ </div>
+ </t>
+
+</templates>
diff --git a/addons/mail/static/src/components/discuss/discuss.js b/addons/mail/static/src/components/discuss/discuss.js
new file mode 100644
index 00000000000..271235bccb0
--- /dev/null
+++ b/addons/mail/static/src/components/discuss/discuss.js
@@ -0,0 +1,277 @@
+odoo.define('mail/static/src/components/discuss/discuss.js', function (require) {
+'use strict';
+
+const components = {
+ AutocompleteInput: require('mail/static/src/components/autocomplete_input/autocomplete_input.js'),
+ Composer: require('mail/static/src/components/composer/composer.js'),
+ DiscussMobileMailboxSelection: require('mail/static/src/components/discuss_mobile_mailbox_selection/discuss_mobile_mailbox_selection.js'),
+ DiscussSidebar: require('mail/static/src/components/discuss_sidebar/discuss_sidebar.js'),
+ MobileMessagingNavbar: require('mail/static/src/components/mobile_messaging_navbar/mobile_messaging_navbar.js'),
+ ModerationDiscardDialog: require('mail/static/src/components/moderation_discard_dialog/moderation_discard_dialog.js'),
+ ModerationRejectDialog: require('mail/static/src/components/moderation_reject_dialog/moderation_reject_dialog.js'),
+ NotificationList: require('mail/static/src/components/notification_list/notification_list.js'),
+ ThreadViewer: require('mail/static/src/components/thread_viewer/thread_viewer.js'),
+};
+const useStore = require('mail/static/src/component_hooks/use_store/use_store.js');
+
+const { Component } = owl;
+const { useRef } = owl.hooks;
+
+class Discuss extends Component {
+
+ /**
+ * @override
+ */
+ constructor(...args) {
+ super(...args);
+ useStore(props => {
+ const discuss = this.env.messaging.discuss;
+ return {
+ checkedMessages: discuss.threadViewer.checkedMessages.map(message => message.__state),
+ discuss: discuss.__state,
+ isDeviceMobile: this.env.messaging.device.isMobile,
+ isMessagingInitialized: this.env.messaging.isInitialized,
+ thread: discuss.thread ? discuss.thread.__state : undefined,
+ threadCache: discuss.threadViewer.threadCache
+ ? discuss.threadViewer.threadCache.__state
+ : undefined,
+ uncheckedMessages: discuss.threadViewer.uncheckedMessages.map(message => message.__state),
+ };
+ }, {
+ compareDepth: {
+ checkedMessages: 1,
+ uncheckedMessages: 1,
+ },
+ });
+ this._updateLocalStoreProps();
+ /**
+ * Reference of the thread. Useful to update scroll position correctly
+ * on patch. AKU TODO: this made sense when composer was outside of
+ * thread, but this may no longer be necessary??
+ */
+ this._threadRef = useRef('thread');
+
+ // bind since passed as props
+ this._onMobileAddItemHeaderInputSelect = this._onMobileAddItemHeaderInputSelect.bind(this);
+ this._onMobileAddItemHeaderInputSource = this._onMobileAddItemHeaderInputSource.bind(this);
+ }
+
+ mounted() {
+ this.discuss.update({ isOpen: true });
+ if (this.discuss.thread) {
+ this.trigger('o-push-state-action-manager');
+ } else if (this.env.messaging.isInitialized) {
+ this.discuss.openInitThread();
+ }
+ this._updateLocalStoreProps();
+ }
+
+ patched() {
+ this.trigger('o-update-control-panel');
+ if (this.discuss.thread) {
+ this.trigger('o-push-state-action-manager');
+ }
+ if (
+ this.discuss.thread &&
+ this.discuss.thread === this.env.messaging.inbox &&
+ this._lastThreadCache === this.discuss.threadViewer.threadCache.localId &&
+ this._lastThreadCounter > 0 && this.discuss.thread.counter === 0
+ ) {
+ this.trigger('o-show-rainbow-man');
+ }
+ this._activeThreadCache = this.discuss.threadViewer.threadCache;
+ this._updateLocalStoreProps();
+ }
+
+ willUnmount() {
+ if (this.discuss) {
+ this.discuss.close();
+ }
+ }
+
+ //--------------------------------------------------------------------------
+ // Public
+ //--------------------------------------------------------------------------
+
+ /**
+ * @returns {string}
+ */
+ get addChannelInputPlaceholder() {
+ return this.env._t("Create or search channel...");
+ }
+
+ /**
+ * @returns {string}
+ */
+ get addChatInputPlaceholder() {
+ return this.env._t("Search user...");
+ }
+
+ /**
+ * @returns {mail.discuss}
+ */
+ get discuss() {
+ return this.env.messaging && this.env.messaging.discuss;
+ }
+
+ /**
+ * @returns {Object[]}
+ */
+ mobileNavbarTabs() {
+ return [{
+ icon: 'fa fa-inbox',
+ id: 'mailbox',
+ label: this.env._t("Mailboxes"),
+ }, {
+ icon: 'fa fa-user',
+ id: 'chat',
+ label: this.env._t("Chat"),
+ }, {
+ icon: 'fa fa-users',
+ id: 'channel',
+ label: this.env._t("Channel"),
+ }];
+ }
+
+ //--------------------------------------------------------------------------
+ // Private
+ //--------------------------------------------------------------------------
+
+ /**
+ * @private
+ */
+ _updateLocalStoreProps() {
+ /**
+ * Locally tracked store props `activeThreadCache`.
+ * Useful to set scroll position from last stored one and to display
+ * rainbox man on inbox.
+ */
+ this._lastThreadCache = (
+ this.discuss.threadViewer.threadCache &&
+ this.discuss.threadViewer.threadCache.localId
+ );
+ /**
+ * Locally tracked store props `threadCounter`.
+ * Useful to display the rainbow man on inbox.
+ */
+ this._lastThreadCounter = (
+ this.discuss.thread &&
+ this.discuss.thread.counter
+ );
+ }
+
+ //--------------------------------------------------------------------------
+ // Handlers
+ //--------------------------------------------------------------------------
+
+ /**
+ * @private
+ */
+ _onDialogClosedModerationDiscard() {
+ this.discuss.update({ hasModerationDiscardDialog: false });
+ }
+
+ /**
+ * @private
+ */
+ _onDialogClosedModerationReject() {
+ this.discuss.update({ hasModerationRejectDialog: false });
+ }
+
+ /**
+ * @private
+ * @param {CustomEvent} ev
+ */
+ _onHideMobileAddItemHeader(ev) {
+ ev.stopPropagation();
+ this.discuss.clearIsAddingItem();
+ }
+
+ /**
+ * @private
+ * @param {Event} ev
+ * @param {Object} ui
+ * @param {Object} ui.item
+ * @param {integer} ui.item.id
+ */
+ _onMobileAddItemHeaderInputSelect(ev, ui) {
+ const discuss = this.discuss;
+ if (discuss.isAddingChannel) {
+ discuss.handleAddChannelAutocompleteSelect(ev, ui);
+ } else {
+ discuss.handleAddChatAutocompleteSelect(ev, ui);
+ }
+ }
+
+ /**
+ * @private
+ * @param {Object} req
+ * @param {string} req.term
+ * @param {function} res
+ */
+ _onMobileAddItemHeaderInputSource(req, res) {
+ if (this.discuss.isAddingChannel) {
+ this.discuss.handleAddChannelAutocompleteSource(req, res);
+ } else {
+ this.discuss.handleAddChatAutocompleteSource(req, res);
+ }
+ }
+
+ /**
+ * @private
+ */
+ _onReplyingToMessageMessagePosted() {
+ this.env.services['notification'].notify({
+ message: _.str.sprintf(
+ this.env._t(`Message posted on "%s"`),
+ owl.utils.escape(this.discuss.replyingToMessage.originThread.displayName)
+ ),
+ type: 'warning',
+ });
+ this.discuss.clearReplyingToMessage();
+ }
+
+ /**
+ * @private
+ * @param {CustomEvent} ev
+ * @param {Object} ev.detail
+ * @param {string} ev.detail.tabId
+ */
+ _onSelectMobileNavbarTab(ev) {
+ ev.stopPropagation();
+ if (this.discuss.activeMobileNavbarTabId === ev.detail.tabId) {
+ return;
+ }
+ this.discuss.clearReplyingToMessage();
+ this.discuss.update({ activeMobileNavbarTabId: ev.detail.tabId });
+ }
+
+ /**
+ * @private
+ * @param {CustomEvent} ev
+ * @param {Object} ev.detail
+ * @param {mail.thread} ev.detail.thread
+ */
+ _onSelectThread(ev) {
+ this.discuss.threadViewer.update({ thread: [['link', ev.detail.thread]] });
+ }
+
+ /**
+ * @private
+ * @param {CustomEvent} ev
+ */
+ _onThreadRendered(ev) {
+ this.trigger('o-update-control-panel');
+ }
+
+}
+
+Object.assign(Discuss, {
+ components,
+ props: {},
+ template: 'mail.Discuss',
+});
+
+return Discuss;
+
+});
diff --git a/addons/mail/static/src/components/discuss/discuss.scss b/addons/mail/static/src/components/discuss/discuss.scss
new file mode 100644
index 00000000000..5cddf1017ba
--- /dev/null
+++ b/addons/mail/static/src/components/discuss/discuss.scss
@@ -0,0 +1,114 @@
+// ------------------------------------------------------------------
+// Layout
+// ------------------------------------------------------------------
+
+.o_action_manager {
+ // bug with scrollable inside discuss mobile without this...
+ min-height: 0;
+}
+
+.o-autogrow {
+ flex: 1 1 auto;
+}
+
+.o_Discuss {
+ display: flex;
+ height: 100%;
+ min-height: 0;
+
+ &.o-mobile {
+ flex-flow: column;
+ align-items: center;
+ }
+}
+
+.o_Discuss_chatWindowHeader {
+ width: 100%;
+ flex: 0 0 auto;
+}
+
+.o_Discuss_content {
+ height: 100%;
+ overflow: auto;
+ flex: 1 1 auto;
+ display: flex;
+ flex-flow: column;
+}
+
+.o_Discuss_messagingNotInitialized {
+ flex: 1 1 auto;
+ display: flex;
+ align-items: center;
+ justify-content: center;
+}
+
+.o_Discuss_messagingNotInitializedIcon {
+ margin-right: 3px;
+}
+
+.o_Discuss_mobileAddItemHeader {
+ display: flex;
+ justify-content: center;
+ width: 100%;
+ padding: 0 10px;
+}
+
+.o_Discuss_mobileAddItemHeaderInput {
+ flex: 1 1 auto;
+ margin-bottom: 8px;
+ padding: 8px;
+}
+
+.o_Discuss_mobileMailboxSelection {
+ width: 100%;
+}
+
+.o_Discuss_mobileNavbar {
+ width: 100%;
+}
+
+.o_Discuss_noThread {
+ display: flex;
+ flex: 1 1 auto;
+ width: 100%;
+ align-items: center;
+ justify-content: center;
+}
+
+.o_Discuss_replyingToMessageComposer {
+ width: 100%;
+}
+
+.o_Discuss_sidebar {
+ height: 100%;
+ overflow: auto;
+ padding-top: 10px;
+ flex: 0 0 auto;
+}
+
+.o_Discuss_thread {
+ flex: 1 1 auto;
+
+ &.o-mobile {
+ width: 100%;
+ }
+}
+
+.o_Discuss_notificationList {
+ width: 100%;
+ flex: 1 1 auto;
+}
+// ------------------------------------------------------------------
+// Style
+// ------------------------------------------------------------------
+
+.o_Discuss.o-mobile {
+ background-color: white;
+}
+
+.o_Discuss_mobileAddItemHeaderInput {
+ appearance: none;
+ border: 1px solid gray('400');
+ border-radius: 5px;
+ outline: none;
+}
diff --git a/addons/mail/static/src/components/discuss/discuss.xml b/addons/mail/static/src/components/discuss/discuss.xml
new file mode 100644
index 00000000000..789c7a465c8
--- /dev/null
+++ b/addons/mail/static/src/components/discuss/discuss.xml
@@ -0,0 +1,106 @@
+<?xml version="1.0" encoding="UTF-8"?>
+<templates xml:space="preserve">
+
+ <t t-name="mail.Discuss" owl="1">
+ <div class="o_Discuss"
+ t-att-class="{
+ 'o-adding-item': discuss.isAddingChannel or discuss.isAddingChat,
+ 'o-mobile': env.messaging.device.isMobile,
+ }"
+ >
+ <t t-if="!env.messaging.isInitialized">
+ <div class="o_Discuss_messagingNotInitialized"><i class="o_Discuss_messagingNotInitializedIcon fa fa-spinner fa-spin"/>Please wait...</div>
+ </t>
+ <t t-else="">
+ <t t-if="!env.messaging.device.isMobile">
+ <DiscussSidebar class="o_Discuss_sidebar"/>
+ </t>
+ <t t-if="env.messaging.device.isMobile" t-call="mail.Discuss.content"/>
+ <t t-else="">
+ <div class="o_Discuss_content">
+ <t t-call="mail.Discuss.content"/>
+ </div>
+ </t>
+ </t>
+
+ <t t-if="discuss.hasModerationDiscardDialog">
+ <ModerationDiscardDialog messageLocalIds="discuss.threadViewer.checkedMessages.map(message => message.localId)" t-on-dialog-closed="_onDialogClosedModerationDiscard"/>
+ </t>
+ <t t-if="discuss.hasModerationRejectDialog">
+ <ModerationRejectDialog messageLocalIds="discuss.threadViewer.checkedMessages.map(message => message.localId)" t-on-dialog-closed="_onDialogClosedModerationReject"/>
+ </t>
+ </div>
+ </t>
+
+ <t t-name="mail.Discuss.content" owl="1">
+ <t t-if="env.messaging.device.isMobile and discuss.activeMobileNavbarTabId === 'mailbox'">
+ <DiscussMobileMailboxSelection class="o_Discuss_mobileMailboxSelection"/>
+ </t>
+ <t t-if="env.messaging.device.isMobile and (discuss.isAddingChannel or discuss.isAddingChat)">
+ <div class="o_Discuss_mobileAddItemHeader">
+ <AutocompleteInput
+ class="o_Discuss_mobileAddItemHeaderInput"
+ isFocusOnMount="true"
+ isHtml="discuss.isAddingChannel"
+ placeholder="discuss.isAddingChannel ? addChannelInputPlaceholder : addChatInputPlaceholder"
+ select="_onMobileAddItemHeaderInputSelect"
+ source="_onMobileAddItemHeaderInputSource"
+ t-on-o-hide="_onHideMobileAddItemHeader"
+ />
+ </div>
+ </t>
+ <t t-if="(env.messaging.device.isMobile and discuss.activeMobileNavbarTabId === 'mailbox' and discuss.thread and discuss.thread.model === 'mail.box') or (!env.messaging.device.isMobile and discuss.thread)">
+ <ThreadViewer
+ class="o_Discuss_thread"
+ t-att-class="{ 'o-mobile': env.messaging.device.isMobile }"
+ composerAttachmentsDetailsMode="'card'"
+ hasComposer="discuss.thread.model !== 'mail.box'"
+ hasComposerCurrentPartnerAvatar="!env.messaging.device.isMobile"
+ hasComposerThreadTyping="true"
+ hasMessageCheckbox="true"
+ hasSquashCloseMessages="discuss.thread.model !== 'mail.box'"
+ haveMessagesAuthorRedirect="discuss.thread.channel_type !== 'chat'"
+ haveMessagesMarkAsReadIcon="discuss.thread === env.messaging.inbox"
+ haveMessagesReplyIcon="discuss.thread === env.messaging.inbox"
+ selectedMessageLocalId="
+ discuss.replyingToMessage and
+ discuss.replyingToMessage.localId"
+ threadViewerLocalId="discuss.threadViewer.localId"
+ t-on-o-rendered="_onThreadRendered"
+ />
+ </t>
+ <t t-if="!discuss.thread and (!env.messaging.device.isMobile or discuss.activeMobileNavbarTabId === 'mailbox')">
+ <div class="o_Discuss_noThread">
+ No conversation selected.
+ </div>
+ </t>
+ <t t-if="env.messaging.device.isMobile and discuss.activeMobileNavbarTabId !== 'mailbox'">
+ <NotificationList
+ class="o_Discuss_notificationList"
+ filter="discuss.activeMobileNavbarTabId"
+ t-on-o-select-thread="_onSelectThread"
+ />
+ </t>
+ <t t-if="env.messaging.device.isMobile and !discuss.isReplyingToMessage">
+ <MobileMessagingNavbar
+ class="o_Discuss_mobileNavbar"
+ activeTabId="discuss.activeMobileNavbarTabId"
+ tabs="mobileNavbarTabs()"
+ t-on-o-select-mobile-messaging-navbar-tab="_onSelectMobileNavbarTab"
+ />
+ </t>
+ <t t-if="discuss.isReplyingToMessage">
+ <Composer
+ class="o_Discuss_replyingToMessageComposer"
+ composerLocalId="discuss.replyingToMessage.originThread.composer.localId"
+ hasCurrentPartnerAvatar="!env.messaging.device.isMobile"
+ hasDiscardButton="true"
+ hasThreadName="true"
+ isDiscardOnClickAway="true"
+ isFocusOnMount="true"
+ t-on-o-message-posted="_onReplyingToMessageMessagePosted"
+ />
+ </t>
+ </t>
+
+</templates>
diff --git a/addons/mail/static/src/components/discuss/discuss_tests.js b/addons/mail/static/src/components/discuss/discuss_tests.js
new file mode 100644
index 00000000000..7a37349041f
--- /dev/null
+++ b/addons/mail/static/src/components/discuss/discuss_tests.js
@@ -0,0 +1,5523 @@
+odoo.define('mail/static/src/components/discuss/discuss_tests.js', function (require) {
+'use strict';
+
+const BusService = require('bus.BusService');
+
+const {
+ afterEach: utilsAfterEach,
+ afterNextRender,
+ beforeEach: utilsBeforeEach,
+ inputFiles,
+ start: utilsStart,
+} = require('mail/static/src/utils/test_utils.js');
+
+const Bus = require('web.Bus');
+const { makeTestPromise, file: { createFile } } = require('web.test_utils');
+
+QUnit.module('mail', {}, function () {
+QUnit.module('components', {}, function () {
+QUnit.module('discuss', {}, function () {
+QUnit.module('discuss_tests.js', {
+ beforeEach() {
+ utilsBeforeEach(this);
+
+ this.start = async params => {
+ if (this.widget) {
+ this.widget.destroy();
+ }
+ let { env, widget } = await utilsStart(Object.assign({}, params, {
+ autoOpenDiscuss: true,
+ data: this.data,
+ hasDiscuss: true,
+ }));
+ this.env = env;
+ this.widget = widget;
+ };
+ },
+ afterEach() {
+ utilsAfterEach(this);
+ if (this.widget) {
+ this.widget.destroy();
+ }
+ },
+});
+
+QUnit.test('messaging not initialized', async function (assert) {
+ assert.expect(1);
+
+ await this.start({
+ async mockRPC(route) {
+ if (route === '/mail/init_messaging') {
+ // simulate messaging never initialized
+ return new Promise(resolve => {});
+ }
+ return this._super(...arguments);
+ },
+ waitUntilMessagingInitialized: false,
+ });
+ assert.strictEqual(
+ document.querySelectorAll('.o_Discuss_messagingNotInitialized').length,
+ 1,
+ "should display messaging not initialized"
+ );
+});
+
+QUnit.test('messaging becomes initialized', async function (assert) {
+ assert.expect(2);
+
+ const messagingInitializedProm = makeTestPromise();
+
+ await this.start({
+ async mockRPC(route) {
+ const _super = this._super.bind(this, ...arguments); // limitation of class.js
+ if (route === '/mail/init_messaging') {
+ await messagingInitializedProm;
+ }
+ return _super();
+ },
+ waitUntilMessagingInitialized: false,
+ });
+ assert.strictEqual(
+ document.querySelectorAll('.o_Discuss_messagingNotInitialized').length,
+ 1,
+ "should display messaging not initialized"
+ );
+
+ await afterNextRender(() => messagingInitializedProm.resolve());
+ assert.strictEqual(
+ document.querySelectorAll('.o_Discuss_messagingNotInitialized').length,
+ 0,
+ "should no longer display messaging not initialized"
+ );
+});
+
+QUnit.test('basic rendering', async function (assert) {
+ assert.expect(4);
+
+ await this.start();
+ assert.strictEqual(
+ document.querySelectorAll('.o_Discuss_sidebar').length,
+ 1,
+ "should have a sidebar section"
+ );
+ assert.strictEqual(
+ document.querySelectorAll('.o_Discuss_content').length,
+ 1,
+ "should have content section"
+ );
+ assert.strictEqual(
+ document.querySelectorAll('.o_Discuss_thread').length,
+ 1,
+ "should have thread section inside content"
+ );
+ assert.ok(
+ document.querySelector('.o_Discuss_thread').classList.contains('o_ThreadViewer'),
+ "thread section should use ThreadViewer component"
+ );
+});
+
+QUnit.test('basic rendering: sidebar', async function (assert) {
+ assert.expect(20);
+
+ await this.start();
+ assert.strictEqual(
+ document.querySelectorAll(`.o_DiscussSidebar_group`).length,
+ 3,
+ "should have 3 groups in sidebar"
+ );
+ assert.strictEqual(
+ document.querySelectorAll(`.o_DiscussSidebar_groupMailbox`).length,
+ 1,
+ "should have group 'Mailbox' in sidebar"
+ );
+ assert.strictEqual(
+ document.querySelectorAll(`
+ .o_DiscussSidebar_groupMailbox .o_DiscussSidebar_groupHeader
+ `).length,
+ 0,
+ "mailbox category should not have any header"
+ );
+ assert.strictEqual(
+ document.querySelectorAll(`
+ .o_DiscussSidebar_groupMailbox .o_DiscussSidebar_item
+ `).length,
+ 3,
+ "should have 3 mailbox items"
+ );
+ assert.strictEqual(
+ document.querySelectorAll(`
+ .o_DiscussSidebar_groupMailbox
+ .o_DiscussSidebar_item[data-thread-local-id="${
+ this.env.messaging.inbox.localId
+ }"]
+ `).length,
+ 1,
+ "should have inbox mailbox item"
+ );
+ assert.strictEqual(
+ document.querySelectorAll(`
+ .o_DiscussSidebar_groupMailbox
+ .o_DiscussSidebar_item[data-thread-local-id="${
+ this.env.messaging.starred.localId
+ }"]
+ `).length,
+ 1,
+ "should have starred mailbox item"
+ );
+ assert.strictEqual(
+ document.querySelectorAll(`
+ .o_DiscussSidebar_groupMailbox
+ .o_DiscussSidebar_item[data-thread-local-id="${
+ this.env.messaging.history.localId
+ }"]
+ `).length,
+ 1,
+ "should have history mailbox item"
+ );
+ assert.strictEqual(
+ document.querySelectorAll(`.o_Discuss_sidebar .o_DiscussSidebar_separator`).length,
+ 1,
+ "should have separator (between mailboxes and channels, but that's not tested)"
+ );
+ assert.strictEqual(
+ document.querySelectorAll(`.o_DiscussSidebar_groupChannel`).length,
+ 1,
+ "should have group 'Channel' in sidebar"
+ );
+ assert.strictEqual(
+ document.querySelectorAll(`
+ .o_DiscussSidebar_groupChannel .o_DiscussSidebar_groupHeader
+ `).length,
+ 1,
+ "channel category should have a header"
+ );
+ assert.strictEqual(
+ document.querySelectorAll(`
+ .o_DiscussSidebar_groupChannel .o_DiscussSidebar_groupTitle
+ `).length,
+ 1,
+ "should have title in channel header"
+ );
+ assert.strictEqual(
+ document.querySelector(`
+ .o_DiscussSidebar_groupChannel .o_DiscussSidebar_groupTitle
+ `).textContent.trim(),
+ "Channels"
+ );
+ assert.strictEqual(
+ document.querySelectorAll(`.o_DiscussSidebar_groupChannel .o_DiscussSidebar_list`).length,
+ 1,
+ "channel category should list items"
+ );
+ assert.strictEqual(
+ document.querySelectorAll(`.o_DiscussSidebar_groupChannel .o_DiscussSidebar_item`).length,
+ 0,
+ "channel category should have no item by default"
+ );
+ assert.strictEqual(
+ document.querySelectorAll(`.o_DiscussSidebar_groupChat`).length,
+ 1,
+ "should have group 'Chat' in sidebar"
+ );
+ assert.strictEqual(
+ document.querySelectorAll(`.o_DiscussSidebar_groupChat .o_DiscussSidebar_groupHeader`).length,
+ 1,
+ "channel category should have a header"
+ );
+ assert.strictEqual(
+ document.querySelectorAll(`.o_DiscussSidebar_groupChat .o_DiscussSidebar_groupTitle`).length,
+ 1,
+ "should have title in chat header"
+ );
+ assert.strictEqual(
+ document.querySelector(`
+ .o_DiscussSidebar_groupChat .o_DiscussSidebar_groupTitle
+ `).textContent.trim(),
+ "Direct Messages"
+ );
+ assert.strictEqual(
+ document.querySelectorAll(`.o_DiscussSidebar_groupChat .o_DiscussSidebar_list`).length,
+ 1,
+ "chat category should list items"
+ );
+ assert.strictEqual(
+ document.querySelectorAll(`.o_DiscussSidebar_groupChat .o_DiscussSidebar_item`).length,
+ 0,
+ "chat category should have no item by default"
+ );
+});
+
+QUnit.test('sidebar: basic mailbox rendering', async function (assert) {
+ assert.expect(6);
+
+ await this.start();
+ const inbox = document.querySelector(`
+ .o_DiscussSidebar_groupMailbox
+ .o_DiscussSidebar_item[data-thread-local-id="${
+ this.env.messaging.inbox.localId
+ }"]
+ `);
+ assert.strictEqual(
+ inbox.querySelectorAll(`:scope .o_DiscussSidebarItem_activeIndicator`).length,
+ 1,
+ "mailbox should have active indicator"
+ );
+ assert.strictEqual(
+ inbox.querySelectorAll(`:scope .o_ThreadIcon`).length,
+ 1,
+ "mailbox should have an icon"
+ );
+ assert.strictEqual(
+ inbox.querySelectorAll(`:scope .o_ThreadIcon_mailboxInbox`).length,
+ 1,
+ "inbox should have 'inbox' icon"
+ );
+ assert.strictEqual(
+ inbox.querySelectorAll(`:scope .o_DiscussSidebarItem_name`).length,
+ 1,
+ "mailbox should have a name"
+ );
+ assert.strictEqual(
+ inbox.querySelector(`:scope .o_DiscussSidebarItem_name`).textContent,
+ "Inbox",
+ "inbox should have name 'Inbox'"
+ );
+ assert.strictEqual(
+ document.querySelectorAll(`
+ .o_DiscussSidebar_item[data-thread-local-id="${
+ this.env.messaging.inbox.localId
+ }"]
+ .o_DiscussSidebarItem_counter
+ `).length,
+ 0,
+ "should have no counter when equal to 0 (default value)"
+ );
+});
+
+QUnit.test('sidebar: default active inbox', async function (assert) {
+ assert.expect(1);
+
+ await this.start();
+ const inbox = document.querySelector(`
+ .o_DiscussSidebar_groupMailbox
+ .o_DiscussSidebar_item[data-thread-local-id="${
+ this.env.messaging.inbox.localId
+ }"]
+ `);
+ assert.ok(
+ inbox.querySelector(`
+ :scope .o_DiscussSidebarItem_activeIndicator
+ `).classList.contains('o-item-active'),
+ "inbox should be active by default"
+ );
+});
+
+QUnit.test('sidebar: change item', async function (assert) {
+ assert.expect(4);
+
+ await this.start();
+ assert.ok(
+ document.querySelector(`
+ .o_DiscussSidebar_item[data-thread-local-id="${
+ this.env.messaging.inbox.localId
+ }"]
+ .o_DiscussSidebarItem_activeIndicator
+ `).classList.contains('o-item-active'),
+ "inbox should be active by default"
+ );
+ assert.notOk(
+ document.querySelector(`
+ .o_DiscussSidebar_item[data-thread-local-id="${
+ this.env.messaging.starred.localId
+ }"]
+ .o_DiscussSidebarItem_activeIndicator
+ `).classList.contains('o-item-active'),
+ "starred should be inactive by default"
+ );
+
+ await afterNextRender(() =>
+ document.querySelector(`
+ .o_DiscussSidebar_item[data-thread-local-id="${
+ this.env.messaging.starred.localId
+ }"]
+ `).click()
+ );
+ assert.notOk(
+ document.querySelector(`
+ .o_DiscussSidebar_item[data-thread-local-id="${
+ this.env.messaging.inbox.localId
+ }"]
+ .o_DiscussSidebarItem_activeIndicator
+ `).classList.contains('o-item-active'),
+ "inbox mailbox should become inactive"
+ );
+ assert.ok(
+ document.querySelector(`
+ .o_DiscussSidebar_item[data-thread-local-id="${
+ this.env.messaging.starred.localId
+ }"]
+ .o_DiscussSidebarItem_activeIndicator
+ `).classList.contains('o-item-active'),
+ "starred mailbox should become active");
+});
+
+QUnit.test('sidebar: inbox with counter', async function (assert) {
+ assert.expect(2);
+
+ Object.assign(this.data.initMessaging, {
+ needaction_inbox_counter: 100,
+ });
+ await this.start();
+ assert.strictEqual(
+ document.querySelectorAll(`
+ .o_DiscussSidebar_item[data-thread-local-id="${
+ this.env.messaging.inbox.localId
+ }"]
+ .o_DiscussSidebarItem_counter
+ `).length,
+ 1,
+ "should display a counter (= have a counter when different from 0)"
+ );
+ assert.strictEqual(
+ document.querySelector(`
+ .o_DiscussSidebar_item[data-thread-local-id="${
+ this.env.messaging.inbox.localId
+ }"]
+ .o_DiscussSidebarItem_counter
+ `).textContent,
+ "100",
+ "should have counter value"
+ );
+});
+
+QUnit.test('sidebar: add channel', async function (assert) {
+ assert.expect(3);
+
+ await this.start();
+ assert.strictEqual(
+ document.querySelectorAll(`
+ .o_DiscussSidebar_groupChannel
+ .o_DiscussSidebar_groupHeaderItemAdd
+ `).length,
+ 1,
+ "should be able to add channel from header"
+ );
+ assert.strictEqual(
+ document.querySelector(`
+ .o_DiscussSidebar_groupChannel
+ .o_DiscussSidebar_groupHeaderItemAdd
+ `).title,
+ "Add or join a channel");
+
+ await afterNextRender(() =>
+ document.querySelector(`
+ .o_DiscussSidebar_groupChannel .o_DiscussSidebar_groupHeaderItemAdd
+ `).click()
+ );
+ assert.strictEqual(
+ document.querySelectorAll(`.o_DiscussSidebar_groupChannel .o_DiscussSidebar_itemNew`).length,
+ 1,
+ "should have item to add a new channel"
+ );
+});
+
+QUnit.test('sidebar: basic channel rendering', async function (assert) {
+ assert.expect(14);
+
+ Object.assign(this.data.initMessaging, {
+ channel_slots: {
+ channel_channel: [{
+ channel_type: "channel",
+ id: 20,
+ name: "General",
+ }],
+ },
+ });
+ await this.start();
+ assert.strictEqual(
+ document.querySelectorAll(`.o_DiscussSidebar_groupChannel .o_DiscussSidebar_item`).length,
+ 1,
+ "should have one channel item");
+ let channel = document.querySelector(`
+ .o_DiscussSidebar_groupChannel
+ .o_DiscussSidebar_item
+ `);
+ assert.strictEqual(
+ channel.dataset.threadLocalId,
+ this.env.models['mail.thread'].find(thread =>
+ thread.id === 20 &&
+ thread.model === 'mail.channel'
+ ).localId,
+ "should have channel with Id 20"
+ );
+ assert.strictEqual(
+ channel.querySelectorAll(`:scope .o_DiscussSidebarItem_activeIndicator`).length,
+ 1,
+ "should have active indicator"
+ );
+ assert.strictEqual(
+ channel.querySelectorAll(`:scope .o_DiscussSidebarItem_activeIndicator.o-item-active`).length,
+ 0,
+ "should not be active by default"
+ );
+ assert.strictEqual(
+ channel.querySelectorAll(`:scope .o_ThreadIcon`).length,
+ 1,
+ "should have an icon"
+ );
+ assert.strictEqual(
+ channel.querySelectorAll(`:scope .o_DiscussSidebarItem_name`).length,
+ 1,
+ "should have a name"
+ );
+ assert.strictEqual(
+ channel.querySelector(`:scope .o_DiscussSidebarItem_name`).textContent,
+ "General",
+ "should have name value"
+ );
+ assert.strictEqual(
+ channel.querySelectorAll(`:scope .o_DiscussSidebarItem_commands`).length,
+ 1,
+ "should have commands"
+ );
+ assert.strictEqual(
+ channel.querySelectorAll(`:scope .o_DiscussSidebarItem_command`).length,
+ 2,
+ "should have 2 commands"
+ );
+ assert.strictEqual(
+ channel.querySelectorAll(`:scope .o_DiscussSidebarItem_commandSettings`).length,
+ 1,
+ "should have 'settings' command"
+ );
+ assert.strictEqual(
+ channel.querySelectorAll(`:scope .o_DiscussSidebarItem_commandLeave`).length,
+ 1,
+ "should have 'leave' command"
+ );
+ assert.strictEqual(
+ channel.querySelectorAll(`:scope .o_DiscussSidebarItem_counter`).length,
+ 0,
+ "should have a counter when equals 0 (default value)"
+ );
+
+ await afterNextRender(() =>
+ document.querySelector(`.o_DiscussSidebar_groupChannel .o_DiscussSidebar_item`).click()
+ );
+ channel = document.querySelector(`.o_DiscussSidebar_groupChannel .o_DiscussSidebar_item`);
+ assert.strictEqual(
+ channel.querySelectorAll(`:scope .o_DiscussSidebarItem_activeIndicator.o-item-active`).length,
+ 1,
+ "channel should become active"
+ );
+ assert.strictEqual(
+ document.querySelectorAll(`.o_Discuss_thread .o_ThreadViewer_composer`).length,
+ 1,
+ "should have composer section inside thread content (can post message in channel)"
+ );
+});
+
+QUnit.test('sidebar: channel rendering with needaction counter', async function (assert) {
+ assert.expect(5);
+
+ Object.assign(this.data.initMessaging, {
+ channel_slots: {
+ channel_channel: [{
+ channel_type: "channel",
+ id: 20,
+ name: "General",
+ message_needaction_counter: 10,
+ }],
+ },
+ });
+ await this.start();
+ const channel = document.querySelector(`.o_DiscussSidebar_groupChannel .o_DiscussSidebar_item`);
+ assert.strictEqual(
+ channel.querySelectorAll(`:scope .o_DiscussSidebarItem_counter`).length,
+ 1,
+ "should have a counter when different from 0"
+ );
+ assert.strictEqual(
+ channel.querySelector(`:scope .o_DiscussSidebarItem_counter`).textContent,
+ "10",
+ "should have counter value"
+ );
+ assert.strictEqual(
+ channel.querySelectorAll(`:scope .o_DiscussSidebarItem_command`).length,
+ 1,
+ "should have single command"
+ );
+ assert.strictEqual(
+ channel.querySelectorAll(`:scope .o_DiscussSidebarItem_commandSettings`).length,
+ 1,
+ "should have 'settings' command"
+ );
+ assert.strictEqual(
+ channel.querySelectorAll(`:scope .o_DiscussSidebarItem_commandLeave`).length,
+ 0,
+ "should not have 'leave' command"
+ );
+});
+
+QUnit.test('sidebar: public/private channel rendering', async function (assert) {
+ assert.expect(5);
+
+ Object.assign(this.data.initMessaging, {
+ channel_slots: {
+ channel_channel: [{
+ channel_type: "channel",
+ id: 100,
+ name: "channel1",
+ public: 'public',
+ }],
+ channel_private_group: [{
+ channel_type: "channel",
+ id: 101,
+ name: "channel2",
+ public: 'private',
+ }],
+ },
+ });
+ await this.start();
+ assert.strictEqual(
+ document.querySelectorAll(`.o_DiscussSidebar_groupChannel .o_DiscussSidebar_item`).length,
+ 2,
+ "should have 2 channel items"
+ );
+ assert.strictEqual(
+ document.querySelectorAll(`
+ .o_DiscussSidebar_groupChannel
+ .o_DiscussSidebar_item[data-thread-local-id="${
+ this.env.models['mail.thread'].find(thread =>
+ thread.id === 100 &&
+ thread.model === 'mail.channel'
+ ).localId
+ }"]
+ `).length,
+ 1,
+ "should have channel1 (Id 100)"
+ );
+ assert.strictEqual(
+ document.querySelectorAll(`
+ .o_DiscussSidebar_groupChannel
+ .o_DiscussSidebar_item[data-thread-local-id="${
+ this.env.models['mail.thread'].find(thread =>
+ thread.id === 101 &&
+ thread.model === 'mail.channel'
+ ).localId
+ }"]
+ `).length,
+ 1,
+ "should have channel2 (Id 101)"
+ );
+ const channel1 = document.querySelector(`
+ .o_DiscussSidebar_groupChannel
+ .o_DiscussSidebar_item[data-thread-local-id="${
+ this.env.models['mail.thread'].find(thread =>
+ thread.id === 100 &&
+ thread.model === 'mail.channel'
+ ).localId
+ }"]
+ `);
+ const channel2 = document.querySelector(`
+ .o_DiscussSidebar_groupChannel
+ .o_DiscussSidebar_item[data-thread-local-id="${
+ this.env.models['mail.thread'].find(thread =>
+ thread.id === 101 &&
+ thread.model === 'mail.channel'
+ ).localId
+ }"]
+ `);
+ assert.strictEqual(
+ channel1.querySelectorAll(`:scope .o_ThreadIcon_channelPublic`).length,
+ 1,
+ "channel1 (public) has hashtag icon"
+ );
+ assert.strictEqual(
+ channel2.querySelectorAll(`:scope .o_ThreadIcon_channelPrivate`).length,
+ 1,
+ "channel2 (private) has lock icon"
+ );
+});
+
+QUnit.test('sidebar: basic chat rendering', async function (assert) {
+ assert.expect(11);
+
+ Object.assign(this.data.initMessaging, {
+ channel_slots: {
+ channel_direct_message: [{
+ channel_type: "chat",
+ direct_partner: [{
+ id: 7,
+ name: "Demo",
+ }],
+ id: 10,
+ }],
+ },
+ });
+ await this.start();
+ assert.strictEqual(
+ document.querySelectorAll(`.o_DiscussSidebar_groupChat .o_DiscussSidebar_item`).length,
+ 1,
+ "should have one chat item"
+ );
+ const chat = document.querySelector(`.o_DiscussSidebar_groupChat .o_DiscussSidebar_item`);
+ assert.strictEqual(
+ chat.dataset.threadLocalId,
+ this.env.models['mail.thread'].find(thread =>
+ thread.id === 10 &&
+ thread.model === 'mail.channel'
+ ).localId,
+ "should have chat with Id 20"
+ );
+ assert.strictEqual(
+ chat.querySelectorAll(`:scope .o_DiscussSidebarItem_activeIndicator`).length,
+ 1,
+ "should have active indicator"
+ );
+ assert.strictEqual(
+ chat.querySelectorAll(`:scope .o_ThreadIcon`).length,
+ 1,
+ "should have an icon"
+ );
+ assert.strictEqual(
+ chat.querySelectorAll(`:scope .o_DiscussSidebarItem_name`).length,
+ 1,
+ "should have a name"
+ );
+ assert.strictEqual(
+ chat.querySelector(`:scope .o_DiscussSidebarItem_name`).textContent,
+ "Demo",
+ "should have correspondent name as name"
+ );
+ assert.strictEqual(
+ chat.querySelectorAll(`:scope .o_DiscussSidebarItem_commands`).length,
+ 1,
+ "should have commands"
+ );
+ assert.strictEqual(
+ chat.querySelectorAll(`:scope .o_DiscussSidebarItem_command`).length,
+ 2,
+ "should have 2 commands"
+ );
+ assert.strictEqual(
+ chat.querySelectorAll(`:scope .o_DiscussSidebarItem_commandRename`).length,
+ 1,
+ "should have 'rename' command"
+ );
+ assert.strictEqual(
+ chat.querySelectorAll(`:scope .o_DiscussSidebarItem_commandUnpin`).length,
+ 1,
+ "should have 'unpin' command"
+ );
+ assert.strictEqual(
+ chat.querySelectorAll(`:scope .o_DiscussSidebarItem_counter`).length,
+ 0,
+ "should have a counter when equals 0 (default value)"
+ );
+});
+
+QUnit.test('sidebar: chat rendering with unread counter', async function (assert) {
+ assert.expect(5);
+
+ Object.assign(this.data.initMessaging, {
+ channel_slots: {
+ channel_direct_message: [{
+ channel_type: "chat",
+ direct_partner: [{
+ id: 7,
+ name: "Demo",
+ }],
+ id: 10,
+ message_unread_counter: 100,
+ }],
+ },
+ });
+ await this.start();
+ const chat = document.querySelector(`.o_DiscussSidebar_groupChat .o_DiscussSidebar_item`);
+ assert.strictEqual(
+ chat.querySelectorAll(`:scope .o_DiscussSidebarItem_counter`).length,
+ 1,
+ "should have a counter when different from 0"
+ );
+ assert.strictEqual(
+ chat.querySelector(`:scope .o_DiscussSidebarItem_counter`).textContent,
+ "100",
+ "should have counter value"
+ );
+ assert.strictEqual(
+ chat.querySelectorAll(`:scope .o_DiscussSidebarItem_command`).length,
+ 1,
+ "should have single command"
+ );
+ assert.strictEqual(
+ chat.querySelectorAll(`:scope .o_DiscussSidebarItem_commandRename`).length,
+ 1,
+ "should have 'rename' command"
+ );
+ assert.strictEqual(
+ chat.querySelectorAll(`:scope .o_DiscussSidebarItem_commandUnpin`).length,
+ 0,
+ "should not have 'unpin' command"
+ );
+});
+
+QUnit.test('sidebar: chat im_status rendering', async function (assert) {
+ assert.expect(7);
+
+ Object.assign(this.data.initMessaging, {
+ channel_slots: {
+ channel_direct_message: [{
+ channel_type: "chat",
+ direct_partner: [{
+ id: 101,
+ im_status: 'offline',
+ name: "Partner1",
+ }],
+ id: 11,
+ }, {
+ channel_type: "chat",
+ direct_partner: [{
+ id: 102,
+ im_status: 'online',
+ name: "Partner2",
+ }],
+ id: 12,
+ }, {
+ channel_type: "chat",
+ direct_partner: [{
+ id: 103,
+ im_status: 'away',
+ name: "Partner3",
+ }],
+ id: 13,
+ }],
+ },
+ });
+ await this.start();
+ assert.strictEqual(
+ document.querySelectorAll(`.o_DiscussSidebar_groupChat .o_DiscussSidebar_item`).length,
+ 3,
+ "should have 3 chat items"
+ );
+ assert.strictEqual(
+ document.querySelectorAll(`
+ .o_DiscussSidebar_groupChat
+ .o_DiscussSidebar_item[data-thread-local-id="${
+ this.env.models['mail.thread'].find(thread =>
+ thread.id === 11 &&
+ thread.model === 'mail.channel'
+ ).localId
+ }"]
+ `).length,
+ 1,
+ "should have Partner1 (Id 11)"
+ );
+ assert.strictEqual(
+ document.querySelectorAll(`
+ .o_DiscussSidebar_groupChat
+ .o_DiscussSidebar_item[data-thread-local-id="${
+ this.env.models['mail.thread'].find(thread =>
+ thread.id === 12 &&
+ thread.model === 'mail.channel'
+ ).localId
+ }"]
+ `).length,
+ 1,
+ "should have Partner2 (Id 12)"
+ );
+ assert.strictEqual(
+ document.querySelectorAll(`
+ .o_DiscussSidebar_groupChat
+ .o_DiscussSidebar_item[data-thread-local-id="${
+ this.env.models['mail.thread'].find(thread =>
+ thread.id === 13 &&
+ thread.model === 'mail.channel'
+ ).localId
+ }"]
+ `).length,
+ 1,
+ "should have Partner3 (Id 13)"
+ );
+ const chat1 = document.querySelector(`
+ .o_DiscussSidebar_groupChat
+ .o_DiscussSidebar_item[data-thread-local-id="${
+ this.env.models['mail.thread'].find(thread =>
+ thread.id === 11 &&
+ thread.model === 'mail.channel'
+ ).localId
+ }"]
+ `);
+ const chat2 = document.querySelector(`
+ .o_DiscussSidebar_groupChat
+ .o_DiscussSidebar_item[data-thread-local-id="${
+ this.env.models['mail.thread'].find(thread =>
+ thread.id === 12 &&
+ thread.model === 'mail.channel'
+ ).localId
+ }"]
+ `);
+ const chat3 = document.querySelector(`
+ .o_DiscussSidebar_groupChat
+ .o_DiscussSidebar_item[data-thread-local-id="${
+ this.env.models['mail.thread'].find(thread =>
+ thread.id === 13 &&
+ thread.model === 'mail.channel'
+ ).localId
+ }"]
+ `);
+ assert.strictEqual(
+ chat1.querySelectorAll(`:scope .o_ThreadIcon_offline`).length,
+ 1,
+ "chat1 should have offline icon"
+ );
+ assert.strictEqual(
+ chat2.querySelectorAll(`:scope .o_ThreadIcon_online`).length,
+ 1,
+ "chat2 should have online icon"
+ );
+ assert.strictEqual(
+ chat3.querySelectorAll(`:scope .o_ThreadIcon_away`).length,
+ 1,
+ "chat3 should have away icon"
+ );
+});
+
+QUnit.test('sidebar: chat custom name', async function (assert) {
+ assert.expect(1);
+
+ Object.assign(this.data.initMessaging, {
+ channel_slots: {
+ channel_direct_message: [{
+ channel_type: "chat",
+ custom_channel_name: "Marc",
+ direct_partner: [{
+ id: 7,
+ name: "Marc Demo",
+ }],
+ id: 10,
+ }],
+ },
+ });
+ await this.start();
+ const chat = document.querySelector(`.o_DiscussSidebar_groupChat .o_DiscussSidebar_item`);
+ assert.strictEqual(
+ chat.querySelector(`:scope .o_DiscussSidebarItem_name`).textContent,
+ "Marc",
+ "chat should have custom name as name"
+ );
+});
+
+QUnit.test('sidebar: rename chat', async function (assert) {
+ assert.expect(8);
+
+ Object.assign(this.data.initMessaging, {
+ channel_slots: {
+ channel_direct_message: [{
+ custom_channel_name: "Marc",
+ channel_type: "chat",
+ direct_partner: [{
+ id: 7,
+ name: "Marc Demo",
+ }],
+ id: 10,
+ }],
+ },
+ });
+ await this.start({
+ async mockRPC(route, args) {
+ if (args.method === 'channel_set_custom_name') {
+ return;
+ }
+ return this._super(...arguments);
+ },
+ });
+ const chat = document.querySelector(`.o_DiscussSidebar_groupChat .o_DiscussSidebar_item`);
+ assert.strictEqual(
+ chat.querySelector(`:scope .o_DiscussSidebarItem_name`).textContent,
+ "Marc",
+ "chat should have custom name as name"
+ );
+ assert.notOk(
+ chat.querySelector(`:scope .o_DiscussSidebarItem_name`).classList.contains('o-editable'),
+ "chat name should not be editable"
+ );
+
+ await afterNextRender(() =>
+ chat.querySelector(`:scope .o_DiscussSidebarItem_commandRename`).click()
+ );
+ assert.ok(
+ chat.querySelector(`:scope .o_DiscussSidebarItem_name`).classList.contains('o-editable'),
+ "chat should have editable name"
+ );
+ assert.strictEqual(
+ chat.querySelectorAll(`:scope .o_DiscussSidebarItem_nameInput`).length,
+ 1,
+ "chat should have editable name input"
+ );
+ assert.strictEqual(
+ chat.querySelector(`:scope .o_DiscussSidebarItem_nameInput`).value,
+ "Marc",
+ "editable name input should have custom chat name as value by default"
+ );
+ assert.strictEqual(
+ chat.querySelector(`:scope .o_DiscussSidebarItem_nameInput`).placeholder,
+ "Marc Demo",
+ "editable name input should have partner name as placeholder"
+ );
+
+ await afterNextRender(() => {
+ chat.querySelector(`:scope .o_DiscussSidebarItem_nameInput`).value = "Demo";
+ const kevt = new window.KeyboardEvent('keydown', { key: "Enter" });
+ chat.querySelector(`:scope .o_DiscussSidebarItem_nameInput`).dispatchEvent(kevt);
+ });
+ assert.notOk(
+ chat.querySelector(`:scope .o_DiscussSidebarItem_name`).classList.contains('o-editable'),
+ "chat should no longer show editable name"
+ );
+ assert.strictEqual(
+ chat.querySelector(`:scope .o_DiscussSidebarItem_name`).textContent,
+ "Demo",
+ "chat should have renamed name as name"
+ );
+});
+
+QUnit.test('default thread rendering', async function (assert) {
+ assert.expect(16);
+
+ Object.assign(this.data.initMessaging, {
+ channel_slots: {
+ channel_channel: [{
+ channel_type: "channel",
+ id: 20,
+ name: "General",
+ }],
+ },
+ });
+ await this.start();
+ assert.strictEqual(
+ document.querySelectorAll(`
+ .o_DiscussSidebar_item[data-thread-local-id="${
+ this.env.messaging.inbox.localId
+ }"]
+ `).length,
+ 1,
+ "should have inbox mailbox in the sidebar"
+ );
+ assert.strictEqual(
+ document.querySelectorAll(`
+ .o_DiscussSidebar_item[data-thread-local-id="${
+ this.env.messaging.starred.localId
+ }"]
+ `).length,
+ 1,
+ "should have starred mailbox in the sidebar"
+ );
+ assert.strictEqual(
+ document.querySelectorAll(`
+ .o_DiscussSidebar_item[data-thread-local-id="${
+ this.env.messaging.history.localId
+ }"]
+ `).length,
+ 1,
+ "should have history mailbox in the sidebar"
+ );
+ assert.strictEqual(
+ document.querySelectorAll(`
+ .o_DiscussSidebar_item[data-thread-local-id="${
+ this.env.models['mail.thread'].find(thread =>
+ thread.id === 20 &&
+ thread.model === 'mail.channel'
+ ).localId
+ }"]
+ `).length,
+ 1,
+ "should have 'general' channel in the sidebar"
+ );
+ assert.ok(
+ document.querySelector(`
+ .o_DiscussSidebar_item[data-thread-local-id="${
+ this.env.messaging.inbox.localId
+ }"]
+ `).classList.contains('o-active'),
+ "inbox mailbox should be active thread"
+ );
+ assert.strictEqual(
+ document.querySelectorAll(`
+ .o_Discuss_thread .o_ThreadViewer_messageList .o_MessageList_empty
+ `).length,
+ 1,
+ "should have empty thread in inbox"
+ );
+ assert.strictEqual(
+ document.querySelector(`
+ .o_Discuss_thread .o_ThreadViewer_messageList .o_MessageList_empty
+ `).textContent.trim(),
+ "Congratulations, your inbox is empty New messages appear here."
+ );
+
+ await afterNextRender(() =>
+ document.querySelector(`
+ .o_DiscussSidebar_item[data-thread-local-id="${
+ this.env.messaging.starred.localId
+ }"]
+ `).click()
+ );
+ assert.ok(
+ document.querySelector(`
+ .o_DiscussSidebar_item[data-thread-local-id="${
+ this.env.messaging.starred.localId
+ }"]
+ `).classList.contains('o-active'),
+ "starred mailbox should be active thread"
+ );
+ assert.strictEqual(
+ document.querySelectorAll(`
+ .o_Discuss_thread .o_ThreadViewer_messageList .o_MessageList_empty
+ `).length,
+ 1,
+ "should have empty thread in starred"
+ );
+ assert.strictEqual(
+ document.querySelector(`
+ .o_Discuss_thread .o_ThreadViewer_messageList .o_MessageList_empty
+ `).textContent.trim(),
+ "No starred messages You can mark any message as 'starred', and it shows up in this mailbox."
+ );
+
+ await afterNextRender(() =>
+ document.querySelector(`
+ .o_DiscussSidebar_item[data-thread-local-id="${
+ this.env.messaging.history.localId
+ }"]
+ `).click()
+ );
+ assert.ok(
+ document.querySelector(`
+ .o_DiscussSidebar_item[data-thread-local-id="${
+ this.env.messaging.history.localId
+ }"]
+ `).classList.contains('o-active'),
+ "history mailbox should be active thread"
+ );
+ assert.strictEqual(
+ document.querySelectorAll(`
+ .o_Discuss_thread .o_ThreadViewer_messageList .o_MessageList_empty
+ `).length,
+ 1,
+ "should have empty thread in starred"
+ );
+ assert.strictEqual(
+ document.querySelector(`.o_Discuss_thread .o_MessageList_empty`).textContent.trim(),
+ "No history messages Messages marked as read will appear in the history."
+ );
+
+ await afterNextRender(() =>
+ document.querySelector(`
+ .o_DiscussSidebar_item[data-thread-local-id="${
+ this.env.models['mail.thread'].find(thread =>
+ thread.id === 20 &&
+ thread.model === 'mail.channel'
+ ).localId
+ }"]
+ `).click()
+ );
+ assert.ok(
+ document.querySelector(`
+ .o_DiscussSidebar_item[data-thread-local-id="${
+ this.env.models['mail.thread'].find(thread =>
+ thread.id === 20 &&
+ thread.model === 'mail.channel'
+ ).localId
+ }"]
+ `).classList.contains('o-active'),
+ "channel 'general' should be active thread"
+ );
+ assert.strictEqual(
+ document.querySelectorAll(`
+ .o_Discuss_thread .o_ThreadViewer_messageList .o_MessageList_empty
+ `).length,
+ 1,
+ "should have empty thread in starred"
+ );
+ assert.strictEqual(
+ document.querySelector(`
+ .o_Discuss_thread .o_ThreadViewer_messageList .o_MessageList_empty
+ `).textContent.trim(),
+ "There are no messages in this conversation."
+ );
+});
+
+QUnit.test('initially load messages from inbox', async function (assert) {
+ assert.expect(5);
+
+ await this.start({
+ async mockRPC(route, args) {
+ if (args.method === 'message_fetch') {
+ assert.step('message_fetch');
+ assert.strictEqual(
+ args.kwargs.limit,
+ 30,
+ "should fetch up to 30 messages"
+ );
+ assert.strictEqual(
+ args.args.length,
+ 1,
+ "should have a single item in args"
+ );
+ assert.deepEqual(
+ args.args[0],
+ [["needaction", "=", true]],
+ "should fetch needaction messages"
+ );
+ }
+ return this._super(...arguments);
+ },
+ });
+ assert.verifySteps(['message_fetch']);
+});
+
+QUnit.test('default select thread in discuss params', async function (assert) {
+ assert.expect(1);
+
+ await this.start({
+ discuss: {
+ params: {
+ default_active_id: 'mail.box_starred',
+ },
+ }
+ });
+ assert.ok(
+ document.querySelector(`
+ .o_DiscussSidebar_item[data-thread-local-id="${
+ this.env.messaging.starred.localId
+ }"]
+ .o_DiscussSidebarItem_activeIndicator
+ `).classList.contains('o-item-active'),
+ "starred mailbox should become active"
+ );
+});
+
+QUnit.test('auto-select thread in discuss context', async function (assert) {
+ assert.expect(1);
+
+ await this.start({
+ discuss: {
+ context: {
+ active_id: 'mail.box_starred',
+ },
+ },
+ });
+ assert.ok(
+ document.querySelector(`
+ .o_DiscussSidebar_item[data-thread-local-id="${
+ this.env.messaging.starred.localId
+ }"]
+ .o_DiscussSidebarItem_activeIndicator
+ `).classList.contains('o-item-active'),
+ "starred mailbox should become active"
+ );
+});
+
+QUnit.test('load single message from channel initially', async function (assert) {
+ assert.expect(8);
+
+ Object.assign(this.data.initMessaging, {
+ channel_slots: {
+ channel_channel: [{
+ channel_type: "channel",
+ id: 20,
+ name: "General",
+ }],
+ },
+ });
+ await this.start({
+ discuss: {
+ params: {
+ default_active_id: 'mail.channel_20',
+ },
+ },
+ async mockRPC(route, args) {
+ if (args.method === 'message_fetch') {
+ assert.strictEqual(
+ args.kwargs.limit,
+ 30,
+ "should fetch up to 30 messages"
+ );
+ assert.strictEqual(
+ args.args.length,
+ 1,
+ "should have a single item in args"
+ );
+ assert.deepEqual(
+ args.args[0],
+ [["channel_ids", "in", [20]]],
+ "should fetch messages from channel"
+ );
+ return [{
+ author_id: [11, "Demo"],
+ body: "<p>body</p>",
+ channel_ids: [20],
+ date: "2019-04-20 10:00:00",
+ id: 100,
+ message_type: 'comment',
+ model: 'mail.channel',
+ record_name: 'General',
+ res_id: 20,
+ }];
+ }
+ return this._super(...arguments);
+ },
+ });
+ assert.strictEqual(
+ document.querySelectorAll(`.o_Discuss_thread .o_ThreadViewer_messageList`).length,
+ 1,
+ "should have list of messages"
+ );
+ assert.strictEqual(
+ document.querySelectorAll(`.o_Discuss_thread .o_MessageList_separatorDate`).length,
+ 1,
+ "should have a single date separator" // to check: may be client timezone dependent
+ );
+ assert.strictEqual(
+ document.querySelector(`.o_Discuss_thread .o_MessageList_separatorLabelDate`).textContent,
+ "April 20, 2019",
+ "should display date day of messages"
+ );
+ assert.strictEqual(
+ document.querySelectorAll(`.o_Discuss_thread .o_MessageList_message`).length,
+ 1,
+ "should have a single message"
+ );
+ assert.strictEqual(
+ document.querySelectorAll(`
+ .o_Discuss_thread
+ .o_MessageList_message[data-message-local-id="${
+ this.env.models['mail.message'].find(message => message.id === 100).localId
+ }"]
+ `).length,
+ 1,
+ "should have message with Id 100"
+ );
+});
+
+QUnit.test('basic rendering of message', async function (assert) {
+ // AKU TODO: should be in message-only tests
+ assert.expect(13);
+
+ Object.assign(this.data.initMessaging, {
+ channel_slots: {
+ channel_channel: [{
+ channel_type: "channel",
+ id: 20,
+ name: "General",
+ }],
+ },
+ });
+ await this.start({
+ discuss: {
+ params: {
+ default_active_id: 'mail.channel_20',
+ },
+ },
+ async mockRPC(route, args) {
+ if (args.method === 'message_fetch') {
+ return [{
+ author_id: [11, "Demo"],
+ body: "<p>body</p>",
+ channel_ids: [20],
+ date: "2019-04-20 10:00:00",
+ id: 100,
+ message_type: 'comment',
+ model: 'mail.channel',
+ record_name: 'General',
+ res_id: 20,
+ }];
+ }
+ return this._super(...arguments);
+ },
+ });
+ const message = document.querySelector(`
+ .o_Discuss_thread
+ .o_ThreadViewer_messageList
+ .o_MessageList_message[data-message-local-id="${
+ this.env.models['mail.message'].find(message => message.id === 100).localId
+ }"]
+ `);
+ assert.strictEqual(
+ message.querySelectorAll(`:scope .o_Message_sidebar`).length,
+ 1,
+ "should have message sidebar of message"
+ );
+ assert.strictEqual(
+ message.querySelectorAll(`:scope .o_Message_authorAvatar`).length,
+ 1,
+ "should have author avatar in sidebar of message"
+ );
+ assert.strictEqual(
+ message.querySelector(`:scope .o_Message_authorAvatar`).dataset.src,
+ "/web/image/res.partner/11/image_128",
+ "should have url of message in author avatar sidebar"
+ );
+ assert.strictEqual(
+ message.querySelectorAll(`:scope .o_Message_core`).length,
+ 1,
+ "should have core part of message"
+ );
+ assert.strictEqual(
+ message.querySelectorAll(`:scope .o_Message_header`).length,
+ 1,
+ "should have header in core part of message"
+ );
+ assert.strictEqual(
+ message.querySelectorAll(`:scope .o_Message_authorName`).length,
+ 1,
+ "should have author name in header of message"
+ );
+ assert.strictEqual(
+ message.querySelector(`:scope .o_Message_authorName`).textContent,
+ "Demo",
+ "should have textually author name in header of message"
+ );
+ assert.strictEqual(
+ message.querySelectorAll(`:scope .o_Message_header .o_Message_date`).length,
+ 1,
+ "should have date in header of message"
+ );
+ assert.strictEqual(
+ message.querySelectorAll(`:scope .o_Message_header .o_Message_commands`).length,
+ 1,
+ "should have commands in header of message"
+ );
+ assert.strictEqual(
+ message.querySelectorAll(`:scope .o_Message_header .o_Message_command`).length,
+ 1,
+ "should have a single command in header of message"
+ );
+ assert.strictEqual(
+ message.querySelectorAll(`:scope .o_Message_commandStar`).length,
+ 1,
+ "should have command to star message"
+ );
+ assert.strictEqual(
+ message.querySelectorAll(`:scope .o_Message_content`).length,
+ 1,
+ "should have content in core part of message"
+ );
+ assert.strictEqual(
+ message.querySelector(`:scope .o_Message_content`).textContent.trim(),
+ "body",
+ "should have body of message in content part of message"
+ );
+});
+
+QUnit.test('basic rendering of squashed message', async function (assert) {
+ // messages are squashed when "close", e.g. less than 1 minute has elapsed
+ // from messages of same author and same thread. Note that this should
+ // be working in non-mailboxes
+ // AKU TODO: should be message and/or message list-only tests
+ assert.expect(12);
+
+ Object.assign(this.data.initMessaging, {
+ channel_slots: {
+ channel_channel: [{
+ channel_type: "channel",
+ id: 20,
+ name: "General",
+ }],
+ },
+ });
+ await this.start({
+ discuss: {
+ params: {
+ default_active_id: 'mail.channel_20',
+ },
+ },
+ async mockRPC(route, args) {
+ if (args.method === 'message_fetch') {
+ return [{
+ author_id: [11, "Demo"],
+ body: "<p>body1</p>",
+ channel_ids: [20],
+ date: "2019-04-20 10:00:00",
+ id: 100,
+ message_type: 'comment',
+ model: 'mail.channel',
+ record_name: 'General',
+ res_id: 20,
+ }, {
+ author_id: [11, "Demo"],
+ body: "<p>body2</p>",
+ channel_ids: [20],
+ date: "2019-04-20 10:00:30",
+ id: 101,
+ message_type: 'comment',
+ model: 'mail.channel',
+ record_name: 'General',
+ res_id: 20,
+ }];
+ }
+ return this._super(...arguments);
+ },
+ });
+ assert.strictEqual(
+ document.querySelectorAll(`
+ .o_Discuss_thread .o_ThreadViewer_messageList .o_MessageList_message
+ `).length,
+ 2,
+ "should have 2 messages"
+ );
+ const message1 = document.querySelector(`
+ .o_Discuss_thread
+ .o_ThreadViewer_messageList
+ .o_MessageList_message[data-message-local-id="${
+ this.env.models['mail.message'].find(message => message.id === 100).localId
+ }"]
+ `);
+ const message2 = document.querySelector(`
+ .o_Discuss_thread
+ .o_ThreadViewer_messageList
+ .o_MessageList_message[data-message-local-id="${
+ this.env.models['mail.message'].find(message => message.id === 101).localId
+ }"]
+ `);
+ assert.notOk(
+ message1.classList.contains('o-squashed'),
+ "message 1 should not be squashed"
+ );
+ assert.notOk(
+ message1.querySelector(`:scope .o_Message_sidebar`).classList.contains('o-message-squashed'),
+ "message 1 should not have squashed sidebar"
+ );
+ assert.ok(
+ message2.classList.contains('o-squashed'),
+ "message 2 should be squashed"
+ );
+ assert.ok(
+ message2.querySelector(`:scope .o_Message_sidebar`).classList.contains('o-message-squashed'),
+ "message 2 should have squashed sidebar"
+ );
+ assert.strictEqual(
+ message2.querySelectorAll(`:scope .o_Message_sidebar .o_Message_date`).length,
+ 1,
+ "message 2 should have date in sidebar"
+ );
+ assert.strictEqual(
+ message2.querySelectorAll(`:scope .o_Message_sidebar .o_Message_commands`).length,
+ 1,
+ "message 2 should have some commands in sidebar"
+ );
+ assert.strictEqual(
+ message2.querySelectorAll(`:scope .o_Message_sidebar .o_Message_commandStar`).length,
+ 1,
+ "message 2 should have star command in sidebar"
+ );
+ assert.strictEqual(
+ message2.querySelectorAll(`:scope .o_Message_core`).length,
+ 1,
+ "message 2 should have core part"
+ );
+ assert.strictEqual(
+ message2.querySelectorAll(`:scope .o_Message_header`).length,
+ 0,
+ "message 2 should have a header in core part"
+ );
+ assert.strictEqual(
+ message2.querySelectorAll(`:scope .o_Message_content`).length,
+ 1,
+ "message 2 should have some content in core part"
+ );
+ assert.strictEqual(
+ message2.querySelector(`:scope .o_Message_content`).textContent.trim(),
+ "body2",
+ "message 2 should have body in content part"
+ );
+});
+
+QUnit.test('inbox messages are never squashed', async function (assert) {
+ assert.expect(3);
+
+ await this.start({
+ async mockRPC(route, args) {
+ if (args.method === 'message_fetch') {
+ // fetching messages from inbox
+ return [{
+ author_id: [11, "Demo"],
+ body: "<p>body1</p>",
+ channel_ids: [20],
+ date: "2019-04-20 10:00:00",
+ id: 100,
+ message_type: 'comment',
+ model: 'mail.channel',
+ needaction: true,
+ needaction_partner_ids: [3],
+ record_name: 'General',
+ res_id: 20,
+ }, {
+ author_id: [11, "Demo"],
+ body: "<p>body2</p>",
+ channel_ids: [20],
+ date: "2019-04-20 10:00:30",
+ id: 101,
+ message_type: 'comment',
+ model: 'mail.channel',
+ needaction: true,
+ needaction_partner_ids: [3],
+ record_name: 'General',
+ res_id: 20,
+ }];
+ }
+ return this._super(...arguments);
+ },
+ });
+ assert.strictEqual(
+ document.querySelectorAll(`
+ .o_Discuss_thread .o_ThreadViewer_messageList .o_MessageList_message
+ `).length,
+ 2,
+ "should have 2 messages"
+ );
+ const message1 = document.querySelector(`
+ .o_Discuss_thread
+ .o_ThreadViewer_messageList
+ .o_MessageList_message[data-message-local-id="${
+ this.env.models['mail.message'].find(message => message.id === 100).localId
+ }"]
+ `);
+ const message2 = document.querySelector(`
+ .o_Discuss_thread
+ .o_ThreadViewer_messageList
+ .o_MessageList_message[data-message-local-id="${
+ this.env.models['mail.message'].find(message => message.id === 101).localId
+ }"]
+ `);
+ assert.notOk(
+ message1.classList.contains('o-squashed'),
+ "message 1 should not be squashed"
+ );
+ assert.notOk(
+ message2.classList.contains('o-squashed'),
+ "message 2 should not be squashed"
+ );
+});
+
+QUnit.test('load all messages from channel initially, less than fetch limit (29 < 30)', async function (assert) {
+ // AKU TODO: thread specific test
+ assert.expect(5);
+
+ Object.assign(this.data.initMessaging, {
+ channel_slots: {
+ channel_channel: [{
+ channel_type: "channel",
+ id: 20,
+ name: "General",
+ }],
+ },
+ });
+ await this.start({
+ discuss: {
+ params: {
+ default_active_id: 'mail.channel_20',
+ },
+ },
+ async mockRPC(route, args) {
+ if (args.method === 'message_fetch') {
+ assert.strictEqual(args.kwargs.limit, 30, "should fetch up to 30 messages");
+ let messagesData = [];
+ // 29 messages
+ for (let i = 28; i >= 0; i--) {
+ messagesData.push({
+ author_id: [10 + i, `User${i}`],
+ body: `<p>body${i}</p>`,
+ channel_ids: [20],
+ date: "2019-04-20 10:00:00",
+ id: 100 + i,
+ message_type: 'comment',
+ model: 'mail.channel',
+ record_name: 'General',
+ res_id: 20,
+ });
+ }
+ return messagesData;
+ }
+ return this._super(...arguments);
+ },
+ });
+ assert.strictEqual(
+ document.querySelectorAll(`
+ .o_Discuss_thread .o_ThreadViewer_messageList .o_MessageList_separatorDate
+ `).length,
+ 1,
+ "should have a single date separator" // to check: may be client timezone dependent
+ );
+ assert.strictEqual(
+ document.querySelector(`
+ .o_Discuss_thread .o_ThreadViewer_messageList .o_MessageList_separatorLabelDate
+ `).textContent,
+ "April 20, 2019",
+ "should display date day of messages"
+ );
+ assert.strictEqual(
+ document.querySelectorAll(`
+ .o_Discuss_thread .o_ThreadViewer_messageList .o_MessageList_message
+ `).length,
+ 29,
+ "should have 29 messages"
+ );
+ assert.strictEqual(
+ document.querySelectorAll(`
+ .o_Discuss_thread .o_ThreadViewer_messageList .o_MessageList_loadMore
+ `).length,
+ 0,
+ "should not have load more link"
+ );
+});
+
+QUnit.test('load more messages from channel', async function (assert) {
+ // AKU: thread specific test
+ assert.expect(8);
+
+ let step = 0;
+ Object.assign(this.data.initMessaging, {
+ channel_slots: {
+ channel_channel: [{
+ channel_type: "channel",
+ id: 20,
+ name: "General",
+ }],
+ },
+ });
+ await this.start({
+ discuss: {
+ params: {
+ default_active_id: 'mail.channel_20',
+ },
+ },
+ async mockRPC(route, args) {
+ if (args.method === 'message_fetch') {
+ step++;
+ if (step === 1) {
+ // fetching messages from channel (initial load)
+ assert.strictEqual(
+ args.kwargs.limit,
+ 30,
+ "should fetch up to 30 messages"
+ );
+ let messagesData = [];
+ // 30 messages
+ for (let i = 39; i >= 10; i--) {
+ messagesData.push({
+ author_id: [10 + i, `User${i}`],
+ body: `<p>body${i}</p>`,
+ channel_ids: [20],
+ date: "2019-04-20 10:00:00",
+ id: 100 + i,
+ message_type: 'comment',
+ model: 'mail.channel',
+ record_name: 'General',
+ res_id: 20,
+ });
+ }
+ return messagesData;
+ }
+ if (step === 2) {
+ // fetching more messages from channel (load more)
+ assert.strictEqual(
+ args.kwargs.limit,
+ 30,
+ "should fetch up to 30 messages"
+ );
+ let messagesData = [];
+ // 10 messages
+ for (let i = 9; i >= 0; i--) {
+ messagesData.push({
+ author_id: [10 + i, `User${i}`],
+ body: `<p>body${i}</p>`,
+ channel_ids: [20],
+ date: "2019-04-20 10:00:00",
+ id: 100 + i,
+ message_type: 'comment',
+ model: 'mail.channel',
+ record_name: 'General',
+ res_id: 20,
+ });
+ }
+ return messagesData;
+ }
+ }
+ return this._super(...arguments);
+ },
+ });
+ assert.strictEqual(
+ document.querySelectorAll(`
+ .o_Discuss_thread .o_ThreadViewer_messageList .o_MessageList_separatorDate
+ `).length,
+ 1,
+ "should have a single date separator" // to check: may be client timezone dependent
+ );
+ assert.strictEqual(
+ document.querySelector(`
+ .o_Discuss_thread .o_ThreadViewer_messageList .o_MessageList_separatorLabelDate
+ `).textContent,
+ "April 20, 2019",
+ "should display date day of messages"
+ );
+ assert.strictEqual(
+ document.querySelectorAll(`
+ .o_Discuss_thread .o_ThreadViewer_messageList .o_MessageList_message
+ `).length,
+ 30,
+ "should have 30 messages"
+ );
+ assert.strictEqual(
+ document.querySelectorAll(`
+ .o_Discuss_thread .o_ThreadViewer_messageList .o_MessageList_loadMore
+ `).length,
+ 1,
+ "should have load more link"
+ );
+
+ await afterNextRender(() =>
+ document.querySelector(`
+ .o_Discuss_thread .o_ThreadViewer_messageList .o_MessageList_loadMore
+ `).click()
+ );
+ assert.strictEqual(
+ document.querySelectorAll(`
+ .o_Discuss_thread .o_ThreadViewer_messageList .o_MessageList_message
+ `).length,
+ 40,
+ "should have 40 messages"
+ );
+ assert.strictEqual(
+ document.querySelectorAll(`
+ .o_Discuss_thread .o_ThreadViewer_messageList .o_MessageList_loadMore
+ `).length,
+ 0,
+ "should not longer have load more link (all messages loaded)"
+ );
+});
+
+QUnit.test('auto-scroll to bottom of thread', async function (assert) {
+ // AKU TODO: thread specific test
+ assert.expect(2);
+
+ Object.assign(this.data.initMessaging, {
+ channel_slots: {
+ channel_channel: [{
+ channel_type: "channel",
+ id: 20,
+ name: "General",
+ }],
+ },
+ });
+ await this.start({
+ discuss: {
+ params: {
+ default_active_id: 'mail.channel_20',
+ },
+ },
+ async mockRPC(route, args) {
+ if (args.method === 'message_fetch') {
+ let messagesData = [];
+ // 25 messages
+ for (let i = 1; i <= 25; i++) {
+ messagesData.push({
+ author_id: [10 + i, `User${i}`],
+ body: `<p>body${i}</p>`,
+ channel_ids: [20],
+ date: "2019-04-20 10:00:00",
+ id: 100 + i,
+ message_type: 'comment',
+ model: 'mail.channel',
+ record_name: 'General',
+ res_id: 20,
+ });
+ }
+ return messagesData;
+ }
+ return this._super(...arguments);
+ },
+ });
+ assert.strictEqual(
+ document.querySelectorAll(`
+ .o_Discuss_thread .o_ThreadViewer_messageList .o_MessageList_message
+ `).length,
+ 25,
+ "should have 25 messages"
+ );
+ const messageList = document.querySelector(`.o_Discuss_thread .o_ThreadViewer_messageList`);
+ assert.strictEqual(
+ messageList.scrollTop + messageList.clientHeight,
+ messageList.scrollHeight,
+ "should have scrolled to bottom of thread"
+ );
+});
+
+QUnit.test('load more messages from channel (auto-load on scroll)', async function (assert) {
+ // AKU TODO: thread specific test
+ assert.expect(3);
+
+ let step = 0;
+ Object.assign(this.data.initMessaging, {
+ channel_slots: {
+ channel_channel: [{
+ channel_type: "channel",
+ id: 20,
+ name: "General",
+ }],
+ },
+ });
+ await this.start({
+ discuss: {
+ params: {
+ default_active_id: 'mail.channel_20',
+ },
+ },
+ async mockRPC(route, args) {
+ if (args.method === 'message_fetch') {
+ step++;
+ if (step === 1) {
+ // fetching messages from channel (initial load)
+ let messagesData = [];
+ // 30 messages
+ for (let i = 39; i >= 10; i--) {
+ messagesData.push({
+ author_id: [10 + i, `User${i}`],
+ body: `<p>body${i}</p>`,
+ channel_ids: [20],
+ date: "2019-04-20 10:00:00",
+ id: 100 + i,
+ message_type: 'comment',
+ model: 'mail.channel',
+ record_name: 'General',
+ res_id: 20,
+ });
+ }
+ return messagesData;
+ }
+ if (step === 2) {
+ // fetching more messages from channel (load more)
+ let messagesData = [];
+ // 10 messages
+ for (let i = 9; i >= 0; i--) {
+ messagesData.push({
+ author_id: [10 + i, `User${i}`],
+ body: `<p>body${i}</p>`,
+ channel_ids: [20],
+ date: "2019-04-20 10:00:00",
+ id: 100 + i,
+ message_type: 'comment',
+ model: 'mail.channel',
+ record_name: 'General',
+ res_id: 20,
+ });
+ }
+ return messagesData;
+ }
+ }
+ return this._super(...arguments);
+ },
+ });
+ assert.strictEqual(
+ document.querySelectorAll(`
+ .o_Discuss_thread .o_ThreadViewer_messageList .o_MessageList_message
+ `).length,
+ 30,
+ "should have 30 messages"
+ );
+
+ await afterNextRender(() => {
+ document.querySelector(`.o_Discuss_thread .o_ThreadViewer_messageList`).scrollTop = 0;
+ });
+ assert.strictEqual(
+ document.querySelectorAll(`
+ .o_Discuss_thread .o_ThreadViewer_messageList .o_MessageList_message
+ `).length,
+ 40,
+ "should have 40 messages"
+ );
+ assert.strictEqual(
+ document.querySelectorAll(`
+ .o_Dsiscuss_thread .o_ThreadViewer_messageList .o_MessageList_loadMore
+ `).length,
+ 0,
+ "should not longer have load more link (all messages loaded)"
+ );
+});
+
+QUnit.test('new messages separator', async function (assert) {
+ // this test requires several messages so that the last message is not
+ // visible. This is necessary in order to display 'new messages' and not
+ // remove from DOM right away from seeing last message.
+ // AKU TODO: thread specific test
+ assert.expect(5);
+
+ let step = 0;
+ Object.assign(this.data.initMessaging, {
+ channel_slots: {
+ channel_channel: [{
+ channel_type: "channel",
+ id: 20,
+ message_unread_counter: 0,
+ name: "General",
+ seen_message_id: 125,
+ }],
+ },
+ });
+ await this.start({
+ discuss: {
+ params: {
+ default_active_id: 'mail.channel_20',
+ },
+ },
+ async mockRPC(route, args) {
+ if (args.method === 'message_fetch') {
+ step++;
+ if (step === 1) {
+ // fetching messages from channel (initial load)
+ let messagesData = [];
+ // 25 messages
+ for (let i = 1; i <= 25; i++) {
+ messagesData.push({
+ author_id: [10 + i, `User${i}`],
+ body: `<p>body${i}</p>`,
+ channel_ids: [20],
+ date: "2019-04-20 10:00:00",
+ id: 100 + i,
+ message_type: 'comment',
+ model: 'mail.channel',
+ record_name: 'General',
+ res_id: 20,
+ });
+ }
+ return messagesData;
+ }
+ if (step === 2) {
+ throw new Error("should not fetch more messages");
+ }
+ }
+ return this._super(...arguments);
+ },
+ });
+ assert.strictEqual(
+ document.querySelectorAll(`
+ .o_Discuss_thread .o_ThreadViewer_messageList .o_MessageList_message
+ `).length,
+ 25,
+ "should have 25 messages"
+ );
+ assert.strictEqual(
+ document.querySelectorAll(`
+ .o_Discuss_thread .o_ThreadViewer_messageList .o_MessageList_separatorNewMessages
+ `).length,
+ 0,
+ "should not display 'new messages' separator"
+ );
+
+ document.querySelector(`.o_Discuss_thread .o_ThreadViewer_messageList`).scrollTop = 0;
+ // simulate receiving a new message
+ const data = {
+ author_id: [36, "User26"],
+ body: "<p>boddy26</p>",
+ channel_ids: [20],
+ date: "2019-04-20 10:00:00",
+ id: 126,
+ message_type: 'comment',
+ model: 'mail.channel',
+ record_name: 'General',
+ res_id: 20,
+ };
+ await afterNextRender(() => {
+ const notifications = [[['my-db', 'mail.channel', 20], data]];
+ this.widget.call('bus_service', 'trigger', 'notification', notifications);
+ });
+ assert.strictEqual(
+ document.querySelectorAll(`
+ .o_Discuss_thread .o_ThreadViewer_messageList .o_MessageList_message
+ `).length,
+ 26,
+ "should have 26 messages"
+ );
+ assert.strictEqual(
+ document.querySelectorAll(`
+ .o_Discuss_thread .o_ThreadViewer_messageList .o_MessageList_separatorNewMessages
+ `).length,
+ 1,
+ "should display 'new messages' separator"
+ );
+
+ // scroll to bottom
+ await afterNextRender(() => {
+ document.querySelector(`.o_Discuss_thread .o_ThreadViewer_messageList`).scrollTop =
+ document.querySelector(`.o_Discuss_thread .o_ThreadViewer_messageList`).scrollHeight;
+ });
+ assert.strictEqual(
+ document.querySelectorAll(`
+ .o_Discuss_thread .o_ThreadViewer_messageList .o_MessageList_separatorNewMessages
+ `).length,
+ 0,
+ "should no longer display 'new messages' separator (message seen)"
+ );
+});
+
+QUnit.test('restore thread scroll position', async function (assert) {
+ assert.expect(4);
+
+ let step = 0;
+ Object.assign(this.data.initMessaging, {
+ channel_slots: {
+ channel_channel: [{
+ channel_type: "channel",
+ id: 1,
+ name: "channel1",
+ }, {
+ channel_type: "channel",
+ id: 2,
+ name: "channel2",
+ }],
+ },
+ });
+ await this.start({
+ discuss: {
+ params: {
+ default_active_id: 'mail.channel_1',
+ },
+ },
+ async mockRPC(route, args) {
+ if (args.method === 'message_fetch') {
+ step++;
+ if (step === 1) {
+ // fetching messages from channel1 (initial load)
+ let messagesData = [];
+ // 25 messages
+ for (let i = 1; i <= 25; i++) {
+ messagesData.push({
+ author_id: [10 + i, `User${i}`],
+ body: `<p>body${i}</p>`,
+ channel_ids: [1],
+ date: "2019-04-20 10:00:00",
+ id: 100 + i,
+ message_type: 'comment',
+ model: 'mail.channel',
+ record_name: 'channel1',
+ res_id: 1,
+ });
+ }
+ return messagesData;
+ }
+ if (step === 2) {
+ // fetching messages from channel2 (initial load)
+ let messagesData = [];
+ // 25 messages
+ for (let i = 1; i <= 25; i++) {
+ messagesData.push({
+ author_id: [10 + i, `User${i}`],
+ body: `<p>body${i}</p>`,
+ channel_ids: [2],
+ date: "2019-04-20 10:00:00",
+ id: 200 + i,
+ message_type: 'comment',
+ model: 'mail.channel',
+ record_name: 'channel2',
+ res_id: 2,
+ });
+ }
+ return messagesData;
+ }
+ }
+ return this._super(...arguments);
+ },
+ });
+ assert.strictEqual(
+ document.querySelectorAll(`
+ .o_Discuss_thread .o_ThreadViewer_messageList .o_MessageList_message
+ `).length,
+ 25,
+ "should have 25 messages"
+ );
+
+ // scroll to top of channel1
+ await afterNextRender(() => {
+ document.querySelector(`.o_Discuss_thread .o_ThreadViewer_messageList`).scrollTop = 0;
+ });
+ assert.strictEqual(
+ document.querySelector(`.o_Discuss_thread .o_ThreadViewer_messageList`).scrollTop,
+ 0,
+ "should have scrolled to top of thread"
+ );
+
+ // select channel2
+ await afterNextRender(() =>
+ document.querySelector(`
+ .o_DiscussSidebar_groupChannel
+ .o_DiscussSidebar_item[data-thread-local-id="${
+ this.env.models['mail.thread'].find(thread =>
+ thread.id === 2 &&
+ thread.model === 'mail.channel'
+ ).localId
+ }"]
+ `).click()
+ );
+ // select channel1
+ await afterNextRender(() =>
+ document.querySelector(`
+ .o_DiscussSidebar_groupChannel
+ .o_DiscussSidebar_item[data-thread-local-id="${
+ this.env.models['mail.thread'].find(thread =>
+ thread.id === 1 &&
+ thread.model === 'mail.channel'
+ ).localId
+ }"]
+ `).click()
+ );
+ assert.strictEqual(
+ document.querySelector(`.o_Discuss_thread .o_ThreadViewer_messageList`).scrollTop,
+ 0,
+ "should have recovered scroll position of channel1 (scroll to top)"
+ );
+
+ // select channel2
+ await afterNextRender(() =>
+ document.querySelector(`
+ .o_DiscussSidebar_groupChannel
+ .o_DiscussSidebar_item[data-thread-local-id="${
+ this.env.models['mail.thread'].find(thread =>
+ thread.id === 2 &&
+ thread.model === 'mail.channel'
+ ).localId
+ }"]
+ `).click()
+ );
+ const messageList = document.querySelector(`
+ .o_Discuss_thread
+ .o_ThreadViewer_messageList
+ `);
+ assert.strictEqual(
+ messageList.scrollTop + messageList.clientHeight,
+ messageList.scrollHeight,
+ "should have recovered scroll position of channel2 (scroll to bottom)"
+ );
+});
+
+QUnit.test('message origin redirect to channel', async function (assert) {
+ assert.expect(15);
+
+ let step = 0;
+ Object.assign(this.data.initMessaging, {
+ channel_slots: {
+ channel_channel: [{
+ channel_type: 'channel',
+ id: 1,
+ name: "channel1",
+ }, {
+ channel_type: 'channel',
+ id: 2,
+ name: "channel2",
+ }],
+ },
+ });
+ let messagesData = [{
+ author_id: [10, "User1"],
+ body: `<p>message1</p>`,
+ channel_ids: [1, 2],
+ date: "2019-04-20 10:00:00",
+ id: 100,
+ message_type: 'comment',
+ model: 'mail.channel',
+ record_name: "channel1",
+ res_id: 1,
+ }, {
+ author_id: [11, "User2"],
+ body: `<p>message2</p>`,
+ channel_ids: [1, 2],
+ date: "2019-04-20 10:00:00",
+ id: 101,
+ message_type: 'comment',
+ model: 'mail.channel',
+ record_name: "channel2",
+ res_id: 2,
+ }];
+ await this.start({
+ discuss: {
+ params: {
+ default_active_id: 'mail.channel_1',
+ },
+ },
+ async mockRPC(route, args) {
+ if (args.method === 'message_fetch') {
+ step++;
+ if (step === 1) {
+ // fetching messages from channel1 (initial load)
+ return messagesData;
+ }
+ if (step === 2) {
+ // fetching messages from channel2 (initial load)
+ return messagesData;
+ }
+ }
+ return this._super(...arguments);
+ },
+ });
+ assert.strictEqual(
+ document.querySelectorAll('.o_Discuss_thread .o_Message').length,
+ 2,
+ "should have 2 messages"
+ );
+ assert.strictEqual(
+ document.querySelectorAll(`
+ .o_Discuss_thread
+ .o_Message[data-message-local-id="${
+ this.env.models['mail.message'].find(message => message.id === 100).localId
+ }"]
+ `).length,
+ 1,
+ "should have message1 (Id 100)"
+ );
+ assert.strictEqual(
+ document.querySelectorAll(`
+ .o_Discuss_thread
+ .o_Message[data-message-local-id="${
+ this.env.models['mail.message'].find(message => message.id === 101).localId
+ }"]
+ `).length,
+ 1,
+ "should have message2 (Id 101)"
+ );
+ assert.strictEqual(
+ document.querySelectorAll(`
+ .o_Discuss_thread
+ .o_Message[data-message-local-id="${
+ this.env.models['mail.message'].find(message => message.id === 100).localId
+ }"]
+ .o_Message_originThread
+ `).length,
+ 0,
+ "message1 should not have origin part in channel1 (same origin as channel)"
+ );
+ assert.strictEqual(
+ document.querySelectorAll(`
+ .o_Discuss_thread
+ .o_Message[data-message-local-id="${
+ this.env.models['mail.message'].find(message => message.id === 101).localId
+ }"]
+ .o_Message_originThread
+ `).length,
+ 1,
+ "message2 should have origin part (origin is channel2 !== channel1)"
+ );
+ assert.strictEqual(
+ document.querySelector(`
+ .o_Discuss_thread
+ .o_Message[data-message-local-id="${
+ this.env.models['mail.message'].find(message => message.id === 101).localId
+ }"]
+ .o_Message_originThread
+ `).textContent.trim(),
+ "(from #channel2)",
+ "message2 should display name of origin channel"
+ );
+ assert.strictEqual(
+ document.querySelectorAll(`
+ .o_Discuss_thread
+ .o_Message[data-message-local-id="${
+ this.env.models['mail.message'].find(message => message.id === 101).localId
+ }"]
+ .o_Message_originThreadLink
+ `).length,
+ 1,
+ "message2 should have link to redirect to origin"
+ );
+
+ // click on origin link of message2 (= channel2)
+ await afterNextRender(() =>
+ document.querySelector(`
+ .o_Discuss_thread
+ .o_Message[data-message-local-id="${
+ this.env.models['mail.message'].find(message => message.id === 101).localId
+ }"]
+ .o_Message_originThreadLink
+ `).click()
+ );
+ assert.ok(
+ document.querySelector(`
+ .o_DiscussSidebar_groupChannel
+ .o_DiscussSidebar_item[data-thread-local-id="${
+ this.env.models['mail.thread'].find(thread =>
+ thread.id === 2 &&
+ thread.model === 'mail.channel'
+ ).localId
+ }"]
+ .o_DiscussSidebarItem_activeIndicator
+ `).classList.contains('o-item-active'),
+ "channel2 should be active channel on redirect from discuss app"
+ );
+ assert.strictEqual(
+ document.querySelectorAll(`.o_Discuss_thread .o_Message`).length,
+ 2,
+ "should have 2 messages"
+ );
+ assert.strictEqual(
+ document.querySelectorAll(`
+ .o_Discuss_thread
+ .o_Message[data-message-local-id="${
+ this.env.models['mail.message'].find(message => message.id === 100).localId
+ }"]
+ `).length,
+ 1,
+ "should have message1 (Id 100)"
+ );
+ assert.strictEqual(
+ document.querySelectorAll(`
+ .o_Discuss_thread
+ .o_Message[data-message-local-id="${
+ this.env.models['mail.message'].find(message => message.id === 101).localId
+ }"]
+ `).length,
+ 1,
+ "should have message2 (Id 101)"
+ );
+ assert.strictEqual(
+ document.querySelectorAll(`
+ .o_Discuss_thread
+ .o_Message[data-message-local-id="${
+ this.env.models['mail.message'].find(message => message.id === 100).localId
+ }"]
+ .o_Message_originThread
+ `).length,
+ 1,
+ "message1 should have origin thread part (= channel1 !== channel2)"
+ );
+ assert.strictEqual(
+ document.querySelectorAll(`
+ .o_Discuss_thread
+ .o_Message[data-message-local-id="${
+ this.env.models['mail.message'].find(message => message.id === 101).localId
+ }"]
+ .o_Message_originThread
+ `).length,
+ 0,
+ "message2 should not have origin thread part in channel2 (same as current channel)"
+ );
+ assert.strictEqual(
+ document.querySelector(`
+ .o_Discuss_thread
+ .o_Message[data-message-local-id="${
+ this.env.models['mail.message'].find(message => message.id === 100).localId
+ }"]
+ .o_Message_originThread
+ `).textContent.trim(),
+ "(from #channel1)",
+ "message1 should display name of origin channel"
+ );
+ assert.strictEqual(
+ document.querySelectorAll(`
+ .o_Discuss_thread
+ .o_Message[data-message-local-id="${
+ this.env.models['mail.message'].find(message => message.id === 100).localId
+ }"]
+ .o_Message_originThreadLink
+ `).length,
+ 1,
+ "message1 should have link to redirect to origin channel"
+ );
+});
+
+QUnit.test('redirect to author (open chat)', async function (assert) {
+ assert.expect(9);
+
+ let step = 0;
+ Object.assign(this.data.initMessaging, {
+ channel_slots: {
+ channel_channel: [{
+ channel_type: "channel",
+ id: 1,
+ name: "General",
+ }],
+ channel_direct_message: [{
+ channel_type: "chat",
+ direct_partner: [{
+ id: 7,
+ name: "Demo",
+ }],
+ id: 10,
+ }],
+ },
+ });
+ await this.start({
+ discuss: {
+ params: {
+ default_active_id: 'mail.channel_1',
+ },
+ },
+ async mockRPC(route, args) {
+ if (args.method === 'message_fetch') {
+ step++;
+ if (step === 1) {
+ // fetching messages from General (initial load)
+ return [{
+ author_id: [7, "Demo"],
+ body: `<p>message1</p>`,
+ channel_ids: [1],
+ date: "2019-04-20 10:00:00",
+ id: 100,
+ message_type: 'comment',
+ model: 'mail.channel',
+ record_name: "General",
+ res_id: 1,
+ }, {
+ author_id: [3, "Me"],
+ body: `<p>message2</p>`,
+ channel_ids: [1],
+ date: "2019-04-20 10:00:00",
+ id: 101,
+ message_type: 'comment',
+ model: 'mail.channel',
+ record_name: "General",
+ res_id: 1,
+ }];
+ }
+ if (step === 2) {
+ // fetching messages from DM (initial load)
+ return [];
+ }
+ }
+ if (args.model === 'res.users' && args.method === 'search') {
+ return [2];
+ }
+ return this._super(...arguments);
+ },
+ });
+ assert.ok(
+ document.querySelector(`
+ .o_DiscussSidebar_groupChannel
+ .o_DiscussSidebar_item[data-thread-local-id="${
+ this.env.models['mail.thread'].find(thread =>
+ thread.id === 1 &&
+ thread.model === 'mail.channel'
+ ).localId
+ }"]
+ .o_DiscussSidebarItem_activeIndicator
+ `).classList.contains('o-item-active'),
+ "channel 'General' should be active"
+ );
+ assert.notOk(
+ document.querySelector(`
+ .o_DiscussSidebar_groupChat
+ .o_DiscussSidebar_item[data-thread-local-id="${
+ this.env.models['mail.thread'].find(thread =>
+ thread.id === 10 &&
+ thread.model === 'mail.channel'
+ ).localId
+ }"]
+ .o_DiscussSidebarItem_activeIndicator
+ `).classList.contains('o-item-active'),
+ "Chat 'Demo' should not be active"
+ );
+ assert.strictEqual(
+ document.querySelectorAll(`.o_Discuss_thread .o_Message`).length,
+ 2,
+ "should have 2 messages"
+ );
+ const msg1 = document.querySelector(`
+ .o_Discuss_thread
+ .o_Message[data-message-local-id="${
+ this.env.models['mail.message'].find(message => message.id === 100).localId
+ }"]
+ `);
+ const msg2 = document.querySelector(`
+ .o_Discuss_thread
+ .o_Message[data-message-local-id="${
+ this.env.models['mail.message'].find(message => message.id === 101).localId
+ }"]
+ `);
+ assert.strictEqual(
+ msg1.querySelectorAll(`:scope .o_Message_authorAvatar`).length,
+ 1,
+ "message1 should have author image"
+ );
+ assert.ok(
+ msg1.querySelector(`:scope .o_Message_authorAvatar`).classList.contains('o_redirect'),
+ "message1 should have redirect to author"
+ );
+ assert.strictEqual(
+ msg2.querySelectorAll(`:scope .o_Message_authorAvatar`).length,
+ 1,
+ "message2 should have author image"
+ );
+ assert.notOk(
+ msg2.querySelector(`:scope .o_Message_authorAvatar`).classList.contains('o_redirect'),
+ "message2 should not have redirect to author (self-author)"
+ );
+
+ await afterNextRender(() =>
+ msg1.querySelector(`:scope .o_Message_authorAvatar`).click()
+ );
+ assert.notOk(
+ document.querySelector(`
+ .o_DiscussSidebar_groupChannel
+ .o_DiscussSidebar_item[data-thread-local-id="${
+ this.env.models['mail.thread'].find(thread =>
+ thread.id === 1 &&
+ thread.model === 'mail.channel'
+ ).localId
+ }"]
+ .o_DiscussSidebarItem_activeIndicator
+ `).classList.contains('o-item-active'),
+ "channel 'General' should become inactive after author redirection"
+ );
+ assert.ok(
+ document.querySelector(`
+ .o_DiscussSidebar_groupChat
+ .o_DiscussSidebar_item[data-thread-local-id="${
+ this.env.models['mail.thread'].find(thread =>
+ thread.id === 10 &&
+ thread.model === 'mail.channel'
+ ).localId
+ }"]
+ .o_DiscussSidebarItem_activeIndicator
+ `).classList.contains('o-item-active'),
+ "chat 'Demo' should become active after author redirection"
+ );
+});
+
+QUnit.test('sidebar quick search', async function (assert) {
+ // feature enables at 20 or more channels
+ assert.expect(6);
+
+ let channelsData = [];
+ for (let id = 1; id <= 20; id++) {
+ channelsData.push({
+ channel_type: 'channel',
+ id,
+ name: `channel${id}`,
+ });
+ }
+ Object.assign(this.data.initMessaging, {
+ channel_slots: {
+ channel_channel: channelsData,
+ },
+ });
+ await this.start();
+ assert.strictEqual(
+ document.querySelectorAll(`.o_DiscussSidebar_groupChannel .o_DiscussSidebar_item`).length,
+ 20,
+ "should have 20 channel items"
+ );
+ assert.strictEqual(
+ document.querySelectorAll(`.o_Discuss_sidebar input.o_DiscussSidebar_quickSearch`).length,
+ 1,
+ "should have quick search in sidebar"
+ );
+
+ const quickSearch = document.querySelector(`
+ .o_Discuss_sidebar input.o_DiscussSidebar_quickSearch
+ `);
+ await afterNextRender(() => {
+ quickSearch.value = "1";
+ const kevt1 = new window.KeyboardEvent('input');
+ quickSearch.dispatchEvent(kevt1);
+ });
+ assert.strictEqual(
+ document.querySelectorAll(`.o_DiscussSidebar_groupChannel .o_DiscussSidebar_item`).length,
+ 11,
+ "should have filtered to 11 channel items"
+ );
+
+ await afterNextRender(() => {
+ quickSearch.value = "12";
+ const kevt2 = new window.KeyboardEvent('input');
+ quickSearch.dispatchEvent(kevt2);
+ });
+ assert.strictEqual(
+ document.querySelectorAll(`.o_DiscussSidebar_groupChannel .o_DiscussSidebar_item`).length,
+ 1,
+ "should have filtered to a single channel item"
+ );
+ assert.strictEqual(
+ document.querySelector(`
+ .o_DiscussSidebar_groupChannel .o_DiscussSidebar_item
+ `).dataset.threadLocalId,
+ this.env.models['mail.thread'].find(thread =>
+ thread.id === 12 &&
+ thread.model === 'mail.channel'
+ ).localId,
+ "should have filtered to a single channel item with Id 12"
+ );
+
+ await afterNextRender(() => {
+ quickSearch.value = "123";
+ const kevt3 = new window.KeyboardEvent('input');
+ quickSearch.dispatchEvent(kevt3);
+ });
+ assert.strictEqual(
+ document.querySelectorAll(`.o_DiscussSidebar_groupChannel .o_DiscussSidebar_item`).length,
+ 0,
+ "should have filtered to no channel item"
+ );
+});
+
+QUnit.test('basic control panel rendering', async function (assert) {
+ assert.expect(8);
+
+ Object.assign(this.data.initMessaging, {
+ channel_slots: {
+ channel_channel: [{
+ channel_type: "channel",
+ id: 20,
+ name: "General",
+ }],
+ },
+ });
+ await this.start();
+ assert.strictEqual(
+ document.querySelector(`
+ .o_widget_Discuss .o_control_panel .breadcrumb
+ `).textContent,
+ "Inbox",
+ "display inbox in the breadcrumb"
+ );
+ const markAllReadButton = document.querySelector(`.o_widget_Discuss_controlPanelButtonMarkAllRead`);
+ assert.isVisible(
+ markAllReadButton,
+ "should have visible button 'Mark all read' in the control panel of inbox"
+ );
+ assert.ok(
+ markAllReadButton.disabled,
+ "should have disabled button 'Mark all read' in the control panel of inbox (no messages)"
+ );
+
+ await afterNextRender(() =>
+ document.querySelector(`
+ .o_DiscussSidebar_item[data-thread-local-id="${
+ this.env.messaging.starred.localId
+ }"]
+ `).click()
+ );
+ assert.strictEqual(
+ document.querySelector(`
+ .o_widget_Discuss .o_control_panel .breadcrumb
+ `).textContent,
+ "Starred",
+ "display starred in the breadcrumb"
+ );
+ const unstarAllButton = document.querySelector(`.o_widget_Discuss_controlPanelButtonUnstarAll`);
+ assert.isVisible(
+ unstarAllButton,
+ "should have visible button 'Unstar all' in the control panel of starred"
+ );
+ assert.ok(
+ unstarAllButton.disabled,
+ "should have disabled button 'Unstar all' in the control panel of starred (no messages)"
+ );
+
+ await afterNextRender(() =>
+ document.querySelector(`
+ .o_DiscussSidebar_item[data-thread-local-id="${
+ this.env.models['mail.thread'].find(thread =>
+ thread.id === 20 &&
+ thread.model === 'mail.channel'
+ ).localId
+ }"]
+ `).click()
+ );
+ assert.strictEqual(
+ document.querySelector(`
+ .o_widget_Discuss .o_control_panel .breadcrumb
+ `).textContent,
+ "#General",
+ "display general in the breadcrumb"
+ );
+ const inviteButton = document.querySelector(`.o_widget_Discuss_controlPanelButtonInvite`);
+ assert.isVisible(
+ inviteButton,
+ "should have visible button 'Invite' in the control panel of channel"
+ );
+});
+
+QUnit.test('inbox: mark all messages as read', async function (assert) {
+ assert.expect(8);
+
+ const self = this;
+ Object.assign(this.data.initMessaging, {
+ needaction_inbox_counter: 2,
+ channel_slots: {
+ channel_channel: [{
+ channel_type: "channel",
+ id: 20,
+ message_needaction_counter: 2,
+ name: "General",
+ }],
+ },
+ });
+ await this.start({
+ async mockRPC(route, args) {
+ if (args.method === 'message_fetch') {
+ return [{
+ author_id: [7, "Demo"],
+ body: `<p>message1</p>`,
+ channel_ids: [20],
+ date: "2019-04-20 10:00:00",
+ id: 100,
+ message_type: 'comment',
+ model: 'mail.channel',
+ needaction: true,
+ needaction_partner_ids: [3],
+ record_name: "General",
+ res_id: 20,
+ }, {
+ author_id: [8, "Other"],
+ body: `<p>message2</p>`,
+ channel_ids: [20],
+ date: "2019-04-20 10:00:00",
+ id: 101,
+ message_type: 'comment',
+ model: 'mail.channel',
+ needaction: true,
+ needaction_partner_ids: [3],
+ record_name: "General",
+ res_id: 20,
+ }];
+ }
+ if (args.method === 'mark_all_as_read') {
+ // simulate mark as read notification
+ const data = {
+ message_ids: [100, 101],
+ type: 'mark_as_read',
+ };
+ const notifications = [[['my-db', 'res.partner'], data]];
+ self.widget.call('bus_service', 'trigger', 'notification', notifications);
+ return;
+
+ }
+ return this._super(...arguments);
+ },
+ });
+ assert.strictEqual(
+ document.querySelector(`
+ .o_DiscussSidebar_item[data-thread-local-id="${
+ this.env.messaging.inbox.localId
+ }"]
+ .o_DiscussSidebarItem_counter
+ `).textContent,
+ "2",
+ "inbox should have counter of 2"
+ );
+ assert.strictEqual(
+ document.querySelector(`
+ .o_DiscussSidebar_item[data-thread-local-id="${
+ this.env.models['mail.thread'].find(thread =>
+ thread.id === 20 &&
+ thread.model === 'mail.channel'
+ ).localId
+ }"]
+ .o_DiscussSidebarItem_counter
+ `).textContent,
+ "2",
+ "channel should have counter of 2"
+ );
+ assert.strictEqual(
+ document.querySelectorAll(`.o_Discuss .o_Message`).length,
+ 2,
+ "should have 2 messages in inbox"
+ );
+ let markAllReadButton = document.querySelector(`.o_widget_Discuss_controlPanelButtonMarkAllRead`);
+ assert.notOk(
+ markAllReadButton.disabled,
+ "should have enabled button 'Mark all read' in the control panel of inbox (has messages)"
+ );
+
+ await afterNextRender(() => markAllReadButton.click());
+ assert.strictEqual(
+ document.querySelectorAll(`
+ .o_DiscussSidebar_item[data-thread-local-id="${
+ this.env.messaging.inbox.localId
+ }"]
+ .o_DiscussSidebarItem_counter
+ `).length,
+ 0,
+ "inbox should display no counter (= 0)"
+ );
+ assert.strictEqual(
+ document.querySelectorAll(`
+ .o_DiscussSidebar_item[data-thread-local-id="${
+ this.env.models['mail.thread'].find(thread =>
+ thread.id === 20 &&
+ thread.model === 'mail.channel'
+ ).localId
+ }"]
+ .o_DiscussSidebarItem_counter
+ `).length,
+ 0,
+ "channel should display no counter (= 0)"
+ );
+ assert.strictEqual(
+ document.querySelectorAll(`.o_Discuss .o_Message`).length,
+ 0,
+ "should have no message in inbox"
+ );
+ markAllReadButton = document.querySelector(`.o_widget_Discuss_controlPanelButtonMarkAllRead`);
+ assert.ok(
+ markAllReadButton.disabled,
+ "should have disabled button 'Mark all read' in the control panel of inbox (no messages)"
+ );
+});
+
+QUnit.test('starred: unstar all', async function (assert) {
+ assert.expect(6);
+
+ const self = this;
+ Object.assign(this.data.initMessaging, { starred_counter: 2 });
+ await this.start({
+ discuss: {
+ params: {
+ default_active_id: 'mail.box_starred',
+ },
+ },
+ async mockRPC(route, args) {
+ if (args.method === 'message_fetch') {
+ return [{
+ author_id: [7, "Demo"],
+ body: `<p>message1</p>`,
+ channel_ids: [20],
+ date: "2019-04-20 10:00:00",
+ id: 100,
+ message_type: 'comment',
+ model: 'mail.channel',
+ record_name: "General",
+ res_id: 20,
+ starred: true,
+ starred_partner_ids: [3],
+ }, {
+ author_id: [8, "Other"],
+ body: `<p>message2</p>`,
+ channel_ids: [20],
+ date: "2019-04-20 10:00:00",
+ id: 101,
+ message_type: 'comment',
+ model: 'mail.channel',
+ record_name: "General",
+ res_id: 20,
+ starred: true,
+ starred_partner_ids: [3],
+ }];
+ }
+ if (args.method === 'unstar_all') {
+ // simulate toggle_star notification
+ const data = {
+ message_ids: [100, 101],
+ starred: false,
+ type: 'toggle_star',
+ };
+ const notifications = [[['my-db', 'res.partner'], data]];
+ self.widget.call('bus_service', 'trigger', 'notification', notifications);
+ return;
+
+ }
+ return this._super(...arguments);
+ },
+ });
+ assert.strictEqual(
+ document.querySelector(`
+ .o_DiscussSidebar_item[data-thread-local-id="${
+ this.env.messaging.starred.localId
+ }"]
+ .o_DiscussSidebarItem_counter
+ `).textContent,
+ "2",
+ "starred should have counter of 2"
+ );
+ assert.strictEqual(
+ document.querySelectorAll(`.o_Discuss .o_Message`).length,
+ 2,
+ "should have 2 messages in starred"
+ );
+ let unstarAllButton = document.querySelector(`.o_widget_Discuss_controlPanelButtonUnstarAll`);
+ assert.notOk(
+ unstarAllButton.disabled,
+ "should have enabled button 'Unstar all' in the control panel of starred (has messages)"
+ );
+
+ await afterNextRender(() => unstarAllButton.click());
+ assert.strictEqual(
+ document.querySelectorAll(`
+ .o_DiscussSidebar_item[data-thread-local-id="${
+ this.env.messaging.starred.localId
+ }"]
+ .o_DiscussSidebarItem_counter
+ `).length,
+ 0,
+ "starred should display no counter (= 0)"
+ );
+ assert.strictEqual(
+ document.querySelectorAll(`.o_Discuss .o_Message`).length,
+ 0,
+ "should have no message in starred"
+ );
+ unstarAllButton = document.querySelector(`.o_widget_Discuss_controlPanelButtonUnstarAll`);
+ assert.ok(
+ unstarAllButton.disabled,
+ "should have disabled button 'Unstar all' in the control panel of starred (no messages)"
+ );
+});
+
+QUnit.test('toggle_star message', async function (assert) {
+ assert.expect(16);
+
+ const self = this;
+ Object.assign(this.data.initMessaging, {
+ channel_slots: {
+ channel_channel: [{
+ channel_type: "channel",
+ id: 20,
+ name: "General",
+ }],
+ },
+ });
+ let messageData = {
+ author_id: [11, "Demo"],
+ body: "<p>body</p>",
+ channel_ids: [20],
+ date: "2019-04-20 10:00:00",
+ id: 100,
+ message_type: 'comment',
+ model: 'mail.channel',
+ record_name: 'General',
+ res_id: 20,
+ starred: false,
+ starred_partner_ids: [],
+ };
+
+ await this.start({
+ discuss: {
+ params: {
+ default_active_id: 'mail.channel_20',
+ },
+ },
+ async mockRPC(route, args) {
+ if (args.method === 'message_fetch') {
+ return [messageData];
+ }
+ if (args.method === 'toggle_message_starred') {
+ assert.step('rpc:toggle_message_starred');
+ assert.strictEqual(
+ args.args[0][0],
+ 100,
+ "should have message Id in args"
+ );
+ // simulate toggle_star notification
+ messageData.starred = !messageData.starred;
+ const data = {
+ message_ids: [100],
+ starred: messageData.starred,
+ type: 'toggle_star',
+ };
+ const notifications = [[['my-db', 'res.partner'], data]];
+ self.widget.call('bus_service', 'trigger', 'notification', notifications);
+ return;
+ }
+ return this._super(...arguments);
+ },
+ });
+ assert.strictEqual(
+ document.querySelectorAll(`
+ .o_DiscussSidebar_item[data-thread-local-id="${
+ this.env.messaging.starred.localId
+ }"]
+ .o_DiscussSidebarItem_counter
+ `).length,
+ 0,
+ "starred should display no counter (= 0)"
+ );
+ assert.strictEqual(
+ document.querySelectorAll(`.o_Discuss .o_Message`).length,
+ 1,
+ "should have 1 message in channel"
+ );
+ let message = document.querySelector(`.o_Discuss .o_Message`);
+ assert.notOk(
+ message.classList.contains('o-starred'),
+ "message should not be starred"
+ );
+ assert.strictEqual(
+ message.querySelectorAll(`:scope .o_Message_commandStar`).length,
+ 1,
+ "message should have star command"
+ );
+
+ await afterNextRender(() => message.querySelector(`:scope .o_Message_commandStar`).click());
+ assert.verifySteps(['rpc:toggle_message_starred']);
+ assert.strictEqual(
+ document.querySelector(`
+ .o_DiscussSidebar_item[data-thread-local-id="${
+ this.env.messaging.starred.localId
+ }"]
+ .o_DiscussSidebarItem_counter
+ `).textContent,
+ "1",
+ "starred should display a counter of 1"
+ );
+ assert.strictEqual(
+ document.querySelectorAll(`.o_Discuss .o_Message`).length,
+ 1,
+ "should have kept 1 message in channel"
+ );
+ message = document.querySelector(`.o_Discuss .o_Message`);
+ assert.ok(
+ message.classList.contains('o-starred'),
+ "message should be starred"
+ );
+
+ await afterNextRender(() => message.querySelector(`:scope .o_Message_commandStar`).click());
+ assert.verifySteps(['rpc:toggle_message_starred']);
+ assert.strictEqual(
+ document.querySelectorAll(`
+ .o_DiscussSidebar_item[data-thread-local-id="${
+ this.env.messaging.starred.localId
+ }"]
+ .o_DiscussSidebarItem_counter
+ `).length,
+ 0,
+ "starred should no longer display a counter (= 0)"
+ );
+ assert.strictEqual(
+ document.querySelectorAll(`.o_Discuss .o_Message`).length,
+ 1,
+ "should still have 1 message in channel"
+ );
+ message = document.querySelector(`.o_Discuss .o_Message`);
+ assert.notOk(
+ message.classList.contains('o-starred'),
+ "message should no longer be starred"
+ );
+});
+
+QUnit.test('composer state: text save and restore', async function (assert) {
+ assert.expect(2);
+
+ Object.assign(this.data.initMessaging, {
+ channel_slots: {
+ channel_channel: [{
+ channel_type: 'channel',
+ id: 20,
+ name: "General",
+ }, {
+ channel_type: 'channel',
+ id: 21,
+ name: "Special",
+ }],
+ },
+ });
+ await this.start({
+ discuss: {
+ params: {
+ default_active_id: 'mail.channel_20',
+ },
+ },
+ async mockRPC(route, args) {
+ if (args.method === 'message_fetch') {
+ return [];
+ }
+ return this._super(...arguments);
+ },
+ });
+ // Write text in composer for #general
+ await afterNextRender(() => {
+ document.querySelector(`.o_ComposerTextInput_textarea`).focus();
+ document.execCommand('insertText', false, "A message");
+ document.querySelector(`.o_ComposerTextInput_textarea`)
+ .dispatchEvent(new window.KeyboardEvent('input'));
+ });
+ await afterNextRender(() =>
+ document.querySelector(`.o_DiscussSidebarItem[data-thread-name="Special"]`).click()
+ );
+ await afterNextRender(() => {
+ document.querySelector(`.o_ComposerTextInput_textarea`).focus();
+ document.execCommand('insertText', false, "An other message");
+ document.querySelector(`.o_ComposerTextInput_textarea`)
+ .dispatchEvent(new window.KeyboardEvent('input'));
+ });
+ // Switch back to #general
+ await afterNextRender(() =>
+ document.querySelector(`.o_DiscussSidebarItem[data-thread-name="General"]`).click()
+ );
+ assert.strictEqual(
+ document.querySelector(`.o_ComposerTextInput_textarea`).value,
+ "A message",
+ "should restore the input text"
+ );
+
+ await afterNextRender(() =>
+ document.querySelector(`.o_DiscussSidebarItem[data-thread-name="Special"]`).click()
+ );
+ assert.strictEqual(
+ document.querySelector(`.o_ComposerTextInput_textarea`).value,
+ "An other message",
+ "should restore the input text"
+ );
+});
+
+QUnit.test('composer state: attachments save and restore', async function (assert) {
+ assert.expect(6);
+
+ Object.assign(this.data.initMessaging, {
+ channel_slots: {
+ channel_channel: [{
+ channel_type: 'channel',
+ id: 20,
+ name: "General",
+ }, {
+ channel_type: 'channel',
+ id: 21,
+ name: "Special",
+ }],
+ },
+ });
+ await this.start({
+ discuss: {
+ params: {
+ default_active_id: 'mail.channel_20',
+ },
+ },
+ async mockRPC(route, args) {
+ if (args.method === 'message_fetch') {
+ return [];
+ }
+ return this._super(...arguments);
+ },
+ });
+ const channels = document.querySelectorAll(`
+ .o_DiscussSidebar_groupChannel .o_DiscussSidebar_item
+ `);
+ // Add attachment in a message for #general
+ await afterNextRender(async () => {
+ const file = await createFile({
+ content: 'hello, world',
+ contentType: 'text/plain',
+ name: 'text.txt',
+ });
+ inputFiles(
+ document.querySelector('.o_FileUploader_input'),
+ [file]
+ );
+ });
+ // Switch to #special
+ await afterNextRender(() => channels[1].click());
+ // Add attachments in a message for #special
+ const files = [
+ await createFile({
+ content: 'hello2, world',
+ contentType: 'text/plain',
+ name: 'text2.txt',
+ }),
+ await createFile({
+ content: 'hello3, world',
+ contentType: 'text/plain',
+ name: 'text3.txt',
+ }),
+ await createFile({
+ content: 'hello4, world',
+ contentType: 'text/plain',
+ name: 'text4.txt',
+ }),
+ ];
+ await afterNextRender(() =>
+ inputFiles(
+ document.querySelector('.o_FileUploader_input'),
+ files
+ )
+ );
+ // Switch back to #general
+ await afterNextRender(() => channels[0].click());
+ // Check attachment is reloaded
+ assert.strictEqual(
+ document.querySelectorAll(`.o_Composer .o_Attachment`).length,
+ 1,
+ "should have 1 attachment in the composer"
+ );
+ assert.strictEqual(
+ document.querySelector(`.o_Composer .o_Attachment`).dataset.attachmentLocalId,
+ this.env.models['mail.attachment'].find(attachment => attachment.id === 1).localId,
+ "should have correct 1st attachment in the composer"
+ );
+
+ // Switch back to #special
+ await afterNextRender(() => channels[1].click());
+ // Check attachments are reloaded
+ assert.strictEqual(
+ document.querySelectorAll(`.o_Composer .o_Attachment`).length,
+ 3,
+ "should have 3 attachments in the composer"
+ );
+ assert.strictEqual(
+ document.querySelectorAll(`.o_Composer .o_Attachment`)[0].dataset.attachmentLocalId,
+ this.env.models['mail.attachment'].find(attachment => attachment.id === 2).localId,
+ "should have attachment with id 2 as 1st attachment"
+ );
+ assert.strictEqual(
+ document.querySelectorAll(`.o_Composer .o_Attachment`)[1].dataset.attachmentLocalId,
+ this.env.models['mail.attachment'].find(attachment => attachment.id === 3).localId,
+ "should have attachment with id 3 as 2nd attachment"
+ );
+ assert.strictEqual(
+ document.querySelectorAll(`.o_Composer .o_Attachment`)[2].dataset.attachmentLocalId,
+ this.env.models['mail.attachment'].find(attachment => attachment.id === 4).localId,
+ "should have attachment with id 4 as 3rd attachment"
+ );
+});
+
+QUnit.test('post a simple message', async function (assert) {
+ assert.expect(15);
+
+ const self = this;
+ Object.assign(this.data.initMessaging, {
+ channel_slots: {
+ channel_channel: [{
+ channel_type: "channel",
+ id: 20,
+ name: "General",
+ }],
+ },
+ });
+ let messagesData = [];
+ await this.start({
+ discuss: {
+ params: {
+ default_active_id: 'mail.channel_20',
+ },
+ },
+ async mockRPC(route, args) {
+ if (args.method === 'message_fetch') {
+ return messagesData;
+ }
+ if (args.method === 'message_post') {
+ assert.step('message_post');
+ assert.strictEqual(
+ args.args[0],
+ 20,
+ "should post message to channel Id 20"
+ );
+ assert.strictEqual(
+ args.kwargs.body,
+ "Test",
+ "should post with provided content in composer input"
+ );
+ assert.strictEqual(
+ args.kwargs.message_type,
+ "comment",
+ "should set message type as 'comment'"
+ );
+ assert.strictEqual(
+ args.kwargs.subtype_xmlid,
+ "mail.mt_comment",
+ "should set subtype_xmlid as 'comment'"
+ );
+ // simulate receiving a new message
+ const data = {
+ author_id: [3, "Admin"],
+ body: args.kwargs.body,
+ channel_ids: [20],
+ date: "2019-04-20 11:00:00",
+ id: 101,
+ message_type: args.kwargs.message_type,
+ model: 'mail.channel',
+ subtype_xmlid: args.kwargs.subtype_xmlid,
+ record_name: 'General',
+ res_id: 20,
+ };
+ const notifications = [
+ [['my-db', 'mail.channel', 20], data]
+ ];
+ messagesData.push(data);
+ self.widget.call('bus_service', 'trigger', 'notification', notifications);
+ return;
+ }
+ return this._super(...arguments);
+ },
+ });
+ assert.strictEqual(
+ document.querySelectorAll(`.o_MessageList_empty`).length,
+ 1,
+ "should display thread with no message initially"
+ );
+ assert.strictEqual(
+ document.querySelectorAll(`.o_Message`).length,
+ 0,
+ "should display no message initially"
+ );
+ assert.strictEqual(
+ document.querySelector(`.o_ComposerTextInput_textarea`).value,
+ "",
+ "should have empty content initially"
+ );
+
+ // insert some HTML in editable
+ document.querySelector(`.o_ComposerTextInput_textarea`).focus();
+ document.execCommand('insertText', false, "Test");
+ assert.strictEqual(
+ document.querySelector(`.o_ComposerTextInput_textarea`).value,
+ "Test",
+ "should have inserted text in editable"
+ );
+
+ await afterNextRender(() =>
+ document.querySelector(`.o_ComposerTextInput_textarea`)
+ .dispatchEvent(new window.KeyboardEvent('keydown', { key: 'Enter' }))
+ );
+ assert.verifySteps(['message_post']);
+ assert.strictEqual(
+ document.querySelector(`.o_ComposerTextInput_textarea`).value,
+ "",
+ "should have no content in composer input after posting message"
+ );
+ assert.strictEqual(
+ document.querySelectorAll(`.o_Message`).length,
+ 1,
+ "should display a message after posting message"
+ );
+ const message = document.querySelector(`.o_Message`);
+ assert.strictEqual(
+ message.dataset.messageLocalId,
+ this.env.models['mail.message'].find(message => message.id === 101).localId,
+ "new message in thread should be linked to newly created message from message post"
+ );
+ assert.strictEqual(
+ message.querySelector(`:scope .o_Message_authorName`).textContent,
+ "Admin",
+ "new message in thread should be from Admin"
+ );
+ assert.strictEqual(
+ message.querySelector(`:scope .o_Message_content`).textContent,
+ "Test",
+ "new message in thread should have content typed from composer text input"
+ );
+});
+
+QUnit.test('rendering of inbox message', async function (assert) {
+ // AKU TODO: kinda message specific test
+ assert.expect(7);
+
+ await this.start({
+ async mockRPC(route, args) {
+ if (args.method === 'message_fetch') {
+ return [{
+ author_id: [11, "Demo"],
+ body: "<p>body</p>",
+ date: "2019-04-20 10:00:00",
+ id: 100,
+ message_type: 'comment',
+ model: 'project.task',
+ needaction_partner_ids: [44],
+ record_name: 'Refactoring',
+ res_id: 20,
+ }];
+ }
+ return this._super(...arguments);
+ },
+ });
+ assert.strictEqual(
+ document.querySelectorAll('.o_Message').length,
+ 1,
+ "should display a message"
+ );
+ const message = document.querySelector('.o_Message');
+ assert.strictEqual(
+ message.querySelectorAll(`:scope .o_Message_originThread`).length,
+ 1,
+ "should display origin thread of message"
+ );
+ assert.strictEqual(
+ message.querySelector(`:scope .o_Message_originThread`).textContent,
+ " on Refactoring",
+ "should display origin thread name"
+ );
+ assert.strictEqual(
+ message.querySelectorAll(`:scope .o_Message_command`).length,
+ 3,
+ "should display 3 commands"
+ );
+ assert.strictEqual(
+ message.querySelectorAll(`:scope .o_Message_commandStar`).length,
+ 1,
+ "should display star command"
+ );
+ assert.strictEqual(
+ message.querySelectorAll(`:scope .o_Message_commandReply`).length,
+ 1,
+ "should display reply command"
+ );
+ assert.strictEqual(
+ message.querySelectorAll(`:scope .o_Message_commandMarkAsRead`).length,
+ 1,
+ "should display mark as read command"
+ );
+});
+
+QUnit.test('mark channel as seen on last message visible', async function (assert) {
+ assert.expect(3);
+
+ let step = 0;
+ Object.assign(this.data.initMessaging, {
+ channel_slots: {
+ channel_channel: [{
+ channel_type: 'channel',
+ id: 10,
+ message_unread_counter: 1,
+ name: "General",
+ }],
+ },
+ });
+ await this.start({
+ async mockRPC(route, args) {
+ if (args.method === 'message_fetch') {
+ step++;
+ if (step === 1) {
+ return [];
+ }
+ if (step === 2) {
+ return [{
+ author_id: [11, "Demo"],
+ body: "<p>body</p>",
+ date: "2019-04-20 10:00:00",
+ id: 100,
+ message_type: 'comment',
+ model: 'mail.channel',
+ record_name: "General",
+ res_id: 10,
+ }];
+ }
+ throw new Error("should not call 'message_fetch' more than twice");
+ }
+ return this._super(...arguments);
+ },
+ });
+ assert.strictEqual(
+ document.querySelectorAll(`
+ .o_DiscussSidebar_item[data-thread-local-id="${
+ this.env.models['mail.thread'].find(thread =>
+ thread.id === 10 &&
+ thread.model === 'mail.channel'
+ ).localId
+ }"]
+ `).length,
+ 1,
+ "should have discuss sidebar item with the channel"
+ );
+ assert.ok(
+ document.querySelector(`
+ .o_DiscussSidebar_item[data-thread-local-id="${
+ this.env.models['mail.thread'].find(thread =>
+ thread.id === 10 &&
+ thread.model === 'mail.channel'
+ ).localId
+ }"]
+ `).classList.contains('o-unread'),
+ "sidebar item of channel ID 10 should be unread"
+ );
+
+ await afterNextRender(() =>
+ document.querySelector(`
+ .o_DiscussSidebar_item[data-thread-local-id="${
+ this.env.models['mail.thread'].find(thread =>
+ thread.id === 10 &&
+ thread.model === 'mail.channel'
+ ).localId
+ }"]
+ `).click()
+ );
+ assert.notOk(
+ document.querySelector(`
+ .o_DiscussSidebar_item[data-thread-local-id="${
+ this.env.models['mail.thread'].find(thread =>
+ thread.id === 10 &&
+ thread.model === 'mail.channel'
+ ).localId
+ }"]
+ `).classList.contains('o-unread'),
+ "sidebar item of channel ID 10 should not longer be unread"
+ );
+});
+
+QUnit.test('receive new needaction messages', async function (assert) {
+ assert.expect(12);
+
+ await this.start({
+ async mockRPC(route, args) {
+ if (args.method === 'message_fetch') {
+ return [];
+ }
+ return this._super(...arguments);
+ },
+ });
+ assert.ok(
+ document.querySelector(`
+ .o_DiscussSidebar_item[data-thread-local-id="${
+ this.env.messaging.inbox.localId
+ }"]
+ `),
+ "should have inbox in sidebar"
+ );
+ assert.ok(
+ document.querySelector(`
+ .o_DiscussSidebar_item[data-thread-local-id="${
+ this.env.messaging.inbox.localId
+ }"]
+ `).classList.contains('o-active'),
+ "inbox should be current discuss thread"
+ );
+ assert.notOk(
+ document.querySelector(`
+ .o_DiscussSidebar_item[data-thread-local-id="${
+ this.env.messaging.inbox.localId
+ }"]
+ .o_DiscussSidebarItem_counter
+ `),
+ "inbox item in sidebar should not have any counter"
+ );
+ assert.strictEqual(
+ document.querySelectorAll(`.o_Discuss_thread .o_Message`).length,
+ 0,
+ "should have no messages in inbox initially"
+ );
+
+ // simulate receiving a new needaction message
+ await afterNextRender(() => {
+ const data = {
+ author_id: [7, "Demo"],
+ body: "<p>Test</p>",
+ date: "2019-04-20 11:00:00",
+ id: 100,
+ message_type: 'comment',
+ needaction_partner_ids: [3],
+ model: 'project.task',
+ record_name: 'Refactoring',
+ res_id: 20,
+ };
+ const notifications = [[['my-db', 'ir.needaction', 3], data]];
+ this.widget.call('bus_service', 'trigger', 'notification', notifications);
+ });
+ assert.ok(
+ document.querySelector(`
+ .o_DiscussSidebar_item[data-thread-local-id="${
+ this.env.messaging.inbox.localId
+ }"]
+ .o_DiscussSidebarItem_counter
+ `),
+ "inbox item in sidebar should now have counter"
+ );
+ assert.strictEqual(
+ document.querySelector(`
+ .o_DiscussSidebar_item[data-thread-local-id="${
+ this.env.messaging.inbox.localId
+ }"]
+ .o_DiscussSidebarItem_counter
+ `).textContent,
+ '1',
+ "inbox item in sidebar should have counter of '1'"
+ );
+ assert.strictEqual(
+ document.querySelectorAll(`.o_Discuss_thread .o_Message`).length,
+ 1,
+ "should have one message in inbox"
+ );
+ assert.strictEqual(
+ document.querySelector(`.o_Discuss_thread .o_Message`).dataset.messageLocalId,
+ this.env.models['mail.message'].find(message => message.id === 100).localId,
+ "should display newly received needaction message"
+ );
+
+ // simulate receiving another new needaction message
+ await afterNextRender(() => {
+ const data2 = {
+ author_id: [7, "Demo"],
+ body: "<p>Test2</p>",
+ date: "2019-04-20 11:00:00",
+ id: 101,
+ message_type: 'comment',
+ needaction_partner_ids: [3],
+ model: 'project.task',
+ record_name: 'Refactoring',
+ res_id: 20,
+ };
+ const notifications2 = [[['my-db', 'ir.needaction', 3], data2]];
+ this.widget.call('bus_service', 'trigger', 'notification', notifications2);
+ });
+ assert.strictEqual(
+ document.querySelector(`
+ .o_DiscussSidebar_item[data-thread-local-id="${
+ this.env.messaging.inbox.localId
+ }"]
+ .o_DiscussSidebarItem_counter
+ `).textContent,
+ '2',
+ "inbox item in sidebar should have counter of '2'"
+ );
+ assert.strictEqual(
+ document.querySelectorAll(`.o_Discuss_thread .o_Message`).length,
+ 2,
+ "should have 2 messages in inbox"
+ );
+ assert.ok(
+ document.querySelector(`
+ .o_Discuss_thread
+ .o_Message[data-message-local-id="${
+ this.env.models['mail.message'].find(message => message.id === 100).localId
+ }"]
+ `),
+ "should still display 1st needaction message"
+ );
+ assert.ok(
+ document.querySelector(`
+ .o_Discuss_thread
+ .o_Message[data-message-local-id="${
+ this.env.models['mail.message'].find(message => message.id === 101).localId
+ }"]
+ `),
+ "should display 2nd needaction message"
+ );
+});
+
+QUnit.test('reply to message from inbox (message linked to document)', async function (assert) {
+ assert.expect(20);
+
+ Object.assign(this.data.initMessaging, { needaction_inbox_counter: 1 });
+
+ let messagesData = [];
+ await this.start({
+ async mockRPC(route, args) {
+ if (args.method === 'message_fetch') {
+ return [{
+ author_id: [7, "Demo"],
+ body: "<p>Test</p>",
+ date: "2019-04-20 11:00:00",
+ id: 100,
+ message_type: 'comment',
+ needaction_partner_ids: [3],
+ model: 'project.task',
+ record_name: 'Refactoring',
+ res_id: 20,
+ }];
+ }
+ if (args.method === 'message_post') {
+ assert.step('message_post');
+ assert.strictEqual(
+ args.model,
+ 'project.task',
+ "should post message to record with model 'project.task'"
+ );
+ assert.strictEqual(
+ args.args[0],
+ 20,
+ "should post message to record with Id 20"
+ );
+ assert.strictEqual(
+ args.kwargs.body,
+ "Test",
+ "should post with provided content in composer input"
+ );
+ assert.strictEqual(
+ args.kwargs.message_type,
+ "comment",
+ "should set message type as 'comment'"
+ );
+ assert.strictEqual(
+ args.kwargs.subtype_xmlid,
+ "mail.mt_comment",
+ "should set subtype_xmlid as 'comment'"
+ );
+ messagesData.push({
+ author_id: [3, "Admin"],
+ body: args.kwargs.body,
+ date: "2019-04-20 11:00:00",
+ id: 101,
+ message_type: args.kwargs.message_type,
+ model: args.model,
+ subtype_xmlid: args.kwargs.subtype_xmlid,
+ record_name: 'Refactoring',
+ res_id: 20,
+ });
+ return;
+ }
+ if (args.method === 'message_format') {
+ return messagesData;
+ }
+ return this._super(...arguments);
+ },
+ });
+ assert.strictEqual(
+ document.querySelectorAll('.o_Message').length,
+ 1,
+ "should display a single message"
+ );
+ assert.strictEqual(
+ document.querySelector('.o_Message').dataset.messageLocalId,
+ this.env.models['mail.message'].find(message => message.id === 100).localId,
+ "should display message with ID 100"
+ );
+ assert.strictEqual(
+ document.querySelector('.o_Message_originThread').textContent,
+ " on Refactoring",
+ "should display message originates from record 'Refactoring'"
+ );
+
+ await afterNextRender(() =>
+ document.querySelector('.o_Message_commandReply').click()
+ );
+ assert.ok(
+ document.querySelector('.o_Message').classList.contains('o-selected'),
+ "message should be selected after clicking on reply icon"
+ );
+ assert.ok(
+ document.querySelector('.o_Composer'),
+ "should have composer after clicking on reply to message"
+ );
+ assert.strictEqual(
+ document.querySelector(`.o_Composer_threadName`).textContent,
+ " on: Refactoring",
+ "composer should display origin thread name of message"
+ );
+ assert.strictEqual(
+ document.activeElement,
+ document.querySelector(`.o_ComposerTextInput_textarea`),
+ "composer text input should be auto-focus"
+ );
+
+ await afterNextRender(() => {
+ document.execCommand('insertText', false, "Test");
+ document.querySelector(`.o_ComposerTextInput_textarea`)
+ .dispatchEvent(new window.KeyboardEvent('keydown', { key: 'Enter' }));
+ });
+ assert.verifySteps(['message_post']);
+ assert.notOk(
+ document.querySelector('.o_Composer'),
+ "should no longer have composer after posting reply to message"
+ );
+ assert.strictEqual(
+ document.querySelectorAll('.o_Message').length,
+ 1,
+ "should still display a single message after posting reply"
+ );
+ assert.strictEqual(
+ document.querySelector('.o_Message').dataset.messageLocalId,
+ this.env.models['mail.message'].find(message => message.id === 100).localId,
+ "should still display message with ID 100 after posting reply"
+ );
+ assert.notOk(
+ document.querySelector('.o_Message').classList.contains('o-selected'),
+ "message should not longer be selected after posting reply"
+ );
+ assert.ok(
+ document.querySelector('.o_notification'),
+ "should display a notification after posting reply"
+ );
+ assert.strictEqual(
+ document.querySelector('.o_notification_content').textContent,
+ "Message posted on \"Refactoring\"",
+ "notification should tell that message has been posted to the record 'Refactoring'"
+ );
+});
+
+QUnit.skip('load recent messages from thread (already loaded some old messages)', async function (assert) {
+ // TODO FIXME should be restored as part of task-2171881 (robust "load more" with "holes")
+ // Load old message that has been needaction, when opening this thread for
+ // the first time, it should load messages that are more recent.
+ assert.expect(17);
+
+ Object.assign(this.data.initMessaging, {
+ channel_slots: {
+ channel_channel: [{
+ channel_type: 'channel',
+ id: 20,
+ name: "General",
+ }],
+ },
+ needaction_inbox_counter: 1,
+ });
+ let step = 0;
+ let mailMessages = {};
+ for (let i = 0; i < 50; i++) {
+ mailMessages[100 + i] = {
+ author_id: [7, "Demo"],
+ body: `<p>Test${i}</p>`,
+ date: `2019-04-20 11:00:${i < 10 ? '0' + i : i}`,
+ id: 100 + i,
+ message_type: 'comment',
+ needaction_partner_ids: i === 0 ? [3] : [],
+ model: 'mail.channel',
+ record_name: "General",
+ res_id: 20,
+ };
+ }
+ await this.start({
+ async mockRPC(route, args) {
+ if (args.method === 'message_fetch') {
+ step++;
+ if (step === 1) {
+ assert.step('message_fetch:load_inbox');
+ assert.deepEqual(
+ args.args[0],
+ [['needaction', '=', true]],
+ "should fetch needaction messages from inbox"
+ );
+ const needactionMessages = Object
+ .values(mailMessages)
+ .filter(mailMessage =>
+ mailMessage.needaction_partner_ids.length > 0
+ );
+ return needactionMessages;
+ }
+ if (step === 2) {
+ assert.step('message_fetch:load_channel_20');
+ assert.deepEqual(
+ args.args[0],
+ [['channel_ids', 'in', [20]]],
+ "should fetch messages from channel ID 20"
+ );
+ assert.strictEqual(
+ args.kwargs.limit,
+ 30,
+ "should limit fetch to 30 messages (load)"
+ );
+ const moreRecentMessages = Object
+ .values(mailMessages)
+ .sort((mailMsg1, mailMsg2) => mailMsg1.id > mailMsg2.id ? -1 : 1)
+ .splice(0, 30);
+ return moreRecentMessages;
+ }
+ if (step === 3) {
+ assert.step('message_fetch:load_more_channel_20');
+ assert.deepEqual(
+ args.args[0],
+ // loaded 30 messages, so 150 - 30 = 120 = ID of last message fetched
+ // => load more messages starting from this ID
+ [['id', '<', 120], ['channel_ids', 'in', [20]]],
+ "should fetch more messages from 30 more recent messages"
+ );
+ assert.strictEqual(
+ args.kwargs.limit,
+ 30,
+ "should limit fetch to 30 messages (load more)"
+ );
+ const loadMoreMessages = Object
+ .values(mailMessages)
+ .filter(mailMessage => mailMessage.id < 120)
+ .sort((mailMsg1, mailMsg2) => mailMsg1.id > mailMsg2.id ? -1 : 1)
+ .splice(0, 30);
+ return loadMoreMessages;
+ }
+ throw new Error("should not fetch more than twice");
+ }
+ return this._super(...arguments);
+ },
+ });
+ assert.verifySteps(
+ ['message_fetch:load_inbox'],
+ "should initially have fetched messages from inbox"
+ );
+ assert.strictEqual(
+ document.querySelectorAll('.o_Message').length,
+ 1,
+ "should fetch a single message from inbox"
+ );
+ assert.strictEqual(
+ document.querySelector('.o_Message').dataset.messageLocalId,
+ this.env.models['mail.message'].find(message => message.id === 100).localId,
+ "should have fetched 1st message of channel 'General' as needaction from inbox"
+ );
+
+ await afterNextRender(() =>
+ document.querySelector(`
+ .o_DiscussSidebar_item[data-thread-local-id="${
+ this.env.models['mail.thread'].find(thread =>
+ thread.id === 20 &&
+ thread.model === 'mail.channel'
+ ).localId
+ }"]
+ `).click()
+ );
+ assert.verifySteps(
+ ['message_fetch:load_channel_20'],
+ "should initially have fetched messages from channel 'General' (channel 20)"
+ );
+ assert.strictEqual(
+ document.querySelectorAll('.o_Message').length,
+ 31,
+ "should display 30 messages from channel 'General' (only fetched ones)"
+ );
+ assert.strictEqual(
+ document.querySelectorAll(`
+ .o_Message[data-message-local-id="${
+ this.env.models['mail.message'].find(message => message.id === 100).localId
+ }"]
+ `).length,
+ 1,
+ "should not display 1st message of 'General' as needaction from inbox"
+ );
+
+ await afterNextRender(() => {
+ document.querySelector(`.o_Discuss_thread .o_ThreadViewer_messageList`).scrollTop = 0;
+ });
+ assert.verifySteps(
+ ['message_fetch:load_more_channel_20'],
+ "should have fetched more messages from channel 'General' (channel 20)"
+ );
+ assert.strictEqual(
+ document.querySelectorAll('.o_Message').length,
+ 50,
+ "should display 50 messages from channel 'General' (all fetched)"
+ );
+ assert.strictEqual(
+ document.querySelectorAll(`
+ .o_Message[data-message-local-id="${
+ this.env.models['mail.message'].find(message => message.id === 100).localId
+ }"]
+ `).length,
+ 1,
+ "should include 1st message of 'General' as needaction from inbox in the fetched more messages"
+ );
+});
+
+QUnit.test('messages marked as read move to "History" mailbox', async function (assert) {
+ assert.expect(10);
+
+ const self = this;
+ this.data['mail.message'].records = [{
+ author_id: [5, 'Demo User'],
+ body: '<p>test 1</p>',
+ id: 1,
+ needaction: true,
+ needaction_partner_ids: [3],
+ }, {
+ author_id: [6, 'Test User'],
+ body: '<p>test 2</p>',
+ id: 2,
+ needaction: true,
+ needaction_partner_ids: [3],
+ }];
+ this.data['mail.notification'].records = [{
+ id: 50,
+ is_read: false,
+ mail_message_id: 1,
+ res_partner_id: 3,
+ }, {
+ id: 51,
+ is_read: false,
+ mail_message_id: 1,
+ res_partner_id: 3,
+ }];
+ this.data.initMessaging.needaction_inbox_counter = 2;
+ await this.start({
+ discuss: {
+ params: {
+ default_active_id: 'mail.box_history',
+ },
+ },
+ async mockRPC(route, args) {
+ if (args.method === 'mark_all_as_read') {
+ for (const message of this.data['mail.message'].records) {
+ message.history_partner_ids = [3];
+ message.needaction_partner_ids = [];
+ }
+ const notificationData = {
+ type: 'mark_as_read',
+ message_ids: [1, 2],
+ };
+ const notification = [[false, 'res.partner', 3], notificationData];
+ self.widget.call('bus_service', 'trigger', 'notification', [notification]);
+ return 3;
+ }
+ return this._super(...arguments);
+ },
+ });
+ assert.ok(
+ document.querySelector(`
+ .o_DiscussSidebar_item[data-thread-local-id="${
+ this.env.messaging.history.localId
+ }"]
+ `).classList.contains('o-active'),
+ "History mailbox should be active thread"
+ );
+ assert.strictEqual(
+ document.querySelectorAll(`.o_Discuss_thread .o_MessageList_empty`).length,
+ 1,
+ "should have empty thread in history"
+ );
+
+ await afterNextRender(() =>
+ document.querySelector(`
+ .o_DiscussSidebar_item[data-thread-local-id="${
+ this.env.messaging.inbox.localId
+ }"]
+ `).click()
+ );
+ assert.ok(
+ document.querySelector(`
+ .o_DiscussSidebar_item[data-thread-local-id="${
+ this.env.messaging.inbox.localId
+ }"]
+ `).classList.contains('o-active'),
+ "Inbox mailbox should be active thread"
+ );
+ assert.strictEqual(
+ document.querySelectorAll(`.o_Discuss_thread .o_MessageList_empty`).length,
+ 0,
+ "Inbox mailbox should not be empty"
+ );
+ assert.strictEqual(
+ document.querySelectorAll(`.o_Discuss_thread .o_MessageList_message`).length,
+ 2,
+ "Inbox mailbox should have 2 messages"
+ );
+
+ await afterNextRender(() =>
+ document.querySelector('.o_widget_Discuss_controlPanelButtonMarkAllRead').click()
+ );
+ assert.ok(
+ document.querySelector(`
+ .o_DiscussSidebar_item[data-thread-local-id="${
+ this.env.messaging.inbox.localId
+ }"]
+ `).classList.contains('o-active'),
+ "Inbox mailbox should still be active after mark as read"
+ );
+ assert.strictEqual(
+ document.querySelectorAll(`.o_Discuss_thread .o_MessageList_empty`).length,
+ 1,
+ "Inbox mailbox should now be empty after mark as read"
+ );
+
+ await afterNextRender(() =>
+ document.querySelector(`
+ .o_DiscussSidebar_item[data-thread-local-id="${
+ this.env.messaging.history.localId
+ }"]
+ `).click()
+ );
+ assert.ok(
+ document.querySelector(`
+ .o_DiscussSidebar_item[data-thread-local-id="${
+ this.env.messaging.history.localId
+ }"]
+ `).classList.contains('o-active'),
+ "History mailbox should be active"
+ );
+ assert.strictEqual(
+ document.querySelectorAll(`.o_Discuss_thread .o_MessageList_empty`).length,
+ 0,
+ "History mailbox should not be empty after mark as read"
+ );
+ assert.strictEqual(
+ document.querySelectorAll(`.o_Discuss_thread .o_MessageList_message`).length,
+ 2,
+ "History mailbox should have 2 messages"
+ );
+});
+
+QUnit.test('all messages in "Inbox" in "History" after marked all as read', async function (assert) {
+ assert.expect(10);
+
+ const self = this;
+ const messagesData = [];
+ const messageOffset = 200;
+ const partnerOffset = 100;
+ for (let i = messageOffset; i < messageOffset + 40; i++) {
+ messagesData.push({
+ author_id: [partnerOffset + i, 'User ' + (partnerOffset + i)],
+ body: '<p>test ' + i + '</p>',
+ id: i,
+ needaction: true,
+ needaction_partner_ids: [3],
+ });
+ }
+
+ this.data['mail.message'].records = messagesData;
+
+ let messageFetchCount = 0;
+ const initDef = makeTestPromise();
+ const markAllReadDef = makeTestPromise();
+ const clickHistoryDef = makeTestPromise();
+ const loadMoreDef = makeTestPromise();
+
+ await this.start({
+ async mockRPC(route, args) {
+ if (args.method === 'mark_all_as_read') {
+ const messageIDs = [];
+ for (let i = 0; i < messagesData.length; i++) {
+ this.data['mail.message'].records[i].history_partner_ids = [3];
+ this.data['mail.message'].records[i].needaction_partner_ids = [];
+ this.data['mail.message'].records[i].needaction = false;
+ messageIDs.push(messageOffset + i);
+ }
+ const notificationData = {
+ type: 'mark_as_read',
+ message_ids: messageIDs,
+ };
+ const notification = [[false, 'res.partner', 3], notificationData];
+ self.widget.call('bus_service', 'trigger', 'notification', [notification]);
+ markAllReadDef.resolve();
+ return 3;
+ }
+ if (args.method === 'message_fetch') {
+ // 1st message_fetch: 'Inbox' initially
+ // 2nd message_fetch: 'History' initially
+ // 3rd message_fetch: 'History' load more
+ assert.step(args.method);
+
+ messageFetchCount++;
+ if (messageFetchCount === 1) {
+ initDef.resolve();
+ }
+ if (messageFetchCount === 2) {
+ clickHistoryDef.resolve();
+ }
+ if (messageFetchCount === 3) {
+ loadMoreDef.resolve();
+ }
+ }
+ return this._super(...arguments);
+ },
+ });
+
+ await initDef;
+ assert.verifySteps(
+ ['message_fetch'],
+ "should fetch messages once for needaction messages (Inbox)"
+ );
+ assert.containsN(
+ document.body,
+ '.o_Message',
+ 30,
+ "there should be 30 messages that are loaded in Inbox"
+ );
+
+ await afterNextRender(async () => {
+ const markAllReadButton = document.querySelector('.o_widget_Discuss_controlPanelButtonMarkAllRead');
+ markAllReadButton.click();
+ await markAllReadDef;
+ });
+ assert.containsNone(
+ document.body,
+ '.o_Message',
+ "there should no message in inbox anymore"
+ );
+
+ await afterNextRender(async () => {
+ document.querySelector(`
+ .o_DiscussSidebarItem[data-thread-local-id="${
+ this.env.messaging.history.localId
+ }"]
+ `).click();
+ await clickHistoryDef;
+ });
+ assert.verifySteps(
+ ['message_fetch'],
+ "should fetch messages once for history"
+ );
+ assert.containsN(
+ document.body,
+ '.o_Message',
+ 30,
+ "there should be 30 messages in History"
+ );
+
+ // simulate a scroll to top to load more messages
+ await afterNextRender(async () => {
+ document.querySelector('.o_MessageList').scrollTop = 0;
+ await loadMoreDef;
+ });
+ assert.verifySteps(
+ ['message_fetch'],
+ "should fetch more messages in history for loadMore"
+ );
+ assert.containsN(
+ document.body,
+ '.o_Message',
+ 40,
+ "there should be 40 messages in History"
+ );
+});
+
+QUnit.test('receive new channel message: out of odoo focus (notification, channel)', async function (assert) {
+ assert.expect(4);
+
+ Object.assign(this.data.initMessaging, {
+ channel_slots: {
+ channel_channel: [{
+ channel_type: "channel",
+ id: 20,
+ message_unread_counter: 0,
+ name: "General",
+ }],
+ },
+ });
+ const bus = new Bus();
+ bus.on('set_title_part', null, payload => {
+ assert.step('set_title_part');
+ assert.strictEqual(payload.part, '_chat');
+ assert.strictEqual(payload.title, "1 Message");
+ });
+
+ await this.start({
+ env: { bus },
+ services: {
+ bus_service: BusService.extend({
+ _beep() {}, // Do nothing
+ _poll() {}, // Do nothing
+ _registerWindowUnload() {}, // Do nothing
+ isOdooFocused: () => false,
+ updateOption() {},
+ }),
+ },
+ });
+
+ // simulate receiving a new message with odoo focused
+ await afterNextRender(() => {
+ const messageData = {
+ author_id: [7, "Demo User"],
+ body: "<p>Test</p>",
+ channel_ids: [20],
+ date: "2019-04-20 10:00:00",
+ id: 126,
+ message_type: 'comment',
+ model: 'mail.channel',
+ record_name: 'General',
+ res_id: 20,
+ };
+ const notifications = [[['my-db', 'mail.channel', 20], messageData]];
+ this.widget.call('bus_service', 'trigger', 'notification', notifications);
+ });
+ assert.verifySteps(['set_title_part']);
+});
+
+QUnit.test('receive new channel message: out of odoo focus (notification, chat)', async function (assert) {
+ assert.expect(4);
+
+ Object.assign(this.data.initMessaging, {
+ channel_slots: {
+ channel_direct_message: [{
+ channel_type: "chat",
+ direct_partner: [{
+ id: 7,
+ name: "Demo User",
+ }],
+ id: 10,
+ message_unread_counter: 0,
+ }],
+ },
+ });
+ const bus = new Bus();
+ bus.on('set_title_part', null, payload => {
+ assert.step('set_title_part');
+ assert.strictEqual(payload.part, '_chat');
+ assert.strictEqual(payload.title, "1 Message");
+ });
+
+ await this.start({
+ env: { bus },
+ services: {
+ bus_service: BusService.extend({
+ _beep() {}, // Do nothing
+ _poll() {}, // Do nothing
+ _registerWindowUnload() {}, // Do nothing
+ isOdooFocused: () => false,
+ updateOption() {},
+ }),
+ },
+ });
+
+ // simulate receiving a new message with odoo focused
+ await afterNextRender(() => {
+ const messageData = {
+ author_id: [7, "Demo User"],
+ body: "<p>Test</p>",
+ channel_ids: [10],
+ date: "2019-04-20 10:00:00",
+ id: 126,
+ message_type: 'comment',
+ model: 'mail.channel',
+ record_name: 'General',
+ res_id: 10,
+ };
+ const notifications = [[['my-db', 'mail.channel', 10], messageData]];
+ this.widget.call('bus_service', 'trigger', 'notification', notifications);
+ });
+ assert.verifySteps(['set_title_part']);
+});
+
+QUnit.test('receive new channel messages: out of odoo focus (tab title)', async function (assert) {
+ assert.expect(12);
+
+ let step = 0;
+ Object.assign(this.data.initMessaging, {
+ channel_slots: {
+ channel_channel: [{
+ channel_type: "channel",
+ id: 20,
+ message_unread_counter: 0,
+ name: "General",
+ }],
+ channel_direct_message: [{
+ channel_type: "chat",
+ direct_partner: [{
+ id: 7,
+ name: "Demo User",
+ }],
+ id: 10,
+ message_unread_counter: 0,
+ }],
+ },
+ });
+ const bus = new Bus();
+ bus.on('set_title_part', null, payload => {
+ step++;
+ assert.step('set_title_part');
+ assert.strictEqual(payload.part, '_chat');
+ if (step === 1) {
+ assert.strictEqual(payload.title, "1 Message");
+ }
+ if (step === 2) {
+ assert.strictEqual(payload.title, "2 Messages");
+ }
+ if (step === 3) {
+ assert.strictEqual(payload.title, "3 Messages");
+ }
+ });
+
+ await this.start({
+ env: { bus },
+ services: {
+ bus_service: BusService.extend({
+ _beep() {}, // Do nothing
+ _poll() {}, // Do nothing
+ _registerWindowUnload() {}, // Do nothing
+ isOdooFocused: () => false,
+ updateOption() {},
+ }),
+ },
+ });
+
+ // simulate receiving a new message in general with odoo focused
+ await afterNextRender(() => {
+ const messageData1 = {
+ author_id: [7, "Demo User"],
+ body: "<p>Test1</p>",
+ channel_ids: [20],
+ date: "2019-04-20 10:00:00",
+ id: 126,
+ message_type: 'comment',
+ model: 'mail.channel',
+ record_name: 'General',
+ res_id: 20,
+ };
+ const notifications1 = [[['my-db', 'mail.channel', 20], messageData1]];
+ this.widget.call('bus_service', 'trigger', 'notification', notifications1);
+ });
+ assert.verifySteps(['set_title_part']);
+
+ // simulate receiving a new message in chat with odoo focused
+ await afterNextRender(() => {
+ const messageData2 = {
+ author_id: [7, "Demo User"],
+ body: "<p>Test2</p>",
+ channel_ids: [10],
+ date: "2019-04-20 10:00:00",
+ id: 127,
+ message_type: 'comment',
+ model: 'mail.channel',
+ record_name: 'General',
+ res_id: 10,
+ };
+ const notifications2 = [[['my-db', 'mail.channel', 10], messageData2]];
+ this.widget.call('bus_service', 'trigger', 'notification', notifications2);
+ });
+ assert.verifySteps(['set_title_part']);
+
+ // simulate receiving another new message in chat with odoo focused
+ await afterNextRender(() => {
+ const messageData3 = {
+ author_id: [7, "Demo User"],
+ body: "<p>Test3</p>",
+ channel_ids: [10],
+ date: "2019-04-20 10:00:00",
+ id: 128,
+ message_type: 'comment',
+ model: 'mail.channel',
+ record_name: 'General',
+ res_id: 10,
+ };
+ const notifications3 = [[['my-db', 'mail.channel', 20], messageData3]];
+ this.widget.call('bus_service', 'trigger', 'notification', notifications3);
+ });
+ assert.verifySteps(['set_title_part']);
+});
+
+QUnit.test('auto-focus composer on opening thread', async function (assert) {
+ assert.expect(14);
+
+ Object.assign(this.data.initMessaging, {
+ channel_slots: {
+ channel_channel: [{
+ channel_type: "channel",
+ id: 20,
+ message_unread_counter: 0,
+ name: "General",
+ }],
+ channel_direct_message: [{
+ channel_type: "chat",
+ direct_partner: [{
+ id: 7,
+ name: "Demo User",
+ }],
+ id: 10,
+ message_unread_counter: 0,
+ }],
+ },
+ });
+ await this.start();
+ assert.strictEqual(
+ document.querySelectorAll(`
+ .o_DiscussSidebar_item[data-thread-name="Inbox"]
+ `).length,
+ 1,
+ "should have mailbox 'Inbox' in the sidebar"
+ );
+ assert.ok(
+ document.querySelector(`
+ .o_DiscussSidebar_item[data-thread-name="Inbox"]
+ `).classList.contains('o-active'),
+ "mailbox 'Inbox' should be active initially"
+ );
+ assert.strictEqual(
+ document.querySelectorAll(`
+ .o_DiscussSidebar_item[data-thread-name="General"]
+ `).length,
+ 1,
+ "should have channel 'General' in the sidebar"
+ );
+ assert.notOk(
+ document.querySelector(`
+ .o_DiscussSidebar_item[data-thread-name="General"]
+ `).classList.contains('o-active'),
+ "channel 'General' should not be active initially"
+ );
+ assert.strictEqual(
+ document.querySelectorAll(`
+ .o_DiscussSidebar_item[data-thread-name="Demo User"]
+ `).length,
+ 1,
+ "should have chat 'Demo User' in the sidebar"
+ );
+ assert.notOk(
+ document.querySelector(`
+ .o_DiscussSidebar_item[data-thread-name="Demo User"]
+ `).classList.contains('o-active'),
+ "chat 'Demo User' should not be active initially"
+ );
+ assert.strictEqual(
+ document.querySelectorAll(`.o_Composer`).length,
+ 0,
+ "there should be no composer when active thread of discuss is mailbox 'Inbox'"
+ );
+
+ await afterNextRender(() =>
+ document.querySelector(`.o_DiscussSidebar_item[data-thread-name="General"]`).click()
+ );
+ assert.ok(
+ document.querySelector(`
+ .o_DiscussSidebar_item[data-thread-name="General"]
+ `).classList.contains('o-active'),
+ "channel 'General' should become active after selecting it from the sidebar"
+ );
+ assert.strictEqual(
+ document.querySelectorAll(`.o_Composer`).length,
+ 1,
+ "there should be a composer when active thread of discuss is channel 'General'"
+ );
+ assert.strictEqual(
+ document.activeElement,
+ document.querySelector(`.o_ComposerTextInput_textarea`),
+ "composer of channel 'General' should be automatically focused on opening"
+ );
+
+ document.querySelector(`.o_ComposerTextInput_textarea`).blur();
+ assert.notOk(
+ document.activeElement === document.querySelector(`.o_ComposerTextInput_textarea`),
+ "composer of channel 'General' should no longer focused on click away"
+ );
+
+ await afterNextRender(() =>
+ document.querySelector(`.o_DiscussSidebar_item[data-thread-name="Demo User"]`).click()
+ );
+ assert.ok(
+ document.querySelector(`
+ .o_DiscussSidebar_item[data-thread-name="Demo User"]
+ `).classList.contains('o-active'),
+ "chat 'Demo User' should become active after selecting it from the sidebar"
+ );
+ assert.strictEqual(
+ document.querySelectorAll(`.o_Composer`).length,
+ 1,
+ "there should be a composer when active thread of discuss is chat 'Demo User'"
+ );
+ assert.strictEqual(
+ document.activeElement,
+ document.querySelector(`.o_ComposerTextInput_textarea`),
+ "composer of chat 'Demo User' should be automatically focused on opening"
+ );
+});
+
+QUnit.test('moderation: moderated channel with pending moderation message', async function (assert) {
+ assert.expect(37);
+
+ Object.assign(this.data.initMessaging, {
+ channel_slots: {
+ channel_channel: [{
+ id: 20,
+ channel_type: "channel",
+ name: "general",
+ moderation: true,
+ }],
+ },
+ is_moderator: true,
+ moderation_counter: 1,
+ moderation_channel_ids: [20],
+ });
+ this.data['mail.message'].records = [{
+ author_id: [2, "Someone"],
+ body: "<p>test</p>",
+ channel_ids: [20],
+ id: 100,
+ model: 'mail.channel',
+ moderation_status: 'pending_moderation',
+ need_moderation: true,
+ res_id: 20,
+ }];
+
+ await this.start();
+
+ assert.ok(
+ document.querySelector(`
+ .o_DiscussSidebar_item[data-thread-local-id="${
+ this.env.messaging.moderation.localId
+ }"]
+ `),
+ "should display the moderation box"
+ );
+ const mailboxCounter = document.querySelector(`
+ .o_DiscussSidebar_item[data-thread-local-id="${
+ this.env.messaging.moderation.localId
+ }"]
+ .o_DiscussSidebarItem_counter
+ `);
+ assert.ok(
+ mailboxCounter,
+ "there should be a counter next to the moderation mailbox in the sidebar"
+ );
+ assert.strictEqual(
+ mailboxCounter.textContent.trim(),
+ "1",
+ "the mailbox counter of the moderation mailbox should display '1'"
+ );
+
+ // 1. go to moderation mailbox
+ await afterNextRender(() =>
+ document.querySelector(`
+ .o_DiscussSidebar_item[data-thread-local-id="${
+ this.env.messaging.moderation.localId
+ }"]
+ `).click()
+ );
+ // check message
+ assert.containsOnce(
+ document.body,
+ '.o_Message',
+ "should be only one message in moderation box"
+ );
+ assert.strictEqual(
+ document.querySelector('.o_Message_content').textContent,
+ "test",
+ "this message pending moderation should have the correct content"
+ );
+ assert.containsOnce(
+ document.body,
+ '.o_Message_originThreadLink',
+ "thee message should have one origin"
+ );
+ assert.strictEqual(
+ document.querySelector('.o_Message_originThreadLink').textContent,
+ "#general",
+ "the message pending moderation should have correct origin as its linked document"
+ );
+ assert.containsOnce(
+ document.body,
+ '.o_Message_checkbox',
+ "there should be a moderation checkbox next to the message"
+ );
+ assert.notOk(
+ document.querySelector('.o_Message_checkbox').checked,
+ "the moderation checkbox should be unchecked by default"
+ );
+ // check select all (enabled) / unselect all (disabled) buttons
+ assert.containsOnce(
+ document.body,
+ '.o_widget_Discuss_controlPanelButtonSelectAll',
+ "there should be a 'Select All' button in the control panel"
+ );
+ assert.doesNotHaveClass(
+ document.querySelector('.o_widget_Discuss_controlPanelButtonSelectAll'),
+ 'disabled',
+ "the 'Select All' button should not be disabled"
+ );
+ assert.containsOnce(
+ document.body,
+ '.o_widget_Discuss_controlPanelButtonUnselectAll',
+ "there should be a 'Unselect All' button in the control panel"
+ );
+ assert.hasClass(
+ document.querySelector('.o_widget_Discuss_controlPanelButtonUnselectAll'),
+ 'disabled',
+ "the 'Unselect All' button should be disabled"
+ );
+ // check moderate all buttons (invisible)
+ assert.containsN(
+ document.body,
+ '.o_widget_Discuss_controlPanelButtonModeration',
+ 3,
+ "there should be 3 buttons to moderate selected messages in the control panel"
+ );
+ assert.containsOnce(
+ document.body,
+ '.o_widget_Discuss_controlPanelButtonModeration.o-accept',
+ "there should one moderate button to accept messages pending moderation"
+ );
+ assert.isNotVisible(
+ document.querySelector('.o_widget_Discuss_controlPanelButtonModeration.o-accept'),
+ "the moderate button 'Accept' should be invisible by default"
+ );
+ assert.containsOnce(
+ document.body,
+ '.o_widget_Discuss_controlPanelButtonModeration.o-reject',
+ "there should one moderate button to reject messages pending moderation"
+ );
+ assert.isNotVisible(
+ document.querySelector('.o_widget_Discuss_controlPanelButtonModeration.o-reject'),
+ "the moderate button 'Reject' should be invisible by default"
+ );
+ assert.containsOnce(
+ document.body,
+ '.o_widget_Discuss_controlPanelButtonModeration.o-discard',
+ "there should one moderate button to discard messages pending moderation"
+ );
+ assert.isNotVisible(
+ document.querySelector('.o_widget_Discuss_controlPanelButtonModeration.o-discard'),
+ "the moderate button 'Discard' should be invisible by default"
+ );
+
+ // click on message moderation checkbox
+ await afterNextRender(() => document.querySelector('.o_Message_checkbox').click());
+ assert.ok(
+ document.querySelector('.o_Message_checkbox').checked,
+ "the moderation checkbox should become checked after click"
+ );
+ // check select all (disabled) / unselect all buttons (enabled)
+ assert.hasClass(
+ document.querySelector('.o_widget_Discuss_controlPanelButtonSelectAll'),
+ 'disabled',
+ "the 'Select All' button should be disabled"
+ );
+ assert.doesNotHaveClass(
+ document.querySelector('.o_widget_Discuss_controlPanelButtonUnselectAll'),
+ 'disabled',
+ "the 'Unselect All' button should not be disabled"
+ );
+ // check moderate all buttons updated (visible)
+ assert.isVisible(
+ document.querySelector('.o_widget_Discuss_controlPanelButtonModeration.o-accept'),
+ "the moderate button 'Accept' should be visible"
+ );
+ assert.isVisible(
+ document.querySelector('.o_widget_Discuss_controlPanelButtonModeration.o-reject'),
+ "the moderate button 'Reject' should be visible"
+ );
+ assert.isVisible(
+ document.querySelector('.o_widget_Discuss_controlPanelButtonModeration.o-discard'),
+ "the moderate button 'Discard' should be visible"
+ );
+
+ // test select buttons
+ await afterNextRender(() =>
+ document.querySelector('.o_widget_Discuss_controlPanelButtonUnselectAll').click()
+ );
+ assert.notOk(
+ document.querySelector('.o_Message_checkbox').checked,
+ "the moderation checkbox should become unchecked after click"
+ );
+
+ await afterNextRender(() =>
+ document.querySelector('.o_widget_Discuss_controlPanelButtonSelectAll').click()
+ );
+ assert.ok(
+ document.querySelector('.o_Message_checkbox').checked,
+ "the moderation checkbox should become checked again after click"
+ );
+
+ // 2. go to channel 'general'
+ await afterNextRender(() =>
+ document.querySelector(`
+ .o_DiscussSidebar_item[data-thread-local-id="${
+ this.env.models['mail.thread'].find(thread =>
+ thread.id === 20 &&
+ thread.model === 'mail.channel'
+ ).localId
+ }"]
+ `).click()
+ );
+ // check correct message
+ assert.containsOnce(
+ document.body,
+ '.o_Message',
+ "should be only one message in general channel"
+ );
+ assert.containsOnce(
+ document.body,
+ '.o_Message_checkbox',
+ "there should be a moderation checkbox next to the message"
+ );
+ assert.notOk(
+ document.querySelector('.o_Message_checkbox').checked,
+ "the moderation checkbox should not be checked here"
+ );
+ await afterNextRender(() => document.querySelector('.o_Message_checkbox').click());
+ // Don't test moderation actions visibility, since it is similar to moderation box.
+
+ // 3. test discard button
+ await afterNextRender(() =>
+ document.querySelector('.o_widget_Discuss_controlPanelButtonModeration.o-discard').click()
+ );
+ assert.containsOnce(
+ document.body,
+ '.o_ModerationDiscardDialog',
+ "discard dialog should be open"
+ );
+ // the dialog will be tested separately
+ await afterNextRender(() =>
+ document.querySelector('.o_ModerationDiscardDialog .o-cancel').click()
+ );
+ assert.containsNone(
+ document.body,
+ '.o_ModerationDiscardDialog',
+ "discard dialog should be closed"
+ );
+
+ // 4. test reject button
+ await afterNextRender(() =>
+ document.querySelector(`
+ .o_widget_Discuss_controlPanelButtonModeration.o-reject
+ `).click()
+ );
+ assert.containsOnce(
+ document.body,
+ '.o_ModerationRejectDialog',
+ "reject dialog should be open"
+ );
+ // the dialog will be tested separately
+ await afterNextRender(() =>
+ document.querySelector('.o_ModerationRejectDialog .o-cancel').click()
+ );
+ assert.containsNone(
+ document.body,
+ '.o_ModerationRejectDialog',
+ "reject dialog should be closed"
+ );
+
+ // 5. test accept button
+ await afterNextRender(() =>
+ document.querySelector('.o_widget_Discuss_controlPanelButtonModeration.o-accept').click()
+ );
+ assert.containsOnce(
+ document.body,
+ '.o_Message',
+ "should still be only one message in general channel"
+ );
+ assert.containsNone(
+ document.body,
+ '.o_Message_checkbox',
+ "there should not be a moderation checkbox next to the message"
+ );
+});
+
+QUnit.test('moderation: accept pending moderation message', async function (assert) {
+ assert.expect(12);
+
+ Object.assign(this.data.initMessaging, {
+ channel_slots: {
+ channel_channel: [{
+ id: 20,
+ channel_type: "channel",
+ name: "general",
+ moderation: true,
+ }],
+ },
+ is_moderator: true,
+ moderation_counter: 1,
+ moderation_channel_ids: [20],
+ });
+ this.data['mail.message'].records = [{
+ author_id: [2, "Someone"],
+ body: "<p>test</p>",
+ id: 100,
+ model: 'mail.channel',
+ moderation_status: 'pending_moderation',
+ need_moderation: true,
+ res_id: 20,
+ }];
+
+ await this.start({
+ async mockRPC(route, args) {
+ if (args.method === 'moderate') {
+ assert.step('moderate');
+ const messageIDs = args.args[0];
+ const decision = args.args[1];
+ assert.strictEqual(
+ messageIDs.length,
+ 1,
+ "should moderate one message"
+ );
+ assert.strictEqual(
+ messageIDs[0],
+ 100,
+ "should moderate message with ID 100"
+ );
+ assert.strictEqual(
+ decision,
+ 'accept',
+ "should accept the message"
+ );
+ }
+ return this._super(...arguments);
+ },
+ });
+
+ // 1. go to moderation box
+ const moderationBox = document.querySelector(`
+ .o_DiscussSidebar_item[data-thread-local-id="${
+ this.env.messaging.moderation.localId
+ }"]
+ `);
+ assert.ok(
+ moderationBox,
+ "should display the moderation box"
+ );
+
+ await afterNextRender(() => moderationBox.click());
+ assert.ok(
+ document.querySelector(`
+ .o_Message[data-message-local-id="${
+ this.env.models['mail.message'].find(message => message.id === 100).localId
+ }"]
+ `),
+ "should display the message to moderate"
+ );
+ const acceptButton = document.querySelector(`
+ .o_Message[data-message-local-id="${
+ this.env.models['mail.message'].find(message => message.id === 100).localId
+ }"]
+ .o_Message_moderationAction.o-accept
+ `);
+ assert.ok(acceptButton, "should display the accept button");
+
+ await afterNextRender(() => acceptButton.click());
+ assert.verifySteps(['moderate']);
+ assert.containsOnce(
+ document.body,
+ '.o_MessageList_emptyTitle',
+ "should now have no message displayed in moderation box"
+ );
+
+ // 2. go to channel 'general'
+ const channel = document.querySelector(`
+ .o_DiscussSidebar_item[data-thread-local-id="${
+ this.env.models['mail.thread'].find(thread =>
+ thread.id === 20 &&
+ thread.model === 'mail.channel'
+ ).localId
+ }"]
+ `);
+ assert.ok(
+ channel,
+ "should display the general channel"
+ );
+
+ await afterNextRender(() => channel.click());
+ const message = document.querySelector(`
+ .o_Message[data-message-local-id="${
+ this.env.models['mail.message'].find(message => message.id === 100).localId
+ }"]
+ `);
+ assert.ok(
+ message,
+ "should display the accepted message"
+ );
+ assert.containsNone(
+ message,
+ '.o_Message_moderationPending',
+ "the message should not be pending moderation"
+ );
+});
+
+QUnit.test('moderation: reject pending moderation message (reject with explanation)', async function (assert) {
+ assert.expect(23);
+
+ Object.assign(this.data.initMessaging, {
+ channel_slots: {
+ channel_channel: [{
+ id: 20,
+ channel_type: "channel",
+ name: "general",
+ moderation: true,
+ }],
+ },
+ is_moderator: true,
+ moderation_counter: 1,
+ moderation_channel_ids: [20],
+ });
+ this.data['mail.message'].records = [{
+ author_id: [2, "Someone"],
+ body: "<p>test</p>",
+ id: 100,
+ model: 'mail.channel',
+ moderation_status: 'pending_moderation',
+ need_moderation: true,
+ res_id: 20,
+ }];
+
+ await this.start({
+ async mockRPC(route, args) {
+ if (args.method === 'moderate') {
+ assert.step('moderate');
+ const messageIDs = args.args[0];
+ const decision = args.args[1];
+ const kwargs = args.kwargs;
+ assert.strictEqual(
+ messageIDs.length,
+ 1,
+ "should moderate one message"
+ );
+ assert.strictEqual(
+ messageIDs[0],
+ 100,
+ "should moderate message with ID 100"
+ );
+ assert.strictEqual(
+ decision,
+ 'reject',
+ "should reject the message"
+ );
+ assert.strictEqual(
+ kwargs.title,
+ "Message Rejected",
+ "should have correct reject message title"
+ );
+ assert.strictEqual(
+ kwargs.comment,
+ "Your message was rejected by moderator.",
+ "should have correct reject message body / comment"
+ );
+ }
+ return this._super(...arguments);
+ },
+ });
+
+ // 1. go to moderation box
+ const moderationBox = document.querySelector(`
+ .o_DiscussSidebar_item[data-thread-local-id="${
+ this.env.messaging.moderation.localId
+ }"]
+ `);
+ assert.ok(
+ moderationBox,
+ "should display the moderation box"
+ );
+
+ await afterNextRender(() => moderationBox.click());
+ const pendingMessage = document.querySelector(`
+ .o_Message[data-message-local-id="${
+ this.env.models['mail.message'].find(message => message.id === 100).localId
+ }"]
+ `);
+ assert.ok(
+ pendingMessage,
+ "should display the message to moderate"
+ );
+ const rejectButton = pendingMessage.querySelector(':scope .o_Message_moderationAction.o-reject');
+ assert.ok(
+ rejectButton,
+ "should display the reject button"
+ );
+
+ await afterNextRender(() => rejectButton.click());
+ const dialog = document.querySelector('.o_ModerationRejectDialog');
+ assert.ok(
+ dialog,
+ "a dialog should be prompt to the moderator on click reject"
+ );
+ assert.strictEqual(
+ dialog.querySelector('.modal-title').textContent,
+ // TODO FIXME, this should be a proper title "Send explanation to author"
+ // see https://github.com/odoo/owl/issues/670
+ "[object Object]",
+ "dialog should have correct title"
+ );
+
+ const messageTitle = dialog.querySelector(':scope .o_ModerationRejectDialog_title');
+ assert.ok(
+ messageTitle,
+ "should have a title for rejecting"
+ );
+ assert.hasAttrValue(
+ messageTitle,
+ 'placeholder',
+ "Subject",
+ "title for reject reason should have correct placeholder"
+ );
+ assert.strictEqual(
+ messageTitle.value,
+ "Message Rejected",
+ "title for reject reason should have correct default value"
+ );
+
+ const messageComment = dialog.querySelector(':scope .o_ModerationRejectDialog_comment');
+ assert.ok(
+ messageComment,
+ "should have a comment for rejecting"
+ );
+ assert.hasAttrValue(
+ messageComment,
+ 'placeholder',
+ "Mail Body",
+ "comment for reject reason should have correct placeholder"
+ );
+ assert.strictEqual(
+ messageComment.value,
+ "Your message was rejected by moderator.",
+ "comment for reject reason should have correct default text content"
+ );
+ const confirmReject = dialog.querySelector(':scope .o-reject');
+ assert.ok(
+ confirmReject,
+ "should have reject button"
+ );
+ assert.strictEqual(
+ confirmReject.textContent,
+ "Reject"
+ );
+
+ await afterNextRender(() => confirmReject.click());
+ assert.verifySteps(['moderate']);
+ assert.containsOnce(
+ document.body,
+ '.o_MessageList_emptyTitle',
+ "should now have no message displayed in moderation box"
+ );
+
+ // 2. go to channel 'general'
+ const channel = document.querySelector(`
+ .o_DiscussSidebar_item[data-thread-local-id="${
+ this.env.models['mail.thread'].find(thread =>
+ thread.id === 20 &&
+ thread.model === 'mail.channel'
+ ).localId
+ }"]
+ `);
+ assert.ok(
+ channel,
+ 'should display the general channel'
+ );
+
+ await afterNextRender(() => channel.click());
+ assert.containsNone(
+ document.body,
+ '.o_Message',
+ "should now have no message in channel"
+ );
+});
+
+QUnit.test('moderation: discard pending moderation message (reject without explanation)', async function (assert) {
+ assert.expect(16);
+
+ Object.assign(this.data.initMessaging, {
+ channel_slots: {
+ channel_channel: [{
+ id: 20,
+ channel_type: "channel",
+ name: "general",
+ moderation: true,
+ }],
+ },
+ is_moderator: true,
+ moderation_counter: 1,
+ moderation_channel_ids: [20],
+ });
+ this.data['mail.message'].records = [{
+ author_id: [2, "Someone"],
+ body: "<p>test</p>",
+ id: 100,
+ model: 'mail.channel',
+ moderation_status: 'pending_moderation',
+ need_moderation: true,
+ res_id: 20,
+ }];
+
+ await this.start({
+ async mockRPC(route, args) {
+ if (args.method === 'moderate') {
+ assert.step('moderate');
+ const messageIDs = args.args[0];
+ const decision = args.args[1];
+ assert.strictEqual(messageIDs.length, 1, "should moderate one message");
+ assert.strictEqual(messageIDs[0], 100, "should moderate message with ID 100");
+ assert.strictEqual(decision, 'discard', "should discard the message");
+ }
+ return this._super(...arguments);
+ },
+ });
+
+ // 1. go to moderation box
+ const moderationBox = document.querySelector(`
+ .o_DiscussSidebar_item[data-thread-local-id="${
+ this.env.messaging.moderation.localId
+ }"]
+ `);
+ assert.ok(
+ moderationBox,
+ "should display the moderation box"
+ );
+
+ await afterNextRender(() => moderationBox.click());
+ const pendingMessage = document.querySelector(`
+ .o_Message[data-message-local-id="${
+ this.env.models['mail.message'].find(message => message.id === 100).localId
+ }"]
+ `);
+ assert.ok(
+ pendingMessage,
+ "should display the message to moderate"
+ );
+
+ const discardButton = pendingMessage.querySelector(`
+ :scope .o_Message_moderationAction.o-discard
+ `);
+ assert.ok(
+ discardButton,
+ "should display the discard button"
+ );
+
+ await afterNextRender(() => discardButton.click());
+ const dialog = document.querySelector('.o_ModerationDiscardDialog');
+ assert.ok(
+ dialog,
+ "a dialog should be prompt to the moderator on click discard"
+ );
+ assert.strictEqual(
+ dialog.querySelector('.modal-title').textContent,
+ // TODO FIXME, this should be a proper title "Confirmation"
+ // see https://github.com/odoo/owl/issues/670
+ "[object Object]",
+ "dialog should have correct title"
+ );
+ assert.strictEqual(
+ dialog.textContent,
+ "[object Object]×You are going to discard 1 message.Do you confirm the action?DiscardCancel",
+ "should warn the user on discard action"
+ );
+
+ const confirmDiscard = dialog.querySelector(':scope .o-discard');
+ assert.ok(
+ confirmDiscard,
+ "should have discard button"
+ );
+ assert.strictEqual(
+ confirmDiscard.textContent,
+ "Discard"
+ );
+
+ await afterNextRender(() => confirmDiscard.click());
+ assert.verifySteps(['moderate']);
+ assert.containsOnce(
+ document.body,
+ '.o_MessageList_emptyTitle',
+ "should now have no message displayed in moderation box"
+ );
+
+ // 2. go to channel 'general'
+ const channel = document.querySelector(`
+ .o_DiscussSidebar_item[data-thread-local-id="${
+ this.env.models['mail.thread'].find(thread =>
+ thread.id === 20 &&
+ thread.model === 'mail.channel'
+ ).localId
+ }"]
+ `);
+ assert.ok(
+ channel,
+ "should display the general channel"
+ );
+
+ await afterNextRender(() => channel.click());
+ assert.containsNone(
+ document.body,
+ '.o_Message',
+ "should now have no message in channel"
+ );
+});
+
+QUnit.test('moderation: send message in moderated channel', async function (assert) {
+ assert.expect(4);
+
+ const self = this;
+ Object.assign(this.data.initMessaging, {
+ channel_slots: {
+ channel_channel: [{
+ id: 20,
+ channel_type: "channel",
+ name: "general",
+ moderation: true,
+ }],
+ },
+ });
+
+ await this.start({
+ async mockRPC(route, args) {
+ if (args.method === 'message_post') {
+ const message = {
+ id: 100,
+ author_id: [13, 'Someone'],
+ body: args.kwargs.body,
+ channel_ids: [20],
+ message_type: args.kwargs.message_type,
+ model: 'mail.channel',
+ moderation_status: 'pending_moderation',
+ res_id: 20,
+ };
+ const notificationData = {
+ type: 'author',
+ message: message,
+ };
+ const notification = [[false, 'res.partner', 13], notificationData];
+ self.widget.call('bus_service', 'trigger', 'notification', [notification]);
+
+ return message.id;
+ }
+ return this._super(...arguments);
+ },
+ });
+
+ // go to channel 'general'
+ const channel = document.querySelector(`
+ .o_DiscussSidebar_item[data-thread-local-id="${
+ this.env.models['mail.thread'].find(thread =>
+ thread.id === 20 &&
+ thread.model === 'mail.channel'
+ ).localId
+ }"]
+ `);
+ assert.ok(
+ channel,
+ "should display the general channel"
+ );
+
+ await afterNextRender(() => channel.click());
+ assert.containsNone(
+ document.body,
+ '.o_Message',
+ "should have no message in channel"
+ );
+
+ // post a message
+ await afterNextRender(() => {
+ const textInput = document.querySelector('.o_ComposerTextInput_textarea');
+ textInput.focus();
+ document.execCommand('insertText', false, "Some Text");
+ });
+ await afterNextRender(() => document.querySelector('.o_Composer_buttonSend').click());
+ const messagePending = document.querySelector(`
+ .o_Message[data-message-local-id="${
+ this.env.models['mail.message'].find(message => message.id === 100).localId
+ }"]
+ .o_Message_moderationPending
+ `);
+ assert.ok(
+ messagePending,
+ "should display the pending message with pending info"
+ );
+ assert.hasClass(
+ messagePending,
+ 'o-author',
+ "the message should be pending moderation as author"
+ );
+});
+
+QUnit.test('moderation: sent message accepted in moderated channel', async function (assert) {
+ assert.expect(5);
+
+ Object.assign(this.data.initMessaging, {
+ channel_slots: {
+ channel_channel: [{
+ id: 20,
+ channel_type: "channel",
+ name: "general",
+ moderation: true,
+ }],
+ },
+ });
+ this.data['mail.message'].records = [{
+ author_id: [13, "Someone"],
+ body: "<p>test</p>",
+ id: 100,
+ model: 'mail.channel',
+ moderation_status: 'pending_moderation',
+ need_moderation: true,
+ res_id: 20,
+ }];
+
+ await this.start();
+
+ const channel = document.querySelector(`
+ .o_DiscussSidebar_item[data-thread-local-id="${
+ this.env.models['mail.thread'].find(thread =>
+ thread.id === 20 &&
+ thread.model === 'mail.channel'
+ ).localId
+ }"]
+ `);
+ assert.ok(
+ channel,
+ "should display the general channel"
+ );
+
+ await afterNextRender(() => channel.click());
+ const messagePending = document.querySelector(`
+ .o_Message[data-message-local-id="${
+ this.env.models['mail.message'].find(message => message.id === 100).localId
+ }"]
+ .o_Message_moderationPending
+ `);
+ assert.ok(
+ messagePending,
+ "should display the pending message with pending info"
+ );
+ assert.hasClass(
+ messagePending,
+ 'o-author',
+ "the message should be pending moderation as author"
+ );
+
+ // simulate accepted message
+ await afterNextRender(() => {
+ const messageData = {
+ author_id: [13, "Someone"],
+ body: "<p>test</p>",
+ channel_ids: [20],
+ id: 100,
+ model: 'mail.channel',
+ moderation_status: 'accepted',
+ res_id: 20,
+ };
+ const notification = [[false, 'mail.channel', 20], messageData];
+ this.widget.call('bus_service', 'trigger', 'notification', [notification]);
+ });
+
+ // check message is accepted
+ const message = document.querySelector(`
+ .o_Message[data-message-local-id="${
+ this.env.models['mail.message'].find(message => message.id === 100).localId
+ }"]
+ `);
+ assert.ok(
+ message,
+ "should still display the message"
+ );
+ assert.containsNone(
+ message,
+ '.o_Message_moderationPending',
+ "the message should not be in pending moderation anymore"
+ );
+});
+
+QUnit.test('moderation: sent message rejected in moderated channel', async function (assert) {
+ assert.expect(4);
+
+ Object.assign(this.data.initMessaging, {
+ channel_slots: {
+ channel_channel: [{
+ id: 20,
+ channel_type: "channel",
+ name: "general",
+ moderation: true,
+ }],
+ },
+ });
+ this.data['mail.message'].records = [{
+ author_id: [13, "Someone"],
+ body: "<p>test</p>",
+ id: 100,
+ model: 'mail.channel',
+ moderation_status: 'pending_moderation',
+ need_moderation: true,
+ res_id: 20,
+ }];
+
+ await this.start();
+
+ const channel = document.querySelector(`
+ .o_DiscussSidebar_item[data-thread-local-id="${
+ this.env.models['mail.thread'].find(thread =>
+ thread.id === 20 &&
+ thread.model === 'mail.channel'
+ ).localId
+ }"]
+ `);
+ assert.ok(
+ channel,
+ "should display the general channel"
+ );
+
+ await afterNextRender(() => channel.click());
+ const messagePending = document.querySelector(`
+ .o_Message[data-message-local-id="${
+ this.env.models['mail.message'].find(message => message.id === 100).localId
+ }"]
+ .o_Message_moderationPending
+ `);
+ assert.ok(
+ messagePending,
+ "should display the pending message with pending info"
+ );
+ assert.hasClass(
+ messagePending,
+ 'o-author',
+ "the message should be pending moderation as author"
+ );
+
+ // simulate reject from moderator
+ await afterNextRender(() => {
+ const notifData = {
+ type: 'deletion',
+ message_ids: [100],
+ };
+ const notification = [[false, 'res.partner', 13], notifData];
+ this.widget.call('bus_service', 'trigger', 'notification', [notification]);
+ });
+ // check no message
+ assert.containsNone(
+ document.body,
+ '.o_Message',
+ "message should be removed from channel after reject"
+ );
+});
+
+});
+});
+});
+
+});
diff --git a/addons/mail/static/src/components/discuss_mobile_mailbox_selection/discuss_mobile_mailbox_selection.js b/addons/mail/static/src/components/discuss_mobile_mailbox_selection/discuss_mobile_mailbox_selection.js
new file mode 100644
index 00000000000..258d7770066
--- /dev/null
+++ b/addons/mail/static/src/components/discuss_mobile_mailbox_selection/discuss_mobile_mailbox_selection.js
@@ -0,0 +1,89 @@
+odoo.define('mail/static/src/components/discuss_mobile_mailbox_selection/discuss_mobile_mailbox_selection.js', function (require) {
+'use strict';
+
+const useStore = require('mail/static/src/component_hooks/use_store/use_store.js');
+
+const { Component } = owl;
+
+class DiscussMobileMailboxSelection extends Component {
+
+ /**
+ * @override
+ */
+ constructor(...args) {
+ super(...args);
+ useStore(props => {
+ return {
+ allOrderedAndPinnedMailboxes: this.orderedMailboxes.map(mailbox => mailbox.__state),
+ discussThread: this.env.messaging.discuss.thread
+ ? this.env.messaging.discuss.thread.__state
+ : undefined,
+ };
+ }, {
+ compareDepth: {
+ allOrderedAndPinnedMailboxes: 1,
+ },
+ });
+ }
+
+ //--------------------------------------------------------------------------
+ // Public
+ //--------------------------------------------------------------------------
+
+ /**
+ * @returns {mail.thread[]}
+ */
+ get orderedMailboxes() {
+ return this.env.models['mail.thread']
+ .all(thread => thread.isPinned && thread.model === 'mail.box')
+ .sort((mailbox1, mailbox2) => {
+ if (mailbox1 === this.env.messaging.inbox) {
+ return -1;
+ }
+ if (mailbox2 === this.env.messaging.inbox) {
+ return 1;
+ }
+ if (mailbox1 === this.env.messaging.starred) {
+ return -1;
+ }
+ if (mailbox2 === this.env.messaging.starred) {
+ return 1;
+ }
+ const mailbox1Name = mailbox1.displayName;
+ const mailbox2Name = mailbox2.displayName;
+ mailbox1Name < mailbox2Name ? -1 : 1;
+ });
+ }
+
+ /**
+ * @returns {mail.discuss}
+ */
+ get discuss() {
+ return this.env.messaging && this.env.messaging.discuss;
+ }
+
+ //--------------------------------------------------------------------------
+ // Handlers
+ //--------------------------------------------------------------------------
+
+ /**
+ * Called when clicking on a mailbox selection item.
+ *
+ * @private
+ * @param {MouseEvent} ev
+ */
+ _onClick(ev) {
+ const { mailbox } = ev.currentTarget.dataset;
+ this.discuss.threadViewer.update({ thread: [['link', mailbox]] });
+ }
+
+}
+
+Object.assign(DiscussMobileMailboxSelection, {
+ props: {},
+ template: 'mail.DiscussMobileMailboxSelection',
+});
+
+return DiscussMobileMailboxSelection;
+
+});
diff --git a/addons/mail/static/src/components/discuss_mobile_mailbox_selection/discuss_mobile_mailbox_selection.scss b/addons/mail/static/src/components/discuss_mobile_mailbox_selection/discuss_mobile_mailbox_selection.scss
new file mode 100644
index 00000000000..b620e2f1328
--- /dev/null
+++ b/addons/mail/static/src/components/discuss_mobile_mailbox_selection/discuss_mobile_mailbox_selection.scss
@@ -0,0 +1,26 @@
+// ------------------------------------------------------------------
+// Layout
+// ------------------------------------------------------------------
+
+.o_DiscussMobileMailboxSelection {
+ display: flex;
+ flex: 0 0 auto;
+}
+
+.o_DiscussMobileMailboxSelection_button {
+ flex: 1 1 0;
+ padding: 8px;
+ z-index: 1;
+
+ &.o-active {
+ z-index: 2;
+ }
+}
+
+// ------------------------------------------------------------------
+// Style
+// ------------------------------------------------------------------
+
+.o_DiscussMobileMailboxSelection_button {
+ box-shadow: 0 2px 4px gray('400');
+}
diff --git a/addons/mail/static/src/components/discuss_mobile_mailbox_selection/discuss_mobile_mailbox_selection.xml b/addons/mail/static/src/components/discuss_mobile_mailbox_selection/discuss_mobile_mailbox_selection.xml
new file mode 100644
index 00000000000..e996a203966
--- /dev/null
+++ b/addons/mail/static/src/components/discuss_mobile_mailbox_selection/discuss_mobile_mailbox_selection.xml
@@ -0,0 +1,20 @@
+<?xml version="1.0" encoding="UTF-8"?>
+<templates xml:space="preserve">
+
+ <t t-name="mail.DiscussMobileMailboxSelection" owl="1">
+ <div class="o_DiscussMobileMailboxSelection">
+ <t t-foreach="orderedMailboxes" t-as="mailbox" t-key="mailbox.localId">
+ <button class="o_DiscussMobileMailboxSelection_button btn"
+ t-att-class="{
+ 'btn-primary': discuss.thread === mailbox,
+ 'btn-secondary': discuss.thread !== mailbox,
+ 'o-active': discuss.thread === mailbox,
+ }" t-on-click="_onClick" t-att-data-mailbox-local-id="mailbox.localId" type="button"
+ >
+ <t t-esc="mailbox.name"/>
+ </button>
+ </t>
+ </div>
+ </t>
+
+</templates>
diff --git a/addons/mail/static/src/components/discuss_sidebar/discuss_sidebar.js b/addons/mail/static/src/components/discuss_sidebar/discuss_sidebar.js
new file mode 100644
index 00000000000..11737fa04dc
--- /dev/null
+++ b/addons/mail/static/src/components/discuss_sidebar/discuss_sidebar.js
@@ -0,0 +1,307 @@
+odoo.define('mail/static/src/components/discuss_sidebar/discuss_sidebar.js', function (require) {
+'use strict';
+
+const components = {
+ AutocompleteInput: require('mail/static/src/components/autocomplete_input/autocomplete_input.js'),
+ DiscussSidebarItem: require('mail/static/src/components/discuss_sidebar_item/discuss_sidebar_item.js'),
+};
+const useStore = require('mail/static/src/component_hooks/use_store/use_store.js');
+
+const { Component } = owl;
+const { useRef } = owl.hooks;
+
+class DiscussSidebar extends Component {
+
+ /**
+ * @override
+ */
+ constructor(...args) {
+ super(...args);
+ useStore(
+ (...args) => this._useStoreSelector(...args),
+ { compareDepth: () => this._useStoreCompareDepth() }
+ );
+
+ /**
+ * Reference of the quick search input. Useful to filter channels and
+ * chats based on this input content.
+ */
+ this._quickSearchInputRef = useRef('quickSearchInput');
+
+ // bind since passed as props
+ this._onAddChannelAutocompleteSelect = this._onAddChannelAutocompleteSelect.bind(this);
+ this._onAddChannelAutocompleteSource = this._onAddChannelAutocompleteSource.bind(this);
+ this._onAddChatAutocompleteSelect = this._onAddChatAutocompleteSelect.bind(this);
+ this._onAddChatAutocompleteSource = this._onAddChatAutocompleteSource.bind(this);
+ }
+
+ mounted() {
+ this._update();
+ }
+
+ patched() {
+ this._update();
+ }
+
+ //--------------------------------------------------------------------------
+ // Public
+ //--------------------------------------------------------------------------
+
+ /**
+ * @returns {mail.discuss}
+ */
+ get discuss() {
+ return this.env.messaging && this.env.messaging.discuss;
+ }
+
+ /**
+ * @returns {mail.thread[]}
+ */
+ get orderedMailboxes() {
+ return this.env.models['mail.thread']
+ .all(thread => thread.isPinned && thread.model === 'mail.box')
+ .sort((mailbox1, mailbox2) => {
+ if (mailbox1 === this.env.messaging.inbox) {
+ return -1;
+ }
+ if (mailbox2 === this.env.messaging.inbox) {
+ return 1;
+ }
+ if (mailbox1 === this.env.messaging.starred) {
+ return -1;
+ }
+ if (mailbox2 === this.env.messaging.starred) {
+ return 1;
+ }
+ const mailbox1Name = mailbox1.displayName;
+ const mailbox2Name = mailbox2.displayName;
+ mailbox1Name < mailbox2Name ? -1 : 1;
+ });
+ }
+
+ /**
+ * Return the list of chats that match the quick search value input.
+ *
+ * @returns {mail.thread[]}
+ */
+ get quickSearchPinnedAndOrderedChats() {
+ const allOrderedAndPinnedChats = this.env.models['mail.thread']
+ .all(thread =>
+ thread.channel_type === 'chat' &&
+ thread.isPinned &&
+ thread.model === 'mail.channel'
+ )
+ .sort((c1, c2) => c1.displayName < c2.displayName ? -1 : 1);
+ if (!this.discuss.sidebarQuickSearchValue) {
+ return allOrderedAndPinnedChats;
+ }
+ const qsVal = this.discuss.sidebarQuickSearchValue.toLowerCase();
+ return allOrderedAndPinnedChats.filter(chat => {
+ const nameVal = chat.displayName.toLowerCase();
+ return nameVal.includes(qsVal);
+ });
+ }
+
+ /**
+ * Return the list of channels that match the quick search value input.
+ *
+ * @returns {mail.thread[]}
+ */
+ get quickSearchOrderedAndPinnedMultiUserChannels() {
+ const allOrderedAndPinnedMultiUserChannels = this.env.models['mail.thread']
+ .all(thread =>
+ thread.channel_type === 'channel' &&
+ thread.isPinned &&
+ thread.model === 'mail.channel'
+ )
+ .sort((c1, c2) => c1.displayName < c2.displayName ? -1 : 1);
+ if (!this.discuss.sidebarQuickSearchValue) {
+ return allOrderedAndPinnedMultiUserChannels;
+ }
+ const qsVal = this.discuss.sidebarQuickSearchValue.toLowerCase();
+ return allOrderedAndPinnedMultiUserChannels.filter(channel => {
+ const nameVal = channel.displayName.toLowerCase();
+ return nameVal.includes(qsVal);
+ });
+ }
+
+ //--------------------------------------------------------------------------
+ // Private
+ //--------------------------------------------------------------------------
+
+ /**
+ * @private
+ */
+ _update() {
+ if (this._quickSearchInputRef.el) {
+ this._quickSearchInputRef.el.value = this.discuss.sidebarQuickSearchValue;
+ }
+ }
+
+ /**
+ * @private
+ * @returns {Object}
+ */
+ _useStoreCompareDepth() {
+ return {
+ allOrderedAndPinnedChats: 1,
+ allOrderedAndPinnedMailboxes: 1,
+ allOrderedAndPinnedMultiUserChannels: 1,
+ allPinnedChannelAmount: 1,
+ };
+ }
+
+ /**
+ * @private
+ * @param {Object} props
+ * @returns {Object}
+ */
+ _useStoreSelector(props) {
+ return {
+ allOrderedAndPinnedChats:
+ this.env.models['mail.thread']
+ .all(thread =>
+ thread.channel_type === 'chat' &&
+ thread.isPinned &&
+ thread.model === 'mail.channel'
+ )
+ .sort((c1, c2) => c1.displayName < c2.displayName ? -1 : 1)
+ .map(chat => chat.__state),
+ allOrderedAndPinnedMailboxes: this.orderedMailboxes.map(mailbox => mailbox.__state),
+ allOrderedAndPinnedMultiUserChannels:
+ this.env.models['mail.thread']
+ .all(thread =>
+ thread.channel_type === 'channel' &&
+ thread.isPinned &&
+ thread.model === 'mail.channel'
+ )
+ .sort((c1, c2) => c1.displayName < c2.displayName ? -1 : 1)
+ .map(channel => channel.__state),
+ allPinnedChannelAmount:
+ this.env.models['mail.thread']
+ .all(thread =>
+ thread.isPinned &&
+ thread.model === 'mail.channel'
+ ).length,
+ discuss: this.env.messaging.discuss.__state,
+ };
+ }
+
+ //--------------------------------------------------------------------------
+ // Handlers
+ //--------------------------------------------------------------------------
+
+ /**
+ * @private
+ * @param {Event} ev
+ * @param {Object} ui
+ * @param {Object} ui.item
+ * @param {integer} ui.item.id
+ */
+ _onAddChannelAutocompleteSelect(ev, ui) {
+ this.discuss.handleAddChannelAutocompleteSelect(ev, ui);
+ }
+
+ /**
+ * @private
+ * @param {Object} req
+ * @param {string} req.term
+ * @param {function} res
+ */
+ _onAddChannelAutocompleteSource(req, res) {
+ this.discuss.handleAddChannelAutocompleteSource(req, res);
+ }
+
+ /**
+ * @private
+ * @param {Event} ev
+ * @param {Object} ui
+ * @param {Object} ui.item
+ * @param {integer} ui.item.id
+ */
+ _onAddChatAutocompleteSelect(ev, ui) {
+ this.discuss.handleAddChatAutocompleteSelect(ev, ui);
+ }
+
+ /**
+ * @private
+ * @param {Object} req
+ * @param {string} req.term
+ * @param {function} res
+ */
+ _onAddChatAutocompleteSource(req, res) {
+ this.discuss.handleAddChatAutocompleteSource(req, res);
+ }
+
+ /**
+ * Called when clicking on add channel icon.
+ *
+ * @private
+ * @param {MouseEvent} ev
+ */
+ _onClickChannelAdd(ev) {
+ ev.stopPropagation();
+ this.discuss.update({ isAddingChannel: true });
+ }
+
+ /**
+ * Called when clicking on channel title.
+ *
+ * @private
+ * @param {MouseEvent} ev
+ */
+ _onClickChannelTitle(ev) {
+ ev.stopPropagation();
+ return this.env.bus.trigger('do-action', {
+ action: {
+ name: this.env._t("Public Channels"),
+ type: 'ir.actions.act_window',
+ res_model: 'mail.channel',
+ views: [[false, 'kanban'], [false, 'form']],
+ domain: [['public', '!=', 'private']]
+ },
+ });
+ }
+
+ /**
+ * Called when clicking on add chat icon.
+ *
+ * @private
+ * @param {MouseEvent} ev
+ */
+ _onClickChatAdd(ev) {
+ ev.stopPropagation();
+ this.discuss.update({ isAddingChat: true });
+ }
+
+ /**
+ * @private
+ * @param {CustomEvent} ev
+ */
+ _onHideAddingItem(ev) {
+ ev.stopPropagation();
+ this.discuss.clearIsAddingItem();
+ }
+
+ /**
+ * @private
+ * @param {KeyboardEvent} ev
+ */
+ _onInputQuickSearch(ev) {
+ ev.stopPropagation();
+ this.discuss.update({
+ sidebarQuickSearchValue: this._quickSearchInputRef.el.value,
+ });
+ }
+
+}
+
+Object.assign(DiscussSidebar, {
+ components,
+ props: {},
+ template: 'mail.DiscussSidebar',
+});
+
+return DiscussSidebar;
+
+});
diff --git a/addons/mail/static/src/components/discuss_sidebar/discuss_sidebar.scss b/addons/mail/static/src/components/discuss_sidebar/discuss_sidebar.scss
new file mode 100644
index 00000000000..3e49cddf7dd
--- /dev/null
+++ b/addons/mail/static/src/components/discuss_sidebar/discuss_sidebar.scss
@@ -0,0 +1,110 @@
+// ------------------------------------------------------------------
+// Layout
+// ------------------------------------------------------------------
+
+.o_DiscussSidebar {
+ display: flex;
+ flex-flow: column;
+ width: $o-mail-chat-sidebar-width;
+
+ @include media-breakpoint-up(xl) {
+ width: $o-mail-chat-sidebar-width + 50px;
+ }
+}
+
+.o_DiscussSidebar_group {
+ display: flex;
+ flex-flow: column;
+ flex: 0 0 auto;
+}
+
+.o_DiscussSidebar_groupHeader {
+ display: flex;
+ align-items: center;
+ margin: 5px 0;
+}
+
+.o_DiscussSidebar_groupHeaderItem {
+ margin-left: 3px;
+ margin-right: 3px;
+
+ &:first-child {
+ margin-left: $o-mail-discuss-sidebar-active-indicator-margin-right;
+ }
+
+ &:last-child {
+ margin-right: $o-mail-discuss-sidebar-scrollbar-width;
+ }
+}
+
+.o_DiscussSidebar_itemNew {
+ display: flex;
+ justify-content: center;
+}
+
+.o_DiscussSidebar_itemNewInput {
+ flex: 1 1 auto;
+ margin-left: $o-mail-discuss-sidebar-active-indicator-margin-right + 3px;
+ margin-right: $o-mail-discuss-sidebar-scrollbar-width;
+}
+
+.o_DiscussSidebar_quickSearch {
+ border-radius: 10px;
+ margin: 0 $o-mail-discuss-sidebar-scrollbar-width 10px;
+ padding: 3px 10px;
+}
+
+.o_DiscussSidebar_separator {
+ width: 100%;
+}
+
+// ------------------------------------------------------------------
+// Style
+// ------------------------------------------------------------------
+
+.o_DiscussSidebar {
+ background-color: gray('900');
+ color: gray('300');
+}
+
+.o_DiscussSidebar_groupHeader {
+ font-size: $font-size-sm;
+ text-transform: uppercase;
+ font-weight: bolder;
+}
+
+.o_DiscussSidebar_groupHeaderItemAdd {
+ cursor: pointer;
+
+ &:not(:hover) {
+ color: gray('600');
+ }
+}
+
+.o_DiscussSidebar_groupTitle {
+
+ &:not(.o-clickable) {
+ color: gray('600');
+ }
+
+ &.o-clickable {
+ cursor: pointer;
+
+ &:not(:hover) {
+ color: gray('600');
+ }
+ }
+}
+
+.o_DiscussSidebar_itemNewInput {
+ outline: none;
+}
+
+.o_DiscussSidebar_quickSearch {
+ border: none;
+ outline: none;
+}
+
+.o_DiscussSidebar_separator {
+ background-color: gray('600');
+}
diff --git a/addons/mail/static/src/components/discuss_sidebar/discuss_sidebar.xml b/addons/mail/static/src/components/discuss_sidebar/discuss_sidebar.xml
new file mode 100644
index 00000000000..ac4ac02bd46
--- /dev/null
+++ b/addons/mail/static/src/components/discuss_sidebar/discuss_sidebar.xml
@@ -0,0 +1,80 @@
+<?xml version="1.0" encoding="UTF-8"?>
+<templates xml:space="preserve">
+
+ <t t-name="mail.DiscussSidebar" owl="1">
+ <div name="root" class="o_DiscussSidebar">
+ <div class="o_DiscussSidebar_group o_DiscussSidebar_groupMailbox">
+ <t t-foreach="orderedMailboxes" t-as="mailbox" t-key="mailbox.localId">
+ <DiscussSidebarItem
+ class="o_DiscussSidebar_item"
+ threadLocalId="mailbox.localId"
+ />
+ </t>
+ </div>
+ <hr class="o_DiscussSidebar_separator"/>
+ <t t-if="env.models['mail.thread'].all(thread => thread.isPinned and thread.model === 'mail.channel').length > 19">
+ <input class="o_DiscussSidebar_quickSearch" t-on-input="_onInputQuickSearch" placeholder="Quick search..." t-ref="quickSearchInput" t-esc="discuss.sidebarQuickSearchValue"/>
+ </t>
+ <div class="o_DiscussSidebar_group o_DiscussSidebar_groupChannel">
+ <div class="o_DiscussSidebar_groupHeader">
+ <div class="o_DiscussSidebar_groupHeaderItem o_DiscussSidebar_groupTitle o-clickable" t-on-click="_onClickChannelTitle">
+ Channels
+ </div>
+ <div class="o-autogrow"/>
+ <div class="o_DiscussSidebar_groupHeaderItem o_DiscussSidebar_groupHeaderItemAdd fa fa-plus" t-on-click="_onClickChannelAdd" title="Add or join a channel"/>
+ </div>
+ <div class="o_DiscussSidebar_list">
+ <t t-if="discuss.isAddingChannel">
+ <div class="o_DiscussSidebar_item o_DiscussSidebar_itemNew">
+ <AutocompleteInput
+ class="o_DiscussSidebar_itemNewInput"
+ isFocusOnMount="true"
+ isHtml="true"
+ placeholder="'Find or create a channel...'"
+ select="_onAddChannelAutocompleteSelect"
+ source="_onAddChannelAutocompleteSource"
+ t-on-o-hide="_onHideAddingItem"
+ />
+ </div>
+ </t>
+ <t t-foreach="quickSearchOrderedAndPinnedMultiUserChannels" t-as="channel" t-key="channel.localId">
+ <DiscussSidebarItem
+ class="o_DiscussSidebar_item"
+ threadLocalId="channel.localId"
+ />
+ </t>
+ </div>
+ </div>
+ <div class="o_DiscussSidebar_group o_DiscussSidebar_groupChat">
+ <div class="o_DiscussSidebar_groupHeader">
+ <div class="o_DiscussSidebar_groupHeaderItem o_DiscussSidebar_groupTitle">
+ Direct Messages
+ </div>
+ <div class="o-autogrow"/>
+ <div class="o_DiscussSidebar_groupHeaderItem o_DiscussSidebar_groupHeaderItemAdd fa fa-plus" t-on-click="_onClickChatAdd" title="Start a conversation"/>
+ </div>
+ <div class="o_DiscussSidebar_list">
+ <t t-if="discuss.isAddingChat">
+ <div class="o_DiscussSidebar_item o_DiscussSidebar_itemNew">
+ <AutocompleteInput
+ class="o_DiscussSidebar_itemNewInput"
+ isFocusOnMount="true"
+ placeholder="'Find or start a conversation...'"
+ select="_onAddChatAutocompleteSelect"
+ source="_onAddChatAutocompleteSource"
+ t-on-o-hide="_onHideAddingItem"
+ />
+ </div>
+ </t>
+ <t t-foreach="quickSearchPinnedAndOrderedChats" t-as="chat" t-key="chat.localId">
+ <DiscussSidebarItem
+ class="o_DiscussSidebar_item"
+ threadLocalId="chat.localId"
+ />
+ </t>
+ </div>
+ </div>
+ </div>
+ </t>
+
+</templates>
diff --git a/addons/mail/static/src/components/discuss_sidebar_item/discuss_sidebar_item.js b/addons/mail/static/src/components/discuss_sidebar_item/discuss_sidebar_item.js
new file mode 100644
index 00000000000..db22f9f2116
--- /dev/null
+++ b/addons/mail/static/src/components/discuss_sidebar_item/discuss_sidebar_item.js
@@ -0,0 +1,203 @@
+odoo.define('mail/static/src/components/discuss_sidebar_item/discuss_sidebar_item.js', function (require) {
+'use strict';
+
+const components = {
+ EditableText: require('mail/static/src/components/editable_text/editable_text.js'),
+ ThreadIcon: require('mail/static/src/components/thread_icon/thread_icon.js'),
+};
+const useStore = require('mail/static/src/component_hooks/use_store/use_store.js');
+
+const Dialog = require('web.Dialog');
+
+const { Component } = owl;
+
+class DiscussSidebarItem extends Component {
+
+ /**
+ * @override
+ */
+ constructor(...args) {
+ super(...args);
+ useStore(props => {
+ const thread = this.env.models['mail.thread'].get(props.threadLocalId);
+ const correspondent = thread ? thread.correspondent : undefined;
+ return {
+ correspondent: correspondent ? correspondent.__state : undefined,
+ discuss: this.env.messaging.discuss.__state,
+ thread: thread ? thread.__state : undefined,
+ };
+ });
+ }
+
+ //--------------------------------------------------------------------------
+ // Public
+ //--------------------------------------------------------------------------
+
+ /**
+ * Get the counter of this discuss item, which is based on the thread type.
+ *
+ * @returns {integer}
+ */
+ get counter() {
+ if (this.thread.model === 'mail.box') {
+ return this.thread.counter;
+ } else if (this.thread.channel_type === 'channel') {
+ return this.thread.message_needaction_counter;
+ } else if (this.thread.channel_type === 'chat') {
+ return this.thread.message_unread_counter;
+ }
+ return 0;
+ }
+
+ /**
+ * @returns {mail.discuss}
+ */
+ get discuss() {
+ return this.env.messaging && this.env.messaging.discuss;
+ }
+
+ /**
+ * @returns {boolean}
+ */
+ hasUnpin() {
+ return this.thread.channel_type === 'chat';
+ }
+
+ /**
+ * @returns {mail.thread}
+ */
+ get thread() {
+ return this.env.models['mail.thread'].get(this.props.threadLocalId);
+ }
+
+ //--------------------------------------------------------------------------
+ // Private
+ //--------------------------------------------------------------------------
+
+ /**
+ * @private
+ * @returns {Promise}
+ */
+ _askAdminConfirmation() {
+ return new Promise(resolve => {
+ Dialog.confirm(this,
+ this.env._t("You are the administrator of this channel. Are you sure you want to leave?"),
+ {
+ buttons: [
+ {
+ text: this.env._t("Leave"),
+ classes: 'btn-primary',
+ close: true,
+ click: resolve
+ },
+ {
+ text: this.env._t("Discard"),
+ close: true
+ }
+ ]
+ }
+ );
+ });
+ }
+
+ //--------------------------------------------------------------------------
+ // Handlers
+ //--------------------------------------------------------------------------
+
+ /**
+ * @private
+ * @param {Event} ev
+ */
+ _onCancelRenaming(ev) {
+ this.discuss.cancelThreadRenaming(this.thread);
+ }
+
+ /**
+ * @private
+ * @param {MouseEvent} ev
+ */
+ _onClick(ev) {
+ this.discuss.threadViewer.update({ thread: [['link', this.thread]] });
+ }
+
+ /**
+ * Stop propagation to prevent selecting this item.
+ *
+ * @private
+ * @param {CustomEvent} ev
+ */
+ _onClickedEditableText(ev) {
+ ev.stopPropagation();
+ }
+
+ /**
+ * @private
+ * @param {MouseEvent} ev
+ */
+ async _onClickLeave(ev) {
+ ev.stopPropagation();
+ if (this.thread.creator === this.env.messaging.currentUser) {
+ await this._askAdminConfirmation();
+ }
+ this.thread.unsubscribe();
+ }
+
+ /**
+ * @private
+ * @param {MouseEvent} ev
+ */
+ _onClickRename(ev) {
+ ev.stopPropagation();
+ this.discuss.setThreadRenaming(this.thread);
+ }
+
+ /**
+ * @private
+ * @param {MouseEvent} ev
+ */
+ _onClickSettings(ev) {
+ ev.stopPropagation();
+ return this.env.bus.trigger('do-action', {
+ action: {
+ type: 'ir.actions.act_window',
+ res_model: this.thread.model,
+ res_id: this.thread.id,
+ views: [[false, 'form']],
+ target: 'current'
+ },
+ });
+ }
+
+ /**
+ * @private
+ * @param {MouseEvent} ev
+ */
+ _onClickUnpin(ev) {
+ ev.stopPropagation();
+ this.thread.unsubscribe();
+ }
+
+ /**
+ * @private
+ * @param {CustomEvent} ev
+ * @param {Object} ev.detail
+ * @param {string} ev.detail.newName
+ */
+ _onValidateEditableText(ev) {
+ ev.stopPropagation();
+ this.discuss.renameThread(this.thread, ev.detail.newName);
+ }
+
+}
+
+Object.assign(DiscussSidebarItem, {
+ components,
+ props: {
+ threadLocalId: String,
+ },
+ template: 'mail.DiscussSidebarItem',
+});
+
+return DiscussSidebarItem;
+
+});
diff --git a/addons/mail/static/src/components/discuss_sidebar_item/discuss_sidebar_item.scss b/addons/mail/static/src/components/discuss_sidebar_item/discuss_sidebar_item.scss
new file mode 100644
index 00000000000..649297585f9
--- /dev/null
+++ b/addons/mail/static/src/components/discuss_sidebar_item/discuss_sidebar_item.scss
@@ -0,0 +1,107 @@
+// ------------------------------------------------------------------
+// Layout
+// ------------------------------------------------------------------
+
+.o_DiscussSidebarItem {
+ display: flex;
+ align-items: center;
+ padding: map-get($spacers, 1) 0;
+
+ &:hover .o_DiscussSidebarItem_commands {
+ display: flex;
+ }
+}
+
+.o_DiscussSidebarItem_activeIndicator {
+ width: $o-mail-discuss-sidebar-active-indicator-width;
+ align-self: stretch;
+}
+
+.o_DiscussSidebarItem_command {
+ margin-left: 3px;
+ margin-right: 3px;
+
+ &:first-child {
+ margin-left: 0px;
+ }
+
+ &:last-child {
+ margin-right: 0px;
+ }
+}
+
+.o_DiscussSidebarItem_commands {
+ display: none;
+}
+
+.o_DiscussSidebarItem_item {
+ margin-left: 3px;
+ margin-right: 3px;
+
+ &:first-child {
+ margin-left: 0px;
+ margin-right: $o-mail-discuss-sidebar-active-indicator-margin-right;
+ }
+
+ &:last-child {
+ margin-right: $o-mail-discuss-sidebar-scrollbar-width;
+ }
+}
+
+.o_DiscussSidebarItem_name {
+ overflow: hidden;
+ text-overflow: ellipsis;
+
+ &.o-editable {
+ margin-left: $o-mail-discuss-sidebar-active-indicator-width + $o-mail-discuss-sidebar-active-indicator-margin-right;
+ margin-right: $o-mail-discuss-sidebar-scrollbar-width;
+ }
+}
+
+.o_DiscussSidebarItem_nameInput {
+ width: 100%;
+}
+
+// ------------------------------------------------------------------
+// Style
+// ------------------------------------------------------------------
+
+.o_DiscussSidebarItem {
+ cursor: pointer;
+
+ &:hover {
+ background-color: darken(gray('900'), 5%);
+ }
+
+ &.o-starred-box {
+ .o_DiscussSidebarItem_counter {
+ border-color: gray('600');
+ background-color: gray('600');
+ }
+ }
+}
+
+.o_DiscussSidebarItem_activeIndicator.o-item-active {
+ background-color: $o-brand-primary;
+}
+
+.o_DiscussSidebarItem_command:not(:hover) {
+ color: gray('600');
+}
+
+.o_DiscussSidebarItem_counter {
+ background-color: $o-brand-primary;
+}
+
+.o_DiscussSidebarItem_name {
+
+ &.o-item-unread {
+ font-weight: bold;
+ }
+}
+
+.o_DiscussSidebarItem_nameInput {
+ outline: none;
+ border: none;
+ border-radius: 2px;
+}
diff --git a/addons/mail/static/src/components/discuss_sidebar_item/discuss_sidebar_item.xml b/addons/mail/static/src/components/discuss_sidebar_item/discuss_sidebar_item.xml
new file mode 100644
index 00000000000..2db165b4c34
--- /dev/null
+++ b/addons/mail/static/src/components/discuss_sidebar_item/discuss_sidebar_item.xml
@@ -0,0 +1,60 @@
+<?xml version="1.0" encoding="UTF-8"?>
+<templates xml:space="preserve">
+
+ <t t-name="mail.DiscussSidebarItem" owl="1">
+ <div class="o_DiscussSidebarItem"
+ t-att-class="{
+ 'o-active': thread and discuss.thread === thread,
+ 'o-starred-box': thread and thread === env.messaging.starred,
+ 'o-unread': thread and thread.message_unread_counter > 0,
+ }" t-on-click="_onClick" t-att-data-thread-local-id="thread ? thread.localId : undefined" t-att-data-thread-name="thread ? thread.displayName : undefined"
+ >
+ <t t-if="thread">
+ <div class=" o_DiscussSidebarItem_activeIndicator o_DiscussSidebarItem_item" t-att-class="{ 'o-item-active': discuss.thread === thread }"/>
+ <ThreadIcon class="o_DiscussSidebarItem_item" threadLocalId="thread.localId"/>
+ <t t-if="thread.channel_type === 'chat' and discuss.renamingThreads.includes(thread)">
+ <div class="o_DiscussSidebarItem_item o_DiscussSidebarItem_name o-editable">
+ <EditableText
+ class="o_DiscussSidebarItem_nameInput"
+ placeholder="thread.correspondent.name"
+ value="thread.displayName"
+ t-on-o-cancel="_onCancelRenaming"
+ t-on-o-clicked="_onClickedEditableText"
+ t-on-o-validate="_onValidateEditableText"
+ />
+ </div>
+ </t>
+ <t t-else="">
+ <div class="o_DiscussSidebarItem_item o_DiscussSidebarItem_name" t-att-class="{ 'o-item-unread': thread.message_unread_counter > 0 }">
+ <t t-esc="thread.displayName"/>
+ </div>
+ </t>
+ <div class="o-autogrow o_DiscussSidebarItem_item"/>
+ <t t-if="thread.model !== 'mail.box'">
+ <div class="o_DiscussSidebarItem_commands o_DiscussSidebarItem_item">
+ <t t-if="thread.channel_type === 'channel'">
+ <div class="fa fa-cog o_DiscussSidebarItem_command o_DiscussSidebarItem_commandSettings" t-on-click="_onClickSettings" title="Channel settings" role="img"/>
+ <t t-if="!thread.message_needaction_counter and !thread.group_based_subscription">
+ <div class="o_DiscussSidebarItem_command o_DiscussSidebarItem_commandLeave fa fa-times" t-on-click="_onClickLeave" title="Leave this channel" role="img"/>
+ </t>
+ </t>
+ <t t-if="thread.channel_type === 'chat'">
+ <div class="o_DiscussSidebarItem_command o_DiscussSidebarItem_commandRename fa fa-cog" t-on-click="_onClickRename" title="Rename conversation" role="img"/>
+ </t>
+ <t t-if="hasUnpin()">
+ <t t-if="!thread.message_unread_counter">
+ <div class="fa fa-times o_DiscussSidebarItem_command o_DiscussSidebarItem_commandUnpin" t-on-click="_onClickUnpin" title="Unpin conversation" role="img"/>
+ </t>
+ </t>
+ </div>
+ </t>
+ <t t-if="counter > 0">
+ <div class="o_DiscussSidebarItem_counter o_DiscussSidebarItem_item badge badge-pill">
+ <t t-esc="counter"/>
+ </div>
+ </t>
+ </t>
+ </div>
+ </t>
+
+</templates>
diff --git a/addons/mail/static/src/components/drop_zone/drop_zone.js b/addons/mail/static/src/components/drop_zone/drop_zone.js
new file mode 100644
index 00000000000..2eb4dc8ef74
--- /dev/null
+++ b/addons/mail/static/src/components/drop_zone/drop_zone.js
@@ -0,0 +1,136 @@
+odoo.define('mail/static/src/components/drop_zone/drop_zone.js', function (require) {
+'use strict';
+
+const { Component, useState } = owl;
+
+class DropZone extends Component {
+
+ /**
+ * @override
+ */
+ constructor(...args) {
+ super(...args);
+ this.state = useState({
+ /**
+ * Determine whether the user is dragging files over the dropzone.
+ * Useful to provide visual feedback in that case.
+ */
+ isDraggingInside: false,
+ });
+ /**
+ * Counts how many drag enter/leave happened on self and children. This
+ * ensures the drop effect stays active when dragging over a child.
+ */
+ this._dragCount = 0;
+ }
+
+ //--------------------------------------------------------------------------
+ // Public
+ //--------------------------------------------------------------------------
+
+ /**
+ * Returns whether the given node is self or a children of self.
+ *
+ * @param {Node} node
+ * @returns {boolean}
+ */
+ contains(node) {
+ return this.el.contains(node);
+ }
+
+ //--------------------------------------------------------------------------
+ // Private
+ //--------------------------------------------------------------------------
+
+ /**
+ * Making sure that dragging content is external files.
+ * Ignoring other content dragging like text.
+ *
+ * @private
+ * @param {DataTransfer} dataTransfer
+ * @returns {boolean}
+ */
+ _isDragSourceExternalFile(dataTransfer) {
+ const dragDataType = dataTransfer.types;
+ if (dragDataType.constructor === window.DOMStringList) {
+ return dragDataType.contains('Files');
+ }
+ if (dragDataType.constructor === Array) {
+ return dragDataType.includes('Files');
+ }
+ return false;
+ }
+
+ //--------------------------------------------------------------------------
+ // Handlers
+ //--------------------------------------------------------------------------
+
+ /**
+ * Shows a visual drop effect when dragging inside the dropzone.
+ *
+ * @private
+ * @param {DragEvent} ev
+ */
+ _onDragenter(ev) {
+ ev.preventDefault();
+ if (this._dragCount === 0) {
+ this.state.isDraggingInside = true;
+ }
+ this._dragCount++;
+ }
+
+ /**
+ * Hides the visual drop effect when dragging outside the dropzone.
+ *
+ * @private
+ * @param {DragEvent} ev
+ */
+ _onDragleave(ev) {
+ this._dragCount--;
+ if (this._dragCount === 0) {
+ this.state.isDraggingInside = false;
+ }
+ }
+
+ /**
+ * Prevents default (from the template) in order to receive the drop event.
+ * The drop effect cursor works only when set on dragover.
+ *
+ * @private
+ * @param {DragEvent} ev
+ */
+ _onDragover(ev) {
+ ev.preventDefault();
+ ev.dataTransfer.dropEffect = 'copy';
+ }
+
+ /**
+ * Triggers the `o-dropzone-files-dropped` event when new files are dropped
+ * on the dropzone, and then removes the visual drop effect.
+ *
+ * The parents should handle this event to process the files as they wish,
+ * such as uploading them.
+ *
+ * @private
+ * @param {DragEvent} ev
+ */
+ _onDrop(ev) {
+ ev.preventDefault();
+ if (this._isDragSourceExternalFile(ev.dataTransfer)) {
+ this.trigger('o-dropzone-files-dropped', {
+ files: ev.dataTransfer.files,
+ });
+ }
+ this.state.isDraggingInside = false;
+ }
+
+}
+
+Object.assign(DropZone, {
+ props: {},
+ template: 'mail.DropZone',
+});
+
+return DropZone;
+
+});
diff --git a/addons/mail/static/src/components/drop_zone/drop_zone.scss b/addons/mail/static/src/components/drop_zone/drop_zone.scss
new file mode 100644
index 00000000000..e1a901cb12e
--- /dev/null
+++ b/addons/mail/static/src/components/drop_zone/drop_zone.scss
@@ -0,0 +1,29 @@
+// ------------------------------------------------------------------
+// Layout
+// ------------------------------------------------------------------
+
+.o_DropZone {
+ display: flex;
+ position: absolute;
+ left: 0;
+ bottom: 0;
+ height: 100%;
+ width: 100%;
+ z-index: 1;
+ align-items: center;
+ justify-content: center;
+}
+
+// ------------------------------------------------------------------
+// Style
+// ------------------------------------------------------------------
+
+.o_DropZone {
+ color: $o-enterprise-primary-color;
+ background: rgba(255, 255, 255, 0.9);
+ border: 2px dashed $o-enterprise-primary-color;
+
+ &.o-dragging-inside {
+ border-width: 5px;
+ }
+}
diff --git a/addons/mail/static/src/components/drop_zone/drop_zone.xml b/addons/mail/static/src/components/drop_zone/drop_zone.xml
new file mode 100644
index 00000000000..b3db940fde9
--- /dev/null
+++ b/addons/mail/static/src/components/drop_zone/drop_zone.xml
@@ -0,0 +1,12 @@
+<?xml version="1.0" encoding="UTF-8"?>
+<templates xml:space="preserve">
+
+ <t t-name="mail.DropZone" owl="1">
+ <div class="o_DropZone" t-att-class="{ 'o-dragging-inside': state.isDraggingInside }" t-on-dragenter="_onDragenter" t-on-dragleave="_onDragleave" t-on-dragover="_onDragover" t-on-drop="_onDrop">
+ <h4>
+ Drag Files Here <i class="fa fa-download"/>
+ </h4>
+ </div>
+ </t>
+
+</templates>
diff --git a/addons/mail/static/src/components/editable_text/editable_text.js b/addons/mail/static/src/components/editable_text/editable_text.js
new file mode 100644
index 00000000000..4a4b926741e
--- /dev/null
+++ b/addons/mail/static/src/components/editable_text/editable_text.js
@@ -0,0 +1,82 @@
+odoo.define('mail/static/src/components/editable_text/editable_text.js', function (require) {
+'use strict';
+
+const { Component } = owl;
+
+class EditableText extends Component {
+
+ mounted() {
+ this.el.focus();
+ this.el.setSelectionRange(0, (this.el.value && this.el.value.length) || 0);
+ }
+
+ willUnmount() {
+ this.trigger('o-cancel');
+ }
+
+ //--------------------------------------------------------------------------
+ // Handlers
+ //--------------------------------------------------------------------------
+
+ /**
+ * @private
+ * @param {MouseEvent} ev
+ */
+ _onBlur(ev) {
+ this.trigger('o-cancel');
+ }
+
+ /**
+ * @private
+ * @param {MouseEvent} ev
+ */
+ _onClick(ev) {
+ this.trigger('o-clicked');
+ }
+
+ /**
+ * @private
+ * @param {KeyboardEvent} ev
+ */
+ _onKeydown(ev) {
+ switch (ev.key) {
+ case 'Enter':
+ this._onKeydownEnter(ev);
+ break;
+ case 'Escape':
+ this.trigger('o-cancel');
+ break;
+ }
+ }
+
+ /**
+ * @private
+ * @param {KeyboardEvent} ev
+ */
+ _onKeydownEnter(ev) {
+ const value = this.el.value;
+ const newName = value || this.props.placeholder;
+ if (this.props.value !== newName) {
+ this.trigger('o-validate', { newName });
+ } else {
+ this.trigger('o-cancel');
+ }
+ }
+
+}
+
+Object.assign(EditableText, {
+ defaultProps: {
+ placeholder: "",
+ value: "",
+ },
+ props: {
+ placeholder: String,
+ value: String,
+ },
+ template: 'mail.EditableText',
+});
+
+return EditableText;
+
+});
diff --git a/addons/mail/static/src/components/editable_text/editable_text.xml b/addons/mail/static/src/components/editable_text/editable_text.xml
new file mode 100644
index 00000000000..5e3aa52abe6
--- /dev/null
+++ b/addons/mail/static/src/components/editable_text/editable_text.xml
@@ -0,0 +1,8 @@
+<?xml version="1.0" encoding="UTF-8"?>
+<templates xml:space="preserve">
+
+ <t t-name="mail.EditableText" owl="1">
+ <input class="o_EditableText" t-att-value="props.value" t-on-blur="_onBlur" t-on-click="_onClick" t-on-keydown="_onKeydown" t-att-placeholder="props.placeholder"/>
+ </t>
+
+</templates>
diff --git a/addons/mail/static/src/components/emojis_popover/emojis_popover.js b/addons/mail/static/src/components/emojis_popover/emojis_popover.js
new file mode 100644
index 00000000000..2e8ee6c6ae8
--- /dev/null
+++ b/addons/mail/static/src/components/emojis_popover/emojis_popover.js
@@ -0,0 +1,42 @@
+odoo.define('mail/static/src/components/emojis_popover/emojis_popover.js', function (require) {
+'use strict';
+
+const emojis = require('mail.emojis');
+
+const { Component } = owl;
+
+class EmojisPopover extends Component {
+
+ /**
+ * @param {...any} args
+ */
+ constructor(...args) {
+ super(...args);
+ this.emojis = emojis;
+ }
+
+ //--------------------------------------------------------------------------
+ // Handlers
+ //--------------------------------------------------------------------------
+
+ /**
+ * @private
+ * @param {MouseEvent} ev
+ */
+ _onClickEmoji(ev) {
+ this.trigger('o-popover-close');
+ this.trigger('o-emoji-selection', {
+ unicode: ev.currentTarget.dataset.unicode,
+ });
+ }
+
+}
+
+Object.assign(EmojisPopover, {
+ props: {},
+ template: 'mail.EmojisPopover',
+});
+
+return EmojisPopover;
+
+});
diff --git a/addons/mail/static/src/components/emojis_popover/emojis_popover.scss b/addons/mail/static/src/components/emojis_popover/emojis_popover.scss
new file mode 100644
index 00000000000..e49edff659d
--- /dev/null
+++ b/addons/mail/static/src/components/emojis_popover/emojis_popover.scss
@@ -0,0 +1,21 @@
+// ------------------------------------------------------------------
+// Layout
+// ------------------------------------------------------------------
+
+.o_EmojisPopover {
+ display: flex;
+ flex-flow: row wrap;
+}
+
+.o_EmojisPopover_emoji {
+ font-size: 1.1em;
+ margin: 3px;
+}
+
+// ------------------------------------------------------------------
+// Style
+// ------------------------------------------------------------------
+
+.o_EmojisPopover_emoji {
+ cursor: pointer;
+}
diff --git a/addons/mail/static/src/components/emojis_popover/emojis_popover.xml b/addons/mail/static/src/components/emojis_popover/emojis_popover.xml
new file mode 100644
index 00000000000..cac840bbedc
--- /dev/null
+++ b/addons/mail/static/src/components/emojis_popover/emojis_popover.xml
@@ -0,0 +1,14 @@
+<?xml version="1.0" encoding="UTF-8"?>
+<templates xml:space="preserve">
+
+ <t t-name="mail.EmojisPopover" owl="1">
+ <div class="o_EmojisPopover">
+ <t t-foreach="emojis" t-as="emoji" t-key="emoji.unicode">
+ <span class="o_EmojisPopover_emoji" t-on-click="_onClickEmoji" t-att-title="emoji.description" t-att-data-source="emoji.sources[0]" t-att-data-unicode="emoji.unicode">
+ <t t-esc="emoji.unicode"/>
+ </span>
+ </t>
+ </div>
+ </t>
+
+</templates>
diff --git a/addons/mail/static/src/components/file_uploader/file_uploader.js b/addons/mail/static/src/components/file_uploader/file_uploader.js
new file mode 100644
index 00000000000..732eb312126
--- /dev/null
+++ b/addons/mail/static/src/components/file_uploader/file_uploader.js
@@ -0,0 +1,206 @@
+odoo.define('mail/static/src/components/file_uploader/file_uploader.js', function (require) {
+'use strict';
+
+const core = require('web.core');
+
+const { Component } = owl;
+const { useRef } = owl.hooks;
+
+class FileUploader extends Component {
+
+ /**
+ * @override
+ */
+ constructor(...args) {
+ super(...args);
+ this._fileInputRef = useRef('fileInput');
+ this._fileUploadId = _.uniqueId('o_FileUploader_fileupload');
+ this._onAttachmentUploaded = this._onAttachmentUploaded.bind(this);
+ }
+
+ mounted() {
+ $(window).on(this._fileUploadId, this._onAttachmentUploaded);
+ }
+
+ willUnmount() {
+ $(window).off(this._fileUploadId);
+ }
+
+ //--------------------------------------------------------------------------
+ // Public
+ //--------------------------------------------------------------------------
+
+ /**
+ * @param {FileList|Array} files
+ * @returns {Promise}
+ */
+ async uploadFiles(files) {
+ await this._unlinkExistingAttachments(files);
+ this._createTemporaryAttachments(files);
+ await this._performUpload(files);
+ this._fileInputRef.el.value = '';
+ }
+
+ openBrowserFileUploader() {
+ this._fileInputRef.el.click();
+ }
+
+ //--------------------------------------------------------------------------
+ // Private
+ //--------------------------------------------------------------------------
+
+ /**
+ * @private
+ * @param {Object} fileData
+ * @returns {mail.attachment}
+ */
+ _createAttachment(fileData) {
+ return this.env.models['mail.attachment'].create(Object.assign(
+ {},
+ fileData,
+ this.props.newAttachmentExtraData
+ ));
+ }
+
+ /**
+ * @private
+ * @param {File} file
+ * @returns {FormData}
+ */
+ _createFormData(file) {
+ let formData = new window.FormData();
+ formData.append('callback', this._fileUploadId);
+ formData.append('csrf_token', core.csrf_token);
+ formData.append('id', this.props.uploadId);
+ formData.append('model', this.props.uploadModel);
+ formData.append('ufile', file, file.name);
+ return formData;
+ }
+
+ /**
+ * @private
+ * @param {FileList|Array} files
+ */
+ _createTemporaryAttachments(files) {
+ for (const file of files) {
+ this._createAttachment({
+ filename: file.name,
+ isTemporary: true,
+ name: file.name
+ });
+ }
+ }
+ /**
+ * @private
+ * @param {FileList|Array} files
+ * @returns {Promise}
+ */
+ async _performUpload(files) {
+ for (const file of files) {
+ const response = await window.fetch('/web/binary/upload_attachment', {
+ method: 'POST',
+ body: this._createFormData(file),
+ });
+ let html = await response.text();
+ const template = document.createElement('template');
+ template.innerHTML = html.trim();
+ window.eval(template.content.firstChild.textContent);
+ }
+ }
+
+ /**
+ * @private
+ * @param {FileList|Array} files
+ * @returns {Promise}
+ */
+ async _unlinkExistingAttachments(files) {
+ for (const file of files) {
+ const attachment = this.props.attachmentLocalIds
+ .map(attachmentLocalId => this.env.models['mail.attachment'].get(attachmentLocalId))
+ .find(attachment => attachment.name === file.name && attachment.size === file.size);
+ // if the files already exits, delete the file before upload
+ if (attachment) {
+ attachment.remove();
+ }
+ }
+ }
+
+ //--------------------------------------------------------------------------
+ // Handlers
+ //--------------------------------------------------------------------------
+
+ /**
+ * @private
+ * @param {jQuery.Event} ev
+ * @param {...Object} filesData
+ */
+ async _onAttachmentUploaded(ev, ...filesData) {
+ for (const fileData of filesData) {
+ const { error, filename, id, mimetype, name, size } = fileData;
+ if (error || !id) {
+ this.env.services['notification'].notify({
+ type: 'danger',
+ message: owl.utils.escape(error),
+ });
+ const relatedTemporaryAttachments = this.env.models['mail.attachment']
+ .find(attachment =>
+ attachment.filename === filename &&
+ attachment.isTemporary
+ );
+ for (const attachment of relatedTemporaryAttachments) {
+ attachment.delete();
+ }
+ return;
+ }
+ // FIXME : needed to avoid problems on uploading
+ // Without this the useStore selector of component could be not called
+ // E.g. in attachment_box_tests.js
+ await new Promise(resolve => setTimeout(resolve));
+ const attachment = this._createAttachment({
+ filename,
+ id,
+ mimetype,
+ name,
+ size,
+ });
+ this.trigger('o-attachment-created', { attachment });
+ }
+ }
+
+ /**
+ * Called when there are changes in the file input.
+ *
+ * @private
+ * @param {Event} ev
+ * @param {EventTarget} ev.target
+ * @param {FileList|Array} ev.target.files
+ */
+ async _onChangeAttachment(ev) {
+ await this.uploadFiles(ev.target.files);
+ }
+
+}
+
+Object.assign(FileUploader, {
+ defaultProps: {
+ uploadId: 0,
+ uploadModel: 'mail.compose.message'
+ },
+ props: {
+ attachmentLocalIds: {
+ type: Array,
+ element: String,
+ },
+ newAttachmentExtraData: {
+ type: Object,
+ optional: true,
+ },
+ uploadId: Number,
+ uploadModel: String,
+ },
+ template: 'mail.FileUploader',
+});
+
+return FileUploader;
+
+});
diff --git a/addons/mail/static/src/components/file_uploader/file_uploader.scss b/addons/mail/static/src/components/file_uploader/file_uploader.scss
new file mode 100644
index 00000000000..32792313ed8
--- /dev/null
+++ b/addons/mail/static/src/components/file_uploader/file_uploader.scss
@@ -0,0 +1,3 @@
+.o_FileUploader_input {
+ display: none !important;
+}
diff --git a/addons/mail/static/src/components/file_uploader/file_uploader.xml b/addons/mail/static/src/components/file_uploader/file_uploader.xml
new file mode 100644
index 00000000000..bf144037511
--- /dev/null
+++ b/addons/mail/static/src/components/file_uploader/file_uploader.xml
@@ -0,0 +1,10 @@
+<?xml version="1.0" encoding="UTF-8"?>
+<templates xml:space="preserve">
+
+ <t t-name="mail.FileUploader" owl="1">
+ <div class="o_FileUploader">
+ <input class="o_FileUploader_input" t-on-change="_onChangeAttachment" multiple="true" type="file" t-ref="fileInput" t-key="'fileInput'"/>
+ </div>
+ </t>
+
+</templates>
diff --git a/addons/mail/static/src/components/file_uploader/file_uploader_tests.js b/addons/mail/static/src/components/file_uploader/file_uploader_tests.js
new file mode 100644
index 00000000000..a5831a89efb
--- /dev/null
+++ b/addons/mail/static/src/components/file_uploader/file_uploader_tests.js
@@ -0,0 +1,104 @@
+odoo.define('mail/static/src/components/file_uploader/file_uploader_tests.js', function (require) {
+"use strict";
+
+const components = {
+ FileUploader: require('mail/static/src/components/file_uploader/file_uploader.js'),
+};
+const {
+ afterEach: utilsAfterEach,
+ beforeEach: utilsBeforeEach,
+ inputFiles,
+ nextAnimationFrame,
+ start: utilsStart,
+} = require('mail/static/src/utils/test_utils.js');
+
+const { file: { createFile } } = require('web.test_utils');
+
+QUnit.module('mail', {}, function () {
+QUnit.module('components', {}, function () {
+QUnit.module('file_uploader', {}, function () {
+QUnit.module('file_uploader_tests.js', {
+ beforeEach() {
+ utilsBeforeEach(this);
+ this.components = [];
+
+ this.createFileUploaderComponent = async props => {
+ const FileUploaderComponent = components.FileUploader;
+ FileUploaderComponent.env = this.env;
+ const fileUploader = new FileUploaderComponent(
+ null,
+ Object.assign({ attachmentLocalIds: [] }, props)
+ );
+ await fileUploader.mount(this.widget.el);
+ this.components.push(fileUploader);
+ return fileUploader;
+ };
+
+ this.start = async params => {
+ if (this.widget) {
+ this.widget.destroy();
+ }
+ const { env, widget } = await utilsStart(Object.assign({}, params, {
+ data: this.data,
+ }));
+ this.env = env;
+ this.widget = widget;
+ };
+ },
+ afterEach() {
+ utilsAfterEach(this);
+ for (const fileUploader of this.components) {
+ fileUploader.destroy();
+ }
+ if (this.widget) {
+ this.widget.destroy();
+ }
+ delete components.FileUploader.env;
+ this.env = undefined;
+ },
+});
+
+QUnit.test('no conflicts between file uploaders', async function (assert) {
+ assert.expect(2);
+
+ await this.start();
+ const fileUploader1 = await this.createFileUploaderComponent();
+ const fileUploader2 = await this.createFileUploaderComponent();
+ const file1 = await createFile({
+ name: 'text1.txt',
+ content: 'hello, world',
+ contentType: 'text/plain',
+ });
+ inputFiles(
+ fileUploader1.el.querySelector('.o_FileUploader_input'),
+ [file1]
+ );
+ await nextAnimationFrame(); // we can't use afterNextRender as fileInput are display:none
+ assert.strictEqual(
+ this.env.models['mail.attachment'].all().length,
+ 1,
+ 'Uploaded file should be the only attachment created'
+ );
+
+ const file2 = await createFile({
+ name: 'text2.txt',
+ content: 'hello, world',
+ contentType: 'text/plain',
+ });
+ inputFiles(
+ fileUploader2.el.querySelector('.o_FileUploader_input'),
+ [file2]
+ );
+ await nextAnimationFrame();
+ assert.strictEqual(
+ this.env.models['mail.attachment'].all().length,
+ 2,
+ 'Uploaded file should be the only attachment added'
+ );
+});
+
+});
+});
+});
+
+});
diff --git a/addons/mail/static/src/components/follow_button/follow_button.js b/addons/mail/static/src/components/follow_button/follow_button.js
new file mode 100644
index 00000000000..b1639567c14
--- /dev/null
+++ b/addons/mail/static/src/components/follow_button/follow_button.js
@@ -0,0 +1,91 @@
+odoo.define('mail/static/src/components/follow_button/follow_button.js', function (require) {
+'use strict';
+
+const useStore = require('mail/static/src/component_hooks/use_store/use_store.js');
+
+const { Component } = owl;
+const { useState } = owl.hooks;
+
+class FollowButton extends Component {
+ /**
+ * @override
+ */
+ constructor(...args) {
+ super(...args);
+ this.state = useState({
+ /**
+ * Determine whether the unfollow button is highlighted or not.
+ */
+ isUnfollowButtonHighlighted: false,
+ });
+ useStore(props => {
+ const thread = this.env.models['mail.thread'].get(props.threadLocalId);
+ return {
+ thread: thread ? thread.__state : undefined,
+ };
+ });
+ }
+
+ //--------------------------------------------------------------------------
+ // Public
+ //--------------------------------------------------------------------------
+
+ /**
+ * @return {mail.thread}
+ */
+ get thread() {
+ return this.env.models['mail.thread'].get(this.props.threadLocalId);
+ }
+
+ //--------------------------------------------------------------------------
+ // Handlers
+ //--------------------------------------------------------------------------
+
+ /**
+ * @private
+ * @param {MouseEvent} ev
+ */
+ _onClickFollow(ev) {
+ this.thread.follow();
+ }
+
+ /**
+ * @private
+ * @param {MouseEvent} ev
+ */
+ _onClickUnfollow(ev) {
+ this.thread.unfollow();
+ }
+
+ /**
+ * @private
+ * @param {MouseEvent} ev
+ */
+ _onMouseLeaveUnfollow(ev) {
+ this.state.isUnfollowButtonHighlighted = false;
+ }
+
+ /**
+ * @private
+ * @param {MouseEvent} ev
+ */
+ _onMouseEnterUnfollow(ev) {
+ this.state.isUnfollowButtonHighlighted = true;
+ }
+
+}
+
+Object.assign(FollowButton, {
+ defaultProps: {
+ isDisabled: false,
+ },
+ props: {
+ isDisabled: Boolean,
+ threadLocalId: String,
+ },
+ template: 'mail.FollowButton',
+});
+
+return FollowButton;
+
+});
diff --git a/addons/mail/static/src/components/follow_button/follow_button.scss b/addons/mail/static/src/components/follow_button/follow_button.scss
new file mode 100644
index 00000000000..431de0e8577
--- /dev/null
+++ b/addons/mail/static/src/components/follow_button/follow_button.scss
@@ -0,0 +1,19 @@
+// ------------------------------------------------------------------
+// Style
+// ------------------------------------------------------------------
+
+.o_FollowButton_follow {
+ color: gray('600');
+}
+
+.o_FollowButton_unfollow {
+ color: gray('600');
+
+ &.o-following {
+ color: $green;
+ }
+
+ &.o-unfollow {
+ color: $orange;
+ }
+}
diff --git a/addons/mail/static/src/components/follow_button/follow_button.xml b/addons/mail/static/src/components/follow_button/follow_button.xml
new file mode 100644
index 00000000000..00fc8d65310
--- /dev/null
+++ b/addons/mail/static/src/components/follow_button/follow_button.xml
@@ -0,0 +1,24 @@
+<?xml version="1.0" encoding="UTF-8"?>
+<templates xml:space="preserve">
+
+ <t t-name="mail.FollowButton" owl="1">
+ <div class="o_FollowButton">
+ <t t-if="thread.isCurrentPartnerFollowing">
+ <button class="o_FollowButton_unfollow btn btn-link" t-att-class="{ 'o-following': !state.isUnfollowButtonHighlighted, 'o-unfollow': state.isUnfollowButtonHighlighted }" t-att-disabled="props.isDisabled" t-on-click="_onClickUnfollow" t-on-mouseenter="_onMouseEnterUnfollow" t-on-mouseleave="_onMouseLeaveUnfollow">
+ <t t-if="state.isUnfollowButtonHighlighted">
+ <i class="fa fa-times"/> Unfollow
+ </t>
+ <t t-else="">
+ <i class="fa fa-check"/> Following
+ </t>
+ </button>
+ </t>
+ <t t-else="">
+ <button class="o_FollowButton_follow btn btn-link" t-att-disabled="props.isDisabled" t-on-click="_onClickFollow">
+ Follow
+ </button>
+ </t>
+ </div>
+ </t>
+
+</templates>
diff --git a/addons/mail/static/src/components/follow_button/follow_button_tests.js b/addons/mail/static/src/components/follow_button/follow_button_tests.js
new file mode 100644
index 00000000000..a32cdb76e9d
--- /dev/null
+++ b/addons/mail/static/src/components/follow_button/follow_button_tests.js
@@ -0,0 +1,331 @@
+odoo.define('mail/static/src/components/follow_button/follow_button_tests.js', function (require) {
+'use strict';
+
+const components = {
+ FollowButton: require('mail/static/src/components/follow_button/follow_button.js'),
+};
+const {
+ afterEach: utilsAfterEach,
+ afterNextRender,
+ beforeEach: utilsBeforeEach,
+ start: utilsStart,
+} = require('mail/static/src/utils/test_utils.js');
+
+QUnit.module('mail', {}, function () {
+QUnit.module('components', {}, function () {
+QUnit.module('follow_button', {}, function () {
+QUnit.module('follow_button_tests.js', {
+ beforeEach() {
+ utilsBeforeEach(this);
+
+ this.createFollowButtonComponent = async (thread, otherProps = {}) => {
+ const FollowButtonComponent = components.FollowButton;
+ FollowButtonComponent.env = this.env;
+ this.component = new FollowButtonComponent(null,
+ Object.assign(otherProps, { threadLocalId: thread.localId })
+ );
+ await afterNextRender(() => this.component.mount(this.widget.el));
+ };
+
+ this.start = async params => {
+ let { env, widget } = await utilsStart(Object.assign({}, params, {
+ data: this.data,
+ }));
+ this.env = env;
+ this.widget = widget;
+ };
+ },
+ afterEach() {
+ utilsAfterEach(this);
+ if (this.component) {
+ this.component.destroy();
+ this.component = undefined;
+ }
+ if (this.widget) {
+ this.widget.destroy();
+ this.widget = undefined;
+ }
+ this.env = undefined;
+ delete components.FollowButton.env;
+ },
+});
+
+QUnit.test('base rendering not editable', async function (assert) {
+ assert.expect(3);
+
+ await this.start();
+ const thread = this.env.models['mail.thread'].create({
+ id: 100,
+ model: 'res.partner',
+ });
+ await this.createFollowButtonComponent(thread, { isDisabled: true });
+ assert.containsOnce(
+ document.body,
+ '.o_FollowButton',
+ "should have follow button component"
+ );
+ assert.containsOnce(
+ document.body,
+ '.o_FollowButton_follow',
+ "should have 'Follow' button"
+ );
+ assert.ok(
+ document.querySelector('.o_FollowButton_follow').disabled,
+ "'Follow' button should be disabled"
+ );
+});
+
+QUnit.test('base rendering editable', async function (assert) {
+ assert.expect(3);
+
+ await this.start();
+ const thread = this.env.models['mail.thread'].create({
+ id: 100,
+ model: 'res.partner',
+ });
+ await this.createFollowButtonComponent(thread);
+ assert.containsOnce(
+ document.body,
+ '.o_FollowButton',
+ "should have follow button component"
+ );
+ assert.containsOnce(
+ document.body,
+ '.o_FollowButton_follow',
+ "should have 'Follow' button"
+ );
+ assert.notOk(
+ document.querySelector('.o_FollowButton_follow').disabled,
+ "'Follow' button should be disabled"
+ );
+});
+
+QUnit.test('hover following button', async function (assert) {
+ assert.expect(8);
+ const self = this;
+
+ await this.start({
+ async mockRPC(route, args) {
+ if (route.includes('web/image/')) {
+ return;
+ } else if (route.includes('res.partner/read')) {
+ return [{
+ id: 100,
+ message_follower_ids: [1],
+ }];
+ } else if (route.includes('message_subscribe')) {
+ return;
+ } else if (route.includes('message_unsubscribe')) {
+ return;
+ } else if (route.includes('mail/read_followers')) {
+ return {
+ followers: [{
+ partner_id: self.env.session.partner_id,
+ email: "bla@bla.bla",
+ id: 1,
+ is_active: true,
+ is_editable: true,
+ name: "François Perusse",
+ }]
+ };
+ }
+ return this._super(...arguments);
+ },
+ });
+ const thread = this.env.models['mail.thread'].create({
+ id: 100,
+ model: 'res.partner',
+ });
+ thread.follow();
+ await this.createFollowButtonComponent(thread);
+ assert.containsOnce(
+ document.body,
+ '.o_FollowButton',
+ "should have follow button component"
+ );
+ assert.containsOnce(
+ document.body,
+ '.o_FollowButton_unfollow',
+ "should have 'Unfollow' button"
+ );
+ assert.strictEqual(
+ document.querySelector('.o_FollowButton_unfollow').textContent.trim(),
+ 'Following',
+ "'unfollow' button should display 'Following' as text when not hovered"
+ );
+ assert.containsNone(
+ document.querySelector('.o_FollowButton_unfollow'),
+ '.fa-times',
+ "'unfollow' button should not contain a cross icon when not hovered"
+ );
+ assert.containsOnce(
+ document.querySelector('.o_FollowButton_unfollow'),
+ '.fa-check',
+ "'unfollow' button should contain a check icon when not hovered"
+ );
+
+ await afterNextRender(() => {
+ document
+ .querySelector('.o_FollowButton_unfollow')
+ .dispatchEvent(new window.MouseEvent('mouseenter'));
+ }
+ );
+ assert.strictEqual(
+ document.querySelector('.o_FollowButton_unfollow').textContent.trim(),
+ 'Unfollow',
+ "'unfollow' button should display 'Unfollow' as text when hovered"
+ );
+ assert.containsOnce(
+ document.querySelector('.o_FollowButton_unfollow'),
+ '.fa-times',
+ "'unfollow' button should contain a cross icon when hovered"
+ );
+ assert.containsNone(
+ document.querySelector('.o_FollowButton_unfollow'),
+ '.fa-check',
+ "'unfollow' button should not contain a check icon when hovered"
+ );
+});
+
+QUnit.test('click on "follow" button', async function (assert) {
+ assert.expect(8);
+
+ await this.start({
+ async mockRPC(route, args) {
+ if (route.includes('web/image/')) {
+ return;
+ } else if (route.includes('res.partner/read')) {
+ assert.step('rpc:read_follower_ids');
+ return [{
+ id: 100,
+ message_follower_ids: [1],
+ }];
+ } else if (route.includes('message_subscribe')) {
+ assert.step('rpc:message_subscribe');
+ return;
+ } else if (route.includes('mail/read_followers')) {
+ assert.step('rpc:read_followers_details');
+ return {
+ followers: [{
+ partner_id: 3,
+ email: "bla@bla.bla",
+ id: 1,
+ is_active: true,
+ is_editable: true,
+ name: "François Perusse",
+ }]
+ };
+ }
+ return this._super(...arguments);
+ },
+ });
+ const thread = this.env.models['mail.thread'].create({
+ id: 100,
+ model: 'res.partner',
+ });
+ await this.createFollowButtonComponent(thread);
+ assert.containsOnce(
+ document.body,
+ '.o_FollowButton',
+ "should have follow button component"
+ );
+ assert.containsOnce(
+ document.body,
+ '.o_FollowButton_follow',
+ "should have button follow"
+ );
+
+ await afterNextRender(() => {
+ document.querySelector('.o_FollowButton_follow').click();
+ });
+ assert.verifySteps([
+ 'rpc:message_subscribe',
+ 'rpc:read_follower_ids',
+ 'rpc:read_followers_details',
+ ]);
+ assert.containsNone(
+ document.body,
+ '.o_FollowButton_follow',
+ "should not have follow button after clicked on follow"
+ );
+ assert.containsOnce(
+ document.body,
+ '.o_FollowButton_unfollow',
+ "should have unfollow button after clicked on follow"
+ );
+});
+
+QUnit.test('click on "unfollow" button', async function (assert) {
+ assert.expect(7);
+ const self = this;
+
+ await this.start({
+ async mockRPC(route, args) {
+ if (route.includes('web/image/')) {
+ return;
+ } else if (route.includes('res.partner/read')) {
+ return [{
+ id: 100,
+ message_follower_ids: [1],
+ }];
+ } else if (route.includes('message_subscribe')) {
+ return;
+ } else if (route.includes('message_unsubscribe')) {
+ assert.step('rpc:message_unsubscribe');
+ return;
+ } else if (route.includes('mail/read_followers')) {
+ return {
+ followers: [{
+ partner_id: self.env.session.partner_id,
+ email: "bla@bla.bla",
+ id: 1,
+ is_active: true,
+ is_editable: true,
+ name: "François Perusse",
+ }]
+ };
+ }
+ return this._super(...arguments);
+ },
+ });
+ const thread = this.env.models['mail.thread'].create({
+ id: 100,
+ model: 'res.partner',
+ });
+ thread.follow();
+ await this.createFollowButtonComponent(thread);
+ assert.containsOnce(
+ document.body,
+ '.o_FollowButton',
+ "should have follow button component"
+ );
+ assert.containsNone(
+ document.body,
+ '.o_FollowButton_follow',
+ "should not have button follow"
+ );
+ assert.containsOnce(
+ document.body,
+ '.o_FollowButton_unfollow',
+ "should have button unfollow"
+ );
+
+ await afterNextRender(() => document.querySelector('.o_FollowButton_unfollow').click());
+ assert.verifySteps(['rpc:message_unsubscribe']);
+ assert.containsOnce(
+ document.body,
+ '.o_FollowButton_follow',
+ "should have follow button after clicked on unfollow"
+ );
+ assert.containsNone(
+ document.body,
+ '.o_FollowButton_unfollow',
+ "should not have unfollow button after clicked on unfollow"
+ );
+});
+
+});
+});
+});
+
+});
diff --git a/addons/mail/static/src/components/follower/follower.js b/addons/mail/static/src/components/follower/follower.js
new file mode 100644
index 00000000000..f446c098914
--- /dev/null
+++ b/addons/mail/static/src/components/follower/follower.js
@@ -0,0 +1,81 @@
+odoo.define('mail/static/src/components/follower/follower.js', function (require) {
+'use strict';
+
+const components = {
+ FollowerSubtypeList: require('mail/static/src/components/follower_subtype_list/follower_subtype_list.js'),
+};
+const useStore = require('mail/static/src/component_hooks/use_store/use_store.js');
+
+const { Component } = owl;
+
+class Follower extends Component {
+
+ /**
+ * @override
+ */
+ constructor(...args) {
+ super(...args);
+ useStore(props => {
+ const follower = this.env.models['mail.follower'].get(props.followerLocalId);
+ return [follower ? follower.__state : undefined];
+ });
+ }
+
+ //--------------------------------------------------------------------------
+ // Public
+ //--------------------------------------------------------------------------
+
+ /**
+ * @returns {mail.follower}
+ */
+ get follower() {
+ return this.env.models['mail.follower'].get(this.props.followerLocalId);
+ }
+
+ //--------------------------------------------------------------------------
+ // Handlers
+ //--------------------------------------------------------------------------
+
+ /**
+ * @private
+ * @param {MouseEvent} ev
+ */
+ _onClickDetails(ev) {
+ ev.preventDefault();
+ ev.stopPropagation();
+ this.env.messaging.openDocument({
+ id: this.follower.resId,
+ model: this.follower.resModel,
+ });
+ }
+
+ /**
+ * @private
+ * @param {MouseEvent} ev
+ */
+ _onClickEdit(ev) {
+ ev.preventDefault();
+ this.follower.showSubtypes();
+ }
+
+ /**
+ * @private
+ * @param {MouseEvent} ev
+ */
+ _onClickRemove(ev) {
+ this.follower.remove();
+ }
+
+}
+
+Object.assign(Follower, {
+ components,
+ props: {
+ followerLocalId: String,
+ },
+ template: 'mail.Follower',
+});
+
+return Follower;
+
+});
diff --git a/addons/mail/static/src/components/follower/follower.scss b/addons/mail/static/src/components/follower/follower.scss
new file mode 100644
index 00000000000..509a119fb61
--- /dev/null
+++ b/addons/mail/static/src/components/follower/follower.scss
@@ -0,0 +1,55 @@
+// ------------------------------------------------------------------
+// Layout
+// ------------------------------------------------------------------
+
+.o_Follower {
+ display: flex;
+ flex-flow: row;
+ justify-content: space-between;
+ padding: map-get($spacers, 0);
+}
+
+.o_Follower_avatar {
+ width: 24px;
+ height: 24px;
+ margin-inline-end: map-get($spacers, 2);
+}
+
+.o_Follower_details {
+ align-items: center;
+ display: flex;
+ flex: 1;
+ padding-left: map-get($spacers, 3);
+ padding-right: map-get($spacers, 3);
+}
+
+// ------------------------------------------------------------------
+// Style
+// ------------------------------------------------------------------
+
+.o_Follower_avatar {
+ border-radius: 50%;
+}
+
+.o_Follower_button {
+ border-radius: 0;
+
+ &:hover {
+ background: gray('400');
+ color: $black;
+ }
+}
+
+.o_Follower_details {
+ color: gray('700');
+
+ &:hover {
+ background: gray('400');
+ color: $black;
+ }
+
+ &.o-inactive {
+ opacity: 0.25;
+ font-style: italic;
+ }
+}
diff --git a/addons/mail/static/src/components/follower/follower.xml b/addons/mail/static/src/components/follower/follower.xml
new file mode 100644
index 00000000000..26a26ece3f9
--- /dev/null
+++ b/addons/mail/static/src/components/follower/follower.xml
@@ -0,0 +1,23 @@
+<?xml version="1.0" encoding="UTF-8"?>
+<templates xml:space="preserve">
+
+ <t t-name="mail.Follower" owl="1">
+ <div class="o_Follower">
+ <t t-if="follower">
+ <a class="o_Follower_details" t-att-class="{ 'o-inactive': !follower.isActive }" href="#" t-on-click="_onClickDetails">
+ <img class="o_Follower_avatar" t-attf-src="/web/image/{{ follower.resModel }}/{{ follower.resId }}/image_128" alt="Avatar"/>
+ <span class="o_Follower_name" t-esc="follower.name"/>
+ </a>
+ <t t-if="follower.isEditable">
+ <button class="btn btn-icon o_Follower_button o_Follower_editButton" title="Edit subscription" t-on-click="_onClickEdit">
+ <i class="fa fa-pencil"/>
+ </button>
+ <button class="btn btn-icon o_Follower_button o_Follower_removeButton" title="Remove this follower" t-on-click="_onClickRemove">
+ <i class="fa fa-remove"/>
+ </button>
+ </t>
+ </t>
+ </div>
+ </t>
+
+</templates>
diff --git a/addons/mail/static/src/components/follower/follower_tests.js b/addons/mail/static/src/components/follower/follower_tests.js
new file mode 100644
index 00000000000..87e09dda2df
--- /dev/null
+++ b/addons/mail/static/src/components/follower/follower_tests.js
@@ -0,0 +1,354 @@
+odoo.define('mail/static/src/components/follower/follower_tests.js', function (require) {
+'use strict';
+
+const components = {
+ Follower: require('mail/static/src/components/follower/follower.js'),
+};
+const {
+ afterEach: utilsAfterEach,
+ afterNextRender,
+ beforeEach: utilsBeforeEach,
+ start: utilsStart,
+} = require('mail/static/src/utils/test_utils.js');
+
+const Bus = require('web.Bus');
+
+QUnit.module('mail', {}, function () {
+QUnit.module('components', {}, function () {
+QUnit.module('follower', {}, function () {
+QUnit.module('follower_tests.js', {
+ beforeEach() {
+ utilsBeforeEach(this);
+
+ this.createFollowerComponent = async (follower) => {
+ const FollowerComponent = components.Follower;
+ FollowerComponent.env = this.env;
+ this.component = new FollowerComponent(null, { followerLocalId: follower.localId });
+ await this.component.mount(this.widget.el);
+ };
+
+ this.start = async params => {
+ let { env, widget } = await utilsStart(Object.assign({}, params, {
+ data: this.data,
+ }));
+ this.env = env;
+ this.widget = widget;
+ };
+ },
+ afterEach() {
+ utilsAfterEach(this);
+ if (this.component) {
+ this.component.destroy();
+ this.component = undefined;
+ }
+ if (this.widget) {
+ this.widget.destroy();
+ this.widget = undefined;
+ }
+ this.env = undefined;
+ delete components.Follower.env;
+ },
+});
+
+QUnit.test('base rendering not editable', async function (assert) {
+ assert.expect(5);
+
+ await this.start({
+ async mockRPC(route, args) {
+ if (route === '/web/image/mail.channel/1/image_128') {
+ return;
+ }
+ return this._super(...arguments);
+ },
+ });
+
+ const thread = this.env.models['mail.thread'].create({
+ id: 100,
+ model: 'res.partner',
+ });
+ const follower = await this.env.models['mail.follower'].create({
+ channel: [['insert', { id: 1, model: 'mail.channel', name: "François Perusse" }]],
+ followedThread: [['link', thread]],
+ id: 2,
+ isActive: true,
+ isEditable: false,
+ });
+ await this.createFollowerComponent(follower);
+ assert.containsOnce(
+ document.body,
+ '.o_Follower',
+ "should have follower component"
+ );
+ assert.containsOnce(
+ document.body,
+ '.o_Follower_details',
+ "should display a details part"
+ );
+ assert.containsOnce(
+ document.body,
+ '.o_Follower_avatar',
+ "should display the avatar of the follower"
+ );
+ assert.containsOnce(
+ document.body,
+ '.o_Follower_name',
+ "should display the name of the follower"
+ );
+ assert.containsNone(
+ document.body,
+ '.o_Follower_button',
+ "should have no button as follower is not editable"
+ );
+});
+
+QUnit.test('base rendering editable', async function (assert) {
+ assert.expect(6);
+
+ await this.start({
+ async mockRPC(route, args) {
+ if (route === 'web/image/mail.channel/1/image_128') {
+ return;
+ }
+ return this._super(...arguments);
+ },
+ });
+ const thread = this.env.models['mail.thread'].create({
+ id: 100,
+ model: 'res.partner',
+ });
+ const follower = await this.env.models['mail.follower'].create({
+ channel: [['insert', { id: 1, model: 'mail.channel', name: "François Perusse" }]],
+ followedThread: [['link', thread]],
+ id: 2,
+ isActive: true,
+ isEditable: true,
+ });
+ await this.createFollowerComponent(follower);
+ assert.containsOnce(
+ document.body,
+ '.o_Follower',
+ "should have follower component"
+ );
+ assert.containsOnce(
+ document.body,
+ '.o_Follower_details',
+ "should display a details part"
+ );
+ assert.containsOnce(
+ document.body,
+ '.o_Follower_avatar',
+ "should display the avatar of the follower"
+ );
+ assert.containsOnce(
+ document.body,
+ '.o_Follower_name',
+ "should display the name of the follower"
+ );
+ assert.containsOnce(
+ document.body,
+ '.o_Follower_editButton',
+ "should have an edit button"
+ );
+ assert.containsOnce(
+ document.body,
+ '.o_Follower_removeButton',
+ "should have a remove button"
+ );
+});
+
+QUnit.test('click on channel follower details', async function (assert) {
+ assert.expect(7);
+
+ const bus = new Bus();
+ bus.on('do-action', null, payload => {
+ assert.step('do_action');
+ assert.strictEqual(
+ payload.action.res_id,
+ 1,
+ "The redirect action should redirect to the right res id (1)"
+ );
+ assert.strictEqual(
+ payload.action.res_model,
+ 'mail.channel',
+ "The redirect action should redirect to the right res model (mail.channel)"
+ );
+ assert.strictEqual(
+ payload.action.type,
+ "ir.actions.act_window",
+ "The redirect action should be of type 'ir.actions.act_window'"
+ );
+ });
+
+ await this.start({
+ env: { bus },
+ async mockRPC(route, args) {
+ if (route === 'web/image/mail.channel/1/image_128') {
+ return;
+ }
+ return this._super(...arguments);
+ },
+ });
+ const thread = this.env.models['mail.thread'].create({
+ id: 100,
+ model: 'res.partner',
+ });
+ const follower = await this.env.models['mail.follower'].create({
+ channel: [['insert', { id: 1, model: 'mail.channel', name: "channel" }]],
+ followedThread: [['link', thread]],
+ id: 2,
+ isActive: true,
+ isEditable: true,
+ });
+ await this.createFollowerComponent(follower);
+ assert.containsOnce(
+ document.body,
+ '.o_Follower',
+ "should have follower component"
+ );
+ assert.containsOnce(
+ document.body,
+ '.o_Follower_details',
+ "should display a details part"
+ );
+
+ document.querySelector('.o_Follower_details').click();
+ assert.verifySteps(
+ ['do_action'],
+ "clicking on channel should redirect to channel form view"
+ );
+});
+
+QUnit.test('click on partner follower details', async function (assert) {
+ assert.expect(7);
+
+ const bus = new Bus();
+ bus.on('do-action', null, payload => {
+ assert.step('do_action');
+ assert.strictEqual(
+ payload.action.res_id,
+ 3,
+ "The redirect action should redirect to the right res id (3)"
+ );
+ assert.strictEqual(
+ payload.action.res_model,
+ 'res.partner',
+ "The redirect action should redirect to the right res model (res.partner)"
+ );
+ assert.strictEqual(
+ payload.action.type,
+ "ir.actions.act_window",
+ "The redirect action should be of type 'ir.actions.act_window'"
+ );
+ });
+
+ await this.start({
+ env: { bus },
+ async mockRPC(route, args) {
+ if (route === 'web/image/res.partner/3/image_128') {
+ return;
+ }
+ return this._super(...arguments);
+ },
+ });
+ const thread = this.env.models['mail.thread'].create({
+ id: 100,
+ model: 'res.partner',
+ });
+ const follower = await this.env.models['mail.follower'].create({
+ followedThread: [['link', thread]],
+ id: 2,
+ isActive: true,
+ isEditable: true,
+ partner: [['insert', {
+ email: "bla@bla.bla",
+ id: this.env.session.partner_id,
+ name: "François Perusse",
+ }]],
+ });
+ await this.createFollowerComponent(follower);
+ assert.containsOnce(
+ document.body,
+ '.o_Follower',
+ "should have follower component"
+ );
+ assert.containsOnce(
+ document.body,
+ '.o_Follower_details',
+ "should display a details part"
+ );
+
+ document.querySelector('.o_Follower_details').click();
+ assert.verifySteps(
+ ['do_action'],
+ "clicking on follower should redirect to partner form view"
+ );
+});
+
+QUnit.test('click on edit follower', async function (assert) {
+ assert.expect(5);
+
+ await this.start({
+ hasDialog: true,
+ async mockRPC(route, args) {
+ if (route === 'web/image/res.partner/3/image_128') {
+ return;
+ }
+ if (route.includes('/mail/read_subscription_data')) {
+ assert.step('fetch_subtypes');
+ return [{
+ default: true,
+ followed: true,
+ internal: false,
+ id: 1,
+ name: "Dummy test",
+ res_model: 'res.partner'
+ }];
+ }
+ return this._super(...arguments);
+ },
+ });
+ const thread = this.env.models['mail.thread'].create({
+ id: 100,
+ model: 'res.partner',
+ });
+ const follower = await this.env.models['mail.follower'].create({
+ followedThread: [['link', thread]],
+ id: 2,
+ isActive: true,
+ isEditable: true,
+ partner: [['insert', {
+ email: "bla@bla.bla",
+ id: this.env.session.partner_id,
+ name: "François Perusse",
+ }]],
+ });
+ await this.createFollowerComponent(follower);
+ assert.containsOnce(
+ document.body,
+ '.o_Follower',
+ "should have follower component"
+ );
+ assert.containsOnce(
+ document.body,
+ '.o_Follower_editButton',
+ "should display an edit button"
+ );
+
+ await afterNextRender(() => document.querySelector('.o_Follower_editButton').click());
+ assert.verifySteps(
+ ['fetch_subtypes'],
+ "clicking on edit follower should fetch subtypes"
+ );
+
+ assert.containsOnce(
+ document.body,
+ '.o_FollowerSubtypeList',
+ "A dialog allowing to edit follower subtypes should have been created"
+ );
+});
+
+});
+});
+});
+
+});
diff --git a/addons/mail/static/src/components/follower_list_menu/follower_list_menu.js b/addons/mail/static/src/components/follower_list_menu/follower_list_menu.js
new file mode 100644
index 00000000000..54eac6e284c
--- /dev/null
+++ b/addons/mail/static/src/components/follower_list_menu/follower_list_menu.js
@@ -0,0 +1,152 @@
+odoo.define('mail/static/src/components/follower_list_menu/follower_list_menu.js', function (require) {
+'use strict';
+
+const components = {
+ Follower: require('mail/static/src/components/follower/follower.js'),
+};
+const useStore = require('mail/static/src/component_hooks/use_store/use_store.js');
+
+const { Component } = owl;
+const { useRef, useState } = owl.hooks;
+
+class FollowerListMenu extends Component {
+ /**
+ * @override
+ */
+ constructor(...args) {
+ super(...args);
+ this.state = useState({
+ /**
+ * Determine whether the dropdown is open or not.
+ */
+ isDropdownOpen: false,
+ });
+ useStore(props => {
+ const thread = this.env.models['mail.thread'].get(props.threadLocalId);
+ const followers = thread ? thread.followers : [];
+ return {
+ followers: followers.map(follower => follower.__state),
+ thread: thread ? thread.__state : undefined,
+ };
+ }, {
+ compareDepth: {
+ followers: 1,
+ },
+ });
+ this._dropdownRef = useRef('dropdown');
+ this._onClickCaptureGlobal = this._onClickCaptureGlobal.bind(this);
+ }
+
+ mounted() {
+ document.addEventListener('click', this._onClickCaptureGlobal, true);
+ }
+
+ willUnmount() {
+ document.removeEventListener('click', this._onClickCaptureGlobal, true);
+ }
+
+ //--------------------------------------------------------------------------
+ // Public
+ //--------------------------------------------------------------------------
+
+ /**
+ * @return {mail.thread}
+ */
+ get thread() {
+ return this.env.models['mail.thread'].get(this.props.threadLocalId);
+ }
+
+ //--------------------------------------------------------------------------
+ // Private
+ //--------------------------------------------------------------------------
+
+ /**
+ * @private
+ */
+ _hide() {
+ this.state.isDropdownOpen = false;
+ }
+
+ /**
+ * @private
+ * @param {KeyboardEvent} ev
+ */
+ _onKeydown(ev) {
+ ev.stopPropagation();
+ switch (ev.key) {
+ case 'Escape':
+ ev.preventDefault();
+ this._hide();
+ break;
+ }
+ }
+
+ //--------------------------------------------------------------------------
+ // Handlers
+ //--------------------------------------------------------------------------
+
+ /**
+ * @private
+ * @param {MouseEvent} ev
+ */
+ _onClickAddChannels(ev) {
+ ev.preventDefault();
+ this._hide();
+ this.thread.promptAddChannelFollower();
+ }
+
+ /**
+ * @private
+ * @param {MouseEvent} ev
+ */
+ _onClickAddFollowers(ev) {
+ ev.preventDefault();
+ this._hide();
+ this.thread.promptAddPartnerFollower();
+ }
+
+ /**
+ * Close the dropdown when clicking outside of it.
+ *
+ * @private
+ * @param {MouseEvent} ev
+ */
+ _onClickCaptureGlobal(ev) {
+ // since dropdown is conditionally shown based on state, dropdownRef can be null
+ if (this._dropdownRef.el && !this._dropdownRef.el.contains(ev.target)) {
+ this._hide();
+ }
+ }
+
+ /**
+ * @private
+ * @param {MouseEvent} ev
+ */
+ _onClickFollowersButton(ev) {
+ this.state.isDropdownOpen = !this.state.isDropdownOpen;
+ }
+
+ /**
+ * @private
+ * @param {MouseEvent} ev
+ */
+ _onClickFollower(ev) {
+ this._hide();
+ }
+}
+
+Object.assign(FollowerListMenu, {
+ components,
+ defaultProps: {
+ isDisabled: false,
+ },
+ props: {
+ isDisabled: Boolean,
+ threadLocalId: String,
+ },
+ template: 'mail.FollowerListMenu',
+});
+
+return FollowerListMenu;
+
+});
diff --git a/addons/mail/static/src/components/follower_list_menu/follower_list_menu.scss b/addons/mail/static/src/components/follower_list_menu/follower_list_menu.scss
new file mode 100644
index 00000000000..2fa52819c59
--- /dev/null
+++ b/addons/mail/static/src/components/follower_list_menu/follower_list_menu.scss
@@ -0,0 +1,19 @@
+// ------------------------------------------------------------------
+// Layout
+// ------------------------------------------------------------------
+
+.o_FollowerListMenu {
+ position: relative;
+}
+
+.o_FollowerListMenu_dropdown {
+ display: flex;
+ flex-flow: column;
+ padding-top: map-get($spacers, 0);
+ padding-bottom: map-get($spacers, 0);
+ overflow-y: auto;
+}
+
+.o_FollowerListMenu_followers {
+ display: flex;
+}
diff --git a/addons/mail/static/src/components/follower_list_menu/follower_list_menu.xml b/addons/mail/static/src/components/follower_list_menu/follower_list_menu.xml
new file mode 100644
index 00000000000..5bf6c129dda
--- /dev/null
+++ b/addons/mail/static/src/components/follower_list_menu/follower_list_menu.xml
@@ -0,0 +1,36 @@
+<?xml version="1.0" encoding="UTF-8"?>
+<templates xml:space="preserve">
+
+ <t t-name="mail.FollowerListMenu" owl="1">
+ <div class="o_FollowerListMenu" t-on-keydown="_onKeydown">
+ <div class="o_FollowerListMenu_followers" t-ref="dropdown">
+ <button class="o_FollowerListMenu_buttonFollowers btn btn-link" t-att-disabled="props.isDisabled" t-on-click="_onClickFollowersButton" title="Show Followers">
+ <i class="fa fa-user"/>
+ <span class="o_FollowerListMenu_buttonFollowersCount pl-1" t-esc="thread.followers.length"/>
+ </button>
+
+ <t t-if="state.isDropdownOpen">
+ <div class="o_FollowerListMenu_dropdown dropdown-menu dropdown-menu-right" role="menu">
+ <a class="o_FollowerListMenu_addFollowersButton dropdown-item" href="#" role="menuitem" t-on-click="_onClickAddFollowers">
+ Add Followers
+ </a>
+ <a class="o_FollowerListMenu_addChannelsButton dropdown-item" href="#" role="menuitem" t-on-click="_onClickAddChannels">
+ Add Channels
+ </a>
+ <t t-if="thread.followers.length > 0">
+ <div role="separator" class="dropdown-divider"/>
+ <t t-foreach="thread.followers" t-as="follower" t-key="follower.localId">
+ <Follower
+ class="o_FollowerMenu_follower dropdown-item"
+ followerLocalId="follower.localId"
+ t-on-click="_onClickFollower"
+ />
+ </t>
+ </t>
+ </div>
+ </t>
+ </div>
+ </div>
+ </t>
+
+</templates>
diff --git a/addons/mail/static/src/components/follower_list_menu/follower_list_menu_tests.js b/addons/mail/static/src/components/follower_list_menu/follower_list_menu_tests.js
new file mode 100644
index 00000000000..b67c87398ed
--- /dev/null
+++ b/addons/mail/static/src/components/follower_list_menu/follower_list_menu_tests.js
@@ -0,0 +1,465 @@
+odoo.define('mail/static/src/components/follower_list_menu/follower_list_menu_tests.js', function (require) {
+'use strict';
+
+const components = {
+ FollowerListMenu: require('mail/static/src/components/follower_list_menu/follower_list_menu.js'),
+};
+const {
+ afterEach: utilsAfterEach,
+ afterNextRender,
+ beforeEach: utilsBeforeEach,
+ start: utilsStart,
+} = require('mail/static/src/utils/test_utils.js');
+
+const Bus = require('web.Bus');
+
+QUnit.module('mail', {}, function () {
+QUnit.module('components', {}, function () {
+QUnit.module('follower_list_menu', {}, function () {
+QUnit.module('follower_list_menu_tests.js', {
+ beforeEach() {
+ utilsBeforeEach(this);
+
+ this.createFollowerListMenuComponent = async (thread, otherProps = {}) => {
+ const FollowerListMenuComponent = components.FollowerListMenu;
+ FollowerListMenuComponent.env = this.env;
+ this.component = new FollowerListMenuComponent(null,
+ Object.assign(otherProps, { threadLocalId: thread.localId })
+ );
+ await this.component.mount(this.widget.el);
+ };
+
+ this.start = async params => {
+ let { env, widget } = await utilsStart(Object.assign({}, params, {
+ data: this.data,
+ }));
+ this.env = env;
+ this.widget = widget;
+ };
+ },
+ afterEach() {
+ utilsAfterEach(this);
+ if (this.component) {
+ this.component.destroy();
+ this.component = undefined;
+ }
+ if (this.widget) {
+ this.widget.destroy();
+ this.widget = undefined;
+ }
+ this.env = undefined;
+ delete components.FollowerListMenu.env;
+ },
+});
+
+QUnit.test('base rendering not editable', async function (assert) {
+ assert.expect(5);
+
+ await this.start();
+ const thread = this.env.models['mail.thread'].create({
+ id: 100,
+ model: 'res.partner',
+ });
+ await this.createFollowerListMenuComponent(thread, { isDisabled: true });
+ assert.containsOnce(
+ document.body,
+ '.o_FollowerListMenu',
+ "should have followers menu component"
+ );
+ assert.containsOnce(
+ document.body,
+ '.o_FollowerListMenu_buttonFollowers',
+ "should have followers button"
+ );
+ assert.ok(
+ document.querySelector('.o_FollowerListMenu_buttonFollowers').disabled,
+ "followers button should be disabled"
+ );
+ assert.containsNone(
+ document.body,
+ '.o_FollowerListMenu_dropdown',
+ "followers dropdown should not be opened"
+ );
+
+ document.querySelector('.o_FollowerListMenu_buttonFollowers').click();
+ assert.containsNone(
+ document.body,
+ '.o_FollowerListMenu_dropdown',
+ "followers dropdown should still be closed as button is disabled"
+ );
+});
+
+QUnit.test('base rendering editable', async function (assert) {
+ assert.expect(5);
+
+ await this.start();
+ const thread = this.env.models['mail.thread'].create({
+ id: 100,
+ model: 'res.partner',
+ });
+ await this.createFollowerListMenuComponent(thread);
+
+ assert.containsOnce(
+ document.body,
+ '.o_FollowerListMenu',
+ "should have followers menu component"
+ );
+ assert.containsOnce(
+ document.body,
+ '.o_FollowerListMenu_buttonFollowers',
+ "should have followers button"
+ );
+ assert.notOk(
+ document.querySelector('.o_FollowerListMenu_buttonFollowers').disabled,
+ "followers button should not be disabled"
+ );
+ assert.containsNone(
+ document.body,
+ '.o_FollowerListMenu_dropdown',
+ "followers dropdown should not be opened"
+ );
+
+ await afterNextRender(() => {
+ document.querySelector('.o_FollowerListMenu_buttonFollowers').click();
+ });
+ assert.containsOnce(
+ document.body,
+ '.o_FollowerListMenu_dropdown',
+ "followers dropdown should be opened"
+ );
+});
+
+QUnit.test('click on "add followers" button', async function (assert) {
+ assert.expect(18);
+
+ const followerIds = [];
+ const bus = new Bus();
+ bus.on('do-action', null, payload => {
+ assert.step('action:open_view');
+ assert.strictEqual(
+ payload.action.context.default_res_model,
+ 'res.partner',
+ "'The 'add followers' action should contain thread model in context'"
+ );
+ assert.notOk(
+ payload.action.context.mail_invite_follower_channel_only,
+ "The 'add followers' action should not be restricted to channels only"
+ );
+ assert.strictEqual(
+ payload.action.context.default_res_id,
+ 100,
+ "The 'add followers' action should contain thread id in context"
+ );
+ assert.strictEqual(
+ payload.action.res_model,
+ 'mail.wizard.invite',
+ "The 'add followers' action should be a wizard invite of mail module"
+ );
+ assert.strictEqual(
+ payload.action.type,
+ "ir.actions.act_window",
+ "The 'add followers' action should be of type 'ir.actions.act_window'"
+ );
+ followerIds.push(1);
+ payload.options.on_close();
+ });
+
+ await this.start({
+ env: { bus },
+ async mockRPC(route, args) {
+ if (route.includes('web/image/')) {
+ return;
+ } else if (route.includes('res.partner/read')) {
+ assert.step('rpc:read_follower_ids');
+ return [{
+ id: 100,
+ message_follower_ids: followerIds,
+ }];
+ } else if (route.includes('mail/read_followers')) {
+ assert.step('rpc:read_followers_details');
+ return {
+ followers: [{
+ partner_id: 42,
+ email: "bla@bla.bla",
+ id: 1,
+ is_active: true,
+ is_editable: true,
+ name: "François Perusse",
+ }]
+ };
+ }
+ return this._super(...arguments);
+ },
+ });
+ const thread = this.env.models['mail.thread'].create({
+ id: 100,
+ model: 'res.partner',
+ });
+ await this.createFollowerListMenuComponent(thread);
+
+ assert.containsOnce(
+ document.body,
+ '.o_FollowerListMenu',
+ "should have followers menu component"
+ );
+ assert.containsOnce(
+ document.body,
+ '.o_FollowerListMenu_buttonFollowers',
+ "should have followers button"
+ );
+ assert.strictEqual(
+ document.querySelector('.o_FollowerListMenu_buttonFollowersCount').textContent,
+ "0",
+ "Followers counter should be equal to 0"
+ );
+
+ await afterNextRender(() => {
+ document.querySelector('.o_FollowerListMenu_buttonFollowers').click();
+ });
+ assert.containsOnce(
+ document.body,
+ '.o_FollowerListMenu_dropdown',
+ "followers dropdown should be opened"
+ );
+ assert.containsOnce(
+ document.body,
+ '.o_FollowerListMenu_addFollowersButton',
+ "followers dropdown should contain a 'Add followers' button"
+ );
+
+ await afterNextRender(() => {
+ document.querySelector('.o_FollowerListMenu_addFollowersButton').click();
+ });
+ assert.containsNone(
+ document.body,
+ '.o_FollowerListMenu_dropdown',
+ "followers dropdown should be closed after click on 'Add followers'"
+ );
+ assert.verifySteps([
+ 'action:open_view',
+ 'rpc:read_follower_ids',
+ 'rpc:read_followers_details',
+ ]);
+ assert.strictEqual(
+ document.querySelector('.o_FollowerListMenu_buttonFollowersCount').textContent,
+ "1",
+ "Followers counter should now be equal to 1"
+ );
+
+ await afterNextRender(() => {
+ document.querySelector('.o_FollowerListMenu_buttonFollowers').click();
+ });
+ assert.containsOnce(
+ document.body,
+ '.o_FollowerMenu_follower',
+ "Follower list should be refreshed and contain a follower"
+ );
+ assert.strictEqual(
+ document.querySelector('.o_Follower_name').textContent,
+ "François Perusse",
+ "Follower added in follower list should be the one added"
+ );
+});
+
+QUnit.test('click on "add channels" button', async function (assert) {
+ assert.expect(18);
+
+ const followerIds = [];
+ const bus = new Bus();
+ bus.on('do-action', null, payload => {
+ assert.step('action:open_view');
+ assert.strictEqual(
+ payload.action.context.default_res_model,
+ 'res.partner',
+ "'The 'add channels' action should contain thread model in context'"
+ );
+ assert.ok(
+ payload.action.context.mail_invite_follower_channel_only,
+ "The 'add channels' action should be restricted to channels only"
+ );
+ assert.strictEqual(
+ payload.action.context.default_res_id,
+ 100,
+ "The 'add channels' action should contain thread id in context"
+ );
+ assert.strictEqual(
+ payload.action.res_model,
+ 'mail.wizard.invite',
+ "The 'add channels' action should be a wizard invite of mail module"
+ );
+ assert.strictEqual(
+ payload.action.type,
+ "ir.actions.act_window",
+ "The 'add channels' action should be of type 'ir.actions.act_window'"
+ );
+ followerIds.push(1);
+ payload.options.on_close();
+ });
+ await this.start({
+ env: { bus },
+ async mockRPC(route, args) {
+ if (route.includes('web/image/')) {
+ return;
+ } else if (route.includes('res.partner/read')) {
+ assert.step('rpc:read_follower_ids');
+ return [{
+ id: 100,
+ message_follower_ids: followerIds,
+ }];
+ } else if (route.includes('mail/read_followers')) {
+ assert.step('rpc:read_followers_details');
+ return {
+ followers: [{
+ channel_id: 42,
+ email: "bla@bla.bla",
+ id: 1,
+ is_active: true,
+ is_editable: true,
+ name: "Supa channel",
+ }]
+ };
+ }
+ return this._super(...arguments);
+ },
+ });
+ const thread = this.env.models['mail.thread'].create({
+ id: 100,
+ model: 'res.partner',
+ });
+ await this.createFollowerListMenuComponent(thread);
+
+ assert.containsOnce(
+ document.body,
+ '.o_FollowerListMenu',
+ "should have followers menu component"
+ );
+ assert.strictEqual(
+ document.querySelector('.o_FollowerListMenu_buttonFollowersCount').textContent,
+ "0",
+ "Followers counter should be equal to 0"
+ );
+ assert.containsOnce(
+ document.body,
+ '.o_FollowerListMenu_buttonFollowers',
+ "should have followers button"
+ );
+
+ await afterNextRender(() => {
+ document.querySelector('.o_FollowerListMenu_buttonFollowers').click();
+ });
+ assert.containsOnce(
+ document.body,
+ '.o_FollowerListMenu_dropdown',
+ "followers dropdown should be opened"
+ );
+ assert.containsOnce(
+ document.body,
+ '.o_FollowerListMenu_addChannelsButton',
+ "followers dropdown should contain a 'Add channels' button"
+ );
+
+ await afterNextRender(() => {
+ document.querySelector('.o_FollowerListMenu_addChannelsButton').click();
+ });
+ assert.containsNone(
+ document.body,
+ '.o_FollowerListMenu_dropdown',
+ "followers dropdown should be closed after click on 'add channels'"
+ );
+ assert.verifySteps([
+ 'action:open_view',
+ 'rpc:read_follower_ids',
+ 'rpc:read_followers_details',
+ ]);
+ assert.strictEqual(
+ document.querySelector('.o_FollowerListMenu_buttonFollowersCount').textContent,
+ "1",
+ "Followers counter should now be equal to 1"
+ );
+
+ await afterNextRender(() => {
+ document.querySelector('.o_FollowerListMenu_buttonFollowers').click();
+ });
+ assert.containsOnce(
+ document.body,
+ '.o_FollowerMenu_follower',
+ "Follower list should be refreshed and contain a follower"
+ );
+ assert.strictEqual(
+ document.querySelector('.o_Follower_name').textContent,
+ "Supa channel",
+ "Follower added in follower list should be the one added"
+ );
+});
+
+QUnit.test('click on remove follower', async function (assert) {
+ assert.expect(6);
+
+ const self = this;
+ await this.start({
+ async mockRPC(route, args) {
+ if (route.includes('web/image/')) {
+ return;
+ } else if (route.includes('message_unsubscribe')) {
+ assert.step('message_unsubscribe');
+ // FIXME "integration" test, should be replaced ... maybe by a tour.
+ assert.deepEqual(
+ args.args,
+ [[100],[self.env.session.partner_id]],
+ "message unsubscribe should be called with right argument"
+ );
+ return;
+ }
+ return this._super(...arguments);
+ },
+ });
+ const thread = this.env.models['mail.thread'].create({
+ id: 100,
+ model: 'res.partner',
+ });
+ await this.env.models['mail.follower'].create({
+ followedThread: [['link', thread]],
+ id: 2,
+ isActive: true,
+ isEditable: true,
+ partner: [['insert', {
+ email: "bla@bla.bla",
+ id: this.env.session.partner_id,
+ name: "François Perusse",
+ }]],
+ });
+ await this.createFollowerListMenuComponent(thread);
+
+ await afterNextRender(() => {
+ document.querySelector('.o_FollowerListMenu_buttonFollowers').click();
+ });
+ assert.containsOnce(
+ document.body,
+ '.o_Follower',
+ "should have follower component"
+ );
+ assert.containsOnce(
+ document.body,
+ '.o_Follower_removeButton',
+ "should display a remove button"
+ );
+
+ await afterNextRender(() => {
+ document.querySelector('.o_Follower_removeButton').click();
+ });
+ assert.verifySteps(
+ ['message_unsubscribe'],
+ "clicking on remove button should call 'message_unsubscribe' route"
+ );
+ assert.containsNone(
+ document.body,
+ '.o_Follower',
+ "should no more have follower component"
+ );
+});
+
+});
+});
+});
+
+});
diff --git a/addons/mail/static/src/components/follower_subtype/follower_subtype.js b/addons/mail/static/src/components/follower_subtype/follower_subtype.js
new file mode 100644
index 00000000000..8ff47ada34f
--- /dev/null
+++ b/addons/mail/static/src/components/follower_subtype/follower_subtype.js
@@ -0,0 +1,69 @@
+odoo.define('mail/static/src/components/follower_subtype/follower_subtype.js', function (require) {
+'use strict';
+
+const useStore = require('mail/static/src/component_hooks/use_store/use_store.js');
+
+const { Component } = owl;
+
+class FollowerSubtype extends Component {
+
+ /**
+ * @override
+ */
+ constructor(...args) {
+ super(...args);
+ useStore(props => {
+ const followerSubtype = this.env.models['mail.follower_subtype'].get(props.followerSubtypeLocalId);
+ return [followerSubtype ? followerSubtype.__state : undefined];
+ });
+ }
+
+ //--------------------------------------------------------------------------
+ // Public
+ //--------------------------------------------------------------------------
+
+ /**
+ * @returns {mail.follower|undefined}
+ */
+ get follower() {
+ return this.env.models['mail.follower'].get(this.props.followerLocalId);
+ }
+
+ /**
+ * @returns {mail.follower_subtype}
+ */
+ get followerSubtype() {
+ return this.env.models['mail.follower_subtype'].get(this.props.followerSubtypeLocalId);
+ }
+
+ //--------------------------------------------------------------------------
+ // Handlers
+ //--------------------------------------------------------------------------
+
+ /**
+ * Called when clicking on cancel button.
+ *
+ * @private
+ * @param {Event} ev
+ */
+ _onChangeCheckbox(ev) {
+ if (ev.target.checked) {
+ this.follower.selectSubtype(this.followerSubtype);
+ } else {
+ this.follower.unselectSubtype(this.followerSubtype);
+ }
+ }
+
+}
+
+Object.assign(FollowerSubtype, {
+ props: {
+ followerLocalId: String,
+ followerSubtypeLocalId: String,
+ },
+ template: 'mail.FollowerSubtype',
+});
+
+return FollowerSubtype;
+
+});
diff --git a/addons/mail/static/src/components/follower_subtype/follower_subtype.scss b/addons/mail/static/src/components/follower_subtype/follower_subtype.scss
new file mode 100644
index 00000000000..3be0ad4656e
--- /dev/null
+++ b/addons/mail/static/src/components/follower_subtype/follower_subtype.scss
@@ -0,0 +1,27 @@
+// ------------------------------------------------------------------
+// Layout
+// ------------------------------------------------------------------
+
+.o_FollowerSubtype_checkbox {
+ margin-inline-end: map-get($spacers, 2);
+}
+
+.o_FollowerSubtype_label {
+ display: flex;
+ flex: 1;
+ flex-direction: row;
+ align-items: center;
+ margin-bottom: map-get($spacers, 0);
+ padding: map-get($spacers, 2);
+}
+
+// ------------------------------------------------------------------
+// Style
+// ------------------------------------------------------------------
+
+.o_FollowerSubtype_label {
+ cursor: pointer;
+ &:hover {
+ background-color: gray('200');
+ }
+}
diff --git a/addons/mail/static/src/components/follower_subtype/follower_subtype.xml b/addons/mail/static/src/components/follower_subtype/follower_subtype.xml
new file mode 100644
index 00000000000..b23800099d6
--- /dev/null
+++ b/addons/mail/static/src/components/follower_subtype/follower_subtype.xml
@@ -0,0 +1,13 @@
+<?xml version="1.0" encoding="UTF-8"?>
+<templates xml:space="preserve">
+
+ <t t-name="mail.FollowerSubtype" owl="1">
+ <div class="o_FollowerSubtype">
+ <label class="o_FollowerSubtype_label">
+ <input class="o_FollowerSubtype_checkbox" type="checkbox" t-att-checked="follower.selectedSubtypes.includes(followerSubtype) ? 'checked': ''" t-on-change="_onChangeCheckbox"/>
+ <t t-esc="followerSubtype.name"/>
+ </label>
+ </div>
+ </t>
+
+</templates>
diff --git a/addons/mail/static/src/components/follower_subtype/follower_subtype_tests.js b/addons/mail/static/src/components/follower_subtype/follower_subtype_tests.js
new file mode 100644
index 00000000000..ac5baa13ec3
--- /dev/null
+++ b/addons/mail/static/src/components/follower_subtype/follower_subtype_tests.js
@@ -0,0 +1,241 @@
+odoo.define('mail/static/src/components/follower_subtype/follower_subtype_tests.js', function (require) {
+'use strict';
+
+const components = {
+ FollowerSubtype: require('mail/static/src/components/follower_subtype/follower_subtype.js'),
+};
+const {
+ afterEach: utilsAfterEach,
+ afterNextRender,
+ beforeEach: utilsBeforeEach,
+ start: utilsStart,
+} = require('mail/static/src/utils/test_utils.js');
+
+QUnit.module('mail', {}, function () {
+QUnit.module('components', {}, function () {
+QUnit.module('follower_subtype', {}, function () {
+QUnit.module('follower_subtype_tests.js', {
+ beforeEach() {
+ utilsBeforeEach(this);
+
+ this.createFollowerSubtypeComponent = async ({ follower, followerSubtype }) => {
+ const FollowerSubtypeComponent = components.FollowerSubtype;
+ FollowerSubtypeComponent.env = this.env;
+ this.component = new FollowerSubtypeComponent(null, {
+ followerLocalId: follower.localId,
+ followerSubtypeLocalId: followerSubtype.localId,
+ });
+ await this.component.mount(this.widget.el);
+ };
+
+ this.start = async params => {
+ let { env, widget } = await utilsStart(Object.assign({}, params, {
+ data: this.data,
+ }));
+ this.env = env;
+ this.widget = widget;
+ };
+ },
+ afterEach() {
+ utilsAfterEach(this);
+ if (this.component) {
+ this.component.destroy();
+ this.component = undefined;
+ }
+ if (this.widget) {
+ this.widget.destroy();
+ this.widget = undefined;
+ }
+ this.env = undefined;
+ delete components.FollowerSubtype.env;
+ }
+});
+
+QUnit.test('simplest layout of a followed subtype', async function (assert) {
+ assert.expect(5);
+
+ await this.start();
+
+ const thread = this.env.models['mail.thread'].create({
+ id: 100,
+ model: 'res.partner',
+ });
+ const follower = this.env.models['mail.follower'].create({
+ channel: [['insert', {
+ id: 1,
+ model: 'mail.channel',
+ name: "François Perusse",
+ }]],
+ followedThread: [['link', thread]],
+ id: 2,
+ isActive: true,
+ isEditable: true,
+ });
+ const followerSubtype = this.env.models['mail.follower_subtype'].create({
+ id: 1,
+ isDefault: true,
+ isInternal: false,
+ name: "Dummy test",
+ resModel: 'res.partner'
+ });
+ follower.update({
+ selectedSubtypes: [['link', followerSubtype]],
+ subtypes: [['link', followerSubtype]],
+ });
+ await this.createFollowerSubtypeComponent({
+ follower,
+ followerSubtype,
+ });
+ assert.containsOnce(
+ document.body,
+ '.o_FollowerSubtype',
+ "should have follower subtype component"
+ );
+ assert.containsOnce(
+ document.body,
+ '.o_FollowerSubtype_label',
+ "should have a label"
+ );
+ assert.containsOnce(
+ document.body,
+ '.o_FollowerSubtype_checkbox',
+ "should have a checkbox"
+ );
+ assert.strictEqual(
+ document.querySelector('.o_FollowerSubtype_label').textContent,
+ "Dummy test",
+ "should have the name of the subtype as label"
+ );
+ assert.ok(
+ document.querySelector('.o_FollowerSubtype_checkbox').checked,
+ "checkbox should be checked as follower subtype is followed"
+ );
+});
+
+QUnit.test('simplest layout of a not followed subtype', async function (assert) {
+ assert.expect(5);
+
+ await this.start();
+
+ const thread = this.env.models['mail.thread'].create({
+ id: 100,
+ model: 'res.partner',
+ });
+ const follower = this.env.models['mail.follower'].create({
+ channel: [['insert', {
+ id: 1,
+ model: 'mail.channel',
+ name: "François Perusse",
+ }]],
+ followedThread: [['link', thread]],
+ id: 2,
+ isActive: true,
+ isEditable: true,
+ });
+ const followerSubtype = this.env.models['mail.follower_subtype'].create({
+ id: 1,
+ isDefault: true,
+ isInternal: false,
+ name: "Dummy test",
+ resModel: 'res.partner'
+ });
+ follower.update({ subtypes: [['link', followerSubtype]] });
+ await this.createFollowerSubtypeComponent({
+ follower,
+ followerSubtype,
+ });
+ assert.containsOnce(
+ document.body,
+ '.o_FollowerSubtype',
+ "should have follower subtype component"
+ );
+ assert.containsOnce(
+ document.body,
+ '.o_FollowerSubtype_label',
+ "should have a label"
+ );
+ assert.containsOnce(
+ document.body,
+ '.o_FollowerSubtype_checkbox',
+ "should have a checkbox"
+ );
+ assert.strictEqual(
+ document.querySelector('.o_FollowerSubtype_label').textContent,
+ "Dummy test",
+ "should have the name of the subtype as label"
+ );
+ assert.notOk(
+ document.querySelector('.o_FollowerSubtype_checkbox').checked,
+ "checkbox should not be checked as follower subtype is not followed"
+ );
+});
+
+QUnit.test('toggle follower subtype checkbox', async function (assert) {
+ assert.expect(5);
+
+ await this.start();
+
+ const thread = this.env.models['mail.thread'].create({
+ id: 100,
+ model: 'res.partner',
+ });
+ const follower = this.env.models['mail.follower'].create({
+ channel: [['insert', {
+ id: 1,
+ model: 'mail.channel',
+ name: "François Perusse",
+ }]],
+ followedThread: [['link', thread]],
+ id: 2,
+ isActive: true,
+ isEditable: true,
+ });
+ const followerSubtype = this.env.models['mail.follower_subtype'].create({
+ id: 1,
+ isDefault: true,
+ isInternal: false,
+ name: "Dummy test",
+ resModel: 'res.partner'
+ });
+ follower.update({ subtypes: [['link', followerSubtype]] });
+ await this.createFollowerSubtypeComponent({
+ follower,
+ followerSubtype,
+ });
+ assert.containsOnce(
+ document.body,
+ '.o_FollowerSubtype',
+ "should have follower subtype component"
+ );
+ assert.containsOnce(
+ document.body,
+ '.o_FollowerSubtype_checkbox',
+ "should have a checkbox"
+ );
+ assert.notOk(
+ document.querySelector('.o_FollowerSubtype_checkbox').checked,
+ "checkbox should not be checked as follower subtype is not followed"
+ );
+
+ await afterNextRender(() =>
+ document.querySelector('.o_FollowerSubtype_checkbox').click()
+ );
+ assert.ok(
+ document.querySelector('.o_FollowerSubtype_checkbox').checked,
+ "checkbox should now be checked"
+ );
+
+ await afterNextRender(() =>
+ document.querySelector('.o_FollowerSubtype_checkbox').click()
+ );
+ assert.notOk(
+ document.querySelector('.o_FollowerSubtype_checkbox').checked,
+ "checkbox should be no more checked"
+ );
+});
+
+});
+});
+});
+
+});
diff --git a/addons/mail/static/src/components/follower_subtype_list/follower_subtype_list.js b/addons/mail/static/src/components/follower_subtype_list/follower_subtype_list.js
new file mode 100644
index 00000000000..851382861ea
--- /dev/null
+++ b/addons/mail/static/src/components/follower_subtype_list/follower_subtype_list.js
@@ -0,0 +1,87 @@
+odoo.define('mail/static/src/components/follower_subtype_list/follower_subtype_list.js', function (require) {
+'use strict';
+
+const components = {
+ FollowerSubtype: require('mail/static/src/components/follower_subtype/follower_subtype.js'),
+};
+const useStore = require('mail/static/src/component_hooks/use_store/use_store.js');
+
+const { Component, QWeb } = owl;
+
+class FollowerSubtypeList extends Component {
+
+ /**
+ * @override
+ */
+ constructor(...args) {
+ super(...args);
+ useStore(props => {
+ const followerSubtypeList = this.env.models['mail.follower_subtype_list'].get(props.localId);
+ const follower = followerSubtypeList
+ ? followerSubtypeList.follower
+ : undefined;
+ const followerSubtypes = follower ? follower.subtypes : [];
+ return {
+ follower: follower ? follower.__state : undefined,
+ followerSubtypeList: followerSubtypeList
+ ? followerSubtypeList.__state
+ : undefined,
+ followerSubtypes: followerSubtypes.map(subtype => subtype.__state),
+ };
+ }, {
+ compareDepth: {
+ followerSubtypes: 1,
+ },
+ });
+ }
+
+ //--------------------------------------------------------------------------
+ // Public
+ //--------------------------------------------------------------------------
+
+ /**
+ * @returns {mail.follower_subtype_list}
+ */
+ get followerSubtypeList() {
+ return this.env.models['mail.follower_subtype_list'].get(this.props.localId);
+ }
+
+ //--------------------------------------------------------------------------
+ // Handlers
+ //--------------------------------------------------------------------------
+
+ /**
+ * Called when clicking on cancel button.
+ *
+ * @private
+ * @param {MouseEvent} ev
+ */
+ _onClickCancel(ev) {
+ this.followerSubtypeList.follower.closeSubtypes();
+ }
+
+ /**
+ * Called when clicking on apply button.
+ *
+ * @private
+ * @param {MouseEvent} ev
+ */
+ _onClickApply(ev) {
+ this.followerSubtypeList.follower.updateSubtypes();
+ }
+
+}
+
+Object.assign(FollowerSubtypeList, {
+ components,
+ props: {
+ localId: String,
+ },
+ template: 'mail.FollowerSubtypeList',
+});
+
+QWeb.registerComponent('FollowerSubtypeList', FollowerSubtypeList);
+
+return FollowerSubtypeList;
+
+});
diff --git a/addons/mail/static/src/components/follower_subtype_list/follower_subtype_list.scss b/addons/mail/static/src/components/follower_subtype_list/follower_subtype_list.scss
new file mode 100644
index 00000000000..82aef9fc974
--- /dev/null
+++ b/addons/mail/static/src/components/follower_subtype_list/follower_subtype_list.scss
@@ -0,0 +1,8 @@
+// ------------------------------------------------------------------
+// Layout
+// ------------------------------------------------------------------
+
+.o_FollowerSubtypeList_subtypes {
+ display: flex;
+ flex-flow: column;
+}
diff --git a/addons/mail/static/src/components/follower_subtype_list/follower_subtype_list.xml b/addons/mail/static/src/components/follower_subtype_list/follower_subtype_list.xml
new file mode 100644
index 00000000000..9ab73328a9e
--- /dev/null
+++ b/addons/mail/static/src/components/follower_subtype_list/follower_subtype_list.xml
@@ -0,0 +1,38 @@
+<?xml version="1.0" encoding="UTF-8"?>
+<templates xml:space="preserve">
+
+ <t t-name="mail.FollowerSubtypeList" owl="1">
+ <div class="o_FollowerSubtypeList modal-dialog">
+ <t t-if="followerSubtypeList">
+ <div class="modal-content">
+ <header class="modal-header">
+ <h4 class="modal-title">
+ Edit Subscription of <t t-esc="followerSubtypeList.follower.name"/>
+ </h4>
+ <i class="close fa fa-times" aria-label="Close" t-on-click="_onClickCancel"/>
+ </header>
+ <main class="modal-body">
+ <div class="o_FollowerSubtypeList_subtypes">
+ <t t-foreach="followerSubtypeList.follower.subtypes" t-as="subtype" t-key="subtype.id">
+ <FollowerSubtype
+ class="o_FollowerSubtypeList_subtype"
+ followerLocalId="followerSubtypeList.follower.localId"
+ followerSubtypeLocalId="subtype.localId"
+ />
+ </t>
+ </div>
+ </main>
+ <div class="modal-footer">
+ <button class="o-apply btn btn-primary" t-on-click="_onClickApply">
+ Apply
+ </button>
+ <button class="o-cancel btn btn-secondary" t-on-click="_onClickCancel">
+ Cancel
+ </button>
+ </div>
+ </div>
+ </t>
+ </div>
+ </t>
+
+</templates>
diff --git a/addons/mail/static/src/components/mail_template/mail_template.js b/addons/mail/static/src/components/mail_template/mail_template.js
new file mode 100644
index 00000000000..45dc8c344c6
--- /dev/null
+++ b/addons/mail/static/src/components/mail_template/mail_template.js
@@ -0,0 +1,79 @@
+odoo.define('mail/static/src/components/mail_template/mail_template.js', function (require) {
+'use strict';
+
+const useStore = require('mail/static/src/component_hooks/use_store/use_store.js');
+
+const { Component } = owl;
+
+class MailTemplate extends Component {
+
+ /**
+ * @override
+ */
+ constructor(...args) {
+ super(...args);
+ useStore(props => {
+ const activity = this.env.models['mail.activity'].get(props.activityLocalId);
+ const mailTemplate = this.env.models['mail.mail_template'].get(props.mailTemplateLocalId);
+ return {
+ activity: activity ? activity.__state : undefined,
+ mailTemplate: mailTemplate ? mailTemplate.__state : undefined,
+ };
+ });
+ }
+
+ //--------------------------------------------------------------------------
+ // Public
+ //--------------------------------------------------------------------------
+
+ /**
+ * @returns {mail.activity}
+ */
+ get activity() {
+ return this.env.models['mail.activity'].get(this.props.activityLocalId);
+ }
+
+ /**
+ * @returns {mail.mail_template}
+ */
+ get mailTemplate() {
+ return this.env.models['mail.mail_template'].get(this.props.mailTemplateLocalId);
+ }
+
+ //--------------------------------------------------------------------------
+ // Handlers
+ //--------------------------------------------------------------------------
+
+ /**
+ * @private
+ * @param {MouseEvent} ev
+ */
+ _onClickPreview(ev) {
+ ev.stopPropagation();
+ ev.preventDefault();
+ this.mailTemplate.preview(this.activity);
+ }
+
+ /**
+ * @private
+ * @param {MouseEvent} ev
+ */
+ _onClickSend(ev) {
+ ev.stopPropagation();
+ ev.preventDefault();
+ this.mailTemplate.send(this.activity);
+ }
+
+}
+
+Object.assign(MailTemplate, {
+ props: {
+ activityLocalId: String,
+ mailTemplateLocalId: String,
+ },
+ template: 'mail.MailTemplate',
+});
+
+return MailTemplate;
+
+});
diff --git a/addons/mail/static/src/components/mail_template/mail_template.scss b/addons/mail/static/src/components/mail_template/mail_template.scss
new file mode 100644
index 00000000000..7800ab62bc5
--- /dev/null
+++ b/addons/mail/static/src/components/mail_template/mail_template.scss
@@ -0,0 +1,27 @@
+// ------------------------------------------------------------------
+// Layout
+// ------------------------------------------------------------------
+
+.o_MailTemplate {
+ display: flex;
+ flex: 0 0 auto;
+ align-items: center;
+}
+
+.o_MailTemplate_button {
+ padding-top: map-get($spacers, 0);
+ padding-bottom: map-get($spacers, 0);
+}
+
+.o_MailTemplate_name {
+ margin-inline-start: map-get($spacers, 2);
+}
+
+// ------------------------------------------------------------------
+// Style
+// ------------------------------------------------------------------
+
+.o_MailTemplate_text {
+ color: gray('500');
+ font-style: italic;
+}
diff --git a/addons/mail/static/src/components/mail_template/mail_template.xml b/addons/mail/static/src/components/mail_template/mail_template.xml
new file mode 100644
index 00000000000..48f7c050fcc
--- /dev/null
+++ b/addons/mail/static/src/components/mail_template/mail_template.xml
@@ -0,0 +1,29 @@
+<?xml version="1.0" encoding="UTF-8"?>
+<templates xml:space="preserve">
+
+ <t t-name="mail.MailTemplate" owl="1">
+ <div class="o_MailTemplate">
+ <t t-if="mailTemplate">
+ <i class="fa fa-envelope-o" title="Mail" role="img"/>
+ <span class="o_MailTemplate_name" t-esc="mailTemplate.name"/>
+ <span>:</span>
+ <button
+ class="o_MailTemplate_button o_MailTemplate_preview btn btn-link"
+ t-att-data-mail-template-id="mailTemplate.id"
+ t-on-click="_onClickPreview"
+ >
+ Preview
+ </button>
+ <span class="o_MailTemplate_text">or</span>
+ <button
+ class="o_MailTemplate_button o_MailTemplate_send btn btn-link"
+ t-att-data-mail-template-id="mailTemplate.id"
+ t-on-click="_onClickSend"
+ >
+ Send Now
+ </button>
+ </t>
+ </div>
+ </t>
+
+</templates>
diff --git a/addons/mail/static/src/components/message/message.js b/addons/mail/static/src/components/message/message.js
new file mode 100644
index 00000000000..f4940a1e566
--- /dev/null
+++ b/addons/mail/static/src/components/message/message.js
@@ -0,0 +1,621 @@
+odoo.define('mail/static/src/components/message/message.js', function (require) {
+'use strict';
+
+const components = {
+ AttachmentList: require('mail/static/src/components/attachment_list/attachment_list.js'),
+ MessageSeenIndicator: require('mail/static/src/components/message_seen_indicator/message_seen_indicator.js'),
+ ModerationBanDialog: require('mail/static/src/components/moderation_ban_dialog/moderation_ban_dialog.js'),
+ ModerationDiscardDialog: require('mail/static/src/components/moderation_discard_dialog/moderation_discard_dialog.js'),
+ ModerationRejectDialog: require('mail/static/src/components/moderation_reject_dialog/moderation_reject_dialog.js'),
+ NotificationPopover: require('mail/static/src/components/notification_popover/notification_popover.js'),
+ PartnerImStatusIcon: require('mail/static/src/components/partner_im_status_icon/partner_im_status_icon.js'),
+};
+const useStore = require('mail/static/src/component_hooks/use_store/use_store.js');
+const { timeFromNow } = require('mail.utils');
+
+const { _lt } = require('web.core');
+const { getLangDatetimeFormat } = require('web.time');
+
+const { Component, useState } = owl;
+const { useRef } = owl.hooks;
+
+const READ_MORE = _lt("read more");
+const READ_LESS = _lt("read less");
+
+class Message extends Component {
+
+ /**
+ * @override
+ */
+ constructor(...args) {
+ super(...args);
+ this.state = useState({
+ // Determine if the moderation ban dialog is displayed.
+ hasModerationBanDialog: false,
+ // Determine if the moderation discard dialog is displayed.
+ hasModerationDiscardDialog: false,
+ // Determine if the moderation reject dialog is displayed.
+ hasModerationRejectDialog: false,
+ /**
+ * Determine whether the message is clicked. When message is in
+ * clicked state, it keeps displaying the commands.
+ */
+ isClicked: false,
+ /**
+ * Time elapsed from message datetime to current datetime.
+ */
+ timeElapsed: null,
+ });
+ useStore(props => {
+ const message = this.env.models['mail.message'].get(props.messageLocalId);
+ const author = message ? message.author : undefined;
+ const partnerRoot = this.env.messaging.partnerRoot;
+ const originThread = message ? message.originThread : undefined;
+ const threadViewer = this.env.models['mail.thread_viewer'].get(props.threadViewerLocalId);
+ const thread = threadViewer ? threadViewer.thread : undefined;
+ const threadStringifiedDomain = threadViewer
+ ? threadViewer.stringifiedDomain
+ : undefined;
+ return {
+ attachments: message
+ ? message.attachments.map(attachment => attachment.__state)
+ : undefined,
+ author: author ? author.__state : undefined,
+ hasMessageCheckbox: message ? message.hasCheckbox : false,
+ isDeviceMobile: this.env.messaging.device.isMobile,
+ isMessageChecked: message && threadViewer
+ ? message.isChecked(thread, threadStringifiedDomain)
+ : false,
+ message: message ? message.__state : undefined,
+ notifications: message ? message.notifications.map(notif => notif.__state) : [],
+ originThread: originThread ? originThread.__state : undefined,
+ partnerRoot: partnerRoot ? partnerRoot.__state : undefined,
+ thread: thread ? thread.__state : undefined,
+ threadViewer: threadViewer ? threadViewer.__state : undefined,
+ };
+ }, {
+ compareDepth: {
+ notifications: 1,
+ },
+ });
+ /**
+ * Reference to the content of the message.
+ */
+ this._contentRef = useRef('content');
+ /**
+ * To get checkbox state.
+ */
+ this._checkboxRef = useRef('checkbox');
+ /**
+ * Id of setInterval used to auto-update time elapsed of message at
+ * regular time.
+ */
+ this._intervalId = undefined;
+ this._constructor();
+ }
+
+ /**
+ * Allows patching constructor.
+ */
+ _constructor() {}
+
+ mounted() {
+ this._insertReadMoreLess($(this._contentRef.el));
+ this._update();
+ }
+
+ patched() {
+ this._update();
+ }
+
+ willUnmount() {
+ clearInterval(this._intervalId);
+ }
+
+ //--------------------------------------------------------------------------
+ // Public
+ //--------------------------------------------------------------------------
+
+ /**
+ * @returns {string}
+ */
+ get avatar() {
+ if (
+ this.message.author &&
+ this.message.author === this.env.messaging.partnerRoot
+ ) {
+ return '/mail/static/src/img/odoobot.png';
+ } else if (this.message.author) {
+ // TODO FIXME for public user this might not be accessible. task-2223236
+ // we should probably use the correspondig attachment id + access token
+ // or create a dedicated route to get message image, checking the access right of the message
+ return `/web/image/res.partner/${this.message.author.id}/image_128`;
+ } else if (this.message.message_type === 'email') {
+ return '/mail/static/src/img/email_icon.png';
+ }
+ return '/mail/static/src/img/smiley/avatar.jpg';
+ }
+
+ /**
+ * Get the date time of the message at current user locale time.
+ *
+ * @returns {string}
+ */
+ get datetime() {
+ return this.message.date.format(getLangDatetimeFormat());
+ }
+
+ /**
+ * Determine whether author redirect feature is enabled on message.
+ * Click on message author should redirect to author.
+ *
+ * @returns {boolean}
+ */
+ get hasAuthorRedirect() {
+ if (!this.props.hasAuthorRedirect) {
+ return false;
+ }
+ if (!this.message.author) {
+ return false;
+ }
+ if (this.message.author === this.env.messaging.currentPartner) {
+ return false;
+ }
+ return true;
+ }
+
+ /**
+ * Determine whether the message origin thread is the same as the context
+ * of displaying this message. In other word, if the enclosing thread
+ * component of this message component is linked to the origin thread of
+ * this message, then the origin is the same.
+ *
+ * @returns {boolean}
+ */
+ get hasDifferentOriginThread() {
+ if (!this.threadViewer) {
+ return false;
+ }
+ if (!this.message.originThread) {
+ return false;
+ }
+ return this.message.originThread !== this.threadViewer.thread;
+ }
+
+ /**
+ * @returns {mail.attachment[]}
+ */
+ get imageAttachments() {
+ return this.message.attachments.filter(attachment => attachment.fileType === 'image');
+ }
+
+ /**
+ * Tell whether the bottom of this message is visible or not.
+ *
+ * @param {Object} param0
+ * @param {integer} [offset=0]
+ * @returns {boolean}
+ */
+ isBottomVisible({ offset=0 } = {}) {
+ if (!this.el) {
+ return false;
+ }
+ const elRect = this.el.getBoundingClientRect();
+ if (!this.el.parentNode) {
+ return false;
+ }
+ const parentRect = this.el.parentNode.getBoundingClientRect();
+ // bottom with (double) 10px offset
+ return (
+ elRect.bottom < parentRect.bottom + offset &&
+ parentRect.top < elRect.bottom + offset
+ );
+ }
+
+ /**
+ * Tell whether the message is partially visible on browser window or not.
+ *
+ * @returns {boolean}
+ */
+ isPartiallyVisible() {
+ const elRect = this.el.getBoundingClientRect();
+ if (!this.el.parentNode) {
+ return false;
+ }
+ const parentRect = this.el.parentNode.getBoundingClientRect();
+ // intersection with 5px offset
+ return (
+ elRect.top < parentRect.bottom + 5 &&
+ parentRect.top < elRect.bottom + 5
+ );
+ }
+
+ /**
+ * Determine whether the message is starred.
+ *
+ * @returns {boolean}
+ */
+ get isStarred() {
+ return this.message.allThreads.includes(this.env.messaging.starred);
+ }
+
+ /**
+ * @returns {mail.message}
+ */
+ get message() {
+ return this.env.models['mail.message'].get(this.props.messageLocalId);
+ }
+
+ /**
+ * @returns {mail.attachment[]}
+ */
+ get nonImageAttachments() {
+ return this.message.attachments.filter(attachment => attachment.fileType !== 'image');
+ }
+
+ /**
+ * Make this message viewable in its enclosing scroll environment (usually
+ * message list).
+ *
+ * @param {Object} [param0={}]
+ * @param {string} [param0.behavior='auto']
+ * @param {string} [param0.block='end']
+ * @returns {Promise}
+ */
+ async scrollIntoView({ behavior='auto', block='end' }={}) {
+ this.el.scrollIntoView({
+ behavior,
+ block,
+ inline: 'nearest',
+ });
+ if (behavior === 'smooth') {
+ return new Promise(resolve => setTimeout(resolve, 500));
+ } else {
+ return Promise.resolve();
+ }
+ }
+
+ /**
+ * Get the shorttime format of the message date.
+ *
+ * @returns {string}
+ */
+ get shortTime() {
+ return this.message.date.format('hh:mm');
+ }
+
+ /**
+ * @returns {mail.thread_viewer}
+ */
+ get threadViewer() {
+ return this.env.models['mail.thread_viewer'].get(this.props.threadViewerLocalId);
+ }
+
+ /**
+ * @returns {Object}
+ */
+ get trackingValues() {
+ return this.message.tracking_value_ids.map(trackingValue => {
+ const value = Object.assign({}, trackingValue);
+ value.changed_field = _.str.sprintf(this.env._t("%s:"), value.changed_field);
+ if (value.field_type === 'datetime') {
+ if (value.old_value) {
+ value.old_value =
+ moment.utc(value.old_value).local().format('LLL');
+ }
+ if (value.new_value) {
+ value.new_value =
+ moment.utc(value.new_value).local().format('LLL');
+ }
+ } else if (value.field_type === 'date') {
+ if (value.old_value) {
+ value.old_value =
+ moment(value.old_value).local().format('LL');
+ }
+ if (value.new_value) {
+ value.new_value =
+ moment(value.new_value).local().format('LL');
+ }
+ }
+ return value;
+ });
+ }
+
+ //--------------------------------------------------------------------------
+ // Private
+ //--------------------------------------------------------------------------
+
+ /**
+ * Modifies the message to add the 'read more/read less' functionality
+ * All element nodes with 'data-o-mail-quote' attribute are concerned.
+ * All text nodes after a ``#stopSpelling`` element are concerned.
+ * Those text nodes need to be wrapped in a span (toggle functionality).
+ * All consecutive elements are joined in one 'read more/read less'.
+ *
+ * @private
+ * @param {jQuery} $element
+ */
+ _insertReadMoreLess($element) {
+ const groups = [];
+ let readMoreNodes;
+
+ // nodeType 1: element_node
+ // nodeType 3: text_node
+ const $children = $element.contents()
+ .filter((index, content) =>
+ content.nodeType === 1 || (content.nodeType === 3 && content.nodeValue.trim())
+ );
+
+ for (const child of $children) {
+ let $child = $(child);
+
+ // Hide Text nodes if "stopSpelling"
+ if (
+ child.nodeType === 3 &&
+ $child.prevAll('[id*="stopSpelling"]').length > 0
+ ) {
+ // Convert Text nodes to Element nodes
+ $child = $('<span>', {
+ text: child.textContent,
+ 'data-o-mail-quote': '1',
+ });
+ child.parentNode.replaceChild($child[0], child);
+ }
+
+ // Create array for each 'read more' with nodes to toggle
+ if (
+ $child.attr('data-o-mail-quote') ||
+ (
+ $child.get(0).nodeName === 'BR' &&
+ $child.prev('[data-o-mail-quote="1"]').length > 0
+ )
+ ) {
+ if (!readMoreNodes) {
+ readMoreNodes = [];
+ groups.push(readMoreNodes);
+ }
+ $child.hide();
+ readMoreNodes.push($child);
+ } else {
+ readMoreNodes = undefined;
+ this._insertReadMoreLess($child);
+ }
+ }
+
+ for (const group of groups) {
+ // Insert link just before the first node
+ const $readMoreLess = $('<a>', {
+ class: 'o_Message_readMore',
+ href: '#',
+ text: READ_MORE,
+ }).insertBefore(group[0]);
+
+ // Toggle All next nodes
+ let isReadMore = true;
+ $readMoreLess.click(e => {
+ e.preventDefault();
+ isReadMore = !isReadMore;
+ for (const $child of group) {
+ $child.hide();
+ $child.toggle(!isReadMore);
+ }
+ $readMoreLess.text(isReadMore ? READ_MORE : READ_LESS);
+ });
+ }
+ }
+
+ /**
+ * @private
+ */
+ _update() {
+ if (!this.state.timeElapsed) {
+ this.state.timeElapsed = timeFromNow(this.message.date);
+ }
+ clearInterval(this._intervalId);
+ this._intervalId = setInterval(() => {
+ this.state.timeElapsed = timeFromNow(this.message.date);
+ }, 60 * 1000);
+ }
+
+ //--------------------------------------------------------------------------
+ // Handlers
+ //--------------------------------------------------------------------------
+
+ /**
+ * @private
+ */
+ _onChangeCheckbox() {
+ this.message.toggleCheck(this.threadViewer.thread, this.threadViewer.stringifiedDomain);
+ }
+
+ /**
+ * @private
+ * @param {MouseEvent} ev
+ */
+ _onClick(ev) {
+ if (ev.target.closest('.o_mention')) {
+ this.env.messaging.redirect({
+ id: Number(ev.target.dataset.oeId),
+ model: ev.target.dataset.oeModel,
+ });
+ ev.preventDefault();
+ return;
+ }
+ if (ev.target.closest('.o_mail_redirect')) {
+ this.env.messaging.redirect({
+ id: Number(ev.target.dataset.oeId),
+ model: ev.target.dataset.oeModel,
+ });
+ ev.preventDefault();
+ return;
+ }
+ ev.stopPropagation();
+ this.state.isClicked = !this.state.isClicked;
+ }
+
+ /**
+ * @private
+ * @param {MouseEvent} ev
+ */
+ _onClickAuthor(ev) {
+ if (!this.hasAuthorRedirect) {
+ return;
+ }
+ if (!this.message.author) {
+ return;
+ }
+ this.env.messaging.redirect({
+ id: this.message.author.id,
+ model: this.message.author.model,
+ });
+ }
+
+ /**
+ * @private
+ * @param {MouseEvent} ev
+ */
+ _onClickFailure(ev) {
+ this.message.openResendAction();
+ }
+
+ /**
+ * @private
+ * @param {MouseEvent} ev
+ */
+ _onClickModerationAccept(ev) {
+ ev.preventDefault();
+ this.message.moderate('accept');
+ }
+
+ /**
+ * @private
+ * @param {MouseEvent} ev
+ */
+ _onClickModerationAllow(ev) {
+ ev.preventDefault();
+ this.message.moderate('allow');
+ }
+
+ /**
+ * @private
+ * @param {MouseEvent} ev
+ */
+ _onClickModerationBan(ev) {
+ ev.preventDefault();
+ this.state.hasModerationBanDialog = true;
+ }
+
+ /**
+ * @private
+ * @param {MouseEvent} ev
+ */
+ _onClickModerationDiscard(ev) {
+ ev.preventDefault();
+ this.state.hasModerationDiscardDialog = true;
+ }
+
+ /**
+ * @private
+ * @param {MouseEvent} ev
+ */
+ _onClickModerationReject(ev) {
+ ev.preventDefault();
+ this.state.hasModerationRejectDialog = true;
+ }
+
+ /**
+ * @private
+ * @param {MouseEvent} ev
+ */
+ _onClickOriginThread(ev) {
+ ev.preventDefault();
+ this.env.messaging.redirect({
+ id: this.message.originThread.id,
+ model: this.message.originThread.model,
+ });
+ }
+
+ /**
+ * @private
+ * @param {MouseEvent} ev
+ */
+ _onClickStar(ev) {
+ ev.stopPropagation();
+ this.message.toggleStar();
+ }
+
+ /**
+ * @private
+ * @param {MouseEvent} ev
+ */
+ _onClickMarkAsRead(ev) {
+ ev.stopPropagation();
+ this.message.markAsRead();
+ }
+
+ /**
+ * @private
+ * @param {MouseEvent} ev
+ */
+ _onClickReply(ev) {
+ ev.stopPropagation();
+ this.message.replyTo();
+ }
+
+ /**
+ * @private
+ */
+ _onDialogClosedModerationBan() {
+ this.state.hasModerationBanDialog = false;
+ }
+
+ /**
+ * @private
+ */
+ _onDialogClosedModerationDiscard() {
+ this.state.hasModerationDiscardDialog = false;
+ }
+
+ /**
+ * @private
+ */
+ _onDialogClosedModerationReject() {
+ this.state.hasModerationRejectDialog = false;
+ }
+
+}
+
+Object.assign(Message, {
+ components,
+ defaultProps: {
+ hasAuthorRedirect: false,
+ hasCheckbox: false,
+ hasMarkAsReadIcon: false,
+ hasReplyIcon: false,
+ isSelected: false,
+ isSquashed: false,
+ },
+ props: {
+ attachmentsDetailsMode: {
+ type: String,
+ optional: true,
+ validate: prop => ['auto', 'card', 'hover', 'none'].includes(prop),
+ },
+ hasAuthorRedirect: Boolean,
+ hasCheckbox: Boolean,
+ hasMarkAsReadIcon: Boolean,
+ hasReplyIcon: Boolean,
+ isSelected: Boolean,
+ isSquashed: Boolean,
+ messageLocalId: String,
+ threadViewerLocalId: {
+ type: String,
+ optional: true,
+ },
+ },
+ template: 'mail.Message',
+});
+
+return Message;
+
+});
diff --git a/addons/mail/static/src/components/message/message.scss b/addons/mail/static/src/components/message/message.scss
new file mode 100644
index 00000000000..fa7b7474558
--- /dev/null
+++ b/addons/mail/static/src/components/message/message.scss
@@ -0,0 +1,354 @@
+// ------------------------------------------------------------------
+// Layout
+// ------------------------------------------------------------------
+
+.o_Message {
+ display: flex;
+ flex: 0 0 auto;
+ padding: map-get($spacers, 2);
+}
+
+.o_Message_authorAvatar {
+ height: 100%;
+ width: 100%;
+ object-fit: cover;
+}
+
+.o_Message_authorAvatarContainer {
+ position: relative;
+ height: 36px;
+ width: 36px;
+}
+
+.o_Message_authorName {
+ margin-inline-end: map-get($spacers, 2);
+}
+
+.o_Message_checkbox {
+ margin-inline-end: map-get($spacers, 2);
+}
+
+.o_Message_commandStar {
+ font-size: 1.3em;
+}
+
+.o_Message_Composer {
+ flex: 1 1 auto;
+}
+
+.o_Message_commands {
+ display: flex;
+ align-items: center;
+}
+
+.o_Message_content {
+ flex: 1 1 auto;
+ min-width: 0;
+ max-width: 100%;
+ word-wrap: break-word;
+ word-break: break-word;
+
+ > p:last-of-type {
+ margin-bottom: 0;
+ }
+}
+
+.o_Message_core {
+ margin-inline-end: map-get($spacers, 3);
+ overflow: auto;
+}
+
+.o_Message_footer {
+ display: flex;
+ flex-direction: column;
+}
+
+.o_Message_header {
+ display: flex;
+ flex-flow: row wrap;
+ align-items: baseline;
+}
+
+.o_Message_headerCommands {
+ margin-inline-end: map-get($spacers, 2);
+ align-self: center;
+
+ .o_Message_headerCommand {
+ padding-left: map-get($spacers, 2);
+ padding-right: map-get($spacers, 2);
+
+ &.o-mobile {
+ padding-left: map-get($spacers, 3);
+ padding-right: map-get($spacers, 3);
+
+ &:first-child {
+ padding-left: map-get($spacers, 2);
+ }
+
+ &:last-child {
+ padding-right: map-get($spacers, 2);
+ }
+ }
+ }
+}
+
+.o_Message_headerDate {
+ margin-inline-end: map-get($spacers, 2);
+ font-size: 0.8em;
+}
+
+.o_Message_moderationAction {
+ margin-inline-end: map-get($spacers, 3);
+}
+
+.o_Message_moderationPending {
+ margin-inline-end: map-get($spacers, 3);
+}
+
+.o_Message_moderationSubHeader {
+ display: flex;
+ flex-flow: row wrap;
+ align-items: center;
+}
+
+.o_Message_originThread {
+ margin-inline-end: map-get($spacers, 2);
+}
+
+.o_Message_partnerImStatusIcon {
+ @include o-position-absolute($bottom: 0, $right: 0);
+ display: flex;
+ align-items: center;
+ justify-content: center;
+}
+
+.o_Message_readMore {
+ display: block;
+}
+
+.o_Message_sidebar {
+ flex: 0 0 50px;
+ max-width: 50px;
+ display: flex;
+ margin-inline-end: map-get($spacers, 2);
+ justify-content: center;
+
+ &.o-message-squashed {
+ align-items: flex-start;
+ }
+}
+
+.o_Message_sidebarItem {
+ margin-left: map-get($spacers, 1);
+ margin-right: map-get($spacers, 1);
+
+ &.o-message-squashed {
+ display: flex;
+ }
+}
+
+.o_Message_trackingValues {
+ margin-top: map-get($spacers, 3);
+}
+
+.o_Message_trackingValue {
+ display: flex;
+ align-items: center;
+}
+
+.o_Message_trackingValueItem {
+ margin-inline-end: map-get($spacers, 1);
+}
+
+// ------------------------------------------------------------------
+// Style
+// ------------------------------------------------------------------
+
+.o_Message {
+ background-color: white;
+
+ &:hover, &.o-clicked {
+
+ .o_Message_command {
+ display: flex;
+ }
+
+ .o_Message_commands {
+ display: flex;
+ }
+
+ .o_Message_sidebarItem.o-message-squashed {
+ display: flex;
+ }
+
+ .o_Message_seenIndicator.o-message-squashed {
+ display: none;
+ }
+ }
+
+ .o_Message_partnerImStatusIcon {
+ color: white;
+ }
+
+ &.o-not-discussion {
+ background-color: lighten(gray('300'), 5%);
+ border-bottom: 1px solid darken(gray('300'), 5%);
+
+ .o_Message_partnerImStatusIcon {
+ color: lighten(gray('300'), 5%);
+ }
+
+ &.o-selected {
+ border-bottom: 1px solid darken(gray('400'), 5%);
+ }
+ }
+
+ &.o-selected {
+ background-color: gray('400');
+
+ .o_Message_partnerImStatusIcon {
+ color: gray('400');
+ }
+ }
+
+ &.o-starred {
+
+ .o_Message_commandStar {
+ display: flex;
+ }
+
+ .o_Message_commands {
+ display: flex;
+ }
+ }
+}
+
+.o_Message_authorName {
+ font-weight: bold;
+}
+
+.o_Message_authorRedirect {
+ cursor: pointer;
+}
+
+.o_Message_command {
+ cursor: pointer;
+ color: gray('400');
+
+ &:not(.o-mobile) {
+ display: none;
+
+ &:hover {
+ filter: brightness(0.8);
+ }
+ }
+
+ &.o-mobile {
+ filter: brightness(0.8);
+
+ &:hover {
+ filter: brightness(0.75);
+ }
+ }
+
+ &.o-message-selected {
+ color: gray('500');
+ }
+}
+
+.o_Message_commandStar {
+
+ &.o-message-starred {
+ color: gold;
+
+ &:hover {
+ filter: brightness(0.9);
+ }
+ }
+}
+
+.o_Message_content .o_mention {
+ color: $o-brand-primary;
+ cursor: pointer;
+
+ &:hover {
+ color: darken($o-brand-primary, 15%);
+ }
+}
+
+.o_Message_date {
+ color: gray('500');
+
+ &.o-message-selected {
+ color: gray('600');
+ }
+}
+
+.o_Message_headerCommands:not(.o-mobile) {
+ display: none;
+}
+
+.o_Message_originThread {
+ font-size: 0.8em;
+ color: gray('500');
+
+ &.o-message-selected {
+ color: gray('600');
+ }
+}
+
+.o_Message_originThreadLink {
+ font-size: 1.25em; // original size
+}
+
+.o_Message_partnerImStatusIcon:not(.o_Message_partnerImStatusIcon-mobile) {
+ font-size: x-small;
+}
+
+.o_Message_moderationAction {
+ font-weight: bold;
+ font-style: italic;
+
+ &.o-accept,
+ &.o-allow {
+ color: $o-mail-moderation-accept-color;
+ @include hover-focus {
+ color: darken($o-mail-moderation-accept-color, $emphasized-link-hover-darken-percentage);
+ }
+ }
+
+ &.o-ban,
+ &.o-discard,
+ &.o-reject {
+ color: $o-mail-moderation-reject-color;
+ @include hover-focus {
+ color: darken($o-mail-moderation-reject-color, $emphasized-link-hover-darken-percentage);
+ }
+ }
+}
+
+.o_Message_moderationPending {
+ font-style: italic;
+
+ &.o-author {
+ color: theme-color('danger');
+ font-weight: bold;
+ }
+}
+
+.o_Message_notificationIconContainer {
+ color: gray('600');
+ cursor: pointer;
+
+ &.o-error {
+ color: $red;
+ }
+}
+
+.o_Message_sidebarCommands {
+ display: none;
+}
+
+.o_Message_sidebarItem.o-message-squashed {
+ display: none;
+}
diff --git a/addons/mail/static/src/components/message/message.xml b/addons/mail/static/src/components/message/message.xml
new file mode 100644
index 00000000000..b55e7a5ccf6
--- /dev/null
+++ b/addons/mail/static/src/components/message/message.xml
@@ -0,0 +1,220 @@
+<?xml version="1.0" encoding="UTF-8"?>
+<templates xml:space="preserve">
+
+ <t t-name="mail.Message" owl="1">
+ <div class="o_Message"
+ t-att-class="{
+ 'o-clicked': state.isClicked,
+ 'o-discussion': message and (message.is_discussion or message.is_notification),
+ 'o-mobile': env.messaging.device.isMobile,
+ 'o-not-discussion': message and !(message.is_discussion or message.is_notification),
+ 'o-notification': message and message.message_type === 'notification',
+ 'o-selected': props.isSelected,
+ 'o-squashed': props.isSquashed,
+ 'o-starred': message and isStarred,
+ }" t-on-click="_onClick" t-att-data-message-local-id="message and message.localId"
+ >
+ <t t-if="message" name="rootCondition">
+ <div class="o_Message_sidebar" t-att-class="{ 'o-message-squashed': props.isSquashed }">
+ <t t-if="!props.isSquashed">
+ <div class="o_Message_authorAvatarContainer o_Message_sidebarItem">
+ <img class="o_Message_authorAvatar rounded-circle" t-att-class="{ o_Message_authorRedirect: hasAuthorRedirect, o_redirect: hasAuthorRedirect }" t-att-src="avatar" t-on-click="_onClickAuthor" t-att-title="hasAuthorRedirect ? 'Open chat' : ''" alt=""/>
+ <t t-if="message.author and message.author.im_status">
+ <PartnerImStatusIcon
+ class="o_Message_partnerImStatusIcon"
+ t-att-class="{
+ 'o-message-not-discussion': !(message.is_discussion or message.is_notification),
+ 'o-message-selected': props.isSelected,
+ 'o_Message_partnerImStatusIcon-mobile': env.messaging.device.isMobile,
+ }"
+ partnerLocalId="message.author.localId"
+ />
+ </t>
+ </div>
+ </t>
+ <t t-else="">
+ <div class="o_Message_date o_Message_sidebarItem o-message-squashed" t-att-class="{ 'o-message-selected': props.isSelected }">
+ <t t-esc="shortTime"/>
+ </div>
+ <div class="o_Message_commands o_Message_sidebarCommands o_Message_sidebarItem o-message-squashed" t-att-class="{ 'o-message-selected': props.isSelected, 'o-mobile': env.messaging.device.isMobile }">
+ <t t-if="message.message_type !== 'notification'">
+ <div class="o_Message_command o_Message_commandStar fa"
+ t-att-class="{
+ 'fa-star': isStarred,
+ 'fa-star-o': !isStarred,
+ 'o-message-selected': props.isSelected,
+ 'o-message-starred': isStarred,
+ 'o-mobile': env.messaging.device.isMobile,
+ }" t-on-click="_onClickStar"
+ />
+ </t>
+ </div>
+ <t t-if="message.isCurrentPartnerAuthor and threadViewer and threadViewer.thread">
+ <MessageSeenIndicator class="o_Message_seenIndicator o-message-squashed" messageLocalId="message.localId" threadLocalId="threadViewer.thread.localId"/>
+ </t>
+ </t>
+ </div>
+ <div class="o_Message_core">
+ <t t-if="!props.isSquashed">
+ <div class="o_Message_header">
+ <t t-if="message.author">
+ <div class="o_Message_authorName" t-att-class="hasAuthorRedirect ? 'o_Message_authorRedirect o_redirect' : ''" t-on-click="_onClickAuthor" t-att-title="hasAuthorRedirect ? 'Open chat' : ''">
+ <t t-esc="message.author.nameOrDisplayName"/>
+ </div>
+ </t>
+ <t t-elif="message.email_from">
+ <a class="o_Message_authorName" t-attf-href="mailto:{{ message.email_from }}?subject=Re: {{ message.subject ? message.subject : '' }}">
+ <t t-esc="message.email_from"/>
+ </a>
+ </t>
+ <t t-else="">
+ <div class="o_Message_authorName">
+ Anonymous
+ </div>
+ </t>
+ <div class="o_Message_date o_Message_headerDate" t-att-class="{ 'o-message-selected': props.isSelected }" t-att-title="datetime">
+ - <t t-esc="state.timeElapsed"/>
+ </div>
+ <t t-if="message.isCurrentPartnerAuthor and threadViewer and threadViewer.thread">
+ <MessageSeenIndicator class="o_Message_seenIndicator" messageLocalId="message.localId" threadLocalId="threadViewer.thread.localId"/>
+ </t>
+ <t t-if="hasDifferentOriginThread">
+ <div class="o_Message_originThread" t-att-class="{ 'o-message-selected': props.isSelected }">
+ <t t-if="message.originThread.model === 'mail.channel'">
+ (from <a class="o_Message_originThreadLink" href="#" t-on-click="_onClickOriginThread">#<t t-esc="message.originThread.name"/></a>)
+ </t>
+ <t t-else="">
+ on <a class="o_Message_originThreadLink" href="#" t-on-click="_onClickOriginThread"><t t-esc="message.originThread.name"/></a>
+ </t>
+ </div>
+ </t>
+ <t t-if="message.moderation_status === 'pending_moderation' and !message.isModeratedByCurrentPartner">
+ <span class="o_Message_moderationPending o-author" title="Your message is pending moderation.">Pending moderation</span>
+ </t>
+ <t t-if="message.notifications.length > 0">
+ <t t-if="message.failureNotifications.length > 0">
+ <span class="o_Message_notificationIconContainer o-error" t-on-click="_onClickFailure">
+ <i name="failureIcon" class="o_Message_notificationIcon fa fa-envelope"/>
+ </span>
+ </t>
+ <t t-else="">
+ <Popover>
+ <span class="o_Message_notificationIconContainer">
+ <i name="notificationIcon" class="o_Message_notificationIcon fa fa-envelope-o"/>
+ </span>
+ <t t-set="opened">
+ <NotificationPopover
+ notificationLocalIds="message.notifications.map(notification => notification.localId)"
+ />
+ </t>
+ </Popover>
+ </t>
+ </t>
+ <div class="o_Message_commands o_Message_headerCommands" t-att-class="{ 'o-mobile': env.messaging.device.isMobile }">
+ <t t-if="!message.isTemporary and ((message.message_type !== 'notification' and message.originThread and message.originThread.model === 'mail.channel') or !message.isTransient) and message.moderation_status !== 'pending_moderation'">
+ <span class="o_Message_command o_Message_commandStar o_Message_headerCommand fa"
+ t-att-class="{
+ 'fa-star': isStarred,
+ 'fa-star-o': !isStarred,
+ 'o-message-selected': props.isSelected,
+ 'o-message-starred': isStarred,
+ 'o-mobile': env.messaging.device.isMobile,
+ }" t-on-click="_onClickStar" title="Mark as Todo"
+ />
+ </t>
+ <t t-if="props.hasReplyIcon">
+ <span class="o_Message_command o_Message_commandReply o_Message_headerCommand fa fa-reply"
+ t-att-class="{
+ 'o-message-selected': props.isSelected,
+ 'o-mobile': env.messaging.device.isMobile,
+ }" t-on-click="_onClickReply" title="Reply"
+ />
+ </t>
+ <t t-if="props.hasMarkAsReadIcon">
+ <span class="o_Message_command o_Message_commandMarkAsRead o_Message_headerCommand fa fa-check"
+ t-att-class="{
+ 'o-message-selected': props.isSelected,
+ 'o-mobile': env.messaging.device.isMobile,
+ }" t-on-click="_onClickMarkAsRead" title="Mark as Read"
+ />
+ </t>
+ </div>
+ </div>
+ <t t-if="message.isModeratedByCurrentPartner">
+ <div class="o_Message_moderationSubHeader">
+ <t t-if="threadViewer and props.hasCheckbox and message.hasCheckbox">
+ <input class="o_Message_checkbox" type="checkbox" t-att-checked="message.isChecked(threadViewer.thread, threadViewer.stringifiedDomain) ? 'checked': ''" t-on-change="_onChangeCheckbox" t-ref="checkbox"/>
+ </t>
+ <span class="o_Message_moderationPending">Pending moderation:</span>
+ <a class="o_Message_moderationAction o-accept" href="#" title="Accept" t-on-click="_onClickModerationAccept">Accept</a>
+ <a class="o_Message_moderationAction o-reject" href="#" title="Remove message with explanation" t-on-click="_onClickModerationReject">Reject</a>
+ <a class="o_Message_moderationAction o-discard" href="#" title="Remove message without explanation" t-on-click="_onClickModerationDiscard">Discard</a>
+ <a class="o_Message_moderationAction o-allow" href="#" title="Add this email address to white list of people" t-on-click="_onClickModerationAllow">Always Allow</a>
+ <a class="o_Message_moderationAction o-ban" href="#" title="Ban this email address" t-on-click="_onClickModerationBan">Ban</a>
+ </div>
+ </t>
+ </t>
+ <div class="o_Message_content" t-ref="content">
+ <t t-raw="message.prettyBody"/>
+ <t t-if="message.subtype_description">
+ <p t-esc="message.subtype_description"/>
+ </t>
+ <t t-if="trackingValues.length > 0">
+ <ul class="o_Message_trackingValues">
+ <t t-foreach="trackingValues" t-as="value" t-key="value.id">
+ <li>
+ <div class="o_Message_trackingValue">
+ <div class="o_Message_trackingValueItem" t-esc="value.changed_field"/>
+ <t t-if="value.old_value">
+ <div class="o_Message_trackingValueItem" t-esc="value.old_value || ((value.field_type !== 'boolean') and '')"/>
+ <t t-if="value.old_value !== value.new_value">
+ <div class="o_Message_trackingValueItem fa fa-long-arrow-right" title="Changed" role="img"/>
+ </t>
+ </t>
+ <t t-if="value.old_value !== value.new_value">
+ <div class="o_Message_trackingValueItem" t-esc="value.new_value || ((value.field_type !== 'boolean') and '')"/>
+ </t>
+ </div>
+ </li>
+ </t>
+ </ul>
+ </t>
+ </div>
+ <t t-if="message.attachments and message.attachments.length > 0">
+ <div class="o_Message_footer">
+ <!-- TODO XDU This does not allow to preview all attachments at once, find somethind else -->
+ <t t-if="imageAttachments and imageAttachments.length > 0">
+ <AttachmentList
+ class="o_Message_attachmentList"
+ areAttachmentsDownloadable="true"
+ areAttachmentsEditable="true"
+ attachmentLocalIds="imageAttachments.map(attachment => attachment.localId)"
+ attachmentsDetailsMode="props.attachmentsDetailsMode"
+ />
+ </t>
+ <t t-if="nonImageAttachments and nonImageAttachments.length > 0">
+ <AttachmentList
+ class="o_Message_attachmentList"
+ areAttachmentsDownloadable="true"
+ areAttachmentsEditable="true"
+ attachmentLocalIds="nonImageAttachments.map(attachment => attachment.localId)"
+ attachmentsDetailsMode="props.attachmentsDetailsMode"
+ />
+ </t>
+ </div>
+ </t>
+ </div>
+ <t t-if="state.hasModerationBanDialog">
+ <ModerationBanDialog messageLocalIds="[message.localId]" t-on-dialog-closed="_onDialogClosedModerationBan"/>
+ </t>
+ <t t-if="state.hasModerationDiscardDialog">
+ <ModerationDiscardDialog messageLocalIds="[message.localId]" t-on-dialog-closed="_onDialogClosedModerationDiscard"/>
+ </t>
+ <t t-if="state.hasModerationRejectDialog">
+ <ModerationRejectDialog messageLocalIds="[message.localId]" t-on-dialog-closed="_onDialogClosedModerationReject"/>
+ </t>
+ </t>
+ </div>
+ </t>
+
+</templates>
diff --git a/addons/mail/static/src/components/message/message_tests.js b/addons/mail/static/src/components/message/message_tests.js
new file mode 100644
index 00000000000..b09d0a181bd
--- /dev/null
+++ b/addons/mail/static/src/components/message/message_tests.js
@@ -0,0 +1,692 @@
+odoo.define('mail/static/src/components/message/message_tests.js', function (require) {
+'use strict';
+
+const components = {
+ Message: require('mail/static/src/components/message/message.js'),
+};
+const {
+ afterEach: utilsAfterEach,
+ afterNextRender,
+ beforeEach: utilsBeforeEach,
+ start: utilsStart,
+} = require('mail/static/src/utils/test_utils.js');
+
+const Bus = require('web.Bus');
+
+QUnit.module('mail', {}, function () {
+QUnit.module('components', {}, function () {
+QUnit.module('message', {}, function () {
+QUnit.module('message_tests.js', {
+ beforeEach() {
+ utilsBeforeEach(this);
+
+ this.createMessageComponent = async (message, otherProps) => {
+ const MessageComponent = components.Message;
+ MessageComponent.env = this.env;
+ this.component = new MessageComponent(null, Object.assign({
+ messageLocalId: message.localId,
+ }, otherProps));
+ await afterNextRender(() => this.component.mount(this.widget.el));
+ };
+
+ this.start = async params => {
+ if (this.widget) {
+ this.widget.destroy();
+ }
+ let { env, widget } = await utilsStart(Object.assign({}, params, {
+ data: this.data,
+ }));
+ this.env = env;
+ this.widget = widget;
+ };
+ },
+ afterEach() {
+ utilsAfterEach(this);
+ if (this.component) {
+ this.component.destroy();
+ }
+ if (this.widget) {
+ this.widget.destroy();
+ }
+ this.env = undefined;
+ delete components.Message.env;
+ },
+});
+
+QUnit.test('basic rendering', async function (assert) {
+ assert.expect(12);
+
+ await this.start();
+ const message = this.env.models['mail.message'].create({
+ author: [['insert', { id: 7, display_name: "Demo User" }]],
+ body: "<p>Test</p>",
+ id: 100,
+ });
+ await this.createMessageComponent(message);
+ assert.strictEqual(
+ document.querySelectorAll('.o_Message').length,
+ 1,
+ "should display a message component"
+ );
+ const messageEl = document.querySelector('.o_Message');
+ assert.strictEqual(
+ messageEl.dataset.messageLocalId,
+ this.env.models['mail.message'].find(message => message.id === 100).localId,
+ "message component should be linked to message store model"
+ );
+ assert.strictEqual(
+ messageEl.querySelectorAll(`:scope .o_Message_sidebar`).length,
+ 1,
+ "message should have a sidebar"
+ );
+ assert.strictEqual(
+ messageEl.querySelectorAll(`:scope .o_Message_sidebar .o_Message_authorAvatar`).length,
+ 1,
+ "message should have author avatar in the sidebar"
+ );
+ assert.strictEqual(
+ messageEl.querySelector(`:scope .o_Message_authorAvatar`).tagName,
+ 'IMG',
+ "message author avatar should be an image"
+ );
+ assert.strictEqual(
+ messageEl.querySelector(`:scope .o_Message_authorAvatar`).dataset.src,
+ '/web/image/res.partner/7/image_128',
+ "message author avatar should GET image of the related partner"
+ );
+ assert.strictEqual(
+ messageEl.querySelectorAll(`:scope .o_Message_authorName`).length,
+ 1,
+ "message should display author name"
+ );
+ assert.strictEqual(
+ messageEl.querySelector(`:scope .o_Message_authorName`).textContent,
+ "Demo User",
+ "message should display correct author name"
+ );
+ assert.strictEqual(
+ messageEl.querySelectorAll(`:scope .o_Message_date`).length,
+ 1,
+ "message should display date"
+ );
+ assert.strictEqual(
+ messageEl.querySelectorAll(`:scope .o_Message_commands`).length,
+ 1,
+ "message should display list of commands"
+ );
+ assert.strictEqual(
+ messageEl.querySelectorAll(`:scope .o_Message_content`).length,
+ 1,
+ "message should display the content"
+ );
+ assert.strictEqual(messageEl.querySelector(`:scope .o_Message_content`).innerHTML,
+ "<p>Test</p>",
+ "message should display the correct content"
+ );
+});
+
+QUnit.test('delete attachment linked to message', async function (assert) {
+ assert.expect(1);
+
+ await this.start();
+ const message = this.env.models['mail.message'].create({
+ attachments: [['insert-and-replace', {
+ filename: "BLAH.jpg",
+ id: 10,
+ name: "BLAH",
+ }]],
+ author: [['insert', { id: 7, display_name: "Demo User" }]],
+ body: "<p>Test</p>",
+ id: 100,
+ });
+ await this.createMessageComponent(message);
+ await afterNextRender(() => document.querySelector('.o_Attachment_asideItemUnlink').click());
+ assert.notOk(this.env.models['mail.attachment'].find(attachment => attachment.id === 10));
+});
+
+QUnit.test('moderation: moderated channel with pending moderation message (author)', async function (assert) {
+ assert.expect(1);
+
+ await this.start();
+ const thread = this.env.models['mail.thread'].create({
+ id: 20,
+ model: 'mail.channel',
+ });
+ const message = this.env.models['mail.message'].create({
+ author: [['insert', { id: 1, display_name: "Admin" }]],
+ body: "<p>Test</p>",
+ id: 100,
+ moderation_status: 'pending_moderation',
+ originThread: [['link', thread]],
+ threadCaches: [['link', thread.mainCache]],
+ });
+ await this.createMessageComponent(message);
+
+ assert.strictEqual(
+ document.querySelectorAll(`.o_Message_moderationPending.o-author`).length,
+ 1,
+ "should have the message pending moderation"
+ );
+});
+
+QUnit.test('moderation: moderated channel with pending moderation message (moderator)', async function (assert) {
+ assert.expect(9);
+
+ Object.assign(this.data.initMessaging, {
+ moderation_channel_ids: [20],
+ });
+ await this.start();
+ const thread = this.env.models['mail.thread'].create({
+ id: 20,
+ model: 'mail.channel',
+ });
+ const message = this.env.models['mail.message'].create({
+ author: [['insert', { id: 7, display_name: "Demo User" }]],
+ body: "<p>Test</p>",
+ id: 100,
+ moderation_status: 'pending_moderation',
+ originThread: [['link', thread]],
+ threadCaches: [['link', thread.mainCache]],
+ });
+ await this.createMessageComponent(message);
+ const messageEl = document.querySelector('.o_Message');
+ assert.ok(messageEl, "should display a message");
+ assert.containsOnce(messageEl, `.o_Message_moderationSubHeader`,
+ "should have the message pending moderation"
+ );
+ assert.containsNone(messageEl, `.o_Message_checkbox`,
+ "should not have the moderation checkbox by default"
+ );
+ assert.containsN(messageEl, '.o_Message_moderationAction', 5,
+ "there should be 5 contextual moderation decisions next to the message"
+ );
+ assert.containsOnce(messageEl, '.o_Message_moderationAction.o-accept',
+ "there should be a contextual moderation decision to accept the message"
+ );
+ assert.containsOnce(messageEl, '.o_Message_moderationAction.o-reject',
+ "there should be a contextual moderation decision to reject the message"
+ );
+ assert.containsOnce(messageEl, '.o_Message_moderationAction.o-discard',
+ "there should be a contextual moderation decision to discard the message"
+ );
+ assert.containsOnce(messageEl, '.o_Message_moderationAction.o-allow',
+ "there should be a contextual moderation decision to allow the user of the message)"
+ );
+ assert.containsOnce(messageEl, '.o_Message_moderationAction.o-ban',
+ "there should be a contextual moderation decision to ban the user of the message"
+ );
+ // The actions are tested as part of discuss tests.
+});
+
+QUnit.test('Notification Sent', async function (assert) {
+ assert.expect(9);
+
+ await this.start();
+ const message = this.env.models['mail.message'].create({
+ id: 10,
+ message_type: 'email',
+ notifications: [['insert', {
+ id: 11,
+ notification_status: 'sent',
+ notification_type: 'email',
+ partner: [['insert', { id: 12, name: "Someone" }]],
+ }]],
+ });
+ await this.createMessageComponent(message);
+
+ assert.containsOnce(
+ document.body,
+ '.o_Message',
+ "should display a message component"
+ );
+ assert.containsOnce(
+ document.body,
+ '.o_Message_notificationIconContainer',
+ "should display the notification icon container"
+ );
+ assert.containsOnce(
+ document.body,
+ '.o_Message_notificationIcon',
+ "should display the notification icon"
+ );
+ assert.hasClass(
+ document.querySelector('.o_Message_notificationIcon'),
+ 'fa-envelope-o',
+ "icon should represent email success"
+ );
+
+ await afterNextRender(() => {
+ document.querySelector('.o_Message_notificationIconContainer').click();
+ });
+ assert.containsOnce(
+ document.body,
+ '.o_NotificationPopover',
+ "notification popover should be open"
+ );
+ assert.containsOnce(
+ document.body,
+ '.o_NotificationPopover_notificationIcon',
+ "popover should have one icon"
+ );
+ assert.hasClass(
+ document.querySelector('.o_NotificationPopover_notificationIcon'),
+ 'fa-check',
+ "popover should have the sent icon"
+ );
+ assert.containsOnce(
+ document.body,
+ '.o_NotificationPopover_notificationPartnerName',
+ "popover should have the partner name"
+ );
+ assert.strictEqual(
+ document.querySelector('.o_NotificationPopover_notificationPartnerName').textContent.trim(),
+ "Someone",
+ "partner name should be correct"
+ );
+});
+
+QUnit.test('Notification Error', async function (assert) {
+ assert.expect(8);
+
+ const bus = new Bus();
+ bus.on('do-action', null, payload => {
+ assert.step('do_action');
+ assert.strictEqual(
+ payload.action,
+ 'mail.mail_resend_message_action',
+ "action should be the one to resend email"
+ );
+ assert.strictEqual(
+ payload.options.additional_context.mail_message_to_resend,
+ 10,
+ "action should have correct message id"
+ );
+ });
+
+ await this.start({ env: { bus } });
+ const message = this.env.models['mail.message'].create({
+ id: 10,
+ message_type: 'email',
+ notifications: [['insert', {
+ id: 11,
+ notification_status: 'exception',
+ notification_type: 'email',
+ }]],
+ });
+ await this.createMessageComponent(message);
+
+ assert.containsOnce(
+ document.body,
+ '.o_Message',
+ "should display a message component"
+ );
+ assert.containsOnce(
+ document.body,
+ '.o_Message_notificationIconContainer',
+ "should display the notification icon container"
+ );
+ assert.containsOnce(
+ document.body,
+ '.o_Message_notificationIcon',
+ "should display the notification icon"
+ );
+ assert.hasClass(
+ document.querySelector('.o_Message_notificationIcon'),
+ 'fa-envelope',
+ "icon should represent email error"
+ );
+
+ await afterNextRender(() => {
+ document.querySelector('.o_Message_notificationIconContainer').click();
+ });
+ assert.verifySteps(
+ ['do_action'],
+ "should do an action to display the resend email dialog"
+ );
+});
+
+QUnit.test("'channel_fetch' notification received is correctly handled", async function (assert) {
+ assert.expect(3);
+
+ await this.start();
+ const currentPartner = this.env.models['mail.partner'].create({
+ id: this.env.session.partner_id,
+ display_name: "Demo User",
+ });
+ const thread = this.env.models['mail.thread'].create({
+ id: 11,
+ members: [
+ [['link', currentPartner]],
+ [['insert', { id: 11, display_name: "Recipient" }]]
+ ],
+ model: 'mail.channel',
+ });
+ const threadViewer = this.env.models['mail.thread_viewer'].create({ thread: [['link', thread]] });
+ const message = this.env.models['mail.message'].create({
+ author: [['link', currentPartner]],
+ body: "<p>Test</p>",
+ id: 100,
+ threadCaches: [['link', thread.mainCache]],
+ });
+
+ await this.createMessageComponent(message, { threadViewerLocalId: threadViewer.localId });
+
+ assert.containsOnce(
+ document.body,
+ '.o_Message',
+ "should display a message component"
+ );
+ assert.containsNone(
+ document.body,
+ '.o_MessageSeenIndicator_icon',
+ "message component should not have any check (V) as message is not yet received"
+ );
+
+ // Simulate received channel fetched notification
+ const notifications = [
+ [['myDB', 'mail.channel', 11], {
+ info: 'channel_fetched',
+ last_message_id: 100,
+ partner_id: 11,
+ }],
+ ];
+ await afterNextRender(() => {
+ this.widget.call('bus_service', 'trigger', 'notification', notifications);
+ });
+
+ assert.containsOnce(
+ document.body,
+ '.o_MessageSeenIndicator_icon',
+ "message seen indicator component should only contain one check (V) as message is just received"
+ );
+});
+
+QUnit.test("'channel_seen' notification received is correctly handled", async function (assert) {
+ assert.expect(3);
+
+ await this.start();
+ const currentPartner = this.env.models['mail.partner'].create({
+ id: this.env.session.partner_id,
+ display_name: "Demo User",
+ });
+ const thread = this.env.models['mail.thread'].create({
+ id: 11,
+ members: [
+ [['link', currentPartner]],
+ [['insert', { id: 11, display_name: "Recipient" }]]
+ ],
+ model: 'mail.channel',
+ });
+ const threadViewer = this.env.models['mail.thread_viewer'].create({ thread: [['link', thread]] });
+ const message = this.env.models['mail.message'].create({
+ author: [['link', currentPartner]],
+ body: "<p>Test</p>",
+ id: 100,
+ threadCaches: [['link', thread.mainCache]],
+ });
+ await this.createMessageComponent(message, { threadViewerLocalId: threadViewer.localId });
+
+ assert.containsOnce(
+ document.body,
+ '.o_Message',
+ "should display a message component"
+ );
+ assert.containsNone(
+ document.body,
+ '.o_MessageSeenIndicator_icon',
+ "message component should not have any check (V) as message is not yet received"
+ );
+
+ // Simulate received channel seen notification
+ const notifications = [
+ [['myDB', 'mail.channel', 11], {
+ info: 'channel_seen',
+ last_message_id: 100,
+ partner_id: 11,
+ }],
+ ];
+ await afterNextRender(() => {
+ this.widget.call('bus_service', 'trigger', 'notification', notifications);
+ });
+ assert.containsN(
+ document.body,
+ '.o_MessageSeenIndicator_icon',
+ 2,
+ "message seen indicator component should contain two checks (V) as message is seen"
+ );
+});
+
+QUnit.test("'channel_fetch' notification then 'channel_seen' received are correctly handled", async function (assert) {
+ assert.expect(4);
+
+ await this.start();
+ const currentPartner = this.env.models['mail.partner'].create({
+ id: this.env.session.partner_id,
+ display_name: "Demo User",
+ });
+ const thread = this.env.models['mail.thread'].create({
+ id: 11,
+ members: [
+ [['link', currentPartner]],
+ [['insert', { id: 11, display_name: "Recipient" }]]
+ ],
+ model: 'mail.channel',
+ });
+ const threadViewer = this.env.models['mail.thread_viewer'].create({ thread: [['link', thread]] });
+ const message = this.env.models['mail.message'].create({
+ author: [['link', currentPartner]],
+ body: "<p>Test</p>",
+ id: 100,
+ threadCaches: [['link', thread.mainCache]],
+ });
+ await this.createMessageComponent(message, { threadViewerLocalId: threadViewer.localId });
+
+ assert.containsOnce(
+ document.body,
+ '.o_Message',
+ "should display a message component"
+ );
+ assert.containsNone(
+ document.body,
+ '.o_MessageSeenIndicator_icon',
+ "message component should not have any check (V) as message is not yet received"
+ );
+
+ // Simulate received channel fetched notification
+ let notifications = [
+ [['myDB', 'mail.channel', 11], {
+ info: 'channel_fetched',
+ last_message_id: 100,
+ partner_id: 11,
+ }],
+ ];
+ await afterNextRender(() => {
+ this.widget.call('bus_service', 'trigger', 'notification', notifications);
+ });
+ assert.containsOnce(
+ document.body,
+ '.o_MessageSeenIndicator_icon',
+ "message seen indicator component should only contain one check (V) as message is just received"
+ );
+
+ // Simulate received channel seen notification
+ notifications = [
+ [['myDB', 'mail.channel', 11], {
+ info: 'channel_seen',
+ last_message_id: 100,
+ partner_id: 11,
+ }],
+ ];
+ await afterNextRender(() => {
+ this.widget.call('bus_service', 'trigger', 'notification', notifications);
+ });
+ assert.containsN(
+ document.body,
+ '.o_MessageSeenIndicator_icon',
+ 2,
+ "message seen indicator component should contain two checks (V) as message is now seen"
+ );
+});
+
+QUnit.test('do not show messaging seen indicator if not authored by me', async function (assert) {
+ assert.expect(2);
+
+ await this.start();
+ const author = this.env.models['mail.partner'].create({
+ id: 100,
+ display_name: "Demo User"
+ });
+ const thread = this.env.models['mail.thread'].create({
+ id: 11,
+ partnerSeenInfos: [['create', [
+ {
+ lastFetchedMessage: [['insert', {id: 100}]],
+ partner: [['insert', {id: this.env.session.partner_id}]],
+ },
+ {
+ lastFetchedMessage: [['insert', {id: 100}]],
+ partner: [['link', author]],
+ },
+ ]]],
+ model: 'mail.channel',
+ });
+ const threadViewer = this.env.models['mail.thread_viewer'].create({ thread: [['link', thread]] });
+ const message = this.env.models['mail.message'].create({
+ author: [['link', author]],
+ body: "<p>Test</p>",
+ id: 100,
+ threadCaches: [['link', thread.mainCache]],
+ });
+ await this.createMessageComponent(message, { threadViewerLocalId: threadViewer.localId });
+
+ assert.containsOnce(
+ document.body,
+ '.o_Message',
+ "should display a message component"
+ );
+ assert.containsNone(
+ document.body,
+ '.o_Message_seenIndicator',
+ "message component should not have any message seen indicator"
+ );
+});
+
+QUnit.test('do not show messaging seen indicator if before last seen by all message', async function (assert) {
+ assert.expect(3);
+
+ await this.start();
+ const currentPartner = this.env.models['mail.partner'].create({
+ id: this.env.session.partner_id,
+ display_name: "Demo User",
+ });
+ const thread = this.env.models['mail.thread'].create({
+ id: 11,
+ messageSeenIndicators: [['insert', {
+ id: this.env.models['mail.message_seen_indicator'].computeId(99, 11),
+ message: [['insert', {id: 99}]],
+ }]],
+ model: 'mail.channel',
+ });
+ const threadViewer = this.env.models['mail.thread_viewer'].create({ thread: [['link', thread]] });
+ const lastSeenMessage = this.env.models['mail.message'].create({
+ author: [['link', currentPartner]],
+ body: "<p>You already saw me</p>",
+ id: 100,
+ threadCaches: [['link', thread.mainCache]],
+ });
+ const message = this.env.models['mail.message'].create({
+ author: [['link', currentPartner]],
+ body: "<p>Test</p>",
+ id: 99,
+ threadCaches: [['link', thread.mainCache]],
+ });
+ thread.update({
+ partnerSeenInfos: [['create', [
+ {
+ lastSeenMessage: [['link', lastSeenMessage]],
+ partner: [['link', currentPartner]],
+ },
+ {
+ lastSeenMessage: [['link', lastSeenMessage]],
+ partner: [['insert', {id: 100}]],
+ },
+ ]]],
+ });
+ await this.createMessageComponent(message, { threadViewerLocalId: threadViewer.localId });
+
+ assert.containsOnce(
+ document.body,
+ '.o_Message',
+ "should display a message component"
+ );
+ assert.containsOnce(
+ document.body,
+ '.o_Message_seenIndicator',
+ "message component should have a message seen indicator"
+ );
+ assert.containsNone(
+ document.body,
+ '.o_MessageSeenIndicator_icon',
+ "message component should not have any check (V)"
+ );
+});
+
+QUnit.test('only show messaging seen indicator if authored by me, after last seen by all message', async function (assert) {
+ assert.expect(3);
+
+ await this.start();
+ const currentPartner = this.env.models['mail.partner'].create({
+ id: this.env.session.partner_id,
+ display_name: "Demo User"
+ });
+ const thread = this.env.models['mail.thread'].create({
+ id: 11,
+ partnerSeenInfos: [['create', [
+ {
+ lastSeenMessage: [['insert', {id: 100}]],
+ partner: [['link', currentPartner]],
+ },
+ {
+ partner: [['insert', {id: 100}]],
+ lastFetchedMessage: [['insert', {id: 100}]],
+ lastSeenMessage: [['insert', {id: 99}]],
+ },
+ ]]],
+ messageSeenIndicators: [['insert', {
+ id: this.env.models['mail.message_seen_indicator'].computeId(100, 11),
+ message: [['insert', {id: 100}]],
+ }]],
+ model: 'mail.channel',
+ });
+ const threadViewer = this.env.models['mail.thread_viewer'].create({ thread: [['link', thread]] });
+ const message = this.env.models['mail.message'].create({
+ author: [['link', currentPartner]],
+ body: "<p>Test</p>",
+ id: 100,
+ threadCaches: [['link', thread.mainCache]],
+ });
+ await this.createMessageComponent(message, { threadViewerLocalId: threadViewer.localId });
+
+ assert.containsOnce(
+ document.body,
+ '.o_Message',
+ "should display a message component"
+ );
+ assert.containsOnce(
+ document.body,
+ '.o_Message_seenIndicator',
+ "message component should have a message seen indicator"
+ );
+ assert.containsN(
+ document.body,
+ '.o_MessageSeenIndicator_icon',
+ 2,
+ "message component should have two checks (V)"
+ );
+});
+
+});
+});
+});
+
+});
diff --git a/addons/mail/static/src/components/message_author_prefix/message_author_prefix.js b/addons/mail/static/src/components/message_author_prefix/message_author_prefix.js
new file mode 100644
index 00000000000..07fb1d054f0
--- /dev/null
+++ b/addons/mail/static/src/components/message_author_prefix/message_author_prefix.js
@@ -0,0 +1,65 @@
+odoo.define('mail/static/src/components/message_author_prefix/message_author_prefix.js', function (require) {
+'use strict';
+
+const useStore = require('mail/static/src/component_hooks/use_store/use_store.js');
+
+const { Component } = owl;
+
+class MessageAuthorPrefix extends Component {
+
+ /**
+ * @override
+ */
+ constructor(...args) {
+ super(...args);
+ useStore(props => {
+ const message = this.env.models['mail.message'].get(props.messageLocalId);
+ const author = message ? message.author : undefined;
+ const thread = props.threadLocalId
+ ? this.env.models['mail.thread'].get(props.threadLocalId)
+ : undefined;
+ return {
+ author: author ? author.__state : undefined,
+ currentPartner: this.env.messaging.currentPartner
+ ? this.env.messaging.currentPartner.__state
+ : undefined,
+ message: message ? message.__state : undefined,
+ thread: thread ? thread.__state : undefined,
+ };
+ });
+ }
+
+ //--------------------------------------------------------------------------
+ // Public
+ //--------------------------------------------------------------------------
+
+ /**
+ * @returns {mail.message}
+ */
+ get message() {
+ return this.env.models['mail.message'].get(this.props.messageLocalId);
+ }
+
+ /**
+ * @returns {mail.thread|undefined}
+ */
+ get thread() {
+ return this.env.models['mail.thread'].get(this.props.threadLocalId);
+ }
+
+}
+
+Object.assign(MessageAuthorPrefix, {
+ props: {
+ messageLocalId: String,
+ threadLocalId: {
+ type: String,
+ optional: true,
+ },
+ },
+ template: 'mail.MessageAuthorPrefix',
+});
+
+return MessageAuthorPrefix;
+
+});
diff --git a/addons/mail/static/src/components/message_author_prefix/message_author_prefix.scss b/addons/mail/static/src/components/message_author_prefix/message_author_prefix.scss
new file mode 100644
index 00000000000..362eaeb5b92
--- /dev/null
+++ b/addons/mail/static/src/components/message_author_prefix/message_author_prefix.scss
@@ -0,0 +1,11 @@
+// ------------------------------------------------------------------
+// Layout
+// ------------------------------------------------------------------
+
+.o_MessageAuthorPrefixIcon {
+ margin-right: 3px;
+}
+
+// ------------------------------------------------------------------
+// Style
+// ------------------------------------------------------------------
diff --git a/addons/mail/static/src/components/message_author_prefix/message_author_prefix.xml b/addons/mail/static/src/components/message_author_prefix/message_author_prefix.xml
new file mode 100644
index 00000000000..de8992e4b8f
--- /dev/null
+++ b/addons/mail/static/src/components/message_author_prefix/message_author_prefix.xml
@@ -0,0 +1,17 @@
+<?xml version="1.0" encoding="UTF-8"?>
+<templates xml:space="preserve">
+
+ <t t-name="mail.MessageAuthorPrefix" owl="1">
+ <span class="o_MessageAuthorPrefix">
+ <t t-if="message">
+ <t t-if="message.author and message.author === env.messaging.currentPartner">
+ <i class="o_MessageAuthorPrefixIcon fa fa-mail-reply"/>You:
+ </t>
+ <t t-elif="thread and thread.channel_type === 'channel'">
+ <t t-esc="message.author.nameOrDisplayName"/>:
+ </t>
+ </t>
+ </span>
+ </t>
+
+</templates>
diff --git a/addons/mail/static/src/components/message_list/message_list.js b/addons/mail/static/src/components/message_list/message_list.js
new file mode 100644
index 00000000000..c948689c6d1
--- /dev/null
+++ b/addons/mail/static/src/components/message_list/message_list.js
@@ -0,0 +1,531 @@
+odoo.define('mail/static/src/components/message_list/message_list.js', function (require) {
+'use strict';
+
+const components = {
+ Message: require('mail/static/src/components/message/message.js'),
+};
+const useRefs = require('mail/static/src/component_hooks/use_refs/use_refs.js');
+const useStore = require('mail/static/src/component_hooks/use_store/use_store.js');
+
+const { Component } = owl;
+const { useRef } = owl.hooks;
+
+class MessageList extends Component {
+
+ /**
+ * @override
+ */
+ constructor(...args) {
+ super(...args);
+ useStore(props => {
+ const threadViewer = this.env.models['mail.thread_viewer'].get(props.threadViewerLocalId);
+ const thread = threadViewer ? threadViewer.thread : undefined;
+ const threadCache = threadViewer ? threadViewer.threadCache : undefined;
+ return {
+ isDeviceMobile: this.env.messaging.device.isMobile,
+ messages: threadCache
+ ? threadCache.orderedMessages.map(message => message.__state)
+ : [],
+ thread: thread ? thread.__state : undefined,
+ threadCache: threadCache ? threadCache.__state : undefined,
+ threadViewer: threadViewer ? threadViewer.__state : undefined,
+ };
+ }, {
+ compareDepth: {
+ messages: 1,
+ },
+ });
+ this._getRefs = useRefs();
+ /**
+ * Determine whether the auto-scroll on load is active or not. This
+ * is useful to disable some times, such as when mounting message list
+ * in ASC order: the initial scroll position is at the top of the
+ * conversation, and most of the time the expected initial scroll
+ * position should be at the bottom of the thread. During this time,
+ * the programmatical scrolling should not trigger auto-load messages
+ * on scroll.
+ */
+ this._isAutoLoadOnScrollActive = true;
+ /**
+ * Reference of the "load more" item. Useful to trigger load more
+ * on scroll when it becomes visible.
+ */
+ this._loadMoreRef = useRef('loadMore');
+ /**
+ * Tracked last thread cache rendered. Useful to determine scroll
+ * position on patch if it is on the same thread cache or not.
+ */
+ this._renderedThreadCache = null;
+ /**
+ * Tracked last selected message. Useful to determine when patch comes
+ * from a message selection on a given thread cache, so that it
+ * auto-scroll to that message.
+ */
+ this._selectedMessage = null;
+ /**
+ * Snapshot computed during willPatch, which is used by patched.
+ */
+ this._willPatchSnapshot = undefined;
+ this._onScroll = _.throttle(this._onScroll.bind(this), 100);
+ }
+
+ mounted() {
+ this._update();
+ }
+
+ willPatch() {
+ const lastMessageRef = this.lastMessageRef;
+ this._willPatchSnapshot = {
+ isLastMessageVisible:
+ lastMessageRef &&
+ lastMessageRef.isBottomVisible({ offset: 10 }),
+ scrollHeight: this.el.scrollHeight,
+ scrollTop: this.el.scrollTop,
+ };
+ }
+
+ patched() {
+ this._update();
+ }
+
+ //--------------------------------------------------------------------------
+ // Public
+ //--------------------------------------------------------------------------
+
+ /**
+ * Update the scroll position of the message list.
+ * This is not done in patched/mounted hooks because scroll position is
+ * dependent on UI globally. To illustrate, imagine following UI:
+ *
+ * +----------+ < viewport top = scrollable top
+ * | message |
+ * | list |
+ * | |
+ * +----------+ < scrolltop = viewport bottom = scrollable bottom
+ *
+ * Now if a composer is mounted just below the message list, it is shrinked
+ * and scrolltop is altered as a result:
+ *
+ * +----------+ < viewport top = scrollable top
+ * | message |
+ * | list | < scrolltop = viewport bottom <-+
+ * | | |-- dist = composer height
+ * +----------+ < scrollable bottom <-+
+ * +----------+
+ * | composer |
+ * +----------+
+ *
+ * Because of this, the scroll position must be changed when whole UI
+ * is rendered. To make this simpler, this is done when <ThreadViewer/>
+ * component is patched. This is acceptable when <ThreadViewer/> has a
+ * fixed height, which is the case for the moment.
+ */
+ async adjustFromComponentHints() {
+ for (const hint of this.threadViewer.componentHintList) {
+ switch (hint.type) {
+ case 'change-of-thread-cache':
+ this._adjustFromChangeOfThreadCache(hint);
+ break;
+ case 'current-partner-just-posted-message':
+ this._adjustFromCurrentPartnerJustPostedMessage(hint);
+ break;
+ case 'more-messages-loaded':
+ this._adjustFromMoreMessagesLoaded(hint);
+ break;
+ default:
+ this.threadViewer.markComponentHintProcessed(hint);
+ break;
+ }
+ }
+ this._willPatchSnapshot = undefined;
+ }
+
+ /**
+ * @param {mail.message} message
+ * @returns {string}
+ */
+ getDateDay(message) {
+ const date = message.date.format('YYYY-MM-DD');
+ if (date === moment().format('YYYY-MM-DD')) {
+ return this.env._t("Today");
+ } else if (
+ date === moment()
+ .subtract(1, 'days')
+ .format('YYYY-MM-DD')
+ ) {
+ return this.env._t("Yesterday");
+ }
+ return message.date.format('LL');
+ }
+
+ /**
+ * @returns {integer}
+ */
+ getScrollTop() {
+ return this.el.scrollTop;
+ }
+
+ /**
+ * @returns {mail/static/src/components/message/message.js|undefined}
+ */
+ get mostRecentMessageRef() {
+ if (this.props.order === 'desc') {
+ return this.messageRefs[0];
+ }
+ const { length: l, [l - 1]: mostRecentMessageRef } = this.messageRefs;
+ return mostRecentMessageRef;
+ }
+
+ /**
+ * @param {integer} messageId
+ * @returns {mail/static/src/components/message/message.js|undefined}
+ */
+ messageRefFromId(messageId) {
+ return this.messageRefs.find(ref => ref.message.id === messageId);
+ }
+
+ /**
+ * Get list of sub-components Message, ordered based on prop `order`
+ * (ASC/DESC).
+ *
+ * The asynchronous nature of OWL rendering pipeline may reveal disparity
+ * between knowledgeable state of store between components. Use this getter
+ * with extreme caution!
+ *
+ * Let's illustrate the disparity with a small example:
+ *
+ * - Suppose this component is aware of ordered (record) messages with
+ * following IDs: [1, 2, 3, 4, 5], and each (sub-component) messages map
+ * each of these records.
+ * - Now let's assume a change in store that translate to ordered (record)
+ * messages with following IDs: [2, 3, 4, 5, 6].
+ * - Because store changes trigger component re-rendering by their "depth"
+ * (i.e. from parents to children), this component may be aware of
+ * [2, 3, 4, 5, 6] but not yet sub-components, so that some (component)
+ * messages should be destroyed but aren't yet (the ref with message ID 1)
+ * and some do not exist yet (no ref with message ID 6).
+ *
+ * @returns {mail/static/src/components/message/message.js[]}
+ */
+ get messageRefs() {
+ const refs = this._getRefs();
+ const ascOrderedMessageRefs = Object.entries(refs)
+ .filter(([refId, ref]) => (
+ // Message refs have message local id as ref id, and message
+ // local ids contain name of model 'mail.message'.
+ refId.includes(this.env.models['mail.message'].modelName) &&
+ // Component that should be destroyed but haven't just yet.
+ ref.message
+ )
+ )
+ .map(([refId, ref]) => ref)
+ .sort((ref1, ref2) => (ref1.message.id < ref2.message.id ? -1 : 1));
+ if (this.props.order === 'desc') {
+ return ascOrderedMessageRefs.reverse();
+ }
+ return ascOrderedMessageRefs;
+ }
+
+ /**
+ * @returns {mail.message[]}
+ */
+ get orderedMessages() {
+ const threadCache = this.threadViewer.threadCache;
+ if (this.props.order === 'desc') {
+ return [...threadCache.orderedMessages].reverse();
+ }
+ return threadCache.orderedMessages;
+ }
+
+ /**
+ * @param {integer} value
+ */
+ async setScrollTop(value) {
+ this._isAutoLoadOnScrollActive = false;
+ this.el.scrollTop = value;
+ await new Promise(resolve => setTimeout(resolve, 0));
+ this._isAutoLoadOnScrollActive = true;
+ }
+
+ /**
+ * @param {mail.message} prevMessage
+ * @param {mail.message} message
+ * @returns {boolean}
+ */
+ shouldMessageBeSquashed(prevMessage, message) {
+ if (!this.props.hasSquashCloseMessages) {
+ return false;
+ }
+ if (Math.abs(message.date.diff(prevMessage.date)) > 60000) {
+ // more than 1 min. elasped
+ return false;
+ }
+ if (prevMessage.message_type !== 'comment' || message.message_type !== 'comment') {
+ return false;
+ }
+ if (prevMessage.author !== message.author) {
+ // from a different author
+ return false;
+ }
+ if (prevMessage.originThread !== message.originThread) {
+ return false;
+ }
+ if (
+ prevMessage.moderation_status === 'pending_moderation' ||
+ message.moderation_status === 'pending_moderation'
+ ) {
+ return false;
+ }
+ if (
+ prevMessage.notifications.length > 0 ||
+ message.notifications.length > 0
+ ) {
+ // visual about notifications is restricted to non-squashed messages
+ return false;
+ }
+ const prevOriginThread = prevMessage.originThread;
+ const originThread = message.originThread;
+ if (
+ prevOriginThread &&
+ originThread &&
+ prevOriginThread.model === originThread.model &&
+ originThread.model !== 'mail.channel' &&
+ prevOriginThread.id !== originThread.id
+ ) {
+ // messages linked to different document thread
+ return false;
+ }
+ return true;
+ }
+
+ /**
+ * @returns {mail.thread_viewer}
+ */
+ get threadViewer() {
+ return this.env.models['mail.thread_viewer'].get(this.props.threadViewerLocalId);
+ }
+
+ //--------------------------------------------------------------------------
+ // Private
+ //--------------------------------------------------------------------------
+
+ /**
+ * @private
+ * @param {Object} hint
+ */
+ async _adjustFromChangeOfThreadCache(hint) {
+ const threadCache = this.threadViewer.threadCache;
+ if (!threadCache.isLoaded) {
+ return;
+ }
+ let isProcessed = false;
+ if (threadCache.messages.length > 0) {
+ if (this.threadViewer.threadCacheInitialPosition !== undefined) {
+ this.el.scrollTop = this.threadViewer.threadCacheInitialPosition;
+ isProcessed = true;
+ } else {
+ const lastMessage = threadCache.lastMessage;
+ if (this.messageRefFromId(lastMessage.id)) {
+ this._scrollToMostRecentMessage();
+ isProcessed = true;
+ }
+ }
+ } else {
+ isProcessed = true;
+ }
+ if (isProcessed) {
+ this.threadViewer.markComponentHintProcessed(hint);
+ }
+ }
+
+ /**
+ * @private
+ * @param {Object} hint
+ * @param {integer} hint.messageId
+ */
+ async _adjustFromCurrentPartnerJustPostedMessage(hint) {
+ const threadCache = this.threadViewer.threadCache;
+ const { messageId } = hint;
+ if (threadCache.isLoaded) {
+ const threadCacheMessageIds = threadCache.messages.map(message => message.id);
+ if (threadCacheMessageIds.includes(messageId) && this.messageRefFromId(messageId)) {
+ await this._scrollToMessage(messageId);
+ this.threadViewer.markComponentHintProcessed(hint);
+ }
+ }
+ }
+
+ /**
+ * @private
+ * @param {Object} hint
+ */
+ _adjustFromMoreMessagesLoaded(hint) {
+ if (!this._willPatchSnapshot) {
+ this.threadViewer.markComponentHintProcessed(hint);
+ return;
+ }
+ const { scrollHeight, scrollTop } = this._willPatchSnapshot;
+ if (this.props.order === 'asc') {
+ this.el.scrollTop = this.el.scrollHeight - scrollHeight + scrollTop;
+ }
+ this.threadViewer.markComponentHintProcessed(hint);
+ }
+
+ /**
+ * @private
+ */
+ _checkThreadMarkAsRead() {
+ const thread = this.threadViewer.thread;
+ const threadCache = this.threadViewer.threadCache;
+ if (!threadCache) {
+ return;
+ }
+ if (threadCache.messages.length === 0) {
+ return;
+ }
+ if (!this.mostRecentMessageRef) {
+ return;
+ }
+ if (
+ threadCache === thread.mainCache &&
+ this.mostRecentMessageRef.isPartiallyVisible()
+ ) {
+ thread.markAsSeen();
+ }
+ }
+
+ /**
+ * @private
+ * @returns {boolean}
+ */
+ _isLoadMoreVisible() {
+ const loadMore = this._loadMoreRef.el;
+ if (!loadMore) {
+ return false;
+ }
+ const loadMoreRect = loadMore.getBoundingClientRect();
+ const elRect = this.el.getBoundingClientRect();
+ const isInvisible = loadMoreRect.top > elRect.bottom || loadMoreRect.bottom < elRect.top;
+ return !isInvisible;
+ }
+
+ /**
+ * @private
+ */
+ _loadMore() {
+ this.threadViewer.threadCache.loadMoreMessages();
+ }
+
+ /**
+ * @private
+ * @returns {Promise}
+ */
+ async _scrollToMostRecentMessage() {
+ if (!this.mostRecentMessageRef) {
+ return;
+ }
+ this._isAutoLoadOnScrollActive = false;
+ await this.mostRecentMessageRef.scrollIntoView();
+ if (!this.el) {
+ this._isAutoLoadOnScrollActive = true;
+ return;
+ }
+ this.el.scrollTop = this.props.order === 'asc'
+ ? this.el.scrollTop + 15
+ : this.el.scrollTop - 15;
+ this._isAutoLoadOnScrollActive = true;
+ }
+
+ /**
+ * @param {integer} messageId
+ */
+ async _scrollToMessage(messageId) {
+ const messageRef = this.messageRefFromId(messageId);
+ if (!messageRef) {
+ return;
+ }
+ this._isAutoLoadOnScrollActive = false;
+ await messageRef.scrollIntoView();
+ if (!this.el) {
+ this._isAutoLoadOnScrollActive = true;
+ return;
+ }
+ this.el.scrollTop = this.props.order === 'asc'
+ ? this.el.scrollTop + 15
+ : this.el.scrollTop - 15;
+ this._isAutoLoadOnScrollActive = true;
+ }
+
+ /**
+ * @private
+ */
+ _update() {
+ this._checkThreadMarkAsRead();
+ }
+
+ //--------------------------------------------------------------------------
+ // Handlers
+ //--------------------------------------------------------------------------
+
+ /**
+ * @private
+ * @param {MouseEvent} ev
+ */
+ _onClickLoadMore(ev) {
+ ev.preventDefault();
+ this._loadMore();
+ }
+
+ /**
+ * @private
+ * @param {ScrollEvent} ev
+ */
+ _onScroll(ev) {
+ if (!this.el) {
+ // could be unmounted in the meantime (due to throttled behavior)
+ return;
+ }
+ this.threadViewer.saveThreadCacheScrollPositionsAsInitial(this.el.scrollTop);
+ if (!this._isAutoLoadOnScrollActive) {
+ return;
+ }
+ if (this._isLoadMoreVisible()) {
+ this._loadMore();
+ }
+ this._checkThreadMarkAsRead();
+ }
+
+}
+
+Object.assign(MessageList, {
+ components,
+ defaultProps: {
+ hasMessageCheckbox: false,
+ hasSquashCloseMessages: false,
+ haveMessagesAuthorRedirect: false,
+ haveMessagesMarkAsReadIcon: false,
+ haveMessagesReplyIcon: false,
+ order: 'asc',
+ },
+ props: {
+ hasMessageCheckbox: Boolean,
+ hasSquashCloseMessages: Boolean,
+ haveMessagesAuthorRedirect: Boolean,
+ haveMessagesMarkAsReadIcon: Boolean,
+ haveMessagesReplyIcon: Boolean,
+ order: {
+ type: String,
+ validate: prop => ['asc', 'desc'].includes(prop),
+ },
+ selectedMessageLocalId: {
+ type: String,
+ optional: true,
+ },
+ threadViewerLocalId: String,
+ },
+ template: 'mail.MessageList',
+});
+
+return MessageList;
+
+});
diff --git a/addons/mail/static/src/components/message_list/message_list.scss b/addons/mail/static/src/components/message_list/message_list.scss
new file mode 100644
index 00000000000..cb06adda261
--- /dev/null
+++ b/addons/mail/static/src/components/message_list/message_list.scss
@@ -0,0 +1,135 @@
+// ------------------------------------------------------------------
+// Layout
+// ------------------------------------------------------------------
+
+.o_MessageList {
+ display: flex;
+ flex-flow: column;
+ overflow: auto;
+
+ &.o-empty {
+ align-items: center;
+ justify-content: center;
+ }
+
+ &:not(.o-empty) {
+ padding-bottom: 15px;
+ }
+}
+
+.o_MessageList_empty {
+ flex: 1 1 auto;
+ height: 100%;
+ width: 100%;
+ align-self: center;
+ display: flex;
+ flex-flow: column;
+ align-items: center;
+ justify-content: center;
+ padding: 20px;
+ line-height: 2.5rem;
+}
+
+.o_MessageList_isLoadingMore {
+ align-self: center;
+}
+
+.o_MessageList_isLoadingMoreIcon {
+ margin-right: 3px;
+}
+
+.o_MessageList_loadMore {
+ align-self: center;
+}
+
+.o_MessageList_separator {
+ display: flex;
+ align-items: center;
+ padding: 0 0;
+ flex: 0 0 auto;
+}
+
+.o_MessageList_separatorDate {
+ padding: 15px 0;
+}
+
+.o_MessageList_separatorLine {
+ flex: 1 1 auto;
+ width: auto;
+}
+
+.o_MessageList_separatorNewMessages {
+ // bug with safari: container does not auto-grow from child size
+ padding: 0 0;
+ margin-right: 15px;
+}
+
+.o_MessageList_separatorLabel {
+ padding: 0 10px;
+}
+
+// ------------------------------------------------------------------
+// Style
+// ------------------------------------------------------------------
+
+.o_MessageList {
+ background-color: white;
+}
+
+.o_MessageList_empty {
+ text-align: center;
+}
+
+.o_MessageList_emptyTitle {
+ font-weight: bold;
+ font-size: 1.3rem;
+
+ &.o-neutral-face-icon:before {
+ @extend %o-nocontent-init-image;
+ @include size(120px, 140px);
+ background: transparent url(/web/static/src/img/neutral_face.svg) no-repeat center;
+ }
+}
+
+.o_MessageList_loadMore {
+ cursor: pointer;
+}
+
+.o_MessageList_message.o-has-message-selection:not(.o-selected) {
+ opacity: 0.5;
+}
+
+.o_MessageList_separator {
+ font-weight: bold;
+}
+
+.o_MessageList_separatorLine {
+ border-color: gray('400');
+}
+
+.o_MessageList_separatorLineNewMessages {
+ border-color: lighten($o-brand-odoo, 15%);
+}
+
+.o_MessageList_separatorNewMessages {
+ color: lighten($o-brand-odoo, 15%);
+
+}
+
+.o_MessageList_separatorLabel {
+ background-color: white;
+}
+
+// ------------------------------------------------------------------
+// Animation
+// ------------------------------------------------------------------
+
+.o_MessageList_separatorNewMessages:not(.o-disable-animation) {
+ &.fade-leave-active {
+ transition: opacity 0.5s;
+ }
+
+ &.fade-leave-to {
+ opacity: 0;
+ }
+}
diff --git a/addons/mail/static/src/components/message_list/message_list.xml b/addons/mail/static/src/components/message_list/message_list.xml
new file mode 100644
index 00000000000..4951bcea951
--- /dev/null
+++ b/addons/mail/static/src/components/message_list/message_list.xml
@@ -0,0 +1,107 @@
+<?xml version="1.0" encoding="UTF-8"?>
+<templates xml:space="preserve">
+
+ <t t-name="mail.MessageList" owl="1">
+ <div class="o_MessageList" t-att-class="{ 'o-empty': threadViewer and threadViewer.messages.length === 0, 'o-has-message-selection': props.selectedMessageLocalId }" t-on-scroll="_onScroll">
+ <t t-if="threadViewer">
+ <!-- No result messages -->
+ <t t-if="threadViewer.messages.length === 0">
+ <div class="o_MessageList_empty o_MessageList_item">
+ <t t-if="threadViewer.thread === env.messaging.inbox">
+ <div class="o_MessageList_emptyTitle">
+ Congratulations, your inbox is empty
+ </div>
+ New messages appear here.
+ </t>
+ <t t-elif="threadViewer.thread === env.messaging.starred">
+ <div class="o_MessageList_emptyTitle">
+ No starred messages
+ </div>
+ You can mark any message as 'starred', and it shows up in this mailbox.
+ </t>
+ <t t-elif="threadViewer.thread === env.messaging.history">
+ <div class="o_MessageList_emptyTitle o-neutral-face-icon">
+ No history messages
+ </div>
+ Messages marked as read will appear in the history.
+ </t>
+ <t t-elif="threadViewer.thread === env.messaging.moderation">
+ <div class="o_MessageList_emptyTitle">
+ You have no messages to moderate.
+ </div>
+ Messages pending moderation appear here.
+ </t>
+ <t t-else="">
+ There are no messages in this conversation.
+ </t>
+ </div>
+ </t>
+ <!-- LOADING (if order asc)-->
+ <t t-if="props.order === 'asc' and threadViewer.messages.length > 0">
+ <t t-call="mail.MessageList.loadMore"/>
+ </t>
+ <!-- MESSAGES -->
+ <t t-set="current_day" t-value="0"/>
+ <t t-set="prev_message" t-value="0"/>
+ <t t-if="threadViewer.thread.model === 'mail.channel' and threadViewer.thread.message_unread_counter > 0 and !threadViewer.thread.seen_message_id">
+ <div class="o_MessageList_separator o_MessageList_separatorNewMessages o_MessageList_item" t-att-class="{ 'o-disable-animation': env.disableAnimation }" t-transition="fade">
+ <hr class="o_MessageList_separatorLine o_MessageList_separatorLineNewMessages"/><span class="o_MessageList_separatorLabel o_MessageList_separatorLabelNewMessages">New messages</span>
+ </div>
+ </t>
+ <t t-foreach="orderedMessages" t-as="message" t-key="message.localId">
+ <t t-set="message_day" t-value="getDateDay(message)"/>
+ <t t-if="current_day !== message_day">
+ <div class="o_MessageList_separator o_MessageList_separatorDate o_MessageList_item">
+ <hr class="o_MessageList_separatorLine"/><span class="o_MessageList_separatorLabel o_MessageList_separatorLabelDate"><t t-esc="message_day"/></span><hr class="o_MessageList_separatorLine"/>
+ <t t-set="current_day" t-value="message_day"/>
+ <t t-set="isMessageSquashed" t-value="false"/>
+ </div>
+ </t>
+ <t t-else="">
+ <t t-set="isMessageSquashed" t-value="shouldMessageBeSquashed(prev_message, message)"/>
+ </t>
+ <Message
+ class="o_MessageList_item o_MessageList_message"
+ t-att-class="{
+ 'o-has-message-selection': props.selectedMessageLocalId,
+ }"
+ hasAuthorRedirect="props.haveMessagesAuthorRedirect"
+ hasMarkAsReadIcon="props.haveMessagesMarkAsReadIcon"
+ hasCheckbox="props.hasMessageCheckbox"
+ hasReplyIcon="props.haveMessagesReplyIcon"
+ isSelected="props.selectedMessageLocalId === message.localId"
+ isSquashed="isMessageSquashed"
+ messageLocalId="message.localId"
+ threadViewerLocalId="threadViewer.localId"
+ t-ref="{{ message.localId }}"
+ />
+ <t t-set="prev_message" t-value="message"/>
+ <t t-if="threadViewer.thread.model === 'mail.channel' and threadViewer.thread.message_unread_counter > 0 and threadViewer.thread.seen_message_id === message.id">
+ <div class="o_MessageList_separator o_MessageList_separatorNewMessages o_MessageList_item" t-att-class="{ 'o-disable-animation': env.disableAnimation }" t-transition="fade">
+ <hr class="o_MessageList_separatorLine o_MessageList_separatorLineNewMessages"/><span class="o_MessageList_separatorLabel o_MessageList_separatorLabelNewMessages">New messages</span>
+ </div>
+ </t>
+ </t>
+ <!-- LOADING (if order desc)-->
+ <t t-if="props.order === 'desc' and threadViewer.messages.length > 0">
+ <t t-call="mail.MessageList.loadMore"/>
+ </t>
+ </t>
+ </div>
+ </t>
+
+ <t t-name="mail.MessageList.loadMore" owl="1">
+ <t t-if="threadViewer.threadCache.isLoadingMore">
+ <div class="o_MessageList_item o_MessageList_isLoadingMore">
+ <i class="o_MessageList_isLoadingMoreIcon fa fa-spin fa-spinner"/>
+ Loading...
+ </div>
+ </t>
+ <t t-elif="!threadViewer.threadCache.isAllHistoryLoaded">
+ <a class="o_MessageList_item o_MessageList_loadMore" href="#" t-on-click="_onClickLoadMore" t-ref="loadMore">
+ Load more
+ </a>
+ </t>
+ </t>
+
+</templates>
diff --git a/addons/mail/static/src/components/message_seen_indicator/message_seen_indicator.js b/addons/mail/static/src/components/message_seen_indicator/message_seen_indicator.js
new file mode 100644
index 00000000000..8c5239c9e2d
--- /dev/null
+++ b/addons/mail/static/src/components/message_seen_indicator/message_seen_indicator.js
@@ -0,0 +1,130 @@
+odoo.define('mail/static/src/components/message_seen_indicator/message_seen_indicator.js', function (require) {
+'use strict';
+
+const useStore = require('mail/static/src/component_hooks/use_store/use_store.js');
+
+const { Component } = owl;
+
+class MessageSeenIndicator extends Component {
+
+ /**
+ * @override
+ */
+ constructor(...args) {
+ super(...args);
+ useStore(props => {
+ const message = this.env.models['mail.message'].get(props.messageLocalId);
+ const thread = this.env.models['mail.thread'].get(props.threadLocalId);
+ const messageSeenIndicator = this.env.models['mail.message_seen_indicator'].find(
+ messageSeenIndicator =>
+ messageSeenIndicator.message === message &&
+ messageSeenIndicator.thread === thread
+ );
+ return {
+ messageSeenIndicator: messageSeenIndicator ? messageSeenIndicator.__state : undefined,
+ };
+ });
+ }
+
+ //--------------------------------------------------------------------------
+ // Public
+ //--------------------------------------------------------------------------
+
+ /**
+ * @returns {string}
+ */
+ get indicatorTitle() {
+ if (!this.messageSeenIndicator) {
+ return '';
+ }
+ if (this.messageSeenIndicator.hasEveryoneSeen) {
+ return this.env._t("Seen by Everyone");
+ }
+ if (this.messageSeenIndicator.hasSomeoneSeen) {
+ const partnersThatHaveSeen = this.messageSeenIndicator.partnersThatHaveSeen.map(
+ partner => partner.name
+ );
+ if (partnersThatHaveSeen.length === 1) {
+ return _.str.sprintf(
+ this.env._t("Seen by %s"),
+ partnersThatHaveSeen[0]
+ );
+ }
+ if (partnersThatHaveSeen.length === 2) {
+ return _.str.sprintf(
+ this.env._t("Seen by %s and %s"),
+ partnersThatHaveSeen[0],
+ partnersThatHaveSeen[1]
+ );
+ }
+ return _.str.sprintf(
+ this.env._t("Seen by %s, %s and more"),
+ partnersThatHaveSeen[0],
+ partnersThatHaveSeen[1]
+ );
+ }
+ if (this.messageSeenIndicator.hasEveryoneFetched) {
+ return this.env._t("Received by Everyone");
+ }
+ if (this.messageSeenIndicator.hasSomeoneFetched) {
+ const partnersThatHaveFetched = this.messageSeenIndicator.partnersThatHaveFetched.map(
+ partner => partner.name
+ );
+ if (partnersThatHaveFetched.length === 1) {
+ return _.str.sprintf(
+ this.env._t("Received by %s"),
+ partnersThatHaveFetched[0]
+ );
+ }
+ if (partnersThatHaveFetched.length === 2) {
+ return _.str.sprintf(
+ this.env._t("Received by %s and %s"),
+ partnersThatHaveFetched[0],
+ partnersThatHaveFetched[1]
+ );
+ }
+ return _.str.sprintf(
+ this.env._t("Received by %s, %s and more"),
+ partnersThatHaveFetched[0],
+ partnersThatHaveFetched[1]
+ );
+ }
+ return '';
+ }
+
+ /**
+ * @returns {mail.message}
+ */
+ get message() {
+ return this.env.models['mail.message'].get(this.props.messageLocalId);
+ }
+
+ /**
+ * @returns {mail.message_seen_indicator}
+ */
+ get messageSeenIndicator() {
+ return this.env.models['mail.message_seen_indicator'].find(messageSeenIndicator =>
+ messageSeenIndicator.message === this.message &&
+ messageSeenIndicator.thread === this.thread
+ );
+ }
+
+ /**
+ * @returns {mail.Thread}
+ */
+ get thread() {
+ return this.env.models['mail.thread'].get(this.props.threadLocalId);
+ }
+}
+
+Object.assign(MessageSeenIndicator, {
+ props: {
+ messageLocalId: String,
+ threadLocalId: String,
+ },
+ template: 'mail.MessageSeenIndicator',
+});
+
+return MessageSeenIndicator;
+
+});
diff --git a/addons/mail/static/src/components/message_seen_indicator/message_seen_indicator.scss b/addons/mail/static/src/components/message_seen_indicator/message_seen_indicator.scss
new file mode 100644
index 00000000000..3a9d566e399
--- /dev/null
+++ b/addons/mail/static/src/components/message_seen_indicator/message_seen_indicator.scss
@@ -0,0 +1,39 @@
+// ------------------------------------------------------------------
+// Layout
+// ------------------------------------------------------------------
+
+.o_MessageSeenIndicator {
+ display: flex;
+ position: relative;
+ flex-wrap: nowrap;
+}
+
+.o_MessageSeenIndicator_icon {
+
+ &.o-first {
+ padding-left: map-get($spacers, 1);
+ }
+
+ &.o-second {
+ position: absolute;
+ top: -1px;
+ left: -1px;
+ }
+}
+
+// ------------------------------------------------------------------
+// Style
+// ------------------------------------------------------------------
+
+.o_MessageSeenIndicator {
+ opacity: 0.6;
+
+ &.o-all-seen {
+ color: $o-brand-odoo;
+ }
+
+ &:hover {
+ cursor: pointer;
+ opacity: 0.8;
+ }
+}
diff --git a/addons/mail/static/src/components/message_seen_indicator/message_seen_indicator.xml b/addons/mail/static/src/components/message_seen_indicator/message_seen_indicator.xml
new file mode 100644
index 00000000000..e905afaa392
--- /dev/null
+++ b/addons/mail/static/src/components/message_seen_indicator/message_seen_indicator.xml
@@ -0,0 +1,16 @@
+<?xml version="1.0" encoding="UTF-8"?>
+<templates xml:space="preserve">
+
+ <t t-name="mail.MessageSeenIndicator" owl="1">
+ <span class="o_MessageSeenIndicator" t-att-class="{ 'o-all-seen': messageSeenIndicator and messageSeenIndicator.hasEveryoneSeen }" t-att-title="indicatorTitle">
+ <t t-if="messageSeenIndicator and !messageSeenIndicator.isMessagePreviousToLastCurrentPartnerMessageSeenByEveryone">
+ <t t-if="messageSeenIndicator.hasSomeoneFetched or messageSeenIndicator.hasSomeoneSeen">
+ <i class="o_MessageSeenIndicator_icon o-first fa fa-check"/>
+ </t>
+ <t t-if="messageSeenIndicator.hasSomeoneSeen">
+ <i class="o_MessageSeenIndicator_icon o-second fa fa-check"/>
+ </t>
+ </t>
+ </span>
+ </t>
+</templates>
diff --git a/addons/mail/static/src/components/message_seen_indicator/message_seen_indicator_tests.js b/addons/mail/static/src/components/message_seen_indicator/message_seen_indicator_tests.js
new file mode 100644
index 00000000000..9184dc0c692
--- /dev/null
+++ b/addons/mail/static/src/components/message_seen_indicator/message_seen_indicator_tests.js
@@ -0,0 +1,293 @@
+odoo.define('mail/static/src/components/message_seen_indicator/message_seen_indicator_tests', function (require) {
+'use strict';
+
+const components = {
+ MessageSendIndicator: require('mail/static/src/components/message_seen_indicator/message_seen_indicator.js'),
+};
+const {
+ afterEach: utilsAfterEach,
+ beforeEach: utilsBeforeEach,
+ start: utilsStart,
+} = require('mail/static/src/utils/test_utils.js');
+
+QUnit.module('mail', {}, function () {
+QUnit.module('components', {}, function () {
+QUnit.module('message_seen_indicator', {}, function () {
+QUnit.module('message_seen_indicator_tests.js', {
+ beforeEach() {
+ utilsBeforeEach(this);
+
+ this.createMessageSeenIndicatorComponent = async ({ message, thread }, otherProps) => {
+ const MessageSeenIndicatorComponent = components.MessageSendIndicator;
+ MessageSeenIndicatorComponent.env = this.env;
+ this.component = new MessageSeenIndicatorComponent(null, Object.assign({
+ messageLocalId: message.localId,
+ threadLocalId: thread.localId,
+ }, otherProps));
+ await this.component.mount(this.widget.el);
+ };
+
+ this.start = async params => {
+ if (this.widget) {
+ this.widget.destroy();
+ }
+ let { env, widget } = await utilsStart(Object.assign({}, params, {
+ data: this.data,
+ }));
+ this.env = env;
+ this.widget = widget;
+ };
+ },
+ afterEach() {
+ utilsAfterEach(this);
+ if (this.component) {
+ this.component.destroy();
+ }
+ if (this.widget) {
+ this.widget.destroy();
+ }
+ this.env = undefined;
+ delete components.MessageSendIndicator.env;
+ },
+});
+
+QUnit.test('rendering when just one has received the message', async function (assert) {
+ assert.expect(3);
+
+ await this.start();
+ const thread = this.env.models['mail.thread'].create({
+ id: 1000,
+ model: 'mail.channel',
+ partnerSeenInfos: [['create', [
+ {
+ partner: [['create', {id: 10}]],
+ lastFetchedMessage: [['insert', {id: 100}]]
+ },
+ {
+ partner: [['create', {id: 100}]],
+ },
+ ]]],
+ messageSeenIndicators: [['insert', {
+ id: this.env.models['mail.message_seen_indicator'].computeId(100, 1000),
+ message: [['insert', {id: 100}]],
+ }]],
+ });
+ const message = this.env.models['mail.message'].create({
+ author: [['insert', { id: this.env.session.partner_id, display_name: "Demo User" }]],
+ body: "<p>Test</p>",
+ id: 100,
+ threadCaches: [['link', thread.mainCache]],
+ });
+ await this.createMessageSeenIndicatorComponent({message, thread});
+ assert.containsOnce(
+ document.body,
+ '.o_MessageSeenIndicator',
+ "should display a message seen indicator component"
+ );
+ assert.doesNotHaveClass(
+ document.querySelector('.o_MessageSeenIndicator'),
+ 'o-all-seen',
+ "indicator component should not be considered as all seen"
+ );
+ assert.containsOnce(
+ document.body,
+ '.o_MessageSeenIndicator_icon',
+ "should display only one seen indicator icon"
+ );
+});
+
+QUnit.test('rendering when everyone have received the message', async function (assert) {
+ assert.expect(3);
+
+ await this.start();
+ const thread = this.env.models['mail.thread'].create({
+ id: 1000,
+ model: 'mail.channel',
+ partnerSeenInfos: [['create', [
+ {
+ partner: [['create', {id: 10}]],
+ lastFetchedMessage: [['insert', {id: 100}]],
+ },
+ {
+ partner: [['create', {id: 100}]],
+ lastFetchedMessage: [['insert', {id: 99}]],
+ },
+ ]]],
+ messageSeenIndicators: [['insert', {
+ id: this.env.models['mail.message_seen_indicator'].computeId(100, 1000),
+ message: [['insert', {id: 100}]],
+ }]],
+ });
+ const message = this.env.models['mail.message'].create({
+ author: [['insert', { id: this.env.session.partner_id, display_name: "Demo User" }]],
+ body: "<p>Test</p>",
+ id: 100,
+ threadCaches: [['link', thread.mainCache]],
+ });
+ await this.createMessageSeenIndicatorComponent({message, thread});
+ assert.containsOnce(
+ document.body,
+ '.o_MessageSeenIndicator',
+ "should display a message seen indicator component"
+ );
+ assert.doesNotHaveClass(
+ document.querySelector('.o_MessageSeenIndicator'),
+ 'o-all-seen',
+ "indicator component should not be considered as all seen"
+ );
+ assert.containsOnce(
+ document.body,
+ '.o_MessageSeenIndicator_icon',
+ "should display only one seen indicator icon"
+ );
+});
+
+QUnit.test('rendering when just one has seen the message', async function (assert) {
+ assert.expect(3);
+
+ await this.start();
+ const thread = this.env.models['mail.thread'].create({
+ id: 1000,
+ model: 'mail.channel',
+ partnerSeenInfos: [['create', [
+ {
+ partner: [['create', {id: 10}]],
+ lastFetchedMessage: [['insert', {id: 100}]],
+ lastSeenMessage: [['insert', {id: 100}]],
+ },
+ {
+ partner: [['create', {id: 100}]],
+ lastFetchedMessage: [['insert', {id: 99}]],
+ },
+ ]]],
+ messageSeenIndicators: [['insert', {
+ id: this.env.models['mail.message_seen_indicator'].computeId(100, 1000),
+ message: [['insert', {id: 100}]],
+ }]],
+ });
+ const message = this.env.models['mail.message'].create({
+ author: [['insert', { id: this.env.session.partner_id, display_name: "Demo User" }]],
+ body: "<p>Test</p>",
+ id: 100,
+ threadCaches: [['link', thread.mainCache]],
+ });
+ await this.createMessageSeenIndicatorComponent({message, thread});
+ assert.containsOnce(
+ document.body,
+ '.o_MessageSeenIndicator',
+ "should display a message seen indicator component"
+ );
+ assert.doesNotHaveClass(
+ document.querySelector('.o_MessageSeenIndicator'),
+ 'o-all-seen',
+ "indicator component should not be considered as all seen"
+ );
+ assert.containsN(
+ document.body,
+ '.o_MessageSeenIndicator_icon',
+ 2,
+ "should display two seen indicator icon"
+ );
+});
+
+QUnit.test('rendering when just one has seen & received the message', async function (assert) {
+ assert.expect(3);
+
+ await this.start();
+ const thread = this.env.models['mail.thread'].create({
+ id: 1000,
+ model: 'mail.channel',
+ partnerSeenInfos: [['create', [
+ {
+ partner: [['create', {id: 10}]],
+ lastFetchedMessage: [['insert', {id: 100}]],
+ lastSeenMessage: [['insert', {id: 100}]],
+ },
+ {
+ partner: [['create', {id: 100}]],
+ },
+ ]]],
+ messageSeenIndicators: [['insert', {
+ id: this.env.models['mail.message_seen_indicator'].computeId(100, 1000),
+ message: [['insert', {id: 100}]],
+ }]],
+ });
+ const message = this.env.models['mail.message'].create({
+ author: [['insert', { id: this.env.session.partner_id, display_name: "Demo User" }]],
+ body: "<p>Test</p>",
+ id: 100,
+ threadCaches: [['link', thread.mainCache]],
+ });
+ await this.createMessageSeenIndicatorComponent({message, thread});
+ assert.containsOnce(
+ document.body,
+ '.o_MessageSeenIndicator',
+ "should display a message seen indicator component"
+ );
+ assert.doesNotHaveClass(
+ document.querySelector('.o_MessageSeenIndicator'),
+ 'o-all-seen',
+ "indicator component should not be considered as all seen"
+ );
+ assert.containsN(
+ document.body,
+ '.o_MessageSeenIndicator_icon',
+ 2,
+ "should display two seen indicator icon"
+ );
+});
+
+QUnit.test('rendering when just everyone has seen the message', async function (assert) {
+ assert.expect(3);
+
+ await this.start();
+ const thread = this.env.models['mail.thread'].create({
+ id: 1000,
+ model: 'mail.channel',
+ partnerSeenInfos: [['create', [
+ {
+ partner: [['create', {id: 10}]],
+ lastFetchedMessage: [['insert', {id: 100}]],
+ lastSeenMessage: [['insert', {id: 100}]],
+ },
+ {
+ partner: [['create', {id: 100}]],
+ lastFetchedMessage: [['insert', {id: 100}]],
+ lastSeenMessage: [['insert', {id: 100}]],
+ },
+ ]]],
+ messageSeenIndicators: [['insert', {
+ id: this.env.models['mail.message_seen_indicator'].computeId(100, 1000),
+ message: [['insert', {id: 100}]],
+ }]],
+ });
+ const message = this.env.models['mail.message'].create({
+ author: [['insert', { id: this.env.session.partner_id, display_name: "Demo User" }]],
+ body: "<p>Test</p>",
+ id: 100,
+ threadCaches: [['link', thread.mainCache]],
+ });
+ await this.createMessageSeenIndicatorComponent({message, thread});
+ assert.containsOnce(
+ document.body,
+ '.o_MessageSeenIndicator',
+ "should display a message seen indicator component"
+ );
+ assert.hasClass(
+ document.querySelector('.o_MessageSeenIndicator'),
+ 'o-all-seen',
+ "indicator component should not considered as all seen"
+ );
+ assert.containsN(
+ document.body,
+ '.o_MessageSeenIndicator_icon',
+ 2,
+ "should display two seen indicator icon"
+ );
+});
+
+});
+});
+});
+
+});
diff --git a/addons/mail/static/src/components/messaging_menu/messaging_menu.js b/addons/mail/static/src/components/messaging_menu/messaging_menu.js
new file mode 100644
index 00000000000..864c7df97e1
--- /dev/null
+++ b/addons/mail/static/src/components/messaging_menu/messaging_menu.js
@@ -0,0 +1,266 @@
+odoo.define('mail/static/src/components/messaging_menu/messaging_menu.js', function (require) {
+'use strict';
+
+const components = {
+ AutocompleteInput: require('mail/static/src/components/autocomplete_input/autocomplete_input.js'),
+ MobileMessagingNavbar: require('mail/static/src/components/mobile_messaging_navbar/mobile_messaging_navbar.js'),
+ NotificationList: require('mail/static/src/components/notification_list/notification_list.js'),
+};
+const useStore = require('mail/static/src/component_hooks/use_store/use_store.js');
+
+const { Component } = owl;
+const { useRef } = owl.hooks;
+
+class MessagingMenu extends Component {
+
+ /**
+ * @override
+ */
+ constructor(...args) {
+ super(...args);
+ /**
+ * global JS generated ID for this component. Useful to provide a
+ * custom class to autocomplete input, so that click in an autocomplete
+ * item is not considered as a click away from messaging menu in mobile.
+ */
+ this.id = _.uniqueId('o_messagingMenu_');
+ useStore((...args) => this._useStoreSelector(...args));
+
+ /**
+ * Reference of the new message input in mobile. Useful to include it
+ * and autocomplete menu as "inside" the messaging menu, to prevent
+ * closing the messaging menu otherwise.
+ */
+ this._mobileNewMessageInputRef = useRef('mobileNewMessageInput');
+
+ // bind since passed as props
+ this._onMobileNewMessageInputSelect = this._onMobileNewMessageInputSelect.bind(this);
+ this._onMobileNewMessageInputSource = this._onMobileNewMessageInputSource.bind(this);
+
+ this._onClickCaptureGlobal = this._onClickCaptureGlobal.bind(this);
+ }
+
+ mounted() {
+ document.addEventListener('click', this._onClickCaptureGlobal, true);
+ }
+
+ willUnmount() {
+ document.removeEventListener('click', this._onClickCaptureGlobal, true);
+ }
+
+ //--------------------------------------------------------------------------
+ // Public
+ //--------------------------------------------------------------------------
+
+ /**
+ * @returns {mail.discuss}
+ */
+ get discuss() {
+ return this.env.messaging && this.env.messaging.discuss;
+ }
+
+ /**
+ * @returns {mail.messaging_menu}
+ */
+ get messagingMenu() {
+ return this.env.messaging.messagingMenu;
+ }
+
+ /**
+ * @returns {string}
+ */
+ get mobileNewMessageInputPlaceholder() {
+ return this.env._t("Search user...");
+ }
+
+ /**
+ * @returns {Object[]}
+ */
+ get tabs() {
+ return [{
+ icon: 'fa fa-envelope',
+ id: 'all',
+ label: this.env._t("All"),
+ }, {
+ icon: 'fa fa-user',
+ id: 'chat',
+ label: this.env._t("Chat"),
+ }, {
+ icon: 'fa fa-users',
+ id: 'channel',
+ label: this.env._t("Channel"),
+ }];
+ }
+
+ //--------------------------------------------------------------------------
+ // Private
+ //--------------------------------------------------------------------------
+
+ /**
+ * @private
+ */
+ _useStoreSelector(props) {
+ return {
+ messagingMenu: this.env.messaging.messagingMenu.__state,
+ isDeviceMobile: this.env.messaging.device.isMobile,
+ isDiscussOpen: this.env.messaging.discuss.isOpen,
+ isMessagingInitialized: this.env.messaging.isInitialized,
+ };
+ }
+
+ //--------------------------------------------------------------------------
+ // Handlers
+ //--------------------------------------------------------------------------
+
+ /**
+ * @private
+ * @param {MouseEvent} ev
+ */
+ _onClickCaptureGlobal(ev) {
+ // in mobile: keeps the messaging menu open in background
+ // TODO: maybe need to move this to a mobile component?
+ // task-2089887
+ if (
+ this.env.messaging.device.isMobile &&
+ this.env.messaging.chatWindowManager.hasVisibleChatWindows
+ ) {
+ return;
+ }
+ // closes the menu when clicking outside
+ if (this.el.contains(ev.target)) {
+ return;
+ }
+ const input = this._mobileNewMessageInputRef.comp;
+ if (input && input.contains(ev.target)) {
+ return;
+ }
+ this.messagingMenu.close();
+ }
+
+ /**
+ * @private
+ * @param {MouseEvent} ev
+ */
+ _onClickDesktopTabButton(ev) {
+ ev.stopPropagation();
+ this.messagingMenu.update({ activeTabId: ev.currentTarget.dataset.tabId });
+ }
+
+ /**
+ * @private
+ * @param {MouseEvent} ev
+ */
+ _onClickNewMessage(ev) {
+ ev.stopPropagation();
+ if (!this.env.messaging.device.isMobile) {
+ this.env.models['mail.thread'].openNewMessage();
+ this.messagingMenu.close();
+ } else {
+ this.messagingMenu.toggleMobileNewMessage();
+ }
+ }
+
+ /**
+ * @private
+ * @param {MouseEvent} ev
+ */
+ _onClickToggler(ev) {
+ ev.stopPropagation();
+ ev.preventDefault();
+ this.messagingMenu.toggleOpen();
+ }
+
+ /**
+ * @private
+ * @param {CustomEvent} ev
+ */
+ _onHideMobileNewMessage(ev) {
+ ev.stopPropagation();
+ this.messagingMenu.toggleMobileNewMessage();
+ }
+
+ /**
+ * @private
+ * @param {Event} ev
+ * @param {Object} ui
+ * @param {Object} ui.item
+ * @param {integer} ui.item.id
+ */
+ _onMobileNewMessageInputSelect(ev, ui) {
+ const partnerId = ui.item.id;
+ const partner = this.env.models['mail.partner'].find(partner => partner.id === partnerId);
+ const chat = partner.correspondentThreads.find(thread => thread.channel_type === 'chat');
+ if (chat) {
+ chat.open();
+ } else {
+ this.env.models['mail.thread'].createChannel({
+ autoselect: true,
+ partnerId,
+ type: 'chat',
+ });
+ }
+ if (!this.env.messaging.device.isMobile) {
+ this.messagingMenu.close();
+ }
+ }
+
+ /**
+ * @private
+ * @param {Object} req
+ * @param {string} req.term
+ * @param {function} res
+ */
+ _onMobileNewMessageInputSource(req, res) {
+ const value = _.escape(req.term);
+ this.env.models['mail.partner'].imSearch({
+ callback: partners => {
+ const suggestions = partners.map(partner => {
+ return {
+ id: partner.id,
+ value: partner.nameOrDisplayName,
+ label: partner.nameOrDisplayName,
+ };
+ });
+ res(_.sortBy(suggestions, 'label'));
+ },
+ keyword: value,
+ limit: 10,
+ });
+ }
+
+ /**
+ * @private
+ * @param {CustomEvent} ev
+ * @param {Object} ev.detail
+ * @param {string} ev.detail.tabId
+ */
+ _onSelectMobileNavbarTab(ev) {
+ ev.stopPropagation();
+ this.messagingMenu.update({ activeTabId: ev.detail.tabId });
+ }
+
+ /**
+ * @private
+ * @param {CustomEvent} ev
+ * @param {Object} ev.detail
+ * @param {mail.thread} ev.detail.thread
+ */
+ _onSelectThread(ev) {
+ ev.stopPropagation();
+ this.env.models['mail.thread'].get(ev.detail.thread).open();
+ if (!this.env.messaging.device.isMobile) {
+ this.messagingMenu.close();
+ }
+ }
+
+}
+
+Object.assign(MessagingMenu, {
+ components,
+ props: {},
+ template: 'mail.MessagingMenu',
+});
+
+return MessagingMenu;
+
+});
diff --git a/addons/mail/static/src/components/messaging_menu/messaging_menu.scss b/addons/mail/static/src/components/messaging_menu/messaging_menu.scss
new file mode 100644
index 00000000000..776d6af5371
--- /dev/null
+++ b/addons/mail/static/src/components/messaging_menu/messaging_menu.scss
@@ -0,0 +1,133 @@
+// ------------------------------------------------------------------
+// Layout
+// ------------------------------------------------------------------
+
+.o_MessagingMenu_counter {
+ position: relative;
+ transform: translate(-5px, -5px);
+ margin-right: -10px; // "cancel" right padding of systray items
+}
+
+.o_MessagingMenu_dropdownMenu {
+ display: flex;
+ flex-flow: column;
+ padding-top: 0;
+ padding-bottom: 0;
+ overflow-y: auto;
+
+ &.o-messaging-not-initialized {
+ align-items: center;
+ justify-content: center;
+ }
+
+ &:not(.o-mobile) {
+ flex: 0 1 auto;
+ width: 350px;
+ min-height: 50px;
+ max-height: 400px;
+ z-index: 1100; // on top of chat windows
+ }
+
+ &.o-mobile {
+ flex: 1 1 auto;
+ position: fixed;
+ top: $o-mail-chat-window-header-height-mobile;
+ bottom: 0;
+ left: 0;
+ right: 0;
+ width: 100%;
+ margin: 0;
+ max-height: none;
+ }
+}
+
+.o_MessagingMenu_dropdownMenuHeader {
+
+ &:not(.o-mobile) {
+ display: flex;
+ }
+
+ &.o-mobile {
+ display: grid;
+ grid-template-areas:
+ "top"
+ "bottom";
+ grid-template-rows: auto auto;
+ padding: 5px
+ }
+}
+
+.o_MessagingMenu_dropdownLoadingIcon {
+ margin-right: 3px;
+}
+
+.o_MessagingMenu_icon {
+ font-size: larger
+}
+
+.o_MessagingMenu_loading {
+ font-size: small;
+ position: absolute;
+ bottom: 50%;
+ right: 0;
+}
+
+.o_MessagingMenu_newMessageButton.o-mobile {
+ grid-area: top;
+ justify-self: start;
+}
+
+.o_MessagingMenu_mobileNewMessageInput {
+ grid-area: bottom;
+ padding: 8px;
+ margin-top: 10px
+}
+
+.o_MessagingMenu_notificationList.o-mobile {
+ flex: 1 1 auto;
+ overflow-y: auto;
+}
+
+
+// ------------------------------------------------------------------
+// Style
+// ------------------------------------------------------------------
+
+// Make hightlight more consistent, due to messaging menu looking quite similar to discuss app in mobile
+.o_MessagingMenu.show {
+ background-color: rgba(black, 0.1);
+}
+
+.o_MessagingMenu_counter {
+ background-color: $o-brand-primary;
+}
+
+.o_MessagingMenu_dropdownMenu.o-mobile {
+ border: 0;
+}
+
+.o_MessagingMenu_dropdownMenuHeader {
+ border-bottom: 1px solid gray('400');
+ z-index: 1;
+}
+
+.o_MessagingMenu_mobileNewMessageInput {
+ appearance: none;
+ border: 1px solid gray('400');
+ border-radius: 5px;
+ outline: none;
+}
+
+.o_MessagingMenu_tabButton.o-desktop {
+
+ &.o-active {
+ font-weight: bold;
+ }
+
+ &:not(:hover) {
+
+ &:not(.o-active) {
+ color: gray('500');
+ }
+ }
+}
diff --git a/addons/mail/static/src/components/messaging_menu/messaging_menu.xml b/addons/mail/static/src/components/messaging_menu/messaging_menu.xml
new file mode 100644
index 00000000000..77c33e5ce7e
--- /dev/null
+++ b/addons/mail/static/src/components/messaging_menu/messaging_menu.xml
@@ -0,0 +1,89 @@
+<?xml version="1.0" encoding="UTF-8"?>
+<templates xml:space="preserve">
+
+ <t t-name="mail.MessagingMenu" owl="1">
+ <li class="o_MessagingMenu" t-att-class="{ show: messagingMenu.isOpen, 'o-mobile': env.messaging.device.isMobile }">
+ <!-- TODO FIXME restore mobile stuff here (activeMobileThread) -->
+ <t t-if="!env.messaging.device.isMobile or true">
+ <a class="o_MessagingMenu_toggler o-no-caret" t-att-class="{ show: messagingMenu.isOpen }" href="#" t-on-click="_onClickToggler" data-toggle="dropdown" data-display="static" title="Conversations" role="button" aria-expanded="false">
+ <i class="o_MessagingMenu_icon fa fa-comments-o" role="img" aria-label="Messages"/>
+ <t t-if="!env.messaging.isInitialized">
+ <i class="o_MessagingMenu_loading fa fa-spinner fa-spin"/>
+ </t>
+ <t t-elif="messagingMenu.counter > 0">
+ <span class="o_MessagingMenu_counter badge badge-pill">
+ <t t-esc="messagingMenu.counter"/>
+ </span>
+ </t>
+ </a>
+ </t>
+ <!-- TODO FIXME restore mobile stuff here (activeMobileThread) -->
+ <t t-if="messagingMenu.isOpen and (!env.messaging.device.isMobile or true)">
+ <div class="o_MessagingMenu_dropdownMenu dropdown-menu dropdown-menu-right" t-att-class="{ 'o-mobile': env.messaging.device.isMobile, 'o-messaging-not-initialized': !env.messaging.isInitialized }" role="menu">
+ <t t-if="!env.messaging.isInitialized">
+ <span><i class="o_MessagingMenu_dropdownLoadingIcon fa fa-spinner fa-spin"/>Please wait...</span>
+ </t>
+ <t t-else="">
+ <div class="o_MessagingMenu_dropdownMenuHeader" t-att-class="{ 'o-mobile': env.messaging.device.isMobile }">
+ <t t-if="!env.messaging.device.isMobile">
+ <t t-foreach="['all', 'chat', 'channel']" t-as="tabId" t-key="tabId">
+ <button class="o_MessagingMenu_tabButton o-desktop btn btn-link" t-att-class="{ 'o-active': messagingMenu.activeTabId === tabId, }" t-on-click="_onClickDesktopTabButton" type="button" role="tab" t-att-data-tab-id="tabId">
+ <t t-if="tabId === 'all'">All</t>
+ <t t-elif="tabId === 'chat'">Chat</t>
+ <t t-elif="tabId === 'channel'">Channels</t>
+ </button>
+ </t>
+ </t>
+ <t t-if="env.messaging.device.isMobile">
+ <t t-call="mail.MessagingMenu.newMessageButton"/>
+ </t>
+ <div class="o-autogrow"/>
+ <t t-if="!env.messaging.device.isMobile and !discuss.isOpen">
+ <t t-call="mail.MessagingMenu.newMessageButton"/>
+ </t>
+ <t t-if="env.messaging.device.isMobile and messagingMenu.isMobileNewMessageToggled">
+ <AutocompleteInput
+ class="o_MessagingMenu_mobileNewMessageInput"
+ customClass="id + '_mobileNewMessageInputAutocomplete'"
+ isFocusOnMount="true"
+ placeholder="mobileNewMessageInputPlaceholder"
+ select="_onMobileNewMessageInputSelect"
+ source="_onMobileNewMessageInputSource"
+ t-on-o-hide="_onHideMobileNewMessage"
+ t-ref="mobileNewMessageInput"
+ />
+ </t>
+ </div>
+ <NotificationList
+ class="o_MessagingMenu_notificationList"
+ t-att-class="{ 'o-mobile': env.messaging.device.isMobile }"
+ filter="messagingMenu.activeTabId"
+ t-on-o-select-thread="_onSelectThread"
+ />
+ <t t-if="env.messaging.device.isMobile">
+ <MobileMessagingNavbar
+ class="o_MessagingMenu_mobileNavbar"
+ activeTabId="messagingMenu.activeTabId"
+ tabs="tabs"
+ t-on-o-select-mobile-messaging-navbar-tab="_onSelectMobileNavbarTab"
+ />
+ </t>
+ </t>
+ </div>
+ </t>
+ </li>
+ </t>
+
+ <t t-name="mail.MessagingMenu.newMessageButton" owl="1">
+ <button class="o_MessagingMenu_newMessageButton btn"
+ t-att-class="{
+ 'btn-link': !env.messaging.device.isMobile,
+ 'btn-secondary': env.messaging.device.isMobile,
+ 'o-mobile': env.messaging.device.isMobile,
+ }" t-on-click="_onClickNewMessage" type="button"
+ >
+ New message
+ </button>
+ </t>
+
+</templates> \ No newline at end of file
diff --git a/addons/mail/static/src/components/messaging_menu/messaging_menu_tests.js b/addons/mail/static/src/components/messaging_menu/messaging_menu_tests.js
new file mode 100644
index 00000000000..60159360899
--- /dev/null
+++ b/addons/mail/static/src/components/messaging_menu/messaging_menu_tests.js
@@ -0,0 +1,849 @@
+odoo.define('mail/static/src/components/messaging_menu/messaging_menu_tests.js', function (require) {
+'use strict';
+
+const {
+ afterEach: utilsAfterEach,
+ afterNextRender,
+ beforeEach: utilsBeforeEach,
+ start: utilsStart,
+} = require('mail/static/src/utils/test_utils.js');
+
+const { makeTestPromise } = require('web.test_utils');
+
+QUnit.module('mail', {}, function () {
+QUnit.module('components', {}, function () {
+QUnit.module('messaging_menu', {}, function () {
+QUnit.module('messaging_menu_tests.js', {
+ beforeEach() {
+ utilsBeforeEach(this);
+
+ this.start = async params => {
+ if (this.widget) {
+ this.widget.destroy();
+ }
+ let { discussWidget, env, widget } = await utilsStart(Object.assign({}, params, {
+ data: this.data,
+ hasMessagingMenu: true,
+ }));
+ this.discussWidget = discussWidget;
+ this.env = env;
+ this.widget = widget;
+ };
+ },
+ afterEach() {
+ utilsAfterEach(this);
+ if (this.widget) {
+ this.widget.destroy();
+ }
+ },
+});
+
+QUnit.test('messaging not initialized', async function (assert) {
+ assert.expect(2);
+
+ await this.start({
+ async mockRPC(route) {
+ if (route === '/mail/init_messaging') {
+ // simulate messaging never initialized
+ return new Promise(resolve => {});
+ }
+ return this._super(...arguments);
+ },
+ waitUntilMessagingInitialized: false,
+ });
+ assert.strictEqual(
+ document.querySelectorAll('.o_MessagingMenu_loading').length,
+ 1,
+ "should display loading icon on messaging menu when messaging not yet initialized"
+ );
+
+ await afterNextRender(() => document.querySelector(`.o_MessagingMenu_toggler`).click());
+ assert.strictEqual(
+ document.querySelector('.o_MessagingMenu_dropdownMenu').textContent,
+ "Please wait...",
+ "should prompt loading when opening messaging menu"
+ );
+});
+
+QUnit.test('messaging becomes initialized', async function (assert) {
+ assert.expect(2);
+
+ const messagingInitializedProm = makeTestPromise();
+
+ await this.start({
+ async mockRPC(route) {
+ const _super = this._super.bind(this, ...arguments); // limitation of class.js
+ if (route === '/mail/init_messaging') {
+ await messagingInitializedProm;
+ }
+ return _super();
+ },
+ waitUntilMessagingInitialized: false,
+ });
+ await afterNextRender(() => document.querySelector(`.o_MessagingMenu_toggler`).click());
+
+ // simulate messaging becomes initialized
+ await afterNextRender(() => messagingInitializedProm.resolve());
+ assert.strictEqual(
+ document.querySelectorAll('.o_MessagingMenu_loading').length,
+ 0,
+ "should no longer display loading icon on messaging menu when messaging becomes initialized"
+ );
+ assert.notOk(
+ document.querySelector('.o_MessagingMenu_dropdownMenu').textContent.includes("Please wait..."),
+ "should no longer prompt loading when opening messaging menu when messaging becomes initialized"
+ );
+});
+
+QUnit.test('basic rendering', async function (assert) {
+ assert.expect(21);
+
+ await this.start({
+ async mockRPC(route, args) {
+ if (args.method === 'channel_fetch_preview') {
+ return [];
+ }
+ return this._super(...arguments);
+ },
+ });
+ assert.strictEqual(
+ document.querySelectorAll('.o_MessagingMenu').length,
+ 1,
+ "should have messaging menu"
+ );
+ assert.notOk(
+ document.querySelector('.o_MessagingMenu').classList.contains('show'),
+ "should not mark messaging menu item as shown by default"
+ );
+ assert.strictEqual(
+ document.querySelectorAll(`.o_MessagingMenu_toggler`).length,
+ 1,
+ "should have clickable element on messaging menu"
+ );
+ assert.notOk(
+ document.querySelector(`.o_MessagingMenu_toggler`).classList.contains('show'),
+ "should not mark messaging menu clickable item as shown by default"
+ );
+ assert.strictEqual(
+ document.querySelectorAll(`.o_MessagingMenu_icon`).length,
+ 1,
+ "should have icon on clickable element in messaging menu"
+ );
+ assert.ok(
+ document.querySelector(`.o_MessagingMenu_icon`).classList.contains('fa-comments-o'),
+ "should have 'comments' icon on clickable element in messaging menu"
+ );
+ assert.strictEqual(
+ document.querySelectorAll(`.o_MessagingMenu_dropdownMenu`).length,
+ 0,
+ "should not display any messaging menu dropdown by default"
+ );
+
+ await afterNextRender(() => document.querySelector(`.o_MessagingMenu_toggler`).click());
+ assert.ok(
+ document.querySelector('.o_MessagingMenu').classList.contains('show'),
+ "should mark messaging menu item as shown"
+ );
+ assert.ok(
+ document.querySelector(`.o_MessagingMenu_toggler`).classList.contains('show'),
+ "should mark messaging menu clickable item as shown"
+ );
+ assert.strictEqual(
+ document.querySelectorAll(`.o_MessagingMenu_dropdownMenu`).length,
+ 1,
+ "should display messaging menu dropdown after click"
+ );
+ assert.strictEqual(
+ document.querySelectorAll(`.o_MessagingMenu_dropdownMenuHeader`).length,
+ 1,
+ "should have dropdown menu header"
+ );
+ assert.strictEqual(
+ document.querySelectorAll(`
+ .o_MessagingMenu_dropdownMenuHeader
+ .o_MessagingMenu_tabButton
+ `).length,
+ 3,
+ "should have 3 tab buttons to filter items in the header"
+ );
+ assert.strictEqual(
+ document.querySelectorAll(`.o_MessagingMenu_tabButton[data-tab-id="all"]`).length,
+ 1,
+ "1 tab button should be 'All'"
+ );
+ assert.strictEqual(
+ document.querySelectorAll(`.o_MessagingMenu_tabButton[data-tab-id="chat"]`).length,
+ 1,
+ "1 tab button should be 'Chat'"
+ );
+ assert.strictEqual(
+ document.querySelectorAll(`.o_MessagingMenu_tabButton[data-tab-id="channel"]`).length,
+ 1,
+ "1 tab button should be 'Channels'"
+ );
+ assert.ok(
+ document.querySelector(`
+ .o_MessagingMenu_tabButton[data-tab-id="all"]
+ `).classList.contains('o-active'),
+ "'all' tab button should be active"
+ );
+ assert.notOk(
+ document.querySelector(`
+ .o_MessagingMenu_tabButton[data-tab-id="chat"]
+ `).classList.contains('o-active'),
+ "'chat' tab button should not be active"
+ );
+ assert.notOk(
+ document.querySelector(`
+ .o_MessagingMenu_tabButton[data-tab-id="channel"]
+ `).classList.contains('o-active'),
+ "'channel' tab button should not be active"
+ );
+ assert.strictEqual(
+ document.querySelectorAll(`.o_MessagingMenu_newMessageButton`).length,
+ 1,
+ "should have button to make a new message"
+ );
+ assert.strictEqual(
+ document.querySelectorAll(`
+ .o_MessagingMenu_dropdownMenu
+ .o_NotificationList
+ `).length,
+ 1,
+ "should display thread preview list"
+ );
+ assert.strictEqual(
+ document.querySelectorAll(`
+ .o_MessagingMenu_dropdownMenu
+ .o_NotificationList_noConversation
+ `).length,
+ 1,
+ "should display no conversation in thread preview list"
+ );
+});
+
+QUnit.test('counter is taking into account failure notification', async function (assert) {
+ assert.expect(2);
+
+ this.data.initMessaging.mail_failures = [{
+ date: moment.utc().format("YYYY-MM-DD HH:mm:ss"),
+ id: 11,
+ message_type: 'email',
+ model: 'mail.channel',
+ notifications: [{
+ failure_type: 'SMTP',
+ id: 21,
+ notification_status: 'exception',
+ notification_type: 'email',
+ partner_id: [41, "Someone"],
+ }],
+ res_id: 31,
+ res_model_name: "Channel",
+ }];
+ await this.start();
+
+ assert.containsOnce(
+ document.body,
+ '.o_MessagingMenu_counter',
+ "should display a notification counter next to the messaging menu for one notification"
+ );
+ assert.strictEqual(
+ document.querySelector('.o_MessagingMenu_counter').textContent,
+ "1",
+ "should display a counter of '1' next to the messaging menu"
+ );
+});
+
+QUnit.test('switch tab', async function (assert) {
+ assert.expect(15);
+
+ await this.start({
+ async mockRPC(route, args) {
+ if (args.method === 'channel_fetch_preview') {
+ return [];
+ }
+ return this._super(...arguments);
+ },
+ });
+
+ await afterNextRender(() => document.querySelector(`.o_MessagingMenu_toggler`).click());
+ assert.strictEqual(
+ document.querySelectorAll(`.o_MessagingMenu_tabButton[data-tab-id="all"]`).length,
+ 1,
+ "1 tab button should be 'All'"
+ );
+ assert.strictEqual(
+ document.querySelectorAll(`.o_MessagingMenu_tabButton[data-tab-id="chat"]`).length,
+ 1,
+ "1 tab button should be 'Chat'"
+ );
+ assert.strictEqual(
+ document.querySelectorAll(`.o_MessagingMenu_tabButton[data-tab-id="channel"]`).length,
+ 1,
+ "1 tab button should be 'Channels'"
+ );
+ assert.ok(
+ document.querySelector(`
+ .o_MessagingMenu_tabButton[data-tab-id="all"]
+ `).classList.contains('o-active'),
+ "'all' tab button should be active"
+ );
+ assert.notOk(
+ document.querySelector(`
+ .o_MessagingMenu_tabButton[data-tab-id="chat"]
+ `).classList.contains('o-active'),
+ "'chat' tab button should not be active"
+ );
+ assert.notOk(
+ document.querySelector(`
+ .o_MessagingMenu_tabButton[data-tab-id="channel"]
+ `).classList.contains('o-active'),
+ "'channel' tab button should not be active"
+ );
+
+ await afterNextRender(() =>
+ document.querySelector(`.o_MessagingMenu_tabButton[data-tab-id="chat"]`).click()
+ );
+ assert.notOk(
+ document.querySelector(`
+ .o_MessagingMenu_tabButton[data-tab-id="all"]
+ `).classList.contains('o-active'),
+ "'all' tab button should become inactive"
+ );
+ assert.ok(
+ document.querySelector(`
+ .o_MessagingMenu_tabButton[data-tab-id="chat"]
+ `).classList.contains('o-active'),
+ "'chat' tab button should not become active"
+ );
+ assert.notOk(
+ document.querySelector(`
+ .o_MessagingMenu_tabButton[data-tab-id="channel"]
+ `).classList.contains('o-active'),
+ "'channel' tab button should stay inactive"
+ );
+
+ await afterNextRender(() =>
+ document.querySelector(`.o_MessagingMenu_tabButton[data-tab-id="channel"]`).click()
+ );
+ assert.notOk(
+ document.querySelector(`
+ .o_MessagingMenu_tabButton[data-tab-id="all"]
+ `).classList.contains('o-active'),
+ "'all' tab button should stay active"
+ );
+ assert.notOk(
+ document.querySelector(`
+ .o_MessagingMenu_tabButton[data-tab-id="chat"]
+ `).classList.contains('o-active'),
+ "'chat' tab button should become inactive"
+ );
+ assert.ok(
+ document.querySelector(`
+ .o_MessagingMenu_tabButton[data-tab-id="channel"]
+ `).classList.contains('o-active'),
+ "'channel' tab button should become active"
+ );
+
+ await afterNextRender(() =>
+ document.querySelector(`.o_MessagingMenu_tabButton[data-tab-id="all"]`).click()
+ );
+ assert.ok(
+ document.querySelector(`
+ .o_MessagingMenu_tabButton[data-tab-id="all"]
+ `).classList.contains('o-active'),
+ "'all' tab button should become active"
+ );
+ assert.notOk(
+ document.querySelector(`
+ .o_MessagingMenu_tabButton[data-tab-id="chat"]
+ `).classList.contains('o-active'),
+ "'chat' tab button should stay inactive"
+ );
+ assert.notOk(
+ document.querySelector(`
+ .o_MessagingMenu_tabButton[data-tab-id="channel"]
+ `).classList.contains('o-active'),
+ "'channel' tab button should become inactive"
+ );
+});
+
+QUnit.test('new message', async function (assert) {
+ assert.expect(3);
+
+ await this.start({
+ hasChatWindow: true,
+ async mockRPC(route, args) {
+ if (args.method === 'channel_fetch_preview') {
+ return [];
+ }
+ return this._super(...arguments);
+ },
+ });
+
+ await afterNextRender(() =>
+ document.querySelector(`.o_MessagingMenu_toggler`).click()
+ );
+ await afterNextRender(() =>
+ document.querySelector(`.o_MessagingMenu_newMessageButton`).click()
+ );
+
+ assert.strictEqual(
+ document.querySelectorAll(`.o_ChatWindow`).length,
+ 1,
+ "should have open a chat window"
+ );
+ assert.ok(
+ document.querySelector(`.o_ChatWindow`).classList.contains('o-new-message'),
+ "chat window should be for new message"
+ );
+ assert.ok(
+ document.querySelector(`.o_ChatWindow`).classList.contains('o-focused'),
+ "chat window should be focused"
+ );
+});
+
+QUnit.test('no new message when discuss is open', async function (assert) {
+ assert.expect(3);
+
+ await this.start({
+ autoOpenDiscuss: true,
+ hasDiscuss: true,
+ async mockRPC(route, args) {
+ if (args.method === 'channel_fetch_preview') {
+ return [];
+ }
+ return this._super(...arguments);
+ },
+ });
+
+ await afterNextRender(() =>
+ document.querySelector(`.o_MessagingMenu_toggler`).click()
+ );
+ assert.strictEqual(
+ document.querySelectorAll(`.o_MessagingMenu_newMessageButton`).length,
+ 0,
+ "should not have 'new message' when discuss is open"
+ );
+
+ // simulate closing discuss app
+ await afterNextRender(() => this.discussWidget.on_detach_callback());
+ assert.strictEqual(
+ document.querySelectorAll(`.o_MessagingMenu_newMessageButton`).length,
+ 1,
+ "should have 'new message' when discuss is closed"
+ );
+
+ // simulate opening discuss app
+ await afterNextRender(() => this.discussWidget.on_attach_callback());
+ assert.strictEqual(
+ document.querySelectorAll(`.o_MessagingMenu_newMessageButton`).length,
+ 0,
+ "should not have 'new message' when discuss is open again"
+ );
+});
+
+QUnit.test('channel preview: basic rendering', async function (assert) {
+ assert.expect(9);
+
+ Object.assign(this.data.initMessaging, {
+ channel_slots: {
+ channel_channel: [{
+ channel_type: "channel",
+ id: 20,
+ name: "General",
+ }],
+ },
+ });
+ await this.start({
+ async mockRPC(route, args) {
+ if (args.method === 'channel_fetch_preview') {
+ return [{
+ id: 20,
+ last_message: {
+ author_id: [7, "Demo"],
+ body: "<p>test</p>",
+ channel_ids: [20],
+ id: 100,
+ message_type: 'comment',
+ model: 'mail.channel',
+ record_name: "General",
+ res_id: 20,
+ },
+ }];
+ }
+ return this._super(...arguments);
+ },
+ });
+
+ await afterNextRender(() => document.querySelector(`.o_MessagingMenu_toggler`).click());
+ assert.strictEqual(
+ document.querySelectorAll(`
+ .o_MessagingMenu_dropdownMenu .o_ThreadPreview
+ `).length,
+ 1,
+ "should have one preview"
+ );
+ assert.strictEqual(
+ document.querySelectorAll(`
+ .o_MessagingMenu_dropdownMenu
+ .o_ThreadPreview_sidebar
+ `).length,
+ 1,
+ "preview should have a sidebar"
+ );
+ assert.strictEqual(
+ document.querySelectorAll(`
+ .o_MessagingMenu_dropdownMenu
+ .o_ThreadPreview_content
+ `).length,
+ 1,
+ "preview should have some content"
+ );
+ assert.strictEqual(
+ document.querySelectorAll(`
+ .o_MessagingMenu_dropdownMenu
+ .o_ThreadPreview_header
+ `).length,
+ 1,
+ "preview should have header in content"
+ );
+ assert.strictEqual(
+ document.querySelectorAll(`
+ .o_MessagingMenu_dropdownMenu
+ .o_ThreadPreview_header
+ .o_ThreadPreview_name
+ `).length,
+ 1,
+ "preview should have name in header of content"
+ );
+ assert.strictEqual(
+ document.querySelector(`
+ .o_MessagingMenu_dropdownMenu
+ .o_ThreadPreview_name
+ `).textContent,
+ "General", "preview should have name of channel"
+ );
+ assert.strictEqual(
+ document.querySelectorAll(`
+ .o_MessagingMenu_dropdownMenu
+ .o_ThreadPreview_content
+ .o_ThreadPreview_core
+ `).length,
+ 1,
+ "preview should have core in content"
+ );
+ assert.strictEqual(
+ document.querySelectorAll(`
+ .o_MessagingMenu_dropdownMenu
+ .o_ThreadPreview_core
+ .o_ThreadPreview_inlineText
+ `).length,
+ 1,
+ "preview should have inline text in core of content"
+ );
+ assert.strictEqual(
+ document.querySelector(`
+ .o_MessagingMenu_dropdownMenu
+ .o_ThreadPreview_core
+ .o_ThreadPreview_inlineText
+ `).textContent.trim(),
+ "Demo: test",
+ "preview should have message content as inline text of core content"
+ );
+});
+
+QUnit.test('filtered previews', async function (assert) {
+ assert.expect(12);
+
+ Object.assign(this.data.initMessaging, {
+ channel_slots: {
+ channel_channel: [{
+ channel_type: "channel",
+ id: 20,
+ name: "General",
+ }],
+ channel_direct_message: [{
+ channel_type: "chat",
+ direct_partner: [{
+ id: 7,
+ name: "Demo",
+ }],
+ id: 10,
+ }],
+ },
+ });
+ await this.start({
+ async mockRPC(route, args) {
+ if (args.method === 'channel_fetch_preview') {
+ return [{
+ id: 20,
+ last_message: {
+ author_id: [7, "Demo"],
+ body: "<p>test</p>",
+ channel_ids: [20],
+ id: 100,
+ message_type: 'comment',
+ model: 'mail.channel',
+ res_id: 20,
+ },
+ }, {
+ id: 10,
+ last_message: {
+ author_id: [7, "Demo"],
+ body: "<p>test2</p>",
+ channel_ids: [10],
+ id: 101,
+ message_type: 'comment',
+ model: 'mail.channel',
+ res_id: 10,
+ },
+ }];
+ }
+ return this._super(...arguments);
+ },
+ });
+
+ await afterNextRender(() =>
+ document.querySelector(`.o_MessagingMenu_toggler`).click()
+ );
+ assert.strictEqual(
+ document.querySelectorAll(`.o_MessagingMenu_dropdownMenu .o_ThreadPreview`).length,
+ 2,
+ "should have 2 previews"
+ );
+ assert.strictEqual(
+ document.querySelectorAll(`
+ .o_MessagingMenu_dropdownMenu
+ .o_ThreadPreview[data-thread-local-id="${
+ this.env.models['mail.thread'].find(thread =>
+ thread.id === 10 &&
+ thread.model === 'mail.channel'
+ ).localId
+ }"]
+ `).length,
+ 1,
+ "should have preview of chat"
+ );
+ assert.strictEqual(
+ document.querySelectorAll(`
+ .o_MessagingMenu_dropdownMenu
+ .o_ThreadPreview[data-thread-local-id="${
+ this.env.models['mail.thread'].find(thread =>
+ thread.id === 20 &&
+ thread.model === 'mail.channel'
+ ).localId
+ }"]
+ `).length,
+ 1,
+ "should have preview of channel"
+ );
+
+ await afterNextRender(() =>
+ document.querySelector('.o_MessagingMenu_tabButton[data-tab-id="chat"]').click()
+ );
+ assert.strictEqual(
+ document.querySelectorAll(`.o_MessagingMenu_dropdownMenu .o_ThreadPreview`).length,
+ 1,
+ "should have one preview"
+ );
+ assert.strictEqual(
+ document.querySelectorAll(`
+ .o_MessagingMenu_dropdownMenu
+ .o_ThreadPreview[data-thread-local-id="${
+ this.env.models['mail.thread'].find(thread =>
+ thread.id === 10 &&
+ thread.model === 'mail.channel'
+ ).localId
+ }"]
+ `).length,
+ 1,
+ "should have preview of chat"
+ );
+ assert.strictEqual(
+ document.querySelectorAll(`
+ .o_MessagingMenu_dropdownMenu
+ .o_ThreadPreview[data-thread-local-id="${
+ this.env.models['mail.thread'].find(thread =>
+ thread.id === 20 &&
+ thread.model === 'mail.channel'
+ ).localId
+ }"]
+ `).length,
+ 0,
+ "should not have preview of channel"
+ );
+
+ await afterNextRender(() =>
+ document.querySelector('.o_MessagingMenu_tabButton[data-tab-id="channel"]').click()
+ );
+ assert.strictEqual(
+ document.querySelectorAll(`
+ .o_MessagingMenu_dropdownMenu
+ .o_ThreadPreview
+ `).length,
+ 1,
+ "should have one preview"
+ );
+ assert.strictEqual(
+ document.querySelectorAll(`
+ .o_MessagingMenu_dropdownMenu
+ .o_ThreadPreview[data-thread-local-id="${
+ this.env.models['mail.thread'].find(thread =>
+ thread.id === 10 &&
+ thread.model === 'mail.channel'
+ ).localId
+ }"]
+ `).length,
+ 0,
+ "should not have preview of chat"
+ );
+ assert.strictEqual(
+ document.querySelectorAll(`
+ .o_MessagingMenu_dropdownMenu
+ .o_ThreadPreview[data-thread-local-id="${
+ this.env.models['mail.thread'].find(thread =>
+ thread.id === 20 &&
+ thread.model === 'mail.channel'
+ ).localId
+ }"]
+ `).length,
+ 1,
+ "should have preview of channel"
+ );
+
+ await afterNextRender(() =>
+ document.querySelector('.o_MessagingMenu_tabButton[data-tab-id="all"]').click()
+ );
+ assert.strictEqual(
+ document.querySelectorAll(`.o_MessagingMenu_dropdownMenu .o_ThreadPreview`).length,
+ 2,
+ "should have 2 previews"
+ );
+ assert.strictEqual(
+ document.querySelectorAll(`
+ .o_MessagingMenu_dropdownMenu
+ .o_ThreadPreview[data-thread-local-id="${
+ this.env.models['mail.thread'].find(thread =>
+ thread.id === 10 &&
+ thread.model === 'mail.channel'
+ ).localId
+ }"]
+ `).length,
+ 1,
+ "should have preview of chat"
+ );
+ assert.strictEqual(
+ document.querySelectorAll(`
+ .o_MessagingMenu_dropdownMenu
+ .o_ThreadPreview[data-thread-local-id="${
+ this.env.models['mail.thread'].find(thread =>
+ thread.id === 20 &&
+ thread.model === 'mail.channel'
+ ).localId
+ }"]
+ `).length,
+ 1,
+ "should have preview of channel"
+ );
+});
+
+QUnit.test('open chat window from preview', async function (assert) {
+ assert.expect(1);
+
+ Object.assign(this.data.initMessaging, {
+ channel_slots: {
+ channel_channel: [{
+ channel_type: "channel",
+ id: 20,
+ name: "General",
+ }],
+ },
+ });
+ await this.start({
+ hasChatWindow: true,
+ async mockRPC(route, args) {
+ if (args.method === 'channel_fetch_preview') {
+ return [];
+ }
+ return this._super(...arguments);
+ },
+ });
+
+ await afterNextRender(() =>
+ document.querySelector(`.o_MessagingMenu_toggler`).click()
+ );
+ await afterNextRender(() =>
+ document.querySelector(`.o_MessagingMenu_dropdownMenu .o_ThreadPreview`).click()
+ );
+ assert.strictEqual(
+ document.querySelectorAll(`.o_ChatWindow`).length,
+ 1,
+ "should have open a chat window"
+ );
+});
+
+QUnit.test('no code injection in message body preview', async function (assert) {
+ assert.expect(5);
+
+ Object.assign(this.data.initMessaging, {
+ channel_slots: {
+ channel_channel: [{
+ channel_type: "channel",
+ id: 1,
+ name: "General",
+ }],
+ },
+ });
+
+ this.data['mail.channel'].records = [{
+ id: 1,
+ name: "general",
+ channel_type: "channel",
+ channel_message_ids: [1],
+ }];
+ this.data['mail.message'].records = [{
+ id: 1,
+ author_id: [1, 'Georges'],
+ body: "<p><em>&shoulnotberaised</em><script>throw new Error('CodeInjectionError');</script></p>",
+ channel_ids: [1],
+ }];
+
+ await this.start();
+
+ await afterNextRender(() => {
+ document.querySelector(`.o_MessagingMenu_toggler`).click();
+ });
+ assert.containsOnce(
+ document.body,
+ '.o_MessagingMenu_dropdownMenu .o_ThreadPreview',
+ "should display a preview",
+ );
+ assert.containsOnce(
+ document.body,
+ '.o_ThreadPreview_core',
+ "preview should have core in content",
+ );
+ assert.containsOnce(
+ document.body,
+ '.o_ThreadPreview_inlineText',
+ "preview should have inline text in core of content",
+ );
+ assert.strictEqual(
+ document.querySelector('.o_ThreadPreview_inlineText')
+ .textContent.replace(/\s/g, ""),
+ "Georges:&shoulnotberaisedthrownewError('CodeInjectionError');",
+ "should display correct uninjected last message inline content"
+ );
+ assert.containsNone(
+ document.querySelector('.o_ThreadPreview_inlineText'),
+ 'script',
+ "last message inline content should not have any code injection"
+ );
+});
+
+});
+});
+});
+
+});
diff --git a/addons/mail/static/src/components/mobile_messaging_navbar/mobile_messaging_navbar.js b/addons/mail/static/src/components/mobile_messaging_navbar/mobile_messaging_navbar.js
new file mode 100644
index 00000000000..923480b3f73
--- /dev/null
+++ b/addons/mail/static/src/components/mobile_messaging_navbar/mobile_messaging_navbar.js
@@ -0,0 +1,50 @@
+odoo.define('mail/static/src/components/mobile_messaging_navbar/mobile_messaging_navbar.js', function (require) {
+'use strict';
+
+const { Component } = owl;
+
+class MobileMessagingNavbar extends Component {
+
+ //--------------------------------------------------------------------------
+ // Handlers
+ //--------------------------------------------------------------------------
+
+ /**
+ * @private
+ * @param {MouseEvent} ev
+ */
+ _onClick(ev) {
+ this.trigger('o-select-mobile-messaging-navbar-tab', {
+ tabId: ev.currentTarget.dataset.tabId,
+ });
+ }
+
+}
+
+Object.assign(MobileMessagingNavbar, {
+ defaultProps: {
+ tabs: [],
+ },
+ props: {
+ activeTabId: String,
+ tabs: {
+ type: Array,
+ element: {
+ type: Object,
+ shape: {
+ icon: {
+ type: String,
+ optional: true,
+ },
+ id: String,
+ label: String,
+ },
+ },
+ },
+ },
+ template: 'mail.MobileMessagingNavbar',
+});
+
+return MobileMessagingNavbar;
+
+});
diff --git a/addons/mail/static/src/components/mobile_messaging_navbar/mobile_messaging_navbar.scss b/addons/mail/static/src/components/mobile_messaging_navbar/mobile_messaging_navbar.scss
new file mode 100644
index 00000000000..df0611f9115
--- /dev/null
+++ b/addons/mail/static/src/components/mobile_messaging_navbar/mobile_messaging_navbar.scss
@@ -0,0 +1,43 @@
+// ------------------------------------------------------------------
+// Layout
+// ------------------------------------------------------------------
+
+.o_MobileMessagingNavbar {
+ display: flex;
+ flex: 0 0 auto;
+ z-index: 1;
+}
+
+.o_MobileMessagingNavbar_tab {
+ display: flex;
+ flex-flow: column;
+ align-items: center;
+ flex: 1 1 0;
+ padding: 8px;
+}
+
+.o_MobileMessagingNavbar_tabIcon {
+ margin-bottom: 4%;
+ font-size: 1.3em;
+}
+
+.o_MobileMessagingNavbar_tabLabel {
+ font-size: 0.8em;
+}
+
+// ------------------------------------------------------------------
+// Style
+// ------------------------------------------------------------------
+
+.o_MobileMessagingNavbar {
+ background-color: white;
+ box-shadow: 0 0 8px gray('400');
+}
+
+.o_MobileMessagingNavbar_tab {
+ box-shadow: 1px 0 0 gray('400');
+
+ &.o-active {
+ color: $o-brand-primary;
+ }
+}
diff --git a/addons/mail/static/src/components/mobile_messaging_navbar/mobile_messaging_navbar.xml b/addons/mail/static/src/components/mobile_messaging_navbar/mobile_messaging_navbar.xml
new file mode 100644
index 00000000000..d60611bf786
--- /dev/null
+++ b/addons/mail/static/src/components/mobile_messaging_navbar/mobile_messaging_navbar.xml
@@ -0,0 +1,17 @@
+<?xml version="1.0" encoding="UTF-8"?>
+<templates xml:space="preserve">
+
+ <t t-name="mail.MobileMessagingNavbar" owl="1">
+ <div class="o_MobileMessagingNavbar">
+ <t t-foreach="props.tabs" t-as="tab" t-key="tab.id">
+ <div class="o_MobileMessagingNavbar_tab" t-att-class="{ 'o-active': props.activeTabId === tab.id }" t-on-click="_onClick" t-att-data-tab-id="tab.id">
+ <t t-if="tab.icon">
+ <span class="o_MobileMessagingNavbar_tabIcon" t-att-class="tab.icon"/>
+ </t>
+ <span class="o_MobileMessagingNavbar_tabLabel"><t t-esc="tab.label"/></span>
+ </div>
+ </t>
+ </div>
+ </t>
+
+</templates>
diff --git a/addons/mail/static/src/components/moderation_ban_dialog/moderation_ban_dialog.js b/addons/mail/static/src/components/moderation_ban_dialog/moderation_ban_dialog.js
new file mode 100644
index 00000000000..ec260829cf4
--- /dev/null
+++ b/addons/mail/static/src/components/moderation_ban_dialog/moderation_ban_dialog.js
@@ -0,0 +1,79 @@
+odoo.define('mail/static/src/components/moderation_ban_dialog/moderation_ban_dialog.js', function (require) {
+'use strict';
+
+const useStore = require('mail/static/src/component_hooks/use_store/use_store.js');
+
+const Dialog = require('web.OwlDialog');
+
+const { Component } = owl;
+const { useRef } = owl.hooks;
+
+class ModerationBanDialog extends Component {
+
+ /**
+ * @override
+ */
+ constructor(...args) {
+ super(...args);
+ useStore(props => {
+ const messages = props.messageLocalIds.map(localId =>
+ this.env.models['mail.message'].get(localId)
+ );
+ return {
+ messages: messages.map(message => message ? message.__state : undefined),
+ };
+ }, {
+ compareDepth: {
+ messages: 1,
+ },
+ });
+ // to manually trigger the dialog close event
+ this._dialogRef = useRef('dialog');
+ }
+
+ //--------------------------------------------------------------------------
+ // Public
+ //--------------------------------------------------------------------------
+
+ /**
+ * @returns {mail.message[]}
+ */
+ get messages() {
+ return this.props.messageLocalIds.map(localId => this.env.models['mail.message'].get(localId));
+ }
+
+ //--------------------------------------------------------------------------
+ // Handlers
+ //--------------------------------------------------------------------------
+
+ /**
+ * @private
+ */
+ _onClickBan() {
+ this._dialogRef.comp._close();
+ this.env.models['mail.message'].moderate(this.messages, 'ban');
+ }
+
+ /**
+ * @private
+ */
+ _onClickCancel() {
+ this._dialogRef.comp._close();
+ }
+
+}
+
+Object.assign(ModerationBanDialog, {
+ components: { Dialog },
+ props: {
+ messageLocalIds: {
+ type: Array,
+ element: String,
+ },
+ },
+ template: 'mail.ModerationBanDialog',
+});
+
+return ModerationBanDialog;
+
+});
diff --git a/addons/mail/static/src/components/moderation_ban_dialog/moderation_ban_dialog.xml b/addons/mail/static/src/components/moderation_ban_dialog/moderation_ban_dialog.xml
new file mode 100644
index 00000000000..d0bc59c19a6
--- /dev/null
+++ b/addons/mail/static/src/components/moderation_ban_dialog/moderation_ban_dialog.xml
@@ -0,0 +1,26 @@
+<?xml version="1.0" encoding="UTF-8"?>
+<templates xml:space="preserve">
+ <t t-name="mail.ModerationBanDialog" owl="1">
+ <div><!-- TODO FIXME necessary div for useRef, can be removed at next owl update, see https://github.com/odoo/owl/issues/638 -->
+ <t t-set="title">Confirmation</t>
+ <Dialog contentClass="'o_ModerationBanDialog'" title="title" size="'medium'" t-ref="dialog">
+ <t t-if="messages.length === 1">
+ <p>You are going to ban the following user:</p>
+ </t>
+ <t t-else="">
+ <p>You are going to ban the following users:</p>
+ </t>
+ <ul class="my-5">
+ <t t-foreach="messages" t-as="message" t-key="message.localId">
+ <li t-esc="message.email_from"/>
+ </t>
+ </ul>
+ <p>Do you confirm the action?</p>
+ <t t-set-slot="buttons">
+ <button class="o-ban btn btn-primary" t-on-click="_onClickBan">Ban</button>
+ <button class="o-cancel btn btn-secondary" t-on-click="_onClickCancel">Cancel</button>
+ </t>
+ </Dialog>
+ </div>
+ </t>
+</templates>
diff --git a/addons/mail/static/src/components/moderation_discard_dialog/moderation_discard_dialog.js b/addons/mail/static/src/components/moderation_discard_dialog/moderation_discard_dialog.js
new file mode 100644
index 00000000000..597d2ac3262
--- /dev/null
+++ b/addons/mail/static/src/components/moderation_discard_dialog/moderation_discard_dialog.js
@@ -0,0 +1,94 @@
+odoo.define('mail/static/src/components/moderation_discard_dialog/moderation_discard_dialog.js', function (require) {
+'use strict';
+
+const useStore = require('mail/static/src/component_hooks/use_store/use_store.js');
+
+const Dialog = require('web.OwlDialog');
+
+const { Component } = owl;
+const { useRef } = owl.hooks;
+
+class ModerationDiscardDialog extends Component {
+
+ /**
+ * @override
+ */
+ constructor(...args) {
+ super(...args);
+ useStore(props => {
+ const messages = props.messageLocalIds.map(localId =>
+ this.env.models['mail.message'].get(localId)
+ );
+ return {
+ messages: messages.map(message => message ? message.__state : undefined),
+ };
+ }, {
+ compareDepth: {
+ messages: 1,
+ },
+ });
+ // to manually trigger the dialog close event
+ this._dialogRef = useRef('dialog');
+ }
+
+ //--------------------------------------------------------------------------
+ // Public
+ //--------------------------------------------------------------------------
+
+ /**
+ * @returns {string}
+ */
+ getText() {
+ if (this.messages.length === 1) {
+ return this.env._t("You are going to discard 1 message.");
+ }
+ return _.str.sprintf(
+ this.env._t("You are going to discard %s messages."),
+ this.messages.length
+ );
+ }
+
+ /**
+ * @returns {mail.message[]}
+ */
+ get messages() {
+ return this.props.messageLocalIds.map(localId =>
+ this.env.models['mail.message'].get(localId)
+ );
+ }
+
+ //--------------------------------------------------------------------------
+ // Handlers
+ //--------------------------------------------------------------------------
+
+ /**
+ * @private
+ */
+ _onClickCancel() {
+ this._dialogRef.comp._close();
+ }
+
+ /**
+ * @private
+ */
+ _onClickDiscard() {
+ this._dialogRef.comp._close();
+ this.env.models['mail.message'].moderate(this.messages, 'discard');
+ }
+
+}
+
+Object.assign(ModerationDiscardDialog, {
+ components: { Dialog },
+ props: {
+ messageLocalIds: {
+ type: Array,
+ element: String,
+ },
+ },
+ template: 'mail.ModerationDiscardDialog',
+});
+
+return ModerationDiscardDialog;
+
+});
diff --git a/addons/mail/static/src/components/moderation_discard_dialog/moderation_discard_dialog.xml b/addons/mail/static/src/components/moderation_discard_dialog/moderation_discard_dialog.xml
new file mode 100644
index 00000000000..13cafb0f047
--- /dev/null
+++ b/addons/mail/static/src/components/moderation_discard_dialog/moderation_discard_dialog.xml
@@ -0,0 +1,16 @@
+<?xml version="1.0" encoding="UTF-8"?>
+<templates xml:space="preserve">
+ <t t-name="mail.ModerationDiscardDialog" owl="1">
+ <div><!-- TODO FIXME necessary div for useRef, can be removed at next owl update, see https://github.com/odoo/owl/issues/638 -->
+ <t t-set="title">Confirmation</t>
+ <Dialog contentClass="'o_ModerationDiscardDialog'" title="title" size="'medium'" t-ref="dialog">
+ <p t-esc="getText()"/>
+ <p>Do you confirm the action?</p>
+ <t t-set-slot="buttons">
+ <button class="o-discard btn btn-primary" t-on-click="_onClickDiscard">Discard</button>
+ <button class="o-cancel btn btn-secondary" t-on-click="_onClickCancel">Cancel</button>
+ </t>
+ </Dialog>
+ </div>
+ </t>
+</templates>
diff --git a/addons/mail/static/src/components/moderation_reject_dialog/moderation_reject_dialog.js b/addons/mail/static/src/components/moderation_reject_dialog/moderation_reject_dialog.js
new file mode 100644
index 00000000000..95f90e42c7d
--- /dev/null
+++ b/addons/mail/static/src/components/moderation_reject_dialog/moderation_reject_dialog.js
@@ -0,0 +1,89 @@
+odoo.define('mail/static/src/components/moderation_reject_dialog/moderation_reject_dialog.js', function (require) {
+'use strict';
+
+const useStore = require('mail/static/src/component_hooks/use_store/use_store.js');
+
+const Dialog = require('web.OwlDialog');
+
+const { Component, useState } = owl;
+const { useRef } = owl.hooks;
+
+class ModerationRejectDialog extends Component {
+
+ /**
+ * @override
+ */
+ constructor(...args) {
+ super(...args);
+ this.state = useState({
+ title: this.env._t("Message Rejected"),
+ comment: this.env._t("Your message was rejected by moderator."),
+ });
+ useStore(props => {
+ const messages = props.messageLocalIds.map(localId =>
+ this.env.models['mail.message'].get(localId)
+ );
+ return {
+ messages: messages.map(message => message ? message.__state : undefined),
+ };
+ }, {
+ compareDepth: {
+ messages: 1,
+ },
+ });
+ // to manually trigger the dialog close event
+ this._dialogRef = useRef('dialog');
+ }
+
+ //--------------------------------------------------------------------------
+ // Public
+ //--------------------------------------------------------------------------
+
+ /**
+ * @returns {mail.message[]}
+ */
+ get messages() {
+ return this.props.messageLocalIds.map(localId =>
+ this.env.models['mail.message'].get(localId)
+ );
+ }
+
+ //--------------------------------------------------------------------------
+ // Handlers
+ //--------------------------------------------------------------------------
+
+ /**
+ * @private
+ */
+ _onClickCancel() {
+ this._dialogRef.comp._close();
+ }
+
+ /**
+ * @private
+ */
+ _onClickReject() {
+ this._dialogRef.comp._close();
+ const kwargs = {
+ title: this.state.title,
+ comment: this.state.comment,
+ };
+ this.env.models['mail.message'].moderate(this.messages, 'reject', kwargs);
+ }
+
+}
+
+Object.assign(ModerationRejectDialog, {
+ components: { Dialog },
+ props: {
+ messageLocalIds: {
+ type: Array,
+ element: String,
+ },
+ },
+ template: 'mail.ModerationRejectDialog',
+});
+
+return ModerationRejectDialog;
+
+});
diff --git a/addons/mail/static/src/components/moderation_reject_dialog/moderation_reject_dialog.xml b/addons/mail/static/src/components/moderation_reject_dialog/moderation_reject_dialog.xml
new file mode 100644
index 00000000000..a908265d33b
--- /dev/null
+++ b/addons/mail/static/src/components/moderation_reject_dialog/moderation_reject_dialog.xml
@@ -0,0 +1,16 @@
+<?xml version="1.0" encoding="UTF-8"?>
+<templates xml:space="preserve">
+ <t t-name="mail.ModerationRejectDialog" owl="1">
+ <div><!-- TODO FIXME necessary div for useRef, can be removed at next owl update, see https://github.com/odoo/owl/issues/638 -->
+ <t t-set="title">Send explanation to author</t>
+ <Dialog contentClass="'o_ModerationRejectDialog'" title="title" size="'medium'" t-ref="dialog">
+ <input class="o_ModerationRejectDialog_title form-control" type="text" placeholder="Subject" autofocus="autofocus" t-model="state.title"/>
+ <textarea class="o_ModerationRejectDialog_comment form-control mt16" placeholder="Mail Body" t-model="state.comment"/>
+ <t t-set-slot="buttons">
+ <button class="o-reject btn btn-primary" t-on-click="_onClickReject">Reject</button>
+ <button class="o-cancel btn btn-secondary" t-on-click="_onClickCancel">Cancel</button>
+ </t>
+ </Dialog>
+ </div>
+ </t>
+</templates>
diff --git a/addons/mail/static/src/components/notification_group/notification_group.js b/addons/mail/static/src/components/notification_group/notification_group.js
new file mode 100644
index 00000000000..4020a610dc6
--- /dev/null
+++ b/addons/mail/static/src/components/notification_group/notification_group.js
@@ -0,0 +1,85 @@
+odoo.define('mail/static/src/components/notification_group/notification_group.js', function (require) {
+'use strict';
+
+const useStore = require('mail/static/src/component_hooks/use_store/use_store.js');
+
+const { Component } = owl;
+const { useRef } = owl.hooks;
+
+class NotificationGroup extends Component {
+
+ /**
+ * @override
+ */
+ constructor(...args) {
+ super(...args);
+ useStore(props => {
+ const group = this.env.models['mail.notification_group'].get(props.notificationGroupLocalId);
+ return {
+ group: group ? group.__state : undefined,
+ };
+ });
+ /**
+ * Reference of the "mark as read" button. Useful to disable the
+ * top-level click handler when clicking on this specific button.
+ */
+ this._markAsReadRef = useRef('markAsRead');
+ }
+
+ //--------------------------------------------------------------------------
+ // Public
+ //--------------------------------------------------------------------------
+
+ /**
+ * @returns {mail.notification_group}
+ */
+ get group() {
+ return this.env.models['mail.notification_group'].get(this.props.notificationGroupLocalId);
+ }
+
+ /**
+ * @returns {string|undefined}
+ */
+ image() {
+ if (this.group.notification_type === 'email') {
+ return '/mail/static/src/img/smiley/mailfailure.jpg';
+ }
+ }
+
+ //--------------------------------------------------------------------------
+ // Handlers
+ //--------------------------------------------------------------------------
+
+ /**
+ * @private
+ * @param {MouseEvent} ev
+ */
+ _onClick(ev) {
+ const markAsRead = this._markAsReadRef.el;
+ if (markAsRead && markAsRead.contains(ev.target)) {
+ // handled in `_onClickMarkAsRead`
+ return;
+ }
+ this.group.openDocuments();
+ }
+
+ /**
+ * @private
+ * @param {MouseEvent} ev
+ */
+ _onClickMarkAsRead(ev) {
+ this.group.openCancelAction();
+ }
+
+}
+
+Object.assign(NotificationGroup, {
+ props: {
+ notificationGroupLocalId: String,
+ },
+ template: 'mail.NotificationGroup',
+});
+
+return NotificationGroup;
+
+});
diff --git a/addons/mail/static/src/components/notification_group/notification_group.scss b/addons/mail/static/src/components/notification_group/notification_group.scss
new file mode 100644
index 00000000000..0ba937ec127
--- /dev/null
+++ b/addons/mail/static/src/components/notification_group/notification_group.scss
@@ -0,0 +1,89 @@
+// ------------------------------------------------------------------
+// Layout
+// ------------------------------------------------------------------
+
+.o_NotificationGroup {
+ @include o-mail-notification-list-item-layout();
+
+ &:hover .o_NotificationGroup_markAsRead {
+ // TODO also mixin this
+ // task-2258605
+ opacity: 1;
+ }
+}
+
+.o_NotificationGroup_content {
+ @include o-mail-notification-list-item-content-layout();
+}
+
+.o_NotificationGroup_core {
+ @include o-mail-notification-list-item-core-layout();
+}
+
+.o_NotificationGroup_coreItem {
+ @include o-mail-notification-list-item-core-item-layout();
+}
+
+.o_NotificationGroup_counter {
+ @include o-mail-notification-list-item-counter-layout();
+}
+
+.o_NotificationGroup_date {
+ @include o-mail-notification-list-item-date-layout();
+}
+
+.o_NotificationGroup_header {
+ @include o-mail-notification-list-item-header-layout();
+}
+
+.o_NotificationGroup_image {
+ @include o-mail-notification-list-item-image-layout();
+}
+
+.o_NotificationGroup_imageContainer {
+ @include o-mail-notification-list-item-image-container-layout();
+}
+
+.o_NotificationGroup_inlineText {
+ @include o-mail-notification-list-item-inline-text-layout();
+}
+
+.o_NotificationGroup_markAsRead {
+ @include o-mail-notification-list-item-mark-as-read-layout();
+}
+
+.o_NotificationGroup_name {
+ @include o-mail-notification-list-item-name-layout();
+}
+
+.o_NotificationGroup_sidebar {
+ @include o-mail-notification-list-item-sidebar-layout();
+}
+
+// ------------------------------------------------------------------
+// Style
+// ------------------------------------------------------------------
+
+.o_NotificationGroup {
+ @include o-mail-notification-list-item-style();
+}
+
+.o_NotificationGroup_core {
+ @include o-mail-notification-list-item-core-style();
+}
+
+.o_NotificationGroup_counter {
+ @include o-mail-notification-list-item-bold-style();
+}
+
+.o_NotificationGroup_date {
+ @include o-mail-notification-list-item-date-style();
+}
+
+.o_NotificationGroup_markAsRead {
+ @include o-mail-notification-list-item-mark-as-read-style();
+}
+
+.o_NotificationGroup_name {
+ @include o-mail-notification-list-item-bold-style();
+}
diff --git a/addons/mail/static/src/components/notification_group/notification_group.xml b/addons/mail/static/src/components/notification_group/notification_group.xml
new file mode 100644
index 00000000000..c2f3dcebda1
--- /dev/null
+++ b/addons/mail/static/src/components/notification_group/notification_group.xml
@@ -0,0 +1,39 @@
+<?xml version="1.0" encoding="UTF-8"?>
+<templates xml:space="preserve">
+
+ <t t-name="mail.NotificationGroup" owl="1">
+ <div class="o_NotificationGroup" t-on-click="_onClick">
+ <t t-if="group">
+ <div class="o_NotificationGroup_sidebar">
+ <div class="o_NotificationGroup_imageContainer o_NotificationGroup_sidebarItem">
+ <img class="o_NotificationGroup_image rounded-circle" t-att-src="image()" alt="Message delivery failure image"/>
+ </div>
+ </div>
+ <div class="o_NotificationGroup_content">
+ <div class="o_NotificationGroup_header">
+ <span class="o_NotificationGroup_name">
+ <t t-esc="group.res_model_name"/>
+ </span>
+ <span class="o_NotificationGroup_counter">
+ (<t t-esc="group.notifications.length"/>)
+ </span>
+ <span class="o-autogrow"/>
+ <span class="o_NotificationGroup_date">
+ <t t-esc="group.date.fromNow()"/>
+ </span>
+ </div>
+ <div class="o_NotificationGroup_core">
+ <span class="o_NotificationGroup_coreItem o_NotificationGroup_inlineText">
+ <t t-if="group.notification_type === 'email'">
+ An error occurred when sending an email.
+ </t>
+ </span>
+ <span class="o-autogrow"/>
+ <span class="o_NotificationGroup_coreItem o_NotificationGroup_markAsRead fa fa-check" title="Discard message delivery failures" t-on-click="_onClickMarkAsRead" t-ref="markAsRead"/>
+ </div>
+ </div>
+ </t>
+ </div>
+ </t>
+
+</templates>
diff --git a/addons/mail/static/src/components/notification_list/notification_list.js b/addons/mail/static/src/components/notification_list/notification_list.js
new file mode 100644
index 00000000000..5f714afc6d6
--- /dev/null
+++ b/addons/mail/static/src/components/notification_list/notification_list.js
@@ -0,0 +1,216 @@
+odoo.define('mail/static/src/components/notification_list/notification_list.js', function (require) {
+'use strict';
+
+const components = {
+ NotificationGroup: require('mail/static/src/components/notification_group/notification_group.js'),
+ ThreadNeedactionPreview: require('mail/static/src/components/thread_needaction_preview/thread_needaction_preview.js'),
+ ThreadPreview: require('mail/static/src/components/thread_preview/thread_preview.js'),
+};
+const useStore = require('mail/static/src/component_hooks/use_store/use_store.js');
+
+const { Component } = owl;
+
+class NotificationList extends Component {
+
+ /**
+ * @override
+ */
+ constructor(...args) {
+ super(...args);
+ this.storeProps = useStore((...args) => this._useStoreSelector(...args), {
+ compareDepth: {
+ // list + notification object created in useStore
+ notifications: 2,
+ },
+ });
+ }
+
+ mounted() {
+ this._loadPreviews();
+ }
+
+ //--------------------------------------------------------------------------
+ // Public
+ //--------------------------------------------------------------------------
+
+ /**
+ * @returns {Object[]}
+ */
+ get notifications() {
+ const { notifications } = this.storeProps;
+ return notifications;
+ }
+
+ //--------------------------------------------------------------------------
+ // Private
+ //--------------------------------------------------------------------------
+
+ /**
+ * Load previews of given thread. Basically consists of fetching all missing
+ * last messages of each thread.
+ *
+ * @private
+ */
+ async _loadPreviews() {
+ const threads = this.notifications
+ .filter(notification => notification.thread)
+ .map(notification => this.env.models['mail.thread'].get(notification.thread));
+ this.env.models['mail.thread'].loadPreviews(threads);
+ }
+
+ /**
+ * @private
+ * @param {Object} props
+ */
+ _useStoreSelector(props) {
+ const threads = this._useStoreSelectorThreads(props);
+ let threadNeedactionNotifications = [];
+ if (props.filter === 'all') {
+ // threads with needactions
+ threadNeedactionNotifications = this.env.models['mail.thread']
+ .all(t => t.model !== 'mail.box' && t.needactionMessages.length > 0)
+ .sort((t1, t2) => {
+ if (t1.needactionMessages.length > 0 && t2.needactionMessages.length === 0) {
+ return -1;
+ }
+ if (t1.needactionMessages.length === 0 && t2.needactionMessages.length > 0) {
+ return 1;
+ }
+ if (t1.lastNeedactionMessage && t2.lastNeedactionMessage) {
+ return t1.lastNeedactionMessage.date.isBefore(t2.lastNeedactionMessage.date) ? 1 : -1;
+ }
+ if (t1.lastNeedactionMessage) {
+ return -1;
+ }
+ if (t2.lastNeedactionMessage) {
+ return 1;
+ }
+ return t1.id < t2.id ? -1 : 1;
+ })
+ .map(thread => {
+ return {
+ thread,
+ type: 'thread_needaction',
+ uniqueId: thread.localId + '_needaction',
+ };
+ });
+ }
+ // thread notifications
+ const threadNotifications = threads
+ .sort((t1, t2) => {
+ if (t1.message_unread_counter > 0 && t2.message_unread_counter === 0) {
+ return -1;
+ }
+ if (t1.message_unread_counter === 0 && t2.message_unread_counter > 0) {
+ return 1;
+ }
+ if (t1.lastMessage && t2.lastMessage) {
+ return t1.lastMessage.date.isBefore(t2.lastMessage.date) ? 1 : -1;
+ }
+ if (t1.lastMessage) {
+ return -1;
+ }
+ if (t2.lastMessage) {
+ return 1;
+ }
+ return t1.id < t2.id ? -1 : 1;
+ })
+ .map(thread => {
+ return {
+ thread,
+ type: 'thread',
+ uniqueId: thread.localId,
+ };
+ });
+ let notifications = threadNeedactionNotifications.concat(threadNotifications);
+ if (props.filter === 'all') {
+ const notificationGroups = this.env.messaging.notificationGroupManager.groups;
+ notifications = Object.values(notificationGroups)
+ .sort((group1, group2) =>
+ group1.date.isAfter(group2.date) ? -1 : 1
+ ).map(notificationGroup => {
+ return {
+ notificationGroup,
+ uniqueId: notificationGroup.localId,
+ };
+ }).concat(notifications);
+ }
+ return {
+ isDeviceMobile: this.env.messaging.device.isMobile,
+ notifications,
+ };
+ }
+
+ /**
+ * @private
+ * @param {Object} props
+ * @throws {Error} in case `props.filter` is not supported
+ * @returns {mail.thread[]}
+ */
+ _useStoreSelectorThreads(props) {
+ if (props.filter === 'mailbox') {
+ return this.env.models['mail.thread']
+ .all(thread => thread.isPinned && thread.model === 'mail.box')
+ .sort((mailbox1, mailbox2) => {
+ if (mailbox1 === this.env.messaging.inbox) {
+ return -1;
+ }
+ if (mailbox2 === this.env.messaging.inbox) {
+ return 1;
+ }
+ if (mailbox1 === this.env.messaging.starred) {
+ return -1;
+ }
+ if (mailbox2 === this.env.messaging.starred) {
+ return 1;
+ }
+ const mailbox1Name = mailbox1.displayName;
+ const mailbox2Name = mailbox2.displayName;
+ mailbox1Name < mailbox2Name ? -1 : 1;
+ });
+ } else if (props.filter === 'channel') {
+ return this.env.models['mail.thread']
+ .all(thread =>
+ thread.channel_type === 'channel' &&
+ thread.isPinned &&
+ thread.model === 'mail.channel'
+ )
+ .sort((c1, c2) => c1.displayName < c2.displayName ? -1 : 1);
+ } else if (props.filter === 'chat') {
+ return this.env.models['mail.thread']
+ .all(thread =>
+ thread.channel_type === 'chat' &&
+ thread.isPinned &&
+ thread.model === 'mail.channel'
+ )
+ .sort((c1, c2) => c1.displayName < c2.displayName ? -1 : 1);
+ } else if (props.filter === 'all') {
+ // "All" filter is for channels and chats
+ return this.env.models['mail.thread']
+ .all(thread => thread.isPinned && thread.model === 'mail.channel')
+ .sort((c1, c2) => c1.displayName < c2.displayName ? -1 : 1);
+ } else {
+ throw new Error(`Unsupported filter ${props.filter}`);
+ }
+ }
+
+}
+
+Object.assign(NotificationList, {
+ _allowedFilters: ['all', 'mailbox', 'channel', 'chat'],
+ components,
+ defaultProps: {
+ filter: 'all',
+ },
+ props: {
+ filter: {
+ type: String,
+ validate: prop => NotificationList._allowedFilters.includes(prop),
+ },
+ },
+ template: 'mail.NotificationList',
+});
+
+return NotificationList;
+
+});
diff --git a/addons/mail/static/src/components/notification_list/notification_list.scss b/addons/mail/static/src/components/notification_list/notification_list.scss
new file mode 100644
index 00000000000..18e3114952c
--- /dev/null
+++ b/addons/mail/static/src/components/notification_list/notification_list.scss
@@ -0,0 +1,37 @@
+// ------------------------------------------------------------------
+// Layout
+// ------------------------------------------------------------------
+
+ .o_NotificationList {
+ display: flex;
+ flex-flow: column;
+ overflow: auto;
+
+ &.o-empty {
+ justify-content: center;
+ }
+}
+
+.o_NotificationList_noConversation {
+ display: flex;
+ align-items: center;
+ justify-content: center;
+ padding: map-get($spacers, 4) map-get($spacers, 2);
+}
+
+.o_NotificationList_separator {
+ flex: 0 0 auto;
+ width: map-get($sizes, 100);
+}
+
+// ------------------------------------------------------------------
+// Style
+// ------------------------------------------------------------------
+
+.o_NotificationList_separator {
+ border-bottom: $border-width solid $border-color;
+}
+
+.o_NotificationList_noConversation {
+ color: $text-muted;
+}
diff --git a/addons/mail/static/src/components/notification_list/notification_list.xml b/addons/mail/static/src/components/notification_list/notification_list.xml
new file mode 100644
index 00000000000..bcf65da985d
--- /dev/null
+++ b/addons/mail/static/src/components/notification_list/notification_list.xml
@@ -0,0 +1,41 @@
+<?xml version="1.0" encoding="UTF-8"?>
+<templates xml:space="preserve">
+
+ <t t-name="mail.NotificationList" owl="1">
+ <div class="o_NotificationList" t-att-class="{ 'o-empty': notifications.length === 0 }">
+ <t t-if="notifications.length === 0">
+ <div class="o_NotificationList_noConversation">
+ No conversation yet...
+ </div>
+ </t>
+ <t t-else="">
+ <t t-foreach="notifications" t-as="notification" t-key="notification.uniqueId">
+ <t t-if="notification.type === 'thread' and notification.thread">
+ <ThreadPreview
+ class="o_NotificationList_preview"
+ t-att-class="{ 'o-mobile': env.messaging.device.isMobile }"
+ threadLocalId="notification.thread.localId"
+ />
+ </t>
+ <t t-if="notification.type === 'thread_needaction' and notification.thread">
+ <ThreadNeedactionPreview
+ class="o_NotificationList_preview"
+ t-att-class="{ 'o-mobile': env.messaging.device.isMobile }"
+ threadLocalId="notification.thread.localId"
+ />
+ </t>
+ <t t-if="notification.notificationGroup">
+ <NotificationGroup
+ class="o_NotificationList_group"
+ notificationGroupLocalId="notification.notificationGroup.localId"
+ />
+ </t>
+ <t name="separatorCondition" t-if="!notification_last">
+ <div class="o_NotificationList_separator"/>
+ </t>
+ </t>
+ </t>
+ </div>
+ </t>
+
+</templates>
diff --git a/addons/mail/static/src/components/notification_list/notification_list_item.scss b/addons/mail/static/src/components/notification_list/notification_list_item.scss
new file mode 100644
index 00000000000..265e908a894
--- /dev/null
+++ b/addons/mail/static/src/components/notification_list/notification_list_item.scss
@@ -0,0 +1,174 @@
+// -----------------------------------------------------------------------------
+// Layout
+// -----------------------------------------------------------------------------
+
+@mixin o-mail-notification-list-item-layout {
+ display: flex;
+ align-items: center;
+ padding: map-get($spacers, 1);
+
+ &.o-mobile {
+ padding: map-get($spacers, 2);
+ }
+}
+
+@mixin o-mail-notification-list-item-content-layout {
+ display: flex;
+ flex-flow: column;
+ flex: 1 1 auto;
+ align-self: flex-start;
+ min-width: 0; // needed for flex to work correctly
+ margin: map-get($spacers, 2);
+}
+
+@mixin o-mail-notification-list-item-core-layout {
+ display: flex;
+}
+
+@mixin o-mail-notification-list-item-core-item-layout {
+ margin: map-get($spacers, 0) map-get($spacers, 2);
+
+ &:first-child {
+ margin-inline-start: map-get($spacers, 0);
+ }
+
+ &:last-child {
+ margin-inline-end: map-get($spacers, 0);
+ }
+}
+
+@mixin o-mail-notification-list-item-counter-layout() {
+ margin: map-get($spacers, 0) map-get($spacers, 2);
+}
+
+@mixin o-mail-notification-list-item-date-layout() {
+ flex: 0 0 auto;
+}
+
+@mixin o-mail-notification-list-item-header-layout {
+ display: flex;
+ margin-bottom: map-get($spacers, 1);
+}
+
+@mixin o-mail-notification-list-item-image-layout {
+ width: map-get($sizes, 100);
+ height: map-get($sizes, 100);
+}
+
+@mixin o-mail-notification-list-item-image-container-layout {
+ position: relative;
+ width: 40px;
+ height: 40px;
+}
+
+@mixin o-mail-notification-list-item-inline-text-layout {
+ white-space: nowrap;
+ overflow: hidden;
+ text-overflow: ellipsis;
+
+ &.o-empty::before {
+ content: '\00a0'; // keep line-height as if it had content
+ }
+}
+
+@mixin o-mail-notification-list-item-mark-as-read-layout() {
+ display: flex;
+ flex: 0 0 auto;
+}
+
+@mixin o-mail-notification-list-item-name-layout {
+ white-space: nowrap;
+ overflow: hidden;
+ text-overflow: ellipsis;
+
+ &.o-mobile {
+ font-size: 1.1em;
+ }
+}
+
+@mixin o-mail-notification-list-item-partner-im-status-icon-layout {
+ @include o-position-absolute($bottom: 0, $right: 0);
+ display: flex;
+ align-items: center;
+ justify-content: center;
+}
+
+@mixin o-mail-notification-list-item-sidebar-layout {
+ margin: map-get($spacers, 1);
+}
+
+// -----------------------------------------------------------------------------
+// Style
+// -----------------------------------------------------------------------------
+
+$o-mail-notification-list-item-background-color: $white !default;
+$o-mail-notification-list-item-hover-background-color:
+ darken($o-mail-notification-list-item-background-color, 7%) !default;
+
+$o-mail-notification-list-item-muted-background-color: gray('100') !default;
+$o-mail-notification-list-item-muted-hover-background-color:
+ darken($o-mail-notification-list-item-muted-background-color, 7%) !default;
+
+@mixin o-mail-notification-list-item-style {
+ cursor: pointer;
+ user-select: none;
+ background-color: $o-mail-notification-list-item-background-color;
+
+ &:hover {
+ background-color: $o-mail-notification-list-item-hover-background-color;
+ }
+
+ &.o-muted {
+ background-color: $o-mail-notification-list-item-muted-background-color;
+
+ &:hover {
+ background-color: $o-mail-notification-list-item-muted-hover-background-color;
+ }
+ }
+}
+
+@mixin o-mail-notification-list-item-bold-style {
+ font-weight: bold;
+
+ &.o-muted {
+ font-weight: initial;
+ }
+}
+
+@mixin o-mail-notification-list-item-core-style {
+ color: gray('500');
+}
+
+@mixin o-mail-notification-list-item-date-style() {
+ @include o-mail-notification-list-item-bold-style();
+ font-size: x-small;
+ color: $o-brand-primary;
+}
+
+@mixin o-mail-notification-list-item-mark-as-read-style() {
+ opacity: 0;
+
+ &:hover {
+ color: gray('600');
+ }
+}
+
+@mixin o-mail-notification-list-item-hover-partner-im-status-icon-style {
+ color: $o-mail-notification-list-item-hover-background-color;
+}
+
+@mixin o-mail-notification-list-item-muted-hover-partner-im-status-icon-style {
+ color: $o-mail-notification-list-item-muted-hover-background-color;
+}
+
+@mixin o-mail-notification-list-item-partner-im-status-icon-style {
+ color: $o-mail-notification-list-item-background-color;
+
+ &:not(.o-mobile) {
+ font-size: x-small;
+ }
+
+ &.o-muted {
+ color: $o-mail-notification-list-item-muted-background-color;
+ }
+}
diff --git a/addons/mail/static/src/components/notification_list/notification_list_notification_group_tests.js b/addons/mail/static/src/components/notification_list/notification_list_notification_group_tests.js
new file mode 100644
index 00000000000..f76fd920d82
--- /dev/null
+++ b/addons/mail/static/src/components/notification_list/notification_list_notification_group_tests.js
@@ -0,0 +1,563 @@
+odoo.define('mail/static/src/components/notification_list/notification_list_notification_group_tests.js', function (require) {
+'use strict';
+
+const components = {
+ NotificationList: require('mail/static/src/components/notification_list/notification_list.js'),
+};
+
+const {
+ afterEach: utilsAfterEach,
+ afterNextRender,
+ beforeEach: utilsBeforeEach,
+ start: utilsStart,
+} = require('mail/static/src/utils/test_utils.js');
+
+const Bus = require('web.Bus');
+
+QUnit.module('mail', {}, function () {
+QUnit.module('components', {}, function () {
+QUnit.module('notification_list', {}, function () {
+QUnit.module('notification_list_notification_group_tests.js', {
+ beforeEach() {
+ utilsBeforeEach(this);
+
+ /**
+ * @param {Object} param0
+ * @param {string} [param0.filter='all']
+ */
+ this.createNotificationListComponent = async ({ filter = 'all' } = {}) => {
+ const NotificationListComponent = components.NotificationList;
+ NotificationListComponent.env = this.env;
+ this.component = new NotificationListComponent(null, { filter });
+ await afterNextRender(() => this.component.mount(this.widget.el));
+ };
+
+ this.start = async params => {
+ let { env, widget } = await utilsStart(Object.assign({}, params, {
+ data: this.data,
+ }));
+ this.env = env;
+ this.widget = widget;
+ };
+ },
+ afterEach() {
+ utilsAfterEach(this);
+ if (this.component) {
+ this.component.destroy();
+ this.component = undefined;
+ }
+ if (this.widget) {
+ this.widget.destroy();
+ this.widget = undefined;
+ }
+ this.env = undefined;
+ delete components.NotificationList.env;
+ },
+});
+
+QUnit.test('notification group basic layout', async function (assert) {
+ assert.expect(10);
+
+ this.data.initMessaging.mail_failures = [{
+ date: moment.utc().format("YYYY-MM-DD HH:mm:ss"),
+ id: 11,
+ message_type: 'email',
+ model: 'mail.channel',
+ notifications: [{
+ failure_type: 'SMTP',
+ id: 21,
+ notification_status: 'exception',
+ notification_type: 'email',
+ partner_id: [41, "Someone"],
+ }],
+ res_id: 31,
+ res_model_name: "Channel",
+ }];
+ await this.start();
+ await this.createNotificationListComponent();
+
+ assert.containsOnce(
+ document.body,
+ '.o_NotificationGroup',
+ "should have 1 notification group"
+ );
+ assert.containsOnce(
+ document.body,
+ '.o_NotificationGroup_name',
+ "should have 1 group name"
+ );
+ assert.strictEqual(
+ document.querySelector('.o_NotificationGroup_name').textContent,
+ "Channel",
+ "should have model name as group name"
+ );
+ assert.containsOnce(
+ document.body,
+ '.o_NotificationGroup_counter',
+ "should have 1 group counter"
+ );
+ assert.strictEqual(
+ document.querySelector('.o_NotificationGroup_counter').textContent.trim(),
+ "(1)",
+ "should have only 1 notification in the group"
+ );
+ assert.containsOnce(
+ document.body,
+ '.o_NotificationGroup_date',
+ "should have 1 group date"
+ );
+ assert.strictEqual(
+ document.querySelector('.o_NotificationGroup_date').textContent,
+ "a few seconds ago",
+ "should have the group date corresponding to now"
+ );
+ assert.containsOnce(
+ document.body,
+ '.o_NotificationGroup_inlineText',
+ "should have 1 group text"
+ );
+ assert.strictEqual(
+ document.querySelector('.o_NotificationGroup_inlineText').textContent.trim(),
+ "An error occurred when sending an email.",
+ "should have the group text corresponding to email"
+ );
+ assert.containsOnce(
+ document.body,
+ '.o_NotificationGroup_markAsRead',
+ "should have 1 mark as read button"
+ );
+});
+
+QUnit.test('mark as read', async function (assert) {
+ assert.expect(6);
+
+ this.data.initMessaging.mail_failures = [{
+ date: moment.utc().format("YYYY-MM-DD HH:mm:ss"),
+ id: 11,
+ message_type: 'email',
+ model: 'mail.channel',
+ notifications: [{
+ failure_type: 'SMTP',
+ id: 21,
+ notification_status: 'exception',
+ notification_type: 'email',
+ partner_id: [41, "Someone"],
+ }],
+ res_id: 31,
+ res_model_name: "Channel",
+ }];
+ const bus = new Bus();
+ bus.on('do-action', null, payload => {
+ assert.step('do_action');
+ assert.strictEqual(
+ payload.action,
+ 'mail.mail_resend_cancel_action',
+ "action should be the one to cancel email"
+ );
+ assert.strictEqual(
+ payload.options.additional_context.default_model,
+ 'mail.channel',
+ "action should have the group model as default_model"
+ );
+ assert.strictEqual(
+ payload.options.additional_context.unread_counter,
+ 1,
+ "action should have the group notification length as unread_counter"
+ );
+ });
+
+ await this.start({ env: { bus } });
+ await this.createNotificationListComponent();
+
+ assert.containsOnce(
+ document.body,
+ '.o_NotificationGroup_markAsRead',
+ "should have 1 mark as read button"
+ );
+
+ document.querySelector('.o_NotificationGroup_markAsRead').click();
+ assert.verifySteps(
+ ['do_action'],
+ "should do an action to display the cancel email dialog"
+ );
+});
+
+QUnit.test('grouped notifications by document', async function (assert) {
+ // If some failures linked to a document refers to a same document, a single
+ // notification should group all those failures.
+ assert.expect(9);
+
+ this.data.initMessaging.mail_failures = [{
+ date: moment.utc().format("YYYY-MM-DD HH:mm:ss"),
+ id: 11,
+ message_type: 'email',
+ model: 'res.partner', // key element of this test: same model
+ notifications: [{
+ failure_type: 'SMTP',
+ id: 21,
+ notification_status: 'exception',
+ notification_type: 'email',
+ partner_id: [41, "Someone"],
+ }],
+ res_id: 31,
+ res_model_name: "Partner",
+ }, {
+ date: moment.utc().format("YYYY-MM-DD HH:mm:ss"),
+ id: 12,
+ message_type: 'email',
+ model: 'res.partner', // key element of this test: same model
+ notifications: [{
+ failure_type: 'SMTP',
+ id: 22,
+ notification_status: 'bounce',
+ notification_type: 'email',
+ partner_id: [42, "Someone else"],
+ }],
+ res_id: 31,
+ res_model_name: "Partner",
+ }];
+ const bus = new Bus();
+ bus.on('do-action', null, payload => {
+ assert.step('do_action');
+ assert.strictEqual(
+ payload.action.type,
+ 'ir.actions.act_window',
+ "action should have the type act_window"
+ );
+ assert.strictEqual(
+ payload.action.res_model,
+ 'res.partner',
+ "action should have the group model as res_model"
+ );
+ assert.strictEqual(
+ JSON.stringify(payload.action.views),
+ JSON.stringify([[false, 'form']]),
+ "action should have form view"
+ );
+ assert.strictEqual(
+ payload.action.res_id,
+ 31,
+ "action should have the group res_id as res_id"
+ );
+ });
+
+ await this.start({ env: { bus } });
+ await this.createNotificationListComponent();
+
+ assert.containsOnce(
+ document.body,
+ '.o_NotificationGroup',
+ "should have 1 notification group"
+ );
+ assert.containsOnce(
+ document.body,
+ '.o_NotificationGroup_counter',
+ "should have 1 group counter"
+ );
+ assert.strictEqual(
+ document.querySelector('.o_NotificationGroup_counter').textContent.trim(),
+ "(2)",
+ "should have 2 notifications in the group"
+ );
+
+ document.querySelector('.o_NotificationGroup').click();
+ assert.verifySteps(
+ ['do_action'],
+ "should do an action to display the related record"
+ );
+});
+
+QUnit.test('grouped notifications by document model', async function (assert) {
+ // If all failures linked to a document model refers to different documents,
+ // a single notification should group all failures that are linked to this
+ // document model.
+ assert.expect(12);
+
+ this.data.initMessaging.mail_failures = [{
+ date: moment.utc().format("YYYY-MM-DD HH:mm:ss"),
+ id: 11,
+ message_type: 'email',
+ model: 'res.partner', // key element of this test: same model
+ notifications: [{
+ failure_type: 'SMTP',
+ id: 21,
+ notification_status: 'exception',
+ notification_type: 'email',
+ partner_id: [41, "Someone"],
+ }],
+ res_id: 31, // key element of this test: a different res_id
+ res_model_name: "Partner",
+ }, {
+ date: moment.utc().format("YYYY-MM-DD HH:mm:ss"),
+ id: 12,
+ message_type: 'email',
+ model: 'res.partner', // key element of this test: same model
+ notifications: [{
+ failure_type: 'SMTP',
+ id: 22,
+ notification_status: 'bounce',
+ notification_type: 'email',
+ partner_id: [42, "Someone else"],
+ }],
+ res_id: 32, // key element of this test: a different res_id
+ res_model_name: "Partner",
+ }];
+ const bus = new Bus();
+ bus.on('do-action', null, payload => {
+ assert.step('do_action');
+ assert.strictEqual(
+ payload.action.name,
+ "Mail Failures",
+ "action should have 'Mail Failures' as name",
+ );
+ assert.strictEqual(
+ payload.action.type,
+ 'ir.actions.act_window',
+ "action should have the type act_window"
+ );
+ assert.strictEqual(
+ payload.action.view_mode,
+ 'kanban,list,form',
+ "action should have 'kanban,list,form' as view_mode"
+ );
+ assert.strictEqual(
+ JSON.stringify(payload.action.views),
+ JSON.stringify([[false, 'kanban'], [false, 'list'], [false, 'form']]),
+ "action should have correct views"
+ );
+ assert.strictEqual(
+ payload.action.target,
+ 'current',
+ "action should have 'current' as target"
+ );
+ assert.strictEqual(
+ payload.action.res_model,
+ 'res.partner',
+ "action should have the group model as res_model"
+ );
+ assert.strictEqual(
+ JSON.stringify(payload.action.domain),
+ JSON.stringify([['message_has_error', '=', true]]),
+ "action should have 'message_has_error' as domain"
+ );
+ });
+
+ await this.start({ env: { bus } });
+ await this.createNotificationListComponent();
+
+ assert.containsOnce(
+ document.body,
+ '.o_NotificationGroup',
+ "should have 1 notification group"
+ );
+ assert.containsOnce(
+ document.body,
+ '.o_NotificationGroup_counter',
+ "should have 1 group counter"
+ );
+ assert.strictEqual(
+ document.querySelector('.o_NotificationGroup_counter').textContent.trim(),
+ "(2)",
+ "should have 2 notifications in the group"
+ );
+
+ document.querySelector('.o_NotificationGroup').click();
+ assert.verifySteps(
+ ['do_action'],
+ "should do an action to display the related records"
+ );
+});
+
+QUnit.test('different mail.channel are not grouped', async function (assert) {
+ // `mail.channel` is a special case where notifications are not grouped when
+ // they are linked to different channels, even though the model is the same.
+ assert.expect(6);
+
+ this.data.initMessaging.mail_failures = [{
+ date: moment.utc().format("YYYY-MM-DD HH:mm:ss"),
+ id: 11,
+ message_type: 'email',
+ model: 'mail.channel', // key element of this test: `mail.channel`
+ notifications: [{
+ failure_type: 'SMTP',
+ id: 21,
+ notification_status: 'exception',
+ notification_type: 'email',
+ partner_id: [41, "Someone"],
+ }],
+ res_id: 31, // key element of this test: a different res_id
+ res_model_name: "Channel",
+ }, {
+ date: moment.utc().format("YYYY-MM-DD HH:mm:ss"),
+ id: 12,
+ message_type: 'email',
+ model: 'mail.channel', // key element of this test: `mail.channel`
+ notifications: [{
+ failure_type: 'SMTP',
+ id: 22,
+ notification_status: 'bounce',
+ notification_type: 'email',
+ partner_id: [42, "Someone else"],
+ }],
+ res_id: 32, // key element of this test: a different res_id
+ res_model_name: "Channel",
+ }];
+ await this.start({
+ hasChatWindow: true, // needed to assert thread.open
+ });
+ await this.createNotificationListComponent();
+
+ assert.containsN(
+ document.body,
+ '.o_NotificationGroup',
+ 2,
+ "should have 2 notifications group"
+ );
+ const groups = document.querySelectorAll('.o_NotificationGroup');
+ assert.containsOnce(
+ groups[0],
+ '.o_NotificationGroup_counter',
+ "should have 1 group counter in first group"
+ );
+ assert.strictEqual(
+ groups[0].querySelector('.o_NotificationGroup_counter').textContent.trim(),
+ "(1)",
+ "should have 1 notification in first group"
+ );
+ assert.containsOnce(
+ groups[1],
+ '.o_NotificationGroup_counter',
+ "should have 1 group counter in second group"
+ );
+ assert.strictEqual(
+ groups[1].querySelector('.o_NotificationGroup_counter').textContent.trim(),
+ "(1)",
+ "should have 1 notification in second group"
+ );
+
+ await afterNextRender(() => groups[0].click());
+ assert.containsOnce(
+ document.body,
+ '.o_ChatWindow',
+ "should have opened the channel related to the first group in a chat window"
+ );
+});
+
+QUnit.test('multiple grouped notifications by document model, sorted by date desc', async function (assert) {
+ assert.expect(9);
+
+ this.data.initMessaging.mail_failures = [{
+ date: moment.utc().format("YYYY-MM-DD HH:mm:ss"),
+ id: 11,
+ message_type: 'email',
+ model: 'res.partner', // key element of this test: different model
+ notifications: [{
+ failure_type: 'SMTP',
+ id: 21,
+ notification_status: 'exception',
+ notification_type: 'email',
+ partner_id: [41, "Someone"],
+ }],
+ res_id: 31,
+ res_model_name: "Partner",
+ }, {
+ date: moment.utc().add(1, 'days').format("YYYY-MM-DD HH:mm:ss"),
+ id: 12,
+ message_type: 'email',
+ model: 'res.company', // key element of this test: different model
+ notifications: [{
+ failure_type: 'SMTP',
+ id: 22,
+ notification_status: 'bounce',
+ notification_type: 'email',
+ partner_id: [42, "Someone else"],
+ }],
+ res_id: 32,
+ res_model_name: "Company",
+ }];
+ await this.start();
+ await this.createNotificationListComponent();
+
+ assert.containsN(
+ document.body,
+ '.o_NotificationGroup',
+ 2,
+ "should have 2 notifications group"
+ );
+ const groups = document.querySelectorAll('.o_NotificationGroup');
+ assert.containsOnce(
+ groups[0],
+ '.o_NotificationGroup_name',
+ "should have 1 group name in first group"
+ );
+ assert.strictEqual(
+ groups[0].querySelector('.o_NotificationGroup_name').textContent,
+ "Company",
+ "should have first model name as group name"
+ );
+ assert.containsOnce(
+ groups[0],
+ '.o_NotificationGroup_counter',
+ "should have 1 group counter in first group"
+ );
+ assert.strictEqual(
+ groups[0].querySelector('.o_NotificationGroup_counter').textContent.trim(),
+ "(1)",
+ "should have 1 notification in first group"
+ );
+ assert.containsOnce(
+ groups[1],
+ '.o_NotificationGroup_name',
+ "should have 1 group name in second group"
+ );
+ assert.strictEqual(
+ groups[1].querySelector('.o_NotificationGroup_name').textContent,
+ "Partner",
+ "should have second model name as group name"
+ );
+ assert.containsOnce(
+ groups[1],
+ '.o_NotificationGroup_counter',
+ "should have 1 group counter in second group"
+ );
+ assert.strictEqual(
+ groups[1].querySelector('.o_NotificationGroup_counter').textContent.trim(),
+ "(1)",
+ "should have 1 notification in second group"
+ );
+});
+
+QUnit.test('non-failure notifications are ignored', async function (assert) {
+ assert.expect(1);
+
+ this.data.initMessaging.mail_failures = [{
+ date: moment.utc().format("YYYY-MM-DD HH:mm:ss"),
+ id: 11,
+ message_type: 'email',
+ model: 'res.partner',
+ notifications: [{
+ failure_type: false,
+ id: 21,
+ // key element of this test: status is not bounce or exception
+ notification_status: 'ready',
+ notification_type: 'email',
+ partner_id: [41, "Someone"],
+ }],
+ res_id: 31,
+ res_model_name: "Partner",
+ }];
+ await this.start();
+ await this.createNotificationListComponent();
+
+ assert.containsNone(
+ document.body,
+ '.o_NotificationGroup',
+ "should have 0 notification group"
+ );
+});
+
+});
+});
+});
+
+});
diff --git a/addons/mail/static/src/components/notification_list/notification_list_tests.js b/addons/mail/static/src/components/notification_list/notification_list_tests.js
new file mode 100644
index 00000000000..cc71ee2d0f2
--- /dev/null
+++ b/addons/mail/static/src/components/notification_list/notification_list_tests.js
@@ -0,0 +1,210 @@
+odoo.define('mail/static/src/components/notification_list/notification_list_tests.js', function (require) {
+'use strict';
+
+const components = {
+ NotificationList: require('mail/static/src/components/notification_list/notification_list.js'),
+};
+
+const {
+ afterEach: utilsAfterEach,
+ afterNextRender,
+ beforeEach: utilsBeforeEach,
+ start: utilsStart,
+} = require('mail/static/src/utils/test_utils.js');
+
+QUnit.module('mail', {}, function () {
+QUnit.module('components', {}, function () {
+QUnit.module('notification_list', {}, function () {
+QUnit.module('notification_list_tests.js', {
+ beforeEach() {
+ utilsBeforeEach(this);
+
+ /**
+ * @param {Object} param0
+ * @param {string} [param0.filter='all']
+ */
+ this.createNotificationListComponent = async ({ filter = 'all' }) => {
+ const NotificationListComponent = components.NotificationList;
+ NotificationListComponent.env = this.env;
+ this.component = new NotificationListComponent(null, { filter });
+ await afterNextRender(() => this.component.mount(this.widget.el));
+ };
+
+ this.start = async params => {
+ Object.assign(this.data.initMessaging, {
+ channel_slots: {
+ channel_channel: [{
+ channel_type: 'channel',
+ id: 100,
+ name: "Channel 2019",
+ message_unread_counter: 0,
+ }, {
+ channel_type: 'channel',
+ id: 200,
+ name: "Channel 2020",
+ message_unread_counter: 0,
+ }],
+ },
+ });
+ let { env, widget } = await utilsStart(Object.assign({}, params, {
+ data: this.data,
+ }));
+ this.env = env;
+ this.widget = widget;
+ };
+ },
+ afterEach() {
+ utilsAfterEach(this);
+ if (this.component) {
+ this.component.destroy();
+ this.component = undefined;
+ }
+ if (this.widget) {
+ this.widget.destroy();
+ this.widget = undefined;
+ }
+ this.env = undefined;
+ delete components.NotificationList.env;
+ },
+});
+
+QUnit.test('marked as read thread notifications are ordered by last message date', async function (assert) {
+ assert.expect(3);
+
+ this.data['mail.channel'].records = [{
+ channel_type: 'channel',
+ id: 100,
+ message_unread_counter: 0,
+ name: "Channel 2019",
+ }, {
+ channel_type: 'channel',
+ id: 200,
+ message_unread_counter: 0,
+ name: "Channel 2020",
+ }];
+ this.data['mail.message'].records = [{
+ author_id: [10, "Author A"],
+ body: "<p>Message A</p>",
+ channel_ids: [100],
+ date: "2019-01-01 00:00:00",
+ id: 42,
+ message_type: 'comment',
+ model: 'mail.channel',
+ record_name: 'Channel 2019',
+ res_id: 100,
+ }, {
+ author_id: [20, "Author B"],
+ body: "<p>Message B</p>",
+ channel_ids: [200],
+ date: "2020-01-01 00:00:00",
+ id: 43,
+ message_type: 'comment',
+ model: 'mail.channel',
+ record_name: 'Channel 2020',
+ res_id: 200,
+ }];
+ await this.start();
+ await this.createNotificationListComponent({ filter: 'all' });
+ assert.containsN(
+ document.body,
+ '.o_ThreadPreview',
+ 2,
+ "there should be two thread previews"
+ );
+ const threadPreviewElList = document.querySelectorAll('.o_ThreadPreview');
+ assert.strictEqual(
+ threadPreviewElList[0].querySelector(':scope .o_ThreadPreview_name').textContent,
+ 'Channel 2020',
+ "First channel in the list should be the channel of 2020 (more recent last message)"
+ );
+ assert.strictEqual(
+ threadPreviewElList[1].querySelector(':scope .o_ThreadPreview_name').textContent,
+ 'Channel 2019',
+ "Second channel in the list should be the channel of 2019 (least recent last message)"
+ );
+});
+
+QUnit.test('thread notifications are re-ordered on receiving a new message', async function (assert) {
+ assert.expect(4);
+
+ this.data['mail.channel'].records = [{
+ channel_type: 'channel',
+ id: 100,
+ message_unread_counter: 0,
+ name: "Channel 2019",
+ }, {
+ channel_type: 'channel',
+ id: 200,
+ message_unread_counter: 0,
+ name: "Channel 2020",
+ }];
+ this.data['mail.message'].records = [{
+ author_id: [10, "Author A"],
+ body: "<p>Message A</p>",
+ channel_ids: [100],
+ date: "2019-01-01 00:00:00",
+ id: 42,
+ message_type: 'comment',
+ model: 'mail.channel',
+ record_name: 'Channel 2019',
+ res_id: 100,
+ }, {
+ author_id: [20, "Author B"],
+ body: "<p>Message B</p>",
+ channel_ids: [200],
+ date: "2020-01-01 00:00:00",
+ id: 43,
+ message_type: 'comment',
+ model: 'mail.channel',
+ record_name: 'Channel 2020',
+ res_id: 200,
+ }];
+ await this.start();
+ await this.createNotificationListComponent({ filter: 'all' });
+ assert.containsN(
+ document.body,
+ '.o_ThreadPreview',
+ 2,
+ "there should be two thread previews"
+ );
+
+ await afterNextRender(() => {
+ const messageData = {
+ author_id: [7, "Demo User"],
+ body: "<p>New message !</p>",
+ channel_ids: [100],
+ date: "2020-03-23 10:00:00",
+ id: 44,
+ message_type: 'comment',
+ model: 'mail.channel',
+ record_name: 'Channel 2019',
+ res_id: 100,
+ };
+ this.widget.call('bus_service', 'trigger', 'notification', [
+ [['my-db', 'mail.channel', 100], messageData]
+ ]);
+ });
+ assert.containsN(
+ document.body,
+ '.o_ThreadPreview',
+ 2,
+ "there should still be two thread previews"
+ );
+ const threadPreviewElList = document.querySelectorAll('.o_ThreadPreview');
+ assert.strictEqual(
+ threadPreviewElList[0].querySelector(':scope .o_ThreadPreview_name').textContent,
+ 'Channel 2019',
+ "First channel in the list should now be 'Channel 2019'"
+ );
+ assert.strictEqual(
+ threadPreviewElList[1].querySelector(':scope .o_ThreadPreview_name').textContent,
+ 'Channel 2020',
+ "Second channel in the list should now be 'Channel 2020'"
+ );
+});
+
+});
+});
+});
+
+});
diff --git a/addons/mail/static/src/components/notification_popover/notification_popover.js b/addons/mail/static/src/components/notification_popover/notification_popover.js
new file mode 100644
index 00000000000..0f3fce77a58
--- /dev/null
+++ b/addons/mail/static/src/components/notification_popover/notification_popover.js
@@ -0,0 +1,89 @@
+odoo.define('mail/static/src/components/notification_popover/notification_popover.js', function (require) {
+'use strict';
+
+const { Component } = owl;
+const useStore = require('mail/static/src/component_hooks/use_store/use_store.js');
+
+class NotificationPopover extends Component {
+
+ /**
+ * @override
+ */
+ constructor(...args) {
+ super(...args);
+ useStore(props => {
+ const notifications = props.notificationLocalIds.map(
+ notificationLocalId => this.env.models['mail.notification'].get(notificationLocalId)
+ );
+ return {
+ notifications: notifications.map(notification => notification ? notification.__state : undefined),
+ };
+ }, {
+ compareDepth: {
+ notifications: 1,
+ },
+ });
+ }
+
+ /**
+ * @returns {string}
+ */
+ get iconClass() {
+ switch (this.notification.notification_status) {
+ case 'sent':
+ return 'fa fa-check';
+ case 'bounce':
+ return 'fa fa-exclamation';
+ case 'exception':
+ return 'fa fa-exclamation';
+ case 'ready':
+ return 'fa fa-send-o';
+ case 'canceled':
+ return 'fa fa-trash-o';
+ }
+ return '';
+ }
+
+ /**
+ * @returns {string}
+ */
+ get iconTitle() {
+ switch (this.notification.notification_status) {
+ case 'sent':
+ return this.env._t("Sent");
+ case 'bounce':
+ return this.env._t("Bounced");
+ case 'exception':
+ return this.env._t("Error");
+ case 'ready':
+ return this.env._t("Ready");
+ case 'canceled':
+ return this.env._t("Canceled");
+ }
+ return '';
+ }
+
+ /**
+ * @returns {mail.notification[]}
+ */
+ get notifications() {
+ return this.props.notificationLocalIds.map(
+ notificationLocalId => this.env.models['mail.notification'].get(notificationLocalId)
+ );
+ }
+
+}
+
+Object.assign(NotificationPopover, {
+ props: {
+ notificationLocalIds: {
+ type: Array,
+ element: String,
+ },
+ },
+ template: 'mail.NotificationPopover',
+});
+
+return NotificationPopover;
+
+});
diff --git a/addons/mail/static/src/components/notification_popover/notification_popover.scss b/addons/mail/static/src/components/notification_popover/notification_popover.scss
new file mode 100644
index 00000000000..06b4201ca2c
--- /dev/null
+++ b/addons/mail/static/src/components/notification_popover/notification_popover.scss
@@ -0,0 +1,7 @@
+// -----------------------------------------------------------------------------
+// Layout
+// -----------------------------------------------------------------------------
+
+.o_NotificationPopover_notificationIcon {
+ margin-inline-end: map-get($spacers, 2);
+}
diff --git a/addons/mail/static/src/components/notification_popover/notification_popover.xml b/addons/mail/static/src/components/notification_popover/notification_popover.xml
new file mode 100644
index 00000000000..cf5aa027672
--- /dev/null
+++ b/addons/mail/static/src/components/notification_popover/notification_popover.xml
@@ -0,0 +1,17 @@
+<?xml version="1.0" encoding="UTF-8"?>
+<templates xml:space="preserve">
+
+ <t t-name="mail.NotificationPopover" owl="1">
+ <div class="o_NotificationPopover">
+ <t t-foreach="notifications" t-as="notification" t-key="notification.localId">
+ <div class="o_NotificationPopover_notification">
+ <i class="o_NotificationPopover_notificationIcon" t-att-class="iconClass" t-att-title="iconTitle" role="img"/>
+ <t t-if="notification.partner">
+ <span class="o_NotificationPopover_notificationPartnerName" t-esc="notification.partner.nameOrDisplayName"/>
+ </t>
+ </div>
+ </t>
+ </div>
+ </t>
+
+</templates>
diff --git a/addons/mail/static/src/components/partner_im_status_icon/partner_im_status_icon.js b/addons/mail/static/src/components/partner_im_status_icon/partner_im_status_icon.js
new file mode 100644
index 00000000000..7150c2914a9
--- /dev/null
+++ b/addons/mail/static/src/components/partner_im_status_icon/partner_im_status_icon.js
@@ -0,0 +1,52 @@
+odoo.define('mail/static/src/components/partner_im_status_icon/partner_im_status_icon.js', function (require) {
+'use strict';
+
+const useStore = require('mail/static/src/component_hooks/use_store/use_store.js');
+
+const { Component } = owl;
+
+class PartnerImStatusIcon extends Component {
+
+ /**
+ * @override
+ */
+ constructor(...args) {
+ super(...args);
+ useStore(props => {
+ const partner = this.env.models['mail.partner'].get(props.partnerLocalId);
+ return {
+ partner: partner ? partner.__state : undefined,
+ partnerRoot: this.env.messaging.partnerRoot
+ ? this.env.messaging.partnerRoot.__state
+ : undefined,
+ };
+ });
+ }
+
+ //--------------------------------------------------------------------------
+ // Public
+ //--------------------------------------------------------------------------
+
+ /**
+ * @returns {mail.partner}
+ */
+ get partner() {
+ return this.env.models['mail.partner'].get(this.props.partnerLocalId);
+ }
+
+}
+
+Object.assign(PartnerImStatusIcon, {
+ defaultProps: {
+ hasBackground: true
+ },
+ props: {
+ partnerLocalId: String,
+ hasBackground: Boolean,
+ },
+ template: 'mail.PartnerImStatusIcon',
+});
+
+return PartnerImStatusIcon;
+
+});
diff --git a/addons/mail/static/src/components/partner_im_status_icon/partner_im_status_icon.scss b/addons/mail/static/src/components/partner_im_status_icon/partner_im_status_icon.scss
new file mode 100644
index 00000000000..d5791c2f0c6
--- /dev/null
+++ b/addons/mail/static/src/components/partner_im_status_icon/partner_im_status_icon.scss
@@ -0,0 +1,53 @@
+// ------------------------------------------------------------------
+// Layout
+// ------------------------------------------------------------------
+
+.o_PartnerImStatusIcon {
+ display: flex;
+ flex-flow: column;
+
+ width: 1.2em;
+ height: 1.2em;
+ line-height: 1.3em;
+}
+
+.o_PartnerImStatusIcon_outerBackground {
+ transform: scale(1.5);
+}
+
+.o-background {
+ transform: scale(1);
+ margin-inline-end: map-get($spacers, 1);
+ margin-top: 2px;
+}
+
+// ------------------------------------------------------------------
+// Style
+// ------------------------------------------------------------------
+
+.o_PartnerImStatusIcon_innerBackground {
+ color: white;
+}
+
+.o_PartnerImStatusIcon_icon {
+
+ &.o-away {
+ color: theme-color('warning');
+ }
+
+ &.o-bot {
+ color: $o-enterprise-primary-color;
+ }
+
+ &.o-offline {
+ color: gray('700');
+ }
+
+ &.o-online {
+ color: $o-enterprise-primary-color;
+ }
+}
+
+
+
+
diff --git a/addons/mail/static/src/components/partner_im_status_icon/partner_im_status_icon.xml b/addons/mail/static/src/components/partner_im_status_icon/partner_im_status_icon.xml
new file mode 100644
index 00000000000..23879cc6b39
--- /dev/null
+++ b/addons/mail/static/src/components/partner_im_status_icon/partner_im_status_icon.xml
@@ -0,0 +1,36 @@
+<?xml version="1.0" encoding="UTF-8"?>
+<templates xml:space="preserve">
+
+ <t t-name="mail.PartnerImStatusIcon" owl="1">
+ <span class="o_PartnerImStatusIcon fa-stack"
+ t-att-class="{
+ 'o-away': partner and partner.im_status === 'away',
+ 'o-background': !props.hasBackground,
+ 'o-bot': partner and env.messaging.partnerRoot === partner,
+ 'o-offline': partner and partner.im_status === 'offline',
+ 'o-online': partner and partner.im_status === 'online',
+ }"
+ t-att-data-partner-local-id="partner ? partner.localId : undefined"
+ >
+ <t t-if="partner" name="rootCondition">
+ <t t-if="props.hasBackground">
+ <i class="o_PartnerImStatusIcon_outerBackground fa fa-circle fa-stack-1x"/>
+ <i class="o_PartnerImStatusIcon_innerBackground fa fa-circle fa-stack-1x"/>
+ </t>
+ <t t-if="partner.im_status === 'online'">
+ <i class="o_PartnerImStatusIcon_icon o-online fa fa-circle fa-stack-1x" title="Online" role="img" aria-label="User is online"/>
+ </t>
+ <t t-if="partner.im_status === 'away'">
+ <i class="o_PartnerImStatusIcon_icon o-away fa fa-circle fa-stack-1x" title="Idle" role="img" aria-label="User is idle"/>
+ </t>
+ <t t-if="partner.im_status === 'offline'">
+ <i class="o_PartnerImStatusIcon_icon o-offline fa fa-circle-o fa-stack-1x" title="Offline" role="img" aria-label="User is offline"/>
+ </t>
+ <t t-if="partner === env.messaging.partnerRoot">
+ <i class="o_PartnerImStatusIcon_icon o-bot fa fa-heart fa-stack-1x" title="Bot" role="img" aria-label="User is a bot"/>
+ </t>
+ </t>
+ </span>
+ </t>
+
+</templates>
diff --git a/addons/mail/static/src/components/partner_im_status_icon/partner_im_status_icon_tests.js b/addons/mail/static/src/components/partner_im_status_icon/partner_im_status_icon_tests.js
new file mode 100644
index 00000000000..01246ddcdb3
--- /dev/null
+++ b/addons/mail/static/src/components/partner_im_status_icon/partner_im_status_icon_tests.js
@@ -0,0 +1,152 @@
+odoo.define('mail/static/src/components/partner_im_status_icon/partner_im_status_icon_tests.js', function (require) {
+'use strict';
+
+const components = {
+ PartnerImStatusIcon: require('mail/static/src/components/partner_im_status_icon/partner_im_status_icon.js'),
+};
+const {
+ afterEach: utilsAfterEach,
+ afterNextRender,
+ beforeEach: utilsBeforeEach,
+ start: utilsStart,
+} = require('mail/static/src/utils/test_utils.js');
+
+QUnit.module('mail', {}, function () {
+QUnit.module('components', {}, function () {
+QUnit.module('partner_im_status_icon', {}, function () {
+QUnit.module('partner_im_status_icon_tests.js', {
+ beforeEach() {
+ utilsBeforeEach(this);
+
+ this.createPartnerImStatusIcon = async partner => {
+ const PartnerImStatusIconComponent = components.PartnerImStatusIcon;
+ PartnerImStatusIconComponent.env = this.env;
+ this.component = new PartnerImStatusIconComponent(null, { partnerLocalId: partner.localId });
+ await this.component.mount(this.widget.el);
+ };
+
+ this.start = async params => {
+ let { env, widget } = await utilsStart(Object.assign({}, params, {
+ data: this.data,
+ }));
+ this.env = env;
+ this.widget = widget;
+ };
+ },
+ afterEach() {
+ utilsAfterEach(this);
+ if (this.component) {
+ this.component.destroy();
+ }
+ if (this.widget) {
+ this.widget.destroy();
+ }
+ this.env = undefined;
+ delete components.PartnerImStatusIcon.env;
+ },
+});
+
+QUnit.test('initially online', async function (assert) {
+ assert.expect(3);
+
+ await this.start();
+ const partner = this.env.models['mail.partner'].create({
+ id: 7,
+ name: "Demo User",
+ im_status: 'online',
+ });
+ await this.createPartnerImStatusIcon(partner);
+ assert.strictEqual(
+ document.querySelectorAll(`.o_PartnerImStatusIcon`).length,
+ 1,
+ "should have partner IM status icon"
+ );
+ assert.strictEqual(
+ document.querySelector(`.o_PartnerImStatusIcon`).dataset.partnerLocalId,
+ partner.localId,
+ "partner IM status icon should be linked to partner with ID 7"
+ );
+ assert.strictEqual(
+ document.querySelectorAll(`.o_PartnerImStatusIcon.o-online`).length,
+ 1,
+ "partner IM status icon should have online status rendering"
+ );
+});
+
+QUnit.test('initially offline', async function (assert) {
+ assert.expect(1);
+
+ await this.start();
+ const partner = this.env.models['mail.partner'].create({
+ id: 7,
+ name: "Demo User",
+ im_status: 'offline',
+ });
+ await this.createPartnerImStatusIcon(partner);
+ assert.strictEqual(
+ document.querySelectorAll(`.o_PartnerImStatusIcon.o-offline`).length,
+ 1,
+ "partner IM status icon should have offline status rendering"
+ );
+});
+
+QUnit.test('initially away', async function (assert) {
+ assert.expect(1);
+
+ await this.start();
+ const partner = this.env.models['mail.partner'].create({
+ id: 7,
+ name: "Demo User",
+ im_status: 'away',
+ });
+ await this.createPartnerImStatusIcon(partner);
+ assert.strictEqual(
+ document.querySelectorAll(`.o_PartnerImStatusIcon.o-away`).length,
+ 1,
+ "partner IM status icon should have away status rendering"
+ );
+});
+
+QUnit.test('change icon on change partner im_status', async function (assert) {
+ assert.expect(4);
+
+ await this.start();
+ const partner = this.env.models['mail.partner'].create({
+ id: 7,
+ name: "Demo User",
+ im_status: 'online',
+ });
+ await this.createPartnerImStatusIcon(partner);
+ assert.strictEqual(
+ document.querySelectorAll(`.o_PartnerImStatusIcon.o-online`).length,
+ 1,
+ "partner IM status icon should have online status rendering"
+ );
+
+ await afterNextRender(() => partner.update({ im_status: 'offline' }));
+ assert.strictEqual(
+ document.querySelectorAll(`.o_PartnerImStatusIcon.o-offline`).length,
+ 1,
+ "partner IM status icon should have offline status rendering"
+ );
+
+ await afterNextRender(() => partner.update({ im_status: 'away' }));
+ assert.strictEqual(
+ document.querySelectorAll(`.o_PartnerImStatusIcon.o-away`).length,
+ 1,
+ "partner IM status icon should have away status rendering"
+ );
+
+ await afterNextRender(() => partner.update({ im_status: 'online' }));
+ assert.strictEqual(
+ document.querySelectorAll(`.o_PartnerImStatusIcon.o-online`).length,
+ 1,
+ "partner IM status icon should have online status rendering in the end"
+ );
+});
+
+});
+});
+});
+
+});
diff --git a/addons/mail/static/src/components/partner_mention_suggestion/partner_mention_suggestion.js b/addons/mail/static/src/components/partner_mention_suggestion/partner_mention_suggestion.js
new file mode 100644
index 00000000000..7c39f148264
--- /dev/null
+++ b/addons/mail/static/src/components/partner_mention_suggestion/partner_mention_suggestion.js
@@ -0,0 +1,79 @@
+odoo.define('mail/static/src/components/partner_mention_suggestion/partner_mention_suggestion.js', function (require) {
+'use strict';
+
+const useStore = require('mail/static/src/component_hooks/use_store/use_store.js');
+
+const components = {
+ PartnerImStatusIcon: require('mail/static/src/components/partner_im_status_icon/partner_im_status_icon.js'),
+};
+
+const { Component } = owl;
+
+class PartnerMentionSuggestion extends Component {
+
+ /**
+ * @override
+ */
+ constructor(...args) {
+ super(...args);
+ useStore(props => {
+ const partner = this.env.models['mail.partner'].get(props.partnerLocalId);
+ return {
+ partner: partner ? partner.__state : undefined,
+ };
+ });
+ }
+
+ //--------------------------------------------------------------------------
+ // Public
+ //--------------------------------------------------------------------------
+
+ /**
+ * @returns {mail.partner}
+ */
+ get partner() {
+ return this.env.models['mail.partner'].get(this.props.partnerLocalId);
+ }
+
+ //--------------------------------------------------------------------------
+ // Handlers
+ //--------------------------------------------------------------------------
+
+ /**
+ * @private
+ * @param {Event} ev
+ */
+ _onClick(ev) {
+ // avoid following dummy href
+ ev.preventDefault();
+ this.trigger('o-partner-mention-suggestion-clicked', {
+ partner: this.partner,
+ });
+ }
+
+ /**
+ * @private
+ * @param {Event} ev
+ */
+ _onMouseOver(ev) {
+ this.trigger('o-partner-mention-suggestion-mouse-over', {
+ partner: this.partner,
+ });
+ }
+}
+
+Object.assign(PartnerMentionSuggestion, {
+ components,
+ defaultProps: {
+ isActive: false,
+ },
+ props: {
+ isActive: Boolean,
+ partnerLocalId: String,
+ },
+ template: 'mail.PartnerMentionSuggestion',
+});
+
+return PartnerMentionSuggestion;
+
+});
diff --git a/addons/mail/static/src/components/partner_mention_suggestion/partner_mention_suggestion.scss b/addons/mail/static/src/components/partner_mention_suggestion/partner_mention_suggestion.scss
new file mode 100644
index 00000000000..b93d245972c
--- /dev/null
+++ b/addons/mail/static/src/components/partner_mention_suggestion/partner_mention_suggestion.scss
@@ -0,0 +1,26 @@
+// ------------------------------------------------------------------
+// Layout
+// ------------------------------------------------------------------
+
+.o_PartnerMentionSuggestion {
+ display: flex;
+ width: map-get($sizes, 100);
+ padding: map-get($spacers, 2) map-get($spacers, 4);
+}
+
+.o_PartnerMentionSuggestion_name {
+ padding-inline-end: map-get($spacers, 2);
+}
+
+// ------------------------------------------------------------------
+// Style
+// ------------------------------------------------------------------
+
+.o_PartnerMentionSuggestion_email {
+ font-style: italic;
+ color: $gray-600;
+}
+
+.o_PartnerMentionSuggestion_name {
+ font-weight: $font-weight-bold;
+}
diff --git a/addons/mail/static/src/components/partner_mention_suggestion/partner_mention_suggestion.xml b/addons/mail/static/src/components/partner_mention_suggestion/partner_mention_suggestion.xml
new file mode 100644
index 00000000000..a45e991ab19
--- /dev/null
+++ b/addons/mail/static/src/components/partner_mention_suggestion/partner_mention_suggestion.xml
@@ -0,0 +1,19 @@
+<?xml version="1.0" encoding="UTF-8"?>
+<templates xml:space="preserve">
+
+ <t t-name="mail.PartnerMentionSuggestion" owl="1">
+ <a class="o_PartnerMentionSuggestion dropdown-item" t-att-class="{ 'active': props.isActive }" href="#" role="menuitem" t-on-click="_onClick" t-on-mouseover="_onMouseOver">
+ <t t-if="partner">
+ <PartnerImStatusIcon
+ hasBackground="false"
+ partnerLocalId="partner.localId"
+ />
+ <span class="o_PartnerMentionSuggestion_name"><t t-esc="partner.nameOrDisplayName"/></span>
+ <t t-if="partner.email">
+ <span class="o_PartnerMentionSuggestion_email">(<t t-esc="partner.email"/>)</span>
+ </t>
+ </t>
+ </a>
+ </t>
+
+</templates>
diff --git a/addons/mail/static/src/components/partner_mention_suggestion/partner_mention_suggestion_tests.js b/addons/mail/static/src/components/partner_mention_suggestion/partner_mention_suggestion_tests.js
new file mode 100644
index 00000000000..e9b31a264ef
--- /dev/null
+++ b/addons/mail/static/src/components/partner_mention_suggestion/partner_mention_suggestion_tests.js
@@ -0,0 +1,141 @@
+odoo.define('mail/static/src/components/partner_mention_suggestion/partner_mention_suggestion_tests.js', function (require) {
+'use strict';
+
+const components = {
+ PartnerMentionSuggestion: require('mail/static/src/components/partner_mention_suggestion/partner_mention_suggestion.js'),
+};
+const {
+ afterEach: utilsAfterEach,
+ beforeEach: utilsBeforeEach,
+ start: utilsStart,
+} = require('mail/static/src/utils/test_utils.js');
+
+QUnit.module('mail', {}, function () {
+QUnit.module('components', {}, function () {
+QUnit.module('partner_mention_suggestion', {}, function () {
+QUnit.module('partner_mention_suggestion_tests.js', {
+ beforeEach() {
+ utilsBeforeEach(this);
+ this.createPartnerMentionSuggestion = async partner => {
+ const PartnerMentionSuggestionComponent = components.PartnerMentionSuggestion;
+ PartnerMentionSuggestionComponent.env = this.env;
+ this.component = new PartnerMentionSuggestionComponent(
+ null,
+ {
+ isActive: true,
+ partnerLocalId: partner.localId,
+ });
+ await this.component.mount(this.widget.el);
+ };
+ this.start = async params => {
+ let { env, widget } = await utilsStart(Object.assign({}, params, {
+ data: this.data,
+ }));
+ this.env = env;
+ this.widget = widget;
+ };
+ },
+ afterEach() {
+ utilsAfterEach(this);
+ if (this.component) {
+ this.component.destroy();
+ }
+ if (this.widget) {
+ this.widget.destroy();
+ }
+ this.env = undefined;
+ delete components.PartnerMentionSuggestion.env;
+ },
+});
+
+QUnit.test('partner mention suggestion displayed', async function (assert) {
+ assert.expect(1);
+
+ await this.start();
+ const partner = this.env.models['mail.partner'].create({
+ id: 7,
+ im_status: 'online',
+ name: "Demo User",
+ });
+ await this.createPartnerMentionSuggestion(partner);
+
+ assert.containsOnce(
+ document.body,
+ `.o_PartnerMentionSuggestion`,
+ "Partner mention suggestion should be present"
+ );
+});
+
+QUnit.test('partner mention suggestion correct data', async function (assert) {
+ assert.expect(6);
+
+ await this.start();
+ const partner = this.env.models['mail.partner'].create({
+ email: "demo_user@odoo.com",
+ id: 7,
+ im_status: 'online',
+ name: "Demo User",
+ });
+ await this.createPartnerMentionSuggestion(partner);
+
+ assert.containsOnce(
+ document.body,
+ '.o_PartnerMentionSuggestion',
+ "Partner mention suggestion should be present"
+ );
+ assert.strictEqual(
+ document.querySelectorAll(`.o_PartnerImStatusIcon`).length,
+ 1,
+ "Partner's im_status should be displayed"
+ );
+ assert.containsOnce(
+ document.body,
+ '.o_PartnerMentionSuggestion_name',
+ "Partner's name should be present"
+ );
+ assert.strictEqual(
+ document.querySelector(`.o_PartnerMentionSuggestion_name`).textContent,
+ "Demo User",
+ "Partner's name should be displayed"
+ );
+ assert.containsOnce(
+ document.body,
+ '.o_PartnerMentionSuggestion_email',
+ "Partner's email should be present"
+ );
+ assert.strictEqual(
+ document.querySelector(`.o_PartnerMentionSuggestion_email`).textContent,
+ "(demo_user@odoo.com)",
+ "Partner's email should be displayed"
+ );
+});
+
+QUnit.test('partner mention suggestion active', async function (assert) {
+ assert.expect(2);
+
+ await this.start();
+ const partner = this.env.models['mail.partner'].create({
+ email: "demo_user@odoo.com",
+ id: 7,
+ im_status: 'online',
+ name: "Demo User",
+ });
+ await this.createPartnerMentionSuggestion(partner);
+
+ assert.containsOnce(
+ document.body,
+ '.o_PartnerMentionSuggestion',
+ "Partner mention suggestion should be displayed"
+ );
+ assert.hasClass(
+ document.querySelector('.o_PartnerMentionSuggestion'),
+ 'active',
+ "should be active initially"
+ );
+});
+
+});
+});
+});
+
+});
diff --git a/addons/mail/static/src/components/thread_icon/thread_icon.js b/addons/mail/static/src/components/thread_icon/thread_icon.js
new file mode 100644
index 00000000000..0b82d0c9510
--- /dev/null
+++ b/addons/mail/static/src/components/thread_icon/thread_icon.js
@@ -0,0 +1,54 @@
+odoo.define('mail/static/src/components/thread_icon/thread_icon.js', function (require) {
+'use strict';
+
+const components = {
+ ThreadTypingIcon: require('mail/static/src/components/thread_typing_icon/thread_typing_icon.js'),
+};
+const useStore = require('mail/static/src/component_hooks/use_store/use_store.js');
+
+const { Component } = owl;
+
+class ThreadIcon extends Component {
+
+ /**
+ * @override
+ */
+ constructor(...args) {
+ super(...args);
+ useStore(props => {
+ const thread = this.env.models['mail.thread'].get(props.threadLocalId);
+ const correspondent = thread ? thread.correspondent : undefined;
+ return {
+ correspondent: correspondent ? correspondent.__state : undefined,
+ partnerRoot: this.env.messaging.partnerRoot
+ ? this.env.messaging.partnerRoot.__state
+ : undefined,
+ thread: thread ? thread.__state : undefined,
+ };
+ });
+ }
+
+ //--------------------------------------------------------------------------
+ // Public
+ //--------------------------------------------------------------------------
+
+ /**
+ * @returns {mail.thread}
+ */
+ get thread() {
+ return this.env.models['mail.thread'].get(this.props.threadLocalId);
+ }
+
+}
+
+Object.assign(ThreadIcon, {
+ components,
+ props: {
+ threadLocalId: String,
+ },
+ template: 'mail.ThreadIcon',
+});
+
+return ThreadIcon;
+
+});
diff --git a/addons/mail/static/src/components/thread_icon/thread_icon.scss b/addons/mail/static/src/components/thread_icon/thread_icon.scss
new file mode 100644
index 00000000000..f27b48d0a8b
--- /dev/null
+++ b/addons/mail/static/src/components/thread_icon/thread_icon.scss
@@ -0,0 +1,26 @@
+
+// ------------------------------------------------------------------
+// Layout
+// ------------------------------------------------------------------
+
+.o_ThreadIcon {
+ display: flex;
+ width: 13px;
+ justify-content: center;
+}
+
+.o_ThreadIcon_typing {
+ flex: 1 1 auto;
+}
+
+// ------------------------------------------------------------------
+// Style
+// ------------------------------------------------------------------
+
+.o_ThreadIcon_away {
+ color: theme-color('warning');
+}
+
+.o_ThreadIcon_online {
+ color: $o-enterprise-primary-color;
+}
diff --git a/addons/mail/static/src/components/thread_icon/thread_icon.xml b/addons/mail/static/src/components/thread_icon/thread_icon.xml
new file mode 100644
index 00000000000..95c4694a661
--- /dev/null
+++ b/addons/mail/static/src/components/thread_icon/thread_icon.xml
@@ -0,0 +1,58 @@
+<?xml version="1.0" encoding="UTF-8"?>
+<templates xml:space="preserve">
+
+ <t t-name="mail.ThreadIcon" owl="1">
+ <div class="o_ThreadIcon">
+ <t t-if="thread" name="rootCondition">
+ <t t-if="thread.channel_type === 'channel'">
+ <t t-if="thread.public === 'private'">
+ <!-- AKU TODO: channel of type 'groups' should maybe also have lock icon -->
+ <div class="o_ThreadIcon_channelPrivate fa fa-lock" title="Private channel"/>
+ </t>
+ <t t-else="">
+ <div class="o_ThreadIcon_channelPublic fa fa-hashtag" title="Public channel"/>
+ </t>
+ </t>
+ <t t-elif="thread.channel_type === 'chat' and thread.correspondent">
+ <t t-if="thread.orderedOtherTypingMembers.length > 0">
+ <ThreadTypingIcon
+ class="o_ThreadIcon_typing"
+ animation="'pulse'"
+ title="thread.typingStatusText"
+ />
+ </t>
+ <t t-elif="thread.correspondent.im_status === 'online'">
+ <div class="o_ThreadIcon_online fa fa-circle" title="Online"/>
+ </t>
+ <t t-elif="thread.correspondent.im_status === 'offline'">
+ <div class="o_ThreadIcon_offline fa fa-circle-o" title="Offline"/>
+ </t>
+ <t t-elif="thread.correspondent.im_status === 'away'">
+ <div class="o_ThreadIcon_away fa fa-circle" title="Away"/>
+ </t>
+ <t t-elif="thread.correspondent === env.messaging.partnerRoot">
+ <div class="o_ThreadIcon_online fa fa-heart" title="Bot"/>
+ </t>
+ <t t-else="" name="noImStatusCondition">
+ <div class="o_ThreadIcon_noImStatus fa fa-question-circle" title="No IM status available"/>
+ </t>
+ </t>
+ <t t-elif="thread.model === 'mail.box'">
+ <t t-if="thread === env.messaging.inbox">
+ <div class="o_ThreadIcon_mailboxInbox fa fa-inbox"/>
+ </t>
+ <t t-elif="thread === env.messaging.starred">
+ <div class="o_ThreadIcon_mailboxStarred fa fa-star-o"/>
+ </t>
+ <t t-elif="thread === env.messaging.history">
+ <div class="o_ThreadIcon_mailboxHistory fa fa-history"/>
+ </t>
+ <t t-elif="thread === env.messaging.moderation">
+ <div class="o_ThreadIcon_mailboxModeration fa fa-envelope"/>
+ </t>
+ </t>
+ </t>
+ </div>
+ </t>
+
+</templates>
diff --git a/addons/mail/static/src/components/thread_icon/thread_icon_tests.js b/addons/mail/static/src/components/thread_icon/thread_icon_tests.js
new file mode 100644
index 00000000000..c21aef4c393
--- /dev/null
+++ b/addons/mail/static/src/components/thread_icon/thread_icon_tests.js
@@ -0,0 +1,146 @@
+odoo.define('mail/static/src/components/thread_icon/thread_icon_tests.js', function (require) {
+'use strict';
+
+const components = {
+ ThreadIcon: require('mail/static/src/components/thread_icon/thread_icon.js'),
+};
+const {
+ afterEach: utilsAfterEach,
+ afterNextRender,
+ beforeEach: utilsBeforeEach,
+ start: utilsStart,
+} = require('mail/static/src/utils/test_utils.js');
+
+QUnit.module('mail', {}, function () {
+QUnit.module('components', {}, function () {
+QUnit.module('thread_icon', {}, function () {
+QUnit.module('thread_icon_tests.js', {
+ beforeEach() {
+ utilsBeforeEach(this);
+
+ this.createThreadIcon = async thread => {
+ const ThreadIconComponent = components.ThreadIcon;
+ ThreadIconComponent.env = this.env;
+ this.component = new ThreadIconComponent(null, { threadLocalId: thread.localId });
+ await this.component.mount(this.widget.el);
+ };
+
+ this.start = async params => {
+ let { env, widget } = await utilsStart(Object.assign({}, params, {
+ data: this.data,
+ }));
+ this.env = env;
+ this.widget = widget;
+ };
+
+ },
+ afterEach() {
+ utilsAfterEach(this);
+ if (this.component) {
+ this.component.destroy();
+ }
+ if (this.widget) {
+ this.widget.destroy();
+ }
+ this.env = undefined;
+ delete components.ThreadIcon.env;
+ },
+});
+
+QUnit.test('chat: correspondent is typing', async function (assert) {
+ assert.expect(5);
+
+ Object.assign(this.data.initMessaging, {
+ channel_slots: {
+ channel_direct_message: [{
+ channel_type: 'chat',
+ direct_partner: [{
+ email: 'demo@odoo.com',
+ id: 7,
+ im_status: 'online',
+ name: "Demo",
+ }],
+ id: 20,
+ members: [{
+ email: 'admin@odoo.com',
+ id: 3,
+ name: 'Admin',
+ }, {
+ email: 'demo@odoo.com',
+ id: 7,
+ im_status: 'online',
+ name: 'Demo',
+ }],
+ }],
+ },
+ });
+ await this.start({
+ env: {
+ session: {
+ name: 'Admin',
+ partner_display_name: 'Your Company, Admin',
+ partner_id: 3,
+ uid: 2,
+ },
+ },
+ });
+ const thread = this.env.models['mail.thread'].find(thread =>
+ thread.id === 20 &&
+ thread.model === 'mail.channel'
+ );
+ await this.createThreadIcon(thread);
+
+ assert.containsOnce(
+ document.body,
+ '.o_ThreadIcon',
+ "should have thread icon"
+ );
+ assert.containsOnce(
+ document.body,
+ '.o_ThreadIcon_online',
+ "should have thread icon with partner im status icon 'online'"
+ );
+
+ // simulate receive typing notification from demo "is typing"
+ await afterNextRender(() => {
+ const typingData = {
+ info: 'typing_status',
+ partner_id: 7,
+ is_typing: true,
+ };
+ const notification = [[false, 'mail.channel', 20], typingData];
+ this.widget.call('bus_service', 'trigger', 'notification', [notification]);
+ });
+ assert.containsOnce(
+ document.body,
+ '.o_ThreadIcon_typing',
+ "should have thread icon with partner currently typing"
+ );
+ assert.strictEqual(
+ document.querySelector('.o_ThreadIcon_typing').title,
+ "Demo is typing...",
+ "title of icon should tell demo is currently typing"
+ );
+
+ // simulate receive typing notification from demo "no longer is typing"
+ await afterNextRender(() => {
+ const typingData = {
+ info: 'typing_status',
+ partner_id: 7,
+ is_typing: false,
+ };
+ const notification = [[false, 'mail.channel', 20], typingData];
+ this.widget.call('bus_service', 'trigger', 'notification', [notification]);
+ });
+ assert.containsOnce(
+ document.body,
+ '.o_ThreadIcon_online',
+ "should have thread icon with partner im status icon 'online' (no longer typing)"
+ );
+});
+
+});
+});
+});
+
+});
diff --git a/addons/mail/static/src/components/thread_needaction_preview/thread_needaction_preview.js b/addons/mail/static/src/components/thread_needaction_preview/thread_needaction_preview.js
new file mode 100644
index 00000000000..efcd8f5d86e
--- /dev/null
+++ b/addons/mail/static/src/components/thread_needaction_preview/thread_needaction_preview.js
@@ -0,0 +1,131 @@
+odoo.define('mail/static/src/components/thread_needaction_preview/thread_needaction_preview.js', function (require) {
+'use strict';
+
+const components = {
+ MessageAuthorPrefix: require('mail/static/src/components/message_author_prefix/message_author_prefix.js'),
+ PartnerImStatusIcon: require('mail/static/src/components/partner_im_status_icon/partner_im_status_icon.js'),
+};
+const useStore = require('mail/static/src/component_hooks/use_store/use_store.js');
+const mailUtils = require('mail.utils');
+
+const { Component } = owl;
+const { useRef } = owl.hooks;
+
+class ThreadNeedactionPreview extends Component {
+
+ /**
+ * @override
+ */
+ constructor(...args) {
+ super(...args);
+ useStore(props => {
+ const thread = this.env.models['mail.thread'].get(props.threadLocalId);
+ const mainThreadCache = thread ? thread.mainCache : undefined;
+ let lastNeedactionMessageAuthor;
+ let lastNeedactionMessage;
+ let threadCorrespondent;
+ if (thread) {
+ lastNeedactionMessage = mainThreadCache.lastNeedactionMessage;
+ threadCorrespondent = thread.correspondent;
+ }
+ if (lastNeedactionMessage) {
+ lastNeedactionMessageAuthor = lastNeedactionMessage.author;
+ }
+ return {
+ isDeviceMobile: this.env.messaging.device.isMobile,
+ lastNeedactionMessage: lastNeedactionMessage ? lastNeedactionMessage.__state : undefined,
+ lastNeedactionMessageAuthor: lastNeedactionMessageAuthor
+ ? lastNeedactionMessageAuthor.__state
+ : undefined,
+ thread: thread ? thread.__state : undefined,
+ threadCorrespondent: threadCorrespondent
+ ? threadCorrespondent.__state
+ : undefined,
+ };
+ });
+ /**
+ * Reference of the "mark as read" button. Useful to disable the
+ * top-level click handler when clicking on this specific button.
+ */
+ this._markAsReadRef = useRef('markAsRead');
+ }
+
+ //--------------------------------------------------------------------------
+ // Public
+ //--------------------------------------------------------------------------
+
+ /**
+ * Get the image route of the thread.
+ *
+ * @returns {string}
+ */
+ image() {
+ if (this.thread.moduleIcon) {
+ return this.thread.moduleIcon;
+ }
+ if (this.thread.correspondent) {
+ return `/web/image/res.partner/${this.thread.correspondent.id}/image_128`;
+ }
+ if (this.thread.model === 'mail.channel') {
+ return `/web/image/mail.channel/${this.thread.id}/image_128`;
+ }
+ return '/mail/static/src/img/smiley/avatar.jpg';
+ }
+
+ /**
+ * Get inline content of the last message of this conversation.
+ *
+ * @returns {string}
+ */
+ get inlineLastNeedactionMessageBody() {
+ if (!this.thread.lastNeedactionMessage) {
+ return '';
+ }
+ return mailUtils.htmlToTextContentInline(this.thread.lastNeedactionMessage.prettyBody);
+ }
+
+ /**
+ * @returns {mail.thread}
+ */
+ get thread() {
+ return this.env.models['mail.thread'].get(this.props.threadLocalId);
+ }
+
+ //--------------------------------------------------------------------------
+ // Handlers
+ //--------------------------------------------------------------------------
+
+ /**
+ * @private
+ * @param {MouseEvent} ev
+ */
+ _onClick(ev) {
+ const markAsRead = this._markAsReadRef.el;
+ if (markAsRead && markAsRead.contains(ev.target)) {
+ // handled in `_onClickMarkAsRead`
+ return;
+ }
+ this.trigger('o-select-thread', { thread: this.thread });
+ }
+
+ /**
+ * @private
+ * @param {MouseEvent} ev
+ */
+ _onClickMarkAsRead(ev) {
+ this.thread.markNeedactionMessagesAsRead();
+ }
+
+}
+
+Object.assign(ThreadNeedactionPreview, {
+ components,
+ props: {
+ threadLocalId: String,
+ },
+ template: 'mail.ThreadNeedactionPreview',
+});
+
+return ThreadNeedactionPreview;
+
+});
diff --git a/addons/mail/static/src/components/thread_needaction_preview/thread_needaction_preview.scss b/addons/mail/static/src/components/thread_needaction_preview/thread_needaction_preview.scss
new file mode 100644
index 00000000000..08219402ab4
--- /dev/null
+++ b/addons/mail/static/src/components/thread_needaction_preview/thread_needaction_preview.scss
@@ -0,0 +1,104 @@
+// ------------------------------------------------------------------
+// Layout
+// ------------------------------------------------------------------
+
+.o_ThreadNeedactionPreview {
+ @include o-mail-notification-list-item-layout();
+
+ &:hover .o_ThreadNeedactionPreview_markAsRead {
+ opacity: 1;
+ }
+}
+
+.o_ThreadNeedactionPreview_content {
+ @include o-mail-notification-list-item-content-layout();
+}
+
+.o_ThreadNeedactionPreview_core {
+ @include o-mail-notification-list-item-core-layout();
+}
+
+.o_ThreadNeedactionPreview_coreItem {
+ @include o-mail-notification-list-item-core-item-layout();
+}
+
+.o_ThreadNeedactionPreview_counter {
+ @include o-mail-notification-list-item-counter-layout();
+}
+
+.o_ThreadNeedactionPreview_date {
+ @include o-mail-notification-list-item-date-layout();
+}
+
+.o_ThreadNeedactionPreview_header {
+ @include o-mail-notification-list-item-header-layout();
+}
+
+.o_ThreadNeedactionPreview_image {
+ @include o-mail-notification-list-item-image-layout();
+}
+
+.o_ThreadNeedactionPreview_imageContainer {
+ @include o-mail-notification-list-item-image-container-layout();
+}
+
+.o_ThreadNeedactionPreview_inlineText {
+ @include o-mail-notification-list-item-inline-text-layout();
+}
+
+.o_ThreadNeedactionPreview_markAsRead {
+ @include o-mail-notification-list-item-mark-as-read-layout();
+}
+
+.o_ThreadNeedactionPreview_name {
+ @include o-mail-notification-list-item-name-layout();
+}
+
+.o_ThreadNeedactionPreview_partnerImStatusIcon {
+ @include o-mail-notification-list-item-partner-im-status-icon-layout();
+}
+
+.o_ThreadNeedactionPreview_sidebar {
+ @include o-mail-notification-list-item-sidebar-layout();
+}
+
+// ------------------------------------------------------------------
+// Style
+// ------------------------------------------------------------------
+
+.o_ThreadNeedactionPreview {
+ @include o-mail-notification-list-item-style();
+ background-color: rgba($o-brand-primary, 0.1);
+
+ &:hover {
+ background-color: rgba($o-brand-primary, 0.2);
+
+ .o_ThreadNeedactionPreview_partnerImStatusIcon {
+ @include o-mail-notification-list-item-hover-partner-im-status-icon-style();
+ }
+ }
+}
+
+.o_ThreadNeedactionPreview_core {
+ @include o-mail-notification-list-item-core-style();
+}
+
+.o_ThreadNeedactionPreview_counter {
+ @include o-mail-notification-list-item-bold-style();
+}
+
+.o_ThreadNeedactionPreview_date {
+ @include o-mail-notification-list-item-date-style();
+}
+
+.o_ThreadNeedactionPreview_markAsRead {
+ @include o-mail-notification-list-item-mark-as-read-style();
+}
+
+.o_ThreadNeedactionPreview_name {
+ @include o-mail-notification-list-item-bold-style();
+}
+
+.o_ThreadNeedactionPreview_partnerImStatusIcon {
+ @include o-mail-notification-list-item-partner-im-status-icon-style();
+}
diff --git a/addons/mail/static/src/components/thread_needaction_preview/thread_needaction_preview.xml b/addons/mail/static/src/components/thread_needaction_preview/thread_needaction_preview.xml
new file mode 100644
index 00000000000..82612ed233c
--- /dev/null
+++ b/addons/mail/static/src/components/thread_needaction_preview/thread_needaction_preview.xml
@@ -0,0 +1,58 @@
+<?xml version="1.0" encoding="UTF-8"?>
+<templates xml:space="preserve">
+
+ <t t-name="mail.ThreadNeedactionPreview" owl="1">
+ <!--
+ The preview template is used by the discuss in mobile, and by the systray
+ menu in order to show preview of threads.
+ -->
+ <div class="o_ThreadNeedactionPreview" t-on-click="_onClick" t-att-data-thread-local-id="thread ? thread.localId : undefined">
+ <t t-if="thread">
+ <div class="o_ThreadNeedactionPreview_sidebar">
+ <div class="o_ThreadNeedactionPreview_imageContainer o_ThreadNeedactionPreview_sidebarItem">
+ <img class="o_ThreadNeedactionPreview_image" t-att-src="image()" alt="Thread Image"/>
+ <t t-if="thread.correspondent and thread.correspondent.im_status">
+ <PartnerImStatusIcon
+ class="o_ThreadNeedactionPreview_partnerImStatusIcon"
+ t-att-class="{
+ 'o-mobile': env.messaging.device.isMobile,
+ }"
+ partnerLocalId="thread.correspondent.localId"
+ />
+ </t>
+ </div>
+ </div>
+ <div class="o_ThreadNeedactionPreview_content">
+ <div class="o_ThreadNeedactionPreview_header">
+ <span class="o_ThreadNeedactionPreview_name" t-att-class="{ 'o-mobile': env.messaging.device.isMobile }">
+ <t t-esc="thread.displayName"/>
+ </span>
+ <span class="o_ThreadNeedactionPreview_counter">
+ (<t t-esc="thread.needactionMessages.length"/>)
+ </span>
+ <span class="o-autogrow"/>
+ <t t-if="thread.lastNeedactionMessage">
+ <span class="o_ThreadNeedactionPreview_date">
+ <t t-esc="thread.lastNeedactionMessage.date.fromNow()"/>
+ </span>
+ </t>
+ </div>
+ <div class="o_ThreadNeedactionPreview_core">
+ <span class="o_ThreadNeedactionPreview_coreItem o_ThreadNeedactionPreview_inlineText" t-att-class="{ 'o-empty': inlineLastNeedactionMessageBody.length === 0 }">
+ <t t-if="thread.lastMessage and thread.lastMessage.author">
+ <MessageAuthorPrefix
+ messageLocalId="thread.lastMessage.localId"
+ threadLocalId="thread.localId"
+ />
+ </t>
+ <t t-raw="inlineLastNeedactionMessageBody"/>
+ </span>
+ <span class="o-autogrow"/>
+ <span class="o_ThreadNeedactionPreview_coreItem o_ThreadNeedactionPreview_markAsRead fa fa-check" title="Mark as Read" t-on-click="_onClickMarkAsRead"/>
+ </div>
+ </div>
+ </t>
+ </div>
+ </t>
+
+</templates>
diff --git a/addons/mail/static/src/components/thread_preview/thread_preview.js b/addons/mail/static/src/components/thread_preview/thread_preview.js
new file mode 100644
index 00000000000..f2bbfabe3f9
--- /dev/null
+++ b/addons/mail/static/src/components/thread_preview/thread_preview.js
@@ -0,0 +1,124 @@
+odoo.define('mail/static/src/components/thread_preview/thread_preview.js', function (require) {
+'use strict';
+
+const components = {
+ MessageAuthorPrefix: require('mail/static/src/components/message_author_prefix/message_author_prefix.js'),
+ PartnerImStatusIcon: require('mail/static/src/components/partner_im_status_icon/partner_im_status_icon.js'),
+};
+const useStore = require('mail/static/src/component_hooks/use_store/use_store.js');
+const mailUtils = require('mail.utils');
+
+const { Component } = owl;
+const { useRef } = owl.hooks;
+
+class ThreadPreview extends Component {
+
+ /**
+ * @override
+ */
+ constructor(...args) {
+ super(...args);
+ useStore(props => {
+ const thread = this.env.models['mail.thread'].get(props.threadLocalId);
+ const mainThreadCache = thread ? thread.mainCache : undefined;
+ let lastMessageAuthor;
+ let lastMessage;
+ if (thread) {
+ const orderedMessages = mainThreadCache.orderedMessages;
+ lastMessage = orderedMessages[orderedMessages.length - 1];
+ }
+ if (lastMessage) {
+ lastMessageAuthor = lastMessage.author;
+ }
+ return {
+ isDeviceMobile: this.env.messaging.device.isMobile,
+ lastMessage: lastMessage ? lastMessage.__state : undefined,
+ lastMessageAuthor: lastMessageAuthor
+ ? lastMessageAuthor.__state
+ : undefined,
+ thread: thread ? thread.__state : undefined,
+ threadCorrespondent: thread && thread.correspondent
+ ? thread.correspondent.__state
+ : undefined,
+ };
+ });
+ /**
+ * Reference of the "mark as read" button. Useful to disable the
+ * top-level click handler when clicking on this specific button.
+ */
+ this._markAsReadRef = useRef('markAsRead');
+ }
+
+ //--------------------------------------------------------------------------
+ // Public
+ //--------------------------------------------------------------------------
+
+ /**
+ * Get the image route of the thread.
+ *
+ * @returns {string}
+ */
+ image() {
+ if (this.thread.correspondent) {
+ return `/web/image/res.partner/${this.thread.correspondent.id}/image_128`;
+ }
+ return `/web/image/mail.channel/${this.thread.id}/image_128`;
+ }
+
+ /**
+ * Get inline content of the last message of this conversation.
+ *
+ * @returns {string}
+ */
+ get inlineLastMessageBody() {
+ if (!this.thread.lastMessage) {
+ return '';
+ }
+ return mailUtils.htmlToTextContentInline(this.thread.lastMessage.prettyBody);
+ }
+
+ /**
+ * @returns {mail.thread}
+ */
+ get thread() {
+ return this.env.models['mail.thread'].get(this.props.threadLocalId);
+ }
+
+ //--------------------------------------------------------------------------
+ // Handlers
+ //--------------------------------------------------------------------------
+
+ /**
+ * @private
+ * @param {MouseEvent} ev
+ */
+ _onClick(ev) {
+ const markAsRead = this._markAsReadRef.el;
+ if (markAsRead && markAsRead.contains(ev.target)) {
+ // handled in `_onClickMarkAsRead`
+ return;
+ }
+ this.trigger('o-select-thread', { thread: this.thread });
+ }
+
+ /**
+ * @private
+ * @param {MouseEvent} ev
+ */
+ _onClickMarkAsRead(ev) {
+ this.thread.markAsSeen();
+ }
+
+}
+
+Object.assign(ThreadPreview, {
+ components,
+ props: {
+ threadLocalId: String,
+ },
+ template: 'mail.ThreadPreview',
+});
+
+return ThreadPreview;
+
+});
diff --git a/addons/mail/static/src/components/thread_preview/thread_preview.scss b/addons/mail/static/src/components/thread_preview/thread_preview.scss
new file mode 100644
index 00000000000..7e7f8abdf1a
--- /dev/null
+++ b/addons/mail/static/src/components/thread_preview/thread_preview.scss
@@ -0,0 +1,113 @@
+// ------------------------------------------------------------------
+// Layout
+// ------------------------------------------------------------------
+
+.o_ThreadPreview {
+ @include o-mail-notification-list-item-layout();
+
+ &:hover .o_ThreadPreview_markAsRead {
+ opacity: 1;
+ }
+}
+
+.o_ThreadPreview_content {
+ @include o-mail-notification-list-item-content-layout();
+}
+
+.o_ThreadPreview_core {
+ @include o-mail-notification-list-item-core-layout();
+}
+
+.o_ThreadPreview_coreItem {
+ @include o-mail-notification-list-item-core-item-layout();
+}
+
+.o_ThreadPreview_counter {
+ @include o-mail-notification-list-item-counter-layout();
+}
+
+.o_ThreadPreview_date {
+ @include o-mail-notification-list-item-date-layout();
+}
+
+.o_ThreadPreview_header {
+ @include o-mail-notification-list-item-header-layout();
+}
+
+.o_ThreadPreview_image {
+ @include o-mail-notification-list-item-image-layout();
+}
+
+.o_ThreadPreview_imageContainer {
+ @include o-mail-notification-list-item-image-container-layout();
+}
+
+.o_ThreadPreview_inlineText {
+ @include o-mail-notification-list-item-inline-text-layout();
+}
+
+.o_ThreadPreview_markAsRead {
+ @include o-mail-notification-list-item-mark-as-read-layout();
+}
+
+.o_ThreadPreview_name {
+ @include o-mail-notification-list-item-name-layout();
+}
+
+.o_ThreadPreview_partnerImStatusIcon {
+ @include o-mail-notification-list-item-partner-im-status-icon-layout();
+}
+
+.o_ThreadPreview_sidebar {
+ @include o-mail-notification-list-item-sidebar-layout();
+}
+
+// ------------------------------------------------------------------
+// Style
+// ------------------------------------------------------------------
+
+.o_ThreadPreview {
+ @include o-mail-notification-list-item-style();
+
+ &:hover {
+ .o_ThreadPreview_partnerImStatusIcon {
+ @include o-mail-notification-list-item-hover-partner-im-status-icon-style();
+ }
+ }
+
+ &.o-muted {
+ &:hover {
+ .o_ThreadPreview_partnerImStatusIcon {
+ @include o-mail-notification-list-item-muted-hover-partner-im-status-icon-style();
+ }
+ }
+ }
+}
+
+.o_ThreadPreview_core {
+ @include o-mail-notification-list-item-core-style();
+}
+
+.o_ThreadPreview_counter {
+ @include o-mail-notification-list-item-bold-style();
+}
+
+.o_ThreadPreview_date {
+ @include o-mail-notification-list-item-date-style();
+
+ &.o-muted {
+ color: gray('500');
+ }
+}
+
+.o_ThreadPreview_markAsRead {
+ @include o-mail-notification-list-item-mark-as-read-style();
+}
+
+.o_ThreadPreview_name {
+ @include o-mail-notification-list-item-bold-style();
+}
+
+.o_ThreadPreview_partnerImStatusIcon {
+ @include o-mail-notification-list-item-partner-im-status-icon-style();
+}
diff --git a/addons/mail/static/src/components/thread_preview/thread_preview.xml b/addons/mail/static/src/components/thread_preview/thread_preview.xml
new file mode 100644
index 00000000000..ded9c6fadac
--- /dev/null
+++ b/addons/mail/static/src/components/thread_preview/thread_preview.xml
@@ -0,0 +1,63 @@
+<?xml version="1.0" encoding="UTF-8"?>
+<templates xml:space="preserve">
+
+ <t t-name="mail.ThreadPreview" owl="1">
+ <!--
+ The preview template is used by the discuss in mobile, and by the systray
+ menu in order to show preview of threads.
+ -->
+ <div class="o_ThreadPreview" t-att-class="{ 'o-muted': thread and thread.message_unread_counter === 0 }" t-on-click="_onClick" t-att-data-thread-local-id="thread ? thread.localId : undefined">
+ <t t-if="thread">
+ <div class="o_ThreadPreview_sidebar">
+ <div class="o_ThreadPreview_imageContainer o_ThreadPreview_sidebarItem">
+ <img class="o_ThreadPreview_image rounded-circle" t-att-src="image()" alt="Thread Image"/>
+ <t t-if="thread.correspondent and thread.correspondent.im_status">
+ <PartnerImStatusIcon
+ class="o_ThreadPreview_partnerImStatusIcon"
+ t-att-class="{
+ 'o-mobile': env.messaging.device.isMobile,
+ 'o-muted': thread.message_unread_counter === 0,
+ }"
+ partnerLocalId="thread.correspondent.localId"
+ />
+ </t>
+ </div>
+ </div>
+ <div class="o_ThreadPreview_content">
+ <div class="o_ThreadPreview_header">
+ <span class="o_ThreadPreview_name" t-att-class="{ 'o-mobile': env.messaging.device.isMobile, 'o-muted': thread.message_unread_counter === 0 }">
+ <t t-esc="thread.displayName"/>
+ </span>
+ <t t-if="thread.message_unread_counter > 0">
+ <span class="o_ThreadPreview_counter" t-att-class="{ 'o-muted': thread.message_unread_counter === 0 }">
+ (<t t-esc="thread.message_unread_counter"/>)
+ </span>
+ </t>
+ <span class="o-autogrow"/>
+ <t t-if="thread.lastMessage">
+ <span class="o_ThreadPreview_date" t-att-class="{ 'o-muted': thread.message_unread_counter === 0 }">
+ <t t-esc="thread.lastMessage.date.fromNow()"/>
+ </span>
+ </t>
+ </div>
+ <div class="o_ThreadPreview_core">
+ <span class="o_ThreadPreview_coreItem o_ThreadPreview_inlineText" t-att-class="{ 'o-empty': inlineLastMessageBody.length === 0 }">
+ <t t-if="thread.lastMessage and thread.lastMessage.author">
+ <MessageAuthorPrefix
+ messageLocalId="thread.lastMessage.localId"
+ threadLocalId="thread.localId"
+ />
+ </t>
+ <t t-raw="inlineLastMessageBody"/>
+ </span>
+ <span class="o-autogrow"/>
+ <t t-if="thread.message_unread_counter > 0">
+ <span class="o_ThreadPreview_coreItem o_ThreadPreview_markAsRead fa fa-check" title="Mark as Read" t-on-click="_onClickMarkAsRead"/>
+ </t>
+ </div>
+ </div>
+ </t>
+ </div>
+ </t>
+
+</templates>
diff --git a/addons/mail/static/src/components/thread_textual_typing_status/thread_textual_typing_status.js b/addons/mail/static/src/components/thread_textual_typing_status/thread_textual_typing_status.js
new file mode 100644
index 00000000000..94f8c2b95bb
--- /dev/null
+++ b/addons/mail/static/src/components/thread_textual_typing_status/thread_textual_typing_status.js
@@ -0,0 +1,49 @@
+odoo.define('mail/static/src/components/thread_textual_typing_status/thread_textual_typing_status.js', function (require) {
+'use strict';
+
+const components = {
+ ThreadTypingIcon: require('mail/static/src/components/thread_typing_icon/thread_typing_icon.js'),
+};
+const useStore = require('mail/static/src/component_hooks/use_store/use_store.js');
+
+const { Component } = owl;
+
+class ThreadTextualTypingStatus extends Component {
+
+ /**
+ * @override
+ */
+ constructor(...args) {
+ super(...args);
+ useStore(props => {
+ const thread = this.env.models['mail.thread'].get(props.threadLocalId);
+ return {
+ thread: thread ? thread.__state : undefined,
+ };
+ });
+ }
+
+ //--------------------------------------------------------------------------
+ // Public
+ //--------------------------------------------------------------------------
+
+ /**
+ * @returns {mail.thread}
+ */
+ get thread() {
+ return this.env.models['mail.thread'].get(this.props.threadLocalId);
+ }
+
+}
+
+Object.assign(ThreadTextualTypingStatus, {
+ components,
+ props: {
+ threadLocalId: String,
+ },
+ template: 'mail.ThreadTextualTypingStatus',
+});
+
+return ThreadTextualTypingStatus;
+
+});
diff --git a/addons/mail/static/src/components/thread_textual_typing_status/thread_textual_typing_status.scss b/addons/mail/static/src/components/thread_textual_typing_status/thread_textual_typing_status.scss
new file mode 100644
index 00000000000..4cb9e1cfc3f
--- /dev/null
+++ b/addons/mail/static/src/components/thread_textual_typing_status/thread_textual_typing_status.scss
@@ -0,0 +1,12 @@
+
+// ------------------------------------------------------------------
+// Layout
+// ------------------------------------------------------------------
+
+.o_ThreadTextualTypingStatus {
+ display: flex;
+}
+
+.o_ThreadTextualTypingStatus_separator {
+ width: map-get($spacers, 1);
+}
diff --git a/addons/mail/static/src/components/thread_textual_typing_status/thread_textual_typing_status.xml b/addons/mail/static/src/components/thread_textual_typing_status/thread_textual_typing_status.xml
new file mode 100644
index 00000000000..722d07389f8
--- /dev/null
+++ b/addons/mail/static/src/components/thread_textual_typing_status/thread_textual_typing_status.xml
@@ -0,0 +1,14 @@
+<?xml version="1.0" encoding="UTF-8"?>
+<templates xml:space="preserve">
+
+ <t t-name="mail.ThreadTextualTypingStatus" owl="1">
+ <div class="o_ThreadTextualTypingStatus">
+ <t t-if="thread and thread.orderedOtherTypingMembers.length > 0">
+ <ThreadTypingIcon animation="'pulse'" size="'medium'"/>
+ <span class="o_ThreadTextualTypingStatus_separator"/>
+ <span t-esc="thread.typingStatusText"/>
+ </t>
+ </div>
+ </t>
+
+</templates>
diff --git a/addons/mail/static/src/components/thread_textual_typing_status/thread_textual_typing_status_tests.js b/addons/mail/static/src/components/thread_textual_typing_status/thread_textual_typing_status_tests.js
new file mode 100644
index 00000000000..2d4665e4920
--- /dev/null
+++ b/addons/mail/static/src/components/thread_textual_typing_status/thread_textual_typing_status_tests.js
@@ -0,0 +1,480 @@
+odoo.define('mail/static/src/components/thread_textual_typing_status/thread_textual_typing_status_tests.js', function (require) {
+'use strict';
+
+const components = {
+ ThreadTextualTypingStatus: require('mail/static/src/components/thread_textual_typing_status/thread_textual_typing_status.js'),
+};
+const {
+ afterEach: utilsAfterEach,
+ afterNextRender,
+ beforeEach: utilsBeforeEach,
+ nextAnimationFrame,
+ start: utilsStart,
+} = require('mail/static/src/utils/test_utils.js');
+
+QUnit.module('mail', {}, function () {
+QUnit.module('components', {}, function () {
+QUnit.module('thread_textual_typing_status', {}, function () {
+QUnit.module('thread_textual_typing_status_tests.js', {
+ beforeEach() {
+ utilsBeforeEach(this);
+
+ this.createThreadTextualTypingStatusComponent = async thread => {
+ const ThreadTextualTypingStatusComponent = components.ThreadTextualTypingStatus;
+ ThreadTextualTypingStatusComponent.env = this.env;
+ this.component = new ThreadTextualTypingStatusComponent(null, {
+ threadLocalId: thread.localId,
+ });
+ await afterNextRender(() => this.component.mount(this.widget.el));
+ };
+
+ this.start = async params => {
+ if (this.widget) {
+ this.widget.destroy();
+ }
+ let { env, widget } = await utilsStart(Object.assign({}, params, {
+ data: this.data,
+ }));
+ this.env = env;
+ this.widget = widget;
+ };
+ },
+ async afterEach() {
+ utilsAfterEach(this);
+ if (this.component) {
+ this.component.destroy();
+ }
+ if (this.widget) {
+ this.widget.destroy();
+ }
+ this.env = undefined;
+ delete components.ThreadTextualTypingStatus.env;
+ },
+});
+
+QUnit.test('receive other member typing status "is typing"', async function (assert) {
+ assert.expect(2);
+
+ Object.assign(this.data.initMessaging, {
+ channel_slots: {
+ channel_channel: [{
+ channel_type: 'channel',
+ id: 20,
+ members: [{
+ email: 'admin@odoo.com',
+ id: 3,
+ name: 'Admin',
+ }, {
+ email: 'demo@odoo.com',
+ id: 7,
+ name: 'Demo',
+ }],
+ name: "General",
+ }],
+ },
+ });
+ await this.start({
+ env: {
+ session: {
+ name: 'Admin',
+ partner_display_name: 'Your Company, Admin',
+ partner_id: 3,
+ uid: 2,
+ },
+ },
+ });
+ const thread = this.env.models['mail.thread'].find(thread =>
+ thread.id === 20 &&
+ thread.model === 'mail.channel'
+ );
+ await this.createThreadTextualTypingStatusComponent(thread);
+
+ assert.strictEqual(
+ document.querySelector('.o_ThreadTextualTypingStatus').textContent,
+ "",
+ "Should display no one is currently typing"
+ );
+
+ // simulate receive typing notification from demo
+ await afterNextRender(() => {
+ const typingData = {
+ info: 'typing_status',
+ partner_id: 7,
+ is_typing: true,
+ };
+ const notification = [[false, 'mail.channel', 20], typingData];
+ this.widget.call('bus_service', 'trigger', 'notification', [notification]);
+ });
+ assert.strictEqual(
+ document.querySelector('.o_ThreadTextualTypingStatus').textContent,
+ "Demo is typing...",
+ "Should display that demo user is typing"
+ );
+});
+
+QUnit.test('receive other member typing status "is typing" then "no longer is typing"', async function (assert) {
+ assert.expect(3);
+
+ Object.assign(this.data.initMessaging, {
+ channel_slots: {
+ channel_channel: [{
+ channel_type: 'channel',
+ id: 20,
+ members: [{
+ email: 'admin@odoo.com',
+ id: 3,
+ name: 'Admin',
+ }, {
+ email: 'demo@odoo.com',
+ id: 7,
+ name: 'Demo',
+ }],
+ name: "General",
+ }],
+ },
+ });
+ await this.start({
+ env: {
+ session: {
+ name: 'Admin',
+ partner_display_name: 'Your Company, Admin',
+ partner_id: 3,
+ uid: 2,
+ },
+ },
+ });
+ const thread = this.env.models['mail.thread'].find(thread =>
+ thread.id === 20 &&
+ thread.model === 'mail.channel'
+ );
+ await this.createThreadTextualTypingStatusComponent(thread);
+
+ assert.strictEqual(
+ document.querySelector('.o_ThreadTextualTypingStatus').textContent,
+ "",
+ "Should display no one is currently typing"
+ );
+
+ // simulate receive typing notification from demo "is typing"
+ await afterNextRender(() => {
+ const typingData = {
+ info: 'typing_status',
+ partner_id: 7,
+ is_typing: true,
+ };
+ const notification = [[false, 'mail.channel', 20], typingData];
+ this.widget.call('bus_service', 'trigger', 'notification', [notification]);
+ });
+ assert.strictEqual(
+ document.querySelector('.o_ThreadTextualTypingStatus').textContent,
+ "Demo is typing...",
+ "Should display that demo user is typing"
+ );
+
+ // simulate receive typing notification from demo "is no longer typing"
+ await afterNextRender(() => {
+ const typingData = {
+ info: 'typing_status',
+ partner_id: 7,
+ is_typing: false,
+ };
+ const notification = [[false, 'mail.channel', 20], typingData];
+ this.widget.call('bus_service', 'trigger', 'notification', [notification]);
+ });
+ assert.strictEqual(
+ document.querySelector('.o_ThreadTextualTypingStatus').textContent,
+ "",
+ "Should no longer display that demo user is typing"
+ );
+});
+
+QUnit.test('assume other member typing status becomes "no longer is typing" after 60 seconds without any updated typing status', async function (assert) {
+ assert.expect(3);
+
+ Object.assign(this.data.initMessaging, {
+ channel_slots: {
+ channel_channel: [{
+ channel_type: 'channel',
+ id: 20,
+ members: [{
+ email: 'admin@odoo.com',
+ id: 3,
+ name: 'Admin',
+ }, {
+ email: 'demo@odoo.com',
+ id: 7,
+ name: 'Demo',
+ }],
+ name: "General",
+ }],
+ },
+ });
+ await this.start({
+ env: {
+ session: {
+ name: 'Admin',
+ partner_display_name: 'Your Company, Admin',
+ partner_id: 3,
+ uid: 2,
+ },
+ },
+ hasTimeControl: true,
+ });
+ const thread = this.env.models['mail.thread'].find(thread =>
+ thread.id === 20 &&
+ thread.model === 'mail.channel'
+ );
+ await this.createThreadTextualTypingStatusComponent(thread);
+
+ assert.strictEqual(
+ document.querySelector('.o_ThreadTextualTypingStatus').textContent,
+ "",
+ "Should display no one is currently typing"
+ );
+
+ // simulate receive typing notification from demo "is typing"
+ await afterNextRender(() => {
+ const typingData = {
+ info: 'typing_status',
+ partner_id: 7,
+ is_typing: true,
+ };
+ const notification = [[false, 'mail.channel', 20], typingData];
+ this.widget.call('bus_service', 'trigger', 'notification', [notification]);
+ });
+ assert.strictEqual(
+ document.querySelector('.o_ThreadTextualTypingStatus').textContent,
+ "Demo is typing...",
+ "Should display that demo user is typing"
+ );
+
+ await afterNextRender(() => this.env.testUtils.advanceTime(60 * 1000));
+ assert.strictEqual(
+ document.querySelector('.o_ThreadTextualTypingStatus').textContent,
+ "",
+ "Should no longer display that demo user is typing"
+ );
+});
+
+QUnit.test ('other member typing status "is typing" refreshes 60 seconds timer of assuming no longer typing', async function (assert) {
+ assert.expect(4);
+
+ Object.assign(this.data.initMessaging, {
+ channel_slots: {
+ channel_channel: [{
+ channel_type: 'channel',
+ id: 20,
+ members: [{
+ email: 'admin@odoo.com',
+ id: 3,
+ name: 'Admin',
+ }, {
+ email: 'demo@odoo.com',
+ id: 7,
+ name: 'Demo',
+ }],
+ name: "General",
+ }],
+ },
+ });
+ await this.start({
+ env: {
+ session: {
+ name: 'Admin',
+ partner_display_name: 'Your Company, Admin',
+ partner_id: 3,
+ uid: 2,
+ },
+ },
+ hasTimeControl: true,
+ });
+ const thread = this.env.models['mail.thread'].find(thread =>
+ thread.id === 20 &&
+ thread.model === 'mail.channel'
+ );
+ await this.createThreadTextualTypingStatusComponent(thread);
+
+ assert.strictEqual(
+ document.querySelector('.o_ThreadTextualTypingStatus').textContent,
+ "",
+ "Should display no one is currently typing"
+ );
+
+ // simulate receive typing notification from demo "is typing"
+ await afterNextRender(() => {
+ const typingData = {
+ info: 'typing_status',
+ partner_id: 7,
+ is_typing: true,
+ };
+ const notification = [[false, 'mail.channel', 20], typingData];
+ this.widget.call('bus_service', 'trigger', 'notification', [notification]);
+ });
+ assert.strictEqual(
+ document.querySelector('.o_ThreadTextualTypingStatus').textContent,
+ "Demo is typing...",
+ "Should display that demo user is typing"
+ );
+
+ // simulate receive typing notification from demo "is typing" again after 50s.
+ await this.env.testUtils.advanceTime(50 * 1000);
+ const typingData = {
+ info: 'typing_status',
+ partner_id: 7,
+ is_typing: true,
+ };
+ const notification = [[false, 'mail.channel', 20], typingData];
+ this.widget.call('bus_service', 'trigger', 'notification', [notification]);
+ await this.env.testUtils.advanceTime(50 * 1000);
+ await nextAnimationFrame();
+ assert.strictEqual(
+ document.querySelector('.o_ThreadTextualTypingStatus').textContent,
+ "Demo is typing...",
+ "Should still display that demo user is typing after 100 seconds (refreshed is typing status at 50s => (100 - 50) = 50s < 60s after assuming no-longer typing)"
+ );
+
+ await afterNextRender(() => this.env.testUtils.advanceTime(11 * 1000));
+ assert.strictEqual(
+ document.querySelector('.o_ThreadTextualTypingStatus').textContent,
+ "",
+ "Should no longer display that demo user is typing after 111 seconds (refreshed is typing status at 50s => (111 - 50) = 61s > 60s after assuming no-longer typing)"
+ );
+});
+
+QUnit.test('receive several other members typing status "is typing"', async function (assert) {
+ assert.expect(6);
+
+ Object.assign(this.data.initMessaging, {
+ channel_slots: {
+ channel_channel: [{
+ channel_type: 'channel',
+ id: 20,
+ members: [{
+ email: 'admin@odoo.com',
+ id: 3,
+ name: 'Admin',
+ }, {
+ email: 'other10@odoo.com',
+ id: 10,
+ name: 'Other10',
+ }, {
+ email: 'other11@odoo.com',
+ id: 11,
+ name: 'Other11',
+ }, {
+ email: 'other12@odoo.com',
+ id: 12,
+ name: 'Other12',
+ }],
+ name: "General",
+ }],
+ },
+ });
+ await this.start({
+ env: {
+ session: {
+ name: 'Admin',
+ partner_display_name: 'Your Company, Admin',
+ partner_id: 3,
+ uid: 2,
+ },
+ },
+ });
+ const thread = this.env.models['mail.thread'].find(thread =>
+ thread.id === 20 &&
+ thread.model === 'mail.channel'
+ );
+ await this.createThreadTextualTypingStatusComponent(thread);
+
+ assert.strictEqual(
+ document.querySelector('.o_ThreadTextualTypingStatus').textContent,
+ "",
+ "Should display no one is currently typing"
+ );
+
+ // simulate receive typing notification from other10 (is typing)
+ await afterNextRender(() => {
+ const typingData = {
+ info: 'typing_status',
+ partner_id: 10,
+ is_typing: true,
+ };
+ const notification = [[false, 'mail.channel', 20], typingData];
+ this.widget.call('bus_service', 'trigger', 'notification', [notification]);
+ });
+ assert.strictEqual(
+ document.querySelector('.o_ThreadTextualTypingStatus').textContent,
+ "Other10 is typing...",
+ "Should display that 'Other10' member is typing"
+ );
+
+ // simulate receive typing notification from other11 (is typing)
+ await afterNextRender(() => {
+ const typingData = {
+ info: 'typing_status',
+ partner_id: 11,
+ is_typing: true,
+ };
+ const notification = [[false, 'mail.channel', 20], typingData];
+ this.widget.call('bus_service', 'trigger', 'notification', [notification]);
+ });
+ assert.strictEqual(
+ document.querySelector('.o_ThreadTextualTypingStatus').textContent,
+ "Other10 and Other11 are typing...",
+ "Should display that members 'Other10' and 'Other11' are typing (order: longer typer named first)"
+ );
+
+ // simulate receive typing notification from other12 (is typing)
+ await afterNextRender(() => {
+ const typingData = {
+ info: 'typing_status',
+ partner_id: 12,
+ is_typing: true,
+ };
+ const notification = [[false, 'mail.channel', 20], typingData];
+ this.widget.call('bus_service', 'trigger', 'notification', [notification]);
+ });
+ assert.strictEqual(
+ document.querySelector('.o_ThreadTextualTypingStatus').textContent,
+ "Other10, Other11 and more are typing...",
+ "Should display that members 'Other10', 'Other11' and more (at least 1 extra member) are typing (order: longer typer named first)"
+ );
+
+ // simulate receive typing notification from other10 (no longer is typing)
+ await afterNextRender(() => {
+ const typingData = {
+ info: 'typing_status',
+ partner_id: 10,
+ is_typing: false,
+ };
+ const notification = [[false, 'mail.channel', 20], typingData];
+ this.widget.call('bus_service', 'trigger', 'notification', [notification]);
+ });
+ assert.strictEqual(
+ document.querySelector('.o_ThreadTextualTypingStatus').textContent,
+ "Other11 and Other12 are typing...",
+ "Should display that members 'Other11' and 'Other12' are typing ('Other10' stopped typing)"
+ );
+
+ // simulate receive typing notification from other10 (is typing again)
+ await afterNextRender(() => {
+ const typingData = {
+ info: 'typing_status',
+ partner_id: 10,
+ is_typing: true,
+ };
+ const notification = [[false, 'mail.channel', 20], typingData];
+ this.widget.call('bus_service', 'trigger', 'notification', [notification]);
+ });
+ assert.strictEqual(
+ document.querySelector('.o_ThreadTextualTypingStatus').textContent,
+ "Other11, Other12 and more are typing...",
+ "Should display that members 'Other11' and 'Other12' and more (at least 1 extra member) are typing (order by longer typer, 'Other10' just recently restarted typing)"
+ );
+});
+
+});
+});
+});
+
+});
diff --git a/addons/mail/static/src/components/thread_typing_icon/thread_typing_icon.js b/addons/mail/static/src/components/thread_typing_icon/thread_typing_icon.js
new file mode 100644
index 00000000000..95caee984d4
--- /dev/null
+++ b/addons/mail/static/src/components/thread_typing_icon/thread_typing_icon.js
@@ -0,0 +1,32 @@
+odoo.define('mail/static/src/components/thread_typing_icon/thread_typing_icon.js', function (require) {
+'use strict';
+
+const { Component } = owl;
+
+class ThreadTypingIcon extends Component {}
+
+Object.assign(ThreadTypingIcon, {
+ defaultProps: {
+ animation: 'none',
+ size: 'small',
+ },
+ props: {
+ animation: {
+ type: String,
+ validate: prop => ['bounce', 'none', 'pulse'].includes(prop),
+ },
+ size: {
+ type: String,
+ validate: prop => ['small', 'medium'].includes(prop),
+ },
+ title: {
+ type: String,
+ optional: true,
+ }
+ },
+ template: 'mail.ThreadTypingIcon',
+});
+
+return ThreadTypingIcon;
+
+});
diff --git a/addons/mail/static/src/components/thread_typing_icon/thread_typing_icon.scss b/addons/mail/static/src/components/thread_typing_icon/thread_typing_icon.scss
new file mode 100644
index 00000000000..ac3c5b2fb83
--- /dev/null
+++ b/addons/mail/static/src/components/thread_typing_icon/thread_typing_icon.scss
@@ -0,0 +1,108 @@
+// ------------------------------------------------------------------
+// Variables
+// ------------------------------------------------------------------
+
+$o-thread-typing-icon-size-medium: 5px;
+$o-thread-typing-icon-size-small: 3px;
+
+// ------------------------------------------------------------------
+// Layout
+// ------------------------------------------------------------------
+
+.o_ThreadTypingIcon {
+ display: flex;
+ align-items: center;
+}
+
+.o_ThreadTypingIcon_dot {
+ display: flex;
+ flex: 0 0 auto;
+}
+
+.o_ThreadTypingIcon_separator {
+ min-width: 1px;
+ flex: 1 0 auto;
+}
+
+// ------------------------------------------------------------------
+// Style
+// ------------------------------------------------------------------
+
+.o_ThreadTypingIcon_dot {
+ border-radius: 50%;
+ background: gray('500');
+
+ &.o-sizeMedium {
+ width: $o-thread-typing-icon-size-medium;
+ height: $o-thread-typing-icon-size-medium;
+ }
+
+ &.o-sizeSmall {
+ width: $o-thread-typing-icon-size-small;
+ height: $o-thread-typing-icon-size-small;
+ }
+}
+
+// ------------------------------------------------------------------
+// Animation
+// ------------------------------------------------------------------
+
+.o_ThreadTypingIcon_dot.o-animationBounce {
+
+ // Note: duplicated animation because dependent on size, and current SASS version doesn't support var()
+ &.o-sizeMedium {
+ animation: o_ThreadTypingIcon_dot_animationBounce_sizeMedium_animation 1.5s linear infinite;
+ }
+
+ &.o-sizeSmall {
+ animation: o_ThreadTypingIcon_dot_animationBounce_sizeSmall_animation 1.5s linear infinite;
+ }
+
+ &.o_ThreadTypingIcon_dot2 {
+ animation-delay: -1.35s;
+ }
+
+ &.o_ThreadTypingIcon_dot3 {
+ animation-delay: -1.2s;
+ }
+}
+
+.o_ThreadTypingIcon_dot.o-animationPulse {
+ animation: o_ThreadTypingIcon_dot_animationPulse_animation 1.5s linear infinite;
+
+ &.o_ThreadTypingIcon_dot2 {
+ animation-delay: -1.35s;
+ }
+
+ &.o_ThreadTypingIcon_dot3 {
+ animation-delay: -1.2s;
+ }
+}
+
+@keyframes o_ThreadTypingIcon_dot_animationBounce_sizeMedium_animation {
+ 0%, 40%, 100% {
+ transform: initial;
+ }
+ 20% {
+ transform: translateY(-$o-thread-typing-icon-size-medium);
+ }
+}
+
+@keyframes o_ThreadTypingIcon_dot_animationBounce_sizeSmall_animation {
+ 0%, 40%, 100% {
+ transform: initial;
+ }
+ 20% {
+ transform: translateY(-$o-thread-typing-icon-size-small);
+ }
+}
+
+
+@keyframes o_ThreadTypingIcon_dot_animationPulse_animation {
+ 0%, 40%, 100% {
+ opacity: initial;
+ }
+ 20% {
+ opacity: 25%;
+ }
+}
diff --git a/addons/mail/static/src/components/thread_typing_icon/thread_typing_icon.xml b/addons/mail/static/src/components/thread_typing_icon/thread_typing_icon.xml
new file mode 100644
index 00000000000..1bdb4adaf92
--- /dev/null
+++ b/addons/mail/static/src/components/thread_typing_icon/thread_typing_icon.xml
@@ -0,0 +1,29 @@
+<?xml version="1.0" encoding="UTF-8"?>
+<templates xml:space="preserve">
+
+ <t t-name="mail.ThreadTypingIcon" owl="1">
+ <div class="o_ThreadTypingIcon" t-att-title="props.title">
+ <span class="o_ThreadTypingIcon_dot o_ThreadTypingIcon_dot1" t-att-class="{
+ 'o-animationBounce': props.animation === 'bounce',
+ 'o-animationPulse': props.animation === 'pulse',
+ 'o-sizeMedium': props.size === 'medium',
+ 'o-sizeSmall': props.size === 'small',
+ }"/>
+ <span class="o_ThreadTypingIcon_separator"/>
+ <span class="o_ThreadTypingIcon_dot o_ThreadTypingIcon_dot2" t-att-class="{
+ 'o-animationBounce': props.animation === 'bounce',
+ 'o-animationPulse': props.animation === 'pulse',
+ 'o-sizeMedium': props.size === 'medium',
+ 'o-sizeSmall': props.size === 'small',
+ }"/>
+ <span class="o_ThreadTypingIcon_separator"/>
+ <span class="o_ThreadTypingIcon_dot o_ThreadTypingIcon_dot3" t-att-class="{
+ 'o-animationBounce': props.animation === 'bounce',
+ 'o-animationPulse': props.animation === 'pulse',
+ 'o-sizeMedium': props.size === 'medium',
+ 'o-sizeSmall': props.size === 'small',
+ }"/>
+ </div>
+ </t>
+
+</templates>
diff --git a/addons/mail/static/src/components/thread_viewer/thread_viewer.js b/addons/mail/static/src/components/thread_viewer/thread_viewer.js
new file mode 100644
index 00000000000..98c3c6edfb6
--- /dev/null
+++ b/addons/mail/static/src/components/thread_viewer/thread_viewer.js
@@ -0,0 +1,199 @@
+odoo.define('mail/static/src/components/thread_viewer/thread_viewer.js', function (require) {
+'use strict';
+
+const components = {
+ Composer: require('mail/static/src/components/composer/composer.js'),
+ MessageList: require('mail/static/src/components/message_list/message_list.js'),
+};
+const useStore = require('mail/static/src/component_hooks/use_store/use_store.js');
+
+const { Component } = owl;
+const { useRef } = owl.hooks;
+
+class ThreadViewer extends Component {
+
+ /**
+ * @param {...any} args
+ */
+ constructor(...args) {
+ super(...args);
+ useStore((...args) => this._useStoreSelector(...args));
+ /**
+ * Reference of the composer. Useful to set focus on composer when
+ * thread has the focus.
+ */
+ this._composerRef = useRef('composer');
+ /**
+ * Reference of the message list. Useful to determine scroll positions.
+ */
+ this._messageListRef = useRef('messageList');
+ }
+
+ mounted() {
+ this._update();
+ }
+
+ patched() {
+ this._update();
+ }
+
+ //--------------------------------------------------------------------------
+ // Public
+ //--------------------------------------------------------------------------
+
+ /**
+ * Focus the thread. If it has a composer, focus it.
+ */
+ focus() {
+ if (!this._composerRef.comp) {
+ return;
+ }
+ this._composerRef.comp.focus();
+ }
+
+ /**
+ * Focusout the thread.
+ */
+ focusout() {
+ if (!this._composerRef.comp) {
+ return;
+ }
+ this._composerRef.comp.focusout();
+ }
+
+ /**
+ * Get the scroll position in the message list.
+ *
+ * @returns {integer|undefined}
+ */
+ getScrollTop() {
+ if (!this._messageListRef.comp) {
+ return undefined;
+ }
+ return this._messageListRef.comp.getScrollTop();
+ }
+
+ /**
+ * @returns {mail.thread_viewer}
+ */
+ get threadViewer() {
+ return this.env.models['mail.thread_viewer'].get(this.props.threadViewerLocalId);
+ }
+
+ //--------------------------------------------------------------------------
+ // Private
+ //--------------------------------------------------------------------------
+
+ /**
+ * Called when thread component is mounted or patched.
+ *
+ * @private
+ */
+ async _update() {
+ const messageList = this._messageListRef.comp;
+ this.trigger('o-rendered');
+ /**
+ * Control panel may offset scrolling position of message list due to
+ * height of buttons. To prevent this, control panel re-render is
+ * triggered before message list. Correct way should be to adjust
+ * scroll positions after everything has been rendered, but OWL doesn't
+ * have such an API for the moment.
+ */
+ if (messageList) {
+ messageList.adjustFromComponentHints();
+ }
+ }
+
+ /**
+ * Returns data selected from the store.
+ *
+ * @private
+ * @param {Object} props
+ * @returns {Object}
+ */
+ _useStoreSelector(props) {
+ const threadViewer = this.env.models['mail.thread_viewer'].get(props.threadViewerLocalId);
+ const thread = threadViewer ? threadViewer.thread : undefined;
+ const threadCache = threadViewer ? threadViewer.threadCache : undefined;
+ return {
+ isDeviceMobile: this.env.messaging.device.isMobile,
+ thread: thread ? thread.__state : undefined,
+ threadCache: threadCache ? threadCache.__state : undefined,
+ threadViewer: threadViewer ? threadViewer.__state : undefined,
+ };
+ }
+
+}
+
+Object.assign(ThreadViewer, {
+ components,
+ defaultProps: {
+ composerAttachmentsDetailsMode: 'auto',
+ hasComposer: false,
+ hasMessageCheckbox: false,
+ hasSquashCloseMessages: false,
+ haveMessagesAuthorRedirect: false,
+ haveMessagesMarkAsReadIcon: false,
+ haveMessagesReplyIcon: false,
+ order: 'asc',
+ showComposerAttachmentsExtensions: true,
+ showComposerAttachmentsFilenames: true,
+ },
+ props: {
+ composerAttachmentsDetailsMode: {
+ type: String,
+ validate: prop => ['auto', 'card', 'hover', 'none'].includes(prop),
+ },
+ hasComposer: Boolean,
+ hasComposerCurrentPartnerAvatar: {
+ type: Boolean,
+ optional: true,
+ },
+ hasComposerSendButton: {
+ type: Boolean,
+ optional: true,
+ },
+ /**
+ * If set, determines whether the composer should display status of
+ * members typing on related thread. When this prop is not provided,
+ * it defaults to composer component default value.
+ */
+ hasComposerThreadTyping: {
+ type: Boolean,
+ optional: true,
+ },
+ hasMessageCheckbox: Boolean,
+ hasSquashCloseMessages: Boolean,
+ haveMessagesAuthorRedirect: Boolean,
+ haveMessagesMarkAsReadIcon: Boolean,
+ haveMessagesReplyIcon: Boolean,
+ /**
+ * Set the initial scroll position of message list on mount. Note that
+ * this prop is not directly passed to message list as props because
+ * it may compute scroll top without composer, and then composer may alter
+ * them on mount. To solve this issue, thread handles setting initial scroll
+ * positions, so that this is always done after composer has been mounted.
+ * (child `mounted()` are called before parent `mounted()`)
+ */
+ messageListInitialScrollTop: {
+ type: Number,
+ optional: true
+ },
+ order: {
+ type: String,
+ validate: prop => ['asc', 'desc'].includes(prop),
+ },
+ selectedMessageLocalId: {
+ type: String,
+ optional: true,
+ },
+ showComposerAttachmentsExtensions: Boolean,
+ showComposerAttachmentsFilenames: Boolean,
+ threadViewerLocalId: String,
+ },
+ template: 'mail.ThreadViewer',
+});
+
+return ThreadViewer;
+
+});
diff --git a/addons/mail/static/src/components/thread_viewer/thread_viewer.scss b/addons/mail/static/src/components/thread_viewer/thread_viewer.scss
new file mode 100644
index 00000000000..9b39a493979
--- /dev/null
+++ b/addons/mail/static/src/components/thread_viewer/thread_viewer.scss
@@ -0,0 +1,38 @@
+// ------------------------------------------------------------------
+// Layout
+// ------------------------------------------------------------------
+
+.o_ThreadViewer {
+ display: flex;
+ position: relative;
+ flex-flow: column;
+ overflow: auto;
+}
+
+.o_ThreadViewer_composer {
+ flex: 0 0 auto;
+}
+
+.o_ThreadViewer_loading {
+ display: flex;
+ align-self: center;
+ flex: 1 1 auto;
+ align-items: center;
+}
+
+.o_ThreadViewer_messageList {
+ flex: 1 1 auto;
+}
+
+// ------------------------------------------------------------------
+// Style
+// ------------------------------------------------------------------
+
+.o_ThreadViewer {
+ background-color: gray('100');
+
+}
+
+.o_ThreadViewer_loadingIcon {
+ margin-right: 3px;
+}
diff --git a/addons/mail/static/src/components/thread_viewer/thread_viewer.xml b/addons/mail/static/src/components/thread_viewer/thread_viewer.xml
new file mode 100644
index 00000000000..e292ac4e4ec
--- /dev/null
+++ b/addons/mail/static/src/components/thread_viewer/thread_viewer.xml
@@ -0,0 +1,44 @@
+<?xml version="1.0" encoding="UTF-8"?>
+<templates xml:space="preserve">
+
+ <t t-name="mail.ThreadViewer" owl="1">
+ <div class="o_ThreadViewer" t-att-data-thread-local-id="threadViewer and threadViewer.thread and threadViewer.thread.localId">
+ <t t-if="threadViewer">
+ <t t-if="threadViewer.isShowingLoading" name="loadingCondition">
+ <div class="o_ThreadViewer_loading">
+ <span><i class="o_ThreadViewer_loadingIcon fa fa-spinner fa-spin" title="Loading..." role="img"/>Loading...</span>
+ </div>
+ </t>
+ <t t-elif="threadViewer.threadCache.isLoaded">
+ <MessageList
+ class="o_ThreadViewer_messageList"
+ hasMessageCheckbox="props.hasMessageCheckbox"
+ hasSquashCloseMessages="props.hasSquashCloseMessages"
+ haveMessagesAuthorRedirect="props.haveMessagesAuthorRedirect"
+ haveMessagesMarkAsReadIcon="props.haveMessagesMarkAsReadIcon"
+ haveMessagesReplyIcon="props.haveMessagesReplyIcon"
+ order="props.order"
+ selectedMessageLocalId="props.selectedMessageLocalId"
+ threadViewerLocalId="threadViewer.localId"
+ t-ref="messageList"
+ />
+ </t>
+ <t t-if="props.hasComposer">
+ <Composer
+ class="o_ThreadViewer_composer"
+ attachmentsDetailsMode="props.composerAttachmentsDetailsMode"
+ composerLocalId="threadViewer.thread.composer.localId"
+ hasCurrentPartnerAvatar="props.hasComposerCurrentPartnerAvatar"
+ hasSendButton="props.hasComposerSendButton"
+ hasThreadTyping="props.hasComposerThreadTyping"
+ isFocusOnMount="!env.messaging.device.isMobile"
+ showAttachmentsExtensions="props.showComposerAttachmentsExtensions"
+ showAttachmentsFilenames="props.showComposerAttachmentsFilenames"
+ t-ref="composer"
+ />
+ </t>
+ </t>
+ </div>
+ </t>
+
+</templates>
diff --git a/addons/mail/static/src/components/thread_viewer/thread_viewer_tests.js b/addons/mail/static/src/components/thread_viewer/thread_viewer_tests.js
new file mode 100644
index 00000000000..1c7d24a86e1
--- /dev/null
+++ b/addons/mail/static/src/components/thread_viewer/thread_viewer_tests.js
@@ -0,0 +1,364 @@
+odoo.define('mail/static/src/components/thread_viewer/thread_viewer_tests.js', function (require) {
+'use strict';
+
+const components = {
+ ThreadViewer: require('mail/static/src/components/thread_viewer/thread_viewer.js'),
+};
+const {
+ afterEach: utilsAfterEach,
+ afterNextRender,
+ beforeEach: utilsBeforeEach,
+ dragenterFiles,
+ start: utilsStart,
+} = require('mail/static/src/utils/test_utils.js');
+
+QUnit.module('mail', {}, function () {
+QUnit.module('components', {}, function () {
+QUnit.module('thread_viewer', {}, function () {
+QUnit.module('thread_viewer_tests.js', {
+ beforeEach() {
+ utilsBeforeEach(this);
+
+ /**
+ * @param {mail.thread_viewer} threadViewer
+ * @param {Object} [otherProps={}]
+ * @param {Object} [param2={}]
+ * @param {boolean} [param2.isFixedSize=false]
+ */
+ this.createThreadViewerComponent = async (threadViewer, otherProps = {}, { isFixedSize = false } = {}) => {
+ const ThreadViewerComponent = components.ThreadViewer;
+ ThreadViewerComponent.env = this.env;
+ this.component = new ThreadViewerComponent(
+ null,
+ Object.assign({ threadViewerLocalId: threadViewer.localId }, otherProps)
+ );
+ let target;
+ if (isFixedSize) {
+ // needed to allow scrolling in some tests
+ const div = document.createElement('div');
+ Object.assign(div.style, {
+ display: 'flex',
+ 'flex-flow': 'column',
+ height: '300px',
+ });
+ this.widget.el.append(div);
+ target = div;
+ } else {
+ target = this.widget.el;
+ }
+ await afterNextRender(() => this.component.mount(target));
+ };
+
+ this.start = async params => {
+ if (this.widget) {
+ this.widget.destroy();
+ }
+ let { env, widget } = await utilsStart(Object.assign({}, params, {
+ data: this.data,
+ }));
+ this.env = env;
+ this.widget = widget;
+ };
+ },
+ afterEach() {
+ utilsAfterEach(this);
+ if (this.component) {
+ this.component.destroy();
+ }
+ if (this.widget) {
+ this.widget.destroy();
+ }
+ this.env = undefined;
+ delete components.ThreadViewer.env;
+ },
+});
+
+QUnit.test('dragover files on thread with composer', async function (assert) {
+ assert.expect(1);
+
+ await this.start();
+ const thread = this.env.models['mail.thread'].create({
+ channel_type: 'channel',
+ id: 100,
+ members: [['insert', [
+ {
+ email: "john@example.com",
+ id: 9,
+ name: "John",
+ },
+ {
+ email: "fred@example.com",
+ id: 10,
+ name: "Fred",
+ },
+ ]]],
+ model: 'mail.channel',
+ name: "General",
+ public: 'public',
+ });
+ const threadViewer = this.env.models['mail.thread_viewer'].create({ thread: [['link', thread]] });
+ await this.createThreadViewerComponent(threadViewer, { hasComposer: true });
+ await afterNextRender(() =>
+ dragenterFiles(document.querySelector('.o_ThreadViewer'))
+ );
+ assert.ok(
+ document.querySelector('.o_Composer_dropZone'),
+ "should have dropzone when dragging file over the thread"
+ );
+});
+
+QUnit.test('message list desc order', async function (assert) {
+ assert.expect(8);
+
+ let lastId = 10000;
+ let amountOfCalls = 0;
+ await this.start({
+ async mockRPC(route, args) {
+ if (args.method === 'message_fetch') {
+ amountOfCalls ++;
+ assert.step(`message_fetch_${amountOfCalls}`);
+ // Just return 30 different messages
+ const messagesData = [...Array(30).keys()].reduce(function (acc, i) {
+ acc.push({
+ author_id: [i + 1, `Author #${i}`],
+ body: `<p>The message</p>`,
+ channel_ids: [20],
+ date: "2019-04-20 10:00:00",
+ id: lastId - i,
+ message_type: 'comment',
+ model: 'mail.channel',
+ record_name: 'General',
+ res_id: 20,
+ });
+ return acc;
+ }, []);
+ lastId -= 30;
+ return messagesData;
+ }
+ return this._super(...arguments);
+ },
+ });
+ const thread = this.env.models['mail.thread'].create({
+ channel_type: 'channel',
+ id: 100,
+ members: [['insert', [
+ {
+ email: "john@example.com",
+ id: 9,
+ name: "John",
+ },
+ {
+ email: "fred@example.com",
+ id: 10,
+ name: "Fred",
+ },
+ ]]],
+ model: 'mail.channel',
+ name: "General",
+ public: 'public',
+ });
+ const threadViewer = this.env.models['mail.thread_viewer'].create({ thread: [['link', thread]] });
+ await this.createThreadViewerComponent(threadViewer, { order: 'desc' }, { isFixedSize: true });
+ const messageItems = document.querySelectorAll(`.o_MessageList_item`);
+ assert.notOk(
+ messageItems[0].classList.contains("o_MessageList_loadMore"),
+ "load more link should NOT be before messages"
+ );
+ assert.ok(
+ messageItems[messageItems.length - 1].classList.contains("o_MessageList_loadMore"),
+ "load more link should be after messages"
+ );
+ assert.strictEqual(
+ document.querySelectorAll(`.o_Message`).length,
+ 30,
+ "should have 30 messages at the beginning"
+ );
+
+ // scroll to bottom
+ await afterNextRender(() => {
+ document.querySelector(`.o_ThreadViewer_messageList`).scrollTop =
+ document.querySelector(`.o_ThreadViewer_messageList`).scrollHeight;
+ });
+ assert.strictEqual(
+ document.querySelectorAll(`.o_Message`).length,
+ 60,
+ "should have 60 messages after scrolled to bottom"
+ );
+
+ await afterNextRender(() => {
+ document.querySelector(`.o_ThreadViewer_messageList`).scrollTop = 0;
+ });
+ assert.strictEqual(
+ document.querySelectorAll(`.o_Message`).length,
+ 60,
+ "scrolling to top should not trigger any message fetching"
+ );
+ assert.verifySteps(['message_fetch_1', 'message_fetch_2']);
+});
+
+QUnit.test('message list asc order', async function (assert) {
+ assert.expect(8);
+
+ let lastId = 10000;
+ let amountOfCalls = 0;
+ await this.start({
+ async mockRPC(route, args) {
+ if (args.method === 'message_fetch') {
+ amountOfCalls ++;
+ assert.step(`message_fetch_${amountOfCalls}`);
+ // Just return 30 different messages
+ const messagesData = [...Array(30).keys()].reduce(function (acc, i) {
+ acc.push({
+ author_id: [i + 1, `Author #${i}`],
+ body: `<p>The message</p>`,
+ channel_ids: [20],
+ date: "2019-04-20 10:00:00",
+ id: lastId - i,
+ message_type: 'comment',
+ model: 'mail.channel',
+ record_name: 'General',
+ res_id: 20,
+ });
+ return acc;
+ }, []);
+ lastId -= 30;
+ return messagesData;
+ }
+ return this._super(...arguments);
+ },
+ });
+ const thread = this.env.models['mail.thread'].create({
+ channel_type: 'channel',
+ id: 100,
+ members: [['insert', [
+ {
+ email: "john@example.com",
+ id: 9,
+ name: "John",
+ },
+ {
+ email: "fred@example.com",
+ id: 10,
+ name: "Fred",
+ },
+ ]]],
+ model: 'mail.channel',
+ name: "General",
+ public: 'public',
+ });
+ const threadViewer = this.env.models['mail.thread_viewer'].create({ thread: [['link', thread]] });
+ await this.createThreadViewerComponent(threadViewer, { order: 'asc' }, { isFixedSize: true });
+ const messageItems = document.querySelectorAll(`.o_MessageList_item`);
+ assert.notOk(
+ messageItems[messageItems.length - 1].classList.contains("o_MessageList_loadMore"),
+ "load more link should be before messages"
+ );
+ assert.ok(
+ messageItems[0].classList.contains("o_MessageList_loadMore"),
+ "load more link should NOT be after messages"
+ );
+ assert.strictEqual(
+ document.querySelectorAll(`.o_Message`).length,
+ 30,
+ "should have 30 messages at the beginning"
+ );
+
+ // scroll to top
+ await afterNextRender(() => {
+ document.querySelector(`.o_ThreadViewer_messageList`).scrollTop = 0;
+ });
+ assert.strictEqual(
+ document.querySelectorAll(`.o_Message`).length,
+ 60,
+ "should have 60 messages after scrolled to top"
+ );
+
+ // scroll to bottom
+ await afterNextRender(() => {
+ document.querySelector(`.o_ThreadViewer_messageList`).scrollTop =
+ document.querySelector(`.o_ThreadViewer_messageList`).scrollHeight;
+ });
+ assert.strictEqual(
+ document.querySelectorAll(`.o_Message`).length,
+ 60,
+ "scrolling to bottom should not trigger any message fetching"
+ );
+ assert.verifySteps(['message_fetch_1', 'message_fetch_2']);
+});
+
+QUnit.test('mark channel as fetched when a new message is loaded and as seen when message is visible', async function (assert) {
+ assert.expect(7);
+
+ await this.start({
+ mockRPC(route, args) {
+ if (args.method === 'channel_fetched') {
+ assert.strictEqual(
+ args.args[0][0],
+ 100,
+ 'channel_fetched is called on the right channel id'
+ );
+ assert.strictEqual(
+ args.model,
+ 'mail.channel',
+ 'channel_fetched is called on the right channel model'
+ );
+ assert.step('rpc_channel_fetched:called');
+ } else if (args.method === 'channel_seen') {
+ assert.strictEqual(
+ args.args[0][0],
+ 100,
+ 'channel_seen is called on the right channel id'
+ );
+ assert.strictEqual(
+ args.model,
+ 'mail.channel',
+ 'channel_seeb is called on the right channel model'
+ );
+ assert.step('rpc_channel_seen:called');
+ }
+ return this._super(...arguments);
+ }
+ });
+ const thread = this.env.models['mail.thread'].create({
+ id: 100,
+ isPinned: true, // just to avoid joinChannel to be called
+ members: [['insert', [
+ {
+ email: "john@example.com",
+ id: this.env.session.partner_id,
+ name: "John",
+ },
+ {
+ email: "fred@example.com",
+ id: 10,
+ name: "Fred",
+ },
+ ]]],
+ model: 'mail.channel',
+ });
+ const threadViewer = this.env.models['mail.thread_viewer'].create({ thread: [['link', thread]] });
+ await this.createThreadViewerComponent(threadViewer);
+ const notifications = [
+ [['myDB', 'mail.channel', 100], {
+ channelId: 100,
+ id: 1,
+ body: "<p>fdsfsd</p>",
+ author_id: [10, "Fred"],
+ model: "mail.channel",
+ channel_ids: [100],
+ }]
+ ];
+ await afterNextRender(() => {
+ this.widget.call('bus_service', 'trigger', 'notification', notifications);
+ });
+ assert.verifySteps(
+ ['rpc_channel_fetched:called', 'rpc_channel_seen:called'],
+ "Channel should have been fetched before having been seen"
+ );
+});
+
+});
+});
+});
+
+});
diff --git a/addons/mail/static/src/env/env.js b/addons/mail/static/src/env/env.js
new file mode 100644
index 00000000000..ea760ba6e11
--- /dev/null
+++ b/addons/mail/static/src/env/env.js
@@ -0,0 +1,104 @@
+odoo.define('mail/static/src/env/env.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;
+
+/**
+ * Messaging store
+ */
+const store = new Store({
+ env,
+ state: {},
+});
+
+/**
+ * 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;
+ },
+ loadingBaseDelayDuration: 400,
+ messaging: undefined,
+ messagingBus: new EventBus(),
+ modelManager: new ModelManager(),
+ 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')
+);
+
+(async function start() {
+ 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));
+
+ env.modelManager.start(env);
+ /**
+ * Create the messaging singleton record.
+ */
+ env.messaging = env.models['mail.messaging'].create();
+ await env.messaging.start();
+})();
+
+});
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 00000000000..75e921c27c7
--- /dev/null
+++ b/addons/mail/static/src/env/test_env.js
@@ -0,0 +1,152 @@
+odoo.define('mail/static/src/env/test_env.js', function (require) {
+'use strict';
+
+const ModelManager = require('mail/static/src/model/model_manager.js');
+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 defaultEnv = Object.assign({}, providedEnv, {
+ session: Object.assign({
+ is_bound: Promise.resolve(),
+ name: 'Admin',
+ partner_display_name: 'Your Company, Admin',
+ partner_id: 3,
+ uid: 2,
+ url: s => s,
+ }, providedEnv.session)
+ });
+
+ let env = Object.assign({
+ loadingBaseDelayDuration: defaultEnv.loadingBaseDelayDuration || 0,
+ }, defaultEnv);
+
+ /**
+ * Messaging store
+ */
+ const store = new Store({
+ env,
+ state: {},
+ });
+
+ /**
+ * 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;
+ },
+ messaging: undefined,
+ messagingInitializedDeferred: makeDeferred(),
+ messagingBus: new EventBus(),
+ modelManager: new ModelManager(),
+ 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/js/activity.js b/addons/mail/static/src/js/activity.js
index 8a59c976ba3..d7a8f6bf9b2 100644
--- a/addons/mail/static/src/js/activity.js
+++ b/addons/mail/static/src/js/activity.js
@@ -213,7 +213,6 @@ var BasicActivity = AbstractField.extend({
/**
* @private
* @param {integer} id
- * @param {integer} previousActivityTypeID
* @param {function} callback
* @return {Promise}
*/
@@ -544,6 +543,7 @@ var BasicActivity = AbstractField.extend({
// -----------------------------------------------------------------------------
// 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, {
@@ -859,7 +859,6 @@ var ActivityException = AbstractField.extend({
});
field_registry
- .add('mail_activity', Activity)
.add('kanban_activity', KanbanActivity)
.add('list_activity', ListActivity)
.add('activity_exception', ActivityException);
diff --git a/addons/mail/static/src/js/attachment_box.js b/addons/mail/static/src/js/attachment_box.js
deleted file mode 100644
index 507bb38ce49..00000000000
--- a/addons/mail/static/src/js/attachment_box.js
+++ /dev/null
@@ -1,141 +0,0 @@
-odoo.define('mail.AttachmentBox', function (require) {
-"use strict";
-
-var core = require('web.core');
-var Widget = require('web.Widget');
-
-var DocumentViewer = require('mail.DocumentViewer');
-
-var QWeb = core.qweb;
-var _t = core._t;
-
-var AttachmentBox = Widget.extend({
- template: 'mail.chatter.AttachmentBox',
- events: {
- "click .o_attachment_download": "_onAttachmentDownload",
- "click .o_attachment_view": "_onAttachmentView",
- "click .o_attachment_delete_cross": "_onDeleteAttachment",
- "click .o_upload_attachments_button": "_onUploadAttachments",
- "change .o_chatter_attachment_form .o_form_binary_form": "_onAddAttachment",
- },
- /**
- * @override
- * @param {string} record.model
- * @param {Number} record.res_id
- * @param {Object[]} attachments
- */
- init: function (parent, record, attachments) {
- this._super.apply(this, arguments);
- this.fileuploadId = _.uniqueId('oe_fileupload');
- $(window).on(this.fileuploadId, this._onUploaded.bind(this));
- this.currentResID = record.res_id;
- this.currentResModel = record.model;
- this.attachmentIDs = attachments;
- this.imageList = {};
- this.otherList = {};
-
- _.each(attachments, function (attachment) {
- // required for compatibility with the chatter templates.
- attachment.url = '/web/content/' + attachment.id + '?download=true';
- attachment.filename = attachment.name || _t('unnamed');
- });
- var sortedAttachments = _.partition(attachments, function (att) {
- return att.mimetype && att.mimetype.split('/')[0] === 'image';
- });
- this.imageList = sortedAttachments[0];
- this.otherList = sortedAttachments[1];
- },
- /**
- * @override
- */
- destroy: function () {
- $(window).off(this.fileuploadId);
- this._super.apply(this, arguments);
- },
-
- //--------------------------------------------------------------------------
- // Public
- //--------------------------------------------------------------------------
-
- /**
- * @param {Object} record
- */
- update: function (record) {
- this.currentResID = record.res_id;
- this.currentResModel = record.model;
- },
-
- //--------------------------------------------------------------------------
- // Handlers
- //--------------------------------------------------------------------------
-
- /**
- * Method triggered when user click on 'add attachment' and select a file
- *
- * @private
- * @param {MouseEvent} ev
- */
- _onAddAttachment: function (ev) {
- var $input = $(ev.currentTarget).find('input.o_input_file');
- if ($input.val() !== '') {
- var $binaryForm = this.$('form.o_form_binary_form');
- $binaryForm.submit();
- }
- },
- /**
- * @private
- * @param {MouseEvent} ev
- * used to prevent the click from opening the document viewer.
- */
- _onAttachmentDownload: function (ev) {
- ev.stopPropagation();
- },
- /**
- * @private
- * @param {MouseEvent} ev
- */
- _onAttachmentView: function (ev) {
- ev.stopPropagation();
- ev.preventDefault();
- var activeAttachmentID = $(ev.currentTarget).data('id');
- if (activeAttachmentID) {
- var attachmentViewer = new DocumentViewer(this, this.attachmentIDs, activeAttachmentID);
- attachmentViewer.appendTo($('body'));
- }
- },
- /**
- * Opens File Explorer dialog if all fields are valid and record is saved
- *
- * @private
- * @param {MouseEvent} ev
- */
- _onUploadAttachments: function (ev) {
- this.$('input.o_input_file').click();
- },
- /**
- * @private
- * @param {MouseEvent} ev
- */
- _onDeleteAttachment: function (ev) {
- ev.stopPropagation();
- var $target = $(ev.currentTarget);
- this.trigger_up('delete_attachment', {
- attachmentId: $target.data('id'),
- attachmentName: $target.data('name')
- });
- },
- /**
- * @private
- */
- _onUploaded: function(ev, response) {
- if (response.error) {
- this.do_warn(false, _t("Attachment upload not authorized here"));
- } else {
- this.trigger_up('reload_attachment_box');
- }
- },
-});
-
-return AttachmentBox;
-
-});
diff --git a/addons/mail/static/src/js/basic_model.js b/addons/mail/static/src/js/basic_model.js
deleted file mode 100644
index 11139819a60..00000000000
--- a/addons/mail/static/src/js/basic_model.js
+++ /dev/null
@@ -1,33 +0,0 @@
-odoo.define('mail.basic_model', function (require) {
-"use strict";
-
-var BasicModel = require('web.BasicModel');
-
-/**
- * Include the BasicModel to update the datapoint on the model when a
- * message is posted.
- */
-BasicModel.include({
-
- //--------------------------------------------------------------------------
- // Public
- //--------------------------------------------------------------------------
-
- /**
- * Update the message ids on a datapoint.
- *
- * Note that we directly update the res_ids on the datapoint as the message
- * has already been posted ; this change can't be handled 'normally' with
- * x2m commands because the change won't be saved as a normal field.
- *
- * @param {string} id
- * @param {integer[]} msgIDs
- */
- updateMessageIDs: function (id, msgIDs) {
- var element = this.localData[id];
- element.res_ids = msgIDs;
- element.count = msgIDs.length;
- },
-});
-
-});
diff --git a/addons/mail/static/src/js/basic_view.js b/addons/mail/static/src/js/basic_view.js
index a7ce716b460..042e0ded8e8 100644
--- a/addons/mail/static/src/js/basic_view.js
+++ b/addons/mail/static/src/js/basic_view.js
@@ -1,17 +1,29 @@
odoo.define('mail.BasicView', function (require) {
"use strict";
-var BasicView = require('web.BasicView');
+const BasicView = require('web.BasicView');
-var mailWidgets = ['mail_followers', 'mail_thread', 'mail_activity', 'kanban_activity'];
+const mailWidgets = ['kanban_activity'];
BasicView.include({
init: function () {
this._super.apply(this, arguments);
+ const post_refresh = this._getFieldOption('message_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,
+ isActivityBoxVisible: this._getFieldOption('message_ids', 'open_attachments', false),
+ };
+ const fieldsInfo = this.fieldsInfo[this.viewType];
+ this.rendererParams.chatterFields = this.chatterFields;
+
+ // LEGACY for widget kanban_activity
this.mailFields = {};
- var fieldsInfo = this.fieldsInfo[this.viewType];
- for (var fieldName in fieldsInfo) {
- var fieldInfo = fieldsInfo[fieldName];
+ for (const fieldName in fieldsInfo) {
+ const fieldInfo = fieldsInfo[fieldName];
if (_.contains(mailWidgets, fieldInfo.widget)) {
this.mailFields[fieldInfo.widget] = fieldName;
fieldInfo.__no_fetch = true;
@@ -20,6 +32,32 @@ BasicView.include({
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/chatter.js b/addons/mail/static/src/js/chatter.js
deleted file mode 100644
index 2b7186a46e3..00000000000
--- a/addons/mail/static/src/js/chatter.js
+++ /dev/null
@@ -1,660 +0,0 @@
-odoo.define('mail.Chatter', function (require) {
-"use strict";
-
-var Activity = require('mail.Activity');
-var AttachmentBox = require('mail.AttachmentBox');
-var ChatterComposer = require('mail.composer.Chatter');
-var Dialog = require('web.Dialog');
-var Followers = require('mail.Followers');
-var ThreadField = require('mail.ThreadField');
-var mailUtils = require('mail.utils');
-
-var concurrency = require('web.concurrency');
-var config = require('web.config');
-var core = require('web.core');
-var Widget = require('web.Widget');
-
-var _t = core._t;
-var QWeb = core.qweb;
-
-// The purpose of this widget is to display the chatter area below the form view
-//
-// It instantiates the optional mail_thread, mail_activity and mail_followers widgets.
-// It Ensures that those widgets are appended at the right place, and allows them to communicate
-// with each other.
-// It synchronizes the rendering of those widgets (as they may be asynchronous), to mitigate
-// the flickering when switching between records
-var Chatter = Widget.extend({
- template: 'mail.Chatter',
- custom_events: {
- delete_attachment: '_onDeleteAttachment',
- discard_record_changes: '_onDiscardRecordChanges',
- reload_attachment_box: '_onReloadAttachmentBox',
- reload_mail_fields: '_onReloadMailFields',
- reset_suggested_partners: '_onResetSuggestedPartners',
- },
- events: {
- 'click .o_chatter_button_new_message': '_onOpenComposerMessage',
- 'click .o_chatter_button_log_note': '_onOpenComposerNote',
- 'click .o_chatter_button_attachment': '_onClickAttachmentButton',
- 'click .o_chatter_button_schedule_activity': '_onScheduleActivity',
- },
- supportedFieldTypes: ['one2many'],
-
- /**
- * @override
- * @param {widget} parent
- * @param {Object} record
- * @param {Object} mailFields
- * @param {string} [mailFields.mail_activity]
- * @param {string} [mailFields.mail_followers]
- * @param {string} [mailFields.mail_thread]
- * @param {Object} options
- * @param {string} [options.viewType=record.viewType] current viewType in
- * which the chatter is instantiated
- */
- init: function (parent, record, mailFields, options) {
- this._super.apply(this, arguments);
- this._setState(record);
-
- this.attachments = {};
- this.fields = {};
- this._areAttachmentsLoaded = false;
- this._disableAttachmentBox = !!options.disable_attachment_box;
- this._dp = new concurrency.DropPrevious();
- this._isAttachmentBoxOpen = false;
- this._isComposerOpen = false;
- // mention: get the prefetched partners and use them as mention suggestions
- // if there is a follower widget, the followers will be added to the
- // suggestions as well once fetched
- this._mentionPartnerSuggestions = this.call('mail_service', 'getMentionPartnerSuggestions');
- this._mentionSuggestions = this._mentionPartnerSuggestions;
- /**
- * List of fetched suggested partners. This is lazy-loaded on opening
- * composer. `undefined` means it should be fetched again.
- */
- this._suggestedPartners = undefined;
-
- if (mailFields.mail_activity) {
- this.fields.activity = new Activity(this, mailFields.mail_activity, record, options);
- }
- if (mailFields.mail_followers) {
- this.fields.followers = new Followers(this, mailFields.mail_followers, record, options);
- }
- if (mailFields.mail_thread) {
- this.fields.thread = new ThreadField(this, mailFields.mail_thread, record, options);
- var fieldsInfo = record.fieldsInfo[options.viewType || record.viewType];
- var nodeOptions = fieldsInfo[mailFields.mail_thread].options || {};
- this.hasLogButton = options.display_log_button || nodeOptions.display_log_button;
- this.postRefresh = nodeOptions.post_refresh || 'never';
- this.reloadOnUploadAttachment = this.postRefresh === 'always';
- this.openAttachments = nodeOptions.open_attachments || false;
- }
- },
- /**
- * @override
- */
- start: function () {
- this._$topbar = this.$('.o_chatter_topbar');
- if(!this._disableAttachmentBox) {
- this.$('.o_topbar_right_area').append(QWeb.render('mail.chatter.Attachment.Button', {
- count: this.record.data.message_attachment_count || 0,
- }));
- }
- // render and append the buttons
- this._$topbar.prepend(this._renderButtons());
- // start and append the widgets
- var fieldDefs = _.invoke(this.fields, 'appendTo', $('<div>'));
- var def = this._dp.add(Promise.all(fieldDefs));
- this._render(def)
- .then(this._updateMentionSuggestions.bind(this))
- .then(() => {
- if (this.openAttachments) {
- this._fetchAttachments().then(() => this._openAttachmentBox());
- }
- });
-
- return this._super.apply(this, arguments);
- },
-
- //--------------------------------------------------------------------------
- // Public
- //--------------------------------------------------------------------------
-
- /**
- * @param {Object} record
- * @param {integer} [record.res_id=undefined]
- * @param {Object[]} [fieldNames=undefined]
- */
- update: function (record, fieldNames) {
- var self = this;
-
- // close the composer if we switch to another record as it is record dependent
- if (this.record.res_id !== record.res_id) {
- this._closeComposer(true);
- this._closeAttachments();
- this._areAttachmentsLoaded = false;
- this.attachments = {};
- }
-
- // update the state
- this._setState(record);
-
- // detach the thread and activity widgets (temporarily force the height to prevent flickering)
- // keep the followers in the DOM as it has a synchronous pre-rendering
- this.$el.height(this.$el.height());
- if (this.fields.activity) {
- this.fields.activity.$el.detach();
- }
- if (this.fields.thread && this.fields.thread.$el ) {
- this.fields.thread.$el.detach();
- }
-
- // reset and re-append the widgets (and reset 'height: auto' rule)
- // if fieldNames is given, only reset those fields, otherwise reset all fields
- var fieldsToReset;
- if (fieldNames) {
- fieldsToReset = _.filter(this.fields, function (field) {
- return _.contains(fieldNames, field.name);
- });
- } else {
- fieldsToReset = this.fields;
- }
- var fieldDefs = _.invoke(fieldsToReset, 'reset', record);
- var def = this._dp.add(Promise.all(fieldDefs));
- this._render(def).then(function () {
- self.$el.height('auto');
- self._updateMentionSuggestions();
- });
- this._updateAttachmentCounter();
- },
- async updateSuggestedPartners() {
- this._suggestedPartners = undefined;
- if (this._composer && this._isComposerOpen) {
- const suggestedPartners = await this._getSuggestedPartners();
- this._composer.updateSuggestedPartners(suggestedPartners);
- }
- },
-
- //--------------------------------------------------------------------------
- // Private
- //--------------------------------------------------------------------------
-
- /**
- * @private
- */
- _closeAttachments: function () {
- if (this.fields.attachments) {
- this.$('.o_chatter_button_attachment').removeClass('o_active_attach');
- this.fields.attachments.destroy();
- this._isAttachmentBoxOpen = false;
- }
- },
- /**
- * @private
- * @param {boolean} force
- */
- _closeComposer: function (force) {
- if (this._composer && (this._composer.isEmpty() || force)) {
- this.$el.removeClass('o_chatter_composer_active');
- this.$('.o_chatter_button_new_message, .o_chatter_button_log_note').removeClass('o_active');
- this._composer.do_hide();
- this._composer.clearComposer();
- this._isComposerOpen = false;
- }
- },
- /**
- * @private
- */
- _disableChatter: function () {
- this.$('.btn').prop('disabled', true); // disable buttons
- },
- /**
- * @private
- */
- _disableComposer: function () {
- this.$(".o_composer_button_send").prop('disabled', true);
- },
- /**
- * Discard changes on the record.
- *
- * @private
- * @returns {Promise} resolved if successfully discarding changes on
- * the record, rejected otherwise
- */
- _discardChanges: function () {
- var self = this;
- return new Promise(function (resolve, reject) {
- self.trigger_up('discard_changes', {
- recordID: self.record.id,
- onSuccess: resolve,
- onFailure: reject,
- });
- });
- },
- /**
- * Discard changes on the record if the message will reload the record
- * after posting it
- *
- * @private
- * @param {Object} messageData
- * @return {Promise} resolved if no reload or proceed to discard the
- * changes on the record, rejected otherwise
- */
- _discardOnReload: function (messageData) {
- if (this._reloadAfterPost(messageData)) {
- return this._discardChanges();
- }
- return Promise.resolve();
- },
- /**
- * @private
- */
- _enableChatter: function () {
- this.$('.btn').prop('disabled', false); // enable buttons
- },
- /**
- * @private
- */
- _enableComposer: function () {
- this.$(".o_composer_button_send").prop('disabled', false);
- },
- /*
- * @private
- * @return {Deferred}
- */
- _fetchAttachments: function () {
- var self = this;
- var domain = [
- ['res_id', '=', this.record.res_id],
- ['res_model', '=', this.record.model],
- ];
- return this._rpc({
- model: 'ir.attachment',
- method: 'search_read',
- domain: domain,
- fields: ['id', 'name', 'mimetype'],
- }).then(function (result) {
- self._areAttachmentsLoaded = true;
- self.attachments = result;
- });
-
- },
- /**
- * @private
- */
- async _getSuggestedPartners() {
- if (this._suggestedPartners) {
- return this._suggestedPartners;
- }
- const result = await this._rpc({
- route: '/mail/get_suggested_recipients',
- params: {
- model: this.record.model,
- res_ids: [this.context.default_res_id],
- },
- });
- const suggestedPartners = [];
- const threadRecipients = result[this.context.default_res_id] || [];
- for (const recipient of threadRecipients) {
- const parsedEmail = recipient[1] && mailUtils.parseEmail(recipient[1]);
- suggestedPartners.push({
- checked: true,
- partner_id: recipient[0],
- full_name: recipient[1],
- name: parsedEmail[0],
- email_address: parsedEmail[1],
- reason: recipient[2],
- });
- }
- this._suggestedPartners = suggestedPartners;
- return this._suggestedPartners;
- },
- /**
- * @private
- */
- _openAttachmentBox: function () {
- if (this.fields.attachments) {
- this._closeAttachments();
- }
- this.fields.attachments = new AttachmentBox(this, this.record, this.attachments);
- var $anchor = this.$('.o_chatter_topbar');
- if (this._composer) {
- var $anchor = this.$('.o_thread_composer');
- } else {
- var $anchor = this.$('.o_chatter_topbar');
- }
- this.fields.attachments.insertAfter($anchor);
- this.$('.o_chatter_button_attachment').addClass('o_active_attach');
-
- this._isAttachmentBoxOpen = true;
- },
- /**
- * @private
- * @param {Object} options
- * @param {Object[]} [options.suggestedPartners=[]]
- * @param {boolean} [options.isLog]
- */
- _openComposer: function (options) {
- var self = this;
- var oldComposer = this._composer;
- // create the new composer
- this._composer = new ChatterComposer(this, this.record.model, options.suggestedPartners || [], {
- commandsEnabled: false,
- context: this.context,
- inputMinHeight: 50,
- isLog: options && options.isLog,
- recordName: this.recordName,
- defaultBody: oldComposer && oldComposer.$input && oldComposer.$input.val(),
- defaultMentionSelections: oldComposer && oldComposer.getMentionListenerSelections(),
- attachmentIds: (oldComposer && oldComposer.get('attachment_ids')) || [],
- });
- this._composer.on('input_focused', this, function () {
- this._composer.mentionSetPrefetchedPartners(this._mentionSuggestions || []);
- });
- this._composer.insertAfter(this.$('.o_chatter_topbar')).then(function () {
- // destroy existing composer
- if (oldComposer) {
- oldComposer.destroy();
- }
- self._composer.focus();
- self._composer.on('post_message', self, function (messageData) {
- self._discardOnReload(messageData).then(function () {
- self._disableComposer();
- self.fields.thread.postMessage(messageData).then(function () {
- self._closeComposer(true);
- if (self._reloadAfterPost(messageData)) {
- self.trigger_up('reload');
- } else if (messageData.attachment_ids.length) {
- self._reloadAttachmentBox();
- self.trigger_up('reload', {fieldNames: ['message_attachment_count'], keepChanges: true});
- }
- }).guardedCatch(function () {
- self._enableComposer();
- });
- });
- });
- self._composer.on('need_refresh', self, self.trigger_up.bind(self, 'reload'));
- self._composer.on('close_composer', null, self._closeComposer.bind(self, true));
-
- self._isComposerOpen = true;
- self.$el.addClass('o_chatter_composer_active');
- self.$('.o_chatter_button_new_message, .o_chatter_button_log_note').removeClass('o_active');
- self.$('.o_chatter_button_new_message').toggleClass('o_active', !self._composer.options.isLog);
- self.$('.o_chatter_button_log_note').toggleClass('o_active', self._composer.options.isLog);
- });
- },
- /**
- * State if the record will be reloaded after posting a message.
- * Useful to warn the user of unsaved changes if the record is dirty.
- *
- * @private
- * @param {Object} messageData
- * @param {Array} [messageData.partner_ids] list of recipients of a message
- * @return {boolean} true if record will be reloaded after posting the
- * message, false otherwise
- */
- _reloadAfterPost: function (messageData) {
- return this.postRefresh === 'always' ||
- (
- this.postRefresh === 'recipients' &&
- messageData.partner_ids &&
- messageData.partner_ids.length
- );
- },
- /**
- * @private
- */
- _reloadAttachmentBox: function () {
- this._areAttachmentsLoaded = false;
- if (this._isAttachmentBoxOpen) {
- this._fetchAttachments().then(this._openAttachmentBox.bind(this));
- }
- if (!this._disableAttachmentBox) {
- this.trigger_up('reload', { fieldNames: ['message_attachment_count'], keepChanges: true });
- }
- },
- /**
- * @private
- * @param {Deferred} def
- * @returns {Deferred}
- */
- _render: function (def) {
- // the rendering of the chatter is aynchronous: relational data of its fields needs to be
- // fetched (in some case, it might be synchronous as they hold an internal cache).
- // this function takes a promise as argument, which is resolved once all fields have
- // fetched their data
- // this function appends the fields where they should be once the given promise is resolved
- // and if it takes more than 500ms, displays a spinner to indicate that it is loading
- var self = this;
-
- var $spinner = $(QWeb.render('Spinner'));
- concurrency.rejectAfter(concurrency.delay(500), def).then(function () {
- $spinner.appendTo(self.$el);
- });
- var always = function () {
- // disable widgets in create mode, otherwise enable
- self._isCreateMode ? self._disableChatter() : self._enableChatter();
- $spinner.remove();
- };
-
- return def.then(function () {
- if (self.fields.activity) {
- self.fields.activity.$el.appendTo(self.$el);
- }
- if (self.fields.followers) {
- if (self._disableAttachmentBox) {
- self.fields.followers.$el.appendTo(self.$('.o_topbar_right_area'));
- } else {
- self.fields.followers.$el.insertBefore(self.$('.o_chatter_button_attachment'));
- }
- }
- if (self.fields.thread && self.fields.thread.$el) {
- self.fields.thread.$el.appendTo(self.$el);
- }
- }).then(always).guardedCatch(always);
- },
- _renderButtons: function () {
- return QWeb.render('mail.chatter.Buttons', {
- newMessageButton: !!this.fields.thread,
- logNoteButton: this.hasLogButton,
- scheduleActivityButton: !!this.fields.activity,
- isMobile: config.device.isMobile,
- });
- },
- /**
- * @private
- * @param {Object} record
- * @param {integer} [record.res_id]
- * @param {string} [record.model]
- * @param {string} record.data.display_name
- */
- _setState: function (record) {
-
- this._isCreateMode = !record.res_id;
-
- if (!this.record || this.record.res_id !== record.res_id) {
- this.context = {
- default_res_id: record.res_id || false,
- default_model: record.model || false,
- };
- // ensure a reload of the suggested partners when
- // opening the composer on another record
- this._suggestedPartners = undefined;
- }
- this.record = record;
- this.recordName = record.data.display_name;
- },
- /**
- * @private
- */
- _updateAttachmentCounter: function () {
- var count = this.record.data.message_attachment_count || 0;
- var $element = this.$('.o_chatter_attachment_button_count');
- if (Number($element.html()) !== count) {
- this._areAttachmentsLoaded = false;
- $element.html(count);
- }
- },
- /**
- * @private
- */
- _updateMentionSuggestions: function () {
- if (!this.fields.followers) {
- return;
- }
- var self = this;
-
- this._mentionSuggestions = [];
-
- // add the followers to the mention suggestions
- var followerSuggestions = [];
- var followers = this.fields.followers.getFollowers();
- _.each(followers, function (follower) {
- if (follower.res_model === 'res.partner') {
- followerSuggestions.push({
- id: follower.res_id,
- name: follower.name,
- email: follower.email,
- });
- }
- });
- if (followerSuggestions.length) {
- this._mentionSuggestions.push(followerSuggestions);
- }
-
- // add the partners (followers filtered out) to the mention suggestions
- _.each(this._mentionPartnerSuggestions, function (partners) {
- self._mentionSuggestions.push(_.filter(partners, function (partner) {
- return !_.findWhere(followerSuggestions, { id: partner.id });
- }));
- });
- },
-
-
- //--------------------------------------------------------------------------
- // Handlers
- //--------------------------------------------------------------------------
-
- /**
- * @private
- * @param {OdooEvent} ev
- * @param {integer} ev.data.attachmentId
- * @param {String} ev.data.attachmentName
- */
- _onDeleteAttachment: function (ev) {
- ev.stopPropagation();
- var self = this;
- var options = {
- confirm_callback: function () {
- self._rpc({
- model: 'ir.attachment',
- method: 'unlink',
- args: [parseInt(ev.data.attachmentId, 10)],
- })
- .then(function () {
- self._reloadAttachmentBox();
- if (self.fields.thread) {
- self.fields.thread.removeAttachments([ev.data.attachmentId]);
- }
- self.trigger_up('reload');
- });
- }
- };
- var promptText = _.str.sprintf(_t("Do you really want to delete %s?"), _.escape(ev.data.attachmentName));
- Dialog.confirm(this, promptText, options);
- },
- /**
- * @private
- */
- _onClickAttachmentButton: function () {
- if(this._disableAttachmentBox) {
- return;
- }
- if (this._isAttachmentBoxOpen) {
- this._closeAttachments();
- } else {
- var def;
- if (!this._areAttachmentsLoaded) {
- def = this._fetchAttachments();
- }
- Promise.resolve(def).then(this._openAttachmentBox.bind(this));
- }
- },
- /**
- * Discard changes on the record.
- * This is notified by the composer, when opening the full-composer.
- *
- * @private
- * @param {OdooEvent} ev
- * @param {function} ev.data.proceed callback to tell to proceed
- */
- _onDiscardRecordChanges: function (ev) {
- this._discardChanges().then(ev.data.proceed);
- },
- async _onOpenComposerMessage() {
- const suggestedPartners = await this._getSuggestedPartners();
- this._openComposer({
- isLog: false,
- suggestedPartners,
- });
- },
- /**
- * @private
- */
- _onOpenComposerNote: function () {
- this._openComposer({ isLog: true });
- },
- /**
- * @private
- */
- _onReloadAttachmentBox: function () {
- if (this.reloadOnUploadAttachment) {
- this.trigger_up('reload');
- }
- this._reloadAttachmentBox();
- },
- /**
- * @private
- * @param {OdooEvent} event
- * @param {string} event.name
- * @param {Object} event.data
- * @param {boolean} [event.data.activity]
- * @param {boolean} [event.data.followers]
- * @param {boolean} [event.data.thread]
- */
- _onReloadMailFields: function (event) {
- var fieldNames = [];
- if (this.fields.activity && event.data.activity) {
- fieldNames.push(this.fields.activity.name);
- }
- if (this.fields.followers && event.data.followers) {
- fieldNames.push(this.fields.followers.name);
- }
- if (this.fields.thread && event.data.thread) {
- fieldNames.push(this.fields.thread.name);
- }
- this.trigger_up('reload', {
- fieldNames: fieldNames,
- keepChanges: true,
- });
- },
- /**
- * @private
- * @param {OdooEvent} ev
- */
- _onResetSuggestedPartners(ev) {
- ev.stopPropagation();
- this._suggestedPartners = undefined;
- },
- /**
- * @private
- */
- _onScheduleActivity: function () {
- this.fields.activity.scheduleActivity();
- },
-});
-
-return Chatter;
-
-});
diff --git a/addons/mail/static/src/js/composers/basic_composer.js b/addons/mail/static/src/js/composers/basic_composer.js
deleted file mode 100644
index ffde77eac9a..00000000000
--- a/addons/mail/static/src/js/composers/basic_composer.js
+++ /dev/null
@@ -1,864 +0,0 @@
-odoo.define('mail.composer.Basic', function (require) {
-"use strict";
-
-var emojis = require('mail.emojis');
-var MentionManager = require('mail.composer.MentionManager');
-var DocumentViewer = require('mail.DocumentViewer');
-var utils = require('web.utils');
-
-var config = require('web.config');
-var core = require('web.core');
-var data = require('web.data');
-var dom = require('web.dom');
-var session = require('web.session');
-var Widget = require('web.Widget');
-
-var QWeb = core.qweb;
-var _t = core._t;
-
-var BasicComposer = Widget.extend({
- template: "mail.Composer",
- events: {
- 'change input.o_input_file': '_onAttachmentChange',
- 'click .o_attachment_delete': '_onAttachmentDelete',
- 'click .o_attachment_download': '_onAttachmentDownload',
- 'click .o_attachment_view': '_onAttachmentView',
- 'click .o_composer_button_add_attachment': '_onClickAddAttachment',
- 'click .o_composer_button_emoji': '_onEmojiButtonClick',
- 'focusout .o_composer_button_emoji': '_onEmojiButtonFocusout',
- 'mousedown .o_mail_emoji_container .o_mail_emoji': '_onEmojiImageClick',
- 'focus .o_mail_emoji_container .o_mail_emoji': '_onEmojiImageFocus',
- 'dragover .o_file_drop_zone_container': '_onFileDragover',
- 'drop .o_file_drop_zone_container': '_onFileDrop',
- 'input .o_input': '_onInput',
- 'keydown .o_composer_input textarea': '_onKeydown',
- 'keyup .o_composer_input': '_onKeyup',
- 'click .o_composer_button_send': '_sendMessage',
- },
- // RPCs done to fetch the mention suggestions are throttled with the
- // following value
- MENTION_THROTTLE: 200,
-
- init: function (parent, options) {
- this._super.apply(this, arguments);
- this.options = _.defaults(options || {}, {
- commandsEnabled: true,
- context: {},
- inputMinHeight: 28,
- mentionFetchLimit: 8,
- // set to true to only suggest prefetched partners
- mentionPartnersRestricted: false,
- sendText: _t("Send"),
- defaultBody: '',
- defaultMentionSelections: {},
- isMobile: config.device.isMobile
- });
- this.context = this.options.context;
-
- // Attachments
- this._attachmentDataSet = new data.DataSetSearch(this, 'ir.attachment', this.context);
- this.fileuploadID = _.uniqueId('o_chat_fileupload');
- this.set('attachment_ids', options.attachmentIds || []);
-
- // Mention
- this._mentionManager = new MentionManager(this);
- this._mentionManager.register({
- delimiter: '@',
- fetchCallback: this._mentionFetchPartners.bind(this),
- generateLinks: true,
- model: 'res.partner',
- redirectClassname: 'o_mail_redirect',
- selection: this.options.defaultMentionSelections['@'],
- suggestionTemplate: 'mail.MentionSuggestions.Partner',
- });
- this._mentionManager.register({
- delimiter: '#',
- fetchCallback: this._mentionFetchChannels.bind(this),
- generateLinks: true,
- model: 'mail.channel',
- redirectClassname: 'o_channel_redirect',
- selection: this.options.defaultMentionSelections['#'],
- suggestionTemplate: 'mail.MentionSuggestions.Channel',
- });
- this._mentionManager.register({
- delimiter: ':',
- fetchCallback: this._mentionGetCannedResponses.bind(this),
- selection: this.options.defaultMentionSelections[':'],
- suggestionTemplate: 'mail.MentionSuggestions.CannedResponse',
- });
- if (this.options.commandsEnabled) {
- this._mentionManager.register({
- beginningOnly: true,
- delimiter: '/',
- fetchCallback: this._mentionGetCommands.bind(this),
- selection: this.options.defaultMentionSelections['/'],
- suggestionTemplate: 'mail.MentionSuggestions.Command',
- });
- }
-
- this.isMini = options.isMini;
-
- this.avatarURL = session.uid > 0 ? session.url('/web/image', {
- model: 'res.users',
- field: 'image_128',
- id: session.uid,
- }) : '/web/static/src/img/user_menu_avatar.png';
- },
- start: function () {
- var self = this;
-
- this._$attachmentButton = this.$('.o_composer_button_add_attachment');
- this._$attachmentsList = this.$('.o_composer_attachments_list');
- this.$input = this.$('.o_composer_input textarea');
- this.$input.focus(function () {
- self.trigger('input_focused');
- });
- this.$input.val(this.options.defaultBody);
- dom.autoresize(this.$input, {
- parent: this,
- min_height: this.options.inputMinHeight
- });
-
- // Attachments
- this._renderAttachments();
- $(window).on(this.fileuploadID, this._onAttachmentLoaded.bind(this));
- this.on('change:attachment_ids', this, this._renderAttachments);
-
- this.call('mail_service', 'getMailBus')
- .on('update_typing_partners', this, this._onUpdateTypingPartners);
-
- // Mention
- var prependPromise = this._mentionManager.prependTo(this.$('.o_composer'));
-
- // Drag-Drop files
- // Allowing body to detect dragenter and dragleave for display
- var $body = $('body');
- this._dropZoneNS = _.uniqueId('o_dz_'); // For event namespace used when multiple chat window is open
- $body.on('dragleave.' + this._dropZoneNS, this._onBodyFileDragLeave.bind(this));
- $body.on("dragover." + this._dropZoneNS, this._onBodyFileDragover.bind(this));
- $body.on("drop." + this._dropZoneNS, this._onBodyFileDrop.bind(this));
- return Promise.all([this._super(), prependPromise]);
- },
- destroy: function () {
- $("body").off('dragleave.' + this._dropZoneNS);
- $("body").off('dragover.' + this._dropZoneNS);
- $("body").off('drop.' + this._dropZoneNS);
- $(window).off(this.fileuploadID);
- return this._super.apply(this, arguments);
- },
-
- //--------------------------------------------------------------------------
- // Public
- //--------------------------------------------------------------------------
-
- /**
- * Empty input, selected partners and attachments
- */
- clearComposer: function () {
- this.$input.val('');
- this._mentionManager.resetSelections();
- this.set('attachment_ids', []);
- },
- /**
- * Set the focus on the input
- */
- focus: function () {
- this.$input.focus();
- },
- /**
- * Get cursor position and selection
- *
- * @returns {Object} a current cursor position as { start: {integer}, end: {integer} }
- */
- getSelectionPositions: function () {
- var InputElement = this.$input.get(0);
- return InputElement ? dom.getSelectionRange(InputElement) : { start: 0, end: 0 };
- },
- /**
- * Get the state of the composer.
- *
- * This is useful in order to (re)store its state when switchng to another
- * thread with a composer, and coming back to the thread with this composer.
- *
- * @returns {Object}
- */
- getState: function () {
- return {
- attachments: this.get('attachment_ids'),
- text: this.$input.val(),
- };
- },
- /**
- * @returns {integer} it returns length of attachment and trim content of
- * input box to check for empty.
- */
- isEmpty: function () {
- return !this.$input.val().trim() &&
- !this.$('.o_attachments').children().length;
- },
- /**
- * Set the state of the composer.
- *
- * This is useful in order to (re)store its state when switching to another
- * thread with a composer, and coming back to the thread with this composer.
- *
- * @param {Object} state
- * @param {Array} state.attachments
- * @param {string} state.text
- */
- setState: function (state) {
- this.set('attachment_ids', state.attachments);
- this.$input.val(state.text);
- },
- /**
- * Set the thread that this composer refers to.
- *
- * @param {mail.model.Thread} thread
- */
- setThread: function (thread) {
- this.options.thread = thread;
- },
- /**
- * Set the list of command suggestions on the thread.
- *
- * When the user partially types a command, matching commands are displayed
- * to the user (@see _mentionGetCommands).
- *
- * @param {Object[]} commands
- */
- mentionSetCommands: function (commands) {
- this._mentionCommands = commands;
- },
- /**
- * Set the list of partner mentions suggestions that has been prefetched.
- *
- * When the user partially types a mention, matching prefetched partners are
- * displayed to the user. If none of them match, then it will fetch for more
- * partner suggestions (@see _mentionFetchPartners).
- *
- * @param {Promise<Object[]>} prefetchedPartners list of list of
- * prefetched partners.
- */
- mentionSetPrefetchedPartners: function (prefetchedPartners) {
- this._mentionPrefetchedPartners = prefetchedPartners;
- },
- /**
- * @returns {Object} containing listener selections, grouped by delimiter
- * as key of the object.
- */
- getMentionListenerSelections: function () {
- return this._mentionManager.getListenerSelections();
- },
-
- //--------------------------------------------------------------------------
- // Private
- //--------------------------------------------------------------------------
-
- /**
- * @private
- */
- _clearComposerOnSend: function () {
- this.clearComposer();
- },
- /**
- * @private
- */
- _doCheckAttachmentUpload: function () {
- if (_.find(this.get('attachment_ids'), function (file) { return file.upload; })) {
- this.do_warn(false, _t("Please wait while the file is uploading..."));
- return false;
- }
- return true;
- },
- /**
- * Hides the emojis container.
- *
- * @private
- */
- _hideEmojis: function () {
- this._$emojisContainer.remove();
- },
- /**
- * Making sure that dragging content is external files.
- * Ignoring other content draging like text.
- *
- * @private
- * @param {DataTransfer} dataTransfer
- * @returns {boolean}
- */
- _isDragSourceExternalFile: function (dataTransfer) {
- var DragDataType = dataTransfer.types;
- if (DragDataType.constructor === DOMStringList) {
- return DragDataType.contains('Files');
- }
- if (DragDataType.constructor === Array) {
- return DragDataType.indexOf('Files') !== -1;
- }
- return false;
- },
- /**
- * @private
- * @param {string} search
- * @returns {Promise<Object[]>}
- */
- _mentionGetCannedResponses: function (search) {
- var self = this;
- var def = new Promise(function (resolve, reject) {
- clearTimeout(self._cannedTimeout);
- self._cannedTimeout = setTimeout(function () {
- var cannedResponses = self.call('mail_service', 'getCannedResponses');
- var matches = fuzzy.filter(utils.unaccent(search), _.pluck(cannedResponses, 'source'));
- var indexes = _.pluck(matches.slice(0, self.options.mentionFetchLimit), 'index');
- resolve(_.map(indexes, function (index) {
- return cannedResponses[index];
- }));
- }, 500);
- });
- return def;
- },
- /**
- * @private
- * @param {string} search
- * @returns {Array}
- */
- _mentionGetCommands: function (search) {
- var searchRegexp = new RegExp(_.str.escapeRegExp(utils.unaccent(search)), 'i');
- return _.filter(this._mentionCommands, function (command) {
- return searchRegexp.test(command.name);
- }).slice(0, this.options.mentionFetchLimit);
- },
- /**
- * @private
- * @param {string} search
- */
- _mentionFetchChannels: function (search) {
- return this._mentionFetchThrottled('mail.channel', 'get_mention_suggestions', {
- limit: this.options.mentionFetchLimit,
- search: search,
- }).then(function (suggestions) {
- return _.partition(suggestions, function (suggestion) {
- return _.contains(['public', 'groups'], suggestion.public);
- });
- });
- },
- /**
- * @private
- * @param {string} search
- */
- _mentionFetchPartners: function (search) {
- var self = this;
- return Promise.resolve(this._mentionPrefetchedPartners).then(function (prefetchedPartners) {
- // filter prefetched partners with the given search string
- var suggestions = [];
- var limit = self.options.mentionFetchLimit;
- var searchRegexp = new RegExp(_.str.escapeRegExp(utils.unaccent(search)), 'i');
- _.each(prefetchedPartners, function (partners) {
- if (limit > 0) {
- var filteredPartners = _.filter(partners, function (partner) {
- return partner.email && searchRegexp.test(partner.email) ||
- partner.name && searchRegexp.test(utils.unaccent(partner.name));
- });
- if (filteredPartners.length) {
- suggestions.push(filteredPartners.slice(0, limit));
- limit -= filteredPartners.length;
- }
- }
- });
- if (!suggestions.length && !self.options.mentionPartnersRestricted) {
- // no result found among prefetched partners, fetch other suggestions
- suggestions = self._mentionFetchThrottled(
- 'res.partner',
- 'get_mention_suggestions',
- { limit: limit, search: search }
- );
- }
- return Promise.resolve(suggestions).then(function (suggestions) {
- //add im_status on suggestions
- _.each(suggestions, function (suggestionsSet) {
- _.each(suggestionsSet, function (suggestion) {
- suggestion.im_status = self.call('mail_service', 'getImStatus', { partnerID: suggestion.id });
- });
- });
- return suggestions;
- });
- });
- },
- /**
- * @private
- * @param {string} model
- * @param {string} method
- * @param {Object} kwargs
- * @return {Promise}
- */
- _mentionFetchThrottled: function (model, method, kwargs) {
- var self = this;
- // Delays the execution of the RPC to prevent unnecessary RPCs when the user is still typing
- return new Promise(function (resolve, reject) {
- clearTimeout(self.mentionFetchTimer);
- self.mentionFetchTimer = setTimeout(function () {
- return self._rpc({model: model, method: method, kwargs: kwargs})
- .then(function (results) {
- resolve(results);
- });
- }, self.MENTION_THROTTLE);
- });
- },
- /**
- * @private
- * @returns {Promise}
- */
- _preprocessMessage: function () {
- // Return a promise as this function is extended with asynchronous
- // behavior for the chatter composer
-
- //Removing unwanted extra spaces from message
- var value = _.escape(this.$input.val()).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');
- var commands = this.options.commandsEnabled ?
- this._mentionManager.getListenerSelection('/') :
- [];
- return Promise.resolve({
- content: this._mentionManager.generateLinks(value),
- attachment_ids: _.pluck(this.get('attachment_ids'), 'id'),
- partner_ids: _.uniq(_.pluck(this._mentionManager.getListenerSelection('@'), 'id')),
- canned_response_ids: _.uniq(_.pluck(this._mentionManager.getListenerSelections()[':'], 'id')),
- channel_ids: _.uniq(_.pluck(this._mentionManager.getListenerSelection('#'), 'id')),
- command: commands.length > 0 ? commands[0].name : undefined,
- });
- },
- /**
- * Allowing to upload attachment with file selector as well drag drop feature.
- *
- * @private
- * @param {Array<File>} params.files
- * @param {boolean} params.submitForm [optional]
- */
- _processAttachmentChange: function (params) {
- var self = this;
- var attachments = this.get('attachment_ids');
- var files = params.files;
- var submitForm = params.submitForm;
- var $form = this.$('form.o_form_binary_form');
-
- /**
- * makes a new formData as formData.delete() is not supported by IE or Safari Mobile.
- *
- * @return {FormData}
- */
- function makeFormDataWithoutUfile() {
- var newFormData = new window.FormData();
- $form.find('input').each(function (index, input) {
- if (input.name !== 'ufile') {
- newFormData.append(input.name, input.value);
- }
- });
- return newFormData;
- }
-
- _.each(files, function (file) {
- var attachment = _.findWhere(attachments, {
- name: file.name,
- size: file.size
- });
- // if the files already exits, delete the file before upload
- if (attachment) {
- self._attachmentDataSet.unlink([attachment.id]);
- attachments = _.without(attachments, attachment);
- }
- });
- if (submitForm) {
- $form.submit();
- this._$attachmentButton.prop('disabled', true);
- } else {
- _.each(files, function (file) {
- var formData = makeFormDataWithoutUfile();
- formData.append("ufile", file, file.name);
- $.ajax({
- url: $form.attr("action"),
- type: "POST",
- enctype: 'multipart/form-data',
- processData: false,
- contentType: false,
- data: formData,
- success: function (result) {
- var $el = $(result);
- $.globalEval($el.contents().text());
- }
- });
- });
- }
- var uploadAttachments = _.map(files, function (file){
- return {
- id: 0,
- name: file.name,
- filename: file.name,
- url: '',
- upload: true,
- mimetype: '',
- };
- });
- attachments = attachments.concat(uploadAttachments);
- this.set('attachment_ids', attachments);
- },
- /**
- * @private
- */
- _renderAttachments: function () {
- this._$attachmentsList.html(QWeb.render('mail.composer.Attachments', {
- attachments: this.get('attachment_ids'),
- }));
- },
- /**
- * @private
- */
- _sendMessage: function () {
- if (this.isEmpty() || !this._doCheckAttachmentUpload()) {
- return;
- }
-
- clearTimeout(this._cannedTimeout);
- var self = this;
- this._preprocessMessage().then(function (message) {
- self.trigger('post_message', message);
- self._clearComposerOnSend();
- self.$input.focus();
- });
- },
- /**
- * Send the message on ENTER, but go to new line on SHIFT+ENTER
- *
- * @private
- */
- _shouldSend: function (ev) {
- return !ev.shiftKey;
- },
-
- //--------------------------------------------------------------------------
- // Handlers
- //--------------------------------------------------------------------------
-
- /**
- * @private
- * @param {jQuery.Event} ev
- */
- _onAttachmentChange: function (ev) {
- this._processAttachmentChange({
- files: ev.currentTarget.files,
- submitForm: true
- });
- ev.target.value = "";
- },
- /**
- * @private
- * @param {MouseEvent} ev
- */
- _onAttachmentDelete: function (ev){
- ev.stopPropagation();
- var self = this;
- var attachmentID = $(ev.currentTarget).data('id');
- if (attachmentID) {
- var attachments = [];
- _.each(this.get('attachment_ids'), function (attachment){
- if (attachmentID !== attachment.id) {
- attachments.push(attachment);
- } else {
- self._attachmentDataSet.unlink([attachmentID]);
- }
- });
- this.set('attachment_ids', attachments);
- this.$('input.o_input_file').val('');
- }
- },
- /**
- * @private
- * @param {MouseEvent} ev
- */
- _onAttachmentDownload: function (ev) {
- ev.stopPropagation();
- },
- /**
- * @private
- */
- _onAttachmentLoaded: function () {
- var attachments = this.get('attachment_ids');
- var files = Array.prototype.slice.call(arguments, 1);
-
- _.each(files, function (file) {
- if (file.error || !file.id){
- this.do_warn(file.error);
- attachments = _.filter(attachments, function (attachment) {
- return !attachment.upload;
- });
- } else {
- var attachment = _.findWhere(attachments, { filename: file.filename, upload: true });
- if (attachment) {
- attachments = _.without(attachments, attachment);
- attachments.push({
- id: file.id,
- name: file.name || file.filename,
- filename: file.filename,
- mimetype: file.mimetype,
- size: file.size,
- url: session.url('/web/content', { id: file.id, download: true }),
- });
- }
- }
- }.bind(this));
- this.set('attachment_ids', attachments);
- this._$attachmentButton.prop('disabled', false);
- },
- /**
- * @private
- * @param {MouseEvent} ev
- */
- _onAttachmentView: function (ev) {
- var activeAttachmentID = $(ev.currentTarget).data('id');
- var attachments = this.get('attachment_ids');
- if (activeAttachmentID) {
- var attachmentViewer = new DocumentViewer(this, attachments, activeAttachmentID);
- attachmentViewer.appendTo($('body'));
- }
- },
- /**
- * @private
- * @param {MouseEvent} ev
- */
- _onBodyFileDragLeave: function (ev) {
- // On every dragenter chain created with parent child element
- // That's why dragleave is fired every time when a child elemnt is hovered
- // so here we hide dropzone based on mouse position
- if (ev.originalEvent.clientX <= 0
- || ev.originalEvent.clientY <= 0
- || ev.originalEvent.clientX >= window.innerWidth
- || ev.originalEvent.clientY >= window.innerHeight
- ) {
- this.$(".o_file_drop_zone_container").addClass("d-none");
- }
- },
- /**
- * When user start dragging on element drop area will be visible to drop selected files.
- *
- * @private
- * @param {MouseEvent} ev
- */
- _onBodyFileDragover: function (ev) {
- ev.preventDefault();
- if (this._isDragSourceExternalFile(ev.originalEvent.dataTransfer)) {
- this.$(".o_file_drop_zone_container").removeClass("d-none");
- }
- },
- /**
- * @private
- * @param {MouseEvent} ev
- */
- _onBodyFileDrop: function (ev) {
- ev.preventDefault();
- this.$(".o_file_drop_zone_container").addClass("d-none");
- },
- /**
- * @private
- */
- _onClickAddAttachment: function () {
- this.$('input.o_input_file').click();
- this.$input.focus();
- },
- /**
- * Called when the emoji button is clicked -> opens/hides the emoji panel.
- * Also, this method is in charge of the rendering of this panel the first
- * time it is opened.
- *
- * @private
- */
- _onEmojiButtonClick: function () {
- if (!this._$emojisContainer) { // lazy rendering
- this._$emojisContainer = $(QWeb.render('mail.Composer.emojis', {
- emojis: emojis,
- }));
- }
- if (this._$emojisContainer.parent().length) {
- this._hideEmojis();
- } else {
- this._$emojisContainer.appendTo(this.$('.o_composer'));
- }
- },
- /**
- * Called when the emoji button is blurred -> closes the emoji panel. The
- * closing is scheduled to be done at the end of the current execution
- * stack to allow stoping this closing if the button was focusout to select
- * an emoji (for example).
- *
- * @private
- */
- _onEmojiButtonFocusout: function () {
- if (this._$emojisContainer) {
- this._hideEmojisTimeout = setTimeout(this._hideEmojis.bind(this), 0);
- }
- },
- /**
- * Called when an emoji is clicked from the emoji panel.
- * Emoji is inserted in the composer based on the position of the cursor,
- * and it automatically focuses the composer and set the cursor position
- * just after the newly inserted emoji.
- *
- * @private
- * @param {Event} ev
- */
- _onEmojiImageClick: function (ev) {
- ev.preventDefault();
- var cursorPosition = this.getSelectionPositions();
- var inputVal = this.$input.val();
- var leftSubstring = inputVal.substring(0, cursorPosition.start);
- var rightSubstring = inputVal.substring(cursorPosition.end);
- var newInputVal = [leftSubstring , $(ev.currentTarget).data('emoji'), rightSubstring].join(" ");
- var newCursorPosition = newInputVal.length - rightSubstring.length;
- this.$input.val(newInputVal);
- this.$input.focus();
- this.$input[0].setSelectionRange(newCursorPosition, newCursorPosition);
- this._hideEmojis();
- },
- /**
- * Called when an emoji is focused -> @see _onEmojiButtonFocusout
- *
- * @private
- */
- _onEmojiImageFocus: function () {
- clearTimeout(this._hideEmojisTimeout);
- },
- /**
- * Setting drop Effect to copy so when mouse pointer on dropzone
- * cursor icon changed to copy ('+')
- *
- * @private
- * @param {MouseEvent} ev
- */
- _onFileDragover: function (ev) {
- ev.originalEvent.dataTransfer.dropEffect = "copy";
- },
- /**
- * Called when user drop selected files on drop area
- *
- * @private
- * @param {MouseEvent} ev
- */
- _onFileDrop: function (ev) {
- ev.preventDefault();
- // FIX: In case multiple chat windows are opened, and file droped in one of them
- // at that time, other chat windows are still displaing drop areas so here hide them all with $ selector
- $(".o_file_drop_zone_container").addClass("d-none");
- if (this._isDragSourceExternalFile(ev.originalEvent.dataTransfer)) {
- var files = ev.originalEvent.dataTransfer.files;
- this._processAttachmentChange({ files: files });
- }
- },
- /**
- * Called when the input in the composer changes
- *
- * @private
- */
- _onInput: function () {
- if (this.options.thread && this.options.thread.hasTypingNotification()) {
- var isTyping = this.$input.val().length > 0;
- this.options.thread.setMyselfTyping({ typing: isTyping });
- }
- },
- /**
- * _onKeydown event is triggered when is key is pressed
- * - on UP and DOWN arrow is pressed then event prevents it's default
- * behaviour if mention manager is open else it break it.
- * - on ENTER key is pressed and mention manager is open then event
- * prevents it's default behaviour else check if ControlKey is
- * pressed or not if yes then it send a message
- *
- * @private
- * @param {KeyboardEvent} ev
- */
- _onKeydown: function (ev) {
- switch (ev.which) {
- // UP, DOWN, TAB: prevent moving cursor if navigation in mention propositions
- case $.ui.keyCode.UP:
- case $.ui.keyCode.DOWN:
- case $.ui.keyCode.TAB:
- if (this._mentionManager.isOpen()) {
- ev.preventDefault();
- }
- break;
- // ENTER: submit the message only if the dropdown mention proposition is not displayed
- case $.ui.keyCode.ENTER:
- if (this._mentionManager.isOpen()) {
- ev.preventDefault();
- } else {
- var sendMessage = ev.ctrlKey || this._shouldSend(ev);
- if (sendMessage) {
- ev.preventDefault();
- this._sendMessage();
- }
- }
- break;
- }
- },
- /**
- * _onKeyup event is triggered when key is released.
- * on ESCAP key it close the mention suggestion dropdown menu.
- * on ENTER key it selects that mention and add to a content editable div.
- * on UP or DOWN key it highlights previous or next mention respectively.
- *
- * @private
- * @param {KeyboardEvent} ev
- */
- _onKeyup: function (ev) {
- switch (ev.which) {
- // ESCAPED KEYS: do nothing
- case $.ui.keyCode.END:
- case $.ui.keyCode.PAGE_UP:
- case $.ui.keyCode.PAGE_DOWN:
- break;
- // ESCAPE: close mention propositions
- case $.ui.keyCode.ESCAPE:
- if (this._mentionManager.isOpen()) {
- ev.stopPropagation();
- this._mentionManager.resetSuggestions();
- } else {
- this.trigger_up('escape_pressed');
- }
- break;
- // ENTER, UP, DOWN, TAB: check if navigation in mention propositions
- case $.ui.keyCode.ENTER:
- case $.ui.keyCode.UP:
- case $.ui.keyCode.DOWN:
- case $.ui.keyCode.TAB:
- if (this._mentionManager.isOpen()) {
- this._mentionManager.propositionNavigation(ev.which);
- }
- break;
- // Otherwise, check if a mention is typed
- default:
- this._mentionManager.detectDelimiter();
- }
- },
- /**
- * @private
- * @param {integer|string} threadID
- */
- _onUpdateTypingPartners: function (threadID) {
- if (!this.options.showTyping) {
- return;
- }
- if (!this.options.thread) {
- return;
- }
- if (this.options.thread.getID() !== threadID) {
- return;
- }
- this.$('.o_composer_thread_typing').html(QWeb.render('mail.Composer.ThreadTyping', {
- thread: this.options.thread,
- }));
- },
-});
-
-return BasicComposer;
-
-});
diff --git a/addons/mail/static/src/js/composers/chatter_composer.js b/addons/mail/static/src/js/composers/chatter_composer.js
deleted file mode 100644
index 509212019c9..00000000000
--- a/addons/mail/static/src/js/composers/chatter_composer.js
+++ /dev/null
@@ -1,331 +0,0 @@
-odoo.define('mail.composer.Chatter', function (require) {
-"use strict";
-
-var BasicComposer = require('mail.composer.Basic');
-var mailUtils = require('mail.utils');
-var session = require('web.session');
-
-var core = require('web.core');
-var viewDialogs = require('web.view_dialogs');
-
-var _t = core._t;
-const Qweb = core.qweb;
-
-/**
- * Chat Composer for the Chatter
- *
- * Extends the basic Composer Widget to add 'suggested partner' layer (open
- * popup when suggested partner is selected without email, or other
- * informations), and the button to open the full composer wizard.
- */
-var ChatterComposer = BasicComposer.extend({
- template: 'mail.chatter.Composer',
- events: _.extend({}, BasicComposer.prototype.events, {
- 'click .o_composer_button_full_composer': '_onOpenFullComposer',
- 'click .o_suggested_show_more_less': '_onClickPartnerSuggestionsReadMoreLess',
- }),
- init: function (parent, model, suggestedPartners, options) {
- this._super(parent, options);
- this._model = model;
- this.suggestedPartners = suggestedPartners;
- this.options = _.defaults(this.options, {
- display_mode: 'textarea',
- recordName: false,
- isLog: false,
- });
- if (this.options.isLog) {
- this.options.sendText = _t("Log");
- }
- this.notInline = true;
- },
-
- //--------------------------------------------------------------------------
- // Public
- //--------------------------------------------------------------------------
-
- /**
- * Chatter don't clear message on sent but after successful sent
- *
- * @override
- */
- _clearComposerOnSend: function () {},
- /**
- * Updates suggested partners in DOM
- *
- * @param {Array} suggestedPartners list of 'recipient' partners
- */
- updateSuggestedPartners(suggestedPartners) {
- this.suggestedPartners = suggestedPartners;
- if (!this.options.isLog) {
- const $suggestedPartners = $(Qweb.render('mail.chatter.ComposerSuggestedPartners', {
- widget: this,
- }));
- this.$('.o_composer_suggested_partners').replaceWith($suggestedPartners);
- }
- },
-
- //--------------------------------------------------------------------------
- // Private
- //--------------------------------------------------------------------------
-
- /**
- * Check the additional partners (not necessary registered partners), and
- * open a popup form view for the ones who informations is missing.
- *
- * @private
- * @param {Array} checkedSuggestedPartners list of 'recipient' partners to
- * complete informations or validate
- * @returns {Promise} resolved with the list of checked suggested partners
- * (real partner)
- **/
- _checkSuggestedPartners: function (checkedSuggestedPartners) {
- var self = this;
- var checkDone = new Promise(function (resolve, reject) {
- var recipients = _.filter(checkedSuggestedPartners, function (recipient) {
- return recipient.checked;
- });
- var recipientsToFind = _.filter(recipients, function (recipient) {
- return (! recipient.partner_id);
- });
- var namesToFind = _.pluck(recipientsToFind, 'full_name');
- var recipientsToCheck = _.filter(recipients, function (recipient) {
- return (recipient.partner_id && ! recipient.email_address);
- });
- var recipientIDs = _.pluck(_.filter(recipients, function (recipient) {
- return recipient.partner_id && recipient.email_address;
- }), 'partner_id');
-
- var namesToRemove = [];
- var recipientIDsToRemove = [];
-
- // have unknown names
- // -> call message_get_partner_info_from_emails to try to find
- // partner_id
- var def;
- if (namesToFind.length > 0) {
- def = self._rpc({
- route: '/mail/get_partner_info',
- params: {
- model: self._model,
- res_ids: [self.context.default_res_id],
- emails: namesToFind,
- },
- });
- }
-
- // for unknown names + incomplete partners
- // -> open popup - cancel = remove from recipients
- Promise.resolve(def).then(function (result) {
- result = result || [];
- var emailDefs = [];
- var recipientPopups = result.concat(recipientsToCheck);
-
- _.each(recipientPopups, function (partnerInfo) {
- var prom = new Promise(function(innerResolve, innerReject) {
- var partnerName = partnerInfo.full_name;
- var partnerID = partnerInfo.partner_id;
- var parsedEmail = mailUtils.parseEmail(partnerName);
-
- var dialog = new viewDialogs.FormViewDialog(self, {
- res_model: 'res.partner',
- res_id: partnerID,
- context: {
- active_model: self._model,
- active_id: self.context.default_res_id,
- force_email: true,
- ref: 'compound_context',
- default_name: parsedEmail[0],
- default_email: parsedEmail[1],
- },
- title: _t("Please complete customer's informations"),
- disable_multiple_selection: true,
- }).open();
- dialog.on('closed', self, function () {
- innerResolve();
- });
- dialog.opened().then(function () {
- dialog.form_view.on('on_button_cancel', self, function () {
- namesToRemove.push(partnerName);
- if (partnerID) {
- recipientIDsToRemove.push(partnerID);
- }
- });
- });
- });
-
- emailDefs.push(prom);
-
- });
- Promise.all(emailDefs).then(function () {
- var newNamesToFind = _.difference(namesToFind, namesToRemove);
- var def;
- if (newNamesToFind.length > 0) {
- def = self._rpc({
- route: '/mail/get_partner_info',
- params: {
- model: self._model,
- res_ids: [self.context.default_res_id],
- emails: namesToFind,
- link_mail: true,
- },
- });
- }
- Promise.resolve(def).then(function (result) {
- result = result || [];
- var recipientPopups = result.concat(recipientsToCheck);
- _.each(recipientPopups, function (partnerInfo) {
- if (
- partnerInfo.partner_id &&
- _.indexOf(partnerInfo.partner_id, recipientIDsToRemove) === -1
- ) {
- recipientIDs.push(partnerInfo.partner_id);
- }
- });
- }).then(function () {
- resolve(recipientIDs);
- });
- });
- });
- });
-
- return checkDone;
- },
- /**
- * Get the list of selected suggested partners
- *
- * @private
- * @returns {Array} list of 'recipient' selected partners (may not be
- * created in db)
- **/
- _getCheckedSuggestedPartners: function () {
- var self = this;
- var checkedPartners = [];
- this.$('.o_composer_suggested_partners input:checked').each(function () {
- var fullName = $(this).data('fullname');
- checkedPartners = checkedPartners.concat(
- _.filter(self.suggestedPartners, function (item) {
- return fullName === item.full_name;
- })
- );
- });
- return checkedPartners;
- },
- /**
- * @override
- * @private
- * @returns {Promise}
- */
- _preprocessMessage: function () {
- var self = this;
- return new Promise(function (resolve, reject) {
- self._super().then(function (message) {
- message = _.extend(message, {
- subtype_xmlid: 'mail.mt_comment',
- message_type: 'comment',
- context: _.defaults({}, self.context, session.user_context),
- });
-
- // Subtype
- if (self.options.isLog) {
- message.subtype_xmlid = 'mail.mt_note';
- }
-
- // Partner_ids
- if (!self.options.isLog) {
- var checkedSuggestedPartners = self._getCheckedSuggestedPartners();
- self._checkSuggestedPartners(checkedSuggestedPartners).then(function (partnerIDs) {
- message.partner_ids = (message.partner_ids || []).concat(partnerIDs);
- // update context
- message.context = _.defaults({}, message.context, {
- mail_post_autofollow: true,
- });
- if (partnerIDs.length) {
- self.trigger_up('reset_suggested_partners');
- }
- resolve(message);
- });
- } else {
- resolve(message);
- }
-
- });
- });
- },
- /**
- * @override
- * @private
- */
- _shouldSend: function () {
- return false;
- },
-
- //--------------------------------------------------------------------------
- // Handlers
- //--------------------------------------------------------------------------
-
- /**
- * @private
- * @param {MouseEvent} ev
- */
- _onClickPartnerSuggestionsReadMoreLess: function (ev) {
- ev.preventDefault();
- this.$('.o_suggested_toggle').toggleClass('o_hidden');
- },
- /**
- * @private
- */
- _onOpenFullComposer: function () {
- if (!this._doCheckAttachmentUpload()){
- return false;
- }
-
- var self = this;
- var recipientDoneDef = new Promise(function (resolve, reject) {
- // any operation on the full-composer will reload the record, so
- // warn the user that any unsaved changes on the record will be lost.
- self.trigger_up('discard_record_changes', {
- proceed: function () {
- if (self.options.isLog) {
- resolve([]);
- } else {
- var checkedSuggestedPartners = self._getCheckedSuggestedPartners();
- self._checkSuggestedPartners(checkedSuggestedPartners)
- .then(resolve);
- }
- },
- });
- });
-
- recipientDoneDef.then(function (partnerIDs) {
- var context = {
- default_parent_id: self.id,
- default_body: mailUtils.getTextToHTML(self.$input.val()),
- default_attachment_ids: _.pluck(self.get('attachment_ids'), 'id'),
- default_partner_ids: partnerIDs,
- default_is_log: self.options.isLog,
- mail_post_autofollow: true,
- };
-
- if (self.context.default_model && self.context.default_res_id) {
- context.default_model = self.context.default_model;
- context.default_res_id = self.context.default_res_id;
- }
-
- var action = {
- type: 'ir.actions.act_window',
- res_model: 'mail.compose.message',
- view_mode: 'form',
- views: [[false, 'form']],
- target: 'new',
- context: context,
- };
- self.do_action(action, {
- on_close: self.trigger.bind(self, 'need_refresh'),
- }).then(self.trigger.bind(self, 'close_composer'));
- });
- }
-});
-
-return ChatterComposer;
-
-});
diff --git a/addons/mail/static/src/js/composers/extended_composer.js b/addons/mail/static/src/js/composers/extended_composer.js
deleted file mode 100644
index 1acfba1058c..00000000000
--- a/addons/mail/static/src/js/composers/extended_composer.js
+++ /dev/null
@@ -1,138 +0,0 @@
-odoo.define('mail.composer.Extended', function (require) {
-"use strict";
-
-var BasicComposer = require('mail.composer.Basic');
-
-var core = require('web.core');
-
-var QWeb = core.qweb;
-
-var ExtendedComposer = BasicComposer.extend({
- events: _.extend({}, BasicComposer.prototype.events, {
- 'click .o_composer_button_discard': '_onClickDiscard',
- }),
- /**
- * @override
- */
- init: function (parent, options) {
- options = _.defaults(options || {}, {
- inputMinHeight: 120,
- });
- this._super(parent, options);
- this.extended = true;
- },
- /**
- * @override
- */
- start: function () {
- this._$subjectInput = this.$('.o_composer_subject input');
- return this._super.apply(this, arguments);
- },
-
- //--------------------------------------------------------------------------
- // Public
- //--------------------------------------------------------------------------
-
- /**
- * @override
- */
- clearComposer: function () {
- this._super.apply(this, arguments);
- this._$subjectInput.val('');
- },
- /**
- * @override
- * @param {string} target
- */
- focus: function (target) {
- if (target === 'body') {
- this.$input.focus();
- } else {
- this._$subjectInput.focus();
- }
- },
- /**
- * @override
- */
- getState: function () {
- var state = this._super.apply(this, arguments);
- state.subject = this._$subjectInput.val();
- return state;
- },
- /**
- * @override
- * @param {Object} state
- * @param {string} state.subject
- */
- setState: function (state) {
- this._super.apply(this, arguments);
- this.setSubject(state.subject);
- },
- /**
- * @param {string} subject
- */
- setSubject: function (subject) {
- this.$('.o_composer_subject input').val(subject);
- },
- /**
- * Show the button 'discard' next to the send button. This is useful while
- * replying to a message, when in the end we do not want to reply.
- */
- showDiscardButton: function () {
- var $discard = this.$('.o_composer_button_discard');
- $discard.removeClass('d-none');
- $discard.addClass('d-md-inline-block');
- },
- /**
- * Hide the button 'discard' next to the send button. This is useful when
- * we were replying to a message from a certain thread, then we discard it
- * with some other actions (i.e. switch thread). The discard button only
- * makes sense during the selection of a message.
- */
- hideDiscardButton: function () {
- var $discard = this.$('.o_composer_button_discard');
- $discard.removeClass('d-md-inline-block');
- $discard.addClass('d-none');
- },
-
- //--------------------------------------------------------------------------
- // Private
- //--------------------------------------------------------------------------
-
- /**
- * @override
- * @private
- * @returns {Promise}
- */
- _preprocessMessage: function () {
- var self = this;
- return this._super().then(function (message) {
- var subject = self._$subjectInput.val();
- self._$subjectInput.val("");
- message.subject = subject;
- return message;
- });
- },
- /**
- * @override
- * @private
- */
- _shouldSend: function () {
- return false;
- },
-
- //--------------------------------------------------------------------------
- // Handlers
- //--------------------------------------------------------------------------
-
- /**
- * @private
- */
- _onClickDiscard: function () {
- this.trigger_up('discard_extended_composer');
- },
-});
-
-return ExtendedComposer;
-
-});
diff --git a/addons/mail/static/src/js/composers/mention_manager.js b/addons/mail/static/src/js/composers/mention_manager.js
deleted file mode 100644
index 8aa8b251343..00000000000
--- a/addons/mail/static/src/js/composers/mention_manager.js
+++ /dev/null
@@ -1,439 +0,0 @@
-odoo.define('mail.composer.MentionManager', function (require) {
-"use strict";
-
-var core = require('web.core');
-var dom = require('web.dom');
-var session = require('web.session');
-var Widget = require('web.Widget');
-
-var QWeb = core.qweb;
-
-var NON_BREAKING_SPACE = '\u00a0';
-
-// The MentionManager allows the Composer to register listeners. For each
-// listener, it detects if the user is currently typing a mention (starting by a
-// given delimiter). If so, if fetches mention suggestions and renders them. On
-// suggestion clicked, it updates the selection for the corresponding listener.
-var MentionManager = Widget.extend({
- className: 'dropup o_composer_mention_dropdown',
- events: {
- 'click .o_mention_proposition': '_onClickMentionItem',
- 'mouseover .o_mention_proposition': '_onHoverMentionProposition',
- },
- init: function (parent) {
- this._super.apply(this, arguments);
-
- this._composer = parent;
-
- this._open = false;
- this._listeners = [];
- this.set('mention_suggestions', []);
- this.on('change:mention_suggestions', this, this._renderSuggestions);
- },
-
- //--------------------------------------------------------------------------
- // Public
- //--------------------------------------------------------------------------
-
- /**
- * Returns true if the mention suggestions dropdown is open, false otherwise
- *
- * @returns {boolean}
- */
- isOpen: function () {
- return this._open;
- },
- /**
- * Returns the mentions of the given listener that haven't been erased from
- * the composer's input
- *
- * @returns {Array}
- */
- getListenerSelection: function (delimiter) {
- var listener = _.findWhere(this._listeners, { delimiter: delimiter });
- if (listener) {
- var escapedVal = _.escape(this._composer.$input.val());
- var inputMentions = escapedVal.match(new RegExp(delimiter+'[^ ]+(?= |&nbsp;|$)', 'g'));
- return this._validateSelection(listener.selection, inputMentions);
- }
- return [];
- },
- /**
- * @returns {Object} containing listener selections, grouped by delimiter
- * as key of the object.
- */
- getListenerSelections: function () {
- var selections = {};
- _.each(this._listeners, function (listener) {
- selections[listener.delimiter] = listener.selection;
- });
- return selections;
- },
- /**
- * Detects if the user is currently typing a mention word
- */
- detectDelimiter: function () {
- var self = this;
- var textVal = this._composer.$input.val();
- var cursorPosition = this._getComposerSelectionPositions().start;
- var leftString = textVal.substring(0, cursorPosition);
-
- function validateKeyword(delimiter, beginningOnly) {
- // use position before delimiter because there should be whitespaces
- // or line feed/carriage return before the delimiter
- var beforeDelimiterPosition = leftString.lastIndexOf(delimiter) - 1;
- if (beginningOnly && beforeDelimiterPosition > 0) {
- return false;
- }
- var searchStr = textVal.substring(beforeDelimiterPosition, cursorPosition);
- // regex string start with delimiter or whitespace then delimiter
- var pattern = "^"+delimiter+"|^\\s"+delimiter;
- var 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.indexOf(' ') < 0 && !/[\r\n]/.test(searchStr) ?
- searchStr.replace(delimiter, '') :
- false;
- }
- return false;
- }
-
- this._activeListener = undefined;
- for (var i = 0; i < this._listeners.length; i++) {
- var listener = this._listeners[i];
- this._mentionWord = validateKeyword(listener.delimiter, listener.beginningOnly);
-
- if (this._mentionWord !== false) {
- this._activeListener = listener;
- break;
- }
- }
-
- if (this._activeListener) {
- var mentionWord = this._mentionWord;
- Promise.resolve(this._activeListener.fetchCallback(mentionWord))
- .then(function (suggestions) {
- if (mentionWord === self._mentionWord) {
- // update suggestions only if mentionWord didn't change
- // in the meantime
- self.set('mention_suggestions', suggestions);
- }
- });
- } else {
- this.set('mention_suggestions', []); // close the dropdown
- }
- },
- /**
- * Replaces mentions appearing in the string 's' by html links with proper
- * redirection
- *
- * @param {string} s
- * @returns {string}
- */
- generateLinks: function (s) {
- var self = this;
- var baseHREF = session.url('/web');
- var linkParams = "href='%s' class='%s' data-oe-id='%s' data-oe-model='%s' target='_blank'";
- var mentionLink = "<a " + linkParams + " >" +
- "%s%s" +
- "</a>";
- _.each(this._listeners, function (listener) {
- if (!listener.generateLinks) {
- return;
- }
- var selection = listener.selection;
- if (selection.length) {
- var matches = self._getMatch(s, listener);
- var substrings = [];
- var startIndex = 0;
- for (var i = 0; i < matches.length; i++) {
- var match = matches[i];
- var endIndex = match.index + match[0].length;
- var selectionID = self._getSelectionID(match, selection);
- // put back white spaces instead of non-breaking spaces in
- // mention's name
- var matchName = match[0].substring(1)
- .replace(new RegExp(NON_BREAKING_SPACE, 'g'), ' ');
- var href = baseHREF +
- _.str.sprintf("#model=%s&id=%s", listener.model, selectionID);
- var processedText = _.str.sprintf(mentionLink,
- href,
- listener.redirectClassname,
- selectionID,
- listener.model,
- listener.delimiter,
- matchName);
- substrings.push(s.substring(startIndex, match.index));
- substrings.push(processedText);
- startIndex = endIndex;
- }
- substrings.push(s.substring(startIndex, s.length));
- s = substrings.join('');
- }
- });
- return s;
- },
- /**
- * @param {integer} keycode
- */
- propositionNavigation: function (keycode) {
- var $active = this.$('.o_mention_proposition.active');
- if (keycode === $.ui.keyCode.ENTER) {
- // selecting proposition
- $active.click();
- } else {
- // navigation in propositions
- var $to;
- if (keycode === $.ui.keyCode.DOWN) {
- $to = $active.nextAll('.o_mention_proposition').first();
- } else if (keycode === $.ui.keyCode.UP) {
- $to = $active.prevAll('.o_mention_proposition').first();
- } else if (keycode === $.ui.keyCode.TAB) {
- $to = $active.nextAll('.o_mention_proposition').first();
- if (!$to.length) {
- $to = $active.prevAll('.o_mention_proposition').last();
- }
- }
- if ($to && $to.length) {
- $active.removeClass('active');
- $to.addClass('active');
- }
- }
- },
- /**
- * Registers a new listener, described by an object containing the following
- * keys
- *
- * @param {Object} listener
- * @param {boolean} [listener.beginningOnly] true to enable autocomplete
- * only at first position of input
- * @param {char} [listener.delimiter] the mention delimiter
- * @param {function} [listener.fetchCallback] the callback to fetch mention
- * suggestions
- * @param {boolean} [listener.generateLinks] true to wrap mentions in <a>
- * links
- * @param {string} [listener.model=''] (optional) the model used for
- * redirection
- * @param {string} [listener.redirectClassname=''] (optional) the classname
- * of the <a> wrapping the mention
- * @param {Array} [listener.selection=[]] (optional) initial mentions for
- * each listener
- * @param {string} [listener.suggestionTemplate] the template of
- * suggestions' dropdown
- */
- register: function (listener) {
- this._listeners.push(_.defaults(listener, {
- model: '',
- redirectClassname: '',
- selection: [],
- }));
- },
- resetSuggestions: function () {
- this.set('mention_suggestions', []);
- },
- resetSelections: function () {
- _.each(this._listeners, function (listener) {
- listener.selection = [];
- });
- },
-
- //--------------------------------------------------------------------------
- // Private
- //--------------------------------------------------------------------------
-
- /**
- * Returns the matches (as RexExp.exec does) for the mention in the input
- * text
- *
- * @private
- * @param {string} inputText: the text to search matches
- * @param {Object} listener: the listener for which we want to find a match
- * @returns {Object[]} matches in the same format as RexExp.exec()
- */
- _getMatch: function (inputText, listener) {
- // create the regex of all mention's names
- var names = _.pluck(listener.selection, 'name');
- var escapedNames = _.map(names, function (str) {
- return "("+_.str.escapeRegExp(listener.delimiter+str)+")(?= |&nbsp;|$)";
- });
- var regexStr = escapedNames.join('|');
- // extract matches
- var result = [];
- if (regexStr.length) {
- var myRegexp = new RegExp(regexStr, 'g');
- var match = myRegexp.exec(inputText);
- while (match !== null) {
- result.push(match);
- match = myRegexp.exec(inputText);
- }
- }
- return result;
- },
- /**
- * @private
- * @param {string[]} match
- * @param {Object} selection
- * @returns {Object}
- */
- _getSelectionID: function (match, selection) {
- return _.findWhere(selection, { name: match[0].slice(1) }).id;
- },
- /**
- * Get cursor position and selection
- *
- * @private
- * @returns a current cursor position
- */
- _getComposerSelectionPositions: function () {
- return this._composer.getSelectionPositions();
- },
- /**
- * @private
- */
- _renderSuggestions: function () {
- var suggestions = [];
- if (_.find(this.get('mention_suggestions'), _.isArray)) {
- // Array of arrays -> Flatten and insert dividers between groups
- var insertDivider = false;
- _.each(this.get('mention_suggestions'), function (suggestionGroup) {
- if (suggestionGroup.length > 0) {
- if (insertDivider) {
- suggestions.push({ divider: true });
- }
- suggestions = suggestions.concat(suggestionGroup);
- insertDivider = true;
- }
- });
- } else {
- suggestions = this.get('mention_suggestions');
- }
- if (suggestions.length) {
- this.$el.html(QWeb.render(this._activeListener.suggestionTemplate, {
- suggestions: _.map(suggestions, function (suggestion) {
- // mention manager stores escaped suggestion names
- return _.extend({}, suggestion, { name: _.unescape(suggestion.name) });
- })
- }));
- this.$el
- .addClass('show')
- .find('.dropdown-menu')
- .addClass('show')
- .css('max-width', this._composer.$input.width())
- .find('.o_mention_proposition')
- .first()
- .addClass('active');
- this._open = true;
- } else {
- this.$el.removeClass('show')
- .find('.dropdown-menu')
- .removeClass('show');
- this.$el.empty();
- this._open = false;
- }
- },
- /*
- * Set cursor position
- *
- * @private
- * @param {integer} pos
- */
- _setCursorPosition: function (pos) {
- this._composer.$input.each(function (index, elem) {
- dom.setSelectionRange(elem, { start: pos, end: pos });
- });
- },
- /**
- * @private
- * @param {Object} selection
- * @param {Array} inputMentions
- * @returns {Array}
- */
- _validateSelection: function (selection, inputMentions) {
- var validatedSelection = [];
- _.each(inputMentions, function (mention) {
- var validatedMention = _.findWhere(selection, { name: mention.slice(1) });
- if (validatedMention) {
- validatedSelection.push(validatedMention);
- }
- });
- return validatedSelection;
- },
-
- //--------------------------------------------------------------------------
- // Handlers
- //--------------------------------------------------------------------------
-
- /**
- * @private
- * @param {KeyboardEvent} ev - to get a current selected mention from list
- * and eventlistener.
- */
- _onClickMentionItem: function (ev) {
- ev.preventDefault();
-
- var textInput = this._composer.$input.val();
- var id = $(ev.currentTarget).data('id');
- var suggestions = _.flatten(this.get('mention_suggestions'));
- var selectedSuggestion = _.clone(_.find(suggestions, function (s) {
- return s.id === id;
- }));
- var substitution = selectedSuggestion.substitution;
- if (!substitution) {
- // no substitution string given, so use the mention name instead
- // replace white spaces with non-breaking spaces to facilitate
- // mentions detection in text.
- // mention manager stores escaped suggestion names
- selectedSuggestion.name = _.unescape(selectedSuggestion.name.replace(/ /g, NON_BREAKING_SPACE));
- substitution = this._activeListener.delimiter + selectedSuggestion.name;
- }
- var getMentionIndex = function (matches, cursorPosition) {
- for (var i = 0; i < matches.length; i++) {
- if (cursorPosition <= matches[i].index) {
- return i;
- }
- }
- return i;
- };
-
- // add the selected suggestion to the list
- if (this._activeListener.selection.length) {
- // get mention matches (ordered by index in the text)
- var matches = this._getMatch(textInput, this._activeListener);
- var index = getMentionIndex(matches, this._getComposerSelectionPositions().start);
- this._activeListener.selection.splice(index, 0, selectedSuggestion);
- } else {
- this._activeListener.selection.push(selectedSuggestion);
- }
-
- // update input text, and reset dropdown
- var cursorPosition = this._getComposerSelectionPositions().start;
- var textLeft = textInput.substring(0, cursorPosition-(this._mentionWord.length+1));
- var textRight = textInput.substring(cursorPosition, textInput.length);
- var textInputNew = textLeft + substitution + ' ' + textRight;
- this._composer.$input.val(textInputNew);
- this._setCursorPosition(textLeft.length+substitution.length+2);
- this.set('mention_suggestions', []);
- this._composer.focus('body'); // to trigger autoresize
- // suggestion after inserting will be used with escaped content
- if (selectedSuggestion.name) {
- selectedSuggestion.name = _.escape(selectedSuggestion.name);
- }
- },
- /**
- * @private
- * @param {MouseEvent} ev
- */
- _onHoverMentionProposition: function (ev) {
- var $elem = $(ev.currentTarget);
- this.$('.o_mention_proposition').removeClass('active');
- $elem.addClass('active');
- },
-
-});
-
-return MentionManager;
-
-});
diff --git a/addons/mail/static/src/js/discuss.js b/addons/mail/static/src/js/discuss.js
deleted file mode 100644
index 5e17ce86d67..00000000000
--- a/addons/mail/static/src/js/discuss.js
+++ /dev/null
@@ -1,1642 +0,0 @@
-odoo.define('mail.Discuss', function (require) {
-"use strict";
-
-var BasicComposer = require('mail.composer.Basic');
-var ExtendedComposer = require('mail.composer.Extended');
-var ThreadWidget = require('mail.widget.Thread');
-
-var AbstractAction = require('web.AbstractAction');
-var config = require('web.config');
-var core = require('web.core');
-var Dialog = require('web.Dialog');
-var dom = require('web.dom');
-
-var QWeb = core.qweb;
-var _t = core._t;
-
-/**
- * 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.
- */
-var PartnerInviteDialog = Dialog.extend({
- dialog_title: _t("Invite people"),
- template: 'mail.PartnerInviteDialog',
-
- /**
- * @override
- * @param {integer|string} channelID id of the channel,
- * a string for static channels (e.g. 'mailbox_inbox').
- */
- init: function (parent, title, channelID) {
- this._channelID = channelID;
-
- this._super(parent, {
- title: title,
- size: 'medium',
- buttons: [{
- text: _t("Invite"),
- close: true,
- classes: 'btn-primary',
- click: this._addChannel.bind(this),
- }],
- });
- },
- /**
- * @override
- */
- start: function () {
- var self = this;
- this.$input = this.$('.o_mail_discuss_partner_invite_input');
- this.$input.select2({
- width: '100%',
- allowClear: true,
- multiple: true,
- formatResult: function (item) {
- var status = QWeb.render('mail.UserStatus', {
- status: self.call('mail_service', 'getImStatus', { partnerID: item.id }),
- partnerID: item.id,
- });
- return $('<span>').text(item.text).prepend(status);
- },
- query: function (query) {
- self.call('mail_service', 'searchPartner', query.term, 20)
- .then(function (partners) {
- query.callback({
- results: _.map(partners, function (partner) {
- return _.extend(partner, { text: partner.label });
- }),
- });
- });
- }
- });
- return this._super.apply(this, arguments);
- },
-
- //--------------------------------------------------------------------------
- // Private
- //--------------------------------------------------------------------------
-
- /**
- * @private
- * @returns {Promise}
- */
- _addChannel: function () {
- var self = this;
- var data = this.$input.select2('data');
- if (data.length >= 1) {
- return this._rpc({
- model: 'mail.channel',
- method: 'channel_invite',
- args: [this._channelID],
- kwargs: {partner_ids: _.pluck(data, 'id')},
- }).then(function () {
- var names = _.escape(_.pluck(data, 'text').join(', '));
- var notification =
- _.str.sprintf(
- _t("You added <b>%s</b> to the conversation"),
- names
- );
- self.do_notify(false, notification);
- // Update list of members with the invited user, so that
- // we can mention this user in this channel right away.
- var channel = self.call('mail_service', 'getChannel', self._channelID);
- channel.forceFetchMembers();
- });
- }
- },
-});
-
-/**
- * Widget : Rename Conversation Dialog
- */
-var RenameConversationDialog = Dialog.extend({
- dialog_title: _t("Rename conversation"),
- template: 'mail.RenameConversationDialog',
- /**
- * @override
- * @param {integer|string} channelID id of the channel
- * @param {function} callback to call when successfully renaming
- * conversation.
- */
- init: function (parent, channelID, callback) {
- this._channelID = channelID;
- this._callback = callback;
-
- this._super(parent, {
- title: _t('Rename conversation'),
- size: 'medium',
- buttons: [{
- text: _t("Rename"),
- close: true,
- classes: 'btn-primary o_mail_conversation_rename',
- click: this._rename.bind(this),
- }, {
- text: _t("Discard"),
- close: true,
- }],
- });
- },
- /**
- * @override
- * @returns {$.Promise}
- */
- start: function () {
- var channel = this.call('mail_service', 'getChannel', this._channelID);
- this.$('input').val(channel.getName());
- return this._super.apply(this, arguments);
- },
-
- //--------------------------------------------------------------------------
- // Private
- //--------------------------------------------------------------------------
-
- /**
- * @private
- * @returns {$.Promise}
- */
- _rename: function () {
- var self = this;
- var name = this.$('input').val();
- return this._rpc({
- model: 'mail.channel',
- method: 'channel_set_custom_name',
- args: [this._channelID],
- kwargs: {
- name: name,
- }
- }).then(function () {
- var channel = self.call('mail_service', 'getThread', self._channelID);
- channel.setName(name);
- self._callback();
- });
- },
-});
-
-/**
- * Widget : Moderator reject message dialog
- *
- * Popup containing message title and reject message body.
- * This let the moderator provide a reason for rejecting the messages.
- */
-var ModeratorRejectMessageDialog = Dialog.extend({
- template: 'mail.ModeratorRejectMessageDialog',
-
- /**
- * @override
- * @param {web.Widget} parent
- * @param {Object} params
- * @param {integer[]} params.messageIDs list of message IDs to send
- * 'reject' decision reason
- * @param {function} params.proceedReject
- *
- * a function to call when the
- * moderator confirms the reason for rejecting the
- * messages. This function passes an object as the
- * reason for reject, which is structured as follow::
- *
- * {
- * title: <string>,
- * comment: <string>,
- * }
- */
- init: function (parent, params) {
- this._messageIDs = params.messageIDs;
- this._proceedReject = params.proceedReject;
- this._super(parent, {
- title: _t("Send explanation to author"),
- size: 'medium',
- buttons: [{
- text: _t("Send"),
- close: true,
- classes: 'btn-primary',
- click: _.bind(this._onSendClicked, this),
- }],
- });
- },
- //--------------------------------------------------------------------------
- // Handlers
- //--------------------------------------------------------------------------
-
- /**
- * Called when the moderator would like to submit reason for rejecting the
- * messages.
- *
- * @private
- */
- _onSendClicked: function () {
- var title = this.$('#message_title').val();
- var comment = this.$('#reject_message').val();
- if (title && comment) {
- this._proceedReject({
- title: title,
- comment: comment
- });
- }
- },
-});
-
-var Discuss = AbstractAction.extend({
- contentTemplate: 'mail.discuss',
- custom_events: _.extend({}, AbstractAction.prototype.custom_events, {
- discard_extended_composer: '_onDiscardExtendedComposer',
- message_moderation: '_onMessageModeration',
- update_moderation_buttons: '_onUpdateModerationButtons',
- }),
- events: {
- 'click .o_mail_sidebar_title .o_add': '_onAddThread',
- 'blur .o_mail_add_thread input': '_onAddThreadBlur',
- 'click .o_mail_channel_settings': '_onChannelSettingsClicked',
- 'click .o_mail_discuss_item': '_onDiscussItemClicked',
- 'keydown': '_onKeydown',
- 'click .o_mail_open_channels': '_onPublicChannelsClick',
- 'click .o_mail_partner_unpin': '_onUnpinChannel',
- 'input .o_discuss_sidebar_quick_search input': '_onInputSidebarQuickSearchInput',
- },
- hasControlPanel: true,
- loadControlPanel: true,
- withSearchBar: true,
- searchMenuTypes: ['filter', 'favorite'],
-
- /**
- * @override
- * @param {Object} [options]
- * @param {integer} [options.channelQuickSearchThreshold=20] amount of
- * channels (dm inluded) for which a quick search appears in the sidebar.
- */
- init: function (parent, action, options) {
- this._super.apply(this, arguments);
-
- this.action = action;
- this.context = action.context;
- this.action_manager = parent;
- this.domain = [];
- this.options = options || {};
-
- if (!('channelQuickSearchThreshold' in this.options)) {
- this.options.channelQuickSearchThreshold = 20;
- }
-
- this._isMessagingReady = this.call('mail_service', 'isReady');
- this._isStarted = false;
- this._threadsScrolltop = {};
- this._composerStates = {};
- this._defaultThreadID = this.options.active_id ||
- this.action.context.active_id ||
- this.action.params.default_active_id ||
- 'mailbox_inbox';
- this._selectedMessage = null;
- this._throttledUpdateThreads = _.throttle(
- this._updateThreads.bind(this), 100, { leading: false });
-
- this.controlPanelModelConfig.modelName = 'mail.message';
- this.call('mail_service', 'getMailBus').on('messaging_ready', this, this._onMessagingReady);
- },
- /**
- * @override
- */
- start: function () {
- var self = this;
- this._isStarted = true;
- return this._super.apply(this, arguments).then(function () {
- if (!self._isMessagingReady) {
- return;
- }
- return self._initRender();
- });
- },
- /**
- * @override
- */
- do_show: function () {
- this._super.apply(this, arguments);
- this.updateControlPanel();
- this.action_manager.do_push_state({
- action: this.action.id,
- active_id: this._thread.getID(),
- });
- },
- /**
- * @override
- */
- destroy: function () {
- if (this.$buttons) {
- this.$buttons.off().remove();
- }
- this._super.apply(this, arguments);
- },
- /**
- * @override
- */
- on_attach_callback: function () {
- this._super();
- this.call('mail_service', 'getMailBus').trigger('discuss_open', true);
- if (this._thread) {
- this._threadWidget.scrollToPosition(this._threadsScrolltop[this._thread.getID()]);
- this._loadEnoughMessages();
- }
- },
- /**
- * @override
- */
- on_detach_callback: function () {
- this._super();
- this.call('mail_service', 'getMailBus').trigger('discuss_open', false);
- if (this._thread) {
- this._threadsScrolltop[this._thread.getID()] = this._threadWidget.getScrolltop();
- }
- },
-
- //--------------------------------------------------------------------------
- // Private
- //--------------------------------------------------------------------------
-
- /**
- * Ask confirmation to the user to unsubscribe, due to the user being the
- * administrator of the channel.
- *
- * @private
- * @param {mail.model.Channel} channel a channel for which the current user
- * is administrator of.
- */
- _askConfirmationAdminUnsubscribe: function (channel) {
- Dialog.confirm(this,
- _t("You are the administrator of this channel. Are you sure you want to unsubscribe?"),
- {
- buttons: [{
- text: _t("Unsubscribe"),
- classes: 'btn-primary',
- close: true,
- click: function () {
- channel.unsubscribe();
- }
- }, {
- text: _t("Discard"),
- close: true,
- }]
- }
- );
- },
- /**
- * Ban the authors of the messages with ID in `messageIDs`
- * Show a confirmation dialog to the moderator.
- *
- * @private
- * @param {integer[]} messageIDs IDs of messages for which we should ban authors
- */
- _banAuthorsFromMessageIDs: function (messageIDs) {
- var self = this;
- var emailList = _.map(messageIDs, function (messageID) {
- return self.call('mail_service', 'getMessage', messageID).getEmailFrom();
- }).join(", ");
- var text = _.str.sprintf(
- _t("You are going to ban: %s. Do you confirm the action?"),
- emailList
- );
- var options = {
- confirm_callback: function () {
- self._moderateMessages(messageIDs, 'ban');
- }
- };
- Dialog.confirm(this, text, options);
- },
- /**
- * Discard the messages with ID in `messageIDs`
- * Show a confirmation dialog to the moderator.
- *
- * @private
- * @param {integer[]]} messageIDs list of message IDs to discard
- */
- _discardMessages: function (messageIDs) {
- var self = this;
- var num = messageIDs.length;
- var text;
- if (num > 1) {
- text = _.str.sprintf(
- _t("You are going to discard %s messages. Do you confirm the action?"),
- num
- );
- } else if (num === 1) {
- text = _t("You are going to discard 1 message. Do you confirm the action?");
- }
- var options = {
- confirm_callback: function () {
- self._moderateMessages(messageIDs, 'discard');
- }
- };
- Dialog.confirm(this, text, options);
- },
- /**
- * @private
- * @returns {Promise}
- */
- _fetchAndRenderThread: function () {
- var self = this;
- return this._thread.fetchMessages(this.domain)
- .then(function () {
- self._threadWidget.render(
- self._thread,
- self._getThreadRenderingOptions()
- );
- self._updateButtonStatus(!self._thread.hasMessages());
- return self._loadEnoughMessages();
- });
- },
- /**
- * @private
- * @returns {Object}
- */
- _getThreadRenderingOptions: function () {
- // Compute position of the 'New messages' separator, only once when
- // joining a channel to keep it in the thread when new messages arrive
- if (_.isUndefined(this.messagesSeparatorPosition)) {
- if (!this._unreadCounter) {
- // no unread message -> don't display separator
- this.messagesSeparatorPosition = false;
- } else {
- var messageID = this._thread.getLastSeenMessageID();
- this.messagesSeparatorPosition = messageID || 'top';
- }
- }
- var hasThreadMessages = this._thread.hasMessages({domain: this.domain});
- return {
- displayLoadMore: !this._thread.isAllHistoryLoaded(this.domain),
- displayMarkAsRead: this._thread.getID() === 'mailbox_inbox',
- domain: this.domain,
- messagesSeparatorPosition: this.messagesSeparatorPosition,
- squashCloseMessages: this._thread.getType() !== 'mailbox' &&
- !this._thread.isMassMailing(),
- displayEmptyThread: !hasThreadMessages && !this.domain.length,
- displayNoMatchFound: !hasThreadMessages && !!this.domain.length,
- displaySubjectOnMessages: this._thread.isMassMailing() ||
- this._thread.getID() === 'mailbox_inbox' ||
- this._thread.getID() === 'mailbox_moderation',
- displayNotificationIcons: false,
- displayReplyIcons: true,
- displayBottomThreadFreeSpace: true,
- displayModerationCommands: true,
- };
- },
- /**
- * Determine the action to apply on messages with ID in `messageIDs`
- * based on the moderation decision `decision`.
- *
- * @private
- * @param {number[]} messageIDs list of message ids that are moderated
- * @param {string} decision of the moderator, could be either 'reject',
- * 'discard', 'ban', 'accept', 'allow'.
- */
- _handleModerationDecision: function (messageIDs, decision) {
- if (messageIDs) {
- if (decision === 'reject') {
- this._rejectMessages(messageIDs);
- } else if (decision === 'discard') {
- this._discardMessages(messageIDs);
- } else if (decision === 'ban') {
- this._banAuthorsFromMessageIDs(messageIDs);
- } else {
- // other decisions do not need more information,
- // confirmation dialog, etc.
- this._moderateMessages(messageIDs, decision);
- }
- }
- },
- /**
- * @private
- */
- _initRender: function () {
- var self = this;
- this.$('.o_mail_discuss_loading').remove();
- this._basicComposer = new BasicComposer(this, {
- mentionPartnersRestricted: true,
- showTyping: true,
- });
- this._extendedComposer = new ExtendedComposer(this, {
- mentionPartnersRestricted: true,
- showTyping: true,
- });
- this._basicComposer
- .on('post_message', this, this._onPostMessage)
- .on('input_focused', this, this._onComposerFocused);
- this._extendedComposer
- .on('post_message', this, this._onPostMessage)
- .on('input_focused', this, this._onComposerFocused);
- this._renderButtons();
-
- var defs = [];
- defs.push(
- this._renderThread()
- );
- defs.push(
- this._basicComposer.appendTo(this.$('.o_mail_discuss_content')));
- defs.push(
- this._extendedComposer.appendTo(this.$('.o_mail_discuss_content')));
-
- return Promise.all(defs)
- .then(function () {
- return self._setThread(self._defaultThreadID);
- })
- .then(function () {
- self._initThreads();
- self._startListening();
- self._threadWidget.$el.on('scroll', null, _.debounce(function () {
- if (
- self._threadWidget.getScrolltop() < 20 &&
- !self._threadWidget.$('.o_mail_no_content').length &&
- !self._thread.isAllHistoryLoaded(self.domain)
- ) {
- self._loadMoreMessages();
- }
- if (
- self._threadWidget.isAtBottom() &&
- self._thread.getType() !== 'mailbox'
- ) {
- self._thread.markAsRead();
- }
- }, 100));
- });
- },
- /**
- * Renders the mainside bar with current threads
- *
- * @private
- */
- _initThreads: function () {
- var self = this;
- var $sidebar = this._renderSidebar();
- this.$('.o_mail_discuss_sidebar').html($sidebar.contents());
- _.each(['dm_chat', 'multi_user_channel'], function (type) {
- var $input = self.$('.o_mail_add_thread[data-type=' + type + '] input');
- self._prepareAddThreadInput($input, type);
- });
- },
- /**
- * Ensures that enough messages have been loaded to fill the entire screen
- * (this is particularily important because remaining messages can only be
- * loaded when scrolling to the top, so they can't be loaded if there is no
- * scrollbar)
- *
- * @returns {Promise} resolved when there are enough messages to fill the
- * screen, or when there is no more message to fetch
- */
- _loadEnoughMessages: function () {
- var loadMoreMessages =
- this._threadWidget.el.clientHeight &&
- (this._threadWidget.el.clientHeight === this._threadWidget.el.scrollHeight) &&
- !this._thread.isAllHistoryLoaded(this.domain);
- if (loadMoreMessages) {
- return this._loadMoreMessages().then(this._loadEnoughMessages.bind(this));
- }
- },
- /**
- * Load more messages for the current thread
- *
- * @private
- * @returns {Promise}
- */
- _loadMoreMessages: function () {
- var self = this;
- var oldestMessageID = this.$('.o_thread_message').first().data('messageId');
- var oldestMessageSelector = '.o_thread_message[data-message-id="' + oldestMessageID + '"]';
- var offset = -dom.getPosition(document.querySelector(oldestMessageSelector)).top;
- return this._thread.fetchMessages(this.domain, true)
- .then(function () {
- if (self.messagesSeparatorPosition === 'top') {
- // reset value to re-compute separator position
- self.messagesSeparatorPosition = undefined;
- }
- self._threadWidget.render(
- self._thread,
- self._getThreadRenderingOptions()
- );
- offset += dom.getPosition(document.querySelector(oldestMessageSelector)).top;
- self._threadWidget.scrollToPosition(offset);
- });
- },
- /**
- * Apply the moderation decision `decision` on the messages with ID in
- * `messageIDs`.
- *
- * @private
- * @param {integer[]} messageIDs list of message IDs to apply the
- * moderation decision.
- * @param {string} decision the moderation decision to apply on the
- * messages. Could be either 'reject', 'discard', 'ban', 'accept',
- * or 'allow'.
- * @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]
- * @return {undefined|$.Promise}
- */
- _moderateMessages: function (messageIDs, decision, kwargs) {
- if (messageIDs.length && decision) {
- return this._rpc({
- model: 'mail.message',
- method: 'moderate',
- args: [messageIDs, decision],
- kwargs: kwargs
- });
- }
- },
- /**
- * Binds handlers on the given $input to make them autocomplete and/or
- * create threads.
- *
- * @private
- * @param {JQuery} $input the input to prepare
- * @param {string} type the type of thread to create ('dm_chat',
- * 'multi_user_channel')
- */
- _prepareAddThreadInput: function ($input, type) {
- var self = this;
- if (type === 'multi_user_channel') {
- $input.autocomplete({
- autoFocus: true,
- source: function (request, response) {
- self._lastSearchVal = _.escape(request.term);
- self._searchChannel(self._lastSearchVal).then(function (result){
- result.push({
- label: _.str.sprintf(
- '<strong>' + _t("Create %s (Public)") + '</strong>',
- '<em>"#' + self._lastSearchVal + '"</em>'
- ),
- value: self._lastSearchVal,
- special: 'public',
- }, {
- label: _.str.sprintf(
- '<strong>' + _t("Create %s (Private)") + '</strong>',
- '<em>"#' + self._lastSearchVal + '"</em>'
- ),
- value: self._lastSearchVal,
- special: 'private',
- });
- response(result);
- });
- },
- select: function (ev, ui) {
- if (self._lastSearchVal) {
- if (ui.item.special) {
- if (ui.item.special === 'public') {
- self.call('mail_service', 'createChannel', self._lastSearchVal, 'public');
- } else {
- self.call('mail_service', 'createChannel', self._lastSearchVal, 'private');
- }
- } else {
- self.call( 'mail_service', 'joinChannel', ui.item.id);
- }
- }
- },
- focus: function (ev) {
- ev.preventDefault();
- },
- html: true,
- });
- } else if (type === 'dm_chat') {
- $input.autocomplete({
- autoFocus: true,
- source: function (request, response) {
- self._lastSearchVal = _.escape(request.term);
- self.call('mail_service', 'searchPartner', self._lastSearchVal, 10).then(response);
- },
- select: function (ev, ui) {
- var partnerID = ui.item.id;
- var dmChat = self.call('mail_service', 'getDMChatFromPartnerID', partnerID);
- if (dmChat) {
- self._setThread(dmChat.getID());
- } else {
- self.call('mail_service', 'createChannel', partnerID, 'dm_chat');
- }
- // clear the input
- $(this).val('');
- return false;
- },
- focus: function (ev) {
- ev.preventDefault();
- },
- });
- }
- },
- /**
- * Reject the messages
- *
- * The moderator must provide a reason for reject, and may also
- * cancel his action.
- *
- * @private
- * @param {number[]} messageIDs list of message IDs to reject
- */
- _rejectMessages: function (messageIDs) {
- var self = this;
- new ModeratorRejectMessageDialog(this, {
- messageIDs: messageIDs,
- proceedReject: function (reason) {
- self._moderateMessages(messageIDs, 'reject', reason);
- }
- }).open();
- },
- /**
- * @private
- */
- _renderButtons: function () {
- this.$buttons = $(QWeb.render('mail.discuss.ControlButtons', { debug: config.isDebug() }));
- this.$buttons.find('button').css({display:'inline-block'});
- this.$buttons
- .on('click', '.o_mail_discuss_button_invite', this._onInviteButtonClicked.bind(this))
- .on('click', '.o_mail_discuss_button_mark_all_read', this._onMarkAllAsReadClicked.bind(this))
- .on('click', '.o_mail_discuss_button_unstar_all', this._onUnstarAllClicked.bind(this))
- .on('click', '.o_mail_discuss_button_moderate_all', this._onModerateAllClicked.bind(this))
- .on('click', '.o_mail_discuss_button_select_all', this._onSelectAllClicked.bind(this))
- .on('click', '.o_mail_discuss_button_unselect_all', this._onUnselectAllClicked.bind(this));
- },
- /**
- * Render the sidebar of discuss app
- *
- * @private
- * @returns {jQueryElement}
- */
- _renderSidebar: function () {
- var channels = this.call('mail_service', 'getChannels');
- channels = this._sortChannels(channels);
- var $sidebar = $(QWeb.render('mail.discuss.Sidebar', {
- activeThreadID: this._thread ? this._thread.getID() : undefined,
- inbox: this.call('mail_service', 'getMailbox', 'inbox'),
- starred: this.call('mail_service', 'getMailbox', 'starred'),
- moderation: this.call('mail_service', 'getMailbox', 'moderation'),
- channels: channels,
- isMyselfModerator: this.call('mail_service', 'isMyselfModerator'),
- displayQuickSearch: channels.length >= this.options.channelQuickSearchThreshold,
- options: this.options,
- }));
- return $sidebar;
- },
- /**
- * @private
- * @param {Object} options
- * @param {string} [options.searchChannelVal='']
- */
- _renderSidebarChannels: function (options) {
- options.searchChannelVal = options.searchChannelVal || '';
- var channels = this.call('mail_service', 'getChannels');
- var searchChannelValLowerCase = options.searchChannelVal.toLowerCase();
- channels = _.filter(channels, function (channel) {
- var channelNameLowerCase = channel.getName().toLowerCase();
- return channelNameLowerCase.indexOf(searchChannelValLowerCase) !== -1;
- });
- channels = this._sortChannels(channels);
- this.$('.o_mail_discuss_sidebar_channels').html(
- QWeb.render('mail.discuss.SidebarChannels', {
- activeThreadID: this._thread ? this._thread.getID() : undefined,
- channels: channels,
- displayQuickSearch: channels.length >= this.options.channelQuickSearchThreshold,
- })
- );
- },
- /**
- * @private
- */
- _renderSidebarMailboxes: function () {
- this.$('.o_mail_discuss_sidebar_mailboxes').html(
- QWeb.render('mail.discuss.SidebarMailboxes', {
- activeThreadID: this._thread ? this._thread.getID() : undefined,
- inbox: this.call('mail_service', 'getMailbox', 'inbox'),
- starred: this.call('mail_service', 'getMailbox', 'starred'),
- moderation: this.call('mail_service', 'getMailbox', 'moderation'),
- isMyselfModerator: this.call('mail_service', 'isMyselfModerator'),
- })
- );
- },
- /**
- * @private
- * @param {string} template
- * @param {Object} context rendering context
- * @param {integer} [timeout=20000] the delay before the snackbar disappears
- */
- _renderSnackbar: function (template, context, timeout) {
- if (this.$snackbar) {
- this.$snackbar.remove();
- }
- timeout = timeout || 20000;
- this.$snackbar = $(QWeb.render(template, context));
- this.$('.o_mail_discuss_content').append(this.$snackbar);
- // Hide snackbar after [timeout] milliseconds (by default, 20s)
- var $snackbar = this.$snackbar;
- setTimeout(function () { $snackbar.fadeOut(); }, timeout);
- },
- /**
- * Renders, binds events and appends a thread widget.
- *
- * @private
- * @returns {Promise}
- */
- _renderThread: function () {
- this._threadWidget = new ThreadWidget(this, {
- loadMoreOnScroll: true
- });
-
- this._threadWidget
- .on('redirect', this, function (resModel, resID) {
- this.call('mail_service', 'redirect', resModel, resID, this._setThread.bind(this));
- })
- .on('redirect_to_channel', this, function (channelID) {
- this.call('mail_service', 'joinChannel', channelID).then(this._setThread.bind(this));
- })
- .on('load_more_messages', this, this._loadMoreMessages)
- .on('mark_as_read', this, function (messageID) {
- this.call('mail_service', 'markMessagesAsRead', [messageID]);
- })
- .on('toggle_star_status', this, function (messageID) {
- var message = this.call('mail_service', 'getMessage', messageID);
- message.toggleStarStatus();
- })
- .on('select_message', this, this._selectMessage)
- .on('unselect_message', this, this._unselectMessage);
-
- return this._threadWidget.appendTo(this.$('.o_mail_discuss_content'));
- },
- /**
- * @private
- * @param {mail.model.Channel} channel
- */
- _restoreComposerState: function (channel) {
- var composer = channel.isMassMailing() ? this._extendedComposer : this._basicComposer;
- var composerState = this._composerStates[channel.getUUID()];
- if (composerState) {
- composer.setState(composerState);
- }
- },
- /**
- * Restores the scroll position and composer state of the current thread
- *
- * @private
- */
- _restoreThreadState: function () {
- var $newMessagesSeparator = this.$('.o_thread_new_messages_separator');
- if ($newMessagesSeparator.length) {
- this._threadWidget.$el.scrollTo($newMessagesSeparator);
- } else {
- var newThreadScrolltop = this._threadsScrolltop[this._thread.getID()];
- this._threadWidget.scrollToPosition(newThreadScrolltop);
- }
- if (this._thread.getType() !== 'mailbox') {
- this._restoreComposerState(this._thread);
- }
- },
- /**
- * @private
- * @param {string} searchVal
- * @returns {Promise<Array>}
- */
- _searchChannel: function (searchVal){
- return this._rpc({
- model: 'mail.channel',
- method: 'channel_search_to_join',
- args: [searchVal]
- })
- .then(function (result){
- var values = [];
- _.each(result, function (channel){
- var escapedName = _.escape(channel.name);
- values.push(_.extend(channel, {
- 'value': escapedName,
- 'label': escapedName,
- }));
- });
- return values;
- });
- },
- /**
- * @private
- * @param {integer} messageID
- */
- _selectMessage: function (messageID) {
- this.$el.addClass('o_mail_selection_mode');
- var message = this.call('mail_service', 'getMessage', messageID);
- this._selectedMessage = message;
- var subject = "Re: " + message.getDocumentName();
- this._extendedComposer.setSubject(subject);
- this._extendedComposer.showDiscardButton();
-
- if (this._thread.getType() !== 'mailbox') {
- this._basicComposer.do_hide();
- }
- this._extendedComposer.do_show();
-
- this._threadWidget.scrollToMessage({
- messageID: messageID,
- duration: 200,
- onlyIfNecessary: true
- });
- this._extendedComposer.focus('body');
- },
- /**
- * Set the selected thread with ID `threadID`.
- * If there is no thread with such ID, fallback on mailbox 'inbox'.
- *
- * @private
- * @param {integer} threadID a thread with such ID
- * @returns {Promise}
- */
- _setThread: async function (threadID) {
- this.$('.o_out_of_office').remove();
- // Store scroll position and composer state of the previous thread
- this._storeThreadState();
-
- this._thread = this.call('mail_service', 'getThread', threadID) ||
- this.call('mail_service', 'getMailbox', 'inbox');
-
- // reset value on channel change
- this.messagesSeparatorPosition = undefined;
- this._unreadCounter = this._thread.getUnreadCounter();
- if (this.$snackbar) {
- this.$snackbar.remove();
- }
-
- this.action.context.active_id = this._thread.getID();
- this.action.context.active_ids = [this._thread.getID()];
-
- this._basicComposer.setThread(this._thread);
- this._extendedComposer.setThread(this._thread);
-
- await this._fetchAndRenderThread();
- // Mark thread's messages as read and clear needactions
- if (this._thread.getType() !== 'mailbox') {
- this._thread.markAsRead();
- }
- // Restore scroll position and composer of the new current thread
- this._restoreThreadState();
-
- // Update control panel before focusing the composer, otherwise
- // focus is on the searchview
- await this.updateControlPanel({
- title: '#' + this._thread.getName(),
- cp_content: {
- $buttons: this.$buttons,
- },
- });
- this._updateControlPanelButtons(this._thread);
-
- // Display and focus the adequate composer, and unselect possibly
- // selected message to prevent sending messages as reply to that
- // message
- this._unselectMessage();
-
- if (this._thread.hasOutOfOffice()) {
- const $outOfOffice = $(QWeb.render('mail.thread.OutOfOffice', {
- thread: this._thread
- }));
- $outOfOffice.prependTo(this.$('.o_mail_discuss_content'));
- }
-
- this.action_manager.do_push_state({
- action: this.action.id,
- active_id: this._thread.getID(),
- });
- },
- /**
- * @private
- * @param {mail.model.Channel[]} channels
- * @returns {mail.model.Channel[]}
- */
- _sortChannels: function (channels) {
- return channels;
- },
- /**
- * Binds handlers on mail bus events
- *
- * @private
- */
- _startListening: function () {
- this.call('mail_service', 'getMailBus')
- .on('open_thread_in_discuss', this, this._onOpenThreadInDiscuss)
- .on('new_message', this, this._onNewMessage)
- .on('update_message', this, this._onMessageUpdated)
- .on('new_channel', this, this._onNewChannel)
- .on('is_thread_bottom_visible', this, this._onIsThreadBottomVisible)
- .on('unsubscribe_from_channel', this, this._onChannelLeft)
- .on('updated_im_status', this, this._onUpdatedImStatus)
- .on('update_needaction', this, this._onUpdateNeedaction)
- .on('update_starred', this, this._onUpdateStarred)
- .on('update_thread_unread_counter', this, this._onUpdateThreadUnreadCounter)
- .on('activity_updated', this, this._onActivityUpdated)
- .on('update_moderation_counter', this, this._onUpdateModerationCounter)
- .on('update_typing_partners', this, this._onTypingPartnersUpdated)
- .on('update_channel', this, this._onUpdateChannel);
- },
- /**
- * @private
- * @param {mail.model.Channel} channel
- */
- _storeComposerState: function (channel) {
- var composer = channel.isMassMailing() ? this._extendedComposer : this._basicComposer;
- this._composerStates[channel.getUUID()] = composer.getState();
- composer.clearComposer();
- },
- /**
- * Stores the scroll position of the current thread.
- * For channels, also stores composer state.
- *
- * @private
- */
- _storeThreadState: function () {
- if (this._thread) {
- this._threadsScrolltop[this._thread.getID()] = this._threadWidget.getScrolltop();
- if (this._thread.getType() !== 'mailbox') {
- this._storeComposerState(this._thread);
- }
- }
- },
- /**
- * @private
- */
- _unselectMessage: function () {
- this._basicComposer.do_toggle(this._thread.getType() !== 'mailbox' && !this._thread.isMassMailing());
- this._extendedComposer.do_toggle(this._thread.isMassMailing());
- this._extendedComposer.hideDiscardButton();
-
- if (!config.device.isMobile) {
- var composer = this._thread.getType() !== 'mailbox' && this._thread.isMassMailing() ?
- this._extendedComposer :
- this._basicComposer;
- composer.focus();
- }
- this.$el.removeClass('o_mail_selection_mode');
- this._threadWidget.unselectMessage();
- this._selectedMessage = null;
- },
- /**
- * @private
- * @param {boolean} disabled
- * @param {string} type
- */
- _updateButtonStatus: function (disabled, type) {
- if (this._thread.getID() === 'mailbox_inbox') {
- this.$buttons
- .find('.o_mail_discuss_button_mark_all_read')
- .toggleClass('disabled', disabled);
- // Display Rainbowman when all inbox messages are read through
- // 'MARK ALL READ' or marking last inbox message as read
- if (disabled && type === 'mark_as_read') {
- this.trigger_up('show_effect', {
- message: _t("Congratulations, your inbox is empty!"),
- type: 'rainbow_man',
- });
- }
- }
- if (this._thread.getID() === 'mailbox_starred') {
- this.$buttons
- .find('.o_mail_discuss_button_unstar_all')
- .toggleClass('disabled', disabled);
- }
- if (
- this._thread.getID() === 'mailbox_moderation' ||
- (
- this._thread.isModerated() &&
- this._thread.isMyselfModerator()
- )
- ) {
- this._updateModerationButtons();
- }
- },
- /**
- * Updates the control panel buttons visibility based on thread type
- *
- * @private
- * @param {mail.model.Thread} thread
- */
- _updateControlPanelButtons: function (thread) {
- // Hide 'unsubscribe' button in state channels and DM chats and channels with group-based subscription
- // Invite
- if (thread.getType() !== 'dm_chat' && thread.getType() !== 'mailbox') {
- this.$buttons
- .find('.o_mail_discuss_button_invite, .o_mail_discuss_button_settings')
- .removeClass('d-none d-md-inline-block')
- .addClass('d-none d-md-inline-block');
- } else {
- this.$buttons
- .find('.o_mail_discuss_button_invite, .o_mail_discuss_button_settings')
- .removeClass('d-none d-md-inline-block')
- .addClass('d-none');
- }
- // Mark All Read
- if (thread.getID() === 'mailbox_inbox') {
- this.$buttons
- .find('.o_mail_discuss_button_mark_all_read')
- .removeClass('d-none d-md-inline-block')
- .addClass('d-none d-md-inline-block');
- } else {
- this.$buttons
- .find('.o_mail_discuss_button_mark_all_read')
- .removeClass('d-none d-md-inline-block')
- .addClass('d-none');
- }
- // Unstar All
- if (thread.getID() === 'mailbox_starred') {
- this.$buttons
- .find('.o_mail_discuss_button_unstar_all')
- .removeClass('d-none');
- } else {
- this.$buttons
- .find('.o_mail_discuss_button_unstar_all')
- .addClass('d-none');
- }
- // Select All & Unselect All
- if (
- (thread.isModerated() && thread.isMyselfModerator()) ||
- thread.getID() === 'mailbox_moderation'
- ) {
- this.$buttons
- .find('.o_mail_discuss_button_select_all')
- .removeClass('d-none');
- this.$buttons
- .find('.o_mail_discuss_button_unselect_all')
- .removeClass('d-none');
- } else {
- this.$buttons
- .find('.o_mail_discuss_button_select_all')
- .addClass('d-none');
- this.$buttons
- .find('.o_mail_discuss_button_unselect_all')
- .addClass('d-none');
- }
- this.$buttons.find('.o_mail_discuss_button_moderate_all').addClass('d-none');
-
- this.$('.o_mail_discuss_item')
- .removeClass('o_active')
- .filter('[data-thread-id=' + thread.getID() + ']')
- .removeClass('o_unread_message')
- .addClass('o_active');
- },
- /**
- * Update the moderation buttons.
- *
- * @private
- */
- _updateModerationButtons: function () {
- this._updateSelectUnselectAllButtons();
- this._updateModerationDecisionButton();
- },
- /**
- * Display/hide the "moderate all" button based on whether
- * some moderation checkboxes are checked or not.
- * If some checkboxes are checked, display this button,
- * otherwise hide it.
- *
- * @private
- */
- _updateModerationDecisionButton: function () {
- if (this._threadWidget.$('.moderation_checkbox:checked').length) {
- this.$buttons.find('.o_mail_discuss_button_moderate_all').removeClass('d-none');
- } else {
- this.$buttons.find('.o_mail_discuss_button_moderate_all').addClass('d-none');
- }
- },
- /**
- * @private
- */
- _updateSelectUnselectAllButtons: function () {
- var buttonSelect = this.$buttons.find('.o_mail_discuss_button_select_all');
- var buttonUnselect = this.$buttons.find('.o_mail_discuss_button_unselect_all');
- var numCheckboxes = this._threadWidget.$('.moderation_checkbox').length;
- var numCheckboxesChecked = this._threadWidget.$('.moderation_checkbox:checked').length;
- if (numCheckboxes) {
- if (numCheckboxesChecked === numCheckboxes) {
- buttonSelect.toggleClass('disabled', true);
- buttonUnselect.toggleClass('disabled', false);
- } else if (numCheckboxesChecked === 0) {
- buttonSelect.toggleClass('disabled', false);
- buttonUnselect.toggleClass('disabled', true);
- } else {
- buttonSelect.toggleClass('disabled', false);
- buttonUnselect.toggleClass('disabled', false);
- }
- } else {
- buttonSelect.toggleClass('disabled', true);
- buttonUnselect.toggleClass('disabled', true);
- }
- },
- /**
- * Re-renders the mainside bar with current threads
- *
- * @private
- * @param {Object} [options={}]
- * @param {string} [options.searchChannelVal='']
- */
- _updateThreads: function (options) {
- var self = this;
- options = options || {};
- this._renderSidebarMailboxes(options);
- this._renderSidebarChannels(options);
- _.each(['dm_chat', 'multi_user_channel'], function (type) {
- var $input = self.$('.o_mail_add_thread[data-type=' + type + '] input');
- self._prepareAddThreadInput($input, type);
- });
- },
-
- //--------------------------------------------------------------------------
- // Handlers
- //--------------------------------------------------------------------------
-
- /**
- * @private
- */
- _onActivityUpdated: function () {
- this._throttledUpdateThreads();
- },
- /**
- * @private
- * @param {MouseEvent} ev
- */
- _onAddThread: function (ev) {
- ev.preventDefault();
- var type = $(ev.target).data('type');
- this.$('.o_mail_add_thread[data-type=' + type + ']')
- .show()
- .find('input').focus();
- },
- /**
- * @private
- */
- _onAddThreadBlur: function () {
- this.$('.o_mail_add_thread').hide();
- },
- /**
- * @private
- * @param {integer|string} channelID
- */
- _onChannelLeft: function (channelID) {
- if (this._thread.getID() === channelID) {
- this._setThread('mailbox_inbox');
- }
- this._updateThreads();
- delete this._threadsScrolltop[channelID];
- },
- /**
- * @private
- * @param {MouseEvent} ev
- */
- _onChannelSettingsClicked: function (ev) {
- ev.stopPropagation();
- var threadID = $(ev.currentTarget).data('thread-id');
- var thread = this.call('mail_service', 'getThread', threadID);
- if (thread.getType() === 'dm_chat') {
- new RenameConversationDialog(this, threadID, this._updateThreads.bind(this)).open();
- return;
- }
- this.do_action({
- type: 'ir.actions.act_window',
- res_model: 'mail.channel',
- res_id: threadID,
- views: [[false, 'form']],
- target: 'current'
- });
- },
- /**
- * @private
- */
- _onComposerFocused: function () {
- var composer = this._thread.isMassMailing() ? this._extendedComposer : this._basicComposer;
- var commands = this._thread.getCommands();
- var partners = this._thread.getMentionPartnerSuggestions();
- composer.mentionSetCommands(commands);
- composer.mentionSetPrefetchedPartners(partners);
- },
- /**
- * @private
- * @param {OdooEvent} ev
- */
- _onDiscardExtendedComposer: function (ev) {
- ev.stopPropagation();
- this._unselectMessage();
- },
- /**
- * When clicking on an item in the sidebar
- *
- * @private
- * @param {MouseEvent} ev
- */
- _onDiscussItemClicked: function (ev) {
- ev.preventDefault();
- var threadID = $(ev.currentTarget).data('thread-id');
- this._setThread(threadID);
- },
- /**
- * @private
- * @param {MouseEvent} ev
- */
- _onInputSidebarQuickSearchInput: function (ev) {
- ev.preventDefault();
- this._updateThreads({
- searchChannelVal: $(ev.currentTarget).val(),
- });
- },
- /**
- * Invite button is only for channels (not mailboxes)
- *
- * @private
- */
- _onInviteButtonClicked: function () {
- var title = _.str.sprintf(_t("Invite people to #%s"), this._thread.getName());
- new PartnerInviteDialog(this, title, this._thread.getID()).open();
- },
- /**
- * Called when someone asks discuss whether the bottom of `thread` is
- * visible or not. An object `query` is provided in order to reponse on the
- * key `isVisible`.
- *
- * @private
- * @param {mail.model.Thread} thread
- * @param {Object} query
- * @param {boolean} query.isVisible the response to provide on whether the
- * bottom of the thread is visible in discuss.
- */
- _onIsThreadBottomVisible: function (thread, query) {
- query.isVisible = query.isVisible ||
- (
- thread.getID() === this._thread.getID() &&
- this._threadWidget.isAtBottom()
- );
- },
- /**
- * @private
- * @param {KeyEvent} ev
- */
- _onKeydown: function (ev) {
- if (ev.which === $.ui.keyCode.ESCAPE && this._selectedMessage) {
- this._unselectMessage();
- }
- },
- /**
- * @private
- */
- _onMarkAllAsReadClicked: function () {
- this._thread.markAllMessagesAsRead(this.domain);
- },
- /**
- * @private
- * @param {OdooEvent} ev
- * @param {integer} ev.data.messageID ID of the moderated message
- * @param {string} ev.data.decision can be 'reject', 'discard', 'ban', 'accept', 'allow'.
- */
- _onMessageModeration: function (ev) {
- var messageIDs = [ev.data.messageID];
- var decision = ev.data.decision;
- this._handleModerationDecision(messageIDs, decision);
- },
- /**
- * @private
- * @param {mail.model.Message} message
- * @param {string} [type] the channel
- */
- _onMessageUpdated: function (message, type) {
- var self = this;
- var currentThreadID = this._thread.getID();
- if (
- (currentThreadID === 'mailbox_starred' && !message.isStarred()) ||
- (currentThreadID === 'mailbox_inbox' && !message.isNeedaction()) ||
- (currentThreadID === 'mailbox_moderation' && !message.needsModeration())
- ) {
- this._thread.fetchMessages(this.domain)
- .then(function () {
- var options = self._getThreadRenderingOptions();
- self._threadWidget.removeMessageAndRender(message.getID(), self._thread, options)
- .then(function () {
- self._updateButtonStatus(!self._thread.hasMessages(), type);
- });
- });
- } else if (_.contains(message.getThreadIDs(), currentThreadID)) {
- this._fetchAndRenderThread();
- }
- },
- /**
- * @private
- */
- _onMessagingReady: function () {
- if (this._isMessagingReady) {
- return;
- }
- this._isMessagingReady = true;
- if (!this._isStarted) {
- return;
- }
- this._initRender();
- },
- /**
- * @private
- * @param {MouseEvent} ev
- */
- _onModerateAllClicked: function (ev) {
- var decision = $(ev.target).data('decision');
- var messageIDs = this._threadWidget.$('.moderation_checkbox:checked')
- .map(function () {
- return $(this).data('message-id');
- })
- .get();
- this._handleModerationDecision(messageIDs, decision);
- },
- /**
- * @private
- * @param {mail.model.Channel} channel
- */
- _onNewChannel: function (channel) {
- this._updateThreads();
- if (channel.isAutoswitch()) {
- this._setThread(channel.getID());
- }
- },
- /**
- * @private
- * @param {mail.model.Message} message
- */
- _onNewMessage: function (message) {
- var self = this;
- if (_.contains(message.getThreadIDs(), this._thread.getID())) {
- if (this._thread.getType() !== 'mailbox' && this._threadWidget.isAtBottom()) {
- this._thread.markAsRead();
- }
- var shouldScroll = this._threadWidget.isAtBottom();
- this._fetchAndRenderThread().then(function () {
- if (shouldScroll) {
- self._threadWidget.scrollToMessage({ msgID: message.getID() });
- }
- });
- }
- // Re-render sidebar to indicate that there is a new message in the corresponding threads
- this._updateThreads();
- // Dump scroll position of threads in which the new message arrived
- this._threadsScrolltop = _.omit(this._threadsScrolltop, message.getThreadIDs());
- },
- /**
- * Called when opening a thread in discuss, due to discuss being open.
- * All threads except document threads are valid for discuss app.
- *
- * @private
- * @param {mail.model.Thread}
- */
- _onOpenThreadInDiscuss: function (thread) {
- if (thread.getType() !== 'document_thread') {
- this._setThread(thread.getID());
- }
- },
- /**
- * @private
- * @param {Object} messageData
- */
- _onPostMessage: function (messageData) {
- var self = this;
- var options = {};
- if (this._selectedMessage) {
- messageData.subtype_xmlid = this._selectedMessage.isNote() ? 'mail.mt_note': 'mail.mt_comment';
- messageData.subtype_id = false;
- messageData.message_type = 'comment';
-
- options.documentID = this._selectedMessage.getDocumentID();
- options.documentModel = this._selectedMessage.getDocumentModel();
- }
- this._thread.postMessage(messageData, options)
- .then(function () {
- if (self._selectedMessage) {
- self._renderSnackbar('mail.discuss.MessageSentSnackbar', {
- documentName: self._selectedMessage.getDocumentName()
- }, 5000);
- self._unselectMessage();
- } else {
- self._threadWidget.scrollToBottom();
- }
- });
- },
- /**
- * @private
- */
- _onPublicChannelsClick: function () {
- this.do_action({
- name: _t("Public Channels"),
- type: 'ir.actions.act_window',
- res_model: 'mail.channel',
- views: [[false, 'kanban'], [false, 'form']],
- domain: [['public', '!=', 'private']],
- }, {
- on_reverse_breadcrumb: this.on_reverse_breadcrumb,
- });
- },
- /**
- * @private
- * @param {Object} searchQuery
- */
- _onSearch: function (searchQuery) {
- this.domain = searchQuery.domain;
- if (this._thread) {
- // initially (when _onSearch is called manually), there is no
- // thread set yet, so don't try to fetch and render the thread as
- // this will be done as soon as the default thread is set
- this._fetchAndRenderThread();
- }
- },
- /**
- * @private
- * @param {MouseEvent} ev
- */
- _onSelectAllClicked: function (ev) {
- var $button = $(ev.target);
- if (!$button.hasClass('disabled')) {
- this._threadWidget.toggleModerationCheckboxes(true);
- this._updateModerationButtons();
- }
- },
- /**
- * @private
- */
- _onTypingPartnersUpdated: function () {
- this._updateThreads();
- },
- /**
- * @private
- * @param {MouseEvent} ev
- */
- _onUnpinChannel: function (ev) {
- ev.stopPropagation();
- var channelID = $(ev.target).data('thread-id');
- var channel = this.call('mail_service', 'getChannel', channelID);
- if (channel.isMyselfAdministrator()) {
- this._askConfirmationAdminUnsubscribe(channel);
- } else {
- channel.unsubscribe();
- }
- },
- /**
- * @private
- * @param {MouseEvent} ev
- */
- _onUnselectAllClicked: function (ev) {
- var $button = $(ev.target);
- if (!$button.hasClass('disabled')) {
- this._threadWidget.toggleModerationCheckboxes(false);
- this._updateModerationButtons();
- }
- },
- /**
- * @private
- */
- _onUnstarAllClicked: function () {
- this.call('mail_service', 'unstarAll');
- },
- /**
- * @private
- * @param {integer} channelID
- */
- _onUpdateChannel: function (channelID) {
- if (this._thread.getID() !== channelID) {
- return;
- }
- this._fetchAndRenderThread();
- },
- /**
- * @private
- */
- _onUpdatedImStatus: function () {
- this._throttledUpdateThreads();
- },
- /**
- * Update the moderation buttons.
- * This is triggered when a moderation checkbox
- * has its checked property changed.
- *
- * @private
- */
- _onUpdateModerationButtons: function () {
- this._updateModerationButtons();
- },
- /**
- * @private
- */
- _onUpdateModerationCounter: function () {
- this._throttledUpdateThreads();
- },
- /**
- * @private
- */
- _onUpdateNeedaction: function () {
- this._throttledUpdateThreads();
- },
- /**
- * @private
- */
- _onUpdateStarred: function () {
- this._throttledUpdateThreads();
- },
- /**
- * @private
- */
- _onUpdateThreadUnreadCounter: function () {
- this._throttledUpdateThreads();
- },
-});
-
-core.action_registry.add('mail.discuss', Discuss);
-
-return Discuss;
-
-});
diff --git a/addons/mail/static/src/js/document_viewer.js b/addons/mail/static/src/js/document_viewer.js
index be7dfeb9115..b46aea302c5 100644
--- a/addons/mail/static/src/js/document_viewer.js
+++ b/addons/mail/static/src/js/document_viewer.js
@@ -9,6 +9,13 @@ 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: {
@@ -86,7 +93,6 @@ var DocumentViewer = Widget.extend({
if (this.isDestroyed()) {
return;
}
- this.trigger_up('document_viewer_closed');
this.$el.modal('hide');
this.$el.remove();
this._super.apply(this, arguments);
diff --git a/addons/mail/static/src/js/followers.js b/addons/mail/static/src/js/followers.js
deleted file mode 100644
index bed5d27f176..00000000000
--- a/addons/mail/static/src/js/followers.js
+++ /dev/null
@@ -1,461 +0,0 @@
-odoo.define('mail.Followers', function (require) {
-"use strict";
-
-var AbstractField = require('web.AbstractField');
-var concurrency = require('web.concurrency');
-var core = require('web.core');
-var Dialog = require('web.Dialog');
-var field_registry = require('web.field_registry');
-var session = require('web.session');
-
-var _t = core._t;
-var QWeb = core.qweb;
-
-// -----------------------------------------------------------------------------
-// Followers ('mail_followers') widget
-// -----------------------------------------------------------------------------
-var Followers = AbstractField.extend({
- template: 'mail.Followers',
- events: {
- // click on '(Un)Follow' button, that toggles the follow for uid
- 'click .o_followers_follow_button': '_onFollowButtonClicked',
- // click on a subtype, that (un)subscribes for this subtype
- 'click .o_subtypes_list .custom-checkbox': '_onSubtypeClicked',
- // click on 'invite' button, that opens the invite wizard
- 'click .o_add_follower': '_onAddFollower',
- 'click .o_add_follower_channel': '_onAddChannel',
- // click on 'edit_subtype' (pencil) button to edit subscription
- 'click .o_edit_subtype': '_onEditSubtype',
- 'click .o_remove_follower': '_onRemoveFollower',
- 'click .o_mail_redirect': '_onRedirect',
- },
- // this widget only supports one2many but is not generic enough to claim
- // that it supports all one2many fields
- // supportedFieldTypes: ['one2many'],
-
- // inherited
- init: function (parent, name, record, options) {
- this._super.apply(this, arguments);
-
- this.image = this.attrs.image || 'image_128';
- this.comment = this.attrs.help || false;
-
- this.followers = [];
- this.subtypes = [];
- this.data_subtype = {};
- this._isFollower = undefined;
- this.partnerID = session.partner_id;
-
- this.dp = new concurrency.DropPrevious();
-
- options = options || {};
- this.isEditable = options.isEditable;
- },
- _render: function () {
- // note: the rendering of this widget is asynchronous as it needs to
- // fetch the details of the followers, but it performs a first rendering
- // synchronously (_displayGeneric), and updates its rendering once it
- // has fetched the required data, so this function doesn't return a promise
- // as we don't want to wait to the data to be loaded to display the widget
- var self = this;
- var fetch_def = this.dp.add(this._readFollowers());
-
- concurrency.rejectAfter(concurrency.delay(0), fetch_def).then(this._displayGeneric.bind(this));
-
- return fetch_def.then(function () {
- self._displayButtons();
- self._displayFollowers(self.followers);
- if (self.subtypes) { // current user is follower
- self._displaySubtypes(self.subtypes);
- }
- });
- },
- isSet: function () {
- return true;
- },
- _reset: function (record) {
- this._super.apply(this, arguments);
- // the mail widgets being persistent, one need to update the res_id on reset
- this.res_id = record.res_id;
- },
-
- // public
- getFollowers: function () {
- return this.followers;
- },
-
- // private
- _displayButtons: function () {
- if (this._isFollower) {
- this.$('button.o_followers_follow_button').removeClass('o_followers_notfollow').addClass('o_followers_following');
- this.$('.o_subtypes_list > .dropdown-toggle').attr('disabled', false);
- this.$('.o_followers_actions .dropdown-toggle').addClass('o_followers_following');
- } else {
- this.$('button.o_followers_follow_button').removeClass('o_followers_following').addClass('o_followers_notfollow');
- this.$('.o_subtypes_list > .dropdown-toggle').attr('disabled', true);
- this.$('.o_followers_actions .dropdown-toggle').removeClass('o_followers_following');
- }
- this.$('button.o_followers_follow_button').attr("aria-pressed", this.is_follower);
- },
- _displayGeneric: function () {
- // only display the number of followers (e.g. if read failed)
- this.$('.o_followers_actions').hide();
- this.$('.o_followers_title_box > button').prop('disabled', true);
- this.$('.o_followers_count')
- .html(this.value.res_ids.length)
- .parent().attr('title', this._formatFollowers(this.value.res_ids.length));
- },
- _displayFollowers: function () {
- var self = this;
-
- // render the dropdown content
- var $followers_list = this.$('.o_followers_list').empty();
- $(QWeb.render('mail.Followers.add_more', {widget: this})).appendTo($followers_list);
- var $follower_li;
- _.each(this.followers, function (record) {
- if (!record.is_active) {
- record.title = _.str.sprintf(_t('%s \n(inactive)'), record.name);
- } else {
- record.title = record.name;
- }
-
- $follower_li = $(QWeb.render('mail.Followers.partner', {
- 'record': record,
- 'widget': self})
- );
- $follower_li.appendTo($followers_list);
-
- // On mouse-enter it will show the edit_subtype pencil.
- if (record.is_editable) {
- $follower_li.on('mouseenter mouseleave', function (e) {
- $(e.currentTarget).find('.o_edit_subtype').toggleClass('d-none', e.type === 'mouseleave');
- });
- }
- });
-
- // clean and display title
- this.$('.o_followers_actions').show();
- this.$('.o_followers_title_box > button').prop('disabled', !$followers_list.children().length);
- this.$('.o_followers_count')
- .html(this.value.res_ids.length)
- .parent().attr('title', this._formatFollowers(this.value.res_ids.length));
- },
- _displaySubtypes:function (data, dialog, display_warning) {
- var old_parent_model;
- var $list;
- if (dialog) {
- $list = $('<div>').appendTo(this.dialog.$el);
- } else {
- $list = this.$('.o_subtypes_list .dropdown-menu');
- }
- $list.empty();
-
- this.data_subtype = data;
-
- _.each(data, function (record) {
- if (old_parent_model !== record.parent_model && old_parent_model !== undefined) {
- $list.append($('<div>', {class: 'dropdown-divider'}));
- }
- old_parent_model = record.parent_model;
- record.followed = record.followed || undefined;
- $list.append(QWeb.render('mail.Followers.subtype', {
- 'record': record,
- 'dialog': dialog,
- 'display_warning': display_warning && record.internal,
- }));
- });
-
- if (display_warning) {
- $(QWeb.render('mail.Followers.subtypes.warning')).appendTo(this.dialog.$el);
- }
- },
- _inviteFollower: function (channel_only) {
- var action = {
- type: 'ir.actions.act_window',
- res_model: 'mail.wizard.invite',
- view_mode: 'form',
- views: [[false, 'form']],
- name: _t("Invite Follower"),
- target: 'new',
- context: {
- 'default_res_model': this.model,
- 'default_res_id': this.res_id,
- 'mail_invite_follower_channel_only': channel_only,
- },
- };
- this.do_action(action, {
- on_close: this._reload.bind(this),
- });
- },
- _formatFollowers: function (count){
- var str = '';
- if (count <= 0) {
- str = _t("No follower");
- } else if (count === 1){
- str = _t("One follower");
- } else {
- str = ''+count+' '+_t("followers");
- }
- return str;
- },
- _readFollowers: function () {
- var missing_ids = _.difference(this.value.res_ids, _.pluck(this.followers, 'id'));
- var def;
- if (missing_ids.length) {
- def = this._rpc({
- route: '/mail/read_followers',
- params: { follower_ids: missing_ids, context: {} } // empty context to be overridden in session.js with 'allowed_company_ids'
- });
- }
- return Promise.resolve(def).then((results) => {
- if (results) {
- // Preprocess records
- _.each(results.followers, (record) => {
- var resModel = record.partner_id ? 'res.partner' : 'mail.channel';
- var resId = record.partner_id ? record.partner_id : record.channel_id;
- record.res_id = resId;
- record.res_model = resModel;
- record.avatar_url = '/web/image/' + resModel + '/' + resId + '/image_128';
- });
- this.followers = _.uniq(results.followers.concat(this.followers), 'id');
- if (results.subtypes) { //read_followers will return False if current user is not in the list
- this.subtypes = results.subtypes;
- }
- }
- // filter out previously fetched followers that are no longer following
- this.followers = _.filter(this.followers, (follower) => {
- return _.contains(this.value.res_ids, follower.id);
- });
- var userFollower = _.filter(this.followers, (rec) => {
- return this.partnerID === rec.partner_id;
- });
- this._isFollower = userFollower.length >= 1;
- });
- },
- _reload: function () {
- this.trigger_up('reload', {fieldNames: [this.name]});
- },
- _follow: function () {
- var kwargs = {
- partner_ids: [this.partnerID],
- context: {}, // FIXME
- };
- this._rpc({
- model: this.model,
- method: 'message_subscribe',
- args: [[this.res_id]],
- kwargs: kwargs,
- })
- .then(this._reload.bind(this));
- },
- /**
- * Remove partners or channels from the followers
- *
- * @private
- * @param {Object} ids
- * @param {Array} [ids.partner_ids] the partner ids
- * @param {Array} [ids.channel_ids] the channel ids
- */
- _messageUnsubscribe: function (ids) {
- return this._rpc({
- model: this.model,
- method: 'message_unsubscribe',
- args: [[this.res_id], ids.partner_ids, ids.channel_ids],
- }).then(this._reload.bind(this));
- },
- /**
- * Remove partners or channels from the followers
- * @param {Array} [ids.partner_ids] the partner ids
- * @param {Array} [ids.channel_ids] the channel ids
- */
- _unfollow: function (ids) {
- var self = this;
- // do not prompt confirmation dialog on unsubscribe of current user.
- if (_.isEqual(ids.partner_ids, [this.partnerID]) && _.isEmpty(ids.channel_ids)) {
- return this._messageUnsubscribe(ids);
- }
- return new Promise(function (resolve, reject) {
- var follower = _.find(self.followers, { res_id: ids.partner_ids ? ids.partner_ids[0] : ids.channel_ids[0] });
- var text = _.str.sprintf(_t("If you remove a follower, he won't be notified of any email or discussion on this document. Do you really want to remove %s?"), follower.name);
- Dialog.confirm(this, text, {
- title: _t("Warning"),
- confirm_callback: function () {
- self._messageUnsubscribe(ids);
- resolve();
- },
- cancel_callback: reject,
- });
- });
- },
- _updateSubscription: function (event, followerID, isChannel) {
- var ids = {};
- var subtypes;
-
- if (followerID !== undefined) {
- // Subtypes edited from the modal
- subtypes = this.dialog.$('input[type="checkbox"]');
- if (isChannel) {
- ids.channel_ids = [followerID];
- } else {
- ids.partner_ids = [followerID];
- }
- } else {
- subtypes = this.$('.o_followers_actions input[type="checkbox"]');
- ids.partner_ids = [this.partnerID];
- }
-
- // Get the subtype ids
- var checklist = [];
- _.each(subtypes, function (record) {
- if ($(record).is(':checked')) {
- checklist.push(parseInt($(record).data('id')));
- }
- });
-
- // If no more subtype followed, unsubscribe the follower
- if (!checklist.length) {
- this._unfollow(ids).guardedCatch(function () {
- $(event.currentTarget).find('input').addBack('input').prop('checked', true);
- });
- } else {
- var kwargs = _.extend({}, ids);
- if (followerID === undefined || followerID === this.partnerID) {
- //this.subtypes will only be updated if the current user
- //just added himself to the followers. We need to update
- //the subtypes manually when editing subtypes
- //for current user
- _.each(this.subtypes, function (subtype) {
- subtype.followed = checklist.indexOf(subtype.id) > -1;
- });
- }
- kwargs.subtype_ids = checklist;
- kwargs.context = {}; // FIXME
- this._rpc({
- model: this.model,
- method: 'message_subscribe',
- args: [[this.res_id]],
- kwargs: kwargs,
- })
- .then(this._reload.bind(this));
- }
- },
-
- //--------------------------------------------------------------------------
- // Handlers
- //--------------------------------------------------------------------------
-
- /**
- * @private
- * @param {MouseEvent} ev
- */
- _onAddFollower: function (ev) {
- ev.preventDefault();
- this._inviteFollower(false);
- },
- /**
- * @private
- * @param {MouseEvent} ev
- */
- _onAddChannel: function (ev) {
- ev.preventDefault();
- this._inviteFollower(true);
- },
- /**
- * @private
- * @param {MouseEvent} ev
- */
- _onEditSubtype: function (ev) {
- var self = this;
- var $currentTarget = $(ev.currentTarget);
- var follower_id = $currentTarget.data('follower-id'); // id of model mail_follower
- this._rpc({
- route: '/mail/read_subscription_data',
- params: {follower_id: follower_id},
- })
- .then(function (data) {
- var res_id = $currentTarget.data('oe-id'); // id of model res_partner or mail_channel
- var is_channel = $currentTarget.data('oe-model') === 'mail.channel';
- self.dialog = new Dialog(this, {
- size: 'medium',
- title: _t("Edit Subscription of ") + $currentTarget.siblings('a').text(),
- buttons: [
- {
- text: _t("Apply"),
- classes: 'btn-primary',
- click: function () {
- self._updateSubscription(ev, res_id, is_channel);
- },
- close: true
- },
- {
- text: _t("Cancel"),
- close: true,
- },
- ],
- });
- self.dialog.opened().then(function () {
- self._displaySubtypes(data, true, is_channel);
- });
- self.dialog.open();
- });
- },
- /**
- * @private
- */
- _onFollowButtonClicked: function () {
- if (!this._isFollower) {
- this._follow();
- } else {
- this._unfollow({partner_ids: [this.partnerID]});
- }
- },
- /**
- * @private
- * @param {MouseEvent} ev
- */
- _onRedirect: function (ev) {
- ev.preventDefault();
- var $target = $(ev.target).closest('.o_mail_redirect');
- this.do_action({
- type: 'ir.actions.act_window',
- view_mode: 'form',
- res_model: $target.data('oe-model'),
- views: [[false, 'form']],
- res_id: $target.data('oe-id'),
- });
- },
- /**
- * @private
- * @param {MouseEvent} ev
- */
- _onRemoveFollower: function (ev) {
- var resModel = $(ev.target).parent().find('a').data('oe-model');
- var resID = $(ev.target).parent().find('a').data('oe-id');
- if (resModel === 'res.partner') {
- return this._unfollow({partner_ids: [resID]});
- } else {
- return this._unfollow({channel_ids: [resID]});
- }
- },
- /**
- * @private
- * @param {MouseEvent} ev
- */
- _onSubtypeClicked: function (ev) {
- ev.stopPropagation();
- this._updateSubscription(ev);
- var $list = this.$('.o_subtypes_list');
- if (!$list.hasClass('show')) {
- $list.addClass('show');
- }
- if (this.$('.o_subtypes_list .dropdown-menu')[0].children.length < 1) {
- $list.removeClass('show');
- }
- },
-});
-
-field_registry.add('mail_followers', Followers);
-
-return Followers;
-
-});
diff --git a/addons/mail/static/src/js/form_controller.js b/addons/mail/static/src/js/form_controller.js
deleted file mode 100644
index 0d4c03d7a64..00000000000
--- a/addons/mail/static/src/js/form_controller.js
+++ /dev/null
@@ -1,47 +0,0 @@
-
-odoo.define('mail.form_controller', function (require) {
-"use strict";
-
-var FormController = require('web.FormController');
-
-/**
- * Include the FormController to update the datapoint on the model when a
- * message is posted.
- */
-FormController.include({
- custom_events: _.extend({}, FormController.prototype.custom_events, {
- new_message: '_onNewMessage',
- }),
-
- //--------------------------------------------------------------------------
- // Public
- //--------------------------------------------------------------------------
-
- /**
- * @override
- */
- async saveRecord() {
- const changedFields = await this._super(...arguments);
- const chatter = this.renderer.chatter;
- if (chatter) {
- await chatter.updateSuggestedPartners();
- }
- return changedFields;
- },
-
- //--------------------------------------------------------------------------
- // Handlers
- //--------------------------------------------------------------------------
-
- /**
- * @private
- * @param {OdooEvent} ev
- * @param {string} event.data.id datapointID
- * @param {integer[]} event.data.messageIDs list of message IDs
- */
- _onNewMessage: function (ev) {
- this.model.updateMessageIDs(ev.data.id, ev.data.messageIDs);
- },
-});
-
-});
diff --git a/addons/mail/static/src/js/form_renderer.js b/addons/mail/static/src/js/form_renderer.js
deleted file mode 100644
index e7b767c0b2f..00000000000
--- a/addons/mail/static/src/js/form_renderer.js
+++ /dev/null
@@ -1,77 +0,0 @@
-odoo.define('mail.form_renderer', function (require) {
-"use strict";
-
-var Chatter = require('mail.Chatter');
-var FormRenderer = require('web.FormRenderer');
-
-/**
- * Include the FormRenderer to instanciate the chatter area containing (a
- * subset of) the mail widgets (mail_thread, mail_followers and mail_activity).
- */
-FormRenderer.include({
- /**
- * @override
- */
- init: function (parent, state, params) {
- this._super.apply(this, arguments);
- this.mailFields = params.mailFields;
- this.chatter = undefined;
- },
-
- //--------------------------------------------------------------------------
- // Public
- //--------------------------------------------------------------------------
-
- /**
- * Updates the chatter area with the new state if its fields has changed
- *
- * @override
- */
- confirmChange: function (state, id, fields) {
- if (this.chatter) {
- var chatterFields = ['message_attachment_count'].concat(_.values(this.mailFields));
- var updatedMailFields = _.intersection(fields, chatterFields);
- if (updatedMailFields.length) {
- this.chatter.update(state, updatedMailFields);
- }
- }
- return this._super.apply(this, arguments);
- },
-
- //--------------------------------------------------------------------------
- // Private
- //--------------------------------------------------------------------------
-
- /**
- * Overrides the function that renders the nodes to return the chatter's $el
- * for the 'oe_chatter' div node.
- *
- * @override
- * @private
- */
- _renderNode: function (node) {
- var self = this;
- if (node.tag === 'div' && node.attrs.class === 'oe_chatter') {
- if (!this.chatter) {
- this.chatter = new Chatter(this, this.state, this.mailFields, {
- isEditable: this.activeActions.edit,
- viewType: 'form',
- });
-
- var $temporaryParentDiv = $('<div>');
- this.defs.push(this.chatter.appendTo($temporaryParentDiv).then(function () {
- self.chatter.$el.unwrap();
- self._handleAttributes(self.chatter.$el, node);
- }));
- return $temporaryParentDiv;
- } else {
- this.chatter.update(this.state);
- return this.chatter.$el;
- }
- } else {
- return this._super.apply(this, arguments);
- }
- },
-});
-
-});
diff --git a/addons/mail/static/src/js/form_view.js b/addons/mail/static/src/js/form_view.js
deleted file mode 100644
index 18a8f77da16..00000000000
--- a/addons/mail/static/src/js/form_view.js
+++ /dev/null
@@ -1,26 +0,0 @@
-odoo.define('mail.FormView', function (require) {
-"use strict";
-
-var FormView = require('web.FormView');
-
-/**
- * This file is used to add "message_attachment_count" to fieldsInfo so we can fetch its value for the
- * chatter's attachment button without having it explicitly declared in the form view template.
- *
- */
-
-FormView.include({
- /**
- * @override
- */
- init: function () {
- this._super.apply(this, arguments);
-
- var fieldsInfo = this.fieldsInfo[this.viewType];
- if (!_.isEmpty(this.mailFields) && !('message_attachment_count' in fieldsInfo)) {
- fieldsInfo.message_attachment_count = {};
- }
- },
-});
-
-});
diff --git a/addons/mail/static/src/js/many2one_avatar_user.js b/addons/mail/static/src/js/many2one_avatar_user.js
index ba92fdc3d5c..3a4e04089f5 100644
--- a/addons/mail/static/src/js/many2one_avatar_user.js
+++ b/addons/mail/static/src/js/many2one_avatar_user.js
@@ -26,6 +26,7 @@ odoo.define('mail.Many2OneAvatarUser', function (require) {
const { Many2OneAvatar } = require('web.relational_fields');
const session = require('web.session');
+ const { Component } = owl;
const Many2OneAvatarUser = Many2OneAvatar.extend({
events: Object.assign({}, Many2OneAvatar.prototype.events, {
@@ -117,7 +118,11 @@ odoo.define('mail.Many2OneAvatarUser', function (require) {
partnerId = await this._resIdToPartnerId(this.value.res_id);
}
if (partnerId && partnerId !== session.partner_id) {
- this.call('mail_service', 'openDMChatWindow', partnerId);
+ const env = Component.env;
+ const partner = env.models['mail.partner'].insert({
+ id: partnerId,
+ });
+ partner.openChat();
} else {
this._displayWarning(partnerId);
}
diff --git a/addons/mail/static/src/js/models/mail_failure.js b/addons/mail/static/src/js/models/mail_failure.js
deleted file mode 100644
index 815c95a034f..00000000000
--- a/addons/mail/static/src/js/models/mail_failure.js
+++ /dev/null
@@ -1,127 +0,0 @@
-odoo.define('mail.model.MailFailure', function (require) {
-"use strict";
-
-var Class = require('web.Class');
-var core = require('web.core');
-var Mixins = require('web.mixins');
-var ServicesMixin = require('web.ServicesMixin');
-var time = require('web.time');
-
-var _t = core._t;
-
-var MailFailure = Class.extend(Mixins.EventDispatcherMixin, ServicesMixin, {
- /**
- * @param {mail.Manager} parent
- * @param {Object} data
- * @param {string} data.failure_type
- * @param {string} data.last_message_date
- * @param {integer} data.message_id
- * @param {string} data.model
- * @param {string} data.model_name
- * @param {string} data.module_icon
- * @param {Array} data.notifications
- * @param {string} data.record_name
- * @param {integer} data.res_id
- * @param {string} data.uuid
- */
- init: function (parent, data) {
- Mixins.EventDispatcherMixin.init.call(this, arguments);
- this.setParent(parent);
-
- this._documentID = data.res_id;
- this._documentModel = data.model;
- this._lastMessageDate = moment(); // by default: current datetime
- this._messageID = data.message_id;
- this._messageType = data.message_type;
- this._modelName = data.model_name;
- this._moduleIcon = data.module_icon;
- this._notifications = data.notifications;
-
- if (data.last_message_date) {
- this._lastMessageDate = moment(time.str_to_datetime(data.last_message_date));
- }
- },
-
- //--------------------------------------------------------------------------
- // Public
- //--------------------------------------------------------------------------
-
- /**
- * Get the document model that this mail failure is linked to.
- *
- * @returns {string}
- */
- getDocumentModel: function () {
- return this._documentModel;
- },
- /**
- * Get the document ID that this mail failure is linked to.
- *
- * @returns {integer}
- */
- getDocumentID: function () {
- return this._documentID;
- },
- /**
- * Get the ID of the message that this mail failure is related to.
- *
- * @returns {integer}
- */
- getMessageID: function () {
- return this._messageID;
- },
- /**
- * Get the type of the message that this mail failure is related to.
- *
- * @returns {string}
- */
- getMessageType() {
- return this._messageType;
- },
- /**
- * Get a valid object for the 'mail.preview' template
- *
- * @returns {Object}
- */
- getPreview: function () {
- var preview = {
- body: this._getPreviewBody(),
- date: this._lastMessageDate,
- documentID: this._documentID,
- documentModel: this._documentModel,
- id: 'mail_failure',
- imageSRC: this._getPreviewImage(),
- title: this._modelName,
- };
- return preview;
- },
- /**
- * Tell whether the mail failure comes from a message in a document.
- *
- * @returns {boolean}
- */
- isLinkedToDocument: function () {
- return !!(this._documentModel && this._documentID);
- },
-
- //--------------------------------------------------------------------------
- // Private
- //--------------------------------------------------------------------------
-
- /**
- * @returns {string}
- */
- _getPreviewBody() {
- return _t("An error occurred when sending an email.");
- },
- /**
- * @returns {string}
- */
- _getPreviewImage() {
- return '/mail/static/src/img/smiley/mailfailure.jpg';
- },
-});
-
-return MailFailure;
-
-});
diff --git a/addons/mail/static/src/js/models/messages/abstract_message.js b/addons/mail/static/src/js/models/messages/abstract_message.js
deleted file mode 100644
index b293f6f1db0..00000000000
--- a/addons/mail/static/src/js/models/messages/abstract_message.js
+++ /dev/null
@@ -1,462 +0,0 @@
-odoo.define('mail.model.AbstractMessage', function (require) {
-"use strict";
-
-var mailUtils = require('mail.utils');
-
-var Class = require('web.Class');
-var core = require('web.core');
-var session = require('web.session');
-var time = require('web.time');
-
-var _t = core._t;
-
-/**
- * This is an abstract class for modeling messages in JS.
- * The purpose of this interface is to make im_livechat compatible with
- * mail.widget.Thread, as this widget was designed to work with messages that
- * are instances of mail.model.Messages.
- *
- * Ideally, im_livechat should also handle mail.model.Message, but this is not
- * feasible for the moment, as mail.model.Message requires mail.Manager to work,
- * and this module should not leak outside of the backend, hence the use of
- * mail.model.AbstractMessage as a work-around.
- */
-var AbstractMessage = Class.extend({
-
- /**
- * @param {Widget} parent
- * @param {Object} data
- * @param {Array} [data.attachment_ids=[]]
- * @param {Array} [data.author_id]
- * @param {string} [data.body = ""]
- * @param {string} [data.date] the server-format date time of the message.
- * If not provided, use current date time for this message.
- * @param {integer} data.id
- * @param {boolean} [data.is_discussion = false]
- * @param {boolean} [data.is_notification = false]
- * @param {string} [data.message_type = undefined]
- */
- init: function (parent, data) {
- this._attachmentIDs = data.attachment_ids || [];
- this._body = data.body || "";
- // by default: current datetime
- this._date = data.date ? moment(time.str_to_datetime(data.date)) : moment();
- this._id = data.id;
- this._isDiscussion = data.is_discussion;
- this._isNotification = data.is_notification;
- this._serverAuthorID = data.author_id;
- this._type = data.message_type || undefined;
-
- this._processAttachmentURL();
- this._attachmentIDs.forEach(function (attachment) {
- attachment.filename = attachment.filename || attachment.name || _t("unnamed");
- });
- },
-
- //--------------------------------------------------------------------------
- // Public
- //--------------------------------------------------------------------------
-
- /**
- * Get the list of files attached to this message.
- * Note that attachments are stored with server-format
- *
- * @return {Object[]}
- */
- getAttachments: function () {
- return this._attachmentIDs;
- },
- /**
- * Get the server ID (number) of the author of this message
- * If there are no author, return -1;
- *
- * @return {integer}
- */
- getAuthorID: function () {
- if (!this.hasAuthor()) {
- return -1;
- }
- return this._serverAuthorID[0];
- },
- /**
- * Threads do not have an im status by default
- *
- * @return {undefined}
- */
- getAuthorImStatus: function () {
- return undefined;
- },
- /**
- * Get the relative url of the avatar to display next to the message
- *
- * @abstract
- * @return {string}
- */
- getAvatarSource: function () {
- if (this.hasAuthor()) {
- return '/web/image/res.partner/' + this.getAuthorID() + '/image_128';
- }
- },
- /**
- * Get the body content of this message
- *
- * @return {string}
- */
- getBody: function () {
- return this._body;
- },
- /**
- * @return {moment}
- */
- getDate: function () {
- return this._date;
- },
- /**
- * Get the date day of this message
- *
- * @return {string}
- */
- getDateDay: function () {
- var date = this.getDate().format('YYYY-MM-DD');
- if (date === moment().format('YYYY-MM-DD')) {
- return _t("Today");
- } else if (date === moment().subtract(1, 'days').format('YYYY-MM-DD')) {
- return _t("Yesterday");
- }
- return this.getDate().format('LL');
- },
- /**
- * Get the name of the author, if there is an author of this message
- * If there are no author of this message, returns 'null'
- *
- * @return {string}
- */
- getDisplayedAuthor: function () {
- return this.hasAuthor() ? this._getAuthorName() : null;
- },
- /**
- * Get the server ID (number) of this message
- *
- * @override
- * @return {integer}
- */
- getID: function () {
- return this._id;
- },
- /**
- * Get the list of images attached to this message.
- * Note that attachments are stored with server-format
- *
- * @return {Object[]}
- */
- getImageAttachments: function () {
- return _.filter(this.getAttachments(), function (attachment) {
- return attachment.mimetype && attachment.mimetype.split('/')[0] === 'image';
- });
- },
- /**
- * Get the list of non-images attached to this message.
- * Note that attachments are stored with server-format
- *
- * @return {Object[]}
- */
- getNonImageAttachments: function () {
- return _.difference(this.getAttachments(), this.getImageAttachments());
- },
- /**
- * Gets the class to use as the notification icon.
- *
- * @returns {string}
- */
- getNotificationIcon() {
- if (!this.hasNotificationsError()) {
- return 'fa fa-envelope-o';
- }
- return 'fa fa-envelope';
- },
- /**
- * Gets the list of notifications of this message, in no specific order.
- * By default messages do not have notifications.
- *
- * @returns {Object[]}
- */
- getNotifications() {
- return [];
- },
- /**
- * Gets the text to display next to the notification icon.
- *
- * @returns {string}
- */
- getNotificationText() {
- return '';
- },
- /**
- * Get the time elapsed between sent message and now
- *
- * @return {string}
- */
- getTimeElapsed: function () {
- return mailUtils.timeFromNow(this.getDate());
- },
- /**
- * Get the type of message (e.g. 'comment', 'email', 'notification', ...)
- * By default, messages are of type 'undefined'
- *
- * @override
- * @return {string|undefined}
- */
- getType: function () {
- return this._type;
- },
- /**
- * State whether this message contains some attachments.
- *
- * @override
- * @return {boolean}
- */
- hasAttachments: function () {
- return this.getAttachments().length > 0;
- },
- /**
- * State whether this message has an author
- *
- * @return {boolean}
- */
- hasAuthor: function () {
- return !!(this._serverAuthorID && this._serverAuthorID[0]);
- },
- /**
- * State whether this message has an email of its sender.
- * By default, messages do not have any email of its sender.
- *
- * @return {string}
- */
- hasEmailFrom: function () {
- return false;
- },
- /**
- * State whether this image contains images attachments
- *
- * @return {boolean}
- */
- hasImageAttachments: function () {
- return _.some(this.getAttachments(), function (attachment) {
- return attachment.mimetype && attachment.mimetype.split('/')[0] === 'image';
- });
- },
- /**
- * State whether this image contains non-images attachments
- *
- * @return {boolean}
- */
- hasNonImageAttachments: function () {
- return _.some(this.getAttachments(), function (attachment) {
- return !(attachment.mimetype && attachment.mimetype.split('/')[0] === 'image');
- });
- },
- /**
- * States whether this message has some notifications.
- *
- * @returns {boolean}
- */
- hasNotifications() {
- return this.getNotifications().length > 0;
- },
- /**
- * States whether this message has notifications that are in error.
- *
- * @returns {boolean}
- */
- hasNotificationsError() {
- return this.getNotifications().some(notif =>
- notif.notification_status === 'exception' ||
- notif.notification_status === 'bounce'
- );
- },
- /**
- * State whether this message originates from a channel.
- * By default, messages do not originate from a channel.
- *
- * @override
- * @return {boolean}
- */
- originatesFromChannel: function () {
- return false;
- },
- /**
- * State whether this message has a subject
- * By default, messages do not have any subject.
- *
- * @return {boolean}
- */
- hasSubject: function () {
- return false;
- },
- /**
- * State whether this message is empty
- *
- * @return {boolean}
- */
- isEmpty: function () {
- return !this.hasTrackingValues() &&
- !this.hasAttachments() &&
- !this.getBody();
- },
- /**
- * By default, messages do not have any subtype description
- *
- * @return {boolean}
- */
- hasSubtypeDescription: function () {
- return false;
- },
- /**
- * State whether this message contains some tracking values
- * By default, messages do not have any tracking values.
- *
- * @return {boolean}
- */
- hasTrackingValues: function () {
- return false;
- },
- /**
- * State whether this message is a discussion
- *
- * @return {boolean}
- */
- isDiscussion: function () {
- return this._isDiscussion;
- },
- /**
- * State whether this message is linked to a document thread
- * By default, messages are not linked to a document thread.
- *
- * @return {boolean}
- */
- isLinkedToDocumentThread: function () {
- return false;
- },
- /**
- * State whether this message is needaction
- * By default, messages are not needaction.
- *
- * @return {boolean}
- */
- isNeedaction: function () {
- return false;
- },
- /**
- * State whether this message is a note (i.e. a message from "Log note")
- *
- * @return {boolean}
- */
- isNote: function () {
- return this._isNote;
- },
- /**
- * State whether this message is a notification
- *
- * User notifications are defined as either
- * - notes
- * - pushed to user Inbox or email through classic notification process
- * - not linked to any document, meaning model and res_id are void
- *
- * This is useful in order to display white background for user
- * notifications in chatter
- *
- * @returns {boolean}
- */
- isNotification: function () {
- return this._isNotification;
- },
- /**
- * State whether this message is starred
- * By default, messages are not starred.
- *
- * @return {boolean}
- */
- isStarred: function () {
- return false;
- },
- /**
- * State whether this message is a system notification
- * By default, messages are not system notifications
- *
- * @override
- * @return {boolean}
- */
- isSystemNotification: function () {
- return false;
- },
- /**
- * States whether the current message needs moderation in general.
- * By default, messages do not require any moderation.
- *
- * @returns {boolean}
- */
- needsModeration: function () {
- return false;
- },
- /**
- * @params {integer[]} attachmentIDs
- */
- removeAttachments: function (attachmentIDs) {
- this._attachmentIDs = _.reject(this._attachmentIDs, function (attachment) {
- return _.contains(attachmentIDs, attachment.id);
- });
- },
- /**
- * State whether this message should redirect to the author
- * when clicking on the author of this message.
- *
- * Do not redirect on author clicked of self-posted messages.
- *
- * @return {boolean}
- */
- shouldRedirectToAuthor: function () {
- return !this._isMyselfAuthor();
- },
-
- //--------------------------------------------------------------------------
- // Private
- //--------------------------------------------------------------------------
-
- /**
- * Get the name of the author of this message.
- * If there are no author of this messages, returns '' (empty string).
- *
- * @private
- * @returns {string}
- */
- _getAuthorName: function () {
- if (!this.hasAuthor()) {
- return "";
- }
- return this._serverAuthorID[1];
- },
- /**
- * State whether the current user is the author of this message
- *
- * @private
- * @return {boolean}
- */
- _isMyselfAuthor: function () {
- return this.hasAuthor() && (this.getAuthorID() === session.partner_id);
- },
- /**
- * Compute url of attachments of this message
- *
- * @private
- */
- _processAttachmentURL: function () {
- _.each(this.getAttachments(), function (attachment) {
- attachment.url = '/web/content/' + attachment.id + '?download=true';
- });
- },
-
-});
-
-return AbstractMessage;
-
-});
diff --git a/addons/mail/static/src/js/models/messages/message.js b/addons/mail/static/src/js/models/messages/message.js
deleted file mode 100644
index 1e3ae8c5289..00000000000
--- a/addons/mail/static/src/js/models/messages/message.js
+++ /dev/null
@@ -1,806 +0,0 @@
-odoo.define('mail.model.Message', function (require) {
-"use strict";
-
-var emojis = require('mail.emojis');
-var AbstractMessage = require('mail.model.AbstractMessage');
-var mailUtils = require('mail.utils');
-
-var core = require('web.core');
-var Mixins = require('web.mixins');
-var ServicesMixin = require('web.ServicesMixin');
-var session = require('web.session');
-
-var _t = core._t;
-
-/**
- * This is the main class for the modeling messages in JS.
- * Such messages are stored in the mail manager, and any piece of JS code whose
- * logic relies on threads must ideally interact with such objects.
- */
-var Message = AbstractMessage.extend(Mixins.EventDispatcherMixin, ServicesMixin, {
- /**
- * @override
- * @param {web.Manager} parent
- * @param {Object} data
- * @param {string} [data.body = ""]
- * @param {(string|integer)[]} [data.channel_ids]
- * @param {string} [data.email_from]
- * @param {Array} [data.history_partner_ids = []]
- * @param {string} [data.info]
- * @param {string} [data.model]
- * @param {string} [data.moderation_status='accepted']
- * @param {string} [data.module_icon]
- * @param {Array} [data.needaction_partner_ids = []]
- * @param {Object[]} [data.notifications]
- * @param {string} [data.record_name]
- * @param {integer} [data.res_id]
- * @param {Array} [data.starred_partner_ids = []]
- * @param {string} [data.subject]
- * @param {string} [data.subtype_description]
- * @param {Object[]} [data.tracking_value_ids]
- * @param {Object[]} emojis
- */
- init: function (parent, data, emojis) {
- this._super.apply(this, arguments);
-
- Mixins.EventDispatcherMixin.init.call(this, arguments);
- this.setParent(parent);
-
- this._setInitialData(data);
-
- this._processBody(emojis);
- this._processMailboxes();
- this._processModeration();
- this._processDocumentThread();
- this._processTrackingValues();
- },
-
- //--------------------------------------------------------------------------
- // Public
- //--------------------------------------------------------------------------
-
- /**
- * @override
- * @return {string|undefined}
- */
- getAuthorImStatus: function () {
- if (!this.hasAuthor()) {
- return undefined;
- }
- return this.call('mail_service', 'getImStatus', { partnerID: this.getAuthorID() });
- },
- /**
- * Get the name of the author of this message
- * If there are no author, return "".
- *
- * @return {string}
- */
- getAuthorName: function () {
- return this._getAuthorName();
- },
- /**
- * Get the relative url of the avatar to display next to the message
- *
- * @override
- * @return {string}
- */
- getAvatarSource: function () {
- if (this._isOdoobotAuthor()) {
- return '/mail/static/src/img/odoobot.png';
- } else if (this.hasAuthor()) {
- return '/web/image/res.partner/' + this.getAuthorID() + '/image_128';
- } else if (this.getType() === 'email') {
- return '/mail/static/src/img/email_icon.png';
- }
- return '/mail/static/src/img/smiley/avatar.jpg';
- },
- /**
- * Get the text to display for the author of the message
- *
- * Rule of precedence for the displayed author::
- *
- * author name > sender email > "anonymous"
- *
- * @override
- * @return {string}
- */
- getDisplayedAuthor: function () {
- var res = this._super.apply(this, arguments);
- if (res) {
- return res;
- } else {
- return this.hasEmailFrom() ? this.getEmailFrom() :
- _t("Anonymous");
- }
- },
- /**
- * Get the ID of the document that this message is linked.
- *
- * @override
- * @return {integer}
- */
- getDocumentID: function () {
- return this._documentID;
- },
- /**
- * Get the model of the document that this message is linked.
- * If this message is not linked to a document, returns "".
- *
- * @override
- * @return {string}
- */
- getDocumentModel: function () {
- return this._documentModel;
- },
- /**
- * Get the name of the document that this message is linked.
- * If this message is not linked to a document, returns "".
- *
- * It uses the name of an existing document thread, in order to have the
- * updated document name. If there is no document thread yet, use the
- * currently aware document name.
- *
- * As a result, this method should ideally be called internally, in order
- * to avoid having different names for messages linked to a same modified
- * document.
- *
- * @override
- * @return {string}
- */
- getDocumentName: function () {
- if (!this.isLinkedToDocumentThread()) {
- return "";
- }
- var model = this.getDocumentModel();
- var id = this.getDocumentID();
- var documentThread = this.call('mail_service', 'getDocumentThread', model, id);
- if (documentThread) {
- this._documentName = documentThread.getName();
- }
- return this._documentName;
- },
- /**
- * Get the email of the sender of this message.
- * If this email has no sender email, returns "".
- *
- * @return {string}
- */
- getEmailFrom: function () {
- if (!this.hasEmailFrom()) {
- return "";
- }
- return this._emailFrom;
- },
- /**
- * @override
- */
- getNotifications() {
- return Object.values(this._notifications);
- },
- /**
- * Get the ID of the channel that this message originates from.
- * If this message does not originate from a channel, returns `-1`.
- *
- * @override
- * @return {integer}
- */
- getOriginChannelID: function () {
- if (!this.originatesFromChannel()) {
- return -1;
- }
- return this._documentID;
- },
- /**
- * Get the name of the channel that this message originates from.
- * If this message does not originate from a channel, returns "".
- *
- * @override
- * @returns {string}
- */
- getOriginChannelName: function () {
- if (!this.originatesFromChannel()) {
- return "";
- }
- var originChannelID = this.getOriginChannelID();
- var channel = originChannelID && this.call(
- 'mail_service',
- 'getChannel',
- originChannelID);
- if (!channel) {
- return "";
- }
- return channel.getName();
- },
- /**
- * Returns the information required to render the preview of this channel.
- *
- * @return {Object}
- */
- getPreview: function () {
- var id, title;
- if (this.isLinkedToDocumentThread()) {
- id = this.getDocumentModel() + '_' + this.getDocumentID();
- title = this.getDocumentName();
- } else {
- id = 'mailbox_inbox';
- title = this.hasSubject() ? this.getSubject() : this.getDisplayedAuthor();
- }
- return {
- author: this.getDisplayedAuthor(),
- body: mailUtils.htmlToTextContentInline(this.getBody()),
- date: this.getDate(),
- documentModel: this.getDocumentModel(),
- documentID: this.getDocumentID(),
- id: id,
- imageSRC: this._getModuleIcon() || this.getAvatarSource(),
- messageID: this.getID(),
- status: this.status,
- title: title,
- isLinkedToDocumentThread: this.isLinkedToDocumentThread(),
- };
- },
- /**
- * Get the subject of this message
- * If this message has no subject, returns "".
- *
- * @override
- * @return {string}
- */
- getSubject: function () {
- if (!this.hasSubject()) {
- return "";
- }
- return this._subject;
- },
- /**
- * @override
- * @return {string}
- */
- getSubtypeDescription: function () {
- return this._subtypeDescription;
- },
- /**
- * Get the list of thread IDs that this message is linked to
- * If this message is not linked to a thread, returns 'undefined'
- *
- * @return {string[]|undefined} list of thread IDs, if any
- */
- getThreadIDs: function () {
- return this._threadIDs;
- },
- /**
- * Get the tracking values of this message
- * If this message has no tracking values, returns 'undefined'
- *
- * @override
- * @return {Object[]|undefined}
- */
- getTrackingValues: function () {
- if (!this.hasTrackingValues()) {
- return undefined;
- }
- return this._trackingValueIDs;
- },
- /**
- * @override
- * @return {string}
- */
- getURL: function () {
- return session.url('/mail/view?message_id=' + this._id);
- },
- /**
- * State whether this message has an email of its sender.
- *
- * @override
- * @return {string}
- */
- hasEmailFrom: function () {
- return !!(this._emailFrom);
- },
- /**
- * State whether this message has a subject
- *
- * @return {boolean}
- */
- hasSubject: function () {
- return !!(this._subject);
- },
- /**
- * @override
- * @return {boolean}
- */
- hasSubtypeDescription: function () {
- return !!(this._subtypeDescription);
- },
- /**
- * State whether this message contains some tracking values
- *
- * @override
- * @return {boolean}
- */
- hasTrackingValues: function () {
- return !!(this._trackingValueIDs && (this._trackingValueIDs.length > 0));
- },
- /**
- * State whether this message is linked to a document thread (not channel)
- *
- * Usually, if this is true, then this message comes from a document thread,
- * but the document model could be a channel. In that case, the document
- * resID tells the channel that this message originally comes from.
- *
- * To detect whether the message comes from a channel, see the method
- * 'originatesFromChannel'.
- *
- * @override
- * @return {boolean}
- */
- isLinkedToDocumentThread: function () {
- return !!(this._documentModel !== 'mail.channel' && this._documentID && this._type !== 'user_notification');
- },
- /**
- * State whether the current user is the author of this message
- *
- * @return {boolean}
- */
- isMyselfAuthor: function () {
- return this._isMyselfAuthor();
- },
- /**
- * States whether the current message needs moderation in general.
- *
- * @override
- * @returns {boolean}
- */
- needsModeration: function () {
- return this._moderationStatus === 'pending_moderation';
- },
- /**
- * States whether the current message needs moderation by the current user.
- * Such a message should be in the moderation mailbox.
- *
- * @returns {boolean}
- */
- needsModerationByUser: function () {
- return _.contains(this._threadIDs, 'mailbox_moderation');
- },
- /**
- * State whether this message is needaction
- *
- * @override
- * @returns {boolean}
- */
- isNeedaction: function () {
- return _.contains(this._threadIDs, 'mailbox_inbox');
- },
- /**
- * State whether this message is starred
- *
- * @override
- * @returns {boolean}
- */
- isStarred: function () {
- return _.contains(this._threadIDs, 'mailbox_starred');
- },
- /**
- * State whether this message is a system notification
- *
- * @override
- * @returns {boolean}
- */
- isSystemNotification: function () {
- return (
- this.getType() === 'notification' &&
- this._documentModel === 'mail.channel'
- ) || this._isTransient();
- },
- /**
- * States whether the message originates from a channel or not
- *
- * @returns {boolean}
- */
- originatesFromChannel: function () {
- return this._documentModel === 'mail.channel';
- },
- /**
- * Unregister thread with ID `threadID` from this message
- *
- * @param {string|integer} threadID ID of thread
- */
- removeThread: function (threadID) {
- this._threadIDs = _.without(this._threadIDs, threadID);
- },
- /**
- * Update the moderation status of the message, so that it is now accepted
- * or rejected. When the message is accepted, it may be linked to more
- * threads, which is the case for relay channels on moderated channels.
- *
- * @param {string} newModerationStatus ['accepted', 'rejected']
- * @param {Object} [options]
- * @param {Object} [options.additionalThreadIDs] contains additional thread
- * IDs to be registered on the message.
- */
- setModerationStatus: function (newModerationStatus, options) {
- var self = this;
- if (newModerationStatus === this._moderationStatus) {
- return;
- }
- this._moderationStatus = newModerationStatus;
- if (newModerationStatus === 'accepted' && options) {
- _.each(options.additionalThreadIDs, function (threadID) {
- self._addThread(threadID);
- });
- }
- this._warnMessageModerated();
- },
- /**
- * Sets notifications based on data from the server.
- *
- * @param {Object} notifications
- */
- setNotifications(notifications) {
- this._notifications = notifications;
- },
- /**
- * Set whether the message is starred or not.
- * If it is starred, the message is moved to the "Starred" mailbox.
- * Note that this function only applies it locally, the server is not aware
- *
- * @param {boolean} starred if set, the message is starred
- */
- setStarred: function (starred) {
- if (starred) {
- this._addThread('mailbox_starred');
- } else {
- this.removeThread('mailbox_starred');
- }
- },
- /**
- * State whether this message should display the subject
- *
- * @return {boolean}
- */
- shouldDisplaySubject: function () {
- return this.hasSubject() &&
- this.getType() !== 'notification' &&
- !this.originatesFromChannel();
- },
- /**
- * State whether this message should redirect to the author
- * when clicking on the author of this message.
- *
- * Do not redirect on author clicked of self-posted or Odoobot messages
- * (note: Odoobot is the default author of transient messages)
- *
- * @override
- * @return {boolean}
- */
- shouldRedirectToAuthor: function () {
- return this._super.apply(this, arguments) && !this._isOdoobotAuthor();
- },
- /**
- * Toggle the star status of the message
- *
- * It relies on the star status of the message from the date of the server.
- * The star status is updated from a 'toggle_star' notification on the
- * longpoll bus
- *
- * @see {mail.Manager.Notification} for the receipt of 'toggle_star'
- * notification after this rpc.
- *
- * @return {Promise}
- */
- toggleStarStatus: function () {
- return this._rpc({
- model: 'mail.message',
- method: 'toggle_message_starred',
- args: [[this._id]],
- });
- },
-
- //--------------------------------------------------------------------------
- // Private
- //--------------------------------------------------------------------------
-
- /**
- * Register a thread to the message
- * Useful when you mark a message as 'to do'.
- * This message will be available in 'Starred' mailbox.
- *
- * @private
- * @param {string|integer} threadID
- */
- _addThread: function (threadID) {
- if (!this._threadIDs) {
- this._threadIDs = [];
- }
- this._threadIDs.push(threadID);
- this._threadIDs = _.uniq(this._threadIDs);
- },
- /**
- * Get the name of the author of this message.
- * If Odoobot is the author (default author for transient messages),
- * returns 'Odoobot'.
- *
- * @override
- * @private
- * @returns {string}
- */
- _getAuthorName: function () {
- if (this._isOdoobotAuthor()) {
- return "OdooBot";
- }
- return this._super.apply(this, arguments);
- },
- /**
- * @private
- * @return {string}
- */
- _getModuleIcon: function () {
- return this._moduleIcon;
- },
- /**
- * State if the author of this message is OdooBot
- * This is the default author for transient messages.
- *
- * @private
- * @return {boolean}
- */
- _isOdoobotAuthor: function () {
- return this._serverAuthorID &&
- this._serverAuthorID[0] === this.call('mail_service', 'getOdoobotID')[0];
- },
- /**
- * State whether the message is transient or not
- *
- * @private
- * @return {boolean}
- */
- _isTransient: function () {
- return this._info === 'transient_message';
- },
- /**
- * Convert the server-format body of the message to the client-format.
- * Basically, it processes emojis and url.
- *
- * @private
- */
- _processBody: function () {
- var self = this;
-
- _.each(emojis, function (emoji) {
- var unicode = emoji.unicode;
- var regexp = new RegExp("(?:^|\\s|<[a-z]*>)(" + unicode + ")(?=\\s|$|</[a-z]*>)", 'g');
- var originalBody = self._body;
- self._body = self._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(self._body, 'o_mail_emoji') > 200) {
- self._body = originalBody;
- }
- });
-
- // add anchor tags to urls
- self._body = mailUtils.parseAndTransform(self._body, mailUtils.addLink);
- },
- /**
- * If the message is linked to a document, link this message to the related
- * document thread. If there is no document thread yet, create it
- */
- _processDocumentThread: function () {
- if (!this.isLinkedToDocumentThread()) {
- return;
- }
- var resModel = this.getDocumentModel();
- var resID = this.getDocumentID();
- var documentThread = this.call('mail_service', 'getOrAddDocumentThread', {
- resModel: resModel,
- resID: resID,
- name: this.getDocumentName(),
- });
- this._threadIDs.push(documentThread.getID());
- },
- /**
- * Set the appropriate mailboxes to this message based on server data
- *
- * @private
- */
- _processMailboxes: function () {
- if (_.contains(this._needactionPartnerIDs, session.partner_id)) {
- this._setNeedaction(true);
- }
- if (_.contains(this._starredPartnerIDs, session.partner_id)) {
- this.setStarred(true);
- }
- if (_.contains(this._historyPartnerIDs, session.partner_id)) {
- this._setHistory(true);
- }
- if (
- this.originatesFromChannel() &&
- _.contains(
- this.call('mail_service', 'getModeratedChannelIDs'),
- this.getOriginChannelID()
- ) &&
- this.needsModeration()
- ) {
- this._setModeratedByUser(true);
- }
- },
- /**
- * Do some extra processing at message init, related to the
- * moderated status of the message.
- *
- * If the message needs moderation, it is not yet linked to the
- * moderated channel server-side. Therefore, it is not registered
- * in the list of threadIDs, as it is built on server-side information
- * at message initialisation (using data.channel_ids).
- *
- * Since the web client uses the list of thread IDs to show visually the
- * message in a thread, we should hack the response of the server so that
- * it assumes the message really belongs to this thread.
- *
- * @private
- */
- _processModeration: function () {
- if (this.needsModeration()) {
- // the message is not linked to the moderated channel on the
- // server, therefore this message has not this channel in
- // channel_ids. Here, just to show this message in the channel
- // visually, it links this message to the channel
- this._threadIDs.push(this.getOriginChannelID());
- }
- },
- /**
- * Process the tracking values on message creation, which
- * basically format date to the local only once by message
- *
- * Cannot be done in preprocess, since it alter the original value
- *
- * @private
- */
- _processTrackingValues: function () {
- if (this.hasTrackingValues()) {
- _.each(this.getTrackingValues(), function (trackingValue) {
- if (trackingValue.field_type === 'datetime') {
- if (trackingValue.old_value) {
- trackingValue.old_value =
- moment
- .utc(trackingValue.old_value)
- .local()
- .format('LLL');
- }
- if (trackingValue.new_value) {
- trackingValue.new_value =
- moment
- .utc(trackingValue.new_value)
- .local()
- .format('LLL');
- }
- } else if (trackingValue.field_type === 'date') {
- if (trackingValue.old_value) {
- trackingValue.old_value =
- moment(trackingValue.old_value)
- .local()
- .format('LL');
- }
- if (trackingValue.new_value) {
- trackingValue.new_value =
- moment(trackingValue.new_value)
- .local()
- .format('LL');
- }
- }
- });
- }
- },
- /**
- * @private
- * @param {Object} data
- * @param {string} [data.body = ""]
- * @param {(string|integer)[]} [data.channel_ids]
- * @param {Object[]} [data.notifications]
- * @param {string} [data.email_from]
- * @param {string} [data.info]
- * @param {string} [data.model]
- * @param {string} [data.moderation_status='accepted']
- * @param {string} [data.module_icon]
- * @param {Array} [data.needaction_partner_ids = []]
- * @param {Array} [data.history_partner_ids = []]
- * @param {string} [data.record_name]
- * @param {integer} [data.res_id]
- * @param {Array} [data.starred_partner_ids = []]
- * @param {string} [data.subject]
- * @param {string} [data.subtype_description]
- * @param {Object[]} [data.tracking_value_ids]
- */
- _setInitialData(data) {
- this._notifications = data.notifications || {};
- this._documentModel = data.model;
- this._documentName = data.record_name;
- this._documentID = data.res_id;
- this._emailFrom = data.email_from;
- this._info = data.info;
- this._isNote = data.is_note;
- this._moduleIcon = data.module_icon;
- this._needactionPartnerIDs = data.needaction_partner_ids || [];
- this._starredPartnerIDs = data.starred_partner_ids || [];
- this._historyPartnerIDs = data.history_partner_ids || [];
- this._subject = data.subject;
- this._subtypeDescription = data.subtype_description;
- this._threadIDs = data.channel_ids || [];
- this._trackingValueIDs = data.tracking_value_ids;
-
- this._moderationStatus = data.moderation_status || 'accepted';
- },
- /*
- * Set whether the message is history or not.
- * If it is history, the message is moved to the "History" mailbox.
- * Note that this function only applies it locally, the server is not aware
- *
- * @private
- * @param {boolean} history if set, the message is history
- */
- _setHistory: function (history) {
- if (history) {
- this._addThread('mailbox_history');
- } else {
- this.removeThread('mailbox_history');
- }
- },
- /**
- * Set whether the message is moderated by current user or not.
- * If it is moderated by the current user, the message is moved to the
- * "Moderation" mailbox. Note that this function only applies it locally,
- * the server is not aware
- *
- * @private
- * @param {boolean} moderated if set, the message is moderated by user
- */
- _setModeratedByUser: function (moderated) {
- if (moderated) {
- this._addThread('mailbox_moderation');
- } else {
- this.removeThread('mailbox_moderation');
- }
- },
- /**
- * Set whether the message is needaction or not.
- * If it is needaction, the message is moved to the "Inbox" mailbox.
- * Note that this function only applies it locally, the server is not aware
- *
- * @private
- * @param {boolean} needaction if set, the message is needaction
- */
- _setNeedaction: function (needaction) {
- if (needaction) {
- this._addThread('mailbox_inbox');
- } else {
- this.removeThread('mailbox_inbox');
- }
- },
- /**
- * @private
- */
- _warnMessageModerated: function () {
- var mailBus = this.call('mail_service', 'getMailBus');
- if (this.needsModerationByUser()) {
- this._setModeratedByUser(false);
- var moderationBox = this.call('mail_service', 'getMailbox', 'moderation');
- moderationBox.decrementMailboxCounter();
- moderationBox.removeMessage(this.getID());
- mailBus.trigger('update_moderation_counter');
- }
- if (this._moderationStatus !== 'accepted') {
- this.call('mail_service', 'removeMessageFromThreads', this);
- }
- mailBus.trigger('update_message', this);
- },
-
-});
-
-return Message;
-
-});
diff --git a/addons/mail/static/src/js/models/threads/abstract_thread.js b/addons/mail/static/src/js/models/threads/abstract_thread.js
deleted file mode 100644
index 619bf95b8e3..00000000000
--- a/addons/mail/static/src/js/models/threads/abstract_thread.js
+++ /dev/null
@@ -1,231 +0,0 @@
-odoo.define('mail.model.AbstractThread', function (require) {
-"use strict";
-
-var Class = require('web.Class');
-var Mixins = require('web.mixins');
-
-/**
- * Abstract thread is the super class of all threads, either backend threads
- * (which are compatible with mail service) or website livechats.
- *
- * Abstract threads contain abstract messages
- */
-var AbstractThread = Class.extend(Mixins.EventDispatcherMixin, {
- /**
- * @param {Object} params
- * @param {Object} params.data
- * @param {integer|string} params.data.id the ID of this thread
- * @param {string} params.data.name the name of this thread
- * @param {string} [params.data.status=''] the status of this thread
- * @param {Object} params.parent Object with the event-dispatcher mixin
- * (@see {web.mixins.EventDispatcherMixin})
- */
- init: function (params) {
- Mixins.EventDispatcherMixin.init.call(this, arguments);
- this.setParent(params.parent);
-
- this._folded = false; // threads are unfolded by default
- this._id = params.data.id;
- this._name = params.data.name;
- this._status = params.data.status || '';
- this._unreadCounter = 0; // amount of messages not yet been read
- },
-
- //--------------------------------------------------------------------------
- // Public
- //--------------------------------------------------------------------------
-
- /**
- * Add a message to this thread.
- *
- * @param {mail.model.AbstractMessage} message
- */
- addMessage: function (message) {
- this._addMessage.apply(this, arguments);
- this.trigger('message_added', message);
- },
- /**
- * Updates the folded state of the thread
- *
- * @param {boolean} folded
- */
- fold: function (folded) {
- this._folded = folded;
- },
- /**
- * Get the ID of this thread
- *
- * @returns {integer|string}
- */
- getID: function () {
- return this._id;
- },
- /**
- * @abstract
- * @returns {mail.model.AbstractMessage[]}
- */
- getMessages: function () {},
- /**
- * Get the name of this thread. If the name of the thread has been created
- * by the user from an input, it may be escaped.
- *
- * @returns {string}
- */
- getName: function () {
- return this._name;
- },
- /**
- * By default, threads do not have out-of-office feature
- *
- * @return {string|undefined}
- */
- getOutOfOfficeInfo: function () {
- return undefined;
- },
- /**
- * Get the status of the thread (e.g. 'online', 'offline', etc.)
- *
- * @returns {string}
- */
- getStatus: function () {
- return this._status;
- },
- /**
- * Returns the title to display in thread window's headers.
- *
- * @returns {string} the name of the thread by default (see @getName)
- */
- getTitle: function () {
- return this.getName();
- },
- getType: function () {},
- /**
- * @returns {integer}
- */
- getUnreadCounter: function () {
- return this._unreadCounter;
- },
- /**
- * @returns {boolean}
- */
- hasMessages: function () {
- return !_.isEmpty(this.getMessages());
- },
- /**
- * @return {boolean}
- */
- hasOutOfOffice() {
- return !!this.getOutOfOfficeInfo();
- },
- /**
- * States whether this thread is compatible with the 'seen' feature.
- * By default, threads do not have thsi feature active.
- * @see {mail.model.ThreadSeenMixin} to enable this feature on a thread.
- *
- * @returns {boolean}
- */
- hasSeenFeature: function () {
- return false;
- },
- /**
- * States whether this thread is compatible with the 'is typing...' feature.
- * By default, threads do not have this feature active.
- * @see {mail.model.ThreadTypingMixin} to enable this feature on a thread.
- *
- * @returns {boolean}
- */
- hasTypingNotification: function () {
- return false;
- },
- /**
- * States whether this thread is folded or not.
- *
- * @return {boolean}
- */
- isFolded: function () {
- return this._folded;
- },
- /**
- * Mark the thread as read, which resets the unread counter to 0. This is
- * only performed if the unread counter is not 0.
- *
- * @returns {Promise}
- */
- markAsRead: function () {
- if (this._unreadCounter > 0) {
- return this._markAsRead();
- }
- return Promise.resolve();
- },
- /**
- * Post a message on this thread
- *
- * @returns {Promise} resolved with the message object to be sent to the
- * server
- */
- postMessage: function () {
- return this._postMessage.apply(this, arguments)
- .then(this.trigger.bind(this, 'message_posted'));
- },
- /**
- * Resets the unread counter of this thread to 0.
- */
- resetUnreadCounter: function () {
- this._unreadCounter = 0;
- this._warnUpdatedUnreadCounter();
- },
-
- //--------------------------------------------------------------------------
- // Private
- //--------------------------------------------------------------------------
-
- /**
- * Add a message to this thread.
- *
- * @abstract
- * @private
- * @param {mail.model.AbstractMessage} message
- */
- _addMessage: function (message) {},
- /**
- * Increments the unread counter of this thread by 1 unit.
- *
- * @private
- */
- _incrementUnreadCounter: function () {
- this._unreadCounter++;
- },
- /**
- * Mark the thread as read
- *
- * @private
- * @returns {Promise}
- */
- _markAsRead: function () {
- this.resetUnreadCounter();
- return Promise.resolve();
- },
- /**
- * Post a message on this thread
- *
- * @abstract
- * @private
- * @returns {Promise} resolved with the message object to be sent to the
- * server
- */
- _postMessage: function () {
- return Promise.resolve();
- },
- /**
- * Warn views (e.g. discuss app, thread window, etc.) to update visually
- * the unread counter of this thread.
- *
- * @abstract
- * @private
- */
- _warnUpdatedUnreadCounter: function () {},
-});
-
-return AbstractThread;
-
-});
diff --git a/addons/mail/static/src/js/models/threads/channel.js b/addons/mail/static/src/js/models/threads/channel.js
deleted file mode 100644
index dced861c00a..00000000000
--- a/addons/mail/static/src/js/models/threads/channel.js
+++ /dev/null
@@ -1,474 +0,0 @@
-odoo.define('mail.model.Channel', function (require) {
-"use strict";
-
-var SearchableThread = require('mail.model.SearchableThread');
-var ThreadTypingMixin = require('mail.model.ThreadTypingMixin');
-var mailUtils = require('mail.utils');
-
-const config = require('web.config');
-var session = require('web.session');
-var time = require('web.time');
-
-/**
- * This class represent channels in JS. In this context, the word channel
- * has the same meaning of channel on the server, meaning that direct messages
- * (DM) and livechats are also channels.
- *
- * Any piece of code in JS that make use of channels must ideally interact with
- * such objects, instead of direct data from the server.
- */
-var Channel = SearchableThread.extend(ThreadTypingMixin, {
- /**
- * @override
- * @param {Object} params
- * @param {Object} params.data
- * @param {string} params.data.channel_type
- * @param {integer} [params.data.create_uid] the ID of the user that has
- * created the channel.
- * @param {boolean} params.data.group_based_subscription
- * @param {boolean} [params.data.is_minimized=false]
- * @param {boolean} params.data.is_moderator whether the current user is
- * moderator of this channel.
- * @param {string} [params.data.last_message_date] date in server-format
- * @param {Object[]} [params.data.members=[]]
- * @param {integer} [params.data.members[i].id]
- * @param {string} [params.data.members[i].name]
- * @param {string} [params.data.members[i].email]
- * @param {integer} [params.data.message_unread_counter]
- * @param {boolean} [params.data.moderation=false] whether the channel is
- * moderated or not
- * @param {Object[]} [params.data.partners_info=[]]
- * @param {integer} [params.data.partners_info[i].partner_id]
- * @param {integer} [params.data.partners_info[i].fetched_message_id]
- * @param {integer} [params.data.partners_info[i].seen_message_id]
- * @param {string} params.data.state
- * @param {string} [params.data.uuid]
- * @param {Object} params.options
- * @param {boolean} [params.options.autoswitch=true]
- * @param {Object[]} params.commands
- */
- init: function (params) {
- var self = this;
- this._super.apply(this, arguments);
- ThreadTypingMixin.init.apply(this, arguments);
-
- var data = params.data;
- var options = params.options;
- var commands = params.commands;
-
- // If set, autoswitch channel on joining this channel in discuss
- // the default behaviour is to autoswitch on join.
- // exception: receiving channel session notifications
- this._autoswitch = 'autoswitch' in options ? options.autoswitch : true;
- this._commands = undefined;
- this._creatorUID = data.create_uid;
- this._detached = data.is_minimized || false;
- this._directPartnerID = undefined;
- this._folded = data.state === 'folded';
- // if set: hide 'Leave channel' button
- this._groupBasedSubscription = data.group_based_subscription;
- this._isModerated = data.moderation;
- this._isMyselfModerator = data.is_moderator;
- this._lastMessageDate = undefined;
- this._members = data.members || [];
- // Promise that is resolved on fetched members of this channel.
- this._membersDef = undefined;
- // number of messages that are 'needaction', which is equivalent to the
- // number of messages in this channel that are in inbox.
- this._needactionCounter = data.message_needaction_counter || 0;
- this._serverType = data.channel_type;
- // unique identifier for this channel, which is required for some rpc
- this._uuid = data.uuid;
-
- // * list of commands available for this channel
- this._commands = _.filter(commands, function (command) {
- return !command.channel_types ||
- _.contains(command.channel_types, self._serverType);
- });
- if (data.last_message_date) {
- this._lastMessageDate = moment(time.str_to_datetime(data.last_message_date));
- }
- if (data.message_unread_counter) {
- this._unreadCounter = data.message_unread_counter;
- }
- },
-
- //--------------------------------------------------------------------------
- // Public
- //--------------------------------------------------------------------------
-
- /**
- * Close the channel
- *
- * This operation is executed server-side and the chat window will be
- * folded in all potential tabs from all browsers.
- *
- * @override
- */
- close: function () {
- this._super.apply(this, arguments);
- // Do not notify the server to avoid desktop chat window from closing
- // when a chat window is closed on mobile.
- if (!config.device.isMobile) {
- this._rpc({
- model: 'mail.channel',
- method: 'channel_fold',
- kwargs: { uuid: this.getUUID(), state: 'closed' },
- }, { shadow: true });
- }
- },
- /**
- * Decrement the needaction counter of the channel
- * Floor value at 0.
- *
- * @param {integer} [num=1] the amount to decrement at most
- */
- decrementNeedactionCounter: function (num) {
- num = _.isNumber(num) ? num : 1;
- this._needactionCounter = Math.max(this._needactionCounter - 1, 0);
- },
- /**
- * Open the chat window for this channel
- * (in all potential tabs from all browsers)
- *
- * @pverride
- */
- detach: function () {
- var self = this;
- return this._super.apply(this, arguments).then(function () {
- // Do not notify the server to avoid desktop chat window from opening
- // when a chat window is opened on mobile.
- if (!config.device.isMobile) {
- self._rpc({
- model: 'mail.channel',
- method: 'channel_minimize',
- args: [self.getUUID(), true],
- }, {
- shadow: true,
- });
- }
- });
- },
- /**
- * Force fetch members of the channel
- *
- * @returns {Promise<Object[]>} resolved with list of channel listeners
- */
- forceFetchMembers: function () {
- this._membersDef = undefined;
- return this.getMentionPartnerSuggestions();
- },
- /**
- * Folds/Minimize the channel
- * (in all potential tabs from all browsers)
- *
- * @override
- * @param {boolean} folded
- */
- fold: function (folded) {
- this._super.apply(this, arguments);
- var args = {
- uuid: this.getUUID(),
- };
- if (_.isBoolean(folded)) {
- args.state = folded ? 'folded' : 'open';
- }
- this._rpc({
- model: 'mail.channel',
- method: 'channel_fold',
- kwargs: args,
- }, {shadow: true});
- },
- /**
- * Show the list of available commands on this channel (e.g. '/help')
- *
- * @override
- * @returns {Array} list of commands
- */
- getCommands: function () {
- return this._commands;
- },
- /**
- * @returns {mail.model.Message|undefined} last message of channel, if any
- */
- getLastMessage: function () {
- return _.last(this._cache['[]'].messages);
- },
- /**
- * Get listeners of a channel
- *
- * @returns {Promise<Object[]>} resolved with list of list of
- * channel listeners.
- */
- getMentionPartnerSuggestions: function () {
- var self = this;
- if (!this._membersDef) {
- this._membersDef = this._rpc({
- model: 'mail.channel',
- method: 'channel_fetch_listeners',
- args: [this.getUUID()],
- }, {
- shadow: true
- })
- .then(function (members) {
- self._members = members;
- return [members];
- });
- }
- return this._membersDef;
- },
- /**
- * @returns {integer}
- */
- getNeedactionCounter: function () {
- return this._needactionCounter;
- },
- /**
- * @override
- */
- getPreview: function () {
- var result = this._super.apply(this, arguments);
- if (!this.isTwoUserThread()) {
- result.imageSRC = '/web/image/mail.channel/' + this.getID() + '/image_128';
- }
- var lastMessage = this.getLastMessage();
- return _.extend(result, {
- author: lastMessage ? lastMessage.getDisplayedAuthor() : '',
- body: lastMessage ? mailUtils.htmlToTextContentInline(lastMessage.getBody()) : '',
- date: lastMessage ? lastMessage.getDate() : undefined,
- isMyselfAuthor: this.hasMessages() && this.getLastMessage().isMyselfAuthor(),
- });
- },
- /**
- * Get the UUID of the channel.
- * This is a string that uniquely links this channel to a server channel.
- *
- * @returns {string} uuid of this channel
- */
- getUUID: function () {
- return this._uuid;
- },
- /**
- * Increment the needaction counter of this channel by 1 unit
- */
- incrementNeedactionCounter: function () {
- this._needactionCounter++;
- },
- /**
- * States whether the channel should be auto-selected on creation
- *
- * Note that this is not of the responsibility of the channel
- * (see mail.model.Thread for more information)
- *
- * @override
- * @returns {boolean}
- */
- isAutoswitch: function () {
- return this._autoswitch;
- },
- /**
- * All instances of this class are chanels
- *
- * @override
- * @returns {boolean}
- */
- isChannel: function () {
- return true;
- },
- /**
- * States whether the channel auto-subscribes some users in a group
- *
- * @returns {boolean}
- */
- isGroupBasedSubscription: function () {
- return this._groupBasedSubscription;
- },
- /**
- * States whether the channel is moderated or not.
- *
- * @override
- * @returns {boolean}
- */
- isModerated: function () {
- return this._isModerated;
- },
- /**
- * Tells whether the current user is administrator of the channel.
- * Note that there is no administrator for two-user channels
- *
- * @returns {boolean}
- */
- isMyselfAdministrator: function () {
- return session.uid === this._creatorUID && !this.isTwoUserThread();
- },
- /**
- * States whether the current user is moderator of this channel.
- *
- * @returns {boolean}
- */
- isMyselfModerator: function () {
- return this._isMyselfModerator;
- },
- /**
- * Unsubscribes from channel
- *
- * @abstract
- * @returns {Promise} resolve when unsubscribed
- */
- unsubscribe: function () {},
- /**
- * Updates the internal state of the channel, and reflects the changes in
- * the UI.
- *
- * Called by {mail.Manager.Notification} on receiving a channel window
- * notification. A notification is received after a client-side action
- * that changes the state of the channel window visually, so that cross tab
- * channels are updated similarly.
- *
- * TODO: This method should be dropped if the state of windows are
- * completely handled client-side, which should be the case in the near
- * future.
- *
- * @param {Object} params
- * @param {boolean} params.detached
- * @param {boolean} params.folded
- */
- updateWindowState: function (params) {
- this._folded = 'folded' in params ? params.folded : this._folded;
- this._detached = 'detached' in params ? params.detached : this._detached;
- this._warnUpdatedWindowState();
- },
- /**
- * Reset the needaction counter to 0.
- */
- resetNeedactionCounter: function () {
- this._needactionCounter = 0;
- },
-
- //--------------------------------------------------------------------------
- // Private
- //--------------------------------------------------------------------------
-
- /**
- * Override so that it tells whether the channel is moderated or not. This
- * is useful in order to display pending moderation messages when the
- * current user is either moderator of the channel or has posted some
- * messages that are pending moderation.
- *
- * @override
- * @private
- * @returns {Object}
- */
- _getFetchMessagesKwargs: function () {
- var kwargs = this._super.apply(this, arguments);
- if (this.isModerated()) {
- kwargs.moderated_channel_ids = [this.getID()];
- }
- return kwargs;
- },
- /**
- * Get the domain to fetch all the messages in the current channel
- *
- * @override
- * @private
- * @returns {Array}
- */
- _getThreadDomain: function () {
- return [['channel_ids', 'in', [this._id]]];
- },
- /**
- * @override {mail.model.ThreadTypingMixin}
- * @private
- * @param {Object} params
- * @param {integer} params.partnerID
- */
- _isTypingMyselfInfo: function (params) {
- return session.partner_id === params.partnerID;
- },
- /**
- * Marks this channel as read.
- * The last seen message will be the last message.
- * Resolved with the last seen message, only for non-mailbox channels
- *
- * @override
- * @private
- * @returns {Promise} resolved when message has been marked as read
- */
- _markAsRead: function () {
- var superDef = this._super.apply(this, arguments);
- var seenDef = this._notifySeen();
- return Promise.all([superDef, seenDef]);
- },
- /**
- * @override {mail.model.ThreadTypingMixin}
- * @private
- * @param {Object} params
- * @param {boolean} params.typing
- * @returns {Promise}
- */
- _notifyMyselfTyping: function (params) {
- return this._rpc({
- model: 'mail.channel',
- method: 'notify_typing',
- args: [this.getID()],
- kwargs: { is_typing: params.typing },
- }, { shadow: true });
- },
- /**
- * @private
- * @returns {$.Promise<integer>} resolved with ID of last seen message
- */
- _notifySeen: function () {
- var self = this;
- return this._rpc({
- model: 'mail.channel',
- method: 'channel_seen',
- args: [[this._id]],
- }, { shadow: true }).then(function (lastSeenMessageID) {
- self._lastSeenMessageID = lastSeenMessageID;
- return lastSeenMessageID;
- });
- },
- /**
- * Prepare and send a message to the server on this channel.
- *
- * @override
- * @private
- * @param {Object} data data related to the new message
- * @returns {Promise<Object>} resolved when the message has been sent to
- * the server, with the object message that has been sent to the server.
- */
- _postMessage: function (data) {
- var self = this;
- return this._super.apply(this, arguments).then(function (messageData) {
- _.extend(messageData, {
- message_type: 'comment',
- subtype_xmlid: 'mail.mt_comment',
- command: data.command,
- });
- return self._rpc({
- model: 'mail.channel',
- method: data.command ? 'execute_command' : 'message_post',
- args: [self._id],
- kwargs: messageData,
- }).then(function () {
- return messageData;
- });
- });
- },
- /**
- * Warn views that the list of users that are currently typing on this
- * thread has been updated.
- *
- * @override {mail.model.ThreadTypingMixin}
- * @private
- */
- _warnUpdatedTypingPartners: function () {
- this.call('mail_service', 'getMailBus')
- .trigger('update_typing_partners', this.getID());
- },
-});
-
-return Channel;
-
-});
diff --git a/addons/mail/static/src/js/models/threads/create_mode_document_thread.js b/addons/mail/static/src/js/models/threads/create_mode_document_thread.js
deleted file mode 100644
index da632de0470..00000000000
--- a/addons/mail/static/src/js/models/threads/create_mode_document_thread.js
+++ /dev/null
@@ -1,75 +0,0 @@
-odoo.define('mail.model.CreateModeDocumentThread', function (require) {
-"use strict";
-
-var AbstractMessage = require('mail.model.AbstractMessage');
-var AbstractThread = require('mail.model.AbstractThread');
-
-var core = require('web.core');
-var session = require('web.session');
-
-var _t = core._t;
-
-/**
- * Special document thread that is linked to a new document that is in create
- * mode. This thread only display a single (fake) message which displays the
- * user avatar and the message 'Creating a new record...'.
- *
- * Note: it uses AbstractThread and AbstractMessage, because this is a fake
- * thread which contains a fake message. We do not want to register both of them
- * in the mail service.
- */
-var CreateModeDocumentThread = AbstractThread.extend({
-
- /**
- * @override
- */
- init: function () {
- var params = {
- data: {
- id: '_createModeDocumentThread'
- }
- };
-
- this._super(params);
-
- this._messages = this._forgeCreateMessages();
- },
-
- //--------------------------------------------------------------------------
- // Public
- //--------------------------------------------------------------------------
-
- /**
- * @returns {mail.model.AbstractMessage[]}
- */
- getMessages: function () {
- return this._messages;
- },
-
- //--------------------------------------------------------------------------
- // Private
- //--------------------------------------------------------------------------
-
- /**
- * Make a fake list of messages to render in create mode.
- * Instead of rendering no messages at all, it displays a single message
- * with body content "Creating a new record".
- *
- * @private
- * @returns {mail.model.AbstractMessage[]} an array containing a single
- * message 'Creating a new record...'
- */
- _forgeCreateMessages: function () {
- var createMessage = new AbstractMessage(this, {
- id: 0,
- body: _t("<p>Creating a new record...</p>"),
- author_id: [session.partner_id, session.partner_display_name],
- });
- return [createMessage];
- },
-
-});
-
-return CreateModeDocumentThread;
-
-});
diff --git a/addons/mail/static/src/js/models/threads/dm_chat.js b/addons/mail/static/src/js/models/threads/dm_chat.js
deleted file mode 100644
index 177c5224ea4..00000000000
--- a/addons/mail/static/src/js/models/threads/dm_chat.js
+++ /dev/null
@@ -1,109 +0,0 @@
-odoo.define('mail.model.DMChat', function (require) {
-"use strict";
-
-var ChannelSeenMixin = require('mail.model.ChannelSeenMixin');
-var TwoUserChannel = require('mail.model.TwoUserChannel');
-
-var core = require('web.core');
-const { str_to_datetime } = require('web.time');
-
-var _t = core._t;
-
-/**
- * Any piece of code in JS that make use of DMs must ideally interact with
- * such objects, instead of direct data from the server.
- */
-var DMChat = TwoUserChannel.extend(ChannelSeenMixin, {
- /**
- * @override
- * @param {Object} params
- * @param {Object} params.data
- * @param {string|undefined} [params.custom_channel_name] if set, use this
- * custom name for this DM
- * @param {Object[]} params.data.direct_partner
- * @param {integer} params.data.direct_partner[0].id
- * @param {string} params.data.direct_partner[0].im_status
- * @param {string} params.data.direct_partner[0].name
- * @param {string} [params.data.direct_partner[0].out_of_office_date_end]
- */
- init: function (params) {
- this._super.apply(this, arguments);
- ChannelSeenMixin.init.apply(this, arguments);
-
- var data = params.data;
-
- this._directPartnerID = data.direct_partner[0].id;
- this._name = data.custom_channel_name || data.direct_partner[0].name;
- this._outOfOfficeDateEnd = data.direct_partner[0].out_of_office_date_end;
- this._type = 'dm_chat';
-
- this.call('mail_service', 'updateImStatus', [{
- id: this._directPartnerID,
- im_status: data.direct_partner[0].im_status
- }]);
- },
-
- //--------------------------------------------------------------------------
- // Public
- //--------------------------------------------------------------------------
-
- /**
- * Get the direct partner ID linked to the DM, i.e. the partner ID of the
- * user at the other end of the DM conversation. All DM chats do have a
- * direct partner iD.
- *
- * @returns {integer}
- */
- getDirectPartnerID: function () {
- return this._directPartnerID;
- },
- /**
- * Get the out of office info
- *
- * @override {mail.model.AbstractThread}
- * @returns {string|undefined}
- */
- getOutOfOfficeInfo: function () {
- if (!this._outOfOfficeDateEnd) {
- return undefined;
- }
- var currentDate = new Date();
- var date = str_to_datetime(this._outOfOfficeDateEnd);
- var options = { day: 'numeric', month: 'short' };
- if (currentDate.getFullYear() !== date.getFullYear()) {
- options.year = 'numeric';
- }
- var formattedDate = date.toLocaleDateString(window.navigator.language, options);
- return _.str.sprintf(_t("Out of office until %s"), formattedDate);
- },
- /**
- * @override
- */
- getPreview: function () {
- var result = this._super.apply(this, arguments);
- result.imageSRC = '/web/image/res.partner/' + this.getDirectPartnerID() + '/image_128';
- return result;
- },
- /**
- * @override
- * @return {string}
- */
- getStatus: function () {
- return this.call('mail_service', 'getImStatus', { partnerID: this._directPartnerID });
- },
-
- /**
- * @param {Object} data
- * @param {string} data.outOfOfficeDateEnd
- */
- updateOutOfOfficeInfo: function (data) {
- this._outOfOfficeDateEnd = data.outOfOfficeDateEnd;
- this.call('mail_service', 'getMailBus').trigger('updated_out_of_office', {
- threadID: this.getID(),
- });
- }
-});
-
-return DMChat;
-
-});
diff --git a/addons/mail/static/src/js/models/threads/document_thread.js b/addons/mail/static/src/js/models/threads/document_thread.js
deleted file mode 100644
index eefc8f9bd79..00000000000
--- a/addons/mail/static/src/js/models/threads/document_thread.js
+++ /dev/null
@@ -1,364 +0,0 @@
-odoo.define('mail.model.DocumentThread', function (require) {
-"use strict";
-
-var Thread = require('mail.model.Thread');
-
-var session = require('web.session');
-
-/**
- * These threads represent threads that are directly attached to documents.
- * It is sometimes called the "chatter", but technically this is just the
- * part of the chatter containing the thread, as the chatter also contains
- * the activity and listener fields.
- *
- * Note that there are still some hacks in the handling of document threads,
- * due to document threads are instantiated from fetched messages, either from
- * mail.ThreadField or by fetching messages in Inbox.
- *
- * Also, because messages are not sent on the longpoll bus for this kind of
- * threads, it stores the list of message IDs that it contains, which should
- * be up-to-date whenever someone wants to get the messages in the document
- * thread.
- */
-var DocumentThread = Thread.extend({
-
- /**
- * @override
- * @param {Object} params
- * @param {Object} params.data
- * @param {string} [params.data.messagesIDs] the list of message ids linked
- * to the document (if not given, they will be fetched before fetching the
- * messages)
- * @param {string} params.data.model
- * @param {integer} params.data.resID
- */
- init: function (params) {
- var data = params.data;
- data.id = data.resModel + '_' + data.resID;
- data.type = 'document_thread';
-
- this._super.apply(this, arguments);
-
- // ID of the related document
- this._documentID = data.resID;
- // model of the related document
- this._documentModel = data.resModel;
- // used to handle history of messages
- this._messageIDs = data.messageIDs || [];
- // list of loaded messages
- this._messages = [];
-
- // if the messageIDs haven't been given, fetch them before fetching the
- // messages
- this._mustFetchMessageIDs = !_.isArray(data.messageIDs);
- },
-
- //--------------------------------------------------------------------------
- // Public
- //--------------------------------------------------------------------------
-
- /**
- * Overrides to store the thread's state in the LocalStorage, so that it is
- * shared between tabs, and restored on F5.
- *
- * @override
- * @param {Object} [options]
- * @param {boolean} [options.skipCrossTabSync=false] if set, it should
- * not notify other tabs from updating document thread state. This happens
- * in case the `close` operation comes from local storage event.
- */
- close: function (options) {
- this._super.apply(this, arguments);
- if (options && options.skipCrossTabSync) {
- return;
- }
- this.call('mail_service', 'updateDocumentThreadState', this._id, {
- name: this.getName(),
- windowState: 'closed',
- });
- },
- /**
- * Overrides to store the thread's state in the LocalStorage, so that it is
- * shared between tabs, and restored on F5.
- *
- * @override
- * @param {Object} [options]
- * @param {boolean} [options.skipCrossTabSync=false] if set, it should
- * not notify other tabs from updating document thread state. This happens
- * in case the `detach` operation comes from local storage event.
- */
- detach: function (options) {
- this._super.apply(this, arguments);
- if (options && options.skipCrossTabSync) {
- return;
- }
- var windowState = this._folded ? 'folded' : 'open';
- this.call('mail_service', 'updateDocumentThreadState', this._id, {
- name: this.getName(),
- windowState: windowState,
- });
- },
- /**
- *
- * Fetch messages of the document thread
- *
- * @override
- * @param {Object} options
- * @param {boolean} [options.forceFetch] if true, fetch anyway, as user
- * clicked on 'load more'.
- * @returns {Promise<mail.model.Message[]>}
- */
- fetchMessages: function (options) {
- var self = this;
- return this._fetchMessages(options).then(function () {
- return self._messages;
- });
- },
- /**
- * Overrides to store the thread's state in the LocalStorage, so that it is
- * shared between tabs, and restored on F5.
- *
- * @override
- * @param {Object} [options]
- * @param {boolean} [options.skipCrossTabSync=false] if set, it should
- * not notify other tabs from updating document thread state. This happens
- * in case the `detach` operation comes from local storage event.
- */
- fold: function (folded, options) {
- this._super.apply(this, arguments);
- if (options && options.skipCrossTabSync) {
- return;
- }
- var windowState = this._folded ? 'folded' : 'open';
- this.call('mail_service', 'updateDocumentThreadState', this._id, {
- name: this.getName(),
- windowState: windowState,
- });
- },
- /**
- * Get the model name of the document that is linked to this document thread
- *
- * @returns {string}
- */
- getDocumentModel: function () {
- return this._documentModel;
- },
- /**
- * Get the ID of the document that is linked to this document thread
- *
- * @returns {integer}
- */
- getDocumentID: function () {
- return this._documentID;
- },
- /**
- * Get he list of message IDs that this document threads contain.
- *
- * @returns {integer[]}
- */
- getMessageIDs: function () {
- return this._messageIDs;
- },
- /**
- * @override
- * @returns {mail.model.Message[]}
- */
- getMessages: function () {
- return this._messages;
- },
- /**
- * States whether the thread is linked to a document
- * Document thread's are always linked to a document.
- *
- * @override
- * @returns {boolean}
- */
- isLinkedToDocument: function () {
- return true;
- },
- /**
- * @param {integer[]} attachmentIDs
- */
- removeAttachmentsFromMessages: function (attachmentIDs) {
- _.each(this.getMessages(), function (message) {
- message.removeAttachments(attachmentIDs);
- });
- },
- /**
- * Set list of message IDs of this document thread
- *
- * Useful in order to handle message history of the document thread,
- * in particular to fetch messages when necessary and/or display 'load more'.
- *
- * @param {integer[]} messageIDs
- */
- setMessageIDs: function (messageIDs) {
- this._mustFetchMessageIDs = false;
- this._messageIDs = messageIDs;
- },
-
- //--------------------------------------------------------------------------
- // Private
- //--------------------------------------------------------------------------
-
- /**
- * Add this message to this document thread.
- *
- * This is ignored if the message is already linked to this document thread.
- * Also, there is no fetch of unread message counter in a document thread,
- * as this object is built from messages and/or thread field widget. The
- * unread counter of a document thread is the sum of needaction messages
- * linked to this document thread.
- *
- * @override
- * @private
- * @param {mail.model.Message} message
- */
- _addMessage: function (message) {
- this._super.apply(this, arguments);
- if (_.contains(this._messages, message)) {
- return;
- }
- // update internal list of messages
- this._messages.push(message);
- this._messages = _.sortBy(this._messages, function (msg) {
- return msg.getID();
- });
- // update message ids associated to this document thread
- if (!_.contains(this._messageIDs, message.getID())) {
- this._messageIDs.push(message.getID());
- }
- // update unread counter
- if (message.isNeedaction()) {
- this._unreadCounter++;
- }
- },
- /**
- * Get most up to date messageIDs
- *
- * @private
- * @returns {Promise} resolved when message IDs have been fetched and set
- * in the model
- */
- _fetchMessageIDs: function () {
- var self = this;
- var resID = this.getDocumentID();
- if (!resID || !this._mustFetchMessageIDs) {
- return Promise.resolve();
- }
- return this._rpc({
- model: this.getDocumentModel(),
- method: 'read',
- args: [[resID], ['message_ids']],
- }).then(function (result) {
- self.setMessageIDs(result[0].message_ids);
- });
- },
- /**
- * @override
- * @private
- * @param {Object} options
- * @param {boolean} [options.forceFetch]
- * @returns {Promise} resolved when messages have been fetched + document
- * thread has updated messages
- */
- _fetchMessages: function (options) {
- var self = this;
- return this._fetchMessageIDs().then(function () {
- var messageIDs = self._messageIDs;
- var loadedMessages = _.filter(self._messages, function (message) {
- return _.contains(messageIDs, message.getID());
- });
- var loadedMessageIDs = _.map(loadedMessages, function (message) {
- return message.getID();
- });
-
- options = options || {};
- var shouldFetch = _.difference(
- messageIDs.slice(0, self._FETCH_LIMIT),
- loadedMessageIDs
- ).length > 0;
- if (options.forceFetch || shouldFetch) {
- var idsToLoad = _.difference(messageIDs, loadedMessageIDs)
- .slice(0, self._FETCH_LIMIT);
- return self._rpc({
- model: 'mail.message',
- method: 'message_format',
- args: [idsToLoad],
- context: session.user_context,
- })
- .then(function (messagesData) {
- _.each(messagesData, function (messageData) {
- self.call('mail_service', 'addMessage', messageData, { silent: true });
- });
- });
- } else {
- return Promise.resolve();
- }
-
- });
- },
- /**
- * Overrides this method so that all the messages of this document thread
- * are marked as read on the server.
- *
- * @override
- * @private
- * @returns {Promise} resolved when messages have been marked as read on
- * the server.
- */
- _markAsRead: function () {
- var self = this;
- return this._super.apply(this, arguments).then(function () {
- self.call('mail_service', 'markMessagesAsRead', self._messageIDs);
- });
- },
- /**
- * Post message for document thread
- *
- * @override
- * @private
- * @param {Object} data data related to the new message
- * @return {Promise<Object>} resolved when the message has been sent to
- * the server, with the object message that has been sent to the server.
- */
- _postMessage: function (data) {
- var self = this;
- var resModel = this.getDocumentModel();
- var resID = this.getDocumentID();
- return this._super.apply(this, arguments)
- .then(function (messageData) {
- _.extend(messageData, {
- context: data.context,
- message_type: data.message_type,
- subtype_xmlid: data.subtype_xmlid || "mail.mt_comment",
- subtype_id: data.subtype_id,
- });
- return self._rpc({
- model: resModel,
- method: 'message_post',
- args: [resID],
- kwargs: messageData,
- })
- .then(function (messageID) {
- return self._rpc({
- model: 'mail.message',
- method: 'message_format',
- args: [[messageID]],
- })
- .then(function (messages) {
- messages[0].model = resModel;
- messages[0].res_id = resID;
- self.call('mail_service', 'addMessage', messages[0], {
- postedFromDocumentThread: true,
- });
- return messages[0];
- });
- });
- });
- },
-});
-
-return DocumentThread;
-
-});
diff --git a/addons/mail/static/src/js/models/threads/livechat.js b/addons/mail/static/src/js/models/threads/livechat.js
deleted file mode 100644
index c3b4a5e2b4d..00000000000
--- a/addons/mail/static/src/js/models/threads/livechat.js
+++ /dev/null
@@ -1,95 +0,0 @@
-odoo.define('mail.model.Livechat', function (require) {
-"use strict";
-
-var TwoUserChannel = require('mail.model.TwoUserChannel');
-
-/**
- * backend-side of the livechat.
- *
- * Any piece of code in JS that make use of Livechats must ideally interact with
- * such objects, instead of direct data from the server.
- */
-var Livechat = TwoUserChannel.extend({
- _WEBSITE_USER_ID: '_websiteUser',
- _WEBSITE_USER_NAME: 'Website user',
-
- /**
- * @override
- * @param {Object} params
- * @param {Object} params.data
- */
- init: function (params) {
- this._super.apply(this, arguments);
- this._name = params.data.correspondent_name;
- this._WEBSITE_USER_NAME = this._name;
- },
-
- //--------------------------------------------------------------------------
- // Public
- //--------------------------------------------------------------------------
-
- /**
- * Override so that the list of members has the website use. This is
- * necessary in order for the 'is_typing' feature to compute the name to
- * display of a user that is typing.
- *
- * @override
- * @returns {Promise<Object[]>} resolved with list of list of
- * livechat members.
- */
- getMentionPartnerSuggestions: function () {
- var self = this;
- return this._super.apply(this, arguments).then(function () {
- var websiteUser = _.findWhere(self._members, { id: self._WEBSITE_USER_ID });
- if (!websiteUser) {
- self._members.push({
- id: self._WEBSITE_USER_ID,
- name: self._WEBSITE_USER_NAME,
- });
- }
- return [self._members];
- });
- },
- /**
- * Called when someone starts typing something on the livechat.
- *
- * Overrides it so that it determine the partner based on the received
- * userID. The reason is that anonymous users have the partner ID of the
- * admin, which is likely also an operator, so userID must be used in order
- * to distinct them.
- *
- * @override {mail.model.ThreadTypingMixin}
- * @param {Object} params
- * @param {integer} params.partnerID ID of the partner that is currently
- * typing something on the thread.
- * @param {boolean} [params.isWebsiteUser=false] whether the typing partner
- * is an anonymous user (reminder: they share partnerID with admin).
- */
- registerTyping: function (params) {
- params.partnerID = this._WEBSITE_USER_ID;
- this._super.call(this, params);
- },
- /**
- * Called when someone stops typing something on the livechat.
- *
- * Overrides it so that it determine the partner based on the received
- * userID. The reason is that anonymous users have the partner ID of the
- * admin, which is likely also an operator, so userID must be used in order
- * to distinct them.
- *
- * @override {mail.model.ThreadTypingMixin}
- * @param {Object} params
- * @param {integer} params.partnerID ID of the partner that stops typing
- * something on the thread.
- * @param {boolean} [params.isWebsiteUser=false] whether the typing partner
- * is an anonymous user (reminder: they share partnerID with admin).
- */
- unregisterTyping: function (params) {
- params.partnerID = this._WEBSITE_USER_ID;
- this._super.call(this, params);
- },
-});
-
-return Livechat;
-
-});
diff --git a/addons/mail/static/src/js/models/threads/mailbox.js b/addons/mail/static/src/js/models/threads/mailbox.js
deleted file mode 100644
index a08f92f0ab8..00000000000
--- a/addons/mail/static/src/js/models/threads/mailbox.js
+++ /dev/null
@@ -1,224 +0,0 @@
-odoo.define('mail.model.Mailbox', function (require) {
-"use strict";
-
-var SearchableThread = require('mail.model.SearchableThread');
-
-var core = require('web.core');
-
-var _t = core._t;
-
-/**
- * Mailboxes, called "static channels" in earlier version of Odoo, are threads
- * that are not represented on the server. We can see them as "threads that are
- * not conversations". For instance, the well-known "Inbox" folder contains a
- * list of messages, but the inbox does not represent a conversation: Inbox is
- * modeled as a mailbox.
- */
-var Mailbox = SearchableThread.extend({
-
- /**
- * @override
- * @param {Object} params
- * @param {Object} params.data
- * @param {string} params.data.id the ID of the mailbox, without the ``mailbox_`` prefix.
- * @param {integer} [params.data.mailboxCounter=0] the initial mailbox counter of this mailbox.
- */
- init: function (params) {
- var data = params.data;
- // ID or mailboxes are always prefixed with 'mailbox_'
- data.id = 'mailbox_' + data.id;
- data.type = 'mailbox';
- this._super.apply(this, arguments);
- this._mailboxCounter = data.mailboxCounter || 0;
- },
-
- //--------------------------------------------------------------------------
- // Public
- //--------------------------------------------------------------------------
-
- /**
- * Decrement the counter of this mailbox. It floors to 0.
- *
- * @param {integer} [num=1] how many, at most, the counter is decremented
- */
- decrementMailboxCounter: function (num) {
- num = _.isNumber(num) ? num : 1;
- this._mailboxCounter = Math.max(this._mailboxCounter - num, 0);
- },
- /**
- * Get the mailbox counter of this mailbox.
- *
- * @returns {integer}
- */
- getMailboxCounter: function () {
- return this._mailboxCounter;
- },
- /**
- * Get the preview of the messages from the inbox mailbox.
- * If there are multiple messages associated to a similar document or
- * channel, only keep the lastest message of this document/channel in the
- * previews.
- *
- * @returns {Promise<Object[]>}
- */
- getMessagePreviews: function () {
- var self = this;
-
- return this.fetchMessages().then(function (messages) {
- // pick only last message of chatter
- // items = list of objects
- // {
- // unreadCounter: {integer},
- // message: {mail.model.Message},
- // messageIDs: {integer[]},
- // }
- var items = [];
- _.each(messages, function (message) {
- var unreadCounter = 1;
- var messageIDs = [message.getID()];
- var similarItem = _.find(items, function (item) {
- return self._areMessagesFromSameDocumentThread(item.message, message) ||
- self._areMessagesFromSameChannel(item.message, message);
- });
- if (similarItem) {
- unreadCounter = similarItem.unreadCounter + 1;
- messageIDs = similarItem.messageIDs.concat(messageIDs);
- var index = _.findIndex(items, similarItem);
- items[index] = {
- unreadCounter: unreadCounter,
- message: message,
- messageIDs: messageIDs
- };
- } else {
- items.push({
- unreadCounter: unreadCounter,
- message: message,
- messageIDs: messageIDs,
- });
- }
- });
- return _.map(items, function (item) {
- return _.extend(item.message.getPreview(), {
- unreadCounter: item.unreadCounter,
- messageIDs: item.messageIDs,
- });
- });
- });
- },
- /**
- * Increment the counter of this mailbox
- *
- * @param {integer} [num=1] how many, the counter is incremented
- */
- incrementMailboxCounter: function (num) {
- num = _.isNumber(num) ? num : 1;
- this._mailboxCounter = Math.max(this._mailboxCounter + num, 0);
- },
- /**
- * Marks all messages from the mailbox as read. At the moment, this method
- * makes only sense for 'Inbox'.
- *
- * @param {Array} domain
- * @return {Promise} resolved when all messages have been marked as read
- * on the server
- */
- markAllMessagesAsRead: function (domain) {
- if (this._id === 'mailbox_inbox' && this.getMailboxCounter() > 0) {
- return this._rpc({
- model: 'mail.message',
- method: 'mark_all_as_read',
- kwargs: {
- domain: domain,
- },
- });
- }
- return Promise.resolve();
- },
- /**
- * @param {integer} newCounter
- */
- setMailboxCounter(newCounter) {
- this._mailboxCounter = newCounter;
- },
-
- //--------------------------------------------------------------------------
- // Private
- //--------------------------------------------------------------------------
-
- /**
- * Tell whether the two messages originate from the same channel.
- * If any one of them does not originate from a channel, returns false.
- *
- * @private
- * @param {mail.model.Message} message1
- * @param {mail.model.Message} message2
- * @returns {boolean}
- */
- _areMessagesFromSameChannel: function (message1, message2) {
- return message1.originatesFromChannel() &&
- message2.originatesFromChannel() &&
- message1.getOriginChannelID() === message2.getOriginChannelID();
- },
- /**
- * Tell whether the two messages are linked to the same document thread.
- * If any one of them is not linked to a document, returns false.
- *
- * @private
- * @param {mail.model.Message} message1
- * @param {mail.model.Message} message2
- * @returns {boolean}
- */
- _areMessagesFromSameDocumentThread: function (message1, message2) {
- return message1.isLinkedToDocumentThread() &&
- message2.isLinkedToDocumentThread() &&
- message1.getDocumentModel() === message2.getDocumentModel() &&
- message1.getDocumentID() === message2.getDocumentID();
- },
- /**
- * Get the domain to fetch all the messages in the current mailbox
- *
- * @override
- * @private
- * @returns {Array}
- * @throws on missing domain for the provided mailbox (mailboxes should
- * always have a domain on messages to fetch)
- */
- _getThreadDomain: function () {
- if (this._id === 'mailbox_inbox') {
- return [['needaction', '=', true]];
- } else if (this._id === 'mailbox_starred') {
- return [['starred', '=', true]];
- } else if (this._id === 'mailbox_history') {
- return [['needaction', '=', false]];
- } else if (this._id === 'mailbox_moderation') {
- return [['need_moderation', '=', true]];
- } else {
- throw (_.str(_t("Missing domain for mailbox with ID '%s'"), this._id));
- }
- },
- /**
- * Post a message from inbox. This is used when using the 'reply' feature
- * on a message that is linked to a document thread.
- *
- * @override
- * @private
- * @param {Object} messageData
- * @param {Object} options
- * @param {integer} options.documentID
- * @param {string} options.documentModel
- * @returns {$.Promise}
- */
- _postMessage: function (messageData, options) {
- var documentThread = this.call(
- 'mail_service',
- 'getDocumentThread',
- options.documentModel,
- options.documentID
- );
- return documentThread.postMessage(messageData);
- },
-});
-
-return Mailbox;
-
-});
diff --git a/addons/mail/static/src/js/models/threads/mixins/channel_seen_mixin.js b/addons/mail/static/src/js/models/threads/mixins/channel_seen_mixin.js
deleted file mode 100644
index 20a5bba5407..00000000000
--- a/addons/mail/static/src/js/models/threads/mixins/channel_seen_mixin.js
+++ /dev/null
@@ -1,229 +0,0 @@
-odoo.define('mail.model.ChannelSeenMixin', function (require) {
-"use strict";
-
-var session = require('web.session');
-
-/**
- * Mixin for enabling the "seen" feature on channels.
- */
-var ChannelSeenMixin = {
- /**
- * Initialize the internal data for seen feature on channels.
- *
- * Also listens on some internal events of the channel:
- *
- * - 'message_added': used to notify channel has been fetched
- *
- * @param {Object} params
- * @param {Object[]} [params.data.partners_info=[]]
- * @param {integer} [params.data.seen_partners_info[i].partner_id]
- * @param {integer|boolean} [params.data.seen_partners_info[i].fetched_message_id=false]
- * @param {integer|boolean} [params.data.seen_partners_info[i].seen_message_id=false]
- */
- init: function (params) {
- this._seenPartnersInfo = params.data.seen_partners_info || [];
-
- this.on('message_added', this, this._onSeenMessageAdded);
- },
-
- //--------------------------------------------------------------------------
- // Public
- //--------------------------------------------------------------------------
-
- /**
- * Get list of members that have fetched the provided message on this
- * channel, but not yet seen it. Ignore current user in this list.
- *
- * @param {mail.model.Message} message
- * @returns {Object[]} list of members
- */
- getFetchedNotSeenMembers: function (message) {
- var otherPartnersInfo = _.filter(this._seenPartnersInfo, function (info) {
- return info.partner_id !== session.partner_id;
- });
-
- // seen partner IDs
- var seenPartnersInfo = _.filter(otherPartnersInfo, function (info) {
- return info.seen_message_id && info.seen_message_id >= message.getID();
- });
- var seenPartnerIDs = _.map(seenPartnersInfo, function (info) {
- return info.partner_id;
- });
-
- // fetched partner IDs (ignore seen)
- var fetchedPartnersInfo = _.filter(otherPartnersInfo, function (info) {
- var lastFetchedID = info.fetched_message_id || info.seen_message_id || 0;
- return lastFetchedID >= message.getID();
- });
- var fetchedPartnerIDs = _.map(fetchedPartnersInfo, function (info) {
- return info.partner_id;
- });
- fetchedPartnerIDs = _.reject(fetchedPartnerIDs, function (fetchedPartnerID) {
- return _.contains(seenPartnerIDs, fetchedPartnerID);
- });
-
- // fetched members
- var fetchedMembers = _.filter(this._members, function (member) {
- return _.contains(fetchedPartnerIDs, member.id);
- });
-
- return fetchedMembers;
- },
- /**
- * @returns {integer}
- */
- getLastMessageIDSeenByEveryone: function () {
- var otherPartnersInfo = _.filter(this._seenPartnersInfo, function (info) {
- return info.partner_id !== session.partner_id;
- });
- var seenMessageIDs = _.map(otherPartnersInfo, function (info) {
- return info.seen_message_id || 0;
- });
- if (_.isEmpty(seenMessageIDs)) {
- return 0;
- }
- return _.min(seenMessageIDs);
- },
- /**
- * Get list of members that have seen the provided message on this channel.
- * Ignore current user in this list.
- *
- * @param {mail.model.Message} message
- * @returns {Object[]} list of members
- */
- getSeenMembers: function (message) {
- var otherPartnersInfo = _.filter(this._seenPartnersInfo, function (info) {
- return info.partner_id !== session.partner_id;
- });
-
- // seen partner IDs
- var seenPartnersInfo = _.filter(otherPartnersInfo, function (info) {
- return info.seen_message_id >= message.getID();
- });
- var seenPartnerIDs = _.map(seenPartnersInfo, function (info) {
- return info.partner_id;
- });
-
- // seen members
- var seenMembers = _.filter(this._members, function (member) {
- return _.contains(seenPartnerIDs, member.id);
- });
-
- return seenMembers;
- },
- /**
- * @param {mail.model.Message} message
- * @returns {boolean}
- */
- hasEveryoneFetched: function (message) {
- var otherPartnersInfo = _.filter(this._seenPartnersInfo, function (info) {
- return info.partner_id !== session.partner_id;
- });
- var lastFetchedMessageIDs = _.map(otherPartnersInfo, function (info) {
- return info.fetched_message_id || info.seen_message_id || 0;
- });
- if (_.isEmpty(lastFetchedMessageIDs)) {
- return 0;
- }
- return message.getID() <= _.min(lastFetchedMessageIDs);
- },
- /**
- * @param {mail.model.Message} message
- * @returns {boolean}
- */
- hasEveryoneSeen: function (message) {
- var otherPartnersInfo = _.filter(this._seenPartnersInfo, function (info) {
- return info.partner_id !== session.partner_id;
- });
- var lastSeenMessageIDs = _.map(otherPartnersInfo, function (info) {
- return info.seen_message_id || 0;
- });
- if (_.isEmpty(lastSeenMessageIDs)) {
- return 0;
- }
- return message.getID() <= _.min(lastSeenMessageIDs);
- },
- /**
- * @param {mail.model.Message} message
- * @returns {boolean}
- */
- hasSomeoneFetched: function (message) {
- var otherPartnersInfo = _.filter(this._seenPartnersInfo, function (info) {
- return info.partner_id !== session.partner_id;
- });
- var lastFetchedMessageIDs = _.map(otherPartnersInfo, function (info) {
- return info.fetched_message_id || info.seen_message_id || 0;
- });
- if (_.isEmpty(lastFetchedMessageIDs)) {
- return false;
- }
- var fetched = _.find(lastFetchedMessageIDs, function (msgID) {
- return msgID >= message.getID();
- });
- return fetched;
- },
- /**
- * @param {mail.model.Message} message
- * @returns {boolean}
- */
- hasSomeoneSeen: function (message) {
- var otherPartnersInfo = _.filter(this._seenPartnersInfo, function (info) {
- return info.partner_id !== session.partner_id;
- });
- var lastSeenMessageIDs = _.map(otherPartnersInfo, function (info) {
- return info.seen_message_id || 0;
- });
- if (_.isEmpty(lastSeenMessageIDs)) {
- return false;
- }
- var seen = _.find(lastSeenMessageIDs, function (msgID) {
- return msgID >= message.getID();
- });
- return seen;
- },
- /**
- * @returns {boolean}
- */
- hasSeenFeature: function () {
- return true;
- },
- /**
- * @param {Object} data
- * @param {string} data.info either 'channel_seen' or 'channel_fetched'
- * @param {integer} data.partner_id
- * @param {integer} data.last_message_id
- */
- updateSeenPartnersInfo: function (data) {
- var seenInfo = _.findWhere(this._seenPartnersInfo, { partner_id: data.partner_id });
- if (!seenInfo) {
- seenInfo = {
- partner_id: data.partner_id,
- seen_message_id: 0,
- fetched_message_id: 0,
- };
- this._seenPartnersInfo.push(seenInfo);
- }
- if (data.info === 'channel_fetched') {
- seenInfo.fetched_message_id = data.last_message_id;
- } else if (data.info === 'channel_seen') {
- seenInfo.fetched_message_id = data.last_message_id;
- seenInfo.seen_message_id = data.last_message_id;
- }
- this.call('mail_service', 'getMailBus').trigger('update_channel', this.getID());
- },
-
- //--------------------------------------------------------------------------
- // Handlers
- //--------------------------------------------------------------------------
-
- /**
- * @private
- */
- _onSeenMessageAdded: function () {
- this.call('mail_service', 'notifyChannelFetched', { channelID: this._id });
- },
-};
-
-return ChannelSeenMixin;
-
-});
diff --git a/addons/mail/static/src/js/models/threads/mixins/thread_typing_mixin.js b/addons/mail/static/src/js/models/threads/mixins/thread_typing_mixin.js
deleted file mode 100644
index 3c98836dc70..00000000000
--- a/addons/mail/static/src/js/models/threads/mixins/thread_typing_mixin.js
+++ /dev/null
@@ -1,360 +0,0 @@
-odoo.define('mail.model.ThreadTypingMixin', function (require) {
-"use strict";
-
-var CCThrottleFunction = require('mail.model.CCThrottleFunction');
-var Timer = require('mail.model.Timer');
-var Timers = require('mail.model.Timers');
-
-var core = require('web.core');
-
-var _t = core._t;
-
-/**
- * Mixin for enabling the "is typing..." notification on a type of thread.
- */
-var ThreadTypingMixin = {
- // Default partner infos
- _DEFAULT_TYPING_PARTNER_ID: '_default',
- _DEFAULT_TYPING_PARTNER_NAME: 'Someone',
-
- /**
- * Initialize the internal data for typing feature on threads.
- *
- * Also listens on some internal events of the thread:
- *
- * - 'message_added': when a message is added, remove the author in the
- * typing partners.
- * - 'message_posted': when a message is posted, let the user have the
- * possibility to immediately notify if he types something right away,
- * instead of waiting for a throttle behaviour.
- */
- init: function () {
- // Store the last "myself typing" status that has been sent to the
- // server. This is useful in order to not notify the same typing
- // status multiple times.
- this._lastNotifiedMyselfTyping = false;
-
- // Timer of current user that is typing a very long text. When the
- // receivers do not receive any typing notification for a long time,
- // they assume that the related partner is no longer typing
- // something (e.g. they have closed the browser tab).
- // This is a timer to let others know that we are still typing
- // something, so that they do not assume we stopped typing
- // something.
- this._myselfLongTypingTimer = new Timer({
- duration: 50*1000,
- onTimeout: this._onMyselfLongTypingTimeout.bind(this),
- });
-
- // Timer of current user that was currently typing something, but
- // there is no change on the input for several time. This is used
- // in order to automatically notify other users that we have stopped
- // typing something, due to making no changes on the composer for
- // some time.
- this._myselfTypingInactivityTimer = new Timer({
- duration: 5*1000,
- onTimeout: this._onMyselfTypingInactivityTimeout.bind(this),
- });
-
- // Timers of users currently typing in the thread. This is useful
- // in order to automatically unregister typing users when we do not
- // receive any typing notification after a long time. Timers are
- // internally indexed by partnerID. The current user is ignored in
- // this list of timers.
- this._othersTypingTimers = new Timers({
- duration: 60*1000,
- onTimeout: this._onOthersTypingTimeout.bind(this),
- });
-
- // Clearable and cancellable throttled version of the
- // `doNotifyMyselfTyping` method. (basically `notifyMyselfTyping`
- // with slight pre- and post-processing)
- // @see {mail.model.ResetableThrottleFunction}
- // This is useful when the user posts a message and types something
- // else: he must notify immediately that he is typing something,
- // instead of waiting for the throttle internal timer.
- this._throttleNotifyMyselfTyping = CCThrottleFunction({
- duration: 2.5*1000,
- func: this._onNotifyMyselfTyping.bind(this),
- });
-
- // This is used to track the order of registered partners typing
- // something, in order to display the oldest typing partners.
- this._typingPartnerIDs = [];
-
- this.on('message_added', this, this._onTypingMessageAdded);
- this.on('message_posted', this, this._onTypingMessagePosted);
- },
-
- //--------------------------------------------------------------------------
- // Public
- //--------------------------------------------------------------------------
-
- /**
- * Get the text to display when some partners are typing something on the
- * thread:
- *
- * - single typing partner:
- *
- * A is typing...
- *
- * - two typing partners:
- *
- * A and B are typing...
- *
- * - three or more typing partners:
- *
- * A, B and more are typing...
- *
- * The choice of the members name for display is not random: it displays
- * the user that have been typing for the longest time. Also, this function
- * is hard-coded to display at most 2 partners. This limitation comes from
- * how translation works in Odoo, for which unevaluated string cannot be
- * translated.
- *
- * @returns {string} list of members that are typing something on the thread
- * (excluding the current user).
- */
- getTypingMembersToText: function () {
- var typingPartnerIDs = this._typingPartnerIDs;
- var typingMembers = _.filter(this._members, function (member) {
- return _.contains(typingPartnerIDs, member.id);
- });
- var sortedTypingMembers = _.sortBy(typingMembers, function (member) {
- return _.indexOf(typingPartnerIDs, member.id);
- });
- var displayableTypingMembers = sortedTypingMembers.slice(0, 3);
-
- if (displayableTypingMembers.length === 0) {
- return '';
- } else if (displayableTypingMembers.length === 1) {
- return _.str.sprintf(_t("%s is typing..."), displayableTypingMembers[0].name);
- } else if (displayableTypingMembers.length === 2) {
- return _.str.sprintf(_t("%s and %s are typing..."),
- displayableTypingMembers[0].name,
- displayableTypingMembers[1].name);
- } else {
- return _.str.sprintf(_t("%s, %s and more are typing..."),
- displayableTypingMembers[0].name,
- displayableTypingMembers[1].name);
- }
- },
- /**
- * Threads with this mixin have the typing notification feature
- *
- * @returns {boolean}
- */
- hasTypingNotification: function () {
- return true;
- },
- /**
- * Tells if someone other than current user is typing something on this
- * thread.
- *
- * @returns {boolean}
- */
- isSomeoneTyping: function () {
- return !(_.isEmpty(this._typingPartnerIDs));
- },
- /**
- * Register someone that is currently typing something in this thread.
- * If this is the current user that is typing something, don't do anything
- * (we do not have to display anything)
- *
- * This method is ignored if we try to register the current user.
- *
- * @param {Object} params
- * @param {integer} params.partnerID ID of the partner linked to the user
- * currently typing something on the thread.
- */
- registerTyping: function (params) {
- if (this._isTypingMyselfInfo(params)) {
- return;
- }
- var partnerID = params.partnerID;
- this._othersTypingTimers.registerTimer({
- timeoutCallbackArguments: [partnerID],
- timerID: partnerID,
- });
- if (_.contains(this._typingPartnerIDs, partnerID)) {
- return;
- }
- this._typingPartnerIDs.push(partnerID);
- this._warnUpdatedTypingPartners();
- },
- /**
- * This method must be called when the user starts or stops typing something
- * in the composer of the thread.
- *
- * @param {Object} params
- * @param {boolean} params.typing tell whether the current is typing or not.
- */
- setMyselfTyping: function (params) {
- var typing = params.typing;
- if (this._lastNotifiedMyselfTyping === typing) {
- this._throttleNotifyMyselfTyping.cancel();
- } else {
- this._throttleNotifyMyselfTyping(params);
- }
-
- if (typing) {
- this._myselfTypingInactivityTimer.reset();
- } else {
- this._myselfTypingInactivityTimer.clear();
- }
- },
- /**
- * Unregister someone from currently typing something in this thread.
- *
- * @param {Object} params
- * @param {integer} params.partnerID ID of the partner related to the user
- * that is currently typing something
- */
- unregisterTyping: function (params) {
- var partnerID = params.partnerID;
- this._othersTypingTimers.unregisterTimer({ timerID: partnerID });
- if (!_.contains(this._typingPartnerIDs, partnerID)) {
- return;
- }
- this._typingPartnerIDs = _.reject(this._typingPartnerIDs, function (id) {
- return id === partnerID;
- });
- this._warnUpdatedTypingPartners();
- },
-
- //--------------------------------------------------------------------------
- // Private
- //--------------------------------------------------------------------------
-
- /**
- * Tells whether the provided information on a partner is related to the
- * current user or not.
- *
- * @abstract
- * @private
- * @param {Object} params
- * @param {integer} params.partner ID of partner to check
- */
- _isTypingMyselfInfo: function (params) {
- return false;
- },
- /**
- * Notify to the server that the current user either starts or stops typing
- * something.
- *
- * @abstract
- * @private
- * @param {Object} params
- * @param {boolean} params.typing whether we are typing something or not
- * @returns {Promise} resolved if the server is notified, rejected
- * otherwise
- */
- _notifyMyselfTyping: function (params) {
- return Promise.resolve();
- },
- /**
- * Warn views that the list of users that are currently typing on this
- * thread has been updated.
- *
- * @abstract
- * @private
- */
- _warnUpdatedTypingPartners: function () {},
-
- //--------------------------------------------------------------------------
- // Handlers
- //--------------------------------------------------------------------------
-
- /**
- * Called when current user is typing something for a long time. In order
- * to not let other users assume that we are no longer typing something, we
- * must notify again that we are typing something.
- *
- * @private
- */
- _onMyselfLongTypingTimeout: function () {
- this._throttleNotifyMyselfTyping.clear();
- this._throttleNotifyMyselfTyping({ typing: true });
- },
- /**
- * Called when current user has something typed in the composer, but is
- * inactive for some time. In this case, he automatically notifies that he
- * is no longer typing something
- *
- * @private
- */
- _onMyselfTypingInactivityTimeout: function () {
- this._throttleNotifyMyselfTyping.clear();
- this._throttleNotifyMyselfTyping({ typing: false });
- },
- /**
- * Called by throttled version of notify myself typing
- *
- * Notify to the server that the current user either starts or stops typing
- * something. Remember last notified stuff from the server, and update
- * related typing timers.
- *
- * @private
- * @param {Object} params
- * @param {boolean} params.typing whether we are typing something or not.
- */
- _onNotifyMyselfTyping: function (params) {
- var typing = params.typing;
- this._lastNotifiedMyselfTyping = typing;
- this._notifyMyselfTyping(params);
- if (typing) {
- this._myselfLongTypingTimer.reset();
- } else {
- this._myselfLongTypingTimer.clear();
- }
- },
- /**
- * Called when current user do not receive a typing notification of someone
- * else typing for a long time. In this case, we assume that this person is
- * no longer typing something.
- *
- * @private
- * @param {integer} partnerID partnerID of the person we assume he is no
- * longer typing something.
- */
- _onOthersTypingTimeout: function (partnerID) {
- this.unregisterTyping({ partnerID: partnerID });
- },
- /**
- * Called when a new message is added to the thread
- * On receiving a message from a typing partner, unregister this partner
- * from typing partners (otherwise, it will still display it until timeout).
- *
- * @private
- * @param {mail.model.AbstractMessage} message
- */
- _onTypingMessageAdded: function (message) {
- var partnerID = message.hasAuthor() ?
- message.getAuthorID() :
- this._DEFAULT_TYPING_PARTNER_ID;
- this.unregisterTyping({ partnerID: partnerID });
- },
- /**
- * Called when current user has posted a message on this thread.
- *
- * The current user receives the possibility to immediately notify the
- * other users if he is typing something else.
- *
- * Refresh the context for the current user to notify that he starts or
- * stops typing something. In other words, when this function is called and
- * then the current user types something, it immediately notifies the
- * server as if it is the first time he is typing something.
- *
- * @private
- */
- _onTypingMessagePosted: function () {
- this._lastNotifiedMyselfTyping = false;
- this._throttleNotifyMyselfTyping.clear();
- this._myselfLongTypingTimer.clear();
- this._myselfTypingInactivityTimer.clear();
- },
-};
-
-return ThreadTypingMixin;
-
-});
diff --git a/addons/mail/static/src/js/models/threads/multi_user_channel.js b/addons/mail/static/src/js/models/threads/multi_user_channel.js
deleted file mode 100644
index 3dab9337502..00000000000
--- a/addons/mail/static/src/js/models/threads/multi_user_channel.js
+++ /dev/null
@@ -1,73 +0,0 @@
-odoo.define('mail.model.MultiUserChannel', function (require) {
-"use strict";
-
-var Channel = require('mail.model.Channel');
-
-var MultiUserChannel = Channel.extend({
- /**
- * @override
- * @param {Object} params
- * @param {Object} params.data
- * @param {boolean} [params.data.mass_mailing=false]
- * @param {string} [params.data.public='public'] either 'public' or
- * 'private'
- */
- init: function (params) {
- this._super.apply(this, arguments);
-
- var data = params.data;
-
- this._isMassMailing = data.mass_mailing || false;
- this._type = 'multi_user_channel';
- this._public = data.public !== 'private';
- },
-
- //--------------------------------------------------------------------------
- // Public
- //--------------------------------------------------------------------------
-
- /**
- * Returns the title to display in thread window's headers.
- * For channels, the title is prefixed with "#".
- *
- * @override
- * @returns {string|Object} the name of the thread by default (see getName)
- */
- getTitle: function () {
- return "#" + this._super.apply(this, arguments);
- },
- /**
- * States whether this thread has the mass mailing setting active or not.
- * This is a server-side setting, that determine the type of composer that
- * is used (basic or extended composer).
- *
- * @return {boolean}
- */
- isMassMailing: function () {
- return this._isMassMailing;
- },
- /**
- * @returns {boolean}
- */
- isPublic: function () {
- return this._public;
- },
- /**
- * Unsubscribes from channel
- *
- * @override
- * @returns {Promise} resolve when unsubscribed
- */
- unsubscribe: function () {
- return this._rpc({
- model: 'mail.channel',
- method: 'action_unfollow',
- args: [[this.getID()]],
- });
- },
-
-});
-
-return MultiUserChannel;
-
-});
diff --git a/addons/mail/static/src/js/models/threads/searchable_thread.js b/addons/mail/static/src/js/models/threads/searchable_thread.js
deleted file mode 100644
index c2a48a2d410..00000000000
--- a/addons/mail/static/src/js/models/threads/searchable_thread.js
+++ /dev/null
@@ -1,244 +0,0 @@
-odoo.define('mail.model.SearchableThread', function (require) {
-"use strict";
-
-var Thread = require('mail.model.Thread');
-
-var session = require('web.session');
-
-/**
- * This is a version of a thread that is handling a cache.
- * Basically, this cache is an object indexed by stringified indexes.
- * These indexes represent the domain of messages that match this domain.
- *
- * Any threads that are instances of this Class can be used with a search view,
- * in order to make searches on messages in a thread.
- */
-var SearchableThread = Thread.extend({
-
- /**
- * @override
- * @param {Object} params
- * @param {Object} params.data
- * @param {integer} [params.data.seen_message_id=undefined]
- */
- init: function (params) {
- this._super.apply(this, arguments);
-
- this._cache = {
- '[]': {
- allHistoryLoaded: false,
- loaded: false,
- messages: [],
- }
- };
- this._lastSeenMessageID = params.data.seen_message_id;
- },
-
- //--------------------------------------------------------------------------
- // Public
- //--------------------------------------------------------------------------
-
- /**
- * @override
- * @param {Array} [domain]
- * @param {boolean} [loadMore=false]
- */
- fetchMessages: function (domain, loadMore) {
- if (loadMore) {
- return this._fetchMessages(domain, true);
- }
- var cache = this._getCache(domain);
- if (cache.loaded) {
- return Promise.all(cache.messages);
- } else {
- return this._fetchMessages(domain);
- }
- },
- /**
- * Get the last seen message for a given thread
- *
- * @return {integer|null} last seen message ID (if any)
- */
- getLastSeenMessageID: function () {
- return this._lastSeenMessageID || null;
- },
- /**
- * @override
- * @param {Object} [options={}]
- * @param {Array} [options.domain=[]]
- * @return {mail.model.Message[]}
- */
- getMessages: function (options) {
- options = options || {};
- var domain = options.domain || [];
- return this._getCache(domain).messages;
- },
- /**
- * Override so that a thread can tell whether there are message based on
- * the domain.
- *
- * @override
- * @param {Object} [options={}]
- * @param {Array} [options.domain=[]]
- * @returns {boolean}
- */
- hasMessages: function (options) {
- options = options || {};
- var domain = options.domain || [];
- return !_.isEmpty(this.getMessages({ domain: domain }));
- },
- /**
- * Invalidate the caches of the thread
- */
- invalidateCaches: function () {
- this._cache = _.pick(this._cache, '[]');
- },
- /**
- * State whether all messages have been loaded or not
- *
- * @param {Array} domain
- * @return {boolean}
- */
- isAllHistoryLoaded: function (domain) {
- return this._getCache(domain).allHistoryLoaded;
- },
- /**
- * Remove message with ID `messageID` from this thread
- *
- * This is equivalent to removing this message from all the caches
- *
- * @param {integer} messageID
- */
- removeMessage: function (messageID) {
- _.each(this._cache, function (cache) {
- cache.messages = _.reject(cache.messages, function (message) {
- return message.getID() === messageID;
- });
- });
- },
- /**
- * Set 'messageID' as the ID of the last message seen in this thread
- *
- * @param {integer} messageID
- */
- setLastSeenMessageID: function (messageID) {
- this._lastSeenMessageID = messageID;
- },
-
- //--------------------------------------------------------------------------
- // Private
- //--------------------------------------------------------------------------
-
- /**
- * Add the message to this thread
- *
- * @override
- * @private
- * @param {mail.model.Message} message
- * @param {Object} [options={}]
- * @param {Array} [options.domain=[]]
- * @param {boolean} [options.incrementUnread=false]
- */
- _addMessage: function (message, options) {
- options = options || {};
- this._super.apply(this, arguments);
- var cache = this._getCache(options.domain || []);
- var index = _.sortedIndex(cache.messages, message, function (msg) {
- return msg.getID();
- });
- if (cache.messages[index] !== message) {
- cache.messages.splice(index, 0, message);
- }
- if (
- !message.isMyselfAuthor() &&
- options.incrementUnread &&
- message.getType() !== 'notification'
- ) {
- this._incrementUnreadCounter();
- }
- },
- /**
- * Gets messages from thread
- *
- * @override
- * @private
- * @param {Array} [pDomain] filter on the messages of the channel
- * @param {boolean} [loadMore] Whether it should load more message
- * @return {Promise<mail.model.Message[]>} resolved with list of messages
- */
- _fetchMessages: function (pDomain, loadMore) {
- var self = this;
- var domain = this._getThreadDomain();
- var cache = this._getCache(pDomain);
- if (pDomain) {
- domain = domain.concat(pDomain || []);
- }
- if (loadMore) {
- var minMessageID = cache.messages[0].getID();
- domain = [['id', '<', minMessageID]].concat(domain);
- }
- return this._rpc({
- model: 'mail.message',
- method: 'message_fetch',
- args: [domain],
- kwargs: this._getFetchMessagesKwargs(),
- }).then(function (messages) {
- if (!cache.allHistoryLoaded) {
- cache.allHistoryLoaded = messages.length < self._FETCH_LIMIT;
- }
- cache.loaded = true;
- _.each(messages, function (message) {
- self.call('mail_service', 'addMessage', message, {
- silent: true,
- domain: pDomain,
- });
- });
- cache = self._getCache(pDomain || []);
- return cache.messages;
- });
- },
- /**
- * Get thread content from the cache
- * Useful to get cached messages.
- *
- * @private
- * @param {Array} domain
- * @return {Object|undefined}
- */
- _getCache: function (domain) {
- var stringifiedDomain = JSON.stringify(domain || []);
- if (!this._cache[stringifiedDomain]) {
- this._cache[stringifiedDomain] = {
- allHistoryLoaded: false,
- loaded: false,
- messages: [],
- };
- }
- return this._cache[stringifiedDomain];
- },
- /**
- * Get the kwargs that are passed to the server with the 'message_fetch'
- * RPC.
- *
- * @private
- * @returns {Object}
- */
- _getFetchMessagesKwargs: function () {
- return {
- limit: this._FETCH_LIMIT,
- context: session.user_context
- };
- },
- /**
- * Get the domain to fetch all the messages in the current thread
- *
- * @abstract
- * @private
- * @returns {Array}
- */
- _getThreadDomain: function () {},
-});
-
-return SearchableThread;
-
-});
diff --git a/addons/mail/static/src/js/models/threads/thread.js b/addons/mail/static/src/js/models/threads/thread.js
deleted file mode 100644
index 1e99ee84c11..00000000000
--- a/addons/mail/static/src/js/models/threads/thread.js
+++ /dev/null
@@ -1,383 +0,0 @@
-odoo.define('mail.model.Thread', function (require) {
-"use strict";
-
-var emojis = require('mail.emojis');
-var AbstractThread = require('mail.model.AbstractThread');
-var mailUtils = require('mail.utils');
-
-var ServicesMixin = require('web.ServicesMixin');
-
-/**
- * This is the super class modeling threads in JS.
- * Such threads are stored in the mail manager, and any piece of JS code whose
- * logic relies on threads must ideally interact with such objects.
- *
- * In particular, channels and mailboxes are two different kinds of threads.
- */
-var Thread = AbstractThread.extend(ServicesMixin, {
- // max number of fetched messages from the server
- _FETCH_LIMIT: 30,
- /**
- * @override
- * @param {Object} params
- * @param {mail.Manager} params.parent
- * @param {Object} params.data
- * @param {string} [params.data.channel_type]
- * @param {string} params.data.name
- * @param {string} [params.data.type]
- */
- init: function (params) {
- this._super.apply(this, arguments);
- // threads are not detached by default
- this._detached = false;
- // on 1st request to getPreview, fetch data if incomplete. Otherwise it
- // means that there is no message in this channel.
- this._previewed = false;
- this._type = params.data.type || params.data.channel_type;
- },
-
- //--------------------------------------------------------------------------
- // Public
- //--------------------------------------------------------------------------
-
- /**
- * Updates the _detached state of the thread. Must be overridden to reflect
- * the new state in the interface.
- */
- close: function () {
- this._detached = false;
- this._folded = false;
- this._warnUpdatedWindowState();
- },
- /**
- * Updates the _detached state of the thread. Must be overridden to reflect
- * the new state in the interface.
- *
- * @param {Object} [options={}]
- * @param {boolean} [options.keepFoldState=false] if set, keep the fold state
- * of this thread. Otherwise unfold it while detaching it.
- * @param {boolean} [options.passively=false] if set, if the thread window
- * will be created passively.
- * @returns {Promise}
- */
- detach: function (options) {
- options = options || {};
- this._detached = true;
- this._folded = options.keepFoldState ? this._folded : false;
- return this._warnUpdatedWindowState({
- passively: options.passively,
- });
- },
- /**
- * Fetch the list of messages in this thread.
- * By default, a thread has no messages.
- *
- * Note that this method only returns some messages, as we do not want to
- * fetch all messages of a thread at once, just to read the last message.
- *
- * As a result, this method fetches only some messages of the thread,
- * starting from the last message in the thread. At most, it fetches
- * `this.LIMIT` number of messages at a time.
- *
- * @abstract
- * @returns {Promise<mail.model.Message[]>}
- */
- fetchMessages: function () {
- return Promise.resolve([]);
- },
- /**
- * Updates the folded state of the thread. Must be overridden to reflect
- * the new state in the interface.
- *
- * @override
- * @param {boolean} folded
- */
- fold: function (folded) {
- this._super.apply(this, arguments);
- this._detached = true; // auto-detach the thread
- this._warnUpdatedWindowState();
- },
- /**
- * Get the list of available commands for the thread.
- * By default, threads do not have any available command.
- *
- * @returns {Array}
- */
- getCommands: function () {
- return [];
- },
- /**
- * Get the listeners of the thread.
- * By default, a thread has not listener.
- *
- * @abstract
- * @returns {Promise<Array<Object[]>>}
- */
- getMentionPartnerSuggestions: function () {
- return Promise.resolve([]);
- },
- /**
- * Returns the information required to render the preview of this channel.
- *
- * @returns {Object} a valid object for the rendering of previews
- * (@see mail.Preview template)
- */
- getPreview: function () {
- return {
- id: this.getID(),
- imageSRC: '/mail/static/src/img/smiley/avatar.jpg',
- isTwoUserThread: this.isTwoUserThread(),
- status: this.getStatus(),
- title: this.getName(),
- unreadCounter: this.getUnreadCounter(),
- };
- },
- /**
- * @returns {string}
- */
- getType: function () {
- return this._type;
- },
- /**
- * State whether this channel has been previewed
- *
- * A channel that has been previewed means that it had the necessary data
- * to display its preview format. A channel needs its meta data and the
- * last message in order to build its preview format.
- *
- * This is useful in order to not fetch preview info on this channel more
- * than once on channels that have no message at all.
- *
- * Any received message updates the last_message, so a channel should
- * always have all the necessary information to display its preview after
- * the 1st time.
- *
- * @returns {boolean}
- */
- hasBeenPreviewed: function () {
- return this._previewed;
- },
- /**
- * State whether there are unread messages in this thread
- *
- * @returns {boolean}
- */
- hasUnreadMessages: function () {
- return this._unreadCounter !== 0;
- },
- /**
- * Increments the needaction counter of this thread.
- *
- * @abstract
- */
- incrementNeedactionCounter: function () {},
- /**
- * States whether the thread should be auto-selected on creation
- *
- * Note that this is not of the responsibility of the thread: it only
- * provides guidance for the object that uses threads (e.g. mail.Discuss
- * must listen on threads and auto-select the thread if autoswitch is set).
- *
- * By default, threads are not in autoswitch mode.
- *
- * @returns {boolean}
- */
- isAutoswitch: function () {
- return false;
- },
- /**
- * States whether this thread is a channel or not. A thread is a channel if
- * it is an instance of mail.model.Channel (direct or indirect).
- * By default, any thread is not a channel
- *
- * @returns {boolean}
- */
- isChannel: function () {
- return false;
- },
- /**
- * States whether this thread is detached or not.
- * A thread that is detached must have a thread window linked to itself.
- *
- * @return {boolean}
- */
- isDetached: function () {
- return this._detached;
- },
- /**
- * States whether the thread is linked to a document
- * By default, threads are not linked to a document.
- *
- * @returns {boolean}
- */
- isLinkedToDocument: function () {
- return false;
- },
- /**
- * States whether this thread has the mass mailing setting active or not.
- * This is a server-side setting, that determine the type of composer that
- * is used (basic or extended composer).
- *
- * By default, thread have not mass-mailings feature set.
- *
- * @return {boolean}
- */
- isMassMailing: function () {
- return false;
- },
- /**
-
- * States whether the thread is moderated or not.
- * By default, threads are not moderated.
- *
- * @returns {boolean}
- */
- isModerated: function () {
- return false;
- },
- /**
- * States whether the current user is moderator of this thread.
- * By default, the current user is not moderator of this thread.
- *
- * @returns {boolean}
- */
- isMyselfModerator: function () {
- return false;
- },
- /**
- * States whether this is a two-user thread or not. In particular, public
- * and private channels are not two-user threads, but DMs and Livechats are
- * two-user threads. Two-user threads are used for communication between
- * two users. By default, any thread is not two-user thread.
- *
- * @returns {boolean}
- */
- isTwoUserThread: function () {
- return false;
- },
- /**
- * Mark this channel as previewed
- *
- * This is useful in order to not fetch preview info on this channel
- * is the server has no preview in the first place.
- *
- * Note: preview fetch is useful only when the channel contains messages
- * that have not been fetched at all. After that, this channel instance
- * is updated regularly so that the most up-to-date info are available
- * to make the preview of this channel.
- */
- markAsPreviewed: function () {
- this._previewed = true;
- },
- /**
- * Set the name of this thread
- *
- * @param {string} newName
- */
- setName: function (newName) {
- this._name = newName;
- },
-
- //--------------------------------------------------------------------------
- // Private
- //--------------------------------------------------------------------------
-
- /**
- * @abstract
- * @private
- * @return {Promise}
- */
- _fetchMessages: function () {
- return Promise.resolve();
- },
- /**
- * Replace character representations of emojis by their unicode
- * representation in the provided HTML string. Note that the provided html
- * string is altered by this function.
- *
- * @param {string} htmlString
- * @returns {string}
- */
- _generateEmojis: function (htmlString) {
- _.each(emojis, function (emoji) {
- _.each(emoji.sources, function (source) {
- var escapedSource = String(source).replace(/([.*+?=^!:${}()|[\]/\\])/g, '\\$1');
- var regexp = new RegExp("(\\s|^)(" + escapedSource + ")(?=\\s|$)", 'g');
- htmlString = htmlString.replace(regexp, '$1' + emoji.unicode);
- });
- });
- return htmlString;
- },
- /**
- * Increment the unread counter of this thread by 1 unit, and warn that the
- * counter has been changed.
- *
- * @override
- * @private
- */
- _incrementUnreadCounter: function () {
- this._super.apply(this, arguments);
- this._warnUpdatedUnreadCounter();
- },
- /**
- * Post a message on the thread.
- *
- * This method must be completed by concrete threads,
- * As it currently only pre-process the messages at the moment.
- *
- * @abstract
- * @private
- * @param {Object} data
- * @returns {Promise<Object>} resolved with the message object to be sent
- * to the server
- */
- _postMessage: function (data) {
- // 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.
- var body = mailUtils.parseAndTransform(_.str.trim(data.content), mailUtils.addLink);
- body = this._generateEmojis(body);
- var messageData = {
- partner_ids: data.partner_ids,
- channel_ids: data.channel_ids,
- body: body,
- attachment_ids: data.attachment_ids,
- canned_response_ids: data.canned_response_ids,
- };
- if ('subject' in data) {
- messageData.subject = data.subject;
- }
- return this._super.apply(this, arguments).then(function () {
- return messageData;
- });
- },
- /**
- * Warn on the mail bus that the unread counter has been updated
- *
- * @override
- * @private
- */
- _warnUpdatedUnreadCounter: function () {
- this.call('mail_service', 'getMailBus').trigger('update_thread_unread_counter', this);
- },
- /**
- * Warn other mail components that the window state of this thread has
- * changed (it has become closed, folded, unfolded, detached, etc.)
- *
- * @private
- * @param {Object} [options={}]
- * @param {boolean} [options.passively=false]
- * @returns {Promise}
- */
- _warnUpdatedWindowState: function (options) {
- options = options || {};
- return this.call('mail_service', 'updateThreadWindow', this.getID(), options);
- },
-});
-
-return Thread;
-
-});
diff --git a/addons/mail/static/src/js/models/threads/two_user_channel.js b/addons/mail/static/src/js/models/threads/two_user_channel.js
deleted file mode 100644
index 54a4145bd13..00000000000
--- a/addons/mail/static/src/js/models/threads/two_user_channel.js
+++ /dev/null
@@ -1,32 +0,0 @@
-odoo.define('mail.model.TwoUserChannel', function (require) {
-"use strict";
-
-var Channel = require('mail.model.Channel');
-
-var TwoUserChannel = Channel.extend({
- /**
- * @override
- * @returns {boolean}
- */
- isTwoUserThread: function () {
- return true;
- },
- /**
- * Unpin from two-user thread
- *
- * @override
- * @returns {Promise} resolve when unsubscribed
- */
- unsubscribe: function () {
- return this._rpc({
- model: 'mail.channel',
- method: 'channel_pin',
- args: [this.getUUID(), false],
- });
- },
-
-});
-
-return TwoUserChannel;
-
-});
diff --git a/addons/mail/static/src/js/models/utils/cc_throttle_function.js b/addons/mail/static/src/js/models/utils/cc_throttle_function.js
deleted file mode 100644
index b4b87087e6d..00000000000
--- a/addons/mail/static/src/js/models/utils/cc_throttle_function.js
+++ /dev/null
@@ -1,181 +0,0 @@
-odoo.define('mail.model.CCThrottleFunctionObject', function (require) {
-"use strict";
-
-var Class = require('web.Class');
-
-/**
- * This object models the behaviour of the clearable and cancellable (CC)
- * throttle version of a provided function.
- */
-var CCThrottleFunctionObject = Class.extend({
-
- /**
- * @param {Object} params
- * @param {integer} params.duration duration of the 'cooldown' phase, i.e.
- * the minimum duration between the most recent function call that has
- * been made and the following function call.
- * @param {function} params.func provided function for making the CC
- * throttled version.
- */
- init: function (params) {
- this._arguments = undefined;
- this._cooldownTimeout = undefined;
- this._duration = params.duration;
- this._func = params.func;
- this._shouldCallFunctionAfterCD = false;
- },
-
- //--------------------------------------------------------------------------
- // Public
- //--------------------------------------------------------------------------
-
- /**
- * Cancel any buffered function call, but keep the cooldown phase running.
- */
- cancel: function () {
- this._arguments = undefined;
- this._shouldCallFunctionAfterCD = false;
- },
- /**
- * Clear the internal throttle timer, so that the following function call
- * is immediate. For instance, if there is a cooldown stage, it is aborted.
- */
- clear: function () {
- if (this._cooldownTimeout) {
- clearTimeout(this._cooldownTimeout);
- this._onCooldownTimeout();
- }
- },
- /**
- * 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.
- */
- do: function () {
- this._arguments = Array.prototype.slice.call(arguments);
- if (this._cooldownTimeout === undefined) {
- this._callFunction();
- } else {
- this._shouldCallFunctionAfterCD = true;
- }
- },
-
- //--------------------------------------------------------------------------
- // Private
- //--------------------------------------------------------------------------
-
- /**
- * Immediately calls the function with arguments of last buffered function
- * call. It initiates the cooldown stage after this function call.
- *
- * @private
- */
- _callFunction: function () {
- this._func.apply(null, this._arguments);
- this._cooldown();
- },
- /**
- * Called when the function has been successfully called. The following
- * calls to the function with this object should suffer a "cooldown stage",
- * which prevents the function from being called until this stage has ended.
- *
- * @private
- */
- _cooldown: function () {
- this.cancel();
- this._cooldownTimeout = setTimeout(
- this._onCooldownTimeout.bind(this),
- this._duration
- );
- },
-
- //--------------------------------------------------------------------------
- // Handlers
- //--------------------------------------------------------------------------
-
- /**
- * Called when the cooldown stage ended from timeout. Calls the function if
- * a function call was buffered.
- *
- * @private
- */
- _onCooldownTimeout: function () {
- if (this._shouldCallFunctionAfterCD) {
- this._callFunction();
- } else {
- this._cooldownTimeout = undefined;
- }
- },
-});
-
-return CCThrottleFunctionObject;
-
-});
-
-
-odoo.define('mail.model.CCThrottleFunction', function (require) {
-"use strict";
-
-var CCThrottleFunctionObject = require('mail.model.CCThrottleFunctionObject');
-
-/**
- * A function that creates a cancellable and clearable (CC) throttle version
- * of a provided function.
- *
- * 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.
- *
- * This throttle version has the following interesting properties:
- *
- * - cancellable: it allows removing a buffered function call during the
- * cooldown phase, but it keeps the cooldown phase running.
- * - clearable: it allows to clear the internal clock of the throttled function,
- * so that any cooldown phase is immediately ending.
- *
- * @param {Object} params
- * @param {integer} params.duration a duration for the throttled behaviour,
- * in milli-seconds.
- * @param {function} params.func the function to throttle
- * @returns {function} the cancellable and clearable throttle version of the
- * provided function in argument.
- */
-var CCThrottleFunction = function (params) {
- var duration = params.duration;
- var func = params.func;
-
- var throttleFunctionObject = new CCThrottleFunctionObject({
- duration: duration,
- func: func,
- });
-
- var callable = function () {
- return throttleFunctionObject.do.apply(throttleFunctionObject, arguments);
- };
- callable.cancel = function () {
- throttleFunctionObject.cancel();
- };
- callable.clear = function () {
- throttleFunctionObject.clear();
- };
-
- return callable;
-};
-
-return CCThrottleFunction;
-
-});
diff --git a/addons/mail/static/src/js/models/utils/timer.js b/addons/mail/static/src/js/models/utils/timer.js
deleted file mode 100644
index 9aaae471d2d..00000000000
--- a/addons/mail/static/src/js/models/utils/timer.js
+++ /dev/null
@@ -1,69 +0,0 @@
-odoo.define('mail.model.Timer', function (require) {
-"use strict";
-
-var Class = require('web.Class');
-
-/**
- * This class creates a timer which, when times out, calls a function.
- */
-var Timer = Class.extend({
-
- /**
- * Instantiate a new timer. Note that the timer is not started on
- * initialization (@see start method).
- *
- * @param {Object} params
- * @param {number} params.duration duration of timer before timeout in
- * milli-seconds.
- * @param {function} params.onTimeout function that is called when the
- * timer times out.
- */
- init: function (params) {
- this._duration = params.duration;
- this._timeout = undefined;
- this._timeoutCallback = params.onTimeout;
- },
-
- //--------------------------------------------------------------------------
- // Public
- //--------------------------------------------------------------------------
-
- /**
- * Clears the countdown of the timer.
- */
- clear: function () {
- clearTimeout(this._timeout);
- },
- /**
- * Resets the timer, i.e. resets its duration.
- */
- reset: function () {
- this.clear();
- this.start();
- },
- /**
- * Starts the timer, i.e. after a certain duration, it times out and calls
- * a function back.
- */
- start: function () {
- this._timeout = setTimeout(this._onTimeout.bind(this), this._duration);
- },
-
- //--------------------------------------------------------------------------
- // Handler
- //--------------------------------------------------------------------------
-
- /**
- * Called when the timer times out, calls back a function on timeout.
- *
- * @private
- */
- _onTimeout: function () {
- this._timeoutCallback();
- },
-
-});
-
-return Timer;
-
-});
diff --git a/addons/mail/static/src/js/models/utils/timers.js b/addons/mail/static/src/js/models/utils/timers.js
deleted file mode 100644
index 757945223f7..00000000000
--- a/addons/mail/static/src/js/models/utils/timers.js
+++ /dev/null
@@ -1,82 +0,0 @@
-odoo.define('mail.model.Timers', function (require) {
-"use strict";
-
-var Timer = require('mail.model.Timer');
-
-var Class = require('web.Class');
-
-/**
- * This class lists several timers that use a same callback and duration.
- */
-var Timers = Class.extend({
-
- /**
- * Instantiate a new list of timers
- *
- * @param {Object} params
- * @param {integer} params.duration duration of the underlying timers from
- * start to timeout, in milli-seconds.
- * @param {function} params.onTimeout a function to call back for underlying
- * timers on timeout.
- */
- init: function (params) {
- this._duration = params.duration;
- this._timeoutCallback = params.onTimeout;
- this._timers = {};
- },
-
- //--------------------------------------------------------------------------
- // Public
- //--------------------------------------------------------------------------
-
- /**
- * Register a timer with ID `timerID` to start.
- *
- * - an already registered timer with this ID is reset.
- * - (optional) can provide a list of arguments that is passed to the
- * function callback when timer times out.
- *
- * @param {Object} params
- * @param {Array} [params.timeoutCallbackArguments]
- * @param {integer} params.timerID
- */
- registerTimer: function (params) {
- var timerID = params.timerID;
- if (this._timers[timerID]) {
- this._timers[timerID].clear();
- }
- var timerParams = {
- duration: this._duration,
- onTimeout: this._timeoutCallback,
- };
- if ('timeoutCallbackArguments' in params) {
- timerParams.onTimeout = this._timeoutCallback.bind.apply(
- this._timeoutCallback,
- [null].concat(params.timeoutCallbackArguments)
- );
- } else {
- timerParams.onTimeout = this._timeoutCallback;
- }
- this._timers[timerID] = new Timer(timerParams);
- this._timers[timerID].start();
- },
- /**
- * Unregister a timer with ID `timerID`. The unregistered timer is aborted
- * and will not time out.
- *
- * @param {Object} params
- * @param {integer} params.timerID
- */
- unregisterTimer: function (params) {
- var timerID = params.timerID;
- if (this._timers[timerID]) {
- this._timers[timerID].clear();
- delete this._timers[timerID];
- }
- },
-
-});
-
-return Timers;
-
-});
diff --git a/addons/mail/static/src/js/services/mail_document_thread_manager.js b/addons/mail/static/src/js/services/mail_document_thread_manager.js
deleted file mode 100644
index 652567baf14..00000000000
--- a/addons/mail/static/src/js/services/mail_document_thread_manager.js
+++ /dev/null
@@ -1,260 +0,0 @@
-odoo.define('mail.Manager.DocumentThread', function (require) {
-"use strict";
-
-var MailManager = require('mail.Manager');
-var DocumentThread = require('mail.model.DocumentThread');
-
-var session = require('web.session');
-
-/**
- * Document Thread Manager
- *
- * This part of the mail manager extends the core functionnalities of mail
- * manager with document threads, in addition to enable cross tab
- * synchronization of document thread windows, through the local storage.
- *
- * Note that it assumes that the Mail Window Manager is loaded
- */
-MailManager.include({
- DOCUMENT_THREAD_MESSAGE_KEY: 'mail.document_threads_last_message',
- DOCUMENT_THREAD_STATE_KEY: 'mail.document_threads_state',
-
- start: function () {
- this._super.apply(this, arguments);
- this._documentThreadWindowStates = {};
- this._tabId = this.call('bus_service', 'getTabId');
- // retrieve the open DocumentThreads from the localStorage
- var localStorageLength = this.call('local_storage', 'length');
- for (var i = 0; i < localStorageLength; i++) {
- var key = this.call('local_storage', 'key', i);
- if (key.indexOf(this.DOCUMENT_THREAD_STATE_KEY) === 0) {
- // key format: DOCUMENT_THREAD_STATE_KEY + '/' + threadId
- var documentThreadId = key.substring(this.DOCUMENT_THREAD_STATE_KEY.length+1);
- this._documentThreadWindowStates[documentThreadId] =
- this.call('local_storage', 'getItem', key);
- }
- }
- this._mailBus.on('messaging_ready', this, this._updateDocumentThreadWindows.bind(this, this._documentThreadWindowStates));
- // listen to localStorage changes to synchronize DocumentThread's
- // windows between tabs
- this.call('local_storage', 'onStorage', this, this._onStorage);
- },
-
- //--------------------------------------------------------------------------
- // Public
- //--------------------------------------------------------------------------
-
- /**
- * For new posted message of current user in a document thread: store it in
- * the localStorage to make it appear on the other tabs.
- *
- * Note: 'addMessage' modifies the message in place (by setting the channel_id,
- * so we must store the message in the localStorage *before* calling
- * 'addMessage')
- *
- * @override
- * @param {Object} data
- * @param {integer} data.id server ID of the message
- * @param {Object} [options]
- * @param {boolean} [options.postedFromDocumentThread=false]
- */
- addMessage: function (data, options) {
- var message = this.getMessage(data.id);
- if (
- !message &&
- options &&
- options.postedFromDocumentThread
- ) {
- var key = this.DOCUMENT_THREAD_MESSAGE_KEY;
- this.call('local_storage', 'setItem', key, {
- messageData: data,
- tabId: this._tabId,
- });
- }
- return this._super.apply(this, arguments);
- },
- /**
- * Returns a document thread corresponding to the given model and resID.
- *
- * @param {string} model of the document thread, if it exists
- * @return {integer} resID of the document thread, if it exists
- */
- getDocumentThread: function (model, resID) {
- return this.getThread(model + '_' + resID);
- },
- /**
- * Add a new document thread, or get if it exists already.
- *
- * Also, if a name is provided for the document thread, it overwrites the
- * previous one.
- *
- * @param {Object} params
- * @param {interger[]} [params.message_ids] the list of message ids linked
- * to the document (if not given, they will be fetched before fetching the
- * messages)
- * @param {string} [params.name] if provided, overwrites the name of the
- * existing document thread
- * @param {integer} params.resID
- * @param {string} params.resModel
- * @return {mail.model.DocumentThread}
- */
- getOrAddDocumentThread: function (params) {
- var thread = this.getDocumentThread(params.resModel, params.resID);
- if (!thread) {
- thread = new DocumentThread({
- parent: this,
- data: {
- messageIDs: params.messageIDs,
- name: params.name,
- resID: params.resID,
- resModel: params.resModel,
- },
- });
- this._threads.push(thread);
- } else {
- if ('messageIDs' in params) {
- thread.setMessageIDs(params.messageIDs);
- }
- if ('name' in params && params.name) {
- // document thread may have a change of name
- thread.setName(params.name);
- }
- }
- return thread;
- },
- /**
- * Open the document thread form if discuss is open
- *
- * @override
- * @param {integer|string} threadID
- */
- openThread: function (threadID) {
- var thread = this.getThread(threadID);
- if (
- thread &&
- thread.getType() === 'document_thread' &&
- this._isDiscussOpen()
- ) {
- var resModel = thread.getDocumentModel();
- var resID = thread.getDocumentID();
- this._redirectToDocument(resModel, resID);
- } else {
- this._super.apply(this, arguments);
- }
- },
- /**
- * Updates the state of a given document thread (stored in localStorage).
- * Garbage collects windows previously marked as 'closed' (assuming that the
- * info has already been processed by the other tabs).
- *
- * @param {string} threadID ID of a document thread
- * @param {Object} state
- * @param {string} state.name name of the document thread
- * @param {string} state.windowState ('closed', 'folded' or 'open')
- */
- updateDocumentThreadState: function (threadID, state) {
- this._documentThreadWindowStates[threadID] = state;
- this.call('local_storage', 'setItem', this.DOCUMENT_THREAD_STATE_KEY + '/' + threadID, {
- state: state,
- tabId: this._tabId,
- });
- },
-
- //--------------------------------------------------------------------------
- // Private
- //--------------------------------------------------------------------------
-
- /**
- * For new messages, postprocess document thread linked to this message.
- * If there is no such document thread, create it and register its ID to
- * the message's thread IDs.
- *
- * @override
- * @private
- * @param {mail.model.Message} message
- */
- _addNewMessagePostprocessThread: function (message) {
- var resModel = message.getDocumentModel();
- var resID = message.getDocumentID();
- if (resModel && resModel !== 'mail.channel' && resID) {
- this.getOrAddDocumentThread({
- name: message.getDocumentName(),
- resID: resID,
- resModel: resModel,
- });
- }
- return this._super.apply(this, arguments);
- },
- /**
- * Updates the thread windows related to document threads.
- *
- * @param {Object} documentThreadStates - keys are strings 'resModel_resID',
- * and values are the state of the corresponding document thread ('open'
- * or 'folded')
- */
- _updateDocumentThreadWindows: function (documentThreadStates) {
- var self = this;
- _.each(documentThreadStates, function (state, key) {
- var info = key.split('_');
- var documentThread = self.getOrAddDocumentThread({
- name: state.name,
- resID: parseInt(info[1]),
- resModel: info[0],
- });
- if (state.windowState === 'closed') {
- documentThread.close({
- skipCrossTabSync: true,
- });
- } else {
- documentThread.fold(state.windowState === 'folded', {
- skipCrossTabSync: true,
- });
- self.openThreadWindow(documentThread.getID(), {
- keepFoldState: true,
- skipCrossTabSync: true,
- });
- }
- });
- },
-
- //--------------------------------------------------------------------------
- // Handlers
- //--------------------------------------------------------------------------
-
- /**
- * Called each time a localStorage key is updated.
- *
- * @private
- * @param {StorageEvent} ev
- */
- _onStorage: function (ev) {
- var value;
- try {
- value = JSON.parse(ev.newValue);
- } catch (err) {
- return;
- }
- if (ev.key.indexOf(this.DOCUMENT_THREAD_STATE_KEY) === 0) {
- if (value.tabId === this._tabId) {
- return;
- }
- // key format: DOCUMENT_THREAD_STATE_KEY + '/' + threadId
- var documentThreadId = ev.key.substring(this.DOCUMENT_THREAD_STATE_KEY.length+1);
- var param = {};
- param[documentThreadId] = value.state;
- this._updateDocumentThreadWindows(param);
- } else if (ev.key === this.DOCUMENT_THREAD_MESSAGE_KEY) {
- if (value.tabId === this._tabId) {
- return;
- }
- if (value.messageData) {
- this.addMessage(value.messageData);
- }
- }
- },
-
-});
-
-return MailManager;
-
-});
diff --git a/addons/mail/static/src/js/services/mail_manager.js b/addons/mail/static/src/js/services/mail_manager.js
deleted file mode 100644
index f52af585f94..00000000000
--- a/addons/mail/static/src/js/services/mail_manager.js
+++ /dev/null
@@ -1,1356 +0,0 @@
-odoo.define('mail.Manager', function (require) {
-"use strict";
-
-/**
- * This service is responsible for anything related to mail in JS.
- * In particular, it stores all threads and messages that have been fetched
- * from the server.
- *
- * As this service does quite a lot of stuffs, it is split into many different
- * files. For instance, the handling of notification in the longpoll is in
- * mail_notification_manager.js. This file contains the core functionnality
- * of the mail service.
- *
- * The main point of entry of this service is to call public methods by mean
- * of a service call.
- *
- * @example
- *
- * To get all threads that have been fetched from the server:
- *
- * this.call('mail_service', 'getThreads');
- *
- * To get a particular thread with server ID 1:
- *
- * this.call('mail_service', 'getThread', 1);
- */
-var AbstractService = require('web.AbstractService');
-
-var DMChat = require('mail.model.DMChat');
-var Livechat = require('mail.model.Livechat');
-var Mailbox = require('mail.model.Mailbox');
-var MailFailure = require('mail.model.MailFailure');
-var Message = require('mail.model.Message');
-var MultiUserChannel = require('mail.model.MultiUserChannel');
-var mailUtils = require('mail.utils');
-var utils = require('web.utils');
-
-var Bus = require('web.Bus');
-var config = require('web.config');
-var core = require('web.core');
-var session = require('web.session');
-
-var _t = core._t;
-
-var PREVIEW_MSG_MAX_SIZE = 350; // optimal for native english speakers
-
-var MailManager = AbstractService.extend({
- dependencies: ['ajax', 'bus_service', 'local_storage'],
- _isReady: false,
- _ODOOBOT_ID: ["ODOOBOT", "ODOOBOT"], // authorID for transient messages
-
- /**
- * @override
- */
- start: function () {
- this._super.apply(this, arguments);
-
- this._throttleNotifyChannelFetched = _.throttle(this._notifyChannelFetched.bind(this), 3000);
- // List of channel that have requested the service to notify when
- // it has been fetched, so that throttled RPCs batch the ids.
- this._toNotifyChannelFetchedIDs = [];
-
- this._initializeInternalState();
- this._listenOnBuses();
- this._fetchMailStateFromServer();
- },
-
- //--------------------------------------------------------------------------
- // Public
- //--------------------------------------------------------------------------
-
- /**
- * Add a message
- *
- * @param {Object} data message data
- * @param {integer} data.id
- * @param {string} [data.moderation_status]
- * @param {Object} [options]
- * @param {Array} [options.domain]
- * @param {boolean} [options.incrementUnread] whether we should increment
- * the unread_counter of channel.
- * @param {boolean} [options.silent] whether it should inform in the mailBus
- * of the newly created message.
- * @returns {Promise<mail.model.Message>}
- */
- addMessage: function (data, options) {
- options = options || {};
- var message = this.getMessage(data.id);
- var prom;
- if (!message) {
- prom = this._addNewMessage(data, options);
- } else {
- if (data.moderation_status === 'accepted') {
- message.setModerationStatus('accepted', {
- additionalThreadIDs: data.channel_ids
- });
- }
- this._addMessageToThreads(message, options);
- prom = Promise.resolve(message);
- }
- return prom;
- },
- /**
- * Creates a channel, can be either a true channel or a DM chat based on
- * `type`
- *
- * @param {integer|string} name id of partner (in case of dm) or name
- * @param {string} type ['dm_chat', 'public', 'private']
- * @returns {Promise<integer>} resolved with ID of the newly created
- * channel
- */
- createChannel: function (name, type) {
- if (type === 'dm_chat') {
- return this._createDMChat(name);
- } else {
- return this._createChannel(name, type);
- }
- },
- /**
- * Returns the list of canned responses
- * A canned response is a pre-formatted text that is triggered with
- * some keystrokes, such as with ':'.
- *
- * @returns {Array} array of Objects (mail.shortcode)
- */
- getCannedResponses: function () {
- return this._cannedResponses;
- },
- /**
- * Returns a channel corresponding to the given id.
- *
- * @param {integer} threadID
- * @returns {mail.model.Channel|undefined} the channel, if it exists
- */
- getChannel: function (threadID) {
- return _.find(this._threads, function (thread) {
- return thread.isChannel() && (thread.getID() === threadID);
- });
- },
- /**
- * Returns a list of channels
- *
- * @returns {mail.model.Channel[]} list of channels
- */
- getChannels: function () {
- return _.filter(this._threads, function (thread) {
- return thread.isChannel();
- });
- },
- /**
- * Returns the content that will be shown in the mail navbar dropdown
- *
- * @param {mail.model.Channel[]} channels
- * @returns {Promise<Object[]>} resolved with list of channel previews
- */
- getChannelPreviews: function (channels) {
- var self = this;
- return this._getChannelPreviews(channels).then(function (previews) {
- var sortedPreviews = self._sortPreviews(previews);
- return sortedPreviews;
- });
- },
- /**
- * @returns {web.Bus} the mail bus
- */
- getMailBus: function () {
- return this._mailBus;
- },
- /**
- * Returns the record id of ir.ui.menu for Discuss
- *
- * @returns {integer} record id
- */
- getDiscussMenuID: function () {
- return this._discussMenuID;
- },
- /**
- * Gets direct message channel
- *
- * @param {integer} partnerID
- * @returns {Object|undefined} channel
- */
- getDMChatFromPartnerID: function (partnerID) {
- return _.find(this._threads, function (thread) {
- return thread.getType() === 'dm_chat' &&
- thread.getDirectPartnerID() === partnerID;
- });
- },
- /**
- * @param {string} mailboxID
- * @returns {mail.model.Mailbox} the mailbox, if any
- */
- getMailbox: function (mailboxID) {
- return _.find(this._threads, function (thread) {
- return thread.getID() === 'mailbox_' + mailboxID;
- });
- },
- /**
- * Returns a list of mail failures
- *
- * @returns {mail.model.MailFailure[]} list of mail failures
- */
- getMailFailures: function () {
- return this._mailFailures;
- },
- /**
- * Get partners as mentions from a chatter
- * Typically all employees as partner suggestions.
- *
- * @returns {Array<Array<Object[]>>}
- */
- getMentionPartnerSuggestions: function () {
- return this._mentionPartnerSuggestions;
- },
- /**
- * Gets message from its ID
- *
- * @param {integer} messageID
- * @returns {mail.model.Message|undefined} the matched message (if any)
- */
- getMessage: function (messageID) {
- return _.find(this._messages, function (message) {
- return message.getID() === messageID;
- });
- },
- /**
- * Returns the previews to display in the systray's messaging menu.
- *
- * @param {string} [filter=undefined] specify 'chat' or 'channels' to only
- * get previews for that type of threads
- * @returns {Promise<Object[]>} resolved with list of objects that have a
- * valid format for rendering a messaging menu preview items.
- */
- getSystrayPreviews: function (filter) {
- var self = this;
-
- var channelDef = this._getSystrayChannelPreviews(filter);
- var inboxDef = this._getSystrayInboxPreviews(filter);
- var failureDef = this._getSystrayMailFailurePreviews(filter);
-
- return Promise.all([channelDef, inboxDef, failureDef])
- .then(function (result) { //previewsChannel, previewsInbox, previewsFailure
- // order: failures > inbox > channel, each group must be sorted
- var previewsChannel = self._sortPreviews(result[0]);
- var previewsInbox = self._sortPreviews(result[1]);
- var previewsFailure = self._sortPreviews(result[2]);
- return _.union(previewsFailure, previewsInbox, previewsChannel);
- });
- },
- /**
- * Get the list of channel IDs where the current user is a moderator
- *
- * @returns {integer[]}
- */
- getModeratedChannelIDs: function () {
- return this._moderatedChannelIDs || [];
- },
- /**
- * Get the OdooBot ID, which is the default authorID for transient messages
- *
- * @returns {Array<string>}
- */
- getOdoobotID: function () {
- return this._ODOOBOT_ID;
- },
- /**
- * Returns thread matching provided ID, if any
- *
- * @param {string|integer} threadID
- * @returns {mail.model.Thread|undefined} the thread, if it exists
- */
- getThread: function (threadID) {
- return _.find(this._threads, function (thread) {
- return thread.getID() === threadID;
- });
- },
- /**
- * Returns a list of threads
- *
- * @returns {mail.model.Thread[]} list of threads
- */
- getThreads: function () {
- return this._threads;
- },
- /**
- * States whether the current user is a moderator or not
- *
- * @returns {boolean}
- */
- isMyselfModerator: function () {
- return this._isMyselfModerator;
- },
- /**
- * States whether the mail manager is ready or not
- * This is the case when it has fetched the initial state from the server,
- * by means of the route 'mail/init_messaging'
- *
- * @returns {boolean}
- */
- isReady: function () {
- return this._isReady;
- },
- /**
- * Join an existing channel
- * See @createChannel to join a new channel
- *
- * @param {integer} channelID
- * @param {Object} [options] options to be passed on channel add
- * @returns {Promise<integer>} resolved with channelID joined
- */
- joinChannel: function (channelID, options) {
- var prom;
- var channel = this.getChannel(channelID);
- if (channel) {
- // channel already joined
- prom = Promise.resolve(channelID);
- } else {
- prom = this._joinAndAddChannel(channelID, options);
- }
- return prom;
- },
- /**
- * Mark messages as read
- *
- * @param {Array} messageIDs list of messages IDs
- * @returns {Promise} resolved when messages have been marked as read on
- * the server.
- */
- markMessagesAsRead: function (messageIDs) {
- var self = this;
- var ids = _.filter(messageIDs, function (id) {
- var message = self.getMessage(id);
- // If too many messages, not all are fetched,
- // and some might not be found
- return !message || message.isNeedaction();
- });
- if (ids.length) {
- return this._rpc({
- model: 'mail.message',
- method: 'set_message_done',
- args: [ids],
- });
- } else {
- return Promise.resolve();
- }
- },
- /**
- * @param {Object} data
- * @param {integer} data.channelID
- */
- notifyChannelFetched: function (data) {
- this._toNotifyChannelFetchedIDs = _.uniq(this._toNotifyChannelFetchedIDs.concat(data.channelID));
- this._throttleNotifyChannelFetched();
- },
- /**
- * @param {integer|string} threadID
- */
- openThread: function (threadID) {
- this._openThreadInDiscuss(threadID);
- },
- /**
- * Special redirection handling for given model and id
- *
- * If the model is res.partner, and there is a user associated with this
- * partner which isn't the current user, open the DM chat with this user.
- * Otherwhise, open the record's form view (if not current user's).
- *
- * @param {string} resModel model to open
- * @param {integer} resID record to open
- * @param {function} [dmRedirectionCallback] only used if 'res.partner',
- * a function that has a threadID as input
- */
- redirect: function (resModel, resID, dmRedirectionCallback) {
- if (resModel === 'res.partner') {
- this._redirectPartner(resModel, resID, dmRedirectionCallback);
- } else {
- this._redirectDefault(resModel, resID);
- }
- },
- /**
- * Remove the message from all of its threads
- *
- * @param {mail.model.Message} message
- */
- removeMessageFromThreads: function (message) {
- var self = this;
- _.each(message.getThreadIDs(), function (threadID) {
- var thread = self.getThread(threadID);
- if (thread) {
- thread.removeMessage(message.getID());
- }
- });
- },
- /**
- * Search among prefetched partners, using the string 'searchVal'
- *
- * @param {string} searchVal
- * @param {integer} limit max number of found partners in the response
- * @returns {Promise<Object[]>} list of found partners (matching
- * 'searchVal')
- */
- searchPartner: function (searchVal, limit) {
- var self = this;
- var partners = this._searchPartnerPrefetch(searchVal, limit);
- return new Promise(function (resolve, reject) {
- if (!partners.length) {
- resolve(self._searchPartnerFetch(searchVal, limit));
- } else {
- resolve(partners);
- }
- }).then(function (partners) {
- var suggestions = _.map(partners, function (partner) {
- return {
- id: partner.id,
- value: partner.name,
- label: partner.name
- };
- });
- return _.sortBy(suggestions, 'label');
- });
- },
- /**
- * Unstars all messages from all channels
- *
- * @returns {Promise}
- */
- unstarAll: function () {
- return this._rpc({
- model: 'mail.message',
- method: 'unstar_all',
- });
- },
-
- //--------------------------------------------------------------------------
- // Private
- //--------------------------------------------------------------------------
-
- /**
- * Adds a channel and returns it.
- * Simply returns the channel if already exists.
- *
- * @private
- * @param {Object} data
- * @param {integer} data.id id of channel
- * @param {string|Object} data.name name of channel, e.g. 'general'
- * @param {string} data.type type of channel, e.g. 'public'
- * @param {string} [data.state] e.g. 'open', 'folded'
- * @param {Object|integer} [options=undefined]
- * @param {boolean} [options.silent=false]
- * @returns {Promise<integer>} resolves with the ID of the newly or already
- * existing channel
- */
- _addChannel: function (data, options) {
- options = typeof options === 'object' ? options : {};
- var channel = this.getChannel(data.id);
- var proms = [];
- if (!channel) {
- channel = this._makeChannel(data, options);
- if (channel.getType() === 'dm_chat') {
- this._pinnedDmPartners.push(channel.getDirectPartnerID());
- }
- this._threads.push(channel);
- if (data.last_message) {
- // channel_info in mobile, necessary for showing channel
- // preview in mobile
- proms.push(this.addMessage(data.last_message));
- }
- this._sortThreads();
- if (!options.silent) {
- this._mailBus.trigger('new_channel', channel, proms);
- }
- }
- return Promise.all(proms).then(function () {
- return channel.getID();
- });
- },
- /**
- * Add a new mailbox
- *
- * @private
- * @param {Object} data
- * @param {Object} options
- * @returns {mail.model.Mailbox}
- */
- _addMailbox: function (data, options) {
- options = typeof options === 'object' ? options : {};
- var mailbox = new Mailbox({
- parent: this,
- data: data,
- options: options,
- commands: this._commands
- });
- this._threads.push(mailbox);
- this._sortThreads();
- return mailbox;
- },
- /**
- * Stores a message to the cache `domain` of all of its threads
- *
- * @private
- * @param {mail.model.Message} message
- * @param {Object} [options={}]
- * @param {Array} [options.domain=[]]
- * @param {boolean} [options.incrementUnread=false]
- */
- _addMessageToThreads: function (message, options) {
- var self = this;
- options = options || {};
- _.each(message.getThreadIDs(), function (threadID) {
- var thread = self.getThread(threadID);
- if (thread) {
- thread.addMessage(message, options);
- }
- });
- },
- /**
- * Add a new message
- *
- * @private
- * @param {Object} data
- * @param {Object} options
- * @return {Promise<mail.model.Message>}
- */
- _addNewMessage: function (data, options) {
- var self = this;
- var message = this._makeMessage(data);
- // Keep the array ordered by ID when inserting the new message
- var index = _.sortedIndex(this._messages, message, function (msg) {
- return msg.getID();
- });
- this._messages.splice(index, 0, message);
- return this._addNewMessagePostprocessThread(message, options).then(function () {
- self._addMessageToThreads(message, options);
- if (!options.silent) {
- self._mailBus.trigger('new_message', message);
- }
- return message;
- });
- },
- /**
- * For newly added message, postprocess threads linked to this message
- *
- * @private
- * @param {mail.model.Message} message
- * @param {Object} options
- * @param {Array} [options.domain]
- * @param {boolean} [options.incrementUnread]
- * @param {boolean} [options.showNotification]
- * @returns {Promise}
- */
- _addNewMessagePostprocessThread: function (message, options) {
- var self = this;
- var proms = [];
- _.each(message.getThreadIDs(), function (threadID) {
- var thread = self.getThread(threadID);
- if (thread) {
- if (
- thread.getType() !== 'mailbox' &&
- !message.isMyselfAuthor() &&
- !message.isSystemNotification()
- ) {
- if (thread.isTwoUserThread() && options.showNotification) {
- var prom = Promise.resolve();
- if (
- !self._isDiscussOpen() &&
- !config.device.isMobile &&
- !thread.isDetached()
- ) {
- // automatically open thread window
- // while keeping it unread
- prom = thread.detach({ passively: true });
- }
- proms.push(prom);
- prom.then(function () {
- var query = { isVisible: false };
- self._mailBus.trigger('is_thread_bottom_visible', thread, query);
- if (!self.call('bus_service', 'isOdooFocused') || !query.isVisible) {
- self._notifyIncomingMessage(message);
- }
- });
- }
- }
- }
- });
- return Promise.all(proms);
- },
- /**
- * Create a Channel (other than a DM)
- *
- * @private
- * @param {string} name
- * @param {string} type
- * @returns {Promise<integer>} ID of the created channel
- */
- _createChannel: function (name, type) {
- var context = _.extend({ isMobile: config.device.isMobile }, session.user_context);
- return this._rpc({
- model: 'mail.channel',
- method: 'channel_create',
- args: [name, type],
- kwargs: { context: context },
- })
- .then(this._addChannel.bind(this));
- },
- /**
- * Create a Direct Messages Chat
- *
- * @private
- * @param {string} name
- * @returns {Promise<integer>} ID of the created channel
- */
- _createDMChat: function (name) {
- var context = _.extend({ isMobile: config.device.isMobile }, session.user_context);
- return this._rpc({
- model: 'mail.channel',
- method: 'channel_get',
- args: [[name]],
- kwargs: { context: context },
- })
- .then(this._addChannel.bind(this));
- },
- /**
- * @private
- * @param {integer[]} channelIDs
- * @returns {Promise<Object[]>} resolved with list of channel preview
- */
- _fetchChannelPreviews: function (channelIDs) {
- return this._rpc({
- model: 'mail.channel',
- method: 'channel_fetch_preview',
- args: [channelIDs],
- }, { shadow: true });
- },
- /**
- * @private
- */
- _fetchMailStateFromServer: function () {
- var self = this;
- session.is_bound.then(function () {
- var context = _.extend(
- { isMobile: config.device.isMobile },
- session.user_context
- );
- return self._rpc({
- route: '/mail/init_messaging',
- params: { context: context },
- });
- }).then(function (result) {
- self._updateInternalStateFromServer(result);
- self.call('bus_service', 'startPolling');
- self._isReady = true;
- self._mailBus.trigger('messaging_ready');
- });
- },
- /**
- * Get previews of the channels
- *
- * The preview of a channel is built from the channel information and its
- * lastest message. A channel without a last message is incomplete, so it
- * must fetch this message.
- *
- * Note that a channel could have no message at all, so it fetches for the
- * last message only once per channel. This is correct to not fetch more
- * than once in this case, because any later received message updates
- * automatically the last message of a channel.
- *
- * @private
- * @param {mail.model.Channel[]} channels
- * @returns {Promise<Object[]>} resolved with list of channel previews
- */
- _getChannelPreviews: function (channels) {
- var self = this;
-
- var unpreviewedChannels = _.reject(channels, function (channel) {
- return channel.hasBeenPreviewed();
- });
- var fetchDef;
- if (unpreviewedChannels.length) {
- var ids = _.map(unpreviewedChannels, function (channel) {
- return channel.getID();
- });
- fetchDef = this._fetchChannelPreviews(ids);
- } else {
- fetchDef = Promise.resolve();
- }
- return fetchDef.then(function (fetchedPreviews) {
- // add fetched messages
- var proms = [];
- _.each(fetchedPreviews, function (fetchedPreview) {
- if (fetchedPreview.last_message) {
- proms.push(self.addMessage(fetchedPreview.last_message));
- }
- // mark the channel as previewed, so that we do not need to
- // fetch preview again
- var channel = self.getChannel(fetchedPreview.id);
- channel.markAsPreviewed();
- });
- return Promise.all(proms);
- }).then(function () {
- return _.map(channels, function (channel) {
- return channel.getPreview();
- });
- });
- },
- /**
- * Get the previews of the mail failures
- * Mail failures of a same model with the same type are grouped together, so
- * that there are few preview items on the messaging menu in the systray.
- *
- * To determine whether this is a model preview or a document preview review
- * the documentID is omitted for a model preview, whereas it is set for a
- * preview of a failure related to a document.
- *
- * @returns {Promise<Object[]>} resolved with previews of mail failures
- */
- _getMailFailurePreviews: function () {
- // items = list of objects:
- // item = {
- // unreadCounter: {integer},
- // failure: {mail.model.MailFailure},
- // isSameDocument: {boolean}
- // }
- var items = [];
- _.each(this._mailFailures, function (failure) {
- var unreadCounter = 1;
- var isSameDocument = true;
- var sameModelAndTypeItem = _.find(items, function (item) {
- if (
- item.failure.isLinkedToDocument() &&
- (item.failure.getDocumentModel() === failure.getDocumentModel()) &&
- (item.failure.getMessageType() === failure.getMessageType())
- ) {
- isSameDocument = item.failure.getDocumentID() === failure.getDocumentID();
- return true;
- }
- return false;
- });
-
- if (failure.isLinkedToDocument() && sameModelAndTypeItem) {
- unreadCounter = sameModelAndTypeItem.unreadCounter + 1;
- isSameDocument = sameModelAndTypeItem.isSameDocument && isSameDocument;
- var index = _.findIndex(items, sameModelAndTypeItem);
- items[index] = {
- unreadCounter: unreadCounter,
- failure: failure,
- isSameDocument: isSameDocument,
- };
- } else {
- items.push({
- unreadCounter: unreadCounter,
- failure: failure,
- isSameDocument: true,
- });
- }
- });
- return _.map(items, function (item) {
- // return preview with correct unread counter
- // also unset documentID if the grouped mail failures are from
- // same model but different document
- var preview = {};
- _.extend(preview, item.failure.getPreview(), {
- unreadCounter: item.unreadCounter,
- });
- if (!item.isSameDocument) {
- preview.documentID = undefined;
- }
- return preview;
- });
- },
- /**
- * @private
- * @param {string|undefined} [filter] the filter to apply on channel
- * previews in systray messaging menu
- * @returns {Promise<Object[]>} resolved with object valid for template
- * mail.Preview on channel previews, based on selected filter
- */
- _getSystrayChannelPreviews: function (filter) {
- var filteredThreads = _.filter(this._threads, function (thread) {
- if (!thread.isChannel()) {
- return false;
- }
- if (filter === 'chat') {
- return thread.isTwoUserThread();
- } else if (filter === 'channels') {
- return !thread.isTwoUserThread();
- }
- return true;
- });
- return this.getChannelPreviews(filteredThreads);
- },
- /**
- * Get the previews of needaction messages, i.e. messages in Inbox mailbox,
- * based on selected filter.
- *
- * FIXME: it will use the current fetched messages in inbox + perform
- * another message fetch on inbox. In other words, it means that some thread
- * previews may be missing in the systray messaging menu. For example:
- *
- * - Each message fetch on Inbox fetches at most 30 messages.
- * - There are 31 messages on Inbox.
- * - No message on Inbox has been fetched at the moment
- * - the 1st message on Inbox is linked to a document, and no other
- * message in Inbox is linked to this document
- *
- * When clicking on the messaging menu, it will fetch all messages of Inbox,
- * except the 1st message, in order to show the previews. The preview of
- * the document thread of the 1st message will not be shown in the message
- * menu
- *
- * This is a limitation, which should be solved by making changes on the
- * server logic (e.g. provide list of all document threads in inbox on
- * the rpc mail/init_messaging).
- *
- * @private
- * @param {string|undefined} [filter]
- * @returns {Promise<Object[]>} resolved with valid objects for template
- * mail.Preview on inbox message previews
- */
- _getSystrayInboxPreviews: function (filter) {
- // 'All' filter, show messages preview from inbox and mail failures
- // inbox previews
- if (filter === 'mailbox_inbox' || !filter) {
- var inbox = this.getMailbox('inbox');
- return inbox.getMessagePreviews();
- } else {
- return Promise.resolve([]);
- }
- },
- /**
- * Get the previews of mail failure, based on selected filter.
- *
- * @private
- * @param {string|undefined} [filter]
- */
- _getSystrayMailFailurePreviews: function (filter) {
- // mail failure previews
- if (filter === 'mailbox_inbox' || !filter) {
- return this._getMailFailurePreviews();
- } else {
- return Promise.resolve([]);
- }
- },
- /**
- * Initialize the internal state of the mail service. Ensure that all
- * attributes are set before doing any operation on them.
- *
- * @private
- */
- _initializeInternalState: function () {
- this._cannedResponses = [];
- this._mailBus = new Bus(this);
- this._commands = [];
- this._discussMenuID = undefined;
- this._discussOpen = false;
- this._isMyselfModerator = false;
- this._mailFailures = [];
- // list of employees for chatter mentions
- this._mentionPartnerSuggestions = [];
- this._messages = [];
- this._moderatedChannelIDs = [];
- // # of message received when odoo is out of focus
- this._outOfFocusUnreadMessageCounter = 0;
- // partner_ids we have a pinned DM chat with
- this._pinnedDmPartners = [];
- // all threads, including channels, DM, mailboxes, document threads, ...
- this._threads = [];
-
- this._addMailbox({
- id: 'inbox',
- name: _t("Inbox"),
- });
- this._addMailbox({
- id: 'starred',
- name: _t("Starred"),
- });
- this._addMailbox({
- id: 'history',
- name: _t("History"),
- });
- },
- /**
- * State whether discuss app is open or not
- *
- * @private
- * @returns {boolean}
- */
- _isDiscussOpen: function () {
- return this._discussOpen;
- },
- /**
- * Join the channel, and add it locally afterwards
- *
- * @private
- * @param {integer|string} channelID
- * @param {Object} options options passed on channel add
- * @returns {Promise<integer>} channelID of joined and added channel
- */
- _joinAndAddChannel: function (channelID, options) {
- var self = this;
- return this._rpc({
- model: 'mail.channel',
- method: 'channel_join_and_get_info',
- args: [[channelID]],
- })
- .then(function (result) {
- return self._addChannel(result, options);
- });
- },
- /**
- * Listen on several buses, before doing any action that trigger something
- * on those buses.
- *
- * @private
- */
- _listenOnBuses: function () {
- this._mailBus.on('discuss_open', this, this._onDiscussOpen);
- this.call('bus_service', 'on', 'window_focus', this, this._onWindowFocus);
- },
- /**
- * Creates a new instance of Channel with the given data and options.
- *
- * @private
- * @param {Object} data
- * @param {Array} [data.channel_type] if set and is 'livechat', the channel
- * is a Livechat.
- * @param {Array} [data.direct_partner] if set and is an non-empty array,
- * the channel is a DM chat
- * @param {Object} [options]
- * @returns {mail.model.Channel}
- */
- _makeChannel: function (data, options) {
- if (_.size(data.direct_partner) > 0) {
- return new DMChat({
- parent: this,
- data: data,
- options: options,
- commands: this._commands
- });
- }
- if (data.channel_type === 'livechat') {
- return new Livechat({
- parent: this,
- data: data,
- options: options,
- commands: this._commands,
- });
- }
- return new MultiUserChannel({
- parent: this,
- data: data,
- options: options,
- commands: this._commands,
- });
- },
- /**
- * Creates a new instance of Message with the given data.
- *
- * @private
- * @param {Object} data
- * @returns {mail.model.Message}
- */
- _makeMessage: function (data) {
- return new Message(this, data);
- },
- /**
- * @private
- * @returns {$.Promise}
- */
- _notifyChannelFetched: function () {
- var channelIDs = this._toNotifyChannelFetchedIDs;
- this._toNotifyChannelFetchedIDs = [];
- if (_.isEmpty(channelIDs)) {
- // no channel to notify fetched
- return Promise.resolve();
- }
- return this._rpc({
- model: 'mail.channel',
- method: 'channel_fetched',
- args: [channelIDs],
- }, { shadow: true });
- },
- /**
- * shows a popup to notify a new received message.
- * This will also rename the browser tab if this is not the active tab.
- *
- * @private
- * @param {mail.model.Message} message message received
- */
- _notifyIncomingMessage: function (message) {
- if (this.call('bus_service', 'isOdooFocused')) {
- // no need to notify
- return;
- }
- var title = _t("New message");
- if (message.hasAuthor()) {
- title = _.escape(message.getAuthorName());
- }
- var content = mailUtils.parseAndTransform(message.getBody(), mailUtils.stripHTML)
- .substr(0, PREVIEW_MSG_MAX_SIZE);
-
- if (!this.call('bus_service', 'isOdooFocused')) {
- this._outOfFocusUnreadMessageCounter++;
- var tabTitle = _.str.sprintf(
- _t("%d Messages"),
- this._outOfFocusUnreadMessageCounter
- );
- this.env.bus.trigger('set_title_part', {
- part: '_chat',
- title: tabTitle
- });
- }
-
- this.call('bus_service', 'sendNotification', title, content);
- },
- /**
- * Open the thread in the Discuss app
- *
- * @private
- * @param {integer|string} threadID a valid threadID
- */
- _openThreadInDiscuss: function (threadID) {
- var thread = this.getThread(threadID);
- this._mailBus.trigger('open_thread_in_discuss', thread);
- },
- /**
- * @private
- * @param {string} resModel
- * @param {integer} resID
- */
- _redirectDefault: function (resModel, resID) {
- var self = this;
- this._rpc({
- model: resModel,
- method: 'get_formview_id',
- args: [[resID], session.user_context.uid],
- })
- .then(function (viewID) {
- self._redirectToDocument(resModel, resID, viewID);
- });
- },
- /**
- * @private
- * @param {Object} message
- * @param {Array} message.channel_ids list of integers and strings,
- * where strings for static channels, e.g. 'mailbox_inbox'.
- * @param {string} resModel
- * @param {integer} resID
- * @param {string} viewID
- */
- _redirectToDocument: function (resModel, resID, viewID) {
- this.env.bus.trigger('do-action', {
- action: {
- type: 'ir.actions.act_window',
- view_mode: 'form',
- res_model: resModel,
- views: [[viewID || false, 'form']],
- res_id: resID,
- },
- });
- },
- /**
- * @private
- * @param {string} resModel 'res.partner'
- * @param {integer} resID
- * @param {function} dmRedirection a function callback that has
- * threadID as input
- */
- _redirectPartner: function (resModel, resID, dmRedirection) {
- var self = this;
- var domain = [['partner_id', '=', resID]];
- this._rpc({
- model: 'res.users',
- method: 'search',
- args: [domain],
- })
- .then(function (userIDs) {
- if (
- userIDs.length &&
- userIDs[0] !== session.uid &&
- dmRedirection
- ) {
- self.createChannel(resID, 'dm_chat').then(dmRedirection);
- } else {
- self._redirectToDocument(resModel, resID);
- }
- });
- },
- /**
- * Remove channel
- *
- * This is only called by the mail_notification_manager
- *
- * @private
- * @param {mail.model.Channel} [channel]
- */
- _removeChannel: function (channel) {
- if (!channel) { return; }
- if (channel.getType() === 'dm_chat') {
- var index = this._pinnedDmPartners.indexOf(channel.getDirectPartnerID());
- if (index > -1) {
- this._pinnedDmPartners.splice(index, 1);
- }
- }
- this._threads = _.without(this._threads, channel);
- },
- /**
- * Removes a message from a thread.
- *
- * @private
- * @param {string} threadID
- * @param {mail.model.Message} message
- */
- _removeMessageFromThread: function (threadID, message) {
- message.removeThread(threadID);
- var thread = _.find(this._threads, function (thread) {
- return thread.getID() === threadID;
- });
- thread.removeMessage(message.getID());
- },
- /**
- * @private
- */
- _resetOutOfFocusUnreadMessageCounter: function () {
- this._outOfFocusUnreadMessageCounter = 0;
- },
- /**
- * Extend the research to all users
- *
- * @private
- * @param {string} searchVal
- * @param {integer} limit
- * @returns {Promise<Object[]>} fetched partners matching 'searchVal'
- */
- _searchPartnerFetch: function (searchVal, limit) {
- return this._rpc({
- model: 'res.partner',
- method: 'im_search',
- args: [searchVal, limit || 20],
- }, { shadow: true });
- },
- /**
- * Search among prefetched partners
- *
- * @private
- * @param {string} searchVal
- * @param {integer} limit
- * @returns {string[]} partner suggestions that match searchVal
- * (max limit, exclude session partner)
- */
- _searchPartnerPrefetch: function (searchVal, limit) {
- var values = [];
- var searchRegexp = new RegExp(_.str.escapeRegExp(utils.unaccent(searchVal)), 'i');
- _.each(this._mentionPartnerSuggestions, function (partners) {
- if (values.length < limit) {
- values = values.concat(_.filter(partners, function (partner) {
- return (session.partner_id !== partner.id) &&
- searchRegexp.test(partner.name);
- })).splice(0, limit);
- }
- });
- return values;
- },
- /**
- * Sort previews
- *
- * 1. unread,
- * 2. dated previews
- * 3. two-user thread,
- * 4. date,
- *
- * @private
- * @param {Object[]} previews
- * @returns {Object[]} sorted list of previews
- */
- _sortPreviews: function (previews) {
- var res = previews.sort(function (p1, p2) {
- var unreadDiff = Math.min(1, p2.unreadCounter) - Math.min(1, p1.unreadCounter);
- var datedDiff = !!p2.date - !!p1.date;
- var isTwoUserThreadDiff = p2.isTwoUserThread - p1.isTwoUserThread;
- var dateDiff = p2.date && p2.date.diff(p1.date);
- return unreadDiff || datedDiff || isTwoUserThreadDiff || dateDiff;
- });
- return res;
- },
- /**
- * Sort threads
- *
- * In case of mailboxes (Inbox, Starred), the name is translated
- * thanks to _lt (lazy translate). In this case, channel.getName() is an
- * object, not a string.
- *
- * @private
- */
- _sortThreads: function () {
- this._threads = _.sortBy(this._threads, function (thread) {
- var name = thread.getName();
- return _.isString(name) ? name.toLowerCase() : '';
- });
- },
- /**
- * Update the canned responses with mail data fetched from the server
- *
- * @private
- * @param {Object} data
- * @param {Object[]} [data.shortcodes]
- * @param {integer} data.shortcodes[i].id
- * @param {string} data.shortcodes[i].source
- * @param {string} data.shortcodes[i].substitution
- */
- _updateCannedResponsesFromServer: function (data) {
- var self = this;
- _.each(data.shortcodes, function (s) {
- var cannedResponse = _.pick(s, ['id', 'source', 'substitution']);
- self._cannedResponses.push(cannedResponse);
- });
- },
- /**
- * Update the channels with the mail data fetched from server, including
- * public, private, DM, livechat, etc.
- *
- * @private
- * @param {Object} data
- * @param {Object} [data.channel_slots] contains the data of channels to
- * update, which are grouped by channel type by key of the object
- * (e.g. list of public channel data are stored in 'channel_channel')
- * @param {Object[]} [data.channel_slots[i] list of data of channel of type
- * `i`
- * @returns {Promise}
- */
- _updateChannelsFromServer: function (data) {
- var self = this;
- var proms = [];
- const options = {};
-
- _.each(data.channel_slots, function (channels) {
- _.each(channels, function (channel) {
- proms.push(self._addChannel(channel, options));
- });
- });
- return Promise.all(proms);
- },
- /**
- * Update commands from mail data fetched from the server
- *
- * @private
- * @param {Object} data
- * @param {Object[]} data.commands list of command data from the server
- */
- _updateCommandsFromServer: function (data) {
- this._commands = _.map(data.commands, function (command) {
- return _.extend({ id: command.name }, command);
- });
- },
- /**
- * Update internal state from server data (mail/init_messaging rpc result)
- *
- * @private
- * @param {Object} result data from server on mail/init_messaging rpc
- * @param {Array<Object[]>} result.mention_partner_suggestions list of
- * suggestions.
- * @param {integer} result.menu_id the menu ID of discuss app
- * @returns {Promise}
- */
- _updateInternalStateFromServer: function (result) {
- // commands are needed for channel instantiation
- this._updateCommandsFromServer(result);
- var prom = this._updateChannelsFromServer(result);
- this._updateModerationSettingsFromServer(result);
- this._updateMailboxesFromServer(result);
- this._updateMailFailuresFromServer(result);
- this._updateCannedResponsesFromServer(result);
-
- this._mentionPartnerSuggestions = result.mention_partner_suggestions;
- this._discussMenuID = result.menu_id;
-
- return prom;
- },
- /**
- * Update the mailboxes with mail data fetched from server, namely 'Inbox',
- * 'Starred', 'History', and 'Moderation Queue' if the user is a moderator of a channel
- *
- * @private
- * @param {Object} data
- * @param {boolean} [data.is_moderator=false] states whether the user is
- * moderator of a channel
- * @param {integer} [data.moderation_counter=0] states the mailbox counter
- * to set to 'Moderation Queue'
- * @param {integer} [data.needaction_inbox_counter=0] states the mailbox
- * counter to set to 'Inbox'
- * @param {integer} [data.starred_counter=0] states the mailbox counter to
- * set to 'Starred'
- */
- _updateMailboxesFromServer: function (data) {
- this.getMailbox('inbox').setMailboxCounter(data.needaction_inbox_counter || 0);
- this.getMailbox('starred').setMailboxCounter(data.starred_counter || 0);
-
- if (data.is_moderator) {
- this._addMailbox({
- id: 'moderation',
- name: _t("Moderate Messages"),
- mailboxCounter: data.moderation_counter || 0,
- });
- }
- },
- /**
- * Update mail failures with mail data fetched from the server
- *
- * @private
- * @param {Object} data
- * @param {Object[]} data.mail_failures data to update mail failures
- * locally
- */
- _updateMailFailuresFromServer: function (data) {
- var self = this;
- this._mailFailures = _.map(data.mail_failures, function (mailFailureData) {
- return new MailFailure(self, mailFailureData);
- });
- },
- /**
- * Update moderation settings with mail data fetched from server
- *
- * @private
- * @param {Object} data
- * @param {boolean} [data.is_moderator=false]
- * @param {integer[]} [data.moderation_channel_ids]
- */
- _updateModerationSettingsFromServer: function (data) {
- this._isMyselfModerator = data.is_moderator;
- this._moderatedChannelIDs = data.moderation_channel_ids;
- },
-
- //--------------------------------------------------------------------------
- // Handlers
- //--------------------------------------------------------------------------
-
- /**
- * @private
- * @param {boolean} open
- */
- _onDiscussOpen: function (open) {
- this._discussOpen = open;
- },
- /**
- * Reset out of focus unread message counter + tab title
- *
- * @private
- */
- _onWindowFocus: function () {
- this._resetOutOfFocusUnreadMessageCounter();
- this.env.bus.trigger('set_title_part', { part: '_chat' });
- },
-});
-
-return MailManager;
-
-});
diff --git a/addons/mail/static/src/js/services/mail_notification_manager.js b/addons/mail/static/src/js/services/mail_notification_manager.js
deleted file mode 100644
index c7c5a03e6bf..00000000000
--- a/addons/mail/static/src/js/services/mail_notification_manager.js
+++ /dev/null
@@ -1,597 +0,0 @@
-odoo.define('mail.Manager.Notification', function (require) {
-"use strict";
-
-/**
- * Mail Notification Manager
- *
- * This part of the mail manager is responsible for receiving notifications on
- * the longpoll bus, which are data received from the server.
- */
-var MailManager = require('mail.Manager');
-var MailFailure = require('mail.model.MailFailure');
-
-const config = require('web.config');
-var core = require('web.core');
-var session = require('web.session');
-
-var _t = core._t;
-
-MailManager.include({
-
- //--------------------------------------------------------------------------
- // Private
- //--------------------------------------------------------------------------
-
- /**
- * Remove channel notifications if there is an unsubscribe notification
- * on this channel.
- *
- * @private
- * @param {Object[]} notifications
- * @returns {Object[]} notifications filtered of channel notifications
- * matching unsubscribe notifs
- */
- _filterNotificationsOnUnsubscribe: function (notifications) {
- var unsubscribedNotif = _.find(notifications, function (notif) {
- return notif[1].info === 'unsubscribe';
- });
- if (unsubscribedNotif) {
- notifications = _.reject(notifications, function (notif) {
- return notif[0][1] === 'mail.channel' &&
- notif[0][2] === unsubscribedNotif[1].id;
- });
- }
- return notifications;
- },
- /**
- * Called when receiving a notification on a channel (all members of a
- * channel receive this notification)
- *
- * @private
- * @param {Object} params
- * @param {integer} [params.channelID]
- * @param {Object} params.data
- * @param {string} [params.data.info] if set, specify the type of data on
- * this channel notification
- */
- _handleChannelNotification: function (params) {
- if (params.data && params.data.info === 'typing_status') {
- this._handleChannelTypingNotification(params.channelID, params.data);
- } else if (params.data && params.data.info === 'channel_fetched') {
- this._handleChannelFetchedNotification(params.channelID, params.data);
- } else if (params.data && params.data.info === 'channel_seen') {
- this._handleChannelSeenNotification(params.channelID, params.data);
- } else {
- this._handleChannelMessageNotification(params.data);
- }
- },
- /**
- * Called when a channel has been fetched, and the server responses with the
- * last message fetched. Useful in order to track last message fetched.
- *
- * @private
- * @param {integer} channelID
- * @param {Object} data
- * @param {string} data.info 'channel_fetched'
- * @param {integer} data.last_message_id
- * @param {integer} data.partner_id
- */
- _handleChannelFetchedNotification: function (channelID, data) {
- var channel = this.getChannel(channelID);
- if (!channel) {
- return;
- }
- if (channel.hasSeenFeature()) {
- channel.updateSeenPartnersInfo(data);
- }
- },
- /**
- * Called when a new or updated message is received on a channel
- *
- * @private
- * @param {Object} messageData
- * @param {integer[]} messageData.channel_ids channel IDs of this message
- * (note that 'pending moderation' messages in moderated channels do not
- * have the moderated channels in this array).
- */
- _handleChannelMessageNotification: function (messageData) {
- var self = this;
- var def;
- let notify = true;
- if (messageData.channel_ids.length === 1) {
- const channel = this.getChannel(messageData.channel_ids[0]);
- // Message from mailing channel should not make a notification in Odoo for users
- // with notification "Handled by Email", but web client should receive the message.
- // Channel has been marked as read server-side in this case, so it should not display
- // a notification by incrementing the unread counter.
- notify = channel && (!channel.isMassMailing() || session.notification_type !== 'email');
- def = this.joinChannel(messageData.channel_ids[0], { autoswitch: false });
- } else {
- def = Promise.resolve();
- }
- def.then(function () {
- // don't increment unread if channel wasn't in cache yet as
- // its unread counter has just been fetched
- return self.addMessage(messageData, {
- showNotification: true,
- incrementUnread: notify,
- });
- });
- },
- /**
- * Called when a channel has been seen, and the server responses with the
- * last message seen. Useful in order to track last message seen.
- *
- * @private
- * @param {integer} channelID
- * @param {Object} data
- * @param {string} data.info 'channel_seen'
- * @param {integer} data.last_message_id
- * @param {integer} data.partner_id
- */
- _handleChannelSeenNotification: function (channelID, data) {
- var channel = this.getChannel(channelID);
- if (!channel) {
- return;
- }
- if (channel.hasSeenFeature()) {
- channel.updateSeenPartnersInfo(data);
- }
- if (session.partner_id !== data.partner_id) {
- return;
- }
- channel.setLastSeenMessageID(data.last_message_id);
- if (channel.hasUnreadMessages()) {
- channel.resetUnreadCounter();
- }
- },
- /**
- * Called when someone starts or stops typing a message in a channel
- *
- * @private
- * @param {integer} channelID
- * @param {Object} typingData
- * @param {boolean} typingData.is_typing
- * @param {boolean} typingData.is_website_user
- * @param {integer} typingData.partner_id
- */
- _handleChannelTypingNotification: function (channelID, typingData) {
- var isWebsiteUser = typingData.is_website_user;
- var partnerID = typingData.partner_id;
- if (partnerID === session.partner_id && !isWebsiteUser) {
- return; // ignore typing notification of myself
- }
- var channel = this.getChannel(channelID);
- if (!channel) {
- return;
- }
- var typingID = {
- isWebsiteUser: typingData.is_website_user,
- partnerID: typingData.partner_id,
- };
- if (typingData.is_typing) {
- channel.registerTyping(typingID);
- } else {
- channel.unregisterTyping(typingID);
- }
- },
- /**
- * On message becoming a need action (pinned to inbox)
- *
- * @private
- * @param {Object} messageData
- * @param {integer[]} messageData.channel_ids
- */
- _handleNeedactionNotification: function (messageData) {
- var self = this;
- var inbox = this.getMailbox('inbox');
- this.addMessage(messageData, {
- incrementUnread: true,
- showNotification: true,
- }).then(function (message) {
- inbox.incrementMailboxCounter();
- _.each(message.getThreadIDs(), function (threadID) {
- var channel = self.getChannel(threadID);
- if (channel) {
- channel.incrementNeedactionCounter();
- }
- });
- self._mailBus.trigger('update_needaction', inbox.getMailboxCounter());
- });
- },
- /**
- * Called when an activity record has been updated on the server
- *
- * @private
- * @param {Object} data key, value to decide activity created or deleted
- */
- _handlePartnerActivityUpdateNotification: function (data) {
- this._mailBus.trigger('activity_updated', data);
- },
- /**
- * Called to open the channel in detach mode (minimized) even if no new message:
- *
- * @private
- * @param {Object} channelData
- * @param {integer} channelData.id
- * @param {string} [channelData.info]
- * @param {boolean} channelData.is_minimized
- * @param {string} channelData.state
- */
- _handlePartnerChannelMinimizeNotification: function (channelData) {
- var self = this;
- this._addChannel(channelData).then(function (channelID){
- self.getChannel(channelID).detach()
- });
- },
- /**
- * Called when receiving a channel state as a partner notification:
- *
- * - if it is a new channel, it means we have been invited to this channel
- * - if it is an existing channel, it means the window state of the channel
- * may have changed (as this state is stored server-side)
- *
- * @private
- * @param {Object} channelData
- * @param {integer} channelData.id
- * @param {string} [channelData.info]
- * @param {boolean} channelData.is_minimized
- * @param {string} channelData.state
- */
- _handlePartnerChannelNotification: function (channelData) {
- if (
- (channelData.channel_type === 'channel') &&
- (channelData.state === 'open')
- ) {
- // invited to a new channel
- this._addChannel(channelData, { autoswitch: false });
- if (
- !channelData.is_minimized &&
- channelData.info !== 'creation'
- ) {
- this.do_notify(
- false,
- _t("You have been invited to: ") + channelData.name);
- }
- }
- // Prevent to open/close a channel on mobile when you open/close it on desktop.
- if (!config.device.isMobile) {
- const channel = this.getChannel(channelData.id);
- if (channel && channelData.info !== 'join') {
- channel.updateWindowState({
- folded: channelData.state === 'folded' ? true : false,
- detached: channelData.is_minimized,
- });
- }
- }
- },
- /**
- * Called when receiving a multi_user_channel seen notification. Only
- * the current user is notified. This must be handled as if this is a
- * channel seen notification.
- *
- * Note that this is a 'res.partner' notification because only the current
- * user is notified on channel seen. This is a consequence from disabling
- * the seen feature on multi_user_channel, because we still need to get
- * the last seen message ID in order to display the "New Messages" separator
- * in Discuss.
- *
- * @private
- * @param {Object} data
- * @param {integer} data.channel_id
- * @param {string} data.info 'channel_seen'
- * @param {integer} data.last_message_id
- * @param {integer} data.partner_id
- */
- _handlePartnerChannnelSeenNotification: function (data) {
- this._handleChannelSeenNotification(data.channel_id, data);
- },
- /**
- * Add or remove failure when receiving a failure update message
- *
- * @private
- * @param {Object} datas
- * @param {Object[]} datas.elements list of mail failure data
- * @param {string} datas.elements[].message_id ID of related message that
- * has a mail failure.
- * @param {Array} datas.elements[].notifications list of notifications
- * that is related to a mail failure.
- * @param {string} datas.elements[].notifications[0] sending state of a mail
- * failure (e.g. 'exception').
- */
- _handlePartnerMessageNotificationUpdateNotification(datas) {
- var self = this;
- _.each(datas.elements, function (data) {
- var isNewFailure = _.some(data.notifications, function (notif) {
- return notif[0] === 'exception' || notif[0] === 'bounce';
- });
- var matchedFailure = _.find(self._mailFailures, function (failure) {
- return failure.getMessageID() === data.message_id;
- });
- if (matchedFailure) {
- var index = _.findIndex(self._mailFailures, matchedFailure);
- if (isNewFailure) {
- self._mailFailures[index] = new MailFailure(self, data);
- } else {
- self._mailFailures.splice(index, 1);
- }
- } else if (isNewFailure) {
- self._mailFailures.push(new MailFailure(self, data));
- }
- var message = _.find(self._messages, function (msg) {
- return msg.getID() === data.message_id;
- });
- if (message) {
- message.setNotifications(data.notifications);
- self._mailBus.trigger('update_message', message);
- }
- });
- this._mailBus.trigger('update_needaction', this.needactionCounter);
- },
- /**
- * Updates mailbox_inbox when a message has marked as read.
- *
- * @private
- * @param {Object} data
- * @param {integer[]} [data.channel_ids]
- * @param {integer[]} [data.message_ids]
- * @param {string} [data.type]
- */
- _handlePartnerMarkAsReadNotification: function (data) {
- var self = this;
- var history = this.getMailbox('history');
- _.each(data.message_ids, function (messageID) {
- var message = _.find(self._messages, function (msg) {
- return msg.getID() === messageID;
- });
- if (message) {
- self._removeMessageFromThread('mailbox_inbox', message);
- history.addMessage(message);
- self._mailBus.trigger('update_message', message, data.type);
- }
- });
- if (data.channel_ids) {
- _.each(data.channel_ids, function (channelID) {
- var channel = self.getChannel(channelID);
- if (channel) {
- channel.decrementNeedactionCounter(data.message_ids.length, 0);
- }
- });
- } else {
- // if no channel_ids specified, this is a 'mark all read' in inbox
- _.each(this.getChannels(), function (channel) {
- channel.resetNeedactionCounter();
- });
- }
- var inbox = this.getMailbox('inbox');
- inbox.decrementMailboxCounter(data.message_ids.length);
- this._mailBus.trigger('update_needaction', inbox.getMailboxCounter());
- },
- /**
- * On receiving a message made by the current user, on a moderated channel,
- * which is pending moderation.
- *
- * @private
- * @param {Object} data
- * @param {Object} data.message server-side data of the message
- */
- _handlePartnerMessageAuthorNotification: function (data) {
- this.addMessage(data.message);
- },
- /**
- * Notification to delete several messages locally
- * Useful when a pending moderation message has been rejected, so that
- * this message should not be displayed anymore.
- *
- * @private
- * @param {Object} data
- * @param {Object[]} [data.message_ids] IDs of messages to delete locally.
- */
- _handlePartnerMessageDeletionNotification: function (data) {
- var self = this;
- _.each(data.message_ids, function (messageID) {
- var message = self.getMessage(messageID);
- if (message) {
- message.setModerationStatus('rejected');
- }
- });
- },
- /**
- * On receiving a message pending moderation, and current user is moderator
- * of such message.
- *
- * @param {Object} data notification data
- * @param {Object} data.message data of message
- */
- _handlePartnerMessageModeratorNotification: function (data) {
- var self = this;
- this.addMessage(data.message).then(function () {
- self._mailBus.trigger('update_moderation_counter');
- });
- },
- /**
- * On receiving a notification that is specific to a user
- *
- * @private
- * @param {Object} data structure depending on the type
- * @param {integer} data.id
- */
- _handlePartnerNotification: function (data) {
- if (data.info === 'unsubscribe') {
- this._handlePartnerUnsubscribeNotification(data);
- } else if (data.type === 'toggle_star') {
- this._handlePartnerToggleStarNotification(data);
- } else if (data.type === 'mark_as_read') {
- this._handlePartnerMarkAsReadNotification(data);
- } else if (data.type === 'moderator') {
- this._handlePartnerMessageModeratorNotification(data);
- } else if (data.type === 'author') {
- this._handlePartnerMessageAuthorNotification(data);
- } else if (data.type === 'deletion') {
- this._handlePartnerMessageDeletionNotification(data);
- } else if (data.info === 'transient_message') {
- this._handlePartnerTransientMessageNotification(data);
- } else if (data.type === 'activity_updated') {
- this._handlePartnerActivityUpdateNotification(data);
- } else if (data.type === 'message_notification_update') {
- this._handlePartnerMessageNotificationUpdateNotification(data);
- } else if (data.type === 'user_connection') {
- this._handlePartnerUserConnectionNotification(data);
- } else if (data.info === 'channel_seen') {
- this._handlePartnerChannnelSeenNotification(data);
- } else if (data.type === 'simple_notification') {
- var title = _.escape(data.title), message = _.escape(data.message);
- data.warning ? this.do_warn(title, message, data.sticky) : this.do_notify(title, message, data.sticky);
- } else if (data.info === 'channel_minimize') {
- this._handlePartnerChannelMinimizeNotification(data);
- } else {
- this._handlePartnerChannelNotification(data);
- }
- },
- /**
- * On toggling on or off the star status of one or several messages.
- * As the information is stored server-side, the web client must adapt
- * itself from server's data on the messages.
- *
- * @private
- * @param {Object} data
- * @param {integer[]} data.message_ids IDs of messages that have a change
- * of their starred status.
- * @param {boolean} data.starred states whether the messages with id in
- * `data.message_ids` have become either starred or unstarred
- */
- _handlePartnerToggleStarNotification: function (data) {
- var self = this;
- var starred = this.getMailbox('starred');
- _.each(data.message_ids, function (messageID) {
- var message = _.find(self._messages, function (msg) {
- return msg.getID() === messageID;
- });
- if (message) {
- message.setStarred(data.starred);
- if (!message.isStarred()) {
- self._removeMessageFromThread('mailbox_starred', message);
- } else {
- self._addMessageToThreads(message, []);
- var channelStarred = self.getMailbox('starred');
- channelStarred.invalidateCaches();
- }
- self._mailBus.trigger('update_message', message);
- }
- });
-
- if (data.starred) {
- // increase starred counter if message is marked as star
- starred.incrementMailboxCounter(data.message_ids.length);
- } else {
- // decrease starred counter if message is remove from starred
- // if unstar_all then it will set to 0.
- starred.decrementMailboxCounter(data.message_ids.length);
- }
-
- this._mailBus.trigger('update_starred', starred.getMailboxCounter());
- },
- /**
- * 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
- */
- _handlePartnerTransientMessageNotification: function (data) {
- var lastMessage = _.last(this._messages);
- data.id = (lastMessage ? lastMessage.getID() : 0) + 0.01;
- data.author_id = this.getOdoobotID();
- this.addMessage(data);
- },
- /**
- * On receiving a unsubscribe from channel notification, confirm
- * unsubscription from channel and adapt screen accordingly.
- *
- * @private
- * @param {Object} data
- * @param {Object} data.id ID of the unsubscribed channel
- */
- _handlePartnerUnsubscribeNotification: function (data) {
- var channel = this.getChannel(data.id);
- if (channel) {
- var message;
- if (_.contains(['public', 'private'], channel.getType())) {
- message = _.str.sprintf(
- _t("You unsubscribed from <b>%s</b>."),
- channel.getName()
- );
- } else {
- message = _.str.sprintf(
- _t("You unpinned your conversation with <b>%s</b>."),
- channel.getName()
- );
- }
- this._removeChannel(channel);
- this._mailBus.trigger('unsubscribe_from_channel', data.id);
- this.do_notify(false, message);
- }
- },
- /**
- * Shows a popup to notify a user connection
- *
- * @private
- * @param {Object} data
- * @param {Object[]} data.partner_id id of the connected partner
- * @param {string} data.title title to display on notification
- * @param {Array} data.messages message to display on notification
- */
- _handlePartnerUserConnectionNotification: function (data) {
- var self = this;
- var partnerID = data.partner_id;
- this.call('bus_service', 'sendNotification', data.title, data.message, function ( ){
- self.call('mail_service', 'openDMChatWindowFromBlankThreadWindow', partnerID);
- });
- },
- /**
- * @override
- * @private
- */
- _listenOnBuses: function () {
- this._super.apply(this, arguments);
- this.call('bus_service', 'onNotification', this, this._onNotification);
- },
-
- //--------------------------------------------------------------------------
- // Handlers
- //--------------------------------------------------------------------------
-
- /**
- * Notification handlers
- * Sometimes, the web client receives unsubscribe notification and an extra
- * notification on that channel. This is then followed by an attempt to
- * rejoin the channel that we just left. The next few lines remove the
- * extra notification to prevent that situation to occur.
- *
- * @private
- * @param {Array} notifs
- */
- _onNotification: function (notifs) {
- var self = this;
- notifs = this._filterNotificationsOnUnsubscribe(notifs);
- _.each(notifs, function (notif) {
- var model = notif[0][1];
- if (model === 'ir.needaction') {
- self._handleNeedactionNotification(notif[1]);
- } else if (model === 'mail.channel') {
- // new message in a channel
- self._handleChannelNotification({
- channelID: notif[0][2],
- data: notif[1],
- });
- } else if (model === 'res.partner') {
- self._handlePartnerNotification(notif[1]);
- }
- });
- },
-});
-
-return MailManager;
-
-});
diff --git a/addons/mail/static/src/js/services/mail_service.js b/addons/mail/static/src/js/services/mail_service.js
deleted file mode 100644
index 2256e696c2f..00000000000
--- a/addons/mail/static/src/js/services/mail_service.js
+++ /dev/null
@@ -1,17 +0,0 @@
-odoo.define('mail.Service', function (require) {
-"use strict";
-
-var core = require('web.core');
-
-var MailManager = require('mail.Manager');
-
-require('mail.Manager.Status');
-require('mail.Manager.Notification');
-require('mail.Manager.Window');
-require('mail.Manager.DocumentThread');
-
-core.serviceRegistry.add('mail_service', MailManager);
-
-return MailManager;
-
-});
diff --git a/addons/mail/static/src/js/services/mail_status_manager.js b/addons/mail/static/src/js/services/mail_status_manager.js
deleted file mode 100644
index 184499e8bb4..00000000000
--- a/addons/mail/static/src/js/services/mail_status_manager.js
+++ /dev/null
@@ -1,232 +0,0 @@
-odoo.define('mail.Manager.Status', function (require) {
-"use strict";
-
-var core = require('web.core');
-var MailManager = require('mail.Manager');
-var mailUtils = require('mail.utils');
-var QWeb = core.qweb;
-
-/**
- * Mail Manager: IM Status
- *
- * This component handles im status of partners, which is useful for DM Chats,
- * partner mention suggestions, and chatter messages that display the user icon.
- */
-MailManager.include({
- _UPDATE_INTERVAL: 50,
-
- //--------------------------------------------------------------------------
- // Public
- //--------------------------------------------------------------------------
-
- /**
- * Returns the uin cache im status, and triggers an asynchronous throttled
- * update of im_status cache for missings
- *
- * @param {Object} data
- * @param {integer} data.partnerID
- * @return {String}
- */
- getImStatus: function (data) {
- var self = this;
- var partnerID = data.partnerID;
- if (partnerID === this.getOdoobotID()[0]) {
- return 'bot';
- }
- if (!this._imStatus[partnerID]) {
- // Add to list to call it in next bus update or _fetchMissingImStatus
- this._imStatus[partnerID] = undefined;
- // fetch after some time if no other getImStatus occurs
- mailUtils.clearTimeout(this._fetchStatusTimeout);
- this._fetchStatusTimeout = mailUtils.setTimeout(function () {
- self._fetchMissingImStatus();
- }, 500);
- }
- return this._imStatus[partnerID];
- },
- /**
- * Update status manually, to avoid to do a rpc and an asynchronous update
- * after getImStatus. Can be done by any caller knowing the last im_status
- * state.
- *
- * @param {Object[]} statusList, A list of {id, im_status}
- */
- updateImStatus: function (statusList) {
- var updatedIDs = [];
- var self = this;
- var dmChat;
- var toUpdateOutOfOfficeChatIDs = [];
- _.each(statusList, function (status) {
- if (self._imStatus[status.id] === status.im_status) {
- return;
- }
- if (
- status.im_status.indexOf('leave') !== -1 ||
- (
- self._imStatus[status.id] &&
- self._imStatus[status.id].indexOf('leave') !== -1
- )
- ) {
- dmChat = self.getDMChatFromPartnerID(status.id);
- if (dmChat) {
- toUpdateOutOfOfficeChatIDs.push(dmChat.getID());
- }
- }
- updatedIDs.push(status.id);
- self._imStatus[status.id] = status.im_status;
- });
- if (!_.isEmpty(toUpdateOutOfOfficeChatIDs)) {
- this._rpc({
- model: 'mail.channel',
- method: 'channel_info',
- args: [toUpdateOutOfOfficeChatIDs],
- }).then(function (channelsInfo) {
- _.each(channelsInfo, function (channelInfo) {
- dmChat = self.getChannel(channelInfo.id);
- if (!dmChat) {
- return;
- }
- dmChat.updateOutOfOfficeInfo({
- outOfOfficeDateEnd: channelInfo.direct_partner[0].out_of_office_date_end,
- });
- });
- });
- }
- if (! _.isEmpty(updatedIDs)) {
- this._mailBus.trigger('updated_im_status', updatedIDs); // useful for thread window header
- this._renderImStatus(updatedIDs);
- }
- },
-
- //--------------------------------------------------------------------------
- // Private
- //--------------------------------------------------------------------------
-
- /**
- * Fetch the list of im_status for partner with id in ids list and triggers
- * an update.
- *
- * @private
- * @param {Object} data
- * @param {integer[]} data.partnerIDs
- * @return {Deferred}
- */
- _fetchImStatus: function (data) {
- var self = this;
- var partnerIDs = data.partnerIDs;
- if (_.isEmpty(partnerIDs)) {
- return Promise.resolve();
- }
- return this._rpc({
- route: '/longpolling/im_status',
- params: { partner_ids: partnerIDs },
- }, {
- shadow: true,
- }).then( function (results) {
- self.updateImStatus(results);
- });
- },
- /**
- * Fetch the list of im_status for partner with an unknown im_status and
- * triggers an update.
- *
- * @private
- * @return {Deferred}
- */
- _fetchMissingImStatus: function () {
- var missing = [];
- _.each(this._imStatus, function (value, key) {
- if (value === undefined) {
- missing.push(Number(key));
- }
- });
- return this._fetchImStatus({ partnerIDs: missing });
- },
- /**
- * @private
- * @return {integer[]} a list of partner ids that needs update
- */
- _getImStatusToUpdate: function () {
- var toUpdate = [];
- _.each(this._imStatus, function (status, key) {
- //filter on im_partner and bot: useless to update them, status won't change
- if (['im_partner', 'bot'].indexOf(status) === -1) {
- toUpdate.push(Number(key));
- }
- });
- return toUpdate;
- },
- /**
- * @override
- * @private
- */
- _initializeInternalState: function () {
- this._super.apply(this, arguments);
- this._fetchStatusTimeout = undefined;
- this._imStatus = {};
- this._isTabFocused = true;
- this._updateImStatusLoop();
- },
- /**
- * @override
- * @private
- */
- _listenOnBuses: function () {
- this._super.apply(this, arguments);
- $(window).on("focus", this._onWindowFocusChange.bind(this, true));
- $(window).on("blur", this._onWindowFocusChange.bind(this, false));
- $(window).on("unload", this._onWindowFocusChange.bind(this, false));
- },
- /**
- * @private
- * @param {integer[]} updatedIds
- */
- _renderImStatus: function (updatedIds) {
- var self = this;
- $('.o_updatable_im_status').each(function () {
- var $this = $(this);
- var partnerID = $this.data('partner-id');
- if (partnerID !== undefined && updatedIds.indexOf(partnerID) !== -1) { // todo instead add id on o_updatable_im_status and select only concerned ones
- var status = QWeb.render('mail.UserStatus', {
- status: self.getImStatus({ partnerID: partnerID }),
- partnerID: partnerID,
- });
- $this.replaceWith(status);
- }
- });
- },
- /**
- * Once initialised, this loop will update the im_status of registered
- * users.
- *
- * @private
- * @param {integer} [counter=0] The recursion loop counter
- */
- _updateImStatusLoop: function (counter) {
- var self = this;
- if (!_.isNumber(counter)) {
- counter = 0;
- }
- mailUtils.setTimeout(function () {
- if (counter >= self._UPDATE_INTERVAL && self._isTabFocused) {
- self._fetchImStatus({ partnerIDs: self._getImStatusToUpdate() });
- counter = 0;
- }
- self._updateImStatusLoop(counter+1);
- }, 1000);
- },
-
- //--------------------------------------------------------------------------
- // Handlers
- //--------------------------------------------------------------------------
-
- /**
- * @private
- * @param {boolean} focused
- */
- _onWindowFocusChange: function (focused) {
- this._isTabFocused = focused;
- },
-});
-
-});
diff --git a/addons/mail/static/src/js/services/mail_window_manager.js b/addons/mail/static/src/js/services/mail_window_manager.js
deleted file mode 100644
index cb0eb0b7dca..00000000000
--- a/addons/mail/static/src/js/services/mail_window_manager.js
+++ /dev/null
@@ -1,780 +0,0 @@
-odoo.define('mail.Manager.Window', function (require) {
-"use strict";
-
-var MailManager = require('mail.Manager');
-var ThreadWindow = require('mail.ThreadWindow');
-
-var config = require('web.config');
-var core = require('web.core');
-var utils = require('web.utils');
-
-var QWeb = core.qweb;
-var _t = core._t;
-
-/**
- * Mail Window Manager
- *
- * This part of the mail manager is responsible for the management of thread
- * windows.
- */
-MailManager.include({
- custom_events: _.extend({}, MailManager.prototype.custom_events, {
- close_blank_thread_window: '_onCloseBlankThreadWindow',
- }),
-
- // width of 'hidden thread window' dropdown button
- HIDDEN_THREAD_WINDOW_DROPDOWN_BUTTON_WIDTH: 50,
- // where thread windows are appended
- THREAD_WINDOW_APPENDTO: 'body',
- // width of a thread window (+ 5 pixels between windows)
- THREAD_WINDOW_WIDTH: 325 + 5,
-
- //--------------------------------------------------------------------------
- // Public
- //--------------------------------------------------------------------------
-
- /**
- * Open the blank thread window (i.e. the thread window without any thread
- * linked to it). Make it if there is no blank thread window yet.
- */
- openBlankThreadWindow: function () {
- var blankThreadWindow = this._getBlankThreadWindow();
- if (!blankThreadWindow) {
- blankThreadWindow = new ThreadWindow(this, null);
- this._addThreadWindow(blankThreadWindow);
- blankThreadWindow.appendTo(this.THREAD_WINDOW_APPENDTO)
- .then(this._repositionThreadWindows.bind(this));
- } else {
- if (blankThreadWindow.isHidden()) {
- this._makeThreadWindowVisible(blankThreadWindow);
- } else if (blankThreadWindow.isFolded()) {
- blankThreadWindow.toggleFold(false);
- }
- }
- },
- /**
- * Open a DM chat in a thread window.
- *
- * @param {integer} partnerID
- * @returns {Promise}
- */
- openDMChatWindow: async function (partnerID) {
- let dmChat = this.call('mail_service', 'getDMChatFromPartnerID', partnerID);
- if (!dmChat) {
- const dmChatId = await this.call('mail_service', 'createChannel', partnerID, 'dm_chat');
- dmChat = this.call('mail_service', 'getChannel', dmChatId);
- }
- dmChat.detach();
- },
- /**
- * Open a DM chat in a thread window. This is useful when selecting a DM
- * chatin the blank thread window, so that it replaces it with the DM chat
- * window.
- *
- * @param {integer} partnerID
- */
- openDMChatWindowFromBlankThreadWindow: function (partnerID) {
- var dmChat = this.getDMChatFromPartnerID(partnerID);
- if (!dmChat) {
- this._openAndDetachDMChat(partnerID);
- } else {
- this.openThreadWindow(dmChat.getID());
- }
- this._closeBlankThreadWindow();
- },
- /**
- * Open the thread window if discuss is not opened
- *
- * @override
- * @param {integer|string} threadID
- */
- openThread: function (threadID) {
- if (!this._isDiscussOpen()) {
- var thread = this.getThread(threadID);
- if (thread) {
- thread.detach();
- }
- } else {
- this._super.apply(this, arguments);
- }
- },
- /**
- * Open a thread in a thread window
- *
- * @param {integer} threadID a valid thread ID
- * @param {Object} [options]
- * @param {boolean} [options.passively] if set to true, open the thread
- * window without focusing the input and marking messages as read if it
- * is not open yet, and do nothing otherwise.
- * @param {boolean} [options.keepFoldState=false] if set to true, keep the
- * fold state of the thread
- * @param {boolean} [options.skipCrossTabSync=false] if set, thread
- * should not notify other tabs from new document thread chat window state.
- * @returns {Promise}
- */
- openThreadWindow: function (threadID, options) {
- var self = this;
- options = options || {};
- // valid threadID, therefore no check
- var thread = this.getThread(threadID);
- if (thread.isCreatingWindow) {
- // abort creating a chat window, to prevent open chat window twice.
- // This may happen due to concurrent calls to this method from
- // messaging menu preview click and handling of longpolling chat
- // window state.
- return Promise.resolve();
- }
- var threadWindow = this._getThreadWindow(threadID);
- var prom = Promise.resolve();
- if (!threadWindow) {
- thread.isCreatingWindow = true;
- prom = thread.fetchMessages().then(function () {
- threadWindow = self._makeNewThreadWindow(thread, options);
- return threadWindow.appendTo($(self.THREAD_WINDOW_APPENDTO));
- }).then(function () {
- self._placeNewThreadWindow(threadWindow, options.passively);
- self._repositionThreadWindows();
- threadWindow.render();
- threadWindow.scrollToBottom();
- if (
- !self._areAllThreadWindowsHidden() &&
- !thread.isFolded() &&
- !threadWindow.isPassive()
- ) {
- thread.markAsRead();
- }
- thread.isCreatingWindow = false;
- }).guardedCatch(function () {
- // thread window could not be open, which may happen due to
- // access error while fetching messages to the document.
- // abort opening the thread window in this case.
- thread.close({
- skipCrossTabSync: true,
- });
- thread.isCreatingWindow = false;
- });
- }
- return prom.then(function () {
- threadWindow.updateVisualFoldState();
- });
- },
- /**
- * Called when a thread has its window state that has been changed, so its
- * thread window view should be changed to match the model.
- *
- * @param {integer|string} threadID
- * @param {Object} options option to be applied on opening thread window, if
- * the thread is detached
- * @param {boolean} [options.passively=false] if set, the window will behave
- * passively.
- */
- updateThreadWindow: function (threadID, options) {
- var thread = this.getThread(threadID);
- var prom = Promise.resolve();
- if (thread) {
- if (thread.isDetached()) {
- _.extend(options, { keepFoldState: true });
- prom = this.openThreadWindow(threadID, options);
- } else {
- this._closeThreadWindow(threadID);
- }
- }
- return prom;
- },
-
- //--------------------------------------------------------------------------
- // Private
- //--------------------------------------------------------------------------
-
- /**
- * Add the thread window such that it will be the left-most visible window.
- * Note that adding a new thread window may decrement the amount of
- * available slots for visible thread windows.
- * For example:
- *
- * - global width of 800px
- * - button width of 100px
- * - thread width of 250px
- *
- * Without button: 3 thread windows (3*250px = 750px < 800px)
- * With button: 2 thread windows (2*250px + 100px = 600px < 800px)
- *
- * So in order for the thread window to be the left-most visible window,
- * we should compute available slots after insertion of the thread window.
- *
- * @private
- * @param {mail.ThreadWindow} threadWindow
- */
- _addThreadWindow: function (threadWindow) {
- this._threadWindows.push(threadWindow);
- this._computeAvailableSlotsForThreadWindows();
- this._threadWindows.pop();
- this._threadWindows.splice(this._availableSlotsForThreadWindows-1, 0, threadWindow);
- },
- /**
- * States whether all the thread windows are hidden or not.
- * When discuss is open, the thread windows are hidden.
- *
- * @private
- * @returns {boolean}
- */
- _areAllThreadWindowsHidden: function () {
- return $(this.THREAD_WINDOW_APPENDTO).hasClass('o_no_thread_window');
- },
- /**
- * Close the thread window linked to the thread with ID `threadID`.
- * If there is no window linked to this thread, do nothing.
- *
- * @private
- * @param {integer|string} threadID
- */
- _closeThreadWindow: function (threadID) {
- var threadWindow = _.find(this._threadWindows, function (threadWindow) {
- return threadWindow.getID() === threadID;
- });
- if (threadWindow) {
- this._threadWindows = _.without(this._threadWindows, threadWindow);
- this._repositionThreadWindows();
- threadWindow.destroy();
- }
- },
- /**
- * Close the blank thread window.
- */
- _closeBlankThreadWindow: function () {
- var blankThreadWindow = this._getBlankThreadWindow();
- if (blankThreadWindow) {
- this._closeThreadWindow(blankThreadWindow.getID());
- }
- },
- /**
- * Compute the number of available slots to display thread windows on the
- * screen. This is based on the width of the screen, and the width of a
- * single thread window.
- *
- * The available slots attributes are updated as a consequence of this
- * method call.
- *
- * @private
- */
- _computeAvailableSlotsForThreadWindows: function () {
- if (config.device.isMobile) {
- // one thread window full screen in mobile
- this._availableSlotsForThreadWindows = 1;
- return;
- }
- var GLOBAL_WIDTH = this._getGlobalWidth();
- var THREAD_WINDOW_NUM = this._threadWindows.length;
- var availableSlots = Math.floor(GLOBAL_WIDTH/this.THREAD_WINDOW_WIDTH);
- var spaceLeft = GLOBAL_WIDTH - (Math.min(availableSlots, THREAD_WINDOW_NUM)*this.THREAD_WINDOW_WIDTH);
- if (availableSlots < THREAD_WINDOW_NUM && spaceLeft < this.HIDDEN_THREAD_WINDOW_DROPDOWN_BUTTON_WIDTH) {
- // leave at least space for the hidden windows dropdown button
- availableSlots--;
- spaceLeft += this.THREAD_WINDOW_WIDTH;
- }
-
- this._availableSlotsForThreadWindows = availableSlots;
- this._spaceLeftForThreadWindows = spaceLeft;
- },
- /**
- * Get the blank thread window, which is the special thread window that has
- * no thread linked to it.
- *
- * This is useful in case a DM chat window may replace the blank thread
- * window, when we want to open a DM chat from the blank thread window.
- *
- * @private
- * @returns {mail.ThreadWindow|undefined} the "blank thread" window,
- * if it exists, otherwise undefined
- */
- _getBlankThreadWindow: function () {
- return _.find(this._threadWindows, function (threadWindow) {
- return threadWindow.getID() === '_blank';
- });
- },
- /**
- * Get the width of the browser, which is useful to determine how many
- * open thread windows are visible or hidden.
- *
- * @private
- * @returns {integer}
- */
- _getGlobalWidth: function () {
- return window.innerWidth;
- },
- /**
- * Get thread window in the hidden windows matching ID `threadID`.
- *
- * Note: hidden windows are open windows that cannot be displayed
- * due to having more thread windows open than available slots for thread
- * windows on the screen. These thread windows are displayed in the hidden
- * thread window dropdown menu.
- *
- * @private
- * @param {integer|string} threadID
- * @returns {mail.ThreadWindow|undefined} the hidden thread window,
- * if exists
- */
- _getHiddenThreadWindow: function (threadID) {
- return _.find(this._hiddenThreadWindows, function (threadWindow) {
- return threadWindow.getID() === threadID;
- });
- },
- /**
- * Get thread window matching ID `threadID`
- *
- * @private
- * @param {integer} threadID
- * @returns {mail.ThreadWindow|undefined} the thread window, if exists
- */
- _getThreadWindow: function (threadID) {
- return _.find(this._threadWindows, function (threadWindow) {
- return threadWindow.getID() === threadID;
- });
- },
- /**
- * @override
- * @private
- */
- _initializeInternalState: function () {
- this._super.apply(this, arguments);
-
- this._availableSlotsForThreadWindows = 0;
- this._hiddenThreadWindows = [];
- // used to keep dropdown open when closing thread windows
- this._keepHiddenThreadWindowsDropdownOpen = false;
- this._spaceLeftForThreadWindows = 0;
- this._threadWindows = [];
- // jQuery element for the dropdown of hidden thread windows
- // see _renderHiddenThreadWindowsDropdown
- this._$hiddenThreadWindowsDropdown = null;
- },
- /**
- * @override
- * @private
- */
- _listenOnBuses: function () {
- this._super.apply(this, arguments);
- this._mailBus
- .on('update_message', this, this._onUpdateMessage)
- .on('new_message', this, this._onNewMessage)
- .on('new_channel', this, this._onNewChannel)
- .on('is_thread_bottom_visible', this, this._onIsThreadBottomVisible)
- .on('unsubscribe_from_channel', this, this._onUnsubscribeFromChannel)
- .on('updated_im_status', this, this._onUpdatedImStatus)
- .on('updated_out_of_office', this, this._onUpdatedOutOfOffice)
- .on('update_thread_unread_counter', this, this._onUpdateThreadUnreadCounter);
-
- core.bus.on('resize', this, _.debounce(this._repositionThreadWindows.bind(this), 100));
- },
- /**
- * Make the hidden thread window dropdown menu, that is render it and set
- * event listener on this dropdown menu DOM element.
- *
- * @private
- */
- _makeHiddenThreadWindowsDropdown: function () {
- var self = this;
- if (this._$hiddenThreadWindowsDropdown) {
- this._$hiddenThreadWindowsDropdown.remove();
- }
- if (this._hiddenThreadWindows.length) {
- this._$hiddenThreadWindowsDropdown = this._renderHiddenThreadWindowsDropdown();
- var $hiddenWindowsDropdown = this._$hiddenThreadWindowsDropdown;
- $hiddenWindowsDropdown.css({right: this.THREAD_WINDOW_WIDTH * this._availableSlotsForThreadWindows, bottom: 0 })
- .appendTo(this.THREAD_WINDOW_APPENDTO);
- this._repositionHiddenWindowsDropdown();
- this._keepHiddenThreadWindowsDropdownOpen = false;
-
- $hiddenWindowsDropdown
- .on('click', '.o_thread_window_header', function (ev) {
- var threadID = $(ev.currentTarget).data('thread-id');
- var threadWindow = self._getHiddenThreadWindow(threadID);
- if (threadWindow) {
- self._makeThreadWindowVisible(threadWindow);
- }
- })
- .on('click', '.o_thread_window_close', function (ev) {
- ev.preventDefault();
- var threadID = $(ev.currentTarget).closest('.o_thread_window_header')
- .data('thread-id');
- var threadWindow = self._getHiddenThreadWindow(threadID);
- if (threadWindow) {
- threadWindow.close();
- // keep the dropdown open
- self._keepHiddenThreadWindowsDropdownOpen = true;
- }
- });
- }
- },
- /**
- * Make a new thread window linked to a thread.
- *
- * @private
- * @param {mail.model.Thread} thread
- * @param {Object} options
- * @param {boolean} [options.passively=false]
- */
- _makeNewThreadWindow: function (thread, options) {
- return new ThreadWindow(this, thread, _.extend(options, {
- autofocus: !options.passively,
- }));
- },
- /**
- * Make an open thread window fully visible on screen.
- *
- * This method assumes that the thread window is hidden (i.e. in the hidden
- * dropdown menu). To make it visible, it swap the position of this thread
- * window with the last thread window that is visible (i.e. the left-most
- * visible thread window).
- *
- * @private
- * @param {mail.ThreadWindow} threadWindow
- */
- _makeThreadWindowVisible: function (threadWindow) {
- utils.swap(
- this._threadWindows,
- threadWindow,
- this._threadWindows[this._availableSlotsForThreadWindows-1]
- );
- this._repositionThreadWindows();
- threadWindow.toggleFold(false);
- },
- /**
- * Open and detach the DM chat in a thread window.
- *
- * This method assumes that no such DM chat exists locally, so it is kind
- * of a "create DM chat and open DM chat window" operation
- *
- * @private
- * @param {integer} partnerID
- * @returns {Promise<integer>} resolved with ID of the DM chat
- */
- _openAndDetachDMChat: async function (partnerID) {
- const data = await this._rpc({
- model: 'mail.channel',
- method: config.device.isMobile ? 'channel_get' : 'channel_get_and_minimize',
- args: [[partnerID]],
- });
- const channelID = await this._addChannel(data);
- const channel = this.getChannel(channelID);
- channel.detach();
- },
- /**
- * On opening a new thread window, place it with other thread windows:
- *
- * - if it has been open with the blank thread window, replace the blank
- * thread window with this one
- * - if it has been open passively, simply but it after all windows
- * - otherwise, make it the left-most visible thread window
- *
- * @param {mail.ThreadWindow} threadWindow a thread window that is linked
- * to a thread (this must not be the blank thread window)
- * @param {boolean} [passively=false] if set, if the thread window does not
- * replace the blank thread window, it is add at the tail of the list of
- * thread windows, which might be put in the thread window hidden dropdown
- * menu if there are not enough space on the screen.
- */
- _placeNewThreadWindow: function (threadWindow, passively) {
- var thread = this.getThread(threadWindow.getID());
- // replace the blank thread window?
- // the thread window should be a DM
- var blankThreadWindow = this._getBlankThreadWindow();
- if (
- blankThreadWindow &&
- thread.getType() === 'dm_chat' &&
- thread.getDirectPartnerID() === blankThreadWindow.directPartnerID
- ) {
- // the window takes the place of the 'blank' thread window
- var index = _.indexOf(this._threadWindows, blankThreadWindow);
- this._threadWindows[index] = threadWindow;
- blankThreadWindow.destroy();
- } else if (passively) {
- // simply insert the window to the left
- this._threadWindows.push(threadWindow);
- } else {
- // add window such that it is visible
- this._addThreadWindow(threadWindow);
- }
- },
- /**
- * Unfold dropdown to the left if there is enough space on the screen.
- *
- * @private
- */
- _repositionHiddenWindowsDropdown: function () {
- var $dropdownUL = this._$hiddenThreadWindowsDropdown.children('ul');
- if (this._spaceLeftForThreadWindows > $dropdownUL.width() + 10) {
- $dropdownUL.addClass('dropdown-menu-right');
- }
- },
- /**
- * Load the template of the hidden thread window dropdown
- *
- * @private
- * @returns {jQuery.Element}
- */
- _renderHiddenThreadWindowsDropdown: function () {
- var $dropdown = $(QWeb.render('mail.HiddenThreadWindowsDropdown', {
- threadWindows: this._hiddenThreadWindows,
- open: this._keepHiddenThreadWindowsDropdownOpen,
- unreadCounter: this._hiddenThreadWindowsUnreadCounter,
- widget: {
- isMobile: function () {
- return config.device.isMobile;
- },
- },
- }));
- return $dropdown;
- },
- /**
- * Reposition the thread windows that should be hidden on the screen.
- * Thread windows that have an index equal or greater than `index` in the
- * attribute `threadWindows` should be hidden. Those thread windows are put
- * in the hidden thread window dropdown menu.
- *
- * This function assumes that we have already computed the available slots.
- *
- * @private
- */
- _repositionHiddenThreadWindows: function () {
- var hiddenWindows = [];
- var hiddenUnreadCounter = 0;
- var index = this._availableSlotsForThreadWindows;
- while (index < this._threadWindows.length) {
- var threadWindow = this._threadWindows[index];
- hiddenWindows.push(threadWindow);
- hiddenUnreadCounter += threadWindow.getUnreadCounter();
- threadWindow.do_hide();
- index++;
- }
- this._hiddenThreadWindows = hiddenWindows;
- this._hiddenThreadWindowsUnreadCounter = hiddenUnreadCounter;
-
- this._makeHiddenThreadWindowsDropdown();
- },
- /**
- * Reposition the thread windows, based on the size of the screen:
- *
- * - display thread windows by increasing order of index in
- * `_threadWindows` attribute, from right to left on the screen
- * - if there is no enough space to show all windows at once, display
- * a dropdown menu for hidden windows.
- *
- * This method should be called whenever there is a change of state of a
- * thread in a window. Also, when this method is called, all the windows
- * are visible and stacked in the top-left corner of the screen.
- *
- * @private
- */
- _repositionThreadWindows: function () {
- this._computeAvailableSlotsForThreadWindows();
-
- this._repositionVisibleThreadWindows();
- this._repositionHiddenThreadWindows();
- },
- /**
- * Reposition the thread windows that should be visible on the screen.
- *
- * This function assumes that we have already computed the available slots.
- *
- * @private
- */
- _repositionVisibleThreadWindows: function () {
- var index = 0;
- while (
- index < this._availableSlotsForThreadWindows &&
- index < this._threadWindows.length
- ) {
- var threadWindow = this._threadWindows[index];
- var cssProps = {bottom: 0};
- cssProps[_t.database.parameters.direction === 'rtl' ? 'left' : 'right'] = this.THREAD_WINDOW_WIDTH*index;
- threadWindow.$el.css(cssProps);
- threadWindow.do_show();
- index++;
- }
- },
- /**
- * Update thread windows state of threads that have `message`.
- * This is either a new message or an updated message.
- *
- * @private
- * @param {mail.model.Message} message
- * @param {Object} options
- * @param {boolean} [options.keepBottom=false] if set, thread windows with
- * this message should scroll to the bottom if their bottoms are currently
- * visible.
- * @param {boolean} [options.passively=false] if set, thread windows with
- * this message become passive, so that they are marked as read only when
- * the focus is on the thread window.
- */
- _updateThreadWindowsFromMessage: function (message, options) {
- _.each(this._threadWindows, function (threadWindow) {
- if (_.contains(message.getThreadIDs(), threadWindow.getID())) {
- threadWindow.update(options);
- }
- });
- },
-
- //--------------------------------------------------------------------------
- // Handlers
- //--------------------------------------------------------------------------
-
- /**
- * Called when manually closing the blank thread window.
- *
- * @private
- */
- _onCloseBlankThreadWindow: function () {
- this._closeBlankThreadWindow();
- },
- /**
- * @private
- * @param {boolean} open
- */
- _onDiscussOpen: function (open) {
- this._super.apply(this, arguments);
-
- if (open) {
- $(this.THREAD_WINDOW_APPENDTO).addClass('o_no_thread_window');
- } else {
- $(this.THREAD_WINDOW_APPENDTO).removeClass('o_no_thread_window');
- this._repositionThreadWindows();
- }
- },
- /**
- * Called when someone asks window manager whether the bottom of `thread` is
- * visible or not. An object `query` is provided in order to reponse on the
- * key `isVisible`.
- *
- * @private
- * @param {mail.model.Thread} thread
- * @param {Object} query
- * @param {boolean} query.isVisible write on it
- */
- _onIsThreadBottomVisible: function (thread, query) {
- _.each(this._threadWindows, function (threadWindow) {
- if (
- thread.getID() === threadWindow.getID() &&
- threadWindow.isAtBottom() &&
- !threadWindow.isHidden()
- ) {
- query.isVisible = true;
- }
- });
- },
- /**
- * Show or hide window of this channel when a new channel is added.
- *
- * @private
- * @param {mail.model.Channel} channel
- * @param {Promise[]} proms used to synchronize async operations on the
- * channel (like the rendering of its window)
- */
- _onNewChannel: function (channel, proms) {
- if (channel.isDetached()) {
- if (!config.device.isMobile) {
- var prom = this.openThreadWindow(channel.getID(), {
- keepFoldState: true,
- passively: true,
- });
- proms.push(prom);
- }
- } else {
- this._closeThreadWindow(channel.getID());
- }
- },
- /**
- * Update thread window containing this message when a new message is added.
- *
- * @private
- * @param {Object} message
- */
- _onNewMessage: function (message) {
- this._updateThreadWindowsFromMessage(message, { keepBottom: true, passively: true });
- },
- /**
- * Close the thread window when unsusbscribe from a channel.
- *
- * @private
- * @param {integer} channelID
- */
- _onUnsubscribeFromChannel: function (channelID) {
- this._closeThreadWindow(channelID);
- },
- /**
- * Called when there is a change of the im status of the partner.
- * The header of the thread window should be updated accordingly,
- * in order to display the correct new im status of this users.
- *
- * @private
- * @param {integer} partnerID
- */
- _onUpdatedImStatus: function (partnerIDs) {
- var self = this;
- _.each(partnerIDs, function (partnerID) {
- var thread = self.getDMChatFromPartnerID(partnerID);
- if (! thread) {
- return;
- }
- var threadWindow = self._getThreadWindow(thread.getID());
- if (!threadWindow) {
- return;
- }
- threadWindow.renderHeader();
- });
- },
- /**
- * @private
- * @param {Object} data
- * @param {integer} data.threadID
- */
- _onUpdatedOutOfOffice: function (data) {
- var threadWindow = this._getThreadWindow(data.threadID);
- if (!threadWindow) {
- return;
- }
- threadWindow.renderOutOfOffice();
- },
- /**
- * Called when a thread has its unread counter that has changed.
- * The unread counter on the thread windows should be updated.
- *
- * @private
- * @param {mail.model.Thread} thread
- */
- _onUpdateThreadUnreadCounter: function (thread) {
- var self = this;
- this._hiddenThreadWindowsUnreadCounter = 0;
- _.each(this._threadWindows, function (threadWindow) {
- if (thread.getID() === threadWindow.getID()) {
- threadWindow.renderHeader();
- if (thread.getUnreadCounter() === 0) {
- threadWindow.removePassive();
- }
- }
- if (threadWindow.isHidden()) {
- self._hiddenThreadWindowsUnreadCounter += threadWindow.getUnreadCounter();
- }
- });
- if (this._$hiddenThreadWindowsDropdown) {
- this._$hiddenThreadWindowsDropdown.html(
- this._renderHiddenThreadWindowsDropdown().html());
- this._repositionHiddenWindowsDropdown();
- }
- },
- /**
- * Called when a message has been updated.
- *
- * @private
- * @param {Object} message
- */
- _onUpdateMessage: function (message) {
- this._updateThreadWindowsFromMessage(message, { keepBottom: false });
- },
-
-});
-
-return MailManager;
-
-});
diff --git a/addons/mail/static/src/js/systray/systray_activity_menu.js b/addons/mail/static/src/js/systray/systray_activity_menu.js
index 5491c0f48a0..97b0c227523 100644
--- a/addons/mail/static/src/js/systray/systray_activity_menu.js
+++ b/addons/mail/static/src/js/systray/systray_activity_menu.js
@@ -7,6 +7,8 @@ var SystrayMenu = require('web.SystrayMenu');
var Widget = require('web.Widget');
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
@@ -22,7 +24,7 @@ var ActivityMenu = Widget.extend({
},
start: function () {
this._$activitiesPreview = this.$('.o_mail_systray_dropdown_items');
- this.call('mail_service', 'getMailBus').on('activity_updated', this, this._updateCounter);
+ Component.env.bus.on('activity_updated', this, this._updateCounter);
this._updateCounter();
this._updateActivityPreview();
return this._super();
diff --git a/addons/mail/static/src/js/systray/systray_messaging_menu.js b/addons/mail/static/src/js/systray/systray_messaging_menu.js
deleted file mode 100644
index 88e339b32b0..00000000000
--- a/addons/mail/static/src/js/systray/systray_messaging_menu.js
+++ /dev/null
@@ -1,394 +0,0 @@
-odoo.define('mail.systray.MessagingMenu', function (require) {
-"use strict";
-
-var config = require('web.config');
-var core = require('web.core');
-var SystrayMenu = require('web.SystrayMenu');
-var Widget = require('web.Widget');
-var QWeb = core.qweb;
-
-/**
- * Menu item appended in the systray part of the navbar
- *
- * The menu item indicates the counter of needactions + unread messages in chat
- * channels. When clicking on it, it toggles a dropdown containing a preview of
- * each pinned channels (except mailbox and mass mailing channels) with a quick
- * link to open them in chat windows. It also contains a direct link to the
- * Inbox in Discuss.
- **/
-var MessagingMenu = Widget.extend({
- name: 'messaging_menu',
- template:'mail.systray.MessagingMenu',
- events: {
- 'click .o_mail_preview': '_onClickPreview',
- 'click .o_filter_button': '_onClickFilterButton',
- 'click .o_new_message': '_onClickNewMessage',
- 'click .o_mail_preview_mark_as_read': '_onClickPreviewMarkAsRead',
- 'click .o_thread_window_expand': '_onClickExpand',
- 'show.bs.dropdown': '_onShowDropdown',
- 'hide.bs.dropdown': '_onHideDropdown',
- },
- /**
- * @override
- */
- start: function () {
- this._$filterButtons = this.$('.o_filter_button');
- this._$previews = this.$('.o_mail_systray_dropdown_items');
- this._filter = false;
- this._isMessagingReady = this.call('mail_service', 'isReady');
- this._updateCounter();
- var mailBus = this.call('mail_service', 'getMailBus');
- mailBus.on('messaging_ready', this, this._onMessagingReady);
- mailBus.on('update_needaction', this, this._updateCounter);
- mailBus.on('new_channel', this, this._updateCounter);
- mailBus.on('update_thread_unread_counter', this, this._updateCounter);
- return this._super.apply(this, arguments);
- },
-
- //--------------------------------------------------------------------------
- // Public
- //--------------------------------------------------------------------------
-
- /**
- * States whether the widget is in mobile mode or not.
- * This is used by the template.
- *
- * @returns {boolean}
- */
- isMobile: function () {
- return config.device.isMobile;
- },
-
- //--------------------------------------------------------------------------
- // Private
- //--------------------------------------------------------------------------
-
- /**
- * Called when clicking on a preview related to a mail failure
- *
- * @private
- * @param {$.Element} $target DOM of preview element clicked
- */
- _clickMailFailurePreview: function ($target) {
- var documentID = $target.data('document-id');
- var documentModel = $target.data('document-model');
- if (documentModel && documentID) {
- this._openDocument(documentModel, documentID);
- } else if (documentModel !== 'mail.channel') {
- // preview of mail failures grouped to different document of same model
- this.do_action({
- name: "Mail failures",
- type: 'ir.actions.act_window',
- view_mode: 'kanban,list,form',
- views: [[false, 'kanban'], [false, 'list'], [false, 'form']],
- target: 'current',
- res_model: documentModel,
- domain: [['message_has_error', '=', true]],
- });
- }
- },
- /**
- * Compute the counter next to the systray messaging menu. This counter is
- * the sum of unread messages in channels, the counter of the mailbox inbox,
- * and the amount of mail failures.
- *
- * @private
- * @returns {integer}
- */
- _computeCounter: function () {
- var channels = this.call('mail_service', 'getChannels');
- var channelUnreadCounters = _.map(channels, function (channel) {
- return channel.getUnreadCounter();
- });
- var unreadChannelCounter = _.reduce(channelUnreadCounters, function (acc, c) {
- return c > 0 ? acc + 1 : acc;
- }, 0);
- var inboxCounter = this.call('mail_service', 'getMailbox', 'inbox').getMailboxCounter();
- var mailFailureCounter = this.call('mail_service', 'getMailFailures').length;
-
- return unreadChannelCounter + inboxCounter + mailFailureCounter;
- },
- /**
- * @private
- * @returns {Promise<Object[]>} resolved with list of previews that are
- * compatible with the 'mail.Preview' template.
- */
- _getPreviews: function () {
- return this.call('mail_service', 'getSystrayPreviews', this._filter);
- },
- /**
- * @private
- * @return {boolean} whether the messaging menu is shown or not.
- */
- _isShown: function () {
- return this.$el.hasClass('show');
- },
- /**
- * Process Preview Mark As Read
- *
- * @private
- * @param {Element} $preview
- */
- _markAsRead: function ($preview) {
- var previewID = $preview.data('preview-id');
- if (previewID === 'mailbox_inbox') {
- var messageIDs = $preview.data('message-ids');
-
- if (typeof messageIDs === 'string') {
- messageIDs = messageIDs.split(',').map(id => Number(id));
- } else {
- messageIDs = [$preview.data('message-ids')];
- }
-
- this.call('mail_service', 'markMessagesAsRead', messageIDs);
- } else if (previewID === 'mail_failure') {
- var documentModel = $preview.data('document-model');
- var unreadCounter = $preview.data('unread-counter');
- this.do_action('mail.mail_resend_cancel_action', {
- additional_context: {
- default_model: documentModel,
- unread_counter: unreadCounter
- }
- });
- } else {
- // this is mark as read on a thread
- var thread = this.call('mail_service', 'getThread', previewID);
- if (thread) {
- thread.markAsRead();
- }
- }
- },
- /**
- * Open discuss
- *
- * @private
- * @param {integer} [channelID] if set, auto-select this channel when
- * opening the discuss app.
- */
- _openDiscuss: function (channelID) {
- var self = this;
- var discussOptions = { clear_breadcrumbs: true };
-
- if (channelID) {
- discussOptions.active_id = channelID;
- }
-
- this.do_action('mail.action_discuss', discussOptions)
- .then(function () {
- // we cannot 'go back to previous page' otherwise
- self.trigger_up('hide_home_menu');
- core.bus.trigger('change_menu_section',
- self.call('mail_service', 'getDiscussMenuID'));
- });
- },
- /**
- * Open the document
- *
- * @private
- * @param {string} documentModel the model of the document
- * @param {integer} documentID
- */
- _openDocument: function (documentModel, documentID) {
- if (documentModel === 'mail.channel') {
- this._openDiscuss(documentID);
- } else {
- this.do_action({
- type: 'ir.actions.act_window',
- res_model: documentModel,
- views: [[false, 'form']],
- res_id: documentID
- });
- }
- },
- /**
- * Render the list of conversation previews
- *
- * @private
- * @param {Object} previews list of valid objects for preview rendering
- * (see mail.Preview template)
- */
- _renderPreviews: function (previews) {
- this._$previews.html(QWeb.render('mail.systray.MessagingMenu.Previews', {
- previews: previews,
- }));
- },
- /**
- * Get and render list of previews, based on the selected filter
- *
- * preview shows the last message of a channel with inline format.
- * There is a hack where filter "All" also shows preview of chatter
- * messages (which are not channels).
- *
- * List of filters:
- *
- * 1. All
- * - filter: undefined
- * - previews: last messages of all non-mailbox channels, in addition
- * to last messages of chatter (get from inbox)
- *
- * 2. Channel
- * - filter: "Channels"
- * - previews: last messages of all non-mailbox and non-DM channels
- *
- * 3. Chat
- * - filter: "Chat"
- * - previews: last messages of all DM channels
- *
- * @private
- */
- _updatePreviews: function () {
- // Display spinner while waiting for conversations preview
- this._$previews.html(QWeb.render('Spinner'));
- if (!this._isMessagingReady) {
- return;
- }
- this._getPreviews()
- .then(this._renderPreviews.bind(this));
- },
- /**
- * Update the counter on the systray messaging menu icon.
- * The counter display the number of unread messages in channels (DM included), the number of
- * messages in Inbox mailbox, and the number of mail failures.
- * Also updates the previews if the messaging menu is open.
- *
- * Note that the number of unread messages in document thread are ignored, because they are
- * already considered in the number of messages in Inbox with the current design.
- * Also, some unread messages in channel can also be in inbox, so they are considered twice in
- * the counter. This is intended, as the number of needaction messages in a channel are
- * separately considered in the messaging menu from the unread messages, even though a message
- * can be both unread and needaction (such a message increments the counter twice).
- *
- * The global counter of the messaging menu should match the counter next to each of the preview
- * item when the messaging menu is open.
- *
- * @private
- */
- _updateCounter: function () {
- if (!this._isMessagingReady) {
- return;
- }
- var counter = this._computeCounter();
- this.$('.o_mail_messaging_menu_icon').removeClass('fa-spinner fa-spin');
- this.$('.o_notification_counter').text(counter);
- this.$el.toggleClass('o_no_notification', !counter);
- if (this._isShown()) {
- this._updatePreviews();
- }
- },
-
- //--------------------------------------------------------------------------
- // Handlers
- //--------------------------------------------------------------------------
-
- /**
- * @private
- */
- _onShowDropdown: function () {
- document.body.classList.add('modal-open');
- this._updatePreviews();
- },
- /**
- * @private
- */
- _onHideDropdown: function () {
- document.body.classList.remove('modal-open');
- },
- /**
- * Opens the related document
- *
- * @private
- * @param {MouseEvent} ev
- */
- _onClickExpand: function (ev) {
- ev.stopPropagation();
- var $preview = $(ev.currentTarget).closest('.o_mail_preview');
- var documentModel = $preview.data('document-model');
- var documentID = $preview.data('document-id');
- this._openDocument(documentModel, documentID);
- },
- /**
- * @private
- * @param {MouseEvent} ev
- */
- _onClickFilterButton: function (ev) {
- ev.stopPropagation();
- this._$filterButtons.removeClass('active');
- var $target = $(ev.currentTarget);
- $target.addClass('active');
- this._filter = $target.data('filter');
- this._updatePreviews();
- },
- /**
- * @private
- */
- _onClickNewMessage: function () {
- this.call('mail_service', 'openBlankThreadWindow');
- },
- /**
- * When a preview is clicked on, we want to open the related object
- * (thread, mail failure, etc.)
- *
- * @private
- * @param {MouseEvent} ev
- */
- _onClickPreview: function (ev) {
- var $target = $(ev.currentTarget);
- var previewID = $target.data('preview-id');
-
- if (previewID === 'mail_failure') {
- this._clickMailFailurePreview($target);
- } else if (previewID === 'mailbox_inbox') {
- // inbox preview for non-document thread,
- // e.g. needaction message of channel
- var documentID = $target.data('document-id');
- var documentModel = $target.data('document-model');
- if (!documentModel) {
- this._openDiscuss('mailbox_inbox');
- } else {
- this._openDocument(documentModel, documentID);
- }
- } else {
- // preview of thread
- this.call('mail_service', 'openThread', previewID);
- }
- },
- /**
- * When a preview "Mark as read" button is clicked on, we want mark message
- * as read
- *
- * @private
- * @param {MouseEvent} ev
- */
- _onClickPreviewMarkAsRead: function (ev) {
- ev.stopPropagation();
- var $preview = $(ev.currentTarget).closest('.o_mail_preview');
- this._markAsRead($preview);
- },
- /**
- * @private
- */
- _onMessagingReady: function () {
- if (this._isMessagingReady) {
- return;
- }
- this._isMessagingReady = true;
- this._updateCounter();
- },
-});
-
-// 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.
-var activityMenuIndex = _.findIndex(SystrayMenu.Items, function (SystrayMenuItem) {
- return 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/js/thread_field.js b/addons/mail/static/src/js/thread_field.js
deleted file mode 100644
index a34582c1dde..00000000000
--- a/addons/mail/static/src/js/thread_field.js
+++ /dev/null
@@ -1,265 +0,0 @@
-odoo.define('mail.ThreadField', function (require) {
-"use strict";
-
-var CreateModeDocumentThread = require('mail.model.CreateModeDocumentThread');
-var ThreadWidget = require('mail.widget.Thread');
-
-var AbstractField = require('web.AbstractField');
-var core = require('web.core');
-var field_registry = require('web.field_registry');
-var concurrency = require('web.concurrency');
-
-var _t = core._t;
-
-/**
- * 'mail_thread' widget: displays the thread of messages
- */
-var ThreadField = AbstractField.extend({
- /**
- * @override
- */
- init: function () {
- this._super.apply(this, arguments);
- this._isMessagingReady = this.call('mail_service', 'isReady');
- this._isStarted = false;
- this.call('mail_service', 'getMailBus').on('messaging_ready', this, this._onMessagingReady);
- // Used to automatically mark document thread as read at the moment we
- // access the document and render the thread.
- this._markAsReadOnNextRender = false;
- this._setDocumentThread();
- },
- /**
- * @override
- */
- start: function () {
- var self = this;
-
- this._isStarted = true;
- this.dp = new concurrency.DropPrevious();
-
- this._threadWidget = new ThreadWidget(this, {
- displayOrder: ThreadWidget.ORDER.DESC,
- displayDocumentLinks: false,
- displayMarkAsRead: false,
- hasMessageAttachmentDeletable: true,
- squashCloseMessages: false,
- });
-
- this._threadWidget.on('load_more_messages', this, this._onLoadMoreMessages);
- this._threadWidget.on('redirect', this, this._onRedirect);
- this._threadWidget.on('redirect_to_channel', this, this._onRedirectToChannel);
- this._threadWidget.on('toggle_star_status', this, function (messageID) {
- var message = self.call('mail_service', 'getMessage', messageID);
- message.toggleStarStatus();
- });
-
- var def1 = this._threadWidget.appendTo(this.$el);
- var def2 = this._super.apply(this, arguments);
-
- return this.alive(Promise.all([def1, def2])).then(function () {
- // unwrap the thread to remove an unnecessary level on div
- self.setElement(self._threadWidget.$el);
- var mailBus = self.call('mail_service', 'getMailBus');
- mailBus.on('new_message', self, self._onNewMessage);
- mailBus.on('update_message', self, self._onUpdateMessage);
- });
- },
-
- //--------------------------------------------------------------------------
- // Public
- //--------------------------------------------------------------------------
-
- /**
- * @param {integer[]} attachmentIDs
- */
- removeAttachments: function (attachmentIDs) {
- this._documentThread.removeAttachmentsFromMessages(attachmentIDs);
- },
- /**
- * @override
- * @return {boolean}
- */
- isSet: function () {
- return true;
- },
- /**
- * @param {Object} message
- * @param {integer[]} message.partner_ids
- * @return {Promise}
- */
- postMessage: function (message) {
- var self = this;
- return this._documentThread.postMessage(message)
- .then(function () {
- if (message.partner_ids.length) {
- self.trigger_up('reload_mail_fields', { followers: true });
- }
- }).guardedCatch(function () {
- self.do_notify(false, _t("Message not sent"));
- return Promise.reject();
- });
- },
-
- //--------------------------------------------------------------------------
- // Private
- //--------------------------------------------------------------------------
-
- /**
- * @private
- * @param {Object} [options]
- * @param {boolean} [options.forceFetch]
- * @return {Promise}
- */
- _fetchAndRenderThread: function (options) {
- var self = this;
- if (!this._isMessagingReady) {
- self._threadWidget.renderLoading();
- return Promise.resolve();
- } else if (!this._documentThread) {
- var thread = new CreateModeDocumentThread();
- options = { isCreateMode: true };
- self._threadWidget.render(thread, options);
- return Promise.resolve();
- } else {
- var fetchDef = this.dp.add(this._documentThread.fetchMessages(options));
- return fetchDef.then(function () {
- self._threadWidget.render(self._documentThread, {
- displayLoadMore: self._documentThread.getMessages().length < self._documentThread.getMessageIDs().length,
- });
- if (self._markAsReadOnNextRender) {
- self._markAsReadOnNextRender = false;
- return self._documentThread.markAsRead();
- }
- });
- }
- },
- /**
- * @override
- * @private
- * @returns {Promise}
- */
- _render: function () {
- return this._fetchAndRenderThread();
- },
- /**
- * The mail widget being persistent, one needs to update the res_id and
- * to set the correct DocumentThread on reset.
- *
- * @override
- * @private
- * @param {any} record
- */
- _reset: function (record) {
- this._super.apply(this, arguments);
- this.res_id = record.res_id;
- this._setDocumentThread();
- },
- /**
- * Sets this._documentThread, the DocumentThread associated with the current
- * model and resID.
- *
- * If it is a new document in create mode, unset the document thread.
- */
- _setDocumentThread: function () {
- if (!this._isMessagingReady) {
- return;
- }
- var params = {
- messageIDs: this.value.res_ids,
- name: this.recordData.display_name,
- resID: this.res_id,
- resModel: this.model,
- };
- if (!params.resID) {
- this._documentThread = null;
- } else {
- this._documentThread = this.call('mail_service', 'getOrAddDocumentThread', params);
- this._markAsReadOnNextRender = true;
- }
- },
-
- //--------------------------------------------------------------------------
- // Handlers
- //--------------------------------------------------------------------------
-
- /**
- * When a new message arrives, fetch its data to render it
- *
- * @private
- */
- _onLoadMoreMessages: function () {
- this._fetchAndRenderThread({ forceFetch: true });
- },
- /**
- * @private
- */
- _onMessagingReady: function () {
- if (this._isMessagingReady) {
- return;
- }
- this._isMessagingReady = true;
- if (!this._isStarted) {
- return;
- }
- this._setDocumentThread();
- this._render();
- },
- /**
- * @private
- * @param {mail.model.Message}
- */
- _onNewMessage: function (message) {
- if (
- message.isLinkedToDocumentThread() &&
- message.getDocumentModel() === this.model &&
- message.getDocumentID() === this.res_id
- ) {
- this.trigger_up('new_message', {
- id: this.value.id,
- messageIDs: this._documentThread.getMessageIDs(),
- });
- this._fetchAndRenderThread();
- }
- },
- /**
- * @private
- * @param {integer} channelID
- */
- _onRedirectToChannel: function (channelID) {
- var self = this;
- this.call('mail_service', 'joinChannel', channelID).then(function () {
- // Execute Discuss with 'channel' as default channel
- self.do_action('mail.action_discuss', { active_id: channelID });
- });
- },
- /**
- * @private
- * @param {string} resModel
- * @param {integer} resID
- */
- _onRedirect: function (resModel, resID) {
- this.trigger_up('redirect', {
- res_id: resID,
- res_model: resModel,
- });
- },
- /**
- * @private
- * @param {mail.model.Message}
- */
- _onUpdateMessage: function (message) {
- if (
- message.isLinkedToDocumentThread() &&
- message.getDocumentModel() === this.model &&
- message.getDocumentID() === this.res_id
- ) {
- this._fetchAndRenderThread();
- }
- },
-});
-
-field_registry.add('mail_thread', ThreadField);
-
-return ThreadField;
-
-});
diff --git a/addons/mail/static/src/js/thread_widget.js b/addons/mail/static/src/js/thread_widget.js
deleted file mode 100644
index 583d14e949a..00000000000
--- a/addons/mail/static/src/js/thread_widget.js
+++ /dev/null
@@ -1,686 +0,0 @@
-odoo.define('mail.widget.Thread', function (require) {
-"use strict";
-
-var DocumentViewer = require('mail.DocumentViewer');
-var mailUtils = require('mail.utils');
-
-var core = require('web.core');
-var time = require('web.time');
-var Widget = require('web.Widget');
-
-var QWeb = core.qweb;
-var _t = core._t;
-var _lt = core._lt;
-
-var ORDER = {
- ASC: 1, // visually, ascending order of message IDs (from top to bottom)
- DESC: -1, // visually, descending order of message IDs (from top to bottom)
-};
-
-var READ_MORE = _lt("read more");
-var READ_LESS = _lt("read less");
-
-/**
- * This is a generic widget to render a thread.
- * Any thread that extends mail.model.AbstractThread can be used with this
- * widget.
- */
-var ThreadWidget = Widget.extend({
- className: 'o_mail_thread',
-
- events: {
- 'click a': '_onClickRedirect',
- 'click img': '_onClickRedirect',
- 'click strong': '_onClickRedirect',
- 'click .o_thread_show_more': '_onClickShowMore',
- 'click .o_attachment_download': '_onAttachmentDownload',
- 'click .o_attachment_view': '_onAttachmentView',
- 'click .o_attachment_delete_cross': '_onDeleteAttachment',
- 'click .o_thread_message_needaction': '_onClickMessageNeedaction',
- 'click .o_thread_message_star': '_onClickMessageStar',
- 'click .o_thread_message_reply': '_onClickMessageReply',
- 'click .oe_mail_expand': '_onClickMailExpand',
- 'click .o_thread_message': '_onClickMessage',
- 'click': '_onClick',
- 'click .o_thread_message_notification_error': '_onClickMessageNotificationError',
- 'click .o_thread_message_moderation': '_onClickMessageModeration',
- 'change .moderation_checkbox': '_onChangeModerationCheckbox',
- },
-
- /**
- * @override
- * @param {widget} parent
- * @param {Object} options
- */
- init: function (parent, options) {
- this._super.apply(this, arguments);
- this.attachments = [];
- // options when the thread is enabled (e.g. can send message,
- // interact on messages, etc.)
- this._enabledOptions = _.defaults(options || {}, {
- displayOrder: ORDER.ASC,
- displayMarkAsRead: true,
- displayModerationCommands: false,
- displayStars: true,
- displayDocumentLinks: true,
- displayAvatars: true,
- squashCloseMessages: true,
- displayNotificationIcons: true,
- displayReplyIcons: false,
- loadMoreOnScroll: false,
- hasMessageAttachmentDeletable: false,
- });
- // options when the thread is disabled
- this._disabledOptions = {
- displayOrder: this._enabledOptions.displayOrder,
- displayMarkAsRead: false,
- displayModerationCommands: false,
- displayStars: false,
- displayDocumentLinks: false,
- displayAvatars: this._enabledOptions.displayAvatars,
- squashCloseMessages: false,
- displayNotificationIcons: false,
- displayReplyIcons: false,
- loadMoreOnScroll: this._enabledOptions.loadMoreOnScroll,
- hasMessageAttachmentDeletable: false,
- };
- this._selectedMessageID = null;
- this._currentThreadID = null;
- this._messageMailPopover = null;
- this._messageSeenPopover = null;
- // used to track popover IDs to destroy on re-rendering of popovers
- this._openedSeenPopoverIDs = [];
- },
- /**
- * The message mail popover may still be shown at this moment. If we do not
- * remove it, it stays visible on the page until a page reload.
- *
- * @override
- */
- destroy: function () {
- clearInterval(this._updateTimestampsInterval);
- if (this._messageMailPopover) {
- this._messageMailPopover.popover('hide');
- }
- if (this._messageSeenPopover) {
- this._messageSeenPopover.popover('hide');
- }
- this._destroyOpenSeenPopoverIDs();
- this._super();
- },
- /**
- * @param {mail.model.AbstractThread} thread the thread to render.
- * @param {Object} [options]
- * @param {integer} [options.displayOrder=ORDER.ASC] order of displaying
- * messages in the thread:
- * - ORDER.ASC: last message is at the bottom of the thread
- * - ORDER.DESC: last message is at the top of the thread
- * @param {boolean} [options.displayLoadMore]
- * @param {Array} [options.domain=[]] the domain for the messages in the
- * thread.
- * @param {boolean} [options.isCreateMode]
- * @param {boolean} [options.scrollToBottom=false]
- * @param {boolean} [options.squashCloseMessages]
- */
- render: function (thread, options) {
- var self = this;
-
- var shouldScrollToBottomAfterRendering = false;
- if (this._currentThreadID === thread.getID() && this.isAtBottom()) {
- shouldScrollToBottomAfterRendering = true;
- }
- this._currentThreadID = thread.getID();
-
- // copy so that reverse do not alter order in the thread object
- var messages = _.clone(thread.getMessages({ domain: options.domain || [] }));
-
- var modeOptions = options.isCreateMode ? this._disabledOptions :
- this._enabledOptions;
-
- // attachments ordered by messages order (increasing ID)
- this.attachments = _.uniq(_.flatten(_.map(messages, function (message) {
- return message.getAttachments();
- })));
-
- options = _.extend({}, modeOptions, options, {
- selectedMessageID: this._selectedMessageID,
- });
-
- // dict where key is message ID, and value is whether it should display
- // the author of message or not visually
- var displayAuthorMessages = {};
-
- // Hide avatar and info of a message if that message and the previous
- // one are both comments wrote by the same author at the same minute
- // and in the same document (users can now post message in documents
- // directly from a channel that follows it)
- var prevMessage;
- _.each(messages, function (message) {
- if (
- // is first message of thread
- !prevMessage ||
- // more than 1 min. elasped
- (Math.abs(message.getDate().diff(prevMessage.getDate())) > 60000) ||
- prevMessage.getType() !== 'comment' ||
- message.getType() !== 'comment' ||
- // from a different author
- (prevMessage.getAuthorID() !== message.getAuthorID()) ||
- (
- // messages are linked to a document thread
- (
- prevMessage.isLinkedToDocumentThread() &&
- message.isLinkedToDocumentThread()
- ) &&
- (
- // are from different documents
- prevMessage.getDocumentModel() !== message.getDocumentModel() ||
- prevMessage.getDocumentID() !== message.getDocumentID()
- )
- )
- ) {
- displayAuthorMessages[message.getID()] = true;
- } else {
- displayAuthorMessages[message.getID()] = !options.squashCloseMessages;
- }
- prevMessage = message;
- });
-
- if (modeOptions.displayOrder === ORDER.DESC) {
- messages.reverse();
- }
-
- this.$el.html(QWeb.render('mail.widget.Thread', {
- thread: thread,
- displayAuthorMessages: displayAuthorMessages,
- options: options,
- ORDER: ORDER,
- dateFormat: time.getLangDatetimeFormat(),
- }));
-
- _.each(messages, function (message) {
- var $message = self.$('.o_thread_message[data-message-id="'+ message.getID() +'"]');
- $message.find('.o_mail_timestamp').data('date', message.getDate());
-
- self._insertReadMore($message);
- });
-
- if (shouldScrollToBottomAfterRendering) {
- this.scrollToBottom();
- }
-
- if (!this._updateTimestampsInterval) {
- this.updateTimestampsInterval = setInterval(function () {
- self._updateTimestamps();
- }, 1000*60);
- }
-
- this._renderMessageNotificationPopover(messages);
- if (thread.hasSeenFeature()) {
- this._renderMessageSeenPopover(thread, messages);
- }
- },
-
- /**
- * Render thread widget when loading, i.e. when messaging is not yet ready.
- * @see /mail/init_messaging
- */
- renderLoading: function () {
- this.$el.html(QWeb.render('mail.widget.ThreadLoading'));
- },
-
- //--------------------------------------------------------------------------
- // Public
- //--------------------------------------------------------------------------
-
- getScrolltop: function () {
- return this.$el.scrollTop();
- },
- /**
- * State whether the bottom of the thread is visible or not,
- * with a tolerance of 5 pixels
- *
- * @return {boolean}
- */
- isAtBottom: function () {
- var fullHeight = this.el.scrollHeight;
- var topHiddenHeight = this.$el.scrollTop();
- var visibleHeight = this.$el.outerHeight();
- var bottomHiddenHeight = fullHeight - topHiddenHeight - visibleHeight;
- return bottomHiddenHeight < 5;
- },
- /**
- * Removes a message and re-renders the thread
- *
- * @param {integer} [messageID] the id of the removed message
- * @param {mail.model.AbstractThread} thread the thread which contains
- * updated list of messages (so it does not contain any message with ID
- * `messageID`).
- * @param {Object} [options] options for the thread rendering
- */
- removeMessageAndRender: function (messageID, thread, options) {
- var self = this;
- this._currentThreadID = thread.getID();
- return new Promise(function (resolve, reject) {
- self.$('.o_thread_message[data-message-id="' + messageID + '"]')
- .fadeOut({
- done: function () {
- if (self._currentThreadID === thread.getID()) {
- self.render(thread, options);
- }
- resolve();
- },
- duration: 200,
- });
- });
- },
- /**
- * Scroll to the bottom of the thread
- */
- scrollToBottom: function () {
- this.$el.scrollTop(this.el.scrollHeight);
- },
- /**
- * Scrolls the thread to a given message
- *
- * @param {integer} options.msgID the ID of the message to scroll to
- * @param {integer} [options.duration]
- * @param {boolean} [options.onlyIfNecessary]
- */
- scrollToMessage: function (options) {
- var $target = this.$('.o_thread_message[data-message-id="' + options.messageID + '"]');
- if (options.onlyIfNecessary) {
- var delta = $target.parent().height() - $target.height();
- var offset = delta < 0 ?
- 0 :
- delta - ($target.offset().top - $target.offsetParent().offset().top);
- offset = - Math.min(offset, 0);
- this.$el.scrollTo("+=" + offset + "px", options.duration);
- } else if ($target.length) {
- this.$el.scrollTo($target);
- }
- },
- /**
- * Scroll to the specific position in pixel
- *
- * If no position is provided, scroll to the bottom of the thread
- *
- * @param {integer} [position] distance from top to position in pixels.
- * If not provided, scroll to the bottom.
- */
- scrollToPosition: function (position) {
- if (position) {
- this.$el.scrollTop(position);
- } else {
- this.scrollToBottom();
- }
- },
- /**
- * Toggle all the moderation checkboxes in the thread
- *
- * @param {boolean} checked if true, check the boxes,
- * otherwise uncheck them.
- */
- toggleModerationCheckboxes: function (checked) {
- this.$('.moderation_checkbox').prop('checked', checked);
- },
- /**
- * Unselect the selected message
- */
- unselectMessage: function () {
- this.$('.o_thread_message').removeClass('o_thread_selected_message');
- this._selectedMessageID = null;
- },
-
- //--------------------------------------------------------------------------
- // Private
- //--------------------------------------------------------------------------
-
- /**
- * @private
- */
- _destroyOpenSeenPopoverIDs: function () {
- _.each(this._openedSeenPopoverIDs, function (popoverID) {
- $('#' + popoverID).remove();
- });
- this._openedSeenPopoverIDs = [];
- },
- /**
- * Modifies $element to add the 'read more/read less' functionality
- * All element nodes with 'data-o-mail-quote' attribute are concerned.
- * All text nodes after a ``#stopSpelling`` element are concerned.
- * Those text nodes need to be wrapped in a span (toggle functionality).
- * All consecutive elements are joined in one 'read more/read less'.
- *
- * @private
- * @param {jQuery} $element
- */
- _insertReadMore: function ($element) {
- var self = this;
-
- var groups = [];
- var readMoreNodes;
-
- // nodeType 1: element_node
- // nodeType 3: text_node
- var $children = $element.contents()
- .filter(function () {
- return this.nodeType === 1 ||
- this.nodeType === 3 &&
- this.nodeValue.trim();
- });
-
- _.each($children, function (child) {
- var $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;
- self._insertReadMore($child);
- }
- });
-
- _.each(groups, function (group) {
- // Insert link just before the first node
- var $readMore = $('<a>', {
- class: 'o_mail_read_more',
- href: '#',
- text: READ_MORE,
- }).insertBefore(group[0]);
-
- // Toggle All next nodes
- var isReadMore = true;
- $readMore.click(function (e) {
- e.preventDefault();
- isReadMore = !isReadMore;
- _.each(group, function ($child) {
- $child.hide();
- $child.toggle(!isReadMore);
- });
- $readMore.text(isReadMore ? READ_MORE : READ_LESS);
- });
- });
- },
- /**
- * @private
- * @param {MouseEvent} ev
- */
- _onDeleteAttachment: function (ev) {
- ev.stopPropagation();
- var $target = $(ev.currentTarget);
- this.trigger_up('delete_attachment', {
- attachmentId: $target.data('id'),
- attachmentName: $target.data('name')
- });
- },
- /**
- * @private
- * @param {Object} options
- * @param {integer} [options.channelID]
- * @param {string} options.model
- * @param {integer} options.id
- */
- _redirect: _.debounce(function (options) {
- if ('channelID' in options) {
- this.trigger('redirect_to_channel', options.channelID);
- } else {
- this.trigger('redirect', options.model, options.id);
- }
- }, 500, true),
- /**
- * Render the popover when mouse-hovering on the notification icon of a
- * message in the thread.
- * There is at most one such popover at any given time.
- *
- * @private
- * @param {mail.model.AbstractMessage[]} messages list of messages in the
- * rendered thread, for which popover on mouseover interaction is
- * permitted.
- */
- _renderMessageNotificationPopover(messages) {
- if (this._messageMailPopover) {
- this._messageMailPopover.popover('hide');
- }
- if (!this.$('.o_thread_tooltip').length) {
- return;
- }
- this._messageMailPopover = this.$('.o_thread_tooltip').popover({
- html: true,
- boundary: 'viewport',
- placement: 'auto',
- trigger: 'hover',
- offset: '0, 1',
- content: function () {
- var messageID = $(this).data('message-id');
- var message = _.find(messages, function (message) {
- return message.getID() === messageID;
- });
- return QWeb.render('mail.widget.Thread.Message.MailTooltip', {
- notifications: message.getNotifications(),
- });
- },
- });
- },
- /**
- * Render the popover when mouse hovering on the seen icon of a message
- * in the thread. Only seen icons in non-squashed message have popover,
- * because squashed messages hides this icon on message mouseover.
- *
- * @private
- * @param {mail.model.AbstractThread} thread with thread seen mixin,
- * @see {mail.model.ThreadSeenMixin}
- * @param {mail.model.Message[]} messages list of messages in the
- * rendered thread.
- */
- _renderMessageSeenPopover: function (thread, messages) {
- var self = this;
- this._destroyOpenSeenPopoverIDs();
- if (this._messageSeenPopover) {
- this._messageSeenPopover.popover('hide');
- }
- if (!this.$('.o_thread_message_core .o_mail_thread_message_seen_icon').length) {
- return;
- }
- this._messageSeenPopover = this.$('.o_thread_message_core .o_mail_thread_message_seen_icon').popover({
- html: true,
- boundary: 'viewport',
- placement: 'auto',
- trigger: 'hover',
- offset: '0, 1',
- content: function () {
- var $this = $(this);
- self._openedSeenPopoverIDs.push($this.attr('aria-describedby'));
- var messageID = $this.data('message-id');
- var message = _.find(messages, function (message) {
- return message.getID() === messageID;
- });
- return QWeb.render('mail.widget.Thread.Message.SeenIconPopoverContent', {
- thread: thread,
- message: message,
- });
- },
- });
- },
- /**
- * @private
- */
- _updateTimestamps: function () {
- var isAtBottom = this.isAtBottom();
- this.$('.o_mail_timestamp').each(function () {
- var date = $(this).data('date');
- $(this).html(mailUtils.timeFromNow(date));
- });
- if (isAtBottom && !this.isAtBottom()) {
- this.scrollToBottom();
- }
- },
-
- //--------------------------------------------------------------------------
- // Handlers
- //--------------------------------------------------------------------------
-
- /**
- * @private
- * @param {MouseEvent} event
- */
- _onAttachmentDownload: function (event) {
- event.stopPropagation();
- },
- /**
- * @private
- * @param {MouseEvent} event
- */
- _onAttachmentView: function (event) {
- event.stopPropagation();
- var activeAttachmentID = $(event.currentTarget).data('id');
- if (activeAttachmentID) {
- var attachmentViewer = new DocumentViewer(this, this.attachments, activeAttachmentID);
- attachmentViewer.appendTo($('body'));
- }
- },
- /**
- * @private
- * @param {MouseEvent} ev
- */
- _onChangeModerationCheckbox: function (ev) {
- this.trigger_up('update_moderation_buttons');
- },
- /**
- * @private
- */
- _onClick: function () {
- if (this._selectedMessageID) {
- this.unselectMessage();
- this.trigger('unselect_message');
- }
- },
- /**
- * @private
- * @param {MouseEvent} ev
- */
- _onClickMailExpand: function (ev) {
- ev.preventDefault();
- },
- /**
- * @private
- * @param {MouseEvent} ev
- */
- _onClickMessage: function (ev) {
- $(ev.currentTarget).toggleClass('o_thread_selected_message');
- },
- /**
- * @private
- * @param {MouseEvent} ev
- */
- _onClickMessageNeedaction: function (ev) {
- var messageID = $(ev.currentTarget).data('message-id');
- this.trigger('mark_as_read', messageID);
- },
- /**
- * @private
- * @param {MouseEvent} ev
- */
- _onClickMessageNotificationError(ev) {
- const messageID = $(ev.currentTarget).data('message-id');
- this.do_action('mail.mail_resend_message_action', {
- additional_context: {
- mail_message_to_resend: messageID,
- }
- });
- },
- /**
- * @private
- * @param {MouseEvent} ev
- */
- _onClickMessageReply: function (ev) {
- this._selectedMessageID = $(ev.currentTarget).data('message-id');
- this.$('.o_thread_message').removeClass('o_thread_selected_message');
- this.$('.o_thread_message[data-message-id="' + this._selectedMessageID + '"]')
- .addClass('o_thread_selected_message');
- this.trigger('select_message', this._selectedMessageID);
- ev.stopPropagation();
- },
- /**
- * @private
- * @param {MouseEvent} ev
- */
- _onClickMessageStar: function (ev) {
- var messageID = $(ev.currentTarget).data('message-id');
- this.trigger('toggle_star_status', messageID);
- },
- /**
- * @private
- * @param {MouseEvent} ev
- */
- _onClickMessageModeration: function (ev) {
- var $button = $(ev.currentTarget);
- var messageID = $button.data('message-id');
- var decision = $button.data('decision');
- this.trigger_up('message_moderation', {
- messageID: messageID,
- decision: decision,
- });
- },
- /**
- * @private
- * @param {MouseEvent} ev
- */
- _onClickRedirect: function (ev) {
- // ignore inherited branding
- if ($(ev.target).data('oe-field') !== undefined) {
- return;
- }
- var id = $(ev.target).data('oe-id');
- if (id) {
- ev.preventDefault();
- var model = $(ev.target).data('oe-model');
- var options;
- if (model && (model !== 'mail.channel')) {
- options = {
- model: model,
- id: id
- };
- } else {
- options = { channelID: id };
- }
- this._redirect(options);
- }
- },
- /**
- * @private
- */
- _onClickShowMore: function () {
- this.trigger('load_more_messages');
- },
-});
-
-ThreadWidget.ORDER = ORDER;
-
-return ThreadWidget;
-
-});
diff --git a/addons/mail/static/src/js/thread_windows/abstract_thread_window.js b/addons/mail/static/src/js/thread_windows/abstract_thread_window.js
deleted file mode 100644
index 7e39f9c7e2e..00000000000
--- a/addons/mail/static/src/js/thread_windows/abstract_thread_window.js
+++ /dev/null
@@ -1,519 +0,0 @@
-odoo.define('mail.AbstractThreadWindow', function (require) {
-"use strict";
-
-var ThreadWidget = require('mail.widget.Thread');
-
-var config = require('web.config');
-var core = require('web.core');
-var Widget = require('web.Widget');
-
-var QWeb = core.qweb;
-var _t = core._t;
-
-/**
- * This is an abstract widget for rendering thread windows.
- *
- * It contains logic that are shared between mail.ThreadWindow and
- * im_livechat.WebsiteLivechatWindow.
- *
- * The reason for having two different implementation of thread windows is
- * that mail.ThreadWindow makes use of mail.Manager, which is used in the
- * backend, while im_livechat.WebsiteLivechatWindow must work without this
- * mail service.
- */
-var AbstractThreadWindow = Widget.extend({
- template: 'mail.AbstractThreadWindow',
- custom_events: {
- escape_pressed: '_onEscapePressed',
- document_viewer_closed: '_onDocumentViewerClose',
- },
- events: {
- 'click .o_thread_window_close': '_onClickClose',
- 'click .o_thread_window_title': '_onClickFold',
- 'click .o_composer_text_field': '_onComposerClick',
- 'click .o_mail_thread': '_onThreadWindowClicked',
- 'keydown .o_composer_text_field': '_onKeydown',
- 'keypress .o_composer_text_field': '_onKeypress',
- },
- FOLD_ANIMATION_DURATION: 200, // duration in ms for (un)fold transition
- HEIGHT_OPEN: '400px', // height in px of thread window when open
- HEIGHT_FOLDED: '34px', // height, in px, of thread window when folded
- /**
- * Children of this class must make use of `thread`, which is an object that
- * represent the thread that is linked to this thread window.
- *
- * If no thread is provided, this will represent the "blank" thread window.
- *
- * @abstract
- * @param {Widget} parent
- * @param {mail.model.AbstractThread} [thread=null] the thread that this
- * thread window is linked to. If not set, it is the "blank" thread
- * window.
- * @param {Object} [options={}]
- * @param {mail.model.AbstractThread} [options.thread]
- */
- init: function (parent, thread, options) {
- this._super(parent);
-
- this.options = _.defaults(options || {}, {
- autofocus: true,
- displayStars: true,
- displayReplyIcons: false,
- displayNotificationIcons: false,
- placeholder: _t("Say something"),
- });
-
- this._hidden = false;
- this._thread = thread || null;
-
- this._debouncedOnScroll = _.debounce(this._onScroll.bind(this), 100);
-
- if (!this.hasThread()) {
- // internal fold state of thread window without any thread
- this._folded = false;
- }
- },
- start: function () {
- var self = this;
- this.$input = this.$('.o_composer_text_field');
- this.$header = this.$('.o_thread_window_header');
- var options = {
- displayMarkAsRead: false,
- displayStars: this.options.displayStars,
- };
- if (this._thread && this._thread._type === 'document_thread') {
- options.displayDocumentLinks = false;
- }
- this._threadWidget = new ThreadWidget(this, options);
-
- // animate the (un)folding of thread windows
- this.$el.css({transition: 'height ' + this.FOLD_ANIMATION_DURATION + 'ms linear'});
- if (this.isFolded()) {
- this.$el.css('height', this.HEIGHT_FOLDED);
- } else if (this.options.autofocus) {
- this._focusInput();
- }
- if (!config.device.isMobile) {
- var margin_dir = _t.database.parameters.direction === "rtl" ? "margin-left" : "margin-right";
- this.$el.css(margin_dir, $.position.scrollbarWidth());
- }
- var def = this._threadWidget.replace(this.$('.o_thread_window_content')).then(function () {
- self._threadWidget.$el.on('scroll', self, self._debouncedOnScroll);
- });
- return Promise.all([this._super(), def]);
- },
- /**
- * @override
- */
- do_hide: function () {
- this._hidden = true;
- this._super.apply(this, arguments);
- },
- /**
- * @override
- */
- do_show: function () {
- this._hidden = false;
- this._super.apply(this, arguments);
- },
- /**
- * @override
- */
- do_toggle: function (display) {
- this._hidden = _.isBoolean(display) ? !display : !this._hidden;
- this._super.apply(this, arguments);
- },
-
- //--------------------------------------------------------------------------
- // Public
- //--------------------------------------------------------------------------
-
- /**
- * Close this window
- *
- * @abstract
- */
- close: function () {},
- /**
- * Get the ID of the thread window, which is equivalent to the ID of the
- * thread related to this window
- *
- * @returns {integer|string}
- */
- getID: function () {
- return this._getThreadID();
- },
- /**
- * @returns {mail.model.Thread|undefined}
- */
- getThread: function () {
- if (!this.hasThread()) {
- return undefined;
- }
- return this._thread;
- },
- /**
- * Get the status of the thread, such as the im status of a DM chat
- * ('online', 'offline', etc.). If this window has no thread, returns
- * `undefined`.
- *
- * @returns {string|undefined}
- */
- getThreadStatus: function () {
- if (!this.hasThread()) {
- return undefined;
- }
- return this._thread.getStatus();
- },
- /**
- * Get the title of the thread window, which usually contains the name of
- * the thread.
- *
- * @returns {string}
- */
- getTitle: function () {
- if (!this.hasThread()) {
- return _t("Undefined");
- }
- return this._thread.getTitle();
- },
- /**
- * Get the unread counter of the related thread. If there are no thread
- * linked to this window, returns 0.
- *
- * @returns {integer}
- */
- getUnreadCounter: function () {
- if (!this.hasThread()) {
- return 0;
- }
- return this._thread.getUnreadCounter();
- },
- /**
- * States whether this thread window is related to a thread or not.
- *
- * This is useful in order to provide specific behaviour for thread windows
- * without any thread, e.g. let them open a thread from this "blank" thread
- * window.
- *
- * @returns {boolean}
- */
- hasThread: function () {
- return !! this._thread;
- },
- /**
- * Tells whether the bottom of the thread in the thread window is visible
- * or not.
- *
- * @returns {boolean}
- */
- isAtBottom: function () {
- return this._threadWidget.isAtBottom();
- },
- /**
- * State whether the related thread is folded or not. If there are no
- * thread related to this window, it means this is the "blank" thread
- * window, therefore we use the internal folded state.
- *
- * @returns {boolean}
- */
- isFolded: function () {
- if (!this.hasThread()) {
- return this._folded;
- }
- return this._thread.isFolded();
- },
- /**
- * States whether the current environment is in mobile or not. This is
- * useful in order to customize the template rendering for mobile view.
- *
- * @returns {boolean}
- */
- isMobile: function () {
- return config.device.isMobile;
- },
- /**
- * States whether the thread window is hidden or not.
- *
- * @returns {boolean}
- */
- isHidden: function () {
- return this._hidden;
- },
- /**
- * States whether the input of the thread window should be displayed or not.
- * By default, any thread window with a thread needs a composer.
- *
- * @returns {boolean}
- */
- needsComposer: function () {
- return this.hasThread();
- },
- /**
- * Render the thread window
- */
- render: function () {
- this.renderHeader();
- if (this.hasThread()) {
- this._threadWidget.render(this._thread, { displayLoadMore: false });
- }
- },
- /**
- * Render the header of this thread window.
- * This is useful when some information on the header have be updated such
- * as the status or the title of the thread that have changed.
- *
- * @private
- */
- renderHeader: function () {
- var options = this._getHeaderRenderingOptions();
- this.$header.html(
- QWeb.render('mail.AbstractThreadWindow.HeaderContent', options));
- },
- /**
- * Scroll to the bottom of the thread in the thread window
- */
- scrollToBottom: function () {
- this._threadWidget.scrollToBottom();
- },
- /**
- * Toggle the fold state of this thread window. Also update the fold state
- * of the thread model. If the boolean parameter `folded` is provided, it
- * folds/unfolds the window when it is set/unset.
- *
- * @param {boolean} [folded] if not a boolean, toggle the fold state.
- * Otherwise, fold/unfold the window if set/unset.
- */
- toggleFold: function (folded) {
- if (!_.isBoolean(folded)) {
- folded = !this.isFolded();
- }
- this._updateThreadFoldState(folded);
- },
- /**
- * Update the visual state of the window so that it matched the internal
- * fold state. This is useful in case the related thread has its fold state
- * that has been changed.
- */
- updateVisualFoldState: function () {
- if (!this.isFolded()) {
- this._threadWidget.scrollToBottom();
- if (this.options.autofocus) {
- this._focusInput();
- }
- }
- var height = this.isFolded() ? this.HEIGHT_FOLDED : this.HEIGHT_OPEN;
- this.$el.css({height: height});
- },
-
- //--------------------------------------------------------------------------
- // Private
- //--------------------------------------------------------------------------
-
- /**
- * Set the focus on the composer of the thread window. This operation is
- * ignored in mobile context.
- *
- * @private
- * Set the focus on the input of the window
- */
- _focusInput: function () {
- if (
- config.device.touch &&
- config.device.size_class <= config.device.SIZES.SM
- ) {
- return;
- }
- this.$input.focus();
- },
- /**
- * Returns the options used by the rendering of the window's header
- *
- * @private
- * @returns {Object}
- */
- _getHeaderRenderingOptions: function () {
- return {
- status: this.getThreadStatus(),
- thread: this.getThread(),
- title: this.getTitle(),
- unreadCounter: this.getUnreadCounter(),
- widget: this,
- };
- },
- /**
- * Get the ID of the related thread.
- * If this window is not related to a thread, it means this is the "blank"
- * thread window, therefore it returns "_blank" as its ID.
- *
- * @private
- * @returns {integer|string} the threadID, or '_blank' for the window that
- * is not related to any thread.
- */
- _getThreadID: function () {
- if (!this.hasThread()) {
- return '_blank';
- }
- return this._thread.getID();
- },
- /**
- * Tells whether there is focus on this thread. Note that a thread that has
- * the focus means the input has focus.
- *
- * @private
- * @returns {boolean}
- */
- _hasFocus: function () {
- return this.$input.is(':focus');
- },
- /**
- * Post a message on this thread window, and auto-scroll to the bottom of
- * the thread.
- *
- * @private
- * @param {Object} messageData
- */
- _postMessage: function (messageData) {
- var self = this;
- if (!this.hasThread()) {
- return;
- }
- this._thread.postMessage(messageData)
- .then(function () {
- self._threadWidget.scrollToBottom();
- });
- },
- /**
- * Update the fold state of the thread.
- *
- * This function is called when toggling the fold state of this window.
- * If there is no thread linked to this window, it means this is the
- * "blank" thread window, therefore we use the internal state 'folded'
- *
- * @private
- * @param {boolean} folded
- */
- _updateThreadFoldState: function (folded) {
- if (this.hasThread()) {
- this._thread.fold(folded);
- } else {
- this._folded = folded;
- this.updateVisualFoldState();
- }
- },
-
- //--------------------------------------------------------------------------
- // Handlers
- //--------------------------------------------------------------------------
-
- /**
- * Close the thread window.
- * Mark the thread as read if the thread window was open.
- *
- * @private
- * @param {MouseEvent} ev
- */
- _onClickClose: function (ev) {
- ev.stopPropagation();
- ev.preventDefault();
- if (
- this.hasThread() &&
- this._thread.getUnreadCounter() > 0 &&
- !this.isFolded()
- ) {
- this._thread.markAsRead();
- }
- this.close();
- },
- /**
- * Fold/unfold the thread window.
- * Also mark the thread as read.
- *
- * @private
- */
- _onClickFold: function () {
- if (!config.device.isMobile) {
- this.toggleFold();
- }
- },
- /**
- * Called when the composer is clicked -> forces focus on input even if
- * jquery's blockUI is enabled.
- *
- * @private
- * @param {Event} ev
- */
- _onComposerClick: function (ev) {
- if ($(ev.target).closest('a, button').length) {
- return;
- }
- this._focusInput();
- },
- /**
- * @private
- */
- _onDocumentViewerClose: function () {
- this._focusInput();
- },
- /**
- * @private
- */
- _onEscapePressed: function () {
- if (!this.isFolded()) {
- this.close();
- }
- },
- /**
- * Called when typing something on the composer of this thread window.
- *
- * @private
- * @param {KeyboardEvent} ev
- */
- _onKeydown: function (ev) {
- ev.stopPropagation(); // to prevent jquery's blockUI to cancel event
- // ENTER key (avoid requiring jquery ui for external livechat)
- if (ev.which === 13) {
- var content = _.str.trim(this.$input.val());
- var messageData = {
- content: content,
- attachment_ids: [],
- partner_ids: [],
- };
- this.$input.val('');
- if (content) {
- this._postMessage(messageData);
- }
- }
- },
- /**
- * @private
- * @param {KeyboardEvent} ev
- */
- _onKeypress: function (ev) {
- ev.stopPropagation(); // to prevent jquery's blockUI to cancel event
- },
- /**
- * @private
- */
- _onScroll: function () {
- if (this.hasThread() && this.isAtBottom()) {
- this._thread.markAsRead();
- }
- },
- /**
- * When a thread window is clicked on, we want to give the focus to the main
- * input. An exception is made when the user is selecting something.
- *
- * @private
- */
- _onThreadWindowClicked: function () {
- var selectObj = window.getSelection();
- if (selectObj.anchorOffset === selectObj.focusOffset) {
- this.$input.focus();
- }
- },
-});
-
-return AbstractThreadWindow;
-
-});
diff --git a/addons/mail/static/src/js/thread_windows/thread_window.js b/addons/mail/static/src/js/thread_windows/thread_window.js
deleted file mode 100644
index 7280f704fa3..00000000000
--- a/addons/mail/static/src/js/thread_windows/thread_window.js
+++ /dev/null
@@ -1,403 +0,0 @@
-odoo.define('mail.ThreadWindow', function (require) {
-"use strict";
-
-var AbstractThreadWindow = require('mail.AbstractThreadWindow');
-var BasicComposer = require('mail.composer.Basic');
-var ExtendedComposer = require('mail.composer.Extended');
-
-var config = require('web.config');
-var core = require('web.core');
-
-var QWeb = core.qweb;
-var _t = core._t;
-
-/**
- * This is the main widget for rendering small windows for mail.model.Thread.
- * Almost all instances of this class are linked to a thread. The sole
- * exception is the "blank" thread window. This window let us open another
- * thread window, using this "blank" thread window.
- */
-var ThreadWindow = AbstractThreadWindow.extend({
- template: 'mail.ThreadWindow',
- custom_events: AbstractThreadWindow.prototype.custom_events,
- events: _.extend({}, AbstractThreadWindow.prototype.events, {
- 'click .o_mail_thread': '_onThreadWindowFocus',
- 'click .o_thread_composer': '_onThreadWindowFocus',
- 'click .o_thread_window_expand': '_onClickExpand',
- }),
- /**
- * Version of thread window that supports {mail.model.Thread}
- *
- * @override
- * @param {mail.Manager} parent
- * @param {mail.model.Thread} [thread = null] if not set, this is a "blank"
- * thread window. It lets us open a DM chat by providing the name of a
- * user.
- * @param {Object} [options={}]
- * @param {boolean} [options.passively=false]
- */
- init: function (parent, thread, options) {
- this._super.apply(this, arguments);
-
- // don't automatically mark unread messages as seen when at the bottom
- // of the thread
- this._passive = this.options.passively || false;
-
- if (!this.hasThread()) {
- // remembered partner ID of "blank" thread window in order to be
- // replaced with newly opened DM chat window
- this.directPartnerID = null;
- }
- },
-
- /**
- * @override
- */
- start: function () {
- var self = this;
-
- var superDef = this._super().then(this._listenThreadWidget.bind(this));
-
- this.call('mail_service', 'getMailBus')
- .on('update_typing_partners', this, this._onUpdateTypingPartners)
- .on('update_channel', this, this._onUpdateChannel);
-
- var composerDef;
- if (!this.hasThread()) {
- this._startWithoutThread();
- } else if (this.needsComposer()) {
- var composer;
- var composerParams = {
- mentionPartnersRestricted: this._thread.getType() !== 'document_thread',
- thread: this._thread,
- };
- if (
- config.device.isMobile &&
- this._thread.isMassMailing()
- ) {
- composer = new ExtendedComposer(this, composerParams);
- } else {
- composer = new BasicComposer(this, _.extend({ isMini: true }, composerParams));
- }
- composer.on('post_message', this, this._postMessage);
- composer.once('input_focused', this, function () {
- var commands = this._thread.getCommands();
- var partners = this._thread.getMentionPartnerSuggestions();
- composer.mentionSetCommands(commands);
- composer.mentionSetPrefetchedPartners(partners);
- });
- composerDef = composer.replace(this.$('.o_thread_composer'));
- composerDef.then(function () {
- self.$input = self.$('.o_composer_text_field');
- });
- }
- return Promise.all([superDef, composerDef]);
- },
-
- //--------------------------------------------------------------------------
- // Public
- //--------------------------------------------------------------------------
-
- /**
- * @override
- */
- close: function () {
- if (this.hasThread()) {
- this._thread.close();
- } else {
- this.trigger_up('close_blank_thread_window');
- }
- },
- /**
- * Set the focus on the input of this thread window
- */
- focusInput: function () {
- this._focusInput();
- },
- /**
- * Overrides so that if this thread window is not linked to any thread
- * (= "blank" thread window), displays "New message" as its title.
- *
- * @override
- * @returns {string}
- */
- getTitle: function () {
- if (!this.hasThread()) {
- return _t("New message");
- }
- return this._super.apply(this, arguments);
- },
- /**
- * Tell whether the thread window is passive or not. A passive thread window
- * does not auto-mark the thread as read when scrolling at the bottom.
- *
- * @returns {boolean}
- */
- isPassive: function () {
- return this._passive;
- },
- /**
- * Turn the thread window in active mode, so that when the bottom of the
- * thread is visible, it is automatically marked as read.
- */
- removePassive: function () {
- this._passive = false;
- },
- renderOutOfOffice: function () {
- var $outOfOffice = this.$('.o_out_of_office');
- const thread = this.getThread();
- if (!thread.hasOutOfOffice()) {
- if ($outOfOffice.length) {
- $outOfOffice.remove();
- }
- return;
- }
- const $newOutOfOffice = $(QWeb.render('mail.thread.OutOfOffice', { thread }));
- if ($outOfOffice.length) {
- $outOfOffice.replaceWith($newOutOfOffice);
- } else {
- $newOutOfOffice.insertAfter(this.$('.o_thread_window_header'));
- }
- },
- /**
- * Update this thread window
- *
- * @param {Object} options
- * @param {boolean} [options.keepBottom=false] if set, this thread window
- * should scroll to the bottom if it was at the bottom before update
- * @param {boolean} [options.passively=false] if set, this thread window
- * becomes passive, so that it is marked as read only when the focus is
- * on it. Ignore this option if the focus is already on the thread window.
- */
- update: function (options) {
- var self = this;
- if (options.passively && !this._hasFocus()) {
- this._setPassive();
- }
- var bottomVisible = !this.isFolded() &&
- !this.isHidden() &&
- this.isAtBottom();
- if (bottomVisible && !this.isPassive()) {
- this._thread.markAsRead();
- }
- this._thread.fetchMessages().then(function () {
- self.render();
- if (bottomVisible && options.keepBottom) {
- self.scrollToBottom();
- }
- });
- },
-
- //--------------------------------------------------------------------------
- // Private
- //--------------------------------------------------------------------------
-
- /**
- * @override
- * @private
- */
- _getHeaderRenderingOptions: function () {
- var options = this._super.apply(this, arguments);
- if (this._thread.getType() === 'document_thread') {
- options.expandTitle = _t("Open document");
- } else {
- options.expandTitle = _t("Open in Discuss");
- }
- return options;
- },
- /**
- * Listen on thread widget events
- *
- * @private
- */
- _listenThreadWidget: function () {
- this._threadWidget
- .on('redirect', this, this._onRedirect)
- .on('redirect_to_channel', this, this._onRedirectToChannel)
- .on('toggle_star_status', this, this._onToggleStarStatus);
- },
- /**
- * Open this thread window.
- * This private method exists only for the purpose of providing a callback
- * function on redirect to a partner.
- *
- * @private
- */
- _open: function (channelID) {
- this.call('mail_service', 'openThreadWindow', channelID || this.getID());
- },
- /**
- * Set the thread window in passive mode, so that new received message will
- * keep the thread window as unread until there is focus on the thread
- * window.
- *
- * @private
- */
- _setPassive: function () {
- this._passive = true;
- },
- /**
- * @private
- */
- _startWithoutThread: function () {
- var self = this;
- this.$el.addClass('o_thread_less');
- this.$('.o_thread_search_input input')
- .autocomplete({
- autoFocus: true,
- source: function (request, response) {
- self.call('mail_service', 'searchPartner', request.term, 10)
- .then(response);
- },
- select: function (event, ui) {
- // remember partner ID so that we can replace this window
- // with new DM chat window
- var partnerID = ui.item.id;
- self.directPartnerID = partnerID;
- self.call('mail_service', 'openDMChatWindowFromBlankThreadWindow', partnerID);
- },
- })
- .focus();
- },
-
- //--------------------------------------------------------------------------
- // Handlers
- //--------------------------------------------------------------------------
-
- /**
- * Expand the thread, which will open the thread in discuss.
- *
- * If there is no thread linked to this window, it means this is the "blank"
- * thread window, therefore it opens discuss with the default thread (which
- * should be the mailbox 'inbox')
- *
- * @private
- * @param {MouseEvent} ev
- */
- _onClickExpand: _.debounce(function (ev) {
- var self = this;
- ev.preventDefault();
- if (this.hasThread() && this._thread.getType() === 'document_thread') {
- this.do_action({
- type: 'ir.actions.act_window',
- res_model: this._thread.getDocumentModel(),
- views: [[false, 'form']],
- res_id: this._thread.getDocumentID(),
- });
- } else {
- this.do_action('mail.action_discuss', {
- clear_breadcrumbs: false,
- active_id: this.hasThread() ? this._getThreadID() : undefined,
- on_reverse_breadcrumb: function () {
- var mailBus = self.call('mail_service', 'getMailBus');
- if (mailBus) {
- mailBus.trigger('discuss_open', false);
- }
- },
- });
- }
- }, 1000, true),
- /**
- * @override
- * @private
- * @param {KeyboardEvent} ev
- *
- * Override _onKeydown to only prevent jquery's blockUI to cancel event,
- * but without sending the message on ENTER keydown as this is handled by
- * the BasicComposer
- */
- _onKeydown: function (ev) {
- ev.stopPropagation();
- },
- /**
- * callback function receives the threadID linked to resModel and resID
- * Note: this callback function is only called for 'res.partner'
- *
- * @private
- * @param {string} resModel
- * @param {integer} resID
- */
- _onRedirect: function (resModel, resID) {
- var callback = this._open.bind(this);
- this.call('mail_service', 'redirect', resModel, resID, callback);
- },
- /**
- * @private
- * @param {integer} channelID
- */
- _onRedirectToChannel: function (channelID) {
- var self = this;
- var thread = this.call('mail_service', 'getThread', channelID);
- if (!thread) {
- this.call('mail_service', 'joinChannel', channelID)
- .then(function (channelID) {
- var channel = self.call('mail_service', 'getThread', channelID);
- channel.detach();
- });
- } else {
- this.toggleFold(false);
- }
- },
- /**
- * Override it so that passive thread windows do not mark thread as read
- * on scroll
- *
- * @override
- * @private
- */
- _onScroll: function () {
- if (this.isPassive()) {
- return;
- }
- return this._super.apply(this, arguments);
- },
- /**
- * Called when focusing the thread window (click on thread or composer)
- *
- * @private
- */
- _onThreadWindowFocus: function () {
- if (this.isPassive()) {
- this.removePassive();
- this._thread.markAsRead();
- }
- },
- /**
- * @private
- * @param {integer} messageID
- */
- _onToggleStarStatus: function (messageID) {
- var message = this.call('mail_service', 'getMessage', messageID);
- message.toggleStarStatus();
- },
- /**
- * @private
- * @param {integer|string} threadID
- */
- _onUpdateTypingPartners: function (threadID) {
- if (!this.hasThread()) {
- return;
- }
- if (this._thread.getID() !== threadID) {
- return;
- }
- this.renderHeader();
- },
- /**
- * @private
- * @param {integer} channelID
- */
- _onUpdateChannel: function (channelID) {
- if (!this.hasThread()) {
- return;
- }
- if (this._thread.getID() !== channelID) {
- return;
- }
- this.render();
- },
-});
-
-return ThreadWindow;
-
-});
diff --git a/addons/mail/static/src/js/tools/debug_manager.js b/addons/mail/static/src/js/tools/debug_manager.js
index 3a4c1d3c372..798765eb364 100644
--- a/addons/mail/static/src/js/tools/debug_manager.js
+++ b/addons/mail/static/src/js/tools/debug_manager.js
@@ -11,7 +11,6 @@ var _t = core._t;
*/
DebugManager.include({
getMailMessages: function () {
- var self = this;
var selectedIDs = this._controller.getSelectedIds();
if (!selectedIDs.length) {
console.warn(_t("No message available"));
diff --git a/addons/mail/static/src/js/tours/mail.js b/addons/mail/static/src/js/tours/mail.js
index e63647540a0..b37ed70751f 100644
--- a/addons/mail/static/src/js/tours/mail.js
+++ b/addons/mail/static/src/js/tours/mail.js
@@ -7,26 +7,26 @@ var tour = require('web_tour.tour');
var _t = core._t;
tour.register('mail_tour', {
- url: "/web#action=mail.discuss",
+ url: "/web#action=mail.widgets.discuss",
}, [{
- trigger: '.o_mail_discuss .o_mail_discuss_sidebar .o_add[data-type="multi_user_channel"]',
+ trigger: '.o_DiscussSidebar_groupChannel .o_DiscussSidebar_groupHeaderItemAdd',
content: _t("<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_mail_discuss .o_mail_discuss_sidebar .o_mail_add_thread[data-type="multi_user_channel"]',
+ 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.find("input"));
+ actions.text("SomeChannel_" + t, this.$anchor);
},
}, {
- trigger: ".ui-autocomplete .ui-menu-item > a:contains(Private)",
+ trigger: ".ui-autocomplete .ui-menu-item > a:contains(Create):has(.fa-lock)",
content: _t("<p> Create a private channel.</p>"),
position: 'bottom',
}, {
- trigger: '.o_mail_discuss .o_composer_text_field',
+ 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,
@@ -35,19 +35,19 @@ tour.register('mail_tour', {
actions.text("SomeText_" + t, this.$anchor);
},
}, {
- trigger: '.o_mail_discuss .o_thread_composer .o_composer_button_send',
+ trigger: '.o_Discuss_thread .o_Composer_buttonSend',
content: _t("Post your message on the thread"),
position: "top",
}, {
- trigger: '.o_mail_discuss .o_mail_thread .o_thread_message_star',
+ trigger: '.o_Discuss_thread .o_Message_commandStar',
content: _t("Messages can be <b>starred</b> to remind you to check back later."),
position: "bottom",
}, {
- trigger: '.o_mail_discuss .o_mail_discuss_item[data-thread-id="mailbox_starred"]',
+ trigger: '.o_DiscussSidebarItem.o-starred-box',
content: _t("Once a message has been starred, you can come back and review it at any time here."),
position: "bottom",
}, {
- trigger: '.o_mail_discuss .o_mail_discuss_sidebar .o_add[data-type="dm_chat"]',
+ trigger: '.o_DiscussSidebar_groupChat .o_DiscussSidebar_groupHeaderItemAdd',
content: _t("<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
index d4b7d8e7875..d54f5932619 100644
--- a/addons/mail/static/src/js/utils.js
+++ b/addons/mail/static/src/js/utils.js
@@ -5,6 +5,15 @@ var core = require('web.core');
var _t = core._t;
+/**
+ * WARNING: this is not enough to unescape potential XSS contained in htmlString, transformFunction
+ * should handle it or it should be handled after/before calling parseAndTransform. So if the result
+ * of this function is used in a t-raw, be very careful.
+ *
+ * @param {string} htmlString
+ * @param {function} transformFunction
+ * @returns {string}
+ */
function parseAndTransform(htmlString, transformFunction) {
var openToken = "OPEN" + Date.now();
var string = htmlString.replace(/&lt;/g, openToken);
@@ -17,6 +26,7 @@ function parseAndTransform(htmlString, transformFunction) {
return _parseAndTransform(children, transformFunction)
.replace(new RegExp(openToken, "g"), "&lt;");
}
+
/**
* @param {Node[]} nodes
* @param {function} transformFunction with:
@@ -127,6 +137,23 @@ function parseEmail(text) {
}
}
+/**
+ * Returns an escaped conversion of a content.
+ *
+ * @param {string} content
+ * @returns {string}
+ */
+function escapeAndCompactTextContent(content) {
+ //Removing unwanted extra spaces from message
+ let value = _.escape(content).trim();
+ value = value.replace(/(\r|\n){2,}/g, '<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) {
@@ -142,14 +169,6 @@ function timeFromNow(date) {
return date.fromNow();
}
-function o_clearTimeout(id) {
- return clearTimeout(id);
-}
-
-function o_setTimeout(func, delay) {
- return setTimeout(func, delay);
-}
-
return {
addLink: addLink,
getTextToHTML: getTextToHTML,
@@ -160,8 +179,7 @@ return {
parseEmail: parseEmail,
stripHTML: stripHTML,
timeFromNow: timeFromNow,
- clearTimeout: o_clearTimeout,
- setTimeout: o_setTimeout,
+ escapeAndCompactTextContent,
};
});
diff --git a/addons/mail/static/src/model/model_core.js b/addons/mail/static/src/model/model_core.js
new file mode 100644
index 00000000000..0198e47ee7d
--- /dev/null
+++ b/addons/mail/static/src/model/model_core.js
@@ -0,0 +1,125 @@
+odoo.define('mail/static/src/model/model_core.js', function (require) {
+'use strict';
+
+/**
+ * Module that contains registry for adding new models or patching models.
+ * Useful for model manager in order to generate model classes.
+ *
+ * This code is not in model manager because other JS modules should populate
+ * a registry, and it's difficult to ensure availability of the model manager
+ * when these JS modules are deployed.
+ */
+
+const registry = {};
+
+//------------------------------------------------------------------------------
+// Private
+//------------------------------------------------------------------------------
+
+/**
+ * @private
+ * @param {string} modelName
+ * @returns {Object}
+ */
+function _getEntryFromModelName(modelName) {
+ if (!registry[modelName]) {
+ registry[modelName] = {
+ dependencies: [],
+ factory: undefined,
+ name: modelName,
+ patches: [],
+ };
+ }
+ return registry[modelName];
+}
+
+/**
+ * @private
+ * @param {string} modelName
+ * @param {string} patchName
+ * @param {Object} patch
+ * @param {Object} [param3={}]
+ * @param {string} [param3.type='instance'] 'instance', 'class' or 'field'
+ */
+function _registerPatchModel(modelName, patchName, patch, { type = 'instance' } = {}) {
+ const entry = _getEntryFromModelName(modelName);
+ Object.assign(entry, {
+ patches: (entry.patches || []).concat([{
+ name: patchName,
+ patch,
+ type,
+ }]),
+ });
+}
+
+//------------------------------------------------------------------------------
+// Public
+//------------------------------------------------------------------------------
+
+/**
+ * Register a patch for static methods in model.
+ *
+ * @param {string} modelName
+ * @param {string} patchName
+ * @param {Object} patch
+ */
+function registerClassPatchModel(modelName, patchName, patch) {
+ _registerPatchModel(modelName, patchName, patch, { type: 'class' });
+}
+
+/**
+ * Register a patch for fields in model.
+ *
+ * @param {string} modelName
+ * @param {string} patchName
+ * @param {Object} patch
+ */
+function registerFieldPatchModel(modelName, patchName, patch) {
+ _registerPatchModel(modelName, patchName, patch, { type: 'field' });
+}
+
+/**
+ * Register a patch for instance methods in model.
+ *
+ * @param {string} modelName
+ * @param {string} patchName
+ * @param {Object} patch
+ */
+function registerInstancePatchModel(modelName, patchName, patch) {
+ _registerPatchModel(modelName, patchName, patch, { type: 'instance' });
+}
+
+/**
+ * @param {string} name
+ * @param {function} factory
+ * @param {string[]} [dependencies=[]]
+ */
+function registerNewModel(name, factory, dependencies = []) {
+ const entry = _getEntryFromModelName(name);
+ let entryDependencies = [...dependencies];
+ if (name !== 'mail.model') {
+ entryDependencies = [...new Set(entryDependencies.concat(['mail.model']))];
+ }
+ if (entry.factory) {
+ throw new Error(`Model "${name}" has already been registered!`);
+ }
+ Object.assign(entry, {
+ dependencies: entryDependencies,
+ factory,
+ name,
+ });
+}
+
+//------------------------------------------------------------------------------
+// Export
+//------------------------------------------------------------------------------
+
+return {
+ registerClassPatchModel,
+ registerFieldPatchModel,
+ registerInstancePatchModel,
+ registerNewModel,
+ registry,
+};
+
+});
diff --git a/addons/mail/static/src/model/model_errors.js b/addons/mail/static/src/model/model_errors.js
new file mode 100644
index 00000000000..17ecc96013c
--- /dev/null
+++ b/addons/mail/static/src/model/model_errors.js
@@ -0,0 +1,22 @@
+odoo.define('mail/static/src/model/model_errors.js', function (require) {
+'use strict';
+
+class RecordDeletedError extends Error {
+
+ /**
+ * @override
+ * @param {string} recordLocalId local id of record that has been deleted
+ * @param {...any} args
+ */
+ constructor(recordLocalId, ...args) {
+ super(...args);
+ this.recordLocalId = recordLocalId;
+ this.name = 'RecordDeletedError';
+ }
+}
+
+return {
+ RecordDeletedError,
+};
+
+});
diff --git a/addons/mail/static/src/model/model_field.js b/addons/mail/static/src/model/model_field.js
new file mode 100644
index 00000000000..589eee5c782
--- /dev/null
+++ b/addons/mail/static/src/model/model_field.js
@@ -0,0 +1,988 @@
+odoo.define('mail/static/src/model/model_field.js', function (require) {
+'use strict';
+
+/**
+ * Class whose instances represent field on a model.
+ * These field definitions are generated from declared fields in static prop
+ * `fields` on the model.
+ */
+class ModelField {
+
+ //--------------------------------------------------------------------------
+ // Public
+ //--------------------------------------------------------------------------
+
+ constructor({
+ autocreate = false,
+ compute,
+ default: def,
+ dependencies = [],
+ dependents = [],
+ fieldName,
+ fieldType,
+ hashes: extraHashes = [],
+ inverse,
+ isCausal = false,
+ modelManager,
+ related,
+ relationType,
+ to,
+ } = {}) {
+ const id = _.uniqueId('field_');
+ /**
+ * This prop only makes sense for fields of type "relation". If set,
+ * it automatically creates a new record for this field on creation of
+ * record, and auto-links with this record.
+ */
+ this.autocreate = autocreate;
+ /**
+ * If set, this field acts as a computed field, and this prop
+ * contains the name of the instance method that computes the value
+ * for this field. This compute method is called on creation of record
+ * and whenever some of its dependencies change. @see dependencies
+ */
+ this.compute = compute;
+ /**
+ * Default value for this field. Used on creation of this field, to
+ * set a value by default.
+ */
+ this.default = def;
+ /**
+ * List of field on current record that this field depends on for its
+ * `compute` method. Useful to determine whether this field should be
+ * registered for recomputation when some record fields have changed.
+ * This list must be declared in model definition, or compute method
+ * is only computed once.
+ */
+ this.dependencies = dependencies;
+ /**
+ * List of fields that are dependent of this field. They should never
+ * be declared, and are automatically generated while processing
+ * declared fields. This is populated by compute `dependencies` and
+ * `related`.
+ */
+ this.dependents = dependents;
+ /**
+ * Name of the field in the definition of fields on model.
+ */
+ this.fieldName = fieldName;
+ /**
+ * Type of this field. 2 types of fields are currently supported:
+ *
+ * 1. 'attribute': fields that store primitive values like integers,
+ * booleans, strings, objects, array, etc.
+ *
+ * 2. 'relation': fields that relate to some other records.
+ */
+ this.fieldType = fieldType;
+ /**
+ * List of hashes registered on this field definition. Technical
+ * prop that is specifically used in processing of dependent
+ * fields, useful to clearly identify which fields of a relation are
+ * dependents and must be registered for computed. Indeed, not all
+ * related records may have a field that depends on changed field,
+ * especially when dependency is defined on sub-model on a relation in
+ * a super-model.
+ *
+ * To illustrate the purpose of this hash, suppose following definition
+ * of models and fields:
+ *
+ * - 3 models (A, B, C) and 3 fields (x, y, z)
+ * - A.fields: { x: one2one(C, inverse: x') }
+ * - B extends A
+ * - B.fields: { z: related(x.y) }
+ * - C.fields: { y: attribute }
+ *
+ * Visually:
+ * x'
+ * <-----------
+ * A -----------> C { y }
+ * ^ x
+ * |
+ * | (extends)
+ * |
+ * B { z = x.y }
+ *
+ * If z has a dependency on x.y, it means y has a dependent on x'.z.
+ * Note that field z exists on B but not on all A. To determine which
+ * kinds of records in relation x' are dependent on y, y is aware of an
+ * hash on this dependent, and any dependents who has this hash in list
+ * of hashes are actual dependents.
+ */
+ this.hashes = extraHashes.concat([id]);
+ /**
+ * Identification for this field definition. Useful to map a dependent
+ * from a dependency. Indeed, declared field definitions use
+ * 'dependencies' but technical process need inverse as 'dependents'.
+ * Dependencies just need name of fields, but dependents cannot just
+ * rely on inverse field names because these dependents are a subset.
+ */
+ this.id = id;
+ /**
+ * This prop only makes sense in a relational field. This contains
+ * the name of the field name in the inverse relation. This may not
+ * be defined in declared field definitions, but processed relational
+ * field definitions always have inverses.
+ */
+ this.inverse = inverse;
+ /**
+ * This prop only makes sense in a relational field. If set, when this
+ * relation is removed, the related record is automatically deleted.
+ */
+ this.isCausal = isCausal;
+ /**
+ * Reference to the model manager.
+ */
+ this.modelManager = modelManager;
+ /**
+ * If set, this field acts as a related field, and this prop contains
+ * a string that references the related field. It should have the
+ * following format: '<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;
+ }
+
+ /**
+ * Define an attribute field.
+ *
+ * @param {Object} [options]
+ * @returns {Object}
+ */
+ static attr(options) {
+ return Object.assign({ fieldType: 'attribute' }, options);
+ }
+
+ /**
+ * Define a many2many field.
+ *
+ * @param {string} modelName
+ * @param {Object} [options]
+ * @returns {Object}
+ */
+ static many2many(modelName, options) {
+ return ModelField._relation(modelName, Object.assign({}, options, { relationType: 'many2many' }));
+ }
+
+ /**
+ * Define a many2one field.
+ *
+ * @param {string} modelName
+ * @param {Object} [options]
+ * @returns {Object}
+ */
+ static many2one(modelName, options) {
+ return ModelField._relation(modelName, Object.assign({}, options, { relationType: 'many2one' }));
+ }
+
+ /**
+ * Define a one2many field.
+ *
+ * @param {string} modelName
+ * @param {Object} [options]
+ * @returns {Object}
+ */
+ static one2many(modelName, options) {
+ return ModelField._relation(modelName, Object.assign({}, options, { relationType: 'one2many' }));
+ }
+
+ /**
+ * Define a one2one field.
+ *
+ * @param {string} modelName
+ * @param {Object} [options]
+ * @returns {Object}
+ */
+ static one2one(modelName, options) {
+ return ModelField._relation(modelName, Object.assign({}, options, { relationType: 'one2one' }));
+ }
+
+ /**
+ * Combine current field definition with provided field definition and
+ * return the combined field definition. Useful to track list of hashes of
+ * a given field, which is necessary for the working of dependent fields
+ * (computed and related fields).
+ *
+ * @param {ModelField} field
+ * @returns {ModelField}
+ */
+ combine(field) {
+ return new ModelField(Object.assign({}, this, {
+ dependencies: this.dependencies.concat(field.dependencies),
+ hashes: this.hashes.concat(field.hashes),
+ }));
+ }
+
+ /**
+ * Perform computation of this field, which is either a computed or related
+ * field.
+ *
+ * @param {mail.model} record
+ */
+ doCompute(record) {
+ if (this.compute) {
+ this.set(record, record[this.compute]());
+ return;
+ }
+ if (this.related) {
+ this.set(record, this._computeRelated(record));
+ return;
+ }
+ throw new Error("No compute method defined on this field definition");
+ }
+
+ /**
+ * Get the env with messaging.
+ *
+ * @returns {mail/static/src/env/env.js}
+ */
+ get env() {
+ return this.modelManager.env;
+ }
+
+ /**
+ * Get the value associated to this field. Relations must convert record
+ * local ids to records.
+ *
+ * @param {mail.model} record
+ * @returns {any}
+ */
+ get(record) {
+ if (this.fieldType === 'attribute') {
+ return this.read(record);
+ }
+ if (this.fieldType === 'relation') {
+ const OtherModel = this.env.models[this.to];
+ if (['one2one', 'many2one'].includes(this.relationType)) {
+ return OtherModel.get(this.read(record));
+ }
+ return this.read(record)
+ .map(localId => OtherModel.get(localId))
+ /**
+ * FIXME: Stored relation may still contain
+ * outdated records.
+ */
+ .filter(record => !!record);
+ }
+ throw new Error(`cannot get field with unsupported type ${this.fieldType}.`);
+ }
+
+ /**
+ * Get the raw value associated to this field. For relations, this means
+ * the local id or list of local ids of records in this relational field.
+ *
+ * @param {mail.model} record
+ * @returns {any}
+ */
+ read(record) {
+ return record.__state[this.fieldName];
+ }
+
+ /**
+ * Set a value on this field. The format of the value comes from business
+ * code.
+ *
+ * @param {mail.model} record
+ * @param {any} newVal
+ */
+ set(record, newVal) {
+ if (this.fieldType === 'attribute') {
+ this.write(record, newVal);
+ }
+ if (this.fieldType === 'relation') {
+ for (const val of newVal) {
+ switch (val[0]) {
+ case 'create':
+ this._setRelationCreate(record, val[1]);
+ break;
+ case 'insert':
+ this._setRelationInsert(record, val[1]);
+ break;
+ case 'insert-and-replace':
+ this._setRelationInsertAndReplace(record, val[1]);
+ break;
+ case 'link':
+ this._setRelationLink(record, val[1]);
+ break;
+ case 'replace':
+ // TODO IMP replace should not unlink-all (task-2270780)
+ this._setRelationUnlink(record, null);
+ this._setRelationLink(record, val[1]);
+ break;
+ case 'unlink':
+ this._setRelationUnlink(record, val[1]);
+ break;
+ case 'unlink-all':
+ this._setRelationUnlink(record, null);
+ break;
+ }
+ }
+ }
+ }
+
+ /**
+ * Set a value in state associated to this field. Value corresponds exactly
+ * that what is stored on this field, like local id or list of local ids
+ * for a relational field. If the value changes, dependent fields are
+ * automatically registered for (re-)computation.
+ *
+ * @param {mail.model} record
+ * @param {any} newVal
+ * @param {Object} [param2={}]
+ * @param {Object} [param2.registerDependents=true] If set, write
+ * on this field with changed value registers dependent fields for compute.
+ * Of course, we almost always want to register them, so that they reflect
+ * the value with their dependencies. Disabling this feature prevents
+ * useless potentially heavy computation, like when setting default values.
+ */
+ write(record, newVal, { registerDependents = true } = {}) {
+ if (this.read(record) === newVal) {
+ return;
+ }
+ const prevStringified = JSON.stringify(this.read(record));
+ record.__state[this.fieldName] = newVal;
+ const newStringified = JSON.stringify(this.read(record));
+ if (this._containsRecords(newVal)) {
+ throw new Error("Forbidden write operation with records in the __state!!");
+ }
+ if (newStringified === prevStringified) {
+ // value unchanged, don't need to compute dependent fields
+ return;
+ }
+ if (!registerDependents) {
+ return;
+ }
+
+ // flag all dependent fields for compute
+ for (const dependent of this.dependents) {
+ const [hash, currentFieldName, relatedFieldName] = dependent.split(
+ this.modelManager.DEPENDENT_INNER_SEPARATOR
+ );
+ if (relatedFieldName) {
+ const Model = record.constructor;
+ const relationField = Model.fields[currentFieldName];
+ if (['one2many', 'many2many'].includes(relationField.relationType)) {
+ for (const otherRecord of record[currentFieldName]) {
+ const OtherModel = otherRecord.constructor;
+ const field = OtherModel.fields[relatedFieldName];
+ if (field && field.hashes.includes(hash)) {
+ this.modelManager.registerToComputeField(otherRecord, field);
+ }
+ }
+ } else {
+ const otherRecord = record[currentFieldName];
+ if (!otherRecord) {
+ continue;
+ }
+ const OtherModel = otherRecord.constructor;
+ const field = OtherModel.fields[relatedFieldName];
+ if (field && field.hashes.includes(hash)) {
+ this.modelManager.registerToComputeField(otherRecord, field);
+ }
+ }
+ } else {
+ const Model = record.constructor;
+ const field = Model.fields[currentFieldName];
+ if (field && field.hashes.includes(hash)) {
+ this.modelManager.registerToComputeField(record, field);
+ }
+ }
+ }
+ }
+
+ //--------------------------------------------------------------------------
+ // Private
+ //--------------------------------------------------------------------------
+
+ /**
+ * @private
+ * @param {string} modelName
+ * @param {Object} [options]
+ */
+ static _relation(modelName, options) {
+ return Object.assign({
+ fieldType: 'relation',
+ to: modelName,
+ }, options);
+ }
+
+ /**
+ * Compute method when this field is related.
+ *
+ * @private
+ * @param {mail.model} record
+ */
+ _computeRelated(record) {
+ const [relationName, relatedFieldName] = this.related.split('.');
+ const Model = record.constructor;
+ const relationField = Model.fields[relationName];
+ if (['one2many', 'many2many'].includes(relationField.relationType)) {
+ const newVal = [];
+ for (const otherRecord of record[relationName]) {
+ const OtherModel = otherRecord.constructor;
+ const otherField = OtherModel.fields[relatedFieldName];
+ const otherValue = otherField.get(otherRecord);
+ if (otherValue) {
+ if (otherValue instanceof Array) {
+ // avoid nested array if otherField is x2many too
+ // TODO IMP task-2261221
+ for (const v of otherValue) {
+ newVal.push(v);
+ }
+ } else {
+ newVal.push(otherValue);
+ }
+ }
+ }
+ if (this.fieldType === 'relation') {
+ return [['replace', newVal]];
+ }
+ return newVal;
+ }
+ const otherRecord = record[relationName];
+ if (otherRecord) {
+ const OtherModel = otherRecord.constructor;
+ const otherField = OtherModel.fields[relatedFieldName];
+ const newVal = otherField.get(otherRecord);
+ if (this.fieldType === 'relation') {
+ if (newVal) {
+ return [['replace', newVal]];
+ } else {
+ return [['unlink-all']];
+ }
+ }
+ return newVal;
+ }
+ if (this.fieldType === 'relation') {
+ return [];
+ }
+ }
+
+ /**
+ * Determines whether the provided value contains some records. Useful to
+ * prevent writing records directly in state of this field, which should be
+ * treated as buggy design. Indeed, state of field should only contain
+ * either a primitive type or a simple datastructure containing itself
+ * simple datastructures too.
+ *
+ * @private
+ * @param {any} val
+ * @returns {boolean}
+ */
+ _containsRecords(val) {
+ if (!val) {
+ return false;
+ }
+ if (val instanceof this.env.models['mail.model']) {
+ return true;
+ }
+ if (!(val instanceof Array)) {
+ return false;
+ }
+ if (val.length > 0 && val[0] instanceof this.env.models['mail.model']) {
+ return true;
+ }
+ return false;
+ }
+
+ /**
+ * Converts given value to expected format for x2many processing, which is
+ * an array of localId.
+ *
+ * @private
+ * @param {string|mail.model|<mail.model|string>[]} newValue
+ * @returns {string[]}
+ */
+ _setRelationConvertX2ManyValue(newValue) {
+ if (newValue instanceof Array) {
+ return newValue.map(v => this._setRelationConvertX2OneValue(v));
+ }
+ return [this._setRelationConvertX2OneValue(newValue)];
+ }
+
+ /**
+ * Converts given value to expected format for x2one processing, which is
+ * a localId.
+ *
+ * @private
+ * @param {string|mail.model} newValue
+ * @returns {string}
+ */
+ _setRelationConvertX2OneValue(newValue) {
+ return newValue instanceof this.env.models['mail.model'] ? newValue.localId : newValue;
+ }
+
+ /**
+ * Set on this relational field in 'create' mode. Basically data provided
+ * during set on this relational field contain data to create new records,
+ * which themselves must be linked to record of this field by means of
+ * this field.
+ *
+ * @private
+ * @param {mail.model} record
+ * @param {Object|Object[]} data
+ */
+ _setRelationCreate(record, data) {
+ const OtherModel = this.env.models[this.to];
+ let other;
+ if (['one2one', 'many2one'].includes(this.relationType)) {
+ other = OtherModel.create(data);
+ } else {
+ if (data instanceof Array) {
+ other = data.map(d => OtherModel.create(d));
+ } else {
+ other = OtherModel.create(data);
+ }
+ }
+ this._setRelationLink(record, other);
+ }
+
+ /**
+ * Set on this relational field in 'insert' mode. Basically data provided
+ * during set on this relational field contain data to insert records,
+ * which themselves must be linked to record of this field by means of
+ * this field.
+ *
+ * @private
+ * @param {mail.model} record
+ * @param {Object|Object[]} data
+ */
+ _setRelationInsert(record, data) {
+ const OtherModel = this.env.models[this.to];
+ let other;
+ if (['one2one', 'many2one'].includes(this.relationType)) {
+ other = OtherModel.insert(data);
+ } else {
+ if (data instanceof Array) {
+ other = data.map(d => OtherModel.insert(d));
+ } else {
+ other = OtherModel.insert(data);
+ }
+ }
+ this._setRelationLink(record, other);
+ }
+
+ /**
+ * Set on this relational field in 'insert-and-repalce' mode. Basically
+ * data provided during set on this relational field contain data to insert
+ * records, which themselves must replace value on this field.
+ *
+ * @private
+ * @param {mail.model} record
+ * @param {Object|Object[]} data
+ */
+ _setRelationInsertAndReplace(record, data) {
+ // unlink must be done before insert:
+ // because unlink might trigger delete due to causality and new data
+ // shouldn't be deleted just after being inserted
+ // TODO IMP insert-and-replace should not unlink-all (task-2270780)
+ this._setRelationUnlink(record, null);
+ const OtherModel = this.env.models[this.to];
+ let other;
+ if (['one2one', 'many2one'].includes(this.relationType)) {
+ other = OtherModel.insert(data);
+ } else {
+ if (data instanceof Array) {
+ other = data.map(d => OtherModel.insert(d));
+ } else {
+ other = OtherModel.insert(data);
+ }
+ }
+ this._setRelationLink(record, other);
+ }
+
+ /**
+ * Set a 'link' operation on this relational field.
+ *
+ * @private
+ * @param {string|string[]|mail.model|mail.model[]} newValue
+ */
+ _setRelationLink(record, newValue) {
+ const Model = record.constructor;
+ if (!Model.get(record)) {
+ // current record may be deleted due to causality
+ return;
+ }
+ switch (this.relationType) {
+ case 'many2many':
+ this._setRelationLinkMany2Many(record, newValue);
+ break;
+ case 'many2one':
+ this._setRelationLinkMany2One(record, newValue);
+ break;
+ case 'one2many':
+ this._setRelationLinkOne2Many(record, newValue);
+ break;
+ case 'one2one':
+ this._setRelationLinkOne2One(record, newValue);
+ break;
+ }
+ }
+
+ /**
+ * Handling of a `set` 'link' of a many2many relational field.
+ *
+ * @private
+ * @param {mail.model} record
+ * @param {string|mail.model|<mail.model|string>[]} newValue
+ */
+ _setRelationLinkMany2Many(record, newValue) {
+ // convert newValue to array of localId
+ const newLocalIds = this._setRelationConvertX2ManyValue(newValue);
+ const OtherModel = this.env.models[this.to];
+
+ for (const newLocalId of newLocalIds) {
+ // read in loop to catch potential changes from previous iteration
+ const prevLocalIds = this.read(record);
+
+ // other record already linked, avoid linking twice
+ if (prevLocalIds.includes(newLocalId)) {
+ continue;
+ }
+
+ const newOtherRecord = OtherModel.get(newLocalId);
+ // other record may be deleted due to causality, avoid linking
+ // deleted records
+ if (!newOtherRecord) {
+ continue;
+ }
+
+ // link other record to current record
+ this.write(record, prevLocalIds.concat([newLocalId]));
+
+ // link current record to other record
+ newOtherRecord.update({
+ [this.inverse]: [['link', record]],
+ });
+ }
+ }
+
+ /**
+ * Handling of a `set` 'link' of a many2one relational field.
+ *
+ * @private
+ * @param {mail.model} record
+ * @param {string|mail.model} newValue
+ */
+ _setRelationLinkMany2One(record, newValue) {
+ // convert newValue to localId
+ const newLocalId = this._setRelationConvertX2OneValue(newValue);
+ const OtherModel = this.env.models[this.to];
+ const prevLocalId = this.read(record);
+
+ // other record already linked, avoid linking twice
+ if (prevLocalId === newLocalId) {
+ return;
+ }
+
+ // unlink previous other record from current record
+ this.write(record, undefined);
+
+ const prevOtherRecord = OtherModel.get(prevLocalId);
+ // there may be no previous other record or the previous other record
+ // may be deleted due to causality
+ if (prevOtherRecord) {
+ // unlink current record from previous other record
+ prevOtherRecord.update({
+ [this.inverse]: [['unlink', record]],
+ });
+ }
+
+ const newOtherRecord = OtherModel.get(newLocalId);
+ // other record may be deleted due to causality, avoid linking
+ // deleted records
+ if (!newOtherRecord) {
+ return;
+ }
+
+ // link other record to current records
+ this.write(record, newLocalId);
+
+ // link current record to other record
+ newOtherRecord.update({
+ [this.inverse]: [['link', record]],
+ });
+ }
+
+ /**
+ * Handling of a `set` 'link' of an one2many relational field.
+ *
+ * @private
+ * @param {mail.model} record
+ * @param {string|mail.model|<string|mail.model>[]} newValue
+ */
+ _setRelationLinkOne2Many(record, newValue) {
+ // convert newValue to array of localId
+ const newLocalIds = this._setRelationConvertX2ManyValue(newValue);
+ const OtherModel = this.env.models[this.to];
+
+ for (const newLocalId of newLocalIds) {
+ // read in loop to catch potential changes from previous iteration
+ const prevLocalIds = this.read(record);
+
+ // other record already linked, avoid linking twice
+ if (prevLocalIds.includes(newLocalId)) {
+ continue;
+ }
+
+ const newOtherRecord = OtherModel.get(newLocalId);
+ // other record may be deleted due to causality, avoid linking
+ // deleted records
+ if (!newOtherRecord) {
+ continue;
+ }
+
+ // link other record to current record
+ this.write(record, prevLocalIds.concat([newLocalId]));
+
+ // link current record to other record
+ newOtherRecord.update({
+ [this.inverse]: [['link', record]],
+ });
+ }
+ }
+
+ /**
+ * Handling of a `set` 'link' of an one2one relational field.
+ *
+ * @private
+ * @param {mail.model} record
+ * @param {string|mail.model} value
+ */
+ _setRelationLinkOne2One(record, newValue) {
+ // convert newValue to localId
+ const newLocalId = this._setRelationConvertX2OneValue(newValue);
+ const prevLocalId = this.read(record);
+ const OtherModel = this.env.models[this.to];
+
+ // other record already linked, avoid linking twice
+ if (prevLocalId === newLocalId) {
+ return;
+ }
+
+ // unlink previous other record from current record
+ this.write(record, undefined);
+
+ const prevOtherRecord = OtherModel.get(prevLocalId);
+ // there may be no previous other record or the previous other record
+ // may be deleted due to causality
+ if (prevOtherRecord) {
+ // unlink current record from previous other record
+ prevOtherRecord.update({
+ [this.inverse]: [['unlink', record]],
+ });
+ // apply causality
+ if (this.isCausal) {
+ prevOtherRecord.delete();
+ }
+ }
+
+ const newOtherRecord = OtherModel.get(newLocalId);
+ // other record may be deleted due to causality, avoid linking deleted
+ // records
+ if (!newOtherRecord) {
+ return;
+ }
+
+ // link other record to current record
+ this.write(record, newLocalId);
+
+ // link current record to other record
+ newOtherRecord.update({
+ [this.inverse]: [['link', record]],
+ });
+ }
+
+ /**
+ * Set an 'unlink' operation on this relational field.
+ *
+ * @private
+ * @param {mail.model} record
+ * @param {string|string[]|mail.model|mail.model[]|null} newValue
+ */
+ _setRelationUnlink(record, newValue) {
+ const Model = record.constructor;
+ if (!Model.get(record)) {
+ // current record may be deleted due to causality
+ return;
+ }
+ switch (this.relationType) {
+ case 'many2many':
+ this._setRelationUnlinkMany2Many(record, newValue);
+ break;
+ case 'many2one':
+ this._setRelationUnlinkMany2One(record);
+ break;
+ case 'one2many':
+ this._setRelationUnlinkOne2Many(record, newValue);
+ break;
+ case 'one2one':
+ this._setRelationUnlinkOne2One(record);
+ break;
+ }
+ }
+
+ /**
+ * Handling of a `set` 'unlink' of a many2many relational field.
+ *
+ * @private
+ * @param {mail.model} record
+ * @param {string|mail.model|<string|mail.model>[]|null} newValue
+ */
+ _setRelationUnlinkMany2Many(record, newValue) {
+ // convert newValue to array of localId, null is considered unlink all
+ const otherLocalIds = newValue === null
+ ? [...this.read(record)]
+ : this._setRelationConvertX2ManyValue(newValue);
+ const OtherModel = this.env.models[this.to];
+
+ for (const otherLocalId of otherLocalIds) {
+ // read in loop to catch potential changes from previous iteration
+ const prevLocalIds = this.read(record);
+
+ // other record already unlinked, avoid useless processing
+ if (!prevLocalIds.includes(otherLocalId)) {
+ continue;
+ }
+
+ // unlink other record from current record
+ this.write(record, prevLocalIds.filter(
+ localId => localId !== otherLocalId
+ ));
+
+ const otherRecord = OtherModel.get(otherLocalId);
+ // other record may be deleted due to causality, avoid useless
+ // processing
+ if (otherRecord) {
+ // unlink current record from other record
+ otherRecord.update({
+ [this.inverse]: [['unlink', record]],
+ });
+ }
+ }
+ }
+
+ /**
+ * Handling of a `set` 'unlink' of a many2one relational field.
+ *
+ * @private
+ * @param {mail.model} record
+ */
+ _setRelationUnlinkMany2One(record) {
+ const otherLocalId = this.read(record);
+ const OtherModel = this.env.models[this.to];
+
+ // other record already unlinked, avoid useless processing
+ if (!otherLocalId) {
+ return;
+ }
+
+ // unlink other record from current record
+ this.write(record, undefined);
+
+ const otherRecord = OtherModel.get(otherLocalId);
+ // other record may be deleted due to causality, avoid useless
+ // processing
+ if (otherRecord) {
+ // unlink current record from other record
+ otherRecord.update({
+ [this.inverse]: [['unlink', record]],
+ });
+ }
+ }
+
+ /**
+ * Handling of a `set` 'unlink' of an one2many relational field.
+ *
+ * @private
+ * @param {mail.model} record
+ * @param {string|mail.model|<string|mail.model>[]|null} newValue
+ * if null, unlink all items in the relation of provided record.
+ */
+ _setRelationUnlinkOne2Many(record, newValue) {
+ // convert newValue to array of localId, null is considered unlink all
+ const otherLocalIds = newValue === null
+ ? [...this.read(record)]
+ : this._setRelationConvertX2ManyValue(newValue);
+ const OtherModel = this.env.models[this.to];
+
+ for (const otherLocalId of otherLocalIds) {
+ // read in loop to catch potential changes from previous iteration
+ const prevLocalIds = this.read(record);
+
+ // other record already unlinked, avoid useless processing
+ if (!prevLocalIds.includes(otherLocalId)) {
+ continue;
+ }
+
+ // unlink other record from current record
+ this.write(record, prevLocalIds.filter(
+ localId => localId !== otherLocalId
+ ));
+
+ const otherRecord = OtherModel.get(otherLocalId);
+ // other record may be deleted due to causality, avoid useless
+ // processing
+ if (otherRecord) {
+ // unlink current record from other record
+ otherRecord.update({
+ [this.inverse]: [['unlink', record]],
+ });
+ }
+ }
+ }
+
+ /**
+ * Handling of a `set` 'unlink' of an one2one relational field.
+ *
+ * @private
+ * @param {mail.model} record
+ */
+ _setRelationUnlinkOne2One(record) {
+ const otherLocalId = this.read(record);
+ const OtherModel = this.env.models[this.to];
+
+ // other record already unlinked, avoid useless processing
+ if (!otherLocalId) {
+ return;
+ }
+
+ // unlink other record from current record
+ this.write(record, undefined);
+
+ const otherRecord = OtherModel.get(otherLocalId);
+ // other record may be deleted due to causality, avoid useless
+ // processing
+ if (otherRecord) {
+ // unlink current record from other record
+ otherRecord.update({
+ [this.inverse]: [['unlink', record]],
+ });
+ }
+ }
+
+}
+
+return ModelField;
+
+});
diff --git a/addons/mail/static/src/model/model_manager.js b/addons/mail/static/src/model/model_manager.js
new file mode 100644
index 00000000000..37ce2d2808c
--- /dev/null
+++ b/addons/mail/static/src/model/model_manager.js
@@ -0,0 +1,947 @@
+odoo.define('mail/static/src/model/model_manager.js', function (require) {
+'use strict';
+
+const { registry } = require('mail/static/src/model/model_core.js');
+const ModelField = require('mail/static/src/model/model_field.js');
+const { patchClassMethods, patchInstanceMethods } = require('mail/static/src/utils/utils.js');
+
+/**
+ * Inner separator used between 2 bits of information in string that is used to
+ * identify record and field to be computed during an update cycle.
+ */
+const COMPUTE_RECORD_FIELD_INNER_SEPARATOR = "--||--||--";
+/**
+ * Inner separator used between bits of information in string that is used to
+ * identify a dependent of a field. Useful to determine which record and field
+ * to register for compute during this update cycle.
+ */
+const DEPENDENT_INNER_SEPARATOR = "--//--//--";
+
+/**
+ * Object that manage models and records, notably their update cycle: whenever
+ * some records are requested for update (either with model static method
+ * `create()` or record method `update()`), this object processes them with
+ * direct field & and computed field updates.
+ */
+class ModelManager {
+
+ //--------------------------------------------------------------------------
+ // Public
+ //--------------------------------------------------------------------------
+
+ constructor(env) {
+ /**
+ * Inner separator used inside string to represent dependents.
+ * Set as public attribute so that it can be used by model field.
+ */
+ this.DEPENDENT_INNER_SEPARATOR = DEPENDENT_INNER_SEPARATOR;
+
+ /**
+ * The messaging env. It is passed on start(), due to the way mocking
+ * of OWL env in tests work...
+ */
+ this.env = undefined;
+
+ /**
+ * Whether this is currently handling an "update after" on a record.
+ * Useful to determine if we should process computed/related fields.
+ */
+ this._isHandlingToUpdateAfters = false;
+ /**
+ * Determine whether an update cycle is currently in progress.
+ * Useful to determine whether an update should initiate an update
+ * cycle or not. An update cycle basically prioritizes processing
+ * of all direct updates (i.e. explicit from `data`) before
+ * processing computes.
+ */
+ this._isInUpdateCycle = false;
+ /**
+ * Contains all records. key is local id, while value is the record.
+ */
+ this._records = {};
+ /**
+ * Fields flagged to call compute during an update cycle.
+ * For instance, when a field with dependents got update, dependent
+ * fields should update themselves by invoking compute at end of
+ * update cycle. Key is of format
+ * <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();
+ /**
+ * List of "update after" on records that have been registered.
+ * These are processed after any explicit update and computed/related
+ * fields.
+ */
+ this._toUpdateAfters = [];
+ }
+
+ /**
+ * Called when all JS modules that register or patch models have been
+ * done. This launches generation of models.
+ */
+ start(env) {
+ this.env = env;
+ /**
+ * Generate the models.
+ */
+ Object.assign(this.env.models, this._generateModels());
+ }
+
+ //--------------------------------------------------------------------------
+ // Public
+ //--------------------------------------------------------------------------
+
+ /**
+ * Returns all records of provided model that match provided criteria.
+ *
+ * @param {mail.model} Model class
+ * @param {function} [filterFunc]
+ * @returns {mail.model[]} records matching criteria.
+ */
+ all(Model, filterFunc) {
+ const allRecords = Object.values(this._records)
+ .filter(e => e instanceof Model);
+ if (filterFunc) {
+ return allRecords.filter(filterFunc);
+ }
+ return allRecords;
+ }
+
+ /**
+ * Register a record that has been created, and manage update of records
+ * from this record creation.
+ *
+ * @param {mail.model} Model class
+ * @param {Object} [data={}]
+ * @returns {mail.model} newly created record
+ */
+ create(Model, data = {}) {
+ const record = new Model({ valid: true });
+ Object.defineProperty(record, 'env', { get: () => Model.env });
+ record.localId = record._createRecordLocalId(data);
+
+ // Make state, which contain field values of record that have to
+ // be observed in store.
+ this.env.store.state[record.localId] = {};
+ record.__state = this.env.store.state[record.localId];
+
+ // Make proxified record, so that access to field redirects
+ // to field getter.
+ const proxifiedRecord = this._makeProxifiedRecord(record);
+ this._records[record.localId] = proxifiedRecord;
+ proxifiedRecord.init();
+ this._makeDefaults(proxifiedRecord);
+
+ const data2 = Object.assign({}, data);
+ for (const field of Object.values(Model.fields)) {
+ if (field.fieldType !== 'relation') {
+ continue;
+ }
+ if (!field.autocreate) {
+ continue;
+ }
+ data2[field.fieldName] = [['create']];
+ }
+
+ for (const field of Object.values(Model.fields)) {
+ if (field.compute || field.related) {
+ // new record should always invoke computed fields.
+ this.registerToComputeField(record, field);
+ }
+ }
+
+ this.update(proxifiedRecord, data2);
+
+ return proxifiedRecord;
+ }
+
+ /**
+ * Delete the record. After this operation, it's as if this record never
+ * existed. Note that relation are removed, which may delete more relations
+ * if some of them are causal.
+ *
+ * @param {mail.model} record
+ */
+ delete(record) {
+ const Model = record.constructor;
+ if (!this.get(Model, record)) {
+ // Record has already been deleted.
+ // (e.g. unlinking one of its reverse relation was causal)
+ return;
+ }
+ const data = {};
+ const recordRelations = Object.values(Model.fields)
+ .filter(field => field.fieldType === 'relation');
+ for (const relation of recordRelations) {
+ if (relation.isCausal) {
+ switch (relation.relationType) {
+ case 'one2one':
+ case 'many2one':
+ if (record[relation.fieldName]) {
+ record[relation.fieldName].delete();
+ }
+ break;
+ case 'one2many':
+ case 'many2many':
+ for (const relatedRecord of record[relation.fieldName]) {
+ relatedRecord.delete();
+ }
+ break;
+ }
+ }
+ data[relation.fieldName] = [['unlink-all']];
+ }
+ record.update(data);
+ delete this._records[record.localId];
+ delete this.env.store.state[record.localId];
+ }
+
+ /**
+ * Delete all records.
+ */
+ deleteAll() {
+ for (const record of Object.values(this._records)) {
+ record.delete();
+ }
+ }
+
+ /**
+ * Get the record of provided model that has provided
+ * criteria, if it exists.
+ *
+ * @param {mail.model} Model class
+ * @param {function} findFunc
+ * @returns {mail.model|undefined} the record of model matching criteria, if
+ * exists.
+ */
+ find(Model, findFunc) {
+ return this.all(Model).find(findFunc);
+ }
+
+ /**
+ * This method returns the record of provided model that matches provided
+ * record/local id. Useful to convert a local id to a record, and also to
+ * determine whether the record is still "alive" (i.e. not deleted). Note
+ * that even if there's a record in the system having provided local id, if
+ * the resulting record is not an instance of this model, this getter
+ * assumes the record does not exist.
+ *
+ * @param {mail.model} Model class
+ * @param {string|mail.model|undefined} recordOrLocalId
+ * @returns {mail.model|undefined} record, if exists
+ */
+ get(Model, recordOrLocalId) {
+ if (recordOrLocalId === undefined) {
+ return undefined;
+ }
+ const record = this._records[
+ recordOrLocalId instanceof this.env.models['mail.model']
+ ? recordOrLocalId.localId
+ : recordOrLocalId
+ ];
+ if (!(record instanceof Model)) {
+ return;
+ }
+ return record;
+ }
+
+ /**
+ * This method creates a record or updates one of provided Model, based on
+ * provided data. This method assumes that records are uniquely identifiable
+ * per "unique find" criteria from data on Model.
+ *
+ * @param {mail.model} Model class
+ * @param {Object} data
+ * @returns {mail.model} created or updated record.
+ */
+ insert(Model, data) {
+ let record = Model.find(Model._findFunctionFromData(data));
+ if (!record) {
+ record = Model.create(data);
+ } else {
+ record.update(data);
+ }
+ return record;
+ }
+
+ /**
+ * Process an update on provided record with provided data. Updating
+ * a record consists of applying direct updates first (i.e. explicit
+ * ones from `data`) and then indirect ones (i.e. compute/related fields
+ * and "after updates").
+ *
+ * @param {mail.model} record
+ * @param {Object} data
+ */
+ update(record, data) {
+ if (!this._isInUpdateCycle) {
+ this._isInUpdateCycle = true;
+ this._updateDirect(record, data);
+ while (
+ this._toComputeFields.size > 0 ||
+ this._toUpdateAfters.length > 0
+ ) {
+ if (this._toComputeFields.size > 0) {
+ this._updateComputes();
+ } else {
+ this._isHandlingToUpdateAfters = true;
+ // process one update after
+ const [recordToUpdate, previous] = this._toUpdateAfters.pop();
+ const RecordToUpdateModel = recordToUpdate.constructor;
+ if (this.get(RecordToUpdateModel, recordToUpdate)) {
+ recordToUpdate._updateAfter(previous);
+ }
+ this._isHandlingToUpdateAfters = false;
+ }
+ }
+ this._toComputeFields.clear();
+ this._isInUpdateCycle = false;
+ } else {
+ this._updateDirect(record, data);
+ if (this._isHandlingToUpdateAfters) {
+ this._updateComputes();
+ }
+ }
+ }
+
+ /**
+ * Register a pair record/field for the compute step of the update cycle in
+ * progress.
+ *
+ * @param {mail.model} record
+ * @param {ModelField} field
+ */
+ registerToComputeField(record, field) {
+ const entry = [record.localId, field.fieldName].join(COMPUTE_RECORD_FIELD_INNER_SEPARATOR);
+ this._toComputeFields.set(entry, true);
+ }
+
+ //--------------------------------------------------------------------------
+ // Private
+ //--------------------------------------------------------------------------
+
+ /**
+ * @private
+ * @param {mail.model} Model class
+ * @param {Object} patch
+ */
+ _applyModelPatchFields(Model, patch) {
+ for (const [fieldName, field] of Object.entries(patch)) {
+ if (!Model.fields[fieldName]) {
+ Model.fields[fieldName] = field;
+ } else {
+ Object.assign(Model.fields[fieldName].dependencies, field.dependencies);
+ }
+ }
+ }
+
+ /**
+ * @private
+ * @param {Object} Models
+ * @throws {Error} in case some declared fields are not correct.
+ */
+ _checkDeclaredFieldsOnModels(Models) {
+ for (const Model of Object.values(Models)) {
+ for (const fieldName in Model.fields) {
+ const field = Model.fields[fieldName];
+ // 0. Get parented declared fields
+ const parentedMatchingFields = [];
+ let TargetModel = Model.__proto__;
+ while (Models[TargetModel.modelName]) {
+ if (TargetModel.fields) {
+ const matchingField = TargetModel.fields[fieldName];
+ if (matchingField) {
+ parentedMatchingFields.push(matchingField);
+ }
+ }
+ TargetModel = TargetModel.__proto__;
+ }
+ // 1. Field type is required.
+ if (!(['attribute', 'relation'].includes(field.fieldType))) {
+ throw new Error(`Field "${Model.modelName}/${fieldName}" has unsupported type ${field.fieldType}.`);
+ }
+ // 2. Invalid keys based on field type.
+ if (field.fieldType === 'attribute') {
+ const invalidKeys = Object.keys(field).filter(key =>
+ ![
+ 'autocreate',
+ 'compute',
+ 'default',
+ 'dependencies',
+ 'fieldType',
+ 'related',
+ ].includes(key)
+ );
+ if (invalidKeys.length > 0) {
+ throw new Error(`Field "${Model.modelName}/${fieldName}" contains some invalid keys: "${invalidKeys.join(", ")}".`);
+ }
+ }
+ if (field.fieldType === 'relation') {
+ const invalidKeys = Object.keys(field).filter(key =>
+ ![
+ 'autocreate',
+ 'compute',
+ 'dependencies',
+ 'fieldType',
+ 'inverse',
+ 'isCausal',
+ 'related',
+ 'relationType',
+ 'to',
+ ].includes(key)
+ );
+ if (invalidKeys.length > 0) {
+ throw new Error(`Field "${Model.modelName}/${fieldName}" contains some invalid keys: "${invalidKeys.join(", ")}".`);
+ }
+ if (!Models[field.to]) {
+ throw new Error(`Relational field "${Model.modelName}/${fieldName}" targets to unknown model name "${field.to}".`);
+ }
+ if (field.isCausal && !(['one2many', 'one2one'].includes(field.relationType))) {
+ throw new Error(`Relational field "${Model.modelName}/${fieldName}" has "isCausal" true with a relation of type "${field.relationType}" but "isCausal" is only supported for "one2many" and "one2one".`);
+ }
+ }
+ // 3. Computed field.
+ if (field.compute && !(typeof field.compute === 'string')) {
+ throw new Error(`Field "${Model.modelName}/${fieldName}" property "compute" must be a string (instance method name).`);
+ }
+ if (field.compute && !(Model.prototype[field.compute])) {
+ throw new Error(`Field "${Model.modelName}/${fieldName}" property "compute" does not refer to an instance method of this Model.`);
+ }
+ if (
+ field.dependencies &&
+ (!field.compute && !parentedMatchingFields.some(field => field.compute))
+ ) {
+ throw new Error(`Field "${Model.modelName}/${fieldName} contains dependendencies but no compute method in itself or parented matching fields (dependencies only make sense for compute fields)."`);
+ }
+ if (
+ (field.compute || parentedMatchingFields.some(field => field.compute)) &&
+ (field.dependencies || parentedMatchingFields.some(field => field.dependencies))
+ ) {
+ if (!(field.dependencies instanceof Array)) {
+ throw new Error(`Compute field "${Model.modelName}/${fieldName}" dependencies must be an array of field names.`);
+ }
+ const unknownDependencies = field.dependencies.every(dependency => !(Model.fields[dependency]));
+ if (unknownDependencies.length > 0) {
+ throw new Error(`Compute field "${Model.modelName}/${fieldName}" contains some unknown dependencies: "${unknownDependencies.join(", ")}".`);
+ }
+ }
+ // 4. Related field.
+ if (field.compute && field.related) {
+ throw new Error(`Field "${Model.modelName}/${fieldName}" cannot be a related and compute field at the same time.`);
+ }
+ if (field.related) {
+ if (!(typeof field.related === 'string')) {
+ throw new Error(`Field "${Model.modelName}/${fieldName}" property "related" has invalid format.`);
+ }
+ const [relationName, relatedFieldName, other] = field.related.split('.');
+ if (!relationName || !relatedFieldName || other) {
+ throw new Error(`Field "${Model.modelName}/${fieldName}" property "related" has invalid format.`);
+ }
+ // find relation on self or parents.
+ let relatedRelation;
+ let TargetModel = Model;
+ while (Models[TargetModel.modelName] && !relatedRelation) {
+ if (TargetModel.fields) {
+ relatedRelation = TargetModel.fields[relationName];
+ }
+ TargetModel = TargetModel.__proto__;
+ }
+ if (!relatedRelation) {
+ throw new Error(`Related field "${Model.modelName}/${fieldName}" relates to unknown relation name "${relationName}".`);
+ }
+ if (relatedRelation.fieldType !== 'relation') {
+ throw new Error(`Related field "${Model.modelName}/${fieldName}" relates to non-relational field "${relationName}".`);
+ }
+ // Assuming related relation is valid...
+ // find field name on related model or any parents.
+ const RelatedModel = Models[relatedRelation.to];
+ let relatedField;
+ TargetModel = RelatedModel;
+ while (Models[TargetModel.modelName] && !relatedField) {
+ if (TargetModel.fields) {
+ relatedField = TargetModel.fields[relatedFieldName];
+ }
+ TargetModel = TargetModel.__proto__;
+ }
+ if (!relatedField) {
+ throw new Error(`Related field "${Model.modelName}/${fieldName}" relates to unknown related model field "${relatedFieldName}".`);
+ }
+ if (relatedField.fieldType !== field.fieldType) {
+ throw new Error(`Related field "${Model.modelName}/${fieldName}" has mismatch type with its related model field.`);
+ }
+ if (
+ relatedField.fieldType === 'relation' &&
+ relatedField.to !== field.to
+ ) {
+ throw new Error(`Related field "${Model.modelName}/${fieldName}" has mismatch target model name with its related model field.`);
+ }
+ }
+ }
+ }
+ }
+
+ /**
+ * @private
+ * @param {Object} Models
+ * @throws {Error} in case some fields are not correct.
+ */
+ _checkProcessedFieldsOnModels(Models) {
+ for (const Model of Object.values(Models)) {
+ for (const fieldName in Model.fields) {
+ const field = Model.fields[fieldName];
+ if (!(['attribute', 'relation'].includes(field.fieldType))) {
+ throw new Error(`Field "${Model.modelName}/${fieldName}" has unsupported type ${field.fieldType}.`);
+ }
+ if (field.compute && field.related) {
+ throw new Error(`Field "${Model.modelName}/${fieldName}" cannot be a related and compute field at the same time.`);
+ }
+ if (field.fieldType === 'attribute') {
+ continue;
+ }
+ if (!field.relationType) {
+ throw new Error(
+ `Field "${Model.modelName}/${fieldName}" must define a relation type in "relationType".`
+ );
+ }
+ if (!(['one2one', 'one2many', 'many2one', 'many2many'].includes(field.relationType))) {
+ throw new Error(
+ `Field "${Model.modelName}/${fieldName}" has invalid relation type "${field.relationType}".`
+ );
+ }
+ if (!field.inverse) {
+ throw new Error(
+ `Field "${
+ Model.modelName
+ }/${
+ fieldName
+ }" must define an inverse relation name in "inverse".`
+ );
+ }
+ if (!field.to) {
+ throw new Error(
+ `Relation "${
+ Model.modelNames
+ }/${
+ fieldName
+ }" must define a model name in "to" (1st positional parameter of relation field helpers).`
+ );
+ }
+ const RelatedModel = Models[field.to];
+ if (!RelatedModel) {
+ throw new Error(
+ `Model name of relation "${Model.modelName}/${fieldName}" does not exist.`
+ );
+ }
+ const inverseField = RelatedModel.fields[field.inverse];
+ if (!inverseField) {
+ throw new Error(
+ `Relation "${
+ Model.modelName
+ }/${
+ fieldName
+ }" has no inverse field "${RelatedModel.modelName}/${field.inverse}".`
+ );
+ }
+ if (inverseField.inverse !== fieldName) {
+ throw new Error(
+ `Inverse field name of relation "${
+ Model.modelName
+ }/${
+ fieldName
+ }" does not match with field name of relation "${
+ RelatedModel.modelName
+ }/${
+ inverseField.inverse
+ }".`
+ );
+ }
+ const allSelfAndParentNames = [];
+ let TargetModel = Model;
+ while (TargetModel) {
+ allSelfAndParentNames.push(TargetModel.modelName);
+ TargetModel = TargetModel.__proto__;
+ }
+ if (!allSelfAndParentNames.includes(inverseField.to)) {
+ throw new Error(
+ `Relation "${
+ Model.modelName
+ }/${
+ fieldName
+ }" has inverse relation "${
+ RelatedModel.modelName
+ }/${
+ field.inverse
+ }" misconfigured (currently "${
+ inverseField.to
+ }", should instead refer to this model or parented models: ${
+ allSelfAndParentNames.map(name => `"${name}"`).join(', ')
+ }?)`
+ );
+ }
+ if (
+ (field.relationType === 'many2many' && inverseField.relationType !== 'many2many') ||
+ (field.relationType === 'one2one' && inverseField.relationType !== 'one2one') ||
+ (field.relationType === 'one2many' && inverseField.relationType !== 'many2one') ||
+ (field.relationType === 'many2one' && inverseField.relationType !== 'one2many')
+ ) {
+ throw new Error(
+ `Mismatch relations types "${
+ Model.modelName
+ }/${
+ fieldName
+ }" (${
+ field.relationType
+ }) and "${
+ RelatedModel.modelName
+ }/${
+ field.inverse
+ }" (${
+ inverseField.relationType
+ }).`
+ );
+ }
+ }
+ }
+ }
+
+ /**
+ * @private
+ * @returns {Object}
+ * @throws {Error} in case it cannot generate models.
+ */
+ _generateModels() {
+ const allNames = Object.keys(registry);
+ const Models = {};
+ const generatedNames = [];
+ let toGenerateNames = [...allNames];
+ while (toGenerateNames.length > 0) {
+ const generatable = toGenerateNames.map(name => registry[name]).find(entry => {
+ let isGenerateable = true;
+ for (const dependencyName of entry.dependencies) {
+ if (!generatedNames.includes(dependencyName)) {
+ isGenerateable = false;
+ }
+ }
+ return isGenerateable;
+ });
+ if (!generatable) {
+ throw new Error(`Cannot generate following Model: ${toGenerateNames.join(', ')}`);
+ }
+ // Make environment accessible from Model.
+ const Model = generatable.factory(Models);
+ Object.defineProperty(Model, 'env', { get: () => this.env });
+ for (const patch of generatable.patches) {
+ switch (patch.type) {
+ case 'class':
+ patchClassMethods(Model, patch.name, patch.patch);
+ break;
+ case 'instance':
+ patchInstanceMethods(Model, patch.name, patch.patch);
+ break;
+ case 'field':
+ this._applyModelPatchFields(Model, patch.patch);
+ break;
+ }
+ }
+ if (!Model.hasOwnProperty('modelName')) {
+ throw new Error(`Missing static property "modelName" on Model class "${Model.name}".`);
+ }
+ if (generatedNames.includes(Model.modelName)) {
+ throw new Error(`Duplicate model name "${Model.modelName}" shared on 2 distinct Model classes.`);
+ }
+ Models[Model.modelName] = Model;
+ generatedNames.push(Model.modelName);
+ toGenerateNames = toGenerateNames.filter(name => name !== Model.modelName);
+ }
+ /**
+ * Check that declared model fields are correct.
+ */
+ this._checkDeclaredFieldsOnModels(Models);
+ /**
+ * Process declared model fields definitions, so that these field
+ * definitions are much easier to use in the system. For instance, all
+ * relational field definitions have an inverse, or fields track all their
+ * dependents.
+ */
+ this._processDeclaredFieldsOnModels(Models);
+ /**
+ * Check that all model fields are correct, notably one relation
+ * should have matching reversed relation.
+ */
+ this._checkProcessedFieldsOnModels(Models);
+ return Models;
+ }
+
+ /**
+ * Make default values of its fields for newly created record.
+ *
+ * @private
+ * @param {mail.model} record
+ */
+ _makeDefaults(record) {
+ const Model = record.constructor;
+ for (const field of Object.values(Model.fields)) {
+ if (field.fieldType === 'attribute') {
+ field.write(record, field.default, { registerDependents: false });
+ }
+ if (field.fieldType === 'relation') {
+ if (['one2many', 'many2many'].includes(field.relationType)) {
+ // Ensure X2many relations are arrays by defaults.
+ field.write(record, [], { registerDependents: false });
+ } else {
+ field.write(record, undefined, { registerDependents: false });
+ }
+ }
+ }
+ }
+
+ /**
+ * @private
+ * @param {mail.model} Model class
+ * @param {ModelField} field
+ * @returns {ModelField}
+ */
+ _makeInverseRelationField(Model, field) {
+ const relFunc =
+ field.relationType === 'many2many' ? ModelField.many2many
+ : field.relationType === 'many2one' ? ModelField.one2many
+ : field.relationType === 'one2many' ? ModelField.many2one
+ : field.relationType === 'one2one' ? ModelField.one2one
+ : undefined;
+ if (!relFunc) {
+ throw new Error(`Cannot compute inverse Relation of "${Model.modelName}/${field.fieldName}".`);
+ }
+ const inverseField = new ModelField(Object.assign(
+ {},
+ relFunc(Model.modelName, { inverse: field.fieldName }),
+ {
+ fieldName: `_inverse_${Model.modelName}/${field.fieldName}`,
+ modelManager: this,
+ }
+ ));
+ return inverseField;
+ }
+
+ /**
+ * Wrap record that has just been created in a proxy. Proxy is useful for
+ * auto-getting records when accessing relational fields.
+ *
+ * @private
+ * @param {mail.model} record
+ * @return {Proxy<mail.model>} proxified record
+ */
+ _makeProxifiedRecord(record) {
+ const proxifiedRecord = new Proxy(record, {
+ get: (target, k) => {
+ if (k === 'constructor') {
+ return target[k];
+ }
+ const field = target.constructor.fields[k];
+ if (!field) {
+ // No crash, we allow these reads due to patch()
+ // implementation details that read on `this._super` even
+ // if not set before-hand.
+ return target[k];
+ }
+ return field.get(proxifiedRecord);
+ },
+ set: (target, k, newVal) => {
+ if (target.constructor.fields[k]) {
+ throw new Error("Forbidden to write on record field without .update()!!");
+ } else {
+ // No crash, we allow these writes due to following concerns:
+ // - patch() implementation details that write on `this._super`
+ // - record listeners that need setting on this with `.bind(this)`
+ target[k] = newVal;
+ }
+ return true;
+ },
+ });
+ return proxifiedRecord;
+ }
+
+ /**
+ * This function processes definition of declared fields in provided models.
+ * Basically, models have fields declared in static prop `fields`, and this
+ * function processes and modifies them in place so that they are fully
+ * configured. For instance, model relations need bi-directional mapping, but
+ * inverse relation may be omitted in declared field: this function auto-fill
+ * this inverse relation.
+ *
+ * @private
+ * @param {Object} Models
+ */
+ _processDeclaredFieldsOnModels(Models) {
+ /**
+ * 1. Prepare fields.
+ */
+ for (const Model of Object.values(Models)) {
+ if (!Model.hasOwnProperty('fields')) {
+ Model.fields = {};
+ }
+ Model.inverseRelations = [];
+ // Make fields aware of their field name.
+ for (const [fieldName, fieldData] of Object.entries(Model.fields)) {
+ Model.fields[fieldName] = new ModelField(Object.assign({}, fieldData, {
+ fieldName,
+ modelManager: this,
+ }));
+ }
+ }
+ /**
+ * 2. Auto-generate definitions of undeclared inverse relations.
+ */
+ for (const Model of Object.values(Models)) {
+ for (const field of Object.values(Model.fields)) {
+ if (field.fieldType !== 'relation') {
+ continue;
+ }
+ if (field.inverse) {
+ continue;
+ }
+ const RelatedModel = Models[field.to];
+ const inverseField = this._makeInverseRelationField(Model, field);
+ field.inverse = inverseField.fieldName;
+ RelatedModel.fields[inverseField.fieldName] = inverseField;
+ }
+ }
+ /**
+ * 3. Generate dependents and inverse-relates on fields.
+ * Field definitions are not yet combined, so registration of `dependents`
+ * may have to walk structural hierarchy of models in order to find
+ * the appropriate field. Also, while dependencies are defined just with
+ * field names, dependents require an additional data called a "hash"
+ * (= field id), which is a way to identify dependents in an inverse
+ * relation. This is necessary because dependents are a subset of an inverse
+ * relation.
+ */
+ for (const Model of Object.values(Models)) {
+ for (const field of Object.values(Model.fields)) {
+ for (const dependencyFieldName of field.dependencies) {
+ let TargetModel = Model;
+ let dependencyField = TargetModel.fields[dependencyFieldName];
+ while (!dependencyField) {
+ TargetModel = TargetModel.__proto__;
+ dependencyField = TargetModel.fields[dependencyFieldName];
+ }
+ const dependent = [field.id, field.fieldName].join(DEPENDENT_INNER_SEPARATOR);
+ dependencyField.dependents = [
+ ...new Set(dependencyField.dependents.concat([dependent]))
+ ];
+ }
+ if (field.related) {
+ const [relationName, relatedFieldName] = field.related.split('.');
+ let TargetModel = Model;
+ let relationField = TargetModel.fields[relationName];
+ while (!relationField) {
+ TargetModel = TargetModel.__proto__;
+ relationField = TargetModel.fields[relationName];
+ }
+ const relationFieldDependent = [
+ field.id,
+ field.fieldName,
+ ].join(DEPENDENT_INNER_SEPARATOR);
+ relationField.dependents = [
+ ...new Set(relationField.dependents.concat([relationFieldDependent]))
+ ];
+ const OtherModel = Models[relationField.to];
+ let OtherTargetModel = OtherModel;
+ let relatedField = OtherTargetModel.fields[relatedFieldName];
+ while (!relatedField) {
+ OtherTargetModel = OtherTargetModel.__proto__;
+ relatedField = OtherTargetModel.fields[relatedFieldName];
+ }
+ const relatedFieldDependent = [
+ field.id,
+ relationField.inverse,
+ field.fieldName,
+ ].join(DEPENDENT_INNER_SEPARATOR);
+ relatedField.dependents = [
+ ...new Set(
+ relatedField.dependents.concat([relatedFieldDependent])
+ )
+ ];
+ }
+ }
+ }
+ /**
+ * 4. Extend definition of fields of a model with the definition of
+ * fields of its parents. Field definitions on self has precedence over
+ * parented fields.
+ */
+ for (const Model of Object.values(Models)) {
+ Model.__combinedFields = {};
+ for (const field of Object.values(Model.fields)) {
+ Model.__combinedFields[field.fieldName] = field;
+ }
+ let TargetModel = Model.__proto__;
+ while (TargetModel && TargetModel.fields) {
+ for (const targetField of Object.values(TargetModel.fields)) {
+ const field = Model.__combinedFields[targetField.fieldName];
+ if (field) {
+ Model.__combinedFields[targetField.fieldName] = field.combine(targetField);
+ } else {
+ Model.__combinedFields[targetField.fieldName] = targetField;
+ }
+ }
+ TargetModel = TargetModel.__proto__;
+ }
+ }
+ for (const Model of Object.values(Models)) {
+ Model.fields = Model.__combinedFields;
+ delete Model.__combinedFields;
+ }
+ }
+
+ /**
+ * Process registered computed fields in the current update cycle.
+ *
+ * @private
+ */
+ _updateComputes() {
+ while (this._toComputeFields.size > 0) {
+ // process one compute field
+ const key = this._toComputeFields.keys().next().value;
+ const [recordLocalId, fieldName] = key.split(COMPUTE_RECORD_FIELD_INNER_SEPARATOR);
+ this._toComputeFields.delete(key);
+ const record = this.env.models['mail.model'].get(recordLocalId);
+ if (record) {
+ const Model = record.constructor;
+ const field = Model.fields[fieldName];
+ field.doCompute(record);
+ }
+ }
+ }
+
+ /**
+ * Process a direct update on given record with provided data.
+ *
+ * @private
+ * @param {mail.model} record
+ * @param {Object} data
+ */
+ _updateDirect(record, data) {
+ const existing = this._toUpdateAfters.find(entry => entry[0] === record);
+ if (!existing) {
+ // queue updateAfter before calling field.set to ensure previous
+ // contains the value at the start of update cycle
+ this._toUpdateAfters.push([record, record._updateBefore()]);
+ }
+ for (const [k, v] of Object.entries(data)) {
+ const Model = record.constructor;
+ const field = Model.fields[k];
+ if (!field) {
+ throw new Error(`Cannot create/update record with data unrelated to a field. (model: "${Model.modelName}", non-field attempted update: "${k}")`);
+ }
+ field.set(record, v);
+ }
+ }
+
+}
+
+return ModelManager;
+
+});
diff --git a/addons/mail/static/src/models/activity/activity.js b/addons/mail/static/src/models/activity/activity.js
new file mode 100644
index 00000000000..12c40bd8bc5
--- /dev/null
+++ b/addons/mail/static/src/models/activity/activity.js
@@ -0,0 +1,289 @@
+odoo.define('mail/static/src/models/activity/activity/js', function (require) {
+'use strict';
+
+const { registerNewModel } = require('mail/static/src/model/model_core.js');
+const { attr, many2many, many2one } = require('mail/static/src/model/model_field.js');
+
+function factory(dependencies) {
+
+ class Activity extends dependencies['mail.model'] {
+
+
+ //----------------------------------------------------------------------
+ // Public
+ //----------------------------------------------------------------------
+
+ /**
+ * Delete the record from database and locally.
+ */
+ async deleteServerRecord() {
+ await this.async(() => this.env.services.rpc({
+ model: 'mail.activity',
+ method: 'unlink',
+ args: [[this.id]],
+ }));
+ this.delete();
+ }
+
+ //----------------------------------------------------------------------
+ // Public
+ //----------------------------------------------------------------------
+
+ /**
+ * @static
+ * @param {Object} data
+ * @return {Object}
+ */
+ static convertData(data) {
+ const data2 = {};
+ if ('activity_category' in data) {
+ data2.category = data.activity_category;
+ }
+ if ('can_write' in data) {
+ data2.canWrite = data.can_write;
+ }
+ if ('create_data' in data) {
+ data2.dateCreate = data.create_date;
+ }
+ if ('date_deadline' in data) {
+ data2.dateDeadline = data.date_deadline;
+ }
+ if ('force_next' in data) {
+ data2.force_next = data.force_next;
+ }
+ if ('icon' in data) {
+ data2.icon = data.icon;
+ }
+ if ('id' in data) {
+ data2.id = data.id;
+ }
+ if ('note' in data) {
+ data2.note = data.note;
+ }
+ if ('res_id' in data) {
+ data2.res_id = data.res_id;
+ }
+ if ('res_model' in data) {
+ data2.res_model = data.res_model;
+ }
+ if ('state' in data) {
+ data2.state = data.state;
+ }
+ if ('summary' in data) {
+ data2.summary = data.summary;
+ }
+
+ // relation
+ if ('activity_type_id' in data) {
+ if (!data.activity_type_id) {
+ data2.type = [['unlink-all']];
+ } else {
+ data2.type = [
+ ['insert', {
+ displayName: data.activity_type_id[1],
+ id: data.activity_type_id[0],
+ }],
+ ];
+ }
+ }
+ if ('create_uid' in data) {
+ if (!data.create_uid) {
+ data2.creator = [['unlink-all']];
+ } else {
+ data2.creator = [
+ ['insert', {
+ id: data.create_uid[0],
+ partnerDisplayName: data.create_uid[1],
+ }],
+ ];
+ }
+ }
+ if ('mail_template_ids' in data) {
+ data2.mailTemplates = [['insert', data.mail_template_ids]];
+ }
+ if ('user_id' in data) {
+ if (!data.user_id) {
+ data2.assignee = [['unlink-all']];
+ } else {
+ data2.assignee = [
+ ['insert', {
+ id: data.user_id[0],
+ partnerDisplayName: data.user_id[1],
+ }],
+ ];
+ }
+ }
+
+ return data2;
+ }
+
+ /**
+ * Opens (legacy) form view dialog to edit current activity and updates
+ * the activity when dialog is closed.
+ */
+ edit() {
+ const action = {
+ type: 'ir.actions.act_window',
+ name: this.env._t("Schedule Activity"),
+ res_model: 'mail.activity',
+ view_mode: 'form',
+ views: [[false, 'form']],
+ target: 'new',
+ context: {
+ default_res_id: this.res_id,
+ default_res_model: this.res_model,
+ },
+ res_id: this.id,
+ };
+ this.env.bus.trigger('do-action', {
+ action,
+ options: { on_close: () => this.fetchAndUpdate() },
+ });
+ }
+
+ async fetchAndUpdate() {
+ const [data] = await this.async(() => this.env.services.rpc({
+ model: 'mail.activity',
+ method: 'activity_format',
+ args: [this.id],
+ }));
+ this.update(this.constructor.convertData(data));
+ if (this.chatter) {
+ this.chatter.refresh();
+ }
+ }
+
+ /**
+ * @param {Object} param0
+ * @param {mail.attachment[]} [param0.attachments=[]]
+ * @param {string|boolean} [param0.feedback=false]
+ */
+ async markAsDone({ attachments = [], feedback = false }) {
+ const attachmentIds = attachments.map(attachment => attachment.id);
+ await this.async(() => this.env.services.rpc({
+ model: 'mail.activity',
+ method: 'action_feedback',
+ args: [[this.id]],
+ kwargs: {
+ attachment_ids: attachmentIds,
+ feedback,
+ },
+ context: this.chatter ? this.chatter.context : {},
+ }));
+ if (this.chatter) {
+ this.chatter.refresh();
+ }
+ this.delete();
+ }
+
+ /**
+ * @param {Object} param0
+ * @param {string} param0.feedback
+ * @returns {Object}
+ */
+ async markAsDoneAndScheduleNext({ feedback }) {
+ const action = await this.async(() => this.env.services.rpc({
+ model: 'mail.activity',
+ method: 'action_feedback_schedule_next',
+ args: [[this.id]],
+ kwargs: { feedback },
+ }));
+ const chatter = this.chatter;
+ if (chatter) {
+ this.chatter.refresh();
+ }
+ this.delete();
+ this.env.bus.trigger('do-action', {
+ action,
+ options: {
+ on_close: () => {
+ if (chatter) {
+ chatter.refreshActivities();
+ }
+ },
+ },
+ });
+ }
+
+ //----------------------------------------------------------------------
+ // Private
+ //----------------------------------------------------------------------
+
+ /**
+ * @private
+ * @returns {boolean}
+ */
+ _computeIsCurrentPartnerAssignee() {
+ if (!this.assigneePartner || !this.messagingCurrentPartner) {
+ return false;
+ }
+ return this.assigneePartner === this.messagingCurrentPartner;
+ }
+
+ /**
+ * @private
+ * @returns {mail.messaging}
+ */
+ _computeMessaging() {
+ return [['link', this.env.messaging]];
+ }
+ }
+
+ Activity.fields = {
+ assignee: many2one('mail.user'),
+ assigneePartner: many2one('mail.partner', {
+ related: 'assignee.partner',
+ }),
+ attachments: many2many('mail.attachment', {
+ inverse: 'activities',
+ }),
+ canWrite: attr({
+ default: false,
+ }),
+ category: attr(),
+ chatter: many2one('mail.chatter', {
+ inverse: 'activities',
+ }),
+ creator: many2one('mail.user'),
+ dateCreate: attr(),
+ dateDeadline: attr(),
+ force_next: attr({
+ default: false,
+ }),
+ icon: attr(),
+ id: attr(),
+ isCurrentPartnerAssignee: attr({
+ compute: '_computeIsCurrentPartnerAssignee',
+ default: false,
+ dependencies: [
+ 'assigneePartner',
+ 'messagingCurrentPartner',
+ ],
+ }),
+ mailTemplates: many2many('mail.mail_template', {
+ inverse: 'activities',
+ }),
+ messaging: many2one('mail.messaging', {
+ compute: '_computeMessaging',
+ }),
+ messagingCurrentPartner: many2one('mail.partner', {
+ related: 'messaging.currentPartner',
+ }),
+ note: attr(),
+ res_id: attr(),
+ res_model: attr(),
+ state: attr(),
+ summary: attr(),
+ type: many2one('mail.activity_type', {
+ inverse: 'activities',
+ }),
+ };
+
+ Activity.modelName = 'mail.activity';
+
+ return Activity;
+}
+
+registerNewModel('mail.activity', factory);
+
+});
diff --git a/addons/mail/static/src/models/activity_type/activity_type.js b/addons/mail/static/src/models/activity_type/activity_type.js
new file mode 100644
index 00000000000..7af2ae74095
--- /dev/null
+++ b/addons/mail/static/src/models/activity_type/activity_type.js
@@ -0,0 +1,26 @@
+odoo.define('mail/static/src/models/activity_type/activity_type.js', function (require) {
+'use strict';
+
+const { registerNewModel } = require('mail/static/src/model/model_core.js');
+const { attr, one2many } = require('mail/static/src/model/model_field.js');
+
+function factory(dependencies) {
+
+ class ActivityType extends dependencies['mail.model'] {}
+
+ ActivityType.fields = {
+ activities: one2many('mail.activity', {
+ inverse: 'type',
+ }),
+ displayName: attr(),
+ id: attr(),
+ };
+
+ ActivityType.modelName = 'mail.activity_type';
+
+ return ActivityType;
+}
+
+registerNewModel('mail.activity_type', factory);
+
+});
diff --git a/addons/mail/static/src/models/attachment/attachment.js b/addons/mail/static/src/models/attachment/attachment.js
new file mode 100644
index 00000000000..0454890401c
--- /dev/null
+++ b/addons/mail/static/src/models/attachment/attachment.js
@@ -0,0 +1,342 @@
+odoo.define('mail/static/src/models/attachment/attachment.js', function (require) {
+'use strict';
+
+const { registerNewModel } = require('mail/static/src/model/model_core.js');
+const { attr, many2many, many2one } = require('mail/static/src/model/model_field.js');
+
+function factory(dependencies) {
+
+ let nextTemporaryId = -1;
+ function getAttachmentNextTemporaryId() {
+ const id = nextTemporaryId;
+ nextTemporaryId -= 1;
+ return id;
+ }
+ class Attachment extends dependencies['mail.model'] {
+
+ //----------------------------------------------------------------------
+ // Public
+ //----------------------------------------------------------------------
+
+ /**
+ * @static
+ * @param {Object} data
+ * @return {Object}
+ */
+ static convertData(data) {
+ const data2 = {};
+ if ('filename' in data) {
+ data2.filename = data.filename;
+ }
+ if ('id' in data) {
+ data2.id = data.id;
+ }
+ if ('mimetype' in data) {
+ data2.mimetype = data.mimetype;
+ }
+ if ('name' in data) {
+ data2.name = data.name;
+ }
+
+ // relation
+ if ('res_id' in data && 'res_model' in data) {
+ data2.originThread = [['insert', {
+ id: data.res_id,
+ model: data.res_model,
+ }]];
+ }
+
+ return data2;
+ }
+
+ /**
+ * View provided attachment(s), with given attachment initially. Prompts
+ * the attachment viewer.
+ *
+ * @param {Object} param0
+ * @param {mail.attachment} [param0.attachment]
+ * @param {mail.attachment>[]} param0.attachments
+ * @returns {string|undefined} unique id of open dialog, if open
+ */
+ static view({ attachment, attachments }) {
+ if (!attachments || attachments.length === 0) {
+ return;
+ }
+ if (!attachment) {
+ attachment = attachments[0];
+ }
+ if (!attachments.includes(attachment)) {
+ return;
+ }
+ this.env.messaging.dialogManager.open('mail.attachment_viewer', {
+ attachment: [['replace', attachment]],
+ attachments: [['replace', attachments]],
+ });
+ }
+
+ /**
+ * Remove this attachment globally.
+ */
+ async remove() {
+ await this.async(() => this.env.services.rpc({
+ model: 'ir.attachment',
+ method: 'unlink',
+ args: [this.id],
+ }, { shadow: true }));
+ this.delete();
+ }
+
+ //----------------------------------------------------------------------
+ // Private
+ //----------------------------------------------------------------------
+
+ /**
+ * @private
+ * @returns {mail.composer[]}
+ */
+ _computeComposers() {
+ if (this.isTemporary) {
+ return [];
+ }
+ const relatedTemporaryAttachment = this.env.models['mail.attachment']
+ .find(attachment =>
+ attachment.filename === this.filename &&
+ attachment.isTemporary
+ );
+ if (relatedTemporaryAttachment) {
+ const composers = relatedTemporaryAttachment.composers;
+ relatedTemporaryAttachment.delete();
+ return [['replace', composers]];
+ }
+ return [];
+ }
+
+ /**
+ * @private
+ * @returns {string}
+ */
+ _computeDefaultSource() {
+ if (this.fileType === 'image') {
+ return `/web/image/${this.id}?unique=1&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/image/${this.id}?model=ir.attachment`;
+ }
+ return undefined;
+ }
+
+ /**
+ * @private
+ * @returns {string}
+ */
+ _computeDisplayName() {
+ return this.name || this.filename;
+ }
+
+ /**
+ * @private
+ * @returns {string|undefined}
+ */
+ _computeExtension() {
+ return this.filename && this.filename.split('.').pop();
+ }
+
+ /**
+ * @private
+ * @returns {string|undefined}
+ */
+ _computeFileType() {
+ if (this.type === 'url' && !this.url) {
+ return undefined;
+ } else if (!this.mimetype) {
+ return undefined;
+ }
+ const match = this.type === 'url'
+ ? this.url.match('(youtu|.png|.jpg|.gif)')
+ : this.mimetype.match('(image|video|application/pdf|text)');
+ if (!match) {
+ return undefined;
+ }
+ if (match[1].match('(.png|.jpg|.gif)')) {
+ return 'image';
+ }
+ return match[1];
+ }
+
+ /**
+ * @private
+ * @returns {integer}
+ */
+ _computeId() {
+ if (this.isTemporary && (this.id === undefined || this.id > 0)) {
+ return getAttachmentNextTemporaryId();
+ }
+ return this.id;
+ }
+
+ /**
+ * @private
+ * @returns {boolean}
+ */
+ _computeIsLinkedToComposer() {
+ return this.composers.length > 0;
+ }
+
+ /**
+ * @private
+ * @returns {boolean}
+ */
+ _computeIsTextFile() {
+ if (!this.fileType) {
+ return false;
+ }
+ return this.fileType.includes('text');
+ }
+
+ /**
+ * @private
+ * @returns {boolean}
+ */
+ _computeIsViewable() {
+ return (
+ this.mediaType === 'image' ||
+ this.mediaType === 'video' ||
+ this.mimetype === 'application/pdf' ||
+ this.isTextFile
+ );
+ }
+
+ /**
+ * @private
+ * @returns {string}
+ */
+ _computeMediaType() {
+ return this.mimetype && this.mimetype.split('/').shift();
+ }
+
+ /**
+ * @override
+ */
+ _createRecordLocalId(data) {
+ const { id, isTemporary = false } = data;
+ const Attachment = this.env.models['mail.attachment'];
+ if (isTemporary) {
+ return `${Attachment.modelName}_${nextTemporaryId}`;
+ }
+ return `${Attachment.modelName}_${id}`;
+ }
+
+ }
+
+ Attachment.fields = {
+ activities: many2many('mail.activity', {
+ inverse: 'attachments',
+ }),
+ attachmentViewer: many2many('mail.attachment_viewer', {
+ inverse: 'attachments',
+ }),
+ checkSum: attr(),
+ composers: many2many('mail.composer', {
+ compute: '_computeComposers',
+ inverse: 'attachments',
+ }),
+ defaultSource: attr({
+ compute: '_computeDefaultSource',
+ dependencies: [
+ 'checkSum',
+ 'fileType',
+ 'id',
+ 'url',
+ ],
+ }),
+ displayName: attr({
+ compute: '_computeDisplayName',
+ dependencies: [
+ 'filename',
+ 'name',
+ ],
+ }),
+ extension: attr({
+ compute: '_computeExtension',
+ dependencies: ['filename'],
+ }),
+ filename: attr(),
+ fileType: attr({
+ compute: '_computeFileType',
+ dependencies: [
+ 'mimetype',
+ 'type',
+ 'url',
+ ],
+ }),
+ id: attr({
+ compute: '_computeId',
+ dependencies: ['isTemporary'],
+ }),
+ isLinkedToComposer: attr({
+ compute: '_computeIsLinkedToComposer',
+ dependencies: ['composers'],
+ }),
+ isTemporary: attr({
+ default: false,
+ }),
+ isTextFile: attr({
+ compute: '_computeIsTextFile',
+ dependencies: ['fileType'],
+ }),
+ isViewable: attr({
+ compute: '_computeIsViewable',
+ dependencies: [
+ 'mediaType',
+ 'isTextFile',
+ 'mimetype',
+ ],
+ }),
+ mediaType: attr({
+ compute: '_computeMediaType',
+ dependencies: ['mimetype'],
+ }),
+ messages: many2many('mail.message', {
+ inverse: 'attachments',
+ }),
+ mimetype: attr({
+ default: '',
+ }),
+ name: attr(),
+ originThread: many2one('mail.thread', {
+ inverse: 'originThreadAttachments',
+ }),
+ size: attr(),
+ threads: many2many('mail.thread', {
+ inverse: 'attachments',
+ }),
+ type: attr(),
+ url: attr(),
+ };
+
+ Attachment.modelName = 'mail.attachment';
+
+ return Attachment;
+}
+
+registerNewModel('mail.attachment', factory);
+
+});
diff --git a/addons/mail/static/src/models/attachment/attachment_tests.js b/addons/mail/static/src/models/attachment/attachment_tests.js
new file mode 100644
index 00000000000..b2761f0d99d
--- /dev/null
+++ b/addons/mail/static/src/models/attachment/attachment_tests.js
@@ -0,0 +1,156 @@
+odoo.define('mail/static/src/models/attachment/attachment_tests.js', function (require) {
+'use strict';
+
+const {
+ afterEach: utilsAfterEach,
+ beforeEach: utilsBeforeEach,
+ start: utilsStart,
+} = require('mail/static/src/utils/test_utils.js');
+
+QUnit.module('mail', {}, function () {
+QUnit.module('models', {}, function () {
+QUnit.module('attachment', {}, function () {
+QUnit.module('attachment_tests.js', {
+ beforeEach() {
+ utilsBeforeEach(this);
+
+ this.start = async params => {
+ if (this.widget) {
+ this.widget.destroy();
+ }
+ let { env, widget } = await utilsStart(Object.assign({}, params, {
+ data: this.data,
+ }));
+ this.env = env;
+ this.widget = widget;
+ };
+ },
+ afterEach() {
+ utilsAfterEach(this);
+ this.env = undefined;
+ if (this.widget) {
+ this.widget.destroy();
+ this.widget = undefined;
+ }
+ },
+});
+
+QUnit.test('create (txt)', async function (assert) {
+ assert.expect(9);
+
+ await this.start();
+ assert.notOk(this.env.models['mail.attachment'].find(attachment => attachment.id === 750));
+
+ const attachment = this.env.models['mail.attachment'].create({
+ filename: "test.txt",
+ id: 750,
+ mimetype: 'text/plain',
+ name: "test.txt",
+ });
+ assert.ok(attachment);
+ assert.ok(this.env.models['mail.attachment'].find(attachment => attachment.id === 750));
+ assert.strictEqual(this.env.models['mail.attachment'].find(attachment => attachment.id === 750), attachment);
+ assert.strictEqual(attachment.filename, "test.txt");
+ assert.strictEqual(attachment.id, 750);
+ assert.notOk(attachment.isTemporary);
+ assert.strictEqual(attachment.mimetype, 'text/plain');
+ assert.strictEqual(attachment.name, "test.txt");
+});
+
+QUnit.test('displayName', async function (assert) {
+ assert.expect(5);
+
+ await this.start();
+ assert.notOk(this.env.models['mail.attachment'].find(attachment => attachment.id === 750));
+
+ const attachment = this.env.models['mail.attachment'].create({
+ filename: "test.txt",
+ id: 750,
+ mimetype: 'text/plain',
+ name: "test.txt",
+ });
+ assert.ok(attachment);
+ assert.ok(this.env.models['mail.attachment'].find(attachment => attachment.id === 750));
+ assert.strictEqual(attachment, this.env.models['mail.attachment'].find(attachment => attachment.id === 750));
+ assert.strictEqual(attachment.displayName, "test.txt");
+});
+
+QUnit.test('extension', async function (assert) {
+ assert.expect(5);
+
+ await this.start();
+ assert.notOk(this.env.models['mail.attachment'].find(attachment => attachment.id === 750));
+
+ const attachment = this.env.models['mail.attachment'].create({
+ filename: "test.txt",
+ id: 750,
+ mimetype: 'text/plain',
+ name: "test.txt",
+ });
+ assert.ok(attachment);
+ assert.ok(this.env.models['mail.attachment'].find(attachment => attachment.id === 750));
+ assert.strictEqual(attachment, this.env.models['mail.attachment'].find(attachment => attachment.id === 750));
+ assert.strictEqual(attachment.extension, 'txt');
+});
+
+QUnit.test('fileType', async function (assert) {
+ assert.expect(5);
+
+ await this.start();
+ assert.notOk(this.env.models['mail.attachment'].find(attachment => attachment.id === 750));
+
+ const attachment = this.env.models['mail.attachment'].create({
+ filename: "test.txt",
+ id: 750,
+ mimetype: 'text/plain',
+ name: "test.txt",
+ });
+ assert.ok(attachment);
+ assert.ok(this.env.models['mail.attachment'].find(attachment => attachment.id === 750));
+ assert.strictEqual(attachment, this.env.models['mail.attachment'].find(attachment =>
+ attachment.id === 750)
+ );
+ assert.strictEqual(attachment.fileType, 'text');
+});
+
+QUnit.test('isTextFile', async function (assert) {
+ assert.expect(5);
+
+ await this.start();
+ assert.notOk(this.env.models['mail.attachment'].find(attachment => attachment.id === 750));
+
+ const attachment = this.env.models['mail.attachment'].create({
+ filename: "test.txt",
+ id: 750,
+ mimetype: 'text/plain',
+ name: "test.txt",
+ });
+ assert.ok(attachment);
+ assert.ok(this.env.models['mail.attachment'].find(attachment => attachment.id === 750));
+ assert.strictEqual(attachment, this.env.models['mail.attachment'].find(attachment => attachment.id === 750));
+ assert.ok(attachment.isTextFile);
+});
+
+QUnit.test('isViewable', async function (assert) {
+ assert.expect(5);
+
+ await this.start();
+ assert.notOk(this.env.models['mail.attachment'].find(attachment => attachment.id === 750));
+
+ const attachment = this.env.models['mail.attachment'].create({
+ filename: "test.txt",
+ id: 750,
+ mimetype: 'text/plain',
+ name: "test.txt",
+ });
+ assert.ok(attachment);
+ assert.ok(this.env.models['mail.attachment'].find(attachment => attachment.id === 750));
+ assert.strictEqual(attachment, this.env.models['mail.attachment'].find(attachment => attachment.id === 750));
+ assert.ok(attachment.isViewable);
+});
+
+});
+});
+});
+
+});
diff --git a/addons/mail/static/src/models/attachment_viewer/attachment_viewer.js b/addons/mail/static/src/models/attachment_viewer/attachment_viewer.js
new file mode 100644
index 00000000000..7f9da0c7a8f
--- /dev/null
+++ b/addons/mail/static/src/models/attachment_viewer/attachment_viewer.js
@@ -0,0 +1,44 @@
+odoo.define('mail/static/src/models/attachment_viewer/attachment_viewer.js', function (require) {
+'use strict';
+
+const { registerNewModel } = require('mail/static/src/model/model_core.js');
+const { attr, many2many, many2one } = require('mail/static/src/model/model_field.js');
+
+function factory(dependencies) {
+
+ class AttachmentViewer extends dependencies['mail.model'] {}
+
+ AttachmentViewer.fields = {
+ /**
+ * Angle of the image. Changes when the user rotates it.
+ */
+ angle: attr({
+ default: 0,
+ }),
+ attachment: many2one('mail.attachment'),
+ attachments: many2many('mail.attachment', {
+ inverse: 'attachmentViewer',
+ }),
+ /**
+ * Determine whether the image is loading or not. Useful to diplay
+ * a spinner when loading image initially.
+ */
+ isImageLoading: attr({
+ default: false,
+ }),
+ /**
+ * Scale size of the image. Changes when user zooms in/out.
+ */
+ scale: attr({
+ default: 1,
+ }),
+ };
+
+ AttachmentViewer.modelName = 'mail.attachment_viewer';
+
+ return AttachmentViewer;
+}
+
+registerNewModel('mail.attachment_viewer', factory);
+
+});
diff --git a/addons/mail/static/src/models/chat_window/chat_window.js b/addons/mail/static/src/models/chat_window/chat_window.js
new file mode 100644
index 00000000000..e08fc9029b2
--- /dev/null
+++ b/addons/mail/static/src/models/chat_window/chat_window.js
@@ -0,0 +1,322 @@
+odoo.define('mail/static/src/models/chat_window/chat_window.js', function (require) {
+'use strict';
+
+const { registerNewModel } = require('mail/static/src/model/model_core.js');
+const { attr, many2one, one2many, one2one } = require('mail/static/src/model/model_field.js');
+
+function factory(dependencies) {
+
+ class ChatWindow extends dependencies['mail.model'] {
+
+ //----------------------------------------------------------------------
+ // Public
+ //----------------------------------------------------------------------
+
+ /**
+ * Close this chat window.
+ */
+ close() {
+ const thread = this.thread;
+ this.delete();
+ if (thread) {
+ thread.update({ pendingFoldState: 'closed' });
+ }
+ }
+
+ expand() {
+ if (this.thread) {
+ this.thread.openExpanded();
+ }
+ }
+
+ /**
+ * Programmatically auto-focus an existing chat window.
+ */
+ focus() {
+ this.update({ isDoFocus: true });
+ }
+
+ focusNextVisibleUnfoldedChatWindow() {
+ const nextVisibleUnfoldedChatWindow = this._getNextVisibleUnfoldedChatWindow();
+ if (nextVisibleUnfoldedChatWindow) {
+ nextVisibleUnfoldedChatWindow.focus();
+ }
+ }
+
+ focusPreviousVisibleUnfoldedChatWindow() {
+ const previousVisibleUnfoldedChatWindow =
+ this._getNextVisibleUnfoldedChatWindow({ reverse: true });
+ if (previousVisibleUnfoldedChatWindow) {
+ previousVisibleUnfoldedChatWindow.focus();
+ }
+ }
+
+ fold() {
+ if (this.thread) {
+ this.thread.update({ pendingFoldState: 'folded' });
+ } else {
+ this.update({ _isFolded: true });
+ }
+ }
+
+ /**
+ * Assume that this chat window was hidden before-hand.
+ */
+ makeVisible() {
+ const lastVisible = this.manager.lastVisible;
+ this.manager.swap(this, lastVisible);
+ }
+
+ /**
+ * Shift this chat window to the left on screen.
+ */
+ shiftLeft() {
+ this.manager.shiftLeft(this);
+ }
+
+ /**
+ * Shift this chat window to the right on screen.
+ */
+ shiftRight() {
+ this.manager.shiftRight(this);
+ }
+
+ unfold() {
+ if (this.thread) {
+ this.thread.update({ pendingFoldState: 'open' });
+ } else {
+ this.update({ _isFolded: false });
+ }
+ }
+
+ //----------------------------------------------------------------------
+ // Private
+ //----------------------------------------------------------------------
+
+ /**
+ * @private
+ * @returns {boolean}
+ */
+ _computeHasShiftLeft() {
+ if (!this.manager) {
+ return false;
+ }
+ const allVisible = this.manager.allOrderedVisible;
+ const index = allVisible.findIndex(visible => visible === this);
+ if (index === -1) {
+ return false;
+ }
+ return index < allVisible.length - 1;
+ }
+
+ /**
+ * @private
+ * @returns {boolean}
+ */
+ _computeHasShiftRight() {
+ if (!this.manager) {
+ return false;
+ }
+ const index = this.manager.allOrderedVisible.findIndex(visible => visible === this);
+ if (index === -1) {
+ return false;
+ }
+ return index > 0;
+ }
+
+ /**
+ * @private
+ * @returns {boolean}
+ */
+ _computeIsFolded() {
+ const thread = this.thread;
+ if (thread) {
+ return thread.foldState === 'folded';
+ }
+ return this._isFolded;
+ }
+
+ /**
+ * @private
+ * @returns {string}
+ */
+ _computeName() {
+ if (this.thread) {
+ return this.thread.displayName;
+ }
+ return this.env._t("New message");
+ }
+
+ /**
+ * @private
+ * @returns {integer}
+ */
+ _computeVisibleOffset() {
+ if (!this.manager) {
+ return 0;
+ }
+ const visible = this.manager.visual.visible;
+ const index = visible.findIndex(visible => visible.chatWindowLocalId === this.localId);
+ if (index === -1) {
+ return 0;
+ }
+ return visible[index].offset;
+ }
+
+ /**
+ * Cycles to the next possible visible and unfolded chat window starting
+ * from the `currentChatWindow`, following the natural order based on the
+ * current text direction, and with the possibility to `reverse` based on
+ * the given parameter.
+ *
+ * @private
+ * @param {Object} [param0={}]
+ * @param {boolean} [param0.reverse=false]
+ * @returns {mail.chat_window|undefined}
+ */
+ _getNextVisibleUnfoldedChatWindow({ reverse = false } = {}) {
+ const orderedVisible = this.manager.allOrderedVisible;
+ /**
+ * Return index of next visible chat window of a given visible chat
+ * window index. The direction of "next" chat window depends on
+ * `reverse` option.
+ *
+ * @param {integer} index
+ * @returns {integer}
+ */
+ const _getNextIndex = index => {
+ const directionOffset = reverse ? -1 : 1;
+ let nextIndex = index + directionOffset;
+ if (nextIndex > orderedVisible.length - 1) {
+ nextIndex = 0;
+ }
+ if (nextIndex < 0) {
+ nextIndex = orderedVisible.length - 1;
+ }
+ return nextIndex;
+ };
+
+ const currentIndex = orderedVisible.findIndex(visible => visible === this);
+ let nextIndex = _getNextIndex(currentIndex);
+ let nextToFocus = orderedVisible[nextIndex];
+ while (nextToFocus.isFolded) {
+ nextIndex = _getNextIndex(nextIndex);
+ nextToFocus = orderedVisible[nextIndex];
+ }
+ return nextToFocus;
+ }
+
+ /**
+ * @override
+ */
+ _updateAfter(previous) {
+ // thread
+ if (previous.thread && this.thread !== previous.thread) {
+ this.update({ threadInitialScrollTop: undefined });
+ }
+ }
+
+ /**
+ * @override
+ */
+ _updateBefore() {
+ return {
+ thread: this.thread,
+ };
+ }
+
+ }
+
+ ChatWindow.fields = {
+ /**
+ * Determine whether the chat window is folded or not, when not
+ * linked to a thread.
+ * Note: this value only make sense for chat window not linked
+ * to a thread. State of chat window of a thread is entirely
+ * based on thread.foldState. @see isFolded .
+ */
+ _isFolded: attr({
+ default: false,
+ }),
+ hasShiftLeft: attr({
+ compute: '_computeHasShiftLeft',
+ dependencies: ['managerAllOrderedVisible'],
+ default: false,
+ }),
+ hasShiftRight: attr({
+ compute: '_computeHasShiftRight',
+ dependencies: ['managerAllOrderedVisible'],
+ default: false,
+ }),
+ /**
+ * Determine whether the chat window should be programmatically
+ * focused by observed component of chat window. Those components
+ * are responsible to unmark this record afterwards, otherwise
+ * any re-render will programmatically set focus again!
+ */
+ isDoFocus: attr({
+ default: false,
+ }),
+ /**
+ * Determine whether the chat window is focused or not. Useful for
+ * visual clue.
+ */
+ isFocused: attr({
+ default: false,
+ }),
+ isFolded: attr({
+ compute: '_computeIsFolded',
+ dependencies: [
+ 'thread',
+ 'threadFoldState',
+ '_isFolded',
+ ],
+ default: false,
+ }),
+ manager: many2one('mail.chat_window_manager', {
+ inverse: 'chatWindows',
+ }),
+ managerAllOrderedVisible: one2many('mail.chat_window', {
+ related: 'manager.allOrderedVisible',
+ }),
+ managerVisual: attr({
+ related: 'manager.visual',
+ }),
+ name: attr({
+ compute: '_computeName',
+ dependencies: [
+ 'thread',
+ 'threadDisplayName',
+ ],
+ }),
+ /**
+ * If set, this is the scroll top position of the thread of this
+ * chat window to put initially on mount.
+ */
+ threadInitialScrollTop: attr(),
+ thread: one2one('mail.thread', {
+ related: 'threadViewer.thread',
+ }),
+ threadDisplayName: attr({
+ related: 'thread.displayName',
+ }),
+ threadFoldState: attr({
+ related: 'thread.foldState',
+ }),
+ threadViewer: one2one('mail.thread_viewer', {
+ inverse: 'chatWindow',
+ }),
+ visibleOffset: attr({
+ compute: '_computeVisibleOffset',
+ dependencies: ['managerVisual'],
+ }),
+ };
+
+ ChatWindow.modelName = 'mail.chat_window';
+
+ return ChatWindow;
+}
+
+registerNewModel('mail.chat_window', factory);
+
+});
diff --git a/addons/mail/static/src/models/chat_window_manager/chat_window_manager.js b/addons/mail/static/src/models/chat_window_manager/chat_window_manager.js
new file mode 100644
index 00000000000..3b054f83a30
--- /dev/null
+++ b/addons/mail/static/src/models/chat_window_manager/chat_window_manager.js
@@ -0,0 +1,461 @@
+odoo.define('mail/static/src/models/chat_window_manager/chat_window_manager.js', function (require) {
+'use strict';
+
+const { registerNewModel } = require('mail/static/src/model/model_core.js');
+const { attr, many2one, one2many, one2one } = require('mail/static/src/model/model_field.js');
+
+function factory(dependencies) {
+
+ const BASE_VISUAL = {
+ /**
+ * Amount of visible slots available for chat windows.
+ */
+ availableVisibleSlots: 0,
+ /**
+ * Data related to the hidden menu.
+ */
+ hidden: {
+ /**
+ * List of hidden docked chat windows. Useful to compute counter.
+ * Chat windows are ordered by their `chatWindows` order.
+ */
+ chatWindowLocalIds: [],
+ /**
+ * Whether hidden menu is visible or not
+ */
+ isVisible: false,
+ /**
+ * Offset of hidden menu starting point from the starting point
+ * of chat window manager. Makes only sense if it is visible.
+ */
+ offset: 0,
+ },
+ /**
+ * Data related to visible chat windows. Index determine order of
+ * docked chat windows.
+ *
+ * Value:
+ *
+ * {
+ * chatWindowLocalId,
+ * offset,
+ * }
+ *
+ * Offset is offset of starting point of docked chat window from
+ * starting point of dock chat window manager. Docked chat windows
+ * are ordered by their `chatWindows` order
+ */
+ visible: [],
+ };
+
+
+ class ChatWindowManager extends dependencies['mail.model'] {
+
+ //----------------------------------------------------------------------
+ // Public
+ //----------------------------------------------------------------------
+
+ /**
+ * Close all chat windows.
+ *
+ */
+ closeAll() {
+ const chatWindows = this.allOrdered;
+ for (const chatWindow of chatWindows) {
+ chatWindow.close();
+ }
+ }
+
+ closeHiddenMenu() {
+ this.update({ isHiddenMenuOpen: false });
+ }
+
+ openHiddenMenu() {
+ this.update({ isHiddenMenuOpen: true });
+ }
+
+ openNewMessage() {
+ let newMessageChatWindow = this.newMessageChatWindow;
+ if (!newMessageChatWindow) {
+ newMessageChatWindow = this.env.models['mail.chat_window'].create({
+ manager: [['link', this]],
+ });
+ }
+ newMessageChatWindow.makeVisible();
+ newMessageChatWindow.unfold();
+ newMessageChatWindow.focus();
+ }
+
+ /**
+ * @param {mail.thread} thread
+ * @param {Object} [param1={}]
+ * @param {string} [param1.mode]
+ */
+ openThread(thread, { mode } = {}) {
+ let chatWindow = this.env.models['mail.chat_window'].find(chatWindow =>
+ chatWindow.thread === thread
+ );
+ if (!chatWindow) {
+ chatWindow = this.env.models['mail.chat_window'].create({
+ manager: [['link', this]],
+ threadViewer: [['create', {
+ thread: [['link', thread]],
+ }]],
+ });
+ }
+ if (mode === 'last_visible' && !chatWindow.isVisible) {
+ chatWindow.makeVisible();
+ chatWindow.unfold();
+ chatWindow.focus();
+ }
+ if (mode === 'from_new_message') {
+ if (!this.newMessageChatWindow) {
+ throw new Error('Cannot open thread in chat window in mode "from_new_message" without any new message chat window');
+ }
+ this.swap(chatWindow, this.newMessageChatWindow);
+ this.newMessageChatWindow.close();
+ chatWindow.makeVisible();
+ chatWindow.unfold();
+ chatWindow.focus();
+ }
+ }
+
+ /**
+ * Shift provided chat window to the left on screen.
+ *
+ * @param {mail.chat_window} chatWindow
+ */
+ shiftLeft(chatWindow) {
+ const chatWindows = this.allOrdered;
+ const index = chatWindows.findIndex(cw => cw === chatWindow);
+ if (index === chatWindows.length - 1) {
+ // already left-most
+ return;
+ }
+ const otherChatWindow = chatWindows[index + 1];
+ const _newOrdered = [...this._ordered];
+ _newOrdered[index] = otherChatWindow.localId;
+ _newOrdered[index + 1] = chatWindow.localId;
+ this.update({ _ordered: _newOrdered });
+ chatWindow.focus();
+ }
+
+ /**
+ * Shift provided chat window to the right on screen.
+ *
+ * @param {mail.chat_window} chatWindow
+ */
+ shiftRight(chatWindow) {
+ const chatWindows = this.allOrdered;
+ const index = chatWindows.findIndex(cw => cw === chatWindow);
+ if (index === 0) {
+ // already right-most
+ return;
+ }
+ const otherChatWindow = chatWindows[index - 1];
+ const _newOrdered = [...this._ordered];
+ _newOrdered[index] = otherChatWindow.localId;
+ _newOrdered[index - 1] = chatWindow.localId;
+ this.update({ _ordered: _newOrdered });
+ chatWindow.focus();
+ }
+
+ /**
+ * @param {mail.chat_window} chatWindow1
+ * @param {mail.chat_window} chatWindow2
+ */
+ swap(chatWindow1, chatWindow2) {
+ const ordered = this.allOrdered;
+ const index1 = ordered.findIndex(chatWindow => chatWindow === chatWindow1);
+ const index2 = ordered.findIndex(chatWindow => chatWindow === chatWindow2);
+ if (index1 === -1 || index2 === -1) {
+ return;
+ }
+ const _newOrdered = [...this._ordered];
+ _newOrdered[index1] = chatWindow2.localId;
+ _newOrdered[index2] = chatWindow1.localId;
+ this.update({ _ordered: _newOrdered });
+ }
+
+ //----------------------------------------------------------------------
+ // Private
+ //----------------------------------------------------------------------
+
+ /**
+ * @private
+ * @returns {string[]}
+ */
+ _compute_ordered() {
+ // remove unlinked chatWindows
+ const _ordered = this._ordered.filter(chatWindowLocalId =>
+ this.chatWindows.includes(this.env.models['mail.chat_window'].get(chatWindowLocalId))
+ );
+ // add linked chatWindows
+ for (const chatWindow of this.chatWindows) {
+ if (!_ordered.includes(chatWindow.localId)) {
+ _ordered.push(chatWindow.localId);
+ }
+ }
+ return _ordered;
+ }
+
+ /**
+ * // FIXME: dependent on implementation that uses arbitrary order in relations!!
+ *
+ * @private
+ * @returns {mail.chat_window}
+ */
+ _computeAllOrdered() {
+ return [['replace', this._ordered.map(chatWindowLocalId =>
+ this.env.models['mail.chat_window'].get(chatWindowLocalId)
+ )]];
+ }
+
+ /**
+ * @private
+ * @returns {mail.chat_window[]}
+ */
+ _computeAllOrderedHidden() {
+ return [['replace', this.visual.hidden.chatWindowLocalIds.map(chatWindowLocalId =>
+ this.env.models['mail.chat_window'].get(chatWindowLocalId)
+ )]];
+ }
+
+ /**
+ * @private
+ * @returns {mail.chat_window[]}
+ */
+ _computeAllOrderedVisible() {
+ return [['replace', this.visual.visible.map(({ chatWindowLocalId }) =>
+ this.env.models['mail.chat_window'].get(chatWindowLocalId)
+ )]];
+ }
+
+ /**
+ * @private
+ * @returns {boolean}
+ */
+ _computeHasHiddenChatWindows() {
+ return this.allOrderedHidden.length > 0;
+ }
+
+ /**
+ * @private
+ * @returns {boolean}
+ */
+ _computeHasVisibleChatWindows() {
+ return this.allOrderedVisible.length > 0;
+ }
+
+ /**
+ * @private
+ * @returns {mail.chat_window|undefined}
+ */
+ _computeLastVisible() {
+ const { length: l, [l - 1]: lastVisible } = this.allOrderedVisible;
+ if (!lastVisible) {
+ return [['unlink-all']];
+ }
+ return [['replace', lastVisible]];
+ }
+
+ /**
+ * @private
+ * @returns {mail.chat_window|undefined}
+ */
+ _computeNewMessageChatWindow() {
+ const chatWindow = this.allOrdered.find(chatWindow => !chatWindow.thread);
+ if (!chatWindow) {
+ return [['unlink']];
+ }
+ return [['replace', chatWindow]];
+ }
+
+ /**
+ * @private
+ * @returns {integer}
+ */
+ _computeUnreadHiddenConversationAmount() {
+ const allHiddenWithThread = this.allOrderedHidden.filter(
+ chatWindow => chatWindow.thread
+ );
+ let amount = 0;
+ for (const chatWindow of allHiddenWithThread) {
+ if (chatWindow.thread.message_unread_counter > 0) {
+ amount++;
+ }
+ }
+ return amount;
+ }
+
+ /**
+ * @private
+ * @returns {Object}
+ */
+ _computeVisual() {
+ let visual = JSON.parse(JSON.stringify(BASE_VISUAL));
+ if (!this.env.messaging) {
+ return visual;
+ }
+ const device = this.env.messaging.device;
+ const discuss = this.env.messaging.discuss;
+ const BETWEEN_GAP_WIDTH = 5;
+ const CHAT_WINDOW_WIDTH = 325;
+ const END_GAP_WIDTH = device.isMobile ? 0 : 10;
+ const GLOBAL_WINDOW_WIDTH = device.globalWindowInnerWidth;
+ const HIDDEN_MENU_WIDTH = 200; // max width, including width of dropup list items
+ const START_GAP_WIDTH = device.isMobile ? 0 : 10;
+ const chatWindows = this.allOrdered;
+ if (!device.isMobile && discuss.isOpen) {
+ return visual;
+ }
+ if (!chatWindows.length) {
+ return visual;
+ }
+ const relativeGlobalWindowWidth = GLOBAL_WINDOW_WIDTH - START_GAP_WIDTH - END_GAP_WIDTH;
+ let maxAmountWithoutHidden = Math.floor(
+ relativeGlobalWindowWidth / (CHAT_WINDOW_WIDTH + BETWEEN_GAP_WIDTH));
+ let maxAmountWithHidden = Math.floor(
+ (relativeGlobalWindowWidth - HIDDEN_MENU_WIDTH - BETWEEN_GAP_WIDTH) /
+ (CHAT_WINDOW_WIDTH + BETWEEN_GAP_WIDTH));
+ if (device.isMobile) {
+ maxAmountWithoutHidden = 1;
+ maxAmountWithHidden = 1;
+ }
+ if (chatWindows.length <= maxAmountWithoutHidden) {
+ // all visible
+ for (let i = 0; i < chatWindows.length; i++) {
+ const chatWindowLocalId = chatWindows[i].localId;
+ const offset = START_GAP_WIDTH + i * (CHAT_WINDOW_WIDTH + BETWEEN_GAP_WIDTH);
+ visual.visible.push({ chatWindowLocalId, offset });
+ }
+ visual.availableVisibleSlots = maxAmountWithoutHidden;
+ } else if (maxAmountWithHidden > 0) {
+ // some visible, some hidden
+ for (let i = 0; i < maxAmountWithHidden; i++) {
+ const chatWindowLocalId = chatWindows[i].localId;
+ const offset = START_GAP_WIDTH + i * (CHAT_WINDOW_WIDTH + BETWEEN_GAP_WIDTH);
+ visual.visible.push({ chatWindowLocalId, offset });
+ }
+ if (chatWindows.length > maxAmountWithHidden) {
+ visual.hidden.isVisible = !device.isMobile;
+ visual.hidden.offset = visual.visible[maxAmountWithHidden - 1].offset
+ + CHAT_WINDOW_WIDTH + BETWEEN_GAP_WIDTH;
+ }
+ for (let j = maxAmountWithHidden; j < chatWindows.length; j++) {
+ visual.hidden.chatWindowLocalIds.push(chatWindows[j].localId);
+ }
+ visual.availableVisibleSlots = maxAmountWithHidden;
+ } else {
+ // all hidden
+ visual.hidden.isVisible = !device.isMobile;
+ visual.hidden.offset = START_GAP_WIDTH;
+ visual.hidden.chatWindowLocalIds.concat(chatWindows.map(chatWindow => chatWindow.localId));
+ console.warn('cannot display any visible chat windows (screen is too small)');
+ visual.availableVisibleSlots = 0;
+ }
+ return visual;
+ }
+
+ }
+
+ ChatWindowManager.fields = {
+ /**
+ * List of ordered chat windows (list of local ids)
+ */
+ _ordered: attr({
+ compute: '_compute_ordered',
+ default: [],
+ dependencies: [
+ 'chatWindows',
+ ],
+ }),
+ // FIXME: dependent on implementation that uses arbitrary order in relations!!
+ allOrdered: one2many('mail.chat_window', {
+ compute: '_computeAllOrdered',
+ dependencies: [
+ '_ordered',
+ ],
+ }),
+ allOrderedThread: one2many('mail.thread', {
+ related: 'allOrdered.thread',
+ }),
+ allOrderedHidden: one2many('mail.chat_window', {
+ compute: '_computeAllOrderedHidden',
+ dependencies: ['visual'],
+ }),
+ allOrderedHiddenThread: one2many('mail.thread', {
+ related: 'allOrderedHidden.thread',
+ }),
+ allOrderedHiddenThreadMessageUnreadCounter: attr({
+ related: 'allOrderedHiddenThread.message_unread_counter',
+ }),
+ allOrderedVisible: one2many('mail.chat_window', {
+ compute: '_computeAllOrderedVisible',
+ dependencies: ['visual'],
+ }),
+ chatWindows: one2many('mail.chat_window', {
+ inverse: 'manager',
+ isCausal: true,
+ }),
+ device: one2one('mail.device', {
+ related: 'messaging.device',
+ }),
+ deviceGlobalWindowInnerWidth: attr({
+ related: 'device.globalWindowInnerWidth',
+ }),
+ deviceIsMobile: attr({
+ related: 'device.isMobile',
+ }),
+ discuss: one2one('mail.discuss', {
+ related: 'messaging.discuss',
+ }),
+ discussIsOpen: attr({
+ related: 'discuss.isOpen',
+ }),
+ hasHiddenChatWindows: attr({
+ compute: '_computeHasHiddenChatWindows',
+ dependencies: ['allOrderedHidden'],
+ }),
+ hasVisibleChatWindows: attr({
+ compute: '_computeHasVisibleChatWindows',
+ dependencies: ['allOrderedVisible'],
+ }),
+ isHiddenMenuOpen: attr({
+ default: false,
+ }),
+ lastVisible: many2one('mail.chat_window', {
+ compute: '_computeLastVisible',
+ dependencies: ['allOrderedVisible'],
+ }),
+ messaging: one2one('mail.messaging', {
+ inverse: 'chatWindowManager',
+ }),
+ newMessageChatWindow: one2one('mail.chat_window', {
+ compute: '_computeNewMessageChatWindow',
+ dependencies: ['allOrderedThread'],
+ }),
+ unreadHiddenConversationAmount: attr({
+ compute: '_computeUnreadHiddenConversationAmount',
+ dependencies: ['allOrderedHiddenThreadMessageUnreadCounter'],
+ }),
+ visual: attr({
+ compute: '_computeVisual',
+ default: BASE_VISUAL,
+ dependencies: [
+ 'allOrdered',
+ 'deviceGlobalWindowInnerWidth',
+ 'deviceIsMobile',
+ 'discussIsOpen',
+ ],
+ }),
+ };
+
+ ChatWindowManager.modelName = 'mail.chat_window_manager';
+
+ return ChatWindowManager;
+}
+
+registerNewModel('mail.chat_window_manager', factory);
+
+});
diff --git a/addons/mail/static/src/models/chatter/chatter.js b/addons/mail/static/src/models/chatter/chatter.js
new file mode 100644
index 00000000000..fc50dc4d3ef
--- /dev/null
+++ b/addons/mail/static/src/models/chatter/chatter.js
@@ -0,0 +1,326 @@
+odoo.define('mail/static/src/models/chatter/chatter.js', function (require) {
+'use strict';
+
+const { registerNewModel } = require('mail/static/src/model/model_core.js');
+const { attr, one2many, one2one } = require('mail/static/src/model/model_field.js');
+
+function factory(dependencies) {
+
+ const getThreadNextTemporaryId = (function () {
+ let tmpId = 0;
+ return () => {
+ tmpId -= 1;
+ return tmpId;
+ };
+ })();
+
+ const getMessageNextTemporaryId = (function () {
+ let tmpId = 0;
+ return () => {
+ tmpId -= 1;
+ return tmpId;
+ };
+ })();
+
+ class Chatter extends dependencies['mail.model'] {
+
+ /**
+ * @override
+ */
+ delete() {
+ this._stopAttachmentsLoading();
+ super.delete();
+ }
+
+ //----------------------------------------------------------------------
+ // Public
+ //----------------------------------------------------------------------
+
+ async refresh() {
+ const thread = this.thread;
+ if (!thread || thread.isTemporary) {
+ return;
+ }
+ thread.loadNewMessages();
+ if (!this._isPreparingAttachmentsLoading && !this.isShowingAttachmentsLoading) {
+ this._prepareAttachmentsLoading();
+ }
+ await thread.fetchAttachments();
+ this._stopAttachmentsLoading();
+ }
+
+ async refreshActivities() {
+ // A bit "extreme", may be improved
+ const [{ activity_ids: newActivityIds }] = await this.async(() => this.env.services.rpc({
+ model: this.thread.model,
+ method: 'read',
+ args: [this.thread.id, ['activity_ids']]
+ }));
+ const activitiesData = await this.async(() => this.env.services.rpc({
+ model: 'mail.activity',
+ method: 'activity_format',
+ args: [newActivityIds]
+ }));
+ const activities = [];
+ for (const activityData of activitiesData) {
+ const activity = this.env.models['mail.activity'].insert(
+ this.env.models['mail.activity'].convertData(activityData)
+ );
+ activities.push(activity);
+ }
+ this.update({ activities: [['replace', activities]] });
+ }
+
+ showLogNote() {
+ this.update({
+ isComposerLog: true,
+ isComposerVisible: true,
+ });
+ }
+
+ showSendMessage() {
+ this.update({
+ isComposerLog: false,
+ isComposerVisible: true,
+ });
+ }
+
+ toggleActivityBoxVisibility() {
+ this.update({ isActivityBoxVisible: !this.isActivityBoxVisible });
+ }
+
+ //----------------------------------------------------------------------
+ // Private
+ //----------------------------------------------------------------------
+
+ /**
+ * @private
+ * @returns {mail.activity[]}
+ */
+ _computeFutureActivities() {
+ return [['replace', this.activities.filter(activity => activity.state === 'planned')]];
+ }
+
+ /**
+ * @private
+ * @returns {boolean}
+ */
+ _computeIsDisabled() {
+ return !this.threadId;
+ }
+
+ /**
+ * @private
+ * @returns {mail.activity[]}
+ */
+ _computeOverdueActivities() {
+ return [['replace', this.activities.filter(activity => activity.state === 'overdue')]];
+ }
+
+ /**
+ * @private
+ * @returns {mail.activity[]}
+ */
+ _computeTodayActivities() {
+ return [['replace', this.activities.filter(activity => activity.state === 'today')]];
+ }
+
+ /**
+ * @private
+ */
+ _prepareAttachmentsLoading() {
+ this._isPreparingAttachmentsLoading = true;
+ this._attachmentsLoaderTimeout = this.env.browser.setTimeout(() => {
+ this.update({isShowingAttachmentsLoading: true});
+ this._isPreparingAttachmentsLoading = false;
+ }, this.env.loadingBaseDelayDuration);
+ }
+
+ /**
+ * @private
+ */
+ _stopAttachmentsLoading() {
+ this.env.browser.clearTimeout(this._attachmentsLoaderTimeout);
+ this._attachmentsLoaderTimeout = null;
+ this.update({isShowingAttachmentsLoading: false});
+ this._isPreparingAttachmentsLoading = false;
+ }
+
+ /**
+ * @override
+ */
+ _updateAfter(previous) {
+ // thread
+ if (
+ this.threadModel !== previous.threadModel ||
+ this.threadId !== previous.threadId
+ ) {
+ // change of thread
+ this._updateRelationThread();
+ if (previous.thread && previous.thread.isTemporary) {
+ // AKU FIXME: make dedicated models for "temporary" threads,
+ // so that it auto-handles causality of messages for deletion
+ // automatically
+ const oldMainThreadCache = previous.thread.mainCache;
+ const message = oldMainThreadCache.messages[0];
+ message.delete();
+ previous.thread.delete();
+ }
+ }
+
+ if (previous.activityIds.join(',') !== this.activityIds.join(',')) {
+ this.refreshActivities();
+ }
+ if (
+ previous.followerIds.join(',') !== this.followerIds.join(',') &&
+ !this.thread.isTemporary
+ ) {
+ this.thread.refreshFollowers();
+ }
+ if (
+ previous.thread !== this.thread ||
+ (this.thread && this.messageIds.join(',') !== previous.messageIds.join(','))
+ ) {
+ this.refresh();
+ }
+ }
+
+ /**
+ * @override
+ */
+ _updateBefore() {
+ return {
+ activityIds: this.activityIds,
+ followerIds: this.followerIds,
+ messageIds: this.messageIds,
+ threadModel: this.threadModel,
+ threadId: this.threadId,
+ thread: this.thread,
+ };
+ }
+
+ /**
+ * @private
+ */
+ _updateRelationThread() {
+ if (!this.threadId) {
+ if (this.thread && this.thread.isTemporary) {
+ return;
+ }
+ const nextId = getThreadNextTemporaryId();
+ const thread = this.env.models['mail.thread'].create({
+ areAttachmentsLoaded: true,
+ id: nextId,
+ isTemporary: true,
+ model: this.threadModel,
+ });
+ const currentPartner = this.env.messaging.currentPartner;
+ const message = this.env.models['mail.message'].create({
+ author: [['link', currentPartner]],
+ body: this.env._t("Creating a new record..."),
+ id: getMessageNextTemporaryId(),
+ isTemporary: true,
+ });
+ this.threadViewer.update({ thread: [['link', thread]] });
+ for (const cache of thread.caches) {
+ cache.update({ messages: [['link', message]] });
+ }
+ } else {
+ // thread id and model
+ const thread = this.env.models['mail.thread'].insert({
+ id: this.threadId,
+ model: this.threadModel,
+ });
+ this.threadViewer.update({ thread: [['link', thread]] });
+ }
+ }
+
+ }
+
+ Chatter.fields = {
+ activities: one2many('mail.activity', {
+ inverse: 'chatter',
+ }),
+ activityIds: attr({
+ default: [],
+ }),
+ activitiesState: attr({
+ related: 'activities.state',
+ }),
+ context: attr({
+ default: {},
+ }),
+ followerIds: attr({
+ default: [],
+ }),
+ futureActivities: one2many('mail.activity', {
+ compute: '_computeFutureActivities',
+ dependencies: ['activitiesState'],
+ }),
+ hasActivities: attr({
+ default: true,
+ }),
+ hasExternalBorder: attr({
+ default: true,
+ }),
+ hasFollowers: attr({
+ default: true,
+ }),
+ hasThread: attr({
+ default: true,
+ }),
+ hasTopbarCloseButton: attr({
+ default: false,
+ }),
+ isActivityBoxVisible: attr({
+ default: true,
+ }),
+ isAttachmentBoxVisible: attr({
+ default: false,
+ }),
+ isComposerLog: attr({
+ default: true,
+ }),
+ isComposerVisible: attr({
+ default: false,
+ }),
+ isDisabled: attr({
+ compute: '_computeIsDisabled',
+ default: false,
+ dependencies: ['threadId'],
+ }),
+ isShowingAttachmentsLoading: attr({
+ default: false,
+ }),
+ messageIds: attr({
+ default: [],
+ }),
+ overdueActivities: one2many('mail.activity', {
+ compute: '_computeOverdueActivities',
+ dependencies: ['activitiesState'],
+ }),
+ thread: one2one('mail.thread', {
+ related: 'threadViewer.thread',
+ }),
+ threadAttachmentCount: attr({
+ default: 0,
+ }),
+ threadId: attr(),
+ threadModel: attr(),
+ threadViewer: one2one('mail.thread_viewer', {
+ autocreate: true,
+ }),
+ todayActivities: one2many('mail.activity', {
+ compute: '_computeTodayActivities',
+ dependencies: ['activitiesState'],
+ }),
+ };
+
+ Chatter.modelName = 'mail.chatter';
+
+ return Chatter;
+}
+
+registerNewModel('mail.chatter', factory);
+
+});
diff --git a/addons/mail/static/src/models/composer/composer.js b/addons/mail/static/src/models/composer/composer.js
new file mode 100644
index 00000000000..343e0d372c0
--- /dev/null
+++ b/addons/mail/static/src/models/composer/composer.js
@@ -0,0 +1,593 @@
+odoo.define('mail/static/src/models/composer/composer.js', function (require) {
+'use strict';
+
+const emojis = require('mail.emojis');
+const { registerNewModel } = require('mail/static/src/model/model_core.js');
+const { attr, many2many, many2one, one2one } = require('mail/static/src/model/model_field.js');
+
+const {
+ addLink,
+ escapeAndCompactTextContent,
+ parseAndTransform,
+} = require('mail.utils');
+
+function factory(dependencies) {
+
+ class Composer extends dependencies['mail.model'] {
+
+ //----------------------------------------------------------------------
+ // Public
+ //----------------------------------------------------------------------
+
+ closeMentionSuggestions() {
+ this.update({
+ activeSuggestedPartner: [['unlink']],
+ extraSuggestedPartners: [['unlink-all']],
+ mainSuggestedPartners: [['unlink-all']],
+ });
+ }
+
+ discard() {
+ const discuss = this.env.messaging.discuss;
+ const thread = this.thread;
+ if (
+ !discuss.isOpen ||
+ discuss.thread !== thread ||
+ !discuss.isReplyingToMessage
+ ) {
+ return;
+ }
+ discuss.clearReplyingToMessage();
+ }
+
+ focus() {
+ this.update({ isDoFocus: true });
+ }
+
+ /**
+ * Inserts text content in text input based on selection.
+ *
+ * @param {string} content
+ */
+ insertIntoTextInput(content) {
+ const partA = this.textInputContent.slice(0, this.textInputCursorStart);
+ const partB = this.textInputContent.slice(
+ this.textInputCursorEnd,
+ this.textInputContent.length
+ );
+ this.update({
+ textInputContent: partA + content + partB,
+ textInputCursorStart: this.textInputCursorStart + content.length,
+ textInputCursorEnd: this.textInputCursorStart + content.length,
+ });
+ }
+
+ /**
+ * @param {mail.partner} partner
+ */
+ insertMentionedPartner(partner) {
+ const cursorPosition = this.textInputCursorStart;
+ const textLeft = this.textInputContent.substring(
+ 0,
+ this.textInputContent.substring(0, cursorPosition).lastIndexOf('@') + 1
+ );
+ const textRight = this.textInputContent.substring(
+ cursorPosition,
+ this.textInputContent.length
+ );
+ const partnerName = partner.name.replace(/ /g, '\u00a0');
+ this.update({
+ mentionedPartners: [['link', partner]],
+ textInputContent: textLeft + partnerName + ' ' + textRight,
+ textInputCursorEnd: textLeft.length + partnerName.length + 1,
+ textInputCursorStart: textLeft.length + partnerName.length + 1,
+ });
+ }
+
+ /**
+ * Post a message in provided composer's thread with given data.
+ *
+ * @param {Object} data
+ * @param {string[]} data.attachments
+ * @param {*[]} data.canned_response_ids
+ * @param {integer[]} data.channel_ids
+ * @param {boolean} [data.isLog=false]
+ * @param {string} data.subject
+ * @param {integer} [data.subtype_id]
+ * @param {string} [data.subtype_xmlid='mail.mt_comment']
+ * @param {Object} [options]
+ * @param {integer} options.res_id
+ * @param {string} options.res_model
+ */
+ async postMessage(data, options) {
+ const thread = this.thread;
+ this.thread.unregisterCurrentPartnerIsTyping({ immediateNotify: true });
+ if (thread.model === 'mail.box') {
+ const { res_id, res_model } = options;
+ const otherThread = this.env.models['mail.thread'].find(thread =>
+ thread.id === res_id &&
+ thread.model === res_model
+ );
+ return otherThread.composer.postMessage(Object.assign({}, data));
+ }
+ const {
+ canned_response_ids,
+ channel_ids = [],
+ context,
+ isLog = false,
+ subject,
+ subtype_id,
+ // subtype_xmlid='mail.mt_comment',
+ } = data;
+ const escapedAndCompactContent = escapeAndCompactTextContent(this.textInputContent);
+ let body = escapedAndCompactContent.replace(/&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,
+ partner_ids: this.mentionedPartners.map(partner => partner.id),
+ message_type: 'comment',
+ };
+ let messageId;
+ if (thread.model === 'mail.channel') {
+ const command = this._getCommandFromText(body);
+ Object.assign(postData, {
+ command,
+ subtype_xmlid: 'mail.mt_comment'
+ });
+ messageId = await this.async(() => this.env.services.rpc({
+ model: 'mail.channel',
+ method: command ? 'execute_command' : 'message_post',
+ args: [thread.id],
+ kwargs: postData
+ }));
+ } else {
+ Object.assign(postData, {
+ channel_ids: channel_ids.map(channelId => [4, channelId, false]),
+ canned_response_ids
+ });
+ if (subject) {
+ postData.subject = subject;
+ }
+ Object.assign(postData, {
+ context,
+ subtype_id,
+ subtype_xmlid: isLog ? 'mail.mt_note' : 'mail.mt_comment',
+ });
+ messageId = await this.async(() => this.env.services.rpc({
+ model: thread.model,
+ method: 'message_post',
+ args: [thread.id],
+ kwargs: postData
+ }));
+ const [messageData] = await this.async(() => this.env.services.rpc({
+ model: 'mail.message',
+ method: 'message_format',
+ args: [[messageId]]
+ }));
+ this.env.models['mail.message'].insert(Object.assign(
+ {},
+ this.env.models['mail.message'].convertData(messageData),
+ {
+ originThread: [['insert', {
+ id: thread.id,
+ model: thread.model,
+ }]],
+ })
+ );
+ thread.loadNewMessages();
+ }
+ for (const threadViewer of this.thread.viewers) {
+ threadViewer.addComponentHint('current-partner-just-posted-message', messageId);
+ }
+ this._reset();
+ }
+
+ /**
+ * Called when current partner is inserting some input in composer.
+ * Useful to notify current partner is currently typing something in the
+ * composer of this thread to all other members.
+ */
+ handleCurrentPartnerIsTyping() {
+ if (!this.thread) {
+ return;
+ }
+ if (this.thread.typingMembers.includes(this.env.messaging.currentPartner)) {
+ this.thread.refreshCurrentPartnerIsTyping();
+ } else {
+ this.thread.registerCurrentPartnerIsTyping();
+ }
+ }
+
+ setFirstSuggestedPartnerActive() {
+ if (!this.allSuggestedPartners[0]) {
+ return;
+ }
+ this.update({
+ activeSuggestedPartner: [['link', this.allSuggestedPartners[0]]],
+ });
+ }
+
+ setLastSuggestedPartnerActive() {
+ if (this.allSuggestedPartners.length === 0) {
+ return;
+ }
+ this.update({
+ activeSuggestedPartner: [[
+ 'link',
+ this.allSuggestedPartners[this.allSuggestedPartners.length - 1]
+ ]],
+ });
+ }
+
+ setNextSuggestedPartnerActive() {
+ if (this.allSuggestedPartners.length === 0) {
+ return;
+ }
+ const activeSuggestedPartnerIndex = this.allSuggestedPartners.findIndex(
+ suggestedPartner => suggestedPartner === this.activeSuggestedPartner
+ );
+ if (activeSuggestedPartnerIndex !== this.allSuggestedPartners.length - 1) {
+ this.update({
+ activeSuggestedPartner: [[
+ 'link',
+ this.allSuggestedPartners[activeSuggestedPartnerIndex + 1]
+ ]],
+ });
+ } else {
+ this.update({
+ activeSuggestedPartner: [['link', this.allSuggestedPartners[0]]],
+ });
+ }
+ }
+
+ setPreviousSuggestedPartnerActive() {
+ if (this.allSuggestedPartners.length === 0) {
+ return;
+ }
+ const activeSuggestedPartnerIndex = this.allSuggestedPartners.findIndex(
+ suggestedPartner => suggestedPartner === this.activeSuggestedPartner
+ );
+ if (activeSuggestedPartnerIndex === -1) {
+ this.update({
+ activeSuggestedPartner: [['link', this.allSuggestedPartners[0]]]
+ });
+ } else if (activeSuggestedPartnerIndex !== 0) {
+ this.update({
+ activeSuggestedPartner: [[
+ 'link',
+ this.allSuggestedPartners[activeSuggestedPartnerIndex - 1]
+ ]],
+ });
+ } else {
+ this.update({
+ activeSuggestedPartner: [[
+ 'link',
+ this.allSuggestedPartners[this.allSuggestedPartners.length - 1]
+ ]],
+ });
+ }
+ }
+
+ //----------------------------------------------------------------------
+ // Private
+ //----------------------------------------------------------------------
+
+ /**
+ * @private
+ * @returns {mail.partner[]}
+ */
+ _computeAllSuggestedPartners() {
+ return [['replace', this.mainSuggestedPartners.concat(this.extraSuggestedPartners)]];
+ }
+
+ /**
+ * @private
+ * @returns {boolean}
+ */
+ _computeCanPostMessage() {
+ return this.textInputContent || this.attachments.length !== 0;
+ }
+
+ /**
+ * Ensure extraSuggestedPartners does not contain any partner already
+ * present in mainSuggestedPartners. This is necessary for the
+ * consistency of suggestion list because allSuggestedPartners is the
+ * union of both but without duplicates (because it is a relational
+ * field).
+ *
+ * @private
+ * @returns {mail.partner[]}
+ */
+ _computeExtraSuggestedPartners() {
+ return [['unlink', this.mainSuggestedPartners]];
+ }
+
+ /**
+ * @private
+ * @return {boolean}
+ */
+ _computeHasSuggestedPartners() {
+ return this.allSuggestedPartners.length > 0;
+ }
+
+ /**
+ * Detects if mentioned partners are still in the composer text input content
+ * and removes them if not.
+ *
+ * @private
+ * @returns {mail.partner[]}
+ */
+ _computeMentionedPartners() {
+ const inputMentions = this.textInputContent.match(
+ new RegExp("@[^ ]+(?= |&nbsp;|$)", 'g')
+ ) || [];
+ const unmentionedPartners = [];
+ for (const partner of this.mentionedPartners) {
+ let inputMention = inputMentions.find(item => {
+ return item === ("@" + partner.name).replace(/ /g, '\u00a0');
+ });
+ if (!inputMention) {
+ unmentionedPartners.push(partner);
+ }
+ }
+ return [['unlink', unmentionedPartners]];
+ }
+
+ /**
+ * Detects if mentions suggestions should be displayed when user is typing
+ * and searches partners based on user's research.
+ *
+ * @private
+ */
+ _detectDelimiter() {
+ const mentionKeyword = this._validateMentionKeyword('@', false);
+ if (mentionKeyword !== false) {
+ this._getSuggestedPartners(mentionKeyword);
+ } else {
+ this.closeMentionSuggestions();
+ }
+ }
+
+ /**
+ * @private
+ * @param {string} htmlString
+ * @returns {string}
+ */
+ _generateEmojisOnHtml(htmlString) {
+ for (const emoji of emojis) {
+ for (const source of emoji.sources) {
+ const escapedSource = String(source).replace(
+ /([.*+?=^!:${}()|[\]/\\])/g,
+ '\\$1');
+ const regexp = new RegExp(
+ '(\\s|^)(' + escapedSource + ')(?=\\s|$)',
+ 'g');
+ htmlString = htmlString.replace(regexp, '$1' + emoji.unicode);
+ }
+ }
+ return htmlString;
+ }
+
+ /**
+ *
+ * Generates the html link related to the mentioned partner
+ *
+ * @private
+ * @param {string} body
+ * @returns {string}
+ */
+ _generateMentionsLinks(body) {
+ if (this.mentionedPartners.length === 0) {
+ return body;
+ }
+ const inputMentions = body.match(new RegExp("@"+'[^ ]+(?= |&nbsp;|$)', 'g'));
+ const substrings = [];
+ let startIndex = 0;
+ for (const i in inputMentions) {
+ const match = inputMentions[i];
+ const matchName = owl.utils.escape(match.substring(1).replace(new RegExp('\u00a0', 'g'), ' '));
+ const endIndex = body.indexOf(match, startIndex) + match.length;
+ const partner = this.mentionedPartners.find(partner =>
+ partner.name === matchName
+ );
+ let mentionLink = "@" + matchName;
+ if (partner) {
+ const baseHREF = this.env.session.url('/web');
+ const href = `href='${baseHREF}#model=res.partner&id=${partner.id}'`;
+ const attClass = `class='o_mail_redirect'`;
+ const dataOeId = `data-oe-id='${partner.id}'`;
+ const dataOeModel = `data-oe-model='res.partner'`;
+ const target = `target='_blank'`;
+ mentionLink = `<a ${href} ${attClass} ${dataOeId} ${dataOeModel} ${target} >@${matchName}</a>`;
+ }
+ substrings.push(body.substring(startIndex, body.indexOf(match, startIndex)));
+ substrings.push(mentionLink);
+ startIndex = endIndex;
+ }
+ substrings.push(body.substring(startIndex, body.length));
+ return substrings.join('');
+ }
+
+ /**
+ * @private
+ * @param {string} content html content
+ * @returns {string|undefined} command, if any in the content
+ */
+ _getCommandFromText(content) {
+ if (content.startsWith('/')) {
+ return content.substring(1).split(/\s/)[0];
+ }
+ return undefined;
+ }
+
+ /**
+ * @private
+ * @param {string} mentionKeyword
+ */
+ async _getSuggestedPartners(mentionKeyword) {
+ const mentions = await this.async(() => this.env.services.rpc(
+ {
+ model: 'res.partner',
+ method: 'get_mention_suggestions',
+ kwargs: {
+ limit: 8,
+ search: mentionKeyword,
+ },
+ },
+ { shadow: true }
+ ));
+
+ const mainSuggestedPartners = mentions[0];
+ const extraSuggestedPartners = mentions[1];
+ this.update({
+ extraSuggestedPartners: [[
+ 'insert-and-replace',
+ extraSuggestedPartners.map(data =>
+ this.env.models['mail.partner'].convertData(data)
+ )
+ ]],
+ mainSuggestedPartners: [[
+ 'insert-and-replace',
+ mainSuggestedPartners.map(data =>
+ this.env.models['mail.partner'].convertData(data))
+ ]],
+ });
+
+ if (this.allSuggestedPartners[0]) {
+ this.update({
+ activeSuggestedPartner: [['link', this.allSuggestedPartners[0]]],
+ });
+ } else {
+ this.update({
+ activeSuggestedPartner: [['unlink']],
+ });
+ }
+ }
+
+ /**
+ * @private
+ */
+ _reset() {
+ this.closeMentionSuggestions();
+ this.update({
+ attachments: [['unlink-all']],
+ mentionedPartners: [['unlink-all']],
+ textInputContent: '',
+ textInputCursorStart: 0,
+ textInputCursorEnd: 0,
+ });
+ }
+
+ /**
+ * Validates user's current typing as a correct mention keyword
+ * in order to trigger mentions suggestions display.
+ * Returns the mention keyword without the delimiter if it has been validated
+ * and false if not
+ *
+ * @private
+ * @param {string} delimiter
+ * @param {boolean} beginningOnly
+ * @returns {string|boolean}
+ */
+ _validateMentionKeyword(delimiter, beginningOnly) {
+ const leftString = this.textInputContent.substring(0, this.textInputCursorStart);
+
+ // use position before delimiter because there should be whitespaces
+ // or line feed/carriage return before the delimiter
+ const beforeDelimiterPosition = leftString.lastIndexOf(delimiter) - 1;
+ if (beginningOnly && beforeDelimiterPosition > 0) {
+ return false;
+ }
+ let searchStr = this.textInputContent.substring(
+ beforeDelimiterPosition,
+ this.textInputCursorStart
+ );
+ // regex string start with delimiter or whitespace then delimiter
+ const pattern = "^"+delimiter+"|^\\s"+delimiter;
+ const regexStart = new RegExp(pattern, 'g');
+ // trim any left whitespaces or the left line feed/ carriage return
+ // at the beginning of the string
+ searchStr = searchStr.replace(/^\s\s*|^[\n\r]/g, '');
+ if (regexStart.test(searchStr) && searchStr.length) {
+ searchStr = searchStr.replace(pattern, '');
+ return !searchStr.includes(' ') && !/[\r\n]/.test(searchStr)
+ ? searchStr.replace(delimiter, '')
+ : false;
+ }
+ return false;
+ }
+ }
+
+ Composer.fields = {
+ activeSuggestedPartner: many2one('mail.partner'),
+ allSuggestedPartners: many2many('mail.partner', {
+ compute: '_computeAllSuggestedPartners',
+ dependencies: [
+ 'extraSuggestedPartners',
+ 'mainSuggestedPartners',
+ ],
+ }),
+ attachments: many2many('mail.attachment', {
+ inverse: 'composers',
+ }),
+ canPostMessage: attr({
+ compute: '_computeCanPostMessage',
+ dependencies: [
+ 'attachments',
+ 'textInputContent',
+ ],
+ default: false,
+ }),
+ extraSuggestedPartners: many2many('mail.partner', {
+ compute: '_computeExtraSuggestedPartners',
+ dependencies: [
+ 'extraSuggestedPartners',
+ 'mainSuggestedPartners',
+ ],
+ }),
+ hasSuggestedPartners: attr({
+ compute: '_computeHasSuggestedPartners',
+ dependencies: [
+ 'allSuggestedPartners',
+ ],
+ default: false,
+ }),
+ isDoFocus: attr({
+ default: false,
+ }),
+ mainSuggestedPartners: many2many('mail.partner'),
+ mentionedPartners: many2many('mail.partner', {
+ compute: '_computeMentionedPartners',
+ dependencies: ['textInputContent'],
+ }),
+ textInputContent: attr({
+ default: "",
+ }),
+ textInputCursorStart: attr({
+ default: 0,
+ }),
+ textInputCursorEnd: attr({
+ default: 0,
+ }),
+ thread: one2one('mail.thread', {
+ inverse: 'composer',
+ }),
+ };
+
+ Composer.modelName = 'mail.composer';
+
+ return Composer;
+}
+
+registerNewModel('mail.composer', factory);
+
+});
diff --git a/addons/mail/static/src/models/country/country.js b/addons/mail/static/src/models/country/country.js
new file mode 100644
index 00000000000..8fe6b282a31
--- /dev/null
+++ b/addons/mail/static/src/models/country/country.js
@@ -0,0 +1,23 @@
+odoo.define('mail/static/src/models/country/country.js', function (require) {
+'use strict';
+
+const { registerNewModel } = require('mail/static/src/model/model_core.js');
+const { attr } = require('mail/static/src/model/model_field.js');
+
+function factory(dependencies) {
+
+ class Country extends dependencies['mail.model'] {}
+
+ Country.fields = {
+ id: attr(),
+ name: attr(),
+ };
+
+ Country.modelName = 'mail.country';
+
+ return Country;
+}
+
+registerNewModel('mail.country', factory);
+
+});
diff --git a/addons/mail/static/src/models/device/device.js b/addons/mail/static/src/models/device/device.js
new file mode 100644
index 00000000000..d3aba92aac4
--- /dev/null
+++ b/addons/mail/static/src/models/device/device.js
@@ -0,0 +1,71 @@
+odoo.define('mail/static/src/models/device/device.js', function (require) {
+'use strict';
+
+const { registerNewModel } = require('mail/static/src/model/model_core.js');
+const { attr } = require('mail/static/src/model/model_field.js');
+
+function factory(dependencies) {
+
+ class Device extends dependencies['mail.model'] {
+
+ /**
+ * @override
+ */
+ static create() {
+ const record = super.create();
+ record._refresh();
+ record._onResize = _.debounce(() => record._refresh(), 100);
+ return record;
+ }
+
+ /**
+ * @override
+ */
+ delete() {
+ window.removeEventListener('resize', this._onResize);
+ super.delete();
+ }
+
+ //----------------------------------------------------------------------
+ // Public
+ //----------------------------------------------------------------------
+
+ /**
+ * Called when messaging is started.
+ */
+ start() {
+ // TODO FIXME Not using this.env.browser because it's proxified, and
+ // addEventListener does not work on proxified window. task-2234596
+ window.addEventListener('resize', this._onResize);
+ }
+
+ //----------------------------------------------------------------------
+ // Private
+ //----------------------------------------------------------------------
+
+ /**
+ * @private
+ */
+ _refresh() {
+ this.update({
+ globalWindowInnerHeight: this.env.browser.innerHeight,
+ globalWindowInnerWidth: this.env.browser.innerWidth,
+ isMobile: this.env.device.isMobile,
+ });
+ }
+ }
+
+ Device.fields = {
+ globalWindowInnerHeight: attr(),
+ globalWindowInnerWidth: attr(),
+ isMobile: attr(),
+ };
+
+ Device.modelName = 'mail.device';
+
+ return Device;
+}
+
+registerNewModel('mail.device', factory);
+
+});
diff --git a/addons/mail/static/src/models/dialog/dialog.js b/addons/mail/static/src/models/dialog/dialog.js
new file mode 100644
index 00000000000..d4b0bcddfb5
--- /dev/null
+++ b/addons/mail/static/src/models/dialog/dialog.js
@@ -0,0 +1,52 @@
+odoo.define('mail/static/src/models/dialog/dialog.js', function (require) {
+'use strict';
+
+const { registerNewModel } = require('mail/static/src/model/model_core.js');
+const { many2one, one2one } = require('mail/static/src/model/model_field.js');
+
+function factory(dependencies) {
+
+ class Dialog extends dependencies['mail.model'] {
+
+ /**
+ * @override
+ */
+ delete() {
+ if (this.manager) {
+ this.manager.unregister(this);
+ }
+ super.delete();
+ }
+
+ //----------------------------------------------------------------------
+ // Public
+ //----------------------------------------------------------------------
+
+ close() {
+ this.delete();
+ }
+
+ }
+
+ Dialog.fields = {
+ manager: many2one('mail.dialog_manager', {
+ inverse: 'dialogs',
+ }),
+ /**
+ * Content of dialog that is directly linked to a record that models
+ * a UI component, such as AttachmentViewer. These records must be
+ * created from @see `mail.dialog_manager:open()`.
+ */
+ record: one2one('mail.model', {
+ isCausal: true,
+ }),
+ };
+
+ Dialog.modelName = 'mail.dialog';
+
+ return Dialog;
+}
+
+registerNewModel('mail.dialog', factory);
+
+});
diff --git a/addons/mail/static/src/models/dialog_manager/dialog_manager.js b/addons/mail/static/src/models/dialog_manager/dialog_manager.js
new file mode 100644
index 00000000000..024172f7cdf
--- /dev/null
+++ b/addons/mail/static/src/models/dialog_manager/dialog_manager.js
@@ -0,0 +1,99 @@
+odoo.define('mail/static/src/models/dialog_manager/dialog_manager.js', function (require) {
+'use strict';
+
+const { registerNewModel } = require('mail/static/src/model/model_core.js');
+const { attr, one2many } = require('mail/static/src/model/model_field.js');
+
+function factory(dependencies) {
+
+ class DialogManager extends dependencies['mail.model'] {
+
+ //----------------------------------------------------------------------
+ // Public
+ //----------------------------------------------------------------------
+
+ /**
+ * @param {mail.dialog} dialog
+ */
+ close(dialog) {
+ this.unregister(dialog);
+ dialog.delete();
+ }
+
+ /**
+ * @param {string} modelName
+ * @param {Object} [recordData]
+ */
+ open(modelName, recordData) {
+ const dialog = this.env.models['mail.dialog'].create({
+ manager: [['link', this]],
+ });
+ if (!modelName) {
+ throw new Error("Dialog should have a link to a model");
+ }
+ const Model = this.env.models[modelName];
+ if (!Model) {
+ throw new Error(`No model exists with name ${modelName}`);
+ }
+ const record = Model.create(recordData);
+ dialog.update({ record: [['link', record]] });
+ this.update({ _ordered: this._ordered.concat([dialog.localId]) });
+ return dialog;
+ }
+
+ /**
+ * @param {mail.dialog} dialog
+ */
+ unregister(dialog) {
+ if (!this.allOrdered.includes(dialog)) {
+ return;
+ }
+ this.update({
+ _ordered: this._ordered.filter(
+ dialogLocalId => dialogLocalId !== dialog.localId
+ ),
+ dialogs: [['unlink', dialog]],
+ });
+ }
+
+ //----------------------------------------------------------------------
+ // Private
+ //----------------------------------------------------------------------
+
+ /**
+ * FIXME: dependent on implementation that uses arbitrary order in relations!!
+ *
+ * @private
+ * @returns {mail.dialog}
+ */
+ _computeAllOrdered() {
+ return [['replace', this._ordered.map(dialogLocalId =>
+ this.env.models['mail.dialog'].get(dialogLocalId)
+ )]];
+ }
+ }
+
+ DialogManager.fields = {
+ _ordered: attr({ default: [] }),
+ // FIXME: dependent on implementation that uses arbitrary order in relations!!
+ allOrdered: one2many('mail.dialog', {
+ compute: '_computeAllOrdered',
+ dependencies: [
+ '_ordered',
+ 'dialogs',
+ ],
+ }),
+ dialogs: one2many('mail.dialog', {
+ inverse: 'manager',
+ isCausal: true,
+ }),
+ };
+
+ DialogManager.modelName = 'mail.dialog_manager';
+
+ return DialogManager;
+}
+
+registerNewModel('mail.dialog_manager', factory);
+
+});
diff --git a/addons/mail/static/src/models/discuss/discuss.js b/addons/mail/static/src/models/discuss/discuss.js
new file mode 100644
index 00000000000..c5d6c3ecf91
--- /dev/null
+++ b/addons/mail/static/src/models/discuss/discuss.js
@@ -0,0 +1,402 @@
+odoo.define('mail/static/src/models/discuss.discuss.js', function (require) {
+'use strict';
+
+const { registerNewModel } = require('mail/static/src/model/model_core.js');
+const { attr, many2one, one2many, one2one } = require('mail/static/src/model/model_field.js');
+
+function factory(dependencies) {
+
+ class Discuss extends dependencies['mail.model'] {
+
+ //----------------------------------------------------------------------
+ // Public
+ //----------------------------------------------------------------------
+
+ /**
+ * @param {mail.thread} thread
+ */
+ cancelThreadRenaming(thread) {
+ this.update({ renamingThreads: [['unlink', thread]] });
+ }
+
+ clearIsAddingItem() {
+ this.update({
+ addingChannelValue: "",
+ isAddingChannel: false,
+ isAddingChat: false,
+ });
+ }
+
+ clearReplyingToMessage() {
+ this.update({ replyingToMessage: [['unlink-all']] });
+ }
+
+ /**
+ * Close the discuss app. Should reset its internal state.
+ */
+ close() {
+ this.update({ isOpen: false });
+ }
+
+ /**
+ * @param {Event} ev
+ * @param {Object} ui
+ * @param {Object} ui.item
+ * @param {integer} ui.item.id
+ */
+ handleAddChannelAutocompleteSelect(ev, ui) {
+ if (ui.item.special) {
+ this.env.models['mail.thread'].createChannel({
+ autoselect: true,
+ name: this.addingChannelValue,
+ public: ui.item.special,
+ type: 'channel',
+ });
+ } else {
+ this.env.models['mail.thread'].joinChannel(ui.item.id, { autoselect: true });
+ }
+ this.clearIsAddingItem();
+ }
+
+ /**
+ * @param {Object} req
+ * @param {string} req.term
+ * @param {function} res
+ */
+ async handleAddChannelAutocompleteSource(req, res) {
+ const value = req.term;
+ const escapedValue = owl.utils.escape(value);
+ this.update({ addingChannelValue: value });
+ const result = await this.async(() => this.env.services.rpc({
+ model: 'mail.channel',
+ method: 'channel_search_to_join',
+ args: [value],
+ }));
+ const items = result.map(data => {
+ let escapedName = owl.utils.escape(data.name);
+ return Object.assign(data, {
+ label: escapedName,
+ value: escapedName
+ });
+ });
+ // XDU FIXME could use a component but be careful with owl's
+ // renderToString https://github.com/odoo/owl/issues/708
+ items.push({
+ label: _.str.sprintf(
+ `<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) {
+ const partnerId = ui.item.id;
+ const partner = this.env.models['mail.partner'].find(partner =>
+ partner.id === partnerId
+ );
+ const chat = partner.correspondentThreads.find(thread => thread.channel_type === 'chat');
+ if (chat) {
+ this.threadViewer.update({ thread: [['link', chat]] });
+ } else {
+ this.env.models['mail.thread'].createChannel({
+ autoselect: true,
+ partnerId,
+ type: 'chat',
+ });
+ }
+ this.clearIsAddingItem();
+ }
+
+ /**
+ * @param {Object} req
+ * @param {string} req.term
+ * @param {function} res
+ */
+ handleAddChatAutocompleteSource(req, res) {
+ const value = owl.utils.escape(req.term);
+ this.env.models['mail.partner'].imSearch({
+ callback: partners => {
+ const suggestions = partners.map(partner => {
+ return {
+ id: partner.id,
+ value: partner.nameOrDisplayName,
+ label: partner.nameOrDisplayName,
+ };
+ });
+ res(_.sortBy(suggestions, 'label'));
+ },
+ keyword: value,
+ limit: 10,
+ });
+ }
+
+ /**
+ * Open thread from init active id. `initActiveId` is used to refer to
+ * a thread that we may not have full data yet, such as when messaging
+ * is not yet initialized.
+ */
+ openInitThread() {
+ const [model, id] = this.initActiveId.split('_');
+ const thread = this.env.models['mail.thread'].find(thread =>
+ thread.id === (model !== 'mail.box' ? Number(id) : id) &&
+ thread.model === model
+ );
+ if (!thread) {
+ return;
+ }
+ this.threadViewer.update({
+ stringifiedDomain: '[]',
+ thread: [['link', thread]],
+ });
+ thread.open({ resetDiscussDomain: true });
+ }
+
+ /**
+ * @param {mail.thread} thread
+ * @param {string} newName
+ */
+ async renameThread(thread, newName) {
+ await this.async(() => thread.rename(newName));
+ this.update({ renamingThreads: [['unlink', thread]] });
+ }
+
+ /**
+ * @param {mail.thread} thread
+ */
+ setThreadRenaming(thread) {
+ this.update({ renamingThreads: [['link', thread ]] });
+ }
+
+ /**
+ * @param {mail.thread} thread
+ * @returns {string}
+ */
+ threadToActiveId(thread) {
+ return `${thread.model}_${thread.id}`;
+ }
+
+ //----------------------------------------------------------------------
+ // Private
+ //----------------------------------------------------------------------
+
+ /**
+ * @private
+ * @returns {string|undefined}
+ */
+ _computeActiveId() {
+ if (!this.thread) {
+ return undefined;
+ }
+ return this.threadToActiveId(this.thread);
+ }
+
+ /**
+ * @private
+ * @returns {string}
+ */
+ _computeAddingChannelValue() {
+ if (!this.isOpen) {
+ return "";
+ }
+ return this.addingChannelValue;
+ }
+
+ /**
+ * @private
+ * @returns {string}
+ */
+ _computeInitActiveId() {
+ if (!this.isOpen) {
+ return this.defaultInitActiveId;
+ }
+ if (this.thread) {
+ return this.threadToActiveId(this.thread);
+ }
+ return this.initActiveId;
+ }
+
+ /**
+ * @private
+ * @returns {boolean}
+ */
+ _computeIsAddingChannel() {
+ if (!this.isOpen) {
+ return false;
+ }
+ return this.isAddingChannel;
+ }
+
+ /**
+ * @private
+ * @returns {boolean}
+ */
+ _computeIsAddingChat() {
+ if (!this.isOpen) {
+ return false;
+ }
+ return this.isAddingChat;
+ }
+
+ /**
+ * @private
+ * @returns {boolean}
+ */
+ _computeIsReplyingToMessage() {
+ return !!this.replyingToMessage;
+ }
+
+ /**
+ * @private
+ * @returns {mail.message|undefined}
+ */
+ _computeReplyingToMessage() {
+ if (!this.isOpen) {
+ return [['unlink-all']];
+ }
+ return [];
+ }
+
+ }
+
+ Discuss.fields = {
+ activeId: attr({
+ compute: '_computeActiveId',
+ dependencies: [
+ 'thread',
+ 'threadId',
+ 'threadModel',
+ ],
+ }),
+ /**
+ * Active mobile navbar tab, either 'mailbox', 'chat', or 'channel'.
+ */
+ activeMobileNavbarTabId: attr({
+ default: 'mailbox',
+ }),
+ /**
+ * Value that is used to create a channel from the sidebar.
+ */
+ addingChannelValue: attr({
+ compute: '_computeAddingChannelValue',
+ default: "",
+ dependencies: ['isOpen'],
+ }),
+ defaultInitActiveId: attr({
+ default: 'mail.box_inbox',
+ }),
+ /**
+ * Determine if the moderation discard dialog is displayed.
+ */
+ hasModerationDiscardDialog: attr({
+ default: false,
+ }),
+ /**
+ * Determine if the moderation reject dialog is displayed.
+ */
+ hasModerationRejectDialog: attr({
+ default: false,
+ }),
+ /**
+ * Formatted init thread on opening discuss for the first time,
+ * when no active thread is defined. Useful to set a thread to
+ * open without knowing its local id in advance.
+ * format: <threadModel>_<threadId>
+ */
+ initActiveId: attr({
+ compute: '_computeInitActiveId',
+ default: 'mail.box_inbox',
+ dependencies: [
+ 'isOpen',
+ 'thread',
+ 'threadId',
+ 'threadModel',
+ ],
+ }),
+ /**
+ * Determine whether current user is currently adding a channel from
+ * the sidebar.
+ */
+ isAddingChannel: attr({
+ compute: '_computeIsAddingChannel',
+ default: false,
+ dependencies: ['isOpen'],
+ }),
+ /**
+ * Determine whether current user is currently adding a chat from
+ * the sidebar.
+ */
+ isAddingChat: attr({
+ compute: '_computeIsAddingChat',
+ default: false,
+ dependencies: ['isOpen'],
+ }),
+ /**
+ * Whether the discuss app is open or not. Useful to determine
+ * whether the discuss or chat window logic should be applied.
+ */
+ isOpen: attr({
+ default: false,
+ }),
+ isReplyingToMessage: attr({
+ compute: '_computeIsReplyingToMessage',
+ default: false,
+ dependencies: ['replyingToMessage'],
+ }),
+ /**
+ * The menu_id of discuss app, received on mail/init_messaging and
+ * used to open discuss from elsewhere.
+ */
+ menu_id: attr({
+ default: null,
+ }),
+ renamingThreads: one2many('mail.thread'),
+ replyingToMessage: one2one('mail.message', {
+ compute: '_computeReplyingToMessage',
+ dependencies: ['isOpen'],
+ }),
+ /**
+ * Quick search input value in the discuss sidebar (desktop). Useful
+ * to filter channels and chats based on this input content.
+ */
+ sidebarQuickSearchValue: attr({
+ default: "",
+ }),
+ thread: many2one('mail.thread', {
+ related: 'threadViewer.thread',
+ }),
+ threadId: attr({
+ related: 'thread.id',
+ }),
+ threadModel: attr({
+ related: 'thread.model',
+ }),
+ threadViewer: one2one('mail.thread_viewer', {
+ autocreate: true,
+ isCausal: true,
+ }),
+ };
+
+ Discuss.modelName = 'mail.discuss';
+
+ return Discuss;
+}
+
+registerNewModel('mail.discuss', factory);
+
+});
diff --git a/addons/mail/static/src/models/follower/follower.js b/addons/mail/static/src/models/follower/follower.js
new file mode 100644
index 00000000000..78667ed755c
--- /dev/null
+++ b/addons/mail/static/src/models/follower/follower.js
@@ -0,0 +1,265 @@
+odoo.define('mail/static/src/models/follower.follower.js', function (require) {
+'use strict';
+
+const { registerNewModel } = require('mail/static/src/model/model_core.js');
+const { attr, many2many, many2one } = require('mail/static/src/model/model_field.js');
+
+function factory(dependencies) {
+
+ class Follower extends dependencies['mail.model'] {
+
+ //----------------------------------------------------------------------
+ // Public
+ //----------------------------------------------------------------------
+
+ /**
+ * @static
+ * @param {Object} data
+ * @returns {Object}
+ */
+ static convertData(data) {
+ const data2 = {};
+ if ('channel_id' in data) {
+ if (!data.channel_id) {
+ data2.channel = [['unlink-all']];
+ } else {
+ const channelData = {
+ id: data.channel_id,
+ model: 'mail.channel',
+ name: data.name,
+ };
+ data2.channel = [['insert', channelData]];
+ }
+ }
+ if ('id' in data) {
+ data2.id = data.id;
+ }
+ if ('is_active' in data) {
+ data2.isActive = data.is_active;
+ }
+ if ('is_editable' in data) {
+ data2.isEditable = data.is_editable;
+ }
+ if ('partner_id' in data) {
+ if (!data.partner_id) {
+ data2.partner = [['unlink-all']];
+ } else {
+ const partnerData = {
+ email: data.email,
+ id: data.partner_id,
+ name: data.name,
+ };
+ data2.partner = [['insert', partnerData]];
+ }
+ }
+ return data2;
+ }
+
+ /**
+ * Close subtypes dialog
+ */
+ closeSubtypes() {
+ this.env.messaging.dialogManager.close(this._subtypesListDialog);
+ this._subtypesListDialog = undefined;
+ }
+
+ /**
+ * Remove this follower from its related thread.
+ */
+ async remove() {
+ const args = [[this.followedThread.id]];
+ if (this.partner) {
+ args.push([this.partner.id]);
+ } else {
+ args.push([this.channel.id]);
+ }
+ await this.async(() => this.env.services.rpc({
+ model: this.followedThread.model,
+ method: 'message_unsubscribe',
+ args
+ }));
+ this.delete();
+ }
+
+ /**
+ * @param {mail.follower_subtype} subtype
+ */
+ selectSubtype(subtype) {
+ if (!this.selectedSubtypes.includes(subtype)) {
+ this.update({ selectedSubtypes: [['link', subtype]] });
+ }
+ }
+
+ /**
+ * Show (editable) list of subtypes of this follower.
+ */
+ async showSubtypes() {
+ const subtypesData = await this.async(() => this.env.services.rpc({
+ route: '/mail/read_subscription_data',
+ params: { follower_id: this.id },
+ }));
+ this.update({ subtypes: [['unlink-all']] });
+ for (const data of subtypesData) {
+ const subtype = this.env.models['mail.follower_subtype'].insert(
+ this.env.models['mail.follower_subtype'].convertData(data)
+ );
+ this.update({ subtypes: [['link', subtype]] });
+ if (data.followed) {
+ this.update({ selectedSubtypes: [['link', subtype]] });
+ } else {
+ this.update({ selectedSubtypes: [['unlink', subtype]] });
+ }
+ }
+ this._subtypesListDialog = this.env.messaging.dialogManager.open('mail.follower_subtype_list', {
+ follower: [['replace', this]],
+ });
+ }
+
+ /**
+ * @param {mail.follower_subtype} subtype
+ */
+ unselectSubtype(subtype) {
+ if (this.selectedSubtypes.includes(subtype)) {
+ this.update({ selectedSubtypes: [['unlink', subtype]] });
+ }
+ }
+
+ /**
+ * Update server-side subscription of subtypes of this follower.
+ */
+ async updateSubtypes() {
+ if (this.selectedSubtypes.length === 0) {
+ this.remove();
+ } else {
+ const kwargs = {
+ subtype_ids: this.selectedSubtypes.map(subtype => subtype.id),
+ };
+ if (this.partner) {
+ kwargs.partner_ids = [this.partner.id];
+ } else {
+ kwargs.channel_ids = [this.channel.id];
+ }
+ await this.async(() => this.env.services.rpc({
+ model: this.followedThread.model,
+ method: 'message_subscribe',
+ args: [[this.followedThread.id]],
+ kwargs,
+ }));
+ }
+ this.closeSubtypes();
+ }
+
+ //----------------------------------------------------------------------
+ // Private
+ //----------------------------------------------------------------------
+
+ /**
+ * @private
+ * @returns {string}
+ */
+ _computeName() {
+ if (this.channel) {
+ return this.channel.name;
+ }
+ if (this.partner) {
+ return this.partner.name;
+ }
+ return '';
+ }
+
+ /**
+ * @private
+ * @returns {integer}
+ */
+ _computeResId() {
+ if (this.partner) {
+ return this.partner.id;
+ }
+ if (this.channel) {
+ return this.channel.id;
+ }
+ return 0;
+ }
+
+ /**
+ * @private
+ * @returns {string}
+ */
+ _computeResModel() {
+ if (this.partner) {
+ return this.partner.model;
+ }
+ if (this.channel) {
+ return this.channel.model;
+ }
+ return '';
+ }
+
+ }
+
+ Follower.fields = {
+ resId: attr({
+ compute: '_computeResId',
+ default: 0,
+ dependencies: [
+ 'channelId',
+ 'partnerId',
+ ],
+ }),
+ channel: many2one('mail.thread'),
+ channelId: attr({
+ related: 'channel.id',
+ }),
+ channelModel: attr({
+ related: 'channel.model',
+ }),
+ channelName: attr({
+ related: 'channel.name',
+ }),
+ followedThread: many2one('mail.thread', {
+ inverse: 'followers',
+ }),
+ id: attr(),
+ isActive: attr({
+ default: true,
+ }),
+ isEditable: attr({
+ default: false,
+ }),
+ name: attr({
+ compute: '_computeName',
+ dependencies: [
+ 'channelName',
+ 'partnerName',
+ ],
+ }),
+ partner: many2one('mail.partner'),
+ partnerId: attr({
+ related: 'partner.id',
+ }),
+ partnerModel: attr({
+ related: 'partner.model',
+ }),
+ partnerName: attr({
+ related: 'partner.name',
+ }),
+ resModel: attr({
+ compute: '_computeResModel',
+ default: '',
+ dependencies: [
+ 'channelModel',
+ 'partnerModel',
+ ],
+ }),
+ selectedSubtypes: many2many('mail.follower_subtype'),
+ subtypes: many2many('mail.follower_subtype'),
+ };
+
+ Follower.modelName = 'mail.follower';
+
+ return Follower;
+}
+
+registerNewModel('mail.follower', factory);
+
+});
diff --git a/addons/mail/static/src/models/follower_subtype/follower_subtype.js b/addons/mail/static/src/models/follower_subtype/follower_subtype.js
new file mode 100644
index 00000000000..37e65129c61
--- /dev/null
+++ b/addons/mail/static/src/models/follower_subtype/follower_subtype.js
@@ -0,0 +1,71 @@
+odoo.define('mail/static/src/models/follower_subtype/follower_subtype.js', function (require) {
+'use strict';
+
+const { registerNewModel } = require('mail/static/src/model/model_core.js');
+const { attr } = require('mail/static/src/model/model_field.js');
+
+function factory(dependencies) {
+
+ class FollowerSubtype extends dependencies['mail.model'] {
+
+ //----------------------------------------------------------------------
+ // Public
+ //----------------------------------------------------------------------
+
+ /**
+ * @static
+ * @param {Object} data
+ * @returns {Object}
+ */
+ static convertData(data) {
+ const data2 = {};
+ if ('default' in data) {
+ data2.isDefault = data.default;
+ }
+ if ('id' in data) {
+ data2.id = data.id;
+ }
+ if ('internal' in data) {
+ data2.isInternal = data.internal;
+ }
+ if ('name' in data) {
+ data2.name = data.name;
+ }
+ if ('parent_model' in data) {
+ data2.parentModel = data.parent_model;
+ }
+ if ('res_model' in data) {
+ data2.resModel = data.res_model;
+ }
+ if ('sequence' in data) {
+ data2.sequence = data.sequence;
+ }
+ return data2;
+ }
+
+ }
+
+ FollowerSubtype.fields = {
+ id: attr(),
+ isDefault: attr({
+ default: false,
+ }),
+ isInternal: attr({
+ default: false,
+ }),
+ name: attr(),
+ // AKU FIXME: use relation instead
+ parentModel: attr(),
+ // AKU FIXME: use relation instead
+ resModel: attr(),
+ sequence: attr(),
+ };
+
+ FollowerSubtype.modelName = 'mail.follower_subtype';
+
+ return FollowerSubtype;
+}
+
+registerNewModel('mail.follower_subtype', factory);
+
+});
diff --git a/addons/mail/static/src/models/follower_subtype_list/follower_subtype_list.js b/addons/mail/static/src/models/follower_subtype_list/follower_subtype_list.js
new file mode 100644
index 00000000000..9d67cedb97d
--- /dev/null
+++ b/addons/mail/static/src/models/follower_subtype_list/follower_subtype_list.js
@@ -0,0 +1,22 @@
+odoo.define('mail/static/src/models/follower_subtype_list/follower_subtype_list.js', function (require) {
+'use strict';
+
+const { registerNewModel } = require('mail/static/src/model/model_core.js');
+const { many2one } = require('mail/static/src/model/model_field.js');
+
+function factory(dependencies) {
+
+ class FollowerSubtypeList extends dependencies['mail.model'] {}
+
+ FollowerSubtypeList.fields = {
+ follower: many2one('mail.follower'),
+ };
+
+ FollowerSubtypeList.modelName = 'mail.follower_subtype_list';
+
+ return FollowerSubtypeList;
+}
+
+registerNewModel('mail.follower_subtype_list', factory);
+
+});
diff --git a/addons/mail/static/src/models/locale/locale.js b/addons/mail/static/src/models/locale/locale.js
new file mode 100644
index 00000000000..4007dbc796c
--- /dev/null
+++ b/addons/mail/static/src/models/locale/locale.js
@@ -0,0 +1,38 @@
+odoo.define('mail/static/src/models/locale/locale.js', function (require) {
+'use strict';
+
+const { registerNewModel } = require('mail/static/src/model/model_core.js');
+const { attr } = require('mail/static/src/model/model_field.js');
+
+function factory(dependencies) {
+
+ class Locale extends dependencies['mail.model'] {
+
+ //----------------------------------------------------------------------
+ // Private
+ //----------------------------------------------------------------------
+
+ /**
+ * @private
+ * @returns {string}
+ */
+ _computeTextDirection() {
+ return this.env._t.database.parameters.direction;
+ }
+
+ }
+
+ Locale.fields = {
+ textDirection: attr({
+ compute: '_computeTextDirection',
+ }),
+ };
+
+ Locale.modelName = 'mail.locale';
+
+ return Locale;
+}
+
+registerNewModel('mail.locale', factory);
+
+});
diff --git a/addons/mail/static/src/models/mail_template/mail_template.js b/addons/mail/static/src/models/mail_template/mail_template.js
new file mode 100644
index 00000000000..69d2b4c18b1
--- /dev/null
+++ b/addons/mail/static/src/models/mail_template/mail_template.js
@@ -0,0 +1,76 @@
+odoo.define('mail/static/src/models/mail_template/mail_template.js', function (require) {
+'use strict';
+
+const { registerNewModel } = require('mail/static/src/model/model_core.js');
+const { attr, many2many } = require('mail/static/src/model/model_field.js');
+
+function factory(dependencies) {
+
+ class MailTemplate extends dependencies['mail.model'] {
+
+ //----------------------------------------------------------------------
+ // Public
+ //----------------------------------------------------------------------
+
+ /**
+ * @param {mail.activity} activity
+ */
+ preview(activity) {
+ const action = {
+ name: this.env._t("Compose Email"),
+ type: 'ir.actions.act_window',
+ res_model: 'mail.compose.message',
+ views: [[false, 'form']],
+ target: 'new',
+ context: {
+ default_res_id: activity.res_id,
+ default_model: activity.res_model,
+ default_use_template: true,
+ default_template_id: this.id,
+ force_email: true,
+ },
+ };
+ this.env.bus.trigger('do-action', {
+ action,
+ options: {
+ on_close: () => {
+ if (activity.chatter) {
+ activity.chatter.refresh();
+ }
+ },
+ },
+ });
+ }
+
+ /**
+ * @param {mail.activity} activity
+ */
+ async send(activity) {
+ await this.async(() => this.env.services.rpc({
+ model: activity.res_model,
+ method: 'activity_send_mail',
+ args: [[activity.res_id], this.id],
+ }));
+ if (activity.chatter) {
+ activity.chatter.refresh();
+ }
+ }
+
+ }
+
+ MailTemplate.fields = {
+ activities: many2many('mail.activity', {
+ inverse: 'mailTemplates',
+ }),
+ id: attr(),
+ name: attr(),
+ };
+
+ MailTemplate.modelName = 'mail.mail_template';
+
+ return MailTemplate;
+}
+
+registerNewModel('mail.mail_template', factory);
+
+});
diff --git a/addons/mail/static/src/models/message/message.js b/addons/mail/static/src/models/message/message.js
new file mode 100644
index 00000000000..f09d063b473
--- /dev/null
+++ b/addons/mail/static/src/models/message/message.js
@@ -0,0 +1,587 @@
+odoo.define('mail/static/src/models/message/message.js', function (require) {
+'use strict';
+
+const emojis = require('mail.emojis');
+const { registerNewModel } = require('mail/static/src/model/model_core.js');
+const { attr, many2many, many2one, one2many } = require('mail/static/src/model/model_field.js');
+const { addLink, parseAndTransform } = require('mail.utils');
+
+const { str_to_datetime } = require('web.time');
+
+function factory(dependencies) {
+
+ class Message extends dependencies['mail.model'] {
+
+ //----------------------------------------------------------------------
+ // Public
+ //----------------------------------------------------------------------
+
+ /**
+ * @static
+ * @param {mail.thread} thread
+ * @param {string} threadStringifiedDomain
+ */
+ static checkAll(thread, threadStringifiedDomain) {
+ const threadCache = thread.cache(threadStringifiedDomain);
+ threadCache.update({ checkedMessages: [['link', threadCache.messages]] });
+ }
+
+ /**
+ * @static
+ * @param {Object} data
+ * @return {Object}
+ */
+ static convertData(data) {
+ const data2 = {
+ threadCaches: [],
+ };
+ if ('body' in data) {
+ data2.body = data.body;
+ }
+ if ('date' in data && data.date) {
+ data2.date = moment(str_to_datetime(data.date));
+ }
+ if ('email_from' in data) {
+ data2.email_from = data.email_from;
+ }
+ if ('id' in data) {
+ data2.id = data.id;
+ }
+ if ('is_discussion' in data) {
+ data2.is_discussion = data.is_discussion;
+ }
+ if ('is_note' in data) {
+ data2.is_note = data.is_note;
+ }
+ if ('is_notification' in data) {
+ data2.is_notification = data.is_notification;
+ }
+ if ('message_type' in data) {
+ data2.message_type = data.message_type;
+ }
+ if ('moderation_status' in data) {
+ data2.moderation_status = data.moderation_status;
+ const moderationMailbox = this.env.messaging.moderation;
+ if (moderationMailbox && data.moderation_status !== 'pending') {
+ data2.threadCaches.push(['unlink', moderationMailbox.mainCache]);
+ }
+ }
+ if ('subject' in data) {
+ data2.subject = data.subject;
+ }
+ if ('subtype_description' in data) {
+ data2.subtype_description = data.subtype_description;
+ }
+ if ('subtype_id' in data) {
+ data2.subtype_id = data.subtype_id;
+ }
+ if ('tracking_value_ids' in data) {
+ data2.tracking_value_ids = data.tracking_value_ids;
+ }
+
+ // relations
+ if ('attachment_ids' in data) {
+ if (!data.attachment_ids) {
+ data2.attachments = [['unlink-all']];
+ } else {
+ data2.attachments = [
+ ['insert-and-replace', data.attachment_ids.map(attachmentData =>
+ this.env.models['mail.attachment'].convertData(attachmentData)
+ )],
+ ];
+ }
+ }
+ if ('author_id' in data) {
+ if (!data.author_id) {
+ data2.author = [['unlink-all']];
+ } else if (data.author_id[0] !== 0) {
+ // partner id 0 is a hack of message_format to refer to an
+ // author non-related to a partner. display_name equals
+ // email_from, so this is omitted due to being redundant.
+ data2.author = [
+ ['insert', {
+ display_name: data.author_id[1],
+ id: data.author_id[0],
+ }],
+ ];
+ }
+ }
+ if ('channel_ids' in data && data.channel_ids) {
+ // AKU FIXME: side-effect of calling convert...
+ const channelList = [];
+ for (const channelId of data.channel_ids) {
+ const channel = this.env.models['mail.thread'].insert({
+ id: channelId,
+ model: 'mail.channel',
+ });
+ channelList.push(channel);
+ }
+ data2.threadCaches.push(['replace', channelList.map(channel => channel.mainCache)]);
+ }
+ if ('history_partner_ids' in data) {
+ const history = this.env.messaging.history;
+ if (data.history_partner_ids.includes(this.env.messaging.currentPartner.id)) {
+ data2.threadCaches.push(['link', history.mainCache]);
+ } else {
+ data2.threadCaches.push(['unlink', history.mainCache]);
+ }
+ }
+ if ('model' in data && 'res_id' in data && data.model && data.res_id) {
+ const originThreadData = {
+ id: data.res_id,
+ model: data.model,
+ };
+ if ('record_name' in data && data.record_name) {
+ originThreadData.name = data.record_name;
+ }
+ if ('res_model_name' in data && data.res_model_name) {
+ originThreadData.model_name = data.res_model_name;
+ }
+ if ('module_icon' in data) {
+ originThreadData.moduleIcon = data.module_icon;
+ }
+ data2.originThread = [['insert', originThreadData]];
+ }
+ if ('needaction_partner_ids' in data) {
+ const inbox = this.env.messaging.inbox;
+ if (data.needaction_partner_ids.includes(this.env.messaging.currentPartner.id)) {
+ data2.threadCaches.push(['link', inbox.mainCache]);
+ } else {
+ data2.threadCaches.push(['unlink', inbox.mainCache]);
+ }
+ }
+ if ('notifications' in data) {
+ data2.notifications = [['insert', data.notifications.map(notificationData =>
+ this.env.models['mail.notification'].convertData(notificationData)
+ )]];
+ }
+ if ('starred_partner_ids' in data) {
+ const starred = this.env.messaging.starred;
+ if (data.starred_partner_ids.includes(this.env.messaging.currentPartner.id)) {
+ data2.threadCaches.push(['link', starred.mainCache]);
+ } else {
+ data2.threadCaches.push(['unlink', starred.mainCache]);
+ }
+ }
+
+ return data2;
+ }
+
+ /**
+ * Mark all messages of current user with given domain as read.
+ *
+ * @static
+ * @param {Array[]} domain
+ */
+ static async markAllAsRead(domain) {
+ await this.env.services.rpc({
+ model: 'mail.message',
+ method: 'mark_all_as_read',
+ kwargs: { domain },
+ });
+ }
+
+ /**
+ * Mark provided messages as read. Messages that have been marked as
+ * read are acknowledged by server with response as longpolling
+ * notification of following format:
+ *
+ * [[dbname, 'res.partner', partnerId], { type: 'mark_as_read' }]
+ *
+ * @see mail.messaging_notification_handler:_handleNotificationPartnerMarkAsRead()
+ *
+ * @static
+ * @param {mail.message[]} messages
+ */
+ static async markAsRead(messages) {
+ await this.env.services.rpc({
+ model: 'mail.message',
+ method: 'set_message_done',
+ args: [messages.map(message => message.id)]
+ });
+ }
+
+ /**
+ * Applies the moderation `decision` on the provided messages.
+ *
+ * @static
+ * @param {mail.message[]} messages
+ * @param {string} decision: 'accept', 'allow', ban', 'discard', or 'reject'
+ * @param {Object|undefined} [kwargs] optional data to pass on
+ * message moderation. This is provided when rejecting the messages
+ * for which title and comment give reason(s) for reject.
+ * @param {string} [kwargs.title]
+ * @param {string} [kwargs.comment]
+ */
+ static async moderate(messages, decision, kwargs) {
+ const messageIds = messages.map(message => message.id);
+ await this.env.services.rpc({
+ model: 'mail.message',
+ method: 'moderate',
+ args: [messageIds, decision],
+ kwargs: kwargs,
+ });
+ }
+
+ /**
+ * @static
+ * @param {mail.thread} thread
+ * @param {string} threadStringifiedDomain
+ */
+ static uncheckAll(thread, threadStringifiedDomain) {
+ const threadCache = thread.cache(threadStringifiedDomain);
+ threadCache.update({ checkedMessages: [['unlink', threadCache.messages]] });
+ }
+
+ /**
+ * Unstar all starred messages of current user.
+ */
+ static async unstarAll() {
+ await this.env.services.rpc({
+ model: 'mail.message',
+ method: 'unstar_all',
+ });
+ }
+
+ /**
+ * @param {mail.thread} thread
+ * @param {string} threadStringifiedDomain
+ * @returns {boolean}
+ */
+ isChecked(thread, threadStringifiedDomain) {
+ // aku todo
+ const relatedCheckedThreadCache = this.checkedThreadCaches.find(
+ threadCache => (
+ threadCache.thread === thread &&
+ threadCache.stringifiedDomain === threadStringifiedDomain
+ )
+ );
+ return !!relatedCheckedThreadCache;
+ }
+
+ /**
+ * Mark this message as read, so that it no longer appears in current
+ * partner Inbox.
+ */
+ async markAsRead() {
+ await this.async(() => this.env.services.rpc({
+ model: 'mail.message',
+ method: 'set_message_done',
+ args: [[this.id]]
+ }));
+ }
+
+ /**
+ * Applies the moderation `decision` on this message.
+ *
+ * @param {string} decision: 'accept', 'allow', ban', 'discard', or 'reject'
+ * @param {Object|undefined} [kwargs] optional data to pass on
+ * message moderation. This is provided when rejecting the messages
+ * for which title and comment give reason(s) for reject.
+ * @param {string} [kwargs.title]
+ * @param {string} [kwargs.comment]
+ */
+ async moderate(decision, kwargs) {
+ await this.async(() => this.constructor.moderate([this], decision, kwargs));
+ }
+
+ /**
+ * Opens the view that allows to resend the message in case of failure.
+ */
+ openResendAction() {
+ this.env.bus.trigger('do-action', {
+ action: 'mail.mail_resend_message_action',
+ options: {
+ additional_context: {
+ mail_message_to_resend: this.id,
+ },
+ },
+ });
+ }
+
+ /**
+ * Action to initiate reply to given message.
+ */
+ replyTo() {
+ const discuss = this.env.messaging.discuss;
+ if (!discuss.isOpen) {
+ return;
+ }
+ if (discuss.replyingToMessage === this) {
+ discuss.clearReplyingToMessage();
+ } else {
+ discuss.update({ replyingToMessage: [['link', this]] });
+ }
+ }
+
+ /**
+ * Toggle check state of this message in the context of the provided
+ * thread and its stringifiedDomain.
+ *
+ * @param {mail.thread} thread
+ * @param {string} threadStringifiedDomain
+ */
+ toggleCheck(thread, threadStringifiedDomain) {
+ const threadCache = thread.cache(threadStringifiedDomain);
+ if (threadCache.checkedMessages.includes(this)) {
+ threadCache.update({ checkedMessages: [['unlink', this]] });
+ } else {
+ threadCache.update({ checkedMessages: [['link', this]] });
+ }
+ }
+
+ /**
+ * Toggle the starred status of the provided message.
+ */
+ async toggleStar() {
+ await this.async(() => this.env.services.rpc({
+ model: 'mail.message',
+ method: 'toggle_message_starred',
+ args: [[this.id]]
+ }));
+ }
+
+ //----------------------------------------------------------------------
+ // Private
+ //----------------------------------------------------------------------
+
+ /**
+ * @private
+ * @returns {mail.thread[]}
+ */
+ _computeAllThreads() {
+ const threads = this.threadCaches.map(cache => cache.thread);
+ let allThreads = threads;
+ if (this.originThread) {
+ allThreads = allThreads.concat([this.originThread]);
+ }
+ return [['replace', [...new Set(allThreads)]]];
+ }
+
+ /**
+ * @returns {boolean}
+ */
+ _computeFailureNotifications() {
+ return [['replace', this.notifications.filter(notifications =>
+ ['exception', 'bounce'].includes(notifications.notification_status)
+ )]];
+ }
+
+ /**
+ * @private
+ * @returns {boolean}
+ */
+ _computeHasCheckbox() {
+ return this.isModeratedByCurrentPartner;
+ }
+
+ /**
+ * @private
+ * @returns {boolean}
+ */
+ _computeIsCurrentPartnerAuthor() {
+ return (
+ this.author &&
+ this.messagingCurrentPartner &&
+ this.messagingCurrentPartner === this.author
+ );
+ }
+
+ /**
+ * @private
+ * @returns {boolean}
+ */
+ _computeIsModeratedByCurrentPartner() {
+ return (
+ this.moderation_status === 'pending_moderation' &&
+ this.originThread &&
+ this.originThread.isModeratedByCurrentPartner
+ );
+ }
+
+ /**
+ * @private
+ * @returns {boolean}
+ */
+ _computeIsNeedaction() {
+ const inbox = this.env.messaging.inbox;
+ if (!inbox) {
+ return false;
+ }
+ return this.allThreads.includes(inbox);
+ }
+
+ /**
+ * @private
+ * @returns {mail.messaging}
+ */
+ _computeMessaging() {
+ return [['link', this.env.messaging]];
+ }
+
+ /**
+ * @private
+ * @returns {string}
+ */
+ _computePrettyBody() {
+ let prettyBody;
+ for (const emoji of emojis) {
+ const { unicode } = emoji;
+ const regexp = new RegExp(
+ `(?:^|\\s|<[a-z]*>)(${unicode})(?=\\s|$|</[a-z]*>)`,
+ "g"
+ );
+ const originalBody = this.body;
+ prettyBody = this.body.replace(
+ regexp,
+ ` <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);
+ }
+
+ /**
+ * @override
+ */
+ _createRecordLocalId(data) {
+ const Message = this.env.models['mail.message'];
+ return `${Message.modelName}_${data.id}`;
+ }
+
+ }
+
+ Message.fields = {
+ allThreads: many2many('mail.thread', {
+ compute: '_computeAllThreads',
+ dependencies: [
+ 'originThread',
+ 'threadCachesThread',
+ ],
+ }),
+ attachments: many2many('mail.attachment', {
+ inverse: 'messages',
+ }),
+ author: many2one('mail.partner', {
+ inverse: 'messagesAsAuthor',
+ }),
+ body: attr({
+ default: "",
+ }),
+ checkedThreadCaches: many2many('mail.thread_cache', {
+ inverse: 'checkedMessages',
+ }),
+ date: attr({
+ default: moment(),
+ }),
+ email_from: attr(),
+ failureNotifications: one2many('mail.notification', {
+ compute: '_computeFailureNotifications',
+ dependencies: ['notificationsStatus'],
+ }),
+ hasCheckbox: attr({
+ compute: '_computeHasCheckbox',
+ default: false,
+ dependencies: ['isModeratedByCurrentPartner'],
+ }),
+ id: attr(),
+ isCurrentPartnerAuthor: attr({
+ compute: '_computeIsCurrentPartnerAuthor',
+ default: false,
+ dependencies: [
+ 'author',
+ 'messagingCurrentPartner',
+ ],
+ }),
+ isModeratedByCurrentPartner: attr({
+ compute: '_computeIsModeratedByCurrentPartner',
+ default: false,
+ dependencies: [
+ 'moderation_status',
+ 'originThread',
+ 'originThreadIsModeratedByCurrentPartner',
+ ],
+ }),
+ isNeedaction: attr({
+ compute: '_computeIsNeedaction',
+ dependencies: [
+ 'allThreads',
+ 'messagingInbox',
+ ],
+ default: false,
+ }),
+ isTemporary: attr({
+ default: false,
+ }),
+ isTransient: attr({
+ default: false,
+ }),
+ is_discussion: attr({
+ default: false,
+ }),
+ is_note: attr({
+ default: false,
+ }),
+ is_notification: attr({
+ default: false,
+ }),
+ message_type: attr(),
+ messaging: many2one('mail.messaging', {
+ compute: '_computeMessaging',
+ }),
+ messagingCurrentPartner: many2one('mail.partner', {
+ related: 'messaging.currentPartner',
+ }),
+ messagingInbox: many2one('mail.thread', {
+ related: 'messaging.inbox',
+ }),
+ moderation_status: attr(),
+ notifications: one2many('mail.notification', {
+ inverse: 'message',
+ isCausal: true,
+ }),
+ notificationsStatus: attr({
+ default: [],
+ related: 'notifications.notification_status',
+ }),
+ originThread: many2one('mail.thread', {
+ inverse: 'originThreadMessages',
+ }),
+ originThreadIsModeratedByCurrentPartner: attr({
+ default: false,
+ related: 'originThread.isModeratedByCurrentPartner',
+ }),
+ prettyBody: attr({
+ compute: '_computePrettyBody',
+ dependencies: ['body'],
+ }),
+ subject: attr(),
+ subtype_description: attr(),
+ subtype_id: attr(),
+ threadCaches: many2many('mail.thread_cache', {
+ inverse: 'messages',
+ }),
+ threadCachesThread: many2many('mail.thread', {
+ related: 'threadCaches.thread',
+ }),
+ tracking_value_ids: attr({
+ default: [],
+ }),
+ };
+
+ Message.modelName = 'mail.message';
+
+ return Message;
+}
+
+registerNewModel('mail.message', factory);
+
+});
diff --git a/addons/mail/static/src/models/message/message_tests.js b/addons/mail/static/src/models/message/message_tests.js
new file mode 100644
index 00000000000..fefb495a3ba
--- /dev/null
+++ b/addons/mail/static/src/models/message/message_tests.js
@@ -0,0 +1,132 @@
+odoo.define('mail/static/src/models/message/message_tests.js', function (require) {
+'use strict';
+
+const {
+ afterEach: utilsAfterEach,
+ beforeEach: utilsBeforeEach,
+ start: utilsStart,
+} = require('mail/static/src/utils/test_utils.js');
+
+const { str_to_datetime } = require('web.time');
+
+QUnit.module('mail', {}, function () {
+QUnit.module('models', {}, function () {
+QUnit.module('message', {}, function () {
+QUnit.module('message_tests.js', {
+ beforeEach() {
+ utilsBeforeEach(this);
+
+ this.start = async params => {
+ if (this.widget) {
+ this.widget.destroy();
+ }
+ let { env, widget } = await utilsStart(Object.assign({}, params, {
+ data: this.data,
+ }));
+ this.env = env;
+ this.widget = widget;
+ };
+ },
+ afterEach() {
+ utilsAfterEach(this);
+ this.env = undefined;
+ if (this.widget) {
+ this.widget.destroy();
+ this.widget = undefined;
+ }
+ },
+});
+
+QUnit.test('create', async function (assert) {
+ assert.expect(31);
+
+ await this.start();
+ assert.notOk(this.env.models['mail.partner'].find(partner => partner.id === 5));
+ assert.notOk(this.env.models['mail.thread'].find(thread =>
+ thread.id === 100 &&
+ thread.model === 'mail.channel'
+ ));
+ assert.notOk(this.env.models['mail.attachment'].find(attachment => attachment.id === 750));
+ assert.notOk(this.env.models['mail.message'].find(message => message.id === 4000));
+
+ const thread = this.env.models['mail.thread'].create({
+ id: 100,
+ model: 'mail.channel',
+ name: "General",
+ });
+ const message = this.env.models['mail.message'].create({
+ attachments: [['insert-and-replace', {
+ filename: "test.txt",
+ id: 750,
+ mimetype: 'text/plain',
+ name: "test.txt",
+ }]],
+ author: [['insert', { id: 5, display_name: "Demo" }]],
+ body: "<p>Test</p>",
+ date: moment(str_to_datetime("2019-05-05 10:00:00")),
+ id: 4000,
+ originThread: [['link', thread]],
+ threadCaches: [['link', [
+ thread.mainCache,
+ this.env.messaging.inbox.mainCache,
+ this.env.messaging.starred.mainCache,
+ ]]],
+ });
+
+ assert.ok(this.env.models['mail.partner'].find(partner => partner.id === 5));
+ assert.ok(this.env.models['mail.thread'].find(thread =>
+ thread.id === 100 &&
+ thread.model === 'mail.channel'
+ ));
+ assert.ok(this.env.models['mail.attachment'].find(attachment => attachment.id === 750));
+ assert.ok(this.env.models['mail.message'].find(message => message.id === 4000));
+
+ assert.ok(message);
+ assert.strictEqual(this.env.models['mail.message'].find(message => message.id === 4000), message);
+ assert.strictEqual(message.body, "<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'].find(thread =>
+ thread.id === 100 &&
+ thread.model === 'mail.channel'
+ ));
+ assert.ok(
+ message.allThreads.includes(this.env.models['mail.thread'].find(thread =>
+ thread.id === 100 &&
+ thread.model === 'mail.channel'
+ ))
+ );
+ // from partnerId being in needaction_partner_ids
+ assert.ok(message.allThreads.includes(this.env.messaging.inbox));
+ // from partnerId being in starred_partner_ids
+ assert.ok(message.allThreads.includes(this.env.messaging.starred));
+ const attachment = this.env.models['mail.attachment'].find(attachment => attachment.id === 750);
+ assert.ok(attachment);
+ assert.strictEqual(attachment.filename, "test.txt");
+ assert.strictEqual(attachment.id, 750);
+ assert.notOk(attachment.isTemporary);
+ assert.strictEqual(attachment.mimetype, 'text/plain');
+ assert.strictEqual(attachment.name, "test.txt");
+ const channel = this.env.models['mail.thread'].find(thread =>
+ thread.id === 100 &&
+ thread.model === 'mail.channel'
+ );
+ assert.ok(channel);
+ assert.strictEqual(channel.model, 'mail.channel');
+ assert.strictEqual(channel.id, 100);
+ assert.strictEqual(channel.name, "General");
+ const partner = this.env.models['mail.partner'].find(partner => partner.id === 5);
+ assert.ok(partner);
+ assert.strictEqual(partner.display_name, "Demo");
+ assert.strictEqual(partner.id, 5);
+});
+
+});
+});
+});
+
+});
diff --git a/addons/mail/static/src/models/message_seen_indicator/message_seen_indicator.js b/addons/mail/static/src/models/message_seen_indicator/message_seen_indicator.js
new file mode 100644
index 00000000000..9ad56eb8da0
--- /dev/null
+++ b/addons/mail/static/src/models/message_seen_indicator/message_seen_indicator.js
@@ -0,0 +1,286 @@
+odoo.define('mail/static/src/models/message_seen_indicator/message_seen_indicator.js', function (require) {
+'use strict';
+
+const { registerNewModel } = require('mail/static/src/model/model_core.js');
+const { attr, many2many, many2one, one2many } = require('mail/static/src/model/model_field.js');
+
+function factory(dependencies) {
+
+ class MessageSeenIndicator extends dependencies['mail.model'] {
+
+ //----------------------------------------------------------------------
+ // Public
+ //----------------------------------------------------------------------
+
+ /**
+ * @static
+ * @param {mail.message|integer} message
+ * @param {mail.thread|integer} thread
+ * @returns {string|undefined}
+ */
+ static computeId(message, thread) {
+ if (message && thread) {
+ const messageId = Number.isInteger(message) ? message : message.id;
+ const threadId = Number.isInteger(thread) ? thread : thread.id;
+ return [messageId, threadId].join('-');
+ }
+ return undefined;
+ }
+
+ /**
+ * @static
+ * @param {mail.thread} [channel] the concerned thread
+ */
+ static recomputeFetchedValues(channel = undefined) {
+ const indicatorFindFunction = channel ? localIndicator => localIndicator.thread === channel : undefined;
+ const indicators = this.env.models['mail.message_seen_indicator'].all(indicatorFindFunction);
+ for (const indicator of indicators) {
+ indicator.update({
+ hasEveryoneFetched: indicator._computeHasEveryoneFetched(),
+ hasSomeoneFetched: indicator._computeHasSomeoneFetched(),
+ partnersThatHaveFetched: indicator._computePartnersThatHaveFetched(),
+ });
+ }
+ }
+
+ /**
+ * @static
+ * @param {mail.thread} [channel] the concerned thread
+ */
+ static recomputeSeenValues(channel = undefined) {
+ const indicatorFindFunction = channel ? localIndicator => localIndicator.thread === channel : undefined;
+ const indicators = this.env.models['mail.message_seen_indicator'].all(indicatorFindFunction);
+ for (const indicator of indicators) {
+ indicator.update({
+ hasEveryoneSeen: indicator._computeHasEveryoneSeen(),
+ hasSomeoneFetched: indicator._computeHasSomeoneFetched(),
+ hasSomeoneSeen: indicator._computeHasSomeoneSeen(),
+ isMessagePreviousToLastCurrentPartnerMessageSeenByEveryone:
+ indicator._computeIsMessagePreviousToLastCurrentPartnerMessageSeenByEveryone(),
+ partnersThatHaveFetched: indicator._computePartnersThatHaveFetched(),
+ partnersThatHaveSeen: indicator._computePartnersThatHaveSeen(),
+ });
+ }
+ }
+
+ //----------------------------------------------------------------------
+ // Private
+ //----------------------------------------------------------------------
+
+ /**
+ * Manually called as not always called when necessary
+ *
+ * @private
+ * @returns {boolean}
+ * @see computeFetchedValues
+ * @see computeSeenValues
+ */
+ _computeHasEveryoneFetched() {
+ if (!this.message || !this.thread || !this.thread.partnerSeenInfos) {
+ return false;
+ }
+ const otherPartnerSeenInfosDidNotFetch =
+ this.thread.partnerSeenInfos.filter(partnerSeenInfo =>
+ partnerSeenInfo.partner !== this.message.author &&
+ (
+ !partnerSeenInfo.lastFetchedMessage ||
+ partnerSeenInfo.lastFetchedMessage.id < this.message.id
+ )
+ );
+ return otherPartnerSeenInfosDidNotFetch.length === 0;
+ }
+
+ /**
+ * Manually called as not always called when necessary
+ *
+ * @private
+ * @returns {boolean}
+ * @see computeSeenValues
+ */
+ _computeHasEveryoneSeen() {
+ if (!this.message || !this.thread || !this.thread.partnerSeenInfos) {
+ return false;
+ }
+ const otherPartnerSeenInfosDidNotSee =
+ this.thread.partnerSeenInfos.filter(partnerSeenInfo =>
+ partnerSeenInfo.partner !== this.message.author &&
+ (
+ !partnerSeenInfo.lastSeenMessage ||
+ partnerSeenInfo.lastSeenMessage.id < this.message.id
+ )
+ );
+ return otherPartnerSeenInfosDidNotSee.length === 0;
+ }
+
+ /**
+ * Manually called as not always called when necessary
+ *
+ * @private
+ * @returns {boolean}
+ * @see computeFetchedValues
+ * @see computeSeenValues
+ */
+ _computeHasSomeoneFetched() {
+ if (!this.message || !this.thread || !this.thread.partnerSeenInfos) {
+ return false;
+ }
+ const otherPartnerSeenInfosFetched =
+ this.thread.partnerSeenInfos.filter(partnerSeenInfo =>
+ partnerSeenInfo.partner !== this.message.author &&
+ partnerSeenInfo.lastFetchedMessage &&
+ partnerSeenInfo.lastFetchedMessage.id >= this.message.id
+ );
+ return otherPartnerSeenInfosFetched.length > 0;
+ }
+
+ /**
+ * Manually called as not always called when necessary
+ *
+ * @private
+ * @returns {boolean}
+ * @see computeSeenValues
+ */
+ _computeHasSomeoneSeen() {
+ if (!this.message || !this.thread || !this.thread.partnerSeenInfos) {
+ return false;
+ }
+ const otherPartnerSeenInfosSeen =
+ this.thread.partnerSeenInfos.filter(partnerSeenInfo =>
+ partnerSeenInfo.partner !== this.message.author &&
+ partnerSeenInfo.lastSeenMessage &&
+ partnerSeenInfo.lastSeenMessage.id >= this.message.id
+ );
+ return otherPartnerSeenInfosSeen.length > 0;
+ }
+
+ /**
+ * Manually called as not always called when necessary
+ *
+ * @private
+ * @returns {boolean}
+ * @see computeSeenValues
+ */
+ _computeIsMessagePreviousToLastCurrentPartnerMessageSeenByEveryone() {
+ if (
+ !this.message ||
+ !this.thread ||
+ !this.thread.lastCurrentPartnerMessageSeenByEveryone
+ ) {
+ return false;
+ }
+ return this.message.id < this.thread.lastCurrentPartnerMessageSeenByEveryone.id;
+ }
+
+ /**
+ * Manually called as not always called when necessary
+ *
+ * @private
+ * @returns {mail.partner[]}
+ * @see computeFetchedValues
+ * @see computeSeenValues
+ */
+ _computePartnersThatHaveFetched() {
+ if (!this.message || !this.thread || !this.thread.partnerSeenInfos) {
+ return [['unlink-all']];
+ }
+ const otherPartnersThatHaveFetched = this.thread.partnerSeenInfos
+ .filter(partnerSeenInfo =>
+ partnerSeenInfo.partner !== this.message.author &&
+ partnerSeenInfo.lastFetchedMessage &&
+ partnerSeenInfo.lastFetchedMessage.id >= this.message.id
+ )
+ .map(partnerSeenInfo => partnerSeenInfo.partner);
+ if (otherPartnersThatHaveFetched.length === 0) {
+ return [['unlink-all']];
+ }
+ return [['replace', otherPartnersThatHaveFetched]];
+ }
+
+ /**
+ * Manually called as not always called when necessary
+ *
+ * @private
+ * @returns {mail.partner[]}
+ * @see computeSeenValues
+ */
+ _computePartnersThatHaveSeen() {
+ if (!this.message || !this.thread || !this.thread.partnerSeenInfos) {
+ return [['unlink-all']];
+ }
+ const otherPartnersThatHaveSeen = this.thread.partnerSeenInfos
+ .filter(partnerSeenInfo =>
+ partnerSeenInfo.partner !== this.message.author &&
+ partnerSeenInfo.lastSeenMessage &&
+ partnerSeenInfo.lastSeenMessage.id >= this.message.id)
+ .map(partnerSeenInfo => partnerSeenInfo.partner);
+ if (otherPartnersThatHaveSeen.length === 0) {
+ return [['unlink-all']];
+ }
+ return [['replace', otherPartnersThatHaveSeen]];
+ }
+ }
+
+ MessageSeenIndicator.modelName = 'mail.message_seen_indicator';
+
+ MessageSeenIndicator.fields = {
+ hasEveryoneFetched: attr({
+ compute: '_computeHasEveryoneFetched',
+ default: false,
+ dependencies: ['messageAuthor', 'messageId', 'threadPartnerSeenInfos'],
+ }),
+ hasEveryoneSeen: attr({
+ compute: '_computeHasEveryoneSeen',
+ default: false,
+ dependencies: ['messageAuthor', 'messageId', 'threadPartnerSeenInfos'],
+ }),
+ hasSomeoneFetched: attr({
+ compute: '_computeHasSomeoneFetched',
+ default: false,
+ dependencies: ['messageAuthor', 'messageId', 'threadPartnerSeenInfos'],
+ }),
+ hasSomeoneSeen: attr({
+ compute: '_computeHasSomeoneSeen',
+ default: false,
+ dependencies: ['messageAuthor', 'messageId', 'threadPartnerSeenInfos'],
+ }),
+ id: attr(),
+ isMessagePreviousToLastCurrentPartnerMessageSeenByEveryone: attr({
+ compute: '_computeIsMessagePreviousToLastCurrentPartnerMessageSeenByEveryone',
+ default: false,
+ dependencies: [
+ 'messageId',
+ 'threadLastCurrentPartnerMessageSeenByEveryone',
+ ],
+ }),
+ message: many2one('mail.message'),
+ messageAuthor: many2one('mail.partner', {
+ related: 'message.author',
+ }),
+ messageId: attr({
+ related: 'message.id',
+ }),
+ partnersThatHaveFetched: many2many('mail.partner', {
+ compute: '_computePartnersThatHaveFetched',
+ dependencies: ['messageAuthor', 'messageId', 'threadPartnerSeenInfos'],
+ }),
+ partnersThatHaveSeen: many2many('mail.partner', {
+ compute: '_computePartnersThatHaveSeen',
+ dependencies: ['messageAuthor', 'messageId', 'threadPartnerSeenInfos'],
+ }),
+ thread: many2one('mail.thread', {
+ inverse: 'messageSeenIndicators'
+ }),
+ threadPartnerSeenInfos: one2many('mail.thread_partner_seen_info', {
+ related: 'thread.partnerSeenInfos',
+ }),
+ threadLastCurrentPartnerMessageSeenByEveryone: many2one('mail.message', {
+ related: 'thread.lastCurrentPartnerMessageSeenByEveryone',
+ }),
+ };
+
+ return MessageSeenIndicator;
+}
+
+registerNewModel('mail.message_seen_indicator', factory);
+
+});
diff --git a/addons/mail/static/src/models/messaging/messaging.js b/addons/mail/static/src/models/messaging/messaging.js
new file mode 100644
index 00000000000..4cd4c22a82f
--- /dev/null
+++ b/addons/mail/static/src/models/messaging/messaging.js
@@ -0,0 +1,216 @@
+odoo.define('mail/static/src/models/messaging/messaging.js', function (require) {
+'use strict';
+
+const { registerNewModel } = require('mail/static/src/model/model_core.js');
+const { attr, many2one, one2one } = require('mail/static/src/model/model_field.js');
+
+function factory(dependencies) {
+
+ class Messaging extends dependencies['mail.model'] {
+
+ /**
+ * @override
+ */
+ delete() {
+ this.env.services['bus_service'].off('window_focus', null, this._handleGlobalWindowFocus);
+ super.delete();
+ }
+
+ //----------------------------------------------------------------------
+ // Public
+ //----------------------------------------------------------------------
+
+ /**
+ * Open the form view of the record with provided id and model.
+ *
+ * @param {Object} param0
+ * @param {integer} param0.id
+ * @param {string} param0.model
+ */
+ openDocument({ id, model }) {
+ this.env.bus.trigger('do-action', {
+ action: {
+ type: 'ir.actions.act_window',
+ res_model: model,
+ views: [[false, 'form']],
+ res_id: id,
+ },
+ });
+ this.messagingMenu.close();
+ }
+
+ /**
+ * Handles redirection to a model and id. Try to handle it in the context
+ * of messaging (e.g. open chat if this is a user), otherwise fallback to
+ * opening form view of record.
+ *
+ * @param {Object} param0
+ * @param {integer} param0.id
+ * @param {string} param0.model
+ * FIXME needs to be tested and maybe refactored (see task-2244279)
+ */
+ async redirect({ id, model }) {
+ if (model === 'mail.channel') {
+ const channel = this.env.models['mail.thread'].find(thread =>
+ thread.id === id &&
+ thread.model === 'mail.channel'
+ );
+ if (!channel || !channel.isPinned) {
+ this.env.models['mail.thread'].joinChannel(id, { autoselect: true });
+ return;
+ }
+ channel.open();
+ } else if (model === 'res.partner') {
+ if (id === this.currentPartner.id) {
+ this.openDocument({
+ model: 'res.partner',
+ id,
+ });
+ return;
+ }
+ const partner = this.env.models['mail.partner'].insert({ id });
+ if (!partner.user) {
+ await this.async(() => partner.checkIsUser());
+ }
+ if (!partner.user) {
+ // partner is not a user, open document instead
+ this.openDocument({
+ model: 'res.partner',
+ id: partner.id,
+ });
+ return;
+ }
+ const chat = partner.correspondentThreads.find(thread =>
+ thread.channel_type === 'chat'
+ );
+ if (!chat) {
+ this.env.models['mail.thread'].createChannel({
+ autoselect: true,
+ partnerId: id,
+ type: 'chat',
+ });
+ return;
+ }
+ chat.open();
+ } else {
+ this.openDocument({
+ model: 'res.partner',
+ id,
+ });
+ }
+ }
+
+ /**
+ * Start messaging and related records.
+ */
+ async start() {
+ this._handleGlobalWindowFocus = this._handleGlobalWindowFocus.bind(this);
+ this.env.services['bus_service'].on('window_focus', null, this._handleGlobalWindowFocus);
+ await this.async(() => this.initializer.start());
+ this.notificationHandler.start();
+ this.update({ isInitialized: true });
+ }
+
+ //----------------------------------------------------------------------
+ // Private
+ //----------------------------------------------------------------------
+
+ /**
+ * @private
+ */
+ _handleGlobalWindowFocus() {
+ this.update({ outOfFocusUnreadMessageCounter: 0 });
+ this.env.bus.trigger('set_title_part', {
+ part: '_chat',
+ });
+ }
+
+ }
+
+ Messaging.fields = {
+ attachmentViewer: one2one('mail.attachment_viewer', {
+ autocreate: true,
+ isCausal: true,
+ }),
+ cannedResponses: attr({
+ default: {},
+ }),
+ chatWindowManager: one2one('mail.chat_window_manager', {
+ autocreate: true,
+ inverse: 'messaging',
+ isCausal: true,
+ }),
+ commands: attr({
+ default: {},
+ }),
+ currentPartner: one2one('mail.partner'),
+ currentUser: one2one('mail.user'),
+ device: one2one('mail.device', {
+ autocreate: true,
+ isCausal: true,
+ }),
+ dialogManager: one2one('mail.dialog_manager', {
+ autocreate: true,
+ isCausal: true,
+ }),
+ discuss: one2one('mail.discuss', {
+ autocreate: true,
+ isCausal: true,
+ }),
+ /**
+ * Mailbox History.
+ */
+ history: one2one('mail.thread'),
+ /**
+ * Mailbox Inbox.
+ */
+ inbox: one2one('mail.thread'),
+ initializer: one2one('mail.messaging_initializer', {
+ autocreate: true,
+ inverse: 'messaging',
+ isCausal: true,
+ }),
+ isInitialized: attr({
+ default: false,
+ }),
+ locale: one2one('mail.locale', {
+ autocreate: true,
+ isCausal: true,
+ }),
+ messagingMenu: one2one('mail.messaging_menu', {
+ autocreate: true,
+ inverse: 'messaging',
+ isCausal: true,
+ }),
+ /**
+ * Mailbox Moderation.
+ */
+ moderation: one2one('mail.thread'),
+ notificationGroupManager: one2one('mail.notification_group_manager', {
+ autocreate: true,
+ isCausal: true,
+ }),
+ notificationHandler: one2one('mail.messaging_notification_handler', {
+ autocreate: true,
+ inverse: 'messaging',
+ isCausal: true,
+ }),
+ outOfFocusUnreadMessageCounter: attr({
+ default: 0,
+ }),
+ partnerRoot: many2one('mail.partner'),
+ publicPartner: many2one('mail.partner'),
+ /**
+ * Mailbox Starred.
+ */
+ starred: one2one('mail.thread'),
+ };
+
+ Messaging.modelName = 'mail.messaging';
+
+ return Messaging;
+}
+
+registerNewModel('mail.messaging', factory);
+
+});
diff --git a/addons/mail/static/src/models/messaging/messaging_tests.js b/addons/mail/static/src/models/messaging/messaging_tests.js
new file mode 100644
index 00000000000..11a7b1c5779
--- /dev/null
+++ b/addons/mail/static/src/models/messaging/messaging_tests.js
@@ -0,0 +1,66 @@
+odoo.define('mail/static/src/models/messaging/messaging_tests.js', function (require) {
+'use strict';
+
+const {
+ afterEach: utilsAfterEach,
+ beforeEach: utilsBeforeEach,
+ start: utilsStart,
+} = require('mail/static/src/utils/test_utils.js');
+
+QUnit.module('mail', {}, function () {
+QUnit.module('models', {}, function () {
+QUnit.module('messaging', {}, function () {
+QUnit.module('messaging_tests.js', {
+ beforeEach() {
+ utilsBeforeEach(this);
+
+ this.start = async params => {
+ if (this.widget) {
+ this.widget.destroy();
+ }
+ const { env, widget } = await utilsStart(Object.assign({}, params, {
+ data: this.data,
+ }));
+ this.env = env;
+ this.widget = widget;
+ };
+ },
+ afterEach() {
+ utilsAfterEach(this);
+ this.env = undefined;
+ if (this.widget) {
+ this.widget.destroy();
+ this.widget = undefined;
+ }
+ },
+});
+
+QUnit.test('currentPartner initialized from session', async function (assert) {
+ assert.expect(5);
+
+ await this.start();
+
+ assert.ok(this.env.messaging.currentPartner);
+ assert.strictEqual(
+ this.env.messaging.currentPartner.id,
+ this.env.session.partner_id
+ );
+ assert.strictEqual(
+ this.env.messaging.currentPartner.name,
+ this.env.session.name
+ );
+ assert.strictEqual(
+ this.env.messaging.currentPartner.display_name,
+ this.env.session.partner_display_name
+ );
+ assert.strictEqual(
+ this.env.messaging.currentPartner.user.id,
+ this.env.session.uid
+ );
+});
+
+});
+});
+});
+
+});
diff --git a/addons/mail/static/src/models/messaging_initializer/messaging_initializer.js b/addons/mail/static/src/models/messaging_initializer/messaging_initializer.js
new file mode 100644
index 00000000000..3cee3777d3a
--- /dev/null
+++ b/addons/mail/static/src/models/messaging_initializer/messaging_initializer.js
@@ -0,0 +1,292 @@
+odoo.define('mail/static/src/models/messaging_initializer/messaging_initializer.js', function (require) {
+'use strict';
+
+const { registerNewModel } = require('mail/static/src/model/model_core.js');
+const { one2one } = require('mail/static/src/model/model_field.js');
+
+function factory(dependencies) {
+
+ class MessagingInitializer extends dependencies['mail.model'] {
+
+ //----------------------------------------------------------------------
+ // Public
+ //----------------------------------------------------------------------
+
+ /**
+ * Fetch messaging data initially to populate the store specifically for
+ * the current user. This includes pinned channels for instance.
+ */
+ async start() {
+ await this.async(() => this.env.session.is_bound);
+
+ this.messaging.update({
+ history: [['create', {
+ id: 'history',
+ isPinned: true,
+ model: 'mail.box',
+ name: this.env._t("History"),
+ }]],
+ inbox: [['create', {
+ id: 'inbox',
+ isPinned: true,
+ model: 'mail.box',
+ name: this.env._t("Inbox"),
+ }]],
+ starred: [['create', {
+ id: 'starred',
+ isPinned: true,
+ model: 'mail.box',
+ name: this.env._t("Starred"),
+ }]],
+ });
+
+ const device = this.messaging.device;
+ device.start();
+ const context = Object.assign({
+ isMobile: device.isMobile,
+ }, this.env.session.user_context);
+ const discuss = this.messaging.discuss;
+ const data = await this.async(() => this.env.services.rpc({
+ route: '/mail/init_messaging',
+ params: { context: context }
+ }));
+ this._init(data);
+ if (discuss.isOpen) {
+ discuss.openInitThread();
+ }
+ if (this.env.autofetchPartnerImStatus) {
+ this.env.models['mail.partner'].startLoopFetchImStatus();
+ }
+ }
+
+ //----------------------------------------------------------------------
+ // Private
+ //----------------------------------------------------------------------
+
+ /**
+ * @private
+ * @param {Object} param0
+ * @param {Object} param0.channel_slots
+ * @param {Array} [param0.commands=[]]
+ * @param {boolean} [param0.is_moderator=false]
+ * @param {Object} [param0.mail_failures={}]
+ * @param {Object[]} [param0.mention_partner_suggestions=[]]
+ * @param {Object[]} [param0.moderation_channel_ids=[]]
+ * @param {integer} [param0.moderation_counter=0]
+ * @param {integer} [param0.needaction_inbox_counter=0]
+ * @param {Array} param0.partner_root
+ * @param {Object[]} [param0.shortcodes=[]]
+ * @param {integer} [param0.starred_counter=0]
+ */
+ _init({
+ channel_slots,
+ commands = [],
+ is_moderator = false,
+ mail_failures = {},
+ mention_partner_suggestions = [],
+ menu_id,
+ moderation_channel_ids = [],
+ moderation_counter = 0,
+ needaction_inbox_counter = 0,
+ partner_root,
+ public_partner,
+ shortcodes = [],
+ starred_counter = 0
+ }) {
+ const discuss = this.messaging.discuss;
+ this._initPartners({
+ moderation_channel_ids,
+ partner_root,
+ public_partner,
+ });
+ this._initChannels(channel_slots);
+ this._initCommands(commands);
+ this._initMailboxes({
+ is_moderator,
+ moderation_counter,
+ needaction_inbox_counter,
+ starred_counter,
+ });
+ this._initMailFailures(mail_failures);
+ this._initCannedResponses(shortcodes);
+ this._initMentionPartnerSuggestions(mention_partner_suggestions);
+ discuss.update({ menu_id });
+ }
+
+ /**
+ * @private
+ * @param {Object[]} shortcodes
+ */
+ _initCannedResponses(shortcodes) {
+ const messaging = this.messaging;
+ const cannedResponses = shortcodes
+ .map(s => {
+ const { id, source, substitution } = s;
+ return { id, source, substitution };
+ })
+ .reduce((obj, cr) => {
+ obj[cr.id] = cr;
+ return obj;
+ }, {});
+ messaging.update({ cannedResponses });
+ }
+
+ /**
+ * @private
+ * @param {Object} [param0={}]
+ * @param {Object[]} [param0.channel_channel=[]]
+ * @param {Object[]} [param0.channel_direct_message=[]]
+ * @param {Object[]} [param0.channel_private_group=[]]
+ */
+ _initChannels({
+ channel_channel = [],
+ channel_direct_message = [],
+ channel_private_group = [],
+ } = {}) {
+ for (const data of channel_channel.concat(channel_direct_message, channel_private_group)) {
+ this.env.models['mail.thread'].insert(Object.assign(
+ {},
+ this.env.models['mail.thread'].convertData(data),
+ { isPinned: true }
+ ));
+ }
+ }
+
+ /**
+ * @private
+ * @param {Object[]} commandsData
+ */
+ _initCommands(commandsData) {
+ const messaging = this.messaging;
+ const commands = commandsData
+ .map(command => {
+ return Object.assign({
+ id: command.name,
+ }, command);
+ })
+ .reduce((obj, command) => {
+ obj[command.id] = command;
+ return obj;
+ }, {});
+ messaging.update({ commands });
+ }
+
+ /**
+ * @private
+ * @param {Object} param0
+ * @param {boolean} param0.is_moderator
+ * @param {integer} param0.moderation_counter
+ * @param {integer} param0.needaction_inbox_counter
+ * @param {integer} param0.starred_counter
+ */
+ _initMailboxes({
+ is_moderator,
+ moderation_counter,
+ needaction_inbox_counter,
+ starred_counter,
+ }) {
+ this.env.messaging.inbox.update({ counter: needaction_inbox_counter });
+ this.env.messaging.starred.update({ counter: starred_counter });
+ if (is_moderator) {
+ this.messaging.update({
+ moderation: [['create', {
+ counter: moderation_counter,
+ id: 'moderation',
+ isPinned: true,
+ model: 'mail.box',
+ name: this.env._t("Moderation"),
+ }]],
+ });
+ }
+ }
+
+ /**
+ * @private
+ * @param {Object} mailFailuresData
+ */
+ _initMailFailures(mailFailuresData) {
+ for (const messageData of mailFailuresData) {
+ const message = this.env.models['mail.message'].insert(
+ this.env.models['mail.message'].convertData(messageData)
+ );
+ // implicit: failures are sent by the server at initialization
+ // only if the current partner is author of the message
+ if (!message.author && this.messaging.currentPartner) {
+ message.update({ author: [['link', this.messaging.currentPartner]] });
+ }
+ }
+ this.messaging.notificationGroupManager.computeGroups();
+ // manually force recompute of counter (after computing the groups)
+ this.messaging.messagingMenu.update();
+ }
+
+ /**
+ * @private
+ * @param {Object[]} mentionPartnerSuggestionsData
+ */
+ _initMentionPartnerSuggestions(mentionPartnerSuggestionsData) {
+ for (const suggestions of mentionPartnerSuggestionsData) {
+ for (const suggestion of suggestions) {
+ const { email, id, name } = suggestion;
+ this.env.models['mail.partner'].insert({ email, id, name });
+ }
+ }
+ }
+
+ /**
+ * @private
+ * @param {Array} param0 partner root name get
+ * @param {integer} param0[0] partner root id
+ * @param {string} param0[1] partner root display_name
+ */
+ _initPartners({
+ moderation_channel_ids = [],
+ partner_root: {
+ active: partnerRootIsActive,
+ display_name: partnerRootDisplayName,
+ id: partnerRootId,
+ },
+ public_partner: {
+ active: publicPartnerIsActive,
+ display_name: publicPartnerDisplayName,
+ id: publicPartnerId,
+ },
+ }) {
+ this.messaging.update({
+ currentPartner: [['insert', {
+ display_name: this.env.session.partner_display_name,
+ id: this.env.session.partner_id,
+ moderatedChannelIds: moderation_channel_ids,
+ name: this.env.session.name,
+ user: [['insert', { id: this.env.session.uid }]],
+ }]],
+ currentUser: [['insert', { id: this.env.session.uid }]],
+ partnerRoot: [['insert', {
+ active: partnerRootIsActive,
+ display_name: partnerRootDisplayName,
+ id: partnerRootId,
+ }]],
+ publicPartner: [['insert', {
+ active: publicPartnerIsActive,
+ display_name: publicPartnerDisplayName,
+ id: publicPartnerId,
+ }]],
+ });
+ }
+
+ }
+
+ MessagingInitializer.fields = {
+ messaging: one2one('mail.messaging', {
+ inverse: 'initializer',
+ }),
+ };
+
+ MessagingInitializer.modelName = 'mail.messaging_initializer';
+
+ return MessagingInitializer;
+}
+
+registerNewModel('mail.messaging_initializer', factory);
+
+});
diff --git a/addons/mail/static/src/models/messaging_menu/messaging_menu.js b/addons/mail/static/src/models/messaging_menu/messaging_menu.js
new file mode 100644
index 00000000000..c8e52733ff7
--- /dev/null
+++ b/addons/mail/static/src/models/messaging_menu/messaging_menu.js
@@ -0,0 +1,131 @@
+odoo.define('mail/static/src/models/messaging_menu/messaging_menu.js', function (require) {
+'use strict';
+
+const { registerNewModel } = require('mail/static/src/model/model_core.js');
+const { attr, one2one } = require('mail/static/src/model/model_field.js');
+
+function factory(dependencies) {
+
+ class MessagingMenu extends dependencies['mail.model'] {
+
+ //----------------------------------------------------------------------
+ // Public
+ //----------------------------------------------------------------------
+
+ /**
+ * Close the messaging menu. Should reset its internal state.
+ */
+ close() {
+ this.update({
+ activeTabId: 'all',
+ isMobileNewMessageToggled: false,
+ isOpen: false,
+ });
+ }
+
+ /**
+ * Toggle the visibility of the messaging menu "new message" input in
+ * mobile.
+ */
+ toggleMobileNewMessage() {
+ this.update({ isMobileNewMessageToggled: !this.isMobileNewMessageToggled });
+ }
+
+ /**
+ * Toggle whether the messaging menu is open or not.
+ */
+ async toggleOpen() {
+ if (!this.isOpen) {
+ const inbox = this.env.messaging.inbox;
+ if (!inbox.mainCache.isLoaded && !inbox.mainCache.isLoading) {
+ // populate some needaction messages on threads.
+ // FIXME: await necessary due to bug in tests without it
+ // see task-id 2275999
+ await this.async(() => inbox.mainCache.loadMessages());
+ }
+ }
+ this.update({ isOpen: !this.isOpen });
+ }
+
+ //----------------------------------------------------------------------
+ // Private
+ //----------------------------------------------------------------------
+
+ /**
+ * @private
+ * @returns {integer}
+ */
+ _updateCounter() {
+ if (!this.env.messaging) {
+ return 0;
+ }
+ const inboxMailbox = this.env.messaging.inbox;
+ const unreadChannels = this.env.models['mail.thread'].all(thread =>
+ thread.message_unread_counter > 0 &&
+ thread.model === 'mail.channel'
+ );
+ let counter = unreadChannels.length;
+ if (inboxMailbox) {
+ counter += inboxMailbox.counter;
+ }
+ if (!this.messaging) {
+ // compute after delete
+ return counter;
+ }
+ if (this.messaging.notificationGroupManager) {
+ counter += this.messaging.notificationGroupManager.groups.reduce(
+ (total, group) => total + group.notifications.length,
+ 0
+ );
+ }
+ return counter;
+ }
+
+ /**
+ * @override
+ */
+ _updateAfter(previous) {
+ const counter = this._updateCounter();
+ if (this.counter !== counter) {
+ this.update({ counter });
+ }
+ }
+
+ }
+
+ MessagingMenu.fields = {
+ /**
+ * Tab selected in the messaging menu.
+ * Either 'all', 'chat' or 'channel'.
+ */
+ activeTabId: attr({
+ default: 'all',
+ }),
+ counter: attr({
+ default: 0,
+ }),
+ /**
+ * Determine whether the mobile new message input is visible or not.
+ */
+ isMobileNewMessageToggled: attr({
+ default: false,
+ }),
+ /**
+ * Determine whether the messaging menu dropdown is open or not.
+ */
+ isOpen: attr({
+ default: false,
+ }),
+ messaging: one2one('mail.messaging', {
+ inverse: 'messagingMenu',
+ }),
+ };
+
+ MessagingMenu.modelName = 'mail.messaging_menu';
+
+ return MessagingMenu;
+}
+
+registerNewModel('mail.messaging_menu', factory);
+
+});
diff --git a/addons/mail/static/src/models/messaging_notification_handler/messaging_notification_handler.js b/addons/mail/static/src/models/messaging_notification_handler/messaging_notification_handler.js
new file mode 100644
index 00000000000..e779b5a20ff
--- /dev/null
+++ b/addons/mail/static/src/models/messaging_notification_handler/messaging_notification_handler.js
@@ -0,0 +1,744 @@
+odoo.define('mail/static/src/models/messaging_notification_handler/messaging_notification_handler.js', function (require) {
+'use strict';
+
+const { registerNewModel } = require('mail/static/src/model/model_core.js');
+const { one2one } = require('mail/static/src/model/model_field.js');
+
+const PREVIEW_MSG_MAX_SIZE = 350; // optimal for native English speakers
+
+function factory(dependencies) {
+
+ class MessagingNotificationHandler extends dependencies['mail.model'] {
+
+ /**
+ * @override
+ */
+ delete() {
+ this.env.services['bus_service'].off('notification');
+ this.env.services['bus_service'].stopPolling();
+ super.delete();
+ }
+
+ //----------------------------------------------------------------------
+ // Public
+ //----------------------------------------------------------------------
+
+ /**
+ * Fetch messaging data initially to populate the store specifically for
+ * the current users. This includes pinned channels for instance.
+ */
+ start() {
+ this.env.services.bus_service.onNotification(null, notifs => this._handleNotifications(notifs));
+ this.env.services.bus_service.startPolling();
+ }
+
+ //----------------------------------------------------------------------
+ // Private
+ //----------------------------------------------------------------------
+
+ /**
+ * @private
+ * @param {Object[]} notifications
+ * @returns {Object[]}
+ */
+ _filterNotificationsOnUnsubscribe(notifications) {
+ const unsubscribedNotif = notifications.find(notif =>
+ notif[1].info === 'unsubscribe');
+ if (unsubscribedNotif) {
+ notifications = notifications.filter(notif =>
+ notif[0][1] !== 'mail.channel' ||
+ notif[0][2] !== unsubscribedNotif[1].id
+ );
+ }
+ return notifications;
+ }
+
+ /**
+ * @private
+ * @param {Object[]} notifications
+ * @param {Array} notifications[i][0] meta-data of the notification.
+ * @param {string} notifications[i][0][0] name of database this
+ * notification comes from.
+ * @param {string} notifications[i][0][1] type of notification.
+ * @param {integer} notifications[i][0][2] usually id of related type
+ * of notification. For instance, with `mail.channel`, this is the id
+ * of the channel.
+ * @param {Object} notifications[i][1] payload of the notification
+ */
+ async _handleNotifications(notifications) {
+ const filteredNotifications = this._filterNotificationsOnUnsubscribe(notifications);
+ const proms = filteredNotifications.map(notification => {
+ const [[, model, id], data] = notification;
+ switch (model) {
+ case 'ir.needaction':
+ return this._handleNotificationNeedaction(data);
+ case 'mail.channel':
+ return this._handleNotificationChannel(Object.assign({
+ channelId: id,
+ }, data));
+ case 'res.partner':
+ return this._handleNotificationPartner(Object.assign({}, data));
+ default:
+ console.warn(`mail.messaging_notification_handler: Unhandled notification "${model}"`);
+ return;
+ }
+ });
+ await this.async(() => Promise.all(proms));
+ }
+
+ /**
+ * @private
+ * @param {Object} data
+ * @param {integer} data.channelId
+ * @param {string} [data.info]
+ * @param {boolean} [data.is_typing]
+ * @param {integer} [data.last_message_id]
+ * @param {integer} [data.partner_id]
+ */
+ async _handleNotificationChannel(data) {
+ const {
+ channelId,
+ id,
+ info,
+ is_typing,
+ last_message_id,
+ partner_id,
+ } = data;
+ switch (info) {
+ case 'channel_fetched':
+ return this._handleNotificationChannelFetched({
+ id,
+ channelId,
+ last_message_id,
+ partner_id,
+ });
+ case 'channel_seen':
+ return this._handleNotificationChannelSeen({
+ id,
+ channelId,
+ last_message_id,
+ partner_id,
+ });
+ case 'typing_status':
+ return this._handleNotificationChannelTypingStatus({
+ channelId,
+ is_typing,
+ partner_id,
+ });
+ default:
+ return this._handleNotificationChannelMessage(data);
+ }
+ }
+
+ /**
+ * @private
+ * @param {Object} param0
+ * @param {integer} param0.channelId
+ * @param {integer} param0.id
+ * @param {integer} param0.last_message_id
+ * @param {integer} param0.partner_id
+ */
+ async _handleNotificationChannelFetched({
+ id,
+ channelId,
+ last_message_id,
+ partner_id,
+ }) {
+ const channel = this.env.models['mail.thread'].find(thread =>
+ thread.id === channelId && thread.model === 'mail.channel'
+ );
+ if (!channel) {
+ // for example seen from another browser, the current one has no
+ // knowledge of the channel
+ return;
+ }
+ channel.update({
+ partnerSeenInfos: [['insert',
+ {
+ lastFetchedMessage: [['insert', {id: last_message_id}]],
+ id,
+ partner: [['insert', {id: partner_id}]],
+ }
+ ]],
+ messageSeenIndicators: [['insert',
+ {
+ id: this.env.models['mail.message_seen_indicator'].computeId(last_message_id, channel.id),
+ message: [['insert', {id: last_message_id}]],
+ }
+ ]],
+ });
+ // FIXME force the computing of message values (cf task-2261221)
+ this.env.models['mail.message_seen_indicator'].recomputeFetchedValues(channel);
+ }
+
+ /**
+ * @private
+ * @param {Object} param0
+ * @param {integer} param0.channelId
+ * @param {...Object} param0.data
+ * @param {Array} [param0.data.author_id]
+ * @param {integer} param0.data.author_id[0]
+ * @param {integer[]} param0.data.channel_ids
+ */
+ async _handleNotificationChannelMessage(param0) {
+ const channelId = param0.channelId;
+ const convertedData = this.env.models['mail.message'].convertData(param0);
+
+ const oldMessage = this.env.models['mail.message'].find(message =>
+ message.id === convertedData.id
+ );
+ // locally save old value, as insert would overwrite them
+ const oldMessageModerationStatus = (
+ oldMessage && oldMessage.moderation_status
+ );
+ const oldMessageWasModeratedByCurrentPartner = (
+ oldMessage && oldMessage.isModeratedByCurrentPartner
+ );
+
+ const message = this.env.models['mail.message'].insert(convertedData);
+
+ // join the corresponding channel if necessary
+ const channel = this.env.models['mail.thread'].find(thread =>
+ thread.id === channelId &&
+ thread.model === 'mail.channel'
+ );
+ if (!channel || !channel.isPinned) {
+ this.env.models['mail.thread'].joinChannel(channelId);
+ }
+
+ // If the message was already known: nothing else should be done,
+ // except if it was pending moderation by the current partner, then
+ // decrement the moderation counter.
+ if (oldMessage) {
+ if (
+ oldMessageModerationStatus === 'pending_moderation' &&
+ message.moderation_status !== 'pending_moderation' &&
+ oldMessageWasModeratedByCurrentPartner
+ ) {
+ const moderation = this.env.messaging.moderation;
+ moderation.update({ counter: moderation.counter - 1 });
+ }
+ return;
+ }
+
+ // If the current partner is author, do nothing else.
+ if (message.author === this.env.messaging.currentPartner) {
+ return;
+ }
+
+ // If the channel wasn't known, joining the channel already updated
+ // the counter.
+ if (!channel) {
+ return;
+ }
+
+ // Message from mailing channel should not make a notification in
+ // Odoo for users with notification "Handled by Email".
+ // Channel has been marked as read server-side in this case, so
+ // it should not display a notification by incrementing the
+ // unread counter.
+ if (
+ channel &&
+ channel.mass_mailing &&
+ this.env.session.notification_type === 'email'
+ ) {
+ return;
+ }
+
+ // In all other cases: update counter and notify if out of focus.
+ const isOdooFocused = this.env.services['bus_service'].isOdooFocused();
+ if (!isOdooFocused) {
+ this._notifyNewChannelMessageWhileOutOfFocus({ channel, message });
+ }
+ if (channel.model === 'mail.channel') {
+ channel.markAsFetched();
+ }
+ channel.update({ message_unread_counter: channel.message_unread_counter + 1 });
+ // manually force recompute of counter
+ this.messaging.messagingMenu.update();
+ }
+
+ /**
+ * Called when a channel has been seen, and the server responses with the
+ * last message seen. Useful in order to track last message seen.
+ *
+ * @private
+ * @param {Object} param0
+ * @param {integer} param0.channelId
+ * @param {integer} param0.id
+ * @param {integer} param0.last_message_id
+ * @param {integer} param0.partner_id
+ */
+ async _handleNotificationChannelSeen({
+ channelId,
+ id,
+ last_message_id,
+ partner_id,
+ }) {
+ const channel = this.env.models['mail.thread'].find(thread =>
+ thread.id === channelId && thread.model === 'mail.channel'
+ );
+ if (!channel) {
+ // for example seen from another browser, the current one has no
+ // knowledge of the channel
+ return;
+ }
+ const lastMessage = this.env.models['mail.message'].insert({id: last_message_id});
+ const updateData = {
+ partnerSeenInfos: [['insert',
+ {
+ id,
+ lastFetchedMessage: [['link', lastMessage]],
+ lastSeenMessage: [['link', lastMessage]],
+ partner: [['insert', {id: partner_id}]],
+ }],
+ ],
+ messageSeenIndicators: [['insert',
+ {
+ id: this.env.models['mail.message_seen_indicator'].computeId(last_message_id, channel.id),
+ message: [['link', lastMessage]],
+ }
+ ]],
+ };
+ if (this.env.messaging.currentPartner.id === partner_id) {
+ Object.assign(updateData, {
+ message_unread_counter: 0,
+ seen_message_id: last_message_id,
+ });
+ }
+ channel.update(updateData);
+ // FIXME force the computing of thread values (cf task-2261221)
+ this.env.models['mail.thread'].computeLastCurrentPartnerMessageSeenByEveryone(channel);
+ // FIXME force the computing of message values (cf task-2261221)
+ this.env.models['mail.message_seen_indicator'].recomputeSeenValues(channel);
+ // manually force recompute of counter
+ this.messaging.messagingMenu.update();
+ }
+
+ /**
+ * @private
+ * @param {Object} param0
+ * @param {integer} param0.channelId
+ * @param {boolean} param0.is_typing
+ * @param {integer} param0.partner_id
+ */
+ _handleNotificationChannelTypingStatus({
+ channelId,
+ is_typing,
+ partner_id,
+ }) {
+ const channel = this.env.models['mail.thread'].insert({
+ id: channelId,
+ model: 'mail.channel',
+ });
+ const partner = this.env.models['mail.partner'].insert({ id: partner_id });
+ if (partner === this.env.messaging.currentPartner) {
+ // Ignore management of current partner is typing notification.
+ return;
+ }
+ if (is_typing) {
+ if (channel.typingMembers.includes(partner)) {
+ channel.refreshOtherMemberTypingMember(partner);
+ } else {
+ channel.registerOtherMemberTypingMember(partner);
+ }
+ } else {
+ if (!channel.typingMembers.includes(partner)) {
+ // Ignore no longer typing notifications of members that
+ // are not registered as typing something.
+ return;
+ }
+ channel.unregisterOtherMemberTypingMember(partner);
+ }
+ }
+
+ /**
+ * @private
+ * @param {Object} data
+ */
+ _handleNotificationNeedaction(data) {
+ const message = this.env.models['mail.message'].insert(
+ this.env.models['mail.message'].convertData(data)
+ );
+ const inboxMailbox = this.env.messaging.inbox;
+ inboxMailbox.update({ counter: inboxMailbox.counter + 1 });
+ for (const thread of message.allThreads) {
+ if (
+ thread.channel_type === 'channel' &&
+ message.allThreads.includes(inboxMailbox)
+ ) {
+ thread.update({ message_needaction_counter: thread.message_needaction_counter + 1 });
+ }
+ const mainThreadCache = thread.mainCache;
+ mainThreadCache.update({ messages: [['link', message]] });
+ }
+ // manually force recompute of counter
+ this.messaging.messagingMenu.update();
+ }
+
+ /**
+ * @private
+ * @param {Object} data
+ * @param {string} [data.info]
+ * @param {string} [data.type]
+ */
+ async _handleNotificationPartner(data) {
+ const {
+ info,
+ type,
+ } = data;
+ if (type === 'activity_updated') {
+ this.env.bus.trigger('activity_updated', data);
+ } else if (type === 'author') {
+ return this._handleNotificationPartnerAuthor(data);
+ } else if (type === 'deletion') {
+ return this._handleNotificationPartnerDeletion(data);
+ } else if (type === 'message_notification_update') {
+ return this._handleNotificationPartnerMessageNotificationUpdate(data.elements);
+ } else if (type === 'mark_as_read') {
+ return this._handleNotificationPartnerMarkAsRead(data);
+ } else if (type === 'moderator') {
+ return this._handleNotificationPartnerModerator(data);
+ } else if (type === 'simple_notification') {
+ const escapedTitle = owl.utils.escape(data.title);
+ const escapedMessage = owl.utils.escape(data.message);
+ this.env.services['notification'].notify({
+ message: escapedMessage,
+ sticky: data.sticky,
+ title: escapedTitle,
+ type: data.warning ? 'warning' : 'danger',
+ });
+ } else if (type === 'toggle_star') {
+ return this._handleNotificationPartnerToggleStar(data);
+ } else if (info === 'transient_message') {
+ return this._handleNotificationPartnerTransientMessage(data);
+ } else if (info === 'unsubscribe') {
+ return this._handleNotificationPartnerUnsubscribe(data.id);
+ } else if (type === 'user_connection') {
+ return this._handleNotificationPartnerUserConnection(data);
+ } else {
+ return this._handleNotificationPartnerChannel(data);
+ }
+ }
+
+ /**
+ * @private
+ * @param {Object} data
+ * @param {Object} data.message
+ */
+ _handleNotificationPartnerAuthor(data) {
+ this.env.models['mail.message'].insert(
+ this.env.models['mail.message'].convertData(data.message)
+ );
+ }
+
+ /**
+ * @private
+ * @param {Object} data
+ * @param {string} data.channel_type
+ * @param {integer} data.id
+ * @param {string} [data.info]
+ * @param {boolean} data.is_minimized
+ * @param {string} data.name
+ * @param {string} data.state
+ * @param {string} data.uuid
+ */
+ _handleNotificationPartnerChannel(data) {
+ const convertedData = this.env.models['mail.thread'].convertData(data);
+
+ let channel = this.env.models['mail.thread'].find(thread =>
+ thread.id === convertedData.id &&
+ thread.model === 'mail.channel'
+ );
+ const wasCurrentPartnerMember = (
+ channel &&
+ channel.members.includes(this.env.messaging.currentPartner)
+ );
+
+ channel = this.env.models['mail.thread'].insert(Object.assign(
+ { isPinned: true },
+ convertedData,
+ ));
+
+ if (
+ channel.channel_type === 'channel' &&
+ data.info !== 'creation' &&
+ !wasCurrentPartnerMember
+ ) {
+ this.env.services['notification'].notify({
+ message: _.str.sprintf(
+ this.env._t("You have been invited to: %s"),
+ owl.utils.escape(channel.name)
+ ),
+ title: this.env._t("Invitation"),
+ type: 'warning',
+ });
+ }
+ // a new thread with unread messages could have been added
+ // manually force recompute of counter
+ this.messaging.messagingMenu.update();
+ }
+
+ /**
+ * @private
+ * @param {Object} param0
+ * @param {integer[]} param0.messag_ids
+ */
+ _handleNotificationPartnerDeletion({ message_ids }) {
+ const moderationMailbox = this.env.messaging.moderation;
+ for (const id of message_ids) {
+ const message = this.env.models['mail.message'].find(message => message.id === id);
+ if (message) {
+ if (
+ message.moderation_status === 'pending_moderation' &&
+ message.originThread.isModeratedByCurrentPartner
+ ) {
+ moderationMailbox.update({ counter: moderationMailbox.counter - 1 });
+ }
+ message.delete();
+ }
+ }
+ // deleting message might have deleted notifications, force recompute
+ this.messaging.notificationGroupManager.computeGroups();
+ // manually force recompute of counter (after computing the groups)
+ this.messaging.messagingMenu.update();
+ }
+
+ /**
+ * @private
+ * @param {Object} data
+ */
+ _handleNotificationPartnerMessageNotificationUpdate(data) {
+ for (const messageData of data) {
+ const message = this.env.models['mail.message'].insert(
+ this.env.models['mail.message'].convertData(messageData)
+ );
+ // implicit: failures are sent by the server as notification
+ // only if the current partner is author of the message
+ if (!message.author && this.messaging.currentPartner) {
+ message.update({ author: [['link', this.messaging.currentPartner]] });
+ }
+ }
+ this.messaging.notificationGroupManager.computeGroups();
+ // manually force recompute of counter (after computing the groups)
+ this.messaging.messagingMenu.update();
+ }
+
+ /**
+ * @private
+ * @param {Object} param0
+ * @param {integer[]} [param0.channel_ids
+ * @param {integer[]} [param0.message_ids=[]]
+ */
+ _handleNotificationPartnerMarkAsRead({ channel_ids, message_ids = [] }) {
+ const inboxMailbox = this.env.messaging.inbox;
+ const historyMailbox = this.env.messaging.history;
+ const mainHistoryThreadCache = historyMailbox.mainCache;
+
+ // 1. move messages from inbox to history
+ for (const cache of inboxMailbox.caches) {
+ const messages = message_ids
+ .map(id => this.env.models['mail.message'].find(message => message.id === id))
+ .filter(message => !!message);
+ cache.update({ messages: [['unlink', messages]] });
+ // TODO FIXME cannot update other caches of history since the
+ // messages in them depend on the search domain
+ mainHistoryThreadCache.update({ messages: [['link', messages]] });
+ }
+
+ // 2. remove "needaction" from channels
+ let channels;
+ if (channel_ids) {
+ channels = channel_ids
+ .map(id => this.env.models['mail.thread'].find(thread =>
+ thread.id === id &&
+ thread.model === 'mail.channel'
+ ))
+ .filter(thread => !!thread);
+ } else {
+ // flux specific: channel_ids unset means "mark all as read"
+ channels = this.env.models['mail.thread'].all(thread =>
+ thread.model === 'mail.channel'
+ );
+ }
+ for (const channel of channels) {
+ channel.update({ message_needaction_counter: 0 });
+ }
+ inboxMailbox.update({ counter: inboxMailbox.counter - message_ids.length });
+
+ // manually force recompute of counter
+ this.messaging.messagingMenu.update();
+ }
+
+ /**
+ * @private
+ * @param {Object} param0
+ * @param {Object} param0.message
+ */
+ _handleNotificationPartnerModerator({ message: data }) {
+ this.env.models['mail.message'].insert(
+ this.env.models['mail.message'].convertData(data)
+ );
+ const moderationMailbox = this.env.messaging.moderation;
+ if (moderationMailbox) {
+ moderationMailbox.update({ counter: moderationMailbox.counter + 1 });
+ }
+ // manually force recompute of counter
+ this.messaging.messagingMenu.update();
+ }
+
+ /**
+ * @private
+ * @param {Object} param0
+ * @param {integer[]} param0.message_ids
+ * @param {boolean} param0.starred
+ */
+ _handleNotificationPartnerToggleStar({ message_ids = [], starred }) {
+ const starredMailbox = this.env.messaging.starred;
+ for (const messageId of message_ids) {
+ const message = this.env.models['mail.message'].find(message =>
+ message.id === messageId
+ );
+ if (!message) {
+ continue;
+ }
+ if (starred) {
+ message.update({ threadCaches: [['link', starredMailbox.mainCache]] });
+ starredMailbox.update({ counter: starredMailbox.counter + 1 });
+ } else {
+ for (const cache of starredMailbox.caches) {
+ cache.update({ messages: [['unlink', message]] });
+ }
+ starredMailbox.update({ counter: starredMailbox.counter - 1 });
+ }
+ }
+ }
+
+ /**
+ * On receiving a transient message, i.e. a message which does not come
+ * from a member of the channel. Usually a log message, such as one
+ * generated from a command with ('/').
+ *
+ * @private
+ * @param {Object} data
+ */
+ _handleNotificationPartnerTransientMessage(data) {
+ const convertedData = this.env.models['mail.message'].convertData(data);
+ const messageIds = this.env.models['mail.message'].all().map(message => message.id);
+ const partnerRoot = this.env.messaging.partnerRoot;
+ this.env.models['mail.message'].create(Object.assign(convertedData, {
+ author: [['link', partnerRoot]],
+ id: (messageIds ? Math.max(...messageIds) : 0) + 0.01,
+ isTransient: true,
+ }));
+ }
+
+ /**
+ * @private
+ * @param {integer} channelId
+ */
+ _handleNotificationPartnerUnsubscribe(channelId) {
+ const channel = this.env.models['mail.thread'].find(thread =>
+ thread.id === channelId &&
+ thread.model === 'mail.channel'
+ );
+ if (!channel) {
+ return;
+ }
+ let message;
+ if (channel.correspondent) {
+ const correspondent = channel.correspondent;
+ message = _.str.sprintf(
+ this.env._t("You unpinned your conversation with <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)
+ );
+ }
+ this.env.services['notification'].notify({
+ message,
+ title: this.env._t("Unsubscribed"),
+ type: 'warning',
+ });
+ channel.update({ isPinned: false });
+ }
+
+ /**
+ * @private
+ * @param {Object} param0
+ * @param {string} param0.message
+ * @param {integer} param0.partner_id
+ * @param {string} param0.title
+ */
+ _handleNotificationPartnerUserConnection({ message, partner_id, title }) {
+ this.env.services['bus_service'].sendNotification(title, message);
+ const partner = this.env.models['mail.partner'].insert({
+ id: partner_id,
+ });
+ partner.openChat();
+ }
+
+ /**
+ * @private
+ * @param {Object} param0
+ * @param {mail.thread} param0.channel
+ * @param {mail.message} param0.message
+ */
+ _notifyNewChannelMessageWhileOutOfFocus({ channel, message }) {
+ const author = message.author;
+ const messaging = this.env.messaging;
+ let notificationTitle;
+ if (!author) {
+ notificationTitle = this.env._t("New message");
+ } else {
+ const authorName = author.nameOrDisplayName;
+ if (channel.channel_type === 'channel') {
+ // hack: notification template does not support OWL components,
+ // so we simply use their template to make HTML as if it comes
+ // from component
+ const channelIcon = this.env.qweb.renderToString('mail.ThreadIcon', {
+ env: this.env,
+ thread: channel,
+ });
+ const channelName = owl.utils.escape(channel.displayName);
+ const channelNameWithIcon = channelIcon + channelName;
+ notificationTitle = _.str.sprintf(
+ this.env._t("%s from %s"),
+ owl.utils.escape(authorName),
+ channelNameWithIcon
+ );
+ } else {
+ notificationTitle = owl.utils.escape(authorName);
+ }
+ }
+ const notificationContent = message.prettyBody.substr(0, PREVIEW_MSG_MAX_SIZE);
+ this.env.services['bus_service'].sendNotification(notificationTitle, notificationContent);
+ messaging.update({ outOfFocusUnreadMessageCounter: messaging.outOfFocusUnreadMessageCounter + 1 });
+ const titlePattern = messaging.outOfFocusUnreadMessageCounter === 1
+ ? this.env._t("%d Message")
+ : this.env._t("%d Messages");
+ this.env.bus.trigger('set_title_part', {
+ part: '_chat',
+ title: _.str.sprintf(titlePattern, messaging.outOfFocusUnreadMessageCounter),
+ });
+ }
+
+ }
+
+ MessagingNotificationHandler.fields = {
+ messaging: one2one('mail.messaging', {
+ inverse: 'notificationHandler',
+ }),
+ };
+
+ MessagingNotificationHandler.modelName = 'mail.messaging_notification_handler';
+
+ return MessagingNotificationHandler;
+}
+
+registerNewModel('mail.messaging_notification_handler', factory);
+
+});
diff --git a/addons/mail/static/src/models/model/model.js b/addons/mail/static/src/models/model/model.js
new file mode 100644
index 00000000000..15647acaf49
--- /dev/null
+++ b/addons/mail/static/src/models/model/model.js
@@ -0,0 +1,245 @@
+odoo.define('mail/static/src/models/Model', function (require) {
+'use strict';
+
+const { registerNewModel } = require('mail/static/src/model/model_core.js');
+const { RecordDeletedError } = require('mail/static/src/model/model_errors.js');
+
+/**
+ * This function generates a class that represent a model. Instances of such
+ * model (or inherited models) represent logical objects used in whole
+ * application. They could represent server record (e.g. Thread, Message) or
+ * UI elements (e.g. MessagingMenu, ChatWindow). These instances are called
+ * "records", while the classes are called "models".
+ */
+function factory() {
+
+ class Model {
+
+ /**
+ * @param {Object} [param0={}]
+ * @param {boolean} [param0.valid=false] if set, this constructor is
+ * called by static method `create()`. This should always be the case.
+ * @throws {Error} in case constructor is called in an invalid way, i.e.
+ * by instantiating the record manually with `new` instead of from
+ * static method `create()`.
+ */
+ constructor({ valid = false } = {}) {
+ if (!valid) {
+ throw new Error("Record must always be instantiated from static method 'create()'");
+ }
+ }
+
+ /**
+ * Called when the record is being created, but not yet processed
+ * its create value on the fields. This method is handy to define purely
+ * technical property on this record, like handling of timers. This
+ * method acts like the constructor, but has a very important difference:
+ * the `this` is the proxified record, so evaluation of field values
+ * on get/set work correctly.
+ */
+ init() {}
+
+ //--------------------------------------------------------------------------
+ // Public
+ //--------------------------------------------------------------------------
+
+ /**
+ * Returns all records of this model that match provided criteria.
+ *
+ * @static
+ * @param {function} [filterFunc]
+ * @returns {mail.model[]}
+ */
+ static all(filterFunc) {
+ return this.env.modelManager.all(this, filterFunc);
+ }
+
+ /**
+ * This method is used to create new records of this model
+ * with provided data. This is the only way to create them:
+ * instantiation must never been done with keyword `new` outside of this
+ * function, otherwise the record will not be registered.
+ *
+ * @static
+ * @param {Object} [data] data object with initial data, including relations.
+ * @returns {mail.model} newly created record
+ */
+ static create(data) {
+ return this.env.modelManager.create(this, data);
+ }
+
+ /**
+ * Get the record that has provided criteria, if it exists.
+ *
+ * @static
+ * @param {function} findFunc
+ * @returns {mail.model|undefined}
+ */
+ static find(findFunc) {
+ return this.env.modelManager.find(this, findFunc);
+ }
+
+ /**
+ * This method returns the record of this model that matches provided
+ * local id. Useful to convert a local id to a record. Note that even
+ * if there's a record in the system having provided local id, if the
+ * resulting record is not an instance of this model, this getter
+ * assumes the record does not exist.
+ *
+ * @static
+ * @param {string|mail.model|undefined} recordOrLocalId
+ * @returns {mail.model|undefined}
+ */
+ static get(recordOrLocalId) {
+ return this.env.modelManager.get(this, recordOrLocalId);
+ }
+
+ /**
+ * This method creates a record or updates one, depending
+ * on provided data.
+ *
+ * @static
+ * @param {Object} data
+ * @returns {mail.model} created or updated record.
+ */
+ static insert(data) {
+ return this.env.modelManager.insert(this, data);
+ }
+
+ /**
+ * Perform an async function and wait until it is done. If the record
+ * is deleted, it raises a RecordDeletedError.
+ *
+ * @param {function} func an async function
+ * @throws {RecordDeletedError} in case the current record is not alive
+ * at the end of async function call, whether it's resolved or
+ * rejected.
+ * @throws {any} forwards any error in case the current record is still
+ * alive at the end of rejected async function call.
+ * @returns {any} result of resolved async function.
+ */
+ async async(func) {
+ return new Promise((resolve, reject) => {
+ Promise.resolve(func()).then(result => {
+ if (this.constructor.get(this)) {
+ resolve(result);
+ } else {
+ reject(new RecordDeletedError(this.localId));
+ }
+ }).catch(error => {
+ if (this.constructor.get(this)) {
+ reject(error);
+ } else {
+ reject(new RecordDeletedError(this.localId));
+ }
+ });
+ });
+ }
+
+ /**
+ * This method deletes this record.
+ */
+ delete() {
+ this.env.modelManager.delete(this);
+ }
+
+ /**
+ * Update this record with provided data.
+ *
+ * @param {Object} [data={}]
+ */
+ update(data = {}) {
+ this.env.modelManager.update(this, data);
+ }
+
+ //--------------------------------------------------------------------------
+ // Private
+ //--------------------------------------------------------------------------
+
+ /**
+ * @static
+ * @private
+ * @param {Object} data
+ * @param {any} data.id
+ * @return {function}
+ */
+ static _findFunctionFromData(data) {
+ return record => record.id === data.id;
+ }
+
+ /**
+ * This method generates a local id for this record that is
+ * being created at the moment.
+ *
+ * This function helps customizing the local id to ease mapping a local
+ * id to its record for the developer that reads the local id. For
+ * instance, the local id of a thread cache could combine the thread
+ * and stringified domain in its local id, which is much easier to
+ * track relations and records in the system instead of arbitrary
+ * number to differenciate them.
+ *
+ * @private
+ * @param {Object} data
+ * @returns {string}
+ */
+ _createRecordLocalId(data) {
+ return _.uniqueId(`${this.constructor.modelName}_`);
+ }
+
+ /**
+ * This function is called when this record has been explicitly updated
+ * with `.update()` or static method `.create()`, at the end of an
+ * record update cycle. This is a backward-compatible behaviour that
+ * is deprecated: you should use computed fields instead.
+ *
+ * @deprecated
+ * @abstract
+ * @private
+ * @param {Object} previous contains data that have been stored by
+ * `_updateBefore()`. Useful to make extra update decisions based on
+ * previous data.
+ */
+ _updateAfter(previous) {}
+
+ /**
+ * This function is called just at the beginning of an explicit update
+ * on this function, with `.update()` or static method `.create()`. This
+ * is useful to remember previous values of fields in `_updateAfter`.
+ * This is a backward-compatible behaviour that is deprecated: you
+ * should use computed fields instead.
+ *
+ * @deprecated
+ * @abstract
+ * @private
+ * @param {Object} data
+ * @returns {Object}
+ */
+ _updateBefore() {
+ return {};
+ }
+
+ }
+
+ /**
+ * Models should define fields in static prop or getter `fields`.
+ * It contains an object with name of field as key and value are objects
+ * that define the field. There are some helpers to ease the making of these
+ * objects, @see `mail/static/src/model/model_field.js`
+ *
+ * Note: fields of super-class are automatically inherited, therefore a
+ * sub-class should (re-)define fields without copying ancestors' fields.
+ */
+ Model.fields = {};
+
+ /**
+ * Name of the model. Important to refer to appropriate model class
+ * like in relational fields. Name of model classes must be unique.
+ */
+ Model.modelName = 'mail.model';
+
+ return Model;
+}
+
+registerNewModel('mail.model', factory);
+
+});
diff --git a/addons/mail/static/src/models/notification/notification.js b/addons/mail/static/src/models/notification/notification.js
new file mode 100644
index 00000000000..95e8a013876
--- /dev/null
+++ b/addons/mail/static/src/models/notification/notification.js
@@ -0,0 +1,69 @@
+odoo.define('mail/static/src/models/notification/notification.js', function (require) {
+'use strict';
+
+const { registerNewModel } = require('mail/static/src/model/model_core.js');
+const { attr, many2one } = require('mail/static/src/model/model_field.js');
+
+function factory(dependencies) {
+
+ class Notification extends dependencies['mail.model'] {
+
+ //----------------------------------------------------------------------
+ // Public
+ //----------------------------------------------------------------------
+
+ /**
+ * @static
+ * @param {Object} data
+ * @return {Object}
+ */
+ static convertData(data) {
+ const data2 = {};
+ if ('failure_type' in data) {
+ data2.failure_type = data.failure_type;
+ }
+ if ('id' in data) {
+ data2.id = data.id;
+ }
+ if ('notification_status' in data) {
+ data2.notification_status = data.notification_status;
+ }
+ if ('notification_type' in data) {
+ data2.notification_type = data.notification_type;
+ }
+ if ('partner_id' in data) {
+ if (!data.partner_id) {
+ data2.partner = [['unlink-all']];
+ } else {
+ data2.partner = [
+ ['insert', {
+ display_name: data.partner_id[1],
+ id: data.partner_id[0],
+ }],
+ ];
+ }
+ }
+ return data2;
+ }
+
+ }
+
+ Notification.fields = {
+ failure_type: attr(),
+ id: attr(),
+ message: many2one('mail.message', {
+ inverse: 'notifications',
+ }),
+ notification_status: attr(),
+ notification_type: attr(),
+ partner: many2one('mail.partner'),
+ };
+
+ Notification.modelName = 'mail.notification';
+
+ return Notification;
+}
+
+registerNewModel('mail.notification', factory);
+
+});
diff --git a/addons/mail/static/src/models/notification_group/notification_group.js b/addons/mail/static/src/models/notification_group/notification_group.js
new file mode 100644
index 00000000000..9fdbefc4979
--- /dev/null
+++ b/addons/mail/static/src/models/notification_group/notification_group.js
@@ -0,0 +1,115 @@
+odoo.define('mail/static/src/models/notification_group/notification_group.js', function (require) {
+'use strict';
+
+const { registerNewModel } = require('mail/static/src/model/model_core.js');
+const { attr, one2many } = require('mail/static/src/model/model_field.js');
+
+function factory(dependencies) {
+
+ class NotificationGroup extends dependencies['mail.model'] {
+
+ //----------------------------------------------------------------------
+ // Public
+ //----------------------------------------------------------------------
+
+ /**
+ * Opens the view that allows to cancel all notifications of the group.
+ */
+ openCancelAction() {
+ const device = this.env.messaging.device;
+ if (this.notification_type === 'email') {
+ this.env.bus.trigger('do-action', {
+ action: 'mail.mail_resend_cancel_action',
+ options: {
+ additional_context: {
+ default_model: this.res_model,
+ unread_counter: this.notifications.length,
+ },
+ },
+ });
+ }
+ if (!device.isMobile) {
+ this.env.messaging.messagingMenu.close();
+ }
+ }
+
+ /**
+ * Opens the view that displays either the single record of the group or
+ * all the records in the group.
+ */
+ openDocuments() {
+ if (this.res_id) {
+ if (this.res_model === 'mail.channel') {
+ const channel = this.env.models['mail.thread'].insert({
+ id: this.res_id,
+ model: this.res_model,
+ });
+ channel.open();
+ } else {
+ this.env.messaging.openDocument({
+ id: this.res_id,
+ model: this.res_model,
+ });
+ }
+ } else {
+ this._openDocuments();
+ }
+ }
+
+ //----------------------------------------------------------------------
+ // Private
+ //----------------------------------------------------------------------
+
+ /**
+ * @override
+ */
+ _createRecordLocalId(data) {
+ const NotificationGroup = this.env.models['mail.notification_group'];
+ return `${NotificationGroup.modelName}_${data.id}`;
+ }
+
+ /**
+ * Opens the view that displays all the records of the group.
+ *
+ * @private
+ */
+ _openDocuments() {
+ const device = this.env.messaging.device;
+ if (this.notification_type === 'email') {
+ this.env.bus.trigger('do-action', {
+ action: {
+ name: this.env._t("Mail Failures"),
+ type: 'ir.actions.act_window',
+ view_mode: 'kanban,list,form',
+ views: [[false, 'kanban'], [false, 'list'], [false, 'form']],
+ target: 'current',
+ res_model: this.res_model,
+ domain: [['message_has_error', '=', true]],
+ },
+ });
+ }
+ if (!device.isMobile) {
+ this.env.messaging.messagingMenu.close();
+ }
+ }
+
+ }
+
+ NotificationGroup.fields = {
+ date: attr(),
+ id: attr(),
+ notification_type: attr(),
+ notifications: one2many('mail.notification'),
+ res_id: attr(),
+ res_model: attr(),
+ res_model_name: attr(),
+ };
+
+ NotificationGroup.modelName = 'mail.notification_group';
+
+ return NotificationGroup;
+}
+
+registerNewModel('mail.notification_group', factory);
+
+});
diff --git a/addons/mail/static/src/models/notification_group_manager/notification_group_manager.js b/addons/mail/static/src/models/notification_group_manager/notification_group_manager.js
new file mode 100644
index 00000000000..be7bef8352a
--- /dev/null
+++ b/addons/mail/static/src/models/notification_group_manager/notification_group_manager.js
@@ -0,0 +1,76 @@
+odoo.define('mail/static/src/models/notification_group_manager/notification_group_manager.js', function (require) {
+'use strict';
+
+const { registerNewModel } = require('mail/static/src/model/model_core.js');
+const { one2many } = require('mail/static/src/model/model_field.js');
+
+function factory(dependencies) {
+
+ class NotificationGroupManager extends dependencies['mail.model'] {
+
+ //----------------------------------------------------------------------
+ // Public
+ //----------------------------------------------------------------------
+
+ computeGroups() {
+ for (const group of this.groups) {
+ group.delete();
+ }
+ const groups = [];
+ this.env.messaging.currentPartner.failureNotifications.forEach(notification => {
+ const thread = notification.message.originThread;
+ // Notifications are grouped by model and notification_type.
+ // Except for channel where they are also grouped by id because
+ // we want to open the actual channel in discuss or chat window
+ // and not its kanban/list/form view.
+ const channelId = thread.model === 'mail.channel' ? thread.id : null;
+ const id = `${thread.model}/${channelId}/${notification.notification_type}`;
+ const group = this.env.models['mail.notification_group'].insert({
+ id,
+ notification_type: notification.notification_type,
+ res_model: thread.model,
+ res_model_name: thread.model_name,
+ });
+ group.update({ notifications: [['link', notification]] });
+ // keep res_id only if all notifications are for the same record
+ // set null if multiple records are present in the group
+ let res_id = group.res_id;
+ if (group.res_id === undefined) {
+ res_id = thread.id;
+ } else if (group.res_id !== thread.id) {
+ res_id = null;
+ }
+ // keep only the most recent date from all notification messages
+ let date = group.date;
+ if (!date) {
+ date = notification.message.date;
+ } else {
+ date = moment.max(group.date, notification.message.date);
+ }
+ group.update({
+ date,
+ res_id,
+ });
+ // avoid linking the same group twice when adding a notification
+ // to an existing group
+ if (!groups.includes(group)) {
+ groups.push(group);
+ }
+ });
+ this.update({ groups: [['link', groups]] });
+ }
+
+ }
+
+ NotificationGroupManager.fields = {
+ groups: one2many('mail.notification_group'),
+ };
+
+ NotificationGroupManager.modelName = 'mail.notification_group_manager';
+
+ return NotificationGroupManager;
+}
+
+registerNewModel('mail.notification_group_manager', factory);
+
+});
diff --git a/addons/mail/static/src/models/partner/partner.js b/addons/mail/static/src/models/partner/partner.js
new file mode 100644
index 00000000000..3bf38a60b95
--- /dev/null
+++ b/addons/mail/static/src/models/partner/partner.js
@@ -0,0 +1,273 @@
+odoo.define('mail/static/src/models/partner/partner.js', function (require) {
+'use strict';
+
+const { registerNewModel } = require('mail/static/src/model/model_core.js');
+const { attr, many2many, many2one, one2many, one2one } = require('mail/static/src/model/model_field.js');
+
+const utils = require('web.utils');
+
+function factory(dependencies) {
+
+ let nextPublicId = -1;
+
+ class Partner extends dependencies['mail.model'] {
+
+ //----------------------------------------------------------------------
+ // Public
+ //----------------------------------------------------------------------
+
+ /**
+ * @static
+ * @private
+ * @param {Object} data
+ * @return {Object}
+ */
+ static convertData(data) {
+ const data2 = {};
+ if ('country' in data) {
+ if (!data.country) {
+ data2.country = [['unlink-all']];
+ } else {
+ data2.country = [['insert', {
+ id: data.country[0],
+ name: data.country[1],
+ }]];
+ }
+ }
+ if ('display_name' in data) {
+ data2.display_name = data.display_name;
+ }
+ if ('email' in data) {
+ data2.email = data.email;
+ }
+ if ('id' in data) {
+ data2.id = data.id;
+ }
+ if ('im_status' in data) {
+ data2.im_status = data.im_status;
+ }
+ if ('name' in data) {
+ data2.name = data.name;
+ }
+
+ // relation
+ if ('user_id' in data) {
+ if (!data.user_id) {
+ data2.user = [['unlink-all']];
+ } else {
+ data2.user = [
+ ['insert', {
+ id: data.user_id[0],
+ partnerDisplayName: data.user_id[1],
+ }],
+ ];
+ }
+ }
+
+ return data2;
+ }
+
+ static getNextPublicId() {
+ const id = nextPublicId;
+ nextPublicId -= 1;
+ return id;
+ }
+
+ /**
+ * Search for partners matching `keyword`.
+ *
+ * @static
+ * @param {Object} param0
+ * @param {function} param0.callback
+ * @param {string} param0.keyword
+ * @param {integer} [param0.limit=10]
+ */
+ static async imSearch({ callback, keyword, limit = 10 }) {
+ // prefetched partners
+ let partners = [];
+ const searchRegexp = new RegExp(
+ _.str.escapeRegExp(utils.unaccent(keyword)),
+ 'i'
+ );
+ const currentPartner = this.env.messaging.currentPartner;
+ for (const partner of this.all(partner => partner.active)) {
+ if (partners.length < limit) {
+ if (
+ partner !== currentPartner &&
+ searchRegexp.test(partner.name)
+ ) {
+ partners.push(partner);
+ }
+ }
+ }
+ if (!partners.length) {
+ const partnersData = await this.env.services.rpc(
+ {
+ model: 'res.partner',
+ method: 'im_search',
+ args: [keyword, limit]
+ },
+ { shadow: true }
+ );
+ for (const data of partnersData) {
+ const partner = this.insert(data);
+ partners.push(partner);
+ }
+ }
+ callback(partners);
+ }
+
+ /**
+ * @static
+ */
+ static async startLoopFetchImStatus() {
+ await this._fetchImStatus();
+ this._loopFetchImStatus();
+ }
+
+ async checkIsUser() {
+ const userIds = await this.async(() => this.env.services.rpc({
+ model: 'res.users',
+ method: 'search',
+ args: [[['partner_id', '=', this.id]]],
+ }));
+ if (userIds.length) {
+ this.update({ user: [['insert', { id: userIds[0] }]] });
+ }
+ }
+
+ /**
+ * Opens an existing or new chat.
+ */
+ openChat() {
+ const chat = this.correspondentThreads.find(thread => thread.channel_type === 'chat');
+ if (chat) {
+ chat.open();
+ } else {
+ this.env.models['mail.thread'].createChannel({
+ autoselect: true,
+ partnerId: this.id,
+ type: 'chat',
+ });
+ }
+ }
+
+ //----------------------------------------------------------------------
+ // Private
+ //----------------------------------------------------------------------
+
+ /**
+ * @static
+ * @private
+ */
+ static async _fetchImStatus() {
+ let toFetchPartnersLocalIds = [];
+ let partnerIdToLocalId = {};
+ const toFetchPartners = this.all(partner => partner.im_status !== null);
+ for (const partner of toFetchPartners) {
+ toFetchPartnersLocalIds.push(partner.localId);
+ partnerIdToLocalId[partner.id] = partner.localId;
+ }
+ if (!toFetchPartnersLocalIds.length) {
+ return;
+ }
+ const dataList = await this.env.services.rpc({
+ route: '/longpolling/im_status',
+ params: {
+ partner_ids: toFetchPartnersLocalIds.map(partnerLocalId =>
+ this.get(partnerLocalId).id
+ ),
+ },
+ }, { shadow: true });
+ for (const { id, im_status } of dataList) {
+ this.insert({ id, im_status });
+ delete partnerIdToLocalId[id];
+ }
+ // partners with no im_status => set null
+ for (const noImStatusPartnerLocalId of Object.values(partnerIdToLocalId)) {
+ const partner = this.get(noImStatusPartnerLocalId);
+ if (partner) {
+ partner.update({ im_status: null });
+ }
+ }
+ }
+
+ /**
+ * @static
+ * @private
+ */
+ static _loopFetchImStatus() {
+ setTimeout(async () => {
+ await this._fetchImStatus();
+ this._loopFetchImStatus();
+ }, 50 * 1000);
+ }
+
+ /**
+ * @private
+ * @returns {string}
+ */
+ _computeNameOrDisplayName() {
+ return this.name || this.display_name;
+ }
+
+ /**
+ * @override
+ */
+ _createRecordLocalId(data) {
+ const Partner = this.env.models['mail.partner'];
+ return `${Partner.modelName}_${data.id}`;
+ }
+
+ }
+
+ Partner.fields = {
+ active: attr({
+ default: true,
+ }),
+ correspondentThreads: one2many('mail.thread', {
+ inverse: 'correspondent',
+ }),
+ country: many2one('mail.country'),
+ display_name: attr({
+ default: "",
+ }),
+ email: attr(),
+ failureNotifications: one2many('mail.notification', {
+ related: 'messagesAsAuthor.failureNotifications',
+ }),
+ id: attr(),
+ im_status: attr(),
+ memberThreads: many2many('mail.thread', {
+ inverse: 'members',
+ }),
+ messagesAsAuthor: one2many('mail.message', {
+ inverse: 'author',
+ }),
+ model: attr({
+ default: 'res.partner',
+ }),
+ moderatedChannelIds: attr({
+ default: [],
+ }),
+ name: attr(),
+ nameOrDisplayName: attr({
+ compute: '_computeNameOrDisplayName',
+ dependencies: [
+ 'display_name',
+ 'name',
+ ],
+ }),
+ user: one2one('mail.user', {
+ inverse: 'partner',
+ }),
+ };
+
+ Partner.modelName = 'mail.partner';
+
+ return Partner;
+}
+
+registerNewModel('mail.partner', factory);
+
+});
diff --git a/addons/mail/static/src/models/thread/thread.js b/addons/mail/static/src/models/thread/thread.js
new file mode 100644
index 00000000000..16f52ab861f
--- /dev/null
+++ b/addons/mail/static/src/models/thread/thread.js
@@ -0,0 +1,1370 @@
+odoo.define('mail/static/src/models/thread/thread.js', function (require) {
+'use strict';
+
+const { registerNewModel } = require('mail/static/src/model/model_core.js');
+const { attr, many2many, many2one, one2many, one2one } = require('mail/static/src/model/model_field.js');
+const throttle = require('mail/static/src/utils/throttle/throttle.js');
+const Timer = require('mail/static/src/utils/timer/timer.js');
+
+function factory(dependencies) {
+
+ class Thread extends dependencies['mail.model'] {
+
+ /**
+ * @override
+ */
+ init(...args) {
+ super.init(...args);
+ /**
+ * Timer of current partner that was currently typing something, but
+ * there is no change on the input for 5 seconds. This is used
+ * in order to automatically notify other members that current
+ * partner has stopped typing something, due to making no changes
+ * on the composer for some time.
+ */
+ this._currentPartnerInactiveTypingTimer = new Timer(
+ this.env,
+ () => this.async(() => this._onCurrentPartnerInactiveTypingTimeout()),
+ 5 * 1000
+ );
+ /**
+ * Last 'is_typing' status of current partner that has been notified
+ * to other members. Useful to prevent spamming typing notifications
+ * to other members if it hasn't changed. An exception is the
+ * current partner long typing scenario where current partner has
+ * to re-send the same typing notification from time to time, so
+ * that other members do not assume he/she is no longer typing
+ * something from not receiving any typing notifications for a
+ * very long time.
+ *
+ * Supported values: true/false/undefined.
+ * undefined makes only sense initially and during current partner
+ * long typing timeout flow.
+ */
+ this._currentPartnerLastNotifiedIsTyping = undefined;
+ /**
+ * Timer of current partner that is typing a very long text. When
+ * the other members do not receive any typing notification for a
+ * long time, they must assume that the related partner is no longer
+ * typing something (e.g. they have closed the browser tab).
+ * This is a timer to let other members know that current partner
+ * is still typing something, so that they should not assume he/she
+ * has stopped typing something.
+ */
+ this._currentPartnerLongTypingTimer = new Timer(
+ this.env,
+ () => this.async(() => this._onCurrentPartnerLongTypingTimeout()),
+ 50 * 1000
+ );
+ /**
+ * Determines whether the next request to notify current partner
+ * typing status should always result to making RPC, regardless of
+ * whether last notified current partner typing status is the same.
+ * Most of the time we do not want to notify if value hasn't
+ * changed, exception being the long typing scenario of current
+ * partner.
+ */
+ this._forceNotifyNextCurrentPartnerTypingStatus = false;
+ /**
+ * Registry of timers of partners currently typing in the thread,
+ * excluding current partner. This is useful in order to
+ * automatically unregister typing members when not receive any
+ * typing notification after a long time. Timers are internally
+ * indexed by partner records as key. The current partner is
+ * ignored in this registry of timers.
+ *
+ * @see registerOtherMemberTypingMember
+ * @see unregisterOtherMemberTypingMember
+ */
+ this._otherMembersLongTypingTimers = new Map();
+
+ /**
+ * Clearable and cancellable throttled version of the
+ * `_notifyCurrentPartnerTypingStatus` method.
+ * This is useful when the current partner posts a message and
+ * types something else afterwards: it must notify immediately that
+ * he/she is typing something, instead of waiting for the throttle
+ * internal timer.
+ *
+ * @see _notifyCurrentPartnerTypingStatus
+ */
+ this._throttleNotifyCurrentPartnerTypingStatus = throttle(
+ this.env,
+ ({ isTyping }) => this.async(() => this._notifyCurrentPartnerTypingStatus({ isTyping })),
+ 2.5 * 1000
+ );
+ }
+
+ /**
+ * @override
+ */
+ delete(...args) {
+ this._currentPartnerInactiveTypingTimer.clear();
+ this._currentPartnerLongTypingTimer.clear();
+ this._throttleNotifyCurrentPartnerTypingStatus.clear();
+ for (const timer of this._otherMembersLongTypingTimers.values()) {
+ timer.clear();
+ }
+ super.delete(...args);
+ }
+
+ //----------------------------------------------------------------------
+ // Public
+ //----------------------------------------------------------------------
+
+ /**
+ * @static
+ * @param {mail.thread} [thread] the concerned thread
+ */
+ static computeLastCurrentPartnerMessageSeenByEveryone(thread = undefined) {
+ const threads = thread ? [thread] : this.env.models['mail.thread'].all();
+ threads.map(localThread => {
+ localThread.update({
+ lastCurrentPartnerMessageSeenByEveryone: localThread._computeLastCurrentPartnerMessageSeenByEveryone(),
+ });
+ });
+ }
+
+ /**
+ * @static
+ * @param {Object} data
+ * @return {Object}
+ */
+ static convertData(data) {
+ const data2 = {};
+ if ('channel_type' in data) {
+ data2.channel_type = data.channel_type;
+ data2.model = 'mail.channel';
+ }
+ if ('create_uid' in data) {
+ data2.creator = [['insert', { id: data.create_uid }]];
+ }
+ if ('custom_channel_name' in data) {
+ data2.custom_channel_name = data.custom_channel_name;
+ }
+ if ('group_based_subscription' in data) {
+ data2.group_based_subscription = data.group_based_subscription;
+ }
+ if ('id' in data) {
+ data2.id = data.id;
+ }
+ if ('is_minimized' in data && 'state' in data) {
+ data2.serverFoldState = data.is_minimized ? data.state : 'closed';
+ }
+ if ('is_moderator' in data) {
+ data2.is_moderator = data.is_moderator;
+ }
+ if ('mass_mailing' in data) {
+ data2.mass_mailing = data.mass_mailing;
+ }
+ if ('moderation' in data) {
+ data2.moderation = data.moderation;
+ }
+ if ('message_needaction_counter' in data) {
+ data2.message_needaction_counter = data.message_needaction_counter;
+ }
+ if ('message_unread_counter' in data) {
+ data2.message_unread_counter = data.message_unread_counter;
+ }
+ if ('name' in data) {
+ data2.name = data.name;
+ }
+ if ('public' in data) {
+ data2.public = data.public;
+ }
+ if ('seen_message_id' in data) {
+ data2.seen_message_id = data.seen_message_id;
+ }
+ if ('uuid' in data) {
+ data2.uuid = data.uuid;
+ }
+
+ // relation
+ if ('direct_partner' in data) {
+ if (!data.direct_partner) {
+ data2.correspondent = [['unlink-all']];
+ } else {
+ data2.correspondent = [
+ ['insert', this.env.models['mail.partner'].convertData(data.direct_partner[0])],
+ ];
+ }
+ }
+ if ('members' in data) {
+ if (!data.members) {
+ data2.members = [['unlink-all']];
+ } else {
+ data2.members = [
+ ['insert-and-replace', data.members.map(memberData =>
+ this.env.models['mail.partner'].convertData(memberData)
+ )],
+ ];
+ }
+ }
+ if ('seen_partners_info' in data) {
+ if (!data.seen_partners_info) {
+ data2.partnerSeenInfos = [['unlink-all']];
+ } else {
+ data2.partnerSeenInfos = [
+ ['insert-and-replace',
+ data.seen_partners_info.map(
+ ({ fetched_message_id, id, partner_id, seen_message_id}) => {
+ return {
+ id,
+ lastFetchedMessage: [seen_message_id ? ['insert', {id: seen_message_id}] : ['unlink-all']],
+ lastSeenMessage: [fetched_message_id ? ['insert', {id: fetched_message_id}] : ['unlink-all']],
+ partner: [['insert', {id: partner_id}]],
+ };
+ })
+ ]
+ ];
+ if (data.id || this.id) {
+ const messageIds = data.seen_partners_info.reduce((currentSet, { fetched_message_id, seen_message_id}) => {
+ if (fetched_message_id) {
+ currentSet.add(fetched_message_id);
+ }
+ if (seen_message_id) {
+ currentSet.add(seen_message_id);
+ }
+ return currentSet;
+ }, new Set());
+ if (messageIds.size > 0) {
+ data2.messageSeenIndicators = [
+ ['insert',
+ [...messageIds].map(messageId => {
+ return {
+ id: this.env.models['mail.message_seen_indicator'].computeId(messageId, data.id || this.id),
+ message: [['insert', {id: messageId}]],
+ };
+ })
+ ]
+ ];
+ }
+ }
+ }
+ }
+
+ return data2;
+ }
+
+ /**
+ * Create a channel, which is a special kind of thread on model
+ * 'mail.channel' with multiple members.
+ *
+ * @static
+ * @param {Object} param0
+ * @param {boolean} [param0.autoselect=false] if set, when channel
+ * has been created, it auto-open the channel. This opens in discuss
+ * or chat window, depending on whether discuss is open or not.
+ * @param {string} [param0.autoselectChatWindowMode]
+ * @param {string} param0.name
+ * @param {integer} [param0.partnerId]
+ * @param {string} [param0.public]
+ * @param {string} param0.type
+ */
+ static async createChannel({
+ autoselect = false,
+ autoselectChatWindowMode,
+ name,
+ partnerId,
+ public: publicStatus,
+ type,
+ }) {
+ const device = this.env.messaging.device;
+ // TODO FIX: potential duplicate chat task-2276490
+ const data = await this.env.services.rpc({
+ model: 'mail.channel',
+ method: type === 'chat' ? 'channel_get' : 'channel_create',
+ args: type === 'chat' ? [[partnerId]] : [name, publicStatus],
+ kwargs: {
+ context: Object.assign({}, this.env.session.user_content, {
+ isMobile: device.isMobile,
+ }),
+ }
+ });
+ const thread = this.insert(Object.assign(
+ { isPinned: true },
+ this.convertData(data)
+ ));
+ if (autoselect) {
+ thread.open({ chatWindowMode: autoselectChatWindowMode });
+ }
+ }
+
+ /**
+ * Join a channel. This channel may not yet exists in the store.
+ *
+ * @static
+ * @param {integer} channelId
+ * @param {Object} [param1={}]
+ * @param {boolean} [param1.autoselect=false]
+ */
+ static async joinChannel(channelId, { autoselect = false } = {}) {
+ const channel = this.find(thread =>
+ thread.id === channelId &&
+ thread.model === 'mail.channel'
+ );
+ if (channel && channel.isPinned) {
+ return;
+ }
+ const data = await this.env.services.rpc({
+ model: 'mail.channel',
+ method: 'channel_join_and_get_info',
+ args: [[channelId]]
+ });
+ const thread = this.insert(Object.assign(
+ { isPinned: true },
+ this.convertData(data)
+ ));
+ if (autoselect) {
+ thread.open({ resetDiscussDomain: true });
+ }
+ }
+
+ /**
+ * Load the previews of the specified threads. Basically, it fetches the
+ * last messages, since they are used to display inline content of them.
+ *
+ * @static
+ * @param {mail.thread[]} threads
+ */
+ static async loadPreviews(threads) {
+ const channelIds = threads.reduce((list, thread) => {
+ if (thread.model === 'mail.channel') {
+ return list.concat(thread.id);
+ }
+ return list;
+ }, []);
+ const messagePreviews = await this.env.services.rpc({
+ model: 'mail.channel',
+ method: 'channel_fetch_preview',
+ args: [channelIds],
+ }, { shadow: true });
+ for (const preview of messagePreviews) {
+ const messageData = preview.last_message;
+ if (messageData) {
+ this.env.models['mail.message'].insert(
+ this.env.models['mail.message'].convertData(messageData)
+ );
+ }
+ }
+ }
+
+ /**
+ * @static
+ */
+ static openNewMessage() {
+ const discuss = this.env.messaging.discuss;
+ if (discuss.isOpen) {
+ discuss.openNewMessage();
+ } else {
+ this.env.messaging.chatWindowManager.openNewMessage();
+ }
+ }
+
+ /**
+ * @param {string} [stringifiedDomain='[]']
+ * @returns {mail.thread_cache}
+ */
+ cache(stringifiedDomain = '[]') {
+ let cache = this.caches.find(cache => cache.stringifiedDomain === stringifiedDomain);
+ if (!cache) {
+ cache = this.env.models['mail.thread_cache'].create({
+ stringifiedDomain,
+ thread: [['link', this]],
+ });
+ }
+ return cache;
+ }
+
+ /**
+ * Fetch attachments linked to a record. Useful for populating the store
+ * with these attachments, which are used by attachment box in the chatter.
+ */
+ async fetchAttachments() {
+ const attachmentsData = await this.async(() => this.env.services.rpc({
+ model: 'ir.attachment',
+ method: 'search_read',
+ domain: [
+ ['res_id', '=', this.id],
+ ['res_model', '=', this.model],
+ ],
+ fields: ['id', 'name', 'mimetype'],
+ orderBy: [{ name: 'id', asc: false }],
+ }));
+ for (const attachmentData of attachmentsData) {
+ this.env.models['mail.attachment'].insert(Object.assign({
+ originThread: [['link', this]],
+ }, this.env.models['mail.attachment'].convertData(attachmentData)));
+ }
+ this.update({ areAttachmentsLoaded: true });
+ }
+
+ /**
+ * Add current user to provided thread's followers.
+ */
+ async follow() {
+ await this.async(() => this.env.services.rpc({
+ model: this.model,
+ method: 'message_subscribe',
+ args: [[this.id]],
+ kwargs: {
+ partner_ids: [this.env.messaging.currentPartner.id],
+ context: {}, // FIXME empty context to be overridden in session.js with 'allowed_company_ids' task-2243187
+ },
+ }));
+ this.refreshFollowers();
+ }
+
+ /**
+ * Load new messages on the main cache of this thread.
+ */
+ loadNewMessages() {
+ this.mainCache.loadNewMessages();
+ }
+
+ /**
+ * Mark the specified conversation as fetched.
+ */
+ async markAsFetched() {
+ await this.async(() => this.env.services.rpc({
+ model: 'mail.channel',
+ method: 'channel_fetched',
+ args: [[this.id]],
+ }, { shadow: true }));
+ }
+
+ /**
+ * Mark the specified conversation as read/seen.
+ */
+ async markAsSeen() {
+ if (this.message_unread_counter === 0) {
+ return;
+ }
+ if (this.model === 'mail.channel') {
+ const seen_message_id = await this.async(() => this.env.services.rpc({
+ model: 'mail.channel',
+ method: 'channel_seen',
+ args: [[this.id]]
+ }, { shadow: true }));
+ this.update({ seen_message_id });
+ }
+ this.update({ message_unread_counter: 0 });
+ }
+
+ /**
+ * Mark all needaction messages of this thread as read.
+ */
+ async markNeedactionMessagesAsRead() {
+ await this.async(() =>
+ this.env.models['mail.message'].markAsRead(this.needactionMessages)
+ );
+ }
+
+ /**
+ * Notify server the fold state of this thread. Useful for cross-tab
+ * and cross-device chat window state synchronization.
+ *
+ * Only makes sense if pendingFoldState is set to the desired value.
+ */
+ async notifyFoldStateToServer() {
+ await this.async(() => this.env.services.rpc({
+ model: 'mail.channel',
+ method: 'channel_fold',
+ kwargs: {
+ uuid: this.uuid,
+ state: this.pendingFoldState,
+ }
+ }, { shadow: true }));
+ }
+
+ /**
+ * Open provided thread, either in discuss app or as a chat window.
+ *
+ * @param {Object} param0
+ * @param {string} [param0.chatWindowMode='last_visible']
+ * @param {boolean} [param0.resetDiscussDomain=false]
+ */
+ open({ chatWindowMode = 'last_visible', resetDiscussDomain = false } = {}) {
+ const device = this.env.messaging.device;
+ const discuss = this.env.messaging.discuss;
+ const messagingMenu = this.env.messaging.messagingMenu;
+ if (!['mail.box', 'mail.channel'].includes(this.model)) {
+ this.env.messaging.openDocument({
+ id: this.id,
+ model: this.model,
+ });
+ }
+ if (
+ (!device.isMobile && discuss.isOpen) ||
+ (device.isMobile && this.model === 'mail.box')
+ ) {
+ if (resetDiscussDomain) {
+ discuss.threadViewer.update({ stringifiedDomain: '[]' });
+ }
+ discuss.threadViewer.update({ thread: [['link', this]] });
+ } else {
+ this.env.messaging.chatWindowManager.openThread(this, { mode: chatWindowMode });
+ }
+ if (!device.isMobile) {
+ messagingMenu.close();
+ }
+ }
+
+ /**
+ * Open this thread in an expanded way, that is not in a chat window.
+ */
+ openExpanded() {
+ const discuss = this.env.messaging.discuss;
+ if (['mail.channel', 'mail.box'].includes(this.model)) {
+ this.env.bus.trigger('do-action', {
+ action: 'mail.action_new_discuss',
+ options: {
+ clear_breadcrumbs: false,
+ active_id: discuss.threadToActiveId(this),
+ on_reverse_breadcrumb: () => discuss.close(),
+ },
+ });
+ } else {
+ this.env.bus.trigger('do-action', {
+ action: {
+ type: 'ir.actions.act_window',
+ res_model: this.model,
+ views: [[false, 'form']],
+ res_id: this.id,
+ },
+ });
+ }
+ }
+
+ /**
+ * Open a dialog to add channels as followers.
+ */
+ promptAddChannelFollower() {
+ this._promptAddFollower({ mail_invite_follower_channel_only: true });
+ }
+
+ /**
+ * Open a dialog to add partners as followers.
+ */
+ promptAddPartnerFollower() {
+ this._promptAddFollower({ mail_invite_follower_channel_only: false });
+ }
+
+ /**
+ * Refresh followers information from server.
+ */
+ async refreshFollowers() {
+ // FIXME Do that with only one RPC (see task-2243180)
+ const [{ message_follower_ids: followerIds }] = await this.async(() => this.env.services.rpc({
+ model: this.model,
+ method: 'read',
+ args: [this.id, ['message_follower_ids']],
+ }));
+ if (followerIds && followerIds.length > 0) {
+ const { followers } = await this.async(() => this.env.services.rpc({
+ route: '/mail/read_followers',
+ params: {
+ follower_ids: followerIds,
+ context: {}, // FIXME empty context to be overridden in session.js with 'allowed_company_ids' task-2243187
+ }
+ }));
+ this.update({
+ followers: [['insert-and-replace', followers.map(data =>
+ this.env.models['mail.follower'].convertData(data))
+ ]],
+ });
+ } else {
+ this.update({
+ followers: [['unlink-all']],
+ });
+ }
+ }
+
+ /**
+ * Refresh the typing status of the current partner.
+ */
+ refreshCurrentPartnerIsTyping() {
+ this._currentPartnerInactiveTypingTimer.reset();
+ }
+
+ /**
+ * Called to refresh a registered other member partner that is typing
+ * something.
+ *
+ * @param {mail.partner} partner
+ */
+ refreshOtherMemberTypingMember(partner) {
+ this._otherMembersLongTypingTimers.get(partner).reset();
+ }
+
+ /**
+ * Called when current partner is inserting some input in composer.
+ * Useful to notify current partner is currently typing something in the
+ * composer of this thread to all other members.
+ */
+ async registerCurrentPartnerIsTyping() {
+ // Handling of typing timers.
+ this._currentPartnerInactiveTypingTimer.start();
+ this._currentPartnerLongTypingTimer.start();
+ // Manage typing member relation.
+ const currentPartner = this.env.messaging.currentPartner;
+ const newOrderedTypingMemberLocalIds = this.orderedTypingMemberLocalIds
+ .filter(localId => localId !== currentPartner.localId);
+ newOrderedTypingMemberLocalIds.push(currentPartner.localId);
+ this.update({
+ orderedTypingMemberLocalIds: newOrderedTypingMemberLocalIds,
+ typingMembers: [['link', currentPartner]],
+ });
+ // Notify typing status to other members.
+ await this._throttleNotifyCurrentPartnerTypingStatus({ isTyping: true });
+ }
+
+ /**
+ * Called to register a new other member partner that is typing
+ * something.
+ *
+ * @param {mail.partner} partner
+ */
+ registerOtherMemberTypingMember(partner) {
+ const timer = new Timer(
+ this.env,
+ () => this.async(() => this._onOtherMemberLongTypingTimeout(partner)),
+ 60 * 1000
+ );
+ this._otherMembersLongTypingTimers.set(partner, timer);
+ timer.start();
+ const newOrderedTypingMemberLocalIds = this.orderedTypingMemberLocalIds
+ .filter(localId => localId !== partner.localId);
+ newOrderedTypingMemberLocalIds.push(partner.localId);
+ this.update({
+ orderedTypingMemberLocalIds: newOrderedTypingMemberLocalIds,
+ typingMembers: [['link', partner]],
+ });
+ }
+
+ /**
+ * Rename the given thread with provided new name.
+ *
+ * @param {string} newName
+ */
+ async rename(newName) {
+ if (this.channel_type === 'chat') {
+ await this.async(() => this.env.services.rpc({
+ model: 'mail.channel',
+ method: 'channel_set_custom_name',
+ args: [this.id],
+ kwargs: {
+ name: newName,
+ },
+ }));
+ }
+ this.update({ custom_channel_name: newName });
+ }
+
+ /**
+ * Unfollow current partner from this thread.
+ */
+ async unfollow() {
+ const currentPartnerFollower = this.followers.find(
+ follower => follower.partner === this.env.messaging.currentPartner
+ );
+ await this.async(() => currentPartnerFollower.remove());
+ }
+
+ /**
+ * Called when current partner has explicitly stopped inserting some
+ * input in composer. Useful to notify current partner has currently
+ * stopped typing something in the composer of this thread to all other
+ * members.
+ *
+ * @param {Object} [param0={}]
+ * @param {boolean} [param0.immediateNotify=false] if set, is typing
+ * status of current partner is immediately notified and doesn't
+ * consume throttling at all.
+ */
+ async unregisterCurrentPartnerIsTyping({ immediateNotify = false } = {}) {
+ // Handling of typing timers.
+ this._currentPartnerInactiveTypingTimer.clear();
+ this._currentPartnerLongTypingTimer.clear();
+ // Manage typing member relation.
+ const currentPartner = this.env.messaging.currentPartner;
+ const newOrderedTypingMemberLocalIds = this.orderedTypingMemberLocalIds
+ .filter(localId => localId !== currentPartner.localId);
+ this.update({
+ orderedTypingMemberLocalIds: newOrderedTypingMemberLocalIds,
+ typingMembers: [['unlink', currentPartner]],
+ });
+ // Notify typing status to other members.
+ if (immediateNotify) {
+ this._throttleNotifyCurrentPartnerTypingStatus.clear();
+ }
+ await this.async(
+ () => this._throttleNotifyCurrentPartnerTypingStatus({ isTyping: false })
+ );
+ }
+
+ /**
+ * Called to unregister an other member partner that is no longer typing
+ * something.
+ *
+ * @param {mail.partner} partner
+ */
+ unregisterOtherMemberTypingMember(partner) {
+ this._otherMembersLongTypingTimers.get(partner).clear();
+ this._otherMembersLongTypingTimers.delete(partner);
+ const newOrderedTypingMemberLocalIds = this.orderedTypingMemberLocalIds
+ .filter(localId => localId !== partner.localId);
+ this.update({
+ orderedTypingMemberLocalIds: newOrderedTypingMemberLocalIds,
+ typingMembers: [['unlink', partner]],
+ });
+ }
+
+ /**
+ * Unsubscribe current user from provided channel.
+ */
+ async unsubscribe() {
+ if (this.channel_type === 'mail.channel') {
+ return this.async(() => this.env.services.rpc({
+ model: 'mail.channel',
+ method: 'action_unfollow',
+ args: [[this.id]]
+ }));
+ }
+ return this.async(() => this.env.services.rpc({
+ model: 'mail.channel',
+ method: 'channel_pin',
+ args: [this.uuid, false]
+ }));
+ }
+
+ //----------------------------------------------------------------------
+ // Private
+ //----------------------------------------------------------------------
+
+ /**
+ * @override
+ */
+ static _findFunctionFromData(data) {
+ return record => record.id === data.id && record.model === data.model;
+ }
+
+ /**
+ * @private
+ * @returns {mail.attachment[]}
+ */
+ _computeAllAttachments() {
+ const allAttachments = [...new Set(this.originThreadAttachments.concat(this.attachments))]
+ .sort((a1, a2) => a1.id < a2.id ? 1 : -1);
+ return [['replace', allAttachments]];
+ }
+
+ /**
+ * @private
+ * @returns {mail.chat_window[]}
+ */
+ _computeChatWindows() {
+ const chatWindowViewers = this.viewers.filter(viewer => !!viewer.chatWindow);
+ return [['replace', chatWindowViewers.map(viewer => viewer.chatWindow)]];
+ }
+
+ /**
+ * @private
+ * @returns {string}
+ */
+ _computeDisplayName() {
+ if (this.channel_type === 'chat' && this.correspondent) {
+ return this.custom_channel_name || this.correspondent.nameOrDisplayName;
+ }
+ return this.name;
+ }
+
+ /**
+ * @private
+ * @returns {string}
+ */
+ _computeFoldState() {
+ return this.pendingFoldState || this.serverFoldState;
+ }
+
+ /**
+ * @private
+ * @returns {boolean}
+ */
+ _computeIsCurrentPartnerFollowing() {
+ return this.followers.some(follower =>
+ follower.partner && follower.partner === this.env.messaging.currentPartner
+ );
+ }
+
+ /**
+ * @private
+ * @returns {boolean}
+ */
+ _computeIsModeratedByCurrentPartner() {
+ if (this.model !== 'mail.channel') {
+ return false;
+ }
+ if (!this.messaging) {
+ return false;
+ }
+ if (!this.messaging.currentPartner) {
+ return false;
+ }
+ return this.messaging.currentPartner.moderatedChannelIds.includes(this.id);
+ }
+
+ /**
+ * @private
+ * @returns {mail.message}
+ */
+ _computeLastCurrentPartnerMessageSeenByEveryone() {
+ if (!this.partnerSeenInfos || !this.mainCacheOrderedMessages) {
+ return [['unlink-all']];
+ }
+ const otherPartnerSeenInfos =
+ this.partnerSeenInfos.filter(partnerSeenInfo =>
+ partnerSeenInfo.partner !== this.messagingCurrentPartner);
+ if (otherPartnerSeenInfos.length === 0) {
+ return [['unlink-all']];
+ }
+
+ const otherPartnersLastSeenMessageIds =
+ otherPartnerSeenInfos.map(partnerSeenInfo =>
+ partnerSeenInfo.lastSeenMessage ? partnerSeenInfo.lastSeenMessage.id : 0
+ );
+ if(otherPartnersLastSeenMessageIds.length === 0) {
+ return [['unlink-all']];
+ }
+ const lastMessageSeenByAllId = Math.min(
+ ...otherPartnersLastSeenMessageIds
+ );
+ const currentPartnerOrderedSeenMessages =
+ this.mainCacheOrderedMessages.filter(message =>
+ message.author === this.messagingCurrentPartner &&
+ message.id <= lastMessageSeenByAllId);
+
+ if (
+ !currentPartnerOrderedSeenMessages ||
+ currentPartnerOrderedSeenMessages.length === 0
+ ) {
+ return [['unlink-all']];
+ }
+ return [['replace', currentPartnerOrderedSeenMessages.slice().pop()]];
+ }
+
+ /**
+ * @private
+ * @returns {mail.message|undefined}
+ */
+ _computeLastNeedactionMessage() {
+ const orderedNeedactionMessages = this.needactionMessages.sort(
+ (m1, m2) => m1.id < m2.id ? -1 : 1
+ );
+ const {
+ length: l,
+ [l - 1]: lastNeedactionMessage,
+ } = orderedNeedactionMessages;
+ return [['replace', lastNeedactionMessage]];
+ }
+
+ /**
+ * @private
+ * @returns {mail.thread_cache}
+ */
+ _computeMainCache() {
+ return [['replace', this.cache('[]')]];
+ }
+
+ /**
+ * @private
+ * @returns {mail.message[]}
+ */
+ _computeMessages() {
+ const allMessages = this.mainCacheMessages.concat(this.originThreadMessages);
+ return [['replace', allMessages]];
+ }
+
+ /**
+ * @private
+ * @returns {mail.messaging}
+ */
+ _computeMessaging() {
+ return [['link', this.env.messaging]];
+ }
+
+ /**
+ * @private
+ * @returns {mail.message[]}
+ */
+ _computeNeedactionMessages() {
+ return [['replace', this.messages.filter(message => message.isNeedaction)]];
+ }
+
+ /**
+ * @private
+ * @returns {mail.partner[]}
+ */
+ _computeOrderedOtherTypingMembers() {
+ return [[
+ 'replace',
+ this.orderedTypingMembers.filter(
+ member => member !== this.env.messaging.currentPartner
+ ),
+ ]];
+ }
+
+ /**
+ * @private
+ * @returns {mail.partner[]}
+ */
+ _computeOrderedTypingMembers() {
+ return [[
+ 'replace',
+ this.orderedTypingMemberLocalIds
+ .map(localId => this.env.models['mail.partner'].get(localId))
+ .filter(member => !!member),
+ ]];
+ }
+
+ /**
+ * @private
+ * @returns {string}
+ */
+ _computeTypingStatusText() {
+ if (this.orderedOtherTypingMembers.length === 0) {
+ return this.constructor.fields.typingStatusText.default;
+ }
+ if (this.orderedOtherTypingMembers.length === 1) {
+ return _.str.sprintf(
+ this.env._t("%s is typing..."),
+ this.orderedOtherTypingMembers[0].nameOrDisplayName
+ );
+ }
+ if (this.orderedOtherTypingMembers.length === 2) {
+ return _.str.sprintf(
+ this.env._t("%s and %s are typing..."),
+ this.orderedOtherTypingMembers[0].nameOrDisplayName,
+ this.orderedOtherTypingMembers[1].nameOrDisplayName
+ );
+ }
+ return _.str.sprintf(
+ this.env._t("%s, %s and more are typing..."),
+ this.orderedOtherTypingMembers[0].nameOrDisplayName,
+ this.orderedOtherTypingMembers[1].nameOrDisplayName
+ );
+ }
+
+ /**
+ * @override
+ */
+ _createRecordLocalId(data) {
+ const { channel_type, id, isTemporary = false, model } = data;
+ let threadModel = model;
+ if (!threadModel && channel_type) {
+ threadModel = 'mail.channel';
+ }
+ const Thread = this.env.models['mail.thread'];
+ if (isTemporary) {
+ return `${Thread.modelName}_${id}`;
+ }
+ return `${Thread.modelName}_${threadModel}_${id}`;
+ }
+
+ /**
+ * @private
+ * @param {Object} param0
+ * @param {boolean} param0.isTyping
+ */
+ async _notifyCurrentPartnerTypingStatus({ isTyping }) {
+ if (
+ this._forceNotifyNextCurrentPartnerTypingStatus ||
+ isTyping !== this._currentPartnerLastNotifiedIsTyping
+ ) {
+ if (this.model === 'mail.channel') {
+ await this.async(() => this.env.services.rpc({
+ model: 'mail.channel',
+ method: 'notify_typing',
+ args: [this.id],
+ kwargs: { is_typing: isTyping },
+ }, { shadow: true }));
+ }
+ if (isTyping && this._currentPartnerLongTypingTimer.isRunning) {
+ this._currentPartnerLongTypingTimer.reset();
+ }
+ }
+ this._forceNotifyNextCurrentPartnerTypingStatus = false;
+ this._currentPartnerLastNotifiedIsTyping = isTyping;
+ }
+
+ /**
+ * @private
+ * @param {Object} [param0={}]
+ * @param {boolean} [param0.mail_invite_follower_channel_only=false]
+ */
+ _promptAddFollower({ mail_invite_follower_channel_only = false } = {}) {
+ const action = {
+ type: 'ir.actions.act_window',
+ res_model: 'mail.wizard.invite',
+ view_mode: 'form',
+ views: [[false, 'form']],
+ name: this.env._t("Invite Follower"),
+ target: 'new',
+ context: {
+ default_res_model: this.model,
+ default_res_id: this.id,
+ mail_invite_follower_channel_only,
+ },
+ };
+ this.env.bus.trigger('do-action', {
+ action,
+ options: {
+ on_close: () => this.refreshFollowers(),
+ },
+ });
+ }
+
+ /**
+ * @override
+ */
+ _updateAfter(previous) {
+ if (this.model !== 'mail.channel') {
+ // fold state only makes sense on channels
+ return;
+ }
+ if (
+ this.pendingFoldState &&
+ previous.pendingFoldState !== this.pendingFoldState
+ ) {
+ this.notifyFoldStateToServer();
+ }
+ if (
+ this.serverFoldState === this.pendingFoldState
+ ) {
+ this.update({ pendingFoldState: undefined });
+ }
+
+ // TODO FIXME prevent to open/close a channel on mobile when you
+ // open/close it on desktop (task-2267593)
+
+ // chat window
+ if (previous.foldState === this.foldState) {
+ // avoid updating chatWindows when not changing foldState
+ // important to avoid issues when thread is in progress of being
+ // opened, because the foldState is updated only at the end of
+ // the process
+ return;
+ }
+ if (this.foldState !== 'closed' && this.chatWindows.length === 0) {
+ // condition to avoid crash during destroy
+ if (this.env.messaging.chatWindowManager) {
+ this.env.messaging.chatWindowManager.openThread(this);
+ }
+ }
+ if (this.foldState === 'closed' && this.chatWindows.length > 0) {
+ for (const chatWindow of this.chatWindows) {
+ chatWindow.close();
+ }
+ }
+ }
+
+ /**
+ * @override
+ */
+ _updateBefore() {
+ return {
+ foldState: this.foldState,
+ pendingFoldState: this.pendingFoldState,
+ };
+ }
+
+ //----------------------------------------------------------------------
+ // Handlers
+ //----------------------------------------------------------------------
+
+ /**
+ * @private
+ */
+ async _onCurrentPartnerInactiveTypingTimeout() {
+ await this.async(() => this.unregisterCurrentPartnerIsTyping());
+ }
+
+ /**
+ * Called when current partner has been typing for a very long time.
+ * Immediately notify other members that he/she is still typing.
+ *
+ * @private
+ */
+ async _onCurrentPartnerLongTypingTimeout() {
+ this._forceNotifyNextCurrentPartnerTypingStatus = true;
+ this._throttleNotifyCurrentPartnerTypingStatus.clear();
+ await this.async(
+ () => this._throttleNotifyCurrentPartnerTypingStatus({ isTyping: true })
+ );
+ }
+
+ /**
+ * @private
+ * @param {mail.partner} partner
+ */
+ async _onOtherMemberLongTypingTimeout(partner) {
+ if (!this.typingMembers.includes(partner)) {
+ this._otherMembersLongTypingTimers.delete(partner);
+ return;
+ }
+ this.unregisterOtherMemberTypingMember(partner);
+ }
+ }
+
+ Thread.fields = {
+ allAttachments: many2many('mail.attachment', {
+ compute: '_computeAllAttachments',
+ dependencies: [
+ 'attachments',
+ 'originThreadAttachments',
+ ],
+ }),
+ areAttachmentsLoaded: attr({
+ default: false,
+ }),
+ attachments: many2many('mail.attachment', {
+ inverse: 'threads',
+ }),
+ caches: one2many('mail.thread_cache', {
+ inverse: 'thread',
+ isCausal: true,
+ }),
+ channel_type: attr(),
+ chatWindows: one2many('mail.chat_window', {
+ compute: '_computeChatWindows',
+ dependencies: ['viewersChatWindow'],
+ }),
+ composer: one2one('mail.composer', {
+ autocreate: true,
+ inverse: 'thread',
+ isCausal: true,
+ }),
+ correspondent: many2one('mail.partner', {
+ inverse: 'correspondentThreads',
+ }),
+ correspondentNameOrDisplayName: attr({
+ related: 'correspondent.nameOrDisplayName',
+ }),
+ counter: attr({
+ default: 0,
+ }),
+ creator: many2one('mail.user'),
+ custom_channel_name: attr(),
+ displayName: attr({
+ compute: '_computeDisplayName',
+ dependencies: [
+ 'channel_type',
+ 'correspondent',
+ 'correspondentNameOrDisplayName',
+ 'custom_channel_name',
+ 'name',
+ ],
+ }),
+ /**
+ * Determine the fold state of the channel on the web client.
+ *
+ * If there is a pending fold state change, it is immediately applied on
+ * the interface to avoid a feeling of unresponsiveness. Otherwise the
+ * last known fold state of the server is used.
+ *
+ * This field must be considered read only.
+ */
+ foldState: attr({
+ compute: '_computeFoldState',
+ dependencies: [
+ 'pendingFoldState',
+ 'serverFoldState',
+ ],
+ }),
+ followersPartner: many2many('mail.partner', {
+ related: 'followers.partner',
+ }),
+ followers: one2many('mail.follower', {
+ inverse: 'followedThread',
+ }),
+ group_based_subscription: attr({
+ default: false,
+ }),
+ id: attr(),
+ isCurrentPartnerFollowing: attr({
+ compute: '_computeIsCurrentPartnerFollowing',
+ default: false,
+ dependencies: [
+ 'followersPartner',
+ 'messagingCurrentPartner',
+ ],
+ }),
+ isModeratedByCurrentPartner: attr({
+ compute: '_computeIsModeratedByCurrentPartner',
+ dependencies: [
+ 'model',
+ 'messagingCurrentPartner',
+ ],
+ }),
+ isPinned: attr({
+ default: false,
+ }),
+ isTemporary: attr({
+ default: false,
+ }),
+ is_moderator: attr({
+ default: false,
+ }),
+ lastCurrentPartnerMessageSeenByEveryone: many2one('mail.message', {
+ compute: '_computeLastCurrentPartnerMessageSeenByEveryone',
+ dependencies: [
+ 'partnerSeenInfos',
+ 'mainCacheOrderedMessages',
+ 'messagingCurrentPartner',
+ ],
+ }),
+ lastMessage: many2one('mail.message', {
+ related: 'mainCache.lastMessage',
+ }),
+ lastNeedactionMessage: many2one('mail.message', {
+ compute: '_computeLastNeedactionMessage',
+ dependencies: ['needactionMessages'],
+ }),
+ mainCache: one2one('mail.thread_cache', {
+ compute: '_computeMainCache',
+ dependencies: ['caches'],
+ }),
+ mainCacheMessages: many2many('mail.message', {
+ related: 'mainCache.messages',
+ }),
+ mainCacheOrderedMessages: many2many('mail.message', {
+ related: 'mainCache.orderedMessages',
+ }),
+ mass_mailing: attr({
+ default: false,
+ }),
+ members: many2many('mail.partner', {
+ inverse: 'memberThreads',
+ }),
+ message_needaction_counter: attr({
+ default: 0,
+ }),
+ message_unread_counter: attr({
+ default: 0,
+ }),
+ messages: many2many('mail.message', {
+ compute: '_computeMessages',
+ dependencies: [
+ 'mainCacheMessages',
+ 'originThreadMessages',
+ ],
+ }),
+ messageSeenIndicators: one2many('mail.message_seen_indicator', {
+ inverse: 'thread',
+ isCausal: true,
+ }),
+ messaging: many2one('mail.messaging', {
+ compute: '_computeMessaging',
+ }),
+ messagingCurrentPartner: many2one('mail.partner', {
+ related: 'messaging.currentPartner',
+ }),
+ model: attr(),
+ model_name: attr(),
+ moderation: attr({
+ default: false,
+ }),
+ moduleIcon: attr(),
+ name: attr(),
+ needactionMessages: many2many('mail.message', {
+ compute: '_computeNeedactionMessages',
+ dependencies: ['messages'],
+ }),
+ /**
+ * Ordered typing members on this thread, excluding the current partner.
+ */
+ orderedOtherTypingMembers: many2many('mail.partner', {
+ compute: '_computeOrderedOtherTypingMembers',
+ dependencies: ['orderedTypingMembers'],
+ }),
+ /**
+ * Ordered typing members on this thread. Lower index means this member
+ * is currently typing for the longest time. This list includes current
+ * partner as typer.
+ */
+ orderedTypingMembers: many2many('mail.partner', {
+ compute: '_computeOrderedTypingMembers',
+ dependencies: [
+ 'orderedTypingMemberLocalIds',
+ 'typingMembers',
+ ],
+ }),
+ /**
+ * Technical attribute to manage ordered list of typing members.
+ */
+ orderedTypingMemberLocalIds: attr({
+ default: [],
+ }),
+ originThreadAttachments: one2many('mail.attachment', {
+ inverse: 'originThread',
+ }),
+ originThreadMessages: one2many('mail.message', {
+ inverse: 'originThread',
+ }),
+ partnerSeenInfos: one2many('mail.thread_partner_seen_info', {
+ inverse: 'thread',
+ isCausal: true,
+ }),
+ /**
+ * Determine if there is a pending fold state change, which is a change
+ * of fold state requested by the client but not yet confirmed by the
+ * server.
+ *
+ * This field can be updated to immediately change the fold state on the
+ * interface and to notify the server of the new state.
+ */
+ pendingFoldState: attr(),
+ public: attr(),
+ seen_message_id: attr(),
+ /**
+ * Determine the last fold state known by the server, which is the fold
+ * state displayed after initialization or when the last pending
+ * fold state change was confirmed by the server.
+ *
+ * This field should be considered read only in most situations. Only
+ * the code handling fold state change from the server should typically
+ * update it.
+ */
+ serverFoldState: attr({
+ default: 'closed',
+ }),
+ /**
+ * Members that are currently typing something in the composer of this
+ * thread, including current partner.
+ */
+ typingMembers: many2many('mail.partner'),
+ /**
+ * Text that represents the status on this thread about typing members.
+ */
+ typingStatusText: attr({
+ compute: '_computeTypingStatusText',
+ default: '',
+ dependencies: ['orderedOtherTypingMembers'],
+ }),
+ uuid: attr(),
+ viewers: one2many('mail.thread_viewer', {
+ inverse: 'thread',
+ }),
+ viewersChatWindow: many2many('mail.chat_window', {
+ related: 'viewers.chatWindow',
+ }),
+ };
+
+ Thread.modelName = 'mail.thread';
+
+ return Thread;
+}
+
+registerNewModel('mail.thread', factory);
+
+});
diff --git a/addons/mail/static/src/models/thread/thread_tests.js b/addons/mail/static/src/models/thread/thread_tests.js
new file mode 100644
index 00000000000..02b3966ecde
--- /dev/null
+++ b/addons/mail/static/src/models/thread/thread_tests.js
@@ -0,0 +1,162 @@
+odoo.define('mail/static/src/models/thread/thread_tests.js', function (require) {
+'use strict';
+
+const {
+ afterEach: utilsAfterEach,
+ beforeEach: utilsBeforeEach,
+ start: utilsStart,
+} = require('mail/static/src/utils/test_utils.js');
+
+QUnit.module('mail', {}, function () {
+QUnit.module('models', {}, function () {
+QUnit.module('thread', {}, function () {
+QUnit.module('thread_tests.js', {
+ beforeEach() {
+ utilsBeforeEach(this);
+
+ this.start = async params => {
+ if (this.widget) {
+ this.widget.destroy();
+ }
+ let { env, widget } = await utilsStart(Object.assign({}, params, {
+ data: this.data,
+ }));
+ this.env = env;
+ this.widget = widget;
+ };
+ },
+ afterEach() {
+ utilsAfterEach(this);
+ this.env = undefined;
+ if (this.widget) {
+ this.widget.destroy();
+ this.widget = undefined;
+ }
+ },
+});
+
+QUnit.test('inbox & starred mailboxes', async function (assert) {
+ assert.expect(10);
+
+ await this.start();
+ const mailboxInbox = this.env.messaging.inbox;
+ const mailboxStarred = this.env.messaging.starred;
+ assert.ok(mailboxInbox, "should have mailbox inbox");
+ assert.ok(mailboxStarred, "should have mailbox starred");
+ assert.strictEqual(mailboxInbox.model, 'mail.box');
+ assert.strictEqual(mailboxInbox.counter, 0);
+ assert.strictEqual(mailboxInbox.id, 'inbox');
+ assert.strictEqual(mailboxInbox.name, "Inbox"); // language-dependent
+ assert.strictEqual(mailboxStarred.model, 'mail.box');
+ assert.strictEqual(mailboxStarred.counter, 0);
+ assert.strictEqual(mailboxStarred.id, 'starred');
+ assert.strictEqual(mailboxStarred.name, "Starred"); // language-dependent
+});
+
+QUnit.test('create (channel)', async function (assert) {
+ assert.expect(23);
+
+ await this.start();
+ assert.notOk(this.env.models['mail.partner'].find(partner => partner.id === 9));
+ assert.notOk(this.env.models['mail.partner'].find(partner => partner.id === 10));
+ assert.notOk(this.env.models['mail.thread'].find(thread =>
+ thread.id === 100 &&
+ thread.model === 'mail.channel'
+ ));
+
+ const thread = this.env.models['mail.thread'].create({
+ channel_type: 'channel',
+ id: 100,
+ members: [['insert', [{
+ email: "john@example.com",
+ id: 9,
+ name: "John",
+ }, {
+ email: "fred@example.com",
+ id: 10,
+ name: "Fred",
+ }]]],
+ message_needaction_counter: 6,
+ message_unread_counter: 5,
+ model: 'mail.channel',
+ name: "General",
+ public: 'public',
+ });
+ assert.ok(thread);
+ assert.ok(this.env.models['mail.partner'].find(partner => partner.id === 9));
+ assert.ok(this.env.models['mail.partner'].find(partner => partner.id === 10));
+ assert.ok(this.env.models['mail.thread'].find(thread =>
+ thread.id === 100 &&
+ thread.model === 'mail.channel'
+ ));
+ const partner9 = this.env.models['mail.partner'].find(partner => partner.id === 9);
+ const partner10 = this.env.models['mail.partner'].find(partner => partner.id === 10);
+ assert.strictEqual(thread, this.env.models['mail.thread'].find(thread =>
+ thread.id === 100 &&
+ thread.model === 'mail.channel'
+ ));
+ assert.strictEqual(thread.model, 'mail.channel');
+ assert.strictEqual(thread.channel_type, 'channel');
+ assert.strictEqual(thread.id, 100);
+ assert.ok(thread.members.includes(partner9));
+ assert.ok(thread.members.includes(partner10));
+ assert.strictEqual(thread.message_needaction_counter, 6);
+ assert.strictEqual(thread.message_unread_counter, 5);
+ assert.strictEqual(thread.name, "General");
+ assert.strictEqual(thread.public, 'public');
+ assert.strictEqual(partner9.email, "john@example.com");
+ assert.strictEqual(partner9.id, 9);
+ assert.strictEqual(partner9.name, "John");
+ assert.strictEqual(partner10.email, "fred@example.com");
+ assert.strictEqual(partner10.id, 10);
+ assert.strictEqual(partner10.name, "Fred");
+});
+
+QUnit.test('create (chat)', async function (assert) {
+ assert.expect(15);
+
+ await this.start();
+ assert.notOk(this.env.models['mail.partner'].find(partner => partner.id === 5));
+ assert.notOk(this.env.models['mail.thread'].find(thread =>
+ thread.id === 200 &&
+ thread.model === 'mail.channel'
+ ));
+
+ const channel = this.env.models['mail.thread'].create({
+ channel_type: 'chat',
+ correspondent: [['insert', {
+ email: "demo@example.com",
+ id: 5,
+ im_status: 'online',
+ name: "Demo",
+ }]],
+ id: 200,
+ model: 'mail.channel',
+ });
+ assert.ok(channel);
+ assert.ok(this.env.models['mail.thread'].find(thread =>
+ thread.id === 200 &&
+ thread.model === 'mail.channel'
+ ));
+ assert.ok(this.env.models['mail.partner'].find(partner => partner.id === 5));
+ const partner = this.env.models['mail.partner'].find(partner => partner.id === 5);
+ assert.strictEqual(channel, this.env.models['mail.thread'].find(thread =>
+ thread.id === 200 &&
+ thread.model === 'mail.channel'
+ ));
+ assert.strictEqual(channel.model, 'mail.channel');
+ assert.strictEqual(channel.channel_type, 'chat');
+ assert.strictEqual(channel.id, 200);
+ assert.ok(channel.correspondent);
+ assert.strictEqual(partner, channel.correspondent);
+ assert.strictEqual(partner.email, "demo@example.com");
+ assert.strictEqual(partner.id, 5);
+ assert.strictEqual(partner.im_status, 'online');
+ assert.strictEqual(partner.name, "Demo");
+});
+
+});
+});
+});
+
+});
diff --git a/addons/mail/static/src/models/thread_cache/thread_cache.js b/addons/mail/static/src/models/thread_cache/thread_cache.js
new file mode 100644
index 00000000000..edd991c693c
--- /dev/null
+++ b/addons/mail/static/src/models/thread_cache/thread_cache.js
@@ -0,0 +1,263 @@
+odoo.define('mail/static/src/models/thread_cache/thread_cache.js', function (require) {
+'use strict';
+
+const { registerNewModel } = require('mail/static/src/model/model_core.js');
+const { attr, many2many, many2one } = require('mail/static/src/model/model_field.js');
+
+const MESSAGE_FETCH_LIMIT = 30;
+
+function factory(dependencies) {
+
+ class ThreadCache extends dependencies['mail.model'] {
+
+ //----------------------------------------------------------------------
+ // Public
+ //----------------------------------------------------------------------
+
+ /**
+ * Load this thread cache, by fetching the most recent messages in this
+ * conversation.
+ */
+ async loadMessages() {
+ if (this.isLoaded && this.isLoading) {
+ return;
+ }
+ const searchDomain = JSON.parse(this.stringifiedDomain);
+ let domain = searchDomain.length ? searchDomain : [];
+ domain = this._extendMessageDomain(domain);
+ this.update({ isLoading: true });
+ let messagesData = [];
+ if (!this.thread.isTemporary) {
+ messagesData = await this.async(() => this.env.services.rpc({
+ model: 'mail.message',
+ method: 'message_fetch',
+ args: [domain],
+ kwargs: this._getFetchMessagesKwargs(),
+ }, { shadow: true }));
+ }
+ this._handleMessagesLoaded(messagesData);
+ }
+
+ async loadMoreMessages() {
+ const searchDomain = JSON.parse(this.stringifiedDomain);
+ let domain = searchDomain.length ? searchDomain : [];
+ domain = this._extendMessageDomain(domain);
+ if (this.isAllHistoryLoaded && this.isLoadingMore) {
+ return;
+ }
+ this.update({ isLoadingMore: true });
+ const minMessageId = Math.min(
+ ...this.messages.map(message => message.id)
+ );
+ domain = [['id', '<', minMessageId]].concat(domain);
+ const messagesData = await this.async(() => this.env.services.rpc({
+ model: 'mail.message',
+ method: 'message_fetch',
+ args: [domain],
+ kwargs: this._getFetchMessagesKwargs(),
+ }, { shadow: true }));
+ for (const viewer of this.thread.viewers) {
+ viewer.addComponentHint('more-messages-loaded');
+ }
+ this._handleMessagesLoaded(messagesData);
+ }
+
+ async loadNewMessages() {
+ if (this.isLoading) {
+ return;
+ }
+ if (!this.isLoaded) {
+ await this.async(() => this.loadMessages());
+ return;
+ }
+ const messageIds = this.messages.map(message => message.id);
+ const searchDomain = JSON.parse(this.stringifiedDomain);
+ let domain = searchDomain.length ? searchDomain : [];
+ domain = this._extendMessageDomain(domain);
+ if (messageIds.length > 0) {
+ const lastMessageId = Math.max(...messageIds);
+ domain = [['id', '>', lastMessageId]].concat(domain);
+ }
+ this.update({ isLoading: true });
+ const messageFetchKwargs = this._getFetchMessagesKwargs();
+ messageFetchKwargs.limit = false;
+ const messagesData = await this.async(() => this.env.services.rpc({
+ model: 'mail.message',
+ method: 'message_fetch',
+ args: [domain],
+ kwargs: messageFetchKwargs,
+ }, { shadow: true }));
+ this._handleMessagesLoaded(messagesData);
+ }
+
+ //----------------------------------------------------------------------
+ // Private
+ //----------------------------------------------------------------------
+
+ /**
+ * @private
+ * @returns {mail.message|undefined}
+ */
+ _computeLastMessage() {
+ const { length: l, [l - 1]: lastMessage } = this.orderedMessages;
+ if (!lastMessage) {
+ return [['unlink-all']];
+ }
+ return [['replace', lastMessage]];
+ }
+
+ /**
+ * @private
+ * @returns {mail.message[]}
+ */
+ _computeOrderedMessages() {
+ return [['replace', this.messages.sort((m1, m2) => m1.id < m2.id ? -1 : 1)]];
+ }
+
+ /**
+ * @private
+ * @returns {mail.message[]}
+ */
+ _computeUncheckedMessages() {
+ return [['replace', this.messages.filter(
+ message => message.hasCheckbox && !this.checkedMessages.includes(message)
+ )]];
+ }
+
+ /**
+ * @override
+ */
+ _createRecordLocalId(data) {
+ const {
+ stringifiedDomain = '[]',
+ thread: [[commandInsert, thread]],
+ } = data;
+ const ThreadCache = this.env.models['mail.thread_cache'];
+ return `${ThreadCache.modelName}_[${thread.localId}]_<${stringifiedDomain}>`;
+ }
+
+ /**
+ * @private
+ * @param {Array} domain
+ * @returns {Array}
+ */
+ _extendMessageDomain(domain) {
+ const thread = this.thread;
+ if (thread.model === 'mail.channel') {
+ return domain.concat([['channel_ids', 'in', [thread.id]]]);
+ } else if (thread === this.env.messaging.inbox) {
+ return domain.concat([['needaction', '=', true]]);
+ } else if (thread === this.env.messaging.starred) {
+ return domain.concat([['starred', '=', true]]);
+ } else if (thread === this.env.messaging.history) {
+ return domain.concat([['needaction', '=', false]]);
+ } else if (thread === this.env.messaging.moderation) {
+ return domain.concat([['need_moderation', '=', true]]);
+ } else {
+ return domain.concat([['model', '=', thread.model], ['res_id', '=', thread.id]]);
+ }
+ }
+
+ /**
+ * @private
+ * @returns {Object}
+ */
+ _getFetchMessagesKwargs() {
+ const thread = this.thread;
+ let kwargs = {
+ limit: MESSAGE_FETCH_LIMIT,
+ context: this.env.session.user_context,
+ };
+ if (thread.moderation) {
+ // thread is a channel
+ kwargs.moderated_channel_ids = [thread.id];
+ }
+ return kwargs;
+ }
+
+ /**
+ * @private
+ * @param {Object[]} messageData
+ */
+ _handleMessagesLoaded(messagesData) {
+ this.update({
+ isAllHistoryLoaded: messagesData.length < MESSAGE_FETCH_LIMIT,
+ isLoaded: true,
+ isLoading: false,
+ isLoadingMore: false,
+ messages: [['insert', messagesData.map(data =>
+ this.env.models['mail.message'].convertData(data))
+ ]],
+ });
+ if (!this.thread) {
+ return;
+ }
+ this.thread.update({
+ messageSeenIndicators: [['insert', messagesData.map(messageData => {
+ return {
+ id: this.env.models['mail.message_seen_indicator'].computeId(messageData.id, this.thread.id),
+ message: [['insert', { id: messageData.id }]],
+ };
+ })]],
+ });
+ for (const viewer of this.thread.viewers) {
+ viewer.handleThreadCacheLoaded(this);
+ }
+ }
+
+ }
+
+ ThreadCache.fields = {
+ checkedMessages: many2many('mail.message', {
+ inverse: 'checkedThreadCaches',
+ }),
+ isAllHistoryLoaded: attr({
+ default: false,
+ }),
+ isLoaded: attr({
+ default: false,
+ }),
+ isLoading: attr({
+ default: false,
+ }),
+ isLoadingMore: attr({
+ default: false,
+ }),
+ lastMessage: many2one('mail.message', {
+ compute: '_computeLastMessage',
+ dependencies: ['orderedMessages'],
+ }),
+ messagesCheckboxes: attr({
+ related: 'messages.hasCheckbox',
+ }),
+ messages: many2many('mail.message', {
+ inverse: 'threadCaches',
+ }),
+ orderedMessages: many2many('mail.message', {
+ compute: '_computeOrderedMessages',
+ dependencies: ['messages'],
+ }),
+ stringifiedDomain: attr({
+ default: '[]',
+ }),
+ thread: many2one('mail.thread', {
+ inverse: 'caches',
+ }),
+ uncheckedMessages: many2many('mail.message', {
+ compute: '_computeUncheckedMessages',
+ dependencies: [
+ 'checkedMessages',
+ 'messagesCheckboxes',
+ 'messages',
+ ],
+ }),
+ };
+
+ ThreadCache.modelName = 'mail.thread_cache';
+
+ return ThreadCache;
+}
+
+registerNewModel('mail.thread_cache', factory);
+
+});
diff --git a/addons/mail/static/src/models/thread_partner_seen_info/thread_partner_seen_info.js b/addons/mail/static/src/models/thread_partner_seen_info/thread_partner_seen_info.js
new file mode 100644
index 00000000000..6ea0148d6d0
--- /dev/null
+++ b/addons/mail/static/src/models/thread_partner_seen_info/thread_partner_seen_info.js
@@ -0,0 +1,28 @@
+odoo.define('mail/static/src/models/thread_partner_seen_info/thread_partner_seen_info.js', function (require) {
+'use strict';
+
+const { registerNewModel } = require('mail/static/src/model/model_core.js');
+const { attr, many2one } = require('mail/static/src/model/model_field.js');
+
+function factory(dependencies) {
+
+ class ThreadPartnerSeenInfo extends dependencies['mail.model'] {}
+
+ ThreadPartnerSeenInfo.modelName = 'mail.thread_partner_seen_info';
+
+ ThreadPartnerSeenInfo.fields = {
+ id: attr(),
+ lastFetchedMessage: many2one('mail.message'),
+ lastSeenMessage: many2one('mail.message'),
+ partner: many2one('mail.partner'),
+ thread: many2one('mail.thread', {
+ inverse: 'partnerSeenInfos',
+ }),
+ };
+
+ return ThreadPartnerSeenInfo;
+}
+
+registerNewModel('mail.thread_partner_seen_info', factory);
+
+});
diff --git a/addons/mail/static/src/models/thread_viewer/thread_viewer.js b/addons/mail/static/src/models/thread_viewer/thread_viewer.js
new file mode 100644
index 00000000000..4da88fd9513
--- /dev/null
+++ b/addons/mail/static/src/models/thread_viewer/thread_viewer.js
@@ -0,0 +1,270 @@
+odoo.define('mail/static/src/models/thread_viewer/thread_viewer.js', function (require) {
+'use strict';
+
+const { registerNewModel } = require('mail/static/src/model/model_core.js');
+const { attr, many2many, many2one, one2one } = require('mail/static/src/model/model_field.js');
+
+function factory(dependencies) {
+
+ class ThreadViewer extends dependencies['mail.model'] {
+
+ /**
+ * @override
+ */
+ delete() {
+ this._stopLoading();
+ super.delete();
+ }
+
+ //----------------------------------------------------------------------
+ // Public
+ //----------------------------------------------------------------------
+
+ /**
+ * This function register a hint for the component related to this
+ * record. Hints are information on changes around this viewer that
+ * make require adjustment on the component. For instance, if this
+ * thread viewer initiated a thread cache load and it now has become
+ * loaded, then it may need to auto-scroll to last message.
+ *
+ * @param {string} hintType name of the hint. Used to determine what's
+ * the broad type of adjustement the component has to do.
+ * @param {any} [hintData] data of the hint. Used to fine-tune
+ * adjustments on the component.
+ */
+ addComponentHint(hintType, hintData) {
+ const hint = this._makeComponentHint(hintType, hintData);
+ this.update({
+ componentHintList: this.componentHintList.concat([hint]),
+ });
+ }
+
+ /**
+ * @param {mail.thread_cache} threadCache
+ */
+ handleThreadCacheLoaded(threadCache) {
+ if (threadCache !== this.threadCache) {
+ return;
+ }
+ this._stopLoading();
+ }
+
+ /**
+ * @param {Object} hint
+ */
+ markComponentHintProcessed(hint) {
+ let filterFun;
+ switch (hint.type) {
+ case 'current-partner-just-posted-message':
+ filterFun = h => h.type !== hint.type && h.messageId !== hint.messageId;
+ break;
+ default:
+ filterFun = h => h.type !== hint.type;
+ break;
+ }
+ this.update({
+ componentHintList: this.componentHintList.filter(filterFun),
+ });
+ }
+
+ /**
+ * @param {string} scrollTop
+ */
+ saveThreadCacheScrollPositionsAsInitial(scrollTop) {
+ if (!this.threadCache) {
+ return;
+ }
+ this.update({
+ threadCacheInitialScrollPositions: Object.assign({}, this.threadCacheInitialScrollPositions, {
+ [this.threadCache.localId]: scrollTop,
+ }),
+ });
+ }
+
+ //----------------------------------------------------------------------
+ // Private
+ //----------------------------------------------------------------------
+
+ /**
+ * @private
+ * @returns {mail.thread_cache|undefined}
+ */
+ _computeThreadCache() {
+ if (!this.thread) {
+ return [];
+ }
+ return [['replace', this.thread.cache(this.stringifiedDomain)]];
+ }
+
+ /**
+ * @private
+ * @returns {integer|undefined}
+ */
+ _computeThreadCacheInitialPosition() {
+ if (!this.threadCache) {
+ return undefined;
+ }
+ return this.threadCacheInitialScrollPositions[this.threadCache.localId];
+ }
+
+ /**
+ * @private
+ */
+ _prepareLoading() {
+ this._isPreparingLoading = true;
+ this._loaderTimeout = setTimeout(() => {
+ this.isShowingLoading = true;
+ this._isPreparingLoading = false;
+ }, 400);
+ }
+
+ /**
+ * @private
+ */
+ _stopLoading() {
+ clearTimeout(this._loaderTimeout);
+ this._loaderTimeout = null;
+ this.isShowingLoading = false;
+ this._isPreparingLoading = false;
+ }
+
+ /**
+ * @private
+ * @param {string} hintType
+ * @param {any} hintData
+ * @returns {Object}
+ */
+ _makeComponentHint(hintType, hintData) {
+ let hint;
+ switch (hintType) {
+ case 'change-of-thread-cache':
+ hint = { type: hintType };
+ break;
+ case 'current-partner-just-posted-message':
+ hint = {
+ messageId: hintData,
+ type: hintType,
+ };
+ break;
+ case 'more-messages-loaded':
+ hint = { type: hintType };
+ break;
+ default:
+ throw new Error(`Undefined component hint "${hintType}" for ThreadViewer`);
+ }
+ return hint;
+ }
+
+ /**
+ * @override
+ */
+ _updateAfter(previous) {
+ if (this.thread && this.thread !== previous.thread) {
+ this._stopLoading();
+ if (!this.threadCache.isLoaded && !this.threadCache.isLoading) {
+ this.threadCache.loadMessages();
+ }
+ }
+ if (this.threadCache !== previous.threadCache) {
+ this._stopLoading();
+ this.addComponentHint('change-of-thread-cache');
+ }
+
+ if (
+ this.thread && this.threadCache.isLoading &&
+ !this.isShowingLoading && !this._isPreparingLoading
+ ) {
+ this._prepareLoading();
+ }
+ }
+
+ /**
+ * @override
+ */
+ _updateBefore() {
+ return {
+ thread: this.thread,
+ threadCache: this.threadCache,
+ };
+ }
+
+ }
+
+ ThreadViewer.fields = {
+ chatWindow: one2one('mail.chat_window', {
+ inverse: 'threadViewer',
+ }),
+ checkedMessages: many2many('mail.message', {
+ related: 'threadCache.checkedMessages',
+ }),
+ /**
+ * List of component hints. Hints contain information that help
+ * components make UI/UX decisions based on their UI state.
+ * For instance, on receiving new messages and the last message
+ * is visible, it should auto-scroll to this new last message.
+ *
+ * Format of a component hint:
+ *
+ * {
+ * type: {string} the name of the component hint. Useful
+ * for components to dispatch behaviour
+ * based on its type.
+ * data: {Object} data related to the component hint.
+ * For instance, if hint suggests to scroll
+ * to a certain message, data may contain
+ * message id.
+ * }
+ */
+ componentHintList: attr({
+ default: [],
+ }),
+ messages: many2many('mail.message', {
+ related: 'threadCache.messages',
+ }),
+ stringifiedDomain: attr({
+ default: '[]',
+ }),
+ thread: many2one('mail.thread', {
+ inverse: 'viewers',
+ }),
+ threadCache: many2one('mail.thread_cache', {
+ compute: '_computeThreadCache',
+ dependencies: [
+ 'stringifiedDomain',
+ 'thread',
+ 'threadCaches',
+ ],
+ }),
+ threadCacheInitialPosition: attr({
+ compute: '_computeThreadCacheInitialPosition',
+ dependencies: [
+ 'threadCache',
+ 'threadCacheInitialScrollPositions',
+ ],
+ }),
+ /**
+ * List of saved initial scroll positions of thread caches.
+ * Useful to restore scroll position on changing back to this
+ * thread cache. Note that this is only applied when opening
+ * the thread cache, because scroll position may change fast so
+ * save is already throttled
+ */
+ threadCacheInitialScrollPositions: attr({
+ default: {},
+ }),
+ threadCaches: many2many('mail.thread_cache', {
+ related: 'thread.caches',
+ }),
+ uncheckedMessages: many2many('mail.message', {
+ related: 'threadCache.uncheckedMessages',
+ }),
+ };
+
+ ThreadViewer.modelName = 'mail.thread_viewer';
+
+ return ThreadViewer;
+}
+
+registerNewModel('mail.thread_viewer', factory);
+
+});
diff --git a/addons/mail/static/src/models/user/user.js b/addons/mail/static/src/models/user/user.js
new file mode 100644
index 00000000000..2e6206c79f5
--- /dev/null
+++ b/addons/mail/static/src/models/user/user.js
@@ -0,0 +1,78 @@
+odoo.define('mail/static/src/models/user/user.js', function (require) {
+'use strict';
+
+const { registerNewModel } = require('mail/static/src/model/model_core.js');
+const { attr, one2one } = require('mail/static/src/model/model_field.js');
+
+function factory(dependencies) {
+
+ class User extends dependencies['mail.model'] {
+
+ /**
+ * @override
+ */
+ delete() {
+ if (this.env.messaging) {
+ if (this === this.env.messaging.currentUser) {
+ this.env.messaging.update({ currentUser: [['unlink-all']] });
+ }
+ }
+ super.delete();
+ }
+
+ //----------------------------------------------------------------------
+ // Public
+ //----------------------------------------------------------------------
+
+ /**
+ * @returns {string}
+ */
+ nameOrDisplayName() {
+ const partner = this.partner;
+ if (!partner) {
+ return this.partnerDisplayName;
+ }
+ return partner.nameOrDisplayName;
+ }
+
+ //----------------------------------------------------------------------
+ // Private
+ //----------------------------------------------------------------------
+
+ /**
+ * @override
+ */
+ _createRecordLocalId(data) {
+ return `${this.env.models['mail.user'].modelName}_${data.id}`;
+ }
+
+ /**
+ * @override
+ */
+ _updateAfter(previous) {
+ if (this.partnerDisplayName && this.partner) {
+ this.partner.update({ display_name: this.partnerDisplayName });
+ }
+ }
+
+ }
+
+ User.fields = {
+ id: attr(),
+ model: attr({
+ default: 'res.user',
+ }),
+ partner: one2one('mail.partner', {
+ inverse: 'user',
+ }),
+ partnerDisplayName: attr(),
+ };
+
+ User.modelName = 'mail.user';
+
+ return User;
+}
+
+registerNewModel('mail.user', factory);
+
+});
diff --git a/addons/mail/static/src/scss/abstract_thread_window.scss b/addons/mail/static/src/scss/abstract_thread_window.scss
deleted file mode 100644
index 3a7f028f2f2..00000000000
--- a/addons/mail/static/src/scss/abstract_thread_window.scss
+++ /dev/null
@@ -1,194 +0,0 @@
-$o-mail-thread-window-zindex: $zindex-modal + 1 !default;
-
-.o_thread_window {
- direction: ltr;
- display: flex;
- flex-flow: column nowrap;
- position: fixed;
- width: $o-mail-thread-window-width;
- max-width: 100%;
- height: 400px;
- max-height: 100%;
- font-size: 12px;
- background-color: $o-mail-thread-window-bg;
- border-radius: 6px 6px 0 0;
- z-index: $o-mail-thread-window-zindex;
- box-shadow: -5px -5px 10px rgba(black, 0.18);
-
- @include media-breakpoint-down(sm) {
- width: 100%;
- height: 100%!important;
- box-shadow: none;
- &.o_folded {
- display: none;
- }
- }
-
- @media print {
- display: none;
- }
-
- .o_thread_window_header {
- align-items: center;
- display: flex;
- flex: 0 0 auto;
- color: white;
- padding: $o-mail-chatter-gap*0.5 $o-mail-chatter-gap;
- border-radius: 3px 3px 0 0;
- border-bottom: 1px solid gray('300');
- background-color: $o-brand-odoo;
- padding: 8px;
-
- @include media-breakpoint-down(sm) {
- align-items: center;
- height: $o-mail-chat-header-height;
- padding: 0;
- border-radius: 0px;
- .o_thread_window_title {
- font-size: 16px;
- margin-left: 10px;
- }
-
- .o_thread_window_close {
- $o-close-font-size: 17px;
- padding: (($o-mail-chat-header-height - $o-close-font-size) / 2);
- font-size: $o-close-font-size;
- color: white;
- }
- }
- .o_thread_window_avatar {
- margin: -6px 6px -6px 0;
- position: relative;
-
- img {
- height: 25px;
- width: 25px;
- border-radius: 50%;
- }
- span {
- bottom: -4px;
- right: -2px;
- position: absolute;
-
- .fa-circle-o {
- display: none;
- }
- }
- }
-
- .o_thread_window_title {
- cursor: pointer;
- flex: 1 1 auto;
- @include o-text-overflow;
-
- .o_mail_thread_typing_icon {
- padding-left: 2px;
-
- .o_mail_thread_typing_icon_dot {
- background: gray('300');
- }
- }
- }
-
- .o_thread_window_buttons {
- flex: 0 0 auto;
- .o_thread_window_close, .o_thread_window_expand {
- color: white;
- padding: 3px;
- margin-left: 5px;
- @include o-hover-opacity(0.7, 1);
- }
- }
-
- }
-
- .o_mail_thread {
- flex: 1 1 100%;
- overflow: auto;
- -webkit-overflow-scrolling: touch; // smooth scrolling in iOS app (Safari)
-
- .o_thread_date_separator {
- margin: 0px 0px 15px 0px;
- .o_thread_date {
- background-color: $o-mail-thread-window-bg;
- }
- }
- .o_thread_message {
- padding: 4px 5px;
- .o_thread_message_sidebar {
- margin-right: 5px;
- }
- .o_attachment {
- @include media-breakpoint-up(md) {
- width: percentage(1/3);
- }
- }
- }
- }
-
- .o_thread_composer input {
- width: 100%;
- }
-}
-
-.o_thread_window_dropdown {
- width: auto;
- height: 28px;
- color: white;
- background-color: gray('900');
- cursor: pointer;
- box-shadow: none;
-
- @include media-breakpoint-down(sm) {
- display: none;
- }
-
- .o_thread_window_header {
- border-radius: 0;
- }
-
- .o_thread_window_dropdown_toggler {
- padding: 5px;
-
- .o_total_unread_counter {
- @include o-position-absolute(-10px, 0, auto, auto);
- background-color: $o-brand-primary;
- padding: 0 2px;
- font-size: smaller;
- }
- }
- &.show .o_thread_window_dropdown_toggler .o_total_unread_counter, .o_thread_window_expand {
- display: none;
- }
-
- > ul {
- max-width: $o-mail-thread-window-width;
- padding: 0;
-
- > li.o_thread_window_header {
- font-size: 12px;
- padding: 3px 5px;
- &~li.o_thread_window_header {
- border-top: 1px solid white;
- }
- &:hover {
- background-color: darken($o-brand-odoo, 10%);
- }
- }
- }
-}
-
-.o_ui_blocked .o_thread_window {
- // We cannot put the z-index of thread windows directly to be greater than
- // blockUI's as ui-autocomplete dropdowns (which are below blockUI) would
- // appear under the thread windows (and ui-autocomplete is used to choose the
- // person you want to chat with). So we only raise the z-index value when
- // the ui is really blocked (in that case, the ui-autocomplete dropdowns
- // will disappear under the thread windows but this is not really an issue as
- // there should not be any at that time).
- z-index: 1101; // blockUI's z-index is 1100
-}
-
-.o_no_thread_window .o_thread_window {
- display: none;
-}
diff --git a/addons/mail/static/src/scss/attachment_box.scss b/addons/mail/static/src/scss/attachment_box.scss
deleted file mode 100644
index 7008fcce61e..00000000000
--- a/addons/mail/static/src/scss/attachment_box.scss
+++ /dev/null
@@ -1,34 +0,0 @@
-.oe_chatter {
-
- .o_mail_chatter_attachments {
- min-height: 50px;
-
- .o_chatter_attachment {
- padding: $o-attachment-margin;
-
- input.o_input_file {
- position: absolute;
- top: 1px;
- opacity: 0;
- pointer-events: none;
- }
-
- .o_border_dashed {
- position: relative;
- margin-top: 5px;
- margin-bottom: 5px;
- border-bottom: 1px dashed gray('300');
- text-align: center;
-
- .o_attach_title {
- position: relative;
- top: 10px;
- margin: 0 auto;
- padding: 0 10px;
- font-weight: bold;
- background: white;
- }
- }
- }
- }
-}
diff --git a/addons/mail/static/src/scss/chatter.scss b/addons/mail/static/src/scss/chatter.scss
deleted file mode 100644
index 6784121da58..00000000000
--- a/addons/mail/static/src/scss/chatter.scss
+++ /dev/null
@@ -1,35 +0,0 @@
-.o_chatter {
- .o_spinner {
- text-align: center;
- font-size: large;
- }
-
- .o_chatter_topbar {
- display: flex;
- border-bottom: $nav-tabs-border-width solid transparent;
-
- .btn {
- @extend .text-nowrap;
- margin-bottom: -$nav-tabs-border-width;
- padding: $nav-link-padding-y $nav-link-padding-x;
- border-radius: $border-radius-sm $border-radius-sm 0 0;
- }
-
- > .o_topbar_right_area {
- display: flex;
- margin-left: auto;
- justify-content: flex-end;
- }
- }
-
- &.o_chatter_composer_active .o_chatter_topbar {
- border-bottom-color: gray('300');
-
- .btn.o_active {
- color: $o-brand-odoo;
- background-color: theme-color('light');
- border: $nav-tabs-border-width solid gray('300');
- border-bottom-color: transparent;
- }
- }
-}
diff --git a/addons/mail/static/src/scss/composer.scss b/addons/mail/static/src/scss/composer.scss
index ad3338fb794..b478c0b0941 100644
--- a/addons/mail/static/src/scss/composer.scss
+++ b/addons/mail/static/src/scss/composer.scss
@@ -12,274 +12,6 @@
local(Symbola);
}
-.o_thread_composer {
- flex: 0 0 auto;
- display: flex;
- border-top: none;
- background: theme-color('light');
-
- .o_composer, .o_composer_subject {
- position: relative;
- background: white;
-
- textarea, input {
- border: none;
- }
- }
-
- .o_composer_subject {
- display: flex;
- padding-bottom: 5px;
-
- > input {
- flex: 1 1 auto;
- padding: 5px;
- margin-right: 30px;
- }
- }
-
- .o_composer {
- .o_composer_input > textarea {
- width: 100%;
- resize: none;
- }
- .o_composer_mention_dropdown .o_mention_proposition {
- @include o-text-overflow;
- width: 100%;
- padding: 3px 15px;
- .o_mention_name {
- font-weight: 500;
- }
- .o_mention_info {
- font-style: italic;
- color: gray;
- }
- .fa {
- font-size: 0.8em;
- }
- }
- textarea {
- resize: none;
- }
- .btn.fa {
- @include o-hover-opacity($default-opacity: 0.8, $hover-opacity: 1);
- border: none;
- }
- .o_composer_button_full_composer {
- @include o-position-absolute(auto, 0);
- }
-
- .o_mail_emoji_container {
- padding: 0 $o-mail-chatter-gap $o-mail-chatter-gap 0;
- border: 1px solid gray('300');
- border-width: 1px 0 0 0;
- background-color: theme-color('light');
-
- .o_mail_emoji {
- margin: $o-mail-chatter-gap 0 0 $o-mail-chatter-gap;
- cursor: pointer; // only in the popover
- min-width: 22px;
- min-height: 22px;
- }
- }
- }
-
- // Both inline and not-inline design. Mini-composer disabled.
- &:not(.o_chat_mini_composer) {
- padding: $o-mail-thread-avatar-size*0.5;
- padding-left: $grid-gutter-width*0.5 + $o-mail-thread-avatar-size + $o-mail-chatter-gap;
- border: 1px solid gray('300');
- border-top: none;
-
- .o_chatter_avatar {
- @include o-position-absolute(0, $left: -$o-mail-thread-avatar-size - $o-mail-chatter-gap);
- }
-
- .o_chatter_composer_info, .o_composer_suggested_partners {
- flex: 0 0 100%;
- margin-bottom: $o-mail-chatter-gap*0.5;
- }
-
- .o_composer_container {
- position: relative;
- }
-
- .o_composer {
- border-radius: 2px;
- textarea {
- padding-top: $o-mail-chatter-gap !important;
- padding-right: $grid-gutter-width; // avoid overlaps betwenn textarea and o_composer_button_full_composer
- }
-
- .o_mail_emoji_container {
- margin: 0 -$o-mail-chatter-gap;
- }
-
- @include media-breakpoint-down(sm) {
- .o_composer_button_send {
- color: $o-brand-primary;
- }
- }
- }
-
- .o_composer, .o_composer_subject {
- border: 1px solid gray('300');
-
- textarea, input {
- padding: 0 0 $o-mail-chatter-gap;
- border: none;
- }
- }
- .o_composer_subject {
- border-bottom: 0;
- border-radius: 2px 2px 0 0;
- }
-
- .o_chatter_composer_tools {
- border-top: 1px solid gray('300');
- }
-
- &:not(.o_chat_inline_composer) {
- flex-flow: row wrap;
-
- .o_composer_container {
- max-width: 100%;
- }
- .o_composer_container, .o_composer_button_send {
- flex: 1 0 100%;
- }
- .o_composer, .o_composer_subject {
- padding: 0 $o-mail-chatter-gap 0;
- }
- .o_composer_send {
- margin-top: $o-mail-chatter-gap;
-
- > .o_composer_button_discard:not(:hover) {
- background: theme-color('light');
- border-color: transparent;
- }
- }
- }
-
- &.o_chat_inline_composer {
- .o_composer_container {
- flex: 1 1 auto;
- }
- .o_composer {
- padding: $o-mail-chatter-gap $o-mail-chatter-gap 0 $o-mail-chatter-gap;
- border-radius: 2px 0 0 2px;
- border-right-width: 0;
-
- .o_mail_emoji_container {
- text-align: right;
- border-right-width: 1px;
- }
- }
- .o_composer_input {
- display: flex;
- flex-flow: row wrap;
-
- textarea {
- padding-top: 0px !important;
- &:disabled {
- position: absolute;
- }
- }
- }
- .o_composer_text_field {
- flex: 1 1 50%;
- margin: 0;
- padding: 0;
- }
- .o_chatter_composer_tools {
- flex: 0 1 auto;
- }
- .o_composer_attachments_list {
- flex: 1 0 100%;
- }
-
- .o_composer_thread_typing {
- font-size: 0.9em;
- color: gray('600');
- }
-
- .o_chatter_composer_tools {
- border: none;
- border-left: 1px solid gray('300');
- padding-left: $o-mail-chatter-gap*0.5;
- transform: translateY(-$o-mail-chatter-gap*0.5);
- }
-
- .o_composer_button_send {
- border-radius: 0 3px 3px 0;
- // Make the button appear as the same size as the subject input
- padding: 10px $input-btn-padding-x;
- border: 1px solid darken($o-brand-primary, 5%);
- border-left-width: 0;
- }
- }
- }
-
- // Mini-composer enabled
- &.o_chat_mini_composer {
- .o_composer_container {
- border-top: 1px solid gray('300');
- width: 100%;
- }
- .o_composer {
- .o_mail_emoji_container {
- @include o-position-absolute(auto, 0, 100%, 0);
- border-bottom-width: 1px;
- max-height: calc(100vh - 100%);
- overflow-y: auto;
- }
- }
- .o_composer_input {
- display: flex;
- flex-flow: row nowrap;
-
- textarea.o_composer_text_field {
- flex: 1 1 auto;
- }
-
- .o_chatter_composer_tools {
- flex: 0 0 auto;
- }
- @include media-breakpoint-down(sm) {
- .o_composer_button_send {
- display: inline-block;
- }
- }
- }
- .o_composer_send .o_composer_button_send, .o_chatter_avatar {
- display: none !important;
- }
- .o_attachment {
- width: 50%;
- }
- }
-
- .o_file_drop_zone_container {
- display: flex;
- position: absolute;
- left: 0px;
- bottom: 0px;
- height: 100%;
- width: 100%;
- color: $o-brand-primary;
- background: rgba(255, 255, 255, 0.9);
- border: 2px dashed $o-brand-primary;
- z-index: 1;
- align-items: center;
- justify-content: center;
- }
-}
-
-.o_composer_attachments_list:not(:empty) {
- margin-top: $o-mail-chatter-gap;
- text-align: justify;
-}
-
// Emoji
.o_mail_emoji {
display: inline-block;
@@ -291,80 +23,6 @@
font-size: 100%;
}
-// Attachment Icons (common for chat thread and chat composer)
-.o_attachments_list, .o_attachments_previews {
- margin: 0 (-$o-attachment-margin) 0;
- display: flex;
- flex-flow: row wrap;
-}
-
- .o_attachment_wrap {
- padding-right: 0px;
- }
-
-.o_attachments_previews {
- > .o_attachment {
- width: 50%;
-
- @include media-breakpoint-up(md) {
- width: percentage(1/3);
- }
- @include media-breakpoint-up(lg) {
- width: percentage(1/4);
- }
- @include media-breakpoint-up(xl) {
- width: percentage(1/6);
- }
-
- .o_attachment_wrap {
- padding: 0;
- border-radius: 2px;
- border: 1px solid gray('400');
- }
-
- .o_image_box {
- overflow: hidden;
- padding-top: 75%;
-
- .o_attachment_image {
- @include o-position-absolute(0, 0, 0, 0);
- background-size: cover;
- background-position: center;
- transform: scale(1.05);
- transition: all 0.3s ease 0s;
- }
-
- &:hover .o_attachment_image {
- transform: scale(1);
- }
- }
-
- .o_image_overlay {
- @include o-position-absolute($top: 0, $left: 0, $right:0, $bottom:0);
- @include o-hover-opacity($default-opacity: 0, $hover-opacity: 1);
- background-image: linear-gradient(180deg, rgba(black, 0.2), rgba(black, 0.9));
- transition: all 0.3s ease 0s;
- padding: 10px;
-
- .o_attachment_title {
- @include o-position-absolute($bottom: 7px);
- width: 80%;
- @include o-text-overflow(inline-block);
- font-size: $font-size-sm;
- }
-
- .o_attachment_delete_cross {
- color: white;
- }
-
- .o_attachment_download {
- @include o-position-absolute($right: 7px, $bottom:7px);
- }
- }
- }
-}
-
-
@mixin o-viewer-black-btn {
background-color: rgba(black, 0.4);
color: rgba(theme-color('light'), 0.7);
diff --git a/addons/mail/static/src/scss/discuss.scss b/addons/mail/static/src/scss/discuss.scss
index 81b7460325b..22e5e71c677 100644
--- a/addons/mail/static/src/scss/discuss.scss
+++ b/addons/mail/static/src/scss/discuss.scss
@@ -2,258 +2,10 @@
// Layout
// ------------------------------------------------------------------
-.o_mail_discuss {
- @include o-position-absolute(0, 0, 0, 0);
- height: 100%;
- overflow: hidden;
-
- .o_mail_discuss_loading {
- display: flex;
- flex: 1 1 auto;
- align-items: center;
- justify-content: center;
- min-height: 100%;
-
- > i {
- margin-right: 5px;
- }
- }
-
- .o_mail_discuss_sidebar {
- width: $o-mail-chat-sidebar-width;
- height: 100%;
- float: left;
- overflow: auto;
- padding: $o-horizontal-padding 0;
-
- @include media-breakpoint-up(xl) {
- width: $o-mail-chat-sidebar-width + 50px;
- }
-
- .o_discuss_sidebar_quick_search {
- display: flex;
- justify-content: center;
-
- > input {
- border-radius: 15px;
- flex-basis: 80%;
- }
- }
-
- .o_mail_sidebar_title {
- display: flex;
- padding: 6px $o-horizontal-padding 0 $o-horizontal-padding;
- align-items: center;
- justify-content: space-between;
- h4 {
- text-transform: uppercase;
- margin-bottom: 5px;
- }
- .o_add {
- padding: 6px;
- }
- .o_mail_open_channels {
- cursor: pointer;
- }
- }
- .o_mail_discuss_item {
- display: flex;
- align-items: center;
- width: 100%;
- padding: 2px $o-horizontal-padding 2px ($o-horizontal-padding + 12px); // compensate icon
-
- .o_thread_name {
- flex: 1 1 auto;
- @include o-text-overflow;
- }
- .o_mail_thread_typing_icon_dot {
- background: gray('600');;
- }
- .o_mail_sidebar_needaction, .o_mail_partner_unpin, .o_mail_channel_settings {
- flex: 0 0 auto;
- }
- .o_mail_sidebar_needaction {
- margin: 0 0 0 5px;
- }
- .o_mail_partner_unpin, .o_mail_channel_settings {
- padding: 2px 6px;
- }
- i.fa-envelope-o {
- margin-left: 3px;
- }
-
- &.o_mail_discuss_title_main {
- padding: $input-btn-padding-y $o-horizontal-padding;
- margin-bottom: 2px;
- }
- }
-
- .o_mail_discuss_item + .o_mail_sidebar_title {
- margin-top: $input-btn-padding-y;
- }
-
- .o_mail_add_thread {
- width: 100%;
- padding: 5px ($o-horizontal-padding + 23px); // compensate icon
- position: relative;
-
- > input {
- width: 100%;
- padding: 2px 5px;
- }
- > span {
- @include o-position-absolute(7px, $left: 27px);
- }
- }
- }
-
- .o_mail_discuss_content {
- display: flex;
- flex-flow: column nowrap;
- height: 100%;
- margin-left: $o-mail-chat-sidebar-width;
- position: relative;
- overflow: auto;
-
- @include media-breakpoint-up(xl) {
- margin-left: $o-mail-chat-sidebar-width + 50px;
- }
-
- .o_mail_thread {
- position: relative;
- flex: 1 0 0;
- overflow: auto;
- -webkit-overflow-scrolling: touch; // smooth scrolling in iOS app (Safari)
- }
-
- .o_mail_snackbar {
- @include o-position-absolute(15px, 15px);
- padding-top: 10px;
- padding-bottom: 10px;
- cursor: pointer;
- background-color: darken(gray('700'), 8%);
- color: white;
- }
- }
-}
-
// ------------------------------------------------------------------
// Style
// ------------------------------------------------------------------
-.o_mail_discuss {
- .o_mail_discuss_sidebar {
- background-color: gray('900');
- color: gray('300');
-
- .o_discuss_sidebar_quick_search {
-
- > input:focus {
- &::placeholder {
- color: transparent;
- }
- }
-
- > input {
- outline: none;
- background-color: gray('800');
- border: gray('600');
- text-align: center;
- color: gray('400');
- }
- }
-
- .o_mail_discuss_item {
- cursor: pointer;
-
- &:hover {
- background-color: darken(gray('900'),2%);
-
- .o_mail_partner_unpin, .o_mail_channel_settings {
- display: inline-block;
- }
- }
-
- &.o_active {
- background-color: gray('900');
- box-shadow: inset 3px 0 0 $o-brand-primary;
- }
-
- .o_mail_hash {
- opacity: 0.6
- }
-
- .o_mail_partner_unpin, .o_mail_channel_settings {
- display: none;
- }
- &.o_unread_message {
- color: white;
- font-weight: bold;
- }
-
- > .badge {
- color: white;
- background-color: $o-brand-primary;
- }
-
- &.o_mail_discuss_title_main {
- font-size: $font-size-base;
-
- &.o_mail_mailbox_title_starred > .badge {
- background-color: gray('600');
- border-color: gray('600');
- }
- }
- }
-
- > hr {
- border-color: gray('700');
- }
-
- .o_mail_add_thread {
- display: none;
- > input {
- color: black;
- }
- > span {
- color: gray;
- }
- }
-
- .o_mail_sidebar_title {
- h4 {
- margin: 0;
- font-size: $font-size-sm;
- text-transform: uppercase;
- color: gray('600');
- &.o_mail_open_channels:hover {
- color: white;
- }
- }
- .o_add {
- cursor: pointer;
- @include o-hover-opacity($o-mail-sidebar-icon-opacity, 1);
- @include o-hover-text-color(gray('600'), white);
- }
- }
- }
-
- .o_mail_discuss_content {
- background-color: white;
- }
-
- &.o_mail_selection_mode {
- .o_thread_message {
- opacity: 0.5;
- &.o_thread_selected_message {
- padding: 4px $o-horizontal-padding;
- opacity: 1;
- background-color: lightgray;
- }
- }
- }
-}
-
.o_mail_user_status {
font-size: 1em;
position: relative;
@@ -270,66 +22,6 @@
}
}
-
-// ------------------------------------------------------------------
-// Mobile
-// ------------------------------------------------------------------
-@include media-breakpoint-down(sm) {
- .o_mail_discuss {
- display: flex;
- flex-flow: column nowrap;
- background: white;
-
- .o_mail_discuss_mobile_mailboxes_buttons {
- display: flex;
- flex: 0 0 auto;
- z-index: 2;
- box-shadow: 0 2px 4px gray('400');
-
- .o_mailbox_inbox_item {
- flex: 1 1 auto;
- width: 50%;
- padding: 2%;
-
- &.btn-secondary {
- border-bottom: 1px solid gray('400')
- }
- }
- }
-
- .o_mail_discuss_content {
- flex: 1 1 auto;
- min-height: 0;
- margin-left: 0;
- background-color: transparent;
- }
-
- .o_mail_discuss_tab_pane {
- .o_mail_add_thread {
- display: none;
- position: relative;
- padding: 5px 0px;
- margin: 10px;
- border: 1px solid #afafaf;
- border-radius: 4px;
- > span {
- @include o-position-absolute($top: 10px, $left: 10px);
- }
- > input {
- border: none;
- padding: 5px 8px;
- padding-left: 20px;
- }
- }
- }
- .o_mail_mobile_tabs {
- flex: 0 0 auto;
- z-index: 1;
- background-color: white;
- }
- }
-}
-
// ------------------------------------------------------------------
// Thread preview: shared between discuss (mobile) and systray
// ------------------------------------------------------------------
diff --git a/addons/mail/static/src/scss/followers.scss b/addons/mail/static/src/scss/followers.scss
deleted file mode 100644
index 031affb6e52..00000000000
--- a/addons/mail/static/src/scss/followers.scss
+++ /dev/null
@@ -1,92 +0,0 @@
-.o_followers {
- display: flex;
- justify-content: flex-end;
- order: 1;
-
- &.o_field_widget {
- margin-bottom: initial;
- }
-
- > .o_followers_title_box {
- order: 1;
- }
-
- .o_followers_follow_button {
- color: $body-color;
- font-weight: bold;
- text-align: right;
-
- .o_follow, .o_followers_unfollow, .o_followers_following {
- display: none;
- }
-
- // when following, show 'following'
- &.o_followers_following .o_followers_following {
- display: initial;
- }
-
- // when following and hovering, show 'unfollow'
- &.o_followers_following:hover {
- .o_followers_following {
- display: none;
- }
-
- .o_followers_unfollow {
- display: initial;
- }
- }
-
- // when not following show 'follow'
- &.o_followers_notfollow .o_follow {
- display: initial;
- }
-
- .fa {
- &.fa-times {
- color: theme-color('warning');
- }
-
- &.fa-check {
- color: theme-color('success');
- }
- }
- }
-
- .o_followers_list {
- width: 20vh;
- max-height: 50vh;
- overflow: auto;
- }
-
- .o_followers_title_box .dropdown-item.o_partner {
- padding: 0;
- display: flex;
- align-items: center;
-
- .o_mail_redirect {
- margin-right: auto;
- color: $dropdown-link-color;
-
- img {
- width: $o-mail-partner-avatar-size;
- height: $o-mail-partner-avatar-size;
- object-fit: cover;
- }
- }
-
- .btn {
- padding: 0 $grid-gutter-width*0.25;
- }
-
- &.o_inactive {
- .o_mail_redirect {
- color: $gray-500;
- font-style: italic;
-
- img {
- opacity: 0.25;
- }
- }
- }
- }
-}
diff --git a/addons/mail/static/src/scss/out_of_office.scss b/addons/mail/static/src/scss/out_of_office.scss
deleted file mode 100644
index bcdb3a182f7..00000000000
--- a/addons/mail/static/src/scss/out_of_office.scss
+++ /dev/null
@@ -1,8 +0,0 @@
-.o_out_of_office {
- background-color: #9dd0e8;
- padding:8px;
-
- .o_out_of_office_info {
- font-weight: bold;
- }
-}
diff --git a/addons/mail/static/src/scss/thread.scss b/addons/mail/static/src/scss/thread.scss
index 2fc55c7d637..17c0ae72655 100644
--- a/addons/mail/static/src/scss/thread.scss
+++ b/addons/mail/static/src/scss/thread.scss
@@ -1,32 +1,4 @@
-.o_mail_thread_loading {
- display: flex;
- align-items: center;
- justify-content: center;
-}
-
-.o_mail_thread_loading_icon {
- margin-right: 5px;
-}
-
-.o_mail_thread, .o_mail_activity {
- .o_thread_show_more {
- text-align: center;
- }
-
- .o_mail_thread_content {
- display: flex;
- flex-direction: column;
- min-height: 100%;
- }
-
- .o_thread_bottom_free_space {
- height: 15px;
- }
-
- .o_thread_tooltip_container {
- display: inline;
- position: relative;
- }
+.o_mail_activity {
.o_thread_date_separator {
margin-top: 15px;
@@ -48,30 +20,11 @@
}
}
- .o_thread_new_messages_separator {
- margin-bottom: 15px;
- border-bottom: solid lighten($o-brand-odoo, 15%) 1px;
- text-align: right;
- .o_thread_separator_label {
- position: relative;
- top: 8px;
- padding: 0 10px;
- background: white;
- color: lighten($o-brand-odoo, 15%);
- font-size: smaller;
- }
- }
-
.o_thread_message {
display: flex;
padding: 4px $o-horizontal-padding;
margin-bottom: 0px;
- &.o_mail_not_discussion {
- background-color: rgba(gray('300'), 0.5);
- border-bottom: 1px solid gray('400');
- }
-
.o_thread_message_sidebar {
flex: 0 0 $o-mail-thread-avatar-size;
margin-right: 10px;
@@ -106,37 +59,13 @@
height: $o-mail-thread-avatar-size;
object-fit: cover;
}
- .o_thread_message_side_date {
- display: none;
- margin-left: -5px;
- }
- .o_thread_message_star {
- display: none;
- margin-right: -5px;
- }
-
- .o_thread_message_side_date {
- opacity: 0;
- }
-
- .o_mail_thread_message_seen_icon:not(.o_all_seen) {
- opacity: $o-mail-thread-icon-opacity*0.5;
- }
}
.o_thread_icon {
cursor: pointer;
opacity: 0;
- &.fa-star {
- opacity: $o-mail-thread-icon-opacity;
- color: gold;
- }
}
- &:hover, &.o_thread_selected_message {
- .o_thread_message_side_date {
- display: inline-block;
- opacity: $o-mail-thread-side-date-opacity;
- }
+ &:hover {
.o_thread_icon {
display: inline-block;
opacity: $o-mail-thread-icon-opacity;
@@ -144,11 +73,6 @@
opacity: 1;
}
}
- .o_thread_message_sidebar {
- .o_mail_thread_message_seen_icon {
- display: none;
- }
- }
}
.o_mail_redirect {
@@ -210,29 +134,9 @@
}
}
- .o_thread_message_star, .o_thread_message_needaction, .o_thread_message_reply, .o_thread_message_notification {
+ .o_thread_message_needaction, .o_thread_message_reply {
padding: 4px;
}
-
- .o_thread_message_notification {
- color: grey;
- &.o_thread_message_notification_error {
- color: red;
- opacity: 1;
- cursor: pointer;
- }
- }
-
- .o_attachments_list, .o_attachments_previews {
- &:last-child {
- margin-bottom: $grid-gutter-width;
- }
- }
-
- .o_thread_tooltip_container {
- display: inline;
- position: relative;
- }
}
}
.o_thread_title {
@@ -268,68 +172,3 @@
opacity: $o-mail-thread-icon-opacity;
}
}
-
-// ------------------------------------------------------------------
-// Thread typing icon: shared between discuss and chat windows
-// ------------------------------------------------------------------
-
-.o_mail_thread_typing_icon {
- position: relative;
- text-align: center;
- margin-left: auto;
- margin-right: auto;
-
- .o_mail_thread_typing_icon_dot {
- display: inline-block;
- width: 3px;
- height: 3px;
- border-radius: 50%;
- background: gray('800');
- animation: o_mail_thread_typing_icon_dot 1.5s linear infinite;
-
- &:nth-child(2) {
- animation-delay: -1.35s;
- }
-
- &:nth-child(3) {
- animation-delay: -1.2s;
- }
- }
-}
-
-@keyframes o_mail_thread_typing_icon_dot {
- 0%, 40%, 100% {
- transform: initial;
- }
- 20% {
- transform: translateY(-5px);
- }
-}
-
-// ------------------------------------------------------------------
-// Thread seen icon: shared between discuss and chat windows
-// ------------------------------------------------------------------
-
-.o_mail_thread_message_seen_icon {
- position: relative;
- opacity: 0.6;
-
- &.o_all_seen {
- color: $o-enterprise-color;
- }
-
- &:hover {
- cursor: pointer;
- opacity: 0.8;
- }
-
- .fa-check:nth-child(1) {
- padding-left: 3px;
- }
-
- .fa-check:nth-child(2) {
- position: absolute;
- top: -1px;
- left: 0px;
- }
-}
diff --git a/addons/mail/static/src/scss/thread_window.scss b/addons/mail/static/src/scss/thread_window.scss
deleted file mode 100644
index abc8ff2846d..00000000000
--- a/addons/mail/static/src/scss/thread_window.scss
+++ /dev/null
@@ -1,63 +0,0 @@
-.o_thread_window {
- &.o_thread_less {
- .o_thread_search_input {
- display: flex;
- align-items: baseline;
- padding: $o-mail-chatter-gap*0.5 $o-mail-chatter-gap;
-
- > span {
- flex: 0 0 auto;
- }
-
- > input {
- margin-left: 5px;
- padding: $o-mail-chatter-gap*0.5;
- flex: 1 1 100%;
- }
- }
-
- .o_mail_thread {
- background-color: $o-webclient-background-color;
- }
-
- .o_thread_composer {
- display: none;
- }
- }
-
- .o_thread_composer {
- .o_file_drop_zone_container {
- top: 35px;
- }
- .o_composer {
- .o_composer_input {
- width: 80%;
- > textarea {
- padding: 10px;
- font-size: 13px;
- }
- @include media-breakpoint-down(sm) {
- width: 70%;
- > textarea {
- padding: 15px 10px;
- }
- }
- }
- .o_chatter_composer_tools {
- @include o-position-absolute($right: 0px, $top: 0px);
- button {
- padding: 10px;
- @include media-breakpoint-down(sm) {
- padding: 13px;
- }
- &.btn.fa {
- font-size: 1.3em;
- }
- }
- }
- }
- .o_composer_attachments_list {
- margin: 0px;
- }
- }
-}
diff --git a/addons/mail/static/src/services/chat_window_service/chat_window_service.js b/addons/mail/static/src/services/chat_window_service/chat_window_service.js
new file mode 100644
index 00000000000..8a11c20257c
--- /dev/null
+++ b/addons/mail/static/src/services/chat_window_service/chat_window_service.js
@@ -0,0 +1,104 @@
+odoo.define('mail/static/src/services/chat_window_service/chat_window_service.js', function (require) {
+'use strict';
+
+const components = {
+ ChatWindowManager: require('mail/static/src/components/chat_window_manager/chat_window_manager.js'),
+};
+
+const AbstractService = require('web.AbstractService');
+const { bus, serviceRegistry } = require('web.core');
+
+const ChatWindowService = AbstractService.extend({
+ /**
+ * @override {web.AbstractService}
+ */
+ start() {
+ this._super(...arguments);
+ this._webClientReady = false;
+ this._listenHomeMenu();
+ },
+ /**
+ * @private
+ */
+ destroy() {
+ if (this.component) {
+ this.component.destroy();
+ this.component = undefined;
+ }
+ },
+
+ //--------------------------------------------------------------------------
+ // Private
+ //--------------------------------------------------------------------------
+
+ /**
+ * @private
+ * @returns {Node}
+ */
+ _getParentNode() {
+ return document.querySelector('body');
+ },
+ /**
+ * @private
+ */
+ _listenHomeMenu() {
+ bus.on('hide_home_menu', this, this._onHideHomeMenu.bind(this));
+ bus.on('show_home_menu', this, this._onShowHomeMenu.bind(this));
+ bus.on('web_client_ready', this, this._onWebClientReady.bind(this));
+ },
+ /**
+ * @private
+ */
+ async _mount() {
+ if (this.component) {
+ this.component.destroy();
+ this.component = undefined;
+ }
+ const ChatWindowManagerComponent = components.ChatWindowManager;
+ this.component = new ChatWindowManagerComponent(null);
+ const parentNode = this._getParentNode();
+ await this.component.mount(parentNode);
+ },
+
+ //--------------------------------------------------------------------------
+ // Handlers
+ //--------------------------------------------------------------------------
+
+ /**
+ * @private
+ */
+ async _onHideHomeMenu() {
+ if (!this._webClientReady) {
+ return;
+ }
+ if (document.querySelector('.o_ChatWindowManager')) {
+ return;
+ }
+ await this._mount();
+ },
+ /**
+ * @private
+ */
+ async _onShowHomeMenu() {
+ if (!this._webClientReady) {
+ return;
+ }
+ if (document.querySelector('.o_ChatWindowManager')) {
+ return;
+ }
+ await this._mount();
+ },
+ /**
+ * @private
+ */
+ async _onWebClientReady() {
+ await this._mount();
+ this._webClientReady = true;
+ },
+});
+
+serviceRegistry.add('chat_window', ChatWindowService);
+
+return ChatWindowService;
+
+});
diff --git a/addons/mail/static/src/services/dialog_service/dialog_service.js b/addons/mail/static/src/services/dialog_service/dialog_service.js
new file mode 100644
index 00000000000..88762a2992d
--- /dev/null
+++ b/addons/mail/static/src/services/dialog_service/dialog_service.js
@@ -0,0 +1,101 @@
+odoo.define('mail/static/src/services/dialog_service/dialog_service.js', function (require) {
+'use strict';
+
+const components = {
+ DialogManager: require('mail/static/src/components/dialog_manager/dialog_manager.js'),
+};
+
+const AbstractService = require('web.AbstractService');
+const { bus, serviceRegistry } = require('web.core');
+
+const DialogService = AbstractService.extend({
+ /**
+ * @override {web.AbstractService}
+ */
+ start() {
+ this._super(...arguments);
+ this._webClientReady = false;
+ this._listenHomeMenu();
+ },
+ /**
+ * @private
+ */
+ destroy() {
+ if (this.component) {
+ this.component.destroy();
+ this.component = undefined;
+ }
+ },
+
+ //--------------------------------------------------------------------------
+ // Private
+ //--------------------------------------------------------------------------
+
+ /**
+ * @private
+ * @returns {Node}
+ */
+ _getParentNode() {
+ return document.querySelector('body');
+ },
+ /**
+ * @private
+ */
+ _listenHomeMenu() {
+ bus.on('hide_home_menu', this, this._onHideHomeMenu.bind(this));
+ bus.on('show_home_menu', this, this._onShowHomeMenu.bind(this));
+ bus.on('web_client_ready', this, this._onWebClientReady.bind(this));
+ },
+ /**
+ * @private
+ */
+ async _mount() {
+ if (this.component) {
+ this.component.destroy();
+ this.component = undefined;
+ }
+ const DialogManagerComponent = components.DialogManager;
+ this.component = new DialogManagerComponent(null);
+ const parentNode = this._getParentNode();
+ await this.component.mount(parentNode);
+ },
+
+ //--------------------------------------------------------------------------
+ // Handlers
+ //--------------------------------------------------------------------------
+
+ /**
+ * @private
+ */
+ async _onHideHomeMenu() {
+ if (!this._webClientReady) {
+ return;
+ }
+ if (document.querySelector('.o_DialogManager')) {
+ return;
+ }
+ await this._mount();
+ },
+ async _onShowHomeMenu() {
+ if (!this._webClientReady) {
+ return;
+ }
+ if (document.querySelector('.o_DialogManager')) {
+ return;
+ }
+ await this._mount();
+ },
+ /**
+ * @private
+ */
+ async _onWebClientReady() {
+ await this._mount();
+ this._webClientReady = true;
+ }
+});
+
+serviceRegistry.add('dialog', DialogService);
+
+return DialogService;
+
+});
diff --git a/addons/mail/static/src/utils/deferred/deferred.js b/addons/mail/static/src/utils/deferred/deferred.js
new file mode 100644
index 00000000000..f96696fb992
--- /dev/null
+++ b/addons/mail/static/src/utils/deferred/deferred.js
@@ -0,0 +1,21 @@
+odoo.define('mail/static/src/utils/deferred/deferred.js', function (require) {
+'use strict';
+
+/**
+ * @returns {Deferred}
+ */
+function makeDeferred() {
+ let resolve;
+ let reject;
+ const prom = new Promise(function (res, rej) {
+ resolve = res.bind(this);
+ reject = rej.bind(this);
+ });
+ prom.resolve = (...args) => resolve(...args);
+ prom.reject = (...args) => reject(...args);
+ return prom;
+}
+
+return { makeDeferred };
+
+});
diff --git a/addons/mail/static/src/utils/test_utils.js b/addons/mail/static/src/utils/test_utils.js
new file mode 100644
index 00000000000..e8e0c6dbc10
--- /dev/null
+++ b/addons/mail/static/src/utils/test_utils.js
@@ -0,0 +1,860 @@
+odoo.define('mail/static/src/utils/test_utils.js', function (require) {
+'use strict';
+
+const BusService = require('bus.BusService');
+
+const {
+ addMessagingToEnv,
+ addTimeControlToEnv,
+} = require('mail/static/src/env/test_env.js');
+const ChatWindowService = require('mail/static/src/services/chat_window_service/chat_window_service.js');
+const DialogService = require('mail/static/src/services/dialog_service/dialog_service.js');
+const { nextTick } = require('mail/static/src/utils/utils.js');
+const DiscussWidget = require('mail/static/src/widgets/discuss/discuss.js');
+const MessagingMenuWidget = require('mail/static/src/widgets/messaging_menu/messaging_menu.js');
+
+const AbstractStorageService = require('web.AbstractStorageService');
+const NotificationService = require('web.NotificationService');
+const RamStorage = require('web.RamStorage');
+const {
+ createActionManager,
+ createView,
+ makeTestPromise,
+ mock: {
+ addMockEnvironment,
+ patch: legacyPatch,
+ unpatch: legacyUnpatch,
+ },
+} = require('web.test_utils');
+const Widget = require('web.Widget');
+
+const { Component } = owl;
+
+//------------------------------------------------------------------------------
+// Private
+//------------------------------------------------------------------------------
+
+/**
+ * Create a fake object 'dataTransfer', linked to some files,
+ * which is passed to drag and drop events.
+ *
+ * @param {Object[]} files
+ * @returns {Object}
+ */
+function _createFakeDataTransfer(files) {
+ return {
+ dropEffect: 'all',
+ effectAllowed: 'all',
+ files,
+ items: [],
+ types: ['Files'],
+ };
+}
+
+/**
+ * @private
+ * @param {Object} callbacks
+ * @param {function[]} callbacks.init
+ * @param {function[]} callbacks.mount
+ * @param {function[]} callbacks.destroy
+ * @param {function[]} callbacks.return
+ * @returns {Object} update callbacks
+ */
+function _useChatWindow(callbacks) {
+ const {
+ mount: prevMount,
+ destroy: prevDestroy,
+ } = callbacks;
+ return Object.assign({}, callbacks, {
+ mount: prevMount.concat(() => {
+ // trigger mounting of chat window manager
+ Component.env.services['chat_window']._onWebClientReady();
+ }),
+ destroy: prevDestroy.concat(() => {
+ Component.env.services['chat_window'].destroy();
+ }),
+ });
+}
+
+/**
+ * @private
+ * @param {Object} callbacks
+ * @param {function[]} callbacks.init
+ * @param {function[]} callbacks.mount
+ * @param {function[]} callbacks.destroy
+ * @param {function[]} callbacks.return
+ * @returns {Object} update callbacks
+ */
+function _useDialog(callbacks) {
+ const {
+ mount: prevMount,
+ destroy: prevDestroy,
+ } = callbacks;
+ return Object.assign({}, callbacks, {
+ mount: prevMount.concat(() => {
+ // trigger mounting of dialog manager
+ Component.env.services['dialog']._onWebClientReady();
+ }),
+ destroy: prevDestroy.concat(() => {
+ Component.env.services['dialog'].destroy();
+ }),
+ });
+}
+
+/**
+ * @private
+ * @param {Object} callbacks
+ * @param {function[]} callbacks.init
+ * @param {function[]} callbacks.mount
+ * @param {function[]} callbacks.destroy
+ * @param {function[]} callbacks.return
+ * @return {Object} update callbacks
+ */
+function _useDiscuss(callbacks) {
+ const {
+ init: prevInit,
+ mount: prevMount,
+ return: prevReturn,
+ } = callbacks;
+ let discussWidget;
+ const state = {
+ autoOpenDiscuss: false,
+ discussData: {},
+ };
+ return Object.assign({}, callbacks, {
+ init: prevInit.concat(params => {
+ const {
+ autoOpenDiscuss = state.autoOpenDiscuss,
+ discuss: discussData = state.discussData
+ } = params;
+ Object.assign(state, { autoOpenDiscuss, discussData });
+ delete params.autoOpenDiscuss;
+ delete params.discuss;
+ }),
+ mount: prevMount.concat(async params => {
+ const { selector, widget } = params;
+ DiscussWidget.prototype._pushStateActionManager = () => {};
+ discussWidget = new DiscussWidget(widget, state.discussData);
+ await discussWidget.appendTo($(selector));
+ if (state.autoOpenDiscuss) {
+ discussWidget.on_attach_callback();
+ }
+ }),
+ return: prevReturn.concat(result => {
+ Object.assign(result, { discussWidget });
+ }),
+ });
+}
+
+/**
+ * @private
+ * @param {Object} callbacks
+ * @param {function[]} callbacks.init
+ * @param {function[]} callbacks.mount
+ * @param {function[]} callbacks.destroy
+ * @param {function[]} callbacks.return
+ * @returns {Object} update callbacks
+ */
+function _useMessagingMenu(callbacks) {
+ const {
+ mount: prevMount,
+ return: prevReturn,
+ } = callbacks;
+ let messagingMenuWidget;
+ return Object.assign({}, callbacks, {
+ mount: prevMount.concat(async ({ selector, widget }) => {
+ messagingMenuWidget = new MessagingMenuWidget(widget, {});
+ await messagingMenuWidget.appendTo($(selector));
+ messagingMenuWidget.on_attach_callback();
+ }),
+ return: prevReturn.concat(result => {
+ Object.assign(result, { messagingMenuWidget });
+ }),
+ });
+}
+
+//------------------------------------------------------------------------------
+// Public: rendering timers
+//------------------------------------------------------------------------------
+
+/**
+ * Returns a promise resolved at the next animation frame.
+ *
+ * @returns {Promise}
+ */
+function nextAnimationFrame() {
+ const requestAnimationFrame = owl.Component.scheduler.requestAnimationFrame;
+ return new Promise(function (resolve) {
+ setTimeout(() => requestAnimationFrame(() => resolve()));
+ });
+}
+
+/**
+ * Returns a promise resolved the next time OWL stops rendering.
+ *
+ * @param {function} func function which, when called, is
+ * expected to trigger OWL render(s).
+ * @param {number} [timeoutDelay=5000] in ms
+ * @returns {Promise}
+ */
+const afterNextRender = (function () {
+ const stop = owl.Component.scheduler.stop;
+ const stopPromises = [];
+
+ owl.Component.scheduler.stop = function () {
+ const wasRunning = this.isRunning;
+ stop.call(this);
+ if (wasRunning) {
+ while (stopPromises.length) {
+ stopPromises.pop().resolve();
+ }
+ }
+ };
+
+ async function afterNextRender(func, timeoutDelay = 5000) {
+ // Define the potential errors outside of the promise to get a proper
+ // trace if they happen.
+ const startError = new Error("Timeout: the render didn't start.");
+ const stopError = new Error("Timeout: the render didn't stop.");
+ // Set up the timeout to reject if no render happens.
+ let timeoutNoRender;
+ const timeoutProm = new Promise((resolve, reject) => {
+ timeoutNoRender = setTimeout(() => {
+ let error = startError;
+ if (owl.Component.scheduler.isRunning) {
+ error = stopError;
+ }
+ console.error(error);
+ reject(error);
+ }, timeoutDelay);
+ });
+ // Set up the promise to resolve if a render happens.
+ const prom = makeTestPromise();
+ stopPromises.push(prom);
+ // Start the function expected to trigger a render after the promise
+ // has been registered to not miss any potential render.
+ const funcRes = func();
+ // Make them race (first to resolve/reject wins).
+ await Promise.race([prom, timeoutProm]);
+ clearTimeout(timeoutNoRender);
+ // Wait the end of the function to ensure all potential effects are
+ // taken into account during the following verification step.
+ await funcRes;
+ // Wait one more frame to make sure no new render has been queued.
+ await nextAnimationFrame();
+ if (owl.Component.scheduler.isRunning) {
+ await afterNextRender(() => {}, timeoutDelay);
+ }
+ }
+
+ return afterNextRender;
+})();
+
+
+//------------------------------------------------------------------------------
+// Public: test lifecycle
+//------------------------------------------------------------------------------
+
+function beforeEach(self) {
+ const data = {
+ initMessaging: {
+ channel_slots: {},
+ commands: [],
+ is_moderator: false,
+ mail_failures: [],
+ mention_partner_suggestions: [],
+ menu_id: false,
+ moderation_counter: 0,
+ moderation_channel_ids: [],
+ needaction_inbox_counter: 0,
+ partner_root: {
+ active: false,
+ display_name: "OdooBot",
+ id: 2,
+ },
+ public_partner: {
+ active: false,
+ display_name: "Public user",
+ id: 4,
+ },
+ shortcodes: [],
+ starred_counter: 0,
+ },
+ 'ir.attachment': {
+ fields: {
+ name: { type: 'char', string: "attachment name", required: true },
+ res_model: { type: 'char', string: "res model" },
+ res_id: { type: 'integer', string: "res id" },
+ url: { type: 'char', string: 'url' },
+ type: { type: 'selection', selection: [['url', "URL"], ['binary', "BINARY"]] },
+ mimetype: { type: 'char', string: "mimetype" },
+ },
+ },
+ 'mail.activity': {
+ fields: {
+ can_write: {
+ type: 'boolean',
+ },
+ icon: {
+ type: 'string',
+ },
+ id: {
+ type: 'integer',
+ },
+ res_id: {
+ type: 'integer',
+ },
+ res_model: {
+ type: 'string',
+ },
+ },
+ },
+ 'mail.channel': {
+ fields: {
+ channel_type: {
+ string: "Channel Type",
+ type: "selection",
+ },
+ id: {
+ string: "Id",
+ type: 'integer',
+ },
+ message_unread_counter: {
+ string: "# unread messages",
+ type: 'integer',
+ },
+ name: {
+ string: "Name",
+ type: "char",
+ required: true,
+ },
+ },
+ },
+ 'mail.followers': {
+ fields: {
+ channel_id: {
+ type: 'integer',
+ },
+ email: {
+ type: 'string',
+ },
+ id: {
+ type: 'integer',
+ },
+ is_active: {
+ type: 'boolean',
+ },
+ is_editable: {
+ type: 'boolean',
+ },
+ name: {
+ type: 'string',
+ },
+ partner_id: {
+ type: 'integer',
+ },
+ },
+ },
+ 'mail.message': {
+ fields: {
+ attachment_ids: {
+ string: "Attachments",
+ type: 'many2many',
+ relation: 'ir.attachment',
+ default: [],
+ },
+ author_id: {
+ string: "Author",
+ relation: 'res.partner',
+ },
+ body: {
+ string: "Contents",
+ type: 'html',
+ },
+ channel_ids: {
+ string: "Channels",
+ type: 'many2many',
+ relation: 'mail.channel',
+ },
+ date: {
+ string: "Date",
+ type: 'datetime',
+ },
+ history_partner_ids: {
+ string: "Partners with History",
+ type: 'many2many',
+ relation: 'res.partner',
+ },
+ id: {
+ string: "Id",
+ type: 'integer',
+ },
+ is_discussion: {
+ string: "Discussion",
+ type: 'boolean',
+ },
+ is_note: {
+ string: "Note",
+ type: 'boolean',
+ },
+ is_notification: {
+ string: "Notification",
+ type: 'boolean',
+ },
+ is_starred: {
+ string: "Starred",
+ type: 'boolean',
+ },
+ message_type: {
+ string: "Type",
+ type: 'selection',
+ },
+ model: {
+ string: "Related Document model",
+ type: 'char',
+ },
+ needaction: {
+ string: "Need Action",
+ type: 'boolean',
+ },
+ needaction_partner_ids: {
+ string: "Partners with Need Action",
+ type: 'many2many',
+ relation: 'res.partner',
+ },
+ record_name: {
+ string: "Name",
+ type: 'string',
+ },
+ res_id: {
+ string: "Related Document ID",
+ type: 'integer',
+ },
+ starred: {
+ string: "Starred",
+ type: 'boolean',
+ },
+ starred_partner_ids: {
+ string: "Favorited By",
+ type: 'many2many',
+ relation: 'res.partner',
+ },
+ },
+ },
+ 'mail.notification': {
+ fields: {
+ is_read: {
+ string: "Is Read",
+ type: 'boolean',
+ },
+ mail_message_id: {
+ string: "Message",
+ type: 'many2one',
+ relation: 'mail.message',
+ },
+ res_partner_id: {
+ string: "Needaction Recipient",
+ type: 'many2one',
+ relation: 'res.partner',
+ },
+ },
+ },
+ 'res.partner': {
+ fields: {
+ display_name: { string: "Displayed name", type: "char" },
+ im_status: {
+ string: "status",
+ type: 'char',
+ },
+ message_follower_ids: {
+ relation: 'follower',
+ string: "Followers",
+ type: "one2many",
+ },
+ },
+ records: [],
+ },
+ 'res.users': {
+ fields: {
+ partner_id: {
+ string: "Related partners",
+ type: 'many2one',
+ relation: 'res.partner',
+ },
+ },
+ },
+ };
+
+ const originals = {
+ '_.debounce': _.debounce,
+ '_.throttle': _.throttle,
+ 'window.fetch': window.fetch,
+ };
+
+ (function patch() {
+ // patch _.debounce and _.throttle to be fast and synchronous
+ _.debounce = _.identity;
+ _.throttle = _.identity;
+ let uploadedAttachmentsCount = 1;
+ window.fetch = async function (route, form) {
+ const formData = form.body;
+ return {
+ async text() {
+ const ufiles = formData.getAll('ufile');
+ const files = ufiles.map(ufile => JSON.stringify({
+ filename: ufile.name,
+ id: uploadedAttachmentsCount,
+ mimetype: ufile.type,
+ name: ufile.name,
+ }));
+ const callback = formData.get('callback');
+ uploadedAttachmentsCount++;
+ return `
+ <script language="javascript" type="text/javascript">
+ var win = window.top.window;
+ win.jQuery(win).trigger('${callback}', ${files.join(', ')});
+ </script>`;
+ }
+ };
+ };
+ })();
+
+ function unpatch() {
+ _.debounce = originals['_.debounce'];
+ _.throttle = originals['_.throttle'];
+ window.fetch = originals['window.fetch'];
+ }
+
+ Object.assign(self, { data, unpatch });
+
+ return {
+ data,
+ unpatch,
+ };
+}
+
+function afterEach(self) {
+ self.unpatch();
+}
+
+/**
+ * Main function used to make a mocked environment with mocked messaging env.
+ *
+ * @param {Object} [param0={}]
+ * @param {string} [param0.arch] makes only sense when `param0.hasView` is set:
+ * the arch to use in createView.
+ * @param {Object} [param0.archs]
+ * @param {boolean} [param0.autoOpenDiscuss=false] makes only sense when
+ * `param0.hasDiscuss` is set: determine whether mounted discuss should be
+ * open initially.
+ * @param {boolean} [param0.debug=false]
+ * @param {Object} [param0.data] makes only sense when `param0.hasView` is set:
+ * the data to use in createView.
+ * @param {Object} [param0.discuss={}] makes only sense when `param0.hasDiscuss`
+ * is set: provide data that is passed to discuss widget (= client action) as
+ * 2nd positional argument.
+ * @param {Object} [param0.env={}]
+ * @param {function} [param0.mockRPC]
+ * @param {boolean} [param0.hasActionManager=false] if set, use
+ * createActionManager.
+ * @param {boolean} [param0.hasChatWindow=false] if set, mount chat window
+ * service.
+ * @param {boolean} [param0.hasDiscuss=false] if set, mount discuss app.
+ * @param {boolean} [param0.hasMessagingMenu=false] if set, mount messaging
+ * menu.
+ * @param {boolean} [param0.hasTimeControl=false] if set, all flow of time
+ * with `env.browser.setTimeout` are fully controlled by test itself.
+ * @see addTimeControlToEnv that adds `advanceTime` function in
+ * `env.testUtils`.
+ * @param {boolean} [param0.hasView=false] if set, use createView to create a
+ * view instead of a generic widget.
+ * @param {string} [param0.model] makes only sense when `param0.hasView` is set:
+ * the model to use in createView.
+ * @param {integer} [param0.res_id] makes only sense when `param0.hasView` is set:
+ * the res_id to use in createView.
+ * @param {Object} [param0.services]
+ * @param {Object} [param0.session]
+ * @param {Object} [param0.View] makes only sense when `param0.hasView` is set:
+ * the View class to use in createView.
+ * @param {Object} [param0.viewOptions] makes only sense when `param0.hasView`
+ * is set: the view options to use in createView.
+ * @param {boolean} [param0.waitUntilMessagingInitialized=true]
+ * @param {...Object} [param0.kwargs]
+ * @returns {Object}
+ */
+async function start(param0 = {}) {
+ let callbacks = {
+ init: [],
+ mount: [],
+ destroy: [],
+ return: [],
+ };
+ const {
+ hasActionManager = false,
+ hasChatWindow = false,
+ hasDialog = false,
+ hasDiscuss = false,
+ hasMessagingMenu = false,
+ hasTimeControl = false,
+ hasView = false,
+ waitUntilMessagingInitialized = true,
+ } = param0;
+ delete param0.hasActionManager;
+ delete param0.hasChatWindow;
+ delete param0.hasDiscuss;
+ delete param0.hasMessagingMenu;
+ delete param0.hasTimeControl;
+ delete param0.hasView;
+ if (hasChatWindow) {
+ callbacks = _useChatWindow(callbacks);
+ }
+ if (hasDialog) {
+ callbacks = _useDialog(callbacks);
+ }
+ if (hasDiscuss) {
+ callbacks = _useDiscuss(callbacks);
+ }
+ if (hasMessagingMenu) {
+ callbacks = _useMessagingMenu(callbacks);
+ }
+ const {
+ init: initCallbacks,
+ mount: mountCallbacks,
+ destroy: destroyCallbacks,
+ return: returnCallbacks,
+ } = callbacks;
+ const { debug = false } = param0;
+ initCallbacks.forEach(callback => callback(param0));
+
+ let env = addMessagingToEnv(param0.env, { debug });
+ if (hasTimeControl) {
+ env = addTimeControlToEnv(env);
+ }
+
+ const services = Object.assign({}, {
+ bus_service: BusService.extend({
+ _beep() {}, // Do nothing
+ _poll() {}, // Do nothing
+ _registerWindowUnload() {}, // Do nothing
+ isOdooFocused() {
+ return true;
+ },
+ updateOption() {},
+ }),
+ chat_window: ChatWindowService.extend({
+ _getParentNode() {
+ return document.querySelector(debug ? 'body' : '#qunit-fixture');
+ },
+ _listenHomeMenu: () => {},
+ }),
+ dialog: DialogService.extend({
+ _getParentNode() {
+ return document.querySelector(debug ? 'body' : '#qunit-fixture');
+ },
+ _listenHomeMenu: () => {},
+ }),
+ local_storage: AbstractStorageService.extend({ storage: new RamStorage() }),
+ notification: NotificationService.extend(),
+ }, param0.services);
+
+ const kwargs = Object.assign({}, param0, {
+ archs: Object.assign({}, {
+ 'mail.message,false,search': '<search/>'
+ }, param0.archs),
+ debug: param0.debug || false,
+ services: Object.assign({}, services, param0.services),
+ }, { env });
+ let widget;
+ let mockServer; // only in basic mode
+ let testEnv;
+ const selector = debug ? 'body' : '#qunit-fixture';
+ if (hasView) {
+ widget = await createView(kwargs);
+ legacyPatch(widget, {
+ destroy() {
+ this._super(...arguments);
+ destroyCallbacks.forEach(callback => callback({ widget }));
+ legacyUnpatch(widget);
+ if (testEnv) {
+ testEnv.destroyMessaging();
+ }
+ }
+ });
+ } else if (hasActionManager) {
+ widget = await createActionManager(kwargs);
+ legacyPatch(widget, {
+ destroy() {
+ this._super(...arguments);
+ destroyCallbacks.forEach(callback => callback({ widget }));
+ legacyUnpatch(widget);
+ if (testEnv) {
+ testEnv.destroyMessaging();
+ }
+ }
+ });
+ } else {
+ const Parent = Widget.extend({ do_push_state() {} });
+ const parent = new Parent();
+ mockServer = await addMockEnvironment(parent, kwargs);
+ widget = new Widget(parent);
+ await widget.appendTo($(selector));
+ Object.assign(widget, {
+ destroy() {
+ delete widget.destroy;
+ destroyCallbacks.forEach(callback => callback({ widget }));
+ parent.destroy();
+ if (testEnv) {
+ testEnv.destroyMessaging();
+ }
+ },
+ });
+ }
+
+ testEnv = Component.env;
+
+ /**
+ * Components cannot use web.bus, because they cannot use
+ * EventDispatcherMixin, and webclient cannot easily access env.
+ * Communication between webclient and components by core.bus
+ * (usable by webclient) and messagingBus (usable by components), which
+ * the messaging service acts as mediator since it can easily use both
+ * kinds of buses.
+ */
+ testEnv.bus.on(
+ 'hide_home_menu',
+ null,
+ () => testEnv.messagingBus.trigger('hide_home_menu')
+ );
+ testEnv.bus.on(
+ 'show_home_menu',
+ null,
+ () => testEnv.messagingBus.trigger('show_home_menu')
+ );
+ testEnv.bus.on(
+ 'will_hide_home_menu',
+ null,
+ () => testEnv.messagingBus.trigger('will_hide_home_menu')
+ );
+ testEnv.bus.on(
+ 'will_show_home_menu',
+ null,
+ () => testEnv.messagingBus.trigger('will_show_home_menu')
+ );
+ testEnv.modelManager.start(testEnv);
+ /**
+ * Create the messaging singleton record.
+ */
+ testEnv.messaging = testEnv.models['mail.messaging'].create();
+ testEnv.messaging.start().then(() =>
+ testEnv.messagingInitializedDeferred.resolve()
+ );
+
+ const result = { env: testEnv, mockServer, widget };
+
+ if (waitUntilMessagingInitialized) {
+ // env key only accessible after MessagingService has started
+ await testEnv.messagingInitializedDeferred;
+ }
+
+ if (mountCallbacks.length > 0) {
+ await afterNextRender(async () => {
+ await Promise.all(mountCallbacks.map(callback => callback({ selector, widget })));
+ });
+ }
+ returnCallbacks.forEach(callback => callback(result));
+ return result;
+}
+
+//------------------------------------------------------------------------------
+// Public: file utilities
+//------------------------------------------------------------------------------
+
+/**
+ * Drag some files over a DOM element
+ *
+ * @param {DOM.Element} el
+ * @param {Object[]} file must have been create beforehand
+ * @see testUtils.file.createFile
+ */
+function dragenterFiles(el, files) {
+ const ev = new Event('dragenter', { bubbles: true });
+ Object.defineProperty(ev, 'dataTransfer', {
+ value: _createFakeDataTransfer(files),
+ });
+ el.dispatchEvent(ev);
+}
+
+/**
+ * Drop some files on a DOM element
+ *
+ * @param {DOM.Element} el
+ * @param {Object[]} files must have been created beforehand
+ * @see testUtils.file.createFile
+ */
+function dropFiles(el, files) {
+ const ev = new Event('drop', { bubbles: true });
+ Object.defineProperty(ev, 'dataTransfer', {
+ value: _createFakeDataTransfer(files),
+ });
+ el.dispatchEvent(ev);
+}
+
+/**
+ * Set files in a file input
+ *
+ * @param {DOM.Element} el
+ * @param {Object[]} files must have been created beforehand
+ * @see testUtils.file.createFile
+ */
+function inputFiles(el, files) {
+ const dataTransfer = new window.DataTransfer();
+ for (const file of files) {
+ dataTransfer.items.add(file);
+ }
+ el.files = dataTransfer.files;
+ /**
+ * Changing files programatically is not supposed to trigger the event but
+ * it does in Chrome versions before 73 (which is on runbot), so in that
+ * case there is no need to make a manual dispatch, because it would lead to
+ * the files being added twice.
+ */
+ const versionRaw = navigator.userAgent.match(/Chrom(e|ium)\/([0-9]+)\./);
+ const chromeVersion = versionRaw ? parseInt(versionRaw[2], 10) : false;
+ if (!chromeVersion || chromeVersion >= 73) {
+ el.dispatchEvent(new Event('change'));
+ }
+}
+
+/**
+ * Paste some files on a DOM element
+ *
+ * @param {DOM.Element} el
+ * @param {Object[]} files must have been created beforehand
+ * @see testUtils.file.createFile
+ */
+function pasteFiles(el, files) {
+ const ev = new Event('paste', { bubbles: true });
+ Object.defineProperty(ev, 'clipboardData', {
+ value: _createFakeDataTransfer(files),
+ });
+ el.dispatchEvent(ev);
+}
+
+//------------------------------------------------------------------------------
+// Export
+//------------------------------------------------------------------------------
+
+return {
+ afterEach,
+ afterNextRender,
+ beforeEach,
+ dragenterFiles,
+ dropFiles,
+ inputFiles,
+ nextAnimationFrame,
+ nextTick,
+ pasteFiles,
+ start,
+};
+
+});
diff --git a/addons/mail/static/src/utils/throttle/throttle.js b/addons/mail/static/src/utils/throttle/throttle.js
new file mode 100644
index 00000000000..6b9ff00844a
--- /dev/null
+++ b/addons/mail/static/src/utils/throttle/throttle.js
@@ -0,0 +1,382 @@
+odoo.define('mail/static/src/utils/throttle/throttle.js', function (require) {
+'use strict';
+
+const { makeDeferred } = require('mail/static/src/utils/deferred/deferred.js');
+
+/**
+ * This module define an utility function that enables throttling calls on a
+ * provided function. Such throttled calls can be canceled, flushed and/or
+ * cleared:
+ *
+ * - cancel: Canceling a throttle function call means that if a function call is
+ * pending invocation, cancel removes this pending call invocation. It however
+ * preserves the internal timer of the cooling down phase of this throttle
+ * function, meaning that any following throttle function call will be pending
+ * and has to wait for the remaining time of the cooling down phase before
+ * being invoked.
+ *
+ * - flush: Flushing a throttle function call means that if a function call is
+ * pending invocation, flush immediately terminates the cooling down phase and
+ * the pending function call is immediately invoked. Flush also works without
+ * any pending function call: it just terminates the cooling down phase, so
+ * that a following function call is guaranteed to be immediately called.
+ *
+ * - clear: Clearing a throttle function combines canceling and flushing
+ * together.
+ */
+
+//------------------------------------------------------------------------------
+// Errors
+//------------------------------------------------------------------------------
+
+/**
+ * List of internal and external Throttle errors.
+ * Internal errors are prefixed with `_`.
+ */
+
+ /**
+ * Error when throttle function has been canceled with `.cancel()`. Used to
+ * let the caller know of throttle function that the call has been canceled,
+ * which means the inner function will not be called. Usually caller should
+ * just accept it and kindly treat this error as a polite warning.
+ */
+class ThrottleCanceledError extends Error {
+ /**
+ * @override
+ */
+ constructor(throttleId, ...args) {
+ super(...args);
+ this.name = 'ThrottleCanceledError';
+ this.throttleId = throttleId;
+ }
+}
+/**
+ * Error when throttle function has been reinvoked again. Used to let know
+ * caller of throttle function that the call has been canceled and replaced with
+ * another one, which means the (potentially) following inner function will be
+ * in the context of another call. Same as for `ThrottleCanceledError`, usually
+ * caller should just accept it and kindly treat this error as a polite
+ * warning.
+ */
+class ThrottleReinvokedError extends Error {
+ /**
+ * @override
+ */
+ constructor(throttleId, ...args) {
+ super(...args);
+ this.name = 'ThrottleReinvokedError';
+ this.throttleId = throttleId;
+ }
+}
+/**
+ * Error when throttle function has been flushed with `.flush()`. Used
+ * internally to immediately invoke pending inner functions, since a flush means
+ * the termination of cooling down phase.
+ *
+ * @private
+ */
+class _ThrottleFlushedError extends Error {
+ /**
+ * @override
+ */
+ constructor(throttleId, ...args) {
+ super(...args);
+ this.name = '_ThrottleFlushedError';
+ this.throttleId = throttleId;
+ }
+}
+
+//------------------------------------------------------------------------------
+// Private
+//------------------------------------------------------------------------------
+
+/**
+ * This class models the behaviour of the cancelable, flushable and clearable
+ * throttle version of a provided function. See definitions at the top of this
+ * file.
+ */
+class Throttle {
+
+ /**
+ * @param {Object} env the OWL env
+ * @param {function} func provided function for making throttled version.
+ * @param {integer} duration duration of the 'cool down' phase, i.e.
+ * the minimum duration between the most recent function call that has
+ * been made and the following function call (of course, assuming no flush
+ * in-between).
+ */
+ constructor(env, func, duration) {
+ /**
+ * Reference to the OWL envirionment. Useful to fine-tune control of
+ * time flow in tests.
+ * @see mail/static/src/utils/test_utils.js:start.hasTimeControl
+ */
+ this.env = env;
+ /**
+ * Unique id of this throttle function. Useful for the ThrottleError
+ * management, in order to determine whether these errors come from
+ * this throttle or from another one (e.g. inner function makes use of
+ * another throttle).
+ */
+ this.id = _.uniqueId('throttle_');
+ /**
+ * Deferred of current cooling down phase in progress. Defined only when
+ * there is a cooling down phase in progress. Resolved when cooling down
+ * phase terminates from timeout, and rejected if flushed.
+ *
+ * @see _ThrottleFlushedError for rejection of this deferred.
+ */
+ this._coolingDownDeferred = undefined;
+ /**
+ * Duration, in milliseconds, of the cool down phase.
+ */
+ this._duration = duration;
+ /**
+ * Inner function to be invoked and throttled.
+ */
+ this._function = func;
+ /**
+ * Determines whether the throttle function is currently in cool down
+ * phase. Cool down phase happens just after inner function has been
+ * invoked, and during this time any following function call are pending
+ * and will be invoked only after the end of the cool down phase (except
+ * if canceled).
+ */
+ this._isCoolingDown = false;
+ /**
+ * Deferred of a currently pending invocation to inner function. Defined
+ * only during a cooling down phase and just after when throttle
+ * function has been called during this cooling down phase. It is kept
+ * until cooling down phase ends (either from timeout or flushed
+ * throttle) or until throttle is canceled (i.e. removes pending invoke
+ * while keeping cooling down phase live on).
+ */
+ this._pendingInvokeDeferred = undefined;
+ }
+
+ //--------------------------------------------------------------------------
+ // Public
+ //--------------------------------------------------------------------------
+
+ /**
+ * Cancel any buffered function call while keeping the cooldown phase
+ * running.
+ */
+ cancel() {
+ if (!this._isCoolingDown) {
+ return;
+ }
+ if (!this._pendingInvokeDeferred) {
+ return;
+ }
+ this._pendingInvokeDeferred.reject(new ThrottleCanceledError(this.id));
+ }
+
+ /**
+ * Clear any buffered function call and immediately terminates any cooling
+ * down phase in progress.
+ */
+ clear() {
+ this.cancel();
+ this.flush();
+ }
+
+ /**
+ * Called when there is a call to the function. This function is throttled,
+ * so the time it is called depends on whether the "cooldown stage" occurs
+ * or not:
+ *
+ * - no cooldown stage: function is called immediately, and it starts
+ * the cooldown stage when successful.
+ * - in cooldown stage: function is called when the cooldown stage has
+ * ended from timeout.
+ *
+ * Note that after the cooldown stage, only the last attempted function
+ * call will be considered.
+ *
+ * @param {...any} args
+ * @throws {ThrottleReinvokedError|ThrottleCanceledError}
+ * @returns {any} result of called function, if it's called.
+ */
+ async do(...args) {
+ if (!this._isCoolingDown) {
+ return this._invokeFunction(...args);
+ }
+ if (this._pendingInvokeDeferred) {
+ this._pendingInvokeDeferred.reject(new ThrottleReinvokedError(this.id));
+ }
+ try {
+ this._pendingInvokeDeferred = makeDeferred();
+ await Promise.race([this._coolingDownDeferred, this._pendingInvokeDeferred]);
+ } catch (error) {
+ if (
+ !(error instanceof _ThrottleFlushedError) ||
+ error.throttleId !== this.id
+ ) {
+ throw error;
+ }
+ } finally {
+ this._pendingInvokeDeferred = undefined;
+ }
+ return this._invokeFunction(...args);
+ }
+
+ /**
+ * Flush the internal throttle timer, so that the following function call
+ * is immediate. For instance, if there is a cooldown stage, it is aborted.
+ */
+ flush() {
+ if (!this._isCoolingDown) {
+ return;
+ }
+ const coolingDownDeferred = this._coolingDownDeferred;
+ this._coolingDownDeferred = undefined;
+ this._isCoolingDown = false;
+ coolingDownDeferred.reject(new _ThrottleFlushedError(this.id));
+ }
+
+ //--------------------------------------------------------------------------
+ // Private
+ //--------------------------------------------------------------------------
+
+ /**
+ * Invoke the inner function of this throttle and starts cooling down phase
+ * immediately after.
+ *
+ * @private
+ * @param {...any} args
+ */
+ _invokeFunction(...args) {
+ const res = this._function(...args);
+ this._startCoolingDown();
+ return res;
+ }
+
+ /**
+ * Called just when the inner function is being called. Starts the cooling
+ * down phase, which turn any call to this throttle function as pending
+ * inner function calls. This will be called after the end of cooling down
+ * phase (except if canceled).
+ */
+ async _startCoolingDown() {
+ if (this._coolingDownDeferred) {
+ throw new Error("Cannot start cooling down if there's already a cooling down in progress.");
+ }
+ // Keep local reference of cooling down deferred, because the one stored
+ // on `this` could be overwritten by another call to this throttle.
+ const coolingDownDeferred = makeDeferred();
+ this._coolingDownDeferred = coolingDownDeferred;
+ this._isCoolingDown = true;
+ const cooldownTimeoutId = this.env.browser.setTimeout(
+ () => coolingDownDeferred.resolve(),
+ this._duration
+ );
+ let unexpectedError;
+ try {
+ await coolingDownDeferred;
+ } catch (error) {
+ if (
+ !(error instanceof _ThrottleFlushedError) ||
+ error.throttleId !== this.id
+ ) {
+ // This branching should never happen.
+ // Still defined in case of programming error.
+ unexpectedError = error;
+ }
+ } finally {
+ this.env.browser.clearTimeout(cooldownTimeoutId);
+ this._coolingDownDeferred = undefined;
+ this._isCoolingDown = false;
+ }
+ if (unexpectedError) {
+ throw unexpectedError;
+ }
+ }
+
+}
+
+//------------------------------------------------------------------------------
+// Public
+//------------------------------------------------------------------------------
+
+/**
+ * A function that creates a cancelable, flushable and clearable throttle
+ * version of a provided function. See definitions at the top of this file.
+ *
+ * This throttle mechanism allows calling a function at most once during a
+ * certain period:
+ *
+ * - When a function call is made, it enters a 'cooldown' phase, in which any
+ * attempt to call the function is buffered until the cooldown phase ends.
+ * - At most 1 function call can be buffered during the cooldown phase, and the
+ * latest one in this phase will be considered at its end.
+ * - When a cooldown phase ends, any buffered function call will be performed
+ * and another cooldown phase will follow up.
+ *
+ * @param {Object} env the OWL env
+ * @param {function} func the function to throttle.
+ * @param {integer} duration duration, in milliseconds, of the cooling down
+ * phase of the throttling.
+ * @param {Object} [param2={}]
+ * @param {boolean} [param2.silentCancelationErrors=true] if unset, caller
+ * of throttle function will observe some errors that come from current
+ * throttle call that has been canceled, such as when throttle function has
+ * been explicitly canceled with `.cancel()` or when another new throttle call
+ * has been registered.
+ * @see ThrottleCanceledError for when a call has been canceled from explicit
+ * call.
+ * @see ThrottleReinvokedError for when a call has been canceled from another
+ * new throttle call has been registered.
+ * @returns {function} the cancelable, flushable and clearable throttle version
+ * of the provided function.
+ */
+function throttle(
+ env,
+ func,
+ duration,
+ { silentCancelationErrors = true } = {}
+) {
+ const throttleObj = new Throttle(env, func, duration);
+ const callable = async (...args) => {
+ try {
+ // await is important, otherwise errors are not intercepted.
+ return await throttleObj.do(...args);
+ } catch (error) {
+ const isSelfReinvokedError = (
+ error instanceof ThrottleReinvokedError &&
+ error.throttleId === throttleObj.id
+ );
+ const isSelfCanceledError = (
+ error instanceof ThrottleCanceledError &&
+ error.throttleId === throttleObj.id
+ );
+
+ if (silentCancelationErrors && (isSelfReinvokedError || isSelfCanceledError)) {
+ // Silently ignore cancelation errors.
+ // Promise is indefinitely pending for async functions.
+ return new Promise(() => {});
+ } else {
+ throw error;
+ }
+ }
+ };
+ Object.assign(callable, {
+ cancel: () => throttleObj.cancel(),
+ clear: () => throttleObj.clear(),
+ flush: () => throttleObj.flush(),
+ });
+ return callable;
+}
+
+/**
+ * Make external throttle errors accessible from throttle function.
+ */
+Object.assign(throttle, {
+ ThrottleReinvokedError,
+ ThrottleCanceledError,
+});
+
+
+return throttle;
+
+});
diff --git a/addons/mail/static/src/utils/throttle/throttle_tests.js b/addons/mail/static/src/utils/throttle/throttle_tests.js
new file mode 100644
index 00000000000..ecd3710d55c
--- /dev/null
+++ b/addons/mail/static/src/utils/throttle/throttle_tests.js
@@ -0,0 +1,410 @@
+odoo.define('mail/static/src/utils/throttle/throttle_tests.js', function (require) {
+'use strict';
+
+const {
+ afterEach: utilsAfterEach,
+ beforeEach: utilsBeforeEach,
+ start,
+} = require('mail/static/src/utils/test_utils.js');
+const throttle = require('mail/static/src/utils/throttle/throttle.js');
+const { nextTick } = require('mail/static/src/utils/utils.js');
+
+const { ThrottleReinvokedError, ThrottleCanceledError } = throttle;
+
+QUnit.module('mail', {}, function () {
+QUnit.module('utils', {}, function () {
+QUnit.module('throttle', {}, function () {
+QUnit.module('throttle_tests.js', {
+ beforeEach() {
+ utilsBeforeEach(this);
+ this.throttles = [];
+
+ this.start = async (...args) => {
+ const { env, widget } = await start(...args);
+ this.env = env;
+ this.widget = widget;
+ };
+ },
+ afterEach() {
+ utilsAfterEach(this);
+ // Important: tests should cleanly intercept cancelation errors that
+ // may result from this teardown.
+ this.widget.destroy();
+ for (const t of this.throttles) {
+ t.clear();
+ }
+ },
+});
+
+QUnit.test('single call', async function (assert) {
+ assert.expect(6);
+
+ await this.start({
+ hasTimeControl: true,
+ });
+
+ let hasInvokedFunc = false;
+ const throttledFunc = throttle(
+ this.env,
+ () => {
+ hasInvokedFunc = true;
+ return 'func_result';
+ },
+ 0
+ );
+ this.throttles.push(throttledFunc);
+
+ assert.notOk(
+ hasInvokedFunc,
+ "func should not have been invoked on immediate throttle initialization"
+ );
+
+ await this.env.testUtils.advanceTime(0);
+ assert.notOk(
+ hasInvokedFunc,
+ "func should not have been invoked from throttle initialization after 0ms"
+ );
+
+ throttledFunc().then(res => {
+ assert.step('throttle_observed_invoke');
+ assert.strictEqual(
+ res,
+ 'func_result',
+ "throttle call return should forward result of inner func"
+ );
+ });
+ await nextTick();
+ assert.ok(
+ hasInvokedFunc,
+ "func should have been immediately invoked on first throttle call"
+ );
+ assert.verifySteps(
+ ['throttle_observed_invoke'],
+ "throttle should have observed invoked on first throttle call"
+ );
+});
+
+QUnit.test('2nd (throttled) call', async function (assert) {
+ assert.expect(8);
+
+ await this.start({
+ hasTimeControl: true,
+ });
+
+ let funcCalledAmount = 0;
+ const throttledFunc = throttle(
+ this.env,
+ () => {
+ funcCalledAmount++;
+ return `func_result_${funcCalledAmount}`;
+ },
+ 1000
+ );
+ this.throttles.push(throttledFunc);
+
+ throttledFunc().then(result => {
+ assert.step('throttle_observed_invoke_1');
+ assert.strictEqual(
+ result,
+ 'func_result_1',
+ "throttle call return should forward result of inner func 1"
+ );
+ });
+ await nextTick();
+ assert.verifySteps(
+ ['throttle_observed_invoke_1'],
+ "inner function of throttle should have been invoked on 1st call (immediate return)"
+ );
+
+ throttledFunc().then(res => {
+ assert.step('throttle_observed_invoke_2');
+ assert.strictEqual(
+ res,
+ 'func_result_2',
+ "throttle call return should forward result of inner func 2"
+ );
+ });
+ await nextTick();
+ assert.verifySteps(
+ [],
+ "inner function of throttle should not have been immediately invoked after 2nd call immediately after 1st call (throttled with 1s internal clock)"
+ );
+
+ await this.env.testUtils.advanceTime(999);
+ assert.verifySteps(
+ [],
+ "inner function of throttle should not have been invoked after 999ms of 2nd call (throttled with 1s internal clock)"
+ );
+
+ await this.env.testUtils.advanceTime(1);
+ assert.verifySteps(
+ ['throttle_observed_invoke_2'],
+ "inner function of throttle should not have been invoked after 1s of 2nd call (throttled with 1s internal clock)"
+ );
+});
+
+QUnit.test('throttled call reinvocation', async function (assert) {
+ assert.expect(11);
+
+ await this.start({
+ hasTimeControl: true,
+ });
+
+ let funcCalledAmount = 0;
+ const throttledFunc = throttle(
+ this.env,
+ () => {
+ funcCalledAmount++;
+ return `func_result_${funcCalledAmount}`;
+ },
+ 1000,
+ { silentCancelationErrors: false }
+ );
+ this.throttles.push(throttledFunc);
+
+ throttledFunc().then(result => {
+ assert.step('throttle_observed_invoke_1');
+ assert.strictEqual(
+ result,
+ 'func_result_1',
+ "throttle call return should forward result of inner func 1"
+ );
+ });
+ await nextTick();
+ assert.verifySteps(
+ ['throttle_observed_invoke_1'],
+ "inner function of throttle should have been invoked on 1st call (immediate return)"
+ );
+
+ throttledFunc()
+ .then(() => {
+ throw new Error("2nd throttle call should not be resolved (should have been canceled by reinvocation)");
+ })
+ .catch(error => {
+ assert.ok(
+ error instanceof ThrottleReinvokedError,
+ "Should generate a Throttle reinvoked error (from another throttle function call)"
+ );
+ assert.step('throttle_reinvoked_1');
+ });
+ await nextTick();
+ assert.verifySteps(
+ [],
+ "inner function of throttle should not have been immediately invoked after 2nd call immediately after 1st call (throttled with 1s internal clock)"
+ );
+
+ await this.env.testUtils.advanceTime(999);
+ assert.verifySteps(
+ [],
+ "inner function of throttle should not have been invoked after 999ms of 2nd call (throttled with 1s internal clock)"
+ );
+
+ throttledFunc()
+ .then(result => {
+ assert.step('throttle_observed_invoke_2');
+ assert.strictEqual(
+ result,
+ 'func_result_2',
+ "throttle call return should forward result of inner func 2"
+ );
+ });
+ await nextTick();
+ assert.verifySteps(
+ ['throttle_reinvoked_1'],
+ "2nd throttle call should have been canceled from 3rd throttle call (reinvoked before cooling down phase has ended)"
+ );
+
+ await this.env.testUtils.advanceTime(1);
+ assert.verifySteps(
+ ['throttle_observed_invoke_2'],
+ "inner function of throttle should have been invoked after 1s of 1st call (throttled with 1s internal clock, 3rd throttle call re-use timer of 2nd throttle call)"
+ );
+});
+
+QUnit.test('flush throttled call', async function (assert) {
+ assert.expect(9);
+
+ await this.start({
+ hasTimeControl: true,
+ });
+
+ const throttledFunc = throttle(
+ this.env,
+ () => {},
+ 1000,
+ );
+ this.throttles.push(throttledFunc);
+
+ throttledFunc().then(() => assert.step('throttle_observed_invoke_1'));
+ await nextTick();
+ assert.verifySteps(
+ ['throttle_observed_invoke_1'],
+ "inner function of throttle should have been invoked on 1st call (immediate return)"
+ );
+
+ throttledFunc().then(() => assert.step('throttle_observed_invoke_2'));
+ await nextTick();
+ assert.verifySteps(
+ [],
+ "inner function of throttle should not have been immediately invoked after 2nd call immediately after 1st call (throttled with 1s internal clock)"
+ );
+
+ await this.env.testUtils.advanceTime(10);
+ assert.verifySteps(
+ [],
+ "inner function of throttle should not have been invoked after 10ms of 2nd call (throttled with 1s internal clock)"
+ );
+
+ throttledFunc.flush();
+ await nextTick();
+ assert.verifySteps(
+ ['throttle_observed_invoke_2'],
+ "inner function of throttle should have been invoked from 2nd call after flush"
+ );
+
+ throttledFunc().then(() => assert.step('throttle_observed_invoke_3'));
+ await nextTick();
+ await this.env.testUtils.advanceTime(999);
+ assert.verifySteps(
+ [],
+ "inner function of throttle should not have been invoked after 999ms of 3rd call (throttled with 1s internal clock)"
+ );
+
+ await this.env.testUtils.advanceTime(1);
+ assert.verifySteps(
+ ['throttle_observed_invoke_3'],
+ "inner function of throttle should not have been invoked after 999ms of 3rd call (throttled with 1s internal clock)"
+ );
+});
+
+QUnit.test('cancel throttled call', async function (assert) {
+ assert.expect(10);
+
+ await this.start({
+ hasTimeControl: true,
+ });
+
+ const throttledFunc = throttle(
+ this.env,
+ () => {},
+ 1000,
+ { silentCancelationErrors: false }
+ );
+ this.throttles.push(throttledFunc);
+
+ throttledFunc().then(() => assert.step('throttle_observed_invoke_1'));
+ await nextTick();
+ assert.verifySteps(
+ ['throttle_observed_invoke_1'],
+ "inner function of throttle should have been invoked on 1st call (immediate return)"
+ );
+
+ throttledFunc()
+ .then(() => {
+ throw new Error("2nd throttle call should not be resolved (should have been canceled)");
+ })
+ .catch(error => {
+ assert.ok(
+ error instanceof ThrottleCanceledError,
+ "Should generate a Throttle canceled error (from `.cancel()`)"
+ );
+ assert.step('throttle_canceled');
+ });
+ await nextTick();
+ assert.verifySteps(
+ [],
+ "inner function of throttle should not have been immediately invoked after 2nd call immediately after 1st call (throttled with 1s internal clock)"
+ );
+
+ await this.env.testUtils.advanceTime(500);
+ assert.verifySteps(
+ [],
+ "inner function of throttle should not have been invoked after 500ms of 2nd call (throttled with 1s internal clock)"
+ );
+
+ throttledFunc.cancel();
+ await nextTick();
+ assert.verifySteps(
+ ['throttle_canceled'],
+ "2nd throttle function call should have been canceled"
+ );
+
+ throttledFunc().then(() => assert.step('throttle_observed_invoke_3'));
+ await nextTick();
+ assert.verifySteps(
+ [],
+ "3rd throttle function call should not have invoked inner function yet (cancel reuses inner clock of throttle)"
+ );
+
+ await this.env.testUtils.advanceTime(500);
+ assert.verifySteps(
+ ['throttle_observed_invoke_3'],
+ "3rd throttle function call should have invoke inner function after 500ms (cancel reuses inner clock of throttle which was at 500ms in, throttle set at 1ms)"
+ );
+});
+
+QUnit.test('clear throttled call', async function (assert) {
+ assert.expect(9);
+
+ await this.start({
+ hasTimeControl: true,
+ });
+
+ const throttledFunc = throttle(
+ this.env,
+ () => {},
+ 1000,
+ { silentCancelationErrors: false }
+ );
+ this.throttles.push(throttledFunc);
+
+ throttledFunc().then(() => assert.step('throttle_observed_invoke_1'));
+ await nextTick();
+ assert.verifySteps(
+ ['throttle_observed_invoke_1'],
+ "inner function of throttle should have been invoked on 1st call (immediate return)"
+ );
+
+ throttledFunc()
+ .then(() => {
+ throw new Error("2nd throttle call should not be resolved (should have been canceled from clear)");
+ })
+ .catch(error => {
+ assert.ok(
+ error instanceof ThrottleCanceledError,
+ "Should generate a Throttle canceled error (from `.clear()`)"
+ );
+ assert.step('throttle_canceled');
+ });
+ await nextTick();
+ assert.verifySteps(
+ [],
+ "inner function of throttle should not have been immediately invoked after 2nd call immediately after 1st call (throttled with 1s internal clock)"
+ );
+
+ await this.env.testUtils.advanceTime(500);
+ assert.verifySteps(
+ [],
+ "inner function of throttle should not have been invoked after 500ms of 2nd call (throttled with 1s internal clock)"
+ );
+
+ throttledFunc.clear();
+ await nextTick();
+ assert.verifySteps(
+ ['throttle_canceled'],
+ "2nd throttle function call should have been canceled (from `.clear()`)"
+ );
+
+ throttledFunc().then(() => assert.step('throttle_observed_invoke_3'));
+ await nextTick();
+ assert.verifySteps(
+ ['throttle_observed_invoke_3'],
+ "3rd throttle function call should have invoke inner function immediately (`.clear()` flushes throttle)"
+ );
+});
+
+});
+});
+});
+
+});
diff --git a/addons/mail/static/src/utils/timer/timer.js b/addons/mail/static/src/utils/timer/timer.js
new file mode 100644
index 00000000000..56d7f58e39e
--- /dev/null
+++ b/addons/mail/static/src/utils/timer/timer.js
@@ -0,0 +1,165 @@
+odoo.define('mail/static/src/utils/timer/timer.js', function (require) {
+'use strict';
+
+const { makeDeferred } = require('mail/static/src/utils/deferred/deferred.js');
+
+//------------------------------------------------------------------------------
+// Errors
+//------------------------------------------------------------------------------
+
+/**
+ * List of Timer errors.
+ */
+
+ /**
+ * Error when timer has been cleared with `.clear()` or `.reset()`. Used to
+ * let know caller of timer that the countdown has been aborted, which
+ * means the inner function will not be called. Usually caller should just
+ * accept it and kindly treated this error as a polite warning.
+ */
+ class TimerClearedError extends Error {
+ /**
+ * @override
+ */
+ constructor(timerId, ...args) {
+ super(...args);
+ this.name = 'TimerClearedError';
+ this.timerId = timerId;
+ }
+}
+
+//------------------------------------------------------------------------------
+// Private
+//------------------------------------------------------------------------------
+
+/**
+ * This class creates a timer which, when times out, calls a function.
+ * Note that the timer is not started on initialization (@see start method).
+ */
+class Timer {
+
+ /**
+ * @param {Object} env the OWL env
+ * @param {function} onTimeout
+ * @param {integer} duration
+ * @param {Object} [param3={}]
+ * @param {boolean} [param3.silentCancelationErrors=true] if unset, caller
+ * of timer will observe some errors that come from current timer calls
+ * that has been cleared with `.clear()` or `.reset()`.
+ * @see TimerClearedError for when timer has been aborted from `.clear()`
+ * or `.reset()`.
+ */
+ constructor(env, onTimeout, duration, { silentCancelationErrors = true } = {}) {
+ this.env = env;
+ /**
+ * Determine whether the timer has a pending timeout.
+ */
+ this.isRunning = false;
+ /**
+ * Duration, in milliseconds, until timer times out and calls the
+ * timeout function.
+ */
+ this._duration = duration;
+ /**
+ * Determine whether the caller of timer `.start()` and `.reset()`
+ * should observe cancelation errors from `.clear()` or `.reset()`.
+ */
+ this._hasSilentCancelationErrors = silentCancelationErrors;
+ /**
+ * The function that is called when the timer times out.
+ */
+ this._onTimeout = onTimeout;
+ /**
+ * Deferred of a currently pending invocation to inner function on
+ * timeout.
+ */
+ this._timeoutDeferred = undefined;
+ /**
+ * Internal reference of `setTimeout()` that is used to invoke function
+ * when timer times out. Useful to clear it when timer is cleared/reset.
+ */
+ this._timeoutId = undefined;
+ }
+
+ //--------------------------------------------------------------------------
+ // Public
+ //--------------------------------------------------------------------------
+
+ /**
+ * Clear the timer, which basically sets the state of timer as if it was
+ * just instantiated, without being started. This function makes sense only
+ * when this timer is running.
+ */
+ clear() {
+ this.env.browser.clearTimeout(this._timeoutId);
+ this.isRunning = false;
+ if (!this._timeoutDeferred) {
+ return;
+ }
+ this._timeoutDeferred.reject(new TimerClearedError(this.id));
+ }
+
+ /**
+ * Reset the timer, i.e. the pending timeout is refreshed with initial
+ * duration. This function makes sense only when this timer is running.
+ */
+ async reset() {
+ this.clear();
+ await this.start();
+ }
+
+ /**
+ * Starts the timer, i.e. after a certain duration, it times out and calls
+ * a function back. This function makes sense only when this timer is not
+ * yet running.
+ *
+ * @throws {Error} in case the timer is already running.
+ */
+ async start() {
+ if (this.isRunning) {
+ throw new Error("Cannot start a timer that is currently running.");
+ }
+ this.isRunning = true;
+ const timeoutDeferred = makeDeferred();
+ this._timeoutDeferred = timeoutDeferred;
+ const timeoutId = this.env.browser.setTimeout(
+ () => {
+ this.isRunning = false;
+ timeoutDeferred.resolve(this._onTimeout());
+ },
+ this._duration
+ );
+ this._timeoutId = timeoutId;
+ let result;
+ try {
+ result = await timeoutDeferred;
+ } catch (error) {
+ if (
+ !this._hasSilentCancelationErrors ||
+ !(error instanceof TimerClearedError) ||
+ error.timerId !== this.id
+ ) {
+ // This branching should never happens.
+ // Still defined in case of programming error.
+ throw error;
+ }
+ } finally {
+ this.env.browser.clearTimeout(timeoutId);
+ this._timeoutDeferred = undefined;
+ this.isRunning = false;
+ }
+ return result;
+ }
+
+}
+
+/**
+ * Make external timer errors accessible from timer class.
+ */
+Object.assign(Timer, {
+ TimerClearedError,
+});
+
+return Timer;
+
+});
diff --git a/addons/mail/static/src/utils/timer/timer_tests.js b/addons/mail/static/src/utils/timer/timer_tests.js
new file mode 100644
index 00000000000..9b464ac9396
--- /dev/null
+++ b/addons/mail/static/src/utils/timer/timer_tests.js
@@ -0,0 +1,431 @@
+odoo.define('mail/static/src/utils/timer/timer_tests.js', function (require) {
+'use strict';
+
+const {
+ afterEach: utilsAfterEach,
+ beforeEach: utilsBeforeEach,
+ start,
+} = require('mail/static/src/utils/test_utils.js');
+const Timer = require('mail/static/src/utils/timer/timer.js');
+const { nextTick } = require('mail/static/src/utils/test_utils.js');
+
+const { TimerClearedError } = Timer;
+
+QUnit.module('mail', {}, function () {
+QUnit.module('utils', {}, function () {
+QUnit.module('timer', {}, function () {
+QUnit.module('timer_tests.js', {
+ beforeEach() {
+ utilsBeforeEach(this);
+ this.timers = [];
+
+ this.start = async (...args) => {
+ const { env, widget } = await start(...args);
+ this.env = env;
+ this.widget = widget;
+ };
+ },
+ afterEach() {
+ utilsAfterEach(this);
+ // Important: tests should cleanly intercept cancelation errors that
+ // may result from this teardown.
+ this.widget.destroy();
+ for (const timer of this.timers) {
+ timer.clear();
+ }
+ },
+});
+
+QUnit.test('timer does not timeout on initialization', async function (assert) {
+ assert.expect(3);
+
+ await this.start({
+ hasTimeControl: true,
+ });
+
+ let hasTimedOut = false;
+ this.timers.push(
+ new Timer(
+ this.env,
+ () => hasTimedOut = true,
+ 0
+ )
+ );
+
+ assert.notOk(
+ hasTimedOut,
+ "timer should not have timed out on immediate initialization"
+ );
+
+ await this.env.testUtils.advanceTime(0);
+ assert.notOk(
+ hasTimedOut,
+ "timer should not have timed out from initialization after 0ms"
+ );
+
+ await this.env.testUtils.advanceTime(1000 * 1000);
+ assert.notOk(
+ hasTimedOut,
+ "timer should not have timed out from initialization after 1000s"
+ );
+});
+
+QUnit.test('timer start (duration: 0ms)', async function (assert) {
+ assert.expect(2);
+
+ await this.start({
+ hasTimeControl: true,
+ });
+
+ let hasTimedOut = false;
+ this.timers.push(
+ new Timer(
+ this.env,
+ () => hasTimedOut = true,
+ 0
+ )
+ );
+
+ this.timers[0].start();
+ assert.notOk(
+ hasTimedOut,
+ "timer should not have timed out immediately after start"
+ );
+
+ await this.env.testUtils.advanceTime(0);
+ assert.ok(
+ hasTimedOut,
+ "timer should have timed out on start after 0ms"
+ );
+});
+
+QUnit.test('timer start observe termination (duration: 0ms)', async function (assert) {
+ assert.expect(6);
+
+ await this.start({
+ hasTimeControl: true,
+ });
+
+ let hasTimedOut = false;
+ this.timers.push(
+ new Timer(
+ this.env,
+ () => {
+ hasTimedOut = true;
+ return 'timeout_result';
+ },
+ 0
+ )
+ );
+
+ this.timers[0].start()
+ .then(result => {
+ assert.strictEqual(
+ result,
+ 'timeout_result',
+ "value returned by start should be value returned by function on timeout"
+ );
+ assert.step('timeout');
+ });
+ await nextTick();
+ assert.notOk(
+ hasTimedOut,
+ "timer should not have timed out immediately after start"
+ );
+ assert.verifySteps(
+ [],
+ "timer.start() should not have yet observed timeout"
+ );
+
+ await this.env.testUtils.advanceTime(0);
+ assert.ok(
+ hasTimedOut,
+ "timer should have timed out on start after 0ms"
+ );
+ assert.verifySteps(
+ ['timeout'],
+ "timer.start() should have observed timeout after 0ms"
+ );
+});
+
+QUnit.test('timer start (duration: 1000s)', async function (assert) {
+ assert.expect(5);
+
+ await this.start({
+ hasTimeControl: true,
+ });
+
+ let hasTimedOut = false;
+ this.timers.push(
+ new Timer(
+ this.env,
+ () => hasTimedOut = true,
+ 1000 * 1000
+ )
+ );
+
+ this.timers[0].start();
+ assert.notOk(
+ hasTimedOut,
+ "timer should not have timed out immediately after start"
+ );
+
+ await this.env.testUtils.advanceTime(0);
+ assert.notOk(
+ hasTimedOut,
+ "timer should not have timed out on start after 0ms"
+ );
+
+ await this.env.testUtils.advanceTime(1000);
+ assert.notOk(
+ hasTimedOut,
+ "timer should not have timed out on start after 1000ms"
+ );
+
+ await this.env.testUtils.advanceTime(998 * 1000 + 999);
+ assert.notOk(
+ hasTimedOut,
+ "timer should not have timed out on start after 9999ms"
+ );
+
+ await this.env.testUtils.advanceTime(1);
+ assert.ok(
+ hasTimedOut,
+ "timer should have timed out on start after 10s"
+ );
+});
+
+QUnit.test('[no cancelation intercept] timer start then immediate clear (duration: 0ms)', async function (assert) {
+ assert.expect(4);
+
+ await this.start({
+ hasTimeControl: true,
+ });
+
+ let hasTimedOut = false;
+ this.timers.push(
+ new Timer(
+ this.env,
+ () => hasTimedOut = true,
+ 0
+ )
+ );
+
+ this.timers[0].start();
+ assert.notOk(
+ hasTimedOut,
+ "timer should not have timed out immediately after start"
+ );
+
+ this.timers[0].clear();
+ assert.notOk(
+ hasTimedOut,
+ "timer should not have timed out immediately after start and clear"
+ );
+
+ await this.env.testUtils.advanceTime(0);
+ assert.notOk(
+ hasTimedOut,
+ "timer should not have timed out after 0ms of clear"
+ );
+
+ await this.env.testUtils.advanceTime(1000);
+ assert.notOk(
+ hasTimedOut,
+ "timer should not have timed out after 1s of clear"
+ );
+});
+
+QUnit.test('[no cancelation intercept] timer start then clear before timeout (duration: 1000ms)', async function (assert) {
+ assert.expect(4);
+
+ await this.start({
+ hasTimeControl: true,
+ });
+
+ let hasTimedOut = false;
+ this.timers.push(
+ new Timer(
+ this.env,
+ () => hasTimedOut = true,
+ 1000
+ )
+ );
+
+ this.timers[0].start();
+ assert.notOk(
+ hasTimedOut,
+ "timer should not have timed out immediately after start"
+ );
+
+ await this.env.testUtils.advanceTime(999);
+ assert.notOk(
+ hasTimedOut,
+ "timer should not have timed out immediately after 999ms of start"
+ );
+
+ this.timers[0].clear();
+ await this.env.testUtils.advanceTime(1);
+ assert.notOk(
+ hasTimedOut,
+ "timer should not have timed out after 1ms of clear that happens 999ms after start (globally 1s await)"
+ );
+
+ await this.env.testUtils.advanceTime(1000);
+ assert.notOk(
+ hasTimedOut,
+ "timer should not have timed out after 1001ms after clear (timer fully cleared)"
+ );
+});
+
+QUnit.test('[no cancelation intercept] timer start then reset before timeout (duration: 1000ms)', async function (assert) {
+ assert.expect(5);
+
+ await this.start({
+ hasTimeControl: true,
+ });
+
+ let hasTimedOut = false;
+ this.timers.push(
+ new Timer(
+ this.env,
+ () => hasTimedOut = true,
+ 1000
+ )
+ );
+
+ this.timers[0].start();
+ assert.notOk(
+ hasTimedOut,
+ "timer should not have timed out immediately after start"
+ );
+
+ await this.env.testUtils.advanceTime(999);
+ assert.notOk(
+ hasTimedOut,
+ "timer should not have timed out after 999ms of start"
+ );
+
+ this.timers[0].reset();
+ await this.env.testUtils.advanceTime(1);
+ assert.notOk(
+ hasTimedOut,
+ "timer should not have timed out after 1ms of reset which happens 999ms after start"
+ );
+
+ await this.env.testUtils.advanceTime(998);
+ assert.notOk(
+ hasTimedOut,
+ "timer should not have timed out after 999ms of reset"
+ );
+
+ await this.env.testUtils.advanceTime(1);
+ assert.ok(
+ hasTimedOut,
+ "timer should not have timed out after 1s of reset"
+ );
+});
+
+QUnit.test('[with cancelation intercept] timer start then immediate clear (duration: 0ms)', async function (assert) {
+ assert.expect(5);
+
+ await this.start({
+ hasTimeControl: true,
+ });
+
+ let hasTimedOut = false;
+ this.timers.push(
+ new Timer(
+ this.env,
+ () => hasTimedOut = true,
+ 0,
+ { silentCancelationErrors: false }
+ )
+ );
+
+ this.timers[0].start()
+ .then(() => {
+ throw new Error("timer.start() should not be resolved (should have been canceled by clear)");
+ })
+ .catch(error => {
+ assert.ok(
+ error instanceof TimerClearedError,
+ "Should generate a Timer cleared error (from `.clear()`)"
+ );
+ assert.step('timer_cleared');
+ });
+ assert.notOk(
+ hasTimedOut,
+ "timer should not have timed out immediately after start"
+ );
+ await nextTick();
+ assert.verifySteps([], "should not have observed cleared timer (timer not yet cleared)");
+
+ this.timers[0].clear();
+ await nextTick();
+ assert.verifySteps(
+ ['timer_cleared'],
+ "timer.start() should have observed it has been cleared"
+ );
+});
+
+QUnit.test('[with cancelation intercept] timer start then immediate reset (duration: 0ms)', async function (assert) {
+ assert.expect(9);
+
+ await this.start({
+ hasTimeControl: true,
+ });
+
+ let hasTimedOut = false;
+ this.timers.push(
+ new Timer(
+ this.env,
+ () => hasTimedOut = true,
+ 0,
+ { silentCancelationErrors: false }
+ )
+ );
+
+ this.timers[0].start()
+ .then(() => {
+ throw new Error("timer.start() should not observe a timeout");
+ })
+ .catch(error => {
+ assert.ok(error instanceof TimerClearedError, "Should generate a Timer cleared error (from `.reset()`)");
+ assert.step('timer_cleared');
+ });
+ assert.notOk(
+ hasTimedOut,
+ "timer should not have timed out immediately after start"
+ );
+ await nextTick();
+ assert.verifySteps([], "should not have observed cleared timer (timer not yet cleared)");
+
+ this.timers[0].reset()
+ .then(() => assert.step('timer_reset_timeout'));
+ await nextTick();
+ assert.verifySteps(
+ ['timer_cleared'],
+ "timer.start() should have observed it has been cleared"
+ );
+ assert.notOk(
+ hasTimedOut,
+ "timer should not have timed out immediately after reset"
+ );
+
+ await this.env.testUtils.advanceTime(0);
+ assert.ok(
+ hasTimedOut,
+ "timer should have timed out after reset timeout"
+ );
+ assert.verifySteps(
+ ['timer_reset_timeout'],
+ "timer.reset() should have observed it has timed out"
+ );
+});
+
+});
+});
+});
+
+});
diff --git a/addons/mail/static/src/utils/utils.js b/addons/mail/static/src/utils/utils.js
new file mode 100644
index 00000000000..da0855437f7
--- /dev/null
+++ b/addons/mail/static/src/utils/utils.js
@@ -0,0 +1,127 @@
+odoo.define('mail/static/src/utils/utils.js', function (require) {
+'use strict';
+
+const { delay } = require('web.concurrency');
+const {
+ patch: webUtilsPatch,
+ unpatch: webUtilsUnpatch,
+} = require('web.utils');
+
+//------------------------------------------------------------------------------
+// Public
+//------------------------------------------------------------------------------
+
+const classPatchMap = new WeakMap();
+
+/**
+ * Wait a task tick, so that anything in micro-task queue that can be processed
+ * is processed.
+ */
+async function nextTick() {
+ await delay(0);
+}
+
+/**
+ * Inspired by web.utils:patch utility function
+ *
+ * @param {Class} Class
+ * @param {string} patchName
+ * @param {Object} patch
+ * @returns {function} unpatch function
+ */
+function patchClassMethods(Class, patchName, patch) {
+ let metadata = classPatchMap.get(Class);
+ if (!metadata) {
+ metadata = {
+ origMethods: {},
+ patches: {},
+ current: []
+ };
+ classPatchMap.set(Class, metadata);
+ }
+ if (metadata.patches[patchName]) {
+ throw new Error(`Patch [${patchName}] already exists`);
+ }
+ metadata.patches[patchName] = patch;
+ applyPatch(Class, patch);
+ metadata.current.push(patchName);
+
+ function applyPatch(Class, patch) {
+ Object.keys(patch).forEach(function (methodName) {
+ const method = patch[methodName];
+ if (typeof method === "function") {
+ const original = Class[methodName];
+ if (!(methodName in metadata.origMethods)) {
+ metadata.origMethods[methodName] = original;
+ }
+ Class[methodName] = function (...args) {
+ const previousSuper = this._super;
+ this._super = original;
+ const res = method.call(this, ...args);
+ this._super = previousSuper;
+ return res;
+ };
+ }
+ });
+ }
+
+ return () => unpatchClassMethods.bind(Class, patchName);
+}
+
+/**
+ * @param {Class} Class
+ * @param {string} patchName
+ * @param {Object} patch
+ * @returns {function} unpatch function
+ */
+function patchInstanceMethods(Class, patchName, patch) {
+ return webUtilsPatch(Class, patchName, patch);
+}
+
+/**
+ * Inspired by web.utils:unpatch utility function
+ *
+ * @param {Class} Class
+ * @param {string} patchName
+ */
+function unpatchClassMethods(Class, patchName) {
+ let metadata = classPatchMap.get(Class);
+ if (!metadata) {
+ return;
+ }
+ classPatchMap.delete(Class);
+
+ // reset to original
+ for (let k in metadata.origMethods) {
+ Class[k] = metadata.origMethods[k];
+ }
+
+ // apply other patches
+ for (let name of metadata.current) {
+ if (name !== patchName) {
+ patchClassMethods(Class, name, metadata.patches[name]);
+ }
+ }
+}
+
+/**
+ * @param {Class} Class
+ * @param {string} patchName
+ */
+function unpatchInstanceMethods(Class, patchName) {
+ return webUtilsUnpatch(Class, patchName);
+}
+
+//------------------------------------------------------------------------------
+// Export
+//------------------------------------------------------------------------------
+
+return {
+ nextTick,
+ patchClassMethods,
+ patchInstanceMethods,
+ unpatchClassMethods,
+ unpatchInstanceMethods,
+};
+
+});
diff --git a/addons/mail/static/src/variables.scss b/addons/mail/static/src/variables.scss
new file mode 100644
index 00000000000..8d8caab19d6
--- /dev/null
+++ b/addons/mail/static/src/variables.scss
@@ -0,0 +1,14 @@
+// -----------------------------------------------------------------------------
+// Variables
+// -----------------------------------------------------------------------------
+
+$o-mail-thread-window-zindex: $zindex-modal + 1 !default;
+
+$o-mail-chat-window-header-height: 36px !default;
+$o-mail-chat-window-header-height-mobile: 46px !default;
+$o-mail-discuss-sidebar-active-indicator-margin-right: 10px !default;
+$o-mail-discuss-sidebar-active-indicator-width: 3px !default;
+$o-mail-discuss-sidebar-scrollbar-width: 15px !default;
+
+$o-mail-moderation-accept-color: theme-color('success') !default;
+$o-mail-moderation-reject-color: theme-color('danger') !default;
diff --git a/addons/mail/static/src/widgets/common.xml b/addons/mail/static/src/widgets/common.xml
new file mode 100644
index 00000000000..9106f96f4ea
--- /dev/null
+++ b/addons/mail/static/src/widgets/common.xml
@@ -0,0 +1,25 @@
+<?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 00000000000..6f55834895f
--- /dev/null
+++ b/addons/mail/static/src/widgets/discuss/discuss.js
@@ -0,0 +1,383 @@
+odoo.define('mail/static/src/widgets/discuss/discuss.js', function (require) {
+'use strict';
+
+const components = {
+ Discuss: require('mail/static/src/components/discuss/discuss.js'),
+};
+const InvitePartnerDialog = require('mail/static/src/widgets/discuss_invite_partner_dialog/discuss_invite_partner_dialog.js');
+
+const AbstractAction = require('web.AbstractAction');
+const { action_registry, qweb } = require('web.core');
+
+const { Component } = owl;
+
+const DiscussWidget = AbstractAction.extend({
+ template: 'mail.widgets.Discuss',
+ hasControlPanel: true,
+ loadControlPanel: true,
+ withSearchBar: true,
+ searchMenuTypes: ['filter', 'favorite'],
+ /**
+ * @override {web.AbstractAction}
+ * @param {web.ActionManager} parent
+ * @param {Object} action
+ * @param {Object} [action.context]
+ * @param {string} [action.context.active_id]
+ * @param {Object} [action.params]
+ * @param {string} [action.params.default_active_id]
+ * @param {Object} [options={}]
+ */
+ init(parent, action, options={}) {
+ this._super(...arguments);
+
+ // render buttons in control panel
+ this.$buttons = $(qweb.render('mail.widgets.Discuss.DiscussControlButtons'));
+ this.$buttons.find('button').css({ display: 'inline-block' });
+ this.$buttons.on('click', '.o_invite', ev => this._onClickInvite(ev));
+ this.$buttons.on('click', '.o_widget_Discuss_controlPanelButtonMarkAllRead',
+ ev => this._onClickMarkAllAsRead(ev)
+ );
+ this.$buttons.on('click', '.o_mobile_new_channel', ev => this._onClickMobileNewChannel(ev));
+ this.$buttons.on('click', '.o_mobile_new_message', ev => this._onClickMobileNewMessage(ev));
+ this.$buttons.on('click', '.o_unstar_all', ev => this._onClickUnstarAll(ev));
+ this.$buttons.on('click', '.o_widget_Discuss_controlPanelButtonSelectAll', ev => this._onClickSelectAll(ev));
+ this.$buttons.on('click', '.o_widget_Discuss_controlPanelButtonUnselectAll', ev => this._onClickUnselectAll(ev));
+ this.$buttons.on('click', '.o_widget_Discuss_controlPanelButtonModeration.o-accept', ev => this._onClickModerationAccept(ev));
+ this.$buttons.on('click', '.o_widget_Discuss_controlPanelButtonModeration.o-discard', ev => this._onClickModerationDiscard(ev));
+ this.$buttons.on('click', '.o_widget_Discuss_controlPanelButtonModeration.o-reject', ev => this._onClickModerationReject(ev));
+
+ // control panel attributes
+ this.action = action;
+ this.actionManager = parent;
+ this.controlPanelModelConfig.modelName = 'mail.message';
+ this.discuss = undefined;
+ this.options = options;
+
+ this.component = undefined;
+
+ this._lastPushStateActiveThread = null;
+ },
+ /**
+ * @override
+ */
+ async willStart() {
+ await this._super(...arguments);
+ this.env = Component.env;
+ const initActiveId = this.options.active_id ||
+ (this.action.context && this.action.context.active_id) ||
+ (this.action.params && this.action.params.default_active_id) ||
+ 'mail.box_inbox';
+ this.discuss = this.env.messaging.discuss;
+ this.discuss.update({ initActiveId });
+ },
+ /**
+ * @override {web.AbstractAction}
+ */
+ destroy() {
+ if (this.component) {
+ this.component.destroy();
+ this.component = undefined;
+ }
+ if (this.$buttons) {
+ this.$buttons.off().remove();
+ }
+ this._super(...arguments);
+ },
+ /**
+ * @override {web.AbstractAction}
+ */
+ on_attach_callback() {
+ this._super(...arguments);
+ if (this.component) {
+ // prevent twice call to on_attach_callback (FIXME)
+ return;
+ }
+ const DiscussComponent = components.Discuss;
+ this.component = new DiscussComponent();
+ this._pushStateActionManagerEventListener = ev => {
+ ev.stopPropagation();
+ if (this._lastPushStateActiveThread === this.discuss.thread) {
+ return;
+ }
+ this._pushStateActionManager();
+ this._lastPushStateActiveThread = this.discuss.thread;
+ };
+ this._showRainbowManEventListener = ev => {
+ ev.stopPropagation();
+ this._showRainbowMan();
+ };
+ this._updateControlPanelEventListener = ev => {
+ ev.stopPropagation();
+ this._updateControlPanel();
+ };
+
+ this.el.addEventListener(
+ 'o-push-state-action-manager',
+ this._pushStateActionManagerEventListener
+ );
+ this.el.addEventListener(
+ 'o-show-rainbow-man',
+ this._showRainbowManEventListener
+ );
+ this.el.addEventListener(
+ 'o-update-control-panel',
+ this._updateControlPanelEventListener
+ );
+ return this.component.mount(this.el);
+ },
+ /**
+ * @override {web.AbstractAction}
+ */
+ on_detach_callback() {
+ this._super(...arguments);
+ if (this.component) {
+ this.component.destroy();
+ }
+ this.component = undefined;
+ this.el.removeEventListener(
+ 'o-push-state-action-manager',
+ this._pushStateActionManagerEventListener
+ );
+ this.el.removeEventListener(
+ 'o-show-rainbow-man',
+ this._showRainbowManEventListener
+ );
+ this.el.removeEventListener(
+ 'o-update-control-panel',
+ this._updateControlPanelEventListener
+ );
+ },
+
+ //--------------------------------------------------------------------------
+ // Private
+ //--------------------------------------------------------------------------
+
+ /**
+ * @private
+ */
+ _pushStateActionManager() {
+ this.actionManager.do_push_state({
+ action: this.action.id,
+ active_id: this.discuss.activeId,
+ });
+ },
+ /**
+ * @private
+ */
+ _showRainbowMan() {
+ this.trigger_up('show_effect', {
+ message: this.env._t("Congratulations, your inbox is empty!"),
+ type: 'rainbow_man',
+ });
+ },
+ /**
+ * @private
+ */
+ _updateControlPanel() {
+ // Invite
+ if (
+ this.discuss.thread &&
+ this.discuss.thread.channel_type === 'channel'
+ ) {
+ this.$buttons.find('.o_invite').removeClass('o_hidden');
+ } else {
+ this.$buttons.find('.o_invite').addClass('o_hidden');
+ }
+ // Mark All Read
+ if (
+ this.discuss.thread &&
+ this.discuss.thread === this.env.messaging.inbox
+ ) {
+ this.$buttons
+ .find('.o_widget_Discuss_controlPanelButtonMarkAllRead')
+ .removeClass('o_hidden')
+ .prop('disabled', this.discuss.threadViewer.messages.length === 0);
+ } else {
+ this.$buttons
+ .find('.o_widget_Discuss_controlPanelButtonMarkAllRead')
+ .addClass('o_hidden');
+ }
+ // Unstar All
+ if (
+ this.discuss.thread &&
+ this.discuss.thread === this.env.messaging.starred
+ ) {
+ this.$buttons
+ .find('.o_unstar_all')
+ .removeClass('o_hidden')
+ .prop('disabled', this.discuss.threadViewer.messages.length === 0);
+ } else {
+ this.$buttons
+ .find('.o_unstar_all')
+ .addClass('o_hidden');
+ }
+ // Mobile: Add channel
+ if (
+ this.env.messaging.device.isMobile &&
+ this.discuss.activeMobileNavbarTabId === 'channel'
+ ) {
+ this.$buttons
+ .find('.o_mobile_new_channel')
+ .removeClass('o_hidden');
+ } else {
+ this.$buttons
+ .find('.o_mobile_new_channel')
+ .addClass('o_hidden');
+ }
+ // Mobile: Add message
+ if (
+ this.env.messaging.device.isMobile &&
+ this.discuss.activeMobileNavbarTabId === 'chat'
+ ) {
+ this.$buttons
+ .find('.o_mobile_new_message')
+ .removeClass('o_hidden');
+ } else {
+ this.$buttons
+ .find('.o_mobile_new_message')
+ .addClass('o_hidden');
+ }
+ // Select All & Unselect All
+ const $selectAll = this.$buttons.find('.o_widget_Discuss_controlPanelButtonSelectAll');
+ const $unselectAll = this.$buttons.find('.o_widget_Discuss_controlPanelButtonUnselectAll');
+
+ if (
+ this.discuss.threadViewer.checkedMessages.length > 0 ||
+ this.discuss.threadViewer.uncheckedMessages.length > 0
+ ) {
+ $selectAll.removeClass('o_hidden');
+ $selectAll.toggleClass('disabled', this.discuss.threadViewer.uncheckedMessages.length === 0);
+ $unselectAll.removeClass('o_hidden');
+ $unselectAll.toggleClass('disabled', this.discuss.threadViewer.checkedMessages.length === 0);
+ } else {
+ $selectAll.addClass('o_hidden');
+ $selectAll.addClass('disabled');
+ $unselectAll.addClass('o_hidden');
+ $unselectAll.addClass('disabled');
+ }
+
+ // Moderation Actions
+ const $moderationButtons = this.$buttons.find('.o_widget_Discuss_controlPanelButtonModeration');
+ if (
+ this.discuss.threadViewer.checkedMessages.length > 0 &&
+ this.discuss.threadViewer.checkedMessages.filter(
+ message => !message.isModeratedByCurrentPartner
+ ).length === 0
+ ) {
+ $moderationButtons.removeClass('o_hidden');
+ } else {
+ $moderationButtons.addClass('o_hidden');
+ }
+
+ let title;
+ if (this.env.messaging.device.isMobile || !this.discuss.thread) {
+ title = this.env._t("Discuss");
+ } else {
+ const prefix =
+ this.discuss.thread.channel_type === 'channel' &&
+ this.discuss.thread.public !== 'private'
+ ? '#'
+ : '';
+ title = `${prefix}${this.discuss.thread.displayName}`;
+ }
+
+ this.updateControlPanel({
+ cp_content: {
+ $buttons: this.$buttons,
+ },
+ title,
+ });
+ },
+
+ //--------------------------------------------------------------------------
+ // Handlers
+ //--------------------------------------------------------------------------
+
+ /**
+ * @private
+ */
+ _onClickInvite() {
+ new InvitePartnerDialog(this, {
+ activeThreadLocalId: this.discuss.thread.localId,
+ messagingEnv: this.env,
+ }).open();
+ },
+ /**
+ * @private
+ */
+ _onClickMarkAllAsRead() {
+ this.env.models['mail.message'].markAllAsRead({ domain: this.domain });
+ },
+ /**
+ * @private
+ */
+ _onClickMobileNewChannel() {
+ this.discuss.update({ isAddingChannel: true });
+ },
+ /**
+ * @private
+ */
+ _onClickMobileNewMessage() {
+ this.discuss.update({ isAddingChat: true });
+ },
+ /**
+ * @private
+ */
+ _onClickModerationAccept() {
+ this.env.models['mail.message'].moderate(
+ this.discuss.threadViewer.checkedMessages,
+ 'accept'
+ );
+ },
+ /**
+ * @private
+ */
+ _onClickModerationDiscard() {
+ this.discuss.update({ hasModerationDiscardDialog: true });
+ },
+ /**
+ * @private
+ */
+ _onClickModerationReject() {
+ this.discuss.update({ hasModerationRejectDialog: true });
+ },
+ /**
+ * @private
+ */
+ _onClickSelectAll() {
+ this.env.models['mail.message'].checkAll(
+ this.discuss.thread,
+ this.discuss.threadStringifiedDomain
+ );
+ },
+ /**
+ * @private
+ */
+ _onClickUnselectAll() {
+ this.env.models['mail.message'].uncheckAll(
+ this.discuss.thread,
+ this.discuss.threadStringifiedDomain
+ );
+ },
+ /**
+ * @private
+ */
+ _onClickUnstarAll() {
+ this.env.models['mail.message'].unstarAll();
+ },
+ /**
+ * @private
+ * @param {Object} searchQuery
+ */
+ _onSearch: function (searchQuery) {
+ this.discuss.threadViewer.update({
+ stringifiedDomain: JSON.stringify(searchQuery.domain),
+ });
+ },
+});
+
+action_registry.add('mail.widgets.discuss', DiscussWidget);
+
+return DiscussWidget;
+
+});
diff --git a/addons/mail/static/src/widgets/discuss/discuss.scss b/addons/mail/static/src/widgets/discuss/discuss.scss
new file mode 100644
index 00000000000..2bed9b7c373
--- /dev/null
+++ b/addons/mail/static/src/widgets/discuss/discuss.scss
@@ -0,0 +1,36 @@
+// ------------------------------------------------------------------
+// Layout
+// ------------------------------------------------------------------
+
+.o_widget_Discuss {
+ display: flex;
+ flex: 0 0 100%;
+ flex-flow: column;
+ height: 100%;
+}
+
+// ------------------------------------------------------------------
+// Style
+// ------------------------------------------------------------------
+
+.o_widget_Discuss {
+
+ .o_control_panel {
+ border-bottom: 0; // cancel default border, so that we only apply it on top of discuss content
+ }
+
+ .o_Discuss_content {
+ border-top: 1px solid darken($o-control-panel-background-color, 20%);
+ }
+
+ .o_Discuss.o-mobile {
+
+ &:not(.o-adding-item) {
+ border-top: 1px solid darken($o-control-panel-background-color, 20%);
+ }
+
+ &.o-adding-item .o_Discuss_mobileAddItemHeader {
+ border-bottom: 1px solid darken($o-control-panel-background-color, 20%);
+ }
+ }
+}
diff --git a/addons/mail/static/src/widgets/discuss/discuss.xml b/addons/mail/static/src/widgets/discuss/discuss.xml
new file mode 100644
index 00000000000..f8e6de37b8e
--- /dev/null
+++ b/addons/mail/static/src/widgets/discuss/discuss.xml
@@ -0,0 +1,27 @@
+<?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 00000000000..e94d58e752c
--- /dev/null
+++ b/addons/mail/static/src/widgets/discuss_invite_partner_dialog/discuss_invite_partner_dialog.js
@@ -0,0 +1,125 @@
+odoo.define('mail/static/src/widgets/discuss_invite_partner_dialog/discuss_invite_partner_dialog.js', function (require) {
+'use strict';
+
+const core = require('web.core');
+const Dialog = require('web.Dialog');
+
+const _lt = core._lt;
+const QWeb = core.qweb;
+
+/**
+ * Widget : Invite People to Channel Dialog
+ *
+ * Popup containing a 'many2many_tags' custom input to select multiple partners.
+ * Searches user according to the input, and triggers event when selection is
+ * validated.
+ */
+const PartnerInviteDialog = Dialog.extend({
+ dialog_title: _lt("Invite people"),
+ template: 'mail.widgets.DiscussInvitePartnerDialog',
+ /**
+ * @override {web.Dialog}
+ * @param {mail/static/src/widgets/discuss/discuss.js} parent
+ * @param {Object} param1
+ * @param {string} param1.activeThreadLocalId
+ * @param {Object} param1.messagingEnv
+ * @param {Object} param1.messagingEnv.store
+ */
+ init(parent, { activeThreadLocalId, messagingEnv }) {
+ const env = messagingEnv;
+ const channel = env.models['mail.thread'].get(activeThreadLocalId);
+ this.channelId = channel.id;
+ this.env = env;
+ this._super(parent, {
+ title: _.str.sprintf(this.env._t("Invite people to #%s"), channel.displayName),
+ size: 'medium',
+ buttons: [{
+ text: this.env._t("Invite"),
+ close: true,
+ classes: 'btn-primary',
+ click: ev => this._invite(ev),
+ }],
+ });
+ },
+ /**
+ * @override {web.Dialog}
+ * @returns {Promise}
+ */
+ start() {
+ this.$input = this.$('.o_input');
+ this.$input.select2({
+ width: '100%',
+ allowClear: true,
+ multiple: true,
+ formatResult: item => {
+ let status;
+ // TODO FIXME fix this, why do we even have an old widget here
+ if (item.id === 'odoobot') {
+ status = 'bot';
+ } else {
+ const partner = this.env.models['mail.partner'].find(partner =>
+ partner.id === item.id
+ );
+ status = partner.im_status;
+ }
+ const $status = QWeb.render('mail.widgets.UserStatus', { status });
+ return $('<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,
+ title: this.env._t("New people"),
+ type: 'warning',
+ });
+ },
+});
+
+return PartnerInviteDialog;
+
+});
diff --git a/addons/mail/static/src/widgets/discuss_invite_partner_dialog/discuss_invite_partner_dialog.xml b/addons/mail/static/src/widgets/discuss_invite_partner_dialog/discuss_invite_partner_dialog.xml
new file mode 100644
index 00000000000..82553476e21
--- /dev/null
+++ b/addons/mail/static/src/widgets/discuss_invite_partner_dialog/discuss_invite_partner_dialog.xml
@@ -0,0 +1,13 @@
+<?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 00000000000..df9c8b90d25
--- /dev/null
+++ b/addons/mail/static/src/widgets/form_renderer/form_renderer.js
@@ -0,0 +1,199 @@
+odoo.define('mail/static/src/widgets/form_renderer/form_renderer.js', function (require) {
+"use strict";
+
+const components = {
+ ChatterContainer: require('mail/static/src/components/chatter_container/chatter_container.js'),
+};
+
+const FormRenderer = require('web.FormRenderer');
+const { ComponentWrapper } = require('web.OwlCompatibility');
+
+class ChatterContainerWrapperComponent extends ComponentWrapper {}
+
+/**
+ * Include the FormRenderer to instantiate the chatter area containing (a
+ * subset of) the mail widgets (mail_thread, mail_followers and mail_activity).
+ */
+FormRenderer.include({
+ /**
+ * @override
+ */
+ init(parent, state, params) {
+ this._super(...arguments);
+ this.chatterFields = params.chatterFields;
+ this.mailFields = params.mailFields;
+ this._chatterContainerComponent = undefined;
+ /**
+ * The target of chatter, if chatter has to be appended to the DOM.
+ * This is set when arch contains `div.oe_chatter`.
+ */
+ this._chatterContainerTarget = undefined;
+ // Do not load chatter in form view dialogs
+ this._isFromFormViewDialog = params.isFromFormViewDialog;
+ },
+ /**
+ * @override
+ */
+ destroy() {
+ this._super(...arguments);
+ this._chatterContainerComponent = undefined;
+ this.off('o_attachments_changed', this);
+ this.off('o_chatter_rendered', this);
+ this.off('o_message_posted', this);
+ },
+
+ //--------------------------------------------------------------------------
+ // Private
+ //--------------------------------------------------------------------------
+
+ /**
+ * Returns whether the form renderer has a chatter to display or not.
+ * This is based on arch, which should have `div.oe_chatter`.
+ *
+ * @private
+ * @returns {boolean}
+ */
+ _hasChatter() {
+ return !!this._chatterContainerTarget;
+ },
+ /**
+ * @private
+ */
+ _makeChatterContainerComponent() {
+ const props = this._makeChatterContainerProps();
+ this._chatterContainerComponent = new ChatterContainerWrapperComponent(
+ this,
+ components.ChatterContainer,
+ props
+ );
+ // Not in custom_events because other modules may remove this listener
+ // while attempting to extend them.
+ this.on('o_chatter_rendered', this, ev => this._onChatterRendered(ev));
+ if (this.chatterFields.hasRecordReloadOnMessagePosted) {
+ this.on('o_message_posted', this, ev => this.trigger_up('reload'));
+ }
+ if (this.chatterFields.hasRecordReloadOnAttachmentsChanged) {
+ this.on('o_attachments_changed', this, ev => this.trigger_up('reload'));
+ }
+ },
+ /**
+ * @private
+ * @returns {Object}
+ */
+ _makeChatterContainerProps() {
+ const context = this.record ? this.record.getContext() : {};
+ const activityIds = this.state.data.activity_ids
+ ? this.state.data.activity_ids.res_ids
+ : [];
+ const followerIds = this.state.data.message_follower_ids
+ ? this.state.data.message_follower_ids.res_ids
+ : [];
+ const messageIds = this.state.data.message_ids
+ ? this.state.data.message_ids.res_ids
+ : [];
+ const threadAttachmentCount = this.state.data.message_attachment_count || 0;
+ return {
+ activityIds,
+ context,
+ followerIds,
+ hasActivities: this.chatterFields.hasActivityIds,
+ hasFollowers: this.chatterFields.hasMessageFollowerIds,
+ hasThread: this.chatterFields.hasMessageIds,
+ isAttachmentBoxVisible: this.chatterFields.isActivityBoxVisible,
+ messageIds,
+ threadAttachmentCount,
+ threadId: this.state.res_id,
+ threadModel: this.state.model,
+ };
+ },
+ /**
+ * Create the DOM element that will contain the chatter. This is made in
+ * a separate method so it can be overridden (like in mail_enterprise for
+ * example).
+ *
+ * @private
+ * @returns {jQuery.Element}
+ */
+ _makeChatterContainerTarget() {
+ const $el = $('<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 (
+ !this._isFromFormViewDialog &&
+ node.tag === 'div' &&
+ node.attrs.class === 'oe_chatter'
+ ) {
+ return this._makeChatterContainerTarget();
+ }
+ return this._super(...arguments);
+ },
+ /**
+ * Overrides the function to render the chatter once the form view is
+ * rendered.
+ *
+ * @override
+ */
+ async _renderView() {
+ await this._super(...arguments);
+ if (this._hasChatter()) {
+ if (!this._chatterContainerComponent) {
+ this._makeChatterContainerComponent();
+ } else {
+ await this._updateChatterContainerComponent();
+ }
+ await this._mountChatterContainerComponent();
+ }
+ },
+ /**
+ * @private
+ */
+ async _updateChatterContainerComponent() {
+ const props = this._makeChatterContainerProps();
+ try {
+ await this._chatterContainerComponent.update(props);
+ } catch (error) {
+ if (error.message !== "Mounting operation cancelled") {
+ throw error;
+ }
+ }
+ },
+
+ //--------------------------------------------------------------------------
+ // Handlers
+ //--------------------------------------------------------------------------
+
+ /**
+ * @abstract
+ * @private
+ * @param {OdooEvent} ev
+ * @param {Object} ev.data
+ * @param {mail.attachment[]} ev.data.attachments
+ * @param {mail.thread} ev.data.thread
+ */
+ _onChatterRendered(ev) {},
+});
+
+});
diff --git a/addons/mail/static/src/widgets/form_renderer/form_renderer.scss b/addons/mail/static/src/widgets/form_renderer/form_renderer.scss
new file mode 100644
index 00000000000..3092055b645
--- /dev/null
+++ b/addons/mail/static/src/widgets/form_renderer/form_renderer.scss
@@ -0,0 +1,17 @@
+// ------------------------------------------------------------------
+// Layout
+// ------------------------------------------------------------------
+
+.o_FormRenderer_chatterContainer {
+ display: flex;
+ flex: 1 1 auto;
+ margin: 0 auto;
+ max-width: $o-form-view-sheet-max-width;
+ padding: map-get($spacers, 3) map-get($spacers, 3) map-get($spacers, 5);
+ width: 100%;
+}
+
+// FIX to hide chatter in dialogs when they are opened from an action returned by python code
+.modal .modal-dialog .o_form_view .o_FormRenderer_chatterContainer {
+ display: none;
+}
diff --git a/addons/mail/static/src/widgets/form_renderer/form_renderer_tests.js b/addons/mail/static/src/widgets/form_renderer/form_renderer_tests.js
new file mode 100644
index 00000000000..7c3ecea8ab9
--- /dev/null
+++ b/addons/mail/static/src/widgets/form_renderer/form_renderer_tests.js
@@ -0,0 +1,455 @@
+odoo.define('mail/static/src/widgets/form_renderer/form_renderer_tests.js', function (require) {
+"use strict";
+
+const {
+ afterEach: utilsAfterEach,
+ afterNextRender,
+ beforeEach: utilsBeforeEach,
+ start,
+} = require('mail/static/src/utils/test_utils.js');
+
+const FormView = require('web.FormView');
+
+QUnit.module('mail', {}, function () {
+QUnit.module('widgets', {}, function () {
+QUnit.module('form_renderer', {}, function () {
+QUnit.module('form_renderer_tests.js', {
+ beforeEach() {
+ utilsBeforeEach(this);
+ this.data['res.partner'].fields = {
+ activity_ids: {
+ string: "Activities",
+ type: 'one2many',
+ relation: 'mail.activity',
+ },
+ message_attachment_count: {
+ string: 'Attachment count',
+ type: 'integer',
+ },
+ message_follower_ids: {
+ string: "Followers",
+ type: 'one2many',
+ relation: 'mail.followers',
+ },
+ message_ids: {
+ string: "Messages",
+ type: 'one2many',
+ relation: 'mail.message',
+ },
+ name: {
+ string: "Name",
+ type: 'char',
+ },
+ };
+ this.underscoreDebounce = _.debounce;
+ this.underscoreThrottle = _.throttle;
+ _.debounce = _.identity;
+ _.throttle = _.identity;
+ // FIXME archs could be removed once task-2248306 is done
+ this.createView = async (viewParams, ...args) => {
+ await afterNextRender(async () => {
+ const viewArgs = Object.assign({
+ archs: {
+ 'mail.activity,false,list': '<tree/>',
+ 'mail.followers,false,list': '<tree/>',
+ 'mail.message,false,list': '<tree/>',
+ }},
+ viewParams,
+ );
+ const { widget } = await start(viewArgs, ...args);
+ this.view = widget;
+ });
+ };
+ },
+ afterEach() {
+ _.debounce = this.underscoreDebounce;
+ _.throttle = this.underscoreThrottle;
+ if (this.view) {
+ this.view.destroy();
+ }
+ utilsAfterEach(this);
+ },
+});
+
+QUnit.test('basic chatter rendering', async function (assert) {
+ assert.expect(1);
+
+ this.data['res.partner'].records = [{
+ id: 2,
+ display_name: "second partner",
+ }];
+ await this.createView({
+ data: this.data,
+ hasView: true,
+ // View params
+ View: FormView,
+ model: 'res.partner',
+ arch: `
+ <form string="Partners">
+ <sheet>
+ <field name="name"/>
+ </sheet>
+ <div class="oe_chatter"></div>
+ </form>
+ `,
+ res_id: 2,
+ });
+
+ assert.strictEqual(
+ document.querySelectorAll(`.o_Chatter`).length,
+ 1,
+ "there should be a chatter"
+ );
+});
+
+QUnit.test('basic chatter rendering without followers', async function (assert) {
+ assert.expect(6);
+
+ this.data['res.partner'].records = [{
+ activity_ids: [],
+ id: 2,
+ display_name: "second partner",
+ message_ids: [],
+ message_follower_ids: [],
+ }];
+ await this.createView({
+ data: this.data,
+ hasView: true,
+ // View params
+ View: FormView,
+ model: 'res.partner',
+ arch: `
+ <form string="Partners">
+ <sheet>
+ <field name="name"/>
+ </sheet>
+ <div class="oe_chatter">
+ <field name="activity_ids"/>
+ <field name="message_ids"/>
+ </div>
+ </form>
+ `,
+ res_id: 2,
+ });
+
+ assert.containsOnce(
+ document.body,
+ '.o_Chatter',
+ "there should be a chatter"
+ );
+ assert.containsOnce(
+ document.body,
+ '.o_ChatterTopbar',
+ "there should be a chatter topbar"
+ );
+ assert.containsOnce(
+ document.body,
+ '.o_ChatterTopbar_buttonAttachments',
+ "there should be an attachment button"
+ );
+ assert.containsOnce(
+ document.body,
+ '.o_ChatterTopbar_buttonScheduleActivity',
+ "there should be a schedule activity button"
+ );
+ assert.containsNone(
+ document.body,
+ '.o_FollowerListMenu',
+ "there should be no followers menu"
+ );
+ assert.containsOnce(
+ document.body,
+ '.o_Chatter_thread',
+ "there should be a thread"
+ );
+});
+
+QUnit.test('basic chatter rendering without activities', async function (assert) {
+ assert.expect(6);
+
+ this.data['res.partner'].records = [{
+ activity_ids: [],
+ id: 2,
+ display_name: "second partner",
+ message_ids: [],
+ message_follower_ids: [],
+ }];
+ await this.createView({
+ data: this.data,
+ hasView: true,
+ // View params
+ View: FormView,
+ model: 'res.partner',
+ arch: `
+ <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: 2,
+ });
+
+ assert.containsOnce(
+ document.body,
+ '.o_Chatter',
+ "there should be a chatter"
+ );
+ assert.containsOnce(
+ document.body,
+ '.o_ChatterTopbar',
+ "there should be a chatter topbar"
+ );
+ assert.containsOnce(
+ document.body,
+ '.o_ChatterTopbar_buttonAttachments',
+ "there should be an attachment button"
+ );
+ assert.containsNone(
+ document.body,
+ '.o_ChatterTopbar_buttonScheduleActivity',
+ "there should be a schedule activity button"
+ );
+ assert.containsOnce(
+ document.body,
+ '.o_FollowerListMenu',
+ "there should be a followers menu"
+ );
+ assert.containsOnce(
+ document.body,
+ '.o_Chatter_thread',
+ "there should be a thread"
+ );
+});
+
+QUnit.test('basic chatter rendering without messages', async function (assert) {
+ assert.expect(6);
+
+ this.data['res.partner'].records = [{
+ activity_ids: [],
+ id: 2,
+ display_name: "second partner",
+ message_ids: [],
+ message_follower_ids: [],
+ }];
+ await this.createView({
+ data: this.data,
+ hasView: true,
+ // View params
+ View: FormView,
+ model: 'res.partner',
+ arch: `
+ <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: 2,
+ });
+
+ assert.containsOnce(
+ document.body,
+ '.o_Chatter',
+ "there should be a chatter"
+ );
+ assert.containsOnce(
+ document.body,
+ '.o_ChatterTopbar',
+ "there should be a chatter topbar"
+ );
+ assert.containsOnce(
+ document.body,
+ '.o_ChatterTopbar_buttonAttachments',
+ "there should be an attachment button"
+ );
+ assert.containsOnce(
+ document.body,
+ '.o_ChatterTopbar_buttonScheduleActivity',
+ "there should be a schedule activity button"
+ );
+ assert.containsOnce(
+ document.body,
+ '.o_FollowerListMenu',
+ "there should be a followers menu"
+ );
+ assert.containsNone(
+ document.body,
+ '.o_Chatter_thread',
+ "there should be a thread"
+ );
+});
+
+QUnit.test('chatter updating', async function (assert) {
+ assert.expect(3);
+
+ this.data['ir.attachment'].records = [{
+ id: 1,
+ mimetype: 'image/png',
+ name: 'filename.jpg',
+ res_id: 7,
+ res_model: 'partner',
+ type: 'url',
+ }, {
+ id: 2,
+ mimetype: "application/x-msdos-program",
+ name: "file2.txt",
+ res_id: 7,
+ res_model: 'partner',
+ type: 'binary',
+ }, {
+ id: 3,
+ mimetype: "application/x-msdos-program",
+ name: "file3.txt",
+ res_id: 5,
+ res_model: 'partner',
+ type: 'binary',
+ }];
+ this.data['mail.message'].records = [{
+ id: 1000,
+ body: "<p>test 1</p>",
+ author_id: [100, "Someone"],
+ model: 'res.partner',
+ res_id: 2,
+ moderation_status: 'accepted',
+ }];
+ this.data['res.partner'].records = [{
+ id: 1,
+ display_name: "first partner",
+ message_ids: [],
+ }, {
+ id: 2,
+ display_name: "second partner",
+ message_ids: [],
+ }];
+ await this.createView({
+ data: this.data,
+ hasView: true,
+ // View params
+ View: FormView,
+ model: 'res.partner',
+ res_id: 1,
+ viewOptions: {
+ ids: [1, 2],
+ index: 0,
+ },
+ arch: `
+ <form string="Partners">
+ <sheet>
+ <field name="name"/>
+ </sheet>
+ <div class="oe_chatter">
+ <field name="message_ids"/>
+ </div>
+ </form>
+ `,
+ });
+ assert.containsOnce(
+ document.body,
+ '.o_Chatter',
+ "there should be a chatter"
+ );
+ assert.containsNone(
+ document.body,
+ '.o_Message',
+ "there should be no message"
+ );
+
+ await afterNextRender(() => {
+ document.querySelector('.o_pager_next').click();
+ });
+ assert.containsOnce(
+ document.body,
+ '.o_Message',
+ "there should be a message"
+ );
+});
+
+QUnit.test('chatter should become enabled when creation done', async function (assert) {
+ assert.expect(10);
+
+ await this.createView({
+ data: this.data,
+ hasView: true,
+ // View params
+ View: FormView,
+ model: 'res.partner',
+ arch: `
+ <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"
+ );
+});
+
+});
+});
+});
+
+});
diff --git a/addons/mail/static/src/widgets/messaging_menu/messaging_menu.js b/addons/mail/static/src/widgets/messaging_menu/messaging_menu.js
new file mode 100644
index 00000000000..edfef630605
--- /dev/null
+++ b/addons/mail/static/src/widgets/messaging_menu/messaging_menu.js
@@ -0,0 +1,56 @@
+odoo.define('mail/static/src/widgets/messaging_menu/messaging_menu.js', function (require) {
+'use strict';
+
+const components = {
+ MessagingMenu: require('mail/static/src/components/messaging_menu/messaging_menu.js'),
+};
+
+const SystrayMenu = require('web.SystrayMenu');
+const Widget = require('web.Widget');
+
+/**
+ * Odoo Widget, necessary to instantiate component.
+ */
+const MessagingMenu = Widget.extend({
+ template: 'mail.widgets.MessagingMenu',
+ /**
+ * @override
+ */
+ init() {
+ this._super(...arguments);
+ this.component = undefined;
+ },
+ /**
+ * @override
+ */
+ destroy() {
+ if (this.component) {
+ this.component.destroy();
+ }
+ this._super(...arguments);
+ },
+ async on_attach_callback() {
+ const MessagingMenuComponent = components.MessagingMenu;
+ this.component = new MessagingMenuComponent(null);
+ await this.component.mount(this.el);
+ // unwrap
+ this.el.parentNode.insertBefore(this.component.el, this.el);
+ this.el.parentNode.removeChild(this.el);
+ },
+});
+
+// Systray menu items display order matches order in the list
+// lower index comes first, and display is from right to left.
+// For messagin menu, it should come before activity menu, if any
+// otherwise, it is the next systray item.
+const activityMenuIndex = SystrayMenu.Items.findIndex(SystrayMenuItem =>
+ SystrayMenuItem.prototype.name === 'activity_menu');
+if (activityMenuIndex > 0) {
+ SystrayMenu.Items.splice(activityMenuIndex, 0, MessagingMenu);
+} else {
+ SystrayMenu.Items.push(MessagingMenu);
+}
+
+return MessagingMenu;
+
+});
diff --git a/addons/mail/static/src/widgets/messaging_menu/messaging_menu.xml b/addons/mail/static/src/widgets/messaging_menu/messaging_menu.xml
new file mode 100644
index 00000000000..308c1f31134
--- /dev/null
+++ b/addons/mail/static/src/widgets/messaging_menu/messaging_menu.xml
@@ -0,0 +1,8 @@
+<?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/xml/abstract_thread_window.xml b/addons/mail/static/src/xml/abstract_thread_window.xml
deleted file mode 100644
index ae29a21b5ba..00000000000
--- a/addons/mail/static/src/xml/abstract_thread_window.xml
+++ /dev/null
@@ -1,85 +0,0 @@
-<?xml version="1.0" encoding="UTF-8"?>
-<templates xml:space="preserve">
-
- <!--
- @param {mail.AbstractThreadWindow} widget
- -->
- <t t-name="mail.AbstractThreadWindow">
- <div class="o_thread_window o_in_home_menu" t-att-data-thread-id="widget.getID()">
- <div class="o_thread_window_header">
- <t t-call="mail.AbstractThreadWindow.HeaderContent">
- <t t-set="status" t-value="widget.getThreadStatus()"/>
- <t t-set="title" t-value="widget.getTitle()"/>
- <t t-set="unreadCounter" t-value="widget.getUnreadCounter()"/>
- <t t-set="thread" t-value="widget.getThread()"/>
- </t>
- </div>
- <div class="o_thread_window_content">
- </div>
- <div t-if="widget.needsComposer()" class="o_thread_composer o_chat_mini_composer">
- <input class="o_composer_text_field" t-att-placeholder="widget.options.placeholder"/>
- </div>
- </div>
- </t>
-
- <!--
- @param {boolean} [open=false] tell whether the hidden thread window
- dropdown menu is open or not.
- @param {mail.ThreadWindow[]} threadWindows list of thread windows
- that are hidden, i.e. put in the hidden window dropdown menu.
- @param {integer} [unreadCounter] the total unread counter of all
- threads which are linked to the thread windows that are put in the
- hidden thread window dropdown menu.
- -->
- <t t-name="mail.HiddenThreadWindowsDropdown">
- <div t-attf-class="o_thread_window o_in_home_menu o_thread_window_dropdown dropup #{open ? 'open' : ''}">
- <span role="alert" class="o_thread_window_dropdown_toggler dropdown-toggle" data-toggle="dropdown" title="Unread messages">
- <i class="fa fa-comments-o" role="img" aria-label="Messages"/> <t t-esc="threadWindows.length"/>
- <span class="o_total_unread_counter">
- <t t-if="unreadCounter"><t t-esc="unreadCounter"/></t>
- </span>
- </span>
- <ul class="dropdown-menu" role="menu">
- <t t-foreach="threadWindows" t-as="threadWindow">
- <li role="menuitem" class="o_thread_window_header" t-att-data-thread-id="threadWindow.getID()">
- <t t-call="mail.AbstractThreadWindow.HeaderContent">
- <t t-set="status" t-value="threadWindow.getThreadStatus()"/>
- <t t-set="title" t-value="threadWindow.getTitle()"/>
- <t t-set="unreadCounter" t-value="threadWindow.getUnreadCounter()"/>
- </t>
- </li>
- </t>
- </ul>
- </div>
- </t>
-
- <!--
- @param {string} [status] e.g. 'online', 'offline'.
- @param {string} title the title of the thread window, e.g. the record
- name of the document.
- @param {integer} [unreadCounter] the number of unread messages on the
- thread.
- @param {mail.model.Thread|undefined} thread
- @param {Object} widget
- @param {function} widget.isMobile function without any param that
- states whether it should render for desktop or mobile screen.
- -->
- <t t-name="mail.AbstractThreadWindow.HeaderContent">
- <span t-if="widget.isMobile()">
- <a href="#" class="o_thread_window_close fa fa-1x fa-arrow-left" aria-label="Close chat window" title="Close chat window"/>
- </span>
- <div t-if="thread and thread.getType() === 'dm_chat'" class="o_thread_window_avatar">
- <img alt="" t-att-src="'/web/image/res.partner/' + thread.getDirectPartnerID() + '/image_128'"/>
- <t t-call="mail.UserStatus"/>
- </div>
- <span class="o_thread_window_title">
- <t t-esc="title"/>
- <span t-if="unreadCounter"> (<t t-esc="unreadCounter"/>)</span>
- <t t-if="thread and thread.hasTypingNotification() and thread.isSomeoneTyping()" t-call="mail.ThreadTypingIcon"/>
- </span>
- <span t-if="!widget.isMobile()" class="o_thread_window_buttons">
- <a href="#" class="o_thread_window_close fa fa-close"/>
- </span>
- </t>
-
-</templates>
diff --git a/addons/mail/static/src/xml/chatter.xml b/addons/mail/static/src/xml/chatter.xml
deleted file mode 100644
index 1f36287cdf8..00000000000
--- a/addons/mail/static/src/xml/chatter.xml
+++ /dev/null
@@ -1,152 +0,0 @@
-<?xml version="1.0" encoding="UTF-8"?>
-<templates>
-
- <!--
- Chatter attachment tab
- -->
-
- <div t-name="mail.chatter.AttachmentBox" class="o_mail_chatter_attachments">
- <div class="o_chatter_attachment">
- <div class="o_border_dashed">
- <span class="o_attach_title">Attachments</span>
- </div>
- <div t-if="widget.imageList.length > 0" class="o_attachments_previews">
- <t t-foreach="widget.imageList" t-as="attachment">
- <t t-call="mail.AttachmentPreview">
- <t t-set="isDeletable" t-value="true"/>
- </t>
- </t>
- </div>
- <div t-if="widget.otherList.length > 0" class="o_attachments_list">
- <t t-foreach="widget.otherList" t-as="attachment">
- <t t-call="mail.Attachment">
- <t t-set="isDeletable" t-value="true"/>
- </t>
- </t>
- </div>
- <div class="o_chatter_attachment_form">
- <t t-call="HiddenInputFile">
- <t t-set="fileupload_id" t-value="widget.fileuploadId"/>
- <t t-set="fileupload_action" t-translation="off">/web/binary/upload_attachment</t>
- <t t-set="multi_upload" t-value="true"/>
- <input type="hidden" name="model" t-att-value="widget.currentResModel"/>
- <input type="hidden" name="id" t-att-value="widget.currentResID"/>
- <center><span class="btn btn-link o_upload_attachments_button"><span class="fa fa-plus-square"/> Add Attachments</span></center>
- </t>
- </div>
- </div>
- </div>
-
- <!--
- Chatter Suggested Partners
- -->
- <t t-name="mail.chatter.ComposerSuggestedPartners">
- <!-- List of followers -->
- <div class="o_composer_suggested_partners">
- <t t-foreach='widget.suggestedPartners' t-as='recipient'>
- <div t-attf-title="Add as recipient and follower (reason: {{recipient.reason}})" t-att-class="recipient_index > 2 ? 'o_suggested_toggle o_hidden' : ''">
- <div class="custom-control custom-checkbox">
- <input type="checkbox"
- class="custom-control-input"
- t-attf-id="o_composer_suggested_partners_p{{recipient_index}}"
- t-att-checked="recipient.checked ? 'checked' : undefined"
- t-att-data-fullname="recipient.full_name"/>
- <label t-attf-for="o_composer_suggested_partners_p{{recipient_index}}" class="custom-control-label">
- <t t-esc="recipient.name"/>
- <t t-if="recipient.email_address">(<t t-esc="recipient.email_address"/>)</t>
- </label>
- </div>
- </div>
- </t>
- <t t-if='widget.suggestedPartners.length > 3'>
- <a href="#" class='o_suggested_show_more_less o_suggested_toggle'>
- Show <t t-esc="widget.suggestedPartners.length - 3"/> more
- </a>
- <a href="#" class='o_suggested_show_more_less o_suggested_toggle o_hidden'>
- Show <t t-esc="widget.suggestedPartners.length - 3"/> less
- </a>
- </t>
- </div>
- </t>
-
- <!--
- Chatter composer
-
- @param {mail.composer.Chatter} widget
- -->
-
- <t t-name="mail.chatter.Composer" t-extend="mail.Composer">
- <!-- Insert information before the composer -->
- <t t-jquery=".o_composer_container" t-operation="before">
- <!-- New message composer -->
- <t t-if="!widget.options.isLog">
- <!-- Text for followers -->
- <small class="o_chatter_composer_info">
- <b class="text-muted">To: </b>
- <em class="text-muted">Followers of </em>
- <b>
- <t t-if="widget.options.recordName">
- &#32;&quot;<t t-esc="widget.options.recordName"/>&quot;
- </t>
- <t t-if="!widget.options.recordName">
- this document
- </t>
- </b>
- </small>
- <t t-call="mail.chatter.ComposerSuggestedPartners"/>
- </t>
- </t>
-
- <!-- Add a button to open the full composer dialog -->
- <t t-jquery=".o_composer_button_add_attachment" t-operation="after">
- <button tabindex="6" class="btn btn-secondary fa fa-expand o_composer_button_full_composer" type="button" title="Full composer" aria-label="Full composer"/>
- </t>
- </t>
-
- <!--
- Chatter (mail_thread widget) buttons
-
- @param {boolean} [isMobile]
- @param {boolean} [logNoteButton]
- @param {boolean} [newMessageButton]
- @param {boolean} [scheduleActivityButton]
- -->
- <t t-name="mail.chatter.Buttons">
- <button t-if="newMessageButton" type="button" class="btn btn-link o_chatter_button_new_message" title="Send a message" disabled="disabled">
- Send message
- </button>
- <button t-if="logNoteButton" class="btn btn-link o_chatter_button_log_note" title="Log a note. Followers will not be notified." disabled="disabled">
- Log note
- </button>
- <button t-if="scheduleActivityButton" class="btn btn-link o_chatter_button_schedule_activity" title="Log or schedule an activity" disabled="disabled">
- <i class="fa fa-clock-o" title="Dates"/> <t t-if="isMobile">Activity</t><t t-else="">Schedule activity</t>
- </button>
- </t>
-
- <!--
- Chatter attachment paperclip button.
-
- @param {integer} [count]
- -->
- <t t-name="mail.chatter.Attachment.Button">
- <t t-set="attachment_button_aria_label">Attachments</t>
- <button class="btn btn-link o_chatter_button_attachment" title="View all the attachments of the current record" t-attf-aria-label="#{attachment_button_aria_label} #{count}">
- <span>
- <i class="fa fa-paperclip"/>
- <span class="o_chatter_attachment_button_count" t-esc="count"/>
- </span>
- </button>
- </t>
-
- <!--
- Chatter main div
- -->
- <t t-name="mail.Chatter">
- <aside class="o_chatter">
- <div class="o_chatter_topbar">
- <div class="o_topbar_right_area"/>
- </div>
- </aside>
- </t>
-
-</templates>
diff --git a/addons/mail/static/src/xml/composer.xml b/addons/mail/static/src/xml/composer.xml
index 071a8023e83..49c0a3bf9b4 100644
--- a/addons/mail/static/src/xml/composer.xml
+++ b/addons/mail/static/src/xml/composer.xml
@@ -1,54 +1,7 @@
<?xml version="1.0" encoding="UTF-8"?>
<templates xml:space="preserve">
- <div t-name="mail.Composer" t-attf-class="o_thread_composer #{widget.extended ? 'o_thread_composer_extended' : (widget.notInline ? '' : 'o_chat_inline_composer')} #{widget.isMini ? 'o_chat_mini_composer' : ''}">
- <div t-if="widget.isMini || !widget.notInline" class="o_file_drop_zone_container d-none">
- <h4>Drag Files Here <span class="fa fa-download"/></h4>
- </div>
- <div class="o_composer_container">
- <img t-if="widget.avatarURL" height="36" width="36" class="o_thread_message_sidebar o_chatter_avatar o_object_fit_cover rounded-circle" t-att-src="widget.avatarURL" alt="Avatar"/>
- <div t-if="widget.extended" class="o_composer_subject">
- <input tabindex="1" type="text" placeholder="Subject" class="o_input"/>
- </div>
- <div t-attf-class="o_composer #{widget.extended ? 'o_extended_composer' : ''}">
- <div t-if="widget.notInline" class="o_file_drop_zone_container d-none">
- <h4>Drag Files Here <span class="fa fa-download"/></h4>
- </div>
- <div class="o_composer_input">
- <textarea class="o_input o_composer_text_field" tabindex="2" placeholder="Write something..."/>
- <div class="o_chatter_composer_tools">
- <button tabindex="4" class="btn btn-secondary fa fa-smile-o o_composer_button_emoji" type="button" data-toggle="popover" title="Emojis" aria-label="Emojis"/>
- <button tabindex="5" class="btn btn-secondary fa fa-paperclip o_composer_button_add_attachment" type="button" aria-label="Add attachment" title="Add attachment"/>
- <button t-if="widget.options.isMobile" tabindex="3" t-attf-class="btn #{widget.isMini ? 'btn-primary' : 'btn-secondary'} fa fa-paper-plane-o o_composer_button_send" type="button" title="Send" aria-label="Send"/>
- </div>
- </div>
- </div>
- <div class="o_composer_attachments_list"/>
- <div class="o_composer_thread_typing"/>
- </div>
- <div class="o_composer_send">
- <button tabindex="3" class="btn btn-primary o_composer_button_send d-none d-md-inline-block" type="button"><t t-esc="widget.options.sendText"/></button>
- <button tabindex="3" class="btn btn-secondary o_composer_button_discard d-none" type="button">Discard</button>
- </div>
- <span class="d-none">
- <t t-call="HiddenInputFile">
- <t t-set="fileupload_id" t-value="widget.fileuploadID"/>
- <t t-set="fileupload_action" t-translation="off">/web/binary/upload_attachment</t>
- <t t-set="multi_upload" t-value="true"/>
- <input type="hidden" name="model" value="mail.compose.message"/>
- <input type="hidden" name="id" value="0"/>
- </t>
- </span>
- </div>
-
- <!--
- @param {mail.model.Thread} thread
- -->
- <t t-name="mail.Composer.ThreadTyping">
- <t t-esc="thread.getTypingMembersToText()"/>
- </t>
-
- <div t-name="mail.Composer.emojis" class="o_mail_emoji_container">
+ <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"/>
@@ -56,48 +9,6 @@
</t>
</div>
- <!--
- @param {Object[]} suggestions
- @param {boolean} [suggestions.divider=false]
- -->
- <t t-name="mail.MentionSuggestions">
- <div class="dropdown-menu" role="menu">
- <t t-foreach="suggestions" t-as="suggestion">
- <div role="separator" t-if="suggestion.divider" class="dropdown-divider"/>
- <a t-else="" href="#" class="dropdown-item o_mention_proposition" t-att-data-id="suggestion.id" role="menuitem"/>
- </t>
- </div>
- </t>
- <t t-name="mail.MentionSuggestions.Partner" t-extend="mail.MentionSuggestions">
- <t t-jquery=".o_mention_proposition" t-operation="append">
- <t t-call="mail.UserStatus">
- <t t-set="status" t-value="suggestion.im_status"/>
- <t t-set="partnerID" t-value="suggestion.id"/>
- </t>
- <span class="o_mention_name"><t t-esc="suggestion.name"/></span>
- <t t-if="suggestion.email">
- <span class="o_mention_info">(<t t-esc="suggestion.email"/>)</span>
- </t>
- </t>
- </t>
- <t t-name="mail.MentionSuggestions.Channel" t-extend="mail.MentionSuggestions">
- <t t-jquery=".o_mention_proposition" t-operation="append">
- <span class="o_mention_name"><t t-esc="suggestion.name"/></span>
- </t>
- </t>
- <t t-name="mail.MentionSuggestions.CannedResponse" t-extend="mail.MentionSuggestions">
- <t t-jquery=".o_mention_proposition" t-operation="append">
- <span class="o_mention_name"><t t-esc="suggestion.source"/></span>
- <span class="o_mention_info"><t t-esc="suggestion.substitution"/></span>
- </t>
- </t>
- <t t-name="mail.MentionSuggestions.Command" t-extend="mail.MentionSuggestions">
- <t t-jquery=".o_mention_proposition" t-operation="append">
- <span class="o_mention_name">/<t t-esc="suggestion.name"/></span>
- <span class="o_mention_info"><t t-esc="suggestion.help"/></span>
- </t>
- </t>
-
<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]}">
diff --git a/addons/mail/static/src/xml/discuss.xml b/addons/mail/static/src/xml/discuss.xml
deleted file mode 100644
index 98788e303ff..00000000000
--- a/addons/mail/static/src/xml/discuss.xml
+++ /dev/null
@@ -1,352 +0,0 @@
-<?xml version="1.0" encoding="UTF-8"?>
-<templates xml:space="preserve">
-
- <!--
- Discuss Main template
-
- @param {mail.discuss} widget
- -->
- <t t-name="mail.discuss">
- <div class="o_mail_discuss">
- <div class="o_mail_discuss_sidebar">
- <div class="o_mail_discuss_loading">
- <i class="fa fa-spinner fa-spin"/> Please wait...
- </div>
- </div>
- <div class="o_mail_discuss_content">
- <div class="o_mail_discuss_loading">
- <i class="fa fa-spinner fa-spin"/> Please wait...
- </div>
- </div>
- </div>
- </t>
-
- <!--
- @param {string|integer} activeThreadID
- @param {mail.model.Channel[]} channels
- @param {boolean} isMyselfModerator
- @param {mail.model.Mailbox} inbox
- @param {mail.model.Mailbox} starred
- @param {mail.model.Mailbox|undefined} moderation set if current user is moderator
- @param {boolean} displayQuickSearch
- @param {Object} options
- -->
- <t t-name="mail.discuss.Sidebar">
- <div class="o_mail_discuss_sidebar">
- <div class="o_mail_discuss_sidebar_mailboxes">
- <t t-call="mail.discuss.SidebarMailboxes"/>
- </div>
- <hr class="mb8"/>
- <div t-if="displayQuickSearch" class="o_discuss_sidebar_quick_search">
- <t t-set="inputPlaceholder">Quick search...</t>
- <input t-att-placeholder="inputPlaceholder"/>
- </div>
- <div class="o_mail_discuss_sidebar_channels">
- <t t-call="mail.discuss.SidebarChannels"/>
- </div>
- </div>
- </t>
-
- <!--
- @param {string|integer} activeThreadID
- @param {boolean} isMyselfModerator
- @param {mail.model.Mailbox} inbox
- @param {mail.model.Mailbox} starred
- @param {mail.model.Mailbox|undefined} moderation set if current user is moderator
- -->
- <t t-name="mail.discuss.SidebarMailboxes">
- <div t-attf-class="o_mail_discuss_title_main o_mail_discuss_item #{(activeThreadID === 'mailbox_inbox') ? 'o_active': ''}"
- data-thread-id="mailbox_inbox">
- <span class="o_thread_name"><i class="fa fa-inbox mr8"/>Inbox</span>
- <t t-set="counter" t-value="inbox.getMailboxCounter()"/>
- <t t-call="mail.discuss.SidebarCounter"/>
- </div>
- <div t-attf-class="o_mail_discuss_title_main o_mail_mailbox_title_starred o_mail_discuss_item #{(activeThreadID === 'mailbox_starred') ? 'o_active': ''}"
- data-thread-id="mailbox_starred">
- <span class="o_thread_name"><i class="fa fa-star-o mr8"/>Starred</span>
- <t t-set="counter" t-value="starred.getMailboxCounter()"/>
- <t t-call="mail.discuss.SidebarCounter"/>
- </div>
- <div t-attf-class="o_mail_discuss_title_main o_mail_discuss_item #{(activeThreadID === 'mailbox_history') ? 'o_active': ''}"
- data-thread-id="mailbox_history">
- <span class="o_thread_name"><i class="fa fa-history mr8"/>History</span>
- </div>
- <div t-if="isMyselfModerator" t-attf-class="o_mail_discuss_title_main o_mail_discuss_item #{(activeThreadID == 'mailbox_moderation') ? 'o_active': ''}"
- data-thread-id="mailbox_moderation">
- <span class="o_thread_name"> <i class="fa fa-envelope mr8"/>Moderation Queue</span>
- <t t-set="counter" t-value="moderation.getMailboxCounter()"/>
- <t t-call="mail.discuss.SidebarCounter"/>
- </div>
- </t>
-
- <!--
- @param {string|integer} activeThreadID
- @param {mail.model.Channel[]} channels
- -->
- <t t-name="mail.discuss.SidebarChannels">
- <t t-set="type" t-value="'multi_user_channel'"/>
- <t t-call="mail.discuss.SidebarTitle">
- <t t-set="title">Channels</t>
- <t t-set="icon" t-value="fa-users"/>
- </t>
- <t t-call="mail.discuss.SidebarItems">
- <t t-set="displayHash" t-value="true"/>
- <t t-set="inputPlaceholder">Add a channel</t>
- </t>
-
- <t t-set="type" t-value="'dm_chat'"/>
- <t t-call="mail.discuss.SidebarTitle">
- <t t-set="title">Direct Messages</t>
- <t t-set="icon" t-value="fa-user"/>
- </t>
- <t t-call="mail.discuss.SidebarItems">
- <t t-set="displayStatus" t-value="true"/>
- <t t-set="inputPlaceholder">User name</t>
- </t>
- </t>
-
- <!--
- @param {integer} counter
- -->
- <t t-name="mail.discuss.SidebarCounter">
- <span t-attf-class="o_mail_sidebar_needaction badge badge-pill #{(!counter ? 'd-none' : '')}">
- <t t-esc="counter"/>
- </span>
- </t>
-
- <!--
- Don't display the thread type if there is no thread of such type
- and the 'add a thread' option is disabled for this type
-
- @param {mail.model.Channel[]} channels
- @param {boolean} [disableAddThread] whether the 'Add thread' action should disabled or not for the considered discuss item category
- @param {string} [icon] classname to add on title to display icon
- @param {string} title the displayed type of the discuss items (e.g. "Channels")
- @param {string} type the type of the thread
- -->
- <t t-name="mail.discuss.SidebarTitle">
- <t t-if="disableAddThread">
- <t t-set="empty" t-value="true"/>
- <t t-foreach="channels" t-as="channel">
- <t t-if="channel.getType() === type">
- <t t-set="empty" t-value="false"/>
- </t>
- </t>
- </t>
- <t t-if="!disableAddThread || !empty">
- <div class="o_mail_sidebar_title">
- <h4 t-att-class="type == 'multi_user_channel' ? 'o_mail_open_channels' : ''">
- <i t-if="icon" t-attf-class="mr4 fa-fw fa #{icon}" role="img" aria-label="Channel" title="Channel"></i>
- <b><t t-esc="title"/></b>
- </h4>
- <span t-if="!disableAddThread" class="fa fa-plus o_add" title="Add" t-attf-data-type="#{type}" role="img" aria-label="Add"/>
- </div>
- </t>
- </t>
-
- <!--
- @param {string|integer} activeThreadID
- @param {mail.model.Channel[]} channels
- @param {boolean} disableAddThread whether the 'Add thread' action should be disable or not for the considered discuss item category
- @param {boolean} [displayHash] whether the hash ('#') character should be displayed as a prefix for item's name
- @param {boolean} [displayStatus] whether the item should display its status (should be the case only for chats)
- @param {string} inputPlaceholder text placeholder on showing input in a sidebar category
- -->
- <t t-name="mail.discuss.SidebarItems">
- <div t-if="!disableAddThread" class="o_mail_add_thread" t-attf-data-type="#{type}">
- <span t-if="displayHash">#</span>
- <input type="text" class="o_input" t-attf-placeholder="#{inputPlaceholder}"/>
- </div>
- <t t-foreach="channels" t-as="channel">
- <t t-set="counter" t-value="channel.isTwoUserThread() ? channel.getUnreadCounter() : channel.getNeedactionCounter()"/>
- <div t-if="channel.getType() === type" t-att-data-thread-id="channel.getID()" t-att-title="channel.getName()"
- t-attf-class="o_mail_discuss_item #{channel.hasUnreadMessages() ? ' o_unread_message' : ''} #{(activeThreadID == channel.getID()) ? 'o_active': ''}">
- <span class="o_thread_name">
- <span t-if="displayStatus">
- <t t-call="mail.UserStatus">
- <t t-set="status" t-value="channel.getStatus()"/>
- <t t-set="partnerID" t-value="channel.getDirectPartnerID()"/>
- </t>
- </span>
- <span t-if="displayHash" class="o_mail_hash">#</span>
- <i t-if="channel.getType() === 'multi_user_channel' and !channel.isPublic()" class="fa fa-lock" title="Private channel" role="img" aria-label="Private channel"/>
- <t t-esc="_.unescape(channel.getName())"/>
- <i t-if="channel.isMassMailing()" class="fa fa-envelope-o" title="Sends messages by email" role="img" aria-label="Send by mail"/>
- </span>
- <t t-if="channel.hasTypingNotification() and channel.isSomeoneTyping()">
- <t t-set="thread" t-value="channel"/>
- <t t-call="mail.ThreadTypingIcon"/>
- </t>
- <span t-attf-class="fa fa-cog o_mail_channel_settings"
- title="Channel settings" role="img" aria-label="Channel settings" t-att-data-thread-id="channel.getID()"/>
- <t t-call="mail.discuss.SidebarCounter"/>
- <span t-if="!channel.isGroupBasedSubscription()" t-attf-class="fa fa-times o_mail_partner_unpin #{counter ? 'd-none' : ''}"
- title="Leave this channel" role="img" aria-label="Leave this channel" t-att-data-thread-id="channel.getID()"/>
- </div>
- </t>
- </t>
-
- <!--
- @param {string} status
- @param {integer|undefined} [partnerID]
- -->
- <t t-name="mail.UserStatus">
- <span t-att-class="partnerID ? 'o_updatable_im_status' : ''" t-att-data-partner-id="partnerID">
- <i t-if="status == 'online'" class="o_mail_user_status o_user_online fa fa-circle" title="Online" role="img" aria-label="User is online"/>
- <i t-if="status == 'away'" class="o_mail_user_status o_user_idle fa fa-circle" title="Idle" role="img" aria-label="User is idle"/>
- <i t-if="status == 'offline'" class="o_mail_user_status fa fa-circle-o" title="Offline" role="img" aria-label="User is offline"/>
- </span>
- </t>
-
- <!--
- @param {string} documentName
- -->
- <t t-name="mail.discuss.MessageSentSnackbar">
- <div class="alert o_mail_snackbar" data-dismiss="alert" role="alert">
- Message sent in "<t t-esc="documentName"/>".
- </div>
- </t>
-
-
- <!--
- Buttons of the Control Panel
-
- @param {boolean} [disableAddThread] whether the 'Add channel' action should be disabled or not for the considered discuss item category
- -->
- <t t-name="mail.discuss.ControlButtons">
- <div>
- <button type="button" class="btn btn-primary o_mail_discuss_button_invite d-none d-md-inline-block" title="Invite people">Invite</button>
- <button type="button" class="btn btn-secondary o_mail_discuss_button_mark_all_read d-none d-md-inline-block" title="Mark all as read">Mark all read</button>
- <button type="button" class="btn btn-secondary o_mail_discuss_button_unstar_all" title="Unstar all messages">Unstar all</button>
- <button type="button" class="btn btn-secondary o_mail_discuss_button_dm_chat o_hidden d-md-none" title="New Message">New Message</button>
- <button t-if="!disableAddThread" type="button" class="btn btn-secondary o_mail_discuss_button_multi_user_channel d-block d-md-none" title="New Channel">New Channel</button>
- <button type="button" class="btn btn-secondary o_hidden o_mail_discuss_button_select_all" title="Select all messages to moderate">Select All</button>
- <button type="button" class="btn btn-secondary o_hidden o_mail_discuss_button_unselect_all" title="Unselect all messages to moderate">Unselect All</button>
- <button type="button" data-decision="accept" class="btn btn-secondary o_mail_discuss_button_moderate_all" title="Accept selected messages">Accept</button>
- <button type="button" data-decision="reject" class="btn btn-secondary o_mail_discuss_button_moderate_all" title="Reject selected messages">Reject</button>
- <button type="button" data-decision="discard" class="btn btn-secondary o_mail_discuss_button_moderate_all" title="Discard selected messages">Discard</button>
- </div>
- </t>
-
- <!-- Invite dialog's content -->
- <div t-name="mail.PartnerInviteDialog">
- <input type="text" class="o_input o_mail_discuss_partner_invite_input" id="mail_search_partners"/>
- </div>
-
- <!-- Rename channel dialog's content -->
- <div t-name="mail.RenameConversationDialog">
- <input type="text" class="o_input o_mail_discuss_rename_channel_input"/>
- </div>
-
- <div t-name="mail.ModeratorRejectMessageDialog">
- <input class="form-control" type="text" id="message_title" placeholder="Subject" value="Message Rejected"/>
- <textarea class="form-control mt16" id="reject_message" placeholder="Mail Body">Your message was rejected by moderator.</textarea>
- </div>
-
- <!-- Mobile templates -->
- <t t-name="mail.discuss_mobile">
- <div class="o_mail_discuss">
- <div class="o_mail_discuss_mobile_mailboxes_buttons">
- <button type="button" class="btn btn-primary d-inline d-md-none o_mailbox_inbox_item" title="Inbox" data-type="mailbox_inbox">
- Inbox
- </button>
- <button type="button" class="btn btn-secondary d-inline d-md-none o_mailbox_inbox_item" title="Starred" data-type="mailbox_starred">
- Starred
- </button>
- <button type="button" class="btn btn-secondary d-inline d-md-none o_mailbox_inbox_item" title="History" data-type="mailbox_history">
- History
- </button>
- </div>
- <div class="o_mail_discuss_content"/>
- <div class="o_mail_mobile_tabs">
- <div class="o_mail_mobile_tab" data-type="mailbox_inbox">
- <span class="fa fa-inbox"/>
- <span class="o_tab_title">Inbox</span>
- </div>
- <div class="o_mail_mobile_tab" data-type="dm_chat">
- <span class="fa fa-user"/>
- <span class="o_tab_title">Chat</span>
- </div>
- <div class="o_mail_mobile_tab" data-type="multi_user_channel">
- <span class="fa fa-users"/>
- <span class="o_tab_title">Channels</span>
- </div>
- </div>
- </div>
- </t>
-
- <!--
- @param {boolean} [disableAddThread] whether the 'Add thread' action should be disabled or not for the considered discuss item category
- @param {Object[]} previews valid objects for preview rendering (see mail.Preview template)
- -->
- <t t-name="mail.discuss.MobileTabPane">
- <div class="o_mail_discuss_tab_pane" t-att-data-type="type">
- <div t-if="!disableAddThread" class="o_mail_add_thread" t-att-data-type="type">
- <span t-if="type == 'multi_user_channel'">#</span>
- <t t-set="inputPlaceholder" t-if="type == 'multi_user_channel'">Add a channel</t>
- <t t-set="inputPlaceholder" t-if="type == 'dm_chat'">Open chat</t>
- <input type="text" t-attf-placeholder="#{inputPlaceholder}"/>
- </div>
- <t t-foreach="previews" t-as="preview">
- <t t-call="mail.Preview"/>
- </t>
- </div>
- </t>
-
- <!--
- The preview template is used by the discuss in mobile, and by the systray menu
- In order to show preview of threads
-
- FIXME: it is shared with systray, so it should not be in discuss.xml
-
- @param {Object} preview a valid object for preview rendering
- @param {integer} [preview.documentID]
- @param {string} [preview.documentModel]
- @param {string|integer} preview.id
- @param {string} [preview.imageSRC]
- @param {boolean} [preview.isMyselfAuthor]
- @param {moment} [preview.date]
- @param {string} [preview.author]
- @param {string} [preview.body]
- @param {string} [preview.title]
- @param {string} [preview.status]
- @param {integer} [preview.unreadCounter]
- @param {integer[]} [preview.messageIDs]
- -->
- <t t-name="mail.Preview">
- <div t-attf-class="o_mail_preview #{preview.unreadCounter &gt; 0 ? 'o_preview_unread' : ''}"
- t-att-data-preview-id="preview.id" t-att-data-document-id="preview.documentID" t-att-data-document-model="preview.documentModel" t-att-data-unread-counter="preview.unreadCounter" t-att-data-message-ids="preview.messageIDs">
- <div t-attf-class="o_mail_preview_image #{preview.documentModel ? 'o_mail_preview_app' : ''}">
- <img class="o_mail_preview_image" t-att-src="preview.imageSRC" alt="Preview"/>
- <t t-call="mail.UserStatus">
- <t t-set="status" t-value="preview.status"/>
- </t>
- </div>
- <div class="o_preview_info">
- <div class="o_preview_title">
- <span class="o_preview_name">
- <t t-esc="preview.title"/>
- </span>
- <span class="o_preview_counter">
- <t t-if="preview.unreadCounter &gt; 0">&amp;nbsp;(<t t-esc="preview.unreadCounter"/>)</t>
- </span>
- <span t-if="preview.isLinkedToDocumentThread" class="o_discuss_icon o_thread_window_expand fa fa-expand"/>
- <span class="o_last_message_date ml-auto mr-2"> <t t-esc="preview.date ? preview.date.fromNow() : ''"/> </span>
- </div>
- <div class="o_last_message_preview">
- <t t-if="preview.body">
- <t t-if="preview.isMyselfAuthor">
- <span class="fa fa-mail-reply" role="img" aria-label="Reply"/> You:
- </t>
- <t t-elif="preview.author">
- <t t-esc="preview.author"/>:
- </t>
- <t t-esc="preview.body"/>
- </t>
- </div>
- <span title="Mark as Read" class="o_discuss_icon o_mail_preview_mark_as_read fa fa-check" t-if="preview.unreadCounter"/>
- </div>
- </div>
- </t>
-
-</templates>
diff --git a/addons/mail/static/src/xml/followers.xml b/addons/mail/static/src/xml/followers.xml
deleted file mode 100644
index d1f6ebd63a4..00000000000
--- a/addons/mail/static/src/xml/followers.xml
+++ /dev/null
@@ -1,95 +0,0 @@
-<?xml version="1.0" encoding="UTF-8"?>
-<templates>
-
- <!--
- Followers, actions and subtypes in a record.
- -->
- <t t-name="mail.Followers">
- <div class="o_followers btn-group">
- <div class="o_followers_title_box btn-group">
- <button class="btn btn-link" title="Dropdown menu - Followers"
- data-toggle="dropdown" aria-expanded="false">
- <i class="fa fa-user" role="img" aria-label="Followers"></i>
- <span class="o_followers_count"/>
- </button>
- <div class="dropdown-menu dropdown-menu-right o_followers_list" role="menu"/>
- </div>
- <div class="o_followers_actions btn-group">
- <button class="btn btn-link o_followers_follow_button o_followers_notfollow">
- <span class="o_follow">Follow</span>
- <i class="fa fa-times o_followers_unfollow"/>
- <span class="o_followers_unfollow"> Unfollow</span>
- <i class="fa fa-check o_followers_following"/>
- <span class="o_followers_following"> Following</span>
- </button>
- </div>
- </div>
- </t>
-
- <!--
- Partner or channel following the record
- -->
- <t t-name="mail.Followers.partner">
- <div role="menuitem" t-attf-class="dropdown-item o_partner {{ !record.is_active ? 'o_inactive': '' }}">
- <a class="o_mail_redirect text-truncate"
- href="#"
- t-att-title="record.title"
- t-att-data-oe-model="record.partner_id ? 'res.partner' : 'mail.channel'"
- t-att-data-oe-id="record.partner_id ? record.partner_id : record.channel_id">
- <img t-att-src="record.avatar_url" alt="Avatar" class="o_image_64_cover"/>
- <t t-esc="record.name"/>
- </a>
- <button t-if="record.is_editable"
- class="btn btn-icon fa fa-pencil o_edit_subtype d-none"
- title="Edit subscription"
- aria-label="Edit subscription"
- role="img"
- t-att-data-follower-id="record.id"
- t-att-data-oe-id="record.partner_id ? record.partner_id : record.channel_id"
- t-att-data-oe-model="record.partner_id ? 'res.partner' : 'mail.channel'"/>
- <button t-if="widget.isEditable"
- class="btn btn-icon fa fa-remove o_remove_follower"
- aria-label="Remove this follower"
- role="img"
- title="Remove this follower"/>
- </div>
- </t>
-
- <!--
- 'Add followers' and 'Add channels' buttons
- -->
- <t t-name="mail.Followers.add_more">
- <t t-if="widget.isEditable">
- <a href="#" role="menuitem" class="dropdown-item o_add_follower">Add Followers</a>
- <a href="#" role="menuitem" class="dropdown-item o_add_follower_channel">Add Channels</a>
- <div role="separator" t-if="widget.followers.length > 0" class="dropdown-divider"/>
- </t>
- </t>
-
- <!--
- Message subtypes of a follower subscription
- -->
- <t t-name="mail.Followers.subtype">
- <div role="menuitem" class="o_subtype dropdown-item">
- <div class="custom-control custom-checkbox">
- <input type="checkbox"
- t-attf-id="input_mail_followers_subtype_#{record.id + (dialog ? '_in_dialog': '')}"
- t-att-name="record.name"
- class="custom-control-input o_subtype_checkbox"
- t-att-checked="record.followed"
- t-att-data-id="record.id"/>
- <label t-attf-for="input_mail_followers_subtype_#{record.id + (dialog ? '_in_dialog': '')}"
- class="custom-control-label">
- <t t-esc="record.name"/>
- </label>
- </div>
- <i t-if="display_warning" class="fa fa-warning text-warning" role="img" aria-label="Warning" title="Warning"/>
- </div>
- </t>
- <t t-name="mail.Followers.subtypes.warning">
- <span class="text-warning">
- <i class="fa fa-warning"/> Be careful with channels following internal notifications
- </span>
- </t>
-
-</templates>
diff --git a/addons/mail/static/src/xml/out_of_office.xml b/addons/mail/static/src/xml/out_of_office.xml
deleted file mode 100644
index 19fb2c341c6..00000000000
--- a/addons/mail/static/src/xml/out_of_office.xml
+++ /dev/null
@@ -1,9 +0,0 @@
-<?xml version="1.0" encoding="UTF-8"?>
-<templates xml:space="preserve">
- <!--
- @param {mail.model.AbstractThread} thread
- -->
- <div class="o_out_of_office" t-name="mail.thread.OutOfOffice">
- <div class="o_out_of_office_info" t-if="thread.getOutOfOfficeInfo()" t-esc="thread.getOutOfOfficeInfo()"/>
- </div>
-</templates>
diff --git a/addons/mail/static/src/xml/systray.xml b/addons/mail/static/src/xml/systray.xml
index b10ef51a429..b9612e13a27 100644
--- a/addons/mail/static/src/xml/systray.xml
+++ b/addons/mail/static/src/xml/systray.xml
@@ -2,64 +2,6 @@
<templates>
<!--
- @param {mail.systray.MessagingMenu} widget
- -->
- <t t-name="mail.systray.MessagingMenu">
- <li class="o_mail_systray_item">
- <a class="dropdown-toggle o-no-caret" data-toggle="dropdown" data-display="static" aria-expanded="false" title="Conversations" href="#" role="button">
- <i class="o_mail_messaging_menu_icon fa fa-comments fa-spinner fa-spin" role="img" aria-label="Messages"/> <span class="o_notification_counter badge badge-pill"/>
- </a>
- <div class="o_mail_systray_dropdown dropdown-menu dropdown-menu-right" role="menu">
- <t t-if="widget.isMobile()">
- <div class="o_mail_systray_mobile_header">
- <button type="button" class="btn btn-secondary o_new_message"> New message </button>
- </div>
- </t>
- <div t-if="!widget.isMobile()" class="o_mail_systray_dropdown_top">
- <div>
- <button type="button" class="btn btn-link o_filter_button active" role="tab"> All </button>
- <button type="button" class="btn btn-link o_filter_button" data-filter='chat' role="tab"> Chat </button>
- <button type="button" class="btn btn-link o_filter_button" data-filter='channels' role="tab"> Channels </button>
- </div>
- <button type="button" class="btn btn-link o_new_message"> New message </button>
- </div>
- <div class="o_mail_systray_dropdown_items"/>
- <t t-if="widget.isMobile()">
- <div class="o_mail_mobile_tabs">
- <div class="o_mail_mobile_tab o_filter_button active" role="tab">
- <span class="fa fa-envelope"/>
- <span class="o_tab_title">All</span>
- </div>
- <div class="o_mail_mobile_tab o_filter_button" data-filter='chat' role="tab">
- <span class="fa fa-user"/>
- <span class="o_tab_title">Chat</span>
- </div>
- <div class="o_mail_mobile_tab o_filter_button" data-filter='channels' role="tab">
- <span class="fa fa-users"/>
- <span class="o_tab_title">Channels</span>
- </div>
- </div>
- </t>
- </div>
- </li>
- </t>
-
- <!--
- @param {Object} previews valid list of objects for preview rendering
- (see mail.Preview template)
- -->
- <t t-name="mail.systray.MessagingMenu.Previews">
- <t t-if="_.isEmpty(previews)">
- <li class="text-center o_no_activity mt16">
- <span>No conversation yet...</span>
- </li>
- </t>
- <t t-foreach="previews" t-as="preview">
- <t t-call="mail.Preview"/>
- </t>
- </t>
-
- <!--
@param {mail.systray.ActivityMenu} widget
@param {Object[]} widget.activities
-->
diff --git a/addons/mail/static/src/xml/text_emojis.xml b/addons/mail/static/src/xml/text_emojis.xml
index 56a74a54fd3..4f333ba941f 100644
--- a/addons/mail/static/src/xml/text_emojis.xml
+++ b/addons/mail/static/src/xml/text_emojis.xml
@@ -10,7 +10,7 @@
<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.Composer.emojis">
+ <t t-call="mail.legacy.Composer.emojis">
<t t-set="emojis" t-value="widget.emojis"></t>
</t>
</div>
diff --git a/addons/mail/static/src/xml/thread.xml b/addons/mail/static/src/xml/thread.xml
index 05f9f04f6f7..5a3205ed2cb 100644
--- a/addons/mail/static/src/xml/thread.xml
+++ b/addons/mail/static/src/xml/thread.xml
@@ -10,33 +10,6 @@
</t>
</t>
<!--
- @param {mail.model.AbstractThread} thread
- @param {Object} options
- @param {boolean} [options.displayEmptyThread]
- @param {boolean} [options.displayModerationCommands]
- @param {boolean} [options.displayNoMatchFound]
- @param {Array} [options.domain=[]] the domain to restrict messages on the thread.
- -->
- <t t-name="mail.widget.Thread">
- <t t-if="thread.hasMessages({ 'domain': options.domain || [] })">
- <t t-call="mail.widget.Thread.Content"/>
- </t>
- <t t-elif="options.displayEmptyThread">
- <t t-call="mail.widget.Thread.Empty" />
- </t>
- <div t-elif="options.displayNoMatchFound" class="o_mail_no_content">
- <div class="o_thread_title">No matches found</div>
- <div>No message matches your search. Try to change your search filters.</div>
- </div>
- </t>
-
- <!-- Rendering of thread when messaging not yet ready -->
- <div t-name="mail.widget.ThreadLoading" class="o_mail_thread_loading">
- <i class="o_mail_thread_loading_icon fa fa-spinner fa-spin"/>
- <span>Please wait...</span>
- </div>
-
- <!--
@param {mail.DocumentViewer} widget
-->
<t t-name="DocumentViewer.Content">
@@ -126,513 +99,4 @@
</html>
</t>
- <!--
- When a thread is empty
-
- @param {mail.model.Thread} thread
- -->
- <div t-name="mail.widget.Thread.Empty" class="o_mail_no_content">
- <t t-if="thread.getID() === 'mailbox_inbox'">
- <div class="o_thread_title">Congratulations, your inbox is empty</div>
- <div>New messages appear here.</div>
- </t>
- <t t-if="thread.getID() === 'mailbox_starred'">
- <div class="o_thread_title">No starred messages</div>
- <div>You can mark any message as 'starred', and it shows up in this mailbox.</div>
- </t>
- <t t-if="thread.getID() === 'mailbox_history'">
- <div class="o_thread_title o_neutral_face_icon">No history messages</div>
- <div>Messages marked as read will appear in the history.</div>
- </t>
- <t t-if="thread.getID() === 'mailbox_moderation'">
- <div class="o_thread_title">You have no message to moderate</div>
- <div>Pending moderation messages appear here.</div>
- </t>
- </div>
-
- <!--
- @param {mail.model.AbstractThread} thread
- @param {Object} options
- @param {integer} [options.displayOrder] 1 or -1 ascending (respectively, descending) order for
- the thread messages (from top to bottom)
- @param {Array} [options.domain=[]] the domain to restrict messages on the thread.
- @param {Object} ORDER
- @param {integer} ORDER.ASC=1 messages are ordered by ascending order of IDs, (from top to bottom)
- @param {integer} ORDER.DESC=-1 messages are ordered by descending IDs, (from top to bottom)
-
- _____________ _____________
- | | | |
- | message 1 | | message n |
- | message 2 | | ... |
- | ... | | message 2 |
- | message n | | message 1 |
- |_____________| |_____________|
-
- ORDER: ASC DESC
-
- -->
- <t t-name="mail.widget.Thread.Content">
- <t t-set="messages" t-value="thread.getMessages({ 'domain': options.domain || [] })"/>
- <t t-if="options.displayOrder === ORDER.ASC" t-call="mail.widget.Thread.Content.ASC"/>
- <t t-else="" t-call="mail.widget.Thread.Content.DESC"/>
- </t>
-
- <!--
- @param {mail.model.AbstractThread} thread
- @param {Object} options
- @param {boolean} [options.displayBottomThreadFreeSpace=false]
- @param {boolean} [options.displayLoadMore=false]
-
- _____________
- | |
- | message 1 |
- | message 2 |
- | ... |
- | message n |
- |_____________|
-
- ASC Order
- -->
- <t t-name="mail.widget.Thread.Content.ASC">
- <div class="o_mail_thread_content">
- <t t-if="options.displayLoadMore" t-call="mail.widget.Thread.LoadMore"/>
- <t t-call="mail.widget.Thread.Messages"/>
- <t t-if="options.displayBottomThreadFreeSpace">
- <div class="o_thread_bottom_free_space"/>
- </t>
- </div>
- </t>
-
- <!--
- @param {mail.model.AbstractThread} thread
- @param {Object} options
- @param {boolean} [options.displayLoadMore=false]
- @param {string|integer} [options.messagesSeparatorPosition] 'top' or
- message ID, the separator is placed just after this message.
-
- _____________
- | |
- | message n |
- | ... |
- | message 2 |
- | message 1 |
- |_____________|
-
- DESC Order
-
- -->
- <t t-name="mail.widget.Thread.Content.DESC">
- <div class="o_mail_thread_content">
- <t t-if="options.messagesSeparatorPosition == 'top'" t-call="mail.MessagesSeparator"/>
- <t t-set="messages" t-value="messages.slice().reverse()"/>
- <t t-call="mail.widget.Thread.Messages"/>
- <t t-if="options.displayLoadMore" t-call="mail.widget.Thread.LoadMore"/>
- </div>
- </t>
-
- <!--
- @param {mail.model.AbstractMessage[]} messages messages are ordered based
- on desired display order
- -->
- <t t-name="mail.widget.Thread.Messages">
- <t t-set="current_day" t-value="0"/>
- <t t-foreach="messages" t-as="message">
- <div t-if="current_day !== message.getDateDay()" class="o_thread_date_separator">
- <span class="o_thread_date">
- <t t-esc="message.getDateDay()"/>
- </span>
- <t t-set="current_day" t-value="message.getDateDay()"/>
- </div>
-
- <t t-call="mail.widget.Thread.Message"/>
- </t>
- </t>
-
- <!--
- @param {mail.model.AbstractThread} thread
- @param {string} dateFormat
- @param {Object} options
- @param {mail.model.AbstractMessage} message
- @param {Object} options
- @param {boolean} [options.displayAvatars]
- @param {boolean} [options.displayDocumentLinks]
- @param {boolean} [options.displayNotificationIcons]
- @param {boolean} [options.displayMarkAsRead]
- @param {boolean} [options.displayModerationCommands] when set, display the moderation commands on
- the message. This includes the moderation checkboxes (needs a control panel such as in Discuss app).
- @param {boolean} [options.displayReplyIcons]
- @param {boolean} [options.displayStars]
- @param {boolean} [options.displaySubjectsOnMessages]
- @param {boolean} options.hasMessageAttachmentDeletable
- @param {string|integer} [options.messagesSeparatorPosition] 'top' or
- message ID, the separator is placed just after this message.
- @param {integer} [options.selectedMessageID]
- -->
- <t t-name="mail.widget.Thread.Message">
- <div t-if="!message.isEmpty()" t-att-class="'o_thread_message ' + (message.getID() === options.selectedMessageID ? 'o_thread_selected_message ' : ' ') + (message.isDiscussion() or message.isNotification() ? ' o_mail_discussion ' : ' o_mail_not_discussion ')" t-att-data-message-id="message.getID()">
- <div t-if="options.displayAvatars" class="o_thread_message_sidebar">
- <t t-if="message.hasAuthor()">
- <div t-if="displayAuthorMessages[message.getID()]" class="o_thread_message_sidebar_image">
- <img
- alt=""
- t-att-src="message.getAvatarSource()"
- data-oe-model="res.partner"
- t-att-data-oe-id="message.shouldRedirectToAuthor() ? message.getAuthorID() : ''"
- t-attf-class="o_thread_message_avatar rounded-circle #{message.shouldRedirectToAuthor() ? 'o_mail_redirect' : ''}"/>
- <t t-call="mail.UserStatus">
- <t t-set="status" t-value="message.getAuthorImStatus()"/>
- <t t-set="partnerID" t-value="message.getAuthorID()"/>
- </t>
- </div>
- </t>
- <t t-else="">
- <img t-if="displayAuthorMessages[message.getID()]"
- alt=""
- t-att-src="message.getAvatarSource()"
- class="o_thread_message_avatar rounded-circle"/>
- </t>
- <span t-if="!displayAuthorMessages[message.getID()]" t-att-title="message.getDate().format(dateFormat)" class="o_thread_message_side_date">
- <t t-esc="message.getDate().format('hh:mm')"/>
- </span>
- <i t-if="!displayAuthorMessages[message.getID()] and options.displayStars and message.getType() !== 'notification'"
- t-att-class="'fa o_thread_message_star o_thread_icon ' + (message.isStarred() ? 'fa-star' : 'fa-star-o')"
- t-att-data-message-id="message.getID()" title="Mark as Todo" role="img" aria-label="Mark as todo"/>
- <t t-if="!displayAuthorMessages[message.getID()] and thread.hasSeenFeature()" t-call="mail.widget.Thread.Message.SeenIcon"/>
- </div>
- <div class="o_thread_message_core">
- <p t-if="displayAuthorMessages[message.getID()]" class="o_mail_info text-muted">
- <input t-if="message.needsModeration() and message.needsModerationByUser() and options.displayModerationCommands" type="checkbox" class="moderation_checkbox" t-att-data-message-id="message.getID()"/>
- <t t-if="message.isNote()">
- Note by
- </t>
-
- <strong t-if="message.hasAuthor()"
- data-oe-model="res.partner" t-att-data-oe-id="message.shouldRedirectToAuthor() ? message.getAuthorID() : ''"
- t-attf-class="o_thread_author #{message.shouldRedirectToAuthor() ? 'o_mail_redirect' : ''}">
- <t t-esc="message.getDisplayedAuthor()"/>
- </strong>
- <strong t-elif="message.hasEmailFrom()">
- <a class="text-muted" t-attf-href="mailto:#{message.getEmailFrom()}?subject=Re: #{message.hasSubject() ? message.getSubject() : ''}">
- <t t-esc="message.getEmailFrom()"/>
- </a>
- </strong>
- <strong t-else="" class="o_thread_author">
- <t t-esc="message.getDisplayedAuthor()"/>
- </strong>
-
- - <small class="o_mail_timestamp" t-att-title="message.getDate().format(dateFormat)"><t t-esc="message.getTimeElapsed()"/></small>
- <t t-if="message.isLinkedToDocumentThread() and options.displayDocumentLinks">
- <small>on</small> <a t-att-href="message.getURL()" t-att-data-oe-model="message.getDocumentModel()" t-att-data-oe-id="message.getDocumentID()" class="o_document_link"><t t-esc="message.getDocumentName()"/></a>
- </t>
- <t t-if="message.originatesFromChannel() and (message.getOriginChannelID() !== thread.getID())">
- (<small>from</small> <a t-att-data-oe-id="message.getOriginChannelID()" href="#">#<t t-esc="message.getOriginChannelName()"/></a>)
- </t>
- <span t-if="options.displayNotificationIcons and message.hasNotifications()" class="o_thread_tooltip_container">
- <span name="notification_icon" t-attf-class="d-inline-flex align-items-center o_thread_tooltip o_thread_message_notification {{ message.hasNotificationsError() ? 'o_thread_message_notification_error' : '' }}" t-att-data-message-id="message.getID()" t-att-data-message-type="message.getType()">
- <i t-att-class="message.getNotificationIcon()"/>
- <small t-if="message.getNotificationText()" t-esc="message.getNotificationText()" class="font-weight-bold ml-1"/>
- </span>
- </span>
- <span t-attf-class="o_thread_icons">
- <t t-if="thread.hasSeenFeature()" t-call="mail.widget.Thread.Message.SeenIcon"/>
- <i t-if="options.displayStars and !message.isSystemNotification() and !message.needsModeration()"
- t-att-class="'fa fa-lg o_thread_icon o_thread_message_star ' + (message.isStarred() ? 'fa-star' : 'fa-star-o')"
- t-att-data-message-id="message.getID()" title="Mark as Todo" role="img" aria-label="Mark as Todo"/>
- <i t-if="message.isLinkedToDocumentThread() and options.displayReplyIcons"
- class="fa fa-reply o_thread_icon o_thread_message_reply"
- t-att-data-message-id="message.getID()" title="Reply" role="img" aria-label="Reply"/>
- <i t-if="message.isNeedaction() and options.displayMarkAsRead"
- class="fa fa-check o_thread_icon o_thread_message_needaction"
- t-att-data-message-id="message.getID()" title="Mark as Read" role="img" aria-label="Mark as Read"/>
-
- <t t-if="message.needsModeration() and message.originatesFromChannel()">
- <t t-call="mail.MessageModeration"/>
- </t>
- </span>
- </p>
- <div class="o_thread_message_content">
- <t t-raw="message.getBody()"/>
- <t t-if="message.hasTrackingValues()">
- <t t-if="message.hasSubtypeDescription()">
- <p><t t-esc="message.getSubtypeDescription()"/></p>
- </t>
- <t t-call="mail.widget.Thread.MessageTracking"/>
- </t>
- <p t-if="options.displaySubjectOnMessages and message.shouldDisplaySubject()" class="o_mail_subject">Subject: <t t-esc="message.getSubject()"/></p>
- <t t-if="message.hasAttachments()">
- <div t-if="message.hasImageAttachments()" class="o_attachments_previews">
- <t t-foreach="message.getImageAttachments()" t-as="attachment">
- <t t-call="mail.AttachmentPreview">
- <t t-set="isDeletable" t-value="options.hasMessageAttachmentDeletable"/>
- </t>
- </t>
- </div>
- <div t-if="message.hasNonImageAttachments()" class="o_attachments_list">
- <t t-foreach="message.getNonImageAttachments()" t-as="attachment">
- <t t-call="mail.Attachment">
- <t t-set="isDeletable" t-value="options.hasMessageAttachmentDeletable"/>
- </t>
- </t>
- </div>
- </t>
- </div>
- </div>
- </div>
- <t t-if="options.messagesSeparatorPosition == message.getID()">
- <t t-call="mail.MessagesSeparator"/>
- </t>
- </t>
-
- <!--
- Display the seen icon of a message in the thread.
- It shows the fetch 'check' before the seen 'check'. We change the order in the template
- so that fetch 'check' is on top of 'seen' check (z-index does not work with absolute positioning)
-
- @param {mail.model.Thread} thread
- @param {mail.model.Message} message
- -->
- <t t-name="mail.widget.Thread.Message.SeenIcon">
- <t t-if="message.isMyselfAuthor() and message.getID() >= thread.getLastMessageIDSeenByEveryone()">
- <span t-attf-class="o_mail_thread_message_seen_icon #{thread.hasEveryoneSeen(message) ? 'o_all_seen' : ''}" t-att-data-message-id="message.getID()">
- <t t-if="thread.hasSomeoneFetched(message)">
- <i class="fa fa-check"/>
- </t>
- <t t-if="thread.hasSomeoneSeen(message)">
- <i class="fa fa-check"/>
- </t>
- </span>
- </t>
- </t>
-
- <!--
- Display the popover content when clicking the seen icion of a message in the thread.
-
- List the members that have received the messages, and the members that have seen it.
-
- @param {mail.model.Thread} thread
- @param {mail.model.Message} message
- -->
- <t t-name="mail.widget.Thread.Message.SeenIconPopoverContent">
- <div class="o_mail_thread_message_seen_icon_content">
- <t t-if="thread.hasEveryoneSeen(message)">
- <p>Seen by Everyone</p>
- </t>
- <t t-else="">
- <t t-if="thread.hasSomeoneSeen(message)">
- <t t-set="seen_members" t-value="thread.getSeenMembers(message)"/>
- Seen by:
- <ul>
- <li t-foreach="seen_members" t-as="member">
- <t t-esc="member.name"/> (<t t-esc="member.email"/>)
- </li>
- </ul>
- </t>
- <t t-if="thread.hasSomeoneFetched(message)">
- <t t-if="thread.hasEveryoneFetched(message)">
- <p>Received by Everyone</p>
- </t>
- <t t-else="">
- <t t-set="fetched_members" t-value="thread.getFetchedNotSeenMembers(message)"/>
- <t t-if="!_.isEmpty(fetched_members)">
- Received by:
- <ul>
- <li t-foreach="fetched_members" t-as="member">
- <t t-esc="member.name"/> (<t t-esc="member.email"/>)
- </li>
- </ul>
- </t>
- </t>
- </t>
- </t>
- </div>
- </t>
-
- <!--
- @param {Object[]} notifications: list of notifications
- A notification is an object with at least the following keys:
- {notification_status, partner_name}
- -->
- <t t-name="mail.widget.Thread.Message.MailTooltip">
- <div t-foreach="notifications" t-as="notification">
- <span name="notification_status" class="d-inline-block text-center o_thread_tooltip_icon">
- <i t-if="notification.notification_status === 'sent'" class='fa fa-check' title="Sent" role="img" aria-label="Sent"/>
- <i t-if="notification.notification_status === 'bounce'" class='fa fa-exclamation text-danger' title="Bounced" role="img" aria-label="Bounced"/>
- <i t-if="notification.notification_status === 'exception'" class='fa fa-exclamation text-danger' title="Error" role="img" aria-label="Error"/>
- <i t-if="notification.notification_status === 'ready'" class='fa fa-send-o' title="Ready" role="img" aria-label="Ready"/>
- <i t-if="notification.notification_status === 'canceled'" class='fa fa-trash-o' title="Canceled" role="img" aria-label="Canceled"/>
- </span>
- <span name="partner_name" t-esc="notification.partner_name"/>
- </div>
- </t>
- <!--
- @param {mail.model.Message} message
- @param {Object} options
- @param {boolean} [options.displayModerationCommands]
- -->
- <t t-name="mail.MessageModeration">
- <i t-if="message.isMyselfAuthor()" class="text-danger" title="Your message is pending moderation"><b>Pending moderation</b></i>
- <t t-elif="message.needsModerationByUser()">
- <t t-if="options.displayModerationCommands" t-call="mail.MessageModerationDecision"/>
- <i t-else="" class="text-danger" title="Open Channel in Discuss to moderate"><b>Pending moderation</b></i>
- </t>
- </t>
-
- <!--
- @param {mail.model.Message} message
- -->
- <t t-name="mail.MessageModerationDecision">
- <i class="o_thread_icon ml4 o_thread_message_moderation text-success"
- t-att-data-message-id="message.getID()" data-decision="accept" title="Accept"><b>Accept |</b></i>
- <i class="o_thread_icon ml4 o_thread_message_moderation text-danger"
- t-att-data-message-id="message.getID()" data-decision="reject" title="Remove message with explanation"><b>Reject |</b></i>
- <i class="o_thread_icon ml4 o_thread_message_moderation text-danger"
- t-att-data-message-id="message.getID()" data-decision="discard" title="Remove message without explanation"><b>Discard |</b></i>
- <i class="o_thread_icon ml4 o_thread_message_moderation text-success"
- t-att-data-message-id="message.getID()" data-decision="allow" title="Add this email address to white list of people"><b>Always Allow |</b></i>
- <i class="o_thread_icon ml4 o_thread_message_moderation text-danger"
- t-att-data-message-id="message.getID()" data-decision="ban" title="Ban this email address"><b>Ban</b></i>
- </t>
-
- <t t-name="mail.MessagesSeparator">
- <div class="o_thread_new_messages_separator">
- <span class="o_thread_separator_label">New messages</span>
- </div>
- </t>
-
- <!--
- @param {mail.model.Message} message
- -->
- <t t-name="mail.widget.Thread.MessageTracking">
- <ul class="o_mail_thread_message_tracking">
- <t t-foreach='message.getTrackingValues()' t-as='value'>
- <li>
- <t t-esc="value.changed_field"/>:
- <t t-if="value.old_value">
- <span> <t t-esc="value.old_value || ((value.field_type !== 'boolean') and '')"/> </span>
- <span t-if="value.old_value !== value.new_value" class="fa fa-long-arrow-right" role="img" aria-label="Changed" title="Changed"/>
- </t>
- <span t-if="value.old_value !== value.new_value">
- <t t-esc="value.new_value || ((value.field_type !== 'boolean') and '')"/>
- </span>
- </li>
- </t>
- </ul>
- </t>
-
- <!--
- @param {Array} attachments
- -->
- <t t-name="mail.composer.Attachments">
- <div t-if="attachments.length > 0" class="o_attachments o_attachments_list">
- <t t-foreach="attachments" t-as='attachment'>
- <t t-call="mail.Attachment">
- <t t-set="editable" t-value="true"/>
- </t>
- </t>
- </div>
- </t>
-
- <!--
- @param {Object} attachment
- @param {integer} attachment.id
- @param {string} attachment.name
- @param {string} attachment.url
- @param {boolean} [isDeletable=false]
- -->
- <t t-name="mail.AttachmentPreview">
- <div class="o_attachment" t-att-title="attachment.name">
- <div class="o_attachment_wrap">
- <div class="o_image_box">
- <div class="o_attachment_image" t-attf-style="background-image:url('/web/image/#{attachment.id}/160x160/?crop=true')"/>
- <div t-attf-class="o_image_overlay o_attachment_view" t-att-data-id="attachment.id">
- <span t-if="isDeletable" class="fa fa-times o_attachment_delete_cross" t-att-title="'Delete ' + attachment.name" t-att-data-id="attachment.id" t-att-data-name="attachment.name"/>
- <span class="o_attachment_title text-white"><t t-esc="attachment.name"/></span>
- <a class="o_attachment_download" t-att-href='attachment.url'>
- <i t-attf-class="fa fa-download text-white" t-att-title="'Download ' + attachment.name" role="img" aria-label="Download"></i>
- </a>
- </div>
- </div>
- </div>
- </div>
- </t>
-
- <!--
- @param {Object} attachment
- @param {string} attachment.filename
- @param {integer} attachment.id
- @param {string} [attachment.mimetype]
- @param {string} attachment.name
- @param {boolean} attachment.upload
- @param {string} attachment.url
- @param {boolean} [editable=false] if set, it means the attachment is rendered in the composer.
- Some changes are required in that case, such as "delete" button is not visible (pretty unlink is used instead).
- @param {boolean} [isDeletable=false]
- -->
- <t t-name="mail.Attachment">
- <t t-set="type" t-value="attachment.mimetype and attachment.mimetype.split('/').shift()"/>
- <div t-attf-class="o_attachment #{ editable ? 'o_attachment_editable' : '' } #{attachment.upload ? 'o_attachment_uploading' : ''}" t-att-title="attachment.name">
- <div class="o_attachment_wrap">
- <span t-if="!editable and isDeletable" class="fa fa-times o_attachment_delete_cross" t-att-title="'Delete ' + attachment.name" t-att-data-id="attachment.id" t-att-data-name="attachment.name"/>
- <t t-set="has_preview" t-value="type == 'image' or type == 'video' or attachment.mimetype == 'application/pdf'"/>
- <t t-set="ext" t-value="attachment.filename.split('.').pop()"/>
-
- <div t-attf-class="o_image_box float-left #{has_preview ? 'o_attachment_view' : ''}" t-att-data-id="attachment.id">
- <div t-if="has_preview"
- class="o_image o_hover"
- t-att-style="type == 'image' ? 'background-image:url(/web/image/' + attachment.id + '/38x38/?crop=true' : '' "
- t-att-data-mimetype="attachment.mimetype">
- </div>
- <a t-elif="!editable" t-att-href='attachment.url' t-att-title="'Download ' + attachment.name" aria-label="Download">
- <span class="o_image o_hover" t-att-data-mimetype="attachment.mimetype" t-att-data-ext="ext"/>
- </a>
- <span t-else="" class="o_image" t-att-data-mimetype="attachment.mimetype" t-att-data-ext="ext" role="img" aria-label="Document not downloadable"/>
- </div>
-
- <div class="caption">
- <span t-if="has_preview or editable" t-attf-class="ml4 #{has_preview? 'o_attachment_view' : ''}" t-att-data-id="attachment.id"><t t-esc='attachment.name'/></span>
- <a t-else="" class="ml4" t-att-href="attachment.url" t-att-title="'Download ' + attachment.name"><t t-esc='attachment.name'/></a>
- </div>
- <div t-if="editable" class="caption small">
- <b t-attf-class="ml4 small text-uppercase #{has_preview? 'o_attachment_view' : ''}" t-att-data-id="attachment.id"><t t-esc="ext"/></b>
- <div class="progress o_attachment_progress_bar">
- <div class="progress-bar progress-bar-striped active" style="width: 100%">Uploading</div>
- </div>
- </div>
- <div t-if="!editable" class="caption small">
- <b t-if="has_preview" class="ml4 small text-uppercase o_attachment_view" t-att-data-id="attachment.id"><t t-esc="ext"/></b>
- <a t-else="" class="ml4 small text-uppercase" t-att-href="attachment.url" t-att-title="'Download ' + attachment.name"><b><t t-esc='ext'/></b></a>
- <a class="ml4 o_attachment_download float-right" t-att-title="'Download ' + attachment.name" t-att-href='attachment.url'><i t-attf-class="fa fa-download" role="img" aria-label="Download"/></a>
- </div>
- <div t-if="editable" class="o_attachment_uploaded"><i class="text-success fa fa-check" role="img" aria-label="Uploaded" title="Uploaded"/></div>
- <div t-if="editable" class="o_attachment_delete" t-att-data-id="attachment.id"><span class="text-white" role="img" aria-label="Delete" title="Delete">×</span></div>
- </div>
- </div>
- </t>
-
- <!--
- @param {Object} options
- @param {boolean} [options.loadMoreOnScroll]
- -->
- <t t-name="mail.widget.Thread.LoadMore">
- <div class="o_thread_show_more">
- <t t-if="options.loadMoreOnScroll">
- <span><i class="fa fa-spinner fa-spin" role="img" aria-label="Please wait" title="Please wait"/> Loading older messages... </span>
- </t>
- <t t-else="">
- <button class="btn btn-link">-------- Show older messages --------</button>
- </t>
- </div>
- </t>
-
- <!--
- @param {mail.model.Thread} thread with typing feature
- -->
- <t t-name="mail.ThreadTypingIcon">
- <span class="o_mail_thread_typing_icon" t-att-title="thread.getTypingMembersToText()">
- <span class="o_mail_thread_typing_icon_dot"/>
- <span class="o_mail_thread_typing_icon_dot"/>
- <span class="o_mail_thread_typing_icon_dot"/>
- </span>
- </t>
-
</templates>
diff --git a/addons/mail/static/src/xml/thread_window.xml b/addons/mail/static/src/xml/thread_window.xml
deleted file mode 100644
index 1e8d3e97c2e..00000000000
--- a/addons/mail/static/src/xml/thread_window.xml
+++ /dev/null
@@ -1,32 +0,0 @@
-<?xml version="1.0" encoding="UTF-8"?>
-<templates xml:space="preserve">
-
- <!--
- @param {mail.ThreadWindow} widget
- -->
- <t t-name="mail.ThreadWindow" t-extend="mail.AbstractThreadWindow">
- <t t-jquery=".o_thread_window_header" t-operation="after">
- <t t-if="widget.hasThread()">
- <t t-if="widget.getThread().hasOutOfOffice()">
- <t t-call="mail.thread.OutOfOffice">
- <t t-set="thread" t-value="widget.getThread()"/>
- </t>
- </t>
- </t>
- <div t-else="" class="o_thread_search_input">
- <span> To: </span>
- <input type="text" placeholder="User name"/>
- </div>
- </t>
- </t>
-
- <!--
- @param {string} expandTitle text to display on mouse hover (title attribute)
- -->
- <t t-extend="mail.AbstractThreadWindow.HeaderContent">
- <t t-jquery=".o_thread_window_buttons" t-operation="prepend">
- <a href="#" class="o_thread_window_expand fa fa-expand" t-att-title="expandTitle" t-att-aria-label="expandTitle"/>
- </t>
- </t>
-
-</templates>